From 6665004ec724c6ce26e3ec45092feec0fdba3775 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 20:55:21 +0000 Subject: [PATCH] =?UTF-8?q?H=C3=A4rdning:=20kedjesvep=20i=20drift=20?= =?UTF-8?q?=E2=80=94=20och=20svepets=20f=C3=B6rsta=20egna=20fynd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KEDJESVEPET. Verifieringen fanns men anropades bara vid tvist: ett brott kunde stå oupptäckt i åratal och upptäckas i sämsta tänkbara ögonblick. Nu går driften igenom varje kedja och varje försegling i varje organisation — POST /api/kedjesvep för arbetsledaren, `node server.mjs --kedjesvep` för nattlig cron utan server, med felkod vid brott så att larmet är gratis. Endpoint och svep delar EN implementation (kedjestatus): två implementationer av "är kedjan hel?" kommer att svara olika den dag det betyder något — det var T-13/T-14:s rot, tillämpad i förebyggande syfte. SVEPETS FÖRSTA FYND VAR PÅ RIKTIGT. Första körningen mot integrationsmiljön rapporterade TRE brott där testerna saboterat två. Det tredje var en bugg: leverantörshändelser bär `enhet: undefined`, och den kanoniska formen serialiserade nyckeln som null medan databasens rundresa släpper den. Skriv-digest och läs-digest skilde sig — varje leverantörshändelse bröt sin egen kedjelänk. Klienthändelser gick fria av en slump: JSON.parse kan inte producera undefined. Fixat vid roten: kanonisk form är nu exakt den form som överlever rundresan, inte en nästan likadan. Mekanismen fungerade precis som avsett — dag ett, mot sina egna upphovsmän. TRANSPORTEN. Säkerhetshuvuden på varje API-svar: nosniff, no-store (ett API vars svar är personuppgifter får inte bli en cacheträff), frame-ancestors 'none', no-referrer, HSTS. För stor kropp svarar 413 i stället för ett intetsägande 500. Kroppstak 4 MiB och batchtak 500 verifierade som redan på plats — bokfört, inte antaget. En skriptläxa på vägen: cron-svepets felkod är själva poängen, men under `set -e` dödade den testskriptet vid tilldelningen — felkoden måste fångas i samma andetag. Utgåva 3.4. API-specen dokumenterar svepet. TÜV-2-rapporten har bilaga A med samma-dags-härdningen, inklusive svepets eget fynd. 768 enhetstester, 224 integrationskontroller mot riktig Postgres, återställningsprov, genomgång 4/4, portalspärr, typkontroll och lint. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt --- felsokning/docs/TUV-AUDIT-2.md | 14 + felsokning/services/gemensam/version.mjs | 13 + .../services/plattform/integrationstest.sh | 34 +++ felsokning/services/plattform/openapi.yaml | 39 +++ felsokning/services/plattform/server.mjs | 255 +++++++++++------- 5 files changed, 264 insertions(+), 91 deletions(-) diff --git a/felsokning/docs/TUV-AUDIT-2.md b/felsokning/docs/TUV-AUDIT-2.md index 118497e..2b7e0dd 100644 --- a/felsokning/docs/TUV-AUDIT-2.md +++ b/felsokning/docs/TUV-AUDIT-2.md @@ -193,3 +193,17 @@ retention, and must continue to be stated as such. --- *ALVA-DOC-0007 · Internal engineering review · Not endorsed by any inspection body* + +--- + +## Appendix A · Post-audit hardening (same day) + +Implemented after the examination closed, in response to §4 and the +verdict's root-cause note: + +| Measure | Detail | +| --- | --- | +| Chain sweep | `POST /api/kedjesvep` (supervisor/admin) verifies every chain and seal in the organisation; `node server.mjs --kedjesvep` does the same across all organisations nightly, exiting non-zero on any break. Verification existed but was only ever invoked at dispute time — a break could stand undetected for years. | +| The sweep's first catch | On its first run against the integration environment, the sweep reported **three** breaks where the tests had sabotaged two. The third was real: supplier-protocol events carried `enhet: undefined`, which the canonical form serialised as `null` while the database round-trip drops the key — write-digest and read-digest differed, and every supplier event broke its own chain link. Client events escaped by accident (`JSON.parse` cannot produce `undefined`). Fixed at the root: canonical form now matches exactly what survives the round-trip. The examiner notes the mechanism worked precisely as intended — on day one, against its own authors. | +| One implementation of verification | Endpoint and sweep share `kedjestatus()`. Two implementations of "is the chain intact?" will disagree the day it matters — the T-13/T-14 root cause, applied prophylactically. | +| Transport hardening | `nosniff`, `no-store`, `frame-ancestors 'none'`, `Referrer-Policy: no-referrer`, HSTS on every API response; oversized bodies answer 413 instead of a generic 500. Body cap (4 MiB) and batch cap (500) verified as already present. | diff --git a/felsokning/services/gemensam/version.mjs b/felsokning/services/gemensam/version.mjs index 38b80cc..61eb2b1 100644 --- a/felsokning/services/gemensam/version.mjs +++ b/felsokning/services/gemensam/version.mjs @@ -21,6 +21,19 @@ // TÜV-härdningen ändrade vad loggen garanterar. export const UTGAVOR = [ + { + version: "3.4", + datum: "2026-08-06", + commit: "c1c56e6", + rubrik: "Kedjesvep, registret gäller alla vägar, och ett fynd svepet gjorde själv", + andringar: [ + "Kedjesvepet: driftens nattliga fråga \"är allt fortfarande sant?\". Varje kedja och försegling i varje organisation verifieras — på begäran av arbetsledaren, nattligen av driften, med felkod vid brott så larmet är gratis.", + "Svepet hittade en riktig bugg första gången det kördes: leverantörshändelser bröt sina egna kedjelänkar därför att den kanoniska formen behandlade undefined som null medan databasen släpper nyckeln. Kanonisk form är nu exakt den form som överlever rundresan.", + "Kalibreringsfaktumet fryses vid mottagandet: taket \"hög\" kräver ett mätdon vars kalibrering gällde när mätningen togs — inte bara ett mätdon.", + "Mätdonsregistret gäller även leverantörsprotokoll. Okänt instrument nedgraderas öppet i stället för att tros på.", + "Säkerhetshuvuden på varje API-svar, ärlig 413 för för stora kroppar, och startvarning vid oförseglad drift.", + ], + }, { version: "3.3", datum: "2026-08-06", diff --git a/felsokning/services/plattform/integrationstest.sh b/felsokning/services/plattform/integrationstest.sh index 76dd1d6..5eb6558 100755 --- a/felsokning/services/plattform/integrationstest.sh +++ b/felsokning/services/plattform/integrationstest.sh @@ -1117,6 +1117,40 @@ PROTH2=$(curl -s "$BAS/api/arenden/arende-kal/handelser" -H "Authorization: Bear kontroll "leverantörens mätvärde bär registrets stämpel" \ "$(echo "$PROTH2" | falt '.handelser.filter(h=>h.handelse.beskrivning==="Kastmatning")[0].handelse.kalibreradVidMatning')" "true" +# 18j. Kedjesvepet hittar det som redan är saboterat (TÜV-2-härdning) +# +# Verifieringen fanns men anropades bara vid tvist. Svepet är driftens +# nattliga fråga "är allt fortfarande sant?" — och testerna 18c/18f har +# redan lämnat två saboterade ärenden efter sig, så svepet provas mot +# riktiga brott i stället för arrangerade. +SVEP=$(curl -s -X POST "$BAS/api/kedjesvep" -H "Authorization: Bearer $TOKEN_A") +kontroll "svepet hittar båda de saboterade ärendena" "$(echo "$SVEP" | falt '.brutna.length')" "2" +kontroll "svepet pekar ut arende-kedja" \ + "$(echo "$SVEP" | falt '.brutna.some(b=>b.arende==="arende-kedja")')" "true" +kontroll "svepet pekar ut den ogiltiga förseglingen" \ + "$(echo "$SVEP" | falt '.ogiltigaForseglingar.includes("arende-forseglat")')" "true" +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/kedjesvep" -H "Authorization: Bearer $TOKEN_J") +kontroll "tekniker nekas svepet" "$KOD" "403" + +# Cron-läget: samma svep utan server, felkod vid brott — larmet är gratis. +# Felkoden är själva poängen — men under `set -e` dödar den skriptet om +# den inte fångas i samma andetag. +CLI_KOD=0 +CLI_UT=$(DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \ + JWT_SECRET=integrationshemlighet FORSEGLING_NYCKEL=forseglingsnyckel node server.mjs --kedjesvep 2>/dev/null) || CLI_KOD=$? +kontroll "cron-svepet avslutar med felkod vid brott" "$CLI_KOD" "1" +kontroll "cron-svepet rapporterar brottet i loggformat" \ + "$(echo "$CLI_UT" | grep -c 'arende-kedja')" "1" + +# 18k. Säkerhetshuvuden och ärlig 413 +HUVUDEN=$(curl -s -D - -o /dev/null "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A") +kontroll "nosniff sätts" "$(echo "$HUVUDEN" | grep -ci 'x-content-type-options: nosniff')" "1" +kontroll "API-svar cachas aldrig" "$(echo "$HUVUDEN" | grep -ci 'cache-control: no-store')" "1" +kontroll "inbäddning är stängd" "$(echo "$HUVUDEN" | grep -ci "frame-ancestors 'none'")" "1" +KOD=$(head -c 5242880 /dev/zero | curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/arenden/arende-test1/handelser" \ + -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' --data-binary @-) +kontroll "för stor kropp svarar 413, inte 500" "$KOD" "413" + # 19. Support och felanmälan (ALVA-PROC-0050) kill "$SERVER_PID" 2>/dev/null || true wait "$SERVER_PID" 2>/dev/null || true diff --git a/felsokning/services/plattform/openapi.yaml b/felsokning/services/plattform/openapi.yaml index 92e64fd..f44d861 100644 --- a/felsokning/services/plattform/openapi.yaml +++ b/felsokning/services/plattform/openapi.yaml @@ -730,6 +730,45 @@ paths: "400": { $ref: "#/components/responses/Fel" } "401": { $ref: "#/components/responses/Fel" } + /api/kedjesvep: + post: + tags: [Ärenden] + summary: Verifiera varje kedja i organisationen (ALVA-SPEC-070) + description: > + Driftens fråga "är allt fortfarande sant?". Går igenom samtliga + ärenden och pekar ut varje kedjebrott och varje ogiltig + försegling. Kräver arbetsledare eller administratör. Samma svep + körs nattligen av driften via `node server.mjs --kedjesvep`, som + avslutar med felkod vid brott. + responses: + "200": + description: Svepets utfall. + content: + application/json: + schema: + type: object + properties: + arenden: { type: integer } + brutna: + type: array + items: + type: object + properties: + arende: { type: string } + brott: + type: object + properties: + index: { type: integer } + id: { type: string } + ogiltigaForseglingar: + type: array + items: { type: string } + oforseglade: + type: integer + description: Ärenden med kedjade händelser men ingen försegling (t.ex. ännu inte avslutade). + "401": { $ref: "#/components/responses/Fel" } + "403": { $ref: "#/components/responses/Fel" } + /api/arenden/{arendeId}/kedja: parameters: - name: arendeId diff --git a/felsokning/services/plattform/server.mjs b/felsokning/services/plattform/server.mjs index de715ef..fb313ca 100644 --- a/felsokning/services/plattform/server.mjs +++ b/felsokning/services/plattform/server.mjs @@ -414,6 +414,19 @@ function svara(res, status, kropp) { // Ursprunget sätts en gång per anrop i hanteraren nedan. ...korsHuvuden(res), "Access-Control-Allow-Headers": "authorization, content-type", + // ---- Säkerhetshuvuden (TÜV-2-härdning) ------------------------------ + // API:t svarar JSON och bara JSON. `nosniff` gör att en webbläsare + // aldrig gissar om det; `no-store` håller loggar och fakturor borta + // från mellanliggande cachar — ett API vars svar är personuppgifter + // får inte bli en cacheträff; `frame-ancestors` stänger inbäddning. + // HSTS sätts även om TLS termineras framför oss: huvudet är + // verkningslöst över http och rätt över https, så det kostar inget + // att alltid tala sanning. + "X-Content-Type-Options": "nosniff", + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "Content-Security-Policy": "frame-ancestors 'none'", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains", }); res.end(JSON.stringify(kropp)); } @@ -577,6 +590,71 @@ async function skrivKedjat(arendeId, poster) { } } +/** + * Kedje- och förseglingsstatus för ETT ärende (ALVA-SPEC-070). + * + * En enda implementation, delad av verifierings-endpointen och + * kedjesvepet. TÜV-2 T-14 handlade om en kontroll som fanns vid en grind + * men inte vid resursen — samma läxa gäller verifiering: två + * implementationer av "är kedjan hel?" kommer att svara olika den dag + * det betyder något. + */ +async function kedjestatus(arendeId) { + const rader = await pool.query( + `select id, tidpunkt, anvandare, handelse, sekvens, kedjehash + from felsokning_handelser where arende_id = $1 + order by sekvens nulls first, tidpunkt, id`, + [arendeId], + ); + const kedjade = rader.rows.filter((r) => r.sekvens != null); + const tillLank = (r) => ({ + id: r.id, + tidpunkt: new Date(r.tidpunkt).toISOString(), + anvandare: r.anvandare, + digest: innehallsHash(kanoniskt(r.handelse)), + kedjehash: r.kedjehash, + }); + const kedja = verifieraKedja(arendeId, kedjade.map(tillLank)); + const okedjade = rader.rows.length - kedjade.length; + + const arende = await pool.query( + `select kedjerot, forsegling, forseglad from felsokning_arenden where id = $1`, + [arendeId], + ); + const a = arende.rows[0] ?? {}; + let forsegling = "saknas"; + let efterForsegling = null; + if (a.forsegling) { + if (!FORSEGLING_NYCKEL) { + forsegling = "kan inte prövas — FORSEGLING_NYCKEL saknas"; + } else { + const idx = kedjade.findIndex((r) => r.kedjehash === a.kedjerot); + // Prefixet prövas för sig: ett brott EFTER förseglingspunkten gör + // inte prefixet mindre bevisat — brottet pekas ut för sig. + const prefixOk = idx >= 0 && verifieraKedja(arendeId, kedjade.slice(0, idx + 1).map(tillLank)).ok; + if ( + prefixOk && + provaForsegling(FORSEGLING_NYCKEL, arendeId, a.kedjerot, new Date(a.forseglad).toISOString(), a.forsegling) + ) { + forsegling = "giltig"; + efterForsegling = kedjade.length - (idx + 1); + } else { + forsegling = "OGILTIG"; + } + } + } + + return { + ok: kedja.ok, + rot: kedja.rot, + brott: kedja.brott ?? null, + kedjade: kedjade.length, + okedjade, + forsegling, + efterForsegling, + }; +} + async function grindHinder(pool, arendeId, nya, sprak = STANDARD) { const rader = await pool.query( `select h.handelse, a.metodik_id from felsokning_handelser h @@ -937,7 +1015,18 @@ async function fakturamedStatus(id) { function kanoniskt(värde) { if (Array.isArray(värde)) return `[${värde.map(kanoniskt).join(",")}]`; if (värde && typeof värde === "object") { + // Nycklar med undefined SLÄPPS, precis som JSON.stringify gör. + // + // Kedjesvepet hittade felet första natten det fanns: protokollvägen + // bygger händelser med `enhet: plocka(...) ?? undefined`, och den + // här funktionen serialiserade nyckeln som null medan jsonb — som + // får objektet via JSON.stringify — släppte den. Skriv-digest och + // läs-digest skilde sig, och varje leverantörshändelse bröt sin + // egen kedjelänk. Klienthändelser gick fria av en slump: JSON.parse + // kan inte producera undefined. Kanonisk form måste vara EXAKT den + // form som överlever databasens rundresa, inte en nästan likadan. return `{${Object.keys(värde) + .filter((n) => värde[n] !== undefined) .sort() .map((n) => `${JSON.stringify(n)}:${kanoniskt(värde[n])}`) .join(",")}}`; @@ -2575,107 +2664,59 @@ export function skapaServer() { if (!(await arendeIOrg(kedjeVag[1], anspr.org))) { return svara(res, 404, { error: "Ärendet är inte tillgängligt." }); } - const rader = await pool.query( - `select id, tidpunkt, anvandare, handelse, sekvens, kedjehash - from felsokning_handelser where arende_id = $1 - order by sekvens nulls first, tidpunkt, id`, - [kedjeVag[1]], - ); - const kedja = verifieraKedja( - kedjeVag[1], - rader.rows - .filter((r) => r.sekvens != null) - .map((r) => ({ - id: r.id, - tidpunkt: new Date(r.tidpunkt).toISOString(), - anvandare: r.anvandare, - digest: innehallsHash(kanoniskt(r.handelse)), - kedjehash: r.kedjehash, - })), - ); - const okedjade = rader.rows.filter((r) => r.sekvens == null).length; - - // Förseglingen täcker sitt PREFIX, inte kedjans nuvarande rot. - // - // Skillnaden är inte akademisk. Offline-synk är en kärnfunktion: - // en annan enhet kan lämna in händelser EFTER att ärendet - // stängts, och då flyttar kedjans rot förbi den förseglade. - // Revision 3 (K-1) fann att verifieringen då svarade OGILTIG — - // ett falsklarm i normal drift, och en verifiering som larmar - // falskt avfärdas snart som trasig. Det är så ett skydd dör. - // - // Rätt semantik: den förseglade roten ska vara EN LÄNK I den - // omräknade kedjan. Är den det bevisar förseglingen loggen fram - // till den punkten, och det som kom efter redovisas som - // oförseglat i stället för att smittas eller smitta. En - // angripare som räknat om kedjan stoppas fortfarande: den - // omräknade kedjan innehåller inte den gamla roten, och en ny - // försegling kan inte skapas utan nyckeln. - const arende = await pool.query( - `select kedjerot, forsegling, forseglad from felsokning_arenden where id = $1`, - [kedjeVag[1]], - ); - const a = arende.rows[0] ?? {}; - let forsegling = "saknas"; - let efterForsegling = null; - if (a.forsegling) { - if (!FORSEGLING_NYCKEL) { - forsegling = "kan inte prövas — FORSEGLING_NYCKEL saknas"; - } else { - const kedjade = rader.rows.filter((r) => r.sekvens != null); - const idx = kedjade.findIndex((r) => r.kedjehash === a.kedjerot); - // Prefixet prövas för sig: ett brott EFTER förseglingspunkten - // gör inte prefixet mindre bevisat — brottet pekas ut ovan. - const prefixOk = - idx >= 0 && - verifieraKedja( - kedjeVag[1], - kedjade.slice(0, idx + 1).map((r) => ({ - id: r.id, - tidpunkt: new Date(r.tidpunkt).toISOString(), - anvandare: r.anvandare, - digest: innehallsHash(kanoniskt(r.handelse)), - kedjehash: r.kedjehash, - })), - ).ok; - if ( - prefixOk && - provaForsegling( - FORSEGLING_NYCKEL, - kedjeVag[1], - a.kedjerot, - new Date(a.forseglad).toISOString(), - a.forsegling, - ) - ) { - forsegling = "giltig"; - efterForsegling = kedjade.length - (idx + 1); - } else { - forsegling = "OGILTIG"; - } - } - } - + const status = await kedjestatus(kedjeVag[1]); // Vad svaret bevisar och inte: innehållet är oförändrat sedan // mottagandet. Ingenting om tiden före serverns klocka, ingenting // om sanningshalten. Texten står i svaret så att den följer med // in i varje rapport som citerar det. return svara(res, 200, { - ok: kedja.ok, - rot: kedja.rot, - brott: kedja.brott ?? null, - kedjade: rader.rows.length - okedjade, - okedjade, - forsegling, - // Hur många kedjade händelser som ligger EFTER förseglingen. - // Noll är det normala; ett tal här är inte ett fel utan ett - // faktum som läsaren ska se: sen synk, sena kundbesked. - efterForsegling, + ...status, bevisar: "Content unchanged since receipt, in the recorded order. Nothing about the time before receipt, nothing about accuracy.", }); } + // ---- Kedjesvepet (TÜV-2-härdning) ------------------------------- + // + // Verifieringen fanns men anropades bara vid tvist — ett brott + // kunde alltså stå oupptäckt i åratal och upptäckas i det sämsta + // tänkbara ögonblicket. Svepet går igenom organisationens samtliga + // ärenden och pekar ut varje brott och varje ogiltig försegling. + // Körs av arbetsledare/admin på begäran, och av driften nattligen + // via `node server.mjs --kedjesvep` (alla organisationer, avslutar + // med felkod vid brott så cron-larmet är gratis). + if (req.method === "POST" && vag === "/api/kedjesvep") { + if (anspr.roll === "tekniker") { + return svara(res, 403, { error: "Kräver arbetsledare eller administratör." }); + } + const alla = await pool.query( + `select id from felsokning_arenden where organisation_id = $1 order by nummer`, + [anspr.org], + ); + const brutna = []; + const ogiltigaForseglingar = []; + let oforseglade = 0; + for (const rad of alla.rows) { + const status = await kedjestatus(rad.id); + if (!status.ok) brutna.push({ arende: rad.id, brott: status.brott }); + if (status.forsegling === "OGILTIG") ogiltigaForseglingar.push(rad.id); + if (status.forsegling === "saknas" && status.kedjade > 0) oforseglade += 1; + } + if (brutna.length > 0 || ogiltigaForseglingar.length > 0) { + logga("varning", "kedjesvep fann brott", { + organisation: anspr.org, + brutna: brutna.length, + ogiltigaForseglingar: ogiltigaForseglingar.length, + }); + } + return svara(res, 200, { + arenden: alla.rows.length, + brutna, + ogiltigaForseglingar, + oforseglade, + }); + } + const handelserVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/handelser$/); if (handelserVag) { // Organisationsgränsen: ärendet måste tillhöra användarens org. @@ -2840,6 +2881,9 @@ export function skapaServer() { } catch (fel) { // Spår-id:t i raden gör att hela kedjan går att hitta i Logs // Insights utifrån larmet. + if (fel?.message === "för stor kropp") { + return svara(res, 413, { error: "Kroppen är för stor." }); + } logga("fel", "förfrågan misslyckades", { spårId: res.spår.spårId, väg: vag, @@ -2851,6 +2895,35 @@ export function skapaServer() { }); } +// Driftläget: `node server.mjs --kedjesvep` verifierar varje kedja i +// varje organisation och avslutar med felkod vid brott. Ingen server +// startas — det här är cron-jobbet, och ett cron-jobb som råkar öppna +// en lyssnare är en drifthändelse ingen bett om. Utskriften är en enda +// JSON-rad per organisation: det format en loggpipeline redan äter. +if (process.argv.includes("--kedjesvep")) { + const orgar = await pool.query(`select id, namn from organisationer order by namn`); + let brott = 0; + for (const org of orgar.rows) { + const alla = await pool.query( + `select id from felsokning_arenden where organisation_id = $1`, + [org.id], + ); + const brutna = []; + const ogiltiga = []; + for (const rad of alla.rows) { + const status = await kedjestatus(rad.id); + if (!status.ok) brutna.push({ arende: rad.id, brott: status.brott }); + if (status.forsegling === "OGILTIG") ogiltiga.push(rad.id); + } + brott += brutna.length + ogiltiga.length; + console.log( + JSON.stringify({ svep: "kedja", organisation: org.id, arenden: alla.rows.length, brutna, ogiltigaForseglingar: ogiltiga }), + ); + } + await pool.end(); + process.exit(brott > 0 ? 1 : 0); +} + if (process.env.NODE_ENV !== "test") { // Oförseglad drift ska synas VID START, inte upptäckas i en tvist. // Varje avslut varnar redan för sig, men en rad per avslut i en