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
34 changes: 31 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,33 @@ importing `os.system`, a Keras Lambda layer, a Jinja chat template with a
sandbox escape. None of those have a CVE, because the file itself is the
payload rather than a published component with a patchable defect. The full
registry is at [`docs/vex-finding-classes.md`](docs/vex-finding-classes.md)
and each identifier resolves under `https://aisbom.io/vex/`. CVE-keyed
statements about your `requirements.txt` pins are a separate, additive
concern and arrive with OSV mapping.
and each identifier resolves under `https://aisbom.io/vex/`.

#### CVEs in `requirements.txt` pins

The same documents also carry CVE-keyed statements for your Python
dependencies. With `--vex`, each exact pin (`requests==2.19.0`) is looked up in
[OSV](https://osv.dev), and every advisory covering that version becomes a
statement keyed on its CVE — or on its GHSA/PYSEC id when it has no CVE, with
the other ids listed as `aliases`.

- **Exact pins only.** `torch>=2.0` doesn't say which version is installed, so
ranges are skipped and counted in the scan summary rather than guessed at.
- **`affected` means both OSV and AIsbom agree.** OSV names the candidate
advisories; AIsbom re-checks the pinned version against each advisory's
published ranges. When the two disagree, or the ranges can't be evaluated,
the statement is `under_investigation`.
- **No `not_affected` for dependencies.** AIsbom can't see whether your code
reaches the vulnerable function, and "no advisory found" isn't evidence the
vulnerable code is absent, so dependencies never get a negative statement.
- **Cached for 24 hours** in `~/.aisbom/osv_cache.json`, so repeat CI scans of
the same pins make no requests.
- **Never breaks a scan.** If OSV is unreachable, slow or returns something
unexpected, the run prints a warning and the VEX documents simply carry no
CVE statements. Exit codes and model findings are unchanged.

A plain `aisbom scan` without `--vex` never contacts OSV. To keep `--vex` fully
offline, pass `--no-osv` or set `AISBOM_NO_OSV=1`.

#### Remediation evidence (`fixed`)

Expand Down Expand Up @@ -616,6 +640,10 @@ To detect failure loops (see [Authentication](#authentication-private--gated-hug

Each event carries an anonymous `user_id` — a SHA-256 of your machine's MAC address plus an app salt, truncated to 16 hex chars. Stored in `~/.aisbom/config.json`. Lets us see returning users without identifying anyone.

### OSV lookups (`--vex` only)

When you pass `--vex` and the scan finds exact `requirements.txt` pins, AIsbom sends each pinned **package name and version** to the public OSV API at `https://api.osv.dev`, and fetches the advisories it names. Nothing else is sent: no file paths, model names, hashes, findings, or identifiers. This is a request to a third-party service, not telemetry, so `AISBOM_NO_TELEMETRY` does not affect it; `--no-osv` or `AISBOM_NO_OSV=1` does. Responses are cached locally in `~/.aisbom/osv_cache.json` for 24 hours, and deleting that file is always safe.

### What's never collected

File paths, directory contents, model names, target URLs, file hashes from your SBOMs, exception messages, tracebacks, or anything that could identify you, your project, or your organization.
Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ inputs:
required: false
default: 'true'
token:
description: 'Optional. Per-repo API token for posting the generated SBOM to your hosted inventory dashboard at app.aisbom.io. Setting it also runs the scan with --vex and uploads the resulting VEX documents alongside the SBOM, so the dashboard can show whether a finding is exploitable. Leave unset for purely local PR-comment behavior. Get a token at https://app.aisbom.io/connect.'
description: 'Optional. Per-repo API token for posting the generated SBOM to your hosted inventory dashboard at app.aisbom.io. Setting it also runs the scan with --vex and uploads the resulting VEX documents alongside the SBOM, so the dashboard can show whether a finding is exploitable. With --vex, exact requirements.txt pins (package name and version only) are also looked up in the public OSV database (api.osv.dev) from the runner, adding CVE statements to those documents. Leave unset for purely local PR-comment behavior. Get a token at https://app.aisbom.io/connect.'
required: false
default: ''
platform-url:
Expand Down
24 changes: 21 additions & 3 deletions action/platform_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
WEBHOOK_PATH = "/v1/scan-result"
REQUEST_TIMEOUT_SEC = 15.0

# The receiver rejects bodies over 1 MiB with a 413. VEX documents grow with
# dependency CVE statements (one per advisory per pinned package), so an
# envelope can cross that line where the SBOM alone never would.
MAX_BODY_BYTES = 1024 * 1024

EXIT_OK = 0
EXIT_UPLOAD_FAILED = 3

Expand Down Expand Up @@ -117,7 +122,18 @@ def build_request_body(sbom_path: str, vex_documents: List[Dict[str, Any]] | Non
if not isinstance(sbom, dict):
return raw

return json.dumps({"sbom": sbom, "vex": vex_documents}).encode("utf-8")
envelope = json.dumps({"sbom": sbom, "vex": vex_documents}).encode("utf-8")
# Over the receiver's cap the whole request would be rejected, costing the
# inventory entry to carry supplementary documents — the same trade the
# unreadable-VEX branch above already refuses. Send the SBOM on its own.
if len(envelope) > MAX_BODY_BYTES:
print(
f"[aisbom-action] VEX documents omitted: the upload would be "
f"{len(envelope)} bytes, over the {MAX_BODY_BYTES}-byte limit. "
"The SBOM is uploaded alone; the VEX files remain on the runner."
)
return raw
return envelope


def summarize_response(status: int, body: str) -> str:
Expand Down Expand Up @@ -167,8 +183,10 @@ def upload(
payload = build_request_body(sbom_path, vex_documents)
# Part of the same disclosure as the lines above: an opted-in user can
# see from the log exactly how many documents left their runner, not
# just that "an upload happened".
print(f"[aisbom-action] vex-documents={len(vex_documents)}")
# just that "an upload happened". Counted from what is actually sent,
# since an oversized envelope falls back to the SBOM alone.
sent = len(vex_documents) if vex_documents and payload.startswith(b'{"sbom"') else 0
print(f"[aisbom-action] vex-documents={sent}")
resp = requests.post(
url,
data=payload,
Expand Down
46 changes: 46 additions & 0 deletions aisbom/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import uuid
from .version_check import check_latest_version
from . import loop_state
from . import osv
from . import telemetry
import requests

Expand Down Expand Up @@ -63,6 +64,9 @@ def main(

AISBOM_NO_TELEMETRY=1 Disable all anonymous usage telemetry. Honored on
every code path; never overridden.

AISBOM_NO_OSV=1 Same as `scan --no-osv`: never query OSV for
requirements.txt CVEs when emitting VEX.
"""
# Order matters: --version wins over the no-args panel so that
# `aisbom --version` is short and scriptable.
Expand Down Expand Up @@ -434,6 +438,8 @@ def _emit_vex(
vex_format: "VexFormat",
baseline_path: str | None,
schema_version: str,
dependencies: list[dict] | None = None,
osv_enabled: bool = False,
) -> None:
"""Write the VEX document(s) that accompany a just-written SBOM.

Expand All @@ -458,6 +464,20 @@ def _emit_vex(
raise typer.Exit(code=1)

statements = derive_statements(artifacts, baseline)

# CVE-keyed statements for exact requirements.txt pins join the same
# document (#128). Best-effort by contract: a failed lookup costs these
# statements and prints why, and nothing else about the run changes.
osv_result = None
if osv_enabled and dependencies:
osv_result = osv.lookup_dependency_statements(dependencies)
statements = statements + osv_result.statements
if osv_result.error:
err_console.print(
f"[yellow]⚠ {osv_result.error}; VEX carries no CVE statements "
"for requirements.txt pins this run.[/yellow]"
)

openvex_path, cyclonedx_path = _vex_paths(output)

if vex_format in (VexFormat.OPENVEX, VexFormat.BOTH):
Expand All @@ -484,6 +504,20 @@ def _emit_vex(
summary += f", {fixed} fixed since baseline"
console.print(summary + ".[/dim]")

if osv_result is not None and osv_result.error is None:
line = (
f"[dim]OSV: {len(osv_result.statements)} CVE statement(s) across "
f"{osv_result.queried} pinned dependenc"
f"{'y' if osv_result.queried == 1 else 'ies'}"
)
if osv_result.skipped_unpinned:
line += (
f"; {osv_result.skipped_unpinned} unpinned dependenc"
f"{'y' if osv_result.skipped_unpinned == 1 else 'ies'} skipped "
"(only exact == pins are looked up)"
)
console.print(line + ".[/dim]")


def _generate_markdown(results: dict) -> str:
"""Render a GitHub-flavored Markdown report for CI artifacts."""
Expand Down Expand Up @@ -573,6 +607,16 @@ def scan(
),
rich_help_panel="Advanced Options",
),
no_osv: bool = typer.Option(
False,
"--no-osv",
help=(
"With --vex, do not query OSV (api.osv.dev) for CVEs affecting "
"exact requirements.txt pins. Use in air-gapped environments; "
"AISBOM_NO_OSV=1 does the same."
),
rich_help_panel="Advanced Options",
),
):
"""
Deep Introspection Scan: Analyzes binary headers and dependency manifests.
Expand Down Expand Up @@ -859,10 +903,12 @@ def _risk_score(label: str) -> int:
_emit_vex(
sbom_json=sbom_json,
artifacts=results['artifacts'],
dependencies=results.get('dependencies', []),
output=output,
vex_format=vex_format,
baseline_path=vex_baseline,
schema_version=schema_version,
osv_enabled=not (no_osv or osv.disabled_by_env()),
)

has_content = bool(results.get('artifacts') or results.get('dependencies'))
Expand Down
7 changes: 5 additions & 2 deletions aisbom/cyclonedx_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from cyclonedx.model.component import Component, ComponentType
from cyclonedx.output.json import JsonV1Dot5, JsonV1Dot6, JsonV1Dot7

from .modelcard import bom_ref_for, inject_model_cards
from .modelcard import bom_ref_for, dependency_bom_ref, inject_model_cards
from .properties import build_component_properties
from .spdx_gen import _sha256_or_none

Expand Down Expand Up @@ -101,7 +101,7 @@ def build_bom(results: Dict[str, Any]) -> Bom:

bom.components.add(c)

for dep in results.get("dependencies", []):
for dep_index, dep in enumerate(results.get("dependencies", [])):
version = dep.get("version")
# `version: "unknown"` was a placeholder carrying no more information
# than an absent field, and it cost the completeness grade real points
Expand All @@ -112,6 +112,9 @@ def build_bom(results: Dict[str, Any]) -> Bom:
name=dep["name"],
version=None if version == "unknown" else version,
type=ComponentType.LIBRARY,
# Stable for the same reason as the model components: a CVE-keyed
# VEX statement (#128) addresses this component by bom-ref.
bom_ref=dependency_bom_ref(dep_index, dep),
))

return bom
Expand Down
12 changes: 12 additions & 0 deletions aisbom/modelcard.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,18 @@ def bom_ref_for(index: int, art: Dict[str, Any]) -> str:
return f"artifact-{index}-{art.get('name', 'unknown')}"


def dependency_bom_ref(index: int, dep: Dict[str, Any]) -> str:
"""Stable `bom-ref` for a requirements.txt dependency component.

Left to the library this is a fresh random string on every run, which is
what `bom_ref_for` exists to avoid for models. Dependencies need the same
once a VEX statement addresses one (#128): the CVE statement and the SBOM
component are written separately and must join on this value. The index
keeps the same package listed in two requirements files distinct.
"""
return f"dependency-{index}-{dep.get('name', 'unknown')}"


def inject_model_cards(
bom_json: str,
artifacts: List[Dict[str, Any]],
Expand Down
Loading