fix(security,repo-honesty): real security headers + a doctor that checks truth, not presence
masterplan-lock / check (pull_request) Successful in 4s
masterplan-lock / check (push) Successful in 7s
CI (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / security (push) Successful in 18s
CI (SIAX Cloud) / quality (push) Successful in 39s
CI (SIAX Cloud) / integration (pull_request) Successful in 41s
CI (SIAX Cloud) / integration (push) Successful in 44s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 1m37s
Security Scan (SIAX Cloud) / security (push) Successful in 1m35s
CI (SIAX Cloud) / quality (pull_request) Successful in 3m4s

Two defects of the same kind: the repo asserted things about itself that were
not true, and nothing in it could tell.

A. CHANGELOG.md claimed CSP/HSTS/X-Frame-Options since 2026-08. `main`
   contained none of it -- a repo-wide grep for
   `content-security-policy|strict-transport-security|x-frame-options` matched
   exactly one file, CHANGELOG.md itself. The only header on 0pt.app was
   Vercel's own HSTS default, which does not survive the move to
   Coolify/Traefik that siax.config.json and .gitea/workflows/cd.yml are
   already pointed at.

   - next.config.mjs: `headers()` on `/(.*)` with CSP (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. Same idiom as
     sax3l/siax-io's next.config.mjs.
   - Carve-outs re-derived against THIS tree, not inherited: the stale
     `ci/security-headers-and-scanning` branch (8d2ff73, 142 commits behind)
     carried a `form-action https://checkout.stripe.com` allowance justified
     by a redirect that is actually `window.location.href`, which form-action
     does not govern -- dropped rather than kept as decorative permission.
     frame-src is derived from NOCODB_BASE_URL/METABASE_SITE_URL, the same env
     the components/embed.tsx iframe is configured from, so wiring that
     component up cannot be silently blanked by this header (the failure
     sax3l/siax-io recorded as SIO-013). 'unsafe-eval' is dev-only.
   - tests/security/security-headers.test.ts: blocking regression gate inside
     `pnpm run test`, i.e. the required "CI (SIAX Cloud) / quality" context.
     Asserts against expectations written out in the test, not imported from
     the config. Proven to fail: removing X-Frame-Options -> `pnpm run test`
     exits 1; weakening frame-ancestors to 'self' -> exits 1.

B. scripts/siax-doctor.mjs validated that siax.repo.json fields were PRESENT,
   never what they said. Measured on this tree: operationalState "running"
   (false) and "down" (true) both gave 40/40, exit 0, byte-identical output.

   - Value checks (enums, git.siax.io const, fleet node pattern, bare-hostname
     form), cross-file consistency (declared prod hostnames must appear in
     this repo's own siax.config.json / contracts/openapi.yaml, and vice
     versa), and GOV-06 substantiation: operationalState must be backed by a
     dated probe record in evidence/operational-state.json that agrees with
     it. Liveness is not knowable offline; whether a liveness CLAIM is backed
     is. Offline, deterministic, no network -- CI can never go red for want of
     an internet connection.
   - `--live` (opt-in) probes the declared hosts for real and checks the
     headers on the wire; `--write-evidence` refreshes the record. Not wired
     into CI on purpose.
   - tests/repo/siax-doctor.test.ts runs the doctor inside `pnpm run test` and
     asserts the negative cases against fixture trees (SIAX_DOCTOR_ROOT), so
     the "cannot fail" defect cannot come back.

C. CHANGELOG.md is now true. The header claim stands; the four claims it was
   bundled with (Trivy --exit-code 1, gitleaks, SHA-pinned actions, SBOM +
   license artifacts) are still false on main and are listed as not
   implemented instead of left asserted. All four live in .gitea/workflows/**,
   a protected path on this repo, so they need an authorised workflow change.

Nothing under .gitea/workflows/** or .gitignore is touched.

Verified: pnpm run typecheck exit 0; pnpm run test exit 0 (74 files / 965
tests passed, up from 72/945); pnpm run build exit 0 and the built
.next/routes-manifest.json carries all six headers; node scripts/siax-doctor.mjs
55/55 exit 0; --live 57/57 exit 0. eslint on the new files exit 0 (repo lint is
advisory and its 17 pre-existing problems are unchanged).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Simon Axelsson
2026-09-15 02:43:32 +02:00
committed by admin
co-authored by Claude Opus 5
parent d41f30535b
commit 8ebc90ffa3
7 changed files with 1102 additions and 40 deletions
+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);