Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/icebergsca/core/triage.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

import tomllib
from dataclasses import dataclass, replace
from datetime import date, timedelta
from datetime import date, datetime, timedelta
from typing import Any

from icebergsca.core.errors import ConfigError
Expand Down Expand Up @@ -204,6 +204,11 @@ def _expires(path: str, where: str, value: Any, today: date) -> date:
"""
if value is None:
return today + timedelta(days=DEFAULT_EXPIRY_DAYS)
if isinstance(value, datetime):
# TOML also has datetime types, and ``datetime`` *is a* ``date`` — but one
# that ``today > expires`` cannot compare against, which would turn a legal
# ``expires = 2026-10-27 00:00:00`` into a crash at matching time.
return value.date()
if isinstance(value, date):
return value
if isinstance(value, str):
Expand Down
44 changes: 41 additions & 3 deletions src/icebergsca/core/versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@
#: an inclusive upper bound of ``2.0`` would exclude ``2.0.0``.
_RELEASE_WIDTH = 4

#: PEP 440 pre-release letters, already normalised by ``packaging`` (``alpha`` → ``a``).
_PRE_RANK = {"a": 0, "b": 1, "rc": 2}
#: Rank for a release with no pre-release segment — above every pre-release.
_FINAL_RANK = 3
#: Rank for a bare dev release (``1.0.dev1``), which PEP 440 sorts below any alpha.
_DEV_RANK = -1

#: The first element carries the epoch (always 0 outside PyPI) followed by the padded
#: release; the second is 0 for a pre-release and 1 otherwise; the third orders the
#: pre/post/dev suffix.
VersionKey = tuple[tuple[int, ...], int, tuple[tuple[int, str], ...]]


Expand All @@ -54,14 +64,40 @@ def parse(ecosystem: EcosystemId, version: str) -> VersionKey | None:
except InvalidVersion:
return _loose(version)
return (
_pad(parsed.release),
(parsed.epoch, *_pad(parsed.release)),
0 if parsed.is_prerelease else 1,
((0, str(parsed.pre or parsed.dev or "")),),
_pep440_suffix(parsed),
)

return _loose(version)


def _pep440_suffix(parsed: Version) -> tuple[tuple[int, str], ...]:
"""Order the pre/post/dev segments the way PEP 440 does.

dev < a < b < rc < final < post, with the numeric parts compared as numbers — a
stringified ``parsed.pre`` would place ``rc10`` below ``rc2``. Elements are
``(int, str)`` pairs so the shape stays comparable with what :func:`_loose`
yields when an unparseable version in the same package fell back to it.
"""
if parsed.pre is not None:
letter, number = parsed.pre
pre_rank, pre_number = _PRE_RANK.get(letter, _FINAL_RANK - 1), number
elif parsed.dev is not None and parsed.post is None:
pre_rank, pre_number = _DEV_RANK, 0
else:
pre_rank, pre_number = _FINAL_RANK, 0
return (
(pre_rank, ""),
(pre_number, ""),
# An absent post-release sorts below ``.post0``, so ``1.0 < 1.0.post0``.
(-1 if parsed.post is None else parsed.post, ""),
# A dev marker sorts below its own release: ``1.0a1.dev1 < 1.0a1``.
(1 if parsed.dev is None else 0, ""),
(parsed.dev or 0, ""),
)


def _loose(version: str) -> VersionKey | None:
"""Generic dotted ordering with pre-release handling.

Expand All @@ -85,7 +121,9 @@ def _loose(version: str) -> VersionKey | None:
)
if not release:
return None
release = _pad(release)
# The leading 0 is the epoch slot, so a loose key stays comparable with a PEP 440
# one when both appear for the same PyPI package.
release = (0, *_pad(release))

prerelease: tuple[tuple[int, str], ...] = tuple(
(int(segment), "") if segment.isdigit() else (_ALPHA_RANK, segment.lower())
Expand Down
4 changes: 3 additions & 1 deletion src/icebergsca/ecosystems/dotnet.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
from icebergsca.ecosystems.base import EcosystemSpec, FileSpec, build_dependencies

#: NuGet version ranges use interval notation: ``[1.0,2.0)``, ``[1.0]``, ``(1.0,)``.
#: A bare version means "this or newer", so only bracketed equality is a true pin.
#: A bare version formally means "this or newer", but NuGet restores the *lowest*
#: version that satisfies a range, which is the declared version itself whenever it
#: exists — so a bare version is treated as a pin, and bracketed forms as ranges.
_RANGE_CHARS = "[]()"

#: ``PackageReference`` items whose asset flags exclude them from the compiled
Expand Down
24 changes: 20 additions & 4 deletions src/icebergsca/ecosystems/maven/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,8 +473,16 @@ def _children(

# -- effective POMs ----------------------------------------------------

async def _effective(self, coordinate: Coordinate) -> EffectivePom | None:
"""Merge a POM with its parent chain and any BOMs it imports."""
async def _effective(
self, coordinate: Coordinate, visiting: frozenset[str] = frozenset()
) -> EffectivePom | None:
"""Merge a POM with its parent chain and any BOMs it imports.

``visiting`` carries the BOM-import path that led here, so a cycle of
``import``-scoped BOMs is cut rather than recursed into. Nothing on Central
should contain one, but "should" is not a stack-depth guarantee, and the
backfill path has no ``gather`` around it to absorb a RecursionError.
"""
pom = await self._fetch_pom(coordinate)
if pom is None:
return None
Expand Down Expand Up @@ -506,7 +514,7 @@ async def _effective(self, coordinate: Coordinate) -> EffectivePom | None:

for entry in reversed(chain):
await self._apply_management(
entry, properties, managed, managed_scopes, managed_exclusions
entry, properties, managed, managed_scopes, managed_exclusions, visiting
)

return EffectivePom(
Expand All @@ -525,6 +533,7 @@ async def _apply_management(
managed: dict[str, str],
managed_scopes: dict[str, str],
managed_exclusions: dict[str, frozenset[str]],
visiting: frozenset[str] = frozenset(),
) -> None:
"""Fold one POM's dependencyManagement in, expanding imported BOMs first.

Expand All @@ -537,7 +546,14 @@ async def _apply_management(
version = interpolate(entry.version, properties)

if entry.is_import and version and not has_unresolved_property(version):
bom = await self._effective(Coordinate(group, artifact, version))
coordinate = Coordinate(group, artifact, version)
if str(coordinate) in visiting:
# A BOM that imports itself, however indirectly. Everything it
# manages was already folded in on the way down; recursing again
# would never terminate.
logger.debug("BOM import cycle at %s; not descending", coordinate)
continue
bom = await self._effective(coordinate, visiting | {str(coordinate)})
if bom is not None:
properties.update(
{k: v for k, v in bom.properties.items() if k not in properties}
Expand Down
17 changes: 11 additions & 6 deletions src/icebergsca/ecosystems/npm.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,13 @@ def _find_line(content: str, name: str) -> int | None:

def _entry_scope(entry: dict[str, Any]) -> Scope | None:
"""Read npm's precomputed scope flags, or ``None`` if it recorded none."""
if entry.get("dev") or entry.get("devOptional"):
if entry.get("dev"):
return Scope.DEV
if entry.get("optional"):
if entry.get("optional") or entry.get("devOptional"):
# ``devOptional`` marks a package that is in the dev tree *and* an optional
# dependency of something that ships, so a production install still gets it.
# Optional is scanned by default and dev is not — calling it dev would hide
# a shipping package, which is the one direction this tool must not fail in.
return Scope.OPTIONAL
return None

Expand Down Expand Up @@ -500,7 +504,10 @@ def _link_yarn_children(


_YARN_HEADER = re.compile(r"^(?P<specs>\S.*?):\s*$")
_YARN_FIELD = re.compile(r'^\s+(?P<key>[\w-]+)\s+"?(?P<value>[^"]*)"?\s*$')
#: A field or dependency line. The key may be quoted — yarn writes scoped packages
#: as ``"@babel/core" "^7.0.0"`` — and a key pattern that cannot match the quotes
#: silently drops every ``@scope/`` edge in the file.
_YARN_FIELD = re.compile(r'^\s+"?(?P<key>[^\s"]+)"?\s+"?(?P<value>[^"]*)"?\s*$')


def _parse_yarn_v1(path: Path, content: str) -> list[Dependency]:
Expand Down Expand Up @@ -544,9 +551,7 @@ def _parse_yarn_v1(path: Path, content: str) -> list[Dependency]:
if field is None:
continue
if section in ("dependencies", "optionalDependencies"):
current["dependencies"][field.group("key").strip('"')] = field.group(
"value"
)
current["dependencies"][field.group("key")] = field.group("value")
elif field.group("key") == "version":
current["version"] = field.group("value")
section = None
Expand Down
50 changes: 38 additions & 12 deletions src/icebergsca/ecosystems/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from typing import Any

from packaging.requirements import InvalidRequirement, Requirement
from packaging.specifiers import SpecifierSet
from packaging.specifiers import InvalidSpecifier, SpecifierSet

from icebergsca.core.errors import ParseError
from icebergsca.core.graph import Node, most_included, resolve
Expand Down Expand Up @@ -82,6 +82,22 @@ def _pin_from_specifier(spec: SpecifierSet) -> tuple[str | None, Pin]:
return None, Pin.UNRESOLVED


def _pin_from_constraint(constraint: str | None) -> tuple[str | None, Pin]:
"""The same pin rules, for a constraint that is still a raw string.

Slicing ``==`` off the front by hand would read ``==1.*`` as the version ``1.*``
— a junk version that OSV matches nothing against — and ``===1.2.3`` as
``=1.2.3``. Going through the specifier parser keeps one definition of "pinned".
"""
if not constraint:
return None, Pin.UNRESOLVED
try:
spec = SpecifierSet(constraint)
except InvalidSpecifier:
return None, Pin.UNRESOLVED
return _pin_from_specifier(spec)


def _find_line(content: str, needle: str) -> int | None:
"""Best-effort line number for a name inside a structured file.

Expand Down Expand Up @@ -395,16 +411,14 @@ def _parse_pipfile(path: Path, content: str) -> list[Dependency]:
# Pipfile writes "*" for "any version", which is a constraint carrying
# no information rather than a version.
constraint = None if constraint in ("*", "") else constraint
version = (
constraint[2:] if constraint and constraint.startswith("==") else None
)
version, pin = _pin_from_constraint(constraint)
dependencies.append(
Dependency(
ref=PackageRef(EcosystemId.PYPI, _normalise(name), version),
scope=scope,
direct=True,
source=SourceLocation(path, _find_line(content, name)),
pin=Pin.PINNED if version else Pin.UNRESOLVED,
pin=pin,
constraint=constraint,
)
)
Expand Down Expand Up @@ -507,14 +521,20 @@ def _parse_uv_lock(path: Path, content: str) -> list[Dependency]:


def _collect_uv_seeds(root: dict[str, Any], seeds: dict[str, Scope]) -> None:
"""Record what the project asked for directly, and under which scope."""
"""Record what the project asked for directly, and under which scope.

A package declared in several groups is merged to the most-included scope, not
to whichever group happened to be read first: an extra listed under both
``docs`` and ``gui`` installs whenever either is asked for, and first-wins
would let the dev-flavoured group hide it from the default scan.
"""
for name in _uv_names(root.get("dependencies")):
seeds[name] = Scope.RUNTIME
_seed(seeds, name, Scope.RUNTIME)

for group, entries in (root.get("optional-dependencies") or {}).items():
scope = _scope_for_group(group)
for name in _uv_names(entries):
seeds.setdefault(name, scope)
_seed(seeds, name, scope)

# PEP 735 groups live under metadata and are development tooling by definition.
metadata = root.get("metadata")
Expand All @@ -523,7 +543,12 @@ def _collect_uv_seeds(root: dict[str, Any], seeds: dict[str, Scope]) -> None:
for group, entries in requires_dev.items():
scope = _scope_for_group(group) if group else Scope.DEV
for name in _uv_names(entries):
seeds.setdefault(name, scope)
_seed(seeds, name, scope)


def _seed(seeds: dict[str, Scope], name: str, scope: Scope) -> None:
current = seeds.get(name)
seeds[name] = scope if current is None else most_included((scope, current))


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -613,7 +638,8 @@ def _parse_pipfile_lock(path: Path, content: str) -> list[Dependency]:
continue
for name, entry in table.items():
raw = entry.get("version") if isinstance(entry, dict) else None
version = raw[2:] if isinstance(raw, str) and raw.startswith("==") else None
constraint = raw if isinstance(raw, str) else None
version, pin = _pin_from_constraint(constraint)
dependencies.append(
Dependency(
ref=PackageRef(EcosystemId.PYPI, _normalise(name), version),
Expand All @@ -623,8 +649,8 @@ def _parse_pipfile_lock(path: Path, content: str) -> list[Dependency]:
# merges that in; assuming direct here would be a guess.
direct=False,
source=source,
pin=Pin.PINNED if version else Pin.UNRESOLVED,
constraint=raw if isinstance(raw, str) else None,
pin=pin,
constraint=constraint,
)
)

Expand Down
15 changes: 14 additions & 1 deletion src/icebergsca/osv/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,11 @@ async def scan(self, refs: Sequence[PackageRef]) -> OSVResult:
advisory=_advisory(record),
fixed_version=severity_module.fixed_version(record, ref),
)
for entry in entries
# querybatch already said this advisory affects the package; a failed
# detail fetch costs the severity and fix version, never the finding.
if (record := details.get(_detail_key(entry))) is not None
else VulnHit(advisory=_skeleton(entry))
for entry in entries
]
if hits:
merged = merge_aliases(hits)
Expand Down Expand Up @@ -463,6 +466,16 @@ def _detail_key(entry: dict[str, str]) -> str:
return f"{entry['id']}@{entry.get('modified', '')}"


def _skeleton(entry: dict[str, str]) -> Advisory:
"""An advisory built from querybatch data alone, for when detail never arrived.

Carries only the ID and modified timestamp, so it renders with an unknown
severity and no fix version — under-informed, but reported. Dropping the hit
instead would present a package OSV said is affected as if it were clean.
"""
return Advisory(id=entry["id"], modified=entry.get("modified", ""))


def _advisory(record: dict[str, Any]) -> Advisory:
references = tuple(
reference["url"]
Expand Down
7 changes: 7 additions & 0 deletions tests/fixtures/npm/yarn-v1/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,10 @@ ms@^2.1.3:
version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#mno"
integrity sha512-eeee

app-ui@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/app-ui/-/app-ui-1.0.0.tgz#pqr"
integrity sha512-ffff
dependencies:
"@scope/widget" "^2.0.0"
Loading