Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/audit/_preamble.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ for a check you could not determine — a transient network error, or an area yo
ran out of room to reach — and say which it was. It is never a substitute for a
check you could have run.

A condition no audit run can read — a provisioning step, a setting in an
external service's console — is not a check, so the each-clause rule above
does not reach it. Record it as INFO, never as
`UNVERIFIABLE`: nothing a later run can read would settle it, so every later
run would be inconclusive too.

- Written into a `FAIL IF`: verdict only that rule's readable condition.
- Stated beside a rule, or staged under `## Future`: there is no rule to
verdict.
- Promoted above the fold: audit it as the promoted rule is written — a
readable condition as a `FAIL IF`, an unreadable one by the bullets above.

GitHub state `AUDIT_PAT` reaches — rulesets, environments, secret placement,
workflow permissions — is readable, so it is always a check, and
`UNVERIFIABLE` stays right for a call that fails.

Where `docs/specs/security.md` says a risk is accepted ("What is not defended")
or a gap is known ("Known gaps"), do not re-report it as a finding — report
only if the situation has changed or is worse than described.
Expand Down
3 changes: 1 addition & 2 deletions .github/audit/hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@ code from pending production configuration; do not treat local provider
simulations as live OAuth acceptance, and treat a checked-in placeholder as no
evidence about an external control. Production activation is staged under the
spec's `## Future`: while it sits below the fold there is no check here, so
report its state as INFO under `### Qualitative findings`. Once it is promoted
above the fold, audit it as a `FAIL IF` like any other.
report its state as INFO under `### Qualitative findings`.

## Qualitative pass

Expand Down
1 change: 1 addition & 0 deletions docs/specs/security-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ Source of truth: `2. Wait without ending your turn`, `3. Merge`, and `4. The ver
- **Partial has three shapes**, each named in the INCONCLUSIVE issue: `UNVERIFIABLE` for a check reached but not determined; `_Incomplete …_` above a fragment cut off mid-report; `_No report …_` for a domain that never wrote one. The merged `## Summary` may likewise read `INCONCLUSIVE`, and **gives no coverage count for a cut-off domain** (rationale).
- **With no `audit-report.md` the reporting step publishes each fragment verbatim under its own heading**, unmerged (rationale).
- **Must return `VERDICT: INCONCLUSIVE` from a domain with any undetermined check unless it found a failure.** Only all-determined passing checks permit `VERDICT: PASS`; a domain's inconclusive verdict prevents a merged pass.
- **Never write a `FAIL IF` condition no audit run can read**: audit the readable half; stage the rest under `## Future` only while it is unbuilt, and otherwise state it beside the rule. `AUDIT_PAT`-readable GitHub state stays audited (rationale).
- **`STATUS` is assigned in exactly two places**: where the status file is parsed, and in the single escalation block, **which orders `FAIL` > `MISSING` > `PASS`** — a dissent can raise `MISSING` to `FAIL` and never the reverse, and a `FAIL` alongside missing or unreadable fragments still reports them.
- **The report is truncated to 32,000 characters before posting**, head kept, by `scripts/clamp-issue-body.mjs` (self-tested by `scripts/clamp-issue-body-selftest.mjs`). The call is non-fatal; the `audit-transcript` artifact holds the report in full; `.github/workflows/workflow-audit.yaml` truncates its commit list the same way (rationale).
- **Every run uploads the `audit-transcript` artifact, which is world-readable and not secret-masked** — 14-day retention, deep-linked from failure issues (rationale).
Expand Down
2 changes: 2 additions & 0 deletions docs/specs/security-audit.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ Run 35205193090's `## Summary` also inverted the placeholder it was reading: "tw

Collapsing the inconclusive case into `FAIL`, as the step originally did, filed an identical issue for "the repo is insecure" and "the auditor stopped early".

A `FAIL IF` condition no audit run can read makes the verdict a coin flip, because no run can ever determine it. `AUDIT_PAT`-readable GitHub state is not in that class: a failed call there is a real `UNVERIFIABLE`. `## Future` holds such an obligation only while its subject is unbuilt, since a staged item must eventually be promoted; a standing obligation on existing infrastructure is present-tense fact and stays beside its rule. `security-hosted.md` carried two: the Cloudflare script-injection exclusion, which is a zone setting, and a closing activation sentence that stated its own answer. Run 35586089654 (2026-09-21) reached both as `UNVERIFIABLE` in its sub-auditors and its domain lead resolved both to PASS, on the ground that the audited condition was the in-repo half; run 35709640946 (2026-09-22) left both `UNVERIFIABLE`, so a pass with 375 PASS and 0 FAIL returned INCONCLUSIVE and held the release gate shut (issue #747). Nothing in the tree had changed between them. #757 staged both under `security-hosted.md`'s `## Future` and kept the in-repo half — the deploy's `preflight` gate — as a `FAIL IF`.

GitHub rejects an over-long issue body outright; that rejection lands on a `set -e` step *after* the verdict is decided, and the finding then reaches no issue and no comment — only a red run and an artifact that expires. Truncation keeps the head because that is where the verdict and the links are, and the clamp call is non-fatal so a failure of the helper cannot reopen the window it closes.

Issue prose per combination of conditions cannot be kept correct by fixing combinations. Four consecutive review rounds found the same defect in different clothes — an arm whose text was true only of the states that could reach it, made false by the next gate that widened. A note claiming nothing about the other conditions cannot be invalidated by a new one.
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/security-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ Source of truth: `packageRules` in `.github/renovate.json`; `WINDOW` and `is_ten
**Must keep Hosted credentials in dedicated environments.** `hosted-production` and `hosted-release-tag` admit only `main`; `hosted-preview` admits only `main` and `refs/pull/*/merge`. All require Ned or Edgar's review with administrator bypass disabled; self-review is allowed. Preview approval authorizes the PR code to receive test-resource credentials only.

- **FAIL IF** a Hosted environment lacks those branch restrictions, required reviewers, or disabled administrator bypass; inspect all three environments and their deployment policies.
- **FAIL IF** Hosted credentials appear at repository/org scope, production credentials appear in `hosted-preview`, or preview credentials can reach production/TTR/marketing resources. Inspect GitHub secret placement and Cloudflare/Neon token scope; names alone do not isolate resources.
- **FAIL IF** Hosted credentials appear at repository/org scope, or production credentials appear in `hosted-preview`; inspect GitHub secret placement.
- **FAIL IF** `HOSTED_TAG_TOKEN` appears outside `hosted-release-tag`, or that environment is used by a job other than `tag` in `.github/workflows/hosted-production.yml`. Its admin identity's repository-scoped Contents-write PAT can write code and bypass tag protection; it must never enter a deployment job or PR execution.
- **FAIL IF** a Hosted preview deploy accepts a fork or a failing verification, preview cleanup checks out a PR ref rather than `main`, or a Hosted production tag can run before live verification succeeds; inspect the workflow dependency/condition graph.

Expand Down
23 changes: 8 additions & 15 deletions hosted/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,8 @@ revision before a production release.
| Recovery | Neon backups/PITR enabled, encrypted pre-migration dumps retained as GitHub artifacts for 30 days, age identity also retained independently in a password manager |

Cloudflare Workers Scripts and Hyperdrive permissions are account-scoped, so
previews need their own test account. Production deployment isolation likewise
requires a boundary marketing's existing credentials cannot reach; coordinate
the hostname/zone placement before choosing an account. Do not reuse TTR's Neon
project, mail token, or OAuth registrations. `docs/specs/security-ci.md` ->
"Hosted Deployments" owns the credential isolation the audit checks.
previews need their own test account. `docs/specs/security-ci.md` -> "Hosted
Deployments" owns the credential placement the audit checks.

## GitHub setup

Expand Down Expand Up @@ -142,11 +139,11 @@ back from GitHub; retain independent copies in your password manager.
## Provision the production boundary

Use dedicated Dormouse resources in the existing Cloudflare, Neon, and Postmark
accounts. Do not reuse TTR's database, mail server/token, or OAuth registrations.
accounts.

1. Create a dedicated Dormouse production Postgres database (Neon is the TTR
precedent) on PostgreSQL 17; the backup/restore tooling pins PostgreSQL
17.11. Keep TTR, development, and previews separate. Enable backups and a
1. Create a dedicated Dormouse production Postgres database on Neon, on
PostgreSQL 17; the backup/restore tooling pins PostgreSQL
17.11. Keep development and previews separate. Enable backups and a
suitable PITR window, and verify a restore into a separate database before
accepting real accounts.
2. Create a Cloudflare Hyperdrive configuration for that database with **query
Expand All @@ -166,10 +163,7 @@ accounts. Do not reuse TTR's database, mail server/token, or OAuth registrations
`signin@hosted.dormouse.sh` (or update `EMAIL_FROM`). Configure SPF/DKIM and
DMARC. Register the sender with Apple Private Email Relay for relay-address
delivery.
5. Use a deployment identity separate from marketing, with access limited to
the Hosted deployment resources. If a Cloudflare account token cannot express
that isolation, use a separate account/deployment boundary.
6. Configure `hosted.dormouse.sh` as the Worker's custom domain. Exclude this
5. Configure `hosted.dormouse.sh` as the Worker's custom domain. Exclude this
hostname from Cloudflare Web Analytics, Zaraz, and other script injection
or rewriting rules. Disable account API caching. Keep `workers_dev` and
public preview URLs disabled.
Expand All @@ -182,8 +176,7 @@ keychain. Authenticate in your own terminal; account/provider sign-in is operato

## Separate OAuth registrations

Create Dormouse registrations; do not reuse TTR credentials or replace TTR's
callbacks. Register these exact URLs with no trailing slash:
Create Dormouse registrations. Register these exact URLs with no trailing slash:

| Provider | Registration | Callback |
| --- | --- | --- |
Expand Down
2 changes: 1 addition & 1 deletion scripts/spec-word-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"docs/specs/relay.md": 10150,
"docs/specs/remote-api.md": 4700,
"docs/specs/remote-security-model.md": 4750,
"docs/specs/security-audit.md": 1950,
"docs/specs/security-audit.md": 2000,
"docs/specs/security-ci.md": 2750,
"docs/specs/security-hosted.md": 600,
"docs/specs/security-local.md": 3150,
Expand Down
Loading