From f062e9845ec14698f1a41aa9beeab3bd00aa411e Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 16 Sep 2026 13:30:09 +0100 Subject: [PATCH 1/4] ci: make the required check real, and add secret and code scanning Seven workflows each published a check run named `check`, which is the one status context the branch rulesets require. GitHub resolves a same-named context from whichever reports first, so the gate went green on the cheapest job while the rest were still running. Each is now named after its workflow and a new `required-check` job takes the name, waiting for every other check on the commit. Adds gitleaks, semgrep, npm audit and cargo audit on every PR plus a weekly full-history sweep. All on GitHub-hosted runners, so a fork PR cannot run contributor code on the self-hosted fleet. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/android-webview.yml | 1 + .github/workflows/changesets.yml | 2 +- .github/workflows/cypress.yml | 12 +- .github/workflows/ios-webview.yml | 1 + .github/workflows/lint.yml | 12 +- .github/workflows/pinned-versions.yml | 1 + .github/workflows/provider_image.yml | 1 + .github/workflows/required-check.yml | 150 +++++++++++++++++++ .github/workflows/security-scan.yml | 207 ++++++++++++++++++++++++++ .github/workflows/tests.yml | 12 +- .github/workflows/typecheck.yml | 12 +- .gitleaks.toml | 103 +++++++++++++ 12 files changed, 501 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/required-check.yml create mode 100644 .github/workflows/security-scan.yml create mode 100644 .gitleaks.toml diff --git a/.github/workflows/android-webview.yml b/.github/workflows/android-webview.yml index ef3dca8b1a..73ab439027 100644 --- a/.github/workflows/android-webview.yml +++ b/.github/workflows/android-webview.yml @@ -28,6 +28,7 @@ concurrency: jobs: build: + name: android-webview if: ${{ github.event.action != 'converted_to_draft' }} # Hosted, deliberately, and not routed through `vars.GH_RUNNER` like the # other repos: this repository is public, so a fork PR running on the diff --git a/.github/workflows/changesets.yml b/.github/workflows/changesets.yml index 0cc135e033..78f25796fe 100644 --- a/.github/workflows/changesets.yml +++ b/.github/workflows/changesets.yml @@ -28,7 +28,7 @@ defaults: jobs: check: - name: check + name: changesets # Hosted, deliberately, and not routed through `vars.GH_RUNNER` like the # other repos: this repository is public, so a fork PR would run on the # self-hosted fleet -- executing arbitrary contributor code on hardware we diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index 073ee22ce6..f2214bf472 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -10,9 +10,14 @@ on: # CI. Only paths that cannot possibly affect this workflow belong below. # # Safe against the "required check never reports" trap: the one required - # context on main is `check`, and changesets.yml publishes a job named - # `check` with no path filter at all, so a PR touching only ignored paths - # still gets a `check` and does not sit pending forever. + # context on main is `check`, and required-check.yml publishes it with no + # path filter at all, so a PR touching only ignored paths still gets a + # `check` and does not sit pending forever. + # + # That role used to be filled by changesets.yml, which published a job also + # *named* `check` -- which is precisely why the required context resolved + # from whichever of seven same-named jobs reported first, and why this + # paragraph used to describe the defect as the safeguard. paths-ignore: - '.changeset/**' # release metadata; nothing here reads it - '.github/ISSUE_TEMPLATE/**' @@ -51,6 +56,7 @@ defaults: jobs: check: + name: cypress # Hosted, deliberately, and not routed through `vars.GH_RUNNER` like the # other repos: this repository is public, so a fork PR would run on the # self-hosted fleet -- executing arbitrary contributor code on hardware we diff --git a/.github/workflows/ios-webview.yml b/.github/workflows/ios-webview.yml index b49f3a9428..ce05d609f0 100644 --- a/.github/workflows/ios-webview.yml +++ b/.github/workflows/ios-webview.yml @@ -28,6 +28,7 @@ concurrency: jobs: build: + name: ios-webview if: ${{ github.event.action != 'converted_to_draft' }} # Hosted, deliberately: this repository is public, so a fork PR running on # the self-hosted macOS runner would execute arbitrary code on it. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f178f1ea81..de7ff53257 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,9 +10,14 @@ on: # CI. Only paths that cannot possibly affect this workflow belong below. # # Safe against the "required check never reports" trap: the one required - # context on main is `check`, and changesets.yml publishes a job named - # `check` with no path filter at all, so a PR touching only ignored paths - # still gets a `check` and does not sit pending forever. + # context on main is `check`, and required-check.yml publishes it with no + # path filter at all, so a PR touching only ignored paths still gets a + # `check` and does not sit pending forever. + # + # That role used to be filled by changesets.yml, which published a job also + # *named* `check` -- which is precisely why the required context resolved + # from whichever of seven same-named jobs reported first, and why this + # paragraph used to describe the defect as the safeguard. paths-ignore: - '.changeset/**' # release metadata; nothing here reads it - '.github/ISSUE_TEMPLATE/**' @@ -46,6 +51,7 @@ defaults: jobs: check: + name: lint # Hosted, deliberately, and not routed through `vars.GH_RUNNER` like the # other repos: this repository is public, so a fork PR would run on the # self-hosted fleet -- executing arbitrary contributor code on hardware we diff --git a/.github/workflows/pinned-versions.yml b/.github/workflows/pinned-versions.yml index 811d0bdae2..01773a6838 100644 --- a/.github/workflows/pinned-versions.yml +++ b/.github/workflows/pinned-versions.yml @@ -31,6 +31,7 @@ defaults: jobs: check: + name: pinned-versions # Hosted, deliberately, and not routed through `vars.GH_RUNNER` like the # other repos: this repository is public, so a fork PR would run on the # self-hosted fleet -- executing arbitrary contributor code on hardware we diff --git a/.github/workflows/provider_image.yml b/.github/workflows/provider_image.yml index d7266d2dbd..0f924a389d 100644 --- a/.github/workflows/provider_image.yml +++ b/.github/workflows/provider_image.yml @@ -35,6 +35,7 @@ defaults: jobs: check: + name: provider-image # Hosted, deliberately, and not routed through `vars.GH_RUNNER` like the # other repos: this repository is public, so a fork PR would run on the # self-hosted fleet -- executing arbitrary contributor code on hardware we diff --git a/.github/workflows/required-check.yml b/.github/workflows/required-check.yml new file mode 100644 index 0000000000..166f127c6a --- /dev/null +++ b/.github/workflows/required-check.yml @@ -0,0 +1,150 @@ +name: required-check + +# The single required status context for main, and the only job in the repo +# named `check`. +# +# Before this existed, SEVEN workflows each published a job called `check` -- +# changesets, cypress, lint, pinned-versions, provider_image, tests and +# typecheck. GitHub collapses same-named check runs into one context and +# resolves it from whichever reported, so a green `check` from the cheapest of +# the seven satisfied the requirement while the other six were still running. +# The path-filter comments in those workflows even described this as the safety +# mechanism that stopped a docs-only PR hanging: it was, and it was also the +# hole. Another of our repos had the same defect and merged a failing PR through +# it, which is how this was found. +# +# The other seven are now named after their workflow, so each reports its own +# distinct context and nothing can mask anything else. This job takes over the +# `check` name and earns it: it waits for every other check run on the commit +# and fails if any of them failed. +# +# Deliberately NO paths filter. It is the required context, so it MUST report on +# every pull request -- a required context that never reports leaves the PR +# blocked forever, which is exactly the trap that stops the other seven from +# being required directly (they are path-filtered, so a docs-only PR skips them +# entirely and they never report at all). + +on: + pull_request: + # `main` and release branches only. `dev` and `staging` are dead -- last + # commits 2024 and 2025, no open PR targets either, and in Protect the + # branches do not exist at all -- so there is nothing to gate there. + branches: [main, release/*] + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] + # The merge queue is not enabled here today. Supported anyway: inside a queue a + # required context that never reports leaves the entry stalled until it is + # ejected, and that is cheaper to cover now than to debug the first time + # someone turns the queue on. + merge_group: + +permissions: + contents: read + checks: read + statuses: read + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + check: + name: check + # Hosted, deliberately, and not routed through `vars.GH_RUNNER` like the + # private repos: this repository is public, so a fork PR would run on the + # self-hosted fleet -- executing arbitrary contributor code on hardware we + # own, where it can also persist and poison later trusted jobs. This job is + # pure API polling and costs seconds of compute either way. + runs-on: ubuntu-latest + if: github.event.pull_request.draft != true + timeout-minutes: 90 + steps: + - name: Wait for every other check on this commit + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} + run: | + set -euo pipefail + + # Poll rather than use `needs:`, because `needs` cannot reach across + # workflows -- and the suites that matter (tests, cypress, typecheck, + # security-scan) each live in their own workflow file. + deadline=$(( SECONDS + 80 * 60 )) + # Give the other workflows a moment to register their check runs, so + # "nothing else has reported yet" is not mistaken for "all done". + grace=$(( SECONDS + 120 )) + + # One commit can carry several check runs with the SAME name: a manual + # re-run, or -- far more often -- a run cancelled by its workflow's + # concurrency group the instant a replacement starts. The API's + # `filter=latest` only dedupes within a single check suite, and each + # workflow run is its own suite, so both the dead and the live copy + # come back. Keep the highest id per name; check run ids increase + # monotonically, so that one is the live copy. + # + # This is why no two jobs in this repo may share a display name: the + # dedupe is by name, so a second workflow reusing a name would be + # silently dropped here. `build` was shared by android-webview and + # ios-webview and both are renamed. + latest_runs() { + gh api "repos/${GITHUB_REPOSITORY}/commits/${SHA}/check-runs?per_page=100" --paginate \ + --jq '.check_runs[] | [.id, .status, (.conclusion // "-"), .name] | @tsv' 2>/dev/null \ + | awk -F'\t' ' + NF>=4 && $4!="check" { + if (!($4 in id) || $1+0 > id[$4]) { id[$4]=$1+0; st[$4]=$2; cc[$4]=$3 } + } + END { for (n in id) printf "%s\t%s\t%s\n", st[n], cc[n], n }' + } + + failures() { + awk -F'\t' '$1=="completed" && $2!="success" && $2!="skipped" && $2!="neutral" {print $3"="$2}' | sort -u + } + + while :; do + runs=$(latest_runs || true) + + total=$(printf '%s' "$runs" | grep -c . || true) + pending=$(printf '%s\n' "$runs" | awk -F'\t' '$1!="completed"' | grep -c . || true) + bad=$(printf '%s\n' "$runs" | failures || true) + + echo "checks: ${total} total, ${pending} pending" + + # Fail as soon as something has definitively failed -- no point + # holding a runner for another hour to confirm the obvious. But + # re-read once first: a cancel and its replacement are not atomic, + # so a poll landing in that gap sees only the cancelled copy. + if [ -n "$bad" ]; then + sleep 30 + bad=$(latest_runs | failures || true) + if [ -n "$bad" ]; then + echo "::error::failing checks: $(printf '%s' "$bad" | tr '\n' ' ')" + exit 1 + fi + echo "transient failure cleared on re-read, still waiting" + continue + fi + + # `total -eq 0` is a pass once the grace period is over, not a wait. + # Requiring `total > 0` here would loop to the 80 minute deadline and + # then FAIL if no other check ever reported. Nothing reaches that + # state today -- changesets, pinned-versions and provider_image all + # run unfiltered, so something always reports -- but "no other checks + # exist on this commit" is a green condition, and a required gate + # should not be one path filter away from blocking every PR. + if [ "$pending" -eq 0 ] && [ "$SECONDS" -gt "$grace" ]; then + echo "all ${total} checks passed or were skipped" + exit 0 + fi + + if [ "$SECONDS" -gt "$deadline" ]; then + echo "::error::timed out waiting for checks; ${pending} still pending" + printf '%s\n' "$runs" | awk -F'\t' '$1!="completed"{print " pending: "$3}' + exit 1 + fi + + sleep 20 + done diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 0000000000..9ad8d89c6e --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -0,0 +1,207 @@ +# Secret scanning (gitleaks), SAST (semgrep) and dependency audit (npm, cargo). +# +# Until now this repository had none of it -- dependabot opens PRs for +# known-vulnerable versions, which is useful but is not a scan and gates +# nothing. +# +# Both scanners are run as their own binaries rather than via their vendor +# Actions: the gitleaks Action requires a paid licence for organisation-owned +# repos. The CLIs are free and the findings are identical. + +name: security-scan + +on: + pull_request: + # `main` and release branches only. `dev` and `staging` are dead -- last + # commits 2024 and 2025, no open PR targets either, and in Protect the + # branches do not exist at all -- so there is nothing to gate there. + branches: [main, release/*] + types: + - opened + - synchronize + - reopened + - ready_for_review + # Not a type this workflow wants to run for -- the jobs below skip it. It + # exists so the run is created and cancels the in-flight one. + - converted_to_draft + schedule: + # Weekly full-history sweep. The PR jobs only see the commits a PR adds, so + # without this nothing ever re-examines what is already on main. + - cron: "0 4 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + secrets: + name: gitleaks + if: ${{ github.event.action != 'converted_to_draft' && github.event.pull_request.draft != true }} + # Hosted, deliberately, and not routed through `vars.GH_RUNNER` like the + # private repos: this repository is public, so a fork PR would run on the + # self-hosted fleet -- executing arbitrary contributor code on hardware we + # own, where it can also persist and poison later trusted jobs. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # gitleaks walks git history, so the default depth-1 checkout would + # give it nothing to walk. + fetch-depth: 0 + # No submodules. packages/fingerprintjs is a separate private repo + # with its own history, and checking it out needs a PAT that a fork + # PR does not get -- which would make this job fail for contributors + # rather than scan their change. + submodules: false + + - name: Install gitleaks + run: | + version=8.28.0 + curl -sSfL -o /tmp/gitleaks.tar.gz \ + "https://github.com/gitleaks/gitleaks/releases/download/v${version}/gitleaks_${version}_linux_x64.tar.gz" + tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks + sudo install /tmp/gitleaks /usr/local/bin/gitleaks + gitleaks version + + # On a PR, scan only the commits the PR adds. Scanning all of history on + # every PR would block unrelated work on anything already committed -- + # that is what the scheduled sweep below is for, and it has to be fixed by + # rotating the value, not by deleting the file, since deleting leaves it + # in history. + - name: Scan pull request commits + if: github.event_name == 'pull_request' + run: | + gitleaks git \ + --log-opts="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" \ + --redact \ + --verbose + + - name: Scan full history + if: github.event_name != 'pull_request' + run: gitleaks git --redact --verbose + + sast: + name: semgrep + if: ${{ github.event.action != 'converted_to_draft' && github.event.pull_request.draft != true }} + runs-on: ubuntu-latest + container: + image: semgrep/semgrep:1.145.0 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # `--baseline-commit` below diffs against the PR base, so that commit + # has to be in the clone. The default depth-1 checkout does not have + # it and semgrep fails with "baseline commit is not a parent". + fetch-depth: 0 + submodules: false + + - name: Trust the workspace + run: git config --global --add safe.directory "$PWD" + + # p/default is semgrep's curated free ruleset; p/secrets and the language + # packs add the injection/authz rules that matter most here. No token is + # set, so nothing is uploaded to semgrep.dev -- findings stay in the log. + # + # `--baseline-commit` reports only findings the PR *introduces*. This tree + # has never been scanned, so it certainly carries a backlog; gating on the + # total would mean every unrelated PR is blocked by debt its author did + # not create, which reliably ends with the job being deleted rather than + # the debt being paid. New code is gated from day one; the backlog is + # measured by the weekly full scan below and burned down against the + # register. + - name: Semgrep (findings introduced by this PR) + if: github.event_name == 'pull_request' + run: | + semgrep scan \ + --config=p/default \ + --config=p/typescript \ + --config=p/nodejs \ + --config=p/secrets \ + --baseline-commit "${{ github.event.pull_request.base.sha }}" \ + --error \ + --skip-unknown-extensions \ + --exclude=node_modules \ + --exclude=dist \ + --exclude=packages/fingerprintjs \ + --exclude='**/*.min.js' \ + --metrics=off + + # Informational: measures the whole backlog so the trend is visible. + # Does not gate -- see above. + - name: Semgrep (full tree, report only) + if: github.event_name != 'pull_request' + continue-on-error: true + run: | + semgrep scan \ + --config=p/default \ + --config=p/typescript \ + --config=p/nodejs \ + --config=p/secrets \ + --skip-unknown-extensions \ + --exclude=node_modules \ + --exclude=dist \ + --exclude=packages/fingerprintjs \ + --exclude='**/*.min.js' \ + --metrics=off + + dependencies: + name: npm audit + if: ${{ github.event.action != 'converted_to_draft' && github.event.pull_request.draft != true }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + + # Gates at `critical` and reports `high` separately without failing, for + # the same reason semgrep uses a baseline: this tree has never been + # audited in CI, so an unknown backlog gated at `high` would block every + # PR on day one. Ratchet to `high` once it is clear. + # + # `npm audit` needs no install step -- it resolves the lockfile against + # the advisory database over the network. + - name: npm audit (critical - gating) + run: npm audit --audit-level=critical + + - name: npm audit (high - report only) + continue-on-error: true + run: npm audit --audit-level=high + + cargo: + name: cargo audit + if: ${{ github.event.action != 'converted_to_draft' && github.event.pull_request.draft != true }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Two independent crates, each with its own lockfile -- there is no + # workspace here to audit in one pass. + crate: [packages/native-ja4, packages/native-merkle] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: false + + - uses: taiki-e/install-action@26e9283f268b880168bdbd2c545dfcd60ec2c6ab # v2.87.13 + with: + tool: cargo-audit + + # This is what caught RUSTSEC-2026-0285 in Protect -- an advisory that + # appeared with no change to the tree at all. Gating, unlike npm audit: + # these two crates have a handful of dependencies between them, so there + # is no backlog to grandfather in. + - name: cargo audit + working-directory: ${{ matrix.crate }} + run: cargo audit diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2954eadff5..7b811aac12 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,9 +10,14 @@ on: # CI. Only paths that cannot possibly affect this workflow belong below. # # Safe against the "required check never reports" trap: the one required - # context on main is `check`, and changesets.yml publishes a job named - # `check` with no path filter at all, so a PR touching only ignored paths - # still gets a `check` and does not sit pending forever. + # context on main is `check`, and required-check.yml publishes it with no + # path filter at all, so a PR touching only ignored paths still gets a + # `check` and does not sit pending forever. + # + # That role used to be filled by changesets.yml, which published a job also + # *named* `check` -- which is precisely why the required context resolved + # from whichever of seven same-named jobs reported first, and why this + # paragraph used to describe the defect as the safeguard. paths-ignore: - '.changeset/**' # release metadata; nothing here reads it - '.github/ISSUE_TEMPLATE/**' @@ -50,6 +55,7 @@ defaults: jobs: check: + name: tests # Hosted, deliberately, and not routed through `vars.GH_RUNNER` like the # other repos: this repository is public, so a fork PR would run on the # self-hosted fleet -- executing arbitrary contributor code on hardware we diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 5cf580867d..c9854aa2f8 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -10,9 +10,14 @@ on: # CI. Only paths that cannot possibly affect this workflow belong below. # # Safe against the "required check never reports" trap: the one required - # context on main is `check`, and changesets.yml publishes a job named - # `check` with no path filter at all, so a PR touching only ignored paths - # still gets a `check` and does not sit pending forever. + # context on main is `check`, and required-check.yml publishes it with no + # path filter at all, so a PR touching only ignored paths still gets a + # `check` and does not sit pending forever. + # + # That role used to be filled by changesets.yml, which published a job also + # *named* `check` -- which is precisely why the required context resolved + # from whichever of seven same-named jobs reported first, and why this + # paragraph used to describe the defect as the safeguard. paths-ignore: - '.changeset/**' # release metadata; nothing here reads it - '.github/ISSUE_TEMPLATE/**' @@ -49,6 +54,7 @@ defaults: jobs: check: + name: typecheck # Hosted, deliberately, and not routed through `vars.GH_RUNNER` like the # other repos: this repository is public, so a fork PR would run on the # self-hosted fleet -- executing arbitrary contributor code on hardware we diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000000..b2eb438b98 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,103 @@ +# gitleaks configuration for a public repository whose main published artefact +# is a client bundle. +# +# Everything here narrows the `generic-api-key` entropy heuristic, which is the +# only rule that fires on this tree. Nothing disables a rule that looks for a +# real credential format, and nothing is allowlisted by directory alone when the +# value itself can be recognised instead -- a path allowlist also hides whatever +# lands in that path later. + +[extend] +useDefault = true + +[[allowlists]] +description = """ +Prosopo site keys. A site key is a public identifier, not a secret: it is +embedded in the HTML of every site that loads the widget, and it is handed out +in the portal precisely so it can be. It is 48 base58 characters of an ss58 +address, so the entropy heuristic flags every one of them. The large majority of +this rule's hits on this tree are a site key under one of these names. +""" +regexTarget = "line" +regexes = [ + '''(?i)(data-)?site[_-]?key\s*[:=]''', + '''(?i)PROSOPO_SITE_KEY(_[A-Z]+)?\s*[:=]''', + '''(?i)(VITE_|REACT_APP_|NEXT_PUBLIC_)?(PROCAPTCHA|DAPP)_SITE_KEY\s*[:=]''', +] + +[[allowlists]] +description = """ +Deliberately published demo keys. These are the always-pass / always-fail keys +documented for integrators to test against, and the fixed site keys the test +suite asserts on. They are checked into the client packages because the client +has to ship them. +""" +paths = [ + '''(^|/)demoKeys\.ts$''', + '''^packages/types/src/client/testSiteKeys\.ts$''', +] + +[[allowlists]] +description = """ +Cryptographic test vectors. tweetnacl's published vectors are the reference +values the implementation is checked against -- they are supposed to be +identical to everyone else's copy. +""" +paths = [ + '''^packages/util-crypto/src/nacl/tweetnacl-.*-data\.spec\.ts$''', +] + +[[allowlists]] +description = """ +An Ethereum contract ABI in a marketplace demo. Function selectors and topic +hashes are high-entropy hex by construction. +""" +paths = [ + '''^demos/demo-nft-marketplace/src/api/abi\.json$''', +] + +[[allowlists]] +description = """ +Substrate dev-chain account seeds -- [mnemonic, address] pairs for the local +development chain, from when the protocol ran on Substrate. The chain they +belong to is long retired. The file should be generated at first run rather than +committed; tracked as such rather than silently ignored. +""" +paths = [ + '''(^|/)database_accounts\.json$''', +] + +[[allowlists]] +description = """ +Test and spec files. What this suppresses is `generic-api-key` firing on the +fabricated 48-character keys that middleware and domain tests have to pass in to +exercise a code path at all. + +This is broader than it should be, and deliberately so after measuring the +alternative. `targetRules = ["generic-api-key"]` would scope it to the entropy +heuristic and leave every format-specific rule live here, and gitleaks 8.28 +parses that key without complaint and then IGNORES it -- with the key present +this allowlist matches nothing at all. A silently-inert suppression fails safe, +so it shows up as a count that does not move rather than as anything breaking. +Do not re-add the key believing it narrows anything. + +The cost is that a credential in a *recognisable* format -- an AWS key, a Stripe +key, a PEM block -- would not be reported from a test file. Accepted against the +alternative, which is a permanently noisy sweep that becomes unreadable and then +gets deleted. Revisit if gitleaks gains working rule scoping. +""" +paths = [ + '''(^|/)tests?/''', + '''\.(test|spec|unit\.test|integration\.test)\.[cm]?[jt]sx?$''', + '''(^|/)testing\.spec\.ts$''', +] + +[[allowlists]] +description = """ +Built client bundles checked into a demo. These are the compiled output of this +repo's own packages, so anything in them is duplicated from source that is +scanned on its own account. +""" +paths = [ + '''\.main\.bundle\.js$''', +] From 99587b92df11cac706ce353eed2c3d1b5888f64d Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 16 Sep 2026 14:21:28 +0100 Subject: [PATCH 2/4] chore: stop private key material being committed Ignores key material by shape rather than by path, since a path-specific ignore only ever covers the directory someone thought of. Public keys and certificates stay trackable. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.gitignore b/.gitignore index 4093933c49..766f604316 100644 --- a/.gitignore +++ b/.gitignore @@ -179,3 +179,22 @@ local.properties # and that artefact is committed. A local build on any other platform is not. packages/native-*/index.*.node !packages/native-*/index.linux-x64-gnu.node + +# Private key material, by shape rather than by path. A path-specific ignore +# only covers the directory someone thought of; this covers the shape. Nothing +# matching these is tracked, so there are no exceptions. If a public certificate +# or trust anchor ever needs committing, add a `!` line for that exact file. +*.key +*.pem +*.p12 +*.jks +*.keystore +service-account*.json +id_rsa* +id_ecdsa* +id_ed25519* + +# Public keys are not secrets, and the `id_*` patterns above would otherwise +# catch them. `accounts/ssh/id_ecdsa_prosopo.pub` is tracked on purpose -- it is +# what gets deployed into authorized_keys. +!*.pub From fa6933dce691fc28a164cb8ab353cae047d38b93 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 16 Sep 2026 14:57:18 +0100 Subject: [PATCH 3/4] ci: stop the Android job installing an SDK package Google removed setup-android's `packages` input defaults to `tools platform-tools`. `tools` is obsolete and no longer in the SDK repository. The runner image ships cmdline-tools 12.0, the action treats that as the wrong version and downloads 20.0, and 20.0 cannot resolve `tools`: sdkmanager tools Warning: Failed to find package 'tools' Error: ... failed with exit code 1 Licence acceptance succeeded; it was the package install that failed. Now asks for platform-tools only and lets Gradle resolve the rest. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/android-webview.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/android-webview.yml b/.github/workflows/android-webview.yml index 73ab439027..9647216e58 100644 --- a/.github/workflows/android-webview.yml +++ b/.github/workflows/android-webview.yml @@ -50,6 +50,18 @@ jobs: - name: Set up Android SDK uses: android-actions/setup-android@v4 + with: + # Drop the action's default `tools platform-tools`. `tools` is an + # obsolete SDK package that Google has removed from the repository, + # and it is what broke this job: the runner image ships cmdline-tools + # 12.0, the action decides that is the "wrong version" and downloads + # 20.0, and 20.0 cannot resolve `tools` at all -- + # `Warning: Failed to find package 'tools'`, then exit 1. Licence + # acceptance was never the problem; it succeeded on the line above. + # + # `platform-tools` is the half that is real (adb and friends). Gradle + # resolves the build-tools and platform it needs on its own. + packages: platform-tools - name: Gradle build + unit tests run: ./gradlew --no-daemon --stacktrace testReleaseUnitTest assembleRelease From 08e3ae2730dafe1a3c215b7972429d149b4064c5 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 16 Sep 2026 15:32:42 +0100 Subject: [PATCH 4/4] ci: do not commit a secret-scan baseline to a public repo A .gitleaksignore lists each known finding as :::, which in a public repository is a precise index to a credential in history -- readable with one `git show`. Out of hundreds of entropy hits that are nearly all false positives, it publishes exactly the ones that are real, which is the triage step done on the reader's behalf. The file is removed and purged from this branch's history. Known history findings are tracked privately instead, so the weekly full-history sweep is report-only and must not gate. The PR-range scan is the gating one and needs no exception list, because it only sees commits the PR adds. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/security-scan.yml | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 9ad8d89c6e..a82ed2610d 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -73,9 +73,9 @@ jobs: # On a PR, scan only the commits the PR adds. Scanning all of history on # every PR would block unrelated work on anything already committed -- - # that is what the scheduled sweep below is for, and it has to be fixed by - # rotating the value, not by deleting the file, since deleting leaves it - # in history. + # that is what the scheduled sweep below is for. This step is the gating + # one, and it only ever sees commits the PR adds, so it stays clean + # without needing any exception list. - name: Scan pull request commits if: github.event_name == 'pull_request' run: | @@ -84,8 +84,26 @@ jobs: --redact \ --verbose - - name: Scan full history + # Report-only, and deliberately without a committed baseline. + # + # A `.gitleaksignore` is the normal way to stop a sweep repeating findings + # that are already known. It cannot be used here: this repository is + # public, and each entry is `:::` -- a precise + # index to a credential, readable with one `git show`. Out of hundreds of + # entropy hits that are almost all false positives, such a file publishes + # exactly the ones that are real. That is the triage step done for anyone + # looking, so it does not belong in a public tree. + # + # The known history findings are tracked privately instead. This job + # therefore reports a small, stable set of findings every week and must + # not gate on them -- hence continue-on-error. Read the log, compare + # against the private list, and investigate anything new. + # + # Once those credentials are rotated there is nothing left to except, and + # this should become gating: drop continue-on-error. + - name: Scan full history (report only) if: github.event_name != 'pull_request' + continue-on-error: true run: gitleaks git --redact --verbose sast: