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

485 lines
33 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 { RiskAcceptanceDialog } from './RiskAcceptanceDialog';
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 { 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';
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>;
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) => {
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<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, 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;
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 (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<string>();
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, 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;
}
export function FindingReader({ record, title, review: storedReview, previousPolicyReviews, linkedSheets, resultReferences, search, position, recordCount, toolId, onPrevious, onNext, onReview, onRiskAcceptance, onResults, resultsOpen, resultColor, projectId = '', backendStatus = 'unavailable' }: Props) {
const resultButtonStyle = resultsOpen && resultColor ? ({ '--result-color': resultColor } as CSSProperties) : undefined;
const [openLinkedResultsFor, setOpenLinkedResultsFor] = useState('');
const [riskAcceptanceFor, setRiskAcceptanceFor] = 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, policyReadiness } = 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);
const policySearchKeys = policyAnalyzerSearchKeys(record, title, resolvedToolId);
// 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<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => onReview({ [field]: event.target.value });
const riskState = review.riskAcceptance ? riskAcceptanceState(review.riskAcceptance, record.findingOccurrenceId) : undefined;
const regulatoryMapping = useRegulatoryMapping(projectId, record.findingOccurrenceId, backendStatus);
return (
<article className="finding-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">
<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>}
{derivedCategories.map(category => <span className="status-badge" data-derived-category="cis" 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 && <div className="mitre-line">{mitre.map(field => <span className="mitre-badge" key={field.label}>{mitreBadgeLabel(field.label)}: {highlightText(field.value, search)}</span>)}</div>}
</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={() => setRiskAcceptanceFor(record.id)}>
Accepted Risk{riskState
? <small>{riskState}</small>
: ' ?'}
</button>
<div className="reader-heading-actions">
{policySearchKeys.length > 0 && <div className="policy-search-key"><span>Policy Analyzer search {policySearchKeys.length === 1 ? 'key' : 'terms'}</span><div>{policySearchKeys.map(key => <code key={key}>{highlightText(key, search)}</code>)}</div></div>}
{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="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>
{/* 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>
<span className="bookmark-label">Bookmark</span>
<button className="bookmark-btn" type="button" aria-pressed={review.include} onClick={() => onReview({ include: !review.include })}><BookmarkIcon filled={review.include} /></button>
</div>
</div>
{riskAcceptanceFor === record.id && <RiskAcceptanceDialog key={record.id} title={title} occurrenceId={record.findingOccurrenceId} value={review.riskAcceptance} onSave={onRiskAcceptance} onClose={() => setRiskAcceptanceFor('')} />}
<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="reader-summary">
{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 === 'group-policy' ? title : 'Finding')} search={search}/></h3>
{policyReadiness && <div className="policy-evaluation-warning" role="status"><h4>Historical comparison</h4><p>{policyReadiness.value}</p></div>}
{resolvedToolId === 'group-policy' && Boolean(record.policySourceEvidence?.length) && <PolicySourceEvidence sources={record.policySourceEvidence!} search={search}/>}
{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>}
{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 === '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>)}
{optionalFields.map(field => <details className="finding-optional-detail" key={`${field.index}-${field.label}`}>
<summary><TriangleIcon direction="right" />{highlightText(field.label, search)}</summary>
<RichTextValue value={field.value} search={search} label={field.label} onResults={onResults} />
</details>)}
</section>
{regulatoryMapping.open && <RegulatoryMappingPanel response={regulatoryMapping.response} loading={regulatoryMapping.loading} error={regulatoryMapping.error}
onStart={() => void regulatoryMapping.start()} onDecision={(candidate, decision, comment) => void regulatoryMapping.decide(candidate, decision, comment)} />}
<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>
{/* 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. */}
{resultReferences.length > 0 && !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>
);
}