464 lines
23 KiB
TypeScript
464 lines
23 KiB
TypeScript
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&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&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>
|
||
);
|
||
}
|