Threat-Model Drift Gate
How SveltyCMS keeps six security surfaces mapped to reviewed STRIDE models, and how CI fails when a route changes without a matching review.
On this page
Six broad surfaces cover the server-side attack surface. Each surface owns a set
of paths in docs/security/threat-model.json and a STRIDE page in
docs/security/threat-models/. When a file inside a surface changes, its
fingerprint no longer matches the recorded value and CI fails until the model is
reviewed and the manifest is refreshed. Coverage is currently 58/58 server
route files (100.0%).
| Surface | Files | Threat model |
|---|---|---|
api-dispatch |
78 | API dispatch |
auth-session |
82 | Authentication & session |
content-write |
51 | Content write |
media-files |
53 | Media & files |
admin-pages |
207 | Admin pages |
system-ops |
84 | System operations |
The per-surface MDX pages live in docs/security/threat-models/; the numbers
above are the file counts reported by the last --update.
Manifest contract
docs/security/threat-model.json is versioned and validated with Valibot by
scripts/security/threat-model-lib.ts (ManifestSchema):
{
"version": 1,
"surfaces": [
{
"id": "api-dispatch",
"description": "HTTP API dispatch, endpoint permission mapping and request-lane hooks.",
"paths": ["src/routes/api/**", "src/hooks/handle-api-requests.ts"],
"model": "docs/security/threat-models/api-dispatch.mdx",
"reviewed_at": "2026-11-05",
"fingerprint": "<64 hex chars>"
}
]
}
Rules enforced by the schema:
versionis exactly1; surface ids are unique kebab-case strings.reviewed_atis a realYYYY-MM-DDcalendar date.fingerprintis a lowercase sha256 hex digest.modelmust resolve insidedocs/security/threat-models/and end in.mdx.- Every route matched by
src/routes/**/+server.tsorsrc/routes/**/+page.server.tsmust match at least one surface path.
How drift detection works
resolveSurfaceFiles()expands the surface globs with a dependency-free matcher (**spans segments,*stays inside one segment) and walks only the static prefix of each pattern. Results are deduplicated and sorted.computeFingerprint()hashes the sortedpath \0 sha256(content)entries, so reordering files never changes the digest and one changed byte does.evaluateManifest()compares the computed digest withfingerprintfor every surface and returns four failure lists:drifted— fingerprint mismatch, or the surface now matches zero files (a broken pattern is treated as drift, never as a pass).missingModels— the MDX file referenced by the surface does not exist.uncovered— a server route belongs to no surface.stale—reviewed_atis strictly older than 12 calendar months.
- Coverage is the union of route server files matched by any surface. The gate requires 100%; a surface that matches nothing cannot contribute coverage.
Update workflow
# 1. Verify the current state (also prints coverage and the surface table).
# This is part of `bun run security:assurance` and therefore `bun run check`.
bun run security:assurance --only=threat-model
# 2. After reviewing the affected MDX model, refresh fingerprints.
bun run security:threat-model:update --date YYYY-MM-DD
# Machine-readable output for either mode
bun run scripts/security/check-threat-model-drift.ts --check --json
--update refuses to run when a surface matches no files, so a renamed
directory cannot silently shrink a surface to nothing. It always exits 0 when it
writes successfully; run --check afterwards (CI does).
A route change is expected to fail the gate first:
- Add or edit a route under
src/routes/. --checkreports either the new route asuncoveredor the owning surface asdrifted.- Update the surface’s MDX page: threat, surface, existing control with a file reference, residual risk.
- Run
--update --date <today>and commit the model plus the manifest in the same change.
Review dates are not automatic: --update records the date you pass, which is
the claim that the model was re-read. Do not pass a date before the review.
Executable invariants
tests/unit/security/invariants.test.ts runs four rules over the real tree;
each failure names the offending file and line.
| Invariant | Scope | Failure message |
|---|---|---|
No new Date().toISOString() |
src/** except src/paraglide/** |
Use nowISODateString() from src/utils/date.ts |
No Math.random() |
src/utils/security/**, src/databases/auth/**, src/utils/media/** |
Use crypto.getRandomValues() / randomUUID() |
No @components/ui barrel import |
src/**, tests/** |
Import from the module path instead |
| Every server route is covered | src/routes/**/+server.ts, +page.server.ts |
Add the file to a surface in the manifest |
The date invariant is a ratchet: its allowlist records the exact number of
legacy occurrences per file, so a new occurrence in an already-listed file still
fails. src/utils/date.ts is the only sanctioned direct implementation; delete
allowlist entries as files migrate. The full allowlist and its rationale live in
the test file.
CI
.github/workflows/security-governance.yml runs the drift gate and the
invariant suite on every pull request that touches src/**,
scripts/security/**, docs/security/** or the workflow itself, plus a weekly
scheduled run so a stale review cannot hide in an untouched subtree.