396 lines
26 KiB
TypeScript
396 lines
26 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 { PathText } from '../../components/PathText/PathText';
|
|
import { normalizeNumericValue } from '../../shared/text';
|
|
import { sourceFileLabel } from '../../shared/source-file-label';
|
|
import { outcomeForStatusValue, toolIdForRecord } from '../../importers/status-normalization';
|
|
import { isFileReference } from '../../profiles/purple-knight/result-linker';
|
|
import { highlightText } from '../../components/HighlightText/highlight-text';
|
|
import { BookmarkIcon } from '../../components/BookmarkIcon';
|
|
import { TriangleIcon } from '../../components/TriangleIcon';
|
|
import { findingOutcomeMark, resultSectionFields, resultStatusFieldFor } from '../linked-results/result-message';
|
|
import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
|
|
import { formatPolicyComparisonValue, policyValueName } from '../../profiles/group-policy/presentation';
|
|
import { policyFindingCategories } from '../../profiles/group-policy/categories';
|
|
import { findingWebSearch } from '../../profiles/public-search/search';
|
|
import { policyEvidenceResultSheets, POLICY_CONFLICT_DETAILS, POLICY_DUPLICATE_DETAILS } from '../../profiles/group-policy/conflict-presentation';
|
|
|
|
interface Props {
|
|
record: FindingRecord;
|
|
title: string;
|
|
review?: Review;
|
|
linkedSheets: DatasetSheet[];
|
|
resultReferences: string[];
|
|
search: string;
|
|
position: number;
|
|
recordCount: number;
|
|
/** Werkzeug-ID des Datasets; entscheidet ueber die werkzeuggebundene Statusnormalisierung. */
|
|
toolId?: string;
|
|
onPrevious(): void;
|
|
onNext(): void;
|
|
onReview(patch: Partial<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 /^(?: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;
|
|
if (hasPolicyEvidenceResults) record.fields.filter(field => [POLICY_CONFLICT_DETAILS, POLICY_DUPLICATE_DETAILS, 'Sources', 'Configured Value'].includes(field.label))
|
|
.forEach(field => hiddenFieldIndexes.add(field.index));
|
|
const policyComparisonFields = toolId === 'group-policy' && !hasPolicyEvidenceResults ? record.fields.filter(field => {
|
|
const label = normalizeLabel(field.label);
|
|
return field.value && (label === 'configured value' || label === 'baseline value');
|
|
}) : [];
|
|
policyComparisonFields.forEach(field => hiddenFieldIndexes.add(field.index));
|
|
const badges = record.fields.filter(field => {
|
|
if (!field.value) return false;
|
|
const label = normalizeLabel(field.label);
|
|
return /^(?:category|severity|weight|score|category score|domain risk level)$/.test(label)
|
|
|| (/^points?$/.test(label) && normalizeNumericValue(field.value) !== null);
|
|
});
|
|
const mitre = [...record.fields, ...legacyFramework.fields].filter(field => mitreBadgeLabel(field.label) && field.value);
|
|
const summaryPriority = (label: string) => {
|
|
const normalized = normalizeLabel(label);
|
|
if (normalized === 'conflict' || normalized === 'duplicates') return -1;
|
|
if (normalized === 'result message') return 0;
|
|
if (/^number of results?$/.test(normalized)) return 1;
|
|
if (normalized === 'description') return 2;
|
|
if (toolId === 'pingcastle' && normalized === 'technical explanation') return 3;
|
|
if (toolId === 'pingcastle' && normalized === 'advised solution') return 4;
|
|
if (toolId === 'pingcastle' && normalized === 'documentation') return 5;
|
|
if (/^remediation(?: guidance| recommendation)?$/.test(normalized)) return 3;
|
|
if (/^(source|quelle)(?:\s*:\s*.*)?$/.test(normalized)) return 4;
|
|
if (normalized === 'nvd affected configurations') return 11;
|
|
return 10;
|
|
};
|
|
const summaryValues = new Set<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 };
|
|
}
|
|
|
|
export function FindingReader({ record, title, review: storedReview, linkedSheets, resultReferences, search, position, recordCount, toolId, onPrevious, onNext, onReview, onResults, resultsOpen, resultColor }: Props) {
|
|
const resultButtonStyle = resultsOpen && resultColor ? ({ '--result-color': resultColor } as CSSProperties) : undefined;
|
|
const [openLinkedResultsFor, setOpenLinkedResultsFor] = useState('');
|
|
const linkedResultsOpen = openLinkedResultsFor === record.id;
|
|
const review = { ...defaultReview(), ...storedReview };
|
|
const severityField = record.fields.find(field => /^(severity|criticality|priority)$/i.test(field.label) && field.value);
|
|
const severity = normalizeSeverity(severityField?.value);
|
|
const hasStructuredResults = linkedSheets.length > 0;
|
|
const resolvedToolId = toolIdForRecord(record, toolId);
|
|
const webSearch = findingWebSearch(record, resolvedToolId);
|
|
const { statusField, badges, derivedCategories, mitre, summary, checkTitle, optionalFields, remainingFields, policyComparisonFields } = selectFindingFields(record, title, hasStructuredResults, resolvedToolId);
|
|
const comparisonOutcome = statusField?.value ? outcomeForStatusValue(resolvedToolId, statusField.value) : 'unknown';
|
|
const outcomeMark = findingOutcomeMark(record, toolId);
|
|
const policyHeading = policyComparisonFields.map(field => policyValueName(field.value)).find(Boolean);
|
|
// Nur echte Dateiverweise gehoeren in den Hinweis. Werkzeuge, die ihr
|
|
// Ergebnis als Fliesstext oder Tabelle in dasselbe Feld schreiben, haetten
|
|
// sonst ihren gesamten Inhalt ein zweites Mal als "Referenz" dort stehen.
|
|
const fileReferences = [...new Set(resultReferences.filter(isFileReference))];
|
|
const updateText = (field: keyof Review) => (event: ChangeEvent<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 / Record / {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. */}
|
|
{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>)}
|
|
{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>
|
|
<div className="reader-heading-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>}
|
|
<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(policyHeading || checkTitle?.value || (resolvedToolId === 'group-policy' ? title : 'Finding'), search)}</h3>
|
|
{policyComparisonFields.length > 0 && <div className="policy-value-comparison" data-outcome={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>{expected ? 'Expected baseline value' : 'Configured customer value'}</h4>
|
|
<p className="policy-guidance-text"><PathText value={formatPolicyComparisonValue(field.value)} search={search} /></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>
|
|
<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>
|
|
);
|
|
}
|