146 lines
7.1 KiB
TypeScript
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 };
|
|
}
|
|
}
|