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:
parent
5e7e787dbb
commit
9480b039dd
File diff suppressed because one or more lines are too long
|
|
@ -182,14 +182,16 @@ export class PostgresComplianceStore implements ComplianceStore {
|
|||
if (!run || run.ruleKey !== decision.ruleKey || run.status !== 'succeeded') {
|
||||
throw new RepositoryNotFoundError('Completed regulatory mapping does not exist for this rule.');
|
||||
}
|
||||
if (!run.candidates.some(candidate => candidate.controlId === decision.controlId)) {
|
||||
throw new RepositoryValidationError('Decision control was not a grounded candidate.');
|
||||
}
|
||||
const latest = dataRow(await client.query<DataRow<RegulatoryMappingDecision>>(`
|
||||
SELECT data FROM assessor_regulatory_mapping_decisions
|
||||
WHERE rule_key = $1 AND control_id = $2
|
||||
ORDER BY created_at DESC, id DESC LIMIT 1
|
||||
`, [decision.ruleKey, decision.controlId]));
|
||||
// A control with an earlier decision stays decidable after a remap no longer retrieves it; the chain of
|
||||
// previous decisions leads back to the run that grounded it.
|
||||
if (!latest && !run.candidates.some(candidate => candidate.controlId === decision.controlId)) {
|
||||
throw new RepositoryValidationError('Decision control was not a grounded candidate.');
|
||||
}
|
||||
if ((expectedLatestDecisionId ?? undefined) !== (latest?.id ?? undefined)) {
|
||||
throw new RepositoryConflictError('Regulatory mapping decision changed before this update.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
|||
import { privacySafeFinding } from '../../src/domain/privacy-safe-finding';
|
||||
import type { RegulatoryFindingRetrievalService } from './regulatory-finding-retrieval';
|
||||
import { bundledBsiCatalogs, type BsiCatalogs } from './bsi-snapshots';
|
||||
import { 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 { buildRegulatoryFindingQuery, isRegulatoryMappableResult } from '../../src/compliance/regulatory-query';
|
||||
import { canonicalJson } from '../../src/shared/canonical-json';
|
||||
|
|
@ -77,6 +77,7 @@ export class RegulatoryMappingService {
|
|||
}> = [];
|
||||
private activeWorkerCount = 0;
|
||||
private activationPending = false;
|
||||
private titleCache?: { key: string; titles: Record<string, string> };
|
||||
|
||||
constructor(
|
||||
private readonly read: ComplianceReadPort,
|
||||
|
|
@ -271,10 +272,34 @@ export class RegulatoryMappingService {
|
|||
|
||||
/** BSI Kernel requirements derived from accepted ISO controls through the active BSI mapping (Phase 13F, stage 1). */
|
||||
private async bsiOverview(acceptedIsoControlIds: readonly string[]): Promise<BsiOverview | undefined> {
|
||||
const { catalog, crosswalk } = this.options.bsiCatalogs ? await this.options.bsiCatalogs() : bundledBsiCatalogs;
|
||||
const { catalog, crosswalk } = await this.bsiCatalogs();
|
||||
return bsiOverview(acceptedIsoControlIds, crosswalk, catalog);
|
||||
}
|
||||
|
||||
/**
|
||||
* Annex A control titles of the active licensed corpus (one ISO/IEC 27001 reference document per control), for the
|
||||
* Client Info footprint; only titles, never clause text. Cached per corpus version; an unavailable index yields none.
|
||||
*/
|
||||
private async annexATitles(): Promise<Record<string, string>> {
|
||||
const { index, corpusId, corpusVersion } = this.options;
|
||||
if (!index || !corpusId || !corpusVersion) return {};
|
||||
const key = `${corpusId}\u0000${corpusVersion}`;
|
||||
if (this.titleCache?.key === key) return this.titleCache.titles;
|
||||
try {
|
||||
const chunks = await index.list({ corpusId, corpusVersion, sourceStandard: 'ISO/IEC 27001', retrievalRole: 'reference' }, 100);
|
||||
const titles: Record<string, string> = {};
|
||||
for (const chunk of chunks) if (chunk.controlId && chunk.title?.trim() && !titles[chunk.controlId]) titles[chunk.controlId] = chunk.title.trim();
|
||||
if (Object.keys(titles).length) this.titleCache = { key, titles };
|
||||
return titles;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private async bsiCatalogs(): Promise<BsiCatalogs> {
|
||||
return this.options.bsiCatalogs ? this.options.bsiCatalogs() : bundledBsiCatalogs;
|
||||
}
|
||||
|
||||
/** Accepted controls of every mappable finding in a client timeslot, for the Work `ISO control` and `ISO theme` filters. */
|
||||
async controlAssignments(projectId: string, requested: Pick<RegulatoryMappingBatchScope, 'assessmentIds' | 'scanRunIds'>): Promise<RegulatoryControlOverviewResponse> {
|
||||
return this.controlOverview(projectId, requested, { deviationsOnly: false });
|
||||
|
|
@ -302,16 +327,25 @@ export class RegulatoryMappingService {
|
|||
}
|
||||
// Control titles come from the newest succeeded run of each rule that retrieved the control.
|
||||
const titles = new Map<string, Map<string, RegulatoryMappingCandidate>>();
|
||||
const mapped = new Set<string>();
|
||||
const latestRun = new Map<string, RegulatoryMappingRun>();
|
||||
for (const run of await this.store.listRegulatoryMappingRuns()) {
|
||||
if (run.status !== 'succeeded' || !byRule.has(run.ruleKey)) continue;
|
||||
if (!byRule.has(run.ruleKey)) continue;
|
||||
const latest = latestRun.get(run.ruleKey);
|
||||
if (!latest || run.createdAt > latest.createdAt || run.createdAt === latest.createdAt && run.id > latest.id) latestRun.set(run.ruleKey, run);
|
||||
if (run.status !== 'succeeded') continue;
|
||||
mapped.add(run.ruleKey);
|
||||
const known = titles.get(run.ruleKey) ?? new Map<string, RegulatoryMappingCandidate>();
|
||||
for (const candidate of run.candidates) if (!known.has(candidate.controlId)) known.set(candidate.controlId, candidate);
|
||||
titles.set(run.ruleKey, known);
|
||||
}
|
||||
const controls = new Map<string, { item: RegulatoryControlOverviewItem; findings: Map<string, RegulatoryControlFinding> }>();
|
||||
const covered = new Set<string>();
|
||||
const unaccepted: Array<[string, Map<string, RegulatoryControlFinding>]> = [];
|
||||
for (const [ruleKey, findings] of byRule) {
|
||||
for (const controlId of acceptedControlIds(await this.store.listRegulatoryMappingDecisions(ruleKey))) {
|
||||
const accepted = acceptedControlIds(await this.store.listRegulatoryMappingDecisions(ruleKey));
|
||||
if (!accepted.length) unaccepted.push([ruleKey, findings]);
|
||||
for (const controlId of accepted) {
|
||||
const candidate = titles.get(ruleKey)?.get(controlId);
|
||||
const entry = controls.get(controlId) ?? { findings: new Map(), item: {
|
||||
controlId, annexAControlId: candidate?.annexAControlId ?? `A.${controlId}`, findings: [],
|
||||
|
|
@ -323,12 +357,30 @@ export class RegulatoryMappingService {
|
|||
}
|
||||
}
|
||||
const bsi = await this.bsiOverview([...controls.keys()]);
|
||||
// A finding counts once, in the strongest state of its rule: accepted, mapped, failed, else not mapped.
|
||||
const mappedNoneAccepted = new Set<string>();
|
||||
const failed = new Set<string>();
|
||||
const notMapped = new Set<string>();
|
||||
const failedFindings: RegulatoryControlFinding[] = [];
|
||||
for (const [ruleKey, findings] of unaccepted) {
|
||||
const state = mapped.has(ruleKey) ? mappedNoneAccepted : latestRun.get(ruleKey)?.status === 'failed' ? failed : notMapped;
|
||||
for (const [identity, finding] of findings) {
|
||||
if (covered.has(identity)) continue;
|
||||
state.add(identity);
|
||||
if (state === failed) failedFindings.push(finding);
|
||||
}
|
||||
}
|
||||
const coverage = { deviations: covered.size + mappedNoneAccepted.size + failed.size + notMapped.size, accepted: covered.size,
|
||||
mappedNoneAccepted: mappedNoneAccepted.size, failed: failed.size, notMapped: notMapped.size };
|
||||
return {
|
||||
version: 1, projectId, scope: { assessmentIds: scope.assessmentIds, scanRunIds: scope.scanRunIds }, findingCount: covered.size,
|
||||
controls: [...controls.values()].map(({ item, findings }) => ({ ...item, findings: [...findings.values()] }))
|
||||
.sort((left, right) => right.findings.length - left.findings.length
|
||||
|| left.controlId.localeCompare(right.controlId, 'en', { numeric: true })),
|
||||
...(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 } : {})
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* reached through, once per Result DB row, like the ISO theme counts.
|
||||
|
|
|
|||
|
|
@ -93,8 +93,10 @@ export class LocalComplianceStore implements ComplianceStore {
|
|||
if (!run || run.ruleKey !== decision.ruleKey || run.status !== 'succeeded') {
|
||||
throw new RepositoryNotFoundError('Completed regulatory mapping does not exist for this rule.');
|
||||
}
|
||||
if (!run.candidates.some(candidate => candidate.controlId === decision.controlId)) throw new RepositoryValidationError('Decision control was not a grounded candidate.');
|
||||
const latest = this.decisions.filter(item => item.ruleKey === decision.ruleKey && item.controlId === decision.controlId).sort(newestFirst)[0];
|
||||
// A control with an earlier decision stays decidable after a remap no longer retrieves it; the chain of
|
||||
// previous decisions leads back to the run that grounded it.
|
||||
if (!latest && !run.candidates.some(candidate => candidate.controlId === decision.controlId)) throw new RepositoryValidationError('Decision control was not a grounded candidate.');
|
||||
if ((expectedLatestDecisionId ?? undefined) !== (latest?.id ?? undefined)) throw new RepositoryConflictError('Regulatory mapping decision changed before this update.');
|
||||
const stored = clone({ ...decision, ...(latest ? { previousDecisionId: latest.id } : {}) });
|
||||
this.decisions.push(stored);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { ResultDbFilters } from '../domain/result-db-query';
|
||||
import type { UnifiedResultProjection } from '../domain/unified-result';
|
||||
|
|
@ -231,8 +231,33 @@ export interface RegulatoryControlOverviewResponse {
|
|||
mappingHistory?: RegulatoryMappingHistoryEntry[];
|
||||
/** BSI Kernel requirements derived from the accepted controls above; absent when none is derived. */
|
||||
bsi?: BsiOverview;
|
||||
/** How far the mappable deviations of the timeslot are mapped; only on the Client Info overview. */
|
||||
coverage?: RegulatoryMappingCoverage;
|
||||
/** Mappable findings whose rule has no accepted control and whose latest mapping failed; only on the Work assignments. */
|
||||
failedFindings?: RegulatoryControlFinding[];
|
||||
/** Annex A control title by control id from the active licensed corpus, for the footprint; only on the Client Info overview. */
|
||||
annexATitles?: Record<string, string>;
|
||||
/** Every BSI Kernel requirement for the BSI footprint; only on the Client Info overview. */
|
||||
bsiFootprint?: BsiFootprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content-free counts of the mappable deviations of one timeslot, each finding in exactly one state by its rule:
|
||||
* an accepted control, else a succeeded mapping without one, else a failed latest mapping, else not mapped yet
|
||||
* (including queued and running). `accepted` equals the overview's `findingCount`.
|
||||
*/
|
||||
export interface RegulatoryMappingCoverage {
|
||||
deviations: number;
|
||||
accepted: number;
|
||||
mappedNoneAccepted: number;
|
||||
failed: number;
|
||||
notMapped: number;
|
||||
}
|
||||
|
||||
/** The fixed number of controls per ISO/IEC 27002:2022 theme (clauses 5 to 8, 93 controls). */
|
||||
export const ANNEX_A_CONTROL_COUNTS: Readonly<Record<RegulatoryControlTheme, number>> = { Organizational: 37, People: 8, Physical: 14, Technological: 34 };
|
||||
export const ANNEX_A_THEME_CLAUSES: Readonly<Record<RegulatoryControlTheme, number>> = { Organizational: 5, People: 6, Physical: 7, Technological: 8 };
|
||||
|
||||
export interface RegulatoryMappingResponse {
|
||||
version: 1;
|
||||
projectId: string;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { EnvironmentResultsButton } from '../../features/dashboard/EnvironmentRe
|
|||
import type { RegulatoryControlOverviewItem, RegulatoryControlOverviewResponse, RegulatoryControlTheme, RegulatoryMappingHistoryEntry } from '../regulatory-mapping';
|
||||
import { useRegulatoryDecisionRevision } from './decision-revision';
|
||||
import { bsiRequirementFindings, type BsiOverview } from '../bsi-grundschutz';
|
||||
import { ComplianceFrameworkSwitch, ComplianceOverview, type ComplianceFramework } from './ComplianceOverview';
|
||||
import type { ComplianceMappingState } from './compliance-work-facets';
|
||||
import './compliance.css';
|
||||
|
||||
const assessorApi = new AssessorApiClient();
|
||||
|
|
@ -27,6 +29,10 @@ interface Props {
|
|||
onOpenBsiRequirement(requirementId: string): void;
|
||||
/** Opens Work filtered to this BSI practice. */
|
||||
onOpenBsiPractice(practiceId: string): void;
|
||||
/** Opens Work with the deviations in this `ISO mapping` facet state. */
|
||||
onOpenMappingState(state: ComplianceMappingState): void;
|
||||
/** The Client Info context row (client and timeslot), shown below the tab bar like on the Overview. */
|
||||
context?: ReactNode;
|
||||
/** The Client Info tab bar, shown above this tab like on the AI Overview. */
|
||||
navigation?: ReactNode;
|
||||
}
|
||||
|
|
@ -53,10 +59,11 @@ function themeGroups(controls: RegulatoryControlOverviewItem[]): ThemeGroup[] {
|
|||
}
|
||||
|
||||
/**
|
||||
* Client Info `Compliance` tab: one tile per framework in the environment tile geometry, the complete
|
||||
* control list in its Results window, and when the timeslot was last mapped. ISO/IEC 27001/27002 is the first framework.
|
||||
* Client Info `Compliance` tab: an overview row (mapping coverage and Annex A footprint), one tile per framework
|
||||
* in the environment tile geometry, the complete control list in its Results window, and when the timeslot was
|
||||
* last mapped. ISO/IEC 27001/27002 is the first framework.
|
||||
*/
|
||||
export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = [], backendStatus, onOpenControl, onOpenTheme, onOpenBsiRequirement, onOpenBsiPractice, navigation }: Props) {
|
||||
export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = [], backendStatus, onOpenControl, onOpenTheme, onOpenBsiRequirement, onOpenBsiPractice, onOpenMappingState, navigation, context }: Props) {
|
||||
const available = useEnginePreferences().compliance;
|
||||
const revision = useRegulatoryDecisionRevision();
|
||||
const id = useId();
|
||||
|
|
@ -69,6 +76,8 @@ export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds =
|
|||
});
|
||||
|
||||
const [poll, setPoll] = useState(0);
|
||||
// Which framework the overview row shows; the framework tiles below are unaffected.
|
||||
const [framework, setFramework] = useState<ComplianceFramework>('iso');
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !projectId) { setState({ status: 'idle' }); return; }
|
||||
|
|
@ -147,11 +156,19 @@ export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds =
|
|||
|
||||
return <div className="dashboard-page client-command-center client-compliance-page">
|
||||
{navigation}
|
||||
{context}
|
||||
<section className="client-compliance" aria-labelledby={`${id}-title`} aria-busy={state.status === 'loading'}>
|
||||
<header className="client-compliance-header">
|
||||
<h2 id={`${id}-title`}>Compliance</h2>
|
||||
<div className="client-compliance-heading">
|
||||
<h2 id={`${id}-title`}>Compliance</h2>
|
||||
{state.status !== 'error' && <ComplianceFrameworkSwitch value={framework} onChange={setFramework}/>}
|
||||
</div>
|
||||
{state.status === 'ready' && <LastMapping history={history}/>}
|
||||
</header>
|
||||
{state.status !== 'error' && <ComplianceOverview framework={framework} controls={ready ? state.overview.controls : undefined}
|
||||
coverage={ready ? state.overview.coverage : undefined} bsi={ready ? state.overview.bsi : undefined}
|
||||
bsiFootprint={ready ? state.overview.bsiFootprint : undefined} annexATitles={ready ? state.overview.annexATitles : undefined} onOpenControl={onOpenControl} onOpenTheme={onOpenTheme}
|
||||
onOpenBsiRequirement={onOpenBsiRequirement} onOpenBsiPractice={onOpenBsiPractice} onOpenMappingState={onOpenMappingState}/>}
|
||||
{/* Same tile geometry as the environment row; later frameworks add their tiles here. */}
|
||||
<div className="client-compliance-grid" aria-label="Compliance frameworks">
|
||||
<section className="client-env-group" data-framework="iso-27001-27002">
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import { useEnginePreferences } from '../../app/engine-preferences';
|
|||
import type { FindingRecord } from '../../domain/finding';
|
||||
import { gpoWorkSettingIdentity } from '../../features/results-browser/work-record-lookup';
|
||||
import type { DynamicFacet } from '../../features/search-filter/dynamic-facets';
|
||||
import type { RegulatoryControlOverviewResponse, RegulatoryControlTheme } from '../regulatory-mapping';
|
||||
import type { RegulatoryControlFinding, RegulatoryControlOverviewResponse, RegulatoryControlTheme } from '../regulatory-mapping';
|
||||
import { useRegulatoryDecisionRevision } from './decision-revision';
|
||||
|
||||
/** Work facets of the Compliance Engine; present only while the engine is on and its assignments are loaded. */
|
||||
|
|
@ -13,7 +13,13 @@ export const ISO_CONTROL_FACET_ID = 'compliance:iso-control';
|
|||
export const ISO_THEME_FACET_ID = 'compliance:iso-theme';
|
||||
export const BSI_REQUIREMENT_FACET_ID = 'compliance:bsi-requirement';
|
||||
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 THEMES: RegulatoryControlTheme[] = ['Organizational', 'People', 'Physical', 'Technological'];
|
||||
|
||||
|
|
@ -30,18 +36,21 @@ export function complianceWorkFacetIndex(response: RegulatoryControlOverviewResp
|
|||
const byOccurrence = new Map<string, Set<string>>();
|
||||
const bySetting = new Map<string, Set<string>>();
|
||||
const add = (map: Map<string, Set<string>>, key: string, controlId: string) => map.set(key, (map.get(key) ?? new Set()).add(controlId));
|
||||
for (const control of response.controls) {
|
||||
for (const finding of control.findings) {
|
||||
if (finding.sourceKind === 'scanner-finding') add(byOccurrence, finding.sourceResultId, control.controlId);
|
||||
else if (finding.sourceRuleId) add(bySetting, `${finding.policyScope ?? ''}\u0000${finding.sourceRuleId}`, control.controlId);
|
||||
}
|
||||
}
|
||||
// Scanner findings match by occurrence; GPO settings by scope and technical setting identity, like Result DB drill-downs.
|
||||
const controlsFor = (record: FindingRecord): string[] => {
|
||||
if (record.findingOccurrenceId) return [...byOccurrence.get(record.findingOccurrenceId) ?? []];
|
||||
const identity = gpoWorkSettingIdentity(record.id);
|
||||
return identity ? [...bySetting.get(`${identity.scope ?? ''}\u0000${identity.settingId}`) ?? []] : [];
|
||||
const addFinding = (finding: RegulatoryControlFinding, value: string, occurrences: Map<string, Set<string>>, settings: Map<string, Set<string>>) => {
|
||||
if (finding.sourceKind === 'scanner-finding') add(occurrences, finding.sourceResultId, value);
|
||||
else if (finding.sourceRuleId) add(settings, `${finding.policyScope ?? ''}\u0000${finding.sourceRuleId}`, value);
|
||||
};
|
||||
for (const control of response.controls) for (const finding of control.findings) addFinding(finding, control.controlId, byOccurrence, bySetting);
|
||||
// Scanner findings match by occurrence; GPO settings by scope and technical setting identity, like Result DB drill-downs.
|
||||
const valuesOf = (occurrences: Map<string, Set<string>>, settings: Map<string, Set<string>>) => (record: FindingRecord): string[] => {
|
||||
if (record.findingOccurrenceId) return [...occurrences.get(record.findingOccurrenceId) ?? []];
|
||||
const identity = gpoWorkSettingIdentity(record.id);
|
||||
return identity ? [...settings.get(`${identity.scope ?? ''}\u0000${identity.settingId}`) ?? []] : [];
|
||||
};
|
||||
const controlsFor = valuesOf(byOccurrence, bySetting);
|
||||
const failedOccurrences = new Map<string, Set<string>>();
|
||||
const failedSettings = new Map<string, Set<string>>();
|
||||
for (const finding of response.failedFindings ?? []) addFinding(finding, ISO_MAPPING_FAILED, failedOccurrences, failedSettings);
|
||||
const themeOf = new Map(response.controls.map(control => [control.controlId, control.theme]));
|
||||
// BSI requirements derived from the accepted ISO controls through the BSI mapping (Phase 13F, stage 1).
|
||||
const bsi = response.bsi;
|
||||
|
|
@ -52,6 +61,13 @@ export function complianceWorkFacetIndex(response: RegulatoryControlOverviewResp
|
|||
const practiceOf = new Map((bsi?.requirements ?? []).map(requirement => [requirement.requirementId, requirement.practice]));
|
||||
const requirementsFor = (record: FindingRecord): string[] => [...new Set(controlsFor(record).flatMap(controlId => requirementsByControl.get(controlId) ?? []))];
|
||||
const controls = [...response.controls].sort((left, right) => left.controlId.localeCompare(right.controlId, 'en', { numeric: true }));
|
||||
const failedFor = valuesOf(failedOccurrences, failedSettings);
|
||||
// A finding has no BSI requirement when none of its accepted controls reaches one; offered only with hits.
|
||||
const hasNoBsi = [...byOccurrence.values(), ...bySetting.values()]
|
||||
.some(controlIds => [...controlIds].every(controlId => !requirementsByControl.has(controlId)));
|
||||
const mappingValues = [
|
||||
...(response.failedFindings?.length ? [{ value: ISO_MAPPING_FAILED, label: 'Mapping failed', count: 0 }] : []),
|
||||
...(hasNoBsi ? [{ value: ISO_MAPPING_NO_BSI, label: 'ISO control, no BSI requirement', count: 0 }] : [])];
|
||||
const index: ComplianceWorkFacetIndex = {
|
||||
facets: [{
|
||||
id: ISO_CONTROL_FACET_ID,
|
||||
|
|
@ -72,6 +88,16 @@ export function complianceWorkFacetIndex(response: RegulatoryControlOverviewResp
|
|||
.map(theme => ({ value: theme.toLowerCase(), label: theme, count: 0 })),
|
||||
valuesFor: record => [...new Set(controlsFor(record).flatMap(controlId => themeOf.get(controlId)?.toLowerCase() ?? []))]
|
||||
},
|
||||
// Present only while one of its values has hits, so the Client Info coverage rows open exactly these in Work.
|
||||
...(mappingValues.length ? [{
|
||||
id: ISO_MAPPING_FACET_ID,
|
||||
label: 'ISO mapping',
|
||||
source: 'derived',
|
||||
kind: 'category',
|
||||
description: 'Compliance Engine: findings and GPO settings whose latest ISO mapping failed (map them again from the Finding Reader), or whose accepted ISO controls reach no BSI requirement. It does not change results.',
|
||||
values: mappingValues,
|
||||
valuesFor: record => [...failedFor(record), ...(controlsFor(record).length && !requirementsFor(record).length ? [ISO_MAPPING_NO_BSI] : [])]
|
||||
}] satisfies DynamicFacet[] : []),
|
||||
// The BSI facets appear only when a requirement is derived, so a client without one gets no empty controls.
|
||||
...(bsi?.requirements.length ? [{
|
||||
id: BSI_REQUIREMENT_FACET_ID,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@
|
|||
.client-compliance-page { align-content: start; }
|
||||
.client-compliance { margin-block: 0 var(--space-8); color: var(--text-primary); min-width: 0; }
|
||||
.client-compliance-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3); }
|
||||
.client-compliance-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); }
|
||||
/* 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; }
|
||||
|
|
@ -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); }
|
||||
/* Mapping history is a data table, not field/value pairs: every cell keeps the 14px table role. */
|
||||
.client-information-table.client-compliance-history td:nth-child(2) { font-size: var(--type-table-body-size); }
|
||||
|
||||
/* Client Info `Compliance` overview row: same six tracks as the framework row below, so column starts align.
|
||||
Mapping coverage is a square over two tracks; the Annex A footprint takes the other four at the same height. */
|
||||
.compliance-overview { display: grid; grid-template-columns: repeat(6, minmax(var(--tile-environment-min-width), 1fr)); gap: var(--segment-gap); min-width: 0; margin-top: var(--segment-gap); overflow-x: auto; }
|
||||
.compliance-overview > .client-env-group { display: flex; flex-direction: column; min-width: 0; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); }
|
||||
.compliance-overview .client-env-group-title { font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
|
||||
.compliance-coverage { grid-column: span 2; aspect-ratio: 1; }
|
||||
.compliance-footprint { grid-column: span 4; }
|
||||
|
||||
/* Coverage: the shared Client Info donut; accepted, mapped-only and failed are arcs, not mapped is the track. */
|
||||
.compliance-coverage__body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; gap: var(--space-5); flex: 1; min-height: 0; margin-top: var(--space-4); }
|
||||
.compliance-coverage__ring { display: grid; justify-items: center; gap: var(--space-2); min-width: 0; }
|
||||
.compliance-coverage .client-info-donut { width: min(196px, 100%); }
|
||||
.compliance-coverage__caption { color: var(--donut-label); font-size: var(--donut-title-size); text-align: center; }
|
||||
.compliance-coverage [data-state="accepted"], .compliance-coverage [data-state="bsi"] { --chart-color: var(--chart-tone-emphasis); }
|
||||
.compliance-coverage [data-state="isoOnly"] { --chart-color: color-mix(in srgb, var(--chart-tone-emphasis) 45%, var(--tile-surface)); }
|
||||
.compliance-coverage [data-state="mappedNoneAccepted"] { --chart-color: var(--donut-not-run); }
|
||||
.compliance-coverage [data-state="failed"] { --chart-color: var(--donut-error); }
|
||||
.compliance-coverage [data-state="notMapped"] { --chart-color: var(--donut-track); }
|
||||
.compliance-coverage__legend { display: grid; min-width: 0; margin: 0; border-top: var(--bw-hairline) solid var(--tile-divider); }
|
||||
.compliance-coverage__legend > div { display: grid; grid-template-columns: var(--space-3) minmax(0, 1fr) max-content; align-items: baseline; gap: var(--space-2); padding: var(--space-2); border-bottom: var(--bw-hairline) solid var(--tile-divider); }
|
||||
.compliance-coverage__legend i { width: var(--space-3); height: var(--space-3); align-self: center; background: var(--chart-color, transparent); }
|
||||
.compliance-coverage__legend dt { min-width: 0; color: var(--text-secondary); font-size: var(--type-table-body-size); overflow-wrap: break-word; }
|
||||
.compliance-coverage__legend dd { margin: 0; font-size: var(--type-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.compliance-coverage__legend .is-total dt { color: var(--text-primary); }
|
||||
.compliance-coverage__note { margin: var(--space-3) 0 0; color: var(--text-secondary); font-size: var(--type-table-body-size); }
|
||||
|
||||
/* Footprint: all 93 Annex A controls; one neutral chart tone in four steps, never a status colour. */
|
||||
.compliance-footprint__themes { display: flex; flex-wrap: wrap; align-items: flex-start; gap: var(--space-6); margin-block: var(--space-4); }
|
||||
.compliance-footprint__column { display: grid; gap: var(--space-4); }
|
||||
.compliance-footprint__theme { display: grid; align-content: start; gap: var(--space-2); }
|
||||
.compliance-footprint__theme-head { display: grid; justify-items: start; padding: 0 0 var(--space-1); border: 0; border-bottom: var(--bw-hairline) solid var(--tile-divider); background: transparent; color: var(--text-primary); font-family: inherit; text-align: left; }
|
||||
button.compliance-footprint__theme-head { cursor: pointer; }
|
||||
button.compliance-footprint__theme-head:hover .compliance-footprint__theme-name { text-decoration: underline; }
|
||||
button.compliance-footprint__theme-head:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--bw-emphasis); }
|
||||
.compliance-footprint__theme-name { font-size: var(--type-body-size); font-weight: var(--fw-semibold); }
|
||||
.compliance-footprint__theme-meta { color: var(--text-secondary); font-size: var(--type-table-body-size); font-variant-numeric: tabular-nums; }
|
||||
.compliance-footprint__cells { display: grid; grid-template-columns: repeat(6, calc(var(--space-8) + var(--space-1) + var(--bw-emphasis))); gap: calc(var(--space-1) - var(--bw-hairline)); }
|
||||
.compliance-footprint__cell { display: grid; place-items: center; height: calc(var(--space-6) + var(--space-1) + var(--bw-emphasis)); padding: 0; border: var(--bw-hairline) solid var(--tile-divider); background: transparent; color: var(--text-secondary); font-family: inherit; font-size: var(--type-meta-size); font-variant-numeric: tabular-nums; }
|
||||
.compliance-footprint__cell[data-step] { border-color: transparent; color: var(--text-primary); }
|
||||
button.compliance-footprint__cell { cursor: pointer; }
|
||||
.compliance-footprint__cell[data-step="1"] { background: color-mix(in srgb, var(--chart-tone-emphasis) 22%, var(--tile-surface)); }
|
||||
.compliance-footprint__cell[data-step="2"] { background: color-mix(in srgb, var(--chart-tone-emphasis) 45%, var(--tile-surface)); }
|
||||
.compliance-footprint__cell[data-step="3"] { background: color-mix(in srgb, var(--chart-tone-emphasis) 72%, var(--tile-surface)); color: var(--sev-ink); }
|
||||
.compliance-footprint__cell[data-step="4"] { background: var(--chart-tone-emphasis); color: var(--sev-ink); font-weight: var(--fw-semibold); }
|
||||
button.compliance-footprint__cell:hover, .compliance-footprint__cell.is-current { outline: var(--bw-hairline) solid var(--text-primary); outline-offset: calc(var(--bw-hairline) * -1); }
|
||||
button.compliance-footprint__cell:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: 0; }
|
||||
.compliance-footprint__detail { display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: center; gap: var(--space-3); min-height: calc(var(--space-8) * 1.75); margin-top: auto; padding: var(--space-2) var(--space-3); border-block: var(--bw-hairline) solid var(--tile-divider); }
|
||||
.compliance-footprint__detail-id, .compliance-footprint__detail-hint { color: var(--text-secondary); font-size: var(--type-table-body-size); }
|
||||
.compliance-footprint__detail-title { font-size: var(--type-body-size); overflow-wrap: break-word; }
|
||||
.compliance-footprint__detail-count { font-size: var(--type-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
|
||||
.compliance-footprint__legend { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2) var(--space-4); margin-top: var(--space-3); color: var(--text-secondary); font-size: var(--type-table-body-size); }
|
||||
.compliance-footprint__legend > span { display: inline-flex; align-items: center; gap: var(--space-2); }
|
||||
.compliance-footprint__legend .compliance-footprint__cell { width: calc(var(--space-5) + var(--bw-emphasis)); height: var(--space-4); }
|
||||
|
||||
/* BSI Kernel footprint: every requirement of a shown practice is a square with its number, like an Annex A cell;
|
||||
the practices stack at full width so their squares wrap across the tile. A requirement the ISO mapping cannot
|
||||
reach has a dashed outline, a reachable one without findings the solid outline of an empty Annex A cell. */
|
||||
.compliance-footprint__practices { display: grid; gap: var(--space-4); margin-block: var(--space-4); }
|
||||
.compliance-footprint__requirements { display: flex; flex-wrap: wrap; gap: calc(var(--space-1) - var(--bw-hairline)); }
|
||||
.compliance-footprint__requirements > .compliance-footprint__cell { min-width: calc(var(--space-8) + var(--space-1) + var(--bw-emphasis)); height: calc(var(--space-6) + var(--space-1) + var(--bw-emphasis)); padding: 0 var(--space-1); white-space: nowrap; }
|
||||
.compliance-footprint__cell.is-unreachable { border-style: dashed; }
|
||||
.compliance-footprint__pending { margin: var(--space-4) 0; color: var(--text-secondary); font-size: var(--type-body-size); }
|
||||
/* Show all window: every practice, with larger squares and the 14px data role for the numbers. */
|
||||
.compliance-footprint--window { min-width: 0; }
|
||||
.compliance-footprint__practices.is-window .compliance-footprint__requirements { gap: var(--space-1); }
|
||||
.compliance-footprint__practices.is-window .compliance-footprint__requirements > .compliance-footprint__cell { min-width: calc(var(--space-8) + var(--space-4)); height: calc(var(--space-8) + var(--space-1)); padding: 0 var(--space-2); font-size: var(--type-table-body-size); }
|
||||
.compliance-footprint__footer { margin-top: auto; }
|
||||
/* Pinned to the bottom of the window body (its 20px inset included), on the window surface, while the grid scrolls. */
|
||||
.compliance-footprint--window .compliance-footprint__footer { position: sticky; bottom: calc(var(--panel-inset) * -1); padding-bottom: var(--panel-inset); background: var(--bg); }
|
||||
/* `Mapping failed` with findings opens Work: one click target over the whole legend row; hover changes the surface only. */
|
||||
.compliance-coverage__legend > div.is-actionable { position: relative; cursor: pointer; }
|
||||
.compliance-coverage__legend > div.is-actionable:hover { background: var(--tile-surface-hover); }
|
||||
.compliance-coverage__legend > div.is-actionable:has(button:focus-visible) { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(var(--bw-emphasis) * -1); }
|
||||
.compliance-coverage__legend dd button { padding: 0; border: 0; background: transparent; color: inherit; font: inherit; font-variant-numeric: tabular-nums; cursor: pointer; }
|
||||
.compliance-coverage__legend dd button::after { content: ''; position: absolute; inset: 0; }
|
||||
.compliance-coverage__legend dd button:focus-visible { outline: none; }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { readEnginePreferences, useEnginePreferences } from '../../app/engine-preferences';
|
||||
import { ClientContextHeatmap } from '../context-layer/ContextHeatmap';
|
||||
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 { useAssessmentContext } from '../context-layer/use-assessment-context';
|
||||
import { publishContextReport } from '../../assessment-context/context-report-publication';
|
||||
|
|
@ -1529,7 +1529,20 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
|||
setStatusFilter('deviation');
|
||||
}
|
||||
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)) {
|
||||
return <DefenderVulnerabilityPanel dataset={dataset} navigation={clientInfoTabs} reviews={reviews}
|
||||
scopedRiskAcceptances={app.scopedRiskAcceptances} activeScanRunIds={app.activeScanRunIds}
|
||||
|
|
|
|||
|
|
@ -37,10 +37,12 @@ const onOpenControl = vi.fn();
|
|||
const onOpenTheme = vi.fn();
|
||||
const onOpenBsiRequirement = vi.fn();
|
||||
const onOpenBsiPractice = vi.fn();
|
||||
const onOpenMappingState = vi.fn();
|
||||
const render = () => act(async () => {
|
||||
root.render(<ClientIsoControls projectId="dummy-project" assessmentIds={['dummy-assessment']} scanRunIds={[]}
|
||||
backendStatus="connected" onOpenControl={onOpenControl} onOpenTheme={onOpenTheme}
|
||||
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));
|
||||
});
|
||||
|
||||
|
|
@ -53,6 +55,7 @@ beforeEach(() => {
|
|||
onOpenTheme.mockReset();
|
||||
onOpenBsiRequirement.mockReset();
|
||||
onOpenBsiPractice.mockReset();
|
||||
onOpenMappingState.mockReset();
|
||||
host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
|
|
@ -76,10 +79,10 @@ describe('Client Info ISO controls', () => {
|
|||
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue(overview);
|
||||
await render();
|
||||
expect(request).toHaveBeenCalledWith('dummy-project', { assessmentIds: ['dummy-assessment'], scanRunIds: [] });
|
||||
expect(host.querySelector('.client-compliance-header > h2')?.textContent).toBe('Compliance');
|
||||
expect(host.querySelector('.client-compliance-heading > h2')?.textContent).toBe('Compliance');
|
||||
// Same tile geometry as the environment row.
|
||||
expect(host.querySelector('.client-compliance-grid > section.client-env-group')).not.toBeNull();
|
||||
expect(host.querySelector('.client-env-group-title')?.textContent).toBe('ISO 27001/27002');
|
||||
expect(host.querySelector('.client-compliance-grid .client-env-group-title')?.textContent).toBe('ISO 27001/27002');
|
||||
// All four themes in ISO order; a finding counts once per theme; nothing else of the list is on the tile.
|
||||
expect(facts()).toEqual([['Controls', '3'], ['Findings', '3'], ['Organizational', '1 finding'], ['People', '0 findings'],
|
||||
['Physical', '0 findings'], ['Technological', '3 findings']]);
|
||||
|
|
@ -168,7 +171,14 @@ describe('Client Info ISO controls', () => {
|
|||
// The BSI tile opens Work the same way through the lower-case BSI facet values.
|
||||
expect(drilldown).toContain('setFacetFilter(BSI_REQUIREMENT_FACET_ID, { values: [requirementId.toLowerCase()] })');
|
||||
expect(drilldown).toContain('setFacetFilter(BSI_PRACTICE_FACET_ID, { values: [practiceId.toLowerCase()] })');
|
||||
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');
|
||||
// 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 });
|
||||
|
|
@ -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
|
||||
// mapping. Synthetic requirement ids, titles and statements; the findings come from the ISO controls above.
|
||||
const bsi: NonNullable<RegulatoryControlOverviewResponse['bsi']> = {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import {
|
|||
BSI_REQUIREMENT_FACET_ID,
|
||||
complianceWorkFacetIndex,
|
||||
ISO_CONTROL_FACET_ID,
|
||||
ISO_MAPPING_FACET_ID,
|
||||
ISO_MAPPING_FAILED,
|
||||
ISO_MAPPING_NO_BSI,
|
||||
ISO_THEME_FACET_ID,
|
||||
resetComplianceWorkFacetsForTests,
|
||||
useComplianceWorkFacets
|
||||
|
|
@ -60,7 +63,8 @@ describe('Work ISO control and ISO theme facets', () => {
|
|||
const facets = selectWorkFacets(workSheet(), options());
|
||||
const ids = facets.map(facet => facet.id);
|
||||
expect(ids.at(-1)).toBe('provenance:source-scan');
|
||||
expect(ids.slice(-3, -1)).toEqual([ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID]);
|
||||
// Nothing reaches BSI in this fixture, so ISO mapping offers `ISO control, no BSI requirement` after ISO theme.
|
||||
expect(ids.slice(-4, -1)).toEqual([ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID, ISO_MAPPING_FACET_ID]);
|
||||
const control = facets.find(facet => facet.id === ISO_CONTROL_FACET_ID)!;
|
||||
expect(control.label).toBe('ISO control');
|
||||
expect(control.values.map(value => [value.label, value.count])).toEqual([['A.5.15', 1], ['A.8.20 · Networks security', 3]]);
|
||||
|
|
@ -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]]);
|
||||
});
|
||||
|
||||
it('offers ISO mapping: Mapping failed only while a mapping failed, and filters to exactly those records', () => {
|
||||
const sheet = workSheet();
|
||||
sheet.analysis.records[3].findingOccurrenceId = 'dummy-unmapped';
|
||||
const failed = { ...assignments, failedFindings: [
|
||||
{ resultProjectionId: 'scanner-finding:dummy-unmapped', sourceKind: 'scanner-finding' as const, sourceResultId: 'dummy-unmapped' }] };
|
||||
const withFailed = { ...options(), complianceFacets: complianceWorkFacetIndex(failed) };
|
||||
const ids = selectWorkFacets(sheet, withFailed).map(facet => facet.id);
|
||||
expect(ids.slice(-4)).toEqual([ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID, ISO_MAPPING_FACET_ID, 'provenance:source-scan']);
|
||||
const facet = selectWorkFacets(sheet, withFailed).find(item => item.id === ISO_MAPPING_FACET_ID)!;
|
||||
expect(facet.label).toBe('ISO mapping');
|
||||
expect(facet.values.map(value => [value.value, value.label, value.count])).toEqual([[ISO_MAPPING_FAILED, 'Mapping failed', 1],
|
||||
[ISO_MAPPING_NO_BSI, 'ISO control, no BSI requirement', 3]]);
|
||||
const titles = filterAndSortRecords(sheet, { ...withFailed, statusFilter: 'deviation',
|
||||
facetFilters: { [ISO_MAPPING_FACET_ID]: { values: [ISO_MAPPING_FAILED] } }, sortColumn: '', sortDirection: 'asc' }).map(record => record.title);
|
||||
expect(titles).toEqual(['Dummy unmapped check']);
|
||||
// Without failed mappings and without a BSI derivation gap there is no such filter.
|
||||
const allReachBsi = { ...assignments, bsi: { catalogVersion: '2026-01-01', crosswalkVersion: '0.1-synthetic', license: 'Synthetic.',
|
||||
practices: [{ id: 'KONF', title: 'Konfiguration' }], missingTargets: [], requirements: [
|
||||
{ requirementId: 'KONF.1.1', title: 'Synthetisch', practice: 'KONF', statement: 'Synthetisch.', via: [{ isoControlId: '8.20', relationship: 'subset-of' as const },
|
||||
{ isoControlId: '5.15', relationship: 'subset-of' as const }] }] } };
|
||||
expect(selectWorkFacets(sheet, { ...options(), complianceFacets: complianceWorkFacetIndex(allReachBsi) }).some(item => item.id === ISO_MAPPING_FACET_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it('offers ISO mapping: ISO control, no BSI requirement for records whose accepted controls reach no BSI requirement', () => {
|
||||
const sheet = workSheet();
|
||||
// Only 8.20 reaches a BSI requirement; Dummy check B also has 5.15 and therefore one.
|
||||
const partial = { ...assignments, bsi: { catalogVersion: '2026-01-01', crosswalkVersion: '0.1-synthetic', license: 'Synthetic.',
|
||||
practices: [{ id: 'KONF', title: 'Konfiguration' }], missingTargets: [], requirements: [
|
||||
{ requirementId: 'KONF.1.1', title: 'Synthetisch', practice: 'KONF', statement: 'Synthetisch.', via: [{ isoControlId: '5.15', relationship: 'subset-of' as const }] }] } };
|
||||
const withPartial = { ...options(), complianceFacets: complianceWorkFacetIndex(partial) };
|
||||
const facet = selectWorkFacets(sheet, withPartial).find(item => item.id === ISO_MAPPING_FACET_ID)!;
|
||||
expect(facet.values.map(value => [value.value, value.label, value.count])).toEqual([[ISO_MAPPING_NO_BSI, 'ISO control, no BSI requirement', 2]]);
|
||||
const titles = filterAndSortRecords(sheet, { ...withPartial, facetFilters: { [ISO_MAPPING_FACET_ID]: { values: [ISO_MAPPING_NO_BSI] } },
|
||||
sortColumn: '', sortDirection: 'asc' }).map(record => record.title);
|
||||
expect(titles).toEqual(['Dummy check A', 'Dummy GPO setting']);
|
||||
});
|
||||
|
||||
it('filters Work records by control and theme, matching scanner occurrences and GPO settings', () => {
|
||||
const sheet = workSheet();
|
||||
const titles = (filters: object, status = 'all') => filterAndSortRecords(sheet, { ...options(status, filters), sortColumn: '', sortDirection: 'asc' })
|
||||
|
|
@ -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 () => { await new Promise(resolve => setTimeout(resolve, 0)); });
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect([...host.querySelectorAll('[data-iso-facets]')].map(node => node.textContent)).toEqual(['2,2', '2,2']);
|
||||
expect([...host.querySelectorAll('[data-iso-facets]')].map(node => node.textContent)).toEqual(['2,2,1', '2,2,1']);
|
||||
await act(async () => { markRegulatoryDecisionSaved(); await new Promise(resolve => setTimeout(resolve, 0)); });
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { acceptedControlIds, REGULATORY_GPO_QUERY_VERSION, REGULATORY_MAPPING_PR
|
|||
import type { RegulatoryChunk, RegulatoryRetrievalRequest } from '../../src/compliance/regulatory-rag';
|
||||
import { filterUnifiedResults, type UnifiedResultProjection } from '../../src/domain/unified-result';
|
||||
import type { ComplianceReadPort, ComplianceStore } from '../../src/compliance/compliance-store';
|
||||
import { LocalComplianceStore } from '../../src/compliance/local-compliance-store';
|
||||
|
||||
type EngineRepository = ComplianceReadPort & ComplianceStore;
|
||||
import { removeResultDbScopeIdentifiers } from '../../src/domain/result-db-disclosure';
|
||||
|
|
@ -220,6 +221,105 @@ describe('regulatory mapping service', () => {
|
|||
await expect(svc.controlOverview('project-1', { assessmentIds: ['foreign-assessment'], scanRunIds: [] })).rejects.toThrow('outside this project');
|
||||
});
|
||||
|
||||
it('counts every mappable deviation of the timeslot once in one mapping state for the coverage overview', async () => {
|
||||
const rule = (id: string, sourceRuleId: string, title: string) =>
|
||||
finding({ resultProjectionId: `scanner-finding:${id}`, sourceResultId: id, sourceRuleId, title });
|
||||
const findings = [
|
||||
finding(), finding({ resultProjectionId: 'scanner-finding:occurrence-2', sourceResultId: 'occurrence-2' }),
|
||||
rule('rejected', 'A-Rejected', 'SMB signing is not enforced'),
|
||||
rule('failed', 'A-Failed', 'LDAP signing is not required'),
|
||||
rule('unmapped', 'A-Unmapped', 'Print spooler runs on domain controllers'),
|
||||
finding({ resultProjectionId: 'scanner-finding:passed', sourceResultId: 'passed', sourceRuleId: 'A-Passed', isDeviation: false })
|
||||
];
|
||||
const repository = mockRepository(findings);
|
||||
const mapped = { value: { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: '5.1', relationship: 'primary', confidence: 'high',
|
||||
rationale: 'Synthetic rationale.', evidenceChunkIds: ['semantic-5.1', 'reference-5.1'] }] } };
|
||||
const svc = service(repository, vi.fn(async () => mapped));
|
||||
for (const resultProjectionId of ['scanner-finding:occurrence-1', 'scanner-finding:rejected']) {
|
||||
await svc.start('project-1', { resultProjectionId });
|
||||
await svc.close();
|
||||
}
|
||||
const accepted = (await svc.get('project-1', 'scanner-finding:rejected')).decisions[0];
|
||||
await svc.decide('project-1', { resultProjectionId: 'scanner-finding:rejected', controlId: '5.1', decision: 'rejected', expectedLatestDecisionId: accepted.id });
|
||||
const failing = service(repository, vi.fn(async () => { throw new Error('Synthetic model outage.'); }));
|
||||
await failing.start('project-1', { resultProjectionId: 'scanner-finding:failed' });
|
||||
await failing.close();
|
||||
expect((await svc.get('project-1', 'scanner-finding:failed')).state).toBe('failed');
|
||||
|
||||
const scope = { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'] };
|
||||
const overview = await svc.controlOverview('project-1', scope);
|
||||
// Accepted equals the ISO tile's finding count; the passed result is no deviation and stays out.
|
||||
expect(overview.coverage).toEqual({ deviations: 5, accepted: 2, mappedNoneAccepted: 1, failed: 1, notMapped: 1 });
|
||||
expect(overview.findingCount).toBe(overview.coverage?.accepted);
|
||||
// The Work facet assignments carry no coverage and no footprint, but the failed findings for the `ISO mapping` filter;
|
||||
// the not-mapped passed result is no failure.
|
||||
const assignments = await svc.controlAssignments('project-1', scope);
|
||||
expect(assignments.coverage).toBeUndefined();
|
||||
expect(assignments.bsiFootprint).toBeUndefined();
|
||||
expect(assignments.failedFindings?.map(item => item.resultProjectionId)).toEqual(['scanner-finding:failed']);
|
||||
expect(overview.failedFindings).toBeUndefined();
|
||||
// The BSI footprint lists every Kernel requirement of the active catalog, whatever is derived for this client.
|
||||
const footprint = overview.bsiFootprint!;
|
||||
expect(footprint.practices).toHaveLength(14);
|
||||
expect(footprint.practices.flatMap(practice => practice.requirements)).toHaveLength(901);
|
||||
expect(footprint.practices.flatMap(practice => practice.requirements).filter(requirement => requirement.reachable)).toHaveLength(272);
|
||||
expect(footprint.practices.find(practice => practice.id === 'KONF')?.requirements.find(item => item.id === 'KONF.2.1')?.reachable).toBe(true);
|
||||
// Every requirement carries its published title, including unreachable ones.
|
||||
expect(footprint.practices.flatMap(practice => practice.requirements).every(item => item.title.trim().length > 0)).toBe(true);
|
||||
expect(footprint.practices.flatMap(practice => practice.requirements).find(item => !item.reachable)?.title).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects a control accepted by an earlier run after a remap of the same version no longer proposes it (regression)', async () => {
|
||||
// Real engine store: a remap with the same pipeline versions overwrites the rule's only run.
|
||||
let proposed = '5.1';
|
||||
const svc = new RegulatoryMappingService(mockRepository([finding()]), new LocalComplianceStore(), {
|
||||
findingRetrieval: new RegulatoryFindingRetrievalService({ retrieve: vi.fn(async () => [{ score: 0.9, chunk: chunk('ISO/IEC 27002', proposed) }]) }),
|
||||
index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list: vi.fn(async () => [chunk('ISO/IEC 27001', proposed)]) },
|
||||
client: { model: 'work-model', complete: vi.fn(async () => ({ value: { schemaVersion: 1, outcome: 'mapped', mappings: [{ controlId: proposed,
|
||||
relationship: 'primary', confidence: 'high', rationale: 'Synthetic rationale.', evidenceChunkIds: [`semantic-${proposed}`, `reference-${proposed}`] }] } })) as never },
|
||||
corpusId: 'iso-test', corpusVersion: 'v2', embeddingModel: 'bge-m3', now: () => new Date(stamp)
|
||||
});
|
||||
const id = 'scanner-finding:occurrence-1';
|
||||
await svc.start('project-1', { resultProjectionId: id });
|
||||
await svc.close();
|
||||
const accepted = (await svc.get('project-1', id)).decisions.find(item => item.controlId === '5.1')!;
|
||||
expect(accepted.decision).toBe('accepted');
|
||||
// Remap: the model now proposes only 8.9; 5.1 remains an earlier decision.
|
||||
proposed = '8.9';
|
||||
await svc.start('project-1', { resultProjectionId: id });
|
||||
await svc.close();
|
||||
const remapped = await svc.get('project-1', id);
|
||||
expect(remapped.run?.candidates.map(candidate => candidate.controlId)).toEqual(['8.9']);
|
||||
expect(remapped.earlierControls?.map(control => control.controlId)).toEqual(['5.1']);
|
||||
|
||||
const rejected = await svc.decide('project-1', { resultProjectionId: id, controlId: '5.1', decision: 'rejected', expectedLatestDecisionId: accepted.id });
|
||||
expect(rejected.decisions.find(item => item.controlId === '5.1' && item.decision === 'rejected')).toMatchObject({ previousDecisionId: accepted.id });
|
||||
expect(acceptedControlIds(rejected.decisions)).toEqual(['8.9']);
|
||||
// A control no run ever proposed for this rule stays undecidable.
|
||||
await expect(svc.decide('project-1', { resultProjectionId: id, controlId: '7.4', decision: 'accepted' })).rejects.toThrow('not a grounded candidate');
|
||||
});
|
||||
|
||||
it('names every Annex A control from the reference documents of the active corpus, cached per corpus version', async () => {
|
||||
const list = vi.fn(async (filters: { retrievalRole?: string }, _limit: number) => filters.retrievalRole === 'reference'
|
||||
? [chunk('ISO/IEC 27001', '5.1'), chunk('ISO/IEC 27001', '8.9'), { ...chunk('ISO/IEC 27001', '8.9'), title: 'Second chunk of 8.9' }] : []);
|
||||
const svc = service(mockRepository([finding()]), vi.fn(), '5.1',
|
||||
{ index: { ensureCollection: vi.fn(), replaceDocuments: vi.fn(), searchDense: vi.fn(), list } });
|
||||
const scope = { assessmentIds: ['assessment-1'], scanRunIds: ['run-1'] };
|
||||
expect((await svc.controlOverview('project-1', scope)).annexATitles).toEqual({ '5.1': 'Synthetic control', '8.9': 'Synthetic control' });
|
||||
// Only titles of the active corpus version, read once for repeated overviews.
|
||||
const referenceReads = () => list.mock.calls.filter(([filters]) => filters.retrievalRole === 'reference');
|
||||
expect(referenceReads()[0]).toEqual([{ corpusId: 'iso-test', corpusVersion: 'v2', sourceStandard: 'ISO/IEC 27001', retrievalRole: 'reference' }, 100]);
|
||||
await svc.controlOverview('project-1', scope);
|
||||
expect(referenceReads()).toHaveLength(1);
|
||||
// The Work facet assignments carry no titles.
|
||||
expect((await svc.controlAssignments('project-1', scope)).annexATitles).toBeUndefined();
|
||||
// A new corpus version is read again; an unavailable index yields no titles and is retried next time.
|
||||
await svc.activateCorpus('iso-test', 'v3');
|
||||
list.mockRejectedValueOnce(new Error('Synthetic index outage.'));
|
||||
expect((await svc.controlOverview('project-1', scope)).annexATitles).toEqual({});
|
||||
expect((await svc.controlOverview('project-1', scope)).annexATitles).toEqual({ '5.1': 'Synthetic control', '8.9': 'Synthetic control' });
|
||||
});
|
||||
|
||||
it('derives BSI Kernel requirements from accepted ISO controls in the finding, overview and Work responses', async () => {
|
||||
const repository = mockRepository([finding(), finding({ resultProjectionId: 'scanner-finding:occurrence-2', sourceResultId: 'occurrence-2' })]);
|
||||
const svc = service(repository, vi.fn(async () => ({ value: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue