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
36 changes: 9 additions & 27 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
71 changes: 71 additions & 0 deletions .github/workflows/supply-chain.yml
Original file line number Diff line number Diff line change
@@ -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@<full commit sha> # 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" .
3 changes: 2 additions & 1 deletion .github/zizmor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions tests/supply-chain.test.sh
Original file line number Diff line number Diff line change
@@ -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 <description> <command...>
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
Loading