2.9.1: Management Report mit Heatmap und Ergebnisbalken
Die Client-Info-Aktion Management Report erzeugt den faktenbasierten HTML-Bericht aus der gemeinsamen Reportprojektion: erfasste DC, bewertete und abweichende Ergebnisse, Heatmap des Abweichungsanteils und Pass-/Abweichungsbalken je Security Domain. Kein Mixed-Method-Score, keine Scannerlisten, keine LLM-, Internet- oder Risikoableitung. Report B bleibt technisch. Keine Projektions-, API- oder Persistenz- aenderung. Verifiziert: Vollsuite 1645 bestanden, 14 uebersprungen; 7 bestehende Fehler in 6 Dateien identisch auf dem vorherigen Commit. Beide Produktionsbuilds bestanden. Visuelle Browserabnahme offen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
49b2696882
commit
efec1f593a
File diff suppressed because one or more lines are too long
|
|
@ -98,6 +98,33 @@ consumes.
|
||||||
|
|
||||||
## Report A — Management Overview
|
## Report A — Management Overview
|
||||||
|
|
||||||
|
**Superseding user decision 2026-09-29 (Phase 7):** The Management Report is
|
||||||
|
generated offline and without an LLM from the current, selected client's
|
||||||
|
existing report projection. It is a factual overview, not a scanner finding
|
||||||
|
list, mixed-method score or speculative risk assessment. The earlier Report A
|
||||||
|
content contract below (overall-score donut, domain spine, priority findings,
|
||||||
|
remediation status and source overview) is retained as historical context but
|
||||||
|
does not govern the current Management export.
|
||||||
|
|
||||||
|
Current Report A shows project name, report date, the captured domain and DC
|
||||||
|
count when present, counts of assessed and deviating results, a heatmap of
|
||||||
|
deviations divided by assessed results for each catalog Security Domain, and a
|
||||||
|
second stacked chart of passed and deviating results with the Pass Rate and
|
||||||
|
underlying counts. A domain with no assessed results has no percentage or heat
|
||||||
|
level. The colors and percentages describe result frequency only: they do not
|
||||||
|
express severity, business impact, compromise, protection or progress.
|
||||||
|
|
||||||
|
The GPO domain chart counts assigned result rows. When the separate policy
|
||||||
|
evaluation exists, its failing comparisons and affected settings are stated
|
||||||
|
separately and never equated to result rows. The export names its statement
|
||||||
|
limits: source observations may come from different times; affected business
|
||||||
|
services, exploitation and current remediation state are not derivable from
|
||||||
|
these facts. It does not extrapolate backup age or add invented risk scenarios,
|
||||||
|
owners, measures or deadlines. Technical results remain in Report B. The
|
||||||
|
document is standalone HTML with inline styles and no external resources.
|
||||||
|
|
||||||
|
### Historical Report A design (superseded 2026-09-29)
|
||||||
|
|
||||||
Implemented in `src/exports/management-overview-report.ts`
|
Implemented in `src/exports/management-overview-report.ts`
|
||||||
(`MANAGEMENT_OVERVIEW_RENDERER_VERSION = '1.0.0'`), covered by
|
(`MANAGEMENT_OVERVIEW_RENDERER_VERSION = '1.0.0'`), covered by
|
||||||
`tests/exports/management-overview-report.test.ts`.
|
`tests/exports/management-overview-report.test.ts`.
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "assessor",
|
"name": "assessor",
|
||||||
"version": "2.9.0",
|
"version": "2.9.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "assessor",
|
"name": "assessor",
|
||||||
"version": "2.9.0",
|
"version": "2.9.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "assessor",
|
"name": "assessor",
|
||||||
"version": "2.9.0",
|
"version": "2.9.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,520 +1,102 @@
|
||||||
import type {
|
import type { ReportDomainSummary, ReportProjection } from '../domain/report-projection';
|
||||||
ReportDomainPriority,
|
import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';
|
||||||
ReportDomainSummary,
|
|
||||||
ReportFindingRef,
|
|
||||||
ReportGpoBlock,
|
|
||||||
ReportPriorityBasis,
|
|
||||||
ReportProjection,
|
|
||||||
ReportScoreSource
|
|
||||||
} from '../domain/report-projection';
|
|
||||||
import { escapeHtml } from '../shared/html';
|
import { escapeHtml } from '../shared/html';
|
||||||
|
|
||||||
/**
|
/** Offline management report: a factual view of the shared projection. */
|
||||||
* Artifact A of docs/REPORT_STRATEGY.md: the Management Overview.
|
export const MANAGEMENT_OVERVIEW_RENDERER_VERSION = '2.0.0';
|
||||||
*
|
|
||||||
* A frozen snapshot for the customer's decision makers. It answers how the
|
|
||||||
* environment stands, what is most urgent, what the statement rests on and what
|
|
||||||
* is not known — and deliberately carries no registry keys, SIDs, evidence paths
|
|
||||||
* or parser versions, so the file can be forwarded as it is.
|
|
||||||
*
|
|
||||||
* German carries the prose; established English technical terminology stays
|
|
||||||
* English. The file is self-contained: every style is inline, nothing is
|
|
||||||
* fetched when it is opened.
|
|
||||||
*/
|
|
||||||
export const MANAGEMENT_OVERVIEW_RENDERER_VERSION = '1.1.0';
|
|
||||||
|
|
||||||
const DASH = '—';
|
const DASH = '—';
|
||||||
|
const count = (n: number): string => Number.isFinite(n) ? n.toLocaleString('de-DE') : DASH;
|
||||||
|
|
||||||
/** Unknown stays unknown. An absent value never becomes a zero. */
|
function date(iso: string): string {
|
||||||
function value(input: string | number | undefined | null): string {
|
|
||||||
if (input === undefined || input === null) return DASH;
|
|
||||||
if (typeof input === 'number' && !Number.isFinite(input)) return DASH;
|
|
||||||
const text = String(input).trim();
|
|
||||||
return text === '' ? DASH : escapeHtml(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
function integer(input: number | undefined): string {
|
|
||||||
return typeof input === 'number' && Number.isFinite(input)
|
|
||||||
? escapeHtml(input.toLocaleString('de-DE'))
|
|
||||||
: DASH;
|
|
||||||
}
|
|
||||||
|
|
||||||
function percent(ratio: number | undefined, digits = 0): string {
|
|
||||||
return typeof ratio === 'number' && Number.isFinite(ratio)
|
|
||||||
? escapeHtml((ratio * 100).toLocaleString('de-DE', {
|
|
||||||
minimumFractionDigits: digits, maximumFractionDigits: digits
|
|
||||||
}))
|
|
||||||
: DASH;
|
|
||||||
}
|
|
||||||
|
|
||||||
function germanDateTime(iso: string): string {
|
|
||||||
const parsed = Date.parse(iso);
|
const parsed = Date.parse(iso);
|
||||||
if (!Number.isFinite(parsed)) return value(iso);
|
return Number.isFinite(parsed)
|
||||||
return escapeHtml(new Date(parsed).toLocaleString('de-DE', {
|
? new Date(parsed).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function germanDate(iso: string | undefined): string {
|
|
||||||
if (!iso) return DASH;
|
|
||||||
const parsed = Date.parse(iso);
|
|
||||||
if (!Number.isFinite(parsed)) return value(iso);
|
|
||||||
return escapeHtml(new Date(parsed).toLocaleDateString('de-DE', {
|
|
||||||
year: 'numeric', month: '2-digit', day: '2-digit'
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Binding wording from docs/SCORING_MODEL.md. A method is named, never implied,
|
|
||||||
* and the GPO contribution is never called Baseline-Compliance.
|
|
||||||
*/
|
|
||||||
const SCORE_METHOD_LABEL: Record<ReportScoreSource['method'], string> = {
|
|
||||||
native: 'Nativer Score des Werkzeugs',
|
|
||||||
'inverted-native': 'Nativer Risk, invertiert als 100 − Risk',
|
|
||||||
'pass-rate': 'Pass Rate der bewerteten Checks',
|
|
||||||
unavailable: 'Kein verwertbarer Score'
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Names why a source is ordered the way it is, or why it is not ordered. */
|
|
||||||
function basisLabel(basis: ReportPriorityBasis): string {
|
|
||||||
if (basis.kind === 'severity') return 'Sortiert nach nativer Severity';
|
|
||||||
if (basis.kind === 'numeric') {
|
|
||||||
const direction = basis.direction === 'higher-is-worse' ? 'höher ist kritischer' : 'niedriger ist kritischer';
|
|
||||||
return `Sortiert nach ${escapeHtml(basis.property)} (${escapeHtml(basis.scale)}, ${direction})`;
|
|
||||||
}
|
|
||||||
if (basis.kind === 'equal-weight') return `Ohne Rangfolge: ${escapeHtml(basis.reason)}`;
|
|
||||||
return 'Ohne Rangfolge: Die Quelle deklariert keinen belastbaren Maßstab.';
|
|
||||||
}
|
|
||||||
|
|
||||||
const SEVERITY_LABEL: Record<ReportFindingRef['severity'], string> = {
|
|
||||||
critical: 'Critical', high: 'High', medium: 'Medium',
|
|
||||||
low: 'Low', info: 'Info', unknown: 'Unknown'
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared donut geometry: full 360 degrees, continuous neutral track, value arc
|
|
||||||
* clockwise from twelve o'clock, flat caps. The number sits on the exact centre
|
|
||||||
* and its unit follows to the right without displacing it.
|
|
||||||
*/
|
|
||||||
function donut(score: number | undefined, caption: string): string {
|
|
||||||
const radius = 54;
|
|
||||||
const circumference = 2 * Math.PI * radius;
|
|
||||||
const ratio = typeof score === 'number' && Number.isFinite(score)
|
|
||||||
? Math.min(Math.max(score, 0), 100) / 100
|
|
||||||
: 0;
|
|
||||||
const dash = `${(circumference * ratio).toFixed(3)} ${(circumference * (1 - ratio)).toFixed(3)}`;
|
|
||||||
const display = typeof score === 'number' && Number.isFinite(score)
|
|
||||||
? score > 0 && score < 0.5 ? '<1' : Math.round(score).toLocaleString('de-DE')
|
|
||||||
: DASH;
|
: DASH;
|
||||||
const hasScore = display !== DASH;
|
|
||||||
return `<figure class="donut">
|
|
||||||
<svg viewBox="0 0 128 128" role="img" aria-label="Overall Score ${hasScore ? `${display} von 100` : 'nicht verfügbar'}">
|
|
||||||
<circle class="donut__track" cx="64" cy="64" r="${radius}"></circle>
|
|
||||||
${hasScore ? `<circle class="donut__value" cx="64" cy="64" r="${radius}"
|
|
||||||
stroke-dasharray="${dash}" transform="rotate(-90 64 64)"></circle>${ratio > 0 && ratio < 0.02
|
|
||||||
? `<rect class="donut__small-arc" x="112" y="${(64 - Math.max(3, circumference * ratio) / 2).toFixed(3)}" width="12" height="${Math.max(3, circumference * ratio).toFixed(3)}" transform="rotate(-90 64 64)"></rect>`
|
|
||||||
: ''}` : ''}
|
|
||||||
</svg>
|
|
||||||
<figcaption class="donut__readout">
|
|
||||||
<span class="donut__number">${escapeHtml(display)}${hasScore ? '<span class="donut__unit">/100</span>' : ''}</span>
|
|
||||||
<span class="donut__caption">${escapeHtml(caption)}</span>
|
|
||||||
</figcaption>
|
|
||||||
</figure>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Domain pass rates as a connected spine, not a conventional table. */
|
function tile(item: ReportDomainSummary): string {
|
||||||
function domainSpine(domains: ReportDomainSummary[]): string {
|
const assessed = item.assessed > 0 && Number.isFinite(item.assessed) && Number.isFinite(item.deviations);
|
||||||
if (!domains.length) return '<p class="empty">Keine Security Domain wurde bewertet.</p>';
|
const share = assessed ? Math.round(100 * item.deviations / item.assessed) : undefined;
|
||||||
return `<ol class="spine">${domains.map(domain => {
|
const level = share === undefined ? 'unknown' : `h${share < 25 ? 0 : share < 50 ? 1 : share < 75 ? 2 : 3}`;
|
||||||
const known = typeof domain.passRate === 'number';
|
return `<li class="${level}"><span class="area">${escapeHtml(item.domainLabel)}</span>
|
||||||
const width = known ? Math.round(domain.passRate! * 100) : 0;
|
<strong class="value">${share === undefined ? DASH : `${count(share)} %`}</strong>
|
||||||
return `<li class="spine__item">
|
<span class="count">${assessed ? `${count(item.deviations)} von ${count(item.assessed)} bewertet` : 'Keine bewerteten Ergebnisse'}</span></li>`;
|
||||||
<span class="spine__node" aria-hidden="true"></span>
|
|
||||||
<span class="spine__label">${escapeHtml(domain.domainLabel)}</span>
|
|
||||||
<span class="spine__meter"><span class="spine__fill" style="width:${width}%"></span></span>
|
|
||||||
<span class="spine__figure">${known ? `${percent(domain.passRate)} %` : DASH}</span>
|
|
||||||
<span class="spine__counts">${integer(domain.deviations)} Deviations von ${integer(domain.assessed)} bewertet</span>
|
|
||||||
</li>`;
|
|
||||||
}).join('')}</ol>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function findingRow(finding: ReportFindingRef, ranked: boolean): string {
|
function comparisonRow(item: ReportDomainSummary): string {
|
||||||
const severity = finding.severity;
|
const assessed = Number.isFinite(item.assessed) && item.assessed > 0;
|
||||||
const showSeverity = severity !== 'unknown';
|
const passed = assessed && Number.isFinite(item.passed) ? Math.max(0, Math.min(item.passed, item.assessed)) : 0;
|
||||||
return `<li class="finding">
|
const deviations = assessed && Number.isFinite(item.deviations) ? Math.max(0, Math.min(item.deviations, item.assessed - passed)) : 0;
|
||||||
<span class="finding__accent finding__accent--${escapeHtml(severity)}" aria-hidden="true"></span>
|
const other = assessed ? Math.max(0, item.assessed - passed - deviations) : 0;
|
||||||
<span class="finding__title">${escapeHtml(finding.title)}</span>
|
const passPercent = assessed ? Math.round(100 * passed / item.assessed) : undefined;
|
||||||
<span class="finding__meta">
|
return `<li class="comparison-row"><span class="comparison-label">${escapeHtml(item.domainLabel)}</span>
|
||||||
${showSeverity ? `<span class="badge badge--${escapeHtml(severity)}">${escapeHtml(SEVERITY_LABEL[severity])}</span>` : ''}
|
<span class="comparison-bar" aria-label="${assessed ? `${count(passed)} bestanden, ${count(deviations)} abweichend` : 'Keine bewerteten Ergebnisse'}">
|
||||||
${ranked && finding.rankValue !== undefined && finding.rankBasis.kind === 'numeric'
|
<span class="bar-pass" style="width:${assessed ? 100 * passed / item.assessed : 0}%"></span>
|
||||||
? `<span class="badge badge--neutral">${escapeHtml(finding.rankBasis.property)} ${integer(finding.rankValue)}</span>` : ''}
|
<span class="bar-fail" style="width:${assessed ? 100 * deviations / item.assessed : 0}%"></span>
|
||||||
</span>
|
${other ? `<span class="bar-other" style="width:${100 * other / item.assessed}%"></span>` : ''}
|
||||||
</li>`;
|
</span>
|
||||||
}
|
<span class="comparison-value">${passPercent === undefined ? DASH : `${count(passPercent)} %`} <span>· ${assessed ? `${count(item.deviations)} von ${count(item.assessed)}` : DASH}</span></span></li>`;
|
||||||
|
|
||||||
function prioritySection(priorities: ReportDomainPriority[]): string {
|
|
||||||
if (!priorities.length) return '<p class="empty">Es wurde keine Deviation festgestellt.</p>';
|
|
||||||
return priorities.map(domain => `<article class="tile domain">
|
|
||||||
<header class="domain__head">
|
|
||||||
<h3>${escapeHtml(domain.domainLabel)}</h3>
|
|
||||||
<span class="domain__count">${integer(domain.deviations)} Deviations</span>
|
|
||||||
</header>
|
|
||||||
${domain.sources.map(source => `<section class="source">
|
|
||||||
<header class="source__head">
|
|
||||||
<span class="source__tool">${escapeHtml(source.toolId)}</span>
|
|
||||||
<span class="source__basis">${basisLabel(source.basis)}</span>
|
|
||||||
</header>
|
|
||||||
<ol class="findings">${source.shown.map(item => findingRow(item, source.ranked)).join('')}</ol>
|
|
||||||
${source.shown.length < source.total
|
|
||||||
? `<p class="source__more">${integer(source.total - source.shown.length)} weitere Deviations dieser Quelle stehen im Technical Report.</p>`
|
|
||||||
: ''}
|
|
||||||
</section>`).join('')}
|
|
||||||
</article>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GPO deviations carry no native criticality, so they form their own
|
|
||||||
* equal-weight block instead of being ranked against severity-bearing sources.
|
|
||||||
*/
|
|
||||||
function gpoSection(gpo: ReportGpoBlock): string {
|
|
||||||
const rows: Array<[string, number]> = [
|
|
||||||
['Fail', gpo.counts.Fail],
|
|
||||||
['Review required', gpo.counts['Review required']],
|
|
||||||
['Pass', gpo.counts.Pass],
|
|
||||||
['Not configured', gpo.counts['Not configured']],
|
|
||||||
['No baseline rule', gpo.counts['No baseline rule']],
|
|
||||||
['Not comparable', gpo.counts['Not comparable']]
|
|
||||||
];
|
|
||||||
return `<article class="tile domain">
|
|
||||||
<header class="domain__head">
|
|
||||||
<h3>GPO & Baseline</h3>
|
|
||||||
<span class="domain__count">${integer(gpo.counts.Fail)} Deviations</span>
|
|
||||||
</header>
|
|
||||||
<p class="note">${escapeHtml(gpo.basis.reason)} Die Einträge stehen deshalb gleichgewichtig nebeneinander.</p>
|
|
||||||
<dl class="verdicts">${rows.map(([label, count]) => `<div class="verdicts__row">
|
|
||||||
<dt>${escapeHtml(label)}</dt><dd>${integer(count)}</dd>
|
|
||||||
</div>`).join('')}</dl>
|
|
||||||
<p class="note">
|
|
||||||
Betroffene Policy Settings: <strong>${integer(gpo.affectedSettings)}</strong> ·
|
|
||||||
betroffene GPO Backups: <strong>${integer(gpo.affectedBackups)}</strong> ·
|
|
||||||
Conflicts: <strong>${integer(gpo.conflicts)}</strong> ·
|
|
||||||
Duplicates: <strong>${integer(gpo.duplicates)}</strong>
|
|
||||||
</p>
|
|
||||||
<p class="note">
|
|
||||||
<code>Not configured</code>, <code>No baseline rule</code> und <code>Not comparable</code>
|
|
||||||
sind score-neutral und begründen keine Maßnahme.
|
|
||||||
${gpo.scopeLimited
|
|
||||||
? 'Die Auswertung bezieht sich ausschließlich auf den importierten GPO-Backup-Satz und trifft keine Aussage über die gesamte Domäne.'
|
|
||||||
: ''}
|
|
||||||
</p>
|
|
||||||
</article>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildManagementOverviewHtml(projection: ReportProjection): string {
|
export function buildManagementOverviewHtml(projection: ReportProjection): string {
|
||||||
const facts = projection.environment.length
|
const domain = projection.environment.find(fact => fact.key === 'Domain')?.value;
|
||||||
? projection.environment
|
const dc = projection.environment.find(fact => fact.key === 'Domain Controller')?.value;
|
||||||
: [{ key: 'Umgebung', value: undefined }];
|
const gpo = projection.gpo;
|
||||||
|
const gpoNote = gpo && projection.domains.some(item => item.domainId === 'gpo')
|
||||||
const remediation = projection.remediation;
|
? `<p>Die GPO-Kachel zählt zugeordnete Ergebniszeilen. Die gesonderte GPO-Evaluation zählt ${count(gpo.counts.Fail)} abweichende Vergleiche zu ${count(gpo.affectedSettings)} Einstellungen. Diese Zähleinheiten sind nicht gleichzusetzen.${gpo.scopeLimited ? ' Die Evaluation bezieht sich auf die importierten GPO-Backups.' : ''}</p>`
|
||||||
const coverage = projection.coverage;
|
: '';
|
||||||
|
|
||||||
const head = `<header class="doc__head">
|
|
||||||
<p class="doc__kicker">ASSESSOR · Management Overview</p>
|
|
||||||
<h1>${escapeHtml(projection.project.name)}</h1>
|
|
||||||
<dl class="facts">
|
|
||||||
${facts.map(fact => `<div class="facts__item">
|
|
||||||
<dt>${escapeHtml(fact.key)}</dt><dd>${value(fact.value)}</dd>
|
|
||||||
</div>`).join('')}
|
|
||||||
<div class="facts__item"><dt>Assessment</dt><dd>${value(projection.assessmentId)}</dd></div>
|
|
||||||
<div class="facts__item"><dt>Berichtsdatum</dt><dd>${germanDateTime(projection.generatedAt)}</dd></div>
|
|
||||||
</dl>
|
|
||||||
</header>`;
|
|
||||||
|
|
||||||
const posture = `<section class="segment" aria-labelledby="posture">
|
|
||||||
<h2 id="posture">Overall Posture</h2>
|
|
||||||
<div class="posture">
|
|
||||||
<div class="tile posture__score">
|
|
||||||
${donut(projection.overallScore, 'Overall Score')}
|
|
||||||
</div>
|
|
||||||
<div class="tile posture__domains">
|
|
||||||
<h3>Security Domains</h3>
|
|
||||||
${domainSpine(projection.domains)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="note">
|
|
||||||
Der Overall Score ist ein Mixed-Method-Index über die unten genannten Quellen.
|
|
||||||
Der GPO-Anteil bezeichnet die Konformität der konfigurierten, vergleichbaren
|
|
||||||
Einstellungen im Scope — er ist keine Aussage über eine vollständig
|
|
||||||
ausgerollte Microsoft Baseline. Security-Domain-Werte sind Pass Rates und keine
|
|
||||||
Zerlegung des Index.
|
|
||||||
</p>
|
|
||||||
</section>`;
|
|
||||||
|
|
||||||
const priorities = `<section class="segment" aria-labelledby="priorities">
|
|
||||||
<h2 id="priorities">Priority Findings</h2>
|
|
||||||
<p class="note">
|
|
||||||
Gruppiert nach Security Domain. Innerhalb einer Domain wird je Quelle nach deren
|
|
||||||
eigenem, deklariertem Maßstab geordnet; eine quellenübergreifende Rangfolge
|
|
||||||
gibt es nicht, weil die Maßstäbe nicht vergleichbar sind.
|
|
||||||
</p>
|
|
||||||
<div class="segment__body">
|
|
||||||
${prioritySection(projection.priorities)}
|
|
||||||
${projection.gpo ? gpoSection(projection.gpo) : ''}
|
|
||||||
</div>
|
|
||||||
</section>`;
|
|
||||||
|
|
||||||
const convergence = `<section class="segment" aria-labelledby="convergence">
|
|
||||||
<h2 id="convergence">Konvergenz der Quellen</h2>
|
|
||||||
<p class="note">Mehrere Quellen in einer Security Domain belegen nicht automatisch denselben Finding.</p>
|
|
||||||
<div class="segment__body">${projection.convergence.filter(item => item.sourceCount > 0).map(item => `<article class="tile domain">
|
|
||||||
<h3>${value(projection.domains.find(domain => domain.domainId === item.domainId)?.domainLabel ?? item.domainId)}</h3>
|
|
||||||
<p>${integer(item.sourceCount)} unabhängige Quellen mit Deviations: ${item.sources.filter(source => source.deviations > 0)
|
|
||||||
.map(source => escapeHtml(source.toolId)).join(', ')}.</p>
|
|
||||||
<p class="note">${item.evidence === 'shared-framework-reference'
|
|
||||||
? 'Belegte Konvergenz: Mindestens zwei Quellen nennen dieselbe Framework-Referenz.'
|
|
||||||
: 'Nur thematische Nähe innerhalb der Domain; ein gemeinsamer Finding ist nicht belegt und bedarf Validierung.'}</p>
|
|
||||||
</article>`).join('') || '<p class="empty">Keine quellenübergreifende Konvergenz belegt.</p>'}</div>
|
|
||||||
</section>`;
|
|
||||||
|
|
||||||
const bundles = `<section class="segment" aria-labelledby="bundles">
|
|
||||||
<h2 id="bundles">Maßnahmenbündel</h2>
|
|
||||||
<p class="note">Gleiche Framework-Referenzen gruppieren Findings ohne Aussage zu Aufwand, Nutzen oder Priorität.</p>
|
|
||||||
${projection.bundles.length ? `<table class="table"><thead><tr><th>Framework-Referenz</th><th>Findings</th></tr></thead>
|
|
||||||
<tbody>${projection.bundles.map(bundle => `<tr><td>${escapeHtml(bundle.key)}</td><td class="num">${integer(bundle.members.length)}</td></tr>`).join('')}</tbody></table>`
|
|
||||||
: '<p class="empty">Keine mehrfach belegte Framework-Referenz.</p>'}
|
|
||||||
<p class="note">Einzelquellen-GPO-Maßnahmen ohne Conflict oder Duplicate: ${integer(projection.singleSourcePolicyActions.length)}.</p>
|
|
||||||
</section>`;
|
|
||||||
|
|
||||||
const remediationSection = remediation ? `<section class="segment" aria-labelledby="remediation">
|
|
||||||
<h2 id="remediation">Remediation-Status</h2>
|
|
||||||
<p class="note">
|
|
||||||
Selbstauskunft des Kunden aus der Arbeitsgrundlage. Diese Angaben sind
|
|
||||||
<strong>kein technischer Nachweis</strong>: Eine Maßnahme gilt erst dann als
|
|
||||||
behoben, wenn ein Rescan das belegt.
|
|
||||||
</p>
|
|
||||||
<div class="segment__body">
|
|
||||||
<article class="tile kpi"><span class="kpi__figure">${integer(remediation.items)}</span><span class="kpi__label">Maßnahmen in Arbeit</span></article>
|
|
||||||
<article class="tile kpi"><span class="kpi__figure">${integer(remediation.withoutOwner)}</span><span class="kpi__label">ohne Owner</span></article>
|
|
||||||
<article class="tile kpi"><span class="kpi__figure">${integer(remediation.withoutDue)}</span><span class="kpi__label">ohne Termin</span></article>
|
|
||||||
<article class="tile kpi"><span class="kpi__figure">${integer(remediation.riskAccepted)}</span><span class="kpi__label">Accepted Risk</span></article>
|
|
||||||
</div>
|
|
||||||
${Object.keys(remediation.byStatus).length ? `<dl class="verdicts">${Object.entries(remediation.byStatus)
|
|
||||||
.map(([status, count]) => `<div class="verdicts__row"><dt>${escapeHtml(status)}</dt><dd>${integer(count)}</dd></div>`)
|
|
||||||
.join('')}</dl>` : ''}
|
|
||||||
</section>` : '';
|
|
||||||
|
|
||||||
const coverageSection = `<section class="segment" aria-labelledby="coverage">
|
|
||||||
<h2 id="coverage">Abdeckung und Lücken</h2>
|
|
||||||
<div class="segment__body">
|
|
||||||
<article class="tile kpi"><span class="kpi__figure">${integer(coverage.assessed)}</span><span class="kpi__label">bewertet</span></article>
|
|
||||||
<article class="tile kpi"><span class="kpi__figure">${integer(coverage.deviations)}</span><span class="kpi__label">Deviations</span></article>
|
|
||||||
<article class="tile kpi"><span class="kpi__figure">${integer(coverage.notRun)}</span><span class="kpi__label">not run</span></article>
|
|
||||||
<article class="tile kpi"><span class="kpi__figure">${integer(coverage.notApplicable)}</span><span class="kpi__label">not applicable</span></article>
|
|
||||||
<article class="tile kpi"><span class="kpi__figure">${integer(coverage.executionErrors)}</span><span class="kpi__label">execution errors</span></article>
|
|
||||||
</div>
|
|
||||||
<p class="note">
|
|
||||||
Von insgesamt ${integer(coverage.total)} Ergebnissen konnten ${integer(coverage.assessed)}
|
|
||||||
bewertet werden. Nicht ausgeführte, nicht anwendbare und fehlerhaft ausgeführte
|
|
||||||
Checks zählen weder als bestanden noch als Deviation.
|
|
||||||
</p>
|
|
||||||
<table class="table">
|
|
||||||
<caption>Abdeckung je Quelle</caption>
|
|
||||||
<thead><tr><th scope="col">Quelle</th><th scope="col">Ergebnisse</th><th scope="col">bewertet</th><th scope="col">not run</th><th scope="col">not run Anteil</th></tr></thead>
|
|
||||||
<tbody>${coverage.perSource.map(source => `<tr>
|
|
||||||
<td>${escapeHtml(source.toolId)}</td>
|
|
||||||
<td class="num">${integer(source.total)}</td>
|
|
||||||
<td class="num">${integer(source.assessed)}</td>
|
|
||||||
<td class="num">${integer(source.notRun)}</td>
|
|
||||||
<td class="num">${source.total ? `${percent(source.notRun / source.total)} %` : DASH}</td>
|
|
||||||
</tr>`).join('')}</tbody>
|
|
||||||
</table>
|
|
||||||
</section>`;
|
|
||||||
|
|
||||||
const sources = `<section class="segment" aria-labelledby="sources">
|
|
||||||
<h2 id="sources">Source Overview</h2>
|
|
||||||
<table class="table">
|
|
||||||
<caption>Score-Beitrag je Quelle</caption>
|
|
||||||
<thead><tr>
|
|
||||||
<th scope="col">Quelle</th><th scope="col">Stand</th><th scope="col">Beitrag</th>
|
|
||||||
<th scope="col">Methode</th><th scope="col">Pass</th><th scope="col">Deviations</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody>${projection.scoreSources.length ? projection.scoreSources.map(source => `<tr>
|
|
||||||
<td>${escapeHtml(source.toolId)}</td>
|
|
||||||
<td>${germanDate(source.observedAt)}</td>
|
|
||||||
<td class="num">${typeof source.score === 'number' ? integer(Math.round(source.score)) : DASH}</td>
|
|
||||||
<td>${escapeHtml(SCORE_METHOD_LABEL[source.method])}</td>
|
|
||||||
<td class="num">${integer(source.passed)}</td>
|
|
||||||
<td class="num">${integer(source.deviations)}</td>
|
|
||||||
</tr>`).join('') : `<tr><td colspan="6">${DASH}</td></tr>`}</tbody>
|
|
||||||
</table>
|
|
||||||
${projection.scoreSources.some(source => source.method === 'inverted-native')
|
|
||||||
? '<p class="note">Eine invertierte Quelle liefert nativ einen Risk-Wert, bei dem ein höherer Wert schlechter ist. Er geht als <code>100 − Risk</code> in den Index ein; der rohe Risk-Wert bleibt in der Quelle erhalten.</p>'
|
|
||||||
: ''}
|
|
||||||
</section>`;
|
|
||||||
|
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html lang="de">
|
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<head>
|
<title>ASSESSOR · Management Report · ${escapeHtml(projection.project.name)}</title>
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Management Overview · ${escapeHtml(projection.project.name)}</title>
|
|
||||||
<style>
|
<style>
|
||||||
:root{
|
:root{--ink:#17171c;--muted:#5c6069;--navy:#123047;--line:#cfd1d7;--paper:#fff;--surface:#f5f5f7;--orange:#df752d;--heat-0:#e7e8ec;--heat-1:#f8dfcb;--heat-2:#edb68d;--heat-3:#e68f51}
|
||||||
--font-ui:"Inter Variable","Inter",system-ui,-apple-system,"Segoe UI",sans-serif;
|
*{box-sizing:border-box}body{margin:0;background:#e7e7eb;color:var(--ink);font:400 14px/1.45 Inter,Arial,sans-serif}
|
||||||
--type-h1-size:56px; --type-h2-size:32px; --type-h3-size:18px; --type-h4-size:16px;
|
.sheet{width:210mm;min-height:297mm;margin:16px auto;padding:12mm 16mm 11mm;background:var(--paper);display:flex;flex-direction:column}
|
||||||
--type-body-size:16px; --type-table-body-size:14px; --type-label-size:12px;
|
.mast{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-bottom:12px;border-bottom:2px solid var(--navy)}
|
||||||
--type-meta-size:12px; --type-badge-size:11px; --type-table-head-size:12px;
|
.brand{color:var(--navy);font-size:19px;font-weight:700;letter-spacing:.06em}.mast-note{color:var(--navy);text-align:right;font-size:12px;font-weight:600}
|
||||||
--fw-regular:400; --fw-semibold:600; --fw-bold:700;
|
.kicker{margin:20px 0 7px;color:var(--orange);font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase}
|
||||||
--lh-ui:1.35; --lh-prose:1.55; --ls-caps:0.06em;
|
h1{margin:0;color:var(--navy);font-size:30px;line-height:1.13;font-weight:600}h2{margin:0;color:var(--navy);font-size:18px;font-weight:600}
|
||||||
--space-1:4px; --space-2:8px; --space-3:12px; --space-4:16px; --space-5:20px;
|
p{margin:0 0 8px}.subtitle{margin:8px 0 14px;color:var(--muted);font-size:14px}
|
||||||
--space-6:24px; --space-8:32px;
|
.meta{display:flex;flex-wrap:wrap;gap:6px 18px;padding:8px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);color:var(--muted);font-size:14px}
|
||||||
--panel-content-inset:20px; --panel-inset:var(--panel-content-inset);
|
.meta strong{color:var(--ink);font-weight:600}.facts{display:grid;grid-template-columns:repeat(3,1fr);margin:21px 0 26px;border-top:2px solid var(--navy);border-bottom:1px solid var(--line)}
|
||||||
--segment-surface:transparent; --segment-border:transparent; --segment-radius:10px;
|
.fact{padding:13px 15px;border-right:1px solid var(--line)}.fact:last-child{border-right:0}.fact strong{display:block;color:var(--navy);font-size:28px;line-height:1.12;font-weight:600;font-variant-numeric:tabular-nums}.fact span{display:block;margin-top:5px;font-size:14px}
|
||||||
--segment-inset:var(--space-3); --segment-gap:var(--space-3);
|
.section-head{display:flex;align-items:end;justify-content:space-between;gap:15px;margin-bottom:11px}.section-head p{max-width:260px;margin:0;color:var(--muted);font-size:14px;text-align:right}
|
||||||
--tile-radius:0px; --tile-inset:var(--space-4); --tile-border:transparent;
|
.heatmap{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;margin:0;padding:0;list-style:none}.heatmap li{min-height:104px;padding:11px 12px 10px;display:flex;flex-direction:column;border:1px solid rgba(18,48,71,.17);break-inside:avoid}
|
||||||
--radius-xs:0px; --radius-sm:0px;
|
.heatmap .h0{background:var(--heat-0)}.heatmap .h1{background:var(--heat-1)}.heatmap .h2{background:var(--heat-2)}.heatmap .h3{background:var(--heat-3)}.heatmap .unknown{background:var(--surface)}
|
||||||
--donut-ring-size:128px; --donut-ring-stroke:12px;
|
.heatmap .area{color:var(--navy);font-size:14px;line-height:1.25;font-weight:600}.heatmap .value{display:block;margin-top:auto;color:var(--navy);font-size:28px;line-height:1.08;font-weight:600;font-variant-numeric:tabular-nums}.heatmap .count{display:block;margin-top:3px;color:var(--ink);font-size:14px}
|
||||||
--donut-value-size:26px; --donut-percent-size:14px;
|
.legend{display:flex;align-items:center;flex-wrap:wrap;gap:5px 12px;margin:12px 0 0;color:var(--muted);font-size:14px}.legend .key{display:inline-flex;align-items:center;gap:5px;white-space:nowrap}.legend i{display:inline-block;width:14px;height:14px;border:1px solid rgba(18,48,71,.17)}
|
||||||
/* Light is the document default: a report is read and printed on white. */
|
.legend .l0{background:var(--heat-0)}.legend .l1{background:var(--heat-1)}.legend .l2{background:var(--heat-2)}.legend .l3{background:var(--heat-3)}
|
||||||
--surface-app:#F1F1F4; --surface-panel:#FFFFFF; --tile-surface:#FFFFFF;
|
.comparison{margin-top:26px;padding:15px 17px;background:var(--surface);border:1px solid var(--line);break-inside:avoid}.comparison h2{margin-bottom:13px}
|
||||||
--text-primary:#17171C; --text-secondary:#55555F; --text-muted:#74747F;
|
.comparison-scale{display:grid;grid-template-columns:240px minmax(180px,1fr) 135px;gap:12px;margin-bottom:5px;color:var(--muted);font-size:12px}
|
||||||
--border-subtle:#E2E2E8; --border-strong:#A8A8B4;
|
.comparison-scale div{grid-column:2;display:flex;justify-content:space-between}
|
||||||
--donut-track:#B9BEC7; --donut-value:#20242A; --donut-label:#555B65; --donut-all:#1C70C9;
|
.comparison-list{list-style:none;margin:0;padding:0}.comparison-row{display:grid;grid-template-columns:240px minmax(180px,1fr) 135px;gap:12px;align-items:center;min-height:34px;font-size:14px}
|
||||||
--marking-color:#E9A63C;
|
.comparison-bar{display:flex;height:14px;background:#e3e3e8}.bar-pass{background:#167048}.bar-fail{background:#c60b41}.bar-other{background:#92969d}
|
||||||
--sev-critical:#E8214F; --sev-high:#E2680F; --sev-medium:#C99700;
|
.comparison-value{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;white-space:nowrap}.comparison-value span{color:var(--muted);font-weight:400}
|
||||||
--sev-low:#2E9E6B; --sev-info:#3D8FD1; --sev-unknown:#6E6E78; --sev-on:#FFFFFF;
|
.comparison-key{display:flex;gap:24px;margin:13px 0 0 252px;font-size:14px;color:var(--muted)}.comparison-key i{display:inline-block;width:12px;height:12px;margin-right:5px;vertical-align:-1px}.comparison-key .pass{background:#167048}.comparison-key .fail{background:#c60b41}
|
||||||
}
|
.notes{margin-top:23px;padding:13px 15px;background:var(--surface);border-top:2px solid var(--navy)}.notes h2{font-size:16px;margin-bottom:6px}.notes p{margin:0 0 5px;font-size:14px}.notes p:last-child{margin-bottom:0}
|
||||||
@media (prefers-color-scheme:dark){
|
.foot{margin-top:auto;padding-top:10px;border-top:1px solid var(--navy);display:flex;justify-content:space-between;gap:12px;color:var(--muted);font-size:12px}.foot strong{color:var(--navy);font-weight:600}
|
||||||
:root{
|
@media(max-width:800px){body{background:#fff}.sheet{width:auto;min-height:0;margin:0;padding:18px 16px 26px}.heatmap{grid-template-columns:repeat(2,1fr)}.section-head{display:block}.section-head p{max-width:none;margin-top:4px;text-align:left}h1{font-size:26px}}
|
||||||
--surface-app:#242426; --surface-panel:#242426; --tile-surface:#323238;
|
@media(max-width:480px){.heatmap,.facts{grid-template-columns:1fr}.fact{border-right:0;border-bottom:1px solid var(--line)}.fact:last-child{border-bottom:0}}
|
||||||
--text-primary:#E2DFE6; --text-secondary:#B7BAC0; --text-muted:#B7BAC0;
|
@media(max-width:720px){.comparison-scale{display:none}.comparison-row{grid-template-columns:1fr auto;gap:3px 10px;margin-bottom:12px}.comparison-label{grid-column:1/-1}.comparison-bar{grid-column:1}.comparison-value{grid-column:2}.comparison-key{margin-left:0}}
|
||||||
--border-subtle:rgba(255,255,255,.14); --border-strong:#4E4E58;
|
@page{size:A4;margin:0}@media print{body{background:#fff;print-color-adjust:exact;-webkit-print-color-adjust:exact}.sheet{margin:0}}
|
||||||
--donut-track:#555B65; --donut-value:#E2DFE6; --donut-label:#B7BAC0; --donut-all:#348CF2;
|
</style></head><body><main class="sheet">
|
||||||
}
|
<header class="mast"><div class="brand">ASSESSOR</div><div class="mast-note">Management Report</div></header>
|
||||||
}
|
<p class="kicker">Gespeicherter Assessment-Stand</p><h1>Technische Ergebnisse im Überblick</h1>
|
||||||
*{box-sizing:border-box}
|
<p class="subtitle">${escapeHtml(projection.project.name)}</p>
|
||||||
html{background:var(--surface-app)}
|
<div class="meta">${domain ? `<span><strong>Domain:</strong> ${escapeHtml(domain)}</span>` : ''}<span><strong>Berichtsdatum:</strong> ${date(projection.generatedAt)}</span></div>
|
||||||
body{margin:0;padding:var(--panel-inset);background:var(--surface-app);color:var(--text-primary);
|
<div class="facts" aria-label="Erfasste Kennzahlen">
|
||||||
font:var(--fw-regular) var(--type-body-size)/var(--lh-prose) var(--font-ui)}
|
<div class="fact"><strong>${dc ? escapeHtml(dc) : DASH}</strong><span>Domain Controller erfasst</span></div>
|
||||||
h1,h2,h3{margin:0;line-height:var(--lh-ui);font-weight:var(--fw-semibold)}
|
<div class="fact"><strong>${count(projection.coverage.assessed)}</strong><span>Ergebnisse bewertet</span></div>
|
||||||
h1{font-size:var(--type-h1-size);letter-spacing:-.02em}
|
<div class="fact"><strong>${count(projection.coverage.deviations)}</strong><span>Abweichende Ergebnisse</span></div></div>
|
||||||
h2{font-size:var(--type-h2-size)}
|
<section aria-labelledby="heatmap-title"><div class="section-head"><h2 id="heatmap-title">Abweichungsanteil je Security Domain</h2><p>Abweichende von bewerteten Ergebnissen innerhalb des jeweiligen Bereichs.</p></div>
|
||||||
h3{font-size:var(--type-h3-size)}
|
<ol class="heatmap" aria-label="Heatmap der bewerteten Ergebnisse">${projection.domains.length ? projection.domains.map(tile).join('') : '<li class="unknown"><span class="area">Keine Bereiche verfügbar</span><strong class="value">—</strong><span class="count">Keine bewerteten Ergebnisse</span></li>'}</ol>
|
||||||
code{font-family:var(--font-ui);font-size:var(--type-table-body-size)}
|
<div class="legend" aria-label="Farbstufen nach Abweichungsanteil"><span>Farbstufe:</span><span class="key"><i class="l0"></i>0–24 %</span><span class="key"><i class="l1"></i>25–49 %</span><span class="key"><i class="l2"></i>50–74 %</span><span class="key"><i class="l3"></i>75–100 %</span></div></section>
|
||||||
strong{font-weight:var(--fw-semibold)}
|
<section class="comparison" aria-labelledby="comparison-title"><h2 id="comparison-title">Bestandene und abweichende Ergebnisse</h2>
|
||||||
.doc__head{padding-bottom:var(--space-6);border-bottom:1px solid var(--border-strong)}
|
<div class="comparison-scale" aria-hidden="true"><div><span>0 %</span><span>50 %</span><span>100 %</span></div></div>
|
||||||
.doc__kicker{margin:0 0 var(--space-2);font-size:var(--type-label-size);font-weight:var(--fw-semibold);
|
<ol class="comparison-list">${projection.domains.map(comparisonRow).join('')}</ol>
|
||||||
letter-spacing:var(--ls-caps);text-transform:uppercase;color:var(--text-secondary)}
|
<div class="comparison-key"><span><i class="pass"></i>Bestanden</span><span><i class="fail"></i>Abweichung</span></div>
|
||||||
.facts{display:flex;flex-wrap:wrap;gap:var(--space-6);margin:var(--space-5) 0 0}
|
<p>Pass Rate je Security Domain; rechts: Anzahl der Abweichungen von den bewerteten Ergebnissen.</p></section>
|
||||||
.facts__item{margin:0}
|
<aside class="notes" aria-labelledby="notes-title"><h2 id="notes-title">Lesart</h2><p>Die Farbe zeigt die Häufigkeit abweichender Ergebnisse innerhalb eines Bereichs. Sie zeigt weder Schweregrad noch Geschäftsrisiko. Bereiche mit wenigen Prüfungen können hohe Prozentwerte haben.</p>${gpoNote}<p>Die Ergebnisse können aus unterschiedlichen Erhebungszeitpunkten stammen. Betroffene Geschäftsdienste, Ausnutzung und aktueller Behebungsstand sind aus dieser Darstellung nicht ableitbar.</p></aside>
|
||||||
.facts dt{font-size:var(--type-label-size);font-weight:var(--fw-semibold);color:var(--text-secondary)}
|
<footer class="foot"><span><strong>ASSESSOR ${escapeHtml(ASSESSOR_PRODUCT_VERSION)}</strong> · ${escapeHtml(projection.project.name)}</span></footer>
|
||||||
.facts dd{margin:var(--space-1) 0 0;font-size:var(--type-table-body-size)}
|
</main></body></html>`;
|
||||||
.segment{margin-top:var(--space-8);padding:var(--segment-inset);background:var(--segment-surface);
|
|
||||||
border:1px solid var(--segment-border);border-radius:var(--segment-radius)}
|
|
||||||
.segment__body{display:flex;flex-wrap:wrap;gap:var(--segment-gap);margin-top:var(--space-4);align-items:flex-start}
|
|
||||||
.tile{padding:var(--tile-inset);background:var(--tile-surface);border:1px solid var(--tile-border);
|
|
||||||
border-radius:var(--tile-radius)}
|
|
||||||
.note{margin:var(--space-4) 0 0;font-size:var(--type-table-body-size);color:var(--text-secondary)}
|
|
||||||
.empty{margin:var(--space-4) 0 0;font-size:var(--type-table-body-size);color:var(--text-muted)}
|
|
||||||
.posture{display:flex;flex-wrap:wrap;gap:var(--segment-gap);margin-top:var(--space-4);align-items:stretch}
|
|
||||||
.posture__score{display:flex;align-items:center;justify-content:center;min-width:260px}
|
|
||||||
.posture__domains{flex:1 1 420px;min-width:320px}
|
|
||||||
.donut{position:relative;margin:0;width:var(--donut-ring-size);height:var(--donut-ring-size)}
|
|
||||||
.donut svg{width:100%;height:100%;display:block}
|
|
||||||
.donut circle{fill:none;stroke-width:var(--donut-ring-stroke);stroke-linecap:butt}
|
|
||||||
.donut__track{stroke:var(--donut-track)}
|
|
||||||
.donut__value{stroke:var(--donut-all)}
|
|
||||||
.donut__small-arc{fill:var(--donut-all)}
|
|
||||||
.donut__readout{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;
|
|
||||||
justify-content:center;gap:var(--space-1);text-align:center}
|
|
||||||
.donut__number{font-size:var(--donut-value-size);font-weight:var(--fw-semibold);color:var(--donut-value);
|
|
||||||
line-height:1;font-variant-numeric:tabular-nums}
|
|
||||||
.donut__unit{font-size:var(--donut-percent-size);font-weight:var(--fw-regular);color:var(--donut-label)}
|
|
||||||
.donut__caption{font-size:var(--type-badge-size);color:var(--donut-label)}
|
|
||||||
.spine{list-style:none;margin:var(--space-4) 0 0;padding:0 0 0 var(--space-4);
|
|
||||||
border-left:1px solid var(--border-subtle)}
|
|
||||||
.spine__item{position:relative;display:grid;grid-template-columns:minmax(150px,1fr) minmax(80px,140px) 52px;
|
|
||||||
gap:var(--space-2) var(--space-3);align-items:center;padding:var(--space-2) 0}
|
|
||||||
.spine__node{position:absolute;left:calc(var(--space-4) * -1 - 4px);width:7px;height:7px;border-radius:50%;
|
|
||||||
background:var(--donut-all)}
|
|
||||||
.spine__label{font-size:var(--type-table-body-size);font-weight:var(--fw-semibold)}
|
|
||||||
.spine__meter{height:6px;background:var(--donut-track)}
|
|
||||||
.spine__fill{display:block;height:100%;background:var(--donut-all)}
|
|
||||||
.spine__figure{font-size:var(--type-table-body-size);font-variant-numeric:tabular-nums;text-align:right}
|
|
||||||
.spine__counts{grid-column:1 / -1;font-size:var(--type-meta-size);color:var(--text-secondary)}
|
|
||||||
.domain{flex:1 1 460px;min-width:320px}
|
|
||||||
.domain__head{display:flex;align-items:baseline;justify-content:space-between;gap:var(--space-3);
|
|
||||||
padding-bottom:var(--space-2);border-bottom:1px solid var(--border-subtle)}
|
|
||||||
.domain__count{font-size:var(--type-meta-size);color:var(--text-secondary);font-variant-numeric:tabular-nums}
|
|
||||||
.source{margin-top:var(--space-4)}
|
|
||||||
.source__head{display:flex;flex-wrap:wrap;align-items:baseline;gap:var(--space-2)}
|
|
||||||
.source__tool{font-size:var(--type-h4-size);font-weight:var(--fw-semibold)}
|
|
||||||
.source__basis{font-size:var(--type-meta-size);color:var(--text-secondary)}
|
|
||||||
.source__more{margin:var(--space-2) 0 0;font-size:var(--type-meta-size);color:var(--text-muted)}
|
|
||||||
.findings{list-style:none;margin:var(--space-2) 0 0;padding:0}
|
|
||||||
.finding{position:relative;display:flex;flex-direction:column;gap:var(--space-1);
|
|
||||||
padding:var(--space-2) 0 var(--space-2) var(--space-3);border-bottom:1px solid var(--border-subtle)}
|
|
||||||
.finding__accent{position:absolute;left:0;top:var(--space-2);bottom:var(--space-2);width:3px;
|
|
||||||
background:var(--sev-unknown)}
|
|
||||||
.finding__accent--critical{background:var(--sev-critical)}
|
|
||||||
.finding__accent--high{background:var(--sev-high)}
|
|
||||||
.finding__accent--medium{background:var(--sev-medium)}
|
|
||||||
.finding__accent--low{background:var(--sev-low)}
|
|
||||||
.finding__accent--info{background:var(--sev-info)}
|
|
||||||
.finding__title{font-size:var(--type-table-body-size);font-weight:var(--fw-semibold);line-height:var(--lh-ui)}
|
|
||||||
.finding__meta{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2)}
|
|
||||||
.finding__asset{font-size:var(--type-meta-size);color:var(--text-secondary)}
|
|
||||||
.badge{display:inline-block;padding:1px var(--space-2);border-radius:var(--radius-xs);
|
|
||||||
font-size:var(--type-badge-size);font-weight:var(--fw-semibold);letter-spacing:var(--ls-caps);
|
|
||||||
text-transform:uppercase;color:var(--sev-on)}
|
|
||||||
.badge--critical{background:var(--sev-critical)}
|
|
||||||
.badge--high{background:var(--sev-high)}
|
|
||||||
.badge--medium{background:var(--sev-medium)}
|
|
||||||
.badge--low{background:var(--sev-low)}
|
|
||||||
.badge--info{background:var(--sev-info)}
|
|
||||||
.badge--neutral{background:transparent;color:var(--text-secondary);border:1px solid var(--border-strong);
|
|
||||||
text-transform:none;letter-spacing:0;font-variant-numeric:tabular-nums}
|
|
||||||
.kpi{flex:1 1 150px;min-width:140px;display:flex;flex-direction:column;gap:var(--space-1)}
|
|
||||||
.kpi__figure{font-size:var(--type-h2-size);font-weight:var(--fw-semibold);line-height:1;
|
|
||||||
font-variant-numeric:tabular-nums}
|
|
||||||
.kpi__label{font-size:var(--type-meta-size);color:var(--text-secondary)}
|
|
||||||
.verdicts{display:flex;flex-wrap:wrap;gap:var(--space-2) var(--space-6);margin:var(--space-4) 0 0}
|
|
||||||
.verdicts__row{display:flex;align-items:baseline;gap:var(--space-2)}
|
|
||||||
.verdicts dt{font-size:var(--type-meta-size);color:var(--text-secondary)}
|
|
||||||
.verdicts dd{margin:0;font-size:var(--type-table-body-size);font-weight:var(--fw-semibold);
|
|
||||||
font-variant-numeric:tabular-nums}
|
|
||||||
.table{width:100%;margin-top:var(--space-4);border-collapse:collapse;
|
|
||||||
font-size:var(--type-table-body-size)}
|
|
||||||
.table caption{margin-bottom:var(--space-2);text-align:left;font-size:var(--type-label-size);
|
|
||||||
font-weight:var(--fw-semibold);color:var(--text-secondary)}
|
|
||||||
.table th,.table td{padding:var(--space-2) var(--space-3);text-align:left;
|
|
||||||
border-bottom:1px solid var(--border-subtle)}
|
|
||||||
.table th{font-size:var(--type-table-head-size);font-weight:var(--fw-semibold);
|
|
||||||
letter-spacing:var(--ls-caps);text-transform:uppercase;color:var(--text-secondary)}
|
|
||||||
.table .num{text-align:right;font-variant-numeric:tabular-nums}
|
|
||||||
@media print{
|
|
||||||
:root{--surface-app:#FFFFFF;--surface-panel:#FFFFFF;--tile-surface:#FFFFFF}
|
|
||||||
body{padding:0}
|
|
||||||
.segment{break-inside:avoid;margin-top:var(--space-6)}
|
|
||||||
.tile{break-inside:avoid;border:1px solid var(--border-subtle)}
|
|
||||||
.finding{break-inside:avoid}
|
|
||||||
}
|
|
||||||
@media (max-width:720px){
|
|
||||||
h1{font-size:var(--type-h2-size)}
|
|
||||||
.spine__item{grid-template-columns:1fr 60px}
|
|
||||||
.spine__meter{grid-column:1 / -1}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
${head}
|
|
||||||
${posture}
|
|
||||||
${convergence}
|
|
||||||
${coverageSection}
|
|
||||||
${priorities}
|
|
||||||
${bundles}
|
|
||||||
${remediationSection}
|
|
||||||
${sources}
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -761,7 +761,7 @@ function DashboardFactGroup({ title, items, overviewItems, glyph, snapshot, evid
|
||||||
overviewItems?: DashboardFact[];
|
overviewItems?: DashboardFact[];
|
||||||
snapshot?: EnvironmentSnapshot; evidenceGroup: EnvironmentEvidenceGroup;
|
snapshot?: EnvironmentSnapshot; evidenceGroup: EnvironmentEvidenceGroup;
|
||||||
}) {
|
}) {
|
||||||
const tileItems = overviewItems ?? (title === 'Directory' ? items.slice(0, 6) : items);
|
const tileItems = (overviewItems ?? items).slice(0, 4);
|
||||||
return <section className="client-env-group">
|
return <section className="client-env-group">
|
||||||
<header className="client-env-group-header">
|
<header className="client-env-group-header">
|
||||||
<h3 className="client-env-group-title">
|
<h3 className="client-env-group-title">
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ export function OperatingSystemInventory({ groups, icon, snapshot }: {
|
||||||
}) {
|
}) {
|
||||||
const inventories = [...new Set(groups.flatMap(group => group.entries.map(entry => entry.inventory)))];
|
const inventories = [...new Set(groups.flatMap(group => group.entries.map(entry => entry.inventory)))];
|
||||||
const summary = groups.filter(group => group.total !== undefined).map(group => ({ label: group.label, value: quantity(group.total) }));
|
const summary = groups.filter(group => group.total !== undefined).map(group => ({ label: group.label, value: quantity(group.total) }));
|
||||||
const systems = groups.flatMap(group => group.entries).slice(0, Math.max(0, 8 - summary.length))
|
const systems = groups.flatMap(group => group.entries).slice(0, Math.max(0, 4 - summary.length))
|
||||||
.map(entry => ({ label: entry.name, value: quantity(entry.count) }));
|
.map(entry => ({ label: entry.name, value: quantity(entry.count) }));
|
||||||
return <section className="client-os-inventory" aria-label="Operating systems">
|
return <section className="client-os-inventory" aria-label="Operating systems">
|
||||||
<header>
|
<header>
|
||||||
|
|
@ -47,7 +47,7 @@ export function OperatingSystemInventory({ groups, icon, snapshot }: {
|
||||||
</EnvironmentResultsButton>
|
</EnvironmentResultsButton>
|
||||||
</header>
|
</header>
|
||||||
<dl className="client-env-facts">
|
<dl className="client-env-facts">
|
||||||
{summary.length || systems.length ? [...summary, ...systems].map((item, index) => <div key={`${item.label}:${index}`}>
|
{summary.length || systems.length ? [...summary, ...systems].slice(0, 4).map((item, index) => <div key={`${item.label}:${index}`}>
|
||||||
<dt>{item.label}</dt><dd>{item.value}</dd>
|
<dt>{item.label}</dt><dd>{item.value}</dd>
|
||||||
</div>) : <div><dt>Inventory</dt><dd>—</dd></div>}
|
</div>) : <div><dt>Inventory</dt><dd>—</dd></div>}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@
|
||||||
--tile-inset: var(--space-4);
|
--tile-inset: var(--space-4);
|
||||||
--tile-compact-width: 186px;
|
--tile-compact-width: 186px;
|
||||||
--tile-double-compact-width: calc(var(--tile-compact-width) * 2);
|
--tile-double-compact-width: calc(var(--tile-compact-width) * 2);
|
||||||
|
--tile-environment-min-width: 260px;
|
||||||
--tile-content-width: 358px;
|
--tile-content-width: 358px;
|
||||||
--tile-content-min-height: 223px;
|
--tile-content-min-height: 223px;
|
||||||
--tile-card-shadow: none;
|
--tile-card-shadow: none;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ const components = readFileSync(new URL('../../src/styles/components.css', impor
|
||||||
|
|
||||||
describe('ASSESSOR version in the brand header', () => {
|
describe('ASSESSOR version in the brand header', () => {
|
||||||
it('shows the centrally maintained product version and opens About from the brand', () => {
|
it('shows the centrally maintained product version and opens About from the brand', () => {
|
||||||
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.9.0');
|
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.9.1');
|
||||||
expect(app).toContain("import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';");
|
expect(app).toContain("import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';");
|
||||||
expect(app).toContain('aria-label="About ASSESSOR"');
|
expect(app).toContain('aria-label="About ASSESSOR"');
|
||||||
expect(app).toContain("import assessorLogo from '../assets/branding/assessor-icon-transparent.png';");
|
expect(app).toContain("import assessorLogo from '../assets/branding/assessor-icon-transparent.png';");
|
||||||
|
|
|
||||||
|
|
@ -1,248 +1,72 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
import { buildManagementOverviewHtml } from '../../src/exports/management-overview-report';
|
import { buildManagementOverviewHtml } from '../../src/exports/management-overview-report';
|
||||||
import type { ReportProjection } from '../../src/domain/report-projection';
|
import type { ReportProjection } from '../../src/domain/report-projection';
|
||||||
|
|
||||||
function projection(overrides: Partial<ReportProjection> = {}): ReportProjection {
|
function projection(overrides: Partial<ReportProjection> = {}): ReportProjection {
|
||||||
return {
|
return {
|
||||||
version: '1.1.0',
|
version: '1.1.0', generatedAt: '2026-09-29T10:00:00Z',
|
||||||
generatedAt: '2026-09-18T10:00:00.000Z',
|
project: { id: 'synthetic', name: 'Synthetic GmbH' },
|
||||||
project: { id: 'synthetic-project', name: 'Synthetic GmbH' },
|
environment: [{ key: 'Domain', value: 'synthetic.example' }, { key: 'Domain Controller', value: '1' }],
|
||||||
assessmentId: 'synthetic-assessment',
|
overallScore: 37, scoreSources: [],
|
||||||
environment: [{ key: 'Domain', value: 'synthetic.example' }, { key: 'Domain Controller', value: undefined }],
|
domains: [
|
||||||
overallScore: 62,
|
{ domainId: 'identity', domainLabel: 'Identity & Account Hygiene', passed: 204, deviations: 221, assessed: 425, passRate: 204 / 425 },
|
||||||
scoreSources: [],
|
{ domainId: 'gpo', domainLabel: 'GPO & Baseline', passed: 170, deviations: 223, assessed: 393, passRate: 170 / 393 },
|
||||||
domains: [],
|
{ domainId: 'cloud', domainLabel: 'Cloud & Applications', passed: 0, deviations: 0, assessed: 0 }
|
||||||
priorities: [],
|
],
|
||||||
coverage: {
|
priorities: [], coverage: { total: 818, assessed: 818, deviations: 444, passed: 374, notRun: 0,
|
||||||
total: 0, assessed: 0, deviations: 0, passed: 0, notRun: 0,
|
notApplicable: 0, executionErrors: 0, unknown: 0, perSource: [] },
|
||||||
notApplicable: 0, executionErrors: 0, unknown: 0, perSource: []
|
|
||||||
},
|
|
||||||
inventory: { bySeverity: {} as never, byOutcome: {} as never, byDomain: {}, bySource: {} },
|
inventory: { bySeverity: {} as never, byOutcome: {} as never, byDomain: {}, bySource: {} },
|
||||||
findings: [],
|
findings: [], policyEntries: [], workItems: [], sourcePriorities: [], convergence: [], bundles: [],
|
||||||
policyEntries: [],
|
singleSourcePolicyActions: [], diagnostics: [], provenance: [],
|
||||||
workItems: [],
|
gpo: { basis: { kind: 'equal-weight', reason: 'Synthetic' },
|
||||||
sourcePriorities: [],
|
counts: { Fail: 100, Pass: 20, 'Not configured': 0, 'No baseline rule': 0,
|
||||||
convergence: [],
|
'Not comparable': 0, 'Review required': 0 },
|
||||||
bundles: [],
|
conflicts: 0, duplicates: 0, affectedSettings: 25, affectedBackups: 1, scopeLimited: true },
|
||||||
singleSourcePolicyActions: [],
|
|
||||||
diagnostics: [],
|
|
||||||
provenance: [],
|
|
||||||
...overrides
|
...overrides
|
||||||
} as ReportProjection;
|
} as ReportProjection;
|
||||||
}
|
}
|
||||||
|
|
||||||
const finding = (overrides = {}) => ({
|
describe('management export', () => {
|
||||||
resultProjectionId: 'synthetic-1',
|
it('renders a self-contained factual heatmap and pass-rate bars from each projection', () => {
|
||||||
domainId: 'identity',
|
|
||||||
toolId: 'purple-knight',
|
|
||||||
title: 'Synthetic indicator',
|
|
||||||
severity: 'critical' as const,
|
|
||||||
outcome: 'fail' as const,
|
|
||||||
rankBasis: { kind: 'severity' as const },
|
|
||||||
ranked: true,
|
|
||||||
...overrides
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('management overview renderer', () => {
|
|
||||||
it('renders a self-contained document without script or remote resources', () => {
|
|
||||||
const html = buildManagementOverviewHtml(projection());
|
const html = buildManagementOverviewHtml(projection());
|
||||||
expect(html.startsWith('<!doctype html>')).toBe(true);
|
const document = new JSDOM(html).window.document;
|
||||||
expect(html).not.toMatch(/<script\b/i);
|
expect(document.documentElement.lang).toBe('de');
|
||||||
expect(html).not.toMatch(/\son[a-z]+\s*=/i);
|
expect(document.querySelectorAll('.heatmap li')).toHaveLength(3);
|
||||||
expect(html).not.toMatch(/https?:\/\//i);
|
expect(document.querySelector('.heatmap li')?.textContent).toContain('52 %');
|
||||||
expect(html).not.toMatch(/<link\b/i);
|
expect(document.querySelectorAll('.comparison-row')).toHaveLength(3);
|
||||||
expect(html).not.toMatch(/url\(/i);
|
expect(document.querySelector('.comparison-row')?.textContent).toContain('48 %');
|
||||||
|
expect(document.querySelector('.comparison-row .bar-pass')?.getAttribute('style')).toContain('48');
|
||||||
|
expect(document.body.textContent).toContain('444');
|
||||||
|
expect(document.body.textContent).toContain('100 abweichende Vergleiche zu 25 Einstellungen');
|
||||||
|
expect(html).not.toMatch(/<script\b|<link\b|url\(|https?:\/\//i);
|
||||||
|
expect(html).not.toContain('Overall Score');
|
||||||
|
expect(html).not.toContain('37/100');
|
||||||
|
expect(html).not.toContain('Source Overview');
|
||||||
|
expect(html).not.toContain('Priority Findings');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('declares German as the document language', () => {
|
it('shows an unassessed domain as unknown, not zero percent', () => {
|
||||||
expect(buildManagementOverviewHtml(projection())).toContain('<html lang="de">');
|
const document = new JSDOM(buildManagementOverviewHtml(projection())).window.document;
|
||||||
|
const tile = document.querySelectorAll('.heatmap li')[2];
|
||||||
|
expect(tile.className).toBe('unknown');
|
||||||
|
expect(tile.querySelector('.value')?.textContent).toBe('—');
|
||||||
|
expect(document.querySelectorAll('.comparison-row')[2].textContent).not.toContain('0 %');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the binding score wording and never claims baseline compliance', () => {
|
it('uses other customer values, escapes names and omits GPO evaluation when absent', () => {
|
||||||
const html = buildManagementOverviewHtml(projection());
|
|
||||||
expect(html).toContain('Konformität der konfigurierten, vergleichbaren');
|
|
||||||
expect(html).not.toMatch(/Baseline-?Compliance/i);
|
|
||||||
expect(html).not.toMatch(/Baseline-?Abdeckung/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps established English technical terms untranslated', () => {
|
|
||||||
const html = buildManagementOverviewHtml(projection({
|
const html = buildManagementOverviewHtml(projection({
|
||||||
domains: [{ domainId: 'gpo', domainLabel: 'GPO & Baseline', passed: 1, deviations: 1, assessed: 2, passRate: 0.5 }],
|
project: { id: 'other', name: '<Other Customer>' },
|
||||||
priorities: [{
|
environment: [], gpo: undefined,
|
||||||
domainId: 'identity', domainLabel: 'Identity & Account Hygiene', deviations: 1,
|
coverage: { ...projection().coverage, assessed: 10, deviations: 2 },
|
||||||
sources: [{ toolId: 'purple-knight', basis: { kind: 'severity' }, ranked: true, shown: [finding()], total: 1 }]
|
domains: [{ domainId: 'recovery', domainLabel: 'Monitoring & Recovery',
|
||||||
}]
|
passed: 8, deviations: 2, assessed: 10, passRate: 0.8 }]
|
||||||
}));
|
}));
|
||||||
expect(html).toContain('Priority Findings');
|
expect(html).toContain('<Other Customer>');
|
||||||
expect(html).toContain('Overall Score');
|
expect(html).not.toContain('synthetic.example');
|
||||||
expect(html).toContain('Security Domains');
|
expect(html).not.toContain('gesonderte GPO-Evaluation');
|
||||||
expect(html).toContain('Deviations');
|
expect(html).toContain('20 %');
|
||||||
expect(html).toContain('>Critical<');
|
expect(html).toContain('80 %');
|
||||||
expect(html).toContain('GPO & Baseline');
|
expect(html).not.toMatch(/<Other Customer>|<img\b/i);
|
||||||
// German carries the prose around the English terms.
|
|
||||||
expect(html).toContain('Gruppiert nach Security Domain');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders an unknown value as an em dash and never as a zero', () => {
|
|
||||||
const html = buildManagementOverviewHtml(projection({ overallScore: undefined }));
|
|
||||||
expect(html).toContain('—');
|
|
||||||
// No zero-length arc is drawn, and the readout shows no fabricated value.
|
|
||||||
expect(html).not.toContain('<circle class="donut__value"');
|
|
||||||
expect(html).not.toContain('<span class="donut__unit">');
|
|
||||||
expect(html).toContain('nicht verfügbar');
|
|
||||||
expect(html).toContain('<span class="donut__number">—</span>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('makes a positive score below two percent legible without changing its proportional arc', () => {
|
|
||||||
const html = buildManagementOverviewHtml(projection({ overallScore: 1 }));
|
|
||||||
expect(html).toContain('class="donut__small-arc"');
|
|
||||||
expect(html).toContain('stroke-dasharray="3.393 335.899"');
|
|
||||||
expect(buildManagementOverviewHtml(projection({ overallScore: 0.2 }))).toContain('<1<span class="donut__unit">/100</span>');
|
|
||||||
expect(buildManagementOverviewHtml(projection({ overallScore: 0 }))).not.toContain('class="donut__small-arc"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('draws the donut as a full circle starting at twelve o\'clock with flat caps', () => {
|
|
||||||
const html = buildManagementOverviewHtml(projection({ overallScore: 50 }));
|
|
||||||
expect(html).toContain('rotate(-90 64 64)');
|
|
||||||
expect(html).toContain('stroke-linecap:butt');
|
|
||||||
expect(html).toContain('donut__track');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('names the ordering basis of every source and never merges them', () => {
|
|
||||||
const html = buildManagementOverviewHtml(projection({
|
|
||||||
priorities: [{
|
|
||||||
domainId: 'hardening', domainLabel: 'System Hardening', deviations: 2,
|
|
||||||
sources: [
|
|
||||||
{ toolId: 'cis-cat', basis: { kind: 'equal-weight', reason: 'CIS benchmarks state no criticality.' }, ranked: false, shown: [finding({ severity: 'unknown', toolId: 'cis-cat' })], total: 1 },
|
|
||||||
{ toolId: 'pingcastle', basis: { kind: 'numeric', property: 'rule.points', scale: '0-50+', direction: 'higher-is-worse' }, ranked: true, shown: [finding({ toolId: 'pingcastle', severity: 'medium', rankValue: 35, rankBasis: { kind: 'numeric', property: 'rule.points', scale: '0-50+', direction: 'higher-is-worse' } })], total: 1 }
|
|
||||||
]
|
|
||||||
}]
|
|
||||||
}));
|
|
||||||
expect(html).toContain('Ohne Rangfolge: CIS benchmarks state no criticality.');
|
|
||||||
expect(html).toContain('Sortiert nach rule.points (0-50+, höher ist kritischer)');
|
|
||||||
expect(html).toContain('quellenübergreifende Rangfolge');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('points at the technical report instead of silently truncating a domain', () => {
|
|
||||||
const html = buildManagementOverviewHtml(projection({
|
|
||||||
priorities: [{
|
|
||||||
domainId: 'identity', domainLabel: 'Identity & Account Hygiene', deviations: 9,
|
|
||||||
sources: [{ toolId: 'purple-knight', basis: { kind: 'severity' }, ranked: true, shown: [finding()], total: 9 }]
|
|
||||||
}]
|
|
||||||
}));
|
|
||||||
expect(html).toContain('8 weitere Deviations dieser Quelle stehen im Technical Report.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps GPO equal-weight and marks the score-neutral verdicts as such', () => {
|
|
||||||
const html = buildManagementOverviewHtml(projection({
|
|
||||||
gpo: {
|
|
||||||
basis: { kind: 'equal-weight', reason: 'A baseline deviation carries no native criticality.' },
|
|
||||||
counts: {
|
|
||||||
Fail: 3, Pass: 10, 'Not configured': 40, 'No baseline rule': 5,
|
|
||||||
'Not comparable': 2, 'Review required': 1
|
|
||||||
},
|
|
||||||
conflicts: 1, duplicates: 2, affectedSettings: 3, affectedBackups: 2, scopeLimited: true
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
expect(html).toContain('A baseline deviation carries no native criticality.');
|
|
||||||
expect(html).toContain('gleichgewichtig');
|
|
||||||
expect(html).toContain('sind score-neutral und begründen keine Maßnahme');
|
|
||||||
expect(html).toContain('trifft keine Aussage über die gesamte Domäne');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits the remediation section entirely when no reviews exist', () => {
|
|
||||||
expect(buildManagementOverviewHtml(projection())).not.toContain('Remediation-Status');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks remediation as self reported and not as technical proof', () => {
|
|
||||||
const html = buildManagementOverviewHtml(projection({
|
|
||||||
remediation: {
|
|
||||||
selfReported: true, items: 4, byStatus: { Actionable: 4 },
|
|
||||||
withoutOwner: 1, withoutDue: 2, riskAccepted: 0
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
expect(html).toContain('Remediation-Status');
|
|
||||||
expect(html).toContain('Selbstauskunft');
|
|
||||||
expect(html).toContain('kein technischer Nachweis');
|
|
||||||
expect(html).toContain('Rescan');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('names the score method per source and explains an inverted native risk', () => {
|
|
||||||
const html = buildManagementOverviewHtml(projection({
|
|
||||||
scoreSources: [{
|
|
||||||
toolId: 'pingcastle', score: 55, method: 'inverted-native', weight: 1,
|
|
||||||
passed: 0, deviations: 20, assessed: 20, observedAt: '2026-07-22T00:00:00.000Z', issues: []
|
|
||||||
}]
|
|
||||||
}));
|
|
||||||
expect(html).toContain('Nativer Risk, invertiert als 100 − Risk');
|
|
||||||
expect(html).toContain('100 − Risk</code> in den Index ein');
|
|
||||||
expect(html).toContain('22.07.2026');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('escapes hostile content from a finding title', () => {
|
|
||||||
const html = buildManagementOverviewHtml(projection({
|
|
||||||
priorities: [{
|
|
||||||
domainId: 'identity', domainLabel: 'Identity & Account Hygiene', deviations: 1,
|
|
||||||
sources: [{
|
|
||||||
toolId: 'purple-knight', basis: { kind: 'severity' }, ranked: true, total: 1,
|
|
||||||
shown: [finding({ title: '<img src=x onerror=alert(1)>' })]
|
|
||||||
}]
|
|
||||||
}]
|
|
||||||
}));
|
|
||||||
expect(html).toContain('<img src=x onerror=alert(1)>');
|
|
||||||
expect(html).not.toMatch(/<img\b/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('management overview structure', () => {
|
|
||||||
it('keeps the agreed section order and heading levels', async () => {
|
|
||||||
const { JSDOM } = await import('jsdom');
|
|
||||||
const html = buildManagementOverviewHtml(projection({
|
|
||||||
remediation: {
|
|
||||||
selfReported: true, items: 1, byStatus: {}, withoutOwner: 0, withoutDue: 0, riskAccepted: 0
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
const { document } = new JSDOM(html).window;
|
|
||||||
expect([...document.querySelectorAll('h1')].map(node => node.textContent))
|
|
||||||
.toEqual(['Synthetic GmbH']);
|
|
||||||
expect([...document.querySelectorAll('h2')].map(node => node.textContent)).toEqual([
|
|
||||||
'Overall Posture', 'Konvergenz der Quellen', 'Abdeckung und Lücken',
|
|
||||||
'Priority Findings', 'Maßnahmenbündel', 'Remediation-Status', 'Source Overview'
|
|
||||||
]);
|
|
||||||
// Every section is labelled for assistive technology.
|
|
||||||
for (const section of document.querySelectorAll('section.segment')) {
|
|
||||||
expect(section.getAttribute('aria-labelledby')).toBeTruthy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('places no table inside a tile and never nests tables', async () => {
|
|
||||||
const { JSDOM } = await import('jsdom');
|
|
||||||
const { document } = new JSDOM(buildManagementOverviewHtml(projection({
|
|
||||||
coverage: {
|
|
||||||
total: 1, assessed: 1, deviations: 1, passed: 0, notRun: 0, notApplicable: 0,
|
|
||||||
executionErrors: 0, unknown: 0, perSource: [{ toolId: 'alpha', total: 1, assessed: 1, notRun: 0 }]
|
|
||||||
}
|
|
||||||
}))).window;
|
|
||||||
expect(document.querySelectorAll('.tile table')).toHaveLength(0);
|
|
||||||
expect(document.querySelectorAll('table table')).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('draws the value arc proportional to the score on a full circle', async () => {
|
|
||||||
const { JSDOM } = await import('jsdom');
|
|
||||||
const { document } = new JSDOM(buildManagementOverviewHtml(projection({ overallScore: 25 }))).window;
|
|
||||||
const arc = document.querySelector('circle.donut__value')!;
|
|
||||||
const [drawn, gap] = arc.getAttribute('stroke-dasharray')!.split(' ').map(Number);
|
|
||||||
const circumference = 2 * Math.PI * 54;
|
|
||||||
expect(drawn + gap).toBeCloseTo(circumference, 2);
|
|
||||||
expect(drawn / circumference).toBeCloseTo(0.25, 4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clamps an out-of-range score instead of drawing past the ring', async () => {
|
|
||||||
const { JSDOM } = await import('jsdom');
|
|
||||||
const { document } = new JSDOM(buildManagementOverviewHtml(projection({ overallScore: 140 }))).window;
|
|
||||||
const [drawn, gap] = document.querySelector('circle.donut__value')!
|
|
||||||
.getAttribute('stroke-dasharray')!.split(' ').map(Number);
|
|
||||||
expect(gap).toBeCloseTo(0, 3);
|
|
||||||
expect(drawn).toBeCloseTo(2 * Math.PI * 54, 2);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -35,11 +35,14 @@ it.each(['populated', 'unavailable', 'non-numeric count'])('shows each overview
|
||||||
expect(host.querySelector('.client-priority')).not.toBeNull();
|
expect(host.querySelector('.client-priority')).not.toBeNull();
|
||||||
expect(host.querySelectorAll('.client-data-fact')).toHaveLength(6);
|
expect(host.querySelectorAll('.client-data-fact')).toHaveLength(6);
|
||||||
expect(groups).toHaveLength(5);
|
expect(groups).toHaveLength(5);
|
||||||
|
const tiles = [...host.querySelectorAll('.client-environment-grid > section')];
|
||||||
|
expect(tiles).toHaveLength(6);
|
||||||
|
for (const tile of tiles) expect(tile.querySelectorAll('.client-env-facts > div').length).toBeLessThanOrEqual(4);
|
||||||
expect(labels(directory)).toEqual(['NetBIOS name', 'Schema version', 'Domain created', 'Entra ID']);
|
expect(labels(directory)).toEqual(['NetBIOS name', 'Schema version', 'Domain created', 'Entra ID']);
|
||||||
expect(labels(domain)).toEqual(mode === 'non-numeric count'
|
expect(labels(domain)).toEqual(mode === 'non-numeric count'
|
||||||
? ['Domain', 'Forest', 'Domain controllers', 'Domain functional level', 'Forest functional level', 'DC OS']
|
? ['Domain', 'Forest', 'Domain controllers', 'Domain functional level', 'Forest functional level', 'DC OS']
|
||||||
: ['Domain', 'Forest', 'Domain functional level', 'Forest functional level', 'DC OS']);
|
: ['Domain', 'Forest', 'Domain functional level', 'Forest functional level', 'DC OS']);
|
||||||
expect(labels(inventory)).toEqual(['Enabled / disabled', 'Password never expires', 'Admin groups', 'Trusts', 'Assessed host']);
|
expect(labels(inventory)).toEqual(['Enabled / disabled', 'Password never expires', 'Admin groups', 'Trusts']);
|
||||||
// Equal domain/forest and functional-level values are distinct facts, not duplicates.
|
// Equal domain/forest and functional-level values are distinct facts, not duplicates.
|
||||||
if (mode !== 'unavailable') {
|
if (mode !== 'unavailable') {
|
||||||
expect([...domain.querySelectorAll('dd')].filter(item => item.textContent === 'contoso.example')).toHaveLength(2);
|
expect([...domain.querySelectorAll('dd')].filter(item => item.textContent === 'contoso.example')).toHaveLength(2);
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,11 @@ describe('Client Info report buttons', () => {
|
||||||
expect(management.fileName).toMatch(/management-report-.*\.html$/);
|
expect(management.fileName).toMatch(/management-report-.*\.html$/);
|
||||||
expect(technical.fileName).toMatch(/technical-report-.*\.html$/);
|
expect(technical.fileName).toMatch(/technical-report-.*\.html$/);
|
||||||
expect(management.mediaType).toBe('text/html;charset=utf-8');
|
expect(management.mediaType).toBe('text/html;charset=utf-8');
|
||||||
expect(management.text).toContain('Dummy technical deviation');
|
expect(management.text).toContain('Abweichungsanteil je Security Domain');
|
||||||
|
expect(management.text).toContain('Bestandene und abweichende Ergebnisse');
|
||||||
|
expect(management.text).toContain('Dummy Customer');
|
||||||
|
expect(management.text).not.toContain('Dummy technical deviation');
|
||||||
|
expect(management.text).not.toContain('Overall Score');
|
||||||
expect(management.text).not.toContain('DUMMY-RULE-123');
|
expect(management.text).not.toContain('DUMMY-RULE-123');
|
||||||
expect(management.text).not.toContain('dummy-artifact-secret');
|
expect(management.text).not.toContain('dummy-artifact-secret');
|
||||||
expect(technical.text).toContain('DUMMY-RULE-123');
|
expect(technical.text).toContain('DUMMY-RULE-123');
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ describe('local ASSESSOR HTTP server', () => {
|
||||||
|
|
||||||
const health = await fetch(`${baseUrl}/api/v1/health`);
|
const health = await fetch(`${baseUrl}/api/v1/health`);
|
||||||
expect(await health.json()).toEqual({
|
expect(await health.json()).toEqual({
|
||||||
status: 'ok', application: 'ASSESSOR', productVersion: '2.9.0', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
|
status: 'ok', application: 'ASSESSOR', productVersion: '2.9.1', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
|
||||||
});
|
});
|
||||||
expect(health.headers.get('access-control-allow-origin')).toBeNull();
|
expect(health.headers.get('access-control-allow-origin')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue