Assessor_2.0/src/features/finding-reader/FindingReader.tsx

642 lines
46 KiB
TypeScript

import { Fragment, 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 { riskAcceptanceState, type RiskAcceptanceCommand } from '../../domain/risk-acceptance';
import {
defenderRiskTargetsForRecord,
defenderRiskCoverageDecisions,
type ScopedRiskAcceptance,
type ScopedRiskAcceptanceWrite
} from '../../domain/scoped-risk-acceptance';
import { RiskAcceptanceDialog } from './RiskAcceptanceDialog';
import { PolicySourceEvidence } from './PolicySourceEvidence';
import { PolicyBaselineComparison } from './PolicyBaselineComparison';
import { PolicyProblemSummary } from './PolicyProblemSummary';
import { FindingFirstRead } from './FindingFirstRead';
import { FindingContext, FindingContextBadges } from './FindingContext';
import { useContextClusterIndex } from '../context-layer/use-context-cluster-index';
import { numberedClusterLabel, publishedClusterColour } from '../sidebar/context-cluster-display';
import { FindingWorkPackage } from '../../remediation/FindingWorkPackage';
import { RelatedFindingRing, useRelatedFindings } from '../related-findings/RelatedFindings';
import { findingFirstRead, firstReadConsumedFields, firstReadFieldRank, firstReadImpactSplit, firstReadSectionSplit } from './first-read';
import { policyProblemSummary } from '../../profiles/group-policy/problem-summary';
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 { cisCategoryRecordTitle } from '../../profiles/cis-cat/cis-benchmark-section-titles';
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 { maesterExecutionErrorText, maesterFindingTitle, maesterTagText } from '../../profiles/maester/presentation';
import type { BackendStatus } from '../../app/app-state';
import { RegulatoryMappingDialog } from '../../compliance/ui/RegulatoryMappingPanel';
import { FindingIsoControls } from '../../compliance/ui/FindingIsoControls';
import { FindingBsiRequirements } from '../../compliance/ui/FindingBsiRequirements';
import { regulatoryMappingResultId, useRegulatoryMapping } from '../../compliance/ui/use-regulatory-mapping';
interface Props {
record: FindingRecord;
title: string;
review?: Review;
previousPolicyReviews?: Array<{ id: string; baseline: 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<Review>): void;
onRiskAcceptance?(command: RiskAcceptanceCommand): Promise<void>;
scopedRiskAcceptances?: ScopedRiskAcceptance[];
onScopedRiskAcceptance?(write: ScopedRiskAcceptanceWrite): Promise<void>;
onResults?(): void;
/** Ist das Ergebnisfenster zu diesem Fund gerade geoeffnet? */
resultsOpen?: boolean;
/** Farbe des geöffneten Result-Fensters; entspricht der Kachelmarkierung. */
resultColor?: string;
projectId?: string;
backendStatus?: BackendStatus;
}
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, toolId?: string) => {
const normalized = normalizeLabel(label);
// GPO technical provenance is reference data below the problem statement. Scope stays in the report (2026-09-06).
if (toolId === 'group-policy' && /^(?:registry path|technical identity|imported|evaluated|diagnostics)$/.test(normalized)) return true;
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"- bzw. "Alerts"-Tabelle im Ergebnisfenster.
if (toolId === 'defender-for-iot-vulnerabilities' || toolId === 'defender-for-iot-alerts') {
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 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 (script) optionalFields.push(displayField(script, 'Test details', script.value));
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));
// Structured variant comparisons replace the repeated per-package text in the reader;
// the unchanged fields remain the export and search source.
const hasPolicyBaselineComparisons = toolId === 'group-policy' && Boolean(record.policyBaselineComparisons?.length);
if (hasPolicyBaselineComparisons) record.fields
// Conflict and Duplicates summaries are stated in the problem summary.
.filter(field => ['Description', 'Baseline', 'Comparison', 'Baseline value', 'Remediation', 'Conflict', 'Duplicates'].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);
// The grouped baseline comparison states expected values once per distinct value.
return field.value && ((!hasPolicySourceEvidence && label === 'configured value') || (!hasPolicyBaselineComparisons && 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);
// Scanner first read (user decision 2026-09-25): problem, impact and next step are shown above,
// so their fields are not repeated; background precedes references.
if (toolId !== 'group-policy') firstReadConsumedFields(record, toolId).forEach(field => hiddenFieldIndexes.add(field.index));
// A native remediation subsection moved to the next step is not repeated; the text before it stays as background.
// References a scanner appended to its impact text move to the references position.
const sectionSplit = toolId !== 'group-policy' ? firstReadSectionSplit(record, toolId) : undefined;
const impactSplit = toolId !== 'group-policy' ? firstReadImpactSplit(record, toolId) : undefined;
const sectionBackground: RecordField[] = [];
let firstReadIndex = Math.max(-1, ...record.fields.map(field => field.index), ...maesterPresentationFields.map(field => field.index)) + 1;
if (sectionSplit) {
hiddenFieldIndexes.add(sectionSplit.field.index);
if (sectionSplit.background) sectionBackground.push({ ...sectionSplit.field, value: sectionSplit.background, index: firstReadIndex++ });
}
if (impactSplit?.references) sectionBackground.push({ ...impactSplit.field, label: 'References', value: impactSplit.references, index: firstReadIndex++ });
const summaryPriority = (label: string) => {
const normalized = normalizeLabel(label);
const firstReadRank = toolId !== 'group-policy' ? firstReadFieldRank(label, toolId) : undefined;
if (firstReadRank !== undefined) return firstReadRank;
if (normalized === 'conflict' || normalized === 'duplicates') return -1;
if (toolId === 'maester' && normalized === 'id') return -1;
if (normalized === 'result message') 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;
return 10;
};
const summaryValues = new Set<string>();
const presentationFields = [...record.fields, ...maesterPresentationFields, ...sectionBackground];
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, toolId)
|| (!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, toolId)) 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
* presentation aid only: evaluation and finding identity continue to use the
* complete technical policy identity.
*/
export function policyAnalyzerSearchKeys(record: FindingRecord, title: string, toolId?: string): string[] {
if (toolId !== 'group-policy') return [];
const field = (label: string) => record.fields.find(item => normalizeLabel(item.label) === label)?.value.trim() ?? '';
const preference = field('policy type') === 'group-policy-preference';
const technicalTitle = title.split(/\s*\u00b7\s*/).at(-1)?.trim() ?? title.trim();
const titleParts = technicalTitle.split(/[\\/]/).map(part => part.trim()).filter(Boolean);
const keys: string[] = [];
const add = (value?: string) => {
const trimmed = value?.trim();
if (trimmed && !keys.some(item => item.toLocaleLowerCase('en-US') === trimmed.toLocaleLowerCase('en-US'))) keys.push(trimmed);
};
for (const item of record.fields) {
if (!/^(?:configured value|baseline value)$/.test(normalizeLabel(item.label))) continue;
if (preference) {
try {
const values = JSON.parse(item.value) as unknown;
const visit = (value: unknown): void => {
if (Array.isArray(value)) return value.forEach(visit);
if (!value || typeof value !== 'object') return;
const entry = value as Record<string, unknown>;
if (entry.kind === 'registry-preference' && typeof entry.key === 'string') add(entry.key.split(/[\\/]/).at(-1));
Object.values(entry).forEach(visit);
};
visit(values);
} catch { /* The unchanged technical title remains the safe fallback. */ }
}
add(policyValueName(item.value));
}
if (!keys.length && preference && titleParts.length > 1) add(titleParts.at(-2));
if (preference || !keys.length) add(titleParts.at(-1));
return keys;
}
function MitreBadgeDisclosure({ fields, search }: { fields: RecordField[]; search: string }) {
const [expanded, setExpanded] = useState(false);
return <div className="mitre-disclosure">
<button className="mitre-disclosure-toggle" type="button" aria-label="MITRE ATT&CK and D3FEND information"
title={expanded ? 'Hide MITRE information' : 'Show MITRE information'} aria-expanded={expanded}
onClick={() => setExpanded(value => !value)}><TriangleIcon direction={expanded ? 'down' : 'right'}/></button>
{expanded && <div className="mitre-line">{fields.map(field => <span className="mitre-badge" key={field.label}>{mitreBadgeLabel(field.label)}: {highlightText(field.value, search)}</span>)}</div>}
</div>;
}
export function FindingReader({ record, title, review: storedReview, previousPolicyReviews, linkedSheets, resultReferences, search, position, recordCount, toolId, onPrevious, onNext, onReview, onRiskAcceptance, scopedRiskAcceptances = [], onScopedRiskAcceptance, onResults, resultsOpen, resultColor, projectId = '', backendStatus = 'unavailable' }: Props) {
const relatedSelection = useRelatedFindings()?.selection;
const contextClusters = useContextClusterIndex();
const resultButtonStyle = resultsOpen && resultColor ? ({ '--result-color': resultColor } as CSSProperties) : undefined;
const [openLinkedResultsFor, setOpenLinkedResultsFor] = useState('');
const [riskAcceptanceFor, setRiskAcceptanceFor] = useState('');
const [riskScope, setRiskScope] = useState('entire');
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 deviceRiskTargets = defenderRiskTargetsForRecord(record, resolvedToolId);
const scopedForFinding = scopedRiskAcceptances.filter(acceptance => acceptance.target.members.some(member => member.findingDefinitionId === record.findingDefinitionId));
const selectedDeviceTarget = deviceRiskTargets.find(target => target.device.key === riskScope);
const selectedScopedAcceptance = selectedDeviceTarget ? scopedForFinding
.filter(acceptance => acceptance.target.kind === 'device-finding' && acceptance.target.device.key === selectedDeviceTarget.device.key)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] : undefined;
const webSearch = findingWebSearch(record, resolvedToolId);
const { statusField, badges, derivedCategories, mitre, summary, checkTitle, sourceRuleId, optionalFields, remainingFields, policyComparisonFields, policyReadiness } = selectFindingFields(record, title, hasStructuredResults, resolvedToolId);
const comparisonOutcome = statusField?.value ? outcomeForStatusValue(resolvedToolId, statusField.value) : 'unknown';
const problem = resolvedToolId === 'group-policy' && !policyReadiness ? policyProblemSummary(record) : undefined;
const firstRead = resolvedToolId !== 'group-policy' ? findingFirstRead(record, resolvedToolId, linkedSheets, comparisonOutcome) : undefined;
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 hasResolvableResultReference = fileReferences.length > 0
|| resultReferences.some(reference => /source-table:\d+/i.test(reference));
const updateText = (field: keyof Review) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => onReview({ [field]: event.target.value });
const riskState = review.riskAcceptance ? riskAcceptanceState(review.riskAcceptance, record.findingOccurrenceId) : undefined;
const regulatoryMapping = useRegulatoryMapping(projectId, regulatoryMappingResultId(record), backendStatus);
const coveringDecisionLabels = selectedDeviceTarget
? defenderRiskCoverageDecisions(selectedDeviceTarget, review.riskAcceptance, scopedForFinding).map(decision => decision.kind === 'whole-finding'
? `Entire finding · ${selectedDeviceTarget.cve} · ${decision.state}`
: `${decision.kind === 'device-snapshot' ? 'Device snapshot' : 'Device finding'} · ${decision.id}`)
: [];
return (
<article className="finding-reader" data-group-scroll="work-reader">
<nav className="reader-nav" aria-label="Record navigation">
<div>
<button type="button" onClick={onPrevious} disabled={position <= 0} aria-label="Previous record"><TriangleIcon direction="left" /></button>
<button type="button" onClick={onNext} disabled={position >= recordCount - 1} aria-label="Next record"><TriangleIcon direction="right" /></button>
</div>
<span title={record.sourceFile ?? record.sourceSheet}>Inbox&nbsp; / &nbsp;Record&nbsp; / &nbsp;{sourceFileLabel(record.sourceFile) || record.sourceSheet || 'Finding'}</span>
</nav>
<div className="reader-heading">
<RelatedFindingRing record={record} inline/>
<span className={`reader-title-hex severity-${severity}`} aria-hidden="true" />
<div>
<h2><PathText value={title} search={search}/></h2>
<div className="finding-badges">
{/* Ergebnisstatus als Outcome, nicht als Meta-Plakette: sonst ist
`Investigate` optisch nicht von `Passed` zu unterscheiden. */}
{outcomeMark && <span className="outcome-mark" data-outcome={outcomeMark.outcome}>{highlightText(outcomeMark.label, search)}</span>}
{/* The heading already names the CIS section; each number badge names its section on hover. */}
{derivedCategories.map(category => <span className="status-badge" data-derived-category="cis" title={cisCategoryRecordTitle(record, category)} key={category}>{highlightText(category, search)}</span>)}
{badges.map(field => {
const isSeverity = field.label.toLowerCase() === 'severity';
const scale = /^(?:category score|domain risk level)$/i.test(field.label.trim()) ? ' / 100' : '';
const className = isSeverity ? 'severity-badge severity-' + normalizeSeverity(field.value) : 'status-badge';
const value = isSeverity ? field.value : field.label + ': ' + field.value + scale;
return <span className={className} key={field.label}>{highlightText(value, search)}</span>;
})}
{policyFindingCategories(record, resolvedToolId).map(category => <span className="status-badge" data-derived-category="gpo" key={category}>{highlightText(category, search)}</span>)}
{policyDiagnosticBadges(record, resolvedToolId).map(diagnostic => <span className="status-badge policy-diagnostic-badge" data-policy-diagnostic={diagnostic.toLowerCase()} key={`policy-diagnostic-${diagnostic}`}>{diagnostic}</span>)}
{resolvedToolId === 'group-policy' && record.fields.filter(field => normalizeLabel(field.label) === 'scope' && field.value.trim()).slice(0, 1).map(field => (
<span className="status-badge" data-policy-scope key="policy-scope">Scope: {highlightText(field.value, search)}</span>
))}
</div>
{mitre.length > 0 && <MitreBadgeDisclosure key={record.id} fields={mitre} search={search}/>}
</div>
<button className={`btn accepted-risk-trigger${riskState === 'Draft' ? ' is-draft' : ''}`} data-risk-acceptance-state={riskState?.toLowerCase() ?? 'not-recorded'} type="button" aria-haspopup="dialog" onClick={() => { setRiskScope('entire'); setRiskAcceptanceFor(record.id); }}>
Accepted Risk{riskState
? <small>{riskState}</small>
: scopedForFinding.length ? <small>{scopedForFinding.length} scoped</small> : ' ?'}
</button>
<div className="reader-heading-actions">
<FindingContextBadges record={record} isPolicySetting={resolvedToolId === 'group-policy'}/>
{regulatoryMapping.available && <button className="btn" type="button" aria-expanded={regulatoryMapping.open} disabled={!regulatoryMapping.open && Boolean(regulatoryMapping.disabledReason)} title={regulatoryMapping.disabledReason} onClick={() => regulatoryMapping.setOpen(!regulatoryMapping.open)}>
Map ISO controls{regulatoryMapping.acceptedCount ? ` (${regulatoryMapping.acceptedCount})` : ''}
</button>}
<div className="reader-primary-actions" role="group" aria-label="Finding actions">
{webSearch && <a className="btn" href={webSearch.url} target="_blank" rel="noopener noreferrer" referrerPolicy="no-referrer" data-search-specificity={webSearch.specificity} title={`${webSearch.specificity === 'topic' ? 'Topic search — ' : ''}${webSearch.query}`}>AI search</a>}
<button className="bookmark-btn" type="button" aria-label="Bookmark" title="Bookmark" aria-pressed={review.include} onClick={() => onReview({ include: !review.include })}><BookmarkIcon filled={review.include} /></button>
{/* Gleiche Kennzeichnung wie auf den Kacheln: ist das Ergebnisfenster
zu diesem Fund offen, traegt die Schaltflaeche den Fensterton. */}
<button className={`btn results-detail-btn results-btn${resultsOpen ? ' is-open' : ''}`} style={resultButtonStyle} type="button" disabled={!onResults} onClick={onResults}>Results</button>
</div>
</div>
</div>
{riskAcceptanceFor === record.id && <RiskAcceptanceDialog key={`${record.id}:${riskScope}`} title={title} occurrenceId={record.findingOccurrenceId}
value={riskScope === 'entire' ? review.riskAcceptance : selectedScopedAcceptance}
systemScope={selectedDeviceTarget ? `${selectedDeviceTarget.cve} on ${selectedDeviceTarget.device.displayName}${selectedDeviceTarget.device.ip ? ` (${selectedDeviceTarget.device.ip})` : ''}` : undefined}
memberLabels={selectedDeviceTarget ? [selectedDeviceTarget.cve] : []}
coveringDecisionLabels={coveringDecisionLabels}
scopeOptions={deviceRiskTargets.length ? [
{ value: 'entire', label: 'Entire finding', help: 'Applies to every affected device for this CVE.' },
...deviceRiskTargets.map(target => ({ value: target.device.key, label: target.device.displayName, disabled: !target.eligible, help: target.unavailableReason || `${target.device.identityMethod} identity · ${target.device.identityConfidence} confidence` }))
] : []}
selectedScope={riskScope} onScopeChange={setRiskScope}
onSave={riskScope === 'entire' ? onRiskAcceptance : selectedDeviceTarget && onScopedRiskAcceptance ? command => onScopedRiskAcceptance(selectedScopedAcceptance
? { acceptanceId: selectedScopedAcceptance.id, command, expectedRevision: selectedScopedAcceptance.revision }
: { target: { kind: 'device-finding', occurrenceId: selectedDeviceTarget.occurrenceId, deviceKey: selectedDeviceTarget.device.key }, command, expectedRevision: 0 }) : undefined}
onClose={() => setRiskAcceptanceFor('')} />}
<section className="reader-summary">
<div className="reader-source-rule" data-source-rule-id={sourceRuleId ? true : undefined}>
{sourceRuleId && <div><h4>Source Rule ID</h4><p><code className="mono">{highlightText(sourceRuleId.value, search)}</code></p></div>}
<FindingWorkPackage record={record} visibleRecords={relatedSelection?.sheet?.analysis.records}
clusterLabels={Object.fromEntries((contextClusters?.publication.clusters ?? []).map(cluster => [cluster.id, numberedClusterLabel(cluster.id, cluster.label, contextClusters?.publication.clusters)]))}
clusterSeverities={Object.fromEntries((contextClusters?.publication.clusters ?? []).map(cluster => [cluster.id, publishedClusterColour(cluster)]))}/>
</div>
{Boolean(previousPolicyReviews?.length) && <details className="policy-previous-reviews">
<summary>Previous baseline-specific reviews ({previousPolicyReviews!.length})</summary>
<p>These earlier notes remain attached to their original comparisons. Different decisions and risk acceptances are not automatically applied to this combined setting.</p>
{previousPolicyReviews!.map(item => <section key={item.id}>
<h4>{item.baseline}</h4>
<dl>{Object.entries(item.review).filter(([key, value]) => value !== undefined
&& JSON.stringify(value) !== JSON.stringify(defaultReview()[key as keyof Review])).map(([key, value]) =>
<Fragment key={key}><dt>{key.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, character => character.toUpperCase())}</dt>
<dd>{typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)}</dd></Fragment>)}</dl>
</section>)}
</details>}
<h3><PathText value={policyHeading || checkTitle?.value || (resolvedToolId === 'maester' ? maesterFindingTitle(record, title) : resolvedToolId === 'group-policy' ? title : 'Finding')} search={search}/></h3>
{policyReadiness && <div className="policy-evaluation-warning" role="status"><h4>Historical comparison</h4><p>{policyReadiness.value}</p></div>}
{problem && <PolicyProblemSummary summary={problem} outcome={comparisonOutcome ?? 'unknown'} search={search}/>}
{resolvedToolId === 'maester' && !firstRead?.rows.some(row => row.label === 'Result') && <p className="muted maester-missing-result">No result statement is available in this imported finding.</p>}
{firstRead && <FindingFirstRead summary={firstRead} outcome={comparisonOutcome ?? 'unknown'} search={search} onResults={onResults}/>}
{resolvedToolId === 'group-policy' && Boolean(record.policySourceEvidence?.length) && <PolicySourceEvidence sources={record.policySourceEvidence!} search={search} summarized={Boolean(problem)}/>}
{policyComparisonFields.length > 0 && <div className="policy-value-comparison" data-outcome={policyReadiness ? 'historical' : comparisonOutcome}>
{policyComparisonFields.map(field => {
const expected = normalizeLabel(field.label) === 'baseline value';
return <section className="policy-value-block" data-value={expected ? 'expected' : 'configured'} key={`${field.index}-${field.label}`}>
<h4>{policyReadiness ? (expected ? 'Saved baseline value' : 'Saved customer value') : expected ? 'Expected baseline value' : 'Configured customer value'}</h4>
<p className="policy-guidance-text">{policyComparisonDisplayLines(field.value).map((line, index) =>
<Fragment key={index}>{index > 0 && '\n'}{line.emphasized
? <strong className="policy-value-emphasis"><PathText value={line.text} search={search}/></strong>
: <PathText value={line.text} search={search}/>}</Fragment>)}</p>
</section>;
})}
</div>}
{resolvedToolId === 'group-policy' && Boolean(record.policyBaselineComparisons?.length)
&& <PolicyBaselineComparison comparisons={record.policyBaselineComparisons!} search={search}/>}
{/* Context layer (Phase 13E): for every finding after the technical evidence, only while switched on; it never changes the result above. */}
<FindingContext record={record} isPolicySetting={resolvedToolId === 'group-policy'}/>
{summary.map(field => <div className={/^remediation(?: guidance| recommendation)?$/.test(normalizeLabel(field.label)) ? 'reader-remediation' : undefined} key={`${field.index}-${field.label}`}><h4>{highlightText(field.label, search)}</h4>{resolvedToolId === 'maester' && normalizeLabel(field.label) === 'id'
? <p><code className="mono">{highlightText(field.value, search)}</code></p>
: resolvedToolId === 'maester' && normalizeLabel(field.label) === 'test details'
? <pre className="rich-text-code">{highlightText(field.value, search)}</pre>
: resolvedToolId === 'group-policy' && /^(?:description|remediation|sources)$/.test(normalizeLabel(field.label))
? <p className="policy-guidance-text"><PathText value={field.value} search={search} label={field.label} /></p>
: <RichTextValue value={field.value} search={search} label={field.label} onResults={onResults} />}</div>)}
{/* Compliance Engine: controls accepted for this finding, read like Description. */}
<FindingIsoControls controls={regulatoryMapping.acceptedControls}/>
{/* Compliance Engine: BSI requirements derived from those controls through the BSI mapping, and the direct BSI mapping. */}
<FindingBsiRequirements bsi={regulatoryMapping.bsi} projectId={projectId} resultProjectionId={regulatoryMappingResultId(record)} backendStatus={backendStatus}/>
{optionalFields.map(field => <details className="finding-optional-detail" key={`${field.index}-${field.label}`}>
<summary><TriangleIcon direction="right" />{highlightText(field.label, search)}</summary>
{resolvedToolId === 'maester' && normalizeLabel(field.label) === 'test details'
? <pre className="rich-text-code"><code>{highlightText(field.value, search)}</code></pre>
: <RichTextValue value={field.value} search={search} label={field.label} onResults={onResults} />}
</details>)}
</section>
{regulatoryMapping.open && <RegulatoryMappingDialog response={regulatoryMapping.response} loading={regulatoryMapping.loading} error={regulatoryMapping.error}
onStart={() => void regulatoryMapping.start()} onDecision={(candidate, decision, comment) => void regulatoryMapping.decide(candidate, decision, comment)}
onClose={() => regulatoryMapping.setOpen(false)} />}
{/* Work tracking is opt-in below the finding so the first screen states the problem. */}
<FindingReferences record={record}/>
<details className="reader-tracking">
<summary><TriangleIcon direction="right" />Work tracking<span className="reader-tracking__state">
{[`Status: ${review.status}`, `Priority: ${review.priority}`, review.owner && `Owner: ${review.owner}`, review.due && `Due: ${review.due}`].filter(Boolean).join(' · ')}
</span></summary>
<section className="dashboard-grid">
<label>Status<select value={review.status} onChange={updateText('status')}>{statuses.map(value => <option key={value}>{value}</option>)}</select></label>
<label>Priority<select value={review.priority} onChange={updateText('priority')}>{priorities.map(value => <option key={value}>{value}</option>)}</select></label>
<label>Owner / Team<input value={review.owner} onChange={updateText('owner')} /></label>
<label>Due Date<input type="date" value={review.due} onChange={updateText('due')} /></label>
</section>
<section className="review-grid">
{([['prerequisites', 'Prerequisites'], ['access', 'Required Access / Roles'], ['nextStep', 'Next Step'], ['evidence', 'Evidence / Acceptance Criteria'], ['comment', 'Comment']] as Array<[keyof Review, string]>).map(([field, label]) => (
<label key={field}>{label}<textarea value={String(review[field])} onChange={updateText(field)} /></label>
))}
<fieldset className="review-markers">
<legend>Markers</legend>
{([['markerQuestion', 'Question Needed'], ['markerBlocked', 'Permission Blocked'], ['markerQuickwin', 'Quick Win'], ['markerExternal', 'External Team'], ['markerNotRelevant', 'Not Relevant']] as Array<[keyof Review, string]>).map(([field, label]) => (
<label key={field}><input type="checkbox" checked={Boolean(review[field])} onChange={event => onReview({ [field]: event.target.checked })} /> {label}</label>
))}
</fieldset>
</section>
</details>
{/* Ergebnistabellen erscheinen ausschliesslich im schwebenden Fenster
ueber die Results-Schaltflaeche. Inline standen sie ein zweites Mal
in der Lesespalte und drueckten die fachliche Aussage nach unten.
Der Hinweis bleibt nur fuer den Fall, dass zwar eine Referenz
vorliegt, die zugehoerige Datei aber nicht geladen ist - dann ist die
Schaltflaeche abgeschaltet und der Hinweis die einzige Erklaerung. */}
{hasResolvableResultReference && !linkedSheets.length && (
<details className="all-fields linked-results-inline" open={linkedResultsOpen} onToggle={event => setOpenLinkedResultsFor(event.currentTarget.open ? record.id : '')}>
<summary><TriangleIcon direction="right" />Results (reference found)</summary>
{linkedResultsOpen && (
<div className="linked-results-empty"><strong>No matching result file found automatically.</strong>{fileReferences.length > 0 && <><br />Reference: {fileReferences.join(', ')}</>}<br />The referenced file must be loaded in the same project.</div>
)}
</details>
)}
{remainingFields.length > 0 && (
<details className="all-fields">
<summary><TriangleIcon direction="right" />Additional Information</summary>
<table><tbody>{remainingFields.map(field => <tr key={`${field.index}-${field.label}`}><th>{highlightText(field.label, search)}</th><td><RichTextValue value={field.value} search={search} label={field.label} onResults={onResults} /></td></tr>)}</tbody></table>
</details>
)}
</article>
);
}
import { FindingReferences } from './FindingReferences';