import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useApp } from '../../app/AppProvider'; import { WORKSPACE_BAND_ID } from '../../app/workspace-band'; import { TileCard } from '../../components/TileCard/TileCard'; import { FloatingResultsWindow } from '../linked-results/FloatingResultsWindow'; import { linkResultSheets } from '../../profiles/purple-knight/result-linker'; import { linkPingCastleSectionSheets } from '../../profiles/pingcastle/pingcastle-section-links'; import { inlineResultTableSheets } from '../linked-results/inline-result-tables'; import { resultMessageFor, resultOutcomeFor, resultSectionsFor } from '../linked-results/result-message'; import { RESULT_WINDOW_COLORS } from '../linked-results/result-window-colors'; import { useFilteredRecords } from '../search-filter/use-filtered-records'; import { toolIdForRecord } from '../../importers/status-normalization'; import mitreData from '../../assets/data/mitre-enterprise-v18.1.json'; import { buildFindingPathItems, loadStoredMitreMatrix, type FindingPathItem, type MitreGraphData } from './mitre-graph'; const defaultGraphData = mitreData as MitreGraphData; const severityLabels = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', info: 'Info', unknown: 'Unknown' } as const; type SeverityFilter = 'all' | keyof typeof severityLabels; type OriginFilter = 'all' | 'native' | 'derived'; interface MitreGraphViewProps { onOpenFinding(): void; } interface ResultWindowState { recordId: string; toolId?: string; title: string; color: string; zIndex: number; minimized: boolean; } interface ConnectionCurve { x1: number; y1: number; x2: number; y2: number; } interface ColumnWorkspaceCheckboxProps { label: string; checkedCount: number; total: number; onChange(checked: boolean): void; } function ColumnWorkspaceCheckbox({ label, checkedCount, total, onChange }: ColumnWorkspaceCheckboxProps) { const inputRef = useRef(null); useEffect(() => { if (inputRef.current) inputRef.current.indeterminate = checkedCount > 0 && checkedCount < total; }, [checkedCount, total]); return ( ); } export function MitreGraphView({ onOpenFinding }: MitreGraphViewProps) { const app = useApp(); const { dataset, activeSheet, activeSheetId, titleColumn, reviews, search: workSearch, setSelectedRecord, setView, updateReview } = app; const [graphData, setGraphData] = useState(() => loadStoredMitreMatrix() ?? defaultGraphData); const [category, setCategory] = useState('all'); const [severity, setSeverity] = useState('all'); const [originFilter, setOriginFilter] = useState('all'); const [search, setSearchValue] = useState(''); const [selectedId, setSelectedId] = useState(''); const [pathSelection, setPathSelection] = useState>({}); const [resultWindows, setResultWindows] = useState([]); const [svgLines, setSvgLines] = useState([]); const [showFilters, setShowFilters] = useState(true); /* Die Filterzeile steht im Kopfcontainer der Shell, nicht im Inhaltspanel. Den Container rendert die Shell (siehe workspace-band.ts), der Zustand bleibt hier. Der Knoten existiert erst nach dem ersten Commit, deshalb der Effekt. */ const [bandNode, setBandNode] = useState(null); useEffect(() => { setBandNode(document.getElementById(WORKSPACE_BAND_ID)); }, []); const boardRef = useRef(null); const scrollRef = useRef(null); const meterRef = useRef(null); const nextColor = useRef(0); const nextZIndex = useRef(2100); const visibleRecords = useFilteredRecords(); const items = useMemo(() => buildFindingPathItems(visibleRecords, graphData, titleColumn), [graphData, titleColumn, visibleRecords]); const categories = useMemo(() => [...new Set(items.map(item => item.category))].sort((left, right) => left.localeCompare(right)), [items]); const normalizedSearch = search.trim().toLocaleLowerCase('en-US'); const filtered = useMemo(() => items .filter(item => category === 'all' || item.category === category) .filter(item => severity === 'all' || item.severity === severity) .filter(item => originFilter === 'all' || item.mappingOrigin === originFilter) .filter(item => !normalizedSearch || `${item.title} ${item.techniqueIds.join(' ')} ${item.tactics.join(' ')}`.toLocaleLowerCase('en-US').includes(normalizedSearch)) .sort((left, right) => right.risk - left.risk || left.title.localeCompare(right.title)), [category, items, normalizedSearch, originFilter, severity]); const mappedTactics = useMemo(() => graphData.tacticOrder.filter(tactic => filtered.some(item => item.primaryTactic === tactic)), [filtered, graphData.tacticOrder]); const unmapped = filtered.filter(item => item.primaryTactic === 'Unmapped'); const columns = useMemo(() => mappedTactics.map(tactic => ({ tactic, items: filtered.filter(item => item.primaryTactic === tactic) })), [filtered, mappedTactics]); useEffect(() => { const automatic: Record = {}; for (const column of columns) automatic[column.tactic] = column.items[0]?.id ?? ''; setPathSelection(current => { const next = { ...automatic }; for (const column of columns) { if (column.items.some(item => item.id === current[column.tactic])) next[column.tactic] = current[column.tactic]; } return next; }); }, [columns]); const activePath = columns .map(column => column.items.find(item => item.id === pathSelection[column.tactic])) .filter((item): item is FindingPathItem => Boolean(item)); const pathRiskScore = useMemo(() => Math.round(activePath.reduce((sum, item) => sum + item.risk, 0)), [activePath]); useEffect(() => { if (meterRef.current) { const pct = Math.min(100, Math.round((pathRiskScore / 2500) * 100)); meterRef.current.style.width = `${pct}%`; } }, [pathRiskScore]); useLayoutEffect(() => { const updateLines = () => { if (!boardRef.current) return; const boardRect = boardRef.current.getBoundingClientRect(); const lines: ConnectionCurve[] = []; const selectedEls = columns .map(col => boardRef.current?.querySelector(`[data-tactic-name="${col.tactic}"].is-path-step`)) .filter((el): el is Element => Boolean(el)); for (let i = 0; i < selectedEls.length - 1; i++) { const rectA = selectedEls[i].getBoundingClientRect(); const rectB = selectedEls[i + 1].getBoundingClientRect(); lines.push({ x1: rectA.right - boardRect.left, y1: rectA.top + rectA.height / 2 - boardRect.top, x2: rectB.left - boardRect.left, y2: rectB.top + rectB.height / 2 - boardRect.top }); } setSvgLines(lines); }; updateLines(); const timer = setTimeout(updateLines, 80); const scrollContainer = scrollRef.current; if (scrollContainer) scrollContainer.addEventListener('scroll', updateLines); globalThis.addEventListener('resize', updateLines); return () => { clearTimeout(timer); if (scrollContainer) scrollContainer.removeEventListener('scroll', updateLines); globalThis.removeEventListener('resize', updateLines); }; }, [columns, pathSelection]); const selectCriticalChain = () => { const preset: Record = {}; for (const column of columns) { if (column.items.length) { const top = [...column.items].sort((left, right) => right.risk - left.risk)[0]; if (top) preset[column.tactic] = top.id; } } setPathSelection(preset); }; const selected = filtered.find(item => item.id === selectedId) ?? activePath[0] ?? filtered[0]; const linksFor = (item: FindingPathItem) => dataset && activeSheet ? [ ...linkResultSheets(item.record, dataset.sheets, activeSheetId), ...linkPingCastleSectionSheets(item.record, dataset.sheets), ...(resultMessageFor(item.record, activeSheet.toolId ?? dataset.profileId) ? [] : inlineResultTableSheets(item.record)) ] : []; // Dieselben Ergebnistexte wie im Datensatz-Fenster (siehe DatasetViewer): // das Results-Fenster ist werkzeugunabhaengig verbindlich und darf je nach // Ansicht nicht unterschiedlich viel zeigen. const sectionsFor = (item: FindingPathItem) => resultSectionsFor(item.record, activeSheet?.toolId ?? dataset?.profileId); const resultWindowFor = (recordId: string) => resultWindows.find(item => item.recordId === recordId); const activateWindow = (recordId: string) => setResultWindows(current => current.map(item => item.recordId === recordId ? { ...item, zIndex: ++nextZIndex.current } : item)); const openResults = (item: FindingPathItem) => { if (resultWindowFor(item.id)) { setResultWindows(current => current.filter(candidate => candidate.recordId !== item.id)); return; } const sheets = linksFor(item); if (!sheets.length && !sectionsFor(item).length) return; const total = sheets.reduce((sum, sheet) => sum + sheet.analysis.records.length, 0); setResultWindows(current => [...current, { recordId: item.id, toolId: toolIdForRecord(item.record, activeSheet?.toolId ?? dataset?.profileId), title: sheets.length ? `Results (${total.toLocaleString('en-US')}) – ${item.title}` : `Result – ${item.title}`, color: RESULT_WINDOW_COLORS[nextColor.current++ % RESULT_WINDOW_COLORS.length], zIndex: ++nextZIndex.current, minimized: false }]); }; const toggleMinimize = (recordId: string) => setResultWindows(current => current.map(item => item.recordId === recordId ? { ...item, minimized: !item.minimized } : item)); const selectPathItem = (item: FindingPathItem) => { setPathSelection(current => ({ ...current, [item.primaryTactic]: item.id })); setSelectedId(item.id); }; /** * Der Fund stammt aus `filtered` (basiert auf `useFilteredRecords()`), erfuellt * also bereits Suche, Statusfilter und Aktionsfilter - ein Zuruecksetzen dieser * Filter beim Sprung in den Report ist daher nicht noetig und wuerde nur den * aktiven Abweichungen-Filter unerwartet aendern. */ const openFinding = (item: FindingPathItem) => { setSelectedRecord(item.id); setView('records'); onOpenFinding(); }; return (
{/* Kopfzeile und Filter stehen im Kopfcontainer der Shell, nicht im Panel. Das Panel traegt ausschliesslich den Inhalt - dieselbe Aufteilung wie in Work, Datahub, Result DB und DB-Struktur. */} {bandNode && createPortal( <>
ATT&CK tactic
Path Risk Index: {pathRiskScore.toLocaleString('en-US')}
{showFilters && (
)} , bandNode )} {!mappedTactics.length ? (
No findings with a MITRE ATT&CK mapping found. Attack Paths require tactic names or technique IDs in the findings.
) : (
{svgLines.map((line, idx) => { const cx1 = line.x1 + (line.x2 - line.x1) * 0.5; const cx2 = line.x1 + (line.x2 - line.x1) * 0.5; return ( ); })} {columns.map(column => (
{column.tactic}
{column.items.length} reviews[item.id]?.include).length} onChange={checked => column.items.forEach(item => updateReview(item.id, { include: checked }))} label={`${category === 'all' ? 'All path categories' : category} with ${column.tactic}: select all findings in Workspace`} />
{column.items.map(item => { const linkedSheets = linksFor(item); const resultWindow = resultWindowFor(item.id); const isPathStep = pathSelection[column.tactic] === item.id; const isSelected = selected?.id === item.id; return (
selectPathItem(item)} onBookmark={() => updateReview(item.id, { include: !reviews[item.id]?.include })} onResults={linkedSheets.length ? () => openResults(item) : undefined} />
🔵 Native 🟡 Derived
); })}
))} {unmapped.length > 0 && (
Unmapped
{unmapped.length}
{unmapped.map(item => (
setSelectedId(item.id)} onBookmark={() => updateReview(item.id, { include: !reviews[item.id]?.include })} onResults={linksFor(item).length ? () => openResults(item) : undefined} />
⚪ Unmapped
))}
)}
)} {resultWindows.map((item, index) => { if (item.minimized && resultWindows.some(window => !window.minimized)) return null; const finding = items.find(candidate => candidate.id === item.recordId); const sheets = finding ? linksFor(finding) : []; const sections = finding ? sectionsFor(finding) : []; return activateWindow(item.recordId)} onClose={() => setResultWindows(current => current.filter(candidate => candidate.recordId !== item.recordId))} onOpenRecord={finding ? () => openFinding(finding) : undefined} actionLabel="To Report" actionTitle="Jump to this finding’s record in the Report" minimized={item.minimized} onToggleMinimize={() => toggleMinimize(item.recordId)} />; })}
); }