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

494 lines
26 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
/**
* 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: every style is inline, nothing is
* fetched when it is opened.
*/
export const MANAGEMENT_OVERVIEW_RENDERER_VERSION = '1.0.0';
const DASH = '—';
/** 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 integer(input: number | undefined): string {
return typeof input === 'number' && Number.isFinite(input)
? escapeHtml(input.toLocaleString('de-DE'))
: DASH;
}
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.';
}
const SEVERITY_LABEL: Record<ReportFindingRef['severity'], string> = {
critical: 'Critical', high: 'High', medium: 'Medium',
low: 'Low', info: 'Info', unknown: 'Unknown'
};
/**
* Shared donut geometry: full 360 degrees, continuous neutral track, value arc
* clockwise from twelve o'clock, flat caps. The number sits on the exact centre
* and its unit follows to the right without displacing it.
*/
function donut(score: number | undefined, caption: string): string {
const radius = 54;
const circumference = 2 * Math.PI * radius;
const ratio = typeof score === 'number' && Number.isFinite(score)
? Math.min(Math.max(score, 0), 100) / 100
: 0;
const dash = `${(circumference * ratio).toFixed(3)} ${(circumference * (1 - ratio)).toFixed(3)}`;
const display = typeof score === 'number' && Number.isFinite(score)
? Math.round(score).toLocaleString('de-DE')
: DASH;
const hasScore = display !== DASH;
return `<figure class="donut">
<svg viewBox="0 0 128 128" role="img" aria-label="Overall Score ${hasScore ? `${display} von 100` : 'nicht verfügbar'}">
<circle class="donut__track" cx="64" cy="64" r="${radius}"></circle>
${hasScore ? `<circle class="donut__value" cx="64" cy="64" r="${radius}"
stroke-dasharray="${dash}" transform="rotate(-90 64 64)"></circle>` : ''}
</svg>
<figcaption class="donut__readout">
<span class="donut__number">${escapeHtml(display)}${hasScore ? '<span class="donut__unit">/100</span>' : ''}</span>
<span class="donut__caption">${escapeHtml(caption)}</span>
</figcaption>
</figure>`;
}
/** 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">${integer(domain.deviations)} Deviations von ${integer(domain.assessed)} bewertet</span>
</li>`;
}).join('')}</ol>`;
}
function findingRow(finding: ReportFindingRef, ranked: boolean): string {
const severity = finding.severity;
const showSeverity = severity !== 'unknown';
return `<li class="finding">
<span class="finding__accent finding__accent--${escapeHtml(severity)}" aria-hidden="true"></span>
<span class="finding__title">${escapeHtml(finding.title)}</span>
<span class="finding__meta">
${showSeverity ? `<span class="badge badge--${escapeHtml(severity)}">${escapeHtml(SEVERITY_LABEL[severity])}</span>` : ''}
${ranked && finding.rankValue !== undefined && finding.rankBasis.kind === 'numeric'
? `<span class="badge badge--neutral">${escapeHtml(finding.rankBasis.property)} ${integer(finding.rankValue)}</span>` : ''}
${finding.affectedAsset ? `<span class="finding__asset">${escapeHtml(finding.affectedAsset)}</span>` : ''}
</span>
</li>`;
}
function prioritySection(priorities: ReportDomainPriority[]): string {
if (!priorities.length) return '<p class="empty">Es wurde keine Deviation festgestellt.</p>';
return priorities.map(domain => `<article class="tile domain">
<header class="domain__head">
<h3>${escapeHtml(domain.domainLabel)}</h3>
<span class="domain__count">${integer(domain.deviations)} Deviations</span>
</header>
${domain.sources.map(source => `<section class="source">
<header class="source__head">
<span class="source__tool">${escapeHtml(source.toolId)}</span>
<span class="source__basis">${basisLabel(source.basis)}</span>
</header>
<ol class="findings">${source.shown.map(item => findingRow(item, source.ranked)).join('')}</ol>
${source.shown.length < source.total
? `<p class="source__more">${integer(source.total - source.shown.length)} weitere Deviations dieser Quelle stehen im Technical Report.</p>`
: ''}
</section>`).join('')}
</article>`).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]> = [
['Fail', gpo.counts.Fail],
['Review required', gpo.counts['Review required']],
['Pass', gpo.counts.Pass],
['Not configured', gpo.counts['Not configured']],
['No baseline rule', gpo.counts['No baseline rule']],
['Not comparable', gpo.counts['Not comparable']]
];
return `<article class="tile domain">
<header class="domain__head">
<h3>GPO &amp; Baseline</h3>
<span class="domain__count">${integer(gpo.counts.Fail)} Deviations</span>
</header>
<p class="note">${escapeHtml(gpo.basis.reason)} Die Einträge stehen deshalb gleichgewichtig nebeneinander.</p>
<dl class="verdicts">${rows.map(([label, count]) => `<div class="verdicts__row">
<dt>${escapeHtml(label)}</dt><dd>${integer(count)}</dd>
</div>`).join('')}</dl>
<p class="note">
Betroffene Policy Settings: <strong>${integer(gpo.affectedSettings)}</strong> ·
betroffene GPO Backups: <strong>${integer(gpo.affectedBackups)}</strong> ·
Conflicts: <strong>${integer(gpo.conflicts)}</strong> ·
Duplicates: <strong>${integer(gpo.duplicates)}</strong>
</p>
<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>`;
}
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 head = `<header class="doc__head">
<p class="doc__kicker">ASSESSOR · Management Overview</p>
<h1>${escapeHtml(projection.project.name)}</h1>
<dl class="facts">
${facts.map(fact => `<div class="facts__item">
<dt>${escapeHtml(fact.key)}</dt><dd>${value(fact.value)}</dd>
</div>`).join('')}
<div class="facts__item"><dt>Assessment</dt><dd>${value(projection.assessmentId)}</dd></div>
<div class="facts__item"><dt>Berichtsdatum</dt><dd>${germanDateTime(projection.generatedAt)}</dd></div>
</dl>
</header>`;
const posture = `<section class="segment" aria-labelledby="posture">
<h2 id="posture">Overall Posture</h2>
<div class="posture">
<div class="tile posture__score">
${donut(projection.overallScore, 'Overall Score')}
</div>
<div class="tile posture__domains">
<h3>Security Domains</h3>
${domainSpine(projection.domains)}
</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.
</p>
</section>`;
const priorities = `<section class="segment" aria-labelledby="priorities">
<h2 id="priorities">Priority Findings</h2>
<p class="note">
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>
<div class="segment__body">
${prioritySection(projection.priorities)}
${projection.gpo ? gpoSection(projection.gpo) : ''}
</div>
</section>`;
const remediationSection = remediation ? `<section class="segment" aria-labelledby="remediation">
<h2 id="remediation">Remediation-Status</h2>
<p class="note">
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="segment__body">
<article class="tile kpi"><span class="kpi__figure">${integer(remediation.items)}</span><span class="kpi__label">Maßnahmen in Arbeit</span></article>
<article class="tile kpi"><span class="kpi__figure">${integer(remediation.withoutOwner)}</span><span class="kpi__label">ohne Owner</span></article>
<article class="tile kpi"><span class="kpi__figure">${integer(remediation.withoutDue)}</span><span class="kpi__label">ohne Termin</span></article>
<article class="tile kpi"><span class="kpi__figure">${integer(remediation.riskAccepted)}</span><span class="kpi__label">Accepted Risk</span></article>
</div>
${Object.keys(remediation.byStatus).length ? `<dl class="verdicts">${Object.entries(remediation.byStatus)
.map(([status, count]) => `<div class="verdicts__row"><dt>${escapeHtml(status)}</dt><dd>${integer(count)}</dd></div>`)
.join('')}</dl>` : ''}
</section>` : '';
const coverageSection = `<section class="segment" aria-labelledby="coverage">
<h2 id="coverage">Abdeckung und Lücken</h2>
<div class="segment__body">
<article class="tile kpi"><span class="kpi__figure">${integer(coverage.assessed)}</span><span class="kpi__label">bewertet</span></article>
<article class="tile kpi"><span class="kpi__figure">${integer(coverage.deviations)}</span><span class="kpi__label">Deviations</span></article>
<article class="tile kpi"><span class="kpi__figure">${integer(coverage.notRun)}</span><span class="kpi__label">not run</span></article>
<article class="tile kpi"><span class="kpi__figure">${integer(coverage.notApplicable)}</span><span class="kpi__label">not applicable</span></article>
<article class="tile kpi"><span class="kpi__figure">${integer(coverage.executionErrors)}</span><span class="kpi__label">execution errors</span></article>
</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>
<table class="table">
<caption>Abdeckung je Quelle</caption>
<thead><tr><th scope="col">Quelle</th><th scope="col">Ergebnisse</th><th scope="col">bewertet</th><th scope="col">not run</th></tr></thead>
<tbody>${coverage.perSource.map(source => `<tr>
<td>${escapeHtml(source.toolId)}</td>
<td class="num">${integer(source.total)}</td>
<td class="num">${integer(source.assessed)}</td>
<td class="num">${integer(source.notRun)}</td>
</tr>`).join('')}</tbody>
</table>
</section>`;
const sources = `<section class="segment" aria-labelledby="sources">
<h2 id="sources">Source Overview</h2>
<table class="table">
<caption>Score-Beitrag je Quelle</caption>
<thead><tr>
<th scope="col">Quelle</th><th scope="col">Stand</th><th scope="col">Beitrag</th>
<th scope="col">Methode</th><th scope="col">Pass</th><th scope="col">Deviations</th>
</tr></thead>
<tbody>${projection.scoreSources.length ? projection.scoreSources.map(source => `<tr>
<td>${escapeHtml(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>
${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>'
: ''}
</section>`;
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Management Overview · ${escapeHtml(projection.project.name)}</title>
<style>
:root{
--font-ui:"Inter Variable","Inter",system-ui,-apple-system,"Segoe UI",sans-serif;
--type-h1-size:56px; --type-h2-size:32px; --type-h3-size:18px; --type-h4-size:16px;
--type-body-size:16px; --type-table-body-size:14px; --type-label-size:12px;
--type-meta-size:12px; --type-badge-size:11px; --type-table-head-size:12px;
--fw-regular:400; --fw-semibold:600; --fw-bold:700;
--lh-ui:1.35; --lh-prose:1.55; --ls-caps:0.06em;
--space-1:4px; --space-2:8px; --space-3:12px; --space-4:16px; --space-5:20px;
--space-6:24px; --space-8:32px;
--panel-content-inset:20px; --panel-inset:var(--panel-content-inset);
--segment-surface:transparent; --segment-border:transparent; --segment-radius:10px;
--segment-inset:var(--space-3); --segment-gap:var(--space-3);
--tile-radius:0px; --tile-inset:var(--space-4); --tile-border:transparent;
--radius-xs:0px; --radius-sm:0px;
--donut-ring-size:128px; --donut-ring-stroke:12px;
--donut-value-size:26px; --donut-percent-size:14px;
/* Light is the document default: a report is read and printed on white. */
--surface-app:#F1F1F4; --surface-panel:#FFFFFF; --tile-surface:#FFFFFF;
--text-primary:#17171C; --text-secondary:#55555F; --text-muted:#74747F;
--border-subtle:#E2E2E8; --border-strong:#A8A8B4;
--donut-track:#B9BEC7; --donut-value:#20242A; --donut-label:#555B65; --donut-all:#1C70C9;
--marking-color:#E9A63C;
--sev-critical:#E8214F; --sev-high:#E2680F; --sev-medium:#C99700;
--sev-low:#2E9E6B; --sev-info:#3D8FD1; --sev-unknown:#6E6E78; --sev-on:#FFFFFF;
}
@media (prefers-color-scheme:dark){
:root{
--surface-app:#242426; --surface-panel:#242426; --tile-surface:#323238;
--text-primary:#E2DFE6; --text-secondary:#B7BAC0; --text-muted:#B7BAC0;
--border-subtle:rgba(255,255,255,.14); --border-strong:#4E4E58;
--donut-track:#555B65; --donut-value:#E2DFE6; --donut-label:#B7BAC0; --donut-all:#348CF2;
}
}
*{box-sizing:border-box}
html{background:var(--surface-app)}
body{margin:0;padding:var(--panel-inset);background:var(--surface-app);color:var(--text-primary);
font:var(--fw-regular) var(--type-body-size)/var(--lh-prose) var(--font-ui)}
h1,h2,h3{margin:0;line-height:var(--lh-ui);font-weight:var(--fw-semibold)}
h1{font-size:var(--type-h1-size);letter-spacing:-.02em}
h2{font-size:var(--type-h2-size)}
h3{font-size:var(--type-h3-size)}
code{font-family:var(--font-ui);font-size:var(--type-table-body-size)}
strong{font-weight:var(--fw-semibold)}
.doc__head{padding-bottom:var(--space-6);border-bottom:1px solid var(--border-strong)}
.doc__kicker{margin:0 0 var(--space-2);font-size:var(--type-label-size);font-weight:var(--fw-semibold);
letter-spacing:var(--ls-caps);text-transform:uppercase;color:var(--text-secondary)}
.facts{display:flex;flex-wrap:wrap;gap:var(--space-6);margin:var(--space-5) 0 0}
.facts__item{margin:0}
.facts dt{font-size:var(--type-label-size);font-weight:var(--fw-semibold);color:var(--text-secondary)}
.facts dd{margin:var(--space-1) 0 0;font-size:var(--type-table-body-size)}
.segment{margin-top:var(--space-8);padding:var(--segment-inset);background:var(--segment-surface);
border:1px solid var(--segment-border);border-radius:var(--segment-radius)}
.segment__body{display:flex;flex-wrap:wrap;gap:var(--segment-gap);margin-top:var(--space-4);align-items:flex-start}
.tile{padding:var(--tile-inset);background:var(--tile-surface);border:1px solid var(--tile-border);
border-radius:var(--tile-radius)}
.note{margin:var(--space-4) 0 0;font-size:var(--type-table-body-size);color:var(--text-secondary)}
.empty{margin:var(--space-4) 0 0;font-size:var(--type-table-body-size);color:var(--text-muted)}
.posture{display:flex;flex-wrap:wrap;gap:var(--segment-gap);margin-top:var(--space-4);align-items:stretch}
.posture__score{display:flex;align-items:center;justify-content:center;min-width:260px}
.posture__domains{flex:1 1 420px;min-width:320px}
.donut{position:relative;margin:0;width:var(--donut-ring-size);height:var(--donut-ring-size)}
.donut svg{width:100%;height:100%;display:block}
.donut circle{fill:none;stroke-width:var(--donut-ring-stroke);stroke-linecap:butt}
.donut__track{stroke:var(--donut-track)}
.donut__value{stroke:var(--donut-all)}
.donut__readout{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;
justify-content:center;gap:var(--space-1);text-align:center}
.donut__number{font-size:var(--donut-value-size);font-weight:var(--fw-semibold);color:var(--donut-value);
line-height:1;font-variant-numeric:tabular-nums}
.donut__unit{font-size:var(--donut-percent-size);font-weight:var(--fw-regular);color:var(--donut-label)}
.donut__caption{font-size:var(--type-badge-size);color:var(--donut-label)}
.spine{list-style:none;margin:var(--space-4) 0 0;padding:0 0 0 var(--space-4);
border-left:1px solid var(--border-subtle)}
.spine__item{position:relative;display:grid;grid-template-columns:minmax(150px,1fr) minmax(80px,140px) 52px;
gap:var(--space-2) var(--space-3);align-items:center;padding:var(--space-2) 0}
.spine__node{position:absolute;left:calc(var(--space-4) * -1 - 4px);width:7px;height:7px;border-radius:50%;
background:var(--donut-all)}
.spine__label{font-size:var(--type-table-body-size);font-weight:var(--fw-semibold)}
.spine__meter{height:6px;background:var(--donut-track)}
.spine__fill{display:block;height:100%;background:var(--donut-all)}
.spine__figure{font-size:var(--type-table-body-size);font-variant-numeric:tabular-nums;text-align:right}
.spine__counts{grid-column:1 / -1;font-size:var(--type-meta-size);color:var(--text-secondary)}
.domain{flex:1 1 460px;min-width:320px}
.domain__head{display:flex;align-items:baseline;justify-content:space-between;gap:var(--space-3);
padding-bottom:var(--space-2);border-bottom:1px solid var(--border-subtle)}
.domain__count{font-size:var(--type-meta-size);color:var(--text-secondary);font-variant-numeric:tabular-nums}
.source{margin-top:var(--space-4)}
.source__head{display:flex;flex-wrap:wrap;align-items:baseline;gap:var(--space-2)}
.source__tool{font-size:var(--type-h4-size);font-weight:var(--fw-semibold)}
.source__basis{font-size:var(--type-meta-size);color:var(--text-secondary)}
.source__more{margin:var(--space-2) 0 0;font-size:var(--type-meta-size);color:var(--text-muted)}
.findings{list-style:none;margin:var(--space-2) 0 0;padding:0}
.finding{position:relative;display:flex;flex-direction:column;gap:var(--space-1);
padding:var(--space-2) 0 var(--space-2) var(--space-3);border-bottom:1px solid var(--border-subtle)}
.finding__accent{position:absolute;left:0;top:var(--space-2);bottom:var(--space-2);width:3px;
background:var(--sev-unknown)}
.finding__accent--critical{background:var(--sev-critical)}
.finding__accent--high{background:var(--sev-high)}
.finding__accent--medium{background:var(--sev-medium)}
.finding__accent--low{background:var(--sev-low)}
.finding__accent--info{background:var(--sev-info)}
.finding__title{font-size:var(--type-table-body-size);font-weight:var(--fw-semibold);line-height:var(--lh-ui)}
.finding__meta{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2)}
.finding__asset{font-size:var(--type-meta-size);color:var(--text-secondary)}
.badge{display:inline-block;padding:1px var(--space-2);border-radius:var(--radius-xs);
font-size:var(--type-badge-size);font-weight:var(--fw-semibold);letter-spacing:var(--ls-caps);
text-transform:uppercase;color:var(--sev-on)}
.badge--critical{background:var(--sev-critical)}
.badge--high{background:var(--sev-high)}
.badge--medium{background:var(--sev-medium)}
.badge--low{background:var(--sev-low)}
.badge--info{background:var(--sev-info)}
.badge--neutral{background:transparent;color:var(--text-secondary);border:1px solid var(--border-strong);
text-transform:none;letter-spacing:0;font-variant-numeric:tabular-nums}
.kpi{flex:1 1 150px;min-width:140px;display:flex;flex-direction:column;gap:var(--space-1)}
.kpi__figure{font-size:var(--type-h2-size);font-weight:var(--fw-semibold);line-height:1;
font-variant-numeric:tabular-nums}
.kpi__label{font-size:var(--type-meta-size);color:var(--text-secondary)}
.verdicts{display:flex;flex-wrap:wrap;gap:var(--space-2) var(--space-6);margin:var(--space-4) 0 0}
.verdicts__row{display:flex;align-items:baseline;gap:var(--space-2)}
.verdicts dt{font-size:var(--type-meta-size);color:var(--text-secondary)}
.verdicts dd{margin:0;font-size:var(--type-table-body-size);font-weight:var(--fw-semibold);
font-variant-numeric:tabular-nums}
.table{width:100%;margin-top:var(--space-4);border-collapse:collapse;
font-size:var(--type-table-body-size)}
.table caption{margin-bottom:var(--space-2);text-align:left;font-size:var(--type-label-size);
font-weight:var(--fw-semibold);color:var(--text-secondary)}
.table th,.table td{padding:var(--space-2) var(--space-3);text-align:left;
border-bottom:1px solid var(--border-subtle)}
.table th{font-size:var(--type-table-head-size);font-weight:var(--fw-semibold);
letter-spacing:var(--ls-caps);text-transform:uppercase;color:var(--text-secondary)}
.table .num{text-align:right;font-variant-numeric:tabular-nums}
@media print{
:root{--surface-app:#FFFFFF;--surface-panel:#FFFFFF;--tile-surface:#FFFFFF}
body{padding:0}
.segment{break-inside:avoid;margin-top:var(--space-6)}
.tile{break-inside:avoid;border:1px solid var(--border-subtle)}
.finding{break-inside:avoid}
}
@media (max-width:720px){
h1{font-size:var(--type-h2-size)}
.spine__item{grid-template-columns:1fr 60px}
.spine__meter{grid-column:1 / -1}
}
</style>
</head>
<body>
${head}
${posture}
${priorities}
${remediationSection}
${coverageSection}
${sources}
</body>
</html>`;
}