From fe712464627ecfea80b4f1169e94c3d77482a428 Mon Sep 17 00:00:00 2001 From: Binh Date: Fri, 11 Sep 2026 10:15:29 +0200 Subject: [PATCH] feat(defender-for-iot): introduce vulnerability finding result type and update related schemas and logic --- docs/IMPLEMENTATION_PLAN.md | 1 + server/postgres/migration-catalog.ts | 4 ++- .../022_vulnerability_finding_result_type.sql | 12 +++++++ src/domain/canonical-validation.ts | 2 +- src/domain/processing-compatibility.ts | 3 +- src/domain/result-contract-mapping.ts | 5 +++ src/domain/tool-profile-definition.ts | 4 ++- src/importers/status-normalization.ts | 32 +++++++++++++++++-- .../defender-for-iot-definition.ts | 10 +++--- src/profiles/profile-factory.ts | 25 ++------------- tests/domain/deviation-predicate.test.ts | 13 +++++++- tests/domain/processing-compatibility.test.ts | 14 +++++--- tests/domain/result-contract-mapping.test.ts | 12 +++++-- .../datahub-processing-readiness.test.ts | 4 ++- tests/features/dynamic-facets.test.ts | 4 ++- tests/importers/defender-for-iot.test.ts | 26 +++++++++++++-- .../vulnerability-finding-schema.test.ts | 30 +++++++++++++++++ tests/profiles/declarative-profiles.test.ts | 8 +++++ 18 files changed, 163 insertions(+), 46 deletions(-) create mode 100644 server/postgres/migrations/022_vulnerability_finding_result_type.sql create mode 100644 tests/postgres/vulnerability-finding-schema.test.ts diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index ee41a25..514a0fa 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -1835,6 +1835,7 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode ## 12. Änderungsprotokoll | Datum | Änderung | +| 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. | | 2026-09-10 | NVD-Konfigurationsprojektion korrigiert: Der NVD-Adapter flacht CPE-Matches nicht mehr zu einzelnen Konfigurationen ab, sondern speichert jede top-level API-Konfiguration als eine Gruppe mit getrennten `vulnerableMatches` und `runningOnWith`. Work zeigt je Gruppe `Configuration n`, die betroffene Software mit Versionsbereich und darunter die zugehoerigen `Running on/with`-CPEs. Das Enrichmentformat ist Version 2; vorhandene Version-1-Daten bleiben defensiv lesbar und werden beim naechsten `Refresh from NVD` verlustfrei ersetzt. 33 fokussierte Parser-, Persistenz- und Darstellungsregressionen, beide TypeScript-Pruefungen sowie der vollstaendige Client-/Server-Produktionsbuild sind erfolgreich. Keine SQL-Migration und keine Aenderung der Scannerfindings. | diff --git a/server/postgres/migration-catalog.ts b/server/postgres/migration-catalog.ts index e4c72e7..38ed8f8 100644 --- a/server/postgres/migration-catalog.ts +++ b/server/postgres/migration-catalog.ts @@ -17,6 +17,7 @@ import policyEvaluationRuleCatalogs from './migrations/016_policy_evaluation_rul import policyFindingFactory from './migrations/017_policy_finding_factory.sql?raw'; 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'; export interface DatabaseMigration { version: string; @@ -43,5 +44,6 @@ export const databaseMigrations: readonly DatabaseMigration[] = [ { version: '016', name: 'policy-evaluation-rule-catalogs', sql: policyEvaluationRuleCatalogs }, { version: '017', name: 'policy-finding-factory', sql: policyFindingFactory }, { version: '018', name: 'scanner-environment-context', sql: scannerEnvironmentContext }, - { version: '021', name: 'cve-enrichment', sql: cveEnrichment } + { version: '021', name: 'cve-enrichment', sql: cveEnrichment }, + { version: '022', name: 'vulnerability-finding-result-type', sql: vulnerabilityFindingResultType } ]; diff --git a/server/postgres/migrations/022_vulnerability_finding_result_type.sql b/server/postgres/migrations/022_vulnerability_finding_result_type.sql new file mode 100644 index 0000000..04bfbf0 --- /dev/null +++ b/server/postgres/migrations/022_vulnerability_finding_result_type.sql @@ -0,0 +1,12 @@ +-- ASSESSOR-Erweiterung des geschlossenen RESULT_CONTRACT-result_type-Katalogs +-- fuer Scannerbefunde zu konkreten Schwachstellen. Bestehende Occurrences +-- werden nicht umgedeutet; der neue Typ gilt fuer neu verarbeitete Importe. + +ALTER TABLE assessor_finding_occurrences + DROP CONSTRAINT assessor_occurrences_result_type_check, + ADD CONSTRAINT assessor_occurrences_result_type_check CHECK ( + result_type IN ( + 'exposure_finding', 'vulnerability_finding', 'compliance_check', + 'policy_deviation', 'informational', 'unknown' + ) + ); diff --git a/src/domain/canonical-validation.ts b/src/domain/canonical-validation.ts index 74cccf0..09b0bb9 100644 --- a/src/domain/canonical-validation.ts +++ b/src/domain/canonical-validation.ts @@ -8,7 +8,7 @@ import type { import { processingManifest } from './processing-compatibility'; const RESULT_CONTRACT_RESULT_TYPES = new Set([ - 'exposure_finding', 'compliance_check', 'policy_deviation', 'informational', 'unknown' + 'exposure_finding', 'vulnerability_finding', 'compliance_check', 'policy_deviation', 'informational', 'unknown' ]); const RESULT_CONTRACT_EXPOSURE_STATES = new Set([ 'detected', 'not_detected', 'not_evaluated', 'execution_error', 'unknown' diff --git a/src/domain/processing-compatibility.ts b/src/domain/processing-compatibility.ts index b8809fe..8d143ac 100644 --- a/src/domain/processing-compatibility.ts +++ b/src/domain/processing-compatibility.ts @@ -24,7 +24,8 @@ export interface ProcessingVersionManifest extends ToolProcessingContract { */ export const PROCESSING_VERSION_MANIFESTS: readonly ProcessingVersionManifest[] = [ { profileId: 'generic', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'generic-canonical-v1', changeClass: 'compatible', reimportRequired: false, affectedDimensions: [], current: true }, - { profileId: 'defender-for-iot-vulnerabilities', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'defender-for-iot-vulnerabilities-v1', changeClass: 'compatible', reimportRequired: false, affectedDimensions: [], current: true }, + { profileId: 'defender-for-iot-vulnerabilities', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'defender-for-iot-vulnerabilities-v1', changeClass: 'compatible', reimportRequired: false, affectedDimensions: [], current: false }, + { profileId: 'defender-for-iot-vulnerabilities', profileVersion: '1.1.0', mappingVersion: '1.0.0', normalizationVersion: '1.1.0', presentationVersion: '1.0.0', compatibilityKey: 'defender-for-iot-vulnerabilities-v2', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'resultType', 'deviation'], current: true }, { profileId: 'purple-knight', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'purple-knight-canonical-v1', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'isIoe'], current: false }, { profileId: 'purple-knight', profileVersion: '1.1.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.1.0', compatibilityKey: 'purple-knight-canonical-v1.1', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'isIoe'], current: true }, { profileId: 'pingcastle', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'pingcastle-canonical-v1', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'isIoe'], current: false }, diff --git a/src/domain/result-contract-mapping.ts b/src/domain/result-contract-mapping.ts index 85e9949..14946e3 100644 --- a/src/domain/result-contract-mapping.ts +++ b/src/domain/result-contract-mapping.ts @@ -20,6 +20,7 @@ export const RESULT_CONTRACT_MAPPING_VERSION = '1.0.0'; export type ResultContractResultType = | 'exposure_finding' + | 'vulnerability_finding' | 'compliance_check' | 'policy_deviation' | 'informational' @@ -49,6 +50,9 @@ export interface ResultContractIoeDerivation { */ const EXPOSURE_TOOL_IDS = new Set(['pingcastle', 'purple-knight', 'maester']); +/** Scanner, deren native Aussage eine konkrete Schwachstelle bezeichnet. */ +const VULNERABILITY_TOOL_IDS = new Set(['defender-for-iot-vulnerabilities']); + /** * Werkzeuge, deren native Aussage RESULT_CONTRACT als `compliance_check` * einordnet. @@ -65,6 +69,7 @@ const COMPLIANCE_TOOL_IDS = new Set(['cis-cat']); export function deriveResultType(toolId: string): ResultContractResultType { const normalized = toolId.trim().toLocaleLowerCase('en-US'); if (EXPOSURE_TOOL_IDS.has(normalized)) return 'exposure_finding'; + if (VULNERABILITY_TOOL_IDS.has(normalized)) return 'vulnerability_finding'; if (COMPLIANCE_TOOL_IDS.has(normalized)) return 'compliance_check'; return 'unknown'; } diff --git a/src/domain/tool-profile-definition.ts b/src/domain/tool-profile-definition.ts index ec063f1..117eb2e 100644 --- a/src/domain/tool-profile-definition.ts +++ b/src/domain/tool-profile-definition.ts @@ -1,4 +1,4 @@ -import type { CanonicalFieldRole, CanonicalFindingStatus, CanonicalSeverity } from './canonical-model'; +import type { CanonicalFieldRole, CanonicalFindingStatus, CanonicalOutcome, CanonicalSeverity } from './canonical-model'; export type ProfileSignalScope = 'file-name' | 'raw-text' | 'table-name' | 'header' | 'cell-value'; @@ -68,6 +68,8 @@ export interface ToolProfileDefinition { normalization: { severity: Array>; status: Array>; + /** Outcome fuer Finding-Quellen, deren Zeilen selbst der Befund sind und keinen Status liefern. */ + missingStatusOutcome?: CanonicalOutcome; }; presentation: { titleHeaders: RegExp[]; diff --git a/src/importers/status-normalization.ts b/src/importers/status-normalization.ts index 483ac81..8e33a6f 100644 --- a/src/importers/status-normalization.ts +++ b/src/importers/status-normalization.ts @@ -4,6 +4,8 @@ import type { CanonicalTechnicalExecutionState } from '../domain/canonical-model'; import type { FindingRecord } from '../domain/finding'; +import { isDeviation } from '../domain/deviation-predicate'; +import { deriveExposureState, deriveIsIoe, deriveResultType } from '../domain/result-contract-mapping'; import { outcomeFromLegacyStatus, technicalStateFromLegacyStatus } from '../domain/unified-result'; import { toolProfileDefinitions } from '../profiles/profile-definition-catalog'; import { clean } from '../shared/text'; @@ -73,9 +75,12 @@ function dimensionsFor(legacy: CanonicalFindingStatus, outcome?: CanonicalOutcom */ export function normalizeStatusDimensionsFor(toolId: string | undefined, value: string): StatusDimensions { const cleaned = clean(value); - if (!cleaned) return UNKNOWN_DIMENSIONS; + const definition = toolId ? definitionsByToolId.get(toolId) : undefined; + if (!cleaned) return definition?.normalization.missingStatusOutcome + ? { legacy: 'unknown', outcome: definition.normalization.missingStatusOutcome, technical: 'completed' } + : UNKNOWN_DIMENSIONS; - const declared = toolId ? definitionsByToolId.get(toolId)?.normalization.status : undefined; + const declared = definition?.normalization.status; const declaredMatch = declared?.find(mapping => mapping.patterns.some(pattern => pattern.test(cleaned))); if (declaredMatch) return dimensionsFor(declaredMatch.normalizedValue); @@ -111,6 +116,29 @@ export function toolIdForRecord(record: FindingRecord, fallbackToolId?: string): return toolIdByName.get(toolName) ?? fallbackToolId; } +function statusValueForRecord(record: FindingRecord, toolId: string | undefined): string { + const presentation = toolId ? definitionsByToolId.get(toolId)?.presentation : undefined; + const headers = presentation?.statusHeaders ?? [/^(?:status|evaluation|result)$/i]; + for (const header of headers) { + const field = record.fields.find(candidate => + candidate.value + && header.test(candidate.label.trim()) + && !presentation?.resultMessageHeaders?.some(pattern => pattern.test(candidate.label.trim()))); + if (field) return field.value; + } + return ''; +} + +/** Spiegelt fuer rohe Work-Datensaetze das kanonische Result-DB-Praedikat. */ +export function isDeviationRecord(record: FindingRecord, fallbackToolId?: string): boolean { + const toolId = toolIdForRecord(record, fallbackToolId); + const outcome = normalizeStatusDimensionsFor(toolId, statusValueForRecord(record, toolId)).outcome; + const resultType = deriveResultType(toolId ?? ''); + const exposureState = deriveExposureState(resultType, outcome); + const isIoe = deriveIsIoe(resultType, exposureState).isIoe; + return isDeviation(isIoe, outcome); +} + /** * Outcome eines rohen Statusfeldes, wie es die Arbeitsansichten anzeigen. * diff --git a/src/profiles/defender-for-iot/defender-for-iot-definition.ts b/src/profiles/defender-for-iot/defender-for-iot-definition.ts index c2ed3f6..7b71219 100644 --- a/src/profiles/defender-for-iot/defender-for-iot-definition.ts +++ b/src/profiles/defender-for-iot/defender-for-iot-definition.ts @@ -5,11 +5,11 @@ export const defenderForIotDefinition: ToolProfileDefinition = { schemaVersion: 1, id: 'defender-for-iot-vulnerabilities', name: 'Microsoft Defender for IoT', - version: '1.0.0', + version: '1.1.0', processing: { - mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', - compatibilityKey: 'defender-for-iot-vulnerabilities-v1', changeClass: 'compatible', - reimportRequired: false, affectedDimensions: [] + mappingVersion: '1.0.0', normalizationVersion: '1.1.0', presentationVersion: '1.0.0', + compatibilityKey: 'defender-for-iot-vulnerabilities-v2', changeClass: 'semantic-breaking', + reimportRequired: true, affectedDimensions: ['outcome', 'resultType', 'deviation'] }, sourceSignals: [ { scope: 'header', pattern: /^TotalCVEs$/i, contribution: 0.45, message: 'The asset summary exposes TotalCVEs.' }, @@ -32,7 +32,7 @@ export const defenderForIotDefinition: ToolProfileDefinition = { { role: 'asset', headers: [{ pattern: /^Affected Device Count$/i, contribution: 0.92, message: 'The aggregated affected-device count stands in for the affected object of this CVE.' }] }, { role: 'evidence', headers: [{ pattern: /^Source Observations$/i, contribution: 1, message: 'The field preserves every sensor observation and row locator.' }] } ], - normalization: { severity: commonSeverityMappings, status: commonStatusMappings }, + normalization: { severity: commonSeverityMappings, status: commonStatusMappings, missingStatusOutcome: 'warning' }, presentation: { titleHeaders: [/^CVE$/i], severityHeaders: [/^Severity$/i], diff --git a/src/profiles/profile-factory.ts b/src/profiles/profile-factory.ts index 8f4cde9..7d60cc2 100644 --- a/src/profiles/profile-factory.ts +++ b/src/profiles/profile-factory.ts @@ -2,6 +2,7 @@ import type { DatasetSheet } from '../domain/dataset'; import type { FindingRecord } from '../domain/finding'; import type { ImportProfile, ProfileDetectionContext } from '../domain/import-profile'; import type { ToolProfileDefinition, WeightedPattern } from '../domain/tool-profile-definition'; +import { isDeviationRecord } from '../importers/status-normalization'; import { clean, plainTextValue } from '../shared/text'; function matches(pattern: RegExp, value: string): boolean { @@ -24,25 +25,6 @@ function matches(pattern: RegExp, value: string): boolean { */ const normalizedFieldValuesCache = new WeakMap(); -/** - * Werkzeuguebergreifender Arbeitsfilter. Die Muster entsprechen der fachlichen - * Abweichung: fehlgeschlagene oder klaerungsbeduerftige Resultate sowie - * festgestellte Expositionen. Der Filter ist in jedem Profil verfuegbar und - * bildet den unveraenderlichen Startzustand aller Diagramme. - */ -const UNIVERSAL_DEVIATION_PATTERNS = [ - /^fail(?:ed)?$/, - /^not passed?$/, - /^nicht bestanden$/, - /^ioes? (?:found|detected)$/, - /^matched$/, - /^detected$/, - /^open$/, - /^offen$/, - /^investigate$/, - /^warning$/ -]; - function normalizedFieldValues(record: FindingRecord): string[] { let cached = normalizedFieldValuesCache.get(record); if (!cached) { @@ -121,11 +103,9 @@ export function createImportProfile(definition: ToolProfileDefinition): ImportPr }), matchesStatus: (record, status) => { if (status === 'all') return true; + if (status === 'deviation') return isDeviationRecord(record, definition.id); const filter = definition.presentation.statusFilters.find(item => item.value === status); const values = normalizedFieldValues(record); - if (status === 'deviation' && !filter) { - return values.some(value => UNIVERSAL_DEVIATION_PATTERNS.some(pattern => matches(pattern, value))); - } if (!filter) return true; return values.some(value => filter.patterns.some(pattern => matches(pattern, value))); }, @@ -136,4 +116,3 @@ export function createImportProfile(definition: ToolProfileDefinition): ImportPr } }; } - diff --git a/tests/domain/deviation-predicate.test.ts b/tests/domain/deviation-predicate.test.ts index 8588704..ad6094a 100644 --- a/tests/domain/deviation-predicate.test.ts +++ b/tests/domain/deviation-predicate.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { CanonicalOutcome } from '../../src/domain/canonical-model'; import { DEVIATION_PREDICATE_VERSION, isDeviation } from '../../src/domain/deviation-predicate'; import type { FindingRecord } from '../../src/domain/finding'; -import { normalizeStatusDimensionsFor, outcomeForStatusValue, toolIdForRecord } from '../../src/importers/status-normalization'; +import { isDeviationRecord, normalizeStatusDimensionsFor, outcomeForStatusValue, toolIdForRecord } from '../../src/importers/status-normalization'; function recordWith(fields: Array<[string, string]>): FindingRecord { const built = fields.map(([label, value], index) => ({ label, value, index })); @@ -69,6 +69,12 @@ describe('Maester-Statusvokabular', () => { }); describe('Outcome eines rohen Statusfeldes fuer die Arbeitsansichten', () => { + it('wendet in Work dasselbe Praedikat wie die Result DB an', () => { + expect(isDeviationRecord(recordWith([['Severity', 'Critical']]), 'defender-for-iot-vulnerabilities')).toBe(true); + expect(isDeviationRecord(recordWith([['Result', 'Failed']]), 'cis-cat')).toBe(true); + expect(isDeviationRecord(recordWith([['Result', 'Passed']]), 'cis-cat')).toBe(false); + }); + it('unterscheidet die drei Maester-Ergebnisse voneinander', () => { expect(outcomeForStatusValue('maester', 'Passed')).toBe('pass'); expect(outcomeForStatusValue('maester', 'Failed')).toBe('fail'); @@ -80,6 +86,11 @@ describe('Outcome eines rohen Statusfeldes fuer die Arbeitsansichten', () => { expect(outcomeForStatusValue('maester', ' ')).toBeUndefined(); }); + it('normalisiert einen statuslosen Defender-CVE-Befund auf warning', () => { + expect(normalizeStatusDimensionsFor('defender-for-iot-vulnerabilities', '')) + .toEqual({ legacy: 'unknown', outcome: 'warning', technical: 'completed' }); + }); + it('bleibt ohne bekannte Werkzeug-ID ueber die generischen Regeln nutzbar', () => { expect(outcomeForStatusValue(undefined, 'Investigate')).toBe('warning'); expect(outcomeForStatusValue(undefined, 'Passed')).toBe('pass'); diff --git a/tests/domain/processing-compatibility.test.ts b/tests/domain/processing-compatibility.test.ts index d723086..fab6c97 100644 --- a/tests/domain/processing-compatibility.test.ts +++ b/tests/domain/processing-compatibility.test.ts @@ -40,10 +40,16 @@ describe('processing compatibility contracts', () => { }); it('uses the recorded profile when a caller supplies a different detected tool id', () => { - const defender = file('defender', 'defender-for-iot-vulnerabilities', '1.0.0', '1.0.0'); - expect(processingReadiness('microsoft-defender-for-iot', defender.processedWith)).toBe('current'); - expect(resolvedCompatibilityKey('microsoft-defender-for-iot', defender.processedWith)) - .toBe('defender-for-iot-vulnerabilities-v1'); + const legacy = file('defender-old', 'defender-for-iot-vulnerabilities', '1.0.0', '1.0.0'); + const current = file('defender-current', 'defender-for-iot-vulnerabilities', '1.1.0', '1.0.0'); + current.processedWith.normalizationVersion = '1.1.0'; + current.processedWith.presentationVersion = '1.0.0'; + current.processedWith.compatibilityKey = 'defender-for-iot-vulnerabilities-v2'; + + expect(processingReadiness('microsoft-defender-for-iot', legacy.processedWith)).toBe('incompatible'); + expect(processingReadiness('microsoft-defender-for-iot', current.processedWith)).toBe('current'); + expect(resolvedCompatibilityKey('microsoft-defender-for-iot', current.processedWith)) + .toBe('defender-for-iot-vulnerabilities-v2'); }); it('blocks semantically incompatible versions in one scope', () => { diff --git a/tests/domain/result-contract-mapping.test.ts b/tests/domain/result-contract-mapping.test.ts index ea6bf47..d3c1737 100644 --- a/tests/domain/result-contract-mapping.test.ts +++ b/tests/domain/result-contract-mapping.test.ts @@ -19,6 +19,10 @@ describe('RESULT_CONTRACT result_type derivation', () => { expect(deriveResultType('cis-cat')).toBe('compliance_check'); }); + it('maps Defender CVEs to vulnerability_finding', () => { + expect(deriveResultType('defender-for-iot-vulnerabilities')).toBe('vulnerability_finding'); + }); + it('falls back to unknown for unknown or generic tools, never inventing exposure/compliance', () => { expect(deriveResultType('generic')).toBe('unknown'); expect(deriveResultType('some-future-tool')).toBe('unknown'); @@ -74,6 +78,10 @@ describe('RESULT_CONTRACT is_ioe invariant (never default true outside exposure_ expect(deriveIsIoe('unknown', undefined).isIoe).toBe(false); }); + it('is false for vulnerability_finding', () => { + expect(deriveIsIoe('vulnerability_finding', undefined).isIoe).toBe(false); + }); + it('is false for exposure_finding without a detected exposure_state', () => { expect(deriveIsIoe('exposure_finding', 'not_detected').isIoe).toBe(false); expect(deriveIsIoe('exposure_finding', 'not_evaluated').isIoe).toBe(false); @@ -91,8 +99,8 @@ describe('RESULT_CONTRACT is_ioe invariant (never default true outside exposure_ }); describe('exposure_state only exists for exposure_finding', () => { - it('is undefined for compliance_check, policy_deviation, informational and unknown', () => { - (['compliance_check', 'policy_deviation', 'informational', 'unknown'] as const).forEach(resultType => { + it('is undefined for non-exposure result types', () => { + (['vulnerability_finding', 'compliance_check', 'policy_deviation', 'informational', 'unknown'] as const).forEach(resultType => { expect(deriveExposureState(resultType, 'fail')).toBeUndefined(); }); }); diff --git a/tests/features/datahub-processing-readiness.test.ts b/tests/features/datahub-processing-readiness.test.ts index 81da8e2..3e3f0a1 100644 --- a/tests/features/datahub-processing-readiness.test.ts +++ b/tests/features/datahub-processing-readiness.test.ts @@ -40,9 +40,11 @@ describe('Datahub client processing readiness', () => { it('assesses the stored profile contract independently from the detected tool identity', () => { const defender = withDetectedTool( - file('run-defender', 'defender-for-iot-vulnerabilities', '1.0.0', 'defender-for-iot-vulnerabilities-v1'), + file('run-defender', 'defender-for-iot-vulnerabilities', '1.1.0', 'defender-for-iot-vulnerabilities-v2'), 'microsoft-defender-for-iot' ); + defender.processedWith.mappingVersion = '1.0.0'; + defender.processedWith.presentationVersion = '1.0.0'; expect(summarizeScanProcessingReadiness([defender])).toEqual({ state: 'ready', statuses: ['current'], explanation: 'The scan uses a current or compatible ASSESSOR processing profile.' }); diff --git a/tests/features/dynamic-facets.test.ts b/tests/features/dynamic-facets.test.ts index cb54239..0ef511f 100644 --- a/tests/features/dynamic-facets.test.ts +++ b/tests/features/dynamic-facets.test.ts @@ -24,7 +24,9 @@ describe('dynamic facets', () => { // "Ergebnisstatus" diese Aufgabe. Zwei Filter fuer dieselbe Spalte waren // fuer die Bedienung nicht unterscheidbar. expect(facets.some(facet => facet.id === 'field:2')).toBe(false); - expect(facets.find(facet => facet.id === 'profile:status')?.values.map(value => value.label)).toEqual(['Deviation']); + // "Open" normalisiert auf Outcome unknown und ist nach demselben + // kanonischen Praedikat wie in der Result DB keine Abweichung. + expect(facets.find(facet => facet.id === 'profile:status')?.values).toEqual([]); expect(facets.some(facet => facet.id === 'field:3')).toBe(false); expect(facets.some(facet => facet.id === 'field:4')).toBe(false); }); diff --git a/tests/importers/defender-for-iot.test.ts b/tests/importers/defender-for-iot.test.ts index 5a87d20..6babc00 100644 --- a/tests/importers/defender-for-iot.test.ts +++ b/tests/importers/defender-for-iot.test.ts @@ -9,6 +9,7 @@ import { importProfiles } from '../../src/profiles/profile-catalog'; import { confirmEntryMappings, previewEntryFromDataset } from '../../src/features/import-workflow/import-workflow'; import { canonicalModelToDataset } from '../../src/features/project-import/canonical-model-to-dataset'; import { summarizeScanProcessingReadiness } from '../../src/features/project-import/datahub-processing-readiness'; +import { createScannerUnifiedResult } from '../../src/domain/unified-result'; const summaryHeaders = ['Name', 'IP', 'Site', 'Sensor', 'TotalCVEs', 'Critical', 'High', 'Medium', 'Low']; const detailHeaders = ['Name', 'IP', 'Site', 'Sensor', 'CVE', 'Severity', 'NULL', 'Beschreibung']; @@ -129,7 +130,7 @@ describe('Microsoft Defender for IoT vulnerability model', () => { const imported = await importFile(new File([bytes], 'defender-synthetic.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), { profiles: importProfiles }); expect(imported.profileId).toBe('defender-for-iot-vulnerabilities'); - expect(imported.profileVersion).toBe('1.0.0'); + expect(imported.profileVersion).toBe('1.1.0'); expect(imported.extraction?.extractor).toMatchObject({ id: 'defender-for-iot-vulnerability-source-extractor', version: '1.0.0' }); // Two devices share one CVE: the Work view must show a single aggregated finding, not one row per device. expect(imported.extraction?.candidates).toHaveLength(1); @@ -151,13 +152,32 @@ describe('Microsoft Defender for IoT vulnerability model', () => { detectedTool: { id: 'defender-for-iot-vulnerabilities' }, processedWith: { profileId: 'defender-for-iot-vulnerabilities', - profileVersion: '1.0.0', - compatibilityKey: 'defender-for-iot-vulnerabilities-v1' + profileVersion: '1.1.0', + normalizationVersion: '1.1.0', + compatibilityKey: 'defender-for-iot-vulnerabilities-v2' } }); expect(summarizeScanProcessingReadiness(canonical.importFiles)).toMatchObject({ state: 'ready', statuses: ['current'] }); expect(canonical.findingDefinitions).toHaveLength(1); expect(canonical.findingOccurrences).toHaveLength(1); + expect(canonical.findingOccurrences[0]).toMatchObject({ + outcome: { normalizedValue: 'warning' }, + technicalExecutionState: { normalizedValue: 'completed' } + }); + expect(createScannerUnifiedResult({ + project: canonical.project, + assessment: canonical.assessment, + scanRun: canonical.scanRuns[0], + importFile: canonical.importFiles[0], + definition: canonical.findingDefinitions[0], + occurrence: canonical.findingOccurrences[0], + 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 new file mode 100644 index 0000000..9079f94 --- /dev/null +++ b/tests/postgres/vulnerability-finding-schema.test.ts @@ -0,0 +1,30 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { databaseMigrations } from '../../server/postgres/migration-catalog'; + +const migrationPath = resolve( + import.meta.dirname, + '..', + '..', + 'server', + 'postgres', + 'migrations', + '022_vulnerability_finding_result_type.sql' +); + +describe('PostgreSQL vulnerability finding result type migration', () => { + it('registers migration 022 after CVE enrichment', () => { + expect(databaseMigrations.at(-1)).toMatchObject({ + version: '022', + name: 'vulnerability-finding-result-type' + }); + }); + + it('extends the closed result type constraint without rewriting occurrences', 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); + }); +}); diff --git a/tests/profiles/declarative-profiles.test.ts b/tests/profiles/declarative-profiles.test.ts index a423ad3..52f28a8 100644 --- a/tests/profiles/declarative-profiles.test.ts +++ b/tests/profiles/declarative-profiles.test.ts @@ -115,6 +115,14 @@ describe('declarative tool profiles', () => { expect(profile?.matchesStatus(imported.sheets[0].analysis.records[0], 'deviation')).toBe(true); }); + it('uses the canonical missing-status warning for Defender CVEs in Work', () => { + const profile = importProfiles.find(candidate => candidate.id === 'defender-for-iot-vulnerabilities'); + const fields = [{ label: 'CVE', value: 'CVE-2026-0001', index: 0 }, { label: 'Severity', value: 'Critical', index: 1 }]; + const record = { id: 'defender-record', index: 0, title: 'CVE-2026-0001', fields, filled: fields, meta: [] }; + + expect(profile?.matchesStatus(record, 'deviation')).toBe(true); + }); + it('requires confirmation and keeps the generic profile when only the file name identifies a tool', async () => { const imported = await importFile(new File(['Alpha;Beta\none;two'], 'CIS-CAT-SYNTHETIC.csv', { type: 'text/csv' }), options);