127 lines
6.8 KiB
TypeScript
127 lines
6.8 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 { RegulatoryControlOverviewResponse, RegulatoryControlTheme } from '../regulatory-mapping';
|
|
import { useRegulatoryDecisionRevision } from './decision-revision';
|
|
|
|
/** Work facets of the Compliance Engine; present only while the engine is on and its assignments are loaded. */
|
|
export const ISO_CONTROL_FACET_ID = 'compliance:iso-control';
|
|
export const ISO_THEME_FACET_ID = 'compliance:iso-theme';
|
|
export const COMPLIANCE_FACET_IDS = [ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID] as const;
|
|
const SOURCE_SCAN_FACET_ID = 'provenance:source-scan';
|
|
const THEMES: RegulatoryControlTheme[] = ['Organizational', 'People', 'Physical', 'Technological'];
|
|
|
|
export interface ComplianceWorkFacetIndex {
|
|
facets: [DynamicFacet, 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));
|
|
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 themeOf = new Map(response.controls.map(control => [control.controlId, control.theme]));
|
|
const controls = [...response.controls].sort((left, right) => left.controlId.localeCompare(right.controlId, 'en', { numeric: true }));
|
|
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() ?? []))]
|
|
}]
|
|
};
|
|
indexes.set(response, index);
|
|
return index;
|
|
}
|
|
|
|
/** Adds the ISO 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();
|
|
}
|