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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,11 @@ Get a per-repo token at <https://app.aisbom.io/connect> (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.

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 Update the action privacy reference for VEX uploads

The new disclosure conflicts with the linked full Action documentation: action/README_ACTION.md:120-123 still says the dashboard receives the same CycloneDX JSON and that both upload payloads contain only the SBOM. Users relying on that privacy reference are therefore not told that setting token now generates and transmits two additional documents; update that document alongside this disclosure.

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 5c7cd85 — correct, and I had missed that file entirely.

action/README_ACTION.md is the linked full Action reference, so leaving it saying the dashboard receives "the same CycloneDX JSON" and that both upload paths carry only the SBOM would have meant the more detailed of the two privacy documents was the wrong one. Updated to match the root README: what the VEX documents are, that they are derived from findings already in the SBOM and add no new information about the user files, the vex-documents=N log line, and that no token means no --vex and no files written into the workspace. The payload-summary sentence at the end of that section is corrected too.


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.

Expand All @@ -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`.

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. 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:
Expand Down
4 changes: 3 additions & 1 deletion action/README_ACTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
53 changes: 42 additions & 11 deletions action/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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)
Comment on lines +71 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Upload results before enforcing the risk exit

When a token is set and the scan finds a CRITICAL artifact under the default fail-on-risk: true, the CLI still writes the SBOM and VEX files but exits 2 (aisbom/cli.py:637-642). The entrypoint then exits at action/entrypoint.sh:124-127, before the platform uploader at lines 133-146, so the affected VEX statements this change is intended to deliver never reach the dashboard. Move the upload ahead of the risk gate while preserving the final exit status.

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 5c7cd85. This was the best catch of the review — and worse than described.

The bug predates this PR and applied to the SBOM upload as a whole, not just VEX: with fail-on-risk: true (the default), any connected repo whose scan found a CRITICAL artifact exited before the uploader and never appeared in the hosted inventory at all. The inventory has been silently omitting exactly the repos that most needed to be in it.

It also defeats this PR specifically, which is what makes it belong here rather than in a follow-up: 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. Where the two gates can now both apply — a CRITICAL scan whose upload also failed — exit 2 wins over exit 3, on the reasoning that the required check should report the dangerous model rather than the plumbing; the header exit-code table records that precedence.

Asserted by driving the real script, not by reading it: the entrypoint harness gained scan_exit / python_exit knobs and an ordered record of helper invocations, and eight tests now cover upload-on-critical, the preserved exit 2, fail-on-risk off, helper ordering, both precedence cases, and the untouched no-token path.

fi

SHARE_ARGS=()
if [ "${INPUT_SHARE}" = "true" ]; then
SHARE_ARGS=(--share --share-yes)
Expand All @@ -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]}
Expand Down Expand Up @@ -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=""
Expand All @@ -133,15 +152,27 @@ 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
# the job on any helper crash", which contradicts the documented contract.
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
93 changes: 89 additions & 4 deletions action/platform_upload.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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,
Expand Down
Loading