592 lines
42 KiB
TypeScript
592 lines
42 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 { 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 { 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 { RegulatoryMappingPanel } from './RegulatoryMappingPanel';
|
|
import { regulatoryMappingResultId, 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, 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));
|
|
}
|
|
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, 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 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, 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 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);
|
|
|
|
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 / Record / {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. */}
|
|
{statusField?.value && <span className="outcome-mark" data-outcome={outcomeForStatusValue(resolvedToolId, statusField.value)}>{highlightText(statusField.value, 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={() => setRiskAcceptanceFor(record.id)}>
|
|
Accepted Risk{riskState
|
|
? <small>{riskState}</small>
|
|
: ' ?'}
|
|
</button>
|
|
<div className="reader-heading-actions">
|
|
<FindingContextBadges record={record} isPolicySetting={resolvedToolId === 'group-policy'}/>
|
|
<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} title={title} occurrenceId={record.findingOccurrenceId} value={review.riskAcceptance} onSave={onRiskAcceptance} 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>)}
|
|
{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 && <RegulatoryMappingPanel response={regulatoryMapping.response} loading={regulatoryMapping.loading} error={regulatoryMapping.error}
|
|
onStart={() => void regulatoryMapping.start()} onDecision={(candidate, decision, comment) => void regulatoryMapping.decide(candidate, decision, comment)} />}
|
|
{/* 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';
|