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