192 lines
13 KiB
TypeScript
192 lines
13 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { FindingOccurrence, JsonValue } from '../../src/domain/canonical-model';
|
|
import {
|
|
appendScopedRiskAcceptance,
|
|
defenderRiskCoverageDecisions,
|
|
defenderRiskTargetsForOccurrence,
|
|
defenderRiskTargetsForOccurrences,
|
|
scopedAcceptanceCovers,
|
|
scopedRiskAcceptanceState,
|
|
scopedRiskAcceptanceStateForTargets,
|
|
scopedRiskTargetFromOccurrences,
|
|
withCapturedScopeStatus
|
|
} from '../../src/domain/scoped-risk-acceptance';
|
|
import { appendRiskAcceptanceReview } from '../../src/domain/risk-acceptance';
|
|
import { riskCommand } from './risk-acceptance-fixture';
|
|
|
|
const now = '2026-09-21T08:00:00.000Z';
|
|
const confidence = { level: 'high' as const, score: 1, reasons: ['synthetic'] };
|
|
const provenance = { decisionSource: 'source-native' as const, confidence, mappingVersion: '1.0.0' };
|
|
|
|
function observation(deviceId: string, cve: string, name = deviceId, ip = '192.0.2.10') {
|
|
return {
|
|
name, ip, site: 'Synthetic site', sensor: 'sensor-a', cve, severity: 'High', description: 'Synthetic finding',
|
|
additionalSourceFields: deviceId ? { 'Device ID': deviceId } : {}, source: { tableIndex: 1, rowIndex: 2 }
|
|
};
|
|
}
|
|
|
|
function occurrence(id: string, definitionId: string, cve: string, observations: ReturnType<typeof observation>[], scanRunId = 'run-1'): FindingOccurrence {
|
|
return {
|
|
id, definitionId, scanRunId, importFileId: `file-${scanRunId}`, sourceTableId: `table-${scanRunId}`,
|
|
sourceIdentity: {
|
|
tool: { id: 'defender-for-iot-vulnerabilities', name: 'Microsoft Defender for IoT', version: '1.2.0' },
|
|
originalRuleId: cve, canonicalRuleId: cve, originalTitle: cve
|
|
},
|
|
sourcePosition: { rowIndex: 2 },
|
|
originalFields: [{ key: 'source-observations', label: 'Source Observations', value: observations as JsonValue, columnIndex: 0 }],
|
|
fieldMappings: [],
|
|
severity: { originalValue: 'High', normalizedValue: 'high', provenance },
|
|
status: { originalValue: 'Open', normalizedValue: 'open', provenance }
|
|
};
|
|
}
|
|
|
|
describe('device-scoped Defender risk acceptance', () => {
|
|
it('projects stable targets and isolates one CVE across multiple devices', () => {
|
|
const source = occurrence('occurrence-1', 'definition-cve-1', 'CVE-2026-1001', [
|
|
observation('DEV-A', 'CVE-2026-1001', 'Device A', '192.0.2.10'),
|
|
observation('DEV-B', 'CVE-2026-1001', 'Device B', '192.0.2.20')
|
|
]);
|
|
|
|
const projected = defenderRiskTargetsForOccurrence(source);
|
|
expect(projected).toEqual([
|
|
expect.objectContaining({ definitionId: 'definition-cve-1', cve: 'CVE-2026-1001', device: expect.objectContaining({ key: 'device-id:dev-a', identityMethod: 'device-id', identityConfidence: 'high' }), eligible: true }),
|
|
expect.objectContaining({ definitionId: 'definition-cve-1', cve: 'CVE-2026-1001', device: expect.objectContaining({ key: 'device-id:dev-b' }), eligible: true })
|
|
]);
|
|
const target = scopedRiskTargetFromOccurrences('device-finding', 'device-id:dev-a', [source], now);
|
|
expect(target.members).toEqual([expect.objectContaining({
|
|
findingDefinitionId: 'definition-cve-1', cve: 'CVE-2026-1001',
|
|
capturedOccurrences: [expect.objectContaining({ occurrenceId: 'occurrence-1', scanRunId: 'run-1', importFileId: 'file-run-1', sourceTableId: 'table-run-1', sourcePosition: { rowIndex: 2 } })]
|
|
})]);
|
|
const pair = appendScopedRiskAcceptance(undefined, 'project-1', 'pair-1', target, riskCommand('accept', '2026-09-21'), now);
|
|
expect(defenderRiskCoverageDecisions(projected[0], undefined, [pair], '2026-09-21')).toHaveLength(1);
|
|
expect(defenderRiskCoverageDecisions(projected[1], undefined, [pair], '2026-09-21')).toHaveLength(0);
|
|
const whole = appendRiskAcceptanceReview(undefined, source.id, riskCommand('accept', '2026-09-21'), now).riskAcceptance;
|
|
expect(defenderRiskCoverageDecisions(projected[0], whole, [], '2026-09-21')).toHaveLength(1);
|
|
expect(defenderRiskCoverageDecisions(projected[1], whole, [], '2026-09-21')).toHaveLength(1);
|
|
});
|
|
|
|
it('captures every current CVE once and rejects unresolved or ambiguous device identities', () => {
|
|
const cve1 = occurrence('occurrence-1', 'definition-cve-1', 'CVE-2026-1001', [observation('DEV-A', 'CVE-2026-1001', 'Device A')]);
|
|
const cve2 = occurrence('occurrence-2', 'definition-cve-2', 'CVE-2026-1002', [observation('DEV-A', 'CVE-2026-1002', 'Device A')]);
|
|
const duplicate = occurrence('occurrence-3', 'definition-cve-1', 'CVE-2026-1001', [observation('DEV-A', 'CVE-2026-1001', 'Device A')], 'run-2');
|
|
const target = scopedRiskTargetFromOccurrences('device-snapshot', 'device-id:dev-a', [cve1, cve2, duplicate], now);
|
|
expect(target.members.map(item => item.cve)).toEqual(['CVE-2026-1001', 'CVE-2026-1002']);
|
|
expect(target.members[0].capturedOccurrences.map(item => item.occurrenceId)).toEqual(['occurrence-1', 'occurrence-3']);
|
|
expect(target.capturedScanRunIds).toEqual(['run-1', 'run-2']);
|
|
|
|
const unresolvedObservation = { ...observation('', 'CVE-2026-2001', '', ''), site: '' };
|
|
const unresolved = occurrence('unresolved', 'definition-unresolved', 'CVE-2026-2001', [unresolvedObservation]);
|
|
const unresolvedTarget = defenderRiskTargetsForOccurrence(unresolved)[0];
|
|
expect(unresolvedTarget).toMatchObject({ eligible: false, unavailableReason: expect.stringContaining('stable device identity') });
|
|
expect(() => scopedRiskTargetFromOccurrences('device-finding', unresolvedTarget.device.key, [unresolved], now)).toThrow('stable device identity');
|
|
|
|
const ambiguous = occurrence('ambiguous', 'definition-ambiguous', 'CVE-2026-2002', [
|
|
observation('DEV-X', 'CVE-2026-2002', 'Device X', '192.0.2.30'),
|
|
observation('DEV-X', 'CVE-2026-2002', 'Different device', '192.0.2.31')
|
|
]);
|
|
expect(defenderRiskTargetsForOccurrence(ambiguous)[0]).toMatchObject({ eligible: false, unavailableReason: expect.stringContaining('conflicting identity') });
|
|
expect(() => scopedRiskTargetFromOccurrences('device-finding', 'device-id:dev-x', [ambiguous], now)).toThrow('conflicting identity');
|
|
|
|
const crossCveConflict = [
|
|
occurrence('cross-1', 'cross-definition-1', 'CVE-2026-3001', [observation('DEV-Y', 'CVE-2026-3001', 'Device Y', '192.0.2.40')]),
|
|
occurrence('cross-2', 'cross-definition-2', 'CVE-2026-3002', [observation('DEV-Y', 'CVE-2026-3002', 'Different Y', '192.0.2.41')])
|
|
];
|
|
expect(defenderRiskTargetsForOccurrences(crossCveConflict).every(item => !item.eligible)).toBe(true);
|
|
expect(() => scopedRiskTargetFromOccurrences('device-snapshot', 'device-id:dev-y', crossCveConflict, now)).toThrow('conflicting identity');
|
|
});
|
|
|
|
it('keeps same-CVE devices attached to their own source occurrence', () => {
|
|
const first = occurrence('occurrence-a', 'definition-cve-1', 'CVE-2026-1001', [
|
|
observation('DEV-A', 'CVE-2026-1001', 'Device A')
|
|
]);
|
|
const second = occurrence('occurrence-b', 'definition-cve-1', 'CVE-2026-1001', [
|
|
observation('DEV-B', 'CVE-2026-1001', 'Device B')
|
|
]);
|
|
|
|
expect(defenderRiskTargetsForOccurrences([first, second]).map(item => [item.occurrenceId, item.device.key])).toEqual([
|
|
['occurrence-a', 'device-id:dev-a'],
|
|
['occurrence-b', 'device-id:dev-b']
|
|
]);
|
|
expect(() => scopedRiskTargetFromOccurrences('device-finding', 'device-id:dev-b', [first, second], now, new Set([first.id])))
|
|
.toThrow('not an actionable member');
|
|
});
|
|
|
|
it('derives decision state, preserves overlaps, and prevents target mutation', () => {
|
|
const cve1 = occurrence('occurrence-1', 'definition-cve-1', 'CVE-2026-1001', [observation('DEV-A', 'CVE-2026-1001')]);
|
|
const cve2 = occurrence('occurrence-2', 'definition-cve-2', 'CVE-2026-1002', [observation('DEV-A', 'CVE-2026-1002')]);
|
|
const pairTarget = scopedRiskTargetFromOccurrences('device-finding', 'device-id:dev-a', [cve1], now);
|
|
const snapshotTarget = scopedRiskTargetFromOccurrences('device-snapshot', 'device-id:dev-a', [cve1, cve2], now);
|
|
const pair = appendScopedRiskAcceptance(undefined, 'project-1', 'pair-1', pairTarget, riskCommand('accept', '2026-09-21'), now);
|
|
const snapshot = appendScopedRiskAcceptance(undefined, 'project-1', 'snapshot-1', snapshotTarget, riskCommand('accept', '2026-09-21'), now);
|
|
const target1 = defenderRiskTargetsForOccurrence(cve1)[0];
|
|
const target2 = defenderRiskTargetsForOccurrence(cve2)[0];
|
|
|
|
expect(scopedRiskAcceptanceState(pair, '2026-09-21')).toBe('Accepted');
|
|
const draft = appendScopedRiskAcceptance(undefined, 'project-1', 'draft-1', pairTarget, riskCommand('draft', '2026-09-21'), now);
|
|
expect(scopedRiskAcceptanceState(draft, '2026-09-21')).toBe('Draft');
|
|
const scheduledCommand = riskCommand('accept', '2026-09-21');
|
|
scheduledCommand.details.validFrom = '2026-09-22';
|
|
const scheduled = appendScopedRiskAcceptance(undefined, 'project-1', 'scheduled-1', pairTarget, scheduledCommand, now);
|
|
expect(scopedRiskAcceptanceState(scheduled, '2026-09-21')).toBe('Scheduled');
|
|
const reviewDue = structuredClone(pair);
|
|
reviewDue.revisions[0].details.reviewOn = '2026-09-21';
|
|
expect(scopedRiskAcceptanceState(reviewDue, '2026-09-21')).toBe('Review due');
|
|
const expired = structuredClone(pair);
|
|
expired.revisions[0].details.expiresOn = '2026-09-20';
|
|
expect(scopedRiskAcceptanceState(expired, '2026-09-21')).toBe('Expired');
|
|
expect(scopedAcceptanceCovers(pair, target1, '2026-09-21')).toBe(true);
|
|
expect(scopedAcceptanceCovers(pair, target2, '2026-09-21')).toBe(false);
|
|
expect(scopedAcceptanceCovers(snapshot, target1, '2026-09-21')).toBe(true);
|
|
expect(scopedAcceptanceCovers(snapshot, target2, '2026-09-21')).toBe(true);
|
|
|
|
const revokedPair = appendScopedRiskAcceptance(pair, 'project-1', pair.id, pair.target, riskCommand('revoke', '2026-09-21'), '2026-09-21T09:00:00.000Z');
|
|
expect(scopedAcceptanceCovers(revokedPair, target1, '2026-09-21')).toBe(false);
|
|
expect(scopedAcceptanceCovers(snapshot, target1, '2026-09-21')).toBe(true);
|
|
expect(() => appendScopedRiskAcceptance(pair, 'project-1', pair.id, snapshotTarget, riskCommand('draft', '2026-09-21'), now)).toThrow('cannot be retargeted');
|
|
});
|
|
|
|
it('covers only the captured occurrence and needs review on a later import, like a whole-finding acceptance', () => {
|
|
const original = occurrence('occurrence-old', 'definition-cve-1', 'CVE-2026-1001', [observation('DEV-A', 'CVE-2026-1001')]);
|
|
const target = scopedRiskTargetFromOccurrences('device-snapshot', 'device-id:dev-a', [original], now);
|
|
const acceptance = appendScopedRiskAcceptance(undefined, 'project-1', 'snapshot-1', target, riskCommand('accept', '2026-09-21'), now);
|
|
const laterSame = defenderRiskTargetsForOccurrence(occurrence('occurrence-new', 'definition-cve-1', 'CVE-2026-1001', [observation('DEV-A', 'CVE-2026-1001')], 'run-2'))[0];
|
|
const laterNewCve = defenderRiskTargetsForOccurrence(occurrence('occurrence-new-cve', 'definition-cve-2', 'CVE-2026-1002', [observation('DEV-A', 'CVE-2026-1002')], 'run-2'))[0];
|
|
const changedDevice = defenderRiskTargetsForOccurrence(occurrence('occurrence-changed-device', 'definition-cve-1', 'CVE-2026-1001', [observation('DEV-B', 'CVE-2026-1001')], 'run-2'))[0];
|
|
|
|
const captured = defenderRiskTargetsForOccurrence(original)[0];
|
|
|
|
expect(scopedAcceptanceCovers(acceptance, captured, '2026-09-21')).toBe(true);
|
|
expect(scopedRiskAcceptanceStateForTargets(acceptance, [captured], '2026-09-21')).toBe('Accepted');
|
|
// Same device and CVE through a replacement occurrence: kept, but never silently re-attached.
|
|
expect(scopedAcceptanceCovers(acceptance, laterSame, '2026-09-21')).toBe(false);
|
|
expect(scopedRiskAcceptanceStateForTargets(acceptance, [laterSame], '2026-09-21')).toBe('Review required');
|
|
expect(scopedAcceptanceCovers(acceptance, laterNewCve, '2026-09-21')).toBe(false);
|
|
expect(scopedAcceptanceCovers(acceptance, changedDevice, '2026-09-21')).toBe(false);
|
|
});
|
|
|
|
it('marks a decision whose captured scan run was replaced as Review required without coverage', () => {
|
|
const original = occurrence('occurrence-old', 'definition-cve-1', 'CVE-2026-1001', [observation('DEV-A', 'CVE-2026-1001')]);
|
|
const target = scopedRiskTargetFromOccurrences('device-finding', 'device-id:dev-a', [original], now);
|
|
const acceptance = appendScopedRiskAcceptance(undefined, 'project-1', 'pair-1', target, riskCommand('accept', '2026-09-21'), now);
|
|
const captured = defenderRiskTargetsForOccurrence(original)[0];
|
|
|
|
const current = withCapturedScopeStatus(acceptance, new Set(['run-1']));
|
|
expect(current.capturedScopeReplaced).toBe(false);
|
|
expect(scopedRiskAcceptanceState(current, '2026-09-21')).toBe('Accepted');
|
|
|
|
const replaced = withCapturedScopeStatus(acceptance, new Set(['run-2']));
|
|
expect(replaced.capturedScopeReplaced).toBe(true);
|
|
expect(scopedRiskAcceptanceState(replaced, '2026-09-21')).toBe('Review required');
|
|
expect(scopedAcceptanceCovers(replaced, captured, '2026-09-21')).toBe(false);
|
|
expect(defenderRiskCoverageDecisions(captured, undefined, [replaced], '2026-09-21')).toEqual([]);
|
|
// The history is kept and a revoked decision stays revoked.
|
|
expect(replaced.revisions).toEqual(acceptance.revisions);
|
|
const revoked = withCapturedScopeStatus(
|
|
appendScopedRiskAcceptance(acceptance, 'project-1', 'pair-1', target, riskCommand('revoke', '2026-09-21'), now), new Set(['run-2']));
|
|
expect(scopedRiskAcceptanceState(revoked, '2026-09-21')).toBe('Revoked');
|
|
// The marker is derived on read and never becomes part of the stored history.
|
|
expect(appendScopedRiskAcceptance(replaced, 'project-1', 'pair-1', target, riskCommand('draft', '2026-09-21'), now))
|
|
.not.toHaveProperty('capturedScopeReplaced');
|
|
});
|
|
});
|