From 524a3da3b7474ffb0812d57e5a1b57ff976e3739 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 | 1 + server/postgres/migration-catalog.ts | 4 ++- .../023_defender_vulnerability_backfill.sql | 12 +++++++++ src/domain/canonical-model.ts | 5 ++-- src/domain/unified-result.ts | 13 +++++++-- tests/importers/defender-for-iot.test.ts | 17 ++++++++++++ .../vulnerability-finding-schema.test.ts | 27 ++++++++++++++++--- 8 files changed, 85 insertions(+), 8 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 7a21ba9..a2455aa 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -2580,6 +2580,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-09-11 | Phasen 2/6/7/11 um `vulnerability_finding` erweitert. Das Defender-Profil 1.1.0 setzt fuer statuslose CVE-Findings `outcome=warning`; Work und Unified Result projizieren `result_type=vulnerability_finding`, `is_ioe=false` und `deviation=true`. Migration 023 aktualisiert bestehende abgeleitete Result-Type-/Outcome-Spalten, ohne Originalfelder, Occurrence-JSON oder Profilprovenienz zu veraendern. Profil 1.0.0 benoetigt fuer einen vollstaendig aktuellen Datensatz Reimport/Reprocessing. | | 2026-09-10 | Separate globale CVE-Anreicherung nach dem Import umgesetzt: Das tenantunabhaengige CVE-Modell und Migration 021 speichern normalisierte NVD-Beschreibung, CVSS-Metriken, CWE, Referenzen, betroffene Konfigurationen, Zeitpunkte und Provenienz einmal je CVE. Local- und PostgreSQL-Repository, Assessment-/Export- und Unified-Result-Projektionen verwenden denselben Bestand fuer passende Defender-Vorkommen. Same-Origin-API und Results-Browser bieten manuelle Enrich-/Refresh-Aktionen; der NVD-Client verwendet den festen CVE-2.0-Endpunkt mit Timeout und Antwortgrenze. Keine Scanner-Originalfelder werden veraendert. | | 2026-09-10 | Defender-Betroffenheitszahl von der Rolle `score` auf `asset` umgestellt: In ASSESSOR ist `score` die Risikomagnitude, waehrend die Anzahl betroffener Devices eine Verbreitungskennzahl ist. Das aggregierte CVE traegt die Anzahl daher als `affectedAsset`; der Results-Browser zeigt sie unter der bestehenden Betroffenheitszeile, und `score` bleibt fuer echte Risikoscores frei. Kein Vergleichsschluessel betroffen (weiter `rule-id`). | | 2026-09-10 | Results-Browser zeigt fuer Defender-CVEs die aggregierte Betroffenheit als `Affected Devices`-Anzahl statt eines leeren `Affected Object`. Andere Werkzeuge behalten die `Affected Object`-Zeile unveraendert. | diff --git a/server/postgres/migration-catalog.ts b/server/postgres/migration-catalog.ts index f330bbf..0e7bf0f 100644 --- a/server/postgres/migration-catalog.ts +++ b/server/postgres/migration-catalog.ts @@ -20,6 +20,7 @@ import nativePolicyTargets from './migrations/019_native_policy_targets.sql?raw' import baselineDrivenPolicyEvaluation from './migrations/020_baseline_driven_policy_evaluation.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; @@ -49,5 +50,6 @@ export const databaseMigrations: readonly DatabaseMigration[] = [ { version: '019', name: 'native-policy-targets', sql: nativePolicyTargets }, { version: '020', name: 'baseline-driven-policy-evaluation', sql: baselineDrivenPolicyEvaluation }, { 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 24c37fd..4226fff 100644 --- a/src/domain/canonical-model.ts +++ b/src/domain/canonical-model.ts @@ -8,8 +8,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; diff --git a/src/domain/unified-result.ts b/src/domain/unified-result.ts index bde9848..4d4aa66 100644 --- a/src/domain/unified-result.ts +++ b/src/domain/unified-result.ts @@ -359,10 +359,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); + }); });