From e160f29824aae053e8b9d4291a025444edcb12fe Mon Sep 17 00:00:00 2001 From: Ajoy L Date: Mon, 7 Sep 2026 08:49:41 -0500 Subject: [PATCH 1/2] feat(action): upload VEX documents with the SBOM when a platform token is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exploitability statements never left the runner. `aisbom scan --vex` writes them next to the SBOM, but the Action uploaded only the SBOM itself, so a connected repo's hosted inventory could show what the repo contains and never whether a finding was actually exploitable — the question the EU Cyber Resilience Act and FDA §524B ask about most directly. Setting `token` now also runs the scan with `--vex`, and the resulting OpenVEX and CycloneDX VEX documents are uploaded in the same request as `{"sbom": ..., "vex": [...]}`. The receiver sniffs the body shape, so an upload carrying no VEX is still posted as the raw SBOM bytes, verbatim — byte-identical to what every previous release sent. Generation is tied to the platform opt-in rather than made unconditional. A user who has not connected a repo gets no extra files in their workspace and no extra work in their scan; a user who has gets the data without editing their workflow, which is the only way an inventory feature reaches the people who already enabled it. Failure handling matches the existing best-effort posture: an unreadable or non-object VEX sibling is skipped with a log line rather than failing the upload, because the SBOM is what the user actually needs in their inventory. An SBOM that cannot be parsed is posted raw so the receiver returns its own specific rejection reason instead of one invented here. `vex_paths_for` mirrors `aisbom.cli._vex_paths` and must stay in step: if the two drift, a plain `scan --vex` writes documents this helper never looks for and the upload silently stops carrying them. Verified end to end against real output, not stubs — scanning the mock artifacts with --vex and building the actual request body produced a 15.7 KB envelope with both documents bound to the SBOM's serial number and states the receiver accepts. The README privacy section said the SBOM was "the entire payload", which this change would have made false; it now describes the VEX documents, what they do and do not contain, and that no token means no VEX at all. --- README.md | 8 +- action.yml | 2 +- action/entrypoint.sh | 13 +++ action/platform_upload.py | 93 ++++++++++++++++- tests/test_action_entrypoint.py | 55 ++++++++++ tests/test_action_platform_upload.py | 147 +++++++++++++++++++++++++++ 6 files changed, 311 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9115d3a..ab778f7 100644 --- a/README.md +++ b/README.md @@ -449,7 +449,11 @@ Get a per-repo token at (sign in with GitHub). L The model files themselves never leave the GitHub runner in any configuration — the scan, the SBOM and the PR comment are all produced on the runner. Three things can go over the wire, each with its own switch: -**Dashboard upload — off by default, enabled by setting `token`.** The Action POSTs the generated CycloneDX SBOM JSON to `https://app.aisbom.io/v1/scan-result`, along with the branch/tag name (`GITHUB_REF_NAME`) so the dashboard can attribute results to the right ref. That's the entire payload. Data is stored in the EU (Cloudflare R2/D1, EU jurisdiction). Every upload is announced in a loud log group in your CI output. Remove the `token` input to stop. +**Dashboard upload — off by default, enabled by setting `token`.** The Action POSTs the generated CycloneDX SBOM JSON to `https://app.aisbom.io/v1/scan-result`, along with the branch/tag name (`GITHUB_REF_NAME`) so the dashboard can attribute results to the right ref. + +Setting `token` also makes the Action run the scan with `--vex`, and the two generated VEX documents (OpenVEX and CycloneDX VEX) are uploaded in the same request as `{"sbom": …, "vex": [...]}`. They are derived entirely from findings already described in the SBOM — per finding, whether each scanned artifact is actually affected — and add no new information about your files; they let the dashboard show whether a finding is *exploitable* rather than merely present. The CI log reports how many were sent (`vex-documents=N`). That is the entire payload: SBOM, VEX documents, ref, and the trigger/run identifiers. + +Without a `token` the scan runs exactly as before — no `--vex`, no VEX files written to your workspace, no request. Data is stored in the EU (Cloudflare R2/D1, EU jurisdiction). Every upload is announced in a loud log group in your CI output. Remove the `token` input to stop. **Share upload — off by default, enabled by `share: true`.** The same SBOM is POSTed to `aisbom.io/api/sbom-share`, which mints a **publicly-readable** viewer link retained for 30 days and adds it to the PR comment and the `share-url` output. The unguessable URL token is the only access control, and on a public repository the Action prints that URL into the workflow log, which is itself public. With `share` unset, no request reaches `aisbom.io` and `share-url` is empty. @@ -462,7 +466,7 @@ The model files themselves never leave the GitHub runner in any configuration **Anonymous telemetry — on by default,** as described in [Telemetry & Privacy](#telemetry--privacy). `AISBOM_NO_TELEMETRY=1` disables telemetry only; it does not suppress either upload above. -For the two upload paths the payload is the SBOM — names, hashes, licenses, risk levels — describing the *structure and findings* of your model files, never the weights or file contents. Telemetry carries none of that: no SBOM, no file names, no hashes, no repo identifier. +For the two upload paths the payload is the SBOM — names, hashes, licenses, risk levels — plus, on the dashboard path only, the VEX documents derived from those same findings. All of it describes the *structure and findings* of your model files, never the weights or file contents. Telemetry carries none of that: no SBOM, no VEX, no file names, no hashes, no repo identifier. > **Changed in v1.4.0.** Sharing used to be unconditional: every Action run published its SBOM to a public 30-day link whether or not `token` was set, which contradicted the paragraph above. It is now opt-in and off by default. If you consume the `share-url` output or want the viewer link in your PR comments, set `share: true`. diff --git a/action.yml b/action.yml index a0692d1..afb0318 100644 --- a/action.yml +++ b/action.yml @@ -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. 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. Leave unset for purely local PR-comment behavior. Get a token at https://app.aisbom.io/connect.' required: false default: '' platform-url: diff --git a/action/entrypoint.sh b/action/entrypoint.sh index 5e3aca0..9b29f25 100755 --- a/action/entrypoint.sh +++ b/action/entrypoint.sh @@ -56,6 +56,18 @@ SCAN_LOG="${AISBOM_SCAN_LOG:-/tmp/aisbom-scan.log}" # only passed when the user explicitly sets `share: true`. Everything else the # Action does — the SBOM artifact, the PR comment, fail-on-risk, the platform # upload — renders from the local SBOM and works identically with sharing off. +# VEX is generated only when a platform token is set, i.e. when there is a +# hosted inventory to send it to. Exploitability statements are what the CRA +# and FDA §524B ask for most directly, and without them the inventory can show +# what a repo contains but never whether a finding is actually exploitable. +# Tying generation to the opt-in keeps the default run byte-identical: a user +# who has not connected a repo gets no extra files in their workspace and no +# extra work in their scan. +VEX_ARGS=() +if [ -n "${INPUT_TOKEN}" ]; then + VEX_ARGS=(--vex) +fi + SHARE_ARGS=() if [ "${INPUT_SHARE}" = "true" ]; then SHARE_ARGS=(--share --share-yes) @@ -73,6 +85,7 @@ set -o pipefail # words at all when the array is empty. aisbom scan "${DIRECTORY}" \ --output "${OUTPUT_FILE}" \ + ${VEX_ARGS[@]+"${VEX_ARGS[@]}"} \ ${SHARE_ARGS[@]+"${SHARE_ARGS[@]}"} \ 2>&1 | tee "${SCAN_LOG}" SCAN_EXIT=${PIPESTATUS[0]} diff --git a/action/platform_upload.py b/action/platform_upload.py index 6332d7d..538bdcd 100644 --- a/action/platform_upload.py +++ b/action/platform_upload.py @@ -1,11 +1,20 @@ #!/usr/bin/env python3 -"""POST the generated SBOM to the platform webhook (opt-in via --token).""" +"""POST the generated SBOM to the platform webhook (opt-in via --token). + +When the scan also produced VEX documents (`aisbom scan --vex`), they are +uploaded alongside the SBOM in a single request. Exploitability statements +previously stayed on the runner, which meant the hosted inventory could never +show whether a finding was actually exploitable — the question the CRA and +FDA §524B ask about most directly. +""" from __future__ import annotations import argparse +import json import os import sys -from typing import Mapping +from pathlib import Path +from typing import Any, Dict, List, Mapping import requests @@ -39,6 +48,78 @@ def compute_ref(env: Mapping[str, str]) -> str | None: return ref or None +def vex_paths_for(sbom_path: str) -> List[str]: + """The VEX filenames `aisbom scan --vex` would have written for this SBOM. + + Mirrors ``aisbom.cli._vex_paths``. The two must agree exactly: if they + drift, a plain `scan --vex` writes documents this helper never looks for + and the exploitability data silently stops being uploaded — a failure with + no error message anywhere. + """ + stem = sbom_path[: -len(".json")] if sbom_path.endswith(".json") else sbom_path + return [f"{stem}.openvex.json", f"{stem}.vex.cdx.json"] + + +def load_vex_documents(sbom_path: str) -> List[Dict[str, Any]]: + """Read whichever VEX siblings exist next to the SBOM. + + Missing files are the normal case (the scan ran without ``--vex``). An + unreadable or non-object file is skipped rather than raised on: the SBOM is + what the user actually needs in their inventory, and failing the whole + upload because a supplementary document is corrupt would cost them that + entry to save a file the receiver would have ignored anyway. + """ + documents: List[Dict[str, Any]] = [] + for path in vex_paths_for(sbom_path): + if not Path(path).is_file(): + continue + try: + with open(path, "rb") as fh: + parsed = json.loads(fh.read()) + except (OSError, ValueError): + print(f"[aisbom-action] skipping unreadable VEX document: {path}") + continue + if isinstance(parsed, dict): + documents.append(parsed) + else: + print(f"[aisbom-action] skipping VEX document that is not an object: {path}") + return documents + + +def build_request_body(sbom_path: str, vex_documents: List[Dict[str, Any]] | None = None) -> bytes: + """The bytes to POST: the SBOM alone, or an {sbom, vex} envelope. + + With no VEX documents the SBOM's own bytes are sent **verbatim**, so an + upload from a repo that does not use ``--vex`` is byte-identical to what + every previous release sent. Only when there is something extra to carry + does the body become an envelope. + + If the SBOM cannot be parsed we send it raw as well. The SBOM is the + document the receiver validates, and inventing an envelope around bytes we + could not read would replace the receiver's specific rejection reason with + a confusing one. + """ + with open(sbom_path, "rb") as fh: + raw = fh.read() + + # Accepted as an argument so a caller that already loaded the documents + # (upload, which also logs the count) does not parse them a second time and + # emit every "skipping unreadable document" warning twice. + if vex_documents is None: + vex_documents = load_vex_documents(sbom_path) + if not vex_documents: + return raw + + try: + sbom = json.loads(raw) + except ValueError: + return raw + if not isinstance(sbom, dict): + return raw + + return json.dumps({"sbom": sbom, "vex": vex_documents}).encode("utf-8") + + def summarize_response(status: int, body: str) -> str: snippet = (body or "")[:400] return f"status={status} body={snippet!r}" @@ -82,8 +163,12 @@ def upload( headers["X-Aisbom-Ref"] = ref try: - with open(sbom_path, "rb") as fh: - payload = fh.read() + vex_documents = load_vex_documents(sbom_path) + 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)}") resp = requests.post( url, data=payload, diff --git a/tests/test_action_entrypoint.py b/tests/test_action_entrypoint.py index b6de34f..24fb374 100644 --- a/tests/test_action_entrypoint.py +++ b/tests/test_action_entrypoint.py @@ -220,3 +220,58 @@ def test_share_setting_is_forwarded_to_the_comment_renderer( run = run_entrypoint(tmp_path / share, [*BASE_ARGS, share], create_sbom=True) assert "--share-enabled" in run.python_argv assert run.python_argv[run.python_argv.index("--share-enabled") + 1] == expected + + +# Argv with a platform token set — the opt-in that turns on hosted inventory. +TOKEN_ARGS = [ + ".", + "sbom.json", + "gh-token", + "10", + "true", + "true", + "plat-token", # $7 token + "", # $8 platform-url + "false", # $9 fail-on-platform-error +] + + +class TestVexAccompaniesPlatformUpload: + """VEX is generated exactly when there is somewhere to send it. + + Exploitability statements are what the CRA and FDA §524B ask for most + directly, and they used to stay on the runner — the hosted inventory could + never show whether a finding was actually exploitable. Generating them is + now tied to the platform opt-in: a user who has not connected a repo sees + no change at all, including no extra files in their workspace. + """ + + def test_platform_token_generates_vex(self, tmp_path): + run = run_entrypoint(tmp_path, [*TOKEN_ARGS, "false"]) + assert run.proc.returncode == 0 + assert run.scan_argv, "stub aisbom was never invoked" + assert "--vex" in run.scan_argv + + def test_no_token_generates_no_vex(self, tmp_path): + """The broad user base is not opted in, and must be unaffected.""" + run = run_entrypoint(tmp_path, [*BASE_ARGS, "false"]) + assert run.proc.returncode == 0 + assert "--vex" not in run.scan_argv + + def test_vex_does_not_drag_in_sharing(self, tmp_path): + """--vex must not re-introduce the aisbom.io upload by another route.""" + run = run_entrypoint(tmp_path, [*TOKEN_ARGS, "false"]) + assert "--share" not in run.scan_argv + assert "--share-yes" not in run.scan_argv + + def test_vex_and_sharing_can_coexist(self, tmp_path): + run = run_entrypoint(tmp_path, [*TOKEN_ARGS, "true"]) + assert "--vex" in run.scan_argv + assert "--share" in run.scan_argv + + def test_scan_target_and_output_survive_the_vex_conditional(self, tmp_path): + """The flag is appended, not substituted for the positional args.""" + run = run_entrypoint(tmp_path, [*TOKEN_ARGS, "false"]) + assert "." in run.scan_argv + assert "--output" in run.scan_argv + assert run.scan_argv[run.scan_argv.index("--output") + 1] == "sbom.json" diff --git a/tests/test_action_platform_upload.py b/tests/test_action_platform_upload.py index 62246ed..b33b277 100644 --- a/tests/test_action_platform_upload.py +++ b/tests/test_action_platform_upload.py @@ -380,3 +380,150 @@ def test_parse_args_rejects_dash_leading_token_with_space_form(): def test_parse_args_empty_token_with_equals_form_is_allowed(): ns = platform_upload.parse_args(["--sbom=x", "--token="]) assert ns.token == "" + + +# --------------------------------------------------------------------------- +# VEX discovery and the request envelope +# --------------------------------------------------------------------------- +# +# `aisbom scan --vex` writes its VEX documents next to the SBOM, and until now +# the Action uploaded only the SBOM itself — so exploitability data never +# reached the hosted inventory, and the frameworks that ask for it specifically +# (the CRA, FDA §524B) could never be evidenced there. +# +# The upload body is now: the SBOM bytes verbatim when no VEX document is +# present (byte-identical to what every previous release sent), or a +# `{"sbom": …, "vex": [...]}` envelope when there is something to carry. + + +@pytest.fixture +def sbom_with_vex(tmp_path: Path) -> Path: + sbom = tmp_path / "sbom.json" + sbom.write_text(json.dumps({"bomFormat": "CycloneDX", "specVersion": "1.7", "components": []})) + (tmp_path / "sbom.openvex.json").write_text(json.dumps({"@context": "https://openvex.dev/ns/v0.2.0", "statements": []})) + (tmp_path / "sbom.vex.cdx.json").write_text(json.dumps({"bomFormat": "CycloneDX", "vulnerabilities": []})) + return sbom + + +def test_vex_paths_for_mirrors_the_cli_naming(tmp_path: Path): + # Must match aisbom.cli._vex_paths, or a plain `scan --vex` writes files + # this helper never looks for. + paths = platform_upload.vex_paths_for(str(tmp_path / "sbom.json")) + assert [Path(p).name for p in paths] == ["sbom.openvex.json", "sbom.vex.cdx.json"] + + +def test_vex_paths_for_handles_an_output_without_json_suffix(tmp_path: Path): + paths = platform_upload.vex_paths_for(str(tmp_path / "report")) + assert [Path(p).name for p in paths] == ["report.openvex.json", "report.vex.cdx.json"] + + +def test_load_vex_documents_returns_both_siblings(sbom_with_vex: Path): + docs = platform_upload.load_vex_documents(str(sbom_with_vex)) + assert len(docs) == 2 + + +def test_load_vex_documents_is_empty_when_none_were_written(sbom_file: Path): + assert platform_upload.load_vex_documents(str(sbom_file)) == [] + + +def test_load_vex_documents_skips_an_unparseable_sibling(tmp_path: Path): + sbom = tmp_path / "sbom.json" + sbom.write_text(json.dumps({"bomFormat": "CycloneDX"})) + (tmp_path / "sbom.openvex.json").write_text("{ this is not json") + (tmp_path / "sbom.vex.cdx.json").write_text(json.dumps({"bomFormat": "CycloneDX"})) + + # One good document still uploads. Failing the whole upload because a + # supplementary file is corrupt would cost the user their inventory entry. + docs = platform_upload.load_vex_documents(str(sbom)) + assert len(docs) == 1 + + +def test_load_vex_documents_skips_a_non_object_sibling(tmp_path: Path): + sbom = tmp_path / "sbom.json" + sbom.write_text(json.dumps({"bomFormat": "CycloneDX"})) + (tmp_path / "sbom.openvex.json").write_text(json.dumps(["not", "an", "object"])) + assert platform_upload.load_vex_documents(str(sbom)) == [] + + +def test_build_request_body_without_vex_is_the_sbom_verbatim(sbom_file: Path): + # Byte-for-byte: an upload from a repo not using --vex must be + # indistinguishable from what previous releases sent. + body = platform_upload.build_request_body(str(sbom_file)) + assert body == sbom_file.read_bytes() + + +def test_build_request_body_with_vex_is_an_envelope(sbom_with_vex: Path): + payload = json.loads(platform_upload.build_request_body(str(sbom_with_vex))) + assert set(payload) == {"sbom", "vex"} + assert payload["sbom"]["bomFormat"] == "CycloneDX" + assert len(payload["vex"]) == 2 + + +def test_build_request_body_falls_back_to_raw_bytes_on_unparseable_sbom(tmp_path: Path): + # The SBOM itself is the payload the receiver validates; if we cannot parse + # it we must not swallow the error by inventing an envelope. Send it as-is + # and let the receiver reject it with its own explicit reason. + sbom = tmp_path / "sbom.json" + sbom.write_text("{ truncated") + (tmp_path / "sbom.openvex.json").write_text(json.dumps({"statements": []})) + + body = platform_upload.build_request_body(str(sbom)) + assert body == sbom.read_bytes() + + +def test_upload_posts_the_envelope_when_vex_is_present(sbom_with_vex: Path): + captured = {} + + def fake_post(url, **kwargs): + captured["data"] = kwargs.get("data") + return _mock_response(200, "ok") + + with patch("requests.post", side_effect=fake_post): + rc = platform_upload.upload( + sbom_path=str(sbom_with_vex), + token="tok", + platform_url="https://app.aisbom.io", + trigger="push", + fail_on_error=False, + env={"GITHUB_RUN_ID": "1", "GITHUB_RUN_ATTEMPT": "1"}, + ) + + assert rc == 0 + payload = json.loads(captured["data"]) + assert len(payload["vex"]) == 2 + + +def test_upload_posts_bare_sbom_when_no_vex_is_present(sbom_file: Path): + captured = {} + + def fake_post(url, **kwargs): + captured["data"] = kwargs.get("data") + return _mock_response(200, "ok") + + with patch("requests.post", side_effect=fake_post): + platform_upload.upload( + sbom_path=str(sbom_file), + token="tok", + platform_url="https://app.aisbom.io", + trigger="push", + fail_on_error=False, + env={}, + ) + + assert captured["data"] == sbom_file.read_bytes() + + +def test_upload_reports_the_vex_document_count(sbom_with_vex: Path, capsys): + # Opted-in users are told exactly what left their runner, matching the + # existing loud log group. + with patch("requests.post", return_value=_mock_response(200, "ok")): + platform_upload.upload( + sbom_path=str(sbom_with_vex), + token="tok", + platform_url="https://app.aisbom.io", + trigger="push", + fail_on_error=False, + env={}, + ) + out = capsys.readouterr().out + assert "vex-documents=2" in out From 5c7cd8551ad0bbd43272209998e064684677c231 Mon Sep 17 00:00:00 2001 From: Ajoy L Date: Mon, 7 Sep 2026 09:00:44 -0500 Subject: [PATCH 2/2] fix(action): upload before the fail-on-risk gate; document VEX in the Action README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both findings from the Codex review on PR #105. A CRITICAL scan never reached the dashboard. `aisbom scan` exits 2 on a CRITICAL finding but still writes its SBOM and VEX documents, and the entrypoint honoured `fail-on-risk` — true by default — before the platform upload. So a repo containing a genuinely dangerous artifact exited the script and uploaded nothing: the hosted inventory silently omitted exactly the repos that most needed to be in it. The bug predates this change and applied to the SBOM upload as a whole, but it defeats this PR completely: `affected` VEX statements only exist when there IS a critical finding, so the data this change exists to deliver was unreachable in precisely the case that produces it. The upload now runs before both exit gates. Exit status is unchanged for every previously reachable case, and where the two new gates can now both apply — a CRITICAL scan whose upload also failed — exit 2 wins over exit 3, because the required check should report the dangerous model rather than the plumbing. The header's exit-code table records that precedence. The entrypoint test harness gained `scan_exit` / `python_exit` knobs and an ordered record of helper invocations, so this is asserted by driving the real script rather than by reading it. Also: action/README_ACTION.md still said the dashboard receives the same CycloneDX JSON and that both upload paths carry only the SBOM. Users reading that privacy reference would not have been told that setting `token` now transmits two additional documents. It now matches the root README. --- action/README_ACTION.md | 4 +- action/entrypoint.sh | 40 ++++++++--- tests/test_action_entrypoint.py | 116 ++++++++++++++++++++++++++++++-- 3 files changed, 142 insertions(+), 18 deletions(-) diff --git a/action/README_ACTION.md b/action/README_ACTION.md index aeaed6b..7c68d15 100644 --- a/action/README_ACTION.md +++ b/action/README_ACTION.md @@ -118,9 +118,11 @@ Scans run inside the Action container; the model files themselves never leave th 1. **SBOM share upload — off by default, enabled by `share: true`.** The rendered CycloneDX JSON is POSTed to `aisbom.io/api/sbom-share`, which mints a **publicly-readable** viewer link retained for 30 days; the unguessable URL token is the only access control. With `share` unset — the default — no request is made to `aisbom.io` and the `share-url` output is empty. Note that on a public repository the Action prints that URL into the workflow log, which is itself public. 2. **Hosted dashboard upload — off by default, enabled by setting `token`.** The same CycloneDX JSON is POSTed to `https://app.aisbom.io/v1/scan-result` (or your `platform-url` override) along with the branch/tag name (`GITHUB_REF_NAME`), so your dashboard at [app.aisbom.io](https://app.aisbom.io) can track the repo's SBOM history. Data is stored in the EU. The upload is logged loudly in your CI output every time it happens. Remove the token to stop. + + Setting `token` also runs the scan with `--vex`, and the two resulting VEX documents (OpenVEX and CycloneDX VEX) are uploaded in that same request as `{"sbom": …, "vex": [...]}`. They are derived entirely from findings already present in the SBOM — per finding, whether each scanned artifact is actually affected — and add no new information about your files; they are what lets the dashboard show whether a finding is *exploitable* rather than merely present. The log group reports how many were sent (`vex-documents=N`). With no `token`, `--vex` is not passed, no VEX files are written into your workspace, and no request is made. 3. **Anonymous telemetry — on by default.** Two events (`github_action_run` and `github_action_comment_posted`) are POSTed to `api.aisbom.io/v1/telemetry`, plus the CLI's own scan events. No repo identifier, no file paths, no findings content — just severity buckets and whether the comment was created vs updated. Set `AISBOM_NO_TELEMETRY=1` in your workflow's `env:` block to disable. -For the two upload paths (1 and 2) the payload is the SBOM — file names, SHA-256 hashes, licenses, risk and legal findings — never model weights or file contents. Telemetry (3) carries none of that: no SBOM, no file names, no hashes, no repo identifier — just event names and low-cardinality parameters such as severity counts. +For the two upload paths (1 and 2) the payload is the SBOM — file names, SHA-256 hashes, licenses, risk and legal findings — plus, on path 2 only, the VEX documents derived from those same findings. Never model weights or file contents. Telemetry (3) carries none of that: no SBOM, no VEX, no file names, no hashes, no repo identifier — just event names and low-cardinality parameters such as severity counts. `AISBOM_NO_TELEMETRY=1` disables (3) only. It does **not** suppress the share upload: with `share: true` the SBOM is still uploaded, and only the `cli_share_created` event is withheld. Leave `share` unset to stop the upload itself. diff --git a/action/entrypoint.sh b/action/entrypoint.sh index 9b29f25..f63068d 100755 --- a/action/entrypoint.sh +++ b/action/entrypoint.sh @@ -25,6 +25,10 @@ # 2 — Scan reported CRITICAL findings AND fail-on-risk is true. # 3 — Platform upload failed AND fail-on-platform-error is true. # +# 2 takes precedence over 3 when both apply: a dangerous model is the signal +# the user's required check should report, not a failed upload. The upload is +# attempted before either gate, so a CRITICAL repo still reaches the dashboard. +# # Comment-posting failures NEVER fail the job (logged but tolerated so the # user fixes their `permissions:` block, not the scan). @@ -119,16 +123,18 @@ else echo "[aisbom-action] No SBOM file at ${OUTPUT_FILE}; skipping PR comment." fi -# Step 3 — Honor fail-on-risk: re-raise the CLI's exit code so the user's -# branch protection rules and required-checks gates behave correctly. -if [ "${FAIL_ON_RISK}" = "true" ] && [ "${SCAN_EXIT}" -eq 2 ]; then - echo "[aisbom-action] CRITICAL risks detected; failing the job (fail-on-risk=true)." - exit 2 -fi - -# Step 4 — Optional platform upload. Silent skip when no token, +# Step 3 — Optional platform upload. Silent skip when no token, # preserving CLI-only behavior for the broad user base. Opted-in users see # the loud log group emitted by platform_upload.py. +# +# This runs BEFORE the fail-on-risk gate below, and the order is load-bearing. +# `aisbom scan` exits 2 on a CRITICAL finding but still writes its SBOM and VEX +# documents; when the gate came first, a repo containing a genuinely dangerous +# artifact exited here and never uploaded — so the hosted inventory silently +# omitted exactly the repos that most needed to be in it, and the `affected` +# VEX statements (which only exist when there IS a critical finding) could +# never arrive. Uploading first costs nothing: the job's exit status is decided +# below either way. PLATFORM_EXIT=0 if [ -n "${INPUT_TOKEN}" ] && [ -f "${OUTPUT_FILE}" ]; then FAIL_FLAG="" @@ -146,6 +152,21 @@ if [ -n "${INPUT_TOKEN}" ] && [ -f "${OUTPUT_FILE}" ]; then ${FAIL_FLAG} || PLATFORM_EXIT=$? fi +if [ "${PLATFORM_EXIT}" -ne 0 ] && [ "${INPUT_FAIL_ON_PLATFORM_ERROR}" != "true" ]; then + echo "[aisbom-action] Platform upload exited ${PLATFORM_EXIT}; tolerated (fail-on-platform-error=false)." +fi + +# Step 4 — Honor fail-on-risk: re-raise the CLI's exit code so the user's +# branch protection rules and required-checks gates behave correctly. +# +# Deliberately ahead of the platform-error exit below: when a scan finds a +# CRITICAL artifact AND the upload failed, exit 2 is the more useful signal. +# The required check should report the dangerous model, not the plumbing. +if [ "${FAIL_ON_RISK}" = "true" ] && [ "${SCAN_EXIT}" -eq 2 ]; then + echo "[aisbom-action] CRITICAL risks detected; failing the job (fail-on-risk=true)." + exit 2 +fi + # Honour fail-on-platform-error even when the helper exited before its own # error-handling could fire (e.g. argparse usage error). Without this gate, # the default `fail-on-platform-error: false` silently degraded into "fail @@ -153,8 +174,5 @@ fi if [ "${PLATFORM_EXIT}" -ne 0 ] && [ "${INPUT_FAIL_ON_PLATFORM_ERROR}" = "true" ]; then exit "${PLATFORM_EXIT}" fi -if [ "${PLATFORM_EXIT}" -ne 0 ]; then - echo "[aisbom-action] Platform upload exited ${PLATFORM_EXIT}; tolerated (fail-on-platform-error=false)." -fi exit 0 diff --git a/tests/test_action_entrypoint.py b/tests/test_action_entrypoint.py index 24fb374..f990c94 100644 --- a/tests/test_action_entrypoint.py +++ b/tests/test_action_entrypoint.py @@ -40,6 +40,10 @@ # Faithful to the CLI: the viewer URL is only ever printed when --share was # passed. Records its own argv so the test can assert on the real invocation. +# `AISBOM_EXIT` lets a test drive the CLI's real exit codes — notably 2, which +# `aisbom scan` returns when it finds a CRITICAL artifact. The scan still writes +# its SBOM and VEX files in that case, so the entrypoint's ordering around the +# exit code is observable rather than hypothetical. AISBOM_STUB = """#!/bin/bash printf '%s\\n' "$@" > "${AISBOM_ARGV_FILE}" echo "AIsbom scanning ${2:-.}" @@ -48,26 +52,38 @@ echo "Shareable link: __VIEWER_URL__" fi done -exit 0 +exit "${AISBOM_EXIT:-0}" """ -# Stands in for `python /aisbom-action/post_comment.py`, which only exists -# inside the Docker image. +# Stands in for `python /aisbom-action/post_comment.py` and +# `python /aisbom-action/platform_upload.py`, neither of which exists outside +# the Docker image. Two records are kept: PYTHON_ARGV_FILE holds the most +# recent invocation (what the original tests assert on), while +# PYTHON_INVOCATIONS_FILE appends one line per call so a test can assert that a +# *particular* script ran even when another ran after it. PYTHON_STUB = """#!/bin/bash printf '%s\\n' "$@" > "${PYTHON_ARGV_FILE}" -exit 0 +printf '%s ' "$@" >> "${PYTHON_INVOCATIONS_FILE}" +printf '\\n' >> "${PYTHON_INVOCATIONS_FILE}" +exit "${PYTHON_EXIT:-0}" """ class EntrypointRun: """Captured result of one entrypoint.sh invocation.""" - def __init__(self, proc, scan_argv, github_output, scan_log, python_argv): + def __init__(self, proc, scan_argv, github_output, scan_log, python_argv, python_invocations=()): self.proc = proc self.scan_argv = scan_argv self.github_output = github_output self.scan_log = scan_log self.python_argv = python_argv + # One entry per `python …` call, in order. + self.python_invocations = list(python_invocations) + + def ran_script(self, name: str) -> bool: + """Whether a given helper script was invoked at all.""" + return any(name in line for line in self.python_invocations) @property def outputs(self) -> dict[str, str]: @@ -85,7 +101,14 @@ def _write_stub(path: Path, body: str) -> None: path.chmod(0o755) -def run_entrypoint(tmp_path: Path, args: list[str], *, create_sbom: bool = False) -> EntrypointRun: +def run_entrypoint( + tmp_path: Path, + args: list[str], + *, + create_sbom: bool = False, + scan_exit: int = 0, + python_exit: int = 0, +) -> EntrypointRun: """Execute entrypoint.sh with stubbed `aisbom` and `python` on PATH.""" bindir = tmp_path / "bin" bindir.mkdir(parents=True) @@ -99,6 +122,7 @@ def run_entrypoint(tmp_path: Path, args: list[str], *, create_sbom: bool = False scan_argv = tmp_path / "scan-argv.txt" python_argv = tmp_path / "python-argv.txt" + python_invocations = tmp_path / "python-invocations.txt" github_output = tmp_path / "github-output.txt" scan_log = tmp_path / "scan.log" github_output.touch() @@ -108,8 +132,11 @@ def run_entrypoint(tmp_path: Path, args: list[str], *, create_sbom: bool = False "PATH": f"{bindir}{os.pathsep}{os.environ['PATH']}", "AISBOM_ARGV_FILE": str(scan_argv), "PYTHON_ARGV_FILE": str(python_argv), + "PYTHON_INVOCATIONS_FILE": str(python_invocations), "GITHUB_OUTPUT": str(github_output), "AISBOM_SCAN_LOG": str(scan_log), + "AISBOM_EXIT": str(scan_exit), + "PYTHON_EXIT": str(python_exit), } proc = subprocess.run( @@ -125,6 +152,9 @@ def run_entrypoint(tmp_path: Path, args: list[str], *, create_sbom: bool = False github_output=github_output.read_text(), scan_log=scan_log.read_text() if scan_log.exists() else "", python_argv=python_argv.read_text().splitlines() if python_argv.exists() else [], + python_invocations=( + python_invocations.read_text().splitlines() if python_invocations.exists() else [] + ), ) @@ -275,3 +305,77 @@ def test_scan_target_and_output_survive_the_vex_conditional(self, tmp_path): assert "." in run.scan_argv assert "--output" in run.scan_argv assert run.scan_argv[run.scan_argv.index("--output") + 1] == "sbom.json" + + +class TestCriticalFindingsStillReachTheDashboard: + """A CRITICAL scan must upload before the risk gate fails the job. + + `aisbom scan` exits 2 on a CRITICAL finding but still writes its SBOM and + VEX documents. The entrypoint used to honour `fail-on-risk` *before* the + platform upload, so with the default `fail-on-risk: true` a repo containing + a genuinely dangerous artifact never appeared in the hosted inventory at + all — the one case where the inventory matters most, and the only case that + produces `affected` VEX statements. + """ + + def test_critical_scan_still_uploads(self, tmp_path): + run = run_entrypoint( + tmp_path, [*TOKEN_ARGS, "false"], create_sbom=True, scan_exit=2 + ) + assert run.ran_script("platform_upload.py"), ( + "the dashboard upload must run even when the scan found CRITICAL risks" + ) + + def test_critical_scan_still_fails_the_job(self, tmp_path): + """Uploading first must not weaken the branch-protection signal.""" + run = run_entrypoint( + tmp_path, [*TOKEN_ARGS, "false"], create_sbom=True, scan_exit=2 + ) + assert run.proc.returncode == 2 + + def test_critical_scan_with_fail_on_risk_off_uploads_and_exits_zero(self, tmp_path): + args = [*TOKEN_ARGS, "false"] + args[5] = "false" # fail-on-risk + run = run_entrypoint(tmp_path, args, create_sbom=True, scan_exit=2) + assert run.ran_script("platform_upload.py") + assert run.proc.returncode == 0 + + def test_pr_comment_still_precedes_the_upload(self, tmp_path): + """Ordering of the two helpers is unchanged; only the gate moved.""" + run = run_entrypoint( + tmp_path, [*TOKEN_ARGS, "false"], create_sbom=True, scan_exit=2 + ) + joined = "\n".join(run.python_invocations) + assert joined.index("post_comment.py") < joined.index("platform_upload.py") + + def test_risk_exit_wins_over_a_failed_upload(self, tmp_path): + """Both conditions at once: CRITICAL is the more important signal. + + Exit 2 (CRITICAL) takes precedence over exit 3 (upload failed) so the + user's required-checks gate reports the finding, not the plumbing. + """ + args = [*TOKEN_ARGS, "false"] + args[8] = "true" # fail-on-platform-error + run = run_entrypoint( + tmp_path, args, create_sbom=True, scan_exit=2, python_exit=3 + ) + assert run.proc.returncode == 2 + + def test_failed_upload_alone_still_fails_when_asked(self, tmp_path): + """The pre-existing fail-on-platform-error contract is preserved.""" + args = [*TOKEN_ARGS, "false"] + args[8] = "true" # fail-on-platform-error + run = run_entrypoint(tmp_path, args, create_sbom=True, python_exit=3) + assert run.proc.returncode == 3 + + def test_failed_upload_is_tolerated_by_default(self, tmp_path): + run = run_entrypoint( + tmp_path, [*TOKEN_ARGS, "false"], create_sbom=True, python_exit=3 + ) + assert run.proc.returncode == 0 + assert "tolerated" in run.proc.stdout + + def test_clean_scan_without_token_is_unaffected(self, tmp_path): + run = run_entrypoint(tmp_path, [*BASE_ARGS, "false"], create_sbom=True) + assert run.proc.returncode == 0 + assert not run.ran_script("platform_upload.py")