Assessor_2.0/server/reference-data/mitre-catalog-service.ts

146 lines
7.1 KiB
TypeScript

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<MitreCatalogState & { updated: boolean }>;
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<MitreCatalogState> {
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<ReferenceCatalogSnapshot[]> {
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<MitreCatalogState & { updated: boolean }> {
if (this.refreshInProgress) return this.refreshInProgress;
this.refreshInProgress = this.performRefresh().finally(() => { this.refreshInProgress = undefined; });
return this.refreshInProgress;
}
private async performRefresh(): Promise<MitreCatalogState & { updated: boolean }> {
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 };
}
}