184 lines
12 KiB
TypeScript
184 lines
12 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { AssessorApiClient } from '../../api/assessor-api-client';
|
|
import { useApp } from '../../app/AppProvider';
|
|
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 { RegulatoryControlFinding, RegulatoryControlOverviewResponse, RegulatoryControlTheme } from '../regulatory-mapping';
|
|
import { useRegulatoryDecisionRevision } from './decision-revision';
|
|
|
|
/** Work facets of the Compliance Engine; present only while the engine is on and its assignments are loaded. */
|
|
export const ISO_CONTROL_FACET_ID = 'compliance:iso-control';
|
|
export const ISO_THEME_FACET_ID = 'compliance:iso-theme';
|
|
export const BSI_REQUIREMENT_FACET_ID = 'compliance:bsi-requirement';
|
|
export const BSI_PRACTICE_FACET_ID = 'compliance:bsi-practice';
|
|
export const ISO_MAPPING_FACET_ID = 'compliance:iso-mapping';
|
|
/** `ISO mapping` facet values: the rule's latest mapping failed and nothing is accepted. */
|
|
export const ISO_MAPPING_FAILED = 'failed';
|
|
/** `ISO mapping` facet values: accepted ISO controls, but none reaches a BSI requirement through the BSI mapping. */
|
|
export const ISO_MAPPING_NO_BSI = 'no-bsi';
|
|
export type ComplianceMappingState = typeof ISO_MAPPING_FAILED | typeof ISO_MAPPING_NO_BSI;
|
|
export const COMPLIANCE_FACET_IDS = [ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID, ISO_MAPPING_FACET_ID, BSI_REQUIREMENT_FACET_ID, BSI_PRACTICE_FACET_ID] as const;
|
|
const SOURCE_SCAN_FACET_ID = 'provenance:source-scan';
|
|
const THEMES: RegulatoryControlTheme[] = ['Organizational', 'People', 'Physical', 'Technological'];
|
|
|
|
export interface ComplianceWorkFacetIndex {
|
|
facets: DynamicFacet[];
|
|
}
|
|
|
|
const indexes = new WeakMap<RegulatoryControlOverviewResponse, ComplianceWorkFacetIndex>();
|
|
|
|
/** One shared index per loaded response, so every Work consumer uses identical cache keys. */
|
|
export function complianceWorkFacetIndex(response: RegulatoryControlOverviewResponse): ComplianceWorkFacetIndex {
|
|
const existing = indexes.get(response);
|
|
if (existing) return existing;
|
|
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));
|
|
const addFinding = (finding: RegulatoryControlFinding, value: string, occurrences: Map<string, Set<string>>, settings: Map<string, Set<string>>) => {
|
|
if (finding.sourceKind === 'scanner-finding') add(occurrences, finding.sourceResultId, value);
|
|
else if (finding.sourceRuleId) add(settings, `${finding.policyScope ?? ''}\u0000${finding.sourceRuleId}`, value);
|
|
};
|
|
for (const control of response.controls) for (const finding of control.findings) addFinding(finding, control.controlId, byOccurrence, bySetting);
|
|
// Scanner findings match by occurrence; GPO settings by scope and technical setting identity, like Result DB drill-downs.
|
|
const valuesOf = (occurrences: Map<string, Set<string>>, settings: Map<string, Set<string>>) => (record: FindingRecord): string[] => {
|
|
if (record.findingOccurrenceId) return [...occurrences.get(record.findingOccurrenceId) ?? []];
|
|
const identity = gpoWorkSettingIdentity(record.id);
|
|
return identity ? [...settings.get(`${identity.scope ?? ''}\u0000${identity.settingId}`) ?? []] : [];
|
|
};
|
|
const controlsFor = valuesOf(byOccurrence, bySetting);
|
|
const failedOccurrences = new Map<string, Set<string>>();
|
|
const failedSettings = new Map<string, Set<string>>();
|
|
for (const finding of response.failedFindings ?? []) addFinding(finding, ISO_MAPPING_FAILED, failedOccurrences, failedSettings);
|
|
const themeOf = new Map(response.controls.map(control => [control.controlId, control.theme]));
|
|
// BSI requirements derived from the accepted ISO controls through the BSI mapping (Phase 13F, stage 1).
|
|
const bsi = response.bsi;
|
|
const requirementsByControl = new Map<string, string[]>();
|
|
for (const requirement of bsi?.requirements ?? []) {
|
|
for (const via of requirement.via) requirementsByControl.set(via.isoControlId, [...requirementsByControl.get(via.isoControlId) ?? [], requirement.requirementId]);
|
|
}
|
|
const practiceOf = new Map((bsi?.requirements ?? []).map(requirement => [requirement.requirementId, requirement.practice]));
|
|
const requirementsFor = (record: FindingRecord): string[] => [...new Set(controlsFor(record).flatMap(controlId => requirementsByControl.get(controlId) ?? []))];
|
|
const controls = [...response.controls].sort((left, right) => left.controlId.localeCompare(right.controlId, 'en', { numeric: true }));
|
|
const failedFor = valuesOf(failedOccurrences, failedSettings);
|
|
// A finding has no BSI requirement when none of its accepted controls reaches one; offered only with hits.
|
|
const hasNoBsi = [...byOccurrence.values(), ...bySetting.values()]
|
|
.some(controlIds => [...controlIds].every(controlId => !requirementsByControl.has(controlId)));
|
|
const mappingValues = [
|
|
...(response.failedFindings?.length ? [{ value: ISO_MAPPING_FAILED, label: 'Mapping failed', count: 0 }] : []),
|
|
...(hasNoBsi ? [{ value: ISO_MAPPING_NO_BSI, label: 'ISO control, no BSI requirement', count: 0 }] : [])];
|
|
const index: ComplianceWorkFacetIndex = {
|
|
facets: [{
|
|
id: ISO_CONTROL_FACET_ID,
|
|
label: 'ISO control',
|
|
source: 'derived',
|
|
kind: 'category',
|
|
description: 'Compliance Engine: findings and GPO settings with this accepted ISO/IEC 27002 control. It does not change results.',
|
|
values: controls.map(control => ({ value: control.controlId, label: `${control.annexAControlId}${control.title ? ` · ${control.title}` : ''}`, count: 0 })),
|
|
valuesFor: controlsFor
|
|
}, {
|
|
id: ISO_THEME_FACET_ID,
|
|
label: 'ISO theme',
|
|
source: 'derived',
|
|
kind: 'category',
|
|
description: 'Compliance Engine: findings and GPO settings with an accepted ISO/IEC 27002 control of this theme. It does not change results.',
|
|
// Facet values are matched in lower case; the label keeps the ISO spelling.
|
|
values: THEMES.filter(theme => response.controls.some(control => control.theme === theme))
|
|
.map(theme => ({ value: theme.toLowerCase(), label: theme, count: 0 })),
|
|
valuesFor: record => [...new Set(controlsFor(record).flatMap(controlId => themeOf.get(controlId)?.toLowerCase() ?? []))]
|
|
},
|
|
// Present only while one of its values has hits, so the Client Info coverage rows open exactly these in Work.
|
|
...(mappingValues.length ? [{
|
|
id: ISO_MAPPING_FACET_ID,
|
|
label: 'ISO mapping',
|
|
source: 'derived',
|
|
kind: 'category',
|
|
description: 'Compliance Engine: findings and GPO settings whose latest ISO mapping failed (map them again from the Finding Reader), or whose accepted ISO controls reach no BSI requirement. It does not change results.',
|
|
values: mappingValues,
|
|
valuesFor: record => [...failedFor(record), ...(controlsFor(record).length && !requirementsFor(record).length ? [ISO_MAPPING_NO_BSI] : [])]
|
|
}] satisfies DynamicFacet[] : []),
|
|
// The BSI facets appear only when a requirement is derived, so a client without one gets no empty controls.
|
|
...(bsi?.requirements.length ? [{
|
|
id: BSI_REQUIREMENT_FACET_ID,
|
|
label: 'BSI requirement',
|
|
source: 'derived',
|
|
kind: 'category',
|
|
description: 'Compliance Engine: findings and GPO settings with a BSI Grundschutz++ requirement derived from their accepted ISO controls through the BSI mapping. It does not change results.',
|
|
// Values are matched in lower case, like the ISO theme; the label keeps the BSI spelling.
|
|
values: bsi.requirements.map(requirement => ({ value: requirement.requirementId.toLowerCase(), label: `${requirement.requirementId} · ${requirement.title}`, count: 0 })),
|
|
valuesFor: record => requirementsFor(record).map(requirementId => requirementId.toLowerCase())
|
|
}, {
|
|
id: BSI_PRACTICE_FACET_ID,
|
|
label: 'BSI practice',
|
|
source: 'derived',
|
|
kind: 'category',
|
|
description: 'Compliance Engine: findings and GPO settings with a derived BSI Grundschutz++ requirement of this practice. It does not change results.',
|
|
// Catalog order; values are matched in lower case, the label keeps the BSI spelling.
|
|
values: bsi.practices.filter(practice => bsi.requirements.some(requirement => requirement.practice === practice.id))
|
|
.map(practice => ({ value: practice.id.toLowerCase(), label: `${practice.id} · ${practice.title}`, count: 0 })),
|
|
valuesFor: (record: FindingRecord) => [...new Set(requirementsFor(record).flatMap(requirementId => practiceOf.get(requirementId)?.toLowerCase() ?? []))]
|
|
}] satisfies DynamicFacet[] : [])]
|
|
};
|
|
indexes.set(response, index);
|
|
return index;
|
|
}
|
|
|
|
/** Adds the ISO and BSI facets directly before the permanent final `Source: Scan` control. */
|
|
export function withComplianceFacets(facets: DynamicFacet[], index: ComplianceWorkFacetIndex | undefined): DynamicFacet[] {
|
|
if (!index) return facets;
|
|
const sourceScan = facets.findIndex(facet => facet.id === SOURCE_SCAN_FACET_ID);
|
|
return sourceScan < 0 ? [...facets, ...index.facets]
|
|
: [...facets.slice(0, sourceScan), ...index.facets, ...facets.slice(sourceScan)];
|
|
}
|
|
|
|
const assessorApi = new AssessorApiClient();
|
|
// One request per timeslot and decision revision, shared by every Work consumer.
|
|
const requests = new Map<string, Promise<RegulatoryControlOverviewResponse>>();
|
|
const ready = new Map<string, RegulatoryControlOverviewResponse>();
|
|
const lastReadyByScope = new Map<string, RegulatoryControlOverviewResponse>();
|
|
|
|
/** The ISO facet index for Work, or undefined while the engine is off or nothing is loaded yet. */
|
|
export function useComplianceWorkFacets(): ComplianceWorkFacetIndex | undefined {
|
|
const app = useApp();
|
|
const available = useEnginePreferences().compliance;
|
|
const revision = useRegulatoryDecisionRevision();
|
|
const assessmentIds = [...(app.activeAssessmentIds ?? [])].sort();
|
|
const scanRunIds = [...(app.activeScanRunIds ?? [])].sort();
|
|
const projectId = app.targetProjectId;
|
|
const scope = JSON.stringify([projectId, assessmentIds, scanRunIds]);
|
|
const key = `${scope}\u0000${revision}`;
|
|
const enabled = available && app.backendStatus === 'connected' && Boolean(projectId) && (assessmentIds.length > 0 || scanRunIds.length > 0);
|
|
const [, setLoaded] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!enabled || !projectId || ready.has(key)) return;
|
|
let active = true;
|
|
let request = requests.get(key);
|
|
if (!request) {
|
|
request = assessorApi.getRegulatoryControlAssignments(projectId, { assessmentIds, scanRunIds });
|
|
requests.set(key, request);
|
|
request.then(response => {
|
|
ready.set(key, response);
|
|
lastReadyByScope.set(scope, response);
|
|
while (ready.size > 8) ready.delete(ready.keys().next().value!);
|
|
while (lastReadyByScope.size > 4) lastReadyByScope.delete(lastReadyByScope.keys().next().value!);
|
|
}, () => undefined).finally(() => requests.delete(key));
|
|
}
|
|
request.then(() => { if (active) setLoaded(value => value + 1); }, () => undefined);
|
|
return () => { active = false; };
|
|
}, [enabled, key]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
if (!enabled) return undefined;
|
|
// While a new decision revision of the same timeslot loads, the last loaded state keeps an active filter valid.
|
|
const response = ready.get(key) ?? lastReadyByScope.get(scope);
|
|
return response ? complianceWorkFacetIndex(response) : undefined;
|
|
}
|
|
|
|
/** Test support. */
|
|
export function resetComplianceWorkFacetsForTests(): void {
|
|
requests.clear();
|
|
ready.clear();
|
|
lastReadyByScope.clear();
|
|
}
|