From 33d20193a5127c4784f220c60cd204205056cb24 Mon Sep 17 00:00:00 2001 From: Binh Date: Fri, 11 Sep 2026 10:29:25 +0200 Subject: [PATCH] feat(defender-for-iot): add vulnerability finding result type and backfill logic --- docs/CANONICAL_MODEL.md | 14 ++++++++++ docs/IMPLEMENTATION_PLAN.md | 3 +++ server/postgres/migration-catalog.ts | 4 ++- .../023_defender_vulnerability_backfill.sql | 12 +++++++++ src/domain/canonical-model.ts | 6 ++--- src/domain/unified-result.ts | 13 +++++++-- tests/importers/defender-for-iot.test.ts | 17 ++++++++++++ .../vulnerability-finding-schema.test.ts | 27 ++++++++++++++++--- 8 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 server/postgres/migrations/023_defender_vulnerability_backfill.sql diff --git a/docs/CANONICAL_MODEL.md b/docs/CANONICAL_MODEL.md index 8922035..dc40d3f 100644 --- a/docs/CANONICAL_MODEL.md +++ b/docs/CANONICAL_MODEL.md @@ -95,3 +95,17 @@ Haupttabelle. Der unveränderte externe Vertragstext liegt unter und ihre Begründung stehen in [`RESULT_CONTRACT.md`](RESULT_CONTRACT.md#abweichungen-der-assessor-realisierung). Dieses Dokument bleibt der fachlich verbindliche, implementierte Stand. + +### ASSESSOR-Erweiterung: Vulnerability Finding + +Seit 2026-09-11 erweitert ASSESSOR den geschlossenen externen +`result_type`-Katalog additiv um `vulnerability_finding`. Der Typ bezeichnet +einen Scannerbefund zu einer konkreten Schwachstelle, ohne ihn als bestaetigten +Indicator of Exposure zu klassifizieren. Microsoft-Defender-for-IoT-CVEs werden +als `vulnerability_finding` mit `outcome = warning`, `is_ioe = false` und damit +`is_deviation = true` projiziert. Severity bleibt eine unabhaengige Achse. + +`environment-observation` bleibt dagegen ein `source_kind`, kein weiterer +`result_type`; diese Kontext- und Inventardaten werden als `informational` +projiziert. Der unveraenderte externe Referenzvertrag 1.0.0 wird durch diese +ASSESSOR-spezifische Erweiterung nicht rueckwirkend umgeschrieben. diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 514a0fa..1c6aed0 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -1371,6 +1371,7 @@ Aktualisierung nach dem Schritt-11-Orchestrierungsvertikalschnitt vom 2026-08-04 | Datum | Entscheidung | Begründung | |---|---|---| +| 2026-09-11 | ASSESSOR erweitert den unveraenderten externen `result_type`-Katalog additiv um `vulnerability_finding`. Defender-for-IoT-CVEs erhalten bei fehlendem Quellstatus deklarativ `outcome=warning`; sie sind damit Abweichungen, bleiben aber `is_ioe=false`. `environment-observation` bleibt eine Herkunftsart mit `result_type=informational`. | Eine konkrete CVE auf betroffenen Devices ist weder Compliance-Check noch Policy-Abweichung oder blosse Inventarinformation. `exposure_finding` wuerde die vorhandene Exposure-State-/IOE-Semantik unnoetig auf Schwachstellen ausdehnen. Der eigene Typ trennt Schwachstellenbefund, Dringlichkeit und bestaetigte Exposure weiterhin sauber. | | 2026-09-10 | NVD `configurations` bleiben als top-level Konfigurationsgruppen erhalten. Vulnerable CPE-Matches bilden die betroffene Software je Gruppe; nicht vulnerable Matches derselben Gruppe werden getrennt als `Running on/with` gespeichert und dargestellt. Das Enrichment-Recordformat ist dafuer Version 2; Version-1-Flachsaetze bleiben lesbar, muessen fuer die verlustfreie Gruppierung aber erneut von NVD abgerufen werden. | Die NVD-Oberflaeche und API beschreiben eine logische Konfiguration aus betroffener Software und ihren Laufzeit-/Hardwarebedingungen. Ein flaches Match-Array ueberzaehlt Konfigurationen und verliert genau diese Beziehung. Die Gruppierung muss deshalb vor der Persistenz im externen Referenzadapter erhalten werden. | | 2026-09-10 | CVE-Enrichment ist ein globaler, nach normalisierter CVE-ID identifizierter Referenzbestand und weder Bestandteil der tenant-/timeslotgebundenen FindingOccurrence noch der werkzeuggebundenen, durch Reimport ersetzbaren FindingDefinition. Defender-Import und NVD-Enrichment bleiben getrennt; nur eine ausdrueckliche Aktion fuer eine bereits gespeicherte Defender-CVE darf den festen NVD-Endpunkt abfragen. | Beschreibung, CVSS-Metriken, CWE, Referenzen und betroffene Konfigurationen beschreiben die CVE selbst. Ein gemeinsamer Datensatz vermeidet redundante, auseinanderlaufende Kopien und macht vorhandene Anreicherung fuer spaetere Mandanten automatisch wiederverwendbar, waehrend der Import ohne Netzabhaengigkeit reproduzierbar bleibt. | | 2026-08-25 | Die appweite persistente Markierungs- und Auswahlfarbe ist auf ausdrueckliche Nutzerfestlegung immer Gelb und wird in beiden Themes ueber `--marking-color` gefuehrt. Kachelauswahl, Auswahlrahmen/-marker, gemerkte Eintraege und Graphauswahl verwenden diesen Token. Blau bleibt Tastaturfokus; Gruen, Rot, Orange und Severitytoene bleiben fachliche Statussignale. | Ein einzelner semantischer Markierungston verhindert violette oder skinabhaengig wechselnde Auswahlkonturen wie im Datahub-Screenshot und bewahrt gleichzeitig die Bedeutung bestehender Statusfarben. | @@ -1641,6 +1642,7 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode | Risiko | Gegenmaßnahme | |---|---| +| Alte Defender-Importe mit Profil 1.0.0 tragen im gespeicherten Occurrence-JSON weiterhin `outcome=unknown`; wenn Work bereits die aktuelle Profilregel verwendet, koennten Projektion und SQL-Vorfilterung widerspruechlich werden. | Profil 1.1.0 als semantisch inkompatiblen Vertrag mit neuem Compatibility Key registrieren. Die unveraenderliche Migration 022 erweitert den Result-Type-Constraint; Migration 023 zieht ausschliesslich die abgeleiteten relationalen Read-Model-Spalten `result_type` und `outcome_normalized` fuer bestehende Defender-Vorkommen nach. Originalfelder, Occurrence-JSON und Profilprovenienz bleiben unveraendert. Die Leseprojektion behandelt einen nachweislich statuslosen historischen Vulnerability-Fund ebenso. Reimport/Reprocessing bleibt fuer einen vollstaendig aktuellen kanonischen Datensatz erforderlich. | | NVD kann nicht erreichbar oder rate-limitiert sein, unerwartet grosse Antworten liefern oder externe Referenz-URLs enthalten; ein API-Schluessel darf nicht an Browser oder Logs gelangen. Eine automatische Anreicherung waehrend des Imports wuerde zudem dessen Reproduzierbarkeit und Verfuegbarkeit an einen externen Dienst koppeln. | Enrichment ausschliesslich separat und nutzerausgeloest im Backend ausfuehren, nur exakt gespeicherte Defender-CVE-IDs akzeptieren, Zielorigin und API-Pfad fest verdrahten sowie Timeout und 8-MiB-Antwortgrenze erzwingen. Den optionalen Schluessel nur aus `NVD_API_KEY` als Serverheader senden, Antwortfelder normalisieren, Referenzen nur als HTTP(S)-Links darstellen und Importfehler strikt vom Enrichment-Ergebnis trennen. | | Defender-for-IoT-Summarys koennen Severity-Zahlen ohne einzelne CVEs enthalten; wiederholte Sensorzeilen koennen dieselbe Device-CVE-Beziehung mehrfach melden, und pseudonymisierte oder wiederverwendete Adressen koennen Identitaeten mehrdeutig machen. | Aggregate ohne Detail niemals in Findings umwandeln, Device-mal-CVE vor der kanonischen Projektion deduplizieren, alle Sensorzeilen als Evidenz erhalten, Identitaet nach der dokumentierten MAC-/IP-/Name-Site-Reihenfolge bilden und Konflikte beziehungsweise Reconciliation-Abweichungen diagnostizieren statt still zusammenzufuehren. | | Ein gemischtes All-Records-Blatt kann den generischen Titel `Name` auf alle Scanner anwenden und dadurch bei Defender die Device-IP statt der CVE anzeigen; zugleich kann eine rein profilgebundene Client-Info-Sonderansicht in gemischten Timeslots verschwinden. | Titel im gemischten Blatt je Datensatz aus dessen `Source: Scan`-Provenienz und deklarativem Werkzeugprofil bestimmen. Die allgemeine Client-Info-Uebersicht beibehalten und die Defender-Projektion bei vorhandenen Defender-Quellblaettern als eigenen Tab anbieten. | @@ -1835,6 +1837,7 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode ## 12. Änderungsprotokoll | Datum | Änderung | +| 2026-09-11 | Phasen 2/6/7/11 um den eigenen Schwachstellen-Ergebnistyp erweitert: `vulnerability_finding` ist eine additive ASSESSOR-Erweiterung des unveraenderten externen Result Contract 1.0.0. Das Defender-Profil 1.1.0 setzt fuer statuslose CVE-Findings deklarativ `outcome=warning`; Work und Unified Result projizieren dadurch `result_type=vulnerability_finding`, `is_ioe=false` und `deviation=true`. Die bereits angewandte Migration 022 bleibt checksum-identisch und erweitert nur den geschlossenen PostgreSQL-Constraint; die nachtraeglich benoetigte Aktualisierung bestehender abgeleiteter Result-Type-/Outcome-Spalten liegt append-only in Migration 023. Originalfelder, Occurrence-JSON und Profilprovenienz bleiben unveraendert. Profil 1.0.0 bleibt wegen der geaenderten kanonischen Semantik inkompatibel und benoetigt fuer einen vollstaendig aktuellen Datensatz Reimport/Reprocessing. 787 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Phasen 2, 6, 7 und 11 bleiben abgeschlossen. | | 2026-09-11 | Phase-7-Abweichungsfilter zwischen Work und Result DB vereinheitlicht: Work prueft `deviation` nicht mehr gegen beliebige profilbezogene Rohwertmuster wie Defender-Severity `Critical`, sondern rekonstruiert aus Werkzeug und Statusfeld dieselben kanonischen Achsen Outcome, Result Type, Exposure State und `is_ioe` und verwendet anschliessend das gemeinsame versionierte Abweichungspraedikat. Defender-CVEs ohne Status bleiben damit wie in der Result DB `outcome=unknown`, `is_ioe=false`, `deviation=false`; Fail-/Warning-Ergebnisse und festgestellte Expositionen bleiben Abweichungen. Keine Daten-, API-, Schema-, Persistenz- oder Profilversionsaenderung. 781 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Phase 7 bleibt abgeschlossen. | | 2026-09-10 | Result-DB-Beschreibungsdeduplizierung fuer einzelne Defender-CVEs nachgehaertet: Scanner- und NVD-Beschreibung werden fuer den reinen Anzeigevergleich jetzt Unicode-NFKC-normalisiert, von unsichtbaren Formatzeichen bereinigt, case-insensitiv behandelt und ueber ihre Wort-/Zahlfolge verglichen. Dadurch werden visuell identische Saetze auch bei Soft-Hyphen-, Zeichensetzungs- oder Grossschreibungsabweichungen nicht doppelt gerendert; inhaltlich unterschiedliche Wortfolgen bleiben sichtbar. Gespeicherte Quelltexte bleiben unveraendert. Drei fokussierte Regressionstests und der Client-Produktionsbuild sind erfolgreich. | | 2026-09-10 | Work-Darstellung der gruppierten NVD-Konfigurationen an die Originalstruktur angenaehert: Jede nummerierte `Configuration` rendert genau eine Tabelle. Nach den betroffenen CPE-Zeilen steht `Running on/with` als eigene Tabellenzeile; die zugeordneten Plattform-CPEs folgen innerhalb derselben Tabelle statt in einer zweiten Tabelle. Gruppierung, Speicherung und Reihenfolge der Findings bleiben unveraendert. 17 fokussierte Finding-/CVE-Darstellungstests und der Client-Produktionsbuild sind erfolgreich. | diff --git a/server/postgres/migration-catalog.ts b/server/postgres/migration-catalog.ts index 38ed8f8..8804acf 100644 --- a/server/postgres/migration-catalog.ts +++ b/server/postgres/migration-catalog.ts @@ -18,6 +18,7 @@ import policyFindingFactory from './migrations/017_policy_finding_factory.sql?ra import scannerEnvironmentContext from './migrations/018_scanner_environment_context.sql?raw'; import cveEnrichment from './migrations/021_cve_enrichment.sql?raw'; import vulnerabilityFindingResultType from './migrations/022_vulnerability_finding_result_type.sql?raw'; +import defenderVulnerabilityBackfill from './migrations/023_defender_vulnerability_backfill.sql?raw'; export interface DatabaseMigration { version: string; @@ -45,5 +46,6 @@ export const databaseMigrations: readonly DatabaseMigration[] = [ { version: '017', name: 'policy-finding-factory', sql: policyFindingFactory }, { version: '018', name: 'scanner-environment-context', sql: scannerEnvironmentContext }, { version: '021', name: 'cve-enrichment', sql: cveEnrichment }, - { version: '022', name: 'vulnerability-finding-result-type', sql: vulnerabilityFindingResultType } + { version: '022', name: 'vulnerability-finding-result-type', sql: vulnerabilityFindingResultType }, + { version: '023', name: 'defender-vulnerability-backfill', sql: defenderVulnerabilityBackfill } ]; diff --git a/server/postgres/migrations/023_defender_vulnerability_backfill.sql b/server/postgres/migrations/023_defender_vulnerability_backfill.sql new file mode 100644 index 0000000..e1147b8 --- /dev/null +++ b/server/postgres/migrations/023_defender_vulnerability_backfill.sql @@ -0,0 +1,12 @@ +-- Backfill ausschliesslich der abgeleiteten, indizierten Read-Model-Spalten +-- fuer bereits importierte Defender-CVEs. Originalfelder, Occurrence-JSON und +-- Importprovenienz bleiben unveraendert. + +UPDATE assessor_finding_occurrences +SET + result_type = 'vulnerability_finding', + outcome_normalized = CASE + WHEN outcome_normalized = 'unknown' THEN 'warning' + ELSE outcome_normalized + END +WHERE lower(btrim(tool_id)) = 'defender-for-iot-vulnerabilities'; diff --git a/src/domain/canonical-model.ts b/src/domain/canonical-model.ts index fd64599..772412f 100644 --- a/src/domain/canonical-model.ts +++ b/src/domain/canonical-model.ts @@ -6,8 +6,9 @@ export type JsonPrimitive = string | number | boolean | null; export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; /** - * result_type aus RESULT_CONTRACT 1.0.0 (docs/RESULT_CONTRACT.md). Additive - * Facette, ausschließlich über src/domain/result-contract-mapping.ts abgeleitet. + * result_type aus RESULT_CONTRACT 1.0.0 plus dokumentierte ASSESSOR-Erweiterungen + * (docs/CANONICAL_MODEL.md). Additive Facette, ausschließlich über + * src/domain/result-contract-mapping.ts abgeleitet. */ export type CanonicalResultType = ResultContractResultType; @@ -337,4 +338,3 @@ export interface CanonicalAssessmentModel { reviews: FindingReview[]; diagnostics: ImportDiagnostic[]; } - diff --git a/src/domain/unified-result.ts b/src/domain/unified-result.ts index 61c0199..626fa6f 100644 --- a/src/domain/unified-result.ts +++ b/src/domain/unified-result.ts @@ -294,10 +294,19 @@ function mappingConfidence(occurrence: FindingOccurrence): number { export function createScannerUnifiedResult(source: ScannerUnifiedResultSource): UnifiedResultProjection { const review = matchingReview(source); const dimensions = reviewDimensions(review); - const outcomeNormalized = source.occurrence.outcome?.normalizedValue - ?? outcomeFromLegacyStatus(source.occurrence.status.normalizedValue ?? 'unknown'); const resultType = source.occurrence.resultType ?? deriveResultType(source.occurrence.sourceIdentity.tool.id); + const recordedOutcome = source.occurrence.outcome?.normalizedValue + ?? outcomeFromLegacyStatus(source.occurrence.status.normalizedValue ?? 'unknown'); + const originalOutcome = source.occurrence.outcome?.originalValue ?? source.occurrence.status.originalValue; + const hasOriginalOutcome = typeof originalOutcome === 'string' + ? Boolean(originalOutcome.trim()) + : originalOutcome !== null && originalOutcome !== undefined; + const outcomeNormalized = resultType === 'vulnerability_finding' + && recordedOutcome === 'unknown' + && !hasOriginalOutcome + ? 'warning' + : recordedOutcome; const exposureState = source.occurrence.exposureState ?? deriveExposureState(resultType, outcomeNormalized); const isIoe = source.occurrence.isIoe diff --git a/tests/importers/defender-for-iot.test.ts b/tests/importers/defender-for-iot.test.ts index 6babc00..7ce6108 100644 --- a/tests/importers/defender-for-iot.test.ts +++ b/tests/importers/defender-for-iot.test.ts @@ -178,6 +178,23 @@ describe('Microsoft Defender for IoT vulnerability model', () => { isIoe: false, isDeviation: true }); + expect(createScannerUnifiedResult({ + project: canonical.project, + assessment: canonical.assessment, + scanRun: canonical.scanRuns[0], + importFile: canonical.importFiles[0], + definition: canonical.findingDefinitions[0], + occurrence: { + ...canonical.findingOccurrences[0], + outcome: { ...canonical.findingOccurrences[0].outcome!, normalizedValue: 'unknown' } + }, + reviews: canonical.reviews + })).toMatchObject({ + resultType: 'vulnerability_finding', + outcomeNormalized: 'warning', + isIoe: false, + isDeviation: true + }); // The aggregated CVE carries its affected-device count as the affected asset (two devices here); the device list lives in the evidence table. expect(canonical.findingOccurrences[0].affectedAsset?.normalizedValue).toBe('2'); expect(canonical.findingOccurrences[0].score).toBeUndefined(); diff --git a/tests/postgres/vulnerability-finding-schema.test.ts b/tests/postgres/vulnerability-finding-schema.test.ts index 9079f94..2f4af44 100644 --- a/tests/postgres/vulnerability-finding-schema.test.ts +++ b/tests/postgres/vulnerability-finding-schema.test.ts @@ -12,19 +12,40 @@ const migrationPath = resolve( 'migrations', '022_vulnerability_finding_result_type.sql' ); +const backfillPath = resolve( + import.meta.dirname, + '..', + '..', + 'server', + 'postgres', + 'migrations', + '023_defender_vulnerability_backfill.sql' +); describe('PostgreSQL vulnerability finding result type migration', () => { - it('registers migration 022 after CVE enrichment', () => { - expect(databaseMigrations.at(-1)).toMatchObject({ + it('registers the immutable catalog extension before the Defender backfill', () => { + expect(databaseMigrations.at(-2)).toMatchObject({ version: '022', name: 'vulnerability-finding-result-type' }); + expect(databaseMigrations.at(-1)).toMatchObject({ + version: '023', + name: 'defender-vulnerability-backfill' + }); }); - it('extends the closed result type constraint without rewriting occurrences', async () => { + it('keeps migration 022 limited to the closed result type constraint', async () => { const sql = await readFile(migrationPath, 'utf8'); expect(sql).toContain("'vulnerability_finding'"); expect(sql).toContain('assessor_occurrences_result_type_check'); expect(sql).not.toMatch(/UPDATE\s+assessor_finding_occurrences/i); }); + + it('backfills only derived Defender columns in migration 023', async () => { + const sql = await readFile(backfillPath, 'utf8'); + expect(sql).toMatch(/UPDATE\s+assessor_finding_occurrences/i); + expect(sql).toContain("outcome_normalized = 'unknown' THEN 'warning'"); + expect(sql).toContain("lower(btrim(tool_id)) = 'defender-for-iot-vulnerabilities'"); + expect(sql).not.toMatch(/jsonb_set|\bdata\s*=/i); + }); });