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

316 lines
20 KiB
TypeScript

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 { 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 { resultSectionFields, resultStatusFieldFor } from '../linked-results/result-message';
import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
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<Review>): 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 /^(?:shortname|si version|target|runtime|points scale|rule id)$/.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?$/.test(normalizeLabel(label));
const isPingCastleLinkedSectionField = (label: string) => normalizeLabel(label) === 'verwandte daten';
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 resultSectionIndexes = new Set(resultSectionFields(record, toolId).map(field => field.index));
const { optionalFields, hiddenFieldIndexes } = cisCatOptionalFields(record, toolId, resultSectionIndexes);
// 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));
}
if (hasStructuredResults && toolId === 'pingcastle') {
record.fields
.filter(field => isRedundantDetailField(field.label))
.forEach(field => hiddenFieldIndexes.add(field.index));
}
const statusField = resultStatusFieldFor(record, toolId);
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.filter(field => mitreBadgeLabel(field.label) && field.value);
const summaryPriority = (label: string) => {
const normalized = normalizeLabel(label);
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;
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.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 };
}
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 { statusField, badges, derivedCategories, mitre, summary, checkTitle, optionalFields, remainingFields } = selectFindingFields(record, title, hasStructuredResults, 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 });
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>{highlightText(title, search)}</h2>
<div className="finding-badges">
{/* Ergebnisstatus als Outcome, nicht als Meta-Plakette: sonst ist
`Investigate` optisch nicht von `Passed` zu unterscheiden. */}
{statusField?.value && <span className="outcome-mark" data-outcome={outcomeForStatusValue(resolvedToolId, statusField.value)}>{highlightText(statusField.value, 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>;
})}
</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>
<div className="reader-heading-actions">
<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>
{/* 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>
<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">
<h3>{highlightText(checkTitle?.value || 'Finding', search)}</h3>
{summary.map(field => <div key={`${field.index}-${field.label}`}><h4>{highlightText(field.label, search)}</h4><RichTextValue value={field.value} search={search} 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} onResults={onResults} />
</details>)}
</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>
{/* 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} onResults={onResults} /></td></tr>)}</tbody></table>
</details>
)}
</article>
);
}