Neuer Stand Assessor #1
|
|
@ -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 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-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.
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@ Remediation progress is proven by rescans, never by self-reported status. See
|
|||
| Decision | Choice |
|
||||
| --- | --- |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Arbeitsgrundlage format | XLSX to work in, HTML as the frozen status view |
|
||||
| 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
|
||||
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
|
||||
|
||||
Both reports are written in German: headings, prose, labels, table headers,
|
||||
|
|
@ -99,7 +147,7 @@ consumes.
|
|||
## Report A — Management Overview
|
||||
|
||||
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`.
|
||||
|
||||
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
|
||||
of the four page archetypes, because it is read and printed outside the
|
||||
application. Typography roles, donut geometry, tile and segment grammar, status
|
||||
colour rules and the panel inset apply unchanged. Light is the document default
|
||||
because a report is printed on white; dark renders from the same semantic roles
|
||||
through `prefers-color-scheme`.
|
||||
colour rules and the panel inset apply unchanged. Since 2026-09-24 the report is
|
||||
light only (customer-facing) and uses the card layout described under *Export
|
||||
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.
|
||||
|
||||
### 1. Assessment identity
|
||||
|
|
@ -431,15 +479,17 @@ manufacture criticalities the benchmark never stated.
|
|||
the explicit conflict and duplicate flags. Never from comparison prose,
|
||||
historical results, baseline text, guidance or evidence.
|
||||
- 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,
|
||||
scripts or images, no network access when opened. A report leaves the building
|
||||
- Both files are self-contained: inline styles and script, no CDN, no external
|
||||
fonts, scripts or images, no network access when opened. A report leaves the building
|
||||
and must not load anything.
|
||||
- The bundled Inter variable font is embedded or a local system stack is used;
|
||||
no font is downloaded at view time.
|
||||
- Shared typography roles, donut geometry, marking semantics and panel inset
|
||||
apply as in the application. Status colour stays in accent lines, markers,
|
||||
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
|
||||
|
||||
|
|
@ -550,3 +600,15 @@ deliverable and D is not.
|
|||
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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "assessor",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "assessor",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.1",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"jsdom": "^29.1.1",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "assessor",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,19 @@ import type {
|
|||
ReportScoreSource
|
||||
} from '../domain/report-projection';
|
||||
import { escapeHtml } from '../shared/html';
|
||||
import {
|
||||
DASH,
|
||||
SEVERITY_LABEL,
|
||||
SEVERITY_ORDER,
|
||||
barList,
|
||||
filterToolbar,
|
||||
integer,
|
||||
reportDocument,
|
||||
scoreDonut,
|
||||
severityText,
|
||||
toolLabel,
|
||||
type SeverityKey
|
||||
} from './report-theme';
|
||||
|
||||
/**
|
||||
* Artifact A of docs/REPORT_STRATEGY.md: the Management Overview.
|
||||
|
|
@ -18,12 +31,10 @@ import { escapeHtml } from '../shared/html';
|
|||
* or parser versions, so the file can be forwarded as it is.
|
||||
*
|
||||
* German carries the prose; established English technical terminology stays
|
||||
* English. The file is self-contained: every style is inline, nothing is
|
||||
* fetched when it is opened.
|
||||
* English. The file is self-contained: styles, font and the small filter script
|
||||
* are inline, nothing is fetched when it is opened.
|
||||
*/
|
||||
export const MANAGEMENT_OVERVIEW_RENDERER_VERSION = '1.1.0';
|
||||
|
||||
const DASH = '—';
|
||||
export const MANAGEMENT_OVERVIEW_RENDERER_VERSION = '2.1.0';
|
||||
|
||||
/** Unknown stays unknown. An absent value never becomes a zero. */
|
||||
function value(input: string | number | undefined | null): string {
|
||||
|
|
@ -33,12 +44,6 @@ function value(input: string | number | undefined | null): string {
|
|||
return text === '' ? DASH : escapeHtml(text);
|
||||
}
|
||||
|
||||
function integer(input: number | undefined): string {
|
||||
return typeof input === 'number' && Number.isFinite(input)
|
||||
? escapeHtml(input.toLocaleString('de-DE'))
|
||||
: DASH;
|
||||
}
|
||||
|
||||
function percent(ratio: number | undefined, digits = 0): string {
|
||||
return typeof ratio === 'number' && Number.isFinite(ratio)
|
||||
? escapeHtml((ratio * 100).toLocaleString('de-DE', {
|
||||
|
|
@ -86,38 +91,10 @@ function basisLabel(basis: ReportPriorityBasis): string {
|
|||
return 'Ohne Rangfolge: Die Quelle deklariert keinen belastbaren Maßstab.';
|
||||
}
|
||||
|
||||
const SEVERITY_LABEL: Record<ReportFindingRef['severity'], string> = {
|
||||
critical: 'Critical', high: 'High', medium: 'Medium',
|
||||
low: 'Low', info: 'Info', unknown: 'Unknown'
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared donut geometry: full 360 degrees, continuous neutral track, value arc
|
||||
* clockwise from twelve o'clock, flat caps. The number sits on the exact centre
|
||||
* and its unit follows to the right without displacing it.
|
||||
*/
|
||||
function donut(score: number | undefined, caption: string): string {
|
||||
const radius = 54;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const ratio = typeof score === 'number' && Number.isFinite(score)
|
||||
? Math.min(Math.max(score, 0), 100) / 100
|
||||
: 0;
|
||||
const dash = `${(circumference * ratio).toFixed(3)} ${(circumference * (1 - ratio)).toFixed(3)}`;
|
||||
const display = typeof score === 'number' && Number.isFinite(score)
|
||||
? Math.round(score).toLocaleString('de-DE')
|
||||
: DASH;
|
||||
const hasScore = display !== DASH;
|
||||
return `<figure class="donut">
|
||||
<svg viewBox="0 0 128 128" role="img" aria-label="Overall Score ${hasScore ? `${display} von 100` : 'nicht verfügbar'}">
|
||||
<circle class="donut__track" cx="64" cy="64" r="${radius}"></circle>
|
||||
${hasScore ? `<circle class="donut__value" cx="64" cy="64" r="${radius}"
|
||||
stroke-dasharray="${dash}" transform="rotate(-90 64 64)"></circle>` : ''}
|
||||
</svg>
|
||||
<figcaption class="donut__readout">
|
||||
<span class="donut__number">${escapeHtml(display)}${hasScore ? '<span class="donut__unit">/100</span>' : ''}</span>
|
||||
<span class="donut__caption">${escapeHtml(caption)}</span>
|
||||
</figcaption>
|
||||
</figure>`;
|
||||
function severityCounts(findings: ReportFindingRef[]): Record<SeverityKey, number> {
|
||||
const counts = Object.fromEntries(SEVERITY_ORDER.map(key => [key, 0])) as Record<SeverityKey, number>;
|
||||
for (const finding of findings) counts[finding.severity] += 1;
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** Domain pass rates as a connected spine, not a conventional table. */
|
||||
|
|
@ -130,44 +107,47 @@ function domainSpine(domains: ReportDomainSummary[]): string {
|
|||
<span class="spine__node" aria-hidden="true"></span>
|
||||
<span class="spine__label">${escapeHtml(domain.domainLabel)}</span>
|
||||
<span class="spine__meter"><span class="spine__fill" style="width:${width}%"></span></span>
|
||||
<span class="spine__figure">${known ? `${percent(domain.passRate)} %` : DASH}</span>
|
||||
<span class="spine__counts">${integer(domain.deviations)} Deviations von ${integer(domain.assessed)} bewertet</span>
|
||||
<span class="spine__figure">${known ? `${percent(domain.passRate)} %` : DASH}</span>
|
||||
<span class="spine__counts">${domain.assessed
|
||||
? `${integer(domain.deviations)} Deviations von ${integer(domain.assessed)} bewertet`
|
||||
: 'nicht bewertet'}</span>
|
||||
</li>`;
|
||||
}).join('')}</ol>`;
|
||||
}
|
||||
|
||||
function findingRow(finding: ReportFindingRef, ranked: boolean): string {
|
||||
function findingRow(finding: ReportFindingRef, ranked: boolean, member: string): string {
|
||||
const severity = finding.severity;
|
||||
const showSeverity = severity !== 'unknown';
|
||||
return `<li class="finding">
|
||||
<span class="finding__accent finding__accent--${escapeHtml(severity)}" aria-hidden="true"></span>
|
||||
return `<li class="finding" data-filter-item data-filter-member="${member}" data-severity="${escapeHtml(severity)}">
|
||||
<span class="finding__title">${escapeHtml(finding.title)}</span>
|
||||
<span class="finding__meta">
|
||||
${showSeverity ? `<span class="badge badge--${escapeHtml(severity)}">${escapeHtml(SEVERITY_LABEL[severity])}</span>` : ''}
|
||||
${ranked && finding.rankValue !== undefined && finding.rankBasis.kind === 'numeric'
|
||||
? `<span class="badge badge--neutral">${escapeHtml(finding.rankBasis.property)} ${integer(finding.rankValue)}</span>` : ''}
|
||||
? `<span class="tag">${escapeHtml(finding.rankBasis.property)} ${integer(finding.rankValue)}</span>` : ''}
|
||||
${severityText(severity)}
|
||||
</span>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function prioritySection(priorities: ReportDomainPriority[]): string {
|
||||
if (!priorities.length) return '<p class="empty">Es wurde keine Deviation festgestellt.</p>';
|
||||
return priorities.map(domain => `<article class="tile domain">
|
||||
<header class="domain__head">
|
||||
<h3>${escapeHtml(domain.domainLabel)}</h3>
|
||||
if (!priorities.length) return '<p class="empty list__empty">Es wurde keine Deviation festgestellt.</p>';
|
||||
return priorities.map((domain, domainIndex) => {
|
||||
const member = `domain-${domainIndex}`;
|
||||
return `<details class="group domain" data-filter-group="${member}" open>
|
||||
<summary class="domain__head">
|
||||
<span>${escapeHtml(domain.domainLabel)}</span>
|
||||
<span class="domain__count">${integer(domain.deviations)} Deviations</span>
|
||||
</header>
|
||||
${domain.sources.map(source => `<section class="source">
|
||||
<header class="source__head">
|
||||
<span class="source__tool">${escapeHtml(source.toolId)}</span>
|
||||
<span class="source__basis">${basisLabel(source.basis)}</span>
|
||||
</summary>
|
||||
${domain.sources.map(source => `<section class="source-block">
|
||||
<header class="source-block__head">
|
||||
<span class="source-block__tool">${escapeHtml(toolLabel(source.toolId))}</span>
|
||||
<span class="source-block__basis">${basisLabel(source.basis)}</span>
|
||||
</header>
|
||||
<ol class="findings">${source.shown.map(item => findingRow(item, source.ranked)).join('')}</ol>
|
||||
<ol class="findings">${source.shown.map(item => findingRow(item, source.ranked, member)).join('')}</ol>
|
||||
${source.shown.length < source.total
|
||||
? `<p class="source__more">${integer(source.total - source.shown.length)} weitere Deviations dieser Quelle stehen im Technical Report.</p>`
|
||||
? `<p class="source-block__more">${integer(source.total - source.shown.length)} weitere Deviations dieser Quelle stehen im Technical Report.</p>`
|
||||
: ''}
|
||||
</section>`).join('')}
|
||||
</article>`).join('');
|
||||
</details>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -176,38 +156,84 @@ function prioritySection(priorities: ReportDomainPriority[]): string {
|
|||
*/
|
||||
function gpoSection(gpo: ReportGpoBlock): string {
|
||||
const rows: Array<[string, number]> = [
|
||||
['Fail', gpo.counts.Fail],
|
||||
['Deviation', gpo.counts.Fail],
|
||||
['Review required', gpo.counts['Review required']],
|
||||
['Pass', gpo.counts.Pass],
|
||||
['Passed', gpo.counts.Pass],
|
||||
['Not configured', gpo.counts['Not configured']],
|
||||
['No baseline rule', gpo.counts['No baseline rule']],
|
||||
['Not comparable', gpo.counts['Not comparable']]
|
||||
];
|
||||
return `<article class="tile domain">
|
||||
<header class="domain__head">
|
||||
<h3>GPO & Baseline</h3>
|
||||
<span class="domain__count">${integer(gpo.counts.Fail)} Deviations</span>
|
||||
</header>
|
||||
<p class="note">${escapeHtml(gpo.basis.reason)} Die Einträge stehen deshalb gleichgewichtig nebeneinander.</p>
|
||||
<dl class="verdicts">${rows.map(([label, count]) => `<div class="verdicts__row">
|
||||
<dt>${escapeHtml(label)}</dt><dd>${integer(count)}</dd>
|
||||
</div>`).join('')}</dl>
|
||||
<p class="note">
|
||||
Betroffene Policy Settings: <strong>${integer(gpo.affectedSettings)}</strong> ·
|
||||
betroffene GPO Backups: <strong>${integer(gpo.affectedBackups)}</strong> ·
|
||||
Conflicts: <strong>${integer(gpo.conflicts)}</strong> ·
|
||||
Duplicates: <strong>${integer(gpo.duplicates)}</strong>
|
||||
</p>
|
||||
<p class="note">
|
||||
<code>Not configured</code>, <code>No baseline rule</code> und <code>Not comparable</code>
|
||||
sind score-neutral und begründen keine Maßnahme.
|
||||
${gpo.scopeLimited
|
||||
return `<div class="grid grid--2 gpo">
|
||||
<article class="card">
|
||||
<header class="card__head">
|
||||
<h3 class="card__title">GPO & Baseline</h3>
|
||||
<span class="card__meta">${integer(gpo.counts.Fail)} Deviations</span>
|
||||
</header>
|
||||
${barList(rows.map(([label, count]) => ({ label, value: count })), 'Keine GPO Policy Settings bewertet.')}
|
||||
<p class="note">${escapeHtml(gpo.basis.reason)} Die Einträge stehen deshalb gleichgewichtig nebeneinander.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<header class="card__head"><h3 class="card__title">Betroffene GPO-Objekte</h3></header>
|
||||
<div class="kpis">
|
||||
<div class="kpi"><span class="kpi__label">Policy Settings mit Deviation</span><span class="kpi__figure">${integer(gpo.affectedSettings)}</span></div>
|
||||
<div class="kpi"><span class="kpi__label">betroffene GPO Backups</span><span class="kpi__figure">${integer(gpo.affectedBackups)}</span></div>
|
||||
<div class="kpi"><span class="kpi__label">Conflicts</span><span class="kpi__figure">${integer(gpo.conflicts)}</span></div>
|
||||
<div class="kpi"><span class="kpi__label">Duplicates</span><span class="kpi__figure">${integer(gpo.duplicates)}</span></div>
|
||||
</div>
|
||||
<p class="note">
|
||||
<code>Not configured</code>, <code>No baseline rule</code> und <code>Not comparable</code>
|
||||
sind score-neutral und begründen keine Maßnahme.
|
||||
${gpo.scopeLimited
|
||||
? 'Die Auswertung bezieht sich ausschließlich auf den importierten GPO-Backup-Satz und trifft keine Aussage über die gesamte Domäne.'
|
||||
: ''}
|
||||
</p>
|
||||
</p>
|
||||
</article>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function kpiTile(label: string, figure: string, caption: string): string {
|
||||
return `<article class="card kpi">
|
||||
<span class="kpi__label">${escapeHtml(label)}</span>
|
||||
<span class="kpi__figure">${figure}</span>
|
||||
<span class="kpi__caption">${caption}</span>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
const MANAGEMENT_CSS = `
|
||||
.spine{list-style:none;margin:0;padding:0 0 0 16px;border-left:1px solid var(--border-default)}
|
||||
.spine__item{position:relative;display:grid;grid-template-columns:minmax(150px,1fr) minmax(80px,160px) 56px;
|
||||
gap:4px 14px;align-items:center;padding:8px 0}
|
||||
.spine__node{position:absolute;left:-20px;top:17px;width:7px;height:7px;border-radius:50%;background:var(--donut-all)}
|
||||
.spine__label{font-size:16px;font-weight:600}
|
||||
.spine__meter{height:10px;background:var(--bar-track)}
|
||||
.spine__fill{display:block;height:100%;background:var(--donut-all)}
|
||||
.spine__figure{font-size:16px;font-weight:600;font-variant-numeric:tabular-nums;text-align:right}
|
||||
.spine__counts{grid-column:1 / -1;font-size:14px;color:var(--text-secondary)}
|
||||
.kpis{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}
|
||||
.kpis .kpi__figure{font-size:32px}
|
||||
.domain__count{margin-left:auto}
|
||||
.domain__count{font-size:14px;font-weight:600;color:var(--text-secondary);font-variant-numeric:tabular-nums}
|
||||
.source-block{padding:12px 20px 16px}
|
||||
.source-block[hidden]{display:none}
|
||||
.source-block__head{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px}
|
||||
.source-block__tool{font-size:16px;font-weight:600}
|
||||
.source-block__basis{font-size:14px;color:var(--text-secondary)}
|
||||
.source-block__more{margin-top:8px;font-size:14px;color:var(--text-muted)}
|
||||
.findings{list-style:none;margin:8px 0 0;padding:0}
|
||||
.finding{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:16px;align-items:center;
|
||||
padding:10px 0;border-bottom:1px solid var(--border-subtle)}
|
||||
.finding:last-child{border-bottom:0}
|
||||
.finding[hidden]{display:none}
|
||||
.finding__title{font-size:16px;font-weight:400;overflow-wrap:anywhere}
|
||||
.finding__meta{display:flex;flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:10px}
|
||||
.gpo{margin-top:16px}
|
||||
@media (max-width:720px){
|
||||
.spine__item{grid-template-columns:1fr 60px}
|
||||
.spine__meter{grid-column:1 / -1}
|
||||
.finding{grid-template-columns:1fr}
|
||||
.finding__meta{justify-content:flex-start}
|
||||
}`;
|
||||
|
||||
export function buildManagementOverviewHtml(projection: ReportProjection): string {
|
||||
const facts = projection.environment.length
|
||||
? projection.environment
|
||||
|
|
@ -215,28 +241,58 @@ export function buildManagementOverviewHtml(projection: ReportProjection): strin
|
|||
|
||||
const remediation = projection.remediation;
|
||||
const coverage = projection.coverage;
|
||||
const isGpo = (toolId: string | undefined) => toolLabel(toolId) === 'Group Policy';
|
||||
const scanFindings = projection.findings.filter(finding => !isGpo(finding.toolId));
|
||||
const gpoSettings = projection.gpo?.counts.Fail ?? 0;
|
||||
const severities = severityCounts(scanFindings);
|
||||
const criticalHigh = severities.critical + severities.high;
|
||||
const deviationLine = [
|
||||
`${integer(scanFindings.length)} aus Scans`,
|
||||
projection.gpo ? `${integer(gpoSettings)} GPO Policy Settings` : ''
|
||||
].filter(Boolean).join(' · ');
|
||||
const dated = projection.scoreSources.filter(source => germanDate(source.observedAt) !== DASH);
|
||||
const sourceLine = dated.length
|
||||
? dated.map(source => `${escapeHtml(toolLabel(source.toolId))}: ${germanDate(source.observedAt)}`).join(' • ')
|
||||
: projection.scoreSources.map(source => escapeHtml(toolLabel(source.toolId))).join(' • ');
|
||||
|
||||
const head = `<header class="doc__head">
|
||||
<p class="doc__kicker">ASSESSOR · Management Overview</p>
|
||||
const head = `<header class="doc-head">
|
||||
<p class="doc-head__kicker">ASSESSOR · Management Overview</p>
|
||||
<h1>${escapeHtml(projection.project.name)}</h1>
|
||||
${sourceLine ? `<p class="doc-head__sources">${sourceLine}</p>` : ''}
|
||||
<dl class="facts">
|
||||
${facts.map(fact => `<div class="facts__item">
|
||||
${facts.map(fact => `<div>
|
||||
<dt>${escapeHtml(fact.key)}</dt><dd>${value(fact.value)}</dd>
|
||||
</div>`).join('')}
|
||||
<div class="facts__item"><dt>Assessment</dt><dd>${value(projection.assessmentId)}</dd></div>
|
||||
<div class="facts__item"><dt>Berichtsdatum</dt><dd>${germanDateTime(projection.generatedAt)}</dd></div>
|
||||
<div><dt>Assessment</dt><dd>${value(projection.assessmentId)}</dd></div>
|
||||
<div><dt>Berichtsdatum</dt><dd>${germanDateTime(projection.generatedAt)}</dd></div>
|
||||
</dl>
|
||||
</header>`;
|
||||
|
||||
const posture = `<section class="segment" aria-labelledby="posture">
|
||||
<h2 id="posture">Overall Posture</h2>
|
||||
<div class="posture">
|
||||
<div class="tile posture__score">
|
||||
${donut(projection.overallScore, 'Overall Score')}
|
||||
</div>
|
||||
<div class="tile posture__domains">
|
||||
<h3>Security Domains</h3>
|
||||
const posture = `<section class="section segment" aria-labelledby="posture">
|
||||
<div class="section__head"><h2 id="posture">Overall Posture</h2></div>
|
||||
<div class="grid">
|
||||
${kpiTile('Deviations', integer(scanFindings.length + gpoSettings), deviationLine)}
|
||||
${kpiTile('Critical / High', integer(criticalHigh), 'Scan-Deviations mit hoher nativer Kritikalität')}
|
||||
<article class="card kpi kpi--donut">
|
||||
<span class="kpi__label">Overall Score</span>
|
||||
${scoreDonut(projection.overallScore, 'Overall Score')}
|
||||
</article>
|
||||
</div>
|
||||
<div class="grid grid--2" style="margin-top:16px">
|
||||
<article class="card">
|
||||
<header class="card__head"><h3 class="card__title">Security Domains</h3><span class="card__meta">Pass Rate je Domain</span></header>
|
||||
${domainSpine(projection.domains)}
|
||||
</article>
|
||||
<div class="stack">
|
||||
<article class="card">
|
||||
<header class="card__head"><h3 class="card__title">Severity-Verteilung</h3><span class="card__meta">${integer(scanFindings.length)} Scan-Deviations</span></header>
|
||||
${barList(SEVERITY_ORDER.filter(key => key !== 'unknown' || severities.unknown > 0)
|
||||
.map(key => ({ label: SEVERITY_LABEL[key], value: severities[key], tone: key })), 'Keine Deviations.')}
|
||||
</article>
|
||||
<article class="card">
|
||||
<header class="card__head"><h3 class="card__title">Deviations je Quelle</h3></header>
|
||||
${barList(projection.scoreSources.map(source => ({ label: toolLabel(source.toolId), value: source.deviations })), 'Keine Quelle bewertet.')}
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<p class="note">
|
||||
|
|
@ -244,274 +300,151 @@ export function buildManagementOverviewHtml(projection: ReportProjection): strin
|
|||
Der GPO-Anteil bezeichnet die Konformität der konfigurierten, vergleichbaren
|
||||
Einstellungen im Scope — er ist keine Aussage über eine vollständig
|
||||
ausgerollte Microsoft Baseline. Security-Domain-Werte sind Pass Rates und keine
|
||||
Zerlegung des Index.
|
||||
Zerlegung des Index. Quellen ohne native Severity erscheinen als „ohne Severity“.
|
||||
</p>
|
||||
</section>`;
|
||||
|
||||
const priorities = `<section class="segment" aria-labelledby="priorities">
|
||||
<h2 id="priorities">Priority Findings</h2>
|
||||
<p class="note">
|
||||
// With a GPO block, GPO deviations are shown there once per policy setting, not per baseline comparison.
|
||||
const priorityDomains = projection.gpo
|
||||
? projection.priorities.map(domain => {
|
||||
const sources = domain.sources.filter(source => !isGpo(source.toolId));
|
||||
return { ...domain, sources, deviations: sources.reduce((sum, source) => sum + source.total, 0) };
|
||||
}).filter(domain => domain.sources.length)
|
||||
: projection.priorities;
|
||||
const shownFindings = priorityDomains.reduce((sum, domain) =>
|
||||
sum + domain.sources.reduce((inner, source) => inner + source.shown.length, 0), 0);
|
||||
const priorities = `<section class="section segment" aria-labelledby="priorities" data-filter-scope>
|
||||
<div class="section__head"><h2 id="priorities">Priority Findings</h2><a class="to-top" href="#top">↑ Nach oben</a></div>
|
||||
<p class="section__lead">
|
||||
Gruppiert nach Security Domain. Innerhalb einer Domain wird je Quelle nach deren
|
||||
eigenem, deklariertem Maßstab geordnet; eine quellenübergreifende Rangfolge
|
||||
gibt es nicht, weil die Maßstäbe nicht vergleichbar sind.
|
||||
</p>
|
||||
<div class="segment__body">
|
||||
${prioritySection(projection.priorities)}
|
||||
${projection.gpo ? gpoSection(projection.gpo) : ''}
|
||||
${shownFindings ? filterToolbar({
|
||||
placeholder: 'Findings durchsuchen',
|
||||
reset: 'Alle',
|
||||
groups: [{
|
||||
key: 'severity', label: 'Severity',
|
||||
values: (['critical', 'high'] as SeverityKey[]).filter(key => severities[key] > 0)
|
||||
.map(key => ({ value: key, label: SEVERITY_LABEL[key] }))
|
||||
}]
|
||||
}) : ''}
|
||||
<div class="card list">
|
||||
<div class="list__head"><span class="list__crumb">Findings ›</span><span class="list__title">Nach Security Domain</span>
|
||||
<span class="count-tag">${integer(shownFindings)}</span></div>
|
||||
${prioritySection(priorityDomains)}
|
||||
</div>
|
||||
${projection.gpo ? '<p class="note">GPO-Deviations tragen keine native Kritikalität und stehen gleichgewichtig im Block GPO & Baseline.</p>' : ''}
|
||||
${projection.gpo ? gpoSection(projection.gpo) : ''}
|
||||
</section>`;
|
||||
|
||||
const convergence = `<section class="segment" aria-labelledby="convergence">
|
||||
<h2 id="convergence">Konvergenz der Quellen</h2>
|
||||
<p class="note">Mehrere Quellen in einer Security Domain belegen nicht automatisch denselben Finding.</p>
|
||||
<div class="segment__body">${projection.convergence.filter(item => item.sourceCount > 0).map(item => `<article class="tile domain">
|
||||
<h3>${value(projection.domains.find(domain => domain.domainId === item.domainId)?.domainLabel ?? item.domainId)}</h3>
|
||||
const convergence = `<section class="section segment" aria-labelledby="convergence">
|
||||
<div class="section__head"><h2 id="convergence">Konvergenz der Quellen</h2></div>
|
||||
<p class="section__lead">Mehrere Quellen in einer Security Domain belegen nicht automatisch denselben Finding.</p>
|
||||
<div class="grid">${projection.convergence.filter(item => item.sourceCount > 0).map(item => `<article class="card">
|
||||
<header class="card__head"><h3 class="card__title">${value(projection.domains.find(domain => domain.domainId === item.domainId)?.domainLabel ?? item.domainId)}</h3>
|
||||
<span class="card__meta">${integer(item.sourceCount)} Quellen</span></header>
|
||||
<p>${integer(item.sourceCount)} unabhängige Quellen mit Deviations: ${item.sources.filter(source => source.deviations > 0)
|
||||
.map(source => escapeHtml(source.toolId)).join(', ')}.</p>
|
||||
.map(source => escapeHtml(toolLabel(source.toolId))).join(', ')}.</p>
|
||||
<p class="note">${item.evidence === 'shared-framework-reference'
|
||||
? 'Belegte Konvergenz: Mindestens zwei Quellen nennen dieselbe Framework-Referenz.'
|
||||
: 'Nur thematische Nähe innerhalb der Domain; ein gemeinsamer Finding ist nicht belegt und bedarf Validierung.'}</p>
|
||||
</article>`).join('') || '<p class="empty">Keine quellenübergreifende Konvergenz belegt.</p>'}</div>
|
||||
? 'Belegte Konvergenz: Mindestens zwei Quellen nennen dieselbe Framework-Referenz.'
|
||||
: 'Nur thematische Nähe innerhalb der Domain; ein gemeinsamer Finding ist nicht belegt und bedarf Validierung.'}</p>
|
||||
</article>`).join('') || '<article class="card"><p class="empty">Keine quellenübergreifende Konvergenz belegt.</p></article>'}</div>
|
||||
</section>`;
|
||||
|
||||
const bundles = `<section class="segment" aria-labelledby="bundles">
|
||||
<h2 id="bundles">Maßnahmenbündel</h2>
|
||||
<p class="note">Gleiche Framework-Referenzen gruppieren Findings ohne Aussage zu Aufwand, Nutzen oder Priorität.</p>
|
||||
${projection.bundles.length ? `<table class="table"><thead><tr><th>Framework-Referenz</th><th>Findings</th></tr></thead>
|
||||
<tbody>${projection.bundles.map(bundle => `<tr><td>${escapeHtml(bundle.key)}</td><td class="num">${integer(bundle.members.length)}</td></tr>`).join('')}</tbody></table>`
|
||||
const bundles = `<section class="section segment" aria-labelledby="bundles">
|
||||
<div class="section__head"><h2 id="bundles">Maßnahmenbündel</h2></div>
|
||||
<p class="section__lead">Gleiche Framework-Referenzen gruppieren Findings ohne Aussage zu Aufwand, Nutzen oder Priorität.</p>
|
||||
<article class="card">
|
||||
${projection.bundles.length ? `<div class="table-scroll"><table><thead><tr><th>Framework-Referenz</th><th class="num">Findings</th></tr></thead>
|
||||
<tbody>${projection.bundles.map(bundle => `<tr><td>${escapeHtml(bundle.key)}</td><td class="num">${integer(bundle.members.length)}</td></tr>`).join('')}</tbody></table></div>`
|
||||
: '<p class="empty">Keine mehrfach belegte Framework-Referenz.</p>'}
|
||||
<p class="note">Einzelquellen-GPO-Maßnahmen ohne Conflict oder Duplicate: ${integer(projection.singleSourcePolicyActions.length)}.</p>
|
||||
<p class="note">Einzelquellen-GPO-Maßnahmen ohne Conflict oder Duplicate: ${integer(projection.singleSourcePolicyActions.length)}.</p>
|
||||
</article>
|
||||
</section>`;
|
||||
|
||||
const remediationSection = remediation ? `<section class="segment" aria-labelledby="remediation">
|
||||
<h2 id="remediation">Remediation-Status</h2>
|
||||
<p class="note">
|
||||
const remediationSection = remediation ? `<section class="section segment" aria-labelledby="remediation">
|
||||
<div class="section__head"><h2 id="remediation">Remediation-Status</h2></div>
|
||||
<p class="section__lead">
|
||||
Selbstauskunft des Kunden aus der Arbeitsgrundlage. Diese Angaben sind
|
||||
<strong>kein technischer Nachweis</strong>: Eine Maßnahme gilt erst dann als
|
||||
behoben, wenn ein Rescan das belegt.
|
||||
</p>
|
||||
<div class="segment__body">
|
||||
<article class="tile kpi"><span class="kpi__figure">${integer(remediation.items)}</span><span class="kpi__label">Maßnahmen in Arbeit</span></article>
|
||||
<article class="tile kpi"><span class="kpi__figure">${integer(remediation.withoutOwner)}</span><span class="kpi__label">ohne Owner</span></article>
|
||||
<article class="tile kpi"><span class="kpi__figure">${integer(remediation.withoutDue)}</span><span class="kpi__label">ohne Termin</span></article>
|
||||
<article class="tile kpi"><span class="kpi__figure">${integer(remediation.riskAccepted)}</span><span class="kpi__label">Accepted Risk</span></article>
|
||||
<div class="grid">
|
||||
${kpiTile('Maßnahmen in Arbeit', integer(remediation.items), 'Selbstauskunft')}
|
||||
${kpiTile('ohne Owner', integer(remediation.withoutOwner), 'Maßnahmen')}
|
||||
${kpiTile('ohne Termin', integer(remediation.withoutDue), 'Maßnahmen')}
|
||||
${kpiTile('Accepted Risk', integer(remediation.riskAccepted), 'Maßnahmen')}
|
||||
</div>
|
||||
${Object.keys(remediation.byStatus).length ? `<dl class="verdicts">${Object.entries(remediation.byStatus)
|
||||
.map(([status, count]) => `<div class="verdicts__row"><dt>${escapeHtml(status)}</dt><dd>${integer(count)}</dd></div>`)
|
||||
.join('')}</dl>` : ''}
|
||||
${Object.keys(remediation.byStatus).length ? `<article class="card" style="margin-top:16px">
|
||||
<header class="card__head"><h3 class="card__title">Status</h3></header>
|
||||
${barList(Object.entries(remediation.byStatus).map(([status, count]) => ({ label: status, value: count })), '')}
|
||||
</article>` : ''}
|
||||
</section>` : '';
|
||||
|
||||
const coverageSection = `<section class="segment" aria-labelledby="coverage">
|
||||
<h2 id="coverage">Abdeckung und Lücken</h2>
|
||||
<div class="segment__body">
|
||||
<article class="tile kpi"><span class="kpi__figure">${integer(coverage.assessed)}</span><span class="kpi__label">bewertet</span></article>
|
||||
<article class="tile kpi"><span class="kpi__figure">${integer(coverage.deviations)}</span><span class="kpi__label">Deviations</span></article>
|
||||
<article class="tile kpi"><span class="kpi__figure">${integer(coverage.notRun)}</span><span class="kpi__label">not run</span></article>
|
||||
<article class="tile kpi"><span class="kpi__figure">${integer(coverage.notApplicable)}</span><span class="kpi__label">not applicable</span></article>
|
||||
<article class="tile kpi"><span class="kpi__figure">${integer(coverage.executionErrors)}</span><span class="kpi__label">execution errors</span></article>
|
||||
const coverageSection = `<section class="section segment" aria-labelledby="coverage">
|
||||
<div class="section__head"><h2 id="coverage">Abdeckung und Lücken</h2></div>
|
||||
<div class="grid">
|
||||
${kpiTile('bewertet', integer(coverage.assessed), `von ${integer(coverage.total)} Ergebnissen`)}
|
||||
${kpiTile('Deviations', integer(coverage.deviations), 'bewertete Abweichungen')}
|
||||
${kpiTile('not run', integer(coverage.notRun), 'nicht ausgeführt')}
|
||||
${kpiTile('not applicable', integer(coverage.notApplicable), 'nicht anwendbar')}
|
||||
${kpiTile('execution errors', integer(coverage.executionErrors), 'fehlerhaft ausgeführt')}
|
||||
</div>
|
||||
<p class="note">
|
||||
Von insgesamt ${integer(coverage.total)} Ergebnissen konnten ${integer(coverage.assessed)}
|
||||
bewertet werden. Nicht ausgeführte, nicht anwendbare und fehlerhaft ausgeführte
|
||||
Checks zählen weder als bestanden noch als Deviation.
|
||||
</p>
|
||||
<table class="table">
|
||||
<caption>Abdeckung je Quelle</caption>
|
||||
<thead><tr><th scope="col">Quelle</th><th scope="col">Ergebnisse</th><th scope="col">bewertet</th><th scope="col">not run</th><th scope="col">not run Anteil</th></tr></thead>
|
||||
<tbody>${coverage.perSource.map(source => `<tr>
|
||||
<td>${escapeHtml(source.toolId)}</td>
|
||||
<td class="num">${integer(source.total)}</td>
|
||||
<td class="num">${integer(source.assessed)}</td>
|
||||
<td class="num">${integer(source.notRun)}</td>
|
||||
<td class="num">${source.total ? `${percent(source.notRun / source.total)} %` : DASH}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>
|
||||
<article class="card" style="margin-top:16px">
|
||||
<header class="card__head"><h3 class="card__title">Abdeckung je Quelle</h3></header>
|
||||
<div class="table-scroll"><table>
|
||||
<thead><tr><th scope="col">Quelle</th><th scope="col" class="num">Ergebnisse</th><th scope="col" class="num">bewertet</th><th scope="col" class="num">not run</th><th scope="col" class="num">not run Anteil</th></tr></thead>
|
||||
<tbody>${coverage.perSource.map(source => `<tr>
|
||||
<td>${escapeHtml(toolLabel(source.toolId))}</td>
|
||||
<td class="num">${integer(source.total)}</td>
|
||||
<td class="num">${integer(source.assessed)}</td>
|
||||
<td class="num">${integer(source.notRun)}</td>
|
||||
<td class="num">${source.total ? `${percent(source.notRun / source.total)} %` : DASH}</td>
|
||||
</tr>`).join('') || `<tr><td colspan="5">${DASH}</td></tr>`}</tbody>
|
||||
</table></div>
|
||||
</article>
|
||||
</section>`;
|
||||
|
||||
const sources = `<section class="segment" aria-labelledby="sources">
|
||||
<h2 id="sources">Source Overview</h2>
|
||||
<table class="table">
|
||||
<caption>Score-Beitrag je Quelle</caption>
|
||||
<thead><tr>
|
||||
<th scope="col">Quelle</th><th scope="col">Stand</th><th scope="col">Beitrag</th>
|
||||
<th scope="col">Methode</th><th scope="col">Pass</th><th scope="col">Deviations</th>
|
||||
</tr></thead>
|
||||
<tbody>${projection.scoreSources.length ? projection.scoreSources.map(source => `<tr>
|
||||
<td>${escapeHtml(source.toolId)}</td>
|
||||
<td>${germanDate(source.observedAt)}</td>
|
||||
<td class="num">${typeof source.score === 'number' ? integer(Math.round(source.score)) : DASH}</td>
|
||||
<td>${escapeHtml(SCORE_METHOD_LABEL[source.method])}</td>
|
||||
<td class="num">${integer(source.passed)}</td>
|
||||
<td class="num">${integer(source.deviations)}</td>
|
||||
</tr>`).join('') : `<tr><td colspan="6">${DASH}</td></tr>`}</tbody>
|
||||
</table>
|
||||
${projection.scoreSources.some(source => source.method === 'inverted-native')
|
||||
const sources = `<section class="section segment" aria-labelledby="sources">
|
||||
<div class="section__head"><h2 id="sources">Source Overview</h2></div>
|
||||
<article class="card">
|
||||
<header class="card__head"><h3 class="card__title">Score-Beitrag je Quelle</h3></header>
|
||||
<div class="table-scroll"><table>
|
||||
<thead><tr>
|
||||
<th scope="col">Quelle</th><th scope="col">Stand</th><th scope="col" class="num">Beitrag</th>
|
||||
<th scope="col">Methode</th><th scope="col" class="num">Pass</th><th scope="col" class="num">Deviations</th>
|
||||
</tr></thead>
|
||||
<tbody>${projection.scoreSources.length ? projection.scoreSources.map(source => `<tr>
|
||||
<td>${escapeHtml(toolLabel(source.toolId))}</td>
|
||||
<td>${germanDate(source.observedAt)}</td>
|
||||
<td class="num">${typeof source.score === 'number' ? integer(Math.round(source.score)) : DASH}</td>
|
||||
<td>${escapeHtml(SCORE_METHOD_LABEL[source.method])}</td>
|
||||
<td class="num">${integer(source.passed)}</td>
|
||||
<td class="num">${integer(source.deviations)}</td>
|
||||
</tr>`).join('') : `<tr><td colspan="6">${DASH}</td></tr>`}</tbody>
|
||||
</table></div>
|
||||
${projection.scoreSources.some(source => source.method === 'inverted-native')
|
||||
? '<p class="note">Eine invertierte Quelle liefert nativ einen Risk-Wert, bei dem ein höherer Wert schlechter ist. Er geht als <code>100 − Risk</code> in den Index ein; der rohe Risk-Wert bleibt in der Quelle erhalten.</p>'
|
||||
: ''}
|
||||
</article>
|
||||
</section>`;
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Management Overview · ${escapeHtml(projection.project.name)}</title>
|
||||
<style>
|
||||
:root{
|
||||
--font-ui:"Inter Variable","Inter",system-ui,-apple-system,"Segoe UI",sans-serif;
|
||||
--type-h1-size:56px; --type-h2-size:32px; --type-h3-size:18px; --type-h4-size:16px;
|
||||
--type-body-size:16px; --type-table-body-size:14px; --type-label-size:12px;
|
||||
--type-meta-size:12px; --type-badge-size:11px; --type-table-head-size:12px;
|
||||
--fw-regular:400; --fw-semibold:600; --fw-bold:700;
|
||||
--lh-ui:1.35; --lh-prose:1.55; --ls-caps:0.06em;
|
||||
--space-1:4px; --space-2:8px; --space-3:12px; --space-4:16px; --space-5:20px;
|
||||
--space-6:24px; --space-8:32px;
|
||||
--panel-content-inset:20px; --panel-inset:var(--panel-content-inset);
|
||||
--segment-surface:transparent; --segment-border:transparent; --segment-radius:10px;
|
||||
--segment-inset:var(--space-3); --segment-gap:var(--space-3);
|
||||
--tile-radius:0px; --tile-inset:var(--space-4); --tile-border:transparent;
|
||||
--radius-xs:0px; --radius-sm:0px;
|
||||
--donut-ring-size:128px; --donut-ring-stroke:12px;
|
||||
--donut-value-size:26px; --donut-percent-size:14px;
|
||||
/* Light is the document default: a report is read and printed on white. */
|
||||
--surface-app:#F1F1F4; --surface-panel:#FFFFFF; --tile-surface:#FFFFFF;
|
||||
--text-primary:#17171C; --text-secondary:#55555F; --text-muted:#74747F;
|
||||
--border-subtle:#E2E2E8; --border-strong:#A8A8B4;
|
||||
--donut-track:#B9BEC7; --donut-value:#20242A; --donut-label:#555B65; --donut-all:#1C70C9;
|
||||
--marking-color:#E9A63C;
|
||||
--sev-critical:#E8214F; --sev-high:#E2680F; --sev-medium:#C99700;
|
||||
--sev-low:#2E9E6B; --sev-info:#3D8FD1; --sev-unknown:#6E6E78; --sev-on:#FFFFFF;
|
||||
}
|
||||
@media (prefers-color-scheme:dark){
|
||||
:root{
|
||||
--surface-app:#242426; --surface-panel:#242426; --tile-surface:#323238;
|
||||
--text-primary:#E2DFE6; --text-secondary:#B7BAC0; --text-muted:#B7BAC0;
|
||||
--border-subtle:rgba(255,255,255,.14); --border-strong:#4E4E58;
|
||||
--donut-track:#555B65; --donut-value:#E2DFE6; --donut-label:#B7BAC0; --donut-all:#348CF2;
|
||||
}
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html{background:var(--surface-app)}
|
||||
body{margin:0;padding:var(--panel-inset);background:var(--surface-app);color:var(--text-primary);
|
||||
font:var(--fw-regular) var(--type-body-size)/var(--lh-prose) var(--font-ui)}
|
||||
h1,h2,h3{margin:0;line-height:var(--lh-ui);font-weight:var(--fw-semibold)}
|
||||
h1{font-size:var(--type-h1-size);letter-spacing:-.02em}
|
||||
h2{font-size:var(--type-h2-size)}
|
||||
h3{font-size:var(--type-h3-size)}
|
||||
code{font-family:var(--font-ui);font-size:var(--type-table-body-size)}
|
||||
strong{font-weight:var(--fw-semibold)}
|
||||
.doc__head{padding-bottom:var(--space-6);border-bottom:1px solid var(--border-strong)}
|
||||
.doc__kicker{margin:0 0 var(--space-2);font-size:var(--type-label-size);font-weight:var(--fw-semibold);
|
||||
letter-spacing:var(--ls-caps);text-transform:uppercase;color:var(--text-secondary)}
|
||||
.facts{display:flex;flex-wrap:wrap;gap:var(--space-6);margin:var(--space-5) 0 0}
|
||||
.facts__item{margin:0}
|
||||
.facts dt{font-size:var(--type-label-size);font-weight:var(--fw-semibold);color:var(--text-secondary)}
|
||||
.facts dd{margin:var(--space-1) 0 0;font-size:var(--type-table-body-size)}
|
||||
.segment{margin-top:var(--space-8);padding:var(--segment-inset);background:var(--segment-surface);
|
||||
border:1px solid var(--segment-border);border-radius:var(--segment-radius)}
|
||||
.segment__body{display:flex;flex-wrap:wrap;gap:var(--segment-gap);margin-top:var(--space-4);align-items:flex-start}
|
||||
.tile{padding:var(--tile-inset);background:var(--tile-surface);border:1px solid var(--tile-border);
|
||||
border-radius:var(--tile-radius)}
|
||||
.note{margin:var(--space-4) 0 0;font-size:var(--type-table-body-size);color:var(--text-secondary)}
|
||||
.empty{margin:var(--space-4) 0 0;font-size:var(--type-table-body-size);color:var(--text-muted)}
|
||||
.posture{display:flex;flex-wrap:wrap;gap:var(--segment-gap);margin-top:var(--space-4);align-items:stretch}
|
||||
.posture__score{display:flex;align-items:center;justify-content:center;min-width:260px}
|
||||
.posture__domains{flex:1 1 420px;min-width:320px}
|
||||
.donut{position:relative;margin:0;width:var(--donut-ring-size);height:var(--donut-ring-size)}
|
||||
.donut svg{width:100%;height:100%;display:block}
|
||||
.donut circle{fill:none;stroke-width:var(--donut-ring-stroke);stroke-linecap:butt}
|
||||
.donut__track{stroke:var(--donut-track)}
|
||||
.donut__value{stroke:var(--donut-all)}
|
||||
.donut__readout{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;
|
||||
justify-content:center;gap:var(--space-1);text-align:center}
|
||||
.donut__number{font-size:var(--donut-value-size);font-weight:var(--fw-semibold);color:var(--donut-value);
|
||||
line-height:1;font-variant-numeric:tabular-nums}
|
||||
.donut__unit{font-size:var(--donut-percent-size);font-weight:var(--fw-regular);color:var(--donut-label)}
|
||||
.donut__caption{font-size:var(--type-badge-size);color:var(--donut-label)}
|
||||
.spine{list-style:none;margin:var(--space-4) 0 0;padding:0 0 0 var(--space-4);
|
||||
border-left:1px solid var(--border-subtle)}
|
||||
.spine__item{position:relative;display:grid;grid-template-columns:minmax(150px,1fr) minmax(80px,140px) 52px;
|
||||
gap:var(--space-2) var(--space-3);align-items:center;padding:var(--space-2) 0}
|
||||
.spine__node{position:absolute;left:calc(var(--space-4) * -1 - 4px);width:7px;height:7px;border-radius:50%;
|
||||
background:var(--donut-all)}
|
||||
.spine__label{font-size:var(--type-table-body-size);font-weight:var(--fw-semibold)}
|
||||
.spine__meter{height:6px;background:var(--donut-track)}
|
||||
.spine__fill{display:block;height:100%;background:var(--donut-all)}
|
||||
.spine__figure{font-size:var(--type-table-body-size);font-variant-numeric:tabular-nums;text-align:right}
|
||||
.spine__counts{grid-column:1 / -1;font-size:var(--type-meta-size);color:var(--text-secondary)}
|
||||
.domain{flex:1 1 460px;min-width:320px}
|
||||
.domain__head{display:flex;align-items:baseline;justify-content:space-between;gap:var(--space-3);
|
||||
padding-bottom:var(--space-2);border-bottom:1px solid var(--border-subtle)}
|
||||
.domain__count{font-size:var(--type-meta-size);color:var(--text-secondary);font-variant-numeric:tabular-nums}
|
||||
.source{margin-top:var(--space-4)}
|
||||
.source__head{display:flex;flex-wrap:wrap;align-items:baseline;gap:var(--space-2)}
|
||||
.source__tool{font-size:var(--type-h4-size);font-weight:var(--fw-semibold)}
|
||||
.source__basis{font-size:var(--type-meta-size);color:var(--text-secondary)}
|
||||
.source__more{margin:var(--space-2) 0 0;font-size:var(--type-meta-size);color:var(--text-muted)}
|
||||
.findings{list-style:none;margin:var(--space-2) 0 0;padding:0}
|
||||
.finding{position:relative;display:flex;flex-direction:column;gap:var(--space-1);
|
||||
padding:var(--space-2) 0 var(--space-2) var(--space-3);border-bottom:1px solid var(--border-subtle)}
|
||||
.finding__accent{position:absolute;left:0;top:var(--space-2);bottom:var(--space-2);width:3px;
|
||||
background:var(--sev-unknown)}
|
||||
.finding__accent--critical{background:var(--sev-critical)}
|
||||
.finding__accent--high{background:var(--sev-high)}
|
||||
.finding__accent--medium{background:var(--sev-medium)}
|
||||
.finding__accent--low{background:var(--sev-low)}
|
||||
.finding__accent--info{background:var(--sev-info)}
|
||||
.finding__title{font-size:var(--type-table-body-size);font-weight:var(--fw-semibold);line-height:var(--lh-ui)}
|
||||
.finding__meta{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2)}
|
||||
.finding__asset{font-size:var(--type-meta-size);color:var(--text-secondary)}
|
||||
.badge{display:inline-block;padding:1px var(--space-2);border-radius:var(--radius-xs);
|
||||
font-size:var(--type-badge-size);font-weight:var(--fw-semibold);letter-spacing:var(--ls-caps);
|
||||
text-transform:uppercase;color:var(--sev-on)}
|
||||
.badge--critical{background:var(--sev-critical)}
|
||||
.badge--high{background:var(--sev-high)}
|
||||
.badge--medium{background:var(--sev-medium)}
|
||||
.badge--low{background:var(--sev-low)}
|
||||
.badge--info{background:var(--sev-info)}
|
||||
.badge--neutral{background:transparent;color:var(--text-secondary);border:1px solid var(--border-strong);
|
||||
text-transform:none;letter-spacing:0;font-variant-numeric:tabular-nums}
|
||||
.kpi{flex:1 1 150px;min-width:140px;display:flex;flex-direction:column;gap:var(--space-1)}
|
||||
.kpi__figure{font-size:var(--type-h2-size);font-weight:var(--fw-semibold);line-height:1;
|
||||
font-variant-numeric:tabular-nums}
|
||||
.kpi__label{font-size:var(--type-meta-size);color:var(--text-secondary)}
|
||||
.verdicts{display:flex;flex-wrap:wrap;gap:var(--space-2) var(--space-6);margin:var(--space-4) 0 0}
|
||||
.verdicts__row{display:flex;align-items:baseline;gap:var(--space-2)}
|
||||
.verdicts dt{font-size:var(--type-meta-size);color:var(--text-secondary)}
|
||||
.verdicts dd{margin:0;font-size:var(--type-table-body-size);font-weight:var(--fw-semibold);
|
||||
font-variant-numeric:tabular-nums}
|
||||
.table{width:100%;margin-top:var(--space-4);border-collapse:collapse;
|
||||
font-size:var(--type-table-body-size)}
|
||||
.table caption{margin-bottom:var(--space-2);text-align:left;font-size:var(--type-label-size);
|
||||
font-weight:var(--fw-semibold);color:var(--text-secondary)}
|
||||
.table th,.table td{padding:var(--space-2) var(--space-3);text-align:left;
|
||||
border-bottom:1px solid var(--border-subtle)}
|
||||
.table th{font-size:var(--type-table-head-size);font-weight:var(--fw-semibold);
|
||||
letter-spacing:var(--ls-caps);text-transform:uppercase;color:var(--text-secondary)}
|
||||
.table .num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
@media print{
|
||||
:root{--surface-app:#FFFFFF;--surface-panel:#FFFFFF;--tile-surface:#FFFFFF}
|
||||
body{padding:0}
|
||||
.segment{break-inside:avoid;margin-top:var(--space-6)}
|
||||
.tile{break-inside:avoid;border:1px solid var(--border-subtle)}
|
||||
.finding{break-inside:avoid}
|
||||
}
|
||||
@media (max-width:720px){
|
||||
h1{font-size:var(--type-h2-size)}
|
||||
.spine__item{grid-template-columns:1fr 60px}
|
||||
.spine__meter{grid-column:1 / -1}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${head}
|
||||
return reportDocument(
|
||||
`Management Overview · ${projection.project.name}`,
|
||||
`${head}
|
||||
${posture}
|
||||
${convergence}
|
||||
${coverageSection}
|
||||
${priorities}
|
||||
${bundles}
|
||||
${remediationSection}
|
||||
${sources}
|
||||
</body>
|
||||
</html>`;
|
||||
${coverageSection}
|
||||
${convergence}
|
||||
${bundles}
|
||||
${sources}`,
|
||||
MANAGEMENT_CSS
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = /<(\/?)(p|br|b|strong|i|em|u|ul|ol|li)\s*\/?>/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|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>`;
|
||||
}
|
||||
|
|
@ -1,31 +1,82 @@
|
|||
import type { JsonValue } from '../domain/canonical-model';
|
||||
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 { 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 === ''
|
||||
? '—' : escapeHtml(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value));
|
||||
const number = (value: number): string => escapeHtml(value.toLocaleString('de-DE'));
|
||||
const when = (value: string): string => Number.isFinite(Date.parse(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>
|
||||
<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 {
|
||||
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>`;
|
||||
return markedFields.includes('value') && !markedItems.length ? `<div class="different">${members}</div>` : members;
|
||||
}
|
||||
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');
|
||||
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>`;
|
||||
return markedFields.includes('value') && !Object.hasOwn(value, 'value') && !markedItems.length
|
||||
? `<div class="different">${instruction}</div>` : instruction;
|
||||
|
|
@ -34,121 +85,447 @@ function sourceValue(value: JsonValue, markedFields: readonly string[], markedIt
|
|||
}
|
||||
|
||||
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 => `<section class="source">
|
||||
<h5>GPO Backup ${plain(source.gpoId)} · ${plain(source.nativeBackupId)}</h5>
|
||||
<dl class="facts"><div><dt>Backup-Instanz</dt><dd>${plain(source.backupInstanceId)}</dd></div>
|
||||
<div><dt>Evidence</dt><dd>${plain(source.sourceReference)}</dd></div>
|
||||
<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>
|
||||
return sources.map(source => `<div class="source">
|
||||
<h6>GPO ${plain(source.gpoId ?? source.nativeBackupId ?? source.backupInstanceId)}</h6>
|
||||
<span class="source__path">${escapeHtml(source.sourceReference)}${source.backupInstanceId ? ` · Backup ${escapeHtml(source.backupInstanceId)}` : ''}${typeof source.sequence === 'number' ? ` · Reihenfolge ${number(source.sequence)}` : ''}</span>
|
||||
${source.values.map((value, index) => `<div class="source-value">
|
||||
${source.values.length > 1 ? `<p class="compare__caption">Anweisung ${number(index + 1)}</p>` : ''}
|
||||
${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 actual = result.policyActualValue;
|
||||
const expected = result.policyBaselineValue;
|
||||
const variants = result.policyBaselineVariantAssessment;
|
||||
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 isPolicy = (result: UnifiedResultProjection): boolean => result.sourceKind === 'policy-evaluation';
|
||||
|
||||
function severityOf(result: UnifiedResultProjection): SeverityKey {
|
||||
return (SEVERITY_ORDER as string[]).includes(result.severityNormalized) ? result.severityNormalized as SeverityKey : 'unknown';
|
||||
}
|
||||
|
||||
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 {
|
||||
const results = projection.technicalResults ?? [];
|
||||
const labels = new Map(projection.domains.map(domain => [domain.domainId, domain.domainLabel]));
|
||||
const domainOf = (result: UnifiedResultProjection) => projection.technicalResultDomains?.[result.resultProjectionId] ?? 'unclassified';
|
||||
const deviations = results.filter(result => result.isDeviation);
|
||||
const severityOrder = ['critical', 'high', 'medium', 'low', 'info', 'unknown'];
|
||||
const grouped = [...new Set(deviations.map(domainOf))].map(domainId => ({
|
||||
domainId, label: labels.get(domainId) ?? domainId,
|
||||
items: deviations.filter(result => domainOf(result) === domainId)
|
||||
.sort((a, b) => severityOrder.indexOf(a.severityNormalized) - severityOrder.indexOf(b.severityNormalized)
|
||||
|| a.title.localeCompare(b.title))
|
||||
}));
|
||||
const severityOrder = SEVERITY_ORDER as string[];
|
||||
const bySeverity = (a: UnifiedResultProjection, b: UnifiedResultProjection) =>
|
||||
severityOrder.indexOf(a.severityNormalized) - severityOrder.indexOf(b.severityNormalized) || a.title.localeCompare(b.title);
|
||||
const groupByDomain = (items: UnifiedResultProjection[]): ResultGroup[] => [...new Set(items.map(domainOf))].map(domainId => ({
|
||||
label: labels.get(domainId) ?? domainId,
|
||||
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 reviews = new Map((projection.technicalReviews ?? []).map(item => [item.resultProjectionId, item]));
|
||||
const fields = new Map((projection.technicalFindingFields ?? []).map(item => [item.resultProjectionId, item.fields]));
|
||||
const neutral = new Set(['Not configured', 'No baseline rule', 'Not comparable']);
|
||||
const policyRows = projection.policyEntries.map(item => [
|
||||
plain(item.title), plain(item.scope), plain(item.identity),
|
||||
`${plain(item.verdict)}${neutral.has(item.verdict) ? ' · score-neutral' : ''}`,
|
||||
item.conflict ? 'Ja' : 'Nein', item.duplicate ? 'Ja' : 'Nein'
|
||||
]);
|
||||
const detail = (item: UnifiedResultProjection, groupLabel: string) =>
|
||||
scannerDetail(item, groupLabel, reviews.get(item.resultProjectionId), fields.get(item.resultProjectionId));
|
||||
const gpoItems = gpoFindings(deviations.filter(isPolicy), projection.policyEntries, policySources);
|
||||
const registerEntries = projection.policyEntries.filter(entry => entry.verdict !== 'Fail');
|
||||
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)]);
|
||||
return `<!doctype html>
|
||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>ASSESSOR · Technical Report · ${escapeHtml(projection.project.name)}</title>
|
||||
<style>
|
||||
:root{font-family:Inter,system-ui,sans-serif;color:#20212a;background:#f1f1f4;font-size:16px}
|
||||
*{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}
|
||||
h3{font-size:18px}h4{font-size:16px;margin:0}h5{font-size:14px;margin:0 0 8px}p{line-height:1.5}
|
||||
.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}
|
||||
.finding header{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;border-bottom:1px solid #d6d6dc;padding-bottom:12px}
|
||||
.finding header span,.meta,.limit{color:#555967;font-size:14px}.facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 24px}
|
||||
.facts div{min-width:0}.facts dt{font-size:14px;color:#555967}.facts dd{margin:2px 0 0;overflow-wrap:anywhere}
|
||||
.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}
|
||||
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}
|
||||
.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}
|
||||
.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}}
|
||||
@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}}
|
||||
</style></head><body>
|
||||
<header class="head"><p>ASSESSOR · Technical Report</p><h1>${escapeHtml(projection.project.name)}</h1>
|
||||
<p>Assessment ${plain(projection.assessmentId)} · Stand ${when(projection.generatedAt)}</p>
|
||||
<p class="limit">Frozen Snapshot des gewählten Timeslots. GPO-Aussagen beruhen auf Backupvergleichen; der effektive Endpunktzustand ist nicht verifiziert.</p></header>
|
||||
<section class="block"><h2>Scope und Provenienz</h2>
|
||||
${provenanceRows.length ? table(['Art', 'ID', 'Version', 'Beobachtet'], provenanceRows) : '<p class="limit">Keine Provenienz übermittelt.</p>'}</section>
|
||||
<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>
|
||||
${table(['Severity', 'Anzahl'], Object.entries(projection.inventory.bySeverity).map(([label, count]) => [plain(label), number(count)]))}
|
||||
${table(['Outcome', 'Anzahl'], Object.entries(projection.inventory.byOutcome).map(([label, count]) => [plain(label), number(count)]))}
|
||||
${table(['Security Domain', 'Anzahl'], Object.entries(projection.inventory.byDomain).map(([id, count]) => [plain(labels.get(id) ?? id), number(count)]))}
|
||||
${table(['Source Tool', 'Anzahl'], Object.entries(projection.inventory.bySource).map(([tool, count]) => [plain(tool), number(count)]))}</section>
|
||||
<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>
|
||||
<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>
|
||||
${projection.gpo ? table(['Verdict / Diagnostic', 'Anzahl'], [
|
||||
['Deviation', projection.gpo.counts.Fail], ['Passed', projection.gpo.counts.Pass],
|
||||
['Not configured · score-neutral', projection.gpo.counts['Not configured']],
|
||||
['No baseline rule · score-neutral', projection.gpo.counts['No baseline rule']],
|
||||
['Not comparable · score-neutral', projection.gpo.counts['Not comparable']],
|
||||
['Review required · unklar', projection.gpo.counts['Review required']],
|
||||
['Conflicts', projection.gpo.conflicts], ['Duplicates', projection.gpo.duplicates]
|
||||
].map(([label, count]) => [plain(label), number(Number(count))])) : ''}
|
||||
${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>'}
|
||||
${projection.policyEntries.map(item => `<section class="source"><h3>${plain(item.title)}</h3>${sourceSections(policySources.get(item.id) ?? [])}</section>`).join('')}</section>
|
||||
<section class="block"><h2>Konvergenz-Nachweis</h2>
|
||||
${table(['Security Domain', 'Quellen mit Deviations', 'Quellen', 'Evidence'], projection.convergence.map(item => [
|
||||
plain(labels.get(item.domainId) ?? item.domainId), number(item.sourceCount),
|
||||
plain(item.sources.filter(source => source.deviations > 0).map(source => source.toolId).join(', ')),
|
||||
item.evidence === 'shared-framework-reference' ? 'Gemeinsame exakte Framework-Referenz' : 'Nur Domain-Nähe; Validierung erforderlich'
|
||||
]))}
|
||||
${projection.bundles.length ? table(['Framework-Referenz', 'Source Tools', 'Result-IDs'], projection.bundles.map(item => [
|
||||
plain(item.key), plain(item.toolIds.join(', ')), plain(item.members.join(', '))
|
||||
])) : ''}</section>
|
||||
<section class="block"><h2>Scope-Grenzen</h2>
|
||||
<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>
|
||||
<section class="block"><h2>Diagnostics</h2>${diagnosticRows.length ? table(['Code', 'Resolver-Grund', 'Herkunft', 'Anzahl'], diagnosticRows) : '<p>Keine aggregierten Diagnostics übermittelt.</p>'}</section>
|
||||
<section class="block"><h2>Anhang: vollständiger Result-Index</h2>
|
||||
${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>
|
||||
</body></html>`;
|
||||
const dated = projection.scoreSources.filter(source => day(source.observedAt));
|
||||
const sourceLine = dated.length
|
||||
? dated.map(source => `${escapeHtml(toolLabel(source.toolId))}: ${day(source.observedAt)}`).join(' • ')
|
||||
: projection.scoreSources.map(source => escapeHtml(toolLabel(source.toolId))).join(' • ');
|
||||
const gpo = projection.gpo;
|
||||
const convergence = projection.convergence.filter(item => item.sourceCount > 0);
|
||||
const flagged = gpoItems.some(item => item.entry?.conflict || item.entry?.duplicate);
|
||||
|
||||
const body = `<header class="doc-head">
|
||||
<p class="doc-head__kicker">ASSESSOR · Technical Report</p>
|
||||
<h1>${escapeHtml(projection.project.name)}</h1>
|
||||
${sourceLine ? `<p class="doc-head__sources">${sourceLine}</p>` : ''}
|
||||
<p class="doc-head__meta">Assessment ${plain(projection.assessmentId)} · Stand ${when(projection.generatedAt)}</p>
|
||||
<p class="note">Frozen Snapshot des gewählten Timeslots. GPO-Aussagen beruhen auf Backupvergleichen; der effektive Endpunktzustand ist nicht verifiziert.</p>
|
||||
</header>
|
||||
|
||||
<section class="section" aria-labelledby="overview">
|
||||
<div class="section__head"><h2 id="overview">Überblick</h2></div>
|
||||
<div class="grid overview-grid">
|
||||
<article class="card card--dist">
|
||||
<header class="card__head"><h3 class="card__title">Scan-Deviations</h3><span class="card__meta">${escapeHtml(scanTools.map(toolLabel).join(', ') || '—')}</span></header>
|
||||
${severityOverview(scanSeverities, 'Deviations')}
|
||||
</article>
|
||||
<article class="card overview-score">
|
||||
<header class="card__head" style="align-self:stretch"><h3 class="card__title">Overall Score</h3></header>
|
||||
${scoreDonut(projection.overallScore, 'Overall Score')}
|
||||
<ul class="sources-list">${projection.scoreSources.map(source => `<li>
|
||||
<span>${escapeHtml(toolLabel(source.toolId))}</span>
|
||||
<span class="sources-list__value">${typeof source.score === 'number' ? number(Math.round(source.score)) : '—'}</span>
|
||||
</li>`).join('')}</ul>
|
||||
</article>
|
||||
<article class="card env">
|
||||
<header class="card__head"><h3 class="card__title">Umgebung</h3></header>
|
||||
${projection.environment.some(fact => present(fact.value))
|
||||
? `<dl>${projection.environment.filter(fact => present(fact.value)).map(fact => `<div><dt>${escapeHtml(fact.key)}</dt><dd>${plain(fact.value)}</dd></div>`).join('')}</dl>`
|
||||
: '<p class="empty">Keine Umgebungsdaten übermittelt.</p>'}
|
||||
</article>
|
||||
</div>
|
||||
<div class="grid grid--2" style="margin-top:16px">
|
||||
<article class="card">
|
||||
<header class="card__head"><h3 class="card__title">Scan-Deviations je Security Domain</h3></header>
|
||||
${barList(groupByDomain(scanDeviations).map(group => ({ label: group.label, value: group.items.length })), 'Keine Scan-Deviations.')}
|
||||
</article>
|
||||
${gpo ? `<article class="card">
|
||||
<header class="card__head"><h3 class="card__title">GPO Policy Settings</h3><span class="card__meta">${number(projection.policyEntries.length)} Settings</span></header>
|
||||
${barList([
|
||||
{ label: 'Deviation', value: gpo.counts.Fail }, { label: 'Passed', value: gpo.counts.Pass },
|
||||
{ label: 'Review required', value: gpo.counts['Review required'] },
|
||||
{ label: 'Not configured · score-neutral', value: gpo.counts['Not configured'] },
|
||||
{ label: 'No baseline rule · score-neutral', value: gpo.counts['No baseline rule'] },
|
||||
{ label: 'Not comparable · score-neutral', value: gpo.counts['Not comparable'] }
|
||||
], '')}
|
||||
<p class="note">Diagnostics: ${number(gpo.conflicts)} Conflicts · ${number(gpo.duplicates)} Duplicates · ${number(gpo.affectedBackups)} betroffene GPO Backups.
|
||||
${gpo.scopeLimited ? 'Die Auswertung bezieht sich ausschließlich auf den importierten GPO-Backup-Satz.' : ''}</p>
|
||||
</article>` : ''}
|
||||
</div>
|
||||
</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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ export function recordTitleFor(record: FindingRecord, selectedColumn = '', fallb
|
|||
const profileTitle = titlePatterns
|
||||
.map(pattern => record.fields.find(field => field.value && matches(pattern, field.label))?.value)
|
||||
.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
|
||||
?? (selectedColumn && record.fields.find(field => field.label === selectedColumn)?.value)
|
||||
?? record.title;
|
||||
|| (selectedColumn && record.fields.find(field => field.label === selectedColumn)?.value)
|
||||
|| record.title;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const components = readFileSync(new URL('../../src/styles/components.css', impor
|
|||
|
||||
describe('ASSESSOR version in the brand header', () => {
|
||||
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('aria-label="About ASSESSOR"');
|
||||
expect(app).toContain("import assessorLogo from '../assets/branding/assessor-icon-transparent.png';");
|
||||
|
|
|
|||
|
|
@ -44,14 +44,22 @@ const finding = (overrides = {}) => ({
|
|||
});
|
||||
|
||||
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());
|
||||
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(/https?:\/\//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', () => {
|
||||
|
|
@ -142,6 +150,30 @@ describe('management overview renderer', () => {
|
|||
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', () => {
|
||||
expect(buildManagementOverviewHtml(projection())).not.toContain('Remediation-Status');
|
||||
});
|
||||
|
|
@ -198,8 +230,8 @@ describe('management overview structure', () => {
|
|||
expect([...document.querySelectorAll('h1')].map(node => node.textContent))
|
||||
.toEqual(['Synthetic GmbH']);
|
||||
expect([...document.querySelectorAll('h2')].map(node => node.textContent)).toEqual([
|
||||
'Overall Posture', 'Konvergenz der Quellen', 'Abdeckung und Lücken',
|
||||
'Priority Findings', 'Maßnahmenbündel', 'Remediation-Status', 'Source Overview'
|
||||
'Overall Posture', 'Priority Findings', 'Remediation-Status', 'Abdeckung und Lücken',
|
||||
'Konvergenz der Quellen', 'Maßnahmenbündel', 'Source Overview'
|
||||
]);
|
||||
// Every section is labelled for assistive technology.
|
||||
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 { document } = new JSDOM(buildManagementOverviewHtml(projection({
|
||||
coverage: {
|
||||
|
|
@ -215,7 +247,6 @@ describe('management overview structure', () => {
|
|||
executionErrors: 0, unknown: 0, perSource: [{ toolId: 'alpha', total: 1, assessed: 1, notRun: 0 }]
|
||||
}
|
||||
}))).window;
|
||||
expect(document.querySelectorAll('.tile table')).toHaveLength(0);
|
||||
expect(document.querySelectorAll('table table')).toHaveLength(0);
|
||||
});
|
||||
|
||||
|
|
@ -229,6 +260,49 @@ describe('management overview structure', () => {
|
|||
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 () => {
|
||||
const { JSDOM } = await import('jsdom');
|
||||
const { document } = new JSDOM(buildManagementOverviewHtml(projection({ overallScore: 140 }))).window;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,182 @@ describe('Technical Report', () => {
|
|||
expect(html).toContain(verdict);
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ describe('Client Info report buttons', () => {
|
|||
expect(management.text).not.toContain('dummy-artifact-secret');
|
||||
expect(technical.text).toContain('DUMMY-RULE-123');
|
||||
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(); });
|
||||
host.remove();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,16 @@ describe('mixed Work record titles', () => {
|
|||
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', () => {
|
||||
const generic = record([['Name', 'Synthetic finding'], ['Value', '42']]);
|
||||
expect(recordTitleFor(generic, 'Name', 'generic')).toBe('Synthetic finding');
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ describe('local ASSESSOR HTTP server', () => {
|
|||
|
||||
const health = await fetch(`${baseUrl}/api/v1/health`);
|
||||
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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue