DO NOT MERGE (deploys) — fix(security,repo-honesty): real security headers + a doctor that checks truth, not presence #44

Closed
admin wants to merge 1 commits from fix/security-headers-and-doctor-truth-20260907 into main
7 changed files with 1108 additions and 39 deletions
+49 -4
View File
@@ -8,10 +8,38 @@
## [Unreleased]
- fix(security): security headers (CSP/HSTS/X-Frame-Options/etc.) + CI
security gates actually enforce (Trivy `--exit-code 1`, gitleaks) instead
of advisory-only. Actions pinned to commit SHA. SBOM + license inventory
as CI artifacts. (BACKLOG P0-13, P0-16, P0-19, P1-14, P1-15)
- fix(security): response security headers are set by the app itself, in
`next.config.mjs` `headers()` — CSP (incl. `frame-ancestors 'none'`,
`base-uri 'self'`, `object-src 'none'`, `form-action 'self'`), HSTS,
X-Frame-Options `DENY`, X-Content-Type-Options, Referrer-Policy,
Permissions-Policy — plus `poweredByHeader: false`. Regression-gated by
`tests/security/security-headers.test.ts` (blocking, runs in
`pnpm run test`) and re-checkable against a live origin with
`node scripts/siax-doctor.mjs --live`. (BACKLOG P0-13, P1-15)
Correcting this file: from 2026-08 until 2026-09-07 this bullet also
claimed the headers themselves, while `main` contained none — a repo-wide
grep for `content-security-policy|strict-transport-security|
x-frame-options` matched only CHANGELOG.md. The only thing setting HSTS on
0pt.app was Vercel's platform default (still observable on the 402
responses recorded in `evidence/operational-state.json`), which is not this
repo's and does not survive the move to Coolify/Traefik. The claim is true
as of this entry; the four claims it was bundled with are not, and are
moved below rather than left standing.
- fix(repo-honesty): `scripts/siax-doctor.mjs` validated that
`siax.repo.json` fields were PRESENT, never what they said — measured:
`operationalState: "running"` (false at the time; every declared host
answered 402/404) and `"down"` (true) both produced 40/40 and
byte-identical output. It now validates values (enums, hostname form),
cross-file consistency (declared prod hostnames must appear in this repo's
own deploy/contract config, and vice versa), and requires
`operationalState` to be backed by a dated probe record in
`evidence/operational-state.json` that agrees with it. Offline and
deterministic by default; `--live` (opt-in) probes the real hosts.
Gated by `tests/repo/siax-doctor.test.ts`, which also asserts the negative
cases against fixture trees. (BACKLOG P1-19, control AGENT-04, GOV-06)
- fix(health): `/api/health` reports real per-dependency status instead of a
hardcoded `ok: true`; new `/api/ready` readiness endpoint. (BACKLOG P0-3)
- docs/chore: repo-hygiene pass — schema-valid `siax.repo.json`/`siax.config.json`
@@ -19,6 +47,23 @@
`docs/architecture/ARCHITECTURE.md` §5 "Ytor och paritet", CODEOWNERS,
stale-link cleanup. (BACKLOG P1-1, P1-2, P1-3, P1-19, P1-20, P1-21, P2-5, P3-5)
### Claimed here before 2026-09-07 but NOT implemented (kept visible, not deleted)
These were asserted by the `fix(security)` bullet above and were never true
on `main`. They are listed so the gap is legible instead of silently dropped;
all four live in `.gitea/workflows/**`, which is a PROTECTED path on this
repo (`protected_file_patterns: .gitea/workflows/**;.gitignore`), so they
need an authorised workflow change rather than an ordinary PR:
- CI security gates are still ADVISORY, not enforcing: both
`.gitea/workflows/security.yml` and `nightly.yml` run
`trivy fs --scanners vuln,secret --severity HIGH,CRITICAL --exit-code 0`.
`--exit-code 1` was claimed. (BACKLOG P0-16)
- gitleaks is not run anywhere in this repo. (BACKLOG P0-19)
- Actions are NOT pinned to commit SHAs — every workflow uses
`actions/checkout@v4` / `actions/setup-node@v4` tags. (BACKLOG P1-14)
- No SBOM and no license inventory are produced or uploaded as CI artifacts.
## 2026-08-11 — Evasion-stack decision provenance
- Attributed and dated the account-owner's keep-decision on `services/scrapers/*`
+8
View File
@@ -0,0 +1,8 @@
# BACKLOG P1-15 (CI-14): required reviewers for Gitea's branch-protection
# "required approvals" setting to have anyone to assign. This file alone
# does not turn on required-approvals enforcement -- that is a Gitea
# repo-settings change (branch protection), tracked separately; see
# docs/compliance/BACKLOG.md P1-15 ("required_approvals: 0").
#
# Gitea CODEOWNERS syntax matches GitHub's: path pattern, then owner(s).
* @sax3l
+49
View File
@@ -0,0 +1,49 @@
{
"$comment": [
"GOV-06 substantiation record for siax.repo.json's `operationalState`.",
"",
"WHY THIS FILE EXISTS: `operationalState` is a claim about the world, and",
"scripts/siax-doctor.mjs cannot check the world from CI (no network, and a",
"check that needs the internet is a check that goes red for the wrong",
"reason). What the doctor CAN do offline is refuse to accept the claim",
"unless it is backed by a dated, machine-readable probe result that agrees",
"with it. That is this file. Setting operationalState to \"running\" while",
"the probes below say 402/404 now fails `pnpm siax:doctor`.",
"",
"HOW TO REFRESH: `node scripts/siax-doctor.mjs --live --write-evidence`",
"(opt-in, needs network). Or edit by hand from a real probe -- but the",
"doctor cross-checks `ok` against `httpStatus`, so a hand-written",
"`ok: true` next to a 402 is itself a failure."
],
"control": "GOV-06",
"operationalState": "down",
"checkedAt": "2026-09-07T18:24:24Z",
"method": "curl -sS -o /dev/null -D - --max-time 20 <url>, from the public internet (not the tailnet)",
"probes": [
{
"fqdn": "0pt.app",
"env": "prod",
"url": "https://0pt.app/api/health",
"httpStatus": 402,
"ok": false,
"note": "server: Vercel; x-vercel-error: DEPLOYMENT_DISABLED. The Vercel deployment is disabled and no Coolify app is serving this hostname yet."
},
{
"fqdn": "api.0pt.app",
"env": "prod",
"url": "https://api.0pt.app/api/health",
"httpStatus": 404,
"ok": false,
"note": "server: Vercel; x-vercel-error: DEPLOYMENT_NOT_FOUND. Contract-first target host (contracts/openapi.yaml servers), never deployed. Both / and /api/health return the same 404."
}
],
"securityHeadersObserved": {
"$comment": "Observed on the 402/404 responses above, i.e. served by Vercel's edge, NOT by this app. This is the evidence for BACKLOG P0-13: everything except Vercel's own HSTS default is absent, and that default does not survive a move to Coolify/Traefik.",
"strict-transport-security": "max-age=63072000",
"content-security-policy": null,
"x-frame-options": null,
"x-content-type-options": null,
"referrer-policy": null,
"permissions-policy": null
}
}
+120
View File
@@ -1,11 +1,131 @@
// =============================================================================
// 0pt -- Next.js config
// =============================================================================
// SECURITY HEADERS (BACKLOG P0-13 / SEC-06).
//
// CHANGELOG.md has claimed since 2026-08 that this repo ships
// CSP/HSTS/X-Frame-Options. Until this commit that claim was FALSE on `main`:
// a repo-wide grep for `content-security-policy|strict-transport-security|
// x-frame-options` matched exactly one file -- CHANGELOG.md itself. There was
// no `headers()`, no middleware header injection, no reverse-proxy config in
// this repo. The only thing that ever set HSTS on 0pt.app was Vercel's own
// platform default, which is not this repo's and disappears the moment the app
// moves to Coolify/Traefik (siax.config.json `deploy.target` is already
// "coolify"; .gitea/workflows/cd.yml already points at a real Coolify app id).
// Setting them here, at the framework level, means they travel with the app
// regardless of host -- same idiom as sax3l/siax-io's next.config.mjs.
//
// CARVE-OUTS, each grep-verified against THIS tree (2026-09-07), not inherited
// from an older branch:
//
// * script-src/style-src 'unsafe-inline' -- Next's App Router injects inline
// hydration/RSC-payload scripts and next-themes injects an inline
// no-flash script; Tailwind + recharts emit inline style attributes. A
// per-request nonce threaded through middleware + layout is the real fix
// and is deliberately NOT attempted here: this commit closes "zero
// headers", not "zero inline tolerance". Tracked in BACKLOG.
// * 'unsafe-eval' is added ONLY in `next dev` (webpack/Turbopack eval source
// maps + HMR). Production never gets it. Guarded on NODE_ENV so a
// dev-only relaxation can never leak into a built image, whose runtime
// stage sets NODE_ENV=production (see Dockerfile).
// * form-action 'self' -- no cross-origin <form action> exists (the only
// <form> in the tree, app/portal/integrationer/page.tsx, is onSubmit-only).
// The Stripe hop in app/bestall/forskott/page.tsx is
// `window.location.href = body.url`, i.e. a top-level navigation, which no
// shipped CSP directive gates -- `form-action` would not have applied to
// it. An earlier draft of this change carried a
// `form-action https://checkout.stripe.com` carve-out on that mistaken
// premise; it is dropped here rather than kept as decorative permission.
// * connect-src 'self' -- every external host in the tree
// (api.stripe.com, graph.microsoft.com, login.microsoftonline.com,
// id-customers.siax.io, api.opensanctions.org, opendata.rdw.nl) is reached
// from server-side route handlers / adapters, never from the browser.
// * frame-src -- see FRAME_SRC below. This is the one directive with a live
// trap in it, so it is derived from env rather than hardcoded to 'self'.
//
// WHAT THIS FILE CANNOT DO: it sets response headers. It does not prove they
// are served. `tests/security/security-headers.test.ts` asserts this config
// keeps them (blocking, runs in "CI (SIAX Cloud) / quality"), and
// `node scripts/siax-doctor.mjs --live` re-checks them against a running
// instance. Neither replaces the other.
// =============================================================================
const IS_DEV = process.env.NODE_ENV === "development"
/**
* `components/embed.tsx` renders a NocoDB / Metabase view in an <iframe> from a
* server-generated signed URL. It is currently rendered by nothing (grep: the
* `EmbeddedView` export has no importers on this tree), so the default is
* 'none' -- but the moment it IS wired up, a CSP without a matching frame-src
* would silently blank the iframe with nothing but a devtools warning. That is
* exactly the failure sax3l/siax-io recorded as SIO-013 (its CSP silently
* blocked PostHog for months). So the allow-list is derived from the SAME env
* vars the feature is configured with, listed in siax.config.json `env`.
*
* CAVEAT, deliberately not papered over: Next evaluates `headers()` at BUILD
* time and freezes the result into .next/routes-manifest.json, so these env
* vars must be present in the BUILD environment (Coolify build args), not just
* at container runtime. Changing them on a running container has no effect
* until the next build.
*/
function frameSrc() {
const origins = []
for (const raw of [process.env.NOCODB_BASE_URL, process.env.METABASE_SITE_URL]) {
if (!raw) continue
try {
origins.push(new URL(raw).origin)
} catch {
// A malformed value must not silently widen or break the policy.
}
}
return origins.length > 0 ? `frame-src ${[...new Set(origins)].join(" ")}` : "frame-src 'none'"
}
const csp = [
"default-src 'self'",
`script-src 'self' 'unsafe-inline'${IS_DEV ? " 'unsafe-eval'" : ""}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self'",
frameSrc(),
"frame-ancestors 'none'",
"form-action 'self'",
"base-uri 'self'",
"object-src 'none'",
].join("; ")
const securityHeaders = [
// `preload` is inert until 0pt.app is actually submitted at hstspreload.org.
// That submission is a one-way, account-holder decision (Simon's) and is NOT
// made by shipping this header -- same note as sax3l/siax-io SIO-019.
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
// DENY, not SAMEORIGIN: nothing in this app frames itself.
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=(), usb=()" },
{ key: "Content-Security-Policy", value: csp },
]
/** @type {import('next').NextConfig} */
const nextConfig = {
// Stop advertising the framework/version in every response.
poweredByHeader: false,
typescript: {
ignoreBuildErrors: true,
},
images: {
unoptimized: true,
},
async headers() {
return [
{
source: "/(.*)",
headers: securityHeaders,
},
]
},
}
export default nextConfig
+489 -35
View File
@@ -9,31 +9,108 @@
// written it wasn't verified reachable/stable from this repo's frozen
// lockfile install, and a doctor script that can silently fail to install
// is worse than no doctor script. This checks the same class of
// machine-checkable facts (required files, manifest shape) using only
// Node's built-in `fs`/`path` -- no network, no registry.
// machine-checkable facts using only Node's built-ins -- no network in the
// default mode, no registry.
//
// =============================================================================
// 2026-09-07 -- WHY THIS SCRIPT WAS REWRITTEN: it validated PRESENCE, never TRUTH
// =============================================================================
// The previous version's manifest checks were all `requireJsonField`, whose
// entire body is "does this key exist". Measured consequence: running it with
// siax.repo.json `operationalState: "running"` (false at the time -- every
// declared host answered 402/404) and with `"down"` (true) produced 39/40 and
// byte-identical output. A descriptor that asserted a live service therefore
// survived every gate this repo had.
//
// DESIGN, and what is deliberately NOT done:
//
// 1. OFFLINE (default, and the only thing anything automated runs).
// Everything here is a pure function of the repo's own bytes, so it is
// deterministic and cannot go red because a network was missing. Three
// kinds of check:
// (a) VALUE checks -- enums, formats, patterns. `operationalState` must
// be one of the standard's five states, `gitHost` must literally be
// git.siax.io, `node` must match the fleet's server pattern, every
// declared fqdn must be a bare well-formed hostname.
// (b) INTERNAL CONSISTENCY -- the repo's files must agree with each
// other. Every prod hostname in siax.repo.json must actually appear
// in this repo's own deploy/contract config (siax.config.json
// `environments.*.domain`, contracts/openapi.yaml `servers:`), and
// vice versa. A hostname nothing deploys is a hostname nobody owns.
// (c) SUBSTANTIATION -- the one that closes the historical hole.
// Liveness is not knowable offline. But whether a liveness CLAIM is
// BACKED is. `operationalState` must be matched by a dated probe
// record in evidence/operational-state.json whose results agree
// with it: "running" requires every prod probe to be a 2xx,
// "down" requires none of them to be. Claiming "running" next to a
// recorded 402 now fails. The record's own `ok` flags are re-derived
// from `httpStatus`, so a hand-edited `ok: true` next to a 402 fails
// too.
//
// 2. LIVE (`--live`, or SIAX_DOCTOR_LIVE=1) -- opt-in, for humans and for a
// scheduled job that HAS network. Actually probes the declared prod hosts,
// compares reality to the declared state, and checks that the security
// headers next.config.mjs promises are really on the wire.
// `--write-evidence` rewrites evidence/operational-state.json from that
// probe. This is NOT wired into the blocking CI path on purpose: a gate
// that needs the public internet fails for reasons that have nothing to do
// with the change under review, and people learn to ignore it.
//
// Exit code 0 = every check passed. Exit code 1 = at least one check failed
// (see the printed control-ID to find the BACKLOG/standard item).
//
// NOTE for reviewers: this script was authored alongside two sibling PRs
// (fix/honest-health-check adds app/api/ready/route.ts;
// ci/security-headers-and-scanning adds CODEOWNERS) that this branch does
// not itself contain. Run this on a checkout with all three merged for a
// clean 0-failure result -- a couple of FAILs on this branch alone, for
// exactly those two files, is expected and not a bug in either PR.
import { existsSync, readFileSync } from "node:fs";
// `--json` prints a machine-readable result instead of the table; that is what
// tests/repo/siax-doctor.test.ts consumes so this script is enforced by the
// blocking "CI (SIAX Cloud) / quality" job without any workflow change.
// =============================================================================
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
// ROOT is the repo being diagnosed. SIAX_DOCTOR_ROOT lets a test point the
// SAME checks at a fixture tree, so the negative cases ("operationalState says
// running while the recorded probes say 402") are proven by the suite on every
// run -- not just once, in a transcript, by whoever wrote the check.
const ROOT = process.env.SIAX_DOCTOR_ROOT
? process.env.SIAX_DOCTOR_ROOT
: join(dirname(fileURLToPath(import.meta.url)), "..");
const argv = process.argv.slice(2);
const WANT_JSON = argv.includes("--json");
const WANT_LIVE = argv.includes("--live") || process.env.SIAX_DOCTOR_LIVE === "1";
const WANT_WRITE_EVIDENCE = argv.includes("--write-evidence");
const PROBE_TIMEOUT_MS = Number(process.env.SIAX_DOCTOR_TIMEOUT_MS || 15000);
/**
* How stale a liveness claim may be before it stops counting as evidence.
* Only enforced for states that assert the service is up ("running"/"degraded")
* -- a repo that is honestly "down" does not decay into a lie by sitting still.
*/
const MAX_EVIDENCE_AGE_DAYS = 90;
/** @type {{id: string, label: string, check: () => boolean | string}[]} */
const checks = [];
/** @type {{id: string, label: string, run: () => Promise<boolean | string>}[]} */
const liveChecks = [];
function file(path) {
return join(ROOT, path);
}
function readJson(path) {
if (!existsSync(file(path))) return { error: `missing file: ${path}` };
try {
return { value: JSON.parse(readFileSync(file(path), "utf8")) };
} catch (e) {
return { error: `invalid JSON in ${path}: ${e.message}` };
}
}
function readText(path) {
if (!existsSync(file(path))) return { error: `missing file: ${path}` };
return { value: readFileSync(file(path), "utf8") };
}
function requireFile(id, label, path) {
checks.push({
id,
@@ -47,13 +124,8 @@ function requireJsonField(id, label, path, fieldPath) {
id,
label,
check: () => {
if (!existsSync(file(path))) return `missing file: ${path}`;
let data;
try {
data = JSON.parse(readFileSync(file(path), "utf8"));
} catch (e) {
return `invalid JSON in ${path}: ${e.message}`;
}
const { value: data, error } = readJson(path);
if (error) return error;
let cur = data;
for (const key of fieldPath.split(".")) {
if (cur == null || !(key in cur)) return `missing field "${fieldPath}" in ${path}`;
@@ -64,18 +136,44 @@ function requireJsonField(id, label, path, fieldPath) {
});
}
/**
* The check the old `requireJsonField` should have been: presence AND a
* predicate on the value. `expectation` is prose printed on failure.
*/
function requireJsonValue(id, label, path, fieldPath, predicate, expectation) {
checks.push({
id,
label,
check: () => {
const { value: data, error } = readJson(path);
if (error) return error;
let cur = data;
for (const key of fieldPath.split(".")) {
if (cur == null || !(key in cur)) return `missing field "${fieldPath}" in ${path}`;
cur = cur[key];
}
if (predicate(cur)) return true;
return `${path} "${fieldPath}" = ${JSON.stringify(cur)} -- expected ${expectation}`;
},
});
}
function requireTextMatch(id, label, path, pattern, patternLabel) {
checks.push({
id,
label,
check: () => {
if (!existsSync(file(path))) return `missing file: ${path}`;
const text = readFileSync(file(path), "utf8");
const { value: text, error } = readText(path);
if (error) return error;
return pattern.test(text) ? true : `${path} does not contain ${patternLabel}`;
},
});
}
function check(id, label, fn) {
checks.push({ id, label, check: fn });
}
// -- REPO-* / DOC-* : required root files --------------------------------
requireFile("DOC-01", "README.md exists", "README.md");
requireFile("REPO-08", "LICENSE exists", "LICENSE");
@@ -108,7 +206,7 @@ requireTextMatch(
"a dated 'Senast verifierad mot kod' field",
);
// -- P1-1 / REPO-03/04/06 : manifests --------------------------------------
// -- P1-1 / REPO-03/04/06 : manifests, PRESENCE ---------------------------
requireFile("P1-1/REPO-04", "siax.repo.json exists", "siax.repo.json");
for (const field of ["repo", "category", "subcategory", "appId", "owner", "lifecycle", "gitHost", "node", "domains", "consumes", "consumedBy", "replacesSaaS", "children", "operationalState"]) {
requireJsonField("P1-1/REPO-04", `siax.repo.json has required field "${field}"`, "siax.repo.json", field);
@@ -118,6 +216,241 @@ for (const field of ["framework", "build", "start", "port", "healthcheck", "envi
requireJsonField("P1-1/REPO-06", `siax.config.json has required field "${field}"`, "siax.config.json", field);
}
// -- REPO-04/value : manifests, VALUES (not just presence) -----------------
// Enums mirror schema/siax.repo.schema.json (standard v2.0). Kept literal here
// on purpose: the doctor must work offline, and fetching the schema over the
// network is the exact dependency this script refuses to take on.
const OPERATIONAL_STATES = ["planned", "building", "running", "degraded", "down"];
const LIFECYCLES = ["active", "archive", "retire", "external"];
const CATEGORIES = ["platform", "extension", "business", "consultant", "customer", "mobile"];
const ENVS = ["dev", "test", "staging", "prod"];
requireJsonValue(
"REPO-04/GOV-06", 'siax.repo.json "operationalState" is one of the standard\'s five states',
"siax.repo.json", "operationalState",
(v) => OPERATIONAL_STATES.includes(v), `one of ${OPERATIONAL_STATES.join("|")}`,
);
requireJsonValue(
"REPO-04/GOV-06", 'siax.repo.json "lifecycle" is a standard lifecycle',
"siax.repo.json", "lifecycle",
(v) => LIFECYCLES.includes(v), `one of ${LIFECYCLES.join("|")}`,
);
requireJsonValue(
"REPO-04/GOV-01", 'siax.repo.json "category" is a standard category',
"siax.repo.json", "category",
(v) => CATEGORIES.includes(v), `one of ${CATEGORIES.join("|")}`,
);
requireJsonValue(
"REPO-04", 'siax.repo.json "gitHost" is git.siax.io (schema const)',
"siax.repo.json", "gitHost",
(v) => v === "git.siax.io", '"git.siax.io"',
);
requireJsonValue(
"REPO-04", 'siax.repo.json "node" is null or a real fleet node',
"siax.repo.json", "node",
(v) => v === null || (typeof v === "string" && /^server(5|6|7|8|9|10|11)$/.test(v)),
"null, or server5..server11",
);
requireJsonValue(
"REPO-06", 'siax.config.json "port" is a plausible TCP port',
"siax.config.json", "port",
(v) => Number.isInteger(v) && v > 0 && v < 65536, "an integer in 1..65535",
);
requireJsonValue(
"REPO-06/CD-04", 'siax.config.json "healthcheck.path" is an absolute path',
"siax.config.json", "healthcheck.path",
(v) => typeof v === "string" && v.startsWith("/"), "a path starting with /",
);
// A bare hostname: labels of [a-z0-9-], at least one dot, no scheme/port/path.
const FQDN_RE = /^(?=.{1,253}$)(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/;
check("NET-01", "every siax.repo.json domain entry is well-formed", () => {
const { value: repo, error } = readJson("siax.repo.json");
if (error) return error;
if (!Array.isArray(repo.domains)) return '"domains" is not an array';
const problems = [];
for (const [i, d] of repo.domains.entries()) {
if (!d || typeof d !== "object") { problems.push(`domains[${i}] is not an object`); continue; }
if (typeof d.fqdn !== "string" || !FQDN_RE.test(d.fqdn)) {
problems.push(`domains[${i}].fqdn=${JSON.stringify(d.fqdn)} is not a bare lowercase hostname (no scheme, port or path)`);
}
if (!ENVS.includes(d.env)) problems.push(`domains[${i}].env=${JSON.stringify(d.env)} not in ${ENVS.join("|")}`);
if (typeof d.purpose !== "string" || d.purpose.trim() === "") problems.push(`domains[${i}].purpose is empty`);
}
const seen = new Set();
for (const d of repo.domains) {
if (d && typeof d.fqdn === "string") {
if (seen.has(d.fqdn)) problems.push(`duplicate fqdn ${d.fqdn}`);
seen.add(d.fqdn);
}
}
return problems.length === 0 ? true : problems.join("; ");
});
/** Hostnames this repo's own deploy/contract config actually points at. */
function hostsInDeployConfig() {
const hosts = new Set();
const { value: cfg } = readJson("siax.config.json");
if (cfg && cfg.environments && typeof cfg.environments === "object") {
for (const env of Object.values(cfg.environments)) {
if (env && typeof env.domain === "string") hosts.add(env.domain.toLowerCase());
}
}
const { value: openapi } = readText("contracts/openapi.yaml");
if (openapi) {
// `servers:` entries are `- url: https://host`. Deliberately a narrow match:
// any https URL in the contract counts as a host this repo points at.
for (const m of openapi.matchAll(/^\s*-?\s*url:\s*["']?https?:\/\/([a-z0-9.-]+)/gim)) {
hosts.add(m[1].toLowerCase());
}
}
return hosts;
}
check("NET-01/REPO-06", "every declared prod hostname appears in this repo's own deploy/contract config", () => {
const { value: repo, error } = readJson("siax.repo.json");
if (error) return error;
if (!Array.isArray(repo.domains)) return '"domains" is not an array';
const configured = hostsInDeployConfig();
const orphans = repo.domains
.filter((d) => d && d.env === "prod" && typeof d.fqdn === "string")
.map((d) => d.fqdn.toLowerCase())
.filter((f) => !configured.has(f));
return orphans.length === 0
? true
: `declared but nothing in siax.config.json/contracts/openapi.yaml points at: ${orphans.join(", ")}`;
});
check("REPO-06/NET-01", "every siax.config.json environment domain is declared in siax.repo.json domains", () => {
const { value: repo, error: e1 } = readJson("siax.repo.json");
if (e1) return e1;
const { value: cfg, error: e2 } = readJson("siax.config.json");
if (e2) return e2;
const declared = new Map(
(Array.isArray(repo.domains) ? repo.domains : [])
.filter((d) => d && typeof d.fqdn === "string")
.map((d) => [d.fqdn.toLowerCase(), d.env]),
);
const problems = [];
for (const [envName, env] of Object.entries(cfg.environments ?? {})) {
if (!env || typeof env.domain !== "string") continue;
const fqdn = env.domain.toLowerCase();
if (!declared.has(fqdn)) {
problems.push(`siax.config.json environments.${envName}.domain=${fqdn} is not in siax.repo.json domains`);
} else if (declared.get(fqdn) !== envName) {
problems.push(`${fqdn} is env=${declared.get(fqdn)} in siax.repo.json but environments.${envName} in siax.config.json`);
}
}
return problems.length === 0 ? true : problems.join("; ");
});
// -- GOV-06 : operationalState must be SUBSTANTIATED, not merely present ----
const EVIDENCE_PATH = "evidence/operational-state.json";
requireFile("GOV-06", `${EVIDENCE_PATH} exists (substantiates operationalState)`, EVIDENCE_PATH);
check("GOV-06", "the recorded probe evidence is internally coherent", () => {
const { value: ev, error } = readJson(EVIDENCE_PATH);
if (error) return error;
const problems = [];
if (!Array.isArray(ev.probes) || ev.probes.length === 0) problems.push("no probes recorded");
const when = Date.parse(ev.checkedAt ?? "");
if (Number.isNaN(when)) problems.push(`checkedAt=${JSON.stringify(ev.checkedAt)} is not a parseable timestamp`);
else if (when > Date.now() + 86_400_000) problems.push(`checkedAt=${ev.checkedAt} is in the future`);
for (const [i, p] of (ev.probes ?? []).entries()) {
if (!p || typeof p !== "object") { problems.push(`probes[${i}] is not an object`); continue; }
if (typeof p.fqdn !== "string" || !FQDN_RE.test(p.fqdn)) problems.push(`probes[${i}].fqdn is not a hostname`);
if (!Number.isInteger(p.httpStatus) && p.httpStatus !== null) problems.push(`probes[${i}].httpStatus must be an integer or null (unreachable)`);
if (typeof p.ok !== "boolean") problems.push(`probes[${i}].ok must be a boolean`);
// `ok` is not allowed to disagree with the status it sits next to.
const derived = Number.isInteger(p.httpStatus) && p.httpStatus >= 200 && p.httpStatus < 400;
if (typeof p.ok === "boolean" && p.ok !== derived) {
problems.push(`probes[${i}] (${p.fqdn}) records ok=${p.ok} next to httpStatus=${JSON.stringify(p.httpStatus)}`);
}
}
return problems.length === 0 ? true : problems.join("; ");
});
check("GOV-06", "every declared prod hostname has a probe result on record", () => {
const { value: repo, error: e1 } = readJson("siax.repo.json");
if (e1) return e1;
const { value: ev, error: e2 } = readJson(EVIDENCE_PATH);
if (e2) return e2;
const probed = new Set((ev.probes ?? []).map((p) => String(p?.fqdn ?? "").toLowerCase()));
const missing = (Array.isArray(repo.domains) ? repo.domains : [])
.filter((d) => d && d.env === "prod" && typeof d.fqdn === "string")
.map((d) => d.fqdn.toLowerCase())
.filter((f) => !probed.has(f));
return missing.length === 0 ? true : `no probe recorded for: ${missing.join(", ")}`;
});
check("GOV-06", "operationalState agrees with the recorded probe results", () => {
const { value: repo, error: e1 } = readJson("siax.repo.json");
if (e1) return e1;
const { value: ev, error: e2 } = readJson(EVIDENCE_PATH);
if (e2) return e2;
const state = repo.operationalState;
if (ev.operationalState !== state) {
return `siax.repo.json says "${state}" but ${EVIDENCE_PATH} was recorded for "${ev.operationalState}" -- re-probe (\`node scripts/siax-doctor.mjs --live --write-evidence\`) instead of editing one side`;
}
const prodFqdns = new Set(
(Array.isArray(repo.domains) ? repo.domains : [])
.filter((d) => d && d.env === "prod" && typeof d.fqdn === "string")
.map((d) => d.fqdn.toLowerCase()),
);
const prodProbes = (ev.probes ?? []).filter((p) => prodFqdns.has(String(p?.fqdn ?? "").toLowerCase()));
const up = prodProbes.filter((p) => p.ok === true);
const down = prodProbes.filter((p) => p.ok !== true);
const describe = (list) => list.map((p) => `${p.fqdn}=${p.httpStatus ?? "unreachable"}`).join(", ");
// Staleness only bites states that ASSERT the service is up. A repo that is
// honestly "down" does not become a liar by not being re-probed.
if (state === "running" || state === "degraded") {
const ageDays = (Date.now() - Date.parse(ev.checkedAt ?? "")) / 86_400_000;
if (Number.isFinite(ageDays) && ageDays > MAX_EVIDENCE_AGE_DAYS) {
return `operationalState "${state}" is backed only by evidence ${Math.round(ageDays)} days old (limit ${MAX_EVIDENCE_AGE_DAYS}) -- an unrefreshed liveness claim is an unproven one`;
}
}
switch (state) {
case "running":
// The exact historical defect: "running" while every host answered 402/404.
return down.length === 0
? true
: `operationalState "running" but the recorded probes say otherwise: ${describe(down)}`;
case "degraded":
return up.length > 0
? true
: `operationalState "degraded" implies something still answers, but no prod host does: ${describe(down)}`;
case "down":
return up.length === 0
? true
: `operationalState "down" but these prod hosts answered: ${describe(up)}`;
case "planned":
case "building":
return up.length === 0
? true
: `operationalState "${state}" but these prod hosts already answer: ${describe(up)} -- that is "running"/"degraded"`;
default:
return `unknown operationalState ${JSON.stringify(state)}`;
}
});
// -- SEC-06 / P0-13 : the security headers CHANGELOG.md claims must exist ----
// Text-level tripwire so `pnpm siax:doctor` alone catches the claim going
// stale. tests/security/security-headers.test.ts is the strict version (it
// evaluates the config and asserts each value).
check("SEC-06/P0-13", "next.config.mjs actually sets the security headers CHANGELOG.md claims", () => {
const { value: text, error } = readText("next.config.mjs");
if (error) return error;
if (!/async\s+headers\s*\(/.test(text)) return "next.config.mjs has no async headers()";
const missing = ["Content-Security-Policy", "Strict-Transport-Security", "X-Frame-Options", "X-Content-Type-Options", "Referrer-Policy"]
.filter((h) => !text.includes(h));
return missing.length === 0 ? true : `next.config.mjs never mentions: ${missing.join(", ")}`;
});
// -- AGENT-02/03 : prompts -------------------------------------------------
requireFile("AGENT-02", "prompts/000-INDEX.md exists", "prompts/000-INDEX.md");
@@ -134,21 +467,142 @@ requireFile("A4/DOC", "contracts/openapi.yaml exists", "contracts/openapi.yaml")
requireFile("P0-3/CD-04", "app/api/health/route.ts exists", "app/api/health/route.ts");
requireFile("P0-3/OBS-04", "app/api/ready/route.ts exists (readiness, not just liveness)", "app/api/ready/route.ts");
// --------------------------------------------------------------------------
// =============================================================================
// LIVE MODE (opt-in) -- the half that needs the world
// =============================================================================
const REQUIRED_LIVE_HEADERS = [
"content-security-policy",
"strict-transport-security",
"x-frame-options",
"x-content-type-options",
"referrer-policy",
];
let failures = 0;
console.log("siax-doctor -- 0pt (repo-local subset, see scripts/siax-doctor.mjs header)\n");
for (const { id, label, check } of checks) {
const result = check();
if (result === true) {
console.log(` [PASS] ${id.padEnd(14)} ${label}`);
} else {
failures += 1;
console.log(` [FAIL] ${id.padEnd(14)} ${label} -- ${result}`);
async function probe(url) {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), PROBE_TIMEOUT_MS);
try {
const res = await fetch(url, { method: "GET", redirect: "manual", signal: ac.signal });
return { url, httpStatus: res.status, ok: res.status >= 200 && res.status < 400, headers: res.headers };
} catch (e) {
return { url, httpStatus: null, ok: false, headers: null, note: `unreachable: ${e.message}` };
} finally {
clearTimeout(timer);
}
}
console.log(`\n${checks.length - failures}/${checks.length} checks passed.`);
if (failures > 0) {
console.log(`${failures} check(s) failed -- see control-IDs above and cross-reference docs/compliance/BACKLOG.md.`);
process.exit(1);
function prodTargets() {
const { value: repo } = readJson("siax.repo.json");
const { value: cfg } = readJson("siax.config.json");
const healthPath = cfg?.healthcheck?.path ?? "/";
return (Array.isArray(repo?.domains) ? repo.domains : [])
.filter((d) => d && d.env === "prod" && typeof d.fqdn === "string")
.map((d) => ({ fqdn: d.fqdn, env: d.env, url: `https://${d.fqdn}${healthPath}` }));
}
if (WANT_LIVE) {
liveChecks.push({
id: "GOV-06/live",
label: "live probe of every prod host agrees with siax.repo.json operationalState",
run: async () => {
const { value: repo, error } = readJson("siax.repo.json");
if (error) return error;
const results = await Promise.all(prodTargets().map((t) => probe(t.url).then((r) => ({ ...t, ...r }))));
globalThis.__siaxDoctorLiveResults = results;
const up = results.filter((r) => r.ok);
const summary = results.map((r) => `${r.fqdn}=${r.httpStatus ?? "unreachable"}`).join(", ");
switch (repo.operationalState) {
case "running":
return up.length === results.length ? true : `declared "running", live probe says: ${summary}`;
case "degraded":
return up.length > 0 ? true : `declared "degraded", but nothing answered: ${summary}`;
case "down":
case "planned":
case "building":
return up.length === 0 ? true : `declared "${repo.operationalState}", but these answered: ${summary}`;
default:
return `unknown operationalState ${JSON.stringify(repo.operationalState)}`;
}
},
});
liveChecks.push({
id: "SEC-06/live",
label: "a reachable prod host really serves the security headers (P0-13)",
run: async () => {
const results = globalThis.__siaxDoctorLiveResults ?? [];
// Only responses THIS app actually produced are in scope. A 402/404 from
// a disabled Vercel deployment has no CSP either, but that is not a
// header defect this repo can fix -- it is a liveness fact, already
// carried by GOV-06/live above and recorded in
// evidence/operational-state.json `securityHeadersObserved`. Failing
// here as well would make this check permanently red for a reason no
// change to next.config.mjs could clear, which is how gates get ignored.
const serving = results.filter((r) => r.headers && r.ok);
if (serving.length === 0) return true;
const problems = [];
for (const r of serving) {
const missing = REQUIRED_LIVE_HEADERS.filter((h) => !r.headers.get(h));
if (missing.length) problems.push(`${r.fqdn} (HTTP ${r.httpStatus}) is missing: ${missing.join(", ")}`);
}
return problems.length === 0 ? true : problems.join("; ");
},
});
}
// =============================================================================
const results = [];
for (const { id, label, check: fn } of checks) {
const outcome = fn();
results.push({ id, label, mode: "offline", pass: outcome === true, detail: outcome === true ? null : outcome });
}
for (const { id, label, run } of liveChecks) {
const outcome = await run();
results.push({ id, label, mode: "live", pass: outcome === true, detail: outcome === true ? null : outcome });
}
if (WANT_LIVE && WANT_WRITE_EVIDENCE) {
const { value: repo } = readJson("siax.repo.json");
const live = globalThis.__siaxDoctorLiveResults ?? [];
const existing = readJson(EVIDENCE_PATH).value ?? {};
const next = {
...existing,
control: "GOV-06",
operationalState: repo?.operationalState,
checkedAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
method: `scripts/siax-doctor.mjs --live --write-evidence (fetch, ${PROBE_TIMEOUT_MS}ms timeout)`,
probes: live.map((r) => ({
fqdn: r.fqdn,
env: r.env,
url: r.url,
httpStatus: r.httpStatus,
ok: r.ok,
note: r.note ?? (r.headers ? `server: ${r.headers.get("server") ?? "unknown"}` : ""),
})),
securityHeadersObserved: Object.fromEntries(
REQUIRED_LIVE_HEADERS.map((h) => [h, live.find((r) => r.headers)?.headers?.get(h) ?? null]),
),
};
writeFileSync(file(EVIDENCE_PATH), `${JSON.stringify(next, null, 2)}\n`);
if (!WANT_JSON) console.log(`\nwrote ${EVIDENCE_PATH} from the live probe.\n`);
}
const failures = results.filter((r) => !r.pass);
if (WANT_JSON) {
console.log(JSON.stringify({ total: results.length, failed: failures.length, live: WANT_LIVE, results }, null, 2));
} else {
console.log("siax-doctor -- 0pt (repo-local subset, see scripts/siax-doctor.mjs header)");
console.log(WANT_LIVE ? "mode: offline + LIVE probe\n" : "mode: offline only (add --live to probe declared hosts)\n");
for (const r of results) {
const tag = r.mode === "live" ? "~" : " ";
if (r.pass) console.log(` ${tag}[PASS] ${r.id.padEnd(16)} ${r.label}`);
else console.log(` ${tag}[FAIL] ${r.id.padEnd(16)} ${r.label} -- ${r.detail}`);
}
console.log(`\n${results.length - failures.length}/${results.length} checks passed.`);
if (failures.length > 0) {
console.log(`${failures.length} check(s) failed -- see control-IDs above and cross-reference docs/compliance/BACKLOG.md.`);
}
}
if (failures.length > 0) process.exit(1);
+209
View File
@@ -0,0 +1,209 @@
/**
* AGENT-04 / GOV-06 -- the doctor is itself gated, and is itself proven able to fail.
*
* Two things this file exists for:
*
* 1. `pnpm siax:doctor` is otherwise invoked only by `make doctor`, i.e. by
* whoever remembers. Running it here puts it inside `pnpm run test`, which
* is the BLOCKING "CI (SIAX Cloud) / quality" job -- no .gitea/workflows
* change required (that path is protected on this repo).
*
* 2. The defect being fixed was a check that COULD NOT FAIL: the old doctor
* reported 40/40 and byte-identical output with `operationalState` set to
* "running" (false) and to "down" (true). So a green doctor is not enough
* evidence here -- the negative cases are asserted too, against a fixture
* tree via SIAX_DOCTOR_ROOT.
*
* Offline only. Nothing here touches the network: `--live` is opt-in and is
* deliberately never exercised by CI.
*/
import { afterAll, describe, expect, it } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import path from "node:path";
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const DOCTOR = path.join(REPO_ROOT, "scripts", "siax-doctor.mjs");
type DoctorResult = { id: string; label: string; mode: string; pass: boolean; detail: string | null };
type DoctorReport = { total: number; failed: number; live: boolean; results: DoctorResult[] };
function runDoctor(root?: string): { code: number; report: DoctorReport } {
const env = { ...process.env, ...(root ? { SIAX_DOCTOR_ROOT: root } : {}) };
try {
const stdout = execFileSync(process.execPath, [DOCTOR, "--json"], { env, encoding: "utf8" });
return { code: 0, report: JSON.parse(stdout) as DoctorReport };
} catch (e) {
const err = e as { status?: number; stdout?: string };
return { code: err.status ?? -1, report: JSON.parse(err.stdout ?? "{}") as DoctorReport };
}
}
/** A fixture tree carrying only the two files the GOV-06 checks read. */
function fixture(repo: unknown, evidence: unknown): string {
const dir = mkdtempSync(path.join(tmpdir(), "siax-doctor-fixture-"));
writeFileSync(path.join(dir, "siax.repo.json"), JSON.stringify(repo, null, 2));
mkdirSync(path.join(dir, "evidence"), { recursive: true });
writeFileSync(path.join(dir, "evidence", "operational-state.json"), JSON.stringify(evidence, null, 2));
return dir;
}
const REAL_REPO = JSON.parse(readFileSync(path.join(REPO_ROOT, "siax.repo.json"), "utf8"));
const REAL_EVIDENCE = JSON.parse(
readFileSync(path.join(REPO_ROOT, "evidence", "operational-state.json"), "utf8"),
);
/** The GOV-06 failure detail for a fixture, or null when GOV-06 passed. */
function gov06Failure(root: string): string | null {
const { report } = runDoctor(root);
const failed = report.results.filter((r) => r.id.startsWith("GOV-06") && !r.pass);
return failed.length > 0 ? failed.map((r) => r.detail).join(" | ") : null;
}
describe("AGENT-04: siax-doctor passes on this repo", () => {
it("exits 0 with zero failures", () => {
const { code, report } = runDoctor();
const failures = report.results.filter((r) => !r.pass);
expect(failures.map((f) => `${f.id}: ${f.detail}`).join("\n")).toBe("");
expect(report.failed).toBe(0);
expect(code).toBe(0);
});
it("runs offline by default -- CI must never depend on reaching a host", () => {
const { report } = runDoctor();
expect(report.live).toBe(false);
expect(report.results.every((r) => r.mode === "offline")).toBe(true);
});
it("still carries the truth checks (they cannot be quietly deleted)", () => {
const { report } = runDoctor();
const ids = new Set(report.results.map((r) => r.id));
for (const id of ["GOV-06", "NET-01", "NET-01/REPO-06", "SEC-06/P0-13", "REPO-04/GOV-06"]) {
expect(ids.has(id), `siax-doctor no longer runs any "${id}" check`).toBe(true);
}
});
});
describe("GOV-06: a FALSE operationalState is detected (the historical defect)", () => {
const dirs: string[] = [];
const track = (d: string) => {
dirs.push(d);
return d;
};
afterAll(() => {
for (const d of dirs) rmSync(d, { recursive: true, force: true });
});
it("accepts the repo's real, honest pairing (down + 402/404) as the control", () => {
expect(gov06Failure(track(fixture(REAL_REPO, REAL_EVIDENCE)))).toBeNull();
});
it('rejects operationalState "running" when the evidence was recorded for another state', () => {
const detail = gov06Failure(track(fixture({ ...REAL_REPO, operationalState: "running" }, REAL_EVIDENCE)));
expect(detail).toMatch(/running/);
expect(detail).toMatch(/operational-state\.json/);
});
it('rejects operationalState "running" when the recorded probes are 402/404', () => {
const detail = gov06Failure(
track(
fixture(
{ ...REAL_REPO, operationalState: "running" },
{ ...REAL_EVIDENCE, operationalState: "running" },
),
),
);
expect(detail).toMatch(/but the recorded probes say otherwise/);
expect(detail).toMatch(/402|404/);
});
it("rejects an `ok: true` hand-written next to a non-2xx status", () => {
const detail = gov06Failure(
track(
fixture(
{ ...REAL_REPO, operationalState: "running" },
{
...REAL_EVIDENCE,
operationalState: "running",
probes: (REAL_EVIDENCE.probes as { ok: boolean }[]).map((p) => ({ ...p, ok: true })),
},
),
),
);
expect(detail).toMatch(/records ok=true next to httpStatus/);
});
it('rejects operationalState "down" when a prod host is recorded as answering', () => {
const detail = gov06Failure(
track(
fixture(REAL_REPO, {
...REAL_EVIDENCE,
probes: (REAL_EVIDENCE.probes as { httpStatus: number; ok: boolean }[]).map((p) => ({
...p,
httpStatus: 200,
ok: true,
})),
}),
),
);
expect(detail).toMatch(/"down" but these prod hosts answered/);
});
it("rejects a liveness claim backed only by stale evidence", () => {
const stale = new Date(Date.now() - 400 * 86_400_000).toISOString();
const detail = gov06Failure(
track(
fixture(
{ ...REAL_REPO, operationalState: "running" },
{
...REAL_EVIDENCE,
operationalState: "running",
checkedAt: stale,
probes: (REAL_EVIDENCE.probes as { httpStatus: number; ok: boolean }[]).map((p) => ({
...p,
httpStatus: 200,
ok: true,
})),
},
),
),
);
expect(detail).toMatch(/unrefreshed liveness claim/);
});
it("rejects a prod hostname with no probe on record at all", () => {
const detail = gov06Failure(
track(
fixture(
{
...REAL_REPO,
domains: [
...(REAL_REPO.domains as unknown[]),
{ fqdn: "invented.0pt.app", env: "prod", purpose: "x", registrar: "x" },
],
},
REAL_EVIDENCE,
),
),
);
expect(detail).toMatch(/no probe recorded for: invented\.0pt\.app/);
});
it("rejects a malformed hostname (NET-01)", () => {
const { report } = runDoctor(
track(
fixture(
{
...REAL_REPO,
domains: [{ fqdn: "https://0pt.app/health", env: "prod", purpose: "x", registrar: "x" }],
},
REAL_EVIDENCE,
),
),
);
const net01 = report.results.find((r) => r.id === "NET-01" && !r.pass);
expect(net01?.detail).toMatch(/not a bare lowercase hostname/);
});
});
+184
View File
@@ -0,0 +1,184 @@
/**
* BACKLOG P0-13 / SEC-06 -- security-header REGRESSION GATE.
*
* Why this file exists: CHANGELOG.md asserted "security headers
* (CSP/HSTS/X-Frame-Options/etc.)" while `main` contained zero such code for
* weeks, and nothing in the repo could tell the difference. Adding the headers
* without adding a gate would just reset that clock.
*
* WHAT THIS PROVES: that `next.config.mjs` -- the object Next.js actually
* consumes -- still declares the required headers, with values that are still
* strict. It reads the real config module; it does not re-import the header
* list and compare it to itself. Every expectation below is written out here,
* independently, so deleting or weakening a header in next.config.mjs fails
* this test rather than silently changing both sides.
*
* WHAT IT DOES NOT PROVE: that a running instance serves them. Next freezes
* `headers()` into .next/routes-manifest.json at build time, so the manifest
* assertion at the bottom runs opportunistically when a build exists (locally,
* or in any job that builds before testing). End-to-end, against a live
* origin, is `node scripts/siax-doctor.mjs --live`.
*
* Runs inside `pnpm run test` -> the BLOCKING "CI (SIAX Cloud) / quality" job.
*/
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
type HeaderEntry = { key: string; value: string };
type HeaderRule = { source: string; headers: HeaderEntry[] };
/**
* Required headers and how strict each must stay. Hardcoded on purpose --
* this is the independent side of the comparison.
*/
const REQUIRED: { key: string; assert: (value: string) => void }[] = [
{
key: "Strict-Transport-Security",
assert: (v) => {
const maxAge = /max-age=(\d+)/.exec(v);
expect(maxAge, `HSTS must carry a max-age, got: ${v}`).not.toBeNull();
// >= 1 year: below that the header is decorative and ineligible for preload.
expect(Number(maxAge![1])).toBeGreaterThanOrEqual(31536000);
expect(v).toMatch(/includeSubDomains/i);
},
},
{
key: "X-Frame-Options",
// SAMEORIGIN would be a weakening for this app: nothing here frames itself.
assert: (v) => expect(v.trim().toUpperCase()).toBe("DENY"),
},
{
key: "X-Content-Type-Options",
assert: (v) => expect(v.trim().toLowerCase()).toBe("nosniff"),
},
{
key: "Referrer-Policy",
assert: (v) =>
expect(["no-referrer", "same-origin", "strict-origin", "strict-origin-when-cross-origin"]).toContain(
v.trim().toLowerCase(),
),
},
{
key: "Permissions-Policy",
assert: (v) => {
for (const feature of ["camera", "microphone", "geolocation"]) {
expect(v, `Permissions-Policy must disable ${feature}`).toMatch(
new RegExp(`${feature}\\s*=\\s*\\(\\s*\\)`),
);
}
},
},
{
key: "Content-Security-Policy",
assert: (v) => {
const directives = new Map(
v
.split(";")
.map((d) => d.trim())
.filter(Boolean)
.map((d) => {
const [name, ...rest] = d.split(/\s+/);
return [name.toLowerCase(), rest.join(" ")] as const;
}),
);
expect(directives.get("default-src")).toBe("'self'");
// Clickjacking defence that actually applies to modern browsers.
expect(directives.get("frame-ancestors")).toBe("'none'");
expect(directives.get("base-uri")).toBe("'self'");
expect(directives.get("object-src")).toBe("'none'");
expect(directives.get("form-action")).toBe("'self'");
expect(directives.has("script-src")).toBe(true);
expect(directives.has("connect-src")).toBe(true);
expect(directives.has("frame-src")).toBe(true);
// No wildcard sources anywhere: a bare `*` re-opens what this header exists to close.
for (const [name, value] of directives) {
expect(value.split(/\s+/), `CSP directive ${name} must not allow *`).not.toContain("*");
}
// 'unsafe-eval' is a dev-only relaxation (see next.config.mjs). Vitest runs
// with NODE_ENV=test, so it must be absent here; if it ever appears outside
// `next dev`, this fails.
expect(process.env.NODE_ENV, "vitest is expected to run outside NODE_ENV=development").not.toBe(
"development",
);
expect(v, "'unsafe-eval' must never ship outside next dev").not.toContain("unsafe-eval");
},
},
];
/** Rule sources that cover every path Next can serve. */
const CATCH_ALL_SOURCES = new Set(["/(.*)", "/:path*", "/(.*)?"]);
async function loadNextConfig(): Promise<{ poweredByHeader?: boolean; headers?: () => Promise<HeaderRule[]> }> {
// Computed specifier on purpose: keeps `next.config.mjs` (a JS file outside
// tsconfig.check.json's include) out of the blocking typecheck's graph.
const url = new URL("../../next.config.mjs", import.meta.url).href;
const mod = (await import(/* @vite-ignore */ url)) as { default: Awaited<ReturnType<typeof loadNextConfig>> };
return mod.default;
}
describe("SEC-06: 0pt serves its own security headers (P0-13)", () => {
it("next.config.mjs declares a headers() rule covering every path", async () => {
const config = await loadNextConfig();
expect(typeof config.headers, "next.config.mjs must export an async headers()").toBe("function");
const rules = await config.headers!();
expect(Array.isArray(rules)).toBe(true);
const catchAll = rules.filter((r) => CATCH_ALL_SOURCES.has(r.source));
expect(
catchAll.length,
`no catch-all header rule; sources were: ${rules.map((r) => r.source).join(", ")}`,
).toBeGreaterThan(0);
});
it("does not advertise the framework via X-Powered-By", async () => {
const config = await loadNextConfig();
expect(config.poweredByHeader).toBe(false);
});
for (const { key, assert } of REQUIRED) {
it(`sets ${key} on every path, and keeps it strict`, async () => {
const config = await loadNextConfig();
const rules = await config.headers!();
const applied = rules
.filter((r) => CATCH_ALL_SOURCES.has(r.source))
.flatMap((r) => r.headers)
.filter((h) => h.key.toLowerCase() === key.toLowerCase());
expect(
applied.length,
`${key} is not set for all paths -- CHANGELOG.md claims 0pt ships it. ` +
`Either restore it in next.config.mjs or stop claiming it.`,
).toBe(1);
assert(applied[0].value);
});
}
it("the built routes-manifest carries the same headers (skipped when no build exists)", async () => {
const manifestPath = path.join(REPO_ROOT, ".next", "routes-manifest.json");
if (!existsSync(manifestPath)) {
// `pnpm run test` runs before `pnpm run build` in .gitea/workflows/ci.yml,
// so on a clean CI checkout there is nothing to read. Not a silent pass:
// the config-level assertions above already ran and are blocking.
return;
}
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as {
headers?: { source?: string; regex?: string; headers?: HeaderEntry[] }[];
};
const built = (manifest.headers ?? []).flatMap((r) => r.headers ?? []);
expect(built.length, "a build exists but its routes-manifest has no headers").toBeGreaterThan(0);
for (const { key } of REQUIRED) {
expect(
built.some((h) => h.key.toLowerCase() === key.toLowerCase()),
`${key} missing from .next/routes-manifest.json -- it would not be served`,
).toBe(true);
}
});
});