> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tuturuuu.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Security Review Cadence

> Pull request security checks, the security review classifier, the daily findings register and the weekly deep review.

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

| Check | Workflow | Runs when | Fails on |
| - | - | - | - |
| Secret scan (gitleaks, checksum-verified release) | `security-checks.yaml` → **Secret scan** | Every PR and `main` push; scans the commits in the change | Any finding in those commits |
| Dependency review | `security-checks.yaml` → **Dependency review** | PRs that change a manifest or lockfile | New dependency with a high/critical advisory (GitHub dependency graph) |
| Dependency audit (`bun audit`) | `security-checks.yaml` → **Dependency audit** | PRs and pushes that change a manifest or lockfile | High/critical advisory present at head but not at base |
| Workflow trust boundaries | `security-checks.yaml` → **Workflow trust boundaries** | Changes under `.github/` or `scripts/ci/` | Any failing workflow security suite (`scripts/ci/workflow-*.test.js`, `security-*.test.js`, protected concurrency, workflow config) |
| CodeQL (`security-extended`) | `codeql.yml` → **Analyze (language)** | PRs and `main` pushes; only languages the diff touches (`javascript-typescript`, `python`, `actions`) | High/critical result absent from the latest `main` analysis |
| Security review classification | `security-review-classifier.yaml` → **Security review** check (plus a cosmetic `security-review-required` label); recomputed by `security-review-reclassify.yaml` and `security-review-sweep.yaml` | Every PR event (no path filter), auto-merge or merge-queue entry, every review submission or dismissal, and an hourly trusted sweep | Reports `action_required` until an independent security review is recorded; `success` when no sensitive path changed |

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`:

| Category | Examples | Owner role |
| - | - | - |
| `ci` | `.github/**`, `scripts/ci/**`, `tuturuuu.ci.ts`, `scripts/git-*` | `ci-cd` |
| `auth` | `packages/auth`, `packages/sso`, auth/session/OAuth/OIDC/MFA paths, `proxy.ts`, `api-middleware`, `authorization`, `api-proxy-guard`, `required-mfa*`, `external-apps*` | `platform-auth` |
| `rls-database` | `apps/database/**`, `packages/supabase/**`, RLS/policy files | `database` |
| `tenant-isolation` | permission, RBAC, tenant and workspace-membership helpers, calendar sync/schedule routes, notification send-immediate | `platform` |
| `runner-sandbox` | devbox, cron, coordination, playground, Dockerfiles, Wrangler config | `runtime` |
| `tokens-secrets` | token, secret, credential, crypto, JWT and API-key paths, `email-unsubscribe`, `.env*` | `platform-auth` |
| `scheduled-jobs` | every `apps/*/src/app/api/cron/` route, `legacy-api-routes/cron/`, `*cron*` modules such as the Infrastructure cron gateway, post-email-queue run-now | `runtime` |
| `payments` | `packages/payment*`, Pay payment/billing APIs (including `apps/pay/src/app/api/payment/webhooks`), SePay libraries | `payments` |
| `inbound-webhooks` | every `webhook(s)` path, mail inbound | `platform` |
| `storage` | `packages/storage-core`, Drive, upload/bucket/signed-URL paths | `storage` |

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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.