diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 3ef1778..6838fd8 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -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. ## Security finding reading hierarchy (2026-10-11, Phase 7 Presentation) diff --git a/package-lock.json b/package-lock.json index e1cd40c..daa8bd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 38f49fe..f884866 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "assessor", - "version": "2.26.1", + "version": "2.27.0", "private": true, "type": "module", "scripts": { diff --git a/server/assessor-http-server.ts b/server/assessor-http-server.ts index ef49395..6cc8525 100644 --- a/server/assessor-http-server.ts +++ b/server/assessor-http-server.ts @@ -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(); const importBatchPreviews = new Map(); const timeslotMetricsCache = new Map>(); + // Comparison Engine results per committed revision and exact A/B scope; bounded, a new revision never reuses one. + const comparisonCache = new Map>(); + const cachedComparison = (key: string, compute: () => Promise): Promise => { + let pending = comparisonCache.get(key) as Promise | 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(); 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. diff --git a/server/timeslot-comparison-input.ts b/server/timeslot-comparison-input.ts index 3b29fae..d1fe2e3 100644 --- a/server/timeslot-comparison-input.ts +++ b/server/timeslot-comparison-input.ts @@ -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 { + 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; +} diff --git a/src/api/assessor-api-client.ts b/src/api/assessor-api-client.ts index 681d6f8..01b7bb9 100644 --- a/src/api/assessor-api-client.ts +++ b/src/api/assessor-api-client.ts @@ -208,12 +208,13 @@ export class AssessorApiClient { /** Comparison Engine route. The engine owns and types its report; the Core client stays engine-free. */ getTimeslotComparison(projectId: string, baseline: { assessmentIds: string[]; scanRunIds: string[] }, target: { assessmentIds: string[]; scanRunIds: string[] }): Promise { - 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(projectId: string, baseline: { assessmentIds: string[]; scanRunIds: string[] }, + target: { assessmentIds: string[]; scanRunIds: string[] }): Promise { + return this.request(`/projects/${encodeURIComponent(projectId)}/timeslot-comparison/domains?${timeslotComparisonQuery(baseline, target)}`); } getWorkPackages(projectId: string, assessmentIds: string[], scanRunIds: string[] = []): Promise { @@ -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(); +} diff --git a/src/comparison/comparison-read-port.ts b/src/comparison/comparison-read-port.ts index c345855..c33a61b 100644 --- a/src/comparison/comparison-read-port.ts +++ b/src/comparison/comparison-read-port.ts @@ -5,14 +5,38 @@ export type ComparisonReadMethods = Pick; export interface ComparisonReadPort extends ComparisonReadMethods { currentRevision(): Promise } +/** 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): ComparisonReadPort { + let cachedRevision: number | undefined; + const caches = new Map>>(); + const shared = (name: string, read: (...args: A) => Promise) => async (...args: A): Promise => { + 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 | 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)) }); } diff --git a/src/comparison/gpo-record-key.ts b/src/comparison/gpo-record-key.ts new file mode 100644 index 0000000..5795f2c --- /dev/null +++ b/src/comparison/gpo-record-key.ts @@ -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; +} diff --git a/src/comparison/gpo-timeslot-comparison.ts b/src/comparison/gpo-timeslot-comparison.ts index 6ccaa8d..89644d2 100644 --- a/src/comparison/gpo-timeslot-comparison.ts +++ b/src/comparison/gpo-timeslot-comparison.ts @@ -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 { const verdict = verdicts.reduce((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); diff --git a/src/comparison/timeslot-comparison-engine.ts b/src/comparison/timeslot-comparison-engine.ts index 0be897b..37477da 100644 --- a/src/comparison/timeslot-comparison-engine.ts +++ b/src/comparison/timeslot-comparison-engine.ts @@ -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(); + const baselinePassIds = new Map(); 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))!) }; } diff --git a/src/comparison/timeslot-comparison-report.ts b/src/comparison/timeslot-comparison-report.ts index 8aec64a..fc3208d 100644 --- a/src/comparison/timeslot-comparison-report.ts +++ b/src/comparison/timeslot-comparison-report.ts @@ -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 } : {}), diff --git a/src/comparison/timeslot-domain-comparison.ts b/src/comparison/timeslot-domain-comparison.ts new file mode 100644 index 0000000..d1b4168 --- /dev/null +++ b/src/comparison/timeslot-domain-comparison.ts @@ -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; + sourceOf: Map; + domainOf: Map; +} + +/** 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(); + const sourceOf = new Map(); + const domainOf = new Map(); + 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(); + const worse = new Map(); + const add = (counts: Map, 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 + }; + }) + }; +} diff --git a/src/comparison/timeslot-value-comparison.ts b/src/comparison/timeslot-value-comparison.ts new file mode 100644 index 0000000..03d17d4 --- /dev/null +++ b/src/comparison/timeslot-value-comparison.ts @@ -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 { + const values = new Map(); + 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) }; +} diff --git a/src/features/comparison/ComparisonTab.tsx b/src/features/comparison/ComparisonTab.tsx index e27d215..b7d5f37 100644 --- a/src/features/comparison/ComparisonTab.tsx +++ b/src/features/comparison/ComparisonTab.tsx @@ -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 = { '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(ALL_CHANGES); + const [selected, setSelected] = useState(); + 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
+
+

Findings

+ +
+ {rows.length ?
+
    { + // 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('.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
  • + +
  • ; + })} +
+ {current && onOpen(current.item, timeslot) : undefined}/>} +
:

No finding has this change.

} +
; +} + +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(); + 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 }) { + 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 ? : null; + } + const summary = findingFirstRead(record, toolId, recordResultSheets(record, work.sheets, work.sheetId, toolId), outcome); + return summary ? : 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; + 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
+
+ {row.source} + {fact.label} +
+

{row.item.title ?? '—'}

+
+ {record && } +
+ + {onOpen && (record || inBaseline) &&
+ {record && } + {inBaseline && } +
} +
; +} + +function DomainNode({ value }: { value?: number }) { + return value === undefined + ? — + : {Math.round(value)}; +} + +/** 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
+

Security Domains

+ {state.status === 'loading' &&

Loading Security Domains…

} + {state.status === 'error' &&

The Security Domains could not be compared.

} + {state.status === 'ready' &&
+
+ DomainA
+ {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
+ {domain.label} + + + + {domain.onlyIn + ? ○ Only in {domain.onlyIn === 'target' ? 'B' : 'A'} + : } + {domain.onlyIn + ? {domain.onlyInSources.map(sourceName).join(', ')} only in {domain.onlyIn === 'target' ? 'B' : 'A'} · not comparable + : + + + + } +
; + })} +
} + {state.status === 'ready' &&

Pass rate of each domain as in Client Info, over the sources compared in both timeslots: {state.domains.sources.map(sourceName).join(', ') || 'none'}.

} +
; +} + +function DomainDelta({ before, after }: { before?: number; after?: number }) { + if (before === undefined || after === undefined) return —; + const change = after - before; + if (change === 0) return = 0; + return change > 0 ? ▲ +{change} : ▼ −{Math.abs(change)}; +} + /** 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 + +

Sources

@@ -160,6 +388,9 @@ export function ComparisonTab({ projectId, assessmentIds = [], onOpenChange }: P
+ + onOpenFinding(entry, timeslot, baseline, target) : undefined}/> } ; } diff --git a/src/features/comparison/FindingComparison.tsx b/src/features/comparison/FindingComparison.tsx index b4df68d..3cb49c6 100644 --- a/src/features/comparison/FindingComparison.tsx +++ b/src/features/comparison/FindingComparison.tsx @@ -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 = { - 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 = { + 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 = { + 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 {verdict.text}; +} + +/** 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) =>

{policyComparisonDisplayLines(value).map((line, lineIndex) => - {lineIndex > 0 && '\n'}{line.emphasized ? {line.text} : line.text})}

)}; + 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) => + {lineIndex > 0 && '\n'}{line.emphasized ? {line.text} : line.text}); + return

{mark ? {lines} : lines}

; + })}; +} + +/** 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) { + const keyed = (values: Array<{ label: string; value: string }>) => { + const seen = new Map(); + 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 ? {value} : <>{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
+

Compare

+ A · {date(baselineDate)} → B · {date(targetDate)}
+
+ + + + + {gpo && <> + + + + + } + {/* 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 => + + + + )} + {!gpo && entry.values && } + +
A · {date(baselineDate)}B · {date(targetDate)}
Verdict
Configured value
Baseline value
Contributing GPO backups{gpo.baseline?.backups || '—'}{gpo.target?.backups || '—'}
Conflicts / Duplicates{flags(gpo.baseline)}{flags(gpo.target)}
{row.label}
Configured value
+
+

{CHANGE_NOTES[entry.change]}{reasons && ` ${reasons}.`}

+
; +} + +/** + * 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
- Compare A · {date(baseline.date)} → B · {date(target.date)} -

- {fact?.label ?? entry.change} - {entry.change === 'value-changed' && Still a deviation; the configured value differs from timeslot A.} -

-
- - - - - {entry.values && } - -
A · {date(baseline.date)}B · {date(target.date)}
Result{state.a}{state.b}
Configured value
-
- {reasons &&

{reasons}

} -

The comparison reads both timeslots and changes no verdict or score.

-
; + return ; } diff --git a/src/features/comparison/comparison-work-facets.ts b/src/features/comparison/comparison-work-facets.ts index dda4545..046b935 100644 --- a/src/features/comparison/comparison-work-facets.ts +++ b/src/features/comparison/comparison-work-facets.ts @@ -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(); const bySetting = new Map(); 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); +} diff --git a/src/features/comparison/comparison.css b/src/features/comparison/comparison.css index 6d36352..f0007dd 100644 --- a/src/features/comparison/comparison.css +++ b/src/features/comparison/comparison.css @@ -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); } diff --git a/src/features/comparison/use-timeslot-comparison.ts b/src/features/comparison/use-timeslot-comparison.ts index fec30d8..0fb257a 100644 --- a/src/features/comparison/use-timeslot-comparison.ts +++ b/src/features/comparison/use-timeslot-comparison.ts @@ -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>(); +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(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' }; +} diff --git a/src/features/dashboard/DashboardPanel.tsx b/src/features/dashboard/DashboardPanel.tsx index 090eccf..d063c03 100644 --- a/src/features/dashboard/DashboardPanel.tsx +++ b/src/features/dashboard/DashboardPanel.tsx @@ -5,7 +5,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'; @@ -1146,6 +1147,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; @@ -1234,6 +1241,28 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) { [analysis] ); + // 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 ?

Client Info

: ; @@ -1255,7 +1284,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)}/> + }, 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); + }}/> : null; // Compliance Engine tab: one tile per framework; a Results window opens a control or theme in Work. const complianceContent = showCompliance ? { 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'"); diff --git a/tests/comparison/comparison-open-finding.test.tsx b/tests/comparison/comparison-open-finding.test.tsx new file mode 100644 index 0000000..7aa7344 --- /dev/null +++ b/tests/comparison/comparison-open-finding.test.tsx @@ -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: () => Synthetic timeslot })); + +// 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; +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); + 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('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()); + 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()); + await settle(); + expect(app.setPathFindingIds).toHaveBeenLastCalledWith([sheetA.analysis.records[1].id]); + expect(openWork).toHaveBeenCalledTimes(2); +}); diff --git a/tests/comparison/comparison-read-port.test.ts b/tests/comparison/comparison-read-port.test.ts new file mode 100644 index 0000000..6515764 --- /dev/null +++ b/tests/comparison/comparison-read-port.test.ts @@ -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); + }); +}); diff --git a/tests/comparison/comparison-tab.test.tsx b/tests/comparison/comparison-tab.test.tsx index 242916c..026cb7b 100644 --- a/tests/comparison/comparison-tab.test.tsx +++ b/tests/comparison/comparison-tab.test.tsx @@ -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()); + 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('.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('.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()); + 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('.comparison-domain__bar.is-better')?.style.width).toBe('100%'); + expect(rows[0].querySelector('.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()); + 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()); + 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()); + 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()); diff --git a/tests/comparison/comparison-work-facet.test.ts b/tests/comparison/comparison-work-facet.test.ts index 1ca258d..48ac9e1 100644 --- a/tests/comparison/comparison-work-facet.test.ts +++ b/tests/comparison/comparison-work-facet.test.ts @@ -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' }); diff --git a/tests/comparison/finding-comparison.test.tsx b/tests/comparison/finding-comparison.test.tsx index 9e25af4..7892bd3 100644 --- a/tests/comparison/finding-comparison.test.tsx +++ b/tests/comparison/finding-comparison.test.tsx @@ -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 () => {
)); await settle(); - expect(host.querySelector('.finding-comparison')).toBeNull(); + expect(host.querySelector('[data-testid="scanner"] .finding-comparison')).toBeNull(); }); diff --git a/tests/comparison/timeslot-comparison-http.test.ts b/tests/comparison/timeslot-comparison-http.test.ts index 1d5b20d..031877c 100644 --- a/tests/comparison/timeslot-comparison-http.test.ts +++ b/tests/comparison/timeslot-comparison-http.test.ts @@ -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); }); diff --git a/tests/comparison/timeslot-comparison-report.test.ts b/tests/comparison/timeslot-comparison-report.test.ts index b6f8f6e..19fd10c 100644 --- a/tests/comparison/timeslot-comparison-report.test.ts +++ b/tests/comparison/timeslot-comparison-report.test.ts @@ -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'); diff --git a/tests/comparison/timeslot-value-comparison.test.ts b/tests/comparison/timeslot-value-comparison.test.ts new file mode 100644 index 0000000..c6691e9 --- /dev/null +++ b/tests/comparison/timeslot-value-comparison.test.ts @@ -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); + }); +}); diff --git a/tests/server/assessor-http-server.test.ts b/tests/server/assessor-http-server.test.ts index 4a477e7..de4cb6f 100644 --- a/tests/server/assessor-http-server.test.ts +++ b/tests/server/assessor-http-server.test.ts @@ -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(); });