72 lines
4.8 KiB
TypeScript
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\)$/)
|
|
]);
|
|
});
|
|
});
|