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.
This commit is contained in:
Binh 2026-10-08 15:40:01 +02:00
parent ca66fbdef9
commit 75a600fa82
14 changed files with 922 additions and 36 deletions

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') { if (!run || run.ruleKey !== decision.ruleKey || run.status !== 'succeeded') {
throw new RepositoryNotFoundError('Completed regulatory mapping does not exist for this rule.'); throw new RepositoryNotFoundError('Completed regulatory mapping does not exist for this rule.');
} }
if (!run.candidates.some(candidate => candidate.controlId === decision.controlId)) {
throw new RepositoryValidationError('Decision control was not a grounded candidate.');
}
const latest = dataRow(await client.query<DataRow<RegulatoryMappingDecision>>(` const latest = dataRow(await client.query<DataRow<RegulatoryMappingDecision>>(`
SELECT data FROM assessor_regulatory_mapping_decisions SELECT data FROM assessor_regulatory_mapping_decisions
WHERE rule_key = $1 AND control_id = $2 WHERE rule_key = $1 AND control_id = $2
ORDER BY created_at DESC, id DESC LIMIT 1 ORDER BY created_at DESC, id DESC LIMIT 1
`, [decision.ruleKey, decision.controlId])); `, [decision.ruleKey, decision.controlId]));
// A control with an earlier decision stays decidable after a remap no longer retrieves it; the chain of
// previous decisions leads back to the run that grounded it.
if (!latest && !run.candidates.some(candidate => candidate.controlId === decision.controlId)) {
throw new RepositoryValidationError('Decision control was not a grounded candidate.');
}
if ((expectedLatestDecisionId ?? undefined) !== (latest?.id ?? undefined)) { if ((expectedLatestDecisionId ?? undefined) !== (latest?.id ?? undefined)) {
throw new RepositoryConflictError('Regulatory mapping decision changed before this update.'); throw new RepositoryConflictError('Regulatory mapping decision changed before this update.');
} }

View File

@ -39,7 +39,7 @@ import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { privacySafeFinding } from '../../src/domain/privacy-safe-finding'; import { privacySafeFinding } from '../../src/domain/privacy-safe-finding';
import type { RegulatoryFindingRetrievalService } from './regulatory-finding-retrieval'; import type { RegulatoryFindingRetrievalService } from './regulatory-finding-retrieval';
import { bundledBsiCatalogs, type BsiCatalogs } from './bsi-snapshots'; import { bundledBsiCatalogs, type BsiCatalogs } from './bsi-snapshots';
import { bsiOverview, type BsiOverview } from '../../src/compliance/bsi-grundschutz'; import { bsiFootprint, bsiOverview, type BsiOverview } from '../../src/compliance/bsi-grundschutz';
import type { RegulatoryChunkIndex } from './qdrant-regulatory-index'; import type { RegulatoryChunkIndex } from './qdrant-regulatory-index';
import { buildRegulatoryFindingQuery, isRegulatoryMappableResult } from '../../src/compliance/regulatory-query'; import { buildRegulatoryFindingQuery, isRegulatoryMappableResult } from '../../src/compliance/regulatory-query';
import { canonicalJson } from '../../src/shared/canonical-json'; import { canonicalJson } from '../../src/shared/canonical-json';
@ -77,6 +77,7 @@ export class RegulatoryMappingService {
}> = []; }> = [];
private activeWorkerCount = 0; private activeWorkerCount = 0;
private activationPending = false; private activationPending = false;
private titleCache?: { key: string; titles: Record<string, string> };
constructor( constructor(
private readonly read: ComplianceReadPort, private readonly read: ComplianceReadPort,
@ -271,10 +272,34 @@ export class RegulatoryMappingService {
/** BSI Kernel requirements derived from accepted ISO controls through the active BSI mapping (Phase 13F, stage 1). */ /** 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> { private async bsiOverview(acceptedIsoControlIds: readonly string[]): Promise<BsiOverview | undefined> {
const { catalog, crosswalk } = this.options.bsiCatalogs ? await this.options.bsiCatalogs() : bundledBsiCatalogs; const { catalog, crosswalk } = await this.bsiCatalogs();
return bsiOverview(acceptedIsoControlIds, crosswalk, catalog); return bsiOverview(acceptedIsoControlIds, crosswalk, catalog);
} }
/**
* Annex A control titles of the active licensed corpus (one ISO/IEC 27001 reference document per control), for the
* Client Info footprint; only titles, never clause text. Cached per corpus version; an unavailable index yields none.
*/
private async annexATitles(): Promise<Record<string, string>> {
const { index, corpusId, corpusVersion } = this.options;
if (!index || !corpusId || !corpusVersion) return {};
const key = `${corpusId}\u0000${corpusVersion}`;
if (this.titleCache?.key === key) return this.titleCache.titles;
try {
const chunks = await index.list({ corpusId, corpusVersion, sourceStandard: 'ISO/IEC 27001', retrievalRole: 'reference' }, 100);
const titles: Record<string, string> = {};
for (const chunk of chunks) if (chunk.controlId && chunk.title?.trim() && !titles[chunk.controlId]) titles[chunk.controlId] = chunk.title.trim();
if (Object.keys(titles).length) this.titleCache = { key, titles };
return titles;
} catch {
return {};
}
}
private async bsiCatalogs(): Promise<BsiCatalogs> {
return this.options.bsiCatalogs ? this.options.bsiCatalogs() : bundledBsiCatalogs;
}
/** Accepted controls of every mappable finding in a client timeslot, for the Work `ISO control` and `ISO theme` filters. */ /** Accepted controls of every mappable finding in a client timeslot, for the Work `ISO control` and `ISO theme` filters. */
async controlAssignments(projectId: string, requested: Pick<RegulatoryMappingBatchScope, 'assessmentIds' | 'scanRunIds'>): Promise<RegulatoryControlOverviewResponse> { async controlAssignments(projectId: string, requested: Pick<RegulatoryMappingBatchScope, 'assessmentIds' | 'scanRunIds'>): Promise<RegulatoryControlOverviewResponse> {
return this.controlOverview(projectId, requested, { deviationsOnly: false }); return this.controlOverview(projectId, requested, { deviationsOnly: false });
@ -302,16 +327,25 @@ export class RegulatoryMappingService {
} }
// Control titles come from the newest succeeded run of each rule that retrieved the control. // Control titles come from the newest succeeded run of each rule that retrieved the control.
const titles = new Map<string, Map<string, RegulatoryMappingCandidate>>(); const titles = new Map<string, Map<string, RegulatoryMappingCandidate>>();
const mapped = new Set<string>();
const latestRun = new Map<string, RegulatoryMappingRun>();
for (const run of await this.store.listRegulatoryMappingRuns()) { for (const run of await this.store.listRegulatoryMappingRuns()) {
if (run.status !== 'succeeded' || !byRule.has(run.ruleKey)) continue; if (!byRule.has(run.ruleKey)) continue;
const latest = latestRun.get(run.ruleKey);
if (!latest || run.createdAt > latest.createdAt || run.createdAt === latest.createdAt && run.id > latest.id) latestRun.set(run.ruleKey, run);
if (run.status !== 'succeeded') continue;
mapped.add(run.ruleKey);
const known = titles.get(run.ruleKey) ?? new Map<string, RegulatoryMappingCandidate>(); const known = titles.get(run.ruleKey) ?? new Map<string, RegulatoryMappingCandidate>();
for (const candidate of run.candidates) if (!known.has(candidate.controlId)) known.set(candidate.controlId, candidate); for (const candidate of run.candidates) if (!known.has(candidate.controlId)) known.set(candidate.controlId, candidate);
titles.set(run.ruleKey, known); titles.set(run.ruleKey, known);
} }
const controls = new Map<string, { item: RegulatoryControlOverviewItem; findings: Map<string, RegulatoryControlFinding> }>(); const controls = new Map<string, { item: RegulatoryControlOverviewItem; findings: Map<string, RegulatoryControlFinding> }>();
const covered = new Set<string>(); const covered = new Set<string>();
const unaccepted: Array<[string, Map<string, RegulatoryControlFinding>]> = [];
for (const [ruleKey, findings] of byRule) { for (const [ruleKey, findings] of byRule) {
for (const controlId of acceptedControlIds(await this.store.listRegulatoryMappingDecisions(ruleKey))) { const accepted = acceptedControlIds(await this.store.listRegulatoryMappingDecisions(ruleKey));
if (!accepted.length) unaccepted.push([ruleKey, findings]);
for (const controlId of accepted) {
const candidate = titles.get(ruleKey)?.get(controlId); const candidate = titles.get(ruleKey)?.get(controlId);
const entry = controls.get(controlId) ?? { findings: new Map(), item: { const entry = controls.get(controlId) ?? { findings: new Map(), item: {
controlId, annexAControlId: candidate?.annexAControlId ?? `A.${controlId}`, findings: [], controlId, annexAControlId: candidate?.annexAControlId ?? `A.${controlId}`, findings: [],
@ -323,12 +357,30 @@ export class RegulatoryMappingService {
} }
} }
const bsi = await this.bsiOverview([...controls.keys()]); const bsi = await this.bsiOverview([...controls.keys()]);
// A finding counts once, in the strongest state of its rule: accepted, mapped, failed, else not mapped.
const mappedNoneAccepted = new Set<string>();
const failed = new Set<string>();
const notMapped = new Set<string>();
const failedFindings: RegulatoryControlFinding[] = [];
for (const [ruleKey, findings] of unaccepted) {
const state = mapped.has(ruleKey) ? mappedNoneAccepted : latestRun.get(ruleKey)?.status === 'failed' ? failed : notMapped;
for (const [identity, finding] of findings) {
if (covered.has(identity)) continue;
state.add(identity);
if (state === failed) failedFindings.push(finding);
}
}
const coverage = { deviations: covered.size + mappedNoneAccepted.size + failed.size + notMapped.size, accepted: covered.size,
mappedNoneAccepted: mappedNoneAccepted.size, failed: failed.size, notMapped: notMapped.size };
return { return {
version: 1, projectId, scope: { assessmentIds: scope.assessmentIds, scanRunIds: scope.scanRunIds }, findingCount: covered.size, version: 1, projectId, scope: { assessmentIds: scope.assessmentIds, scanRunIds: scope.scanRunIds }, findingCount: covered.size,
controls: [...controls.values()].map(({ item, findings }) => ({ ...item, findings: [...findings.values()] })) controls: [...controls.values()].map(({ item, findings }) => ({ ...item, findings: [...findings.values()] }))
.sort((left, right) => right.findings.length - left.findings.length .sort((left, right) => right.findings.length - left.findings.length
|| left.controlId.localeCompare(right.controlId, 'en', { numeric: true })), || left.controlId.localeCompare(right.controlId, 'en', { numeric: true })),
...(deviationsOnly ? { mappingHistory: await this.mappingHistory(projectId, scope) } : {}), ...(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 } : {}) ...(bsi ? { bsi } : {})
}; };
} }

View File

@ -243,6 +243,25 @@ export function bsiOverview(acceptedIsoControlIds: readonly string[], crosswalk:
}; };
} }
/** 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 * 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. * reached through, once per Result DB row, like the ISO theme counts.

View File

@ -93,8 +93,10 @@ export class LocalComplianceStore implements ComplianceStore {
if (!run || run.ruleKey !== decision.ruleKey || run.status !== 'succeeded') { if (!run || run.ruleKey !== decision.ruleKey || run.status !== 'succeeded') {
throw new RepositoryNotFoundError('Completed regulatory mapping does not exist for this rule.'); throw new RepositoryNotFoundError('Completed regulatory mapping does not exist for this rule.');
} }
if (!run.candidates.some(candidate => candidate.controlId === decision.controlId)) throw new RepositoryValidationError('Decision control was not a grounded candidate.');
const latest = this.decisions.filter(item => item.ruleKey === decision.ruleKey && item.controlId === decision.controlId).sort(newestFirst)[0]; const latest = this.decisions.filter(item => item.ruleKey === decision.ruleKey && item.controlId === decision.controlId).sort(newestFirst)[0];
// A control with an earlier decision stays decidable after a remap no longer retrieves it; the chain of
// previous decisions leads back to the run that grounded it.
if (!latest && !run.candidates.some(candidate => candidate.controlId === decision.controlId)) throw new RepositoryValidationError('Decision control was not a grounded candidate.');
if ((expectedLatestDecisionId ?? undefined) !== (latest?.id ?? undefined)) throw new RepositoryConflictError('Regulatory mapping decision changed before this update.'); if ((expectedLatestDecisionId ?? undefined) !== (latest?.id ?? undefined)) throw new RepositoryConflictError('Regulatory mapping decision changed before this update.');
const stored = clone({ ...decision, ...(latest ? { previousDecisionId: latest.id } : {}) }); const stored = clone({ ...decision, ...(latest ? { previousDecisionId: latest.id } : {}) });
this.decisions.push(stored); this.decisions.push(stored);

View File

@ -1,5 +1,5 @@
import type { AiOverviewTokenUsage } from './ai-overview'; import type { AiOverviewTokenUsage } from './ai-overview';
import type { BsiOverview } from './bsi-grundschutz'; import type { BsiFootprint, BsiOverview } from './bsi-grundschutz';
import type { RegulatoryFindingQuery, RegulatorySectionKind } from './regulatory-rag'; import type { RegulatoryFindingQuery, RegulatorySectionKind } from './regulatory-rag';
import type { ResultDbFilters } from '../domain/result-db-query'; import type { ResultDbFilters } from '../domain/result-db-query';
import type { UnifiedResultProjection } from '../domain/unified-result'; import type { UnifiedResultProjection } from '../domain/unified-result';
@ -231,8 +231,33 @@ export interface RegulatoryControlOverviewResponse {
mappingHistory?: RegulatoryMappingHistoryEntry[]; mappingHistory?: RegulatoryMappingHistoryEntry[];
/** BSI Kernel requirements derived from the accepted controls above; absent when none is derived. */ /** BSI Kernel requirements derived from the accepted controls above; absent when none is derived. */
bsi?: BsiOverview; bsi?: BsiOverview;
/** How far the mappable deviations of the timeslot are mapped; only on the Client Info overview. */
coverage?: RegulatoryMappingCoverage;
/** Mappable findings whose rule has no accepted control and whose latest mapping failed; only on the Work assignments. */
failedFindings?: RegulatoryControlFinding[];
/** Annex A control title by control id from the active licensed corpus, for the footprint; only on the Client Info overview. */
annexATitles?: Record<string, string>;
/** Every BSI Kernel requirement for the BSI footprint; only on the Client Info overview. */
bsiFootprint?: BsiFootprint;
} }
/**
* Content-free counts of the mappable deviations of one timeslot, each finding in exactly one state by its rule:
* an accepted control, else a succeeded mapping without one, else a failed latest mapping, else not mapped yet
* (including queued and running). `accepted` equals the overview's `findingCount`.
*/
export interface RegulatoryMappingCoverage {
deviations: number;
accepted: number;
mappedNoneAccepted: number;
failed: number;
notMapped: number;
}
/** The fixed number of controls per ISO/IEC 27002:2022 theme (clauses 5 to 8, 93 controls). */
export const ANNEX_A_CONTROL_COUNTS: Readonly<Record<RegulatoryControlTheme, number>> = { Organizational: 37, People: 8, Physical: 14, Technological: 34 };
export const ANNEX_A_THEME_CLAUSES: Readonly<Record<RegulatoryControlTheme, number>> = { Organizational: 5, People: 6, Physical: 7, Technological: 8 };
export interface RegulatoryMappingResponse { export interface RegulatoryMappingResponse {
version: 1; version: 1;
projectId: string; projectId: string;

View File

@ -7,6 +7,8 @@ import { EnvironmentResultsButton } from '../../features/dashboard/EnvironmentRe
import type { RegulatoryControlOverviewItem, RegulatoryControlOverviewResponse, RegulatoryControlTheme, RegulatoryMappingHistoryEntry } from '../regulatory-mapping'; import type { RegulatoryControlOverviewItem, RegulatoryControlOverviewResponse, RegulatoryControlTheme, RegulatoryMappingHistoryEntry } from '../regulatory-mapping';
import { useRegulatoryDecisionRevision } from './decision-revision'; import { useRegulatoryDecisionRevision } from './decision-revision';
import { bsiRequirementFindings, type BsiOverview } from '../bsi-grundschutz'; import { bsiRequirementFindings, type BsiOverview } from '../bsi-grundschutz';
import { ComplianceFrameworkSwitch, ComplianceOverview, type ComplianceFramework } from './ComplianceOverview';
import type { ComplianceMappingState } from './compliance-work-facets';
import './compliance.css'; import './compliance.css';
const assessorApi = new AssessorApiClient(); const assessorApi = new AssessorApiClient();
@ -27,6 +29,10 @@ interface Props {
onOpenBsiRequirement(requirementId: string): void; onOpenBsiRequirement(requirementId: string): void;
/** Opens Work filtered to this BSI practice. */ /** Opens Work filtered to this BSI practice. */
onOpenBsiPractice(practiceId: string): void; 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. */ /** The Client Info tab bar, shown above this tab like on the AI Overview. */
navigation?: ReactNode; navigation?: ReactNode;
} }
@ -53,10 +59,11 @@ function themeGroups(controls: RegulatoryControlOverviewItem[]): ThemeGroup[] {
} }
/** /**
* Client Info `Compliance` tab: one tile per framework in the environment tile geometry, the complete * Client Info `Compliance` tab: an overview row (mapping coverage and Annex A footprint), one tile per framework
* control list in its Results window, and when the timeslot was last mapped. ISO/IEC 27001/27002 is the first framework. * in the environment tile geometry, the complete control list in its Results window, and when the timeslot was
* last mapped. ISO/IEC 27001/27002 is the first framework.
*/ */
export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = [], backendStatus, onOpenControl, onOpenTheme, onOpenBsiRequirement, onOpenBsiPractice, navigation }: Props) { export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = [], backendStatus, onOpenControl, onOpenTheme, onOpenBsiRequirement, onOpenBsiPractice, onOpenMappingState, navigation, context }: Props) {
const available = useEnginePreferences().compliance; const available = useEnginePreferences().compliance;
const revision = useRegulatoryDecisionRevision(); const revision = useRegulatoryDecisionRevision();
const id = useId(); const id = useId();
@ -69,6 +76,8 @@ export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds =
}); });
const [poll, setPoll] = useState(0); const [poll, setPoll] = useState(0);
// Which framework the overview row shows; the framework tiles below are unaffected.
const [framework, setFramework] = useState<ComplianceFramework>('iso');
useEffect(() => { useEffect(() => {
if (!enabled || !projectId) { setState({ status: 'idle' }); return; } if (!enabled || !projectId) { setState({ status: 'idle' }); return; }
@ -147,11 +156,19 @@ export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds =
return <div className="dashboard-page client-command-center client-compliance-page"> return <div className="dashboard-page client-command-center client-compliance-page">
{navigation} {navigation}
{context}
<section className="client-compliance" aria-labelledby={`${id}-title`} aria-busy={state.status === 'loading'}> <section className="client-compliance" aria-labelledby={`${id}-title`} aria-busy={state.status === 'loading'}>
<header className="client-compliance-header"> <header className="client-compliance-header">
<div className="client-compliance-heading">
<h2 id={`${id}-title`}>Compliance</h2> <h2 id={`${id}-title`}>Compliance</h2>
{state.status !== 'error' && <ComplianceFrameworkSwitch value={framework} onChange={setFramework}/>}
</div>
{state.status === 'ready' && <LastMapping history={history}/>} {state.status === 'ready' && <LastMapping history={history}/>}
</header> </header>
{state.status !== 'error' && <ComplianceOverview framework={framework} controls={ready ? state.overview.controls : undefined}
coverage={ready ? state.overview.coverage : undefined} bsi={ready ? state.overview.bsi : undefined}
bsiFootprint={ready ? state.overview.bsiFootprint : undefined} annexATitles={ready ? state.overview.annexATitles : undefined} onOpenControl={onOpenControl} onOpenTheme={onOpenTheme}
onOpenBsiRequirement={onOpenBsiRequirement} onOpenBsiPractice={onOpenBsiPractice} onOpenMappingState={onOpenMappingState}/>}
{/* Same tile geometry as the environment row; later frameworks add their tiles here. */} {/* Same tile geometry as the environment row; later frameworks add their tiles here. */}
<div className="client-compliance-grid" aria-label="Compliance frameworks"> <div className="client-compliance-grid" aria-label="Compliance frameworks">
<section className="client-env-group" data-framework="iso-27001-27002"> <section className="client-env-group" data-framework="iso-27001-27002">

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

@ -5,7 +5,7 @@ import { useEnginePreferences } from '../../app/engine-preferences';
import type { FindingRecord } from '../../domain/finding'; import type { FindingRecord } from '../../domain/finding';
import { gpoWorkSettingIdentity } from '../../features/results-browser/work-record-lookup'; import { gpoWorkSettingIdentity } from '../../features/results-browser/work-record-lookup';
import type { DynamicFacet } from '../../features/search-filter/dynamic-facets'; import type { DynamicFacet } from '../../features/search-filter/dynamic-facets';
import type { RegulatoryControlOverviewResponse, RegulatoryControlTheme } from '../regulatory-mapping'; import type { RegulatoryControlFinding, RegulatoryControlOverviewResponse, RegulatoryControlTheme } from '../regulatory-mapping';
import { useRegulatoryDecisionRevision } from './decision-revision'; import { useRegulatoryDecisionRevision } from './decision-revision';
/** Work facets of the Compliance Engine; present only while the engine is on and its assignments are loaded. */ /** Work facets of the Compliance Engine; present only while the engine is on and its assignments are loaded. */
@ -13,7 +13,13 @@ export const ISO_CONTROL_FACET_ID = 'compliance:iso-control';
export const ISO_THEME_FACET_ID = 'compliance:iso-theme'; export const ISO_THEME_FACET_ID = 'compliance:iso-theme';
export const BSI_REQUIREMENT_FACET_ID = 'compliance:bsi-requirement'; export const BSI_REQUIREMENT_FACET_ID = 'compliance:bsi-requirement';
export const BSI_PRACTICE_FACET_ID = 'compliance:bsi-practice'; export const BSI_PRACTICE_FACET_ID = 'compliance:bsi-practice';
export const COMPLIANCE_FACET_IDS = [ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID, BSI_REQUIREMENT_FACET_ID, BSI_PRACTICE_FACET_ID] as const; export const ISO_MAPPING_FACET_ID = 'compliance:iso-mapping';
/** `ISO mapping` facet values: the rule's latest mapping failed and nothing is accepted. */
export const ISO_MAPPING_FAILED = 'failed';
/** `ISO mapping` facet values: accepted ISO controls, but none reaches a BSI requirement through the BSI mapping. */
export const ISO_MAPPING_NO_BSI = 'no-bsi';
export type ComplianceMappingState = typeof ISO_MAPPING_FAILED | typeof ISO_MAPPING_NO_BSI;
export const COMPLIANCE_FACET_IDS = [ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID, ISO_MAPPING_FACET_ID, BSI_REQUIREMENT_FACET_ID, BSI_PRACTICE_FACET_ID] as const;
const SOURCE_SCAN_FACET_ID = 'provenance:source-scan'; const SOURCE_SCAN_FACET_ID = 'provenance:source-scan';
const THEMES: RegulatoryControlTheme[] = ['Organizational', 'People', 'Physical', 'Technological']; const THEMES: RegulatoryControlTheme[] = ['Organizational', 'People', 'Physical', 'Technological'];
@ -30,18 +36,21 @@ export function complianceWorkFacetIndex(response: RegulatoryControlOverviewResp
const byOccurrence = new Map<string, Set<string>>(); const byOccurrence = new Map<string, Set<string>>();
const bySetting = new Map<string, Set<string>>(); const bySetting = new Map<string, Set<string>>();
const add = (map: Map<string, Set<string>>, key: string, controlId: string) => map.set(key, (map.get(key) ?? new Set()).add(controlId)); const add = (map: Map<string, Set<string>>, key: string, controlId: string) => map.set(key, (map.get(key) ?? new Set()).add(controlId));
for (const control of response.controls) { const addFinding = (finding: RegulatoryControlFinding, value: string, occurrences: Map<string, Set<string>>, settings: Map<string, Set<string>>) => {
for (const finding of control.findings) { if (finding.sourceKind === 'scanner-finding') add(occurrences, finding.sourceResultId, value);
if (finding.sourceKind === 'scanner-finding') add(byOccurrence, finding.sourceResultId, control.controlId); else if (finding.sourceRuleId) add(settings, `${finding.policyScope ?? ''}\u0000${finding.sourceRuleId}`, value);
else if (finding.sourceRuleId) add(bySetting, `${finding.policyScope ?? ''}\u0000${finding.sourceRuleId}`, control.controlId);
}
}
// Scanner findings match by occurrence; GPO settings by scope and technical setting identity, like Result DB drill-downs.
const controlsFor = (record: FindingRecord): string[] => {
if (record.findingOccurrenceId) return [...byOccurrence.get(record.findingOccurrenceId) ?? []];
const identity = gpoWorkSettingIdentity(record.id);
return identity ? [...bySetting.get(`${identity.scope ?? ''}\u0000${identity.settingId}`) ?? []] : [];
}; };
for (const control of response.controls) for (const finding of control.findings) addFinding(finding, control.controlId, byOccurrence, bySetting);
// Scanner findings match by occurrence; GPO settings by scope and technical setting identity, like Result DB drill-downs.
const valuesOf = (occurrences: Map<string, Set<string>>, settings: Map<string, Set<string>>) => (record: FindingRecord): string[] => {
if (record.findingOccurrenceId) return [...occurrences.get(record.findingOccurrenceId) ?? []];
const identity = gpoWorkSettingIdentity(record.id);
return identity ? [...settings.get(`${identity.scope ?? ''}\u0000${identity.settingId}`) ?? []] : [];
};
const controlsFor = valuesOf(byOccurrence, bySetting);
const failedOccurrences = new Map<string, Set<string>>();
const failedSettings = new Map<string, Set<string>>();
for (const finding of response.failedFindings ?? []) addFinding(finding, ISO_MAPPING_FAILED, failedOccurrences, failedSettings);
const themeOf = new Map(response.controls.map(control => [control.controlId, control.theme])); const themeOf = new Map(response.controls.map(control => [control.controlId, control.theme]));
// BSI requirements derived from the accepted ISO controls through the BSI mapping (Phase 13F, stage 1). // BSI requirements derived from the accepted ISO controls through the BSI mapping (Phase 13F, stage 1).
const bsi = response.bsi; const bsi = response.bsi;
@ -52,6 +61,13 @@ export function complianceWorkFacetIndex(response: RegulatoryControlOverviewResp
const practiceOf = new Map((bsi?.requirements ?? []).map(requirement => [requirement.requirementId, requirement.practice])); 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 requirementsFor = (record: FindingRecord): string[] => [...new Set(controlsFor(record).flatMap(controlId => requirementsByControl.get(controlId) ?? []))];
const controls = [...response.controls].sort((left, right) => left.controlId.localeCompare(right.controlId, 'en', { numeric: true })); const controls = [...response.controls].sort((left, right) => left.controlId.localeCompare(right.controlId, 'en', { numeric: true }));
const failedFor = valuesOf(failedOccurrences, failedSettings);
// A finding has no BSI requirement when none of its accepted controls reaches one; offered only with hits.
const hasNoBsi = [...byOccurrence.values(), ...bySetting.values()]
.some(controlIds => [...controlIds].every(controlId => !requirementsByControl.has(controlId)));
const mappingValues = [
...(response.failedFindings?.length ? [{ value: ISO_MAPPING_FAILED, label: 'Mapping failed', count: 0 }] : []),
...(hasNoBsi ? [{ value: ISO_MAPPING_NO_BSI, label: 'ISO control, no BSI requirement', count: 0 }] : [])];
const index: ComplianceWorkFacetIndex = { const index: ComplianceWorkFacetIndex = {
facets: [{ facets: [{
id: ISO_CONTROL_FACET_ID, id: ISO_CONTROL_FACET_ID,
@ -72,6 +88,16 @@ export function complianceWorkFacetIndex(response: RegulatoryControlOverviewResp
.map(theme => ({ value: theme.toLowerCase(), label: theme, count: 0 })), .map(theme => ({ value: theme.toLowerCase(), label: theme, count: 0 })),
valuesFor: record => [...new Set(controlsFor(record).flatMap(controlId => themeOf.get(controlId)?.toLowerCase() ?? []))] valuesFor: record => [...new Set(controlsFor(record).flatMap(controlId => themeOf.get(controlId)?.toLowerCase() ?? []))]
}, },
// Present only while one of its values has hits, so the Client Info coverage rows open exactly these in Work.
...(mappingValues.length ? [{
id: ISO_MAPPING_FACET_ID,
label: 'ISO mapping',
source: 'derived',
kind: 'category',
description: 'Compliance Engine: findings and GPO settings whose latest ISO mapping failed (map them again from the Finding Reader), or whose accepted ISO controls reach no BSI requirement. It does not change results.',
values: mappingValues,
valuesFor: record => [...failedFor(record), ...(controlsFor(record).length && !requirementsFor(record).length ? [ISO_MAPPING_NO_BSI] : [])]
}] satisfies DynamicFacet[] : []),
// The BSI facets appear only when a requirement is derived, so a client without one gets no empty controls. // The BSI facets appear only when a requirement is derived, so a client without one gets no empty controls.
...(bsi?.requirements.length ? [{ ...(bsi?.requirements.length ? [{
id: BSI_REQUIREMENT_FACET_ID, id: BSI_REQUIREMENT_FACET_ID,

View File

@ -25,7 +25,8 @@
.client-compliance-page { align-content: start; } .client-compliance-page { align-content: start; }
.client-compliance { margin-block: 0 var(--space-8); color: var(--text-primary); min-width: 0; } .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-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3); }
.client-compliance-header > h2 { margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); } .client-compliance-heading { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-4); }
.client-compliance-heading > h2 { margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
.iso-controls__error { margin: var(--space-2) 0 0; font-size: var(--type-table-body-size); } .iso-controls__error { margin: var(--space-2) 0 0; font-size: var(--type-table-body-size); }
/* The theme facts are this tile's purpose, so they stay visible where narrow environment tiles show two facts. */ /* The theme facts are this tile's purpose, so they stay visible where narrow environment tiles show two facts. */
.client-compliance-grid .client-env-facts > div:nth-child(n + 3) { display: grid; } .client-compliance-grid .client-env-facts > div:nth-child(n + 3) { display: grid; }
@ -78,3 +79,80 @@ button.iso-controls__theme:hover .iso-controls__theme-name { text-decoration: un
.client-compliance-last-mapping.is-empty { color: var(--text-secondary); font-size: var(--type-table-body-size); } .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. */ /* 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-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

@ -4,7 +4,7 @@ import { ClientSecurityPanel, type SecurityFindingGroup } from './ClientSecurity
import { managementCategoryTexts } from '../../reporting/management-report-texts'; import { managementCategoryTexts } from '../../reporting/management-report-texts';
import { ClientContextHeatmap } from '../context-layer/ContextHeatmap'; import { ClientContextHeatmap } from '../context-layer/ContextHeatmap';
import { ClientIsoControls } from '../../compliance/ui/ClientIsoControls'; import { ClientIsoControls } from '../../compliance/ui/ClientIsoControls';
import { BSI_PRACTICE_FACET_ID, BSI_REQUIREMENT_FACET_ID, ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID } from '../../compliance/ui/compliance-work-facets'; import { BSI_PRACTICE_FACET_ID, BSI_REQUIREMENT_FACET_ID, ISO_CONTROL_FACET_ID, ISO_MAPPING_FACET_ID, ISO_THEME_FACET_ID } from '../../compliance/ui/compliance-work-facets';
import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet'; import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet';
import { useAssessmentContext } from '../context-layer/use-assessment-context'; import { useAssessmentContext } from '../context-layer/use-assessment-context';
import { publishContextReport } from '../../assessment-context/context-report-publication'; import { publishContextReport } from '../../assessment-context/context-report-publication';
@ -1291,7 +1291,20 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
setStatusFilter('deviation'); setStatusFilter('deviation');
} }
setFacetFilter(BSI_PRACTICE_FACET_ID, { values: [practiceId.toLowerCase()] }); setFacetFilter(BSI_PRACTICE_FACET_ID, { values: [practiceId.toLowerCase()] });
}, false)}/>; }, false)}
onOpenMappingState={state => jump(() => {
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
if (targetSheet) {
app.setActiveSheet(targetSheet.id);
setStatusFilter('deviation');
}
setFacetFilter(ISO_MAPPING_FACET_ID, { values: [state] });
}, false)}
// Client and timeslot like the Overview; no Source control, because the tab always covers the whole timeslot.
context={<header className="client-overview-header">
<div className="client-overview-identity"><span>Client:</span><strong>{dataset.fileName}</strong></div>
<div className="client-overview-assessment"><span>Assessment:</span><WorkTimeslotControl /></div>
</header>}/>;
if (hasDefender && (clientInfoView === 'defender' || pureDefender)) { if (hasDefender && (clientInfoView === 'defender' || pureDefender)) {
return <DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} reviews={reviews} return <DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} reviews={reviews}
scopedRiskAcceptances={app.scopedRiskAcceptances} activeScanRunIds={app.activeScanRunIds} scopedRiskAcceptances={app.scopedRiskAcceptances} activeScanRunIds={app.activeScanRunIds}

View File

@ -37,10 +37,12 @@ const onOpenControl = vi.fn();
const onOpenTheme = vi.fn(); const onOpenTheme = vi.fn();
const onOpenBsiRequirement = vi.fn(); const onOpenBsiRequirement = vi.fn();
const onOpenBsiPractice = vi.fn(); const onOpenBsiPractice = vi.fn();
const onOpenMappingState = vi.fn();
const render = () => act(async () => { const render = () => act(async () => {
root.render(<ClientIsoControls projectId="dummy-project" assessmentIds={['dummy-assessment']} scanRunIds={[]} root.render(<ClientIsoControls projectId="dummy-project" assessmentIds={['dummy-assessment']} scanRunIds={[]}
backendStatus="connected" onOpenControl={onOpenControl} onOpenTheme={onOpenTheme} backendStatus="connected" onOpenControl={onOpenControl} onOpenTheme={onOpenTheme}
onOpenBsiRequirement={onOpenBsiRequirement} onOpenBsiPractice={onOpenBsiPractice}/>); onOpenBsiRequirement={onOpenBsiRequirement} onOpenBsiPractice={onOpenBsiPractice} onOpenMappingState={onOpenMappingState}
context={<header className="client-overview-header" data-testid="dummy-context">Dummy context</header>}/>);
await new Promise(resolve => setTimeout(resolve, 0)); await new Promise(resolve => setTimeout(resolve, 0));
}); });
@ -53,6 +55,7 @@ beforeEach(() => {
onOpenTheme.mockReset(); onOpenTheme.mockReset();
onOpenBsiRequirement.mockReset(); onOpenBsiRequirement.mockReset();
onOpenBsiPractice.mockReset(); onOpenBsiPractice.mockReset();
onOpenMappingState.mockReset();
host = document.createElement('div'); host = document.createElement('div');
document.body.append(host); document.body.append(host);
root = createRoot(host); root = createRoot(host);
@ -76,10 +79,10 @@ describe('Client Info ISO controls', () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue(overview); const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue(overview);
await render(); await render();
expect(request).toHaveBeenCalledWith('dummy-project', { assessmentIds: ['dummy-assessment'], scanRunIds: [] }); expect(request).toHaveBeenCalledWith('dummy-project', { assessmentIds: ['dummy-assessment'], scanRunIds: [] });
expect(host.querySelector('.client-compliance-header > h2')?.textContent).toBe('Compliance'); expect(host.querySelector('.client-compliance-heading > h2')?.textContent).toBe('Compliance');
// Same tile geometry as the environment row. // Same tile geometry as the environment row.
expect(host.querySelector('.client-compliance-grid > section.client-env-group')).not.toBeNull(); expect(host.querySelector('.client-compliance-grid > section.client-env-group')).not.toBeNull();
expect(host.querySelector('.client-env-group-title')?.textContent).toBe('ISO 27001/27002'); expect(host.querySelector('.client-compliance-grid .client-env-group-title')?.textContent).toBe('ISO 27001/27002');
// All four themes in ISO order; a finding counts once per theme; nothing else of the list is on the tile. // All four themes in ISO order; a finding counts once per theme; nothing else of the list is on the tile.
expect(facts()).toEqual([['Controls', '3'], ['Findings', '3'], ['Organizational', '1 finding'], ['People', '0 findings'], expect(facts()).toEqual([['Controls', '3'], ['Findings', '3'], ['Organizational', '1 finding'], ['People', '0 findings'],
['Physical', '0 findings'], ['Technological', '3 findings']]); ['Physical', '0 findings'], ['Technological', '3 findings']]);
@ -168,7 +171,14 @@ describe('Client Info ISO controls', () => {
// The BSI tile opens Work the same way through the lower-case BSI facet values. // 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_REQUIREMENT_FACET_ID, { values: [requirementId.toLowerCase()] })');
expect(drilldown).toContain('setFacetFilter(BSI_PRACTICE_FACET_ID, { values: [practiceId.toLowerCase()] })'); expect(drilldown).toContain('setFacetFilter(BSI_PRACTICE_FACET_ID, { values: [practiceId.toLowerCase()] })');
expect(drilldown.match(/setStatusFilter\('deviation'\)/g)).toHaveLength(4); // `Mapping failed` in the coverage legend opens Work with the deviations whose latest ISO mapping failed.
expect(drilldown).toContain('setFacetFilter(ISO_MAPPING_FACET_ID, { values: [state] })');
// Client and timeslot like the Overview, without a Source control, because the tab covers the whole timeslot.
const context = drilldown.slice(drilldown.indexOf('context={'));
expect(context).toContain('<span>Client:</span><strong>{dataset.fileName}</strong>');
expect(context).toContain('<span>Assessment:</span><WorkTimeslotControl />');
expect(context).not.toContain('Source:');
expect(drilldown.match(/setStatusFilter\('deviation'\)/g)).toHaveLength(5);
expect(drilldown).not.toContain('setPathFindingIds'); expect(drilldown).not.toContain('setPathFindingIds');
// A scanner finding resolves to its Work record through the occurrence id. // A scanner finding resolves to its Work record through the occurrence id.
const sheet = makeSheet('Dummy', [['Title', 'Status'], ['Dummy check', 'Fail']], '', 'dummy.csv', { isProjectSheet: true }); const sheet = makeSheet('Dummy', [['Title', 'Status'], ['Dummy check', 'Fail']], '', 'dummy.csv', { isProjectSheet: true });
@ -246,6 +256,204 @@ describe('Client Info ISO controls', () => {
}); });
}); });
// 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 // 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. // mapping. Synthetic requirement ids, titles and statements; the findings come from the ISO controls above.
const bsi: NonNullable<RegulatoryControlOverviewResponse['bsi']> = { const bsi: NonNullable<RegulatoryControlOverviewResponse['bsi']> = {

View File

@ -11,6 +11,9 @@ import {
BSI_REQUIREMENT_FACET_ID, BSI_REQUIREMENT_FACET_ID,
complianceWorkFacetIndex, complianceWorkFacetIndex,
ISO_CONTROL_FACET_ID, ISO_CONTROL_FACET_ID,
ISO_MAPPING_FACET_ID,
ISO_MAPPING_FAILED,
ISO_MAPPING_NO_BSI,
ISO_THEME_FACET_ID, ISO_THEME_FACET_ID,
resetComplianceWorkFacetsForTests, resetComplianceWorkFacetsForTests,
useComplianceWorkFacets useComplianceWorkFacets
@ -60,7 +63,8 @@ describe('Work ISO control and ISO theme facets', () => {
const facets = selectWorkFacets(workSheet(), options()); const facets = selectWorkFacets(workSheet(), options());
const ids = facets.map(facet => facet.id); const ids = facets.map(facet => facet.id);
expect(ids.at(-1)).toBe('provenance:source-scan'); expect(ids.at(-1)).toBe('provenance:source-scan');
expect(ids.slice(-3, -1)).toEqual([ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID]); // Nothing reaches BSI in this fixture, so ISO mapping offers `ISO control, no BSI requirement` after ISO theme.
expect(ids.slice(-4, -1)).toEqual([ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID, ISO_MAPPING_FACET_ID]);
const control = facets.find(facet => facet.id === ISO_CONTROL_FACET_ID)!; const control = facets.find(facet => facet.id === ISO_CONTROL_FACET_ID)!;
expect(control.label).toBe('ISO control'); expect(control.label).toBe('ISO control');
expect(control.values.map(value => [value.label, value.count])).toEqual([['A.5.15', 1], ['A.8.20 · Networks security', 3]]); expect(control.values.map(value => [value.label, value.count])).toEqual([['A.5.15', 1], ['A.8.20 · Networks security', 3]]);
@ -68,6 +72,43 @@ describe('Work ISO control and ISO theme facets', () => {
expect(theme.values.map(value => [value.value, value.label, value.count])).toEqual([['organizational', 'Organizational', 1], ['technological', 'Technological', 3]]); expect(theme.values.map(value => [value.value, value.label, value.count])).toEqual([['organizational', 'Organizational', 1], ['technological', 'Technological', 3]]);
}); });
it('offers ISO mapping: Mapping failed only while a mapping failed, and filters to exactly those records', () => {
const sheet = workSheet();
sheet.analysis.records[3].findingOccurrenceId = 'dummy-unmapped';
const failed = { ...assignments, failedFindings: [
{ resultProjectionId: 'scanner-finding:dummy-unmapped', sourceKind: 'scanner-finding' as const, sourceResultId: 'dummy-unmapped' }] };
const withFailed = { ...options(), complianceFacets: complianceWorkFacetIndex(failed) };
const ids = selectWorkFacets(sheet, withFailed).map(facet => facet.id);
expect(ids.slice(-4)).toEqual([ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID, ISO_MAPPING_FACET_ID, 'provenance:source-scan']);
const facet = selectWorkFacets(sheet, withFailed).find(item => item.id === ISO_MAPPING_FACET_ID)!;
expect(facet.label).toBe('ISO mapping');
expect(facet.values.map(value => [value.value, value.label, value.count])).toEqual([[ISO_MAPPING_FAILED, 'Mapping failed', 1],
[ISO_MAPPING_NO_BSI, 'ISO control, no BSI requirement', 3]]);
const titles = filterAndSortRecords(sheet, { ...withFailed, statusFilter: 'deviation',
facetFilters: { [ISO_MAPPING_FACET_ID]: { values: [ISO_MAPPING_FAILED] } }, sortColumn: '', sortDirection: 'asc' }).map(record => record.title);
expect(titles).toEqual(['Dummy unmapped check']);
// Without failed mappings and without a BSI derivation gap there is no such filter.
const allReachBsi = { ...assignments, bsi: { catalogVersion: '2026-01-01', crosswalkVersion: '0.1-synthetic', license: 'Synthetic.',
practices: [{ id: 'KONF', title: 'Konfiguration' }], missingTargets: [], requirements: [
{ requirementId: 'KONF.1.1', title: 'Synthetisch', practice: 'KONF', statement: 'Synthetisch.', via: [{ isoControlId: '8.20', relationship: 'subset-of' as const },
{ isoControlId: '5.15', relationship: 'subset-of' as const }] }] } };
expect(selectWorkFacets(sheet, { ...options(), complianceFacets: complianceWorkFacetIndex(allReachBsi) }).some(item => item.id === ISO_MAPPING_FACET_ID)).toBe(false);
});
it('offers ISO mapping: ISO control, no BSI requirement for records whose accepted controls reach no BSI requirement', () => {
const sheet = workSheet();
// Only 8.20 reaches a BSI requirement; Dummy check B also has 5.15 and therefore one.
const partial = { ...assignments, bsi: { catalogVersion: '2026-01-01', crosswalkVersion: '0.1-synthetic', license: 'Synthetic.',
practices: [{ id: 'KONF', title: 'Konfiguration' }], missingTargets: [], requirements: [
{ requirementId: 'KONF.1.1', title: 'Synthetisch', practice: 'KONF', statement: 'Synthetisch.', via: [{ isoControlId: '5.15', relationship: 'subset-of' as const }] }] } };
const withPartial = { ...options(), complianceFacets: complianceWorkFacetIndex(partial) };
const facet = selectWorkFacets(sheet, withPartial).find(item => item.id === ISO_MAPPING_FACET_ID)!;
expect(facet.values.map(value => [value.value, value.label, value.count])).toEqual([[ISO_MAPPING_NO_BSI, 'ISO control, no BSI requirement', 2]]);
const titles = filterAndSortRecords(sheet, { ...withPartial, facetFilters: { [ISO_MAPPING_FACET_ID]: { values: [ISO_MAPPING_NO_BSI] } },
sortColumn: '', sortDirection: 'asc' }).map(record => record.title);
expect(titles).toEqual(['Dummy check A', 'Dummy GPO setting']);
});
it('filters Work records by control and theme, matching scanner occurrences and GPO settings', () => { it('filters Work records by control and theme, matching scanner occurrences and GPO settings', () => {
const sheet = workSheet(); const sheet = workSheet();
const titles = (filters: object, status = 'all') => filterAndSortRecords(sheet, { ...options(status, filters), sortColumn: '', sortDirection: 'asc' }) const titles = (filters: object, status = 'all') => filterAndSortRecords(sheet, { ...options(status, filters), sortColumn: '', sortDirection: 'asc' })
@ -176,7 +217,7 @@ describe('Work ISO facet loading and the engine switch', () => {
await act(async () => { host.querySelector('button')!.click(); await new Promise(resolve => setTimeout(resolve, 0)); }); await act(async () => { host.querySelector('button')!.click(); await new Promise(resolve => setTimeout(resolve, 0)); });
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
expect(request).toHaveBeenCalledTimes(1); expect(request).toHaveBeenCalledTimes(1);
expect([...host.querySelectorAll('[data-iso-facets]')].map(node => node.textContent)).toEqual(['2,2', '2,2']); expect([...host.querySelectorAll('[data-iso-facets]')].map(node => node.textContent)).toEqual(['2,2,1', '2,2,1']);
await act(async () => { markRegulatoryDecisionSaved(); await new Promise(resolve => setTimeout(resolve, 0)); }); await act(async () => { markRegulatoryDecisionSaved(); await new Promise(resolve => setTimeout(resolve, 0)); });
expect(request).toHaveBeenCalledTimes(2); expect(request).toHaveBeenCalledTimes(2);
}); });

View File

@ -6,6 +6,7 @@ import { acceptedControlIds, REGULATORY_GPO_QUERY_VERSION, REGULATORY_MAPPING_PR
import type { RegulatoryChunk, RegulatoryRetrievalRequest } from '../../src/compliance/regulatory-rag'; import type { RegulatoryChunk, RegulatoryRetrievalRequest } from '../../src/compliance/regulatory-rag';
import { filterUnifiedResults, type UnifiedResultProjection } from '../../src/domain/unified-result'; import { filterUnifiedResults, type UnifiedResultProjection } from '../../src/domain/unified-result';
import type { ComplianceReadPort, ComplianceStore } from '../../src/compliance/compliance-store'; import type { ComplianceReadPort, ComplianceStore } from '../../src/compliance/compliance-store';
import { LocalComplianceStore } from '../../src/compliance/local-compliance-store';
type EngineRepository = ComplianceReadPort & ComplianceStore; type EngineRepository = ComplianceReadPort & ComplianceStore;
import { removeResultDbScopeIdentifiers } from '../../src/domain/result-db-disclosure'; import { removeResultDbScopeIdentifiers } from '../../src/domain/result-db-disclosure';
@ -220,6 +221,105 @@ describe('regulatory mapping service', () => {
await expect(svc.controlOverview('project-1', { assessmentIds: ['foreign-assessment'], scanRunIds: [] })).rejects.toThrow('outside this project'); await expect(svc.controlOverview('project-1', { assessmentIds: ['foreign-assessment'], scanRunIds: [] })).rejects.toThrow('outside this project');
}); });
it('counts every mappable deviation of the timeslot once in one mapping state for the coverage overview', async () => {
const rule = (id: string, sourceRuleId: string, title: string) =>
finding({ resultProjectionId: `scanner-finding:${id}`, sourceResultId: id, sourceRuleId, title });
const findings = [
finding(), finding({ resultProjectionId: 'scanner-finding:occurrence-2', sourceResultId: 'occurrence-2' }),
rule('rejected', 'A-Rejected', 'SMB signing is not enforced'),
rule('failed', 'A-Failed', 'LDAP signing is not required'),
rule('unmapped', 'A-Unmapped', 'Print spooler runs on domain controllers'),
finding({ resultProjectionId: 'scanner-finding:passed', sourceResultId: 'passed', sourceRuleId: 'A-Passed', isDeviation: false })
];
const repository = mockRepository(findings);
const mapped = { value: { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'primary', confidence: 'high',
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }] } };
const svc = service(repository, vi.fn(async () => mapped));
for (const resultProjectionId of ['scanner-finding:occurrence-1', 'scanner-finding:rejected']) {
await svc.start('project-1', { resultProjectionId });
await svc.close();
}
const accepted = (await svc.get('project-1', 'scanner-finding:rejected')).decisions[0];
await svc.decide('project-1', { resultProjectionId: 'scanner-finding:rejected', controlId: '5.1', decision: 'rejected', expectedLatestDecisionId: accepted.id });
const failing = service(repository, vi.fn(async () => { throw new Error('Synthetic model outage.'); }));
await failing.start('project-1', { resultProjectionId: 'scanner-finding:failed' });
await failing.close();
expect((await svc.get('project-1', 'scanner-finding:failed')).state).toBe('failed');
const scope = { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'] };
const overview = await svc.controlOverview('project-1', scope);
// Accepted equals the ISO tile's finding count; the passed result is no deviation and stays out.
expect(overview.coverage).toEqual({ deviations: 5, accepted: 2, mappedNoneAccepted: 1, failed: 1, notMapped: 1 });
expect(overview.findingCount).toBe(overview.coverage?.accepted);
// The Work facet assignments carry no coverage and no footprint, but the failed findings for the `ISO mapping` filter;
// the not-mapped passed result is no failure.
const assignments = await svc.controlAssignments('project-1', scope);
expect(assignments.coverage).toBeUndefined();
expect(assignments.bsiFootprint).toBeUndefined();
expect(assignments.failedFindings?.map(item => item.resultProjectionId)).toEqual(['scanner-finding:failed']);
expect(overview.failedFindings).toBeUndefined();
// The BSI footprint lists every Kernel requirement of the active catalog, whatever is derived for this client.
const footprint = overview.bsiFootprint!;
expect(footprint.practices).toHaveLength(14);
expect(footprint.practices.flatMap(practice => practice.requirements)).toHaveLength(901);
expect(footprint.practices.flatMap(practice => practice.requirements).filter(requirement => requirement.reachable)).toHaveLength(272);
expect(footprint.practices.find(practice => practice.id === 'KONF')?.requirements.find(item => item.id === 'KONF.2.1')?.reachable).toBe(true);
// Every requirement carries its published title, including unreachable ones.
expect(footprint.practices.flatMap(practice => practice.requirements).every(item => item.title.trim().length > 0)).toBe(true);
expect(footprint.practices.flatMap(practice => practice.requirements).find(item => !item.reachable)?.title).toBeTruthy();
});
it('rejects a control accepted by an earlier run after a remap of the same version no longer proposes it (regression)', async () => {
// Real engine store: a remap with the same pipeline versions overwrites the rule's only run.
let proposed = '5.1';
const svc = new RegulatoryMappingService(mockRepository([finding()]), new LocalComplianceStore(), {
findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve: vi.fn(async () => [{ score: 0.9, chunk: chunk('ISO/IEC 27002', proposed) }]) }),
index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list: vi.fn(async () => [chunk('ISO/IEC 27001', proposed)]) },
client: { model: 'work-model', complete: vi.fn(async () => ({ value: { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: proposed,
relationship: 'primary', confidence: 'high', rationale: 'Synthetic rationale.', evidenceChunkIds: [`semantic-${proposed}`, `reference-${proposed}`] }] } })) as never },
corpusId: 'iso-test', corpusVersion: 'v2', embeddingModel: 'bge-m3', now: () => new Date(stamp)
});
const id = 'scanner-finding:occurrence-1';
await svc.start('project-1', { resultProjectionId: id });
await svc.close();
const accepted = (await svc.get('project-1', id)).decisions.find(item => item.controlId === '5.1')!;
expect(accepted.decision).toBe('accepted');
// Remap: the model now proposes only 8.9; 5.1 remains an earlier decision.
proposed = '8.9';
await svc.start('project-1', { resultProjectionId: id });
await svc.close();
const remapped = await svc.get('project-1', id);
expect(remapped.run?.candidates.map(candidate => candidate.controlId)).toEqual(['8.9']);
expect(remapped.earlierControls?.map(control => control.controlId)).toEqual(['5.1']);
const rejected = await svc.decide('project-1', { resultProjectionId: id, controlId: '5.1', decision: 'rejected', expectedLatestDecisionId: accepted.id });
expect(rejected.decisions.find(item => item.controlId === '5.1' && item.decision === 'rejected')).toMatchObject({ previousDecisionId: accepted.id });
expect(acceptedControlIds(rejected.decisions)).toEqual(['8.9']);
// A control no run ever proposed for this rule stays undecidable.
await expect(svc.decide('project-1', { resultProjectionId: id, controlId: '7.4', decision: 'accepted' })).rejects.toThrow('not a grounded candidate');
});
it('names every Annex A control from the reference documents of the active corpus, cached per corpus version', async () => {
const list = vi.fn(async (filters: { retrievalRole?: string }, _limit: number) => filters.retrievalRole === 'reference'
? [chunk('ISO/IEC 27001', '5.1'), chunk('ISO/IEC 27001', '8.9'), { ...chunk('ISO/IEC 27001', '8.9'), title: 'Second chunk of 8.9' }] : []);
const svc = service(mockRepository([finding()]), vi.fn(), '5.1',
{ index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list } });
const scope = { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'] };
expect((await svc.controlOverview('project-1', scope)).annexATitles).toEqual({ '5.1': 'Synthetic control', '8.9': 'Synthetic control' });
// Only titles of the active corpus version, read once for repeated overviews.
const referenceReads = () => list.mock.calls.filter(([filters]) => filters.retrievalRole === 'reference');
expect(referenceReads()[0]).toEqual([{ corpusId: 'iso-test', corpusVersion: 'v2', sourceStandard: 'ISO/IEC 27001', retrievalRole: 'reference' }, 100]);
await svc.controlOverview('project-1', scope);
expect(referenceReads()).toHaveLength(1);
// The Work facet assignments carry no titles.
expect((await svc.controlAssignments('project-1', scope)).annexATitles).toBeUndefined();
// A new corpus version is read again; an unavailable index yields no titles and is retried next time.
await svc.activateCorpus('iso-test', 'v3');
list.mockRejectedValueOnce(new Error('Synthetic index outage.'));
expect((await svc.controlOverview('project-1', scope)).annexATitles).toEqual({});
expect((await svc.controlOverview('project-1', scope)).annexATitles).toEqual({ '5.1': 'Synthetic control', '8.9': 'Synthetic control' });
});
it('derives BSI Kernel requirements from accepted ISO controls in the finding, overview and Work responses', async () => { 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 repository = mockRepository([finding(), finding({ resultProjectionId: 'scanner-finding:occurrence-2', sourceResultId: 'occurrence-2' })]);
const svc = service(repository, vi.fn(async () => ({ value: { const svc = service(repository, vi.fn(async () => ({ value: {