diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index d5aabc5..90cac5b 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -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. diff --git a/docs/REPORT_STRATEGY.md b/docs/REPORT_STRATEGY.md index 30e34a5..609c65e 100644 --- a/docs/REPORT_STRATEGY.md +++ b/docs/REPORT_STRATEGY.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 4849588..81639bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ce95e5e..c2510ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "assessor", - "version": "2.6.0", + "version": "2.6.1", "private": true, "type": "module", "scripts": { diff --git a/src/exports/management-overview-report.ts b/src/exports/management-overview-report.ts index be13b47..c26532a 100644 --- a/src/exports/management-overview-report.ts +++ b/src/exports/management-overview-report.ts @@ -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 = { - 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 `
- - - ${hasScore ? `` : ''} - -
- ${escapeHtml(display)}${hasScore ? '/100' : ''} - ${escapeHtml(caption)} -
-
`; +function severityCounts(findings: ReportFindingRef[]): Record { + const counts = Object.fromEntries(SEVERITY_ORDER.map(key => [key, 0])) as Record; + 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 { ${escapeHtml(domain.domainLabel)} - ${known ? `${percent(domain.passRate)} %` : DASH} - ${integer(domain.deviations)} Deviations von ${integer(domain.assessed)} bewertet + ${known ? `${percent(domain.passRate)} %` : DASH} + ${domain.assessed + ? `${integer(domain.deviations)} Deviations von ${integer(domain.assessed)} bewertet` + : 'nicht bewertet'} `; }).join('')}`; } -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 `
  • - + return `
  • ${escapeHtml(finding.title)} - ${showSeverity ? `${escapeHtml(SEVERITY_LABEL[severity])}` : ''} ${ranked && finding.rankValue !== undefined && finding.rankBasis.kind === 'numeric' - ? `${escapeHtml(finding.rankBasis.property)} ${integer(finding.rankValue)}` : ''} + ? `${escapeHtml(finding.rankBasis.property)} ${integer(finding.rankValue)}` : ''} + ${severityText(severity)}
  • `; } function prioritySection(priorities: ReportDomainPriority[]): string { - if (!priorities.length) return '

    Es wurde keine Deviation festgestellt.

    '; - return priorities.map(domain => `
    -
    -

    ${escapeHtml(domain.domainLabel)}

    + if (!priorities.length) return '

    Es wurde keine Deviation festgestellt.

    '; + return priorities.map((domain, domainIndex) => { + const member = `domain-${domainIndex}`; + return `
    + + ${escapeHtml(domain.domainLabel)} ${integer(domain.deviations)} Deviations -
    - ${domain.sources.map(source => `
    -
    - ${escapeHtml(source.toolId)} - ${basisLabel(source.basis)} + + ${domain.sources.map(source => `
    +
    + ${escapeHtml(toolLabel(source.toolId))} + ${basisLabel(source.basis)}
    -
      ${source.shown.map(item => findingRow(item, source.ranked)).join('')}
    +
      ${source.shown.map(item => findingRow(item, source.ranked, member)).join('')}
    ${source.shown.length < source.total - ? `

    ${integer(source.total - source.shown.length)} weitere Deviations dieser Quelle stehen im Technical Report.

    ` + ? `

    ${integer(source.total - source.shown.length)} weitere Deviations dieser Quelle stehen im Technical Report.

    ` : ''}
    `).join('')} -
    `).join(''); + `; + }).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 `
    -
    -

    GPO & Baseline

    - ${integer(gpo.counts.Fail)} Deviations -
    -

    ${escapeHtml(gpo.basis.reason)} Die Einträge stehen deshalb gleichgewichtig nebeneinander.

    -
    ${rows.map(([label, count]) => `
    -
    ${escapeHtml(label)}
    ${integer(count)}
    -
    `).join('')}
    -

    - Betroffene Policy Settings: ${integer(gpo.affectedSettings)} · - betroffene GPO Backups: ${integer(gpo.affectedBackups)} · - Conflicts: ${integer(gpo.conflicts)} · - Duplicates: ${integer(gpo.duplicates)} -

    -

    - Not configured, No baseline rule und Not comparable - sind score-neutral und begründen keine Maßnahme. - ${gpo.scopeLimited + return `

    +
    +
    +

    GPO & Baseline

    + ${integer(gpo.counts.Fail)} Deviations +
    + ${barList(rows.map(([label, count]) => ({ label, value: count })), 'Keine GPO Policy Settings bewertet.')} +

    ${escapeHtml(gpo.basis.reason)} Die Einträge stehen deshalb gleichgewichtig nebeneinander.

    +
    +
    +

    Betroffene GPO-Objekte

    +
    +
    Policy Settings mit Deviation${integer(gpo.affectedSettings)}
    +
    betroffene GPO Backups${integer(gpo.affectedBackups)}
    +
    Conflicts${integer(gpo.conflicts)}
    +
    Duplicates${integer(gpo.duplicates)}
    +
    +

    + Not configured, No baseline rule und Not comparable + 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.' : ''} -

    +

    +
    +
    `; +} + +function kpiTile(label: string, figure: string, caption: string): string { + return `
    + ${escapeHtml(label)} + ${figure} + ${caption}
    `; } +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 = `
    -

    ASSESSOR · Management Overview

    + const head = `
    +

    ASSESSOR · Management Overview

    ${escapeHtml(projection.project.name)}

    + ${sourceLine ? `

    ${sourceLine}

    ` : ''}
    - ${facts.map(fact => `
    + ${facts.map(fact => `
    ${escapeHtml(fact.key)}
    ${value(fact.value)}
    `).join('')} -
    Assessment
    ${value(projection.assessmentId)}
    -
    Berichtsdatum
    ${germanDateTime(projection.generatedAt)}
    +
    Assessment
    ${value(projection.assessmentId)}
    +
    Berichtsdatum
    ${germanDateTime(projection.generatedAt)}
    `; - const posture = `
    -

    Overall Posture

    -
    -
    - ${donut(projection.overallScore, 'Overall Score')} -
    -
    -

    Security Domains

    + const posture = `
    +

    Overall Posture

    +
    + ${kpiTile('Deviations', integer(scanFindings.length + gpoSettings), deviationLine)} + ${kpiTile('Critical / High', integer(criticalHigh), 'Scan-Deviations mit hoher nativer Kritikalität')} +
    + Overall Score + ${scoreDonut(projection.overallScore, 'Overall Score')} +
    +
    +
    +
    +

    Security Domains

    Pass Rate je Domain
    ${domainSpine(projection.domains)} +
    +
    +
    +

    Severity-Verteilung

    ${integer(scanFindings.length)} Scan-Deviations
    + ${barList(SEVERITY_ORDER.filter(key => key !== 'unknown' || severities.unknown > 0) + .map(key => ({ label: SEVERITY_LABEL[key], value: severities[key], tone: key })), 'Keine Deviations.')} +
    +
    +

    Deviations je Quelle

    + ${barList(projection.scoreSources.map(source => ({ label: toolLabel(source.toolId), value: source.deviations })), 'Keine Quelle bewertet.')} +

    @@ -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“.

    `; - const priorities = `
    -

    Priority Findings

    -

    + // 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 = `

    +

    Priority Findings

    ↑ Nach oben
    +

    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.

    -
    - ${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] })) + }] + }) : ''} +
    +
    Findings ›Nach Security Domain + ${integer(shownFindings)}
    + ${prioritySection(priorityDomains)}
    + ${projection.gpo ? '

    GPO-Deviations tragen keine native Kritikalität und stehen gleichgewichtig im Block GPO & Baseline.

    ' : ''} + ${projection.gpo ? gpoSection(projection.gpo) : ''}
    `; - const convergence = `
    -

    Konvergenz der Quellen

    -

    Mehrere Quellen in einer Security Domain belegen nicht automatisch denselben Finding.

    -
    ${projection.convergence.filter(item => item.sourceCount > 0).map(item => `
    -

    ${value(projection.domains.find(domain => domain.domainId === item.domainId)?.domainLabel ?? item.domainId)}

    + const convergence = `
    +

    Konvergenz der Quellen

    +

    Mehrere Quellen in einer Security Domain belegen nicht automatisch denselben Finding.

    +
    ${projection.convergence.filter(item => item.sourceCount > 0).map(item => `
    +

    ${value(projection.domains.find(domain => domain.domainId === item.domainId)?.domainLabel ?? item.domainId)}

    + ${integer(item.sourceCount)} Quellen

    ${integer(item.sourceCount)} unabhängige Quellen mit Deviations: ${item.sources.filter(source => source.deviations > 0) - .map(source => escapeHtml(source.toolId)).join(', ')}.

    + .map(source => escapeHtml(toolLabel(source.toolId))).join(', ')}.

    ${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.'}

    -
    `).join('') || '

    Keine quellenübergreifende Konvergenz belegt.

    '}
    + ? '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.'}

    +
    `).join('') || '

    Keine quellenübergreifende Konvergenz belegt.

    '}
    `; - const bundles = `
    -

    Maßnahmenbündel

    -

    Gleiche Framework-Referenzen gruppieren Findings ohne Aussage zu Aufwand, Nutzen oder Priorität.

    - ${projection.bundles.length ? ` - ${projection.bundles.map(bundle => ``).join('')}
    Framework-ReferenzFindings
    ${escapeHtml(bundle.key)}${integer(bundle.members.length)}
    ` + const bundles = `
    +

    Maßnahmenbündel

    +

    Gleiche Framework-Referenzen gruppieren Findings ohne Aussage zu Aufwand, Nutzen oder Priorität.

    +
    + ${projection.bundles.length ? `
    + ${projection.bundles.map(bundle => ``).join('')}
    Framework-ReferenzFindings
    ${escapeHtml(bundle.key)}${integer(bundle.members.length)}
    ` : '

    Keine mehrfach belegte Framework-Referenz.

    '} -

    Einzelquellen-GPO-Maßnahmen ohne Conflict oder Duplicate: ${integer(projection.singleSourcePolicyActions.length)}.

    +

    Einzelquellen-GPO-Maßnahmen ohne Conflict oder Duplicate: ${integer(projection.singleSourcePolicyActions.length)}.

    +
    `; - const remediationSection = remediation ? `
    -

    Remediation-Status

    -

    + const remediationSection = remediation ? `

    +

    Remediation-Status

    +

    Selbstauskunft des Kunden aus der Arbeitsgrundlage. Diese Angaben sind kein technischer Nachweis: Eine Maßnahme gilt erst dann als behoben, wenn ein Rescan das belegt.

    -
    -
    ${integer(remediation.items)}Maßnahmen in Arbeit
    -
    ${integer(remediation.withoutOwner)}ohne Owner
    -
    ${integer(remediation.withoutDue)}ohne Termin
    -
    ${integer(remediation.riskAccepted)}Accepted Risk
    +
    + ${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')}
    - ${Object.keys(remediation.byStatus).length ? `
    ${Object.entries(remediation.byStatus) - .map(([status, count]) => `
    ${escapeHtml(status)}
    ${integer(count)}
    `) - .join('')}
    ` : ''} + ${Object.keys(remediation.byStatus).length ? `
    +

    Status

    + ${barList(Object.entries(remediation.byStatus).map(([status, count]) => ({ label: status, value: count })), '')} +
    ` : ''}
    ` : ''; - const coverageSection = `
    -

    Abdeckung und Lücken

    -
    -
    ${integer(coverage.assessed)}bewertet
    -
    ${integer(coverage.deviations)}Deviations
    -
    ${integer(coverage.notRun)}not run
    -
    ${integer(coverage.notApplicable)}not applicable
    -
    ${integer(coverage.executionErrors)}execution errors
    + const coverageSection = `
    +

    Abdeckung und Lücken

    +
    + ${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')}

    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.

    - - - - ${coverage.perSource.map(source => ` - - - - - - `).join('')} -
    Abdeckung je Quelle
    QuelleErgebnissebewertetnot runnot run Anteil
    ${escapeHtml(source.toolId)}${integer(source.total)}${integer(source.assessed)}${integer(source.notRun)}${source.total ? `${percent(source.notRun / source.total)} %` : DASH}
    +
    +

    Abdeckung je Quelle

    +
    + + ${coverage.perSource.map(source => ` + + + + + + `).join('') || ``} +
    QuelleErgebnissebewertetnot runnot run Anteil
    ${escapeHtml(toolLabel(source.toolId))}${integer(source.total)}${integer(source.assessed)}${integer(source.notRun)}${source.total ? `${percent(source.notRun / source.total)} %` : DASH}
    ${DASH}
    +
    `; - const sources = `
    -

    Source Overview

    - - - - - - - ${projection.scoreSources.length ? projection.scoreSources.map(source => ` - - - - - - - `).join('') : ``} -
    Score-Beitrag je Quelle
    QuelleStandBeitragMethodePassDeviations
    ${escapeHtml(source.toolId)}${germanDate(source.observedAt)}${typeof source.score === 'number' ? integer(Math.round(source.score)) : DASH}${escapeHtml(SCORE_METHOD_LABEL[source.method])}${integer(source.passed)}${integer(source.deviations)}
    ${DASH}
    - ${projection.scoreSources.some(source => source.method === 'inverted-native') + const sources = `
    +

    Source Overview

    +
    +

    Score-Beitrag je Quelle

    +
    + + + + + ${projection.scoreSources.length ? projection.scoreSources.map(source => ` + + + + + + + `).join('') : ``} +
    QuelleStandBeitragMethodePassDeviations
    ${escapeHtml(toolLabel(source.toolId))}${germanDate(source.observedAt)}${typeof source.score === 'number' ? integer(Math.round(source.score)) : DASH}${escapeHtml(SCORE_METHOD_LABEL[source.method])}${integer(source.passed)}${integer(source.deviations)}
    ${DASH}
    + ${projection.scoreSources.some(source => source.method === 'inverted-native') ? '

    Eine invertierte Quelle liefert nativ einen Risk-Wert, bei dem ein höherer Wert schlechter ist. Er geht als 100 − Risk in den Index ein; der rohe Risk-Wert bleibt in der Quelle erhalten.

    ' : ''} +
    `; - return ` - - - - -Management Overview · ${escapeHtml(projection.project.name)} - - - -${head} + return reportDocument( + `Management Overview · ${projection.project.name}`, + `${head} ${posture} -${convergence} -${coverageSection} ${priorities} -${bundles} ${remediationSection} -${sources} - -`; +${coverageSection} +${convergence} +${bundles} +${sources}`, + MANAGEMENT_CSS + ); } diff --git a/src/exports/report-theme.ts b/src/exports/report-theme.ts new file mode 100644 index 0000000..85eca48 --- /dev/null +++ b/src/exports/report-theme.ts @@ -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 = { + 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 `${escapeHtml(SEVERITY_LABEL[severity])}`; +} + +const FORMAT_TAG = /<(\/?)(p|br|b|strong|i|em|u|ul|ol|li)\s*\/?>/gi; +const FORMAT_NAME: Record = { 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' ? '
    ' : `<${close}${tag}>`; + }); + const body = /<(p|ul|ol|li)>/.test(html) ? html : textBlocks(html); + return `
    ${body}
    `; +} + +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(`

    ${paragraph.join('
    ')}

    `); + paragraph = []; + }; + const table = (head: string[], bodyRows: string[][]) => + `
    ${head.map(cell => ``).join('')} + ${bodyRows.map(row => `${row.map(cell => ``).join('')}`).join('')}
    ${cell}
    ${cell}
    `; + 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 `
    + + + ${hasScore ? `` : ''} + +
    + ${escapeHtml(display)}${hasScore ? '/100' : ''} + ${escapeHtml(caption)} +
    +
    `; +} + +/** 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 = ``; + offset += length; + return arc; + }).join('') : ''; + return `
    + + ${arcs} + +
    + ${integer(total)} + ${escapeHtml(caption)} +
    +
    `; +} + +/** Severity legend beside a distribution donut, with the large total. */ +export function severityOverview(counts: Record, caption: string): string { + const segments = SEVERITY_ORDER.map(key => ({ key, value: counts[key] ?? 0 })); + return `
    + ${distributionDonut(segments, caption)} +
      ${segments.filter(segment => segment.key !== 'unknown' || segment.value > 0).map(segment => `
    • + + ${escapeHtml(SEVERITY_LABEL[segment.key])} + ${integer(segment.value)} +
    • `).join('')}
    +
    `; +} + +/** 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 `

    ${escapeHtml(empty)}

    `; + const max = Math.max(...rows.map(row => row.value), 0); + return `
      ${rows.map(row => { + const width = max > 0 ? Math.round(row.value / max * 1000) / 10 : 0; + return `
    • + ${escapeHtml(row.label)} + + ${integer(row.value)} +
    • `; + }).join('')}
    `; +} + +/** + * 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 ``; +} + +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 ` + + + + + +${escapeHtml(title)} + + + +
    +${body} +
    + + +`; +} diff --git a/src/exports/technical-report.ts b/src/exports/technical-report.ts index b24bc20..a895499 100644 --- a/src/exports/technical-report.ts +++ b/src/exports/technical-report.ts @@ -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 = (items: T[], key: (item: T) => string = item => JSON.stringify(item)): T[] => { + const seen = new Set(); + 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 `
    ${headers.map(label => ``).join('')} - ${rows.map(row => `${row.map(cell => ``).join('')}`).join('')}
    ${escapeHtml(label)}
    ${cell}
    `; + ${rows.map((row, index) => `${row.map(cell => `${cell}`).join('')}`).join('')}
    `; } +/** 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 `
    ${shown.map(([label, value, mono]) => + `
    ${escapeHtml(label)}
    ${plain(value)}`).join('')}
    `; +} + +const block = (label: string, content: string, className = '') => content + ? `

    ${escapeHtml(label)}

    ${content}
    ` : ''; + +const INSTRUCTION_LABEL: Record = { + 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 = `
      ${value.map(item => `
    • ${sourceValue(item, [], [])}
    • `).join('')}
    `; return markedFields.includes('value') && !markedItems.length ? `
    ${members}
    ` : members; } if (value && typeof value === 'object') { - const 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 = `
    ${entries.map(([key, item]) => { const marked = markedFields.includes(key) || (markedFields.includes('value') && key === 'value'); - return `
    ${escapeHtml(key)}
    ${sourceValue(item, [], markedItems)}
    `; + return `
    ${escapeHtml(INSTRUCTION_LABEL[key] ?? key)}
    ${sourceValue(item, [], markedItems)}
    `; }).join('')}
    `; return markedFields.includes('value') && !Object.hasOwn(value, 'value') && !markedItems.length ? `
    ${instruction}
    ` : instruction; @@ -34,121 +85,447 @@ function sourceValue(value: JsonValue, markedFields: readonly string[], markedIt } function sourceSections(sources: PolicySourceEvidence[]): string { - if (!sources.length) return '

    Keine einzelnen GPO-Quellanweisungen in diesem Snapshot verfügbar.

    '; - return sources.map(source => `
    -
    GPO Backup ${plain(source.gpoId)} · ${plain(source.nativeBackupId)}
    -
    Backup-Instanz
    ${plain(source.backupInstanceId)}
    -
    Evidence
    ${plain(source.sourceReference)}
    -
    Quellreihenfolge
    ${plain(source.sequence)}
    - ${source.values.map((value, index) => `
    Anweisung ${number(index + 1)} + return sources.map(source => `
    +
    GPO ${plain(source.gpoId ?? source.nativeBackupId ?? source.backupInstanceId)}
    + ${escapeHtml(source.sourceReference)}${source.backupInstanceId ? ` · Backup ${escapeHtml(source.backupInstanceId)}` : ''}${typeof source.sequence === 'number' ? ` · Reihenfolge ${number(source.sequence)}` : ''} + ${source.values.map((value, index) => `
    + ${source.values.length > 1 ? `

    Anweisung ${number(index + 1)}

    ` : ''} ${sourceValue(value, source.deviationFields?.[index] ?? [], source.deviationItems?.[index] ?? [])}
    `).join('')} -
    `).join(''); +
    `).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 `
    -

    ${plain(result.title)}

    ${plain(result.sourceTool)} · ${plain(result.severityNormalized)} · ${plain(result.outcomeNormalized)}
    -
    Security Domain
    ${escapeHtml(domainLabel)}
    -
    Source Rule ID
    ${plain(result.sourceRuleId)}
    -
    Technische Identität
    ${plain(result.policyIdentityKey ?? result.canonicalDefinitionId)}
    -
    Scope
    ${plain(result.policyScope ?? result.affectedAsset)}
    -
    Result Status
    ${plain(result.policyClassification ?? result.outcomeNormalized)}
    -
    Baseline
    ${plain(result.policyBaselineLabel)}
    - ${result.description ? `

    ${plain(result.description)}

    ` : ''} - ${result.recommendation ? `

    Empfehlung: ${plain(result.recommendation)}

    ` : ''} - ${actual !== undefined || expected !== undefined ? table(['Konfiguriert', 'Erwartet', 'Vergleich'], [[plain(actual), plain(expected), plain(result.policyComparisonRelation)]]) - : '

    Diese Quelle liefert hier keinen strukturierten Ist-/Sollwert. Es wird keiner abgeleitet.

    '} - ${variants ? `
    Baseline-Varianten
    ${plain(variants)}
    ` : ''} - ${result.policyDiagnosticCodes?.length ? `

    Diagnostics: ${result.policyDiagnosticCodes.map(plain).join(', ')}

    ` : ''} - ${result.frameworkReferences.length ? `
    Framework-Referenzen (exakte Quellwerte)
    ${table(['Namespace', 'Referenz'], - result.frameworkReferences.map(reference => [plain(reference.namespace), plain(reference.value)]))}` : ''} - ${fields?.length ? `
    Originale technische Felder
    ${table(['Feld', 'Wert'], fields.map(field => [plain(field.label), plain(field.value)]))}` : ''} - ${review?.nextStep ? `

    Geplanter Remediation-Schritt (unverifizierte Selbstauskunft): ${plain(review.nextStep)}

    ` : ''} -

    Result-ID ${plain(result.resultProjectionId)} · ScanRun ${plain(result.scanRunId)} · ImportArtifact ${plain(result.importArtifactId)} · Source Locator ${plain(result.sourceLocator)}

    -
    `; +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 = { + 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 = { + 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 `
    + ${present(result.description) ? block('Beschreibung', richText(result.description!)) : ''} + ${found ? block('Befund', richText(found.value)) : ''} + ${hasValues ? block('Ist- und Sollwert', `
    +

    Konfiguriert

    ${plain(result.policyActualValue)}
    +

    Erwartet

    ${plain(result.policyBaselineValue)}
    `) : ''} + ${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', `
    ${references.map(reference => `${escapeHtml(reference.value)}`).join('')}
    `) : ''} +
    Technische Details
    + ${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 ? `

    Originale Felder der Quelle

    ${remaining.map(field => + `
    ${escapeHtml(field.label)}
    ${richText(field.value)}
    `).join('')}
    ` : ''} +

    Result-ID ${plain(result.resultProjectionId)}${result.scanRunId ? ` · ScanRun ${plain(result.scanRunId)}` : ''}${result.importArtifactId ? ` · ImportArtifact ${plain(result.importArtifactId)}` : ''}

    +
    +
    `; +} + +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 '

    Keine Scan-Deviations in diesem Snapshot.

    '; + return `
    ${groups.map((group, groupIndex) => { + const member = `scan-${groupIndex}`; + return `
    ${escapeHtml(group.label)} ${number(group.items.length)} + ${group.items.map(item => { + const severity = severityOf(item); + return `
    + + + +

    ${plain(item.title)}

    + ${escapeHtml(toolLabel(item.sourceTool))}${present(item.affectedAsset) ? ` · ${plain(item.affectedAsset)}` : ''} +
    + ${severityText(severity)} + +
    + ${detail(item, group.label)} +
    `; + }).join('')}
    `; + }).join('')}
    `; +} + +/** 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): GpoFinding[] { + const failing = entries.filter(entry => entry.verdict === 'Fail'); + const byKey = new Map(); + 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 + ? `

    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.

    ` : ''; + const flags = [ + entry?.conflict ? '

    Mehrere GPOs setzen diese Einstellung unterschiedlich (Conflicts). Vor der Änderung klären, welche GPO für die betroffenen Systeme maßgeblich ist.

    ' : '', + entry?.duplicate ? '

    Die Einstellung ist in mehreren GPOs gleich gesetzt (Duplicates); eine Bereinigung vereinfacht die Pflege.

    ' : '' + ].join(''); + return `
    + ${description ? block('Beschreibung', richText(description)) : ''} + ${block('Befund', `${relations.length === 1 && RELATION_LABEL[relations[0]] ? `

    ${RELATION_LABEL[relations[0]]}

    ` : ''} +
    +

    Erwartet laut Baseline

    + ${baselines.length ? `

    ${baselines.map(escapeHtml).join(' · ')}

    ` : ''} + ${expected.length ? expected.map(value => sourceValue(value, [], [])).join('') : '

    Kein Baseline-Wert übermittelt.

    '}
    +

    Konfiguriert

    + ${sources.length ? sourceSections(sources) : configured.length ? configured.map(value => sourceValue(value, [], [])).join('') + : '

    Keine einzelnen GPO-Quellanweisungen in diesem Snapshot verfügbar.

    '}
    +
    `)} + ${recommendation || fix || flags ? block('Lösung', `${recommendation ? richText(recommendation) : ''}
    ${fix}${flags}
    `, 'solution') : ''} + ${references.length ? block('Referenzen', `
    ${references.map(reference => `${escapeHtml(reference.value)}`).join('')}
    `) : ''} +
    Technische Details
    + ${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) ? `

    Baseline-Varianten (Rohdaten)

    +
    ${plain(results.map(result => result.policyBaselineVariantAssessment).filter(Boolean))}
    ` : ''} +
    +
    `; +} + +/** + * 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 `
    + + + +

    ${escapeHtml(finding.title)}

    + ${[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'} +
    + ${finding.entry?.conflict ? 'Conflicts' : ''}${finding.entry?.duplicate ? 'Duplicates' : ''} + +
    + ${gpoDetail(finding)} +
    `; +} + +function gpoList(findings: GpoFinding[]): string { + if (!findings.length) return '

    Keine GPO-Deviations in diesem Snapshot.

    '; + 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 `
    ${sections.map((section, index) => { + const member = `gpo-${index}`; + const items = findings.filter(finding => gpoSection(finding.title) === section); + return `
    ${escapeHtml(section)} ${number(items.length)} + ${items.map(finding => gpoRow(finding, member)).join('')}
    `; + }).join('')}
    `; +} + +const NEUTRAL_VERDICTS = new Set(['Not configured', 'No baseline rule', 'Not comparable']); +const VERDICT_LABEL: Record = { + 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 = ['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 { + const groups = REGISTER_ORDER.map(verdict => ({ verdict, items: entries.filter(entry => entry.verdict === verdict) })) + .filter(group => group.items.length); + if (!groups.length) return '

    Keine weiteren gerollten GPO Policy Settings im gewählten Assessment vorhanden.

    '; + return `
    ${groups.map(group => { + const member = `register-${filterToken(group.verdict)}`; + const collapsed = group.verdict === 'Not configured' || group.items.length > 50; + return `
    + ${escapeHtml(VERDICT_LABEL[group.verdict])} ${number(group.items.length)} + ${NEUTRAL_VERDICTS.has(group.verdict) ? 'score-neutral, keine Maßnahme' : ''} + ${group.items.map(item => { + const flags = [item.conflict ? 'conflict' : '', item.duplicate ? 'duplicate' : ''].filter(Boolean).join(' '); + const itemSources = sources.get(item.id) ?? []; + const head = ` +

    ${plain(item.title)}

    + ${present(item.scope) ? `Scope ${plain(item.scope)}` : ''}
    + ${item.conflict ? 'Conflicts' : ''}${item.duplicate ? 'Duplicates' : ''}`; + const attributes = `data-filter-item data-filter-member="${member}" data-flags="${flags}"`; + return itemSources.length + ? `
    ${head} +
    ${block('Konfiguriert', sourceSections(itemSources))} +
    Technische Details
    ${keyValues([ + ['Technische Identität', item.identity, true], ['GPO Backups', item.backupInstanceIds.join(', '), true]])}
    +
    ` + : `
    ${head}
    `; + }).join('')} +
    `; + }).join('')}
    `; +} + +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; + 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 ` - -ASSESSOR · Technical Report · ${escapeHtml(projection.project.name)} - -

    ASSESSOR · Technical Report

    ${escapeHtml(projection.project.name)}

    -

    Assessment ${plain(projection.assessmentId)} · Stand ${when(projection.generatedAt)}

    -

    Frozen Snapshot des gewählten Timeslots. GPO-Aussagen beruhen auf Backupvergleichen; der effektive Endpunktzustand ist nicht verifiziert.

    -

    Scope und Provenienz

    -${provenanceRows.length ? table(['Art', 'ID', 'Version', 'Beobachtet'], provenanceRows) : '

    Keine Provenienz übermittelt.

    '}
    -

    Result-Inventar

    ${number(projection.coverage.total)} Ergebnisse · ${number(projection.coverage.deviations)} Deviations · ${number(projection.coverage.passed)} Passed · ${number(projection.coverage.notRun)} Not run

    -${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)]))}
    -

    Findings im Detail

    ${grouped.length ? grouped.map(group => `

    ${escapeHtml(group.label)}

    ${group.items.map(item => resultDetail(item, group.label, reviews.get(item.resultProjectionId), fields.get(item.resultProjectionId))).join('')}
    `).join('') : '

    Keine Deviations in diesem Snapshot.

    '}
    -

    GPO Policy Register

    Conflicts und Duplicates sind separate Diagnostics, keine Ersatz-Verdicts. Not configured, No baseline rule und Not comparable sind score-neutral.

    -${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) : '

    Keine gerollten GPO Policy Settings im gewählten Assessment vorhanden.

    '} -${projection.policyEntries.map(item => `

    ${plain(item.title)}

    ${sourceSections(policySources.get(item.id) ?? [])}
    `).join('')}
    -

    Konvergenz-Nachweis

    -${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(', ')) -])) : ''}
    -

    Scope-Grenzen

    -

    GPO-Vergleiche gelten nur für das importierte Backup-Set. Ohne RSoP bzw. Group Policy Results wird kein wirksamer Endpoint-Zustand behauptet.

    -

    not_run ist kein Pass. Nicht ausgeführte Checks und unbekannte Werte werden weder als bestanden noch als Deviation gezählt.

    -

    Diagnostics

    ${diagnosticRows.length ? table(['Code', 'Resolver-Grund', 'Herkunft', 'Anzahl'], diagnosticRows) : '

    Keine aggregierten Diagnostics übermittelt.

    '}
    -

    Anhang: vollständiger Result-Index

    -${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))]))}
    -`; + 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 = `
    +

    ASSESSOR · Technical Report

    +

    ${escapeHtml(projection.project.name)}

    + ${sourceLine ? `

    ${sourceLine}

    ` : ''} +

    Assessment ${plain(projection.assessmentId)} · Stand ${when(projection.generatedAt)}

    +

    Frozen Snapshot des gewählten Timeslots. GPO-Aussagen beruhen auf Backupvergleichen; der effektive Endpunktzustand ist nicht verifiziert.

    +
    + +
    +

    Überblick

    +
    +
    +

    Scan-Deviations

    ${escapeHtml(scanTools.map(toolLabel).join(', ') || '—')}
    + ${severityOverview(scanSeverities, 'Deviations')} +
    +
    +

    Overall Score

    + ${scoreDonut(projection.overallScore, 'Overall Score')} +
      ${projection.scoreSources.map(source => `
    • + ${escapeHtml(toolLabel(source.toolId))} + ${typeof source.score === 'number' ? number(Math.round(source.score)) : '—'} +
    • `).join('')}
    +
    +
    +

    Umgebung

    + ${projection.environment.some(fact => present(fact.value)) + ? `
    ${projection.environment.filter(fact => present(fact.value)).map(fact => `
    ${escapeHtml(fact.key)}
    ${plain(fact.value)}
    `).join('')}
    ` + : '

    Keine Umgebungsdaten übermittelt.

    '} +
    +
    +
    +
    +

    Scan-Deviations je Security Domain

    + ${barList(groupByDomain(scanDeviations).map(group => ({ label: group.label, value: group.items.length })), 'Keine Scan-Deviations.')} +
    + ${gpo ? `
    +

    GPO Policy Settings

    ${number(projection.policyEntries.length)} Settings
    + ${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'] } + ], '')} +

    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.' : ''}

    +
    ` : ''} +
    +
    + +
    +

    Scan Findings

    ↑ Nach oben
    +

    Nach Security Domain gruppiert. Ein Klick auf ein Finding zeigt Beschreibung, Befund und Lösung.

    + ${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)} +
    + +
    +

    GPO Findings

    ↑ Nach oben
    +

    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.

    + ${gpoItems.length ? filterToolbar({ + placeholder: 'GPO Findings durchsuchen', + groups: flagged ? [{ key: 'flags', label: 'Diagnostics', values: [{ value: 'conflict', label: 'Conflicts' }, { value: 'duplicate', label: 'Duplicates' }] }] : [] + }) : ''} + ${gpoList(gpoItems)} +
    + +
    +

    GPO Policy Register

    ↑ Nach oben
    +

    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.

    + ${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)} +
    + +
    +

    Result-Inventar

    +

    ${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.

    +
    +

    Outcome

    + ${barList(sortedBars(Object.entries(projection.inventory.byOutcome), outcomeLabel), '—')}
    +

    Severity

    + ${barList(SEVERITY_ORDER.filter(key => (projection.inventory.bySeverity[key] ?? 0) > 0) + .map(key => ({ label: SEVERITY_LABEL[key], value: projection.inventory.bySeverity[key], tone: key })), '—')}
    +

    Security Domain

    + ${barList(sortedBars(Object.entries(projection.inventory.byDomain), id => labels.get(id) ?? id), '—')}
    +

    Quelle

    + ${barList(sortedBars(Object.entries(projection.inventory.bySource), toolLabel), '—')}
    +
    +
    + +
    +

    Konvergenz-Nachweis

    +
    + ${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' + ])) : '

    Keine quellenübergreifende Konvergenz belegt.

    '} + ${projection.bundles.length ? `
    ${table(['Framework-Referenz', 'Quellen', 'Findings'], projection.bundles.map(item => [ + plain(item.key), plain(item.toolIds.map(toolLabel).join(', ')), number(item.members.length) + ]))}
    ` : ''} +
    +
    + +
    +

    Scope-Grenzen

    +
    +

    GPO-Vergleiche gelten nur für das importierte Backup-Set. Ohne RSoP bzw. Group Policy Results wird kein wirksamer Endpoint-Zustand behauptet.

    +

    not_run ist kein Pass. Nicht ausgeführte Checks und unbekannte Werte werden weder als bestanden noch als Deviation gezählt.

    +
    +
    + +
    + +
    +
    Diagnostics ${number(diagnosticRows.length)}
    + ${diagnosticRows.length ? table(['Code', 'Resolver-Grund', 'Herkunft', 'Anzahl'], diagnosticRows) : '

    Keine aggregierten Diagnostics übermittelt.

    '}
    +
    Scope und Provenienz ${number(provenanceRows.length)}
    + ${provenanceRows.length ? table(['Art', 'ID', 'Version', 'Beobachtet'], provenanceRows) : '

    Keine Provenienz übermittelt.

    '}
    +
    Vollständiger Result-Index ${number(results.length)}
    + ${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'))} +
    +
    +
    `; + + return reportDocument(`ASSESSOR · Technical Report · ${projection.project.name}`, body, TECHNICAL_CSS); } diff --git a/src/features/dataset-viewer/record-title.ts b/src/features/dataset-viewer/record-title.ts index afb8e47..dad6fc1 100644 --- a/src/features/dataset-viewer/record-title.ts +++ b/src/features/dataset-viewer/record-title.ts @@ -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; } diff --git a/tests/app/app-version.test.ts b/tests/app/app-version.test.ts index f1b6f5d..a1a62b2 100644 --- a/tests/app/app-version.test.ts +++ b/tests/app/app-version.test.ts @@ -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';"); diff --git a/tests/exports/management-overview-report.test.ts b/tests/exports/management-overview-report.test.ts index 2178c91..db4c02e 100644 --- a/tests/exports/management-overview-report.test.ts +++ b/tests/exports/management-overview-report.test.ts @@ -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('')).toBe(true); - expect(html).not.toMatch(/]*\bsrc\s*=/i); expect(html).not.toMatch(/\son[a-z]+\s*=/i); expect(html).not.toMatch(/https?:\/\//i); expect(html).not.toMatch(/ { + 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'); + // 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('[data-filter-item]')]; + const visible = () => rows.filter(row => !row.hidden).map(row => row.querySelector('.finding__title')!.textContent); + expect(document.querySelector('[data-filter-toolbar]')!.hidden).toBe(false); + expect(visible()).toEqual(['Synthetic critical', 'Synthetic high']); + + document.querySelector('[data-filter-value="critical"]')!.click(); + expect(visible()).toEqual(['Synthetic critical']); + expect(document.querySelector('[data-filter-reset]')!.getAttribute('aria-pressed')).toBe('false'); + + document.querySelector('[data-filter-reset]')!.click(); + const search = document.querySelector('[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('[data-filter-toolbar]')!.hidden).toBe(true); + expect([...document.querySelectorAll('[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; diff --git a/tests/exports/technical-report.test.ts b/tests/exports/technical-report.test.ts index b5fbd30..e4b1cf4 100644 --- a/tests/exports/technical-report.test.ts +++ b/tests/exports/technical-report.test.ts @@ -39,6 +39,182 @@ describe('Technical Report', () => { expect(html).toContain(verdict); } expect(html).toContain('effektive Endpunktzustand ist nicht verifiziert'); - expect(html).not.toMatch(/]*\bsrc\s*=| = {}) => ({ + 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[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: '

    Restrict the dummy right.

    ' })], + 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(''); + 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('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('section[aria-labelledby="scan"] [data-filter-search]')!; + search.value = 'second'; + search.dispatchEvent(new Event('input')); + expect(group.open).toBe(true); + expect([...group.querySelectorAll('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('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('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 | 2', + '', + '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('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); }); }); diff --git a/tests/features/client-report-buttons.test.tsx b/tests/features/client-report-buttons.test.tsx index 9691e91..7e15b32 100644 --- a/tests/features/client-report-buttons.test.tsx +++ b/tests/features/client-report-buttons.test.tsx @@ -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(); }); diff --git a/tests/features/record-title.test.ts b/tests/features/record-title.test.ts index 42cf858..b08f446 100644 --- a/tests/features/record-title.test.ts +++ b/tests/features/record-title.test.ts @@ -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'); diff --git a/tests/server/assessor-http-server.test.ts b/tests/server/assessor-http-server.test.ts index 2d9503b..9584475 100644 --- a/tests/server/assessor-http-server.test.ts +++ b/tests/server/assessor-http-server.test.ts @@ -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(); });