225 lines
19 KiB
TypeScript
225 lines
19 KiB
TypeScript
import type { DatasetSheet, ImportedDataset } from '../domain/dataset';
|
|
import type { FindingRecord } from '../domain/finding';
|
|
import type { Review } from '../domain/review';
|
|
import { toolIdForRecord } from '../importers/status-normalization';
|
|
import { recordResultSheets } from '../features/linked-results/record-results';
|
|
import { resultSectionsFor, resultStatusFieldFor } from '../features/linked-results/result-message';
|
|
import { isFileReference, resultReferenceValues } from '../profiles/purple-knight/result-linker';
|
|
import { formatPolicyComparisonValue } from '../profiles/group-policy/presentation';
|
|
import { policySourceGroups, policySourceValueLines } from '../profiles/group-policy/source-evidence';
|
|
import { parseMarkdownBlocks, type InlineToken } from '../shared/markdown';
|
|
import { rowsToSpreadsheetSafeCsv } from './csv-export';
|
|
import bundledInter from '@fontsource-variable/inter/files/inter-latin-wght-normal.woff2?inline';
|
|
|
|
export const WORK_VIEW_EXPORT_VERSION = '1.0.0';
|
|
export const WORK_VIEW_COLUMNS = ['Soll-Zustand', 'Abweichungen'] as const;
|
|
export interface WorkExportBlock { label: string; value: string; rich?: boolean; lines?: Array<{ text: string; deviating?: true }> }
|
|
export interface WorkExportEntry {
|
|
id: string;
|
|
title: string;
|
|
status: string;
|
|
expected: WorkExportBlock[];
|
|
observed: WorkExportBlock[];
|
|
resultSheetIds: string[];
|
|
resultRowCount: number;
|
|
}
|
|
export interface WorkViewExport {
|
|
version: string;
|
|
generatedAt: string;
|
|
context: WorkExportBlock[];
|
|
entries: WorkExportEntry[];
|
|
}
|
|
export interface WorkViewExportInput {
|
|
dataset: ImportedDataset;
|
|
sheet: DatasetSheet;
|
|
/** Already filtered and sorted by Work; never select again in an exporter. */
|
|
records: readonly FindingRecord[];
|
|
generatedAt: string;
|
|
context?: WorkExportBlock[];
|
|
reviews?: Record<string, Review>;
|
|
titleColumn?: string;
|
|
}
|
|
|
|
const EXPECTED = /^(?:baseline value|expected(?: value| result)?|desired(?: value| state)?|recommended value|soll(?:-zustand|wert)?)$/i;
|
|
const SOLUTION = /^(?:remediation(?: guidance| recommendation| action)?|advised solution|solution|recommendation|resolution)$/i;
|
|
const AUDIT = /^(?:audit(?: procedure)?|verification|verify|test procedure)$/i;
|
|
const GUIDANCE = /^(?:description|rationale|impact|technical explanation|documentation|references?|notes)$/i;
|
|
const CONTEXT = /^(?:id|rule id|shortname|technical identity|scope|target|registry path|baseline|policy type|policy section|imported|evaluated|source: .+|source file|source sheet)$/i;
|
|
const TITLES = /^(?:title|name|finding|benchmark item|check title)$/i;
|
|
const NEUTRAL = new Set(['Not configured', 'No baseline rule', 'Not comparable', 'Review required']);
|
|
const GPO_SCOPE = 'Vergleich importierter GPO-Backups, kein Nachweis der wirksamen Endpoint-Konfiguration. Baseline-Varianten sind getrennte Empfehlungen; ihre Anwendbarkeit und Zielrolle vor einer Änderung bestätigen. Keine GPO-Priorität aus der Quellenreihenfolge ableiten.';
|
|
|
|
function sourceBlocks(record: FindingRecord): WorkExportBlock[] {
|
|
return policySourceGroups(record.policySourceEvidence ?? []).flatMap(group => [
|
|
{ label: group.label, value: [group.backupLabel, `Backup-Identität: ${group.id}`].filter(Boolean).join('\n') },
|
|
...group.observations.map(source => {
|
|
const lines = [
|
|
`Evidence: ${source.sourceReference}`, `Observation: ${source.id}`,
|
|
source.sequence === undefined ? '' : `Source sequence: ${source.sequence}`
|
|
].filter(Boolean).map(text => ({ text }));
|
|
const instructions = source.values.flatMap((value, index) => policySourceValueLines(value, source.deviationFields?.[index], source.deviationItems?.[index]));
|
|
return { label: 'GPO-Quellbeleg', value: [...lines, ...instructions].map(line => line.text).join('\n'), lines: [...lines, ...instructions] };
|
|
})
|
|
]);
|
|
}
|
|
|
|
function entryFor(input: WorkViewExportInput, record: FindingRecord): WorkExportEntry {
|
|
const tool = toolIdForRecord(record, input.sheet.toolId ?? input.dataset.profileId);
|
|
const gpo = tool === 'group-policy';
|
|
const statusField = resultStatusFieldFor(record, tool);
|
|
const status = statusField?.value ?? 'Unknown';
|
|
const title = record.fields.find(field => field.label === input.titleColumn)?.value || record.title;
|
|
const expected: WorkExportBlock[] = [];
|
|
const observed: WorkExportBlock[] = [{ label: 'Result Status', value: status }];
|
|
const context = [`Finding: ${title}`, `Record ID: ${record.id}`, `Source tool: ${tool ?? 'Unknown'}`,
|
|
record.findingOccurrenceId ? `Occurrence: ${record.findingOccurrenceId}` : '',
|
|
record.scanRunId ? `Scan run: ${record.scanRunId}` : '', record.importFileId ? `Import file: ${record.importFileId}` : ''];
|
|
const resultSheets = recordResultSheets(record, input.dataset.sheets, input.sheet.id, tool);
|
|
const sections = resultSectionsFor(record, tool);
|
|
const sectionValues = new Set(sections.map(section => section.value));
|
|
let hasExpected = false;
|
|
let hasSolution = false;
|
|
let hasAudit = false;
|
|
for (const field of record.fields) {
|
|
if (!field.value || TITLES.test(field.label) || field.index === statusField?.index) continue;
|
|
if (CONTEXT.test(field.label)) { context.push(`${field.label}: ${field.value}`); continue; }
|
|
if (sectionValues.has(field.value)) continue;
|
|
if (EXPECTED.test(field.label)) {
|
|
hasExpected = true;
|
|
expected.push({ label: 'Sollwert · ' + field.label, value: gpo ? formatPolicyComparisonValue(field.value) : field.value });
|
|
// The reader formatter is intentionally human-facing; retain the typed original
|
|
// too so empty collections, strings, zero and type codes remain unambiguous.
|
|
if (gpo) expected.push({ label: 'Sollwert · typisierter Originalwert', value: field.value });
|
|
} else if (SOLUTION.test(field.label)) {
|
|
hasSolution = true;
|
|
expected.push({ label: 'Behebung laut Quelle · ' + field.label, value: field.value, rich: true });
|
|
} else if (AUDIT.test(field.label)) {
|
|
hasAudit = true;
|
|
expected.push({ label: 'Prüfung laut Quelle · ' + field.label, value: field.value, rich: true });
|
|
} else if (GUIDANCE.test(field.label) && !gpo) {
|
|
// Maester retains its complete remediation within Description. Do not cut it off.
|
|
expected.push({ label: 'Regel und Anleitung laut Quelle · ' + field.label, value: field.value, rich: true });
|
|
} else if (gpo && field.label === 'Configured Value') {
|
|
if (!record.policySourceEvidence?.length) observed.push({ label: 'Konfiguration im Backup', value: formatPolicyComparisonValue(field.value) });
|
|
} else if (gpo && ['Sources', 'GPO conflict details', 'GPO duplicate details'].includes(field.label)) {
|
|
if (!resultSheets.length && !record.policySourceEvidence?.length) observed.push({ label: field.label, value: field.value });
|
|
} else if (/^(?:results?|verwandte daten)$/i.test(field.label) && resultSheets.length) {
|
|
// The matching sheets below carry these references in full, not only their filenames.
|
|
} else observed.push({ label: field.label, value: field.value, rich: !gpo && /^(?:description|details?|result message)$/i.test(field.label) });
|
|
}
|
|
expected.unshift({ label: 'Finding und Geltungsbereich', value: context.filter(Boolean).join('\n') });
|
|
if (gpo) expected.push({ label: 'Geltungsgrenze', value: GPO_SCOPE });
|
|
if (gpo && NEUTRAL.has(status)) {
|
|
expected.push({ label: 'Einordnung', value: status === 'Not configured'
|
|
? 'Score-neutral: Empfehlung im analysierten Backup-Set nicht konfiguriert. Keine automatische Behebungspflicht; vollständiges Baseline-Hardening ist ein eigener Auftrag.'
|
|
: status === 'No baseline rule' ? 'Score-neutral: Keine anwendbare Baseline-Regel vorhanden. Kein belegter Sollwert und keine daraus ableitbare Behebungspflicht.'
|
|
: 'Score-neutral: Vergleich nicht abschließend möglich. Zuerst Anwendbarkeit, technische Vergleichbarkeit und Diagnostik klären; keine Abweichung erfinden.' });
|
|
}
|
|
if (!hasExpected) expected.push({ label: 'Sollwert', value: 'Kein separater Sollwert hinterlegt. Maßgeblich sind die wiedergegebenen Quell-Anleitungen; es wird kein Wert aus dem Finding-Titel abgeleitet.' });
|
|
if (!hasSolution) expected.push({ label: 'Behebung', value: 'Keine separate Behebungsanleitung hinterlegt. Eingebettete Anleitung und Referenzen im Regeltext prüfen. Fehlen diese ebenfalls, ist eine fachliche Klärung erforderlich.' });
|
|
if (!hasAudit) expected.push({ label: 'Nachweis nach Änderung', value: gpo
|
|
? 'Nach bestätigter Anwendbarkeit und freigegebener Änderung neue GPO-Backups importieren und dieselbe technische Einstellung erneut auswerten. Wirksamkeit am Endpoint separat prüfen; der Backup-Vergleich beweist sie nicht.'
|
|
: 'Nach freigegebener Änderung dieselbe Regel im gleichen Scope erneut mit dem Quellwerkzeug ausführen. Nur ein vergleichbarer Rescan belegt die Behebung; Review-Status und Abwesenheit eines Findings reichen nicht.' });
|
|
observed.push(...sourceBlocks(record));
|
|
const referencedTables = [...new Set(resultReferenceValues(record).flatMap(value =>
|
|
[...value.matchAll(/source-table:(\d+)/g)].map(match => Number(match[1]))))];
|
|
const linkedTables = new Set(resultSheets.map(sheet => sheet.sourceTableIndex));
|
|
const missingTables = referencedTables.filter(index => !linkedTables.has(index));
|
|
if (missingTables.length) observed.push({ label: 'Results · fehlende Belege',
|
|
value: `Referenzierte Ergebnistabellen nicht geladen: ${missingTables.map(index => `source-table:${index}`).join(', ')}. Die verfügbaren Belege folgen; der Nachweis ist unvollständig.` });
|
|
for (const section of sections) observed.push({ label: 'Results · ' + section.title, value: section.value, rich: !/xml/i.test(section.title) });
|
|
let resultRowCount = 0;
|
|
for (const sheet of resultSheets) {
|
|
observed.push({ label: 'Results · ' + sheet.name, value: [`Sheet ID: ${sheet.id}`, sheet.sourceFile ? `Source: ${sheet.sourceFile}` : '', sheet.resultPreamble ?? '', `Zeilen: ${sheet.analysis.records.length}`].filter(Boolean).join('\n') });
|
|
for (const [index, row] of sheet.analysis.records.entries()) {
|
|
resultRowCount++;
|
|
// Vertical name/value records remain readable with arbitrarily wide source tables.
|
|
observed.push({ label: `${sheet.name} · Zeile ${index + 1}${row.highlighted ? ' · vom Quellbericht hervorgehoben' : ''}`,
|
|
value: sheet.analysis.headers.map(header => `${header}: ${row.fields.find(field => field.label === header)?.value ?? ''}`).join('\n') });
|
|
}
|
|
}
|
|
if (!resultSheets.length && !sections.length) {
|
|
const refs = resultReferenceValues(record).filter(isFileReference);
|
|
observed.push({ label: 'Results', value: refs.length
|
|
? `Referenzierte Results nicht geladen: ${[...new Set(refs)].join(', ')}. Diese Belege fehlen im Export; Quelldateien im selben Projekt laden.`
|
|
: 'Keine zusätzlichen Results hinterlegt. Dies ist kein Nachweis, dass keine Abweichung besteht.' });
|
|
}
|
|
const review = input.reviews?.[record.id];
|
|
if (review) {
|
|
const plan = [review.nextStep && `Next step: ${review.nextStep}`, review.prerequisites && `Prerequisites: ${review.prerequisites}`,
|
|
review.access && `Access: ${review.access}`, review.owner && `Owner: ${review.owner}`, review.due && `Due: ${review.due}`].filter(Boolean);
|
|
if (plan.length) expected.push({ label: 'Geplante Maßnahme · ungeprüfte Review-Angaben', value: plan.join('\n') });
|
|
observed.push({ label: 'Review · kein technischer Behebungsnachweis', value: [
|
|
`Status: ${review.status}`, `Priority: ${review.priority}`, review.comment && `Comment: ${review.comment}`,
|
|
review.evidence && `Evidence (Review): ${review.evidence}`, review.riskAcceptance ? `Risk acceptance: ${JSON.stringify(review.riskAcceptance)}` : ''
|
|
].filter(Boolean).join('\n') });
|
|
}
|
|
return { id: record.id, title, status, expected, observed, resultSheetIds: resultSheets.map(sheet => sheet.id), resultRowCount };
|
|
}
|
|
|
|
/** Click-time, platform-neutral, frozen projection shared by all three view formats. */
|
|
export function buildWorkViewExport(input: WorkViewExportInput): WorkViewExport {
|
|
return { version: WORK_VIEW_EXPORT_VERSION, generatedAt: input.generatedAt,
|
|
context: [{ label: 'Ansicht', value: `${input.dataset.fileName} · ${input.sheet.name}` },
|
|
{ label: 'Exportzeitpunkt', value: input.generatedAt }, { label: 'Treffer', value: String(input.records.length) },
|
|
...(input.context ?? []),
|
|
{ label: 'Umfang', value: 'Alle Treffer der aktuellen Filterung und Sortierung, einschließlich Results. Nicht passende oder noch nicht bewertete Treffer bleiben unverändert; keine Neuberechnung. Quelltexte bleiben in ihrer Originalsprache.' }],
|
|
entries: input.records.map(record => entryFor(input, record)) };
|
|
}
|
|
|
|
function inlineText(tokens: InlineToken[]): string {
|
|
return tokens.map(token => token.kind === 'strong' || token.kind === 'emphasis' ? inlineText(token.children)
|
|
: token.kind === 'link' ? `${inlineText(token.children)} (${token.href})` : token.text).join('');
|
|
}
|
|
function blockText(block: WorkExportBlock): string {
|
|
if (!block.rich) return block.value;
|
|
return parseMarkdownBlocks(block.value).map(part => part.kind === 'code' ? part.text
|
|
: part.kind === 'list' ? part.items.map((item, index) => `${part.ordered ? `${index + 1}.` : '•'} ${inlineText(item)}`).join('\n')
|
|
: part.kind === 'table' ? part.table.rows.map((row, index) => `Zeile ${index + 1}\n` + part.table.headers.map((header, column) => `${header}: ${row[column] ?? ''}`).join('\n')).join('\n\n')
|
|
: inlineText(part.content)).join('\n\n');
|
|
}
|
|
const blocksText = (blocks: WorkExportBlock[]) => blocks.map(block => `${block.label}\n${blockText(block)}`).join('\n\n');
|
|
const escapeHtml = (value: string) => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
|
const markdownCell = (value: string) => escapeHtml(value).replace(/[\\`*_{}\[\]()#!~]/g, '\\$&').replaceAll('|', '|').replace(/\r\n?|\n/g, '<br>');
|
|
|
|
export function workViewCsvRows(report: WorkViewExport): string[][] {
|
|
// A CSV has no cell-size limit, but Excel silently loses oversized cells. Explicit
|
|
// continuation rows retain two columns and the same finding ID, never new findings.
|
|
const chunks = (text: string) => {
|
|
const result: string[] = [];
|
|
while (text.length > 30000) {
|
|
let end = text.lastIndexOf('\n', 30000);
|
|
if (end < 15000) end = 30000;
|
|
else end += 1;
|
|
if (/[\uD800-\uDBFF]/.test(text[end - 1])) end--;
|
|
result.push(text.slice(0, end)); text = text.slice(end);
|
|
}
|
|
result.push(text);
|
|
return result;
|
|
};
|
|
return [[...WORK_VIEW_COLUMNS], ...report.entries.flatMap(entry => {
|
|
const left = chunks(`${blocksText(report.context)}\n\n${blocksText(entry.expected)}`);
|
|
const right = chunks(blocksText(entry.observed));
|
|
const count = Math.max(left.length, right.length);
|
|
return Array.from({ length: count }, (_, index) => {
|
|
const prefix = count > 1 ? `Finding ID: ${entry.id}\nTeil ${index + 1}/${count} · Fortsetzungszeile desselben Findings\n\n` : '';
|
|
return [prefix + (left[index] ?? ''), prefix + (right[index] ?? '')];
|
|
});
|
|
})];
|
|
}
|
|
export function renderWorkViewCsv(report: WorkViewExport, delimiter = ';'): string {
|
|
return '\ufeff' + rowsToSpreadsheetSafeCsv(workViewCsvRows(report), delimiter);
|
|
}
|
|
export function renderWorkViewMarkdown(report: WorkViewExport): string {
|
|
return `# ASSESSOR · Aktuelle Work-Ansicht\n\n${markdownCell(blocksText(report.context))}\n\n| ${WORK_VIEW_COLUMNS.join(' | ')} |\n| --- | --- |\n`
|
|
+ report.entries.map(entry => `| ${markdownCell(blocksText(entry.expected))} | ${markdownCell(blocksText(entry.observed))} |`).join('\n') + '\n';
|
|
}
|
|
export function renderWorkViewHtml(report: WorkViewExport): string {
|
|
const blocks = (items: WorkExportBlock[]) => items.map(item => `<section class="fact${item.rich ? ' prose' : ''}"><h4>${escapeHtml(item.label)}</h4><div class="value">${item.lines ? item.lines.map(line => line.deviating ? `<span class="deviation-value">${escapeHtml(line.text)}</span>` : escapeHtml(line.text)).join('\n') : escapeHtml(blockText(item))}</div></section>`).join('');
|
|
return `<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; font-src data:; base-uri 'none'; form-action 'none'"><title>ASSESSOR · Work-Ansicht</title><style>
|
|
*{box-sizing:border-box}body{margin:0;background:#f3f4f6;color:#192231;font-family:Inter,system-ui,sans-serif;font-size:16px;line-height:1.5;font-weight:400}main{max-width:1500px;margin:auto;padding:20px}h1{font-size:32px;font-weight:600;margin:0 0 20px}h2{font-size:20px;font-weight:600;overflow-wrap:anywhere}h4{font-size:16px;font-weight:600;margin:0 0 6px}.meta{font-size:12px}.finding{margin:28px 0;background:white;padding:20px;border-top:3px solid #adb2bb}.columns{width:100%;table-layout:fixed;border-collapse:collapse}.columns th{text-align:left;font-size:12px;font-weight:600;padding:12px;background:#e9ecf0}.columns td{vertical-align:top;width:50%;padding:16px;border:1px solid #d7dbe1}.fact{margin:0 0 20px;min-width:0}.value{white-space:pre-wrap;overflow-wrap:anywhere;word-break:normal}.meta .fact{margin:0 0 8px}.finding-count{font-size:12px}footer{font-size:12px}@page{size:A4 landscape;margin:12mm}@media print{body{background:white}main{padding:0;max-width:none}.finding{padding:0;break-before:page}.finding:first-of-type{break-before:auto}h2,h4{break-after:avoid}.columns thead{display:table-header-group}.columns td{padding:10px}.fact{break-inside:auto}}@media(max-width:700px){.columns,.columns tbody,.columns tr,.columns td{display:block;width:100%}.columns thead{display:none}.columns td:before{display:block;font-weight:600;margin-bottom:16px}.columns td:first-child:before{content:'Soll-Zustand'}.columns td:last-child:before{content:'Abweichungen'}}
|
|
.deviation-value{background:#f5dce0;border-left:2px solid #bf3a50;padding:0 4px}
|
|
@font-face{font-family:Inter;src:url('${bundledInter}') format('woff2');font-style:normal;font-weight:100 900;font-display:swap}h1{font-size:56px;overflow-wrap:anywhere}.columns .value{font-size:14px}.columns .prose .value{font-size:16px}.meta h4{font-size:12px}
|
|
</style></head><body><main><h1>ASSESSOR · Aktuelle Work-Ansicht</h1><div class="meta">${blocks(report.context)}</div>${report.entries.map((entry, index) => `<article class="finding"><div class="finding-count">${index + 1} / ${report.entries.length} · ${escapeHtml(entry.status)}</div><h2>${escapeHtml(entry.title)}</h2><table class="columns"><thead><tr><th scope="col">Soll-Zustand</th><th scope="col">Abweichungen</th></tr></thead><tbody><tr><td>${blocks(entry.expected)}</td><td>${blocks(entry.observed)}</td></tr></tbody></table></article>`).join('')}<footer>Exportvertrag ${escapeHtml(report.version)} · Statischer Snapshot. Review-Angaben sind kein technischer Behebungsnachweis.</footer></main></body></html>`;
|
|
}
|