diff --git a/.agents/skills/maintain/SKILL.md b/.agents/skills/maintain/SKILL.md index cd771c710..7c69d3afa 100644 --- a/.agents/skills/maintain/SKILL.md +++ b/.agents/skills/maintain/SKILL.md @@ -24,6 +24,17 @@ upstream version cannot preserve a required security contract, establish the incompatibility, retain the newest safe version with a tested/documented pin, and complete the rest of the pass; do not replace security checks with weaker ones. +Audit CI, not just its results. A green run says nothing about what the workflow +definition hands to the code it executes, so each pass reviews `.github/workflows/` +against the Workflow Credential Audit in `knowledge/operations/maintenance.md`: +checkout credentials persisted only where a `git`/`gh` call authenticates with them +and never in a pull-request-triggered job, a top-level `permissions` block on every +workflow, runtime-fetched secrets masked before they travel and never routed through +`$GITHUB_ENV`, broad tokens confined to steps that run no repository code, no +`pull_request_target`/`workflow_run`/`issue_comment` triggers, and no untrusted +`${{ github.event.* }}` interpolation inside a `run:` block. Treat a finding here +as a fix in this pass; `scripts/tests/test_ci_supply_chain.py` holds the regressions. + Do not create deferral issues instead of completing maintenance unless the user explicitly requests a deferred scope or a genuine external blocker requires it. Only stop for an actual missing permission, unavailable dependency/service, or diff --git a/.github/workflows/cli-binaries.yml b/.github/workflows/cli-binaries.yml index 49fd58969..ebbb9b606 100644 --- a/.github/workflows/cli-binaries.yml +++ b/.github/workflows/cli-binaries.yml @@ -229,9 +229,11 @@ jobs: # fine-grained PAT scoped to `everruns/homebrew-tap` only # (Contents: read+write, Metadata: read), so a leak cannot # touch the main bashkit repo. + # Register the mask before the value can reach any log line: an + # `set -x` or error trace between fetch and mask would print it. GH_PAT=$(doppler secrets get HOMEBREW_TAP_GITHUB_TOKEN --plain) - export GH_PAT echo "::add-mask::$GH_PAT" + export GH_PAT # Use askpass to avoid embedding token in clone/push URLs or logs ASKPASS_SCRIPT=$(mktemp) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 88a460bd0..362b23c6a 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -28,6 +28,8 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install Rust toolchain uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 @@ -76,6 +78,8 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install Rust toolchain uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 @@ -125,6 +129,8 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install Rust toolchain uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index db47c5cd3..899dd5859 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -30,6 +30,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install Rust nightly uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index d915d6658..ff3a6f598 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -38,6 +38,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install Rust toolchain uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 @@ -110,6 +112,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install Rust toolchain uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 6ad18f26c..cc0efcd3e 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -21,6 +21,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install Rust nightly with Miri uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # nightly @@ -72,6 +74,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install Rust toolchain uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 @@ -106,6 +110,8 @@ jobs: timeout-minutes: 90 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # `-Z build-std` rebuilds std + every dep + every bashkit test with # ASAN instrumentation. Target dir grows past the default runner's diff --git a/.github/workflows/publish-js.yml b/.github/workflows/publish-js.yml index 172fae3de..fb375de59 100644 --- a/.github/workflows/publish-js.yml +++ b/.github/workflows/publish-js.yml @@ -82,6 +82,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 @@ -172,6 +174,8 @@ jobs: runs-on: ${{ matrix.settings.host }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 @@ -293,6 +297,8 @@ jobs: runs-on: ${{ contains(matrix.target, 'aarch64') && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 diff --git a/.github/workflows/publish-python.yml b/.github/workflows/publish-python.yml index b54895eca..b7c5a0a25 100644 --- a/.github/workflows/publish-python.yml +++ b/.github/workflows/publish-python.yml @@ -31,6 +31,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: @@ -94,6 +96,8 @@ jobs: runs-on: ${{ matrix.runs-on }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: @@ -131,6 +135,8 @@ jobs: PYODIDE_BUILD_VERSION: "0.34.4" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # Python 3.13 -> pyodide-build's modern config (Pyodide 0.29.x, Emscripten # 4.0.9), whose binaryen understands modern LLVM's wasm target-features and diff --git a/.github/workflows/publish-wasm.yml b/.github/workflows/publish-wasm.yml index cb7fdbac9..2c700e5b5 100644 --- a/.github/workflows/publish-wasm.yml +++ b/.github/workflows/publish-wasm.yml @@ -48,6 +48,8 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install Rust uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 9bdf8d7c7..0612ee4dd 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -41,6 +41,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 @@ -74,6 +76,8 @@ jobs: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: @@ -121,6 +125,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: @@ -173,6 +179,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: @@ -212,6 +220,8 @@ jobs: PYODIDE_BUILD_VERSION: "0.34.4" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # Python 3.13 selects pyodide-build's modern config (Pyodide 0.29.x, # Emscripten 4.0.9), whose binaryen understands the wasm target-features diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8f58e8d7..cda3e6533 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,12 @@ on: - main workflow_dispatch: +# Least privilege by default: the single `release` job below opts into the +# write scopes it needs. Any job added later starts read-only instead of +# inheriting the repository default. +permissions: + contents: read + jobs: release: name: Create Release @@ -16,6 +22,9 @@ jobs: # Only run if manually triggered OR commit message starts with "chore(release): prepare v" if: "${{ github.event_name == 'workflow_dispatch' || startsWith(github.event.head_commit.message, 'chore(release): prepare v') }}" steps: + # Checkout credentials are deliberately persisted here: `git push origin + # "$TAG"` below authenticates through them. This job runs only from + # protected `main` and executes no pull-request-authored code. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml index 4acbcc238..aa64512de 100644 --- a/.github/workflows/site.yml +++ b/.github/workflows/site.yml @@ -39,6 +39,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 with: @@ -55,6 +57,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index f2c4da756..ebea4d988 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -277,7 +277,7 @@ Scripts may attempt to leak sensitive information. | Threat | Attack Example | Mitigation | Status | |--------|---------------|------------|--------| -| Fork-PR secret exfil (TM-INF-026) | Fork PR edits `examples/*.rs` or `build.rs` to read `$DOPPLER_TOKEN` / `$ANTHROPIC_API_KEY` from the runner env and exfiltrate; first-time-contributor approval gate runs the workflow from PR head, and `DOPPLER_TOKEN` is the master key to every other secret in the Doppler config | Trusted-run gates exclude PR execution from secret-backed examples. Dedicated fetch steps in `.github/workflows/{ci,js,publish-js}.yml` request one API key, mask it, and exit before build/example execution. Execution steps receive only that scoped key; Docker inherits it by name, without a secret value in argv. Neither the execution shell nor a live Doppler parent retains the broad service token. This protects process environments on a trusted runner; it does not sandbox dependencies persisting across steps. Workflow-script regression tests check child/parent environments. CI has contents-read permission only and does not persist checkout credentials; release examples install the reviewed lockfile without lifecycle scripts before linking the built artifact | **FIXED** | +| Fork-PR secret exfil (TM-INF-026) | Fork PR edits `examples/*.rs` or `build.rs` to read `$DOPPLER_TOKEN` / `$ANTHROPIC_API_KEY` from the runner env and exfiltrate; first-time-contributor approval gate runs the workflow from PR head, and `DOPPLER_TOKEN` is the master key to every other secret in the Doppler config | Trusted-run gates exclude PR execution from secret-backed examples. Dedicated fetch steps in `.github/workflows/{ci,js,publish-js}.yml` request one API key, mask it, and exit before build/example execution. Execution steps receive only that scoped key; Docker inherits it by name, without a secret value in argv. Neither the execution shell nor a live Doppler parent retains the broad service token. This protects process environments on a trusted runner; it does not sandbox dependencies persisting across steps. Workflow-script regression tests check child/parent environments. CI has contents-read permission only and does not persist checkout credentials anywhere they could reach untrusted code (TM-INF-034); release examples install the reviewed lockfile without lifecycle scripts before linking the built artifact | **FIXED** | **Additional information-disclosure hardening:** @@ -287,6 +287,7 @@ Scripts may attempt to leak sensitive information. | jq `halt`/`halt_error` exits the host process (TM-INF-023) | Filter calls `halt(N)` → `std::process::exit` | Upstream `halt` native stripped; safe replacement returns a jq error | MITIGATED | | Host env side-channel via clap `Arg::env` (TM-INF-024) | `ls` resolved `TABSIZE`/`TIME_STYLE` from host env | Codegen strips `.env(...)`; builtins read `ctx.env` only | MITIGATED | | Untrusted generated Rust in drift CI (TM-INF-025) | Malicious upstream `uu_app()` runs with a write token | Generator validates the emitted shape; drift workflow splits read/write privilege | FIXED | +| Checkout token readable by untrusted CI code (TM-INF-034) | `actions/checkout` leaves `GITHUB_TOKEN` in `.git/config`, where a dependency build script or test in the same job can read it | `persist-credentials: false` on every checkout that does not back an authenticated `git`/`gh` call, and on every job of a pull-request-triggered workflow; every workflow declares a read-only default `permissions` block | FIXED | | Publish from unverified release refs (TM-INF-027) | Dispatch publish from a branch or unprotected tag | Dispatch requires `refs/heads/main`; publish gated on real `vX.Y.Z` tags | FIXED | | JS `onOutput` errors expose host stack traces (TM-INF-028) | Callback throws, leaking `error.stack` | Propagate `error.message` only; strip absolute/`file://` paths | FIXED | | Raw callback errors leak host internals (TM-INF-030) | Tool callback throws API keys/connection strings/stack traces | `sanitize_errors` defaults on for `ScriptedTool`/`ToolImpl`/`ToolRegistry` across shell/Python/TypeScript | MITIGATED | diff --git a/knowledge/operations/maintenance.md b/knowledge/operations/maintenance.md index 8a7b7db6c..3030c9fc0 100644 --- a/knowledge/operations/maintenance.md +++ b/knowledge/operations/maintenance.md @@ -276,6 +276,34 @@ See [Coreutils Argument Port](../runtimes/coreutils-args-port.md). - Git-sourced dependencies still resolve - No orphaned CI quarantines - `grep -rn continue-on-error .github/workflows/`; each hit must cite its tracking issue (bun-canary quarantine: #2417, revert when the canary `bun-linux-x64.zip` asset is back) +#### Workflow Credential Audit + +Run-level green is not the same as a safe workflow definition. Audit +`.github/workflows/` itself each pass; the enforced invariants live in +`scripts/tests/test_ci_supply_chain.py` and `scripts/tests/test_maintenance_security.py` +(TM-INF-026, TM-INF-027, TM-INF-034), so a new workflow that violates one fails +`just test` rather than silently shipping. + +- **Checkout credentials**: every `actions/checkout` sets + `persist-credentials: false` unless the job authenticates a `git`/`gh` call. + Without it the job's `GITHUB_TOKEN` sits in `.git/config` where any + `build.rs`, npm lifecycle script, or test in the same job can read it. No job + in a `pull_request`-triggered workflow may keep it. +- **Default permissions**: every workflow declares a top-level `permissions` + block; write scopes are opt-in per job, never inherited. +- **Secret transport**: a secret fetched at runtime is invisible to GitHub's log + masker until `::add-mask::` announces it. Fetch, mask, then pass it on through + `$GITHUB_OUTPUT` and step `env`. Never `$GITHUB_ENV` - that republishes the + value in the environment block of every later step. Only a + `secrets.X != ''` presence boolean may reach `$GITHUB_ENV`. +- **Credential lifetime**: the step holding a broad token (`DOPPLER_TOKEN`) + fetches one key and exits before any repository code runs. No `cargo`, `node`, + `bun`, `docker`, or `examples/` invocation shares a step with it. +- **Triggers**: no `pull_request_target`, `workflow_run`, or `issue_comment` + trigger; these run with a writable token in an attacker-influenced context. +- **Untrusted interpolation**: no `${{ github.event.* }}` or `${{ github.head_ref }}` + expanded directly into a `run:` block. Pass it through `env:` instead. + #### Escalation Policy Failures persisting **>2 consecutive days** on any workflow (CI, nightly, fuzz) diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 6dd6a4ad6..075a46a19 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -431,13 +431,14 @@ execute permission (mode & 0o111); exit 127 missing / 126 non-executable; sheban | TM-INF-023 | jq `halt` / `halt_error` terminate the host process | jaq-std 3.0's `halt(N)` native calls `std::process::exit()`; untrusted jq filters could exit the entire embedding process, escaping the `ExecResult` sandbox boundary (process-wide DoS) | Strip the upstream `halt` native from `jaq_std::funs::()` and chain in a safe replacement returning `Error::str("halt is disabled in the bashkit sandbox")`; wrapper defs (`halt`, `halt_error`) still resolve, so callers see a normal jq runtime error (exit 5). Regression tests in `builtins::jq::tests`: `halt_does_not_terminate_host_process`, `halt_with_arg_does_not_terminate_host_process`, `halt_error_does_not_terminate_host_process` | **FIXED** | | TM-INF-024 | Host env side-channel via clap `Arg::env(...)` | uutils-ported clap builtins (currently `ls`: `.env("TABSIZE")`/`.env("TIME_STYLE")`) resolved defaults from `std::env`, not `ctx.env`, (1) presence-probe of host env vars via `ls` behaviour; (2) a host-set `TIME_STYLE` became a value source for an unimplemented option, tripping the unsupported-option gate on every plain `ls` for unrelated tenants | Four-layer fix: (1) codegen, `bashkit-coreutils-port` strips runtime `.env(...)` calls and emits a sidecar `_ENV_DEFAULTS: &[EnvDefault]` table per stripped annotation; (2) virtual-env shim, `crate::builtins::clap_env::apply_env_defaults` rewrites argv from `ctx.env` (never `std::env`) before `try_get_matches_from`, emulating clap's "argv > env > default" precedence; (3) static guards, `no_clap_env_in_generated_parsers` forbids runtime `.env(` in `generated/*.rs`; `every_generated_parser_emits_env_defaults_table` enforces the sidecar on every util; `no_host_env_reads_in_generated_code` recursively forbids `std::env::var*` reads in `generated/**`, covering vendored uucore module bodies (which have no `ctx` and so cannot reach the virtual env) — `bashkit-coreutils-port`'s `fold_host_env` folds names allow-listed in `vendored.toml`'s `host_env` to "unset" at port time and aborts the port on any other env read, so upstream adding one (as it did with a `POSIXLY_CORRECT` probe in `format/argument.rs`) fails drift loudly; (4) defence-in-depth, workspace `clap` drops the `env` cargo feature, so a re-introduced `.env(...)` fails to compile. Coverage: `ls_ignores_host_time_style_and_tabsize` (host env ignored) and `ls_honors_virtual_env_time_style` (virtual env honoured) | **FIXED** | | TM-INF-025 | Untrusted generated Rust runs in drift CI with repository write token | The coreutils argument-drift workflow regenerates Rust from third-party `uutils/coreutils` `uu_app()` then builds/tests bashkit; if the generator preserved arbitrary upstream statements and the job held `contents: write`/`pull-requests: write`, malicious upstream code could execute with repository write impact | Two-layer fix: (1) `bashkit-coreutils-port` validates args-mode `uu_app()` before emission, accepts only a single tail clap `Command` builder chain, or `let = Command::new(...); .(...)` with the tail chained off the let-bound ident; both shapes pass a disallowed-method/closure/macro/block visitor. Regression tests `rejects_executable_statements_in_uu_app`, `rejects_let_with_non_command_initializer`, `rejects_tail_not_chained_off_let_binding`, `rejects_three_statement_body`. (2) `.github/workflows/coreutils-args-drift.yml` separates privilege: regeneration/build/test runs with `contents: read` + `persist-credentials: false`; the `open-pr` job has write permission but never builds or executes generated Rust, only commits the tested artifact | **FIXED** | -| TM-INF-026 | Fork-PR secret exfiltration via approved CI run | GitHub's first-time-contributor gate runs the workflow **from the PR head**, and approved contributors auto-run thereafter. The `examples` job in `ci.yml` set `DOPPLER_TOKEN` at job level and `ANTHROPIC_API_KEY` at step level, so a fork PR could edit any `examples/*.rs`/`build.rs`/script the job runs and exfiltrate. `DOPPLER_TOKEN` is the master key, it fetches every other secret in the Doppler config, so one leak compromises all. Every `doppler run --` also injected the entire config into the step env | Trusted-run gates exclude PR execution from secret-backed examples. Dedicated credential-fetch steps receive `DOPPLER_TOKEN`, request one API key, mask it, and exit before build/example processes run. Execution steps receive only the requested API key through step output/env; Docker inherits it by name, without putting its value in argv. No broad token remains in the execution shell or a live Doppler parent. This is process-lifetime isolation on a trusted runner, not a sandbox against compromised dependencies persisting across steps. Regression tests execute the workflow scripts and inspect child/parent environments: `scripts/tests/test_maintenance_security.py`. CI has contents-read permission only and checkout credentials are not persisted. Release examples install the committed dependency lockfile with lifecycle scripts disabled before linking the built artifact; `scripts/tests/test_ci_supply_chain.py` enforces both boundaries | **FIXED** | +| TM-INF-026 | Fork-PR secret exfiltration via approved CI run | GitHub's first-time-contributor gate runs the workflow **from the PR head**, and approved contributors auto-run thereafter. The `examples` job in `ci.yml` set `DOPPLER_TOKEN` at job level and `ANTHROPIC_API_KEY` at step level, so a fork PR could edit any `examples/*.rs`/`build.rs`/script the job runs and exfiltrate. `DOPPLER_TOKEN` is the master key, it fetches every other secret in the Doppler config, so one leak compromises all. Every `doppler run --` also injected the entire config into the step env | Trusted-run gates exclude PR execution from secret-backed examples. Dedicated credential-fetch steps receive `DOPPLER_TOKEN`, request one API key, mask it, and exit before build/example processes run. Execution steps receive only the requested API key through step output/env; Docker inherits it by name, without putting its value in argv. No broad token remains in the execution shell or a live Doppler parent. This is process-lifetime isolation on a trusted runner, not a sandbox against compromised dependencies persisting across steps. Regression tests execute the workflow scripts and inspect child/parent environments: `scripts/tests/test_maintenance_security.py`. CI has contents-read permission only and checkout credentials are not persisted anywhere they could reach untrusted code (TM-INF-034). Release examples install the committed dependency lockfile with lifecycle scripts disabled before linking the built artifact; `scripts/tests/test_ci_supply_chain.py` enforces both boundaries | **FIXED** | | TM-INF-027 | Package publish from unverified release refs | A user with Actions/write privileges could dispatch release/publish workflows from a branch or unprotected `vX.Y.Z` tag; `release.yml` dispatches downstream workflows with `--ref "$TAG"`, and `publish-js.yml` treated stable `package.json` semver as `latest` even on non-tag refs, an attacker-controlled ref could source official crates.io/npm/PyPI/CLI/Homebrew artifacts | Release dispatches require full ref `refs/heads/main`; `release.yml` verifies the source is reachable from `origin/main` and the created tag resolves to the current `GITHUB_SHA` before dispatching; `publish.yml` rejects manual runs unless `GITHUB_REF` is a stable release tag matching `Cargo.toml` on a main-reachable commit; `publish-js.yml` requires main-reachable commit, assigns npm `latest` only for stable `vX.Y.Z` tag refs matching `package.json`; `publish-python.yml` applies the same main-reachable stable-tag + Cargo version gates; `cli-binaries.yml` accepts only stable `vX.Y.Z` inputs and verifies workflow ref = tag = `Cargo.toml` version on a main-reachable commit before building/uploading or updating Homebrew; CLI and C ABI validation resolve the fetched tag to an immutable commit, read its checked-out manifest, and export that SHA to all build jobs, preventing tag movement from substituting unvalidated source. Real Git tag-movement regressions: `scripts/tests/test_release_security.py` | **FIXED** | | TM-INF-028 | JS callback errors expose host stack traces | When a caller-supplied output or custom-builtin callback throws, propagating `error.stack` or an unsanitized message can expose host file paths and internal function names across the sandbox boundary | Native and wasm bindings propagate `error.message` only, strip absolute-path and `file://` segments, and cap diagnostics at 256 characters. Regressions: `onOutput error does not leak stack trace or host paths` in `crates/bashkit-js/__test__/streaming-output.spec.ts` and `callback errors do not leak host paths or stacks` in `crates/bashkit-wasm/__test__/bashkit-wasm.test.mjs`; mitigations annotated `THREAT[TM-INF-028]` in both binding implementations | **FIXED** | | TM-INF-030 | Raw callback errors leak host internals through ScriptedTool/ToolImpl/ToolRegistry | Tool callbacks may throw errors containing API keys, connection strings, or stack traces; output is attacker-influenced and agent-visible | `sanitize_errors` defaults to true on `ScriptedTool`, `ToolImpl`, and the cross-runtime `ToolRegistry`; every shell/Python/TypeScript registry surface returns the same generic message (`scripted_tool/mod.rs`, `tool_def.rs`, `tool_registry.rs`) | **MITIGATED** | | TM-INF-031 | `final_env` capture bypasses output filtering and size caps | When `capture_final_env` is enabled, env contents are a user-visible output channel that could leak internal markers or exceed output limits | Visibility filtering + output-byte cap applied when building `final_env` (`interpreter/mod.rs`) | **MITIGATED** | | TM-INF-032 | Competitor fixture import executes untrusted upstream code in CI | Automatically downloading or mechanically copying third-party regression scripts into a host-shell oracle would let a compromised upstream run arbitrary commands with CI permissions; a source commit hash proves provenance, not trust | Competitor corpora are manually reviewed, data-only JSON checked into the repository with full commit provenance; CI performs no fetch; Bashkit cases run in the VFS with normal resource limits; only cases explicitly marked `real_bash` run under the host oracle, with a cleared environment and temporary cwd. The schema/classification gate is `competitor_corpus_has_complete_provenance_and_classification`; process contract is [Testing Strategy](../operations/testing.md) | **MITIGATED** | | TM-INF-033 | `time` becomes a high-resolution host timing oracle or exposes host-process metrics | Untrusted scripts repeatedly measure small operations or request GNU CPU/RSS fields to infer co-tenant and host behavior | Monotonic elapsed time follows Bashkit's portable/virtual clock; the Hardened profile floors elapsed values into 100 ms buckets; CPU, RSS, and other host-process fields deterministically report `unavailable`; verbose output includes only Bashkit-owned counters | **MITIGATED** | +| TM-INF-034 | Checkout token readable by untrusted CI code outside `ci.yml` | `actions/checkout` writes the job's `GITHUB_TOKEN` into `.git/config` as `http..extraheader` unless `persist-credentials: false` is set. Only `ci.yml` set it, so the pull-request-triggered `js.yml`, `python.yml`, `coverage.yml` and `site.yml` handed a live repository credential to fork-authored code (dependency `build.rs`, npm lifecycle scripts, pytest, `pnpm build`) running in the same job, and the release-time build/test jobs of `publish-js.yml`, `publish-python.yml` and `publish-wasm.yml` did the same for artifact builds. `release.yml` additionally inherited the repository default permissions rather than declaring its own | Every checkout that does not back an authenticated `git`/`gh` call now sets `persist-credentials: false`; the credential survives only in the eight tag-verification, source-verification and release jobs that push or fetch with it, all of which run from `main`-reachable refs and execute no PR-authored code. `release.yml` declares a top-level `permissions: contents: read` so the job's `contents`/`actions` write scopes stay opt-in. The rule is derived, not allowlisted: `scripts/tests/test_ci_supply_chain.py` fails any job that persists the token without running `git`/`gh`, and any job at all in a pull-request-triggered workflow that persists it | **FIXED** | **Release compiler credentials**: `publish.yml` verifies each crate using `cargo publish --dry-run` without registry credentials, then runs a separate diff --git a/scripts/tests/test_ci_supply_chain.py b/scripts/tests/test_ci_supply_chain.py index 1417b3cfa..880ba3d32 100644 --- a/scripts/tests/test_ci_supply_chain.py +++ b/scripts/tests/test_ci_supply_chain.py @@ -1,6 +1,7 @@ """Keep CI credentials read-only and release examples on reviewed dependencies.""" import os from pathlib import Path +import re import subprocess import tempfile import unittest @@ -42,3 +43,126 @@ def test_release_examples_install_lockfile_and_use_built_binding(self): result = subprocess.run(['/bin/bash', '-e', '-o', 'pipefail', '-c', script], cwd=root / 'examples', env=env, capture_output=True, text=True) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual((root / 'examples/node_modules/@everruns/bashkit/index.js').read_text(), 'local release artifact') + + +class CheckoutCredentialTests(unittest.TestCase): + """TM-INF-026: a checkout token left in `.git/config` is a live credential. + + `actions/checkout` writes the job's `GITHUB_TOKEN` into + `http..extraheader` unless told not to. Anything the job then runs + (a dependency `build.rs`, an npm lifecycle script, a test) can read it. + The rule is derived, not allowlisted: a job may keep the credential only + when it actually authenticates a `git`/`gh` call, and never when the + workflow can be triggered by a pull request. + """ + + GIT_CALL = re.compile(r'(?:^|[\s;&|(`])(?:git|gh)\s+[a-z-]', re.M) + + @staticmethod + def workflows(): + for path in sorted((ROOT / '.github/workflows').glob('*.yml')): + yield path, yaml.safe_load(path.read_text()) + + @staticmethod + def triggers(workflow): + # PyYAML resolves the bare `on:` key to the boolean True. + return workflow.get('on', workflow.get(True)) or {} + + @staticmethod + def checkouts(job): + return [step for step in job.get('steps', []) or [] + if str(step.get('uses', '')).startswith('actions/checkout@')] + + @classmethod + def persisting(cls, job): + return [step for step in cls.checkouts(job) + if step.get('with', {}).get('persist-credentials') is not False] + + def test_checkout_keeps_credentials_only_where_git_authenticates(self): + for path, workflow in self.workflows(): + for name, job in (workflow.get('jobs') or {}).items(): + if not self.persisting(job): + continue + script = '\n'.join(step.get('run', '') for step in job.get('steps', []) or []) + with self.subTest(workflow=path.name, job=name): + self.assertRegex( + script, self.GIT_CALL, + f'{path.name}:{name} persists the checkout token but runs no git/gh ' + 'command; add `persist-credentials: false`') + + def test_pull_request_jobs_never_persist_the_checkout_token(self): + for path, workflow in self.workflows(): + if 'pull_request' not in self.triggers(workflow): + continue + for name, job in (workflow.get('jobs') or {}).items(): + with self.subTest(workflow=path.name, job=name): + self.assertEqual( + [], self.persisting(job), + f'{path.name}:{name} runs pull-request-authored code with the ' + 'checkout token in .git/config') + + def test_every_workflow_defaults_to_read_only_permissions(self): + for path, workflow in self.workflows(): + with self.subTest(workflow=path.name): + permissions = workflow.get('permissions') + self.assertIsNotNone( + permissions, f'{path.name} inherits repository default permissions') + self.assertNotIn('write', permissions.values()) + + +class SecretHandlingTests(unittest.TestCase): + """Runtime-fetched secrets are not registered with the log masker. + + A value pulled from Doppler during a job is invisible to GitHub's + scrubber until `::add-mask::` announces it, and `$GITHUB_ENV` republishes + it in the environment block of every later step. So: mask first, hand it + on through `$GITHUB_OUTPUT`, and never through `$GITHUB_ENV`. + """ + + FETCH = re.compile(r'^\s*(\w+)=\$\(doppler secrets get\b', re.M) + + @staticmethod + def steps(): + for path in sorted((ROOT / '.github/workflows').glob('*.yml')): + workflow = yaml.safe_load(path.read_text()) + for name, job in (workflow.get('jobs') or {}).items(): + for step in job.get('steps', []) or []: + if step.get('run'): + yield path.name, name, step + + def test_github_env_only_carries_secret_presence_booleans(self): + for workflow, job, step in self.steps(): + for line in step['run'].split('\n'): + if 'GITHUB_ENV' not in line or 'secrets.' not in line: + continue + with self.subTest(workflow=workflow, job=job, line=line.strip()): + self.assertRegex( + line, r"secrets\.\w+\s*(?:!=|==)\s*''", + 'only a presence comparison may reach $GITHUB_ENV, never a secret value') + + def test_fetched_secrets_never_reach_github_env(self): + for workflow, job, step in self.steps(): + if 'doppler secrets get' not in step['run']: + continue + with self.subTest(workflow=workflow, job=job, step=step.get('name')): + self.assertNotIn( + 'GITHUB_ENV', step['run'], + 'a Doppler-fetched value in $GITHUB_ENV is unmasked in every later step') + + def test_fetched_secrets_are_masked_before_they_travel(self): + for workflow, job, step in self.steps(): + lines = step['run'].split('\n') + for match in self.FETCH.finditer(step['run']): + variable = match.group(1) + fetched = step['run'][:match.start()].count('\n') + masked = next((i for i, line in enumerate(lines) + if i > fetched and '::add-mask::' in line and variable in line), None) + with self.subTest(workflow=workflow, job=job, variable=variable): + self.assertIsNotNone(masked, f'${variable} is never passed to ::add-mask::') + travels = next((i for i, line in enumerate(lines) + if i > fetched and variable in line + and ('GITHUB_OUTPUT' in line or re.search(rf'\bexport\s+{variable}\b', line))), + None) + if travels is not None: + self.assertLess(masked, travels, + f'${variable} escapes the fetch step before it is masked')