494 lines
26 KiB
TypeScript
494 lines
26 KiB
TypeScript
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 & 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>`;
|
||
}
|