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; tactics: Set; 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; 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 = { critical: 500, high: 400, medium: 300, low: 200, info: 100, unknown: 0 }; export function extractFindingMitreFocus(records: FindingRecord[], tacticOrder: string[]): FindingMitreFocus { const techniqueIds = new Set(); const tactics = new Set(); 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); }