186 lines
9.5 KiB
TypeScript
186 lines
9.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { FindingOccurrence } from '../../src/domain/canonical-model';
|
|
import type { EnvironmentObservation } from '../../src/domain/environment-context';
|
|
import {
|
|
advanceFindingObligation,
|
|
comparisonObservationTime,
|
|
comparisonScopeIdentity,
|
|
evaluateRemediationEvidence,
|
|
findingComparisonIdentity,
|
|
retireFindingObligation,
|
|
type FindingObligation
|
|
} from '../../src/domain/timeslot-comparison';
|
|
import type { ToolTimeslotComparisonDefinition } from '../../src/domain/tool-profile-definition';
|
|
|
|
const provenance = { decisionSource: 'source-native' as const, confidence: { level: 'high' as const, score: 1, reasons: ['synthetic'] }, mappingVersion: '1' };
|
|
|
|
function occurrence(id: string, ruleId = 'RULE-1', asset = 'Server-A'): FindingOccurrence {
|
|
return {
|
|
id, definitionId: 'definition-rule-1', scanRunId: `run-${id}`, importFileId: `file-${id}`,
|
|
sourceIdentity: { tool: { id: 'scanner', name: 'Synthetic scanner' }, canonicalRuleId: ruleId },
|
|
sourcePosition: { rowIndex: 1 }, originalFields: [], fieldMappings: [],
|
|
severity: { originalValue: 'high', normalizedValue: 'high', provenance },
|
|
status: { originalValue: 'failed', normalizedValue: 'failed', provenance },
|
|
outcome: { originalValue: 'fail', normalizedValue: 'fail', provenance },
|
|
affectedAsset: { originalValue: asset, normalizedValue: asset, provenance }
|
|
};
|
|
}
|
|
|
|
const identity = (item: FindingOccurrence, objectKey = item.affectedAsset?.normalizedValue ?? '') => findingComparisonIdentity({
|
|
occurrence: item,
|
|
granularity: 'asset',
|
|
scope: { kind: 'forest', key: 'example.test' },
|
|
object: { kind: 'host', namespace: 'dns', key: objectKey }
|
|
});
|
|
|
|
const scopeContract: ToolTimeslotComparisonDefinition = {
|
|
version: '1.0.0', kind: 'available', granularity: 'scope',
|
|
scope: { kind: 'host-benchmark', properties: ['host.name', 'benchmark.name'] },
|
|
observationTimeProperties: ['report.generated_at'],
|
|
resultEvidence: 'explicit-results', negativeEvidence: 'unsupported',
|
|
diagnosticPolicy: 'fail-closed-by-impact'
|
|
};
|
|
|
|
function observation(property: string, value: string, id = property): EnvironmentObservation {
|
|
return {
|
|
version: 1, id, scanRunId: 'run-a', importFileId: 'file-a',
|
|
tool: { id: 'scanner', name: 'Synthetic scanner' },
|
|
subject: { type: 'synthetic' }, property, label: property,
|
|
originalValue: value, normalizedValue: value, valueType: 'string', state: 'observed',
|
|
source: {}, mappingVersion: '1', attributes: {}
|
|
};
|
|
}
|
|
|
|
describe('timeslot comparison contract', () => {
|
|
it('builds a scope only from all profile-declared observed properties', () => {
|
|
const result = comparisonScopeIdentity(scopeContract, [
|
|
observation('host.name', 'Server-A'), observation('benchmark.name', 'CIS-1')
|
|
]);
|
|
expect(result.state).toBe('verified');
|
|
if (result.state !== 'verified') return;
|
|
expect(result.scope.kind).toBe('host-benchmark');
|
|
expect(result.scope.key).toContain('server-a');
|
|
expect(result.scope.key).toContain('cis-1');
|
|
});
|
|
|
|
it('fails closed for missing or conflicting scope properties', () => {
|
|
expect(comparisonScopeIdentity(scopeContract, [observation('host.name', 'Server-A')]))
|
|
.toEqual({ state: 'unverified', reasons: ['scope-property-missing'], properties: ['benchmark.name'] });
|
|
expect(comparisonScopeIdentity(scopeContract, [
|
|
observation('host.name', 'Server-A', 'host-a'),
|
|
observation('host.name', 'Server-B', 'host-b'),
|
|
observation('benchmark.name', 'CIS-1')
|
|
])).toEqual({ state: 'unverified', reasons: ['scope-property-ambiguous'], properties: ['host.name'] });
|
|
});
|
|
|
|
it('uses declared report time and never an import timestamp substitute', () => {
|
|
expect(comparisonObservationTime(scopeContract, [])).toEqual({
|
|
state: 'unverified', reason: 'observation-time-missing'
|
|
});
|
|
expect(comparisonObservationTime(scopeContract, [
|
|
observation('report.generated_at', '2026-02-01T10:00:00Z')
|
|
])).toEqual({
|
|
state: 'verified', observedAt: '2026-02-01T10:00:00.000Z', property: 'report.generated_at'
|
|
});
|
|
});
|
|
|
|
it('keeps rule definition and object-specific comparison identity separate', () => {
|
|
const serverA = identity(occurrence('a', 'RULE-1', 'Server-A'));
|
|
const serverB = identity(occurrence('b', 'RULE-1', 'Server-B'));
|
|
expect(serverA.state).toBe('verified');
|
|
expect(serverB.state).toBe('verified');
|
|
if (serverA.state !== 'verified' || serverB.state !== 'verified') return;
|
|
expect(serverA.identity.ruleId).toBe(serverB.identity.ruleId);
|
|
expect(serverA.identity.key).not.toBe(serverB.identity.key);
|
|
});
|
|
|
|
it('does not treat a plain affected-asset display value as verified without kind and namespace', () => {
|
|
const result = findingComparisonIdentity({
|
|
occurrence: occurrence('a'), granularity: 'asset',
|
|
scope: { kind: 'forest', key: 'example.test' }
|
|
});
|
|
expect(result).toEqual({
|
|
state: 'unverified',
|
|
reasons: ['object-kind-missing', 'object-namespace-missing', 'object-key-missing']
|
|
});
|
|
});
|
|
|
|
it('verifies fail-to-pass only for the same identity and a newer compatible observation', () => {
|
|
const baseline = identity(occurrence('a'));
|
|
const sameTarget = identity(occurrence('b'));
|
|
const differentTarget = identity(occurrence('c', 'RULE-1', 'Server-B'));
|
|
const base = {
|
|
baselineIdentity: baseline,
|
|
baselineObservedAt: '2026-01-01T10:00:00.000Z', targetObservedAt: '2026-02-01T10:00:00.000Z',
|
|
processingCompatible: true, coverage: 'complete' as const, absenceSemantics: 'not-reported' as const
|
|
};
|
|
expect(evaluateRemediationEvidence({ ...base, targetIdentity: sameTarget, targetOutcome: 'pass' }).state)
|
|
.toBe('verified-remediated');
|
|
expect(evaluateRemediationEvidence({ ...base, targetIdentity: differentTarget, targetOutcome: 'pass' }))
|
|
.toMatchObject({ state: 'unverified-absent', reasons: ['comparison-identity-changed'] });
|
|
expect(evaluateRemediationEvidence({ ...base, targetIdentity: sameTarget, targetOutcome: 'fail' }).state)
|
|
.toBe('confirmed-open');
|
|
});
|
|
|
|
it('keeps absence unresolved unless the exact identity is covered by explicit complete negative evidence', () => {
|
|
const baseline = identity(occurrence('a'));
|
|
if (baseline.state !== 'verified') throw new Error('Synthetic identity must be valid.');
|
|
const base = {
|
|
baselineIdentity: baseline,
|
|
baselineObservedAt: '2026-01-01T10:00:00.000Z', targetObservedAt: '2026-02-01T10:00:00.000Z',
|
|
processingCompatible: true, targetOutcome: undefined
|
|
};
|
|
expect(evaluateRemediationEvidence({
|
|
...base, coverage: 'partial', coveredIdentityKeys: [baseline.identity.key], absenceSemantics: 'explicit-negative'
|
|
}).state).toBe('unverified-absent');
|
|
expect(evaluateRemediationEvidence({
|
|
...base, coverage: 'complete', coveredIdentityKeys: [], absenceSemantics: 'explicit-negative'
|
|
}).state).toBe('unverified-absent');
|
|
expect(evaluateRemediationEvidence({
|
|
...base, coverage: 'complete', coveredIdentityKeys: [baseline.identity.key], absenceSemantics: 'not-reported'
|
|
}).state).toBe('unverified-absent');
|
|
expect(evaluateRemediationEvidence({
|
|
...base, coverage: 'complete', coveredIdentityKeys: [baseline.identity.key], absenceSemantics: 'explicit-negative'
|
|
}).state).toBe('verified-remediated');
|
|
});
|
|
|
|
it('treats result-impacting diagnostics and a mere reimport timestamp as unresolved', () => {
|
|
const baseline = identity(occurrence('a'));
|
|
const target = identity(occurrence('b'));
|
|
const common = {
|
|
baselineIdentity: baseline, targetIdentity: target, processingCompatible: true,
|
|
baselineObservedAt: '2026-01-01T10:00:00.000Z', targetOutcome: 'pass' as const,
|
|
coverage: 'complete' as const, absenceSemantics: 'not-reported' as const
|
|
};
|
|
expect(evaluateRemediationEvidence({ ...common, targetObservedAt: '2026-01-01T10:00:00.000Z' }).state)
|
|
.toBe('unverified-absent');
|
|
expect(evaluateRemediationEvidence({
|
|
...common, targetObservedAt: '2026-02-01T10:00:00.000Z', blockingDiagnostics: ['coverage-unknown']
|
|
}).state).toBe('unverified-absent');
|
|
});
|
|
|
|
it('preserves obligation origin across unresolved follow-ups and requires audited retirement', () => {
|
|
const verified = identity(occurrence('a'));
|
|
if (verified.state !== 'verified') throw new Error('Synthetic identity must be valid.');
|
|
const obligation: FindingObligation = {
|
|
version: '1.0.0', id: 'obligation-1', projectId: 'project-1', identity: verified.identity,
|
|
originAssessmentId: 'assessment-a', originRunId: 'run-a', originOccurrenceId: 'a',
|
|
originObservedAt: '2026-01-01T10:00:00.000Z', lastObservedAt: '2026-01-01T10:00:00.000Z',
|
|
state: 'open-confirmed', stateReason: 'initial deviation'
|
|
};
|
|
const pending = advanceFindingObligation(obligation, {
|
|
version: '1.0.0', state: 'unverified-absent', identityKey: verified.identity.key,
|
|
reasons: ['target-coverage-not-complete']
|
|
}, undefined, '2026-02-01T10:00:00.000Z');
|
|
expect(pending).toMatchObject({
|
|
state: 'awaiting-revalidation', originOccurrenceId: 'a',
|
|
originObservedAt: '2026-01-01T10:00:00.000Z', lastObservedAt: '2026-02-01T10:00:00.000Z'
|
|
});
|
|
expect(() => retireFindingObligation(pending, { actor: '', reason: 'scope ended', effectiveAt: '2026-03-01' }))
|
|
.toThrow(/actor/);
|
|
expect(retireFindingObligation(pending, {
|
|
actor: 'synthetic-owner', reason: 'approved scope exit', effectiveAt: '2026-03-01T00:00:00.000Z'
|
|
})).toMatchObject({ state: 'retired-out-of-scope', retirement: { actor: 'synthetic-owner' } });
|
|
});
|
|
});
|