diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index e85e745..a29d346 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -50,6 +50,14 @@ on: without private git deps can omit it. Scoped on purpose — do not reintroduce blanket `secrets: inherit`. required: false + gh-api-token: + description: >- + GitHub API token for zizmor's online audits, which resolve every repository + a workflow references with `uses:`. Optional: defaults to GITHUB_TOKEN, and + when a referenced repository is unreadable the zizmor job degrades to + offline audits rather than failing. Supply a token that can read every + referenced repository to keep the online audits running. + required: false permissions: # contents: read only. `pull-requests: write` was here for clj-holmes-action, @@ -405,6 +413,13 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Check out the runner script + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: cleancoders/github-actions + ref: ${{ inputs.rules-ref }} + path: .cc-security-rules + persist-credentials: false - name: zizmor # Prebuilt binary rather than `uvx` via setup-uv. setup-uv tries to cache # Python project dependencies and warns on every run of a Clojure repo: @@ -415,14 +430,17 @@ jobs: # gitleaks and actionlint are installed in this workflow. shell: bash env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Falls back to GITHUB_TOKEN, which reaches only the repository being + # scanned. That is enough for the online audits unless a workflow here + # references a repository the token cannot read — see run-zizmor.sh. + GH_TOKEN: ${{ secrets.gh-api-token || secrets.GITHUB_TOKEN }} run: | set -euo pipefail VER=1.28.0 curl -fsSL "https://github.com/zizmorcore/zizmor/releases/download/v${VER}/zizmor-x86_64-unknown-linux-gnu.tar.gz" \ | sudo tar -xz -C /usr/local/bin zizmor zizmor --version - zizmor --no-progress --format sarif . > zizmor.sarif + bash .cc-security-rules/bin/run-zizmor.sh zizmor.sarif . - name: Upload SARIF if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 (node24) diff --git a/.github/workflows/self-test.yml b/.github/workflows/self-test.yml index 22acf8d..455742e 100644 --- a/.github/workflows/self-test.yml +++ b/.github/workflows/self-test.yml @@ -61,6 +61,10 @@ jobs: - name: Run SARIF reporter tests # Pins the exit-code contract: warning-level rules must not block. run: bash bin/test-report-sarif.sh + - name: Run zizmor runner tests + # Pins the degradation contract: a `uses:` the token cannot read must not + # abort the audit, and every other failure must stay red. + run: bash bin/test-run-zizmor.sh - name: Run rule fixture tests run: bash bin/test-rules.sh - name: Check coverage table is current diff --git a/README.md b/README.md index c2e260a..9d8ed26 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,15 @@ jobs: | `rules-ref` | `"v1"` | Ref of this repo to source the `cc-*` rules from. **Must match the ref you consume the workflow at** — a reusable workflow cannot determine its own ref, so consuming `@v2` or a SHA without setting this gets you `v1` rules. | | `ignored-paths` | `""` | Paths semgrep must skip, e.g. deliberately-vulnerable fixtures. | +### Secrets + +Both are optional; the jobs that use them self-skip or degrade when they are unset. + +| secret | purpose | +|--------|---------| +| `private-git-ssh-key` | Deploy key for cloning private git dependencies while building the classpath. Only clj-kondo and clj-watson use it, and they skip SSH setup when it is unset. | +| `gh-api-token` | API token for zizmor's online audits, which resolve every repository a workflow references with `uses:`. Defaults to `GITHUB_TOKEN`, which reaches only the repository being scanned — so if a workflow calls a **private** reusable workflow, zizmor cannot resolve it and the job degrades to offline audits with a warning. Supply a token that can read every referenced repository to keep the online audits running. | + ### Coverage Scanner rows below are generated from rule metadata by diff --git a/bin/run-zizmor.sh b/bin/run-zizmor.sh new file mode 100755 index 0000000..4df1747 --- /dev/null +++ b/bin/run-zizmor.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Runs zizmor and writes SARIF to the given path. +# +# zizmor's online audits (ref-confusion, and the commit lookup inside +# unpinned-uses) resolve every repository a workflow references with `uses:`. +# When one of them is private to the token in use, zizmor 1.28.0 does not skip +# that audit — it aborts the whole run with "fatal: no audit was performed", so +# a repo whose build calls a private reusable workflow gets NO audit rather than +# a partial one, and the SARIF comes out empty. +# +# GITHUB_TOKEN is scoped to the repository being scanned, so this is the default +# state for any consumer that calls a private reusable workflow, not an edge +# case. Rather than drop the online audits for everyone, run them and fall back +# to offline audits only when that specific lookup failure occurs. Consumers who +# want the online audits kept can pass a token that reaches every referenced +# repository. +# +# Deliberately narrow: only the lookup failure triggers the retry. Retrying on +# any nonzero exit would turn an unparseable workflow or a missing binary into a +# green job, which is indistinguishable from "audited and found nothing". +# +# Tested by bin/test-run-zizmor.sh. +set -uo pipefail + +OUT="${1:?usage: run-zizmor.sh [target...]}" +shift +targets=("$@") +[ "${#targets[@]}" -eq 0 ] && targets=(".") + +args=(--no-progress --format sarif) + +# zizmor decides online-vs-offline from the token's presence and warns when it +# picks offline implicitly. Being explicit keeps that warning out of the log and +# makes the choice visible in the argv. +offline=0 +if [ -z "${GH_TOKEN:-}" ]; then + echo "zizmor: no API token supplied; running offline audits only" + args+=(--no-online-audits) + offline=1 +fi + +err="$(mktemp)" +trap 'rm -f "${err}"' EXIT + +zizmor "${args[@]}" "${targets[@]}" > "${OUT}" 2>"${err}" +rc=$? +cat "${err}" >&2 + +if [ "${rc}" -ne 0 ] && [ "${offline}" -eq 0 ] && + grep -qE "no audit was performed|no access|couldn't list branches" "${err}"; then + echo "::warning::zizmor could not resolve every repository referenced by a \`uses:\`, which aborts its online audits. Retrying with them disabled — offline audits still ran. To keep the online audits, pass the gh-api-token secret with read access to every referenced repository." + zizmor "${args[@]}" --no-online-audits "${targets[@]}" > "${OUT}" 2>"${err}" + rc=$? + cat "${err}" >&2 +fi + +exit "${rc}" diff --git a/bin/test-run-zizmor.sh b/bin/test-run-zizmor.sh new file mode 100755 index 0000000..9eaa2fc --- /dev/null +++ b/bin/test-run-zizmor.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Tests bin/run-zizmor.sh, which decides whether zizmor runs its online audits. +# +# The case that matters is a `uses:` pointing at a repository the token cannot +# read. zizmor 1.28.0 treats that as fatal — "no audit was performed" — so a +# consumer whose build calls a private reusable workflow gets no audit at all, +# not a partial one. Degrading to offline audits keeps the scan meaningful; +# propagating the failure keeps a genuinely broken scan visible. +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUN="${ROOT}/bin/run-zizmor.sh" +pass=0; fail=0 + +ok() { pass=$((pass+1)); } +bad() { fail=$((fail+1)); echo "FAIL: $1"; } +check() { if [ "$2" = "$3" ]; then ok; else bad "$1 — expected '$3', got '$2'"; fi; } + +WORK="$(mktemp -d)" +trap 'rm -rf "${WORK}"' EXIT + +# A fake zizmor recording its argv, so the tests assert on the flags actually +# passed rather than on zizmor's behaviour. FAKE_MODE picks the failure to +# simulate; FAKE_CALLS counts invocations so a retry is distinguishable from a +# single run. +mkdir -p "${WORK}/bin" +cat > "${WORK}/bin/zizmor" <<'FAKE' +#!/usr/bin/env bash +echo "$*" >> "${FAKE_ARGV}" +echo "1" >> "${FAKE_CALLS}" +case "${FAKE_MODE}" in + ok) exit 0 ;; + private-uses) + # Verbatim shape of the zizmor 1.28.0 failure, minus the repository name. + if grep -q -- "--no-online-audits" <<<"$*"; then exit 0; fi + echo "fatal: no audit was performed" >&2 + echo "'ref-confusion' audit failed on file://./.github/workflows/build.yml" >&2 + echo " 2: can't access some-org/some-repo: missing or you have no access" >&2 + exit 1 ;; + broken) echo "error: failed to parse workflow" >&2; exit 1 ;; +esac +FAKE +chmod +x "${WORK}/bin/zizmor" + +# Each run gets fresh argv/call logs. +run() { + FAKE_MODE="$1"; shift + FAKE_ARGV="${WORK}/argv"; FAKE_CALLS="${WORK}/calls" + : > "${FAKE_ARGV}"; : > "${FAKE_CALLS}" + export FAKE_MODE FAKE_ARGV FAKE_CALLS + PATH="${WORK}/bin:${PATH}" bash "${RUN}" "${WORK}/zizmor.sarif" "$@" 2>&1 +} +argv() { cat "${WORK}/argv"; } +calls() { grep -c . "${WORK}/calls"; } + +# --- token present and every `uses:` reachable: online audits stay on --------- +out="$(GH_TOKEN=tok run ok)"; rc=$? +check "reachable scan passes" "$rc" "0" +check "online audits are not disabled" "$(argv | grep -c -- '--no-online-audits')" "0" +check "runs once when it succeeds" "$(calls)" "1" + +# --- no token: zizmor would warn it is implicitly offline, so be explicit ----- +out="$(GH_TOKEN='' run ok)"; rc=$? +check "tokenless scan passes" "$rc" "0" +check "tokenless scan disables online" "$(argv | grep -c -- '--no-online-audits')" "1" +check "tokenless scan says why" "$(echo "$out" | grep -c 'no API token')" "1" + +# --- the regression: a `uses:` the token cannot read -------------------------- +# Without the retry this is a hard failure and the SARIF is empty, so the job is +# red for a reason unrelated to any finding in the consumer's workflows. +out="$(GH_TOKEN=tok run private-uses)"; rc=$? +check "unreachable uses: still passes" "$rc" "0" +check "retries with online audits off" "$(argv | grep -c -- '--no-online-audits')" "1" +check "retry is a second invocation" "$(calls)" "2" +check "degradation is announced" "$(echo "$out" | grep -c '::warning::')" "1" +check "names the audit that failed" "$(echo "$out" | grep -c 'ref-confusion')" "1" + +# --- a genuinely broken scan must stay broken -------------------------------- +# Retrying everything would turn real breakage (unparseable workflow, missing +# binary) into a green job — the silent-pass failure mode this repo exists to +# prevent. +out="$(GH_TOKEN=tok run broken)"; rc=$? +check "unrelated failure propagates" "$rc" "1" +check "unrelated failure is not retried" "$(calls)" "1" + +# --- wiring: the workflow must call the script, not zizmor directly ----------- +WF="${ROOT}/.github/workflows/security.yml" +if [ -f "$WF" ]; then + check "the runner is actually called" \ + "$(grep -c 'run-zizmor.sh zizmor.sarif' "$WF")" "1" + check "zizmor is not invoked inline" \ + "$(grep -cE '^\s+zizmor --no-progress' "$WF")" "0" +fi + +echo "run-zizmor tests: ${pass} passed, ${fail} failed" +[ "${fail}" -eq 0 ]