feat(vex): add CVE statements for pinned requirements.txt dependencies via OSV - #110
Conversation
…s via OSV With --vex, each exact requirements.txt pin is looked up in OSV and every advisory covering that version becomes a VEX statement keyed on its CVE (or GHSA/PYSEC id when there is none), in the same OpenVEX and CycloneDX VEX documents as the model finding classes. - Exact == / === pins only; range specifiers are counted and skipped. - OSV querybatch shortlists advisories; each record is re-checked locally against its versions list and ECOSYSTEM ranges (PEP 440). Agreement is `affected`; disagreement or an unevaluable record is `under_investigation`. Dependencies never get `not_affected`. - Best-effort by contract: network failure, timeouts, malformed responses or an exhausted 30s budget degrade to no CVE statements plus a warning. Exit codes and model findings are unchanged. Transient 429/5xx and connection errors are retried within the budget. - Records are fetched concurrently over a pooled session; answers are cached in ~/.aisbom/osv_cache.json for 24h. - --no-osv / AISBOM_NO_OSV=1 disable the lookup. A scan without --vex never contacts OSV. - Dependency components get stable bom-refs (dependency-<n>-<name>) so the statements resolve into the SBOM. - VEX classes can carry their own reference URL and source, so CVE statements resolve to osv.dev with OSV as source; alias sources are attributed by prefix instead of always NVD. - The Action uploads the SBOM alone when the SBOM+VEX envelope would exceed the receiver's 1 MiB limit, instead of losing the upload to a 413. - README (VEX section + privacy disclosure), air-gapped guide, action.yml and the finding-classes registry updated.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ca76a9b4ec
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| raw = str(event[kind]) | ||
| # "0" is OSV's spelling of "every version before the next event". | ||
| return kind, Version("0") if raw == "0" else _parse(raw) |
There was a problem hiding this comment.
Handle
introduced: 0 as an unbounded start
For valid pre-release pins below the numeric release zero, such as demo==0.dev0 or demo==0rc1, converting OSV's special introduced: "0" sentinel to Version("0") makes the pin compare lower than the introduction boundary. An advisory covering all versions before its fix is consequently downgraded to under_investigation even though both OSV and a correct local range evaluation agree it is affected; handle this sentinel independently of PEP 440 ordering.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in a473745. The introduced "0" sentinel is now an unbounded start (a None boundary that sorts first and precedes every pin) instead of Version("0"), so 0.dev0 / 0rc1 / 0a1 pins match advisories covering every version. Regression test: test_introduced_zero_covers_prereleases_below_release_zero.
| fixed = sorted( | ||
| {v for r in records for v in _fixed_versions(r, name)}, | ||
| key=lambda v: (Version(v) if _is_version(v) else Version("0"), v), | ||
| ) |
There was a problem hiding this comment.
Recommend only fixes applicable to the pinned interval
When an advisory has multiple disjoint affected intervals, this collects every historical fixed boundary. For events such as introduced 1.0, fixed 1.5, introduced 2.0, fixed 2.2 and a pin at 2.1, the emitted action says to upgrade from 2.1 to versions "fixed in: 1.5, 2.2", presenting an older interval's boundary as an upgrade target. Select the fix that closes the interval containing the pinned version, or at minimum exclude fixes not newer than the pin.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in a473745. The action now names only the fixed boundary that closes the interval containing the pin (per range), and reports no fix when that interval is open-ended or closed by last_affected. Fixes are deduplicated by version, so 2.20 and 2.20.0 from twin records list once. Tests cover the disjoint 1.0-1.5 / 2.0-2.2 case from this comment, an unfixed later interval, and last_affected followed by a fixed interval.
…he pinned interval
- OSV's `introduced: "0"` means "from the first version". Mapping it to
Version("0") put PEP 440 pre-releases such as 0.dev0 and 0rc1 below the
boundary, downgrading advisories that cover every version to
under_investigation. The sentinel is now an unbounded start.
- The action statement listed every `fixed` event in an advisory, so a pin in
a later disjoint interval was told to "upgrade" to an older interval's fix.
It now names only the fix that closes the interval containing the pin, per
range, deduplicated by version rather than string.
What
aisbom scan --vexnow adds CVE-keyed statements for exactrequirements.txtpins, looked up in OSV, to the same OpenVEX and CycloneDX VEX documents as the model finding classes. The README previously said these "arrive with OSV mapping"; this delivers that.How it decides
==/===without a wildcard.torch>=2.0doesn't say what is installed, so ranges are skipped and counted in the scan summary.querybatchnames candidate advisories; each full record is re-evaluated locally against itsversionslist andECOSYSTEMranges with PEP 440 ordering (fixedexclusive,last_affectedinclusive, events applied in version order). Agreement →affected. Disagreement, or a record with onlyGITranges or unparseable versions →under_investigation.vulnerable_code_not_present, and AIsbom never sees reachability.aliases. Twin GHSA/PYSEC records for one CVE merge into one statement.Failure contract
Every failure (no network, timeout, malformed body, missing record, exhausted 30s budget) degrades to no CVE statements plus a stderr warning. Exit codes and model findings are unchanged. Partial answers are discarded rather than emitted as if complete. Transient 429/5xx and connection errors are retried within the budget; a 404 is not.
--no-osv/AISBOM_NO_OSV=1disable it; a scan without--vexnever contacts OSV.~/.aisbom/osv_cache.jsonfor 24h (hits and misses); an unwritable directory just means no cache.Consumer-visible changes
bom-refs (dependency-<n>-<name>) instead of random ones, so statements resolve into the SBOM.aisbom diffcompares by name and reports no drift across the change.reference_url/source_name: CVE statements resolve toosv.devwith sourceOSV. Unset for every existing class, so finding-class output is unchanged. Aliasreferences[].sourceis now attributed by prefix (CVE→NVD, GHSA→GitHub Advisory Database, PYSEC→PyPA, AISBOM→AIsbom) rather than always NVD; no shipped class has aliases today.action.ymldiscloses the OSV lookup undertoken:.api.osv.dev. Air-gapped guide covers--no-osv.Verification
poetry run pytest --cov=aisbom --cov-fail-under=85: 1260 passed, 93.35% coverage;aisbom/osv.py99%.aisbom bypass-scorecard --check: gate passed.introduced, pre-release of the fix, atfixed,last_affectedinclusive, disjoint ranges, unsorted events,versions-only records, non-PyPI and wrong-package entries.requestsand the socket layer refuses. The run exits exactly like a--no-osvrun with identical finding-class statements.generate-test-artifacts+ a requirements file): both exit 2; normalized VEX documents identical apart from tool version; SBOM differs only in dependencybom-refs;aisbom diffold→new: no changes.requests==2.19.0,urllib3==1.24.1,flask==3.0.3→ 18 CVE statements (incl. CVE-2018-18074), OpenVEX 0.2.0 and CycloneDX 1.7 strict validation pass, zero dangling refs, repeat run served from cache with no refetch.django==2.0.0+pillow==6.0.0(140 advisories) → 79 statements in ~2.4s, stable across runs. Before concurrency and retries it blew the budget, and a single observed 503 dropped every statement; both are covered by tests now.Not changed
The scanner still records
torch>=2.0as version2.0in the SBOM itself. The OSV lookup ignores it, but correcting the SBOM would change public output, so it's left for a separate change.