191 lines
9.5 KiB
TypeScript
191 lines
9.5 KiB
TypeScript
import { createHash, randomUUID } from 'node:crypto';
|
|
import { lstat, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
import { dirname, isAbsolute } from 'node:path';
|
|
import { BSI_KERNEL_CLASS, bsiIsoCrosswalk, bsiKernelCatalog, type BsiIsoCrosswalk, type BsiKernelCatalog } from '../../src/compliance/bsi-grundschutz';
|
|
import type { BsiCatalogState } from '../../src/compliance/bsi-catalog-management';
|
|
import { bundledBsiCatalogs, type BsiCatalogs } from './bsi-snapshots';
|
|
|
|
// Fixed official sources (security model: outbound HTTPS to raw.githubusercontent.com only, user decision 2026-10-08).
|
|
// BSI publishes continuously without releases, so the update reads `main` and records the SHA-256 of what it read.
|
|
const BSI_BASE = 'https://raw.githubusercontent.com/BSI-Bund/Stand-der-Technik-Bibliothek/main/control_layer';
|
|
export const BSI_CATALOG_SOURCE_URL = `${BSI_BASE}/Grundschutz++/Grundschutz++-resolved_catalog.json`;
|
|
export const BSI_CROSSWALK_SOURCE_URL = `${BSI_BASE}/Mappings/ISO-27001-zu-GSpp/ISO27001-AnnexA-to-GS++-mapping_collection.json`;
|
|
const MAXIMUM_SOURCE_BYTES = 32 * 1024 * 1024;
|
|
const MAXIMUM_STORED_BYTES = 8 * 1024 * 1024;
|
|
|
|
export class BsiCatalogError extends Error {
|
|
constructor(readonly code: string, message: string) {
|
|
super(message);
|
|
this.name = 'BsiCatalogError';
|
|
}
|
|
}
|
|
|
|
interface StoredBsiCatalogs extends BsiCatalogs {
|
|
version: 1;
|
|
updatedAt: string;
|
|
}
|
|
|
|
/**
|
|
* The active BSI Kernel catalog and ISO 27001 mapping of the Compliance Engine. The pinned snapshots stay active
|
|
* until `Update from BSI` stores a newer official state in the engine's private folder; derived requirements
|
|
* follow the active state at once, because they are never stored.
|
|
*/
|
|
export class BsiCatalogService {
|
|
private refreshInProgress?: Promise<BsiCatalogState & { updated: boolean }>;
|
|
private cached?: Promise<{ catalogs: BsiCatalogs; updatedAt?: string }>;
|
|
|
|
constructor(
|
|
private readonly storagePath: string,
|
|
private readonly fetcher: typeof fetch = globalThis.fetch.bind(globalThis),
|
|
private readonly now: () => Date = () => new Date()
|
|
) {
|
|
if (!storagePath || !isAbsolute(storagePath)) throw new Error('An absolute BSI catalog storage path is required.');
|
|
}
|
|
|
|
/** The active catalogs for the derivation. */
|
|
async catalogs(): Promise<BsiCatalogs> {
|
|
return (await this.active()).catalogs;
|
|
}
|
|
|
|
async current(): Promise<BsiCatalogState> {
|
|
const { catalogs, updatedAt } = await this.active();
|
|
return state(catalogs, updatedAt);
|
|
}
|
|
|
|
refresh(): Promise<BsiCatalogState & { updated: boolean }> {
|
|
if (this.refreshInProgress) return this.refreshInProgress;
|
|
this.refreshInProgress = this.performRefresh().finally(() => { this.refreshInProgress = undefined; });
|
|
return this.refreshInProgress;
|
|
}
|
|
|
|
/** Returns to the pinned snapshots. */
|
|
async reset(): Promise<BsiCatalogState> {
|
|
await unlink(this.storagePath).catch(error => {
|
|
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error;
|
|
});
|
|
this.cached = undefined;
|
|
return this.current();
|
|
}
|
|
|
|
private active(): Promise<{ catalogs: BsiCatalogs; updatedAt?: string }> {
|
|
this.cached ??= this.read().catch(error => { this.cached = undefined; throw error; });
|
|
return this.cached;
|
|
}
|
|
|
|
private async read(): Promise<{ catalogs: BsiCatalogs; updatedAt?: string }> {
|
|
let text: string;
|
|
try {
|
|
const information = await stat(this.storagePath);
|
|
if (!information.isFile() || information.size > MAXIMUM_STORED_BYTES) throw new BsiCatalogError('bsi_storage_invalid', 'The stored BSI catalog is not a regular bounded file.');
|
|
text = await readFile(this.storagePath, 'utf8');
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return { catalogs: bundledBsiCatalogs };
|
|
throw error;
|
|
}
|
|
const stored = JSON.parse(text) as Partial<StoredBsiCatalogs>;
|
|
if (stored.version !== 1 || typeof stored.updatedAt !== 'string' || !validCatalog(stored.catalog) || !validCrosswalk(stored.crosswalk)) {
|
|
throw new BsiCatalogError('bsi_storage_invalid', 'The stored BSI catalog is invalid. Use the bundled catalog or update from BSI again.');
|
|
}
|
|
// A stored state older than the bundled snapshot is superseded by it.
|
|
if (stored.catalog.version < bundledBsiCatalogs.catalog.version) return { catalogs: bundledBsiCatalogs };
|
|
return { catalogs: { catalog: stored.catalog, crosswalk: stored.crosswalk }, updatedAt: stored.updatedAt };
|
|
}
|
|
|
|
private async performRefresh(): Promise<BsiCatalogState & { updated: boolean }> {
|
|
const [catalogSource, crosswalkSource] = await Promise.all([download(this.fetcher, BSI_CATALOG_SOURCE_URL), download(this.fetcher, BSI_CROSSWALK_SOURCE_URL)]);
|
|
let latest: BsiCatalogs;
|
|
try {
|
|
latest = {
|
|
catalog: bsiKernelCatalog(JSON.parse(catalogSource.toString('utf8')), BSI_CATALOG_SOURCE_URL, sha256(catalogSource)),
|
|
crosswalk: bsiIsoCrosswalk(JSON.parse(crosswalkSource.toString('utf8')), BSI_CROSSWALK_SOURCE_URL, sha256(crosswalkSource))
|
|
};
|
|
} catch (error) {
|
|
throw new BsiCatalogError('bsi_source_invalid', `The BSI files are not a usable Grundschutz++ catalog and ISO mapping: ${error instanceof Error ? error.message : 'invalid content'}`);
|
|
}
|
|
const current = await this.active();
|
|
const unchanged = latest.catalog.sourceSha256 === current.catalogs.catalog.sourceSha256
|
|
&& latest.crosswalk.sourceSha256 === current.catalogs.crosswalk.sourceSha256;
|
|
// Never step back behind the active catalog.
|
|
if (unchanged || latest.catalog.version < current.catalogs.catalog.version) return { ...state(current.catalogs, current.updatedAt), updated: false };
|
|
const directory = dirname(this.storagePath);
|
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
const directoryInfo = await lstat(directory);
|
|
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()) throw new BsiCatalogError('bsi_storage_unavailable', 'The private BSI catalog storage is not available.');
|
|
const stored: StoredBsiCatalogs = { version: 1, updatedAt: this.now().toISOString(), ...latest };
|
|
const temporaryPath = `${this.storagePath}.${randomUUID()}.tmp`;
|
|
try {
|
|
await writeFile(temporaryPath, JSON.stringify(stored), { mode: 0o600 });
|
|
await rename(temporaryPath, this.storagePath);
|
|
} finally {
|
|
await unlink(temporaryPath).catch(error => {
|
|
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error;
|
|
});
|
|
}
|
|
this.cached = Promise.resolve({ catalogs: latest, updatedAt: stored.updatedAt });
|
|
return { ...state(latest, stored.updatedAt), updated: true };
|
|
}
|
|
}
|
|
|
|
const sha256 = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex');
|
|
|
|
async function download(fetcher: typeof fetch, url: string): Promise<Buffer> {
|
|
let response: Response;
|
|
try {
|
|
response = await fetcher(url, { headers: { Accept: 'application/json' }, cache: 'no-store', redirect: 'error', signal: AbortSignal.timeout(120_000) });
|
|
} catch {
|
|
throw new BsiCatalogError('bsi_unreachable', 'BSI could not be reached. Please try again later.');
|
|
}
|
|
if (!response.ok || !response.body) throw new BsiCatalogError('bsi_unavailable', `BSI returned no usable file (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 BsiCatalogError('bsi_source_too_large', 'A BSI file exceeds the size limit.');
|
|
}
|
|
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 BsiCatalogError('bsi_source_too_large', 'A BSI file exceeds the size limit.');
|
|
chunks.push(Buffer.from(value));
|
|
}
|
|
} catch (error) {
|
|
await reader.cancel().catch(() => undefined);
|
|
if (error instanceof BsiCatalogError) throw error;
|
|
throw new BsiCatalogError('bsi_download_failed', 'The BSI download was interrupted.');
|
|
}
|
|
return Buffer.concat(chunks, size);
|
|
}
|
|
|
|
function validCatalog(value: unknown): value is BsiKernelCatalog {
|
|
const catalog = value as BsiKernelCatalog | undefined;
|
|
return catalog?.schemaVersion === 1 && catalog.framework === 'bsi-grundschutz-plus-plus' && catalog.catalogClass === BSI_KERNEL_CLASS
|
|
&& typeof catalog.version === 'string' && /^[0-9a-f]{64}$/.test(catalog.sourceSha256 ?? '')
|
|
&& Array.isArray(catalog.practices) && Array.isArray(catalog.requirements) && catalog.requirements.length > 0;
|
|
}
|
|
|
|
function validCrosswalk(value: unknown): value is BsiIsoCrosswalk {
|
|
const crosswalk = value as BsiIsoCrosswalk | undefined;
|
|
return crosswalk?.schemaVersion === 1 && crosswalk.framework === 'bsi-iso27001-annex-a-to-grundschutz-plus-plus'
|
|
&& typeof crosswalk.version === 'string' && /^[0-9a-f]{64}$/.test(crosswalk.sourceSha256 ?? '') && Array.isArray(crosswalk.entries);
|
|
}
|
|
|
|
function state({ catalog, crosswalk }: BsiCatalogs, updatedAt: string | undefined): BsiCatalogState {
|
|
const ids = new Set(catalog.requirements.map(item => item.id));
|
|
return {
|
|
version: 1,
|
|
origin: updatedAt ? 'bsi' : 'bundled',
|
|
...(updatedAt ? { updatedAt } : {}),
|
|
catalogVersion: catalog.version,
|
|
crosswalkVersion: crosswalk.version,
|
|
requirementCount: catalog.requirements.length,
|
|
crosswalkEntryCount: crosswalk.entries.length,
|
|
catalogSha256: catalog.sourceSha256,
|
|
crosswalkSha256: crosswalk.sourceSha256,
|
|
missingTargets: [...new Set(crosswalk.entries.flatMap(entry => entry.targets.filter(target => !ids.has(target))))].sort()
|
|
};
|
|
}
|