Assessor_2.0/src/comparison/timeslot-domain-comparison.ts

128 lines
6.7 KiB
TypeScript

import type { ImportedDataset } from '../domain/dataset';
import type { FindingRecord } from '../domain/finding';
import { securityDomainBuckets, securityDomainCatalog } from '../evaluation/security-domains';
import { dashboardResultState, dashboardScoreFromStates, type DashboardResultState } from '../features/dashboard/dashboard-result-state';
import { profileForSheet } from '../features/project-import/workspace-profile';
import { toolIdForRecord } from '../importers/status-normalization';
import { gpoRecordSettingKey } from './gpo-record-key';
import { GPO_SOURCE_ID, type TimeslotChangeEntry, type TimeslotComparisonReport } from './timeslot-comparison-report';
export const TIMESLOT_DOMAIN_COMPARISON_VERSION = '1.0.0' as const;
export interface TimeslotDomainSide {
/** Client Info's domain value: pass rate over passed and deviating checks. */
score?: number;
passed: number;
deviations: number;
}
export interface TimeslotDomainRow {
id: string;
label: string;
/** Over the sources compared in both timeslots only, so A and B rest on the same population. */
baseline: TimeslotDomainSide;
target: TimeslotDomainSide;
/** Set when the domain has assessed checks in one timeslot only, from sources the other lacks. */
onlyIn?: 'baseline' | 'target';
/** Sources behind `onlyIn`. */
onlyInSources: string[];
resolved: number;
/** New plus regressed deviations. */
worse: number;
}
export interface TimeslotDomainComparison {
version: typeof TIMESLOT_DOMAIN_COMPARISON_VERSION;
/** Sources whose checks enter the domain values: compared in both timeslots. */
sources: string[];
domains: TimeslotDomainRow[];
}
interface SideRecords {
records: FindingRecord[];
states: Map<FindingRecord, DashboardResultState>;
sourceOf: Map<FindingRecord, string | undefined>;
domainOf: Map<FindingRecord, string>;
}
/** Client Info's records, states and domain assignment for one timeslot, from its project sheet. */
function sideRecords(dataset: ImportedDataset | null | undefined): SideRecords {
const sheet = dataset?.sheets.find(item => item.isProjectSheet) ?? dataset?.sheets[0];
const records = sheet?.analysis.records ?? [];
const states = new Map<FindingRecord, DashboardResultState>();
const sourceOf = new Map<FindingRecord, string | undefined>();
const domainOf = new Map<FindingRecord, string>();
if (!dataset || !sheet) return { records, states, sourceOf, domainOf };
const profile = profileForSheet(dataset, sheet);
for (const record of records) {
states.set(record, dashboardResultState(record, profile));
sourceOf.set(record, toolIdForRecord(record, sheet.toolId));
}
for (const [domainId, members] of securityDomainBuckets(records, sheet.toolId)) for (const record of members) domainOf.set(record, domainId);
return { records, states, sourceOf, domainOf };
}
/** The Work record of one timeslot behind a change: scanner records by occurrence, GPO settings by scope and identity. */
function recordFinder(side: SideRecords) {
const byOccurrence = new Map(side.records.flatMap(record => record.findingOccurrenceId ? [[record.findingOccurrenceId, record] as const] : []));
const bySetting = new Map(side.records.flatMap(record => { const key = gpoRecordSettingKey(record); return key ? [[key, record] as const] : []; }));
return (entry: TimeslotChangeEntry, ids: string[]) => entry.sourceId === GPO_SOURCE_ID
? (entry.identityKey ? bySetting.get(entry.identityKey) : undefined)
: ids.map(id => byOccurrence.get(id)).find(Boolean);
}
/**
* Security Domain values of two timeslots for the Compare tab (mockup A). Values use Client Info's records,
* result states, classifier and pass rate, restricted to sources compared in both timeslots (score delta only
* over the shared population). Resolved and worse counts place each change in the domain of its Work record.
*/
export function compareTimeslotDomains(report: TimeslotComparisonReport, baseline: ImportedDataset | null | undefined,
target: ImportedDataset | null | undefined): TimeslotDomainComparison {
const compared = new Set(report.sources.filter(item => item.state === 'compared').map(item => item.sourceId));
const a = sideRecords(baseline);
const b = sideRecords(target);
const findA = recordFinder(a);
const findB = recordFinder(b);
const resolved = new Map<string, number>();
const worse = new Map<string, number>();
const add = (counts: Map<string, number>, domain?: string) => { if (domain) counts.set(domain, (counts.get(domain) ?? 0) + 1); };
for (const entry of report.changes) {
if (entry.change === 'resolved') add(resolved, a.domainOf.get(findA(entry, entry.recordIds)!) ?? b.domainOf.get(findB(entry, entry.counterpartIds)!));
else if (entry.change === 'new' || entry.change === 'regressed') add(worse, b.domainOf.get(findB(entry, entry.recordIds)!));
}
const side = (records: SideRecords, domainId: string, shared: boolean) => {
const members = records.records.filter(record => records.domainOf.get(record) === domainId
&& (!shared || compared.has(records.sourceOf.get(record) ?? '')));
const score = dashboardScoreFromStates(members.map(record => records.states.get(record) ?? 'unknown'), members);
return { members, value: { ...(score.score === undefined ? {} : { score: score.score }), passed: score.passed, deviations: score.deviations } };
};
const assessedSources = (records: SideRecords, members: FindingRecord[]) => [...new Set(members
.filter(record => ['passed', 'deviation'].includes(records.states.get(record) ?? ''))
.flatMap(record => records.sourceOf.get(record) ?? []))].sort();
return {
version: TIMESLOT_DOMAIN_COMPARISON_VERSION,
sources: [...compared].sort(),
domains: securityDomainCatalog.map(({ id, label }) => {
const sharedA = side(a, id, true).value;
const sharedB = side(b, id, true).value;
const allA = side(a, id, false).members;
const allB = side(b, id, false).members;
const assessed = (value: TimeslotDomainSide) => value.passed + value.deviations > 0;
const sourcesA = assessedSources(a, allA);
const sourcesB = assessedSources(b, allB);
const onlyIn = assessed(sharedA) || assessed(sharedB) ? undefined
: sourcesB.length && !sourcesA.length ? 'target' as const : sourcesA.length && !sourcesB.length ? 'baseline' as const : undefined;
// A domain held only by one timeslot's sources shows that timeslot's own value; it has no delta.
return {
id, label,
baseline: onlyIn === 'baseline' ? side(a, id, false).value : sharedA,
target: onlyIn === 'target' ? side(b, id, false).value : sharedB,
...(onlyIn ? { onlyIn } : {}),
onlyInSources: onlyIn === 'target' ? sourcesB : onlyIn === 'baseline' ? sourcesA : [],
resolved: resolved.get(id) ?? 0, worse: worse.get(id) ?? 0
};
})
};
}