Skip to main content
Tuturuuu is open source and a high-value target, so security review is a default part of delivery rather than an occasional audit. Three workflows carry the automated part, with two small helpers that recompute the review check when a review changes. People and agents carry the review part, on the cadence below. No automated result is a security clearance. Each run is evidence for the listed sources at one commit.

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 latest main 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.yaml and codeql.yml use pull_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.js enforces 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 inline jq check: any gitleaks finding, any high/critical advisory, or any high/critical CodeQL result fails. A result whose rule is unknown, or whose security-severity is missing, null, empty or not numeric, counts as high. The trusted codeql-new-alerts.js applies 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_PATH change. 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 to registry.npmjs.org. A checkout-local bunfig.toml or .npmrc cannot redirect the advisory lookup. This scanner pin is independent of the repository’s development Bun version. To upgrade it, change BUN_VERSION and BUN_SHA256 together in one reviewed PR. Take the SHA-256 for bun-linux-x64.zip from the release’s SHASUMS256.txt and confirm that it matches the release asset digest (gh release view bun-v<version> -R oven-sh/bun --json assets). Run security-trusted-helpers.test.js and security-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 delete security-checks.yaml or codeql.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/** and scripts/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 inline gitleaks:allow comments suppress nothing, and it deletes the checkout’s .gitleaksignore and .gitleaks.toml first: gitleaks loads both from the scanned directory even when explicit paths are given. To accept a finding, merge its fingerprint into .gitleaksignore through 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.js proves 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.js runs 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.js covers 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.qls to SARIF. Compare results with newHighFindings in scripts/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.js preserves this separation and the deployment admission checks.
  • Bootstrap sequencing. Until scripts/ci/codeql-new-alerts.js is on main, the pull request gate fails on every high/critical result, including pre-existing ones. Land the gate script on main first, in its own PR. The trusted lookup reads it from HEAD^1, which is the base (main) commit of the pull request merge ref; the gate does not run on pushes. Then make sure main has a CodeQL analysis for every scanned category, because the script compares against main’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.yml exists. 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 starts security-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:
  1. Native code-owner review (primary). The main ruleset 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/CODEOWNERS assigns. #6491 adds the file with the security reviewer on .github/, auth, migrations, devbox and payments, including apps/web/src/app/api/auth/**, packages/satellite/src/auth.ts, packages/supabase/src/next/server.ts and apps/pay/src/app/api/payment/**. Classifier categories outside CODEOWNERS paths, and every path until CODEOWNERS merges, rely on the layers below.
  2. 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.yaml runs 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 schedule and conclusion success.
      • The fast path cannot starve the partition, which always runs first.
    • It posts a check only when the decision changed, and never writes labels. Its token has actions: read (run history), checks: write, pull-requests: read and contents: 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_TOKEN allowance (1,000 calls an hour, shared by all workflows) bounds this.
    GitHub may delay or drop scheduled runs, so the sweep bounds staleness only loosely. A dropped run delays its slice by a day. The sweep is a backstop, not a guarantee.
  3. The relay remains the fast path for honest PRs.
The Security review check is therefore stricter, but it is not the only control for revocation. 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 ci change that requires review.

Enforcement

The check is enforced only when a ruleset requires the Security review status check from the GitHub Actions app (integration ID 15368). 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 latestCheckConclusion by its slug.
The 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 daily security-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: stable TTR-SEC-<SOURCE>-<hash>, derived from the advisory, alert number or fingerprint.
  • severity, owner (role), source and status (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, accepted or expired).

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).
Agents add entries as 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 the deep input. During the week that follows, the security integration owner, or an agent assigned that role by the program owner, reviews:
  1. The latest findings register: triage new unconfirmed entries, confirm or dismiss them with a reason, and check that blocker owners have remediation in progress.
  2. 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.
  3. Merged PRs carrying security-review-required: each needs a recorded independent approval of its merged head.
  4. Workflow trust drift: run uvx zizmor --offline .github locally and compare it with the workflow security suites; fix or record new high findings.
Record the outcome in the private program backlog. Keep public notes to IDs, counts and remediation receipts.