What runs on every pull request and main push
Path gating uses the same changed-file resolution as the rest of CI, but
security-checks.yaml and codeql.yml deliberately do not call the ci-check
switchboard and have no tuturuuu.ci.ts keys. The switchboard’s selectors run
from the pull request checkout, so a hostile copy could report should_run=false
and skip every scan. Instead, each scope job reads resolve-changed-files.ts and
security-paths.js from the base commit. Unknown change sets, or a base without
those scripts, scan everything.
Pre-existing findings
The October 2026 CodeQL refresh reported 270 open alerts. Pull requests fail only on high/critical CodeQL results the diff introduces, matched against the latestmain analysis by rule and GitHub’s primary location fingerprint. The same
applies to bun audit advisories, compared against the base commit. Existing
findings are tracked in the findings register instead of blocking unrelated work.
Fork safety
security-checks.yamlandcodeql.ymlusepull_request, so fork runs get a read-only token and no secrets. Jobs install no packages and run no package scripts.- Pull request code does not select or decide a scanner verdict: no switchboard,
scope selector, summarizer, differ, gate script, local action or setup script
from the checkout runs before a scan job’s verdict is final.
security-workflows.test.jsenforces this for every job. The scan summarizer, the audit differ and the CodeQL new-alert gate come only from the base commit. Without them, for example while the PR that introduces them is open, the gate fails closed with an inlinejqcheck: any gitleaks finding, any high/critical advisory, or any high/critical CodeQL result fails. A result whose rule is unknown, or whosesecurity-severityis missing,null, empty or not numeric, counts as high. The trustedcodeql-new-alerts.jsapplies the same rule. One fixture (scripts/ci/fixtures/codeql-severity.sarif.json) drives the tests for both gates. The register refuses to build. The checkout’s copy is never executed. The scope scripts that choose which scans run (resolve-changed-files.ts,security-paths.js) are also read from the base commit. Without them, every scan runs. - Dependency audit runs nothing from the checkout before the audit: no local
action, setup script or
GITHUB_PATHchange. bun is a checksum-verified release (BUN_VERSION/BUN_SHA256) called by absolute path, and head and base manifests are copied out of git into empty directories with the registry pinned toregistry.npmjs.org. A checkout-localbunfig.tomlor.npmrccannot redirect the advisory lookup. This scanner pin is independent of the repository’s development Bun version. To upgrade it, changeBUN_VERSIONandBUN_SHA256together in one reviewed PR. Take the SHA-256 forbun-linux-x64.zipfrom the release’sSHASUMS256.txtand confirm that it matches the release asset digest (gh release view bun-v<version> -R oven-sh/bun --json assets). Runsecurity-trusted-helpers.test.jsandsecurity-workflows.test.js, and confirm that the Dependency audit job’s install step passes the checksum check in CI. - Limits. Under
pull_request, the workflow files themselves come from the PR. A PR can still edit or deletesecurity-checks.yamlorcodeql.yml, and the job runs whatever the PR defines. The Workflow trust boundaries job is a test run of the change itself, like unit tests. These cases are covered only by the Security review classification (.github/**andscripts/ci/**are sensitive), code-owner review, and a required Security review check. Scans are not tamper-proof against a PR that edits them. - Gitleaks configuration (
.github/security/gitleaks.toml), the ignore list (.gitleaksignore) and the scan summarizers are read from the base commit, so a PR cannot allowlist or hide its own finding. The scan passes--ignore-gitleaks-allow, so inlinegitleaks:allowcomments suppress nothing, and it deletes the checkout’s.gitleaksignoreand.gitleaks.tomlfirst: gitleaks loads both from the scanned directory even when explicit paths are given. To accept a finding, merge its fingerprint into.gitleaksignorethrough a reviewed PR; it applies to later scans. Allowlists are path-scoped with a reason; never allowlist by value.scripts/ci/security-gitleaks-suppression.test.jsproves this with a synthetic token. The Secret scan job runs it against the installed gitleaks release as its last step, after the scan is final, because it is checkout code.scripts/ci/security-trusted-helpers.test.jsruns the real scan, audit and CodeQL gate steps against a base without the trusted helpers and a hostile checkout copy. - Fork CodeQL results are not uploaded. They feed only the new-alert gate.
- The new-alert gate prints each blocking result’s rule ID and primary
path:line, with workflow-command characters escaped. SARIF messages and full SARIF artifacts remain omitted.codeql-new-alerts.test.jscovers diagnostics, baseline matching, missing output and missing baselines. Pull request uploads are diff-filtered, so the Security tab can show zero alerts while the local SARIF still blocks the gate. Use the printed location to triage that result. The inline bootstrap gate still prints counts only. Diagnostics from the trusted script take effect after that script reaches the base branch. - For a local Actions reproduction, download the official platform bundle at the
hosted job’s CLI version and verify its published SHA-256 checksum. Create an
Actions database from an isolated exact-head worktree, then analyze
codeql/actions-queries:codeql-suites/actions-security-extended.qlsto SARIF. Compare results withnewHighFindingsinscripts/ci/codeql-new-alerts.js; if a category has no baseline, compare the same suite against the base source. Clean up the downloaded bundle and databases after retaining the rule, location and verification outcome in the private handoff. - Production migration prerequisite evaluation executes tooling from
github.sha, the trusted workflow revision. Configuration is also read from that revision, rather than the migration target, while passing the immutable resolved production SHA to changed-file evaluation. Only the admitted deploy job checks out the approved target. Node package-manager caching is disabled in prerequisite evaluation.supabase-production-tooling.test.jspreserves this separation and the deployment admission checks. - Bootstrap sequencing. Until
scripts/ci/codeql-new-alerts.jsis onmain, the pull request gate fails on every high/critical result, including pre-existing ones. Land the gate script onmainfirst, in its own PR. The trusted lookup reads it fromHEAD^1, which is the base (main) commit of the pull request merge ref; the gate does not run on pushes. Then make suremainhas a CodeQL analysis for every scanned category, because the script compares againstmain’s latest analysis per category and still fails closed when a category has no baseline. New findings always fail. - Do not enable GitHub CodeQL default setup while
codeql.ymlexists. Code scanning rejects advanced-setup uploads when default setup is on.
Security review classification
security-review-classifier.yaml runs on pull_request_target from the default
branch. It never checks out, installs or executes pull request content. It lists
the PR’s file names (including the old name of renamed files) through the API
and classifies them with scripts/ci/security-paths.js:
Docs (
.md, .mdx) and translation bundles are exempt. A file list at GitHub’s
3,000-file limit is always classified as requiring review. Every path that
.github/CODEOWNERS assigns must classify as requiring review.
scripts/ci/security-codeowners-sync.test.js checks the live file and a pinned
copy of #6491’s, so the two lists cannot drift.
Membership is always decided from the PR’s changed paths, never from its
label. When a PR matches, the workflow records a Security review check run
on the head commit and, as a best-effort cosmetic hint, adds the
security-review-required label. Neither revocation nor an approved PR’s
success depends on the label. The check concludes
success only when a user listed in the repository variable SECURITY_REVIEWERS
has approved that exact head commit and is not the PR author. A new commit
requires a new approval. PRs that touch no sensitive path also get a
Security review check, concluding success, so the check can be required.
SECURITY_REVIEWERS
An admin sets this repository Actions variable (Settings → Secrets and
variables → Actions → Variables). The value is a list of GitHub user logins,
separated by commas and/or whitespace and matched case-insensitively, for
example vhpx or vhpx, another-reviewer. Team slugs such as
tutur3u/security are not expanded: they never match a reviewer login, so they
behave like an empty list.
The classifier fails closed. If the variable is unset or empty, or no listed user
has approved the current head, a sensitive PR’s check stays action_required
(titled “SECURITY_REVIEWERS is not configured” when the list is empty). Approvals
by the PR author, bots, unlisted users or of an older head never count.
Re-evaluation after reviews
Submitting or dismissing a review startssecurity-review-recheck.yaml. That
workflow runs on pull_request_review from the PR itself, has no permissions and
only signals. security-review-reclassify.yaml (workflow_run, always the
default-branch file) then checks out only the default-branch classifier. It finds
the open PR whose live head matches the signal through the API, and recomputes
the check from the current file list and reviews. The latest submitted review per
reviewer decides, so a dismissed approval (DISMISSED) withdraws the success.
Comments and draft reviews are ignored. At most 1,000 reviews are read. GitHub
lists them oldest first, so a later change request could sit beyond that bound.
A history that does not end inside the bound reports action_required. A file
or review listing that cannot be read records action_required (“could not be
evaluated”) instead of leaving an earlier success standing. The revocation is
always posted first. Duplicate suppression (sweep only) and labeling are
best-effort, so a failed check lookup or label write cannot suppress it. A
rejected check post fails the run.
Writers never interleave for one PR. The classifier and the reclassify job share
the repository-wide, non-cancelling concurrency group
security-review-pr-<number>. Reclassify first resolves the PR in a separate
job, so it can join that group. The sweep spans many PRs and cannot hold their
groups. So every writer, including the sweep, re-reads the live head and then
the reviews immediately before posting, and posts nothing when the head moved.
An action_required followed by a stale success for the same head is
therefore impossible: a success always reflects the reviews read just before
its post. Event fields serve only as lookup keys, so
a spoofed signal can only cause an honest recomputation. A fork contributor’s
first workflow runs may wait for maintainer approval. Until then, re-run the
latest classifier run to recompute.
Revocation does not depend on the relay
The relay is defined by the pull request itself, so a PR can delete or rename it. A later dismissal would then never be signalled, and an earlier success would go stale. Revocation is therefore enforced in layers that the PR does not own:-
Native code-owner review (primary). The
mainruleset requires code-owner review and dismisses stale approvals on push. GitHub then withholds the merge when a code owner’s approval is dismissed or a new commit lands. This covers only paths that.github/CODEOWNERSassigns. #6491 adds the file with the security reviewer on.github/, auth, migrations, devbox and payments, includingapps/web/src/app/api/auth/**,packages/satellite/src/auth.ts,packages/supabase/src/next/server.tsandapps/pay/src/app/api/payment/**. Classifier categories outside CODEOWNERS paths, and every path untilCODEOWNERSmerges, rely on the layers below. -
Trusted recomputes. The classifier (
pull_request_target, default-branch file) also runs when auto-merge is enabled or the PR enters a merge queue.security-review-sweep.yamlruns hourly on a schedule from the default branch (scripts/ci/security-review-sweep.js) and keeps no state:- Partition (the guarantee). Each run evaluates every open PR whose number modulo 24 equals the current UTC hour. Membership comes from changed paths, never labels. Every open PR is therefore visited once per 24 scheduled runs. A failed or over-budget slice fails its run but cannot block a later slice, and the rest of a failing slice is still evaluated. The slice starts at a different member each UTC day, so an expensive PR cannot starve the same later members of its slot every day.
- Fast path (best effort). Afterwards, at most 10 other PRs updated
since the last successful sweep are evaluated, oldest change first.
- The cursor is read from GitHub’s run history. It is used only when the
run is on the default branch, from this workflow’s ID and path, with
event
scheduleand conclusionsuccess. - The fast path cannot starve the partition, which always runs first.
- The cursor is read from GitHub’s run history. It is used only when the
run is on the default branch, from this workflow’s ID and path, with
event
- It posts a check only when the decision changed, and never writes labels.
Its token has
actions: read(run history),checks: write,pull-requests: readandcontents: read. - Call budget. The partition’s budget is sized to its slice: 8 REST
calls per PR, which covers the files, live head, reviews, check lookup
and post, with headroom for an extra page.
- A run costs about three cursor calls, one listing call per 100 open PRs, and about 4 to 6 calls per evaluated PR.
- At 200 open PRs that is roughly 9 slice PRs plus 10 fast-path PRs: about 120 calls a run.
- The repository’s
GITHUB_TOKENallowance (1,000 calls an hour, shared by all workflows) bounds this.
- The relay remains the fast path for honest PRs.
scripts/ci/security-review-recheck.test.js fails if
removing the relay would leave no trusted recompute path.
The control cannot be bypassed by the PR author:
- The workflow files that hold write tokens and the classifier come from the base/default branch.
- Removing the label changes nothing: it is cosmetic, and the next run re-adds it.
- Changing the classifier is itself a
cichange that requires review.
Enforcement
The check is enforced only when a ruleset requires the Security review status check from the GitHub Actions app (integration ID15368). Pinning the app stops
a same-named commit status or another app’s check from satisfying it. It cannot
stop a same-repository workflow holding checks: write from posting a same-named
check: every GITHUB_TOKEN check run comes from the shared github-actions app,
with nothing that binds it to the classifier. security-workflows.test.js fails
if any workflow other than the three Security review writers requests
checks: write. Keep the repository’s default workflow token read-only, and
treat a PR that adds such a workflow as a security review case. This is a
mitigation, not a fix: a pull request can still edit workflows under
pull_request.
The full fix needs a repository-setting decision by the owner:
- create a dedicated GitHub App with only
checks: write, and have all three writers post with its installation token; - require the Security review check from that App’s integration ID;
- filter
latestCheckConclusionby its slug.
tuturuuu-bot App (#6493) does not fit, because it deliberately has no
checks permission and is never used from workflows. Organization admins with an
always bypass skip the requirement, and GitHub records the bypass. Without that
rule, the integration owner must not merge a PR with a pending Security review
check. Before merging, re-derive the decision from main:
node -e "console.log(require('./scripts/ci/security-paths.js').classifyReview(process.argv.slice(1)))" <paths>.
Daily audit and findings register
The dailysecurity-checks.yaml schedule (17 2 * * *, the only security scan timer)
runs CodeQL on main through codeql.yml, gitleaks over the last three days of
history, bun audit on the lockfile, and the workflow trust suites. It then
builds the findings register (scripts/ci/security-findings-register.js) from
those results and the code scanning and Dependabot alert APIs.
Each register entry (tuturuuu-security-register/v1) has:
id: stableTTR-SEC-<SOURCE>-<hash>, derived from the advisory, alert number or fingerprint.severity,owner(role),sourceandstatus(open,fixed,accepted).confirmed: whether a finding is a confirmed advisory match or untriaged scanner output.remediation: receipts such as fix or dismissal time and reason.reference: advisory or alert URL.acceptance: present when a risk acceptance matches (pending,acceptedorexpired).
Risk acceptances
.github/security/risk-acceptances.json lists proposed risk acceptances for
dependency advisories (matched by advisory ID). Each entry records:
- the scope;
- the guards that keep the vulnerable path unreachable;
- the owner role;
proposedAt;expiresAt, at most 90 days after the proposal;- what ends the acceptance earlier (for example the first patched release).
pending. Only the release authority approves one, in a
reviewed PR that sets status: accepted, approvedBy and approvedAt. A
pending, unapproved or expired acceptance leaves the finding open, so a
confirmed high or critical finding still blocks the release. An unreadable
file accepts nothing and is reported as a coverage gap.
Prefer resolving a risk over accepting it. For example, node-forge
GHSA-86w9-cpqp-85rv is removed in #6519 (an in-process PKCS#12 parser)
rather than accepted. The shipped list is empty. Until #6519 merges, the
findings register reports that advisory as a confirmed release blocker. Pull
request audits do not fail on it, because it is already present at the base
commit.
Actions logs and artifacts of a public repository are public, so the register is
redacted. It omits secret values and locations, and the rule IDs and paths of
code-scanning alerts; those stay in GitHub’s maintainer-only security views and
are referenced by alert URL. Dependency entries name the package and public
advisory. Never add raw secrets or customer logs to scans, summaries or artifacts.
Confirmed critical/high findings are release blockers. The register job fails
while any confirmed open critical/high entry exists. The integration owner treats
a red register run, or any open private security advisory, as a blocker for
production sync. Untriaged CodeQL and gitleaks output stays visible, unconfirmed,
until a maintainer triages it. Record a confirmed code finding as a private
repository security advisory, or fix it.
A source that could not be read (for example, secret scanning alerts are not
readable with the workflow token) is listed as a coverage gap, never as clean.
Weekly deep review
No extra timer runs this. On Mondays (UTC) the daily schedule also runs a full-history secret scan, and a manual dispatch can request the same deep pass with thedeep input. During the week that follows, the security integration
owner, or an agent assigned that role by the program owner, reviews:
- The latest findings register: triage new unconfirmed entries, confirm or dismiss them with a reason, and check that blocker owners have remediation in progress.
- GitHub’s maintainer-only views:
- secret scanning and push-protection bypasses (rotate exposed credentials first);
- code scanning alerts by severity;
- Dependabot alerts, including manifests outside
bun.lock.
- Merged PRs carrying
security-review-required: each needs a recorded independent approval of its merged head. - Workflow trust drift: run
uvx zizmor --offline .githublocally and compare it with the workflow security suites; fix or record new high findings.