import { useState, type ChangeEvent, type CSSProperties } from 'react'; import type { DatasetSheet } from '../../domain/dataset'; import type { FindingRecord, RecordField } from '../../domain/finding'; import type { Review } from '../../domain/review'; import { defaultReview } from '../../domain/review'; 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 { formatPolicyComparisonValue, policyValueName } from '../../profiles/group-policy/presentation'; import { 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'; interface Props { record: FindingRecord; title: string; review?: Review; linkedSheets: DatasetSheet[]; resultReferences: string[]; search: string; position: number; recordCount: number; /** Werkzeug-ID des Datasets; entscheidet ueber die werkzeuggebundene Statusnormalisierung. */ toolId?: string; onPrevious(): void; onNext(): void; onReview(patch: Partial): void; onResults?(): void; /** Ist das Ergebnisfenster zu diesem Fund gerade geoeffnet? */ resultsOpen?: boolean; /** Farbe des geöffneten Result-Fensters; entspricht der Kachelmarkierung. */ resultColor?: string; } 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|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()) { const hiddenFieldIndexes = new Set(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, hiddenFieldIndexes } = cisCatOptionalFields(record, toolId, resultSectionIndexes); 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 hasPolicyEvidenceResults = toolId === 'group-policy' && hasStructuredResults && policyEvidenceResultSheets(record).length > 0; 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 ? record.fields.filter(field => { const label = normalizeLabel(field.label); return field.value && (label === 'configured value' || label === 'baseline value'); }) : []; 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 (normalized === 'result message') return 0; 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(); const summaryFields = record.fields .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) || 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 = [...record.fields, ...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, optionalFields, remainingFields, policyComparisonFields }; } export function FindingReader({ record, title, review: storedReview, linkedSheets, resultReferences, search, position, recordCount, toolId, onPrevious, onNext, onReview, onResults, resultsOpen, resultColor }: Props) { const resultButtonStyle = resultsOpen && resultColor ? ({ '--result-color': resultColor } as CSSProperties) : undefined; const [openLinkedResultsFor, setOpenLinkedResultsFor] = useState(''); const linkedResultsOpen = openLinkedResultsFor === record.id; const review = { ...defaultReview(), ...storedReview }; const severityField = record.fields.find(field => /^(severity|criticality|priority)$/i.test(field.label) && field.value); const severity = normalizeSeverity(severityField?.value); const hasStructuredResults = linkedSheets.length > 0; const resolvedToolId = toolIdForRecord(record, toolId); const webSearch = findingWebSearch(record, resolvedToolId); const { statusField, badges, derivedCategories, mitre, summary, checkTitle, optionalFields, remainingFields, policyComparisonFields } = selectFindingFields(record, title, hasStructuredResults, resolvedToolId); const comparisonOutcome = statusField?.value ? outcomeForStatusValue(resolvedToolId, statusField.value) : 'unknown'; const outcomeMark = findingOutcomeMark(record, toolId); const policyHeading = policyComparisonFields.map(field => policyValueName(field.value)).find(Boolean); // Nur echte Dateiverweise gehoeren in den Hinweis. Werkzeuge, die ihr // Ergebnis als Fliesstext oder Tabelle in dasselbe Feld schreiben, haetten // sonst ihren gesamten Inhalt ein zweites Mal als "Referenz" dort stehen. const fileReferences = [...new Set(resultReferences.filter(isFileReference))]; const updateText = (field: keyof Review) => (event: ChangeEvent) => onReview({ [field]: event.target.value }); return (

{highlightText(policyHeading || checkTitle?.value || (resolvedToolId === 'group-policy' ? title : 'Finding'), search)}

{policyComparisonFields.length > 0 &&
{policyComparisonFields.map(field => { const expected = normalizeLabel(field.label) === 'baseline value'; return

{expected ? 'Expected baseline value' : 'Configured customer value'}

; })}
} {summary.map(field =>

{highlightText(field.label, search)}

{resolvedToolId === 'group-policy' && /^(?:description|remediation|sources)$/.test(normalizeLabel(field.label)) ?

: }
)} {optionalFields.map(field =>
{highlightText(field.label, search)}
)}
{([['prerequisites', 'Prerequisites'], ['access', 'Required Access / Roles'], ['nextStep', 'Next Step'], ['evidence', 'Evidence / Acceptance Criteria'], ['comment', 'Comment']] as Array<[keyof Review, string]>).map(([field, label]) => (