952 lines
51 KiB
TypeScript
952 lines
51 KiB
TypeScript
import type { CanonicalOutcome, JsonValue } from '../domain/canonical-model';
|
|
import type { StoredGpoPolicyAnalysis } from '../domain/gpo-analysis-persistence';
|
|
import type { UnifiedResultProjection } from '../domain/unified-result';
|
|
import type { ReferenceRecognitionRun } from '../domain/reference-recognition';
|
|
import type { ScannerReportSection } from '../domain/environment-context';
|
|
import { cisBenchmarkSectionTitles } from '../profiles/cis-cat/cis-benchmark-section-titles';
|
|
import {
|
|
assessmentContextCatalogErrors, type AssessmentContextCatalog, type ContextCondition, type ContextRelationship,
|
|
type ContextGpoVerdictTarget, type ContextScannerRule, type ContextSemanticDefinition, type ContextSettingTarget, type ContextSource, type ContextText
|
|
} from './context-catalog';
|
|
import { CONTEXT_RECOGNITION_TERMS, CONTEXT_RECOGNITION_VOCABULARY, type RecognitionTerm } from './context-recognition';
|
|
import { assignRemainingDeviations, type ContextAssignmentMethod } from './context-assignment';
|
|
|
|
/**
|
|
* Context engine (Phase 13E): a derived, switchable second reading of stored ASSESSOR results.
|
|
* It never produces a pass/fail verdict, a priority or an effective host state, and it never writes.
|
|
* GPO backups contribute what they declare; scanners contribute what they observed. Both stay
|
|
* separately attributed evidence of the same catalogued conditions.
|
|
*/
|
|
export const ASSESSMENT_CONTEXT_ENGINE = { id: 'assessment-context-engine', version: '1.10.0' } as const;
|
|
|
|
export type DeclaredValue =
|
|
| { state: 'configured'; value: number }
|
|
| { state: 'deleted' }
|
|
| { state: 'unknown'; reason: 'multiple-instructions' | 'conflicted-observation' | 'unsupported-value' };
|
|
|
|
/** What one GPO backup declares for one catalogued setting. Backup configuration only. */
|
|
export interface DeclaredFact {
|
|
id: string;
|
|
semanticId: string;
|
|
backupInstanceId: string;
|
|
gpoId?: string;
|
|
linkStatus?: 'linked' | 'not-linked';
|
|
/** Same technical identity and scope as the Work row, so the layer can attach to it. */
|
|
identityKey: string;
|
|
scope: 'computer' | 'user';
|
|
mechanism: ContextSettingTarget['mechanism'];
|
|
value: DeclaredValue;
|
|
observationIds: string[];
|
|
assertion: 'declared-in-gpo-backup';
|
|
}
|
|
|
|
/**
|
|
* What one scanner result observed for one catalogued condition: via its exact rule ID, for CIS-CAT via
|
|
* the benchmark section of its own report, otherwise as a visible uncatalogued deviation.
|
|
*/
|
|
export interface ObservedFact {
|
|
id: string;
|
|
conditionId: string;
|
|
tool: ContextScannerRule['tool'];
|
|
sourceRuleId: string;
|
|
ruleText: string;
|
|
matchedBy: 'rule-id' | 'report-section' | 'uncatalogued-deviation';
|
|
/** Report section title that assigned the finding (`report-section` only). */
|
|
sectionTitle?: string;
|
|
resultProjectionId: string;
|
|
/** Same finding occurrence as the Work record, so the layer can attach to it. */
|
|
occurrenceId: string;
|
|
outcome: CanonicalOutcome;
|
|
observedAt?: string;
|
|
assertion: 'observed-by-scanner';
|
|
}
|
|
|
|
/**
|
|
* ASSESSOR's authoritative, rolled-up baseline verdict for one GPO setting (user decision 2026-09-30).
|
|
* Read as it is, never re-derived; used where no catalogued value semantic covers the setting.
|
|
*/
|
|
export interface EvaluatedFact {
|
|
id: string;
|
|
conditionId: string;
|
|
identityKey: string;
|
|
scope: 'computer' | 'user';
|
|
settingName: string;
|
|
verdict: 'deviation' | 'passed';
|
|
matchedBy: 'setting' | 'uncatalogued-deviation' | 'assigned-by-recognition';
|
|
resultProjectionIds: string[];
|
|
assertion: 'evaluated-by-assessor';
|
|
}
|
|
|
|
export type ConditionState = 'satisfied' | 'not-satisfied' | 'unknown';
|
|
|
|
export interface ContextConditionResult {
|
|
conditionId: string;
|
|
/** The GPO setting behind the condition; absent for conditions only scanners observe. */
|
|
semanticId?: string;
|
|
label: ContextText;
|
|
/** Aggregate over all evidence: satisfied as soon as one GPO declares or one scanner observes it. */
|
|
state: ConditionState;
|
|
/** Declared and observed fact IDs; look them up in `facts` or `observations`. */
|
|
satisfiedBy: string[];
|
|
notSatisfiedBy: string[];
|
|
unknownBy: string[];
|
|
/** Different GPO backups declare contradicting values; GPO precedence is never assumed. */
|
|
conflictingDeclarations: boolean;
|
|
/** A GPO declaration and a scanner observation point in opposite directions. */
|
|
declaredVersusObserved: boolean;
|
|
/** Every satisfying piece of evidence is a declaration of a GPO backup without an enabled link. */
|
|
onlyUnlinkedSources: boolean;
|
|
relationships: ContextRelationship[];
|
|
}
|
|
|
|
export interface ContextClusterResult {
|
|
clusterId: string;
|
|
label: ContextText;
|
|
description: ContextText;
|
|
/** `present`: at least `minimumSatisfied` conditions are met; never a proof of an attack. */
|
|
state: 'present' | 'not-present' | 'undetermined';
|
|
minimumSatisfied: number;
|
|
satisfied: number;
|
|
notSatisfied: number;
|
|
unknown: number;
|
|
conditions: ContextConditionResult[];
|
|
sources: ContextSource[];
|
|
}
|
|
|
|
/** Attach point for a GPO Work row: its technical identity and scope. */
|
|
export interface ContextSettingAttachment {
|
|
identityKey: string;
|
|
scope: 'computer' | 'user';
|
|
/** Catalogued value semantic; absent when only ASSESSOR's baseline verdict links the setting. */
|
|
semanticId?: string;
|
|
settingName: string;
|
|
factIds: string[];
|
|
evaluationIds: string[];
|
|
conditionIds: string[];
|
|
clusterIds: string[];
|
|
/** Confidence of each cluster that is not catalogued (user decision 2026-10-01); absent means catalogued. */
|
|
clusterConfidence?: Record<string, 'recognized' | 'estimated'>;
|
|
}
|
|
|
|
/** Attach point for a scanner Work record: its finding occurrence. */
|
|
export interface ContextFindingAttachment {
|
|
occurrenceId: string;
|
|
resultProjectionId: string;
|
|
tool: string;
|
|
sourceRuleId: string;
|
|
observationIds: string[];
|
|
conditionIds: string[];
|
|
clusterIds: string[];
|
|
/** ATT&CK and D3FEND IDs the scanner itself declares for this finding (user decisions 2026-09-30). */
|
|
declaredReferenceIds: string[];
|
|
/** CIS benchmark sections containing this rule in its own scan report, top level first. */
|
|
cisControlAreaIds: string[];
|
|
/** Confidence of each cluster that is not catalogued (user decision 2026-10-01); absent means catalogued. */
|
|
clusterConfidence?: Record<string, 'recognized' | 'estimated'>;
|
|
}
|
|
|
|
/**
|
|
* Tier 1 (user decision 2026-10-01): a deviation whose scanner itself declares an active ATT&CK technique that the
|
|
* catalogue's sourced relationships name for exactly one cluster. Counts like a catalogued rule for that cluster.
|
|
*/
|
|
export interface DeclaredTechniqueAssignment {
|
|
id: string;
|
|
occurrenceId: string;
|
|
resultProjectionId: string;
|
|
tool: string;
|
|
sourceRuleId: string;
|
|
ruleText: string;
|
|
techniqueId: string;
|
|
clusterId: string;
|
|
outcome: CanonicalOutcome;
|
|
assertion: 'declared-technique';
|
|
}
|
|
|
|
/**
|
|
* Tiers 2 to 5 (user decision 2026-10-01): a deviation no rule, section or declared technique assigns receives a cluster
|
|
* from its rule text, description, technical anchors, the most similar assigned finding or its scanner category.
|
|
* It counts for the cluster and always carries its confidence, method and evidence.
|
|
*/
|
|
export interface ContextRecognition {
|
|
id: string;
|
|
subject: 'scanner-finding' | 'gpo-setting';
|
|
occurrenceId?: string;
|
|
identityKey?: string;
|
|
scope?: 'computer' | 'user';
|
|
resultProjectionIds: string[];
|
|
tool: string;
|
|
sourceRuleId?: string;
|
|
text: string;
|
|
clusterId: string;
|
|
confidence: 'recognized' | 'estimated';
|
|
method: ContextAssignmentMethod;
|
|
termId?: string;
|
|
/** The words, neighbour text or category that led to the assignment. */
|
|
matchedText: string;
|
|
assertion: 'automatically-recognized' | 'automatically-estimated';
|
|
}
|
|
|
|
/**
|
|
* Findings whose scanner declares the same ATT&CK tactic or technique, or the same D3FEND
|
|
* countermeasure. Only references the source declares count; mentioned ones never do. States a
|
|
* shared declared topic or countermeasure, never a common attack path or cause.
|
|
*/
|
|
export interface DeclaredReferenceGroup {
|
|
framework: 'mitre-attack' | 'mitre-d3fend';
|
|
id: string;
|
|
title?: string;
|
|
kind?: 'technique' | 'tactic';
|
|
findings: Array<{ occurrenceId: string; resultProjectionId: string; tool: string; sourceRuleId?: string; outcome: CanonicalOutcome }>;
|
|
}
|
|
|
|
/**
|
|
* One CIS benchmark section of one scan report, derived from the exact rule-number hierarchy
|
|
* (9.3.6 lies in 9.3 and 9). Numbers are compared only inside the same scan run, because
|
|
* different benchmarks reuse the same numbering. Same section, never a proven attack chain.
|
|
*/
|
|
export interface CisControlArea {
|
|
id: string;
|
|
scanRunId: string;
|
|
sectionId: string;
|
|
parentSectionId?: string;
|
|
/** Section title as the scan report's section-score table lists it; absent when missing or ambiguous. */
|
|
title?: string;
|
|
findings: number;
|
|
fail: number;
|
|
pass: number;
|
|
other: number;
|
|
/** Pass/Fail counts the scan report itself states for this section. */
|
|
reported?: { pass: number; fail: number };
|
|
}
|
|
|
|
/** Remediation lever: a plain count, never a rank, effort or risk score. */
|
|
export interface ContextLever {
|
|
semanticId: string;
|
|
settingName: string;
|
|
satisfiedConditions: number;
|
|
clusters: number;
|
|
backupInstanceIds: string[];
|
|
}
|
|
|
|
export interface AssessmentContext {
|
|
version: 1;
|
|
engine: typeof ASSESSMENT_CONTEXT_ENGINE;
|
|
catalog: AssessmentContextCatalog['catalog'];
|
|
projectId: string;
|
|
assessmentIds: string[];
|
|
scanRunIds?: string[];
|
|
generatedAt: string;
|
|
repositoryRevision: number;
|
|
facts: DeclaredFact[];
|
|
observations: ObservedFact[];
|
|
evaluations: EvaluatedFact[];
|
|
settings: ContextSettingAttachment[];
|
|
findings: ContextFindingAttachment[];
|
|
clusters: ContextClusterResult[];
|
|
declaredReferences: DeclaredReferenceGroup[];
|
|
cisControlAreas: CisControlArea[];
|
|
levers: ContextLever[];
|
|
/** Tier 1: deviations assigned through a technique their scanner declares. */
|
|
techniqueAssignments: DeclaredTechniqueAssignment[];
|
|
/** Tier 2: suggestions from the recognition vocabulary; kept apart from every catalogued statement. */
|
|
recognitions: ContextRecognition[];
|
|
recognitionVocabulary: typeof CONTEXT_RECOGNITION_VOCABULARY;
|
|
/** Whether the backend run read everything Raw Data shows; absent when no deviation needed it. */
|
|
rawDataRead?: boolean;
|
|
/** Gap list with vocabulary suggestions; added by the backend run (context-run.ts), read by the UI. */
|
|
gapReport?: import('./context-gaps').ContextGapReport;
|
|
semantics: Array<Pick<ContextSemanticDefinition, 'id' | 'label' | 'settingName' | 'valueMeaning' | 'sources'>>;
|
|
scannerRules: Array<Pick<ContextScannerRule, 'tool' | 'ruleId' | 'conditionId' | 'ruleText' | 'sources'>>;
|
|
coverage: {
|
|
observations: number; semanticallyMapped: number; notSemanticallyMapped: number; unresolvedIdentity: number;
|
|
scannerFindings: number; scannerMapped: number; scannerNotInCatalog: number;
|
|
/** Rule ID known, but the finding does not name the exact setting: not mapped (benchmark renumbering guard). */
|
|
scannerGuardRejected: number;
|
|
/** GPO settings with an authoritative baseline verdict, and how many of them the context assigns. */
|
|
gpoEvaluatedSettings: number;
|
|
gpoMappedByVerdict: number;
|
|
gpoUncatalogued: number;
|
|
/** CIS-CAT findings assigned through the section title of their own report. */
|
|
scannerMappedBySection: number;
|
|
/** Deviations no rule or section assigns yet; they are kept in the uncatalogued cluster. */
|
|
scannerUncatalogued: number;
|
|
/** Scanner findings with at least one ATT&CK ID their scanner declares. */
|
|
scannerWithDeclaredReferences: number;
|
|
/** CIS-CAT findings placed in at least one benchmark section of their scan report. */
|
|
scannerWithCisControlArea: number;
|
|
/** Tier 1: deviations assigned through a declared technique; they are no longer uncatalogued. */
|
|
scannerMappedByDeclaredTechnique: number;
|
|
/** Tiers 2 to 5: deviations assigned as recognized or estimated, and how many stay without context. */
|
|
scannerRecognized: number;
|
|
scannerEstimated: number;
|
|
gpoRecognized: number;
|
|
gpoEstimated: number;
|
|
uncataloguedWithoutRecognition: number;
|
|
};
|
|
}
|
|
|
|
export interface AssessmentContextInput {
|
|
projectId: string;
|
|
assessmentIds: string[];
|
|
scanRunIds?: string[];
|
|
generatedAt: string;
|
|
repositoryRevision: number;
|
|
/** Current `policy-observation` results of the scope, as Work reads them. */
|
|
observations: UnifiedResultProjection[];
|
|
analyses: StoredGpoPolicyAnalysis[];
|
|
/** Current `policy-evaluation` results of the scope, as Work reads them (authoritative GPO verdicts). */
|
|
evaluations?: UnifiedResultProjection[];
|
|
/** Resolved technical targets by identity ID and scope (from the stored policy evaluations). */
|
|
identityTargets?: Array<{ id: string; scope: string; target: unknown }>;
|
|
/** Current `scanner-finding` results of the scope, as Work reads them. */
|
|
scannerFindings?: UnifiedResultProjection[];
|
|
/** Stored reference recognition runs; only the latest run per scan run counts. */
|
|
referenceRuns?: ReferenceRecognitionRun[];
|
|
/** Stored report sections of the CIS-CAT scan runs in scope; only the section-score table is read. */
|
|
cisReportSections?: Array<Pick<ScannerReportSection, 'scanRunId' | 'sectionKey' | 'tables'>>;
|
|
catalog: AssessmentContextCatalog;
|
|
/** Tier 2 vocabulary; defaults to the released one. */
|
|
recognitionTerms?: RecognitionTerm[];
|
|
/** Template names of Microsoft baseline settings by technical identity (for example "MSFT Windows 11 - BitLocker"). */
|
|
baselineTemplates?: Array<{ identityKey: string; names: string[] }>;
|
|
/** Every field Raw Data shows for each scanner record, by finding occurrence (user decision 2026-10-01). */
|
|
recordFields?: Array<{ occurrenceId: string; fields: Array<{ label: string; value: string }> }>;
|
|
/** Every field Raw Data shows for each GPO setting record, by scope and technical identity. */
|
|
settingRecordFields?: Array<{ identityKey: string; scope: string; fields: Array<{ label: string; value: string }> }>;
|
|
}
|
|
|
|
const CIS_RULE_ID = /^\d+(?:\.\d+)+$/;
|
|
const CIS_SECTION_ROW = /^((?:\d+\.)*\d+)\s+\S/;
|
|
const WHOLE_NUMBER = /^\d+$/;
|
|
|
|
/** Pass/Fail counts per section exactly as the section-score table states them; ambiguous rows are left out. */
|
|
function cisReportedCounts(sections: AssessmentContextInput['cisReportSections']): Map<string, Map<string, { pass: number; fail: number }>> {
|
|
const result = new Map<string, Map<string, { pass: number; fail: number }>>();
|
|
for (const section of sections ?? []) {
|
|
if (section.sectionKey !== 'cis-cat-section-scores') continue;
|
|
const run = result.get(section.scanRunId) ?? new Map<string, { pass: number; fail: number }>();
|
|
const conflicts = new Set<string>();
|
|
for (const table of section.tables) {
|
|
const column = (name: RegExp) => table.headers.findIndex(header => name.test(header.trim()));
|
|
const [description, pass, fail] = [column(/^description$/i), column(/^pass$/i), column(/^fail$/i)];
|
|
if (description < 0 || pass < 0 || fail < 0) continue;
|
|
for (const row of table.rows) {
|
|
const id = CIS_SECTION_ROW.exec(String(row[description] ?? '').trim())?.[1];
|
|
const [passText, failText] = [String(row[pass] ?? '').trim(), String(row[fail] ?? '').trim()];
|
|
if (!id || !WHOLE_NUMBER.test(passText) || !WHOLE_NUMBER.test(failText)) continue;
|
|
const counts = { pass: Number(passText), fail: Number(failText) };
|
|
const known = run.get(id);
|
|
if (known && (known.pass !== counts.pass || known.fail !== counts.fail)) conflicts.add(id);
|
|
else run.set(id, counts);
|
|
}
|
|
}
|
|
conflicts.forEach(id => run.delete(id));
|
|
result.set(section.scanRunId, run);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const fold = (value: string) => value.normalize('NFC').replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase();
|
|
|
|
type ResolvedTarget =
|
|
| { kind: 'security-template' | 'advanced-audit-policy'; section: string; key: string }
|
|
| { kind: 'registry-key' | 'registry-value'; hive: string; key: string; valueName?: string };
|
|
|
|
function targetMatches(target: ContextSettingTarget, resolved: ResolvedTarget, scope: string): boolean {
|
|
if (target.scope !== scope) return false;
|
|
if (target.mechanism === 'security-template') {
|
|
return resolved.kind === 'security-template' && fold(resolved.section) === fold(target.section) && fold(resolved.key) === fold(target.key);
|
|
}
|
|
const hive = target.scope === 'computer' ? 'HKEY_LOCAL_MACHINE' : 'HKEY_CURRENT_USER';
|
|
return resolved.kind === 'registry-value' && resolved.hive === hive
|
|
&& fold(resolved.key) === fold(target.key) && fold(resolved.valueName ?? '') === fold(target.valueName);
|
|
}
|
|
|
|
/** Registry verdict rules match their key and every subkey; the longer key is the more specific rule. */
|
|
function verdictTargetMatches(target: ContextGpoVerdictTarget, resolved: ResolvedTarget, scope: string): boolean {
|
|
if (target.scope !== scope) return false;
|
|
if (target.mechanism === 'advanced-audit-policy') return resolved.kind === 'advanced-audit-policy';
|
|
if (target.mechanism === 'security-template') {
|
|
return resolved.kind === 'security-template' && fold(resolved.section) === fold(target.section) && fold(resolved.key) === fold(target.key);
|
|
}
|
|
const hive = target.scope === 'computer' ? 'HKEY_LOCAL_MACHINE' : 'HKEY_CURRENT_USER';
|
|
if ((resolved.kind !== 'registry-key' && resolved.kind !== 'registry-value') || resolved.hive !== hive) return false;
|
|
const key = fold(resolved.key);
|
|
const ruleKey = fold(target.key);
|
|
if (key !== ruleKey && !key.startsWith(`${ruleKey}\\`)) return false;
|
|
return target.valueName === undefined || fold(resolved.valueName ?? '') === fold(target.valueName);
|
|
}
|
|
const verdictSpecificity = (target: ContextGpoVerdictTarget) =>
|
|
target.mechanism === 'registry-key' ? target.key.length + (target.valueName ? 1000 : 0) : target.mechanism === 'security-template' ? 2000 : 0;
|
|
|
|
function record(value: JsonValue): Record<string, JsonValue> | undefined {
|
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
}
|
|
|
|
/** Decodes only an exact integer instruction; everything else stays explicitly unknown. */
|
|
export function declaredValue(observation: UnifiedResultProjection): DeclaredValue {
|
|
if (observation.policyState === 'conflicted') return { state: 'unknown', reason: 'conflicted-observation' };
|
|
const values = observation.policyValues ?? [];
|
|
if (values.length !== 1) return { state: 'unknown', reason: 'multiple-instructions' };
|
|
const value = record(values[0]);
|
|
if (value?.kind === 'registry' && value.typeCode === 4 && typeof value.rawData === 'string' && /^\d{1,10}$/.test(value.rawData.trim())) {
|
|
return { state: 'configured', value: Number(value.rawData.trim()) };
|
|
}
|
|
// Account policies ([System Access]) carry the instruction as a plain scalar; only an exact integer counts.
|
|
if (value?.kind === 'scalar' && typeof value.raw === 'string' && /^\d{1,10}$/.test(value.raw.trim())) {
|
|
return { state: 'configured', value: Number(value.raw.trim()) };
|
|
}
|
|
if (value?.kind === 'registry-policy') {
|
|
const data = record(value.data ?? null);
|
|
if (value.operation === 'set-value' && value.typeCode === 4 && data?.kind === 'uint32' && typeof data.value === 'number') {
|
|
return { state: 'configured', value: data.value };
|
|
}
|
|
if (value.operation === 'delete-value') return { state: 'deleted' };
|
|
}
|
|
return { state: 'unknown', reason: 'unsupported-value' };
|
|
}
|
|
|
|
function declaredState(condition: ContextCondition, value: DeclaredValue): ConditionState {
|
|
if (value.state !== 'configured') return 'unknown';
|
|
if (condition.satisfiedValues.includes(value.value)) return 'satisfied';
|
|
if (condition.notSatisfiedValues.includes(value.value)) return 'not-satisfied';
|
|
return 'unknown';
|
|
}
|
|
|
|
/**
|
|
* `fail-means-met`: every failing or deviating native result (`fail`, `warning`) is a finding and meets the
|
|
* condition (user decision 2026-09-30); a pass refutes it; everything else stays unknown.
|
|
*/
|
|
const isDeviation = (outcome: CanonicalOutcome) => outcome === 'fail' || outcome === 'warning';
|
|
const clustersOfConditionId = (catalog: AssessmentContextCatalog, conditionId: string) =>
|
|
catalog.clusters.filter(cluster => cluster.conditionIds.includes(conditionId)).map(cluster => cluster.id);
|
|
/** Subjects whose strongest automatic assignment has this confidence (recognized wins over estimated). */
|
|
function countSubjects(recognitions: ContextRecognition[], subject: ContextRecognition['subject'], confidence: ContextRecognition['confidence']) {
|
|
const best = new Map<string, ContextRecognition['confidence']>();
|
|
for (const item of recognitions.filter(entry => entry.subject === subject)) {
|
|
const key = item.occurrenceId ?? `${item.scope}\u0000${item.identityKey}`;
|
|
if (best.get(key) !== 'recognized') best.set(key, item.confidence);
|
|
}
|
|
return [...best.values()].filter(value => value === confidence).length;
|
|
}
|
|
function observedState(outcome: CanonicalOutcome): ConditionState {
|
|
if (isDeviation(outcome)) return 'satisfied';
|
|
if (outcome === 'pass') return 'not-satisfied';
|
|
return 'unknown';
|
|
}
|
|
|
|
export function buildAssessmentContext(input: AssessmentContextInput): AssessmentContext {
|
|
const errors = assessmentContextCatalogErrors(input.catalog);
|
|
if (errors.length) throw new Error(`Context catalog is invalid: ${errors.join(' ')}`);
|
|
|
|
// The unified result already carries the identity ASSESSOR selected; only that resolution counts.
|
|
const resolutions = new Map<string, ResolvedTarget>();
|
|
for (const analysis of input.analyses) {
|
|
for (const item of analysis.resolutions) {
|
|
if (item.resolution.status === 'resolved') resolutions.set(`${item.observationId}\u0000${item.resolution.identity.id}`, item.resolution.identity.target as ResolvedTarget);
|
|
}
|
|
}
|
|
|
|
const facts: DeclaredFact[] = [];
|
|
let unresolvedIdentity = 0;
|
|
let notMapped = 0;
|
|
for (const observation of input.observations) {
|
|
const scope = observation.policyScope;
|
|
const target = observation.policyIdentityStatus === 'resolved' && observation.policyIdentityKey
|
|
? resolutions.get(`${observation.sourceResultId}\u0000${observation.policyIdentityKey}`) : undefined;
|
|
if (!target || (scope !== 'computer' && scope !== 'user') || !observation.policyBackupInstanceId) {
|
|
unresolvedIdentity += 1;
|
|
continue;
|
|
}
|
|
const semantic = input.catalog.semantics.find(item => item.targets.some(candidate => targetMatches(candidate, target, scope)));
|
|
if (!semantic) {
|
|
notMapped += 1;
|
|
continue;
|
|
}
|
|
const mechanism = semantic.targets.find(candidate => targetMatches(candidate, target, scope))!.mechanism;
|
|
facts.push({
|
|
id: `context-fact:${observation.policyBackupInstanceId}:${semantic.id}:${observation.sourceResultId}`,
|
|
semanticId: semantic.id,
|
|
backupInstanceId: observation.policyBackupInstanceId,
|
|
gpoId: observation.policyGpoId,
|
|
linkStatus: observation.policyBackupLinkStatus,
|
|
identityKey: observation.policyIdentityKey!,
|
|
scope,
|
|
mechanism,
|
|
value: declaredValue(observation),
|
|
observationIds: [observation.sourceResultId],
|
|
assertion: 'declared-in-gpo-backup'
|
|
});
|
|
}
|
|
facts.sort((left, right) => left.id.localeCompare(right.id));
|
|
|
|
// GPO verdicts: every setting whose rolled-up baseline verdict ASSESSOR reports as a deviation enters the context.
|
|
const identityKeyOf = (scope: string, identityKey: string) => `${scope}\u0000${identityKey}`;
|
|
const targetByIdentity = new Map<string, ResolvedTarget>();
|
|
for (const observation of input.observations) {
|
|
const target = observation.policyIdentityStatus === 'resolved' && observation.policyIdentityKey
|
|
? resolutions.get(`${observation.sourceResultId}\u0000${observation.policyIdentityKey}`) : undefined;
|
|
if (target && observation.policyScope) targetByIdentity.set(identityKeyOf(observation.policyScope, observation.policyIdentityKey!), target);
|
|
}
|
|
for (const item of input.identityTargets ?? []) {
|
|
const key = identityKeyOf(item.scope, item.id);
|
|
if (!targetByIdentity.has(key) && item.target && typeof item.target === 'object') targetByIdentity.set(key, item.target as ResolvedTarget);
|
|
}
|
|
const declaredIdentities = new Set(facts.map(fact => identityKeyOf(fact.scope, fact.identityKey)));
|
|
const verdicts = new Map<string, { scope: 'computer' | 'user'; identityKey: string; settingName: string; deviation: boolean; passed: boolean; ids: string[] }>();
|
|
for (const evaluation of input.evaluations ?? []) {
|
|
const scope = evaluation.policyScope;
|
|
if ((scope !== 'computer' && scope !== 'user') || !evaluation.policyIdentityKey) continue;
|
|
const key = identityKeyOf(scope, evaluation.policyIdentityKey);
|
|
const entry = verdicts.get(key) ?? { scope, identityKey: evaluation.policyIdentityKey, settingName: evaluation.title, deviation: false, passed: false, ids: [] };
|
|
entry.deviation ||= evaluation.isDeviation;
|
|
entry.passed ||= evaluation.outcomeNormalized === 'pass';
|
|
entry.ids.push(evaluation.resultProjectionId);
|
|
verdicts.set(key, entry);
|
|
}
|
|
const verdictRuleFor = (target: ResolvedTarget, scope: string) => input.catalog.gpoVerdictRules
|
|
.filter(rule => verdictTargetMatches(rule.target, target, scope))
|
|
.sort((left, right) => verdictSpecificity(right.target) - verdictSpecificity(left.target))[0];
|
|
const evaluations: EvaluatedFact[] = [];
|
|
let gpoMappedByVerdict = 0;
|
|
let gpoUncatalogued = 0;
|
|
for (const [key, verdict] of verdicts) {
|
|
if (declaredIdentities.has(key)) continue;
|
|
const state = verdict.deviation ? 'deviation' : verdict.passed ? 'passed' : undefined;
|
|
if (!state) continue;
|
|
const target = targetByIdentity.get(key);
|
|
const rule = target ? verdictRuleFor(target, verdict.scope) : undefined;
|
|
const conditionId = rule?.conditionId ?? (state === 'deviation' ? input.catalog.uncataloguedDeviationConditions.gpo : undefined);
|
|
if (!conditionId) continue;
|
|
if (rule) gpoMappedByVerdict += 1; else gpoUncatalogued += 1;
|
|
evaluations.push({
|
|
id: `context-evaluation:${verdict.scope}:${verdict.identityKey}`, conditionId, identityKey: verdict.identityKey, scope: verdict.scope,
|
|
settingName: verdict.settingName, verdict: state, matchedBy: rule ? 'setting' : 'uncatalogued-deviation',
|
|
resultProjectionIds: verdict.ids.sort(), assertion: 'evaluated-by-assessor'
|
|
});
|
|
}
|
|
evaluations.sort((left, right) => left.id.localeCompare(right.id));
|
|
|
|
// Scanner observations: exact tool and rule ID only; the title only guards renumbered benchmarks.
|
|
const rulesByKey = new Map(input.catalog.scannerRules.map(rule => [`${rule.tool}\u0000${rule.ruleId}`, rule]));
|
|
const foldTitle = (value: string) => value.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
const sectionRules = new Map(input.catalog.cisSectionRules.map(rule => [foldTitle(rule.sectionTitle), rule]));
|
|
const sectionTitles = cisBenchmarkSectionTitles(input.cisReportSections);
|
|
const sectionOf = (finding: UnifiedResultProjection) => {
|
|
const ruleId = finding.sourceRuleId?.trim() ?? '';
|
|
if (finding.sourceTool !== 'cis-cat' || !finding.scanRunId || !CIS_RULE_ID.test(ruleId)) return undefined;
|
|
const segments = ruleId.split('.');
|
|
for (let length = segments.length - 1; length > 0; length -= 1) {
|
|
const title = sectionTitles.get(finding.scanRunId)?.get(segments.slice(0, length).join('.'));
|
|
const rule = title ? sectionRules.get(foldTitle(title)) : undefined;
|
|
if (rule) return { rule, title: title! };
|
|
}
|
|
return undefined;
|
|
};
|
|
// Latest reference recognition run per scan run; read by tier 1 and by the declared reference groups below.
|
|
const latestRuns = new Map<string, ReferenceRecognitionRun>();
|
|
for (const run of input.referenceRuns ?? []) {
|
|
const previous = latestRuns.get(run.scanRunId);
|
|
if (!previous || run.createdAt > previous.createdAt || (run.createdAt === previous.createdAt && run.id > previous.id)) latestRuns.set(run.scanRunId, run);
|
|
}
|
|
// Tier 1: active ATT&CK techniques the scanner itself declares, by finding occurrence (exact IDs, never tactics).
|
|
const declaredTechniques = new Map<string, string[]>();
|
|
for (const run of latestRuns.values()) {
|
|
for (const result of run.results) {
|
|
const ids = result.references
|
|
.filter(reference => reference.framework === 'mitre-attack' && reference.evidence === 'declared' && reference.status === 'active'
|
|
&& reference.kind === 'technique' && /^T\d{4}(?:\.\d{3})?$/.test(reference.id))
|
|
.map(reference => reference.id);
|
|
if (ids.length) declaredTechniques.set(result.occurrenceId, [...new Set([...(declaredTechniques.get(result.occurrenceId) ?? []), ...ids])]);
|
|
}
|
|
}
|
|
// A technique assigns only when the catalogue's sourced relationships name it for exactly one cluster, and for that
|
|
// cluster in at least two relationships; a single incidental mention is not enough to stand for the attack path.
|
|
const clustersByTechnique = new Map<string, Map<string, number>>();
|
|
for (const relationship of input.catalog.relationships) {
|
|
for (const techniqueId of relationship.techniqueIds) {
|
|
for (const cluster of input.catalog.clusters.filter(item => item.conditionIds.includes(relationship.conditionId))) {
|
|
if (Object.values(input.catalog.uncataloguedDeviationConditions).some(id => cluster.conditionIds.includes(id))) continue;
|
|
const counts = clustersByTechnique.get(techniqueId) ?? new Map<string, number>();
|
|
counts.set(cluster.id, (counts.get(cluster.id) ?? 0) + 1);
|
|
clustersByTechnique.set(techniqueId, counts);
|
|
}
|
|
}
|
|
}
|
|
const techniqueCluster = (techniqueId: string) => {
|
|
const counts = clustersByTechnique.get(techniqueId);
|
|
const [only] = counts?.size === 1 ? [...counts] : [];
|
|
return only && only[1] >= 2 ? only[0] : undefined;
|
|
};
|
|
const techniqueClusters = (occurrenceId: string) => (declaredTechniques.get(occurrenceId) ?? [])
|
|
.flatMap(techniqueId => { const clusterId = techniqueCluster(techniqueId); return clusterId ? [{ techniqueId, clusterId }] : []; });
|
|
const techniqueAssignments: DeclaredTechniqueAssignment[] = [];
|
|
const recognitionTerms = input.recognitionTerms ?? CONTEXT_RECOGNITION_TERMS;
|
|
const recognitions: ContextRecognition[] = [];
|
|
|
|
const observations: ObservedFact[] = [];
|
|
let scannerNotInCatalog = 0;
|
|
let scannerGuardRejected = 0;
|
|
let scannerMappedBySection = 0;
|
|
let scannerUncatalogued = 0;
|
|
const pendingFindings: Array<{ finding: UnifiedResultProjection; tool: string; uncatalogued?: string;
|
|
observe: (conditionId: string, tool: ContextScannerRule['tool'], sourceRuleId: string, ruleText: string, matchedBy: ObservedFact['matchedBy']) => void }> = [];
|
|
// A fixed processing order makes every assignment independent of the order the repository returns findings in.
|
|
const scannerFindings = [...(input.scannerFindings ?? [])].sort((left, right) => left.resultProjectionId.localeCompare(right.resultProjectionId));
|
|
for (const finding of scannerFindings) {
|
|
const observe = (conditionId: string, tool: ContextScannerRule['tool'], sourceRuleId: string, ruleText: string,
|
|
matchedBy: ObservedFact['matchedBy'], sectionTitle?: string) => observations.push({
|
|
id: `context-observation:${finding.resultProjectionId}:${conditionId}`,
|
|
conditionId, tool, sourceRuleId, ruleText, matchedBy, ...(sectionTitle ? { sectionTitle } : {}),
|
|
resultProjectionId: finding.resultProjectionId,
|
|
occurrenceId: finding.sourceResultId,
|
|
outcome: finding.outcomeNormalized,
|
|
observedAt: finding.sourceObservedAt,
|
|
assertion: 'observed-by-scanner'
|
|
});
|
|
const rule = rulesByKey.get(`${finding.sourceTool ?? ''}\u0000${(finding.sourceRuleId ?? '').trim()}`);
|
|
if (rule && (!rule.requiredTitleFragment || (finding.title ?? '').includes(rule.requiredTitleFragment))) {
|
|
observe(rule.conditionId, rule.tool, rule.ruleId, rule.ruleText, 'rule-id');
|
|
continue;
|
|
}
|
|
if (rule) scannerGuardRejected += 1;
|
|
const section = sectionOf(finding);
|
|
if (section) {
|
|
scannerMappedBySection += 1;
|
|
observe(section.rule.conditionId, 'cis-cat', finding.sourceRuleId!.trim(), finding.title ?? '', 'report-section', section.title);
|
|
continue;
|
|
}
|
|
if (isDeviation(finding.outcomeNormalized)) {
|
|
const assigned = techniqueClusters(finding.sourceResultId);
|
|
if (assigned.length) {
|
|
for (const item of assigned) {
|
|
techniqueAssignments.push({
|
|
id: `context-technique:${finding.resultProjectionId}:${item.techniqueId}:${item.clusterId}`,
|
|
occurrenceId: finding.sourceResultId, resultProjectionId: finding.resultProjectionId, tool: finding.sourceTool ?? 'unknown',
|
|
sourceRuleId: (finding.sourceRuleId ?? '').trim(), ruleText: finding.title ?? '', techniqueId: item.techniqueId,
|
|
clusterId: item.clusterId, outcome: finding.outcomeNormalized, assertion: 'declared-technique'
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
const tool = finding.sourceTool as ContextScannerRule['tool'] | undefined;
|
|
if (isDeviation(finding.outcomeNormalized) && finding.sourceTool) {
|
|
// Tiers 2 to 5 run after every finding of the scope is known (user decision 2026-10-01).
|
|
pendingFindings.push({ finding, tool: finding.sourceTool, uncatalogued: tool ? input.catalog.uncataloguedDeviationConditions[tool] : undefined, observe });
|
|
continue;
|
|
}
|
|
scannerNotInCatalog += 1;
|
|
}
|
|
|
|
// Tiers 2 to 5 (user decision 2026-10-01): every remaining deviation receives a cluster in a fixed, deterministic order.
|
|
const assigned = assignRemainingDeviations({
|
|
catalog: input.catalog, recognitionTerms,
|
|
resolvedFindings: (input.scannerFindings ?? []).flatMap(finding => {
|
|
const clusterIds = [...new Set([
|
|
...observations.filter(item => item.occurrenceId === finding.sourceResultId).flatMap(item => clustersOfConditionId(input.catalog, item.conditionId)),
|
|
...techniqueAssignments.filter(item => item.occurrenceId === finding.sourceResultId).map(item => item.clusterId)
|
|
])];
|
|
return clusterIds.length ? [{ finding, clusterIds }] : [];
|
|
}),
|
|
pendingFindings: pendingFindings.map(item => item.finding),
|
|
recordFields: new Map((input.recordFields ?? []).map(record => [record.occurrenceId, record.fields])),
|
|
settingRecordFields: new Map((input.settingRecordFields ?? []).map(record => [`${record.scope}\u0000${record.identityKey}`, record.fields])),
|
|
pendingSettings: evaluations.filter(item => item.matchedBy === 'uncatalogued-deviation').map(item => {
|
|
// Registry path and baseline template names carry the topic when the setting name alone does not.
|
|
const target = targetByIdentity.get(identityKeyOf(item.scope, item.identityKey)) as { key?: string; valueName?: string } | undefined;
|
|
const templates = [...new Set((input.baselineTemplates ?? []).filter(entry => entry.identityKey === item.identityKey).flatMap(entry => entry.names))].sort();
|
|
return { ...item, technicalText: [target?.key && [target.key, target.valueName].filter(Boolean).join('\\'), ...templates].filter(Boolean).join('\n') };
|
|
})
|
|
});
|
|
for (const item of pendingFindings) {
|
|
const matches = assigned.findings.get(item.finding.sourceResultId) ?? [];
|
|
for (const match of matches) {
|
|
recognitions.push({
|
|
id: `context-recognition:${item.finding.resultProjectionId}:${match.clusterId}`, subject: 'scanner-finding',
|
|
occurrenceId: item.finding.sourceResultId, resultProjectionIds: [item.finding.resultProjectionId], tool: item.tool,
|
|
sourceRuleId: (item.finding.sourceRuleId ?? '').trim(), text: item.finding.title ?? '', clusterId: match.clusterId,
|
|
confidence: match.confidence, method: match.method, termId: match.termId, matchedText: match.evidence,
|
|
assertion: match.confidence === 'estimated' ? 'automatically-estimated' : 'automatically-recognized'
|
|
});
|
|
}
|
|
if (matches.length) continue;
|
|
if (item.uncatalogued) {
|
|
scannerUncatalogued += 1;
|
|
item.observe(item.uncatalogued, item.tool as ContextScannerRule['tool'], (item.finding.sourceRuleId ?? '').trim(), item.finding.title ?? '', 'uncatalogued-deviation');
|
|
} else {
|
|
scannerNotInCatalog += 1;
|
|
}
|
|
}
|
|
observations.sort((left, right) => left.id.localeCompare(right.id));
|
|
for (const evaluation of evaluations.filter(item => item.matchedBy === 'uncatalogued-deviation')) {
|
|
for (const match of assigned.settings.get(`${evaluation.scope}\u0000${evaluation.identityKey}`) ?? []) {
|
|
recognitions.push({
|
|
id: `context-recognition:${evaluation.scope}:${evaluation.identityKey}:${match.clusterId}`, subject: 'gpo-setting',
|
|
identityKey: evaluation.identityKey, scope: evaluation.scope, resultProjectionIds: evaluation.resultProjectionIds, tool: 'gpo',
|
|
text: evaluation.settingName, clusterId: match.clusterId, confidence: match.confidence, method: match.method,
|
|
termId: match.termId, matchedText: match.evidence,
|
|
assertion: match.confidence === 'estimated' ? 'automatically-estimated' : 'automatically-recognized'
|
|
});
|
|
}
|
|
}
|
|
// An assigned GPO setting leaves the uncatalogued cluster; it keeps its authoritative verdict record.
|
|
const assignedSettingKeys = new Set(recognitions.filter(item => item.subject === 'gpo-setting').map(item => `${item.scope}\u0000${item.identityKey}`));
|
|
for (const evaluation of evaluations) {
|
|
if (evaluation.matchedBy === 'uncatalogued-deviation' && assignedSettingKeys.has(`${evaluation.scope}\u0000${evaluation.identityKey}`)) {
|
|
evaluation.matchedBy = 'assigned-by-recognition';
|
|
gpoUncatalogued -= 1;
|
|
}
|
|
}
|
|
recognitions.sort((left, right) => left.id.localeCompare(right.id));
|
|
techniqueAssignments.sort((left, right) => left.id.localeCompare(right.id));
|
|
|
|
const semanticById = new Map(input.catalog.semantics.map(semantic => [semantic.id, semantic]));
|
|
const conditionResults = new Map<string, ContextConditionResult>();
|
|
const conditionSources: Array<{ semantic?: ContextSemanticDefinition; condition: { id: string; label: ContextText } & Partial<ContextCondition> }> = [
|
|
...input.catalog.semantics.flatMap(semantic => semantic.conditions.map(condition => ({ semantic, condition }))),
|
|
...input.catalog.standaloneConditions.map(condition => ({ semantic: undefined, condition }))
|
|
];
|
|
for (const { semantic, condition } of conditionSources) {
|
|
const declaring = semantic ? facts.filter(fact => fact.semanticId === semantic.id) : [];
|
|
const observing = observations.filter(item => item.conditionId === condition.id);
|
|
const evaluating = evaluations.filter(item => item.conditionId === condition.id && item.matchedBy !== 'assigned-by-recognition');
|
|
const evidence = [
|
|
...declaring.map(fact => ({ id: fact.id, state: declaredState(condition as ContextCondition, fact.value), declared: fact })),
|
|
...observing.map(item => ({ id: item.id, state: observedState(item.outcome), declared: undefined })),
|
|
...evaluating.map(item => ({ id: item.id, state: (item.verdict === 'deviation' ? 'satisfied' : 'not-satisfied') as ConditionState, declared: undefined }))
|
|
];
|
|
const ids = (state: ConditionState) => evidence.filter(item => item.state === state).map(item => item.id);
|
|
const satisfied = evidence.filter(item => item.state === 'satisfied');
|
|
const notSatisfied = evidence.filter(item => item.state === 'not-satisfied');
|
|
const declaredStates = new Set(evidence.filter(item => item.declared && item.state !== 'unknown').map(item => item.state));
|
|
const observedStates = new Set(evidence.filter(item => !item.declared && item.state !== 'unknown').map(item => item.state));
|
|
conditionResults.set(condition.id, {
|
|
conditionId: condition.id,
|
|
...(semantic ? { semanticId: semantic.id } : {}),
|
|
label: condition.label,
|
|
state: satisfied.length ? 'satisfied' : notSatisfied.length && notSatisfied.length === evidence.length ? 'not-satisfied' : 'unknown',
|
|
satisfiedBy: ids('satisfied'),
|
|
notSatisfiedBy: ids('not-satisfied'),
|
|
unknownBy: ids('unknown'),
|
|
conflictingDeclarations: declaredStates.size > 1,
|
|
declaredVersusObserved: [...declaredStates].some(state => observedStates.size && !observedStates.has(state))
|
|
|| [...observedStates].some(state => declaredStates.size && !declaredStates.has(state)),
|
|
onlyUnlinkedSources: satisfied.length > 0 && satisfied.every(item => item.declared?.linkStatus === 'not-linked'),
|
|
relationships: input.catalog.relationships.filter(item => item.conditionId === condition.id)
|
|
});
|
|
}
|
|
|
|
const clusters: ContextClusterResult[] = input.catalog.clusters.map(cluster => {
|
|
const conditions = cluster.conditionIds.map(id => conditionResults.get(id)!);
|
|
const count = (state: ConditionState) => conditions.filter(item => item.state === state).length;
|
|
const satisfied = count('satisfied');
|
|
const unknown = count('unknown');
|
|
return {
|
|
clusterId: cluster.id, label: cluster.label, description: cluster.description,
|
|
state: satisfied >= cluster.minimumSatisfied ? 'present'
|
|
: satisfied + unknown < cluster.minimumSatisfied ? 'not-present' : 'undetermined',
|
|
minimumSatisfied: cluster.minimumSatisfied,
|
|
satisfied, notSatisfied: count('not-satisfied'), unknown,
|
|
conditions, sources: cluster.sources
|
|
};
|
|
});
|
|
|
|
const clustersOfCondition = (conditionId: string) => input.catalog.clusters.filter(cluster => cluster.conditionIds.includes(conditionId)).map(cluster => cluster.id);
|
|
const settings = new Map<string, ContextSettingAttachment>();
|
|
for (const fact of facts) {
|
|
const key = `${fact.scope}\u0000${fact.identityKey}`;
|
|
const semantic = semanticById.get(fact.semanticId)!;
|
|
const conditionIds = semantic.conditions.map(condition => condition.id);
|
|
const entry = settings.get(key) ?? {
|
|
identityKey: fact.identityKey, scope: fact.scope, semanticId: semantic.id, settingName: semantic.settingName,
|
|
factIds: [], evaluationIds: [], conditionIds, clusterIds: [...new Set(conditionIds.flatMap(clustersOfCondition))]
|
|
};
|
|
entry.factIds.push(fact.id);
|
|
settings.set(key, entry);
|
|
}
|
|
for (const evaluation of evaluations) {
|
|
const key = `${evaluation.scope}\u0000${evaluation.identityKey}`;
|
|
if (evaluation.matchedBy === 'assigned-by-recognition') {
|
|
const assignedEntry = settings.get(key) ?? {
|
|
identityKey: evaluation.identityKey, scope: evaluation.scope, settingName: evaluation.settingName,
|
|
factIds: [], evaluationIds: [], conditionIds: [], clusterIds: []
|
|
};
|
|
assignedEntry.evaluationIds.push(evaluation.id);
|
|
settings.set(key, assignedEntry);
|
|
continue;
|
|
}
|
|
const entry = settings.get(key) ?? {
|
|
identityKey: evaluation.identityKey, scope: evaluation.scope, settingName: evaluation.settingName,
|
|
factIds: [], evaluationIds: [], conditionIds: [], clusterIds: []
|
|
};
|
|
entry.evaluationIds.push(evaluation.id);
|
|
if (!entry.conditionIds.includes(evaluation.conditionId)) entry.conditionIds.push(evaluation.conditionId);
|
|
entry.clusterIds = [...new Set([...entry.clusterIds, ...clustersOfCondition(evaluation.conditionId)])];
|
|
settings.set(key, entry);
|
|
}
|
|
const findings = new Map<string, ContextFindingAttachment>();
|
|
for (const item of observations) {
|
|
const entry = findings.get(item.occurrenceId) ?? {
|
|
occurrenceId: item.occurrenceId, resultProjectionId: item.resultProjectionId, tool: item.tool, sourceRuleId: item.sourceRuleId,
|
|
observationIds: [], conditionIds: [], clusterIds: [], declaredReferenceIds: [], cisControlAreaIds: []
|
|
};
|
|
entry.observationIds.push(item.id);
|
|
if (!entry.conditionIds.includes(item.conditionId)) entry.conditionIds.push(item.conditionId);
|
|
entry.clusterIds = [...new Set([...entry.clusterIds, ...clustersOfCondition(item.conditionId)])];
|
|
findings.set(item.occurrenceId, entry);
|
|
}
|
|
// Tier 1 counts like a catalogued rule: the cluster joins the finding's clusters.
|
|
for (const item of techniqueAssignments) {
|
|
const entry = findings.get(item.occurrenceId) ?? {
|
|
occurrenceId: item.occurrenceId, resultProjectionId: item.resultProjectionId, tool: item.tool, sourceRuleId: item.sourceRuleId,
|
|
observationIds: [], conditionIds: [], clusterIds: [], declaredReferenceIds: [], cisControlAreaIds: []
|
|
};
|
|
if (!entry.clusterIds.includes(item.clusterId)) entry.clusterIds.push(item.clusterId);
|
|
findings.set(item.occurrenceId, entry);
|
|
}
|
|
// Tiers 2 to 5 count for their clusters and carry their confidence.
|
|
for (const item of recognitions) {
|
|
const entry = item.subject === 'scanner-finding'
|
|
? findings.get(item.occurrenceId!) ?? findings.set(item.occurrenceId!, {
|
|
occurrenceId: item.occurrenceId!, resultProjectionId: item.resultProjectionIds[0], tool: item.tool, sourceRuleId: item.sourceRuleId ?? '',
|
|
observationIds: [], conditionIds: [], clusterIds: [], declaredReferenceIds: [], cisControlAreaIds: []
|
|
}).get(item.occurrenceId!)!
|
|
: settings.get(`${item.scope}\u0000${item.identityKey}`);
|
|
if (!entry) continue;
|
|
if (!entry.clusterIds.includes(item.clusterId)) entry.clusterIds.push(item.clusterId);
|
|
entry.clusterConfidence = { ...(entry.clusterConfidence ?? {}), [item.clusterId]: item.confidence };
|
|
}
|
|
|
|
// Declared ATT&CK and D3FEND references: latest run per scan run, only findings of this scope, only declared and active IDs.
|
|
const scopedFindings = new Map((input.scannerFindings ?? []).map(finding => [finding.sourceResultId, finding]));
|
|
const groups = new Map<string, DeclaredReferenceGroup>();
|
|
for (const run of latestRuns.values()) {
|
|
for (const result of run.results) {
|
|
const finding = scopedFindings.get(result.occurrenceId);
|
|
if (!finding) continue;
|
|
const declared = [...new Map(result.references
|
|
.filter(reference => (reference.framework === 'mitre-attack' || reference.framework === 'mitre-d3fend')
|
|
&& reference.evidence === 'declared' && reference.status === 'active')
|
|
.map(reference => [`${reference.framework}\u0000${reference.id}`, reference])).values()];
|
|
for (const reference of declared) {
|
|
const groupKey = `${reference.framework}\u0000${reference.id}`;
|
|
const group = groups.get(groupKey) ?? { framework: reference.framework, id: reference.id, title: reference.title, kind: reference.kind, findings: [] };
|
|
if (!group.findings.some(item => item.occurrenceId === result.occurrenceId)) {
|
|
group.findings.push({ occurrenceId: result.occurrenceId, resultProjectionId: finding.resultProjectionId,
|
|
tool: finding.sourceTool ?? 'unknown', sourceRuleId: finding.sourceRuleId, outcome: finding.outcomeNormalized });
|
|
}
|
|
groups.set(groupKey, group);
|
|
const entry = findings.get(result.occurrenceId) ?? {
|
|
occurrenceId: result.occurrenceId, resultProjectionId: finding.resultProjectionId, tool: finding.sourceTool ?? 'unknown',
|
|
sourceRuleId: finding.sourceRuleId ?? '', observationIds: [], conditionIds: [], clusterIds: [], declaredReferenceIds: [], cisControlAreaIds: []
|
|
};
|
|
if (!entry.declaredReferenceIds.includes(reference.id)) entry.declaredReferenceIds.push(reference.id);
|
|
findings.set(result.occurrenceId, entry);
|
|
}
|
|
}
|
|
}
|
|
const declaredReferences = [...groups.values()]
|
|
.map(group => ({ ...group, findings: group.findings.sort((left, right) => left.occurrenceId.localeCompare(right.occurrenceId)) }))
|
|
.sort((left, right) => right.findings.length - left.findings.length || left.id.localeCompare(right.id));
|
|
|
|
// CIS control areas: the exact rule-number hierarchy inside each scan run; titles and counts from that scan report.
|
|
const cisTitles = sectionTitles;
|
|
const cisReported = cisReportedCounts(input.cisReportSections);
|
|
const areas = new Map<string, CisControlArea>();
|
|
for (const finding of input.scannerFindings ?? []) {
|
|
const ruleId = finding.sourceRuleId?.trim() ?? '';
|
|
if (finding.sourceTool !== 'cis-cat' || !finding.scanRunId || !CIS_RULE_ID.test(ruleId)) continue;
|
|
const scanRunId = finding.scanRunId;
|
|
const segments = ruleId.split('.');
|
|
const areaIds = segments.slice(0, -1).map((_segment, index) => {
|
|
const sectionId = segments.slice(0, index + 1).join('.');
|
|
const id = `cis:${scanRunId}:${sectionId}`;
|
|
const title = cisTitles.get(scanRunId)?.get(sectionId);
|
|
const reported = cisReported.get(scanRunId)?.get(sectionId);
|
|
const area = areas.get(id) ?? {
|
|
id, scanRunId, sectionId,
|
|
...(index > 0 ? { parentSectionId: segments.slice(0, index).join('.') } : {}),
|
|
...(title ? { title } : {}),
|
|
findings: 0, fail: 0, pass: 0, other: 0,
|
|
...(reported ? { reported } : {})
|
|
};
|
|
area.findings += 1;
|
|
if (finding.outcomeNormalized === 'fail') area.fail += 1;
|
|
else if (finding.outcomeNormalized === 'pass') area.pass += 1;
|
|
else area.other += 1;
|
|
areas.set(id, area);
|
|
return id;
|
|
});
|
|
const entry = findings.get(finding.sourceResultId) ?? {
|
|
occurrenceId: finding.sourceResultId, resultProjectionId: finding.resultProjectionId, tool: finding.sourceTool,
|
|
sourceRuleId: ruleId, observationIds: [], conditionIds: [], clusterIds: [], declaredReferenceIds: [], cisControlAreaIds: []
|
|
};
|
|
entry.cisControlAreaIds = [...new Set([...entry.cisControlAreaIds, ...areaIds])];
|
|
findings.set(finding.sourceResultId, entry);
|
|
}
|
|
const cisControlAreas = [...areas.values()].sort((left, right) => left.scanRunId.localeCompare(right.scanRunId)
|
|
|| left.sectionId.localeCompare(right.sectionId, undefined, { numeric: true }));
|
|
|
|
const levers: ContextLever[] = input.catalog.semantics.flatMap(semantic => {
|
|
const satisfiedConditions = semantic.conditions.filter(condition => conditionResults.get(condition.id)?.state === 'satisfied');
|
|
if (!satisfiedConditions.length) return [];
|
|
const satisfying = new Set(satisfiedConditions.flatMap(condition => conditionResults.get(condition.id)!.satisfiedBy));
|
|
return [{
|
|
semanticId: semantic.id,
|
|
settingName: semantic.settingName,
|
|
satisfiedConditions: satisfiedConditions.length,
|
|
clusters: new Set(satisfiedConditions.flatMap(condition => clustersOfCondition(condition.id))).size,
|
|
backupInstanceIds: [...new Set(facts.filter(fact => satisfying.has(fact.id)).map(fact => fact.backupInstanceId))].sort()
|
|
}];
|
|
});
|
|
|
|
return {
|
|
version: 1,
|
|
engine: ASSESSMENT_CONTEXT_ENGINE,
|
|
catalog: input.catalog.catalog,
|
|
projectId: input.projectId,
|
|
assessmentIds: input.assessmentIds,
|
|
...(input.scanRunIds?.length ? { scanRunIds: input.scanRunIds } : {}),
|
|
generatedAt: input.generatedAt,
|
|
repositoryRevision: input.repositoryRevision,
|
|
facts,
|
|
observations,
|
|
evaluations,
|
|
settings: [...settings.values()],
|
|
findings: [...findings.values()],
|
|
clusters,
|
|
declaredReferences,
|
|
cisControlAreas,
|
|
levers,
|
|
techniqueAssignments,
|
|
recognitions,
|
|
recognitionVocabulary: CONTEXT_RECOGNITION_VOCABULARY,
|
|
semantics: input.catalog.semantics.map(({ id, label, settingName, valueMeaning, sources }) => ({ id, label, settingName, valueMeaning, sources })),
|
|
scannerRules: input.catalog.scannerRules.map(({ tool, ruleId, conditionId, ruleText, sources }) => ({ tool, ruleId, conditionId, ruleText, sources })),
|
|
coverage: {
|
|
observations: input.observations.length,
|
|
semanticallyMapped: facts.length,
|
|
notSemanticallyMapped: notMapped,
|
|
unresolvedIdentity,
|
|
scannerFindings: input.scannerFindings?.length ?? 0,
|
|
scannerMapped: observations.length,
|
|
scannerNotInCatalog,
|
|
scannerGuardRejected,
|
|
gpoEvaluatedSettings: verdicts.size,
|
|
gpoMappedByVerdict,
|
|
gpoUncatalogued,
|
|
scannerMappedBySection,
|
|
scannerUncatalogued,
|
|
scannerWithDeclaredReferences: [...findings.values()].filter(item => item.declaredReferenceIds.length > 0).length,
|
|
scannerWithCisControlArea: [...findings.values()].filter(item => item.cisControlAreaIds.length > 0).length,
|
|
scannerMappedByDeclaredTechnique: new Set(techniqueAssignments.map(item => item.occurrenceId)).size,
|
|
scannerRecognized: countSubjects(recognitions, 'scanner-finding', 'recognized'),
|
|
scannerEstimated: countSubjects(recognitions, 'scanner-finding', 'estimated'),
|
|
gpoRecognized: countSubjects(recognitions, 'gpo-setting', 'recognized'),
|
|
gpoEstimated: countSubjects(recognitions, 'gpo-setting', 'estimated'),
|
|
uncataloguedWithoutRecognition: scannerUncatalogued + gpoUncatalogued
|
|
}
|
|
};
|
|
}
|