Compare commits
5 Commits
f35b1795a1
...
0fe5f1322e
| Author | SHA1 | Date |
|---|---|---|
|
|
0fe5f1322e | |
|
|
9480b039dd | |
|
|
5e7e787dbb | |
|
|
8ac7050e29 | |
|
|
e9ad4e9372 |
|
|
@ -15,6 +15,7 @@ Mock-Defender/
|
||||||
artifacts/
|
artifacts/
|
||||||
evaluation/
|
evaluation/
|
||||||
backups/
|
backups/
|
||||||
|
docs/mockups/
|
||||||
|
|
||||||
.env.postgres
|
.env.postgres
|
||||||
.assessor-data/
|
.assessor-data/
|
||||||
|
|
|
||||||
25
SECURITY.md
25
SECURITY.md
|
|
@ -28,6 +28,31 @@ bleibt ableitbar; PostgreSQL-Findings und Review-Entscheidungen werden durch
|
||||||
den Upload nicht veraendert. Phase-14-Authentifizierung und Rollen bleiben
|
den Upload nicht veraendert. Phase-14-Authentifizierung und Rollen bleiben
|
||||||
offen.
|
offen.
|
||||||
|
|
||||||
|
## BSI-Katalog: Update from BSI (Phase 13F)
|
||||||
|
|
||||||
|
Nutzerfreigabe 2026-10-08: Die Data-Hub-Aktion `Update from BSI` ist ein neues,
|
||||||
|
eng begrenztes ausgehendes Ziel. Nur das Backend ruft genau zwei feste
|
||||||
|
oeffentliche Dateien der BSI-Stand-der-Technik-Bibliothek ab
|
||||||
|
(`https://raw.githubusercontent.com/BSI-Bund/Stand-der-Technik-Bibliothek/main/...`:
|
||||||
|
aufgeloester Grundschutz++-Katalog und Zuordnung ISO 27001 Annex A ->
|
||||||
|
Grundschutz++), ausschliesslich ueber HTTPS, ohne Weiterleitung, mit 120 s
|
||||||
|
Zeitlimit und 32 MiB je Datei. Es werden keine Projekt-, Finding- oder
|
||||||
|
Kundendaten gesendet; die Anfrage enthaelt nur die feste URL. Der Browser
|
||||||
|
bleibt Same-Origin und erreicht GitHub nicht selbst.
|
||||||
|
|
||||||
|
Beide Dateien sind nicht vertrauenswuerdige Eingabe: Sie werden nur als JSON
|
||||||
|
gelesen und vom Konverter fail-closed geprueft (Version, IDs, Parameter,
|
||||||
|
Beziehungen); ein ungueltiger oder unvollstaendiger Stand wird abgelehnt, und
|
||||||
|
der aktive Katalog bleibt unveraendert. Gespeichert wird nur der kompakte
|
||||||
|
Kernel-Katalog samt Zuordnung mit Quell-URL und SHA-256 atomar in
|
||||||
|
`<artifact root>/regulatory-corpora/bsi/active-catalog.json` (Dateimodus 0600,
|
||||||
|
privates Verzeichnis, kein Symlink). Ein Stand, der aelter als der aktive oder
|
||||||
|
der mitgelieferte ist, wird nie aktiviert; `Use bundled catalog` loescht nur
|
||||||
|
diese Datei. Keine PostgreSQL-Tabelle, kein Finding und keine ISO-Entscheidung
|
||||||
|
wird veraendert; abgeleitete BSI-Anforderungen folgen dem aktiven Katalog. Wie
|
||||||
|
`Update from MITRE` ist die Aktion bis zur Phase-14-Nutzerverwaltung durch den
|
||||||
|
Same-Origin-Mutationsschutz, aber nicht durch einen eigenen Token geschuetzt.
|
||||||
|
|
||||||
## Accepted Risk: dokumentierte externe Entscheidung
|
## Accepted Risk: dokumentierte externe Entscheidung
|
||||||
|
|
||||||
Ergaenzung 2026-09-10: Der lokale Accepted-Risk-Dialog speichert begruendete,
|
Ergaenzung 2026-09-10: Der lokale Accepted-Risk-Dialog speichert begruendete,
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "assessor",
|
"name": "assessor",
|
||||||
"version": "2.19.1",
|
"version": "2.22.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "assessor",
|
"name": "assessor",
|
||||||
"version": "2.19.1",
|
"version": "2.22.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "assessor",
|
"name": "assessor",
|
||||||
"version": "2.19.1",
|
"version": "2.22.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import type { RegulatoryMappingService } from './compliance/regulatory-mapping-s
|
||||||
import type { ComplianceStore } from '../src/compliance/compliance-store';
|
import type { ComplianceStore } from '../src/compliance/compliance-store';
|
||||||
import { RegulatoryMappingServiceError } from './compliance/regulatory-mapping-service';
|
import { RegulatoryMappingServiceError } from './compliance/regulatory-mapping-service';
|
||||||
import { RegulatoryCorpusUploadError, type RegulatoryCorpusUploadService } from './compliance/regulatory-corpus-upload-service';
|
import { RegulatoryCorpusUploadError, type RegulatoryCorpusUploadService } from './compliance/regulatory-corpus-upload-service';
|
||||||
|
import { BsiCatalogError, type BsiCatalogService } from './compliance/bsi-catalog-service';
|
||||||
import type { GpoAutomaticAnalysisService } from './gpo/gpo-automatic-analysis-service';
|
import type { GpoAutomaticAnalysisService } from './gpo/gpo-automatic-analysis-service';
|
||||||
import type { MicrosoftSecurityBaselineSelectionContext } from '../src/domain/microsoft-security-baseline';
|
import type { MicrosoftSecurityBaselineSelectionContext } from '../src/domain/microsoft-security-baseline';
|
||||||
import { listProjectGpoBackupStatus } from './gpo/gpo-backup-status-service';
|
import { listProjectGpoBackupStatus } from './gpo/gpo-backup-status-service';
|
||||||
|
|
@ -179,6 +180,8 @@ export interface AssessorHttpServerOptions {
|
||||||
regulatoryMappingService?: RegulatoryMappingService;
|
regulatoryMappingService?: RegulatoryMappingService;
|
||||||
regulatoryCorpusUploadService?: RegulatoryCorpusUploadService;
|
regulatoryCorpusUploadService?: RegulatoryCorpusUploadService;
|
||||||
complianceExport?: Pick<ComplianceStore, 'exportProject'>;
|
complianceExport?: Pick<ComplianceStore, 'exportProject'>;
|
||||||
|
/** Compliance Engine: active BSI Kernel catalog and ISO mapping with Update from BSI. */
|
||||||
|
bsiCatalogService?: Pick<BsiCatalogService, 'current' | 'refresh' | 'reset'>;
|
||||||
isoUploadToken?: string;
|
isoUploadToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -411,6 +414,7 @@ function statusForError(error: unknown): { status: number; code: string; message
|
||||||
if (error instanceof AiOverviewServiceError) return { status: error.status, code: error.code, message: error.message };
|
if (error instanceof AiOverviewServiceError) return { status: error.status, code: error.code, message: error.message };
|
||||||
if (error instanceof RegulatoryMappingServiceError) return { status: error.status, code: error.code, message: error.message };
|
if (error instanceof RegulatoryMappingServiceError) return { status: error.status, code: error.code, message: error.message };
|
||||||
if (error instanceof RegulatoryCorpusUploadError) return { status: error.status, code: error.code, message: error.message };
|
if (error instanceof RegulatoryCorpusUploadError) return { status: error.status, code: error.code, message: error.message };
|
||||||
|
if (error instanceof BsiCatalogError) return { status: 502, code: error.code, message: error.message };
|
||||||
return { status: 500, code: 'internal_error', message: 'Die Anfrage konnte nicht verarbeitet werden.' };
|
return { status: 500, code: 'internal_error', message: 'Die Anfrage konnte nicht verarbeitet werden.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -775,6 +779,26 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
|
||||||
...corpusStatus, configured: false, disabledReason: 'An ISO upload token of at least 32 characters must be configured.'
|
...corpusStatus, configured: false, disabledReason: 'An ISO upload token of at least 32 characters must be configured.'
|
||||||
}, requestId); return;
|
}, requestId); return;
|
||||||
}
|
}
|
||||||
|
if (segments.join('/') === 'reference-data/bsi-grundschutz' && method === 'GET') {
|
||||||
|
if (!options.bsiCatalogService) throw new HttpError(503, 'bsi_catalog_unavailable', 'The BSI catalog is not configured.');
|
||||||
|
status = 200; sendJson(response, status, await options.bsiCatalogService.current(), requestId); return;
|
||||||
|
}
|
||||||
|
if (segments.join('/') === 'reference-data/bsi-grundschutz/refresh' && method === 'POST') {
|
||||||
|
if (!options.bsiCatalogService) throw new HttpError(503, 'bsi_catalog_unavailable', 'The BSI catalog is not configured.');
|
||||||
|
await readJson<Record<string, never>>(request, maximumBodyBytes);
|
||||||
|
status = 200; sendJson(response, status, await options.bsiCatalogService.refresh(), requestId); return;
|
||||||
|
}
|
||||||
|
if (segments.join('/') === 'reference-data/bsi-grundschutz/reset' && method === 'POST') {
|
||||||
|
if (!options.bsiCatalogService) throw new HttpError(503, 'bsi_catalog_unavailable', 'The BSI catalog is not configured.');
|
||||||
|
await readJson<Record<string, never>>(request, maximumBodyBytes);
|
||||||
|
status = 200; sendJson(response, status, await options.bsiCatalogService.reset(), requestId); return;
|
||||||
|
}
|
||||||
|
if (segments.join('/') === 'reference-data/iso-corpus/auto-mapping' && method === 'PUT') {
|
||||||
|
if (!options.regulatoryCorpusUploadService) throw new HttpError(503, 'iso_corpus_unavailable', 'ISO corpus management is not configured.');
|
||||||
|
const body = await readJson<{ enabled: boolean }>(request, maximumBodyBytes);
|
||||||
|
if (typeof body?.enabled !== 'boolean') throw new HttpError(400, 'invalid_auto_mapping', 'enabled must be true or false.');
|
||||||
|
status = 200; sendJson(response, status, await options.regulatoryCorpusUploadService.setAutoMapping(body.enabled), requestId); return;
|
||||||
|
}
|
||||||
if (segments.join('/') === 'reference-data/iso-corpus' && method === 'POST') {
|
if (segments.join('/') === 'reference-data/iso-corpus' && method === 'POST') {
|
||||||
if (!options.regulatoryCorpusUploadService) throw new HttpError(503, 'iso_corpus_unavailable', 'ISO corpus management is not configured.');
|
if (!options.regulatoryCorpusUploadService) throw new HttpError(503, 'iso_corpus_unavailable', 'ISO corpus management is not configured.');
|
||||||
verifyIsoUploadToken(request, options.isoUploadToken);
|
verifyIsoUploadToken(request, options.isoUploadToken);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import catalog from './data/bsi-grundschutz-kernel-2026-09-24.json';
|
||||||
|
import crosswalk from './data/bsi-iso27001-crosswalk-1.0.1-qa.json';
|
||||||
|
import type { BsiIsoCrosswalk, BsiKernelCatalog } from '../../src/compliance/bsi-grundschutz';
|
||||||
|
|
||||||
|
/** A Kernel catalog with the BSI ISO 27001 mapping that belongs to it. */
|
||||||
|
export interface BsiCatalogs {
|
||||||
|
catalog: BsiKernelCatalog;
|
||||||
|
crosswalk: BsiIsoCrosswalk;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinned BSI snapshots (Phase 13F, user decision 2026-10-08); regenerate with update-bsi-assets.mjs.
|
||||||
|
// They stay active until `Update from BSI` stores a newer official state.
|
||||||
|
export const bundledBsiCatalogs: BsiCatalogs = { catalog: catalog as BsiKernelCatalog, crosswalk: crosswalk as BsiIsoCrosswalk };
|
||||||
|
|
@ -9,12 +9,15 @@ import { PostgresComplianceStore } from './postgres-compliance-store';
|
||||||
import { createRegulatoryRagRuntime } from './regulatory-rag-runtime';
|
import { createRegulatoryRagRuntime } from './regulatory-rag-runtime';
|
||||||
import { RegulatoryMappingService } from './regulatory-mapping-service';
|
import { RegulatoryMappingService } from './regulatory-mapping-service';
|
||||||
import { RegulatoryCorpusUploadService } from './regulatory-corpus-upload-service';
|
import { RegulatoryCorpusUploadService } from './regulatory-corpus-upload-service';
|
||||||
|
import { RegulatoryAutoMapping } from './regulatory-auto-mapping';
|
||||||
import { AiOverviewService, type AiOverviewServiceOptions } from './ai-overview-service';
|
import { AiOverviewService, type AiOverviewServiceOptions } from './ai-overview-service';
|
||||||
|
import { BsiCatalogService } from './bsi-catalog-service';
|
||||||
|
|
||||||
export interface ComplianceEngineRuntime {
|
export interface ComplianceEngineRuntime {
|
||||||
mappingService: RegulatoryMappingService;
|
mappingService: RegulatoryMappingService;
|
||||||
corpusUploadService: RegulatoryCorpusUploadService;
|
corpusUploadService: RegulatoryCorpusUploadService;
|
||||||
aiOverviewService: AiOverviewService;
|
aiOverviewService: AiOverviewService;
|
||||||
|
bsiCatalogService: BsiCatalogService;
|
||||||
store: ComplianceStore;
|
store: ComplianceStore;
|
||||||
/** Stops running work; ASSESSOR keeps its own repository open. */
|
/** Stops running work; ASSESSOR keeps its own repository open. */
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
|
|
@ -39,6 +42,10 @@ export async function createComplianceEngineRuntime(options: ComplianceEngineRun
|
||||||
const pool = options.persistence === 'postgresql' ? compliancePool(options.environment ?? process.env as RepositoryEnvironment) : undefined;
|
const pool = options.persistence === 'postgresql' ? compliancePool(options.environment ?? process.env as RepositoryEnvironment) : undefined;
|
||||||
const store: ComplianceStore = pool ? new PostgresComplianceStore(pool) : new LocalComplianceStore();
|
const store: ComplianceStore = pool ? new PostgresComplianceStore(pool) : new LocalComplianceStore();
|
||||||
const read = complianceReadPortFrom(options.repository);
|
const read = complianceReadPortFrom(options.repository);
|
||||||
|
const corpusRoot = join(options.artifactRoot, 'regulatory-corpora');
|
||||||
|
// The active BSI catalog and ISO mapping; the pinned snapshots until Update from BSI stores a newer state.
|
||||||
|
const bsiCatalogService = new BsiCatalogService(join(corpusRoot, 'bsi', 'active-catalog.json'));
|
||||||
|
const bsiCatalogs = () => bsiCatalogService.catalogs();
|
||||||
let mappingService: RegulatoryMappingService;
|
let mappingService: RegulatoryMappingService;
|
||||||
let rag: ReturnType<typeof createRegulatoryRagRuntime> | undefined;
|
let rag: ReturnType<typeof createRegulatoryRagRuntime> | undefined;
|
||||||
let disabledReason: string | undefined;
|
let disabledReason: string | undefined;
|
||||||
|
|
@ -51,31 +58,36 @@ export async function createComplianceEngineRuntime(options: ComplianceEngineRun
|
||||||
client: options.aiClient,
|
client: options.aiClient,
|
||||||
corpusId: process.env.ASSESSOR_RAG_ACTIVE_CORPUS_ID?.trim(),
|
corpusId: process.env.ASSESSOR_RAG_ACTIVE_CORPUS_ID?.trim(),
|
||||||
corpusVersion: process.env.ASSESSOR_RAG_ACTIVE_CORPUS_VERSION?.trim(),
|
corpusVersion: process.env.ASSESSOR_RAG_ACTIVE_CORPUS_VERSION?.trim(),
|
||||||
|
bsiCatalogs,
|
||||||
...(!options.aiClient ? { disabledReason: 'Work LiteLLM is not configured.' } : {})
|
...(!options.aiClient ? { disabledReason: 'Work LiteLLM is not configured.' } : {})
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
disabledReason = error instanceof Error ? error.message : 'Regulatory mapping is unavailable.';
|
disabledReason = error instanceof Error ? error.message : 'Regulatory mapping is unavailable.';
|
||||||
mappingService = new RegulatoryMappingService(read, store, { disabledReason });
|
mappingService = new RegulatoryMappingService(read, store, { disabledReason, bsiCatalogs });
|
||||||
}
|
}
|
||||||
await mappingService.recoverInterruptedRuns();
|
await mappingService.recoverInterruptedRuns();
|
||||||
|
const autoMapping = new RegulatoryAutoMapping({ settingsPath: join(corpusRoot, 'auto-mapping.json'), read, mapping: mappingService });
|
||||||
const corpusUploadService = new RegulatoryCorpusUploadService({
|
const corpusUploadService = new RegulatoryCorpusUploadService({
|
||||||
root: join(options.artifactRoot, 'regulatory-corpora'),
|
root: corpusRoot,
|
||||||
ingestion: rag?.ingestion,
|
ingestion: rag?.ingestion,
|
||||||
countIndexed: rag ? (corpusId, corpusVersion) => rag!.index.count({ corpusId, corpusVersion }) : undefined,
|
countIndexed: rag ? (corpusId, corpusVersion) => rag!.index.count({ corpusId, corpusVersion }) : undefined,
|
||||||
pruneIndexed: rag ? (corpusId, corpusVersion) => rag!.index.deleteOtherCorpusVersions(corpusId, corpusVersion) : undefined,
|
pruneIndexed: rag ? (corpusId, corpusVersion) => rag!.index.deleteOtherCorpusVersions(corpusId, corpusVersion) : undefined,
|
||||||
mapping: mappingService,
|
mapping: mappingService,
|
||||||
|
autoMapping,
|
||||||
embeddingModel: rag?.embeddingModel,
|
embeddingModel: rag?.embeddingModel,
|
||||||
disabledReason
|
disabledReason
|
||||||
});
|
});
|
||||||
await corpusUploadService.initialize();
|
await corpusUploadService.initialize();
|
||||||
|
await autoMapping.start();
|
||||||
const aiOverviewService = new AiOverviewService(read, store, { ...options.aiOverview, client: options.aiClient });
|
const aiOverviewService = new AiOverviewService(read, store, { ...options.aiOverview, client: options.aiClient });
|
||||||
await aiOverviewService.recoverInterruptedRuns();
|
await aiOverviewService.recoverInterruptedRuns();
|
||||||
return {
|
return {
|
||||||
mappingService,
|
mappingService,
|
||||||
corpusUploadService,
|
corpusUploadService,
|
||||||
aiOverviewService,
|
aiOverviewService,
|
||||||
|
bsiCatalogService,
|
||||||
store,
|
store,
|
||||||
stop: () => Promise.all([corpusUploadService.stop().then(() => mappingService.close()), aiOverviewService.close()]).then(() => undefined),
|
stop: () => Promise.all([autoMapping.stop(), corpusUploadService.stop().then(() => mappingService.close()), aiOverviewService.close()]).then(() => undefined),
|
||||||
close: async () => { await pool?.end(); }
|
close: async () => { await pool?.end(); }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -182,14 +182,16 @@ export class PostgresComplianceStore implements ComplianceStore {
|
||||||
if (!run || run.ruleKey !== decision.ruleKey || run.status !== 'succeeded') {
|
if (!run || run.ruleKey !== decision.ruleKey || run.status !== 'succeeded') {
|
||||||
throw new RepositoryNotFoundError('Completed regulatory mapping does not exist for this rule.');
|
throw new RepositoryNotFoundError('Completed regulatory mapping does not exist for this rule.');
|
||||||
}
|
}
|
||||||
if (!run.candidates.some(candidate => candidate.controlId === decision.controlId)) {
|
|
||||||
throw new RepositoryValidationError('Decision control was not a grounded candidate.');
|
|
||||||
}
|
|
||||||
const latest = dataRow(await client.query<DataRow<RegulatoryMappingDecision>>(`
|
const latest = dataRow(await client.query<DataRow<RegulatoryMappingDecision>>(`
|
||||||
SELECT data FROM assessor_regulatory_mapping_decisions
|
SELECT data FROM assessor_regulatory_mapping_decisions
|
||||||
WHERE rule_key = $1 AND control_id = $2
|
WHERE rule_key = $1 AND control_id = $2
|
||||||
ORDER BY created_at DESC, id DESC LIMIT 1
|
ORDER BY created_at DESC, id DESC LIMIT 1
|
||||||
`, [decision.ruleKey, decision.controlId]));
|
`, [decision.ruleKey, decision.controlId]));
|
||||||
|
// A control with an earlier decision stays decidable after a remap no longer retrieves it; the chain of
|
||||||
|
// previous decisions leads back to the run that grounded it.
|
||||||
|
if (!latest && !run.candidates.some(candidate => candidate.controlId === decision.controlId)) {
|
||||||
|
throw new RepositoryValidationError('Decision control was not a grounded candidate.');
|
||||||
|
}
|
||||||
if ((expectedLatestDecisionId ?? undefined) !== (latest?.id ?? undefined)) {
|
if ((expectedLatestDecisionId ?? undefined) !== (latest?.id ?? undefined)) {
|
||||||
throw new RepositoryConflictError('Regulatory mapping decision changed before this update.');
|
throw new RepositoryConflictError('Regulatory mapping decision changed before this update.');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
import type { ComplianceReadPort } from '../../src/compliance/compliance-store';
|
||||||
|
import { AUTO_MAPPING_BATCH_KEY_PREFIXES, type RegulatoryMappingBatchCreateRequest } from '../../src/compliance/regulatory-mapping';
|
||||||
|
import type { RegulatoryMappingService } from './regulatory-mapping-service';
|
||||||
|
|
||||||
|
const [SCAN_KEY_PREFIX, GPO_KEY_PREFIX] = AUTO_MAPPING_BATCH_KEY_PREFIXES;
|
||||||
|
const DEFAULT_POLL_INTERVAL_MS = 10_000;
|
||||||
|
const PAGE_SIZE = 500;
|
||||||
|
|
||||||
|
interface AutoMappingSettings { version: 1; enabled: boolean }
|
||||||
|
interface PendingBatch { projectId: string; request: RegulatoryMappingBatchCreateRequest }
|
||||||
|
|
||||||
|
export interface RegulatoryAutoMappingOptions {
|
||||||
|
/** Engine-owned settings file; no Core table or column stores the switch. */
|
||||||
|
settingsPath: string;
|
||||||
|
read: Pick<ComplianceReadPort, 'listProjects' | 'listAssessments' | 'listProjectScanRuns' | 'queryUnifiedResults'>;
|
||||||
|
mapping: Pick<RegulatoryMappingService, 'generationEnabled' | 'startBatch'>;
|
||||||
|
pollIntervalMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps newly uploaded scans and GPO evaluations automatically while its Data Hub switch is on (default off).
|
||||||
|
* It reads the Core only through the read-only port: committed scan runs, and the GPO deviation rows of each
|
||||||
|
* timeslot as filtered Result DB pages (GPO backups have no scan run). For every new scan run, and every
|
||||||
|
* timeslot with new GPO deviation rows, it starts the existing ISO batch on those deviations; rules mapped
|
||||||
|
* before are reused, so only new rules reach the model. What exists when the watcher starts or is switched
|
||||||
|
* on forms the baseline and is not mapped retroactively.
|
||||||
|
*/
|
||||||
|
export class RegulatoryAutoMapping {
|
||||||
|
private enabled = false;
|
||||||
|
private knownScanRuns: Set<string> | null = null;
|
||||||
|
private knownGpoRows: Set<string> | null = null;
|
||||||
|
private readonly pending = new Map<string, PendingBatch>();
|
||||||
|
private timer?: ReturnType<typeof setInterval>;
|
||||||
|
private polling?: Promise<void>;
|
||||||
|
|
||||||
|
constructor(private readonly options: RegulatoryAutoMappingOptions) {}
|
||||||
|
|
||||||
|
get isEnabled(): boolean { return this.enabled; }
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
const settings = await this.readSettings();
|
||||||
|
if (settings?.enabled) await this.activate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async setEnabled(enabled: boolean): Promise<void> {
|
||||||
|
if (typeof enabled !== 'boolean') throw new TypeError('enabled must be a boolean.');
|
||||||
|
await this.writeSettings({ version: 1, enabled });
|
||||||
|
if (enabled && !this.enabled) await this.activate();
|
||||||
|
if (!enabled && this.enabled) await this.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.enabled = false;
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
this.timer = undefined;
|
||||||
|
await this.polling;
|
||||||
|
this.knownScanRuns = null;
|
||||||
|
this.knownGpoRows = null;
|
||||||
|
this.pending.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One watcher pass; serialized so a slow pass never overlaps the next. */
|
||||||
|
poll(): Promise<void> {
|
||||||
|
this.polling ??= this.pass().catch(() => undefined).finally(() => { this.polling = undefined; });
|
||||||
|
return this.polling;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async activate(): Promise<void> {
|
||||||
|
this.enabled = true;
|
||||||
|
await this.poll();
|
||||||
|
this.timer = setInterval(() => { void this.poll(); }, this.options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
|
||||||
|
this.timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pass(): Promise<void> {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
const scanRuns: Array<{ id: string; projectId: string; assessmentId: string }> = [];
|
||||||
|
const gpoRows = new Map<string, { projectId: string; assessmentId: string; ids: string[] }>();
|
||||||
|
for (const project of await this.options.read.listProjects()) {
|
||||||
|
for (const run of await this.options.read.listProjectScanRuns(project.id)) {
|
||||||
|
if (!run.supersededByScanRunId) scanRuns.push({ id: run.id, projectId: project.id, assessmentId: run.assessmentId });
|
||||||
|
}
|
||||||
|
for (const assessment of await this.options.read.listAssessments(project.id)) {
|
||||||
|
const ids = await this.gpoDeviationRowIds(project.id, assessment.id);
|
||||||
|
if (ids.length) gpoRows.set(assessment.id, { projectId: project.id, assessmentId: assessment.id, ids });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!this.knownScanRuns || !this.knownGpoRows) {
|
||||||
|
this.knownScanRuns = new Set(scanRuns.map(run => run.id));
|
||||||
|
this.knownGpoRows = new Set([...gpoRows.values()].flatMap(entry => entry.ids));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const run of scanRuns) {
|
||||||
|
if (this.knownScanRuns.has(run.id)) continue;
|
||||||
|
this.knownScanRuns.add(run.id);
|
||||||
|
this.pending.set(`scan:${run.id}`, { projectId: run.projectId, request: {
|
||||||
|
// Stable per scan run, so a restart or a second pass never creates a duplicate batch.
|
||||||
|
idempotencyKey: safeKey(`${SCAN_KEY_PREFIX}${run.id}`),
|
||||||
|
assessmentIds: [run.assessmentId], scanRunIds: [run.id], filters: { deviation: true }
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
for (const { projectId, assessmentId, ids } of gpoRows.values()) {
|
||||||
|
const added = ids.filter(id => !this.knownGpoRows!.has(id));
|
||||||
|
if (!added.length) continue;
|
||||||
|
for (const id of added) this.knownGpoRows.add(id);
|
||||||
|
// Stable per set of new GPO rows; one batch maps every GPO deviation of the timeslot.
|
||||||
|
const fingerprint = createHash('sha256').update(added.sort().join('\n'), 'utf8').digest('hex').slice(0, 16);
|
||||||
|
this.pending.set(`gpo:${assessmentId}`, { projectId, request: {
|
||||||
|
idempotencyKey: safeKey(`${GPO_KEY_PREFIX}${fingerprint}:${assessmentId}`),
|
||||||
|
assessmentIds: [assessmentId], scanRunIds: [], filters: { sourceKind: ['policy-evaluation'], deviation: true }
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
// New imports wait until the ISO corpus and model are available.
|
||||||
|
if (!this.options.mapping.generationEnabled) return;
|
||||||
|
for (const [key, { projectId, request }] of [...this.pending]) {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
this.pending.delete(key);
|
||||||
|
try { await this.options.mapping.startBatch(projectId, request); }
|
||||||
|
catch { /* A removed or superseded timeslot is skipped; the manual batch stays available. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async gpoDeviationRowIds(projectId: string, assessmentId: string): Promise<string[]> {
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let offset = 0; ; offset += PAGE_SIZE) {
|
||||||
|
const page = await this.options.read.queryUnifiedResults({ audience: 'result-db', projectId, assessmentIds: [assessmentId],
|
||||||
|
sourceKind: ['policy-evaluation'], deviation: true, offset, limit: PAGE_SIZE });
|
||||||
|
ids.push(...page.items.map(item => item.resultProjectionId));
|
||||||
|
if (offset + page.items.length >= page.total || page.items.length === 0) return ids;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readSettings(): Promise<AutoMappingSettings | null> {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(await readFile(this.options.settingsPath, 'utf8')) as Partial<AutoMappingSettings>;
|
||||||
|
return { version: 1, enabled: parsed.enabled === true };
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeSettings(settings: AutoMappingSettings): Promise<void> {
|
||||||
|
await mkdir(dirname(this.options.settingsPath), { recursive: true, mode: 0o700 });
|
||||||
|
const temporary = `${this.options.settingsPath}.${randomUUID()}.tmp`;
|
||||||
|
try {
|
||||||
|
await writeFile(temporary, JSON.stringify(settings), { mode: 0o600, flag: 'wx' });
|
||||||
|
await rename(temporary, this.options.settingsPath);
|
||||||
|
} finally { await rm(temporary, { force: true }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeKey(value: string): string {
|
||||||
|
return value.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 128);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import type { IsoPdfCorpusOptions } from './iso-pdf-corpus';
|
||||||
import { prepareIsoCorpusInWorker } from './iso-pdf-worker-client';
|
import { prepareIsoCorpusInWorker } from './iso-pdf-worker-client';
|
||||||
import type { RegulatoryIngestionService } from './regulatory-rag-service';
|
import type { RegulatoryIngestionService } from './regulatory-rag-service';
|
||||||
import type { RegulatoryMappingService } from './regulatory-mapping-service';
|
import type { RegulatoryMappingService } from './regulatory-mapping-service';
|
||||||
|
import type { RegulatoryAutoMapping } from './regulatory-auto-mapping';
|
||||||
|
|
||||||
const MAXIMUM_PDF_BYTES = 32 * 1024 * 1024;
|
const MAXIMUM_PDF_BYTES = 32 * 1024 * 1024;
|
||||||
const MAXIMUM_CORPUS_BYTES = 64 * 1024 * 1024;
|
const MAXIMUM_CORPUS_BYTES = 64 * 1024 * 1024;
|
||||||
|
|
@ -21,6 +22,7 @@ export interface RegulatoryCorpusUploadOptions {
|
||||||
root: string;
|
root: string;
|
||||||
ingestion?: RegulatoryIngestionService;
|
ingestion?: RegulatoryIngestionService;
|
||||||
mapping: RegulatoryMappingService;
|
mapping: RegulatoryMappingService;
|
||||||
|
autoMapping?: Pick<RegulatoryAutoMapping, 'isEnabled' | 'setEnabled'>;
|
||||||
embeddingModel?: string;
|
embeddingModel?: string;
|
||||||
disabledReason?: string;
|
disabledReason?: string;
|
||||||
countIndexed?: (corpusId: string, corpusVersion: string) => Promise<number>;
|
countIndexed?: (corpusId: string, corpusVersion: string) => Promise<number>;
|
||||||
|
|
@ -77,10 +79,18 @@ export class RegulatoryCorpusUploadService {
|
||||||
configured: Boolean(this.options.ingestion),
|
configured: Boolean(this.options.ingestion),
|
||||||
...(!this.options.ingestion ? { disabledReason: this.options.disabledReason ?? 'Embedding or Qdrant is not configured.' } : {}),
|
...(!this.options.ingestion ? { disabledReason: this.options.disabledReason ?? 'Embedding or Qdrant is not configured.' } : {}),
|
||||||
active: this.active,
|
active: this.active,
|
||||||
job: this.job
|
job: this.job,
|
||||||
|
...(this.options.autoMapping ? { autoMapping: { enabled: this.options.autoMapping.isEnabled } } : {})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setAutoMapping(enabled: boolean): Promise<RegulatoryCorpusStatus> {
|
||||||
|
if (!this.options.autoMapping) throw new RegulatoryCorpusUploadError('regulatory_auto_mapping_unavailable', 'Automatic ISO mapping is not configured.', 503);
|
||||||
|
if (typeof enabled !== 'boolean') throw new RegulatoryCorpusUploadError('invalid_auto_mapping', 'enabled must be true or false.');
|
||||||
|
await this.options.autoMapping.setEnabled(enabled);
|
||||||
|
return this.liveStatus();
|
||||||
|
}
|
||||||
|
|
||||||
async liveStatus(): Promise<RegulatoryCorpusStatus> {
|
async liveStatus(): Promise<RegulatoryCorpusStatus> {
|
||||||
const status = this.status();
|
const status = this.status();
|
||||||
if (!this.active || !this.options.countIndexed) return status;
|
if (!this.active || !this.options.countIndexed) return status;
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,17 @@ import {
|
||||||
type RegulatoryControlOverviewItem,
|
type RegulatoryControlOverviewItem,
|
||||||
type RegulatoryControlOverviewResponse,
|
type RegulatoryControlOverviewResponse,
|
||||||
acceptedControlIds,
|
acceptedControlIds,
|
||||||
regulatoryControlTheme
|
regulatoryControlTheme,
|
||||||
|
AUTO_MAPPING_BATCH_KEY_PREFIXES,
|
||||||
|
MAPPING_HISTORY_LIMIT,
|
||||||
|
type RegulatoryMappingHistoryEntry
|
||||||
} from '../../src/compliance/regulatory-mapping';
|
} from '../../src/compliance/regulatory-mapping';
|
||||||
import type { RegulatoryRetrievalCandidate } from '../../src/compliance/regulatory-rag';
|
import type { RegulatoryRetrievalCandidate } from '../../src/compliance/regulatory-rag';
|
||||||
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
||||||
import { privacySafeFinding } from '../../src/domain/privacy-safe-finding';
|
import { privacySafeFinding } from '../../src/domain/privacy-safe-finding';
|
||||||
import type { RegulatoryFindingRetrievalService } from './regulatory-finding-retrieval';
|
import type { RegulatoryFindingRetrievalService } from './regulatory-finding-retrieval';
|
||||||
|
import { bundledBsiCatalogs, type BsiCatalogs } from './bsi-snapshots';
|
||||||
|
import { bsiFootprint, bsiOverview, type BsiOverview } from '../../src/compliance/bsi-grundschutz';
|
||||||
import type { RegulatoryChunkIndex } from './qdrant-regulatory-index';
|
import type { RegulatoryChunkIndex } from './qdrant-regulatory-index';
|
||||||
import { buildRegulatoryFindingQuery, isRegulatoryMappableResult } from '../../src/compliance/regulatory-query';
|
import { buildRegulatoryFindingQuery, isRegulatoryMappableResult } from '../../src/compliance/regulatory-query';
|
||||||
import { canonicalJson } from '../../src/shared/canonical-json';
|
import { canonicalJson } from '../../src/shared/canonical-json';
|
||||||
|
|
@ -58,6 +63,8 @@ export interface RegulatoryMappingServiceOptions {
|
||||||
disabledReason?: string;
|
disabledReason?: string;
|
||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
maximumConcurrentRuns?: number;
|
maximumConcurrentRuns?: number;
|
||||||
|
/** The active BSI catalog and mapping (`Update from BSI`); the pinned snapshots by default. */
|
||||||
|
bsiCatalogs?: () => Promise<BsiCatalogs>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RegulatoryMappingService {
|
export class RegulatoryMappingService {
|
||||||
|
|
@ -70,6 +77,7 @@ export class RegulatoryMappingService {
|
||||||
}> = [];
|
}> = [];
|
||||||
private activeWorkerCount = 0;
|
private activeWorkerCount = 0;
|
||||||
private activationPending = false;
|
private activationPending = false;
|
||||||
|
private titleCache?: { key: string; titles: Record<string, string> };
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly read: ComplianceReadPort,
|
private readonly read: ComplianceReadPort,
|
||||||
|
|
@ -133,6 +141,7 @@ export class RegulatoryMappingService {
|
||||||
const run = ruleKey && this.options.client ? await this.store.getRegulatoryMappingRun(this.mappingId(ruleKey)) : null;
|
const run = ruleKey && this.options.client ? await this.store.getRegulatoryMappingRun(this.mappingId(ruleKey)) : null;
|
||||||
const decisions = ruleKey ? await this.store.listRegulatoryMappingDecisions(ruleKey) : [];
|
const decisions = ruleKey ? await this.store.listRegulatoryMappingDecisions(ruleKey) : [];
|
||||||
const earlierControls = ruleKey ? await this.earlierControls(ruleKey, run, decisions) : [];
|
const earlierControls = ruleKey ? await this.earlierControls(ruleKey, run, decisions) : [];
|
||||||
|
const bsi = await this.bsiOverview(acceptedControlIds(decisions));
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
projectId,
|
projectId,
|
||||||
|
|
@ -146,7 +155,8 @@ export class RegulatoryMappingService {
|
||||||
...(run && ['queued', 'running'].includes(run.status) ? { pollAfterMs: POLL_AFTER_MS } : {}),
|
...(run && ['queued', 'running'].includes(run.status) ? { pollAfterMs: POLL_AFTER_MS } : {}),
|
||||||
...(run ? { run: withoutEvidenceText(run) } : {}),
|
...(run ? { run: withoutEvidenceText(run) } : {}),
|
||||||
decisions,
|
decisions,
|
||||||
...(earlierControls.length ? { earlierControls } : {})
|
...(earlierControls.length ? { earlierControls } : {}),
|
||||||
|
...(bsi ? { bsi } : {})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,7 +194,7 @@ export class RegulatoryMappingService {
|
||||||
const finding = await this.finding(projectId, request.resultProjectionId);
|
const finding = await this.finding(projectId, request.resultProjectionId);
|
||||||
if (!isRegulatoryMappableResult(finding)) throw new RegulatoryMappingServiceError('unsupported_finding_kind', UNSUPPORTED_KIND_MESSAGE, 422);
|
if (!isRegulatoryMappableResult(finding)) throw new RegulatoryMappingServiceError('unsupported_finding_kind', UNSUPPORTED_KIND_MESSAGE, 422);
|
||||||
if (!buildRegulatoryFindingQuery(finding)) throw new RegulatoryMappingServiceError('insufficient_public_context', 'This finding has insufficient public context for regulatory mapping.', 422);
|
if (!buildRegulatoryFindingQuery(finding)) throw new RegulatoryMappingServiceError('insufficient_public_context', 'This finding has insufficient public context for regulatory mapping.', 422);
|
||||||
const claimed = await this.claim(finding, true);
|
const claimed = await this.claim(finding, true, 'single', await this.origin(projectId, request.origin));
|
||||||
if (claimed.claimed) this.track(this.enqueue(() => this.process(claimed.run, finding)).then(() => undefined));
|
if (claimed.claimed) this.track(this.enqueue(() => this.process(claimed.run, finding)).then(() => undefined));
|
||||||
return this.get(projectId, request.resultProjectionId);
|
return this.get(projectId, request.resultProjectionId);
|
||||||
}
|
}
|
||||||
|
|
@ -260,6 +270,36 @@ export class RegulatoryMappingService {
|
||||||
return this.get(projectId, request.resultProjectionId);
|
return this.get(projectId, request.resultProjectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** BSI Kernel requirements derived from accepted ISO controls through the active BSI mapping (Phase 13F, stage 1). */
|
||||||
|
private async bsiOverview(acceptedIsoControlIds: readonly string[]): Promise<BsiOverview | undefined> {
|
||||||
|
const { catalog, crosswalk } = await this.bsiCatalogs();
|
||||||
|
return bsiOverview(acceptedIsoControlIds, crosswalk, catalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annex A control titles of the active licensed corpus (one ISO/IEC 27001 reference document per control), for the
|
||||||
|
* Client Info footprint; only titles, never clause text. Cached per corpus version; an unavailable index yields none.
|
||||||
|
*/
|
||||||
|
private async annexATitles(): Promise<Record<string, string>> {
|
||||||
|
const { index, corpusId, corpusVersion } = this.options;
|
||||||
|
if (!index || !corpusId || !corpusVersion) return {};
|
||||||
|
const key = `${corpusId}\u0000${corpusVersion}`;
|
||||||
|
if (this.titleCache?.key === key) return this.titleCache.titles;
|
||||||
|
try {
|
||||||
|
const chunks = await index.list({ corpusId, corpusVersion, sourceStandard: 'ISO/IEC 27001', retrievalRole: 'reference' }, 100);
|
||||||
|
const titles: Record<string, string> = {};
|
||||||
|
for (const chunk of chunks) if (chunk.controlId && chunk.title?.trim() && !titles[chunk.controlId]) titles[chunk.controlId] = chunk.title.trim();
|
||||||
|
if (Object.keys(titles).length) this.titleCache = { key, titles };
|
||||||
|
return titles;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async bsiCatalogs(): Promise<BsiCatalogs> {
|
||||||
|
return this.options.bsiCatalogs ? this.options.bsiCatalogs() : bundledBsiCatalogs;
|
||||||
|
}
|
||||||
|
|
||||||
/** Accepted controls of every mappable finding in a client timeslot, for the Work `ISO control` and `ISO theme` filters. */
|
/** Accepted controls of every mappable finding in a client timeslot, for the Work `ISO control` and `ISO theme` filters. */
|
||||||
async controlAssignments(projectId: string, requested: Pick<RegulatoryMappingBatchScope, 'assessmentIds' | 'scanRunIds'>): Promise<RegulatoryControlOverviewResponse> {
|
async controlAssignments(projectId: string, requested: Pick<RegulatoryMappingBatchScope, 'assessmentIds' | 'scanRunIds'>): Promise<RegulatoryControlOverviewResponse> {
|
||||||
return this.controlOverview(projectId, requested, { deviationsOnly: false });
|
return this.controlOverview(projectId, requested, { deviationsOnly: false });
|
||||||
|
|
@ -287,16 +327,25 @@ export class RegulatoryMappingService {
|
||||||
}
|
}
|
||||||
// Control titles come from the newest succeeded run of each rule that retrieved the control.
|
// Control titles come from the newest succeeded run of each rule that retrieved the control.
|
||||||
const titles = new Map<string, Map<string, RegulatoryMappingCandidate>>();
|
const titles = new Map<string, Map<string, RegulatoryMappingCandidate>>();
|
||||||
|
const mapped = new Set<string>();
|
||||||
|
const latestRun = new Map<string, RegulatoryMappingRun>();
|
||||||
for (const run of await this.store.listRegulatoryMappingRuns()) {
|
for (const run of await this.store.listRegulatoryMappingRuns()) {
|
||||||
if (run.status !== 'succeeded' || !byRule.has(run.ruleKey)) continue;
|
if (!byRule.has(run.ruleKey)) continue;
|
||||||
|
const latest = latestRun.get(run.ruleKey);
|
||||||
|
if (!latest || run.createdAt > latest.createdAt || run.createdAt === latest.createdAt && run.id > latest.id) latestRun.set(run.ruleKey, run);
|
||||||
|
if (run.status !== 'succeeded') continue;
|
||||||
|
mapped.add(run.ruleKey);
|
||||||
const known = titles.get(run.ruleKey) ?? new Map<string, RegulatoryMappingCandidate>();
|
const known = titles.get(run.ruleKey) ?? new Map<string, RegulatoryMappingCandidate>();
|
||||||
for (const candidate of run.candidates) if (!known.has(candidate.controlId)) known.set(candidate.controlId, candidate);
|
for (const candidate of run.candidates) if (!known.has(candidate.controlId)) known.set(candidate.controlId, candidate);
|
||||||
titles.set(run.ruleKey, known);
|
titles.set(run.ruleKey, known);
|
||||||
}
|
}
|
||||||
const controls = new Map<string, { item: RegulatoryControlOverviewItem; findings: Map<string, RegulatoryControlFinding> }>();
|
const controls = new Map<string, { item: RegulatoryControlOverviewItem; findings: Map<string, RegulatoryControlFinding> }>();
|
||||||
const covered = new Set<string>();
|
const covered = new Set<string>();
|
||||||
|
const unaccepted: Array<[string, Map<string, RegulatoryControlFinding>]> = [];
|
||||||
for (const [ruleKey, findings] of byRule) {
|
for (const [ruleKey, findings] of byRule) {
|
||||||
for (const controlId of acceptedControlIds(await this.store.listRegulatoryMappingDecisions(ruleKey))) {
|
const accepted = acceptedControlIds(await this.store.listRegulatoryMappingDecisions(ruleKey));
|
||||||
|
if (!accepted.length) unaccepted.push([ruleKey, findings]);
|
||||||
|
for (const controlId of accepted) {
|
||||||
const candidate = titles.get(ruleKey)?.get(controlId);
|
const candidate = titles.get(ruleKey)?.get(controlId);
|
||||||
const entry = controls.get(controlId) ?? { findings: new Map(), item: {
|
const entry = controls.get(controlId) ?? { findings: new Map(), item: {
|
||||||
controlId, annexAControlId: candidate?.annexAControlId ?? `A.${controlId}`, findings: [],
|
controlId, annexAControlId: candidate?.annexAControlId ?? `A.${controlId}`, findings: [],
|
||||||
|
|
@ -307,14 +356,74 @@ export class RegulatoryMappingService {
|
||||||
controls.set(controlId, entry);
|
controls.set(controlId, entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const bsi = await this.bsiOverview([...controls.keys()]);
|
||||||
|
// A finding counts once, in the strongest state of its rule: accepted, mapped, failed, else not mapped.
|
||||||
|
const mappedNoneAccepted = new Set<string>();
|
||||||
|
const failed = new Set<string>();
|
||||||
|
const notMapped = new Set<string>();
|
||||||
|
const failedFindings: RegulatoryControlFinding[] = [];
|
||||||
|
for (const [ruleKey, findings] of unaccepted) {
|
||||||
|
const state = mapped.has(ruleKey) ? mappedNoneAccepted : latestRun.get(ruleKey)?.status === 'failed' ? failed : notMapped;
|
||||||
|
for (const [identity, finding] of findings) {
|
||||||
|
if (covered.has(identity)) continue;
|
||||||
|
state.add(identity);
|
||||||
|
if (state === failed) failedFindings.push(finding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const coverage = { deviations: covered.size + mappedNoneAccepted.size + failed.size + notMapped.size, accepted: covered.size,
|
||||||
|
mappedNoneAccepted: mappedNoneAccepted.size, failed: failed.size, notMapped: notMapped.size };
|
||||||
return {
|
return {
|
||||||
version: 1, projectId, scope: { assessmentIds: scope.assessmentIds, scanRunIds: scope.scanRunIds }, findingCount: covered.size,
|
version: 1, projectId, scope: { assessmentIds: scope.assessmentIds, scanRunIds: scope.scanRunIds }, findingCount: covered.size,
|
||||||
controls: [...controls.values()].map(({ item, findings }) => ({ ...item, findings: [...findings.values()] }))
|
controls: [...controls.values()].map(({ item, findings }) => ({ ...item, findings: [...findings.values()] }))
|
||||||
.sort((left, right) => right.findings.length - left.findings.length
|
.sort((left, right) => right.findings.length - left.findings.length
|
||||||
|| left.controlId.localeCompare(right.controlId, 'en', { numeric: true }))
|
|| left.controlId.localeCompare(right.controlId, 'en', { numeric: true })),
|
||||||
|
...(deviationsOnly ? { mappingHistory: await this.mappingHistory(projectId, scope), coverage, annexATitles: await this.annexATitles(),
|
||||||
|
bsiFootprint: await this.bsiCatalogs().then(({ catalog, crosswalk }) => bsiFootprint(crosswalk, catalog)) }
|
||||||
|
// The Work `ISO mapping` filter: findings whose rule's latest mapping failed, matched like the control filters.
|
||||||
|
: { failedFindings }),
|
||||||
|
...(bsi ? { bsi } : {})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Newest first, globally (user decision 2026-10-08), because mappings and decisions are global per rule:
|
||||||
|
* every batch of every client and every single finding mapping, each marked by where it was started
|
||||||
|
* relative to the shown timeslot. Entries carry dates, trigger, that mark and counts only, never a client,
|
||||||
|
* rule or scope id. Runs from before 2.21.0 record no trigger; a run a batch started is counted by its batch.
|
||||||
|
*/
|
||||||
|
private async mappingHistory(projectId: string, scope: Pick<RegulatoryMappingBatchScope, 'assessmentIds' | 'scanRunIds'>): Promise<RegulatoryMappingHistoryEntry[]> {
|
||||||
|
const assessments = new Set(scope.assessmentIds);
|
||||||
|
const scanRuns = new Set(scope.scanRunIds);
|
||||||
|
const viewed = originTokens(projectId, scope);
|
||||||
|
const viewedTimeslot = new Set(viewed.timeslot);
|
||||||
|
const batchRuns = (await Promise.all((await this.read.listProjects())
|
||||||
|
.map(project => this.store.listRegulatoryMappingBatchRuns(project.id)))).flat();
|
||||||
|
const batches: RegulatoryMappingHistoryEntry[] = batchRuns.map(run => ({
|
||||||
|
id: run.id, kind: 'batch',
|
||||||
|
scope: run.projectId !== projectId ? 'other-client'
|
||||||
|
: run.assessmentIds.some(id => assessments.has(id)) || run.scanRunIds.some(id => scanRuns.has(id)) ? 'timeslot' : 'other-timeslot',
|
||||||
|
trigger: AUTO_MAPPING_BATCH_KEY_PREFIXES.some(prefix => run.idempotencyKey.startsWith(prefix)) ? 'automatic' : 'manual',
|
||||||
|
status: run.status, createdAt: run.createdAt, ...(run.completedAt ? { completedAt: run.completedAt } : {}),
|
||||||
|
counts: { findings: run.counts.eligibleFindingCount, sentToModel: run.counts.scheduledRuleCount,
|
||||||
|
reused: run.counts.reusedSucceededRuleCount + run.counts.reusedActiveRuleCount,
|
||||||
|
succeeded: run.counts.succeededRuleCount, failed: run.counts.failedRuleCount }
|
||||||
|
}));
|
||||||
|
// A single mapping maps one rule for every client; it has no finding count of its own.
|
||||||
|
const singles: RegulatoryMappingHistoryEntry[] = (await this.store.listRegulatoryMappingRuns())
|
||||||
|
.filter(run => run.trigger === 'single')
|
||||||
|
.map(run => ({
|
||||||
|
id: run.id, kind: 'single', trigger: 'manual',
|
||||||
|
scope: !run.origin ? 'unknown' : run.origin.timeslot.some(token => viewedTimeslot.has(token)) ? 'timeslot'
|
||||||
|
: run.origin.project === viewed.project ? 'other-timeslot' : 'other-client',
|
||||||
|
status: run.status === 'succeeded' ? 'completed' : run.status,
|
||||||
|
createdAt: run.createdAt, ...(run.completedAt ? { completedAt: run.completedAt } : {}),
|
||||||
|
counts: { sentToModel: 1, reused: 0, succeeded: run.status === 'succeeded' ? 1 : 0, failed: run.status === 'failed' ? 1 : 0 }
|
||||||
|
}));
|
||||||
|
return [...batches, ...singles]
|
||||||
|
.sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id))
|
||||||
|
.slice(0, MAPPING_HISTORY_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accepts the first proposals of a finished mapping, in the model's order. A control that already has a
|
* Accepts the first proposals of a finished mapping, in the model's order. A control that already has a
|
||||||
* decision keeps it, so a manual reject is never overridden; a failure here never fails the mapping.
|
* decision keeps it, so a manual reject is never overridden; a failure here never fails the mapping.
|
||||||
|
|
@ -386,7 +495,7 @@ export class RegulatoryMappingService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private queuedRun(finding: UnifiedResultProjection): RegulatoryMappingRun {
|
private queuedRun(finding: UnifiedResultProjection, trigger: 'single' | 'batch', origin?: RegulatoryMappingRun['origin']): RegulatoryMappingRun {
|
||||||
const preparedQuery = buildRegulatoryFindingQuery(finding);
|
const preparedQuery = buildRegulatoryFindingQuery(finding);
|
||||||
if (!preparedQuery) throw new RegulatoryMappingServiceError('insufficient_public_context', 'This finding has insufficient public context for regulatory mapping.', 422);
|
if (!preparedQuery) throw new RegulatoryMappingServiceError('insufficient_public_context', 'This finding has insufficient public context for regulatory mapping.', 422);
|
||||||
const timestamp = this.now().toISOString();
|
const timestamp = this.now().toISOString();
|
||||||
|
|
@ -408,14 +517,26 @@ export class RegulatoryMappingService {
|
||||||
promptVersion: this.promptVersion(ruleKey),
|
promptVersion: this.promptVersion(ruleKey),
|
||||||
outputSchemaVersion: REGULATORY_MAPPING_OUTPUT_SCHEMA_VERSION,
|
outputSchemaVersion: REGULATORY_MAPPING_OUTPUT_SCHEMA_VERSION,
|
||||||
model: this.options.client!.model,
|
model: this.options.client!.model,
|
||||||
|
trigger,
|
||||||
|
...(origin ? { origin } : {}),
|
||||||
createdAt: timestamp,
|
createdAt: timestamp,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
candidates: []
|
candidates: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async claim(finding: UnifiedResultProjection, forceRegeneration: boolean) {
|
private async claim(finding: UnifiedResultProjection, forceRegeneration: boolean, trigger: 'single' | 'batch', origin?: RegulatoryMappingRun['origin']) {
|
||||||
return this.store.claimRegulatoryMappingRun(this.queuedRun(finding), forceRegeneration);
|
return this.store.claimRegulatoryMappingRun(this.queuedRun(finding, trigger, origin), forceRegeneration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The client always; the timeslot only when it is a valid timeslot of that client. */
|
||||||
|
private async origin(projectId: string, requested: RegulatoryMappingCreateRequest['origin']): Promise<RegulatoryMappingRun['origin']> {
|
||||||
|
let timeslot: Pick<RegulatoryMappingBatchScope, 'assessmentIds' | 'scanRunIds'> = { assessmentIds: [], scanRunIds: [] };
|
||||||
|
if (requested) {
|
||||||
|
try { timeslot = await this.validateBatchScope(projectId, { assessmentIds: requested.assessmentIds, scanRunIds: requested.scanRunIds, filters: {} }); }
|
||||||
|
catch { /* An unusable timeslot leaves only the client as origin. */ }
|
||||||
|
}
|
||||||
|
return originTokens(projectId, timeslot);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validateBatchScope(projectId: string, requested: RegulatoryMappingBatchScope): Promise<RegulatoryMappingBatchScope> {
|
private async validateBatchScope(projectId: string, requested: RegulatoryMappingBatchScope): Promise<RegulatoryMappingBatchScope> {
|
||||||
|
|
@ -508,7 +629,7 @@ export class RegulatoryMappingService {
|
||||||
counts.distinctRuleCount = representatives.size;
|
counts.distinctRuleCount = representatives.size;
|
||||||
const scheduled: Array<Promise<'succeeded' | 'failed'>> = [];
|
const scheduled: Array<Promise<'succeeded' | 'failed'>> = [];
|
||||||
for (const finding of representatives.values()) {
|
for (const finding of representatives.values()) {
|
||||||
const claimed = await this.claim(finding, false);
|
const claimed = await this.claim(finding, false, 'batch');
|
||||||
if (!claimed.claimed) {
|
if (!claimed.claimed) {
|
||||||
if (claimed.run.status === 'succeeded') counts.reusedSucceededRuleCount += 1;
|
if (claimed.run.status === 'succeeded') counts.reusedSucceededRuleCount += 1;
|
||||||
else counts.reusedActiveRuleCount += 1;
|
else counts.reusedActiveRuleCount += 1;
|
||||||
|
|
@ -738,6 +859,15 @@ function isGpoRuleKey(ruleKey: string): boolean {
|
||||||
return ruleKey.startsWith('GPOs:') || ruleKey.startsWith('GPOs#');
|
return ruleKey.startsWith('GPOs:') || ruleKey.startsWith('GPOs#');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Content-free origin tokens of a client and its timeslot; equal inputs give equal tokens. */
|
||||||
|
function originTokens(projectId: string, scope: Pick<RegulatoryMappingBatchScope, 'assessmentIds' | 'scanRunIds'>): { project: string; timeslot: string[] } {
|
||||||
|
return {
|
||||||
|
project: sha256(`origin-project:${projectId}`),
|
||||||
|
timeslot: [...scope.assessmentIds.map(id => sha256(`origin-assessment:${projectId}:${id}`)),
|
||||||
|
...scope.scanRunIds.map(id => sha256(`origin-scan-run:${projectId}:${id}`))].sort()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function sha256(value: string): string {
|
function sha256(value: string): string {
|
||||||
return createHash('sha256').update(value, 'utf8').digest('hex');
|
return createHash('sha256').update(value, 'utf8').digest('hex');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { bsiIsoCrosswalk, bsiKernelCatalog } from '../../src/compliance/bsi-grundschutz.ts';
|
||||||
|
|
||||||
|
// Regenerates the pinned BSI snapshots from official files downloaded from these exact commit URLs.
|
||||||
|
const commit = process.argv[4];
|
||||||
|
const [catalogPath, crosswalkPath] = process.argv.slice(2);
|
||||||
|
if (!catalogPath || !crosswalkPath || !/^[0-9a-f]{40}$/.test(commit ?? '')) {
|
||||||
|
throw new Error('Usage: node --experimental-strip-types server/compliance/update-bsi-assets.mjs <Grundschutz++-resolved_catalog.json> <ISO27001-AnnexA-to-GS++-mapping_collection.json> <BSI commit sha>');
|
||||||
|
}
|
||||||
|
const base = `https://raw.githubusercontent.com/BSI-Bund/Stand-der-Technik-Bibliothek/${commit}/control_layer`;
|
||||||
|
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
|
||||||
|
|
||||||
|
const catalogBytes = await readFile(catalogPath);
|
||||||
|
const catalog = bsiKernelCatalog(JSON.parse(catalogBytes.toString('utf8')), `${base}/Grundschutz++/Grundschutz++-resolved_catalog.json`, sha256(catalogBytes));
|
||||||
|
const catalogOutput = `server/compliance/data/bsi-grundschutz-kernel-${catalog.version.slice(0, 10)}.json`;
|
||||||
|
await writeFile(catalogOutput, JSON.stringify(catalog) + '\n');
|
||||||
|
console.log(`${catalogOutput}: ${catalog.requirements.length} requirements`);
|
||||||
|
|
||||||
|
const crosswalkBytes = await readFile(crosswalkPath);
|
||||||
|
const crosswalk = bsiIsoCrosswalk(JSON.parse(crosswalkBytes.toString('utf8')),
|
||||||
|
`${base}/Mappings/ISO-27001-zu-GSpp/ISO27001-AnnexA-to-GS++-mapping_collection.json`, sha256(crosswalkBytes));
|
||||||
|
const crosswalkOutput = `server/compliance/data/bsi-iso27001-crosswalk-${crosswalk.version}.json`;
|
||||||
|
await writeFile(crosswalkOutput, JSON.stringify(crosswalk) + '\n');
|
||||||
|
console.log(`${crosswalkOutput}: ${crosswalk.entries.length} entries`);
|
||||||
|
|
@ -151,6 +151,7 @@ async function main(): Promise<void> {
|
||||||
aiOverviewService: complianceEngine.aiOverviewService,
|
aiOverviewService: complianceEngine.aiOverviewService,
|
||||||
regulatoryMappingService: complianceEngine.mappingService,
|
regulatoryMappingService: complianceEngine.mappingService,
|
||||||
regulatoryCorpusUploadService: complianceEngine.corpusUploadService,
|
regulatoryCorpusUploadService: complianceEngine.corpusUploadService,
|
||||||
|
bsiCatalogService: complianceEngine.bsiCatalogService,
|
||||||
complianceExport: complianceEngine.store,
|
complianceExport: complianceEngine.store,
|
||||||
isoUploadToken: process.env.ASSESSOR_ISO_UPLOAD_TOKEN?.trim(),
|
isoUploadToken: process.env.ASSESSOR_ISO_UPLOAD_TOKEN?.trim(),
|
||||||
automaticGpoAnalysis,
|
automaticGpoAnalysis,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import type { ClientInformationQuery, ClientInformationResult } from '../domain/
|
||||||
import type { ImportedDataset } from '../domain/dataset';
|
import type { ImportedDataset } from '../domain/dataset';
|
||||||
import type { MicrosoftSecurityBaselineSelectionContext } from '../domain/microsoft-security-baseline';
|
import type { MicrosoftSecurityBaselineSelectionContext } from '../domain/microsoft-security-baseline';
|
||||||
import type { RegulatoryCorpusStatus } from '../compliance/regulatory-corpus-management';
|
import type { RegulatoryCorpusStatus } from '../compliance/regulatory-corpus-management';
|
||||||
|
import type { BsiCatalogRefreshResult, BsiCatalogState } from '../compliance/bsi-catalog-management';
|
||||||
import type { ImportBatchPreview, ImportBatchSelection } from '../domain/import-workflow';
|
import type { ImportBatchPreview, ImportBatchSelection } from '../domain/import-workflow';
|
||||||
import type { ResultDbMaterializationStatus } from '../domain/result-db-materialization';
|
import type { ResultDbMaterializationStatus } from '../domain/result-db-materialization';
|
||||||
import {
|
import {
|
||||||
|
|
@ -480,10 +481,26 @@ export class AssessorApiClient {
|
||||||
return this.request('/reference-data/microsoft-security-baselines');
|
return this.request('/reference-data/microsoft-security-baselines');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getBsiCatalog(): Promise<BsiCatalogState> {
|
||||||
|
return this.request('/reference-data/bsi-grundschutz');
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshBsiCatalog(): Promise<BsiCatalogRefreshResult> {
|
||||||
|
return this.request('/reference-data/bsi-grundschutz/refresh', { method: 'POST', body: '{}' });
|
||||||
|
}
|
||||||
|
|
||||||
|
resetBsiCatalog(): Promise<BsiCatalogState> {
|
||||||
|
return this.request('/reference-data/bsi-grundschutz/reset', { method: 'POST', body: '{}' });
|
||||||
|
}
|
||||||
|
|
||||||
getIsoCorpusStatus(): Promise<RegulatoryCorpusStatus> {
|
getIsoCorpusStatus(): Promise<RegulatoryCorpusStatus> {
|
||||||
return this.request('/reference-data/iso-corpus');
|
return this.request('/reference-data/iso-corpus');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsoAutoMapping(enabled: boolean): Promise<RegulatoryCorpusStatus> {
|
||||||
|
return this.request('/reference-data/iso-corpus/auto-mapping', { method: 'PUT', body: JSON.stringify({ enabled }) });
|
||||||
|
}
|
||||||
|
|
||||||
async submitIsoCorpus(iso27001: File, iso27002: File, token: string): Promise<RegulatoryCorpusStatus> {
|
async submitIsoCorpus(iso27001: File, iso27002: File, token: string): Promise<RegulatoryCorpusStatus> {
|
||||||
const [first, second] = await Promise.all([iso27001.arrayBuffer(), iso27002.arrayBuffer()]);
|
const [first, second] = await Promise.all([iso27001.arrayBuffer(), iso27002.arrayBuffer()]);
|
||||||
return this.request('/reference-data/iso-corpus', {
|
return this.request('/reference-data/iso-corpus', {
|
||||||
|
|
|
||||||
|
|
@ -35,5 +35,8 @@ function complianceActivity(route: string, method: string): string | undefined {
|
||||||
if (route.includes('/regulatory-mapping-decisions')) return 'Saving ISO mapping decision';
|
if (route.includes('/regulatory-mapping-decisions')) return 'Saving ISO mapping decision';
|
||||||
if (route.includes('/regulatory-mappings/')) return 'Loading ISO mapping';
|
if (route.includes('/regulatory-mappings/')) return 'Loading ISO mapping';
|
||||||
if (route.endsWith('/reference-data/iso-corpus')) return write ? 'Uploading ISO corpus' : 'Loading ISO corpus status';
|
if (route.endsWith('/reference-data/iso-corpus')) return write ? 'Uploading ISO corpus' : 'Loading ISO corpus status';
|
||||||
|
if (route.endsWith('/reference-data/bsi-grundschutz/refresh')) return 'Updating BSI catalog';
|
||||||
|
if (route.endsWith('/reference-data/bsi-grundschutz/reset')) return 'Restoring bundled BSI catalog';
|
||||||
|
if (route.endsWith('/reference-data/bsi-grundschutz')) return 'Loading BSI catalog';
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,11 @@ function subscribe(listener: () => void) {
|
||||||
return () => { listeners.delete(listener); window.removeEventListener('storage', changed); };
|
return () => { listeners.delete(listener); window.removeEventListener('storage', changed); };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Calls the listener on every engine choice, including one made in another tab; returns the unsubscribe. */
|
||||||
|
export function subscribeEnginePreferences(listener: () => void): () => void {
|
||||||
|
return subscribe(listener);
|
||||||
|
}
|
||||||
|
|
||||||
export function useEnginePreferences(): Preferences {
|
export function useEnginePreferences(): Preferences {
|
||||||
return useSyncExternalStore(subscribe, readEnginePreferences, () => defaults);
|
return useSyncExternalStore(subscribe, readEnginePreferences, () => defaults);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
/** Data Hub BSI catalog tile: which BSI Kernel catalog and ISO 27001 mapping are active, and where from. */
|
||||||
|
export interface BsiCatalogState {
|
||||||
|
version: 1;
|
||||||
|
/** `bundled`: the pinned snapshots shipped with ASSESSOR; `bsi`: stored by `Update from BSI`. */
|
||||||
|
origin: 'bundled' | 'bsi';
|
||||||
|
updatedAt?: string;
|
||||||
|
catalogVersion: string;
|
||||||
|
crosswalkVersion: string;
|
||||||
|
requirementCount: number;
|
||||||
|
crosswalkEntryCount: number;
|
||||||
|
catalogSha256: string;
|
||||||
|
crosswalkSha256: string;
|
||||||
|
/** Mapping targets the active catalog does not contain; they are reported, never reinterpreted. */
|
||||||
|
missingTargets: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BsiCatalogRefreshResult extends BsiCatalogState {
|
||||||
|
updated: boolean;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,284 @@
|
||||||
|
/**
|
||||||
|
* BSI Grundschutz++ for the Compliance Engine (Phase 13F, user decision 2026-10-08): the Kernel requirements of
|
||||||
|
* the BSI Stand-der-Technik-Bibliothek and the official BSI mapping ISO/IEC 27001:2022 Annex A -> Grundschutz++,
|
||||||
|
* converted into compact, version-pinned snapshots like the MITRE reference catalogs. Source prose is kept as
|
||||||
|
* published, including its OSCAL parameter placeholders; `bsiRequirementText` resolves them for display.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const BSI_KERNEL_CLASS = 'BSI-Stand-der-Technik-Kernel-G0';
|
||||||
|
export const BSI_NOTICE = 'Source: German Federal Office for Information Security (BSI), Stand-der-Technik-Bibliothek, '
|
||||||
|
+ 'https://github.com/BSI-Bund/Stand-der-Technik-Bibliothek. License: Creative Commons Attribution-ShareAlike 4.0 International '
|
||||||
|
+ '(CC BY-SA 4.0), https://creativecommons.org/licenses/by-sa/4.0/. Compacted; the requirement content is unchanged.';
|
||||||
|
|
||||||
|
export type BsiCrosswalkRelationship = 'equivalent-to' | 'subset-of' | 'superset-of' | 'intersects-with';
|
||||||
|
|
||||||
|
export interface BsiRequirement {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
/** Top-level practice of the requirement, for example `KONF`. */
|
||||||
|
practice: string;
|
||||||
|
parentId?: string;
|
||||||
|
/** Statement prose exactly as published, including `{{ insert: param, <id> }}` placeholders. */
|
||||||
|
statement: string;
|
||||||
|
modalVerb?: string;
|
||||||
|
guidance?: string;
|
||||||
|
securityLevel?: string;
|
||||||
|
effortLevel?: string;
|
||||||
|
/** Parameter id -> its published value, or its label where no value is set. */
|
||||||
|
params?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BsiKernelCatalog {
|
||||||
|
schemaVersion: 1;
|
||||||
|
framework: 'bsi-grundschutz-plus-plus';
|
||||||
|
catalogClass: typeof BSI_KERNEL_CLASS;
|
||||||
|
title: string;
|
||||||
|
version: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
sourceSha256: string;
|
||||||
|
license: string;
|
||||||
|
practices: Array<{ id: string; title: string }>;
|
||||||
|
requirements: BsiRequirement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BsiCrosswalkEntry {
|
||||||
|
uuid: string;
|
||||||
|
isoControlId: string;
|
||||||
|
relationship: BsiCrosswalkRelationship;
|
||||||
|
targets: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BsiIsoCrosswalk {
|
||||||
|
schemaVersion: 1;
|
||||||
|
framework: 'bsi-iso27001-annex-a-to-grundschutz-plus-plus';
|
||||||
|
title: string;
|
||||||
|
version: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
sourceSha256: string;
|
||||||
|
license: string;
|
||||||
|
/** Only the mapping whose target is the Kernel catalog; the Methodik mapping is outside the decided scope. */
|
||||||
|
targetCatalog: string;
|
||||||
|
entries: BsiCrosswalkEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ObjectValue = Record<string, unknown>;
|
||||||
|
const object = (value: unknown): ObjectValue => value && typeof value === 'object' && !Array.isArray(value) ? value as ObjectValue : {};
|
||||||
|
const list = (value: unknown): unknown[] => Array.isArray(value) ? value : [];
|
||||||
|
const text = (value: unknown): string => typeof value === 'string' ? value : '';
|
||||||
|
const prop = (props: unknown, name: string): string | undefined => list(props).map(object).find(item => item.name === name)?.value as string | undefined;
|
||||||
|
|
||||||
|
const REQUIREMENT_ID = /^[A-Z]{2,5}(?:\.\d+)+$/;
|
||||||
|
const PARAM_INSERT = /\{\{\s*insert:\s*param,\s*([^\s}]+)\s*\}\}/g;
|
||||||
|
const RELATIONSHIPS = new Set<string>(['equivalent-to', 'subset-of', 'superset-of', 'intersects-with']);
|
||||||
|
|
||||||
|
export function bsiKernelCatalog(raw: unknown, sourceUrl: string, sourceSha256: string): BsiKernelCatalog {
|
||||||
|
const catalog = object(object(raw).catalog);
|
||||||
|
const metadata = object(catalog.metadata);
|
||||||
|
const version = text(metadata.version);
|
||||||
|
if (!version || !Array.isArray(catalog.groups)) throw new Error('Expected an OSCAL Grundschutz++ catalog with a version.');
|
||||||
|
const practices: BsiKernelCatalog['practices'] = [];
|
||||||
|
const requirements: BsiRequirement[] = [];
|
||||||
|
const visitControls = (controls: unknown, practice: string, parentId?: string) => {
|
||||||
|
for (const control of list(controls).map(object)) {
|
||||||
|
const id = text(control.id);
|
||||||
|
if (control.class === BSI_KERNEL_CLASS) {
|
||||||
|
if (!REQUIREMENT_ID.test(id)) throw new Error(`Invalid Kernel requirement id: ${id || '(missing)'}`);
|
||||||
|
const parts = list(control.parts).map(object);
|
||||||
|
const statementPart = parts.find(part => part.name === 'statement');
|
||||||
|
const statement = text(statementPart?.prose);
|
||||||
|
if (!statement) throw new Error(`Kernel requirement ${id} has no statement.`);
|
||||||
|
const params = Object.fromEntries(list(control.params).map(object).map(param => {
|
||||||
|
const values = list(param.values).filter((value): value is string => typeof value === 'string');
|
||||||
|
return [text(param.id), values.length === 1 ? values[0] : text(param.label)];
|
||||||
|
}));
|
||||||
|
for (const part of parts) {
|
||||||
|
for (const match of text(part.prose).matchAll(PARAM_INSERT)) {
|
||||||
|
if (!params[match[1]]) throw new Error(`Kernel requirement ${id} inserts unknown parameter ${match[1]}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const guidance = text(parts.find(part => part.name === 'guidance')?.prose);
|
||||||
|
requirements.push({
|
||||||
|
id, title: text(control.title), practice, ...(parentId ? { parentId } : {}), statement,
|
||||||
|
...optional('modalVerb', prop(statementPart?.props, 'modal_verb')),
|
||||||
|
...optional('guidance', guidance || undefined),
|
||||||
|
...optional('securityLevel', prop(control.props, 'sec_level')),
|
||||||
|
...optional('effortLevel', prop(control.props, 'effort_level')),
|
||||||
|
...(Object.keys(params).length ? { params } : {})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
visitControls(control.controls, practice, id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const visitGroups = (groups: unknown, practice?: string) => {
|
||||||
|
for (const group of list(groups).map(object)) {
|
||||||
|
const own = practice ?? text(group.id);
|
||||||
|
if (!practice) practices.push({ id: own, title: text(group.title) });
|
||||||
|
visitControls(group.controls, own);
|
||||||
|
visitGroups(group.groups, own);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visitGroups(catalog.groups);
|
||||||
|
if (!requirements.length) throw new Error('The catalog contains no Kernel requirements.');
|
||||||
|
const ids = requirements.map(item => item.id);
|
||||||
|
const repeated = ids.filter((id, index) => ids.indexOf(id) !== index);
|
||||||
|
if (repeated.length) throw new Error(`Repeated Kernel requirement ids: ${[...new Set(repeated)].join(', ')}`);
|
||||||
|
const used = new Set(requirements.map(item => item.practice));
|
||||||
|
return {
|
||||||
|
schemaVersion: 1, framework: 'bsi-grundschutz-plus-plus', catalogClass: BSI_KERNEL_CLASS, title: text(metadata.title), version,
|
||||||
|
sourceUrl, sourceSha256, license: BSI_NOTICE, practices: practices.filter(item => used.has(item.id)), requirements
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bsiIsoCrosswalk(raw: unknown, sourceUrl: string, sourceSha256: string): BsiIsoCrosswalk {
|
||||||
|
const collection = object(object(raw)['mapping-collection']);
|
||||||
|
const metadata = object(collection.metadata);
|
||||||
|
const version = text(metadata.version);
|
||||||
|
if (!version || !Array.isArray(collection.mappings)) throw new Error('Expected an OSCAL mapping collection with a version.');
|
||||||
|
const kernel = collection.mappings.map(object).filter(mapping => /Kernel/i.test(text(object(mapping['target-resource']).href)));
|
||||||
|
if (kernel.length !== 1) throw new Error(`Expected exactly one mapping to the Kernel catalog, found ${kernel.length}.`);
|
||||||
|
const entries = list(kernel[0].maps).map(object).map((map): BsiCrosswalkEntry => {
|
||||||
|
const relationship = text(map.relationship);
|
||||||
|
const sources = list(map.sources).map(object).map(source => text(source['id-ref']));
|
||||||
|
const targets = list(map.targets).map(object).map(target => text(target['id-ref']));
|
||||||
|
if (!RELATIONSHIPS.has(relationship)) throw new Error(`Unknown mapping relationship: ${relationship || '(missing)'}`);
|
||||||
|
if (sources.length !== 1 || !/^[5-8]\.\d+$/.test(sources[0])) throw new Error(`Expected one ISO Annex A source control, found ${sources.join(', ') || 'none'}.`);
|
||||||
|
if (!targets.length || targets.some(target => !REQUIREMENT_ID.test(target))) throw new Error(`Invalid mapping targets for ISO ${sources[0]}.`);
|
||||||
|
return { uuid: text(map.uuid), isoControlId: sources[0], relationship: relationship as BsiCrosswalkRelationship, targets };
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
schemaVersion: 1, framework: 'bsi-iso27001-annex-a-to-grundschutz-plus-plus', title: text(metadata.title), version,
|
||||||
|
sourceUrl, sourceSha256, license: BSI_NOTICE, targetCatalog: text(object(kernel[0]['target-resource']).href), entries
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Statement or guidance prose with each parameter placeholder replaced by its published value or label. */
|
||||||
|
export function bsiRequirementText(prose: string, params: Record<string, string> | undefined): string {
|
||||||
|
return prose.replace(PARAM_INSERT, (placeholder, id: string) => params?.[id] ?? placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BsiDerivedRequirement {
|
||||||
|
requirementId: string;
|
||||||
|
title: string;
|
||||||
|
practice: string;
|
||||||
|
/** Every accepted ISO control and relationship through which the requirement was reached. */
|
||||||
|
via: Array<{ isoControlId: string; relationship: BsiCrosswalkRelationship }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BsiDerivation {
|
||||||
|
crosswalkVersion: string;
|
||||||
|
catalogVersion: string;
|
||||||
|
requirements: BsiDerivedRequirement[];
|
||||||
|
/** Mapping targets that the pinned Kernel catalog does not contain. They are reported, never reinterpreted. */
|
||||||
|
missingTargets: Array<{ isoControlId: string; requirementId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage 1 (user decision 2026-10-08): the Kernel requirements the official BSI mapping assigns to accepted ISO
|
||||||
|
* controls. Derived only, never a decision of its own. Output is sorted, so the input order does not matter.
|
||||||
|
*/
|
||||||
|
export function deriveBsiRequirements(acceptedIsoControlIds: readonly string[], crosswalk: BsiIsoCrosswalk, catalog: BsiKernelCatalog): BsiDerivation {
|
||||||
|
const accepted = new Set(acceptedIsoControlIds);
|
||||||
|
const byId = new Map(catalog.requirements.map(item => [item.id, item]));
|
||||||
|
const derived = new Map<string, BsiDerivedRequirement>();
|
||||||
|
const missing = new Map<string, { isoControlId: string; requirementId: string }>();
|
||||||
|
for (const entry of crosswalk.entries) {
|
||||||
|
if (!accepted.has(entry.isoControlId)) continue;
|
||||||
|
for (const target of entry.targets) {
|
||||||
|
const requirement = byId.get(target);
|
||||||
|
if (!requirement) {
|
||||||
|
missing.set(`${entry.isoControlId}\u0000${target}`, { isoControlId: entry.isoControlId, requirementId: target });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const item = derived.get(target) ?? { requirementId: target, title: requirement.title, practice: requirement.practice, via: [] };
|
||||||
|
if (!item.via.some(via => via.isoControlId === entry.isoControlId && via.relationship === entry.relationship)) {
|
||||||
|
item.via.push({ isoControlId: entry.isoControlId, relationship: entry.relationship });
|
||||||
|
}
|
||||||
|
derived.set(target, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const order = (a: string, b: string) => a.localeCompare(b, 'en', { numeric: true });
|
||||||
|
return {
|
||||||
|
crosswalkVersion: crosswalk.version,
|
||||||
|
catalogVersion: catalog.version,
|
||||||
|
requirements: [...derived.values()]
|
||||||
|
.map(item => ({ ...item, via: item.via.sort((a, b) => order(a.isoControlId, b.isoControlId) || a.relationship.localeCompare(b.relationship)) }))
|
||||||
|
.sort((a, b) => order(a.requirementId, b.requirementId)),
|
||||||
|
missingTargets: [...missing.values()].sort((a, b) => order(a.isoControlId, b.isoControlId) || order(a.requirementId, b.requirementId))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BsiOverviewRequirement extends BsiDerivedRequirement {
|
||||||
|
/** Statement with its parameters filled in, for display. */
|
||||||
|
statement: string;
|
||||||
|
modalVerb?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The derived BSI requirements a response carries, with the catalog provenance and attribution the UI shows. */
|
||||||
|
export interface BsiOverview {
|
||||||
|
catalogVersion: string;
|
||||||
|
crosswalkVersion: string;
|
||||||
|
license: string;
|
||||||
|
/** Every practice of the catalog in catalog order, so the UI can order and title them without the catalog. */
|
||||||
|
practices: Array<{ id: string; title: string }>;
|
||||||
|
requirements: BsiOverviewRequirement[];
|
||||||
|
missingTargets: BsiDerivation['missingTargets'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stage 1 overview for accepted ISO controls; undefined when nothing is derived, so ISO-only responses stay unchanged. */
|
||||||
|
export function bsiOverview(acceptedIsoControlIds: readonly string[], crosswalk: BsiIsoCrosswalk, catalog: BsiKernelCatalog): BsiOverview | undefined {
|
||||||
|
const derivation = deriveBsiRequirements(acceptedIsoControlIds, crosswalk, catalog);
|
||||||
|
if (!derivation.requirements.length && !derivation.missingTargets.length) return undefined;
|
||||||
|
const byId = new Map(catalog.requirements.map(item => [item.id, item]));
|
||||||
|
return {
|
||||||
|
catalogVersion: catalog.version,
|
||||||
|
crosswalkVersion: crosswalk.version,
|
||||||
|
license: catalog.license,
|
||||||
|
practices: catalog.practices,
|
||||||
|
requirements: derivation.requirements.map(item => {
|
||||||
|
const requirement = byId.get(item.requirementId)!;
|
||||||
|
return { ...item, statement: bsiRequirementText(requirement.statement, requirement.params),
|
||||||
|
...(requirement.modalVerb ? { modalVerb: requirement.modalVerb } : {}) };
|
||||||
|
}),
|
||||||
|
missingTargets: derivation.missingTargets
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every Kernel requirement by practice in catalog order with its title (CC BY-SA 4.0), and whether the BSI mapping can reach it from any ISO control. */
|
||||||
|
export interface BsiFootprint {
|
||||||
|
catalogVersion: string;
|
||||||
|
crosswalkVersion: string;
|
||||||
|
practices: Array<{ id: string; title: string; requirements: Array<{ id: string; title: string; reachable: boolean }> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Client Info footprint grid; independent of decisions, so it exists before anything is derived. */
|
||||||
|
export function bsiFootprint(crosswalk: BsiIsoCrosswalk, catalog: BsiKernelCatalog): BsiFootprint {
|
||||||
|
const reachable = new Set(crosswalk.entries.flatMap(entry => entry.targets));
|
||||||
|
return {
|
||||||
|
catalogVersion: catalog.version,
|
||||||
|
crosswalkVersion: crosswalk.version,
|
||||||
|
practices: catalog.practices.map(practice => ({ ...practice, requirements: catalog.requirements
|
||||||
|
.filter(requirement => requirement.practice === practice.id)
|
||||||
|
.map(requirement => ({ id: requirement.id, title: requirement.title, reachable: reachable.has(requirement.id) })) }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The findings of each derived requirement: every finding of an accepted ISO control the requirement was
|
||||||
|
* reached through, once per Result DB row, like the ISO theme counts.
|
||||||
|
*/
|
||||||
|
export function bsiRequirementFindings<F extends { resultProjectionId: string }>(
|
||||||
|
controls: ReadonlyArray<{ controlId: string; findings: readonly F[] }>, overview: BsiOverview | undefined
|
||||||
|
): Map<string, F[]> {
|
||||||
|
const byControl = new Map(controls.map(control => [control.controlId, control.findings]));
|
||||||
|
return new Map((overview?.requirements ?? []).map(requirement => {
|
||||||
|
const findings = new Map<string, F>();
|
||||||
|
for (const via of requirement.via) {
|
||||||
|
for (const finding of byControl.get(via.isoControlId) ?? []) if (!findings.has(finding.resultProjectionId)) findings.set(finding.resultProjectionId, finding);
|
||||||
|
}
|
||||||
|
return [requirement.requirementId, [...findings.values()]];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function optional<K extends string>(key: K, value: string | undefined): Partial<Record<K, string>> {
|
||||||
|
return value ? { [key]: value } as Record<K, string> : {};
|
||||||
|
}
|
||||||
|
|
@ -93,8 +93,10 @@ export class LocalComplianceStore implements ComplianceStore {
|
||||||
if (!run || run.ruleKey !== decision.ruleKey || run.status !== 'succeeded') {
|
if (!run || run.ruleKey !== decision.ruleKey || run.status !== 'succeeded') {
|
||||||
throw new RepositoryNotFoundError('Completed regulatory mapping does not exist for this rule.');
|
throw new RepositoryNotFoundError('Completed regulatory mapping does not exist for this rule.');
|
||||||
}
|
}
|
||||||
if (!run.candidates.some(candidate => candidate.controlId === decision.controlId)) throw new RepositoryValidationError('Decision control was not a grounded candidate.');
|
|
||||||
const latest = this.decisions.filter(item => item.ruleKey === decision.ruleKey && item.controlId === decision.controlId).sort(newestFirst)[0];
|
const latest = this.decisions.filter(item => item.ruleKey === decision.ruleKey && item.controlId === decision.controlId).sort(newestFirst)[0];
|
||||||
|
// A control with an earlier decision stays decidable after a remap no longer retrieves it; the chain of
|
||||||
|
// previous decisions leads back to the run that grounded it.
|
||||||
|
if (!latest && !run.candidates.some(candidate => candidate.controlId === decision.controlId)) throw new RepositoryValidationError('Decision control was not a grounded candidate.');
|
||||||
if ((expectedLatestDecisionId ?? undefined) !== (latest?.id ?? undefined)) throw new RepositoryConflictError('Regulatory mapping decision changed before this update.');
|
if ((expectedLatestDecisionId ?? undefined) !== (latest?.id ?? undefined)) throw new RepositoryConflictError('Regulatory mapping decision changed before this update.');
|
||||||
const stored = clone({ ...decision, ...(latest ? { previousDecisionId: latest.id } : {}) });
|
const stored = clone({ ...decision, ...(latest ? { previousDecisionId: latest.id } : {}) });
|
||||||
this.decisions.push(stored);
|
this.decisions.push(stored);
|
||||||
|
|
|
||||||
|
|
@ -27,4 +27,6 @@ export interface RegulatoryCorpusStatus {
|
||||||
active: RegulatoryCorpusSummary | null;
|
active: RegulatoryCorpusSummary | null;
|
||||||
indexStatus?: 'ready' | 'incomplete' | 'unavailable';
|
indexStatus?: 'ready' | 'incomplete' | 'unavailable';
|
||||||
job: RegulatoryCorpusJob | null;
|
job: RegulatoryCorpusJob | null;
|
||||||
|
/** Data Hub switch: map the deviations of newly uploaded scans automatically. */
|
||||||
|
autoMapping?: { enabled: boolean };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { AiOverviewTokenUsage } from './ai-overview';
|
import type { AiOverviewTokenUsage } from './ai-overview';
|
||||||
|
import type { BsiFootprint, BsiOverview } from './bsi-grundschutz';
|
||||||
import type { RegulatoryFindingQuery, RegulatorySectionKind } from './regulatory-rag';
|
import type { RegulatoryFindingQuery, RegulatorySectionKind } from './regulatory-rag';
|
||||||
import type { ResultDbFilters } from '../domain/result-db-query';
|
import type { ResultDbFilters } from '../domain/result-db-query';
|
||||||
import type { UnifiedResultProjection } from '../domain/unified-result';
|
import type { UnifiedResultProjection } from '../domain/unified-result';
|
||||||
|
|
@ -127,6 +128,13 @@ export interface RegulatoryMappingRun {
|
||||||
promptVersion: string;
|
promptVersion: string;
|
||||||
outputSchemaVersion: 1;
|
outputSchemaVersion: 1;
|
||||||
model: string;
|
model: string;
|
||||||
|
/** How the run was started: a single finding (`Map ISO controls`) or a batch. Absent on runs before 2.21.0. */
|
||||||
|
trigger?: 'single' | 'batch';
|
||||||
|
/**
|
||||||
|
* Where a single mapping was started, as SHA-256 tokens of the client and of each assessment and scan run of
|
||||||
|
* its timeslot. Runs are global and exported with every client, so no client or timeslot id is stored.
|
||||||
|
*/
|
||||||
|
origin?: { project: string; timeslot: string[] };
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
|
|
@ -190,6 +198,28 @@ export interface RegulatoryControlOverviewItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Accepted controls of the deviations in one client timeslot; decisions stay global per scanner rule. */
|
/** Accepted controls of the deviations in one client timeslot; decisions stay global per scanner rule. */
|
||||||
|
/** Idempotency-key prefixes of batches the automatic mapping starts; every other batch was started manually. */
|
||||||
|
export const AUTO_MAPPING_BATCH_KEY_PREFIXES = ['auto:', 'auto-gpo:'] as const;
|
||||||
|
export const MAPPING_HISTORY_LIMIT = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content-free entry of the Client Info `Last mapping` window, listed globally: a batch of any client or a
|
||||||
|
* single finding mapping, which is global per rule.
|
||||||
|
*/
|
||||||
|
export interface RegulatoryMappingHistoryEntry {
|
||||||
|
id: string;
|
||||||
|
kind: 'batch' | 'single';
|
||||||
|
/** Where the mapping was started, relative to the shown timeslot; `unknown` for a single mapping without a recorded origin. */
|
||||||
|
scope: 'timeslot' | 'other-timeslot' | 'other-client' | 'unknown';
|
||||||
|
/** Automatic only for batches the auto-mapping watcher started. */
|
||||||
|
trigger: 'automatic' | 'manual';
|
||||||
|
status: RegulatoryMappingBatchStatus;
|
||||||
|
createdAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
/** `findings` only for a batch: the eligible findings of its own scope. */
|
||||||
|
counts: { findings?: number; sentToModel: number; reused: number; succeeded: number; failed: number };
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegulatoryControlOverviewResponse {
|
export interface RegulatoryControlOverviewResponse {
|
||||||
version: 1;
|
version: 1;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -197,8 +227,37 @@ export interface RegulatoryControlOverviewResponse {
|
||||||
/** Distinct findings that carry at least one accepted control. */
|
/** Distinct findings that carry at least one accepted control. */
|
||||||
findingCount: number;
|
findingCount: number;
|
||||||
controls: RegulatoryControlOverviewItem[];
|
controls: RegulatoryControlOverviewItem[];
|
||||||
|
/** Newest first, at most MAPPING_HISTORY_LIMIT; only on the Client Info overview. */
|
||||||
|
mappingHistory?: RegulatoryMappingHistoryEntry[];
|
||||||
|
/** BSI Kernel requirements derived from the accepted controls above; absent when none is derived. */
|
||||||
|
bsi?: BsiOverview;
|
||||||
|
/** How far the mappable deviations of the timeslot are mapped; only on the Client Info overview. */
|
||||||
|
coverage?: RegulatoryMappingCoverage;
|
||||||
|
/** Mappable findings whose rule has no accepted control and whose latest mapping failed; only on the Work assignments. */
|
||||||
|
failedFindings?: RegulatoryControlFinding[];
|
||||||
|
/** Annex A control title by control id from the active licensed corpus, for the footprint; only on the Client Info overview. */
|
||||||
|
annexATitles?: Record<string, string>;
|
||||||
|
/** Every BSI Kernel requirement for the BSI footprint; only on the Client Info overview. */
|
||||||
|
bsiFootprint?: BsiFootprint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content-free counts of the mappable deviations of one timeslot, each finding in exactly one state by its rule:
|
||||||
|
* an accepted control, else a succeeded mapping without one, else a failed latest mapping, else not mapped yet
|
||||||
|
* (including queued and running). `accepted` equals the overview's `findingCount`.
|
||||||
|
*/
|
||||||
|
export interface RegulatoryMappingCoverage {
|
||||||
|
deviations: number;
|
||||||
|
accepted: number;
|
||||||
|
mappedNoneAccepted: number;
|
||||||
|
failed: number;
|
||||||
|
notMapped: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fixed number of controls per ISO/IEC 27002:2022 theme (clauses 5 to 8, 93 controls). */
|
||||||
|
export const ANNEX_A_CONTROL_COUNTS: Readonly<Record<RegulatoryControlTheme, number>> = { Organizational: 37, People: 8, Physical: 14, Technological: 34 };
|
||||||
|
export const ANNEX_A_THEME_CLAUSES: Readonly<Record<RegulatoryControlTheme, number>> = { Organizational: 5, People: 6, Physical: 7, Technological: 8 };
|
||||||
|
|
||||||
export interface RegulatoryMappingResponse {
|
export interface RegulatoryMappingResponse {
|
||||||
version: 1;
|
version: 1;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -214,6 +273,8 @@ export interface RegulatoryMappingResponse {
|
||||||
* version change), so an earlier accept stays reviewable. Titles come from older runs of the rule.
|
* version change), so an earlier accept stays reviewable. Titles come from older runs of the rule.
|
||||||
*/
|
*/
|
||||||
earlierControls?: RegulatoryEarlierControl[];
|
earlierControls?: RegulatoryEarlierControl[];
|
||||||
|
/** BSI Kernel requirements derived from this rule's accepted controls; absent when none is derived. */
|
||||||
|
bsi?: BsiOverview;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegulatoryEarlierControl {
|
export interface RegulatoryEarlierControl {
|
||||||
|
|
@ -224,6 +285,8 @@ export interface RegulatoryEarlierControl {
|
||||||
|
|
||||||
export interface RegulatoryMappingCreateRequest {
|
export interface RegulatoryMappingCreateRequest {
|
||||||
resultProjectionId: string;
|
resultProjectionId: string;
|
||||||
|
/** The timeslot the Finding Reader shows; the run keeps it only as hashes, for the mapping history scope. */
|
||||||
|
origin?: { assessmentIds: string[]; scanRunIds: string[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegulatoryMappingDecisionCreateRequest {
|
export interface RegulatoryMappingDecisionCreateRequest {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AssessorApiClient } from '../../api/assessor-api-client';
|
||||||
|
import { useEnginePreferences } from '../../app/engine-preferences';
|
||||||
|
import type { BsiCatalogState } from '../bsi-catalog-management';
|
||||||
|
import { markRegulatoryDecisionSaved } from './decision-revision';
|
||||||
|
|
||||||
|
const api = new AssessorApiClient();
|
||||||
|
|
||||||
|
/** Data Hub BSI catalog tile, like the MITRE matrix tile; a switched-off Compliance Engine renders and requests nothing. */
|
||||||
|
export function BsiCatalogLibrary() {
|
||||||
|
return useEnginePreferences().compliance ? <BsiCatalogLibraryPanel/> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const day = (version: string) => version.slice(0, 10);
|
||||||
|
|
||||||
|
function BsiCatalogLibraryPanel() {
|
||||||
|
const [status, setStatus] = useState<BsiCatalogState | null>(null);
|
||||||
|
const [busy, setBusy] = useState<'refresh' | 'reset' | null>(null);
|
||||||
|
const [feedback, setFeedback] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
// Any failure stays inside this tile; Data Hub keeps working.
|
||||||
|
void (async () => {
|
||||||
|
try { const value = await api.getBsiCatalog(); if (active) setStatus(value); }
|
||||||
|
catch (reason) { if (active) setError(reason instanceof Error ? reason.message : 'The BSI catalog status could not be loaded.'); }
|
||||||
|
})();
|
||||||
|
return () => { active = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const run = async (action: 'refresh' | 'reset') => {
|
||||||
|
setBusy(action); setFeedback(null); setError(null);
|
||||||
|
try {
|
||||||
|
if (action === 'refresh') {
|
||||||
|
const result = await api.refreshBsiCatalog();
|
||||||
|
setStatus(result);
|
||||||
|
const missing = result.missingTargets.length ? ` ${result.missingTargets.length} mapping targets are not in the catalog.` : '';
|
||||||
|
setFeedback(result.updated ? `Updated to catalog ${day(result.catalogVersion)} and mapping ${result.crosswalkVersion}.${missing}` : 'The BSI catalog is up to date.');
|
||||||
|
if (result.updated) markRegulatoryDecisionSaved();
|
||||||
|
} else {
|
||||||
|
setStatus(await api.resetBsiCatalog());
|
||||||
|
setFeedback('The bundled BSI catalog is active again.');
|
||||||
|
markRegulatoryDecisionSaved();
|
||||||
|
}
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : 'The BSI catalog could not be updated.');
|
||||||
|
} finally { setBusy(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return <section className="datahub-rack-module datahub-reference-module bsi-catalog-library" data-bsi-catalog>
|
||||||
|
<header className="datahub-rack-header">
|
||||||
|
<span className="datahub-grip" aria-hidden="true">⠿</span>
|
||||||
|
<i className="datahub-led" aria-hidden="true"/>
|
||||||
|
<strong>BSI GRUNDSCHUTZ++ KERNEL</strong>
|
||||||
|
<span className="datahub-rack-badge">{status
|
||||||
|
? `${day(status.catalogVersion)} · Mapping ${status.crosswalkVersion} · ${status.origin === 'bsi' ? 'Updated' : 'Bundled'}`
|
||||||
|
: 'Loading…'}</span>
|
||||||
|
</header>
|
||||||
|
<div className="datahub-rack-body">
|
||||||
|
<div className="datahub-rack-actions">
|
||||||
|
<button className="btn datahub-maintenance-action" type="button" disabled={busy !== null || !status} onClick={() => void run('refresh')}>
|
||||||
|
{busy === 'refresh' ? 'Checking BSI…' : 'Update from BSI'}
|
||||||
|
</button>
|
||||||
|
{status?.origin === 'bsi' && <button className="btn" type="button" disabled={busy !== null} onClick={() => void run('reset')}>
|
||||||
|
{busy === 'reset' ? 'Restoring…' : 'Use bundled catalog'}
|
||||||
|
</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{feedback && <div className="tenant-save-feedback success" role="status"><span aria-hidden="true">✓</span><strong>{feedback}</strong></div>}
|
||||||
|
{error && <div className="datahub-alert" role="alert"><span>{error}</span></div>}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
import { useEffect, useId, useState } from 'react';
|
import { useEffect, useId, useState, type ReactNode } from 'react';
|
||||||
import { AssessorApiClient } from '../../api/assessor-api-client';
|
import { AssessorApiClient } from '../../api/assessor-api-client';
|
||||||
import type { BackendStatus } from '../../app/app-state';
|
import type { BackendStatus } from '../../app/app-state';
|
||||||
import { useEnginePreferences } from '../../app/engine-preferences';
|
import { useEnginePreferences } from '../../app/engine-preferences';
|
||||||
import { DashboardGlyph, type DashboardFact } from '../../features/dashboard/ClientTileParts';
|
import { DashboardGlyph, type DashboardFact } from '../../features/dashboard/ClientTileParts';
|
||||||
import { EnvironmentResultsButton } from '../../features/dashboard/EnvironmentResultsButton';
|
import { EnvironmentResultsButton } from '../../features/dashboard/EnvironmentResultsButton';
|
||||||
import type { RegulatoryControlOverviewItem, RegulatoryControlOverviewResponse, RegulatoryControlTheme } from '../regulatory-mapping';
|
import type { RegulatoryControlOverviewItem, RegulatoryControlOverviewResponse, RegulatoryControlTheme, RegulatoryMappingHistoryEntry } from '../regulatory-mapping';
|
||||||
import { useRegulatoryDecisionRevision } from './decision-revision';
|
import { useRegulatoryDecisionRevision } from './decision-revision';
|
||||||
|
import { bsiRequirementFindings, type BsiOverview } from '../bsi-grundschutz';
|
||||||
|
import { ComplianceFrameworkSwitch, ComplianceOverview, type ComplianceFramework } from './ComplianceOverview';
|
||||||
|
import type { ComplianceMappingState } from './compliance-work-facets';
|
||||||
import './compliance.css';
|
import './compliance.css';
|
||||||
|
|
||||||
const assessorApi = new AssessorApiClient();
|
const assessorApi = new AssessorApiClient();
|
||||||
const THEMES: RegulatoryControlTheme[] = ['Organizational', 'People', 'Physical', 'Technological'];
|
const THEMES: RegulatoryControlTheme[] = ['Organizational', 'People', 'Physical', 'Technological'];
|
||||||
export const ISO_TILE_TITLE = 'ISO 27001/27002';
|
export const ISO_TILE_TITLE = 'ISO 27001/27002';
|
||||||
|
export const BSI_TILE_TITLE = 'BSI Grundschutz++';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
|
@ -21,6 +25,16 @@ interface Props {
|
||||||
onOpenControl(controlId: string): void;
|
onOpenControl(controlId: string): void;
|
||||||
/** Opens Work filtered to this ISO theme. */
|
/** Opens Work filtered to this ISO theme. */
|
||||||
onOpenTheme(theme: RegulatoryControlTheme): void;
|
onOpenTheme(theme: RegulatoryControlTheme): void;
|
||||||
|
/** Opens Work filtered to this derived BSI requirement. */
|
||||||
|
onOpenBsiRequirement(requirementId: string): void;
|
||||||
|
/** Opens Work filtered to this BSI practice. */
|
||||||
|
onOpenBsiPractice(practiceId: string): void;
|
||||||
|
/** Opens Work with the deviations in this `ISO mapping` facet state. */
|
||||||
|
onOpenMappingState(state: ComplianceMappingState): void;
|
||||||
|
/** The Client Info context row (client and timeslot), shown below the tab bar like on the Overview. */
|
||||||
|
context?: ReactNode;
|
||||||
|
/** The Client Info tab bar, shown above this tab like on the AI Overview. */
|
||||||
|
navigation?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
type OverviewState = { status: 'idle' | 'loading' } | { status: 'error' } | { status: 'ready'; overview: RegulatoryControlOverviewResponse };
|
type OverviewState = { status: 'idle' | 'loading' } | { status: 'error' } | { status: 'ready'; overview: RegulatoryControlOverviewResponse };
|
||||||
|
|
@ -45,26 +59,48 @@ function themeGroups(controls: RegulatoryControlOverviewItem[]): ThemeGroup[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client Info `Compliance` row: one tile per framework in the environment tile geometry, the complete
|
* Client Info `Compliance` tab: an overview row (mapping coverage and Annex A footprint), one tile per framework
|
||||||
* control list in its Results window. ISO/IEC 27001/27002 is the first framework.
|
* in the environment tile geometry, the complete control list in its Results window, and when the timeslot was
|
||||||
|
* last mapped. ISO/IEC 27001/27002 is the first framework.
|
||||||
*/
|
*/
|
||||||
export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = [], backendStatus, onOpenControl, onOpenTheme }: Props) {
|
export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = [], backendStatus, onOpenControl, onOpenTheme, onOpenBsiRequirement, onOpenBsiPractice, onOpenMappingState, navigation, context }: Props) {
|
||||||
const available = useEnginePreferences().compliance;
|
const available = useEnginePreferences().compliance;
|
||||||
const revision = useRegulatoryDecisionRevision();
|
const revision = useRegulatoryDecisionRevision();
|
||||||
const id = useId();
|
const id = useId();
|
||||||
const [state, setState] = useState<OverviewState>({ status: 'idle' });
|
|
||||||
const scopeKey = JSON.stringify([projectId, assessmentIds, scanRunIds]);
|
const scopeKey = JSON.stringify([projectId, assessmentIds, scanRunIds]);
|
||||||
const enabled = available && backendStatus === 'connected' && Boolean(projectId) && (assessmentIds.length > 0 || scanRunIds.length > 0);
|
const enabled = available && backendStatus === 'connected' && Boolean(projectId) && (assessmentIds.length > 0 || scanRunIds.length > 0);
|
||||||
|
// Returning to the tab shows the last overview of this timeslot at once; the refresh below replaces it.
|
||||||
|
const [state, setState] = useState<OverviewState>(() => {
|
||||||
|
const cached = enabled ? overviewCache.get(scopeKey) : undefined;
|
||||||
|
return cached ? { status: 'ready', overview: cached } : { status: 'idle' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const [poll, setPoll] = useState(0);
|
||||||
|
// Which framework the overview row shows; the framework tiles below are unaffected.
|
||||||
|
const [framework, setFramework] = useState<ComplianceFramework>('iso');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || !projectId) { setState({ status: 'idle' }); return; }
|
if (!enabled || !projectId) { setState({ status: 'idle' }); return; }
|
||||||
let active = true;
|
let active = true;
|
||||||
setState({ status: 'loading' });
|
const cached = overviewCache.get(scopeKey);
|
||||||
|
// A poll or a remembered overview keeps the shown values until the refreshed overview arrives.
|
||||||
|
if (cached) setState({ status: 'ready', overview: cached });
|
||||||
|
else if (poll === 0) setState({ status: 'loading' });
|
||||||
assessorApi.getRegulatoryControlOverview(projectId, { assessmentIds: [...assessmentIds], scanRunIds: [...scanRunIds] })
|
assessorApi.getRegulatoryControlOverview(projectId, { assessmentIds: [...assessmentIds], scanRunIds: [...scanRunIds] })
|
||||||
.then(overview => { if (active) setState({ status: 'ready', overview }); })
|
.then(overview => { if (active) { rememberOverview(scopeKey, overview); setState({ status: 'ready', overview }); } })
|
||||||
.catch(() => { if (active) setState({ status: 'error' }); });
|
.catch(() => { if (active && !cached && poll === 0) setState({ status: 'error' }); });
|
||||||
return () => { active = false; };
|
return () => { active = false; };
|
||||||
}, [enabled, revision, scopeKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [enabled, revision, scopeKey, poll]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
useEffect(() => { setPoll(0); }, [enabled, revision, scopeKey]);
|
||||||
|
|
||||||
|
const history = state.status === 'ready' ? state.overview.mappingHistory ?? [] : [];
|
||||||
|
const mappingActive = history.some(entry => entry.status === 'queued' || entry.status === 'running');
|
||||||
|
// While a mapping of this timeslot runs, its progress and newly accepted controls appear without a reload.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mappingActive) return;
|
||||||
|
const timer = setTimeout(() => setPoll(value => value + 1), MAPPING_POLL_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [mappingActive, state]);
|
||||||
|
|
||||||
if (!available || state.status === 'idle') return null;
|
if (!available || state.status === 'idle') return null;
|
||||||
const groups = state.status === 'ready' ? themeGroups(state.overview.controls) : [];
|
const groups = state.status === 'ready' ? themeGroups(state.overview.controls) : [];
|
||||||
|
|
@ -118,8 +154,21 @@ export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds =
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
return <section className="client-compliance" aria-labelledby={`${id}-title`} aria-busy={state.status === 'loading'}>
|
return <div className="dashboard-page client-command-center client-compliance-page">
|
||||||
|
{navigation}
|
||||||
|
{context}
|
||||||
|
<section className="client-compliance" aria-labelledby={`${id}-title`} aria-busy={state.status === 'loading'}>
|
||||||
|
<header className="client-compliance-header">
|
||||||
|
<div className="client-compliance-heading">
|
||||||
<h2 id={`${id}-title`}>Compliance</h2>
|
<h2 id={`${id}-title`}>Compliance</h2>
|
||||||
|
{state.status !== 'error' && <ComplianceFrameworkSwitch value={framework} onChange={setFramework}/>}
|
||||||
|
</div>
|
||||||
|
{state.status === 'ready' && <LastMapping history={history}/>}
|
||||||
|
</header>
|
||||||
|
{state.status !== 'error' && <ComplianceOverview framework={framework} controls={ready ? state.overview.controls : undefined}
|
||||||
|
coverage={ready ? state.overview.coverage : undefined} bsi={ready ? state.overview.bsi : undefined}
|
||||||
|
bsiFootprint={ready ? state.overview.bsiFootprint : undefined} annexATitles={ready ? state.overview.annexATitles : undefined} onOpenControl={onOpenControl} onOpenTheme={onOpenTheme}
|
||||||
|
onOpenBsiRequirement={onOpenBsiRequirement} onOpenBsiPractice={onOpenBsiPractice} onOpenMappingState={onOpenMappingState}/>}
|
||||||
{/* Same tile geometry as the environment row; later frameworks add their tiles here. */}
|
{/* Same tile geometry as the environment row; later frameworks add their tiles here. */}
|
||||||
<div className="client-compliance-grid" aria-label="Compliance frameworks">
|
<div className="client-compliance-grid" aria-label="Compliance frameworks">
|
||||||
<section className="client-env-group" data-framework="iso-27001-27002">
|
<section className="client-env-group" data-framework="iso-27001-27002">
|
||||||
|
|
@ -129,6 +178,138 @@ export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds =
|
||||||
</header>
|
</header>
|
||||||
{state.status === 'error' ? <p role="alert" className="iso-controls__error">ISO controls could not be loaded.</p> : tileFacts}
|
{state.status === 'error' ? <p role="alert" className="iso-controls__error">ISO controls could not be loaded.</p> : tileFacts}
|
||||||
</section>
|
</section>
|
||||||
|
<BsiTile state={state} onOpenRequirement={onOpenBsiRequirement} onOpenPractice={onOpenBsiPractice}/>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last overview per client and timeslot, so the tab does not start empty on every visit. Every visit,
|
||||||
|
// decision and running mapping still requests a fresh overview; the bound keeps a few recent timeslots.
|
||||||
|
const OVERVIEW_CACHE_LIMIT = 8;
|
||||||
|
const overviewCache = new Map<string, RegulatoryControlOverviewResponse>();
|
||||||
|
function rememberOverview(scopeKey: string, overview: RegulatoryControlOverviewResponse) {
|
||||||
|
overviewCache.delete(scopeKey);
|
||||||
|
overviewCache.set(scopeKey, overview);
|
||||||
|
while (overviewCache.size > OVERVIEW_CACHE_LIMIT) overviewCache.delete(overviewCache.keys().next().value!);
|
||||||
|
}
|
||||||
|
/** Test support. */
|
||||||
|
export function resetClientComplianceCacheForTests(): void { overviewCache.clear(); }
|
||||||
|
|
||||||
|
const MAPPING_POLL_MS = 5_000;
|
||||||
|
const triggerLabel = (entry: RegulatoryMappingHistoryEntry) =>
|
||||||
|
entry.kind === 'single' ? 'Single finding' : entry.trigger === 'automatic' ? 'Automatic' : 'Manual';
|
||||||
|
const SCOPE_LABEL = { timeslot: 'This timeslot', 'other-timeslot': 'Other timeslot', 'other-client': 'Other client', unknown: 'Unknown' } as const;
|
||||||
|
const STATUS_LABEL = { queued: 'Queued', running: 'Running', completed: 'Completed', failed: 'Failed' } as const;
|
||||||
|
const dateTime = (value: string) => new Date(value).toLocaleString('en-GB',
|
||||||
|
{ day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The latest ISO mapping of any client and what started it; mappings are global per rule. The window lists
|
||||||
|
* the latest batches and single finding mappings with their scope and rule counts.
|
||||||
|
*/
|
||||||
|
function LastMapping({ history }: { history: RegulatoryMappingHistoryEntry[] }) {
|
||||||
|
const latest = history[0];
|
||||||
|
if (!latest) return <span className="client-compliance-last-mapping is-empty">No mapping yet</span>;
|
||||||
|
const label = `Last mapping: ${dateTime(latest.createdAt)} · ${triggerLabel(latest)}`
|
||||||
|
+ (latest.scope === 'timeslot' ? '' : ` · ${SCOPE_LABEL[latest.scope]}`)
|
||||||
|
+ (latest.status === 'completed' ? '' : ` · ${STATUS_LABEL[latest.status]}`);
|
||||||
|
return <EnvironmentResultsButton title="ISO mapping history" trigger={label} triggerClassName="btn client-compliance-last-mapping">
|
||||||
|
<div className="client-env-evidence-scroll">
|
||||||
|
<table className="client-information-table client-compliance-history">
|
||||||
|
<thead><tr><th>Started</th><th>Trigger</th><th>Scope</th><th>Status</th><th>Findings</th><th>Sent to model</th><th>Reused</th><th>Succeeded</th><th>Failed</th></tr></thead>
|
||||||
|
<tbody>{history.map(entry => <tr key={entry.id}>
|
||||||
|
<td>{dateTime(entry.createdAt)}</td>
|
||||||
|
<td>{triggerLabel(entry)}</td>
|
||||||
|
<td>{SCOPE_LABEL[entry.scope]}</td>
|
||||||
|
<td>{STATUS_LABEL[entry.status]}</td>
|
||||||
|
<td>{entry.counts.findings === undefined ? '—' : number(entry.counts.findings)}</td>
|
||||||
|
<td>{number(entry.counts.sentToModel)}</td>
|
||||||
|
<td>{number(entry.counts.reused)}</td>
|
||||||
|
<td>{number(entry.counts.succeeded)}</td>
|
||||||
|
<td>{number(entry.counts.failed)}</td>
|
||||||
|
</tr>)}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</EnvironmentResultsButton>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BSI_TILE_PRACTICES = 4;
|
||||||
|
|
||||||
|
interface BsiPracticeGroup { id: string; title: string; requirements: Array<BsiOverview['requirements'][number] & { findings: number }>; findings: number }
|
||||||
|
|
||||||
|
/** Practices in catalog order, only with derived requirements; a finding counts once per practice. */
|
||||||
|
function bsiPracticeGroups(overview: RegulatoryControlOverviewResponse): { groups: BsiPracticeGroup[]; findings: number } {
|
||||||
|
const bsi = overview.bsi;
|
||||||
|
if (!bsi) return { groups: [], findings: 0 };
|
||||||
|
const findingsOf = bsiRequirementFindings(overview.controls, bsi);
|
||||||
|
const all = new Set<string>();
|
||||||
|
const groups = bsi.practices.map(practice => {
|
||||||
|
const practiceFindings = new Set<string>();
|
||||||
|
const requirements = bsi.requirements.filter(requirement => requirement.practice === practice.id).map(requirement => {
|
||||||
|
const findings = findingsOf.get(requirement.requirementId) ?? [];
|
||||||
|
for (const finding of findings) { practiceFindings.add(finding.resultProjectionId); all.add(finding.resultProjectionId); }
|
||||||
|
return { ...requirement, findings: findings.length };
|
||||||
|
}).sort((left, right) => right.findings - left.findings || left.requirementId.localeCompare(right.requirementId, 'en', { numeric: true }));
|
||||||
|
return { ...practice, requirements, findings: practiceFindings.size };
|
||||||
|
}).filter(group => group.requirements.length > 0);
|
||||||
|
return { groups, findings: all.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BSI Grundschutz++ tile (Phase 13F, stage 1): Kernel requirements derived from the accepted ISO controls through
|
||||||
|
* the BSI mapping. The four practices with the most findings follow the totals; the Results window lists all.
|
||||||
|
*/
|
||||||
|
function BsiTile({ state, onOpenRequirement, onOpenPractice }: { state: OverviewState; onOpenRequirement(id: string): void; onOpenPractice(id: string): void }) {
|
||||||
|
const ready = state.status === 'ready';
|
||||||
|
const { groups, findings } = ready ? bsiPracticeGroups(state.overview) : { groups: [], findings: 0 };
|
||||||
|
const bsi = ready ? state.overview.bsi : undefined;
|
||||||
|
const requirementCount = groups.reduce((total, group) => total + group.requirements.length, 0);
|
||||||
|
const top = [...groups].sort((left, right) => right.findings - left.findings).slice(0, BSI_TILE_PRACTICES);
|
||||||
|
const results = (dismiss: () => void) => {
|
||||||
|
const open = (action: () => void) => { dismiss(); action(); };
|
||||||
|
if (!groups.length || !bsi) return <p className="iso-controls__empty">No BSI requirements derived from accepted ISO controls in this timeslot.</p>;
|
||||||
|
return <div className="iso-controls bsi-requirements">
|
||||||
|
<p className="iso-controls__total" aria-label="Unique findings with a derived BSI requirement">Findings: {number(findings)}</p>
|
||||||
|
<div className="iso-controls__themes" aria-label="Derived BSI requirements by practice">
|
||||||
|
{groups.map(group => <div key={group.id} className="iso-controls__group" data-practice={group.id}>
|
||||||
|
<button type="button" className="iso-controls__theme" aria-label={`Open BSI practice ${group.id} findings in Work (${group.findings})`}
|
||||||
|
onClick={() => open(() => onOpenPractice(group.id))}>
|
||||||
|
<span className="iso-controls__theme-name">{group.id} · {group.title}</span>
|
||||||
|
<span className="iso-controls__theme-count">{count(group.requirements.length, 'requirement')} · {count(group.findings, 'finding')}</span>
|
||||||
|
</button>
|
||||||
|
<div className="iso-controls__rows">{group.requirements.map(requirement => <button key={requirement.requirementId} type="button" className="iso-controls__row"
|
||||||
|
data-requirement={requirement.requirementId}
|
||||||
|
aria-label={`Open ${requirement.requirementId} findings in Work (${requirement.findings})`}
|
||||||
|
onClick={() => open(() => onOpenRequirement(requirement.requirementId))}>
|
||||||
|
<span className="iso-controls__id">{requirement.requirementId}</span>
|
||||||
|
<span className="iso-controls__title">{requirement.title}
|
||||||
|
<span className="bsi-requirements__via"> · via {requirement.via.map(via => `A.${via.isoControlId}`).join(', ')}</span></span>
|
||||||
|
<span className="iso-controls__amount">{number(requirement.findings)}</span>
|
||||||
|
</button>)}</div>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
<p className="bsi-requirements__source">
|
||||||
|
Derived from the accepted ISO controls through the BSI mapping ISO/IEC 27001 Annex A to Grundschutz++ {bsi.crosswalkVersion},
|
||||||
|
catalog {bsi.catalogVersion.slice(0, 10)}. A requirement is reached when one of its ISO controls is accepted; it is no decision of its own.
|
||||||
|
{bsi.missingTargets.length > 0 && ` Not in the catalog: ${[...new Set(bsi.missingTargets.map(item => item.requirementId))].join(', ')}.`} {bsi.license}
|
||||||
|
</p>
|
||||||
|
</div>;
|
||||||
|
};
|
||||||
|
return <section className="client-env-group" data-framework="bsi-grundschutz-plus-plus">
|
||||||
|
<header className="client-env-group-header">
|
||||||
|
<h3 className="client-env-group-title"><DashboardGlyph name="clipboard"/><span>{BSI_TILE_TITLE}</span></h3>
|
||||||
|
{ready && <EnvironmentResultsButton title={BSI_TILE_TITLE}>{results}</EnvironmentResultsButton>}
|
||||||
|
</header>
|
||||||
|
{state.status === 'error' ? <p role="alert" className="iso-controls__error">BSI requirements could not be loaded.</p>
|
||||||
|
: <dl className="client-env-facts">
|
||||||
|
<div><dt>Requirements</dt><dd>{ready ? number(requirementCount) : '—'}</dd></div>
|
||||||
|
<div><dt>Findings</dt><dd>{ready ? number(findings) : '—'}</dd></div>
|
||||||
|
{top.map(group => <div key={group.id} className="iso-tile-theme is-actionable" data-practice={group.id}>
|
||||||
|
<dt>{group.title}</dt>
|
||||||
|
<dd><button type="button" aria-label={`Open BSI practice ${group.id} findings in Work (${group.findings})`}
|
||||||
|
onClick={() => onOpenPractice(group.id)}>{count(group.findings, 'finding')}</button></dd>
|
||||||
|
</div>)}
|
||||||
|
</dl>}
|
||||||
</section>;
|
</section>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { DonutSmallArcMarker, donutArcPath, donutPercentageLabel } from '../../components/DonutMetricCard/donut-arc';
|
||||||
|
import { DashboardGlyph } from '../../features/dashboard/ClientTileParts';
|
||||||
|
import { EnvironmentResultsButton } from '../../features/dashboard/EnvironmentResultsButton';
|
||||||
|
import { ISO_MAPPING_FAILED, ISO_MAPPING_NO_BSI, type ComplianceMappingState } from './compliance-work-facets';
|
||||||
|
import { bsiRequirementFindings, type BsiFootprint, type BsiOverview } from '../bsi-grundschutz';
|
||||||
|
import {
|
||||||
|
ANNEX_A_CONTROL_COUNTS, ANNEX_A_THEME_CLAUSES,
|
||||||
|
type RegulatoryControlOverviewItem, type RegulatoryControlTheme, type RegulatoryMappingCoverage
|
||||||
|
} from '../regulatory-mapping';
|
||||||
|
|
||||||
|
export type ComplianceFramework = 'iso' | 'bsi';
|
||||||
|
// People and Physical share one column, so the footprint stays three columns wide.
|
||||||
|
const COLUMNS: RegulatoryControlTheme[][] = [['Organizational'], ['People', 'Physical'], ['Technological']];
|
||||||
|
export const COVERAGE_TILE_TITLE = 'Mapping coverage';
|
||||||
|
export const FOOTPRINT_TILE_TITLE = 'Annex A footprint';
|
||||||
|
export const BSI_FOOTPRINT_TILE_TITLE = 'Kernel footprint';
|
||||||
|
export const BSI_FOOTPRINT_WINDOW_TITLE = 'BSI Grundschutz++ Kernel footprint';
|
||||||
|
/** Practices the BSI footprint tile shows; `Show all` shows every practice. */
|
||||||
|
const BSI_TILE_PRACTICES = 2;
|
||||||
|
const FRAMEWORKS: Array<[ComplianceFramework, string]> = [['iso', 'ISO 27001/27002'], ['bsi', 'BSI Grundschutz++']];
|
||||||
|
|
||||||
|
const number = (value: number) => value.toLocaleString('en-US');
|
||||||
|
const findingCount = (value: number) => `${number(value)} finding${value === 1 ? '' : 's'}`;
|
||||||
|
/** Shading steps of the footprint; a control or requirement without findings stays unshaded. */
|
||||||
|
export const footprintStep = (findings: number) => findings >= 25 ? 4 : findings >= 10 ? 3 : findings >= 3 ? 2 : findings >= 1 ? 1 : 0;
|
||||||
|
|
||||||
|
/** Switches the overview row between the frameworks; the framework tiles below stay as they are. */
|
||||||
|
export function ComplianceFrameworkSwitch({ value, onChange }: { value: ComplianceFramework; onChange(value: ComplianceFramework): void }) {
|
||||||
|
// The Client Info tab buttons: the approved selection control of this view.
|
||||||
|
return <div className="client-info-tabs compliance-framework-switch" role="group" aria-label="Compliance overview framework">
|
||||||
|
{FRAMEWORKS.map(([id, label]) => <button key={id} type="button" data-framework-view={id}
|
||||||
|
aria-pressed={value === id} onClick={() => onChange(id)}>{label}</button>)}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
framework: ComplianceFramework;
|
||||||
|
/** Undefined while the overview loads: values stay an em dash and nothing opens Work. */
|
||||||
|
controls?: RegulatoryControlOverviewItem[];
|
||||||
|
coverage?: RegulatoryMappingCoverage;
|
||||||
|
bsi?: BsiOverview;
|
||||||
|
bsiFootprint?: BsiFootprint;
|
||||||
|
/** Annex A titles of the active corpus; a control without findings is named by these. */
|
||||||
|
annexATitles?: Record<string, string>;
|
||||||
|
onOpenControl(controlId: string): void;
|
||||||
|
onOpenTheme(theme: RegulatoryControlTheme): void;
|
||||||
|
onOpenBsiRequirement(requirementId: string): void;
|
||||||
|
onOpenBsiPractice(practiceId: string): void;
|
||||||
|
/** Opens Work with the deviations in this `ISO mapping` state (failed, to map them again, or without a BSI requirement). */
|
||||||
|
onOpenMappingState(state: ComplianceMappingState): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client Info `Compliance` overview row above the framework tiles, for the framework chosen by the switch: how far
|
||||||
|
* the timeslot's deviations are mapped, and where they land in the catalog. It states where findings land,
|
||||||
|
* never whether a control or requirement is met.
|
||||||
|
*/
|
||||||
|
export function ComplianceOverview(props: Props) {
|
||||||
|
const { framework, controls, coverage, bsi, onOpenMappingState } = props;
|
||||||
|
const ready = controls && coverage ? coverage : undefined;
|
||||||
|
if (framework === 'iso') {
|
||||||
|
return <div className="compliance-overview" aria-label="Compliance overview" data-framework-view="iso">
|
||||||
|
<CoverageTile caption="Deviations with an ISO control" centre={ready?.accepted} total={ready?.deviations} states={[
|
||||||
|
{ key: 'accepted', label: 'Accepted ISO control', value: ready?.accepted, arc: true },
|
||||||
|
{ key: 'mappedNoneAccepted', label: 'Mapped, none accepted', value: ready?.mappedNoneAccepted, arc: true },
|
||||||
|
{ key: 'notMapped', label: 'Not mapped yet', value: ready?.notMapped },
|
||||||
|
{ key: 'failed', label: 'Mapping failed', value: ready?.failed, arc: true, onOpen: () => onOpenMappingState(ISO_MAPPING_FAILED) }]}/>
|
||||||
|
<IsoFootprint {...props}/>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
// A finding with a derived requirement also has an accepted ISO control; the rest of those keep only ISO.
|
||||||
|
const withBsi = ready && controls
|
||||||
|
? new Set([...bsiRequirementFindings(controls, bsi).values()].flat().map(finding => finding.resultProjectionId)).size : undefined;
|
||||||
|
return <div className="compliance-overview" aria-label="Compliance overview" data-framework-view="bsi">
|
||||||
|
<CoverageTile caption="Deviations with a BSI requirement" centre={withBsi} total={ready?.deviations} states={[
|
||||||
|
{ key: 'bsi', label: 'With BSI requirement', value: withBsi, arc: true },
|
||||||
|
{ key: 'isoOnly', label: 'ISO control, no BSI requirement', value: ready && withBsi !== undefined ? ready.accepted - withBsi : undefined, arc: true,
|
||||||
|
onOpen: () => onOpenMappingState(ISO_MAPPING_NO_BSI) },
|
||||||
|
{ key: 'mappedNoneAccepted', label: 'Mapped, none accepted', value: ready?.mappedNoneAccepted, arc: true },
|
||||||
|
{ key: 'notMapped', label: 'Not mapped yet', value: ready?.notMapped },
|
||||||
|
{ key: 'failed', label: 'Mapping failed', value: ready?.failed, arc: true, onOpen: () => onOpenMappingState(ISO_MAPPING_FAILED) }]}/>
|
||||||
|
<BsiFootprintTile {...props}/>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CoverageState { key: string; label: string; value?: number; arc?: boolean; onOpen?(): void }
|
||||||
|
|
||||||
|
/** The shared Client Info donut; states without an arc are the uncovered remainder drawn by the neutral track. */
|
||||||
|
function CoverageTile({ caption, centre, total, states }: { caption: string; centre?: number; total?: number; states: CoverageState[] }) {
|
||||||
|
const share = (value: number) => total ? value / total * 100 : 0;
|
||||||
|
let offset = 0;
|
||||||
|
const arcs = states.filter(state => state.arc && state.value).map(state => {
|
||||||
|
const percentage = share(state.value!);
|
||||||
|
const arcOffset = offset;
|
||||||
|
offset += percentage;
|
||||||
|
return <g key={state.key} data-state={state.key}><path className="client-info-donut-segment" d={donutArcPath(percentage, arcOffset)}/>
|
||||||
|
<DonutSmallArcMarker percentage={percentage} offset={arcOffset} className="client-info-donut-marker"/></g>;
|
||||||
|
});
|
||||||
|
const shown = (value?: number) => value === undefined ? '—' : number(value);
|
||||||
|
return <section className="client-env-group compliance-coverage" aria-labelledby="compliance-coverage-title">
|
||||||
|
<header className="client-env-group-header">
|
||||||
|
<h3 className="client-env-group-title" id="compliance-coverage-title"><DashboardGlyph name="chart"/><span>{COVERAGE_TILE_TITLE}</span></h3>
|
||||||
|
</header>
|
||||||
|
<div className="compliance-coverage__body">
|
||||||
|
<div className="compliance-coverage__ring">
|
||||||
|
<div className="client-info-donut" role="img" aria-label={`${caption}: ${shown(centre)} of ${shown(total)}`}>
|
||||||
|
<svg viewBox="0 0 120 120" aria-hidden="true">
|
||||||
|
<circle className="client-info-donut-track" cx="60" cy="60" r="48" pathLength="100"/>
|
||||||
|
{arcs}
|
||||||
|
</svg>
|
||||||
|
<span><strong>{shown(centre)}</strong><small>{centre !== undefined && total ? donutPercentageLabel(share(centre)) : '—'}</small></span>
|
||||||
|
</div>
|
||||||
|
<span className="compliance-coverage__caption">{caption}</span>
|
||||||
|
</div>
|
||||||
|
<dl className="compliance-coverage__legend" aria-label="Mappable deviations of this timeslot by mapping state">
|
||||||
|
{states.map(state => {
|
||||||
|
// A state with an action and findings is one click target over its whole row, like an ISO tile theme.
|
||||||
|
const actionable = Boolean(state.onOpen && state.value);
|
||||||
|
return <div key={state.key} data-state={state.key} className={actionable ? 'is-actionable' : undefined}>
|
||||||
|
<i aria-hidden="true"/><dt>{state.label}</dt>
|
||||||
|
<dd>{actionable
|
||||||
|
? <button type="button" aria-label={`Open ${state.label} findings in Work (${state.value})`} onClick={state.onOpen}>{shown(state.value)}</button>
|
||||||
|
: shown(state.value)}</dd>
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
<div className="is-total"><i aria-hidden="true"/><dt>Mappable deviations</dt><dd>{shown(total)}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<p className="compliance-coverage__note">Shows how far mapping has progressed, not whether controls are met.</p>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FootprintDetail { id: string; group: string; title?: string; via?: string; findings: number; reachable: boolean }
|
||||||
|
|
||||||
|
/** Shared footprint frame: hover or focus names a cell in the detail line below the grid. */
|
||||||
|
function FootprintFrame({ title, glyph, action, children }: { title: string; glyph: 'shield' | 'policy'; action?: ReactNode; children: ReactNode }) {
|
||||||
|
return <section className="client-env-group compliance-footprint" aria-labelledby="compliance-footprint-title">
|
||||||
|
<header className="client-env-group-header">
|
||||||
|
<h3 className="client-env-group-title" id="compliance-footprint-title"><DashboardGlyph name={glyph}/><span>{title}</span></h3>
|
||||||
|
{action}
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hover or focus names a cell here, below the grid. */
|
||||||
|
function FootprintDetailLine({ current }: { current?: FootprintDetail }) {
|
||||||
|
return <div className="compliance-footprint__detail" aria-live="polite">
|
||||||
|
{current
|
||||||
|
? <><div><div className="compliance-footprint__detail-id">{[current.id, current.group, current.via].filter(Boolean).join(' · ')}</div>
|
||||||
|
{current.title && <div className="compliance-footprint__detail-title">{current.title}</div>}</div>
|
||||||
|
<div className="compliance-footprint__detail-count">{current.findings > 0 ? findingCount(current.findings)
|
||||||
|
: current.reachable ? 'No mapped findings' : 'Not reachable through the ISO mapping'}</div></>
|
||||||
|
: <div className="compliance-footprint__detail-hint">Hover or focus a cell to see its title and findings.</div>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FootprintLegend({ empty, unreachable }: { empty?: string; unreachable?: string }) {
|
||||||
|
return <div className="compliance-footprint__legend" aria-hidden="true">
|
||||||
|
{empty && <span><i className="compliance-footprint__cell"/>{empty}</span>}
|
||||||
|
{unreachable && <span><i className="compliance-footprint__cell is-unreachable"/>{unreachable}</span>}
|
||||||
|
{(['1–2', '3–9', '10–24', '25+ findings'] as const).map((label, index) =>
|
||||||
|
<span key={label}><i className="compliance-footprint__cell" data-step={index + 1}/>{label}</span>)}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IsoFootprint({ controls, annexATitles, onOpenControl, onOpenTheme }: Props) {
|
||||||
|
const [current, setCurrent] = useState<FootprintDetail>();
|
||||||
|
const byId = new Map((controls ?? []).map(control => [control.controlId, control]));
|
||||||
|
const theme = (name: RegulatoryControlTheme) => {
|
||||||
|
const cells = Array.from({ length: ANNEX_A_CONTROL_COUNTS[name] }, (_, index) => {
|
||||||
|
const controlId = `${ANNEX_A_THEME_CLAUSES[name]}.${index + 1}`;
|
||||||
|
return { controlId, control: byId.get(controlId) };
|
||||||
|
});
|
||||||
|
const hits = cells.filter(cell => cell.control?.findings.length);
|
||||||
|
// A finding with several controls of one theme counts once for that theme.
|
||||||
|
const findings = new Set(hits.flatMap(cell => cell.control!.findings.map(finding => finding.resultProjectionId))).size;
|
||||||
|
const summary = <><span className="compliance-footprint__theme-name">{name}</span>
|
||||||
|
<span className="compliance-footprint__theme-meta">{controls
|
||||||
|
? `${number(hits.length)} of ${number(cells.length)} controls · ${findingCount(findings)}` : '—'}</span></>;
|
||||||
|
return <div key={name} className="compliance-footprint__theme" data-iso-theme={name}>
|
||||||
|
{/* Like the ISO tile, only a theme with hits opens Work: the ISO theme facet offers only values with hits. */}
|
||||||
|
{findings > 0
|
||||||
|
? <button type="button" className="compliance-footprint__theme-head" aria-label={`Open ${name} ISO theme findings in Work (${findings})`}
|
||||||
|
onClick={() => onOpenTheme(name)}>{summary}</button>
|
||||||
|
: <div className="compliance-footprint__theme-head">{summary}</div>}
|
||||||
|
<div className="compliance-footprint__cells">{cells.map(({ controlId, control }) => {
|
||||||
|
const count = control?.findings.length ?? 0;
|
||||||
|
const className = `compliance-footprint__cell${current?.id === `A.${controlId}` ? ' is-current' : ''}`;
|
||||||
|
const show = () => setCurrent({ id: `A.${controlId}`, group: name, title: control?.title ?? annexATitles?.[controlId], findings: count, reachable: true });
|
||||||
|
return count > 0
|
||||||
|
? <button key={controlId} type="button" className={className} data-control={controlId} data-step={footprintStep(count)}
|
||||||
|
aria-label={`Open A.${controlId}${control?.title ? ` ${control.title}` : ''} findings in Work (${count})`}
|
||||||
|
onMouseEnter={show} onFocus={show} onClick={() => onOpenControl(controlId)}>{controlId}</button>
|
||||||
|
: <span key={controlId} className={className} data-control={controlId} aria-hidden="true" onMouseEnter={show}>{controlId}</span>;
|
||||||
|
})}</div>
|
||||||
|
</div>;
|
||||||
|
};
|
||||||
|
return <FootprintFrame title={FOOTPRINT_TILE_TITLE} glyph="shield">
|
||||||
|
<div className="compliance-footprint__themes">
|
||||||
|
{COLUMNS.map(column => <div key={column.join()} className="compliance-footprint__column">{column.map(theme)}</div>)}
|
||||||
|
</div>
|
||||||
|
<FootprintDetailLine current={current}/>
|
||||||
|
<FootprintLegend empty="No mapped findings (not a compliance statement)"/>
|
||||||
|
</FootprintFrame>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BsiGridProps {
|
||||||
|
practices: BsiFootprint['practices'];
|
||||||
|
findingsOf: Map<string, Array<{ resultProjectionId: string }>>;
|
||||||
|
derived: Map<string, BsiOverview['requirements'][number]>;
|
||||||
|
onOpenRequirement(requirementId: string): void;
|
||||||
|
onOpenPractice(practiceId: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BSI Kernel requirements by practice in catalog order, each a square labelled with its number inside the practice.
|
||||||
|
* `hits` (the tile) shows the two practices with the most findings; `all` (the Show all window) shows every practice, larger. A
|
||||||
|
* requirement the BSI mapping cannot reach from any ISO control is marked, so an empty square never reads as a gap of this client.
|
||||||
|
*/
|
||||||
|
function BsiRequirementGrid({ mode, practices, findingsOf, derived, onOpenRequirement, onOpenPractice }: BsiGridProps & { mode: 'hits' | 'all' }) {
|
||||||
|
const [current, setCurrent] = useState<FootprintDetail>();
|
||||||
|
const blocks = practices.map(practice => {
|
||||||
|
const findings = new Set<string>();
|
||||||
|
const hits = practice.requirements.filter(requirement => {
|
||||||
|
const list = findingsOf.get(requirement.id) ?? [];
|
||||||
|
for (const finding of list) findings.add(finding.resultProjectionId);
|
||||||
|
return list.length > 0;
|
||||||
|
});
|
||||||
|
return { practice, findings: findings.size, cells: practice.requirements, hits: hits.length };
|
||||||
|
});
|
||||||
|
// The tile keeps the practices with the most findings, like the BSI tile; ties stay in catalog order.
|
||||||
|
if (mode === 'hits') blocks.splice(0, blocks.length, ...blocks.filter(block => block.hits > 0)
|
||||||
|
.sort((left, right) => right.findings - left.findings).slice(0, BSI_TILE_PRACTICES));
|
||||||
|
if (!blocks.length) return <p className="compliance-footprint__pending">No BSI requirements derived from accepted ISO controls in this timeslot.</p>;
|
||||||
|
return <>
|
||||||
|
<div className={`compliance-footprint__practices${mode === 'all' ? ' is-window' : ''}`}>
|
||||||
|
{blocks.map(({ practice, findings, cells, hits }) => {
|
||||||
|
const summary = <><span className="compliance-footprint__theme-name">{practice.id} · {practice.title}</span>
|
||||||
|
<span className="compliance-footprint__theme-meta">{`${number(hits)} of ${number(practice.requirements.length)} requirements · ${findingCount(findings)}`}</span></>;
|
||||||
|
return <div key={practice.id} className="compliance-footprint__theme" data-bsi-practice={practice.id}>
|
||||||
|
{findings > 0
|
||||||
|
? <button type="button" className="compliance-footprint__theme-head" aria-label={`Open BSI practice ${practice.id} findings in Work (${findings})`}
|
||||||
|
onClick={() => onOpenPractice(practice.id)}>{summary}</button>
|
||||||
|
: <div className="compliance-footprint__theme-head">{summary}</div>}
|
||||||
|
<div className="compliance-footprint__requirements">{cells.map(requirement => {
|
||||||
|
const count = findingsOf.get(requirement.id)?.length ?? 0;
|
||||||
|
const known = derived.get(requirement.id);
|
||||||
|
const className = `compliance-footprint__cell${requirement.reachable ? '' : ' is-unreachable'}${current?.id === requirement.id ? ' is-current' : ''}`;
|
||||||
|
const show = () => setCurrent({ id: requirement.id, group: `${practice.id} ${practice.title}`, title: known?.title ?? requirement.title,
|
||||||
|
via: known ? `via ${known.via.map(via => `A.${via.isoControlId}`).join(', ')}` : undefined, findings: count, reachable: requirement.reachable });
|
||||||
|
// The practice heading names the prefix, so a square shows only the number, like an Annex A cell.
|
||||||
|
const label = requirement.id.slice(practice.id.length + 1);
|
||||||
|
return count > 0
|
||||||
|
? <button key={requirement.id} type="button" className={className} data-requirement={requirement.id} data-step={footprintStep(count)}
|
||||||
|
aria-label={`Open ${requirement.id}${known ? ` ${known.title}` : ''} findings in Work (${count})`}
|
||||||
|
onMouseEnter={show} onFocus={show} onClick={() => onOpenRequirement(requirement.id)}>{label}</button>
|
||||||
|
: <span key={requirement.id} className={className} data-requirement={requirement.id} aria-hidden="true" onMouseEnter={show}>{label}</span>;
|
||||||
|
})}</div>
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* In the Show all window this stays pinned below the scrolling grid, so a hovered square is always named. */}
|
||||||
|
<div className="compliance-footprint__footer">
|
||||||
|
<FootprintDetailLine current={current}/>
|
||||||
|
<FootprintLegend empty="Reachable, no mapped findings" unreachable="Not reachable through the ISO mapping"/>
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tile shows the two practices with the most findings; `Show all` opens every practice in a window. */
|
||||||
|
function BsiFootprintTile({ controls, bsi, bsiFootprint, onOpenBsiRequirement, onOpenBsiPractice }: Props) {
|
||||||
|
const findingsOf = controls ? bsiRequirementFindings(controls, bsi) : new Map<string, RegulatoryControlOverviewItem['findings']>();
|
||||||
|
const derived = new Map((bsi?.requirements ?? []).map(requirement => [requirement.requirementId, requirement]));
|
||||||
|
const grid = { findingsOf, derived };
|
||||||
|
const ready = controls && bsiFootprint;
|
||||||
|
return <FootprintFrame title={BSI_FOOTPRINT_TILE_TITLE} glyph="policy" action={ready && <EnvironmentResultsButton title={BSI_FOOTPRINT_WINDOW_TITLE} trigger="Show all">
|
||||||
|
{dismiss => <div className="compliance-footprint compliance-footprint--window">
|
||||||
|
<BsiRequirementGrid mode="all" practices={bsiFootprint.practices} {...grid}
|
||||||
|
onOpenRequirement={id => { dismiss(); onOpenBsiRequirement(id); }} onOpenPractice={id => { dismiss(); onOpenBsiPractice(id); }}/>
|
||||||
|
</div>}
|
||||||
|
</EnvironmentResultsButton>}>
|
||||||
|
{ready
|
||||||
|
? <BsiRequirementGrid mode="hits" practices={bsiFootprint.practices} {...grid} onOpenRequirement={onOpenBsiRequirement} onOpenPractice={onOpenBsiPractice}/>
|
||||||
|
: <p className="compliance-footprint__pending">—</p>}
|
||||||
|
</FootprintFrame>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { TriangleIcon } from '../../components/TriangleIcon';
|
||||||
|
import type { BsiOverview } from '../bsi-grundschutz';
|
||||||
|
import './compliance.css';
|
||||||
|
|
||||||
|
const byNumber = (left: string, right: string) => left.localeCompare(right, 'en', { numeric: true });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finding Reader section of the BSI Grundschutz++ requirements derived from this finding's accepted ISO
|
||||||
|
* controls through the BSI mapping (Phase 13F, stage 1). Derived only; it is no decision of its own.
|
||||||
|
* User decision 2026-10-08: it starts collapsed like `Context`, groups the requirements under their ISO
|
||||||
|
* control as one-line rows and shows a statement only when its requirement is opened.
|
||||||
|
*/
|
||||||
|
export function FindingBsiRequirements({ bsi }: { bsi?: BsiOverview }) {
|
||||||
|
if (!bsi?.requirements.length) return null;
|
||||||
|
const isoControls = [...new Set(bsi.requirements.flatMap(requirement => requirement.via.map(via => via.isoControlId)))].sort(byNumber);
|
||||||
|
const count = bsi.requirements.length;
|
||||||
|
return <details className="finding-context finding-bsi-requirements" data-bsi-requirements>
|
||||||
|
<summary className="finding-context__header"><TriangleIcon direction="right"/><h4>BSI requirements</h4>
|
||||||
|
<span className="finding-context__meta">{count} {count === 1 ? 'requirement' : 'requirements'} derived from the accepted ISO controls</span></summary>
|
||||||
|
<div className="finding-context__content">
|
||||||
|
{isoControls.map(isoControlId => {
|
||||||
|
const reached = bsi.requirements.filter(requirement => requirement.via.some(via => via.isoControlId === isoControlId));
|
||||||
|
// One BSI mapping entry per ISO control, so its relationship holds for the whole group.
|
||||||
|
const relationship = reached[0].via.find(via => via.isoControlId === isoControlId)!.relationship;
|
||||||
|
return <div key={isoControlId} className="finding-bsi-requirements__group" data-iso-control={isoControlId}>
|
||||||
|
<p className="finding-bsi-requirements__route">A.{isoControlId} · {relationship}</p>
|
||||||
|
<ul>{reached.map(requirement => <li key={requirement.requirementId} data-requirement={requirement.requirementId}>
|
||||||
|
<details className="finding-bsi-requirements__item">
|
||||||
|
<summary><TriangleIcon direction="right"/><strong>{requirement.requirementId}</strong><span>{requirement.title}</span></summary>
|
||||||
|
<p className="finding-bsi-requirements__statement">{requirement.statement}</p>
|
||||||
|
</details>
|
||||||
|
</li>)}</ul>
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
<p className="finding-bsi-requirements__source">
|
||||||
|
Via BSI mapping {bsi.crosswalkVersion} · catalog {bsi.catalogVersion.slice(0, 10)} · Source: BSI Stand-der-Technik-Bibliothek, CC BY-SA 4.0
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>;
|
||||||
|
}
|
||||||
|
|
@ -5,9 +5,13 @@ import { useBackendWorkActivity } from './use-backend-work-activity';
|
||||||
import { statusPollApi } from './status-poll-client';
|
import { statusPollApi } from './status-poll-client';
|
||||||
import type { RegulatoryCorpusStatus } from '../regulatory-corpus-management';
|
import type { RegulatoryCorpusStatus } from '../regulatory-corpus-management';
|
||||||
import { TriangleIcon } from '../../components/TriangleIcon';
|
import { TriangleIcon } from '../../components/TriangleIcon';
|
||||||
|
import './compliance.css';
|
||||||
|
import { linkAutoMappingToEngineSwitch } from './auto-mapping-engine-link';
|
||||||
|
|
||||||
const api = new AssessorApiClient();
|
const api = new AssessorApiClient();
|
||||||
const MAXIMUM_PDF_BYTES = 32 * 1024 * 1024;
|
const MAXIMUM_PDF_BYTES = 32 * 1024 * 1024;
|
||||||
|
// Data Hub loads with the app, so the link is active in every view that offers the Engines popup.
|
||||||
|
if (typeof window !== 'undefined') linkAutoMappingToEngineSwitch(api);
|
||||||
|
|
||||||
/** Data Hub ISO corpus tile; a switched-off Compliance Engine renders nothing and requests nothing. */
|
/** Data Hub ISO corpus tile; a switched-off Compliance Engine renders nothing and requests nothing. */
|
||||||
export function IsoCorpusLibrary() {
|
export function IsoCorpusLibrary() {
|
||||||
|
|
@ -20,6 +24,7 @@ function IsoCorpusLibraryPanel() {
|
||||||
const [iso27002, setIso27002] = useState<File | null>(null);
|
const [iso27002, setIso27002] = useState<File | null>(null);
|
||||||
const [token, setToken] = useState('');
|
const [token, setToken] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [savingAutoMapping, setSavingAutoMapping] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const load = useCallback(async (poll = false) => {
|
const load = useCallback(async (poll = false) => {
|
||||||
try { setStatus(await (poll ? statusPollApi : api).getIsoCorpusStatus()); setError(null); }
|
try { setStatus(await (poll ? statusPollApi : api).getIsoCorpusStatus()); setError(null); }
|
||||||
|
|
@ -50,6 +55,13 @@ function IsoCorpusLibraryPanel() {
|
||||||
finally { setSubmitting(false); }
|
finally { setSubmitting(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleAutoMapping = async (enabled: boolean) => {
|
||||||
|
setSavingAutoMapping(true); setError(null);
|
||||||
|
try { setStatus(await api.setIsoAutoMapping(enabled)); }
|
||||||
|
catch (reason) { setError(reason instanceof Error ? reason.message : 'Automatic ISO mapping could not be changed.'); }
|
||||||
|
finally { setSavingAutoMapping(false); }
|
||||||
|
};
|
||||||
|
|
||||||
const running = status?.job?.status === 'running';
|
const running = status?.job?.status === 'running';
|
||||||
return <details className="tenant-scan-history baseline-catalog iso-corpus-catalog">
|
return <details className="tenant-scan-history baseline-catalog iso-corpus-catalog">
|
||||||
<summary className="tenant-section-bar">
|
<summary className="tenant-section-bar">
|
||||||
|
|
@ -71,6 +83,12 @@ function IsoCorpusLibraryPanel() {
|
||||||
{status?.job?.status === 'failed' && <div className="datahub-alert" role="alert">{status.job.error}</div>}
|
{status?.job?.status === 'failed' && <div className="datahub-alert" role="alert">{status.job.error}</div>}
|
||||||
{status?.job?.status === 'succeeded' && <div className="tenant-save-feedback success" role="status"><strong>ISO corpus indexed and active.</strong></div>}
|
{status?.job?.status === 'succeeded' && <div className="tenant-save-feedback success" role="status"><strong>ISO corpus indexed and active.</strong></div>}
|
||||||
{!status?.configured && status?.disabledReason && <div className="datahub-alert" role="alert">{status.disabledReason}</div>}
|
{!status?.configured && status?.disabledReason && <div className="datahub-alert" role="alert">{status.disabledReason}</div>}
|
||||||
|
{status?.autoMapping && <label className="iso-auto-mapping-toggle">
|
||||||
|
<input type="checkbox" checked={status.autoMapping.enabled} disabled={savingAutoMapping}
|
||||||
|
onChange={event => void toggleAutoMapping(event.target.checked)}/>
|
||||||
|
<span>Map new scans automatically</span>
|
||||||
|
</label>}
|
||||||
|
{status?.autoMapping && <p className="iso-corpus-note">After each upload, deviations of rules without an ISO mapping are mapped like <strong>Map all ISO controls</strong>. Rules mapped before are reused.</p>}
|
||||||
<form className="iso-corpus-form" onSubmit={event => void submit(event)}>
|
<form className="iso-corpus-form" onSubmit={event => void submit(event)}>
|
||||||
<label><span>DIN EN ISO/IEC 27001:2024-01 PDF</span><input className="input" type="file" accept=".pdf,application/pdf" required disabled={submitting || running} onChange={event => setIso27001(event.target.files?.[0] ?? null)}/></label>
|
<label><span>DIN EN ISO/IEC 27001:2024-01 PDF</span><input className="input" type="file" accept=".pdf,application/pdf" required disabled={submitting || running} onChange={event => setIso27001(event.target.files?.[0] ?? null)}/></label>
|
||||||
<label><span>DIN EN ISO/IEC 27002:2024-01 PDF</span><input className="input" type="file" accept=".pdf,application/pdf" required disabled={submitting || running} onChange={event => setIso27002(event.target.files?.[0] ?? null)}/></label>
|
<label><span>DIN EN ISO/IEC 27002:2024-01 PDF</span><input className="input" type="file" accept=".pdf,application/pdf" required disabled={submitting || running} onChange={event => setIso27002(event.target.files?.[0] ?? null)}/></label>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { AssessorApiClient } from '../../api/assessor-api-client';
|
||||||
|
import { readEnginePreferences, subscribeEnginePreferences } from '../../app/engine-preferences';
|
||||||
|
|
||||||
|
let unlink: (() => void) | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switching the Compliance Engine off also switches off the server-side automatic ISO mapping, in whichever
|
||||||
|
* view the Engines popup is used. Switching it on again leaves automatic mapping off until the Data Hub
|
||||||
|
* switch is used. Batches that already started are not cancelled.
|
||||||
|
*/
|
||||||
|
export function linkAutoMappingToEngineSwitch(api: Pick<AssessorApiClient, 'setIsoAutoMapping'> = new AssessorApiClient()): () => void {
|
||||||
|
unlink?.();
|
||||||
|
let active = readEnginePreferences().compliance;
|
||||||
|
const unsubscribe = subscribeEnginePreferences(() => {
|
||||||
|
const next = readEnginePreferences().compliance;
|
||||||
|
if (active && !next) void Promise.resolve().then(() => api.setIsoAutoMapping(false)).catch(() => undefined);
|
||||||
|
active = next;
|
||||||
|
});
|
||||||
|
unlink = () => { unsubscribe(); unlink = undefined; };
|
||||||
|
return unlink;
|
||||||
|
}
|
||||||
|
|
@ -5,18 +5,26 @@ import { useEnginePreferences } from '../../app/engine-preferences';
|
||||||
import type { FindingRecord } from '../../domain/finding';
|
import type { FindingRecord } from '../../domain/finding';
|
||||||
import { gpoWorkSettingIdentity } from '../../features/results-browser/work-record-lookup';
|
import { gpoWorkSettingIdentity } from '../../features/results-browser/work-record-lookup';
|
||||||
import type { DynamicFacet } from '../../features/search-filter/dynamic-facets';
|
import type { DynamicFacet } from '../../features/search-filter/dynamic-facets';
|
||||||
import type { RegulatoryControlOverviewResponse, RegulatoryControlTheme } from '../regulatory-mapping';
|
import type { RegulatoryControlFinding, RegulatoryControlOverviewResponse, RegulatoryControlTheme } from '../regulatory-mapping';
|
||||||
import { useRegulatoryDecisionRevision } from './decision-revision';
|
import { useRegulatoryDecisionRevision } from './decision-revision';
|
||||||
|
|
||||||
/** Work facets of the Compliance Engine; present only while the engine is on and its assignments are loaded. */
|
/** Work facets of the Compliance Engine; present only while the engine is on and its assignments are loaded. */
|
||||||
export const ISO_CONTROL_FACET_ID = 'compliance:iso-control';
|
export const ISO_CONTROL_FACET_ID = 'compliance:iso-control';
|
||||||
export const ISO_THEME_FACET_ID = 'compliance:iso-theme';
|
export const ISO_THEME_FACET_ID = 'compliance:iso-theme';
|
||||||
export const COMPLIANCE_FACET_IDS = [ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID] as const;
|
export const BSI_REQUIREMENT_FACET_ID = 'compliance:bsi-requirement';
|
||||||
|
export const BSI_PRACTICE_FACET_ID = 'compliance:bsi-practice';
|
||||||
|
export const ISO_MAPPING_FACET_ID = 'compliance:iso-mapping';
|
||||||
|
/** `ISO mapping` facet values: the rule's latest mapping failed and nothing is accepted. */
|
||||||
|
export const ISO_MAPPING_FAILED = 'failed';
|
||||||
|
/** `ISO mapping` facet values: accepted ISO controls, but none reaches a BSI requirement through the BSI mapping. */
|
||||||
|
export const ISO_MAPPING_NO_BSI = 'no-bsi';
|
||||||
|
export type ComplianceMappingState = typeof ISO_MAPPING_FAILED | typeof ISO_MAPPING_NO_BSI;
|
||||||
|
export const COMPLIANCE_FACET_IDS = [ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID, ISO_MAPPING_FACET_ID, BSI_REQUIREMENT_FACET_ID, BSI_PRACTICE_FACET_ID] as const;
|
||||||
const SOURCE_SCAN_FACET_ID = 'provenance:source-scan';
|
const SOURCE_SCAN_FACET_ID = 'provenance:source-scan';
|
||||||
const THEMES: RegulatoryControlTheme[] = ['Organizational', 'People', 'Physical', 'Technological'];
|
const THEMES: RegulatoryControlTheme[] = ['Organizational', 'People', 'Physical', 'Technological'];
|
||||||
|
|
||||||
export interface ComplianceWorkFacetIndex {
|
export interface ComplianceWorkFacetIndex {
|
||||||
facets: [DynamicFacet, DynamicFacet];
|
facets: DynamicFacet[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const indexes = new WeakMap<RegulatoryControlOverviewResponse, ComplianceWorkFacetIndex>();
|
const indexes = new WeakMap<RegulatoryControlOverviewResponse, ComplianceWorkFacetIndex>();
|
||||||
|
|
@ -28,20 +36,38 @@ export function complianceWorkFacetIndex(response: RegulatoryControlOverviewResp
|
||||||
const byOccurrence = new Map<string, Set<string>>();
|
const byOccurrence = new Map<string, Set<string>>();
|
||||||
const bySetting = new Map<string, Set<string>>();
|
const bySetting = new Map<string, Set<string>>();
|
||||||
const add = (map: Map<string, Set<string>>, key: string, controlId: string) => map.set(key, (map.get(key) ?? new Set()).add(controlId));
|
const add = (map: Map<string, Set<string>>, key: string, controlId: string) => map.set(key, (map.get(key) ?? new Set()).add(controlId));
|
||||||
for (const control of response.controls) {
|
const addFinding = (finding: RegulatoryControlFinding, value: string, occurrences: Map<string, Set<string>>, settings: Map<string, Set<string>>) => {
|
||||||
for (const finding of control.findings) {
|
if (finding.sourceKind === 'scanner-finding') add(occurrences, finding.sourceResultId, value);
|
||||||
if (finding.sourceKind === 'scanner-finding') add(byOccurrence, finding.sourceResultId, control.controlId);
|
else if (finding.sourceRuleId) add(settings, `${finding.policyScope ?? ''}\u0000${finding.sourceRuleId}`, value);
|
||||||
else if (finding.sourceRuleId) add(bySetting, `${finding.policyScope ?? ''}\u0000${finding.sourceRuleId}`, control.controlId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Scanner findings match by occurrence; GPO settings by scope and technical setting identity, like Result DB drill-downs.
|
|
||||||
const controlsFor = (record: FindingRecord): string[] => {
|
|
||||||
if (record.findingOccurrenceId) return [...byOccurrence.get(record.findingOccurrenceId) ?? []];
|
|
||||||
const identity = gpoWorkSettingIdentity(record.id);
|
|
||||||
return identity ? [...bySetting.get(`${identity.scope ?? ''}\u0000${identity.settingId}`) ?? []] : [];
|
|
||||||
};
|
};
|
||||||
|
for (const control of response.controls) for (const finding of control.findings) addFinding(finding, control.controlId, byOccurrence, bySetting);
|
||||||
|
// Scanner findings match by occurrence; GPO settings by scope and technical setting identity, like Result DB drill-downs.
|
||||||
|
const valuesOf = (occurrences: Map<string, Set<string>>, settings: Map<string, Set<string>>) => (record: FindingRecord): string[] => {
|
||||||
|
if (record.findingOccurrenceId) return [...occurrences.get(record.findingOccurrenceId) ?? []];
|
||||||
|
const identity = gpoWorkSettingIdentity(record.id);
|
||||||
|
return identity ? [...settings.get(`${identity.scope ?? ''}\u0000${identity.settingId}`) ?? []] : [];
|
||||||
|
};
|
||||||
|
const controlsFor = valuesOf(byOccurrence, bySetting);
|
||||||
|
const failedOccurrences = new Map<string, Set<string>>();
|
||||||
|
const failedSettings = new Map<string, Set<string>>();
|
||||||
|
for (const finding of response.failedFindings ?? []) addFinding(finding, ISO_MAPPING_FAILED, failedOccurrences, failedSettings);
|
||||||
const themeOf = new Map(response.controls.map(control => [control.controlId, control.theme]));
|
const themeOf = new Map(response.controls.map(control => [control.controlId, control.theme]));
|
||||||
|
// BSI requirements derived from the accepted ISO controls through the BSI mapping (Phase 13F, stage 1).
|
||||||
|
const bsi = response.bsi;
|
||||||
|
const requirementsByControl = new Map<string, string[]>();
|
||||||
|
for (const requirement of bsi?.requirements ?? []) {
|
||||||
|
for (const via of requirement.via) requirementsByControl.set(via.isoControlId, [...requirementsByControl.get(via.isoControlId) ?? [], requirement.requirementId]);
|
||||||
|
}
|
||||||
|
const practiceOf = new Map((bsi?.requirements ?? []).map(requirement => [requirement.requirementId, requirement.practice]));
|
||||||
|
const requirementsFor = (record: FindingRecord): string[] => [...new Set(controlsFor(record).flatMap(controlId => requirementsByControl.get(controlId) ?? []))];
|
||||||
const controls = [...response.controls].sort((left, right) => left.controlId.localeCompare(right.controlId, 'en', { numeric: true }));
|
const controls = [...response.controls].sort((left, right) => left.controlId.localeCompare(right.controlId, 'en', { numeric: true }));
|
||||||
|
const failedFor = valuesOf(failedOccurrences, failedSettings);
|
||||||
|
// A finding has no BSI requirement when none of its accepted controls reaches one; offered only with hits.
|
||||||
|
const hasNoBsi = [...byOccurrence.values(), ...bySetting.values()]
|
||||||
|
.some(controlIds => [...controlIds].every(controlId => !requirementsByControl.has(controlId)));
|
||||||
|
const mappingValues = [
|
||||||
|
...(response.failedFindings?.length ? [{ value: ISO_MAPPING_FAILED, label: 'Mapping failed', count: 0 }] : []),
|
||||||
|
...(hasNoBsi ? [{ value: ISO_MAPPING_NO_BSI, label: 'ISO control, no BSI requirement', count: 0 }] : [])];
|
||||||
const index: ComplianceWorkFacetIndex = {
|
const index: ComplianceWorkFacetIndex = {
|
||||||
facets: [{
|
facets: [{
|
||||||
id: ISO_CONTROL_FACET_ID,
|
id: ISO_CONTROL_FACET_ID,
|
||||||
|
|
@ -61,13 +87,44 @@ export function complianceWorkFacetIndex(response: RegulatoryControlOverviewResp
|
||||||
values: THEMES.filter(theme => response.controls.some(control => control.theme === theme))
|
values: THEMES.filter(theme => response.controls.some(control => control.theme === theme))
|
||||||
.map(theme => ({ value: theme.toLowerCase(), label: theme, count: 0 })),
|
.map(theme => ({ value: theme.toLowerCase(), label: theme, count: 0 })),
|
||||||
valuesFor: record => [...new Set(controlsFor(record).flatMap(controlId => themeOf.get(controlId)?.toLowerCase() ?? []))]
|
valuesFor: record => [...new Set(controlsFor(record).flatMap(controlId => themeOf.get(controlId)?.toLowerCase() ?? []))]
|
||||||
}]
|
},
|
||||||
|
// Present only while one of its values has hits, so the Client Info coverage rows open exactly these in Work.
|
||||||
|
...(mappingValues.length ? [{
|
||||||
|
id: ISO_MAPPING_FACET_ID,
|
||||||
|
label: 'ISO mapping',
|
||||||
|
source: 'derived',
|
||||||
|
kind: 'category',
|
||||||
|
description: 'Compliance Engine: findings and GPO settings whose latest ISO mapping failed (map them again from the Finding Reader), or whose accepted ISO controls reach no BSI requirement. It does not change results.',
|
||||||
|
values: mappingValues,
|
||||||
|
valuesFor: record => [...failedFor(record), ...(controlsFor(record).length && !requirementsFor(record).length ? [ISO_MAPPING_NO_BSI] : [])]
|
||||||
|
}] satisfies DynamicFacet[] : []),
|
||||||
|
// The BSI facets appear only when a requirement is derived, so a client without one gets no empty controls.
|
||||||
|
...(bsi?.requirements.length ? [{
|
||||||
|
id: BSI_REQUIREMENT_FACET_ID,
|
||||||
|
label: 'BSI requirement',
|
||||||
|
source: 'derived',
|
||||||
|
kind: 'category',
|
||||||
|
description: 'Compliance Engine: findings and GPO settings with a BSI Grundschutz++ requirement derived from their accepted ISO controls through the BSI mapping. It does not change results.',
|
||||||
|
// Values are matched in lower case, like the ISO theme; the label keeps the BSI spelling.
|
||||||
|
values: bsi.requirements.map(requirement => ({ value: requirement.requirementId.toLowerCase(), label: `${requirement.requirementId} · ${requirement.title}`, count: 0 })),
|
||||||
|
valuesFor: record => requirementsFor(record).map(requirementId => requirementId.toLowerCase())
|
||||||
|
}, {
|
||||||
|
id: BSI_PRACTICE_FACET_ID,
|
||||||
|
label: 'BSI practice',
|
||||||
|
source: 'derived',
|
||||||
|
kind: 'category',
|
||||||
|
description: 'Compliance Engine: findings and GPO settings with a derived BSI Grundschutz++ requirement of this practice. It does not change results.',
|
||||||
|
// Catalog order; values are matched in lower case, the label keeps the BSI spelling.
|
||||||
|
values: bsi.practices.filter(practice => bsi.requirements.some(requirement => requirement.practice === practice.id))
|
||||||
|
.map(practice => ({ value: practice.id.toLowerCase(), label: `${practice.id} · ${practice.title}`, count: 0 })),
|
||||||
|
valuesFor: (record: FindingRecord) => [...new Set(requirementsFor(record).flatMap(requirementId => practiceOf.get(requirementId)?.toLowerCase() ?? []))]
|
||||||
|
}] satisfies DynamicFacet[] : [])]
|
||||||
};
|
};
|
||||||
indexes.set(response, index);
|
indexes.set(response, index);
|
||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adds the ISO facets directly before the permanent final `Source: Scan` control. */
|
/** Adds the ISO and BSI facets directly before the permanent final `Source: Scan` control. */
|
||||||
export function withComplianceFacets(facets: DynamicFacet[], index: ComplianceWorkFacetIndex | undefined): DynamicFacet[] {
|
export function withComplianceFacets(facets: DynamicFacet[], index: ComplianceWorkFacetIndex | undefined): DynamicFacet[] {
|
||||||
if (!index) return facets;
|
if (!index) return facets;
|
||||||
const sourceScan = facets.findIndex(facet => facet.id === SOURCE_SCAN_FACET_ID);
|
const sourceScan = facets.findIndex(facet => facet.id === SOURCE_SCAN_FACET_ID);
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,25 @@
|
||||||
.finding-iso-controls ul { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
|
.finding-iso-controls ul { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
|
||||||
.finding-iso-controls li { display: flex; flex-wrap: wrap; gap: var(--space-2); font-size: var(--type-body-size); line-height: 1.5; }
|
.finding-iso-controls li { display: flex; flex-wrap: wrap; gap: var(--space-2); font-size: var(--type-body-size); line-height: 1.5; }
|
||||||
.finding-iso-controls li strong { font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
|
.finding-iso-controls li strong { font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
|
||||||
|
/* BSI requirements derived through the BSI mapping. Client Info Results: the ISO route and source stay readable. */
|
||||||
|
.bsi-requirements__via, .bsi-requirements__source { font-size: var(--type-table-body-size); color: var(--text-secondary); overflow-wrap: break-word; }
|
||||||
|
.bsi-requirements__source { margin: var(--space-3) 0 0; }
|
||||||
|
/* Finding Reader: collapsed like Context, one group per ISO control, one line per requirement; a statement opens on demand. */
|
||||||
|
.finding-bsi-requirements__group { display: grid; gap: var(--space-1); min-width: 0; }
|
||||||
|
.finding-bsi-requirements__route, .finding-bsi-requirements__source { margin: 0; font-size: var(--type-table-body-size); color: var(--text-secondary); font-variant-numeric: tabular-nums; overflow-wrap: break-word; }
|
||||||
|
.finding-bsi-requirements__group ul { display: grid; gap: var(--space-1); margin: 0; padding: 0; list-style: none; }
|
||||||
|
.finding-bsi-requirements__item > summary { display: flex; align-items: baseline; gap: var(--space-2); cursor: pointer; list-style: none; font-size: var(--type-body-size); line-height: 1.5; }
|
||||||
|
.finding-bsi-requirements__item > summary::-webkit-details-marker { display: none; }
|
||||||
|
.finding-bsi-requirements__item > summary:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--bw-emphasis); }
|
||||||
|
.finding-bsi-requirements__item > summary strong { font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.finding-bsi-requirements__statement { margin: var(--space-1) 0 var(--space-2) var(--space-5); font-size: var(--type-body-size); line-height: 1.5; }
|
||||||
|
|
||||||
/* Client Info `Compliance` row; its tiles use the environment tile geometry. */
|
/* Client Info `Compliance` tab; its tiles use the environment tile geometry. */
|
||||||
.client-compliance { margin-block: var(--space-6) var(--space-8); color: var(--text-primary); min-width: 0; }
|
.client-compliance-page { align-content: start; }
|
||||||
.client-compliance > h2 { margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
|
.client-compliance { margin-block: 0 var(--space-8); color: var(--text-primary); min-width: 0; }
|
||||||
|
.client-compliance-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3); }
|
||||||
|
.client-compliance-heading { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-4); }
|
||||||
|
.client-compliance-heading > h2 { margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
|
||||||
.iso-controls__error { margin: var(--space-2) 0 0; font-size: var(--type-table-body-size); }
|
.iso-controls__error { margin: var(--space-2) 0 0; font-size: var(--type-table-body-size); }
|
||||||
/* The theme facts are this tile's purpose, so they stay visible where narrow environment tiles show two facts. */
|
/* The theme facts are this tile's purpose, so they stay visible where narrow environment tiles show two facts. */
|
||||||
.client-compliance-grid .client-env-facts > div:nth-child(n + 3) { display: grid; }
|
.client-compliance-grid .client-env-facts > div:nth-child(n + 3) { display: grid; }
|
||||||
|
|
@ -55,3 +70,89 @@ button.iso-controls__theme:hover .iso-controls__theme-name { text-decoration: un
|
||||||
.regulatory-earlier-controls { margin-top: 16px; }
|
.regulatory-earlier-controls { margin-top: 16px; }
|
||||||
.regulatory-earlier-controls > h4 { margin-bottom: 4px; }
|
.regulatory-earlier-controls > h4 { margin-bottom: 4px; }
|
||||||
.regulatory-earlier-controls > p { color: var(--text-muted); }
|
.regulatory-earlier-controls > p { color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Data Hub ISO tile: switch for automatic mapping of newly uploaded scans. */
|
||||||
|
.iso-auto-mapping-toggle { display: flex; align-items: center; gap: var(--space-2); color: var(--text-primary); font-size: var(--type-table-body-size); }
|
||||||
|
|
||||||
|
/* `Last mapping` button and its empty state beside the tab heading. */
|
||||||
|
.client-compliance-last-mapping { font-variant-numeric: tabular-nums; }
|
||||||
|
.client-compliance-last-mapping.is-empty { color: var(--text-secondary); font-size: var(--type-table-body-size); }
|
||||||
|
/* Mapping history is a data table, not field/value pairs: every cell keeps the 14px table role. */
|
||||||
|
.client-information-table.client-compliance-history td:nth-child(2) { font-size: var(--type-table-body-size); }
|
||||||
|
|
||||||
|
/* Client Info `Compliance` overview row: same six tracks as the framework row below, so column starts align.
|
||||||
|
Mapping coverage is a square over two tracks; the Annex A footprint takes the other four at the same height. */
|
||||||
|
.compliance-overview { display: grid; grid-template-columns: repeat(6, minmax(var(--tile-environment-min-width), 1fr)); gap: var(--segment-gap); min-width: 0; margin-top: var(--segment-gap); overflow-x: auto; }
|
||||||
|
.compliance-overview > .client-env-group { display: flex; flex-direction: column; min-width: 0; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); }
|
||||||
|
.compliance-overview .client-env-group-title { font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
|
||||||
|
.compliance-coverage { grid-column: span 2; aspect-ratio: 1; }
|
||||||
|
.compliance-footprint { grid-column: span 4; }
|
||||||
|
|
||||||
|
/* Coverage: the shared Client Info donut; accepted, mapped-only and failed are arcs, not mapped is the track. */
|
||||||
|
.compliance-coverage__body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; gap: var(--space-5); flex: 1; min-height: 0; margin-top: var(--space-4); }
|
||||||
|
.compliance-coverage__ring { display: grid; justify-items: center; gap: var(--space-2); min-width: 0; }
|
||||||
|
.compliance-coverage .client-info-donut { width: min(196px, 100%); }
|
||||||
|
.compliance-coverage__caption { color: var(--donut-label); font-size: var(--donut-title-size); text-align: center; }
|
||||||
|
.compliance-coverage [data-state="accepted"], .compliance-coverage [data-state="bsi"] { --chart-color: var(--chart-tone-emphasis); }
|
||||||
|
.compliance-coverage [data-state="isoOnly"] { --chart-color: color-mix(in srgb, var(--chart-tone-emphasis) 45%, var(--tile-surface)); }
|
||||||
|
.compliance-coverage [data-state="mappedNoneAccepted"] { --chart-color: var(--donut-not-run); }
|
||||||
|
.compliance-coverage [data-state="failed"] { --chart-color: var(--donut-error); }
|
||||||
|
.compliance-coverage [data-state="notMapped"] { --chart-color: var(--donut-track); }
|
||||||
|
.compliance-coverage__legend { display: grid; min-width: 0; margin: 0; border-top: var(--bw-hairline) solid var(--tile-divider); }
|
||||||
|
.compliance-coverage__legend > div { display: grid; grid-template-columns: var(--space-3) minmax(0, 1fr) max-content; align-items: baseline; gap: var(--space-2); padding: var(--space-2); border-bottom: var(--bw-hairline) solid var(--tile-divider); }
|
||||||
|
.compliance-coverage__legend i { width: var(--space-3); height: var(--space-3); align-self: center; background: var(--chart-color, transparent); }
|
||||||
|
.compliance-coverage__legend dt { min-width: 0; color: var(--text-secondary); font-size: var(--type-table-body-size); overflow-wrap: break-word; }
|
||||||
|
.compliance-coverage__legend dd { margin: 0; font-size: var(--type-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; text-align: right; }
|
||||||
|
.compliance-coverage__legend .is-total dt { color: var(--text-primary); }
|
||||||
|
.compliance-coverage__note { margin: var(--space-3) 0 0; color: var(--text-secondary); font-size: var(--type-table-body-size); }
|
||||||
|
|
||||||
|
/* Footprint: all 93 Annex A controls; one neutral chart tone in four steps, never a status colour. */
|
||||||
|
.compliance-footprint__themes { display: flex; flex-wrap: wrap; align-items: flex-start; gap: var(--space-6); margin-block: var(--space-4); }
|
||||||
|
.compliance-footprint__column { display: grid; gap: var(--space-4); }
|
||||||
|
.compliance-footprint__theme { display: grid; align-content: start; gap: var(--space-2); }
|
||||||
|
.compliance-footprint__theme-head { display: grid; justify-items: start; padding: 0 0 var(--space-1); border: 0; border-bottom: var(--bw-hairline) solid var(--tile-divider); background: transparent; color: var(--text-primary); font-family: inherit; text-align: left; }
|
||||||
|
button.compliance-footprint__theme-head { cursor: pointer; }
|
||||||
|
button.compliance-footprint__theme-head:hover .compliance-footprint__theme-name { text-decoration: underline; }
|
||||||
|
button.compliance-footprint__theme-head:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--bw-emphasis); }
|
||||||
|
.compliance-footprint__theme-name { font-size: var(--type-body-size); font-weight: var(--fw-semibold); }
|
||||||
|
.compliance-footprint__theme-meta { color: var(--text-secondary); font-size: var(--type-table-body-size); font-variant-numeric: tabular-nums; }
|
||||||
|
.compliance-footprint__cells { display: grid; grid-template-columns: repeat(6, calc(var(--space-8) + var(--space-1) + var(--bw-emphasis))); gap: calc(var(--space-1) - var(--bw-hairline)); }
|
||||||
|
.compliance-footprint__cell { display: grid; place-items: center; height: calc(var(--space-6) + var(--space-1) + var(--bw-emphasis)); padding: 0; border: var(--bw-hairline) solid var(--tile-divider); background: transparent; color: var(--text-secondary); font-family: inherit; font-size: var(--type-meta-size); font-variant-numeric: tabular-nums; }
|
||||||
|
.compliance-footprint__cell[data-step] { border-color: transparent; color: var(--text-primary); }
|
||||||
|
button.compliance-footprint__cell { cursor: pointer; }
|
||||||
|
.compliance-footprint__cell[data-step="1"] { background: color-mix(in srgb, var(--chart-tone-emphasis) 22%, var(--tile-surface)); }
|
||||||
|
.compliance-footprint__cell[data-step="2"] { background: color-mix(in srgb, var(--chart-tone-emphasis) 45%, var(--tile-surface)); }
|
||||||
|
.compliance-footprint__cell[data-step="3"] { background: color-mix(in srgb, var(--chart-tone-emphasis) 72%, var(--tile-surface)); color: var(--sev-ink); }
|
||||||
|
.compliance-footprint__cell[data-step="4"] { background: var(--chart-tone-emphasis); color: var(--sev-ink); font-weight: var(--fw-semibold); }
|
||||||
|
button.compliance-footprint__cell:hover, .compliance-footprint__cell.is-current { outline: var(--bw-hairline) solid var(--text-primary); outline-offset: calc(var(--bw-hairline) * -1); }
|
||||||
|
button.compliance-footprint__cell:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: 0; }
|
||||||
|
.compliance-footprint__detail { display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: center; gap: var(--space-3); min-height: calc(var(--space-8) * 1.75); margin-top: auto; padding: var(--space-2) var(--space-3); border-block: var(--bw-hairline) solid var(--tile-divider); }
|
||||||
|
.compliance-footprint__detail-id, .compliance-footprint__detail-hint { color: var(--text-secondary); font-size: var(--type-table-body-size); }
|
||||||
|
.compliance-footprint__detail-title { font-size: var(--type-body-size); overflow-wrap: break-word; }
|
||||||
|
.compliance-footprint__detail-count { font-size: var(--type-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
|
||||||
|
.compliance-footprint__legend { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2) var(--space-4); margin-top: var(--space-3); color: var(--text-secondary); font-size: var(--type-table-body-size); }
|
||||||
|
.compliance-footprint__legend > span { display: inline-flex; align-items: center; gap: var(--space-2); }
|
||||||
|
.compliance-footprint__legend .compliance-footprint__cell { width: calc(var(--space-5) + var(--bw-emphasis)); height: var(--space-4); }
|
||||||
|
|
||||||
|
/* BSI Kernel footprint: every requirement of a shown practice is a square with its number, like an Annex A cell;
|
||||||
|
the practices stack at full width so their squares wrap across the tile. A requirement the ISO mapping cannot
|
||||||
|
reach has a dashed outline, a reachable one without findings the solid outline of an empty Annex A cell. */
|
||||||
|
.compliance-footprint__practices { display: grid; gap: var(--space-4); margin-block: var(--space-4); }
|
||||||
|
.compliance-footprint__requirements { display: flex; flex-wrap: wrap; gap: calc(var(--space-1) - var(--bw-hairline)); }
|
||||||
|
.compliance-footprint__requirements > .compliance-footprint__cell { min-width: calc(var(--space-8) + var(--space-1) + var(--bw-emphasis)); height: calc(var(--space-6) + var(--space-1) + var(--bw-emphasis)); padding: 0 var(--space-1); white-space: nowrap; }
|
||||||
|
.compliance-footprint__cell.is-unreachable { border-style: dashed; }
|
||||||
|
.compliance-footprint__pending { margin: var(--space-4) 0; color: var(--text-secondary); font-size: var(--type-body-size); }
|
||||||
|
/* Show all window: every practice, with larger squares and the 14px data role for the numbers. */
|
||||||
|
.compliance-footprint--window { min-width: 0; }
|
||||||
|
.compliance-footprint__practices.is-window .compliance-footprint__requirements { gap: var(--space-1); }
|
||||||
|
.compliance-footprint__practices.is-window .compliance-footprint__requirements > .compliance-footprint__cell { min-width: calc(var(--space-8) + var(--space-4)); height: calc(var(--space-8) + var(--space-1)); padding: 0 var(--space-2); font-size: var(--type-table-body-size); }
|
||||||
|
.compliance-footprint__footer { margin-top: auto; }
|
||||||
|
/* Pinned to the bottom of the window body (its 20px inset included), on the window surface, while the grid scrolls. */
|
||||||
|
.compliance-footprint--window .compliance-footprint__footer { position: sticky; bottom: calc(var(--panel-inset) * -1); padding-bottom: var(--panel-inset); background: var(--bg); }
|
||||||
|
/* `Mapping failed` with findings opens Work: one click target over the whole legend row; hover changes the surface only. */
|
||||||
|
.compliance-coverage__legend > div.is-actionable { position: relative; cursor: pointer; }
|
||||||
|
.compliance-coverage__legend > div.is-actionable:hover { background: var(--tile-surface-hover); }
|
||||||
|
.compliance-coverage__legend > div.is-actionable:has(button:focus-visible) { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(var(--bw-emphasis) * -1); }
|
||||||
|
.compliance-coverage__legend dd button { padding: 0; border: 0; background: transparent; color: inherit; font: inherit; font-variant-numeric: tabular-nums; cursor: pointer; }
|
||||||
|
.compliance-coverage__legend dd button::after { content: ''; position: absolute; inset: 0; }
|
||||||
|
.compliance-coverage__legend dd button:focus-visible { outline: none; }
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useSyncExternalStore } from 'react';
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
// Counts saved control decisions in this browser session, so views that summarize accepted
|
// Counts saved control decisions in this browser session, so views that summarize accepted
|
||||||
// controls (the Client Info overview) reload after a decision made elsewhere.
|
// controls (the Client Info overview) reload after a decision made elsewhere. A change of the active
|
||||||
|
// BSI catalog counts too, because the derived BSI requirements follow it.
|
||||||
let revision = 0;
|
let revision = 0;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { BackendStatus } from '../../app/app-state';
|
||||||
import type { FindingRecord } from '../../domain/finding';
|
import type { FindingRecord } from '../../domain/finding';
|
||||||
import { AssessorApiClient } from '../../api/assessor-api-client';
|
import { AssessorApiClient } from '../../api/assessor-api-client';
|
||||||
import { useEnginePreferences } from '../../app/engine-preferences';
|
import { useEnginePreferences } from '../../app/engine-preferences';
|
||||||
|
import { useOptionalApp } from '../../app/AppProvider';
|
||||||
import { mappingRunKey, subscribeRegulatoryMappingRuns, trackRegulatoryMappingRun } from './mapping-run-tracker';
|
import { mappingRunKey, subscribeRegulatoryMappingRuns, trackRegulatoryMappingRun } from './mapping-run-tracker';
|
||||||
import { markRegulatoryDecisionSaved } from './decision-revision';
|
import { markRegulatoryDecisionSaved } from './decision-revision';
|
||||||
import {
|
import {
|
||||||
|
|
@ -29,6 +30,10 @@ export function useRegulatoryMapping(
|
||||||
) {
|
) {
|
||||||
// A switched-off Compliance Engine shows no control, opens nothing and sends no request.
|
// A switched-off Compliance Engine shows no control, opens nothing and sends no request.
|
||||||
const available = useEnginePreferences().compliance;
|
const available = useEnginePreferences().compliance;
|
||||||
|
// The shown timeslot is where a single mapping comes from; the mapping history names it relative to its view.
|
||||||
|
const app = useOptionalApp();
|
||||||
|
const origin = useRef({ assessmentIds: [] as string[], scanRunIds: [] as string[] });
|
||||||
|
origin.current = { assessmentIds: [...(app?.activeAssessmentIds ?? [])], scanRunIds: [...(app?.activeScanRunIds ?? [])] };
|
||||||
const [requestedOpen, setOpen] = useState(false);
|
const [requestedOpen, setOpen] = useState(false);
|
||||||
const open = requestedOpen && available;
|
const open = requestedOpen && available;
|
||||||
// Re-enabling the engine starts with the window closed again.
|
// Re-enabling the engine starts with the window closed again.
|
||||||
|
|
@ -77,7 +82,7 @@ export function useRegulatoryMapping(
|
||||||
if (!projectId || !resultProjectionId || activeScope.current !== requestScope) return;
|
if (!projectId || !resultProjectionId || activeScope.current !== requestScope) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const next = await assessorApi.generateRegulatoryMappings(projectId, { resultProjectionId });
|
const next = await assessorApi.generateRegulatoryMappings(projectId, { resultProjectionId, origin: origin.current });
|
||||||
trackRegulatoryMappingRun(projectId, resultProjectionId, next);
|
trackRegulatoryMappingRun(projectId, resultProjectionId, next);
|
||||||
if (activeScope.current !== requestScope) return;
|
if (activeScope.current !== requestScope) return;
|
||||||
setStoredResponse({ scope: requestScope, value: next });
|
setStoredResponse({ scope: requestScope, value: next });
|
||||||
|
|
@ -138,6 +143,8 @@ export function useRegulatoryMapping(
|
||||||
decide,
|
decide,
|
||||||
acceptedControls,
|
acceptedControls,
|
||||||
acceptedCount: acceptedControls.length,
|
acceptedCount: acceptedControls.length,
|
||||||
|
/** BSI requirements derived from the accepted controls; undefined while the engine is off or none is derived. */
|
||||||
|
bsi: available ? response?.bsi : undefined,
|
||||||
disabledReason: localDisabledReason
|
disabledReason: localDisabledReason
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { readEnginePreferences, useEnginePreferences } from '../../app/engine-preferences';
|
import { readEnginePreferences, useEnginePreferences } from '../../app/engine-preferences';
|
||||||
import { ClientContextHeatmap } from '../context-layer/ContextHeatmap';
|
import { ClientContextHeatmap } from '../context-layer/ContextHeatmap';
|
||||||
import { ClientIsoControls } from '../../compliance/ui/ClientIsoControls';
|
import { ClientIsoControls } from '../../compliance/ui/ClientIsoControls';
|
||||||
import { ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID } from '../../compliance/ui/compliance-work-facets';
|
import { BSI_PRACTICE_FACET_ID, BSI_REQUIREMENT_FACET_ID, ISO_CONTROL_FACET_ID, ISO_MAPPING_FACET_ID, ISO_THEME_FACET_ID } from '../../compliance/ui/compliance-work-facets';
|
||||||
import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet';
|
import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet';
|
||||||
import { useAssessmentContext } from '../context-layer/use-assessment-context';
|
import { useAssessmentContext } from '../context-layer/use-assessment-context';
|
||||||
import { publishContextReport } from '../../assessment-context/context-report-publication';
|
import { publishContextReport } from '../../assessment-context/context-report-publication';
|
||||||
|
|
@ -1408,18 +1408,22 @@ export function dashboardAnalysis(
|
||||||
export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||||
const app = useApp();
|
const app = useApp();
|
||||||
const { dataset, activeSheet, activeProfile, reviews, setStatusFilter, setFacetFilter, setPathFindingIds, resetFilters, setView } = app;
|
const { dataset, activeSheet, activeProfile, reviews, setStatusFilter, setFacetFilter, setPathFindingIds, resetFilters, setView } = app;
|
||||||
const [clientInfoView, setClientInfoView] = useState<'overview' | 'defender' | 'ai'>('overview');
|
const [clientInfoView, setClientInfoView] = useState<'overview' | 'defender' | 'compliance' | 'ai'>('overview');
|
||||||
const hasDefender = useMemo(() => Boolean(dataset && defenderModelForDataset(dataset).available), [dataset]);
|
const hasDefender = useMemo(() => Boolean(dataset && defenderModelForDataset(dataset).available), [dataset]);
|
||||||
const pureDefender = dataset?.profileId === DEFENDER_FOR_IOT_PROFILE_ID;
|
const pureDefender = dataset?.profileId === DEFENDER_FOR_IOT_PROFILE_ID;
|
||||||
const environment = useEnvironmentSnapshot(app.targetProjectId, app.activeScanRunIds);
|
const environment = useEnvironmentSnapshot(app.targetProjectId, app.activeScanRunIds);
|
||||||
const aiOverview = useAiOverview(app.targetProjectId, app.activeAssessmentIds ?? [], app.activeScanRunIds ?? []);
|
const aiOverview = useAiOverview(app.targetProjectId, app.activeAssessmentIds ?? [], app.activeScanRunIds ?? []);
|
||||||
const showAiOverview = aiOverview.available && Boolean(aiOverview.loading || aiOverview.response?.generationEnabled || aiOverview.response?.run);
|
const showAiOverview = aiOverview.available && Boolean(aiOverview.loading || aiOverview.response?.generationEnabled || aiOverview.response?.run);
|
||||||
|
// Compliance Engine tab: shown while the engine is on and the backend can deliver the timeslot's ISO overview.
|
||||||
|
const showCompliance = useEnginePreferences().compliance && app.backendStatus === 'connected' && Boolean(app.targetProjectId)
|
||||||
|
&& Boolean(app.activeAssessmentIds?.length || app.activeScanRunIds?.length);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pureDefender && clientInfoView === 'overview') setClientInfoView('defender');
|
if (pureDefender && clientInfoView === 'overview') setClientInfoView('defender');
|
||||||
else if (clientInfoView === 'defender' && !hasDefender) setClientInfoView('overview');
|
else if (clientInfoView === 'defender' && !hasDefender) setClientInfoView('overview');
|
||||||
else if (clientInfoView === 'ai' && !showAiOverview) setClientInfoView(pureDefender ? 'defender' : 'overview');
|
else if (clientInfoView === 'ai' && !showAiOverview) setClientInfoView(pureDefender ? 'defender' : 'overview');
|
||||||
}, [clientInfoView, hasDefender, pureDefender, showAiOverview]);
|
else if (clientInfoView === 'compliance' && !showCompliance) setClientInfoView(pureDefender ? 'defender' : 'overview');
|
||||||
|
}, [clientInfoView, hasDefender, pureDefender, showAiOverview, showCompliance]);
|
||||||
const gpoInventory = useGpoInventory(app.targetProjectId);
|
const gpoInventory = useGpoInventory(app.targetProjectId);
|
||||||
const sourceFilter = app.facetFilters['provenance:source-scan'];
|
const sourceFilter = app.facetFilters['provenance:source-scan'];
|
||||||
const analysis = useDashboardAnalysis(activeSheet, dataset, activeProfile, reviews, sourceFilter);
|
const analysis = useDashboardAnalysis(activeSheet, dataset, activeProfile, reviews, sourceFilter);
|
||||||
|
|
@ -1472,9 +1476,19 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||||
if (!dataset || !activeSheet) return app.targetProjectId
|
if (!dataset || !activeSheet) return app.targetProjectId
|
||||||
? <div className="dashboard-page panel-is-loading"><h2>Client Info</h2><PanelLoadingState message={app.status}/></div>
|
? <div className="dashboard-page panel-is-loading"><h2>Client Info</h2><PanelLoadingState message={app.status}/></div>
|
||||||
: <EmptyWorkspace />;
|
: <EmptyWorkspace />;
|
||||||
const clientInfoViews: Array<readonly ['overview' | 'defender' | 'ai', string]> = [
|
const jump = (apply: () => void, preserveSource = true) => {
|
||||||
|
resetFilters();
|
||||||
|
if (preserveSource && sourceFilter && analysis.sourceFacet) setFacetFilter(analysis.sourceFacet.id, sourceFilter);
|
||||||
|
app.setActionOnly(false);
|
||||||
|
setStatusFilter('deviation');
|
||||||
|
apply();
|
||||||
|
setView('columns');
|
||||||
|
onOpenWork();
|
||||||
|
};
|
||||||
|
const clientInfoViews: Array<readonly ['overview' | 'defender' | 'compliance' | 'ai', string]> = [
|
||||||
...(pureDefender ? [] : [['overview', 'Overview'] as const]),
|
...(pureDefender ? [] : [['overview', 'Overview'] as const]),
|
||||||
...(hasDefender ? [['defender', 'Microsoft Defender for IoT'] as const] : []),
|
...(hasDefender ? [['defender', 'Microsoft Defender for IoT'] as const] : []),
|
||||||
|
...(showCompliance ? [['compliance', 'Compliance'] as const] : []),
|
||||||
...(showAiOverview ? [['ai', 'AI Overview'] as const] : [])
|
...(showAiOverview ? [['ai', 'AI Overview'] as const] : [])
|
||||||
];
|
];
|
||||||
const clientInfoTabs = clientInfoViews.length > 1 ? <nav className="client-info-tabs" aria-label="Client Info views">
|
const clientInfoTabs = clientInfoViews.length > 1 ? <nav className="client-info-tabs" aria-label="Client Info views">
|
||||||
|
|
@ -1482,6 +1496,53 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||||
<button type="button" key={id} aria-pressed={clientInfoView === id} onClick={() => setClientInfoView(id)}>{title}</button>)}
|
<button type="button" key={id} aria-pressed={clientInfoView === id} onClick={() => setClientInfoView(id)}>{title}</button>)}
|
||||||
</nav> : undefined;
|
</nav> : undefined;
|
||||||
if (clientInfoView === 'ai' && showAiOverview) return <AiOverviewPanel controller={aiOverview} navigation={clientInfoTabs} />;
|
if (clientInfoView === 'ai' && showAiOverview) return <AiOverviewPanel controller={aiOverview} navigation={clientInfoTabs} />;
|
||||||
|
// Compliance Engine tab: one tile per framework; a Results window opens a control or theme in Work.
|
||||||
|
if (clientInfoView === 'compliance' && showCompliance) return <ClientIsoControls navigation={clientInfoTabs} projectId={app.targetProjectId} assessmentIds={app.activeAssessmentIds} scanRunIds={app.activeScanRunIds}
|
||||||
|
backendStatus={app.backendStatus} onOpenControl={controlId => jump(() => {
|
||||||
|
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
|
||||||
|
if (targetSheet) {
|
||||||
|
app.setActiveSheet(targetSheet.id);
|
||||||
|
setStatusFilter('deviation');
|
||||||
|
}
|
||||||
|
setFacetFilter(ISO_CONTROL_FACET_ID, { values: [controlId] });
|
||||||
|
}, false)}
|
||||||
|
onOpenTheme={theme => jump(() => {
|
||||||
|
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
|
||||||
|
if (targetSheet) {
|
||||||
|
app.setActiveSheet(targetSheet.id);
|
||||||
|
setStatusFilter('deviation');
|
||||||
|
}
|
||||||
|
setFacetFilter(ISO_THEME_FACET_ID, { values: [theme.toLowerCase()] });
|
||||||
|
}, false)}
|
||||||
|
onOpenBsiRequirement={requirementId => jump(() => {
|
||||||
|
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
|
||||||
|
if (targetSheet) {
|
||||||
|
app.setActiveSheet(targetSheet.id);
|
||||||
|
setStatusFilter('deviation');
|
||||||
|
}
|
||||||
|
setFacetFilter(BSI_REQUIREMENT_FACET_ID, { values: [requirementId.toLowerCase()] });
|
||||||
|
}, false)}
|
||||||
|
onOpenBsiPractice={practiceId => jump(() => {
|
||||||
|
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
|
||||||
|
if (targetSheet) {
|
||||||
|
app.setActiveSheet(targetSheet.id);
|
||||||
|
setStatusFilter('deviation');
|
||||||
|
}
|
||||||
|
setFacetFilter(BSI_PRACTICE_FACET_ID, { values: [practiceId.toLowerCase()] });
|
||||||
|
}, false)}
|
||||||
|
onOpenMappingState={state => jump(() => {
|
||||||
|
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
|
||||||
|
if (targetSheet) {
|
||||||
|
app.setActiveSheet(targetSheet.id);
|
||||||
|
setStatusFilter('deviation');
|
||||||
|
}
|
||||||
|
setFacetFilter(ISO_MAPPING_FACET_ID, { values: [state] });
|
||||||
|
}, false)}
|
||||||
|
// Client and timeslot like the Overview; no Source control, because the tab always covers the whole timeslot.
|
||||||
|
context={<header className="client-overview-header">
|
||||||
|
<div className="client-overview-identity"><span>Client:</span><strong>{dataset.fileName}</strong></div>
|
||||||
|
<div className="client-overview-assessment"><span>Assessment:</span><WorkTimeslotControl /></div>
|
||||||
|
</header>}/>;
|
||||||
if (hasDefender && (clientInfoView === 'defender' || pureDefender)) {
|
if (hasDefender && (clientInfoView === 'defender' || pureDefender)) {
|
||||||
return <DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} reviews={reviews}
|
return <DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} reviews={reviews}
|
||||||
scopedRiskAcceptances={app.scopedRiskAcceptances} activeScanRunIds={app.activeScanRunIds}
|
scopedRiskAcceptances={app.scopedRiskAcceptances} activeScanRunIds={app.activeScanRunIds}
|
||||||
|
|
@ -1540,15 +1601,6 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||||
const domainDistributionTotal = displayedDomains.reduce((sum, domain) => sum + dashboardDomainAmount(domain), 0);
|
const domainDistributionTotal = displayedDomains.reduce((sum, domain) => sum + dashboardDomainAmount(domain), 0);
|
||||||
const overallTone = dashboardScoreTone(overallScore);
|
const overallTone = dashboardScoreTone(overallScore);
|
||||||
|
|
||||||
const jump = (apply: () => void, preserveSource = true) => {
|
|
||||||
resetFilters();
|
|
||||||
if (preserveSource && sourceFilter && sourceFacet) setFacetFilter(sourceFacet.id, sourceFilter);
|
|
||||||
app.setActionOnly(false);
|
|
||||||
setStatusFilter('deviation');
|
|
||||||
apply();
|
|
||||||
setView('columns');
|
|
||||||
onOpenWork();
|
|
||||||
};
|
|
||||||
const sourceOverviewContent = (dismissResults: () => void) => orderedSourceScores.length ? <ul>{orderedSourceScores.map(source =>
|
const sourceOverviewContent = (dismissResults: () => void) => orderedSourceScores.length ? <ul>{orderedSourceScores.map(source =>
|
||||||
<DashboardSourceListItem key={source.tool.id} source={source} native={nativeMetrics}
|
<DashboardSourceListItem key={source.tool.id} source={source} native={nativeMetrics}
|
||||||
onClick={sourceFacet && source.tool.sourceValues.length ? () => { dismissResults(); jump(() => {
|
onClick={sourceFacet && source.tool.sourceValues.length ? () => { dismissResults(); jump(() => {
|
||||||
|
|
@ -1671,25 +1723,6 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||||
setFacetFilter(CONTEXT_CLUSTER_FACET_ID, { values: [clusterId] });
|
setFacetFilter(CONTEXT_CLUSTER_FACET_ID, { values: [clusterId] });
|
||||||
}, false)} />
|
}, false)} />
|
||||||
|
|
||||||
{/* Compliance Engine: `Compliance` row with one tile per framework; its Results window opens a control or theme in Work. */}
|
|
||||||
<ClientIsoControls projectId={app.targetProjectId} assessmentIds={app.activeAssessmentIds} scanRunIds={app.activeScanRunIds}
|
|
||||||
backendStatus={app.backendStatus} onOpenControl={controlId => jump(() => {
|
|
||||||
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
|
|
||||||
if (targetSheet) {
|
|
||||||
app.setActiveSheet(targetSheet.id);
|
|
||||||
setStatusFilter('deviation');
|
|
||||||
}
|
|
||||||
setFacetFilter(ISO_CONTROL_FACET_ID, { values: [controlId] });
|
|
||||||
}, false)}
|
|
||||||
onOpenTheme={theme => jump(() => {
|
|
||||||
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
|
|
||||||
if (targetSheet) {
|
|
||||||
app.setActiveSheet(targetSheet.id);
|
|
||||||
setStatusFilter('deviation');
|
|
||||||
}
|
|
||||||
setFacetFilter(ISO_THEME_FACET_ID, { values: [theme.toLowerCase()] });
|
|
||||||
}, false)} />
|
|
||||||
|
|
||||||
{scoringOpen && <ScoringMethodology sources={sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(false)} />}
|
{scoringOpen && <ScoringMethodology sources={sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(false)} />}
|
||||||
|
|
||||||
<div className="client-environment-grid" aria-label="Environment overview">
|
<div className="client-environment-grid" aria-label="Environment overview">
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import { maesterExecutionErrorText, maesterFindingTitle, maesterTagText } from '
|
||||||
import type { BackendStatus } from '../../app/app-state';
|
import type { BackendStatus } from '../../app/app-state';
|
||||||
import { RegulatoryMappingDialog } from '../../compliance/ui/RegulatoryMappingPanel';
|
import { RegulatoryMappingDialog } from '../../compliance/ui/RegulatoryMappingPanel';
|
||||||
import { FindingIsoControls } from '../../compliance/ui/FindingIsoControls';
|
import { FindingIsoControls } from '../../compliance/ui/FindingIsoControls';
|
||||||
|
import { FindingBsiRequirements } from '../../compliance/ui/FindingBsiRequirements';
|
||||||
import { regulatoryMappingResultId, useRegulatoryMapping } from '../../compliance/ui/use-regulatory-mapping';
|
import { regulatoryMappingResultId, useRegulatoryMapping } from '../../compliance/ui/use-regulatory-mapping';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -555,6 +556,8 @@ export function FindingReader({ record, title, review: storedReview, previousPol
|
||||||
: <RichTextValue value={field.value} search={search} label={field.label} onResults={onResults} />}</div>)}
|
: <RichTextValue value={field.value} search={search} label={field.label} onResults={onResults} />}</div>)}
|
||||||
{/* Compliance Engine: controls accepted for this finding, read like Description. */}
|
{/* Compliance Engine: controls accepted for this finding, read like Description. */}
|
||||||
<FindingIsoControls controls={regulatoryMapping.acceptedControls}/>
|
<FindingIsoControls controls={regulatoryMapping.acceptedControls}/>
|
||||||
|
{/* Compliance Engine: BSI requirements derived from those controls through the BSI mapping. */}
|
||||||
|
<FindingBsiRequirements bsi={regulatoryMapping.bsi}/>
|
||||||
{optionalFields.map(field => <details className="finding-optional-detail" key={`${field.index}-${field.label}`}>
|
{optionalFields.map(field => <details className="finding-optional-detail" key={`${field.index}-${field.label}`}>
|
||||||
<summary><TriangleIcon direction="right" />{highlightText(field.label, search)}</summary>
|
<summary><TriangleIcon direction="right" />{highlightText(field.label, search)}</summary>
|
||||||
{resolvedToolId === 'maester' && normalizeLabel(field.label) === 'test details'
|
{resolvedToolId === 'maester' && normalizeLabel(field.label) === 'test details'
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadi
|
||||||
import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog';
|
import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog';
|
||||||
import { MicrosoftBaselineLibrary } from './MicrosoftBaselineLibrary';
|
import { MicrosoftBaselineLibrary } from './MicrosoftBaselineLibrary';
|
||||||
import { IsoCorpusLibrary } from '../../compliance/ui/IsoCorpusLibrary';
|
import { IsoCorpusLibrary } from '../../compliance/ui/IsoCorpusLibrary';
|
||||||
|
import { BsiCatalogLibrary } from '../../compliance/ui/BsiCatalogLibrary';
|
||||||
import { MitreMatrixLibrary } from './MitreMatrixLibrary';
|
import { MitreMatrixLibrary } from './MitreMatrixLibrary';
|
||||||
import { ReferenceRecognitionPanel } from './ReferenceRecognitionPanel';
|
import { ReferenceRecognitionPanel } from './ReferenceRecognitionPanel';
|
||||||
import { activeMitreMatrix, clearStoredMitreMatrix, loadStoredMitreMatrix, MITRE_MATRIX_CHANGED_EVENT, saveStoredMitreMatrix, setServerMitreMatrix, validateMitreMatrixData } from '../mitre-graph/mitre-graph';
|
import { activeMitreMatrix, clearStoredMitreMatrix, loadStoredMitreMatrix, MITRE_MATRIX_CHANGED_EVENT, saveStoredMitreMatrix, setServerMitreMatrix, validateMitreMatrixData } from '../mitre-graph/mitre-graph';
|
||||||
|
|
@ -1001,6 +1002,7 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
|
||||||
<div className="datahub-reference-row">
|
<div className="datahub-reference-row">
|
||||||
<MicrosoftBaselineLibrary/>
|
<MicrosoftBaselineLibrary/>
|
||||||
<IsoCorpusLibrary/>
|
<IsoCorpusLibrary/>
|
||||||
|
<BsiCatalogLibrary/>
|
||||||
<MitreMatrixLibrary
|
<MitreMatrixLibrary
|
||||||
status={mitreStatus}
|
status={mitreStatus}
|
||||||
activeVersion={mitreActiveVersion}
|
activeVersion={mitreActiveVersion}
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@
|
||||||
.datahub-reference-row > .mitre-library { grid-area: 1 / 1 / 2 / -1; }
|
.datahub-reference-row > .mitre-library { grid-area: 1 / 1 / 2 / -1; }
|
||||||
.datahub-reference-row > .baseline-catalog { grid-area: 2 / 1 / 3 / -1; margin-top: 0; }
|
.datahub-reference-row > .baseline-catalog { grid-area: 2 / 1 / 3 / -1; margin-top: 0; }
|
||||||
.datahub-reference-row > .iso-corpus-catalog { grid-area: 3 / 1 / 4 / -1; }
|
.datahub-reference-row > .iso-corpus-catalog { grid-area: 3 / 1 / 4 / -1; }
|
||||||
|
.datahub-reference-row > .bsi-catalog-library { grid-area: 4 / 1 / 5 / -1; }
|
||||||
.iso-corpus-content { display: grid; gap: var(--space-4); padding: var(--space-5); }
|
.iso-corpus-content { display: grid; gap: var(--space-4); padding: var(--space-5); }
|
||||||
.iso-corpus-summary { display: grid; gap: var(--space-2); color: var(--text-primary); font-size: var(--type-table-body-size); }
|
.iso-corpus-summary { display: grid; gap: var(--space-2); color: var(--text-primary); font-size: var(--type-table-body-size); }
|
||||||
.iso-corpus-summary strong { font-weight: var(--fw-semibold); }
|
.iso-corpus-summary strong { font-weight: var(--fw-semibold); }
|
||||||
|
|
@ -143,6 +144,7 @@
|
||||||
.datahub-reference-row > .mitre-library { grid-area: 1 / 1; }
|
.datahub-reference-row > .mitre-library { grid-area: 1 / 1; }
|
||||||
.datahub-reference-row > .baseline-catalog { grid-area: 2 / 1; }
|
.datahub-reference-row > .baseline-catalog { grid-area: 2 / 1; }
|
||||||
.datahub-reference-row > .iso-corpus-catalog { grid-area: 3 / 1; }
|
.datahub-reference-row > .iso-corpus-catalog { grid-area: 3 / 1; }
|
||||||
|
.datahub-reference-row > .bsi-catalog-library { grid-area: 4 / 1; }
|
||||||
.tenant-timeslot-toolbar, .tenant-timeslot-create { grid-template-columns: 1fr; }
|
.tenant-timeslot-toolbar, .tenant-timeslot-create { grid-template-columns: 1fr; }
|
||||||
.datahub-rack-header { grid-template-columns: 20px 10px minmax(0, 1fr) auto; }
|
.datahub-rack-header { grid-template-columns: 20px 10px minmax(0, 1fr) auto; }
|
||||||
.datahub-rack-actions { justify-content: stretch; }
|
.datahub-rack-actions { justify-content: stretch; }
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ const components = readFileSync(new URL('../../src/styles/components.css', impor
|
||||||
|
|
||||||
describe('ASSESSOR version in the brand header', () => {
|
describe('ASSESSOR version in the brand header', () => {
|
||||||
it('shows the centrally maintained product version and opens About from the brand', () => {
|
it('shows the centrally maintained product version and opens About from the brand', () => {
|
||||||
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.19.1');
|
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.22.0');
|
||||||
expect(app).toContain("'db-structure': 'Data Flow'");
|
expect(app).toContain("'db-structure': 'Data Flow'");
|
||||||
expect(app).toContain("['db-structure', 'Data Flow']");
|
expect(app).toContain("['db-structure', 'Data Flow']");
|
||||||
expect(app).not.toContain("'DB Structure'");
|
expect(app).not.toContain("'DB Structure'");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { AssessorApiClient } from '../../src/api/assessor-api-client';
|
||||||
|
import { ENGINE_PREFERENCES_KEY, resetEnginePreferencesForTests } from '../../src/app/engine-preferences';
|
||||||
|
import { EnginesControl } from '../../src/components/EnginesControl';
|
||||||
|
// Importing the Data Hub ISO tile installs the link, as it does when the app loads.
|
||||||
|
import '../../src/compliance/ui/IsoCorpusLibrary';
|
||||||
|
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
window.localStorage.removeItem(ENGINE_PREFERENCES_KEY);
|
||||||
|
resetEnginePreferencesForTests();
|
||||||
|
host = document.createElement('div');
|
||||||
|
document.body.append(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
host.remove();
|
||||||
|
window.localStorage.removeItem(ENGINE_PREFERENCES_KEY);
|
||||||
|
resetEnginePreferencesForTests();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches automatic ISO mapping off when the Compliance Engine is unticked in the Engines popup, and not back on', async () => {
|
||||||
|
const setAutoMapping = vi.spyOn(AssessorApiClient.prototype, 'setIsoAutoMapping')
|
||||||
|
.mockResolvedValue({ version: 1, configured: true, active: null, job: null, autoMapping: { enabled: false } });
|
||||||
|
await act(async () => root.render(<EnginesControl/>));
|
||||||
|
await act(async () => host.querySelector<HTMLButtonElement>('.engines-button')!.click());
|
||||||
|
const compliance = () => [...document.querySelectorAll<HTMLLabelElement>('.engines-option')]
|
||||||
|
.find(option => option.textContent?.includes('Compliance Engine'))!.querySelector('input')!;
|
||||||
|
|
||||||
|
await act(async () => { compliance().click(); await Promise.resolve(); });
|
||||||
|
expect(compliance().checked).toBe(false);
|
||||||
|
expect(setAutoMapping).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setAutoMapping).toHaveBeenCalledWith(false);
|
||||||
|
|
||||||
|
await act(async () => { compliance().click(); await Promise.resolve(); });
|
||||||
|
expect(compliance().checked).toBe(true);
|
||||||
|
expect(setAutoMapping).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves automatic mapping alone when another engine is switched off', async () => {
|
||||||
|
const setAutoMapping = vi.spyOn(AssessorApiClient.prototype, 'setIsoAutoMapping')
|
||||||
|
.mockResolvedValue({ version: 1, configured: true, active: null, job: null });
|
||||||
|
await act(async () => root.render(<EnginesControl/>));
|
||||||
|
await act(async () => host.querySelector<HTMLButtonElement>('.engines-button')!.click());
|
||||||
|
const context = [...document.querySelectorAll<HTMLLabelElement>('.engines-option')]
|
||||||
|
.find(option => option.textContent?.includes('Context Engine'))!.querySelector('input')!;
|
||||||
|
await act(async () => { context.click(); await Promise.resolve(); });
|
||||||
|
expect(setAutoMapping).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import type { Server } from 'node:http';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createAssessorHttpServer } from '../../server/assessor-http-server';
|
||||||
|
import { BsiCatalogError, type BsiCatalogService } from '../../server/compliance/bsi-catalog-service';
|
||||||
|
import type { BsiCatalogState } from '../../src/compliance/bsi-catalog-management';
|
||||||
|
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
|
||||||
|
|
||||||
|
// Data Hub `Update from BSI` routes; the service is a stub with synthetic states.
|
||||||
|
const bundled: BsiCatalogState = { version: 1, origin: 'bundled', catalogVersion: '2026-01-01T00:00:00+00:00', crosswalkVersion: '0.1-synthetic',
|
||||||
|
requirementCount: 2, crosswalkEntryCount: 1, catalogSha256: 'a'.repeat(64), crosswalkSha256: 'b'.repeat(64), missingTargets: [] };
|
||||||
|
const updated: BsiCatalogState = { ...bundled, origin: 'bsi', updatedAt: '2026-10-08T12:00:00.000Z', catalogVersion: '2099-01-01T00:00:00+00:00' };
|
||||||
|
|
||||||
|
describe('BSI catalog routes', () => {
|
||||||
|
let directory: string;
|
||||||
|
let server: Server | undefined;
|
||||||
|
let baseUrl: string;
|
||||||
|
const service = { current: vi.fn(), refresh: vi.fn(), reset: vi.fn() };
|
||||||
|
|
||||||
|
const start = async (bsiCatalogService?: Pick<BsiCatalogService, 'current' | 'refresh' | 'reset'>) => {
|
||||||
|
server = createAssessorHttpServer({ repository: new LocalAssessorRepository(), staticRoot: directory, bsiCatalogService });
|
||||||
|
await new Promise<void>((resolve, reject) => { server!.once('error', reject); server!.listen(0, '127.0.0.1', resolve); });
|
||||||
|
baseUrl = `http://127.0.0.1:${(server!.address() as AddressInfo).port}/api/v1/reference-data/bsi-grundschutz`;
|
||||||
|
};
|
||||||
|
const call = (suffix = '', method = 'GET') => fetch(`${baseUrl}${suffix}`, method === 'GET' ? {} : { method, headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1', Origin: new URL(baseUrl).origin }, body: '{}' });
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
directory = await mkdtemp(join(tmpdir(), 'assessor-bsi-http-'));
|
||||||
|
await writeFile(join(directory, 'index.html'), '<main>synthetic app</main>');
|
||||||
|
service.current.mockResolvedValue(bundled);
|
||||||
|
service.refresh.mockResolvedValue({ ...updated, updated: true });
|
||||||
|
service.reset.mockResolvedValue(bundled);
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
if (server) await new Promise<void>((resolve, reject) => server!.close(error => error ? reject(error) : resolve()));
|
||||||
|
server = undefined;
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads, updates and resets the active catalog', async () => {
|
||||||
|
await start(service as never);
|
||||||
|
const read = await call();
|
||||||
|
expect(read.status).toBe(200);
|
||||||
|
expect(await read.json()).toEqual(bundled);
|
||||||
|
const refreshed = await call('/refresh', 'POST');
|
||||||
|
expect(refreshed.status).toBe(200);
|
||||||
|
expect(await refreshed.json()).toEqual({ ...updated, updated: true });
|
||||||
|
const reset = await call('/reset', 'POST');
|
||||||
|
expect(await reset.json()).toEqual(bundled);
|
||||||
|
expect([service.current.mock.calls.length, service.refresh.mock.calls.length, service.reset.mock.calls.length]).toEqual([1, 1, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a BSI failure as a bad gateway with its code and an unconfigured service as unavailable', async () => {
|
||||||
|
service.refresh.mockRejectedValue(new BsiCatalogError('bsi_unreachable', 'BSI could not be reached. Please try again later.'));
|
||||||
|
await start(service as never);
|
||||||
|
const failed = await call('/refresh', 'POST');
|
||||||
|
expect(failed.status).toBe(502);
|
||||||
|
expect(await failed.json()).toMatchObject({ error: { code: 'bsi_unreachable', message: 'BSI could not be reached. Please try again later.' } });
|
||||||
|
await new Promise<void>((resolve, reject) => server!.close(error => error ? reject(error) : resolve()));
|
||||||
|
await start();
|
||||||
|
const unavailable = await call();
|
||||||
|
expect(unavailable.status).toBe(503);
|
||||||
|
expect(await unavailable.json()).toMatchObject({ error: { code: 'bsi_catalog_unavailable' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AssessorApiClient } from '../../src/api/assessor-api-client';
|
||||||
|
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
|
||||||
|
import type { BsiCatalogState } from '../../src/compliance/bsi-catalog-management';
|
||||||
|
import { BsiCatalogLibrary } from '../../src/compliance/ui/BsiCatalogLibrary';
|
||||||
|
import { useRegulatoryDecisionRevision } from '../../src/compliance/ui/decision-revision';
|
||||||
|
|
||||||
|
// Data Hub BSI tile (user decision 2026-10-08): like the MITRE matrix tile, it names the active catalog and
|
||||||
|
// mapping and offers `Update from BSI`; `Use bundled catalog` appears only after an update. Synthetic states.
|
||||||
|
const bundled: BsiCatalogState = { version: 1, origin: 'bundled', catalogVersion: '2026-09-24T07:12:39.131983+00:00', crosswalkVersion: '1.0.1-qa',
|
||||||
|
requirementCount: 901, crosswalkEntryCount: 83, catalogSha256: 'a'.repeat(64), crosswalkSha256: 'b'.repeat(64), missingTargets: ['BER.6.8'] };
|
||||||
|
const updated: BsiCatalogState = { ...bundled, origin: 'bsi', updatedAt: '2026-10-08T12:00:00.000Z', catalogVersion: '2026-10-01T00:00:00+00:00',
|
||||||
|
crosswalkVersion: '1.0.2', missingTargets: ['BER.6.8', 'GEB.5.4'] };
|
||||||
|
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
let revisions: number[];
|
||||||
|
function Revision() { revisions.push(useRegulatoryDecisionRevision()); return null; }
|
||||||
|
const render = () => act(async () => {
|
||||||
|
root.render(<><BsiCatalogLibrary/><Revision/></>);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
const badge = () => host.querySelector('[data-bsi-catalog] .datahub-rack-badge')?.textContent;
|
||||||
|
const button = (label: string) => [...host.querySelectorAll<HTMLButtonElement>('[data-bsi-catalog] button')].find(item => item.textContent === label);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
localStorage.clear();
|
||||||
|
resetEnginePreferencesForTests();
|
||||||
|
revisions = [];
|
||||||
|
host = document.createElement('div');
|
||||||
|
document.body.append(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
host.remove();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
resetEnginePreferencesForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Data Hub BSI catalog tile', () => {
|
||||||
|
it('names the bundled catalog and mapping, updates from BSI and reloads the derived BSI views', async () => {
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'getBsiCatalog').mockResolvedValue(bundled);
|
||||||
|
const refresh = vi.spyOn(AssessorApiClient.prototype, 'refreshBsiCatalog').mockResolvedValue({ ...updated, updated: true });
|
||||||
|
await render();
|
||||||
|
expect(host.querySelector('[data-bsi-catalog] .datahub-rack-header strong')?.textContent).toBe('BSI GRUNDSCHUTZ++ KERNEL');
|
||||||
|
expect(badge()).toBe('2026-09-24 · Mapping 1.0.1-qa · Bundled');
|
||||||
|
expect(button('Use bundled catalog')).toBeUndefined();
|
||||||
|
const before = revisions.at(-1)!;
|
||||||
|
await act(async () => button('Update from BSI')!.click());
|
||||||
|
expect(refresh).toHaveBeenCalledTimes(1);
|
||||||
|
expect(badge()).toBe('2026-10-01 · Mapping 1.0.2 · Updated');
|
||||||
|
expect(host.querySelector('[role="status"]')?.textContent).toBe('✓Updated to catalog 2026-10-01 and mapping 1.0.2. 2 mapping targets are not in the catalog.');
|
||||||
|
// Client Info and Work reload their derived BSI requirements.
|
||||||
|
expect(revisions.at(-1)).toBe(before + 1);
|
||||||
|
expect(button('Use bundled catalog')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says when the catalog is up to date without reloading other views', async () => {
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'getBsiCatalog').mockResolvedValue(bundled);
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'refreshBsiCatalog').mockResolvedValue({ ...bundled, updated: false });
|
||||||
|
await render();
|
||||||
|
const before = revisions.at(-1);
|
||||||
|
await act(async () => button('Update from BSI')!.click());
|
||||||
|
expect(host.querySelector('[role="status"]')?.textContent).toBe('✓The BSI catalog is up to date.');
|
||||||
|
expect(revisions.at(-1)).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns to the bundled catalog and shows an update failure without changing the badge', async () => {
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'getBsiCatalog').mockResolvedValue(updated);
|
||||||
|
const reset = vi.spyOn(AssessorApiClient.prototype, 'resetBsiCatalog').mockResolvedValue(bundled);
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'refreshBsiCatalog').mockRejectedValue(new Error('BSI could not be reached. Please try again later.'));
|
||||||
|
await render();
|
||||||
|
await act(async () => button('Update from BSI')!.click());
|
||||||
|
expect(host.querySelector('[role="alert"]')?.textContent).toBe('BSI could not be reached. Please try again later.');
|
||||||
|
expect(badge()).toBe('2026-10-01 · Mapping 1.0.2 · Updated');
|
||||||
|
await act(async () => button('Use bundled catalog')!.click());
|
||||||
|
expect(reset).toHaveBeenCalledTimes(1);
|
||||||
|
expect(badge()).toBe('2026-09-24 · Mapping 1.0.1-qa · Bundled');
|
||||||
|
expect(host.querySelector('[role="status"]')?.textContent).toBe('✓The bundled BSI catalog is active again.');
|
||||||
|
expect(button('Use bundled catalog')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders and requests nothing while the Compliance Engine is off', async () => {
|
||||||
|
const request = vi.spyOn(AssessorApiClient.prototype, 'getBsiCatalog').mockResolvedValue(bundled);
|
||||||
|
setEngineEnabled('compliance', false);
|
||||||
|
await render();
|
||||||
|
expect(host.querySelector('[data-bsi-catalog]')).toBeNull();
|
||||||
|
expect(request).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sits in the Data Hub reference row directly after the ISO corpus tile', () => {
|
||||||
|
const source = readFileSync(join(process.cwd(), 'src/features/project-import/DataHub.tsx'), 'utf8');
|
||||||
|
expect(source).toContain(' <IsoCorpusLibrary/>\n <BsiCatalogLibrary/>\n');
|
||||||
|
const styles = readFileSync(join(process.cwd(), 'src/styles/datahub-redesign.css'), 'utf8');
|
||||||
|
expect(styles).toContain('.datahub-reference-row > .bsi-catalog-library { grid-area: 4 / 1 / 5 / -1; }');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { BSI_KERNEL_CLASS } from '../../src/compliance/bsi-grundschutz';
|
||||||
|
import { BSI_CATALOG_SOURCE_URL, BSI_CROSSWALK_SOURCE_URL, BsiCatalogService } from '../../server/compliance/bsi-catalog-service';
|
||||||
|
import { bundledBsiCatalogs } from '../../server/compliance/bsi-snapshots';
|
||||||
|
|
||||||
|
// `Update from BSI` (Phase 13F, user decision 2026-10-08): synthetic OSCAL in the published shape; ids and prose are invented.
|
||||||
|
const sourceCatalog = (version: string, ids = ['KONF.1.1', 'KONF.2.1']) => JSON.stringify({ catalog: {
|
||||||
|
metadata: { title: 'Synthetischer Katalog', version },
|
||||||
|
groups: [{ id: 'KONF', title: 'Konfiguration', controls: ids.map(id => ({ id, class: BSI_KERNEL_CLASS, title: `Titel ${id}`,
|
||||||
|
parts: [{ name: 'statement', prose: `Konfiguration MUSS ${id} umsetzen.` }] })) }]
|
||||||
|
} });
|
||||||
|
const sourceCrosswalk = (version = '9.9-synthetic', targets = ['KONF.1.1', 'KONF.3.1']) => JSON.stringify({ 'mapping-collection': {
|
||||||
|
metadata: { title: 'Synthetische Zuordnung', version },
|
||||||
|
mappings: [{ 'target-resource': { href: 'BSI-Stand-der-Technik-Kernel-catalog.json' },
|
||||||
|
maps: [{ uuid: 'm1', relationship: 'subset-of', sources: [{ 'id-ref': '8.9' }], targets: targets.map(target => ({ 'id-ref': target })) }] }]
|
||||||
|
} });
|
||||||
|
const NEWER = '2099-01-01T00:00:00+00:00';
|
||||||
|
|
||||||
|
let directory: string;
|
||||||
|
let storage: string;
|
||||||
|
beforeEach(async () => {
|
||||||
|
directory = await mkdtemp(join(tmpdir(), 'assessor-bsi-'));
|
||||||
|
storage = join(directory, 'bsi', 'active-catalog.json');
|
||||||
|
});
|
||||||
|
afterEach(async () => { await rm(directory, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
function fetcher(files: Record<string, string | Response>) {
|
||||||
|
return vi.fn(async (url: string | URL | Request) => {
|
||||||
|
const body = files[String(url)];
|
||||||
|
return body instanceof Response ? body : new Response(body, { status: 200 });
|
||||||
|
}) as unknown as typeof fetch & ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BSI catalog service', () => {
|
||||||
|
it('starts with the bundled snapshots', async () => {
|
||||||
|
const service = new BsiCatalogService(storage, fetcher({}));
|
||||||
|
expect(await service.catalogs()).toBe(bundledBsiCatalogs);
|
||||||
|
expect(await service.current()).toMatchObject({ version: 1, origin: 'bundled', catalogVersion: bundledBsiCatalogs.catalog.version,
|
||||||
|
crosswalkVersion: '1.0.1-qa', requirementCount: 901, missingTargets: ['BER.5.1.2', 'BER.6.8', 'DET.3.1.13', 'GEB.5.4'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores a newer official state privately, activates it at once and reports it unchanged on the next update', async () => {
|
||||||
|
const fetch = fetcher({ [BSI_CATALOG_SOURCE_URL]: sourceCatalog(NEWER), [BSI_CROSSWALK_SOURCE_URL]: sourceCrosswalk() });
|
||||||
|
const service = new BsiCatalogService(storage, fetch, () => new Date('2026-10-08T12:00:00Z'));
|
||||||
|
const result = await service.refresh();
|
||||||
|
expect(result).toMatchObject({ updated: true, origin: 'bsi', updatedAt: '2026-10-08T12:00:00.000Z', catalogVersion: NEWER,
|
||||||
|
crosswalkVersion: '9.9-synthetic', requirementCount: 2, crosswalkEntryCount: 1, missingTargets: ['KONF.3.1'] });
|
||||||
|
// Only the fixed official URLs, without following a redirect.
|
||||||
|
expect(fetch.mock.calls.map(call => [String(call[0]), (call[1] as RequestInit).redirect])).toEqual([
|
||||||
|
[BSI_CATALOG_SOURCE_URL, 'error'], [BSI_CROSSWALK_SOURCE_URL, 'error']]);
|
||||||
|
expect((await service.catalogs()).catalog.requirements.map(item => item.id)).toEqual(['KONF.1.1', 'KONF.2.1']);
|
||||||
|
expect((await service.catalogs()).catalog.sourceUrl).toBe(BSI_CATALOG_SOURCE_URL);
|
||||||
|
if (process.platform !== 'win32') expect((await stat(storage)).mode & 0o777).toBe(0o600);
|
||||||
|
// A restarted server reads the stored state.
|
||||||
|
expect(await new BsiCatalogService(storage, fetcher({})).current()).toMatchObject({ origin: 'bsi', catalogVersion: NEWER });
|
||||||
|
expect(await service.refresh()).toMatchObject({ updated: false, catalogVersion: NEWER });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never steps back behind the active catalog', async () => {
|
||||||
|
const service = new BsiCatalogService(storage, fetcher({ [BSI_CATALOG_SOURCE_URL]: sourceCatalog('2000-01-01T00:00:00+00:00'), [BSI_CROSSWALK_SOURCE_URL]: sourceCrosswalk() }));
|
||||||
|
expect(await service.refresh()).toMatchObject({ updated: false, origin: 'bundled' });
|
||||||
|
await expect(stat(storage)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails explicitly and keeps the active catalog when BSI is unreachable, unavailable, too large or invalid', async () => {
|
||||||
|
const cases: Array<[Record<string, string | Response> | null, string]> = [
|
||||||
|
[null, 'bsi_unreachable'],
|
||||||
|
[{ [BSI_CATALOG_SOURCE_URL]: new Response('', { status: 404 }), [BSI_CROSSWALK_SOURCE_URL]: sourceCrosswalk() }, 'bsi_unavailable'],
|
||||||
|
[{ [BSI_CATALOG_SOURCE_URL]: new Response('{}', { headers: { 'content-length': String(64 * 1024 * 1024) } }), [BSI_CROSSWALK_SOURCE_URL]: sourceCrosswalk() }, 'bsi_source_too_large'],
|
||||||
|
[{ [BSI_CATALOG_SOURCE_URL]: sourceCatalog(NEWER, []), [BSI_CROSSWALK_SOURCE_URL]: sourceCrosswalk() }, 'bsi_source_invalid'],
|
||||||
|
[{ [BSI_CATALOG_SOURCE_URL]: sourceCatalog(NEWER), [BSI_CROSSWALK_SOURCE_URL]: '{"mapping-collection":{}}' }, 'bsi_source_invalid']
|
||||||
|
];
|
||||||
|
for (const [files, code] of cases) {
|
||||||
|
const download = files ? fetcher(files) : vi.fn(async () => { throw new TypeError("offline"); }) as unknown as typeof globalThis.fetch;
|
||||||
|
const service = new BsiCatalogService(storage, download);
|
||||||
|
await expect(service.refresh()).rejects.toMatchObject({ code });
|
||||||
|
expect(await service.current()).toMatchObject({ origin: 'bundled' });
|
||||||
|
}
|
||||||
|
await expect(stat(storage)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns to the bundled snapshots on reset', async () => {
|
||||||
|
const service = new BsiCatalogService(storage, fetcher({ [BSI_CATALOG_SOURCE_URL]: sourceCatalog(NEWER), [BSI_CROSSWALK_SOURCE_URL]: sourceCrosswalk() }));
|
||||||
|
await service.refresh();
|
||||||
|
expect(await service.reset()).toMatchObject({ origin: 'bundled', catalogVersion: bundledBsiCatalogs.catalog.version });
|
||||||
|
expect(await service.catalogs()).toBe(bundledBsiCatalogs);
|
||||||
|
await expect(stat(storage)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a damaged stored state and lets a stored state older than the bundled one give way to it', async () => {
|
||||||
|
const service = new BsiCatalogService(storage, fetcher({ [BSI_CATALOG_SOURCE_URL]: sourceCatalog(NEWER), [BSI_CROSSWALK_SOURCE_URL]: sourceCrosswalk() }));
|
||||||
|
await service.refresh();
|
||||||
|
const stored = JSON.parse(await readFile(storage, 'utf8'));
|
||||||
|
await writeFile(storage, JSON.stringify({ ...stored, catalog: { ...stored.catalog, framework: 'other' } }));
|
||||||
|
await expect(new BsiCatalogService(storage, fetcher({})).current()).rejects.toMatchObject({ code: 'bsi_storage_invalid' });
|
||||||
|
await writeFile(storage, JSON.stringify({ ...stored, catalog: { ...stored.catalog, version: '2000-01-01T00:00:00+00:00' } }));
|
||||||
|
expect(await new BsiCatalogService(storage, fetcher({})).catalogs()).toBe(bundledBsiCatalogs);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
BSI_KERNEL_CLASS, bsiIsoCrosswalk, bsiKernelCatalog, bsiRequirementText, deriveBsiRequirements
|
||||||
|
} from '../../src/compliance/bsi-grundschutz';
|
||||||
|
import pinnedCatalog from '../../server/compliance/data/bsi-grundschutz-kernel-2026-09-24.json';
|
||||||
|
import pinnedCrosswalk from '../../server/compliance/data/bsi-iso27001-crosswalk-1.0.1-qa.json';
|
||||||
|
import type { BsiIsoCrosswalk, BsiKernelCatalog } from '../../src/compliance/bsi-grundschutz';
|
||||||
|
|
||||||
|
// Synthetic OSCAL in the published shape; ids, titles and prose are invented.
|
||||||
|
const control = (id: string, extra: Record<string, unknown> = {}) => ({
|
||||||
|
id, class: BSI_KERNEL_CLASS, title: `Titel ${id}`,
|
||||||
|
props: [{ name: 'sec_level', value: 'normal-SdT' }, { name: 'effort_level', value: '2' }],
|
||||||
|
parts: [
|
||||||
|
{ id: `${id}_stm`, name: 'statement', props: [{ name: 'modal_verb', value: 'MUSS' }], prose: `Praktik MUSS ${id} umsetzen.` },
|
||||||
|
{ id: `${id}_gdn`, name: 'guidance', prose: `Erlaeuterung zu ${id}.` }
|
||||||
|
],
|
||||||
|
...extra
|
||||||
|
});
|
||||||
|
|
||||||
|
const syntheticCatalog = (controls: unknown[], groups: unknown[] = []) => ({
|
||||||
|
catalog: {
|
||||||
|
metadata: { title: 'Synthetischer Katalog', version: '2026-01-01T00:00:00+00:00' },
|
||||||
|
groups: [
|
||||||
|
{ id: 'KONF', title: 'Konfiguration', controls },
|
||||||
|
{ id: 'GC', title: 'Governance', controls: [{ ...control('GC.1.1'), class: 'BSI-Methodik-Grundschutz-plus-plus' }] },
|
||||||
|
...groups
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const syntheticMapping = (maps: unknown[]) => ({
|
||||||
|
'mapping-collection': {
|
||||||
|
metadata: { title: 'Synthetische Zuordnung', version: '0.1' },
|
||||||
|
mappings: [
|
||||||
|
{ 'target-resource': { href: 'BSI-Methodik-Grundschutz++-catalog.json' }, maps: [
|
||||||
|
{ uuid: 'm0', relationship: 'subset-of', sources: [{ 'id-ref': '5.1' }], targets: [{ 'id-ref': 'GC.1.1' }] }] },
|
||||||
|
{ 'target-resource': { href: 'BSI-Stand-der-Technik-Kernel-catalog.json' }, maps }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const map = (uuid: string, iso: string, relationship: string, targets: string[]) =>
|
||||||
|
({ uuid, relationship, sources: [{ 'id-ref': iso }], targets: targets.map(target => ({ 'id-ref': target })) });
|
||||||
|
|
||||||
|
describe('BSI Kernel catalog conversion', () => {
|
||||||
|
it('keeps only Kernel requirements with practice, parent, statement and parameters as published', () => {
|
||||||
|
const catalog = bsiKernelCatalog(syntheticCatalog([
|
||||||
|
control('KONF.1.1', { controls: [control('KONF.1.1.1', {
|
||||||
|
params: [{ id: 'konf.1.1.1-prm1', label: 'regelmaessig' }],
|
||||||
|
parts: [{ id: 's', name: 'statement', prose: 'Praktik MUSS {{ insert: param, konf.1.1.1-prm1 }} pruefen.' }]
|
||||||
|
})] })
|
||||||
|
]), 'https://example.test/catalog.json', 'a'.repeat(64));
|
||||||
|
|
||||||
|
expect(catalog.requirements.map(item => item.id)).toEqual(['KONF.1.1', 'KONF.1.1.1']);
|
||||||
|
expect(catalog.practices).toEqual([{ id: 'KONF', title: 'Konfiguration' }]);
|
||||||
|
expect(catalog.requirements[0]).toMatchObject({ practice: 'KONF', modalVerb: 'MUSS', guidance: 'Erlaeuterung zu KONF.1.1.', securityLevel: 'normal-SdT', effortLevel: '2' });
|
||||||
|
const child = catalog.requirements[1];
|
||||||
|
expect(child).toMatchObject({ parentId: 'KONF.1.1', statement: 'Praktik MUSS {{ insert: param, konf.1.1.1-prm1 }} pruefen.' });
|
||||||
|
expect(child.guidance).toBeUndefined();
|
||||||
|
expect(bsiRequirementText(child.statement, child.params)).toBe('Praktik MUSS regelmaessig pruefen.');
|
||||||
|
expect(catalog).toMatchObject({ version: '2026-01-01T00:00:00+00:00', sourceSha256: 'a'.repeat(64) });
|
||||||
|
expect(catalog.license).toContain('CC BY-SA 4.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers a single published parameter value over its label', () => {
|
||||||
|
const catalog = bsiKernelCatalog(syntheticCatalog([control('KONF.2.1', {
|
||||||
|
params: [{ id: 'p1', label: 'Bezeichnung', values: ['jaehrlich'] }],
|
||||||
|
parts: [{ name: 'statement', prose: 'Praktik MUSS {{ insert: param, p1 }} pruefen.' }]
|
||||||
|
})]), 'u', 's');
|
||||||
|
expect(bsiRequirementText(catalog.requirements[0].statement, catalog.requirements[0].params)).toBe('Praktik MUSS jaehrlich pruefen.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails explicitly on malformed input instead of producing a partial catalog', () => {
|
||||||
|
expect(() => bsiKernelCatalog({}, 'u', 's')).toThrow(/OSCAL Grundschutz\+\+ catalog/);
|
||||||
|
expect(() => bsiKernelCatalog(syntheticCatalog([]), 'u', 's')).toThrow(/no Kernel requirements/);
|
||||||
|
expect(() => bsiKernelCatalog(syntheticCatalog([control('KONF.1.1'), control('KONF.1.1')]), 'u', 's')).toThrow(/Repeated Kernel requirement ids: KONF.1.1/);
|
||||||
|
expect(() => bsiKernelCatalog(syntheticCatalog([control('konf-1')]), 'u', 's')).toThrow(/Invalid Kernel requirement id/);
|
||||||
|
expect(() => bsiKernelCatalog(syntheticCatalog([control('KONF.1.1', { parts: [{ name: 'guidance', prose: 'x' }] })]), 'u', 's')).toThrow(/no statement/);
|
||||||
|
expect(() => bsiKernelCatalog(syntheticCatalog([control('KONF.1.1', {
|
||||||
|
parts: [{ name: 'statement', prose: 'MUSS {{ insert: param, fehlt }}.' }]
|
||||||
|
})]), 'u', 's')).toThrow(/unknown parameter fehlt/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BSI ISO 27001 crosswalk conversion', () => {
|
||||||
|
it('keeps only the Kernel mapping with its relationships and targets as published', () => {
|
||||||
|
const crosswalk = bsiIsoCrosswalk(syntheticMapping([map('m1', '8.9', 'subset-of', ['KONF.1.1', 'KONF.1.1.1'])]), 'u', 's');
|
||||||
|
expect(crosswalk.targetCatalog).toBe('BSI-Stand-der-Technik-Kernel-catalog.json');
|
||||||
|
expect(crosswalk.entries).toEqual([{ uuid: 'm1', isoControlId: '8.9', relationship: 'subset-of', targets: ['KONF.1.1', 'KONF.1.1.1'] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails explicitly on unknown relationships, sources or targets', () => {
|
||||||
|
expect(() => bsiIsoCrosswalk(syntheticMapping([map('m', '8.9', 'related-to', ['KONF.1.1'])]), 'u', 's')).toThrow(/Unknown mapping relationship/);
|
||||||
|
expect(() => bsiIsoCrosswalk(syntheticMapping([map('m', 'A.8.9', 'subset-of', ['KONF.1.1'])]), 'u', 's')).toThrow(/ISO Annex A source/);
|
||||||
|
expect(() => bsiIsoCrosswalk(syntheticMapping([map('m', '8.9', 'subset-of', [])]), 'u', 's')).toThrow(/Invalid mapping targets/);
|
||||||
|
expect(() => bsiIsoCrosswalk({ 'mapping-collection': { metadata: { version: '1' }, mappings: [] } }, 'u', 's')).toThrow(/exactly one mapping to the Kernel catalog/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BSI requirement derivation from accepted ISO controls', () => {
|
||||||
|
const catalog = bsiKernelCatalog(syntheticCatalog([control('KONF.1.1'), control('KONF.2.1'), control('KONF.10.1')]), 'u', 's');
|
||||||
|
const crosswalk = bsiIsoCrosswalk(syntheticMapping([
|
||||||
|
map('m1', '8.9', 'subset-of', ['KONF.10.1', 'KONF.1.1']),
|
||||||
|
map('m2', '8.20', 'intersects-with', ['KONF.1.1', 'KONF.9.9']),
|
||||||
|
map('m3', '5.15', 'equivalent-to', ['KONF.2.1'])
|
||||||
|
]), 'u', 's');
|
||||||
|
|
||||||
|
it('derives Kernel requirements with every ISO route, reports missing targets and ignores other controls', () => {
|
||||||
|
const derivation = deriveBsiRequirements(['8.20', '8.9'], crosswalk, catalog);
|
||||||
|
expect(derivation.requirements).toEqual([
|
||||||
|
{ requirementId: 'KONF.1.1', title: 'Titel KONF.1.1', practice: 'KONF', via: [
|
||||||
|
{ isoControlId: '8.9', relationship: 'subset-of' }, { isoControlId: '8.20', relationship: 'intersects-with' }] },
|
||||||
|
{ requirementId: 'KONF.10.1', title: 'Titel KONF.10.1', practice: 'KONF', via: [{ isoControlId: '8.9', relationship: 'subset-of' }] }
|
||||||
|
]);
|
||||||
|
expect(derivation.missingTargets).toEqual([{ isoControlId: '8.20', requirementId: 'KONF.9.9' }]);
|
||||||
|
expect(derivation).toMatchObject({ crosswalkVersion: '0.1', catalogVersion: '2026-01-01T00:00:00+00:00' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is independent of input order and yields nothing without accepted controls', () => {
|
||||||
|
expect(deriveBsiRequirements(['8.9', '8.20', '8.9'], crosswalk, catalog)).toEqual(deriveBsiRequirements(['8.20', '8.9'], crosswalk, catalog));
|
||||||
|
expect(deriveBsiRequirements([], crosswalk, catalog)).toMatchObject({ requirements: [], missingTargets: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pinned BSI snapshots', () => {
|
||||||
|
const catalog = pinnedCatalog as BsiKernelCatalog;
|
||||||
|
const crosswalk = pinnedCrosswalk as BsiIsoCrosswalk;
|
||||||
|
|
||||||
|
it('carry provenance and the decided Kernel scope', () => {
|
||||||
|
expect(catalog).toMatchObject({ framework: 'bsi-grundschutz-plus-plus', catalogClass: BSI_KERNEL_CLASS, version: '2026-09-24T07:12:39.131983+00:00' });
|
||||||
|
expect(catalog.requirements).toHaveLength(901);
|
||||||
|
expect(new Set(catalog.requirements.map(item => item.id)).size).toBe(901);
|
||||||
|
expect(catalog.sourceUrl).toMatch(/^https:\/\/raw\.githubusercontent\.com\/BSI-Bund\/Stand-der-Technik-Bibliothek\/[0-9a-f]{40}\//);
|
||||||
|
expect(catalog.sourceSha256).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
expect(crosswalk).toMatchObject({ version: '1.0.1-qa', targetCatalog: 'BSI-Stand-der-Technik-Kernel-catalog.json' });
|
||||||
|
expect(crosswalk.sourceSha256).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('report exactly the mapping targets the pinned catalog no longer contains', () => {
|
||||||
|
const allIso = [...new Set(crosswalk.entries.map(entry => entry.isoControlId))];
|
||||||
|
const missing = deriveBsiRequirements(allIso, crosswalk, catalog).missingTargets.map(item => item.requirementId);
|
||||||
|
expect([...new Set(missing)].sort()).toEqual(['BER.5.1.2', 'BER.6.8', 'DET.3.1.13', 'GEB.5.4']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -8,14 +8,14 @@ import { AssessorApiClient } from '../../src/api/assessor-api-client';
|
||||||
import { requestActivity } from '../../src/api/request-activity';
|
import { requestActivity } from '../../src/api/request-activity';
|
||||||
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
|
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
|
||||||
import type { RegulatoryControlOverviewResponse } from '../../src/compliance/regulatory-mapping';
|
import type { RegulatoryControlOverviewResponse } from '../../src/compliance/regulatory-mapping';
|
||||||
import { ClientIsoControls } from '../../src/compliance/ui/ClientIsoControls';
|
import { ClientIsoControls, resetClientComplianceCacheForTests } from '../../src/compliance/ui/ClientIsoControls';
|
||||||
import { markRegulatoryDecisionSaved } from '../../src/compliance/ui/decision-revision';
|
import { markRegulatoryDecisionSaved } from '../../src/compliance/ui/decision-revision';
|
||||||
import { workRecordIdsForResult } from '../../src/features/results-browser/work-record-lookup';
|
import { workRecordIdsForResult } from '../../src/features/results-browser/work-record-lookup';
|
||||||
import { makeSheet } from '../../src/importers/sheet-factory';
|
import { makeSheet } from '../../src/importers/sheet-factory';
|
||||||
|
|
||||||
// Client Info `Compliance` row: one ISO tile in the environment tile geometry with totals and the two themes
|
// Client Info `Compliance` tab: one ISO tile in the environment tile geometry with totals and all four themes;
|
||||||
// with the most findings; its Results window lists every control by theme and opens Work from a control or
|
// its Results window lists every control by theme and opens Work from a control or a theme. `Last mapping`
|
||||||
// a theme. Switched off, nothing renders or loads.
|
// names the newest mapping batch of the timeslot and its trigger. Switched off, nothing renders or loads.
|
||||||
const overview: RegulatoryControlOverviewResponse = {
|
const overview: RegulatoryControlOverviewResponse = {
|
||||||
version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [] }, findingCount: 3,
|
version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [] }, findingCount: 3,
|
||||||
controls: [
|
controls: [
|
||||||
|
|
@ -35,9 +35,14 @@ let host: HTMLDivElement;
|
||||||
let root: Root;
|
let root: Root;
|
||||||
const onOpenControl = vi.fn();
|
const onOpenControl = vi.fn();
|
||||||
const onOpenTheme = vi.fn();
|
const onOpenTheme = vi.fn();
|
||||||
|
const onOpenBsiRequirement = vi.fn();
|
||||||
|
const onOpenBsiPractice = vi.fn();
|
||||||
|
const onOpenMappingState = vi.fn();
|
||||||
const render = () => act(async () => {
|
const render = () => act(async () => {
|
||||||
root.render(<ClientIsoControls projectId="dummy-project" assessmentIds={['dummy-assessment']} scanRunIds={[]}
|
root.render(<ClientIsoControls projectId="dummy-project" assessmentIds={['dummy-assessment']} scanRunIds={[]}
|
||||||
backendStatus="connected" onOpenControl={onOpenControl} onOpenTheme={onOpenTheme}/>);
|
backendStatus="connected" onOpenControl={onOpenControl} onOpenTheme={onOpenTheme}
|
||||||
|
onOpenBsiRequirement={onOpenBsiRequirement} onOpenBsiPractice={onOpenBsiPractice} onOpenMappingState={onOpenMappingState}
|
||||||
|
context={<header className="client-overview-header" data-testid="dummy-context">Dummy context</header>}/>);
|
||||||
await new Promise(resolve => setTimeout(resolve, 0));
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -45,8 +50,12 @@ beforeEach(() => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
resetEnginePreferencesForTests();
|
resetEnginePreferencesForTests();
|
||||||
|
resetClientComplianceCacheForTests();
|
||||||
onOpenControl.mockReset();
|
onOpenControl.mockReset();
|
||||||
onOpenTheme.mockReset();
|
onOpenTheme.mockReset();
|
||||||
|
onOpenBsiRequirement.mockReset();
|
||||||
|
onOpenBsiPractice.mockReset();
|
||||||
|
onOpenMappingState.mockReset();
|
||||||
host = document.createElement('div');
|
host = document.createElement('div');
|
||||||
document.body.append(host);
|
document.body.append(host);
|
||||||
root = createRoot(host);
|
root = createRoot(host);
|
||||||
|
|
@ -70,10 +79,10 @@ describe('Client Info ISO controls', () => {
|
||||||
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue(overview);
|
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue(overview);
|
||||||
await render();
|
await render();
|
||||||
expect(request).toHaveBeenCalledWith('dummy-project', { assessmentIds: ['dummy-assessment'], scanRunIds: [] });
|
expect(request).toHaveBeenCalledWith('dummy-project', { assessmentIds: ['dummy-assessment'], scanRunIds: [] });
|
||||||
expect(host.querySelector('.client-compliance > h2')?.textContent).toBe('Compliance');
|
expect(host.querySelector('.client-compliance-heading > h2')?.textContent).toBe('Compliance');
|
||||||
// Same tile geometry as the environment row.
|
// Same tile geometry as the environment row.
|
||||||
expect(host.querySelector('.client-compliance-grid > section.client-env-group')).not.toBeNull();
|
expect(host.querySelector('.client-compliance-grid > section.client-env-group')).not.toBeNull();
|
||||||
expect(host.querySelector('.client-env-group-title')?.textContent).toBe('ISO 27001/27002');
|
expect(host.querySelector('.client-compliance-grid .client-env-group-title')?.textContent).toBe('ISO 27001/27002');
|
||||||
// All four themes in ISO order; a finding counts once per theme; nothing else of the list is on the tile.
|
// All four themes in ISO order; a finding counts once per theme; nothing else of the list is on the tile.
|
||||||
expect(facts()).toEqual([['Controls', '3'], ['Findings', '3'], ['Organizational', '1 finding'], ['People', '0 findings'],
|
expect(facts()).toEqual([['Controls', '3'], ['Findings', '3'], ['Organizational', '1 finding'], ['People', '0 findings'],
|
||||||
['Physical', '0 findings'], ['Technological', '3 findings']]);
|
['Physical', '0 findings'], ['Technological', '3 findings']]);
|
||||||
|
|
@ -146,14 +155,30 @@ describe('Client Info ISO controls', () => {
|
||||||
expect(requestActivity('/projects/p/regulatory-control-assignments?assessmentIds=a')).toEqual({ area: 'Compliance Engine', label: 'Loading ISO control filters' });
|
expect(requestActivity('/projects/p/regulatory-control-assignments?assessmentIds=a')).toEqual({ area: 'Compliance Engine', label: 'Loading ISO control filters' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('places the section below the Context heatmap and opens Work through the ISO control and theme filters', () => {
|
it('is its own Client Info tab, not part of the Overview, and opens Work through the ISO control and theme filters', () => {
|
||||||
const source = readFileSync(join(process.cwd(), 'src/features/dashboard/DashboardPanel.tsx'), 'utf8');
|
const source = readFileSync(join(process.cwd(), 'src/features/dashboard/DashboardPanel.tsx'), 'utf8');
|
||||||
expect(source.indexOf('<ClientIsoControls')).toBeGreaterThan(source.indexOf('<ClientContextHeatmap'));
|
expect(source).toContain("...(showCompliance ? [['compliance', 'Compliance'] as const] : [])");
|
||||||
expect(source.indexOf('<ClientIsoControls')).toBeLessThan(source.indexOf('client-environment-grid'));
|
expect(source.indexOf("['compliance', 'Compliance']")).toBeLessThan(source.indexOf("['ai', 'AI Overview']"));
|
||||||
const drilldown = source.slice(source.indexOf('<ClientIsoControls'), source.indexOf('client-environment-grid'));
|
const tab = source.slice(source.indexOf("if (clientInfoView === 'compliance' && showCompliance) return <ClientIsoControls"));
|
||||||
expect(drilldown).toContain('setStatusFilter(\x27deviation\x27)');
|
expect(tab.startsWith("if (clientInfoView === 'compliance' && showCompliance) return <ClientIsoControls navigation={clientInfoTabs}")).toBe(true);
|
||||||
|
// The Overview page no longer renders the row.
|
||||||
|
expect(source.indexOf('<ClientIsoControls')).toBe(source.lastIndexOf('<ClientIsoControls'));
|
||||||
|
expect(source.indexOf('<ClientIsoControls')).toBeLessThan(source.indexOf('<ClientContextHeatmap'));
|
||||||
|
const drilldown = tab.slice(0, tab.indexOf('if (hasDefender'));
|
||||||
|
expect(drilldown).toContain("setStatusFilter('deviation')");
|
||||||
expect(drilldown).toContain('setFacetFilter(ISO_CONTROL_FACET_ID, { values: [controlId] })');
|
expect(drilldown).toContain('setFacetFilter(ISO_CONTROL_FACET_ID, { values: [controlId] })');
|
||||||
expect(drilldown).toContain('setFacetFilter(ISO_THEME_FACET_ID, { values: [theme.toLowerCase()] })');
|
expect(drilldown).toContain('setFacetFilter(ISO_THEME_FACET_ID, { values: [theme.toLowerCase()] })');
|
||||||
|
// The BSI tile opens Work the same way through the lower-case BSI facet values.
|
||||||
|
expect(drilldown).toContain('setFacetFilter(BSI_REQUIREMENT_FACET_ID, { values: [requirementId.toLowerCase()] })');
|
||||||
|
expect(drilldown).toContain('setFacetFilter(BSI_PRACTICE_FACET_ID, { values: [practiceId.toLowerCase()] })');
|
||||||
|
// `Mapping failed` in the coverage legend opens Work with the deviations whose latest ISO mapping failed.
|
||||||
|
expect(drilldown).toContain('setFacetFilter(ISO_MAPPING_FACET_ID, { values: [state] })');
|
||||||
|
// Client and timeslot like the Overview, without a Source control, because the tab covers the whole timeslot.
|
||||||
|
const context = drilldown.slice(drilldown.indexOf('context={'));
|
||||||
|
expect(context).toContain('<span>Client:</span><strong>{dataset.fileName}</strong>');
|
||||||
|
expect(context).toContain('<span>Assessment:</span><WorkTimeslotControl />');
|
||||||
|
expect(context).not.toContain('Source:');
|
||||||
|
expect(drilldown.match(/setStatusFilter\('deviation'\)/g)).toHaveLength(5);
|
||||||
expect(drilldown).not.toContain('setPathFindingIds');
|
expect(drilldown).not.toContain('setPathFindingIds');
|
||||||
// A scanner finding resolves to its Work record through the occurrence id.
|
// A scanner finding resolves to its Work record through the occurrence id.
|
||||||
const sheet = makeSheet('Dummy', [['Title', 'Status'], ['Dummy check', 'Fail']], '', 'dummy.csv', { isProjectSheet: true });
|
const sheet = makeSheet('Dummy', [['Title', 'Status'], ['Dummy check', 'Fail']], '', 'dummy.csv', { isProjectSheet: true });
|
||||||
|
|
@ -161,4 +186,347 @@ describe('Client Info ISO controls', () => {
|
||||||
const dataset = { fileName: 'Dummy', sheets: [{ ...sheet, analysis: { ...sheet.analysis, records: [record] } }] } as never;
|
const dataset = { fileName: 'Dummy', sheets: [{ ...sheet, analysis: { ...sheet.analysis, records: [record] } }] } as never;
|
||||||
expect(workRecordIdsForResult(dataset, overview.controls[0].findings[0])).toEqual([record.id]);
|
expect(workRecordIdsForResult(dataset, overview.controls[0].findings[0])).toEqual([record.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const counts = { findings: 7, sentToModel: 2, reused: 3, succeeded: 2, failed: 0 };
|
||||||
|
const lastMapping = () => host.querySelector<HTMLElement>('.client-compliance-last-mapping');
|
||||||
|
|
||||||
|
it('shows the latest mapping of any client and lists batches and single finding mappings with where they came from', async () => {
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, mappingHistory: [
|
||||||
|
{ id: 'dummy-batch-2', kind: 'batch', scope: 'timeslot', trigger: 'automatic', status: 'completed', createdAt: '2026-10-08T09:12:00Z', completedAt: '2026-10-08T09:14:00Z', counts },
|
||||||
|
{ id: 'dummy-single', kind: 'single', scope: 'other-timeslot', trigger: 'manual', status: 'completed', createdAt: '2026-10-08T08:00:00Z',
|
||||||
|
counts: { sentToModel: 1, reused: 0, succeeded: 1, failed: 0 } },
|
||||||
|
{ id: 'dummy-batch-1', kind: 'batch', scope: 'other-client', trigger: 'manual', status: 'failed', createdAt: '2026-10-07T15:00:00Z', counts: { ...counts, failed: 2, succeeded: 0 } }] });
|
||||||
|
await render();
|
||||||
|
const expectedTime = new Date('2026-10-08T09:12:00Z').toLocaleString('en-GB',
|
||||||
|
{ day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
expect(lastMapping()?.tagName).toBe('BUTTON');
|
||||||
|
expect(lastMapping()?.textContent).toBe(`Last mapping: ${expectedTime} · Automatic`);
|
||||||
|
// The framework Results button stays on the tile, unchanged.
|
||||||
|
expect(resultsButton()?.textContent).toBe('Results');
|
||||||
|
await act(async () => lastMapping()!.click());
|
||||||
|
const rows = [...document.querySelectorAll('.client-information-table tbody tr')].map(row => [...row.children].slice(1).map(cell => cell.textContent));
|
||||||
|
expect(rows).toEqual([['Automatic', 'This timeslot', 'Completed', '7', '2', '3', '2', '0'],
|
||||||
|
['Single finding', 'Other timeslot', 'Completed', '—', '1', '0', '1', '0'],
|
||||||
|
['Manual', 'Other client', 'Failed', '7', '2', '3', '0', '2']]);
|
||||||
|
expect(document.querySelector('.client-compliance-history')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the scope on the button when the latest mapping is not this timeslot', async () => {
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, mappingHistory: [
|
||||||
|
{ id: 'dummy-single', kind: 'single', scope: 'other-client', trigger: 'manual', status: 'running', createdAt: '2026-10-08T08:00:00Z',
|
||||||
|
counts: { sentToModel: 1, reused: 0, succeeded: 0, failed: 0 } }] });
|
||||||
|
await render();
|
||||||
|
expect(lastMapping()?.textContent).toMatch(/· Single finding · Other client · Running$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says when the timeslot was never mapped, and refreshes while a mapping runs', async () => {
|
||||||
|
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValueOnce({ ...overview, mappingHistory: [] });
|
||||||
|
await render();
|
||||||
|
expect(lastMapping()?.tagName).toBe('SPAN');
|
||||||
|
expect(lastMapping()?.textContent).toBe('No mapping yet');
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
request.mockResolvedValueOnce({ ...overview, mappingHistory: [{ id: 'dummy-batch', kind: 'batch', scope: 'timeslot', trigger: 'automatic', status: 'running', createdAt: '2026-10-08T09:12:00Z', counts }] })
|
||||||
|
.mockResolvedValue({ ...overview, mappingHistory: [{ id: 'dummy-batch', kind: 'batch', scope: 'timeslot', trigger: 'automatic', status: 'completed', createdAt: '2026-10-08T09:12:00Z', counts }] });
|
||||||
|
await act(async () => { markRegulatoryDecisionSaved(); });
|
||||||
|
expect(lastMapping()?.textContent).toMatch(/· Automatic · Running$/);
|
||||||
|
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
|
||||||
|
expect(lastMapping()?.textContent).toMatch(/· Automatic$/);
|
||||||
|
// Values stay visible during the refresh; nothing falls back to an em dash.
|
||||||
|
expect(facts()[0]).toEqual(['Controls', '3']);
|
||||||
|
} finally { vi.useRealTimers(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the last overview at once when returning to the tab, and refreshes it in the background', async () => {
|
||||||
|
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValueOnce(overview);
|
||||||
|
await render();
|
||||||
|
expect(facts()[0]).toEqual(['Controls', '3']);
|
||||||
|
// Switching to another Client Info tab unmounts this one.
|
||||||
|
await act(async () => root.render(null));
|
||||||
|
|
||||||
|
let refresh: (value: RegulatoryControlOverviewResponse) => void = () => undefined;
|
||||||
|
request.mockReturnValueOnce(new Promise(resolve => { refresh = resolve; }));
|
||||||
|
await render();
|
||||||
|
expect(request).toHaveBeenCalledTimes(2);
|
||||||
|
expect(facts()[0]).toEqual(['Controls', '3']);
|
||||||
|
expect(resultsButton()).not.toBeNull();
|
||||||
|
await act(async () => refresh({ ...overview, controls: overview.controls.slice(0, 1), findingCount: 2 }));
|
||||||
|
expect(facts()[0]).toEqual(['Controls', '1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Overview row (user decision 2026-10-08): a square Mapping coverage tile and the Annex A footprint above the
|
||||||
|
// framework tiles. Coverage counts are content-free; the footprint shows all 93 controls and opens Work only from a hit.
|
||||||
|
describe('Client Info Compliance overview', () => {
|
||||||
|
const coverage = { deviations: 8, accepted: 3, mappedNoneAccepted: 2, failed: 1, notMapped: 2 };
|
||||||
|
const legend = () => [...host.querySelectorAll('.compliance-coverage__legend > div')]
|
||||||
|
.map(row => [row.querySelector('dt')?.textContent, row.querySelector('dd')?.textContent]);
|
||||||
|
const cell = (controlId: string) => host.querySelector<HTMLElement>(`.compliance-footprint__cell[data-control="${controlId}"]`);
|
||||||
|
const themeHead = (theme: string) => host.querySelector<HTMLElement>(`[data-iso-theme="${theme}"] .compliance-footprint__theme-head`);
|
||||||
|
|
||||||
|
it('sits above the framework tiles with the coverage of the deviations by mapping state', async () => {
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, coverage });
|
||||||
|
await render();
|
||||||
|
const section = host.querySelector('.client-compliance')!;
|
||||||
|
expect([...section.children].map(child => child.className)).toEqual(['client-compliance-header', 'compliance-overview', 'client-compliance-grid']);
|
||||||
|
expect([...host.querySelectorAll('.compliance-overview .client-env-group-title')].map(title => title.textContent)).toEqual(['Mapping coverage', 'Annex A footprint']);
|
||||||
|
expect(host.querySelector('.compliance-coverage .client-info-donut > span')?.textContent).toBe('338%');
|
||||||
|
expect(host.querySelector('.compliance-coverage .client-info-donut')?.getAttribute('aria-label')).toBe('Deviations with an ISO control: 3 of 8');
|
||||||
|
expect(legend()).toEqual([['Accepted ISO control', '3'], ['Mapped, none accepted', '2'], ['Not mapped yet', '2'], ['Mapping failed', '1'], ['Mappable deviations', '8']]);
|
||||||
|
// Not mapped yet is the neutral track; the other three states are arcs.
|
||||||
|
expect([...host.querySelectorAll<SVGGElement>('.compliance-coverage svg g')].map(arc => arc.dataset.state)).toEqual(['accepted', 'mappedNoneAccepted', 'failed']);
|
||||||
|
expect(host.querySelector('.compliance-coverage__note')?.textContent).toBe('Shows how far mapping has progressed, not whether controls are met.');
|
||||||
|
// Only `Mapping failed` opens Work, so its findings can be mapped again from the Finding Reader.
|
||||||
|
const legendButtons = [...host.querySelectorAll<HTMLButtonElement>('.compliance-coverage__legend button')];
|
||||||
|
expect(legendButtons.map(button => button.getAttribute('aria-label'))).toEqual(['Open Mapping failed findings in Work (1)']);
|
||||||
|
expect(host.querySelector('.compliance-coverage__legend [data-state="failed"]')?.classList.contains('is-actionable')).toBe(true);
|
||||||
|
await act(async () => legendButtons[0].click());
|
||||||
|
expect(onOpenMappingState).toHaveBeenCalledWith('failed');
|
||||||
|
// The Client Info context row (client and timeslot) follows the tab bar.
|
||||||
|
expect(host.querySelector('.client-compliance-page > [data-testid="dummy-context"] + .client-compliance')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows all 93 Annex A controls and opens Work from a control or theme with findings only', async () => {
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, coverage,
|
||||||
|
annexATitles: { '7.4': 'Synthetischer Kontrolltitel', '8.9': 'Corpus title is not preferred over the mapping title' } });
|
||||||
|
await render();
|
||||||
|
expect(host.querySelectorAll('.compliance-footprint__themes .compliance-footprint__cell')).toHaveLength(93);
|
||||||
|
expect([...host.querySelectorAll<HTMLElement>('[data-iso-theme]')].map(theme =>
|
||||||
|
[theme.dataset.isoTheme, theme.querySelectorAll('.compliance-footprint__cell').length])).toEqual(
|
||||||
|
[['Organizational', 37], ['People', 8], ['Physical', 14], ['Technological', 34]]);
|
||||||
|
expect(themeHead('Technological')?.textContent).toBe('Technological2 of 34 controls · 3 findings');
|
||||||
|
expect(themeHead('People')?.textContent).toBe('People0 of 8 controls · 0 findings');
|
||||||
|
// Only a control or theme with hits opens Work, like the ISO control and theme facets.
|
||||||
|
expect(cell('8.9')?.tagName).toBe('BUTTON');
|
||||||
|
expect(cell('8.9')?.dataset.step).toBe('2');
|
||||||
|
expect(cell('5.15')?.dataset.step).toBe('1');
|
||||||
|
expect(cell('8.10')?.tagName).toBe('SPAN');
|
||||||
|
expect(cell('8.10')?.dataset.step).toBeUndefined();
|
||||||
|
expect(themeHead('People')?.tagName).toBe('DIV');
|
||||||
|
await act(async () => cell('8.20')!.click());
|
||||||
|
expect(onOpenControl).toHaveBeenCalledWith('8.20');
|
||||||
|
await act(async () => themeHead('Organizational')!.click());
|
||||||
|
expect(onOpenTheme).toHaveBeenCalledWith('Organizational');
|
||||||
|
// Focus names the control, its title and findings; an empty control says so without implying compliance.
|
||||||
|
const detail = () => host.querySelector('.compliance-footprint__detail')?.textContent;
|
||||||
|
await act(async () => cell('8.9')!.focus());
|
||||||
|
expect(detail()).toBe('A.8.9 · TechnologicalConfiguration management3 findings');
|
||||||
|
await act(async () => { cell('7.4')!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
|
||||||
|
expect(detail()).toBe('A.7.4 · PhysicalSynthetischer KontrolltitelNo mapped findings');
|
||||||
|
await act(async () => { cell('7.5')!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
|
||||||
|
expect(detail()).toBe('A.7.5 · PhysicalNo mapped findings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps pending values as an em dash with nothing to open while loading', async () => {
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockReturnValue(new Promise(() => undefined));
|
||||||
|
await render();
|
||||||
|
expect(legend().map(([, value]) => value)).toEqual(['—', '—', '—', '—', '—']);
|
||||||
|
expect(host.querySelector('.compliance-coverage .client-info-donut > span')?.textContent).toBe('——');
|
||||||
|
expect(host.querySelector('.compliance-overview button')).toBeNull();
|
||||||
|
expect(themeHead('Organizational')?.textContent).toBe('Organizational—');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Synthetic footprint: ARCH.9.9 and KONF.3.1 are not reachable through the ISO mapping; DET has no findings.
|
||||||
|
const bsiFootprint = { catalogVersion: '2026-01-01', crosswalkVersion: '0.1-synthetic', practices: [
|
||||||
|
{ id: 'BER', title: 'Berechtigung', requirements: [{ id: 'BER.1.1', title: 'Katalogtitel BER.1.1', reachable: true }, { id: 'BER.1.2', title: 'Katalogtitel BER.1.2', reachable: true }] },
|
||||||
|
{ id: 'ARCH', title: 'Architektur', requirements: [{ id: 'ARCH.1.1', title: 'Katalogtitel ARCH.1.1', reachable: true }, { id: 'ARCH.9.9', title: 'Katalogtitel ARCH.9.9', reachable: false }] },
|
||||||
|
{ id: 'KONF', title: 'Konfiguration', requirements: [{ id: 'KONF.1.1', title: 'Katalogtitel KONF.1.1', reachable: true }, { id: 'KONF.2.1', title: 'Katalogtitel KONF.2.1', reachable: true }, { id: 'KONF.3.1', title: 'Katalogtitel KONF.3.1', reachable: false }] },
|
||||||
|
{ id: 'DET', title: 'Detektion', requirements: [{ id: 'DET.1.1', title: 'Katalogtitel DET.1.1', reachable: true }] }] };
|
||||||
|
const switchButton = (view: string) => host.querySelector<HTMLButtonElement>(`.compliance-framework-switch [data-framework-view="${view}"]`);
|
||||||
|
const requirement = (id: string) => host.querySelector<HTMLElement>(`.compliance-footprint__cell[data-requirement="${id}"]`);
|
||||||
|
const practiceHead = (id: string) => host.querySelector<HTMLElement>(`[data-bsi-practice="${id}"] .compliance-footprint__theme-head`);
|
||||||
|
|
||||||
|
it('switches the overview row to BSI with its coverage and Kernel footprint, and opens Work from a requirement or practice', async () => {
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, bsi, bsiFootprint,
|
||||||
|
coverage: { deviations: 9, accepted: 4, mappedNoneAccepted: 2, failed: 1, notMapped: 2 } });
|
||||||
|
await render();
|
||||||
|
expect(switchButton('iso')?.getAttribute('aria-pressed')).toBe('true');
|
||||||
|
expect(switchButton('bsi')?.getAttribute('aria-pressed')).toBe('false');
|
||||||
|
expect(switchButton('iso')?.textContent).toBe('ISO 27001/27002');
|
||||||
|
await act(async () => switchButton('bsi')!.click());
|
||||||
|
expect(switchButton('bsi')?.getAttribute('aria-pressed')).toBe('true');
|
||||||
|
expect(host.querySelector<HTMLElement>('.compliance-overview')?.dataset.frameworkView).toBe('bsi');
|
||||||
|
expect([...host.querySelectorAll('.compliance-overview .client-env-group-title')].map(title => title.textContent)).toEqual(['Mapping coverage', 'Kernel footprint']);
|
||||||
|
// All three findings reach a requirement; the fourth accepted finding keeps only its ISO control.
|
||||||
|
expect(host.querySelector('.compliance-coverage .client-info-donut > span')?.textContent).toBe('333%');
|
||||||
|
expect(host.querySelector('.compliance-coverage__caption')?.textContent).toBe('Deviations with a BSI requirement');
|
||||||
|
expect(legend()).toEqual([['With BSI requirement', '3'], ['ISO control, no BSI requirement', '1'], ['Mapped, none accepted', '2'],
|
||||||
|
['Not mapped yet', '2'], ['Mapping failed', '1'], ['Mappable deviations', '9']]);
|
||||||
|
// ISO control without a BSI requirement and Mapping failed open Work through the ISO mapping filter.
|
||||||
|
const isoOnly = host.querySelector<HTMLButtonElement>('.compliance-coverage__legend [data-state="isoOnly"] button')!;
|
||||||
|
expect(isoOnly.getAttribute('aria-label')).toBe('Open ISO control, no BSI requirement findings in Work (1)');
|
||||||
|
await act(async () => isoOnly.click());
|
||||||
|
expect(onOpenMappingState).toHaveBeenLastCalledWith('no-bsi');
|
||||||
|
expect(host.querySelectorAll('.compliance-coverage__legend button')).toHaveLength(2);
|
||||||
|
// The tile shows the practices with the most findings first, each with all of its squares labelled by number.
|
||||||
|
expect([...host.querySelectorAll('.compliance-footprint__requirements .compliance-footprint__cell')].map(cell => cell.textContent))
|
||||||
|
.toEqual(['1.1', '2.1', '3.1', '1.1', '9.9']);
|
||||||
|
expect(practiceHead('DET')).toBeNull();
|
||||||
|
expect(practiceHead('KONF')?.textContent).toBe('KONF · Konfiguration2 of 3 requirements · 3 findings');
|
||||||
|
// BER has the fewest findings and stays out of the tile; Show all lists it below.
|
||||||
|
expect(practiceHead('BER')).toBeNull();
|
||||||
|
expect(requirement('KONF.1.1')?.tagName).toBe('BUTTON');
|
||||||
|
expect(requirement('KONF.1.1')?.dataset.step).toBe('2');
|
||||||
|
expect(requirement('ARCH.9.9')?.classList.contains('is-unreachable')).toBe(true);
|
||||||
|
await act(async () => requirement('KONF.1.1')!.click());
|
||||||
|
expect(onOpenBsiRequirement).toHaveBeenCalledWith('KONF.1.1');
|
||||||
|
await act(async () => practiceHead('ARCH')!.click());
|
||||||
|
expect(onOpenBsiPractice).toHaveBeenCalledWith('ARCH');
|
||||||
|
const detail = () => host.querySelector('.compliance-footprint__detail')?.textContent;
|
||||||
|
await act(async () => requirement('KONF.1.1')!.focus());
|
||||||
|
expect(detail()).toBe('KONF.1.1 · KONF Konfiguration · via A.8.20, A.8.9Synthetische Konfiguration3 findings');
|
||||||
|
|
||||||
|
// Show all opens every practice in a window, including those without findings; unreachable squares never open Work.
|
||||||
|
const showAll = host.querySelector<HTMLButtonElement>('.compliance-footprint .client-environment-results-button');
|
||||||
|
expect(showAll?.textContent).toBe('Show all');
|
||||||
|
await act(async () => showAll!.click());
|
||||||
|
const inWindow = (id: string) => document.querySelector<HTMLElement>(`.compliance-footprint--window [data-requirement="${id}"]`);
|
||||||
|
expect(document.querySelectorAll('.compliance-footprint--window .compliance-footprint__cell[data-requirement]')).toHaveLength(8);
|
||||||
|
expect(inWindow('BER.1.2')?.tagName).toBe('SPAN');
|
||||||
|
expect(inWindow('ARCH.9.9')?.classList.contains('is-unreachable')).toBe(true);
|
||||||
|
expect(inWindow('BER.1.2')?.classList.contains('is-unreachable')).toBe(false);
|
||||||
|
const windowDetail = () => document.querySelector('.compliance-footprint--window .compliance-footprint__detail')?.textContent;
|
||||||
|
await act(async () => { inWindow('ARCH.9.9')!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
|
||||||
|
expect(windowDetail()).toBe('ARCH.9.9 · ARCH ArchitekturKatalogtitel ARCH.9.9Not reachable through the ISO mapping');
|
||||||
|
await act(async () => { inWindow('BER.1.2')!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
|
||||||
|
expect(windowDetail()).toBe('BER.1.2 · BER BerechtigungKatalogtitel BER.1.2No mapped findings');
|
||||||
|
// The detail line and legend sit in the footer the window pins below the scrolling grid.
|
||||||
|
expect(document.querySelector('.compliance-footprint--window .compliance-footprint__footer .compliance-footprint__detail')).not.toBeNull();
|
||||||
|
expect(document.querySelector('.compliance-footprint--window .compliance-footprint__footer .compliance-footprint__legend')).not.toBeNull();
|
||||||
|
// Opening Work from the window closes it.
|
||||||
|
await act(async () => inWindow('KONF.2.1')!.click());
|
||||||
|
expect(onOpenBsiRequirement).toHaveBeenLastCalledWith('KONF.2.1');
|
||||||
|
expect(document.querySelector('.compliance-footprint--window')).toBeNull();
|
||||||
|
// The framework tiles stay unchanged, and the switch returns to ISO.
|
||||||
|
expect([...host.querySelectorAll<HTMLElement>('.client-compliance-grid > section')].map(section => section.dataset.framework))
|
||||||
|
.toEqual(['iso-27001-27002', 'bsi-grundschutz-plus-plus']);
|
||||||
|
await act(async () => switchButton('iso')!.click());
|
||||||
|
expect(host.querySelector<HTMLElement>('.compliance-overview')?.dataset.frameworkView).toBe('iso');
|
||||||
|
expect(host.querySelectorAll('.compliance-footprint__themes .compliance-footprint__cell')).toHaveLength(93);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows only the two BSI practices with the most findings in the tile, and every practice in Show all', async () => {
|
||||||
|
// Two more synthetic practices with hits: DET via 8.9 (3 findings) and NOT via 5.15 (1 finding).
|
||||||
|
const wider = { ...bsi, practices: [...bsi.practices, { id: 'NOT', title: 'Notfallplanung' }], requirements: [...bsi.requirements,
|
||||||
|
{ requirementId: 'DET.1.1', title: 'Synthetische Detektion', practice: 'DET', statement: 'Synthetisch.', via: [{ isoControlId: '8.9', relationship: 'subset-of' as const }] },
|
||||||
|
{ requirementId: 'NOT.1.1', title: 'Synthetischer Notfall', practice: 'NOT', statement: 'Synthetisch.', via: [{ isoControlId: '5.15', relationship: 'subset-of' as const }] }] };
|
||||||
|
const footprint = { ...bsiFootprint, practices: [...bsiFootprint.practices,
|
||||||
|
{ id: 'NOT', title: 'Notfallplanung', requirements: [{ id: 'NOT.1.1', title: 'Katalogtitel NOT.1.1', reachable: true }] }] };
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, bsi: wider, bsiFootprint: footprint,
|
||||||
|
coverage: { deviations: 9, accepted: 4, mappedNoneAccepted: 2, failed: 1, notMapped: 2 } });
|
||||||
|
await render();
|
||||||
|
await act(async () => switchButton('bsi')!.click());
|
||||||
|
// KONF and DET have 3, ARCH 2, BER and NOT 1 each; the tie between KONF and DET keeps catalog order.
|
||||||
|
const shown = () => [...host.querySelectorAll<HTMLElement>('.compliance-overview [data-bsi-practice]')].map(block => block.dataset.bsiPractice);
|
||||||
|
expect(shown()).toEqual(['KONF', 'DET']);
|
||||||
|
await act(async () => host.querySelector<HTMLButtonElement>('.compliance-footprint .client-environment-results-button')!.click());
|
||||||
|
expect([...document.querySelectorAll<HTMLElement>('.compliance-footprint--window [data-bsi-practice]')].map(block => block.dataset.bsiPractice))
|
||||||
|
.toEqual(['BER', 'ARCH', 'KONF', 'DET', 'NOT']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so in the BSI tile when no requirement is derived, while Show all still lists the catalog', async () => {
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, bsiFootprint,
|
||||||
|
coverage: { deviations: 9, accepted: 4, mappedNoneAccepted: 2, failed: 1, notMapped: 2 } });
|
||||||
|
await render();
|
||||||
|
await act(async () => switchButton('bsi')!.click());
|
||||||
|
expect(host.querySelector('.compliance-footprint .compliance-footprint__pending')?.textContent)
|
||||||
|
.toBe('No BSI requirements derived from accepted ISO controls in this timeslot.');
|
||||||
|
expect(legend()[0]).toEqual(['With BSI requirement', '0']);
|
||||||
|
await act(async () => host.querySelector<HTMLButtonElement>('.compliance-footprint .client-environment-results-button')!.click());
|
||||||
|
expect(document.querySelectorAll('.compliance-footprint--window .compliance-footprint__cell[data-requirement]')).toHaveLength(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the BSI view pending with an em dash and nothing to open while loading', async () => {
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockReturnValue(new Promise(() => undefined));
|
||||||
|
await render();
|
||||||
|
await act(async () => switchButton('bsi')!.click());
|
||||||
|
expect(legend().map(([, value]) => value)).toEqual(['—', '—', '—', '—', '—', '—']);
|
||||||
|
expect(host.querySelector('.compliance-footprint__pending')?.textContent).toBe('—');
|
||||||
|
expect(host.querySelector('.compliance-overview button')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing while the Compliance Engine is off', async () => {
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, coverage });
|
||||||
|
setEngineEnabled('compliance', false);
|
||||||
|
await render();
|
||||||
|
expect(host.querySelector('.compliance-overview')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// BSI Grundschutz++ tile (Phase 13F, stage 1): requirements derived from accepted ISO controls through the BSI
|
||||||
|
// mapping. Synthetic requirement ids, titles and statements; the findings come from the ISO controls above.
|
||||||
|
const bsi: NonNullable<RegulatoryControlOverviewResponse['bsi']> = {
|
||||||
|
catalogVersion: '2026-01-01T00:00:00+00:00', crosswalkVersion: '0.1-synthetic', license: 'Synthetic attribution (CC BY-SA 4.0).',
|
||||||
|
practices: [{ id: 'BER', title: 'Berechtigung' }, { id: 'ARCH', title: 'Architektur' }, { id: 'KONF', title: 'Konfiguration' }, { id: 'DET', title: 'Detektion' }],
|
||||||
|
requirements: [
|
||||||
|
{ requirementId: 'ARCH.1.1', title: 'Synthetische Architektur', practice: 'ARCH', statement: 'Synthetisch.', via: [{ isoControlId: '8.20', relationship: 'subset-of' }] },
|
||||||
|
{ requirementId: 'BER.1.1', title: 'Synthetische Berechtigung', practice: 'BER', statement: 'Synthetisch.', via: [{ isoControlId: '5.15', relationship: 'equivalent-to' }] },
|
||||||
|
{ requirementId: 'KONF.1.1', title: 'Synthetische Konfiguration', practice: 'KONF', statement: 'Synthetisch.',
|
||||||
|
via: [{ isoControlId: '8.20', relationship: 'intersects-with' }, { isoControlId: '8.9', relationship: 'subset-of' }] },
|
||||||
|
{ requirementId: 'KONF.2.1', title: 'Zweite Konfiguration', practice: 'KONF', statement: 'Synthetisch.', via: [{ isoControlId: '8.20', relationship: 'subset-of' }] }
|
||||||
|
],
|
||||||
|
missingTargets: [{ isoControlId: '8.9', requirementId: 'KONF.9.9' }]
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Client Info BSI Grundschutz++ tile', () => {
|
||||||
|
const tile = () => host.querySelector<HTMLElement>('[data-framework="bsi-grundschutz-plus-plus"]');
|
||||||
|
const facts = () => [...tile()!.querySelectorAll('.client-env-facts > div')].map(fact => [fact.querySelector('dt')?.textContent, fact.querySelector('dd')?.textContent]);
|
||||||
|
const openResults = () => act(async () => tile()!.querySelector<HTMLButtonElement>('.client-environment-results-button')!.click());
|
||||||
|
const groups = () => [...document.querySelectorAll<HTMLElement>('.bsi-requirements .iso-controls__group')];
|
||||||
|
|
||||||
|
it('sits beside the ISO tile with totals and the practices with the most findings, and opens Work from a practice', async () => {
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, bsi });
|
||||||
|
await render();
|
||||||
|
expect([...host.querySelectorAll<HTMLElement>('.client-compliance-grid > section.client-env-group')].map(section => section.dataset.framework))
|
||||||
|
.toEqual(['iso-27001-27002', 'bsi-grundschutz-plus-plus']);
|
||||||
|
expect(tile()!.querySelector('.client-env-group-title')?.textContent).toBe('BSI Grundschutz++');
|
||||||
|
// KONF.1.1 reaches all three findings through 8.9; a finding counts once per practice and once in total.
|
||||||
|
expect(facts()).toEqual([['Requirements', '4'], ['Findings', '3'], ['Konfiguration', '3 findings'], ['Architektur', '2 findings'], ['Berechtigung', '1 finding']]);
|
||||||
|
const practice = tile()!.querySelector<HTMLButtonElement>('[data-practice="ARCH"] button')!;
|
||||||
|
expect(practice.getAttribute('aria-label')).toBe('Open BSI practice ARCH findings in Work (2)');
|
||||||
|
await act(async () => practice.click());
|
||||||
|
expect(onOpenBsiPractice).toHaveBeenCalledWith('ARCH');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists every derived requirement by practice in the Results window and opens Work from a requirement or practice', async () => {
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue({ ...overview, bsi });
|
||||||
|
await render();
|
||||||
|
await openResults();
|
||||||
|
// Catalog practice order, only practices with requirements; rows ranked by finding count with their ISO route.
|
||||||
|
expect(groups().map(group => group.dataset.practice)).toEqual(['BER', 'ARCH', 'KONF']);
|
||||||
|
expect(groups()[2].querySelector('.iso-controls__theme')?.textContent).toBe('KONF · Konfiguration2 requirements · 3 findings');
|
||||||
|
expect([...groups()[2].querySelectorAll('.iso-controls__row')].map(row => [...row.children].map(part => part.textContent))).toEqual([
|
||||||
|
['KONF.1.1', 'Synthetische Konfiguration · via A.8.20, A.8.9', '3'], ['KONF.2.1', 'Zweite Konfiguration · via A.8.20', '2']]);
|
||||||
|
const source = document.querySelector('.bsi-requirements__source')?.textContent ?? '';
|
||||||
|
expect(source).toContain('0.1-synthetic');
|
||||||
|
expect(source).toContain('Not in the catalog: KONF.9.9.');
|
||||||
|
expect(source).toContain('Synthetic attribution (CC BY-SA 4.0).');
|
||||||
|
await act(async () => groups()[2].querySelector<HTMLButtonElement>('.iso-controls__row')!.click());
|
||||||
|
expect(onOpenBsiRequirement).toHaveBeenCalledWith('KONF.1.1');
|
||||||
|
expect(document.querySelector('.bsi-requirements')).toBeNull();
|
||||||
|
await openResults();
|
||||||
|
await act(async () => groups()[0].querySelector<HTMLButtonElement>('button.iso-controls__theme')!.click());
|
||||||
|
expect(onOpenBsiPractice).toHaveBeenCalledWith('BER');
|
||||||
|
expect(document.querySelector('.bsi-requirements')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows zero totals and an empty Results window when no requirement is derived', async () => {
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue(overview);
|
||||||
|
await render();
|
||||||
|
expect(facts()).toEqual([['Requirements', '0'], ['Findings', '0']]);
|
||||||
|
await openResults();
|
||||||
|
expect(document.querySelector('.iso-controls__empty')?.textContent).toBe('No BSI requirements derived from accepted ISO controls in this timeslot.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps pending values as an em dash and renders nothing while the Compliance Engine is off', async () => {
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockReturnValue(new Promise(() => undefined));
|
||||||
|
await render();
|
||||||
|
expect(facts()).toEqual([['Requirements', '—'], ['Findings', '—']]);
|
||||||
|
await act(async () => { root.unmount(); });
|
||||||
|
root = createRoot(host);
|
||||||
|
setEngineEnabled('compliance', false);
|
||||||
|
await render();
|
||||||
|
expect(tile()).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,10 @@ describe('Compliance Engine requests in the activity log', () => {
|
||||||
['/projects/p/regulatory-mapping-batches?assessmentIds=a&deviation=true', 'GET', 'Loading ISO mapping batch status'],
|
['/projects/p/regulatory-mapping-batches?assessmentIds=a&deviation=true', 'GET', 'Loading ISO mapping batch status'],
|
||||||
['/projects/p/regulatory-mapping-batches', 'POST', 'Starting ISO mapping batch'],
|
['/projects/p/regulatory-mapping-batches', 'POST', 'Starting ISO mapping batch'],
|
||||||
['/reference-data/iso-corpus', 'GET', 'Loading ISO corpus status'],
|
['/reference-data/iso-corpus', 'GET', 'Loading ISO corpus status'],
|
||||||
['/reference-data/iso-corpus', 'POST', 'Uploading ISO corpus']
|
['/reference-data/iso-corpus', 'POST', 'Uploading ISO corpus'],
|
||||||
|
['/reference-data/bsi-grundschutz', 'GET', 'Loading BSI catalog'],
|
||||||
|
['/reference-data/bsi-grundschutz/refresh', 'POST', 'Updating BSI catalog'],
|
||||||
|
['/reference-data/bsi-grundschutz/reset', 'POST', 'Restoring bundled BSI catalog']
|
||||||
])('labels %s %s', (path, method, label) => {
|
])('labels %s %s', (path, method, label) => {
|
||||||
expect(requestActivity(path, method)).toEqual({ area: 'Compliance Engine', label });
|
expect(requestActivity(path, method)).toEqual({ area: 'Compliance Engine', label });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,10 @@ vi.mock('../../src/app/AppProvider', () => {
|
||||||
let host: HTMLDivElement;
|
let host: HTMLDivElement;
|
||||||
let root: Root;
|
let root: Root;
|
||||||
|
|
||||||
function scannerRecord() {
|
function scannerRecord(occurrence = 'dummy-occurrence') {
|
||||||
const record = makeSheet('Dummy', [['Title', 'Status', 'Source: Scan'], ['Dummy SMB signing check', 'Fail', 'Purple Knight']],
|
const record = makeSheet('Dummy', [['Title', 'Status', 'Source: Scan'], ['Dummy SMB signing check', 'Fail', 'Purple Knight']],
|
||||||
'', 'dummy.csv', { isProjectSheet: true }).analysis.records[0];
|
'', 'dummy.csv', { isProjectSheet: true }).analysis.records[0];
|
||||||
return { ...record, findingOccurrenceId: 'dummy-occurrence' };
|
return { ...record, findingOccurrenceId: occurrence };
|
||||||
}
|
}
|
||||||
|
|
||||||
const mappingResponse: RegulatoryMappingResponse = {
|
const mappingResponse: RegulatoryMappingResponse = {
|
||||||
|
|
@ -37,9 +37,9 @@ const mappingResponse: RegulatoryMappingResponse = {
|
||||||
generationEnabled: true, state: 'empty', decisions: []
|
generationEnabled: true, state: 'empty', decisions: []
|
||||||
};
|
};
|
||||||
|
|
||||||
async function renderReader() {
|
async function renderReader(occurrence?: string) {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(<FindingReader record={scannerRecord()} title="Dummy SMB signing check" linkedSheets={[]} resultReferences={[]}
|
root.render(<FindingReader record={scannerRecord(occurrence)} title="Dummy SMB signing check" linkedSheets={[]} resultReferences={[]}
|
||||||
search="" position={0} recordCount={1} toolId="purple-knight" onPrevious={vi.fn()} onNext={vi.fn()} onReview={vi.fn()}
|
search="" position={0} recordCount={1} toolId="purple-knight" onPrevious={vi.fn()} onNext={vi.fn()} onReview={vi.fn()}
|
||||||
projectId="dummy-project" backendStatus="connected"/>);
|
projectId="dummy-project" backendStatus="connected"/>);
|
||||||
await new Promise(resolve => setTimeout(resolve, 0));
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
|
@ -122,6 +122,48 @@ describe('Compliance Engine switch', () => {
|
||||||
expect(isoButton()?.textContent).toBe('Map ISO controls (2)');
|
expect(isoButton()?.textContent).toBe('Map ISO controls (2)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lists the derived BSI requirements collapsed after the ISO section, grouped by ISO control, and none while off', async () => {
|
||||||
|
const accepted = (controlId: string) => ({ version: 1 as const, id: `d-${controlId}`, ruleKey: 'purple-knight:dummy', sourceTool: 'purple-knight',
|
||||||
|
mappingId: 'dummy-mapping', controlId, decision: 'accepted' as const, createdAt: '2026-10-05T10:00:00Z' });
|
||||||
|
const requirement = (requirementId: string, title: string, isoControlIds: string[]) => ({ requirementId, title, practice: requirementId.split('.')[0],
|
||||||
|
statement: `Synthetische Anforderung ${requirementId}.`, via: isoControlIds.map(isoControlId => ({ isoControlId, relationship: 'subset-of' as const })) });
|
||||||
|
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockResolvedValue({ ...mappingResponse, state: 'succeeded',
|
||||||
|
run: { candidates: [{ controlId: '8.2', annexAControlId: 'A.8.2', score: 0.9, evidence: [] },
|
||||||
|
{ controlId: '5.18', annexAControlId: 'A.5.18', score: 0.8, evidence: [] }] } as never,
|
||||||
|
decisions: [accepted('8.2'), accepted('5.18')],
|
||||||
|
// Synthetic derived requirements; the real ones come from the active BSI mapping on the server.
|
||||||
|
bsi: { catalogVersion: '2026-01-01T00:00:00+00:00', crosswalkVersion: '0.1-synthetic', license: 'Synthetic attribution (CC BY-SA 4.0).',
|
||||||
|
practices: [{ id: 'BER', title: 'Berechtigung' }], missingTargets: [],
|
||||||
|
requirements: [requirement('BER.3.5', 'Synthetisches Konto', ['8.2']), requirement('BER.5.1', 'Synthetische Ausgabe', ['5.18', '8.2'])] } });
|
||||||
|
await renderReader();
|
||||||
|
const section = () => host.querySelector<HTMLDetailsElement>('details[data-bsi-requirements]');
|
||||||
|
expect(host.querySelector('[data-iso-controls]')!.nextElementSibling).toBe(section());
|
||||||
|
// Collapsed like Context, with the count in its summary.
|
||||||
|
expect(section()!.open).toBe(false);
|
||||||
|
expect(section()!.querySelector(':scope > summary')?.textContent).toBe('BSI requirements2 requirements derived from the accepted ISO controls');
|
||||||
|
await act(async () => section()!.querySelector<HTMLElement>(':scope > summary')!.click());
|
||||||
|
expect(section()!.open).toBe(true);
|
||||||
|
// One group per ISO control in numeric order; a requirement reached twice is listed under each of its controls.
|
||||||
|
const groups = [...section()!.querySelectorAll<HTMLElement>('.finding-bsi-requirements__group')];
|
||||||
|
expect(groups.map(group => group.querySelector('.finding-bsi-requirements__route')?.textContent)).toEqual(['A.5.18 · subset-of', 'A.8.2 · subset-of']);
|
||||||
|
expect(groups.map(group => [...group.querySelectorAll('li > details > summary')].map(row => row.textContent)))
|
||||||
|
.toEqual([['BER.5.1Synthetische Ausgabe'], ['BER.3.5Synthetisches Konto', 'BER.5.1Synthetische Ausgabe']]);
|
||||||
|
// A statement opens only with its requirement.
|
||||||
|
const item = groups[1].querySelector<HTMLDetailsElement>('li[data-requirement="BER.3.5"] > details')!;
|
||||||
|
expect(item.open).toBe(false);
|
||||||
|
await act(async () => item.querySelector('summary')!.click());
|
||||||
|
expect(item.open).toBe(true);
|
||||||
|
expect(item.querySelector('.finding-bsi-requirements__statement')?.textContent).toBe('Synthetische Anforderung BER.3.5.');
|
||||||
|
expect(section()!.querySelector('.finding-bsi-requirements__source')?.textContent?.trim())
|
||||||
|
.toBe('Via BSI mapping 0.1-synthetic · catalog 2026-01-01 · Source: BSI Stand-der-Technik-Bibliothek, CC BY-SA 4.0');
|
||||||
|
// Another finding starts collapsed again.
|
||||||
|
await renderReader('dummy-other-occurrence');
|
||||||
|
expect(section()?.open).toBe(false);
|
||||||
|
expect(request).toHaveBeenCalledTimes(2);
|
||||||
|
await act(async () => setEngineEnabled('compliance', false));
|
||||||
|
expect(section()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('requests no Result DB batch status while off and hides the batch action', async () => {
|
it('requests no Result DB batch status while off and hides the batch action', async () => {
|
||||||
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingBatch').mockResolvedValue({
|
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingBatch').mockResolvedValue({
|
||||||
version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [], filters: {} },
|
version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [], filters: {} },
|
||||||
|
|
|
||||||
|
|
@ -70,3 +70,30 @@ it('clears a transient status polling error once a later poll succeeds', async (
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('switches automatic mapping of new scans on and off from the Data Hub ISO tile', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const off: RegulatoryCorpusStatus = { version: 1, configured: true, active: null, job: null, autoMapping: { enabled: false } };
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'getIsoCorpusStatus').mockResolvedValue(off);
|
||||||
|
const toggle = vi.spyOn(AssessorApiClient.prototype, 'setIsoAutoMapping')
|
||||||
|
.mockImplementation(async enabled => ({ ...off, autoMapping: { enabled } }));
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(<IsoCorpusLibrary/>));
|
||||||
|
const checkbox = () => container.querySelector<HTMLInputElement>('.iso-auto-mapping-toggle input[type="checkbox"]')!;
|
||||||
|
expect(container.querySelector('.iso-auto-mapping-toggle')?.textContent).toBe('Map new scans automatically');
|
||||||
|
expect(checkbox().checked).toBe(false);
|
||||||
|
await act(async () => checkbox().click());
|
||||||
|
expect(toggle).toHaveBeenLastCalledWith(true);
|
||||||
|
expect(checkbox().checked).toBe(true);
|
||||||
|
await act(async () => checkbox().click());
|
||||||
|
expect(toggle).toHaveBeenLastCalledWith(false);
|
||||||
|
expect(checkbox().checked).toBe(false);
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,13 @@ import { AppProvider, useApp } from '../../src/app/AppProvider';
|
||||||
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
|
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
|
||||||
import type { RegulatoryControlOverviewResponse } from '../../src/compliance/regulatory-mapping';
|
import type { RegulatoryControlOverviewResponse } from '../../src/compliance/regulatory-mapping';
|
||||||
import {
|
import {
|
||||||
|
BSI_PRACTICE_FACET_ID,
|
||||||
|
BSI_REQUIREMENT_FACET_ID,
|
||||||
complianceWorkFacetIndex,
|
complianceWorkFacetIndex,
|
||||||
ISO_CONTROL_FACET_ID,
|
ISO_CONTROL_FACET_ID,
|
||||||
|
ISO_MAPPING_FACET_ID,
|
||||||
|
ISO_MAPPING_FAILED,
|
||||||
|
ISO_MAPPING_NO_BSI,
|
||||||
ISO_THEME_FACET_ID,
|
ISO_THEME_FACET_ID,
|
||||||
resetComplianceWorkFacetsForTests,
|
resetComplianceWorkFacetsForTests,
|
||||||
useComplianceWorkFacets
|
useComplianceWorkFacets
|
||||||
|
|
@ -58,7 +63,8 @@ describe('Work ISO control and ISO theme facets', () => {
|
||||||
const facets = selectWorkFacets(workSheet(), options());
|
const facets = selectWorkFacets(workSheet(), options());
|
||||||
const ids = facets.map(facet => facet.id);
|
const ids = facets.map(facet => facet.id);
|
||||||
expect(ids.at(-1)).toBe('provenance:source-scan');
|
expect(ids.at(-1)).toBe('provenance:source-scan');
|
||||||
expect(ids.slice(-3, -1)).toEqual([ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID]);
|
// Nothing reaches BSI in this fixture, so ISO mapping offers `ISO control, no BSI requirement` after ISO theme.
|
||||||
|
expect(ids.slice(-4, -1)).toEqual([ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID, ISO_MAPPING_FACET_ID]);
|
||||||
const control = facets.find(facet => facet.id === ISO_CONTROL_FACET_ID)!;
|
const control = facets.find(facet => facet.id === ISO_CONTROL_FACET_ID)!;
|
||||||
expect(control.label).toBe('ISO control');
|
expect(control.label).toBe('ISO control');
|
||||||
expect(control.values.map(value => [value.label, value.count])).toEqual([['A.5.15', 1], ['A.8.20 · Networks security', 3]]);
|
expect(control.values.map(value => [value.label, value.count])).toEqual([['A.5.15', 1], ['A.8.20 · Networks security', 3]]);
|
||||||
|
|
@ -66,6 +72,43 @@ describe('Work ISO control and ISO theme facets', () => {
|
||||||
expect(theme.values.map(value => [value.value, value.label, value.count])).toEqual([['organizational', 'Organizational', 1], ['technological', 'Technological', 3]]);
|
expect(theme.values.map(value => [value.value, value.label, value.count])).toEqual([['organizational', 'Organizational', 1], ['technological', 'Technological', 3]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('offers ISO mapping: Mapping failed only while a mapping failed, and filters to exactly those records', () => {
|
||||||
|
const sheet = workSheet();
|
||||||
|
sheet.analysis.records[3].findingOccurrenceId = 'dummy-unmapped';
|
||||||
|
const failed = { ...assignments, failedFindings: [
|
||||||
|
{ resultProjectionId: 'scanner-finding:dummy-unmapped', sourceKind: 'scanner-finding' as const, sourceResultId: 'dummy-unmapped' }] };
|
||||||
|
const withFailed = { ...options(), complianceFacets: complianceWorkFacetIndex(failed) };
|
||||||
|
const ids = selectWorkFacets(sheet, withFailed).map(facet => facet.id);
|
||||||
|
expect(ids.slice(-4)).toEqual([ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID, ISO_MAPPING_FACET_ID, 'provenance:source-scan']);
|
||||||
|
const facet = selectWorkFacets(sheet, withFailed).find(item => item.id === ISO_MAPPING_FACET_ID)!;
|
||||||
|
expect(facet.label).toBe('ISO mapping');
|
||||||
|
expect(facet.values.map(value => [value.value, value.label, value.count])).toEqual([[ISO_MAPPING_FAILED, 'Mapping failed', 1],
|
||||||
|
[ISO_MAPPING_NO_BSI, 'ISO control, no BSI requirement', 3]]);
|
||||||
|
const titles = filterAndSortRecords(sheet, { ...withFailed, statusFilter: 'deviation',
|
||||||
|
facetFilters: { [ISO_MAPPING_FACET_ID]: { values: [ISO_MAPPING_FAILED] } }, sortColumn: '', sortDirection: 'asc' }).map(record => record.title);
|
||||||
|
expect(titles).toEqual(['Dummy unmapped check']);
|
||||||
|
// Without failed mappings and without a BSI derivation gap there is no such filter.
|
||||||
|
const allReachBsi = { ...assignments, bsi: { catalogVersion: '2026-01-01', crosswalkVersion: '0.1-synthetic', license: 'Synthetic.',
|
||||||
|
practices: [{ id: 'KONF', title: 'Konfiguration' }], missingTargets: [], requirements: [
|
||||||
|
{ requirementId: 'KONF.1.1', title: 'Synthetisch', practice: 'KONF', statement: 'Synthetisch.', via: [{ isoControlId: '8.20', relationship: 'subset-of' as const },
|
||||||
|
{ isoControlId: '5.15', relationship: 'subset-of' as const }] }] } };
|
||||||
|
expect(selectWorkFacets(sheet, { ...options(), complianceFacets: complianceWorkFacetIndex(allReachBsi) }).some(item => item.id === ISO_MAPPING_FACET_ID)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers ISO mapping: ISO control, no BSI requirement for records whose accepted controls reach no BSI requirement', () => {
|
||||||
|
const sheet = workSheet();
|
||||||
|
// Only 8.20 reaches a BSI requirement; Dummy check B also has 5.15 and therefore one.
|
||||||
|
const partial = { ...assignments, bsi: { catalogVersion: '2026-01-01', crosswalkVersion: '0.1-synthetic', license: 'Synthetic.',
|
||||||
|
practices: [{ id: 'KONF', title: 'Konfiguration' }], missingTargets: [], requirements: [
|
||||||
|
{ requirementId: 'KONF.1.1', title: 'Synthetisch', practice: 'KONF', statement: 'Synthetisch.', via: [{ isoControlId: '5.15', relationship: 'subset-of' as const }] }] } };
|
||||||
|
const withPartial = { ...options(), complianceFacets: complianceWorkFacetIndex(partial) };
|
||||||
|
const facet = selectWorkFacets(sheet, withPartial).find(item => item.id === ISO_MAPPING_FACET_ID)!;
|
||||||
|
expect(facet.values.map(value => [value.value, value.label, value.count])).toEqual([[ISO_MAPPING_NO_BSI, 'ISO control, no BSI requirement', 2]]);
|
||||||
|
const titles = filterAndSortRecords(sheet, { ...withPartial, facetFilters: { [ISO_MAPPING_FACET_ID]: { values: [ISO_MAPPING_NO_BSI] } },
|
||||||
|
sortColumn: '', sortDirection: 'asc' }).map(record => record.title);
|
||||||
|
expect(titles).toEqual(['Dummy check A', 'Dummy GPO setting']);
|
||||||
|
});
|
||||||
|
|
||||||
it('filters Work records by control and theme, matching scanner occurrences and GPO settings', () => {
|
it('filters Work records by control and theme, matching scanner occurrences and GPO settings', () => {
|
||||||
const sheet = workSheet();
|
const sheet = workSheet();
|
||||||
const titles = (filters: object, status = 'all') => filterAndSortRecords(sheet, { ...options(status, filters), sortColumn: '', sortDirection: 'asc' })
|
const titles = (filters: object, status = 'all') => filterAndSortRecords(sheet, { ...options(status, filters), sortColumn: '', sortDirection: 'asc' })
|
||||||
|
|
@ -87,6 +130,46 @@ describe('Work ISO control and ISO theme facets', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Work `BSI requirement` and `BSI practice` (Phase 13F, stage 1): requirements derived from the accepted ISO
|
||||||
|
// controls through the BSI mapping, after the ISO facets and before the final `Source: Scan`. Synthetic ids.
|
||||||
|
describe('Work BSI requirement and BSI practice facets', () => {
|
||||||
|
const withBsi: RegulatoryControlOverviewResponse = { ...assignments, bsi: {
|
||||||
|
catalogVersion: '2026-01-01', crosswalkVersion: '0.1-synthetic', license: 'Synthetic attribution.',
|
||||||
|
practices: [{ id: 'BER', title: 'Berechtigung' }, { id: 'ARCH', title: 'Architektur' }, { id: 'DET', title: 'Detektion' }],
|
||||||
|
requirements: [
|
||||||
|
{ requirementId: 'ARCH.1.1', title: 'Synthetische Architektur', practice: 'ARCH', statement: 'Synthetisch.', via: [{ isoControlId: '8.20', relationship: 'subset-of' }] },
|
||||||
|
{ requirementId: 'BER.1.1', title: 'Synthetische Berechtigung', practice: 'BER', statement: 'Synthetisch.', via: [{ isoControlId: '5.15', relationship: 'equivalent-to' }] }
|
||||||
|
],
|
||||||
|
missingTargets: []
|
||||||
|
} };
|
||||||
|
const bsiOptions = (statusFilter = 'all', facetFilters = {}) => ({ ...options(statusFilter, facetFilters), complianceFacets: complianceWorkFacetIndex(withBsi) });
|
||||||
|
|
||||||
|
it('offers derived requirements and practices after the ISO facets, before the final Source: Scan control', () => {
|
||||||
|
const facets = selectWorkFacets(workSheet(), bsiOptions());
|
||||||
|
expect(facets.map(facet => facet.id).slice(-5)).toEqual([ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID, BSI_REQUIREMENT_FACET_ID, BSI_PRACTICE_FACET_ID, 'provenance:source-scan']);
|
||||||
|
const requirement = facets.find(facet => facet.id === BSI_REQUIREMENT_FACET_ID)!;
|
||||||
|
expect(requirement.label).toBe('BSI requirement');
|
||||||
|
expect(requirement.values.map(value => [value.value, value.label, value.count])).toEqual([
|
||||||
|
['arch.1.1', 'ARCH.1.1 · Synthetische Architektur', 3], ['ber.1.1', 'BER.1.1 · Synthetische Berechtigung', 1]]);
|
||||||
|
// Catalog order, only practices with a derived requirement.
|
||||||
|
expect(facets.find(facet => facet.id === BSI_PRACTICE_FACET_ID)!.values.map(value => [value.value, value.label, value.count]))
|
||||||
|
.toEqual([['ber', 'BER · Berechtigung', 1], ['arch', 'ARCH · Architektur', 3]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters Work records by requirement and practice through their ISO controls, with the other filters still applying', () => {
|
||||||
|
const sheet = workSheet();
|
||||||
|
const titles = (filters: object, status = 'all') => filterAndSortRecords(sheet, { ...bsiOptions(status, filters), sortColumn: '', sortDirection: 'asc' })
|
||||||
|
.map(record => record.title);
|
||||||
|
expect(titles({ [BSI_REQUIREMENT_FACET_ID]: { values: ['ber.1.1'] } })).toEqual(['Dummy check B']);
|
||||||
|
expect(titles({ [BSI_PRACTICE_FACET_ID]: { values: ['arch'] } })).toEqual(['Dummy check A', 'Dummy check B', 'Dummy GPO setting']);
|
||||||
|
expect(titles({ [BSI_PRACTICE_FACET_ID]: { values: ['arch'] } }, 'deviation')).toEqual(['Dummy check A', 'Dummy GPO setting']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds no BSI facet when no requirement is derived', () => {
|
||||||
|
expect(selectWorkFacets(workSheet(), options()).some(facet => facet.id.startsWith('compliance:bsi-'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../../src/features/project-import/load-canonical-project-workspace', () => ({ loadCanonicalProjectWorkspace: vi.fn() }));
|
vi.mock('../../src/features/project-import/load-canonical-project-workspace', () => ({ loadCanonicalProjectWorkspace: vi.fn() }));
|
||||||
|
|
||||||
let host: HTMLDivElement;
|
let host: HTMLDivElement;
|
||||||
|
|
@ -134,7 +217,7 @@ describe('Work ISO facet loading and the engine switch', () => {
|
||||||
await act(async () => { host.querySelector('button')!.click(); await new Promise(resolve => setTimeout(resolve, 0)); });
|
await act(async () => { host.querySelector('button')!.click(); await new Promise(resolve => setTimeout(resolve, 0)); });
|
||||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
|
||||||
expect(request).toHaveBeenCalledTimes(1);
|
expect(request).toHaveBeenCalledTimes(1);
|
||||||
expect([...host.querySelectorAll('[data-iso-facets]')].map(node => node.textContent)).toEqual(['2,2', '2,2']);
|
expect([...host.querySelectorAll('[data-iso-facets]')].map(node => node.textContent)).toEqual(['2,2,1', '2,2,1']);
|
||||||
await act(async () => { markRegulatoryDecisionSaved(); await new Promise(resolve => setTimeout(resolve, 0)); });
|
await act(async () => { markRegulatoryDecisionSaved(); await new Promise(resolve => setTimeout(resolve, 0)); });
|
||||||
expect(request).toHaveBeenCalledTimes(2);
|
expect(request).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { RegulatoryAutoMapping } from '../../server/compliance/regulatory-auto-mapping';
|
||||||
|
import type { ScanRun } from '../../src/domain/canonical-model';
|
||||||
|
import type { UnifiedResultQuery } from '../../src/domain/unified-result';
|
||||||
|
|
||||||
|
let root: string;
|
||||||
|
beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'assessor-auto-mapping-')); });
|
||||||
|
afterEach(async () => { await rm(root, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
function run(id: string, extra: Partial<ScanRun> = {}): ScanRun {
|
||||||
|
return { id, assessmentId: 'synthetic-assessment', sequence: 1, label: id, tool: { id: 'synthetic', name: 'Synthetic' } as ScanRun['tool'],
|
||||||
|
importedAt: '2026-10-08T00:00:00Z', ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness(initialRuns: ScanRun[], generationEnabled = true) {
|
||||||
|
const runs = [...initialRuns];
|
||||||
|
const gpoRows: string[] = [];
|
||||||
|
const read = {
|
||||||
|
listProjects: vi.fn(async () => [{ id: 'synthetic-project' }] as never),
|
||||||
|
listAssessments: vi.fn(async () => [{ id: 'synthetic-assessment' }] as never),
|
||||||
|
listProjectScanRuns: vi.fn(async () => runs),
|
||||||
|
queryUnifiedResults: vi.fn(async (query: UnifiedResultQuery) => {
|
||||||
|
const items = gpoRows.slice(query.offset ?? 0, (query.offset ?? 0) + (query.limit ?? gpoRows.length))
|
||||||
|
.map(resultProjectionId => ({ resultProjectionId }));
|
||||||
|
return { total: gpoRows.length, items } as never;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
const mapping = { generationEnabled, startBatch: vi.fn(async (_projectId: string, _request: unknown) => ({}) as never) };
|
||||||
|
const auto = new RegulatoryAutoMapping({ settingsPath: join(root, 'auto-mapping.json'), read, mapping, pollIntervalMs: 3_600_000 });
|
||||||
|
return { runs, gpoRows, read, mapping, auto };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('stays off by default and reads nothing from the Core', async () => {
|
||||||
|
const { auto, read, mapping } = harness([run('existing')]);
|
||||||
|
await auto.start();
|
||||||
|
await auto.poll();
|
||||||
|
expect(auto.isEnabled).toBe(false);
|
||||||
|
expect(read.listProjects).not.toHaveBeenCalled();
|
||||||
|
expect(mapping.startBatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps the deviations of a scan uploaded after switching on, but not the scans that existed before', async () => {
|
||||||
|
const { auto, runs, mapping } = harness([run('existing')]);
|
||||||
|
await auto.setEnabled(true);
|
||||||
|
expect(mapping.startBatch).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
runs.push(run('new-scan'));
|
||||||
|
await auto.poll();
|
||||||
|
expect(mapping.startBatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mapping.startBatch).toHaveBeenCalledWith('synthetic-project', {
|
||||||
|
idempotencyKey: 'auto:new-scan', assessmentIds: ['synthetic-assessment'], scanRunIds: ['new-scan'], filters: { deviation: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
await auto.poll();
|
||||||
|
expect(mapping.startBatch).toHaveBeenCalledTimes(1);
|
||||||
|
await auto.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps the GPO deviations of a timeslot when a GPO import adds new rows, reading only Result DB pages', async () => {
|
||||||
|
const h = harness([]);
|
||||||
|
h.gpoRows.push('policy-evaluation-comparison:existing');
|
||||||
|
await h.auto.setEnabled(true);
|
||||||
|
expect(h.mapping.startBatch).not.toHaveBeenCalled();
|
||||||
|
expect(h.read.queryUnifiedResults.mock.calls.every(([query]) => query.audience === 'result-db')).toBe(true);
|
||||||
|
|
||||||
|
h.gpoRows.push('policy-evaluation-comparison:new-a', 'policy-evaluation-comparison:new-b');
|
||||||
|
await h.auto.poll();
|
||||||
|
expect(h.mapping.startBatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.mapping.startBatch.mock.calls[0][1]).toMatchObject({
|
||||||
|
assessmentIds: ['synthetic-assessment'], scanRunIds: [], filters: { sourceKind: ['policy-evaluation'], deviation: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
await h.auto.poll();
|
||||||
|
expect(h.mapping.startBatch).toHaveBeenCalledTimes(1);
|
||||||
|
// A later GPO import in the same timeslot gets its own batch, not the earlier one by idempotency key.
|
||||||
|
h.gpoRows.push('policy-evaluation-comparison:new-c');
|
||||||
|
await h.auto.poll();
|
||||||
|
expect(h.mapping.startBatch).toHaveBeenCalledTimes(2);
|
||||||
|
const keys = h.mapping.startBatch.mock.calls.map(call => (call[1] as { idempotencyKey: string }).idempotencyKey);
|
||||||
|
expect(new Set(keys).size).toBe(2);
|
||||||
|
await h.auto.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores superseded runs and keeps a new scan pending until mapping is available', async () => {
|
||||||
|
const h = harness([], false);
|
||||||
|
await h.auto.setEnabled(true);
|
||||||
|
h.runs.push(run('replaced', { supersededByScanRunId: 'replacement' }), run('replacement'));
|
||||||
|
await h.auto.poll();
|
||||||
|
expect(h.mapping.startBatch).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
h.mapping.generationEnabled = true;
|
||||||
|
await h.auto.poll();
|
||||||
|
expect(h.mapping.startBatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.mapping.startBatch.mock.calls[0][1]).toMatchObject({ scanRunIds: ['replacement'] });
|
||||||
|
await h.auto.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the switch in its own settings file and stops watching when switched off', async () => {
|
||||||
|
const first = harness([]);
|
||||||
|
await first.auto.setEnabled(true);
|
||||||
|
expect(JSON.parse(await readFile(join(root, 'auto-mapping.json'), 'utf8'))).toEqual({ version: 1, enabled: true });
|
||||||
|
await first.auto.stop();
|
||||||
|
|
||||||
|
const restarted = harness([]);
|
||||||
|
await restarted.auto.start();
|
||||||
|
expect(restarted.auto.isEnabled).toBe(true);
|
||||||
|
await restarted.auto.setEnabled(false);
|
||||||
|
restarted.runs.push(run('after-off'));
|
||||||
|
restarted.gpoRows.push('policy-evaluation-comparison:after-off');
|
||||||
|
await restarted.auto.poll();
|
||||||
|
expect(restarted.mapping.startBatch).not.toHaveBeenCalled();
|
||||||
|
expect(JSON.parse(await readFile(join(root, 'auto-mapping.json'), 'utf8'))).toEqual({ version: 1, enabled: false });
|
||||||
|
});
|
||||||
|
|
@ -188,3 +188,33 @@ describe('operator ISO corpus upload from Data Hub', () => {
|
||||||
await expect(stat(temporary)).rejects.toMatchObject({ code: 'ENOENT' });
|
await expect(stat(temporary)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('automatic ISO mapping switch', () => {
|
||||||
|
it('reports and changes the switch through the ISO corpus route without the upload token', async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'assessor-iso-auto-'));
|
||||||
|
await writeFile(join(directory, 'index.html'), '<main>synthetic app</main>');
|
||||||
|
let enabled = false;
|
||||||
|
const autoMapping = { get isEnabled() { return enabled; }, setEnabled: vi.fn(async (value: boolean) => { enabled = value; }) };
|
||||||
|
const service = new RegulatoryCorpusUploadService({ root: join(directory, 'artifacts', 'regulatory-corpora'),
|
||||||
|
mapping: {} as RegulatoryMappingService, autoMapping });
|
||||||
|
await service.initialize();
|
||||||
|
const server = createAssessorHttpServer({ repository: new LocalAssessorRepository(), staticRoot: directory,
|
||||||
|
regulatoryCorpusUploadService: service, isoUploadToken: token });
|
||||||
|
await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
||||||
|
const baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||||
|
const put = (body: unknown) => fetch(`${baseUrl}${path}/auto-mapping`, { method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1', Origin: baseUrl }, body: JSON.stringify(body) });
|
||||||
|
try {
|
||||||
|
expect((await (await fetch(`${baseUrl}${path}`)).json()).autoMapping).toEqual({ enabled: false });
|
||||||
|
const switched = await put({ enabled: true });
|
||||||
|
expect(switched.status).toBe(200);
|
||||||
|
expect((await switched.json()).autoMapping).toEqual({ enabled: true });
|
||||||
|
expect(autoMapping.setEnabled).toHaveBeenCalledWith(true);
|
||||||
|
expect((await put({ enabled: 'yes' })).status).toBe(400);
|
||||||
|
expect(autoMapping.setEnabled).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act, type ReactNode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { afterEach, expect, it, vi } from 'vitest';
|
||||||
|
import { AssessorApiClient } from '../../src/api/assessor-api-client';
|
||||||
|
import { useOptionalApp } from '../../src/app/AppProvider';
|
||||||
|
import type { RegulatoryMappingResponse } from '../../src/compliance/regulatory-mapping';
|
||||||
|
import { useRegulatoryMapping } from '../../src/compliance/ui/use-regulatory-mapping';
|
||||||
|
|
||||||
|
vi.mock('../../src/app/AppProvider', () => ({ useOptionalApp: vi.fn() }));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: RegulatoryMappingResponse = {
|
||||||
|
version: 1, projectId: 'dummy-project', resultProjectionId: 'scanner-finding:dummy', generationEnabled: true, state: 'empty', decisions: []
|
||||||
|
};
|
||||||
|
|
||||||
|
function Probe(): ReactNode {
|
||||||
|
const controller = useRegulatoryMapping('dummy-project', 'scanner-finding:dummy', 'connected');
|
||||||
|
return <button type="button" onClick={() => void controller.start()}>Map ISO controls</button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mapping history names where a single mapping came from, so `Map ISO controls` sends the shown timeslot.
|
||||||
|
it('sends the timeslot the Finding Reader shows with a single mapping', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
vi.mocked(useOptionalApp).mockReturnValue({ activeAssessmentIds: ['dummy-assessment'], activeScanRunIds: ['dummy-scan-run'] } as never);
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappings').mockResolvedValue(response);
|
||||||
|
const generate = vi.spyOn(AssessorApiClient.prototype, 'generateRegulatoryMappings').mockResolvedValue(response);
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(<Probe/>));
|
||||||
|
await act(async () => container.querySelector('button')!.click());
|
||||||
|
expect(generate).toHaveBeenCalledWith('dummy-project', {
|
||||||
|
resultProjectionId: 'scanner-finding:dummy', origin: { assessmentIds: ['dummy-assessment'], scanRunIds: ['dummy-scan-run'] }
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { AUTO_ACCEPT_COMMENT, RegulatoryMappingService } from '../../server/compliance/regulatory-mapping-service';
|
import { AUTO_ACCEPT_COMMENT, RegulatoryMappingService, type RegulatoryMappingServiceOptions } from '../../server/compliance/regulatory-mapping-service';
|
||||||
|
import { bundledBsiCatalogs } from '../../server/compliance/bsi-snapshots';
|
||||||
import { RegulatoryFindingRetrievalService } from '../../server/compliance/regulatory-finding-retrieval';
|
import { RegulatoryFindingRetrievalService } from '../../server/compliance/regulatory-finding-retrieval';
|
||||||
import { acceptedControlIds, REGULATORY_GPO_QUERY_VERSION, REGULATORY_MAPPING_PROMPT_VERSION, type RegulatoryMappingBatchRun, type RegulatoryMappingDecision, type RegulatoryMappingRun } from '../../src/compliance/regulatory-mapping';
|
import { acceptedControlIds, REGULATORY_GPO_QUERY_VERSION, REGULATORY_MAPPING_PROMPT_VERSION, type RegulatoryMappingBatchRun, type RegulatoryMappingDecision, type RegulatoryMappingRun } from '../../src/compliance/regulatory-mapping';
|
||||||
import type { RegulatoryChunk, RegulatoryRetrievalRequest } from '../../src/compliance/regulatory-rag';
|
import type { RegulatoryChunk, RegulatoryRetrievalRequest } from '../../src/compliance/regulatory-rag';
|
||||||
import { filterUnifiedResults, type UnifiedResultProjection } from '../../src/domain/unified-result';
|
import { filterUnifiedResults, type UnifiedResultProjection } from '../../src/domain/unified-result';
|
||||||
import type { ComplianceReadPort, ComplianceStore } from '../../src/compliance/compliance-store';
|
import type { ComplianceReadPort, ComplianceStore } from '../../src/compliance/compliance-store';
|
||||||
|
import { LocalComplianceStore } from '../../src/compliance/local-compliance-store';
|
||||||
|
|
||||||
type EngineRepository = ComplianceReadPort & ComplianceStore;
|
type EngineRepository = ComplianceReadPort & ComplianceStore;
|
||||||
import { removeResultDbScopeIdentifiers } from '../../src/domain/result-db-disclosure';
|
import { removeResultDbScopeIdentifiers } from '../../src/domain/result-db-disclosure';
|
||||||
|
|
@ -29,15 +31,15 @@ function finding(overrides: Partial<UnifiedResultProjection> = {}): UnifiedResul
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function chunk(sourceStandard: 'ISO/IEC 27001' | 'ISO/IEC 27002'): RegulatoryChunk {
|
function chunk(sourceStandard: 'ISO/IEC 27001' | 'ISO/IEC 27002', controlId = '5.1'): RegulatoryChunk {
|
||||||
const reference = sourceStandard === 'ISO/IEC 27001';
|
const reference = sourceStandard === 'ISO/IEC 27001';
|
||||||
return {
|
return {
|
||||||
schemaVersion: 2, corpusId: 'iso-test', corpusVersion: 'v2', documentId: reference ? '27001-5.1' : '27002-5.1-purpose',
|
schemaVersion: 2, corpusId: 'iso-test', corpusVersion: 'v2', documentId: reference ? `27001-${controlId}` : `27002-${controlId}-purpose`,
|
||||||
framework: 'ISO/IEC 27002', frameworkVersion: '2022', controlId: '5.1', annexAControlId: 'A.5.1',
|
framework: 'ISO/IEC 27002', frameworkVersion: '2022', controlId, annexAControlId: `A.${controlId}`,
|
||||||
controlTheme: 'organizational', sourceStandard, sourceEdition: '2022', nationalEdition: 'DIN 2024',
|
controlTheme: 'organizational', sourceStandard, sourceEdition: '2022', nationalEdition: 'DIN 2024',
|
||||||
sectionKind: reference ? 'control' : 'purpose', sourcePageStart: 1, sourcePageEnd: 1,
|
sectionKind: reference ? 'control' : 'purpose', sourcePageStart: 1, sourcePageEnd: 1,
|
||||||
retrievalRole: reference ? 'reference' : 'semantic', parserVersion: 'test', sourceSha256: reference ? '1'.repeat(64) : '2'.repeat(64),
|
retrievalRole: reference ? 'reference' : 'semantic', parserVersion: 'test', sourceSha256: reference ? '1'.repeat(64) : '2'.repeat(64),
|
||||||
chunkId: reference ? 'reference-5.1' : 'semantic-5.1', chunkIndex: 0, language: 'de', title: 'Synthetic control',
|
chunkId: reference ? `reference-${controlId}` : `semantic-${controlId}`, chunkIndex: 0, language: 'de', title: 'Synthetic control',
|
||||||
text: reference ? 'Synthetic normative statement.' : 'Synthetic semantic evidence.', textSha256: 'a'.repeat(64)
|
text: reference ? 'Synthetic normative statement.' : 'Synthetic semantic evidence.', textSha256: 'a'.repeat(64)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -108,12 +110,14 @@ function mockRepository(findings: UnifiedResultProjection[]) {
|
||||||
} as unknown as EngineRepository;
|
} as unknown as EngineRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
function service(repository: EngineRepository, complete: (system: string, input: string) => Promise<unknown>) {
|
function service(repository: EngineRepository, complete: (system: string, input: string) => Promise<unknown>, controlId = '5.1',
|
||||||
|
extra: Partial<RegulatoryMappingServiceOptions> = {}) {
|
||||||
return new RegulatoryMappingService(repository, repository, {
|
return new RegulatoryMappingService(repository, repository, {
|
||||||
findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve: vi.fn(async (_r: RegulatoryRetrievalRequest) => [{ score: 0.9, chunk: chunk('ISO/IEC 27002') }]) }),
|
findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve: vi.fn(async (_r: RegulatoryRetrievalRequest) => [{ score: 0.9, chunk: chunk('ISO/IEC 27002', controlId) }]) }),
|
||||||
index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list: vi.fn(async () => [chunk('ISO/IEC 27001')]) },
|
index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list: vi.fn(async () => [chunk('ISO/IEC 27001', controlId)]) },
|
||||||
client: { model: 'work-model', complete: complete as never }, corpusId: 'iso-test', corpusVersion: 'v2', embeddingModel: 'bge-m3',
|
client: { model: 'work-model', complete: complete as never }, corpusId: 'iso-test', corpusVersion: 'v2', embeddingModel: 'bge-m3',
|
||||||
now: () => new Date(stamp)
|
now: () => new Date(stamp),
|
||||||
|
...extra
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,6 +221,202 @@ describe('regulatory mapping service', () => {
|
||||||
await expect(svc.controlOverview('project-1', { assessmentIds: ['foreign-assessment'], scanRunIds: [] })).rejects.toThrow('outside this project');
|
await expect(svc.controlOverview('project-1', { assessmentIds: ['foreign-assessment'], scanRunIds: [] })).rejects.toThrow('outside this project');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('counts every mappable deviation of the timeslot once in one mapping state for the coverage overview', async () => {
|
||||||
|
const rule = (id: string, sourceRuleId: string, title: string) =>
|
||||||
|
finding({ resultProjectionId: `scanner-finding:${id}`, sourceResultId: id, sourceRuleId, title });
|
||||||
|
const findings = [
|
||||||
|
finding(), finding({ resultProjectionId: 'scanner-finding:occurrence-2', sourceResultId: 'occurrence-2' }),
|
||||||
|
rule('rejected', 'A-Rejected', 'SMB signing is not enforced'),
|
||||||
|
rule('failed', 'A-Failed', 'LDAP signing is not required'),
|
||||||
|
rule('unmapped', 'A-Unmapped', 'Print spooler runs on domain controllers'),
|
||||||
|
finding({ resultProjectionId: 'scanner-finding:passed', sourceResultId: 'passed', sourceRuleId: 'A-Passed', isDeviation: false })
|
||||||
|
];
|
||||||
|
const repository = mockRepository(findings);
|
||||||
|
const mapped = { value: { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'primary', confidence: 'high',
|
||||||
|
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }] } };
|
||||||
|
const svc = service(repository, vi.fn(async () => mapped));
|
||||||
|
for (const resultProjectionId of ['scanner-finding:occurrence-1', 'scanner-finding:rejected']) {
|
||||||
|
await svc.start('project-1', { resultProjectionId });
|
||||||
|
await svc.close();
|
||||||
|
}
|
||||||
|
const accepted = (await svc.get('project-1', 'scanner-finding:rejected')).decisions[0];
|
||||||
|
await svc.decide('project-1', { resultProjectionId: 'scanner-finding:rejected', controlId: '5.1', decision: 'rejected', expectedLatestDecisionId: accepted.id });
|
||||||
|
const failing = service(repository, vi.fn(async () => { throw new Error('Synthetic model outage.'); }));
|
||||||
|
await failing.start('project-1', { resultProjectionId: 'scanner-finding:failed' });
|
||||||
|
await failing.close();
|
||||||
|
expect((await svc.get('project-1', 'scanner-finding:failed')).state).toBe('failed');
|
||||||
|
|
||||||
|
const scope = { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'] };
|
||||||
|
const overview = await svc.controlOverview('project-1', scope);
|
||||||
|
// Accepted equals the ISO tile's finding count; the passed result is no deviation and stays out.
|
||||||
|
expect(overview.coverage).toEqual({ deviations: 5, accepted: 2, mappedNoneAccepted: 1, failed: 1, notMapped: 1 });
|
||||||
|
expect(overview.findingCount).toBe(overview.coverage?.accepted);
|
||||||
|
// The Work facet assignments carry no coverage and no footprint, but the failed findings for the `ISO mapping` filter;
|
||||||
|
// the not-mapped passed result is no failure.
|
||||||
|
const assignments = await svc.controlAssignments('project-1', scope);
|
||||||
|
expect(assignments.coverage).toBeUndefined();
|
||||||
|
expect(assignments.bsiFootprint).toBeUndefined();
|
||||||
|
expect(assignments.failedFindings?.map(item => item.resultProjectionId)).toEqual(['scanner-finding:failed']);
|
||||||
|
expect(overview.failedFindings).toBeUndefined();
|
||||||
|
// The BSI footprint lists every Kernel requirement of the active catalog, whatever is derived for this client.
|
||||||
|
const footprint = overview.bsiFootprint!;
|
||||||
|
expect(footprint.practices).toHaveLength(14);
|
||||||
|
expect(footprint.practices.flatMap(practice => practice.requirements)).toHaveLength(901);
|
||||||
|
expect(footprint.practices.flatMap(practice => practice.requirements).filter(requirement => requirement.reachable)).toHaveLength(272);
|
||||||
|
expect(footprint.practices.find(practice => practice.id === 'KONF')?.requirements.find(item => item.id === 'KONF.2.1')?.reachable).toBe(true);
|
||||||
|
// Every requirement carries its published title, including unreachable ones.
|
||||||
|
expect(footprint.practices.flatMap(practice => practice.requirements).every(item => item.title.trim().length > 0)).toBe(true);
|
||||||
|
expect(footprint.practices.flatMap(practice => practice.requirements).find(item => !item.reachable)?.title).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a control accepted by an earlier run after a remap of the same version no longer proposes it (regression)', async () => {
|
||||||
|
// Real engine store: a remap with the same pipeline versions overwrites the rule's only run.
|
||||||
|
let proposed = '5.1';
|
||||||
|
const svc = new RegulatoryMappingService(mockRepository([finding()]), new LocalComplianceStore(), {
|
||||||
|
findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve: vi.fn(async () => [{ score: 0.9, chunk: chunk('ISO/IEC 27002', proposed) }]) }),
|
||||||
|
index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list: vi.fn(async () => [chunk('ISO/IEC 27001', proposed)]) },
|
||||||
|
client: { model: 'work-model', complete: vi.fn(async () => ({ value: { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: proposed,
|
||||||
|
relationship: 'primary', confidence: 'high', rationale: 'Synthetic rationale.', evidenceChunkIds: [`semantic-${proposed}`, `reference-${proposed}`] }] } })) as never },
|
||||||
|
corpusId: 'iso-test', corpusVersion: 'v2', embeddingModel: 'bge-m3', now: () => new Date(stamp)
|
||||||
|
});
|
||||||
|
const id = 'scanner-finding:occurrence-1';
|
||||||
|
await svc.start('project-1', { resultProjectionId: id });
|
||||||
|
await svc.close();
|
||||||
|
const accepted = (await svc.get('project-1', id)).decisions.find(item => item.controlId === '5.1')!;
|
||||||
|
expect(accepted.decision).toBe('accepted');
|
||||||
|
// Remap: the model now proposes only 8.9; 5.1 remains an earlier decision.
|
||||||
|
proposed = '8.9';
|
||||||
|
await svc.start('project-1', { resultProjectionId: id });
|
||||||
|
await svc.close();
|
||||||
|
const remapped = await svc.get('project-1', id);
|
||||||
|
expect(remapped.run?.candidates.map(candidate => candidate.controlId)).toEqual(['8.9']);
|
||||||
|
expect(remapped.earlierControls?.map(control => control.controlId)).toEqual(['5.1']);
|
||||||
|
|
||||||
|
const rejected = await svc.decide('project-1', { resultProjectionId: id, controlId: '5.1', decision: 'rejected', expectedLatestDecisionId: accepted.id });
|
||||||
|
expect(rejected.decisions.find(item => item.controlId === '5.1' && item.decision === 'rejected')).toMatchObject({ previousDecisionId: accepted.id });
|
||||||
|
expect(acceptedControlIds(rejected.decisions)).toEqual(['8.9']);
|
||||||
|
// A control no run ever proposed for this rule stays undecidable.
|
||||||
|
await expect(svc.decide('project-1', { resultProjectionId: id, controlId: '7.4', decision: 'accepted' })).rejects.toThrow('not a grounded candidate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names every Annex A control from the reference documents of the active corpus, cached per corpus version', async () => {
|
||||||
|
const list = vi.fn(async (filters: { retrievalRole?: string }, _limit: number) => filters.retrievalRole === 'reference'
|
||||||
|
? [chunk('ISO/IEC 27001', '5.1'), chunk('ISO/IEC 27001', '8.9'), { ...chunk('ISO/IEC 27001', '8.9'), title: 'Second chunk of 8.9' }] : []);
|
||||||
|
const svc = service(mockRepository([finding()]), vi.fn(), '5.1',
|
||||||
|
{ index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list } });
|
||||||
|
const scope = { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'] };
|
||||||
|
expect((await svc.controlOverview('project-1', scope)).annexATitles).toEqual({ '5.1': 'Synthetic control', '8.9': 'Synthetic control' });
|
||||||
|
// Only titles of the active corpus version, read once for repeated overviews.
|
||||||
|
const referenceReads = () => list.mock.calls.filter(([filters]) => filters.retrievalRole === 'reference');
|
||||||
|
expect(referenceReads()[0]).toEqual([{ corpusId: 'iso-test', corpusVersion: 'v2', sourceStandard: 'ISO/IEC 27001', retrievalRole: 'reference' }, 100]);
|
||||||
|
await svc.controlOverview('project-1', scope);
|
||||||
|
expect(referenceReads()).toHaveLength(1);
|
||||||
|
// The Work facet assignments carry no titles.
|
||||||
|
expect((await svc.controlAssignments('project-1', scope)).annexATitles).toBeUndefined();
|
||||||
|
// A new corpus version is read again; an unavailable index yields no titles and is retried next time.
|
||||||
|
await svc.activateCorpus('iso-test', 'v3');
|
||||||
|
list.mockRejectedValueOnce(new Error('Synthetic index outage.'));
|
||||||
|
expect((await svc.controlOverview('project-1', scope)).annexATitles).toEqual({});
|
||||||
|
expect((await svc.controlOverview('project-1', scope)).annexATitles).toEqual({ '5.1': 'Synthetic control', '8.9': 'Synthetic control' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives BSI Kernel requirements from accepted ISO controls in the finding, overview and Work responses', async () => {
|
||||||
|
const repository = mockRepository([finding(), finding({ resultProjectionId: 'scanner-finding:occurrence-2', sourceResultId: 'occurrence-2' })]);
|
||||||
|
const svc = service(repository, vi.fn(async () => ({ value: {
|
||||||
|
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '8.9', relationship: 'primary', confidence: 'high',
|
||||||
|
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-8.9', 'reference-8.9'] }]
|
||||||
|
} })), '8.9');
|
||||||
|
// Nothing accepted yet: no BSI block, so ISO-only responses stay unchanged.
|
||||||
|
expect((await svc.get('project-1', 'scanner-finding:occurrence-1')).bsi).toBeUndefined();
|
||||||
|
await svc.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
|
||||||
|
await svc.close();
|
||||||
|
// The pinned BSI mapping assigns ISO 8.9 to five Kernel requirements of the practice KONF.
|
||||||
|
const expected = ['KONF.2.1', 'KONF.2.1.1', 'KONF.2.5', 'KONF.10.1', 'KONF.10.5'];
|
||||||
|
const single = await svc.get('project-1', 'scanner-finding:occurrence-1');
|
||||||
|
expect(single.bsi?.requirements.map(item => item.requirementId)).toEqual(expected);
|
||||||
|
expect(single.bsi?.requirements[0]).toMatchObject({ practice: 'KONF', via: [{ isoControlId: '8.9', relationship: 'subset-of' }] });
|
||||||
|
expect(single.bsi?.requirements.every(item => item.statement && !item.statement.includes('{{'))).toBe(true);
|
||||||
|
expect(single.bsi).toMatchObject({ crosswalkVersion: '1.0.1-qa', missingTargets: [] });
|
||||||
|
expect(single.bsi?.license).toContain('CC BY-SA 4.0');
|
||||||
|
const scope = { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'] };
|
||||||
|
for (const response of [await svc.controlOverview('project-1', scope), await svc.controlAssignments('project-1', scope)]) {
|
||||||
|
expect(response.bsi?.requirements.map(item => item.requirementId)).toEqual(expected);
|
||||||
|
}
|
||||||
|
// Rejecting the only ISO control removes every derived requirement.
|
||||||
|
await svc.decide('project-1', { resultProjectionId: 'scanner-finding:occurrence-1', controlId: '8.9', decision: 'rejected', expectedLatestDecisionId: single.decisions[0].id });
|
||||||
|
expect((await svc.get('project-1', 'scanner-finding:occurrence-1')).bsi).toBeUndefined();
|
||||||
|
expect((await svc.controlOverview('project-1', scope)).bsi).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives BSI requirements from the active catalog after Update from BSI, not only from the pinned one', async () => {
|
||||||
|
const active = structuredClone(bundledBsiCatalogs);
|
||||||
|
// Synthetic newer state: the BSI mapping now also assigns ISO 8.9 to KONF.1.1.
|
||||||
|
active.crosswalk.entries = active.crosswalk.entries.map(entry => entry.isoControlId === '8.9' ? { ...entry, targets: ['KONF.1.1'] } : entry);
|
||||||
|
const svc = service(mockRepository([finding()]), vi.fn(async () => ({ value: {
|
||||||
|
schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '8.9', relationship: 'primary', confidence: 'high',
|
||||||
|
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-8.9', 'reference-8.9'] }]
|
||||||
|
} })), '8.9', { bsiCatalogs: async () => active });
|
||||||
|
await svc.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1' });
|
||||||
|
await svc.close();
|
||||||
|
expect((await svc.get('project-1', 'scanner-finding:occurrence-1')).bsi?.requirements.map(item => item.requirementId)).toEqual(['KONF.1.1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports every batch and single finding mapping globally, marked by where it came from, on the Client Info overview only', async () => {
|
||||||
|
const repository = mockRepository([finding(),
|
||||||
|
finding({ assessmentId: 'assessment-3', scanRunId: 'run-3', resultProjectionId: 'scanner-finding:earlier', sourceResultId: 'earlier',
|
||||||
|
sourceRuleId: 'C-Earlier', title: 'Earlier synthetic check' }),
|
||||||
|
finding({ projectId: 'project-2', assessmentId: 'assessment-2', scanRunId: 'run-2',
|
||||||
|
resultProjectionId: 'scanner-finding:other', sourceResultId: 'other', sourceRuleId: 'B-Other', title: 'Other synthetic check' })]);
|
||||||
|
const svc = service(repository, vi.fn(async () => ({ value: { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1',
|
||||||
|
relationship: 'primary', confidence: 'high', rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }] } })));
|
||||||
|
const scope = { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'] };
|
||||||
|
const run = async (action: Promise<unknown>) => { await action; await svc.close(); };
|
||||||
|
await run(svc.startBatch('project-1', { ...scope, filters: {}, idempotencyKey: 'regulatory-batch-dummy-manual' }));
|
||||||
|
await run(svc.startBatch('project-1', { ...scope, filters: { deviation: true }, idempotencyKey: 'auto:run-1' }));
|
||||||
|
await run(svc.startBatch('project-1', { assessmentIds: ['assessment-3'], scanRunIds: ['run-3'], filters: {}, idempotencyKey: 'regulatory-batch-dummy-earlier' }));
|
||||||
|
await run(svc.startBatch('project-2', { assessmentIds: ['assessment-2'], scanRunIds: ['run-2'], filters: {}, idempotencyKey: 'regulatory-batch-dummy-other' }));
|
||||||
|
// Single mappings from the Finding Reader carry the timeslot they were started in.
|
||||||
|
await run(svc.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1', origin: scope }));
|
||||||
|
await run(svc.start('project-1', { resultProjectionId: 'scanner-finding:earlier', origin: { assessmentIds: ['assessment-3'], scanRunIds: ['run-3'] } }));
|
||||||
|
await run(svc.start('project-2', { resultProjectionId: 'scanner-finding:other', origin: { assessmentIds: ['assessment-2'], scanRunIds: ['run-2'] } }));
|
||||||
|
|
||||||
|
const overview = await svc.controlOverview('project-1', scope);
|
||||||
|
expect(overview.mappingHistory?.map(entry => [entry.kind, entry.scope, entry.trigger]).sort()).toEqual([
|
||||||
|
['batch', 'other-client', 'manual'], ['batch', 'other-timeslot', 'manual'], ['batch', 'timeslot', 'automatic'], ['batch', 'timeslot', 'manual'],
|
||||||
|
['single', 'other-client', 'manual'], ['single', 'other-timeslot', 'manual'], ['single', 'timeslot', 'manual']]);
|
||||||
|
// The automatic batch reused the rule the first one mapped.
|
||||||
|
const automatic = overview.mappingHistory!.find(entry => entry.trigger === 'automatic')!;
|
||||||
|
expect(automatic.counts).toEqual({ findings: 1, sentToModel: 0, reused: 1, succeeded: 0, failed: 0 });
|
||||||
|
const single = overview.mappingHistory!.find(entry => entry.kind === 'single' && entry.scope === 'timeslot')!;
|
||||||
|
expect(single).toMatchObject({ status: 'completed', counts: { sentToModel: 1, reused: 0, succeeded: 1, failed: 0 } });
|
||||||
|
expect(single.counts.findings).toBeUndefined();
|
||||||
|
// Content-free: no client, rule, scope id, filter or key leaves the engine with an entry.
|
||||||
|
expect(Object.keys(single).sort()).toEqual(['completedAt', 'counts', 'createdAt', 'id', 'kind', 'scope', 'status', 'trigger']);
|
||||||
|
expect(Object.keys(automatic).sort()).toEqual(['completedAt', 'counts', 'createdAt', 'id', 'kind', 'scope', 'status', 'trigger']);
|
||||||
|
// The stored origin is hashed: runs are global and exported with every client, so no id is kept.
|
||||||
|
const stored = JSON.stringify(await repository.listRegulatoryMappingRuns());
|
||||||
|
for (const id of ['project-1', 'project-2', 'assessment-1', 'assessment-2', 'assessment-3', 'run-1', 'run-2', 'run-3']) {
|
||||||
|
expect(stored).not.toContain(`"${id}"`);
|
||||||
|
}
|
||||||
|
expect((await svc.controlAssignments('project-1', scope)).mappingHistory).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a single mapping without a usable origin as unknown, or as the client only', async () => {
|
||||||
|
const repository = mockRepository([finding()]);
|
||||||
|
const svc = service(repository, vi.fn(async () => ({ value: { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1',
|
||||||
|
relationship: 'primary', confidence: 'high', rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }] } })));
|
||||||
|
const scope = { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'] };
|
||||||
|
// A foreign timeslot is not recorded; the client still is.
|
||||||
|
await svc.start('project-1', { resultProjectionId: 'scanner-finding:occurrence-1', origin: { assessmentIds: ['foreign-assessment'], scanRunIds: [] } });
|
||||||
|
await svc.close();
|
||||||
|
expect((await svc.controlOverview('project-1', scope)).mappingHistory?.map(entry => entry.scope)).toEqual(['other-timeslot']);
|
||||||
|
// A run without origin, as recorded before this change, is unknown.
|
||||||
|
const [stored] = await repository.listRegulatoryMappingRuns();
|
||||||
|
const { origin: _origin, ...withoutOrigin } = stored;
|
||||||
|
await repository.saveRegulatoryMappingRun(withoutOrigin);
|
||||||
|
expect((await svc.controlOverview('project-1', scope)).mappingHistory?.map(entry => entry.scope)).toEqual(['unknown']);
|
||||||
|
});
|
||||||
|
|
||||||
it('counts a GPO setting once and gives Work its setting ID although the Result DB withholds it', async () => {
|
it('counts a GPO setting once and gives Work its setting ID although the Result DB withholds it', async () => {
|
||||||
// Internal comparisons carry the setting ID; the Result DB rows of the same comparisons do not.
|
// Internal comparisons carry the setting ID; the Result DB rows of the same comparisons do not.
|
||||||
const comparison = (id: string, settingId: string, title: string, policyScope: 'computer' | 'user') => finding({
|
const comparison = (id: string, settingId: string, title: string, policyScope: 'computer' | 'user') => finding({
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,15 @@ import { SheetAndSearchControls } from '../../src/features/search-filter/SearchF
|
||||||
import { makeSheet } from '../../src/importers/sheet-factory';
|
import { makeSheet } from '../../src/importers/sheet-factory';
|
||||||
|
|
||||||
vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() }));
|
vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() }));
|
||||||
|
const SHEET_COUNT = 300;
|
||||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
||||||
|
|
||||||
it('mounts only the selected sheet until focus, then offers every sheet and preserves the selection', async () => {
|
it('mounts only the selected sheet until focus, then offers every sheet and preserves the selection', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const template = makeSheet('Synthetic', [['Title'], ['One']], '', 'synthetic.csv');
|
const template = makeSheet('Synthetic', [['Title'], ['One']], '', 'synthetic.csv');
|
||||||
const sheets = Array.from({ length: 2537 }, (_, index) => ({ ...template, id: `sheet-${index}`, name: `Synthetic sheet ${index}` }));
|
// The selector defers its options at every size, so a few hundred sheets prove the same behaviour as
|
||||||
|
// thousands; mounting thousands of jsdom options made this test time out under a parallel full run.
|
||||||
|
const sheets = Array.from({ length: SHEET_COUNT }, (_, index) => ({ ...template, id: `sheet-${index}`, name: `Synthetic sheet ${index}` }));
|
||||||
const state = { dataset: { sheets }, activeSheetId: sheets[4].id, status: '', setActiveSheet: vi.fn() } as unknown as ReturnType<typeof useApp>;
|
const state = { dataset: { sheets }, activeSheetId: sheets[4].id, status: '', setActiveSheet: vi.fn() } as unknown as ReturnType<typeof useApp>;
|
||||||
vi.mocked(useApp).mockReturnValue(state);
|
vi.mocked(useApp).mockReturnValue(state);
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
|
|
@ -24,14 +27,14 @@ it('mounts only the selected sheet until focus, then offers every sheet and pres
|
||||||
expect(select.options).toHaveLength(1);
|
expect(select.options).toHaveLength(1);
|
||||||
expect(select.value).toBe(sheets[4].id);
|
expect(select.value).toBe(sheets[4].id);
|
||||||
await act(async () => select.focus());
|
await act(async () => select.focus());
|
||||||
expect(select.options).toHaveLength(2537);
|
expect(select.options).toHaveLength(SHEET_COUNT);
|
||||||
await act(async () => { select.value = sheets[2500].id; select.dispatchEvent(new Event('change', { bubbles: true })); });
|
await act(async () => { select.value = sheets[250].id; select.dispatchEvent(new Event('change', { bubbles: true })); });
|
||||||
expect(state.setActiveSheet).toHaveBeenCalledWith(sheets[2500].id);
|
expect(state.setActiveSheet).toHaveBeenCalledWith(sheets[250].id);
|
||||||
state.activeSheetId = sheets[2500].id;
|
state.activeSheetId = sheets[250].id;
|
||||||
await act(async () => { select.blur(); root.render(<SheetAndSearchControls/>); });
|
await act(async () => { select.blur(); root.render(<SheetAndSearchControls/>); });
|
||||||
expect(select.options).toHaveLength(1);
|
expect(select.options).toHaveLength(1);
|
||||||
expect(select.value).toBe(sheets[2500].id);
|
expect(select.value).toBe(sheets[250].id);
|
||||||
await act(async () => select.dispatchEvent(new Event('pointerdown', { bubbles: true })));
|
await act(async () => select.dispatchEvent(new Event('pointerdown', { bubbles: true })));
|
||||||
expect(select.options).toHaveLength(2537);
|
expect(select.options).toHaveLength(SHEET_COUNT);
|
||||||
} finally { await act(async () => root.unmount()); container.remove(); }
|
} finally { await act(async () => root.unmount()); container.remove(); }
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ describe('local ASSESSOR HTTP server', () => {
|
||||||
|
|
||||||
const health = await fetch(`${baseUrl}/api/v1/health`);
|
const health = await fetch(`${baseUrl}/api/v1/health`);
|
||||||
expect(await health.json()).toEqual({
|
expect(await health.json()).toEqual({
|
||||||
status: 'ok', application: 'ASSESSOR', productVersion: '2.19.1', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
|
status: 'ok', application: 'ASSESSOR', productVersion: '2.22.0', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
|
||||||
});
|
});
|
||||||
expect(health.headers.get('access-control-allow-origin')).toBeNull();
|
expect(health.headers.get('access-control-allow-origin')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue