From abbd5d3eb9460362d36ccd21fbfb267d7b5d89d1 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 22 Sep 2026 18:29:08 +0200 Subject: [PATCH 1/2] test: define a union of version ranges for advisories over several series --- tests/test_advisory_feeds.py | 5 +++-- tests/test_dependencies.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) 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 From 60034ab462e38e92033ac44e831cde47e53a2af4 Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 22 Sep 2026 18:29:08 +0200 Subject: [PATCH 2/2] feat: accept a union of version ranges in a specifier An advisory over several release series (Django's 4.2, 5.2 and 6.0) needs one specifier: '||' now separates alternatives in Version.satisfies and the lower bounds an unpinned requirement is tested against. The OSV import emits one advisory per package with the union of its ranges, since advisories are merged by identifier and one record per range would keep only the last series. --- CHANGELOG.md | 1 + docs/usage.md | 3 ++- src/coretrace_python/dependency/advisories.py | 5 +++-- src/coretrace_python/dependency/graph.py | 20 +++++++++++++++---- 4 files changed, 22 insertions(+), 7 deletions(-) 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