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/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 5e3aca0..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). @@ -56,6 +60,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 +89,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]} @@ -106,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="" @@ -133,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 @@ -140,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/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..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 [] + ), ) @@ -220,3 +250,132 @@ 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" + + +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") 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