DO NOT MERGE (deploys) — fix(security,repo-honesty): real security headers + a doctor that checks truth, not presence #44
+49
-4
@@ -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/*`
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user