From e3a3b5dbdf79e79647728a5d973ab914379b6237 Mon Sep 17 00:00:00 2001 From: Binh Date: Fri, 2 Oct 2026 14:07:18 +0200 Subject: [PATCH] fix(risk-acceptance): keep same-CVE devices with their own occurrence Device part of 95ec322: scoped risk targets are derived from the source rows of each occurrence, so a device-finding cannot target a device that the selected occurrence does not list. --- docs/IMPLEMENTATION_PLAN.md | 1 + src/domain/scoped-risk-acceptance.ts | 13 ++++++++++-- tests/domain/scoped-risk-acceptance.test.ts | 16 ++++++++++++++ tests/persistence/assessor-repository.test.ts | 21 +++++++++++++++++++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 4ed62f0..347900c 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -3652,6 +3652,7 @@ Verifiziert 2026-09-09 (Phase 7): Bereichswechsel beschleunigt und Performance-V Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client Info. Aktuelle Evaluationen aus dem vorhandenen autoritativen Readmodell statt ergebnisabhaengiger Backupgruppierung; Import- und Auswertungszeit getrennt, Source-Scan-Provenienz auch auf GPO-only-Blaettern. 1.014 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Dummy lesend bestaetigt: eine statt drei GPO-Quellenoptionen, 3.805 aktuelle Zeilen, elf historische Evaluationen unveraendert. Keine Persistenzmutation oder Neuanalyse. | Datum | Änderung | +| 2026-10-02 | Phase 13A: Geraeteteil von `95ec322` nachgezogen. `defenderRiskTargetsForOccurrences` ordnet Devices ueber die Quellzeilen der jeweiligen Occurrence zu statt ueber die CVE allein; fuehren zwei Occurrences dieselbe CVE mit unterschiedlichen Devices, bleibt jedes Device bei seiner eigenen Occurrence, und ein `device-finding` fuer ein Device ausserhalb der gewaehlten Occurrence wird abgelehnt. Das stuetzt die Regel, dass eine geraetebezogene Entscheidung nur ihre erfassten Occurrences deckt. Zwei Regressionen (Domain, lokales Repository) uebernommen. Keine API-, SQL- oder Persistenzaenderung. Verifikation: beide Typechecks, Gesamtlauf mit 1.922 bestandenen und 18 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. | | 2026-10-02 | Phase 7: Defender-Teil von `3f6259d` (Stile und Dashboardtest) nachgezogen. Kennzahlbeschriftungen und Tabellenkoepfe des Defender-Panels nutzen die 14px-Tabellenrolle statt der 12px-Labelrolle; die Kennzahlen stehen als kompakte Kacheln (`--tile-compact-width`) in einem Segment statt in sechs festen Spalten; der Zeilenmarker nutzt `--marker-width`. Beibehalten gegenueber dem Original: Kachelkontur `--tile-border`, gemeinsame Textrollen und die blaue Tabellenzeilen-Markierung. Nur CSS und Testassertions; nicht im Browser geprueft. Verifikation: beide Typechecks, Gesamtlauf mit 1.920 bestandenen und 18 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. | | 2026-10-02 | Phasen 6/7/13A, Produktversion 2.12.0: `fdd46e8` (Profil Microsoft Defender for IoT Alerts) angepasst uebernommen. Profil `defender-for-iot-alerts` 1.6.0 mit Domain-Modell, Source Extractor, Manifestzeilen und deklarierten Scoring-/Prioritaets-/Timeslot-Vertraegen (kein nativer Gesamtscore, native Alarm-Severity, kein Timeslot-Vergleich). Alarme werden je Alarmtyp gruppiert; unterschiedliche Beschreibungen eines Typs erscheinen als gemeinsame Woerter mit `…`, die zugehoerige Nutzerregel steht mit ausdruecklicher Governance-Freigabe in `AGENTS.md`. Werkzeuguebergreifend: Unterstriche innerhalb eines Wortes sind in `markdown.ts` keine Auszeichnung mehr; ein Profil kann per `selectExtractedTables` die Tabellen behalten, aus denen sein Extractor Findings gebildet hat; ein Statusmapping kann sein Outcome deklarieren; beide Repositories setzen eine bereits durch einen ersetzten Lauf belegte Scan-Sequenz hinter alle gespeicherten Laeufe. Die Ausblendung von `Source Observations` liegt auf dieser Basis in `FindingReader.tsx`. Zwei Korrekturen aus spaeteren Commits waren fuer Defender auf dieser Basis noetig und sind vorgezogen: aus `210bbc0` der Titel-Rueckfall in `recordTitleFor` (eine leere gewaehlte Titelspalte faellt wieder auf den Datensatztitel zurueck; mit `7d3ef5f` war dieser Rueckfall fuer alle Scanner verloren gegangen) samt Regression fuer die GPO-Zeile, und aus `21a6266` die Ausnahme in `withSourceRuleId`, die eine native ID-Spalte nicht in `Source Rule ID` umbenennt, wenn sie zugleich der Finding-Titel ist (Defender `CVE`, Alert `Title`); die Regel-ID wird dann zusaetzlich angefuegt. Nicht uebernommen: die Versionsangabe 2.10.8 des Originals. Produktversion: Minor-Erhoehung von 2.11.2 auf 2.12.0 fuer die Defender-Serie (zwei neue Scannerprofile, geraetebezogene Risikoakzeptanz); `package.json`, Root-Eintraege in `package-lock.json` und die Versionsvertragstests sind synchron. Die `securityToolId`-Zeile aus `c056c21` folgt mit dem Result-DB-Werkzeugeintrag aus `21a6266`. Neue Profile, daher kein Reimport bestehender Importe; keine SQL-Migration. Nicht im Browser geprueft. Verifikation: beide Typechecks, Gesamtlauf mit 1.920 bestandenen und 18 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. | | 2026-10-02 | Phasen 7/13A: `d9ff6a8` (geraetebezogene Risikoakzeptanz fuer Defender) angepasst uebernommen. Neben der bestehenden Akzeptanz des ganzen Findings gibt es die Ziele `device-finding` (ein Device mal eine CVE) und `device-snapshot` (alle aktuellen CVEs eines Device) mit unveraenderlichem Zielabbild und append-only Historie. Migration `028_scoped_risk_acceptances.sql` legt die projektgebundene Tabelle `assessor_scoped_risk_acceptances` an (Cascade bei Projektloeschung); beide Repositories, `GET`/`POST /api/v1/projects/:id/risk-acceptances/scoped`, App-State, Defender-Panel (Abdeckungszeile, deckende Entscheidungen, `Accept all current findings`), Finding Reader/Dialog und das Accepted-Risks-Register sind erweitert. Nutzerentscheidung 2026-10-02, abweichend vom Original: (1) Die Zaehlung `current` im Register bleibt bei der Basisregel `Accepted`, `Scheduled`, `Review due`, `Review required`. (2) Geraetebezogene Entscheidungen folgen der Regel fuer ganze Findings: sie decken nur die erfassten Occurrences; nach einem Reimport oder entfernten Scan-Lauf sowie bei einer nicht erfassten Occurrence desselben Device und derselben CVE bleibt die Entscheidung mit vollstaendiger Historie erhalten, zeigt `Review required` und deckt nichts. Der Marker `capturedScopeReplaced` wird bei jedem Lesen aus den aktuellen Scan-Laeufen abgeleitet und nie gespeichert. Offen: Ersetzte geraetebezogene Entscheidungen fliessen nicht in die Reimport-Transferzaehlung je Datei und damit nicht in den Data-Hub-Verarbeitungsstatus ein; das Defender-Panel listet nur deckende Entscheidungen. Neue Regressionen: Domain (erfasste gegen ersetzte Occurrence, Marker, Widerruf, keine Persistenz des Markers), lokales Repository (Reimport desselben Berichts mit geaenderter Device-Identitaet) und Register. Der opt-in PostgreSQL-Integrationstest lief nicht; der PostgreSQL-Lesepfad des Markers ist damit nur typgeprueft. Vor der Migration wurde das verifizierte Backup `backups/assessor-pre-028-20261002-132810.dump` erstellt. Nicht im Browser geprueft. Verifikation: beide Typechecks, Gesamtlauf mit 1.908 bestandenen und 18 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. | diff --git a/src/domain/scoped-risk-acceptance.ts b/src/domain/scoped-risk-acceptance.ts index 4e3f539..d953dd2 100644 --- a/src/domain/scoped-risk-acceptance.ts +++ b/src/domain/scoped-risk-acceptance.ts @@ -168,7 +168,13 @@ export function defenderRiskTargetsForOccurrences(occurrences: FindingOccurrence const source = sourceObservationsForOccurrence(occurrence); return /^CVE-\d{4}-\d{4,}$/.test(cve) && source !== undefined ? [{ occurrence, cve, rows: observationRows(source) }] : []; }); - const rows = eligible.flatMap(item => item.rows); + const rowIndexesByOccurrence = new Map(eligible.map(item => [item.occurrence.id, new Set()])); + const rows: DefenderSourceRow[] = []; + for (const item of eligible) for (const row of item.rows) { + const rowIndex = rows.length; + rows.push({ ...row, tableIndex: 0, rowIndex }); + rowIndexesByOccurrence.get(item.occurrence.id)!.add(rowIndex); + } if (!rows.length) continue; const model = buildDefenderVulnerabilityModel([{ tableIndex: 0, @@ -181,7 +187,10 @@ export function defenderRiskTargetsForOccurrences(occurrences: FindingOccurrence .map(item => text(item.details?.deviceId))); const devices = new Map(model.devices.map(device => [device.id, device])); for (const item of eligible) { - const deviceIds = new Set(model.occurrences.filter(value => value.cve === item.cve).map(value => value.deviceId)); + const sourceRows = rowIndexesByOccurrence.get(item.occurrence.id)!; + const deviceIds = new Set(model.observations + .filter(value => value.cve === item.cve && sourceRows.has(value.rowIndex)) + .map(value => value.deviceId)); for (const deviceId of deviceIds) { const device = devices.get(deviceId); if (!device) continue; diff --git a/tests/domain/scoped-risk-acceptance.test.ts b/tests/domain/scoped-risk-acceptance.test.ts index fd0feee..4d98ff8 100644 --- a/tests/domain/scoped-risk-acceptance.test.ts +++ b/tests/domain/scoped-risk-acceptance.test.ts @@ -95,6 +95,22 @@ describe('device-scoped Defender risk acceptance', () => { expect(() => scopedRiskTargetFromOccurrences('device-snapshot', 'device-id:dev-y', crossCveConflict, now)).toThrow('conflicting identity'); }); + it('keeps same-CVE devices attached to their own source occurrence', () => { + const first = occurrence('occurrence-a', 'definition-cve-1', 'CVE-2026-1001', [ + observation('DEV-A', 'CVE-2026-1001', 'Device A') + ]); + const second = occurrence('occurrence-b', 'definition-cve-1', 'CVE-2026-1001', [ + observation('DEV-B', 'CVE-2026-1001', 'Device B') + ]); + + expect(defenderRiskTargetsForOccurrences([first, second]).map(item => [item.occurrenceId, item.device.key])).toEqual([ + ['occurrence-a', 'device-id:dev-a'], + ['occurrence-b', 'device-id:dev-b'] + ]); + expect(() => scopedRiskTargetFromOccurrences('device-finding', 'device-id:dev-b', [first, second], now, new Set([first.id]))) + .toThrow('not an actionable member'); + }); + it('derives decision state, preserves overlaps, and prevents target mutation', () => { const cve1 = occurrence('occurrence-1', 'definition-cve-1', 'CVE-2026-1001', [observation('DEV-A', 'CVE-2026-1001')]); const cve2 = occurrence('occurrence-2', 'definition-cve-2', 'CVE-2026-1002', [observation('DEV-A', 'CVE-2026-1002')]); diff --git a/tests/persistence/assessor-repository.test.ts b/tests/persistence/assessor-repository.test.ts index 44a11c6..0662fa7 100644 --- a/tests/persistence/assessor-repository.test.ts +++ b/tests/persistence/assessor-repository.test.ts @@ -244,6 +244,27 @@ describe('LocalAssessorRepository contract', () => { expect((await repository.exportProject(project.id)).scopedRiskAcceptances).toHaveLength(1); }); + it('rejects accepting a device absent from the selected same-CVE occurrence', async () => { + const repository = await repositoryWithProject(); + const run = defenderRun(1); + const data = defenderImportData(run, [ + { definitionId: 'definition-cve-1', occurrenceId: 'occurrence-a', cve: 'CVE-2026-1001', devices: ['DEV-A'] }, + { definitionId: 'definition-cve-1', occurrenceId: 'occurrence-b', cve: 'CVE-2026-1001', devices: ['DEV-B'] } + ]); + data.findingDefinitions = data.findingDefinitions.slice(0, 1); + await commitRun(repository, run, data); + + await expect(repository.recordScopedRiskAcceptance(project.id, { + target: { kind: 'device-finding', occurrenceId: 'occurrence-a', deviceKey: 'device-id:dev-b' }, + command: riskCommand('accept'), expectedRevision: 0 + })).rejects.toBeInstanceOf(RepositoryValidationError); + const accepted = await repository.recordScopedRiskAcceptance(project.id, { + target: { kind: 'device-finding', occurrenceId: 'occurrence-b', deviceKey: 'device-id:dev-b' }, + command: riskCommand('accept'), expectedRevision: 0 + }); + expect(accepted.target.members[0].capturedOccurrences.map(item => item.occurrenceId)).toEqual(['occurrence-b']); + }); + it('stores risk decisions atomically with optimistic concurrency, export and reimport history', async () => { const repository = await repositoryWithProject(); await commitRun(repository, scanRun(1));