From 818ef04a59dababda4d1b198392198cd52a05b50 Mon Sep 17 00:00:00 2001 From: vivekchand Date: Tue, 8 Sep 2026 21:45:23 +0000 Subject: [PATCH 1/4] Harden CI: run an Actions-security scanner on this repo's own workflows Both private repos in this family run zizmor over their workflows and composite actions on every PR and nightly (clawmetry-cloud security-audit.yml, clawmetry-pro #234). This repo -- 37 workflows and a composite action, the most of the three, and the only public one -- was running no Actions-security scanner at all. The Scorecard job in this same workflow covers part of the ground (Dangerous-Workflow, Token-Permissions), but it is `if: github.event_name != 'pull_request'`, so it never runs on the PR that introduces a problem, and it grades the repository rather than naming a file and a line. Ported from the clawmetry-cloud step, including the discipline that step was built around: a scan that covered nothing must not read as a clean result. - `--no-exit-codes` so FINDINGS exit 0. Without it a findings exit and a crash are the same non-zero, and the usual `|| true` launders a crash -- which writes no json at all -- into an empty file that renders as clean. - Audit composite actions too, named individually. A composite action's steps run inline in the calling job with that job's token and secrets, so it carries the same rule families; naming them individually rather than passing the directory keeps a stray non-action YAML from aborting the audit later. - Record the input list and count. A finding list is identical whether it audited 38 files or none, so coverage is the one thing the report cannot tell you afterwards. - Fail the job ONLY on a scanner outage (no usable report, or an empty input set). Findings are reported in the step summary and the artifact, not gated. Findings are deliberately non-blocking. Today's tree has 42 findings, 5 of them High, and the notable ones are load-bearing by design: the release, auto-quarantine and cloud-pin jobs persist their checkout credential because they push with it, and the `workflow_run` triggers are how those workflows are supposed to fire. A gate that failed on those would be switched off within a week. The summary table is what makes a NEW finding visible in the PR that adds it. Test plan - yaml.safe_load over all 38 workflow files -> parse - bash -n on all three new run blocks -> parse; embedded Python compiles - Ran the audit step against this repo: discovers 38 inputs (37 workflows + setup-openclaw), writes a 42-finding report, exit 0, gate green. Summary renders 5 High / 3 Medium / 9 Low / 25 Informational, broken down by rule. - Negative test, scanner outage: stub zizmor exiting 2 with no json. Step still exits 0 (recorded, not raised) so the artifact uploads, summary renders SCAN FAILED, and the gate fails the job. No-PRD: CI-only change, confined to .github/workflows/. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019q6JAQni1nbx9pqSVA5oj8 --- .github/workflows/supply-chain.yml | 151 +++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml index aeffe02161..a189c0212f 100644 --- a/.github/workflows/supply-chain.yml +++ b/.github/workflows/supply-chain.yml @@ -175,6 +175,157 @@ jobs: GITHUB_TOKEN: ${{ github.token }} run: python3 scripts/check_action_refs.py + actions-security: + name: GitHub Actions security (zizmor) + runs-on: ubuntu-latest + # CI is part of the supply chain: a workflow step runs with this repo's + # token and secrets, and its output is packaged into the wheel PyPI + # serves. Both private repos in this family (clawmetry-cloud, + # clawmetry-pro) have run zizmor on every PR for weeks; this repo, with + # the most workflows of the three and the only public attack surface, + # was the one running no Actions-security scanner at all. Scorecard's + # `Dangerous-Workflow` and `Token-Permissions` probes below cover part + # of the same ground, but they run only on push (`if:` above) and score + # the repository rather than naming the file and line. + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + - run: python3 -m pip install --quiet zizmor + + - name: Audit every workflow and composite action + env: + # zizmor resolves action tags through the GitHub API. Unauthenticated, + # those calls share the runner's IP rate limit and fail intermittently + # — which, because a reachability failure aborts the WHOLE audit, is + # the difference between a scan and no scan. Read-only; the job's + # `contents: read` default is enough to read public action repos. + GH_TOKEN: ${{ github.token }} + run: | + mkdir -p audit + # A composite action under .github/actions/ is not a bystander: its + # steps run inline in the calling job, with that job's token and + # secrets, so it carries the same rule families as a workflow. + # Named individually rather than by directory — pointing zizmor at a + # directory hands it every YAML inside, and a non-action YAML landing + # there later would abort the audit rather than be skipped. + inputs=".github/workflows" + if [ -d .github/actions ]; then + while IFS= read -r a; do + inputs="$inputs $a" + done < <(find .github/actions -type f \ + \( -name 'action.yml' -o -name 'action.yaml' \) | sort) + fi + # Record the file set the scanner was handed. A finding list is + # identical whether it audited thirty-seven files or none, so + # coverage is the one thing the JSON cannot tell you afterwards. + # shellcheck disable=SC2086 + find $inputs -type f \( -name '*.yml' -o -name '*.yaml' \) \ + | sort > audit/zizmor-inputs.txt + n_inputs=$(wc -l < audit/zizmor-inputs.txt | tr -d '[:space:]') + echo "$n_inputs" > audit/zizmor-input-count.txt + echo "zizmor inputs ($n_inputs file(s)):" + sed 's/^/ /' audit/zizmor-inputs.txt + + # `--no-exit-codes` makes FINDINGS exit 0, so a non-zero exit here + # means only "the tool failed". Without it the two cases are + # indistinguishable, and the usual `|| true` launders a crash — which + # writes NO json — into an empty file that reads as a clean scan. + rc=0 + # shellcheck disable=SC2086 + zizmor --no-exit-codes --format json --persona regular $inputs \ + > audit/zizmor.json 2> audit/zizmor.err || rc=$? + # An empty input set would let zizmor exit 0 having audited nothing: + # the same "clean scan" impostor as a crash. + if [ "$n_inputs" -eq 0 ]; then + echo "zizmor was handed no files to audit" >> audit/zizmor.err + if [ "$rc" -eq 0 ]; then rc=1; fi + fi + # A crash leaves no json at all, so reject anything that does not + # parse as a list. + if ! python3 -c "import json; d = json.load(open('audit/zizmor.json')); assert isinstance(d, list); print(f'zizmor: {len(d)} finding(s)')" 2>/dev/null; then + if [ "$rc" -eq 0 ]; then rc=1; fi + fi + if [ "$rc" -ne 0 ]; then + # Recorded, not raised: the artifact still has to upload, and the + # gate at the end of the job is what turns this red — once there + # is something to diagnose it from. + echo "$rc" > audit/zizmor.failed + echo "::error::zizmor did not complete (exit $rc); this is a scanner outage, not a clean scan" + sed 's/^/ /' audit/zizmor.err || true + fi + + # Findings are reported, not gated. Today's tree has findings that are + # load-bearing by design — the release, quarantine and cloud-pin jobs + # persist their checkout credential BECAUSE they push with it — and a + # gate that fails on those would be turned off within a week. The + # summary is what makes a NEW finding visible in the PR that adds it. + - name: Summarise findings + if: always() + run: | + python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY" + import collections, json, pathlib + p = pathlib.Path("audit/zizmor.json") + n = pathlib.Path("audit/zizmor-input-count.txt") + print("### GitHub Actions security (zizmor)\n") + if pathlib.Path("audit/zizmor.failed").exists() or not p.exists(): + print("**SCAN FAILED** — see the `actions-security-audit` artifact.") + raise SystemExit(0) + try: + findings = json.loads(p.read_text()) + except Exception as exc: + print(f"**SCAN FAILED** — unreadable report ({exc}).") + raise SystemExit(0) + covered = n.read_text().strip() if n.exists() else "?" + print(f"{len(findings)} finding(s) across {covered} audited file(s).\n") + if not findings: + raise SystemExit(0) + rank = {"High": 0, "Medium": 1, "Low": 2, "Informational": 3} + counts = collections.Counter( + (f.get("determinations", {}).get("severity") or "?") for f in findings + ) + print("| Severity | Findings |") + print("| --- | ---: |") + for sev, c in sorted(counts.items(), key=lambda kv: rank.get(kv[0], 9)): + print(f"| {sev} | {c} |") + print() + by_rule = collections.Counter( + ( + (f.get("determinations", {}).get("severity") or "?"), + (f.get("ident") or "?"), + ) + for f in findings + ) + print("| Severity | Rule | Findings |") + print("| --- | --- | ---: |") + for (sev, rule), c in sorted( + by_rule.items(), key=lambda kv: (rank.get(kv[0][0], 9), kv[0][1]) + ): + print(f"| {sev} | `{rule}` | {c} |") + PY + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: actions-security-audit + path: audit/ + retention-days: 30 + + # The only red condition: the scanner did not run. A silent outage is + # the failure mode this whole job exists to avoid, because it looks + # exactly like a clean result. + - name: Fail if the scanner did not run + if: always() + run: | + if [ -f audit/zizmor.failed ]; then + echo "zizmor did not produce a usable report; treating as a failed scan." + exit 1 + fi + echo "zizmor completed." + scorecard: name: OpenSSF Scorecard runs-on: ubuntu-latest From 845c79aea17577fc2330baa93b895d3d41b56f77 Mon Sep 17 00:00:00 2001 From: vivekchand Date: Tue, 8 Sep 2026 21:48:25 +0000 Subject: [PATCH 2/4] Narrow the scan to workflow definitions, the scope the blueprint describes Drift Bot flagged the composite-action half of this change: the SecurityAuditScanner component describes scanning "workflow definitions themselves", and .github/actions/ is a scope no product record covers. That is a fair finding, and it applies to a separable slice rather than to the change as a whole -- so this narrows the input set instead of arguing with the gate. Coverage goes from 0 files to 37, which is the whole point of the PR; the composite action is left explicitly uncovered, with a comment saying so and why, rather than silently dropped. Widening the input set to include it is one line, and it is worth doing -- its steps run inline in the calling job with that job's token and secrets, so nothing scans the one place a workflow's privileges are borrowed. But that is a scope change that should arrive with its product record, not as a side effect of turning the scanner on. Baseline moves with the scope: 37 inputs, 40 findings (3 High / 3 Medium / 9 Low / 25 Informational). The two High `github-env` findings were in the composite action and are no longer reported. Re-verified after the change - yaml.safe_load over all 37 workflow files -> parse - bash -n on all three run blocks -> parse - Audit step against this repo: 37 inputs, 40-finding report, exit 0, gate green, summary renders the tables above - Negative test, scanner outage: stub zizmor exiting 2 with no json -> step exits 0, zizmor.failed written, gate exits 1 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019q6JAQni1nbx9pqSVA5oj8 --- .github/workflows/supply-chain.yml | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml index a189c0212f..24cdf1f09a 100644 --- a/.github/workflows/supply-chain.yml +++ b/.github/workflows/supply-chain.yml @@ -182,8 +182,8 @@ jobs: # token and secrets, and its output is packaged into the wheel PyPI # serves. Both private repos in this family (clawmetry-cloud, # clawmetry-pro) have run zizmor on every PR for weeks; this repo, with - # the most workflows of the three and the only public attack surface, - # was the one running no Actions-security scanner at all. Scorecard's + # the most workflow definitions of the three and the only public attack + # surface, was the one running no Actions-security scanner. Scorecard's # `Dangerous-Workflow` and `Token-Permissions` probes below cover part # of the same ground, but they run only on push (`if:` above) and score # the repository rather than naming the file and line. @@ -196,7 +196,7 @@ jobs: python-version: "3.11" - run: python3 -m pip install --quiet zizmor - - name: Audit every workflow and composite action + - name: Audit every workflow definition env: # zizmor resolves action tags through the GitHub API. Unauthenticated, # those calls share the runner's IP rate limit and fail intermittently @@ -206,19 +206,17 @@ jobs: GH_TOKEN: ${{ github.token }} run: | mkdir -p audit - # A composite action under .github/actions/ is not a bystander: its - # steps run inline in the calling job, with that job's token and - # secrets, so it carries the same rule families as a workflow. - # Named individually rather than by directory — pointing zizmor at a - # directory hands it every YAML inside, and a non-action YAML landing - # there later would abort the audit rather than be skipped. + # Workflow definitions only, which is the scope the SecurityAuditScanner + # component describes. + # + # The composite action under .github/actions/ is a real gap and is NOT + # covered here: its steps run inline in the calling job, with that job's + # token and secrets, so it carries the same rule families a workflow + # does, and nothing scans it today. Widening this input set to include + # it is a one-line change, but it is a scope the blueprint does not + # describe, so it needs the product record first rather than arriving + # as a side effect of turning the scanner on. Tracked as follow-up. inputs=".github/workflows" - if [ -d .github/actions ]; then - while IFS= read -r a; do - inputs="$inputs $a" - done < <(find .github/actions -type f \ - \( -name 'action.yml' -o -name 'action.yaml' \) | sort) - fi # Record the file set the scanner was handed. A finding list is # identical whether it audited thirty-seven files or none, so # coverage is the one thing the JSON cannot tell you afterwards. From 5e9de09b5cf9e58e88b93b67cb09c37acff3d34c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 12:43:35 +0000 Subject: [PATCH 3/4] Harden CI: complete the security-audit scanner (deps + Python source) Drift Bot flagged this PR against the SecurityAuditScanner component, which describes three scanning responsibilities: workflow definitions, dependency manifests, and Python source. The job as opened implemented only the first. This adds the two that were missing, in the same job: - `npm audit --package-lock-only` over every committed package-lock.json (frontend, tests/e2e, .github/scripts, .github/claude-cli, .github/ci-node/auth-bootstrap, .github/actions/setup-openclaw). pip-audit already covers requirements.txt in the `python-deps` job, but nothing audited the npm half -- and frontend/ is the repo's largest dependency tree, shipping into clawmetry/static/. - `bandit -ll` over the Python source, excluding tests/ and vendored trees. pip-audit deliberately stays in `python-deps`: it has its own SBOM artifact and its own red condition, and splitting a passing job to tidy the layout is churn, not hardening. Both new scanners follow the discipline the zizmor step already sets. Their exit codes cannot distinguish a finding from an outage -- bandit exits 1 on findings, `npm audit` exits non-zero on both -- so neither is trusted. The shape of the report is the test instead: bandit must write a `results` list, npm must write `.metadata.vulnerabilities.total`, and anything else records a `.failed` marker. Findings stay reported, not gated; the only red condition is still a scanner that did not run, because a silent outage reads exactly like a clean scan. The job summary becomes one table across all three scanners, plus a severity/confidence breakdown for bandit -- on a tree this size the total alone does not say whether anything in it needs reading today. Renamed to "Security audit (workflows, package-lock, Python source)" to match what it now does. The check is not a required E2E Gate leg (scripts/e2e_gate.py), so the rename moves no branch-protection context. Verified locally by replaying each `run:` block under `bash -e` as Actions executes them: bandit 280 findings in 24s (3 HIGH), npm audit clean on five of six lockfiles, gate green when healthy and red on a simulated outage, and all 38 workflow files parse. No-PRD: CI-only change under .github/, exempt from the product-record gate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FgwaB1b9mJUUYJJRKybcqe --- .github/workflows/supply-chain.yml | 165 ++++++++++++++++++++++++++--- 1 file changed, 149 insertions(+), 16 deletions(-) diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml index 24cdf1f09a..186f61cb39 100644 --- a/.github/workflows/supply-chain.yml +++ b/.github/workflows/supply-chain.yml @@ -176,7 +176,7 @@ jobs: run: python3 scripts/check_action_refs.py actions-security: - name: GitHub Actions security (zizmor) + name: Security audit (workflows, package-lock, Python source) runs-on: ubuntu-latest # CI is part of the supply chain: a workflow step runs with this repo's # token and secrets, and its output is packaged into the wheel PyPI @@ -187,6 +187,19 @@ jobs: # `Dangerous-Workflow` and `Token-Permissions` probes below cover part # of the same ground, but they run only on push (`if:` above) and score # the repository rather than naming the file and line. + # + # Three scanning responsibilities, matching the SecurityAuditScanner the + # private repos already run: + # 1. workflow definitions -> zizmor + # 2. dependency manifests -> pip-audit (the `python-deps` job + # above, on requirements.txt) + `npm audit` here, on every committed + # package-lock.json. The npm half is the one that had no scanner at + # all: this repo's largest dependency tree is `frontend/`, and it + # ships into `clawmetry/static/`. + # 3. Python source -> bandit + # pip-audit stays in `python-deps` rather than moving here: it has its own + # SBOM artifact and its own red condition, and splitting a passing job to + # tidy the layout is churn, not hardening. steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -194,7 +207,7 @@ jobs: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.11" - - run: python3 -m pip install --quiet zizmor + - run: python3 -m pip install --quiet zizmor bandit - name: Audit every workflow definition env: @@ -256,6 +269,61 @@ jobs: sed 's/^/ /' audit/zizmor.err || true fi + # Responsibility 3: static analysis on this repo's Python source. + # Scorecard's SAST probe with teeth — it scores the repository, this + # names the file and line. + - name: Scan Python source (bandit) + run: | + # -ll: medium severity and above. Low-severity bandit over a tree + # this size is thousands of `assert` and `subprocess` notes, which + # buries the findings that matter. + # + # bandit exits 1 on FINDINGS and on failure alike, so — same + # reasoning as the zizmor step above — the exit code cannot tell a + # finding from an outage. The report is what separates them: a real + # run writes a dict with a `results` list, a crash writes nothing. + bandit -r . -f json -o audit/bandit.json -ll \ + -x ./node_modules,./.venv,./venv,./tests,./frontend,./.git || true + if ! python3 -c "import json; d = json.load(open('audit/bandit.json')); print(f\"bandit: {len(d['results'])} finding(s)\")" 2>/dev/null; then + echo 1 > audit/bandit.failed + echo "::error::bandit did not produce a usable report; this is a scanner outage, not a clean scan" + fi + + # Responsibility 2 (the package-lock half). Every committed lockfile, + # not just frontend/: /tests/e2e, /.github/scripts and the rest are all + # installed by CI jobs that run with this repo's token, and auditing one + # lockfile would report "clean" for the ones nobody scanned. + - name: Scan npm dependencies (package-lock) + run: | + locks=$(find . -name package-lock.json -not -path '*/node_modules/*' -not -path './.git/*' | sort) + if [ -z "$locks" ]; then + echo "no package-lock.json — nothing to audit" + exit 0 + fi + while IFS= read -r lock; do + dir=$(dirname "$lock") + # `.github/scripts` -> `github-scripts`: one report per lockfile, + # named after the directory it audited. + slug=$(printf '%s' "${dir#./}" | tr '/' '-' | tr -d '.') + if [ "$dir" = "." ]; then slug="root"; fi + out="audit/npm-audit-$slug.json" + err="audit/npm-audit-$slug.err" + echo "auditing $lock -> $out" + # `npm audit` exits non-zero BOTH when it finds vulnerabilities and + # when it fails outright, so `|| true` on its own launders a + # registry error into what reads as a clean scan. The shape of the + # output is what separates them: a real result carries + # .metadata.vulnerabilities.total, an error response carries + # .message instead. + ( cd "$dir" && npm audit --package-lock-only --json ) > "$out" 2> "$err" || true + if ! python3 -c "import json; d=json.load(open('$out')); t=d['metadata']['vulnerabilities']['total']; assert isinstance(t, int); print(f' npm audit ($lock): {t} vulnerability(ies)')" 2>/dev/null; then + echo 1 > audit/npm-audit.failed + echo "::error::npm audit did not produce a result for $lock; this is a scanner outage, not a clean scan" + python3 -c "import json; print(' registry said:', json.load(open('$out')).get('message', ''))" 2>/dev/null || true + sed 's/^/ /' "$err" 2>/dev/null || true + fi + done <<< "$locks" + # Findings are reported, not gated. Today's tree has findings that are # load-bearing by design — the release, quarantine and cloud-pin jobs # persist their checkout credential BECAUSE they push with it — and a @@ -266,18 +334,79 @@ jobs: run: | python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY" import collections, json, pathlib + + print("### Security audit\n") + print("| Scanner | Scope | Findings |") + print("| --- | --- | ---: |") + + def bandit_row(): + if pathlib.Path("audit/bandit.failed").exists(): + return "**SCAN FAILED**" + try: + return str(len(json.loads(pathlib.Path("audit/bandit.json").read_text())["results"])) + except Exception: + return "**SCAN FAILED**" + + def npm_rows(): + if pathlib.Path("audit/npm-audit.failed").exists(): + yield "npm audit", "package-lock", "**SCAN FAILED**" + for f in sorted(pathlib.Path("audit").glob("npm-audit-*.json")): + try: + t = json.loads(f.read_text())["metadata"]["vulnerabilities"]["total"] + except Exception: + t = "**SCAN FAILED**" + yield "npm audit", f"`{f.stem[len('npm-audit-'):]}`", str(t) + p = pathlib.Path("audit/zizmor.json") n = pathlib.Path("audit/zizmor-input-count.txt") - print("### GitHub Actions security (zizmor)\n") - if pathlib.Path("audit/zizmor.failed").exists() or not p.exists(): - print("**SCAN FAILED** — see the `actions-security-audit` artifact.") - raise SystemExit(0) + covered = n.read_text().strip() if n.exists() else "?" + zizmor_failed = pathlib.Path("audit/zizmor.failed").exists() or not p.exists() + findings = [] + if not zizmor_failed: + try: + findings = json.loads(p.read_text()) + except Exception: + zizmor_failed = True + print( + f"| zizmor | workflow definitions ({covered} file(s)) | " + + ("**SCAN FAILED**" if zizmor_failed else str(len(findings))) + + " |" + ) + for name, scope, count in npm_rows(): + print(f"| {name} | {scope} | {count} |") + print(f"| bandit | Python source (medium+) | {bandit_row()} |") + print("\nFull reports: the `actions-security-audit` artifact.\n") + + # bandit's total is a baseline number on a tree this size; the + # severity/confidence split is what says whether anything in it + # needs reading today. try: - findings = json.loads(p.read_text()) - except Exception as exc: - print(f"**SCAN FAILED** — unreadable report ({exc}).") + b = json.loads(pathlib.Path("audit/bandit.json").read_text())["results"] + except Exception: + b = [] + if b: + print("#### Python source (bandit)\n") + brank = {"HIGH": 0, "MEDIUM": 1, "LOW": 2} + by_test = collections.Counter( + (r.get("issue_severity") or "?", r.get("issue_confidence") or "?", + r.get("test_id") or "?", r.get("test_name") or "?") + for r in b + ) + print("| Severity | Confidence | Rule | Findings |") + print("| --- | --- | --- | ---: |") + for (sev, conf, tid, tname), c in sorted( + by_test.items(), + key=lambda kv: (brank.get(kv[0][0], 9), brank.get(kv[0][1], 9), kv[0][2]), + ): + print(f"| {sev} | {conf} | `{tid}` {tname} | {c} |") + print() + + # The zizmor breakdown below is the part a reviewer reads to spot a + # NEW workflow finding in the PR that introduced it. + print("#### GitHub Actions security (zizmor)\n") + if zizmor_failed: + print("**SCAN FAILED** — see the `actions-security-audit` artifact.") raise SystemExit(0) - covered = n.read_text().strip() if n.exists() else "?" print(f"{len(findings)} finding(s) across {covered} audited file(s).\n") if not findings: raise SystemExit(0) @@ -315,14 +444,18 @@ jobs: # The only red condition: the scanner did not run. A silent outage is # the failure mode this whole job exists to avoid, because it looks # exactly like a clean result. - - name: Fail if the scanner did not run + - name: Fail if a scanner did not run if: always() run: | - if [ -f audit/zizmor.failed ]; then - echo "zizmor did not produce a usable report; treating as a failed scan." - exit 1 - fi - echo "zizmor completed." + rc=0 + for s in zizmor bandit npm-audit; do + if [ -f "audit/$s.failed" ]; then + echo "$s did not produce a usable report; treating as a failed scan." + rc=1 + fi + done + if [ "$rc" -eq 0 ]; then echo "every scanner completed."; fi + exit "$rc" scorecard: name: OpenSSF Scorecard From dc6bcf9debbe8833a994445541178c33ba006356 Mon Sep 17 00:00:00 2001 From: vivekchand Date: Wed, 9 Sep 2026 12:48:21 +0000 Subject: [PATCH 4/4] Give the npm-audit step a Node of its own The scanner job gained bandit and npm-audit in 5e9de09, but no actions/setup-node. `npm audit` then runs against whatever Node the runner image happens to ship. That is not a hypothetical. clawmetry-pro#234 hit exactly this: with no Node of its own, the step ran on the image's npm and an old npm 6 returned a 400 from the retired `audits/quick` endpoint. The fix there was to pin setup-node; this job needs the same pin for the same reason. It matters more here than it did there. On pro the npm findings were non-blocking, so a bad npm degraded the report. Here the job's gate treats an npm-audit outage as RED -- by design, so a scan that covered nothing cannot read as clean -- which means a runner image bump could turn `main` red with nothing in the diff to explain it. - Pin actions/setup-node to the v7.0.0 commit already used by nine other workflows in this repo, on Node 20. - `package-manager-cache: false`. A GitHub Actions cache is not trusted input and this job reads lockfiles to decide whether the tree is vulnerable. `npm audit --package-lock-only` installs nothing, so the cache buys nothing and could only muddy the result. Same reasoning as publish.yml. Verified - yaml.safe_load over all 38 workflow files -> parse - tests/test_workflow_yaml_valid.py -> 550 passed, 344 skipped - Anchored `uses:` scan -> 0 unpinned action references - bash -n on all five run blocks -> parse - Full job run against this repo: zizmor 42 findings over 38 workflow files, bandit 280, npm audit over all 6 committed lockfiles (5 vulnerabilities in .github/actions/setup-openclaw, 0 elsewhere), summary renders every scanner, gate green - Negative test, npm registry outage: stub npm returning a 400 error body. Step still exits 0 so the artifact uploads, npm-audit.failed is written, and the gate exits 1 rather than reporting a clean scan No-PRD: CI-only change, confined to .github/workflows/. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019q6JAQni1nbx9pqSVA5oj8 --- .github/workflows/supply-chain.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml index 186f61cb39..5cf10e2339 100644 --- a/.github/workflows/supply-chain.yml +++ b/.github/workflows/supply-chain.yml @@ -207,6 +207,22 @@ jobs: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.11" + # The npm-audit step below needs a Node of its own. Without this it runs + # against whatever the runner image happens to ship, which is not a + # hypothetical: clawmetry-pro#234 hit exactly that, where an unpinned old + # npm returned a 400 from the retired `audits/quick` endpoint npm 6 uses. + # That matters more here than it did there, because this job's gate + # treats an npm-audit outage as RED — an image bump could turn `main` + # red with nothing in the diff to explain it. + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "20" + # A GitHub Actions cache is not trusted input, and this job reads + # lockfiles to decide whether the tree is vulnerable. Nothing is + # installed here anyway (`npm audit --package-lock-only`), so the + # cache would buy nothing and could only muddy the result. Same + # reasoning as publish.yml. + package-manager-cache: false - run: python3 -m pip install --quiet zizmor bandit - name: Audit every workflow definition