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
22 changes: 20 additions & 2 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/self-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions bin/run-zizmor.sh
Original file line number Diff line number Diff line change
@@ -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 <output.sarif> [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}"
96 changes: 96 additions & 0 deletions bin/test-run-zizmor.sh
Original file line number Diff line number Diff line change
@@ -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 ]
Loading