155 lines
14 KiB
TypeScript
155 lines
14 KiB
TypeScript
import type { JsonValue } from '../domain/canonical-model';
|
|
import type { PolicySourceEvidence, RecordField } from '../domain/finding';
|
|
import type { ReportProjection, ReportReviewInput } from '../domain/report-projection';
|
|
import type { UnifiedResultProjection } from '../domain/unified-result';
|
|
import { escapeHtml } from '../shared/html';
|
|
|
|
export const TECHNICAL_REPORT_RENDERER_VERSION = '1.0.0';
|
|
|
|
const plain = (value: unknown): string => value === undefined || value === null || value === ''
|
|
? '—' : escapeHtml(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value));
|
|
const number = (value: number): string => escapeHtml(value.toLocaleString('de-DE'));
|
|
const when = (value: string): string => Number.isFinite(Date.parse(value))
|
|
? escapeHtml(new Date(value).toLocaleString('de-DE')) : plain(value);
|
|
|
|
function table(headers: string[], rows: string[][]): string {
|
|
return `<div class="table-scroll"><table><thead><tr>${headers.map(label => `<th>${escapeHtml(label)}</th>`).join('')}</tr></thead>
|
|
<tbody>${rows.map(row => `<tr>${row.map(cell => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
|
|
}
|
|
|
|
function sourceValue(value: JsonValue, markedFields: readonly string[], markedItems: readonly string[]): string {
|
|
if (Array.isArray(value)) {
|
|
const members = `<ul class="values">${value.map(item => `<li class="${typeof item === 'string' && markedItems.includes(item) ? 'different' : ''}">${sourceValue(item, [], [])}</li>`).join('')}</ul>`;
|
|
return markedFields.includes('value') && !markedItems.length ? `<div class="different">${members}</div>` : members;
|
|
}
|
|
if (value && typeof value === 'object') {
|
|
const instruction = `<dl class="instruction">${Object.entries(value).map(([key, item]) => {
|
|
const marked = markedFields.includes(key) || (markedFields.includes('value') && key === 'value');
|
|
return `<div><dt>${escapeHtml(key)}</dt><dd class="${marked ? 'different' : ''}">${sourceValue(item, [], markedItems)}</dd></div>`;
|
|
}).join('')}</dl>`;
|
|
return markedFields.includes('value') && !Object.hasOwn(value, 'value') && !markedItems.length
|
|
? `<div class="different">${instruction}</div>` : instruction;
|
|
}
|
|
return markedFields.includes('value') ? `<span class="different">${plain(value)}</span>` : plain(value);
|
|
}
|
|
|
|
function sourceSections(sources: PolicySourceEvidence[]): string {
|
|
if (!sources.length) return '<p class="limit">Keine einzelnen GPO-Quellanweisungen in diesem Snapshot verfügbar.</p>';
|
|
return sources.map(source => `<section class="source">
|
|
<h5>GPO Backup ${plain(source.gpoId)} · ${plain(source.nativeBackupId)}</h5>
|
|
<dl class="facts"><div><dt>Backup-Instanz</dt><dd>${plain(source.backupInstanceId)}</dd></div>
|
|
<div><dt>Evidence</dt><dd>${plain(source.sourceReference)}</dd></div>
|
|
<div><dt>Quellreihenfolge</dt><dd>${plain(source.sequence)}</dd></div></dl>
|
|
${source.values.map((value, index) => `<div class="source-value"><span>Anweisung ${number(index + 1)}</span>
|
|
${sourceValue(value, source.deviationFields?.[index] ?? [], source.deviationItems?.[index] ?? [])}</div>`).join('')}
|
|
</section>`).join('');
|
|
}
|
|
|
|
function resultDetail(result: UnifiedResultProjection, domainLabel: string, review?: ReportReviewInput, fields?: RecordField[]): string {
|
|
const actual = result.policyActualValue;
|
|
const expected = result.policyBaselineValue;
|
|
const variants = result.policyBaselineVariantAssessment;
|
|
return `<article class="finding">
|
|
<header><h4>${plain(result.title)}</h4><span>${plain(result.sourceTool)} · ${plain(result.severityNormalized)} · ${plain(result.outcomeNormalized)}</span></header>
|
|
<dl class="facts"><div><dt>Security Domain</dt><dd>${escapeHtml(domainLabel)}</dd></div>
|
|
<div><dt>Source Rule ID</dt><dd>${plain(result.sourceRuleId)}</dd></div>
|
|
<div><dt>Technische Identität</dt><dd>${plain(result.policyIdentityKey ?? result.canonicalDefinitionId)}</dd></div>
|
|
<div><dt>Scope</dt><dd>${plain(result.policyScope ?? result.affectedAsset)}</dd></div>
|
|
<div><dt>Result Status</dt><dd>${plain(result.policyClassification ?? result.outcomeNormalized)}</dd></div>
|
|
<div><dt>Baseline</dt><dd>${plain(result.policyBaselineLabel)}</dd></div></dl>
|
|
${result.description ? `<p>${plain(result.description)}</p>` : ''}
|
|
${result.recommendation ? `<p><strong>Empfehlung:</strong> ${plain(result.recommendation)}</p>` : ''}
|
|
${actual !== undefined || expected !== undefined ? table(['Konfiguriert', 'Erwartet', 'Vergleich'], [[plain(actual), plain(expected), plain(result.policyComparisonRelation)]])
|
|
: '<p class="limit">Diese Quelle liefert hier keinen strukturierten Ist-/Sollwert. Es wird keiner abgeleitet.</p>'}
|
|
${variants ? `<h5>Baseline-Varianten</h5><pre>${plain(variants)}</pre>` : ''}
|
|
${result.policyDiagnosticCodes?.length ? `<p><strong>Diagnostics:</strong> ${result.policyDiagnosticCodes.map(plain).join(', ')}</p>` : ''}
|
|
${result.frameworkReferences.length ? `<h5>Framework-Referenzen (exakte Quellwerte)</h5>${table(['Namespace', 'Referenz'],
|
|
result.frameworkReferences.map(reference => [plain(reference.namespace), plain(reference.value)]))}` : ''}
|
|
${fields?.length ? `<h5>Originale technische Felder</h5>${table(['Feld', 'Wert'], fields.map(field => [plain(field.label), plain(field.value)]))}` : ''}
|
|
${review?.nextStep ? `<p><strong>Geplanter Remediation-Schritt (unverifizierte Selbstauskunft):</strong> ${plain(review.nextStep)}</p>` : ''}
|
|
<p class="meta">Result-ID ${plain(result.resultProjectionId)} · ScanRun ${plain(result.scanRunId)} · ImportArtifact ${plain(result.importArtifactId)} · Source Locator ${plain(result.sourceLocator)}</p>
|
|
</article>`;
|
|
}
|
|
|
|
export function buildTechnicalReportHtml(projection: ReportProjection): string {
|
|
const results = projection.technicalResults ?? [];
|
|
const labels = new Map(projection.domains.map(domain => [domain.domainId, domain.domainLabel]));
|
|
const domainOf = (result: UnifiedResultProjection) => projection.technicalResultDomains?.[result.resultProjectionId] ?? 'unclassified';
|
|
const deviations = results.filter(result => result.isDeviation);
|
|
const severityOrder = ['critical', 'high', 'medium', 'low', 'info', 'unknown'];
|
|
const grouped = [...new Set(deviations.map(domainOf))].map(domainId => ({
|
|
domainId, label: labels.get(domainId) ?? domainId,
|
|
items: deviations.filter(result => domainOf(result) === domainId)
|
|
.sort((a, b) => severityOrder.indexOf(a.severityNormalized) - severityOrder.indexOf(b.severityNormalized)
|
|
|| a.title.localeCompare(b.title))
|
|
}));
|
|
const policySources = new Map((projection.technicalPolicySources ?? []).map(item => [item.settingId, item.sources]));
|
|
const reviews = new Map((projection.technicalReviews ?? []).map(item => [item.resultProjectionId, item]));
|
|
const fields = new Map((projection.technicalFindingFields ?? []).map(item => [item.resultProjectionId, item.fields]));
|
|
const neutral = new Set(['Not configured', 'No baseline rule', 'Not comparable']);
|
|
const policyRows = projection.policyEntries.map(item => [
|
|
plain(item.title), plain(item.scope), plain(item.identity),
|
|
`${plain(item.verdict)}${neutral.has(item.verdict) ? ' · score-neutral' : ''}`,
|
|
item.conflict ? 'Ja' : 'Nein', item.duplicate ? 'Ja' : 'Nein'
|
|
]);
|
|
const provenanceRows = projection.provenance.map(item => [plain(item.kind), plain(item.id), plain(item.version), plain(item.observedAt)]);
|
|
const diagnosticRows = projection.diagnostics.map(item => [plain(item.code), plain(item.reason), plain(item.origin), number(item.count)]);
|
|
return `<!doctype html>
|
|
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>ASSESSOR · Technical Report · ${escapeHtml(projection.project.name)}</title>
|
|
<style>
|
|
:root{font-family:Inter,system-ui,sans-serif;color:#20212a;background:#f1f1f4;font-size:16px}
|
|
*{box-sizing:border-box}body{max-width:1240px;margin:0 auto;padding:20px}h1{font-size:32px}h2{font-size:24px;margin:0 0 16px}
|
|
h3{font-size:18px}h4{font-size:16px;margin:0}h5{font-size:14px;margin:0 0 8px}p{line-height:1.5}
|
|
.head,.block,.finding,.source{background:#f5f5f7;padding:20px;margin:0 0 16px}.head{border-top:4px solid #e2a024}
|
|
.block{margin-top:24px}.finding,.source{background:#fff;border-left:2px solid #d6d6dc;break-inside:avoid}
|
|
.finding header{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;border-bottom:1px solid #d6d6dc;padding-bottom:12px}
|
|
.finding header span,.meta,.limit{color:#555967;font-size:14px}.facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 24px}
|
|
.facts div{min-width:0}.facts dt{font-size:14px;color:#555967}.facts dd{margin:2px 0 0;overflow-wrap:anywhere}
|
|
.table-scroll{overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:14px}th,td{text-align:left;vertical-align:top;border-bottom:1px solid #d6d6dc;padding:8px;overflow-wrap:anywhere}
|
|
th{text-transform:uppercase;font-weight:600;font-size:14px}pre{font:14px/1.5 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
.instruction{margin:8px 0}.instruction div{display:grid;grid-template-columns:140px 1fr;gap:8px;padding:4px 0}.instruction dt{font-weight:600}.instruction dd{margin:0;overflow-wrap:anywhere}
|
|
.different{background:#fce9e9;border-left:3px solid #bd3f45;padding:2px 6px}.values{margin:0;padding-left:20px}.source-value{border-top:1px solid #d6d6dc;padding-top:8px}
|
|
@media(max-width:700px){.facts{grid-template-columns:1fr}.instruction div{grid-template-columns:1fr}}
|
|
@media print{:root{background:#fff}body{max-width:none;padding:0}.block,.head{background:#fff}.finding,.source{background:#fff}h2,h3,h4{break-after:avoid}}
|
|
</style></head><body>
|
|
<header class="head"><p>ASSESSOR · Technical Report</p><h1>${escapeHtml(projection.project.name)}</h1>
|
|
<p>Assessment ${plain(projection.assessmentId)} · Stand ${when(projection.generatedAt)}</p>
|
|
<p class="limit">Frozen Snapshot des gewählten Timeslots. GPO-Aussagen beruhen auf Backupvergleichen; der effektive Endpunktzustand ist nicht verifiziert.</p></header>
|
|
<section class="block"><h2>Scope und Provenienz</h2>
|
|
${provenanceRows.length ? table(['Art', 'ID', 'Version', 'Beobachtet'], provenanceRows) : '<p class="limit">Keine Provenienz übermittelt.</p>'}</section>
|
|
<section class="block"><h2>Result-Inventar</h2><p>${number(projection.coverage.total)} Ergebnisse · ${number(projection.coverage.deviations)} Deviations · ${number(projection.coverage.passed)} Passed · ${number(projection.coverage.notRun)} Not run</p>
|
|
${table(['Severity', 'Anzahl'], Object.entries(projection.inventory.bySeverity).map(([label, count]) => [plain(label), number(count)]))}
|
|
${table(['Outcome', 'Anzahl'], Object.entries(projection.inventory.byOutcome).map(([label, count]) => [plain(label), number(count)]))}
|
|
${table(['Security Domain', 'Anzahl'], Object.entries(projection.inventory.byDomain).map(([id, count]) => [plain(labels.get(id) ?? id), number(count)]))}
|
|
${table(['Source Tool', 'Anzahl'], Object.entries(projection.inventory.bySource).map(([tool, count]) => [plain(tool), number(count)]))}</section>
|
|
<section class="block"><h2>Findings im Detail</h2>${grouped.length ? grouped.map(group => `<section><h3>${escapeHtml(group.label)}</h3>${group.items.map(item => resultDetail(item, group.label, reviews.get(item.resultProjectionId), fields.get(item.resultProjectionId))).join('')}</section>`).join('') : '<p>Keine Deviations in diesem Snapshot.</p>'}</section>
|
|
<section class="block"><h2>GPO Policy Register</h2><p class="limit">Conflicts und Duplicates sind separate Diagnostics, keine Ersatz-Verdicts. Not configured, No baseline rule und Not comparable sind score-neutral.</p>
|
|
${projection.gpo ? table(['Verdict / Diagnostic', 'Anzahl'], [
|
|
['Deviation', projection.gpo.counts.Fail], ['Passed', projection.gpo.counts.Pass],
|
|
['Not configured · score-neutral', projection.gpo.counts['Not configured']],
|
|
['No baseline rule · score-neutral', projection.gpo.counts['No baseline rule']],
|
|
['Not comparable · score-neutral', projection.gpo.counts['Not comparable']],
|
|
['Review required · unklar', projection.gpo.counts['Review required']],
|
|
['Conflicts', projection.gpo.conflicts], ['Duplicates', projection.gpo.duplicates]
|
|
].map(([label, count]) => [plain(label), number(Number(count))])) : ''}
|
|
${policyRows.length ? table(['Setting', 'Scope', 'Technische Identität', 'Verdict', 'Conflicts', 'Duplicates'], policyRows) : '<p class="limit">Keine gerollten GPO Policy Settings im gewählten Assessment vorhanden.</p>'}
|
|
${projection.policyEntries.map(item => `<section class="source"><h3>${plain(item.title)}</h3>${sourceSections(policySources.get(item.id) ?? [])}</section>`).join('')}</section>
|
|
<section class="block"><h2>Konvergenz-Nachweis</h2>
|
|
${table(['Security Domain', 'Quellen mit Deviations', 'Quellen', 'Evidence'], projection.convergence.map(item => [
|
|
plain(labels.get(item.domainId) ?? item.domainId), number(item.sourceCount),
|
|
plain(item.sources.filter(source => source.deviations > 0).map(source => source.toolId).join(', ')),
|
|
item.evidence === 'shared-framework-reference' ? 'Gemeinsame exakte Framework-Referenz' : 'Nur Domain-Nähe; Validierung erforderlich'
|
|
]))}
|
|
${projection.bundles.length ? table(['Framework-Referenz', 'Source Tools', 'Result-IDs'], projection.bundles.map(item => [
|
|
plain(item.key), plain(item.toolIds.join(', ')), plain(item.members.join(', '))
|
|
])) : ''}</section>
|
|
<section class="block"><h2>Scope-Grenzen</h2>
|
|
<p>GPO-Vergleiche gelten nur für das importierte Backup-Set. Ohne RSoP bzw. Group Policy Results wird kein wirksamer Endpoint-Zustand behauptet.</p>
|
|
<p><code>not_run</code> ist kein Pass. Nicht ausgeführte Checks und unbekannte Werte werden weder als bestanden noch als Deviation gezählt.</p></section>
|
|
<section class="block"><h2>Diagnostics</h2>${diagnosticRows.length ? table(['Code', 'Resolver-Grund', 'Herkunft', 'Anzahl'], diagnosticRows) : '<p>Keine aggregierten Diagnostics übermittelt.</p>'}</section>
|
|
<section class="block"><h2>Anhang: vollständiger Result-Index</h2>
|
|
${table(['Source Tool', 'Source Rule ID', 'Titel', 'Outcome', 'Severity', 'Security Domain'], results.map(item => [plain(item.sourceTool), plain(item.sourceRuleId), plain(item.title), plain(item.outcomeNormalized), plain(item.severityNormalized), plain(labels.get(domainOf(item)) ?? domainOf(item))]))}</section>
|
|
</body></html>`;
|
|
}
|