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
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:
co-authored by
Claude Opus 5
parent
d41f30535b
commit
8ebc90ffa3
+489
-35
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user