197 lines
11 KiB
TypeScript
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');
|
|
}
|