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

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
}
};
}