feat(defender-for-iot): introduce vulnerability finding result type

Adapted pick of fe71246. Adds the vulnerability_finding result type,
missingStatusOutcome for sources whose rows are the finding, and Defender
profile 1.1.0. The Work deviation filter uses the canonical predicate
through isDeviationRecord; Michael's GPO status logic and status cache
stay in front of it.

The constraint migration is renumbered from 022 to 027. The CVE
enrichment commit e048295 is skipped for now.
This commit is contained in:
Binh 2026-10-02 12:39:04 +02:00
parent 426a138e55
commit 550947d3b1
18 changed files with 163 additions and 45 deletions

View File

@ -4462,6 +4462,7 @@ Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client
| 2026-10-06 | Phase 7: Data Hub Deviation/Passed now use the same assessed-percentage helper as Client Info; all six donuts state their population basis. 29 focused tests and synthetic Chrome Dark/Light checks passed; absolute counts, other population shares and assessment semantics retained. Client build, design and UI/API version checks passed. Nine out-of-scope report test failures, a report Local-only failure and report server type errors prevent full release verification; attempted 2.16.9 reverted to 2.16.8 pending successful production builds. |
| 2026-10-06 | Phase 7: Client Info Deviations percentage now divides by Passed + Deviations and labels Of assessed checks; zero assessed shows a dash while preserving absolute zero. Permanent UI/click invariance tests in both themes and synthetic Chrome presentation verified; unchanged counts, verdicts, scores and other donuts. Patch 2.16.8: both builds and version contracts passed; full run 1,936 passed/15 skipped with one stale API-version assertion corrected and passed in the 43-test focused rerun; Local-only/design checks passed. |
| 2026-10-06 | Phase 13B: GPO folder read diagnostics retain only safe error names, stages and counts, distinguish empty/denied/unreadable selections, preserve cancellation and wrap complete timeslot messages. 27 focused GPO UI tests and synthetic Chrome Dark/Light checks passed; both builds and version contracts verified for patch 2.16.7. Full run: 1,932 passed, 15 skipped, one general import timeout passed on separate rerun; Local-only/design checks passed. Customer-specific read failure remains unconfirmed; no customer path/data used, Phase 13B remains in progress. |
| 2026-10-02 | Phasen 6/13A: `fe71246` uebernommen; `e048295` (NVD-CVE-Anreicherung) auf Nutzerentscheidung vorerst ausgelassen. Der Result-Contract kennt den Typ `vulnerability_finding`; Defender-Findings erhalten ihn. Ein Profil kann `missingStatusOutcome` deklarieren, Defender setzt `warning`, weil jede CVE-Zeile selbst der Befund ist. Das Defender-Profil steigt auf 1.1.0 mit Kompatibilitaetsschluessel `-v2` (semantic-breaking, Reimport erforderlich; die lokale Datenbank enthaelt keine Defender-Importe), 1.0.0 ist nicht mehr aktuell. Der Work-Filter `Deviation` aller Scanner nutzt statt der Regex-Liste `UNIVERSAL_DEVIATION_PATTERNS` nun `isDeviationRecord` und damit das kanonische Result-DB-Praedikat; die GPO-Statuslogik, der Statuscache und die uebrigen Statusfilter der Basis bleiben unveraendert davor. Alle bestehenden Work-, Filter- und Scannertests bestehen unveraendert; eine vollstaendige Gleichwertigkeit fuer jeden realen Statuswert ist damit nicht bewiesen. Migration `027_vulnerability_finding_result_type.sql` (frueher 022) erweitert nur den Check-Constraint und schreibt keine Occurrences um; ohne die ausgelassene CVE-Tabelle. Vor der Anwendung wurde das verifizierte Backup `backups/assessor-pre-027-20261002-123608.dump` erstellt. Verifikation: beide Typechecks, Gesamtlauf mit 1.890 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
| 2026-10-02 | Phasen 6/7/13A: `5fbbda2` uebernommen. Der Defender-Extractor erzeugt nun ein Finding je CVE statt je Device-mal-CVE und liefert `Affected Device Count` sowie die Tabelle `Affected Devices` (Device, Scanner, Site); das Profil bildet die Anzahl auf die Asset-Rolle ab, nutzt `Affected Devices` als Ergebnisabschnitt und vergleicht nur noch ueber die CVE. Der Finding Reader blendet das Rohfeld `Source Observations` fuer Defender aus, es bleibt als Provenienz im Datensatz. Nicht uebernommen: die Umbenennung der Asset-Zeile im Result-DB-Zeilendetail, weil die Disclosure-Policy `affectedAsset` fuer jede Zeile zurueckhaelt und eine Device-Anzahl dort nicht erscheinen soll. Die Finding-Identitaet aendert sich wie im Original ohne Profilversionssprung; die neu angelegte lokale Datenbank enthaelt keine Defender-Importe, der Versionssprung folgt mit `c7c716c`. Keine API-, SQL- oder Persistenzmigration. Verifikation: beide Typechecks, Gesamtlauf mit 1.883 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
| 2026-10-02 | Phasen 6/7/13A: `7d3ef5f` (Microsoft Defender for IoT, Grundlage) auf Michaels Basis uebernommen. Profil `defender-for-iot-vulnerabilities` 1.0.0 mit Manifestzeile, Source Extractor, Domain-Modell, Client-Info-Panel sowie Titel je Datensatzprofil (`recordTitleFor`) in Work und Pathfinder. Angepasst: Das Profil deklariert die auf dieser Basis verpflichtenden `securityScoring` (kein nativer Gesamtscore, mit Begruendung), `priorityBasis` (native CVE-Severity) und `timeslotComparison` (nicht verfuegbar). Client Info fuehrt Defender als dritten Tab neben Overview und AI Overview; ein reiner Defender-Timeslot startet in der Defender-Sicht. `recordTitleFor` ersetzt nur den Titelkandidaten, die Maester-/CIS-Titelregeln bleiben. `canonicalModelsToDataset` laesst zusaetzlich Modelle mit befuellten Quelltabellen zu, das leere All-Records-Blatt bleibt bestehen. Die Kompatibilitaetspruefung bevorzugt den gespeicherten `processedWith.profileId`; der sichtbare Importvertrag steigt auf 1.4.0. Die kopierten `.client-info-tabs`-Regeln sind aus `ai-overview.css` entfernt und liegen nur noch in `defender-for-iot.css`. Nicht uebernommen: `.gitignore`-Eintrag, `--tile-inset`, der bereits vorhandene `fileProfile`-Vorrang und die alte Versionspruefung. Neues Profil, daher kein Reimport bestehender Importe; keine API-, SQL- oder Persistenzmigration. Die Produktversion wird am Ende der Defender-Serie einmal neu bewertet. Verifikation: beide Typechecks, Gesamtlauf mit 1.883 bestandenen und 17 uebersprungenen Tests, Local-only-/Designpruefung und beide Produktionsbuilds erfolgreich. |
| 2026-10-02 | Phase 7/13F: Aus `95ec322` die AI-Overview- und ISO-Mapping-Korrekturen uebernommen. Schlaegt eine Regeneration fehl oder laeuft sie noch, liefert der AI-Overview-Service zusaetzlich `previousSuccessfulRun`; das Panel zeigt die letzte gespeicherte Uebersicht mit der Fehlermeldung weiter, `Stale` gilt nur fuer einen erfolgreichen, veralteten Lauf. Ein gespeichertes ISO-Mapping bleibt lesbar, wenn Qdrant oder der Embeddingdienst nicht erreichbar ist; die Generierung bleibt dann deaktiviert. Synthetische Service- und Paneltests. Die Defender-Geraete-Risikoakzeptanz desselben Commits folgt mit den Defender-Commits. |

View File

@ -22,6 +22,7 @@ import referenceRecognition from './migrations/023_reference_recognition.sql?raw
import resultDbMaterialization from './migrations/024_result_db_materialization.sql?raw';
import aiOverviewRuns from './migrations/025_ai_overview_runs.sql?raw';
import regulatoryMapping from './migrations/026_regulatory_mapping.sql?raw';
import vulnerabilityFindingResultType from './migrations/027_vulnerability_finding_result_type.sql?raw';
export interface DatabaseMigration {
version: string;
@ -53,5 +54,6 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
{ version: '023', name: 'reference-recognition', sql: referenceRecognition },
{ version: '024', name: 'result-db-materialization', sql: resultDbMaterialization },
{ version: '025', name: 'ai-overview-runs', sql: aiOverviewRuns },
{ version: '026', name: 'regulatory-mapping', sql: regulatoryMapping }
{ version: '026', name: 'regulatory-mapping', sql: regulatoryMapping },
{ version: '027', 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';
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'

View File

@ -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: false },
{ profileId: 'purple-knight', profileVersion: '1.2.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.1.0', compatibilityKey: 'purple-knight-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context'], current: false },

View File

@ -21,6 +21,7 @@ export const RESULT_CONTRACT_MAPPING_VERSION = '1.0.0';
export type ResultContractResultType =
| 'exposure_finding'
| 'vulnerability_finding'
| 'compliance_check'
| 'policy_deviation'
| 'informational'
@ -50,6 +51,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.
@ -66,6 +70,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';
}

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

View File

@ -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';
@ -80,9 +82,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);
@ -118,6 +123,29 @@ export function toolIdForRecord(record: FindingRecord, fallbackToolId?: string):
return toolName === 'gpos' ? 'group-policy' : 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.
*

View File

@ -20,11 +20,11 @@ export const defenderForIotDefinition: ToolProfileDefinition = {
reason: 'The Defender for IoT vulnerability export is a point-in-time CVE inventory without a stable scan scope or observation time for timeslot comparison.'
},
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.' },
@ -47,7 +47,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],

View File

@ -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<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[] {
let cached = normalizedFieldValuesCache.get(record);
if (!cached) {
@ -125,6 +107,7 @@ export function createImportProfile(definition: ToolProfileDefinition): ImportPr
}
if (status === 'no-baseline-rule') return actualStatus === 'no baseline rule';
if (status === 'not-comparable') return actualStatus === 'not comparable';
if (status === 'deviation') return isDeviationRecord(record, definition.id);
const filter = definition.presentation.statusFilters.find(item => item.value === status);
const statusHeaders = definition.presentation.statusHeaders;
const preferredStatus = statusHeaders?.map(pattern => record.fields.find(field => field.value.trim() && matches(pattern, field.label.trim())))
@ -132,9 +115,6 @@ export function createImportProfile(definition: ToolProfileDefinition): ImportPr
const values = statusHeaders?.length
? preferredStatus ? [plainTextValue(preferredStatus.value).trim().toLowerCase()] : []
: 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)));
};

View File

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

View File

@ -42,10 +42,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', () => {

View File

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

View File

@ -125,9 +125,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.'
});

View File

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

View File

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

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',
'027_vulnerability_finding_result_type.sql'
);
describe('PostgreSQL vulnerability finding result type migration', () => {
it('registers migration 027 after regulatory mapping', () => {
expect(databaseMigrations.at(-1)).toMatchObject({
version: '027',
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

@ -185,6 +185,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);