import type { CanonicalOutcome, CanonicalSeverity } from '../domain/canonical-model'; import type { ReportContextPublication } from './report-context'; import type { RemediationEvidenceState } from '../domain/timeslot-comparison'; import type { ToolPriorityBasisDefinition } from '../domain/tool-profile-definition'; import type { UnifiedResultProjection } from '../domain/unified-result'; import type { PolicySourceEvidence, RecordField } from '../domain/finding'; import { correlateReportResults, type ReportDomainConvergence, type ReportRemediationBundle, type ReportSingleSourcePolicyAction } from './report-correlation'; import { severityRank } from '../domain/severity-order'; import { buildReportStatements, type ReportStatement } from './report-narrative'; /** * Shared projection behind every assessment output (docs/REPORT_STRATEGY.md): * the Management Overview, the Technical Report, the Arbeitsgrundlage and the * Delta Report. The renderers choose depth, never content, so a number shown in * two artifacts is provably the same number. * * The projection is language-neutral. It carries codes, counts and identities; * every German wording lives in a renderer. It is also platform-neutral: no * clock, no storage, no network and no presentation types. */ export const REPORT_PROJECTION_VERSION = '1.3.0'; /* -------------------------------------------------------------------------- */ /* Priority basis */ /* -------------------------------------------------------------------------- */ /** * Severity is not a property every scanner delivers: CIS-CAT and the GPO * baseline comparison have none at all, and PingCastle cannot reach `critical` * by construction. A source therefore declares how its findings may be ordered, * or declares that it has no usable basis. Never infer one from an arbitrary * field named severity, level, score or rank. */ export type ReportPriorityBasis = | { kind: 'severity' } | { kind: 'numeric'; /** Exact mapped property behind `UnifiedResultProjection.score`. */ property: string; scale: string; direction: 'higher-is-worse' | 'lower-is-worse'; } | { kind: 'equal-weight'; reason: string } | { kind: 'undeclared' }; export type ReportPriorityBasisIssue = | 'basis-not-declared' | 'declared-severity-without-coverage' | 'declared-numeric-without-values'; /** * Reads a profile's declaration. An `unavailable` declaration is a decision, not * a gap: it becomes an explicit `equal-weight` basis carrying the profile's own * reason, so a report can say why a source is unranked instead of implying the * declaration is missing. */ export function reportPriorityBasisFrom(definition: ToolPriorityBasisDefinition): ReportPriorityBasis { const { basis } = definition; if (basis.kind === 'severity') return { kind: 'severity' }; if (basis.kind === 'numeric') { return { kind: 'numeric', property: basis.properties[0], scale: basis.scale, direction: basis.direction }; } return { kind: 'equal-weight', reason: basis.reason }; } /** Declarations of every registered profile, keyed by tool id. */ export function reportPriorityBases( definitions: ReadonlyArray<{ id: string; priorityBasis: ToolPriorityBasisDefinition }> ): Record { return Object.fromEntries(definitions.map(definition => [definition.id, reportPriorityBasisFrom(definition.priorityBasis)])); } export interface ReportSourcePriority { toolId: string; declared: ReportPriorityBasis; /** What the projection actually applied after validating the declaration. */ applied: ReportPriorityBasis; /** Share of this source's deviations carrying a usable severity, 0..1. */ severityCoverage: number; issues: ReportPriorityBasisIssue[]; } /* -------------------------------------------------------------------------- */ /* Input */ /* -------------------------------------------------------------------------- */ /** One row of the shared Client Info security-domain catalog. */ export interface ReportDomain { id: string; label: string; } /** A single environment fact. An absent value stays absent, never zero. */ export interface ReportFact { key: string; value?: string; } export interface ReportScoreSource { toolId: string; /** Versioned ASSESSOR contribution on the 0-100 scale, absent when unavailable. */ score?: number; method: 'native' | 'inverted-native' | 'pass-rate' | 'unavailable'; weight: number; nativeValue?: number; nativeProperty?: string; passed: number; deviations: number; assessed: number; observedAt?: string; issues: string[]; } /** * One rolled-up GPO policy setting. Supplied by the caller from the evaluation * layer so that the projection never re-derives a verdict of its own. */ export interface ReportPolicyEntry { id: string; /** Authoritative rolled-up verdict; diagnostics stay separate. */ verdict: 'Fail' | 'Pass' | 'Not configured' | 'No baseline rule' | 'Not comparable' | 'Review required'; conflict: boolean; duplicate: boolean; title: string; scope: string; identity: string; backupInstanceIds: string[]; } export interface ReportDiagnostic { code: string; reason?: string; /** Where the diagnostic was raised; keeps one observation from being restated. */ origin: 'import' | 'analysis' | 'aggregation' | 'evaluation' | 'comparison' | 'projection'; count: number; } export interface ReportProvenanceEntry { kind: string; id: string; version?: string; observedAt?: string; } /** Client-owned planning fields. Never proof, always self-reported. */ export interface ReportReviewInput { resultProjectionId: string; include: boolean; status: string; priority: string; owner?: string; due?: string; prerequisites?: string; access?: string; nextStep?: string; markers: string[]; riskAccepted: boolean; } /** * A stored timeslot comparison. The reference run is chosen in ASSESSOR; the * projection reads that choice and never derives a pairing of its own. */ export interface ReportDeltaInput { comparisonId: string; contractVersion: string; referenceRunId: string; referenceObservedAt?: string; currentRunId: string; currentObservedAt?: string; entries: Array<{ resultProjectionId: string; toolId?: string; state: RemediationEvidenceState; reasons: string[]; }>; } export interface ReportProjectionInput { generatedAt: string; project: { id: string; name: string }; assessmentId?: string; results: UnifiedResultProjection[]; domains: ReadonlyArray; /** Shared catalog assignment, injected so the domain layer stays independent. */ domainOf: (result: UnifiedResultProjection) => string; priorityBases?: Readonly>; scoreSources?: ReportScoreSource[]; workspaceCounts?: { deviations: number; assessed: number }; workspaceDeviationKeys?: string[]; overallScore?: number; environment?: ReportFact[]; policyEntries?: ReportPolicyEntry[]; technicalPolicySources?: Array<{ settingId: string; sources: PolicySourceEvidence[] }>; technicalFindingFields?: Array<{ resultProjectionId: string; fields: RecordField[] }>; policyScopeLimited?: boolean; reviews?: ReportReviewInput[]; diagnostics?: ReportDiagnostic[]; provenance?: ReportProvenanceEntry[]; delta?: ReportDeltaInput; /** Cap per domain block in the management cut. Zero means no cap. */ priorityLimitPerDomain?: number; } /* -------------------------------------------------------------------------- */ /* Output */ /* -------------------------------------------------------------------------- */ export interface ReportFindingRef { resultProjectionId: string; sourceRuleId?: string; toolId?: string; domainId: string; title: string; affectedAsset?: string; severity: CanonicalSeverity; outcome: CanonicalOutcome; /** The source's own rank value, meaningless across sources. */ rankValue?: number; rankBasis: ReportPriorityBasis; ranked: boolean; } export interface ReportDomainPriority { domainId: string; domainLabel: string; deviations: number; /** Grouped per source; ordering never crosses sources. */ sources: Array<{ toolId: string; basis: ReportPriorityBasis; ranked: boolean; shown: ReportFindingRef[]; total: number; }>; } export interface ReportDomainSummary { domainId: string; domainLabel: string; passed: number; deviations: number; assessed: number; /** Pass rate 0..1, absent when nothing comparable was assessed. */ passRate?: number; /** Evidence chain: the result IDs behind `passed` and `deviations`. */ evidence: { passed: string[]; deviations: string[] }; } export interface ReportGpoBlock { /** GPO deviations carry no severity and are never ranked against each other. */ basis: Extract; counts: Record; conflicts: number; duplicates: number; affectedSettings: number; affectedBackups: number; scopeLimited: boolean; } export interface ReportCoverage { total: number; assessed: number; deviations: number; passed: number; notRun: number; notApplicable: number; executionErrors: number; unknown: number; /** Sources present in the results, with how much of each could be assessed. */ perSource: Array<{ toolId: string; total: number; assessed: number; notRun: number }>; /** Evidence chain: result IDs per group; the unassessed groups partition every non-assessed result. */ evidence: { passed: string[]; deviations: string[]; notRun: string[]; notApplicable: string[]; executionErrors: string[]; otherUnassessed: string[]; }; } export interface ReportInventory { bySeverity: Record; byOutcome: Record; byDomain: Record; bySource: Record; } export interface ReportRemediationSummary { /** Self-reported planning state. Never a resolved finding. */ selfReported: true; items: number; byStatus: Record; withoutOwner: number; withoutDue: number; riskAccepted: number; } export interface ReportWorkItem { resultProjectionId: string; domainId: string; toolId?: string; title: string; affectedAsset?: string; severity: CanonicalSeverity; /** Planning fields, marked at the point of display as unverified. */ status: string; priority: string; owner?: string; due?: string; prerequisites?: string; access?: string; nextStep?: string; markers: string[]; riskAccepted: boolean; } export interface ReportDelta { comparisonId: string; contractVersion: string; referenceRunId: string; referenceObservedAt?: string; currentRunId: string; currentObservedAt?: string; counts: Record; /** Per source, so a format that cannot prove absence is visible as such. */ perSource: Array<{ toolId: string; counts: Record }>; entries: Array<{ resultProjectionId: string; toolId?: string; state: RemediationEvidenceState; reasons: string[]; title?: string; domainId?: string; }>; } export interface ReportProjection { /** New runs never create a legacy management file without Context; historical snapshots retain their renderer. */ managementContextRequired?: boolean; /** Frozen published Context result, never computed by this engine. */ context?: ReportContextPublication; version: typeof REPORT_PROJECTION_VERSION; generatedAt: string; project: { id: string; name: string }; assessmentId?: string; environment: ReportFact[]; overallScore?: number; scoreSources: ReportScoreSource[]; workspaceCounts?: { deviations: number; assessed: number }; workspaceDeviationKeys?: string[]; domains: ReportDomainSummary[]; priorities: ReportDomainPriority[]; gpo?: ReportGpoBlock; coverage: ReportCoverage; inventory: ReportInventory; /** Present only when reviews exist; an empty section is omitted, not rendered. */ remediation?: ReportRemediationSummary; /** * Every deviation, unordered. Report B groups it, artifact C uses it for the * complete second sheet. Ordering lives in `priorities`, never here. */ findings: ReportFindingRef[]; /** Internal technical evidence, retained only in the Technical Report cut. */ technicalResults?: UnifiedResultProjection[]; technicalResultDomains?: Record; policyEntries: ReportPolicyEntry[]; technicalPolicySources?: Array<{ settingId: string; sources: PolicySourceEvidence[] }>; technicalFindingFields?: Array<{ resultProjectionId: string; fields: RecordField[] }>; technicalReviews?: ReportReviewInput[]; workItems: ReportWorkItem[]; sourcePriorities: ReportSourcePriority[]; convergence: ReportDomainConvergence[]; bundles: ReportRemediationBundle[]; singleSourcePolicyActions: ReportSingleSourcePolicyAction[]; /** Deterministic key statements with their evidence; wording lives in the renderers. */ statements: ReportStatement[]; diagnostics: ReportDiagnostic[]; provenance: ReportProvenanceEntry[]; delta?: ReportDelta; } /* -------------------------------------------------------------------------- */ /* Build */ /* -------------------------------------------------------------------------- */ const SEVERITIES: CanonicalSeverity[] = ['critical', 'high', 'medium', 'low', 'info', 'unknown']; const OUTCOMES: CanonicalOutcome[] = ['fail', 'pass', 'warning', 'informational', 'execution_error', 'not_run', 'not_applicable', 'not_evaluated', 'unknown']; const REMEDIATION_STATES: RemediationEvidenceState[] = ['confirmed-open', 'verified-remediated', 'unverified-absent', 'not-applicable', 'not-comparable']; const GPO_EQUAL_WEIGHT = { kind: 'equal-weight', reason: 'A baseline deviation carries no native criticality.' } as const; function emptyCounts(keys: readonly K[]): Record { return Object.fromEntries(keys.map(key => [key, 0])) as Record; } function toolOf(result: UnifiedResultProjection): string { return result.sourceTool ?? 'unknown'; } /** * Validates a declared basis against the data it would order. A source that * declares severity but delivers none must not silently produce an ordering * that only describes its report format. */ function resolveSourcePriority( toolId: string, declared: ReportPriorityBasis, deviations: UnifiedResultProjection[] ): ReportSourcePriority { const withSeverity = deviations.filter(item => item.severityNormalized !== 'unknown').length; const severityCoverage = deviations.length ? withSeverity / deviations.length : 0; const issues: ReportPriorityBasisIssue[] = []; let applied = declared; if (declared.kind === 'undeclared') { issues.push('basis-not-declared'); } else if (declared.kind === 'severity' && withSeverity === 0) { issues.push('declared-severity-without-coverage'); applied = { kind: 'undeclared' }; } else if (declared.kind === 'numeric' && !deviations.some(item => typeof item.score === 'number' && Number.isFinite(item.score))) { issues.push('declared-numeric-without-values'); applied = { kind: 'undeclared' }; } return { toolId, declared, applied, severityCoverage, issues }; } function coverageEvidence(results: UnifiedResultProjection[]): ReportCoverage['evidence'] { const ids = (predicate: (result: UnifiedResultProjection) => boolean) => results.filter(predicate).map(result => result.resultProjectionId); const assessed = (result: UnifiedResultProjection) => result.outcomeNormalized === 'pass' || result.isDeviation; const unassessed = (outcome: CanonicalOutcome) => (result: UnifiedResultProjection) => !assessed(result) && result.outcomeNormalized === outcome; return { passed: ids(result => result.outcomeNormalized === 'pass'), deviations: ids(result => result.isDeviation), notRun: ids(unassessed('not_run')), notApplicable: ids(unassessed('not_applicable')), executionErrors: ids(unassessed('execution_error')), otherUnassessed: ids(result => !assessed(result) && !['not_run', 'not_applicable', 'execution_error'].includes(result.outcomeNormalized)) }; } function rankValueFor(result: UnifiedResultProjection, basis: ReportPriorityBasis): number | undefined { if (basis.kind === 'severity') return severityRank(result.severityNormalized); if (basis.kind === 'numeric') { return typeof result.score === 'number' && Number.isFinite(result.score) ? result.score : undefined; } return undefined; } /** * Ordering is defined inside one source only; there is no cross-source rank. * `severityRank` counts upwards from `critical`, so severity sorts ascending * while a higher-is-worse numeric basis sorts descending. */ function orderWithinSource(items: ReportFindingRef[], basis: ReportPriorityBasis): ReportFindingRef[] { if (basis.kind !== 'severity' && basis.kind !== 'numeric') return items; const worseFirst = basis.kind === 'numeric' && basis.direction === 'higher-is-worse'; return [...items].sort((left, right) => { const a = left.rankValue; const b = right.rankValue; if (a === undefined && b === undefined) return 0; if (a === undefined) return 1; if (b === undefined) return -1; return worseFirst ? b - a : a - b; }); } export function buildReportProjection(input: ReportProjectionInput): ReportProjection { const { results, domainOf, domains } = input; const bases = input.priorityBases ?? {}; const deviations = results.filter(result => result.isDeviation); const deviationsByTool = new Map(); for (const result of deviations) { const tool = toolOf(result); const bucket = deviationsByTool.get(tool); if (bucket) bucket.push(result); else deviationsByTool.set(tool, [result]); } const sourcePriorities = [...deviationsByTool].map(([toolId, items]) => resolveSourcePriority(toolId, bases[toolId] ?? { kind: 'undeclared' }, items)); const appliedByTool = new Map(sourcePriorities.map(entry => [entry.toolId, entry.applied])); const refOf = (result: UnifiedResultProjection): ReportFindingRef => { const tool = toolOf(result); const basis = appliedByTool.get(tool) ?? { kind: 'undeclared' as const }; return { resultProjectionId: result.resultProjectionId, sourceRuleId: result.sourceRuleId, toolId: result.sourceTool, domainId: domainOf(result), title: result.title, affectedAsset: result.affectedAsset, severity: result.severityNormalized, outcome: result.outcomeNormalized, rankValue: rankValueFor(result, basis), rankBasis: basis, ranked: basis.kind === 'severity' || basis.kind === 'numeric' }; }; const deviationRefs = deviations.map(refOf); // Priority Findings: grouped by domain, ordered per source, never across sources. const limit = input.priorityLimitPerDomain ?? 0; const priorities: ReportDomainPriority[] = domains.map(domain => { const inDomain = deviationRefs.filter(ref => ref.domainId === domain.id); const byTool = new Map(); for (const ref of inDomain) { const tool = ref.toolId ?? 'unknown'; const bucket = byTool.get(tool); if (bucket) bucket.push(ref); else byTool.set(tool, [ref]); } return { domainId: domain.id, domainLabel: domain.label, deviations: inDomain.length, sources: [...byTool].map(([toolId, items]) => { const basis = appliedByTool.get(toolId) ?? { kind: 'undeclared' as const }; const ordered = orderWithinSource(items, basis); return { toolId, basis, ranked: basis.kind === 'severity' || basis.kind === 'numeric', shown: limit > 0 ? ordered.slice(0, limit) : ordered, total: items.length }; }) }; }).filter(entry => entry.deviations > 0); const domainSummaries: ReportDomainSummary[] = domains.map(domain => { const inDomain = results.filter(result => domainOf(result) === domain.id); const passedIds = inDomain.filter(result => result.outcomeNormalized === 'pass').map(result => result.resultProjectionId); const deviationIds = inDomain.filter(result => result.isDeviation).map(result => result.resultProjectionId); const passed = passedIds.length; const failed = deviationIds.length; const assessed = passed + failed; return { domainId: domain.id, domainLabel: domain.label, passed, deviations: failed, assessed, passRate: assessed ? passed / assessed : undefined, evidence: { passed: passedIds, deviations: deviationIds } }; }); const coverage: ReportCoverage = { total: results.length, assessed: results.filter(result => result.outcomeNormalized === 'pass' || result.isDeviation).length, deviations: deviations.length, passed: results.filter(result => result.outcomeNormalized === 'pass').length, notRun: results.filter(result => result.outcomeNormalized === 'not_run').length, notApplicable: results.filter(result => result.outcomeNormalized === 'not_applicable').length, executionErrors: results.filter(result => result.outcomeNormalized === 'execution_error').length, unknown: results.filter(result => result.outcomeNormalized === 'unknown').length, perSource: [...new Set(results.map(toolOf))].sort().map(toolId => { const items = results.filter(result => toolOf(result) === toolId); return { toolId, total: items.length, assessed: items.filter(item => item.outcomeNormalized === 'pass' || item.isDeviation).length, notRun: items.filter(item => item.outcomeNormalized === 'not_run').length }; }), evidence: coverageEvidence(results) }; const inventory: ReportInventory = { bySeverity: emptyCounts(SEVERITIES), byOutcome: emptyCounts(OUTCOMES), byDomain: {}, bySource: {} }; for (const result of results) { inventory.bySeverity[result.severityNormalized] += 1; inventory.byOutcome[result.outcomeNormalized] += 1; const domainId = domainOf(result); inventory.byDomain[domainId] = (inventory.byDomain[domainId] ?? 0) + 1; const tool = toolOf(result); inventory.bySource[tool] = (inventory.bySource[tool] ?? 0) + 1; } const policyEntries = input.policyEntries ?? []; const gpo: ReportGpoBlock | undefined = policyEntries.length ? { basis: GPO_EQUAL_WEIGHT, counts: policyEntries.reduce((counts, entry) => { counts[entry.verdict] += 1; return counts; }, emptyCounts( ['Fail', 'Pass', 'Not configured', 'No baseline rule', 'Not comparable', 'Review required'])), conflicts: policyEntries.filter(entry => entry.conflict).length, duplicates: policyEntries.filter(entry => entry.duplicate).length, affectedSettings: new Set(policyEntries.filter(entry => entry.verdict === 'Fail') .map(entry => `${entry.scope}${entry.identity}`)).size, affectedBackups: new Set(policyEntries.filter(entry => entry.verdict === 'Fail') .flatMap(entry => entry.backupInstanceIds)).size, scopeLimited: input.policyScopeLimited ?? false } : undefined; const reviews = input.reviews ?? []; const reviewById = new Map(reviews.map(review => [review.resultProjectionId, review])); const remediation: ReportRemediationSummary | undefined = reviews.length ? { selfReported: true, items: reviews.filter(review => review.include).length, byStatus: reviews.filter(review => review.include).reduce>((counts, review) => { counts[review.status] = (counts[review.status] ?? 0) + 1; return counts; }, {}), withoutOwner: reviews.filter(review => review.include && !review.owner).length, withoutDue: reviews.filter(review => review.include && !review.due).length, riskAccepted: reviews.filter(review => review.riskAccepted).length } : undefined; // Artifact C sheet 1: only what the consultant selected through `include`. const workItems: ReportWorkItem[] = results.flatMap(result => { const review = reviewById.get(result.resultProjectionId); if (!review?.include) return []; return [{ resultProjectionId: result.resultProjectionId, domainId: domainOf(result), toolId: result.sourceTool, title: result.title, affectedAsset: result.affectedAsset, severity: result.severityNormalized, status: review.status, priority: review.priority, owner: review.owner, due: review.due, prerequisites: review.prerequisites, access: review.access, nextStep: review.nextStep, markers: review.markers, riskAccepted: review.riskAccepted }]; }); const delta: ReportDelta | undefined = input.delta ? { comparisonId: input.delta.comparisonId, contractVersion: input.delta.contractVersion, referenceRunId: input.delta.referenceRunId, referenceObservedAt: input.delta.referenceObservedAt, currentRunId: input.delta.currentRunId, currentObservedAt: input.delta.currentObservedAt, counts: input.delta.entries.reduce((counts, entry) => { counts[entry.state] += 1; return counts; }, emptyCounts(REMEDIATION_STATES)), perSource: [...new Set(input.delta.entries.map(entry => entry.toolId ?? 'unknown'))].sort() .map(toolId => ({ toolId, counts: input.delta!.entries.filter(entry => (entry.toolId ?? 'unknown') === toolId) .reduce((counts, entry) => { counts[entry.state] += 1; return counts; }, emptyCounts(REMEDIATION_STATES)) })), entries: input.delta.entries.map(entry => { const result = results.find(item => item.resultProjectionId === entry.resultProjectionId); return { resultProjectionId: entry.resultProjectionId, toolId: entry.toolId, state: entry.state, reasons: entry.reasons, title: result?.title, domainId: result ? domainOf(result) : undefined }; }) } : undefined; const statements = buildReportStatements(domainSummaries, coverage); const diagnostics = [...(input.diagnostics ?? [])]; const correlation = correlateReportResults(results, domains, domainOf, policyEntries); for (const entry of sourcePriorities) { for (const issue of entry.issues) { diagnostics.push({ code: `report-priority-${issue}:${entry.toolId}`, origin: 'projection', count: 1 }); } } return { version: REPORT_PROJECTION_VERSION, generatedAt: input.generatedAt, project: input.project, assessmentId: input.assessmentId, environment: input.environment ?? [], overallScore: input.overallScore, scoreSources: input.scoreSources ?? [], workspaceCounts: input.workspaceCounts ? { ...input.workspaceCounts } : undefined, workspaceDeviationKeys: input.workspaceDeviationKeys ? [...input.workspaceDeviationKeys] : undefined, domains: domainSummaries, priorities, gpo, coverage, inventory, remediation, findings: deviationRefs, technicalResults: results, technicalResultDomains: Object.fromEntries(results.map(result => [result.resultProjectionId, domainOf(result)])), policyEntries, technicalPolicySources: input.technicalPolicySources, technicalFindingFields: input.technicalFindingFields, technicalReviews: reviews, workItems, sourcePriorities, ...correlation, statements, diagnostics, provenance: input.provenance ?? [], delta }; }