From c9e0097d9d1d8ad7559cb208c8228fddc47ed229 Mon Sep 17 00:00:00 2001 From: naindev <193035803+Nain9Dev@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:49:28 +0200 Subject: [PATCH] feat(supply-chain): add the scan every repository runs as a reusable workflow The job that runs zizmor over the workflows and gitleaks over the history was copied into every repository, so raising a scanner digest meant a change in each. It is now one reusable workflow with the digests pinned inside, taking only the runner and the paths zizmor audits; this repository calls it as its own fixture. The zizmor path goes through an environment variable, not into the script. tests/supply-chain.test.sh checks that every image stays pinned by digest and that the flags that decide what is scanned stay. --- .github/workflows/ci.yml | 36 ++++----------- .github/workflows/supply-chain.yml | 71 ++++++++++++++++++++++++++++++ .github/zizmor.yml | 3 +- README.md | 8 ++++ tests/supply-chain.test.sh | 60 +++++++++++++++++++++++++ 5 files changed, 150 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/supply-chain.yml create mode 100644 tests/supply-chain.test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d251ed2..f712d78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,37 +59,19 @@ jobs: - name: Changes script tests run: bash tests/changes.test.sh + - name: Supply-chain workflow tests + run: bash tests/supply-chain.test.sh + - name: Merge train tests run: python3 -m unittest discover -s tests -p "test_*.py" + # Fixture caller of the reusable supply-chain scan, on the real event: this repository scans itself + # with the same file its consumers call. supply-chain: - name: Supply chain (zizmor + gitleaks) - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - fetch-depth: 0 - persist-credentials: false - - - name: zizmor on the workflows - env: - GH_TOKEN: ${{ github.token }} - run: >- - docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp - -e GH_TOKEN - -v "$GITHUB_WORKSPACE:/repo:ro" -w /repo - ghcr.io/zizmorcore/zizmor@sha256:a2eb396d886c053073405c7a980f2139ba2248ec172243cfa3841e57196e8101 - --format github .github/ - - - name: gitleaks over the history of this commit - if: ${{ !cancelled() }} - run: >- - docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp - -e GIT_CONFIG_COUNT=1 -e GIT_CONFIG_KEY_0=safe.directory -e GIT_CONFIG_VALUE_0=/repo - -v "$GITHUB_WORKSPACE:/repo:ro" -w /repo - ghcr.io/gitleaks/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f - git --redact --no-banner --exit-code 1 --log-opts="--full-history HEAD" . + name: Fixture caller (supply chain) + permissions: + contents: read + uses: ./.github/workflows/supply-chain.yml # The only check a branch rule would need to require. Fixture caller of the reusable gate. gate: diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml new file mode 100644 index 0000000..95a9fc8 --- /dev/null +++ b/.github/workflows/supply-chain.yml @@ -0,0 +1,71 @@ +name: Supply chain + +# Reusable workflow: the scan every repository of the organisation runs, in one place. zizmor audits +# the workflow files (online, with the job's read-only token, so `impostor-commit` can prove that each +# pinned SHA belongs to its action's own repository) and gitleaks scans the history of the commit for +# secrets. Both run from images pinned by digest: a tag-pinned scanner is the vector this job guards +# against. Raising a digest is one change here, and a caller gets it by raising its pin of this file. +# +# jobs: +# supply-chain: +# name: Supply chain (zizmor + gitleaks) +# permissions: +# contents: read +# uses: naindev-contrast3d/ci-shared/.github/workflows/supply-chain.yml@ # vX.Y.Z +# with: +# runs-on: '["self-hosted","linux","x64"]' # optional, a label or an array of labels +# +# The runner needs Docker. A false positive of gitleaks goes in the caller's `.gitleaksignore`, by +# fingerprint and with its reason. + +on: + workflow_call: + inputs: + runs-on: + description: JSON for the `runs-on` of the job, a label or an array of labels. + type: string + default: '"ubuntu-latest"' + paths: + description: What zizmor audits, relative to the repository root. + type: string + default: .github/ + +permissions: {} + +jobs: + supply-chain: + name: Supply chain (zizmor + gitleaks) + runs-on: ${{ fromJSON(inputs.runs-on) }} + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: zizmor on the workflows + # `HOME=/tmp`: as the runner's user the image has no writable home, and zizmor caches API + # answers there. + env: + GH_TOKEN: ${{ github.token }} + AUDIT_PATHS: ${{ inputs.paths }} + run: >- + docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp + -e GH_TOKEN + -v "$GITHUB_WORKSPACE:/repo:ro" -w /repo + ghcr.io/zizmorcore/zizmor@sha256:a2eb396d886c053073405c7a980f2139ba2248ec172243cfa3841e57196e8101 + --format github "$AUDIT_PATHS" + + - name: gitleaks over the history of this commit + # `--redact`: a finding names the file, commit and rule, never the value. `safe.directory` + # because the mount's owner is not the container's user on every runner. `--full-history + # HEAD`: the history of the commit being checked, not every branch and tag. + if: ${{ !cancelled() }} + run: >- + docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp + -e GIT_CONFIG_COUNT=1 -e GIT_CONFIG_KEY_0=safe.directory -e GIT_CONFIG_VALUE_0=/repo + -v "$GITHUB_WORKSPACE:/repo:ro" -w /repo + ghcr.io/gitleaks/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f + git --redact --no-banner --exit-code 1 --log-opts="--full-history HEAD" . diff --git a/.github/zizmor.yml b/.github/zizmor.yml index 6fc57d4..84a62d3 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -6,4 +6,5 @@ rules: # Keyed by line number: update these when lines are added above them in ci.yml. - ci.yml:22 - ci.yml:31 - - ci.yml:96 + - ci.yml:74 + - ci.yml:81 diff --git a/README.md b/README.md index ad45479..ace11f4 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,14 @@ Status: in progress. The design is in `specs/next-ci-reliability` of Inputs: `needs-json` (the caller's `toJSON(needs)`), `allowed-skipped`, `runs-on`. The caller job needs `if: ${{ always() }}`. +- `supply-chain` (available): the scan every repository runs, in one place. zizmor audits the + workflow files online with the job's read-only token and gitleaks scans the history of the commit + for secrets, both from images pinned by digest, so raising a digest is one change here and a + caller gets it by raising its pin. Inputs: `runs-on` (a JSON label or array, default + `"ubuntu-latest"`) and `paths` (what zizmor audits, default `.github/`). The caller job needs + `permissions: contents: read`, and the runner needs Docker. `tests/supply-chain.test.sh` checks + that every image is pinned by digest and that the flags that decide what is scanned stay. + - `changes` (available): changed-file detection inside the workflow, without `paths:` filters. Inputs: `filters` (area -> glob patterns), `global-patterns` (default `.github/**`), `runs-on`. Outputs: `areas` (area -> true/false) and `reason`. When the diff cannot be known with certainty diff --git a/tests/supply-chain.test.sh b/tests/supply-chain.test.sh new file mode 100644 index 0000000..711da6b --- /dev/null +++ b/tests/supply-chain.test.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Tests for .github/workflows/supply-chain.yml. +# The scanners run in containers a test cannot start, so these check what makes the scan trustworthy: +# every image pinned by digest, every flag that decides what is scanned, and the shape of the job. +# The workflow under test is the file that runs in CI, read with yq. +set -uo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +workflow="$root/.github/workflows/supply-chain.yml" +[ -f "$workflow" ] || { echo "FAIL: $workflow does not exist"; exit 1; } + +failures=0 +pass() { echo "ok - $1"; } +fail() { echo "FAIL - $1"; failures=$((failures + 1)); } +check() { # check + local description="$1"; shift + if "$@" >/dev/null 2>&1; then pass "$description"; else fail "$description"; fi +} + +zizmor="$(yq -r '.jobs."supply-chain".steps[] | select(.name | test("zizmor")) | .run' "$workflow")" +gitleaks="$(yq -r '.jobs."supply-chain".steps[] | select(.name | test("gitleaks")) | .run' "$workflow")" + +check "the workflow is a reusable one" test "$(yq -r '.on | has("workflow_call")' "$workflow")" = true +check "runs-on is an input that defaults to a hosted runner" \ + test "$(yq -r '.on.workflow_call.inputs."runs-on".default' "$workflow")" = '"ubuntu-latest"' +check "the job runs with read-only contents" test "$(yq -r '.jobs."supply-chain".permissions.contents' "$workflow")" = read +check "the job is bounded in time" test "$(yq -r '.jobs."supply-chain"."timeout-minutes"' "$workflow")" != null + +checkout_uses="$(yq -r '.jobs."supply-chain".steps[0].uses' "$workflow")" +check "checkout is pinned by a full commit SHA" grep -Eq '@[0-9a-f]{40}( |$)' <<<"$checkout_uses" +check "checkout fetches the whole history" test "$(yq -r '.jobs."supply-chain".steps[0].with."fetch-depth"' "$workflow")" = 0 +check "checkout leaves no credentials behind" \ + test "$(yq -r '.jobs."supply-chain".steps[0].with."persist-credentials"' "$workflow")" = false + +# Every image the scan runs is pinned by digest; a tag can be moved under the pin. +images="$(grep -Eho 'ghcr\.io/[a-z0-9._/-]+(@sha256:[0-9a-f]{64}|:[A-Za-z0-9._-]+)' "$workflow" || true)" +check "the workflow names the two scanner images" test "$(wc -l <<<"$images")" -ge 2 +check "every scanner image is pinned by digest" bash -c '! grep -qv "@sha256:" <<<"$1"' _ "$images" +check "the zizmor image is zizmor" grep -q 'zizmorcore/zizmor@sha256:' "$workflow" +check "the gitleaks image is gitleaks" grep -q 'gitleaks/gitleaks@sha256:' "$workflow" + +# zizmor: online audits with the job token, the workflow files read-only, output as annotations. +check "zizmor gets the job token" grep -q 'GH_TOKEN' "$workflow" +check "zizmor reads the workspace read-only" grep -q ':/repo:ro' <<<"$zizmor" +check "zizmor reports as annotations" grep -q -- '--format github' <<<"$zizmor" +check "zizmor audits the paths input" grep -q 'inputs.paths' "$workflow" + +# gitleaks: the history of the commit being checked, redacted, failing the job on a finding. +check "gitleaks runs even when zizmor failed" test "$(yq -r '.jobs."supply-chain".steps[] | select(.name | test("gitleaks")) | .if' "$workflow")" = '${{ !cancelled() }}' +check "gitleaks fails on a finding" grep -q -- '--exit-code 1' <<<"$gitleaks" +check "gitleaks never prints a secret" grep -q -- '--redact' <<<"$gitleaks" +check "gitleaks scans the history of this commit only" grep -q -- '--log-opts="--full-history HEAD"' <<<"$gitleaks" +check "gitleaks trusts the mounted directory" grep -q 'safe.directory' <<<"$gitleaks" + +# What the caller cannot influence: the images and flags are not inputs. +check "no input changes an image" bash -c '! yq -r ".on.workflow_call.inputs | keys | .[]" "$1" | grep -qi "image"' _ "$workflow" +check "the job runs as the runner's user, not as root" grep -q -- '--user "$(id -u):$(id -g)"' <<<"$zizmor" + +echo +if [ "$failures" -eq 0 ]; then echo "all supply-chain checks passed"; else echo "$failures supply-chain check(s) failed"; exit 1; fi