Assessor_2.0/src/exports/management-overview-report.ts

451 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type {
ReportDomainPriority,
ReportDomainSummary,
ReportFindingRef,
ReportGpoBlock,
ReportPriorityBasis,
ReportProjection,
ReportScoreSource
} from '../domain/report-projection';
import { escapeHtml } from '../shared/html';
import {
DASH,
SEVERITY_LABEL,
SEVERITY_ORDER,
barList,
filterToolbar,
integer,
reportDocument,
scoreDonut,
severityText,
toolLabel,
type SeverityKey
} from './report-theme';
/**
* Artifact A of docs/REPORT_STRATEGY.md: the Management Overview.
*
* A frozen snapshot for the customer's decision makers. It answers how the
* environment stands, what is most urgent, what the statement rests on and what
* is not known — and deliberately carries no registry keys, SIDs, evidence paths
* or parser versions, so the file can be forwarded as it is.
*
* German carries the prose; established English technical terminology stays
* English. The file is self-contained: styles, font and the small filter script
* are inline, nothing is fetched when it is opened.
*/
export const MANAGEMENT_OVERVIEW_RENDERER_VERSION = '2.1.0';
/** Unknown stays unknown. An absent value never becomes a zero. */
function value(input: string | number | undefined | null): string {
if (input === undefined || input === null) return DASH;
if (typeof input === 'number' && !Number.isFinite(input)) return DASH;
const text = String(input).trim();
return text === '' ? DASH : escapeHtml(text);
}
function percent(ratio: number | undefined, digits = 0): string {
return typeof ratio === 'number' && Number.isFinite(ratio)
? escapeHtml((ratio * 100).toLocaleString('de-DE', {
minimumFractionDigits: digits, maximumFractionDigits: digits
}))
: DASH;
}
function germanDateTime(iso: string): string {
const parsed = Date.parse(iso);
if (!Number.isFinite(parsed)) return value(iso);
return escapeHtml(new Date(parsed).toLocaleString('de-DE', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
}));
}
function germanDate(iso: string | undefined): string {
if (!iso) return DASH;
const parsed = Date.parse(iso);
if (!Number.isFinite(parsed)) return value(iso);
return escapeHtml(new Date(parsed).toLocaleDateString('de-DE', {
year: 'numeric', month: '2-digit', day: '2-digit'
}));
}
/**
* Binding wording from docs/SCORING_MODEL.md. A method is named, never implied,
* and the GPO contribution is never called Baseline-Compliance.
*/
const SCORE_METHOD_LABEL: Record<ReportScoreSource['method'], string> = {
native: 'Nativer Score des Werkzeugs',
'inverted-native': 'Nativer Risk, invertiert als 100 − Risk',
'pass-rate': 'Pass Rate der bewerteten Checks',
unavailable: 'Kein verwertbarer Score'
};
/** Names why a source is ordered the way it is, or why it is not ordered. */
function basisLabel(basis: ReportPriorityBasis): string {
if (basis.kind === 'severity') return 'Sortiert nach nativer Severity';
if (basis.kind === 'numeric') {
const direction = basis.direction === 'higher-is-worse' ? 'höher ist kritischer' : 'niedriger ist kritischer';
return `Sortiert nach ${escapeHtml(basis.property)} (${escapeHtml(basis.scale)}, ${direction})`;
}
if (basis.kind === 'equal-weight') return `Ohne Rangfolge: ${escapeHtml(basis.reason)}`;
return 'Ohne Rangfolge: Die Quelle deklariert keinen belastbaren Maßstab.';
}
function severityCounts(findings: ReportFindingRef[]): Record<SeverityKey, number> {
const counts = Object.fromEntries(SEVERITY_ORDER.map(key => [key, 0])) as Record<SeverityKey, number>;
for (const finding of findings) counts[finding.severity] += 1;
return counts;
}
/** Domain pass rates as a connected spine, not a conventional table. */
function domainSpine(domains: ReportDomainSummary[]): string {
if (!domains.length) return '<p class="empty">Keine Security Domain wurde bewertet.</p>';
return `<ol class="spine">${domains.map(domain => {
const known = typeof domain.passRate === 'number';
const width = known ? Math.round(domain.passRate! * 100) : 0;
return `<li class="spine__item">
<span class="spine__node" aria-hidden="true"></span>
<span class="spine__label">${escapeHtml(domain.domainLabel)}</span>
<span class="spine__meter"><span class="spine__fill" style="width:${width}%"></span></span>
<span class="spine__figure">${known ? `${percent(domain.passRate)} %` : DASH}</span>
<span class="spine__counts">${domain.assessed
? `${integer(domain.deviations)} Deviations von ${integer(domain.assessed)} bewertet`
: 'nicht bewertet'}</span>
</li>`;
}).join('')}</ol>`;
}
function findingRow(finding: ReportFindingRef, ranked: boolean, member: string): string {
const severity = finding.severity;
return `<li class="finding" data-filter-item data-filter-member="${member}" data-severity="${escapeHtml(severity)}">
<span class="finding__title">${escapeHtml(finding.title)}</span>
<span class="finding__meta">
${ranked && finding.rankValue !== undefined && finding.rankBasis.kind === 'numeric'
? `<span class="tag">${escapeHtml(finding.rankBasis.property)} ${integer(finding.rankValue)}</span>` : ''}
${severityText(severity)}
</span>
</li>`;
}
function prioritySection(priorities: ReportDomainPriority[]): string {
if (!priorities.length) return '<p class="empty list__empty">Es wurde keine Deviation festgestellt.</p>';
return priorities.map((domain, domainIndex) => {
const member = `domain-${domainIndex}`;
return `<details class="group domain" data-filter-group="${member}" open>
<summary class="domain__head">
<span>${escapeHtml(domain.domainLabel)}</span>
<span class="domain__count">${integer(domain.deviations)} Deviations</span>
</summary>
${domain.sources.map(source => `<section class="source-block">
<header class="source-block__head">
<span class="source-block__tool">${escapeHtml(toolLabel(source.toolId))}</span>
<span class="source-block__basis">${basisLabel(source.basis)}</span>
</header>
<ol class="findings">${source.shown.map(item => findingRow(item, source.ranked, member)).join('')}</ol>
${source.shown.length < source.total
? `<p class="source-block__more">${integer(source.total - source.shown.length)} weitere Deviations dieser Quelle stehen im Technical Report.</p>`
: ''}
</section>`).join('')}
</details>`;
}).join('');
}
/**
* GPO deviations carry no native criticality, so they form their own
* equal-weight block instead of being ranked against severity-bearing sources.
*/
function gpoSection(gpo: ReportGpoBlock): string {
const rows: Array<[string, number]> = [
['Deviation', gpo.counts.Fail],
['Review required', gpo.counts['Review required']],
['Passed', gpo.counts.Pass],
['Not configured', gpo.counts['Not configured']],
['No baseline rule', gpo.counts['No baseline rule']],
['Not comparable', gpo.counts['Not comparable']]
];
return `<div class="grid grid--2 gpo">
<article class="card">
<header class="card__head">
<h3 class="card__title">GPO &amp; Baseline</h3>
<span class="card__meta">${integer(gpo.counts.Fail)} Deviations</span>
</header>
${barList(rows.map(([label, count]) => ({ label, value: count })), 'Keine GPO Policy Settings bewertet.')}
<p class="note">${escapeHtml(gpo.basis.reason)} Die Einträge stehen deshalb gleichgewichtig nebeneinander.</p>
</article>
<article class="card">
<header class="card__head"><h3 class="card__title">Betroffene GPO-Objekte</h3></header>
<div class="kpis">
<div class="kpi"><span class="kpi__label">Policy Settings mit Deviation</span><span class="kpi__figure">${integer(gpo.affectedSettings)}</span></div>
<div class="kpi"><span class="kpi__label">betroffene GPO Backups</span><span class="kpi__figure">${integer(gpo.affectedBackups)}</span></div>
<div class="kpi"><span class="kpi__label">Conflicts</span><span class="kpi__figure">${integer(gpo.conflicts)}</span></div>
<div class="kpi"><span class="kpi__label">Duplicates</span><span class="kpi__figure">${integer(gpo.duplicates)}</span></div>
</div>
<p class="note">
<code>Not configured</code>, <code>No baseline rule</code> und <code>Not comparable</code>
sind score-neutral und begründen keine Maßnahme.
${gpo.scopeLimited
? 'Die Auswertung bezieht sich ausschließlich auf den importierten GPO-Backup-Satz und trifft keine Aussage über die gesamte Domäne.'
: ''}
</p>
</article>
</div>`;
}
function kpiTile(label: string, figure: string, caption: string): string {
return `<article class="card kpi">
<span class="kpi__label">${escapeHtml(label)}</span>
<span class="kpi__figure">${figure}</span>
<span class="kpi__caption">${caption}</span>
</article>`;
}
const MANAGEMENT_CSS = `
.spine{list-style:none;margin:0;padding:0 0 0 16px;border-left:1px solid var(--border-default)}
.spine__item{position:relative;display:grid;grid-template-columns:minmax(150px,1fr) minmax(80px,160px) 56px;
gap:4px 14px;align-items:center;padding:8px 0}
.spine__node{position:absolute;left:-20px;top:17px;width:7px;height:7px;border-radius:50%;background:var(--donut-all)}
.spine__label{font-size:16px;font-weight:600}
.spine__meter{height:10px;background:var(--bar-track)}
.spine__fill{display:block;height:100%;background:var(--donut-all)}
.spine__figure{font-size:16px;font-weight:600;font-variant-numeric:tabular-nums;text-align:right}
.spine__counts{grid-column:1 / -1;font-size:14px;color:var(--text-secondary)}
.kpis{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}
.kpis .kpi__figure{font-size:32px}
.domain__count{margin-left:auto}
.domain__count{font-size:14px;font-weight:600;color:var(--text-secondary);font-variant-numeric:tabular-nums}
.source-block{padding:12px 20px 16px}
.source-block[hidden]{display:none}
.source-block__head{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px}
.source-block__tool{font-size:16px;font-weight:600}
.source-block__basis{font-size:14px;color:var(--text-secondary)}
.source-block__more{margin-top:8px;font-size:14px;color:var(--text-muted)}
.findings{list-style:none;margin:8px 0 0;padding:0}
.finding{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:16px;align-items:center;
padding:10px 0;border-bottom:1px solid var(--border-subtle)}
.finding:last-child{border-bottom:0}
.finding[hidden]{display:none}
.finding__title{font-size:16px;font-weight:400;overflow-wrap:anywhere}
.finding__meta{display:flex;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:10px}
.gpo{margin-top:16px}
@media (max-width:720px){
.spine__item{grid-template-columns:1fr 60px}
.spine__meter{grid-column:1 / -1}
.finding{grid-template-columns:1fr}
.finding__meta{justify-content:flex-start}
}`;
export function buildManagementOverviewHtml(projection: ReportProjection): string {
const facts = projection.environment.length
? projection.environment
: [{ key: 'Umgebung', value: undefined }];
const remediation = projection.remediation;
const coverage = projection.coverage;
const isGpo = (toolId: string | undefined) => toolLabel(toolId) === 'Group Policy';
const scanFindings = projection.findings.filter(finding => !isGpo(finding.toolId));
const gpoSettings = projection.gpo?.counts.Fail ?? 0;
const severities = severityCounts(scanFindings);
const criticalHigh = severities.critical + severities.high;
const deviationLine = [
`${integer(scanFindings.length)} aus Scans`,
projection.gpo ? `${integer(gpoSettings)} GPO Policy Settings` : ''
].filter(Boolean).join(' · ');
const dated = projection.scoreSources.filter(source => germanDate(source.observedAt) !== DASH);
const sourceLine = dated.length
? dated.map(source => `${escapeHtml(toolLabel(source.toolId))}: ${germanDate(source.observedAt)}`).join(' • ')
: projection.scoreSources.map(source => escapeHtml(toolLabel(source.toolId))).join(' • ');
const head = `<header class="doc-head">
<p class="doc-head__kicker">ASSESSOR · Management Overview</p>
<h1>${escapeHtml(projection.project.name)}</h1>
${sourceLine ? `<p class="doc-head__sources">${sourceLine}</p>` : ''}
<dl class="facts">
${facts.map(fact => `<div>
<dt>${escapeHtml(fact.key)}</dt><dd>${value(fact.value)}</dd>
</div>`).join('')}
<div><dt>Assessment</dt><dd>${value(projection.assessmentId)}</dd></div>
<div><dt>Berichtsdatum</dt><dd>${germanDateTime(projection.generatedAt)}</dd></div>
</dl>
</header>`;
const posture = `<section class="section segment" aria-labelledby="posture">
<div class="section__head"><h2 id="posture">Overall Posture</h2></div>
<div class="grid">
${kpiTile('Deviations', integer(scanFindings.length + gpoSettings), deviationLine)}
${kpiTile('Critical / High', integer(criticalHigh), 'Scan-Deviations mit hoher nativer Kritikalität')}
<article class="card kpi kpi--donut">
<span class="kpi__label">Overall Score</span>
${scoreDonut(projection.overallScore, 'Overall Score')}
</article>
</div>
<div class="grid grid--2" style="margin-top:16px">
<article class="card">
<header class="card__head"><h3 class="card__title">Security Domains</h3><span class="card__meta">Pass Rate je Domain</span></header>
${domainSpine(projection.domains)}
</article>
<div class="stack">
<article class="card">
<header class="card__head"><h3 class="card__title">Severity-Verteilung</h3><span class="card__meta">${integer(scanFindings.length)} Scan-Deviations</span></header>
${barList(SEVERITY_ORDER.filter(key => key !== 'unknown' || severities.unknown > 0)
.map(key => ({ label: SEVERITY_LABEL[key], value: severities[key], tone: key })), 'Keine Deviations.')}
</article>
<article class="card">
<header class="card__head"><h3 class="card__title">Deviations je Quelle</h3></header>
${barList(projection.scoreSources.map(source => ({ label: toolLabel(source.toolId), value: source.deviations })), 'Keine Quelle bewertet.')}
</article>
</div>
</div>
<p class="note">
Der Overall Score ist ein Mixed-Method-Index über die unten genannten Quellen.
Der GPO-Anteil bezeichnet die Konformität der konfigurierten, vergleichbaren
Einstellungen im Scope — er ist keine Aussage über eine vollständig
ausgerollte Microsoft Baseline. Security-Domain-Werte sind Pass Rates und keine
Zerlegung des Index. Quellen ohne native Severity erscheinen als „ohne Severity“.
</p>
</section>`;
// With a GPO block, GPO deviations are shown there once per policy setting, not per baseline comparison.
const priorityDomains = projection.gpo
? projection.priorities.map(domain => {
const sources = domain.sources.filter(source => !isGpo(source.toolId));
return { ...domain, sources, deviations: sources.reduce((sum, source) => sum + source.total, 0) };
}).filter(domain => domain.sources.length)
: projection.priorities;
const shownFindings = priorityDomains.reduce((sum, domain) =>
sum + domain.sources.reduce((inner, source) => inner + source.shown.length, 0), 0);
const priorities = `<section class="section segment" aria-labelledby="priorities" data-filter-scope>
<div class="section__head"><h2 id="priorities">Priority Findings</h2><a class="to-top" href="#top">↑ Nach oben</a></div>
<p class="section__lead">
Gruppiert nach Security Domain. Innerhalb einer Domain wird je Quelle nach deren
eigenem, deklariertem Maßstab geordnet; eine quellenübergreifende Rangfolge
gibt es nicht, weil die Maßstäbe nicht vergleichbar sind.
</p>
${shownFindings ? filterToolbar({
placeholder: 'Findings durchsuchen',
reset: 'Alle',
groups: [{
key: 'severity', label: 'Severity',
values: (['critical', 'high'] as SeverityKey[]).filter(key => severities[key] > 0)
.map(key => ({ value: key, label: SEVERITY_LABEL[key] }))
}]
}) : ''}
<div class="card list">
<div class="list__head"><span class="list__crumb">Findings ›</span><span class="list__title">Nach Security Domain</span>
<span class="count-tag">${integer(shownFindings)}</span></div>
${prioritySection(priorityDomains)}
</div>
${projection.gpo ? '<p class="note">GPO-Deviations tragen keine native Kritikalität und stehen gleichgewichtig im Block GPO &amp; Baseline.</p>' : ''}
${projection.gpo ? gpoSection(projection.gpo) : ''}
</section>`;
const convergence = `<section class="section segment" aria-labelledby="convergence">
<div class="section__head"><h2 id="convergence">Konvergenz der Quellen</h2></div>
<p class="section__lead">Mehrere Quellen in einer Security Domain belegen nicht automatisch denselben Finding.</p>
<div class="grid">${projection.convergence.filter(item => item.sourceCount > 0).map(item => `<article class="card">
<header class="card__head"><h3 class="card__title">${value(projection.domains.find(domain => domain.domainId === item.domainId)?.domainLabel ?? item.domainId)}</h3>
<span class="card__meta">${integer(item.sourceCount)} Quellen</span></header>
<p>${integer(item.sourceCount)} unabhängige Quellen mit Deviations: ${item.sources.filter(source => source.deviations > 0)
.map(source => escapeHtml(toolLabel(source.toolId))).join(', ')}.</p>
<p class="note">${item.evidence === 'shared-framework-reference'
? 'Belegte Konvergenz: Mindestens zwei Quellen nennen dieselbe Framework-Referenz.'
: 'Nur thematische Nähe innerhalb der Domain; ein gemeinsamer Finding ist nicht belegt und bedarf Validierung.'}</p>
</article>`).join('') || '<article class="card"><p class="empty">Keine quellenübergreifende Konvergenz belegt.</p></article>'}</div>
</section>`;
const bundles = `<section class="section segment" aria-labelledby="bundles">
<div class="section__head"><h2 id="bundles">Maßnahmenbündel</h2></div>
<p class="section__lead">Gleiche Framework-Referenzen gruppieren Findings ohne Aussage zu Aufwand, Nutzen oder Priorität.</p>
<article class="card">
${projection.bundles.length ? `<div class="table-scroll"><table><thead><tr><th>Framework-Referenz</th><th class="num">Findings</th></tr></thead>
<tbody>${projection.bundles.map(bundle => `<tr><td>${escapeHtml(bundle.key)}</td><td class="num">${integer(bundle.members.length)}</td></tr>`).join('')}</tbody></table></div>`
: '<p class="empty">Keine mehrfach belegte Framework-Referenz.</p>'}
<p class="note">Einzelquellen-GPO-Maßnahmen ohne Conflict oder Duplicate: ${integer(projection.singleSourcePolicyActions.length)}.</p>
</article>
</section>`;
const remediationSection = remediation ? `<section class="section segment" aria-labelledby="remediation">
<div class="section__head"><h2 id="remediation">Remediation-Status</h2></div>
<p class="section__lead">
Selbstauskunft des Kunden aus der Arbeitsgrundlage. Diese Angaben sind
<strong>kein technischer Nachweis</strong>: Eine Maßnahme gilt erst dann als
behoben, wenn ein Rescan das belegt.
</p>
<div class="grid">
${kpiTile('Maßnahmen in Arbeit', integer(remediation.items), 'Selbstauskunft')}
${kpiTile('ohne Owner', integer(remediation.withoutOwner), 'Maßnahmen')}
${kpiTile('ohne Termin', integer(remediation.withoutDue), 'Maßnahmen')}
${kpiTile('Accepted Risk', integer(remediation.riskAccepted), 'Maßnahmen')}
</div>
${Object.keys(remediation.byStatus).length ? `<article class="card" style="margin-top:16px">
<header class="card__head"><h3 class="card__title">Status</h3></header>
${barList(Object.entries(remediation.byStatus).map(([status, count]) => ({ label: status, value: count })), '')}
</article>` : ''}
</section>` : '';
const coverageSection = `<section class="section segment" aria-labelledby="coverage">
<div class="section__head"><h2 id="coverage">Abdeckung und Lücken</h2></div>
<div class="grid">
${kpiTile('bewertet', integer(coverage.assessed), `von ${integer(coverage.total)} Ergebnissen`)}
${kpiTile('Deviations', integer(coverage.deviations), 'bewertete Abweichungen')}
${kpiTile('not run', integer(coverage.notRun), 'nicht ausgeführt')}
${kpiTile('not applicable', integer(coverage.notApplicable), 'nicht anwendbar')}
${kpiTile('execution errors', integer(coverage.executionErrors), 'fehlerhaft ausgeführt')}
</div>
<p class="note">
Von insgesamt ${integer(coverage.total)} Ergebnissen konnten ${integer(coverage.assessed)}
bewertet werden. Nicht ausgeführte, nicht anwendbare und fehlerhaft ausgeführte
Checks zählen weder als bestanden noch als Deviation.
</p>
<article class="card" style="margin-top:16px">
<header class="card__head"><h3 class="card__title">Abdeckung je Quelle</h3></header>
<div class="table-scroll"><table>
<thead><tr><th scope="col">Quelle</th><th scope="col" class="num">Ergebnisse</th><th scope="col" class="num">bewertet</th><th scope="col" class="num">not run</th><th scope="col" class="num">not run Anteil</th></tr></thead>
<tbody>${coverage.perSource.map(source => `<tr>
<td>${escapeHtml(toolLabel(source.toolId))}</td>
<td class="num">${integer(source.total)}</td>
<td class="num">${integer(source.assessed)}</td>
<td class="num">${integer(source.notRun)}</td>
<td class="num">${source.total ? `${percent(source.notRun / source.total)} %` : DASH}</td>
</tr>`).join('') || `<tr><td colspan="5">${DASH}</td></tr>`}</tbody>
</table></div>
</article>
</section>`;
const sources = `<section class="section segment" aria-labelledby="sources">
<div class="section__head"><h2 id="sources">Source Overview</h2></div>
<article class="card">
<header class="card__head"><h3 class="card__title">Score-Beitrag je Quelle</h3></header>
<div class="table-scroll"><table>
<thead><tr>
<th scope="col">Quelle</th><th scope="col">Stand</th><th scope="col" class="num">Beitrag</th>
<th scope="col">Methode</th><th scope="col" class="num">Pass</th><th scope="col" class="num">Deviations</th>
</tr></thead>
<tbody>${projection.scoreSources.length ? projection.scoreSources.map(source => `<tr>
<td>${escapeHtml(toolLabel(source.toolId))}</td>
<td>${germanDate(source.observedAt)}</td>
<td class="num">${typeof source.score === 'number' ? integer(Math.round(source.score)) : DASH}</td>
<td>${escapeHtml(SCORE_METHOD_LABEL[source.method])}</td>
<td class="num">${integer(source.passed)}</td>
<td class="num">${integer(source.deviations)}</td>
</tr>`).join('') : `<tr><td colspan="6">${DASH}</td></tr>`}</tbody>
</table></div>
${projection.scoreSources.some(source => source.method === 'inverted-native')
? '<p class="note">Eine invertierte Quelle liefert nativ einen Risk-Wert, bei dem ein höherer Wert schlechter ist. Er geht als <code>100 − Risk</code> in den Index ein; der rohe Risk-Wert bleibt in der Quelle erhalten.</p>'
: ''}
</article>
</section>`;
return reportDocument(
`Management Overview · ${projection.project.name}`,
`${head}
${posture}
${priorities}
${remediationSection}
${coverageSection}
${convergence}
${bundles}
${sources}`,
MANAGEMENT_CSS
);
}