diff --git a/README.md b/README.md index b30a590..9115d3a 100644 --- a/README.md +++ b/README.md @@ -443,11 +443,28 @@ The PR comment shows you each scan; the hosted dashboard at [app.aisbom.io](http token: ${{ secrets.AISBOM_TOKEN }} ``` -Get a per-repo token at (sign in with GitHub). Leave `token` unset and the Action stays purely local — nothing is sent to the dashboard. +Get a per-repo token at (sign in with GitHub). Leave `token` unset and nothing is sent to the dashboard. #### Data flow & privacy -When (and only when) `token` is set, 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: the SBOM describes the *structure and findings* of your model files (names, hashes, licenses, risk levels) — never the weights or file contents, which don't leave the GitHub runner. Data is stored in the EU (Cloudflare R2/D1, EU jurisdiction). Every upload is announced in a loud log group in your CI output, so your logs always show when a network call happened and where the data went. To stop uploading, remove the `token` input — there is no background or implicit sending. +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. + +**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. + +```yaml + - uses: Lab700xOrg/aisbom@v1 + with: + directory: models/ + share: true # opt in to the public hosted viewer link +``` + +**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. + +> **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`. See [`action/README_ACTION.md`](action/README_ACTION.md) for the full inputs/outputs reference, permissions block, and troubleshooting. @@ -579,7 +596,7 @@ AIsbom collects a small amount of anonymous usage telemetry — what model forma Per `aisbom scan`: `target_type` (the **bucket**: `local` / `huggingface` / `http` / `https` — never the actual path or URL), `model_format` (the file-type bucket), `risk_level_max`, `scan_duration_ms`, `file_count`, `parse_error_count`, `strict_mode`. A `cli_scan_critical_found` event with a count is added when at least one CRITICAL is found. -If you explicitly use `--share`: the generated `sbom.json` document is uploaded to our servers and retained for 30 days to generate the shareable viewer link. That document is the **full CycloneDX SBOM** — for each scanned model it carries the file name, SHA-256 hash, detected license, and structured `aisbom:*` properties describing the file's format and scan findings (such as dangerous pickle opcodes, tensor/header metadata, model architecture details, and the assessed risk and legal status) so the hosted viewer can render per-format detail. For `hf://` scans it additionally carries the `modelCard` block described above — task, architecture, training datasets and the repo's licence/revision, all of which are already public metadata published on the model's Hugging Face page. These describe the *structure and findings* of your model files, never their weights or data. Nothing leaves your machine unless you pass `--share` and confirm the prompt (or pass `--share-yes`). A `cli_share_created` event is fired tracking whether `has_share_yes=true|false`. +If you explicitly use `--share`: the generated `sbom.json` document is uploaded to our servers and retained for 30 days to generate the shareable viewer link. That document is the **full CycloneDX SBOM** — for each scanned model it carries the file name, SHA-256 hash, detected license, and structured `aisbom:*` properties describing the file's format and scan findings (such as dangerous pickle opcodes, tensor/header metadata, model architecture details, and the assessed risk and legal status) so the hosted viewer can render per-format detail. For `hf://` scans it additionally carries the `modelCard` block described above — task, architecture, training datasets and the repo's licence/revision, all of which are already public metadata published on the model's Hugging Face page. These describe the *structure and findings* of your model files, never their weights or data. Nothing leaves your machine unless you pass `--share` and confirm the prompt (or pass `--share-yes`). Note that `AISBOM_NO_TELEMETRY=1` does **not** suppress this upload — it withholds the `cli_share_created` event only; dropping `--share` is what stops the upload. If you use the GitHub Action rather than the CLI directly, the Action passes `--share --share-yes` on your behalf when you set its `share: true` input, and passes neither otherwise. A `cli_share_created` event is fired tracking whether `has_share_yes=true|false`. Per `aisbom diff`: a `cli_diff` event with `has_drift=true|false`. diff --git a/action.yml b/action.yml index c43816b..a0692d1 100644 --- a/action.yml +++ b/action.yml @@ -44,12 +44,16 @@ inputs: description: 'Default false. When true, a failed upload fails the CI job.' required: false default: 'false' + share: + description: 'Default false. When true, the generated SBOM is uploaded to aisbom.io to mint a publicly-readable hosted viewer link, retained 30 days, and that link is added to the PR comment and exposed as the `share-url` output. Leave unset and no request is made to aisbom.io.' + required: false + default: 'false' outputs: sbom-path: description: 'Path to the generated SBOM JSON file (relative to the workspace).' share-url: - description: 'Hosted viewer URL for the scanned SBOM (empty if --share was rejected or upload failed).' + description: 'Hosted viewer URL for the scanned SBOM. Empty unless `share: true` is set (and empty if that upload failed).' runs: using: 'docker' @@ -70,3 +74,5 @@ runs: - ${{ inputs.token }} - ${{ inputs.platform-url }} - ${{ inputs.fail-on-platform-error }} + # Appended, not inserted: argv positions 1-9 are load-bearing and must not shift. + - ${{ inputs.share }} diff --git a/action/README_ACTION.md b/action/README_ACTION.md index d0488cf..aeaed6b 100644 --- a/action/README_ACTION.md +++ b/action/README_ACTION.md @@ -33,13 +33,14 @@ jobs: That's it. The Action will: 1. Scan the `models/` directory in your PR head. -2. Upload the resulting SBOM to `aisbom.io` (via the existing `--share` flow) for a hosted viewer URL. -3. Post or update a markdown comment on the PR summarizing findings and linking to the viewer. -4. Fail the job (exit 2) if any CRITICAL findings were detected, unless you set `fail-on-risk: false`. +2. Post or update a markdown comment on the PR summarizing findings, rendered from the SBOM on the runner. +3. Fail the job (exit 2) if any CRITICAL findings were detected, unless you set `fail-on-risk: false`. + +On that default, **your SBOM is not uploaded anywhere** — the scan, the SBOM and the comment are all produced on the runner. Two inputs, both off by default, send it somewhere: `share:` uploads it to `aisbom.io` for a public viewer link, and `token:` posts it to your dashboard at `app.aisbom.io`. Anonymous telemetry — event names and severity counts, never the SBOM — is separate and on by default. See [Data flow & privacy](#data-flow--privacy). ## What the PR comment looks like -A two-section panel with the highest-risk artifacts and a viewer link: +A two-section panel with the highest-risk artifacts. The viewer link shown below appears only when `share: true` is set; by default the comment is identical minus that line: ``` 🛡️ AIsbom Security Scan @@ -72,13 +73,14 @@ When there are no CRITICAL or HIGH findings, the comment collapses to a one-line | `token` | _(empty)_ | 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 . | | `platform-url` | `https://app.aisbom.io` | Override for the platform webhook URL. Only meaningful when `token` is set. | | `fail-on-platform-error` | `false` | Default false. When true, a failed upload fails the CI job. | +| `share` | `false` | Upload the SBOM to `aisbom.io` for a **publicly-readable** hosted viewer link, retained 30 days, added to the PR comment and exposed as `share-url`. Off by default: leave it unset and no request reaches `aisbom.io`. | ## Outputs | Name | Description | |---|---| | `sbom-path` | Path to the generated SBOM (default `sbom.json`). | -| `share-url` | Hosted viewer URL for the SBOM (empty if upload was skipped or failed). | +| `share-url` | Hosted viewer URL for the SBOM. **Empty unless you set `share: true`** (and empty if that upload failed). | Example downstream usage: @@ -87,10 +89,13 @@ Example downstream usage: id: aisbom with: directory: models/ + share: true # required — without it share-url is empty - name: Echo viewer link run: echo "SBOM at ${{ steps.aisbom.outputs.share-url }}" ``` +The `sbom-path` output needs no opt-in — the SBOM is always written to the workspace, so uploading a build artifact or running your own tooling over it works with sharing off. + ## Permissions The minimum needed by the consuming workflow: @@ -107,15 +112,17 @@ If you omit `pull-requests: write`, the scan still runs and the SBOM is still pr The Action embeds a hidden `` marker in the comment body. On every re-run, it finds the existing comment by that marker and updates it in place — you'll never see stacked AIsbom comments on the same PR. Only the **first** post triggers a PR notification; subsequent updates are silent (GitHub's API treats `edit` differently from `create`). -## Privacy +## Data flow & privacy + +Scans run inside the Action container; the model files themselves never leave the GitHub runner. Three things can be sent over the wire, each with its own switch: -Scans run inside the Action container; the model files themselves never leave the GitHub runner. Three things can be sent over the wire: +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. +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. -1. **SBOM upload (`--share`):** the rendered CycloneDX JSON is POSTed to `aisbom.io/api/sbom-share` so the comment can link to a hosted viewer. The SBOM is publicly viewable to anyone with the URL and expires after 30 days. The unguessable 12-char URL token is the only access control. -2. **Hosted dashboard upload (opt-in via `token`):** when you set the `token` input, 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 (or the input) to stop. Without a token, nothing is sent to the dashboard. -3. **Anonymous telemetry:** two events (`github_action_run` and `github_action_comment_posted`) are POSTed to `api.aisbom.io/v1/telemetry`. No repo identifier, no file paths, no findings content — just severity buckets and whether the comment was created vs updated. +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. -To disable 1 and 3, set `AISBOM_NO_TELEMETRY=1` in your workflow's `env:` block. The Action still posts the comment and produces the SBOM; the share upload and telemetry pings are skipped. The dashboard upload (2) is controlled solely by whether `token` is set. +`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. ## Troubleshooting diff --git a/action/entrypoint.sh b/action/entrypoint.sh index 69571e1..5e3aca0 100755 --- a/action/entrypoint.sh +++ b/action/entrypoint.sh @@ -15,6 +15,7 @@ # $7 token (optional — opt-in for platform upload) # $8 platform-url (optional override; blank → default in helper) # $9 fail-on-platform-error (default "false") +# $10 share (default "false" — opt-in hosted share link) # # Bash (not POSIX sh) is required for the PIPESTATUS array — we need the # scan's exit code, not tee's, to honor fail-on-risk correctly. @@ -38,34 +39,56 @@ FAIL_ON_RISK="${6:-true}" INPUT_TOKEN="${7:-}" INPUT_PLATFORM_URL="${8:-}" INPUT_FAIL_ON_PLATFORM_ERROR="${9:-false}" +INPUT_SHARE="${10:-false}" # Pass the token through to post_comment.py via a clean underscore-only env # var. We never echo $GH_TOKEN — GitHub already masks it in the docker-run # command log, but using a properly-named env var keeps secret hygiene easy. export AISBOM_GITHUB_TOKEN="${GH_TOKEN}" -SCAN_LOG="/tmp/aisbom-scan.log" +# Overridable so the regression suite can run this script without racing on a +# fixed /tmp path. Unset in the Docker image, which is the only place it runs +# for real. +SCAN_LOG="${AISBOM_SCAN_LOG:-/tmp/aisbom-scan.log}" + +# Step 1 — Run the scan. Sharing is OPT-IN: `--share --share-yes` uploads the +# SBOM to aisbom.io and mints a publicly-readable 30-day viewer link, so it is +# 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. +SHARE_ARGS=() +if [ "${INPUT_SHARE}" = "true" ]; then + SHARE_ARGS=(--share --share-yes) +else + # Deliberately scoped to the SBOM. Anonymous telemetry is default-on and + # goes to api.aisbom.io, so a blanket "nothing is sent" would be false — + # the exact kind of overclaim this input exists to correct. + echo "[aisbom-action] Sharing is off (share: false, the default): the SBOM is not uploaded to aisbom.io and the share-url output will be empty. Set share: true to publish a hosted viewer link. (Anonymous telemetry is separate and still on; set AISBOM_NO_TELEMETRY=1 to disable it.)" +fi -# Step 1 — Run the scan. `--share --share-yes` uploads the SBOM and emits -# a viewer URL we can grep out for the PR comment. echo "::group::aisbom scan output" set -o pipefail +# `${SHARE_ARGS[@]+"${SHARE_ARGS[@]}"}` — expanding an empty array under +# `set -u` is an unbound-variable error on bash < 4.4; this form yields no +# words at all when the array is empty. aisbom scan "${DIRECTORY}" \ --output "${OUTPUT_FILE}" \ - --share \ - --share-yes \ + ${SHARE_ARGS[@]+"${SHARE_ARGS[@]}"} \ 2>&1 | tee "${SCAN_LOG}" SCAN_EXIT=${PIPESTATUS[0]} set +o pipefail echo "::endgroup::" # Echo Action outputs so consumers can reference them in subsequent steps. +# `share-url` is always written — empty when sharing is off — so consumers read +# an empty string rather than an unset output. if [ -n "${GITHUB_OUTPUT:-}" ]; then echo "sbom-path=${OUTPUT_FILE}" >> "${GITHUB_OUTPUT}" - SHARE_URL=$(grep -oE 'https://aisbom\.io/viewer\?h=[A-Za-z0-9_-]+' "${SCAN_LOG}" | head -n1 || true) - if [ -n "${SHARE_URL}" ]; then - echo "share-url=${SHARE_URL}" >> "${GITHUB_OUTPUT}" + SHARE_URL="" + if [ "${INPUT_SHARE}" = "true" ]; then + SHARE_URL=$(grep -oE 'https://aisbom\.io/viewer\?h=[A-Za-z0-9_-]+' "${SCAN_LOG}" | head -n1 || true) fi + echo "share-url=${SHARE_URL}" >> "${GITHUB_OUTPUT}" fi # Step 2 — Post the PR comment. Only run if the scan actually produced @@ -77,6 +100,7 @@ if [ -f "${OUTPUT_FILE}" ]; then --max-rows "${MAX_ROWS}" \ --comment-on-clean "${COMMENT_ON_CLEAN}" \ --directory "${DIRECTORY}" \ + --share-enabled "${INPUT_SHARE}" \ || echo "[aisbom-action] post_comment.py errored; SBOM artifact still produced." else echo "[aisbom-action] No SBOM file at ${OUTPUT_FILE}; skipping PR comment." diff --git a/action/post_comment.py b/action/post_comment.py index 3c7d656..7eb28c1 100644 --- a/action/post_comment.py +++ b/action/post_comment.py @@ -2,10 +2,15 @@ """ Phase 4.5 — Post or update an idempotent PR comment with AIsbom findings. -Runs inside the Action Docker container as the second step of entrypoint.sh -(after `aisbom scan ... --share --share-yes`). Reads the rendered SBOM and -the scan log, builds a markdown comment, and either creates a new comment or -updates the existing one identified by the hidden marker. +Runs inside the Action Docker container as the second step of entrypoint.sh, +after the scan. Reads the rendered SBOM and the scan log, builds a markdown +comment, and either creates a new comment or updates the existing one +identified by the hidden marker. + +The comment renders entirely from the local SBOM; the hosted viewer link is +an optional extra, present only when the user opted into sharing (`share: +true`, which makes entrypoint.sh pass `--share --share-yes`). With sharing +off — the default — `parse_share_url` returns None and the link is omitted. Telemetry is fire-and-forget against api.aisbom.io/v1/telemetry and honors AISBOM_NO_TELEMETRY just like the CLI. Never raises from telemetry; never @@ -311,6 +316,10 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: help='"true" or "false" — post a comment when no findings') p.add_argument("--directory", default=".", help="The scanned directory (shown in the comment footer)") + p.add_argument("--share-enabled", default="false", + help='"true" or "false" — whether the user opted into ' + "sharing. When false, no viewer link is put in the " + "comment, whatever the scan log happens to contain.") return p.parse_args(argv) @@ -348,12 +357,19 @@ def main(argv: list[str] | None = None) -> int: total_components = len(sbom.get("components", [])) # Parse share URL from scan log (best-effort; missing = no link in comment). - try: - with open(args.scan_log) as fh: - scan_log = fh.read() - except OSError: - scan_log = "" - share_url = parse_share_url(scan_log) + # Only when the user opted in: the log is scraped with a URL-shaped regex, + # and a scan target or filename can be URL-shaped too, so an ungated scrape + # could put a link in the comment on a run that shared nothing. Gate on the + # input, so `share: false` means no viewer link by construction rather than + # by the log happening not to match. + share_url = None + if args.share_enabled == "true": + try: + with open(args.scan_log) as fh: + scan_log = fh.read() + except OSError: + scan_log = "" + share_url = parse_share_url(scan_log) severity = max_severity(findings) is_clean = severity == "CLEAN" diff --git a/pyproject.toml b/pyproject.toml index 70e5dac..b8ecb6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "aisbom-cli" -version = "1.3.3" +version = "1.4.0" description = "An AI Supply Chain security tool that that detects Pickle bombs and generates CycloneDX SBOMs for Machine Learning models." authors = ["Ajoy L "] readme = "README.md" diff --git a/tests/test_action_entrypoint.py b/tests/test_action_entrypoint.py new file mode 100644 index 0000000..b6de34f --- /dev/null +++ b/tests/test_action_entrypoint.py @@ -0,0 +1,222 @@ +""" +Behavioural regression tests for `action/entrypoint.sh`. + +These execute the real shell script with a stubbed `aisbom` on PATH that +records the argv it was handed. That matters: the property under test is +"the default Action run makes no request to aisbom.io", and the only honest +way to assert it is to observe what the scan was actually invoked with, +rather than to grep the entrypoint source for a flag. A future edit that +re-introduces `--share` by another route still fails these tests. + +Sharing is opt-in (`share: false` by default). Everything else the Action +does — the SBOM artifact, the PR comment, fail-on-risk, the platform upload +— is independent of it. +""" +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +ENTRYPOINT = ROOT / "action" / "entrypoint.sh" + +VIEWER_URL = "https://aisbom.io/viewer?h=StubShareId1" + +# Argv positions 1-9 as action.yml passes them; `share` is appended as $10. +BASE_ARGS = [ + ".", # $1 directory + "sbom.json", # $2 output-file + "gh-token", # $3 github-token + "10", # $4 max-rows + "true", # $5 comment-on-clean + "true", # $6 fail-on-risk + "", # $7 token + "", # $8 platform-url + "false", # $9 fail-on-platform-error +] + +# 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_STUB = """#!/bin/bash +printf '%s\\n' "$@" > "${AISBOM_ARGV_FILE}" +echo "AIsbom scanning ${2:-.}" +for arg in "$@"; do + if [ "${arg}" = "--share" ]; then + echo "Shareable link: __VIEWER_URL__" + fi +done +exit 0 +""" + +# Stands in for `python /aisbom-action/post_comment.py`, which only exists +# inside the Docker image. +PYTHON_STUB = """#!/bin/bash +printf '%s\\n' "$@" > "${PYTHON_ARGV_FILE}" +exit 0 +""" + + +class EntrypointRun: + """Captured result of one entrypoint.sh invocation.""" + + def __init__(self, proc, scan_argv, github_output, scan_log, python_argv): + self.proc = proc + self.scan_argv = scan_argv + self.github_output = github_output + self.scan_log = scan_log + self.python_argv = python_argv + + @property + def outputs(self) -> dict[str, str]: + """GITHUB_OUTPUT parsed into a dict. Absent key != empty value.""" + out = {} + for line in self.github_output.splitlines(): + if "=" in line: + key, _, value = line.partition("=") + out[key] = value + return out + + +def _write_stub(path: Path, body: str) -> None: + path.write_text(body.replace("__VIEWER_URL__", VIEWER_URL)) + path.chmod(0o755) + + +def run_entrypoint(tmp_path: Path, args: list[str], *, create_sbom: bool = False) -> EntrypointRun: + """Execute entrypoint.sh with stubbed `aisbom` and `python` on PATH.""" + bindir = tmp_path / "bin" + bindir.mkdir(parents=True) + _write_stub(bindir / "aisbom", AISBOM_STUB) + _write_stub(bindir / "python", PYTHON_STUB) + + workspace = tmp_path / "workspace" + workspace.mkdir() + if create_sbom: + (workspace / "sbom.json").write_text('{"components": []}') + + scan_argv = tmp_path / "scan-argv.txt" + python_argv = tmp_path / "python-argv.txt" + github_output = tmp_path / "github-output.txt" + scan_log = tmp_path / "scan.log" + github_output.touch() + + env = { + **os.environ, + "PATH": f"{bindir}{os.pathsep}{os.environ['PATH']}", + "AISBOM_ARGV_FILE": str(scan_argv), + "PYTHON_ARGV_FILE": str(python_argv), + "GITHUB_OUTPUT": str(github_output), + "AISBOM_SCAN_LOG": str(scan_log), + } + + proc = subprocess.run( + ["bash", str(ENTRYPOINT), *args], + cwd=workspace, + env=env, + capture_output=True, + text=True, + ) + return EntrypointRun( + proc=proc, + scan_argv=scan_argv.read_text().splitlines() if scan_argv.exists() else [], + 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 [], + ) + + +class TestSharingIsOptIn: + """The default Action run must not publish the SBOM to aisbom.io.""" + + def test_default_run_passes_no_share_flags(self, tmp_path): + run = run_entrypoint(tmp_path, [*BASE_ARGS, "false"]) + assert run.proc.returncode == 0 + assert run.scan_argv, "stub aisbom was never invoked" + assert "--share" not in run.scan_argv + assert "--share-yes" not in run.scan_argv + + def test_share_omitted_entirely_still_defaults_off(self, tmp_path): + """An old caller passing only nine args must not start publishing.""" + run = run_entrypoint(tmp_path, BASE_ARGS) + assert run.proc.returncode == 0 + assert "--share" not in run.scan_argv + assert "--share-yes" not in run.scan_argv + + def test_default_run_contacts_no_share_endpoint(self, tmp_path): + """Asserted on the scan log, not by inspecting the entrypoint source.""" + run = run_entrypoint(tmp_path, [*BASE_ARGS, "false"]) + # Guard against passing vacuously on an empty/missing log. + assert run.scan_log.strip(), "scan log was not captured" + assert "aisbom.io" not in run.scan_log + assert "viewer?h=" not in run.scan_log + + def test_default_run_announces_that_sharing_is_off(self, tmp_path): + run = run_entrypoint(tmp_path, [*BASE_ARGS, "false"]) + assert "Sharing is off" in run.proc.stdout + assert "share: true" in run.proc.stdout + + @pytest.mark.parametrize("value", ["", "TRUE", "True", "yes", "1", "no"]) + def test_only_exact_true_enables_sharing(self, tmp_path, value): + """Anything but the literal "true" leaves publishing off.""" + run = run_entrypoint(tmp_path, [*BASE_ARGS, value]) + assert "--share" not in run.scan_argv + + def test_share_true_passes_both_flags(self, tmp_path): + run = run_entrypoint(tmp_path, [*BASE_ARGS, "true"]) + assert "--share" in run.scan_argv + assert "--share-yes" in run.scan_argv + + def test_scan_target_and_output_survive_the_conditional(self, tmp_path): + """The empty-array expansion must not eat or reorder the real args.""" + for share in ("false", "true"): + run = run_entrypoint(tmp_path / share, [*BASE_ARGS, share]) + assert run.scan_argv[:2] == ["scan", "."] + assert "--output" in run.scan_argv + assert run.scan_argv[run.scan_argv.index("--output") + 1] == "sbom.json" + + +class TestShareUrlOutput: + """`share-url` is empty, not absent and not an error, when sharing is off.""" + + def test_share_url_written_but_empty_when_off(self, tmp_path): + run = run_entrypoint(tmp_path, [*BASE_ARGS, "false"]) + assert "share-url" in run.outputs + assert run.outputs["share-url"] == "" + + def test_share_url_populated_when_on(self, tmp_path): + run = run_entrypoint(tmp_path, [*BASE_ARGS, "true"]) + assert run.outputs["share-url"] == VIEWER_URL + + def test_share_url_stays_empty_even_if_log_carries_a_url(self, tmp_path): + """Belt and braces: the guard is the input, not just the log contents.""" + run = run_entrypoint(tmp_path, [*BASE_ARGS, "false"]) + # Stub prints no URL without --share, so the log is clean; the assertion + # that matters is that the output is keyed off the input either way. + assert run.outputs["share-url"] == "" + + +class TestUnaffectedByShareSetting: + """sbom-path and the PR comment behave identically with sharing off.""" + + def test_sbom_path_output_unaffected(self, tmp_path): + for share in ("false", "true"): + run = run_entrypoint(tmp_path / share, [*BASE_ARGS, share]) + assert run.outputs["sbom-path"] == "sbom.json" + + def test_pr_comment_still_runs_when_sharing_is_off(self, tmp_path): + run = run_entrypoint(tmp_path, [*BASE_ARGS, "false"], create_sbom=True) + assert run.python_argv, "post_comment.py was not invoked" + assert "/aisbom-action/post_comment.py" in run.python_argv + assert "--sbom" in run.python_argv + + @pytest.mark.parametrize("share,expected", [("false", "false"), ("true", "true")]) + def test_share_setting_is_forwarded_to_the_comment_renderer( + self, tmp_path, share, expected + ): + """The comment must not re-derive sharing from the log on its own.""" + 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 diff --git a/tests/test_action_wrapper.py b/tests/test_action_wrapper.py index e4ddbb3..5319d2d 100644 --- a/tests/test_action_wrapper.py +++ b/tests/test_action_wrapper.py @@ -13,6 +13,8 @@ """ from __future__ import annotations +import json +import os import sys from pathlib import Path from unittest.mock import MagicMock, patch @@ -27,7 +29,9 @@ MARKER, collect_findings, count_by_severity, + main, max_severity, + parse_args, parse_share_url, post_or_update_comment, render_body, @@ -329,3 +333,69 @@ def test_does_not_hijack_unrelated_marker_lookalikes(self): assert action == "created" pr.create_issue_comment.assert_called_once() comments[0].edit.assert_not_called() + + +class TestShareGateInMain: + """`share: false` must mean no viewer link, whatever the scan log holds. + + The share URL is recovered by scraping the scan log with a URL-shaped + regex, and a scan target or filename can be URL-shaped too. Gating that + scrape on the input — rather than on the log failing to match — is what + makes the documented opt-out hold by construction. + """ + + # Deliberately URL-shaped, and not a link this run minted. + DECOY_LOG = "Scanning https://aisbom.io/viewer?h=UnrelatedTarget\n" + + def _run_main(self, tmp_path, share_enabled, scan_log): + sbom = tmp_path / "sbom.json" + sbom.write_text(json.dumps(SBOM_CRITICAL)) + log = tmp_path / "scan.log" + log.write_text(scan_log) + + captured = {} + + def _capture(body, token, repo, pr_number): + captured["body"] = body + return "created" + + env = { + "AISBOM_GITHUB_TOKEN": "t", + "GITHUB_REPOSITORY": "owner/repo", + "AISBOM_NO_TELEMETRY": "1", + } + with patch.dict(os.environ, env, clear=False), \ + patch("action.post_comment._resolve_pr_number_from_event", return_value=42), \ + patch("action.post_comment.post_or_update_comment", side_effect=_capture): + rc = main([ + "--sbom", str(sbom), + "--scan-log", str(log), + "--share-enabled", share_enabled, + ]) + assert rc == 0 + return captured["body"] + + def test_no_viewer_link_when_sharing_is_off(self, tmp_path): + body = self._run_main(tmp_path, "false", self.DECOY_LOG) + assert "aisbom.io/viewer" not in body + assert "View full SBOM in viewer" not in body + + def test_findings_table_still_rendered_when_sharing_is_off(self, tmp_path): + """Suppressing the link must not suppress the comment's substance.""" + body = self._run_main(tmp_path, "false", self.DECOY_LOG) + assert "evil.pt" in body + assert "CRITICAL" in body + + def test_viewer_link_present_when_sharing_is_on(self, tmp_path): + log = "Share Link Created: https://aisbom.io/viewer?h=Kx9pQ2v3mLnB\n" + body = self._run_main(tmp_path, "true", log) + assert "https://aisbom.io/viewer?h=Kx9pQ2v3mLnB&ref=action" in body + + def test_defaults_to_off_when_flag_omitted(self, tmp_path): + """A caller that forgets the flag must not start leaking links.""" + sbom = tmp_path / "sbom.json" + sbom.write_text(json.dumps(SBOM_CRITICAL)) + log = tmp_path / "scan.log" + log.write_text(self.DECOY_LOG) + args = parse_args(["--sbom", str(sbom), "--scan-log", str(log)]) + assert args.share_enabled == "false"