b147694e31
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
135 lines
5.2 KiB
JavaScript
135 lines
5.2 KiB
JavaScript
// 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();
|
|
}
|
|
}
|