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
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/kernel-freshness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ on:

permissions:
contents: read
issues: write # file/refresh/close the stale-baselines tracking issue

concurrency:
group: ${{ github.workflow }}
Expand All @@ -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
Expand Down
47 changes: 43 additions & 4 deletions .github/workflows/scholarly-release-v1.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,63 @@ 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
cancel-in-progress: false

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:
Expand Down
69 changes: 69 additions & 0 deletions docs/workflow-token-permissions.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 24 additions & 1 deletion scripts/check-release-consistency.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions scripts/check-release-consistency_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
140 changes: 140 additions & 0 deletions scripts/check-workflow-permissions.sh
Original file line number Diff line number Diff line change
@@ -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
# `<workflow-file> <job-id> <permission> <justification...>`, 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 '<workflow> <job> <permission> <justification>'")
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
Loading
Loading