feat(defender-for-iot): add vulnerability finding result type and backfill logic

This commit is contained in:
Binh 2026-09-11 10:29:25 +02:00
parent 24893f965a
commit 524a3da3b7
8 changed files with 85 additions and 8 deletions

View File

@ -95,3 +95,17 @@ Haupttabelle. Der unveränderte externe Vertragstext liegt unter
und ihre Begründung stehen in und ihre Begründung stehen in
[`RESULT_CONTRACT.md`](RESULT_CONTRACT.md#abweichungen-der-assessor-realisierung). [`RESULT_CONTRACT.md`](RESULT_CONTRACT.md#abweichungen-der-assessor-realisierung).
Dieses Dokument bleibt der fachlich verbindliche, implementierte Stand. 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.

View File

@ -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. 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 | | 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 | 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 | 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. | | 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. |

View File

@ -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 baselineDrivenPolicyEvaluation from './migrations/020_baseline_driven_policy_evaluation.sql?raw';
import cveEnrichment from './migrations/021_cve_enrichment.sql?raw'; import cveEnrichment from './migrations/021_cve_enrichment.sql?raw';
import vulnerabilityFindingResultType from './migrations/022_vulnerability_finding_result_type.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 { export interface DatabaseMigration {
version: string; version: string;
@ -49,5 +50,6 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
{ version: '019', name: 'native-policy-targets', sql: nativePolicyTargets }, { version: '019', name: 'native-policy-targets', sql: nativePolicyTargets },
{ version: '020', name: 'baseline-driven-policy-evaluation', sql: baselineDrivenPolicyEvaluation }, { version: '020', name: 'baseline-driven-policy-evaluation', sql: baselineDrivenPolicyEvaluation },
{ version: '021', name: 'cve-enrichment', sql: cveEnrichment }, { 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 }
]; ];

View File

@ -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';

View File

@ -8,8 +8,9 @@ export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
/** /**
* result_type aus RESULT_CONTRACT 1.0.0 (docs/RESULT_CONTRACT.md). Additive * result_type aus RESULT_CONTRACT 1.0.0 plus dokumentierte ASSESSOR-Erweiterungen
* Facette, ausschließlich über src/domain/result-contract-mapping.ts abgeleitet. * (docs/CANONICAL_MODEL.md). Additive Facette, ausschließlich über
* src/domain/result-contract-mapping.ts abgeleitet.
*/ */
export type CanonicalResultType = ResultContractResultType; export type CanonicalResultType = ResultContractResultType;

View File

@ -359,10 +359,19 @@ function mappingConfidence(occurrence: FindingOccurrence): number {
export function createScannerUnifiedResult(source: ScannerUnifiedResultSource): UnifiedResultProjection { export function createScannerUnifiedResult(source: ScannerUnifiedResultSource): UnifiedResultProjection {
const review = matchingReview(source); const review = matchingReview(source);
const dimensions = reviewDimensions(review); const dimensions = reviewDimensions(review);
const outcomeNormalized = source.occurrence.outcome?.normalizedValue
?? outcomeFromLegacyStatus(source.occurrence.status.normalizedValue ?? 'unknown');
const resultType = source.occurrence.resultType const resultType = source.occurrence.resultType
?? deriveResultType(source.occurrence.sourceIdentity.tool.id); ?? 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 const exposureState = source.occurrence.exposureState
?? deriveExposureState(resultType, outcomeNormalized); ?? deriveExposureState(resultType, outcomeNormalized);
const isIoe = source.occurrence.isIoe const isIoe = source.occurrence.isIoe

View File

@ -178,6 +178,23 @@ describe('Microsoft Defender for IoT vulnerability model', () => {
isIoe: false, isIoe: false,
isDeviation: true 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. // 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].affectedAsset?.normalizedValue).toBe('2');
expect(canonical.findingOccurrences[0].score).toBeUndefined(); expect(canonical.findingOccurrences[0].score).toBeUndefined();

View File

@ -12,19 +12,40 @@ const migrationPath = resolve(
'migrations', 'migrations',
'022_vulnerability_finding_result_type.sql' '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', () => { describe('PostgreSQL vulnerability finding result type migration', () => {
it('registers migration 022 after CVE enrichment', () => { it('registers the immutable catalog extension before the Defender backfill', () => {
expect(databaseMigrations.at(-1)).toMatchObject({ expect(databaseMigrations.at(-2)).toMatchObject({
version: '022', version: '022',
name: 'vulnerability-finding-result-type' 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'); const sql = await readFile(migrationPath, 'utf8');
expect(sql).toContain("'vulnerability_finding'"); expect(sql).toContain("'vulnerability_finding'");
expect(sql).toContain('assessor_occurrences_result_type_check'); expect(sql).toContain('assessor_occurrences_result_type_check');
expect(sql).not.toMatch(/UPDATE\s+assessor_finding_occurrences/i); 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);
});
}); });