Assessor_2.0/src/features/mitre-graph/MitreGraphView.tsx

464 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<HTMLInputElement>(null);
useEffect(() => {
if (inputRef.current) inputRef.current.indeterminate = checkedCount > 0 && checkedCount < total;
}, [checkedCount, total]);
return (
<label className="finding-tactic-workspace" title="Add all findings in this phase to Workspace">
<input ref={inputRef} type="checkbox" checked={total > 0 && checkedCount === total} onChange={event => onChange(event.target.checked)} aria-label={label} />
</label>
);
}
export function MitreGraphView({ onOpenFinding }: MitreGraphViewProps) {
const app = useApp();
const {
dataset,
activeSheet,
activeSheetId,
titleColumn,
reviews,
search: workSearch,
setSelectedRecord,
setView,
updateReview
} = app;
const [graphData, setGraphData] = useState<MitreGraphData>(() => loadStoredMitreMatrix() ?? defaultGraphData);
const [category, setCategory] = useState('all');
const [severity, setSeverity] = useState<SeverityFilter>('all');
const [originFilter, setOriginFilter] = useState<OriginFilter>('all');
const [search, setSearchValue] = useState('');
const [selectedId, setSelectedId] = useState('');
const [pathSelection, setPathSelection] = useState<Record<string, string>>({});
const [resultWindows, setResultWindows] = useState<ResultWindowState[]>([]);
const [svgLines, setSvgLines] = useState<ConnectionCurve[]>([]);
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<HTMLElement | null>(null);
useEffect(() => { setBandNode(document.getElementById(WORKSPACE_BAND_ID)); }, []);
const boardRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const meterRef = useRef<HTMLDivElement>(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<string, string> = {};
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<string, string> = {};
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 (
<div className="finding-paths">
{/* 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(
<>
<div className="finding-path-header-top">
<div className="finding-path-title">
<span>ATT&amp;CK tactic</span>
</div>
<div className="finding-path-center-meter">
<div className="meter-badge">
<span className="meter-label">Path Risk Index:</span>
<strong className="meter-val">{pathRiskScore.toLocaleString('en-US')}</strong>
</div>
<div className="meter-gauge" title="Risk level indicator for the selected path">
<div className="meter-bar" ref={meterRef} />
</div>
</div>
<div className="finding-path-header-actions">
<button className="btn primary finding-btn-critical" type="button" onClick={selectCriticalChain} title="Automatically selects the most severe finding in each tactic phase">
⚡ Most Critical Chain
</button>
<button className={`btn ${showFilters ? 'active' : ''}`} type="button" onClick={() => setShowFilters(s => !s)}>
≡ Filter
</button>
</div>
</div>
{showFilters && (
<div className="finding-path-controls">
<label>Path / Category
<select value={category} onChange={event => setCategory(event.target.value)}>
<option value="all">All Categories</option>
{categories.map(value => <option key={value}>{value}</option>)}
</select>
</label>
<label>Severity
<select value={severity} onChange={event => setSeverity(event.target.value as SeverityFilter)}>
<option value="all">All Severities</option>
{Object.entries(severityLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
</label>
<label>Mapping Origin
<select value={originFilter} onChange={event => setOriginFilter(event.target.value as OriginFilter)}>
<option value="all">All Mappings</option>
<option value="native">Native Only (Tool)</option>
<option value="derived">Derived Only (Profile)</option>
</select>
</label>
<label className="finding-path-search">Search
<input value={search} onChange={event => setSearchValue(event.target.value)} placeholder="Finding or T1059" />
</label>
</div>
)}
</>,
bandNode
)}
{!mappedTactics.length ? (
<div className="finding-path-empty">
<strong>No findings with a MITRE ATT&amp;CK mapping found.</strong>
<span>Attack Paths require tactic names or technique IDs in the findings.</span>
</div>
) : (
<div className="finding-path-layout">
<main className="finding-path-main">
<div className="finding-path-scroll" ref={scrollRef}>
<section className="finding-tactic-board" ref={boardRef} aria-label="Findings by ATT&CK tactic">
<svg className="finding-path-svg-canvas">
{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 (
<path
key={idx}
d={`M ${line.x1} ${line.y1} C ${cx1} ${line.y1}, ${cx2} ${line.y2}, ${line.x2} ${line.y2}`}
stroke="var(--graph-edge)"
strokeWidth="3"
fill="none"
/>
);
})}
</svg>
{columns.map(column => (
<article className="finding-tactic-column" key={column.tactic}>
<header>
<div className="finding-tactic-heading">
<strong>{column.tactic}</strong>
</div>
<div className="finding-tactic-tools">
<span>{column.items.length}</span>
<ColumnWorkspaceCheckbox
total={column.items.length}
checkedCount={column.items.filter(item => 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`}
/>
</div>
</header>
<div className="finding-tactic-tiles">
{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 (
<div
className={`severity-${item.severity} finding-path-card-wrapper origin-${item.mappingOrigin} ${isPathStep ? 'is-path-step ' : ''}${isSelected ? 'is-selected' : ''}`}
data-tactic-name={column.tactic}
key={item.id}
>
<TileCard
record={item.record}
title={item.title}
bookmarked={Boolean(reviews[item.id]?.include)}
resultColor={resultWindow?.color}
toolId={activeSheet?.toolId ?? dataset?.profileId}
onOpen={() => selectPathItem(item)}
onBookmark={() => updateReview(item.id, { include: !reviews[item.id]?.include })}
onResults={linkedSheets.length ? () => openResults(item) : undefined}
/>
<div className="card-bottom-origins">
<span className="origin-pill native">🔵 Native</span>
<span className="origin-pill derived">🟡 Derived</span>
</div>
</div>
);
})}
</div>
</article>
))}
{unmapped.length > 0 && (
<article className="finding-tactic-column is-unmapped">
<header>
<div className="finding-tactic-heading">
<strong>Unmapped</strong>
</div>
<div className="finding-tactic-tools">
<span>{unmapped.length}</span>
</div>
</header>
<div className="finding-tactic-tiles">
{unmapped.map(item => (
<div className={`severity-${item.severity} finding-path-card-wrapper origin-unmapped ${selected?.id === item.id ? 'is-selected' : ''}`} key={item.id}>
<TileCard
record={item.record}
title={item.title}
bookmarked={Boolean(reviews[item.id]?.include)}
toolId={activeSheet?.toolId ?? dataset?.profileId}
onOpen={() => setSelectedId(item.id)}
onBookmark={() => updateReview(item.id, { include: !reviews[item.id]?.include })}
onResults={linksFor(item).length ? () => openResults(item) : undefined}
/>
<div className="card-bottom-origins">
<span className="origin-pill unmapped">⚪ Unmapped</span>
</div>
</div>
))}
</div>
</article>
)}
</section>
</div>
</main>
<aside className="finding-path-detail" aria-label="Finding details">
{selected && (
<div className="finding-detail-boxes">
<div className="inspector-box box-details">
<header className="box-header"><span>3 column inspector</span><h4>Finding details</h4></header>
<div className="box-body">
<div className="box-row"><label>Severity:</label><span className={`finding-detail-severity severity-${selected.severity}`}>{severityLabels[selected.severity]}</span></div>
<div className="box-row"><label>Lastdate:</label><span>{new Date().toLocaleDateString('en-US')}</span></div>
<div className="box-row"><label>Risk score:</label><strong>{Math.round(selected.risk * 100)}</strong></div>
</div>
</div>
<div className="inspector-box box-mitre">
<header className="box-header"><span>2 column inspector</span><h4>MITRE Technique references</h4></header>
<div className="box-body">
{selected.techniqueIds.length ? selected.techniqueIds.map(techId => (
<a key={techId} className="mitre-ref-link" href={`https://attack.mitre.org/techniques/${techId.replace('.', '/')}`} target="_blank" rel="noopener noreferrer">
MITRE {selected.category || 'Technique'} ({techId})
</a>
)) : (
<span className="mitre-ref-none">No specific MITRE technique ID linked</span>
)}
</div>
</div>
<div className="inspector-box box-actions">
<header className="box-header"><span>3 column inspector</span><h4>Actions</h4></header>
<div className="box-body">
<button className="btn primary finding-open-detail" type="button" onClick={() => openFinding(selected)}>
Open in Work
</button>
<button
className={`btn ${reviews[selected.id]?.include ? 'active' : ''}`}
type="button"
onClick={() => updateReview(selected.id, { include: !reviews[selected.id]?.include })}
>
{reviews[selected.id]?.include ? '✓ In Workspace' : 'Add to Workspace'}
</button>
</div>
</div>
</div>
)}
</aside>
</div>
)}
{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 <FloatingResultsWindow key={item.recordId} id={`graph-results-${item.recordId}`} title={item.title} sheets={sheets} toolId={item.toolId} outcome={finding ? resultOutcomeFor(finding.record, activeSheet?.toolId ?? dataset?.profileId) : undefined} sections={sections} color={item.color} zIndex={item.zIndex} offset={index * 24} search={workSearch} onActivate={() => 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)} />;
})}
</div>
);
}