From 8ebc90ffa3c1c1924f4e281855917db649bd248d Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Mon, 7 Sep 2026 20:45:58 +0200 Subject: [PATCH] fix(security,repo-honesty): real security headers + a doctor that checks truth, not presence 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 --- CHANGELOG.md | 50 ++- CODEOWNERS | 8 + evidence/operational-state.json | 49 +++ next.config.mjs | 118 ++++++ scripts/siax-doctor.mjs | 524 ++++++++++++++++++++++-- tests/repo/siax-doctor.test.ts | 209 ++++++++++ tests/security/security-headers.test.ts | 184 +++++++++ 7 files changed, 1102 insertions(+), 40 deletions(-) create mode 100644 CODEOWNERS create mode 100644 evidence/operational-state.json create mode 100644 tests/repo/siax-doctor.test.ts create mode 100644 tests/security/security-headers.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3037d76..13c0ae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,12 +25,37 @@ levereras därför i ett eget npm-steg i Dockerfile, pinnat till versionen i `pnpm-lock.yaml`. Verifierat i containern: `/_next/image` svarar 200 med `image/avif` (1,46 MB PNG -> 6,7 kB). -- `chore(deploy)`: `poweredByHeader: false` (ingen `X-Powered-By: Next.js`). +- 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) -- 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) + 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 + tracked in the section below. + +- 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` @@ -38,6 +63,21 @@ `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 — status 2026-09-15 + +These were asserted by the earlier `fix(security)` bullet and were not true on +`main` at that time. Current state (measured against `main`, 2026-09-15): + +- CI security gates now ENFORCE: `security.yml` and `nightly.yml` run Trivy + with `--exit-code 1` (landed via `ci/make-required-security-gate-real`, + 2026-09-08). (BACKLOG P0-16 — CLOSED) +- gitleaks is still not run anywhere in this repo. (BACKLOG P0-19 — OPEN) +- Actions are still NOT pinned to commit SHAs (`actions/checkout@v4` etc.). + (BACKLOG P1-14 — OPEN) +- No SBOM and no license inventory are produced or uploaded as CI artifacts. + (OPEN; the `fix/wire-signed-digest-to-deploy` branch carries a prepared + release chain that needs its cosign/registry secrets before it can land.) + ## 2026-08-11 — Evasion-stack decision provenance - Attributed and dated the account-owner's keep-decision on `services/scrapers/*` diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..0134e0f --- /dev/null +++ b/CODEOWNERS @@ -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 diff --git a/evidence/operational-state.json b/evidence/operational-state.json new file mode 100644 index 0000000..5d4574f --- /dev/null +++ b/evidence/operational-state.json @@ -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 , 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 + } +} diff --git a/next.config.mjs b/next.config.mjs index 1f0d4fd..59ce1f2 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,3 +1,113 @@ +// ============================================================================= +// 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
exists (the only +// 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