import { createHash, randomUUID } from 'node:crypto'; import { attackReferenceCatalog } from '../../src/references/catalog-converters'; import type { ReferenceCatalogSnapshot } from '../../src/domain/reference-recognition'; import { bundledReferenceCatalogs, referenceHash, referenceSnapshot } from './reference-catalogs'; import { lstat, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'; import { dirname, isAbsolute } from 'node:path'; import { defaultMitreGraphData, validateMitreMatrixData, type MitreGraphData } from '../../src/features/mitre-graph/mitre-graph'; import { convertEnterpriseStix } from '../../src/features/mitre-graph/mitre-stix'; const OFFICIAL_ENTERPRISE_STIX_URL = 'https://raw.githubusercontent.com/mitre-attack/attack-stix-data/master/enterprise-attack/enterprise-attack.json'; const MAXIMUM_SOURCE_BYTES = 80 * 1024 * 1024; const MAXIMUM_CATALOG_BYTES = 5 * 1024 * 1024; export class MitreCatalogError extends Error { constructor(readonly code: string, message: string) { super(message); this.name = 'MitreCatalogError'; } } export interface MitreCatalogState { referenceCatalog?: ReferenceCatalogSnapshot; version: 1; catalog: MitreGraphData; origin: 'bundled' | 'mitre'; } function compareVersions(left: string, right: string): number { const a = left.split('.').map(Number); const b = right.split('.').map(Number); return (a[0] - b[0]) || (a[1] - b[1]); } export class MitreCatalogService { private refreshInProgress?: Promise; constructor( private readonly catalogPath: string, private readonly fetcher: typeof fetch = globalThis.fetch.bind(globalThis) ) { if (!catalogPath || !isAbsolute(catalogPath)) throw new Error('An absolute MITRE catalog storage path is required.'); } async current(): Promise { try { const information = await stat(this.catalogPath); if (!information.isFile() || information.size > MAXIMUM_CATALOG_BYTES) throw new Error('Stored MITRE catalog is not a regular bounded file.'); const stored = JSON.parse(await readFile(this.catalogPath, 'utf8')); const catalog = validateMitreMatrixData(stored); const referenceCatalog = stored.referenceCatalog as ReferenceCatalogSnapshot | undefined; if (referenceCatalog) { const { sha256, ...content } = referenceCatalog; if (content.framework !== 'mitre-attack' || content.version !== catalog.version || referenceHash(content) !== sha256) throw new Error('Stored reference catalog is invalid.'); } return compareVersions(catalog.version, defaultMitreGraphData.version) >= 0 ? { version: 1, catalog, origin: 'mitre', ...(referenceCatalog ? { referenceCatalog } : {}) } : { version: 1, catalog: defaultMitreGraphData, origin: 'bundled' }; } catch (error) { if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') { return { version: 1, catalog: defaultMitreGraphData, origin: 'bundled' }; } throw error; } } async recognitionCatalogs(): Promise { const state = await this.current(); const attack = state.referenceCatalog ?? bundledReferenceCatalogs[0]; if (attack.version !== state.catalog.version) throw new MitreCatalogError('mitre_reference_update_required', 'Bitte zuerst Update from MITRE ausführen, um den vollständigen Referenzkatalog zu laden.'); return [attack, bundledReferenceCatalogs[1]]; } async officialSource(): Promise<{ bytes: Buffer; catalog: MitreGraphData; referenceCatalog: ReferenceCatalogSnapshot }> { let response: Response; try { response = await this.fetcher(OFFICIAL_ENTERPRISE_STIX_URL, { headers: { Accept: 'application/json' }, cache: 'no-store', redirect: 'error', signal: AbortSignal.timeout(120_000) }); } catch { throw new MitreCatalogError('mitre_unreachable', 'MITRE konnte nicht erreicht werden. Bitte später erneut versuchen oder die Datei manuell importieren.'); } if (!response.ok || !response.body) throw new MitreCatalogError('mitre_unavailable', `MITRE lieferte keine nutzbare Datei (HTTP ${response.status}).`); const declaredSize = Number(response.headers.get('content-length')); if (Number.isFinite(declaredSize) && declaredSize > MAXIMUM_SOURCE_BYTES) { await response.body.cancel(); throw new MitreCatalogError('mitre_source_too_large', 'Die MITRE-Datei überschreitet das Größenlimit.'); } const chunks: Buffer[] = []; const reader = response.body.getReader(); let size = 0; try { for (;;) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > MAXIMUM_SOURCE_BYTES) throw new MitreCatalogError('mitre_source_too_large', 'Die MITRE-Datei überschreitet das Größenlimit.'); chunks.push(Buffer.from(value)); } } catch (error) { await reader.cancel().catch(() => undefined); if (error instanceof MitreCatalogError) throw error; throw new MitreCatalogError('mitre_download_failed', 'Der MITRE-Download wurde unterbrochen.'); } const bytes = Buffer.concat(chunks, size); try { const raw = JSON.parse(bytes.toString('utf8')); return { bytes, catalog: validateMitreMatrixData(convertEnterpriseStix(raw)), referenceCatalog: referenceSnapshot(attackReferenceCatalog(raw, OFFICIAL_ENTERPRISE_STIX_URL, createHash('sha256').update(bytes).digest('hex'))) }; } catch { throw new MitreCatalogError('mitre_source_invalid', 'Die MITRE-Datei enthält keinen gültigen Enterprise-ATT&CK-STIX-Katalog.'); } } refresh(): Promise { if (this.refreshInProgress) return this.refreshInProgress; this.refreshInProgress = this.performRefresh().finally(() => { this.refreshInProgress = undefined; }); return this.refreshInProgress; } private async performRefresh(): Promise { const latest = await this.officialSource(); const current = await this.current(); const comparison = compareVersions(latest.catalog.version, current.catalog.version); if (comparison < 0 || (comparison === 0 && current.referenceCatalog)) { return { ...current, updated: false }; } const directory = dirname(this.catalogPath); await mkdir(directory, { recursive: true, mode: 0o700 }); const directoryInfo = await lstat(directory); if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()) { throw new MitreCatalogError('mitre_storage_unavailable', 'Der private MITRE-Speicher ist nicht verfügbar.'); } const temporaryPath = `${this.catalogPath}.${randomUUID()}.tmp`; try { await writeFile(temporaryPath, JSON.stringify({ ...latest.catalog, referenceCatalog: latest.referenceCatalog }), { mode: 0o600 }); await rename(temporaryPath, this.catalogPath); } finally { await unlink(temporaryPath).catch(error => { if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error; }); } return { version: 1, catalog: latest.catalog, referenceCatalog: latest.referenceCatalog, origin: 'mitre', updated: comparison > 0 }; } }