From b34a0bb54f5fd975d3bf4f7b284e4610050d34c2 Mon Sep 17 00:00:00 2001 From: Umar faruk Date: Mon, 31 Aug 2026 12:37:53 +0000 Subject: [PATCH] hardening: lock GITHUB_TOKEN to contents: read across all CI jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an explicit top-level permissions block to ci.yml and document the per-job audit that justifies the chosen scopes. Problem: no permissions: block meant every job ran with the GitHub default, which includes contents: write on push events. A compromised third-party action (Swatinem/rust-cache, dtolnay/rust-toolchain) or a malicious PR step would have had repository write access — more than any job here actually needs. Changes: .github/workflows/ci.yml - Add top-level permissions: contents: read - Inline per-job audit comment on all 6 jobs (fmt, contract, wasm-size, proptest, audit, mutants) - Block header documents: why only read, trigger coverage (push vs pull_request), and guidance for future jobs that need elevated scope CONTRIBUTING.md - New 'GITHUB_TOKEN permission model' section in the Maintainer Guide: per-job audit table, rationale, and example of how to add a narrowly-scoped job-level override for any future job that needs to write (e.g. posting a PR comment) No build logic changed. All existing CI jobs run identically with the narrowed token — none of them call any GitHub API beyond checkout. --- .github/workflows/ci.yml | 43 ++++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 42 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0ad809..c7588f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,37 @@ on: pull_request: branches: [main] +# ── GITHUB_TOKEN permission policy ──────────────────────────────────────────── +# +# Hardened to the minimum required by every job in this workflow. +# +# Why contents: read and nothing else: +# All jobs here only need to check out source code (contents: read). +# None of them write commits, create releases, post PR comments, call the +# Packages API, or use any other GitHub API scope. GITHUB_STEP_SUMMARY +# writes are local runner file writes — not a GitHub API permission. +# +# Trigger coverage: +# For `pull_request` events from forks, GitHub already restricts the token +# to read-only. For `push` to `main` from the same repo, the default token +# carries `contents: write`. This explicit block overrides both trigger +# types and removes that unneeded write privilege. +# +# Future jobs that need elevated scope: +# Add an explicit job-level `permissions:` override with only the additional +# scope that job requires (e.g. `pull-requests: write` for a PR comment job). +# Do NOT widen the top-level block. +# +# Per-job audit (all trigger types, checked 2026-08-31): +# fmt — checkout only → contents: read ✓ +# contract — checkout, clippy, test → contents: read ✓ +# wasm-size — checkout, build, STEP_SUMMARY → contents: read ✓ +# proptest — checkout, cargo test → contents: read ✓ +# audit — checkout, cargo audit → contents: read ✓ +# mutants — checkout, cargo mutants → contents: read ✓ +permissions: + contents: read + # Cancel in-progress runs for the same branch/PR concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -15,6 +46,7 @@ jobs: fmt: name: Formatting runs-on: ubuntu-latest + # Needs: contents: read (checkout only). Inherits workflow-level minimum. defaults: run: working-directory: intent_settlement @@ -30,6 +62,7 @@ jobs: contract: name: Contract (${{ matrix.toolchain }}) runs-on: ubuntu-latest + # Needs: contents: read (checkout only). Inherits workflow-level minimum. strategy: fail-fast: false matrix: @@ -76,6 +109,9 @@ jobs: wasm-size: name: Wasm size budget runs-on: ubuntu-latest + # Needs: contents: read (checkout only). GITHUB_STEP_SUMMARY is a local + # runner file write — it does not require any GitHub API permission. + # Inherits workflow-level minimum. defaults: run: working-directory: intent_settlement @@ -113,6 +149,7 @@ jobs: proptest: name: Bond-conservation proptest runs-on: ubuntu-latest + # Needs: contents: read (checkout only). Inherits workflow-level minimum. defaults: run: working-directory: intent_settlement @@ -134,6 +171,9 @@ jobs: audit: name: Dependency audit runs-on: ubuntu-latest + # Needs: contents: read (checkout only). cargo-audit reads Cargo.lock from + # the checked-out tree and queries the RustSec advisory DB over HTTPS — + # neither operation touches the GitHub API. Inherits workflow-level minimum. defaults: run: working-directory: intent_settlement @@ -152,6 +192,9 @@ jobs: mutants: name: Mutation testing runs-on: ubuntu-latest + # Needs: contents: read (checkout only). cargo-mutants mutates source in a + # temporary copy on the runner — no GitHub API calls. Inherits workflow-level + # minimum. defaults: run: working-directory: intent_settlement diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 72fa2cf..7fcb845 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -413,6 +413,48 @@ once they are merged: | `WASM size gate` | `ci.yml` (planned) | Blocks merges that grow the wasm by > N KB | | `Coverage` | `coverage.yml` (planned) | Advisory until a baseline is established | +### GITHUB_TOKEN permission model + +All jobs in `ci.yml` run under a **`permissions: contents: read`** top-level +policy. This is the minimum required: every job only needs to check out source +code. + +**Why this matters:** Without an explicit `permissions:` block, GitHub applies +its default token scopes, which include `contents: write` for `push` events on +the same repository. A compromised third-party action (e.g. `Swatinem/rust-cache`, +`dtolnay/rust-toolchain`) or a malicious PR-triggered step would then have +write access to the repository — more privilege than any CI job here actually +needs. + +**Per-job audit (checked 2026-08-31):** + +| Job | What it does | Minimum scope | +|---|---|---| +| `fmt` | Checkout + `cargo fmt --check` | `contents: read` | +| `contract` | Checkout + clippy + test + (optional) fmt | `contents: read` | +| `wasm-size` | Checkout + build + write to `$GITHUB_STEP_SUMMARY` (local runner file, not a GitHub API call) | `contents: read` | +| `proptest` | Checkout + `cargo test` | `contents: read` | +| `audit` | Checkout + `cargo audit` (queries RustSec DB over HTTPS, not the GitHub API) | `contents: read` | +| `mutants` | Checkout + `cargo mutants` (mutates source in a runner-local temp copy) | `contents: read` | + +**Adding a job that needs elevated scope:** + +If a future job needs to post PR comments, push a commit, create a release, or +call any other GitHub API, add a **job-level** `permissions:` override with +only the additional scope that specific job requires. Do not widen the +workflow-level block: + +```yaml +jobs: + my-new-job: + permissions: + contents: read # still needed for checkout + pull-requests: write # needed to post a PR comment — only grant here +``` + +See the [GitHub docs on workflow permissions](https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token) +for the full list of available scopes. + ### MSRV policy The declared MSRV is **Rust 1.78** (see README.md). CI enforces this via a