Compare commits

...

5 Commits

Author SHA1 Message Date
Binh 0fe5f1322e Add docs/mockups/ to .gitignore 2026-10-09 09:19:23 +02:00
Binh 9480b039dd Compliance tab overview: mapping coverage and ISO/BSI footprints
- Client Info Compliance tab: overview row above the framework tiles with an
  ISO/BSI switch. Mapping coverage (square donut, per mapping state) and the
  Annex A footprint (all 93 controls, titles from the active corpus) or the
  BSI Kernel footprint (top two practices, Show all window with all 901
  requirements, unreachable ones marked, pinned detail line).
- Mapping failed and ISO control without BSI requirement open Work through
  the new Compliance Engine facet ISO mapping; Client and Assessment context
  row in the tab (Core release Presentation for the DashboardPanel drilldown
  and context row).
- Fix: an ISO control accepted by an earlier run can be decided again after a
  remap of the same pipeline version no longer proposes it.
- Overview response adds coverage, bsiFootprint and annexATitles; Work
  assignments add failedFindings. Product version unchanged pending Platform release.
2026-10-08 15:40:01 +02:00
Binh 5e7e787dbb 2.22.0: BSI Grundschutz++ derived from ISO mappings
Compliance Engine stage 1 for BSI Grundschutz++ (Kernel requirements only):
- Pinned snapshots of the BSI Kernel catalog and the official ISO 27001
  Annex A -> Grundschutz++ mapping with source URL, SHA-256 and CC BY-SA
  attribution, generated by server/compliance/update-bsi-assets.mjs.
- Requirements are derived from accepted ISO controls, never stored as
  decisions; missing mapping targets are reported.
- Client Info Compliance tab: BSI Grundschutz++ tile and Results window.
- Finding Reader: collapsed BSI requirements section grouped by ISO control.
- Work: BSI requirement and BSI practice facets.
- Data Hub: BSI catalog tile with Update from BSI and Use bundled catalog;
  new outbound target documented in SECURITY.md.
2026-10-08 13:08:14 +02:00
Binh 8ac7050e29 2.21.0: Client Info Compliance tab with ISO mapping history
The Compliance row moves from the Client Info Overview into its own
Compliance tab (before AI Overview). The framework tile row and its
Results button stay unchanged so later frameworks add a tile.

The tab heading carries a Last mapping button. Its window lists up to 50
mapping batches and single finding mappings of all clients, because
mappings are global per rule. Each entry shows its trigger (Automatic,
Manual, Single finding), where it came from relative to the shown
timeslot (This timeslot, Other timeslot, Other client, Unknown), status
and content-free counts. Mapping runs now record their trigger, and
single mappings their origin as SHA-256 tokens only, since runs are
exported with every client. The tab refreshes while a mapping runs and
shows the last overview at once when returning to it.

Also: history table keeps the 14px table role in every column, and the
flaky sheet-selection-cost test uses 300 instead of 2,537 sheets.

Core changes (released Presentation, Governance, Platform, Core Tests):
DashboardPanel Compliance tab, AGENTS.md decisions, product version
2.20.0 -> 2.21.0, sheet-selection-cost test.
2026-10-08 11:16:23 +02:00
Binh e9ad4e9372 2.20.0: Automatic ISO mapping of new scans and GPO imports
The Compliance Engine maps newly uploaded scans and GPO evaluations to
ISO controls while the new Data Hub switch "Map new scans automatically"
is on (default off). An engine-side watcher reads only through
ComplianceReadPort: active scan runs and, for GPOs, the deviation rows of
each timeslot as filtered Result DB pages. It starts the existing ISO
batch per new scan run or per timeslot with new GPO rows; already mapped
rules are reused, so only new rules reach the model. Existing data forms
the baseline and is not mapped retroactively.

The switch lives in the engine's own settings file, not a Core table.
Switching the Compliance Engine off in the Engines popup also switches
automatic mapping off server-wide.

Core changes (released Platform, Core Tests, Presentation, Governance):
PUT /reference-data/iso-corpus/auto-mapping route, setIsoAutoMapping API
client method, subscribeEnginePreferences export, AGENTS.md decision and
product version 2.19.1 -> 2.20.0.
2026-10-08 09:39:07 +02:00
57 changed files with 3394 additions and 135 deletions

1
.gitignore vendored
View File

@ -15,6 +15,7 @@ Mock-Defender/
artifacts/
evaluation/
backups/
docs/mockups/
.env.postgres
.assessor-data/

File diff suppressed because one or more lines are too long

View File

@ -28,6 +28,31 @@ bleibt ableitbar; PostgreSQL-Findings und Review-Entscheidungen werden durch
den Upload nicht veraendert. Phase-14-Authentifizierung und Rollen bleiben
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
Ergaenzung 2026-09-10: Der lokale Accepted-Risk-Dialog speichert begruendete,

File diff suppressed because one or more lines are too long

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "assessor",
"version": "2.19.1",
"version": "2.22.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "assessor",
"version": "2.19.1",
"version": "2.22.0",
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"jsdom": "^29.1.1",

View File

@ -1,6 +1,6 @@
{
"name": "assessor",
"version": "2.19.1",
"version": "2.22.0",
"private": true,
"type": "module",
"scripts": {

View File

@ -42,6 +42,7 @@ import type { RegulatoryMappingService } from './compliance/regulatory-mapping-s
import type { ComplianceStore } from '../src/compliance/compliance-store';
import { RegulatoryMappingServiceError } from './compliance/regulatory-mapping-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 { MicrosoftSecurityBaselineSelectionContext } from '../src/domain/microsoft-security-baseline';
import { listProjectGpoBackupStatus } from './gpo/gpo-backup-status-service';
@ -179,6 +180,8 @@ export interface AssessorHttpServerOptions {
regulatoryMappingService?: RegulatoryMappingService;
regulatoryCorpusUploadService?: RegulatoryCorpusUploadService;
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;
}
@ -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 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 BsiCatalogError) return { status: 502, code: error.code, message: error.message };
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.'
}, 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 (!options.regulatoryCorpusUploadService) throw new HttpError(503, 'iso_corpus_unavailable', 'ISO corpus management is not configured.');
verifyIsoUploadToken(request, options.isoUploadToken);

View File

@ -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()
};
}

View File

@ -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 };

View File

@ -9,12 +9,15 @@ import { PostgresComplianceStore } from './postgres-compliance-store';
import { createRegulatoryRagRuntime } from './regulatory-rag-runtime';
import { RegulatoryMappingService } from './regulatory-mapping-service';
import { RegulatoryCorpusUploadService } from './regulatory-corpus-upload-service';
import { RegulatoryAutoMapping } from './regulatory-auto-mapping';
import { AiOverviewService, type AiOverviewServiceOptions } from './ai-overview-service';
import { BsiCatalogService } from './bsi-catalog-service';
export interface ComplianceEngineRuntime {
mappingService: RegulatoryMappingService;
corpusUploadService: RegulatoryCorpusUploadService;
aiOverviewService: AiOverviewService;
bsiCatalogService: BsiCatalogService;
store: ComplianceStore;
/** Stops running work; ASSESSOR keeps its own repository open. */
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 store: ComplianceStore = pool ? new PostgresComplianceStore(pool) : new LocalComplianceStore();
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 rag: ReturnType<typeof createRegulatoryRagRuntime> | undefined;
let disabledReason: string | undefined;
@ -51,31 +58,36 @@ export async function createComplianceEngineRuntime(options: ComplianceEngineRun
client: options.aiClient,
corpusId: process.env.ASSESSOR_RAG_ACTIVE_CORPUS_ID?.trim(),
corpusVersion: process.env.ASSESSOR_RAG_ACTIVE_CORPUS_VERSION?.trim(),
bsiCatalogs,
...(!options.aiClient ? { disabledReason: 'Work LiteLLM is not configured.' } : {})
});
} catch (error) {
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();
const autoMapping = new RegulatoryAutoMapping({ settingsPath: join(corpusRoot, 'auto-mapping.json'), read, mapping: mappingService });
const corpusUploadService = new RegulatoryCorpusUploadService({
root: join(options.artifactRoot, 'regulatory-corpora'),
root: corpusRoot,
ingestion: rag?.ingestion,
countIndexed: rag ? (corpusId, corpusVersion) => rag!.index.count({ corpusId, corpusVersion }) : undefined,
pruneIndexed: rag ? (corpusId, corpusVersion) => rag!.index.deleteOtherCorpusVersions(corpusId, corpusVersion) : undefined,
mapping: mappingService,
autoMapping,
embeddingModel: rag?.embeddingModel,
disabledReason
});
await corpusUploadService.initialize();
await autoMapping.start();
const aiOverviewService = new AiOverviewService(read, store, { ...options.aiOverview, client: options.aiClient });
await aiOverviewService.recoverInterruptedRuns();
return {
mappingService,
corpusUploadService,
aiOverviewService,
bsiCatalogService,
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(); }
};
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -182,14 +182,16 @@ export class PostgresComplianceStore implements ComplianceStore {
if (!run || run.ruleKey !== decision.ruleKey || run.status !== 'succeeded') {
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>>(`
SELECT data FROM assessor_regulatory_mapping_decisions
WHERE rule_key = $1 AND control_id = $2
ORDER BY created_at DESC, id DESC LIMIT 1
`, [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)) {
throw new RepositoryConflictError('Regulatory mapping decision changed before this update.');
}

View File

@ -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);
}

View File

@ -7,6 +7,7 @@ import type { IsoPdfCorpusOptions } from './iso-pdf-corpus';
import { prepareIsoCorpusInWorker } from './iso-pdf-worker-client';
import type { RegulatoryIngestionService } from './regulatory-rag-service';
import type { RegulatoryMappingService } from './regulatory-mapping-service';
import type { RegulatoryAutoMapping } from './regulatory-auto-mapping';
const MAXIMUM_PDF_BYTES = 32 * 1024 * 1024;
const MAXIMUM_CORPUS_BYTES = 64 * 1024 * 1024;
@ -21,6 +22,7 @@ export interface RegulatoryCorpusUploadOptions {
root: string;
ingestion?: RegulatoryIngestionService;
mapping: RegulatoryMappingService;
autoMapping?: Pick<RegulatoryAutoMapping, 'isEnabled' | 'setEnabled'>;
embeddingModel?: string;
disabledReason?: string;
countIndexed?: (corpusId: string, corpusVersion: string) => Promise<number>;
@ -77,10 +79,18 @@ export class RegulatoryCorpusUploadService {
configured: Boolean(this.options.ingestion),
...(!this.options.ingestion ? { disabledReason: this.options.disabledReason ?? 'Embedding or Qdrant is not configured.' } : {}),
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> {
const status = this.status();
if (!this.active || !this.options.countIndexed) return status;

View File

@ -29,12 +29,17 @@ import {
type RegulatoryControlOverviewItem,
type RegulatoryControlOverviewResponse,
acceptedControlIds,
regulatoryControlTheme
regulatoryControlTheme,
AUTO_MAPPING_BATCH_KEY_PREFIXES,
MAPPING_HISTORY_LIMIT,
type RegulatoryMappingHistoryEntry
} from '../../src/compliance/regulatory-mapping';
import type { RegulatoryRetrievalCandidate } from '../../src/compliance/regulatory-rag';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { privacySafeFinding } from '../../src/domain/privacy-safe-finding';
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 { buildRegulatoryFindingQuery, isRegulatoryMappableResult } from '../../src/compliance/regulatory-query';
import { canonicalJson } from '../../src/shared/canonical-json';
@ -58,6 +63,8 @@ export interface RegulatoryMappingServiceOptions {
disabledReason?: string;
now?: () => Date;
maximumConcurrentRuns?: number;
/** The active BSI catalog and mapping (`Update from BSI`); the pinned snapshots by default. */
bsiCatalogs?: () => Promise<BsiCatalogs>;
}
export class RegulatoryMappingService {
@ -70,6 +77,7 @@ export class RegulatoryMappingService {
}> = [];
private activeWorkerCount = 0;
private activationPending = false;
private titleCache?: { key: string; titles: Record<string, string> };
constructor(
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 decisions = ruleKey ? await this.store.listRegulatoryMappingDecisions(ruleKey) : [];
const earlierControls = ruleKey ? await this.earlierControls(ruleKey, run, decisions) : [];
const bsi = await this.bsiOverview(acceptedControlIds(decisions));
return {
version: 1,
projectId,
@ -146,7 +155,8 @@ export class RegulatoryMappingService {
...(run && ['queued', 'running'].includes(run.status) ? { pollAfterMs: POLL_AFTER_MS } : {}),
...(run ? { run: withoutEvidenceText(run) } : {}),
decisions,
...(earlierControls.length ? { earlierControls } : {})
...(earlierControls.length ? { earlierControls } : {}),
...(bsi ? { bsi } : {})
};
}
@ -184,7 +194,7 @@ export class RegulatoryMappingService {
const finding = await this.finding(projectId, request.resultProjectionId);
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);
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));
return this.get(projectId, request.resultProjectionId);
}
@ -260,6 +270,36 @@ export class RegulatoryMappingService {
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. */
async controlAssignments(projectId: string, requested: Pick<RegulatoryMappingBatchScope, 'assessmentIds' | 'scanRunIds'>): Promise<RegulatoryControlOverviewResponse> {
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.
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()) {
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>();
for (const candidate of run.candidates) if (!known.has(candidate.controlId)) known.set(candidate.controlId, candidate);
titles.set(run.ruleKey, known);
}
const controls = new Map<string, { item: RegulatoryControlOverviewItem; findings: Map<string, RegulatoryControlFinding> }>();
const covered = new Set<string>();
const unaccepted: Array<[string, Map<string, RegulatoryControlFinding>]> = [];
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 entry = controls.get(controlId) ?? { findings: new Map(), item: {
controlId, annexAControlId: candidate?.annexAControlId ?? `A.${controlId}`, findings: [],
@ -307,14 +356,74 @@ export class RegulatoryMappingService {
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 {
version: 1, projectId, scope: { assessmentIds: scope.assessmentIds, scanRunIds: scope.scanRunIds }, findingCount: covered.size,
controls: [...controls.values()].map(({ item, findings }) => ({ ...item, findings: [...findings.values()] }))
.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
* 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);
if (!preparedQuery) throw new RegulatoryMappingServiceError('insufficient_public_context', 'This finding has insufficient public context for regulatory mapping.', 422);
const timestamp = this.now().toISOString();
@ -408,14 +517,26 @@ export class RegulatoryMappingService {
promptVersion: this.promptVersion(ruleKey),
outputSchemaVersion: REGULATORY_MAPPING_OUTPUT_SCHEMA_VERSION,
model: this.options.client!.model,
trigger,
...(origin ? { origin } : {}),
createdAt: timestamp,
updatedAt: timestamp,
candidates: []
};
}
private async claim(finding: UnifiedResultProjection, forceRegeneration: boolean) {
return this.store.claimRegulatoryMappingRun(this.queuedRun(finding), forceRegeneration);
private async claim(finding: UnifiedResultProjection, forceRegeneration: boolean, trigger: 'single' | 'batch', origin?: RegulatoryMappingRun['origin']) {
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> {
@ -508,7 +629,7 @@ export class RegulatoryMappingService {
counts.distinctRuleCount = representatives.size;
const scheduled: Array<Promise<'succeeded' | 'failed'>> = [];
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.run.status === 'succeeded') counts.reusedSucceededRuleCount += 1;
else counts.reusedActiveRuleCount += 1;
@ -738,6 +859,15 @@ function isGpoRuleKey(ruleKey: string): boolean {
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 {
return createHash('sha256').update(value, 'utf8').digest('hex');
}

View File

@ -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`);

View File

@ -151,6 +151,7 @@ async function main(): Promise<void> {
aiOverviewService: complianceEngine.aiOverviewService,
regulatoryMappingService: complianceEngine.mappingService,
regulatoryCorpusUploadService: complianceEngine.corpusUploadService,
bsiCatalogService: complianceEngine.bsiCatalogService,
complianceExport: complianceEngine.store,
isoUploadToken: process.env.ASSESSOR_ISO_UPLOAD_TOKEN?.trim(),
automaticGpoAnalysis,

View File

@ -17,6 +17,7 @@ import type { ClientInformationQuery, ClientInformationResult } from '../domain/
import type { ImportedDataset } from '../domain/dataset';
import type { MicrosoftSecurityBaselineSelectionContext } from '../domain/microsoft-security-baseline';
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 { ResultDbMaterializationStatus } from '../domain/result-db-materialization';
import {
@ -480,10 +481,26 @@ export class AssessorApiClient {
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> {
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> {
const [first, second] = await Promise.all([iso27001.arrayBuffer(), iso27002.arrayBuffer()]);
return this.request('/reference-data/iso-corpus', {

View File

@ -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-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/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;
}

View File

@ -45,6 +45,11 @@ function subscribe(listener: () => void) {
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 {
return useSyncExternalStore(subscribe, readEnginePreferences, () => defaults);
}

View File

@ -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;
}

View File

@ -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> : {};
}

View File

@ -93,8 +93,10 @@ export class LocalComplianceStore implements ComplianceStore {
if (!run || run.ruleKey !== decision.ruleKey || run.status !== 'succeeded') {
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];
// 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.');
const stored = clone({ ...decision, ...(latest ? { previousDecisionId: latest.id } : {}) });
this.decisions.push(stored);

View File

@ -27,4 +27,6 @@ export interface RegulatoryCorpusStatus {
active: RegulatoryCorpusSummary | null;
indexStatus?: 'ready' | 'incomplete' | 'unavailable';
job: RegulatoryCorpusJob | null;
/** Data Hub switch: map the deviations of newly uploaded scans automatically. */
autoMapping?: { enabled: boolean };
}

View File

@ -1,4 +1,5 @@
import type { AiOverviewTokenUsage } from './ai-overview';
import type { BsiFootprint, BsiOverview } from './bsi-grundschutz';
import type { RegulatoryFindingQuery, RegulatorySectionKind } from './regulatory-rag';
import type { ResultDbFilters } from '../domain/result-db-query';
import type { UnifiedResultProjection } from '../domain/unified-result';
@ -127,6 +128,13 @@ export interface RegulatoryMappingRun {
promptVersion: string;
outputSchemaVersion: 1;
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;
updatedAt: 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. */
/** 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 {
version: 1;
projectId: string;
@ -197,8 +227,37 @@ export interface RegulatoryControlOverviewResponse {
/** Distinct findings that carry at least one accepted control. */
findingCount: number;
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 {
version: 1;
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.
*/
earlierControls?: RegulatoryEarlierControl[];
/** BSI Kernel requirements derived from this rule's accepted controls; absent when none is derived. */
bsi?: BsiOverview;
}
export interface RegulatoryEarlierControl {
@ -224,6 +285,8 @@ export interface RegulatoryEarlierControl {
export interface RegulatoryMappingCreateRequest {
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 {

View File

@ -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>;
}

View File

@ -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 type { BackendStatus } from '../../app/app-state';
import { useEnginePreferences } from '../../app/engine-preferences';
import { DashboardGlyph, type DashboardFact } from '../../features/dashboard/ClientTileParts';
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 { bsiRequirementFindings, type BsiOverview } from '../bsi-grundschutz';
import { ComplianceFrameworkSwitch, ComplianceOverview, type ComplianceFramework } from './ComplianceOverview';
import type { ComplianceMappingState } from './compliance-work-facets';
import './compliance.css';
const assessorApi = new AssessorApiClient();
const THEMES: RegulatoryControlTheme[] = ['Organizational', 'People', 'Physical', 'Technological'];
export const ISO_TILE_TITLE = 'ISO 27001/27002';
export const BSI_TILE_TITLE = 'BSI Grundschutz++';
interface Props {
projectId?: string;
@ -21,6 +25,16 @@ interface Props {
onOpenControl(controlId: string): void;
/** Opens Work filtered to this ISO theme. */
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 };
@ -45,26 +59,48 @@ function themeGroups(controls: RegulatoryControlOverviewItem[]): ThemeGroup[] {
}
/**
* Client Info `Compliance` row: one tile per framework in the environment tile geometry, the complete
* control list in its Results window. ISO/IEC 27001/27002 is the first framework.
* Client Info `Compliance` tab: an overview row (mapping coverage and Annex A footprint), one tile per 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 revision = useRegulatoryDecisionRevision();
const id = useId();
const [state, setState] = useState<OverviewState>({ status: 'idle' });
const scopeKey = JSON.stringify([projectId, assessmentIds, scanRunIds]);
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(() => {
if (!enabled || !projectId) { setState({ status: 'idle' }); return; }
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] })
.then(overview => { if (active) setState({ status: 'ready', overview }); })
.catch(() => { if (active) setState({ status: 'error' }); });
.then(overview => { if (active) { rememberOverview(scopeKey, overview); setState({ status: 'ready', overview }); } })
.catch(() => { if (active && !cached && poll === 0) setState({ status: 'error' }); });
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;
const groups = state.status === 'ready' ? themeGroups(state.overview.controls) : [];
@ -118,8 +154,21 @@ export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds =
</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>
{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. */}
<div className="client-compliance-grid" aria-label="Compliance frameworks">
<section className="client-env-group" data-framework="iso-27001-27002">
@ -129,6 +178,138 @@ export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds =
</header>
{state.status === 'error' ? <p role="alert" className="iso-controls__error">ISO controls could not be loaded.</p> : tileFacts}
</section>
<BsiTile state={state} onOpenRequirement={onOpenBsiRequirement} onOpenPractice={onOpenBsiPractice}/>
</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>;
}

View File

@ -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>;
}

View File

@ -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>;
}

View File

@ -5,9 +5,13 @@ import { useBackendWorkActivity } from './use-backend-work-activity';
import { statusPollApi } from './status-poll-client';
import type { RegulatoryCorpusStatus } from '../regulatory-corpus-management';
import { TriangleIcon } from '../../components/TriangleIcon';
import './compliance.css';
import { linkAutoMappingToEngineSwitch } from './auto-mapping-engine-link';
const api = new AssessorApiClient();
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. */
export function IsoCorpusLibrary() {
@ -20,6 +24,7 @@ function IsoCorpusLibraryPanel() {
const [iso27002, setIso27002] = useState<File | null>(null);
const [token, setToken] = useState('');
const [submitting, setSubmitting] = useState(false);
const [savingAutoMapping, setSavingAutoMapping] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async (poll = false) => {
try { setStatus(await (poll ? statusPollApi : api).getIsoCorpusStatus()); setError(null); }
@ -50,6 +55,13 @@ function IsoCorpusLibraryPanel() {
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';
return <details className="tenant-scan-history baseline-catalog iso-corpus-catalog">
<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 === '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?.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)}>
<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>

View File

@ -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;
}

View File

@ -5,18 +5,26 @@ import { useEnginePreferences } from '../../app/engine-preferences';
import type { FindingRecord } from '../../domain/finding';
import { gpoWorkSettingIdentity } from '../../features/results-browser/work-record-lookup';
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';
/** 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_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 THEMES: RegulatoryControlTheme[] = ['Organizational', 'People', 'Physical', 'Technological'];
export interface ComplianceWorkFacetIndex {
facets: [DynamicFacet, DynamicFacet];
facets: DynamicFacet[];
}
const indexes = new WeakMap<RegulatoryControlOverviewResponse, ComplianceWorkFacetIndex>();
@ -28,20 +36,38 @@ export function complianceWorkFacetIndex(response: RegulatoryControlOverviewResp
const byOccurrence = 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));
for (const control of response.controls) {
for (const finding of control.findings) {
if (finding.sourceKind === 'scanner-finding') add(byOccurrence, finding.sourceResultId, control.controlId);
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}`) ?? []] : [];
const addFinding = (finding: RegulatoryControlFinding, value: string, occurrences: Map<string, Set<string>>, settings: Map<string, Set<string>>) => {
if (finding.sourceKind === 'scanner-finding') add(occurrences, finding.sourceResultId, value);
else if (finding.sourceRuleId) add(settings, `${finding.policyScope ?? ''}\u0000${finding.sourceRuleId}`, value);
};
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]));
// 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 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 = {
facets: [{
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))
.map(theme => ({ value: theme.toLowerCase(), label: theme, count: 0 })),
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);
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[] {
if (!index) return facets;
const sourceScan = facets.findIndex(facet => facet.id === SOURCE_SCAN_FACET_ID);

View File

@ -8,10 +8,25 @@
.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 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-compliance { margin-block: var(--space-6) var(--space-8); color: var(--text-primary); min-width: 0; }
.client-compliance > h2 { margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
/* Client Info `Compliance` tab; its tiles use the environment tile geometry. */
.client-compliance-page { align-content: start; }
.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); }
/* 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; }
@ -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 > h4 { margin-bottom: 4px; }
.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; }

View File

@ -1,7 +1,8 @@
import { useSyncExternalStore } from 'react';
// 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;
const listeners = new Set<() => void>();

View File

@ -3,6 +3,7 @@ import type { BackendStatus } from '../../app/app-state';
import type { FindingRecord } from '../../domain/finding';
import { AssessorApiClient } from '../../api/assessor-api-client';
import { useEnginePreferences } from '../../app/engine-preferences';
import { useOptionalApp } from '../../app/AppProvider';
import { mappingRunKey, subscribeRegulatoryMappingRuns, trackRegulatoryMappingRun } from './mapping-run-tracker';
import { markRegulatoryDecisionSaved } from './decision-revision';
import {
@ -29,6 +30,10 @@ export function useRegulatoryMapping(
) {
// A switched-off Compliance Engine shows no control, opens nothing and sends no request.
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 open = requestedOpen && available;
// Re-enabling the engine starts with the window closed again.
@ -77,7 +82,7 @@ export function useRegulatoryMapping(
if (!projectId || !resultProjectionId || activeScope.current !== requestScope) return;
setLoading(true);
try {
const next = await assessorApi.generateRegulatoryMappings(projectId, { resultProjectionId });
const next = await assessorApi.generateRegulatoryMappings(projectId, { resultProjectionId, origin: origin.current });
trackRegulatoryMappingRun(projectId, resultProjectionId, next);
if (activeScope.current !== requestScope) return;
setStoredResponse({ scope: requestScope, value: next });
@ -138,6 +143,8 @@ export function useRegulatoryMapping(
decide,
acceptedControls,
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
};
}

View File

@ -1,7 +1,7 @@
import { readEnginePreferences, useEnginePreferences } from '../../app/engine-preferences';
import { ClientContextHeatmap } from '../context-layer/ContextHeatmap';
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 { useAssessmentContext } from '../context-layer/use-assessment-context';
import { publishContextReport } from '../../assessment-context/context-report-publication';
@ -1408,18 +1408,22 @@ export function dashboardAnalysis(
export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const app = useApp();
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 pureDefender = dataset?.profileId === DEFENDER_FOR_IOT_PROFILE_ID;
const environment = useEnvironmentSnapshot(app.targetProjectId, app.activeScanRunIds);
const aiOverview = useAiOverview(app.targetProjectId, app.activeAssessmentIds ?? [], app.activeScanRunIds ?? []);
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(() => {
if (pureDefender && clientInfoView === 'overview') setClientInfoView('defender');
else if (clientInfoView === 'defender' && !hasDefender) setClientInfoView('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 sourceFilter = app.facetFilters['provenance:source-scan'];
const analysis = useDashboardAnalysis(activeSheet, dataset, activeProfile, reviews, sourceFilter);
@ -1472,9 +1476,19 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
if (!dataset || !activeSheet) return app.targetProjectId
? <div className="dashboard-page panel-is-loading"><h2>Client Info</h2><PanelLoadingState message={app.status}/></div>
: <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]),
...(hasDefender ? [['defender', 'Microsoft Defender for IoT'] as const] : []),
...(showCompliance ? [['compliance', 'Compliance'] as const] : []),
...(showAiOverview ? [['ai', 'AI Overview'] as const] : [])
];
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>)}
</nav> : undefined;
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)) {
return <DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} reviews={reviews}
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 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 =>
<DashboardSourceListItem key={source.tool.id} source={source} native={nativeMetrics}
onClick={sourceFacet && source.tool.sourceValues.length ? () => { dismissResults(); jump(() => {
@ -1671,25 +1723,6 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
setFacetFilter(CONTEXT_CLUSTER_FACET_ID, { values: [clusterId] });
}, 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)} />}
<div className="client-environment-grid" aria-label="Environment overview">

View File

@ -39,6 +39,7 @@ import { maesterExecutionErrorText, maesterFindingTitle, maesterTagText } from '
import type { BackendStatus } from '../../app/app-state';
import { RegulatoryMappingDialog } from '../../compliance/ui/RegulatoryMappingPanel';
import { FindingIsoControls } from '../../compliance/ui/FindingIsoControls';
import { FindingBsiRequirements } from '../../compliance/ui/FindingBsiRequirements';
import { regulatoryMappingResultId, useRegulatoryMapping } from '../../compliance/ui/use-regulatory-mapping';
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>)}
{/* Compliance Engine: controls accepted for this finding, read like Description. */}
<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}`}>
<summary><TriangleIcon direction="right" />{highlightText(field.label, search)}</summary>
{resolvedToolId === 'maester' && normalizeLabel(field.label) === 'test details'

View File

@ -17,6 +17,7 @@ import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadi
import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog';
import { MicrosoftBaselineLibrary } from './MicrosoftBaselineLibrary';
import { IsoCorpusLibrary } from '../../compliance/ui/IsoCorpusLibrary';
import { BsiCatalogLibrary } from '../../compliance/ui/BsiCatalogLibrary';
import { MitreMatrixLibrary } from './MitreMatrixLibrary';
import { ReferenceRecognitionPanel } from './ReferenceRecognitionPanel';
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">
<MicrosoftBaselineLibrary/>
<IsoCorpusLibrary/>
<BsiCatalogLibrary/>
<MitreMatrixLibrary
status={mitreStatus}
activeVersion={mitreActiveVersion}

View File

@ -118,6 +118,7 @@
.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 > .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-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); }
@ -143,6 +144,7 @@
.datahub-reference-row > .mitre-library { grid-area: 1 / 1; }
.datahub-reference-row > .baseline-catalog { grid-area: 2 / 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; }
.datahub-rack-header { grid-template-columns: 20px 10px minmax(0, 1fr) auto; }
.datahub-rack-actions { justify-content: stretch; }

View File

@ -9,7 +9,7 @@ const components = readFileSync(new URL('../../src/styles/components.css', impor
describe('ASSESSOR version in the brand header', () => {
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).not.toContain("'DB Structure'");

View File

@ -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();
});

View File

@ -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' } });
});
});

View File

@ -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; }');
});
});

View File

@ -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);
});
});

View File

@ -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']);
});
});

View File

@ -8,14 +8,14 @@ import { AssessorApiClient } from '../../src/api/assessor-api-client';
import { requestActivity } from '../../src/api/request-activity';
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
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 { workRecordIdsForResult } from '../../src/features/results-browser/work-record-lookup';
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
// with the most findings; its Results window lists every control by theme and opens Work from a control or
// a theme. Switched off, nothing renders or loads.
// Client Info `Compliance` tab: one ISO tile in the environment tile geometry with totals and all four themes;
// its Results window lists every control by theme and opens Work from a control or a theme. `Last mapping`
// names the newest mapping batch of the timeslot and its trigger. Switched off, nothing renders or loads.
const overview: RegulatoryControlOverviewResponse = {
version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [] }, findingCount: 3,
controls: [
@ -35,9 +35,14 @@ let host: HTMLDivElement;
let root: Root;
const onOpenControl = vi.fn();
const onOpenTheme = vi.fn();
const onOpenBsiRequirement = vi.fn();
const onOpenBsiPractice = vi.fn();
const onOpenMappingState = vi.fn();
const render = () => act(async () => {
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));
});
@ -45,8 +50,12 @@ beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
localStorage.clear();
resetEnginePreferencesForTests();
resetClientComplianceCacheForTests();
onOpenControl.mockReset();
onOpenTheme.mockReset();
onOpenBsiRequirement.mockReset();
onOpenBsiPractice.mockReset();
onOpenMappingState.mockReset();
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
@ -70,10 +79,10 @@ describe('Client Info ISO controls', () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue(overview);
await render();
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.
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.
expect(facts()).toEqual([['Controls', '3'], ['Findings', '3'], ['Organizational', '1 finding'], ['People', '0 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' });
});
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');
expect(source.indexOf('<ClientIsoControls')).toBeGreaterThan(source.indexOf('<ClientContextHeatmap'));
expect(source.indexOf('<ClientIsoControls')).toBeLessThan(source.indexOf('client-environment-grid'));
const drilldown = source.slice(source.indexOf('<ClientIsoControls'), source.indexOf('client-environment-grid'));
expect(drilldown).toContain('setStatusFilter(\x27deviation\x27)');
expect(source).toContain("...(showCompliance ? [['compliance', 'Compliance'] as const] : [])");
expect(source.indexOf("['compliance', 'Compliance']")).toBeLessThan(source.indexOf("['ai', 'AI Overview']"));
const tab = source.slice(source.indexOf("if (clientInfoView === 'compliance' && showCompliance) return <ClientIsoControls"));
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_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');
// 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 });
@ -161,4 +186,347 @@ describe('Client Info ISO controls', () => {
const dataset = { fileName: 'Dummy', sheets: [{ ...sheet, analysis: { ...sheet.analysis, records: [record] } }] } as never;
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();
});
});

View File

@ -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', 'POST', 'Starting ISO mapping batch'],
['/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) => {
expect(requestActivity(path, method)).toEqual({ area: 'Compliance Engine', label });
});

View File

@ -26,10 +26,10 @@ vi.mock('../../src/app/AppProvider', () => {
let host: HTMLDivElement;
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']],
'', 'dummy.csv', { isProjectSheet: true }).analysis.records[0];
return { ...record, findingOccurrenceId: 'dummy-occurrence' };
return { ...record, findingOccurrenceId: occurrence };
}
const mappingResponse: RegulatoryMappingResponse = {
@ -37,9 +37,9 @@ const mappingResponse: RegulatoryMappingResponse = {
generationEnabled: true, state: 'empty', decisions: []
};
async function renderReader() {
async function renderReader(occurrence?: string) {
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()}
projectId="dummy-project" backendStatus="connected"/>);
await new Promise(resolve => setTimeout(resolve, 0));
@ -122,6 +122,48 @@ describe('Compliance Engine switch', () => {
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 () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryMappingBatch').mockResolvedValue({
version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [], filters: {} },

View File

@ -70,3 +70,30 @@ it('clears a transient status polling error once a later poll succeeds', async (
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();
}
});

View File

@ -7,8 +7,13 @@ import { AppProvider, useApp } from '../../src/app/AppProvider';
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
import type { RegulatoryControlOverviewResponse } from '../../src/compliance/regulatory-mapping';
import {
BSI_PRACTICE_FACET_ID,
BSI_REQUIREMENT_FACET_ID,
complianceWorkFacetIndex,
ISO_CONTROL_FACET_ID,
ISO_MAPPING_FACET_ID,
ISO_MAPPING_FAILED,
ISO_MAPPING_NO_BSI,
ISO_THEME_FACET_ID,
resetComplianceWorkFacetsForTests,
useComplianceWorkFacets
@ -58,7 +63,8 @@ describe('Work ISO control and ISO theme facets', () => {
const facets = selectWorkFacets(workSheet(), options());
const ids = facets.map(facet => facet.id);
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)!;
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]]);
@ -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]]);
});
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', () => {
const sheet = workSheet();
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() }));
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 () => { await new Promise(resolve => setTimeout(resolve, 0)); });
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)); });
expect(request).toHaveBeenCalledTimes(2);
});

View File

@ -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 });
});

View File

@ -188,3 +188,33 @@ describe('operator ISO corpus upload from Data Hub', () => {
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 });
}
});
});

View File

@ -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();
}
});

View File

@ -1,10 +1,12 @@
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 { 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 { filterUnifiedResults, type UnifiedResultProjection } from '../../src/domain/unified-result';
import type { ComplianceReadPort, ComplianceStore } from '../../src/compliance/compliance-store';
import { LocalComplianceStore } from '../../src/compliance/local-compliance-store';
type EngineRepository = ComplianceReadPort & ComplianceStore;
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';
return {
schemaVersion: 2, corpusId: 'iso-test', corpusVersion: 'v2', documentId: reference ? '27001-5.1' : '27002-5.1-purpose',
framework: 'ISO/IEC 27002', frameworkVersion: '2022', controlId: '5.1', annexAControlId: 'A.5.1',
schemaVersion: 2, corpusId: 'iso-test', corpusVersion: 'v2', documentId: reference ? `27001-${controlId}` : `27002-${controlId}-purpose`,
framework: 'ISO/IEC 27002', frameworkVersion: '2022', controlId, annexAControlId: `A.${controlId}`,
controlTheme: 'organizational', sourceStandard, sourceEdition: '2022', nationalEdition: 'DIN 2024',
sectionKind: reference ? 'control' : 'purpose', sourcePageStart: 1, sourcePageEnd: 1,
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)
};
}
@ -108,12 +110,14 @@ function mockRepository(findings: UnifiedResultProjection[]) {
} 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, {
findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve: vi.fn(async (_r: RegulatoryRetrievalRequest) => [{ score: 0.9, chunk: chunk('ISO/IEC 27002') }]) }),
index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list: vi.fn(async () => [chunk('ISO/IEC 27001')]) },
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', controlId)]) },
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');
});
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 () => {
// 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({

View File

@ -7,12 +7,15 @@ import { SheetAndSearchControls } from '../../src/features/search-filter/SearchF
import { makeSheet } from '../../src/importers/sheet-factory';
vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() }));
const SHEET_COUNT = 300;
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
it('mounts only the selected sheet until focus, then offers every sheet and preserves the selection', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
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>;
vi.mocked(useApp).mockReturnValue(state);
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.value).toBe(sheets[4].id);
await act(async () => select.focus());
expect(select.options).toHaveLength(2537);
await act(async () => { select.value = sheets[2500].id; select.dispatchEvent(new Event('change', { bubbles: true })); });
expect(state.setActiveSheet).toHaveBeenCalledWith(sheets[2500].id);
state.activeSheetId = sheets[2500].id;
expect(select.options).toHaveLength(SHEET_COUNT);
await act(async () => { select.value = sheets[250].id; select.dispatchEvent(new Event('change', { bubbles: true })); });
expect(state.setActiveSheet).toHaveBeenCalledWith(sheets[250].id);
state.activeSheetId = sheets[250].id;
await act(async () => { select.blur(); root.render(<SheetAndSearchControls/>); });
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 })));
expect(select.options).toHaveLength(2537);
expect(select.options).toHaveLength(SHEET_COUNT);
} finally { await act(async () => root.unmount()); container.remove(); }
});

View File

@ -59,7 +59,7 @@ describe('local ASSESSOR HTTP server', () => {
const health = await fetch(`${baseUrl}/api/v1/health`);
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();
});