# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. # secret-scanner-reusable.yml — Reusable secret-scanner bundle. # # Consolidates the per-repo `secret-scanner.yml` workflow (estate-wide: # 281 deployments, 54 unique blob SHAs, 19% structural drift — top 4 # SHAs cover 69% of repos, top 6 cover 79%). Sampled top + long-tail # variants all carry the same 3-job structure (gitleaks + rust-secrets + # shell-secrets) at 75-81 lines; drift is action-SHA / whitespace churn. # # Rationale for gitleaks over trufflehog: # - Gitleaks has better CVE coverage for common secret patterns # - Trufflehog was removed as redundant; gitleaks catches what we need # - Cost reduction: eliminates duplicate scanning # # Critical security debt this PR addresses: # The `shell-secrets` job (added to the canonical 2026-05-21 in # response to the Cloudflare token leak via `2-protocols/avow/ # deploy-repos.sh`, which both `trufflehog --only-verified` and # default gitleaks missed) is currently present in 0 of 16 sampled # estate repos. The post-incident guardrail intended to catch the # *next* leak has propagated to nothing. Consolidating behind this # reusable force-promotes shell-secrets to all 281 repos on the next # wrapper sweep. # # Design: # - No per-call inputs other than `runs-on`. Each job self-conditions # internally (rust-secrets exits early on no Cargo.toml; shell-secrets # no-ops on no .sh/.bash files), so there is nothing for the caller # to toggle. # - Caller passes NO `secrets:` line. This reusable declares no # `workflow_call.secrets` and references no `secrets.*` context: # gitleaks runs as a pinned local binary (see the PERMISSIONS note), # and the `GITHUB_TOKEN` the jobs act under is governed by # `permissions:`, not by caller secret passing. `secrets: inherit` # would forward every repository and organisation secret the caller # can see (CWE-250) and buy nothing. An earlier version of this # header claimed inherit was REQUIRED so `gitleaks/gitleaks-action` # could see `secrets.GITHUB_TOKEN`; that claim died with #500 but # kept propagating into generated callers estate-wide. Do not # re-add it. (standards#1108 §6b) # - Caller keeps its own `on:` triggers and `concurrency:` group, so # the read-only cancel-superseded behaviour stays in the wrapper. # - Trufflehog removed: gitleaks provides sufficient coverage at lower cost. # # PERMISSIONS — `contents: read` is the whole requirement. # # Every job here runs `actions/checkout` and then a self-contained binary or # shell scan. There are no artifacts, no cache, no `gh api` calls and no # workflow-run queries, so nothing needs `actions: read`, and since #500 # replaced `gitleaks/gitleaks-action` with a pinned checksum-verified binary, # nothing needs `pull-requests: write` either. # # This block previously instructed callers to grant `pull-requests: write` + # `actions: read`. That guidance was correct for the pre-#500 action-based # gitleaks job and became wrong when the binary replaced it — but it was left # in place, and the workflow-level block below kept an unused `actions: read`. # Because a called workflow cannot exceed its caller's *job-level* grant, a # correctly least-privileged wrapper granting only `contents: read` was # rejected at startup — which is exactly what happened to this repo's own # `secret-scanner.yml`, silently disabling its secret scanning. # # Two rules that make that failure mode non-obvious, worth stating plainly: # 1. A job-level `permissions:` block REPLACES the workflow-level one for # that job — it does not merge with it. A caller with `actions: read` at # the top and `contents: read` on the job grants `contents: read` only. # 2. A rejected run produces NO check run at all — `gh pr checks` shows # nothing and the annotations endpoint 404s. Diagnose with # `gh run list --json conclusion`, never by the absence of a red tick. # # Every job below therefore declares `permissions: contents: read` explicitly # rather than inheriting. Inheritance is what let an unused workflow-level # grant leak into two jobs unnoticed; explicit per-job blocks mean a future # widening of the workflow-level block cannot silently re-break callers. # # Caller example (wrapper) — this is the complete, minimal, correct shape: # # SPDX-License-Identifier: MPL-2.0 # name: Secret Scanner # on: # pull_request: # push: # branches: [main] # concurrency: # group: ${{ github.workflow }}-${{ github.ref }} # cancel-in-progress: true # permissions: # contents: read # jobs: # scan: # permissions: # contents: read # uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@ # # Deliberately NO `secrets:` line — the callee declares none and # # needs none (see the Design note above). name: Secret Scanner (reusable) on: workflow_call: inputs: runs-on: description: Runner label for all secret-scanner jobs type: string required: false default: ubuntu-latest # `actions: read` was removed here: no job in this file reads workflow runs, # artifacts or caches, so it was an unused grant — and because a called # workflow cannot exceed its caller's job-level grant, it broke every wrapper # that correctly granted `contents: read` alone. See the PERMISSIONS note above. permissions: contents: read jobs: gitleaks: timeout-minutes: 20 runs-on: ${{ inputs.runs-on }} # Declared explicitly rather than inherited — see the PERMISSIONS note. permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Full history. One of the two gating passes below scans git history, # and the default depth-1 clone would silently reduce it to a single # commit. That pass asserts non-shallowness for itself rather than # trusting this line to have taken effect. fetch-depth: 0 # Gitleaks runs as a pinned, checksum-verified binary rather than via # gitleaks/gitleaks-action. The action injects gitleaks into the runner # tool-cache and prepends it to PATH, which is unreliable on self-hosted # runners — that unreliability was the reason this step previously carried # `continue-on-error: true`, i.e. the estate's primary secret scanner # could not fail a PR. A pinned direct install is deterministic on every # runner, so the scan gates for real. - name: Install gitleaks (pinned + checksum-verified) run: | set -euo pipefail GITLEAKS_VERSION=8.18.4 SHA256=ba6dbb656933921c775ee5a2d1c13a91046e7952e9d919f9bac4cec61d628e7d cd "$RUNNER_TEMP" curl -fsSL -o gitleaks.tar.gz \ "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" echo "${SHA256} gitleaks.tar.gz" | sha256sum -c - tar -xzf gitleaks.tar.gz gitleaks chmod +x gitleaks ./gitleaks version # Stage the ESTATE BASELINE allowlist into the workspace root. # # WHY THIS EXISTS. The scan below used to run with no `--config` at all, # so gitleaks fell back to looking for `.gitleaks.toml` in the CALLER's # tree. Measured 2026-08-06: none of the eleven repositories then failing # this gate had one, so all eleven ran on bare default rules with no # allowlist — and produced 154 findings of which every single one was a # false positive (ML training corpora, vendored Yarn bundles, published # conformance vectors, `.example` configs, and fixtures belonging to the # estate's own secret DETECTORS). # # standards/.gitleaks.toml already anticipated precisely this: "turn the # gate on with no allowlist, get a wall of red that is entirely noise, # someone reverts, and the estate is back to a scanner that cannot fail." # The file was written and then never wired to the scan. This wires it. # # It is staged at the WORKSPACE ROOT, not $RUNNER_TEMP, because gitleaks # resolves `[extend] path` relative to the PROCESS CWD — verified, and the # opposite of the intuitive "relative to the config file". Putting it here # lets a repo-local `.gitleaks.toml` portably declare # [extend] # path = ".gitleaks-estate.toml" # and inherit the whole baseline while adding only its own exemptions, so # a repo-specific blind spot stays local to the repo that justified it # instead of widening the allowlist for all 400+. # # THIS STEP IS UNGATED ON PURPOSE. It is the only thing that creates # `.gitleaks-estate.toml` for a consumer with no `.gitleaks.toml` of its # own — the large majority — and that is exactly the consumer whose # CONFIG resolves to that file in the scan step below. Do not delete it # as a duplicate of the gated "Estate baseline — is it referenced?" # block further down; that block fires only for repos that DO have a # local config, so removing this one leaves CONFIG naming a file nothing # creates, across most of the estate. # # `ref` was `main` until 2026-09-04, and that was worse than a moving # pin: the file this step fetches has been RENAMED under it. Before # 86bad549 the shared allowlist WAS `.gitleaks.toml` (148 lines, 16 # rules) and gen-1 sparse-checked-out that path; after the rename, # `.gitleaks.toml` is standards' own 18-line dogfooding stub whose # `[extend] path` points at `config/gitleaks/estate-baseline.toml` — a # path the consumer does NOT have, because this step stages exactly one # file. So every consumer pinned before the rename silently began # staging a stub with a dangling `[extend]`. `job.workflow_sha` is the # commit of THIS reusable, so a consumer now receives the baseline as it # stood at the SHA it pins, and the path each SHA references exists at # that SHA (verified for all six gen-1 SHAs, 2026-09-04). - name: Fetch estate gitleaks baseline uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards ref: ${{ job.workflow_sha }} path: .standards-gitleaks sparse-checkout: | config/gitleaks/estate-baseline.toml sparse-checkout-cone-mode: false persist-credentials: false - name: Stage estate gitleaks baseline run: | set -euo pipefail # Stage THE estate baseline, not standards' own repo config. # standards/.gitleaks.toml is a CONSUMER of the baseline -- it carries # `[extend] path = "config/gitleaks/estate-baseline.toml"`, and # `[extend] path` resolves against the PROCESS working directory. Copied # into a consumer, that path does not exist and gitleaks dies with # FTL failed to load extended config ... no such file # scanning nothing and failing the gate. The baseline file's own header # documents this staging as the intended behaviour. cp .standards-gitleaks/config/gitleaks/estate-baseline.toml .gitleaks-estate.toml # Delete the tooling checkout BEFORE scanning: otherwise the scan # walks standards' own tree and reports ITS files as the caller's. # (The Hypatia baseline job above does the same thing for the same # reason.) rm -rf .standards-gitleaks test -s .gitleaks-estate.toml # TWO gating passes run below and they are not redundant. Each sees a # population the other cannot: # * working tree (--no-git) -- sees untracked and generated files; # cannot see a blob deleted in an earlier commit # * git history -- sees committed-then-deleted secrets; # cannot see a file that was never committed # Both gate: a finding in either exits non-zero and fails the check. # Full history costs a slower scan on every PR. That is the accepted # price of a deleted secret no longer being permanently invisible. # ── Estate baseline sharing ────────────────────────────────────────── # A consumer repo can inherit the estate-wide allowlist instead of # copying it, by writing in its own .gitleaks.toml: # # [extend] # path = ".gitleaks-estate.toml" # # The point of the split is blast radius. An entry in the estate baseline # blinds all 400+ repositories; an entry in a repo's own config blinds # one, and sits beside the code it describes. Before this existed the # only way to share the baseline was to copy it, so the two drifted and # repo-local suppressions had nowhere to live except the shared file. # # Staged ONLY when actually referenced. A repo that does not extend it # must not gain an unreferenced file inside its own scanned tree. # # `[extend] path` resolves against the PROCESS working directory, not the # config file's directory (verified empirically: the same relative path # fails when gitleaks is invoked from a subdirectory). The scan below runs # from the workspace root, so the workspace root is where this lands. - name: Estate baseline — is it referenced? id: estate_baseline run: | set -euo pipefail if [ -f .gitleaks.toml ] && \ grep -qE '^[[:space:]]*path[[:space:]]*=[[:space:]]*"\.gitleaks-estate\.toml"' .gitleaks.toml; then echo "referenced=true" >> "$GITHUB_OUTPUT" echo "This repo extends the estate baseline; staging it." else echo "referenced=false" >> "$GITHUB_OUTPUT" echo "No reference to .gitleaks-estate.toml; nothing staged." fi # Pinned by construction: job.workflow_sha is the commit of THIS reusable # workflow, so the baseline always matches the workflow consuming it. A # caller pinned to an old SHA gets that SHA's baseline, not a moving one. - name: Estate baseline — fetch if: steps.estate_baseline.outputs.referenced == 'true' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards ref: ${{ job.workflow_sha }} path: .estate-baseline-checkout sparse-checkout: config/gitleaks sparse-checkout-cone-mode: false persist-credentials: false - name: Estate baseline — install if: steps.estate_baseline.outputs.referenced == 'true' run: | set -euo pipefail src=.estate-baseline-checkout/config/gitleaks/estate-baseline.toml if [ ! -f "$src" ]; then echo "::error::Estate baseline missing at $src. The repo's .gitleaks.toml extends .gitleaks-estate.toml, but the baseline could not be fetched — failing rather than scanning with a silently reduced config." exit 1 fi cp "$src" .gitleaks-estate.toml # Remove the checkout before scanning: otherwise standards' own tree # is scanned as if it were the caller's, which is how a shared config # turns into a wall of someone else's findings. rm -rf .estate-baseline-checkout echo "Staged .gitleaks-estate.toml ($(wc -l < .gitleaks-estate.toml) lines)." # # --verbose is REQUIRED, not cosmetic. Without it gitleaks prints only # "leaks found: N" and nothing else — no rule, no file, no line — and the # run uploads no artifact, so the findings exist solely inside a discarded # container. That is a gate which blocks a merge while telling the author # nothing actionable, and it is why 154 false positives sat untriaged # across eleven repositories. --redact is already set, so values stay # masked: the log gains the rule, path and line, never the secret. - name: Gitleaks secret scan (gating) run: | set -euo pipefail # A repo-local baseline wins outright — it is expected to `[extend]` # the estate one, so "wins" still means "inherits". This mirrors what # the AsciiDoc pass below already did, which was inconsistent with # this step until now. CONFIG=".gitleaks-estate.toml" if [ -f .gitleaks.toml ]; then CONFIG=".gitleaks.toml" echo "Using repository .gitleaks.toml (extending the estate baseline)." else echo "Using estate baseline allowlist." fi "$RUNNER_TEMP/gitleaks" detect \ --source . \ --no-git \ --redact \ --no-banner \ --verbose \ --config "$CONFIG" \ --exit-code 1 # Full-history pass — ADDITIVE, not a replacement. Measured on a fixture # with this exact pinned binary, the same secret in two placements: # # committed then deleted working tree: MISSED history: FOUND # untracked file working tree: FOUND history: MISSED # # git mode scans commit diffs, so it cannot see a file that was never # committed; --no-git walks the checked-out tree, so it cannot see a blob # that is no longer in it. Dropping either pass REMOVES coverage. Keeping # both is why this was added as a new step rather than by editing the one # above, whose name and log lines existing triage keys on. # # KNOWN GAP, stated rather than implied: the AsciiDoc mirror pass below # scans a filesystem copy with --no-git, so fetch-depth: 0 cannot reach # it. An AsciiDoc secret that was committed and later deleted stays # invisible. "The gate is full-history" holds for every file class but # that one. - name: Gitleaks secret scan — git history (gating) run: | set -euo pipefail # fetch-depth: 0 on the checkout is load-bearing HERE. If it ever # regresses to the default depth-1 clone, detect would walk a single # commit, find nothing and report a pass — a gate that cannot fail. # Assert completeness from git itself: gitleaks' own "scanned N # commits" line under-reports and is not proof of depth. if [ "$(git rev-parse --is-shallow-repository)" != "false" ]; then echo "::error::checkout is shallow -- a history scan here would be vacuous; refusing to report a pass" exit 1 fi echo "history available to this scan: $(git rev-list --count HEAD) commits" # Same config resolution as the working-tree pass, duplicated rather # than factored out so that step stays byte-identical. CONFIG=".gitleaks-estate.toml" if [ -f .gitleaks.toml ]; then CONFIG=".gitleaks.toml" fi # A staging step that failed silently would leave gitleaks running a # reduced config over almost nothing and calling the result green. test -s "$CONFIG" "$RUNNER_TEMP/gitleaks" detect \ --source . \ --redact \ --no-banner \ --verbose \ --config "$CONFIG" \ --exit-code 1 # AsciiDoc blind-spot pass — MEASURED, not theoretical. # # gitleaks' DEFAULT global allowlist excludes paths matching # `(jpg|gif|doc|docx|zip|xls|pdf|bin|svg|socket)$`. That `doc` alternative # is an unanchored *substring* match on the filename, so it swallows # `.adoc` and `.asciidoc` as collateral — AsciiDoc is not a binary format # and was never the intended target. # # This estate writes documentation in AsciiDoc by default, so the blind # spot covers ~40,000 files estate-wide (1,620 in standards alone). It is # not hypothetical: a live write-scoped `glpat-` token sat undetected in # `rhodium-pipeline/docs/QUICKSTART.adoc` while this scanner reported # "no leaks found". Proof — identical content, four extensions: # .txt -> DETECTED .md -> DETECTED # .adoc -> MISSED .asciidoc -> MISSED # # The exclusion cannot be switched off from a config that sets # `[extend] useDefault = true` (verified: neither `[allowlist] paths = []` # nor adding an explicit custom rule re-enables scanning — the file is # dropped at path level, before any rule runs). Turning `useDefault` off # would forfeit all ~150 default rules, so instead this step scans a # throwaway mirror in which each AsciiDoc file is copied to a `.txt` # suffix. Same binary, same rules, same repo config — only the filename # differs, which is the sole thing the allowlist keys on. # # Findings are re-mapped back to their real paths before reporting, so # output never mentions the mirror. Runs as a separate step (not folded # into the scan above) so the log distinguishes "leak in code" from # "leak in docs". - name: Gitleaks secret scan — AsciiDoc mirror (gating) run: | set -euo pipefail MIRROR="$RUNNER_TEMP/adoc-mirror" REPORT="$RUNNER_TEMP/adoc-report.json" rm -rf "$MIRROR"; mkdir -p "$MIRROR" # -print0/read -d '' so paths with spaces or newlines survive; the # estate has directories with spaces in them. found=0 while IFS= read -r -d '' f; do dest="$MIRROR/$(dirname "$f")" mkdir -p "$dest" cp "$f" "$dest/$(basename "$f").txt" found=$((found + 1)) done < <(find . -path ./.git -prune -o \ \( -name '*.adoc' -o -name '*.asciidoc' \) -type f -print0) if [ "$found" -eq 0 ]; then echo "No AsciiDoc files present — nothing to scan." exit 0 fi echo "Scanning $found AsciiDoc file(s) via mirror." # Honour the caller's own baseline when it has one, so repo-specific # allowlists still apply to docs exactly as they do to code — and # otherwise fall back to the estate baseline, so docs and code are # judged by the SAME rules. Previously this step honoured a repo # config while the code scan above honoured none, which meant an # allowlist entry could suppress a finding in a `.adoc` file and not # in the `.md` file beside it. # # Absolute paths: this scan's --source is the MIRROR directory, so a # relative config path would resolve against the mirror rather than # the repository. `[extend] path = ".gitleaks-estate.toml"` inside a # repo config resolves against the process CWD (still the repo root), # which is why the estate baseline is staged there. config_args=(--config "$PWD/.gitleaks-estate.toml") if [ -f .gitleaks.toml ]; then config_args=(--config "$PWD/.gitleaks.toml") echo "Using repository .gitleaks.toml." else echo "Using estate baseline allowlist." fi # Report first, decide after: --exit-code would abort before the paths # can be mapped back, leaving an unreadable mirror path in the log. set +e "$RUNNER_TEMP/gitleaks" detect \ --source "$MIRROR" \ --no-git \ --redact \ --no-banner \ --report-format json \ --report-path "$REPORT" \ "${config_args[@]}" set -e # `grep` exits 1 when it matches nothing, which under `set -e` + # `pipefail` aborts the step with status 1 — i.e. a repo whose docs are # CLEAN would fail the gate. Caught by red-teaming the clean case; # pipefail is disabled around the count for exactly this reason. count=0 if [ -s "$REPORT" ]; then set +o pipefail count=$(grep -o '"RuleID"' "$REPORT" 2>/dev/null | wc -l | tr -d ' ') set -o pipefail [ -n "$count" ] || count=0 fi if [ "$count" -eq 0 ]; then echo "No leaks found in AsciiDoc files." exit 0 fi # Findings list, always printed, values redacted. Paths are recovered # from the mirror so the output names the real files. echo "Findings (values redacted; paths are the REAL files, not the mirror):" sed -n 's/.*"File": *"\([^"]*\)".*/\1/p' "$REPORT" \ | sed -e "s|^${MIRROR}/||" -e 's|\.txt$||' \ | sort -u \ | sed 's/^/ - /' echo echo "These files are invisible to a default gitleaks run because the" echo "default allowlist drops any path ending in 'doc'. Treat every hit" echo "above as a real, previously unscanned leak." echo # GRACE WINDOW — same cutoff as the docs and package-policy gates # (standards#513), for the same reason: this step makes ~40,000 # previously unscanned estate files visible at once, and every hit is # pre-existing debt rather than a regression introduced by the PR in # front of it. Turning it straight to blocking would red a large number # of repos simultaneously for faults none of their authors introduced. # # It never prints a green tick while findings exist, and it never # suppresses the list above — the warning is loud and the date is real. # An unparseable date refuses to run rather than silently defaulting to # the permissive branch, which is how a warn-first gate degrades into a # fake one. ENFORCE_ADOC_SCAN_FROM="${ENFORCE_ADOC_SCAN_FROM:-2026-08-21}" TODAY="${ADOC_TODAY:-$(date -u +%Y-%m-%d)}" for pair in "ENFORCE_ADOC_SCAN_FROM=$ENFORCE_ADOC_SCAN_FROM" "ADOC_TODAY=$TODAY"; do case "${pair#*=}" in [0-9][0-9][0-9][0-9]-[0-1][0-9]-[0-3][0-9]) ;; *) echo "::error::${pair%%=*} is not YYYY-MM-DD; refusing to run."; exit 1 ;; esac done if [[ "$TODAY" < "$ENFORCE_ADOC_SCAN_FROM" ]]; then echo "::warning::${count} secret(s) found in AsciiDoc files. This becomes a" \ "BLOCKING failure on ${ENFORCE_ADOC_SCAN_FROM} (today is ${TODAY})." echo "NOT YET ENFORCED: AsciiDoc findings present but inside the grace window." exit 0 fi echo "::error::${count} secret(s) detected in AsciiDoc files." exit 1 # Rust-specific: hardcoded crypto values. Self-skips when no Cargo.toml # is present, so this job is safe to run on non-Rust repos. rust-secrets: timeout-minutes: 10 runs-on: ${{ inputs.runs-on }} # Declared explicitly rather than inherited — see the PERMISSIONS note. permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check for hardcoded secrets in Rust env: # Findings outside ./src (newly visible — see the scan-scope note # below) warn until this date, then block. Same warn-then-enforce # idiom and same cutoff as check-package-policy.sh / # check-docs-presence.sh. ENFORCE_RUST_WIDE_SCAN_FROM: '2026-08-21' run: | TODAY="${RUST_TODAY:-$(date -u +%Y-%m-%d)}" # An unparseable cutoff would pick the warn branch forever, silently # disarming the widened scan. Refuse to run instead. require_date() { case "$2" in [0-9][0-9][0-9][0-9]-[0-1][0-9]-[0-3][0-9]) : ;; *) echo "::error::rust-secrets: $1='$2' is not YYYY-MM-DD." echo "Refusing to run: an unparseable cutoff would disarm this gate." exit 1 ;; esac } require_date ENFORCE_RUST_WIDE_SCAN_FROM "$ENFORCE_RUST_WIDE_SCAN_FROM" require_date RUST_TODAY "$TODAY" if ! find . -name Cargo.toml -not -path './target/*' -print -quit | grep -q .; then echo 'No Cargo.toml found — skipping Rust secrets check' exit 0 fi # Scan every .rs file, not just ./src. A Cargo workspace keeps its # code in crates/*/src, and the previous `grep … src/` matched # nothing there: grep exits 2 on a missing directory, which the `if` # read as "clean". Measured 2026-07-21 across the 64 Rust repos in # the estate re-pin sweep: 8 (12.5%) have no root src/ and were # therefore never scanned at all — a planted secret in # crates/core/src/lib.rs passed this job silently. mapfile -t FILES < <(find . -name '*.rs' \ -not -path './target/*' -not -path '*/target/*' \ -not -path './vendor/*' -not -path '*/vendor/*' \ -not -path './.git/*' -print) if [ ${#FILES[@]} -eq 0 ]; then echo 'Cargo.toml present but no .rs files — nothing to scan' exit 0 fi echo "Scanning ${#FILES[@]} Rust file(s)." # Each pattern requires an assignment DIRECTLY to a string literal of # meaningful length. The previous forms matched `= ... "` — a quote # anywhere after an equals — which flagged code that hardcodes # nothing at all. Measured across the 64 Rust repos in the estate # sweep, every one of these was a false positive: # let api_key = request["api_key"].as_str().unwrap_or("") (lookup) # let api_key_file = temp_dir.path().join("config.js") (path) # Query::new("Here's my api_key=sk-12345") (a demo) # A credential shorter than 8 characters is not a credential either. # `static` is accepted alongside `const`: the old `const.*KEY` form # missed `static AUTH_KEY: &str = "…"`, a genuine hardcoded secret # (caught by canary, not by inspection). PATTERNS=( '(const|static)[[:space:]]+(mut[[:space:]]+)?[A-Za-z0-9_]*SECRET[A-Za-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' '(const|static)[[:space:]]+(mut[[:space:]]+)?[A-Za-z0-9_]*KEY[A-Za-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[a-zA-Z0-9]{16,}"' '(const|static)[[:space:]]+(mut[[:space:]]+)?[A-Za-z0-9_]*TOKEN[A-Za-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' '(let|static)[[:space:]]+(mut[[:space:]]+)?[a-z0-9_]*api_key[a-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' 'HMAC.*"[a-fA-F0-9]{32,}"' '[a-z0-9_]*password[a-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' ) # Exemptions, mirroring the four layers already documented on # shell-secrets. All match on the VALUE or on an explicit pragma, # never on a file path: a path-based exemption would blind the job # to a genuine secret in the same file (the failure mode the # .gitleaks.toml allowlist was rewritten to avoid, standards#512). # # 1. env-lookup RHS — `env::var("NAME")` IS the practice this job # exists to enforce, so flagging it fails the correct code. This # was the sole rust-secrets failure in the 201-repo sweep: # cloudguard-server src/main.rs:59, # `let api_key = env::var("CLOUDGUARD_API_KEY").ok();`. Anchored # to the assignment and stopped at `;`, so a literal secret with # an unrelated env::var mention later on the line still trips. # 2. URL values — an OAuth endpoint is public and not a credential. # `const MS_TOKEN_URL = "https://login.microsoftonline.com/…"` # tripped `const.*TOKEN.*=.*"` in four places in one repo. # 3. comment lines — documentation of a pattern is not a live secret. # 4. inline pragma `// scanner-allow: rust-secrets`. COMMENT='^[^:]*:[0-9][0-9]*:[[:space:]]*(//|/\*|\*)' ENV_RHS='=[^;]*(env::var|env::var_os|std::env|env!\(|option_env!\(|from_env|dotenv)' URL_RHS='=[[:space:]]*b?"(https?|wss?|ftp)://' PRAGMA='scanner-allow:[[:space:]]*rust-secrets' blocking=0 advisory=0 for pattern in "${PATTERNS[@]}"; do hits="$(grep -nHE "$pattern" "${FILES[@]}" 2>/dev/null \ | grep -vE "$COMMENT" \ | grep -vE "$ENV_RHS" \ | grep -vE "$URL_RHS" \ | grep -vE "$PRAGMA" || [ $? -eq 1 ])" [ -n "$hits" ] || continue # ./src was the only path the previous version scanned. Hits there # keep blocking exactly as before — no regression. Hits outside it # are newly visible, so they warn until the cutoff rather than # reddening repos that were never actually being scanned. old_scope="$(printf '%s\n' "$hits" | grep -E '^\./src/' || [ $? -eq 1 ])" new_scope="$(printf '%s\n' "$hits" | grep -vE '^\./src/' || [ $? -eq 1 ])" if [ -n "$old_scope" ]; then printf '%s\n' "$old_scope" echo "WARNING: Potential hardcoded secret found matching: $pattern" blocking=1 fi if [ -n "$new_scope" ]; then printf '%s\n' "$new_scope" if [[ "$TODAY" < "$ENFORCE_RUST_WIDE_SCAN_FROM" ]]; then echo "::warning::Potential hardcoded secret outside ./src matching:" \ "$pattern — becomes BLOCKING on $ENFORCE_RUST_WIDE_SCAN_FROM (today is $TODAY)." advisory=1 else echo "WARNING: Potential hardcoded secret found matching: $pattern" blocking=1 fi fi done if [ "$blocking" -eq 1 ]; then echo "::error::Potential hardcoded secrets detected. Use environment variables instead." exit 1 fi if [ "$advisory" -eq 1 ]; then # Never claim a clean pass while findings are outstanding. echo "NOT YET ENFORCED: findings outside ./src are advisory until $ENFORCE_RUST_WIDE_SCAN_FROM." exit 0 fi echo 'No hardcoded secrets detected in Rust sources.' # Shell-specific: catch hardcoded credentials in shell scripts. # Added to canonical 2026-05-21 after default gitleaks missed a real # Cloudflare API token leaked via 2-protocols/avow/deploy-repos.sh. # As of this PR, 0 of 16 sampled estate repos carry this guardrail — # the reusable closes that gap. # # Exemption mechanisms (four layers, applied before reporting a hit): # 1. Inline pragma (same line or the line immediately above): # # scanner-allow: shell-secrets # # hypatia: allow security_errors/secret_detected # Either form suppresses the hit for that line. # 2. .shell-secrets-ignore in repo root: gitignore-style line globs; # a match against the file path suppresses every hit in that file. # 3. Param-expansion RHS: assignments whose value is ${VAR}, ${VAR:-…}, # ${VAR:?…}, or $VAR (no literal value) are skipped automatically. # 4. Comment lines (leading non-whitespace is '#'): documentation of # patterns cannot be real secrets. shell-secrets: timeout-minutes: 10 runs-on: ${{ inputs.runs-on }} # Declared explicitly rather than inherited — see the PERMISSIONS note. permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check for hardcoded secrets in shell scripts run: | # Patterns: an `export FOO=` or `FOO=` with a quoted literal of meaningful length. # Restricted to *_TOKEN / *_KEY / *_SECRET / PASSWORD to keep false-positives low. PATTERNS=( '(export[[:space:]]+)?[A-Z_]*TOKEN[A-Z_]*=["'"'"'][A-Za-z0-9_./+=-]{20,}["'"'"']' '(export[[:space:]]+)?[A-Z_]*API_KEY[A-Z_]*=["'"'"'][A-Za-z0-9_./+=-]{20,}["'"'"']' '(export[[:space:]]+)?[A-Z_]*SECRET[A-Z_]*=["'"'"'][A-Za-z0-9_./+=-]{16,}["'"'"']' '(export[[:space:]]+)?PASSWORD=["'"'"'][^"'"'"']{6,}["'"'"']' ) # Inline pragma patterns — suppress a hit when found on the same or # immediately preceding line. PRAGMA_RE='(scanner-allow:[[:space:]]*shell-secrets|hypatia:[[:space:]]*allow[[:space:]]+security_errors/secret_detected)' # Param-expansion RHS pattern — assignments whose value is a variable # reference rather than a literal are never real secrets. # Matches: ="$VAR" ="${VAR}" ="${VAR:-…}" ="${VAR:?…}" ='${VAR}' =$VAR PARAM_EXPANSION_RE='=['"'"'"'"'"']?\$\{?[A-Za-z_][A-Za-z0-9_]*(:[?-][^}]*)?\}?['"'"'"'"'"']?[[:space:]]*(#.*)?$' # Load per-repo ignore globs from .shell-secrets-ignore if present. IGNORE_GLOBS=() if [[ -f .shell-secrets-ignore ]]; then while IFS= read -r line || [[ -n "$line" ]]; do # Skip blank lines and comments [[ -z "$line" || "$line" == \#* ]] && continue IGNORE_GLOBS+=("$line") done < .shell-secrets-ignore fi # is_ignored — returns 0 (true) if path matches any ignore glob. is_ignored() { local path="$1" for glob in "${IGNORE_GLOBS[@]}"; do # Use bash pattern matching; strip leading ./ for consistency. local stripped="${path#./}" # shellcheck disable=SC2254 if [[ "$stripped" == $glob ]]; then return 0 fi done return 1 } found=0 for pattern in "${PATTERNS[@]}"; do # Collect raw matches: filepath:lineno:content while IFS= read -r raw_match; do [[ -z "$raw_match" ]] && continue # Parse filepath and line number from grep -n output (file:line:content). filepath="${raw_match%%:*}" rest="${raw_match#*:}" lineno="${rest%%:*}" content="${rest#*:}" # Exemption 1: .shell-secrets-ignore glob match on the file path. if is_ignored "$filepath"; then echo " [skip] $filepath:$lineno — matched .shell-secrets-ignore glob" continue fi # Exemption 2: param-expansion RHS — value is a variable reference, not a literal. if echo "$content" | grep -qE "$PARAM_EXPANSION_RE"; then echo " [skip] $filepath:$lineno — param-expansion (not a literal value)" continue fi # Exemption 3: comment line — leading non-whitespace char is '#'. # Comment lines cannot hold real secrets; they only document patterns. if echo "$content" | grep -qE '^[[:space:]]*#'; then echo " [skip] $filepath:$lineno — comment line (not executable)" continue fi # Exemption 5: inline pragma on same line. if echo "$content" | grep -qE "$PRAGMA_RE"; then echo " [skip] $filepath:$lineno — inline pragma on same line" continue fi # Exemption 6: inline pragma on the immediately preceding line. if [[ "$lineno" -gt 1 ]]; then prev_line=$(sed -n "$((lineno - 1))p" "$filepath" 2>/dev/null) if echo "$prev_line" | grep -qE "$PRAGMA_RE"; then echo " [skip] $filepath:$lineno — pragma on preceding line" continue fi fi echo "::warning::$filepath:$lineno: $content" echo " Potential hardcoded secret matching pattern: $pattern" found=1 done < <(grep -rnE --include='*.sh' --include='*.bash' \ --exclude-dir='.git' --exclude-dir='node_modules' --exclude-dir='target' \ "$pattern" . 2>/dev/null || [ $? -eq 1 ]) done if [ $found -eq 1 ]; then echo "::error::Hardcoded secret detected in a shell script. Read the value from the environment (e.g. \"\${MY_TOKEN:?}\") and supply it via a repo or org secret." exit 1 fi