Compare commits

..

7 Commits

Author SHA1 Message Date
Binh 305b085f19 feat(comparison): change list, Security Domains and value changes (2.27.0)
- Compare tab: Work-style change list with a detail pane (Problem, Next step and
  an A/B Compare section), Open in Work and Open in timeslot A.
- Security Domains A -> B over the sources compared in both timeslots, with
  resolved against new-plus-regressed bars.
- Value changes for scanner findings: the Finding Reader's Problem values of
  A and B (Result, Affected, Results rows, Expected, Found), shown side by side
  and marked red or green like GPO configured values.
- GPO settings matched by Scope and Technical Identity; regressions carry their
  passing A occurrence.
- Loading: shared reads per repository revision and a revision-bound report,
  workspace and GPO overview cache in the comparison route.
2026-10-14 09:33:37 +02:00
Binh dbbbf5a834 fix(comparison): compare real timeslots (2.26.1)
Split runs imported from several scanners into one source per file scanner
and pair several sources of one scanner by verified scope. On user decisions:
the timeslot order is the time basis (a stated report or backup time only
blocks when B is not newer), a PingCastle rule absent from a complete report
of the same domain and engine version counts as remediated (profile contract
1.1.0), and one report per scanner and timeslot without a stated scope uses
the client as scope. Detail-link and sanitizing diagnostics no longer block;
the scanner score delta uses the engine's verified source pairs.
2026-10-13 15:00:31 +02:00
Binh c26216a4f7 feat(comparison): Compare tab, Work Change facet and Finding Reader section (2.26.0)
Register src/features/comparison as the Comparison Engine UI path and wire its
extension points: the Engines popup entry, the Client Info Compare tab (A/B
selection, score delta over comparable sources, change counts, source table),
the Work Change facet before Source: Scan (only after the tab loaded a
comparison of the open timeslot) and a collapsed Finding Reader Compare
section with both states and the retained GPO values side by side. The engine
now carries those values. The Data Flow diagram shows the engine; product
version 2.26.0.
2026-10-13 14:13:57 +02:00
Binh c14396810e feat(comparison): serve the timeslot comparison through a read-only port
Add the Comparison Engine read port, a Core-owned adapter that loads each
timeslot as Work reads it (scoped scanner runs, rolled-up GPO verdicts, backup
creation time only) and GET /projects/:id/timeslot-comparison with validation,
a concurrent-change guard and an off switch. The API client gains an
engine-free getTimeslotComparison and the activity label.
2026-10-13 13:42:30 +02:00
Binh 3b8cb78717 feat(comparison): compare scanners, GPO settings and score basis across timeslots
Separate new from regressed scanner deviations and accept a timeslot without
scanner runs on one side. Pair rolled-up GPO Work verdicts by scope and
technical identity with a newer backup set and the same baselines; a changed
value of a still deviating setting stays neutral. compareTimeslots yields the
change categories for the UI and a score delta over comparable sources only.
2026-10-13 11:52:56 +02:00
Binh c982f0a0a2 feat(comparison): register the Comparison Engine and relocate the Phase 8 scan-run comparison
Move compareScanRuns, comparison normalization and local comparison rules
unchanged to src/evaluation/scan-run-comparison so src/comparison can become
the Comparison Engine; only import paths change. Register the engine paths,
document it in AGENTS.md and onboarding, extend the boundary test and add the
permanent on/off invariance test. Record the decision and staging in the plan.
2026-10-13 11:34:10 +02:00
Binh f83edc83ac feat(data-flow): show the Remediation Engine and its engine-owned schema
- Engines & relationships: Remediation Engine, its tables and UI in a new
  right column; port, published Context, own data and UI relationships.
- DB Structure reads the declared engine schema assessor_remediation and
  lists its tables schema-qualified.
2026-10-12 13:30:53 +02:00
60 changed files with 3590 additions and 187 deletions

View File

@ -17,6 +17,7 @@ export const ENGINE_PATHS = [
['Report Engine', ['src/reporting/', 'server/reporting/', 'tests/reporting/', 'tests/server/report-archive.test.ts',
'tests/postgres/report-read-only-runtime.test.ts']],
['Compliance Engine', ['src/compliance/', 'server/compliance/', 'tests/compliance/']],
['Comparison Engine', ['src/comparison/', 'src/features/comparison/', 'server/comparison/', 'tests/comparison/']],
];
export const CORE_AREAS = [
@ -30,7 +31,7 @@ export const CORE_AREAS = [
'server/import-executor.ts', 'server/import-worker-client.ts', 'server/import-worker.ts',
'server/node-import-runtime-adapters.ts', 'server/artifacts/']],
['Normalization', ['src/domain/']],
['Evaluation', ['src/evaluation/', 'src/comparison/', 'src/scoring/']],
['Evaluation', ['src/evaluation/', 'src/scoring/']],
['Store', ['src/persistence/', 'server/postgres/', 'server/repository-factory.ts', 'docker-compose.postgres.yml']],
['Reference Catalogs', ['src/references/', 'server/reference-data/']],
['Shared Kernel', ['src/shared/', 'src/vite-env.d.ts']],

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,33 @@
# ASSESSOR – Umsetzungsplan
## Comparison Engine: decision and staging (planned 2026-10-13, Phase 8 timeslot comparison as an engine)
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.
Staging and required Core releases:
1. Move the Core Phase 8 scan-run comparison (`comparison-engine.ts`, `comparison-normalization.ts`, `local-mapping-rules.ts` and their tests) from `src/comparison/` to a Core Evaluation location (proposed `src/evaluation/scan-run-comparison/`), updating its Core importers in Store (`src/persistence/local-assessor-repository.ts`, `server/postgres/postgres-assessor-repository.ts`) and Presentation (`src/features/project-import/datahub-scan-analysis.ts`). Without this move, registering `src/comparison/` as an engine path makes Core import an engine and breaks the boundary test. Needs `Governance, Core Tests, Evaluation, Store, Presentation`.
2. Register the engine paths in `.claude/hooks/core-areas.mjs`, the engine section of `AGENTS.md` and `tests/architecture/core-engine-boundary.test.ts`; the unused timeslot code (`timeslot-comparison-engine.ts` and its test) stays in `src/comparison/` and thereby becomes engine code; add the permanent on/off invariance test. Same release as step 1.
3. Engine work P2/P4 (GPO pairing, A/B score basis) inside the engine paths, then the extension points (Engines popup entry, Client Info tab, Work facet, Finding Reader section; `Presentation`) and the backend route (`Platform`).
4. P3 carried-forward obligations need an engine-owned schema: separate approval, verified backup and migration.
Steps 1-2 (Core release Governance, Core Tests, Evaluation, Store, Presentation): the Phase 8 files moved unchanged to `src/evaluation/scan-run-comparison/` (test to `tests/evaluation/scan-run-comparison/`); only import paths changed in the three Core importers and two tests. `assessor_comparisons`, its routes and data are untouched (0 rows). `.claude/hooks/core-areas.mjs` registers `Comparison Engine` and drops `src/comparison/` from Evaluation; `AGENTS.md`, the boundary test and `docs/ONBOARDING.md` name the engine; new permanent invariance test `tests/comparison/comparison-engine-invariance.test.ts` (synthetic, real local repository: a comparison leaves export, revision and models unchanged; one verified fail-to-pass, one confirmed open). Verified: import gate 528/528 (twice); main suite 2,451 passed, 18 optional skipped, 1 failed: the pre-existing `db-structure-layout.test.ts` tile-radius check, unrelated (no CSS or DB Structure file changed). The first gate run hit the documented 5-second timeout of the combined Node import test once; it passed alone (757 ms) and in the rerun, limit unchanged. Both production builds pass (existing chunk-size warning only). No product version change: no visible behaviour or contract changed.
Step 3a (engine paths only, no Core release): `src/comparison/timeslot-comparison-engine.ts` (result 1.1.0) separates new from regressed scanner deviations and accepts a timeslot without scanner runs on one side; new `gpo-timeslot-comparison.ts` pairs rolled-up GPO Work verdicts across timeslots; new `timeslot-comparison-report.ts` (`compareTimeslots`, 1.0.0) yields the UI change categories (resolved, unchanged, value-changed, unverified, not-comparable, not-applicable, new, regressed; target-side deviations of a one-sided source are not comparable) and the A/B score basis over comparable sources only. Contract details in TIMESLOT_COMPARISON_PLAN.md P2/P4. Open for 3b: a read port that builds both timeslots' models and GPO Work results (a timeslot can hold several assessments; the GPO backup time comes from `backupCreatedAt`), Security Domain deltas, the backend route and the UI. 15 synthetic engine tests. Verified: import gate 528/528; main suite 2,462 passed, 18 optional skipped, 1 failed (the same pre-existing, unrelated `db-structure-layout.test.ts` tile-radius check); local-only and design-language checks pass; both production builds pass. No product version change: no visible behaviour.
Step 3b, first part (Core release Presentation, Platform): engine read port `src/comparison/comparison-read-port.ts`; Platform adapter `server/timeslot-comparison-input.ts` builds each timeslot as Work reads it (scanner models scoped to the selected runs, GPO verdicts through `currentPolicyEvaluationsForWork` and `buildPolicyWorkResults`, GPO observation time only from every contributing backup's `backupCreatedAt`); route `GET /api/v1/projects/:id/timeslot-comparison` (baseline/target assessment and scan-run IDs; 400 for a missing timeslot, a shared scan run or explicit dates in the wrong order, 404 for a foreign assessment, 409 on a concurrent change, 503 when switched off; read-only); `AssessorApiClient.getTimeslotComparison<T>` (engine-free) and the activity label `Comparing timeslots`. Engine UI written and synthetically tested but **parked, not in the repository** (git-ignored `artifacts/compare-engine/parked-ui/`): `ComparisonTab` (A/B selection, B defaults to the open timeslot, A to the one before it, only earlier timeslots as A; Overall Score A/B with a labelled delta over comparable sources; change facts that call `onOpenChange`; source table with comparison basis), `useTimeslotComparison` (timeslots grouped like Data Hub) and its four interaction tests. It cannot live in `src/comparison/`, which `tests/local-only-check.mjs` keeps platform-neutral (no React); like the Context Engine's `src/features/context-layer/`, it needs a registered engine UI path (proposed `src/features/comparison/`, a Governance change to the area map). Security Domain deltas are not in it yet. Not done, needs `Governance, Presentation, Core Tests`: the engine UI path, the Engines popup entry (`tests/components/engines-control.test.tsx` pins the engine list), the Client Info `Compare` tab, the Work `Change` facet and the Finding Reader `Compare` section (each a new pinned extension point in `tests/architecture/core-engine-boundary.test.ts`), and the Data Flow engine diagram (`tests/features/db-structure-flow.test.tsx`). No version change while unwired. Verified: 18 engine tests (3 route tests among them); import gate 528/528; main suite 2,465 passed, 18 optional skipped, 1 failed (the same pre-existing, unrelated `db-structure-layout.test.ts` check); local-only and design-language checks pass; both production builds pass.
Step 3b, second part (Core release Governance, Presentation, Core Tests, Platform; the Data Flow diagram part finished under a renewed Presentation, Core Tests, Platform release after an earlier release had expired mid-turn): the engine UI path `src/features/comparison/` is registered (area map, AGENTS.md, onboarding) and holds the formerly parked `ComparisonTab`, `useTimeslotComparison`, `comparison-work-facets` and `FindingComparison`. Extension points (pinned in the boundary test): Engines popup entry `Comparison Engine` (default on); Client Info tab `Compare` after `Compliance`; Work facet `Change` before `Source: Scan` in the shared filter pipeline (filter-records, work-facets, use-filtered-records, SearchFilterBar, ActionlistControls, SidebarSecurityDomains), present only after the Compare tab loaded a comparison whose B is the open timeslot, so opening Work never starts a comparison; a change count opens Work with that facet value (`Resolved` with status All, all others Deviation) and is clickable only when timeslot B holds a record for it; Finding Reader section `Compare` after Context, collapsed, with both states and, for GPO settings, both retained configured values as Work displays them (the engine now carries them). The Data Flow engine diagram shows the Comparison Engine and its two relationships. Product version 2.26.0 (backward-compatible capability). Synthetic test client `compare test` (2026-06-10 and 2026-09-15) created through the regular import API from generated synthetic CIS-CAT reports (resolved, unchanged, regressed, new and absent rules) plus the synthetic Maester fixture in B only; the generated files are in git-ignored `artifacts/compare-engine/test-client/`. Verified: import gate 528/528; main suite 2,474 passed, 18 optional skipped, 1 failed (the same pre-existing, unrelated `db-structure-layout.test.ts` check); local-only and design-language checks pass; both production builds pass. Not yet verified in the running app: it was started outside this session and still runs 2.25.0.
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)
Current Core release: Presentation, Core Tests, Platform. User correction: Security finding headings use the shared Bold 700 token; the scanner/rule subtitle is removed from these compact rows. The original Description appears immediately after Affected and before Impact, including when its text equals Result. Findings without an Affected field retain Description after Result, or first if Result is absent; no empty source fields are invented. Imported fields, tool identities, verdicts and counts remain unchanged. GPO baseline explanations retain their existing dedicated presentation. Permanent synthetic Light/Dark disclosure interaction verifies the exact order, heading weight, absent scanner subtitle and unchanged source data. All six Security UI checks, 17 UI/API version and Core-boundary checks, and design-language check pass. Patch decision: 2.24.2 for this backward-compatible correction, with shared package/lock and UI/API version contracts synchronized. All 504 mandatory import compatibility checks and both production builds pass (existing client chunk-size warning only); local-only check passes. Actual Dummy browser acceptance in Light/Dark confirms Bold 700 finding headings, no scanner subtitles, and Result → Affected → Description → Impact. Light restored and temporary test tab closed. No full-suite, phase-completion or backend-restart claim. Concurrent work preserved.
@ -4346,6 +4374,7 @@ Verifiziert 2026-09-09 (Phase 7): Bereichswechsel beschleunigt und Performance-V
Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client Info. Aktuelle Evaluationen aus dem vorhandenen autoritativen Readmodell statt ergebnisabhaengiger Backupgruppierung; Import- und Auswertungszeit getrennt, Source-Scan-Provenienz auch auf GPO-only-Blaettern. 1.014 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Dummy lesend bestaetigt: eine statt drei GPO-Quellenoptionen, 3.805 aktuelle Zeilen, elf historische Evaluationen unveraendert. Keine Persistenzmutation oder Neuanalyse.
| Datum | Änderung |
| 2026-10-12 | Phase 7 Data Flow (Core-Freigabe Presentation, Core Tests, Store): Die Remediation Engine erscheint im Diagramm `Engines & relationships` als fuenfte Engine in einer eigenen rechten Spalte mit `Remediation tables` (Schema `assessor_remediation`) und `Work packages in the UI`; Beziehungen: Core-Read-only-Port -> Remediation (eigener Read-only-Pool), Context -> Remediation (`Published`, vom Core-Backend zusammengesetzt), Remediation <-> eigene Tabellen, Remediation -> UI. Kein Pfeil kreuzt einen anderen; die Portbeschreibung nennt den Read-only-Zugriff der Remediation Engine. Die Tabellenuebersicht liest zusaetzlich das deklarierte Engine-Schema `assessor_remediation`; dessen Tabellen erscheinen schemaqualifiziert (`assessor_remediation.package_snapshots`, Schicht Result) unter `Other tables` und im Woerterbuch, Spalten, Fremdschluessel und Zeilenzahlen werden je Schema aufgeloest. Am kanonischen Datastore lesend geprueft: 58 Public-Tabellen plus `assessor_remediation.package_snapshots` mit drei Spalten, keine Namensueberschneidung. Diagrammtests und neuer Sichtbarkeitstest gruen; einziger offener Test bleibt der DB-Structure-Radiustest (#6). Keine Versionserhoehung, weil der Bereich Platform nicht freigegeben war. |
| 2026-10-12 | Importvertraege (Nutzerzustimmung nach Diff-Pruefung, Core-Freigabe Core Tests): Die geschuetzten Snapshots `pingcastle-html` und `pingcastle-xml` sind neu aufgenommen. Grund: die freigegebenen PingCastle-Aenderungen aus 2.19.1 (Profil 1.5.0 -> 1.7.0 im Verarbeitungsschluessel; XML-`DomainFQDN` als `domain.fqdn`/`Domain` mit Subjekt `domain-information`; daraus Vergleichsschluessel des Scores). Findings, Ergebnisse, Schweregrad, Evidenz und Scorewerte unveraendert; Vertragskennungen, Fixture-Hashes und Assertions unveraendert; Reimport-Empfehlung wie in 2.19.1 dokumentiert. Importgate gruen. |
| 2026-10-12 | Integration (Core-Freigabe alle, Governance): Michaels Commit `614ac1b` (Remediation Engine, geschuetzte Importvertraege, Client-Info-Tab `Security`, Technical Report) ist die Basis; die 39 Commits seit `250c213` (AI/ISO/BSI-Compliance-Engine, Defender for IoT) sind darauf rebased (Branch `rebase/michael-backup`, `compliance-mapping` unveraendert). Beide Engines stehen nebeneinander in Engines-Popup (Core, Context, Report, Remediation, Compliance), Hook-Tabelle, Grenztest, Server und Work-Facetten (Work Package an der Stelle von Result Message, ISO/BSI-Facetten weiter direkt vor `Source: Scan`). Client Info nutzt Michaels `ClientInfoTabs` in der vereinbarten Reihenfolge Overview, Security, Microsoft Defender for IoT, Compliance, AI Overview; die drei Engine-/Defender-Tabs sind `lazy` und mounten nur ausgewaehlt (neuer Verhaltenstest), die alte Tab-Navigation und ihre CSS-Regeln entfallen. Finding Reader: `Map ISO controls` steht direkt vor Michaels fester Aktionsgruppe `AI search`/Bookmark/`Results`. Abgeleiteter Forest-Hinweis nach `client-info-facts.ts` portiert. Importgate: zwei additive synthetische Defender-Vertraege (Schwachstellen- und Alert-CSV, je 3 Findings); die Snapshots `pingcastle-html`/`pingcastle-xml` weichen durch die freigegebenen PingCastle-Aenderungen aus 2.19.1 ab (Profil 1.5.0 -> 1.7.0, XML-Domaenenfakten, Vergleichsschluessel) und bleiben bis zur Nutzerpruefung unveraendert, das Gate ist deshalb rot. Sechs weitere Testfehler (Context-Cluster-Reihenfolge, Pathfinder-Mock, Data-Hub-Donut- und DB-Structure-Layouttest) bestehen bereits auf `614ac1b` allein. Produktversion 2.25.0 (Minor: beide Funktionslinien vereint); Versionstests, beide Produktionsbuilds, Local-only- und Designpruefung bestanden. Vor dem ersten Serverstart legt die Remediation Engine das Schema `assessor_remediation` an; vorher Backup des kanonischen Datastores. |
| 2026-10-06 | Phase 7: Data Hub Deviation/Passed now use the same assessed-percentage helper as Client Info; all six donuts state their population basis. 29 focused tests and synthetic Chrome Dark/Light checks passed; absolute counts, other population shares and assessment semantics retained. Client build, design and UI/API version checks passed. Nine out-of-scope report test failures, a report Local-only failure and report server type errors prevent full release verification; attempted 2.16.9 reverted to 2.16.8 pending successful production builds. |

View File

@ -76,7 +76,7 @@ GPO backup.
| **Review / risk acceptance** | Client-owned work state. Stored separately and kept across reimports. |
| **Result DB** | The stored, privacy-checked projection of all results. The only dataset that may be shared or fed to AI. |
| **Core / Core area** | Everything ASSESSOR does today. It is split into areas such as Ingest or Evaluation. Changing an area needs explicit consent (section 7). |
| **Engine** | An optional capability that reads the Core and never writes to it: Context, Report, Compliance. |
| **Engine** | An optional capability that reads the Core and never writes to it: Context, Report, Compliance, Comparison. |
| **Extension point** | A place in a Core view where an engine shows its content, for example the Finding Reader `Context` section. |
---
@ -235,7 +235,7 @@ switched off. It is divided into **Core areas**. The exact path map is in
|---|---|---|
| Ingest | `src/importers`, `src/recognition`, `src/profiles`, `src/gpo`, `server/gpo`, import workers | getting data in |
| Normalization | `src/domain` | canonical model, finding identity, status/severity |
| Evaluation | `src/evaluation`, `src/comparison`, `src/scoring` | verdicts, comparison, scores, security domains |
| Evaluation | `src/evaluation` (incl. `scan-run-comparison`), `src/scoring` | verdicts, comparison, scores, security domains |
| Store | `src/persistence`, `server/postgres` | repositories, PostgreSQL, migrations |
| Client Records | reviews, risk acceptances, review transfer on reimport | client-owned state |
| Disclosure | `src/domain/result-db-disclosure.ts`, `src/exports` | Result DB policy, exports |
@ -259,6 +259,7 @@ Documentation under `docs/` is not gated.
| **Context Engine** | `src/assessment-context/`, `src/features/context-layer/` | Gives every deviation a context cluster, in a fixed order of tiers: *catalogued* (catalogue rule, CIS section, declared ATT&CK technique), *recognized* (distinctive terms, technical anchors) or *estimated* (local TF-IDF similarity, category fallback). Deterministic, no AI. Shown as badges, the `Context Cluster` facet, the Finding Reader section and the Client Info heatmap. A context statement says "favours", never "exploitable". | none (in memory) |
| **Report Engine** | `src/reporting/`, `server/reporting/` | Builds the Management Report (German, offline, no LLM, requires published Context results) and the Technical Report from one shared projection. | own file archive, separate read-only DB pool |
| **Compliance Engine** | `src/compliance/`, `server/compliance/` | Maps findings to regulatory controls; ISO/IEC 27001/27002 is the first framework. Owns **all AI integration**: ISO mapping and the AI overview. | own tables in PostgreSQL, Qdrant index |
| **Comparison Engine** | `src/comparison/`, `src/features/comparison/`, `server/comparison/` | Compares two timeslots A and B of one client: verified resolved, still open, unverified, new, regressed and neutral value changes (see `docs/TIMESLOT_COMPARISON_PLAN.md`). Shown in the Client Info `Compare` tab. | none yet (own schema planned) |
**Engine rules**:

View File

@ -4,6 +4,10 @@ Stand: 2026-09-11. Zugehörigkeit: Phase 8, mit UI aus Phase 7, Persistenz/API a
Status: **in Arbeit, nicht fachlich abgenommen**. P0 ist abgeschlossen. P1 besitzt den additiven Domänenvertrag und verpflichtende profilgebundene Scope-/Zeit-/Evidenzdeklarationen; Persistenz/API und Übergangsvertrag bleiben offen. P2 ist begonnen: `compareProjectTimeslotEvidence` paart echte Scannerläufe je Werkzeug und bewertet Processing, Scope, Reportzeit, Diagnosen sowie Fail-zu-Pass konservativ. Die bisherige Engine und Oberfläche verwenden diesen Pfad noch nicht. P3-P6 sind offen. Der aktuelle Implementierungsschnitt führt keine Migration, Reimporte, Datenmutation oder Aktivierung einer Vergleichsoberfläche aus. Der historische Phase-8-Abschluss vom 2026-07-17 bleibt dokumentiert, ist aber keine Abnahme dieses Arbeitspakets.
Ausdrückliche Nutzerentscheidung 2026-10-13: P1-P6 werden als eigenständig abschaltbare **Comparison Engine** umgesetzt (`src/comparison/`, `server/comparison/`, `tests/comparison/`); der Core-Phase-8-Scanlaufvergleich zieht dafür in einen Core-Evaluation-Ort um. Alle Verträge und die Abnahmematrix dieses Plans bleiben unverändert verbindlich. Stufen und Freigaben: [IMPLEMENTATION_PLAN.md](IMPLEMENTATION_PLAN.md), Abschnitt „Comparison Engine: decision and staging“.
Ausdrückliche Nutzerentscheidungen 2026-10-13 (ersetzen die genannten Stellen): (a) Zeitbasis ist die ausdrückliche Timeslot-Reihenfolge A vor B; die Forderung nach einer verifizierten neueren Beobachtungszeit in §3.2/§3.4 und in der Matrixzeile „Neuerer Scanzeitpunkt fehlt …“ entfällt. Eine eigene Bericht- oder Backupzeit sperrt nur, wenn beide Timeslots eine nennen und B nicht neuer ist (gleicher oder älterer Bericht: nicht vergleichbar). Importzeit wird weiterhin nie verwendet. (b) PingCastle erfüllt den profilgebundenen Negativnachweisvertrag: eine in B fehlende Regel gilt als behoben bei gleicher Domain, gleicher PingCastle-Engineversion, kompatibler Verarbeitung und ohne sperrende Diagnose; sonst bleibt sie ungeklärt. (c) Beide Timeslots gehören zu einem Kunden: Enthält jeder Timeslot genau einen Bericht eines Scanners und nennt keiner seinen Scope (Maester-Excel ohne Tenant), ist der Kunde der Scope. Ein nur einseitig genannter Scope oder mehrere Berichte eines Scanners je Timeslot werden so nie gepaart; ersetzt §3.1 nur für diesen Fall. Details: [IMPLEMENTATION_PLAN.md](IMPLEMENTATION_PLAN.md), Abschnitt „Comparison Engine“.
Dieser Plan präzisiert das Arbeitspaket „faire Vergleiche bei fehlenden Folgequellen“ in [IMPLEMENTATION_PLAN.md](IMPLEMENTATION_PLAN.md). Für die künftige Umsetzung ersetzt er ausdrücklich die ältere Schlussfolgerung „abwesend = behoben“ und die pauschale Ablösung offener Verpflichtungen durch einen neuen Lauf derselben Quelle. Historische Ergebnisse und Originaldaten werden dadurch nicht rückwirkend geändert.
## 1. Ziel und Grenzen
@ -22,7 +26,7 @@ Codeprüfung vom 2026-09-11, keine neue Laufzeitabnahme:
- `compareProjectTimeslots` in [datahub-scan-analysis.ts](../src/features/project-import/datahub-scan-analysis.ts) besitzt derzeit keinen Produktaufrufer. Mehrfachauswahl/Öffnen ist kein expliziter A/B-Bericht.
- Die Timeslot-Projektion ersetzt beide Laufwerkzeuge durch `multiple`, lässt `importFiles` aber an Original-Lauf-IDs. Dadurch kann `sameTool` fälschlich gelten und die dateigebundene Kompatibilitätsprüfung ausfallen.
- Die automatischen Matchingstufen der [comparison-engine.ts](../src/comparison/comparison-engine.ts) erzwingen keine objektgenaue Identität. Eine gemeinsame Regeldefinition ist kein Nachweis desselben betroffenen Objekts. Die Zwei-Lauf-Projektion entfernt außerdem frühere Beobachtungen, die zur Verifikation eines Wiederauftretens gebraucht werden.
- Die automatischen Matchingstufen der [comparison-engine.ts](../src/evaluation/scan-run-comparison/comparison-engine.ts) erzwingen keine objektgenaue Identität. Eine gemeinsame Regeldefinition ist kein Nachweis desselben betroffenen Objekts. Die Zwei-Lauf-Projektion entfernt außerdem frühere Beobachtungen, die zur Verifikation eines Wiederauftretens gebraucht werden.
- Abwesende Vorkommen können `resolved` werden, während zugeordnete Fail-zu-Pass-Ergebnisse `persistent` mit Statusänderung bleiben. Die lokale `isOpen`-Prüfung verwechselt fachliche Abweichungen mit technischen beziehungsweise ungeprüften Zuständen.
- Die Score-Paarbildung prüft viele Kompatibilitätsbedingungen. Ihr `evaluationScopeKey` enthält jedoch keine Asset-Identität; eine unveränderte Regelanzahl kann einen Objektwechsel verdecken.
- `awaitingRevalidation` verwendet schwächere Bedingungen als die Score-Paarbildung. Zusätzlich überschreibt `lastKnown` im [Workspace-Lader](../src/features/project-import/load-canonical-project-workspace.ts) ältere Quellstände, ohne jede offene Verpflichtung zu erhalten. Das kann Verpflichtungen aus der Anzeige verdrängen, löscht aber keine gespeicherten Originalfindings.
@ -87,9 +91,9 @@ Die Paketstände beziehen sich ausschließlich auf nachgewiesene Ergebnisse dies
|---|---|---|
| P0 · abgeschlossen | Synthetisches Bestandsaudit je Scanner/Format/GPO: Regelgranularität, bereits erhaltene Objekt-/Scopefelder, Quellenpfade, Vollständigkeitssignale und fehlende Informationen dokumentieren; notwendige Vertragsänderungen und Reimportbetroffenheit bestimmen | [TIMESLOT_COMPARISON_AUDIT.md](TIMESLOT_COMPARISON_AUDIT.md); keine echten Kundendaten; Fähigkeiten und Grenzen je unterstütztem Pfad dokumentiert |
| P1 · in Arbeit | Versionierte Vergleichsidentität, Gegenprüfungs-/Diagnosevertrag und getrennte Ergebnis-/Verpflichtungszustände in der Domäne spezifizieren; Persistenz-/API-Entwurf und Übergangsregeln festlegen | Domänen- und Profilverträge sowie synthetische Scope-/Zeitfälle umgesetzt; Persistenz-/API- und Übergangsentwurf bleiben offen |
| P2 · in Arbeit | Scanner-/GPO-Paarung anhand echter Quellreferenzen; vollständige Kompatibilitätsprüfungen; objektgenauer Lebenszyklus, Fail-zu-Pass, konservative Abwesenheit und einheitliche aktuelle Abweichungsmetriken | Sicherer Scannerpfad mit echter Tool-/Run-/File-Paarung, Processing-, Scope-, Reportzeit- und Diagnosegrenzen umgesetzt; GPO, Historie, Produktintegration und vollständige Matrix bleiben offen |
| P2 · in Arbeit | Scanner-/GPO-Paarung anhand echter Quellreferenzen; vollständige Kompatibilitätsprüfungen; objektgenauer Lebenszyklus, Fail-zu-Pass, konservative Abwesenheit und einheitliche aktuelle Abweichungsmetriken | Sicherer Scannerpfad mit echter Tool-/Run-/File-Paarung, Processing-, Scope-, Reportzeit- und Diagnosegrenzen umgesetzt. 2026-10-13 in der Comparison Engine: Scanner trennen `neu` und `wieder aufgetreten` (genau ein belegtes Pass derselben Identität in A; mehrdeutige Historie behauptet nichts); einseitige Quellen sind nicht vergleichbar; GPO-Paarung über die unverändert gelesenen, wie in Work hochgerollten Verdikte je Scope und technischer Identität, nur mit neuerer Backupzeit und gleicher Baselinemenge; fehlende oder nicht entschiedene Einstellung bleibt ungeklärt; geänderter Istwert einer weiter abweichenden Einstellung ist neutral (`value-changed`). Historie, Produktintegration und vollständige Matrix bleiben offen |
| P3 | Verpflichtungsbezogene Fortschreibung über alle Zwischenstände, getrennt von der Score-Baseline; atomare/idempotente Ablage und API gemäß P1, Wiederladen und auditierter Scope-Ausstieg | P1/P2; nichts verschwindet bei Teilimport, Reload, Reimport oder mehrdeutiger Zuordnung |
| P4 | Explizite A/B-Scorebasis, objekt-/scopebewusste Population und getrennte Freigaben für Delta/Gegenprüfung; native Scoregrenzen und Coverageausgabe | P1/P2/P3; keine Verbesserung durch Quellenauslassung, Objektwechsel oder historische Baselinevermischung |
| P4 · begonnen | Explizite A/B-Scorebasis, objekt-/scopebewusste Population und getrennte Freigaben für Delta/Gegenprüfung; native Scoregrenzen und Coverageausgabe | P1/P2/P3; keine Verbesserung durch Quellenauslassung, Objektwechsel oder historische Baselinevermischung. 2026-10-13: Delta nur über Quellen mit vergleichbarem Paar in A und B (Scanner über `compareSecurityScores`, GPO als Pass-Rate über in beiden Timeslots entschiedene Einstellungen derselben Baselines); jede ausgeschlossene Quelle mit Grund. Security-Domain-Deltas, Coverageausgabe und P3-Abhängigkeit offen |
| P5 | A/B-Auswahl und dreiteiliger Bericht in der vorhandenen Oberfläche; Gründe, Messbasis, Quellen/Versionen und Belege pro Aussage; aktueller Workspace bleibt unverändert nutzbar | P2-P4 fachlich verifiziert; UI ist Konsument der Domänenlogik, kein eigener Matcher |
| P6 | Gesamtabnahme, Dokumentationsabgleich und kontrollierte Aktivierung | Alle unten genannten Fälle; keine Freigabe allein wegen vorhandener Funktion oder grüner Buildausgabe |

4
package-lock.json generated
View File

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

View File

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

View File

@ -68,6 +68,11 @@ import { ASSESSMENT_CONTEXT_CATALOG } from '../src/assessment-context/context-ca
import { buildWorkPackages, policyFindingKey, scannerFindingKey, REMEDIATION_ENGINE_VERSION, REMEDIATION_CATALOG_VERSION, type RemediationFinding } from '../src/remediation/remediation-engine';
import { remediationReadPortFrom, type RemediationReadPort } from '../src/remediation/remediation-read-port';
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 { 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';
@ -160,6 +165,8 @@ export interface AuditEvent {
export interface AssessorHttpServerOptions {
remediationStore?: RemediationStore | null;
remediationPort?: RemediationReadPort | null;
/** Comparison Engine: declared read-only port; null switches the comparison route off. */
comparisonPort?: ComparisonReadPort | null;
artifactStore?: BackendArtifactStore;
mitreCatalogService?: MitreCatalogService;
repository: AssessorRepository;
@ -462,6 +469,7 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
const repository = options.repository;
const remediationStore = options.remediationStore === undefined ? new MemoryRemediationStore() : options.remediationStore;
const remediationPort = options.remediationPort === undefined ? remediationReadPortFrom(repository, async () => repository.revision) : options.remediationPort;
const comparisonPort = options.comparisonPort === undefined ? comparisonReadPortFrom(repository, async () => repository.revision) : options.comparisonPort;
const clientInformation = clientInformationReadPortFrom(repository,
() => repository.getCommittedRevision?.() ?? Promise.resolve(repository.revision));
const reportPort = options.reportPort === undefined
@ -480,6 +488,18 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
const importSessions = new Map<string, ImportSession>();
const importBatchPreviews = new Map<string, CachedImportBatchPreview>();
const timeslotMetricsCache = new Map<string, Promise<DatahubTimeslotResultMetrics | null>>();
// Comparison Engine results per committed revision and exact A/B scope; bounded, a new revision never reuses one.
const comparisonCache = new Map<string, Promise<unknown>>();
const cachedComparison = <T>(key: string, compute: () => Promise<T>): Promise<T> => {
let pending = comparisonCache.get(key) as Promise<T> | undefined;
if (!pending) {
pending = compute();
comparisonCache.set(key, pending);
void pending.catch(() => comparisonCache.delete(key));
while (comparisonCache.size > 16) comparisonCache.delete(comparisonCache.keys().next().value!);
}
return pending;
};
// Context results per committed revision and exact scope; bounded, and a new revision never reuses an entry.
const assessmentContextCache = new Map<string, AssessmentContext>();
const auditEvents: AuditEvent[] = [];
@ -1169,6 +1189,72 @@ 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' && 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];
if (!await comparisonPort.getProject(projectId)) throw new RepositoryNotFoundError(`Projekt ${projectId} ist nicht vorhanden.`);
const selection = (side: 'baseline' | 'target'): TimeslotSelection => ({
assessmentIds: [...new Set(url.searchParams.getAll(`${side}AssessmentIds`))].sort(),
scanRunIds: [...new Set(url.searchParams.getAll(`${side}ScanRunIds`))].sort()
});
const baseline = selection('baseline');
const target = selection('target');
if (!baseline.assessmentIds.length || !target.assessmentIds.length) {
throw new HttpError(400, 'missing_timeslot_scope', 'Both timeslots require at least one assessment ID.');
}
if (baseline.scanRunIds.some(id => target.scanRunIds.includes(id))) {
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),
...(overviewService ? { getPolicyAnalysisOverview: (id: string) =>
cachedComparison(JSON.stringify(['overview', revision, id]), () => overviewService.get(id)) } : {})
};
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);
});
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.
const assessmentIds = [...new Set(url.searchParams.getAll('assessmentIds'))].sort();

View File

@ -79,7 +79,7 @@ import { compareReimportSourceHashes } from '../../src/domain/reimport-source';
import { planReimportReviewTransfer, type ReimportReviewTransferPlan, type ReimportReviewTransferSummary } from '../../src/domain/reimport-review-transfer';
import { canonicalJson } from '../../src/shared/canonical-json';
import { RevisionReadCache } from './revision-read-cache';
import { validateLocalComparisonRules } from '../../src/comparison/local-mapping-rules';
import { validateLocalComparisonRules } from '../../src/evaluation/scan-run-comparison/local-mapping-rules';
import type {
AssessorRepository,
AssessmentDeleteConfirmation,
@ -395,10 +395,18 @@ const DB_STRUCTURE_LAYER_BY_TABLE: Record<string, DbStructureLayerId> = {
assessor_comparisons: 'governance',
assessor_projects: 'core',
assessor_assessments: 'core',
assessor_repository_state: 'core'
assessor_repository_state: 'core',
'assessor_remediation.package_snapshots': 'result'
};
const DB_STRUCTURE_EXCLUDED_TABLES = new Set(['assessor_schema_migrations']);
/** Engine-owned schemas shown beside the Core tables; their tables are named `schema.table`. */
const DB_STRUCTURE_ENGINE_SCHEMAS = ['assessor_remediation'];
const DB_STRUCTURE_SCHEMAS = ['public', ...DB_STRUCTURE_ENGINE_SCHEMAS];
function dbStructureTableName(schema: string, table: string): string {
return schema === 'public' ? table : `${schema}.${table}`;
}
function dbStructureLayer(tableName: string): DbStructureLayerId {
return DB_STRUCTURE_LAYER_BY_TABLE[tableName] ?? 'other';
@ -2823,72 +2831,83 @@ export class PostgresAssessorRepository implements AssessorRepository {
}
async getDatabaseStructure(): Promise<DbStructureResponse> {
const tablesResult = await this.pool.query<{ table_name: string }>(`
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name
`);
const tablesResult = await this.pool.query<{ table_schema: string; table_name: string }>(`
SELECT table_schema, table_name FROM information_schema.tables
WHERE table_schema = ANY($1::text[]) AND table_type = 'BASE TABLE'
ORDER BY table_schema <> 'public', table_schema, table_name
`, [DB_STRUCTURE_SCHEMAS]);
const tableNames = tablesResult.rows
.map(row => row.table_name)
.map(row => dbStructureTableName(row.table_schema, row.table_name))
.filter(name => !DB_STRUCTURE_EXCLUDED_TABLES.has(name));
if (tableNames.length === 0) {
return { generatedAt: new Date().toISOString(), persistence: 'postgresql', tables: [] };
}
const columnsResult = await this.pool.query<{ table_name: string; column_name: string; data_type: string; is_nullable: string }>(`
SELECT table_name, column_name, data_type, is_nullable FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = ANY($1::text[])
ORDER BY table_name, ordinal_position
`, [tableNames]);
const columnsResult = await this.pool.query<{ table_schema: string; table_name: string; column_name: string; data_type: string; is_nullable: string }>(`
SELECT table_schema, table_name, column_name, data_type, is_nullable FROM information_schema.columns
WHERE table_schema = ANY($1::text[])
ORDER BY table_schema, table_name, ordinal_position
`, [DB_STRUCTURE_SCHEMAS]);
const foreignKeysResult = await this.pool.query<{
table_schema: string;
table_name: string;
column_name: string;
referenced_table_schema: string;
referenced_table_name: string;
referenced_column_name: string;
}>(`
SELECT
source_namespace.nspname AS table_schema,
source_table.relname AS table_name,
source_column.attname AS column_name,
target_namespace.nspname AS referenced_table_schema,
target_table.relname AS referenced_table_name,
target_column.attname AS referenced_column_name
FROM pg_constraint constraint_info
JOIN pg_class source_table ON source_table.oid = constraint_info.conrelid
JOIN pg_namespace source_namespace ON source_namespace.oid = source_table.relnamespace
JOIN pg_class target_table ON target_table.oid = constraint_info.confrelid
JOIN pg_namespace target_namespace ON target_namespace.oid = target_table.relnamespace
JOIN LATERAL unnest(constraint_info.conkey, constraint_info.confkey) WITH ORDINALITY
AS cols(source_attnum, target_attnum, ordinal) ON true
JOIN pg_attribute source_column ON source_column.attrelid = source_table.oid AND source_column.attnum = cols.source_attnum
JOIN pg_attribute target_column ON target_column.attrelid = target_table.oid AND target_column.attnum = cols.target_attnum
WHERE constraint_info.contype = 'f'
AND source_table.relnamespace = 'public'::regnamespace
AND source_table.relname = ANY($1::text[])
ORDER BY source_table.relname, cols.ordinal
`, [tableNames]);
AND source_namespace.nspname = ANY($1::text[])
ORDER BY source_namespace.nspname, source_table.relname, cols.ordinal
`, [DB_STRUCTURE_SCHEMAS]);
const rowCountResult = await this.pool.query<{ relname: string; reltuples: string }>(`
SELECT relname, reltuples FROM pg_class
WHERE relkind = 'r' AND relname = ANY($1::text[])
`, [tableNames]);
const rowCountResult = await this.pool.query<{ nspname: string; relname: string; reltuples: string }>(`
SELECT namespace.nspname, class.relname, class.reltuples FROM pg_class class
JOIN pg_namespace namespace ON namespace.oid = class.relnamespace
WHERE class.relkind = 'r' AND namespace.nspname = ANY($1::text[])
`, [DB_STRUCTURE_SCHEMAS]);
const included = new Set(tableNames);
const columnsByTable = new Map<string, DbStructureColumn[]>();
for (const row of columnsResult.rows) {
const columns = columnsByTable.get(row.table_name) ?? [];
const table = dbStructureTableName(row.table_schema, row.table_name);
if (!included.has(table)) continue;
const columns = columnsByTable.get(table) ?? [];
columns.push({ name: row.column_name, dataType: row.data_type, nullable: row.is_nullable === 'YES' });
columnsByTable.set(row.table_name, columns);
columnsByTable.set(table, columns);
}
const foreignKeysByTable = new Map<string, DbStructureForeignKey[]>();
for (const row of foreignKeysResult.rows) {
const foreignKeys = foreignKeysByTable.get(row.table_name) ?? [];
const table = dbStructureTableName(row.table_schema, row.table_name);
if (!included.has(table)) continue;
const foreignKeys = foreignKeysByTable.get(table) ?? [];
foreignKeys.push({
column: row.column_name,
referencedTable: row.referenced_table_name,
referencedTable: dbStructureTableName(row.referenced_table_schema, row.referenced_table_name),
referencedColumn: row.referenced_column_name
});
foreignKeysByTable.set(row.table_name, foreignKeys);
foreignKeysByTable.set(table, foreignKeys);
}
const rowCountByTable = new Map(rowCountResult.rows.map(row => [row.relname, Math.max(0, Math.round(Number(row.reltuples)))]));
const rowCountByTable = new Map(rowCountResult.rows.map(row => [dbStructureTableName(row.nspname, row.relname), Math.max(0, Math.round(Number(row.reltuples)))]));
const tables: DbStructureTable[] = tableNames.map(name => ({
name,

View File

@ -0,0 +1,108 @@
import type { Assessment, CanonicalAssessmentModel } from '../src/domain/canonical-model';
import type { StoredGpoBackupInstance } from '../src/domain/gpo-analysis-persistence';
import type { StoredPolicyEvaluation } from '../src/domain/policy-evaluation';
import type { PolicyAnalysisOverview } from '../src/domain/policy-analysis-overview';
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 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. */
export interface TimeslotSelection {
assessmentIds: string[];
scanRunIds: string[];
}
export interface TimeslotInputReader {
listAssessments(projectId: string): Promise<Assessment[]>;
loadAssessmentModel(assessmentId: string): Promise<CanonicalAssessmentModel | null>;
queryUnifiedResults(query?: UnifiedResultQuery): Promise<UnifiedResultQueryResult>;
listPolicyEvaluations(projectId: string): Promise<StoredPolicyEvaluation[]>;
listGpoBackupInstances(projectId: string): Promise<StoredGpoBackupInstance[]>;
getPolicyAnalysisOverview?(projectId: string): Promise<Pick<PolicyAnalysisOverview, 'current'>>;
}
export interface TimeslotComparisonInput {
assessments: Assessment[];
models: CanonicalAssessmentModel[];
scanRunIds: string[];
gpoResults: PolicyWorkResult[];
/** Latest GPO backup creation time; undefined unless every contributing backup states one. */
gpoObservedAt?: string;
}
/**
* Core-owned adapter: builds one timeslot exactly as Work reads it (scanner models scoped to the
* selected runs, GPO settings through the same current-evaluation selection and Work verdict
* builder). Only the resulting records reach the Comparison Engine. Import times are never used as
* observation times.
*/
export async function loadTimeslotComparisonInput(
reader: TimeslotInputReader,
projectId: string,
selection: TimeslotSelection
): Promise<TimeslotComparisonInput> {
const projectAssessments = await reader.listAssessments(projectId);
const assessments = selection.assessmentIds.map(id => {
const assessment = projectAssessments.find(item => item.id === id);
if (!assessment) throw new RepositoryNotFoundError(`Assessment ${id} is not in this project.`);
return assessment;
});
const loaded = await Promise.all(assessments.map(async assessment => {
const model = await reader.loadAssessmentModel(assessment.id);
if (!model || model.project.id !== projectId) throw new RepositoryNotFoundError(`Assessment ${assessment.id} is not in this project.`);
return model;
}));
const models = loaded.map(model => canonicalModelForScanRuns(model, selection.scanRunIds))
.filter((model): model is CanonicalAssessmentModel => model !== null);
const availableRuns = new Set(models.flatMap(model => model.scanRuns.map(run => run.id)));
const missingRun = selection.scanRunIds.find(id => !availableRuns.has(id));
if (missingRun) throw new RepositoryNotFoundError(`Scan run ${missingRun} is not in the selected assessments.`);
const observations = await queryAllUnifiedResults(query => reader.queryUnifiedResults(query), {
audience: 'internal-analysis', projectId, assessmentIds: selection.assessmentIds, sourceKind: ['policy-observation']
});
let gpoResults: PolicyWorkResult[] = [];
let gpoObservedAt: string | undefined;
if (observations.length) {
const [evaluations, overview, backups] = await Promise.all([
reader.listPolicyEvaluations(projectId),
reader.getPolicyAnalysisOverview?.(projectId),
reader.listGpoBackupInstances(projectId)
]);
gpoResults = buildPolicyWorkResults(observations, currentPolicyEvaluationsForWork(evaluations,
new Set(observations.map(item => item.sourceResultId)), overview ? new Set(overview.current.evaluationRunIds) : undefined));
const backupIds = new Set(observations.flatMap(item => item.policyBackupInstanceId ?? []));
const times = backups.filter(item => backupIds.has(item.instance.id)).map(item => item.instance.backupCreatedAt);
if (backupIds.size && times.length === backupIds.size && times.every(time => time && Number.isFinite(Date.parse(time)))) {
gpoObservedAt = new Date(Math.max(...times.map(time => Date.parse(time!)))).toISOString();
}
}
return { assessments, models, scanRunIds: [...selection.scanRunIds], gpoResults, ...(gpoObservedAt ? { gpoObservedAt } : {}) };
}
/**
* Core-owned adapter: the Work dataset of one timeslot, built by the same loader Client Info uses, so the
* Comparison Engine reads Client Info's records and result states for its Security Domain values.
*/
export async function loadTimeslotWorkspace(
reader: TimeslotInputReader,
projectId: string,
selection: TimeslotSelection
): Promise<ImportedDataset | null> {
const { dataset } = await loadCanonicalProjectWorkspace({
listProjectAssessments: id => reader.listAssessments(id),
loadAssessmentModel: async assessmentId => {
const model = await reader.loadAssessmentModel(assessmentId);
if (!model || model.project.id !== projectId) throw new RepositoryNotFoundError(`Assessment ${assessmentId} is not in this project.`);
return model;
},
queryUnifiedResults: query => reader.queryUnifiedResults(query),
listPolicyEvaluations: async id => ({ records: await reader.listPolicyEvaluations(id) }),
...(reader.getPolicyAnalysisOverview ? { getPolicyAnalysisOverview: (id: string) => reader.getPolicyAnalysisOverview!(id) } : {})
}, projectId, selection.scanRunIds.length ? selection.scanRunIds : undefined, selection.assessmentIds,
{ includeHistoricalScores: false, includeBaselineGuidance: false });
return dataset;
}

View File

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

View File

@ -5,6 +5,7 @@ export function requestActivity(path: string, method = 'GET'): { area: string; l
if (route.includes('/work-packages')) return { area: 'Remediation Engine', label: 'Preparing work packages' };
if (route.includes('/assessment-context')) return { area: 'Context Engine', label: 'Loading and calculating assessment context' };
if (route.includes('/report-runs')) return { area: 'Report Engine', label: 'Generating assessment report' };
if (route.includes('/timeslot-comparison')) return { area: 'Comparison Engine', label: 'Comparing timeslots' };
const compliance = complianceActivity(route, method);
if (compliance) return { area: 'Compliance Engine', label: compliance };
if (route.includes('/mitre-attack')) return { area: 'Core Engine', label: route.endsWith('/source') ? 'Downloading MITRE source' : route.endsWith('/refresh') ? 'Updating MITRE catalog' : 'Loading MITRE catalog' };

View File

@ -6,11 +6,12 @@ export const ENGINES = [
{ id: 'context', label: 'Context Engine', required: false },
{ id: 'report', label: 'Report Engine', required: false },
{ id: 'remediation', label: 'Remediation Engine', required: false },
{ id: 'compliance', label: 'Compliance Engine', required: false }
{ id: 'compliance', label: 'Compliance Engine', required: false },
{ id: 'comparison', label: 'Comparison Engine', required: false }
] as const;
export type EngineId = typeof ENGINES[number]['id'];
export const ENGINE_PREFERENCES_KEY = 'ASSESSOR.engines.v1';
const defaults = Object.freeze({ core: true, context: true, report: true, remediation: true, compliance: true });
const defaults = Object.freeze({ core: true, context: true, report: true, remediation: true, compliance: true, comparison: true });
type Preferences = Readonly<Record<EngineId, boolean>>;
let snapshot: Preferences | undefined;
const listeners = new Set<() => void>();
@ -20,7 +21,7 @@ export function readEnginePreferences(): Preferences {
try {
const saved = JSON.parse(window.localStorage.getItem(ENGINE_PREFERENCES_KEY) ?? '{}');
snapshot = Object.freeze({ core: true, context: saved?.context !== false, report: saved?.report !== false, remediation: saved?.remediation !== false,
compliance: saved?.compliance !== false });
compliance: saved?.compliance !== false, comparison: saved?.comparison !== false });
} catch { snapshot = defaults; }
return snapshot;
}

View File

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

View File

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

View File

@ -0,0 +1,207 @@
import type { RemediationEvidenceState } from '../domain/timeslot-comparison';
import type { PolicyWorkResult, PolicyWorkStatus } from '../evaluation/policy-work-results';
import { policyWorkVerdictPriority } from '../evaluation/policy-work-results';
import { canonicalJson } from '../shared/canonical-json';
export const GPO_TIMESLOT_COMPARISON_VERSION = '1.0.0' as const;
/** One timeslot's rolled-up GPO settings exactly as Work reads them, plus its backup observation time. */
export interface GpoTimeslotInput {
results: PolicyWorkResult[];
/** Latest backup creation time of the timeslot's GPO backups; never the import time. */
observedAt?: string;
}
type SettingVerdict = Exclude<PolicyWorkStatus, 'Conflicts' | 'Duplicates'>;
interface TimeslotSetting {
key: string;
scope: string;
identity: string;
verdict?: SettingVerdict;
baselineIds: string;
actualValues: string;
/** The retained configured values as Work displays them; empty when none is retained. */
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 {
/** Cross-timeslot setting identity: scope and technical identity only, never a run or assessment. */
settingKey: string;
scope: string;
identity: string;
baselineVerdict: SettingVerdict;
targetVerdict?: SettingVerdict;
state: RemediationEvidenceState;
/** Retained configured value differs while the setting still deviates. Neutral, never better or worse. */
valueChanged: boolean;
reasons: string[];
baselineResultIds: string[];
targetResultIds: string[];
/** 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 {
version: typeof GPO_TIMESLOT_COMPARISON_VERSION;
state: 'compared' | 'not-comparable';
reasons: string[];
decisions: GpoSettingDecision[];
newDeviations: GpoTargetChange[];
regressedDeviations: GpoTargetChange[];
/** Pass rate over settings decided (Pass or Fail) in both timeslots against the same baselines. */
sharedPassRate?: { settings: number; baseline: number; target: number };
}
const settingKey = (scope: string, identity: string) => JSON.stringify([scope, identity]);
/**
* Rolls each timeslot up like Work: one verdict per technical setting, chosen by the shared
* verdict priority. Conflict and duplicate entries are diagnostics and never a verdict. Several
* aggregation runs holding the same setting in one timeslot make it ambiguous.
*/
function rollUp(results: PolicyWorkResult[]): Map<string, TimeslotSetting> {
const byIdentity = new Map<string, PolicyWorkResult[]>();
for (const result of results) {
const key = settingKey(result.scope, result.identity);
byIdentity.set(key, [...(byIdentity.get(key) ?? []), result]);
}
const settings = new Map<string, TimeslotSetting>();
for (const [key, group] of byIdentity) {
const verdicts = group.filter((item): item is PolicyWorkResult & { status: SettingVerdict } =>
item.status !== 'Conflicts' && item.status !== 'Duplicates');
const verdict = verdicts.reduce<SettingVerdict | undefined>((best, item) =>
!best || policyWorkVerdictPriority[item.status] > policyWorkVerdictPriority[best] ? item.status : best, undefined);
const decisive = verdicts.filter(item => item.status === verdict);
const shown = (value: unknown) => typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value);
const displayValues = [...new Set(decisive.flatMap(item => {
const actual = item.comparison?.comparator.actual;
return actual === undefined ? [] : [shown(actual)];
}))].sort();
settings.set(key, {
key, scope: group[0].scope, identity: group[0].identity, verdict,
baselineIds: canonicalJson([...new Set(verdicts.flatMap(item => item.comparison?.baselineId ?? []))].sort()),
actualValues: canonicalJson([...new Set(decisive.flatMap(item =>
item.comparison?.comparator.actual === undefined ? [] : [canonicalJson(item.comparison.comparator.actual)]))].sort()),
displayValues,
resultIds: group.map(item => item.id).sort(),
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;
}
/** The timeslot order is the time basis (user decision 2026-10-13); stated backup times only block when B's is not newer. */
const notNewer = (baseline?: string, target?: string) => Boolean(baseline && target
&& Number.isFinite(Date.parse(baseline)) && Number.isFinite(Date.parse(target)) && Date.parse(target) <= Date.parse(baseline));
/**
* Conservative A/B comparison of rolled-up GPO settings. The authoritative verdicts are read as
* they are and never re-derived. Remediation needs the same setting with an explicit Pass against the
* same baselines in timeslot B (whose stated backup time, if any, must be newer); an absent or undecided setting stays unverified, because a
* GPO backup set is no proof of a removed configuration.
*/
export function compareGpoTimeslots(baseline: GpoTimeslotInput, target: GpoTimeslotInput): GpoTimeslotComparison {
const version = GPO_TIMESLOT_COMPARISON_VERSION;
const baselineSettings = rollUp(baseline.results);
const targetSettings = rollUp(target.results);
const baselineFails = [...baselineSettings.values()].filter(item => item.verdict === 'Fail')
.sort((left, right) => left.key.localeCompare(right.key));
const sourceReasons = [
...(baselineSettings.size ? [] : ['baseline-source-missing']),
...(targetSettings.size ? [] : ['target-source-missing'])
];
const decision = (item: TimeslotSetting, state: RemediationEvidenceState, reasons: string[], other?: TimeslotSetting,
valueChanged = false): GpoSettingDecision => ({
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 ?? [],
baseline: item.facts, ...(other ? { target: other.facts } : {})
});
if (sourceReasons.length) return {
version, state: 'not-comparable', reasons: sourceReasons,
decisions: baselineFails.map(item => decision(item, 'not-comparable', sourceReasons)),
newDeviations: [], regressedDeviations: []
};
const timeVerified = !notNewer(baseline.observedAt, target.observedAt);
const decisions = baselineFails.map(item => {
if (item.ambiguous) return decision(item, 'not-comparable', ['gpo-setting-ambiguous']);
const other = targetSettings.get(item.key);
if (!timeVerified) return decision(item, 'not-comparable', ['target-observation-not-newer'], other);
if (!other) return decision(item, 'unverified-absent', ['target-setting-absent']);
if (other.ambiguous) return decision(item, 'unverified-absent', ['gpo-setting-ambiguous'], other);
if (other.baselineIds !== item.baselineIds) return decision(item, 'not-comparable', ['baseline-set-changed'], other);
if (other.verdict === 'Fail') return decision(item, 'confirmed-open', ['same-setting-current-deviation'], other,
other.actualValues !== item.actualValues);
if (other.verdict === 'Pass') return decision(item, 'verified-remediated', ['same-setting-explicit-pass'], other);
return decision(item, 'unverified-absent', [`target-verdict-not-decisive:${other.verdict ?? 'none'}`], other);
});
const newDeviations: GpoTargetChange[] = [];
const regressedDeviations: GpoTargetChange[] = [];
if (timeVerified) {
for (const item of [...targetSettings.values()].sort((left, right) => left.key.localeCompare(right.key))) {
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,
...(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);
}
}
const shared = [...baselineSettings.values()].flatMap(item => {
const other = targetSettings.get(item.key);
return other && !item.ambiguous && !other.ambiguous && item.baselineIds === other.baselineIds
&& (item.verdict === 'Pass' || item.verdict === 'Fail') && (other.verdict === 'Pass' || other.verdict === 'Fail')
? [{ before: item.verdict, after: other.verdict }] : [];
});
const rate = (values: string[]) => values.filter(value => value === 'Pass').length / values.length * 100;
return {
version, state: timeVerified ? 'compared' : 'not-comparable', reasons: timeVerified ? [] : ['target-observation-not-newer'],
decisions, newDeviations, regressedDeviations,
...(timeVerified && shared.length ? { sharedPassRate: {
settings: shared.length, baseline: rate(shared.map(item => item.before)), target: rate(shared.map(item => item.after))
} } : {})
};
}

View File

@ -24,14 +24,22 @@ export interface TimeslotSourceComparison {
toolId: string;
baselineRunId?: string;
targetRunId?: string;
/** The import files of this source in each timeslot; a run from several scanners holds several sources. */
baselineFileIds: string[];
targetFileIds: string[];
state: 'compared' | 'not-comparable';
reasons: string[];
decisions: TimeslotOccurrenceDecision[];
/** Current deviations whose identity was not a deviation and not a verified pass in A. */
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 {
version: '1.0.0';
version: '1.2.0';
projectId: string;
baselineRunIds: string[];
targetRunIds: string[];
@ -44,11 +52,46 @@ export interface ProjectTimeslotEvidenceComparison {
notComparable: number;
notApplicable: number;
newDeviations: number;
regressedDeviations: number;
};
}
const normalizedTool = (value: string): string => value.trim().toLocaleLowerCase('en-US');
/**
* Diagnostic policy `fail-closed-by-impact`: a diagnostic blocks remediation claims unless it is known to leave
* identity, results and coverage untouched. These codes only sanitize or confirm the source; every other code,
* including unknown future ones, still blocks.
*/
const NON_IMPACTING_DIAGNOSTICS = new Set(['html-active-content-removed', 'unicode-null-escaped', 'cis-cat-summary-verified',
// PingCastle detail links affect only attached evidence tables, never a rule, its scope or its presence.
'pingcastle-linked-section-unresolved', 'pingcastle-linked-section-ambiguous', 'pingcastle-linked-section-depth-limit',
'pingcastle-linked-section-target-limit', 'pingcastle-linked-section-table-limit']);
/**
* User decision 2026-10-13: the explicit timeslot order is the time basis; timeslot A lies before B. These markers
* carry that order into the shared evidence contract. A source's own report time only blocks when both timeslots
* state one and B's is not newer (an older report or the same report again).
*/
const TIMESLOT_A = '2000-01-01T00:00:00.000Z';
const TIMESLOT_B = '2000-01-02T00:00:00.000Z';
function oneValue(observations: SourceUnit['observations'], property: string): string | undefined {
const values = [...new Set(observations.filter(item => item.state === 'observed' && item.property === property)
.map(item => String(item.normalizedValue ?? item.originalValue ?? '').trim()).filter(Boolean))];
return values.length === 1 ? values[0] : undefined;
}
type AvailableDefinition = Exclude<ToolTimeslotComparisonDefinition, { kind: 'unavailable' }>;
interface SourceUnit {
toolId: string;
run: ScanRun;
files: ImportFileRecord[];
fileIds: Set<string>;
occurrences: FindingOccurrence[];
observations: NonNullable<CanonicalAssessmentModel['environmentObservations']>;
}
function occurrenceOutcome(occurrence: FindingOccurrence) {
return occurrence.outcome?.normalizedValue
?? outcomeFromLegacyStatus(occurrence.status.normalizedValue ?? 'unknown');
@ -108,83 +151,129 @@ export function compareProjectTimeslotEvidence(
if (!models.length) throw new Error('Timeslot comparison requires project data.');
const projectId = models[0].project.id;
if (models.some(model => model.project.id !== projectId)) throw new Error('Timeslots must belong to the same project.');
if (!baselineRunIds.length || !targetRunIds.length) throw new Error('Both timeslots require scanner runs.');
// One side may lack scanner runs; its sources are then reported as not comparable, never resolved.
if (!baselineRunIds.length && !targetRunIds.length) throw new Error('At least one timeslot requires scanner runs.');
if (baselineRunIds.some(id => targetRunIds.includes(id))) throw new Error('Baseline and target timeslots must use different runs.');
const runs = models.flatMap(model => model.scanRuns);
const runById = new Map(runs.map(run => [run.id, run]));
const missing = [...baselineRunIds, ...targetRunIds].filter(id => !runById.has(id));
if (missing.length) throw new Error('At least one selected scanner run is unavailable.');
const selected = (ids: string[]) => ids.map(id => runById.get(id)!);
const byTool = (items: ScanRun[]) => {
const map = new Map<string, ScanRun[]>();
items.forEach(run => {
const toolId = normalizedTool(run.tool.id);
map.set(toolId, [...(map.get(toolId) ?? []), run]);
});
return map;
};
const baselineByTool = byTool(selected(baselineRunIds));
const targetByTool = byTool(selected(targetRunIds));
const toolIds = [...new Set([...baselineByTool.keys(), ...targetByTool.keys()])].sort();
const occurrences = models.flatMap(model => model.findingOccurrences);
const files = models.flatMap(model => model.importFiles);
const observations = models.flatMap(model => model.environmentObservations ?? []);
const diagnostics = models.flatMap(model => model.diagnostics);
const sources = toolIds.map((toolId): TimeslotSourceComparison => {
const baselineRuns = baselineByTool.get(toolId) ?? [];
const targetRuns = targetByTool.get(toolId) ?? [];
const baselineRun = baselineRuns.length === 1 ? baselineRuns[0] : undefined;
const targetRun = targetRuns.length === 1 ? targetRuns[0] : undefined;
const baselineOccurrences = baselineRun ? occurrences.filter(item => item.scanRunId === baselineRun.id) : [];
const profile = toolProfileDefinitions.find(item => item.id === toolId);
const pairingReasons = [
...(baselineRuns.length !== 1 ? [baselineRuns.length ? 'baseline-source-ambiguous' : 'baseline-source-missing'] : []),
...(targetRuns.length !== 1 ? [targetRuns.length ? 'target-source-ambiguous' : 'target-source-missing'] : []),
...(!profile ? ['comparison-profile-missing'] : []),
...(profile?.timeslotComparison.kind === 'unavailable' ? ['comparison-profile-unavailable'] : [])
];
if (!baselineRun || !targetRun || !profile || profile.timeslotComparison.kind === 'unavailable') return {
toolId, baselineRunId: baselineRun?.id, targetRunId: targetRun?.id,
state: 'not-comparable', reasons: pairingReasons,
decisions: unmatchedDecisions(baselineOccurrences, pairingReasons), newDeviationOccurrenceIds: []
};
// A source is the files of one run that one scanner produced. A run imported from several scanners
// (tool `multiple`) is split by each file's own scanner; sources are never merged into one contract.
const sourcesOf = (ids: string[]): SourceUnit[] => ids.flatMap(runId => {
const run = runById.get(runId)!;
const byTool = new Map<string, ImportFileRecord[]>();
for (const file of files.filter(item => item.scanRunId === runId)) {
const toolId = normalizedTool(file.detectedTool.id && file.detectedTool.id !== 'unknown' ? file.detectedTool.id : run.tool.id);
byTool.set(toolId, [...(byTool.get(toolId) ?? []), file]);
}
if (!byTool.size) byTool.set(normalizedTool(run.tool.id), []);
return [...byTool].map(([toolId, unitFiles]) => {
const fileIds = new Set(unitFiles.map(item => item.id));
return {
toolId, run, files: unitFiles, fileIds,
occurrences: occurrences.filter(item => item.scanRunId === runId && (!fileIds.size || fileIds.has(item.importFileId))),
observations: observations.filter(item => item.scanRunId === runId && fileIds.has(item.importFileId) && normalizedTool(item.tool.id) === toolId)
};
});
});
const baselineSources = sourcesOf(baselineRunIds);
const targetSources = sourcesOf(targetRunIds);
const toolIds = [...new Set([...baselineSources, ...targetSources].map(item => item.toolId))].sort();
const notComparable = (toolId: string, baseline: SourceUnit | undefined, target: SourceUnit | undefined, reasons: string[]): TimeslotSourceComparison => ({
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: [], regressedBaselineOccurrenceIds: []
});
const sources = toolIds.flatMap((toolId): TimeslotSourceComparison[] => {
const baselineUnits = baselineSources.filter(item => item.toolId === toolId);
const targetUnits = targetSources.filter(item => item.toolId === toolId);
const profile = toolProfileDefinitions.find(item => item.id === toolId);
if (!profile || profile.timeslotComparison.kind === 'unavailable') {
const reasons = [profile ? 'comparison-profile-unavailable' : 'comparison-profile-missing'];
return [...baselineUnits.map(unit => notComparable(toolId, unit, undefined, [...reasons, ...(targetUnits.length ? [] : ['target-source-missing'])])),
...targetUnits.map(unit => notComparable(toolId, undefined, unit, [...reasons, ...(baselineUnits.length ? [] : ['baseline-source-missing'])]))];
}
const definition = profile.timeslotComparison;
const baselineFiles = files.filter(item => item.scanRunId === baselineRun.id);
const targetFiles = files.filter(item => item.scanRunId === targetRun.id);
const compatibility = filesCompatibility(baselineFiles, targetFiles);
const baselineObservations = observations.filter(item => item.scanRunId === baselineRun.id && normalizedTool(item.tool.id) === toolId);
const targetObservations = observations.filter(item => item.scanRunId === targetRun.id && normalizedTool(item.tool.id) === toolId);
const baselineScope = comparisonScopeIdentity(definition, baselineObservations);
const targetScope = comparisonScopeIdentity(definition, targetObservations);
const baselineTime = comparisonObservationTime(definition, baselineObservations);
const targetTime = comparisonObservationTime(definition, targetObservations);
// Several sources of one scanner (for example two domains) pair only by their verified scope.
const scopeOf = (unit: SourceUnit) => comparisonScopeIdentity(definition, unit.observations);
const keyOf = (unit: SourceUnit) => { const scope = scopeOf(unit); return scope.state === 'verified' ? scope.scope.key : undefined; };
const paired = new Set<SourceUnit>();
const results = baselineUnits.map((baseline): TimeslotSourceComparison => {
if (baselineUnits.length === 1 && targetUnits.length === 1) {
paired.add(targetUnits[0]);
return compareSources(toolId, definition, baseline, targetUnits[0]);
}
const scope = scopeOf(baseline);
if (scope.state !== 'verified') return notComparable(toolId, baseline, undefined, [...scope.reasons, 'baseline-source-ambiguous']);
const sameBaseline = baselineUnits.filter(unit => keyOf(unit) === scope.scope.key);
const matches = targetUnits.filter(unit => keyOf(unit) === scope.scope.key);
if (sameBaseline.length > 1 || matches.length > 1) {
matches.forEach(unit => paired.add(unit));
return notComparable(toolId, baseline, undefined, [sameBaseline.length > 1 ? 'baseline-source-ambiguous' : 'target-source-ambiguous']);
}
if (!matches.length) return notComparable(toolId, baseline, undefined, [targetUnits.length ? 'comparison-scope-changed' : 'target-source-missing']);
paired.add(matches[0]);
return compareSources(toolId, definition, baseline, matches[0]);
});
// A source only in B is never new or worse; its deviations are reported as not comparable.
for (const target of targetUnits.filter(unit => !paired.has(unit))) {
results.push(notComparable(toolId, undefined, target, [baselineUnits.length ? 'comparison-scope-changed' : 'baseline-source-missing']));
}
return results;
});
function compareSources(toolId: string, definition: AvailableDefinition, baseline: SourceUnit, target: SourceUnit): TimeslotSourceComparison {
const baselineOccurrences = baseline.occurrences;
const compatibility = filesCompatibility(baseline.files, target.files);
let baselineScope = comparisonScopeIdentity(definition, baseline.observations);
let targetScope = comparisonScopeIdentity(definition, target.observations);
// User decision 2026-10-13: both timeslots belong to one client. When each holds exactly one report of this
// scanner and neither states its scope (a Maester Excel export has no tenant), the client is the scope.
// A scope stated on one side only, or several reports per side, never pairs this way.
const single = baselineSources.filter(unit => unit.toolId === toolId).length === 1
&& targetSources.filter(unit => unit.toolId === toolId).length === 1;
const unstated = (scope: typeof baselineScope) => scope.state === 'unverified'
&& scope.reasons.every(reason => reason === 'scope-property-missing');
if (single && unstated(baselineScope) && unstated(targetScope)) {
const clientScope = { state: 'verified' as const, scope: { kind: 'client', key: JSON.stringify(['client', projectId]) } };
baselineScope = clientScope;
targetScope = clientScope;
}
const baselineTime = comparisonObservationTime(definition, baseline.observations);
const targetTime = comparisonObservationTime(definition, target.observations);
const sourceReasons = [
...compatibility.reasons,
...(baselineScope.state === 'unverified' ? baselineScope.reasons : []),
...(targetScope.state === 'unverified' ? targetScope.reasons : []),
...(baselineTime.state === 'unverified' ? [baselineTime.reason] : []),
...(targetTime.state === 'unverified' ? [targetTime.reason] : [])
...(baselineTime.state === 'verified' && targetTime.state === 'verified'
&& Date.parse(targetTime.observedAt) <= Date.parse(baselineTime.observedAt) ? ['target-observation-not-newer'] : [])
];
if (baselineScope.state === 'verified' && targetScope.state === 'verified'
&& baselineScope.scope.key !== targetScope.scope.key) sourceReasons.push('comparison-scope-changed');
const baselineFileIds = new Set(baselineFiles.map(item => item.id));
const targetFileIds = new Set(targetFiles.map(item => item.id));
const blockingDiagnostics = diagnostics
.filter(item => item.severity !== 'info' && (baselineFileIds.has(item.importFileId) || targetFileIds.has(item.importFileId)))
.filter(item => item.severity !== 'info' && !NON_IMPACTING_DIAGNOSTICS.has(item.code)
&& (baseline.fileIds.has(item.importFileId) || target.fileIds.has(item.importFileId)))
.map(item => `diagnostic:${item.code}`);
const sourceComparable = sourceReasons.length === 0;
if (!sourceComparable || baselineScope.state !== 'verified' || targetScope.state !== 'verified'
|| baselineTime.state !== 'verified' || targetTime.state !== 'verified') return {
toolId, baselineRunId: baselineRun.id, targetRunId: targetRun.id,
state: 'not-comparable', reasons: [...new Set(sourceReasons)],
decisions: unmatchedDecisions(baselineOccurrences, [...new Set(sourceReasons)]), newDeviationOccurrenceIds: []
};
if (sourceReasons.length || baselineScope.state !== 'verified' || targetScope.state !== 'verified') {
return notComparable(toolId, baseline, target, [...new Set(sourceReasons)]);
}
// A findings-only report proves absence only under its declared negative-evidence contract: same scope
// (paired above), compatible processing, no blocking diagnostic and the same scanner engine version.
const baselineEngine = oneValue(baseline.observations, 'report.engine_version');
const absenceProven = definition.negativeEvidence === 'complete-explicit-negative' && compatibility.compatible
&& !blockingDiagnostics.length && Boolean(baselineEngine) && baselineEngine === oneValue(target.observations, 'report.engine_version');
const targetOccurrences = occurrences.filter(item => item.scanRunId === targetRun.id);
const targetIdentities = targetOccurrences.map(item => ({ item, identity: identityFor(item, definition, targetScope.scope) }));
const targetIdentities = target.occurrences.map(item => ({ item, identity: identityFor(item, definition, targetScope.scope) }));
const targetByIdentity = new Map<string, typeof targetIdentities>();
targetIdentities.forEach(entry => {
if (entry.identity.state !== 'verified') return;
@ -206,39 +295,55 @@ export function compareProjectTimeslotEvidence(
baselineOccurrenceId: item.id, identityKey: identity.identity.key,
state: 'not-comparable', reasons: ['comparison-identity-ambiguous']
};
const target = matches.length === 1 ? matches[0] : undefined;
const match = matches.length === 1 ? matches[0] : undefined;
const decision = evaluateRemediationEvidence({
baselineIdentity: identity,
targetIdentity: target?.identity,
baselineObservedAt: baselineTime.observedAt,
targetObservedAt: targetTime.observedAt,
targetIdentity: match?.identity,
baselineObservedAt: TIMESLOT_A,
targetObservedAt: TIMESLOT_B,
processingCompatible: compatibility.compatible,
targetOutcome: target ? occurrenceOutcome(target.item) : undefined,
targetIsIoe: target?.item.isIoe,
coverage: definition.resultEvidence === 'explicit-results' ? 'complete' : 'unknown',
targetOutcome: match ? occurrenceOutcome(match.item) : undefined,
targetIsIoe: match?.item.isIoe,
coverage: definition.resultEvidence === 'explicit-results' || absenceProven ? 'complete' : 'unknown',
coveredIdentityKeys: absenceProven ? [identity.identity.key] : [],
absenceSemantics: definition.negativeEvidence === 'complete-explicit-negative' ? 'explicit-negative' : 'not-reported',
blockingDiagnostics
});
return {
baselineOccurrenceId: item.id, ...(target ? { targetOccurrenceId: target.item.id } : {}),
baselineOccurrenceId: item.id, ...(match ? { targetOccurrenceId: match.item.id } : {}),
identityKey: decision.identityKey, state: decision.state, reasons: decision.reasons
};
});
const baselineKeys = new Set(baselineIdentities.flatMap(entry => entry.identity.state === 'verified' ? [entry.identity.identity.key] : []));
const newDeviationOccurrenceIds = targetIdentities.flatMap(entry => {
if (entry.identity.state !== 'verified' || !isDeviation(Boolean(entry.item.isIoe), occurrenceOutcome(entry.item))) return [];
if ((targetByIdentity.get(entry.identity.identity.key) ?? []).length !== 1) return [];
return baselineKeys.has(entry.identity.identity.key) ? [] : [entry.item.id];
// A regression needs exactly one explicit pass of the same identity in A.
const baselinePassCounts = new Map<string, number>();
const baselinePassIds = new Map<string, string>();
baselineOccurrences.filter(item => occurrenceOutcome(item) === 'pass').forEach(item => {
const identity = identityFor(item, definition, baselineScope.scope);
if (identity.state !== 'verified') 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))
&& (targetByIdentity.get(entry.identity.identity.key) ?? []).length === 1
&& !baselineKeys.has(entry.identity.identity.key));
const keyOfEntry = (entry: typeof targetIdentities[number]) => entry.identity.state === 'verified' ? entry.identity.identity.key : '';
const regressed = currentDeviations.filter(entry => baselinePassCounts.get(keyOfEntry(entry)) === 1);
// Several passes of one identity in A are ambiguous history: neither new nor regressed.
const newDeviationOccurrenceIds = currentDeviations.filter(entry => !baselinePassCounts.has(keyOfEntry(entry))).map(entry => entry.item.id);
return {
toolId, baselineRunId: baselineRun.id, targetRunId: targetRun.id,
state: 'compared', reasons: [...new Set(blockingDiagnostics)], decisions, newDeviationOccurrenceIds
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),
regressedBaselineOccurrenceIds: regressed.map(entry => baselinePassIds.get(keyOfEntry(entry))!)
};
});
}
const decisions = sources.flatMap(source => source.decisions);
return {
version: '1.0.0', projectId, baselineRunIds: [...baselineRunIds], targetRunIds: [...targetRunIds], sources,
version: '1.2.0', projectId, baselineRunIds: [...baselineRunIds], targetRunIds: [...targetRunIds], sources,
summary: {
baselineDeviations: decisions.length,
verifiedRemediated: decisions.filter(item => item.state === 'verified-remediated').length,
@ -246,7 +351,8 @@ export function compareProjectTimeslotEvidence(
unverifiedAbsent: decisions.filter(item => item.state === 'unverified-absent').length,
notComparable: decisions.filter(item => item.state === 'not-comparable').length,
notApplicable: decisions.filter(item => item.state === 'not-applicable').length,
newDeviations: sources.reduce((sum, source) => sum + source.newDeviationOccurrenceIds.length, 0)
newDeviations: sources.reduce((sum, source) => sum + source.newDeviationOccurrenceIds.length, 0),
regressedDeviations: sources.reduce((sum, source) => sum + source.regressedDeviationOccurrenceIds.length, 0)
}
};
}

View File

@ -0,0 +1,278 @@
import type { CanonicalAssessmentModel, FindingOccurrence } from '../domain/canonical-model';
import type { RemediationEvidenceState } from '../domain/timeslot-comparison';
import { isDeviation } from '../domain/deviation-predicate';
import { outcomeFromLegacyStatus } from '../domain/unified-result';
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 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';
/**
* Change categories shown by the Comparison Engine. `value-changed` is a still deviating setting
* with a different retained value and is neutral (user decision 2026-10-13). Only `resolved` is better;
* only `new` and `regressed` are worse.
*/
export type TimeslotChange = 'resolved' | 'unchanged' | 'value-changed' | 'unverified' | 'not-comparable'
| 'not-applicable' | 'new' | 'regressed';
export interface TimeslotChangeEntry {
sourceId: string;
change: TimeslotChange;
/** Which timeslot the referenced records belong to. */
side: 'baseline' | 'target';
/** Scanner occurrence IDs or rolled-up GPO Work result IDs. */
recordIds: string[];
/** 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;
targetScore?: number;
change?: number;
weight: number;
method: 'paired-scans' | 'shared-gpo-pass-rate';
reasons: string[];
}
export interface TimeslotComparisonReport {
version: typeof TIMESLOT_COMPARISON_REPORT_VERSION;
projectId: string;
baselineRunIds: string[];
targetRunIds: string[];
sources: Array<{ sourceId: string; state: 'compared' | 'not-comparable'; reasons: string[] }>;
changes: TimeslotChangeEntry[];
summary: Record<TimeslotChange, number> & { targetNotComparable: number };
score: {
/** Weighted only over sources with a comparable score in both timeslots. */
baselineScore?: number;
targetScore?: number;
change?: number;
sources: TimeslotSourceScore[];
excludedSources: Array<{ sourceId: string; reasons: string[] }>;
};
scanner: ProjectTimeslotEvidenceComparison;
gpo?: GpoTimeslotComparison;
}
const changeOf: Record<RemediationEvidenceState, TimeslotChange> = {
'verified-remediated': 'resolved',
'confirmed-open': 'unchanged',
'unverified-absent': 'unverified',
'not-comparable': 'not-comparable',
'not-applicable': 'not-applicable'
};
const outcomeOf = (item: FindingOccurrence) => item.outcome?.normalizedValue
?? outcomeFromLegacyStatus(item.status.normalizedValue ?? 'unknown');
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],
counterpartIds: item.targetOccurrenceId ? [item.targetOccurrenceId] : [],
...(item.identityKey ? { identityKey: item.identityKey } : {}), reasons: item.reasons
})),
...source.newDeviationOccurrenceIds.map((id): TimeslotChangeEntry => ({
sourceId: source.toolId, change: 'new', 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
.filter(item => item.scanRunId === source.targetRunId && (!source.targetFileIds.length || source.targetFileIds.includes(item.importFileId))
&& isDeviation(Boolean(item.isIoe), outcomeOf(item)))
.map((item): TimeslotChangeEntry => ({
sourceId: source.toolId, change: 'not-comparable', side: 'target', recordIds: [item.id], counterpartIds: [],
reasons: source.reasons
})) : [])
]).map(labelled);
}
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 },
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: [], 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: [], gpo: { ...(item.baseline ? { baseline: item.baseline } : {}), target: item.target }
}))
];
if (gpo.state === 'not-comparable' && target) {
const keys = new Set(target.results.filter(item => item.status === 'Fail').map(item => JSON.stringify([item.scope, item.identity])));
for (const key of [...keys].sort()) changes.push({
sourceId: GPO_SOURCE_ID, change: 'not-comparable', side: 'target', identityKey: key, counterpartIds: [],
recordIds: target.results.filter(item => JSON.stringify([item.scope, item.identity]) === key).map(item => item.id).sort(),
reasons: gpo.reasons
});
}
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')
};
}
/**
* Score labels the engine verifies itself through its source pairing (scope, newer report time, compatible
* processing), or that describe both sides of an identical evaluated population. Every other issue blocks the delta.
*/
const ENGINE_VERIFIED_SCORE_ISSUES = new Set(['Scan identity or time not verified', 'Scanner version unavailable', 'Incomplete assessment']);
/**
* Score delta per scanner over the engine's verified source pairs: exactly one scan per side, the same method,
* scoring policy and weight, and the same evaluated rule population. A scanner counts only when every source
* it has in either timeslot is such a pair; native scores are never recomputed for a subset.
*/
function scannerScores(models: CanonicalAssessmentModel[], scanner: ProjectTimeslotEvidenceComparison): TimeslotSourceScore[] {
const scans = canonicalSecurityScores(models);
const scansOf = (fileIds: string[]) => scans.filter(scan => scan.importFileIds.some(id => fileIds.includes(id)));
const toolIds = [...new Set(scanner.sources.map(source => source.toolId))].sort();
return toolIds.map((toolId): TimeslotSourceScore => {
const sources = scanner.sources.filter(source => source.toolId === toolId);
const anyScan = scansOf(sources.flatMap(source => [...source.baselineFileIds, ...source.targetFileIds]))[0];
const policy = anyScan?.policy ?? models[0]?.assessment.scoringPolicy ?? defaultSecurityScoringPolicy();
const weight = policy.weights[toolId] ?? policy.defaultWeight;
const result = (reasons: string[], scores?: { baseline: number; target: number }): TimeslotSourceScore => ({
sourceId: toolId, weight, method: 'paired-scans', reasons: [...new Set(reasons)],
...(scores ? { baselineScore: scores.baseline, targetScore: scores.target, change: scores.target - scores.baseline } : {})
});
const notCompared = sources.filter(source => source.state !== 'compared');
if (notCompared.length) return result(notCompared.flatMap(source => source.reasons));
const pairs: Array<[SecurityScanScore, SecurityScanScore]> = [];
for (const source of sources) {
const left = scansOf(source.baselineFileIds);
const right = scansOf(source.targetFileIds);
if (left.length !== 1 || right.length !== 1) return result(['scan-pair-ambiguous']);
const [a, b] = [left[0], right[0]];
const blocking = [...a.issues, ...b.issues].filter(issue => !ENGINE_VERIFIED_SCORE_ISSUES.has(issue));
if (blocking.length) return result(blocking);
if (a.method !== b.method) return result(['score-method-changed']);
if (a.policy.version !== b.policy.version || (a.policy.weights[toolId] ?? a.policy.defaultWeight) !== (b.policy.weights[toolId] ?? b.policy.defaultWeight)) {
return result(['scoring-policy-changed']);
}
if (!a.evaluationScopeKey || a.evaluationScopeKey !== b.evaluationScopeKey) return result(['scan-scope-changed']);
if (a.score === undefined || b.score === undefined) return result(['score-unavailable']);
pairs.push([a, b]);
}
const baseline = weightedSecurityScore(pairs.map(([a]) => a));
const target = weightedSecurityScore(pairs.map(([, b]) => b));
return baseline === undefined || target === undefined ? result(['score-unavailable']) : result([], { baseline, target });
});
}
/**
* Explicit A/B comparison of two timeslots of one client. Scanner sources use the conservative
* evidence comparison, GPO settings their rolled-up Work verdicts. Score deltas cover only sources
* with a comparable score in both timeslots; every other source is listed as excluded with its reason.
*/
export function compareTimeslots(input: {
projectId: string;
models: CanonicalAssessmentModel[];
baselineRunIds: string[];
targetRunIds: string[];
gpo?: { baseline: GpoTimeslotInput; target: GpoTimeslotInput };
}): TimeslotComparisonReport {
if (input.models.some(model => model.project.id !== input.projectId)) throw new Error('Timeslots must belong to the same project.');
const scanner: ProjectTimeslotEvidenceComparison = input.baselineRunIds.length || input.targetRunIds.length
? compareProjectTimeslotEvidence(input.models, input.baselineRunIds, input.targetRunIds)
: { version: '1.2.0', projectId: input.projectId, baselineRunIds: [], targetRunIds: [], sources: [], summary: {
baselineDeviations: 0, verifiedRemediated: 0, confirmedOpen: 0, unverifiedAbsent: 0, notComparable: 0,
notApplicable: 0, newDeviations: 0, regressedDeviations: 0
} };
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?.baseline, input.gpo?.target) : [])];
const sourceScores = scannerScores(input.models, scanner);
if (gpo) {
const policy = input.models[0]?.assessment.scoringPolicy ?? defaultSecurityScoringPolicy();
const rate = gpo.sharedPassRate;
sourceScores.push({
sourceId: GPO_SOURCE_ID, weight: policy.weights[GPO_SOURCE_ID] ?? policy.defaultWeight, method: 'shared-gpo-pass-rate',
reasons: rate ? [] : gpo.state === 'not-comparable' ? gpo.reasons : ['no-shared-decided-settings'],
...(rate ? { baselineScore: rate.baseline, targetScore: rate.target, change: rate.target - rate.baseline } : {})
});
}
const scored = sourceScores.filter(item => item.change !== undefined);
const baselineScore = weightedSecurityScore(scored.map(item => ({ score: item.baselineScore, weight: item.weight })));
const targetScore = weightedSecurityScore(scored.map(item => ({ score: item.targetScore, weight: item.weight })));
return {
version: TIMESLOT_COMPARISON_REPORT_VERSION,
projectId: input.projectId,
baselineRunIds: [...input.baselineRunIds],
targetRunIds: [...input.targetRunIds],
sources: [
...scanner.sources.map(source => ({ sourceId: source.toolId, state: source.state, reasons: source.reasons })),
...(gpo ? [{ sourceId: GPO_SOURCE_ID, state: gpo.state, reasons: gpo.reasons }] : [])
],
changes,
summary: summarizeChanges(changes),
score: {
...(baselineScore !== undefined && targetScore !== undefined
? { baselineScore, targetScore, change: targetScore - baselineScore } : {}),
sources: sourceScores,
excludedSources: sourceScores.filter(item => item.change === undefined)
.map(item => ({ sourceId: item.sourceId, reasons: item.reasons }))
},
scanner,
...(gpo ? { gpo } : {})
};
}

View File

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

View File

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

View File

@ -6,7 +6,7 @@ import type {
FindingOccurrence,
JsonValue,
ScanRun
} from '../domain/canonical-model';
} from '../../domain/canonical-model';
import type {
ComparisonMatch,
ComparisonMatchMethod,
@ -16,11 +16,11 @@ import type {
FindingComparison,
LocalComparisonRule,
ScanRunComparison
} from '../domain/comparison';
} from '../../domain/comparison';
import { jaccardSimilarity, normalizeComparisonText, similarityTokens, stableJson } from './comparison-normalization';
import { localRuleConnects, validateLocalComparisonRules } from './local-mapping-rules';
import { definitionHasDurableIdentity } from '../domain/finding-identity';
import { resolvedCompatibilityKey } from '../domain/processing-compatibility';
import { definitionHasDurableIdentity } from '../../domain/finding-identity';
import { resolvedCompatibilityKey } from '../../domain/processing-compatibility';
interface ComparisonContext {
definitions: Map<string, FindingDefinition>;

View File

@ -1,4 +1,4 @@
import type { JsonValue } from '../domain/canonical-model';
import type { JsonValue } from '../../domain/canonical-model';
export function normalizeComparisonText(value: unknown): string {
return String(value ?? '')

View File

@ -1,7 +1,7 @@
import type { FindingDefinition, FindingOccurrence } from '../domain/canonical-model';
import type { ComparisonRuleEndpoint, LocalComparisonRule } from '../domain/comparison';
import type { FindingDefinition, FindingOccurrence } from '../../domain/canonical-model';
import type { ComparisonRuleEndpoint, LocalComparisonRule } from '../../domain/comparison';
import { normalizeComparisonText } from './comparison-normalization';
import { definitionHasDurableIdentity } from '../domain/finding-identity';
import { definitionHasDurableIdentity } from '../../domain/finding-identity';
function ruleId(occurrence: FindingOccurrence): string {
return normalizeComparisonText(occurrence.sourceIdentity.canonicalRuleId ?? occurrence.sourceIdentity.originalRuleId);

View File

@ -14,6 +14,7 @@ import { WorkPackagesWindow } from '../../remediation/WorkPackagesWindow';
import { UNASSIGNED_WORK_PACKAGE, WORK_PACKAGE_FACET_ID } from '../../remediation/remediation-engine';
import { numberedClusterLabel, publishedClusterColour } from '../sidebar/context-cluster-display';
import { useComplianceWorkFacets } from '../../compliance/ui/compliance-work-facets';
import { useComparisonWorkFacets } from '../comparison/comparison-work-facets';
export function ActionlistControls() {
const app = useApp();
@ -24,6 +25,7 @@ export function ActionlistControls() {
const workPackages = remediation.status === 'ready' ? remediation.index : undefined;
const packageOverview = remediation.status === 'ready' ? remediation.overview : undefined;
const complianceFacets = useComplianceWorkFacets();
const comparisonFacets = useComparisonWorkFacets();
const [failed, setFailed] = useState(false);
const [packagesOpen, setPackagesOpen] = useState(false);
const exportView = async (format: 'csv' | 'md' | 'html') => {
@ -31,7 +33,7 @@ export function ActionlistControls() {
const activity = beginActivity('Core Engine', `Preparing Work ${format.toUpperCase()} export`);
try {
await showActivityFrame();
const facets = selectWorkFacets(app.activeSheet, { ...app, contextClusters, workPackages, complianceFacets });
const facets = selectWorkFacets(app.activeSheet, { ...app, contextClusters, workPackages, complianceFacets, comparisonFacets });
const statusLabel = app.activeProfile.presentation.statusFilters.find(filter => filter.value === app.statusFilter)?.label ?? app.statusFilter;
const filters = Object.entries(app.facetFilters).map(([id, filter]) => {
const facet = facets.find(item => item.id === id);

View File

@ -0,0 +1,396 @@
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 { 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 {
projectId?: string;
/** Assessments of the timeslot open in Client Info; it is the default timeslot B. */
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. */
export const CHANGE_FACTS: Array<{ change: TimeslotChange; label: string; tone: 'better' | 'worse' | 'neutral'; glyph: string }> = [
{ change: 'resolved', label: 'Resolved', tone: 'better', glyph: '▼' },
{ change: 'new', label: 'New', tone: 'worse', glyph: '▲' },
{ change: 'regressed', label: 'Regressed', tone: 'worse', glyph: '▲' },
{ change: 'value-changed', label: 'Value changed', tone: 'neutral', glyph: '≠' },
{ change: 'unchanged', label: 'Unchanged', tone: 'neutral', glyph: '=' },
{ change: 'unverified', label: 'Unverified, still open', tone: 'neutral', glyph: '?' },
{ change: 'not-comparable', label: 'Not comparable', tone: 'neutral', glyph: '○' }
];
const REASONS: Record<string, string> = {
'baseline-source-missing': 'Not in timeslot A',
'target-source-missing': 'Not in timeslot B',
'baseline-source-ambiguous': 'Several runs in timeslot A',
'target-source-ambiguous': 'Several runs in timeslot B',
'no-comparable-scan-pair': 'No comparable scan pair',
'scan-scope-changed': 'Assessed scope changed',
'comparison-scope-changed': 'Assessed scope changed',
'processing-contract-incompatible': 'Processed with different import versions',
'processing-contract-unknown': 'Import version unknown',
'newer-observation-not-verified': 'No newer scan or backup time',
'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',
'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';
return toolProfileDefinitions.find(item => item.id === sourceId)?.name ?? sourceId;
}
export const date = (value: string) => {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
};
const rounded = (value?: number) => value === undefined ? undefined : Math.round(value);
function Delta({ before, after }: { before?: number; after?: number }) {
if (before === undefined || after === undefined) return <span className="comparison-delta is-neutral">—</span>;
const change = after - before;
if (change === 0) return <span className="comparison-delta is-neutral">= No change</span>;
return change > 0
? <span className="comparison-delta is-better">▲ +{change} Better</span>
: <span className="comparison-delta is-worse">▼ −{Math.abs(change)} Worse</span>;
}
function sourceRows(report: TimeslotComparisonReport) {
// A scanner may hold several sources (for example two domains); the table shows one row per scanner.
const byScanner = new Map<string, TimeslotComparisonReport['sources'][number]>();
for (const item of report.sources) {
const known = byScanner.get(item.sourceId);
byScanner.set(item.sourceId, known ? { sourceId: item.sourceId, state: known.state === 'compared' || item.state === 'compared' ? 'compared' : 'not-comparable',
reasons: [...new Set([...known.reasons, ...item.reasons])] } : item);
}
return [...byScanner.values()].map(source => {
const changes = report.changes.filter(item => item.sourceId === source.sourceId);
const count = (change: TimeslotChange) => changes.filter(item => item.change === change).length;
const score = report.score.sources.find(item => item.sourceId === source.sourceId);
return { source, count, score };
});
}
const ALL_CHANGES = 'changes';
const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info', 'unknown'] as const;
const severityOf = (value?: string) => SEVERITIES.find(item => item === value) ?? 'unknown';
const severityLabel = (value: string) => value[0].toUpperCase() + value.slice(1);
/** What the change says about A and B, in the words of the Finding Reader's `Compare` section. */
function stateNote(item: TimeslotChangeEntry): string {
if (item.change === 'value-changed' && item.problem) {
const changed = problemRows(item.problem).filter(row => row.changed);
const short = (value?: string) => value !== undefined && value.length <= 40 && !value.includes('\n');
// Short values (a count, a setting) are named with A → B; longer texts only by their label.
const named = changed.filter(row => short(row.baseline) && short(row.target));
return named.length
? `Deviation in A and B · ${named.map(row => `${row.noteLabel} ${row.baseline} → ${row.target}`).join(' · ')}`
: `Deviation in A and B · ${changed.map(row => row.label).join(', ') || 'value'} changed`;
}
if (item.change === 'value-changed' && item.values) {
const one = (values: string[]) => values.length === 1 && !formatPolicyComparisonValue(values[0]).includes('\n')
? formatPolicyComparisonValue(values[0]) : undefined;
const [a, b] = [one(item.values.baseline), one(item.values.target)];
return a && b ? `Deviation in A and B · ${a} → ${b}` : 'Deviation in A and B · configured value changed';
}
if (item.change === 'resolved') return 'Deviation in A · passed in B';
if (item.change === 'regressed') return 'Passed in A';
if (item.change === 'unchanged') return 'Deviation in A and B';
if (item.change === 'new') return '';
return item.reasons.length ? reasonText(item.reasons) : '';
}
/** Every finding and GPO setting of the comparison; `All changes` leaves out the unchanged ones. */
function ChangeList({ report, baselineDate, targetDate, targetIsOpen, onOpen }: {
report: TimeslotComparisonReport; baselineDate: string; targetDate: string; targetIsOpen: boolean;
onOpen?(entry: TimeslotChangeEntry, timeslot: 'baseline' | 'target'): void;
}) {
const id = useId();
const work = useWorkRecords(report, targetIsOpen);
const [shown, setShown] = useState<string>(ALL_CHANGES);
const [selected, setSelected] = useState<string>();
const order = new Map(CHANGE_FACTS.map((fact, index) => [fact.change, index]));
const facts = new Map(CHANGE_FACTS.map(fact => [fact.change, fact]));
const rows = report.changes
.filter(item => facts.has(item.change) && (shown === ALL_CHANGES ? item.change !== 'unchanged' : item.change === shown))
.map(item => ({ item, source: sourceName(item.sourceId), severity: severityOf(item.severity) }))
.sort((a, b) => order.get(a.item.change)! - order.get(b.item.change)!
|| SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity) || a.source.localeCompare(b.source)
|| (a.item.ruleId ?? '').localeCompare(b.item.ruleId ?? '', 'en', { numeric: true }) || (a.item.title ?? '').localeCompare(b.item.title ?? ''));
const count = (change: TimeslotChange) => report.changes.filter(item => item.change === change).length;
// The first row is selected until another is chosen, like the Work list; a filter change keeps a still listed row.
const selectedIndex = Math.max(0, rows.findIndex(row => rowKey(row.item) === selected));
const current = rows[selectedIndex];
return <section className="comparison-tile" aria-labelledby={`${id}-title`}>
<div className="comparison-tile__heading">
<h3 id={`${id}-title`}>Findings</h3>
<label className="comparison-tile__filter">Show
<select className="comparison-tab__select" value={shown} onChange={event => setShown(event.target.value)}>
<option value={ALL_CHANGES}>All changes</option>
{CHANGE_FACTS.map(fact => <option key={fact.change} value={fact.change}>{fact.label} ({count(fact.change)})</option>)}
</select>
</label>
</div>
{rows.length ? <div className="comparison-split">
<ul className="comparison-list" aria-label="Changed findings" onKeyDown={event => {
// Arrow keys move the selection like the Work list.
const step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
if (!step) return;
event.preventDefault();
const next = rows[Math.min(rows.length - 1, Math.max(0, selectedIndex + step))];
setSelected(rowKey(next.item));
event.currentTarget.querySelectorAll<HTMLButtonElement>('.comparison-item')[rows.indexOf(next)]?.focus();
}}>
{rows.map(({ item, source, severity }, index) => {
const fact = facts.get(item.change)!;
const note = [item.ruleId, stateNote(item)].filter(Boolean).join(' · ');
return <li key={rowKey(item)}>
<button type="button" className={`comparison-item severity-${severity}${index === selectedIndex ? ' is-selected' : ''}`}
aria-pressed={index === selectedIndex} tabIndex={index === selectedIndex ? 0 : -1} onClick={() => setSelected(rowKey(item))}>
<span className="data-tile-hex" aria-hidden="true"/>
<span className="comparison-item__content">
<span className="comparison-item__title">{item.title ?? '—'}</span>
<span className="comparison-item__meta">
<span className={`comparison-badge is-${fact.tone}`}><span aria-hidden="true">{fact.glyph} </span>{fact.label}</span>
<span className="reader-severity">{source}</span>
{note && <span className="comparison-item__note">{note}</span>}
</span>
</span>
<span className={`reader-severity severity-${severity}`}>{severityLabel(severity)}</span>
</button>
</li>;
})}
</ul>
{current && <ChangeDetailPane row={current} record={work.byEntry.get(current.item)} work={work} baselineDate={baselineDate} targetDate={targetDate}
onOpen={onOpen ? timeslot => onOpen(current.item, timeslot) : undefined}/>}
</div> : <p className="comparison-tile__note">No finding has this change.</p>}
</section>;
}
const rowKey = (item: TimeslotChangeEntry) => `${item.change}|${item.side}|${item.recordIds.join(',')}|${item.identityKey ?? ''}`;
/** The Work record of timeslot B behind each change, while B is the timeslot open in Client Info. */
function useWorkRecords(report: TimeslotComparisonReport, targetIsOpen: boolean) {
const app = useOptionalApp();
const dataset = app?.dataset;
return useMemo(() => {
const byEntry = new Map<TimeslotChangeEntry, FindingRecord>();
const sheet = dataset?.sheets.find(item => item.isProjectSheet) ?? app?.activeSheet;
if (!targetIsOpen || !dataset || !sheet) return { byEntry, sheets: [], sheetId: '', toolId: undefined };
const entryOf = entryIndex(report);
for (const record of sheet.analysis.records) {
const entry = entryOf(record);
if (entry && !byEntry.has(entry)) byEntry.set(entry, record);
}
return { byEntry, sheets: dataset.sheets, sheetId: sheet.id, toolId: sheet.toolId ?? dataset.profileId };
}, [report, targetIsOpen, dataset]); // eslint-disable-line react-hooks/exhaustive-deps
}
/** Problem and Next step of the Work record, exactly as the Finding Reader shows them first. */
function WorkFirstRead({ record, work }: { record: FindingRecord; work: ReturnType<typeof useWorkRecords> }) {
const toolId = toolIdForRecord(record, work.toolId);
const outcome = outcomeForStatusValue(toolId, resultStatusFieldFor(record, toolId)?.value) ?? 'unknown';
if (toolId === 'group-policy') {
const problem = policyProblemSummary(record);
return problem ? <PolicyProblemSummary summary={problem} outcome={outcome} search=""/> : null;
}
const summary = findingFirstRead(record, toolId, recordResultSheets(record, work.sheets, work.sheetId, toolId), outcome);
return summary ? <FindingFirstRead summary={summary} outcome={outcome} search=""/> : null;
}
/** Detail of the selected change (mockup B): source and change, title, Problem and Next step, then Compare. */
function ChangeDetailPane({ row, record, work, baselineDate, targetDate, onOpen }: {
row: { item: TimeslotChangeEntry; source: string }; record?: FindingRecord; work: ReturnType<typeof useWorkRecords>;
baselineDate: string; targetDate: string; onOpen?(timeslot: 'baseline' | 'target'): void;
}) {
// Timeslot A holds a record of this change when it was an A deviation, the single A pass of a regression, or any GPO setting A evaluated.
const inBaseline = row.item.side === 'baseline' || row.item.counterpartIds.length > 0 || Boolean(row.item.gpo?.baseline);
const fact = CHANGE_FACTS.find(item => item.change === row.item.change)!;
return <article className="comparison-detail" aria-live="polite">
<div className="comparison-detail__badges">
<span className="comparison-detail__source">{row.source}</span>
<span className={`comparison-badge is-${fact.tone}`}><span aria-hidden="true">{fact.glyph} </span>{fact.label}</span>
</div>
<h4 className="comparison-detail__title">{row.item.title ?? '—'}</h4>
<div className="reader-summary comparison-detail__summary">
{record && <WorkFirstRead record={record} work={work}/>}
</div>
<CompareSection entry={row.item} baselineDate={baselineDate} targetDate={targetDate} open/>
{onOpen && (record || inBaseline) && <div className="comparison-detail__actions">
{record && <button type="button" className="btn primary" onClick={() => onOpen('target')}>Open in Work</button>}
{inBaseline && <button type="button" className="btn" onClick={() => onOpen('baseline')}>Open in timeslot A</button>}
</div>}
</article>;
}
function DomainNode({ value }: { value?: number }) {
return value === undefined
? <span className="comparison-domain__node is-empty" aria-label="No value">—</span>
: <span className="comparison-domain__node">{Math.round(value)}</span>;
}
/** Security Domains of mockup A: A → B per domain over the shared sources, and resolved against worse changes. */
function DomainComparison({ projectId, baseline, target }: { projectId?: string; baseline: ComparableTimeslot; target: ComparableTimeslot }) {
const id = useId();
const state = useDomainComparison(projectId, baseline, target);
const domains: TimeslotDomainComparison['domains'] = state.status === 'ready' ? state.domains.domains : [];
const widest = Math.max(1, ...domains.flatMap(item => [item.resolved, item.worse]));
return <section className="comparison-tile" aria-labelledby={`${id}-title`} aria-busy={state.status === 'loading'}>
<h3 id={`${id}-title`}>Security Domains</h3>
{state.status === 'loading' && <p className="comparison-tile__note">Loading Security Domains…</p>}
{state.status === 'error' && <p className="comparison-tile__note" role="alert">The Security Domains could not be compared.</p>}
{state.status === 'ready' && <div className="comparison-table-scroll"><div className="comparison-domains" role="table" aria-label="Security Domains A and B">
<div className="comparison-domain is-header" role="row">
<span role="columnheader">Domain</span><span role="columnheader">A</span><span aria-hidden="true"/><span role="columnheader">B</span>
<span role="columnheader">Change</span>
<span role="columnheader" className="comparison-domain__legend"><span>Resolved ◀</span><span>▶ New + regressed</span></span>
</div>
{domains.map(domain => {
const a = domain.baseline.score === undefined ? undefined : Math.round(domain.baseline.score);
const b = domain.target.score === undefined ? undefined : Math.round(domain.target.score);
return <div className="comparison-domain" role="row" key={domain.id}>
<span role="cell" className="comparison-domain__name">{domain.label}</span>
<span role="cell"><DomainNode value={domain.onlyIn === 'target' ? undefined : domain.baseline.score}/></span>
<span aria-hidden="true" className="comparison-domain__arrow">→</span>
<span role="cell"><DomainNode value={domain.onlyIn === 'baseline' ? undefined : domain.target.score}/></span>
<span role="cell">{domain.onlyIn
? <span className="comparison-delta is-neutral">○ Only in {domain.onlyIn === 'target' ? 'B' : 'A'}</span>
: <DomainDelta before={a} after={b}/>}</span>
<span role="cell">{domain.onlyIn
? <span className="comparison-tile__note comparison-domain__only">{domain.onlyInSources.map(sourceName).join(', ')} only in {domain.onlyIn === 'target' ? 'B' : 'A'} · not comparable</span>
: <span className="comparison-domain__bars" aria-label={`${domain.resolved} resolved, ${domain.worse} new or regressed`}>
<span className="comparison-domain__bar is-better" style={{ width: `${domain.resolved / widest * 100}%` }}/>
<span className="comparison-domain__axis"/>
<span className="comparison-domain__bar is-worse" style={{ width: `${domain.worse / widest * 100}%` }}/>
</span>}</span>
</div>;
})}
</div></div>}
{state.status === 'ready' && <p className="comparison-tile__note">Pass rate of each domain as in Client Info, over the sources compared in both timeslots: {state.domains.sources.map(sourceName).join(', ') || 'none'}.</p>}
</section>;
}
function DomainDelta({ before, after }: { before?: number; after?: number }) {
if (before === undefined || after === undefined) return <span className="comparison-delta is-neutral">—</span>;
const change = after - before;
if (change === 0) return <span className="comparison-delta is-neutral">= 0</span>;
return change > 0 ? <span className="comparison-delta is-better">▲ +{change}</span> : <span className="comparison-delta is-worse">▼ −{Math.abs(change)}</span>;
}
/** Client Info `Compare` tab content of the Comparison Engine (mockup A, user decision 2026-10-13). */
export function ComparisonTab({ projectId, assessmentIds = [], onOpenChange, onOpenFinding }: Props) {
const id = useId();
const comparison = useTimeslotComparison(projectId, assessmentIds);
const { state, baseline, target, earlier, timeslots, targetIsOpen } = comparison;
const report = state.status === 'ready' ? state.report : undefined;
// Work shows the open timeslot; only changes with a record there can open Work.
const openable = report && targetIsOpen && onOpenChange ? changesWithWorkRecords(report) : new Set<TimeslotChange>();
const option = (item: ComparableTimeslot) => <option key={item.id} value={item.id}>{date(item.date)}</option>;
return <section className="comparison-tab" aria-labelledby={`${id}-title`} aria-busy={state.status === 'loading'}>
<header className="comparison-tab__header">
<h2 id={`${id}-title`}>Compare</h2>
{timeslots.length > 1 && target && <div className="comparison-tab__timeslots">
<label>A
<select className="comparison-tab__select" value={baseline?.id ?? ''} disabled={!earlier.length}
onChange={event => comparison.selectBaseline(event.target.value)}>{earlier.map(option)}</select>
</label>
<span aria-hidden="true" className="comparison-tab__arrow">→</span>
<label>B
<select className="comparison-tab__select" value={target.id} onChange={event => comparison.selectTarget(event.target.value)}>
{timeslots.map(option)}
</select>
</label>
</div>}
<span className="comparison-tab__note">Read-only comparison · verdicts and scores stay unchanged</span>
</header>
{state.status === 'loading' && <p className="comparison-tab__status">Comparing timeslots…</p>}
{state.status === 'error' && <p className="comparison-tab__status" role="alert">{state.message}</p>}
{state.status === 'unavailable' && <p className="comparison-tab__status">
{state.reason === 'single-timeslot' ? 'This client has only one timeslot. A comparison needs two.' : 'There is no earlier timeslot to compare with.'}
</p>}
{report && baseline && target && <>
<div className="comparison-tab__grid">
<section className="comparison-tile" aria-labelledby={`${id}-score`}>
<h3 id={`${id}-score`}>Overall Score</h3>
<dl className="comparison-score">
<div><dt>A · {date(baseline.date)}</dt><dd>{rounded(report.score.baselineScore) ?? '—'}</dd></div>
<div><dt>B · {date(target.date)}</dt><dd>{rounded(report.score.targetScore) ?? '—'}</dd></div>
</dl>
<Delta before={rounded(report.score.baselineScore)} after={rounded(report.score.targetScore)}/>
<p className="comparison-tile__note">{report.score.change === undefined
? 'No source has a comparable score in both timeslots.'
: `Comparable sources only: ${report.score.sources.filter(item => item.change !== undefined).map(item => sourceName(item.sourceId)).join(', ')}.`}</p>
</section>
<section className="comparison-tile comparison-tile--wide" aria-labelledby={`${id}-changes`}>
<h3 id={`${id}-changes`}>Changes</h3>
{!targetIsOpen && <p className="comparison-tile__note">Open timeslot B in Client Info to show these changes in Work.</p>}
<div className="comparison-facts">
{CHANGE_FACTS.map(fact => {
const count = fact.change === 'not-comparable'
? report.summary['not-comparable'] + report.summary.targetNotComparable : report.summary[fact.change];
return <button key={fact.change} type="button" className="comparison-fact" disabled={!count || !openable.has(fact.change)}
onClick={() => onOpenChange?.(fact.change, baseline, target)}>
<span className="comparison-fact__label">{fact.label}</span>
<span className={`comparison-fact__value is-${fact.tone}`}><span aria-hidden="true">{fact.glyph} </span>{count.toLocaleString('en-US')}</span>
</button>;
})}
</div>
</section>
</div>
<DomainComparison projectId={projectId} baseline={baseline} target={target}/>
<section className="comparison-tile" aria-labelledby={`${id}-sources`}>
<h3 id={`${id}-sources`}>Sources</h3>
<div className="comparison-table-scroll">
<table className="client-information-table comparison-table">
<thead><tr><th>Source</th><th>Resolved</th><th>New</th><th>Regressed</th><th>Value changed</th><th>Unverified</th>
<th>Not comparable</th><th>Score A</th><th>Score B</th><th>Change</th><th>Comparison basis</th></tr></thead>
<tbody>{sourceRows(report).map(({ source, count, score }) => <tr key={source.sourceId}>
<td>{sourceName(source.sourceId)}</td>
<td>{count('resolved')}</td><td>{count('new')}</td><td>{count('regressed')}</td><td>{count('value-changed')}</td>
<td>{count('unverified')}</td><td>{count('not-comparable')}</td>
<td>{rounded(score?.baselineScore) ?? '—'}</td><td>{rounded(score?.targetScore) ?? '—'}</td>
<td><Delta before={rounded(score?.baselineScore)} after={rounded(score?.targetScore)}/></td>
<td>{source.state === 'compared' ? (score?.reasons.length ? reasonText(score.reasons) : 'Compared') : reasonText(source.reasons)}</td>
</tr>)}</tbody>
</table>
</div>
</section>
<ChangeList report={report} baselineDate={baseline.date} targetDate={target.date} targetIsOpen={targetIsOpen}
onOpen={onOpenFinding ? (entry, timeslot) => onOpenFinding(entry, timeslot, baseline, target) : undefined}/>
</>}
</section>;
}

View File

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

View File

@ -0,0 +1,121 @@
import { useApp } from '../../app/AppProvider';
import { useEnginePreferences } from '../../app/engine-preferences';
import type { FindingRecord } from '../../domain/finding';
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';
/** Work facet of the Comparison Engine; present only while a comparison of the open timeslot B is loaded. */
export const COMPARISON_CHANGE_FACET_ID = 'comparison:change';
const SOURCE_SCAN_FACET_ID = 'provenance:source-scan';
/** Facet order: better first, then worse, then neutral and unclear states. */
export const CHANGE_LABELS: Array<{ change: TimeslotChange; label: string }> = [
{ change: 'resolved', label: 'Resolved' },
{ change: 'new', label: 'New' },
{ change: 'regressed', label: 'Regressed' },
{ change: 'value-changed', label: 'Value changed' },
{ change: 'unchanged', label: 'Unchanged' },
{ change: 'unverified', label: 'Unverified, still open' },
{ change: 'not-applicable', label: 'Not applicable' },
{ change: 'not-comparable', label: 'Not comparable' }
];
export interface ComparisonWorkFacetIndex {
facets: DynamicFacet[];
/** The change of a Work record of timeslot B, if the comparison names one. */
changeOf(record: FindingRecord): TimeslotChange | undefined;
/** The full comparison entry behind that change, for the Finding Reader. */
entryOf(record: FindingRecord): TimeslotChangeEntry | undefined;
comparison: PublishedComparison;
}
/**
* 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.
*/
export function entryIndex(report: TimeslotComparisonReport) {
const byOccurrence = new Map<string, TimeslotChangeEntry>();
const bySetting = new Map<string, TimeslotChangeEntry>();
for (const entry of report.changes) {
if (entry.sourceId === GPO_SOURCE_ID) {
if (entry.identityKey && (entry.side === 'target' || entry.counterpartIds.length)) bySetting.set(entry.identityKey, entry);
continue;
}
for (const id of entry.side === 'target' ? entry.recordIds : entry.counterpartIds) byOccurrence.set(id, entry);
}
return (record: FindingRecord): TimeslotChangeEntry | undefined => {
const scanner = record.findingOccurrenceId ? byOccurrence.get(record.findingOccurrenceId) : undefined;
if (scanner) return scanner;
const key = gpoRecordSettingKey(record);
return key ? bySetting.get(key) : undefined;
};
}
/** Changes that have at least one Work record in timeslot B, so the Compare tab can open them in Work. */
export function changesWithWorkRecords(report: TimeslotComparisonReport): Set<TimeslotChange> {
return new Set(report.changes.filter(entry => entry.side === 'target' || entry.counterpartIds.length).map(entry => entry.change));
}
const indexes = new WeakMap<PublishedComparison, ComparisonWorkFacetIndex>();
export function comparisonWorkFacetIndex(comparison: PublishedComparison): ComparisonWorkFacetIndex {
const existing = indexes.get(comparison);
if (existing) return existing;
const entryOf = entryIndex(comparison.report);
const changeOf = (record: FindingRecord) => entryOf(record)?.change;
const date = new Date(comparison.baseline.date);
const label = Number.isNaN(date.getTime()) ? comparison.baseline.date
: date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
const index: ComparisonWorkFacetIndex = {
comparison,
changeOf,
entryOf,
facets: [{
id: COMPARISON_CHANGE_FACET_ID,
label: 'Change',
source: 'derived',
kind: 'category',
description: `Comparison Engine: change of each finding and GPO setting against timeslot A (${label}). It does not change results.`,
values: CHANGE_LABELS.map(item => ({ value: item.change, label: item.label, count: 0 })),
valuesFor: record => { const change = changeOf(record); return change ? [change] : []; }
}]
};
indexes.set(comparison, index);
return index;
}
/** Adds the `Change` facet directly before the permanent final `Source: Scan` control. */
export function withComparisonFacets(facets: DynamicFacet[], index: ComparisonWorkFacetIndex | undefined): DynamicFacet[] {
if (!index) return facets;
const sourceScan = facets.findIndex(facet => facet.id === SOURCE_SCAN_FACET_ID);
return sourceScan < 0 ? [...facets, ...index.facets]
: [...facets.slice(0, sourceScan), ...index.facets, ...facets.slice(sourceScan)];
}
/** The facet index for Work, or undefined while the engine is off or no comparison of the open timeslot is loaded. */
export function useComparisonWorkFacets(): ComparisonWorkFacetIndex | undefined {
const app = useApp();
const enabled = useEnginePreferences().comparison;
const comparison = usePublishedComparison(app.targetProjectId, app.activeAssessmentIds ?? []);
return enabled && comparison ? comparisonWorkFacetIndex(comparison) : undefined;
}
/**
* Work record IDs of one change in the timeslot that is open: scanner records by occurrence (the A records of
* a baseline entry, or the B records and counterparts), GPO settings by scope and technical identity.
*/
export function workRecordIdsFor(entry: TimeslotChangeEntry, timeslot: 'baseline' | 'target', records: FindingRecord[]): string[] {
if (entry.sourceId === GPO_SOURCE_ID) {
if (!entry.identityKey) return [];
return records.filter(record => {
return gpoRecordSettingKey(record) === entry.identityKey;
}).map(record => record.id);
}
const occurrences = new Set(timeslot === 'baseline'
? (entry.side === 'baseline' ? entry.recordIds : entry.counterpartIds)
: (entry.side === 'target' ? entry.recordIds : entry.counterpartIds));
return records.filter(record => record.findingOccurrenceId && occurrences.has(record.findingOccurrenceId)).map(record => record.id);
}

View File

@ -0,0 +1,114 @@
/* Comparison Engine UI. Shared tokens only; better and worse always pair their colour with a glyph and text. */
.comparison-tab { display: grid; gap: var(--segment-gap); margin-block: 0 var(--space-8); color: var(--text-primary); min-width: 0; }
.comparison-tab__header { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-4); }
.comparison-tab__header > h2 { margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
.comparison-tab__timeslots { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
.comparison-tab__timeslots label { display: inline-flex; align-items: center; gap: var(--space-2); font-size: var(--type-table-body-size); color: var(--text-secondary); }
.comparison-tab__select { height: var(--btn-h); padding: 0 var(--space-2); border: var(--bw-hairline) solid var(--border-default); border-radius: var(--btn-radius); background: var(--surface-input); color: var(--text-primary); font: var(--fw-semibold) var(--type-button-size) var(--font-ui); }
.comparison-tab__select:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--bw-hairline); }
.comparison-tab__arrow { color: var(--text-secondary); }
.comparison-tab__note { margin-inline-start: auto; font-size: var(--type-meta-size); color: var(--text-secondary); }
.comparison-tab__status { margin: 0; font-size: var(--type-body-size); color: var(--text-secondary); }
.comparison-tab__grid { display: grid; grid-template-columns: minmax(var(--tile-environment-min-width), 1fr) minmax(0, 2fr); gap: var(--segment-gap); align-items: stretch; }
@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); }
.comparison-score > div { padding: var(--space-2) var(--space-3); border: var(--bw-hairline) solid var(--tile-divider); }
.comparison-score dt { font-size: var(--type-table-body-size); color: var(--text-secondary); }
.comparison-score dd { margin: 0; font-size: var(--type-h2-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
.comparison-delta { font-size: var(--type-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; white-space: nowrap; }
.comparison-delta.is-better, .comparison-fact__value.is-better { color: var(--outcome-pass); }
.comparison-delta.is-worse, .comparison-fact__value.is-worse { color: var(--outcome-fail); }
.comparison-delta.is-neutral, .comparison-fact__value.is-neutral { color: var(--text-primary); }
.comparison-facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
.comparison-fact { display: grid; gap: var(--space-1); padding: var(--space-2) var(--space-3); border: var(--bw-hairline) solid var(--tile-divider); background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
.comparison-fact:hover:not(:disabled) { background: var(--tile-surface-hover); }
.comparison-fact:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(-1 * var(--bw-emphasis)); }
.comparison-fact:disabled { cursor: default; }
.comparison-fact__label { font-size: var(--type-table-body-size); color: var(--text-secondary); }
.comparison-fact__value { font-size: var(--type-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
.comparison-table-scroll { overflow-x: auto; }
.comparison-table td { font-variant-numeric: tabular-nums; }
/* Finding Reader `Compare` section, collapsed like Context. */
/* Same disclosure surface as the Finding Reader's Context section, so both derived readings look alike. */
.finding-comparison { display: block; min-width: 0; margin: 0 0 var(--space-4); padding: var(--space-4); border: var(--bw-hairline) solid var(--tile-border); background: var(--tile-surface); }
.reader-summary > .finding-comparison { border-bottom: var(--bw-hairline) solid var(--tile-border); padding: var(--space-4); margin: 0 0 var(--space-4); }
.finding-comparison__header { cursor: pointer; display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2) var(--space-4); list-style: none; }
.finding-comparison__header::-webkit-details-marker { display: none; }
.finding-comparison__header h4 { margin: 0; }
.finding-comparison[open] > .finding-comparison__header { margin-bottom: var(--space-3); }
.finding-comparison__scope { font-size: var(--type-table-body-size); font-weight: var(--fw-regular); color: var(--text-secondary); }
.finding-comparison__change { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-3); margin: var(--space-2) 0; }
.finding-comparison__note { margin: var(--space-2) 0 0; font-size: var(--type-table-body-size); color: var(--text-secondary); }
/* Security Domains (mockup A): score nodes A → B, labelled change and diverging resolved/worse bars. */
.comparison-domains { display: grid; min-width: 720px; }
.comparison-domain { display: grid; grid-template-columns: minmax(200px, 1.4fr) 40px 16px 40px 96px minmax(200px, 1fr); align-items: center; gap: var(--space-3);
padding: var(--space-2) var(--space-3); border: var(--bw-hairline) solid var(--tile-divider); margin-top: calc(-1 * var(--bw-hairline)); }
.comparison-domain.is-header { border: 0; padding-block: 0 var(--space-1); font-size: var(--type-meta-size); color: var(--text-secondary); }
.comparison-domain__name { font-size: var(--type-body-size); }
.comparison-domain__node { display: grid; place-items: center; width: 36px; height: 36px; border: 3px solid var(--donut-track); border-radius: 50%;
font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
.comparison-domain__node.is-empty { border-style: dashed; color: var(--text-secondary); }
.comparison-domain__arrow { color: var(--text-secondary); text-align: center; }
.comparison-domain__legend { display: flex; justify-content: space-between; gap: var(--space-2); }
.comparison-domain__bars { display: grid; grid-template-columns: 1fr 1px 1fr; align-items: center; height: 16px; }
.comparison-domain__axis { height: 16px; background: var(--border-strong); }
.comparison-domain__bar { height: 8px; }
.comparison-domain__bar.is-better { justify-self: end; background: var(--outcome-pass); }
.comparison-domain__bar.is-worse { justify-self: start; background: var(--outcome-fail); }
.comparison-domain__only { margin: 0; }
.comparison-detail__actions { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-3); }
/* Compare table: labels never break inside a word, A and B use the same type size and wrap at word boundaries. */
.finding-comparison .comparison-table td, .finding-comparison .comparison-table th { font-size: var(--type-table-body-size); overflow-wrap: break-word; }
.finding-comparison .comparison-table td:first-child { width: 1%; white-space: nowrap; color: var(--text-secondary); }
.finding-comparison .comparison-table th:not(:first-child), .finding-comparison .comparison-table td:not(:first-child) { width: 50%; min-width: 8rem; font-size: var(--type-table-body-size); }

View File

@ -0,0 +1,174 @@
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();
export interface ComparableTimeslot {
id: string;
date: string;
assessmentIds: string[];
scanRunIds: string[];
}
export type TimeslotComparisonState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'unavailable'; reason: 'single-timeslot' | 'no-earlier-timeslot' }
| { status: 'ready'; report: TimeslotComparisonReport };
/** The comparison the Compare tab last loaded for the timeslot open in Client Info; Work reads it for its facet. */
export interface PublishedComparison {
projectId: string;
/** Sorted assessment IDs of timeslot B, the open timeslot. */
targetAssessmentIds: string[];
baseline: ComparableTimeslot;
target: ComparableTimeslot;
report: TimeslotComparisonReport;
}
const key = (values: readonly string[]) => [...values].sort().join('\u0000');
const sameSet = (left: readonly string[], right: readonly string[]) => key(left) === key(right);
const listeners = new Set<() => void>();
let storeVersion = 0;
const notify = () => { storeVersion++; listeners.forEach(listener => listener()); };
const version = () => storeVersion;
const subscribe = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; };
// Timeslot A chosen per client and timeslot B, shared by the Compare tab and Work.
const baselineChoices = new Map<string, string>();
let published: PublishedComparison | undefined;
export function publishedComparison(): PublishedComparison | undefined { return published; }
/** The published comparison while it belongs to this client and open timeslot; never a comparison of another scope. */
export function usePublishedComparison(projectId: string | undefined, openAssessmentIds: readonly string[]): PublishedComparison | undefined {
const current = useSyncExternalStore(subscribe, publishedComparison, publishedComparison);
return current && current.projectId === projectId && sameSet(current.targetAssessmentIds, openAssessmentIds) ? current : undefined;
}
/** Test support. */
export function resetTimeslotComparisonRequestsForTests(): void {
reports.clear(); domainRequests.clear(); timeslotRequests.clear(); baselineChoices.clear(); published = undefined; notify();
}
function comparable(group: ScanImportGroup): ComparableTimeslot {
return { id: group.id, date: group.fileDate, assessmentIds: [...group.assessmentIds].sort(), scanRunIds: [...group.scanRunIds].sort() };
}
const timeslotRequests = new Map<string, Promise<ComparableTimeslot[]>>();
/** Oldest first, the order in which a comparison reads A before B. Reloaded for every newly opened scope. */
function loadTimeslots(projectId: string, scope: string): Promise<ComparableTimeslot[]> {
const requestKey = `${projectId}\u0000${scope}`;
let request = timeslotRequests.get(requestKey);
if (!request) {
request = Promise.all([
api.listProjectImportFiles(projectId), api.listProjectAssessmentHistory(projectId), api.listProjectGpoBackupStatus(projectId)
]).then(([files, history, gpo]) => groupProjectImportFiles(files, history.assessments, history.scanRuns, gpo.records)
.filter(group => group.assessmentIds.length)
.map(comparable)
.sort((left, right) => Date.parse(left.date) - Date.parse(right.date) || left.id.localeCompare(right.id)));
timeslotRequests.set(requestKey, request);
while (timeslotRequests.size > 8) timeslotRequests.delete(timeslotRequests.keys().next().value!);
request.catch(() => timeslotRequests.delete(requestKey));
}
return request;
}
const reports = new Map<string, Promise<TimeslotComparisonReport>>();
function requestReport(projectId: string, baseline: ComparableTimeslot, target: ComparableTimeslot) {
const reportKey = JSON.stringify([projectId, baseline.assessmentIds, baseline.scanRunIds, target.assessmentIds, target.scanRunIds]);
let request = reports.get(reportKey);
if (!request) {
request = api.getTimeslotComparison<TimeslotComparisonReport>(projectId, baseline, target);
reports.set(reportKey, request);
while (reports.size > 8) reports.delete(reports.keys().next().value!);
request.catch(() => reports.delete(reportKey));
}
return request;
}
/**
* Timeslots of the open client and the explicit A/B report. B defaults to the timeslot open in Client
* Info, A to the timeslot directly before it; both stay selectable, A always before B. A report whose B is
* the open timeslot is published for the Work `Change` facet.
*/
export function useTimeslotComparison(projectId: string | undefined, openAssessmentIds: readonly string[]) {
const openKey = key(openAssessmentIds);
const [timeslots, setTimeslots] = useState<{ scope: string; items?: ComparableTimeslot[]; error?: string }>();
const [targetChoice, setTargetChoice] = useState<{ scope: string; id: string }>();
const [result, setResult] = useState<{ key: string; state: TimeslotComparisonState }>();
const scope = `${projectId}\u0000${openKey}`;
useSyncExternalStore(subscribe, version, version);
useEffect(() => {
if (!projectId) return;
let current = true;
loadTimeslots(projectId, openKey).then(items => { if (current) setTimeslots({ scope, items }); },
error => { if (current) setTimeslots({ scope, error: error instanceof Error ? error.message : 'Timeslots could not be loaded.' }); });
return () => { current = false; };
}, [scope]); // eslint-disable-line react-hooks/exhaustive-deps
const items = timeslots?.scope === scope ? timeslots.items : undefined;
const open = useMemo(() => items?.find(item => sameSet(item.assessmentIds, openAssessmentIds)), [items, openKey]); // eslint-disable-line react-hooks/exhaustive-deps
const target = items?.find(item => targetChoice?.scope === scope && item.id === targetChoice.id) ?? open ?? items?.[items.length - 1];
const earlier = items && target ? items.slice(0, items.indexOf(target)) : [];
const choiceKey = target ? JSON.stringify([projectId, target.id]) : '';
const baseline = earlier.find(item => item.id === baselineChoices.get(choiceKey)) ?? earlier[earlier.length - 1];
const requestKey = projectId && baseline && target ? JSON.stringify([projectId, baseline.id, target.id]) : '';
useEffect(() => {
if (!projectId || !baseline || !target) return;
let current = true;
requestReport(projectId, baseline, target).then(report => {
if (!current) return;
setResult({ key: requestKey, state: { status: 'ready', report } });
if (target === open) {
published = { projectId, targetAssessmentIds: [...target.assessmentIds], baseline, target, report };
notify();
}
}, error => { if (current) setResult({ key: requestKey, state: { status: 'error', message: error instanceof Error ? error.message : 'The comparison could not be loaded.' } }); });
return () => { current = false; };
}, [requestKey]); // eslint-disable-line react-hooks/exhaustive-deps
let state: TimeslotComparisonState;
if (timeslots?.scope === scope && timeslots.error) state = { status: 'error', message: timeslots.error };
else if (!items) state = { status: 'loading' };
else if (items.length < 2) state = { status: 'unavailable', reason: 'single-timeslot' };
else if (!baseline) state = { status: 'unavailable', reason: 'no-earlier-timeslot' };
else state = result?.key === requestKey ? result.state : { status: 'loading' };
return {
state, timeslots: items ?? [], baseline, target, earlier,
/** True while B is the timeslot open in Client Info, so Work shows the same records. */
targetIsOpen: Boolean(target && target === open),
selectBaseline: (id: string) => { if (choiceKey) { baselineChoices.set(choiceKey, id); notify(); } },
selectTarget: (id: string) => setTargetChoice({ scope, id })
};
}
const domainRequests = new Map<string, Promise<TimeslotDomainComparison>>();
export type DomainComparisonState = { status: 'loading' } | { status: 'error' } | { status: 'ready'; domains: TimeslotDomainComparison };
/** Security Domain values of the selected A/B pair; requested apart from the report because they read both Work datasets. */
export function useDomainComparison(projectId: string | undefined, baseline?: ComparableTimeslot, target?: ComparableTimeslot): DomainComparisonState {
const requestKey = projectId && baseline && target
? JSON.stringify([projectId, baseline.assessmentIds, baseline.scanRunIds, target.assessmentIds, target.scanRunIds]) : '';
const [result, setResult] = useState<{ key: string; state: DomainComparisonState }>();
useEffect(() => {
if (!requestKey || !projectId || !baseline || !target) return;
let current = true;
let request = domainRequests.get(requestKey);
if (!request) {
request = api.getTimeslotDomainComparison<TimeslotDomainComparison>(projectId, baseline, target);
domainRequests.set(requestKey, request);
while (domainRequests.size > 8) domainRequests.delete(domainRequests.keys().next().value!);
request.catch(() => domainRequests.delete(requestKey));
}
request.then(domains => { if (current) setResult({ key: requestKey, state: { status: 'ready', domains } }); },
() => { if (current) setResult({ key: requestKey, state: { status: 'error' } }); });
return () => { current = false; };
}, [requestKey]); // eslint-disable-line react-hooks/exhaustive-deps
return result?.key === requestKey ? result.state : { status: 'loading' };
}

View File

@ -4,6 +4,9 @@ import { ClientSecurityPanel, type SecurityFindingGroup } from './ClientSecurity
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, 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';
@ -1144,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;
@ -1232,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
? <div className="dashboard-page panel-is-loading"><h2>Client Info</h2><PanelLoadingState message={app.status}/></div>
: <EmptyWorkspace />;
@ -1244,6 +1275,22 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
setView('columns');
onOpenWork();
};
// Comparison Engine tab (user decision 2026-10-13): the open timeslot is B, an earlier timeslot A.
const comparisonContent = engines.comparison && app.backendStatus === 'connected' && app.targetProjectId
? <div className="dashboard-page client-command-center"><ComparisonTab projectId={app.targetProjectId} assessmentIds={app.activeAssessmentIds}
onOpenChange={change => jump(() => {
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
if (targetSheet) app.setActiveSheet(targetSheet.id);
// 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)}
onOpenFinding={(entry, timeslot, baseline) => {
if (timeslot === 'target') { openRecordsInWork(workRecordIdsFor(entry, 'target', workRecords())); return; }
pendingBaselineFinding = { projectId: app.targetProjectId!, assessmentIds: [...baseline.assessmentIds].sort().join(), entry };
void app.openProject(app.targetProjectId!, baseline.scanRunIds, baseline.assessmentIds);
}}/></div>
: null;
// Compliance Engine tab: one tile per framework; a Results window opens a control or theme in Work.
const complianceContent = showCompliance ? <ClientIsoControls projectId={app.targetProjectId} assessmentIds={app.activeAssessmentIds} scanRunIds={app.activeScanRunIds}
backendStatus={app.backendStatus} onOpenControl={controlId => jump(() => {
@ -1483,6 +1530,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
dataset={dataset} sheetId={activeSheet.id} toolId={activeSheet.toolId} contextStatus={impactContext.status} {...securityView}/> }]),
...(defenderContent ? [{ id: 'defender', label: 'Microsoft Defender for IoT', content: defenderContent, lazy: true }] : []),
...(complianceContent ? [{ id: 'compliance', label: 'Compliance', content: complianceContent, lazy: true }] : []),
...(comparisonContent ? [{ id: 'compare', label: 'Compare', content: comparisonContent, lazy: true }] : []),
...(showAiOverview ? [{ id: 'ai', label: 'AI Overview', content: <AiOverviewPanel controller={aiOverview} />, lazy: true }] : [])]} />
);
}

View File

@ -8,16 +8,21 @@ const nodes = [
{ id: 'input', name: 'Source inputs', caption: 'Scanner reports · GPO backups · catalogs', description: 'Original scanner reports, GPO backups and reference catalogs enter the Core import and evaluation pipeline.' },
{ id: 'core', engine: true, name: 'Core Engine', caption: 'Authoritative results · always active', description: 'All existing ASSESSOR functions: import, normalization, evaluation, scoring, client records and disclosure. Owns canonical PostgreSQL data. Works independently of optional engines.' },
{ id: 'database', name: 'Canonical PostgreSQL', caption: 'Core-owned data · disclosed Result DB', description: 'Canonical local datastore. The Core owns persistence, including the stored and verified Result DB projection.' },
{ id: 'port', name: 'Core read-only port', caption: 'Scoped results · provenance', description: 'Declared read-only Core port. Optional engines read authoritative results without changing stored data. Context and Report Engine read through read-only database access; the Compliance Engine still reads through the running Core repository.' },
{ id: 'port', name: 'Core read-only port', caption: 'Scoped results · provenance', description: 'Declared read-only Core port. Optional engines read authoritative results without changing stored data. Context, Report and Remediation Engine read through read-only database access; the Compliance Engine still reads through the running Core repository.' },
{ id: 'context', engine: true, name: 'Context Engine', caption: 'Derived context · optional', description: 'Reads findings and GPO verdicts through the read-only Core port and read-only database access. Produces versioned clusters, confidence and evidence. In-memory results; no own persistence or Core writes.' },
{ id: 'report', engine: true, name: 'Report Engine', caption: 'Read-only reporting · optional', description: 'Reads Core results through the read-only Core port and a separate read-only PostgreSQL pool. Produces report projections, management and technical reports. Own file archive; no Core writes.' },
{ id: 'ui', name: 'Core UI & exports', caption: 'Findings · scores · Result DB', description: 'Application views show authoritative Core findings, verdicts and scores. Result DB exports obey the Core disclosure policy.' },
{ id: 'context-ui', name: 'Context in the UI', caption: 'Work · Client Info · Pathfinder', description: 'Published Context results appear through existing UI extension points. They never override technical results or client records.' },
{ id: 'archive', name: 'Report outputs', caption: 'File archive · downloads', description: 'Generated reports are stored in the separate Report Engine file archive and offered for download; no report storage in Core tables.' },
{ id: 'remediation', engine: true, name: 'Remediation Engine', caption: 'Work packages · optional', description: 'Groups the deviating Work records of a timeslot into work packages: one Context cluster and one platform per package, at most eight tasks (fifteen for Windows host/GPO). Reads through the read-only Core port with its own read-only PostgreSQL pool and uses the published Context of the same scope. Package numbers are identifiers, never priorities; verdicts, scores, reviews and Result DB stay unchanged.' },
{ id: 'remediation-tables', name: 'Remediation tables', caption: 'Package snapshots', description: 'Derived package snapshots in the engine-owned schema assessor_remediation (table package_snapshots), keyed by client, timeslot, Core revision and engine, catalog and Context versions. Written through the engine pool; no columns in Core tables.' },
{ id: 'remediation-ui', name: 'Work packages in the UI', caption: 'Work · Finding Reader · Packages window', description: 'The Work Package filter, the Work Packages window, related-finding rings and the Finding Reader link Context Cluster → AP-xx appear through existing UI extension points. Packages carry no owners, deadlines or effort.' },
{ id: 'compliance', engine: true, name: 'Compliance Engine', caption: 'Regulatory control mapping · optional', description: 'Maps findings and GPO baseline comparisons to regulatory controls; ISO/IEC 27001/27002 is the first framework. Reads Core results through the read-only Core port and writes only its own tables. Proposals never change verdicts, scores or client records.' },
{ id: 'compliance-tables', name: 'Compliance tables', caption: 'Mappings · decisions · batches', description: 'Mapping runs, accept/reject decisions and batches in three engine-owned tables of the canonical PostgreSQL. Written through a separate small pool; engine writes never advance the Core revision.' },
{ id: 'retrieval', name: 'Retrieval & model', caption: 'Qdrant index · LiteLLM', description: 'The rebuildable Qdrant index of the uploaded control corpus and the configured LiteLLM model. They receive privacy-safe finding text and corpus evidence, never assets or customer values.' },
{ id: 'compliance-ui', name: 'ISO mapping in the UI', caption: 'Finding Reader · Result DB · Data Hub', description: 'Control proposals, accept/reject decisions, filtered batches and the ISO corpus upload appear through existing UI extension points. A proposal indicates relevance, not proof of compliance.' }
{ id: 'compliance-ui', name: 'ISO mapping in the UI', caption: 'Finding Reader · Result DB · Data Hub', description: 'Control proposals, accept/reject decisions, filtered batches and the ISO corpus upload appear through existing UI extension points. A proposal indicates relevance, not proof of compliance.' },
{ id: 'comparison', engine: true, name: 'Comparison Engine', caption: 'Timeslot comparison · optional', description: 'Compares two timeslots A and B of one client: verified resolved, still open, unverified, new and regressed findings, neutral GPO value changes and a score delta over sources comparable in both. Reads both timeslots as Work does through the read-only Core port; stores nothing yet. Verdicts, scores, reviews and Result DB stay unchanged.' },
{ id: 'comparison-ui', name: 'Comparison in the UI', caption: 'Client Info · Work · Finding Reader', description: 'The Client Info Compare tab, the Work Change facet and the Finding Reader Compare section appear through existing UI extension points. They never override technical results or client records.' }
];
const relationships = [
{ from: 'input', to: 'core', label: 'Import', detail: 'Import and evaluation', path: 'M240 340 H310' },
@ -29,9 +34,15 @@ const relationships = [
{ from: 'context', to: 'context-ui', label: 'Context', detail: 'Published context results through existing UI extension points', path: 'M710 290 V160' },
{ from: 'report', to: 'archive', label: 'Reports', detail: 'Generated reports; separate from Core database storage', path: 'M1000 520 V390' },
{ from: 'context', to: 'report', label: 'Published', detail: 'Versioned Context publication for the Management Report, composed by the Core backend; neither engine imports the other', path: 'M820 340 H855 V490 H940 V520', planned: false },
{ from: 'port', to: 'remediation', label: 'Read only', detail: 'Deviating Work records of the selected client and timeslot through a separate read-only PostgreSQL pool', path: 'M790 620 V690 H1290 V390' },
{ from: 'context', to: 'remediation', label: 'Published', detail: 'Published Context clusters of the same scope, composed by the Core backend; without them no packages are built, and neither engine imports the other', path: 'M800 290 V250 H1150 V340 H1180' },
{ from: 'remediation', to: 'remediation-tables', label: 'Own data', detail: 'Package snapshots per scope and version in the engine schema assessor_remediation; never Core tables', path: 'M1290 290 V160', bidirectional: true },
{ from: 'remediation', to: 'remediation-ui', label: 'Packages', detail: 'Work packages and their tasks through existing UI extension points', path: 'M1400 340 H1430 V800 H1400' },
{ from: 'port', to: 'compliance', label: 'Read only', detail: 'Single Result DB row by client, filtered Result DB pages, client, assessment and scan-run lists', path: 'M710 620 V750' },
{ from: 'compliance', to: 'compliance-tables', label: 'Own data', detail: 'Mapping runs, decisions and batches; never Core tables', path: 'M600 800 H530', bidirectional: true },
{ from: 'compliance', to: 'retrieval', label: 'Retrieval', detail: 'Privacy-safe query, retrieved control evidence and model proposals', path: 'M820 800 H890', bidirectional: true },
{ from: 'port', to: 'comparison', label: 'Read only', detail: 'Scanner runs and rolled-up GPO verdicts of both selected timeslots, read as Work reads them', path: 'M810 620 V710 H1160 V1050 H1180' },
{ from: 'comparison', to: 'comparison-ui', label: 'Changes', detail: 'Change categories and score delta through existing UI extension points', path: 'M1180 1080 H1110' },
{ from: 'compliance', to: 'compliance-ui', label: 'Proposals', detail: 'Grounded control proposals and decisions through existing UI extension points', path: 'M710 850 V900 H130 V850' }
];
const nodeName = (id: string) => nodes.find(node => node.id === id)!.name;
@ -46,7 +57,7 @@ export function EngineDataFlow() {
<header className="db-engine-flow__heading"><h3>Engines & relationships</h3><span>Solid: implemented · Dashed: planned</span></header>
<div className="db-engine-flow__scroll" role="region" aria-label="Interactive engine diagram" tabIndex={0}>
<div className="db-engine-flow__map">
<svg className="db-engine-flow__edges" width="1130" height="940" aria-hidden="true">
<svg className="db-engine-flow__edges" width="1450" height="1130" aria-hidden="true">
<defs><marker id={markerId} viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 Z" /></marker></defs>
{relationships.map(edge => <path key={`${edge.from}-${edge.to}`} d={edge.path} markerEnd={`url(#${markerId})`} markerStart={edge.bidirectional ? `url(#${markerId})` : undefined}
className={`${edge.planned ? 'is-planned' : ''} ${connections.includes(edge) ? 'is-related' : ''}`} />)}

View File

@ -44,6 +44,7 @@ import type { BackendStatus } from '../../app/app-state';
import { RegulatoryMappingDialog } from '../../compliance/ui/RegulatoryMappingPanel';
import { FindingIsoControls } from '../../compliance/ui/FindingIsoControls';
import { FindingBsiRequirements } from '../../compliance/ui/FindingBsiRequirements';
import { FindingComparison } from '../comparison/FindingComparison';
import { regulatoryMappingResultId, useRegulatoryMapping } from '../../compliance/ui/use-regulatory-mapping';
interface Props {
@ -570,6 +571,8 @@ export function FindingReader({ record, title, review: storedReview, previousPol
&& <PolicyBaselineComparison comparisons={record.policyBaselineComparisons!} search={search}/>}
{/* Context layer (Phase 13E): for every finding after the technical evidence, only while switched on; it never changes the result above. */}
<FindingContext record={record} isPolicySetting={resolvedToolId === 'group-policy'}/>
{/* Comparison Engine: this finding against timeslot A, only while a comparison of the open timeslot is loaded. */}
<FindingComparison record={record}/>
{summary.map(field => <div className={/^remediation(?: guidance| recommendation)?$/.test(normalizeLabel(field.label)) ? 'reader-remediation' : undefined} key={`${field.index}-${field.label}`}><h4>{highlightText(field.label, search)}</h4>{resolvedToolId === 'maester' && normalizeLabel(field.label) === 'id'
? <p><code className="mono">{highlightText(field.value, search)}</code></p>
: resolvedToolId === 'maester' && normalizeLabel(field.label) === 'test details'

View File

@ -1,4 +1,4 @@
import { compareScanRuns } from '../../comparison/comparison-engine';
import { compareScanRuns } from '../../evaluation/scan-run-comparison/comparison-engine';
import type {
CanonicalAssessmentModel,
FindingOccurrence,

View File

@ -7,6 +7,7 @@ import { DynamicFilterControls } from './DynamicFilterControls';
import { selectWorkFacets } from './work-facets';
import { useContextClusterIndex } from '../context-layer/use-context-cluster-index';
import { COMPLIANCE_FACET_IDS, useComplianceWorkFacets } from '../../compliance/ui/compliance-work-facets';
import { useComparisonWorkFacets } from '../comparison/comparison-work-facets';
import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet';
import { useWorkPackages } from '../../remediation/use-work-packages';
import { WORK_PACKAGE_FACET_ID } from '../../remediation/remediation-engine';
@ -98,11 +99,12 @@ export function SearchFilterBar({ hiddenFacetLabels = [] }: SearchFilterBarProps
const remediation = useWorkPackages();
const workPackages = remediation.status === 'ready' ? remediation.index : undefined;
const complianceFacets = useComplianceWorkFacets();
const comparisonFacets = useComparisonWorkFacets();
const countedFacets = useMemo(
() => measureNavigationStep('filter-counts', () => selectWorkFacets(activeSheet, {
activeProfile, search, statusFilter, facetFilters, dataset, actionOnly, reviews, pathFindingIds, contextClusters, workPackages, complianceFacets
activeProfile, search, statusFilter, facetFilters, dataset, actionOnly, reviews, pathFindingIds, contextClusters, workPackages, complianceFacets, comparisonFacets
})),
[actionOnly, activeProfile, activeSheet, complianceFacets, contextClusters, workPackages, dataset, facetFilters, pathFindingIds, reviews, search, statusFilter]
[actionOnly, activeProfile, activeSheet, complianceFacets, comparisonFacets, contextClusters, workPackages, dataset, facetFilters, pathFindingIds, reviews, search, statusFilter]
);
const visibleFacets = useMemo(
() => countedFacets.filter(facet => !hiddenFacetLabels.includes(facet.label)),

View File

@ -14,6 +14,7 @@ import { createSheetDerivationCache } from '../../shared/sheet-derivation-cache'
import { withContextClusterFacet, type ContextClusterIndex } from '../context-layer/context-cluster-facet';
import { withBusinessImpactFacet } from './business-impact-controls';
import { withComplianceFacets, type ComplianceWorkFacetIndex } from '../../compliance/ui/compliance-work-facets';
import { withComparisonFacets, type ComparisonWorkFacetIndex } from '../comparison/comparison-work-facets';
import { readEnginePreferences } from '../../app/engine-preferences';
import { withWorkPackageFacet, type WorkPackageIndex } from '../../remediation/work-package-index';
@ -33,6 +34,8 @@ export interface RecordFilterOptions {
workPackages?: WorkPackageIndex;
/** Compliance Engine ISO control and ISO theme facets; absent while the engine is off. */
complianceFacets?: ComplianceWorkFacetIndex;
/** Comparison Engine `Change` facet; absent while the engine is off or no comparison is loaded. */
comparisonFacets?: ComparisonWorkFacetIndex;
}
const cachedRecords = createSheetDerivationCache<FindingRecord[]>();
@ -42,7 +45,7 @@ export function recordFilterKeys(options: RecordFilterOptions): readonly unknown
return [options.activeProfile, options.dataset, options.reviews, options.search,
options.sortColumn, options.sortDirection, options.statusFilter, options.actionOnly,
options.pathFindingIds?.length ? options.pathFindingIds : undefined,
JSON.stringify(options.facetFilters ?? {}), options.contextClusters, options.workPackages, options.complianceFacets, readEnginePreferences()];
JSON.stringify(options.facetFilters ?? {}), options.contextClusters, options.workPackages, options.complianceFacets, options.comparisonFacets, readEnginePreferences()];
}
export function filterAndSortRecords(sheet: DatasetSheet | undefined, options: RecordFilterOptions): FindingRecord[] {
@ -58,9 +61,9 @@ function deriveFilteredRecords(sheet: DatasetSheet, options: RecordFilterOptions
const normalizedSearch = options.search.trim().toLocaleLowerCase('en-US');
const baseFacets = deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews);
const contextToolId = selectedFacetContextToolId(sheet, baseFacets, options.facetFilters, options.reviews);
const facets = withComplianceFacets(withWorkPackageFacet(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy'
const facets = withComparisonFacets(withComplianceFacets(withWorkPackageFacet(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy'
? deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews, 'group-policy')
: baseFacets, options.contextClusters), options.contextClusters), options.workPackages), options.complianceFacets);
: baseFacets, options.contextClusters), options.contextClusters), options.workPackages), options.complianceFacets), options.comparisonFacets);
const pathFindingIds = options.pathFindingIds?.length ? new Set(options.pathFindingIds) : undefined;
const filtered = sheet.analysis.records.filter(record => {
if (pathFindingIds && !pathFindingIds.has(record.id)) return false;

View File

@ -5,6 +5,7 @@ import { useContextClusterIndex } from '../context-layer/use-context-cluster-ind
import { useWorkPackages } from '../../remediation/use-work-packages';
import { useRelatedFindings } from '../related-findings/RelatedFindings';
import { useComplianceWorkFacets } from '../../compliance/ui/compliance-work-facets';
import { useComparisonWorkFacets } from '../comparison/comparison-work-facets';
type App = ReturnType<typeof useApp>;
function useRecordFilterOptions(app: App | undefined, statusFilterOverride?: string): [App['activeSheet'] | undefined, RecordFilterOptions] {
@ -25,6 +26,7 @@ function useRecordFilterOptions(app: App | undefined, statusFilterOverride?: str
const remediation = useWorkPackages();
const workPackages = remediation.status === 'ready' ? remediation.index : undefined;
const complianceFacets = useComplianceWorkFacets();
const comparisonFacets = useComparisonWorkFacets();
const effectiveStatusFilter = statusFilterOverride ?? statusFilter;
const options = useMemo(() => ({
activeProfile: activeProfile!,
@ -37,8 +39,8 @@ function useRecordFilterOptions(app: App | undefined, statusFilterOverride?: str
pathFindingIds,
actionOnly,
reviews,
contextClusters, workPackages, complianceFacets
} as RecordFilterOptions), [activeProfile, actionOnly, complianceFacets, contextClusters, workPackages, dataset, effectiveStatusFilter, facetFilters, pathFindingIds, reviews, search, sortColumn, sortDirection]);
contextClusters, workPackages, complianceFacets, comparisonFacets
} as RecordFilterOptions), [activeProfile, actionOnly, complianceFacets, comparisonFacets, contextClusters, workPackages, dataset, effectiveStatusFilter, facetFilters, pathFindingIds, reviews, search, sortColumn, sortDirection]);
return [activeSheet, options];
}

View File

@ -12,6 +12,7 @@ import { withBusinessImpactFacet } from './business-impact-controls';
import { withWorkPackageFacet, workPackageCode } from '../../remediation/work-package-index';
import { comparePublishedClusters, numberedClusterLabel } from '../sidebar/context-cluster-display';
import { withComplianceFacets } from '../../compliance/ui/compliance-work-facets';
import { withComparisonFacets } from '../comparison/comparison-work-facets';
export type WorkFacetOptions = Omit<RecordFilterOptions, 'sortColumn' | 'sortDirection'>;
const cache = createSheetDerivationCache<DynamicFacet[]>();
@ -25,9 +26,9 @@ export function selectWorkFacets(sheet: DatasetSheet | undefined, options: WorkF
return cache(sheet, recordFilterKeys(query), () => {
const baseFacets = deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews);
const contextToolId = selectedFacetContextToolId(sheet, baseFacets, options.facetFilters, options.reviews);
const facets = withComplianceFacets(withWorkPackageFacet(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy'
const facets = withComparisonFacets(withComplianceFacets(withWorkPackageFacet(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy'
? deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews, 'group-policy')
: baseFacets, options.contextClusters), options.contextClusters), options.workPackages), options.complianceFacets);
: baseFacets, options.contextClusters), options.contextClusters), options.workPackages), options.complianceFacets), options.comparisonFacets);
const records = filterAndSortRecords(sheet, { ...query, statusFilter: 'all', facetFilters: emptyFilters });
// The engine retains its complete vocabulary; the Work control offers only reachable clusters.
const selectableFacets = facets.map(facet => facet.derivedKind === 'context-cluster'

View File

@ -11,6 +11,7 @@ import { selectWorkFacets } from '../search-filter/work-facets';
import type { ContextClusterIndex } from '../context-layer/context-cluster-facet';
import { useContextClusterIndex } from '../context-layer/use-context-cluster-index';
import { useComplianceWorkFacets, type ComplianceWorkFacetIndex } from '../../compliance/ui/compliance-work-facets';
import { useComparisonWorkFacets, type ComparisonWorkFacetIndex } from '../comparison/comparison-work-facets';
import { businessImpactControls, BUSINESS_IMPACT_FACET_ID } from '../search-filter/business-impact-controls';
import { useEnginePreferences } from '../../app/engine-preferences';
import { useWorkPackages } from '../../remediation/use-work-packages';
@ -32,6 +33,7 @@ interface SecurityDomainOptions {
contextClusters?: ContextClusterIndex;
workPackages?: WorkPackageIndex;
complianceFacets?: ComplianceWorkFacetIndex;
comparisonFacets?: ComparisonWorkFacetIndex;
}
export interface SidebarSecurityDomain {
@ -47,11 +49,11 @@ export interface SidebarSecurityDomainSummary {
/** Uses the exact shared Client Info facet, ordering and self-excluding counts. */
export function summarizeSecurityDomains(options: SecurityDomainOptions): SidebarSecurityDomainSummary | undefined {
const { sheet, dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets } = options;
const { sheet, dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets, comparisonFacets } = options;
if (!sheet) return undefined;
const facets = selectWorkFacets(sheet, {
dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets
dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets, comparisonFacets
});
const domainFacet = facets.find(facet => facet.id === SECURITY_DOMAIN_FACET_ID);
if (!domainFacet) return undefined;
@ -86,15 +88,16 @@ export function SidebarSecurityDomains({ sheet }: { sheet: DatasetSheet | undefi
const remediation = useWorkPackages();
const workPackages = remediation.status === 'ready' ? remediation.index : undefined;
const complianceFacets = useComplianceWorkFacets();
const comparisonFacets = useComparisonWorkFacets();
const engines = useEnginePreferences();
const impactCounts = useMemo(() => {
if (!engines.report || !engines.context || !contextClusters) return undefined;
const facet = selectWorkFacets(sheet, { dataset, activeProfile, reviews, statusFilter: 'deviation', facetFilters,
pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets }).find(item => item.id === BUSINESS_IMPACT_FACET_ID);
pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets, comparisonFacets }).find(item => item.id === BUSINESS_IMPACT_FACET_ID);
if (!facet) return undefined;
const counts = new Map(facet.values.map(value => [value.value, value.count]));
return businessImpactControls.map(theme => ({ ...theme, count: counts.get(theme.id) ?? 0 }));
}, [engines.report, engines.context, complianceFacets, contextClusters, workPackages, sheet, dataset, activeProfile, reviews, facetFilters, pathFindingIds, search, actionOnly]);
}, [engines.report, engines.context, complianceFacets, comparisonFacets, contextClusters, workPackages, sheet, dataset, activeProfile, reviews, facetFilters, pathFindingIds, search, actionOnly]);
const summary = useMemo(() => measureNavigationStep('security-domain-summary', () => summarizeSecurityDomains({
sheet,
dataset,
@ -105,8 +108,8 @@ export function SidebarSecurityDomains({ sheet }: { sheet: DatasetSheet | undefi
pathFindingIds,
search,
actionOnly,
contextClusters, workPackages, complianceFacets
})), [actionOnly, activeProfile, complianceFacets, contextClusters, workPackages, dataset, facetFilters, pathFindingIds, reviews, search, sheet, statusFilter]);
contextClusters, workPackages, complianceFacets, comparisonFacets
})), [actionOnly, activeProfile, complianceFacets, comparisonFacets, contextClusters, workPackages, dataset, facetFilters, pathFindingIds, reviews, search, sheet, statusFilter]);
if (!summary) return null;
const facets = selectWorkFacets(sheet, { dataset, activeProfile, reviews, statusFilter, facetFilters,

View File

@ -48,8 +48,8 @@ import { planReimportReviewTransfer, type ReimportReviewTransferSummary } from '
import { canonicalJson } from '../shared/canonical-json';
import { latestReferenceRuns, type ReferenceCatalogSnapshot, type ReferenceRecognitionRun } from '../domain/reference-recognition';
import { validateReferenceRun } from './reference-run-validation';
import { normalizeComparisonText } from '../comparison/comparison-normalization';
import { validateLocalComparisonRules } from '../comparison/local-mapping-rules';
import { normalizeComparisonText } from '../evaluation/scan-run-comparison/comparison-normalization';
import { validateLocalComparisonRules } from '../evaluation/scan-run-comparison/local-mapping-rules';
import {
createEnvironmentUnifiedResult,
createCurrentPolicyEvaluationUnifiedResults,

View File

@ -39,11 +39,13 @@ export const pingCastleDefinition: ToolProfileDefinition = {
{ property: 'risk.domain', reason: 'Category or domain risk total, not a per-finding rank.' }
]
},
// 1.1.0 (user decision 2026-10-13): a PingCastle report lists every triggered rule of its version, so a rule
// absent from a complete report of the same domain, PingCastle version and processing counts as remediated.
timeslotComparison: {
version: '1.0.0', kind: 'available', granularity: 'scope',
version: '1.1.0', kind: 'available', granularity: 'scope',
scope: { kind: 'ad-domain', properties: ['domain.fqdn'] },
observationTimeProperties: ['report.generated_at'],
resultEvidence: 'findings-only', negativeEvidence: 'unsupported',
resultEvidence: 'findings-only', negativeEvidence: 'complete-explicit-negative',
diagnosticPolicy: 'fail-closed-by-impact'
},
name: 'PingCastle',

View File

@ -1952,7 +1952,7 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
.db-engine-flow__heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: var(--space-3); }
.db-engine-flow__heading h3 { margin: 0; color: var(--heading); font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
.db-engine-flow__heading > span { color: var(--muted); font-size: var(--type-table-body-size); }
.db-engine-flow__map { position: relative; width: 1130px; height: 940px; margin: 0 auto; }
.db-engine-flow__map { position: relative; width: 1450px; height: 1130px; margin: 0 auto; }
.db-engine-flow__edges { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
.db-engine-flow__edges > path { fill: none; stroke: var(--line-strong); stroke-width: 2; }
.db-engine-flow__edges > path.is-related { stroke: var(--marking-color); }
@ -1980,6 +1980,11 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
.db-engine-flow__node[data-node="compliance-tables"] { left: 310px; top: 750px; }
.db-engine-flow__node[data-node="retrieval"] { left: 890px; top: 750px; }
.db-engine-flow__node[data-node="compliance-ui"] { left: 20px; top: 750px; }
.db-engine-flow__node[data-node="remediation"] { left: 1180px; top: 290px; }
.db-engine-flow__node[data-node="remediation-tables"] { left: 1180px; top: 60px; }
.db-engine-flow__node[data-node="remediation-ui"] { left: 1180px; top: 750px; }
.db-engine-flow__node[data-node="comparison"] { left: 1180px; top: 1000px; }
.db-engine-flow__node[data-node="comparison-ui"] { left: 890px; top: 1000px; }
.db-engine-flow__edge-label[data-edge="input-core"] { left: 275px; top: 312px; }
.db-engine-flow__edge-label[data-edge="core-database"] { left: 420px; top: 455px; }
.db-engine-flow__edge-label[data-edge="database-port"] { left: 565px; top: 540px; }
@ -1993,6 +1998,12 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
.db-engine-flow__edge-label[data-edge="compliance-compliance-tables"] { left: 565px; top: 800px; }
.db-engine-flow__edge-label[data-edge="compliance-retrieval"] { left: 855px; top: 800px; }
.db-engine-flow__edge-label[data-edge="compliance-compliance-ui"] { left: 420px; top: 900px; }
.db-engine-flow__edge-label[data-edge="port-remediation"] { left: 1050px; top: 690px; }
.db-engine-flow__edge-label[data-edge="context-remediation"] { left: 1000px; top: 250px; }
.db-engine-flow__edge-label[data-edge="remediation-remediation-tables"] { left: 1290px; top: 225px; }
.db-engine-flow__edge-label[data-edge="remediation-remediation-ui"] { left: 1430px; top: 570px; }
.db-engine-flow__edge-label[data-edge="port-comparison"] { left: 1160px; top: 930px; }
.db-engine-flow__edge-label[data-edge="comparison-comparison-ui"] { left: 1145px; top: 1060px; }
.db-engine-flow__inspector { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-5); padding: var(--tile-inset); background: var(--tile-surface); border: var(--border-hair) solid var(--tile-border); }
.db-engine-flow__inspector h4 { margin: 0 0 var(--space-2); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
.db-engine-flow__inspector p { margin: 0; font-size: var(--type-body-size); line-height: 1.5; }

View File

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

View File

@ -54,27 +54,27 @@ const allSources = () => [...sourceFiles(join(root, 'src')), ...sourceFiles(join
* Pinned Core -> Engine dependencies: the documented extension points (
* `Context Cluster` Work facet, Finding Reader `Context` section, Pathfinder and sidebar cluster
* readers; Finding Reader ISO mapping, Result DB ISO batch and `Export JSON`, Data Hub ISO corpus upload,
* the Client Info `AI Overview` tab and the Work `ISO control`/`ISO theme` facets), the
* the Client Info `AI Overview` tab and the Work `ISO control`/`ISO theme` facets; the Client Info `Compare` tab, the Work `Change` facet and the Finding Reader `Compare` section), the
* type-only API contract and client, and the server wiring of the engines.
*/
const EXTENSION_POINTS: Record<string, { engine: string | readonly string[]; typeOnly?: boolean }> = {
'src/features/related-findings/RelatedFindings.tsx': { engine: 'Remediation Engine' },
'src/features/search-filter/business-impact-controls.ts': { engine: ['Report Engine', 'Context Engine'] },
'src/features/dashboard/DashboardPanel.tsx': { engine: ['Context Engine', 'Report Engine', 'Compliance Engine'] },
'src/features/dashboard/DashboardPanel.tsx': { engine: ['Context Engine', 'Report Engine', 'Compliance Engine', 'Comparison Engine'] },
'src/api/assessor-api-contract.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'], typeOnly: true },
'src/api/assessor-api-client.ts': { engine: 'Compliance Engine', typeOnly: true },
'src/features/finding-reader/FindingReader.tsx': { engine: ['Remediation Engine', 'Context Engine', 'Compliance Engine'] },
'src/features/finding-reader/FindingReader.tsx': { engine: ['Remediation Engine', 'Context Engine', 'Compliance Engine', 'Comparison Engine'] },
'src/features/results-browser/ResultsBrowser.tsx': { engine: 'Compliance Engine' },
'src/features/project-import/DataHub.tsx': { engine: 'Compliance Engine' },
'src/features/actionlist/ActionlistControls.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
'src/features/actionlist/ActionlistControls.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] },
'src/features/finding-reader/FindingContext.tsx': { engine: 'Context Engine' },
'src/features/mitre-graph/graph-filter-bridge.ts': { engine: 'Context Engine' },
'src/features/mitre-graph/ObsidianGraphView.tsx': { engine: 'Context Engine' },
'src/features/search-filter/filter-records.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
'src/features/search-filter/SearchFilterBar.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
'src/features/search-filter/use-filtered-records.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
'src/features/search-filter/work-facets.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
'src/features/sidebar/SidebarSecurityDomains.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
'src/features/search-filter/filter-records.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] },
'src/features/search-filter/SearchFilterBar.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] },
'src/features/search-filter/use-filtered-records.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] },
'src/features/search-filter/work-facets.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] },
'src/features/sidebar/SidebarSecurityDomains.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] },
'server/assessor-http-server.ts': { engine: 'any' },
'server/index.ts': { engine: ['Report Engine', 'Remediation Engine', 'Compliance Engine'] }
};
@ -87,7 +87,7 @@ const ENGINE_FREE_AREAS = ['Ingest', 'Normalization', 'Evaluation', 'Store', 'Di
describe('Core Engine and engines boundary', () => {
it('knows the registered engines and finds source files for each of them', () => {
const engines = areas.ENGINE_PATHS.map(([engine]) => engine);
expect(engines).toEqual(expect.arrayContaining(['Context Engine', 'Report Engine', 'Remediation Engine', 'Compliance Engine']));
expect(engines).toEqual(expect.arrayContaining(['Context Engine', 'Report Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine']));
for (const engine of engines) {
expect(allSources().some(file => engineOf(posix(file)) === engine), engine).toBe(true);
}

View File

@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import type {
CanonicalFindingStatus,
FindingOccurrence,
ImportFileRecord,
ScanRun
} from '../../src/domain/canonical-model';
import type { EnvironmentObservation } from '../../src/domain/environment-context';
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
import { compareProjectTimeslotEvidence } from '../../src/comparison/timeslot-comparison-engine';
// Permanent on/off invariance of the Comparison Engine (AGENTS.md, "Core Engine and engines"): the Core
// works unchanged with the engine switched off, and running a comparison of two stored timeslots leaves
// every stored ASSESSOR value, the repository revision and the compared models exactly as they were.
const timestamp = '2026-01-01T08:00:00.000Z';
const provenance = {
decisionSource: 'source-native' as const,
confidence: { level: 'high' as const, score: 1, reasons: ['synthetic'] },
mappingVersion: '1'
};
const tool = { id: 'cis-cat', name: 'CIS-CAT Pro' };
function run(id: string, assessmentId: string, importedAt: string): ScanRun {
return { id, assessmentId, sequence: 1, label: id, tool, importedAt };
}
function importFile(runId: string, importedAt: string): ImportFileRecord {
return {
id: `file-${runId}`, scanRunId: runId, fileName: `${runId}.html`, format: 'HTML', sizeBytes: 10, importedAt,
detectedTool: tool, detection: { level: 'high', score: 1, reasons: ['synthetic'] },
processedWith: {
parserId: 'html', parserVersion: '1', profileId: 'cis-cat', profileVersion: '1.4.0', mappingVersion: '1.3.0',
normalizationVersion: '1.3.0', presentationVersion: '1.3.0', compatibilityKey: 'cis-cat-canonical-v1'
},
sourceArtifact: { kind: 'not-retained', reason: 'synthetic' }
};
}
function observation(runId: string, property: string, value: string): EnvironmentObservation {
return {
version: 1, id: `${runId}-${property}`, scanRunId: runId, importFileId: `file-${runId}`, tool,
subject: { type: 'host' }, property, label: property, originalValue: value, normalizedValue: value,
valueType: property === 'report.generated_at' ? 'datetime' : 'string', state: 'observed',
source: {}, mappingVersion: '1', attributes: {}
};
}
function occurrence(runId: string, ruleId: string, status: CanonicalFindingStatus): FindingOccurrence {
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: [],
severity: { originalValue: 'high', normalizedValue: 'high', provenance },
status: { originalValue: status, normalizedValue: status, provenance },
outcome: { originalValue: status === 'passed' ? 'pass' : 'fail', normalizedValue: status === 'passed' ? 'pass' : 'fail', provenance }
};
}
async function commitTimeslot(repository: LocalAssessorRepository, name: string, observedAt: string, findings: Array<[string, CanonicalFindingStatus]>) {
const assessmentId = `assessment-${name}`;
await repository.saveAssessment({ id: assessmentId, projectId: 'dummy-project', name, createdAt: observedAt, updatedAt: observedAt });
const scanRun = run(`run-${name}`, assessmentId, observedAt);
const session = await repository.beginImport({ sessionId: `session-${name}`, projectId: 'dummy-project', assessmentId, scanRun });
await session.stage({
importFiles: [importFile(scanRun.id, observedAt)],
environmentObservations: [
observation(scanRun.id, 'host.name', 'server-a'),
observation(scanRun.id, 'benchmark.name', 'benchmark-a'),
observation(scanRun.id, 'report.generated_at', observedAt)
],
findingDefinitions: findings.map(([ruleId]) => ({
id: `definition-${ruleId}`, canonicalKey: `cis-cat:${ruleId}`, title: ruleId, frameworkReferences: [],
createdAt: timestamp, updatedAt: timestamp
})),
findingOccurrences: findings.map(([ruleId, status]) => occurrence(scanRun.id, ruleId, status))
});
await session.commit();
return { assessmentId, runId: scanRun.id };
}
describe('Comparison Engine on/off invariance', () => {
it('leaves every stored ASSESSOR value, the repository revision and the compared models unchanged', async () => {
const repository = new LocalAssessorRepository();
await repository.saveProject({ id: 'dummy-project', name: 'Dummy Customer', createdAt: timestamp, updatedAt: timestamp, attributes: {} });
const a = await commitTimeslot(repository, 'a', '2026-01-01T10:00:00.000Z', [['1.1.1', 'open'], ['1.1.2', 'open']]);
const b = await commitTimeslot(repository, 'b', '2026-02-01T10:00:00.000Z', [['1.1.1', 'passed'], ['1.1.2', 'open']]);
const off = { revision: repository.revision, data: JSON.stringify(await repository.exportProject('dummy-project')) };
const models = [(await repository.loadAssessmentModel(a.assessmentId))!, (await repository.loadAssessmentModel(b.assessmentId))!];
const modelsBefore = JSON.stringify(models);
const comparison = compareProjectTimeslotEvidence(models, [a.runId], [b.runId]);
// The engine really ran: the stored fail-to-pass of the same identity is a verified remediation.
expect(comparison.summary.verifiedRemediated).toBe(1);
expect(comparison.summary.confirmedOpen).toBe(1);
expect(JSON.stringify(models)).toBe(modelsBefore);
expect(repository.revision).toBe(off.revision);
expect(JSON.stringify(await repository.exportProject('dummy-project'))).toBe(off.data);
});
});

View File

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

View File

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

View File

@ -0,0 +1,229 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
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';
// Client Info `Compare` tab content (mockup A): A/B selection with B as the open timeslot, score with a
// labelled delta, change facts that open Work and a source table. Synthetic data only.
const assessment = (id: string, effectiveAt: string): Assessment => ({ id, projectId: 'dummy-project', name: id, createdAt: effectiveAt, updatedAt: effectiveAt, effectiveAt });
const run = (id: string, assessmentId: string, at: string): ScanRun => ({ id, assessmentId, sequence: 1, label: id, tool: { id: 'cis-cat', name: 'CIS-CAT Pro' }, importedAt: at });
const file = (runId: string, at: string) => ({ id: `file-${runId}`, scanRunId: runId, fileName: 'synthetic.html', format: 'HTML', sizeBytes: 1,
importedAt: at, lastModifiedAt: at, detectedTool: { id: 'cis-cat', name: 'CIS-CAT Pro' } }) as unknown as ImportFileRecord;
const dates = { a: '2026-01-10T10:00:00.000Z', b: '2026-02-10T10:00:00.000Z', c: '2026-03-10T10:00:00.000Z' };
const report: TimeslotComparisonReport = {
version: '1.0.0', projectId: 'dummy-project', baselineRunIds: ['run-b'], targetRunIds: ['run-c'],
sources: [{ sourceId: 'cis-cat', state: 'compared', reasons: [] }, { sourceId: 'maester', state: 'not-comparable', reasons: ['baseline-source-missing'] }],
changes: [
{ sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['b-1'], counterpartIds: ['c-1'], reasons: [] },
{ sourceId: 'cis-cat', change: 'regressed', side: 'target', recordIds: ['c-3'], counterpartIds: [], reasons: [] },
{ sourceId: 'maester', change: 'not-comparable', side: 'target', recordIds: ['m-1'], counterpartIds: [], reasons: ['baseline-source-missing'] }
],
summary: { resolved: 1, unchanged: 0, 'value-changed': 0, unverified: 0, 'not-comparable': 0, 'not-applicable': 0, new: 0, regressed: 1, targetNotComparable: 1 },
score: {
baselineScore: 52.4, targetScore: 55.2, change: 2.8,
sources: [
{ sourceId: 'cis-cat', weight: 1, method: 'paired-scans', reasons: [], baselineScore: 52.4, targetScore: 55.2, change: 2.8 },
{ sourceId: 'maester', weight: 1, method: 'paired-scans', reasons: ['baseline-source-missing'] }
],
excludedSources: [{ sourceId: 'maester', reasons: ['baseline-source-missing'] }]
},
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(() => {
resetTimeslotComparisonRequestsForTests();
vi.spyOn(AssessorApiClient.prototype, 'listProjectImportFiles').mockResolvedValue([file('run-a', dates.a), file('run-b', dates.b), file('run-c', dates.c)]);
vi.spyOn(AssessorApiClient.prototype, 'listProjectAssessmentHistory').mockResolvedValue({
assessments: [assessment('assessment-a', dates.a), assessment('assessment-b', dates.b), assessment('assessment-c', dates.c)],
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);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
vi.restoreAllMocks();
});
const settle = () => act(async () => { for (let index = 0; index < 5; index++) await Promise.resolve(); });
describe('Comparison Engine Compare tab', () => {
it('compares the open timeslot with the one before it and opens Work from a change', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report);
const onOpenChange = vi.fn();
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']} onOpenChange={onOpenChange}/>));
await settle();
expect(request).toHaveBeenCalledWith('dummy-project',
expect.objectContaining({ assessmentIds: ['assessment-b'] }), expect.objectContaining({ assessmentIds: ['assessment-c'] }));
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');
const facts = [...host.querySelectorAll<HTMLButtonElement>('.comparison-fact')];
const fact = (label: string) => facts.find(item => item.querySelector('.comparison-fact__label')?.textContent === label)!;
expect(fact('Regressed').querySelector('.comparison-fact__value')?.className).toContain('is-worse');
expect(fact('Not comparable').textContent).toContain('1');
expect(fact('New').disabled).toBe(true);
await act(async () => fact('Resolved').click());
expect(onOpenChange).toHaveBeenCalledWith('resolved', expect.objectContaining({ id: 'timeslot-2026-02-10' }), expect.objectContaining({ id: 'timeslot-2026-03-10' }));
const maester = [...host.querySelectorAll('.comparison-table tbody tr')].find(row => row.textContent?.includes('Maester'));
expect(maester?.textContent).toContain('Not in timeslot A');
});
it('lists the changed findings under the source table and filters them by change', async () => {
const titled: TimeslotComparisonReport = { ...report, changes: [
{ sourceId: 'cis-cat', change: 'unchanged', side: 'baseline', recordIds: ['b-2'], counterpartIds: ['c-2'], title: 'Synthetic unchanged rule', ruleId: '1.2', reasons: [] },
{ sourceId: 'cis-cat', change: 'regressed', side: 'target', recordIds: ['c-4'], counterpartIds: [], title: 'Synthetic low rule', ruleId: '5.2', severity: 'low', reasons: [] },
{ sourceId: 'cis-cat', change: 'regressed', side: 'target', recordIds: ['c-3'], counterpartIds: [], title: 'Synthetic regressed rule', ruleId: '5.1', severity: 'critical', reasons: [] },
{ sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['b-1'], counterpartIds: ['c-1'], title: 'Synthetic resolved rule', ruleId: '1.1', reasons: [] },
{ sourceId: 'group-policy', change: 'value-changed', side: 'baseline', recordIds: ['g-1'], counterpartIds: ['g-2'], title: 'Synthetic setting', reasons: [],
values: { baseline: ['7'], target: ['10'] } }
] };
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(titled);
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']}/>));
await settle();
const rows = () => [...host.querySelectorAll('.comparison-item')].map(row => ({
severity: row.className.replace('comparison-item ', '').replace(' is-selected', ''), title: row.querySelector('.comparison-item__title')?.textContent,
badges: [...row.querySelectorAll('.comparison-item__meta > span')].map(cell => cell.textContent),
badge: row.querySelector(':scope > .reader-severity')?.textContent
}));
// `All changes` leaves out the unchanged ones; better first, then worse by severity, then neutral.
expect(rows()).toEqual([
{ severity: 'severity-unknown', title: 'Synthetic resolved rule', badges: ['▼ Resolved', 'CIS-CAT Pro', '1.1 · Deviation in A · passed in B'], badge: 'Unknown' },
{ severity: 'severity-critical', title: 'Synthetic regressed rule', badges: ['▲ Regressed', 'CIS-CAT Pro', '5.1 · Passed in A'], badge: 'Critical' },
{ severity: 'severity-low', title: 'Synthetic low rule', badges: ['▲ Regressed', 'CIS-CAT Pro', '5.2 · Passed in A'], badge: 'Low' },
{ severity: 'severity-unknown', title: 'Synthetic setting', badges: ['≠ Value changed', 'GPOs', 'Deviation in A and B · 7 → 10'], badge: 'Unknown' }
]);
expect(host.querySelector('.comparison-badge.is-worse')?.textContent).toBe('▲ Regressed');
// Rows are clickable like the Work list: the first is selected, a click or arrow key selects another and
// the detail beside the list shows that change with A and B side by side.
const items = () => [...host.querySelectorAll<HTMLButtonElement>('.comparison-item')];
const detailTitle = () => host.querySelector('.comparison-detail__title')?.textContent;
expect(items()[0].getAttribute('aria-pressed')).toBe('true');
expect(detailTitle()).toBe('Synthetic resolved rule');
await act(async () => items()[3].click());
expect(items()[3].className).toContain('is-selected');
expect(detailTitle()).toBe('Synthetic setting');
const valueRow = [...host.querySelectorAll('.comparison-detail tbody tr')].find(row => row.textContent?.startsWith('Configured value'));
expect([...valueRow!.querySelectorAll('td')].slice(1).map(cell => cell.textContent)).toEqual(['7', '10']);
await act(async () => host.querySelector('.comparison-list')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })));
expect(detailTitle()).toBe('Synthetic low rule');
expect(document.activeElement).toBe(items()[2]);
const filter = host.querySelector<HTMLSelectElement>('.comparison-tile__filter select')!;
await act(async () => { filter.value = 'unchanged'; filter.dispatchEvent(new Event('change', { bubbles: true })); });
expect(rows().map(row => row.title)).toEqual(['Synthetic unchanged rule']);
await act(async () => { filter.value = 'new'; filter.dispatchEvent(new Event('change', { bubbles: true })); });
expect(host.textContent).toContain('No finding has this change.');
});
it('shows Security Domains A → B with a labelled change, resolved against worse bars and a domain only in B', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report);
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']}/>));
await settle();
const rows = [...host.querySelectorAll('.comparison-domain:not(.is-header)')];
const cells = (row: Element) => [...row.children].map(cell => cell.textContent);
expect(cells(rows[0]).slice(0, 5)).toEqual(['Identity & Authentication', '48', '→', '55', '▲ +7']);
expect(rows[0].querySelector<HTMLElement>('.comparison-domain__bar.is-better')?.style.width).toBe('100%');
expect(rows[0].querySelector<HTMLElement>('.comparison-domain__bar.is-worse')?.style.width).toBe('25%');
expect(cells(rows[1])).toEqual(['Cloud & Collaboration', '—', '→', '46', '○ Only in B', 'Maester only in B · not comparable']);
expect(cells(rows[2])[4]).toBe('▼ −3');
expect(rows[2].querySelector('.comparison-delta')?.className).toContain('is-worse');
});
it('shows a scanner value change with both Problem values side by side', async () => {
const changed: TimeslotComparisonReport = { ...report, changes: [{ sourceId: 'purple-knight', change: 'value-changed', side: 'baseline',
recordIds: ['b-5'], counterpartIds: ['c-5'], title: 'Synthetic templates', ruleId: 'SI-1', reasons: [],
problem: { baseline: [{ label: 'Result', value: 'Found 1 synthetic template' }, { label: 'Affected', value: '1' }],
target: [{ label: 'Result', value: 'Found 1 synthetic template' }, { label: 'Affected', value: '3' }] } }] };
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(changed);
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']}/>));
await settle();
expect(host.querySelector('.comparison-item__note')?.textContent).toBe('SI-1 · Deviation in A and B · Affected 1 → 3');
const rows = [...host.querySelectorAll('.comparison-detail .finding-comparison tbody tr')].map(row => [...row.children].map(cell => cell.textContent));
// Only what differs between A and B; the identical Result line stays in the Problem block.
expect(rows).toEqual([['Verdict', '✕ Deviation', '✕ Deviation'], ['Affected', '1', '3']]);
// Like GPO configured values: red where the timeslot deviates (here both), green where it passes.
expect([...host.querySelectorAll('.comparison-detail .finding-comparison .policy-value-mark')].map(item => [item.textContent, item.className])).toEqual([
['1', 'policy-value-mark is-deviating'], ['3', 'policy-value-mark is-deviating']]);
});
it('marks a resolved finding red in A and green in B and keeps the expected value neutral', async () => {
const resolved: TimeslotComparisonReport = { ...report, changes: [{ sourceId: 'cis-cat', change: 'resolved', side: 'baseline',
recordIds: ['b-6'], counterpartIds: ['c-6'], title: 'Synthetic rule', ruleId: '1.1.1', reasons: [],
problem: { baseline: [{ label: 'Expected', value: '>= 14' }, { label: 'Found', value: '7' }],
target: [{ label: 'Expected', value: '>= 14' }, { label: 'Found', value: '14' }] } }] };
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(resolved);
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']}/>));
await settle();
const row = (label: string) => [...host.querySelectorAll('.comparison-detail tbody tr')].find(item => item.firstElementChild?.textContent === label)!;
expect([...row('Found').querySelectorAll('.policy-value-mark')].map(item => item.className)).toEqual(['policy-value-mark is-deviating', 'policy-value-mark is-aligned']);
expect(row('Expected')).toBeUndefined();
});
it('shows a CIS-CAT evidence row that changed only in its value by that value, and hides identical rows', async () => {
const found = (value: string) => [{ label: 'Expected', value: 'Criterion: synthetic' },
{ label: 'Found', value: 'Type · String · Exists · reg_dword' }, { label: 'Found', value: `Value · Int · Exists · ${value}` }];
const cis: TimeslotComparisonReport = { ...report, changes: [{ sourceId: 'cis-cat', change: 'value-changed', side: 'baseline',
recordIds: ['b-7'], counterpartIds: ['c-7'], title: 'Synthetic registry rule', ruleId: '2.3.17.2', reasons: [],
problem: { baseline: found('5'), target: found('0') } }] };
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(cis);
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']}/>));
await settle();
expect(host.querySelector('.comparison-item__note')?.textContent).toBe('2.3.17.2 · Deviation in A and B · Found 5 → 0');
const rows = [...host.querySelectorAll('.comparison-detail .finding-comparison tbody tr')].map(row => [...row.children].map(cell => cell.textContent));
expect(rows).toEqual([['Verdict', '✕ Deviation', '✕ Deviation'], ['Found · Value', '5', '0']]);
});
it('explains that the oldest timeslot has nothing before it', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison');
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-a']}/>));
await settle();
expect(host.querySelectorAll('select')[1].value).toBe('timeslot-2026-01-10');
expect(host.textContent).toContain('no earlier timeslot');
expect(request).not.toHaveBeenCalled();
});
it('offers only earlier timeslots as A and requests the new pair on change', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report);
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']}/>));
await settle();
const [baseline] = [...host.querySelectorAll('select')];
expect([...baseline.options].map(option => option.value)).toEqual(['timeslot-2026-01-10', 'timeslot-2026-02-10']);
await act(async () => { baseline.value = 'timeslot-2026-01-10'; baseline.dispatchEvent(new Event('change', { bubbles: true })); });
await settle();
expect(request).toHaveBeenLastCalledWith('dummy-project',
expect.objectContaining({ assessmentIds: ['assessment-a'] }), expect.objectContaining({ assessmentIds: ['assessment-c'] }));
});
it('explains a client with a single timeslot instead of comparing', async () => {
vi.spyOn(AssessorApiClient.prototype, 'listProjectImportFiles').mockResolvedValue([file('run-a', dates.a)]);
vi.spyOn(AssessorApiClient.prototype, 'listProjectAssessmentHistory').mockResolvedValue({
assessments: [assessment('assessment-a', dates.a)], scanRuns: [run('run-a', 'assessment-a', dates.a)]
} as never);
const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison');
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-a']}/>));
await settle();
expect(host.textContent).toContain('only one timeslot');
expect(request).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import type { FindingRecord } from '../../src/domain/finding';
import type { DynamicFacet } from '../../src/features/search-filter/dynamic-facets';
import type { TimeslotComparisonReport } from '../../src/comparison/timeslot-comparison-report';
import {
changesWithWorkRecords,
comparisonWorkFacetIndex,
COMPARISON_CHANGE_FACET_ID,
withComparisonFacets
} from '../../src/features/comparison/comparison-work-facets';
import type { PublishedComparison } from '../../src/features/comparison/use-timeslot-comparison';
// 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, 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: [
{ sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['a-1'], counterpartIds: ['b-1'], reasons: [] },
{ sourceId: 'cis-cat', change: 'unverified', side: 'baseline', recordIds: ['a-2'], counterpartIds: [], reasons: [] },
{ sourceId: 'cis-cat', change: 'regressed', side: 'target', recordIds: ['b-3'], counterpartIds: [], reasons: [] },
{ sourceId: 'group-policy', change: 'value-changed', side: 'baseline', recordIds: ['ga-1'], counterpartIds: ['gb-1'],
identityKey: JSON.stringify(['computer', 'MinimumPasswordLength']), reasons: [] },
{ sourceId: 'group-policy', change: 'new', side: 'target', recordIds: ['gb-2'], counterpartIds: [],
identityKey: JSON.stringify(['computer', 'SeEnableDelegationPrivilege']), reasons: [] }
]
} as unknown as TimeslotComparisonReport;
const timeslot = (id: string, date: string) => ({ id, date, assessmentIds: [id], scanRunIds: [] });
const published: PublishedComparison = {
projectId: 'dummy-project', targetAssessmentIds: ['b'], baseline: timeslot('a', '2026-01-10'), target: timeslot('b', '2026-02-10'), report
};
describe('Comparison Engine Work Change facet', () => {
it('assigns each timeslot B record its change and leaves unrelated records without one', () => {
const index = comparisonWorkFacetIndex(published);
expect(index.changeOf(record('work-b-1', 'b-1'))).toBe('resolved');
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' });
});
it('offers only changes with a Work record in timeslot B', () => {
expect([...changesWithWorkRecords(report)].sort()).toEqual(['new', 'regressed', 'resolved', 'value-changed']);
});
it('places the facet directly before the permanent final Source: Scan control', () => {
const facet = (id: string) => ({ id, label: id, source: 'derived', kind: 'category', values: [] }) as unknown as DynamicFacet;
const facets = withComparisonFacets([facet('status'), facet('provenance:source-scan')], comparisonWorkFacetIndex(published));
expect(facets.map(item => item.id)).toEqual(['status', COMPARISON_CHANGE_FACET_ID, 'provenance:source-scan']);
expect(withComparisonFacets([facet('status')], undefined).map(item => item.id)).toEqual(['status']);
});
});

View File

@ -0,0 +1,89 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import type { Assessment, ImportFileRecord, ScanRun } from '../../src/domain/canonical-model';
import type { FindingRecord } from '../../src/domain/finding';
import type { TimeslotComparisonReport } from '../../src/comparison/timeslot-comparison-report';
import { ComparisonTab } from '../../src/features/comparison/ComparisonTab';
import { FindingComparison } from '../../src/features/comparison/FindingComparison';
import { resetTimeslotComparisonRequestsForTests } from '../../src/features/comparison/use-timeslot-comparison';
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
// Finding Reader `Compare` section: present once the Compare tab loaded a comparison of the open timeslot B,
// with both states and the GPO values side by side; absent for an unrelated record or with the engine off.
vi.mock('../../src/app/AppProvider', () => ({
useOptionalApp: () => ({ targetProjectId: 'dummy-project', activeAssessmentIds: ['assessment-b'] })
}));
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: 'dummy-project', 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 gpoRecordId = (identity: string) => `gpo-work-setting:${encodeURIComponent(JSON.stringify(['dummy-project', 'aggregation-b', 'computer', identity]))}`;
const record = (id: string, findingOccurrenceId?: string) => ({ id, fields: [], ...(findingOccurrenceId ? { findingOccurrenceId } : {}) }) as unknown as FindingRecord;
const report = {
version: '1.0.0', projectId: 'dummy-project', baselineRunIds: ['run-a'], targetRunIds: ['run-b'], sources: [],
changes: [
{ sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['a-1'], counterpartIds: ['b-1'], reasons: [] },
{ sourceId: 'group-policy', change: 'value-changed', side: 'baseline', recordIds: ['ga-1'], counterpartIds: ['gb-1'],
identityKey: JSON.stringify(['computer', 'MinimumPasswordLength']), reasons: ['same-setting-current-deviation'],
values: { baseline: ['{"kind":"number","value":7}'], target: ['{"kind":"number","value":10}'] } }
],
summary: { resolved: 1, unchanged: 0, 'value-changed': 1, unverified: 0, 'not-comparable': 0, 'not-applicable': 0, new: 0, regressed: 0, targetNotComparable: 0 },
score: { sources: [], excludedSources: [] }, scanner: {}
} as unknown as TimeslotComparisonReport;
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
localStorage.clear(); resetEnginePreferencesForTests(); resetTimeslotComparisonRequestsForTests();
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, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never);
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report);
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount()); host.remove();
vi.restoreAllMocks(); resetEnginePreferencesForTests();
});
const settle = () => act(async () => { for (let index = 0; index < 5; index++) await Promise.resolve(); });
it('shows the comparison of a finding and of a GPO setting after the Compare tab loaded it', async () => {
await act(async () => root.render(<>
<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-b']}/>
<div data-testid="scanner"><FindingComparison record={record('work-b-1', 'b-1')}/></div>
<div data-testid="gpo"><FindingComparison record={record(gpoRecordId('MinimumPasswordLength'))}/></div>
<div data-testid="other"><FindingComparison record={record('work-b-9', 'b-9')}/></div>
</>));
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('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('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');
expect(section('other')).toBeNull();
});
it('renders nothing while the Comparison Engine is off', async () => {
setEngineEnabled('comparison', false);
await act(async () => root.render(<>
<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-b']}/>
<div data-testid="scanner"><FindingComparison record={record('work-b-1', 'b-1')}/></div>
</>));
await settle();
expect(host.querySelector('[data-testid="scanner"] .finding-comparison')).toBeNull();
});

View File

@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import type { PolicyWorkResult, PolicyWorkStatus } from '../../src/evaluation/policy-work-results';
import { compareGpoTimeslots } from '../../src/comparison/gpo-timeslot-comparison';
// Synthetic rolled-up GPO Work results. Only the fields the comparison reads are populated.
const observedA = '2026-01-01T10:00:00.000Z';
const observedB = '2026-02-01T10:00:00.000Z';
function setting(id: string, identity: string, status: PolicyWorkStatus, options: {
actual?: unknown; baselineId?: string; aggregationRun?: string; scope?: string;
} = {}): PolicyWorkResult {
const scope = options.scope ?? 'computer';
return {
id, status, title: identity, scope, identity, importedAt: observedA, sources: [],
settingKey: JSON.stringify(['dummy-project', options.aggregationRun ?? 'aggregation-1', scope, identity]),
comparison: {
baselineId: options.baselineId ?? 'baseline-server',
comparator: { actual: options.actual === undefined ? undefined : { kind: 'number', value: options.actual } }
} as unknown as PolicyWorkResult['comparison']
};
}
describe('GPO timeslot comparison', () => {
it('pairs rolled-up settings by scope and identity and keeps a value change neutral', () => {
const result = compareGpoTimeslots(
{ observedAt: observedA, results: [
setting('a-1', 'MinimumPasswordLength', 'Fail', { actual: 7 }),
setting('a-2', 'LockoutBadCount', 'Fail', { actual: 0 }),
setting('a-3', 'NoLMHash', 'Fail', { actual: 0 }),
setting('a-4', 'PasswordComplexity', 'Fail', { actual: 0 })
] },
{ observedAt: observedB, results: [
setting('b-1', 'MinimumPasswordLength', 'Fail', { actual: 10 }),
setting('b-2', 'LockoutBadCount', 'Fail', { actual: 0 }),
setting('b-3', 'NoLMHash', 'Pass', { actual: 1 }),
setting('b-4', 'PasswordComplexity', 'Not configured')
] }
);
const byIdentity = Object.fromEntries(result.decisions.map(item => [item.identity, item]));
expect(byIdentity.MinimumPasswordLength).toMatchObject({ state: 'confirmed-open', valueChanged: true, targetResultIds: ['b-1'] });
// Both retained values travel as Work displays them, for the Finding Reader's side-by-side reading.
expect(byIdentity.MinimumPasswordLength).toMatchObject({
baselineValues: ['{"kind":"number","value":7}'], targetValues: ['{"kind":"number","value":10}'] });
expect(byIdentity.PasswordComplexity.targetValues).toEqual([]);
expect(byIdentity.LockoutBadCount).toMatchObject({ state: 'confirmed-open', valueChanged: false });
expect(byIdentity.NoLMHash).toMatchObject({ state: 'verified-remediated', reasons: ['same-setting-explicit-pass'] });
// A setting missing from the newer backup set is not proof of a removed configuration.
expect(byIdentity.PasswordComplexity).toMatchObject({ state: 'unverified-absent', reasons: ['target-verdict-not-decisive:Not configured'] });
});
it('rolls several baseline releases of one setting up with the Work verdict order', () => {
const result = compareGpoTimeslots(
{ observedAt: observedA, results: [
setting('a-1', 'SeBackupPrivilege', 'Pass', { baselineId: 'baseline-dc' }),
setting('a-2', 'SeBackupPrivilege', 'Fail', { baselineId: 'baseline-server' })
] },
{ observedAt: observedB, results: [
setting('b-1', 'SeBackupPrivilege', 'Pass', { baselineId: 'baseline-dc' }),
setting('b-2', 'SeBackupPrivilege', 'Pass', { baselineId: 'baseline-server' })
] }
);
expect(result.decisions).toHaveLength(1);
expect(result.decisions[0]).toMatchObject({ state: 'verified-remediated', baselineResultIds: ['a-1', 'a-2'] });
});
it('separates new and regressed settings and claims nothing from undecided history', () => {
const result = compareGpoTimeslots(
{ observedAt: observedA, results: [
setting('a-1', 'SeEnableDelegationPrivilege', 'Not configured'),
setting('a-2', 'SeRestorePrivilege', 'Pass'),
setting('a-3', 'SeAuditPrivilege', 'Review required'),
setting('a-4', 'SeLoadDriverPrivilege', 'Pass', { baselineId: 'baseline-old' })
] },
{ observedAt: observedB, results: [
setting('b-1', 'SeEnableDelegationPrivilege', 'Fail'),
setting('b-2', 'SeRestorePrivilege', 'Fail'),
setting('b-3', 'SeAuditPrivilege', 'Fail'),
setting('b-4', 'SeLoadDriverPrivilege', 'Fail'),
setting('b-5', 'DnsPolicyConfig', 'Fail')
] }
);
expect(result.newDeviations.map(item => item.targetResultIds[0]).sort()).toEqual(['b-1', 'b-5']);
expect(result.regressedDeviations.map(item => item.targetResultIds[0])).toEqual(['b-2']);
});
it('requires a newer backup set, the same baselines and a GPO source in both timeslots', () => {
const fail = { observedAt: observedA, results: [setting('a-1', 'NoLMHash', 'Fail')] };
const sameTime = compareGpoTimeslots(fail, { observedAt: observedA, results: [setting('b-1', 'NoLMHash', 'Pass')] });
expect(sameTime.decisions[0]).toMatchObject({ state: 'not-comparable', reasons: ['target-observation-not-newer'] });
expect(sameTime.sharedPassRate).toBeUndefined();
// Without stated backup times (real backups state no time zone) the timeslot order decides.
const untimed = compareGpoTimeslots({ results: fail.results }, { results: [setting('b-1', 'NoLMHash', 'Pass')] });
expect(untimed.decisions[0]).toMatchObject({ state: 'verified-remediated' });
const changedBaseline = compareGpoTimeslots(fail, { observedAt: observedB, results: [setting('b-1', 'NoLMHash', 'Pass', { baselineId: 'baseline-new' })] });
expect(changedBaseline.decisions[0]).toMatchObject({ state: 'not-comparable', reasons: ['baseline-set-changed'] });
const missing = compareGpoTimeslots(fail, { observedAt: observedB, results: [] });
expect(missing).toMatchObject({ state: 'not-comparable', reasons: ['target-source-missing'] });
expect(missing.decisions[0].state).toBe('not-comparable');
});
it('marks a setting held by several aggregation runs of one timeslot as ambiguous', () => {
const result = compareGpoTimeslots(
{ observedAt: observedA, results: [
setting('a-1', 'NoLMHash', 'Fail', { aggregationRun: 'aggregation-1' }),
setting('a-2', 'NoLMHash', 'Fail', { aggregationRun: 'aggregation-2' })
] },
{ observedAt: observedB, results: [setting('b-1', 'NoLMHash', 'Pass')] }
);
expect(result.decisions[0]).toMatchObject({ state: 'not-comparable', reasons: ['gpo-setting-ambiguous'] });
});
it('computes the shared pass rate only over settings decided in both timeslots', () => {
const result = compareGpoTimeslots(
{ observedAt: observedA, results: [
setting('a-1', 'One', 'Fail'), setting('a-2', 'Two', 'Fail'), setting('a-3', 'Three', 'Pass'),
setting('a-4', 'OnlyInA', 'Fail')
] },
{ observedAt: observedB, results: [
setting('b-1', 'One', 'Pass'), setting('b-2', 'Two', 'Fail'), setting('b-3', 'Three', 'Pass'),
setting('b-5', 'OnlyInB', 'Pass')
] }
);
expect(result.sharedPassRate?.settings).toBe(3);
expect(result.sharedPassRate?.baseline).toBeCloseTo(100 / 3);
expect(result.sharedPassRate?.target).toBeCloseTo(200 / 3);
});
});

View File

@ -89,12 +89,128 @@ describe('conservative project timeslot comparison', () => {
const result = compareProjectTimeslotEvidence([baseline, target], ['run-a'], ['run-b']);
expect(result.summary).toEqual({
baselineDeviations: 2, verifiedRemediated: 1, confirmedOpen: 0,
unverifiedAbsent: 1, notComparable: 0, notApplicable: 0, newDeviations: 1
unverifiedAbsent: 1, notComparable: 0, notApplicable: 0, newDeviations: 1, regressedDeviations: 0
});
expect(result.sources[0].decisions.map(item => item.state).sort())
.toEqual(['unverified-absent', 'verified-remediated']);
});
it('separates a regression after a single verified pass from a new deviation and ambiguous history', () => {
const baseline = model('run-a', observedA, [
occurrence('a-1', 'run-a', 'RULE-1', 'passed'),
occurrence('a-2', 'run-a', 'RULE-2', 'passed'),
occurrence('a-2b', 'run-a', 'RULE-2', 'passed')
]);
const target = model('run-b', observedB, [
occurrence('b-1', 'run-b', 'RULE-1', 'failed'),
occurrence('b-2', 'run-b', 'RULE-2', 'failed'),
occurrence('b-3', 'run-b', 'RULE-3', 'failed')
]);
const result = compareProjectTimeslotEvidence([baseline, target], ['run-a'], ['run-b']);
expect(result.version).toBe('1.2.0');
expect(result.sources[0].regressedDeviationOccurrenceIds).toEqual(['b-1']);
expect(result.sources[0].newDeviationOccurrenceIds).toEqual(['b-3']);
expect(result.summary).toMatchObject({ newDeviations: 1, regressedDeviations: 1, baselineDeviations: 0 });
});
it('splits a run imported from several scanners by the scanner of each file', () => {
const baseline = model('run-a', observedA, [occurrence('a-1', 'run-a', 'RULE-1', 'failed')]);
const target = model('run-b', observedB, [occurrence('b-1', 'run-b', 'RULE-1', 'passed')]);
// One batch with CIS-CAT and Maester: the run is labelled 'multiple', each file keeps its scanner.
target.scanRuns[0].tool = { id: 'multiple', name: 'Multiple Tools' };
const maesterFile = { ...importFile('run-b'), id: 'file-run-b-maester', fileName: 'maester.csv',
detectedTool: { id: 'maester', name: 'Maester' }, processedWith: { ...importFile('run-b').processedWith, profileId: 'maester' } };
target.importFiles.push(maesterFile);
target.findingOccurrences.push({ ...occurrence('m-1', 'run-b', 'MT.1001', 'failed'), importFileId: maesterFile.id,
sourceIdentity: { tool: { id: 'maester', name: 'Maester' }, canonicalRuleId: 'MT.1001' } });
const result = compareProjectTimeslotEvidence([baseline, target], ['run-a'], ['run-b']);
expect(result.sources.map(source => [source.toolId, source.state])).toEqual([['cis-cat', 'compared'], ['maester', 'not-comparable']]);
expect(result.summary).toMatchObject({ verifiedRemediated: 1, newDeviations: 0 });
expect(result.sources[1]).toMatchObject({ reasons: ['baseline-source-missing'], targetFileIds: ['file-run-b-maester'] });
});
it('pairs several sources of one scanner by their verified scope', () => {
const withHost = (built: CanonicalAssessmentModel, host: string) => {
built.environmentObservations = built.environmentObservations?.map(item => item.property === 'host.name' ? { ...item, normalizedValue: host, originalValue: host } : item);
return built;
};
const merge = (left: CanonicalAssessmentModel, right: CanonicalAssessmentModel): CanonicalAssessmentModel => ({
...left, scanRuns: [...left.scanRuns, { ...right.scanRuns[0], assessmentId: left.assessment.id, sequence: 2 }],
importFiles: [...left.importFiles, ...right.importFiles], environmentObservations: [...left.environmentObservations!, ...right.environmentObservations!],
findingDefinitions: [...left.findingDefinitions, ...right.findingDefinitions], findingOccurrences: [...left.findingOccurrences, ...right.findingOccurrences]
});
const baseline = merge(withHost(model('run-a1', observedA, [occurrence('a-1', 'run-a1', 'RULE-1', 'failed')]), 'server-a'),
withHost(model('run-a2', observedA, [occurrence('a-2', 'run-a2', 'RULE-1', 'failed')]), 'server-b'));
const target = merge(withHost(model('run-b2', observedB, [occurrence('b-2', 'run-b2', 'RULE-1', 'failed')]), 'server-b'),
withHost(model('run-b1', observedB, [occurrence('b-1', 'run-b1', 'RULE-1', 'passed')]), 'server-a'));
const result = compareProjectTimeslotEvidence([baseline, target], ['run-a1', 'run-a2'], ['run-b1', 'run-b2']);
const decision = (id: string) => result.sources.flatMap(source => source.decisions).find(item => item.baselineOccurrenceId === id);
// The same rule is resolved on server A and still open on server B; never crossed between hosts.
expect(decision('a-1')).toMatchObject({ state: 'verified-remediated', targetOccurrenceId: 'b-1' });
expect(decision('a-2')).toMatchObject({ state: 'confirmed-open', targetOccurrenceId: 'b-2' });
});
it('blocks only diagnostics whose impact can reach the results', () => {
const diagnostic = (runId: string, code: string) => ({ id: `${runId}-${code}`, importFileId: `file-${runId}`, severity: 'warning' as const, code, message: 'synthetic' });
const baseline = model('run-a', observedA, [occurrence('a-1', 'run-a', 'RULE-1', 'failed')]);
const target = model('run-b', observedB, [occurrence('b-1', 'run-b', 'RULE-1', 'passed')]);
target.diagnostics = [diagnostic('run-b', 'html-active-content-removed')] as never;
expect(compareProjectTimeslotEvidence([baseline, target], ['run-a'], ['run-b']).summary.verifiedRemediated).toBe(1);
target.diagnostics = [diagnostic('run-b', 'cis-cat-summary-mismatch')] as never;
expect(compareProjectTimeslotEvidence([baseline, target], ['run-a'], ['run-b']).summary).toMatchObject({ verifiedRemediated: 0, unverifiedAbsent: 1 });
});
it('uses the timeslot order when a report states no time of its own', () => {
const withoutTime = (built: CanonicalAssessmentModel) => ({ ...built,
environmentObservations: built.environmentObservations?.filter(item => item.property !== 'report.generated_at') });
const baseline = withoutTime(model('run-a', observedA, [occurrence('a-1', 'run-a', 'RULE-1', 'failed')]));
const target = withoutTime(model('run-b', observedB, [occurrence('b-1', 'run-b', 'RULE-1', 'passed')]));
expect(compareProjectTimeslotEvidence([baseline, target], ['run-a'], ['run-b']).summary.verifiedRemediated).toBe(1);
// An older report in B stays not comparable.
const older = compareProjectTimeslotEvidence([model('run-c', observedB, [occurrence('c-1', 'run-c', 'RULE-1', 'failed')]),
model('run-d', observedA, [occurrence('d-1', 'run-d', 'RULE-1', 'passed')])], ['run-c'], ['run-d']);
expect(older.summary).toMatchObject({ verifiedRemediated: 0, notComparable: 1 });
});
it('accepts a PingCastle rule missing from a complete report of the same domain and engine version as remediated', () => {
const pingCastle = (runId: string, observedAt: string, engine: string, rules: string[]): CanonicalAssessmentModel => {
const built = model(runId, observedAt, rules.map(rule => ({ ...occurrence(`${runId}-${rule}`, runId, rule, 'failed'),
sourceIdentity: { tool: { id: 'pingcastle', name: 'PingCastle' }, canonicalRuleId: rule } })));
built.scanRuns[0].tool = { id: 'pingcastle', name: 'PingCastle' };
built.importFiles[0] = { ...built.importFiles[0], detectedTool: { id: 'pingcastle', name: 'PingCastle' },
processedWith: { ...built.importFiles[0].processedWith, profileId: 'pingcastle', compatibilityKey: 'pingcastle-canonical-v1' } };
built.environmentObservations = [
{ ...observation(runId, 'domain.fqdn', 'synthetic.example'), tool: { id: 'pingcastle', name: 'PingCastle' } },
{ ...observation(runId, 'report.engine_version', engine), tool: { id: 'pingcastle', name: 'PingCastle' } }
];
return built;
};
const baseline = pingCastle('run-a', observedA, '3.3.0', ['A-Krbtgt', 'P-AdminLogin']);
const same = compareProjectTimeslotEvidence([baseline, pingCastle('run-b', observedB, '3.3.0', ['P-AdminLogin'])], ['run-a'], ['run-b']);
expect(same.summary).toMatchObject({ verifiedRemediated: 1, confirmedOpen: 1, unverifiedAbsent: 0 });
// Another PingCastle version may have renamed or dropped the rule: absence stays unverified.
const other = compareProjectTimeslotEvidence([baseline, pingCastle('run-c', observedB, '3.4.0', ['P-AdminLogin'])], ['run-a'], ['run-c']);
expect(other.summary).toMatchObject({ verifiedRemediated: 0, confirmedOpen: 1, unverifiedAbsent: 1 });
});
it('uses the client as scope when each timeslot holds one report that states no scope', () => {
const withoutScope = (built: CanonicalAssessmentModel) => ({ ...built,
environmentObservations: built.environmentObservations?.filter(item => item.property !== 'host.name') });
const baseline = withoutScope(model('run-a', observedA, [occurrence('a-1', 'run-a', 'RULE-1', 'failed')]));
const target = withoutScope(model('run-b', observedB, [occurrence('b-1', 'run-b', 'RULE-1', 'passed')]));
expect(compareProjectTimeslotEvidence([baseline, target], ['run-a'], ['run-b']).summary.verifiedRemediated).toBe(1);
// A scope stated on one side only never pairs.
const stated = model('run-c', observedB, [occurrence('c-1', 'run-c', 'RULE-1', 'passed')]);
expect(compareProjectTimeslotEvidence([baseline, stated], ['run-a'], ['run-c']).summary).toMatchObject({ verifiedRemediated: 0, notComparable: 1 });
});
it('reports a source of only one timeslot as not comparable instead of failing', () => {
const target = model('run-b', observedB, [occurrence('b-1', 'run-b', 'RULE-1', 'failed')]);
const result = compareProjectTimeslotEvidence([target], [], ['run-b']);
expect(result.sources).toMatchObject([{ state: 'not-comparable', reasons: ['baseline-source-missing'] }]);
expect(result.summary).toMatchObject({ newDeviations: 0, regressedDeviations: 0 });
});
it('does not treat a missing scanner source or a same-time reimport as remediation', () => {
const baseline = model('run-a', observedA, [occurrence('a-1', 'run-a', 'RULE-1', 'failed')]);
const otherBaseline = model('run-c', observedA, []);
@ -107,7 +223,9 @@ describe('conservative project timeslot comparison', () => {
const target = model('run-b', observedA, [occurrence('b-1', 'run-b', 'RULE-1', 'passed')]);
const sameTime = compareProjectTimeslotEvidence([baseline, target], ['run-a'], ['run-b']);
expect(sameTime.summary).toMatchObject({ verifiedRemediated: 0, unverifiedAbsent: 1 });
// The same report again (unchanged report time) is never progress (user decision 2026-10-13: not comparable).
expect(sameTime.summary).toMatchObject({ verifiedRemediated: 0, notComparable: 1 });
expect(sameTime.sources[0].reasons).toEqual(['target-observation-not-newer']);
});
it('blocks scope changes and incompatible processing before lifecycle claims', () => {

View File

@ -0,0 +1,141 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import type { Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createAssessorHttpServer } from '../../server/assessor-http-server';
import type { CanonicalFindingStatus, FindingOccurrence, ImportFileRecord, ScanRun } from '../../src/domain/canonical-model';
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';
const tool = { id: 'cis-cat', name: 'CIS-CAT Pro' };
const provenance = { decisionSource: 'source-native' as const, confidence: { level: 'high' as const, score: 1, reasons: ['synthetic'] }, mappingVersion: '1' };
let directory: string;
let server: Server | undefined;
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'assessor-comparison-http-'));
await writeFile(join(directory, 'index.html'), '<main>synthetic app</main>');
});
afterEach(async () => {
await new Promise(resolve => server ? server.close(resolve) : resolve(undefined));
server = undefined;
await rm(directory, { recursive: true, force: true });
});
function importFile(runId: string, importedAt: string): ImportFileRecord {
return {
id: `file-${runId}`, scanRunId: runId, fileName: `${runId}.html`, format: 'HTML', sizeBytes: 10, importedAt,
detectedTool: tool, detection: { level: 'high', score: 1, reasons: ['synthetic'] },
processedWith: { parserId: 'html', parserVersion: '1', profileId: 'cis-cat', profileVersion: '1.4.0', mappingVersion: '1.3.0',
normalizationVersion: '1.3.0', presentationVersion: '1.3.0', compatibilityKey: 'cis-cat-canonical-v1' },
sourceArtifact: { kind: 'not-retained', reason: 'synthetic' }
};
}
function observation(runId: string, property: string, value: string): EnvironmentObservation {
return {
version: 1, id: `${runId}-${property}`, scanRunId: runId, importFileId: `file-${runId}`, tool, subject: { type: 'host' },
property, label: property, originalValue: value, normalizedValue: value,
valueType: property === 'report.generated_at' ? 'datetime' : 'string', state: 'observed', source: {}, mappingVersion: '1', attributes: {}
};
}
function occurrence(runId: string, ruleId: string, status: CanonicalFindingStatus): FindingOccurrence {
const outcome = status === 'passed' ? 'pass' : 'fail';
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 },
{ 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 }
};
}
async function timeslot(repository: LocalAssessorRepository, name: string, observedAt: string, findings: Array<[string, CanonicalFindingStatus]>) {
const assessmentId = `assessment-${name}`;
await repository.saveAssessment({ id: assessmentId, projectId: 'dummy-project', name, createdAt: observedAt, updatedAt: observedAt, effectiveAt: observedAt });
const scanRun: ScanRun = { id: `run-${name}`, assessmentId, sequence: 1, label: name, tool, importedAt: observedAt };
const session = await repository.beginImport({ sessionId: `session-${name}`, projectId: 'dummy-project', assessmentId, scanRun });
await session.stage({
importFiles: [importFile(scanRun.id, observedAt)],
environmentObservations: [observation(scanRun.id, 'host.name', 'server-a'), observation(scanRun.id, 'benchmark.name', 'benchmark-a'),
observation(scanRun.id, 'report.generated_at', observedAt), observation(scanRun.id, 'report.engine_version', '4.0.0')],
findingDefinitions: findings.map(([ruleId]) => ({ id: `definition-${ruleId}`, canonicalKey: `cis-cat:${ruleId}`, title: ruleId,
frameworkReferences: [], createdAt: stamp, updatedAt: stamp })),
findingOccurrences: findings.map(([ruleId, status]) => occurrence(scanRun.id, ruleId, status))
});
await session.commit();
}
async function start() {
const repository = new LocalAssessorRepository();
await repository.saveProject({ id: 'dummy-project', name: 'Dummy Customer', createdAt: stamp, updatedAt: stamp, attributes: {} });
await timeslot(repository, 'a', '2026-01-01T10:00:00.000Z', [['1.1.1', 'open'], ['1.1.2', 'open'], ['5.1', 'passed']]);
await timeslot(repository, 'b', '2026-02-01T10:00:00.000Z', [['1.1.1', 'passed'], ['1.1.2', 'open'], ['5.1', 'open']]);
server = createAssessorHttpServer({ repository, staticRoot: directory });
await new Promise<void>(resolve => server!.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
return { repository, base: `http://127.0.0.1:${port}/api/v1/projects/dummy-project/timeslot-comparison` };
}
const query = (a: string, b: string) => `?baselineAssessmentIds=assessment-${a}&baselineScanRunIds=run-${a}&targetAssessmentIds=assessment-${b}&targetScanRunIds=run-${b}`;
describe('Comparison Engine timeslot route', () => {
it('compares two stored timeslots read-only', async () => {
const { repository, base } = await start();
const revision = repository.revision;
const response = await fetch(base + query('a', 'b'));
expect(response.status).toBe(200);
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);
});
it('rejects a reversed order, a missing timeslot and an assessment of another project', async () => {
const { base } = await start();
expect((await fetch(base + query('b', 'a'))).status).toBe(400);
expect((await fetch(`${base}?baselineAssessmentIds=assessment-a`)).status).toBe(400);
const foreign = await fetch(`${base}?baselineAssessmentIds=assessment-x&targetAssessmentIds=assessment-b`);
expect(foreign.status).toBe(404);
});
it('can be switched off', async () => {
const repository = new LocalAssessorRepository();
await repository.saveProject({ id: 'dummy-project', name: 'Dummy Customer', createdAt: stamp, updatedAt: stamp, attributes: {} });
server = createAssessorHttpServer({ repository, staticRoot: directory, comparisonPort: null });
await new Promise<void>(resolve => server!.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
const response = await fetch(`http://127.0.0.1:${port}/api/v1/projects/dummy-project/timeslot-comparison${query('a', 'b')}`);
expect(response.status).toBe(503);
});
});

View File

@ -0,0 +1,169 @@
import { describe, expect, it } from 'vitest';
import type {
CanonicalAssessmentModel,
CanonicalFindingStatus,
FindingOccurrence,
ImportFileRecord
} from '../../src/domain/canonical-model';
import type { EnvironmentObservation } from '../../src/domain/environment-context';
import type { PolicyWorkResult, PolicyWorkStatus } from '../../src/evaluation/policy-work-results';
import { compareTimeslots } from '../../src/comparison/timeslot-comparison-report';
// Synthetic two-timeslot client: CIS-CAT in both, Maester only in B, GPO settings in both.
const observedA = '2026-01-01T10:00:00.000Z';
const observedB = '2026-02-01T10:00:00.000Z';
const provenance = {
decisionSource: 'source-native' as const,
confidence: { level: 'high' as const, score: 1, reasons: ['synthetic'] },
mappingVersion: '1'
};
const tools = { 'cis-cat': { id: 'cis-cat', name: 'CIS-CAT Pro' }, maester: { id: 'maester', name: 'Maester' } };
function occurrence(id: string, runId: string, toolId: keyof typeof tools, ruleId: string, status: CanonicalFindingStatus): FindingOccurrence {
const outcome = status === 'passed' ? 'pass' : 'fail';
return {
id, definitionId: `definition-${ruleId}`, scanRunId: runId, importFileId: `file-${runId}`,
sourceIdentity: { tool: tools[toolId], canonicalRuleId: ruleId }, sourcePosition: { rowIndex: 1 },
originalFields: [], fieldMappings: [],
severity: { originalValue: 'high', normalizedValue: 'high', provenance },
status: { originalValue: status, normalizedValue: status, provenance },
outcome: { originalValue: outcome, normalizedValue: outcome, provenance }
};
}
function importFile(runId: string, toolId: keyof typeof tools): ImportFileRecord {
return {
id: `file-${runId}`, scanRunId: runId, fileName: `${runId}.html`, format: 'HTML', sizeBytes: 10, importedAt: observedA,
detectedTool: tools[toolId], detection: { level: 'high', score: 1, reasons: ['synthetic'] },
processedWith: {
parserId: 'html', parserVersion: '1', profileId: toolId, profileVersion: '1.4.0', mappingVersion: '1.3.0',
normalizationVersion: '1.3.0', presentationVersion: '1.3.0', compatibilityKey: `${toolId}-canonical-v1`
},
sourceArtifact: { kind: 'not-retained', reason: 'synthetic' }
};
}
function observation(runId: string, toolId: keyof typeof tools, property: string, value: string): EnvironmentObservation {
return {
version: 1, id: `${runId}-${property}`, scanRunId: runId, importFileId: `file-${runId}`, tool: tools[toolId],
subject: { type: 'host' }, property, label: property, originalValue: value, normalizedValue: value,
valueType: property === 'report.generated_at' ? 'datetime' : 'string', state: 'observed',
source: {}, mappingVersion: '1', attributes: {}
};
}
function model(slot: 'a' | 'b', runs: Array<{ runId: string; toolId: keyof typeof tools; findings: FindingOccurrence[] }>): CanonicalAssessmentModel {
const observedAt = slot === 'a' ? observedA : observedB;
const assessmentId = `assessment-${slot}`;
const findings = runs.flatMap(run => run.findings);
return {
schemaVersion: 1,
project: { id: 'dummy-project', name: 'Dummy Customer', createdAt: observedA, updatedAt: observedB, attributes: {} },
assessment: { id: assessmentId, projectId: 'dummy-project', name: slot, createdAt: observedAt, updatedAt: observedAt },
scanRuns: runs.map((run, index) => ({ id: run.runId, assessmentId, sequence: index + 1, label: run.runId, tool: tools[run.toolId], importedAt: observedAt })),
importFiles: runs.map(run => importFile(run.runId, run.toolId)), sourceTables: [],
environmentObservations: runs.flatMap(run => [
observation(run.runId, run.toolId, 'host.name', 'server-a'),
observation(run.runId, run.toolId, 'benchmark.name', 'benchmark-a'),
observation(run.runId, run.toolId, 'report.generated_at', observedAt),
observation(run.runId, run.toolId, 'report.engine_version', '4.0.0')
]),
findingDefinitions: [...new Set(findings.map(item => item.definitionId))].map(id => ({
id, canonicalKey: id, title: id, frameworkReferences: [], createdAt: observedA, updatedAt: observedAt
})),
findingOccurrences: findings, evidence: [], reviews: [], diagnostics: []
};
}
function setting(id: string, identity: string, status: PolicyWorkStatus, actual?: number): PolicyWorkResult {
return {
id, status, title: identity, scope: 'computer', identity, importedAt: observedA, sources: [],
settingKey: JSON.stringify(['dummy-project', 'aggregation', 'computer', identity]),
comparison: { baselineId: 'baseline-server', comparator: { actual: actual === undefined ? undefined : { kind: 'number', value: actual } } } as unknown as PolicyWorkResult['comparison']
};
}
function reportInput(reversed = false) {
const order = <T,>(items: T[]) => reversed ? [...items].reverse() : items;
const a = model('a', [{ runId: 'cis-a', toolId: 'cis-cat', findings: [
occurrence('a-1', 'cis-a', 'cis-cat', '1.1.1', 'failed'),
occurrence('a-2', 'cis-a', 'cis-cat', '1.1.2', 'failed'),
occurrence('a-3', 'cis-a', 'cis-cat', '5.1', 'passed')
] }]);
const b = model('b', [
{ runId: 'cis-b', toolId: 'cis-cat', findings: [
occurrence('b-1', 'cis-b', 'cis-cat', '1.1.1', 'passed'),
occurrence('b-2', 'cis-b', 'cis-cat', '1.1.2', 'failed'),
occurrence('b-3', 'cis-b', 'cis-cat', '5.1', 'failed')
] },
{ runId: 'maester-b', toolId: 'maester', findings: [occurrence('m-1', 'maester-b', 'maester', 'MT.1001', 'failed')] }
]);
return {
projectId: 'dummy-project', models: order([a, b]), baselineRunIds: ['cis-a'], targetRunIds: order(['cis-b', 'maester-b']),
gpo: {
baseline: { observedAt: observedA, results: order([setting('ga-1', 'MinimumPasswordLength', 'Fail', 7), setting('ga-2', 'NoLMHash', 'Fail', 0)]) },
target: { observedAt: observedB, results: order([setting('gb-1', 'MinimumPasswordLength', 'Fail', 10), setting('gb-2', 'NoLMHash', 'Pass', 1)]) }
}
};
}
const report = (reversed = false) => compareTimeslots(reportInput(reversed));
describe('Timeslot comparison report', () => {
it('assigns every change category from scanner evidence and rolled-up GPO verdicts', () => {
const result = report();
expect(result.summary).toEqual({
resolved: 2, unchanged: 1, 'value-changed': 1, unverified: 0, 'not-comparable': 0, 'not-applicable': 0,
new: 0, regressed: 1, targetNotComparable: 1
});
const find = (change: string) => result.changes.filter(item => item.change === change).map(item => item.recordIds).flat().sort();
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');
expect(gpo).toMatchObject({ method: 'shared-gpo-pass-rate', baselineScore: 0, targetScore: 50, change: 50 });
expect(result.score.excludedSources).toContainEqual({ sourceId: 'maester', reasons: ['baseline-source-missing'] });
const scored = result.score.sources.filter(item => item.change !== undefined);
expect(scored.map(item => item.sourceId)).toEqual(['cis-cat', 'group-policy']);
// Same three evaluated rules in A and B: one pass each, so the paired CIS-CAT pass rate is unchanged.
expect(result.score.sources.find(item => item.sourceId === 'cis-cat')).toMatchObject({ method: 'paired-scans', change: 0 });
const weight = scored.reduce((sum, item) => sum + item.weight, 0);
expect(result.score.targetScore).toBeCloseTo(scored.reduce((sum, item) => sum + item.targetScore! * item.weight, 0) / weight);
});
it('keeps the score delta for report times without an ISO clock time, as CIS-CAT writes them', () => {
const result = compareTimeslots({ ...reportInput(), models: reportInput().models.map(model => ({ ...model,
environmentObservations: model.environmentObservations?.map(item => item.property === 'report.generated_at'
? { ...item, normalizedValue: model.assessment.id.endsWith('a') ? 'Thursday, January 1 2026 10:00:00' : 'Sunday, February 1 2026 10:00:00' } : item) })) });
expect(result.score.sources.find(item => item.sourceId === 'cis-cat')).toMatchObject({ change: 0 });
expect(result.summary.resolved).toBe(2);
});
it('is deterministic and independent of the input order', () => {
const first = report();
const reversed = report(true);
expect(reversed.summary).toEqual(first.summary);
expect(reversed.changes).toEqual(first.changes);
expect(reversed.score).toEqual(first.score);
});
});

View File

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

View File

@ -25,11 +25,11 @@ it.each(['dark', 'light'])('opens engine selection, locks Core and restores keyb
await act(async () => trigger.click());
const dialog = document.querySelector('[role="dialog"]')!;
const checks = [...dialog.querySelectorAll<HTMLInputElement>('input')];
expect(checks.map(input => input.checked)).toEqual([true, true, true, true, true]);
expect(checks.map(input => input.disabled)).toEqual([true, false, false, false, false]);
await act(async () => { checks[0].click(); checks[1].click(); checks[4].click(); });
expect(readEnginePreferences()).toEqual({ core: true, context: false, report: true, remediation: true, compliance: false });
checks[4].focus();
expect(checks.map(input => input.checked)).toEqual([true, true, true, true, true, true]);
expect(checks.map(input => input.disabled)).toEqual([true, false, false, false, false, false]);
await act(async () => { checks[0].click(); checks[1].click(); checks[4].click(); checks[5].click(); });
expect(readEnginePreferences()).toEqual({ core: true, context: false, report: true, remediation: true, compliance: false, comparison: false });
checks[5].focus();
await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })));
expect(document.activeElement).toBe(dialog.querySelector('button'));
await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })));
@ -43,22 +43,22 @@ it.each(['dark', 'light'])('opens engine selection, locks Core and restores keyb
it('persists independent choices, ignores the obsolete Context preference and cannot disable Core', () => {
localStorage.setItem('ASSESSOR.contextLayer', 'off');
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true });
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true, comparison: true });
setEngineEnabled('report', false); setEngineEnabled('core', false);
resetEnginePreferencesForTests();
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: false, remediation: true, compliance: true });
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: false, remediation: true, compliance: true, comparison: true });
localStorage.setItem(ENGINE_PREFERENCES_KEY, '{"core":false,"context":false}');
resetEnginePreferencesForTests();
expect(readEnginePreferences()).toEqual({ core: true, context: false, report: true, remediation: true, compliance: true });
expect(readEnginePreferences()).toEqual({ core: true, context: false, report: true, remediation: true, compliance: true, comparison: true });
});
it('defaults safely when storage is malformed or blocked and still allows session toggles', () => {
localStorage.setItem(ENGINE_PREFERENCES_KEY, 'bad json');
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true });
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true, comparison: true });
resetEnginePreferencesForTests();
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('blocked'); });
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('blocked'); });
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true });
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true, comparison: true });
expect(() => setEngineEnabled('context', false)).not.toThrow();
expect(readEnginePreferences().context).toBe(false);
});

View File

@ -9,9 +9,9 @@ import type {
ImportFileRecord,
ScanRun,
ToolIdentity
} from '../../src/domain/canonical-model';
import { compareScanRuns } from '../../src/comparison/comparison-engine';
import { createLocalComparisonRule } from '../../src/comparison/local-mapping-rules';
} from '../../../src/domain/canonical-model';
import { compareScanRuns } from '../../../src/evaluation/scan-run-comparison/comparison-engine';
import { createLocalComparisonRule } from '../../../src/evaluation/scan-run-comparison/local-mapping-rules';
const timestamp = '2026-07-17T08:00:00.000Z';
const tool = (id: string): ToolIdentity => ({ id, name: id });

View File

@ -27,8 +27,8 @@ describe('DB Structure data flow', () => {
try {
await act(async () => root.render(createElement(EngineDataFlow)));
const engines = [...container.querySelectorAll<HTMLButtonElement>('.db-engine-flow__node.is-engine')];
expect(engines.map(engine => engine.dataset.node)).toEqual(['core', 'context', 'report', 'compliance']);
const connectionCount: Record<string, number> = { core: 3, context: 3, report: 3, compliance: 4 };
expect(engines.map(engine => engine.dataset.node)).toEqual(['core', 'context', 'report', 'remediation', 'compliance', 'comparison']);
const connectionCount: Record<string, number> = { core: 3, context: 4, report: 3, remediation: 4, compliance: 4, comparison: 2 };
// Remove the initial Core selection; identity frames must remain independently.
await act(async () => engines[0].click());
const highlighted = () => container.querySelectorAll('.db-engine-flow__edges > path.is-related');
@ -60,9 +60,9 @@ describe('DB Structure data flow', () => {
const container = document.createElement('div');
const root = createRoot(container);
await act(async () => root.render(createElement(EngineDataFlow)));
expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(13);
expect(container.querySelectorAll('.db-engine-flow__edges > path')).toHaveLength(13);
expect(container.querySelectorAll('.db-engine-flow__edges > path[marker-start]')).toHaveLength(3);
expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(18);
expect(container.querySelectorAll('.db-engine-flow__edges > path')).toHaveLength(19);
expect(container.querySelectorAll('.db-engine-flow__edges > path[marker-start]')).toHaveLength(4);
expect(container.querySelectorAll('.db-engine-flow__edges > path.is-related')).toHaveLength(3);
expect(container.querySelector('.db-engine-flow__dictionary')?.hasAttribute('open')).toBe(false);
const context = [...container.querySelectorAll<HTMLButtonElement>('.db-engine-flow__node')]
@ -70,7 +70,7 @@ describe('DB Structure data flow', () => {
await act(async () => context.click());
expect(context.getAttribute('aria-pressed')).toBe('true');
expect(container.querySelector('.db-engine-flow__inspector h4')?.textContent).toBe('Context Engine');
expect(container.querySelectorAll('.db-engine-flow__edges > path.is-related')).toHaveLength(3);
expect(container.querySelectorAll('.db-engine-flow__edges > path.is-related')).toHaveLength(4);
// The Management Report consumes the published Context results, so no relationship is drawn as planned.
expect(container.querySelector('.db-engine-flow__edges > path.is-planned')).toBeNull();
expect(container.querySelector('.db-engine-flow__inspector')?.textContent).toContain('Versioned Context publication for the Management Report');
@ -87,12 +87,30 @@ describe('DB Structure data flow', () => {
'Core read-only port → Compliance Engine', 'Compliance Engine ↔ Compliance tables',
'Compliance Engine ↔ Retrieval & model', 'Compliance Engine → ISO mapping in the UI']);
await act(async () => compliance.click());
expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(13);
// The Remediation Engine reads through the port and the published Context, owns its schema and reaches the UI through extension points.
const remediation = [...container.querySelectorAll<HTMLButtonElement>('.db-engine-flow__node')]
.find(button => button.textContent?.startsWith('Remediation Engine'))!;
await act(async () => remediation.click());
expect(container.querySelector('.db-engine-flow__inspector h4')?.textContent).toBe('Remediation Engine');
expect([...container.querySelectorAll('.db-engine-flow__inspector li strong')].map(item => item.textContent)).toEqual([
'Core read-only port → Remediation Engine', 'Context Engine → Remediation Engine',
'Remediation Engine ↔ Remediation tables', 'Remediation Engine → Work packages in the UI']);
expect(container.querySelector('.db-engine-flow__inspector')?.textContent).toContain('assessor_remediation');
expect(container.querySelector('.db-engine-flow__inspector')?.textContent).not.toContain('Planned');
await act(async () => remediation.click());
// The Comparison Engine reads both timeslots through the port and reaches the UI only through extension points.
const comparison = [...container.querySelectorAll<HTMLButtonElement>('.db-engine-flow__node')]
.find(button => button.textContent?.startsWith('Comparison Engine'))!;
await act(async () => comparison.click());
expect([...container.querySelectorAll('.db-engine-flow__inspector li strong')].map(item => item.textContent)).toEqual([
'Core read-only port → Comparison Engine', 'Comparison Engine → Comparison in the UI']);
await act(async () => comparison.click());
expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(18);
const dictionary = container.querySelector<HTMLDetailsElement>('.db-engine-flow__dictionary')!;
await act(async () => dictionary.querySelector('summary')!.click());
expect(dictionary.open).toBe(true);
expect(dictionary.querySelector('summary')?.textContent).toBe('All relationships (13)');
expect(dictionary.querySelectorAll('tbody tr')).toHaveLength(13);
expect(dictionary.querySelector('summary')?.textContent).toBe('All relationships (19)');
expect(dictionary.querySelectorAll('tbody tr')).toHaveLength(19);
act(() => root.unmount());
});
@ -104,6 +122,13 @@ describe('DB Structure data flow', () => {
expect(flow.other.map(item => item.name)).toEqual(['assessor_synthetic_new_table']);
});
it('keeps a schema-qualified engine table visible under Other tables', () => {
// Engine-owned schemas are listed as schema.table; no processing step claims them.
const flow = buildDbStructureFlow([table('projects'), table('remediation.package_snapshots')]);
expect(flow.steps.map(step => step.id)).toEqual(['foundation']);
expect(flow.other.map(item => item.name)).toEqual(['assessor_remediation.package_snapshots']);
});
it('lists outgoing and incoming relations of the selected table', () => {
const tables = [table('projects'), table('assessments', ['projects']), table('reviews', ['assessments', 'projects'])];
expect(dbStructureNeighbours(tables, 'assessor_assessments')).toEqual({ outgoing: ['assessor_projects'], incoming: ['assessor_reviews'] });
@ -200,9 +225,9 @@ describe('DB Structure data flow', () => {
expect(container.querySelector('.db-flow__header p')).toBeNull();
expect(container.querySelector('.db-flow .db-flow__header h3')?.textContent).toBe('Data flow');
expect([...container.querySelectorAll('.db-engine-flow__node.is-engine strong')].map(item => item.textContent))
.toEqual(['Core Engine', 'Context Engine', 'Report Engine', 'Compliance Engine']);
.toEqual(['Core Engine', 'Context Engine', 'Report Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine']);
const relations = container.querySelector('table[aria-label="Directed engine relationships"]');
expect(relations?.querySelectorAll('tbody tr')).toHaveLength(13);
expect(relations?.querySelectorAll('tbody tr')).toHaveLength(19);
expect(relations?.textContent).toContain('Core read-only port');
expect(relations?.textContent).not.toContain('Planned');
expect(relations?.textContent).toContain('Versioned Context publication for the Management Report');
@ -210,8 +235,8 @@ describe('DB Structure data flow', () => {
container.querySelector<HTMLButtonElement>('.db-structure-intro-actions button')!.click();
await new Promise(resolve => setTimeout(resolve, 0));
});
expect(container.querySelectorAll('.db-engine-flow__node.is-engine')).toHaveLength(4);
expect(container.querySelectorAll('.db-engine-flow tbody tr')).toHaveLength(13);
expect(container.querySelectorAll('.db-engine-flow__node.is-engine')).toHaveLength(6);
expect(container.querySelectorAll('.db-engine-flow tbody tr')).toHaveLength(19);
expect(container.querySelector('.db-structure-intro-actions button')?.textContent).toBe('Refresh schema');
expect(container.querySelector('.db-flow__lane.is-reference')?.textContent).toContain('Input to GPO step');
const chips = [...container.querySelectorAll<HTMLButtonElement>('.db-flow__table')];

View File

@ -9,7 +9,7 @@ import type {
SourceTableRecord
} from '../../src/domain/canonical-model';
import type { ImportStagingData } from '../../src/persistence/assessor-repository';
import { compareScanRuns } from '../../src/comparison/comparison-engine';
import { compareScanRuns } from '../../src/evaluation/scan-run-comparison/comparison-engine';
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
import { RepositoryConflictError, RepositoryValidationError } from '../../src/persistence/repository-errors';
import { riskCommand } from '../domain/risk-acceptance-fixture';

View File

@ -12,7 +12,7 @@ import type {
import type { StoredGpoBackupInstance } from '../../src/domain/gpo-analysis-persistence';
import type { ImportStagingData } from '../../src/persistence/assessor-repository';
import type { ImportArtifact, ImportProcessingRun, IngestionJob } from '../../src/domain/ingestion-persistence';
import { compareScanRuns } from '../../src/comparison/comparison-engine';
import { compareScanRuns } from '../../src/evaluation/scan-run-comparison/comparison-engine';
import { RepositoryConflictError } from '../../src/persistence/repository-errors';
import { runDatabaseMigrations } from '../../server/postgres/migration-runner';
import { PostgresAssessorRepository } from '../../server/postgres/postgres-assessor-repository';

View File

@ -60,7 +60,7 @@ describe('local ASSESSOR HTTP server', () => {
const health = await fetch(`${baseUrl}/api/v1/health`);
expect(await health.json()).toEqual({
status: 'ok', application: 'ASSESSOR', productVersion: '2.25.0', 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();
});