Assessor_2.0/tests/assessment-context/context-gaps.test.ts

72 lines
4.8 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { RELEASED_ASSESSMENT_CONTEXT_CATALOG as catalog } from '../../src/assessment-context/context-catalog';
import { buildAssessmentContext } from '../../src/assessment-context/context-engine';
import { contextGapReport, contextSuggestionApprovalText } from '../../src/assessment-context/context-gaps';
// User decision 2026-10-01: a gap list per tool with vocabulary suggestions that never contradict the catalogue.
const finding = (occurrence: string, tool: string, outcome: string, title: string, ruleId = `RULE-${occurrence}`) => ({
resultProjectionId: `scanner-finding:${occurrence}`, sourceKind: 'scanner-finding', sourceResultId: occurrence, scanRunId: 'run-a',
sourceTool: tool, sourceRuleId: ruleId, outcomeNormalized: outcome, title
}) as unknown as UnifiedResultProjection;
const build = (scannerFindings: UnifiedResultProjection[]) => buildAssessmentContext({
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-10-01T08:00:00Z', repositoryRevision: 1,
observations: [], analyses: [], catalog, scannerFindings
});
const catalogued = catalog.scannerRules.find(rule => rule.tool === 'pingcastle')!;
describe('context gap report', () => {
const context = build([
finding('occ-catalogued', 'pingcastle', 'fail', catalogued.ruleText, catalogued.ruleId),
finding('occ-recognized', 'purple-knight', 'fail', 'Users with SPN defined'),
finding('occ-gap-1', 'purple-knight', 'fail', 'Synthetic quarterly widget drift'),
finding('occ-gap-2', 'purple-knight', 'fail', 'Synthetic widget inventory mismatch'),
finding('occ-passed', 'purple-knight', 'pass', 'Synthetic passed rule')
]);
// The vocabulary would recognize the spooler; for the gap list it is switched off so the term surfaces as a suggestion.
const withoutVocabulary = buildAssessmentContext({
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-10-01T08:00:00Z', repositoryRevision: 1,
observations: [], analyses: [], catalog, recognitionTerms: [],
scannerFindings: [finding('occ-gap-1', 'purple-knight', 'fail', 'Synthetic print spooler reachable on hosts'),
finding('occ-gap-2', 'purple-knight', 'fail', 'Synthetic spooler without patch level')]
});
it('counts each deviation once per tool by the strongest tier that places it; passed results are no deviations', () => {
const report = contextGapReport(context);
expect(report.coverage).toEqual([
{ tool: 'pingcastle', deviations: 1, catalogued: 1, declaredTechnique: 0, recognized: 0, estimated: 0, gaps: 0 },
{ tool: 'purple-knight', deviations: 3, catalogued: 0, declaredTechnique: 0, recognized: 1, estimated: 0, gaps: 2 }
]);
expect(report.gaps.map(item => item.key)).toEqual(['finding:occ-gap-1', 'finding:occ-gap-2']);
// The review list holds every automatic assignment with its confidence and method.
expect(report.assignments.map(item => [item.text, item.clusterId, item.confidence, item.method])).toEqual([
['Users with SPN defined', 'kerberoasting-exposure', 'recognized', 'title-term']]);
});
it('suggests a term only for the one cluster every catalogued text containing it belongs to', () => {
const report = contextGapReport(withoutVocabulary);
const winrm = report.suggestions.find(item => item.term === 'spooler');
expect(winrm).toMatchObject({ clusterId: 'credential-relay-exposure', covers: 2 });
expect(winrm!.catalogueSupport).toBeGreaterThanOrEqual(2);
// Every suggestion is backed by catalogued texts that all carry the suggested cluster.
for (const suggestion of report.suggestions) {
expect(suggestion.catalogueSupport).toBeGreaterThanOrEqual(2);
}
// Words that only the synthetic gaps share and the catalogue does not tie to one cluster are listed, never assigned.
expect(report.suggestions.some(item => item.term === 'synthetic')).toBe(false);
expect(report.unassignedTerms.map(item => item.term)).toContain('synthetic');
});
it('writes an approval text for the confirmed assignments and selected terms only', () => {
const report = contextGapReport(withoutVocabulary);
const confirmed = report.assignments.slice(0, 1);
expect(confirmed).toHaveLength(1);
const text = contextSuggestionApprovalText(report, report.suggestions.filter(item => item.term === 'spooler'), confirmed);
expect(text.split('\n')).toEqual([
`Context freigeben (Katalog ${report.catalogVersion}, Vokabular ${report.vocabulary.version}):`,
`- Zuordnung bestaetigen: ${confirmed[0].tool} ${confirmed[0].sourceRuleId} -> ${confirmed[0].clusterId} (${confirmed[0].confidence}, ${confirmed[0].method})`,
expect.stringMatching(/^- Begriff: credential-relay-exposure <- "spooler" \(deckt 2 Texte, Katalogbeleg \d+ Texte\)$/)
]);
});
});