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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/coretrace_python/dependency/advisories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
20 changes: 16 additions & 4 deletions src/coretrace_python/dependency/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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


Expand Down
5 changes: 3 additions & 2 deletions tests/test_advisory_feeds.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions tests/test_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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


Expand Down
Loading