feat(defender-for-iot): introduce vulnerability finding result type and update related schemas and logic

This commit is contained in:
Binh 2026-09-11 10:15:29 +02:00
parent e04829597c
commit fe71246462
18 changed files with 163 additions and 46 deletions

View File

@ -1835,6 +1835,7 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
## 12. Änderungsprotokoll ## 12. Änderungsprotokoll
| Datum | Änderung | | 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 | 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 | 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. | | 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. |

View File

@ -17,6 +17,7 @@ import policyEvaluationRuleCatalogs from './migrations/016_policy_evaluation_rul
import policyFindingFactory from './migrations/017_policy_finding_factory.sql?raw'; import policyFindingFactory from './migrations/017_policy_finding_factory.sql?raw';
import scannerEnvironmentContext from './migrations/018_scanner_environment_context.sql?raw'; import scannerEnvironmentContext from './migrations/018_scanner_environment_context.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';
export interface DatabaseMigration { export interface DatabaseMigration {
version: string; version: string;
@ -43,5 +44,6 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
{ version: '016', name: 'policy-evaluation-rule-catalogs', sql: policyEvaluationRuleCatalogs }, { version: '016', name: 'policy-evaluation-rule-catalogs', sql: policyEvaluationRuleCatalogs },
{ version: '017', name: 'policy-finding-factory', sql: policyFindingFactory }, { version: '017', name: 'policy-finding-factory', sql: policyFindingFactory },
{ version: '018', name: 'scanner-environment-context', sql: scannerEnvironmentContext }, { 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 }
]; ];

View File

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

View File

@ -8,7 +8,7 @@ import type {
import { processingManifest } from './processing-compatibility'; import { processingManifest } from './processing-compatibility';
const RESULT_CONTRACT_RESULT_TYPES = new Set([ 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([ const RESULT_CONTRACT_EXPOSURE_STATES = new Set([
'detected', 'not_detected', 'not_evaluated', 'execution_error', 'unknown' 'detected', 'not_detected', 'not_evaluated', 'execution_error', 'unknown'

View File

@ -24,7 +24,8 @@ export interface ProcessingVersionManifest extends ToolProcessingContract {
*/ */
export const PROCESSING_VERSION_MANIFESTS: readonly ProcessingVersionManifest[] = [ 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: '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.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: '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 }, { 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 },

View File

@ -20,6 +20,7 @@ export const RESULT_CONTRACT_MAPPING_VERSION = '1.0.0';
export type ResultContractResultType = export type ResultContractResultType =
| 'exposure_finding' | 'exposure_finding'
| 'vulnerability_finding'
| 'compliance_check' | 'compliance_check'
| 'policy_deviation' | 'policy_deviation'
| 'informational' | 'informational'
@ -49,6 +50,9 @@ export interface ResultContractIoeDerivation {
*/ */
const EXPOSURE_TOOL_IDS = new Set(['pingcastle', 'purple-knight', 'maester']); 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` * Werkzeuge, deren native Aussage RESULT_CONTRACT als `compliance_check`
* einordnet. * einordnet.
@ -65,6 +69,7 @@ const COMPLIANCE_TOOL_IDS = new Set(['cis-cat']);
export function deriveResultType(toolId: string): ResultContractResultType { export function deriveResultType(toolId: string): ResultContractResultType {
const normalized = toolId.trim().toLocaleLowerCase('en-US'); const normalized = toolId.trim().toLocaleLowerCase('en-US');
if (EXPOSURE_TOOL_IDS.has(normalized)) return 'exposure_finding'; 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'; if (COMPLIANCE_TOOL_IDS.has(normalized)) return 'compliance_check';
return 'unknown'; return 'unknown';
} }

View File

@ -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'; export type ProfileSignalScope = 'file-name' | 'raw-text' | 'table-name' | 'header' | 'cell-value';
@ -68,6 +68,8 @@ export interface ToolProfileDefinition {
normalization: { normalization: {
severity: Array<ProfileValueMapping<CanonicalSeverity>>; severity: Array<ProfileValueMapping<CanonicalSeverity>>;
status: Array<ProfileValueMapping<CanonicalFindingStatus>>; status: Array<ProfileValueMapping<CanonicalFindingStatus>>;
/** Outcome fuer Finding-Quellen, deren Zeilen selbst der Befund sind und keinen Status liefern. */
missingStatusOutcome?: CanonicalOutcome;
}; };
presentation: { presentation: {
titleHeaders: RegExp[]; titleHeaders: RegExp[];

View File

@ -4,6 +4,8 @@ import type {
CanonicalTechnicalExecutionState CanonicalTechnicalExecutionState
} from '../domain/canonical-model'; } from '../domain/canonical-model';
import type { FindingRecord } from '../domain/finding'; 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 { outcomeFromLegacyStatus, technicalStateFromLegacyStatus } from '../domain/unified-result';
import { toolProfileDefinitions } from '../profiles/profile-definition-catalog'; import { toolProfileDefinitions } from '../profiles/profile-definition-catalog';
import { clean } from '../shared/text'; import { clean } from '../shared/text';
@ -73,9 +75,12 @@ function dimensionsFor(legacy: CanonicalFindingStatus, outcome?: CanonicalOutcom
*/ */
export function normalizeStatusDimensionsFor(toolId: string | undefined, value: string): StatusDimensions { export function normalizeStatusDimensionsFor(toolId: string | undefined, value: string): StatusDimensions {
const cleaned = clean(value); 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))); const declaredMatch = declared?.find(mapping => mapping.patterns.some(pattern => pattern.test(cleaned)));
if (declaredMatch) return dimensionsFor(declaredMatch.normalizedValue); if (declaredMatch) return dimensionsFor(declaredMatch.normalizedValue);
@ -111,6 +116,29 @@ export function toolIdForRecord(record: FindingRecord, fallbackToolId?: string):
return toolIdByName.get(toolName) ?? fallbackToolId; 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. * Outcome eines rohen Statusfeldes, wie es die Arbeitsansichten anzeigen.
* *

View File

@ -5,11 +5,11 @@ export const defenderForIotDefinition: ToolProfileDefinition = {
schemaVersion: 1, schemaVersion: 1,
id: 'defender-for-iot-vulnerabilities', id: 'defender-for-iot-vulnerabilities',
name: 'Microsoft Defender for IoT', name: 'Microsoft Defender for IoT',
version: '1.0.0', version: '1.1.0',
processing: { processing: {
mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.1.0', presentationVersion: '1.0.0',
compatibilityKey: 'defender-for-iot-vulnerabilities-v1', changeClass: 'compatible', compatibilityKey: 'defender-for-iot-vulnerabilities-v2', changeClass: 'semantic-breaking',
reimportRequired: false, affectedDimensions: [] reimportRequired: true, affectedDimensions: ['outcome', 'resultType', 'deviation']
}, },
sourceSignals: [ sourceSignals: [
{ scope: 'header', pattern: /^TotalCVEs$/i, contribution: 0.45, message: 'The asset summary exposes TotalCVEs.' }, { 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: '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.' }] } { 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: { presentation: {
titleHeaders: [/^CVE$/i], titleHeaders: [/^CVE$/i],
severityHeaders: [/^Severity$/i], severityHeaders: [/^Severity$/i],

View File

@ -2,6 +2,7 @@ import type { DatasetSheet } from '../domain/dataset';
import type { FindingRecord } from '../domain/finding'; import type { FindingRecord } from '../domain/finding';
import type { ImportProfile, ProfileDetectionContext } from '../domain/import-profile'; import type { ImportProfile, ProfileDetectionContext } from '../domain/import-profile';
import type { ToolProfileDefinition, WeightedPattern } from '../domain/tool-profile-definition'; import type { ToolProfileDefinition, WeightedPattern } from '../domain/tool-profile-definition';
import { isDeviationRecord } from '../importers/status-normalization';
import { clean, plainTextValue } from '../shared/text'; import { clean, plainTextValue } from '../shared/text';
function matches(pattern: RegExp, value: string): boolean { function matches(pattern: RegExp, value: string): boolean {
@ -24,25 +25,6 @@ function matches(pattern: RegExp, value: string): boolean {
*/ */
const normalizedFieldValuesCache = new WeakMap<FindingRecord, string[]>(); const normalizedFieldValuesCache = new WeakMap<FindingRecord, string[]>();
/**
* 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[] { function normalizedFieldValues(record: FindingRecord): string[] {
let cached = normalizedFieldValuesCache.get(record); let cached = normalizedFieldValuesCache.get(record);
if (!cached) { if (!cached) {
@ -121,11 +103,9 @@ export function createImportProfile(definition: ToolProfileDefinition): ImportPr
}), }),
matchesStatus: (record, status) => { matchesStatus: (record, status) => {
if (status === 'all') return true; if (status === 'all') return true;
if (status === 'deviation') return isDeviationRecord(record, definition.id);
const filter = definition.presentation.statusFilters.find(item => item.value === status); const filter = definition.presentation.statusFilters.find(item => item.value === status);
const values = normalizedFieldValues(record); const values = normalizedFieldValues(record);
if (status === 'deviation' && !filter) {
return values.some(value => UNIVERSAL_DEVIATION_PATTERNS.some(pattern => matches(pattern, value)));
}
if (!filter) return true; if (!filter) return true;
return values.some(value => filter.patterns.some(pattern => matches(pattern, value))); return values.some(value => filter.patterns.some(pattern => matches(pattern, value)));
}, },
@ -136,4 +116,3 @@ export function createImportProfile(definition: ToolProfileDefinition): ImportPr
} }
}; };
} }

View File

@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { CanonicalOutcome } from '../../src/domain/canonical-model'; import type { CanonicalOutcome } from '../../src/domain/canonical-model';
import { DEVIATION_PREDICATE_VERSION, isDeviation } from '../../src/domain/deviation-predicate'; import { DEVIATION_PREDICATE_VERSION, isDeviation } from '../../src/domain/deviation-predicate';
import type { FindingRecord } from '../../src/domain/finding'; 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 { function recordWith(fields: Array<[string, string]>): FindingRecord {
const built = fields.map(([label, value], index) => ({ label, value, index })); 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', () => { 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', () => { it('unterscheidet die drei Maester-Ergebnisse voneinander', () => {
expect(outcomeForStatusValue('maester', 'Passed')).toBe('pass'); expect(outcomeForStatusValue('maester', 'Passed')).toBe('pass');
expect(outcomeForStatusValue('maester', 'Failed')).toBe('fail'); expect(outcomeForStatusValue('maester', 'Failed')).toBe('fail');
@ -80,6 +86,11 @@ describe('Outcome eines rohen Statusfeldes fuer die Arbeitsansichten', () => {
expect(outcomeForStatusValue('maester', ' ')).toBeUndefined(); 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', () => { it('bleibt ohne bekannte Werkzeug-ID ueber die generischen Regeln nutzbar', () => {
expect(outcomeForStatusValue(undefined, 'Investigate')).toBe('warning'); expect(outcomeForStatusValue(undefined, 'Investigate')).toBe('warning');
expect(outcomeForStatusValue(undefined, 'Passed')).toBe('pass'); expect(outcomeForStatusValue(undefined, 'Passed')).toBe('pass');

View File

@ -40,10 +40,16 @@ describe('processing compatibility contracts', () => {
}); });
it('uses the recorded profile when a caller supplies a different detected tool id', () => { 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'); const legacy = file('defender-old', 'defender-for-iot-vulnerabilities', '1.0.0', '1.0.0');
expect(processingReadiness('microsoft-defender-for-iot', defender.processedWith)).toBe('current'); const current = file('defender-current', 'defender-for-iot-vulnerabilities', '1.1.0', '1.0.0');
expect(resolvedCompatibilityKey('microsoft-defender-for-iot', defender.processedWith)) current.processedWith.normalizationVersion = '1.1.0';
.toBe('defender-for-iot-vulnerabilities-v1'); 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', () => { it('blocks semantically incompatible versions in one scope', () => {

View File

@ -19,6 +19,10 @@ describe('RESULT_CONTRACT result_type derivation', () => {
expect(deriveResultType('cis-cat')).toBe('compliance_check'); 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', () => { it('falls back to unknown for unknown or generic tools, never inventing exposure/compliance', () => {
expect(deriveResultType('generic')).toBe('unknown'); expect(deriveResultType('generic')).toBe('unknown');
expect(deriveResultType('some-future-tool')).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); 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', () => { 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_detected').isIoe).toBe(false);
expect(deriveIsIoe('exposure_finding', 'not_evaluated').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', () => { describe('exposure_state only exists for exposure_finding', () => {
it('is undefined for compliance_check, policy_deviation, informational and unknown', () => { it('is undefined for non-exposure result types', () => {
(['compliance_check', 'policy_deviation', 'informational', 'unknown'] as const).forEach(resultType => { (['vulnerability_finding', 'compliance_check', 'policy_deviation', 'informational', 'unknown'] as const).forEach(resultType => {
expect(deriveExposureState(resultType, 'fail')).toBeUndefined(); expect(deriveExposureState(resultType, 'fail')).toBeUndefined();
}); });
}); });

View File

@ -40,9 +40,11 @@ describe('Datahub client processing readiness', () => {
it('assesses the stored profile contract independently from the detected tool identity', () => { it('assesses the stored profile contract independently from the detected tool identity', () => {
const defender = withDetectedTool( 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' 'microsoft-defender-for-iot'
); );
defender.processedWith.mappingVersion = '1.0.0';
defender.processedWith.presentationVersion = '1.0.0';
expect(summarizeScanProcessingReadiness([defender])).toEqual({ expect(summarizeScanProcessingReadiness([defender])).toEqual({
state: 'ready', statuses: ['current'], explanation: 'The scan uses a current or compatible ASSESSOR processing profile.' state: 'ready', statuses: ['current'], explanation: 'The scan uses a current or compatible ASSESSOR processing profile.'
}); });

View File

@ -24,7 +24,9 @@ describe('dynamic facets', () => {
// "Ergebnisstatus" diese Aufgabe. Zwei Filter fuer dieselbe Spalte waren // "Ergebnisstatus" diese Aufgabe. Zwei Filter fuer dieselbe Spalte waren
// fuer die Bedienung nicht unterscheidbar. // fuer die Bedienung nicht unterscheidbar.
expect(facets.some(facet => facet.id === 'field:2')).toBe(false); 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:3')).toBe(false);
expect(facets.some(facet => facet.id === 'field:4')).toBe(false); expect(facets.some(facet => facet.id === 'field:4')).toBe(false);
}); });

View File

@ -9,6 +9,7 @@ import { importProfiles } from '../../src/profiles/profile-catalog';
import { confirmEntryMappings, previewEntryFromDataset } from '../../src/features/import-workflow/import-workflow'; import { confirmEntryMappings, previewEntryFromDataset } from '../../src/features/import-workflow/import-workflow';
import { canonicalModelToDataset } from '../../src/features/project-import/canonical-model-to-dataset'; import { canonicalModelToDataset } from '../../src/features/project-import/canonical-model-to-dataset';
import { summarizeScanProcessingReadiness } from '../../src/features/project-import/datahub-processing-readiness'; 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 summaryHeaders = ['Name', 'IP', 'Site', 'Sensor', 'TotalCVEs', 'Critical', 'High', 'Medium', 'Low'];
const detailHeaders = ['Name', 'IP', 'Site', 'Sensor', 'CVE', 'Severity', 'NULL', 'Beschreibung']; 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 }); 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.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' }); 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. // 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); expect(imported.extraction?.candidates).toHaveLength(1);
@ -151,13 +152,32 @@ describe('Microsoft Defender for IoT vulnerability model', () => {
detectedTool: { id: 'defender-for-iot-vulnerabilities' }, detectedTool: { id: 'defender-for-iot-vulnerabilities' },
processedWith: { processedWith: {
profileId: 'defender-for-iot-vulnerabilities', profileId: 'defender-for-iot-vulnerabilities',
profileVersion: '1.0.0', profileVersion: '1.1.0',
compatibilityKey: 'defender-for-iot-vulnerabilities-v1' normalizationVersion: '1.1.0',
compatibilityKey: 'defender-for-iot-vulnerabilities-v2'
} }
}); });
expect(summarizeScanProcessingReadiness(canonical.importFiles)).toMatchObject({ state: 'ready', statuses: ['current'] }); expect(summarizeScanProcessingReadiness(canonical.importFiles)).toMatchObject({ state: 'ready', statuses: ['current'] });
expect(canonical.findingDefinitions).toHaveLength(1); expect(canonical.findingDefinitions).toHaveLength(1);
expect(canonical.findingOccurrences).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. // 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

@ -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);
});
});

View File

@ -115,6 +115,14 @@ describe('declarative tool profiles', () => {
expect(profile?.matchesStatus(imported.sheets[0].analysis.records[0], 'deviation')).toBe(true); 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 () => { 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); const imported = await importFile(new File(['Alpha;Beta\none;two'], 'CIS-CAT-SYNTHETIC.csv', { type: 'text/csv' }), options);