diff --git a/CHANGELOG.md b/CHANGELOG.md index 75d4cf1..eaf8ca9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Advisories distinguish the `affected_symbols` a fix changed from the public `entry_points` through which they are reached, each justified and carrying `conditions` (checkable argument values, or `semantic` ones kept as pending review); `modules` names what the package installs. Every dependency finding records its evidence `level`: `declared`, `imported`, `reachable` or `exploitable`. - A call to an API affected by several advisories of the pinned release reports every one of them, not the first listed. - One advisory per identifier and package: a later contributor replaces an earlier one, so a curated feed refines the bundled sample, and the sample names the modules its packages install. +- A version specifier may be a union of ranges separated by `||`, as an advisory over several release series needs (`>=4.2,<4.2.28 || >=5.2,<5.2.11`); the OSV import produces one such advisory per package instead of one per range. ### Plugins diff --git a/docs/usage.md b/docs/usage.md index bc3d235..68cef3f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -241,7 +241,8 @@ coretrace-python-analyzer --check src/ --advisories advisories.json A directory check reads `advisories.json` at the project root and every file passed with `--advisories`; a local entry wins over a plugin's for the same advisory. The file -lists advisories with the package, the vulnerable version range and, optionally, the +lists advisories with the package, the vulnerable version range (a specifier, or several +separated by `||` when several release series are affected) and, optionally, the affected APIs that feed reachability and correlation: ```json diff --git a/src/coretrace_python/dependency/advisories.py b/src/coretrace_python/dependency/advisories.py index ebd39d9..676558f 100644 --- a/src/coretrace_python/dependency/advisories.py +++ b/src/coretrace_python/dependency/advisories.py @@ -84,8 +84,9 @@ def import_osv(records: Iterable[Mapping[str, Any]]) -> tuple[Advisory, ...]: if str(package.get("ecosystem", "")).lower() != "pypi" or not package.get("name"): continue name = normalize(str(package["name"])) - for specifier in _specifiers(affected.get("ranges") or []): - advisories.append(Advisory(identifier, name, specifier, summary, severity, (), aliases)) + specifiers = _specifiers(affected.get("ranges") or []) + if specifiers: + advisories.append(Advisory(identifier, name, " || ".join(specifiers), summary, severity, (), aliases)) return tuple(advisories) diff --git a/src/coretrace_python/dependency/graph.py b/src/coretrace_python/dependency/graph.py index 4c41f7a..52f97fc 100644 --- a/src/coretrace_python/dependency/graph.py +++ b/src/coretrace_python/dependency/graph.py @@ -50,6 +50,13 @@ def padded(self, length: int) -> tuple[int, ...]: return self.parts + (0,) * (length - len(self.parts)) def satisfies(self, specifier: str) -> bool: + """Whether this version is in ``specifier``: comma-separated clauses all hold, and + ``||`` separates alternatives, as an advisory over several release series needs + (``>=4.2,<4.2.28 || >=5.2,<5.2.11``).""" + + return any(self._satisfies_all(alternative) for alternative in _alternatives(specifier)) + + def _satisfies_all(self, specifier: str) -> bool: for clause in [c.strip() for c in specifier.split(",") if c.strip()]: match = _CLAUSE.match(clause) if match is None: @@ -91,12 +98,17 @@ def _satisfies_clause(self, operator: str, wanted: Version, raw: str) -> bool: return True +def _alternatives(specifier: str) -> list[str]: + return [a for a in specifier.split("||") if a.strip()] or [""] + + def _lower_bounds(specifier: str) -> list[Version]: bounds: list[Version] = [] - for clause in [c.strip() for c in specifier.split(",") if c.strip()]: - match = _CLAUSE.match(clause) - if match is not None and match.group(1) in (">=", ">", "~=", "^", "==", "==="): - bounds.append(Version.parse(match.group(2))) + for alternative in _alternatives(specifier): + for clause in [c.strip() for c in alternative.split(",") if c.strip()]: + match = _CLAUSE.match(clause) + if match is not None and match.group(1) in (">=", ">", "~=", "^", "==", "==="): + bounds.append(Version.parse(match.group(2))) return bounds diff --git a/tests/test_advisory_feeds.py b/tests/test_advisory_feeds.py index 490014a..c05a338 100644 --- a/tests/test_advisory_feeds.py +++ b/tests/test_advisory_feeds.py @@ -124,10 +124,11 @@ def test_osv_severity_summary_and_ecosystem_handling() -> None: ("GHSA-6", Severity.MEDIUM), ("GHSA-7", Severity.MEDIUM), ("GHSA-9", Severity.MEDIUM), - ("GHSA-9", Severity.MEDIUM), ] assert advisories[2].summary == "Long details." - assert [a.vulnerable for a in advisories if a.id == "GHSA-9"] == ["<1.1", ">=2.0,<2.1"] + # Several ranges of one package are one advisory over a union of ranges: advisories + # are merged by identifier, so two records would keep only the last series. + assert [a.vulnerable for a in advisories if a.id == "GHSA-9"] == ["<1.1 || >=2.0,<2.1"] def test_osv_dumps_are_read_from_files_directories_and_archives(tmp_path: Path) -> None: diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index 0a56808..fe70a29 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -163,6 +163,11 @@ def test_graphs_merge_and_locks_pin_declared_requirements() -> None: ("1.26.4", ">=1.26.5", False), ("2.11.0", "^2.11", True), ("3.0.0", "^2.11", False), + # An advisory over several release series is a union of ranges. + ("4.2.27", ">=4.2,<4.2.28 || >=5.2,<5.2.11", True), + ("5.2.10", ">=4.2,<4.2.28 || >=5.2,<5.2.11", True), + ("5.0.0", ">=4.2,<4.2.28 || >=5.2,<5.2.11", False), + ("5.2.11", ">=4.2,<4.2.28 || >=5.2,<5.2.11", False), ], ) def test_version_matching(version: str, specifier: str, expected: bool) -> None: @@ -182,6 +187,13 @@ def test_requirements_may_allow_a_vulnerable_version() -> None: assert unspecified.may_match("<5.4") is True +def test_an_unpinned_requirement_may_match_any_series_of_a_union() -> None: + union = ">=4.2,<4.2.28 || >=5.2,<5.2.11" + assert Requirement.parse("django>=5.2", SourceId("r.txt"), 1).may_match(union) is True + assert Requirement.parse("django>=5.0,<5.1", SourceId("r.txt"), 1).may_match(union) is False + assert Requirement.parse("django==5.2.10", SourceId("r.txt"), 1).may_match(union) is True + + # --------------------------------------------------------------------------- engine