Härdning av hela TÜV-revisionen

Tio av tolv fynd stängda, två reducerade med skälen utskrivna. Varje
stängning prövas av integrationssviten mot riktig Postgres — 144
kontroller, upp från 100.

T-1  Händelsenyckeln är nu (arende_id, id). Det finns inget delat
     namnrum kvar att ockupera, så attacken saknar yta i stället för att
     vara mildrad. Inom ett ärende bedöms en kollision på klientens
     avtryck — en hash av det kanoniserade innehåll klienten skickade,
     taget före serverns egna fält och före krypteringen, eftersom
     varken raden eller nyttolasten går att jämföra. Identiskt innehåll
     är fortfarande idempotent; samma id med annat innehåll ger 409 och
     skriver ingenting alls, inte heller resten av satsen. Samma attack
     en nivå upp — ärende-id är lika förutsägbart — stängs separat: ett
     id som ägs av en annan organisation ger 409 i stället för att tyst
     låta bli, vilket tidigare lämnade offrets ärende oskapat och varje
     senare synk svarande 404.

T-2  Mätdonet slås upp i registret. Beteckning och kalibrering HÄRLEDS
     därifrån och skriver över det klienten skickade. Okänt mätdon ger
     400. Utgånget avvisas inte — mätningen gjordes — men registrets
     datum följer med, så graderingen faller på registrets uppgift. Ett
     mätvärde utan mätdon får sina påstådda uppgifter borttagna.

T-3  Reducerad, inte stängd. Personnycklarna kuverteras under en
     huvudnyckel utanför databasen. En återställd dump ger nycklar som
     inte öppnas — verifierat genom att starta om utan huvudnyckeln. Vad
     som återstår står utskrivet: en backup tagen FÖRE en radering, plus
     huvudnyckeln, återställer fortfarande uppgifterna.

Därtill: gallringen verkställs nu av ett eget jobb och grupperar på det
blindade fordonsindexet så att en delad nyckel inte gallras för tidigt
(T-4) · AI-avlästa mätvärden bär härkomst (T-5) · bcrypt kostnad 12
(T-6) · åtkomstloggen och raderingsregistret är append-only, med en
smalare regel för personnycklarna som måste kunna förstöras (T-7) ·
åtkomstloggen dokumenterad (T-8) · ett externt regelpaket utan signatur
spärrar avslut (T-9) · CORS faller inte längre öppet (T-10) · exp krävs
i token (T-11) · react-router 7 (T-12, med den kvarvarande avvikelsen
motiverad).

Den motspelande hyresgästen som revisionen efterlyste finns nu som
testform och körs i CI.

---- Vad härdningen själv avslöjade -----------------------------------

Två av rättelserna var kortvarigt fel på samma sätt som fynden, och
bägge fångades bara av att jag försökte bevisa dem:

  T-7-testet var grönt mot en TOM tabell. En radnivåtrigger har inga
  rader att fyra på, så delete lyckades och kontrollen mätte ingenting.

  T-6-testet påstod anropet, inte kostnaden. toContain("gen_salt('bf')")
  hade accepterat kostnad 6 för evigt — och gjorde det, så länge det
  fanns.

Bägge är mönstret revisionen namngav: en kontroll som är riktig i sina
egna termer och oprövad vid sin gräns. Det gäller tester lika mycket som
kod.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-06 13:05:16 +00:00
parent 0833a573e0
commit b147694e31
15 changed files with 970 additions and 63 deletions
+134
View File
@@ -0,0 +1,134 @@
// Gallring (ALVA · TÜV T-4).
//
// ---- Varför den här filen finns ---------------------------------------
//
// `gallras_efter` räknades fram vid avslut, utifrån ärendetypen, och
// skrevs till ärenderaden. Sedan hände ingenting. En sökning genom hela
// repot visade att INGENTING läste kolumnen — ingen körning, inget
// schemalagt jobb, ingen fråga, inget CI-steg. Lagringsbegränsningen var
// en nedskriven avsikt, inte en verkställd kontroll.
//
// Det gjorde också att ett annat fel fick leva: triggern som förbjöd all
// update gjorde det omöjligt att ens SKRIVA kolumnen, och ingen märkte
// det, därför att ingenting längre ned konsumerade värdet.
//
// ---- Vad gallring är här ----------------------------------------------
//
// Inte radering av ärenden. Loggen är append-only och ska förbli det —
// protokollet över vad som kontrollerades har ett värde långt efter att
// identifieringen har det. Gallring är att förstöra personnyckeln, precis
// som en raderingsbegäran gör. Kvar blir ett ärende som fortfarande kan
// visa att lufttrycket mättes till 2,4 bar klockan 08:42, bara inte
// längre vems bil det gällde.
//
// ---- Försiktighetsregeln ----------------------------------------------
//
// En nyckel förstörs bara när SAMTLIGA ärenden som delar den har passerat
// sitt gallringsdatum. Ett fordon som varit inne fem gånger har fem
// ärenden och en nyckel; att gallra på det äldsta hade gjort de fyra
// senaste oläsbara i förtid. Ett ärende utan gallringsdatum — öppet, eller
// avslutat innan datum sattes — räknas som ännu inte passerat, eftersom
// för tidig gallring inte går att ångra.
import pg from "pg";
import { createHash } from "node:crypto";
/**
* Hittar och förstör nycklar vars samtliga ärenden har passerat sin tid.
*
* @param pool pg.Pool
* @param nu tidpunkt att jämföra mot; injicerad för testbarhet
* @param torrkor när true räknas allt fram men ingenting förstörs
*/
export async function gallra(pool, { nu = new Date(), torrkor = false } = {}) {
// Ett subjekt i personnycklar är ett ärende-id (se personnyckel() i
// server.mjs). Nyckeln kan delas av flera ärenden på samma fordon via
// det blindade indexet, så gruppen måste bildas på indexet — inte på
// subjektet — annars gallras en delad nyckel för tidigt.
const kandidater = await pool.query(
`with nyckelns_arenden as (
select n.id as nyckel_id,
n.organisation_id,
a.gallras_efter,
coalesce(a.identifierare_index, a.id) as grupp
from personnycklar n
join felsokning_arenden a
on a.organisation_id = n.organisation_id and a.id = n.subjekt
),
samma_fordon as (
select k.nyckel_id, k.organisation_id, b.gallras_efter
from nyckelns_arenden k
join felsokning_arenden b
on b.organisation_id = k.organisation_id
and coalesce(b.identifierare_index, b.id) = k.grupp
)
select nyckel_id, organisation_id, count(*)::int as antal_arenden
from samma_fordon
group by nyckel_id, organisation_id
having bool_and(gallras_efter is not null and gallras_efter <= $1)`,
[nu],
);
if (kandidater.rowCount === 0 || torrkor) {
return { forstorda: 0, arenden: 0, kandidater: kandidater.rowCount, torrkor };
}
let forstorda = 0;
let arenden = 0;
for (const rad of kandidater.rows) {
const klient = await pool.connect();
try {
await klient.query("begin");
const bort = await klient.query(`delete from personnycklar where id = $1 returning id`, [rad.nyckel_id]);
if (bort.rowCount > 0) {
// Gallringen skrivs i samma register som en begärd radering, med
// ett hashat subjekt: registret får inte självt bära uppgiften
// det bevisar att någon gjort oåtkomlig.
await klient.query(
`insert into raderingar (organisation_id, subjekt_hash, begard, begard_av, antal_arenden)
values ($1, $2, $3, $4, $5)`,
[
rad.organisation_id,
createHash("sha256").update(`gallring:${rad.nyckel_id}`).digest("hex"),
nu,
"Gallring (ALVA-PROC-0040)",
rad.antal_arenden,
],
);
forstorda += 1;
arenden += rad.antal_arenden;
}
await klient.query("commit");
} catch (fel) {
await klient.query("rollback").catch(() => {});
throw fel;
} finally {
klient.release();
}
}
return { forstorda, arenden, kandidater: kandidater.rowCount, torrkor };
}
// Körs som eget jobb (CronJob i klustret), inte i webbtjänstens process:
// en gallring som avbryts av en omstart mitt i ska kunna köras om, och
// den ska synas som en egen körning i driften.
//
// node gallring.mjs verkställer
// node gallring.mjs --torrkor räknar bara
if (import.meta.url === `file://${process.argv[1]}`) {
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 2 });
try {
const utfall = await gallra(pool, { torrkor: process.argv.includes("--torrkor") });
console.log(
JSON.stringify({
nivå: "info",
meddelande: "gallring",
tid: new Date().toISOString(),
...utfall,
}),
);
} finally {
await pool.end();
}
}