feat: redesign reports and improve GPO readability

This commit is contained in:
Binh 2026-09-24 14:05:36 +02:00
parent 21a626697b
commit 210bbc05e8
14 changed files with 1665 additions and 463 deletions

View File

@ -2759,6 +2759,8 @@ Festgelegtes Betriebsrisiko vom 2026-09-06: Verzeichnisabhaengige Compose-Projek
2026-09-24 (Phase 13D, AI-Uebersicht): Environment-Fakten gingen bei gechunkter Generierung verloren, weil sie nur im letzten Chunk lagen und die Konsolidierung nur Teilanalysen erhielt; ein Findings-only-Chunk meldete fehlende Fakten und die Konsolidierung uebernahm das. Jeder Chunk und jeder Konsolidierungsaufruf erhaelt jetzt Summary und vollstaendige environmentFacts als gemeinsamen Kontext; nur Findings und Not-run-Checks werden aufgeteilt. Eingabeinhalt und -hash bleiben unveraendert, bestehende Laeufe gelten daher nicht als stale und muessen manuell neu erzeugt werden. Synthetischer Regressionstest fuer Chunk- und Konsolidierungspayload ergaenzt; Gesamtlauf 1.630 bestanden/16 uebersprungen, beide Builds erfolgreich. Result-DB-JSON-Export folgt weiterhin den aktiven Filtern, unter dem Deviation-Standard also ohne Environment-Fakten. Kein Reimport, keine Migration. 2026-09-24 (Phase 13D, AI-Uebersicht): Environment-Fakten gingen bei gechunkter Generierung verloren, weil sie nur im letzten Chunk lagen und die Konsolidierung nur Teilanalysen erhielt; ein Findings-only-Chunk meldete fehlende Fakten und die Konsolidierung uebernahm das. Jeder Chunk und jeder Konsolidierungsaufruf erhaelt jetzt Summary und vollstaendige environmentFacts als gemeinsamen Kontext; nur Findings und Not-run-Checks werden aufgeteilt. Eingabeinhalt und -hash bleiben unveraendert, bestehende Laeufe gelten daher nicht als stale und muessen manuell neu erzeugt werden. Synthetischer Regressionstest fuer Chunk- und Konsolidierungspayload ergaenzt; Gesamtlauf 1.630 bestanden/16 uebersprungen, beide Builds erfolgreich. Result-DB-JSON-Export folgt weiterhin den aktiven Filtern, unter dem Deviation-Standard also ohne Environment-Fakten. Kein Reimport, keine Migration.
2026-09-24 (Phase 7, A/B-Report-Deliverable): Management Overview und Technical Report auf Nutzerwunsch an das Layout der frueheren ASSESSOR-Exporte angelehnt (Renderer 2.0.0, gemeinsames `src/exports/report-theme.ts`): KPI-Karten, Severity-Donut mit Legende und Gesamtzahl, Overall Score mit Quellbeitraegen, Balkenlisten, im Technical Report aufklappbare Findingzeilen fuer Scan- und GPO-Findings sowie filterbares GPO Policy Register. Nur Light Mode, eingebettete Inter, ASSESSOR-Farben; kleines Inline-Skript nur fuer Suche/Filterchips und Druck-Aufklappen, ohne externe Ressourcen. Nutzerentscheidung ersetzt die statische No-JS- und Dark-Vorgabe in `REPORT_STRATEGY.md`; Management behaelt die Priority-Liste ohne Einzelwerttabelle, GPO bleibt ohne erfundene Severity. Anschliessende Lesbarkeitsrunde (Renderer 2.1.0): Findings als Beschreibung/Befund/Loesung, leere und doppelte Quellfelder entfallen, Rohdaten eingeklappt unter Technische Details, Quellprosa mit strikt allowlist-basierter Formatierung und Pipe-Zeilen (z. B. PingCastle-Details) als Tabellen bei gleicher Spaltenzahl, GPO-Vergleiche je Policy Setting zusammengefasst mit Soll/Ist-Gegenueberstellung und belegter Markierung, Register nach Verdict gruppiert, Severity/Status nur noch als dezente Zusatzinformation; Management zeigt GPO-Deviations nur im GPO-Block. Synthetische Renderer-, Filter-Verhaltens-, No-Script-Lesbarkeits-, Leerfeld-, Rich-Text-Escaping- und GPO-Rollup-Tests sowie synthetische Browserabnahme (Desktop/Mobile) bestanden; Gesamtsuite 1.648 bestanden, beide Produktionsbuilds, Local-only- und Designcheck gruen. Patchversion 2.6.1.
2026-09-24 (Phase 7/13C mit Phase 12/13B): Defender-for-IoT-Findingnamen nach dem Merge von `83284c2` wiederhergestellt. Die Source-Rule-ID-Projektion benennt ein natives ID-Feld nicht mehr um, wenn es zugleich der Findingtitel ist (Defender `CVE`), und ergaenzt die ID stattdessen; Disclosure Policy 2.6.0 gibt fuer Defender ausschliesslich die oeffentliche CVE-ID als Rule ID und Titel frei. Synthetische Work-/Result-DB-Regression, keine Migration, kein Reimport. 2026-09-24 (Phase 7/13C mit Phase 12/13B): Defender-for-IoT-Findingnamen nach dem Merge von `83284c2` wiederhergestellt. Die Source-Rule-ID-Projektion benennt ein natives ID-Feld nicht mehr um, wenn es zugleich der Findingtitel ist (Defender `CVE`), und ergaenzt die ID stattdessen; Disclosure Policy 2.6.0 gibt fuer Defender ausschliesslich die oeffentliche CVE-ID als Rule ID und Titel frei. Synthetische Work-/Result-DB-Regression, keine Migration, kein Reimport.
2026-09-23 (Phase 13D): Token-geschuetzten ISO-Doppel-PDF-Upload, privaten Korpusstand, begrenzten Parserworker, Embeddingfortschritt, Indexvollstaendigkeitsanzeige, versionsgebundene Qdrant-Ersetzung und Aktivierung nach erfolgreichem Indexlauf implementiert. Synthetische HTTP-, UI-, Client- und Indexregressionen ergaenzt; Standardlauf vor letzter Statuspraezisierung 1.584 bestanden/16 opt-in uebersprungen, danach fokussiert 29/29 und beide Typechecks. Beide Builds sowie Local-only-/Designpruefung vor der Statuspraezisierung erfolgreich. Echte Lizenzdateien und Zielserver bleiben fuer eine gesonderte Abnahme unberuehrt. Keine PostgreSQL-Migration und kein Scanner-Reimport. 2026-09-23 (Phase 13D): Token-geschuetzten ISO-Doppel-PDF-Upload, privaten Korpusstand, begrenzten Parserworker, Embeddingfortschritt, Indexvollstaendigkeitsanzeige, versionsgebundene Qdrant-Ersetzung und Aktivierung nach erfolgreichem Indexlauf implementiert. Synthetische HTTP-, UI-, Client- und Indexregressionen ergaenzt; Standardlauf vor letzter Statuspraezisierung 1.584 bestanden/16 opt-in uebersprungen, danach fokussiert 29/29 und beide Typechecks. Beide Builds sowie Local-only-/Designpruefung vor der Statuspraezisierung erfolgreich. Echte Lizenzdateien und Zielserver bleiben fuer eine gesonderte Abnahme unberuehrt. Keine PostgreSQL-Migration und kein Scanner-Reimport.

View File

@ -24,9 +24,9 @@ Remediation progress is proven by rescans, never by self-reported status. See
| Decision | Choice | | Decision | Choice |
| --- | --- | | --- | --- |
| Delivery | Separate files per artifact | | Delivery | Separate files per artifact |
| Design | ASSESSOR application look | | Design | Layout direction of the earlier ASSESSOR exports, drawn in the ASSESSOR look (superseded 2026-09-24, see below) |
| Technical scope (B) | Deviations, diagnostics and score-neutral classes | | Technical scope (B) | Deviations, diagnostics and score-neutral classes |
| Interaction | Static, print- and PDF-ready, no JavaScript | | Interaction | Print- and PDF-ready; inline script only for search/filter (superseded 2026-09-24, see below) |
| Language | German, all artifacts | | Language | German, all artifacts |
| Arbeitsgrundlage format | XLSX to work in, HTML as the frozen status view | | Arbeitsgrundlage format | XLSX to work in, HTML as the frozen status view |
| Arbeitsgrundlage scope | Curated selection, plus the complete list on a second sheet | | Arbeitsgrundlage scope | Curated selection, plus the complete list on a second sheet |
@ -38,6 +38,54 @@ forwarded without carrying registry keys, SIDs, evidence paths or parser
versions in its source. A single file with a hidden section would still contain versions in its source. A single file with a hidden section would still contain
them. them.
### Export layout and interaction — explicit user decision, 2026-09-24
The user asked for both reports to follow the earlier ASSESSOR exports again.
This supersedes the static no-JavaScript decision and the dark rendering path:
- Layout: KPI cards, a severity distribution donut with legend and large total,
the Overall Score donut with per-source contributions, bar lists instead of
count tables, and finding lists with expandable rows (Technical Report) or a
filterable Priority list (Management Overview).
- Look: ASSESSOR typography (embedded Inter), flat white cards with thin
borders, shared severity colours and the yellow marking colour for active
filter chips. No rounded shadow cards, gradients, purple accents or system
font from the old exports.
- Light only: customers read both files, so there is no `prefers-color-scheme`
dark variant.
- Interaction: one small inline script adds search and filter chips and opens
every expandable row for printing. It only toggles `hidden`, `open` and
`aria-pressed` on server-rendered markup; without it everything stays
visible. No external script, CDN, font download or inline event handler.
- Unchanged: the Management Overview keeps the grouped Priority list and
carries no per-record table or technical values; GPO deviations still carry no
invented severity; all binding wording and score semantics stay.
Shared presentation lives in `src/exports/report-theme.ts`.
Readability refinement, same day, on user feedback: a reader must see what a
finding is and how to fix it without wading through empty or raw values.
- Technical Report findings read as Beschreibung, Befund, Ist-/Sollwert where
retained, Warum ist das relevant?, Lösung and Referenzen. Empty source fields
and fields repeating already shown text are left out; the remaining original
fields, identities and raw baseline variants sit in a collapsed
`Technische Details` block. Omitting an empty value never invents one.
- Source prose keeps its simple formatting: text is escaped first and only
attribute-free `p/br/b/strong/i/em/u/ul/ol/li` tags are re-emitted.
- GPO Findings show one row per policy setting with Deviation, rolled up over its
baseline comparisons: expected values per baseline beside the configured
source instructions with the retained deviation marking, plus a fix sentence
derived only from the expected value and the Conflicts/Duplicates flags. The
Policy Register lists every other verdict grouped by verdict; long
score-neutral groups start collapsed and stay out of print.
- Severity and status are supporting information: a small dot and muted label,
no filled badges or FAIL pills. Unknown severity renders nothing.
- The Management Overview names tools by their profile name, shows GPO
deviations once in the equal-weight GPO block rather than per comparison in
Priority Findings, and counts the Deviations KPI as scanner deviations plus GPO
policy settings with Deviation.
## Report language ## Report language
Both reports are written in German: headings, prose, labels, table headers, Both reports are written in German: headings, prose, labels, table headers,
@ -99,7 +147,7 @@ consumes.
## Report A — Management Overview ## Report A — Management Overview
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 = '2.1.0'`), covered by
`tests/exports/management-overview-report.test.ts`. `tests/exports/management-overview-report.test.ts`.
Answers: how do we stand, what is most urgent, what is this based on, what do we Answers: how do we stand, what is most urgent, what is this based on, what do we
@ -109,11 +157,11 @@ not know. Its reading order follows the agreed Client Info grammar.
application shell — no sidebar, topbar, command band or context strip — and none application shell — no sidebar, topbar, command band or context strip — and none
of the four page archetypes, because it is read and printed outside the of the four page archetypes, because it is read and printed outside the
application. Typography roles, donut geometry, tile and segment grammar, status application. Typography roles, donut geometry, tile and segment grammar, status
colour rules and the panel inset apply unchanged. Light is the document default colour rules and the panel inset apply unchanged. Since 2026-09-24 the report is
because a report is printed on white; dark renders from the same semantic roles light only (customer-facing) and uses the card layout described under *Export
through `prefers-color-scheme`. layout and interaction*.
The bundled Inter is referenced by family name with a system fallback. No font, The bundled Inter is embedded as a data URL with a system fallback. No font,
style, script or image is loaded when the file is opened. style, script or image is loaded when the file is opened.
### 1. Assessment identity ### 1. Assessment identity
@ -431,15 +479,17 @@ manufacture criticalities the benchmark never stated.
the explicit conflict and duplicate flags. Never from comparison prose, the explicit conflict and duplicate flags. Never from comparison prose,
historical results, baseline text, guidance or evidence. historical results, baseline text, guidance or evidence.
- Unknown or unavailable facts render as an em dash, never as an inferred zero. - Unknown or unavailable facts render as an em dash, never as an inferred zero.
- Both files are self-contained: inline styles, no CDN, no external fonts, - Both files are self-contained: inline styles and script, no CDN, no external
scripts or images, no network access when opened. A report leaves the building fonts, scripts or images, no network access when opened. A report leaves the building
and must not load anything. and must not load anything.
- The bundled Inter variable font is embedded or a local system stack is used; - The bundled Inter variable font is embedded or a local system stack is used;
no font is downloaded at view time. no font is downloaded at view time.
- Shared typography roles, donut geometry, marking semantics and panel inset - Shared typography roles, donut geometry, marking semantics and panel inset
apply as in the application. Status colour stays in accent lines, markers, apply as in the application. Status colour stays in accent lines, markers,
arcs and score nodes, never as a full status fill. arcs and score nodes, never as a full status fill.
- Static output: no JavaScript, printable to PDF without layout loss. - Printable to PDF without layout loss. Inline script is limited to search and
filtering over already rendered content (2026-09-24); it never fetches or
writes markup.
## Relationship to the existing export ## Relationship to the existing export
@ -550,3 +600,15 @@ deliverable and D is not.
the `assessor-ui` shell and page archetypes named above; all token, typography, the `assessor-ui` shell and page archetypes named above; all token, typography,
donut and status rules kept. An out-of-range score is clamped and a missing one donut and status rules kept. An out-of-range score is clamped and a missing one
draws no arc at all. draws no arc at all.
- 2026-09-24: User decision — both reports move back toward the layout of the
earlier ASSESSOR exports (KPI cards, severity donut, bar lists, filterable and
expandable finding lists) in the ASSESSOR look, light only, with a small
inline search/filter script. Supersedes the static no-JavaScript and dark
rendering decisions. The Management Overview keeps the Priority list only and
now reads posture, Priority Findings, remediation, coverage, convergence,
bundles, sources. Renderers 2.0.0 share `src/exports/report-theme.ts`.
- 2026-09-24: Readability refinement on user feedback (renderers 2.1.0): findings
read as description, result and solution; empty and duplicate source values
are omitted and raw details are collapsed; GPO comparisons roll up to one
finding per policy setting; severity and status become quiet supporting
information.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "assessor", "name": "assessor",
"version": "2.6.0", "version": "2.6.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "assessor", "name": "assessor",
"version": "2.6.0", "version": "2.6.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.6.0", "version": "2.6.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

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

566
src/exports/report-theme.ts Normal file
View File

@ -0,0 +1,566 @@
import bundledInter from '@fontsource-variable/inter/files/inter-latin-wght-normal.woff2?inline';
import { toolProfileDefinitions } from '../profiles/profile-definition-catalog';
import { escapeHtml } from '../shared/html';
/**
* Shared presentation of the Management Overview and the Technical Report.
*
* Explicit user decision 2026-09-24: both reports follow the layout direction of
* the earlier ASSESSOR exports (KPI cards, severity donut with legend, bar
* lists, filterable finding lists with expandable rows) drawn with ASSESSOR
* typography and status colours. Customers read them, so they are light only.
* A small inline script adds search and filter chips; every piece of content is
* rendered server-side and stays readable and printable without it.
*/
export const DASH = '—';
export type SeverityKey = 'critical' | 'high' | 'medium' | 'low' | 'info' | 'unknown';
export const SEVERITY_ORDER: SeverityKey[] = ['critical', 'high', 'medium', 'low', 'info', 'unknown'];
export const SEVERITY_LABEL: Record<SeverityKey, string> = {
critical: 'Critical', high: 'High', medium: 'Medium',
low: 'Low', info: 'Info', unknown: 'ohne Severity'
};
export function integer(input: number | undefined): string {
return typeof input === 'number' && Number.isFinite(input)
? escapeHtml(input.toLocaleString('de-DE'))
: DASH;
}
const TOOL_NAMES = new Map(toolProfileDefinitions.map(definition => [definition.id, definition.name]));
/** Readable tool name from the profile catalog; unknown ids stay as they are. */
export function toolLabel(id: string | undefined): string {
const key = String(id ?? '').trim();
if (!key) return DASH;
if (/^(gpos?|group-policy)$/i.test(key)) return 'Group Policy';
return TOOL_NAMES.get(key)
?? [...TOOL_NAMES].find(([toolId]) => toolId.startsWith(key) || key.startsWith(toolId))?.[1]
?? key;
}
/**
* Severity as quiet supporting information: a small dot and a muted label.
* Unknown severity renders nothing; it is never presented as a level.
*/
export function severityText(severity: SeverityKey): string {
if (severity === 'unknown') return '';
return `<span class="sev"><span class="sev__dot sev-bg--${severity}" aria-hidden="true"></span>${escapeHtml(SEVERITY_LABEL[severity])}</span>`;
}
const FORMAT_TAG = /&lt;(\/?)(p|br|b|strong|i|em|u|ul|ol|li)\s*\/?&gt;/gi;
const FORMAT_NAME: Record<string, string> = { b: 'strong', i: 'em' };
/**
* Source prose with its simple formatting. Everything is escaped first; only
* attribute-free p/br/b/strong/i/em/u/ul/ol/li tags are re-emitted as fixed
* markup, so no source attribute, link or active content can reach the file.
*/
export function richText(value: string): string {
const escaped = escapeHtml(value.trim()).replace(/&amp;(amp|lt|gt|quot|#0?39|nbsp);/g, '&$1;');
const html = escaped.replace(FORMAT_TAG, (_match, close: string, name: string) => {
const tag = FORMAT_NAME[name.toLowerCase()] ?? name.toLowerCase();
return tag === 'br' ? '<br>' : `<${close}${tag}>`;
});
const body = /<(p|ul|ol|li)>/.test(html) ? html : textBlocks(html);
return `<div class="prose">${body}</div>`;
}
const COLUMN_LABEL = /^[A-Za-z][\w-]*(?: [A-Za-z][\w-]*){0,2}$/;
const DISTINGUISHED_NAME = /^(?:CN|OU|DC|O|L|C)=[^,=]+(?:,\s*(?:CN|OU|DC|O|L|C|ST)=[^,=]+)+$/i;
const COLUMN_VALUE = (line: string) => line.length > 0 && line.length <= 256
&& (!/\s/.test(line) || DISTINGUISHED_NAME.test(line)) && !line.includes('|');
const PIPE_DELIMITER = /^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?$/;
function pipeCells(line: string): string[] | null {
const text = line.trim();
if (!text.includes('|') || PIPE_DELIMITER.test(text)) return null;
const cells = text.replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim());
return cells.length >= 2 ? cells : null;
}
/**
* Plain source text as paragraphs. Scanners such as PingCastle write tables as
* consecutive `a | b | c` lines; a run of at least two such lines with the same
* column count becomes a table with the first line as header. An irregular run
* stays text rather than being forced into columns. Input is already escaped.
*/
function textBlocks(text: string): string {
const lines = text.split('\n');
const out: string[] = [];
let paragraph: string[] = [];
const flush = () => {
if (paragraph.length) out.push(`<p>${paragraph.join('<br>')}</p>`);
paragraph = [];
};
const table = (head: string[], bodyRows: string[][]) =>
`<div class="table-scroll source-table"><table><thead><tr>${head.map(cell => `<th>${cell}</th>`).join('')}</tr></thead>
<tbody>${bodyRows.map(row => `<tr>${row.map(cell => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
for (let index = 0; index < lines.length; index += 1) {
// PingCastle flattens one-column tables to a short label line followed by
// one value per line ("User", then account names; "DN", then distinguished
// names). A value is a single token or a distinguished name, so a sentence
// never qualifies. Distinguished names also form a list without a label.
const label = COLUMN_LABEL.test(lines[index].trim()) ? lines[index].trim() : '';
let end = label ? index + 1 : index;
while (end < lines.length && COLUMN_VALUE(lines[end].trim())) end += 1;
const values = lines.slice(label ? index + 1 : index, end).map(line => line.trim());
const onlyNames = values.length > 0 && values.every(value => DISTINGUISHED_NAME.test(value));
if (label ? values.length >= 2 || (onlyNames && values.length >= 1) : onlyNames && values.length >= 2) {
flush();
out.push(table([label || 'Distinguished Name'], values.map(value => [value])));
index = end - 1;
continue;
}
const cells = pipeCells(lines[index]);
if (cells) {
const rows = [cells];
let next = index + 1;
for (; next < lines.length; next += 1) {
if (PIPE_DELIMITER.test(lines[next].trim())) continue;
const row = pipeCells(lines[next]);
if (!row || row.length !== cells.length) break;
rows.push(row);
}
if (rows.length >= 2) {
flush();
const [head, ...bodyRows] = rows;
out.push(table(head, bodyRows));
index = next - 1;
continue;
}
}
if (!lines[index].trim()) flush();
else paragraph.push(lines[index]);
}
flush();
return out.join('');
}
/** A stable token for data attributes; filter values are space separated. */
export function filterToken(value: string | undefined): string {
const token = String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
return token || 'unknown';
}
/**
* Shared donut geometry: full 360 degrees, continuous neutral track, value arc
* clockwise from twelve o'clock, flat caps. Score semantics keep their `/100`.
*/
export function scoreDonut(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="${escapeHtml(caption)} ${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>`;
}
/** Multi-segment distribution on the same ring geometry as the score donut. */
export function distributionDonut(segments: Array<{ key: SeverityKey; value: number }>, caption: string): string {
const radius = 54;
const circumference = 2 * Math.PI * radius;
const total = segments.reduce((sum, segment) => sum + segment.value, 0);
let offset = 0;
const arcs = total ? segments.filter(segment => segment.value > 0).map(segment => {
const length = circumference * segment.value / total;
const arc = `<circle class="donut__segment sev-stroke--${segment.key}" cx="64" cy="64" r="${radius}"
stroke-dasharray="${length.toFixed(3)} ${(circumference - length).toFixed(3)}"
stroke-dashoffset="${(-offset).toFixed(3)}" transform="rotate(-90 64 64)"></circle>`;
offset += length;
return arc;
}).join('') : '';
return `<figure class="donut">
<svg viewBox="0 0 128 128" role="img" aria-label="${escapeHtml(caption)}: ${integer(total)}">
<circle class="donut__track" cx="64" cy="64" r="${radius}"></circle>${arcs}
</svg>
<figcaption class="donut__readout">
<span class="donut__number">${integer(total)}</span>
<span class="donut__caption">${escapeHtml(caption)}</span>
</figcaption>
</figure>`;
}
/** Severity legend beside a distribution donut, with the large total. */
export function severityOverview(counts: Record<SeverityKey, number>, caption: string): string {
const segments = SEVERITY_ORDER.map(key => ({ key, value: counts[key] ?? 0 }));
return `<div class="dist">
${distributionDonut(segments, caption)}
<ul class="legend">${segments.filter(segment => segment.key !== 'unknown' || segment.value > 0).map(segment => `<li>
<span class="dot sev-bg--${segment.key}" aria-hidden="true"></span>
<span class="legend__label">${escapeHtml(SEVERITY_LABEL[segment.key])}</span>
<span class="legend__value">${integer(segment.value)}</span>
</li>`).join('')}</ul>
</div>`;
}
/** Horizontal bar list; the exact number stays the authoritative value. */
export function barList(rows: Array<{ label: string; value: number; tone?: SeverityKey }>, empty: string): string {
if (!rows.length) return `<p class="empty">${escapeHtml(empty)}</p>`;
const max = Math.max(...rows.map(row => row.value), 0);
return `<ul class="bars">${rows.map(row => {
const width = max > 0 ? Math.round(row.value / max * 1000) / 10 : 0;
return `<li>
<span class="bars__label">${escapeHtml(row.label)}</span>
<span class="bars__track" aria-hidden="true"><span class="bars__fill${row.tone ? ` sev-bg--${row.tone}` : ''}" style="width:${width}%"></span></span>
<span class="bars__value">${integer(row.value)}</span>
</li>`;
}).join('')}</ul>`;
}
/**
* Filter toolbar. Hidden until the inline script enables it, so a reader
* without script never sees controls that do nothing.
*/
export function filterToolbar(options: {
placeholder: string;
groups?: Array<{ key: string; label: string; values: Array<{ value: string; label: string }> }>;
reset?: string;
}): string {
const groups = (options.groups ?? []).filter(group => group.values.length > 1);
return `<div class="card toolbar" data-filter-toolbar hidden>
<label class="search">
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="7" cy="7" r="5"></circle><path d="M11 11l3.5 3.5"></path></svg>
<input type="search" data-filter-search placeholder="${escapeHtml(options.placeholder)}" aria-label="${escapeHtml(options.placeholder)}">
</label>
<div class="toolbar__row">
${groups.map(group => `<div class="chips" role="group" aria-label="${escapeHtml(group.label)}">
<span class="chips__label">${escapeHtml(group.label)}</span>
${options.reset ? `<button type="button" class="chip" data-filter-reset="${escapeHtml(group.key)}" aria-pressed="true">${escapeHtml(options.reset)}</button>` : ''}
${group.values.map(item => `<button type="button" class="chip" data-filter-key="${escapeHtml(group.key)}" data-filter-value="${escapeHtml(item.value)}" aria-pressed="false">${escapeHtml(item.label)}</button>`).join('')}
</div>`).join('')}
<span class="toolbar__count" data-filter-count></span>
</div>
</div>`;
}
const INTER_FACE = `@font-face{font-family:"Inter";src:url('${bundledInter}') format('woff2');font-style:normal;font-weight:100 900;font-display:swap}`;
export const REPORT_CSS = `${INTER_FACE}
:root{
color-scheme:light;
--font-ui:"Inter",system-ui,-apple-system,"Segoe UI",sans-serif;
--font-mono:ui-monospace,"Cascadia Mono",Consolas,monospace;
--surface-app:#F1F1F4; --card:#FFFFFF; --card-sunken:#F7F7F9;
--text-primary:#17171C; --text-secondary:#55555F; --text-muted:#74747F;
--border-subtle:#E2E2E8; --border-default:#CFCFD8; --border-strong:#A8A8B4;
--donut-track:#DADDE3; --donut-value:#20242A; --donut-label:#555B65; --donut-all:#1C70C9;
--bar-track:#ECECF0; --bar-neutral:#4A5361;
--marking-color:#E9A63C; --marking-ink:#17171C; --focus-ring:#1C70C9;
--sev-critical:#E8214F; --sev-high:#E2680F; --sev-medium:#C99700;
--sev-low:#2E9E6B; --sev-info:#3D8FD1; --sev-unknown:#8A8E95; --sev-on:#FFFFFF;
--pass:#1F7F4B; --fail:#D42836; --different-bg:#FCE9E9;
}
*{box-sizing:border-box}
html{background:var(--surface-app)}
body{margin:0;background:var(--surface-app);color:var(--text-primary);
font:400 16px/1.55 var(--font-ui)}
.page{max-width:1280px;margin:0 auto;padding:20px}
h1,h2,h3,h4{margin:0;line-height:1.3;font-weight:600}
h1{font-size:40px;letter-spacing:-.01em;overflow-wrap:anywhere}
h2{font-size:32px}
h3{font-size:18px}
h4{font-size:16px}
p{margin:0}
code,.mono{font-family:var(--font-mono);font-size:14px}
strong{font-weight:600}
.doc-head{padding:12px 0 24px;border-bottom:1px solid var(--border-default)}
.doc-head__kicker{margin:0 0 8px;font-size:12px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text-secondary)}
.doc-head__sources{margin-top:8px;font-size:16px;font-weight:600;color:var(--text-secondary)}
.doc-head__meta{margin-top:4px;font-size:14px;color:var(--text-muted)}
.facts{display:flex;flex-wrap:wrap;gap:8px 32px;margin:16px 0 0}
.facts div{margin:0}
.facts dt{font-size:14px;color:var(--text-secondary)}
.facts dd{margin:2px 0 0;font-size:16px;overflow-wrap:anywhere}
.section{margin-top:40px}
.section__head{display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:8px 16px;margin-bottom:16px}
.section__lead{margin:-8px 0 16px;font-size:14px;color:var(--text-secondary)}
.grid{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));align-items:stretch}
.grid--2{grid-template-columns:repeat(auto-fit,minmax(420px,1fr))}
.stack{display:flex;flex-direction:column;gap:16px}
.card{background:var(--card);border:1px solid var(--border-subtle);padding:20px;min-width:0}
.card__head{display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:4px 12px;
padding-bottom:12px;margin-bottom:16px;border-bottom:1px solid var(--border-subtle)}
.card__title{font-size:16px;font-weight:600}
.card__meta{font-size:14px;color:var(--text-secondary)}
.note{margin-top:12px;font-size:14px;color:var(--text-secondary)}
.empty{font-size:14px;color:var(--text-muted)}
.kpi{display:flex;flex-direction:column;gap:8px}
.kpi__label{font-size:14px;font-weight:600;color:var(--text-secondary)}
.kpi__figure{font-size:48px;font-weight:700;line-height:1.05;font-variant-numeric:tabular-nums}
.kpi__figure--critical{color:var(--sev-critical)}
.kpi__caption{font-size:14px;color:var(--text-secondary)}
.kpi--donut{align-items:center}
.kpi--donut .kpi__label{align-self:flex-start}
.donut{position:relative;margin:0;width:136px;height:136px;flex:none}
.donut svg{width:100%;height:100%;display:block}
.donut circle{fill:none;stroke-width:14px;stroke-linecap:butt}
.donut__track{stroke:var(--donut-track)}
.donut__value{stroke:var(--donut-all)}
.donut__readout{position:absolute;inset:18px;display:flex;flex-direction:column;align-items:center;
justify-content:center;gap:2px;text-align:center}
.donut__number{font-size:26px;font-weight:700;color:var(--donut-value);line-height:1;font-variant-numeric:tabular-nums}
.donut__unit{font-size:14px;font-weight:400;color:var(--donut-label)}
.donut__caption{font-size:12px;color:var(--donut-label)}
.dist{display:flex;flex-wrap:wrap;align-items:center;gap:24px 48px}
.card--dist{display:flex;flex-direction:column}
.card--dist .dist{flex:1;justify-content:center;align-content:center}
.legend{list-style:none;margin:0;padding:0;display:grid;gap:6px;min-width:190px}
.legend li{display:grid;grid-template-columns:12px 1fr auto;align-items:center;gap:10px}
.legend__label{font-size:16px}
.legend__value{font-size:16px;font-weight:600;text-align:right;font-variant-numeric:tabular-nums}
.dot{width:12px;height:12px;border-radius:50%;display:inline-block}
.sources-list{list-style:none;margin:16px 0 0;padding:0;width:100%}
.sources-list li{display:grid;grid-template-columns:1fr auto;gap:12px;padding:6px 0;border-bottom:1px solid var(--border-subtle);font-size:14px}
.sources-list li:last-child{border-bottom:0}
.sources-list__value{font-weight:600;font-variant-numeric:tabular-nums}
.sources-list__method{grid-column:1 / -1;margin-top:-4px;font-size:12px;color:var(--text-muted)}
.bars{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:minmax(120px,max-content) minmax(80px,1fr) max-content;gap:10px 14px;align-items:center}
.bars li{display:contents}
.bars__label{font-size:14px;color:var(--text-secondary);overflow-wrap:anywhere}
.bars__track{height:14px;background:var(--bar-track)}
.bars__fill{display:block;height:100%;background:var(--bar-neutral)}
.bars__value{font-size:14px;font-weight:600;text-align:right;font-variant-numeric:tabular-nums}
.sev-bg--critical{background:var(--sev-critical)} .sev-bg--high{background:var(--sev-high)}
.sev-bg--medium{background:var(--sev-medium)} .sev-bg--low{background:var(--sev-low)}
.sev-bg--info{background:var(--sev-info)} .sev-bg--unknown{background:var(--sev-unknown)}
.sev-stroke--critical{stroke:var(--sev-critical)} .sev-stroke--high{stroke:var(--sev-high)}
.sev-stroke--medium{stroke:var(--sev-medium)} .sev-stroke--low{stroke:var(--sev-low)}
.sev-stroke--info{stroke:var(--sev-info)} .sev-stroke--unknown{stroke:var(--sev-unknown)}
.sev{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary);white-space:nowrap}
.sev__dot{width:8px;height:8px;border-radius:50%;flex:none}
.tag{display:inline-block;padding:1px 8px;border:1px solid var(--border-subtle);background:var(--card-sunken);
font-size:12px;color:var(--text-secondary);white-space:nowrap;font-variant-numeric:tabular-nums}
.count-tag{display:inline-block;padding:1px 8px;background:var(--card-sunken);border:1px solid var(--border-subtle);
font-size:14px;font-weight:600;font-variant-numeric:tabular-nums}
.toolbar{display:flex;flex-direction:column;gap:12px;margin-bottom:16px}
.toolbar[hidden]{display:none}
.search{display:flex;align-items:center;gap:10px;padding:8px 12px;border:1px solid var(--border-default);background:var(--card)}
.search:focus-within{outline:2px solid var(--focus-ring);outline-offset:-1px}
.search svg{width:16px;height:16px;flex:none;fill:none;stroke:var(--text-muted);stroke-width:1.6}
.search input{flex:1;min-width:0;border:0;outline:0;background:transparent;font:400 16px var(--font-ui);color:var(--text-primary)}
.toolbar__row{display:flex;flex-wrap:wrap;align-items:center;gap:8px 24px}
.chips{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
.chips__label{font-size:12px;font-weight:600;color:var(--text-muted);margin-right:4px}
.chip{padding:4px 10px;border:1px solid var(--border-default);background:var(--card-sunken);color:var(--text-primary);
font:600 13px var(--font-ui);cursor:pointer}
.chip:hover{background:#EDEDF1}
.chip[aria-pressed="true"]{background:var(--marking-color);border-color:var(--marking-color);color:var(--marking-ink)}
.chip:focus-visible{outline:2px solid var(--focus-ring);outline-offset:1px}
.toolbar__count{margin-left:auto;font-size:14px;font-weight:600;color:var(--text-secondary);font-variant-numeric:tabular-nums}
.list{padding:0}
.list__head{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;padding:16px 20px;border-bottom:1px solid var(--border-subtle)}
.list__crumb{font-size:18px;color:var(--text-secondary)}
.list__title{font-size:18px;font-weight:600}
.list__empty{padding:16px 20px}
.row{border-bottom:1px solid var(--border-subtle)}
.list{--row-cols:8px minmax(0,1fr) auto 12px}
.row[hidden]{display:none}
.row>summary,.row--flat{display:grid;grid-template-columns:var(--row-cols);gap:14px;align-items:center;padding:12px 20px;cursor:pointer;list-style:none}
.row--flat{cursor:default}
.row>summary::-webkit-details-marker{display:none}
.row>summary:hover{background:#FAFAFB}
.row>summary:focus-visible{outline:2px solid var(--focus-ring);outline-offset:-2px}
.row[open]>summary{background:var(--card-sunken)}
.row__marker{width:8px;height:8px;border-radius:50%}
.row__main{min-width:0;display:flex;flex-direction:column;gap:2px}
.row__title{font-size:16px;font-weight:600;overflow-wrap:anywhere}
.row__sub{font-size:14px;color:var(--text-secondary);overflow-wrap:anywhere}
.row__side{display:flex;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:6px}
.row__chevron{width:10px;height:10px;border-right:2px solid var(--text-muted);border-bottom:2px solid var(--text-muted);transform:rotate(45deg);justify-self:center}
.row[open] .row__chevron{transform:rotate(225deg)}
.detail{padding:8px 20px 24px 42px;display:flex;flex-direction:column;gap:20px}
.block__label{margin:0 0 6px;font-size:14px;font-weight:600;color:var(--text-secondary)}
.prose{font-size:16px;line-height:1.6;overflow-wrap:anywhere}
.prose p+p,.prose p+ul,.prose ul+p{margin-top:8px}
.prose ul,.prose ol{margin:4px 0 0;padding-left:22px}
.prose .source-table{margin:10px 0;border:1px solid var(--border-subtle)}
.prose .source-table:first-child{margin-top:0}
.source-table th{text-transform:none;letter-spacing:0;vertical-align:bottom}
.source-table th,.source-table td{padding:6px 10px;font-size:14px;line-height:1.4}
.source-table th,.source-table td{overflow-wrap:normal;word-break:normal}
.source-table tbody tr:nth-child(even){background:var(--card-sunken)}
@media print{.source-table th,.source-table td{white-space:normal;overflow-wrap:anywhere}}
.solution{border-left:3px solid var(--text-primary);background:var(--card-sunken);padding:14px 18px}
.compare{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px}
.compare__col{border:1px solid var(--border-subtle);padding:14px 16px;min-width:0;display:flex;flex-direction:column;gap:10px}
.compare__caption{font-size:12px;color:var(--text-muted)}
.refs{display:flex;flex-wrap:wrap;gap:6px}
.kv{display:grid;grid-template-columns:minmax(140px,220px) minmax(0,1fr);gap:6px 16px;margin:0;font-size:14px}
.kv dt{color:var(--text-secondary)}
.kv dd{margin:0;overflow-wrap:anywhere}
.more>summary{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:14px;font-weight:600;color:var(--text-secondary);list-style:none}
.more>summary::-webkit-details-marker{display:none}
.more>summary::before{content:"";width:7px;height:7px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(-45deg)}
.more[open]>summary::before{transform:rotate(45deg)}
.more>summary:focus-visible{outline:2px solid var(--focus-ring);outline-offset:2px}
.more__body{margin-top:12px;display:flex;flex-direction:column;gap:14px}
.group{border-bottom:1px solid var(--border-subtle)}
.group[hidden]{display:none}
.group>summary{display:flex;align-items:center;gap:10px;padding:12px 20px;background:var(--card-sunken);cursor:pointer;font-size:16px;font-weight:600;list-style:none}
.group>summary::-webkit-details-marker{display:none}
.group>summary::before{content:"";width:7px;height:7px;border-right:2px solid var(--text-muted);border-bottom:2px solid var(--text-muted);transform:rotate(-45deg)}
.group[open]>summary::before{transform:rotate(45deg)}
.group>summary:focus-visible{outline:2px solid var(--focus-ring);outline-offset:-2px}
.group__note{font-size:14px;font-weight:400;color:var(--text-muted)}
.meta{font-size:12px;color:var(--text-muted);overflow-wrap:anywhere}
.limit{font-size:14px;color:var(--text-secondary)}
.table-scroll{overflow-x:auto}
table{width:100%;border-collapse:collapse;font-size:14px}
th,td{text-align:left;vertical-align:top;padding:10px 12px;border-bottom:1px solid var(--border-subtle);overflow-wrap:anywhere}
thead th{background:var(--card-sunken);font-size:14px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-secondary)}
tbody th{font-weight:400;color:var(--text-secondary);width:32%}
tr[hidden]{display:none}
.num{text-align:right;font-variant-numeric:tabular-nums}
.attr{border:1px solid var(--border-subtle)}
pre{margin:0;font:14px/1.5 var(--font-mono);white-space:pre-wrap;overflow-wrap:anywhere}
.instruction{margin:0}
.instruction div{display:grid;grid-template-columns:120px 1fr;gap:8px;padding:3px 0}
.instruction dt{font-size:14px;color:var(--text-secondary)}
.instruction dd{margin:0;overflow-wrap:anywhere}
.different{background:var(--different-bg);border-left:3px solid var(--fail);padding:2px 6px}
.values{margin:0;padding-left:20px}
.values li{padding:1px 0}
.source{display:flex;flex-direction:column;gap:8px}
.source+.source{border-top:1px solid var(--border-subtle);padding-top:12px}
.source h6{margin:0;font-size:14px;font-weight:600;overflow-wrap:anywhere}
.source__path{font-family:var(--font-mono);font-size:12px;color:var(--text-muted);overflow-wrap:anywhere}
.source-value{font-size:14px}
.to-top{font-size:13px;font-weight:600;color:var(--text-secondary);text-decoration:none;border:1px solid var(--border-default);padding:4px 10px}
@page{size:A4;margin:12mm}
@media print{
html,body{background:#FFFFFF}
.page{max-width:none;padding:0}
.toolbar,.to-top{display:none !important}
.card{break-inside:avoid}
.list,.card.list{break-inside:auto}
.row{break-inside:avoid}
.row[hidden],.group[hidden],tr[hidden]{display:revert}
h2,h3,h4,h5{break-after:avoid}
}
@media (max-width:720px){
h1{font-size:32px}
h2{font-size:24px}
.grid--2{grid-template-columns:1fr}
.list{--row-cols:8px minmax(0,1fr) 12px}
.row__side{display:none}
.detail{padding-left:20px}
.kv{grid-template-columns:1fr}
.bars{grid-template-columns:minmax(0,1fr) max-content}
.bars__track{grid-column:1 / -1}
.instruction div{grid-template-columns:1fr}
}`;
/**
* Inline behaviour: search, filter chips and expanding every row for print.
* It only toggles `hidden`, `open` and `aria-pressed` on server-rendered markup
* and reads textContent; it never writes markup or loads anything.
*/
export const REPORT_SCRIPT = `(function(){
var scopes = document.querySelectorAll('[data-filter-scope]');
Array.prototype.forEach.call(scopes, function(scope){
var toolbar = scope.querySelector('[data-filter-toolbar]');
if (toolbar) toolbar.hidden = false;
var search = scope.querySelector('[data-filter-search]');
var count = scope.querySelector('[data-filter-count]');
var chips = Array.prototype.slice.call(scope.querySelectorAll('[data-filter-key]'));
var resets = Array.prototype.slice.call(scope.querySelectorAll('[data-filter-reset]'));
var items = Array.prototype.slice.call(scope.querySelectorAll('[data-filter-item]'));
var groups = Array.prototype.slice.call(scope.querySelectorAll('[data-filter-group]'));
var texts = items.map(function(item){ return (item.textContent || '').toLowerCase(); });
function apply(){
var query = search ? search.value.trim().toLowerCase() : '';
var active = {};
chips.forEach(function(chip){
if (chip.getAttribute('aria-pressed') !== 'true') return;
var key = chip.getAttribute('data-filter-key');
(active[key] = active[key] || []).push(chip.getAttribute('data-filter-value'));
});
resets.forEach(function(reset){
reset.setAttribute('aria-pressed', active[reset.getAttribute('data-filter-reset')] ? 'false' : 'true');
});
var shown = 0;
items.forEach(function(item, index){
var visible = !query || texts[index].indexOf(query) !== -1;
Object.keys(active).forEach(function(key){
var values = (item.getAttribute('data-' + key) || '').split(' ');
if (!active[key].some(function(value){ return values.indexOf(value) !== -1; })) visible = false;
});
item.hidden = !visible;
if (visible) shown++;
});
groups.forEach(function(group){
var id = group.getAttribute('data-filter-group');
var hasVisible = Boolean(scope.querySelector('[data-filter-member="' + id + '"]:not([hidden])'));
group.hidden = !hasVisible;
if (query && hasVisible && group.tagName === 'DETAILS') group.open = true;
});
if (count) count.textContent = shown.toLocaleString('de-DE') + ' / ' + items.length.toLocaleString('de-DE');
}
chips.forEach(function(chip){
chip.addEventListener('click', function(){
chip.setAttribute('aria-pressed', chip.getAttribute('aria-pressed') === 'true' ? 'false' : 'true');
apply();
});
});
resets.forEach(function(reset){
reset.addEventListener('click', function(){
var key = reset.getAttribute('data-filter-reset');
chips.forEach(function(chip){ if (chip.getAttribute('data-filter-key') === key) chip.setAttribute('aria-pressed', 'false'); });
apply();
});
});
if (search) search.addEventListener('input', apply);
apply();
});
var opened = [];
addEventListener('beforeprint', function(){
Array.prototype.forEach.call(document.querySelectorAll('details:not([open]):not([data-print-closed])'), function(item){ item.open = true; opened.push(item); });
});
addEventListener('afterprint', function(){
opened.forEach(function(item){ item.open = false; });
opened = [];
});
})();`;
export function reportDocument(title: string, body: string, extraCss = ''): string {
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>${escapeHtml(title)}</title>
<style>
${REPORT_CSS}
${extraCss}
</style>
</head>
<body>
<main class="page" id="top">
${body}
</main>
<script>
${REPORT_SCRIPT}
</script>
</body>
</html>`;
}

View File

@ -1,31 +1,82 @@
import type { JsonValue } from '../domain/canonical-model'; import type { JsonValue } from '../domain/canonical-model';
import type { PolicySourceEvidence, RecordField } from '../domain/finding'; import type { PolicySourceEvidence, RecordField } from '../domain/finding';
import type { ReportProjection, ReportReviewInput } from '../domain/report-projection'; import type { ReportPolicyEntry, ReportProjection, ReportReviewInput } from '../domain/report-projection';
import type { UnifiedResultProjection } from '../domain/unified-result'; import type { UnifiedResultProjection } from '../domain/unified-result';
import { escapeHtml } from '../shared/html'; import { escapeHtml } from '../shared/html';
import {
SEVERITY_LABEL,
SEVERITY_ORDER,
barList,
filterToken,
filterToolbar,
reportDocument,
richText,
scoreDonut,
severityOverview,
severityText,
toolLabel,
type SeverityKey
} from './report-theme';
export const TECHNICAL_REPORT_RENDERER_VERSION = '1.0.0'; export const TECHNICAL_REPORT_RENDERER_VERSION = '2.1.0';
const plain = (value: unknown): string => value === undefined || value === null || value === '' const plain = (value: unknown): string => value === undefined || value === null || value === ''
? '—' : escapeHtml(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)); ? '—' : escapeHtml(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value));
const number = (value: number): string => escapeHtml(value.toLocaleString('de-DE')); const number = (value: number): string => escapeHtml(value.toLocaleString('de-DE'));
const when = (value: string): string => Number.isFinite(Date.parse(value)) const when = (value: string): string => Number.isFinite(Date.parse(value))
? escapeHtml(new Date(value).toLocaleString('de-DE')) : plain(value); ? escapeHtml(new Date(value).toLocaleString('de-DE')) : plain(value);
const day = (value: string | undefined): string => value && Number.isFinite(Date.parse(value))
? escapeHtml(new Date(value).toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' })) : '';
function table(headers: string[], rows: string[][]): string { /** A value worth showing; empty cells and placeholder dashes are left out. */
const present = (value: unknown): boolean => {
if (value === undefined || value === null) return false;
const text = typeof value === 'string' ? value.trim() : String(value);
return text !== '' && text !== '—' && text !== '-';
};
const same = (a: string | undefined, b: string | undefined) =>
Boolean(a && b) && a!.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim() === b!.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
const unique = <T>(items: T[], key: (item: T) => string = item => JSON.stringify(item)): T[] => {
const seen = new Set<string>();
return items.filter(item => { const id = key(item); if (seen.has(id)) return false; seen.add(id); return true; });
};
function table(headers: string[], rows: string[][], rowAttributes?: string[]): string {
return `<div class="table-scroll"><table><thead><tr>${headers.map(label => `<th>${escapeHtml(label)}</th>`).join('')}</tr></thead> return `<div class="table-scroll"><table><thead><tr>${headers.map(label => `<th>${escapeHtml(label)}</th>`).join('')}</tr></thead>
<tbody>${rows.map(row => `<tr>${row.map(cell => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`; <tbody>${rows.map((row, index) => `<tr${rowAttributes?.[index] ?? ''}>${row.map(cell => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
} }
/** Label/value list without empty entries. */
function keyValues(rows: Array<[string, unknown, boolean?]>): string {
const shown = rows.filter(([, value]) => present(value));
if (!shown.length) return '';
return `<dl class="kv">${shown.map(([label, value, mono]) =>
`<dt>${escapeHtml(label)}</dt><dd${mono ? ' class="mono"' : ''}>${plain(value)}</dd>`).join('')}</dl>`;
}
const block = (label: string, content: string, className = '') => content
? `<section class="block ${className}"><p class="block__label">${escapeHtml(label)}</p>${content}</section>` : '';
const INSTRUCTION_LABEL: Record<string, string> = {
operation: 'Operation', data: 'Data', typeCode: 'Type code', valueName: 'Value name', key: 'Key',
principals: 'Principals', values: 'Werte', state: 'State', success: 'Success', failure: 'Failure'
};
const HIDDEN_KEYS = new Set(['kind', 'version']);
/** Readable GPO value; deviation marking is taken only from the retained comparison. */
function sourceValue(value: JsonValue, markedFields: readonly string[], markedItems: readonly string[]): string { function sourceValue(value: JsonValue, markedFields: readonly string[], markedItems: readonly string[]): string {
if (Array.isArray(value)) { if (Array.isArray(value)) {
const members = `<ul class="values">${value.map(item => `<li class="${typeof item === 'string' && markedItems.includes(item) ? 'different' : ''}">${sourceValue(item, [], [])}</li>`).join('')}</ul>`; const members = `<ul class="values">${value.map(item => `<li class="${typeof item === 'string' && markedItems.includes(item) ? 'different' : ''}">${sourceValue(item, [], [])}</li>`).join('')}</ul>`;
return markedFields.includes('value') && !markedItems.length ? `<div class="different">${members}</div>` : members; return markedFields.includes('value') && !markedItems.length ? `<div class="different">${members}</div>` : members;
} }
if (value && typeof value === 'object') { if (value && typeof value === 'object') {
const instruction = `<dl class="instruction">${Object.entries(value).map(([key, item]) => { const entries = Object.entries(value).filter(([key]) => !HIDDEN_KEYS.has(key));
if (entries.length === 1 && Array.isArray(entries[0][1])) {
return sourceValue(entries[0][1], markedFields.includes(entries[0][0]) ? ['value'] : markedFields, markedItems);
}
const instruction = `<dl class="instruction">${entries.map(([key, item]) => {
const marked = markedFields.includes(key) || (markedFields.includes('value') && key === 'value'); const marked = markedFields.includes(key) || (markedFields.includes('value') && key === 'value');
return `<div><dt>${escapeHtml(key)}</dt><dd class="${marked ? 'different' : ''}">${sourceValue(item, [], markedItems)}</dd></div>`; return `<div><dt>${escapeHtml(INSTRUCTION_LABEL[key] ?? key)}</dt><dd class="${marked ? 'different' : ''}">${sourceValue(item, [], markedItems)}</dd></div>`;
}).join('')}</dl>`; }).join('')}</dl>`;
return markedFields.includes('value') && !Object.hasOwn(value, 'value') && !markedItems.length return markedFields.includes('value') && !Object.hasOwn(value, 'value') && !markedItems.length
? `<div class="different">${instruction}</div>` : instruction; ? `<div class="different">${instruction}</div>` : instruction;
@ -34,121 +85,447 @@ function sourceValue(value: JsonValue, markedFields: readonly string[], markedIt
} }
function sourceSections(sources: PolicySourceEvidence[]): string { function sourceSections(sources: PolicySourceEvidence[]): string {
if (!sources.length) return '<p class="limit">Keine einzelnen GPO-Quellanweisungen in diesem Snapshot verfügbar.</p>'; return sources.map(source => `<div class="source">
return sources.map(source => `<section class="source"> <h6>GPO ${plain(source.gpoId ?? source.nativeBackupId ?? source.backupInstanceId)}</h6>
<h5>GPO Backup ${plain(source.gpoId)} · ${plain(source.nativeBackupId)}</h5> <span class="source__path">${escapeHtml(source.sourceReference)}${source.backupInstanceId ? ` · Backup ${escapeHtml(source.backupInstanceId)}` : ''}${typeof source.sequence === 'number' ? ` · Reihenfolge ${number(source.sequence)}` : ''}</span>
<dl class="facts"><div><dt>Backup-Instanz</dt><dd>${plain(source.backupInstanceId)}</dd></div> ${source.values.map((value, index) => `<div class="source-value">
<div><dt>Evidence</dt><dd>${plain(source.sourceReference)}</dd></div> ${source.values.length > 1 ? `<p class="compare__caption">Anweisung ${number(index + 1)}</p>` : ''}
<div><dt>Quellreihenfolge</dt><dd>${plain(source.sequence)}</dd></div></dl>
${source.values.map((value, index) => `<div class="source-value"><span>Anweisung ${number(index + 1)}</span>
${sourceValue(value, source.deviationFields?.[index] ?? [], source.deviationItems?.[index] ?? [])}</div>`).join('')} ${sourceValue(value, source.deviationFields?.[index] ?? [], source.deviationItems?.[index] ?? [])}</div>`).join('')}
</section>`).join(''); </div>`).join('');
} }
function resultDetail(result: UnifiedResultProjection, domainLabel: string, review?: ReportReviewInput, fields?: RecordField[]): string { const isPolicy = (result: UnifiedResultProjection): boolean => result.sourceKind === 'policy-evaluation';
const actual = result.policyActualValue;
const expected = result.policyBaselineValue; function severityOf(result: UnifiedResultProjection): SeverityKey {
const variants = result.policyBaselineVariantAssessment; return (SEVERITY_ORDER as string[]).includes(result.severityNormalized) ? result.severityNormalized as SeverityKey : 'unknown';
return `<article class="finding">
<header><h4>${plain(result.title)}</h4><span>${plain(result.sourceTool)} · ${plain(result.severityNormalized)} · ${plain(result.outcomeNormalized)}</span></header>
<dl class="facts"><div><dt>Security Domain</dt><dd>${escapeHtml(domainLabel)}</dd></div>
<div><dt>Source Rule ID</dt><dd>${plain(result.sourceRuleId)}</dd></div>
<div><dt>Technische Identität</dt><dd>${plain(result.policyIdentityKey ?? result.canonicalDefinitionId)}</dd></div>
<div><dt>Scope</dt><dd>${plain(result.policyScope ?? result.affectedAsset)}</dd></div>
<div><dt>Result Status</dt><dd>${plain(result.policyClassification ?? result.outcomeNormalized)}</dd></div>
<div><dt>Baseline</dt><dd>${plain(result.policyBaselineLabel)}</dd></div></dl>
${result.description ? `<p>${plain(result.description)}</p>` : ''}
${result.recommendation ? `<p><strong>Empfehlung:</strong> ${plain(result.recommendation)}</p>` : ''}
${actual !== undefined || expected !== undefined ? table(['Konfiguriert', 'Erwartet', 'Vergleich'], [[plain(actual), plain(expected), plain(result.policyComparisonRelation)]])
: '<p class="limit">Diese Quelle liefert hier keinen strukturierten Ist-/Sollwert. Es wird keiner abgeleitet.</p>'}
${variants ? `<h5>Baseline-Varianten</h5><pre>${plain(variants)}</pre>` : ''}
${result.policyDiagnosticCodes?.length ? `<p><strong>Diagnostics:</strong> ${result.policyDiagnosticCodes.map(plain).join(', ')}</p>` : ''}
${result.frameworkReferences.length ? `<h5>Framework-Referenzen (exakte Quellwerte)</h5>${table(['Namespace', 'Referenz'],
result.frameworkReferences.map(reference => [plain(reference.namespace), plain(reference.value)]))}` : ''}
${fields?.length ? `<h5>Originale technische Felder</h5>${table(['Feld', 'Wert'], fields.map(field => [plain(field.label), plain(field.value)]))}` : ''}
${review?.nextStep ? `<p><strong>Geplanter Remediation-Schritt (unverifizierte Selbstauskunft):</strong> ${plain(review.nextStep)}</p>` : ''}
<p class="meta">Result-ID ${plain(result.resultProjectionId)} · ScanRun ${plain(result.scanRunId)} · ImportArtifact ${plain(result.importArtifactId)} · Source Locator ${plain(result.sourceLocator)}</p>
</article>`;
} }
const OUTCOME_LABEL: Record<string, string> = {
fail: 'Fail', pass: 'Pass', warning: 'Warning', informational: 'Informational', execution_error: 'Execution error',
not_run: 'Not run', not_applicable: 'Not applicable', not_evaluated: 'Not evaluated', unknown: 'Unknown'
};
const outcomeLabel = (outcome: string | undefined) => OUTCOME_LABEL[String(outcome)] ?? String(outcome ?? '—');
const RELATION_LABEL: Record<string, string> = {
different: 'Der konfigurierte Wert weicht von der Baseline ab.',
weaker: 'Der konfigurierte Wert ist schwächer als die Baseline.',
stronger: 'Der konfigurierte Wert ist strenger als die Baseline.',
match: 'Der konfigurierte Wert entspricht der Baseline.',
incomparable: 'Der konfigurierte Wert ist mit der Baseline nicht vergleichbar.'
};
/* Scanner field roles, read by label; presentation only, the stored fields stay untouched. */
const FINDING_LABEL = /^(result message|message|result details?|details|finding details?|observation|evidence)$/i;
const RISK_LABEL = /likelihood|impact|rationale|^risk$|^why/i;
const SOLUTION_LABEL = /remediation|solution|mitigation|^fix|recommend/i;
function scannerDetail(result: UnifiedResultProjection, domainLabel: string, review?: ReportReviewInput, fields: RecordField[] = []): string {
const filled = fields.filter(field => present(field.value));
const pick = (pattern: RegExp) => filled.find(field => pattern.test(field.label.trim()));
const found = pick(FINDING_LABEL);
const risk = pick(RISK_LABEL);
const solution = present(result.recommendation) ? result.recommendation! : pick(SOLUTION_LABEL)?.value;
const shown = [result.title, result.description, solution, found?.value, risk?.value];
const remaining = filled.filter(field => field !== found && field !== risk
&& !shown.some(text => same(text, field.value)));
const references = unique(result.frameworkReferences.filter(reference => present(reference.value)), reference => reference.value);
const hasValues = result.policyActualValue !== undefined || result.policyBaselineValue !== undefined;
return `<div class="detail">
${present(result.description) ? block('Beschreibung', richText(result.description!)) : ''}
${found ? block('Befund', richText(found.value)) : ''}
${hasValues ? block('Ist- und Sollwert', `<div class="compare">
<div class="compare__col"><p class="block__label">Konfiguriert</p>${plain(result.policyActualValue)}</div>
<div class="compare__col"><p class="block__label">Erwartet</p>${plain(result.policyBaselineValue)}</div></div>`) : ''}
${risk && !same(risk.value, result.description) ? block('Warum ist das relevant?', richText(risk.value)) : ''}
${solution ? block('Lösung', richText(solution), 'solution') : ''}
${review?.nextStep ? block('Geplanter Remediation-Schritt (unverifizierte Selbstauskunft)', richText(review.nextStep)) : ''}
${references.length ? block('Referenzen', `<div class="refs">${references.map(reference => `<span class="tag">${escapeHtml(reference.value)}</span>`).join('')}</div>`) : ''}
<details class="more" data-print-closed><summary>Technische Details</summary><div class="more__body">
${keyValues([
['Quelle', toolLabel(result.sourceTool)], ['Security Domain', domainLabel], ['Source Rule ID', result.sourceRuleId],
['Scope', result.policyScope ?? result.affectedAsset], ['Outcome', outcomeLabel(result.outcomeNormalized)],
['Severity', result.severityNormalized === 'unknown' ? undefined : SEVERITY_LABEL[severityOf(result)]],
['Baseline', result.policyBaselineLabel], ['Diagnostics', result.policyDiagnosticCodes?.join(', ')]
])}
${remaining.length ? `<p class="block__label">Originale Felder der Quelle</p><dl class="kv">${remaining.map(field =>
`<dt>${escapeHtml(field.label)}</dt><dd>${richText(field.value)}</dd>`).join('')}</dl>` : ''}
<p class="meta">Result-ID ${plain(result.resultProjectionId)}${result.scanRunId ? ` · ScanRun ${plain(result.scanRunId)}` : ''}${result.importArtifactId ? ` · ImportArtifact ${plain(result.importArtifactId)}` : ''}</p>
</div></details>
</div>`;
}
interface ResultGroup {
label: string;
items: UnifiedResultProjection[];
}
function scanList(groups: ResultGroup[], detail: (result: UnifiedResultProjection, groupLabel: string) => string): string {
const total = groups.reduce((sum, group) => sum + group.items.length, 0);
if (!total) return '<div class="card list"><p class="empty list__empty">Keine Scan-Deviations in diesem Snapshot.</p></div>';
return `<div class="card list">${groups.map((group, groupIndex) => {
const member = `scan-${groupIndex}`;
return `<details class="group" data-filter-group="${member}" open><summary>${escapeHtml(group.label)} <span class="count-tag">${number(group.items.length)}</span></summary>
${group.items.map(item => {
const severity = severityOf(item);
return `<details class="row finding" data-filter-item data-filter-member="${member}" data-severity="${severity}" data-tool="${filterToken(item.sourceTool)}">
<summary>
<span class="row__marker sev-bg--${severity}" aria-hidden="true"></span>
<span class="row__main">
<h4 class="row__title">${plain(item.title)}</h4>
<span class="row__sub">${escapeHtml(toolLabel(item.sourceTool))}${present(item.affectedAsset) ? ` · ${plain(item.affectedAsset)}` : ''}</span>
</span>
<span class="row__side">${severityText(severity)}</span>
<span class="row__chevron" aria-hidden="true"></span>
</summary>
${detail(item, group.label)}
</details>`;
}).join('')}</details>`;
}).join('')}</div>`;
}
/** One GPO policy setting with Deviation: its comparisons, the rolled-up entry and its sources. */
interface GpoFinding {
key: string;
title: string;
entry?: ReportPolicyEntry;
results: UnifiedResultProjection[];
sources: PolicySourceEvidence[];
}
function gpoFindings(results: UnifiedResultProjection[], entries: ReportPolicyEntry[], sources: Map<string, PolicySourceEvidence[]>): GpoFinding[] {
const failing = entries.filter(entry => entry.verdict === 'Fail');
const byKey = new Map<string, GpoFinding>();
for (const result of results) {
const key = result.policyIdentityKey ?? result.sourceRuleId ?? result.resultProjectionId;
const finding = byKey.get(key);
if (finding) { finding.results.push(result); continue; }
const entry = failing.find(item => item.identity === key);
byKey.set(key, { key, title: entry?.title ?? result.title, entry, results: [result], sources: entry ? sources.get(entry.id) ?? [] : [] });
}
for (const entry of failing) {
if (!byKey.has(entry.identity)) {
byKey.set(entry.identity, { key: entry.identity, title: entry.title, entry, results: [], sources: sources.get(entry.id) ?? [] });
}
}
return [...byKey.values()].sort((a, b) => a.title.localeCompare(b.title));
}
function gpoDetail(finding: GpoFinding): string {
const { results, entry, sources } = finding;
const expected = unique(results.flatMap(result => (result.policyBaselineVariantAssessment?.variants.flatMap(variant => variant.baselineValues)
?? (result.policyBaselineValue !== undefined ? [result.policyBaselineValue] : [])) as unknown as JsonValue[]));
const configured = unique(results.flatMap(result => (result.policyBaselineVariantAssessment?.actualValues
?? (result.policyActualValue !== undefined ? [result.policyActualValue] : [])) as unknown as JsonValue[]));
const baselines = unique(results.map(result => result.policyBaselineLabel).filter(present) as string[], item => item);
const relations = unique(results.map(result => result.policyComparisonRelation).filter(present) as string[], item => item);
const description = results.map(result => result.description).find(present);
const recommendation = results.map(result => result.recommendation).find(present);
const references = unique(results.flatMap(result => result.frameworkReferences).filter(reference => present(reference.value)), reference => reference.value);
const diagnostics = unique(results.flatMap(result => result.policyDiagnosticCodes ?? []), item => item);
const fix = expected.length
? `<p>Die Einstellung in ${sources.length > 1 ? 'den genannten GPOs' : sources.length ? 'der genannten GPO' : 'der zuständigen GPO'} auf ${expected.length > 1
? 'einen der erwarteten Werte der zutreffenden Baseline' : 'den erwarteten Baseline-Wert'} setzen.</p>` : '';
const flags = [
entry?.conflict ? '<p>Mehrere GPOs setzen diese Einstellung unterschiedlich (Conflicts). Vor der Änderung klären, welche GPO für die betroffenen Systeme maßgeblich ist.</p>' : '',
entry?.duplicate ? '<p>Die Einstellung ist in mehreren GPOs gleich gesetzt (Duplicates); eine Bereinigung vereinfacht die Pflege.</p>' : ''
].join('');
return `<div class="detail">
${description ? block('Beschreibung', richText(description)) : ''}
${block('Befund', `${relations.length === 1 && RELATION_LABEL[relations[0]] ? `<p class="prose">${RELATION_LABEL[relations[0]]}</p>` : ''}
<div class="compare">
<div class="compare__col"><p class="block__label">Erwartet laut Baseline</p>
${baselines.length ? `<p class="compare__caption">${baselines.map(escapeHtml).join(' · ')}</p>` : ''}
${expected.length ? expected.map(value => sourceValue(value, [], [])).join('') : '<p class="limit">Kein Baseline-Wert übermittelt.</p>'}</div>
<div class="compare__col"><p class="block__label">Konfiguriert</p>
${sources.length ? sourceSections(sources) : configured.length ? configured.map(value => sourceValue(value, [], [])).join('')
: '<p class="limit">Keine einzelnen GPO-Quellanweisungen in diesem Snapshot verfügbar.</p>'}</div>
</div>`)}
${recommendation || fix || flags ? block('Lösung', `${recommendation ? richText(recommendation) : ''}<div class="prose">${fix}${flags}</div>`, 'solution') : ''}
${references.length ? block('Referenzen', `<div class="refs">${references.map(reference => `<span class="tag">${escapeHtml(reference.value)}</span>`).join('')}</div>`) : ''}
<details class="more" data-print-closed><summary>Technische Details</summary><div class="more__body">
${keyValues([
['Technische Identität', finding.key, true], ['Scope', entry?.scope ?? results[0]?.policyScope],
['Conflicts', entry?.conflict ? 'Ja' : undefined], ['Duplicates', entry?.duplicate ? 'Ja' : undefined],
['GPO Backups', entry?.backupInstanceIds.join(', '), true], ['Diagnostics', diagnostics.join(', ')],
['Baseline-Vergleiche', results.length ? number(results.length) : undefined]
])}
${results.some(result => result.policyBaselineVariantAssessment) ? `<p class="block__label">Baseline-Varianten (Rohdaten)</p>
<pre>${plain(results.map(result => result.policyBaselineVariantAssessment).filter(Boolean))}</pre>` : ''}
</div></details>
</div>`;
}
/**
* Display grouping only: GPO setting titles lead with their policy section
* ("System Access · …", "Privilege Rights · …"); settings titled by a registry
* path are Administrative Template settings. Verdicts and counts are unaffected.
*/
function gpoSection(title: string): string {
if (title.includes('\\')) return 'Registry';
const separator = title.indexOf(' · ');
return separator > 0 ? title.slice(0, separator) : 'Weitere Policy Settings';
}
function gpoRow(finding: GpoFinding, member: string): string {
const flags = [finding.entry?.conflict ? 'conflict' : '', finding.entry?.duplicate ? 'duplicate' : ''].filter(Boolean).join(' ');
const scope = finding.entry?.scope ?? finding.results[0]?.policyScope;
return `<details class="row finding" data-filter-item data-filter-member="${member}" data-flags="${flags}">
<summary>
<span class="row__marker sev-bg--unknown" aria-hidden="true"></span>
<span class="row__main">
<h4 class="row__title">${escapeHtml(finding.title)}</h4>
<span class="row__sub">${[present(scope) ? `Scope ${escapeHtml(String(scope))}` : '',
finding.sources.length ? `${number(finding.sources.length)} GPO-${finding.sources.length === 1 ? 'Quelle' : 'Quellen'}` : ''].filter(Boolean).join(' · ') || 'Group Policy'}</span>
</span>
<span class="row__side">${finding.entry?.conflict ? '<span class="tag">Conflicts</span>' : ''}${finding.entry?.duplicate ? '<span class="tag">Duplicates</span>' : ''}</span>
<span class="row__chevron" aria-hidden="true"></span>
</summary>
${gpoDetail(finding)}
</details>`;
}
function gpoList(findings: GpoFinding[]): string {
if (!findings.length) return '<div class="card list"><p class="empty list__empty">Keine GPO-Deviations in diesem Snapshot.</p></div>';
const sections = [...new Set(findings.map(finding => gpoSection(finding.title)))]
.sort((a, b) => (a === 'Weitere Policy Settings' ? 1 : 0) - (b === 'Weitere Policy Settings' ? 1 : 0) || a.localeCompare(b));
return `<div class="card list">${sections.map((section, index) => {
const member = `gpo-${index}`;
const items = findings.filter(finding => gpoSection(finding.title) === section);
return `<details class="group" data-filter-group="${member}" open><summary>${escapeHtml(section)} <span class="count-tag">${number(items.length)}</span></summary>
${items.map(finding => gpoRow(finding, member)).join('')}</details>`;
}).join('')}</div>`;
}
const NEUTRAL_VERDICTS = new Set(['Not configured', 'No baseline rule', 'Not comparable']);
const VERDICT_LABEL: Record<ReportPolicyEntry['verdict'], string> = {
Fail: 'Deviation', Pass: 'Passed', 'Not configured': 'Not configured',
'No baseline rule': 'No baseline rule', 'Not comparable': 'Not comparable', 'Review required': 'Review required'
};
const REGISTER_ORDER: Array<ReportPolicyEntry['verdict']> = ['Review required', 'Pass', 'No baseline rule', 'Not comparable', 'Not configured'];
/** Every non-deviation policy setting, grouped by verdict; long score-neutral groups start collapsed. */
function policyRegister(entries: ReportPolicyEntry[], sources: Map<string, PolicySourceEvidence[]>): string {
const groups = REGISTER_ORDER.map(verdict => ({ verdict, items: entries.filter(entry => entry.verdict === verdict) }))
.filter(group => group.items.length);
if (!groups.length) return '<div class="card list"><p class="empty list__empty">Keine weiteren gerollten GPO Policy Settings im gewählten Assessment vorhanden.</p></div>';
return `<div class="card list">${groups.map(group => {
const member = `register-${filterToken(group.verdict)}`;
const collapsed = group.verdict === 'Not configured' || group.items.length > 50;
return `<details class="group" data-filter-group="${member}"${collapsed ? ' data-print-closed' : ' open'}>
<summary>${escapeHtml(VERDICT_LABEL[group.verdict])} <span class="count-tag">${number(group.items.length)}</span>
${NEUTRAL_VERDICTS.has(group.verdict) ? '<span class="group__note">score-neutral, keine Maßnahme</span>' : ''}</summary>
${group.items.map(item => {
const flags = [item.conflict ? 'conflict' : '', item.duplicate ? 'duplicate' : ''].filter(Boolean).join(' ');
const itemSources = sources.get(item.id) ?? [];
const head = `<span class="row__marker" aria-hidden="true"></span>
<span class="row__main"><h4 class="row__title">${plain(item.title)}</h4>
${present(item.scope) ? `<span class="row__sub">Scope ${plain(item.scope)}</span>` : ''}</span>
<span class="row__side">${item.conflict ? '<span class="tag">Conflicts</span>' : ''}${item.duplicate ? '<span class="tag">Duplicates</span>' : ''}</span>`;
const attributes = `data-filter-item data-filter-member="${member}" data-flags="${flags}"`;
return itemSources.length
? `<details class="row policy" ${attributes}><summary>${head}<span class="row__chevron" aria-hidden="true"></span></summary>
<div class="detail">${block('Konfiguriert', sourceSections(itemSources))}
<details class="more" data-print-closed><summary>Technische Details</summary><div class="more__body">${keyValues([
['Technische Identität', item.identity, true], ['GPO Backups', item.backupInstanceIds.join(', '), true]])}</div></details>
</div></details>`
: `<div class="row row--flat policy" ${attributes}>${head}<span></span></div>`;
}).join('')}
</details>`;
}).join('')}</div>`;
}
function sortedBars(entries: Array<[string, number]>, label: (key: string) => string) {
return entries.filter(([, count]) => count > 0).sort((a, b) => b[1] - a[1]).map(([key, count]) => ({ label: label(key), value: count }));
}
const TECHNICAL_CSS = `
.overview-grid{grid-template-columns:minmax(0,2fr) minmax(0,1fr) minmax(0,1fr)}
@media (max-width:1100px){.overview-grid{grid-template-columns:1fr}}
.overview-score{display:flex;flex-direction:column;align-items:center}
.env dl{margin:0}
.env div{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;padding:6px 0;border-bottom:1px solid var(--border-subtle);font-size:14px}
.env div:last-child{border-bottom:0}
.env dt{color:var(--text-secondary)}
.env dd{margin:0;font-weight:600;text-align:right;overflow-wrap:anywhere}
.appendix{padding:0}
.appendix>summary{padding:16px 20px}
.appendix .more__body{margin:0;padding:0 20px 20px}
`;
export function buildTechnicalReportHtml(projection: ReportProjection): string { export function buildTechnicalReportHtml(projection: ReportProjection): string {
const results = projection.technicalResults ?? []; const results = projection.technicalResults ?? [];
const labels = new Map(projection.domains.map(domain => [domain.domainId, domain.domainLabel])); const labels = new Map(projection.domains.map(domain => [domain.domainId, domain.domainLabel]));
const domainOf = (result: UnifiedResultProjection) => projection.technicalResultDomains?.[result.resultProjectionId] ?? 'unclassified'; const domainOf = (result: UnifiedResultProjection) => projection.technicalResultDomains?.[result.resultProjectionId] ?? 'unclassified';
const deviations = results.filter(result => result.isDeviation); const deviations = results.filter(result => result.isDeviation);
const severityOrder = ['critical', 'high', 'medium', 'low', 'info', 'unknown']; const severityOrder = SEVERITY_ORDER as string[];
const grouped = [...new Set(deviations.map(domainOf))].map(domainId => ({ const bySeverity = (a: UnifiedResultProjection, b: UnifiedResultProjection) =>
domainId, label: labels.get(domainId) ?? domainId, severityOrder.indexOf(a.severityNormalized) - severityOrder.indexOf(b.severityNormalized) || a.title.localeCompare(b.title);
items: deviations.filter(result => domainOf(result) === domainId) const groupByDomain = (items: UnifiedResultProjection[]): ResultGroup[] => [...new Set(items.map(domainOf))].map(domainId => ({
.sort((a, b) => severityOrder.indexOf(a.severityNormalized) - severityOrder.indexOf(b.severityNormalized) label: labels.get(domainId) ?? domainId,
|| a.title.localeCompare(b.title)) items: items.filter(result => domainOf(result) === domainId).sort(bySeverity)
})); })).sort((a, b) => b.items.length - a.items.length);
const scanDeviations = deviations.filter(result => !isPolicy(result));
const scanTools = [...new Set(scanDeviations.map(result => result.sourceTool ?? 'unknown'))];
const scanSeverities = Object.fromEntries(SEVERITY_ORDER.map(key => [key, 0])) as Record<SeverityKey, number>;
for (const item of scanDeviations) scanSeverities[severityOf(item)] += 1;
const policySources = new Map((projection.technicalPolicySources ?? []).map(item => [item.settingId, item.sources])); const policySources = new Map((projection.technicalPolicySources ?? []).map(item => [item.settingId, item.sources]));
const reviews = new Map((projection.technicalReviews ?? []).map(item => [item.resultProjectionId, item])); const reviews = new Map((projection.technicalReviews ?? []).map(item => [item.resultProjectionId, item]));
const fields = new Map((projection.technicalFindingFields ?? []).map(item => [item.resultProjectionId, item.fields])); const fields = new Map((projection.technicalFindingFields ?? []).map(item => [item.resultProjectionId, item.fields]));
const neutral = new Set(['Not configured', 'No baseline rule', 'Not comparable']); const detail = (item: UnifiedResultProjection, groupLabel: string) =>
const policyRows = projection.policyEntries.map(item => [ scannerDetail(item, groupLabel, reviews.get(item.resultProjectionId), fields.get(item.resultProjectionId));
plain(item.title), plain(item.scope), plain(item.identity), const gpoItems = gpoFindings(deviations.filter(isPolicy), projection.policyEntries, policySources);
`${plain(item.verdict)}${neutral.has(item.verdict) ? ' · score-neutral' : ''}`, const registerEntries = projection.policyEntries.filter(entry => entry.verdict !== 'Fail');
item.conflict ? 'Ja' : 'Nein', item.duplicate ? 'Ja' : 'Nein'
]);
const provenanceRows = projection.provenance.map(item => [plain(item.kind), plain(item.id), plain(item.version), plain(item.observedAt)]); const provenanceRows = projection.provenance.map(item => [plain(item.kind), plain(item.id), plain(item.version), plain(item.observedAt)]);
const diagnosticRows = projection.diagnostics.map(item => [plain(item.code), plain(item.reason), plain(item.origin), number(item.count)]); const diagnosticRows = projection.diagnostics.map(item => [plain(item.code), plain(item.reason), plain(item.origin), number(item.count)]);
return `<!doctype html> const dated = projection.scoreSources.filter(source => day(source.observedAt));
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> const sourceLine = dated.length
<title>ASSESSOR · Technical Report · ${escapeHtml(projection.project.name)}</title> ? dated.map(source => `${escapeHtml(toolLabel(source.toolId))}: ${day(source.observedAt)}`).join(' • ')
<style> : projection.scoreSources.map(source => escapeHtml(toolLabel(source.toolId))).join(' • ');
:root{font-family:Inter,system-ui,sans-serif;color:#20212a;background:#f1f1f4;font-size:16px} const gpo = projection.gpo;
*{box-sizing:border-box}body{max-width:1240px;margin:0 auto;padding:20px}h1{font-size:32px}h2{font-size:24px;margin:0 0 16px} const convergence = projection.convergence.filter(item => item.sourceCount > 0);
h3{font-size:18px}h4{font-size:16px;margin:0}h5{font-size:14px;margin:0 0 8px}p{line-height:1.5} const flagged = gpoItems.some(item => item.entry?.conflict || item.entry?.duplicate);
.head,.block,.finding,.source{background:#f5f5f7;padding:20px;margin:0 0 16px}.head{border-top:4px solid #e2a024}
.block{margin-top:24px}.finding,.source{background:#fff;border-left:2px solid #d6d6dc;break-inside:avoid} const body = `<header class="doc-head">
.finding header{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;border-bottom:1px solid #d6d6dc;padding-bottom:12px} <p class="doc-head__kicker">ASSESSOR · Technical Report</p>
.finding header span,.meta,.limit{color:#555967;font-size:14px}.facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 24px} <h1>${escapeHtml(projection.project.name)}</h1>
.facts div{min-width:0}.facts dt{font-size:14px;color:#555967}.facts dd{margin:2px 0 0;overflow-wrap:anywhere} ${sourceLine ? `<p class="doc-head__sources">${sourceLine}</p>` : ''}
.table-scroll{overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:14px}th,td{text-align:left;vertical-align:top;border-bottom:1px solid #d6d6dc;padding:8px;overflow-wrap:anywhere} <p class="doc-head__meta">Assessment ${plain(projection.assessmentId)} · Stand ${when(projection.generatedAt)}</p>
th{text-transform:uppercase;font-weight:600;font-size:14px}pre{font:14px/1.5 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere} <p class="note">Frozen Snapshot des gewählten Timeslots. GPO-Aussagen beruhen auf Backupvergleichen; der effektive Endpunktzustand ist nicht verifiziert.</p>
.instruction{margin:8px 0}.instruction div{display:grid;grid-template-columns:140px 1fr;gap:8px;padding:4px 0}.instruction dt{font-weight:600}.instruction dd{margin:0;overflow-wrap:anywhere} </header>
.different{background:#fce9e9;border-left:3px solid #bd3f45;padding:2px 6px}.values{margin:0;padding-left:20px}.source-value{border-top:1px solid #d6d6dc;padding-top:8px}
@media(max-width:700px){.facts{grid-template-columns:1fr}.instruction div{grid-template-columns:1fr}} <section class="section" aria-labelledby="overview">
@media print{:root{background:#fff}body{max-width:none;padding:0}.block,.head{background:#fff}.finding,.source{background:#fff}h2,h3,h4{break-after:avoid}} <div class="section__head"><h2 id="overview">Überblick</h2></div>
</style></head><body> <div class="grid overview-grid">
<header class="head"><p>ASSESSOR · Technical Report</p><h1>${escapeHtml(projection.project.name)}</h1> <article class="card card--dist">
<p>Assessment ${plain(projection.assessmentId)} · Stand ${when(projection.generatedAt)}</p> <header class="card__head"><h3 class="card__title">Scan-Deviations</h3><span class="card__meta">${escapeHtml(scanTools.map(toolLabel).join(', ') || '—')}</span></header>
<p class="limit">Frozen Snapshot des gewählten Timeslots. GPO-Aussagen beruhen auf Backupvergleichen; der effektive Endpunktzustand ist nicht verifiziert.</p></header> ${severityOverview(scanSeverities, 'Deviations')}
<section class="block"><h2>Scope und Provenienz</h2> </article>
${provenanceRows.length ? table(['Art', 'ID', 'Version', 'Beobachtet'], provenanceRows) : '<p class="limit">Keine Provenienz übermittelt.</p>'}</section> <article class="card overview-score">
<section class="block"><h2>Result-Inventar</h2><p>${number(projection.coverage.total)} Ergebnisse · ${number(projection.coverage.deviations)} Deviations · ${number(projection.coverage.passed)} Passed · ${number(projection.coverage.notRun)} Not run</p> <header class="card__head" style="align-self:stretch"><h3 class="card__title">Overall Score</h3></header>
${table(['Severity', 'Anzahl'], Object.entries(projection.inventory.bySeverity).map(([label, count]) => [plain(label), number(count)]))} ${scoreDonut(projection.overallScore, 'Overall Score')}
${table(['Outcome', 'Anzahl'], Object.entries(projection.inventory.byOutcome).map(([label, count]) => [plain(label), number(count)]))} <ul class="sources-list">${projection.scoreSources.map(source => `<li>
${table(['Security Domain', 'Anzahl'], Object.entries(projection.inventory.byDomain).map(([id, count]) => [plain(labels.get(id) ?? id), number(count)]))} <span>${escapeHtml(toolLabel(source.toolId))}</span>
${table(['Source Tool', 'Anzahl'], Object.entries(projection.inventory.bySource).map(([tool, count]) => [plain(tool), number(count)]))}</section> <span class="sources-list__value">${typeof source.score === 'number' ? number(Math.round(source.score)) : '—'}</span>
<section class="block"><h2>Findings im Detail</h2>${grouped.length ? grouped.map(group => `<section><h3>${escapeHtml(group.label)}</h3>${group.items.map(item => resultDetail(item, group.label, reviews.get(item.resultProjectionId), fields.get(item.resultProjectionId))).join('')}</section>`).join('') : '<p>Keine Deviations in diesem Snapshot.</p>'}</section> </li>`).join('')}</ul>
<section class="block"><h2>GPO Policy Register</h2><p class="limit">Conflicts und Duplicates sind separate Diagnostics, keine Ersatz-Verdicts. Not configured, No baseline rule und Not comparable sind score-neutral.</p> </article>
${projection.gpo ? table(['Verdict / Diagnostic', 'Anzahl'], [ <article class="card env">
['Deviation', projection.gpo.counts.Fail], ['Passed', projection.gpo.counts.Pass], <header class="card__head"><h3 class="card__title">Umgebung</h3></header>
['Not configured · score-neutral', projection.gpo.counts['Not configured']], ${projection.environment.some(fact => present(fact.value))
['No baseline rule · score-neutral', projection.gpo.counts['No baseline rule']], ? `<dl>${projection.environment.filter(fact => present(fact.value)).map(fact => `<div><dt>${escapeHtml(fact.key)}</dt><dd>${plain(fact.value)}</dd></div>`).join('')}</dl>`
['Not comparable · score-neutral', projection.gpo.counts['Not comparable']], : '<p class="empty">Keine Umgebungsdaten übermittelt.</p>'}
['Review required · unklar', projection.gpo.counts['Review required']], </article>
['Conflicts', projection.gpo.conflicts], ['Duplicates', projection.gpo.duplicates] </div>
].map(([label, count]) => [plain(label), number(Number(count))])) : ''} <div class="grid grid--2" style="margin-top:16px">
${policyRows.length ? table(['Setting', 'Scope', 'Technische Identität', 'Verdict', 'Conflicts', 'Duplicates'], policyRows) : '<p class="limit">Keine gerollten GPO Policy Settings im gewählten Assessment vorhanden.</p>'} <article class="card">
${projection.policyEntries.map(item => `<section class="source"><h3>${plain(item.title)}</h3>${sourceSections(policySources.get(item.id) ?? [])}</section>`).join('')}</section> <header class="card__head"><h3 class="card__title">Scan-Deviations je Security Domain</h3></header>
<section class="block"><h2>Konvergenz-Nachweis</h2> ${barList(groupByDomain(scanDeviations).map(group => ({ label: group.label, value: group.items.length })), 'Keine Scan-Deviations.')}
${table(['Security Domain', 'Quellen mit Deviations', 'Quellen', 'Evidence'], projection.convergence.map(item => [ </article>
plain(labels.get(item.domainId) ?? item.domainId), number(item.sourceCount), ${gpo ? `<article class="card">
plain(item.sources.filter(source => source.deviations > 0).map(source => source.toolId).join(', ')), <header class="card__head"><h3 class="card__title">GPO Policy Settings</h3><span class="card__meta">${number(projection.policyEntries.length)} Settings</span></header>
item.evidence === 'shared-framework-reference' ? 'Gemeinsame exakte Framework-Referenz' : 'Nur Domain-Nähe; Validierung erforderlich' ${barList([
]))} { label: 'Deviation', value: gpo.counts.Fail }, { label: 'Passed', value: gpo.counts.Pass },
${projection.bundles.length ? table(['Framework-Referenz', 'Source Tools', 'Result-IDs'], projection.bundles.map(item => [ { label: 'Review required', value: gpo.counts['Review required'] },
plain(item.key), plain(item.toolIds.join(', ')), plain(item.members.join(', ')) { label: 'Not configured · score-neutral', value: gpo.counts['Not configured'] },
])) : ''}</section> { label: 'No baseline rule · score-neutral', value: gpo.counts['No baseline rule'] },
<section class="block"><h2>Scope-Grenzen</h2> { label: 'Not comparable · score-neutral', value: gpo.counts['Not comparable'] }
<p>GPO-Vergleiche gelten nur für das importierte Backup-Set. Ohne RSoP bzw. Group Policy Results wird kein wirksamer Endpoint-Zustand behauptet.</p> ], '')}
<p><code>not_run</code> ist kein Pass. Nicht ausgeführte Checks und unbekannte Werte werden weder als bestanden noch als Deviation gezählt.</p></section> <p class="note">Diagnostics: ${number(gpo.conflicts)} Conflicts · ${number(gpo.duplicates)} Duplicates · ${number(gpo.affectedBackups)} betroffene GPO Backups.
<section class="block"><h2>Diagnostics</h2>${diagnosticRows.length ? table(['Code', 'Resolver-Grund', 'Herkunft', 'Anzahl'], diagnosticRows) : '<p>Keine aggregierten Diagnostics übermittelt.</p>'}</section> ${gpo.scopeLimited ? 'Die Auswertung bezieht sich ausschließlich auf den importierten GPO-Backup-Satz.' : ''}</p>
<section class="block"><h2>Anhang: vollständiger Result-Index</h2> </article>` : ''}
${table(['Source Tool', 'Source Rule ID', 'Titel', 'Outcome', 'Severity', 'Security Domain'], results.map(item => [plain(item.sourceTool), plain(item.sourceRuleId), plain(item.title), plain(item.outcomeNormalized), plain(item.severityNormalized), plain(labels.get(domainOf(item)) ?? domainOf(item))]))}</section> </div>
</body></html>`; </section>
<section class="section" aria-labelledby="scan" data-filter-scope>
<div class="section__head"><h2 id="scan">Scan Findings</h2><a class="to-top" href="#top">↑ Nach oben</a></div>
<p class="section__lead">Nach Security Domain gruppiert. Ein Klick auf ein Finding zeigt Beschreibung, Befund und Lösung.</p>
${scanDeviations.length ? filterToolbar({
placeholder: 'Findings durchsuchen',
groups: [
{ key: 'severity', label: 'Severity', values: SEVERITY_ORDER.filter(key => key !== 'unknown' && scanSeverities[key] > 0).map(key => ({ value: key, label: SEVERITY_LABEL[key] })) },
{ key: 'tool', label: 'Quelle', values: scanTools.map(tool => ({ value: filterToken(tool), label: toolLabel(tool) })) }
]
}) : ''}
${scanList(groupByDomain(scanDeviations), detail)}
</section>
<section class="section" aria-labelledby="gpo" data-filter-scope>
<div class="section__head"><h2 id="gpo">GPO Findings</h2><a class="to-top" href="#top">↑ Nach oben</a></div>
<p class="section__lead">Eine Zeile je Policy Setting mit Deviation, zusammengefasst über alle Baseline-Vergleiche. Die Markierung zeigt den abweichenden Wert der GPO.
Conflicts und Duplicates sind separate Diagnostics, keine Ersatz-Verdicts.</p>
${gpoItems.length ? filterToolbar({
placeholder: 'GPO Findings durchsuchen',
groups: flagged ? [{ key: 'flags', label: 'Diagnostics', values: [{ value: 'conflict', label: 'Conflicts' }, { value: 'duplicate', label: 'Duplicates' }] }] : []
}) : ''}
${gpoList(gpoItems)}
</section>
<section class="section" aria-labelledby="register" data-filter-scope>
<div class="section__head"><h2 id="register">GPO Policy Register</h2><a class="to-top" href="#top">↑ Nach oben</a></div>
<p class="section__lead">Alle weiteren Policy Settings nach Verdict. Not configured, No baseline rule und Not comparable sind score-neutral und begründen keine Maßnahme;
die Settings mit Deviation stehen unter GPO Findings.</p>
${registerEntries.length ? filterToolbar({
placeholder: 'Policy Settings durchsuchen',
groups: registerEntries.some(entry => entry.conflict || entry.duplicate)
? [{ key: 'flags', label: 'Diagnostics', values: [{ value: 'conflict', label: 'Conflicts' }, { value: 'duplicate', label: 'Duplicates' }] }] : []
}) : ''}
${policyRegister(registerEntries, policySources)}
</section>
<section class="section" aria-labelledby="inventory">
<div class="section__head"><h2 id="inventory">Result-Inventar</h2></div>
<p class="section__lead">${number(projection.coverage.total)} Ergebnisse · ${number(projection.coverage.deviations)} Deviations · ${number(projection.coverage.passed)} Passed · ${number(projection.coverage.notRun)} Not run.
Gezählt werden einzelne Ergebnisse, bei GPO also jeder Baseline-Vergleich.</p>
<div class="grid grid--2">
<article class="card"><header class="card__head"><h3 class="card__title">Outcome</h3></header>
${barList(sortedBars(Object.entries(projection.inventory.byOutcome), outcomeLabel), '—')}</article>
<article class="card"><header class="card__head"><h3 class="card__title">Severity</h3></header>
${barList(SEVERITY_ORDER.filter(key => (projection.inventory.bySeverity[key] ?? 0) > 0)
.map(key => ({ label: SEVERITY_LABEL[key], value: projection.inventory.bySeverity[key], tone: key })), '—')}</article>
<article class="card"><header class="card__head"><h3 class="card__title">Security Domain</h3></header>
${barList(sortedBars(Object.entries(projection.inventory.byDomain), id => labels.get(id) ?? id), '—')}</article>
<article class="card"><header class="card__head"><h3 class="card__title">Quelle</h3></header>
${barList(sortedBars(Object.entries(projection.inventory.bySource), toolLabel), '—')}</article>
</div>
</section>
<section class="section" aria-labelledby="convergence">
<div class="section__head"><h2 id="convergence">Konvergenz-Nachweis</h2></div>
<article class="card">
${convergence.length ? table(['Security Domain', 'Quellen mit Deviations', 'Evidence'], convergence.map(item => [
plain(labels.get(item.domainId) ?? item.domainId),
plain(item.sources.filter(source => source.deviations > 0).map(source => toolLabel(source.toolId)).join(', ')),
item.evidence === 'shared-framework-reference' ? 'Gemeinsame exakte Framework-Referenz' : 'Nur Domain-Nähe; Validierung erforderlich'
])) : '<p class="empty">Keine quellenübergreifende Konvergenz belegt.</p>'}
${projection.bundles.length ? `<div style="margin-top:16px">${table(['Framework-Referenz', 'Quellen', 'Findings'], projection.bundles.map(item => [
plain(item.key), plain(item.toolIds.map(toolLabel).join(', ')), number(item.members.length)
]))}</div>` : ''}
</article>
</section>
<section class="section" aria-labelledby="limits">
<div class="section__head"><h2 id="limits">Scope-Grenzen</h2></div>
<article class="card">
<p>GPO-Vergleiche gelten nur für das importierte Backup-Set. Ohne RSoP bzw. Group Policy Results wird kein wirksamer Endpoint-Zustand behauptet.</p>
<p class="note"><code>not_run</code> ist kein Pass. Nicht ausgeführte Checks und unbekannte Werte werden weder als bestanden noch als Deviation gezählt.</p>
</article>
</section>
<section class="section" aria-labelledby="appendix">
<div class="section__head"><h2 id="appendix">Anhang</h2><a class="to-top" href="#top">↑ Nach oben</a></div>
<div class="stack">
<details class="card more appendix"><summary>Diagnostics <span class="count-tag">${number(diagnosticRows.length)}</span></summary><div class="more__body">
${diagnosticRows.length ? table(['Code', 'Resolver-Grund', 'Herkunft', 'Anzahl'], diagnosticRows) : '<p class="empty">Keine aggregierten Diagnostics übermittelt.</p>'}</div></details>
<details class="card more appendix"><summary>Scope und Provenienz <span class="count-tag">${number(provenanceRows.length)}</span></summary><div class="more__body">
${provenanceRows.length ? table(['Art', 'ID', 'Version', 'Beobachtet'], provenanceRows) : '<p class="empty">Keine Provenienz übermittelt.</p>'}</div></details>
<details class="card more appendix" data-filter-scope data-print-closed><summary>Vollständiger Result-Index <span class="count-tag">${number(results.length)}</span></summary><div class="more__body">
${results.length ? filterToolbar({ placeholder: 'Result-Index durchsuchen' }) : ''}
${table(['Titel', 'Quelle', 'Source Rule ID', 'Outcome', 'Severity', 'Security Domain'],
results.map(item => [plain(item.title), escapeHtml(toolLabel(item.sourceTool)), plain(item.sourceRuleId), escapeHtml(outcomeLabel(item.outcomeNormalized)),
item.severityNormalized === 'unknown' ? '—' : escapeHtml(SEVERITY_LABEL[severityOf(item)]), plain(labels.get(domainOf(item)) ?? domainOf(item))]),
results.map(() => ' data-filter-item'))}
</div></details>
</div>
</section>`;
return reportDocument(`ASSESSOR · Technical Report · ${projection.project.name}`, body, TECHNICAL_CSS);
} }

View File

@ -14,7 +14,9 @@ export function recordTitleFor(record: FindingRecord, selectedColumn = '', fallb
const profileTitle = titlePatterns const profileTitle = titlePatterns
.map(pattern => record.fields.find(field => field.value && matches(pattern, field.label))?.value) .map(pattern => record.fields.find(field => field.value && matches(pattern, field.label))?.value)
.find(Boolean); .find(Boolean);
// An empty selected column (e.g. a scanner `Name` on a GPO row of the mixed
// project sheet) must fall through to the record's own title.
return profileTitle return profileTitle
?? (selectedColumn && record.fields.find(field => field.label === selectedColumn)?.value) || (selectedColumn && record.fields.find(field => field.label === selectedColumn)?.value)
?? record.title; || record.title;
} }

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.6.0'); expect(ASSESSOR_PRODUCT_VERSION).toBe('2.6.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

@ -44,14 +44,22 @@ const finding = (overrides = {}) => ({
}); });
describe('management overview renderer', () => { describe('management overview renderer', () => {
it('renders a self-contained document without script or remote resources', () => { it('renders a self-contained document with only inline script and no remote resources', () => {
const html = buildManagementOverviewHtml(projection()); const html = buildManagementOverviewHtml(projection());
expect(html.startsWith('<!doctype html>')).toBe(true); expect(html.startsWith('<!doctype html>')).toBe(true);
expect(html).not.toMatch(/<script\b/i); expect(html).not.toMatch(/<script\b[^>]*\bsrc\s*=/i);
expect(html).not.toMatch(/\son[a-z]+\s*=/i); expect(html).not.toMatch(/\son[a-z]+\s*=/i);
expect(html).not.toMatch(/https?:\/\//i); expect(html).not.toMatch(/https?:\/\//i);
expect(html).not.toMatch(/<link\b/i); expect(html).not.toMatch(/<link\b/i);
expect(html).not.toMatch(/url\(/i); // The only url() is the embedded font; nothing is fetched when opened.
expect(html.match(/url\(/gi)).toHaveLength(1);
expect(html).toContain("url('data:font/woff2;base64,");
});
it('renders for customers in light mode only', () => {
const html = buildManagementOverviewHtml(projection());
expect(html).toContain('color-scheme:light');
expect(html).not.toMatch(/prefers-color-scheme\s*:\s*dark/i);
}); });
it('declares German as the document language', () => { it('declares German as the document language', () => {
@ -142,6 +150,30 @@ describe('management overview renderer', () => {
expect(html).toContain('trifft keine Aussage über die gesamte Domäne'); expect(html).toContain('trifft keine Aussage über die gesamte Domäne');
}); });
it('shows GPO deviations once in the GPO block instead of per comparison in Priority Findings', () => {
const html = buildManagementOverviewHtml(projection({
priorities: [{
domainId: 'gpo', domainLabel: 'GPO & Baseline', deviations: 4,
sources: [
{ toolId: 'pingcastle', basis: { kind: 'severity' }, ranked: true, shown: [finding({ toolId: 'pingcastle', title: 'Synthetic scanner item' })], total: 1 },
{ toolId: 'GPOs', basis: { kind: 'equal-weight', reason: 'A baseline deviation carries no native criticality.' }, ranked: false,
shown: [1, 2, 3].map(index => finding({ toolId: 'GPOs', severity: 'unknown', title: `Synthetic comparison ${index}` })), total: 3 }
]
}],
findings: [finding({ toolId: 'pingcastle' }), ...[1, 2, 3].map(() => finding({ toolId: 'GPOs', severity: 'unknown' }))],
gpo: {
basis: { kind: 'equal-weight', reason: 'A baseline deviation carries no native criticality.' },
counts: { Fail: 1, Pass: 0, 'Not configured': 0, 'No baseline rule': 0, 'Not comparable': 0, 'Review required': 0 },
conflicts: 0, duplicates: 0, affectedSettings: 1, affectedBackups: 1, scopeLimited: false
}
}));
expect(html).toContain('Synthetic scanner item');
expect(html).not.toContain('Synthetic comparison');
expect(html).toContain('1 Deviations</span>');
// One scanner finding plus one GPO policy setting, not three comparisons.
expect(html).toContain('1 aus Scans · 1 GPO Policy Settings');
});
it('omits the remediation section entirely when no reviews exist', () => { it('omits the remediation section entirely when no reviews exist', () => {
expect(buildManagementOverviewHtml(projection())).not.toContain('Remediation-Status'); expect(buildManagementOverviewHtml(projection())).not.toContain('Remediation-Status');
}); });
@ -198,8 +230,8 @@ describe('management overview structure', () => {
expect([...document.querySelectorAll('h1')].map(node => node.textContent)) expect([...document.querySelectorAll('h1')].map(node => node.textContent))
.toEqual(['Synthetic GmbH']); .toEqual(['Synthetic GmbH']);
expect([...document.querySelectorAll('h2')].map(node => node.textContent)).toEqual([ expect([...document.querySelectorAll('h2')].map(node => node.textContent)).toEqual([
'Overall Posture', 'Konvergenz der Quellen', 'Abdeckung und Lücken', 'Overall Posture', 'Priority Findings', 'Remediation-Status', 'Abdeckung und Lücken',
'Priority Findings', 'Maßnahmenbündel', 'Remediation-Status', 'Source Overview' 'Konvergenz der Quellen', 'Maßnahmenbündel', 'Source Overview'
]); ]);
// Every section is labelled for assistive technology. // Every section is labelled for assistive technology.
for (const section of document.querySelectorAll('section.segment')) { for (const section of document.querySelectorAll('section.segment')) {
@ -207,7 +239,7 @@ describe('management overview structure', () => {
} }
}); });
it('places no table inside a tile and never nests tables', async () => { it('never nests tables', async () => {
const { JSDOM } = await import('jsdom'); const { JSDOM } = await import('jsdom');
const { document } = new JSDOM(buildManagementOverviewHtml(projection({ const { document } = new JSDOM(buildManagementOverviewHtml(projection({
coverage: { coverage: {
@ -215,7 +247,6 @@ describe('management overview structure', () => {
executionErrors: 0, unknown: 0, perSource: [{ toolId: 'alpha', total: 1, assessed: 1, notRun: 0 }] executionErrors: 0, unknown: 0, perSource: [{ toolId: 'alpha', total: 1, assessed: 1, notRun: 0 }]
} }
}))).window; }))).window;
expect(document.querySelectorAll('.tile table')).toHaveLength(0);
expect(document.querySelectorAll('table table')).toHaveLength(0); expect(document.querySelectorAll('table table')).toHaveLength(0);
}); });
@ -229,6 +260,49 @@ describe('management overview structure', () => {
expect(drawn / circumference).toBeCloseTo(0.25, 4); expect(drawn / circumference).toBeCloseTo(0.25, 4);
}); });
it('filters Priority Findings by severity chip and search through the inline script', async () => {
const { JSDOM } = await import('jsdom');
const html = buildManagementOverviewHtml(projection({
priorities: [{
domainId: 'identity', domainLabel: 'Identity & Account Hygiene', deviations: 2,
sources: [{
toolId: 'purple-knight', basis: { kind: 'severity' }, ranked: true, total: 2,
shown: [finding({ title: 'Synthetic critical' }), finding({ resultProjectionId: 'synthetic-2', title: 'Synthetic high', severity: 'high' })]
}]
}],
findings: [finding(), finding({ resultProjectionId: 'synthetic-2', severity: 'high' })]
}));
const { document } = new JSDOM(html, { runScripts: 'dangerously' }).window;
const rows = [...document.querySelectorAll<HTMLElement>('[data-filter-item]')];
const visible = () => rows.filter(row => !row.hidden).map(row => row.querySelector('.finding__title')!.textContent);
expect(document.querySelector<HTMLElement>('[data-filter-toolbar]')!.hidden).toBe(false);
expect(visible()).toEqual(['Synthetic critical', 'Synthetic high']);
document.querySelector<HTMLButtonElement>('[data-filter-value="critical"]')!.click();
expect(visible()).toEqual(['Synthetic critical']);
expect(document.querySelector('[data-filter-reset]')!.getAttribute('aria-pressed')).toBe('false');
document.querySelector<HTMLButtonElement>('[data-filter-reset]')!.click();
const search = document.querySelector<HTMLInputElement>('[data-filter-search]')!;
search.value = 'high';
search.dispatchEvent(new document.defaultView!.Event('input'));
expect(visible()).toEqual(['Synthetic high']);
expect(document.querySelector('[data-filter-count]')!.textContent).toBe('1 / 2');
});
it('keeps every finding readable when the script does not run', async () => {
const { JSDOM } = await import('jsdom');
const { document } = new JSDOM(buildManagementOverviewHtml(projection({
priorities: [{
domainId: 'identity', domainLabel: 'Identity & Account Hygiene', deviations: 1,
sources: [{ toolId: 'purple-knight', basis: { kind: 'severity' }, ranked: true, shown: [finding()], total: 1 }]
}],
findings: [finding(), finding({ resultProjectionId: 'synthetic-2', severity: 'high' })]
}))).window;
expect(document.querySelector<HTMLElement>('[data-filter-toolbar]')!.hidden).toBe(true);
expect([...document.querySelectorAll<HTMLElement>('[data-filter-item]')].every(row => !row.hidden)).toBe(true);
});
it('clamps an out-of-range score instead of drawing past the ring', async () => { it('clamps an out-of-range score instead of drawing past the ring', async () => {
const { JSDOM } = await import('jsdom'); const { JSDOM } = await import('jsdom');
const { document } = new JSDOM(buildManagementOverviewHtml(projection({ overallScore: 140 }))).window; const { document } = new JSDOM(buildManagementOverviewHtml(projection({ overallScore: 140 }))).window;

View File

@ -39,6 +39,182 @@ describe('Technical Report', () => {
expect(html).toContain(verdict); expect(html).toContain(verdict);
} }
expect(html).toContain('effektive Endpunktzustand ist nicht verifiziert'); expect(html).toContain('effektive Endpunktzustand ist nicht verifiziert');
expect(html).not.toMatch(/<script\b|<link\b|https?:\/\//i); expect(html).not.toMatch(/<script\b[^>]*\bsrc\s*=|<link\b|https?:\/\//i);
expect(html).not.toMatch(/prefers-color-scheme\s*:\s*dark/i);
});
const scanner = (overrides: Record<string, unknown> = {}) => ({
resultProjectionId: 'dummy-scan', sourceKind: 'scanner-finding', sourceResultId: 'dummy-scan-source',
projectId: 'dummy-project', collectionRunId: 'dummy-run', sourceTool: 'purple-knight',
title: 'Dummy indicator', sourceRuleId: 'DUMMY-1', isDeviation: true, severityNormalized: 'high',
outcomeNormalized: 'fail', description: 'Dummy description.', frameworkReferences: [], ...overrides
}) as unknown as UnifiedResultProjection;
const render = async (input: Partial<Parameters<typeof buildReportProjection>[0]>) => {
const { JSDOM } = await import('jsdom');
const html = buildTechnicalReportHtml(buildReportProjection({
generatedAt: '2026-09-24T10:00:00Z', project: { id: 'dummy-project', name: 'Dummy Customer' },
results: [], domains: [{ id: 'identity', label: 'Identity & Account Hygiene' }, { id: 'gpo', label: 'GPO & Baseline' }],
domainOf: result => result.sourceKind === 'policy-evaluation' ? 'gpo' : 'identity', ...input
}));
return { html, document: new JSDOM(html).window.document };
};
it('shows a finding as description, result and solution and leaves empty source fields out', async () => {
const { document } = await render({
results: [scanner({ recommendation: '<p>Restrict the <b>dummy</b> right.</p><img src=x onerror=alert(1)>' })],
technicalFindingFields: [{ resultProjectionId: 'dummy-scan', fields: [
{ label: 'Result message', value: 'Found 2 dummy objects.', index: 0 },
{ label: 'MITRE D3FEND_1', value: '', index: 1 },
{ label: 'MITRE ATT&CK_3', value: '—', index: 2 },
{ label: 'Description', value: 'Dummy description.', index: 3 },
{ label: 'Weight', value: '10', index: 4 }
] }]
});
const row = document.querySelector('section[aria-labelledby="scan"] details.row')!;
const labels = [...row.querySelectorAll('.block__label')].map(node => node.textContent);
expect(labels.slice(0, 3)).toEqual(['Beschreibung', 'Befund', 'Lösung']);
expect(row.querySelector('.solution strong')!.textContent).toBe('dummy');
expect(row.querySelector('img')).toBeNull();
expect(row.querySelector('.solution')!.textContent).toContain('<img src=x onerror=alert(1)>');
const original = [...row.querySelectorAll('.more .kv dt')].map(node => node.textContent);
expect(original).toContain('Weight');
expect(original).not.toContain('MITRE D3FEND_1');
expect(original).not.toContain('MITRE ATT&CK_3');
// Already shown as description, so not repeated in the technical details.
expect(original).not.toContain('Description');
// Severity is supporting text, not a filled status badge.
expect(row.querySelector('summary .sev')!.textContent).toBe('High');
expect(document.querySelectorAll('.badge, .pill')).toHaveLength(0);
});
it('shows each Security Domain as a collapsible group that reopens for a matching search', async () => {
const { JSDOM } = await import('jsdom');
const { html } = await render({
results: [scanner(), scanner({ resultProjectionId: 'dummy-scan-2', title: 'Dummy second indicator' })]
});
const { document, Event } = new JSDOM(html, { runScripts: 'dangerously' }).window;
const group = document.querySelector<HTMLDetailsElement>('section[aria-labelledby="scan"] details.group')!;
expect(group.open).toBe(true);
expect(group.querySelector('summary')!.textContent).toContain('Identity & Account Hygiene');
expect(group.querySelectorAll('details.row')).toHaveLength(2);
group.open = false;
const search = document.querySelector<HTMLInputElement>('section[aria-labelledby="scan"] [data-filter-search]')!;
search.value = 'second';
search.dispatchEvent(new Event('input'));
expect(group.open).toBe(true);
expect([...group.querySelectorAll<HTMLElement>('details.row')].filter(row => !row.hidden)).toHaveLength(1);
});
it('groups GPO Findings into collapsible policy sections', async () => {
const { JSDOM } = await import('jsdom');
const failing = (id: string, title: string) => ({ id, verdict: 'Fail' as const, conflict: false, duplicate: false,
title, scope: 'computer', identity: id, backupInstanceIds: [] });
const { html } = await render({ policyEntries: [
failing('dummy-a', 'System Access · DummyLength'),
failing('dummy-b', 'Privilege Rights · SeDummyPrivilege'),
failing('dummy-c', 'System Access · DummyLockout'),
failing('dummy-d', 'Software\\Policies\\Dummy\\Key')
] });
const { document, Event } = new JSDOM(html, { runScripts: 'dangerously' }).window;
const groups = [...document.querySelectorAll<HTMLDetailsElement>('section[aria-labelledby="gpo"] details.group')];
expect(groups.map(group => group.querySelector('summary')!.textContent!.replace(/\s+/g, ' ').trim()))
.toEqual(['Privilege Rights 1', 'Registry 1', 'System Access 2']);
expect(groups.every(group => group.open)).toBe(true);
groups[2].open = false;
const search = document.querySelector<HTMLInputElement>('section[aria-labelledby="gpo"] [data-filter-search]')!;
search.value = 'lockout';
search.dispatchEvent(new Event('input'));
expect(groups[2].open).toBe(true);
expect(groups.filter(group => !group.hidden)).toHaveLength(1);
});
it('renders pipe-delimited source rows as a table and keeps irregular rows as text', async () => {
const details = [
'The detail can be found in Dummy Groups',
'DN',
'CN=dummy.one,OU=Dummy,DC=dummy,DC=example',
'CN=dummy.two,CN=Users,DC=dummy,DC=example',
'This section explains the dummy groups.',
'Group Name | Nb Admins | Nb Enabled',
'Dummy Operators | 0 | 0',
'Dummy Admins | 3 | <b>2</b>',
'',
'Dummy Admins Close',
'',
'User',
'dummy.admin',
'dummy-service',
'Administrator',
'Accounts are listed above.',
'',
'Name | State',
'dummy.user | YES | extra'
].join('\n');
const { document } = await render({
results: [scanner()],
technicalFindingFields: [{ resultProjectionId: 'dummy-scan', fields: [{ label: 'Details', value: details, index: 0 }] }]
});
const found = [...document.querySelectorAll('section[aria-labelledby="scan"] .block')]
.find(block => block.querySelector('.block__label')!.textContent === 'Befund')!;
const tables = found.querySelectorAll('table');
expect(tables).toHaveLength(3);
// A flattened one-column list of account names under its label.
expect([...tables[2].querySelectorAll('th')].map(cell => cell.textContent)).toEqual(['User']);
expect([...tables[2].querySelectorAll('td')].map(cell => cell.textContent)).toEqual(['dummy.admin', 'dummy-service', 'Administrator']);
expect([...found.querySelectorAll('p')].some(node => node.textContent!.includes('Accounts are listed above.'))).toBe(true);
// A flattened one-column table of distinguished names keeps its header.
expect([...tables[0].querySelectorAll('th')].map(cell => cell.textContent)).toEqual(['DN']);
expect([...tables[0].querySelectorAll('td')].map(cell => cell.textContent))
.toEqual(['CN=dummy.one,OU=Dummy,DC=dummy,DC=example', 'CN=dummy.two,CN=Users,DC=dummy,DC=example']);
expect(found.textContent).toContain('This section explains the dummy groups.');
expect([...tables[1].querySelectorAll('th')].map(cell => cell.textContent)).toEqual(['Group Name', 'Nb Admins', 'Nb Enabled']);
expect([...tables[1].querySelectorAll('tbody tr')].map(row => [...row.querySelectorAll('td')].map(cell => cell.textContent)))
.toEqual([['Dummy Operators', '0', '0'], ['Dummy Admins', '3', '2']]);
expect(found.textContent).toContain('The detail can be found in Dummy Groups');
expect(found.textContent).toContain('Dummy Admins Close');
// Differing column counts are not forced into a table.
expect(found.textContent).toContain('Name | State');
expect(found.textContent).toContain('dummy.user | YES | extra');
});
it('rolls GPO baseline comparisons up to one finding per policy setting with expected and configured values', async () => {
const comparison = (variant: number) => ({
resultProjectionId: `dummy-gpo-${variant}`, sourceKind: 'policy-evaluation', sourceResultId: `dummy-gpo-source-${variant}`,
projectId: 'dummy-project', collectionRunId: 'dummy-run', sourceTool: 'GPOs', title: 'Dummy Rights · SeDummyPrivilege',
sourceRuleId: 'dummy-identity', policyIdentityKey: 'dummy-identity', isDeviation: true, severityNormalized: 'unknown',
outcomeNormalized: 'fail', policyBaselineLabel: `Dummy Baseline ${variant}`, policyComparisonRelation: 'different', frameworkReferences: [],
policyBaselineVariantAssessment: { version: 1, scope: 'backup-configuration', decision: 'all-fail', diagnosticCodes: [],
actualValues: [{ kind: 'principal-set', principals: ['*S-1-5-19', '*S-1-5-99'] }],
variants: [{ settingId: `dummy-baseline-${variant}`, baselineValues: [{ kind: 'principal-set', principals: ['*S-1-5-19'] }],
comparator: { version: 1, comparator: { kind: 'equality' }, relation: 'different', diagnosticCodes: [] } }] }
}) as unknown as UnifiedResultProjection;
const { document } = await render({
results: [comparison(1), comparison(2), comparison(3)],
policyEntries: [
{ id: 'dummy-setting', verdict: 'Fail', conflict: false, duplicate: false, title: 'Dummy Rights · SeDummyPrivilege',
scope: 'computer', identity: 'dummy-identity', backupInstanceIds: ['dummy-backup'] },
...Array.from({ length: 3 }, (_, index) => ({ id: `dummy-nc-${index}`, verdict: 'Not configured' as const, conflict: false,
duplicate: false, title: `Dummy absent ${index}`, scope: 'user', identity: `dummy-nc-${index}`, backupInstanceIds: [] }))
],
technicalPolicySources: [{ settingId: 'dummy-setting', sources: [{ id: 'dummy-source', backupInstanceId: 'dummy-backup',
gpoId: '{DUMMY-GUID}', sourceReference: 'dummy/GptTmpl.inf', values: [{ kind: 'principal-set', principals: ['*S-1-5-19', '*S-1-5-99'] }],
deviationItems: [['*S-1-5-99']] }] }]
});
const rows = document.querySelectorAll('section[aria-labelledby="gpo"] details.row');
expect(rows).toHaveLength(1);
const [expected, configured] = [...rows[0].querySelectorAll('.compare__col')];
expect([...expected.querySelectorAll('li')].map(node => node.textContent)).toEqual(['*S-1-5-19']);
expect(expected.textContent).toContain('Dummy Baseline 1 · Dummy Baseline 2 · Dummy Baseline 3');
// Only the configured principal absent from every baseline variant is marked.
expect([...configured.querySelectorAll('li.different')].map(node => node.textContent)).toEqual(['*S-1-5-99']);
expect(rows[0].querySelector('.compare')!.textContent).not.toContain('"kind"');
expect(rows[0].querySelector('.solution')!.textContent).toContain('auf den erwarteten Baseline-Wert setzen');
// The register keeps every other verdict; long score-neutral groups start collapsed and stay out of print.
const group = document.querySelector<HTMLDetailsElement>('section[aria-labelledby="register"] details.group')!;
expect(group.querySelector('summary')!.textContent).toContain('Not configured');
expect(group.open).toBe(false);
expect(group.hasAttribute('data-print-closed')).toBe(true);
expect(group.querySelectorAll('[data-filter-item]')).toHaveLength(3);
}); });
}); });

View File

@ -80,7 +80,7 @@ describe('Client Info report buttons', () => {
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');
expect(technical.text).toContain('dummy-artifact-secret'); expect(technical.text).toContain('dummy-artifact-secret');
expect(technical.text).toContain('vollständiger Result-Index'); expect(technical.text).toContain('Vollständiger Result-Index');
await act(async () => { root.unmount(); }); await act(async () => { root.unmount(); });
host.remove(); host.remove();
}); });

View File

@ -21,6 +21,16 @@ describe('mixed Work record titles', () => {
expect(recordTitleFor(defender, 'Name', 'generic')).toBe('CVE-2026-4242'); expect(recordTitleFor(defender, 'Name', 'generic')).toBe('CVE-2026-4242');
}); });
it('keeps the GPO title when the aggregate title column is empty on the GPO row', () => {
const gpo = record([
['Source: Scan', 'GPOs · 2026-09-10T08:00:00.000Z'],
['Name', ''],
['Title', 'Synthetic policy setting']
], 'Synthetic policy setting');
expect(recordTitleFor(gpo, 'Name', 'generic')).toBe('Synthetic policy setting');
});
it('keeps the selected title column for records without a tool-specific title field', () => { it('keeps the selected title column for records without a tool-specific title field', () => {
const generic = record([['Name', 'Synthetic finding'], ['Value', '42']]); const generic = record([['Name', 'Synthetic finding'], ['Value', '42']]);
expect(recordTitleFor(generic, 'Name', 'generic')).toBe('Synthetic finding'); expect(recordTitleFor(generic, 'Name', 'generic')).toBe('Synthetic finding');

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.6.0', apiVersion: 1, repositoryRevision: 0, persistence: 'memory' status: 'ok', application: 'ASSESSOR', productVersion: '2.6.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();
}); });