2.9.1: Management Report mit Heatmap und Ergebnisbalken

Die Client-Info-Aktion Management Report erzeugt den faktenbasierten
HTML-Bericht aus der gemeinsamen Reportprojektion: erfasste DC,
bewertete und abweichende Ergebnisse, Heatmap des Abweichungsanteils
und Pass-/Abweichungsbalken je Security Domain. Kein Mixed-Method-Score,
keine Scannerlisten, keine LLM-, Internet- oder Risikoableitung.
Report B bleibt technisch. Keine Projektions-, API- oder Persistenz-
aenderung.

Verifiziert: Vollsuite 1645 bestanden, 14 uebersprungen; 7 bestehende
Fehler in 6 Dateien identisch auf dem vorherigen Commit. Beide
Produktionsbuilds bestanden. Visuelle Browserabnahme offen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Michael 2026-09-30 09:49:38 +02:00
parent 49b2696882
commit efec1f593a
13 changed files with 203 additions and 742 deletions

File diff suppressed because one or more lines are too long

View File

@ -98,6 +98,33 @@ consumes.
## Report A — Management Overview ## Report A — Management Overview
**Superseding user decision 2026-09-29 (Phase 7):** The Management Report is
generated offline and without an LLM from the current, selected client's
existing report projection. It is a factual overview, not a scanner finding
list, mixed-method score or speculative risk assessment. The earlier Report A
content contract below (overall-score donut, domain spine, priority findings,
remediation status and source overview) is retained as historical context but
does not govern the current Management export.
Current Report A shows project name, report date, the captured domain and DC
count when present, counts of assessed and deviating results, a heatmap of
deviations divided by assessed results for each catalog Security Domain, and a
second stacked chart of passed and deviating results with the Pass Rate and
underlying counts. A domain with no assessed results has no percentage or heat
level. The colors and percentages describe result frequency only: they do not
express severity, business impact, compromise, protection or progress.
The GPO domain chart counts assigned result rows. When the separate policy
evaluation exists, its failing comparisons and affected settings are stated
separately and never equated to result rows. The export names its statement
limits: source observations may come from different times; affected business
services, exploitation and current remediation state are not derivable from
these facts. It does not extrapolate backup age or add invented risk scenarios,
owners, measures or deadlines. Technical results remain in Report B. The
document is standalone HTML with inline styles and no external resources.
### Historical Report A design (superseded 2026-09-29)
Implemented in `src/exports/management-overview-report.ts` Implemented in `src/exports/management-overview-report.ts`
(`MANAGEMENT_OVERVIEW_RENDERER_VERSION = '1.0.0'`), covered by (`MANAGEMENT_OVERVIEW_RENDERER_VERSION = '1.0.0'`), covered by
`tests/exports/management-overview-report.test.ts`. `tests/exports/management-overview-report.test.ts`.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "assessor", "name": "assessor",
"version": "2.9.0", "version": "2.9.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "assessor", "name": "assessor",
"version": "2.9.0", "version": "2.9.1",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/inter": "^5.3.0",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",

View File

@ -1,6 +1,6 @@
{ {
"name": "assessor", "name": "assessor",
"version": "2.9.0", "version": "2.9.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -1,520 +1,102 @@
import type { import type { ReportDomainSummary, ReportProjection } from '../domain/report-projection';
ReportDomainPriority, import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';
ReportDomainSummary,
ReportFindingRef,
ReportGpoBlock,
ReportPriorityBasis,
ReportProjection,
ReportScoreSource
} from '../domain/report-projection';
import { escapeHtml } from '../shared/html'; import { escapeHtml } from '../shared/html';
/** /** Offline management report: a factual view of the shared projection. */
* Artifact A of docs/REPORT_STRATEGY.md: the Management Overview. export const MANAGEMENT_OVERVIEW_RENDERER_VERSION = '2.0.0';
*
* 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.1.0';
const DASH = '—'; const DASH = '—';
const count = (n: number): string => Number.isFinite(n) ? n.toLocaleString('de-DE') : DASH;
/** Unknown stays unknown. An absent value never becomes a zero. */ function date(iso: string): string {
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); const parsed = Date.parse(iso);
if (!Number.isFinite(parsed)) return value(iso); return Number.isFinite(parsed)
return escapeHtml(new Date(parsed).toLocaleString('de-DE', { ? new Date(parsed).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
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)
? score > 0 && score < 0.5 ? '<1' : Math.round(score).toLocaleString('de-DE')
: DASH; : 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>${ratio > 0 && ratio < 0.02
? `<rect class="donut__small-arc" x="112" y="${(64 - Math.max(3, circumference * ratio) / 2).toFixed(3)}" width="12" height="${Math.max(3, circumference * ratio).toFixed(3)}" transform="rotate(-90 64 64)"></rect>`
: ''}` : ''}
</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 tile(item: ReportDomainSummary): string {
function domainSpine(domains: ReportDomainSummary[]): string { const assessed = item.assessed > 0 && Number.isFinite(item.assessed) && Number.isFinite(item.deviations);
if (!domains.length) return '<p class="empty">Keine Security Domain wurde bewertet.</p>'; const share = assessed ? Math.round(100 * item.deviations / item.assessed) : undefined;
return `<ol class="spine">${domains.map(domain => { const level = share === undefined ? 'unknown' : `h${share < 25 ? 0 : share < 50 ? 1 : share < 75 ? 2 : 3}`;
const known = typeof domain.passRate === 'number'; return `<li class="${level}"><span class="area">${escapeHtml(item.domainLabel)}</span>
const width = known ? Math.round(domain.passRate! * 100) : 0; <strong class="value">${share === undefined ? DASH : `${count(share)} %`}</strong>
return `<li class="spine__item"> <span class="count">${assessed ? `${count(item.deviations)} von ${count(item.assessed)} bewertet` : 'Keine bewerteten Ergebnisse'}</span></li>`;
<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 { function comparisonRow(item: ReportDomainSummary): string {
const severity = finding.severity; const assessed = Number.isFinite(item.assessed) && item.assessed > 0;
const showSeverity = severity !== 'unknown'; const passed = assessed && Number.isFinite(item.passed) ? Math.max(0, Math.min(item.passed, item.assessed)) : 0;
return `<li class="finding"> const deviations = assessed && Number.isFinite(item.deviations) ? Math.max(0, Math.min(item.deviations, item.assessed - passed)) : 0;
<span class="finding__accent finding__accent--${escapeHtml(severity)}" aria-hidden="true"></span> const other = assessed ? Math.max(0, item.assessed - passed - deviations) : 0;
<span class="finding__title">${escapeHtml(finding.title)}</span> const passPercent = assessed ? Math.round(100 * passed / item.assessed) : undefined;
<span class="finding__meta"> return `<li class="comparison-row"><span class="comparison-label">${escapeHtml(item.domainLabel)}</span>
${showSeverity ? `<span class="badge badge--${escapeHtml(severity)}">${escapeHtml(SEVERITY_LABEL[severity])}</span>` : ''} <span class="comparison-bar" aria-label="${assessed ? `${count(passed)} bestanden, ${count(deviations)} abweichend` : 'Keine bewerteten Ergebnisse'}">
${ranked && finding.rankValue !== undefined && finding.rankBasis.kind === 'numeric' <span class="bar-pass" style="width:${assessed ? 100 * passed / item.assessed : 0}%"></span>
? `<span class="badge badge--neutral">${escapeHtml(finding.rankBasis.property)} ${integer(finding.rankValue)}</span>` : ''} <span class="bar-fail" style="width:${assessed ? 100 * deviations / item.assessed : 0}%"></span>
</span> ${other ? `<span class="bar-other" style="width:${100 * other / item.assessed}%"></span>` : ''}
</li>`; </span>
} <span class="comparison-value">${passPercent === undefined ? DASH : `${count(passPercent)} %`} <span>· ${assessed ? `${count(item.deviations)} von ${count(item.assessed)}` : DASH}</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 { export function buildManagementOverviewHtml(projection: ReportProjection): string {
const facts = projection.environment.length const domain = projection.environment.find(fact => fact.key === 'Domain')?.value;
? projection.environment const dc = projection.environment.find(fact => fact.key === 'Domain Controller')?.value;
: [{ key: 'Umgebung', value: undefined }]; const gpo = projection.gpo;
const gpoNote = gpo && projection.domains.some(item => item.domainId === 'gpo')
const remediation = projection.remediation; ? `<p>Die GPO-Kachel zählt zugeordnete Ergebniszeilen. Die gesonderte GPO-Evaluation zählt ${count(gpo.counts.Fail)} abweichende Vergleiche zu ${count(gpo.affectedSettings)} Einstellungen. Diese Zähleinheiten sind nicht gleichzusetzen.${gpo.scopeLimited ? ' Die Evaluation bezieht sich auf die importierten GPO-Backups.' : ''}</p>`
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 convergence = `<section class="segment" aria-labelledby="convergence">
<h2 id="convergence">Konvergenz der Quellen</h2>
<p class="note">Mehrere Quellen in einer Security Domain belegen nicht automatisch denselben Finding.</p>
<div class="segment__body">${projection.convergence.filter(item => item.sourceCount > 0).map(item => `<article class="tile domain">
<h3>${value(projection.domains.find(domain => domain.domainId === item.domainId)?.domainLabel ?? item.domainId)}</h3>
<p>${integer(item.sourceCount)} unabhängige Quellen mit Deviations: ${item.sources.filter(source => source.deviations > 0)
.map(source => escapeHtml(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('') || '<p class="empty">Keine quellenübergreifende Konvergenz belegt.</p>'}</div>
</section>`;
const bundles = `<section class="segment" aria-labelledby="bundles">
<h2 id="bundles">Maßnahmenbündel</h2>
<p class="note">Gleiche Framework-Referenzen gruppieren Findings ohne Aussage zu Aufwand, Nutzen oder Priorität.</p>
${projection.bundles.length ? `<table class="table"><thead><tr><th>Framework-Referenz</th><th>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>`
: '<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>
</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><th scope="col">not run Anteil</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>
<td class="num">${source.total ? `${percent(source.notRun / source.total)} %` : DASH}</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> return `<!doctype html>
<html lang="de"> <html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<head> <title>ASSESSOR · Management Report · ${escapeHtml(projection.project.name)}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Management Overview · ${escapeHtml(projection.project.name)}</title>
<style> <style>
:root{ :root{--ink:#17171c;--muted:#5c6069;--navy:#123047;--line:#cfd1d7;--paper:#fff;--surface:#f5f5f7;--orange:#df752d;--heat-0:#e7e8ec;--heat-1:#f8dfcb;--heat-2:#edb68d;--heat-3:#e68f51}
--font-ui:"Inter Variable","Inter",system-ui,-apple-system,"Segoe UI",sans-serif; *{box-sizing:border-box}body{margin:0;background:#e7e7eb;color:var(--ink);font:400 14px/1.45 Inter,Arial,sans-serif}
--type-h1-size:56px; --type-h2-size:32px; --type-h3-size:18px; --type-h4-size:16px; .sheet{width:210mm;min-height:297mm;margin:16px auto;padding:12mm 16mm 11mm;background:var(--paper);display:flex;flex-direction:column}
--type-body-size:16px; --type-table-body-size:14px; --type-label-size:12px; .mast{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-bottom:12px;border-bottom:2px solid var(--navy)}
--type-meta-size:12px; --type-badge-size:11px; --type-table-head-size:12px; .brand{color:var(--navy);font-size:19px;font-weight:700;letter-spacing:.06em}.mast-note{color:var(--navy);text-align:right;font-size:12px;font-weight:600}
--fw-regular:400; --fw-semibold:600; --fw-bold:700; .kicker{margin:20px 0 7px;color:var(--orange);font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase}
--lh-ui:1.35; --lh-prose:1.55; --ls-caps:0.06em; h1{margin:0;color:var(--navy);font-size:30px;line-height:1.13;font-weight:600}h2{margin:0;color:var(--navy);font-size:18px;font-weight:600}
--space-1:4px; --space-2:8px; --space-3:12px; --space-4:16px; --space-5:20px; p{margin:0 0 8px}.subtitle{margin:8px 0 14px;color:var(--muted);font-size:14px}
--space-6:24px; --space-8:32px; .meta{display:flex;flex-wrap:wrap;gap:6px 18px;padding:8px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);color:var(--muted);font-size:14px}
--panel-content-inset:20px; --panel-inset:var(--panel-content-inset); .meta strong{color:var(--ink);font-weight:600}.facts{display:grid;grid-template-columns:repeat(3,1fr);margin:21px 0 26px;border-top:2px solid var(--navy);border-bottom:1px solid var(--line)}
--segment-surface:transparent; --segment-border:transparent; --segment-radius:10px; .fact{padding:13px 15px;border-right:1px solid var(--line)}.fact:last-child{border-right:0}.fact strong{display:block;color:var(--navy);font-size:28px;line-height:1.12;font-weight:600;font-variant-numeric:tabular-nums}.fact span{display:block;margin-top:5px;font-size:14px}
--segment-inset:var(--space-3); --segment-gap:var(--space-3); .section-head{display:flex;align-items:end;justify-content:space-between;gap:15px;margin-bottom:11px}.section-head p{max-width:260px;margin:0;color:var(--muted);font-size:14px;text-align:right}
--tile-radius:0px; --tile-inset:var(--space-4); --tile-border:transparent; .heatmap{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;margin:0;padding:0;list-style:none}.heatmap li{min-height:104px;padding:11px 12px 10px;display:flex;flex-direction:column;border:1px solid rgba(18,48,71,.17);break-inside:avoid}
--radius-xs:0px; --radius-sm:0px; .heatmap .h0{background:var(--heat-0)}.heatmap .h1{background:var(--heat-1)}.heatmap .h2{background:var(--heat-2)}.heatmap .h3{background:var(--heat-3)}.heatmap .unknown{background:var(--surface)}
--donut-ring-size:128px; --donut-ring-stroke:12px; .heatmap .area{color:var(--navy);font-size:14px;line-height:1.25;font-weight:600}.heatmap .value{display:block;margin-top:auto;color:var(--navy);font-size:28px;line-height:1.08;font-weight:600;font-variant-numeric:tabular-nums}.heatmap .count{display:block;margin-top:3px;color:var(--ink);font-size:14px}
--donut-value-size:26px; --donut-percent-size:14px; .legend{display:flex;align-items:center;flex-wrap:wrap;gap:5px 12px;margin:12px 0 0;color:var(--muted);font-size:14px}.legend .key{display:inline-flex;align-items:center;gap:5px;white-space:nowrap}.legend i{display:inline-block;width:14px;height:14px;border:1px solid rgba(18,48,71,.17)}
/* Light is the document default: a report is read and printed on white. */ .legend .l0{background:var(--heat-0)}.legend .l1{background:var(--heat-1)}.legend .l2{background:var(--heat-2)}.legend .l3{background:var(--heat-3)}
--surface-app:#F1F1F4; --surface-panel:#FFFFFF; --tile-surface:#FFFFFF; .comparison{margin-top:26px;padding:15px 17px;background:var(--surface);border:1px solid var(--line);break-inside:avoid}.comparison h2{margin-bottom:13px}
--text-primary:#17171C; --text-secondary:#55555F; --text-muted:#74747F; .comparison-scale{display:grid;grid-template-columns:240px minmax(180px,1fr) 135px;gap:12px;margin-bottom:5px;color:var(--muted);font-size:12px}
--border-subtle:#E2E2E8; --border-strong:#A8A8B4; .comparison-scale div{grid-column:2;display:flex;justify-content:space-between}
--donut-track:#B9BEC7; --donut-value:#20242A; --donut-label:#555B65; --donut-all:#1C70C9; .comparison-list{list-style:none;margin:0;padding:0}.comparison-row{display:grid;grid-template-columns:240px minmax(180px,1fr) 135px;gap:12px;align-items:center;min-height:34px;font-size:14px}
--marking-color:#E9A63C; .comparison-bar{display:flex;height:14px;background:#e3e3e8}.bar-pass{background:#167048}.bar-fail{background:#c60b41}.bar-other{background:#92969d}
--sev-critical:#E8214F; --sev-high:#E2680F; --sev-medium:#C99700; .comparison-value{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;white-space:nowrap}.comparison-value span{color:var(--muted);font-weight:400}
--sev-low:#2E9E6B; --sev-info:#3D8FD1; --sev-unknown:#6E6E78; --sev-on:#FFFFFF; .comparison-key{display:flex;gap:24px;margin:13px 0 0 252px;font-size:14px;color:var(--muted)}.comparison-key i{display:inline-block;width:12px;height:12px;margin-right:5px;vertical-align:-1px}.comparison-key .pass{background:#167048}.comparison-key .fail{background:#c60b41}
} .notes{margin-top:23px;padding:13px 15px;background:var(--surface);border-top:2px solid var(--navy)}.notes h2{font-size:16px;margin-bottom:6px}.notes p{margin:0 0 5px;font-size:14px}.notes p:last-child{margin-bottom:0}
@media (prefers-color-scheme:dark){ .foot{margin-top:auto;padding-top:10px;border-top:1px solid var(--navy);display:flex;justify-content:space-between;gap:12px;color:var(--muted);font-size:12px}.foot strong{color:var(--navy);font-weight:600}
:root{ @media(max-width:800px){body{background:#fff}.sheet{width:auto;min-height:0;margin:0;padding:18px 16px 26px}.heatmap{grid-template-columns:repeat(2,1fr)}.section-head{display:block}.section-head p{max-width:none;margin-top:4px;text-align:left}h1{font-size:26px}}
--surface-app:#242426; --surface-panel:#242426; --tile-surface:#323238; @media(max-width:480px){.heatmap,.facts{grid-template-columns:1fr}.fact{border-right:0;border-bottom:1px solid var(--line)}.fact:last-child{border-bottom:0}}
--text-primary:#E2DFE6; --text-secondary:#B7BAC0; --text-muted:#B7BAC0; @media(max-width:720px){.comparison-scale{display:none}.comparison-row{grid-template-columns:1fr auto;gap:3px 10px;margin-bottom:12px}.comparison-label{grid-column:1/-1}.comparison-bar{grid-column:1}.comparison-value{grid-column:2}.comparison-key{margin-left:0}}
--border-subtle:rgba(255,255,255,.14); --border-strong:#4E4E58; @page{size:A4;margin:0}@media print{body{background:#fff;print-color-adjust:exact;-webkit-print-color-adjust:exact}.sheet{margin:0}}
--donut-track:#555B65; --donut-value:#E2DFE6; --donut-label:#B7BAC0; --donut-all:#348CF2; </style></head><body><main class="sheet">
} <header class="mast"><div class="brand">ASSESSOR</div><div class="mast-note">Management Report</div></header>
} <p class="kicker">Gespeicherter Assessment-Stand</p><h1>Technische Ergebnisse im Überblick</h1>
*{box-sizing:border-box} <p class="subtitle">${escapeHtml(projection.project.name)}</p>
html{background:var(--surface-app)} <div class="meta">${domain ? `<span><strong>Domain:</strong> ${escapeHtml(domain)}</span>` : ''}<span><strong>Berichtsdatum:</strong> ${date(projection.generatedAt)}</span></div>
body{margin:0;padding:var(--panel-inset);background:var(--surface-app);color:var(--text-primary); <div class="facts" aria-label="Erfasste Kennzahlen">
font:var(--fw-regular) var(--type-body-size)/var(--lh-prose) var(--font-ui)} <div class="fact"><strong>${dc ? escapeHtml(dc) : DASH}</strong><span>Domain Controller erfasst</span></div>
h1,h2,h3{margin:0;line-height:var(--lh-ui);font-weight:var(--fw-semibold)} <div class="fact"><strong>${count(projection.coverage.assessed)}</strong><span>Ergebnisse bewertet</span></div>
h1{font-size:var(--type-h1-size);letter-spacing:-.02em} <div class="fact"><strong>${count(projection.coverage.deviations)}</strong><span>Abweichende Ergebnisse</span></div></div>
h2{font-size:var(--type-h2-size)} <section aria-labelledby="heatmap-title"><div class="section-head"><h2 id="heatmap-title">Abweichungsanteil je Security Domain</h2><p>Abweichende von bewerteten Ergebnissen innerhalb des jeweiligen Bereichs.</p></div>
h3{font-size:var(--type-h3-size)} <ol class="heatmap" aria-label="Heatmap der bewerteten Ergebnisse">${projection.domains.length ? projection.domains.map(tile).join('') : '<li class="unknown"><span class="area">Keine Bereiche verfügbar</span><strong class="value">—</strong><span class="count">Keine bewerteten Ergebnisse</span></li>'}</ol>
code{font-family:var(--font-ui);font-size:var(--type-table-body-size)} <div class="legend" aria-label="Farbstufen nach Abweichungsanteil"><span>Farbstufe:</span><span class="key"><i class="l0"></i>0–24 %</span><span class="key"><i class="l1"></i>25–49 %</span><span class="key"><i class="l2"></i>50–74 %</span><span class="key"><i class="l3"></i>75–100 %</span></div></section>
strong{font-weight:var(--fw-semibold)} <section class="comparison" aria-labelledby="comparison-title"><h2 id="comparison-title">Bestandene und abweichende Ergebnisse</h2>
.doc__head{padding-bottom:var(--space-6);border-bottom:1px solid var(--border-strong)} <div class="comparison-scale" aria-hidden="true"><div><span>0 %</span><span>50 %</span><span>100 %</span></div></div>
.doc__kicker{margin:0 0 var(--space-2);font-size:var(--type-label-size);font-weight:var(--fw-semibold); <ol class="comparison-list">${projection.domains.map(comparisonRow).join('')}</ol>
letter-spacing:var(--ls-caps);text-transform:uppercase;color:var(--text-secondary)} <div class="comparison-key"><span><i class="pass"></i>Bestanden</span><span><i class="fail"></i>Abweichung</span></div>
.facts{display:flex;flex-wrap:wrap;gap:var(--space-6);margin:var(--space-5) 0 0} <p>Pass Rate je Security Domain; rechts: Anzahl der Abweichungen von den bewerteten Ergebnissen.</p></section>
.facts__item{margin:0} <aside class="notes" aria-labelledby="notes-title"><h2 id="notes-title">Lesart</h2><p>Die Farbe zeigt die Häufigkeit abweichender Ergebnisse innerhalb eines Bereichs. Sie zeigt weder Schweregrad noch Geschäftsrisiko. Bereiche mit wenigen Prüfungen können hohe Prozentwerte haben.</p>${gpoNote}<p>Die Ergebnisse können aus unterschiedlichen Erhebungszeitpunkten stammen. Betroffene Geschäftsdienste, Ausnutzung und aktueller Behebungsstand sind aus dieser Darstellung nicht ableitbar.</p></aside>
.facts dt{font-size:var(--type-label-size);font-weight:var(--fw-semibold);color:var(--text-secondary)} <footer class="foot"><span><strong>ASSESSOR ${escapeHtml(ASSESSOR_PRODUCT_VERSION)}</strong> · ${escapeHtml(projection.project.name)}</span></footer>
.facts dd{margin:var(--space-1) 0 0;font-size:var(--type-table-body-size)} </main></body></html>`;
.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__small-arc{fill: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}
${convergence}
${coverageSection}
${priorities}
${bundles}
${remediationSection}
${sources}
</body>
</html>`;
} }

View File

@ -761,7 +761,7 @@ function DashboardFactGroup({ title, items, overviewItems, glyph, snapshot, evid
overviewItems?: DashboardFact[]; overviewItems?: DashboardFact[];
snapshot?: EnvironmentSnapshot; evidenceGroup: EnvironmentEvidenceGroup; snapshot?: EnvironmentSnapshot; evidenceGroup: EnvironmentEvidenceGroup;
}) { }) {
const tileItems = overviewItems ?? (title === 'Directory' ? items.slice(0, 6) : items); const tileItems = (overviewItems ?? items).slice(0, 4);
return <section className="client-env-group"> return <section className="client-env-group">
<header className="client-env-group-header"> <header className="client-env-group-header">
<h3 className="client-env-group-title"> <h3 className="client-env-group-title">

View File

@ -36,7 +36,7 @@ export function OperatingSystemInventory({ groups, icon, snapshot }: {
}) { }) {
const inventories = [...new Set(groups.flatMap(group => group.entries.map(entry => entry.inventory)))]; const inventories = [...new Set(groups.flatMap(group => group.entries.map(entry => entry.inventory)))];
const summary = groups.filter(group => group.total !== undefined).map(group => ({ label: group.label, value: quantity(group.total) })); const summary = groups.filter(group => group.total !== undefined).map(group => ({ label: group.label, value: quantity(group.total) }));
const systems = groups.flatMap(group => group.entries).slice(0, Math.max(0, 8 - summary.length)) const systems = groups.flatMap(group => group.entries).slice(0, Math.max(0, 4 - summary.length))
.map(entry => ({ label: entry.name, value: quantity(entry.count) })); .map(entry => ({ label: entry.name, value: quantity(entry.count) }));
return <section className="client-os-inventory" aria-label="Operating systems"> return <section className="client-os-inventory" aria-label="Operating systems">
<header> <header>
@ -47,7 +47,7 @@ export function OperatingSystemInventory({ groups, icon, snapshot }: {
</EnvironmentResultsButton> </EnvironmentResultsButton>
</header> </header>
<dl className="client-env-facts"> <dl className="client-env-facts">
{summary.length || systems.length ? [...summary, ...systems].map((item, index) => <div key={`${item.label}:${index}`}> {summary.length || systems.length ? [...summary, ...systems].slice(0, 4).map((item, index) => <div key={`${item.label}:${index}`}>
<dt>{item.label}</dt><dd>{item.value}</dd> <dt>{item.label}</dt><dd>{item.value}</dd>
</div>) : <div><dt>Inventory</dt><dd>—</dd></div>} </div>) : <div><dt>Inventory</dt><dd>—</dd></div>}
</dl> </dl>

View File

@ -67,6 +67,7 @@
--tile-inset: var(--space-4); --tile-inset: var(--space-4);
--tile-compact-width: 186px; --tile-compact-width: 186px;
--tile-double-compact-width: calc(var(--tile-compact-width) * 2); --tile-double-compact-width: calc(var(--tile-compact-width) * 2);
--tile-environment-min-width: 260px;
--tile-content-width: 358px; --tile-content-width: 358px;
--tile-content-min-height: 223px; --tile-content-min-height: 223px;
--tile-card-shadow: none; --tile-card-shadow: none;

View File

@ -9,7 +9,7 @@ const components = readFileSync(new URL('../../src/styles/components.css', impor
describe('ASSESSOR version in the brand header', () => { describe('ASSESSOR version in the brand header', () => {
it('shows the centrally maintained product version and opens About from the brand', () => { it('shows the centrally maintained product version and opens About from the brand', () => {
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.9.0'); expect(ASSESSOR_PRODUCT_VERSION).toBe('2.9.1');
expect(app).toContain("import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';"); expect(app).toContain("import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';");
expect(app).toContain('aria-label="About ASSESSOR"'); expect(app).toContain('aria-label="About ASSESSOR"');
expect(app).toContain("import assessorLogo from '../assets/branding/assessor-icon-transparent.png';"); expect(app).toContain("import assessorLogo from '../assets/branding/assessor-icon-transparent.png';");

View File

@ -1,248 +1,72 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { JSDOM } from 'jsdom';
import { buildManagementOverviewHtml } from '../../src/exports/management-overview-report'; import { buildManagementOverviewHtml } from '../../src/exports/management-overview-report';
import type { ReportProjection } from '../../src/domain/report-projection'; import type { ReportProjection } from '../../src/domain/report-projection';
function projection(overrides: Partial<ReportProjection> = {}): ReportProjection { function projection(overrides: Partial<ReportProjection> = {}): ReportProjection {
return { return {
version: '1.1.0', version: '1.1.0', generatedAt: '2026-09-29T10:00:00Z',
generatedAt: '2026-09-18T10:00:00.000Z', project: { id: 'synthetic', name: 'Synthetic GmbH' },
project: { id: 'synthetic-project', name: 'Synthetic GmbH' }, environment: [{ key: 'Domain', value: 'synthetic.example' }, { key: 'Domain Controller', value: '1' }],
assessmentId: 'synthetic-assessment', overallScore: 37, scoreSources: [],
environment: [{ key: 'Domain', value: 'synthetic.example' }, { key: 'Domain Controller', value: undefined }], domains: [
overallScore: 62, { domainId: 'identity', domainLabel: 'Identity & Account Hygiene', passed: 204, deviations: 221, assessed: 425, passRate: 204 / 425 },
scoreSources: [], { domainId: 'gpo', domainLabel: 'GPO & Baseline', passed: 170, deviations: 223, assessed: 393, passRate: 170 / 393 },
domains: [], { domainId: 'cloud', domainLabel: 'Cloud & Applications', passed: 0, deviations: 0, assessed: 0 }
priorities: [], ],
coverage: { priorities: [], coverage: { total: 818, assessed: 818, deviations: 444, passed: 374, notRun: 0,
total: 0, assessed: 0, deviations: 0, passed: 0, notRun: 0, notApplicable: 0, executionErrors: 0, unknown: 0, perSource: [] },
notApplicable: 0, executionErrors: 0, unknown: 0, perSource: []
},
inventory: { bySeverity: {} as never, byOutcome: {} as never, byDomain: {}, bySource: {} }, inventory: { bySeverity: {} as never, byOutcome: {} as never, byDomain: {}, bySource: {} },
findings: [], findings: [], policyEntries: [], workItems: [], sourcePriorities: [], convergence: [], bundles: [],
policyEntries: [], singleSourcePolicyActions: [], diagnostics: [], provenance: [],
workItems: [], gpo: { basis: { kind: 'equal-weight', reason: 'Synthetic' },
sourcePriorities: [], counts: { Fail: 100, Pass: 20, 'Not configured': 0, 'No baseline rule': 0,
convergence: [], 'Not comparable': 0, 'Review required': 0 },
bundles: [], conflicts: 0, duplicates: 0, affectedSettings: 25, affectedBackups: 1, scopeLimited: true },
singleSourcePolicyActions: [],
diagnostics: [],
provenance: [],
...overrides ...overrides
} as ReportProjection; } as ReportProjection;
} }
const finding = (overrides = {}) => ({ describe('management export', () => {
resultProjectionId: 'synthetic-1', it('renders a self-contained factual heatmap and pass-rate bars from each projection', () => {
domainId: 'identity',
toolId: 'purple-knight',
title: 'Synthetic indicator',
severity: 'critical' as const,
outcome: 'fail' as const,
rankBasis: { kind: 'severity' as const },
ranked: true,
...overrides
});
describe('management overview renderer', () => {
it('renders a self-contained document without script or remote resources', () => {
const html = buildManagementOverviewHtml(projection()); const html = buildManagementOverviewHtml(projection());
expect(html.startsWith('<!doctype html>')).toBe(true); const document = new JSDOM(html).window.document;
expect(html).not.toMatch(/<script\b/i); expect(document.documentElement.lang).toBe('de');
expect(html).not.toMatch(/\son[a-z]+\s*=/i); expect(document.querySelectorAll('.heatmap li')).toHaveLength(3);
expect(html).not.toMatch(/https?:\/\//i); expect(document.querySelector('.heatmap li')?.textContent).toContain('52 %');
expect(html).not.toMatch(/<link\b/i); expect(document.querySelectorAll('.comparison-row')).toHaveLength(3);
expect(html).not.toMatch(/url\(/i); expect(document.querySelector('.comparison-row')?.textContent).toContain('48 %');
expect(document.querySelector('.comparison-row .bar-pass')?.getAttribute('style')).toContain('48');
expect(document.body.textContent).toContain('444');
expect(document.body.textContent).toContain('100 abweichende Vergleiche zu 25 Einstellungen');
expect(html).not.toMatch(/<script\b|<link\b|url\(|https?:\/\//i);
expect(html).not.toContain('Overall Score');
expect(html).not.toContain('37/100');
expect(html).not.toContain('Source Overview');
expect(html).not.toContain('Priority Findings');
}); });
it('declares German as the document language', () => { it('shows an unassessed domain as unknown, not zero percent', () => {
expect(buildManagementOverviewHtml(projection())).toContain('<html lang="de">'); const document = new JSDOM(buildManagementOverviewHtml(projection())).window.document;
const tile = document.querySelectorAll('.heatmap li')[2];
expect(tile.className).toBe('unknown');
expect(tile.querySelector('.value')?.textContent).toBe('—');
expect(document.querySelectorAll('.comparison-row')[2].textContent).not.toContain('0 %');
}); });
it('keeps the binding score wording and never claims baseline compliance', () => { it('uses other customer values, escapes names and omits GPO evaluation when absent', () => {
const html = buildManagementOverviewHtml(projection());
expect(html).toContain('Konformität der konfigurierten, vergleichbaren');
expect(html).not.toMatch(/Baseline-?Compliance/i);
expect(html).not.toMatch(/Baseline-?Abdeckung/i);
});
it('keeps established English technical terms untranslated', () => {
const html = buildManagementOverviewHtml(projection({ const html = buildManagementOverviewHtml(projection({
domains: [{ domainId: 'gpo', domainLabel: 'GPO & Baseline', passed: 1, deviations: 1, assessed: 2, passRate: 0.5 }], project: { id: 'other', name: '<Other Customer>' },
priorities: [{ environment: [], gpo: undefined,
domainId: 'identity', domainLabel: 'Identity & Account Hygiene', deviations: 1, coverage: { ...projection().coverage, assessed: 10, deviations: 2 },
sources: [{ toolId: 'purple-knight', basis: { kind: 'severity' }, ranked: true, shown: [finding()], total: 1 }] domains: [{ domainId: 'recovery', domainLabel: 'Monitoring & Recovery',
}] passed: 8, deviations: 2, assessed: 10, passRate: 0.8 }]
})); }));
expect(html).toContain('Priority Findings'); expect(html).toContain('&lt;Other Customer&gt;');
expect(html).toContain('Overall Score'); expect(html).not.toContain('synthetic.example');
expect(html).toContain('Security Domains'); expect(html).not.toContain('gesonderte GPO-Evaluation');
expect(html).toContain('Deviations'); expect(html).toContain('20 %');
expect(html).toContain('>Critical<'); expect(html).toContain('80 %');
expect(html).toContain('GPO &amp; Baseline'); expect(html).not.toMatch(/<Other Customer>|<img\b/i);
// German carries the prose around the English terms.
expect(html).toContain('Gruppiert nach Security Domain');
});
it('renders an unknown value as an em dash and never as a zero', () => {
const html = buildManagementOverviewHtml(projection({ overallScore: undefined }));
expect(html).toContain('—');
// No zero-length arc is drawn, and the readout shows no fabricated value.
expect(html).not.toContain('<circle class="donut__value"');
expect(html).not.toContain('<span class="donut__unit">');
expect(html).toContain('nicht verfügbar');
expect(html).toContain('<span class="donut__number">—</span>');
});
it('makes a positive score below two percent legible without changing its proportional arc', () => {
const html = buildManagementOverviewHtml(projection({ overallScore: 1 }));
expect(html).toContain('class="donut__small-arc"');
expect(html).toContain('stroke-dasharray="3.393 335.899"');
expect(buildManagementOverviewHtml(projection({ overallScore: 0.2 }))).toContain('&lt;1<span class="donut__unit">/100</span>');
expect(buildManagementOverviewHtml(projection({ overallScore: 0 }))).not.toContain('class="donut__small-arc"');
});
it('draws the donut as a full circle starting at twelve o\'clock with flat caps', () => {
const html = buildManagementOverviewHtml(projection({ overallScore: 50 }));
expect(html).toContain('rotate(-90 64 64)');
expect(html).toContain('stroke-linecap:butt');
expect(html).toContain('donut__track');
});
it('names the ordering basis of every source and never merges them', () => {
const html = buildManagementOverviewHtml(projection({
priorities: [{
domainId: 'hardening', domainLabel: 'System Hardening', deviations: 2,
sources: [
{ toolId: 'cis-cat', basis: { kind: 'equal-weight', reason: 'CIS benchmarks state no criticality.' }, ranked: false, shown: [finding({ severity: 'unknown', toolId: 'cis-cat' })], total: 1 },
{ toolId: 'pingcastle', basis: { kind: 'numeric', property: 'rule.points', scale: '0-50+', direction: 'higher-is-worse' }, ranked: true, shown: [finding({ toolId: 'pingcastle', severity: 'medium', rankValue: 35, rankBasis: { kind: 'numeric', property: 'rule.points', scale: '0-50+', direction: 'higher-is-worse' } })], total: 1 }
]
}]
}));
expect(html).toContain('Ohne Rangfolge: CIS benchmarks state no criticality.');
expect(html).toContain('Sortiert nach rule.points (0-50+, höher ist kritischer)');
expect(html).toContain('quellenübergreifende Rangfolge');
});
it('points at the technical report instead of silently truncating a domain', () => {
const html = buildManagementOverviewHtml(projection({
priorities: [{
domainId: 'identity', domainLabel: 'Identity & Account Hygiene', deviations: 9,
sources: [{ toolId: 'purple-knight', basis: { kind: 'severity' }, ranked: true, shown: [finding()], total: 9 }]
}]
}));
expect(html).toContain('8 weitere Deviations dieser Quelle stehen im Technical Report.');
});
it('keeps GPO equal-weight and marks the score-neutral verdicts as such', () => {
const html = buildManagementOverviewHtml(projection({
gpo: {
basis: { kind: 'equal-weight', reason: 'A baseline deviation carries no native criticality.' },
counts: {
Fail: 3, Pass: 10, 'Not configured': 40, 'No baseline rule': 5,
'Not comparable': 2, 'Review required': 1
},
conflicts: 1, duplicates: 2, affectedSettings: 3, affectedBackups: 2, scopeLimited: true
}
}));
expect(html).toContain('A baseline deviation carries no native criticality.');
expect(html).toContain('gleichgewichtig');
expect(html).toContain('sind score-neutral und begründen keine Maßnahme');
expect(html).toContain('trifft keine Aussage über die gesamte Domäne');
});
it('omits the remediation section entirely when no reviews exist', () => {
expect(buildManagementOverviewHtml(projection())).not.toContain('Remediation-Status');
});
it('marks remediation as self reported and not as technical proof', () => {
const html = buildManagementOverviewHtml(projection({
remediation: {
selfReported: true, items: 4, byStatus: { Actionable: 4 },
withoutOwner: 1, withoutDue: 2, riskAccepted: 0
}
}));
expect(html).toContain('Remediation-Status');
expect(html).toContain('Selbstauskunft');
expect(html).toContain('kein technischer Nachweis');
expect(html).toContain('Rescan');
});
it('names the score method per source and explains an inverted native risk', () => {
const html = buildManagementOverviewHtml(projection({
scoreSources: [{
toolId: 'pingcastle', score: 55, method: 'inverted-native', weight: 1,
passed: 0, deviations: 20, assessed: 20, observedAt: '2026-07-22T00:00:00.000Z', issues: []
}]
}));
expect(html).toContain('Nativer Risk, invertiert als 100 − Risk');
expect(html).toContain('100 − Risk</code> in den Index ein');
expect(html).toContain('22.07.2026');
});
it('escapes hostile content from a finding title', () => {
const html = buildManagementOverviewHtml(projection({
priorities: [{
domainId: 'identity', domainLabel: 'Identity & Account Hygiene', deviations: 1,
sources: [{
toolId: 'purple-knight', basis: { kind: 'severity' }, ranked: true, total: 1,
shown: [finding({ title: '<img src=x onerror=alert(1)>' })]
}]
}]
}));
expect(html).toContain('&lt;img src=x onerror=alert(1)&gt;');
expect(html).not.toMatch(/<img\b/i);
});
});
describe('management overview structure', () => {
it('keeps the agreed section order and heading levels', async () => {
const { JSDOM } = await import('jsdom');
const html = buildManagementOverviewHtml(projection({
remediation: {
selfReported: true, items: 1, byStatus: {}, withoutOwner: 0, withoutDue: 0, riskAccepted: 0
}
}));
const { document } = new JSDOM(html).window;
expect([...document.querySelectorAll('h1')].map(node => node.textContent))
.toEqual(['Synthetic GmbH']);
expect([...document.querySelectorAll('h2')].map(node => node.textContent)).toEqual([
'Overall Posture', 'Konvergenz der Quellen', 'Abdeckung und Lücken',
'Priority Findings', 'Maßnahmenbündel', 'Remediation-Status', 'Source Overview'
]);
// Every section is labelled for assistive technology.
for (const section of document.querySelectorAll('section.segment')) {
expect(section.getAttribute('aria-labelledby')).toBeTruthy();
}
});
it('places no table inside a tile and never nests tables', async () => {
const { JSDOM } = await import('jsdom');
const { document } = new JSDOM(buildManagementOverviewHtml(projection({
coverage: {
total: 1, assessed: 1, deviations: 1, passed: 0, notRun: 0, notApplicable: 0,
executionErrors: 0, unknown: 0, perSource: [{ toolId: 'alpha', total: 1, assessed: 1, notRun: 0 }]
}
}))).window;
expect(document.querySelectorAll('.tile table')).toHaveLength(0);
expect(document.querySelectorAll('table table')).toHaveLength(0);
});
it('draws the value arc proportional to the score on a full circle', async () => {
const { JSDOM } = await import('jsdom');
const { document } = new JSDOM(buildManagementOverviewHtml(projection({ overallScore: 25 }))).window;
const arc = document.querySelector('circle.donut__value')!;
const [drawn, gap] = arc.getAttribute('stroke-dasharray')!.split(' ').map(Number);
const circumference = 2 * Math.PI * 54;
expect(drawn + gap).toBeCloseTo(circumference, 2);
expect(drawn / circumference).toBeCloseTo(0.25, 4);
});
it('clamps an out-of-range score instead of drawing past the ring', async () => {
const { JSDOM } = await import('jsdom');
const { document } = new JSDOM(buildManagementOverviewHtml(projection({ overallScore: 140 }))).window;
const [drawn, gap] = document.querySelector('circle.donut__value')!
.getAttribute('stroke-dasharray')!.split(' ').map(Number);
expect(gap).toBeCloseTo(0, 3);
expect(drawn).toBeCloseTo(2 * Math.PI * 54, 2);
}); });
}); });

View File

@ -35,11 +35,14 @@ it.each(['populated', 'unavailable', 'non-numeric count'])('shows each overview
expect(host.querySelector('.client-priority')).not.toBeNull(); expect(host.querySelector('.client-priority')).not.toBeNull();
expect(host.querySelectorAll('.client-data-fact')).toHaveLength(6); expect(host.querySelectorAll('.client-data-fact')).toHaveLength(6);
expect(groups).toHaveLength(5); expect(groups).toHaveLength(5);
const tiles = [...host.querySelectorAll('.client-environment-grid > section')];
expect(tiles).toHaveLength(6);
for (const tile of tiles) expect(tile.querySelectorAll('.client-env-facts > div').length).toBeLessThanOrEqual(4);
expect(labels(directory)).toEqual(['NetBIOS name', 'Schema version', 'Domain created', 'Entra ID']); expect(labels(directory)).toEqual(['NetBIOS name', 'Schema version', 'Domain created', 'Entra ID']);
expect(labels(domain)).toEqual(mode === 'non-numeric count' expect(labels(domain)).toEqual(mode === 'non-numeric count'
? ['Domain', 'Forest', 'Domain controllers', 'Domain functional level', 'Forest functional level', 'DC OS'] ? ['Domain', 'Forest', 'Domain controllers', 'Domain functional level', 'Forest functional level', 'DC OS']
: ['Domain', 'Forest', 'Domain functional level', 'Forest functional level', 'DC OS']); : ['Domain', 'Forest', 'Domain functional level', 'Forest functional level', 'DC OS']);
expect(labels(inventory)).toEqual(['Enabled / disabled', 'Password never expires', 'Admin groups', 'Trusts', 'Assessed host']); expect(labels(inventory)).toEqual(['Enabled / disabled', 'Password never expires', 'Admin groups', 'Trusts']);
// Equal domain/forest and functional-level values are distinct facts, not duplicates. // Equal domain/forest and functional-level values are distinct facts, not duplicates.
if (mode !== 'unavailable') { if (mode !== 'unavailable') {
expect([...domain.querySelectorAll('dd')].filter(item => item.textContent === 'contoso.example')).toHaveLength(2); expect([...domain.querySelectorAll('dd')].filter(item => item.textContent === 'contoso.example')).toHaveLength(2);

View File

@ -75,7 +75,11 @@ describe('Client Info report buttons', () => {
expect(management.fileName).toMatch(/management-report-.*\.html$/); expect(management.fileName).toMatch(/management-report-.*\.html$/);
expect(technical.fileName).toMatch(/technical-report-.*\.html$/); expect(technical.fileName).toMatch(/technical-report-.*\.html$/);
expect(management.mediaType).toBe('text/html;charset=utf-8'); expect(management.mediaType).toBe('text/html;charset=utf-8');
expect(management.text).toContain('Dummy technical deviation'); expect(management.text).toContain('Abweichungsanteil je Security Domain');
expect(management.text).toContain('Bestandene und abweichende Ergebnisse');
expect(management.text).toContain('Dummy Customer');
expect(management.text).not.toContain('Dummy technical deviation');
expect(management.text).not.toContain('Overall Score');
expect(management.text).not.toContain('DUMMY-RULE-123'); expect(management.text).not.toContain('DUMMY-RULE-123');
expect(management.text).not.toContain('dummy-artifact-secret'); expect(management.text).not.toContain('dummy-artifact-secret');
expect(technical.text).toContain('DUMMY-RULE-123'); expect(technical.text).toContain('DUMMY-RULE-123');

View File

@ -52,7 +52,7 @@ describe('local ASSESSOR HTTP server', () => {
const health = await fetch(`${baseUrl}/api/v1/health`); const health = await fetch(`${baseUrl}/api/v1/health`);
expect(await health.json()).toEqual({ expect(await health.json()).toEqual({
status: 'ok', application: 'ASSESSOR', productVersion: '2.9.0', apiVersion: 1, repositoryRevision: 0, persistence: 'memory' status: 'ok', application: 'ASSESSOR', productVersion: '2.9.1', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
}); });
expect(health.headers.get('access-control-allow-origin')).toBeNull(); expect(health.headers.get('access-control-allow-origin')).toBeNull();
}); });