Neuer Stand Assessor #1

Merged
bika merged 33 commits from AI-integration into experimental 2026-09-25 09:34:44 +00:00
18 changed files with 782 additions and 366 deletions
Showing only changes of commit 35a1520150 - Show all commits

View File

@ -71,7 +71,7 @@
## UI Work filter context
- Explicit user extension 2026-09-18: the Work toolbar's `CSV`, `MD` and `HTML` buttons export the complete current filtered/sorted Work record set, including the same linked and embedded Results used by the Results window. Marking alone does not select export rows; `My Workspace` restricts them only when active, alongside ordinary filters and drilldowns. Use exactly two content columns, `Soll-Zustand` and `Abweichungen`, with source-backed remediation/verification, original technical facts, scoped GPO provenance, and explicit missing-evidence notices. Preserve neutral verdicts and review-versus-rescan boundaries. Build the frozen projection only on export, not on navigation. Oversized CSV cells use explicitly identified continuation rows of the same finding without truncation. This view snapshot is separate from the curated Arbeitsgrundlage in `docs/REPORT_STRATEGY.md`.
- Explicit user extension 2026-09-18: the Work toolbar's `XLSX`, `MD` and `HTML` buttons export the complete current filtered/sorted Work record set, including the same linked and embedded Results used by the Results window. Marking alone does not select export rows; `My Workspace` restricts them only when active, alongside ordinary filters and drilldowns. Markdown and HTML use exactly two content columns, `Soll-Zustand` and `Abweichungen`, with source-backed remediation/verification, original technical facts, scoped GPO provenance, and explicit missing-evidence notices. Preserve neutral verdicts and review-versus-rescan boundaries. Build the frozen projection only on export, not on navigation. Oversized Excel cells use explicitly identified continuation rows of the same finding without truncation. Explicit user corrections 2026-09-24: `XLSX` replaces the former `CSV` button and mirrors the Work finding reader instead of the two-column layout: an `Übersicht` sheet with the export context, per-tool counts and the review/GPO reading boundaries, then one sheet per tool in Work order with one row per finding. Columns are Nr, Finding, Status, Severity, Badges and Source Rule ID, then the reader's own fields under their reader labels in reader order (reusing `selectFindingFields`, e.g. Description, Advised solution, Configured customer value, Expected baseline value, GPO sources), then Hinweise for status-specific neutral notes, Results (the Results window content including missing-evidence notices), Additional Information, Review and Record ID; empty optional columns are omitted per sheet. Cells are literal strings (never formulas); the status cell is highlighted only when Work's own Deviation predicate matches. This view snapshot is separate from the curated Arbeitsgrundlage in `docs/REPORT_STRATEGY.md`.
- A unique `Source: Scan` selection is a semantic tool context even when Work remains on the project-wide `All Records` sheet. Visible and active facets must follow the selected tool context rather than the aggregate sheet profile.
- For a GPO-only Work context, whether reached through the dedicated GPO sheet or `Source: Scan = GPOs`, show only `Result Status`, the available GPO facets `Baseline`, `Comparison`, `Conflict`, `Description source`, `Diagnostics`, `Duplicates`, `Imported`, `Policy section`, `Policy type`, and the permanent final `Source: Scan` control. Do not show CIS, severity, category, MITRE, target, result-message, review, or scanner-specific facets.

View File

@ -2753,6 +2753,8 @@ Festgelegtes Betriebsrisiko vom 2026-09-06: Verzeichnisabhaengige Compose-Projek
## 12. Änderungsprotokoll
2026-09-24 (Phase 7, Work-Ansichtsexport als Excel im Finding-Reader-Layout, ausdrueckliche Nutzerauftraege): Der Work-Button `CSV` ist durch `XLSX` ersetzt; `MD` und `HTML` bleiben zweispaltig unveraendert. Auf weitere Nutzerkorrekturen ist die Arbeitsmappe weder Zweispalten- noch Soll/Ist-Tabelle, sondern bildet den Work-Finding-Reader ab: Blatt `Übersicht` mit Exportkontext, Findings/Deviation je Tool und den einmal genannten Lesegrenzen (Review ist kein technischer Behebungsnachweis; GPO-Backupvergleich ist kein Nachweis wirksamer Endpoint-Konfiguration), danach ein Blatt je Tool in Work-Reihenfolge mit einer Zeile je Finding. Spalten: Nr, Finding, Status, Severity, Badges, Source Rule ID, dann die Readerfelder unter ihren Readerlabels in Readerreihenfolge ueber das wiederverwendete `selectFindingFields` (dafuer ohne Verhaltensaenderung aus der React-Komponente in das plattformneutrale `src/features/finding-reader/finding-fields.ts` verschoben, damit `src/exports` keine UI-Abhaengigkeit erhaelt) (z. B. Description, Technical explanation, Advised solution; bei GPO Configured customer value, Expected baseline value, GPO sources), danach Hinweise (nur statusbezogene score-neutrale Einordnung), Results (Inhalt des Results-Fensters samt fehlender Belege), Additional Information, Review und Record ID; leere optionale Spalten entfallen je Blatt. Die Deviation-Markierung der Statuszelle nutzt ausschliesslich Works eigenes Deviation-Praedikat. Fettgedruckte graue Kopfzeile, Autofilter, fixierte Kopfzeile und Spalten Nr/Finding, Umbruch oben ausgerichtet, Spaltenbreiten nach Inhalt begrenzt. Zellen ueber dem Excel-Limit laufen in markierten Fortsetzungszeilen weiter, Identitaetsspalten wiederholen sich. Alle Zellen sind literale Inline-Strings ohne Formel; Steuerzeichen bleiben per OOXML `_xHHHH_` exakt, Additional Information bleibt unformatierter Quelltext. Workbook-XML wird selbst geschrieben, weil SheetJS Community keine Zellstile schreibt; SheetJS `xlsx` 0.18.5 liefert nur den Zip-Container, keine neue Abhaengigkeit. `DownloadAdapter` akzeptiert zusaetzlich Binaerinhalt. Synthetische Unit- und Klickpfadtests (Blatt je Tool, Readerspalten und -reihenfolge, GPO-Werte/-Quellen, Results, Review, Hinweise, Uebersicht, Stile, Filter, Fixierung, keine Formeln, Steuerzeichen, Fortsetzungszeilen) bestanden; eine synthetische PingCastle/CIS-CAT/GPO-Datei oeffnet in Microsoft Excel ohne Reparaturhinweis. Keine Daten-, Import-, Score- oder Reimportaenderung. Nach Code-Review im selben Batch: Gruppierung je Tool linear statt quadratisch, Spaltenbreiten ohne Argument-Spread (250.000 synthetische Findings exportieren ohne Stackueberlauf), und ein Quellfeld mit dem Namen einer festen Spalte (z. B. `Nr`, `Review`) erhaelt eine eigene Spalte `<Label> (Source field)` statt in die feste Spalte eingemischt zu werden. `toolIdForRecord` erkennt zudem die bereichsbezogenen Purple-Knight-Laufnamen (`Purple Knight for Active Directory`/`for Entra ID`/`for Active Directory and Entra ID`) der Projektaggregation; zuvor fielen diese Datensaetze im gemischten `All Records`-Blatt auf das Profil `generic` (`Tabular Data`) zurueck, was das Excel-Blatt falsch benannte und in Work die generische statt der Purple-Knight-Statusnormalisierung verwendete. Aufloesung ueber bereits gespeicherte Provenienz, kein Reimport. Patchversion 2.6.3.
2026-09-24 (Phase 7, GPO-Registry-Zustaende lesbar): Registry-Sollzustaende erscheinen im Finding Reader, in Remediation und Baseline-Empfehlungen nicht mehr als interne Codes `State: absent/key-empty/key-present`, sondern als `Value not set`, `No values under this key` und `Registry key exists` (Nutzerwortlaut). Ein fehlender Kundenzustand zeigt immer `Not configured`, auch wenn der Loeschvergleich intern einen synthetischen `absent`-Istwert fuehrt; `Registry type` entfaellt fuer Schluesselanweisungen (delete-all-values, create-key) und fehlende Typcodes (vorher `0`/`undefined`). Reine Darstellung ueber bestehenden Fakten: Urteile, Filter, Score und gespeicherte Vergleiche unveraendert, keine Reevaluation und kein Reimport. Offen und unentschieden bleibt, ob fehlende leere GPMC-Schluessel (z. B. `SrpV2\Dll`) weiter als Fail zaehlen. Im selben Batch zeigt `Expected baseline value` Baseline-Varianten lesbar je Baseline und Template (`<Template>: <Wert> · Match/Differs`, Fallback `Variant N`) statt interner `Setting Id`/`Relation`-JSON; leere Kontenlisten heissen `Empty account list` statt `Not set`; Kontenlisten-Differenzen stehen dort als `Additional`/`Missing`. Einzelne Konsenswerte bleiben typisiertes JSON. Die GPO-Description nennt ADML-Erklaerung und Geltungsgrenzen nur noch einmal, dazu das Gesamturteil und die Baselines gruppiert nach Urteil, ohne je Template wiederholte Boilerplate, `Setting reference`-IDs und Sollwertbloecke. Remediation nennt die Template-Klaerung einmal und identische konkrete Empfehlungen einmal mit ihren Baselines; Comparison ist eine Zeile mit Urteilszaehlung. Result DB behaelt seine Variantendetails unveraendert. Offen: Ob reine Client-Baselines (Windows 10/11) eine auf Domain Controller passende Einstellung zum Gesamt-Fail machen duerfen (Beispiel `SeEnableDelegationPrivilege`), bleibt eine separate Anwendbarkeitsentscheidung. Synthetische Regressionen fuer Delete-all ohne Kundenzustand (Pass, `Not configured`, Klartext, kein Registry type), Create-key-Remediation und die Variantenanzeige ueber mehrere Baselines; Gesamtlauf 1.654 bestanden/16 uebersprungen (zwei lastabhaengige Timeouts in `client-report-buttons` und `sheet-selection-cost` bestanden isoliert und im Wiederholungslauf), beide Builds, Local-only- und Designcheck gruen. Patchversion 2.6.2.
2026-09-24 (Phase 7, Workspace-Ladezeit): Der erste Timeslot-Load wurde lokal mit autorisiertem Dummy gemessen und beschleunigt. (1) Data Hub laedt den kompletten Workspace fuer seine Donuts; Open lud denselben Scope erneut. Die Ladung wird jetzt genau einmal an das naechste Open desselben normalisierten Projekt-/Run-/Assessment-Scopes uebergeben, nur wenn sie hoechstens 60 s alt ist und seit Beginn keine mutierende API-Anfrage startete oder endete; sonst wird frisch geladen. (2) Mit explizitem Assessment-Scope laeuft die GPO-Kette (Observations, Evaluations/Overview, Baseline-Analysen) parallel zum Scannermodell. (3) Baseline-Analysen (je 2-4 MB) laden mit hoechstens drei gleichzeitigen Anfragen statt strikt sequenziell; jede Baseline schreibt nur eigene Result-IDs, Fehler bleiben je Baseline markiert. (4) Zeilenanalyse: Kopfzeilenordnung und -merkmale einmal je Kopfzeilenliste (Identitaets-Cache mit Kopie, erkennt Mutation), jede Zelle einmal bereinigt, per-Zelle toLowerCase() statt toLocaleLowerCase(de-DE) (fuer alle 1.112.064 Codepoints in Browser und Node identisch; Deutsch hat keine sprachspezifischen Mappings). Ausgabe bytegleich verifiziert (Browser-Loader-Fingerprint unveraendert, 53-MB-Sheet-Serialisierung alt/neu identisch); Record-IDs und Reviews bleiben stabil, kein Reimport. Beobachtungen: Open nach Data-Hub-Auswahl 8,6 s -> 0,7 s; Tenant-Klick bis Client Info (kalt) ~5,2 s statt Data-Hub-Load plus 8,6 s; GPO-Anfuegen in Node 643-653 -> 423-461 ms. Offen: verbleibende ~1,9 s Main-Thread-Block beim Datasetaufbau (GPO-Zeilen werden fuer GPO- und Aggregatblatt analysiert); ein Worker waere eine eigene Architekturentscheidung. Tests: synthetische Nebenlaeufigkeits-, Handoff-, Cache- und Aequivalenzregressionen; Gesamtlauf 1.642 bestanden/16 uebersprungen, beide Builds erfolgreich.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "assessor",
"version": "2.6.2",
"version": "2.6.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "assessor",
"version": "2.6.2",
"version": "2.6.3",
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"jsdom": "^29.1.1",

View File

@ -1,6 +1,6 @@
{
"name": "assessor",
"version": "2.6.2",
"version": "2.6.3",
"private": true,
"type": "module",
"scripts": {

View File

@ -1,6 +1,7 @@
export interface TextDownload {
fileName: string;
text: string;
/** Text content, or binary content such as an Excel workbook. */
text: string | Uint8Array<ArrayBuffer>;
mediaType: string;
}

View File

@ -0,0 +1,74 @@
import type { FindingRecord, RecordField } from '../domain/finding';
import { defaultReview, type Review } from '../domain/review';
import { riskAcceptanceState } from '../domain/risk-acceptance';
import { selectFindingFields } from '../features/finding-reader/finding-fields';
import { cisBenchmarkCategories } from '../profiles/cis-cat/cis-benchmark-hierarchy';
import { policyDiagnosticBadges, policyFindingCategories } from '../profiles/group-policy/categories';
import { policyComparisonDisplayLines, policyValueName } from '../profiles/group-policy/presentation';
import { mitreBadgeLabel } from '../profiles/purple-knight/presentation';
import type { WorkExportBlock } from './work-view-export';
/** The Work finding reader's content as plain export sections, in reader order. */
export interface WorkReaderView {
severity: string;
/** Reader heading badges except status and severity: categories, scores, GPO diagnostics, scope, MITRE. */
badges: string[];
sourceRuleId: string;
/** Finding section: heading, GPO values and sources, summary fields, then collapsed optional fields. */
sections: WorkExportBlock[];
/** The reader's collapsed "Additional Information" table. */
additional: WorkExportBlock[];
/** Review fields as entered by the team; never technical proof of remediation. */
review: string;
}
const normalizeLabel = (label: string) => label.trim().toLocaleLowerCase('en-US').replace(/[\s_-]+/g, ' ');
const REVIEW_TEXT: Array<[keyof Review, string]> = [['status', 'Status'], ['priority', 'Priority'], ['owner', 'Owner / Team'], ['due', 'Due Date'],
['prerequisites', 'Prerequisites'], ['access', 'Required Access / Roles'], ['nextStep', 'Next Step'], ['evidence', 'Evidence / Acceptance Criteria'], ['comment', 'Comment']];
const REVIEW_MARKERS: Array<[keyof Review, string]> = [['markerQuestion', 'Question Needed'], ['markerBlocked', 'Permission Blocked'],
['markerQuickwin', 'Quick Win'], ['markerExternal', 'External Team'], ['markerNotRelevant', 'Not Relevant']];
function reviewText(record: FindingRecord, stored?: Review): string {
if (!stored) return '';
const review = { ...defaultReview(), ...stored };
const markers = REVIEW_MARKERS.filter(([key]) => review[key]).map(([, label]) => label);
const risk = review.riskAcceptance ? riskAcceptanceState(review.riskAcceptance, record.findingOccurrenceId) : undefined;
return [...REVIEW_TEXT.filter(([key]) => String(review[key]).trim()).map(([key, label]) => `${label}: ${review[key]}`),
markers.length ? `Markers: ${markers.join(', ')}` : '', risk ? `Accepted Risk: ${risk}` : ''].filter(Boolean).join('\n');
}
export function readerViewFor(record: FindingRecord, title: string, toolId: string | undefined,
hasStructuredResults: boolean, sourceEvidence: WorkExportBlock[], review?: Review): WorkReaderView {
const selected = selectFindingFields(record, title, hasStructuredResults, toolId);
const gpo = toolId === 'group-policy';
const severityField = record.fields.find(field => /^(severity|criticality|priority)$/i.test(field.label) && field.value);
const badges = [
...cisBenchmarkCategories(record, toolId),
...selected.badges.filter(field => field.label.toLowerCase() !== 'severity').map(field =>
`${field.label}: ${field.value}${/^(?:category score|domain risk level)$/i.test(field.label.trim()) ? ' / 100' : ''}`),
...policyFindingCategories(record, toolId),
...policyDiagnosticBadges(record, toolId),
...(gpo ? record.fields.filter(field => normalizeLabel(field.label) === 'scope' && field.value.trim()).slice(0, 1).map(field => `Scope: ${field.value}`) : []),
...selected.mitre.map(field => `${mitreBadgeLabel(field.label)}: ${field.value}`)
];
const heading = selected.policyComparisonFields.map(field => policyValueName(field.value)).find(Boolean) || selected.checkTitle?.value;
// Same presentation as the reader: GPO description/remediation/sources and Maester IDs/scripts are plain text, the rest rich text.
const plain = (field: RecordField) => (gpo && /^(?:description|remediation|sources)$/.test(normalizeLabel(field.label)))
|| (toolId === 'maester' && /^(?:id|test details)$/.test(normalizeLabel(field.label)));
const sections: WorkExportBlock[] = [
...(heading && heading !== title ? [{ label: selected.checkTitle && !gpo ? selected.checkTitle.label : 'Policy setting', value: heading }] : []),
...(selected.policyReadiness ? [{ label: 'Historical comparison', value: selected.policyReadiness.value }] : []),
// One column for all GPO sources; their headings name the individual backups.
...(gpo && sourceEvidence.length ? [{ label: 'GPO sources', value: sourceEvidence.map(block => `${block.label}\n${block.value}`).join('\n\n') }] : []),
...selected.policyComparisonFields.map(field => {
const expected = normalizeLabel(field.label) === 'baseline value';
return { label: selected.policyReadiness ? (expected ? 'Saved baseline value' : 'Saved customer value') : expected ? 'Expected baseline value' : 'Configured customer value',
value: policyComparisonDisplayLines(field.value).map(line => line.text).join('\n') };
}),
...selected.summary.map(field => ({ label: field.label, value: field.value, rich: !plain(field) })),
...selected.optionalFields.map(field => ({ label: field.label, value: field.value, rich: !/xml/i.test(field.label) }))
];
return { severity: severityField?.value ?? '', badges, sourceRuleId: selected.sourceRuleId?.value ?? '', sections,
// Identifiers and provenance stay exact source text.
additional: selected.remainingFields.map(field => ({ label: field.label, value: field.value })), review: reviewText(record, review) };
}

View File

@ -8,7 +8,7 @@ import { isFileReference, resultReferenceValues } from '../profiles/purple-knigh
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 { readerViewFor, type WorkReaderView } from './work-reader-view';
import bundledInter from '@fontsource-variable/inter/files/inter-latin-wght-normal.woff2?inline';
export const WORK_VIEW_EXPORT_VERSION = '1.0.0';
@ -22,6 +22,12 @@ export interface WorkExportEntry {
observed: WorkExportBlock[];
resultSheetIds: string[];
resultRowCount: number;
/** Tool of the record; the Excel export groups one sheet per tool. */
tool: string;
/** Same predicate as Work's Deviation filter; presentation only. */
deviation: boolean;
/** What the Work finding reader and its Results view show, for the Excel finding list. */
reader: WorkReaderView & { results: WorkExportBlock[]; notes: WorkExportBlock[] };
}
export interface WorkViewExport {
version: string;
@ -38,6 +44,7 @@ export interface WorkViewExportInput {
context?: WorkExportBlock[];
reviews?: Record<string, Review>;
titleColumn?: string;
isDeviation?: (record: FindingRecord) => boolean;
}
const EXPECTED = /^(?:baseline value|expected(?: value| result)?|desired(?: value| state)?|recommended value|soll(?:-zustand|wert)?)$/i;
@ -47,7 +54,7 @@ const GUIDANCE = /^(?:description|rationale|impact|technical explanation|documen
const CONTEXT = /^(?:id|rule id|source 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.';
export 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 => [
@ -120,7 +127,9 @@ function entryFor(input: WorkViewExportInput, record: FindingRecord): WorkExport
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 policySources = sourceBlocks(record);
observed.push(...policySources);
const resultsStart = observed.length;
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));
@ -144,6 +153,7 @@ function entryFor(input: WorkViewExportInput, record: FindingRecord): WorkExport
? `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 results = observed.slice(resultsStart);
const review = input.reviews?.[record.id];
if (review) {
const plan = [review.nextStep && `Next step: ${review.nextStep}`, review.prerequisites && `Prerequisites: ${review.prerequisites}`,
@ -154,7 +164,10 @@ function entryFor(input: WorkViewExportInput, record: FindingRecord): WorkExport
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 };
return { id: record.id, title, status, expected, observed, resultSheetIds: resultSheets.map(sheet => sheet.id), resultRowCount,
tool: tool ?? 'unknown', deviation: input.isDeviation?.(record) ?? false,
reader: { ...readerViewFor(record, title, tool, resultSheets.length > 0, policySources, review), results,
notes: expected.filter(block => block.label === 'Einordnung') } };
}
/** Click-time, platform-neutral, frozen projection shared by all three view formats. */
@ -171,7 +184,7 @@ 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 {
export 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')
@ -182,34 +195,6 @@ const blocksText = (blocks: WorkExportBlock[]) => blocks.map(block => `${block.l
const escapeHtml = (value: string) => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;');
const markdownCell = (value: string) => escapeHtml(value).replace(/[\\`*_{}\[\]()#!~]/g, '\\$&').replaceAll('|', '&#124;').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';

View File

@ -0,0 +1,171 @@
import * as XLSX from 'xlsx';
import { toolProfileDefinitions } from '../profiles/profile-definition-catalog';
import { blockText, GPO_SCOPE, type WorkExportBlock, type WorkExportEntry, type WorkViewExport } from './work-view-export';
/**
* Excel finding list of the frozen Work projection: an overview sheet with the export
* context, then one sheet per tool with one row per finding laid out like the Work
* finding reader: its fields by their own labels, then Results, additional information and
* review. Cells are literal inline strings and never formulas. The workbook XML is written here because SheetJS
* Community writes no cell styles; SheetJS only provides the zip container.
*/
/** Columns before and after the tool-specific reader fields; the reader's own labels fill the middle. */
const LEAD = ['Nr', 'Finding', 'Status', 'Severity', 'Badges', 'Source Rule ID'] as const;
const TRAIL = ['Hinweise', 'Results', 'Additional Information', 'Review', 'Record ID'] as const;
/** Repeated on continuation rows so filtering and sorting keep a finding's parts together. */
const IDENTITY = new Set<string>([...LEAD, 'Record ID']);
const OPTIONAL = new Set<string>(['Severity', 'Badges', 'Source Rule ID', 'Hinweise', 'Additional Information', 'Review']);
const FIXED = new Set<string>([...LEAD, ...TRAIL]);
/** A reader field named like a fixed column keeps its own column instead of merging into it. */
const fieldColumn = (label: string) => FIXED.has(label) ? `${label} (Source field)` : label;
const CELL_LIMIT = 30000; // Excel holds at most 32,767 characters per cell.
const STYLE = { text: 0, header: 1, deviation: 2, title: 3 } as const;
const toolNames = new Map<string, string>([...toolProfileDefinitions.map(definition => [definition.id, definition.name] as const), ['group-policy', 'GPOs']]);
/** Export-wide reading boundaries, stated once instead of on every finding row. */
const BOUNDARIES: WorkExportBlock[] = [{ label: 'Review', value: 'Review-Angaben sind Selbstauskunft des Teams und kein technischer Behebungsnachweis. Nur ein vergleichbarer Rescan im gleichen Scope belegt eine Behebung; die Abwesenheit eines Findings reicht nicht.' },
{ label: 'Results', value: 'Results zeigen die Belege aus dem Results-Fenster. Fehlende oder nicht geladene Belege sind in der Spalte Results ausdrücklich benannt.' }];
const labelled = (blocks: WorkExportBlock[]) => blocks.map(block => `${block.label}\n${blockText(block)}`).join('\n\n');
/** One finding as the Work reader shows it: heading badges, reader fields by label, Results, additional information and review. */
function findingCells(entry: WorkExportEntry, index: number): Map<string, string> {
const { reader } = entry;
const cells = new Map<string, string>([['Nr', String(index + 1)], ['Finding', entry.title], ['Status', entry.status],
['Severity', reader.severity], ['Badges', reader.badges.join('\n')], ['Source Rule ID', reader.sourceRuleId],
['Results', labelled(reader.results.map(block => ({ ...block, label: block.label.replace(/^Results · /, '') })))],
['Additional Information', reader.additional.map(block => `${block.label}: ${block.value}`).join('\n')],
['Hinweise', reader.notes.map(blockText).join('\n\n')], ['Review', reader.review], ['Record ID', entry.id]]);
for (const section of reader.sections) {
const text = blockText(section);
const column = fieldColumn(section.label);
cells.set(column, cells.has(column) ? `${cells.get(column)}\n\n${text}` : text);
}
return cells;
}
function chunks(text: string): string[] {
const result: string[] = [];
while (text.length > CELL_LIMIT) {
let end = text.lastIndexOf('\n', CELL_LIMIT);
end = end < CELL_LIMIT / 2 ? CELL_LIMIT : 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;
}
type Cell = { text: string; style: number };
const xml = (value: string) => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
// OOXML escapes characters that XML 1.0 cannot carry as _xHHHH_; a literal _xHHHH_ is escaped itself.
const cellText = (value: string) => xml(value.replace(/_x([0-9A-Fa-f]{4})_/g, '_x005F_x$1_')
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g, char => `_x${char.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}_`));
const columnName = (index: number): string => (index >= 26 ? columnName(Math.floor(index / 26) - 1) : '') + String.fromCharCode(65 + index % 26);
function sheetXml(rows: Cell[][], widths: readonly number[], options: { filter?: boolean; freezeColumns?: number; selected?: boolean }): string {
const last = `${columnName(widths.length - 1)}${rows.length}`;
const pane = options.freezeColumns
? `<pane xSplit="${options.freezeColumns}" ySplit="1" topLeftCell="${columnName(options.freezeColumns)}2" activePane="bottomRight" state="frozen"/>`
: '<pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/>';
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
+ `<dimension ref="A1:${last}"/><sheetViews><sheetView workbookViewId="0"${options.selected ? ' tabSelected="1"' : ''}>${pane}</sheetView></sheetViews>`
+ `<cols>${widths.map((width, index) => `<col min="${index + 1}" max="${index + 1}" width="${width}" customWidth="1"/>`).join('')}</cols><sheetData>`
+ rows.map((row, r) => `<row r="${r + 1}">${row.map((cell, c) => `<c r="${columnName(c)}${r + 1}" s="${cell.style}" t="inlineStr"><is><t xml:space="preserve">${cellText(cell.text)}</t></is></c>`).join('')}</row>`).join('')
+ `</sheetData>${options.filter ? `<autoFilter ref="A1:${last}"/>` : ''}</worksheet>`;
}
const STYLES = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
+ '<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>'
+ '<fills count="4"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill>'
+ '<fill><patternFill patternType="solid"><fgColor rgb="FFE9ECF0"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FFF5DCE0"/></patternFill></fill></fills>'
+ '<borders count="2"><border/><border><bottom style="thin"><color rgb="FFADB2BB"/></bottom></border></borders>'
+ '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="4">'
+ '<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0" applyAlignment="1"><alignment vertical="top" wrapText="1"/></xf>'
+ '<xf numFmtId="0" fontId="1" fillId="2" borderId="1" xfId="0" applyFont="1" applyFill="1" applyBorder="1" applyAlignment="1"><alignment vertical="top" wrapText="1"/></xf>'
+ '<xf numFmtId="0" fontId="1" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment vertical="top" wrapText="1"/></xf>'
+ '<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1" applyAlignment="1"><alignment vertical="top" wrapText="1"/></xf>'
+ '</cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>';
function sheetNames(names: string[]): string[] {
const used = new Set<string>();
return names.map(name => {
const base = name.replace(/[[\]:*?/\\']/g, '_').trim().slice(0, 31) || 'Tool';
let candidate = base;
for (let n = 2; used.has(candidate.toLowerCase()); n++) candidate = `${base.slice(0, 31 - String(n).length - 1)} ${n}`;
used.add(candidate.toLowerCase());
return candidate;
});
}
function findingSheet(entries: WorkExportEntry[]): { rows: Cell[][]; widths: number[] } {
const cells = entries.map(findingCells);
// Reader fields in order of first appearance; lead/trail columns only when this tool has any value.
const middle = [...new Set(entries.flatMap(entry => entry.reader.sections.map(section => fieldColumn(section.label))))];
const columns = [...LEAD, ...middle, ...TRAIL].filter(column => !OPTIONAL.has(column) || cells.some(cell => cell.get(column)));
const widths = columns.map(column => {
if (column === 'Nr') return 6;
const limit = column === 'Finding' ? 48 : 60;
// A loop, not a spread: one argument per finding exceeds the call stack on large sheets.
let width = column.length + 4;
for (const cell of cells) {
for (const line of (cell.get(column) ?? '').slice(0, 2000).split('\n')) width = Math.max(width, line.length);
if (width >= limit) break;
}
return Math.min(limit, width);
});
const header = columns.map(text => ({ text, style: STYLE.header }));
return { widths, rows: [header, ...entries.flatMap((entry, index) => {
const parts = columns.map(column => IDENTITY.has(column) ? [cells[index].get(column) ?? ''] : chunks(cells[index].get(column) ?? ''));
const count = Math.max(...parts.map(part => part.length));
// Oversized cells continue in explicitly marked rows of the same finding.
return Array.from({ length: count }, (_, row) => columns.map((column, position) => {
const text = IDENTITY.has(column) ? parts[position][0] : parts[position][row] ?? '';
const suffix = column === 'Finding' && count > 1 ? `\nTeil ${row + 1}/${count} · Fortsetzungszeile desselben Findings` : '';
const style = column === 'Finding' ? STYLE.title : column === 'Status' && entry.deviation ? STYLE.deviation : STYLE.text;
return { text: text + suffix, style };
}));
})] };
}
export function renderWorkViewXlsx(report: WorkViewExport): Uint8Array<ArrayBuffer> {
const byTool = new Map<string, WorkExportEntry[]>();
for (const entry of report.entries) {
const list = byTool.get(entry.tool);
if (list) list.push(entry); else byTool.set(entry.tool, [entry]);
}
const tools = [...byTool.keys()];
const names = sheetNames(['Übersicht', ...tools.map(tool => toolNames.get(tool) ?? (tool === 'unknown' ? 'Unbekanntes Tool' : tool))]);
const overview: Cell[][] = [
[{ text: 'Angabe', style: STYLE.header }, { text: 'Wert', style: STYLE.header }, { text: '', style: STYLE.header }],
...[...report.context, ...BOUNDARIES, ...(byTool.has('group-policy') ? [{ label: 'GPO-Geltungsgrenze', value: GPO_SCOPE }] : [])]
.map(block => [{ text: block.label, style: STYLE.title }, { text: blockText(block), style: STYLE.text }, { text: '', style: STYLE.text }]),
[],
[{ text: 'Tool-Blatt', style: STYLE.header }, { text: 'Findings', style: STYLE.header }, { text: 'Davon Deviation', style: STYLE.header }],
...tools.map((tool, index) => [{ text: names[index + 1], style: STYLE.title }, { text: String(byTool.get(tool)!.length), style: STYLE.text },
{ text: String(byTool.get(tool)!.filter(entry => entry.deviation).length), style: STYLE.text }])
];
const toolSheets = tools.map(tool => findingSheet(byTool.get(tool)!));
const sheets = [sheetXml(overview, [28, 100, 18], { selected: true }),
...toolSheets.map(sheet => sheetXml(sheet.rows, sheet.widths, { filter: true, freezeColumns: 2 }))];
const zip = XLSX.CFB.utils.cfb_new();
const add = (path: string, content: string) => XLSX.CFB.utils.cfb_add(zip, path, new TextEncoder().encode(content));
const head = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
const relsNs = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
add('/[Content_Types].xml', `${head}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>`
+ '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
+ sheets.map((_, index) => `<Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join('') + '</Types>');
add('/_rels/.rels', `${head}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="${relsNs}/officeDocument" Target="xl/workbook.xml"/></Relationships>`);
add('/xl/workbook.xml', `${head}<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="${relsNs}"><sheets>`
+ names.map((name, index) => `<sheet name="${xml(name)}" sheetId="${index + 1}" r:id="rId${index + 1}"/>`).join('') + '</sheets><definedNames>'
+ toolSheets.map((sheet, index) => `<definedName name="_xlnm._FilterDatabase" localSheetId="${index + 1}" hidden="1">'${xml(names[index + 1])}'!$A$1:$${columnName(sheet.widths.length - 1)}$${sheet.rows.length}</definedName>`).join('')
+ '</definedNames></workbook>');
add('/xl/_rels/workbook.xml.rels', `${head}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">`
+ sheets.map((_, index) => `<Relationship Id="rId${index + 1}" Type="${relsNs}/worksheet" Target="worksheets/sheet${index + 1}.xml"/>`).join('')
+ `<Relationship Id="rId${sheets.length + 1}" Type="${relsNs}/styles" Target="styles.xml"/></Relationships>`);
add('/xl/styles.xml', STYLES);
sheets.forEach((sheet, index) => add(`/xl/worksheets/sheet${index + 1}.xml`, sheet));
XLSX.CFB.utils.cfb_del(zip, '/\u0001Sh33tJ5'); // container placeholder, not a workbook part
return new Uint8Array(XLSX.CFB.write(zip, { fileType: 'zip', type: 'array' }));
}

View File

@ -1,8 +1,8 @@
import { useState } from 'react';
import { useApp } from '../../app/AppProvider';
import { Dialog } from '../../components/Dialog/Dialog';
import { buildWorkViewExport, renderWorkViewCsv, renderWorkViewHtml, renderWorkViewMarkdown } from '../../exports/work-view-export';
import { downloadText } from '../../exports/text-download';
import { buildWorkViewExport, renderWorkViewHtml, renderWorkViewMarkdown } from '../../exports/work-view-export';
import { renderWorkViewXlsx } from '../../exports/work-view-xlsx';
import { browserDownloadAdapter } from '../../platform/browser/browser-download-adapter';
import { useFilteredRecords } from '../search-filter/use-filtered-records';
import { selectWorkFacets } from '../search-filter/work-facets';
@ -11,7 +11,7 @@ export function ActionlistControls() {
const app = useApp();
const records = useFilteredRecords();
const [failed, setFailed] = useState(false);
const exportView = (format: 'csv' | 'md' | 'html') => {
const exportView = (format: 'xlsx' | 'md' | 'html') => {
if (!app.dataset || !app.activeSheet || !records.length) return;
try {
const facets = selectWorkFacets(app.activeSheet, app);
@ -22,21 +22,23 @@ export function ActionlistControls() {
});
const report = buildWorkViewExport({ dataset: app.dataset, sheet: app.activeSheet, records,
generatedAt: new Date().toISOString(), reviews: app.reviews, titleColumn: app.titleColumn,
isDeviation: record => app.activeProfile.matchesStatus(record, 'deviation'),
context: [
{ label: 'Scope', value: [`Project: ${app.projectId}`, `Assessments: ${app.activeAssessmentIds?.join(', ') ?? 'geladener Workspace'}`, `Scan runs: ${app.activeScanRunIds?.join(', ') ?? 'geladener Workspace'}`].join('\n') },
{ label: 'Filter', value: [`Result Status: ${statusLabel}`, ...filters, `Suche: ${app.search || '—'}`, `My Workspace: ${app.actionOnly ? 'Ja' : 'Nein'}`, `Drilldown: ${app.pathFindingIds.length ? `${app.pathFindingIds.length} Record IDs` : 'Nein'}`].join('\n') },
{ label: 'Sortierung', value: `${app.sortColumn} · ${app.sortDirection}` }
] });
const text = format === 'html' ? renderWorkViewHtml(report) : format === 'md' ? renderWorkViewMarkdown(report)
: renderWorkViewCsv(report, app.delimiter === 'auto' ? ';' : app.delimiter);
const content = format === 'html' ? renderWorkViewHtml(report) : format === 'md' ? renderWorkViewMarkdown(report)
: renderWorkViewXlsx(report);
const stem = app.dataset.fileName.replace(/\.[^.]+$/, '').replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_');
downloadText(browserDownloadAdapter, `${stem}-work-view.${format}`, text,
format === 'html' ? 'text/html;charset=utf-8' : format === 'md' ? 'text/markdown;charset=utf-8' : 'text/csv;charset=utf-8');
browserDownloadAdapter.download({ fileName: `${stem}-work-view.${format}`, text: content,
mediaType: format === 'html' ? 'text/html;charset=utf-8' : format === 'md' ? 'text/markdown;charset=utf-8'
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
setFailed(false);
} catch { setFailed(true); }
};
return <div className="header-export-row" aria-label="Export current view including Results">
{(['csv', 'md', 'html'] as const).map(format => <button key={format} className="btn export-btn" type="button" disabled={!records.length}
{(['xlsx', 'md', 'html'] as const).map(format => <button key={format} className="btn export-btn" type="button" disabled={!records.length}
title={`Export current view including Results (${records.length} records)`} onClick={() => exportView(format)}>{format.toUpperCase()}</button>)}
{failed && <Dialog title="Export unavailable" onClose={() => setFailed(false)}><p>The current view could not be exported completely. No partial file was downloaded.</p></Dialog>}
</div>;

View File

@ -1,6 +1,6 @@
import { Fragment, useState, type ChangeEvent, type CSSProperties } from 'react';
import type { DatasetSheet } from '../../domain/dataset';
import type { FindingRecord, RecordField } from '../../domain/finding';
import type { FindingRecord } from '../../domain/finding';
import type { Review } from '../../domain/review';
import { defaultReview } from '../../domain/review';
import { riskAcceptanceState, type RiskAcceptanceCommand } from '../../domain/risk-acceptance';
@ -15,23 +15,22 @@ import { PolicySourceEvidence } from './PolicySourceEvidence';
import { normalizeSeverity, mitreBadgeLabel } from '../../profiles/purple-knight/presentation';
import { RichTextValue } from '../../components/RichTextValue/RichTextValue';
import { PathText } from '../../components/PathText/PathText';
import { normalizeNumericValue } from '../../shared/text';
import { sourceFileLabel } from '../../shared/source-file-label';
import { outcomeForStatusValue, toolIdForRecord } from '../../importers/status-normalization';
import { isFileReference } from '../../profiles/purple-knight/result-linker';
import { highlightText } from '../../components/HighlightText/highlight-text';
import { BookmarkIcon } from '../../components/BookmarkIcon';
import { TriangleIcon } from '../../components/TriangleIcon';
import { findingOutcomeMark, resultSectionFields, resultStatusFieldFor } from '../linked-results/result-message';
import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
import { findingOutcomeMark } from '../linked-results/result-message';
import { policyComparisonDisplayLines, policyValueName } from '../../profiles/group-policy/presentation';
import { policyDiagnosticBadges, policyFindingCategories } from '../../profiles/group-policy/categories';
import { findingWebSearch } from '../../profiles/public-search/search';
import { policyEvidenceResultSheets, POLICY_CONFLICT_DETAILS, POLICY_DUPLICATE_DETAILS } from '../../profiles/group-policy/conflict-presentation';
import type { BackendStatus } from '../../app/app-state';
import { RegulatoryMappingPanel } from './RegulatoryMappingPanel';
import { useRegulatoryMapping } from './use-regulatory-mapping';
import { maesterExecutionErrorText, maesterTagText, maesterTestResultText } from '../../profiles/maester/presentation';
import { normalizeLabel, selectFindingFields } from './finding-fields';
export { selectFindingFields };
interface Props {
record: FindingRecord;
@ -62,279 +61,6 @@ interface Props {
const statuses = ['Open', 'In Review', 'Clarification Needed', 'Actionable', 'Blocked', 'Done', 'Not Relevant'];
const priorities = ['High', 'Medium', 'Low'];
const normalizeLabel = (label: string) => label.trim().toLocaleLowerCase('en-US').replace(/[\s_-]+/g, ' ');
const normalizeValue = (value: string) => value.trim().replace(/\s+/g, ' ').toLocaleLowerCase('en-US');
/**
* Felder, die nicht in die Lesespalte gehoeren, sondern unter die
* Zusatzinformationen. Herkunft (Datei, Blatt, Scan) und Kennungen sind ueber
* die Filterleiste erreichbar beziehungsweise reine Nachschlagewerte - im
* Finding-Abschnitt drueckten sie die eigentliche Aussage nach unten.
*/
const isAdditionalOnlyField = (label: string) => {
const normalized = normalizeLabel(label);
return /^(?:short\s*name|si version|target|runtime|points scale|rule id|source rule id|policy type|policy section|description source)$/.test(normalized)
|| /^(?:quelle|source):? /.test(normalized)
|| /^anssi \d+$/.test(normalized);
};
/**
* Ein Feld gilt als reine Ueberschrift/Badge und wird nicht als Fliesstext
* gezeigt. "Result" gehoert immer dazu: bei Purple Knight ist es ein kurzes
* Statuswort (Text separat in "Result Message"), bei Maester der vollstaendige
* Befund - der gehoert dann aber in das Results-Fenster (siehe
* `resultMessageFor`/`FloatingResultsWindow`), nicht in den Finding-Fliesstext.
*/
const isHeadingOrBadgeField = (label: string) => /^(?:name|title|finding|indicator|results?|status|evaluation|category|severity|criticality|priority|weight|score)$/.test(normalizeLabel(label));
/** Werkzeugdetails, die bei strukturierten Results nicht doppelt erscheinen duerfen. */
const isRedundantDetailField = (label: string) => /^(?:details?|result detail|resultdetail)$/.test(normalizeLabel(label));
const isPingCastleLinkedSectionField = (label: string) => normalizeLabel(label) === 'verwandte daten';
function legacyPurpleKnightFrameworkFields(record: FindingRecord, toolId?: string): {
fields: RecordField[];
sourceIndexes: number[];
} {
if (toolId !== 'purple-knight') return { fields: [], sourceIndexes: [] };
const source = record.fields.find(field => normalizeLabel(field.label) === 'framework' && field.value);
if (!source || source.value.length > 64 * 1024) return { fields: [], sourceIndexes: [] };
let parsed: unknown;
try {
parsed = JSON.parse(source.value);
} catch {
return { fields: [], sourceIndexes: [] };
}
if (!Array.isArray(parsed) || parsed.length > 32) return { fields: [], sourceIndexes: [] };
const fields: RecordField[] = [];
const nextIndex = Math.max(-1, ...record.fields.map(field => field.index)) + 1;
parsed.forEach(item => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return;
const framework = item as { Name?: unknown; Tags?: unknown };
if (typeof framework.Name !== 'string' || !Array.isArray(framework.Tags)) return;
const frameworkName = framework.Name;
framework.Tags.slice(0, 32).forEach(tag => {
if (typeof tag !== 'string' || !tag.trim()) return;
const ordinal = fields.filter(field => normalizeLabel(field.label).startsWith(normalizeLabel(frameworkName))).length + 1;
fields.push({ index: nextIndex + fields.length, label: `${frameworkName}_${ordinal}`, value: tag.trim() });
});
});
return fields.length ? { fields, sourceIndexes: [source.index] } : { fields: [], sourceIndexes: [] };
}
const CIS_CAT_DETAILS_LABEL = 'cis cat details';
const RULE_RESULT_XML_LABEL = 'rule result xml';
const RULE_RESULT_XML_HEADING = /^##\s+Rule Result XML\s*$/im;
/**
* Das Rule Result XML bleibt bewusst hier: ein standardmaessig geschlossenes
* Detail im Finding, das man gezielt aufklappt. Es ist nicht der Befund,
* sondern dessen maschinenlesbare Rohfassung, und wuerde das Results-Fenster
* vor den eigentlichen Belegtabellen fuellen. Aeltere Importe kennen statt des
* eigenen Feldes nur das zusammengefasste "CIS-CAT Details"; daraus wird der
* XML-Abschnitt herausgeloest.
*
* `resultSectionIndexes` sind die Felder, die bereits das Results-Fenster
* traegt (siehe `resultSectionsFor`, bei CIS-CAT die Rahmenwerk-Zuordnungen).
* Sie werden hier ausgeblendet, damit dieselbe Aussage nicht doppelt steht.
*/
function cisCatOptionalFields(record: FindingRecord, toolId?: string, resultSectionIndexes = new Set<number>()) {
const hiddenFieldIndexes = new Set<number>(resultSectionIndexes);
if (toolId !== 'cis-cat') return { optionalFields: [] as RecordField[], hiddenFieldIndexes };
const explicitXml = record.fields.find(field => normalizeLabel(field.label) === RULE_RESULT_XML_LABEL && field.value);
const legacyDetails = record.fields.find(field => normalizeLabel(field.label) === CIS_CAT_DETAILS_LABEL && field.value);
if (legacyDetails) hiddenFieldIndexes.add(legacyDetails.index);
const cleanXmlValue = (val: string) => val.replace(/^```\w*\n?/, '').replace(/\n?```$/, '').trim();
if (explicitXml) {
hiddenFieldIndexes.add(explicitXml.index);
if (resultSectionIndexes.has(explicitXml.index)) return { optionalFields: [] as RecordField[], hiddenFieldIndexes };
return { optionalFields: [{ ...explicitXml, value: cleanXmlValue(explicitXml.value) }], hiddenFieldIndexes };
}
if (!legacyDetails) return { optionalFields: [] as RecordField[], hiddenFieldIndexes };
const heading = RULE_RESULT_XML_HEADING.exec(legacyDetails.value);
if (!heading?.length || heading.index === undefined) return { optionalFields: [] as RecordField[], hiddenFieldIndexes };
const remainder = legacyDetails.value.slice(heading.index + heading[0].length).trimStart();
const nextHeading = remainder.search(/^##\s+/m);
const value = cleanXmlValue((nextHeading >= 0 ? remainder.slice(0, nextHeading) : remainder).trim());
return {
optionalFields: value ? [{ ...legacyDetails, label: 'Rule Result XML', value }] : [],
hiddenFieldIndexes
};
}
/**
* Purple Knight legt seine Belegstellen als Abschnitt "References" in dieselbe
* Zelle wie den vorangehenden Fliesstext. Beim Import kommt daraus ein einziges
* Feld, in dem der Referenzblock ohne Trennung am Satzende klebt.
*
* Die Aufteilung geschieht bewusst hier in der Darstellung und nicht beim
* Import: der kanonische Feldwert bleibt damit unveraendert die Quellzelle
* (siehe AGENTS.md - Originalwerte und Provenienz erhalten), und bereits
* importierte Berichte profitieren ohne erneuten Import.
*
* Die Verweisziele selbst sind an dieser Stelle bereits verloren: Purple Knight
* hinterlegt sie als Hyperlinks der Excel-Zelle, der Importer liest nur den
* Zelltext. Der Block wird deshalb als Liste der Bezeichnungen sichtbar, nicht
* als anklickbare Verweise.
*/
const referencesSection = /\s*>?\s*References(?![a-z])\s*/;
function splitReferenceSection(fields: RecordField[]): RecordField[] {
return fields.flatMap(field => {
const match = referencesSection.exec(field.value);
if (!match?.index) return [field];
const body = field.value.slice(0, match.index).replace(/[\s>]+$/, '');
const references = field.value.slice(match.index + match[0].length).trim();
if (!body || !references) return [field];
return [{ ...field, value: body }, { ...field, label: 'References', value: references }];
});
}
export function selectFindingFields(record: FindingRecord, title: string, hasStructuredResults = false, toolId?: string) {
const derivedCategories = cisBenchmarkCategories(record, toolId);
const legacyFramework = legacyPurpleKnightFrameworkFields(record, toolId);
const resultSectionIndexes = new Set(resultSectionFields(record, toolId).map(field => field.index));
const { optionalFields: cisCatFields, hiddenFieldIndexes } = cisCatOptionalFields(record, toolId, resultSectionIndexes);
const optionalFields = [...cisCatFields];
const sourceRuleId = record.fields.find(field => normalizeLabel(field.label) === 'source rule id' && field.value.trim());
if (sourceRuleId) hiddenFieldIndexes.add(sourceRuleId.index);
legacyFramework.sourceIndexes.forEach(index => hiddenFieldIndexes.add(index));
// Die technische PingCastle-Sektions-ID bleibt im Datensatz, weil der
// Results-Linker damit die aufgeloesten Tabellen eindeutig zuordnet. Im
// Reader ist sie jedoch reine Implementierungsprovenienz ohne fachlichen
// Lesewert und wird deshalb weder im Finding noch unter Additional
// Information ausgegeben.
if (toolId === 'pingcastle') {
record.fields
.filter(field => isPingCastleLinkedSectionField(field.label))
.forEach(field => hiddenFieldIndexes.add(field.index));
}
// Die Defender-Rohbeobachtungen bleiben als maschinenlesbare Provenienz im
// Datensatz (die Defender-Sonderansicht rekonstruiert daraus ihr Modell),
// gehoeren aber nicht in die Lesespalte: der fachliche Beleg steht als
// "Affected Devices"-Tabelle im Ergebnisfenster.
if (toolId === 'defender-for-iot-vulnerabilities') {
record.fields
.filter(field => normalizeLabel(field.label) === 'source observations')
.forEach(field => hiddenFieldIndexes.add(field.index));
}
if (hasStructuredResults) {
record.fields
.filter(field => isRedundantDetailField(field.label))
.forEach(field => hiddenFieldIndexes.add(field.index));
}
if (toolId === 'purple-knight' && (legacyFramework.fields.length || record.fields.some(field => isRedundantDetailField(field.label)))) {
record.fields
.filter(field => /^(?:grade|exposure type)$/.test(normalizeLabel(field.label)))
.forEach(field => hiddenFieldIndexes.add(field.index));
}
const statusField = resultStatusFieldFor(record, toolId);
const maesterPresentationFields: RecordField[] = [];
if (toolId === 'maester') {
let presentationIndex = Math.max(-1, ...record.fields.map(field => field.index)) + 1;
const displayField = (source: RecordField, label: string, value = source.value): RecordField => ({
...source, index: presentationIndex++, label, value
});
const byLabel = (label: string) => record.fields.find(field => normalizeLabel(field.label) === label && field.value);
const id = byLabel('id');
const detail = byLabel('resultdetail');
const script = byLabel('testscript');
const category = byLabel('block');
const tags = byLabel('tag');
const source = byLabel('testscriptfile');
const error = byLabel('errormessage');
record.fields.filter(field => /^(?:id|result|resultdetail|severity|tag|block|duration|errormessage|name|helpurl|testscriptfile|testscript|index)$/.test(normalizeLabel(field.label)))
.forEach(field => hiddenFieldIndexes.add(field.index));
if (!sourceRuleId && id) maesterPresentationFields.push(displayField(id, 'ID'));
if (statusField?.value) maesterPresentationFields.push(displayField(statusField, 'Test result', maesterTestResultText(statusField.value, detail?.value)));
if (script) maesterPresentationFields.push(displayField(script, 'Test details', script.value.trim()));
if (category) maesterPresentationFields.push(displayField(category, 'Category'));
if (tags) maesterPresentationFields.push(displayField(tags, 'Tags', maesterTagText(tags.value)));
if (source) maesterPresentationFields.push(displayField(source, 'Source'));
if (error) optionalFields.push({ ...error, label: 'Technical execution detail', value: maesterExecutionErrorText(error.value) });
}
const hasPolicyEvidenceResults = toolId === 'group-policy' && hasStructuredResults && policyEvidenceResultSheets(record).length > 0;
const hasPolicySourceEvidence = toolId === 'group-policy' && Boolean(record.policySourceEvidence?.length);
if (hasPolicySourceEvidence) record.fields.filter(field => ['Sources', 'Configured Value'].includes(field.label))
.forEach(field => hiddenFieldIndexes.add(field.index));
if (hasPolicyEvidenceResults) record.fields.filter(field => [POLICY_CONFLICT_DETAILS, POLICY_DUPLICATE_DETAILS, 'Sources', 'Configured Value'].includes(field.label))
.forEach(field => hiddenFieldIndexes.add(field.index));
const policyComparisonFields = toolId === 'group-policy' && (!hasPolicyEvidenceResults || hasPolicySourceEvidence) ? record.fields.filter(field => {
const label = normalizeLabel(field.label);
return field.value && ((!hasPolicySourceEvidence && label === 'configured value') || label === 'baseline value');
}) : [];
const policyReadiness = toolId === 'group-policy'
? record.fields.find(field => normalizeLabel(field.label) === 'evaluation readiness' && field.value) : undefined;
if (policyReadiness) hiddenFieldIndexes.add(policyReadiness.index);
policyComparisonFields.forEach(field => hiddenFieldIndexes.add(field.index));
const badges = record.fields.filter(field => {
if (!field.value) return false;
const label = normalizeLabel(field.label);
return /^(?:category|severity|weight|score|category score|domain risk level)$/.test(label)
|| (/^points?$/.test(label) && normalizeNumericValue(field.value) !== null);
});
const mitre = [...record.fields, ...legacyFramework.fields].filter(field => mitreBadgeLabel(field.label) && field.value);
const summaryPriority = (label: string) => {
const normalized = normalizeLabel(label);
if (normalized === 'conflict' || normalized === 'duplicates') return -1;
if (toolId === 'maester' && normalized === 'id') return -1;
if (normalized === 'result message') return 0;
if (toolId === 'maester' && normalized === 'test result') return 0;
if (toolId === 'maester' && normalized === 'description') return 1;
if (toolId === 'maester' && normalized === 'test details') return 2;
if (toolId === 'maester' && normalized === 'category') return 3;
if (toolId === 'maester' && normalized === 'tags') return 4;
if (toolId === 'maester' && normalized === 'source') return 5;
if (/^number of results?$/.test(normalized)) return 1;
if (normalized === 'description') return 2;
if (toolId === 'pingcastle' && normalized === 'technical explanation') return 3;
if (toolId === 'pingcastle' && normalized === 'advised solution') return 4;
if (toolId === 'pingcastle' && normalized === 'documentation') return 5;
if (/^remediation(?: guidance| recommendation)?$/.test(normalized)) return 3;
if (/^(source|quelle)(?:\s*:\s*.*)?$/.test(normalized)) return 4;
if (normalized === 'nvd affected configurations') return 11;
return 10;
};
const summaryValues = new Set<string>();
const presentationFields = [...record.fields, ...maesterPresentationFields];
const maesterPresentationIndexes = new Set(maesterPresentationFields.map(field => field.index));
const summaryFields = presentationFields
.filter(field => {
if (hiddenFieldIndexes.has(field.index)) return false;
if (!field.value || field.value === title || badges.some(badge => badge.index === field.index) || mitreBadgeLabel(field.label) || isAdditionalOnlyField(field.label)
|| (!maesterPresentationIndexes.has(field.index) && isHeadingOrBadgeField(field.label))) return false;
if (hasStructuredResults && isRedundantDetailField(field.label)) return false;
const value = normalizeValue(field.value);
if (summaryValues.has(value)) return false;
summaryValues.add(value);
return true;
})
.sort((left, right) => summaryPriority(left.label) - summaryPriority(right.label) || left.index - right.index);
const summary = splitReferenceSection(summaryFields).slice(0, 12);
const displayedIndexes = new Set([
...hiddenFieldIndexes,
...summary.map(field => field.index),
...badges.map(field => field.index),
...mitre.map(field => field.index),
...(statusField ? [statusField.index] : [])
]);
const displayedTextValues = new Set([title, ...summary.map(field => field.value)].map(normalizeValue));
const remainingFields = [...presentationFields, ...legacyFramework.fields.filter(field => !mitreBadgeLabel(field.label))].filter(field => {
if (!field.value || displayedIndexes.has(field.index)) return false;
if (isAdditionalOnlyField(field.label)) return true;
if (isHeadingOrBadgeField(field.label)) return false;
return !displayedTextValues.has(normalizeValue(field.value));
});
// "Check title" ist die Pruefbeschreibung der Regel und dient als Ueberschrift
// des Finding-Abschnitts. Die Aufteilung erfolgt bewusst erst hier, damit das
// Feld weiterhin als angezeigt gilt und nicht zusaetzlich unter den
// Zusatzinformationen erscheint.
const checkTitleIndex = summary.findIndex(field => normalizeLabel(field.label) === 'check title');
const checkTitle = checkTitleIndex >= 0 ? summary.splice(checkTitleIndex, 1)[0] : undefined;
return { statusField, badges, derivedCategories, mitre, summary, checkTitle, sourceRuleId, optionalFields, remainingFields, policyComparisonFields, policyReadiness };
}
/**
* Short technical token that can be pasted into Policy Analyzer. This is a

View File

@ -0,0 +1,286 @@
import type { FindingRecord, RecordField } from '../../domain/finding';
import { mitreBadgeLabel } from '../../profiles/purple-knight/presentation';
import { normalizeNumericValue } from '../../shared/text';
import { resultSectionFields, resultStatusFieldFor } from '../linked-results/result-message';
import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
import { policyEvidenceResultSheets, POLICY_CONFLICT_DETAILS, POLICY_DUPLICATE_DETAILS } from '../../profiles/group-policy/conflict-presentation';
import { maesterExecutionErrorText, maesterTagText, maesterTestResultText } from '../../profiles/maester/presentation';
/**
* Platform-neutral field selection of the Work finding reader: which record fields form the
* heading badges, the finding section, optional details and Additional Information. Shared by
* the reader component and the Excel export so both show the same fields.
*/
export const normalizeLabel = (label: string) => label.trim().toLocaleLowerCase('en-US').replace(/[\s_-]+/g, ' ');
const normalizeValue = (value: string) => value.trim().replace(/\s+/g, ' ').toLocaleLowerCase('en-US');
/**
* Felder, die nicht in die Lesespalte gehoeren, sondern unter die
* Zusatzinformationen. Herkunft (Datei, Blatt, Scan) und Kennungen sind ueber
* die Filterleiste erreichbar beziehungsweise reine Nachschlagewerte - im
* Finding-Abschnitt drueckten sie die eigentliche Aussage nach unten.
*/
const isAdditionalOnlyField = (label: string) => {
const normalized = normalizeLabel(label);
return /^(?:short\s*name|si version|target|runtime|points scale|rule id|source rule id|policy type|policy section|description source)$/.test(normalized)
|| /^(?:quelle|source):? /.test(normalized)
|| /^anssi \d+$/.test(normalized);
};
/**
* Ein Feld gilt als reine Ueberschrift/Badge und wird nicht als Fliesstext
* gezeigt. "Result" gehoert immer dazu: bei Purple Knight ist es ein kurzes
* Statuswort (Text separat in "Result Message"), bei Maester der vollstaendige
* Befund - der gehoert dann aber in das Results-Fenster (siehe
* `resultMessageFor`/`FloatingResultsWindow`), nicht in den Finding-Fliesstext.
*/
const isHeadingOrBadgeField = (label: string) => /^(?:name|title|finding|indicator|results?|status|evaluation|category|severity|criticality|priority|weight|score)$/.test(normalizeLabel(label));
/** Werkzeugdetails, die bei strukturierten Results nicht doppelt erscheinen duerfen. */
const isRedundantDetailField = (label: string) => /^(?:details?|result detail|resultdetail)$/.test(normalizeLabel(label));
const isPingCastleLinkedSectionField = (label: string) => normalizeLabel(label) === 'verwandte daten';
function legacyPurpleKnightFrameworkFields(record: FindingRecord, toolId?: string): {
fields: RecordField[];
sourceIndexes: number[];
} {
if (toolId !== 'purple-knight') return { fields: [], sourceIndexes: [] };
const source = record.fields.find(field => normalizeLabel(field.label) === 'framework' && field.value);
if (!source || source.value.length > 64 * 1024) return { fields: [], sourceIndexes: [] };
let parsed: unknown;
try {
parsed = JSON.parse(source.value);
} catch {
return { fields: [], sourceIndexes: [] };
}
if (!Array.isArray(parsed) || parsed.length > 32) return { fields: [], sourceIndexes: [] };
const fields: RecordField[] = [];
const nextIndex = Math.max(-1, ...record.fields.map(field => field.index)) + 1;
parsed.forEach(item => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return;
const framework = item as { Name?: unknown; Tags?: unknown };
if (typeof framework.Name !== 'string' || !Array.isArray(framework.Tags)) return;
const frameworkName = framework.Name;
framework.Tags.slice(0, 32).forEach(tag => {
if (typeof tag !== 'string' || !tag.trim()) return;
const ordinal = fields.filter(field => normalizeLabel(field.label).startsWith(normalizeLabel(frameworkName))).length + 1;
fields.push({ index: nextIndex + fields.length, label: `${frameworkName}_${ordinal}`, value: tag.trim() });
});
});
return fields.length ? { fields, sourceIndexes: [source.index] } : { fields: [], sourceIndexes: [] };
}
const CIS_CAT_DETAILS_LABEL = 'cis cat details';
const RULE_RESULT_XML_LABEL = 'rule result xml';
const RULE_RESULT_XML_HEADING = /^##\s+Rule Result XML\s*$/im;
/**
* Das Rule Result XML bleibt bewusst hier: ein standardmaessig geschlossenes
* Detail im Finding, das man gezielt aufklappt. Es ist nicht der Befund,
* sondern dessen maschinenlesbare Rohfassung, und wuerde das Results-Fenster
* vor den eigentlichen Belegtabellen fuellen. Aeltere Importe kennen statt des
* eigenen Feldes nur das zusammengefasste "CIS-CAT Details"; daraus wird der
* XML-Abschnitt herausgeloest.
*
* `resultSectionIndexes` sind die Felder, die bereits das Results-Fenster
* traegt (siehe `resultSectionsFor`, bei CIS-CAT die Rahmenwerk-Zuordnungen).
* Sie werden hier ausgeblendet, damit dieselbe Aussage nicht doppelt steht.
*/
function cisCatOptionalFields(record: FindingRecord, toolId?: string, resultSectionIndexes = new Set<number>()) {
const hiddenFieldIndexes = new Set<number>(resultSectionIndexes);
if (toolId !== 'cis-cat') return { optionalFields: [] as RecordField[], hiddenFieldIndexes };
const explicitXml = record.fields.find(field => normalizeLabel(field.label) === RULE_RESULT_XML_LABEL && field.value);
const legacyDetails = record.fields.find(field => normalizeLabel(field.label) === CIS_CAT_DETAILS_LABEL && field.value);
if (legacyDetails) hiddenFieldIndexes.add(legacyDetails.index);
const cleanXmlValue = (val: string) => val.replace(/^```\w*\n?/, '').replace(/\n?```$/, '').trim();
if (explicitXml) {
hiddenFieldIndexes.add(explicitXml.index);
if (resultSectionIndexes.has(explicitXml.index)) return { optionalFields: [] as RecordField[], hiddenFieldIndexes };
return { optionalFields: [{ ...explicitXml, value: cleanXmlValue(explicitXml.value) }], hiddenFieldIndexes };
}
if (!legacyDetails) return { optionalFields: [] as RecordField[], hiddenFieldIndexes };
const heading = RULE_RESULT_XML_HEADING.exec(legacyDetails.value);
if (!heading?.length || heading.index === undefined) return { optionalFields: [] as RecordField[], hiddenFieldIndexes };
const remainder = legacyDetails.value.slice(heading.index + heading[0].length).trimStart();
const nextHeading = remainder.search(/^##\s+/m);
const value = cleanXmlValue((nextHeading >= 0 ? remainder.slice(0, nextHeading) : remainder).trim());
return {
optionalFields: value ? [{ ...legacyDetails, label: 'Rule Result XML', value }] : [],
hiddenFieldIndexes
};
}
/**
* Purple Knight legt seine Belegstellen als Abschnitt "References" in dieselbe
* Zelle wie den vorangehenden Fliesstext. Beim Import kommt daraus ein einziges
* Feld, in dem der Referenzblock ohne Trennung am Satzende klebt.
*
* Die Aufteilung geschieht bewusst hier in der Darstellung und nicht beim
* Import: der kanonische Feldwert bleibt damit unveraendert die Quellzelle
* (siehe AGENTS.md - Originalwerte und Provenienz erhalten), und bereits
* importierte Berichte profitieren ohne erneuten Import.
*
* Die Verweisziele selbst sind an dieser Stelle bereits verloren: Purple Knight
* hinterlegt sie als Hyperlinks der Excel-Zelle, der Importer liest nur den
* Zelltext. Der Block wird deshalb als Liste der Bezeichnungen sichtbar, nicht
* als anklickbare Verweise.
*/
const referencesSection = /\s*>?\s*References(?![a-z])\s*/;
function splitReferenceSection(fields: RecordField[]): RecordField[] {
return fields.flatMap(field => {
const match = referencesSection.exec(field.value);
if (!match?.index) return [field];
const body = field.value.slice(0, match.index).replace(/[\s>]+$/, '');
const references = field.value.slice(match.index + match[0].length).trim();
if (!body || !references) return [field];
return [{ ...field, value: body }, { ...field, label: 'References', value: references }];
});
}
export function selectFindingFields(record: FindingRecord, title: string, hasStructuredResults = false, toolId?: string) {
const derivedCategories = cisBenchmarkCategories(record, toolId);
const legacyFramework = legacyPurpleKnightFrameworkFields(record, toolId);
const resultSectionIndexes = new Set(resultSectionFields(record, toolId).map(field => field.index));
const { optionalFields: cisCatFields, hiddenFieldIndexes } = cisCatOptionalFields(record, toolId, resultSectionIndexes);
const optionalFields = [...cisCatFields];
const sourceRuleId = record.fields.find(field => normalizeLabel(field.label) === 'source rule id' && field.value.trim());
if (sourceRuleId) hiddenFieldIndexes.add(sourceRuleId.index);
legacyFramework.sourceIndexes.forEach(index => hiddenFieldIndexes.add(index));
// Die technische PingCastle-Sektions-ID bleibt im Datensatz, weil der
// Results-Linker damit die aufgeloesten Tabellen eindeutig zuordnet. Im
// Reader ist sie jedoch reine Implementierungsprovenienz ohne fachlichen
// Lesewert und wird deshalb weder im Finding noch unter Additional
// Information ausgegeben.
if (toolId === 'pingcastle') {
record.fields
.filter(field => isPingCastleLinkedSectionField(field.label))
.forEach(field => hiddenFieldIndexes.add(field.index));
}
// Die Defender-Rohbeobachtungen bleiben als maschinenlesbare Provenienz im
// Datensatz (die Defender-Sonderansicht rekonstruiert daraus ihr Modell),
// gehoeren aber nicht in die Lesespalte: der fachliche Beleg steht als
// "Affected Devices"-Tabelle im Ergebnisfenster.
if (toolId === 'defender-for-iot-vulnerabilities') {
record.fields
.filter(field => normalizeLabel(field.label) === 'source observations')
.forEach(field => hiddenFieldIndexes.add(field.index));
}
if (hasStructuredResults) {
record.fields
.filter(field => isRedundantDetailField(field.label))
.forEach(field => hiddenFieldIndexes.add(field.index));
}
if (toolId === 'purple-knight' && (legacyFramework.fields.length || record.fields.some(field => isRedundantDetailField(field.label)))) {
record.fields
.filter(field => /^(?:grade|exposure type)$/.test(normalizeLabel(field.label)))
.forEach(field => hiddenFieldIndexes.add(field.index));
}
const statusField = resultStatusFieldFor(record, toolId);
const maesterPresentationFields: RecordField[] = [];
if (toolId === 'maester') {
let presentationIndex = Math.max(-1, ...record.fields.map(field => field.index)) + 1;
const displayField = (source: RecordField, label: string, value = source.value): RecordField => ({
...source, index: presentationIndex++, label, value
});
const byLabel = (label: string) => record.fields.find(field => normalizeLabel(field.label) === label && field.value);
const id = byLabel('id');
const detail = byLabel('resultdetail');
const script = byLabel('testscript');
const category = byLabel('block');
const tags = byLabel('tag');
const source = byLabel('testscriptfile');
const error = byLabel('errormessage');
record.fields.filter(field => /^(?:id|result|resultdetail|severity|tag|block|duration|errormessage|name|helpurl|testscriptfile|testscript|index)$/.test(normalizeLabel(field.label)))
.forEach(field => hiddenFieldIndexes.add(field.index));
if (!sourceRuleId && id) maesterPresentationFields.push(displayField(id, 'ID'));
if (statusField?.value) maesterPresentationFields.push(displayField(statusField, 'Test result', maesterTestResultText(statusField.value, detail?.value)));
if (script) maesterPresentationFields.push(displayField(script, 'Test details', script.value.trim()));
if (category) maesterPresentationFields.push(displayField(category, 'Category'));
if (tags) maesterPresentationFields.push(displayField(tags, 'Tags', maesterTagText(tags.value)));
if (source) maesterPresentationFields.push(displayField(source, 'Source'));
if (error) optionalFields.push({ ...error, label: 'Technical execution detail', value: maesterExecutionErrorText(error.value) });
}
const hasPolicyEvidenceResults = toolId === 'group-policy' && hasStructuredResults && policyEvidenceResultSheets(record).length > 0;
const hasPolicySourceEvidence = toolId === 'group-policy' && Boolean(record.policySourceEvidence?.length);
if (hasPolicySourceEvidence) record.fields.filter(field => ['Sources', 'Configured Value'].includes(field.label))
.forEach(field => hiddenFieldIndexes.add(field.index));
if (hasPolicyEvidenceResults) record.fields.filter(field => [POLICY_CONFLICT_DETAILS, POLICY_DUPLICATE_DETAILS, 'Sources', 'Configured Value'].includes(field.label))
.forEach(field => hiddenFieldIndexes.add(field.index));
const policyComparisonFields = toolId === 'group-policy' && (!hasPolicyEvidenceResults || hasPolicySourceEvidence) ? record.fields.filter(field => {
const label = normalizeLabel(field.label);
return field.value && ((!hasPolicySourceEvidence && label === 'configured value') || label === 'baseline value');
}) : [];
const policyReadiness = toolId === 'group-policy'
? record.fields.find(field => normalizeLabel(field.label) === 'evaluation readiness' && field.value) : undefined;
if (policyReadiness) hiddenFieldIndexes.add(policyReadiness.index);
policyComparisonFields.forEach(field => hiddenFieldIndexes.add(field.index));
const badges = record.fields.filter(field => {
if (!field.value) return false;
const label = normalizeLabel(field.label);
return /^(?:category|severity|weight|score|category score|domain risk level)$/.test(label)
|| (/^points?$/.test(label) && normalizeNumericValue(field.value) !== null);
});
const mitre = [...record.fields, ...legacyFramework.fields].filter(field => mitreBadgeLabel(field.label) && field.value);
const summaryPriority = (label: string) => {
const normalized = normalizeLabel(label);
if (normalized === 'conflict' || normalized === 'duplicates') return -1;
if (toolId === 'maester' && normalized === 'id') return -1;
if (normalized === 'result message') return 0;
if (toolId === 'maester' && normalized === 'test result') return 0;
if (toolId === 'maester' && normalized === 'description') return 1;
if (toolId === 'maester' && normalized === 'test details') return 2;
if (toolId === 'maester' && normalized === 'category') return 3;
if (toolId === 'maester' && normalized === 'tags') return 4;
if (toolId === 'maester' && normalized === 'source') return 5;
if (/^number of results?$/.test(normalized)) return 1;
if (normalized === 'description') return 2;
if (toolId === 'pingcastle' && normalized === 'technical explanation') return 3;
if (toolId === 'pingcastle' && normalized === 'advised solution') return 4;
if (toolId === 'pingcastle' && normalized === 'documentation') return 5;
if (/^remediation(?: guidance| recommendation)?$/.test(normalized)) return 3;
if (/^(source|quelle)(?:\s*:\s*.*)?$/.test(normalized)) return 4;
if (normalized === 'nvd affected configurations') return 11;
return 10;
};
const summaryValues = new Set<string>();
const presentationFields = [...record.fields, ...maesterPresentationFields];
const maesterPresentationIndexes = new Set(maesterPresentationFields.map(field => field.index));
const summaryFields = presentationFields
.filter(field => {
if (hiddenFieldIndexes.has(field.index)) return false;
if (!field.value || field.value === title || badges.some(badge => badge.index === field.index) || mitreBadgeLabel(field.label) || isAdditionalOnlyField(field.label)
|| (!maesterPresentationIndexes.has(field.index) && isHeadingOrBadgeField(field.label))) return false;
if (hasStructuredResults && isRedundantDetailField(field.label)) return false;
const value = normalizeValue(field.value);
if (summaryValues.has(value)) return false;
summaryValues.add(value);
return true;
})
.sort((left, right) => summaryPriority(left.label) - summaryPriority(right.label) || left.index - right.index);
const summary = splitReferenceSection(summaryFields).slice(0, 12);
const displayedIndexes = new Set([
...hiddenFieldIndexes,
...summary.map(field => field.index),
...badges.map(field => field.index),
...mitre.map(field => field.index),
...(statusField ? [statusField.index] : [])
]);
const displayedTextValues = new Set([title, ...summary.map(field => field.value)].map(normalizeValue));
const remainingFields = [...presentationFields, ...legacyFramework.fields.filter(field => !mitreBadgeLabel(field.label))].filter(field => {
if (!field.value || displayedIndexes.has(field.index)) return false;
if (isAdditionalOnlyField(field.label)) return true;
if (isHeadingOrBadgeField(field.label)) return false;
return !displayedTextValues.has(normalizeValue(field.value));
});
// "Check title" ist die Pruefbeschreibung der Regel und dient als Ueberschrift
// des Finding-Abschnitts. Die Aufteilung erfolgt bewusst erst hier, damit das
// Feld weiterhin als angezeigt gilt und nicht zusaetzlich unter den
// Zusatzinformationen erscheint.
const checkTitleIndex = summary.findIndex(field => normalizeLabel(field.label) === 'check title');
const checkTitle = checkTitleIndex >= 0 ? summary.splice(checkTitleIndex, 1)[0] : undefined;
return { statusField, badges, derivedCategories, mitre, summary, checkTitle, sourceRuleId, optionalFields, remainingFields, policyComparisonFields, policyReadiness };
}

View File

@ -99,8 +99,11 @@ export function normalizeStatusFor(toolId: string | undefined, value: string): C
return normalizeStatusDimensionsFor(toolId, value).legacy;
}
const toolIdByName = new Map(toolProfileDefinitions.map(definition =>
[definition.name.toLocaleLowerCase('en-US'), definition.id]));
const toolIdByName = new Map([...toolProfileDefinitions.map(definition =>
[definition.name.toLocaleLowerCase('en-US'), definition.id] as const),
// Scoped run names of the canonical import (see `canonical-import.ts`).
...['purple knight for active directory', 'purple knight for entra id', 'purple knight for active directory and entra id']
.map(name => [name, 'purple-knight'] as const)]);
/** Provenienzspalte der Projektaggregation: "<Werkzeugname> · <Zeitpunkt>". */
const SCAN_PROVENANCE_LABEL = /^(?:quelle|source):\s*scan$/i;

View File

@ -9,7 +9,7 @@ const components = readFileSync(new URL('../../src/styles/components.css', impor
describe('ASSESSOR version in the brand header', () => {
it('shows the centrally maintained product version and opens About from the brand', () => {
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.6.2');
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.6.3');
expect(app).toContain("import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';");
expect(app).toContain('aria-label="About ASSESSOR"');
expect(app).toContain("import assessorLogo from '../assets/branding/assessor-icon-transparent.png';");

View File

@ -123,6 +123,13 @@ describe('Werkzeugherkunft je Datensatz in der Projektaggregation', () => {
expect(outcomeForStatusValue(toolIdForRecord(maester, 'generic'), 'Investigate')).toBe('warning');
});
it('erkennt die bereichsbezogenen Purple-Knight-Laufnamen der Aggregation', () => {
for (const name of ['Purple Knight for Active Directory', 'Purple Knight for Entra ID', 'Purple Knight for Active Directory and Entra ID']) {
const record = recordWith([['Source: Scan', `${name} · 2026-07-25T16:58:46.398Z`], ['Status', 'IOE Found']]);
expect(toolIdForRecord(record, 'generic')).toBe('purple-knight');
}
});
it('faellt ohne Provenienzspalte auf das Werkzeug des Blattes zurueck', () => {
const plain = recordWith([['Result', 'Matched']]);
expect(toolIdForRecord(plain, 'pingcastle')).toBe('pingcastle');

View File

@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest';
import { buildWorkViewExport, renderWorkViewCsv, renderWorkViewHtml, renderWorkViewMarkdown, workViewCsvRows } from '../../src/exports/work-view-export';
import * as XLSX from 'xlsx';
import { buildWorkViewExport, renderWorkViewHtml, renderWorkViewMarkdown, type WorkViewExport } from '../../src/exports/work-view-export';
import { renderWorkViewXlsx } from '../../src/exports/work-view-xlsx';
import type { ImportedDataset } from '../../src/domain/dataset';
import { makeSheet } from '../../src/importers/sheet-factory';
import { defaultReview } from '../../src/domain/review';
@ -10,6 +12,11 @@ function fixture(tool = 'generic', fields: Record<string, string> = {}) {
const dataset = { fileName: 'Synthetic', profileId: tool, sheets: [sheet] } as ImportedDataset;
return { dataset, sheet, record: sheet.analysis.records[0] };
}
/** Text of every cell in every sheet of the Excel export. */
function xlsxText(report: WorkViewExport): string {
const book = XLSX.read(renderWorkViewXlsx(report), { type: 'array' });
return book.SheetNames.flatMap(name => XLSX.utils.sheet_to_json<string[]>(book.Sheets[name], { header: 1, raw: true, defval: '' }).flat()).join('\n');
}
function project(f: ReturnType<typeof fixture>) {
return buildWorkViewExport({ ...f, records: [f.record], generatedAt: '2026-09-18T12:00:00.000Z' });
}
@ -25,8 +32,8 @@ describe('two-column Work export contract', () => {
const report = buildWorkViewExport({ ...f, records: [f.record], generatedAt: 'now',
reviews: { [f.record.id]: { ...defaultReview(), status: 'Done', nextStep: 'Synthetic plan', owner: 'Synthetic owner' } } });
expect(report.entries).toHaveLength(1);
const csv = renderWorkViewCsv(report);
for (const value of ['backup-a', 'backup-b', 'delete-value', 'Data: 0', 'Type code: 4', 'Source sequence: 2', 'Synthetic baseline A', 'Synthetic baseline B', 'Expected: 1', 'Expected: 2', 'Status: Done', 'Result Status\nFail', 'kein technischer Behebungsnachweis', 'Synthetic plan']) expect(csv).toContain(value);
const text = xlsxText(report);
for (const value of ['backup-a', 'backup-b', 'delete-value', 'Data: 0', 'Type code: 4', 'Source sequence: 2', 'Synthetic baseline A', 'Synthetic baseline B', 'Expected: 1', 'Expected: 2', 'Status: Done','kein technischer Behebungsnachweis', 'Synthetic plan']) expect(text).toContain(value);
expect(report.entries[0].status).toBe('Fail');
});
@ -34,8 +41,8 @@ describe('two-column Work export contract', () => {
const f = fixture('group-policy', { Category: 'GPOs', Status: status });
const report = project(f);
expect(report.entries[0].status).toBe(status);
expect(renderWorkViewCsv(report)).toContain('Score-neutral:');
expect(renderWorkViewCsv(report)).toContain('kein Nachweis der wirksamen Endpoint-Konfiguration');
expect(xlsxText(report)).toContain('Score-neutral:');
expect(xlsxText(report)).toContain('kein Nachweis der wirksamen Endpoint-Konfiguration');
});
it('includes complete inline Maester results and embedded remediation in the appropriate column', () => {
@ -47,7 +54,7 @@ describe('two-column Work export contract', () => {
const [entry] = report.entries;
expect(entry.expected.some(block => block.value.includes('Change the synthetic setting'))).toBe(true);
expect(entry.observed.some(block => block.label === 'Results · Test result')).toBe(true);
for (const text of [renderWorkViewCsv(report), renderWorkViewHtml(report), renderWorkViewMarkdown(report)]) {
for (const text of [xlsxText(report), renderWorkViewHtml(report), renderWorkViewMarkdown(report)]) {
expect(text).toContain('Synthetic A');
expect(text).toContain('Synthetic B');
expect(text).toContain('State: Pass');
@ -70,45 +77,28 @@ describe('two-column Work export contract', () => {
expect(html).toContain('&lt;result&gt;');
expect(html).not.toMatch(/<(?:img|script|iframe)\b/i);
expect(html).toContain("default-src 'none'");
const csv = renderWorkViewCsv(report);
expect(csv.startsWith('\ufeffSoll-Zustand;Abweichungen\n')).toBe(true);
expect(csv).toContain('Synthetic 159');
expect(xlsxText(report)).toContain('Synthetic 159');
expect(renderWorkViewMarkdown(report)).not.toContain('<img');
});
it('names missing evidence instead of claiming a complete technical proof', () => {
const f = fixture('purple-knight', { Result: 'missing-synthetic-evidence.csv' });
const csv = renderWorkViewCsv(project(f));
expect(csv).toContain('Referenzierte Results nicht geladen');
expect(csv).toContain('missing-synthetic-evidence.csv');
const text = xlsxText(project(f));
expect(text).toContain('Referenzierte Results nicht geladen');
expect(text).toContain('missing-synthetic-evidence.csv');
});
it('is frozen and does not mutate source or select additional rows', () => {
const f = fixture('generic', { Expected: '1', Actual: '0' });
const original = JSON.stringify(f);
const report = project(f);
const before = renderWorkViewCsv(report);
const before = xlsxText(report);
expect(JSON.stringify(f)).toBe(original);
f.record.fields.find(field => field.label === 'Actual')!.value = 'changed later';
expect(renderWorkViewCsv(report)).toBe(before);
expect(xlsxText(report)).toBe(before);
expect(buildWorkViewExport({ ...f, records: [], generatedAt: 'now' }).entries).toEqual([]);
});
it('preserves oversized evidence in Excel-safe two-column continuation rows without cutting Unicode or data', () => {
const long = 'Synthetic evidence =1; "value" | \\path 😀\n'.repeat(2300);
const f = fixture('generic', { Expected: '1', Evidence: long });
// Input deliberately bypasses sheet whitespace normalization to check exact source text.
f.record.fields.find(field => field.label === 'Evidence')!.value = long;
const report = project(f);
const rows = workViewCsvRows(report);
expect(rows.length).toBeGreaterThan(3);
expect(rows.every(row => row.length === 2)).toBe(true);
expect(rows.flat().every(cell => cell.length < 32767)).toBe(true);
const reassembled = rows.slice(1).map(row => row[1].replace(/^Finding ID: [^\n]+\nTeil \d+\/\d+ · Fortsetzungszeile desselben Findings\n\n/, '')).join('');
expect(reassembled).toContain(long);
expect(report.entries).toHaveLength(1);
});
it('reports a missing source table even when another linked table exists', () => {
const f = fixture('cis-cat', { Results: 'source-table:42 "Available"\nsource-table:43 "Missing"' });
f.record.sourceFile = 'synthetic.html';

View File

@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest';
import * as XLSX from 'xlsx';
import type { ImportedDataset } from '../../src/domain/dataset';
import { defaultReview, type Review } from '../../src/domain/review';
import { buildWorkViewExport } from '../../src/exports/work-view-export';
import { renderWorkViewXlsx } from '../../src/exports/work-view-xlsx';
import { makeSheet } from '../../src/importers/sheet-factory';
/** Synthetic mixed Work sheet; `Source: Scan` assigns each record to its tool as in the project sheet. */
function mixed(rows: Array<Record<string, string>>, extraSheets: ImportedDataset['sheets'] = []) {
const labels = [...new Set(rows.flatMap(row => Object.keys(row)))];
const sheet = makeSheet('All Records', [labels, ...rows.map(row => labels.map(label => row[label] ?? ''))], '', 'synthetic.html', { isProjectSheet: true, toolId: 'generic' });
const dataset = { fileName: 'Synthetic', profileId: 'generic', sheets: [sheet, ...extraSheets] } as ImportedDataset;
return { dataset, sheet, records: sheet.analysis.records };
}
function workbook(input: ReturnType<typeof mixed>, options: { isDeviation?: (title: string) => boolean; reviews?: Record<string, Review> } = {}) {
const report = buildWorkViewExport({ ...input, generatedAt: '2026-09-24T12:00:00.000Z', reviews: options.reviews,
isDeviation: options.isDeviation && (record => options.isDeviation!(record.title)) });
const bytes = renderWorkViewXlsx(report);
const book = XLSX.read(bytes, { type: 'array', cellFormula: true });
const rows = (name: string) => XLSX.utils.sheet_to_json<string[]>(book.Sheets[name], { header: 1, raw: true, defval: '' });
/** Finding rows of a tool sheet as header → value objects. */
const findings = (name: string) => { const [header, ...body] = rows(name); return body.map(row => Object.fromEntries(header.map((key, index) => [key, row[index]]))); };
const zip = XLSX.CFB.read(bytes, { type: 'array' });
const part = (path: string) => new TextDecoder().decode(XLSX.CFB.find(zip, path).content);
return { report, book, rows, findings, part };
}
describe('Work Excel finding list', () => {
it('writes an overview and one sheet per tool whose columns follow the finding reader', () => {
const input = mixed([
{ Title: 'Synthetic PC finding', Status: 'Matched', Severity: 'High', Category: 'Anomalies', Description: 'Synthetic description',
'Technical explanation': 'Synthetic explanation', 'Advised solution': 'Synthetic fix', 'Source Rule ID': 'A-Synthetic', 'Source: Scan': 'PingCastle · 2026-09-01' },
{ Title: 'Synthetic Maester test', Status: 'Failed', Severity: 'Medium', Description: 'Synthetic Maester description', 'Source: Scan': 'Maester · 2026-09-01' },
{ Title: 'Synthetic PC second', Status: 'Matched', Severity: 'Low', Description: 'Second description', 'Source: Scan': 'PingCastle · 2026-09-01' }
]);
const { book, rows, findings } = workbook(input, { isDeviation: title => title !== 'Synthetic PC second' });
expect(book.SheetNames).toEqual(['Übersicht', 'PingCastle', 'Maester']);
const header = rows('PingCastle')[0];
expect(header.slice(0, 6)).toEqual(['Nr', 'Finding', 'Status', 'Severity', 'Badges', 'Source Rule ID']);
// Reader order for PingCastle: Description, Technical explanation, Advised solution.
expect(header.indexOf('Description')).toBeLessThan(header.indexOf('Technical explanation'));
expect(header.indexOf('Technical explanation')).toBeLessThan(header.indexOf('Advised solution'));
expect(header.slice(-3)).toEqual(['Results', 'Additional Information', 'Record ID']);
expect(header).not.toContain('Soll-Zustand');
expect(header).not.toContain('Ist-Zustand / Abweichung');
const [first, second] = findings('PingCastle');
expect(first).toMatchObject({ Nr: '1', Finding: 'Synthetic PC finding', Status: 'Matched', Severity: 'High', Badges: 'Category: Anomalies',
'Source Rule ID': 'A-Synthetic', Description: 'Synthetic description', 'Technical explanation': 'Synthetic explanation', 'Advised solution': 'Synthetic fix' });
expect(second).toMatchObject({ Nr: '2', Finding: 'Synthetic PC second', Description: 'Second description', 'Advised solution': '' });
expect(findings('Maester').map(row => row.Finding)).toEqual(['Synthetic Maester test']);
const overview = rows('Übersicht').map(row => row.join('|')).join('\n');
expect(overview).toContain('Treffer|3');
expect(overview).toContain('PingCastle|2|1');
expect(overview).toContain('Maester|1|1');
});
it('puts the Results window content and the review into their own columns', () => {
const evidence = makeSheet('Synthetic evidence', [['Host', 'State'], ['Synthetic host A', '0'], ['Synthetic host B', 'false']], '', 'synthetic.html',
{ sourceFile: 'synthetic.html', sourceTableIndex: 42, toolId: 'cis-cat' });
const input = mixed([{ Title: 'Synthetic rule', Status: 'Fail', Description: 'Synthetic CIS description', Results: 'source-table:42 "Synthetic evidence"', 'Source: Scan': 'CIS-CAT Pro · 2026-09-01' }], [evidence]);
input.records[0].sourceFile = 'synthetic.html';
const { findings } = workbook(input, { reviews: { [input.records[0].id]: { ...defaultReview(), status: 'Done', owner: 'Synthetic team', markerQuickwin: true } } });
const [finding] = findings('CIS-CAT Pro');
expect(finding.Results).toContain('Synthetic evidence');
expect(finding.Results).toContain('Host: Synthetic host A\nState: 0');
expect(finding.Results).toContain('State: false');
expect(finding.Review).toBe('Status: Done\nPriority: Medium\nOwner / Team: Synthetic team\nMarkers: Quick Win');
expect(finding.Status).toBe('Fail'); // a done review never changes the result status
});
it('shows GPO findings like the reader: customer and baseline values, sources, neutral note and scope boundary', () => {
const input = mixed([
{ Title: 'Synthetic policy', Status: 'Deviation', Category: 'GPOs', 'Configured Value': '1', 'Baseline value': '0', Description: 'Synthetic policy description', 'Source: Scan': 'GPOs · 2026-09-01' },
{ Title: 'Synthetic absent policy', Status: 'Not configured', Category: 'GPOs', 'Baseline value': '1', 'Source: Scan': 'GPOs · 2026-09-01' }
]);
input.records[0].policySourceEvidence = [{ id: 'synthetic-observation', backupKey: 'synthetic-backup', sourceReference: 'Synthetic/registry.pol', sequence: 1,
values: [{ kind: 'registry-policy', operation: 'set-value', key: 'Software\\Synthetic', valueName: 'Value', data: 1, typeCode: 4 }] }];
const { book, findings, rows } = workbook(input);
expect(book.SheetNames).toEqual(['Übersicht', 'GPOs']);
const [configured, absent] = findings('GPOs');
expect(configured['GPO sources']).toContain('Synthetic/registry.pol');
expect(configured['Expected baseline value']).toBeTruthy();
expect(configured.Description).toBe('Synthetic policy description');
expect(configured.Hinweise).toBe('');
expect(absent.Hinweise).toContain('Score-neutral');
expect(rows('Übersicht').flat().join('\n')).toContain('kein Nachweis der wirksamen Endpoint-Konfiguration');
});
it('formats headers, deviations, wrapping, filters and frozen identity columns', () => {
const { part, rows } = workbook(mixed([
{ Title: 'Synthetic deviation', Status: 'Matched', Description: 'x', 'Source: Scan': 'PingCastle · 2026-09-01' },
{ Title: 'Synthetic passed', Status: 'Not matched', Description: 'y', 'Source: Scan': 'PingCastle · 2026-09-01' }
]), { isDeviation: title => title === 'Synthetic deviation' });
const last = String.fromCharCode(64 + rows('PingCastle')[0].length);
const sheet = part('/xl/worksheets/sheet2.xml');
expect(part('/xl/styles.xml')).toContain('<alignment vertical="top" wrapText="1"/>');
expect(sheet).toContain('<pane xSplit="2" ySplit="1" topLeftCell="C2" activePane="bottomRight" state="frozen"/>');
expect(sheet).toContain(`<autoFilter ref="A1:${last}3"/>`);
expect(sheet).toMatch(/<c r="A1" s="1"/); // header style
expect(sheet).toMatch(/<c r="C2" s="2"/); // deviation status highlighted
expect(sheet).toMatch(/<c r="C3" s="0"/); // non-deviation status neutral
expect(part('/xl/workbook.xml')).toContain(`<definedName name="_xlnm._FilterDatabase" localSheetId="1" hidden="1">'PingCastle'!$A$1:$${last}$3</definedName>`);
});
it('keeps formula-like and control-character source text literal and exact', () => {
const input = mixed([{ Title: '=HYPERLINK("https://example.invalid","x")', Status: 'Fail', Description: '=1+1', Target: 'a_x0041_b' }]);
input.records[0].fields.find(field => field.label === 'Target')!.value = 'a_x0041_b\u0001c';
const { book, findings, part } = workbook(input);
const sheet = book.Sheets[book.SheetNames[1]];
const cells = Object.keys(sheet).filter(key => !key.startsWith('!')).map(key => sheet[key] as XLSX.CellObject);
expect(cells.every(cell => cell.t === 's' && cell.f === undefined)).toBe(true);
const [finding] = findings(book.SheetNames[1]);
expect(finding.Finding).toBe('=HYPERLINK("https://example.invalid","x")');
expect(finding.Description).toBe('=1+1');
expect(finding['Additional Information']).toContain('Target: a_x0041_b\u0001c'); // exact source identifier
expect(part('/xl/worksheets/sheet2.xml')).not.toContain('<f>');
});
it('continues oversized cells in marked rows of the same finding without losing data', () => {
const long = 'Synthetic target =1; "value" | \\path 😀\n'.repeat(2300);
const input = mixed([{ Title: 'Synthetic long', Status: 'Fail', Target: 'x', 'Source: Scan': 'PingCastle · 2026-09-01' }]);
input.records[0].fields.find(field => field.label === 'Target')!.value = long;
const { findings } = workbook(input);
const list = findings('PingCastle');
expect(list.length).toBeGreaterThan(2);
expect(list.every(row => row.Nr === '1' && row.Status === 'Fail' && row['Record ID'] === list[0]['Record ID'])).toBe(true);
expect(list.every((row, index) => row.Finding === `Synthetic long\nTeil ${index + 1}/${list.length} · Fortsetzungszeile desselben Findings`)).toBe(true);
expect(list.flatMap(row => Object.values(row)).every(cell => cell.length < 32767)).toBe(true);
expect(list.map(row => row['Additional Information']).join('')).toContain(long); // exact, no Markdown rendering
});
it('names the sheet of an unrecognized tool after its profile', () => {
const { book } = workbook(mixed([{ Title: 'Synthetic', Status: 'Fail' }]));
expect(book.SheetNames).toEqual(['Übersicht', 'Tabular Data']);
});
it('names Purple Knight runs of the mixed project sheet after their tool, not the generic profile', () => {
const { book } = workbook(mixed([
{ Title: 'Synthetic AD indicator', Status: 'IOE Found', 'Source: Scan': 'Purple Knight for Active Directory · 2026-09-01' },
{ Title: 'Synthetic Entra indicator', Status: 'IOE Found', 'Source: Scan': 'Purple Knight for Entra ID · 2026-09-01' }
]));
expect(book.SheetNames).toEqual(['Übersicht', 'PurpleKnight']);
});
it('keeps a source field named like a fixed column in its own column', () => {
const { findings } = workbook(mixed([{ Title: 'Synthetic', Status: 'Fail', Nr: '17', Review: 'Synthetic source review', 'Source: Scan': 'PingCastle · 2026-09-01' }]));
const [finding] = findings('PingCastle');
expect(finding).toMatchObject({ Nr: '1', 'Nr (Source field)': '17', 'Review (Source field)': 'Synthetic source review' });
expect(finding.Review ?? '').not.toContain('Synthetic source review');
});
it('exports a large single-tool sheet', () => {
const rows = Array.from({ length: 250_000 }, (_, index) => ({ Title: `Synthetic ${index}`, Status: 'Matched', 'Source: Scan': 'PingCastle · 2026-09-01' }));
const input = mixed(rows);
const report = buildWorkViewExport({ ...input, generatedAt: '2026-09-24T12:00:00.000Z' });
expect(() => renderWorkViewXlsx(report)).not.toThrow();
}, 120_000);
});

View File

@ -11,6 +11,7 @@ import { loadCanonicalProjectWorkspace } from '../../src/features/project-import
import { useFilteredRecords } from '../../src/features/search-filter/use-filtered-records';
import { makeSheet } from '../../src/importers/sheet-factory';
import { browserDownloadAdapter } from '../../src/platform/browser/browser-download-adapter';
import * as XLSX from 'xlsx';
import * as exporter from '../../src/exports/work-view-export';
vi.mock('../../src/features/project-import/load-canonical-project-workspace', () => ({ loadCanonicalProjectWorkspace: vi.fn() }));
@ -52,7 +53,7 @@ async function mount() {
return { container, downloads, projection, click, close: () => act(async () => root.unmount()) };
}
it.each(['CSV', 'MD', 'HTML'])('exports the current filtered and sorted view, not marked records, with Results using %s', async format => {
it.each(['XLSX', 'MD', 'HTML'])('exports the current filtered and sorted view, not marked records, with Results using %s', async format => {
const ui = await mount();
try {
await ui.click('[data-filter]');
@ -63,18 +64,27 @@ it.each(['CSV', 'MD', 'HTML'])('exports the current filtered and sorted view, no
await act(async () => button.click());
expect(ui.downloads).toHaveBeenCalledTimes(1);
const payload = ui.downloads.mock.calls[0][0];
expect(payload.text).toContain('Synthetic affected host');
expect(payload.text).not.toContain('Excluded marked');
expect(payload.text).not.toContain('Excluded passed');
expect(payload.text).toContain('Included 089');
expect(payload.text).toContain('Included 000');
const text = typeof payload.text === 'string' ? payload.text
: (book => book.SheetNames.flatMap(name => XLSX.utils.sheet_to_json<string[]>(book.Sheets[name], { header: 1 }).flat()).join('\n'))(XLSX.read(payload.text, { type: 'array' }));
if (format === 'XLSX') {
const book = XLSX.read(payload.text, { type: 'array' });
expect(book.SheetNames).toEqual(['Übersicht', 'CIS-CAT Pro']);
const list = XLSX.utils.sheet_to_json<string[]>(book.Sheets['CIS-CAT Pro'], { header: 1 });
expect(list.slice(1).map(row => row[1])).toEqual(ui.container.querySelector('output')!.textContent!.split(','));
}
expect(payload.fileName).toBe(`Synthetic-work-view.${format.toLowerCase()}`);
expect(text).toContain('Synthetic affected host');
expect(text).not.toContain('Excluded marked');
expect(text).not.toContain('Excluded passed');
expect(text).toContain('Included 089');
expect(text).toContain('Included 000');
const report = ui.projection.mock.results[0].value as exporter.WorkViewExport;
expect(report.entries.map(entry => entry.title).join(',')).toBe(ui.container.querySelector('output')!.textContent);
expect(report.entries).toHaveLength(90);
expect(report.entries.every(entry => entry.resultRowCount === 1)).toBe(true);
expect(report.context.find(block => block.label === 'Scope')?.value).toContain('slot');
if (format === 'HTML') {
const doc = new DOMParser().parseFromString(payload.text, 'text/html');
const doc = new DOMParser().parseFromString(text, 'text/html');
expect(doc.querySelectorAll('article')).toHaveLength(90);
expect([...doc.querySelectorAll('table')].every(table => table.querySelectorAll('thead th').length === 2)).toBe(true);
expect(doc.querySelector('script,img,iframe,link')).toBeNull();
@ -94,7 +104,7 @@ it('disables empty exports and reports a failed complete projection without down
const ui = await mount();
try {
ui.projection.mockImplementationOnce(() => { throw new Error('synthetic private evidence must not be exposed'); });
const button = [...ui.container.querySelectorAll('button')].find(item => item.textContent === 'CSV')!;
const button = [...ui.container.querySelectorAll('button')].find(item => item.textContent === 'XLSX')!;
await act(async () => button.click());
expect(ui.downloads).not.toHaveBeenCalled();
expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain('No partial file');

View File

@ -52,7 +52,7 @@ describe('local ASSESSOR HTTP server', () => {
const health = await fetch(`${baseUrl}/api/v1/health`);
expect(await health.json()).toEqual({
status: 'ok', application: 'ASSESSOR', productVersion: '2.6.2', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
status: 'ok', application: 'ASSESSOR', productVersion: '2.6.3', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
});
expect(health.headers.get('access-control-allow-origin')).toBeNull();
});