diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8eb788..39ac898 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,6 +126,15 @@ jobs: # Release-candidate canary observations must not span a runner-OS # migration of the moving ubuntu-latest label. run: bash scripts/canary-runner-pin_test.sh + - name: Workflow token-permission regression + # Top-level GITHUB_TOKEN permissions stay read-only and every job-level + # write grant is listed, with a justification, in + # scripts/workflow-write-permissions.allow. + run: bash scripts/check-workflow-permissions_test.sh + - name: Research v1 release-state regression + # scholarly-release-v1 re-runs on edits to itself; the immutable, + # already-published research-v1 must be verified, never re-attempted. + run: bash scripts/research-v1-release-state_test.sh - name: Coverage-floor regression run: bash scripts/check-coverage_test.sh - name: Release metadata consistency diff --git a/.github/workflows/kernel-freshness.yml b/.github/workflows/kernel-freshness.yml index 3bf535d..edd7ec6 100644 --- a/.github/workflows/kernel-freshness.yml +++ b/.github/workflows/kernel-freshness.yml @@ -24,7 +24,6 @@ on: permissions: contents: read - issues: write # file/refresh/close the stale-baselines tracking issue concurrency: group: ${{ github.workflow }} @@ -35,6 +34,9 @@ jobs: name: Baselines vs kernel-crawler runs-on: ubuntu-latest timeout-minutes: 15 + permissions: + contents: read + issues: write # file/refresh/close the stale-baselines tracking issue steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 diff --git a/.github/workflows/scholarly-release-v1.yml b/.github/workflows/scholarly-release-v1.yml index c394f5d..cee9651 100644 --- a/.github/workflows/scholarly-release-v1.yml +++ b/.github/workflows/scholarly-release-v1.yml @@ -6,11 +6,10 @@ on: paths: - ".github/workflows/scholarly-release-v1.yml" +# Read-only by default; the one publish job holds the write grants +# (scripts/check-workflow-permissions.sh). permissions: - contents: write - actions: read - id-token: write - attestations: write + contents: read concurrency: group: scholarly-release-v1 @@ -18,12 +17,52 @@ concurrency: env: RESEARCH_TAG: research-v1 + # research-v1 was published from this commit on 2026-09-19 (run 35456618576). + # It is immutable: the release is never re-created from any other commit. + RESEARCH_COMMIT: 141c491bd1508600338e7bc27abbc5a117eb7508 jobs: + # This workflow re-runs whenever its own file changes on main. A read-only + # job first establishes the publication state; the write-scoped publish job + # runs only for the original first publication. An intact, verified + # research-v1 ends the run green without a write token; a missing, partial + # or mismatched one fails here (scripts/research-v1-release-state.sh). + state: + name: Verify research v1 publication state + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + attestations: read + outputs: + publish: ${{ steps.state.outputs.publish }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Establish research v1 state + id: state + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + bash scripts/research-v1-release-state.sh \ + "${GITHUB_REPOSITORY}" "${RESEARCH_TAG}" "${RESEARCH_COMMIT}" \ + research/archive/v1/archive-lock.json "${RUNNER_TEMP}/research-v1-state" \ + >>"$GITHUB_OUTPUT" + publish: name: Publish immutable research v1 release + needs: state + if: needs.state.outputs.publish == 'true' runs-on: ubuntu-24.04 timeout-minutes: 20 + permissions: + contents: write # create the research-v1 tag and release + actions: read # download the pinned canonical source artifacts + id-token: write # sign the archive provenance attestation + attestations: write # store the archive provenance attestation steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/docs/workflow-token-permissions.md b/docs/workflow-token-permissions.md new file mode 100644 index 0000000..e037407 --- /dev/null +++ b/docs/workflow-token-permissions.md @@ -0,0 +1,69 @@ +# Workflow token permissions + +Every workflow in `.github/workflows` declares read-only top-level +`permissions`. Write access to `GITHUB_TOKEN` is granted only at the job that +uses it. `scripts/check-workflow-permissions.sh` enforces both rules in CI +(`scripts/check-workflow-permissions_test.sh`): + +- a top-level write, `write-all`, or a missing top-level block fails; +- a job-level write must be listed, with a justification, in + `scripts/workflow-write-permissions.allow`; +- an allowlist entry that no longer matches a grant fails. + +## How OpenSSF Scorecard scores this (v5.5.0) + +The `Token-Permissions` check (`checks/evaluation/permissions.go`) treats the +two levels differently: + +- A **top-level** `contents`, `packages` or `actions` write lowers the check + score by 10, i.e. to 0. +- A **job-level** write is logged as a warning. It lowers the score only when + the same workflow also has a top-level write, or declares no top-level + permissions. + +The SARIF output that becomes code-scanning alerts (`pkg/scorecard/sarif.go`) +skips a check whose score meets the policy minimum, or is inconclusive. +Below the minimum, with details enabled, each warning that has a file location +becomes an alert carrying the check score (`shouldAddLocation`). That is why +one top-level grant in +`scholarly-release-v1.yml` (alert #178) also turned the five required +job-level grants into "score is 0" alerts (#173–#177). + +The same v5.5.0 binary was run locally (`--local`, `--checks +Token-Permissions`, policy minimum 10): +- on main `802d515` it reproduces exactly alerts #173–#178; +- on the read-only-top-level tree it scores 10 and emits no SARIF results. + +The job-level warnings are still present in `--show-details` output. They +describe the required grants below. + +## Required job-level write grants + +These are the five write grants Scorecard reported (#173–#177). Each is +limited to the one job that uses it and is not dismissed. + +| Alert | Workflow / job | Grant | Used by | Gate | +|---|---|---|---|---| +| #173 | `promote-release.yml` / `promote` | `contents: write` | `scripts/promote-release.sh` `gh release edit` publishes the verified draft | `production-release` environment: `v*` tags only, 15-minute wait, no admin bypass; exact candidate run, commit and digest inputs plus typed confirmation | +| #174 | `promote-release.yml` / `promote` | `packages: write` | `scripts/promote-release.sh` `docker buildx imagetools create` writes the promoted image tag | same environment and inputs as #173; digest verified by cosign before promotion | +| #175 | `release-artifacts.yml` / `stage-draft-release` | `contents: write` | `softprops/action-gh-release` creates a **draft** release (`draft: true`, `make_latest: false`) | `if: startsWith(github.ref, 'refs/tags/v')`; tags are immutable (ruleset); publication still requires #173 | +| #176 | `release-candidate-canary.yml` / `external-consumers` | `actions: write` | `gh workflow run external-consumer-canary.yml` at the immutable RC tag | needs the planning job's observation window; dispatch only, no contents or packages write | +| #177 | `rollback-drill.yml` / `rollback` | `packages: write` | `docker buildx imagetools create` moves the drill-only `rollback-drill-*` alias | `production-drill` environment; `GITHUB_REF` must be `refs/heads/main`; `latest` proven unchanged | + +`release-candidate-canary.yml` (#176) produces the v0.4.0 release-candidate +evidence, so it is not changed while a qualification window is open. A +reusable-workflow call could replace the dispatch and drop `actions: write`. +That would change the evidence contract (`external_consumer_run_id`), so it +belongs after graduation. + +## research-v1 + +`scholarly-release-v1.yml` runs on every push that changes its own file. +`research-v1` was published once, from `141c491`, and is immutable. + +A read-only `state` job (`scripts/research-v1-release-state.sh`) +decides whether the write-scoped `publish` job may run: +- **publish** only for the original first publication from the pinned commit; +- **skip, green** only when the published tag, release, assets, lock, + checksums and attestations all verify; +- **fail** in every other case, never a silent skip. diff --git a/scripts/check-release-consistency.sh b/scripts/check-release-consistency.sh index c9a7d9d..392e8b3 100755 --- a/scripts/check-release-consistency.sh +++ b/scripts/check-release-consistency.sh @@ -125,10 +125,33 @@ for required_control in \ 'Create annotated research tag' \ 'Publish GitHub research release' \ '--verify-tag' \ - '--latest=false'; do + '--latest=false' \ + 'RESEARCH_COMMIT: 141c491bd1508600338e7bc27abbc5a117eb7508' \ + 'bash scripts/research-v1-release-state.sh' \ + 'needs: state' \ + "if: needs.state.outputs.publish == 'true'"; do grep -Fq -- "$required_control" "$scholarly_workflow" || fail "$scholarly_workflow is missing required control: $required_control" done +# research-v1 is published and immutable: a re-run must not reach the +# write-scoped publish job unless the read-only state job says so. +python3 - "$scholarly_workflow" <<'PY' || +import sys +import yaml + +wf = yaml.safe_load(open(sys.argv[1], encoding="utf-8")) +jobs = wf.get("jobs") or {} +publish = jobs.get("publish") or {} +state = jobs.get("state") or {} +ok = ( + set(jobs) == {"state", "publish"} + and publish.get("needs") == "state" + and publish.get("if") == "needs.state.outputs.publish == 'true'" + and all(str(v) in ("read", "none") for v in (state.get("permissions") or {"x": "write"}).values()) +) +sys.exit(0 if ok else 1) +PY + fail "$scholarly_workflow publish job must be gated by the read-only state job (needs: state, if: needs.state.outputs.publish == 'true')" if grep -Eq 'workflow_dispatch:|refs/tags/v|tags:[[:space:]]*\[.*v\*|scripts/promote-release\.sh|softprops/action-gh-release@|value=latest' "$scholarly_workflow"; then fail "$scholarly_workflow must remain isolated from product release/promotion paths" fi diff --git a/scripts/check-release-consistency_test.sh b/scripts/check-release-consistency_test.sh index 45288e8..b0c907d 100755 --- a/scripts/check-release-consistency_test.sh +++ b/scripts/check-release-consistency_test.sh @@ -91,6 +91,28 @@ if BPFCOMPAT_RELEASE_METADATA=release.yaml \ fi cp "$tmp/scholarly-release-v1.yml" "$scholarly_workflow" +# Each broken state gate must be rejected for the gate, not by accident. +while IFS='|' read -r gate_mutation gate_reason; do + sed -i "$gate_mutation" "$scholarly_workflow" + if BPFCOMPAT_RELEASE_METADATA=release.yaml \ + "$script" >"$tmp/bad-scholarly-gate.log" 2>&1; then + echo "[release-consistency-test] accepted scholarly workflow with a broken state gate: ${gate_mutation}" >&2 + exit 1 + fi + if ! grep -Fq -- "$gate_reason" "$tmp/bad-scholarly-gate.log"; then + echo "[release-consistency-test] broken state gate rejected for the wrong reason: ${gate_mutation}" >&2 + cat "$tmp/bad-scholarly-gate.log" >&2 + exit 1 + fi + cp "$tmp/scholarly-release-v1.yml" "$scholarly_workflow" +done <<'MUTATIONS' +/if: needs.state.outputs.publish == 'true'/d|missing required control: if: needs.state.outputs.publish == 'true' +/^ needs: state$/d|missing required control: needs: state +s/^ attestations: read$/ contents: write/|must be gated by the read-only state job +s/^ state:$/ status:/|must be gated by the read-only state job +/RESEARCH_COMMIT: 141c491/d|missing required control: RESEARCH_COMMIT +MUTATIONS + sed -i '/--latest=false/d' "$scholarly_workflow" if BPFCOMPAT_RELEASE_METADATA=release.yaml \ "$script" >"$tmp/bad-scholarly-latest.log" 2>&1; then diff --git a/scripts/check-workflow-permissions.sh b/scripts/check-workflow-permissions.sh new file mode 100755 index 0000000..02f4ba0 --- /dev/null +++ b/scripts/check-workflow-permissions.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# Least-privilege GITHUB_TOKEN permissions for every workflow. +# +# check-workflow-permissions.sh +# +# 1. Every workflow declares top-level `permissions:` and that block is +# read-only: `{}`, `read-all`, or a mapping whose values are all `read` or +# `none`. A top-level grant applies to every job, including jobs added +# later, so a write there is never least privilege (OpenSSF Scorecard +# Token-Permissions, topLevel). +# 2. No job uses `write-all`. +# 3. Every job-level `write` is listed in +# scripts/workflow-write-permissions.allow as +# ` `, so a new +# write grant needs a reviewed change to that file. An allowlist entry that +# no longer matches a grant fails too, so the list cannot drift into a +# blanket exemption. +# +# Overridable for the regression suite only: BPFCOMPAT_WORKFLOWS_DIR, +# BPFCOMPAT_WORKFLOW_WRITE_ALLOWLIST. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +workflows_dir="${BPFCOMPAT_WORKFLOWS_DIR:-$ROOT/.github/workflows}" +allowlist="${BPFCOMPAT_WORKFLOW_WRITE_ALLOWLIST:-$ROOT/scripts/workflow-write-permissions.allow}" + +[[ -d "$workflows_dir" ]] || { echo "[workflow-permissions] missing workflows dir: $workflows_dir" >&2; exit 2; } +[[ -f "$allowlist" ]] || { echo "[workflow-permissions] missing allowlist: $allowlist" >&2; exit 2; } + +python3 - "$workflows_dir" "$allowlist" <<'PY' +import os +import sys + +import yaml + +workflows_dir, allowlist_path = sys.argv[1], sys.argv[2] +READ_ONLY = {"read", "none"} +failures = [] + + +class StrictLoader(yaml.SafeLoader): + """SafeLoader that rejects duplicate mapping keys instead of keeping the last.""" + + +def _no_duplicates(loader, node, deep=False): + seen = set() + for key_node, _ in node.value: + key = loader.construct_object(key_node, deep=deep) + if key in seen: + raise yaml.constructor.ConstructorError( + None, None, f"duplicate key {key!r}", key_node.start_mark) + seen.add(key) + return yaml.SafeLoader.construct_mapping(loader, node, deep=deep) + + +StrictLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _no_duplicates) + + +def fail(msg): + failures.append(msg) + print(f"[workflow-permissions] FAIL: {msg}", file=sys.stderr) + + +allowed = set() +with open(allowlist_path, encoding="utf-8") as handle: + for lineno, raw in enumerate(handle, 1): + line = raw.strip() + if not line or line.startswith("#"): + continue + parts = line.split(None, 3) + if len(parts) < 4 or not parts[3].strip(): + fail(f"{allowlist_path}:{lineno}: expected ' '") + continue + key = tuple(parts[:3]) + if key in allowed: + fail(f"{allowlist_path}:{lineno}: duplicate entry {' '.join(key)}") + allowed.add(key) + +granted = set() +files = sorted( + f for f in os.listdir(workflows_dir) if f.lower().endswith((".yml", ".yaml")) +) +if not files: + fail(f"no workflow files under {workflows_dir}") + +for name in files: + path = os.path.join(workflows_dir, name) + with open(path, encoding="utf-8") as handle: + try: + wf = yaml.load(handle, Loader=StrictLoader) + except yaml.YAMLError as err: + fail(f"{name}: invalid or ambiguous YAML: {err}") + continue + if not isinstance(wf, dict): + fail(f"{name}: not a workflow mapping") + continue + + if "permissions" not in wf: + fail(f"{name}: no top-level permissions; the default token may be read-write") + else: + top = wf["permissions"] + if top in ({}, "read-all"): + pass + elif isinstance(top, dict): + for perm, level in top.items(): + if str(level) not in READ_ONLY: + fail(f"{name}: top-level permission '{perm}: {level}' grants write to every job; move it to the job that needs it") + else: + fail(f"{name}: top-level permissions '{top}' is not read-only ({{}}, read-all, or read/none values)") + + for job_id, job in (wf.get("jobs") or {}).items(): + if not isinstance(job, dict) or "permissions" not in job: + continue + perms = job["permissions"] + if perms in ({}, "read-all"): + continue + if not isinstance(perms, dict): + fail(f"{name}/{job_id}: job permissions '{perms}' is not allowed (write-all and other shorthands are rejected)") + continue + for perm, level in perms.items(): + level = str(level) + if level in READ_ONLY: + continue + if level != "write": + fail(f"{name}/{job_id}: unknown permission level '{perm}: {level}'") + continue + key = (name, str(job_id), str(perm)) + granted.add(key) + if key not in allowed: + fail(f"{name}/{job_id}: job-level '{perm}: write' is not in {os.path.basename(allowlist_path)}") + +for key in sorted(allowed - granted): + fail(f"stale allowlist entry (no such job-level write grant): {' '.join(key)}") + +if failures: + print(f"[workflow-permissions] {len(failures)} failure(s)", file=sys.stderr) + sys.exit(1) +print(f"[workflow-permissions] PASS: {len(files)} workflows, top-level read-only, {len(granted)} allowlisted job-level write grant(s)") +PY diff --git a/scripts/check-workflow-permissions_test.sh b/scripts/check-workflow-permissions_test.sh new file mode 100755 index 0000000..ff2e3a2 --- /dev/null +++ b/scripts/check-workflow-permissions_test.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash +# Regression suite for scripts/check-workflow-permissions.sh. +# +# The real repository must pass. Each fixture below must be rejected: they +# are the ways a write-scoped GITHUB_TOKEN can reach jobs that never asked for +# it (top-level grants, shorthands, an omitted block) or slip past review +# (an unlisted job grant, a stale allowlist entry). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +script="$ROOT/scripts/check-workflow-permissions.sh" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +bash "$script" >"$tmp/real.log" +grep -Fq 'PASS' "$tmp/real.log" +echo "ok: repository workflows pass" + +allow="$tmp/allow" +cat >"$allow" <<'EOF' +# fixture allowlist +w.yml build packages push the image +EOF + +# check [reason] [file-name]; reads the +# fixture workflow from stdin. A rejected fixture must be rejected for the +# stated reason, so an unrelated failure or a crash cannot pass for one. +check() { + local expect="$1" desc="$2" reason="${3:-}" file="${4:-w.yml}" + rm -rf "$tmp/wf"; mkdir -p "$tmp/wf" + cat >"$tmp/wf/$file" + if BPFCOMPAT_WORKFLOWS_DIR="$tmp/wf" BPFCOMPAT_WORKFLOW_WRITE_ALLOWLIST="$allow" \ + bash "$script" >"$tmp/out.log" 2>&1; then + got=pass + else + got=fail + fi + if [[ "$got" != "$expect" ]]; then + echo "FAIL: ${desc}: expected ${expect}, got ${got}" >&2 + cat "$tmp/out.log" >&2 + exit 1 + fi + if [[ -n "$reason" ]] && ! grep -Fq -- "$reason" "$tmp/out.log"; then + echo "FAIL: ${desc}: rejected, but not for: ${reason}" >&2 + cat "$tmp/out.log" >&2 + exit 1 + fi + echo "ok: ${desc} -> ${expect}" +} + +base_job='jobs: + build: + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + steps: + - run: true' + +check pass "read-only top level with allowlisted job write" <