diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7f731ede..41de0830 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -17,3 +17,14 @@ updates: - "dependencies" commit-message: prefix: "deps:" + + # Python CLI tools (semgrep, ruff) live in + # `tools/setup/manifests/uv-tools`, installed via + # `common/python-tools.sh`. Dependabot does not support the + # uv-tools format, so pin drift is caught by the periodic + # uv-tools drift scan (BACKLOG P1) rather than by this file. + # Three-way-parity per GOVERNANCE §24 takes precedence over + # Dependabot-trackability — one canonical manifest, installed + # the same way on dev laptop / CI / devcontainer, beats a + # parallel `requirements.txt` that exists only to satisfy + # Dependabot's ecosystem list. diff --git a/.github/workflows/resume-diff.yml b/.github/workflows/resume-diff.yml new file mode 100644 index 00000000..f7787ffa --- /dev/null +++ b/.github/workflows/resume-diff.yml @@ -0,0 +1,166 @@ +# Zeta resume-claim diff reviewer-helper +# +# Runs on every PR that touches `docs/FACTORY-RESUME.md` or +# `docs/SHIPPED-VERIFICATION-CAPABILITIES.md` — the two files +# that form the factory's "job-interview honesty" surface per +# `memory/feedback_factory_resume_job_interview_honesty_only_direct_experience.md`. +# +# Purpose (BACKLOG row "Shipped-capabilities resume diff-based +# CI check (round 44 follow-up, H-risk row 24)"; H-risk flagged +# in `docs/research/imperfect-enforcement-hygiene-audit.md`): +# emit a structured claim-level diff as a PR comment so a +# reviewer can eyeball added / removed / modified claims for +# substantive drift. Tighter than round-cadence; NOT a gate — +# judgment stays with the reviewer and with Aaron (honesty- +# floor owner). +# +# Security (reviewed 2026-04-22; complies with the pre-write +# checklist in `docs/security/GITHUB-ACTIONS-SAFE-PATTERNS.md`, +# which is in turn derived from the GitHub Security Lab guide at +# https://github.blog/security/vulnerability-research/how-to-catch-github-actions-workflow-injections-before-attackers-do/): +# - All `${{ github.event.* }}` values are consumed ONLY via +# `env:` blocks and referenced as shell variables in `run:` +# scripts. No inline `${{ ... }}` interpolation inside any +# `run:` command. +# - Inputs are limited to commit SHAs (40-hex, injection-proof) +# and the PR number (numeric). None of the risky inputs +# (issue/PR titles, commit messages, body text, head refs, +# branch names) appear anywhere in this workflow. +# - Third-party actions SHA-pinned (only actions/checkout is +# used). `gh pr comment` is pre-installed on GitHub-hosted +# runners; uses the default workflow token. +# - permissions: contents: read at the workflow level; +# pull-requests: write only at the single job that posts +# the comment. +# - concurrency: workflow-scoped; cancel-in-progress for PR +# events. +# - Runner digest-pinned (ubuntu-22.04). +# - Graceful no-change handling: if the diff has no claim- +# bearing lines, posts a clarifying message and passes. +# Does not fail the PR. + +name: resume-diff + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + paths: + - 'docs/FACTORY-RESUME.md' + - 'docs/SHIPPED-VERIFICATION-CAPABILITIES.md' + +permissions: + contents: read + +concurrency: + group: resume-diff-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + resume-diff: + name: claim-level diff + runs-on: ubuntu-22.04 + timeout-minutes: 5 + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout PR head with full history + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - name: Compute claim-level diff + id: diff + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + + FILES=( + "docs/FACTORY-RESUME.md" + "docs/SHIPPED-VERIFICATION-CAPABILITIES.md" + ) + + OUTPUT_FILE="$(mktemp)" + HAS_CHANGES=0 + + { + echo "### Resume claim-level diff — reviewer attention requested" + echo + echo "This PR touches one or both of the factory's" + echo "**job-interview honesty** docs. Per the honesty" + echo "floor (\`memory/feedback_factory_resume_job_interview_honesty_only_direct_experience.md\`)," + echo "every resume claim must be backed by in-repo evidence" + echo "a reader can verify. Confirm each added claim has" + echo "evidence, each removed claim is intentionally retired," + echo "each modified line preserves the honesty floor." + echo + echo "Base SHA: \`${BASE_SHA}\`" + echo "Head SHA: \`${HEAD_SHA}\`" + } > "$OUTPUT_FILE" + + for FILE in "${FILES[@]}"; do + if ! git diff --quiet "$BASE_SHA" "$HEAD_SHA" -- "$FILE"; then + HAS_CHANGES=1 + { + echo + echo "#### \`$FILE\`" + echo + + RAW_DIFF="$(git diff --no-color --unified=1 \ + "$BASE_SHA" "$HEAD_SHA" -- "$FILE")" + + CLAIM_LINES="$(printf '%s\n' "$RAW_DIFF" \ + | grep -E '^[+-][^+-]' \ + | grep -E '^[+-]\s*(- \*\*|\| |#{2,4} |.*\b(ships?|shipped|verified|proven|complete[ds]?|honest|already absorbed|implement(ed|s)?|in[- ]repo evidence)\b)' \ + || true)" + + if [ -n "$CLAIM_LINES" ]; then + echo "**Claim-bearing lines** (bullets, table rows, headers, honesty-keyword hits):" + echo + echo '```diff' + printf '%s\n' "$CLAIM_LINES" + echo '```' + else + echo "_No claim-bearing lines detected in this file's diff_" + echo "_(changes may be formatting / punctuation / whitespace — still worth a reviewer glance)._" + fi + + echo + echo "
Full unified diff" + echo + echo '```diff' + printf '%s\n' "$RAW_DIFF" + echo '```' + echo + echo "
" + } >> "$OUTPUT_FILE" + fi + done + + if [ "$HAS_CHANGES" -eq 0 ]; then + { + echo + echo "_No changes detected in either resume file at claim level._" + echo "_(The \`paths\` filter matched one of the two files but" + echo "the diff resolved to zero lines — likely a rename or a" + echo "whitespace-only change.)_" + } >> "$OUTPUT_FILE" + fi + + { + echo "diff_file=$OUTPUT_FILE" + echo "has_changes=$HAS_CHANGES" + } >> "$GITHUB_OUTPUT" + + - name: Post PR comment + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + DIFF_FILE: ${{ steps.diff.outputs.diff_file }} + run: | + set -euo pipefail + gh pr comment "$PR_NUMBER" --body-file "$DIFF_FILE" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 00000000..dbab6e01 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,89 @@ +# OpenSSF Scorecard — weekly project-health audit. +# +# Scorecard runs ~20 heuristic checks that score the repo on +# security-relevant posture: branch protection, signed releases, +# dangerous workflows, pinned dependencies, CII best practices, +# dependency-update tools, SAST coverage, token permissions, +# maintained-ness, and so on. Results upload to GitHub +# Security -> Code scanning as SARIF. +# +# Lane: factory. Orthogonal to the CVE scanners (Dependabot + +# `dotnet list --vulnerable`) - Scorecard audits *configuration*, +# not advisories. See `docs/research/vuln-and-dep-scanner- +# landscape-2026-04-22.md` adopt-now item #3 for the rationale. +# +# SECURITY NOTE on expressions +# ---------------------------- +# No attacker-controlled fields are interpolated into any `run:` +# block or action input in this workflow. The only `${{ ... }}` +# expansions are `github.workflow` and `github.ref` (trusted +# contexts) in the concurrency group. Scorecard action inputs +# (`results_format`, `results_file`, `publish_results`) are +# static literals. `publish_results: true` opts in to the +# OpenSSF public Scorecard dashboard - outbound publish of our +# score; no inbound attack surface. Pre-write checklist: +# `docs/security/GITHUB-ACTIONS-SAFE-PATTERNS.md`. +# +# Action-pin content-review (per `docs/security/SUPPLY-CHAIN- +# SAFE-PATTERNS.md`): ossf/scorecard-action v2.4.3 tagged +# 2025-09-30 by sschrock@google.com (OpenSSF maintainer), +# SSH-signed, GitHub API reports verified=true reason=valid. +# Owner org `ossf` is the OpenSSF foundation's GitHub org. +# Commit SHA `4eaacf0543bb3f2c246792bd56e8cdeffafb205a` locks +# the reviewed release. + +name: scorecard + +on: + schedule: + - cron: '0 7 * * 1' + push: + branches: [main] + branch_protection_rule: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + analysis: + name: scorecard analysis + runs-on: ubuntu-22.04 + timeout-minutes: 10 + + permissions: + id-token: write + security-events: write + contents: read + actions: read + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run Scorecard analysis + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload SARIF as workflow artifact + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: scorecard-sarif + path: results.sarif + retention-days: 7 + + - name: Upload SARIF to GitHub code scanning + # Same pin as `.github/workflows/codeql.yml` - one + # codeql-action version across the repo, bumped together. + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + with: + sarif_file: results.sarif diff --git a/.semgrep.yml b/.semgrep.yml index 322e6380..a331ab42 100644 --- a/.semgrep.yml +++ b/.semgrep.yml @@ -340,3 +340,33 @@ rules: # "file lacks any of these headings"; a bespoke diff-level lint # (which round-30 spec calls the "safety-clause-diff lint") is # the stronger signal. Tracked in docs/DEBT.md; target round-31. + + # ──────────────────────────────────────────────────────────────── + # Rule 17 — GitHub Actions workflow-injection: inline untrusted + # context on a `run:` line. The primary workflow-injection vector + # per https://github.blog/security/vulnerability-research/how-to- + # catch-github-actions-workflow-injections-before-attackers-do/. + # Matches single-line `run: ... ${{ github. }} ...` + # forms for the attacker-controlled contexts enumerated in + # docs/security/GITHUB-ACTIONS-SAFE-PATTERNS.md. Multi-line `run: + # |` blocks are covered by actionlint's YAML-aware parser. + # Fix: bind the value to an `env:` entry on the step and read it + # as `"$VAR"` in the shell. See the safe-patterns doc. + # ──────────────────────────────────────────────────────────────── + - id: gha-untrusted-in-run-line + patterns: + - pattern-regex: '(?m)^\s*-?\s*run:.*\$\{\{\s*github\.(head_ref|event\.(issue\.(title|body)|pull_request\.(title|body|head_ref|head\.ref|head\.label)|comment\.body|review\.body|head_commit\.message|commits))' + paths: + include: + - ".github/workflows/*.yml" + - ".github/workflows/*.yaml" + message: >- + Attacker-controlled `github.event.*` / `github.head_ref` value + expanded directly into a `run:` shell command — classic GitHub + Actions workflow-injection sink. A PR title / issue body / + branch name containing shell metacharacters will execute on + the runner. Bind the value to the step's `env:` block and + reference it as `"$VAR"` in the shell instead. See + `docs/security/GITHUB-ACTIONS-SAFE-PATTERNS.md` §Do / don't. + languages: [generic] + severity: ERROR diff --git a/docs/security/GITHUB-ACTIONS-SAFE-PATTERNS.md b/docs/security/GITHUB-ACTIONS-SAFE-PATTERNS.md new file mode 100644 index 00000000..ff4346da --- /dev/null +++ b/docs/security/GITHUB-ACTIONS-SAFE-PATTERNS.md @@ -0,0 +1,198 @@ +# GitHub Actions workflow injection — safe patterns + +**Purpose:** keep every `.github/workflows/*.yml` file in this +repo secure-by-default against workflow-injection attacks, so +new workflow authors (human or agent) don't have to rediscover +the pattern every time. This doc is the **pre-write checklist** +and the authoritative reference for reviewer gates on CI YAML. + +**Primary source:** GitHub Security Lab, *How to catch GitHub +Actions workflow injections before attackers do* +(). +Audit against the blog's current revision on the same cadence as +the rest of the harness-surface audit (FACTORY-HYGIENE #38). + +## Threat model in one sentence + +Any `${{ ... }}` expression expanded directly into a `run:` shell +script can execute attacker-controlled strings if the value +originates from an untrusted context (PR title, issue body, branch +name, commit message, …). The expression is expanded as raw text +*before* the shell runs, so embedded backticks / `$( )` / +newlines-plus-command are evaluated as shell even if the field +looks like plain data. + +## The one rule you cannot break + +> **Never inline `${{ ... }}` for attacker-controllable data +> inside a `run:` block. Bind it to an `env:` entry and read the +> resulting shell variable (`"$VAR"`, always quoted) instead.** + +This is the only rule that is load-bearing for injection +prevention. Everything else on this page is defence in depth. + +## Untrusted context — treat as attacker-controlled + +| Context | Notes | +|---|---| +| `github.event.issue.title` / `.body` | Comes from any GitHub user. | +| `github.event.pull_request.title` / `.body` | Comes from the PR author (often a forker). | +| `github.event.pull_request.head_ref`, `github.head_ref` | Branch name on the PR's head. Forks control it. | +| `github.event.pull_request.base_ref` | Less risky but still user-influenced via PR retargeting. | +| `github.event.head_commit.message` | Commit message. Author-controlled. | +| `github.event.commits[*].message` | Same, for push events. | +| `github.event.comment.body` | Issue / PR / review comments. | +| `github.event.review.body` | PR review body. | +| `github.event.pull_request.head.label`, `.repo.html_url` | Fork-controlled strings. | +| `github.event.workflow_run.head_branch` | Cross-workflow triggers inherit the same risk. | +| Tag names (`github.ref` on `push` tag events) | Tag authors may be external. | +| `workflow_dispatch` / `workflow_call` inputs | Trusted only if the caller is trusted *and* inputs are typed+validated. | + +Anything reaching a `run:` from these contexts without the +env-block buffer is an injection sink. + +## Trusted context — safe to inline + +- `github.workflow`, `github.run_id`, `github.run_number` +- `github.repository`, `github.repository_owner` +- `github.sha`, `github.ref` (for branch refs on push-to-branch), + `github.event.pull_request.number` +- `github.event.pull_request.base.sha`, + `github.event.pull_request.head.sha` (SHAs are 40-hex, injection-proof) +- `runner.*`, `matrix.*`, `hashFiles(...)`, `env.*` (when `env` was + set from a trusted literal), `secrets.*` + +"Trusted" here means GitHub sets the value, not a user. + +## Pre-write checklist + +Before committing any new workflow or editing an existing one, walk +this list. Every item is reviewer-visible; CI enforcement catches +most but not all. + +- [ ] **Trigger choice.** `pull_request` (default) grants no secrets + and write-permissions to fork PRs. `pull_request_target` runs + with repo-owner secrets on forked PRs — use **only** when + genuinely required and with extra scrutiny of every + interpolation. +- [ ] **Permissions minimized.** Workflow-level `permissions: + contents: read`. Per-job elevations only where needed + (`pull-requests: write` for comment posters, + `security-events: write` for SARIF uploaders, …). No + `write-all`. +- [ ] **Env-block buffer for every untrusted value.** Any + `github.event.*` value read in a `run:` step appears only as + an `env:` entry on that step, then as `"$VAR"` in the shell. + Never `${{ github.event.* }}` inline. +- [ ] **Actions SHA-pinned.** Every `uses:` pins a full 40-char + commit SHA with a trailing `# vX.Y.Z` comment for humans. + Mutable tags (`@main`, `@v1`, `@latest`) are forbidden — they + are a supply-chain vector. +- [ ] **Runners pinned.** `ubuntu-22.04` / `macos-14` — never + `-latest`. +- [ ] **Concurrency group.** Declared at workflow level; + `cancel-in-progress: true` for PR events (never for main + pushes — every main commit deserves a record). +- [ ] **Timeout set.** Every job declares a `timeout-minutes`. A + stuck job is a cost and availability risk. +- [ ] **Header comment block.** Starts with a one-paragraph purpose + + a `SECURITY NOTE` section enumerating *which* contexts the + workflow reads and *where* they are consumed. This is the + reviewer's first stop. +- [ ] **No secrets in `run:` strings.** `secrets.*` is fine in + `env:` blocks; never interpolated into a shell command. +- [ ] **Linters will catch the rest.** Do not rely on that — author + correctly, then let lint confirm. + +## Factory tooling that enforces this + +Three layers; each covers a different failure mode. None catches +everything — **the pre-write checklist is the primary defence**; +the linters are the safety net. + +1. **`actionlint`** (`lint-workflows` job in `gate.yml`). Fires on + every PR. Catches unknown context refs, invalid runner labels, + shellcheck-style issues on `run:` blocks, and several well-known + injection patterns (e.g. `${{ github.head_ref }}` inside `run:`). + Hard-fails the build. Highest-signal layer for most authoring + mistakes. +2. **CodeQL `actions` language** (`codeql.yml` matrix includes + `language: actions`, build-mode `none`). GitHub's late-2024 + taint-tracking query for workflow injection runs here. Runs on + PRs-to-main and on a weekly schedule — **not every push to + feature branches**. Findings surface under Security → Code + scanning; triaged per GOVERNANCE.md §22. The most semantically + precise of the three, but also the slowest feedback loop. +3. **Semgrep** (`lint (semgrep)` job). Fires on every PR via + `.semgrep.yml`. Two GitHub-Actions rules: + - `gha-action-mutable-tag` — catches `uses: foo@v1` / `@main` + instead of a 40-char SHA (supply-chain vector). + - `gha-untrusted-in-run-line` — catches single-line + `run: ... ${{ github. }} ...` forms for the + attacker-controlled context list enumerated above (PR + titles, issue bodies, branch refs, commit messages, etc.). + Runs ahead of CodeQL, so injection is caught at PR time even + when CodeQL is on its weekly cadence. Multi-line `run: |` + blocks are left to actionlint's YAML-aware parser. + +If all three pass, the workflow is compliant with the +author-checkable slice of this document — the env-block buffer and +permission-minimization items remain on the author and reviewer. +When any fail, **fix the code, never the lint**. + +## Do / don't — minimal pair + +### Unsafe + +```yaml +- name: Log PR title + run: echo "Processing PR ${{ github.event.pull_request.title }}" +``` + +A PR with title `` `rm -rf ~` `` executes the embedded command. + +### Safe + +```yaml +- name: Log PR title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + echo "Processing PR $PR_TITLE" +``` + +The expansion now happens inside the shell, which treats +`$PR_TITLE` as a plain string. Always quote the variable (`"$PR_TITLE"`) +in non-trivial uses. + +## Why this doc exists in-repo, not just as a link + +- **Offline readable** by every agent and reviewer, including ones + without web-fetch. +- **Factory-specific cross-refs** — points at our actual lint jobs, + our actual CodeQL config, our actual SHA-pinning convention. The + blog is generic; this is ours. +- **Cadenced audit target** — FACTORY-HYGIENE row 40 cadences a + re-read against the blog's current revision so drift is caught. +- **Reviewer citation** — a CI YAML review can reject with "violates + §Pre-write checklist item N" rather than handwaving. + +## Related + +- `docs/security/SUPPLY-CHAIN-SAFE-PATTERNS.md` — sibling + checklist for the third-party-ingress class. The SHA-pinning + rule for `uses:` pins appears in both docs; the supply-chain + doc is the authoritative reference when a pin is being **added + or bumped** (evaluating the dependency itself), while this doc + is authoritative when a workflow is being **authored or edited** + (evaluating the shell commands inside). +- `docs/security/INCIDENT-PLAYBOOK.md` Playbook A — the reactive + counterpart when a third-party action we pinned is discovered + to have been compromised. + +## Scope + +Factory-wide. Applies to every workflow in `.github/workflows/`, +including future workflows for factory-reuse projects that inherit +this CI shape. Inherits automatically via the factory CI discipline +(`docs/research/ci-workflow-design.md`, FACTORY-HYGIENE row 40). diff --git a/docs/security/INCIDENT-PLAYBOOK.md b/docs/security/INCIDENT-PLAYBOOK.md index c3c69036..8ea79405 100644 --- a/docs/security/INCIDENT-PLAYBOOK.md +++ b/docs/security/INCIDENT-PLAYBOOK.md @@ -48,10 +48,26 @@ investigate before you fix. malicious release, OR the upstream repo is compromised and someone rewrites a tag to point at a malicious commit. -**Canonical case:** tj-actions/changed-files cascade -(CVE-2025-30066, March 2025). 4 hops deep, 3-4 month -dwell, SpotBugs PAT → reviewdog → tj-actions → 23,000 -repos. +**Canonical cases** (both mutable-tag class): + +- **tj-actions/changed-files cascade** (CVE-2025-30066, + March 2025). 4 hops deep, 3-4 month dwell, SpotBugs PAT + → reviewdog → tj-actions → 23,000 repos. +- **Trivy TeamPCP attack** (2026-03-19). 76 of 77 version + tags force-pushed on `aquasecurity/trivy-action` plus 7 + of 7 on `aquasecurity/setup-trivy`, malicious binary + `v0.69.4` published via the compromised `aqua-bot` + service account. Notable because (a) the target was a + *security scanner itself* — the very thing ecosystems use + to audit each other — and (b) even SHA-pinned consumers + were hit if they bumped during the compromise window, + which underscores why `docs/security/SUPPLY-CHAIN-SAFE- + PATTERNS.md` §"Content review is the load-bearing step" + matters: a SHA-256 of a compromised release is still a + valid SHA-256. We do not currently consume `trivy-action`; + scanner-adoption decisions are tracked in + `docs/research/vuln-and-dep-scanner-landscape-2026-04- + 22.md`, which defers Trivy pending rebuild-trust. ### Detect diff --git a/docs/security/SUPPLY-CHAIN-SAFE-PATTERNS.md b/docs/security/SUPPLY-CHAIN-SAFE-PATTERNS.md new file mode 100644 index 00000000..0eaa3772 --- /dev/null +++ b/docs/security/SUPPLY-CHAIN-SAFE-PATTERNS.md @@ -0,0 +1,273 @@ +# Supply-chain attack surface — safe patterns + +**Purpose:** keep every third-party-code ingress point in this +repo — GitHub Actions, NuGet packages, toolchain installers, +MSBuild `.targets`, OS-package manifests — secure-by-default +against tag-rewrite, dep-poisoning, and installer-hijack attacks. +This doc is the **pre-add / pre-bump checklist** and the +authoritative reference when an agent or contributor is about to +introduce or upgrade a dependency. + +**Primary sources:** + +- OWASP Software Component Verification Standard (SCVS) — + +- NIST SP 800-218 *Secure Software Development Framework (SSDF)* + PW.4 (Reuse existing, well-secured software) — + +- SLSA supply-chain levels spec — +- GitHub's dependency-review docs — + +- Canonical incidents (both mutable-tag class): + - **CVE-2025-30066** — tj-actions/changed-files tag-rewrite + cascade (March 2025), malicious commit landed on 23,000+ + repos via a single mutable `@v1` tag. + - **Trivy TeamPCP attack** — 2026-03-19, Aqua Security's + Trivy scanner ecosystem compromised by force-push of 76 of + 77 version tags on `aquasecurity/trivy-action` + 7 of 7 on + `aquasecurity/setup-trivy`, plus a malicious binary + `v0.69.4` published via a compromised `aqua-bot` service + account. Even SHA-pinned consumers were hit if they bumped + during the window. This attack targets a *security + scanner itself* — it is the canonical case study for the + "content-review-is-load-bearing" policy above: a SHA-256 + of a compromised release binary is still a valid SHA-256. + Referenced in Semgrep rule `gha-action-mutable-tag`, + Incident Playbook A, and the scanner-landscape research + doc (`docs/research/vuln-and-dep-scanner-landscape-2026- + 04-22.md`) — which defers Trivy adoption pending + rebuild-trust signals. + +Re-read against current revisions of these sources every 5-10 +rounds (FACTORY-HYGIENE row 41, same cadence as GHA safe-patterns). + +## Threat model in one sentence + +Any third-party code that executes in this repo's build, test, or +runtime (including CI toolchain installers) can execute arbitrary +commands with the permissions of that step — so every external +pin is a trust decision that survives until it is re-audited. +Mutable pins (tags, version ranges, moving URLs) turn a one-time +trust decision into a standing capability for the upstream +maintainer to compromise us later. + +## The one rule you cannot break + +> **Every third-party pin references an immutable identifier. +> Tags, floating versions, and URLs-without-digests are not +> pins — they are standing promises.** + +For actions this is a 40-char commit SHA. For NuGet it is a fully +pinned version (no ranges) with a future `packages.lock.json` +commitment. For toolchain installers it is a SHA-256 of the +downloaded artefact. For OS packages it is the pinned version in +the manifest plus repository signature verification. + +### Content review is the load-bearing step — the pin caches it + +The immutable identifier is *how* we lock a decision, but the +decision itself is **content review at first pin**. A SHA-256 that +points at malicious code is still malicious; a hand-verified +script run `curl | bash`-style after a careful read is safe. + +In this factory, Aaron's standing policy (2026-04-22) is: *"never +run a script you download without checking it for vulnerability, +trojans and things of that nature even like gist and stuff, it's +fine to download and run bash and things like that just validate +them first."* So the actual author-time protocol is: + +1. **Download to disk**, do not execute. +2. **Read the script in full** — check for sudo / privilege + escalation, data exfiltration (`curl -X POST`, `nc`, `scp` to + non-project hosts), shell-metacharacter injection, opaque + base64 blobs, cryptominers, calls to suspicious domains, + trojans masquerading as legitimate installers. +3. **Execute if clean**; record the validation decision in the + commit message or manifest comment. +4. **SHA-256-pin the validated content** — the pin is then a + cache of your review. Any bump invalidates the cache and + forces a re-read. + +The delivery mechanism (`curl | bash` vs `curl -o path && bash +path`) is not the risk. The risk is unvalidated content. + +## Third-party-code ingress points + +Zeta has four classes of supply-chain surface. Each has a +different current enforcement level — **which is itself the +dominant residual risk** when an ingress is bumped. + +| Class | Immutable-pin enforced? | Author-time tooling | Reactive playbook | +|---|---|---|---| +| GitHub Actions (`.github/workflows/**`) | **Yes** — Semgrep `gha-action-mutable-tag` + convention of trailing `# vX.Y.Z` comment | Required by rule; lint hard-fails on mutable tags | INCIDENT-PLAYBOOK Playbook A (third-party action compromise) | +| NuGet packages (`Directory.Packages.props`) | **Partial** — central version management + exact versions. `packages.lock.json` not yet adopted (SDL #7 deliverable). | `tools/audit-packages.sh` + `package-auditor` skill (manual) | INCIDENT-PLAYBOOK Playbook C (NuGet dep poisoning) | +| Toolchain installers (`tools/setup/manifests/{brew,apt,dotnet-tools,uv-tools,verifiers}`) | **Partial** — versions declared per manifest; not all artefacts carry SHA-256. | `tools/setup/install.sh` is the single consumer; `ensure-*` scripts are opportunistic | INCIDENT-PLAYBOOK Playbook B (toolchain installer hijack mid-setup) | +| MSBuild `.targets` auto-imported via NuGet | **No** — SDL #7 open deliverable; any package may ship executable MSBuild logic that runs during `dotnet build`. | Manual review; no allowlist yet | Playbook C covers the exploit path | + +If you bump or add something in a class marked Partial / No, the +pre-add checklist is **load-bearing** — the lint won't save you. + +## Pre-add / pre-bump checklist — universal + +Before committing any third-party addition or bump, walk this +list. Every item is reviewer-visible. + +- [ ] **Justify the dependency at all.** For a new add: does + something already in `Directory.Packages.props` / the toolchain + / the standard library cover this? Dependency deletion beats + dependency audit. +- [ ] **Read the release notes since the current pin.** Not a + skim — specifically look for: breaking changes, new + maintainers, repository transfers, deprecation warnings, + scope expansions, new transitive dependencies. +- [ ] **Confirm project health.** Recent commits, responsive + issue tracker, not a single-maintainer abandonware account. + For high-risk bumps, check the package registry for + maintainer account changes. +- [ ] **Pin by immutable identifier.** SHA for actions; exact + version for NuGet; SHA-256 digest (where available) for + toolchain installers; pinned version + repo signature for + OS packages. Trailing human-readable comment (`# v1.2.3`) + so diffs are readable. +- [ ] **Re-run the whole build gate.** `dotnet build -c Release` + plus `dotnet test Zeta.sln -c Release` plus the full CI + matrix on a PR — do not trust a one-OS bump. +- [ ] **Document the bump rationale** in the commit message. + "Why this version, now?" Future you (or a future incident + responder) needs this. +- [ ] **Check upstream attestations / SLSA level** if available. + SLSA-3+ packages carry build-provenance metadata GitHub can + verify automatically. + +## Pre-add / pre-bump checklist — per class + +### GitHub Actions + +- [ ] `uses:` points at a 40-char commit SHA, never a tag. +- [ ] Trailing `# vX.Y.Z` comment for humans. +- [ ] Action is from a **known maintainer** — GitHub-owned + (`actions/*`), a major-org account, or a single-author action + we've audited source for. Avoid newly-created third-party + actions unless the blast radius is trivial. +- [ ] Semgrep `gha-action-mutable-tag` + `actionlint` both pass. +- [ ] Also apply the injection checklist in + `docs/security/GITHUB-ACTIONS-SAFE-PATTERNS.md`. + +### NuGet packages + +- [ ] Added / bumped only in `Directory.Packages.props` (central + version management). No inline `` + in `.fsproj` / `.csproj`. +- [ ] Version is exact, not a range (`1.2.3`, never `[1.0,2.0)`). +- [ ] `tools/audit-packages.sh` was run locally before commit. +- [ ] `package-auditor` skill workflow was followed for MAJOR + bumps — read CHANGELOG for Breaking / Removed bullets, grep + our source for any usage of removed surface, **test before + deferring**. +- [ ] If the package ships MSBuild `.targets`, manually read the + targets file(s) before committing — they run at build time. +- [ ] When `packages.lock.json` adoption lands (SDL #7), the lock + file gets updated and committed alongside the bump. + +### Toolchain installers (`tools/setup/`) + +- [ ] Addition or bump lands in the relevant manifest under + `tools/setup/manifests/`, not ad-hoc in a script. +- [ ] Where the upstream publishes SHA-256 digests, include the + digest in the manifest; the install script verifies. +- [ ] The three-way-parity invariant still holds (GOVERNANCE.md + §24) — dev laptop, CI, devcontainer all bootstrap from the + same manifest. +- [ ] For new installer scripts, prefer Homebrew / apt / mise / + elan over hand-rolled `curl | bash`; when `curl | bash` is + unavoidable, pin the raw URL by SHA-256. + +### OS-package manifests (brew / apt) + +- [ ] Package listed in `tools/setup/manifests/{brew,apt}` with + pinned version. +- [ ] For apt: repository GPG key is captured under + `tools/setup/apt-keys/` and verified during install, not + fetched from a keyserver at install-time. + +## Factory tooling that enforces this + +Three layers; none individually sufficient — the pre-add +checklist is the primary defence. + +1. **Semgrep** — `.semgrep.yml` rule `gha-action-mutable-tag` + hard-fails any non-SHA action pin. Runs on every PR. One rule + today; expansion candidates tracked in SDL #7. +2. **`package-auditor` skill + `tools/audit-packages.sh`** — the + manual NuGet-side audit. Paired with an agent; not yet + CI-gated (SDL #7 open deliverable to make the audit a gate). +3. **Incident playbooks** (`docs/security/INCIDENT-PLAYBOOK.md`) — + reactive. Playbook A (action compromise), B (toolchain + installer hijack), C (NuGet dep poisoning), D (maintainer + account compromise). Triggered when prevention fails. + +When adding a third-party surface that none of the above covers, +either extend the tooling in the same commit or flag a SECURITY- +BACKLOG row naming the residual risk explicitly. + +## Do / don't — minimal pair + +### Unsafe + +```xml + + +``` + +```yaml +# .github/workflows/foo.yml — one mutable tag is a trust delegation +- uses: tj-actions/changed-files@v45 +``` + +### Safe + +```xml + + +``` + +```yaml +# Full SHA is the trust anchor; trailing comment keeps diffs readable. +- uses: tj-actions/changed-files@0fe0c5a3b5ed3a1df2c6e8bab4a1a52f8e4c07d9 # v45.0.7 +``` + +## Upstream signals to watch + +The supply-chain threat surface evolves fast. During the cadenced +re-read (FACTORY-HYGIENE row 41), check: + +- **GitHub Security Advisories** for packages we depend on + (`dependabot` alerts on the repo; Security → Dependabot). +- **SLSA progress tracker** — new attestation publishers; new + verification requirements. +- **CVE feeds** filtered for NuGet ecosystem + GitHub Actions + ecosystem. +- **Incident write-ups** for the canonical attack classes (the + `tj-actions/changed-files` post-mortem is the reference; newer + incidents get added here). + +## Why this doc exists in-repo + +- **Author-time reference.** Agents and contributors about to add + a dependency should not have to rediscover OWASP SCVS or NIST + SSDF — the relevant subset lives here, cross-referenced to our + actual tooling. +- **Factory-specific cross-refs.** Points at our Semgrep rule, + our `audit-packages.sh`, our manifests, our playbooks. Generic + external guidance can't do that. +- **Cadenced audit target.** FACTORY-HYGIENE row 41 re-reads + upstream guidance on cadence so drift is caught. +- **Reviewer citation surface.** A PR review can reject with + "violates §Pre-add checklist item N" rather than handwaving. + +## Scope + +Factory-wide. Applies to every third-party ingress in this repo +and — via factory reuse — to adopter projects. Inherits +automatically via the factory CI discipline and the ships-to- +project row in `docs/FACTORY-HYGIENE.md`.