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.
This commit is contained in:
parent
b41f4b1ae3
commit
e3a3b5dbdf
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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<number>()]));
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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')]);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in New Issue