Assessor_2.0/src/features/mitre-graph/mitre-graph.ts

214 lines
8.3 KiB
TypeScript

import { findingReferences } from '../../references/finding-reference-projection';
import type { FindingRecord, Severity } from '../../domain/finding';
import { normalizeSeverity } from '../../profiles/purple-knight/presentation';
import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
import mitreData from '../../assets/data/mitre-enterprise-v19.2.json';
import { convertEnterpriseStix } from './mitre-stix';
import { recordTitleFor } from '../dataset-viewer/record-title';
export interface MitreTechnique {
id: string;
name: string;
tactics: string[];
platforms: string[];
parent: string | null;
url: string;
sourceCount: number;
}
export interface MitreEntity {
id: string;
name: string;
type: 'group' | 'campaign' | 'software';
techniques: string[];
}
export interface MitreGraphData {
version: string;
tacticOrder: string[];
techniques: MitreTechnique[];
entities: MitreEntity[];
}
export interface FindingMitreFocus {
techniqueIds: Set<string>;
tactics: Set<string>;
findingCount: number;
}
export interface FindingPathItem {
id: string;
record: FindingRecord;
title: string;
category: string;
categories: string[];
defenses: string[];
targets: string[];
tactics: string[];
primaryTactic: string;
techniqueIds: string[];
severity: Severity;
weight: number;
score: number;
risk: number;
mappingOrigin: 'native' | 'derived' | 'unmapped';
/** Context clusters the finding provides evidence for; set only while the context layer is on (Phase 13E). */
contextClusterIds?: string[];
}
const STORAGE_KEY = 'assessor_custom_mitre_matrix';
export const defaultMitreGraphData = mitreData as MitreGraphData;
export const MITRE_MATRIX_CHANGED_EVENT = 'assessor:mitre-matrix-changed';
let serverMitreGraphData: MitreGraphData | null = null;
export function setServerMitreMatrix(data: MitreGraphData): void {
const validated = validateMitreMatrixData(data);
const [major, minor] = validated.version.split('.').map(Number);
const [currentMajor, currentMinor] = (serverMitreGraphData?.version ?? defaultMitreGraphData.version).split('.').map(Number);
if (major < currentMajor || (major === currentMajor && minor < currentMinor)) return;
serverMitreGraphData = validated;
globalThis.dispatchEvent?.(new Event(MITRE_MATRIX_CHANGED_EVENT));
}
export function validateMitreMatrixData(raw: unknown): MitreGraphData {
if (!raw || typeof raw !== 'object') throw new Error('Invalid MITRE matrix format: expected a JSON object.');
if ('type' in raw && raw.type === 'bundle') return convertEnterpriseStix(raw);
const data = raw as Partial<MitreGraphData>;
if (typeof data.version !== 'string' || !/^\d+\.\d+$/.test(data.version)) throw new Error('Invalid MITRE matrix: a version is required.');
if (!Array.isArray(data.tacticOrder) || !data.tacticOrder.length) throw new Error('Invalid MITRE matrix: "tacticOrder" array is missing or empty.');
if (!Array.isArray(data.techniques) || !data.techniques.length) throw new Error('Invalid MITRE matrix: "techniques" array is missing or empty.');
if (data.techniques.some(t => !t || typeof t.id !== 'string' || !/^T\d{4}(?:\.\d{3})?$/.test(t.id) || typeof t.name !== 'string' || !t.name.trim())) {
throw new Error('Invalid MITRE matrix: a technique ID or name is invalid.');
}
return {
version: data.version,
tacticOrder: data.tacticOrder.map(String),
techniques: data.techniques.map(t => ({
id: String(t.id),
name: String(t.name),
tactics: Array.isArray(t.tactics) ? t.tactics.map(String) : [],
platforms: Array.isArray(t.platforms) ? t.platforms.map(String) : [],
parent: t.parent ? String(t.parent) : null,
url: String(t.url ?? ''),
sourceCount: Number(t.sourceCount ?? 0)
})),
entities: Array.isArray(data.entities) ? data.entities : []
};
}
export function loadStoredMitreMatrix(): MitreGraphData | null {
try {
const raw = globalThis.localStorage?.getItem(STORAGE_KEY);
if (!raw) return null;
return validateMitreMatrixData(JSON.parse(raw));
} catch {
return null;
}
}
export function saveStoredMitreMatrix(data: MitreGraphData): void {
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(data));
}
export function clearStoredMitreMatrix(): void {
globalThis.localStorage?.removeItem(STORAGE_KEY);
}
export function activeMitreMatrix(): MitreGraphData {
return loadStoredMitreMatrix() ?? serverMitreGraphData ?? defaultMitreGraphData;
}
const numberField = (record: FindingRecord, pattern: RegExp) => {
const value = record.fields.find(field => pattern.test(field.label.trim()))?.value ?? '';
const parsed = Number(value.replace(',', '.').match(/-?\d+(?:\.\d+)?/)?.[0] ?? 0);
return Number.isFinite(parsed) ? parsed : 0;
};
const severityRisk: Record<Severity, number> = {
critical: 500,
high: 400,
medium: 300,
low: 200,
info: 100,
unknown: 0
};
export function extractFindingMitreFocus(records: FindingRecord[], tacticOrder: string[]): FindingMitreFocus {
const techniqueIds = new Set<string>();
const tactics = new Set<string>();
const graph = { ...defaultMitreGraphData, tacticOrder };
let findingCount = 0;
for (const record of records) {
const references = findingReferences(record, graph).filter(ref => ref.framework === 'mitre-attack' && ref.status === 'active');
if (references.length) findingCount++;
for (const ref of references) {
if (ref.kind === 'technique') techniqueIds.add(ref.id);
if (ref.kind === 'tactic') tactics.add(ref.title!);
}
}
return { techniqueIds, tactics, findingCount };
}
export function buildFindingPathItems(records: FindingRecord[], data: MitreGraphData, titleColumn = '', fallbackToolId?: string): FindingPathItem[] {
return records.map(record => {
const references = findingReferences(record, data).filter(ref => ref.status === 'active');
const attackReferences = references.filter(ref => ref.framework === 'mitre-attack');
const hasNativeMitreField = attackReferences.some(ref => ref.evidence === 'declared');
const techniqueIds = [...new Set(attackReferences.filter(ref => ref.kind === 'technique').map(ref => ref.id))];
const tactics = new Set(attackReferences.flatMap(ref => ref.tactics));
const orderedTactics = [...data.tacticOrder.filter(tactic => tactics.has(tactic)),
...[...tactics].filter(tactic => !data.tacticOrder.includes(tactic))];
const severityValue = record.fields.find(field => /^(severity|criticality|priority)$/i.test(field.label.trim()))?.value;
const severity = normalizeSeverity(severityValue);
const weight = numberField(record, /^weight$/i);
const score = numberField(record, /^score$/i);
const title = recordTitleFor(record, titleColumn, fallbackToolId);
const categories = [...new Set([
...record.fields
.filter(field => /^category$/i.test(field.label.trim()) && field.value)
.flatMap(field => field.value.split(/[;|]+/))
.map(value => value.trim())
.filter(Boolean),
...cisBenchmarkCategories(record, fallbackToolId)
])];
const category = categories[0] ?? '';
const defenses = [...new Set(references.filter(ref => ref.framework === 'mitre-d3fend').map(ref => `${ref.id} · ${ref.title}`))];
const targets = [...new Set(record.fields
.filter(field => /^(?:target|ziel|betroffenes objekt|asset|host|hostname|domain|resource|system)$/i.test(field.label.trim()) && field.value)
.flatMap(field => field.value.split(/[;|]+/))
.map(value => value.trim())
.filter(Boolean))];
const mappingOrigin: 'native' | 'derived' | 'unmapped' = orderedTactics.length === 0
? 'unmapped'
: hasNativeMitreField
? 'native'
: 'derived';
return {
id: record.id,
record,
title,
category,
categories,
defenses,
targets,
tactics: orderedTactics,
primaryTactic: orderedTactics[0] ?? 'Unmapped',
techniqueIds,
severity,
weight,
score,
risk: severityRisk[severity] + weight * 10 + score / 10,
mappingOrigin
};
});
}
export function sharedEntityIds(left: string, right: string, entities: MitreEntity[], entityType: string): string[] {
return entities
.filter(entity => (entityType === 'all' || entity.type === entityType) && entity.techniques.includes(left) && entity.techniques.includes(right))
.map(entity => entity.id);
}