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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Simon Axelsson
2026-09-15 02:43:32 +02:00
committed by admin
co-authored by Claude Opus 5
parent d41f30535b
commit 8ebc90ffa3
7 changed files with 1102 additions and 40 deletions
+209
View File
@@ -0,0 +1,209 @@
/**
* AGENT-04 / GOV-06 -- the doctor is itself gated, and is itself proven able to fail.
*
* Two things this file exists for:
*
* 1. `pnpm siax:doctor` is otherwise invoked only by `make doctor`, i.e. by
* whoever remembers. Running it here puts it inside `pnpm run test`, which
* is the BLOCKING "CI (SIAX Cloud) / quality" job -- no .gitea/workflows
* change required (that path is protected on this repo).
*
* 2. The defect being fixed was a check that COULD NOT FAIL: the old doctor
* reported 40/40 and byte-identical output with `operationalState` set to
* "running" (false) and to "down" (true). So a green doctor is not enough
* evidence here -- the negative cases are asserted too, against a fixture
* tree via SIAX_DOCTOR_ROOT.
*
* Offline only. Nothing here touches the network: `--live` is opt-in and is
* deliberately never exercised by CI.
*/
import { afterAll, describe, expect, it } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import path from "node:path";
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const DOCTOR = path.join(REPO_ROOT, "scripts", "siax-doctor.mjs");
type DoctorResult = { id: string; label: string; mode: string; pass: boolean; detail: string | null };
type DoctorReport = { total: number; failed: number; live: boolean; results: DoctorResult[] };
function runDoctor(root?: string): { code: number; report: DoctorReport } {
const env = { ...process.env, ...(root ? { SIAX_DOCTOR_ROOT: root } : {}) };
try {
const stdout = execFileSync(process.execPath, [DOCTOR, "--json"], { env, encoding: "utf8" });
return { code: 0, report: JSON.parse(stdout) as DoctorReport };
} catch (e) {
const err = e as { status?: number; stdout?: string };
return { code: err.status ?? -1, report: JSON.parse(err.stdout ?? "{}") as DoctorReport };
}
}
/** A fixture tree carrying only the two files the GOV-06 checks read. */
function fixture(repo: unknown, evidence: unknown): string {
const dir = mkdtempSync(path.join(tmpdir(), "siax-doctor-fixture-"));
writeFileSync(path.join(dir, "siax.repo.json"), JSON.stringify(repo, null, 2));
mkdirSync(path.join(dir, "evidence"), { recursive: true });
writeFileSync(path.join(dir, "evidence", "operational-state.json"), JSON.stringify(evidence, null, 2));
return dir;
}
const REAL_REPO = JSON.parse(readFileSync(path.join(REPO_ROOT, "siax.repo.json"), "utf8"));
const REAL_EVIDENCE = JSON.parse(
readFileSync(path.join(REPO_ROOT, "evidence", "operational-state.json"), "utf8"),
);
/** The GOV-06 failure detail for a fixture, or null when GOV-06 passed. */
function gov06Failure(root: string): string | null {
const { report } = runDoctor(root);
const failed = report.results.filter((r) => r.id.startsWith("GOV-06") && !r.pass);
return failed.length > 0 ? failed.map((r) => r.detail).join(" | ") : null;
}
describe("AGENT-04: siax-doctor passes on this repo", () => {
it("exits 0 with zero failures", () => {
const { code, report } = runDoctor();
const failures = report.results.filter((r) => !r.pass);
expect(failures.map((f) => `${f.id}: ${f.detail}`).join("\n")).toBe("");
expect(report.failed).toBe(0);
expect(code).toBe(0);
});
it("runs offline by default -- CI must never depend on reaching a host", () => {
const { report } = runDoctor();
expect(report.live).toBe(false);
expect(report.results.every((r) => r.mode === "offline")).toBe(true);
});
it("still carries the truth checks (they cannot be quietly deleted)", () => {
const { report } = runDoctor();
const ids = new Set(report.results.map((r) => r.id));
for (const id of ["GOV-06", "NET-01", "NET-01/REPO-06", "SEC-06/P0-13", "REPO-04/GOV-06"]) {
expect(ids.has(id), `siax-doctor no longer runs any "${id}" check`).toBe(true);
}
});
});
describe("GOV-06: a FALSE operationalState is detected (the historical defect)", () => {
const dirs: string[] = [];
const track = (d: string) => {
dirs.push(d);
return d;
};
afterAll(() => {
for (const d of dirs) rmSync(d, { recursive: true, force: true });
});
it("accepts the repo's real, honest pairing (down + 402/404) as the control", () => {
expect(gov06Failure(track(fixture(REAL_REPO, REAL_EVIDENCE)))).toBeNull();
});
it('rejects operationalState "running" when the evidence was recorded for another state', () => {
const detail = gov06Failure(track(fixture({ ...REAL_REPO, operationalState: "running" }, REAL_EVIDENCE)));
expect(detail).toMatch(/running/);
expect(detail).toMatch(/operational-state\.json/);
});
it('rejects operationalState "running" when the recorded probes are 402/404', () => {
const detail = gov06Failure(
track(
fixture(
{ ...REAL_REPO, operationalState: "running" },
{ ...REAL_EVIDENCE, operationalState: "running" },
),
),
);
expect(detail).toMatch(/but the recorded probes say otherwise/);
expect(detail).toMatch(/402|404/);
});
it("rejects an `ok: true` hand-written next to a non-2xx status", () => {
const detail = gov06Failure(
track(
fixture(
{ ...REAL_REPO, operationalState: "running" },
{
...REAL_EVIDENCE,
operationalState: "running",
probes: (REAL_EVIDENCE.probes as { ok: boolean }[]).map((p) => ({ ...p, ok: true })),
},
),
),
);
expect(detail).toMatch(/records ok=true next to httpStatus/);
});
it('rejects operationalState "down" when a prod host is recorded as answering', () => {
const detail = gov06Failure(
track(
fixture(REAL_REPO, {
...REAL_EVIDENCE,
probes: (REAL_EVIDENCE.probes as { httpStatus: number; ok: boolean }[]).map((p) => ({
...p,
httpStatus: 200,
ok: true,
})),
}),
),
);
expect(detail).toMatch(/"down" but these prod hosts answered/);
});
it("rejects a liveness claim backed only by stale evidence", () => {
const stale = new Date(Date.now() - 400 * 86_400_000).toISOString();
const detail = gov06Failure(
track(
fixture(
{ ...REAL_REPO, operationalState: "running" },
{
...REAL_EVIDENCE,
operationalState: "running",
checkedAt: stale,
probes: (REAL_EVIDENCE.probes as { httpStatus: number; ok: boolean }[]).map((p) => ({
...p,
httpStatus: 200,
ok: true,
})),
},
),
),
);
expect(detail).toMatch(/unrefreshed liveness claim/);
});
it("rejects a prod hostname with no probe on record at all", () => {
const detail = gov06Failure(
track(
fixture(
{
...REAL_REPO,
domains: [
...(REAL_REPO.domains as unknown[]),
{ fqdn: "invented.0pt.app", env: "prod", purpose: "x", registrar: "x" },
],
},
REAL_EVIDENCE,
),
),
);
expect(detail).toMatch(/no probe recorded for: invented\.0pt\.app/);
});
it("rejects a malformed hostname (NET-01)", () => {
const { report } = runDoctor(
track(
fixture(
{
...REAL_REPO,
domains: [{ fqdn: "https://0pt.app/health", env: "prod", purpose: "x", registrar: "x" }],
},
REAL_EVIDENCE,
),
),
);
const net01 = report.results.find((r) => r.id === "NET-01" && !r.pass);
expect(net01?.detail).toMatch(/not a bare lowercase hostname/);
});
});
+184
View File
@@ -0,0 +1,184 @@
/**
* BACKLOG P0-13 / SEC-06 -- security-header REGRESSION GATE.
*
* Why this file exists: CHANGELOG.md asserted "security headers
* (CSP/HSTS/X-Frame-Options/etc.)" while `main` contained zero such code for
* weeks, and nothing in the repo could tell the difference. Adding the headers
* without adding a gate would just reset that clock.
*
* WHAT THIS PROVES: that `next.config.mjs` -- the object Next.js actually
* consumes -- still declares the required headers, with values that are still
* strict. It reads the real config module; it does not re-import the header
* list and compare it to itself. Every expectation below is written out here,
* independently, so deleting or weakening a header in next.config.mjs fails
* this test rather than silently changing both sides.
*
* WHAT IT DOES NOT PROVE: that a running instance serves them. Next freezes
* `headers()` into .next/routes-manifest.json at build time, so the manifest
* assertion at the bottom runs opportunistically when a build exists (locally,
* or in any job that builds before testing). End-to-end, against a live
* origin, is `node scripts/siax-doctor.mjs --live`.
*
* Runs inside `pnpm run test` -> the BLOCKING "CI (SIAX Cloud) / quality" job.
*/
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
type HeaderEntry = { key: string; value: string };
type HeaderRule = { source: string; headers: HeaderEntry[] };
/**
* Required headers and how strict each must stay. Hardcoded on purpose --
* this is the independent side of the comparison.
*/
const REQUIRED: { key: string; assert: (value: string) => void }[] = [
{
key: "Strict-Transport-Security",
assert: (v) => {
const maxAge = /max-age=(\d+)/.exec(v);
expect(maxAge, `HSTS must carry a max-age, got: ${v}`).not.toBeNull();
// >= 1 year: below that the header is decorative and ineligible for preload.
expect(Number(maxAge![1])).toBeGreaterThanOrEqual(31536000);
expect(v).toMatch(/includeSubDomains/i);
},
},
{
key: "X-Frame-Options",
// SAMEORIGIN would be a weakening for this app: nothing here frames itself.
assert: (v) => expect(v.trim().toUpperCase()).toBe("DENY"),
},
{
key: "X-Content-Type-Options",
assert: (v) => expect(v.trim().toLowerCase()).toBe("nosniff"),
},
{
key: "Referrer-Policy",
assert: (v) =>
expect(["no-referrer", "same-origin", "strict-origin", "strict-origin-when-cross-origin"]).toContain(
v.trim().toLowerCase(),
),
},
{
key: "Permissions-Policy",
assert: (v) => {
for (const feature of ["camera", "microphone", "geolocation"]) {
expect(v, `Permissions-Policy must disable ${feature}`).toMatch(
new RegExp(`${feature}\\s*=\\s*\\(\\s*\\)`),
);
}
},
},
{
key: "Content-Security-Policy",
assert: (v) => {
const directives = new Map(
v
.split(";")
.map((d) => d.trim())
.filter(Boolean)
.map((d) => {
const [name, ...rest] = d.split(/\s+/);
return [name.toLowerCase(), rest.join(" ")] as const;
}),
);
expect(directives.get("default-src")).toBe("'self'");
// Clickjacking defence that actually applies to modern browsers.
expect(directives.get("frame-ancestors")).toBe("'none'");
expect(directives.get("base-uri")).toBe("'self'");
expect(directives.get("object-src")).toBe("'none'");
expect(directives.get("form-action")).toBe("'self'");
expect(directives.has("script-src")).toBe(true);
expect(directives.has("connect-src")).toBe(true);
expect(directives.has("frame-src")).toBe(true);
// No wildcard sources anywhere: a bare `*` re-opens what this header exists to close.
for (const [name, value] of directives) {
expect(value.split(/\s+/), `CSP directive ${name} must not allow *`).not.toContain("*");
}
// 'unsafe-eval' is a dev-only relaxation (see next.config.mjs). Vitest runs
// with NODE_ENV=test, so it must be absent here; if it ever appears outside
// `next dev`, this fails.
expect(process.env.NODE_ENV, "vitest is expected to run outside NODE_ENV=development").not.toBe(
"development",
);
expect(v, "'unsafe-eval' must never ship outside next dev").not.toContain("unsafe-eval");
},
},
];
/** Rule sources that cover every path Next can serve. */
const CATCH_ALL_SOURCES = new Set(["/(.*)", "/:path*", "/(.*)?"]);
async function loadNextConfig(): Promise<{ poweredByHeader?: boolean; headers?: () => Promise<HeaderRule[]> }> {
// Computed specifier on purpose: keeps `next.config.mjs` (a JS file outside
// tsconfig.check.json's include) out of the blocking typecheck's graph.
const url = new URL("../../next.config.mjs", import.meta.url).href;
const mod = (await import(/* @vite-ignore */ url)) as { default: Awaited<ReturnType<typeof loadNextConfig>> };
return mod.default;
}
describe("SEC-06: 0pt serves its own security headers (P0-13)", () => {
it("next.config.mjs declares a headers() rule covering every path", async () => {
const config = await loadNextConfig();
expect(typeof config.headers, "next.config.mjs must export an async headers()").toBe("function");
const rules = await config.headers!();
expect(Array.isArray(rules)).toBe(true);
const catchAll = rules.filter((r) => CATCH_ALL_SOURCES.has(r.source));
expect(
catchAll.length,
`no catch-all header rule; sources were: ${rules.map((r) => r.source).join(", ")}`,
).toBeGreaterThan(0);
});
it("does not advertise the framework via X-Powered-By", async () => {
const config = await loadNextConfig();
expect(config.poweredByHeader).toBe(false);
});
for (const { key, assert } of REQUIRED) {
it(`sets ${key} on every path, and keeps it strict`, async () => {
const config = await loadNextConfig();
const rules = await config.headers!();
const applied = rules
.filter((r) => CATCH_ALL_SOURCES.has(r.source))
.flatMap((r) => r.headers)
.filter((h) => h.key.toLowerCase() === key.toLowerCase());
expect(
applied.length,
`${key} is not set for all paths -- CHANGELOG.md claims 0pt ships it. ` +
`Either restore it in next.config.mjs or stop claiming it.`,
).toBe(1);
assert(applied[0].value);
});
}
it("the built routes-manifest carries the same headers (skipped when no build exists)", async () => {
const manifestPath = path.join(REPO_ROOT, ".next", "routes-manifest.json");
if (!existsSync(manifestPath)) {
// `pnpm run test` runs before `pnpm run build` in .gitea/workflows/ci.yml,
// so on a clean CI checkout there is nothing to read. Not a silent pass:
// the config-level assertions above already ran and are blocking.
return;
}
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as {
headers?: { source?: string; regex?: string; headers?: HeaderEntry[] }[];
};
const built = (manifest.headers ?? []).flatMap((r) => r.headers ?? []);
expect(built.length, "a build exists but its routes-manifest has no headers").toBeGreaterThan(0);
for (const { key } of REQUIRED) {
expect(
built.some((h) => h.key.toLowerCase() === key.toLowerCase()),
`${key} missing from .next/routes-manifest.json -- it would not be served`,
).toBe(true);
}
});
});