726 lines
27 KiB
TypeScript
726 lines
27 KiB
TypeScript
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<string, ReportPriorityBasis> {
|
||
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<ReportDomain>;
|
||
/** Shared catalog assignment, injected so the domain layer stays independent. */
|
||
domainOf: (result: UnifiedResultProjection) => string;
|
||
priorityBases?: Readonly<Record<string, ReportPriorityBasis>>;
|
||
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<ReportPriorityBasis, { kind: 'equal-weight' }>;
|
||
counts: Record<ReportPolicyEntry['verdict'], number>;
|
||
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<CanonicalSeverity, number>;
|
||
byOutcome: Record<CanonicalOutcome, number>;
|
||
byDomain: Record<string, number>;
|
||
bySource: Record<string, number>;
|
||
}
|
||
|
||
export interface ReportRemediationSummary {
|
||
/** Self-reported planning state. Never a resolved finding. */
|
||
selfReported: true;
|
||
items: number;
|
||
byStatus: Record<string, number>;
|
||
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<RemediationEvidenceState, number>;
|
||
/** Per source, so a format that cannot prove absence is visible as such. */
|
||
perSource: Array<{ toolId: string; counts: Record<RemediationEvidenceState, number> }>;
|
||
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<string, string>;
|
||
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<K extends string>(keys: readonly K[]): Record<K, number> {
|
||
return Object.fromEntries(keys.map(key => [key, 0])) as Record<K, number>;
|
||
}
|
||
|
||
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<string, UnifiedResultProjection[]>();
|
||
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<string, ReportFindingRef[]>();
|
||
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<ReportPolicyEntry['verdict']>(
|
||
['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} |