Skip to content

feat(vex): add CVE statements for pinned requirements.txt dependencies via OSV - #110

Merged
lab700xdev merged 2 commits into
mainfrom
slice-128-osv-cve-vex
Sep 13, 2026
Merged

feat(vex): add CVE statements for pinned requirements.txt dependencies via OSV#110
lab700xdev merged 2 commits into
mainfrom
slice-128-osv-cve-vex

Conversation

@lab700xdev

Copy link
Copy Markdown
Contributor

What

aisbom scan --vex now adds CVE-keyed statements for exact requirements.txt pins, 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

  • Exact pins only. == / === without a wildcard. torch>=2.0 doesn't say what is installed, so ranges are skipped and counted in the scan summary.
  • OSV shortlists, AIsbom confirms. querybatch names candidate advisories; each full record is re-evaluated locally against its versions list and ECOSYSTEM ranges with PEP 440 ordering (fixed exclusive, last_affected inclusive, events applied in version order). Agreement → affected. Disagreement, or a record with only GIT ranges or unparseable versions → under_investigation.
  • No negative statements for dependencies. "No advisory found" is not vulnerable_code_not_present, and AIsbom never sees reachability.
  • IDs. The CVE alias when present, otherwise the OSV id; the rest go in 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=1 disable it; a scan without --vex never contacts OSV.
  • Cached in ~/.aisbom/osv_cache.json for 24h (hits and misses); an unwritable directory just means no cache.

Consumer-visible changes

  • Dependency components get stable bom-refs (dependency-<n>-<name>) instead of random ones, so statements resolve into the SBOM. aisbom diff compares by name and reports no drift across the change.
  • VEX classes may carry their own reference_url / source_name: CVE statements resolve to osv.dev with source OSV. Unset for every existing class, so finding-class output is unchanged. Alias references[].source is now attributed by prefix (CVE→NVD, GHSA→GitHub Advisory Database, PYSEC→PyPA, AISBOM→AIsbom) rather than always NVD; no shipped class has aliases today.
  • GitHub Action: when the SBOM+VEX envelope would exceed the receiver's 1 MiB limit, the SBOM is uploaded alone with a log line, instead of the whole request failing with a 413. action.yml discloses the OSV lookup under token:.
  • README: new CVE section under VEX, and a privacy subsection stating that only pinned package names and versions go to 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.py 99%.
  • aisbom bypass-scorecard --check: gate passed.
  • Range boundaries tested against real OSV shapes: at/below introduced, pre-release of the fix, at fixed, last_affected inclusive, disjoint ranges, unsorted events, versions-only records, non-PyPI and wrong-package entries.
  • Air gap, verified with the real client: the default session is left as requests and the socket layer refuses. The run exits exactly like a --no-osv run with identical finding-class statements.
  • Old vs new CLI on the same tree (generate-test-artifacts + a requirements file): both exit 2; normalized VEX documents identical apart from tool version; SBOM differs only in dependency bom-refs; aisbom diff old→new: no changes.
  • Live OSV: 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.0 as version 2.0 in the SBOM itself. The OSV lookup ignores it, but correcting the SBOM would change public output, so it's left for a separate change.

…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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread aisbom/osv.py Outdated
Comment on lines +133 to +135
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread aisbom/osv.py Outdated
Comment on lines +480 to +483
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),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@lab700xdev
lab700xdev merged commit 9b7e339 into main Sep 13, 2026
2 checks passed
@lab700xdev
lab700xdev deleted the slice-128-osv-cve-vex branch September 13, 2026 21:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant