Assessor_2.0/src/assessment-context/context-gaps.ts

197 lines
11 KiB
TypeScript

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<string, ContextToolCoverage>();
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<string, 'recognized' | 'estimated'>();
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<string, { tool: string; ruleId: string; text: string; ids: Set<string>; 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<string>(), 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<string>();
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<string, ContextGapItem[]>();
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');
}