import { RELEASED_ASSESSMENT_CONTEXT_CATALOG, type AssessmentContextCatalog } from './context-catalog'; import type { AssessmentContext } from './context-engine'; import { CONTEXT_RECOGNITION_VOCABULARY } from './context-recognition'; /** * Context gap report (user decision 2026-10-01): every deviation the context cannot place yet, per tool, with term * suggestions for the recognition vocabulary. A suggestion never contradicts the catalogue: a term is proposed for a * cluster only when at least two catalogued texts contain it and every one of them belongs to that cluster. The report * stores nothing; an approved suggestion enters the versioned vocabulary as a reviewed source change. */ export interface ContextGapItem { key: string; subject: 'scanner-finding' | 'gpo-setting'; tool: string; sourceRuleId?: string; text: string; resultProjectionIds: string[]; } export interface ContextToolCoverage { tool: string; deviations: number; catalogued: number; declaredTechnique: number; recognized: number; estimated: number; gaps: number; } /** A recognized or estimated assignment for review; confirming it makes it a catalogued rule. */ export interface ContextAssignmentReview { key: string; subject: 'scanner-finding' | 'gpo-setting'; tool: string; sourceRuleId?: string; text: string; clusterId: string; clusterLabel: string; confidence: 'recognized' | 'estimated'; method: string; evidence: string; } export interface ContextTermSuggestion { term: string; clusterId: string; clusterLabel: string; /** Gap items whose text contains the term. */ covers: number; /** Catalogued texts containing the term; all of them belong to the suggested cluster. */ catalogueSupport: number; examples: string[]; } export interface ContextGapReport { vocabulary: typeof CONTEXT_RECOGNITION_VOCABULARY; catalogVersion: string; coverage: ContextToolCoverage[]; gaps: ContextGapItem[]; /** Recognized and estimated assignments, estimated first; the review list of the gap report. */ assignments: ContextAssignmentReview[]; suggestions: ContextTermSuggestion[]; /** Frequent words of the gaps the catalogue gives no unambiguous cluster for; listed, never assigned. */ unassignedTerms: Array<{ term: string; covers: number }>; } const STOP_WORDS = new Set(('about above after again against also among another based been before being below between both ' + 'cannot could does doing done each either ensure every following from have having here into itself least less more most ' + 'must neither none only other over same shall should some such than that their them then there these they this those ' + 'through under until upon very were what when where which while will with within without would your configured enabled ' + 'disabled setting settings value values default recommended include includes including allowed allow allows found have ' + 'least last days object objects user users account accounts computer computers domain domains group groups policy policies ' + 'security microsoft windows server servers client clients enable disable require required requirement').split(' ')); const words = (text: string) => [...new Set((text.toLowerCase().match(/[a-z][a-z0-9-]{3,}/g) ?? []).filter(word => !STOP_WORDS.has(word)))]; const containsWord = (text: string, word: string) => new RegExp(`(^|[^a-z0-9-])${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^a-z0-9-])`, 'i').test(text); /** Catalogued texts with the clusters the catalogue assigns them: the only evidence a suggestion may rest on. */ function catalogueTexts(catalog: AssessmentContextCatalog) { const uncatalogued = new Set(Object.values(catalog.uncataloguedDeviationConditions)); const clustersOf = (conditionId: string) => catalog.clusters .filter(cluster => cluster.conditionIds.includes(conditionId) && !cluster.conditionIds.some(id => uncatalogued.has(id))) .map(cluster => cluster.id); return [ ...catalog.scannerRules.map(rule => ({ text: rule.ruleText, clusters: clustersOf(rule.conditionId) })), ...catalog.cisSectionRules.map(rule => ({ text: rule.sectionTitle, clusters: clustersOf(rule.conditionId) })), ...catalog.semantics.map(semantic => ({ text: semantic.settingName, clusters: [...new Set(semantic.conditions.flatMap(condition => clustersOf(condition.id)))] })) ].filter(item => item.clusters.length); } const isDeviation = (outcome: string) => outcome === 'fail' || outcome === 'warning'; export function contextGapReport(context: AssessmentContext, catalog: AssessmentContextCatalog = RELEASED_ASSESSMENT_CONTEXT_CATALOG): ContextGapReport { const coverage = new Map(); const entry = (tool: string) => coverage.get(tool) ?? coverage.set(tool, { tool, deviations: 0, catalogued: 0, declaredTechnique: 0, recognized: 0, estimated: 0, gaps: 0 }).get(tool)!; // Strongest automatic confidence per subject: recognized wins over estimated. const automatic = new Map(); for (const item of context.recognitions) { const key = item.subject === 'scanner-finding' ? `finding:${item.occurrenceId}` : `setting:${item.scope}:${item.identityKey}`; if (automatic.get(key) !== 'recognized') automatic.set(key, item.confidence); } const gaps: ContextGapItem[] = []; // Scanner deviations: one per finding occurrence, classified by the strongest tier that places it. const byOccurrence = new Map; catalogued: boolean }>(); for (const item of context.observations.filter(observation => isDeviation(observation.outcome))) { const current = byOccurrence.get(item.occurrenceId) ?? { tool: item.tool, ruleId: item.sourceRuleId, text: item.ruleText, ids: new Set(), catalogued: false }; current.ids.add(item.resultProjectionId); if (item.matchedBy !== 'uncatalogued-deviation') current.catalogued = true; byOccurrence.set(item.occurrenceId, current); } for (const [occurrenceId, item] of byOccurrence) { const tool = entry(item.tool); tool.deviations += 1; if (item.catalogued) tool.catalogued += 1; else { tool.gaps += 1; gaps.push({ key: `finding:${occurrenceId}`, subject: 'scanner-finding', tool: item.tool, sourceRuleId: item.ruleId || undefined, text: item.text, resultProjectionIds: [...item.ids] }); } } for (const item of context.techniqueAssignments.filter((assignment, index, all) => all.findIndex(other => other.occurrenceId === assignment.occurrenceId) === index)) { const tool = entry(item.tool); tool.deviations += 1; tool.declaredTechnique += 1; } const countedAutomatic = new Set(); for (const item of context.recognitions) { const key = item.subject === 'scanner-finding' ? `finding:${item.occurrenceId}` : `setting:${item.scope}:${item.identityKey}`; if (countedAutomatic.has(key)) continue; countedAutomatic.add(key); const tool = entry(item.tool); tool.deviations += 1; tool[automatic.get(key)!] += 1; } // GPO deviations: one per rolled-up setting; assigned settings are counted above. for (const item of context.evaluations.filter(evaluation => evaluation.verdict === 'deviation' && evaluation.matchedBy !== 'assigned-by-recognition')) { const tool = entry('gpo'); tool.deviations += 1; if (item.matchedBy === 'setting') tool.catalogued += 1; else { tool.gaps += 1; gaps.push({ key: `setting:${item.scope}:${item.identityKey}`, subject: 'gpo-setting', tool: 'gpo', text: item.settingName, resultProjectionIds: item.resultProjectionIds }); } } gaps.sort((left, right) => left.tool.localeCompare(right.tool) || left.text.localeCompare(right.text)); const labelOf = (clusterId: string) => catalog.clusters.find(cluster => cluster.id === clusterId)?.label.en ?? clusterId; const assignments: ContextAssignmentReview[] = context.recognitions.map(item => ({ key: item.id, subject: item.subject, tool: item.tool, ...(item.sourceRuleId ? { sourceRuleId: item.sourceRuleId } : {}), text: item.text, clusterId: item.clusterId, clusterLabel: labelOf(item.clusterId), confidence: item.confidence, method: item.method, evidence: item.matchedText })).sort((left, right) => (left.confidence === right.confidence ? 0 : left.confidence === 'estimated' ? -1 : 1) || left.tool.localeCompare(right.tool) || left.text.localeCompare(right.text) || left.clusterId.localeCompare(right.clusterId)); // Suggestions: frequent words of the gaps that the catalogue ties to exactly one cluster. const texts = catalogueTexts(catalog); const frequency = new Map(); const reviewTexts: ContextGapItem[] = [...gaps, ...assignments.filter(item => item.confidence === 'estimated') .map(item => ({ key: item.key, subject: item.subject, tool: item.tool, sourceRuleId: item.sourceRuleId, text: item.text, resultProjectionIds: [] }))]; for (const gap of reviewTexts) for (const word of words(gap.text)) frequency.set(word, [...(frequency.get(word) ?? []), gap]); const suggestions: ContextTermSuggestion[] = []; const unassignedTerms: ContextGapReport['unassignedTerms'] = []; for (const [term, items] of frequency) { const supporting = texts.filter(item => containsWord(item.text, term)); const shared = supporting.length ? supporting[0].clusters.filter(clusterId => supporting.every(item => item.clusters.includes(clusterId))) : []; if (supporting.length >= 2 && shared.length === 1) { const cluster = catalog.clusters.find(item => item.id === shared[0])!; suggestions.push({ term, clusterId: cluster.id, clusterLabel: cluster.label.en, covers: items.length, catalogueSupport: supporting.length, examples: [...new Set(items.map(item => item.text))].slice(0, 3) }); } else if (items.length >= 2) { unassignedTerms.push({ term, covers: items.length }); } } suggestions.sort((left, right) => right.covers - left.covers || right.catalogueSupport - left.catalogueSupport || left.term.localeCompare(right.term)); unassignedTerms.sort((left, right) => right.covers - left.covers || left.term.localeCompare(right.term)); return { vocabulary: CONTEXT_RECOGNITION_VOCABULARY, catalogVersion: catalog.catalog.version, coverage: [...coverage.values()].sort((left, right) => left.tool.localeCompare(right.tool)), gaps, assignments, suggestions: suggestions.slice(0, 40), unassignedTerms: unassignedTerms.slice(0, 20) }; } /** Approval text for confirmed assignments and vocabulary terms; the maintainer turns it into a reviewed catalogue change. */ export function contextSuggestionApprovalText(report: ContextGapReport, selected: ContextTermSuggestion[], confirmed: ContextAssignmentReview[] = []): string { return [ `Context freigeben (Katalog ${report.catalogVersion}, Vokabular ${report.vocabulary.version}):`, ...confirmed.map(item => `- Zuordnung bestaetigen: ${item.tool} ${item.sourceRuleId ?? `"${item.text}"`} -> ${item.clusterId} (${item.confidence}, ${item.method})`), ...selected.map(item => `- Begriff: ${item.clusterId} <- "${item.term}" (deckt ${item.covers} Texte, Katalogbeleg ${item.catalogueSupport} Texte)`) ].join('\n'); }