128 lines
6.7 KiB
TypeScript
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
|
|
};
|
|
})
|
|
};
|
|
}
|