feat(defender-for-iot): introduce vulnerability finding result type and update related schemas and logic
This commit is contained in:
parent
045d5b31cc
commit
24893f965a
|
|
@ -19,6 +19,7 @@ import scannerEnvironmentContext from './migrations/018_scanner_environment_cont
|
|||
import nativePolicyTargets from './migrations/019_native_policy_targets.sql?raw';
|
||||
import baselineDrivenPolicyEvaluation from './migrations/020_baseline_driven_policy_evaluation.sql?raw';
|
||||
import cveEnrichment from './migrations/021_cve_enrichment.sql?raw';
|
||||
import vulnerabilityFindingResultType from './migrations/022_vulnerability_finding_result_type.sql?raw';
|
||||
|
||||
export interface DatabaseMigration {
|
||||
version: string;
|
||||
|
|
@ -47,5 +48,6 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
|
|||
{ version: '018', name: 'scanner-environment-context', sql: scannerEnvironmentContext },
|
||||
{ version: '019', name: 'native-policy-targets', sql: nativePolicyTargets },
|
||||
{ version: '020', name: 'baseline-driven-policy-evaluation', sql: baselineDrivenPolicyEvaluation },
|
||||
{ version: '021', name: 'cve-enrichment', sql: cveEnrichment }
|
||||
{ version: '021', name: 'cve-enrichment', sql: cveEnrichment },
|
||||
{ version: '022', name: 'vulnerability-finding-result-type', sql: vulnerabilityFindingResultType }
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
);
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
@ -159,6 +159,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[];
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -126,9 +126,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.'
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,7 +25,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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue