feat(comparison): change list, Security Domains and value changes (2.27.0)

- Compare tab: Work-style change list with a detail pane (Problem, Next step and
  an A/B Compare section), Open in Work and Open in timeslot A.
- Security Domains A -> B over the sources compared in both timeslots, with
  resolved against new-plus-regressed bars.
- Value changes for scanner findings: the Finding Reader's Problem values of
  A and B (Result, Affected, Results rows, Expected, Found), shown side by side
  and marked red or green like GPO configured values.
- GPO settings matched by Scope and Technical Identity; regressions carry their
  passing A occurrence.
- Loading: shared reads per repository revision and a revision-bound report,
  workspace and GPO overview cache in the comparison route.
This commit is contained in:
Binh 2026-10-14 09:33:37 +02:00
parent cedfccc903
commit dbfabdda66
29 changed files with 1310 additions and 127 deletions

View File

@ -2,7 +2,7 @@
## Comparison Engine: decision and staging (planned 2026-10-13, Phase 8 timeslot comparison as an engine)
Status: **steps 1, 2, 3a and 3b done (2026-10-13); step 4 (engine storage) and Security Domain deltas open**. Explicit user decision 2026-10-13: the timeslot comparison of [TIMESLOT_COMPARISON_PLAN.md](TIMESLOT_COMPARISON_PLAN.md) (P1-P6) becomes a separately switchable **Comparison Engine** that owns its comparison logic. Its folders follow the other engines without an `-engine` suffix: `src/comparison/`, `server/comparison/`, `tests/comparison/`. It reads the Core only through a declared read-only port and never writes, overrides or re-derives a Core verdict, score, review, risk acceptance or Result DB row. All contracts, the acceptance matrix and the conservative rules of the timeslot plan stay binding unchanged (object/scope identity, no `absent = resolved` without a declared profile negative-evidence contract, a missing source keeps its open deviations as unverified, score delta only over the shared population, UI is a consumer, never its own matcher).
Status: **steps 1, 2, 3a, 3b and the Security Domain deltas done (2026-10-13); step 4 (engine storage) open**. Explicit user decision 2026-10-13: the timeslot comparison of [TIMESLOT_COMPARISON_PLAN.md](TIMESLOT_COMPARISON_PLAN.md) (P1-P6) becomes a separately switchable **Comparison Engine** that owns its comparison logic. Its folders follow the other engines without an `-engine` suffix: `src/comparison/`, `server/comparison/`, `tests/comparison/`. It reads the Core only through a declared read-only port and never writes, overrides or re-derives a Core verdict, score, review, risk acceptance or Result DB row. All contracts, the acceptance matrix and the conservative rules of the timeslot plan stay binding unchanged (object/scope identity, no `absent = resolved` without a declared profile negative-evidence contract, a missing source keeps its open deviations as unverified, score delta only over the shared population, UI is a consumer, never its own matcher).
UI decision 2026-10-13 (mockups A and B in the local, git-ignored `artifacts/compare-engine/`; synthetic Timeslot A): entry is a Client Info tab `Compare` with A/B timeslot selection, change counts, Security Domain and source deltas, each count opening Work; Work receives a `Change` facet and change badges, and the Finding Reader a `Compare` section with A and B values side by side. A retained value change of a still deviating GPO setting is shown neutral (`Value changed`), neither better nor worse. Mockup C (better/worse bridge) is not selected.
@ -22,6 +22,10 @@ Step 3b, second part (Core release Governance, Presentation, Core Tests, Platfor
Corrections after the first real-data test (2026-10-13; engine paths plus Core release Ingest, Core Tests): (1) a scan run imported from several scanners (tool `multiple`) is split into one source per file scanner, and several sources of one scanner pair only by their verified scope (for example two PingCastle domains); before, both made every source not comparable. (2) Explicit user decision 2026-10-13 (supersedes the requirement of a verified newer observation time in TIMESLOT_COMPARISON_PLAN.md §3.2/§3.4 and its matrix row on missing scan times): the explicit timeslot order is the time basis; a source's own report or backup time blocks only when both timeslots state one and B's is not newer (same or older report: `target-observation-not-newer`, not comparable). Import times are still never used. Reason: real GPO backups state their backup time without a zone, which the Core parser drops, and CIS-CAT/Purple Knight times only restate the timeslot order. (3) Diagnostic policy `fail-closed-by-impact`: only codes known to leave identity, results and coverage untouched are exempt (`html-active-content-removed`, `unicode-null-escaped`, `cis-cat-summary-verified`, the five `pingcastle-linked-section-*` detail-link codes); every other or unknown code still blocks. (4) Explicit user decision 2026-10-13: PingCastle declares `negativeEvidence: complete-explicit-negative` (comparison contract 1.1.0, Ingest); a rule absent from B counts as remediated only with the same domain, the same PingCastle engine version (`report.engine_version`), compatible processing and no blocking diagnostic, otherwise it stays unverified. Comparison-contract change only, no extraction change, therefore no reimport (REIMPORT_POLICY.md). (5) The scanner score delta uses the engine's verified source pairs (one scan per side, same method, policy, weight and evaluated rule population); the Core labels `Scan identity or time not verified`, `Scanner version unavailable` and `Incomplete assessment` no longer block it, every other issue does. Test client `iso compare test`: the Dummy lab scans (same formats as `iso test`: Purple Knight and Maester Excel, PingCastle and CIS-CAT HTML, GPO folder) in 2026-07-22 and a changed copy in 2026-10-01 (later report times; Fail/Pass swaps inside one section or category so report totals stay consistent; four GPO password/lockout values; generated in git-ignored `artifacts/compare-engine/iso-copy/B`). (6) Explicit user decision 2026-10-13 (supersedes TIMESLOT_COMPARISON_PLAN.md §3.1 "missing identity is not the empty identity of a supposedly equal object" for this case only): both timeslots belong to one client, so when each timeslot holds exactly one report of a scanner and neither states its scope (the Maester Excel export has no tenant column), the client is the scope. A scope stated on one side only, or several reports of one scanner per side, never pairs this way. Result after all corrections: 9 resolved, 2 value changed, 7 regressed, 467 unchanged, nothing unverified or not comparable; overall score 36.1 -> 36.9 (GPO pass rate 50 -> 54 over 50 settings decided in both). Patch decision: 2.26.1 for these backward-compatible comparison corrections (Core release Platform, Core Tests), package/lock and UI/API version contracts synchronized.
Completion of mockups A and B (2026-10-13; engine paths plus Core release Platform, Presentation, Core Tests): (1) the Compare tab lists every change below the source table as Work-style rows (severity hexagon, title, change and source badges, rule ID with the A/B state, severity badge; filter by change, unchanged hidden by default). A row is selectable by click or arrow keys; beside the list the detail shows the change, the Work record's own Problem and Next step (the Finding Reader first read of timeslot B) and an open Compare section with A and B side by side (verdict, and for a GPO setting the configured and baseline values, contributing backups and Conflicts/Duplicates), followed by one sentence on the change; internal evidence codes are not shown. The Finding Reader Compare section uses the same content. Report entries carry title, rule ID, severity and per-side GPO facts. (2) `Open in Work` opens the exact Work record of timeslot B in the Reports view with status All; `Open in timeslot A` opens timeslot A and then its record of the same finding (scanner occurrence or GPO scope and technical identity). Extension point: DashboardPanel `onOpenFinding`. (3) Security Domains (mockup A): `GET /projects/:id/timeslot-comparison/domains` builds both timeslots' Work datasets with Client Info's loader (Platform adapter `loadTimeslotWorkspace`) and the engine computes Client Info's domain pass rate per domain over the sources compared in both timeslots (score delta only over the shared population); a domain held only by one timeslot's sources shows "Only in A/B · not comparable" without a delta. Resolved and new-plus-regressed counts place each change in the domain of its Work record. Verified on `iso compare test`: B values equal Client Info's Security Domain values (54, 52, 61, 17, 27, 26); counts reconcile to 12 resolved and 10 worse. (4) Loading: the engine read port shares reads per repository revision (models, policy evaluations, backups were loaded twice per request), the route shares the GPO overview call and caches report and domains per revision and exact A/B scope (bounded, cleared on a revision change). Report: 3.3 s -> 1.7 s for a new pair, about 20 ms when cached; domains about 5 s on first request, then cached. Minor decision: 2.27.0 for the completed Compare capability; package/lock and UI/API version contracts synchronized.
Scanner value changes (explicit user decision 2026-10-13; engine paths plus Core release Platform): a scanner finding that deviates in A and B is `Value changed` (neutral, like a GPO value change) when the Finding Reader's Problem values of its two Work records differ: Result, Affected, Results rows, Expected and Found (Impact is explanatory text and not compared). Each scanner change carries both timeslots' Problem values, shown side by side in the Compare section with a neutral marker on the differing value. The user chose that the report waits for these values: the comparison route builds both timeslots' Work datasets before answering (first request of a pair about 7 s, then cached per revision), and Security Domains reuse the same datasets. GPO settings in Work are matched by their `Scope` and `Technical Identity` fields (as the Core report projection does), because not every rolled-up setting's record ID carries them. Test data: timeslot B of `iso compare test` changes three Purple Knight result counts, two PingCastle detail tables (and the count in one rule title) and two Maester result counts, each for a finding failing in both timeslots.
Change log 2026-10-13: recorded the Comparison Engine decision, naming and staging; relocated the Core Phase 8 scan-run comparison and registered the Comparison Engine with its invariance test; step 3a engine comparison of scanners, GPO settings and score basis; step 3b read port, timeslot route, API client, Compare tab, Work Change facet, Finding Reader section and engine diagram; version 2.26.0.
## Results cluster foreground correction (2026-10-13, Phase 7 Presentation, 2.25.6)

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "assessor",
"version": "2.26.1",
"version": "2.27.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "assessor",
"version": "2.26.1",
"version": "2.27.0",
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"jsdom": "^29.1.1",

View File

@ -1,6 +1,6 @@
{
"name": "assessor",
"version": "2.26.1",
"version": "2.27.0",
"private": true,
"type": "module",
"scripts": {

View File

@ -70,7 +70,9 @@ import { remediationReadPortFrom, type RemediationReadPort } from '../src/remedi
import { MemoryRemediationStore, type RemediationStore } from './remediation/remediation-store';
import { comparisonReadPortFrom, type ComparisonReadPort } from '../src/comparison/comparison-read-port';
import { compareTimeslots } from '../src/comparison/timeslot-comparison-report';
import { loadTimeslotComparisonInput, type TimeslotSelection } from './timeslot-comparison-input';
import { compareTimeslotDomains } from '../src/comparison/timeslot-domain-comparison';
import { withScannerValueChanges } from '../src/comparison/timeslot-value-comparison';
import { loadTimeslotComparisonInput, loadTimeslotWorkspace, type TimeslotSelection } from './timeslot-comparison-input';
import { loadCanonicalProjectWorkspace } from '../src/features/project-import/load-canonical-project-workspace';
import { summarizeWorkspaceResults, type DatahubTimeslotResultMetrics } from '../src/features/project-import/datahub-scan-analysis';
import { profileForSheet } from '../src/features/project-import/workspace-profile';
@ -486,6 +488,18 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
const importSessions = new Map<string, ImportSession>();
const importBatchPreviews = new Map<string, CachedImportBatchPreview>();
const timeslotMetricsCache = new Map<string, Promise<DatahubTimeslotResultMetrics | null>>();
// Comparison Engine results per committed revision and exact A/B scope; bounded, a new revision never reuses one.
const comparisonCache = new Map<string, Promise<unknown>>();
const cachedComparison = <T>(key: string, compute: () => Promise<T>): Promise<T> => {
let pending = comparisonCache.get(key) as Promise<T> | undefined;
if (!pending) {
pending = compute();
comparisonCache.set(key, pending);
void pending.catch(() => comparisonCache.delete(key));
while (comparisonCache.size > 16) comparisonCache.delete(comparisonCache.keys().next().value!);
}
return pending;
};
// Context results per committed revision and exact scope; bounded, and a new revision never reuses an entry.
const assessmentContextCache = new Map<string, AssessmentContext>();
const auditEvents: AuditEvent[] = [];
@ -1175,7 +1189,8 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
await remediationStore.save(key, result);
status = 200; sendJson(response, status, { status: 'ready', result }, requestId); return;
}
if (segments[0] === 'projects' && segments[1] && segments[2] === 'timeslot-comparison' && segments.length === 3 && method === 'GET') {
if (segments[0] === 'projects' && segments[1] && segments[2] === 'timeslot-comparison' && method === 'GET'
&& (segments.length === 3 || (segments.length === 4 && segments[3] === 'domains'))) {
// Comparison Engine: explicit A/B comparison of two timeslots through its declared read-only port.
if (!comparisonPort) throw new HttpError(503, 'comparison_unavailable', 'Comparison Engine is not available.');
const projectId = segments[1];
@ -1193,33 +1208,52 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
throw new HttpError(400, 'timeslot_overlap', 'The two timeslots must not share a scan run.');
}
const revision = await comparisonPort.currentRevision();
// Both timeslots read the same project-wide GPO overview; one call serves them and later requests of the revision.
const overviewService = options.gpoPolicyAnalysisOverviewService;
const reader = {
listAssessments: (id: string) => comparisonPort.listAssessments(id),
loadAssessmentModel: (id: string) => comparisonPort.loadAssessmentModel(id),
queryUnifiedResults: (query?: UnifiedResultQuery) => comparisonPort.queryUnifiedResults(query),
listPolicyEvaluations: (id: string) => comparisonPort.listPolicyEvaluations(id),
listGpoBackupInstances: (id: string) => comparisonPort.listGpoBackupInstances(id),
...(options.gpoPolicyAnalysisOverviewService ? { getPolicyAnalysisOverview: (id: string) => options.gpoPolicyAnalysisOverviewService!.get(id) } : {})
...(overviewService ? { getPolicyAnalysisOverview: (id: string) =>
cachedComparison(JSON.stringify(['overview', revision, id]), () => overviewService.get(id)) } : {})
};
const [a, b] = await Promise.all([loadTimeslotComparisonInput(reader, projectId, baseline), loadTimeslotComparisonInput(reader, projectId, target)]);
// A must lie before B where both state an explicit assessment date; import times are no order.
// Without dates the engine's newer-observation rules still block every progress claim.
const datedTimes = (items: typeof a.assessments, other: string[]) => items.filter(item => !other.includes(item.id))
.flatMap(item => item.effectiveAt && Number.isFinite(Date.parse(item.effectiveAt)) ? [Date.parse(item.effectiveAt)] : []);
const before = datedTimes(a.assessments, target.assessmentIds);
const after = datedTimes(b.assessments, baseline.assessmentIds);
if (before.length && after.length && Math.max(...before) >= Math.min(...after)) {
throw new HttpError(400, 'timeslot_order_invalid', 'Timeslot A must lie before timeslot B.');
}
const report = compareTimeslots({
projectId, models: [...a.models, ...b.models], baselineRunIds: a.scanRunIds, targetRunIds: b.scanRunIds,
gpo: {
baseline: { results: a.gpoResults, ...(a.gpoObservedAt ? { observedAt: a.gpoObservedAt } : {}) },
target: { results: b.gpoResults, ...(b.gpoObservedAt ? { observedAt: b.gpoObservedAt } : {}) }
const scopeKey = JSON.stringify([revision, projectId, baseline, target]);
// Both timeslots' Work datasets, as Client Info builds them: scanner value changes and Security Domains read them.
const workspaces = () => cachedComparison(JSON.stringify(['workspaces', scopeKey]),
() => Promise.all([loadTimeslotWorkspace(reader, projectId, baseline), loadTimeslotWorkspace(reader, projectId, target)]));
const report = await cachedComparison(JSON.stringify(['report', scopeKey]), async () => {
const [a, b, [workA, workB]] = await Promise.all([
loadTimeslotComparisonInput(reader, projectId, baseline), loadTimeslotComparisonInput(reader, projectId, target), workspaces()]);
// A must lie before B where both state an explicit assessment date; import times are no order.
// Without dates the engine's newer-observation rules still block every progress claim.
const datedTimes = (items: typeof a.assessments, other: string[]) => items.filter(item => !other.includes(item.id))
.flatMap(item => item.effectiveAt && Number.isFinite(Date.parse(item.effectiveAt)) ? [Date.parse(item.effectiveAt)] : []);
const before = datedTimes(a.assessments, target.assessmentIds);
const after = datedTimes(b.assessments, baseline.assessmentIds);
if (before.length && after.length && Math.max(...before) >= Math.min(...after)) {
throw new HttpError(400, 'timeslot_order_invalid', 'Timeslot A must lie before timeslot B.');
}
return withScannerValueChanges(compareTimeslots({
projectId, models: [...a.models, ...b.models], baselineRunIds: a.scanRunIds, targetRunIds: b.scanRunIds,
gpo: {
baseline: { results: a.gpoResults, ...(a.gpoObservedAt ? { observedAt: a.gpoObservedAt } : {}) },
target: { results: b.gpoResults, ...(b.gpoObservedAt ? { observedAt: b.gpoObservedAt } : {}) }
}
}), workA, workB);
});
if (await comparisonPort.currentRevision() !== revision) throw new HttpError(409, 'comparison_state_changed', 'The data changed while comparing. Reload the timeslots.');
status = 200; sendJson(response, status, report, requestId); return;
const result = segments[3] === 'domains'
? await cachedComparison(JSON.stringify(['domains', scopeKey]), async () => {
const [a, b] = await workspaces();
return compareTimeslotDomains(report, a, b);
})
: report;
if (await comparisonPort.currentRevision() !== revision) {
comparisonCache.clear();
throw new HttpError(409, 'comparison_state_changed', 'The data changed while comparing. Reload the timeslots.');
}
status = 200; sendJson(response, status, result, requestId); return;
}
if (segments[0] === 'projects' && segments[1] && segments[2] === 'assessment-context' && segments.length === 3 && method === 'GET') {
// Context engine (Phase 13E): read-only second reading through the same read-only port as reports.

View File

@ -5,7 +5,8 @@ import type { PolicyAnalysisOverview } from '../src/domain/policy-analysis-overv
import type { UnifiedResultQuery, UnifiedResultQueryResult } from '../src/domain/unified-result';
import { queryAllUnifiedResults } from '../src/exports/unified-result-jsonl';
import { buildPolicyWorkResults, type PolicyWorkResult } from '../src/evaluation/policy-work-results';
import { canonicalModelForScanRuns, currentPolicyEvaluationsForWork } from '../src/features/project-import/load-canonical-project-workspace';
import type { ImportedDataset } from '../src/domain/dataset';
import { canonicalModelForScanRuns, currentPolicyEvaluationsForWork, loadCanonicalProjectWorkspace } from '../src/features/project-import/load-canonical-project-workspace';
import { RepositoryNotFoundError } from '../src/persistence/repository-errors';
/** One timeslot as Data Hub groups it: its assessments and the scanner runs selected inside them. */
@ -81,3 +82,27 @@ export async function loadTimeslotComparisonInput(
}
return { assessments, models, scanRunIds: [...selection.scanRunIds], gpoResults, ...(gpoObservedAt ? { gpoObservedAt } : {}) };
}
/**
* Core-owned adapter: the Work dataset of one timeslot, built by the same loader Client Info uses, so the
* Comparison Engine reads Client Info's records and result states for its Security Domain values.
*/
export async function loadTimeslotWorkspace(
reader: TimeslotInputReader,
projectId: string,
selection: TimeslotSelection
): Promise<ImportedDataset | null> {
const { dataset } = await loadCanonicalProjectWorkspace({
listProjectAssessments: id => reader.listAssessments(id),
loadAssessmentModel: async assessmentId => {
const model = await reader.loadAssessmentModel(assessmentId);
if (!model || model.project.id !== projectId) throw new RepositoryNotFoundError(`Assessment ${assessmentId} is not in this project.`);
return model;
},
queryUnifiedResults: query => reader.queryUnifiedResults(query),
listPolicyEvaluations: async id => ({ records: await reader.listPolicyEvaluations(id) }),
...(reader.getPolicyAnalysisOverview ? { getPolicyAnalysisOverview: (id: string) => reader.getPolicyAnalysisOverview!(id) } : {})
}, projectId, selection.scanRunIds.length ? selection.scanRunIds : undefined, selection.assessmentIds,
{ includeHistoricalScores: false, includeBaselineGuidance: false });
return dataset;
}

View File

@ -208,12 +208,13 @@ export class AssessorApiClient {
/** Comparison Engine route. The engine owns and types its report; the Core client stays engine-free. */
getTimeslotComparison<T>(projectId: string, baseline: { assessmentIds: string[]; scanRunIds: string[] },
target: { assessmentIds: string[]; scanRunIds: string[] }): Promise<T> {
const query = new URLSearchParams();
baseline.assessmentIds.forEach(id => query.append('baselineAssessmentIds', id));
baseline.scanRunIds.forEach(id => query.append('baselineScanRunIds', id));
target.assessmentIds.forEach(id => query.append('targetAssessmentIds', id));
target.scanRunIds.forEach(id => query.append('targetScanRunIds', id));
return this.request(`/projects/${encodeURIComponent(projectId)}/timeslot-comparison?${query.toString()}`);
return this.request(`/projects/${encodeURIComponent(projectId)}/timeslot-comparison?${timeslotComparisonQuery(baseline, target)}`);
}
/** Security Domain values of the same A/B comparison; loaded separately because it reads both Work datasets. */
getTimeslotDomainComparison<T>(projectId: string, baseline: { assessmentIds: string[]; scanRunIds: string[] },
target: { assessmentIds: string[]; scanRunIds: string[] }): Promise<T> {
return this.request(`/projects/${encodeURIComponent(projectId)}/timeslot-comparison/domains?${timeslotComparisonQuery(baseline, target)}`);
}
getWorkPackages(projectId: string, assessmentIds: string[], scanRunIds: string[] = []): Promise<import('./assessor-api-contract').WorkPackagesResponse> {
@ -604,3 +605,13 @@ export class AssessorApiClient {
return this.request('/import-batches/complete', { method: 'POST', body: JSON.stringify({ batchId, entries, targetProjectId, targetAssessmentId, replacesScanRunId }) });
}
}
function timeslotComparisonQuery(baseline: { assessmentIds: string[]; scanRunIds: string[] },
target: { assessmentIds: string[]; scanRunIds: string[] }): string {
const query = new URLSearchParams();
baseline.assessmentIds.forEach(id => query.append('baselineAssessmentIds', id));
baseline.scanRunIds.forEach(id => query.append('baselineScanRunIds', id));
target.assessmentIds.forEach(id => query.append('targetAssessmentIds', id));
target.scanRunIds.forEach(id => query.append('targetScanRunIds', id));
return query.toString();
}

View File

@ -5,14 +5,38 @@ export type ComparisonReadMethods = Pick<AssessorRepository, 'getProject' | 'lis
| 'queryUnifiedResults' | 'listPolicyEvaluations' | 'listGpoBackupInstances'>;
export interface ComparisonReadPort extends ComparisonReadMethods { currentRevision(): Promise<number> }
/** Bounded per method; models and policy evaluations are large. */
const MAX_ENTRIES = 8;
/**
* Reads are shared within one repository revision: both timeslots of a request load the same project-wide
* policy evaluations and backups, and switching A or B reloads the same models. Any committed change starts
* a new revision and drops every entry, so a read never serves an outdated state. Failed reads are not kept.
*/
export function comparisonReadPortFrom(repository: ComparisonReadMethods, revision: () => Promise<number>): ComparisonReadPort {
let cachedRevision: number | undefined;
const caches = new Map<string, Map<string, Promise<unknown>>>();
const shared = <A extends unknown[], R>(name: string, read: (...args: A) => Promise<R>) => async (...args: A): Promise<R> => {
const current = await revision();
if (current !== cachedRevision) { caches.clear(); cachedRevision = current; }
const cache = caches.get(name) ?? caches.set(name, new Map()).get(name)!;
const key = JSON.stringify(args);
let pending = cache.get(key) as Promise<R> | undefined;
if (!pending) {
if (cache.size >= MAX_ENTRIES) cache.delete(cache.keys().next().value!);
pending = read(...args);
cache.set(key, pending);
pending.catch(() => { if (cache.get(key) === pending) cache.delete(key); });
}
return pending;
};
return Object.freeze({
currentRevision: revision,
getProject: repository.getProject.bind(repository),
listAssessments: repository.listAssessments.bind(repository),
loadAssessmentModel: repository.loadAssessmentModel.bind(repository),
queryUnifiedResults: repository.queryUnifiedResults.bind(repository),
listPolicyEvaluations: repository.listPolicyEvaluations.bind(repository),
listGpoBackupInstances: repository.listGpoBackupInstances.bind(repository)
listAssessments: shared('assessments', repository.listAssessments.bind(repository)),
loadAssessmentModel: shared('model', repository.loadAssessmentModel.bind(repository)),
queryUnifiedResults: shared('results', repository.queryUnifiedResults.bind(repository)),
listPolicyEvaluations: shared('evaluations', repository.listPolicyEvaluations.bind(repository)),
listGpoBackupInstances: shared('backups', repository.listGpoBackupInstances.bind(repository))
});
}

View File

@ -0,0 +1,16 @@
import type { FindingRecord } from '../domain/finding';
import { gpoWorkSettingIdentity } from '../features/results-browser/work-record-lookup';
/**
* Cross-timeslot key of a GPO Work record: its scope and technical identity, the same key the comparison
* uses (`identityKey`). Read from the record's Raw Data fields like the Core report projection, because a
* rolled-up setting's record ID does not always carry them; the setting ID form is the fallback.
*/
export function gpoRecordSettingKey(record: FindingRecord): string | undefined {
const field = (label: string) => record.fields.find(item => item.label === label)?.value;
const scope = field('Scope');
const identity = field('Technical Identity');
if (scope && identity) return JSON.stringify([scope, identity]);
const fromId = gpoWorkSettingIdentity(record.id);
return fromId ? JSON.stringify([fromId.scope, fromId.settingId]) : undefined;
}

View File

@ -25,6 +25,20 @@ interface TimeslotSetting {
displayValues: string[];
resultIds: string[];
ambiguous: boolean;
facts: GpoSideFacts;
}
/** What Work shows for a setting in one timeslot, for the Compare detail; read as retained, never re-derived. */
export interface GpoSideFacts {
verdict?: SettingVerdict;
/** Retained configured values as Work displays them. */
values: string[];
/** Expected baseline values of the decisive comparisons. */
expected: string[];
/** Distinct GPO backups contributing to the setting. */
backups: number;
conflict: boolean;
duplicate: boolean;
}
export interface GpoSettingDecision {
@ -43,12 +57,16 @@ export interface GpoSettingDecision {
/** Retained configured values of both timeslots as Work displays them, for a side-by-side reading. */
baselineValues: string[];
targetValues: string[];
baseline: GpoSideFacts;
target?: GpoSideFacts;
}
export interface GpoTargetChange {
settingKey: string;
baselineVerdict?: SettingVerdict;
targetResultIds: string[];
baseline?: GpoSideFacts;
target: GpoSideFacts;
}
export interface GpoTimeslotComparison {
@ -82,17 +100,29 @@ function rollUp(results: PolicyWorkResult[]): Map<string, TimeslotSetting> {
const verdict = verdicts.reduce<SettingVerdict | undefined>((best, item) =>
!best || policyWorkVerdictPriority[item.status] > policyWorkVerdictPriority[best] ? item.status : best, undefined);
const decisive = verdicts.filter(item => item.status === verdict);
const shown = (value: unknown) => typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value);
const displayValues = [...new Set(decisive.flatMap(item => {
const actual = item.comparison?.comparator.actual;
return actual === undefined ? [] : [shown(actual)];
}))].sort();
settings.set(key, {
key, scope: group[0].scope, identity: group[0].identity, verdict,
baselineIds: canonicalJson([...new Set(verdicts.flatMap(item => item.comparison?.baselineId ?? []))].sort()),
actualValues: canonicalJson([...new Set(decisive.flatMap(item =>
item.comparison?.comparator.actual === undefined ? [] : [canonicalJson(item.comparison.comparator.actual)]))].sort()),
displayValues: [...new Set(decisive.flatMap(item => {
const actual = item.comparison?.comparator.actual;
return actual === undefined ? [] : [typeof actual === 'object' && actual !== null ? JSON.stringify(actual) : String(actual)];
}))].sort(),
displayValues,
resultIds: group.map(item => item.id).sort(),
ambiguous: new Set(group.flatMap(item => item.settingKey ?? [])).size > 1
ambiguous: new Set(group.flatMap(item => item.settingKey ?? [])).size > 1,
facts: {
...(verdict ? { verdict } : {}), values: displayValues,
expected: [...new Set(decisive.flatMap(item => {
const expected = item.comparison?.comparator.baseline;
return expected === undefined ? [] : [shown(expected)];
}))].sort(),
backups: new Set(group.flatMap(item => item.sources.flatMap(source => source.policyBackupInstanceId ?? []))).size,
conflict: group.some(item => item.conflict || item.status === 'Conflicts'),
duplicate: group.some(item => item.duplicate || item.status === 'Duplicates')
}
});
}
return settings;
@ -123,7 +153,8 @@ export function compareGpoTimeslots(baseline: GpoTimeslotInput, target: GpoTimes
settingKey: item.key, scope: item.scope, identity: item.identity, baselineVerdict: 'Fail',
...(other?.verdict ? { targetVerdict: other.verdict } : {}), state, valueChanged, reasons,
baselineResultIds: item.resultIds, targetResultIds: other?.resultIds ?? [],
baselineValues: item.displayValues, targetValues: other?.displayValues ?? []
baselineValues: item.displayValues, targetValues: other?.displayValues ?? [],
baseline: item.facts, ...(other ? { target: other.facts } : {})
});
if (sourceReasons.length) return {
version, state: 'not-comparable', reasons: sourceReasons,
@ -151,7 +182,8 @@ export function compareGpoTimeslots(baseline: GpoTimeslotInput, target: GpoTimes
if (item.verdict !== 'Fail' || item.ambiguous) continue;
const before = baselineSettings.get(item.key);
if (before?.ambiguous || before?.verdict === 'Fail') continue;
const change = { settingKey: item.key, ...(before?.verdict ? { baselineVerdict: before.verdict } : {}), targetResultIds: item.resultIds };
const change = { settingKey: item.key, ...(before?.verdict ? { baselineVerdict: before.verdict } : {}), targetResultIds: item.resultIds,
...(before ? { baseline: before.facts } : {}), target: item.facts };
// A regression needs an explicit Pass against the same baselines; undecided history claims nothing.
if (before?.verdict === 'Pass') { if (before.baselineIds === item.baselineIds) regressedDeviations.push(change); }
else if (!before?.verdict || before.verdict === 'Not configured' || before.verdict === 'No baseline rule') newDeviations.push(change);

View File

@ -34,6 +34,8 @@ export interface TimeslotSourceComparison {
newDeviationOccurrenceIds: string[];
/** Current deviations whose identity was a single verified pass in A. */
regressedDeviationOccurrenceIds: string[];
/** The passing A occurrence of each regressed deviation, in the same order. */
regressedBaselineOccurrenceIds: string[];
}
export interface ProjectTimeslotEvidenceComparison {
@ -189,7 +191,7 @@ export function compareProjectTimeslotEvidence(
toolId, baselineRunId: baseline?.run.id, targetRunId: target?.run.id,
baselineFileIds: baseline ? [...baseline.fileIds] : [], targetFileIds: target ? [...target.fileIds] : [],
state: 'not-comparable', reasons, decisions: unmatchedDecisions(baseline?.occurrences ?? [], reasons),
newDeviationOccurrenceIds: [], regressedDeviationOccurrenceIds: []
newDeviationOccurrenceIds: [], regressedDeviationOccurrenceIds: [], regressedBaselineOccurrenceIds: []
});
const sources = toolIds.flatMap((toolId): TimeslotSourceComparison[] => {
@ -315,9 +317,12 @@ export function compareProjectTimeslotEvidence(
const baselineKeys = new Set(baselineIdentities.flatMap(entry => entry.identity.state === 'verified' ? [entry.identity.identity.key] : []));
// A regression needs exactly one explicit pass of the same identity in A.
const baselinePassCounts = new Map<string, number>();
const baselinePassIds = new Map<string, string>();
baselineOccurrences.filter(item => occurrenceOutcome(item) === 'pass').forEach(item => {
const identity = identityFor(item, definition, baselineScope.scope);
if (identity.state === 'verified') baselinePassCounts.set(identity.identity.key, (baselinePassCounts.get(identity.identity.key) ?? 0) + 1);
if (identity.state !== 'verified') return;
baselinePassCounts.set(identity.identity.key, (baselinePassCounts.get(identity.identity.key) ?? 0) + 1);
baselinePassIds.set(identity.identity.key, item.id);
});
const currentDeviations = targetIdentities.filter(entry => entry.identity.state === 'verified'
&& isDeviation(Boolean(entry.item.isIoe), occurrenceOutcome(entry.item))
@ -331,7 +336,8 @@ export function compareProjectTimeslotEvidence(
toolId, baselineRunId: baseline.run.id, targetRunId: target.run.id,
baselineFileIds: [...baseline.fileIds], targetFileIds: [...target.fileIds],
state: 'compared', reasons: [...new Set(blockingDiagnostics)], decisions, newDeviationOccurrenceIds,
regressedDeviationOccurrenceIds: regressed.map(entry => entry.item.id)
regressedDeviationOccurrenceIds: regressed.map(entry => entry.item.id),
regressedBaselineOccurrenceIds: regressed.map(entry => baselinePassIds.get(keyOfEntry(entry))!)
};
}

View File

@ -6,7 +6,7 @@ import type { SecurityScanScore } from '../domain/security-scoring';
import { defaultSecurityScoringPolicy, weightedSecurityScore } from '../domain/security-scoring';
import { canonicalSecurityScores } from '../scoring/canonical-security-score';
import { compareProjectTimeslotEvidence, type ProjectTimeslotEvidenceComparison } from './timeslot-comparison-engine';
import { compareGpoTimeslots, type GpoTimeslotComparison, type GpoTimeslotInput } from './gpo-timeslot-comparison';
import { compareGpoTimeslots, type GpoSideFacts, type GpoTimeslotComparison, type GpoTimeslotInput } from './gpo-timeslot-comparison';
export const TIMESLOT_COMPARISON_REPORT_VERSION = '1.0.0' as const;
export const GPO_SOURCE_ID = 'group-policy';
@ -29,11 +29,23 @@ export interface TimeslotChangeEntry {
/** Counterpart records in the other timeslot, when paired. */
counterpartIds: string[];
identityKey?: string;
/** Title and rule ID of the referenced record as Work shows them, for the Compare tab's change list. */
title?: string;
ruleId?: string;
/** Canonical severity of a scanner record; GPO settings carry none. */
severity?: string;
reasons: string[];
/** GPO settings only: retained configured values of A and B as Work displays them. */
values?: { baseline: string[]; target: string[] };
/** Scanner findings only: the Finding Reader's Problem values of each timeslot (Result, Affected, Results, Expected, Found). */
problem?: { baseline: ProblemValue[]; target: ProblemValue[] };
/** GPO settings only: verdict, values, baseline value, backups and diagnostic flags of each timeslot. */
gpo?: { baseline?: GpoSideFacts; target?: GpoSideFacts };
}
/** One Problem block line as the Finding Reader shows it. */
export interface ProblemValue { label: string; value: string }
export interface TimeslotSourceScore {
sourceId: string;
baselineScore?: number;
@ -77,6 +89,16 @@ const outcomeOf = (item: FindingOccurrence) => item.outcome?.normalizedValue
function scannerChanges(models: CanonicalAssessmentModel[], scanner: ProjectTimeslotEvidenceComparison): TimeslotChangeEntry[] {
const occurrences = models.flatMap(model => model.findingOccurrences);
const definitions = new Map(models.flatMap(model => model.findingDefinitions.map(item => [item.id, item] as const)));
const byId = new Map(occurrences.map(item => [item.id, item] as const));
const labelled = (entry: TimeslotChangeEntry): TimeslotChangeEntry => {
const occurrence = byId.get(entry.recordIds[0]);
if (!occurrence) return entry;
const title = definitions.get(occurrence.definitionId)?.title ?? occurrence.sourceIdentity.originalTitle;
const ruleId = occurrence.sourceIdentity.originalRuleId ?? occurrence.sourceIdentity.canonicalRuleId;
const severity = occurrence.severity.normalizedValue;
return { ...entry, ...(title ? { title } : {}), ...(ruleId ? { ruleId } : {}), ...(severity ? { severity } : {}) };
};
return scanner.sources.flatMap(source => [
...source.decisions.map((item): TimeslotChangeEntry => ({
sourceId: source.toolId, change: changeOf[item.state], side: 'baseline', recordIds: [item.baselineOccurrenceId],
@ -86,8 +108,10 @@ function scannerChanges(models: CanonicalAssessmentModel[], scanner: ProjectTime
...source.newDeviationOccurrenceIds.map((id): TimeslotChangeEntry => ({
sourceId: source.toolId, change: 'new', side: 'target', recordIds: [id], counterpartIds: [], reasons: []
})),
...source.regressedDeviationOccurrenceIds.map((id): TimeslotChangeEntry => ({
sourceId: source.toolId, change: 'regressed', side: 'target', recordIds: [id], counterpartIds: [], reasons: []
// A regression's counterpart is its single passing occurrence in A.
...source.regressedDeviationOccurrenceIds.map((id, index): TimeslotChangeEntry => ({
sourceId: source.toolId, change: 'regressed', side: 'target', recordIds: [id],
counterpartIds: source.regressedBaselineOccurrenceIds?.[index] ? [source.regressedBaselineOccurrenceIds[index]] : [], reasons: []
})),
// Deviations of a source that cannot be compared are neither new nor worse.
...(source.state === 'not-comparable' && source.targetRunId ? occurrences
@ -97,24 +121,25 @@ function scannerChanges(models: CanonicalAssessmentModel[], scanner: ProjectTime
sourceId: source.toolId, change: 'not-comparable', side: 'target', recordIds: [item.id], counterpartIds: [],
reasons: source.reasons
})) : [])
]);
]).map(labelled);
}
function gpoChanges(gpo: GpoTimeslotComparison, target?: GpoTimeslotInput): TimeslotChangeEntry[] {
function gpoChanges(gpo: GpoTimeslotComparison, baseline?: GpoTimeslotInput, target?: GpoTimeslotInput): TimeslotChangeEntry[] {
const changes = [
...gpo.decisions.map((item): TimeslotChangeEntry => ({
sourceId: GPO_SOURCE_ID,
change: item.state === 'confirmed-open' && item.valueChanged ? 'value-changed' : changeOf[item.state],
side: 'baseline', recordIds: item.baselineResultIds, counterpartIds: item.targetResultIds,
identityKey: item.settingKey, reasons: item.reasons, values: { baseline: item.baselineValues, target: item.targetValues }
identityKey: item.settingKey, reasons: item.reasons, values: { baseline: item.baselineValues, target: item.targetValues },
gpo: { baseline: item.baseline, ...(item.target ? { target: item.target } : {}) }
})),
...gpo.newDeviations.map((item): TimeslotChangeEntry => ({
sourceId: GPO_SOURCE_ID, change: 'new', side: 'target', recordIds: item.targetResultIds, counterpartIds: [],
identityKey: item.settingKey, reasons: []
identityKey: item.settingKey, reasons: [], gpo: { ...(item.baseline ? { baseline: item.baseline } : {}), target: item.target }
})),
...gpo.regressedDeviations.map((item): TimeslotChangeEntry => ({
sourceId: GPO_SOURCE_ID, change: 'regressed', side: 'target', recordIds: item.targetResultIds, counterpartIds: [],
identityKey: item.settingKey, reasons: []
identityKey: item.settingKey, reasons: [], gpo: { ...(item.baseline ? { baseline: item.baseline } : {}), target: item.target }
}))
];
if (gpo.state === 'not-comparable' && target) {
@ -125,7 +150,23 @@ function gpoChanges(gpo: GpoTimeslotComparison, target?: GpoTimeslotInput): Time
reasons: gpo.reasons
});
}
return changes;
const titles = new Map([...(baseline?.results ?? []), ...(target?.results ?? [])].map(item => [item.id, item.title] as const));
return changes.map(entry => {
const title = titles.get(entry.recordIds[0]);
return title ? { ...entry, title } : entry;
});
}
/** Change counts of the report; a target-side not-comparable deviation is counted apart from A's. */
export function summarizeChanges(changes: TimeslotChangeEntry[]): TimeslotComparisonReport['summary'] {
const count = (change: TimeslotChange, side?: 'baseline' | 'target') =>
changes.filter(item => item.change === change && (!side || item.side === side)).length;
return {
resolved: count('resolved'), unchanged: count('unchanged'), 'value-changed': count('value-changed'),
unverified: count('unverified'), 'not-comparable': count('not-comparable', 'baseline'),
'not-applicable': count('not-applicable'), new: count('new'), regressed: count('regressed'),
targetNotComparable: count('not-comparable', 'target')
};
}
/**
@ -197,7 +238,7 @@ export function compareTimeslots(input: {
} };
const gpo = input.gpo && (input.gpo.baseline.results.length || input.gpo.target.results.length)
? compareGpoTimeslots(input.gpo.baseline, input.gpo.target) : undefined;
const changes = [...scannerChanges(input.models, scanner), ...(gpo ? gpoChanges(gpo, input.gpo?.target) : [])];
const changes = [...scannerChanges(input.models, scanner), ...(gpo ? gpoChanges(gpo, input.gpo?.baseline, input.gpo?.target) : [])];
const sourceScores = scannerScores(input.models, scanner);
if (gpo) {
@ -213,8 +254,6 @@ export function compareTimeslots(input: {
const baselineScore = weightedSecurityScore(scored.map(item => ({ score: item.baselineScore, weight: item.weight })));
const targetScore = weightedSecurityScore(scored.map(item => ({ score: item.targetScore, weight: item.weight })));
const count = (change: TimeslotChange, side?: 'baseline' | 'target') =>
changes.filter(item => item.change === change && (!side || item.side === side)).length;
return {
version: TIMESLOT_COMPARISON_REPORT_VERSION,
projectId: input.projectId,
@ -225,12 +264,7 @@ export function compareTimeslots(input: {
...(gpo ? [{ sourceId: GPO_SOURCE_ID, state: gpo.state, reasons: gpo.reasons }] : [])
],
changes,
summary: {
resolved: count('resolved'), unchanged: count('unchanged'), 'value-changed': count('value-changed'),
unverified: count('unverified'), 'not-comparable': count('not-comparable', 'baseline'),
'not-applicable': count('not-applicable'), new: count('new'), regressed: count('regressed'),
targetNotComparable: count('not-comparable', 'target')
},
summary: summarizeChanges(changes),
score: {
...(baselineScore !== undefined && targetScore !== undefined
? { baselineScore, targetScore, change: targetScore - baselineScore } : {}),

View File

@ -0,0 +1,127 @@
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
};
})
};
}

View File

@ -0,0 +1,50 @@
import type { ImportedDataset } from '../domain/dataset';
import type { FindingRecord } from '../domain/finding';
import { findingFirstRead } from '../features/finding-reader/first-read';
import { recordResultSheets } from '../features/linked-results/record-results';
import { resultStatusFieldFor } from '../features/linked-results/result-message';
import { outcomeForStatusValue, toolIdForRecord } from '../importers/status-normalization';
import { GPO_SOURCE_ID, summarizeChanges, type ProblemValue, type TimeslotChangeEntry, type TimeslotComparisonReport } from './timeslot-comparison-report';
/** Explanatory text, not a finding value: the same rule explains itself the same way in both timeslots. */
const EXPLANATORY_LABELS = new Set(['Impact']);
/** The Finding Reader's Problem values of each scanner Work record of one timeslot, by finding occurrence. */
function problemValues(dataset: ImportedDataset | null | undefined): Map<string, ProblemValue[]> {
const values = new Map<string, ProblemValue[]>();
const sheet = dataset?.sheets.find(item => item.isProjectSheet) ?? dataset?.sheets[0];
if (!dataset || !sheet) return values;
const read = (record: FindingRecord): ProblemValue[] => {
const toolId = toolIdForRecord(record, sheet.toolId);
const outcome = outcomeForStatusValue(toolId, resultStatusFieldFor(record, toolId)?.value);
const summary = findingFirstRead(record, toolId, recordResultSheets(record, dataset.sheets, sheet.id, toolId), outcome);
return (summary?.rows ?? []).filter(row => !EXPLANATORY_LABELS.has(row.label)).map(row => ({ label: row.label, value: row.value }));
};
for (const record of sheet.analysis.records) {
if (record.findingOccurrenceId && !values.has(record.findingOccurrenceId)) values.set(record.findingOccurrenceId, read(record));
}
return values;
}
/**
* Value changes of scanner findings (explicit user decision 2026-10-13): a finding that still deviates in A and B
* whose Problem values in the Finding Reader differ (Result, Affected, Results rows, Expected, Found) is `value-changed`,
* shown neutral like a GPO value change. Every scanner change carries both timeslots' Problem values for the side-by-side
* reading. Verdicts are never re-derived; only the retained values the Work records show are compared.
*/
export function withScannerValueChanges(report: TimeslotComparisonReport, baseline: ImportedDataset | null | undefined,
target: ImportedDataset | null | undefined): TimeslotComparisonReport {
const a = problemValues(baseline);
const b = problemValues(target);
const changes = report.changes.map((entry): TimeslotChangeEntry => {
if (entry.sourceId === GPO_SOURCE_ID) return entry;
const baselineValues = a.get(entry.side === 'baseline' ? entry.recordIds[0] : entry.counterpartIds[0]);
const targetValues = b.get(entry.side === 'target' ? entry.recordIds[0] : entry.counterpartIds[0]);
if (!baselineValues && !targetValues) return entry;
const problem = { baseline: baselineValues ?? [], target: targetValues ?? [] };
const changed = entry.change === 'unchanged' && Boolean(baselineValues?.length && targetValues?.length)
&& JSON.stringify(problem.baseline) !== JSON.stringify(problem.target);
return { ...entry, ...(changed ? { change: 'value-changed' as const } : {}), problem };
});
return { ...report, changes, summary: summarizeChanges(changes) };
}

View File

@ -1,8 +1,20 @@
import { useId } from 'react';
import { useId, useMemo, useState } from 'react';
import { useOptionalApp } from '../../app/AppProvider';
import type { FindingRecord } from '../../domain/finding';
import { FindingFirstRead } from '../finding-reader/FindingFirstRead';
import { PolicyProblemSummary } from '../finding-reader/PolicyProblemSummary';
import { findingFirstRead } from '../finding-reader/first-read';
import { recordResultSheets } from '../linked-results/record-results';
import { resultStatusFieldFor } from '../linked-results/result-message';
import { outcomeForStatusValue, toolIdForRecord } from '../../importers/status-normalization';
import { policyProblemSummary } from '../../profiles/group-policy/problem-summary';
import { toolProfileDefinitions } from '../../profiles/profile-definition-catalog';
import { GPO_SOURCE_ID, type TimeslotChange, type TimeslotComparisonReport } from '../../comparison/timeslot-comparison-report';
import { useTimeslotComparison, type ComparableTimeslot } from './use-timeslot-comparison';
import { changesWithWorkRecords } from './comparison-work-facets';
import { formatPolicyComparisonValue } from '../../profiles/group-policy/presentation';
import { GPO_SOURCE_ID, type TimeslotChange, type TimeslotChangeEntry, type TimeslotComparisonReport } from '../../comparison/timeslot-comparison-report';
import { useDomainComparison, useTimeslotComparison, type ComparableTimeslot } from './use-timeslot-comparison';
import type { TimeslotDomainComparison } from '../../comparison/timeslot-domain-comparison';
import { changesWithWorkRecords, entryIndex } from './comparison-work-facets';
import { CompareSection, problemRows } from './FindingComparison';
import './comparison.css';
interface Props {
@ -11,6 +23,8 @@ interface Props {
assessmentIds?: readonly string[];
/** Opens Work with the change facet; the extension point decides how. */
onOpenChange?(change: TimeslotChange, baseline: ComparableTimeslot, target: ComparableTimeslot): void;
/** Opens one change's finding in Work, in timeslot B (open) or after opening timeslot A; the extension point decides how. */
onOpenFinding?(entry: TimeslotChangeEntry, timeslot: 'baseline' | 'target', baseline: ComparableTimeslot, target: ComparableTimeslot): void;
}
/** Display order of the change facts; better first, then worse, then neutral and unclear states. */
@ -38,9 +52,14 @@ const REASONS: Record<string, string> = {
'target-observation-not-newer': 'Timeslot B holds the same or an older report',
'no-shared-decided-settings': 'No setting decided in both timeslots',
'comparison-profile-unavailable': 'Scanner not yet comparable',
'comparison-profile-missing': 'Scanner not yet comparable'
'comparison-profile-missing': 'Scanner not yet comparable',
'target-setting-absent': 'Setting not in timeslot B',
'baseline-set-changed': 'Compared against different baselines',
'gpo-setting-ambiguous': 'Setting held by several GPO evaluations'
};
export const reasonText = (reasons: string[]) => [...new Set(reasons.map(reason => REASONS[reason] ?? reason))].join(' · ');
/** Only reasons with a readable text; internal evidence codes stay out of the detail. */
export const knownReasonText = (reasons: string[]) => [...new Set(reasons.flatMap(reason => REASONS[reason] ?? []))].join(' · ');
export function sourceName(sourceId: string): string {
if (sourceId === GPO_SOURCE_ID) return 'GPOs';
@ -78,8 +97,215 @@ function sourceRows(report: TimeslotComparisonReport) {
});
}
const ALL_CHANGES = 'changes';
const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info', 'unknown'] as const;
const severityOf = (value?: string) => SEVERITIES.find(item => item === value) ?? 'unknown';
const severityLabel = (value: string) => value[0].toUpperCase() + value.slice(1);
/** What the change says about A and B, in the words of the Finding Reader's `Compare` section. */
function stateNote(item: TimeslotChangeEntry): string {
if (item.change === 'value-changed' && item.problem) {
const changed = problemRows(item.problem).filter(row => row.changed);
const short = (value?: string) => value !== undefined && value.length <= 40 && !value.includes('\n');
// Short values (a count, a setting) are named with A → B; longer texts only by their label.
const named = changed.filter(row => short(row.baseline) && short(row.target));
return named.length
? `Deviation in A and B · ${named.map(row => `${row.noteLabel} ${row.baseline} → ${row.target}`).join(' · ')}`
: `Deviation in A and B · ${changed.map(row => row.label).join(', ') || 'value'} changed`;
}
if (item.change === 'value-changed' && item.values) {
const one = (values: string[]) => values.length === 1 && !formatPolicyComparisonValue(values[0]).includes('\n')
? formatPolicyComparisonValue(values[0]) : undefined;
const [a, b] = [one(item.values.baseline), one(item.values.target)];
return a && b ? `Deviation in A and B · ${a} → ${b}` : 'Deviation in A and B · configured value changed';
}
if (item.change === 'resolved') return 'Deviation in A · passed in B';
if (item.change === 'regressed') return 'Passed in A';
if (item.change === 'unchanged') return 'Deviation in A and B';
if (item.change === 'new') return '';
return item.reasons.length ? reasonText(item.reasons) : '';
}
/** Every finding and GPO setting of the comparison; `All changes` leaves out the unchanged ones. */
function ChangeList({ report, baselineDate, targetDate, targetIsOpen, onOpen }: {
report: TimeslotComparisonReport; baselineDate: string; targetDate: string; targetIsOpen: boolean;
onOpen?(entry: TimeslotChangeEntry, timeslot: 'baseline' | 'target'): void;
}) {
const id = useId();
const work = useWorkRecords(report, targetIsOpen);
const [shown, setShown] = useState<string>(ALL_CHANGES);
const [selected, setSelected] = useState<string>();
const order = new Map(CHANGE_FACTS.map((fact, index) => [fact.change, index]));
const facts = new Map(CHANGE_FACTS.map(fact => [fact.change, fact]));
const rows = report.changes
.filter(item => facts.has(item.change) && (shown === ALL_CHANGES ? item.change !== 'unchanged' : item.change === shown))
.map(item => ({ item, source: sourceName(item.sourceId), severity: severityOf(item.severity) }))
.sort((a, b) => order.get(a.item.change)! - order.get(b.item.change)!
|| SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity) || a.source.localeCompare(b.source)
|| (a.item.ruleId ?? '').localeCompare(b.item.ruleId ?? '', 'en', { numeric: true }) || (a.item.title ?? '').localeCompare(b.item.title ?? ''));
const count = (change: TimeslotChange) => report.changes.filter(item => item.change === change).length;
// The first row is selected until another is chosen, like the Work list; a filter change keeps a still listed row.
const selectedIndex = Math.max(0, rows.findIndex(row => rowKey(row.item) === selected));
const current = rows[selectedIndex];
return <section className="comparison-tile" aria-labelledby={`${id}-title`}>
<div className="comparison-tile__heading">
<h3 id={`${id}-title`}>Findings</h3>
<label className="comparison-tile__filter">Show
<select className="comparison-tab__select" value={shown} onChange={event => setShown(event.target.value)}>
<option value={ALL_CHANGES}>All changes</option>
{CHANGE_FACTS.map(fact => <option key={fact.change} value={fact.change}>{fact.label} ({count(fact.change)})</option>)}
</select>
</label>
</div>
{rows.length ? <div className="comparison-split">
<ul className="comparison-list" aria-label="Changed findings" onKeyDown={event => {
// Arrow keys move the selection like the Work list.
const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
if (!step) return;
event.preventDefault();
const next = rows[Math.min(rows.length - 1, Math.max(0, selectedIndex + step))];
setSelected(rowKey(next.item));
event.currentTarget.querySelectorAll<HTMLButtonElement>('.comparison-item')[rows.indexOf(next)]?.focus();
}}>
{rows.map(({ item, source, severity }, index) => {
const fact = facts.get(item.change)!;
const note = [item.ruleId, stateNote(item)].filter(Boolean).join(' · ');
return <li key={rowKey(item)}>
<button type="button" className={`comparison-item severity-${severity}${index === selectedIndex ? ' is-selected' : ''}`}
aria-pressed={index === selectedIndex} tabIndex={index === selectedIndex ? 0 : -1} onClick={() => setSelected(rowKey(item))}>
<span className="data-tile-hex" aria-hidden="true"/>
<span className="comparison-item__content">
<span className="comparison-item__title">{item.title ?? '—'}</span>
<span className="comparison-item__meta">
<span className={`comparison-badge is-${fact.tone}`}><span aria-hidden="true">{fact.glyph} </span>{fact.label}</span>
<span className="reader-severity">{source}</span>
{note && <span className="comparison-item__note">{note}</span>}
</span>
</span>
<span className={`reader-severity severity-${severity}`}>{severityLabel(severity)}</span>
</button>
</li>;
})}
</ul>
{current && <ChangeDetailPane row={current} record={work.byEntry.get(current.item)} work={work} baselineDate={baselineDate} targetDate={targetDate}
onOpen={onOpen ? timeslot => onOpen(current.item, timeslot) : undefined}/>}
</div> : <p className="comparison-tile__note">No finding has this change.</p>}
</section>;
}
const rowKey = (item: TimeslotChangeEntry) => `${item.change}|${item.side}|${item.recordIds.join(',')}|${item.identityKey ?? ''}`;
/** The Work record of timeslot B behind each change, while B is the timeslot open in Client Info. */
function useWorkRecords(report: TimeslotComparisonReport, targetIsOpen: boolean) {
const app = useOptionalApp();
const dataset = app?.dataset;
return useMemo(() => {
const byEntry = new Map<TimeslotChangeEntry, FindingRecord>();
const sheet = dataset?.sheets.find(item => item.isProjectSheet) ?? app?.activeSheet;
if (!targetIsOpen || !dataset || !sheet) return { byEntry, sheets: [], sheetId: '', toolId: undefined };
const entryOf = entryIndex(report);
for (const record of sheet.analysis.records) {
const entry = entryOf(record);
if (entry && !byEntry.has(entry)) byEntry.set(entry, record);
}
return { byEntry, sheets: dataset.sheets, sheetId: sheet.id, toolId: sheet.toolId ?? dataset.profileId };
}, [report, targetIsOpen, dataset]); // eslint-disable-line react-hooks/exhaustive-deps
}
/** Problem and Next step of the Work record, exactly as the Finding Reader shows them first. */
function WorkFirstRead({ record, work }: { record: FindingRecord; work: ReturnType<typeof useWorkRecords> }) {
const toolId = toolIdForRecord(record, work.toolId);
const outcome = outcomeForStatusValue(toolId, resultStatusFieldFor(record, toolId)?.value) ?? 'unknown';
if (toolId === 'group-policy') {
const problem = policyProblemSummary(record);
return problem ? <PolicyProblemSummary summary={problem} outcome={outcome} search=""/> : null;
}
const summary = findingFirstRead(record, toolId, recordResultSheets(record, work.sheets, work.sheetId, toolId), outcome);
return summary ? <FindingFirstRead summary={summary} outcome={outcome} search=""/> : null;
}
/** Detail of the selected change (mockup B): source and change, title, Problem and Next step, then Compare. */
function ChangeDetailPane({ row, record, work, baselineDate, targetDate, onOpen }: {
row: { item: TimeslotChangeEntry; source: string }; record?: FindingRecord; work: ReturnType<typeof useWorkRecords>;
baselineDate: string; targetDate: string; onOpen?(timeslot: 'baseline' | 'target'): void;
}) {
// Timeslot A holds a record of this change when it was an A deviation, the single A pass of a regression, or any GPO setting A evaluated.
const inBaseline = row.item.side === 'baseline' || row.item.counterpartIds.length > 0 || Boolean(row.item.gpo?.baseline);
const fact = CHANGE_FACTS.find(item => item.change === row.item.change)!;
return <article className="comparison-detail" aria-live="polite">
<div className="comparison-detail__badges">
<span className="comparison-detail__source">{row.source}</span>
<span className={`comparison-badge is-${fact.tone}`}><span aria-hidden="true">{fact.glyph} </span>{fact.label}</span>
</div>
<h4 className="comparison-detail__title">{row.item.title ?? '—'}</h4>
<div className="reader-summary comparison-detail__summary">
{record && <WorkFirstRead record={record} work={work}/>}
</div>
<CompareSection entry={row.item} baselineDate={baselineDate} targetDate={targetDate} open/>
{onOpen && (record || inBaseline) && <div className="comparison-detail__actions">
{record && <button type="button" className="btn primary" onClick={() => onOpen('target')}>Open in Work</button>}
{inBaseline && <button type="button" className="btn" onClick={() => onOpen('baseline')}>Open in timeslot A</button>}
</div>}
</article>;
}
function DomainNode({ value }: { value?: number }) {
return value === undefined
? <span className="comparison-domain__node is-empty" aria-label="No value">—</span>
: <span className="comparison-domain__node">{Math.round(value)}</span>;
}
/** Security Domains of mockup A: A → B per domain over the shared sources, and resolved against worse changes. */
function DomainComparison({ projectId, baseline, target }: { projectId?: string; baseline: ComparableTimeslot; target: ComparableTimeslot }) {
const id = useId();
const state = useDomainComparison(projectId, baseline, target);
const domains: TimeslotDomainComparison['domains'] = state.status === 'ready' ? state.domains.domains : [];
const widest = Math.max(1, ...domains.flatMap(item => [item.resolved, item.worse]));
return <section className="comparison-tile" aria-labelledby={`${id}-title`} aria-busy={state.status === 'loading'}>
<h3 id={`${id}-title`}>Security Domains</h3>
{state.status === 'loading' && <p className="comparison-tile__note">Loading Security Domains…</p>}
{state.status === 'error' && <p className="comparison-tile__note" role="alert">The Security Domains could not be compared.</p>}
{state.status === 'ready' && <div className="comparison-table-scroll"><div className="comparison-domains" role="table" aria-label="Security Domains A and B">
<div className="comparison-domain is-header" role="row">
<span role="columnheader">Domain</span><span role="columnheader">A</span><span aria-hidden="true"/><span role="columnheader">B</span>
<span role="columnheader">Change</span>
<span role="columnheader" className="comparison-domain__legend"><span>Resolved ◀</span><span>▶ New + regressed</span></span>
</div>
{domains.map(domain => {
const a = domain.baseline.score === undefined ? undefined : Math.round(domain.baseline.score);
const b = domain.target.score === undefined ? undefined : Math.round(domain.target.score);
return <div className="comparison-domain" role="row" key={domain.id}>
<span role="cell" className="comparison-domain__name">{domain.label}</span>
<span role="cell"><DomainNode value={domain.onlyIn === 'target' ? undefined : domain.baseline.score}/></span>
<span aria-hidden="true" className="comparison-domain__arrow">→</span>
<span role="cell"><DomainNode value={domain.onlyIn === 'baseline' ? undefined : domain.target.score}/></span>
<span role="cell">{domain.onlyIn
? <span className="comparison-delta is-neutral">○ Only in {domain.onlyIn === 'target' ? 'B' : 'A'}</span>
: <DomainDelta before={a} after={b}/>}</span>
<span role="cell">{domain.onlyIn
? <span className="comparison-tile__note comparison-domain__only">{domain.onlyInSources.map(sourceName).join(', ')} only in {domain.onlyIn === 'target' ? 'B' : 'A'} · not comparable</span>
: <span className="comparison-domain__bars" aria-label={`${domain.resolved} resolved, ${domain.worse} new or regressed`}>
<span className="comparison-domain__bar is-better" style={{ width: `${domain.resolved / widest * 100}%` }}/>
<span className="comparison-domain__axis"/>
<span className="comparison-domain__bar is-worse" style={{ width: `${domain.worse / widest * 100}%` }}/>
</span>}</span>
</div>;
})}
</div></div>}
{state.status === 'ready' && <p className="comparison-tile__note">Pass rate of each domain as in Client Info, over the sources compared in both timeslots: {state.domains.sources.map(sourceName).join(', ') || 'none'}.</p>}
</section>;
}
function DomainDelta({ before, after }: { before?: number; after?: number }) {
if (before === undefined || after === undefined) return <span className="comparison-delta is-neutral">—</span>;
const change = after - before;
if (change === 0) return <span className="comparison-delta is-neutral">= 0</span>;
return change > 0 ? <span className="comparison-delta is-better">▲ +{change}</span> : <span className="comparison-delta is-worse">▼ −{Math.abs(change)}</span>;
}
/** Client Info `Compare` tab content of the Comparison Engine (mockup A, user decision 2026-10-13). */
export function ComparisonTab({ projectId, assessmentIds = [], onOpenChange }: Props) {
export function ComparisonTab({ projectId, assessmentIds = [], onOpenChange, onOpenFinding }: Props) {
const id = useId();
const comparison = useTimeslotComparison(projectId, assessmentIds);
const { state, baseline, target, earlier, timeslots, targetIsOpen } = comparison;
@ -143,6 +369,8 @@ export function ComparisonTab({ projectId, assessmentIds = [], onOpenChange }: P
</section>
</div>
<DomainComparison projectId={projectId} baseline={baseline} target={target}/>
<section className="comparison-tile" aria-labelledby={`${id}-sources`}>
<h3 id={`${id}-sources`}>Sources</h3>
<div className="comparison-table-scroll">
@ -160,6 +388,9 @@ export function ComparisonTab({ projectId, assessmentIds = [], onOpenChange }: P
</table>
</div>
</section>
<ChangeList report={report} baselineDate={baseline.date} targetDate={target.date} targetIsOpen={targetIsOpen}
onOpen={onOpenFinding ? (entry, timeslot) => onOpenFinding(entry, timeslot, baseline, target) : undefined}/>
</>}
</section>;
}

View File

@ -3,33 +3,142 @@ import { TriangleIcon } from '../../components/TriangleIcon';
import type { FindingRecord } from '../../domain/finding';
import { policyComparisonDisplayLines } from '../../profiles/group-policy/presentation';
import type { TimeslotChange, TimeslotChangeEntry } from '../../comparison/timeslot-comparison-report';
import type { GpoSideFacts } from '../../comparison/gpo-timeslot-comparison';
import { useOptionalApp } from '../../app/AppProvider';
import { useEnginePreferences } from '../../app/engine-preferences';
import { comparisonWorkFacetIndex } from './comparison-work-facets';
import { usePublishedComparison } from './use-timeslot-comparison';
import { CHANGE_FACTS, date, reasonText } from './ComparisonTab';
import { date, knownReasonText } from './ComparisonTab';
type Verdict = { text: string; tone: 'fail' | 'pass' | 'neutral' };
const DEVIATION: Verdict = { text: 'Deviation', tone: 'fail' };
const PASSED: Verdict = { text: 'Passed', tone: 'pass' };
const neutral = (text: string): Verdict => ({ text, tone: 'neutral' });
/** What each change says about timeslot A and B; never a re-derived verdict, only the comparison's statement. */
const STATES: Record<TimeslotChange, { a: string; b: string }> = {
resolved: { a: 'Deviation', b: 'Passed' },
unchanged: { a: 'Deviation', b: 'Deviation' },
'value-changed': { a: 'Deviation', b: 'Deviation' },
unverified: { a: 'Deviation', b: 'Not decisive' },
'not-applicable': { a: 'Deviation', b: 'Not applicable' },
'not-comparable': { a: 'Not comparable', b: 'Not comparable' },
new: { a: 'No deviation', b: 'Deviation' },
regressed: { a: 'Passed', b: 'Deviation' }
const STATES: Record<TimeslotChange, { a: Verdict; b: Verdict }> = {
resolved: { a: DEVIATION, b: PASSED },
unchanged: { a: DEVIATION, b: DEVIATION },
'value-changed': { a: DEVIATION, b: DEVIATION },
unverified: { a: DEVIATION, b: neutral('Not decisive') },
'not-applicable': { a: DEVIATION, b: neutral('Not applicable') },
'not-comparable': { a: neutral('Not comparable'), b: neutral('Not comparable') },
new: { a: neutral('No deviation'), b: DEVIATION },
regressed: { a: PASSED, b: DEVIATION }
};
function Values({ values }: { values: string[] }) {
/** One sentence on the change, as in mockup B; the verdicts themselves are never re-derived. */
const CHANGE_NOTES: Record<TimeslotChange, string> = {
resolved: 'Change: resolved. The deviation of timeslot A passes in timeslot B.',
unchanged: 'Change: none. Still a deviation in both timeslots.',
'value-changed': 'Change: value changed, verdict unchanged. The comparison shows both retained values and never re-derives the verdict.',
unverified: 'Change: unverified. Timeslot B does not decide this check, so the deviation stays open.',
'not-applicable': 'Change: not applicable in timeslot B.',
'not-comparable': 'Change: not comparable.',
new: 'Change: new deviation in timeslot B.',
regressed: 'Change: regressed. Passed in timeslot A, a deviation in timeslot B.'
};
/** Work's GPO verdict words, read as retained. */
function gpoVerdict(facts?: GpoSideFacts): Verdict | undefined {
if (!facts?.verdict) return undefined;
if (facts.verdict === 'Fail') return DEVIATION;
if (facts.verdict === 'Pass') return PASSED;
return neutral(facts.verdict);
}
function VerdictText({ verdict }: { verdict: Verdict }) {
if (verdict.tone === 'neutral') return <>{verdict.text}</>;
return <span className={`comparison-verdict is-${verdict.tone}`}><span aria-hidden="true">{verdict.tone === 'fail' ? '✕ ' : '✓ '}</span>{verdict.text}</span>;
}
/** Configured values; red where that timeslot deviates, green where it passes, like the Finding Reader. */
function Values({ values, verdict }: { values: string[]; verdict?: Verdict }) {
if (!values.length) return <>—</>;
return <>{values.map((value, index) => <p className="policy-guidance-text" key={index}>{policyComparisonDisplayLines(value).map((line, lineIndex) =>
<Fragment key={lineIndex}>{lineIndex > 0 && '\n'}{line.emphasized ? <strong>{line.text}</strong> : line.text}</Fragment>)}</p>)}</>;
const mark = verdict?.tone === 'fail' ? 'is-deviating' : verdict?.tone === 'pass' ? 'is-aligned' : undefined;
return <>{values.map((value, index) => {
const lines = policyComparisonDisplayLines(value).map((line, lineIndex) =>
<Fragment key={lineIndex}>{lineIndex > 0 && '\n'}{line.emphasized ? <strong>{line.text}</strong> : line.text}</Fragment>);
return <p className="policy-guidance-text comparison-value" key={index}>{mark ? <span className={`policy-value-mark ${mark}`}>{lines}</span> : lines}</p>;
})}</>;
}
/** Problem lines of both timeslots by label, in the Finding Reader's order; repeated labels (CIS-CAT Found) keep their position. */
export function problemRows(problem: NonNullable<TimeslotChangeEntry['problem']>) {
const keyed = (values: Array<{ label: string; value: string }>) => {
const seen = new Map<string, number>();
return values.map(item => { const index = seen.get(item.label) ?? 0; seen.set(item.label, index + 1); return [index ? `${item.label} ${index + 1}` : item.label, item] as const; });
};
const a = new Map(keyed(problem.baseline));
const b = new Map(keyed(problem.target));
return [...new Set([...a.keys(), ...b.keys()])].map(key => {
const baseline = a.get(key)?.value;
const target = b.get(key)?.value;
const label = (a.get(key) ?? b.get(key))!.label;
// An evidence row (CIS-CAT `Name · Type · Status · Value`) whose only difference is its value is shown by that value.
const parts = [baseline, target].map(value => value?.split(' · '));
const [left, right] = parts;
const valueOnly = baseline !== target && left && right && left.length > 1 && left.length === right.length
&& left.slice(0, -1).every((part, index) => part === right[index]);
return valueOnly
? { key, label: `${label} · ${left[0]}`, noteLabel: label, baseline: left.at(-1), target: right.at(-1), changed: true }
: { key, label, noteLabel: label, baseline, target, changed: baseline !== target };
});
}
/**
* Finding Reader `Compare` section of the Comparison Engine (mockup B, user decision 2026-10-13): the change of
* this finding against timeslot A, both states and, for a GPO setting, both retained values side by side.
* A Problem value of one timeslot, marked like a GPO configured value: red where that timeslot deviates, green where it
* passes. `Expected` is the requirement, not an observation, and stays neutral like a GPO baseline value.
*/
function ProblemText({ value, verdict }: { value?: string; verdict?: Verdict }) {
if (value === undefined) return <>—</>;
const mark = verdict?.tone === 'fail' ? 'is-deviating' : verdict?.tone === 'pass' ? 'is-aligned' : undefined;
return mark ? <span className={`policy-value-mark ${mark}`}>{value}</span> : <>{value}</>;
}
const flags = (facts?: GpoSideFacts) => facts ? [facts.conflict && 'Conflicts', facts.duplicate && 'Duplicates'].filter(Boolean).join(' · ') || '—' : '—';
/**
* The `Compare` section of mockup B: A and B side by side (verdict, and for a GPO setting the configured and
* baseline values, contributing backups and diagnostic flags), then one sentence on the change.
*/
export function CompareSection({ entry, baselineDate, targetDate, open = false }: {
entry: TimeslotChangeEntry; baselineDate: string; targetDate: string; open?: boolean;
}) {
const gpo = entry.gpo;
const a = gpoVerdict(gpo?.baseline) ?? STATES[entry.change].a;
const b = gpoVerdict(gpo?.target) ?? STATES[entry.change].b;
const reasons = knownReasonText(entry.reasons);
return <details className="finding-comparison" open={open || undefined}>
<summary className="finding-comparison__header"><TriangleIcon direction="right"/><h4>Compare</h4>
<span className="finding-comparison__scope">A · {date(baselineDate)} → B · {date(targetDate)}</span></summary>
<div className="comparison-table-scroll">
<table className="client-information-table comparison-table">
<thead><tr><th></th><th>A · {date(baselineDate)}</th><th>B · {date(targetDate)}</th></tr></thead>
<tbody>
<tr><td>Verdict</td><td><VerdictText verdict={a}/></td><td><VerdictText verdict={b}/></td></tr>
{gpo && <>
<tr><td>Configured value</td><td><Values values={gpo.baseline?.values ?? []} verdict={a}/></td><td><Values values={gpo.target?.values ?? []} verdict={b}/></td></tr>
<tr><td>Baseline value</td><td><Values values={gpo.baseline?.expected ?? []}/></td><td><Values values={gpo.target?.expected ?? []}/></td></tr>
<tr><td>Contributing GPO backups</td><td>{gpo.baseline?.backups || '—'}</td><td>{gpo.target?.backups || '—'}</td></tr>
<tr><td>Conflicts / Duplicates</td><td>{flags(gpo.baseline)}</td><td>{flags(gpo.target)}</td></tr>
</>}
{/* Only what differs between A and B; the Problem block shows the complete values. */}
{!gpo && entry.problem && problemRows(entry.problem).filter(row => row.changed).map(row => <tr key={row.key}>
<td>{row.label}</td>
<td><ProblemText value={row.baseline} verdict={row.label === 'Expected' ? undefined : a}/></td>
<td><ProblemText value={row.target} verdict={row.label === 'Expected' ? undefined : b}/></td>
</tr>)}
{!gpo && entry.values && <tr><td>Configured value</td><td><Values values={entry.values.baseline} verdict={a}/></td><td><Values values={entry.values.target} verdict={b}/></td></tr>}
</tbody>
</table>
</div>
<p className="finding-comparison__note">{CHANGE_NOTES[entry.change]}{reasons && ` ${reasons}.`}</p>
</details>;
}
/**
* Finding Reader `Compare` section of the Comparison Engine (mockup B, user decision 2026-10-13).
* Collapsed like the Context section; present only while a comparison of the open timeslot is loaded.
*/
export function FindingComparison({ record }: { record: FindingRecord }) {
@ -40,26 +149,5 @@ export function FindingComparison({ record }: { record: FindingRecord }) {
const index = enabled && comparison ? comparisonWorkFacetIndex(comparison) : undefined;
const entry: TimeslotChangeEntry | undefined = index?.entryOf(record);
if (!index || !entry) return null;
const fact = CHANGE_FACTS.find(item => item.change === entry.change);
const { baseline, target } = index.comparison;
const state = STATES[entry.change];
const reasons = entry.reasons.length ? reasonText(entry.reasons) : '';
return <details className="finding-optional-detail finding-comparison">
<summary><TriangleIcon direction="right"/>Compare <span className="finding-comparison__scope">A · {date(baseline.date)} → B · {date(target.date)}</span></summary>
<p className="finding-comparison__change">
<span className={`comparison-fact__value is-${fact?.tone ?? 'neutral'}`}><span aria-hidden="true">{fact?.glyph} </span>{fact?.label ?? entry.change}</span>
{entry.change === 'value-changed' && <span className="finding-comparison__note">Still a deviation; the configured value differs from timeslot A.</span>}
</p>
<div className="comparison-table-scroll">
<table className="client-information-table comparison-table">
<thead><tr><th></th><th>A · {date(baseline.date)}</th><th>B · {date(target.date)}</th></tr></thead>
<tbody>
<tr><td>Result</td><td>{state.a}</td><td>{state.b}</td></tr>
{entry.values && <tr><td>Configured value</td><td><Values values={entry.values.baseline}/></td><td><Values values={entry.values.target}/></td></tr>}
</tbody>
</table>
</div>
{reasons && <p className="finding-comparison__note">{reasons}</p>}
<p className="finding-comparison__note">The comparison reads both timeslots and changes no verdict or score.</p>
</details>;
return <CompareSection entry={entry} baselineDate={index.comparison.baseline.date} targetDate={index.comparison.target.date}/>;
}

View File

@ -1,7 +1,7 @@
import { useApp } from '../../app/AppProvider';
import { useEnginePreferences } from '../../app/engine-preferences';
import type { FindingRecord } from '../../domain/finding';
import { gpoWorkSettingIdentity } from '../results-browser/work-record-lookup';
import { gpoRecordSettingKey } from '../../comparison/gpo-record-key';
import type { DynamicFacet } from '../search-filter/dynamic-facets';
import { GPO_SOURCE_ID, type TimeslotChange, type TimeslotChangeEntry, type TimeslotComparisonReport } from '../../comparison/timeslot-comparison-report';
import { usePublishedComparison, type PublishedComparison } from './use-timeslot-comparison';
@ -31,14 +31,13 @@ export interface ComparisonWorkFacetIndex {
comparison: PublishedComparison;
}
const gpoKey = (scope: unknown, identity: unknown) => JSON.stringify([scope, identity]);
/**
* Maps the report onto Work records of timeslot B: scanner records by occurrence (a current record, or the
* B counterpart of an A deviation), GPO settings by scope and technical identity, like Result DB drill-downs.
* A deviation of A without a B record has no Work row in B and is therefore not offered.
*/
function entryIndex(report: TimeslotComparisonReport) {
export function entryIndex(report: TimeslotComparisonReport) {
const byOccurrence = new Map<string, TimeslotChangeEntry>();
const bySetting = new Map<string, TimeslotChangeEntry>();
for (const entry of report.changes) {
@ -49,9 +48,10 @@ function entryIndex(report: TimeslotComparisonReport) {
for (const id of entry.side === 'target' ? entry.recordIds : entry.counterpartIds) byOccurrence.set(id, entry);
}
return (record: FindingRecord): TimeslotChangeEntry | undefined => {
if (record.findingOccurrenceId) return byOccurrence.get(record.findingOccurrenceId);
const identity = gpoWorkSettingIdentity(record.id);
return identity ? bySetting.get(gpoKey(identity.scope, identity.settingId)) : undefined;
const scanner = record.findingOccurrenceId ? byOccurrence.get(record.findingOccurrenceId) : undefined;
if (scanner) return scanner;
const key = gpoRecordSettingKey(record);
return key ? bySetting.get(key) : undefined;
};
}
@ -102,3 +102,20 @@ export function useComparisonWorkFacets(): ComparisonWorkFacetIndex | undefined
const comparison = usePublishedComparison(app.targetProjectId, app.activeAssessmentIds ?? []);
return enabled && comparison ? comparisonWorkFacetIndex(comparison) : undefined;
}
/**
* Work record IDs of one change in the timeslot that is open: scanner records by occurrence (the A records of
* a baseline entry, or the B records and counterparts), GPO settings by scope and technical identity.
*/
export function workRecordIdsFor(entry: TimeslotChangeEntry, timeslot: 'baseline' | 'target', records: FindingRecord[]): string[] {
if (entry.sourceId === GPO_SOURCE_ID) {
if (!entry.identityKey) return [];
return records.filter(record => {
return gpoRecordSettingKey(record) === entry.identityKey;
}).map(record => record.id);
}
const occurrences = new Set(timeslot === 'baseline'
? (entry.side === 'baseline' ? entry.recordIds : entry.counterpartIds)
: (entry.side === 'target' ? entry.recordIds : entry.counterpartIds));
return records.filter(record => record.findingOccurrenceId && occurrences.has(record.findingOccurrenceId)).map(record => record.id);
}

View File

@ -15,6 +15,46 @@
@media (max-width: 900px) { .comparison-tab__grid { grid-template-columns: minmax(0, 1fr); } }
.comparison-tile { min-width: 0; padding: var(--tile-inset); border: var(--bw-hairline) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); }
.comparison-tile > h3 { margin: 0 0 var(--space-3); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
.comparison-tile__heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3); margin-block-end: var(--space-3); }
.comparison-tile__heading > h3 { margin: 0; font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
.comparison-tile__filter { display: inline-flex; align-items: center; gap: var(--space-2); font-size: var(--type-table-body-size); color: var(--text-secondary); }
/* Change list (mockup B): Work-style rows with the severity hexagon, change and source badges and the severity badge. */
.comparison-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--segment-gap); align-items: start; }
@media (max-width: 1100px) { .comparison-split { grid-template-columns: minmax(0, 1fr); } }
.comparison-list { margin: 0; padding: 0; list-style: none; max-height: 70vh; overflow-y: auto; }
.comparison-list > li { border-bottom: var(--bw-hairline) solid var(--border-subtle); }
.comparison-list > li:last-child { border-bottom: 0; }
.comparison-item { position: relative; display: grid; grid-template-columns: 16px minmax(0, 1fr) auto; align-items: start; gap: var(--space-3); width: 100%;
padding: var(--space-3) var(--space-3) var(--space-3) calc(var(--space-3) + var(--marker-width)); border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
.comparison-item:hover { background: var(--tile-surface-hover); }
.comparison-item.is-selected { background: var(--surface-selected-flat); }
.comparison-item.is-selected::before { content: ''; position: absolute; inset: 0 auto 0 0; width: var(--marker-width); background: var(--selection-marker); }
.comparison-item:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(-1 * var(--bw-emphasis)); }
.comparison-detail { min-width: 0; padding: var(--space-4); background: var(--surface-app, transparent); border-left: var(--bw-hairline) solid var(--border-subtle); }
.comparison-detail__source { font-size: var(--type-badge-size); font-weight: var(--fw-semibold); text-transform: uppercase; letter-spacing: var(--ui-label-tracking); color: var(--text-secondary); }
.reader-summary.comparison-detail__summary { margin: 0 0 var(--space-4); padding: 0; border: 0; }
.comparison-detail__summary:empty { display: none; }
.comparison-verdict.is-fail { color: var(--outcome-fail); }
.comparison-verdict.is-pass { color: var(--outcome-pass); }
.comparison-value { margin: 0; }
.comparison-detail__badges { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
.comparison-detail__title { margin: var(--space-3) 0 var(--space-1); font-size: var(--type-detail-title-size); font-weight: var(--fw-semibold); overflow-wrap: anywhere; }
.comparison-item .data-tile-hex { width: 14px; height: 16px; margin-top: 2px; }
.comparison-item .data-tile-hex::after { content: none; }
.comparison-item.severity-critical .data-tile-hex { background: var(--critical); }
.comparison-item.severity-high .data-tile-hex { background: var(--high); }
.comparison-item.severity-medium .data-tile-hex { background: var(--medium); }
.comparison-item.severity-low .data-tile-hex { background: var(--low); }
.comparison-item.severity-info .data-tile-hex { background: var(--info); }
.comparison-item__content { display: grid; gap: var(--space-1); min-width: 0; }
.comparison-item__title { font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); overflow-wrap: anywhere; }
.comparison-item__meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
.comparison-item__note { font-size: var(--type-meta-size); color: var(--text-secondary); overflow-wrap: anywhere; }
.comparison-badge { display: inline-flex; align-items: center; min-height: 22px; padding: 3px var(--space-2); border: var(--bw-hairline) solid currentColor; font-size: var(--type-badge-size); font-weight: var(--fw-semibold); text-transform: uppercase; letter-spacing: var(--ui-label-tracking); white-space: nowrap; }
.comparison-badge.is-better { color: var(--outcome-pass); }
.comparison-badge.is-worse { color: var(--outcome-fail); }
.comparison-badge.is-neutral { color: var(--text-primary); }
.comparison-tile__note { margin: var(--space-3) 0 0; font-size: var(--type-table-body-size); color: var(--text-secondary); }
.comparison-score { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0 0 var(--space-2); }
@ -39,6 +79,36 @@
.comparison-table td { font-variant-numeric: tabular-nums; }
/* Finding Reader `Compare` section, collapsed like Context. */
.finding-comparison__scope { margin-inline-start: var(--space-2); font-size: var(--type-meta-size); font-weight: var(--fw-regular); color: var(--text-secondary); }
/* Same disclosure surface as the Finding Reader's Context section, so both derived readings look alike. */
.finding-comparison { display: block; min-width: 0; margin: 0 0 var(--space-4); padding: var(--space-4); border: var(--bw-hairline) solid var(--tile-border); background: var(--tile-surface); }
.reader-summary > .finding-comparison { border-bottom: var(--bw-hairline) solid var(--tile-border); padding: var(--space-4); margin: 0 0 var(--space-4); }
.finding-comparison__header { cursor: pointer; display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2) var(--space-4); list-style: none; }
.finding-comparison__header::-webkit-details-marker { display: none; }
.finding-comparison__header h4 { margin: 0; }
.finding-comparison[open] > .finding-comparison__header { margin-bottom: var(--space-3); }
.finding-comparison__scope { font-size: var(--type-table-body-size); font-weight: var(--fw-regular); color: var(--text-secondary); }
.finding-comparison__change { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-3); margin: var(--space-2) 0; }
.finding-comparison__note { margin: var(--space-2) 0 0; font-size: var(--type-table-body-size); color: var(--text-secondary); }
/* Security Domains (mockup A): score nodes A → B, labelled change and diverging resolved/worse bars. */
.comparison-domains { display: grid; min-width: 720px; }
.comparison-domain { display: grid; grid-template-columns: minmax(200px, 1.4fr) 40px 16px 40px 96px minmax(200px, 1fr); align-items: center; gap: var(--space-3);
padding: var(--space-2) var(--space-3); border: var(--bw-hairline) solid var(--tile-divider); margin-top: calc(-1 * var(--bw-hairline)); }
.comparison-domain.is-header { border: 0; padding-block: 0 var(--space-1); font-size: var(--type-meta-size); color: var(--text-secondary); }
.comparison-domain__name { font-size: var(--type-body-size); }
.comparison-domain__node { display: grid; place-items: center; width: 36px; height: 36px; border: 3px solid var(--donut-track); border-radius: 50%;
font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
.comparison-domain__node.is-empty { border-style: dashed; color: var(--text-secondary); }
.comparison-domain__arrow { color: var(--text-secondary); text-align: center; }
.comparison-domain__legend { display: flex; justify-content: space-between; gap: var(--space-2); }
.comparison-domain__bars { display: grid; grid-template-columns: 1fr 1px 1fr; align-items: center; height: 16px; }
.comparison-domain__axis { height: 16px; background: var(--border-strong); }
.comparison-domain__bar { height: 8px; }
.comparison-domain__bar.is-better { justify-self: end; background: var(--outcome-pass); }
.comparison-domain__bar.is-worse { justify-self: start; background: var(--outcome-fail); }
.comparison-domain__only { margin: 0; }
.comparison-detail__actions { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-3); }
/* Compare table: labels never break inside a word, A and B use the same type size and wrap at word boundaries. */
.finding-comparison .comparison-table td, .finding-comparison .comparison-table th { font-size: var(--type-table-body-size); overflow-wrap: break-word; }
.finding-comparison .comparison-table td:first-child { width: 1%; white-space: nowrap; color: var(--text-secondary); }
.finding-comparison .comparison-table th:not(:first-child), .finding-comparison .comparison-table td:not(:first-child) { width: 50%; min-width: 8rem; font-size: var(--type-table-body-size); }

View File

@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import { groupProjectImportFiles, type ScanImportGroup } from '../project-import/datahub-import-groups';
import type { TimeslotComparisonReport } from '../../comparison/timeslot-comparison-report';
import type { TimeslotDomainComparison } from '../../comparison/timeslot-domain-comparison';
const api = new AssessorApiClient();
@ -49,7 +50,7 @@ export function usePublishedComparison(projectId: string | undefined, openAssess
/** Test support. */
export function resetTimeslotComparisonRequestsForTests(): void {
reports.clear(); timeslotRequests.clear(); baselineChoices.clear(); published = undefined; notify();
reports.clear(); domainRequests.clear(); timeslotRequests.clear(); baselineChoices.clear(); published = undefined; notify();
}
function comparable(group: ScanImportGroup): ComparableTimeslot {
@ -146,3 +147,28 @@ export function useTimeslotComparison(projectId: string | undefined, openAssessm
selectTarget: (id: string) => setTargetChoice({ scope, id })
};
}
const domainRequests = new Map<string, Promise<TimeslotDomainComparison>>();
export type DomainComparisonState = { status: 'loading' } | { status: 'error' } | { status: 'ready'; domains: TimeslotDomainComparison };
/** Security Domain values of the selected A/B pair; requested apart from the report because they read both Work datasets. */
export function useDomainComparison(projectId: string | undefined, baseline?: ComparableTimeslot, target?: ComparableTimeslot): DomainComparisonState {
const requestKey = projectId && baseline && target
? JSON.stringify([projectId, baseline.assessmentIds, baseline.scanRunIds, target.assessmentIds, target.scanRunIds]) : '';
const [result, setResult] = useState<{ key: string; state: DomainComparisonState }>();
useEffect(() => {
if (!requestKey || !projectId || !baseline || !target) return;
let current = true;
let request = domainRequests.get(requestKey);
if (!request) {
request = api.getTimeslotDomainComparison<TimeslotDomainComparison>(projectId, baseline, target);
domainRequests.set(requestKey, request);
while (domainRequests.size > 8) domainRequests.delete(domainRequests.keys().next().value!);
request.catch(() => domainRequests.delete(requestKey));
}
request.then(domains => { if (current) setResult({ key: requestKey, state: { status: 'ready', domains } }); },
() => { if (current) setResult({ key: requestKey, state: { status: 'error' } }); });
return () => { current = false; };
}, [requestKey]); // eslint-disable-line react-hooks/exhaustive-deps
return result?.key === requestKey ? result.state : { status: 'loading' };
}

View File

@ -8,7 +8,8 @@ import { managementCategoryTexts } from '../../reporting/management-report-texts
import { ClientContextHeatmap } from '../context-layer/ContextHeatmap';
import { ClientIsoControls, preloadClientComplianceOverview } from '../../compliance/ui/ClientIsoControls';
import { ComparisonTab } from '../comparison/ComparisonTab';
import { COMPARISON_CHANGE_FACET_ID } from '../comparison/comparison-work-facets';
import { COMPARISON_CHANGE_FACET_ID, workRecordIdsFor } from '../comparison/comparison-work-facets';
import type { TimeslotChangeEntry } from '../../comparison/timeslot-comparison-report';
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';
@ -1150,6 +1151,12 @@ export function dashboardAnalysis(
}
}
/**
* A Compare tab request to open a finding of timeslot A: kept across the reload of Client Info that opening
* timeslot A starts, and served once that timeslot's records are loaded.
*/
let pendingBaselineFinding: { projectId: string; assessmentIds: string; entry: TimeslotChangeEntry } | undefined;
export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const app = useApp();
const { dataset, activeSheet, activeProfile, reviews, setStatusFilter, setFacetFilter, setPathFindingIds, resetFilters, setView } = app;
@ -1246,6 +1253,28 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
[securityAnalysis]
);
// Opens exact Work records of the open timeslot in the Reports view, whatever their result state.
const openRecordsInWork = (recordIds: string[]) => {
if (!recordIds.length || !dataset) return;
resetFilters();
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
if (targetSheet) app.setActiveSheet(targetSheet.id);
app.setActionOnly(false);
setStatusFilter('all');
setPathFindingIds(recordIds);
app.setSelectedRecord(recordIds[0]);
setView('records');
onOpenWork();
};
const workRecords = () => (dataset?.sheets.find(sheet => sheet.isProjectSheet) ?? activeSheet)?.analysis.records ?? [];
useEffect(() => {
const pending = pendingBaselineFinding;
if (!pending || !dataset || app.targetProjectId !== pending.projectId
|| [...(app.activeAssessmentIds ?? [])].sort().join() !== pending.assessmentIds) return;
pendingBaselineFinding = undefined;
openRecordsInWork(workRecordIdsFor(pending.entry, 'baseline', workRecords()));
}, [dataset, app.targetProjectId, app.activeAssessmentIds]); // eslint-disable-line react-hooks/exhaustive-deps
if (!dataset || !activeSheet) return app.targetProjectId
? <div className="dashboard-page panel-is-loading"><h2>Client Info</h2><PanelLoadingState message={app.status}/></div>
: <EmptyWorkspace />;
@ -1267,7 +1296,12 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
// A resolved finding is Passed in timeslot B; every other change keeps the standard Deviation status.
setStatusFilter(change === 'resolved' ? 'all' : 'deviation');
setFacetFilter(COMPARISON_CHANGE_FACET_ID, { values: [change] });
}, false)}/></div>
}, false)}
onOpenFinding={(entry, timeslot, baseline) => {
if (timeslot === 'target') { openRecordsInWork(workRecordIdsFor(entry, 'target', workRecords())); return; }
pendingBaselineFinding = { projectId: app.targetProjectId!, assessmentIds: [...baseline.assessmentIds].sort().join(), entry };
void app.openProject(app.targetProjectId!, baseline.scanRunIds, baseline.assessmentIds);
}}/></div>
: null;
// Compliance Engine tab: one tile per framework; a Results window opens a control or theme in Work.
const complianceContent = showCompliance ? <ClientIsoControls projectId={app.targetProjectId} assessmentIds={app.activeAssessmentIds} scanRunIds={app.activeScanRunIds}

View File

@ -9,7 +9,7 @@ const components = readFileSync(new URL('../../src/styles/components.css', impor
describe('ASSESSOR version in the brand header', () => {
it('shows the centrally maintained product version and opens About from the brand', () => {
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.26.1');
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.27.0');
expect(app).toContain("'db-structure': 'Data Flow'");
expect(app).toContain("['db-structure', 'Data Flow']");
expect(app).not.toContain("'DB Structure'");

View File

@ -0,0 +1,104 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { DashboardPanel } from '../../src/features/dashboard/DashboardPanel';
import { useApp } from '../../src/app/AppProvider';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import { makeSheet } from '../../src/importers/sheet-factory';
import { genericProfile } from '../../src/profiles/generic/generic-profile';
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
import { resetTimeslotComparisonRequestsForTests } from '../../src/features/comparison/use-timeslot-comparison';
import type { Assessment, ImportFileRecord, ScanRun } from '../../src/domain/canonical-model';
import type { ImportedDataset } from '../../src/domain/dataset';
import type { TimeslotComparisonReport } from '../../src/comparison/timeslot-comparison-report';
vi.mock('../../src/app/AppProvider', () => { const useApp = vi.fn(); return { useApp, useOptionalApp: useApp }; });
vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => <span>Synthetic timeslot</span> }));
// Compare tab → Work (mockup B): a selected change opens its exact finding in Work, in the open timeslot B or,
// after opening it, in timeslot A. Synthetic data only.
const at = { a: '2026-01-10T10:00:00.000Z', b: '2026-02-10T10:00:00.000Z' };
const assessment = (id: string, effectiveAt: string): Assessment => ({ id, projectId: 'synthetic-client', name: id, createdAt: effectiveAt, updatedAt: effectiveAt, effectiveAt });
const run = (id: string, assessmentId: string, time: string): ScanRun => ({ id, assessmentId, sequence: 1, label: id, tool: { id: 'cis-cat', name: 'CIS-CAT Pro' }, importedAt: time });
const file = (runId: string, time: string) => ({ id: `file-${runId}`, scanRunId: runId, fileName: 'synthetic.html', format: 'HTML', sizeBytes: 1,
importedAt: time, lastModifiedAt: time, detectedTool: { id: 'cis-cat', name: 'CIS-CAT Pro' } }) as unknown as ImportFileRecord;
const report = {
version: '1.0.0', projectId: 'synthetic-client', baselineRunIds: ['run-a'], targetRunIds: ['run-b'],
sources: [{ sourceId: 'cis-cat', state: 'compared', reasons: [] }],
changes: [{ sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['a-1'], counterpartIds: ['b-1'], title: 'Synthetic rule', ruleId: '1.1', reasons: [] }],
summary: { resolved: 1, unchanged: 0, 'value-changed': 0, unverified: 0, 'not-comparable': 0, 'not-applicable': 0, new: 0, regressed: 0, targetNotComparable: 0 },
score: { sources: [], excludedSources: [] }, scanner: {}
} as unknown as TimeslotComparisonReport;
function timeslotDataset(occurrence: string) {
const sheet = makeSheet('Synthetic', [['Title', 'Status'], ['Other rule', 'Failed'], ['Synthetic rule', 'Passed']], '', 'synthetic.csv', { isProjectSheet: true });
sheet.analysis.records[0].findingOccurrenceId = `${occurrence}-other`;
sheet.analysis.records[1].findingOccurrenceId = occurrence;
return { sheet, dataset: { fileName: 'Synthetic client', sheets: [sheet], scanTool: 'Generic' } as ImportedDataset };
}
let host: HTMLDivElement;
let root: Root;
let app: Record<string, unknown>;
function mockApp(occurrence: string, assessmentId: string) {
const { sheet, dataset } = timeslotDataset(occurrence);
app = { ...app, dataset, activeSheet: sheet, activeAssessmentIds: [assessmentId] };
vi.mocked(useApp).mockReturnValue(app as unknown as ReturnType<typeof useApp>);
return sheet;
}
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
localStorage.clear(); resetEnginePreferencesForTests(); resetTimeslotComparisonRequestsForTests();
for (const engine of ['context', 'compliance', 'remediation', 'report'] as const) setEngineEnabled(engine, false);
app = { activeProfile: genericProfile, reviews: {}, targetProjectId: 'synthetic-client', activeScanRunIds: [], facetFilters: {}, search: '',
statusFilter: 'all', actionOnly: false, backendStatus: 'connected', status: '',
setStatusFilter: vi.fn(), setFacetFilter: vi.fn(), setPathFindingIds: vi.fn(), resetFilters: vi.fn(), setView: vi.fn(), setActionOnly: vi.fn(),
setActiveSheet: vi.fn(), setSelectedRecord: vi.fn(), openProject: vi.fn(async () => true) };
vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue({ observations: [], sections: [] } as never);
vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never);
vi.spyOn(AssessorApiClient.prototype, 'listProjectImportFiles').mockResolvedValue([file('run-a', at.a), file('run-b', at.b)]);
vi.spyOn(AssessorApiClient.prototype, 'listProjectAssessmentHistory').mockResolvedValue({
assessments: [assessment('assessment-a', at.a), assessment('assessment-b', at.b)],
scanRuns: [run('run-a', 'assessment-a', at.a), run('run-b', 'assessment-b', at.b)]
} as never);
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report);
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotDomainComparison').mockResolvedValue({ version: '1.0.0', sources: ['cis-cat'], domains: [] } as never);
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount()); host.remove();
vi.restoreAllMocks(); vi.unstubAllGlobals(); resetEnginePreferencesForTests();
});
const settle = () => act(async () => { for (let index = 0; index < 8; index++) await Promise.resolve(); });
const button = (label: string) => [...host.querySelectorAll<HTMLButtonElement>('button')].find(item => item.textContent?.trim() === label);
it('opens the selected change in Work for timeslot B and, after opening it, for timeslot A', async () => {
const sheetB = mockApp('b-1', 'assessment-b');
const openWork = vi.fn();
await act(async () => root.render(<DashboardPanel onOpenWork={openWork}/>));
await act(async () => button('Compare')!.click());
await settle();
expect(host.querySelector('.comparison-item.is-selected')?.textContent).toContain('Synthetic rule');
await act(async () => button('Open in Work')!.click());
// The resolved finding is Passed in B: Work opens exactly that record in the Reports view, whatever its status.
expect(app.setStatusFilter).toHaveBeenLastCalledWith('all');
expect(app.setPathFindingIds).toHaveBeenLastCalledWith([sheetB.analysis.records[1].id]);
expect(app.setSelectedRecord).toHaveBeenLastCalledWith(sheetB.analysis.records[1].id);
expect(app.setView).toHaveBeenLastCalledWith('records');
expect(openWork).toHaveBeenCalledTimes(1);
await act(async () => button('Open in timeslot A')!.click());
expect(app.openProject).toHaveBeenCalledWith('synthetic-client', ['run-a'], ['assessment-a']);
expect(openWork).toHaveBeenCalledTimes(1);
// Once timeslot A is loaded, its record of the same finding opens in Work.
const sheetA = mockApp('a-1', 'assessment-a');
await act(async () => root.render(<DashboardPanel onOpenWork={openWork}/>));
await settle();
expect(app.setPathFindingIds).toHaveBeenLastCalledWith([sheetA.analysis.records[1].id]);
expect(openWork).toHaveBeenCalledTimes(2);
});

View File

@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest';
import { comparisonReadPortFrom, type ComparisonReadMethods } from '../../src/comparison/comparison-read-port';
// The engine port shares reads within one repository revision and never serves a read of an older revision.
function repository() {
return {
getProject: vi.fn(async () => null),
listAssessments: vi.fn(async () => []),
loadAssessmentModel: vi.fn(async (id: string) => ({ id })),
queryUnifiedResults: vi.fn(async () => ({ results: [] })),
listPolicyEvaluations: vi.fn(async () => []),
listGpoBackupInstances: vi.fn(async () => [])
};
}
describe('Comparison Engine read port', () => {
it('loads each read once per revision, also for parallel requests of both timeslots', async () => {
const source = repository();
const port = comparisonReadPortFrom(source as unknown as ComparisonReadMethods, async () => 1);
await Promise.all([port.listPolicyEvaluations('p'), port.listPolicyEvaluations('p')]);
const [a, b] = await Promise.all([port.loadAssessmentModel('a'), port.loadAssessmentModel('a')]);
await port.loadAssessmentModel('b');
expect(source.listPolicyEvaluations).toHaveBeenCalledTimes(1);
expect(source.loadAssessmentModel).toHaveBeenCalledTimes(2);
expect(a).toBe(b);
});
it('reloads after a committed change and does not keep a failed read', async () => {
const source = repository();
let revision = 1;
const port = comparisonReadPortFrom(source as unknown as ComparisonReadMethods, async () => revision);
await port.loadAssessmentModel('a');
revision = 2;
await port.loadAssessmentModel('a');
expect(source.loadAssessmentModel).toHaveBeenCalledTimes(2);
source.listGpoBackupInstances.mockRejectedValueOnce(new Error('unavailable'));
await expect(port.listGpoBackupInstances('p')).rejects.toThrow('unavailable');
await port.listGpoBackupInstances('p');
expect(source.listGpoBackupInstances).toHaveBeenCalledTimes(2);
});
});

View File

@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import type { ImportFileRecord, ScanRun, Assessment } from '../../src/domain/canonical-model';
import type { TimeslotComparisonReport } from '../../src/comparison/timeslot-comparison-report';
import type { TimeslotDomainComparison } from '../../src/comparison/timeslot-domain-comparison';
import { ComparisonTab } from '../../src/features/comparison/ComparisonTab';
import { resetTimeslotComparisonRequestsForTests } from '../../src/features/comparison/use-timeslot-comparison';
@ -36,6 +37,13 @@ const report: TimeslotComparisonReport = {
scanner: {} as TimeslotComparisonReport['scanner']
};
const side = (score: number | undefined, passed: number, deviations: number) => ({ ...(score === undefined ? {} : { score }), passed, deviations });
const domains: TimeslotDomainComparison = { version: '1.0.0', sources: ['cis-cat'], domains: [
{ id: 'identity', label: 'Identity & Authentication', baseline: side(48, 12, 13), target: side(55.2, 16, 13), onlyInSources: [], resolved: 4, worse: 1 },
{ id: 'cloud', label: 'Cloud & Collaboration', baseline: side(undefined, 0, 0), target: side(46, 6, 7), onlyIn: 'target', onlyInSources: ['maester'], resolved: 0, worse: 0 },
{ id: 'monitoring', label: 'Monitoring & Recovery', baseline: side(61, 11, 7), target: side(58, 11, 8), onlyInSources: [], resolved: 0, worse: 2 }
] };
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
@ -46,6 +54,7 @@ beforeEach(() => {
scanRuns: [run('run-a', 'assessment-a', dates.a), run('run-b', 'assessment-b', dates.b), run('run-c', 'assessment-c', dates.c)]
} as never);
vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never);
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotDomainComparison').mockResolvedValue(domains as never);
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
@ -66,7 +75,7 @@ describe('Comparison Engine Compare tab', () => {
await settle();
expect(request).toHaveBeenCalledWith('dummy-project',
expect.objectContaining({ assessmentIds: ['assessment-b'] }), expect.objectContaining({ assessmentIds: ['assessment-c'] }));
const selects = [...host.querySelectorAll('select')];
const selects = [...host.querySelectorAll('.comparison-tab__timeslots select')] as HTMLSelectElement[];
expect(selects.map(select => select.value)).toEqual(['timeslot-2026-02-10', 'timeslot-2026-03-10']);
expect([...host.querySelectorAll('.comparison-score dd')].map(item => item.textContent)).toEqual(['52', '55']);
expect(host.querySelector('.comparison-tile .comparison-delta')?.textContent).toBe('▲ +3 Better');
@ -81,6 +90,110 @@ describe('Comparison Engine Compare tab', () => {
expect(maester?.textContent).toContain('Not in timeslot A');
});
it('lists the changed findings under the source table and filters them by change', async () => {
const titled: TimeslotComparisonReport = { ...report, changes: [
{ sourceId: 'cis-cat', change: 'unchanged', side: 'baseline', recordIds: ['b-2'], counterpartIds: ['c-2'], title: 'Synthetic unchanged rule', ruleId: '1.2', reasons: [] },
{ sourceId: 'cis-cat', change: 'regressed', side: 'target', recordIds: ['c-4'], counterpartIds: [], title: 'Synthetic low rule', ruleId: '5.2', severity: 'low', reasons: [] },
{ sourceId: 'cis-cat', change: 'regressed', side: 'target', recordIds: ['c-3'], counterpartIds: [], title: 'Synthetic regressed rule', ruleId: '5.1', severity: 'critical', reasons: [] },
{ sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['b-1'], counterpartIds: ['c-1'], title: 'Synthetic resolved rule', ruleId: '1.1', reasons: [] },
{ sourceId: 'group-policy', change: 'value-changed', side: 'baseline', recordIds: ['g-1'], counterpartIds: ['g-2'], title: 'Synthetic setting', reasons: [],
values: { baseline: ['7'], target: ['10'] } }
] };
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(titled);
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']}/>));
await settle();
const rows = () => [...host.querySelectorAll('.comparison-item')].map(row => ({
severity: row.className.replace('comparison-item ', '').replace(' is-selected', ''), title: row.querySelector('.comparison-item__title')?.textContent,
badges: [...row.querySelectorAll('.comparison-item__meta > span')].map(cell => cell.textContent),
badge: row.querySelector(':scope > .reader-severity')?.textContent
}));
// `All changes` leaves out the unchanged ones; better first, then worse by severity, then neutral.
expect(rows()).toEqual([
{ severity: 'severity-unknown', title: 'Synthetic resolved rule', badges: ['▼ Resolved', 'CIS-CAT Pro', '1.1 · Deviation in A · passed in B'], badge: 'Unknown' },
{ severity: 'severity-critical', title: 'Synthetic regressed rule', badges: ['▲ Regressed', 'CIS-CAT Pro', '5.1 · Passed in A'], badge: 'Critical' },
{ severity: 'severity-low', title: 'Synthetic low rule', badges: ['▲ Regressed', 'CIS-CAT Pro', '5.2 · Passed in A'], badge: 'Low' },
{ severity: 'severity-unknown', title: 'Synthetic setting', badges: ['≠ Value changed', 'GPOs', 'Deviation in A and B · 7 → 10'], badge: 'Unknown' }
]);
expect(host.querySelector('.comparison-badge.is-worse')?.textContent).toBe('▲ Regressed');
// Rows are clickable like the Work list: the first is selected, a click or arrow key selects another and
// the detail beside the list shows that change with A and B side by side.
const items = () => [...host.querySelectorAll<HTMLButtonElement>('.comparison-item')];
const detailTitle = () => host.querySelector('.comparison-detail__title')?.textContent;
expect(items()[0].getAttribute('aria-pressed')).toBe('true');
expect(detailTitle()).toBe('Synthetic resolved rule');
await act(async () => items()[3].click());
expect(items()[3].className).toContain('is-selected');
expect(detailTitle()).toBe('Synthetic setting');
const valueRow = [...host.querySelectorAll('.comparison-detail tbody tr')].find(row => row.textContent?.startsWith('Configured value'));
expect([...valueRow!.querySelectorAll('td')].slice(1).map(cell => cell.textContent)).toEqual(['7', '10']);
await act(async () => host.querySelector('.comparison-list')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })));
expect(detailTitle()).toBe('Synthetic low rule');
expect(document.activeElement).toBe(items()[2]);
const filter = host.querySelector<HTMLSelectElement>('.comparison-tile__filter select')!;
await act(async () => { filter.value = 'unchanged'; filter.dispatchEvent(new Event('change', { bubbles: true })); });
expect(rows().map(row => row.title)).toEqual(['Synthetic unchanged rule']);
await act(async () => { filter.value = 'new'; filter.dispatchEvent(new Event('change', { bubbles: true })); });
expect(host.textContent).toContain('No finding has this change.');
});
it('shows Security Domains A → B with a labelled change, resolved against worse bars and a domain only in B', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report);
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']}/>));
await settle();
const rows = [...host.querySelectorAll('.comparison-domain:not(.is-header)')];
const cells = (row: Element) => [...row.children].map(cell => cell.textContent);
expect(cells(rows[0]).slice(0, 5)).toEqual(['Identity & Authentication', '48', '→', '55', '▲ +7']);
expect(rows[0].querySelector<HTMLElement>('.comparison-domain__bar.is-better')?.style.width).toBe('100%');
expect(rows[0].querySelector<HTMLElement>('.comparison-domain__bar.is-worse')?.style.width).toBe('25%');
expect(cells(rows[1])).toEqual(['Cloud & Collaboration', '—', '→', '46', '○ Only in B', 'Maester only in B · not comparable']);
expect(cells(rows[2])[4]).toBe('▼ −3');
expect(rows[2].querySelector('.comparison-delta')?.className).toContain('is-worse');
});
it('shows a scanner value change with both Problem values side by side', async () => {
const changed: TimeslotComparisonReport = { ...report, changes: [{ sourceId: 'purple-knight', change: 'value-changed', side: 'baseline',
recordIds: ['b-5'], counterpartIds: ['c-5'], title: 'Synthetic templates', ruleId: 'SI-1', reasons: [],
problem: { baseline: [{ label: 'Result', value: 'Found 1 synthetic template' }, { label: 'Affected', value: '1' }],
target: [{ label: 'Result', value: 'Found 1 synthetic template' }, { label: 'Affected', value: '3' }] } }] };
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(changed);
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']}/>));
await settle();
expect(host.querySelector('.comparison-item__note')?.textContent).toBe('SI-1 · Deviation in A and B · Affected 1 → 3');
const rows = [...host.querySelectorAll('.comparison-detail .finding-comparison tbody tr')].map(row => [...row.children].map(cell => cell.textContent));
// Only what differs between A and B; the identical Result line stays in the Problem block.
expect(rows).toEqual([['Verdict', '✕ Deviation', '✕ Deviation'], ['Affected', '1', '3']]);
// Like GPO configured values: red where the timeslot deviates (here both), green where it passes.
expect([...host.querySelectorAll('.comparison-detail .finding-comparison .policy-value-mark')].map(item => [item.textContent, item.className])).toEqual([
['1', 'policy-value-mark is-deviating'], ['3', 'policy-value-mark is-deviating']]);
});
it('marks a resolved finding red in A and green in B and keeps the expected value neutral', async () => {
const resolved: TimeslotComparisonReport = { ...report, changes: [{ sourceId: 'cis-cat', change: 'resolved', side: 'baseline',
recordIds: ['b-6'], counterpartIds: ['c-6'], title: 'Synthetic rule', ruleId: '1.1.1', reasons: [],
problem: { baseline: [{ label: 'Expected', value: '>= 14' }, { label: 'Found', value: '7' }],
target: [{ label: 'Expected', value: '>= 14' }, { label: 'Found', value: '14' }] } }] };
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(resolved);
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']}/>));
await settle();
const row = (label: string) => [...host.querySelectorAll('.comparison-detail tbody tr')].find(item => item.firstElementChild?.textContent === label)!;
expect([...row('Found').querySelectorAll('.policy-value-mark')].map(item => item.className)).toEqual(['policy-value-mark is-deviating', 'policy-value-mark is-aligned']);
expect(row('Expected')).toBeUndefined();
});
it('shows a CIS-CAT evidence row that changed only in its value by that value, and hides identical rows', async () => {
const found = (value: string) => [{ label: 'Expected', value: 'Criterion: synthetic' },
{ label: 'Found', value: 'Type · String · Exists · reg_dword' }, { label: 'Found', value: `Value · Int · Exists · ${value}` }];
const cis: TimeslotComparisonReport = { ...report, changes: [{ sourceId: 'cis-cat', change: 'value-changed', side: 'baseline',
recordIds: ['b-7'], counterpartIds: ['c-7'], title: 'Synthetic registry rule', ruleId: '2.3.17.2', reasons: [],
problem: { baseline: found('5'), target: found('0') } }] };
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(cis);
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']}/>));
await settle();
expect(host.querySelector('.comparison-item__note')?.textContent).toBe('2.3.17.2 · Deviation in A and B · Found 5 → 0');
const rows = [...host.querySelectorAll('.comparison-detail .finding-comparison tbody tr')].map(row => [...row.children].map(cell => cell.textContent));
expect(rows).toEqual([['Verdict', '✕ Deviation', '✕ Deviation'], ['Found · Value', '5', '0']]);
});
it('explains that the oldest timeslot has nothing before it', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison');
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-a']}/>));

View File

@ -13,7 +13,10 @@ import type { PublishedComparison } from '../../src/features/comparison/use-time
// The Work `Change` facet maps the comparison onto Work records of timeslot B. Synthetic records only.
const gpoRecordId = (identity: string) =>
`gpo-work-setting:${encodeURIComponent(JSON.stringify(['dummy-project', 'aggregation-b', 'computer', identity]))}`;
const record = (id: string, findingOccurrenceId?: string) => ({ id, ...(findingOccurrenceId ? { findingOccurrenceId } : {}) }) as FindingRecord;
const record = (id: string, findingOccurrenceId?: string) => ({ id, fields: [], ...(findingOccurrenceId ? { findingOccurrenceId } : {}) }) as unknown as FindingRecord;
// A rolled-up GPO setting whose Work record ID does not carry the setting identity: matched by its Raw Data fields.
const gpoFieldRecord = (scope: string, identity: string) => ({ id: 'record-synthetic', fields: [
{ label: 'Category', value: 'GPOs' }, { label: 'Scope', value: scope }, { label: 'Technical Identity', value: identity }] }) as unknown as FindingRecord;
const report = {
changes: [
@ -38,6 +41,8 @@ describe('Comparison Engine Work Change facet', () => {
expect(index.changeOf(record('work-b-3', 'b-3'))).toBe('regressed');
expect(index.changeOf(record(gpoRecordId('MinimumPasswordLength')))).toBe('value-changed');
expect(index.changeOf(record(gpoRecordId('SeEnableDelegationPrivilege')))).toBe('new');
expect(index.changeOf(gpoFieldRecord('computer', 'MinimumPasswordLength'))).toBe('value-changed');
expect(index.changeOf(gpoFieldRecord('user', 'MinimumPasswordLength'))).toBeUndefined();
expect(index.changeOf(record('work-other', 'b-9'))).toBeUndefined();
expect(index.facets[0].valuesFor?.(record('work-b-1', 'b-1'))).toEqual(['resolved']);
expect(index.facets[0]).toMatchObject({ id: COMPARISON_CHANGE_FACET_ID, label: 'Change' });

View File

@ -66,11 +66,12 @@ it('shows the comparison of a finding and of a GPO setting after the Compare tab
await settle();
const section = (id: string) => host.querySelector(`[data-testid="${id}"] .finding-comparison`);
expect(section('scanner')?.hasAttribute('open')).toBe(false);
expect(section('scanner')?.textContent).toContain('Resolved');
expect(section('scanner')?.querySelector('tbody tr')?.textContent).toBe('ResultDeviationPassed');
expect(section('scanner')?.textContent).toContain('Change: resolved.');
expect(section('scanner')?.querySelector('tbody tr')?.textContent).toBe('Verdict✕ Deviation✓ Passed');
// Internal evidence codes are never shown; only reasons with a readable text.
expect(section('gpo')?.textContent).not.toContain('same-setting-current-deviation');
const gpo = section('gpo')!;
expect(gpo.textContent).toContain('Value changed');
expect(gpo.textContent).toContain('Still a deviation');
expect(gpo.textContent).toContain('Change: value changed, verdict unchanged.');
const valueRow = [...gpo.querySelectorAll('tbody tr')].find(row => row.textContent?.startsWith('Configured value'));
expect(valueRow?.querySelectorAll('td')[1].textContent).toContain('7');
expect(valueRow?.querySelectorAll('td')[2].textContent).toContain('10');
@ -84,5 +85,5 @@ it('renders nothing while the Comparison Engine is off', async () => {
<div data-testid="scanner"><FindingComparison record={record('work-b-1', 'b-1')}/></div>
</>));
await settle();
expect(host.querySelector('.finding-comparison')).toBeNull();
expect(host.querySelector('[data-testid="scanner"] .finding-comparison')).toBeNull();
});

View File

@ -9,6 +9,8 @@ import type { CanonicalFindingStatus, FindingOccurrence, ImportFileRecord, ScanR
import type { EnvironmentObservation } from '../../src/domain/environment-context';
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
import type { TimeslotComparisonReport } from '../../src/comparison/timeslot-comparison-report';
import type { TimeslotDomainComparison } from '../../src/comparison/timeslot-domain-comparison';
import { securityDomainCatalog } from '../../src/evaluation/security-domains';
// The Comparison Engine route reads two synthetic timeslots through its read-only port.
const stamp = '2026-01-01T08:00:00.000Z';
@ -50,7 +52,8 @@ function occurrence(runId: string, ruleId: string, status: CanonicalFindingStatu
return {
id: `${runId}-${ruleId}`, definitionId: `definition-${ruleId}`, scanRunId: runId, importFileId: `file-${runId}`,
sourceIdentity: { tool, canonicalRuleId: ruleId }, sourcePosition: { rowIndex: 1 },
originalFields: [{ key: 'rule', label: 'Rule', columnIndex: 0, value: ruleId }], fieldMappings: [],
originalFields: [{ key: 'rule', label: 'Rule', columnIndex: 0, value: ruleId },
{ key: 'result', label: 'Result', columnIndex: 1, value: status === 'passed' ? 'Pass' : 'Fail' }], fieldMappings: [],
severity: { originalValue: 'high', normalizedValue: 'high', provenance },
status: { originalValue: status, normalizedValue: status, provenance },
outcome: { originalValue: outcome, normalizedValue: outcome, provenance }
@ -95,6 +98,26 @@ describe('Comparison Engine timeslot route', () => {
const report = await response.json() as TimeslotComparisonReport;
expect(report.summary).toMatchObject({ resolved: 1, unchanged: 1, regressed: 1, new: 0 });
expect(report.score.sources.find(item => item.sourceId === 'cis-cat')).toMatchObject({ baselineScore: 100 / 3, targetScore: 100 / 3, change: 0 });
// The route reads both Work datasets, so every scanner change carries the Finding Reader's Problem values of A and B.
expect(report.changes.every(item => item.problem)).toBe(true);
expect(repository.revision).toBe(revision);
});
it('compares Security Domains over Client Info records and places each change in its domain', async () => {
const { repository, base } = await start();
const revision = repository.revision;
const response = await fetch(`${base}/domains${query('a', 'b')}`);
expect(response.status).toBe(200);
const result = await response.json() as TimeslotDomainComparison;
expect(result.sources).toEqual(['cis-cat']);
expect(result.domains.map(domain => domain.id)).toEqual(securityDomainCatalog.map(domain => domain.id));
const sum = (pick: (domain: TimeslotDomainComparison['domains'][number]) => number) => result.domains.reduce((total, domain) => total + pick(domain), 0);
// A: two deviations and one pass; B: one pass and two deviations; one resolved and one regressed.
expect([sum(domain => domain.baseline.passed), sum(domain => domain.baseline.deviations)]).toEqual([1, 2]);
expect([sum(domain => domain.target.passed), sum(domain => domain.target.deviations)]).toEqual([1, 2]);
expect([sum(domain => domain.resolved), sum(domain => domain.worse)]).toEqual([1, 1]);
// A repeated request of the same revision is served from the cache with the same content.
expect(await (await fetch(`${base}/domains${query('a', 'b')}`)).json()).toEqual(result);
expect(repository.revision).toBe(revision);
});

View File

@ -119,10 +119,25 @@ describe('Timeslot comparison report', () => {
expect(find('resolved')).toEqual(['a-1', 'ga-2']);
expect(find('value-changed')).toEqual(['ga-1']);
expect(find('regressed')).toEqual(['b-3']);
// A regression names its single passing occurrence of A as counterpart, so both timeslots can be read.
expect(result.changes.find(item => item.change === 'regressed')?.counterpartIds).toEqual(['a-3']);
// A source only in B is neither new nor worse.
expect(result.changes.find(item => item.change === 'not-comparable')).toMatchObject({ sourceId: 'maester', side: 'target', recordIds: ['m-1'] });
});
it('names every change with the title and rule ID of its record for the Compare tab list', () => {
const result = report();
expect(result.changes.find(item => item.recordIds[0] === 'a-1')).toMatchObject({ title: 'definition-1.1.1', ruleId: '1.1.1', severity: 'high' });
expect(result.changes.find(item => item.recordIds[0] === 'b-3')).toMatchObject({ title: 'definition-5.1', ruleId: '5.1' });
expect(result.changes.find(item => item.recordIds[0] === 'ga-2')).toMatchObject({ title: 'NoLMHash' });
expect(result.changes.every(item => item.title)).toBe(true);
// A GPO change carries what Work shows for the setting in each timeslot, for the Compare detail.
expect(result.changes.find(item => item.recordIds[0] === 'ga-1')?.gpo).toEqual({
baseline: { verdict: 'Fail', values: ['{"kind":"number","value":7}'], expected: [], backups: 0, conflict: false, duplicate: false },
target: { verdict: 'Fail', values: ['{"kind":"number","value":10}'], expected: [], backups: 0, conflict: false, duplicate: false }
});
});
it('limits the score delta to sources comparable in both timeslots and lists every excluded source', () => {
const result = report();
const gpo = result.score.sources.find(item => item.sourceId === 'group-policy');

View File

@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import type { ImportedDataset } from '../../src/domain/dataset';
import { makeSheet } from '../../src/importers/sheet-factory';
import type { TimeslotComparisonReport } from '../../src/comparison/timeslot-comparison-report';
import { withScannerValueChanges } from '../../src/comparison/timeslot-value-comparison';
// Scanner value changes (user decision 2026-10-13): a finding still deviating in A and B whose Finding Reader
// Problem values differ is `value-changed` (neutral). Synthetic Purple Knight records only.
function timeslot(prefix: string, rows: Array<[string, string, string]>): ImportedDataset {
const sheet = makeSheet('Synthetic', [['Name', 'Status', 'Number of results', 'Result message'],
...rows.map(([name, count, message]) => [name, 'IOE Found', count, message])], '', 'synthetic.xlsx', { isProjectSheet: true });
sheet.toolId = 'purple-knight';
sheet.analysis.records.forEach((record, index) => { record.findingOccurrenceId = `${prefix}-${index + 1}`; });
return { fileName: 'Synthetic', sheets: [sheet], scanTool: 'Purple Knight', profileId: 'purple-knight' } as ImportedDataset;
}
const entry = (index: number) => ({ sourceId: 'purple-knight', change: 'unchanged' as const, side: 'baseline' as const,
recordIds: [`a-${index}`], counterpartIds: [`b-${index}`], reasons: [] });
const report = {
changes: [entry(1), entry(2)],
summary: { resolved: 0, unchanged: 2, 'value-changed': 0, unverified: 0, 'not-comparable': 0, 'not-applicable': 0, new: 0, regressed: 0, targetNotComparable: 0 }
} as unknown as TimeslotComparisonReport;
describe('Comparison Engine scanner value changes', () => {
it('marks a still deviating finding with different Problem values as value changed and keeps an identical one unchanged', () => {
const a = timeslot('a', [['Synthetic templates', '1', 'Found 1 synthetic template'], ['Synthetic accounts', '4', 'Found 4 synthetic accounts']]);
const b = timeslot('b', [['Synthetic templates', '3', 'Found 3 synthetic templates'], ['Synthetic accounts', '4', 'Found 4 synthetic accounts']]);
const result = withScannerValueChanges(report, a, b);
expect(result.changes.map(item => item.change)).toEqual(['value-changed', 'unchanged']);
expect(result.changes[0].problem).toEqual({
baseline: [{ label: 'Result', value: 'Found 1 synthetic template' }, { label: 'Affected', value: '1' }],
target: [{ label: 'Result', value: 'Found 3 synthetic templates' }, { label: 'Affected', value: '3' }]
});
expect(result.summary).toMatchObject({ unchanged: 1, 'value-changed': 1 });
// The input report is not changed; only the returned one carries the value change.
expect(report.changes[0].change).toBe('unchanged');
});
it('never turns another change into a value change and leaves a report without Work data as it is', () => {
const resolved = { ...report, changes: [{ ...entry(1), change: 'resolved' as const }] } as TimeslotComparisonReport;
const a = timeslot('a', [['Synthetic templates', '1', 'Found 1']]);
const b = timeslot('b', [['Synthetic templates', '3', 'Found 3']]);
expect(withScannerValueChanges(resolved, a, b).changes[0].change).toBe('resolved');
expect(withScannerValueChanges(report, null, null).changes).toEqual(report.changes);
// A regression reads its passing A record through its counterpart, so identical values are not shown as missing in A.
const regressed = { ...report, changes: [{ ...entry(1), change: 'regressed' as const, side: 'target' as const, recordIds: ['b-1'], counterpartIds: ['a-1'] }] } as TimeslotComparisonReport;
const same = timeslot('a', [['Synthetic templates', '1', 'Found 1']]);
const problem = withScannerValueChanges(regressed, same, timeslot('b', [['Synthetic templates', '1', 'Found 1']])).changes[0].problem;
expect(problem?.baseline).toEqual(problem?.target);
expect(problem?.baseline.length).toBeGreaterThan(0);
});
});

View File

@ -60,7 +60,7 @@ describe('local ASSESSOR HTTP server', () => {
const health = await fetch(`${baseUrl}/api/v1/health`);
expect(await health.json()).toEqual({
status: 'ok', application: 'ASSESSOR', productVersion: '2.26.1', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
status: 'ok', application: 'ASSESSOR', productVersion: '2.27.0', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
});
expect(health.headers.get('access-control-allow-origin')).toBeNull();
});