From cf7a20ac94eccd8c3494c11661ccd69aa51c4cd6 Mon Sep 17 00:00:00 2001 From: ErenAri Date: Wed, 14 Oct 2026 20:56:41 +0300 Subject: [PATCH 1/4] ci: keep top-level workflow token permissions read-only Code-scanning alert #178 (OpenSSF Scorecard Token-Permissions, topLevel): scholarly-release-v1.yml granted contents, id-token and attestations write at the workflow top level. A top-level grant applies to every job, including any job added later, so it is never least privilege. kernel-freshness.yml had the same defect with issues: write; Scorecard does not report that one, but it is the same root cause. Both workflows have exactly one job. The write grants move onto that job (publish / freshness), and the top level becomes contents: read. Each job's effective permissions are unchanged; only the default for any future job is narrowed. Add scripts/check-workflow-permissions.sh, run in CI by scripts/check-workflow-permissions_test.sh: - Every workflow must declare read-only top-level permissions. - write-all is rejected. - Every job-level write must be listed, with a justification, in scripts/workflow-write-permissions.allow. - Stale allowlist entries fail, so the list cannot become a blanket exemption. The suite fails against the workflows on main (802d515) and passes after this change. It covers flow mappings, quoted values, an omitted block, shorthands, an unlisted job grant and a grant moved to another job. Signed-off-by: ErenAri --- .github/workflows/ci.yml | 5 + .github/workflows/kernel-freshness.yml | 4 +- .github/workflows/scholarly-release-v1.yml | 12 +- scripts/check-workflow-permissions.sh | 140 +++++++++++++ scripts/check-workflow-permissions_test.sh | 230 +++++++++++++++++++++ scripts/workflow-write-permissions.allow | 33 +++ 6 files changed, 419 insertions(+), 5 deletions(-) create mode 100755 scripts/check-workflow-permissions.sh create mode 100755 scripts/check-workflow-permissions_test.sh create mode 100644 scripts/workflow-write-permissions.allow diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8eb7881..104a4840 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,6 +126,11 @@ 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: 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 3bf535df..edd7ec63 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 c394f5d1..1677f569 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 @@ -24,6 +23,11 @@ jobs: name: Publish immutable research v1 release 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/scripts/check-workflow-permissions.sh b/scripts/check-workflow-permissions.sh new file mode 100755 index 00000000..02f4ba07 --- /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 00000000..ff2e3a26 --- /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" <