From 614ac1b1478aaa5299857fb6be0e30eea49adef7 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 11 Oct 2026 03:33:25 +0200 Subject: [PATCH] Backup --- .claude/hooks/core-areas.mjs | 1 + AGENTS.md | 28 + docs/IMPLEMENTATION_PLAN.md | 579 + docs/IMPORT_COMPATIBILITY.md | 34 + docs/REPORT_STRATEGY.md | 93 + fixtures/README.md | 6 +- .../application-json-binding.synthetic.html | 7 + fixtures/maester/multi-tenant.synthetic.html | 6 + package-lock.json | 4 +- package.json | 6 +- scripts/check-import-contracts.mjs | 25 + server/assessor-http-server.ts | 72 +- server/index.ts | 7 + server/remediation/migration.sql | 8 + server/remediation/remediation-runtime.ts | 26 + server/remediation/remediation-store.ts | 26 + src/api/assessor-api-client.ts | 7 + src/api/assessor-api-contract.ts | 2 + src/api/request-activity.ts | 1 + src/app/App.tsx | 15 +- src/app/engine-preferences.ts | 7 +- src/assessment-context/context-engine.ts | 15 +- src/assessment-context/context-heatmap.ts | 8 +- .../context-report-publication.ts | 9 +- src/components/DataTable/DataTable.tsx | 8 +- .../DonutMetricCard/DonutMetricCard.tsx | 11 +- .../assessed-result-percentages.ts | 10 + src/components/FindingCard/FindingCard.tsx | 5 + .../FloatingDataWindow/FloatingDataWindow.tsx | 61 +- .../ResizableResultTable.tsx | 15 +- .../ResultTableSelection.tsx | 13 + .../RichTextValue/RichTextValue.tsx | 40 +- src/components/TileCard/TileCard.tsx | 5 +- src/components/use-floating-window-layer.ts | 15 + src/exports/results-excel-format.ts | 43 + src/exports/results-window-excel.ts | 78 + .../actionlist/ActionlistControls.tsx | 25 +- src/features/context-layer/ContextHeatmap.tsx | 9 +- .../context-layer/context-heatmap.css | 21 +- .../use-context-cluster-index.ts | 8 +- src/features/dashboard/ClientInfoTabs.tsx | 37 + .../dashboard/ClientSecurityPanel.tsx | 148 + .../dashboard/CloudInformationTile.tsx | 16 +- src/features/dashboard/DashboardPanel.tsx | 357 +- .../dashboard/OperatingSystemInventory.tsx | 7 +- src/features/dashboard/RiskRegister.tsx | 13 +- src/features/dashboard/client-info-facts.ts | 348 + src/features/dataset-viewer/DatasetViewer.tsx | 36 +- src/features/dataset-viewer/RawDataView.tsx | 12 +- src/features/finding-reader/FindingReader.tsx | 33 +- .../linked-results/FloatingResultsWindow.tsx | 62 +- src/features/linked-results/LinkedResults.tsx | 8 +- .../VariantComparisonResult.tsx | 12 +- src/features/linked-results/result-colors.ts | 5 + src/features/linked-results/result-message.ts | 15 +- .../linked-results/results-excel-content.ts | 62 + src/features/mitre-graph/MitreGraphView.tsx | 18 +- .../mitre-graph/ObsidianGraphView.tsx | 26 +- .../project-import/DatahubHistoryOverview.tsx | 14 +- .../project-import/GpoFolderImport.tsx | 63 +- .../related-findings/RelatedFindings.tsx | 148 + .../search-filter/DynamicFilterControls.tsx | 4 +- .../search-filter/SearchFilterBar.tsx | 11 +- src/features/search-filter/dynamic-facets.ts | 7 +- src/features/search-filter/filter-records.ts | 11 +- .../search-filter/use-filtered-records.ts | 51 +- src/features/search-filter/work-facets.ts | 24 +- src/features/sidebar/DockableFilterPanel.tsx | 169 + .../sidebar/SidebarSecurityDomains.tsx | 98 +- .../sidebar/context-cluster-display.ts | 20 + src/features/view-bookmarks/ViewBookmarks.tsx | 4 +- .../maester-html-source-extractor.ts | 14 +- src/main.tsx | 3 +- src/profiles/pingcastle/result-evidence.ts | 34 + src/profiles/purple-knight/result-evidence.ts | 41 + src/remediation/FindingWorkPackage.tsx | 76 + src/remediation/WorkPackagesWindow.tsx | 146 + src/remediation/intervention-catalog.ts | 316 + src/remediation/remediation-engine.ts | 300 + src/remediation/remediation-read-port.ts | 12 + src/remediation/use-work-packages.ts | 46 + src/remediation/work-package-index.ts | 42 + src/remediation/work-package-label.ts | 4 + src/remediation/work-package-overview.ts | 58 + src/reporting/client-report-projection.ts | 51 +- src/reporting/context-management-report.ts | 57 +- src/reporting/management-overview-report.ts | 2 +- src/reporting/report-charts.ts | 124 + src/reporting/report-css.d.ts | 5 + src/reporting/report-header-facts.ts | 51 + src/reporting/report-input-assembler.ts | 20 +- src/reporting/report-projection.ts | 143 +- src/reporting/report-read-port.ts | 4 +- src/reporting/report-rich-text.ts | 73 + src/reporting/report-run.ts | 6 +- src/reporting/report-workspace-view.ts | 224 + src/reporting/technical-report-model.ts | 135 + src/reporting/technical-report.ts | 593 +- src/styles/base.css | 4 +- src/styles/client-security.css | 73 + src/styles/components.css | 223 +- src/styles/datahub-redesign.css | 2 +- src/styles/datahub.css | 70 +- src/styles/import-workflow.css | 2 +- src/styles/layout.css | 10 +- src/styles/legacy-views.css | 12 +- src/styles/ui-tokens.css | 20 +- tests/app/app-version.test.ts | 2 +- .../architecture/core-engine-boundary.test.ts | 18 +- .../assessment-context/context-engine.test.ts | 31 +- .../context-heatmap-ui.test.tsx | 61 +- ...ent-deviation-percentage-browser-check.mjs | 84 + tests/components/engines-control.test.tsx | 18 +- tests/design-language-check.mjs | 4 +- tests/dummy-package-cluster-audit.mjs | 23 + tests/dummy-results-audit.mjs | 46 + tests/dummy-results-excel-audit.mjs | 42 + tests/exports/results-excel-format.test.ts | 77 + .../features/client-business-impact.test.tsx | 10 +- .../client-deviation-percentage.test.tsx | 109 + .../client-info-action-colors.test.tsx | 3 + tests/features/client-info-tabs.test.tsx | 82 + tests/features/client-info-workspace.test.tsx | 17 +- tests/features/client-report-buttons.test.tsx | 17 +- tests/features/client-security-panel.test.tsx | 150 + tests/features/dashboard-layout.test.ts | 17 +- .../datahub-assessed-percentages.test.tsx | 66 + ...datahub-donut-loading-interaction.test.tsx | 6 +- .../datahub-metrics-prefetch.test.tsx | 10 +- tests/features/finding-context-layer.test.tsx | 2 + .../features/finding-reader-actions.test.tsx | 62 + .../finding-reader-mitre-disclosure.test.tsx | 57 + tests/features/finding-reader.test.ts | 2 +- .../finding-work-package-alignment.test.tsx | 68 + .../gpo-folder-import-diagnostics.test.tsx | 101 + tests/features/related-findings.test.tsx | 227 + tests/features/result-window-layout.test.ts | 2 +- .../results-deviation-evidence.test.tsx | 98 + tests/features/results-excel-export.test.tsx | 127 + .../results-finding-context-export.test.tsx | 65 + .../features/results-header-actions.test.tsx | 38 + ...ecurity-domain-filter-interaction.test.tsx | 2 +- .../source-selection-stays-in-view.test.tsx | 6 +- tests/features/tile-segment-grammar.test.ts | 10 +- tests/features/work-filter-panel.test.tsx | 182 + tests/features/work-package-heatmap.test.tsx | 114 + .../finding-reader-actions-browser-check.mjs | 81 + .../gpo-folder-diagnostics-browser-check.mjs | 72 + .../import-compatibility.test.ts.snap | 19499 ++++++++++++++++ tests/importers/import-compatibility.test.ts | 106 + tests/importers/import-contracts.json | 200 + tests/importers/maester-coverage.test.ts | 41 + .../remediation/intervention-catalog.test.ts | 118 + tests/remediation/remediation-engine.test.ts | 176 + .../work-package-interaction.test.tsx | 729 + tests/reporting/client-report-domains.test.ts | 32 + .../context-management-report.test.ts | 68 +- tests/reporting/report-header-facts.test.ts | 69 + .../reporting/report-input-assembler.test.ts | 4 +- tests/reporting/report-narrative.test.ts | 16 +- tests/reporting/report-run.test.ts | 13 +- tests/reporting/reporting-boundary.test.ts | 5 + tests/reporting/technical-report.test.ts | 389 +- tests/results-evidence-browser-check.mjs | 46 + tests/results-excel-browser-check.mjs | 59 + tests/results-header-browser-check.mjs | 41 + tests/server/assessor-http-server.test.ts | 52 +- tests/work-package-layout-browser-check.mjs | 43 + 168 files changed, 29147 insertions(+), 962 deletions(-) create mode 100644 docs/IMPORT_COMPATIBILITY.md create mode 100644 fixtures/maester/application-json-binding.synthetic.html create mode 100644 fixtures/maester/multi-tenant.synthetic.html create mode 100644 scripts/check-import-contracts.mjs create mode 100644 server/remediation/migration.sql create mode 100644 server/remediation/remediation-runtime.ts create mode 100644 server/remediation/remediation-store.ts create mode 100644 src/components/DonutMetricCard/assessed-result-percentages.ts create mode 100644 src/components/ResizableResultTable/ResultTableSelection.tsx create mode 100644 src/components/use-floating-window-layer.ts create mode 100644 src/exports/results-excel-format.ts create mode 100644 src/exports/results-window-excel.ts create mode 100644 src/features/dashboard/ClientInfoTabs.tsx create mode 100644 src/features/dashboard/ClientSecurityPanel.tsx create mode 100644 src/features/dashboard/client-info-facts.ts create mode 100644 src/features/linked-results/result-colors.ts create mode 100644 src/features/linked-results/results-excel-content.ts create mode 100644 src/features/related-findings/RelatedFindings.tsx create mode 100644 src/features/sidebar/DockableFilterPanel.tsx create mode 100644 src/features/sidebar/context-cluster-display.ts create mode 100644 src/profiles/pingcastle/result-evidence.ts create mode 100644 src/profiles/purple-knight/result-evidence.ts create mode 100644 src/remediation/FindingWorkPackage.tsx create mode 100644 src/remediation/WorkPackagesWindow.tsx create mode 100644 src/remediation/intervention-catalog.ts create mode 100644 src/remediation/remediation-engine.ts create mode 100644 src/remediation/remediation-read-port.ts create mode 100644 src/remediation/use-work-packages.ts create mode 100644 src/remediation/work-package-index.ts create mode 100644 src/remediation/work-package-label.ts create mode 100644 src/remediation/work-package-overview.ts create mode 100644 src/reporting/report-charts.ts create mode 100644 src/reporting/report-css.d.ts create mode 100644 src/reporting/report-header-facts.ts create mode 100644 src/reporting/report-rich-text.ts create mode 100644 src/reporting/report-workspace-view.ts create mode 100644 src/reporting/technical-report-model.ts create mode 100644 src/styles/client-security.css create mode 100644 tests/client-deviation-percentage-browser-check.mjs create mode 100644 tests/dummy-package-cluster-audit.mjs create mode 100644 tests/dummy-results-audit.mjs create mode 100644 tests/dummy-results-excel-audit.mjs create mode 100644 tests/exports/results-excel-format.test.ts create mode 100644 tests/features/client-deviation-percentage.test.tsx create mode 100644 tests/features/client-info-tabs.test.tsx create mode 100644 tests/features/client-security-panel.test.tsx create mode 100644 tests/features/datahub-assessed-percentages.test.tsx create mode 100644 tests/features/finding-reader-actions.test.tsx create mode 100644 tests/features/finding-reader-mitre-disclosure.test.tsx create mode 100644 tests/features/finding-work-package-alignment.test.tsx create mode 100644 tests/features/gpo-folder-import-diagnostics.test.tsx create mode 100644 tests/features/related-findings.test.tsx create mode 100644 tests/features/results-deviation-evidence.test.tsx create mode 100644 tests/features/results-excel-export.test.tsx create mode 100644 tests/features/results-finding-context-export.test.tsx create mode 100644 tests/features/results-header-actions.test.tsx create mode 100644 tests/features/work-filter-panel.test.tsx create mode 100644 tests/features/work-package-heatmap.test.tsx create mode 100644 tests/finding-reader-actions-browser-check.mjs create mode 100644 tests/gpo-folder-diagnostics-browser-check.mjs create mode 100644 tests/importers/__snapshots__/import-compatibility.test.ts.snap create mode 100644 tests/importers/import-compatibility.test.ts create mode 100644 tests/importers/import-contracts.json create mode 100644 tests/remediation/intervention-catalog.test.ts create mode 100644 tests/remediation/remediation-engine.test.ts create mode 100644 tests/remediation/work-package-interaction.test.tsx create mode 100644 tests/reporting/client-report-domains.test.ts create mode 100644 tests/reporting/report-header-facts.test.ts create mode 100644 tests/results-evidence-browser-check.mjs create mode 100644 tests/results-excel-browser-check.mjs create mode 100644 tests/results-header-browser-check.mjs create mode 100644 tests/work-package-layout-browser-check.mjs diff --git a/.claude/hooks/core-areas.mjs b/.claude/hooks/core-areas.mjs index 4a897ff..e55b3d2 100644 --- a/.claude/hooks/core-areas.mjs +++ b/.claude/hooks/core-areas.mjs @@ -10,6 +10,7 @@ export const WHOLE_REPOSITORY = 'Gesamtes Repository'; // Engine paths precede the Core areas they sit in. export const ENGINE_PATHS = [ + ['Remediation Engine', ['src/remediation/', 'server/remediation/', 'tests/remediation/']], ['Context Engine', ['src/assessment-context/', 'src/features/context-layer/', 'tests/assessment-context/', 'tests/features/finding-context-layer.test.tsx', 'tests/features/pathfinder-context-layer.test.tsx', 'tests/features/context-cluster-facet.test.tsx', 'tests/context-browser-check.mjs']], diff --git a/AGENTS.md b/AGENTS.md index 6214630..63d8e52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,21 @@ # ASSESSOR project guidance +## Protected scanner imports: crown jewels (2026-10-09) + +- Explicit user decision: a working scanner import is a permanent compatibility contract. Internal corrections and additive format support are permitted; previously supported input must retain its recognition, approved finding identities, original values, outcomes, severity, evidence, provenance and scoring semantics. Do not replace a working variant with a newer one or introduce a separate engine to bypass this protection. +- `tests/importers/import-contracts.json` registers protected synthetic formats; `tests/importers/import-compatibility.test.ts` and its committed snapshots check browser and backend recognition, preview, explicit mapping approval and canonical results. Fixtures are checksum-protected (line endings normalized). Customer reports must never be used to create these baselines; use synthetic/Dummy inputs. +- Add a synthetic contract for each newly supported format/variant and retain every previous contract. Existing fixture hashes, contract identifiers, assertions and expected results must not be removed, weakened or updated merely to make a regression pass. A deliberate change to an established import contract requires explicit user agreement for the exact behavior and current Core consent for every affected area; document the reason and migration/compatibility implications in the implementation plan. Adding a new contract does not authorize changing an old one. +- `npm run test:imports` is the mandatory compatibility gate, including the scanner import, shared scoring and import transport/worker suites. It runs in CI mode: missing or changed snapshots fail and are never automatically accepted. `npm test` and `npm run build` run this gate first; do not bypass it with direct build commands for a release. Run the gate for shared helper, dependency and Platform changes as well as parser changes. Any failure blocks the release; fixing a regression means restoring the supported behavior, not replacing its baseline. +- Keep this protection inside Core Ingest/Core Tests/Platform/Governance; do not add a nested Core or scanner-specific worker. Synthetic contract coverage protects registered variants, not unknown future vendor formats. New profiles must add a contract, and new public format variants need additive compatibility coverage before release. + +## Remediation Engine (2026-10-08) + +- User-authorized work packages are derived from published Context results and original Work deviations through a declared read-only Core port. A rolled-up GPO setting counts once per scope; result evidence rows are never tasks. +- Explicit user decision 2026-10-09 (replaces the former five-finding limit): a package stays inside exactly one Context cluster and one platform (Windows host/GPO, Active Directory, Cloud/M365) and contains at most eight tasks; explicit user decision 2026-10-09 (later the same day): a Windows host/GPO package contains at most fifteen tasks, because several GPO settings are changed in one change round. These limits are package-size rules, never an effort assessment, priority or security-impact score. A task is one Work finding, except that findings proven redundant because they target the same change target (the same exact technical setting or object identity, or the same published Context condition) form one task and count once. Every redundant finding stays individually listed as evidence of that task, keeps its own source attribution (GPO declaration and scanner observation stay separate), and Work, Client Info, score and Result DB counts stay unchanged. Redundancy is never inferred from title, wording or text similarity. +- The engine owns `src/remediation/`, `server/remediation/`, `tests/remediation/` and the separately migrated `assessor_remediation` schema. Core findings carry no new database column; their displayed assignment is a derived flag. Preserve original verdicts, scores, reviews, risk acceptances and Result DB data. +- Show `Context Cluster → AP-01` next to Source ID in Finding Reader. Work Package replaces the Result Message filter; source message values remain preserved. Package numbers are identifiers, never priorities or quantified security impact. +- Remediation is an independently optional engine in the Engines menu. Context-off consumes only existing package snapshots and never starts Context computation. Disabling Remediation clears its Work filter and suppresses late output. No task workflow, owners or deadlines are part of this feature. + ## Implementation plan - Read `docs/IMPLEMENTATION_PLAN.md` before architecture changes, broad refactors, persistence changes, importer/profile work, or server-readiness work. @@ -48,6 +64,14 @@ - Tool-independent check: run `npm test` before every commit, whichever agent or person made the change; the architecture and invariance tests below are the only Core/Engine checks that apply to every tool. - `tests/architecture/core-engine-boundary.test.ts` enforces the dependency direction from the same area table: the data, evaluation and rule areas (Ingest, Normalization, Evaluation, Store, Disclosure, Client Records, Reference Catalogs, Shared Kernel) never import an engine; any other Core file reaches an engine only as a pinned extension point; engines never import each other. Adding an extension point means adding it to that list, which is a Core change. Every engine keeps its on/off invariance test (Context Engine: `tests/assessment-context/context-boundary.test.ts`; Report Engine: `tests/reporting/report-engine-invariance.test.ts`). +## Report numbers + +- Explicit user decision 2026-10-05, binding for the Management Report and the Technical Report: (1) the data basis is the Raw Data of the Work records, counted once per Work record (a rolled-up GPO setting counts once), never per result row, per-member comparison or Result DB row; (2) every number Client Info displays is the authoritative value for the report. Deviations, assessed, passed, gaps, Security Domain values and Overall Score in a report equal Client Info for the same client and timeslot, using the same records, result states and Security Domain classifier. +- Report figures Client Info does not show (Context problem fields, themes, heatmap) must reconcile to the Client Info totals: their exclusive partition, including `Other Deviations (not yet catalogued)` or `Ohne Problemkategorie`, sums to the Client Info deviation count, and the same deviations carry the same number on every sheet. +- The Technical Report may list more evidence rows than deviations (for example several comparison rows behind one GPO setting), but marks them as evidence; its headline counts follow Client Info. +- An archived report keeps the numbers of its snapshot; after a reimport it may legitimately differ from the current Client Info. Result DB and Data Hub counting rules are not report sources. +- Every mismatch fix adds a synthetic test that compares the report figure with the Client Info figure for the same records. + ## Context layer (Phase 13E) - Latest explicit user decision 2026-10-01 supersedes the preceding permanent-Context decision: the Work toolbar has an `Engines` button at the former Context-button position. It stays marked blue, including on hover and while the popup is closed (an explicit colour-role exception for this control only). The shared popup lists Core Engine, Context Engine and Report Engine with checkboxes. All default to active; Core Engine is always checked and cannot be disabled. User choices for optional engines are stored locally under the shared Core-owned engine-activation contract. Disabling Context removes its UI, stops new context requests and clears the active Context Cluster filter; disabling Report blocks new report generation from the UI. Already submitted backend work is not cancelled by this local preference; its late UI output is suppressed. Cluster badges still select the exact Work filter, and the Context detail section still starts collapsed. Engine independence and Core invariance remain binding. @@ -76,6 +100,8 @@ ## Binding ASSESSOR design language +- Explicit user design principle 2026-10-10: the close X is always the rightmost control in a window/dialog/panel header action bar. No group ring, badge or other action may follow it visually or in DOM/keyboard order. Optional same-change rings precede the close X; preserve their stable reserved slot so title length and ring availability never shift the other header actions. Permanent Results acceptance covers minimized and expanded windows, both themes and narrow window widths. + - Explicit user correction 2026-09-18: the current, user-approved Data Hub, Work, and Pathfinder screens are the binding visual references for ASSESSOR's Ableton-inspired design language. This applies to every view, explicitly including Client Info, and to proposals, generated images, prototypes, and production implementation. - Before any visual task, read `.claude/skills/assessor-ui/SKILL.md` and the relevant current components and `src/styles/ui-tokens.css`. The approved views and these current project agreements take precedence over older skill examples, copied asset templates, and superseded Client Info exceptions. - Use Data Hub for flat square tiles, full-circle donuts, compact action buttons and branding; Work for list/detail hierarchy, typography, badges and reading surfaces; Pathfinder for stable filter/work/detail zones and compact controls. Preserve these approved views as references; do not redesign them as a side effect of Client Info work. @@ -104,6 +130,8 @@ ## UI Finding Reader first read +- Explicit user decision 2026-10-10: Finding Reader actions always form one horizontal, non-wrapping group in this order: `AI search`, bookmark icon, `Results`. Context badges and narrow viewports may move the whole group, never split or reorder its actions. Existing search availability and Results enablement remain unchanged. Bookmark actions everywhere use only the shared icon, with no visible `Bookmark` label; accessible names and tooltips remain. Snapshot naming uses `Snapshot`, not `Bookmark`. Keep a permanent synthetic interaction acceptance test covering both themes, narrow widths, bookmark toggling and Results activation. + - Explicit user decision 2026-09-25: every Finding Reader states within seconds what is wrong and what to do. Directly below the setting or rule heading, a `Problem` block (`Summary` for a passed result) with the outcome-colored marker and a separate `Next step` block with the orange `--next-step-accent` marker precede all explanatory text; both markers use `--marker-width`. Background (description, technical explanation, rationale) follows openly, references and documentation after it; work tracking and Additional Information stay collapsed at the end. - Scanner profiles declare their first-read fields in `presentation.firstRead`; React components never name scanner-specific fields. Use only retained source values: a missing field removes its line and nothing is summarized, rephrased or invented. Counts come from explicit source counts or linked result tables; a result row is red or green only where the source marks it (CIS-CAT criterion/evaluated rows with the finding outcome, `resultSheetRowStatusCells` markers). Split a long remediation only at a paragraph boundary and keep the rest complete behind `Full remediation`. An imported documentation link is offered only as a plain https URL with `noopener noreferrer`. - Explicit user decision 2026-09-25: every documented standard Windows code shown as text carries its meaning directly after it, in muted regular text: well-known SIDs, standardized domain RIDs (`S-1-5-21--500/512/...`; the domain part is never resolved), User Rights Assignment constants (`SeNetworkLogonRight` -> `Access this computer from the network`) and registry type codes (`4` -> `REG_DWORD`). The single list lives in `src/shared/windows-codes.ts` and is applied centrally by `PathText`; the original code stays unchanged and authoritative, unknown or customer-specific codes stay plain, and a meaning already stated next to the code is not repeated. diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 40a77ec..809d1c4 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -1,5 +1,579 @@ # ASSESSOR – Umsetzungsplan +## 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. + +Change log 2026-10-11: clarified the compact Security finding title and source explanation order. + +## Restore compact rectangular Context heatmap (2026-10-11, Phase 7 Presentation) + +User explicitly supersedes square tiles: restore compact four-sided rectangles while preserving the current number, severity, full title and count hierarchy. Registered Context CSS removes the 1:1 aspect ratio and returns to automatic content height with the shared tile-height minimum; width, inset, radius, colours and exact Work clicks remain unchanged. Empty categories retain their neutral surface and sort after populated Critical/High/Medium categories. A High category with new evidence uses the High fill and moves into the populated High group. Permanent synthetic Light/Dark interaction now verifies that exact empty-to-populated transition and click identity. + +Current Core release: Platform, Core Tests. Patch decision: 2.24.1 for the verified backward-compatible contour correction; package/lock and UI/API version contracts synchronized. The Overview invariance check now compares the full original Overview surface and separately preserves the Security total; the concurrent Security workspace intentionally renders published Context categories, and its permanent interaction checks retain all 105 findings with Context on/off. Verified: all 19 heatmap/Context-boundary/Security interactions, 12 UI/API version checks, design-language check, all 504 mandatory import checks and both final production builds pass (existing client chunk-size warning only). Corrected the new test to use the existing TypeScript target rather than changing compiler settings. Actual Dummy Light/Dark acceptance confirms all 22 tiles are compact 260 by 172px rectangles without text overflow, retain 478 findings, and leave the empty High category neutral at position 21. The synthetic empty-to-populated transition proves it moves into High with its fill and exact Work click. Actual keyboard activation opens the exact 42 Credential Relay findings. Normal-width review has no tile/grid overflow. Light restored, viewport reset; backend health confirms 2.24.1, canonical PostgreSQL and unchanged repository revision 592. No Core presentation source change, database mutation or full-suite/phase-completion claim. Concurrent work preserved. + +Change log 2026-10-11: restored compact rectangular Context tiles with retained labels and verified empty/populated High ordering. + +## Client Info Security findings workspace (2026-10-11, Phase 7 Presentation) + +Current Core release: Presentation, Core Tests, Platform. User requests a compact Defender-like findings view in the existing Security tab, without Work Packages or task workflow. Collapsed categories follow the exact published Context heatmap order and retain severity, counts and deviation shares. Category disclosure reuses the Technical Report situation/technical-approach texts and authoritative Security Domain classifier. A collapsible two-column domain/category diagram uses those same counts. Each finding opens the retained source explanation and remediation through the existing Reader helpers; structured GPO comparisons preserve individual baseline recommendations. Missing guidance is explicitly unavailable, never invented. + +DashboardPanel remains the existing pinned Core extension to Context/Report publications; the new presentation component has no engine imports or persistence. One Raw Data Work record counts once, never one Results evidence row. The source filter and client/timeslot scope match Client Info. Unmatched publication identities are explicitly visible as unassigned, not guessed. Context-off retains the Core findings list and suppresses the diagram/categories; Report-off suppresses report-only category wording. All groups/findings start collapsed and retain disclosure state on tab switches. No 100-row limit, data changes, filter writes or navigation on disclosure. + +Minor release decision: 2.24.0 for this backward-compatible capability, following concurrent 2.23.27; package/lock and version contracts synchronized. Synthetic Light/Dark acceptance covers all 105 deviations, exact heatmap order, domain flows, two-level disclosure, retained source text, tab-state preservation and Context-off Core invariance. Verified: 39 targeted UI/version/engine-boundary checks plus the final four Security interactions pass, all 504 mandatory import compatibility checks and both final production builds pass (existing client chunk-size warning only), design-language and local-only checks pass. Browser review with Dummy confirms all 478 deviations across 22 categories in exact heatmap order, 19 Cloud Privileged Access findings, 45 domain/category flows summing to 478, and mouse/Enter disclosure with retained explanation and remediation in Light/Dark. Browser review found two inherited icon-size issues in the diagram; the chart selector now excludes the disclosure triangle and explicitly restores intrinsic SVG height. The synthetic interaction test includes real global styles and checks those boundaries. Corrected the new GPO test fixture to supply the full ImportedDataset shape; no production workaround or importer baseline change. Final production browser review passes in Light/Dark: the diagram measures 1040 by 640, its disclosure triangle remains 12 by 12 and graph text remains 14px; the 45 links still sum to 478 and the panel has no horizontal overflow at normal width. Enter opens the category and individual native finding, and switching Overview/Security preserves the open explanation/remediation. Light restored and temporary browser tab closed. No backend restart or database mutation by this task; concurrent work preserved. No full-suite or phase-completion claim. + +Change log 2026-10-11: populated the Security tab with category/domain overview and compact expandable findings. + +## Square Context heatmap tiles (2026-10-11, Phase 7 Presentation) + +User requests returning to four-sided tiles and trying squares, explicitly superseding the hexagon contour. Registered Context CSS restores the existing 260px environment tile width and shared tile inset/radius, with a 1:1 aspect ratio and no clipping. Full severity fills, numbers, complete titles, counts, published sequence and exact Work cluster clicks remain unchanged. No Context data or Core presentation source changes. Permanent Light/Dark UI acceptance replaces the superseded hexagon geometry with square aspect ratio, standard inset and absence of clip paths; existing interaction/invariance checks remain. + +Current Core release: Platform, Core Tests. Patch decision: 2.23.27 following the completed 2.23.26 batch; the concurrent capability release subsequently advanced the shared product version to 2.24.0, which is preserved. Verified: 13 Context UI/boundary checks, 12 current UI/API version checks, design-language check, all 504 mandatory import checks and both production builds pass (existing chunk-size warning only). Actual Dummy Light/Dark browser review confirms all 22 tiles are 260px squares with shared 4px corners, no clipping or text overflow, and clickable corners. Keyboard focus remains visible; Enter on Credential Relay Exposure opens exactly its 42 Work findings. Normal-width review also confirms squares and no horizontal grid overflow. Light restored, viewport override reset. Local backend runs the verified 2.24.0 build against the existing canonical PostgreSQL datastore, repository revision 592. No database mutation or full-suite/phase-completion claim; concurrent work preserved. + +Change log 2026-10-11: replaced the superseded hexagon contour with square Context heatmap tiles, retaining published contents and exact Work interactions. + +## Data restored as a separate Client Info tile (2026-10-11, Phase 7 Presentation) + +Current Core release: Presentation, Core Tests, Platform. User correction explicitly supersedes the preceding two-tile summary decision: Data becomes its own tile again, containing the existing deviation donut, additional result states and six environment facts. Overall Score and Security Domains retain one common tile; Domain Information retains its existing tile. The summary wrapper becomes transparent and each tile uses the shared theme surface, border and rounded radius. Existing geometry, values and filter interactions are preserved. + +Patch decision: 2.23.26 for this verified layout correction; package, lock and UI/API version contracts synchronized. The 26 targeted layout, theme grammar, tab, dashboard and synthetic deviation/filter checks pass. Verified: all 504 mandatory import checks (including the API version contract), six UI version/Core-boundary checks, design-language and local-only checks, and both production builds pass (existing client chunk-size warning only). Actual Dummy browser review confirms three separate neutral tiles in Light/Dark: score/domains, Data and Domain Information. Data retains the shared 4px corners, all six environment facts and the original 478 deviations / 63% plus additional status counts. Light theme restored and temporary browser tab closed. No full-suite or phase-completion claim. Concurrent work is preserved; no backend restart or database changes are part of this task. + +Change log 2026-10-11: separated Data from the score/domain tile while keeping all Data content together. + +## Hexagonal Context heatmap tiles (2026-10-11, Phase 7 Presentation) + +User requests hexagons, superseding the previous cut-corner outline for Context tiles only. The registered Context stylesheet uses a flat-top six-sided contour with shared small corner rounding, a regular hexagon aspect ratio and a central 50-percent text-safe region. The width adds two existing spacing units to the existing environment tile width to keep full names and severity readable without shrinking typography. Grid wrapping, full severity fills, numbers, counts, published order, cluster IDs and exact Work drilldowns are preserved; no honeycomb reordering or domain changes. The polygon fallback retains all six sides when CSS shape is unavailable. + +Current Core release: Platform, Core Tests. Retain concurrent 2.23.26 for the shared unfinished visual-refinement release batch; its package/lock and UI/API contracts are synchronized. All 25 Context UI/boundary and version/API checks passed. Permanent Light/Dark interactions also assert the six vertices, central padding and geometric ratio. Verified: design-language/local-only checks, all 504 mandatory import checks and both production builds (existing chunk-size warning only). Owned local backend restarted on the same canonical PostgreSQL; health confirms 2.23.26 and unchanged revision 592. Actual Dummy Light/Dark acceptance confirms 22 equal regular hexagons, rounded vertices, complete text inside the central region and no text overflow. Clipped corners are not hit-testable; the centre is clickable. Visible keyboard focus and Enter on Credential Relay Exposure open exactly its 42 Work findings. Normal browser width retains wrapping without grid or cell overflow. Light/Dark screenshots saved, temporary viewport reset, Light Client Info restored. No Core presentation source or stored finding data changed for this request; no phase-completion or full-suite claim. + +## Rounded cut corner on Context tiles (2026-10-11, Phase 7 Presentation) + +User explicitly confirms cutting the bottom-right corner diagonally and rounding the other corners and transitions. This supersedes the rectangle-only contour for Context heatmap tiles. The change is confined to the registered Context Engine stylesheet: existing spacing/radius tokens define a 64 by 32 cut with rounded joins, preserving dimensions, colour, text positions and cluster ordering. The shape clips hit testing as well as paint; keyboard focus moves inside the tile so clipping cannot remove the focus indicator. Older shape-function implementations receive the diagonal polygon fallback. + +Version reassessment: retain the current concurrent 2.23.25 for this isolated contour refinement; no separate capability or product-contract batch and no Core/version files changed. Verified: 13 existing heatmap interaction and Context-boundary tests, design-language checks, all 504 mandatory import checks and both production builds (existing chunk-size warning only). Actual Dummy Light/Dark acceptance confirms the rounded shape on all 22 tiles, full visible titles, an unclipped inset keyboard focus, a non-clickable cut corner and a clickable tile centre. Enter on Credential Relay Exposure opens its exact 42 Work findings. The existing CSS shape implementation is used in the browser; no fallback was needed. No backend restart or database mutation by this task; concurrent work preserved. + +Change log 2026-10-11: rounded the Context tile outline and cut out its bottom-right corner without changing the content layout. + +## Restore full Context severity fills (2026-10-11, Phase 7 Presentation) + +Current Core release: Presentation, Core Tests, Platform. User correction explicitly supersedes the neutral heatmap tile surfaces approved in the preceding batch. Restore full Critical/High/Medium severity fills while preserving the new layout: separate number, top-right severity, complete title and larger count. Keep rectangles and existing geometry. Empty/undetermined clusters remain neutral. All tile text uses the existing severity ink on coloured surfaces; neutral tiles retain theme ink. Hover keeps the full severity fill and adds the existing outline treatment. No numbering, counts, click targets, engine or data changes. + +User follow-up removes the Badges: Base Severity caption completely, including its unused style and description reference. Existing header and Light/Dark click tests now permanently assert its absence while retaining severity text on each tile. These follow-up files are registered Context Engine paths. Patch decision remains 2.23.24 for this same unfinished batch, following concurrent 2.23.23; version contracts synchronized. Verified: 44 heatmap/package/Context-boundary interactions, the updated eight heatmap tests, UI/API version contracts and design-language/local-only checks. Actual Dummy browser acceptance confirms full Critical/High/Medium fills, matching severity ink on numbers/titles/counts, neutral empty/undetermined surfaces and no text overflow in Light/Dark. The first gated build passed all 504 import checks and both production builds. Final gated build after caption removal passed all 504 import checks and both production builds (existing chunk-size warning only); the removed caption is absent from the production bundle. No backend restart or database mutation by this task; concurrent workspace work preserved. + +Change log 2026-10-11: restored full-tile severity colour while retaining the separated number/title/count hierarchy. + +## Leading Client Info tab follows the panel corner (2026-10-11, Phase 7 Presentation) + +Current Core release: Presentation, Platform, Core Tests. User correction changes only the first tab's top-left corner to the original 10px panel radius; all other tab corners, dimensions, positions and interactions stay unchanged. Shared --panel-radius expresses the existing panel geometry without altering it. Permanent Light/Dark tab interactions check the first corner uses that same value, the second tab remains unchanged, and scrolling/mouse/keyboard switching retain their contracts. Moving the existing literal into the token also tightens the design check's legacy-radius allowance from three to two. + +Retain 2.23.25 for the same unfinished two-tile release batch. Browser review exposed the generic aside sidebar surface behind Data; its layout wrapper is now a div, removing sidebar background/z-index semantics from dashboard content while preserving the named heading and controls. Verified: 30 final tab/layout/version checks, design-language and local-only checks; all 504 mandatory import checks and both final production builds passed. Actual Dummy browser acceptance in Light and Dark confirms the first tab top-left is 10px, its top-right and both second-tab top corners remain 3px; Data has a transparent div wrapper and shares the summary surface. Exactly two outer tiles remain. Final Light/Dark screenshots saved, temporary wide viewport reset, Light restored. No full-suite or phase-completion claim. + +## Two top-level Client Info tiles in both themes (2026-10-11, Phase 7 Presentation) + +Current Core release: Presentation, Platform, Core Tests. User correction: Overall Score, Security Domains / Business Impact and Data share one exterior tile; Domain Information is the second tile, in Light and Dark. A shared summary container owns the border, radius and surface; its internal sections no longer have exterior frames. Existing content, metrics, connectors, Results and Work drilldowns remain intact. Narrow layouts stack sections inside the same two tile boundaries. This supersedes the previous four individually bordered Light sections. + +Patch decision: 2.23.25 after preserving concurrent 2.23.24. Permanent synthetic Light/Dark Dashboard interactions assert exactly two top-level groups, all three summary areas, separate Domain Information, retained counts, Results evidence and Context drilldown. Updated old layout expectations to the explicit grouping decision and corrected stale parent assertions for the existing tabpanel wrapper. Verified: 50 distinct targeted layout, interaction, version and architecture checks, design-language and local-only checks, all 504 mandatory import checks and both production builds (existing chunk-size warning only). Local backend health confirms 2.23.25, canonical PostgreSQL and unchanged repository revision 592. Final browser acceptance passed in Light/Dark: the three summary sections and Data wrapper are transparent, their common tile owns the theme surface and single exterior border, and Domain Information is the separate second tile. Normal-width check retains two groups without horizontal content overflow. No phase-completion or full-suite claim. + +## Compact Filters and WP presentation corrections (2026-10-11, Phase 7 Presentation) + +Current request released Presentation, Platform and Core Tests before implementation, including renewed consent for Results activation and tab alignment. Floating filters use content height capped by the user's resize height; closing groups removes the empty remainder and reopening retains the cap. All eight resize handles, selections, docking and the uppermost filter layer remain. The Work Packages toolbar button toggles its window on repeated clicks. Results and shared floating data windows now use one activation sequence: opening or clicking either brings it forward; keyboard focus also activates it. Existing Results activation callbacks, cluster geometry, minimization and the uppermost filter layer remain. Client Info moves its viewer up by the existing tab height and removes the extra tablist top inset, so the content panel starts at the original shared viewer top; the tablist remains outside scrolling content. Only cluster headings retain colour squares in the package table; the Reader and Filters keep their established cluster markers. Visible codes use WP instead of AP across engine output, Reader, both filter paths and package table. Legacy snapshots display WP through a read-only formatter; historical package IDs remain stable, requiring no database migration or reassignment. Counts and package membership are unchanged. + +User-described tile shadows were contrasting gutter surfaces: measured tile and grid box shadows were already none. The Client Info overview and environment grids now paint existing gutters with the shared tile surface, preserving widths, gaps, borders and typography. This is a specific user correction to those gutter surfaces; shared transparent-segment tokens remain unchanged. Browser review found the workspace clipping the elevated tabs despite correct measured alignment. Dashboard-only workspace/viewer overflow now permits the tab strip above the original content edge; scrolling stays inside the tabpanel, and other views keep their clipping contracts. Permanent Light/Dark acceptance includes both ancestor boundaries. + +Patch decision: 2.23.23 after preserving concurrent 2.23.22; package, lock and UI/API version contracts synchronized. Permanent Light/Dark interactions cover opening Results above Work Packages, repeatedly activating either window, Filters above both, toolbar close/reopen, compact filter height with retained resize limit, exact legacy snapshot selection IDs and heading-only colour squares. Tab interaction acceptance retains mouse/keyboard switching and scrolled state, verifies removal of the top inset and the dashboard-only viewer offset. Verified: 57 tab/filter/package/version checks, 50 final ancestor-clipping/interactions/architecture checks, additional engine/related/tile/Results layout checks and design/local-only checks. Corrected the new test's missing sheet prop and JSDOM ResizeObserver; no production workaround or scanner baseline changes. A gate run hit the existing 5-second import test timeout under parallel load; the unchanged gate passed all 504 checks when rerun alone, including the final clipping correction. Both final production builds passed (existing client chunk-size warning only). Restarted the owned local backend; health confirms 2.23.23, PostgreSQL and unchanged repository revision 592. Browser acceptance passed on Dummy: original content edge approximately 134px is retained in both themes, both tab centres are visibly hit-testable, switching to empty Security and back preserves that edge, and both dashboard ancestors allow the full tab height. Work confirms WP-01 labels, 20 coloured cluster headings and zero row squares, the second toolbar click closes the window, Results opens above the parked WP window, and Filters remains at its uppermost layer. Expanding packages reaches the retained 600px cap; closing the group shrinks the floating panel to approximately 158px with no blank remainder. Actual Light/Dark screenshots saved; Light Overview restored. No phase-completion or full-suite claim. + +Change log 2026-10-11: compacted closed floating filters, toggled Work Packages, renamed visible AP codes to WP with stable IDs, retained table squares only on cluster headings, and removed contrasting Client Info gutters. + +## Neutral Context heatmap tiles (2026-10-11, Phase 7 Presentation) + +Current Core release: Presentation, Core Tests, Platform. User approved neutral tiles with an isolated cluster number at top left, a compact coloured Base Severity badge at top right, the full cluster name and a larger finding count. This explicitly supersedes full-tile severity fills and repeating Base Severity on every tile. One shared explanation beside the heading identifies the badge meaning. Empty and undetermined clusters retain neutral badges; counts, source semantics, exact cluster click targets and the heatmap's authoritative sequence are unchanged. + +Uses existing theme surfaces, severity colours, Inter roles, shared tile radius/inset and a wider existing tile size so labels wrap less. Grid tiles retain equal heights; all text remains visible and native buttons retain keyboard activation/focus. No new palette, shadows, decorative stripes or truncation. Existing Light/Dark interaction tests were adapted for the approved hierarchy and keep the exact cluster interaction, on/off invariance and full-publication numbering checks. + +Patch decision: 2.23.22 following concurrent 2.23.21 work; version contracts synchronized. Verified: 52 targeted heatmap/package/architecture/Context-boundary checks, two UI/API version contracts, design-language and local-only checks, all 504 mandatory import checks and both production builds. The first build caught a concurrently introduced missing test prop; after that parallel correction the complete gated build passed. Existing chunk-size warning remains. Actual Dummy browser acceptance at 2.23.22 passed in Light/Dark: all 22 tiles retain the complete names and equal heights without horizontal content overflow, 16px titles and 32px counts, neutral surfaces with coloured badges, and neutral empty/undetermined badges. Visible keyboard focus and Enter activation open exactly the 19 findings of cluster 01; the Work filter retains its matching number and count. Concurrent changes preserved; no backend restart or database mutation by this task. + +Change log 2026-10-11: moved Context severity colour into compact badges and separated cluster number, title and finding count. + +## Heatmap positions govern cluster numbering and AP grouping (2026-10-11, Phase 7 Presentation) + +Current Core release: Presentation, Core Tests, Platform. Explicit user correction supersedes the previous permanent display-number list and independent package ordering. The current client/timeslot's complete published Context heatmap sequence is authoritative: its first cluster is visibly 01, its second 02, including exact published ties and grey/empty cells. Removed the duplicate fixed catalog-number list and the separate severity sort. Technical cluster/package identities, source findings and package membership remain unchanged. Without a Context publication, labels remain unnumbered rather than claiming a heatmap position. + +The heatmap displays these positions; both sidebar filters, toolbar dropdowns, Work Packages and Reader AP links use the same complete publication order and labels. Work/search/table filters never renumber the remaining clusters. The package table shows one coloured cluster heading, then indented AP codes. Cluster groups always stay in heatmap order; column sorting operates within each group. Full cluster/AP names remain available in accessible labels and table filter options. The table's package filter now stores exact package IDs so a changed published display number cannot invalidate the selection. + +Patch decision: 2.23.20 from concurrent 2.23.19; package/lock/UI/API contracts synchronized. Permanent synthetic Light/Dark interaction acceptance renders the actual heatmap, both filter paths, package table and Reader together: Cloud Privileged Access Exposure is 01 and Credential Relay Exposure is 05; selecting/filtering the latter retains 05, and changing published tie order updates all views while retaining the same selected package ID. Other checks preserve grouping, numeric AP order, empty/missing publication handling, counts, exact filter targets, colours, sorting and window interactions. Superseded fixed-number expectations were replaced to match the explicit user correction; scanner/import baselines are unchanged. Verified: 51 targeted UI/version/architecture/Context-boundary tests, design-language and local-only checks. All 504 mandatory import checks and both production builds passed; the existing client chunk-size warning remains. Browser acceptance passed on Dummy at 2.23.20 in Light and Dark: all 20 package group headings match the corresponding full heatmap positions and sidebar headings, with Cloud Privileged Access Exposure at 01 and Credential Relay Exposure at 05. Selecting AP-01 displays the matching 01 Reader link and keeps all 20 groups in the open package window. No backend restart, database write, full-suite or phase-completion claim; concurrent workspace work preserved. + +Change log 2026-10-11: made published heatmap positions the common cluster numbering and grouped the package table by those positions. + +## Radius-only refinement (2026-10-11, Phase 7 Presentation) + +Current Core release: Presentation, Platform, Core Tests. User approval explicitly supersedes the earlier square-tile agreement only for corner radii: tiles and popup exteriors use 4px, ordinary buttons and inputs use 3px. Existing transparent segments, shell geometry, circular controls, joined finding rows, table cells, separators and Context colour squares retain their geometry. No content, navigation, layout, spacing, font, colour, border width, shadow, overflow, interaction or data changes are authorized by this request. + +Shared radius tokens and the existing container/control radius declarations implement the change; popup headers follow the exterior corners without clipping or new overflow rules. Data Hub reference modules also receive the same exterior radius. Each own CSS mutation is audited before/after: removing only radius declarations yields identical CSS. The full stylesheet comparison also preserves concurrent independent package-table changes in components.css. The existing tile grammar acceptance is updated to the explicitly approved shared tile radius, retaining all surface, geometry and shadow assertions; the legacy large-radius allowance decreases by one for the corrected common dialog. Patch decision: 2.23.19 from concurrent 2.23.18, then retained the concurrent 2.23.20 release and synchronized version contracts. A newly concurrent Remediation test fixture needed its existing dataset metadata spread to satisfy the production type check; its behavioral assertions remain unchanged. Verified: 42 final tile/package/version checks, the prior two actual Reader checks, design and local-only checks. Browser comparisons confirm identical tile dimensions, padding, fonts, backgrounds, text colours and border widths in Light/Dark, with only 0px to 4px radius; the common dialog likewise retains identical measured dimensions and style roles while changing 14px to 4px. All 504 mandatory import checks and both final production builds passed at 2.23.20. Final Light/Dark browser checks and screenshots passed; Data Hub reference-module dimensions and all non-radius style roles are identical before/after. Controls resolve to 3px; tiles, reference modules and common dialogs resolve to 4px. UI/backend 2.23.20 use the unchanged canonical PostgreSQL revision 592. No full-suite or phase-completion claim. + +Change log 2026-10-11: applied only the approved subtle corner radii to shared tiles, controls and popup exteriors. + +## Work Packages minimized parking (2026-10-11, Phase 7 Presentation) + +Current Core release: Presentation, Core Tests, Platform. Work Packages adds a minimize icon before Maximize and Close. Minimizing from normal or maximized mode parks a compact 320px title strip at the top right, 8px below the actual Source: Scan filter, with a toolbar fallback when the filter is absent and viewport clamping. Close stays rightmost. The shared floating window offers this optional capability only when an anchor selector is supplied; existing Client Info windows retain their controls and behavior. + +The minimized body stays mounted but hidden, keeping table filters, sort and data intact. Click the parked title or Restore icon to reopen at the normal right parked bounds; header double-click also restores it. While minimized, dragging/resizing and maximization controls are unavailable. Existing explicit Close/Escape and engine unavailability apply. No package assignment, source, scoring, export or stored data changes. + +Patch decision: 2.23.18 from concurrent 2.23.17; package/lock/UI/API contracts synchronized. Permanent Light/Dark actual-toolbar interactions cover Source: Scan anchoring, normal/maximized minimization, title/icon restore, retained filter/sort/table contents, hidden body, absence of resize handles and rightmost Close. Verified: 37 targeted package/shared-dialog/version/architecture tests, design-language and local-only checks. All 504 mandatory import checks and both production builds passed; the existing client chunk-size warning remains. No backend restart, database change, full-suite or phase-completion claim; concurrent workspace changes preserved. + +Change log 2026-10-11: added Work Packages minimization to a restorable title strip below Source: Scan. + +## Reader AP Context colour square (2026-10-11, Phase 7 Presentation) + +Current Core release: Presentation, Platform, Core Tests. The AP link beside Source Rule ID starts with the same 12px square as Filters and Work Packages, using the shared published Context severity. The Reader passes this read-only presentation value to Remediation; Remediation does not import the Context Engine. Its existing Core extension point now also pins the published Context reader. The shared Context index accepts an absent optional application scope without requesting data, preserving standalone Reader use; valid application scope, shared requests and last-ready values stay unchanged. Unknown/unavailable colour is neutral; package/task link semantics and right alignment remain unchanged. + +Patch decision: 2.23.17 from concurrent 2.23.16; package/lock and UI/API contracts synchronized. Permanent actual Reader interactions cover all four Context colours, source-ID present/absent, both themes, wrapping roles and the AP click. Verified: 54 Reader/package/Context/architecture checks, 12 additional Client Info regression checks, 12 UI/API version checks, design-language and local-only checks, all 504 mandatory import checks and both production builds. Local backend restarted at 2.23.17 against the unchanged canonical PostgreSQL repository revision 592. Actual browser acceptance verified the orange published Credential Relay AP-03 square before the right-aligned link in Light/Dark, with the existing six-task label and source ID intact; screenshots saved. No full-suite or phase-completion claim. + +Change log 2026-10-11: prefixed Finding Reader AP links with the shared published Context colour square. + +## Work Packages right parking and persistent interaction (2026-10-11, Phase 7 Presentation) + +Current Core release: Presentation, Core Tests, Platform. User correction supersedes the former left parking and close-on-selection behavior: Work Packages opens 16px from the right viewport edge below the toolbar and returns there after leaving maximized mode, through either the header button or header double-click. Its 830px preferred width and 1080px preferred height remain viewport-clamped. The shared floating window accepts an optional right inset; other windows keep their existing defaults. Manual dragging/resizing remain available. + +Package selection, both Unassigned links and the Redundant findings summary retain the open window while applying their existing exact Work filter or evidence selection. Table sorting, filtering and totals stay available; explicit Close/Escape and engine unavailability still remove the window. No package membership, source values, scoring or stored data changes. + +Patch decision: 2.23.16 from 2.23.15; package/lock/UI/API contracts synchronized. Permanent Light/Dark actual interactions verify right opening/reopening, maximize/restore by button and double-click after viewport width changes, retained visibility after package, both Unassigned and redundant-evidence selections, unchanged source data and existing shared Client Info dialogs. Verified: 35 targeted interaction/version/architecture tests, design-language and local-only checks. All 504 mandatory import checks and both production builds passed; the existing client chunk-size warning remains. No backend restart, database change, full-suite or phase-completion claim; concurrent workspace changes preserved. + +Change log 2026-10-11: parked Work Packages on the right on opening/restoring and kept it visible through its Work selections. + +## Work Packages toolbar position and outline (2026-10-11, Phase 7 Presentation) + +Current Core release: Presentation, Core Tests, Platform. User correction places the package overview button immediately before CSV, renames its visible label to Work Packages and keeps a yellow border through the shared button-accent token in both themes, including hover and the open state. Existing engine availability, overview interactions and export behavior remain unchanged. + +Patch decision: 2.23.15 from 2.23.14; package/lock/UI/API contracts synchronized. Permanent actual-toolbar Light/Dark interaction acceptance covers label, DOM order and the shared yellow border before opening, while open and after closing. JSDOM resolves only the shared accent token for its CSS cascade assertion. Verified: 24 targeted interaction/version tests, design-language checks and all 504 mandatory import checks. Local-only check and both production builds passed; the existing client chunk-size warning remains. No backend restart, database change, full-suite or phase-completion claim. Concurrent workspace changes preserved. + +Change log 2026-10-11: moved and renamed Work Packages and added its persistent yellow outline. + +## Package colours, parked geometry and same-change overview (2026-10-11, Phase 7 Presentation) + +Current Core release: Presentation, Platform, Core Tests. Every Package row starts with the shared 12px Context colour square, using the same published severity as Filters and its Context severity column. Unassigned keeps its existing separate presentation. Work packages supplies an optional initial geometry to the shared floating window: 830px width, up to 1080px height, 16px left inset and 16px below the toolbar, clamped to the available workspace. Other floating views retain their previous defaults. Every fresh opening restores this parked geometry; drag, resize and maximize remain available. + +The Redundant findings number opens the complete evidence population of all published redundant tasks in Work, including each task's counterpart findings. It uses the existing exact task identities and related-finding groups, clears an active single-group focus and incompatible Work filters, selects the project Work sheet and Reports view, and closes Packages. Latest user correction: the headline is Redundant findings and counts every member of the published redundant tasks, matching the population opened in Work (for example twelve rather than six additional findings). Records, reviews, package/source facts, outcomes and scores stay unchanged. + +Patch decision: 2.23.13 from 2.23.12; concurrent workspace work advances the shared version to 2.23.14, which is retained with synchronized package/lock and UI/API version contracts. Permanent Light/Dark package interactions check the initial bounds, colour parity and the actual summary-to-Work interaction with source data unchanged. Verified: 32 Package/related-finding/architecture checks, 14 UI/API version and Reader alignment checks, design-language/local-only checks, all 504 mandatory import checks and both production builds. App/backend run 2.23.13 against canonical PostgreSQL revision 592 unchanged. Actual browser acceptance pending. User correction supersedes the earlier additional-only headline: the engine view now shows the complete redundant-finding count under Redundant findings; 39 interaction/version checks, all 504 import checks and both production builds passed for the corrected count. Actual Light/Dark browser acceptance confirms twelve Redundant findings and a click opens exactly twelve Work records. Reopening Packages keeps all 49 packages despite the twelve-record Work drilldown. Default bounds in both themes are width 830px, left 16px, top 16px below the toolbar and available workspace height; square colours match the published high/critical values. Light restored; screenshot saved. App/backend run the shared 2.23.14 against canonical PostgreSQL revision 592 unchanged. No phase-completion claim. + +Change log 2026-10-11: added Context squares, a dedicated parked window default and a Work drilldown for all redundant evidence. + +## Context severity order in both sidebar filters (2026-10-11, Phase 7 Presentation) + +Current user release: Presentation, Core Tests, Platform. User correction supersedes the earlier identifier-first sidebar order: Context Cluster and Work Package filters now sort by the Context heatmap's displayed severity, Critical / High / Medium / Undetermined. Stable two-digit cluster identifiers break equal-severity ties; packages remain grouped by exact published cluster ID and numerically ordered AP code. Missing/no-evidence Context publication stays neutral, and Unassigned remains last. Numbers are identifiers and are never renumbered by severity or filtering. No package assignment, source values, verdicts, scoring, counts or engine logic changes. + +Both filters share a Presentation comparator reading only the published Context cluster severity and evidence flag; source-finding severity is not substituted. Permanent actual-sidebar Light/Dark interaction acceptance covers Critical-before-01, numeric ties/APs, medium, a Critical catalog entry without evidence sorted neutral, missing publication, exact package-filter click, unchanged original records, Unassigned and docking. + +Patch decision: 2.23.14 from concurrent 2.23.13, synchronized package/lock/UI/API contracts. Verified: 61 distinct sidebar/filter/dock/remediation/version/architecture tests, all 504 mandatory import checks including API version, design-language and local-only checks. Dummy API identity audit succeeded after an initial transient Context HTTP 500: all-assessment scope contains 54 packages, 477 assigned findings, 465 tasks and one Unassigned. Every individual package finding matches its Context primary cluster ID and label, task evidence equals package evidence, package membership is exclusive and repository revision is unchanged. This audit scope differs from the screenshot's 274-finding selection; visible screenshot AP sums 16 / 23 / 36 / 37 / 7 / 6 reconcile to the corresponding heatmap cells. No wrong assignment was detected in the audited scope; the screenshots alone do not validate each member of their selected scope. Both production builds passed; the existing client chunk-size warning remains. No backend restart, Core data write, full-suite or phase-completion claim; unrelated workspace changes preserved. + +Change log 2026-10-11: aligned Context/AP sidebar ordering with published Context severity and audited exact Dummy package membership. + +## Complete Packages scope and compact facts (2026-10-11, Phase 7 Presentation) + +Current Core release: Presentation, Platform, Core Tests. User correction supersedes the earlier Work-filter projection: Packages displays the complete published Remediation snapshot for the selected client/assessment scope, independent of Work search, status, facets and workspace filters. Only the table's own column filters reduce its rows; headline totals remain the full scope. The hook publishes this read-only overview alongside its existing index. Actual scope or dataset changes still refresh the published results. + +Removed the complete Highest finding severity and Critical/High columns. Kept shared published Context severity, numerical cluster/AP order, Unassigned selection and yellow open/header indicators. Replaced explanatory paragraphs with six facts in the shared Client Info fact-table style and one short package-size note. Findings = assigned + Unassigned; additional same-change findings = assigned findings minus tasks. Original finding/source/scoring semantics and stored data remain untouched. + +Patch decision: 2.23.12 from 2.23.11; package, lock and UI/API version contracts synchronized. Verified so far: 62 Remediation interaction/engine/catalog/architecture checks, 20 version/Finding integration checks, design-language and local-only checks, and all 504 mandatory import checks. Permanent Light/Dark interaction coverage checks identical full table content and six totals with a Work search selecting 16 findings, one same-change member, an empty Passed view, and the Unassigned facet after reopening. Both production builds passed. Actual Light/Dark browser acceptance confirms the compact fact table and nine remaining columns. All 49 package rows and their complete cell contents stay identical across a Print spooler search (four visible Work records), Passed status (265 Work records), and the Purple Knight source filter. The table-local Cloud/M365 filter reduces rows to 23 while the six full-scope totals remain unchanged; clearing it restores all 49. App/backend run 2.23.12 against canonical PostgreSQL revision 592 unchanged. Existing client chunk-size warning remains; no full-suite or phase-completion claim. + +Change log 2026-10-11: decoupled Packages content from Work filters, removed both unwanted columns and condensed the header facts. + +## Packages active button and header marker (2026-10-10, Phase 7 Presentation) + +Current release: Presentation, Platform, Core Tests. The Packages toolbar button carries the shared yellow marked state while its window is rendered, including hover. Closing with X, Escape or selecting a package clears the visible active state. The Work packages header receives a left yellow marker using the shared marker-width (8px) and marking-color tokens. A default-off FloatingDataWindow header option applies it only to Work packages. Existing number order, filters and source facts remain unchanged. + +Patch decision: 2.23.11 from 2.23.10; version contracts synchronized. Existing Light/Dark package interaction acceptance now checks inactive/open/closed/reopened/selected states and the marked window header. Verified: 19 package interaction checks, five architecture checks, UI/API version contracts, all 504 mandatory import checks, both production builds, design-language and local-only checks passed. Actual Light/Dark browser acceptance confirms the open button and header marker share the yellow token, marker-width is the shared 8px token, X and Escape clear the active button and reopening restores it. The screenshot documents both markers together. App/backend run 2.23.11 with canonical PostgreSQL revision 592 unchanged. Existing chunk-size warning remains; no full-suite or phase-completion claim. + +Change log 2026-10-10: linked Packages active indication to its window and added the standard header marker. + +## Package number order and fewer column filters (2026-10-10, Phase 7 Presentation) + +User correction explicitly supersedes the earlier default Context-severity ordering: packages open in ascending displayed cluster identifier order (01, 02, 03, ...), then AP identifier order within the cluster. Clicking Package uses this same numbered label, rather than alphabetical cluster names. Severity sorting remains an explicit column-header action only. Remove the Highest finding severity and Critical/High filter controls while retaining their displayed facts and sort buttons; Measure remains without a filter. Context severity, identifiers and engine/source calculations are unchanged. + +Implementation stays within the registered Remediation Engine view and interaction tests. Current Core release: Platform, Core Tests for patch/version contracts. Patch decision: 2.23.9 from 2.23.8; concurrent workspace work advances the shared release to 2.23.10, which is retained. Nineteen interaction tests passed, including permanent Light/Dark regressions for 01/AP-01, 01/AP-02, 02, 03, 12 order on open, filter/clear, manual severity sorting followed by Package sorting, and reopening. The two removed controls are absent while both columns remain. Verified: all 504 mandatory import checks, both production builds, UI/API version contracts, five architecture checks, design-language and local-only checks passed. A TypeScript fixture-shape error was repaired before the successful full build. Actual browser acceptance in Light and Dark confirms both unwanted filters absent with their columns retained; all 49 real package labels are numerically ascending, beginning 01/AP-01 through AP-04, then 02/AP-01 through AP-03, then 03. The app opens by Package ascending. Light restored. App/backend run 2.23.10 with canonical PostgreSQL revision 592 unchanged. Existing client chunk-size warning remains; no full-suite or phase-completion claim. + +Change log 2026-10-10: made cluster/AP number order the package default and removed two unwanted filters. + +## Close X always at the right edge (2026-10-10, Phase 7 Presentation/Governance) + +Current user release: Presentation, Governance, Core Tests, Platform. Binding user design principle: close X is always the last/rightmost header action, including visual and DOM/keyboard order. Added this permanent agreement to AGENTS.md. Results now place the stable optional same-change-ring slot before the close button, correcting the preceding trailing-ring placement while preserving aligned actions, full title tooltips and ellipsis. No group identity, filtering, source value, export scope or engine behavior changes. + +Patch decision: 2.23.10 from concurrent 2.23.9, synchronized package/lock/UI/API contracts. Permanent actual Results acceptance verifies X-last and ring-before-X in minimized and expanded windows, with/without a group and both themes; existing minimize/restore and close callbacks remain covered. Isolated actual Chrome acceptance verifies the rightmost geometry and hit-testable controls in both themes at 440/760px window widths. Verified: 27 targeted Results/group/version/architecture tests, all 504 mandatory import checks including API version, both production builds, design-language and local-only checks. The first build encountered a concurrent incomplete Work Package test fixture; that separate fixture was corrected concurrently, and the complete gated build then passed. Existing client chunk-size warning remains. No backend/datastore restart, full-suite or phase-completion claim; unrelated changes preserved. + +Change log 2026-10-10: made close X the rightmost Results header action and established the rule in project governance. + +## Package column selections and published Context severity (2026-10-10, Phase 7 Presentation) + +Current user release: Presentation, Platform, Core Tests. This corrects the preceding package-table implementation: column filters offer actual distinct column values through selects; Measure has no filter. Source selection matches each individual source in multi-source packages. Context severity now comes from the same published cluster and shared display rule as the Work Package filter; it is explicitly separate from original Highest finding severity and defaults to Critical-first ordering. Cluster identifiers, Work counts, Unassigned and source facts remain unchanged. This supersedes the prior free-text filter and default finding-severity sort decisions. + +The overlap was caused by the global sticky th rule applying to every body row heading. Only the table head now sticks; body headings explicitly remain static. A permanent rendered interaction test loads the real stylesheet and scrolls the package table. Real Sidebar/Packages interaction tests cover published severity updates, GPO packages without source severity, neutral no-evidence clusters, selectable values and no Measure filter in both themes. + +Patch batch: 2.23.8, shared with the concurrent Results-header refinement. Verified so far: 75 targeted remediation, presentation, architecture and UI/API-version tests across the focused run and corrected scroll-test rerun; all 504 import compatibility checks, design-language and local-only checks pass. Both production builds passed. Actual browser acceptance in Light and Dark confirms all 49 package Context severities match their 20 sidebar clusters, six GPO packages retain a displayed High Context severity despite absent original finding severity, Critical selection yields 11 packages, High selection 34 and GPO source selection six. Header remains at the viewport top after 5,912 pixels of Light and 3,860 pixels of Dark scrolling while body headings scroll away. No Measure filter exists. Light, empty column filters and default Context Critical-first ordering restored. App and backend report 2.23.8; canonical PostgreSQL repository revision remains 592. Existing chunk-size warning remains. No full-suite or phase-completion claim; no import contract, engine calculation, persistence or source severity changes. + +Change log 2026-10-10: corrected package selection filters, sticky row-heading overlap and Context severity consistency. + +## Stable Results header actions with long titles (2026-10-10, Phase 7 Presentation) + +Current user release: Presentation, Core Tests, Platform. User screenshot shows minimized Results rows whose long title displaces actions. The title heading now owns only the flexible remaining width, and the title uses a real text ellipsis with its complete text in the native tooltip. Actions retain their fixed non-wrapping area. An equal-width trailing slot holds the optional existing same-change ring, so rows with and without a proven group align identically; no group or ring is invented for unrelated findings. Existing ring click/focus semantics, controls, export behavior, minimize/restore and source data remain unchanged. Both expanded and minimized headers use the same layout. + +Patch decision: 2.23.8 from 2.23.7, synchronized package/lock/UI/API contracts. Verified so far: 43 targeted Results layout, actual bookmark/To Report/minimize/restore/close interactions, export and existing real group-focus checks. Isolated actual Chrome acceptance in both themes at 440/760px Results widths proves identical action positions, title truncation/full tooltip, contained and hit-testable enabled controls, and minimize/restore. The browser's marker is a synthetic geometry fixture; permanent actual group interactions remain separately covered. Existing viewport-under-620px global positioning override was observed to place stacked windows at the same top coordinate; it is separate from the reported normal Work header issue and remains outside this scoped correction. Final verification: 49 targeted Results/group/export/version/architecture checks, all 504 mandatory import checks including API version, both production builds, design-language and local-only checks passed. Existing client chunk-size warning remains. No backend/datastore restart, full-suite or phase-completion claim; concurrent workspace changes preserved. + +Change log 2026-10-10: constrained Results title width and reserved a stable trailing group-ring slot to keep header actions aligned. + +## Results workbook tab names and headings on every sheet (2026-10-10, Phase 7 Disclosure) + +Current user release: Disclosure, Core Tests, Platform. User correction: the first Results workbook tab is Results; remove the separate Results text tab; every worksheet carries the finding heading. The first exported table now uses Results, with further exported tables named Results 02, Results 03, etc., including selected-table exports. Each table retains its original table heading inside the larger bold white-on-black finding title band. Text-only results also use Results. Complete non-table result prose is retained below the first table instead of a separate sheet; its original data filter ends before that text. Selected exports retain only their selected evidence tables and the existing finding context. + +All auxiliary worksheets now receive the same finding heading and two-row title/header structure: Source text, Finding context, Export info and Long text. Physical row splitting reserves title/header and prose footer rows, repeats the title/header for split sheets and never silently loses evidence. Long-value references now use the shifted physical row coordinates; Long text parts remain literal and complete. A title exceeding Excel's cell limit is preserved through the existing long-value mechanism; the Long text sheet points to that complete title instead of duplicating an over-limit cell. Original values, counts, Context/Problem information and export scopes remain unchanged. No Presentation files, profiles, source data, Result DB policy, backend lifecycle or datastore changes. + +Patch decision: 2.23.7 from concurrent 2.23.6, synchronized package/lock/UI/API contracts. Targeted export checks pass for full/selected/text-only paths, all original 1,001 rows, prose retention, original context, literal formula-looking values and complete Unicode long-text parts. Final OOXML acceptance checks every sheet's original finding title, bold white font and black fill, including Long text. Verified: 20 targeted export/context/version/architecture tests, actual full and selected Chrome downloads in both themes, all 504 mandatory import checks including the API-version contract, both production builds, design-language and local-only checks. Existing client chunk-size warning remains. No full-suite or phase-completion claim; unrelated workspace changes preserved. + +Change log 2026-10-10: named Results tabs consistently, integrated the separate result prose and added the finding heading to every worksheet. + +## Persistent, filterable and numbered package table (2026-10-10, Phase 7 Presentation) + +Current user release: Presentation, Platform, Core Tests. The package overview uses the same permanent Context identifiers as the Work filter, supplied through the existing Core extension point; Remediation does not import Context internals or invent assignments. Highest severity now defaults to descending (Critical first), with stable cluster/package identifiers as tie-breaks. Every column has an independent accessible filter; filters combine, numeric comparisons are supported and a clear action restores the table. Column filters refine the table only, with a separate visible row count; the authoritative Work partition and task totals stay unchanged. Unassigned remains available from the summary and is subject to explicit table filters in the table. The table owns both scroll axes and its opaque header remains sticky within that viewport, including the filters. + +The suspected engine refresh defect was not reproduced: synthetic actual-view acceptance proves a new dataset prepares Context before requesting packages, refreshes an already open overview, suppresses the previous dataset's late response and hides all package output with Remediation disabled. Existing context-off snapshot and task/record invariance contracts remain unchanged. Missing numbers were a presentation omission, fixed by reusing the approved numbered label function. No database, assignment, scoring, engine eligibility or source-data changes. + +Verified: 32 focused actual-view, remediation and architecture checks, the UI version contract, all 504 mandatory import/API-version checks, both production builds, design-language and local-only checks. Patch decision: 2.23.6 from concurrent 2.23.5, version contracts synchronized. Actual browser acceptance confirms all six Critical packages precede High, stable numbered labels, severity filtering to six of 49 packages and cluster 13 filtering to two of 49, with unchanged Work totals of 274 = 258 + 16. In both themes the header stays at the table viewport top while vertically scrolling (measured zero offset, including over 4,400 pixels of scrolling in Dark); all ten filters remain in that sticky header and its background is opaque. Light Mode, default Critical-first order and empty column filters restored. The previously loaded browser used 2.23.3; the new client and restarted backend both report 2.23.6. Canonical PostgreSQL volume retained, repository revision unchanged at 592. Existing client chunk-size warning remains; no full-suite or phase-completion claim. + +Change log 2026-10-10: added sticky column filters, Critical-first sorting and shared Context identifiers to the work-package overview. + +## Finding context in every Results Excel export (2026-10-10, Phase 7 Presentation/Disclosure) + +Current user release: Presentation, Disclosure, Core Tests, Platform. User request: include the finding Description and Problem information to explain the exported Results, for both entire and selected-table downloads. A Finding context sheet now accompanies both export paths when explanatory information exists. Actual Results tables remain first, or Results text for a text-only finding; context and other auxiliary sheets follow. The context has the finding title in the existing larger bold white-on-black title band and structured Section / Field / Value columns. + +Presentation reuses the Finding Reader's declared profile mappings and existing GPO problem-summary projection from the opening finding. Description and declared background fields, Result, Affected and Impact retain original native values (including complete impact reference text); Reader evidence statements without a dedicated native field use its existing presentation. GPO context retains Configured, Expected, Additional/Missing entries, Context and Flags from stored comparisons. Unknown profiles accept only explicitly named description/result-message/affected/impact fields; a bare Result without a separate status field is not reinterpreted. No export-row count substitutes for a native Affected value, and selective export retains the complete finding context while restricting evidence tables. Missing values are omitted, never invented. Existing long-value preservation covers context cells, including Unicode boundaries. Work, graph and Reader Results share the same export path. No importer, verdict, scoring, database, engine or Result DB-policy change. + +Patch decision: 2.23.5 from 2.23.4, synchronized package/lock/UI/API contracts. Verified: 19 distinct focused actual-download, export-format, long-text, GPO projection, version and architecture tests; all 504 mandatory import checks including API version; design-language and local-only checks; actual Chrome full/selected downloads in both themes retaining all 1,001 evidence rows and identical original finding context; both production builds passed. Synthetic acceptance proves native Affected 17 remains 17 when two or one evidence rows/tables are exported, missing context stays absent and original records remain unchanged. Existing client chunk-size warning remains. No full-suite or phase-completion claim; unrelated workspace changes preserved and no backend/datastore restart. + +Change log 2026-10-10: added original Description and Problem context to both Results XLSX export scopes. + +## Grouped Work Package sidebar (2026-10-10, Phase 7 Presentation) + +Current user release: Presentation, Core Tests, Platform. User-approved refinement supersedes the per-AP bottom colour stripe: show each Context cluster once as a neutral heading with its existing solid severity colour in a small square at the right, then compact AP rows with right-aligned finding counts. Sort permanent cluster identifiers numerically from 01 and package codes numerically from AP-01. Preserve stable identifiers when a cluster is absent; never renumber the filtered population. Unassigned remains selectable at the end. The Context Cluster facet follows the same identifier order. + +Presentation consumes exact published package cluster IDs, cluster labels and AP codes; no title parsing, new assignment, engine computation or source mutation. The generic dockable panel retains its original callbacks, selected values, disclosure state and keyboard controls; full cluster/package accessible names distinguish repeated AP codes. Published cluster colour stays independent of the amber selection marker, and missing cluster evidence stays neutral. Existing resize/dock behavior and all other filter groups remain intact. + +Patch decision: 2.23.4 from concurrent 2.23.3, synchronized package/lock/UI/API contracts. Verified: 25 focused filter/selection/docking/architecture/UI-version checks, all 504 mandatory import checks including API version, both production builds, design-language and local-only checks, and isolated actual Chrome geometry and keyboard acceptance in both themes. Synthetic actual-sidebar acceptance covers shuffled clusters/APs, first identifier 01, retained counts, one colour per heading, absent-evidence neutral, Unassigned, toggling off and dock/undock selection. Existing client chunk-size warning remains. No backend restart, datastore mutation, full-suite or phase-completion claim; unrelated workspace work preserved. + +Change log 2026-10-10: grouped Work Package filters by numbered Context cluster and replaced bottom stripes with right-hand colour squares. + +## Complete and reconciled work package overview (2026-10-10, Phase 7 Presentation / Remediation display) + +Current user release: Presentation, Platform, Core Tests. User requests an explanation of the visible 274 / 258 / 252 discrepancy and inclusion of Unassigned. The existing overview omitted the published Unassigned bucket and displayed whole-scope package counts independently of active Work filters. It now consumes the exact displayed Work records through the existing read-only extension point, projects each existing package's visible findings and touched published task identities, and states a complete partition: assigned + published Unassigned + records outside the package snapshot = current Work view. Outside-snapshot records are explicitly retained, without guessing a new assignment or changing engine eligibility. Original packages, tasks, identity evidence, counts outside this view, verdicts and stored snapshots remain unchanged. Full-package measures, severity, sources and condition facts retain their original values and are labelled as such. + +Unassigned is always visible as a separate table row, with its finding count and no invented task/package count; the count at the top also selects the exact existing Work Package Unassigned facet. Both paths preserve other Work filters and close the overview. Package counts exclude Unassigned. Additional same-change findings are explained separately from unique tasks. Synthetic actual-view acceptance reproduces 49 packages, 274 records = 258 assigned + 16 Unassigned, and 252 tasks with six additional same-change findings, in both themes; it tests both Unassigned click paths, zero/only-Unassigned, search narrowing to one group member, immutable original records/snapshots and outside-snapshot reconciliation. Existing limits of eight tasks, fifteen for Windows host/GPO, and source-backed redundancy rules remain binding. + +Verified: 29 focused actual-view, remediation and architecture checks, the UI version contract, all 504 mandatory import/API-version checks, both production builds, design-language and local-only checks. Patch decision: 2.23.3 from 2.23.2; package/lock/UI/API version contracts synchronized. Read-only inspection of the existing OpenTAS snapshot at repository revision 592 confirms 49 packages, 258 findings, 252 tasks and 16 Unassigned. Browser acceptance confirms the complete 274 = 258 + 16 partition, both Unassigned click paths selecting exactly 16 Work findings, the narrowed overview showing 0 packages/0 tasks plus 16 Unassigned, and readable Light/Dark rendering; Light Mode and the full overview restored. No full-suite or phase-completion claim; unrelated workspace work preserved. Existing client chunk-size warning remains. + +The subsequently verified concurrent sidebar refinement at 2.23.4 is retained. The restarted local backend reports 2.23.4 with canonical PostgreSQL persistence and unchanged repository revision 592. + +Change log 2026-10-10: included Unassigned and explained complete current-view finding/task counts in the package overview. + +## Results Excel headings and sheet order (2026-10-10, Phase 7 Presentation/Disclosure) + +Current user release: Disclosure, Core Tests, Platform. User decision: each exported Results table begins with a larger bold white heading on a solid black background, and actual Results start at worksheet one; auxiliary information follows all result tables. Both entire and selected Results XLSX exports follow this order. A text-only Result uses its Results text as the first worksheet with the same styled finding heading. + +Each table now starts with a merged title band containing the finding name and table heading, Inter 16pt bold, white text and solid black fill, wrapped with a taller first row. Column headers occupy row two; filtering starts there and excludes the title. Data begins at row three. The table split capacity reserves both title and column-header rows and repeats them per part. Long-value coordinates reflect their physical output cells. Table order, literal strings, exact source cells, empty evidence rows, complete long-text parts and selective-export scope stay preserved. Results text, original section text, export manifest and any long-text parts follow the actual tables. No source data, import contract, Result DB policy, presentation component, persistence or backend lifecycle changes. + +The existing SheetJS writer ignores font/fill cell styles, so the export applies the approved title font/fill/XF directly to its generated OOXML using SheetJS's existing CFB ZIP API. It changes only the generated styles part and the first-row style references; XML is UTF-8 encoded. No new dependency, imported-file rewriting or browser-specific API in Disclosure. Permanent acceptance inspects the final ZIP's exact font, fill and style references, merged heading geometry, larger row height, column-filter range, worksheet order and unchanged table values for both complete and selective exports. Existing 1,001-row real-view tests continue with the added title row. Verified: 15 targeted formatting/export/version/architecture checks, all 504 mandatory import checks including the API-version contract, both production builds, design-language and local-only checks, actual complete/selected browser downloads in both themes, and a read-only full Dummy round-trip retaining all 2,420 tables and 10,208 evidence rows. Long headings receive height according to their wrapped length, within Excel's physical row-height limit. Existing React list-key and client chunk-size warnings remain. No full-suite or phase-completion claim. Patch decision: 2.23.2 from current concurrent workspace 2.23.1; package/lock/UI/API contracts synchronized. Unrelated concurrent changes preserved; no phase-completion claim. + +Change log 2026-10-10: formatted Results XLSX title bands and moved actual Results tables ahead of auxiliary sheets. + +## Cluster identifiers and freely resizable filters (2026-10-10, Phase 7 Presentation) + +Current user release: Presentation, Platform, Core Tests. Explicit user correction supersedes the previous package Severity distribution: each assigned AP now uses one solid colour from its published Context cluster severity, independent of the original findings' severities. Missing/unavailable cluster evidence stays neutral. All current clusters have permanent two-digit display identifiers, shared by the Context Cluster sidebar and their AP rows; filtering, sorting and package selection never renumber them. Numbers identify clusters, never priorities. Existing published read-only results, package boundaries, facet counts, original values and engine independence remain unchanged. + +Floating filters resize from all four edges and corners, with anchored opposite edges, viewport bounds and minimum usable dimensions. Keyboard arrows operate the same grips. Resize gestures never trigger docking; docking, disclosures and exact filter selections remain intact. The shared uppermost normal UI layer keeps filters above activated Results windows. Resize events use stored geometry and ignore temporary 1-by-1 browser capture viewports; permanent synthetic interaction acceptance covers both capture cases, every edge/corner in both directions, docking and repeated activation of real Results windows in both themes. + +Initial patch decision: 2.23.1 from concurrent 2.23.0; the subsequent concurrent Results Excel patch 2.23.2 is retained. Final verification at 2.23.2: 25 focused actual-sidebar, resize/dock/filter, Results stacking, architecture and UI-version checks; all 504 mandatory import/API-version checks; both production builds; design-language and local-only checks. Browser acceptance on synthetic Dummy confirms 54 assigned AP rows with one cluster colour each, matching stable Context numbers, all eight keyboard resize grips, reversible AP selection, hit-testing above newly activated Results and unchanged panel dimensions across screenshots, in both themes. Light Mode restored. Canonical PostgreSQL revision remains 592. Existing client chunk-size warning remains; unrelated concurrent work preserved; no full-suite or phase-completion claim. + +Change log 2026-10-10: replaced package distributions with numbered Context cluster colours and added eight-direction floating filter resizing above Results. + +## Complete Results and Excel exports (2026-10-10, Phase 7 Presentation) + +Current user release: Presentation, Ingest, Disclosure, Core Tests, Platform. User decision: Results exposes every available row; one always-active share icon exports the entire Results content to Excel, and a differently coloured second share icon exports exactly the selected tables. Each Results table has a checkbox; selective export is neutral and disabled until at least one current table is selected. The user explicitly approves distinct export icon colours as an exception to the general button colour role. Selection is local to each Results window; it never changes findings, filters, bookmarks, source values or Result DB disclosure. + +All linked and Markdown rows were already rendered without a 100-row cutoff. No such cutoff or native truncation warning was found in the current complete Dummy. Native HTML tables now also render all retained rows through the shared inert Results table component. No importer contract, source recognition, resource-safety limit, outcome, score or persistence behaviour is changed. An upstream report that omits source rows cannot be reconstructed by presentation/export; its original text is retained, not silently rewritten. + +The complete XLSX contains Results text, original section values and every rendered table; linked table cells come from their original retained source values. GPO variant evidence and collapsed technical details are included. Selective XLSX contains exactly the checked tables with an export manifest. Literal strings retain leading zeros and do not become formulas. Values beyond Excel's 32,767-character limit are preserved in ordered Long text parts without splitting UTF-16 surrogate pairs; tables exceeding Excel's physical row limit split with repeated headers. Export errors produce no partial download. Export reads only the current Results content, never broadens a Result DB query, and uses the existing local browser download adapter. + +Read-only full Dummy round-trip verification: 763 Results with content, 2,420 rendered tables and 10,208 evidence rows across CIS-CAT, Maester, Purple Knight, PingCastle and GPO. Every exported table/row/cell is retained and source objects remain unchanged; selective exports contain exactly one checked table. Largest current Dummy table: 78 rows. Permanent synthetic interaction and real browser download acceptance separately cover linked, Markdown and native HTML tables beyond 100 rows (1,001 rows each), entire prose/source text, explicit selection state, both themes, long Unicode cells and literal formulas. Verified: 90 distinct targeted Results/GPO/CIS-CAT/table/path/version/API/boundary tests, all 504 mandatory import checks, both production builds, design-language and local-only checks, final read-only full Dummy round-trip, and real browser downloads of linked/Markdown/native HTML tables in both themes. Existing React list-key and client chunk-size warnings remain. The specific reported 100-row notice remains unreproduced and needs the affected finding/report to locate its origin; no claim that an unidentified upstream limitation was removed. Minor capability release decision: 2.23.0 from concurrent workspace version 2.22.1; package/lock/UI/API contracts synchronized. Unrelated concurrent changes preserved; no phase-completion claim. + +Change log 2026-10-10: added complete and selected Results XLSX exports and native HTML Results tables without presentation row truncation. + +## Compact Client Info controls, package severity colours and complete group markers (2026-10-10, Phase 7 Presentation) + +Current user release: Presentation, Platform, Core Tests. The three related UI refinements are batched as patch 2.22.1 from 2.22.0. Report buttons precede Source in a shared flexible control row, eliminating the full-width report-only row at suitable viewport widths. Narrow layouts wrap without hiding controls. Work Package sidebar rows show flat proportional segments in existing Severity colours, with readable tooltip/accessibility counts. They count each original Work record under the same filters as the self-excluding Work Package facet, retain unknown severities as unknown and leave package IDs, tasks, ordering, verdicts and selection semantics unchanged. Docked and floating lists share the same rendering. Severity distribution is a display of existing findings, not a new package priority or risk score. + +All members of an active same-change group use the active group's identical filled point, independently of the currently selected reading card. Cards now isolate their stacking context so the reading surface cannot cover their sibling ring. Actual-view synthetic acceptance checks selecting another member while all member points stay filled with the same colour and remain above the card, then restores the original view. Earlier group/Result DB disclosure contracts remain binding. Verified: 41 focused group, heatmap, Client Info control/source, filter-panel, architecture and UI-version checks; all 504 mandatory import/API-version checks; both production builds; design-language and local-only checks. Browser acceptance on synthetic Dummy in both themes verifies all three Print Spooler member points filled identically and hit-testable after changing the reading selection, return to hollow rings, 54 package heatmaps in original Severity colours, and Report buttons to the left of Source with vertically aligned centres (difference below 0.02px). Backend healthy at 2.22.1, canonical PostgreSQL and unchanged repository revision 592. Existing client chunk-size warning remains; no full-suite or phase-completion claim. Unrelated workspace changes remain preserved. + +Change log 2026-10-10: compacted the report/source controls, added package Severity proportions and hardened complete same-change group markers. + +## Related-finding rings and reversible group focus (2026-10-10, Phase 7 Presentation) + +Current user release: Presentation, Platform, Core Tests, Client Records. User decision: mark findings proven to target the same change with a small hollow ring at their upper-right edge, using the Results identity palette. Clicking a ring fills all rings of that exact group and shows the complete group; clicking any filled ring restores the preceding view. This groups redundant members of one published Remediation task, never every task in a package, a shared title or text similarity. Existing Same change as links retain their separate package-filter interaction. + +Implemented: a Core Presentation focus coordinator consumes published Remediation results through an explicitly pinned read-only extension point. Work records from the current client/timeslot aggregate resolve source-sheet aliases by their established identities. Group focus temporarily overrides search/status/facets/path/My Workspace for display without changing original values, verdicts, scores, reviews, bookmarks, risk decisions or Result DB rows. The original filters, sheet, sort, selection, view, navigation area, scroll positions and registered local view state are restored. Scope changes clear focus; engine-off restores the original display and suppresses rings. Rings cover shared list/tile cards, Finding Reader, Work grid, structured Raw Data and original-source navigation, Pathfinder nodes/details/matrix and finding-bound Results windows. Accepted Risks rings open the complete group in Work, retaining Client Info for the return; risk-register counts and decisions retain their authoritative semantics. During group focus the Reader receives the displayed aggregate records, allowing its existing Same change as links to select visible peers across sources; outside focus those links keep their existing filter restrictions. Result DB evidence projections retain their separate disclosure contract and are not original Finding cards. + +Verified minor capability release: 2.22.0 after the concurrent 2.21.9 Results-evidence correction, with synchronized package/lock/UI/API version contracts. Six permanent synthetic actual-view interactions cover both themes: list/tile/grid/raw, passed peers despite existing filters, source aliases and Reader peer links, exact prior filters/view/selection/scroll, engine-off, unchanged reviews/risk counts, Accepted Risks return, and Pathfinder selection/camera with zoom-independent rings. Related Reader, card, tile, risk, dashboard, tab, package, engine-boundary and version checks pass; all 504 mandatory import checks and both production builds pass. Design-language and local-only security checks pass. Browser verification on Dummy confirms hollow and filled rings in both themes, all three cross-scanner Print Spooler members, return to the previous Work list and 478 Pathfinder findings after group focus. Pathfinder rings retain a 28px click target and 16px glyph at overview zoom; the camera returns within 0.004px (synthetic camera restoration is exact). Backend healthy at 2.22.0 with canonical PostgreSQL and unchanged repository revision 592. Existing client chunk-size warning remains. The shared finite Results palette repeats after sixteen group colours; the click always selects the exact task identity. Existing workspace work remains preserved; no full-suite or phase-completion claim. + +Change log 2026-10-10: added same-change finding rings and reversible group focus across interactive original-finding views. + +## Client Info tabs remain visible while scrolling (2026-10-10, Phase 7 Presentation) + +Current user release: Presentation, Platform, Core Tests. User decision: Client Info tabs must remain visible when scrolling. The shared tab container now occupies the available viewer height; its non-shrinking tablist stays outside the independently scrolling active panel. Existing tab heights, flush left alignment, inactive gap and shadow-free surfaces remain. Mounted panels retain their scroll position through mouse and keyboard tab switching. No metric, source-data or engine behavior changes. + +Patch decision: 2.21.8 from 2.21.7, synchronized package/lock/UI/API version contracts. Verified: nine focused Client Info/tab/UI-version tests, all 504 mandatory import checks including API-version contracts, both production builds, design-language and local-only checks. Permanent synthetic interaction acceptance covers panel scrolling and Security/Home switching in both themes. Browser verification on Dummy at the panel end confirms the tablist top remains 133.98px, the outer viewer stays at scrollTop zero and Overview retains scrollTop 2365.67px after switching to Security and back; both themes checked. Light/Overview restored. Backend healthy at 2.21.8 with PostgreSQL and unchanged repository revision 592. Existing client chunk-size warning remains; no full-suite or phase-completion claim. + +Change log 2026-10-10: kept Client Info tabs visible by moving scrolling into their associated panels. + +## Results: proven affected evidence (2026-10-10, Phase 7 Presentation) + +Current user release: Presentation, Ingest, Core Tests, Platform. User request: red Results backgrounds only where the native report proves affected evidence; use the whole Dummy. Existing GPO per-value comparison, CIS-CAT evaluated-row semantics and all protected scanner import contracts remain binding. Red denotes scanner-reported affected evidence, never a claim that every contextual cell is an incorrect configuration. + +Read-only full Dummy audit through the current workspace port: 885 scanner findings (411 CIS-CAT, 318 Maester, 119 Purple Knight, 37 PingCastle) plus 948 derived GPO Work records; 2,391 scanner linked-table deliveries and 292 Maester inline rows. Exact reviewed native rule/layout guards now mark only PingCastle's own import/rule-bound embedded Details (23 Dummy rules, 90 rows; additionally synthetic S-SMB-v1) and Purple Knight's exact indicator-bound source tables with Ignored=False (20 layouts, 46 rows). PingCastle's 394 linked inventory/context rows stay neutral. Unknown rules, new headers, missing/foreign scope, ignored objects and non-fail parent outcomes fail closed. No title similarity, arbitrary boolean or score inference. New scanner formats are not introduced; parser/recognition/canonical/scoring semantics and existing source data are unchanged. Existing securityScoring declarations remain authoritative: native PingCastle overall risk and existing Purple Knight/Maester policies retain priority/fallback through the shared engine. + +Maester's inline Markdown and HTML-wrapped Markdown result tables now receive the existing profile status colors through a generic RichTextValue callback: explicit status columns or source-written glyphs only, contradictory/missing markers neutral. Dummy: 230 fail rows red, 35 pass rows green, 27 neutral. Existing CIS-CAT colors retain 87 violations, 123 matches, 466 criterion and 327 highlighted rows; 8,382 contextual rows stay neutral. GPO values keep their existing per-value/variant proofs. Results retain their opening finding as read-only UI context so changing Work filters cannot lose the marking in an already open window. No data write, reimport, migration or backend restart. + +Permanent synthetic actual Work → Results interaction acceptance covers S-SMB-v1, neutral inventory, finding hidden by filters, retained originals and both themes. Additional actual Results and negative checks cover inline mixed Maester status, contradictory glyphs, names that look like statuses, exact Purple Knight identity/ignored/source guards and unknown/foreign PingCastle layouts. Verified: 116 distinct targeted Results/CIS-CAT/GPO/graph/path/version/boundary tests, all 504 mandatory import checks, both production builds, local-only checks and actual Results browser color acceptance in both themes. The latest global design-language check is blocked by an unrelated concurrent inline style in src/features/related-findings/RelatedFindings.tsx; that main-thread change remains untouched. Existing client chunk-size warning remains. Patch decision: 2.21.9 from current 2.21.8, package/lock/UI/API version contracts synchronized. Concurrent workspace changes preserved; no full-suite or phase completion claim. + +Change log 2026-10-10: added source-proven red affected-evidence rows and inline native status colors to Results. + +## Finding Reader Work Package alignment (2026-10-10, Phase 7 Presentation) + +Current user release: Presentation, Platform, Core Tests. User decision: Work Package information always aligns to the Reader's right content edge, including wrapped package names, task details and redundant-finding links. The existing Source Rule ID stays on the left. The common source/package row now also hosts records without a Source Rule ID; ready, loading/unavailable and unassigned package states all use the same right alignment and safe text wrapping. The Remediation Engine and its selection/filter behavior are unchanged. Permanent synthetic actual-Reader acceptance covers both themes, with/without Source Rule ID, ready/loading/unassigned states, retained record values and package-link clicks. Patch decision: 2.21.7 from the concurrent workspace version 2.21.6, preserving the separate MITRE release. Verified: 34 targeted Reader/package/UI-version tests, all 504 mandatory import checks including API-version contracts, both production builds, design-language and local-only checks. Browser verification on synthetic Dummy in light and dark confirms the wrapped package/task block reaches exactly the source row's right content edge; Source Rule ID remains left. Light restored. Backend healthy at 2.21.7 with canonical PostgreSQL and unchanged repository revision 592. Existing client chunk-size warning remains; no full-suite or phase-completion claim. + +## Finding Reader MITRE disclosure (2026-10-10, Phase 7 Presentation) + +Current release: Presentation, Core Tests, Platform. User decision: MITRE ATT&CK and D3FEND information starts collapsed; a small triangle only reveals/hides the existing badges, with no box, border, extra heading or panel. The keyed disclosure resets on finding changes, including returning to a previously opened finding. Search/review updates for the same finding preserve an explicitly opened disclosure. Findings with no MITRE badges show no toggle. Original badge values, other badges, Context and primary actions remain unchanged. + +Implemented using the shared TriangleIcon, a native accessible button and transparent token-based styles. Permanent synthetic actual-Reader interaction tests cover default collapse, opening/closing, retained source values, search highlighting, finding change/return and no-information state in both themes. Browser acceptance covers Enter/Space, transparent surfaces without borders/shadows, and retained action alignment at 1200/620/320px. Final verification: 25 focused Reader tests plus 17 UI-version/Context tests and the browser check pass; all 504 mandatory import tests (including API-version contracts), both production builds, design-language and local-only checks pass. Patch decision: 2.21.6 from current workspace 2.21.5, synchronized package/lock/UI/API version contracts. Existing client chunk-size warning remains. Concurrent Client Info changes preserved; no backend restart, full-suite or phase-completion claim. + +Change log 2026-10-10: made MITRE badges an initially collapsed, frameless triangle disclosure. + +## Finding Reader action group (2026-10-10, Phase 7 Presentation) + +Current user release: Presentation, Governance, Core Tests, Platform. Binding decision added to AGENTS.md: AI search, bookmark icon and Results always stay in one horizontal non-wrapping group in that order; Context badges may wrap outside that group. Bookmark actions carry no visible label, while accessible names/tooltips remain. View snapshot naming now says Snapshot. Existing search availability, Results enablement and review semantics remain unchanged. + +Implemented in the existing FindingReader and shared styles with a dedicated action group, icon-only accessible bookmark button and shared spacing. Permanent synthetic interaction acceptance covers both themes, resizing, toggle on/off and Results opening. The standalone synthetic browser geometry check verifies 1200/620/320px in both themes, same vertical alignment, ordered separation and viewport containment. Final verification: 58 targeted Reader/Context/Bookmark/UI/API tests pass, all 504 mandatory import compatibility/scoring/transport tests pass, both production builds and design-language/local-only checks pass. Version decision: patch 2.21.4 from current 2.21.3 for this backward-compatible correction; package/lock/UI/API contracts synchronized. Existing client chunk-size warning remains. No full-suite or phase-completion claim. Concurrent workspace changes preserved; no runtime restart requested in this side conversation. + +Change log 2026-10-10: permanently anchored the ordered Reader action group and removed visible Bookmark wording. + +## Client Info tab spacing correction (2026-10-10, Phase 7 Presentation) + +Current user release: Presentation, Platform, Core Tests. The user explicitly supersedes the shadow/recess decisions below: Client Info tabs become taller, the first tab aligns with the panel's left edge, and every inactive tab leaves a visible gap below its lower edge instead of any shadow. Active tabs stay connected to their panel. Shared tokens define 36px active height and a 4px gap (32px inactive height), with aligned top edges, the existing neutral chrome and the unchanged 20px content inset. The common ClientInfoTabs presentation applies this to all future Client Info tabs. Removed the obsolete shadow and recess tokens in both themes. No new tab content or finding/data semantics. Patch decision: 2.21.5 from the concurrent workspace version 2.21.4; preserve the separate Reader release. Verified: all 504 mandatory import checks, eight targeted Client Info/tab/UI/API version tests, both production builds, design-language and local-only checks. Browser verification on synthetic Dummy in light/dark measures 36px active height, 32px inactive height and a 4px inactive gap; the first tab and panel have the same left coordinate, and both tabs have no shadow. Selecting Security transfers the gap to Overview while Security joins its empty panel; keyboard Home restores Overview. Light/Overview restored. Backend healthy at 2.21.5 with canonical PostgreSQL and unchanged repository revision 592. Existing client chunk-size warning remains; no full-suite or phase-completion claim. + +## Client Info attached tabs (2026-10-10, Phase 7 Presentation) + +Current user release: Presentation, Platform, Core Tests. User-approved standard for existing and future Client Info tabs: attached tabs above their associated panel, Overview first and initially selected, Security initially an empty panel. The shared `ClientInfoTabs` component owns presentation, ARIA tab/panel associations and Arrow/Home/End navigation; Overview stays mounted and hidden when another panel is selected, preserving its local state and existing figures/drilldowns. New Client Info tabs use this same component. + +Explicit user design exception: inactive Client Info tabs alone receive subtle inset shading at their lower edge to appear behind the active panel. The active tab joins its panel without shading. This narrowly supersedes the general no-button-shadow rule for this edge only; shared theme surfaces, Inter, control geometry and panel inset remain binding. No separate panel, tile or donut shadow is authorized. + +Version decision: patch 2.21.2 from 2.21.1 for a backward-compatible Client Info navigation/presentation refinement, with package, lock entry and UI/API version contracts synchronized. Verified: 20 focused UI/layout/version tests, including actual DashboardPanel switching and retained figures in both themes; all 504 mandatory import compatibility/scoring/transport tests; both production builds; design-language and local-only checks. Initial concurrent checks hit existing import-test deadlines; the complete unchanged import gate passed when repeated alone. The final chrome-surface correction also passed the complete gate and both builds. Browser verification on synthetic Dummy in light and dark: attached tab geometry, inactive lower-edge shading, active tab without shadow, empty Security panel, mouse/keyboard switching and return to Overview (Overall Score 36, deviations 478 retained). Light-theme Overview restored. Backend restarted on the canonical PostgreSQL datastore, healthy at 2.21.2 with repository revision 592 unchanged. Existing client chunk-size warning remains. No full-suite or phase-completion claim. + +Follow-up user correction 2026-10-10 (renewed Presentation, Platform, Core Tests release): the initial edge shading was too subtle, especially in dark mode. Inactive tabs now have a two-pixel reduced height, aligned to the same panel baseline, and a broader, stronger inset lower-edge shadow. Theme-specific values of the same edge-shadow token keep it legible in both themes; active tabs keep the full shared control height and no shadow. This is the agreed refinement of the existing narrow shadow exception and remains the standard for future Client Info tabs. Patch version 2.21.3, package/lock/UI/API contracts synchronized. Verified: all 504 mandatory import tests, eight targeted tab/DashboardPanel/UI/API version tests, both production builds, design-language and local-only checks. Browser checks on synthetic Dummy in both themes measure active height 28px versus inactive 26px with the same bottom edge; switching tabs transfers the recess/shadow to the newly inactive tab and keyboard Home returns to Overview. Dark/light shadow values verified, Overview/light restored. Backend healthy at 2.21.3 on canonical PostgreSQL, repository revision 592 unchanged. Existing bundle warning remains; no full-suite or phase-completion claim. + +## Protected scanner import contracts (2026-10-09, Phase 13A Ingest) + +User decision: working scanner imports are crown jewels; protect established behavior permanently and add new formats without replacing old ones. Current explicit Core release: Ingest, Core Tests, Platform, Governance. Use only Dummy/synthetic inputs. No additional engine, nested Core or Maester worker is introduced. + +Implemented: `tests/importers/import-contracts.json` registers 18 variants across all five current profiles, using all existing synthetic scanner fixtures and a new synthetic Maester multi-tenant fixture. Normalized fixture checksums prevent unnoticed replacement; the inventory test preserves initial contract IDs and requires coverage of every profile and synthetic fixture. Browser and Node imports must agree through recognition, preview, mapping approval and canonical creation. Readable committed snapshots protect identity, original values, field roles, outcomes/execution states, severity, evidence and its finding association, exact source positions, source tables, report sections, environment observations and scoring. Generated record links/import timestamps and processing versions are excluded where applicable, without recursively changing retained native objects. Existing targeted behavioral assertions remain required alongside snapshots. + +Release enforcement: `scripts/check-import-contracts.mjs` runs the importer/scoring and HTTP/worker suites plus GPO/GPP ingestion/parser checks in CI mode. Missing/changed snapshots fail; no update arguments are forwarded. `npm test` runs this gate before the full suite; `npm run build` invokes it through prebuild. AGENTS.md now makes compatibility and non-bypass binding, including helper/dependency/Platform changes. `docs/IMPORT_COMPATIBILITY.md` describes safe additive registration, deliberate change approval and coverage limits. Existing expectations may not be regenerated merely to pass a regression. + +Verification: the 18-format contract suite passes (20 checks). The complete mandatory import/GPO gate passes 504 tests in 68 files, both standalone through npm test and automatically before the production build. Both client and server production builds pass; the existing client chunk-size warning remains. Deliberately modifying a synthetic fixture, changing an expected canonical result, and removing snapshot contents all cause nonzero exits; CI mode does not generate a missing baseline. Every mutated file was restored byte-for-byte. A first integrated gate attempt caught the Maester HTTP cold-worker test exceeding its 5-second limit on this synchronized Windows drive; only that test gets a bounded 15-second window, retaining every semantic assertion. The full suite has 2032 passed, 15 skipped and one failed test: `tests/features/datahub-centered-layout.test.ts:89`, an existing literal JSX expectation incompatible with the previously modified Data Hub percentage controls. The test and Presentation behavior were not changed in this batch. Full release remains blocked by that unrelated failure; no clean full-suite or phase-completion claim. Local-only and design checks pass independently. Backend health is healthy on the canonical PostgreSQL datastore after its automatic build restart. + +Limits/risks: registered variants and behavioral suites protect known inputs, not unknown future vendor formats. An exploratory raw Maester JSON shape selected generic recognition; it was not silently made a supported Maester contract or repaired in this scope. There is no configured external CI provider; future CI must use the same required entry points. Repository writers can deliberately alter gates, so Governance consent/review remains necessary. No customer data, production import commit, datastore migration or parser runtime modification in this batch. Phase 13A remains open. + +Version decision: this batch started at 2.20.2 and makes no product version increment: it adds tests, test/release tooling and governance/documentation without changing application import semantics or the deployment contract. During final checks a concurrent workspace change advanced package and UI/API version contracts to 2.21.0; live health confirms 2.21.0. That change is preserved and is not attributed to this protection batch. Historical reports remain unchanged. + +Change log 2026-10-09: established additive scanner import contracts, immutable synthetic references, browser/backend result baselines and a mandatory failure-blocking import gate in test/build entry points. + +## Maester HTML data binding correction (2026-10-09, Phase 13A Ingest) + +Current user release: Ingest, Core Tests, Platform. User constraint: use only Dummy/synthetic data; do not request or read customer reports. Public upstream evidence: [Maester report data loader](https://github.com/maester365/maester/blob/main/report/src/lib/reportData.ts) and [ReportTemplate.html](https://github.com/maester365/maester/blob/main/powershell/assets/ReportTemplate.html), checked 2026-10-09. The template embeds `const testResults=` inside ` + diff --git a/fixtures/maester/multi-tenant.synthetic.html b/fixtures/maester/multi-tenant.synthetic.html new file mode 100644 index 0000000..f9d1c9d --- /dev/null +++ b/fixtures/maester/multi-tenant.synthetic.html @@ -0,0 +1,6 @@ +Maester Dummy multi-tenant report +

Synthetic regression fixture only. No customer data.

+ diff --git a/package-lock.json b/package-lock.json index a8ba42a..9fff2a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "assessor", - "version": "2.16.5", + "version": "2.24.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "assessor", - "version": "2.16.5", + "version": "2.24.2", "dependencies": { "@fontsource-variable/inter": "^5.3.0", "jsdom": "^29.1.1", diff --git a/package.json b/package.json index a8c3bf5..4f0d1c1 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "assessor", - "version": "2.16.5", + "version": "2.24.2", "private": true, "type": "module", "scripts": { "dev": "node scripts/dev-local.mjs", "dev:client": "vite --config vite.client.config.ts --host 127.0.0.1", + "prebuild": "npm run test:imports", "build": "npm run build:client && npm run build:server", "build:client": "tsc -p tsconfig.app.json --noEmit && vite build --config vite.client.config.ts", "build:server": "tsc -p tsconfig.server.json --noEmit && vite build --config vite.server.config.ts", @@ -15,7 +16,8 @@ "db:down": "docker compose --env-file .env.postgres -f docker-compose.postgres.yml down", "db:logs": "docker compose --env-file .env.postgres -f docker-compose.postgres.yml logs -f postgres", "test:postgres": "vitest run --config vitest.config.ts tests/postgres/postgres-repository.integration.test.ts && vitest run --config vitest.config.ts tests/postgres/gpo-backup-management.integration.test.ts tests/postgres/policy-analysis.integration.test.ts", - "test": "vitest run --config vitest.config.ts && node tests/local-only-check.mjs && node tests/design-language-check.mjs", + "test:imports": "node scripts/check-import-contracts.mjs", + "test": "npm run test:imports && vitest run --config vitest.config.ts && node tests/local-only-check.mjs && node tests/design-language-check.mjs", "test:security": "node tests/local-only-check.mjs", "test:design": "node tests/design-language-check.mjs", "test:watch": "vitest --config vitest.config.ts" diff --git a/scripts/check-import-contracts.mjs b/scripts/check-import-contracts.mjs new file mode 100644 index 0000000..e88b859 --- /dev/null +++ b/scripts/check-import-contracts.mjs @@ -0,0 +1,25 @@ +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +// CI mode makes missing snapshots fail. Never forward --update or automatically +// accept new expectations: a failing established import blocks the release. +const root = fileURLToPath(new URL('../', import.meta.url)); +const result = spawnSync(process.execPath, [ + fileURLToPath(new URL('../node_modules/vitest/vitest.mjs', import.meta.url)), + 'run', '--config', 'vitest.config.ts', + 'tests/importers', 'tests/scoring', + 'tests/server/node-import-engine.test.ts', + 'tests/server/import-worker-client.test.ts', + 'tests/server/import-base64.test.ts', + 'tests/server/import-batch-http.test.ts', + 'tests/server/assessor-http-server.test.ts', + 'tests/server/gpo-', 'tests/server/gpp-', + 'tests/server/registry-policy-parser.test.ts', + 'tests/server/security-template-parser.test.ts', + 'tests/server/group-policy-preference-parser.test.ts', + 'tests/server/advanced-audit-parser.test.ts', + 'tests/server/admx-policy-catalog.test.ts', + 'tests/domain/gpo-ingestion.test.ts', +], { cwd: root, env: { ...process.env, CI: 'true' }, stdio: 'inherit' }); +if (result.error) console.error(result.error.message); +process.exit(result.status ?? 1); diff --git a/server/assessor-http-server.ts b/server/assessor-http-server.ts index 2d8c146..725d365 100644 --- a/server/assessor-http-server.ts +++ b/server/assessor-http-server.ts @@ -45,6 +45,11 @@ import { ContextStateChangedError, createAssessmentContext } from '../src/assess import { publishContextReport } from '../src/assessment-context/context-report-publication'; import { ReportContextRequiredError } from '../src/reporting/report-context'; import type { AssessmentContext } from '../src/assessment-context/context-engine'; +import { ASSESSMENT_CONTEXT_ENGINE } from '../src/assessment-context/context-engine'; +import { ASSESSMENT_CONTEXT_CATALOG } from '../src/assessment-context/context-catalog'; +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 { 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'; @@ -135,6 +140,8 @@ export interface AuditEvent { } export interface AssessorHttpServerOptions { + remediationStore?: RemediationStore | null; + remediationPort?: RemediationReadPort | null; artifactStore?: BackendArtifactStore; mitreCatalogService?: MitreCatalogService; repository: AssessorRepository; @@ -400,6 +407,8 @@ async function serveStatic(request: IncomingMessage, response: ServerResponse, s export function createAssessorHttpServer(options: AssessorHttpServerOptions): Server { 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 clientInformation = clientInformationReadPortFrom(repository, () => repository.getCommittedRevision?.() ?? Promise.resolve(repository.revision)); const reportPort = options.reportPort === undefined @@ -997,6 +1006,61 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se }); status = 200; sendJson(response, status, buildReportProjection(input), requestId); return; } + if (segments[0] === 'projects' && segments[1] && segments[2] === 'work-packages' && segments.length === 3 && method === 'GET') { + if (!remediationStore || !remediationPort) throw new HttpError(503, 'remediation_unavailable', 'Remediation Engine is not available.'); + const projectId = segments[1]; + if (!await remediationPort.getProject(projectId)) throw new RepositoryNotFoundError(`Projekt ${projectId} ist nicht vorhanden.`); + const assessmentIds = [...new Set(url.searchParams.getAll('assessmentIds'))].sort(); + const scanRunIds = [...new Set(url.searchParams.getAll('scanRunIds'))].sort(); + if (!assessmentIds.length) throw new HttpError(400, 'missing_assessment_scope', 'At least one assessment ID is required.'); + const revision = await remediationPort.currentRevision(); + const contextKey = JSON.stringify([revision, projectId, assessmentIds, scanRunIds]); + const context = assessmentContextCache.get(contextKey); + const key = JSON.stringify([contextKey, REMEDIATION_ENGINE_VERSION, REMEDIATION_CATALOG_VERSION, + ASSESSMENT_CONTEXT_ENGINE.version, ASSESSMENT_CONTEXT_CATALOG.version]); + const stored = await remediationStore.read(key); + if (stored) { status = 200; sendJson(response, status, { status: 'ready', result: stored }, requestId); return; } + if (!context) { status = 200; sendJson(response, status, { status: 'context-required' }, requestId); return; } + // Core-owned adapter: the engine receives only Work records through its declared read port. + const workspace = await loadCanonicalProjectWorkspace({ + listProjectAssessments: id => remediationPort.listAssessments(id), + loadAssessmentModel: async id => { + const model = await remediationPort.loadAssessmentModel(id); + if (!model || model.project.id !== projectId) throw new RepositoryNotFoundError(`Assessment ${id} is not in this project.`); + return model; + }, + queryUnifiedResults: query => remediationPort.queryUnifiedResults(query), + listPolicyEvaluations: async id => ({ records: await remediationPort.listPolicyEvaluations(id) }), + ...(remediationPort.listCurrentPolicyEvaluationsForMetrics ? { listCurrentPolicyEvaluationsForMetrics: async (id: string) => ({ records: await remediationPort.listCurrentPolicyEvaluationsForMetrics!(id) }) } : {}) + }, projectId, scanRunIds.length ? scanRunIds : undefined, assessmentIds, { includeHistoricalScores: false }); + const findings: RemediationFinding[] = []; + const seen = new Set(); + for (const sheet of workspace.dataset?.sheets ?? []) { + const profile = profileForSheet(workspace.dataset!, sheet); + for (const record of sheet.analysis.records) { + if (!profile.matchesStatus(record, 'deviation')) continue; + const field = (label: string) => record.fields.find(f => f.label.trim().toLowerCase() === label)?.value.trim() ?? ''; + const identityKey = field('technical identity'); + const scope = field('scope'); + const key = record.findingOccurrenceId ? scannerFindingKey(record.findingOccurrenceId) : identityKey && scope ? policyFindingKey(scope, identityKey) : ''; + if (!key || seen.has(key)) continue; + seen.add(key); + const canonical = workspace.models.flatMap(m => m.findingOccurrences).find(o => o.id === record.findingOccurrenceId); + findings.push({ key, occurrenceId: record.findingOccurrenceId, identityKey, scope, + tool: canonical?.sourceIdentity.tool.id ?? sheet.toolId ?? '', + ruleId: canonical?.sourceIdentity.originalRuleId ?? field('source rule id'), title: record.title, + outcome: canonical?.outcome?.normalizedValue ?? 'deviation', + // Native severity as normalized by the Core; GPO settings carry none and stay without. + ...(canonical?.severity?.normalizedValue ? { severity: canonical.severity.normalizedValue } : {}), + remediation: canonical ? workspace.models.flatMap(m => m.findingDefinitions).find(d => d.id === canonical.definitionId)?.recommendation + : record.policyBaselineComparisons?.map(c => c.remediation).filter(Boolean).join('\n') }); + } + } + if (await remediationPort.currentRevision() !== revision) throw new HttpError(409, 'remediation_state_changed', 'The data changed while preparing work packages. Reload the scope.'); + const result = buildWorkPackages({ projectId, assessmentIds, scanRunIds, findings, context }); + await remediationStore.save(key, result); + status = 200; sendJson(response, status, { status: 'ready', 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(); @@ -1025,8 +1089,10 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se const run = await createReportRun(port, { projectId: segments[1], generatedAt: startedAt.toISOString(), assessmentIds: ids(body.assessmentIds), scanRunIds: ids(body.scanRunIds) - }, body.kind === 'management' ? { - requireContext: true, + }, { + // Both reports read the same published Context (user decision 2026-10-08): the Management Report + // requires it, the Technical Report renders its problem fields when it is available. + requireContext: body.kind === 'management', readContext: async scope => { const assessmentIds = [...new Set(scope.assessmentIds)].sort(); const scanRunIds = [...new Set(scope.scanRunIds ?? [])].sort(); @@ -1039,7 +1105,7 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se } return publishContextReport(context); } - } : { requireContext: false }); + }); const manifest = await options.reportArchive.write(run, startedAt); const result: ReportRunResponse = { runId: manifest.runId, generatedAt: run.projection.generatedAt, repositoryRevision: run.revision, kind: body.kind, diff --git a/server/index.ts b/server/index.ts index 8a6b8e3..8a2670d 100644 --- a/server/index.ts +++ b/server/index.ts @@ -20,6 +20,7 @@ import { loadPolicyEvaluationRuleCatalog } from './gpo/policy-evaluation-rule-ca import { MitreCatalogService } from './reference-data/mitre-catalog-service'; import { FileSystemReportArchive, resolveReportArchiveRoot, type ReportArchiveEnvironment } from './reporting/report-archive'; import { createPostgresReportReadRuntime, memoryReportReadRuntime, type ReportReadRuntime } from './reporting/report-read-runtime'; +import { createRemediationRuntime } from './remediation/remediation-runtime'; function boundedInteger(value: string | undefined, fallback: number, minimum: number, maximum: number, name: string): number { if (value === undefined || value.trim() === '') return fallback; @@ -60,6 +61,9 @@ async function main(): Promise { expectedSha256: process.env.ASSESSOR_POLICY_RULE_CATALOG_SHA256 }); const runtime = await createRepositoryRuntime(); + let remediationRuntime: Awaited> | undefined; + try { remediationRuntime = await createRemediationRuntime(process.env as RepositoryEnvironment, runtime.repository); } + catch (error) { console.warn('Remediation Engine not available:', error instanceof Error ? error.message : 'Unknown error'); } // The Report Engine runs beside ASSESSOR with its own read-only database reader and archive. // If it cannot start, ASSESSOR keeps running and only report runs are refused. let reportRuntime: ReportReadRuntime | undefined; @@ -123,6 +127,8 @@ async function main(): Promise { persistence: runtime.persistence, reportPort: reportRuntime?.port ?? null, reportArchive, + remediationStore: remediationRuntime?.store ?? null, + remediationPort: remediationRuntime?.port ?? null, staticRoot, releaseId: process.env.ASSESSOR_RELEASE_ID?.trim() || undefined, developmentInstanceId: process.env.ASSESSOR_DEVELOPMENT_INSTANCE_ID?.trim() || undefined, @@ -152,6 +158,7 @@ async function main(): Promise { server.close(async error => { try { await reportRuntime?.close(); + await remediationRuntime?.close(); await runtime.close(); } catch (closeError) { console.error(closeError); diff --git a/server/remediation/migration.sql b/server/remediation/migration.sql new file mode 100644 index 0000000..efa11dc --- /dev/null +++ b/server/remediation/migration.sql @@ -0,0 +1,8 @@ +-- Remediation Engine storage v1: derived flags and package definitions only. +CREATE SCHEMA IF NOT EXISTS assessor_remediation; +CREATE TABLE IF NOT EXISTS assessor_remediation.package_snapshots ( + scope_key text PRIMARY KEY, + data jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CHECK (jsonb_typeof(data->'packages') = 'array') +); diff --git a/server/remediation/remediation-runtime.ts b/server/remediation/remediation-runtime.ts new file mode 100644 index 0000000..fd4d088 --- /dev/null +++ b/server/remediation/remediation-runtime.ts @@ -0,0 +1,26 @@ +import { Pool } from 'pg'; +import { repositoryConnectionString, sslConfiguration, type RepositoryEnvironment } from '../repository-factory'; +import { MemoryRemediationStore, PostgresRemediationStore, type RemediationStore } from './remediation-store'; +import migration from './migration.sql?raw'; +import { PostgresAssessorRepository } from '../postgres/postgres-assessor-repository'; +import { remediationReadPortFrom, type RemediationReadPort } from '../../src/remediation/remediation-read-port'; +import type { AssessorRepository } from '../../src/persistence/assessor-repository'; + +export async function createRemediationRuntime(environment: RepositoryEnvironment, repository: AssessorRepository): Promise<{ store: RemediationStore; port: RemediationReadPort; close(): Promise }> { + const connectionString = repositoryConnectionString(environment); + if (!connectionString) return { store: new MemoryRemediationStore(), port: remediationReadPortFrom(repository, async () => repository.revision), async close() {} }; + const hostname = new URL(connectionString).hostname; + const pool = new Pool({ connectionString, max: 2, application_name: 'assessor-remediation-writer', + ssl: sslConfiguration(environment.ASSESSOR_DATABASE_SSL ?? (['localhost', '127.0.0.1', '[::1]'].includes(hostname) ? 'disable' : 'require')) }); + const reader = new Pool({ connectionString, max: 2, application_name: 'assessor-remediation-reader', + options: '-c default_transaction_read_only=on', ssl: pool.options.ssl }); + try { + await pool.query(migration); + const readRepository = await PostgresAssessorRepository.openReadOnly(reader); + const port = remediationReadPortFrom(readRepository, async () => { + const response = await reader.query<{ revision: string }>('SELECT revision FROM assessor_repository_state WHERE singleton = true'); + return Number(response.rows[0]?.revision ?? -1); + }); + return { store: new PostgresRemediationStore(pool), port, async close() { await Promise.all([pool.end(), reader.end()]); } }; + } catch (error) { await Promise.all([pool.end(), reader.end()]); throw error; } +} diff --git a/server/remediation/remediation-store.ts b/server/remediation/remediation-store.ts new file mode 100644 index 0000000..191bb75 --- /dev/null +++ b/server/remediation/remediation-store.ts @@ -0,0 +1,26 @@ +import type { Pool } from 'pg'; +import type { RemediationResult } from '../../src/remediation/remediation-engine'; + +export interface RemediationStore { + read(scopeKey: string): Promise; + save(scopeKey: string, result: RemediationResult): Promise; +} +export class MemoryRemediationStore implements RemediationStore { + private readonly results = new Map(); + async read(key: string) { const result = this.results.get(key); return result ? structuredClone(result) : undefined; } + async save(key: string, result: RemediationResult) { this.results.set(key, structuredClone(result)); } +} + +/** All writes belong to the separate engine schema. No Core table is read or written. */ +export class PostgresRemediationStore implements RemediationStore { + constructor(private readonly pool: Pool) {} + async read(key: string): Promise { + const response = await this.pool.query<{ data: RemediationResult }>( + 'SELECT data FROM assessor_remediation.package_snapshots WHERE scope_key = $1', [key]); + return response.rows[0]?.data; + } + async save(key: string, result: RemediationResult): Promise { + await this.pool.query('INSERT INTO assessor_remediation.package_snapshots (scope_key, data) VALUES ($1, $2::jsonb) ON CONFLICT (scope_key) DO NOTHING', + [key, JSON.stringify(result)]); + } +} diff --git a/src/api/assessor-api-client.ts b/src/api/assessor-api-client.ts index 6f9be05..685ff83 100644 --- a/src/api/assessor-api-client.ts +++ b/src/api/assessor-api-client.ts @@ -186,6 +186,13 @@ export class AssessorApiClient { return this.request('/projects'); } + getWorkPackages(projectId: string, assessmentIds: string[], scanRunIds: string[] = []): Promise { + const query = new URLSearchParams(); + assessmentIds.forEach(id => query.append('assessmentIds', id)); + scanRunIds.forEach(id => query.append('scanRunIds', id)); + return this.request(`/projects/${encodeURIComponent(projectId)}/work-packages?${query.toString()}`); + } + saveProject(project: AssessmentProject): Promise<{ project: AssessmentProject; repositoryRevision: number }> { return this.request('/projects', { method: 'POST', body: JSON.stringify(project) }); } diff --git a/src/api/assessor-api-contract.ts b/src/api/assessor-api-contract.ts index df83cb8..1eccce8 100644 --- a/src/api/assessor-api-contract.ts +++ b/src/api/assessor-api-contract.ts @@ -17,6 +17,8 @@ import type { PolicyEvaluationRuleCatalog } from '../domain/policy-evaluation-ru import type { PolicyAnalysisOverview } from '../domain/policy-analysis-overview'; import type { MitreGraphData } from '../features/mitre-graph/mitre-graph'; import type { AssessmentContext } from '../assessment-context/context-engine'; +import type { RemediationResult } from '../remediation/remediation-engine'; +export type WorkPackagesResponse = { status: 'ready'; result: RemediationResult } | { status: 'context-required' }; export const ASSESSOR_API_PREFIX = '/api/v1'; export const ASSESSOR_MUTATION_HEADER = 'X-Assessor-Request'; diff --git a/src/api/request-activity.ts b/src/api/request-activity.ts index afb7b31..1008f84 100644 --- a/src/api/request-activity.ts +++ b/src/api/request-activity.ts @@ -2,6 +2,7 @@ export function requestActivity(path: string, method = 'GET'): { area: string; label: string } | undefined { const route = path.split('?')[0]; if (route === '/health') return undefined; + 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('/mitre-attack')) return { area: 'Core Engine', label: route.endsWith('/source') ? 'Downloading MITRE source' : route.endsWith('/refresh') ? 'Updating MITRE catalog' : 'Loading MITRE catalog' }; diff --git a/src/app/App.tsx b/src/app/App.tsx index 8742ced..b368d57 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,7 +1,7 @@ import { trackActivity, showActivityFrame } from '../platform/activity-store'; import { ActivityCenter } from '../components/ActivityCenter'; import { EnginesControl } from '../components/EnginesControl'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { RetainedAppState, useApp } from './AppProvider'; import { WORKSPACE_BAND_ID } from './workspace-band'; import { DataHub } from '../features/project-import/DataHub'; @@ -30,6 +30,7 @@ import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release'; import { hasSelectedTimeslotScope } from './work-scope'; import { AboutDialog } from '../features/about/AboutDialog'; import { installTableRowMarking } from './table-row-marking'; +import { useRelatedFindings, useRelatedViewRestore } from '../features/related-findings/RelatedFindings'; type AppArea = 'db-structure' | 'data-hub' | 'dashboard' | 'graph' | 'work' | 'results'; @@ -68,6 +69,14 @@ export function App() { const app = useApp(); useEffect(() => installTableRowMarking(document), []); const [activeArea, setActiveArea] = useState('data-hub'); + const related = useRelatedFindings(); + const retainDashboard = useRef(false); + const retainWork = useRef(false); + useRelatedViewRestore(() => { + retainDashboard.current = activeArea === 'dashboard'; + retainWork.current = activeArea === 'work'; + return () => setActiveArea(activeArea); + }); const [activeProjectName, setActiveProjectName] = useState(); const [dataHubClientView, setDataHubClientView] = useState(false); const [dataHubDetailClientName, setDataHubDetailClientName] = useState(); @@ -170,7 +179,7 @@ export function App() { {sidebarCollapsed && }
- {activeArea === 'work' && } + {(activeArea === 'work' || related?.selection && retainWork.current) && } {dataset && hasClientTimeslotScope && (graphVisited || activeArea === 'graph') &&
diff --git a/src/features/linked-results/result-colors.ts b/src/features/linked-results/result-colors.ts new file mode 100644 index 0000000..a37e47d --- /dev/null +++ b/src/features/linked-results/result-colors.ts @@ -0,0 +1,5 @@ +/** Shared identity palette for Results windows and related-finding groups. */ +export const resultColors = [ + '#0e7490', '#059669', '#dc2626', '#2563eb', '#ea580c', '#0d9488', '#be185d', '#16a34a', + '#d946ef', '#b45309', '#4f46e5', '#ca8a04', '#0369a1', '#78350f', '#65a30d', '#475569' +]; diff --git a/src/features/linked-results/result-message.ts b/src/features/linked-results/result-message.ts index 7f64f94..c9a2c1c 100644 --- a/src/features/linked-results/result-message.ts +++ b/src/features/linked-results/result-message.ts @@ -4,6 +4,8 @@ import type { CanonicalOutcome } from '../../domain/canonical-model'; import { outcomeForStatusValue, toolIdForRecord } from '../../importers/status-normalization'; import { toolProfileDefinitions } from '../../profiles/profile-definition-catalog'; import { parseMaesterExecutionError, type MaesterExecutionError } from './maester-error'; +import { pingCastleAffectedDetailRow } from '../../profiles/pingcastle/result-evidence'; +import { purpleKnightAffectedResultRow } from '../../profiles/purple-knight/result-evidence'; /** Ein benannter Ergebnisabschnitt des Results-Fensters. */ export interface ResultSection { @@ -41,8 +43,17 @@ export function resultSheetRowHighlightedFor(toolId: string | undefined, record: } export type ResultSheetRowTone = 'criterion' | 'match' | 'violation' | 'highlight'; +/** Inline evidence: status words require a status column; glyphs are source-written markers. */ +export function resultSectionRowClassName(toolId: string | undefined, headers: string[], cells: string[]): string | undefined { + const fields = cells.map((value, index) => ({ value, label: headers[index] ?? '', index })) + .filter(field => /^(?:status|result|test result|outcome|evaluation)$/i.test(field.label.trim()) + || /^(?:✅|✔️?|❌|✖️?|:white_check_mark:|:heavy_check_mark:|:x:|:heavy_multiplication_x:)(?:\s|$)/u.test(field.value.trim())); + const tone = resultSheetRowToneFor(toolId, { id: 'inline-status', index: 0, title: '', fields, filled: [], meta: [] }); + return tone ? `rich-text-table-row--${tone}` : undefined; +} + /** Profilgesteuerte semantische Farbe einer Belegzeile. */ -export function resultSheetRowToneFor(toolId: string | undefined, record: FindingRecord, findingOutcome?: CanonicalOutcome): ResultSheetRowTone | undefined { +export function resultSheetRowToneFor(toolId: string | undefined, record: FindingRecord, findingOutcome?: CanonicalOutcome, context?: { finding?: FindingRecord; sheet: DatasetSheet }): ResultSheetRowTone | undefined { if (!toolId) return record.highlighted ? 'highlight' : undefined; const presentation = toolProfileDefinitions.find(profile => profile.id === toolId.toLocaleLowerCase('en-US'))?.presentation; const value = record.fields.filter(field => field.value).map(field => field.value.trim()).join(' ').trim(); @@ -60,6 +71,8 @@ export function resultSheetRowToneFor(toolId: string | undefined, record: Findin if (findingOutcome === 'fail') return 'violation'; return undefined; } + if (toolId === 'pingcastle' && findingOutcome === 'fail' && context?.finding && pingCastleAffectedDetailRow(context.finding, context.sheet, record)) return 'violation'; + if (toolId === 'purple-knight' && findingOutcome === 'fail' && context?.finding && purpleKnightAffectedResultRow(context.finding, context.sheet, record)) return 'violation'; if (record.highlighted || presentation?.resultSheetHighlightedRowPatterns?.some(pattern => pattern.test(value))) return 'highlight'; return undefined; } diff --git a/src/features/linked-results/results-excel-content.ts b/src/features/linked-results/results-excel-content.ts new file mode 100644 index 0000000..c342ed6 --- /dev/null +++ b/src/features/linked-results/results-excel-content.ts @@ -0,0 +1,62 @@ +import type { ResultsExcelContent } from '../../exports/results-window-excel'; +import type { ResultSection } from './result-message'; +import type { DatasetSheet } from '../../domain/dataset'; +import type { FindingRecord } from '../../domain/finding'; +import type { CanonicalOutcome } from '../../domain/canonical-model'; +import { findingFirstRead, firstReadDefinition } from '../finding-reader/first-read'; +import { policyProblemSummary } from '../../profiles/group-policy/problem-summary'; + +/** Reuse the Reader's declared source mappings; never infer affected counts from exported rows. */ +export function resultsFindingContext(finding?: FindingRecord, toolId?: string, sheets: DatasetSheet[] = [], outcome?: CanonicalOutcome): NonNullable { + if (!finding) return []; + const definition = firstReadDefinition(toolId); + const descriptions = finding.fields.filter(field => field.value.trim() && + (/^description$/i.test(field.label.trim()) || definition?.backgroundHeaders?.some(pattern => pattern.test(field.label.trim())))); + const problem = findingFirstRead(finding, toolId, sheets, outcome); + const rows = descriptions.map(field => ({ section: 'Description', label: field.label, value: field.value })); + const policy = toolId === 'group-policy' ? policyProblemSummary(finding) : undefined; + if (policy) { + for (const entry of policy.configured) rows.push({ section: policy.heading, label: 'Configured', value: entry.text + (entry.note ? ` · ${entry.note}` : '') }); + for (const [label, values] of [['Expected', policy.expected], ['Additional entries', policy.additional], ['Missing entries', policy.missing], ['Context', policy.context], ['Flags', policy.flags]] as const) { + for (const value of values) if (value) rows.push({ section: policy.heading, label, value }); + } + } + for (const row of problem?.rows ?? []) { + const patterns = row.label === 'Result' ? definition?.resultHeaders : row.label === 'Affected' ? definition?.affectedHeaders : row.label === 'Impact' ? definition?.impactHeaders : undefined; + const original = finding.fields.find(field => field.value.trim() && patterns?.some(pattern => pattern.test(field.label.trim()))); + rows.push({ section: problem!.heading, label: row.label, value: original?.value ?? row.value }); + } + // Unknown profiles can still carry explicitly named explanatory fields. A bare Result status is not a problem statement. + if (!definition) for (const field of finding.fields) { + if (!field.value.trim()) continue; + const label = field.label.trim(); + if (/^(?:result message|affected|impact)$/i.test(label) || /^result$/i.test(label) && finding.fields.some(item => /^status$/i.test(item.label.trim()) && item.value.trim())) { + rows.push({ section: 'Problem', label, value: field.value }); + } + } + return rows; +} + +function cleanClone(element: Element): Element { + const clone = element.cloneNode(true) as Element; + clone.querySelectorAll('caption, input, button, .result-column-resizer, .windows-code-meaning, [data-export-exclude]').forEach(node => node.remove()); + clone.querySelectorAll('br').forEach(node => node.replaceWith('\n')); + return clone; +} + +/** Snapshot every rendered row, including offscreen rows and collapsed technical details. */ +export function resultsExcelContent(root: HTMLElement, title: string, sourceSections: ResultSection[], sheets: DatasetSheet[] = []): ResultsExcelContent { + const tables = [...root.querySelectorAll('table')].map((table, index) => { + const block = table.closest('.result-block, .variant-comparison'); + const name = (block?.querySelector('.result-block__subtitle') ?? block?.querySelector('.result-block__title, h3'))?.textContent?.trim() || `Table ${index + 1}`; + const source = sheets.find(sheet => sheet.id === table.dataset.resultSheetId); + const headers = source?.analysis.headers.filter(header => source.analysis.records.some(record => record.fields.find(field => field.label === header)?.value)); + const sourceRows = source && headers ? [headers, ...source.analysis.records.map(record => headers.map(header => record.fields.find(field => field.label === header)?.value ?? ''))] : undefined; + return { id: table.dataset.resultTableId ?? '', title: `${index + 1} ${name}`, + rows: sourceRows ?? [...table.rows].map(row => [...row.cells].map(cell => cell.getAttribute('data-export-value') ?? cleanClone(cell).textContent ?? '')) }; + }); + const clone = cleanClone(root); + clone.querySelectorAll('table').forEach((table, index) => table.replaceWith(`\n[Table ${index + 1}: ${tables[index].title}]\n`)); + clone.querySelectorAll('p, h3, h4, h5, h6, li, dt, dd, pre, summary, section').forEach(node => node.append('\n')); + return { title, text: clone.textContent ?? '', tables, sourceSections: sourceSections.map(({ title, value }) => ({ title, value })) }; +} diff --git a/src/features/mitre-graph/MitreGraphView.tsx b/src/features/mitre-graph/MitreGraphView.tsx index 1a543c4..90baedd 100644 --- a/src/features/mitre-graph/MitreGraphView.tsx +++ b/src/features/mitre-graph/MitreGraphView.tsx @@ -8,6 +8,7 @@ import { linkResultSheets } from '../../profiles/purple-knight/result-linker'; import { linkPingCastleSectionSheets } from '../../profiles/pingcastle/pingcastle-section-links'; import { inlineResultTableSheets } from '../linked-results/inline-result-tables'; import { resultMessageFor, resultOutcomeFor, resultSectionsFor } from '../linked-results/result-message'; +import { RelatedFindingsFocus, useRelatedFindings, useRelatedViewRestore } from '../related-findings/RelatedFindings'; import { useFilteredRecords } from '../search-filter/use-filtered-records'; import { toolIdForRecord } from '../../importers/status-normalization'; import { activeMitreMatrix, buildFindingPathItems, MITRE_MATRIX_CHANGED_EVENT, type FindingPathItem, type MitreGraphData } from './mitre-graph'; @@ -103,16 +104,18 @@ export function MitreGraphView({ onOpenFinding }: MitreGraphViewProps) { const nextColor = useRef(0); const nextZIndex = useRef(2100); + const related = useRelatedFindings(); + useRelatedViewRestore(() => () => { setSelectedId(selectedId); setPathSelection(pathSelection); setCategory(category); setSeverity(severity); setOriginFilter(originFilter); setSearchValue(search); setShowFilters(showFilters); }); const visibleRecords = useFilteredRecords(); const items = useMemo(() => buildFindingPathItems(visibleRecords, graphData, titleColumn), [graphData, titleColumn, visibleRecords]); const categories = useMemo(() => [...new Set(items.map(item => item.category))].sort((left, right) => left.localeCompare(right)), [items]); const normalizedSearch = search.trim().toLocaleLowerCase('en-US'); const filtered = useMemo(() => items - .filter(item => category === 'all' || item.category === category) - .filter(item => severity === 'all' || item.severity === severity) - .filter(item => originFilter === 'all' || item.mappingOrigin === originFilter) - .filter(item => !normalizedSearch || `${item.title} ${item.techniqueIds.join(' ')} ${item.tactics.join(' ')}`.toLocaleLowerCase('en-US').includes(normalizedSearch)) - .sort((left, right) => right.risk - left.risk || left.title.localeCompare(right.title)), [category, items, normalizedSearch, originFilter, severity]); + .filter(item => related?.selection || category === 'all' || item.category === category) + .filter(item => related?.selection || severity === 'all' || item.severity === severity) + .filter(item => related?.selection || originFilter === 'all' || item.mappingOrigin === originFilter) + .filter(item => related?.selection || !normalizedSearch || `${item.title} ${item.techniqueIds.join(' ')} ${item.tactics.join(' ')}`.toLocaleLowerCase('en-US').includes(normalizedSearch)) + .sort((left, right) => right.risk - left.risk || left.title.localeCompare(right.title)), [category, items, normalizedSearch, originFilter, severity, related?.selection]); const mappedTactics = useMemo(() => graphData.tacticOrder.filter(tactic => filtered.some(item => item.primaryTactic === tactic)), [filtered, graphData.tacticOrder]); const unmapped = filtered.filter(item => item.primaryTactic === 'Unmapped'); @@ -245,6 +248,7 @@ export function MitreGraphView({ onOpenFinding }: MitreGraphViewProps) { return (
+ {/* Kopfzeile und Filter stehen im Kopfcontainer der Shell, nicht im Panel. Das Panel traegt ausschliesslich den Inhalt - dieselbe Aufteilung wie in Work, Datahub, Result DB und DB-Struktur. */} @@ -313,7 +317,7 @@ export function MitreGraphView({ onOpenFinding }: MitreGraphViewProps) { ) : (
-
+
@@ -471,7 +475,7 @@ export function MitreGraphView({ onOpenFinding }: MitreGraphViewProps) { const finding = items.find(candidate => candidate.id === item.recordId); const sheets = finding ? linksFor(finding) : []; const sections = finding ? sectionsFor(finding) : []; - return activateWindow(item.recordId)} onClose={() => setResultWindows(current => current.filter(candidate => candidate.recordId !== item.recordId))} onOpenRecord={finding ? () => openFinding(finding) : undefined} actionLabel="To Report" actionTitle="Jump to this finding’s record in the Report" minimized={item.minimized} onToggleMinimize={() => toggleMinimize(item.recordId)} />; + return activateWindow(item.recordId)} onClose={() => setResultWindows(current => current.filter(candidate => candidate.recordId !== item.recordId))} onOpenRecord={finding ? () => openFinding(finding) : undefined} actionLabel="To Report" actionTitle="Jump to this finding’s record in the Report" minimized={item.minimized} onToggleMinimize={() => toggleMinimize(item.recordId)} />; })}
); diff --git a/src/features/mitre-graph/ObsidianGraphView.tsx b/src/features/mitre-graph/ObsidianGraphView.tsx index 8d20ace..430daa7 100644 --- a/src/features/mitre-graph/ObsidianGraphView.tsx +++ b/src/features/mitre-graph/ObsidianGraphView.tsx @@ -2,6 +2,7 @@ import { createPortal } from 'react-dom'; import { FilterEmptyState } from '../../components/FilterEmptyState/FilterEmptyState'; import { searchAllFindings } from '../search-filter/search-all-findings'; import { memo, useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent } from 'react'; +import { RelatedFindingRing, RelatedFindingsFocus, useRelatedFindings, useRelatedViewRestore } from '../related-findings/RelatedFindings'; import { useApp } from '../../app/AppProvider'; import { FloatingResultsWindow } from '../linked-results/FloatingResultsWindow'; import { linkResultSheets } from '../../profiles/purple-knight/result-linker'; @@ -105,6 +106,17 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa cameraRef.current = camera; const marked = Object.values(reviews).filter(review => review.include).length; + const related = useRelatedFindings(); + const restoringGroup = useRef(false); + useRelatedViewRestore(() => () => { + stopCameraAnimation(); + setHoveredNodeId(hoveredNodeId); + restoringGroup.current = true; + setCamera(camera); setPinnedNodeIds(pinnedNodeIds); setSelectedNodeId(selectedNodeId); + setMarkedSelectionIds(markedSelectionIds); setExtractedSelectionIds(extractedSelectionIds); setNodeOverrides(nodeOverrides); + setShowText(showText); + requestAnimationFrame(() => { restoringGroup.current = false; }); + }); const visibleRecords = useFilteredRecords('deviation'); // Context layer (Phase 13E): clusters become hubs only while the layer is on; off, the graph is unchanged. const contextIndex = useContextClusterIndex(); @@ -132,7 +144,7 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa [graph, markedSelectionIds] ); const extractedNodeIds = useMemo( - () => extractedSelectionIds.length ? new Set(selectedPathNodeIds(graph, extractedSelectionIds[0])) : undefined, + () => !related?.selection && extractedSelectionIds.length ? new Set(selectedPathNodeIds(graph, extractedSelectionIds[0])) : undefined, [extractedSelectionIds, graph] ); const displayedNodes = useMemo( @@ -316,6 +328,7 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa ); useEffect(() => { + if (related?.selection || restoringGroup.current) return; setNodeOverrides({}); setPinnedNodeIds(current => current.filter(id => graph.nodes.some(node => node.id === id))); setSelectedNodeId(current => graph.nodes.some(node => node.id === current) ? current : ''); @@ -335,6 +348,7 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa onPathSnapshotApplied?.(); }, [active, graph.nodes, graphSignature, onPathSnapshotApplied, queuedPathSnapshot]); useEffect(() => { + if (restoringGroup.current) { lastFittedGraphSignature.current = graphSignature; return; } if (!active || lastFittedGraphSignature.current === graphSignature) return; const frame = globalThis.requestAnimationFrame(() => { const rect = surfaceRef.current?.getBoundingClientRect(); @@ -347,6 +361,7 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa }, [active, fitGraphTo, graphSignature]); useEffect(() => { if (!active) return; + if (restoringGroup.current) { fittedExtractSignature.current = extractedSelectionIds.join('|'); return; } const extractSignature = extractedSelectionIds.join('|'); if (!extractSignature) { fittedExtractSignature.current = ''; @@ -501,9 +516,10 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
-
+
+
@@ -601,6 +618,7 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa return startNodeDrag(event, node)} onPointerEnter={() => setHoveredNodeId(node.id)} onPointerLeave={() => setHoveredNodeId(current => current === node.id ? '' : current)} onDoubleClick={() => finding && openFinding(finding)} onKeyDown={event => { if (event.key === 'Enter' || event.key === ' ') togglePinnedNode(node.id); }}> {isMarkedBranch && !isSelected && } {isSelected && } + {finding && related?.groups.get(finding.id) && } {node.kind !== 'finding' && } {showInlineLabel && {labelText}} @@ -617,7 +635,7 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa const finding = items.find(candidate => candidate.id === item.recordId); const sheets = finding ? linksFor(finding) : []; const sections = finding ? sectionsFor(finding) : []; - return activateWindow(item.recordId)} onClose={() => setResultWindows(current => current.filter(candidate => candidate.recordId !== item.recordId))} onOpenRecord={finding ? () => openFinding(finding) : undefined} actionLabel="To Report" actionTitle="Jump to this finding’s record in the Report" minimized={item.minimized} onToggleMinimize={() => setResultWindows(current => current.map(candidate => candidate.recordId === item.recordId ? { ...candidate, minimized: !candidate.minimized } : candidate))} />; + return activateWindow(item.recordId)} onClose={() => setResultWindows(current => current.filter(candidate => candidate.recordId !== item.recordId))} onOpenRecord={finding ? () => openFinding(finding) : undefined} actionLabel="To Report" actionTitle="Jump to this finding’s record in the Report" minimized={item.minimized} onToggleMinimize={() => setResultWindows(current => current.map(candidate => candidate.recordId === item.recordId ? { ...candidate, minimized: !candidate.minimized } : candidate))} />; })}
; } diff --git a/src/features/project-import/DatahubHistoryOverview.tsx b/src/features/project-import/DatahubHistoryOverview.tsx index 537f885..d27bb37 100644 --- a/src/features/project-import/DatahubHistoryOverview.tsx +++ b/src/features/project-import/DatahubHistoryOverview.tsx @@ -4,6 +4,7 @@ import { groupProjectImportFiles } from './datahub-import-groups'; import { summarizeClientProcessingReadiness, withReferenceReadiness, type DatahubReferenceReadiness } from './datahub-processing-readiness'; import type { DatahubTimeslotResultMetrics } from './datahub-scan-analysis'; import { DonutMetricCard } from '../../components/DonutMetricCard/DonutMetricCard'; +import { assessedResultPercentages } from '../../components/DonutMetricCard/assessed-result-percentages'; import type { TimeslotOpenReadiness } from './datahub-open-readiness'; import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState'; @@ -57,18 +58,19 @@ export function DatahubHistoryOverview({ onSelectProject }: DatahubHistoryOverviewProps) { const resultMetrics = timeslotMetrics ?? { all: null, deviation: null, passed: null, notApplicable: null, notRun: null, error: null }; + const assessmentPercentages = assessedResultPercentages(resultMetrics.passed ?? 0, resultMetrics.deviation ?? 0); const pendingLabel = loading || comparisonBusy ? 'Loading…' : comparisonError ? 'Unavailable' : selection ? 'No results' : 'Select client'; return <>
- - - - - - + + + + + +
{comparisonError && {comparisonError}}
diff --git a/src/features/project-import/GpoFolderImport.tsx b/src/features/project-import/GpoFolderImport.tsx index 344ec77..33eca62 100644 --- a/src/features/project-import/GpoFolderImport.tsx +++ b/src/features/project-import/GpoFolderImport.tsx @@ -51,6 +51,33 @@ interface SelectedBrowserFile { interface CollectedBrowserDirectory { files: SelectedBrowserFile[]; unreadableEntries: number; + failures: FolderReadFailure[]; +} + +interface FolderReadFailure { + stage: 'Folder enumeration' | 'File access' | 'File reading' | 'Folder selection'; + code: string; +} + +function folderReadFailure(stage: FolderReadFailure['stage'], reason: unknown): FolderReadFailure { + // Browser messages may contain customer paths. Retain only known error names. + const name = reason instanceof Error || reason instanceof DOMException ? reason.name : ''; + const known = ['NotFoundError', 'NotAllowedError', 'SecurityError', 'NotReadableError', 'AbortError']; + return { stage, code: known.includes(name) ? name : 'UnknownError' }; +} + +function folderReadMessage(failures: readonly FolderReadFailure[]): string { + if (!failures.length) return 'The selected GPO folder contains no files. Select the extracted backup folder containing files, or use Import GPO ZIP.'; + const counts = new Map(); + for (const failure of failures) { + const key = `${failure.stage}: ${failure.code}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + const detail = [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([key, count]) => `${key} (${count})`).join('; '); + const denied = failures.some(failure => ['NotAllowedError', 'SecurityError'].includes(failure.code)); + return `Chrome/browser could not read the selected GPO folder. ${detail}. ${denied + ? 'Select the folder again and allow read access.' + : 'Check that the backup files are locally available and readable, then select the folder again.'} Alternatively, use Import GPO ZIP. No upload was started.`; } const stateLabels: Record = { @@ -114,7 +141,8 @@ export function captureGpoFolderFiles(files: readonly File[]): GpoFolderFileSour export async function captureReadableGpoSelectedFiles( entries: readonly SelectedBrowserFile[] -): Promise<{ sources: GpoFolderFileSource[]; unreadableEntries: number }> { +): Promise<{ sources: GpoFolderFileSource[]; unreadableEntries: number; failures: FolderReadFailure[] }> { + const failures: FolderReadFailure[] = []; validateGpoFolderSourceMetadata(entries.map(entry => ({ relativePath: entry.relativePath, sizeBytes: entry.file.size @@ -122,18 +150,22 @@ export async function captureReadableGpoSelectedFiles( const captured: Array = await Promise.all(entries.map(async ({ file, relativePath }): Promise => { try { const bytes = new Uint8Array(await file.arrayBuffer()); - if (bytes.byteLength !== file.size) return undefined; + if (bytes.byteLength !== file.size) { + failures.push({ stage: 'File reading', code: 'SizeMismatch' }); + return undefined; + } return { relativePath, sizeBytes: file.size, readBytes: async () => bytes }; - } catch { + } catch (reason) { + failures.push(folderReadFailure('File reading', reason)); return undefined; } })); const sources = captured.filter((source): source is GpoFolderFileSource => source !== undefined); - return { sources, unreadableEntries: entries.length - sources.length }; + return { sources, unreadableEntries: entries.length - sources.length, failures }; } export async function collectGpoDirectoryFiles( @@ -141,6 +173,7 @@ export async function collectGpoDirectoryFiles( ): Promise { const selected: SelectedBrowserFile[] = []; let unreadableEntries = 0; + const failures: FolderReadFailure[] = []; const visit = async (directory: BrowserFileSystemDirectoryHandle, parentPath: string): Promise => { try { for await (const entry of directory.values()) { @@ -149,17 +182,19 @@ export async function collectGpoDirectoryFiles( else { try { selected.push({ file: await entry.getFile(), relativePath }); - } catch { + } catch (reason) { unreadableEntries += 1; + failures.push(folderReadFailure('File access', reason)); } } } - } catch { + } catch (reason) { unreadableEntries += 1; + failures.push(folderReadFailure('Folder enumeration', reason)); } }; await visit(root, root.name); - return { files: selected, unreadableEntries }; + return { files: selected, unreadableEntries, failures }; } export function GpoFolderImport({ @@ -300,7 +335,7 @@ export function GpoFolderImport({ try { if (files.some(file => !file.webkitRelativePath)) throw new Error('Please select a complete GPO backup folder.'); const captured = await captureReadableGpoSelectedFiles(files.map(file => ({ file, relativePath: file.webkitRelativePath }))); - if (!captured.sources.length) throw new Error('Keine Datei des ausgewaehlten GPO-Ordners konnte gelesen werden.'); + if (!captured.sources.length) throw new Error(folderReadMessage(captured.failures)); const transport = await createGpoFolderTransport(captured.sources); const envelopeBytes = new Uint8Array(transport.bytes.byteLength); envelopeBytes.set(transport.bytes); @@ -329,12 +364,18 @@ export function GpoFolderImport({ setProgress('Preparing GPO backup'); setFeedback(null); try { - const directory = await picker({ mode: 'read' }); + let directory: BrowserFileSystemDirectoryHandle; + try { + directory = await picker.call(globalThis, { mode: 'read' }); + } catch (reason) { + if (reason instanceof DOMException && reason.name === 'AbortError') throw reason; + throw new Error(folderReadMessage([folderReadFailure('Folder selection', reason)])); + } const collected = await collectGpoDirectoryFiles(directory); - if (!collected.files.length) throw new Error('Keine Datei des ausgewaehlten GPO-Backupordners konnte gelesen werden.'); + if (!collected.files.length) throw new Error(folderReadMessage(collected.failures)); const captured = await captureReadableGpoSelectedFiles(collected.files); const unreadableEntries = collected.unreadableEntries + captured.unreadableEntries; - if (!captured.sources.length) throw new Error('Keine Datei des ausgewaehlten GPO-Backupordners konnte gelesen werden.'); + if (!captured.sources.length) throw new Error(folderReadMessage([...collected.failures, ...captured.failures])); const transport = await createGpoFolderTransport(captured.sources); const envelopeBytes = new Uint8Array(transport.bytes.byteLength); envelopeBytes.set(transport.bytes); diff --git a/src/features/related-findings/RelatedFindings.tsx b/src/features/related-findings/RelatedFindings.tsx new file mode 100644 index 0000000..dd1aec5 --- /dev/null +++ b/src/features/related-findings/RelatedFindings.tsx @@ -0,0 +1,148 @@ +import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type PropsWithChildren } from 'react'; +import { useApp } from '../../app/AppProvider'; +import type { SavedViewState } from '../../app/app-state'; +import type { FindingRecord } from '../../domain/finding'; +import type { DatasetSheet } from '../../domain/dataset'; +import { useWorkPackages } from '../../remediation/use-work-packages'; +import { workRecordKey } from '../../remediation/work-package-index'; +import { resultColors } from '../linked-results/result-colors'; + +export interface RelatedFindingGroup { id: string; recordIds: string[]; color: string; } +interface Selection { group: RelatedFindingGroup; sheet?: DatasetSheet; before: SavedViewState; scope: string; dataset: unknown; } +interface GroupContext { + groups: ReadonlyMap; + selection?: Selection; + toggle(group: RelatedFindingGroup, recordId: string): void; + showAllInWork(): void; + registerView(capture: () => () => void): () => void; +} +const Context = createContext(undefined); +export const useRelatedFindings = () => useContext(Context); + +/** Views retain their own UI state; the focus coordinator only stores restore callbacks. */ +export function useRelatedViewRestore(capture: () => () => void) { + const context = useRelatedFindings(); + const latest = useRef(capture); latest.current = capture; + useEffect(() => context?.registerView(() => latest.current()), [context?.registerView]); +} + +/** Presentation-only focus. Published exact change-target identities remain authoritative. */ +export function RelatedFindingsProvider({ children }: PropsWithChildren) { + const app = useApp(); + const packages = useWorkPackages(); + const scope = app.workScopeId; + const workSheet = app.dataset?.sheets.find(sheet => sheet.isProjectSheet) ?? app.activeSheet; + const [selection, setSelection] = useState(); + const positions = useRef>([]); + const restore = useRef(false); + const views = useRef(new Set<() => () => void>()); + const viewRestorers = useRef void>>([]); + const registerView = useMemo(() => (capture: () => () => void) => { + views.current.add(capture); + return () => { views.current.delete(capture); }; + }, []); + const groups = useMemo(() => { + const map = new Map(); + if (packages.status !== 'ready') return map; + const byKey = new Map(); + const aliases = new Map(); + for (const sheet of app.dataset?.sheets ?? []) for (const record of sheet.analysis.records) { + const key = workRecordKey(record); + if (key) aliases.set(key, [...(aliases.get(key) ?? []), record.id]); + } + for (const record of workSheet?.analysis.records ?? []) { + const key = workRecordKey(record); + if (key) byKey.set(key, [...(byKey.get(key) ?? []), record.id]); + } + const tasks = packages.index.packages.flatMap(p => p.tasks).filter(t => t.redundant) + .sort((a, b) => a.id.localeCompare(b.id, 'en')); + tasks.forEach((task, index) => { + const recordIds = [...new Set(task.findingKeys.flatMap(key => byKey.get(key) ?? []))]; + if (recordIds.length < 2) return; + const group = { id: task.id, recordIds, color: resultColors[index % resultColors.length] }; + recordIds.forEach(id => map.set(id, group)); + task.findingKeys.flatMap(key => aliases.get(key) ?? []).forEach(id => map.set(id, group)); + }); + return map; + }, [packages.status === 'ready' ? packages.index : undefined, workSheet, app.dataset]); + const current = selection?.scope === scope && selection.dataset === app.dataset && packages.status === 'ready' + && groups.get(selection.group.recordIds[0])?.id === selection.group.id ? selection : undefined; + const finish = () => { + if (selection?.scope === scope && selection.dataset === app.dataset) { + app.applyViewBookmark(selection.before); + viewRestorers.current.forEach(restoreView => restoreView()); + restore.current = true; + } + setSelection(undefined); + }; + useEffect(() => { + if (selection && !current) finish(); + }, [Boolean(current), selection, scope, app.dataset]); + useLayoutEffect(() => { + if (!restore.current || selection) return; + restore.current = false; + // Restore after Reader selection effects and virtual-list recalculation. + const frame = requestAnimationFrame(() => requestAnimationFrame(() => { + for (const position of positions.current) { + const node = Array.from(document.querySelectorAll('[data-group-scroll]')) + .find(el => el.dataset.groupScroll === position.id); + if (!node) continue; + node.scrollTop = position.top; node.scrollLeft = position.left; + node.dispatchEvent(new Event('scroll')); + } + })); + return () => cancelAnimationFrame(frame); + }, [selection]); + const toggle = (group: RelatedFindingGroup, recordId: string) => { + if (current?.group.id === group.id) { finish(); return; } + if (!current) { + positions.current = Array.from(document.querySelectorAll('[data-group-scroll]')) + .map(node => ({ id: node.dataset.groupScroll!, top: node.scrollTop, left: node.scrollLeft })); + viewRestorers.current = Array.from(views.current, capture => capture()); + } + const before = current?.before ?? { + activeSheetId: app.activeSheetId, view: app.view, search: app.search, sortColumn: app.sortColumn, + sortDirection: app.sortDirection, titleColumn: app.titleColumn, statusFilter: app.statusFilter, + facetFilters: structuredClone(app.facetFilters), pathFindingIds: [...app.pathFindingIds], + actionOnly: app.actionOnly, selectedRecordId: app.selectedRecordId + }; + setSelection({ group, sheet: workSheet, before, scope, dataset: app.dataset }); + const clicked = app.dataset?.sheets.flatMap(sheet => sheet.analysis.records).find(record => record.id === recordId); + const selected = workSheet?.analysis.records.find(record => record.id === recordId || clicked && workRecordKey(record) === workRecordKey(clicked)); + app.setSelectedRecord(selected?.id ?? recordId); + }; + const showAllInWork = () => { + const recordIds = [...new Set([...groups.values()].flatMap(group => group.recordIds))]; + if (!workSheet || !recordIds.length) return; + setSelection(undefined); + restore.current = false; + app.applyViewBookmark({ activeSheetId: workSheet.id, view: 'records', search: '', statusFilter: 'all', + facetFilters: {}, pathFindingIds: recordIds, actionOnly: false, selectedRecordId: recordIds[0], + sortColumn: app.sortColumn, sortDirection: app.sortDirection, titleColumn: app.titleColumn }); + }; + return {children}; +} + +export function RelatedFindingRing({ record, inline = false, onGroupOpen }: { record: FindingRecord; inline?: boolean; onGroupOpen?(): void }) { + const context = useRelatedFindings(); + // Every displayed member uses the active group's colour and filled state, including the reading selection. + const group = context?.selection?.group.recordIds.includes(record.id) ? context.selection.group : context?.groups.get(record.id); + if (!group) return null; + const active = context?.selection?.group.id === group.id; + const style = { '--related-color': group.color } as CSSProperties; + const label = active ? 'Restore previous view' : `Show ${group.recordIds.length} findings for the same change`; + return ; +} + +export function RelatedFindingsFocus() { + const context = useRelatedFindings(); + if (!context?.selection) return null; + return
+ {context.selection.group.recordIds.length} related findings · Same change + +
; +} diff --git a/src/features/search-filter/DynamicFilterControls.tsx b/src/features/search-filter/DynamicFilterControls.tsx index 2677bde..97b98fb 100644 --- a/src/features/search-filter/DynamicFilterControls.tsx +++ b/src/features/search-filter/DynamicFilterControls.tsx @@ -92,8 +92,8 @@ function renderFacet( aria-label={facet.label} > {multipleSeverities && } - - {facet.values.map(value => )} + + {facet.values.map(value => )} ; } const active = Boolean(selected?.minimum || selected?.maximum); diff --git a/src/features/search-filter/SearchFilterBar.tsx b/src/features/search-filter/SearchFilterBar.tsx index ce0a5d1..a98cb65 100644 --- a/src/features/search-filter/SearchFilterBar.tsx +++ b/src/features/search-filter/SearchFilterBar.tsx @@ -7,6 +7,8 @@ import { DynamicFilterControls } from './DynamicFilterControls'; import { selectWorkFacets } from './work-facets'; import { useContextClusterIndex } from '../context-layer/use-context-cluster-index'; 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'; import { measureNavigationStep } from '../../app/navigation-diagnostics'; export { WorkTimeslotControl } from './WorkTimeslotControl'; @@ -92,11 +94,13 @@ export function SearchFilterBar({ hiddenFacetLabels = [] }: SearchFilterBarProps const engines = useEnginePreferences(); // The facet uses the shared context for the current scope. const contextClusters = useContextClusterIndex(); + const remediation = useWorkPackages(); + const workPackages = remediation.status === 'ready' ? remediation.index : undefined; const countedFacets = useMemo( () => measureNavigationStep('filter-counts', () => selectWorkFacets(activeSheet, { - activeProfile, search, statusFilter, facetFilters, dataset, actionOnly, reviews, pathFindingIds, contextClusters + activeProfile, search, statusFilter, facetFilters, dataset, actionOnly, reviews, pathFindingIds, contextClusters, workPackages })), - [actionOnly, activeProfile, activeSheet, contextClusters, dataset, facetFilters, pathFindingIds, reviews, search, statusFilter] + [actionOnly, activeProfile, activeSheet, contextClusters, workPackages, dataset, facetFilters, pathFindingIds, reviews, search, statusFilter] ); const visibleFacets = useMemo( () => countedFacets.filter(facet => !hiddenFacetLabels.includes(facet.label)), @@ -123,6 +127,7 @@ export function SearchFilterBar({ hiddenFacetLabels = [] }: SearchFilterBarProps Object.keys(facetFilters).forEach(facetId => { // Keep the selected context filter while its context is loading. if (facetId === CONTEXT_CLUSTER_FACET_ID && engines.context) return; + if (facetId === WORK_PACKAGE_FACET_ID && engines.remediation && remediation.status === 'loading') return; if (!contextualFacetIds.has(facetId)) setFacetFilter(facetId, null); }); countedFacets.forEach(facet => { @@ -137,7 +142,7 @@ export function SearchFilterBar({ hiddenFacetLabels = [] }: SearchFilterBarProps if (statusFilter !== 'all' && statusFacet && !statusFacet.values.some(value => value.value === statusFilter)) { setStatusFilter('all'); } - }, [engines.context, countedFacets, facetFilters, setFacetFilter, setStatusFilter, statusFilter]); + }, [engines.context, engines.remediation, remediation.status, countedFacets, facetFilters, setFacetFilter, setStatusFilter, statusFilter]); return (
diff --git a/src/features/search-filter/dynamic-facets.ts b/src/features/search-filter/dynamic-facets.ts index f2d5285..5baeffe 100644 --- a/src/features/search-filter/dynamic-facets.ts +++ b/src/features/search-filter/dynamic-facets.ts @@ -50,7 +50,7 @@ export interface DynamicFacet { minimum?: number; maximum?: number; confidence?: RecognitionConfidenceLevel; - derivedKind?: 'cis-benchmark-category' | 'policy-failing-backup' | 'security-domain' | 'policy-baseline' | 'context-cluster'; + derivedKind?: 'cis-benchmark-category' | 'policy-failing-backup' | 'security-domain' | 'policy-baseline' | 'context-cluster' | 'work-package'; /** Record values of a facet derived outside the sheet (context layer); only present while that layer is on. */ valuesFor?: (record: FindingRecord) => string[]; description?: string; @@ -167,7 +167,7 @@ const labelPriority: Array<{ pattern: RegExp; priority: number }> = [ { pattern: /^mitre\s*att&?ck$/i, priority: 88 }, { pattern: /^mitre\s*d3fend$/i, priority: 86 }, { pattern: /^(?:target|ziel|betroffenes objekt|asset)$/i, priority: 84 }, - { pattern: /^result\s*message$/i, priority: 82 } + { pattern: /^work\s*package$/i, priority: 82 } ]; function facetPriority(label: string, role: CanonicalFieldRole | undefined): number { @@ -220,7 +220,7 @@ function facetValueKey(severityScale: boolean | undefined, value: string): strin function recordFacetKey(facet: DynamicFacet, value: string): string { // Provenance identities are exact: never case-fold or whitespace-normalize IDs. - return facet.derivedKind === 'policy-failing-backup' ? value : facetValueKey(facet.severityScale, value); + return facet.derivedKind === 'policy-failing-backup' || facet.derivedKind === 'work-package' ? value : facetValueKey(facet.severityScale, value); } function failingGpoBackupFacet(records: FindingRecord[]): DynamicFacet | null { @@ -376,6 +376,7 @@ function fieldFacets(sheet: DatasetSheet, dataset: ImportedDataset | null | unde if (role === 'score' || normalized(column.header) === 'score') return []; if (role && narrativeRoles.has(role)) return []; if (narrativeHeaders.test(clean(column.header))) return []; + if (/^result[\s_-]*message$/i.test(clean(column.header))) return []; // Auch ueber die Ueberschrift, da die Rollenerkennung die Statusspalte // nicht immer sicher erkennt - sonst steht sie doppelt in der Leiste, // einmal als "Ergebnisstatus" und einmal als roher Spaltenfilter. diff --git a/src/features/search-filter/filter-records.ts b/src/features/search-filter/filter-records.ts index 09f6c94..65eb3cc 100644 --- a/src/features/search-filter/filter-records.ts +++ b/src/features/search-filter/filter-records.ts @@ -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 { readEnginePreferences } from '../../app/engine-preferences'; +import { withWorkPackageFacet, type WorkPackageIndex } from '../../remediation/work-package-index'; export interface RecordFilterOptions { activeProfile: ImportProfile; @@ -28,6 +29,7 @@ export interface RecordFilterOptions { reviews: Record; /** Context layer facet (Phase 13E); absent while the layer is off, so Work filters exactly as without it. */ contextClusters?: ContextClusterIndex; + workPackages?: WorkPackageIndex; } const cachedRecords = createSheetDerivationCache(); @@ -37,7 +39,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, readEnginePreferences()]; + JSON.stringify(options.facetFilters ?? {}), options.contextClusters, options.workPackages, readEnginePreferences()]; } export function filterAndSortRecords(sheet: DatasetSheet | undefined, options: RecordFilterOptions): FindingRecord[] { @@ -47,12 +49,15 @@ export function filterAndSortRecords(sheet: DatasetSheet | undefined, options: R function deriveFilteredRecords(sheet: DatasetSheet, options: RecordFilterOptions): FindingRecord[] { if (!sheet) return []; + // A selected package must never briefly display unrelated records while its new scope loads. + if (readEnginePreferences().remediation && options.facetFilters?.['remediation:work-package']?.values?.length + && !options.workPackages) return []; 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 = withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy' + const facets = withWorkPackageFacet(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy' ? deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews, 'group-policy') - : baseFacets, options.contextClusters), options.contextClusters); + : baseFacets, options.contextClusters), options.contextClusters), options.workPackages); 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; diff --git a/src/features/search-filter/use-filtered-records.ts b/src/features/search-filter/use-filtered-records.ts index ff1dce4..76e6289 100644 --- a/src/features/search-filter/use-filtered-records.ts +++ b/src/features/search-filter/use-filtered-records.ts @@ -1,9 +1,12 @@ -import { useMemo } from 'react'; -import { useApp } from '../../app/AppProvider'; -import { filterAndSortRecords } from './filter-records'; +import { useCallback, useMemo } from 'react'; +import { useApp, useOptionalApp } from '../../app/AppProvider'; +import { filterAndSortRecords, type RecordFilterOptions } from './filter-records'; import { useContextClusterIndex } from '../context-layer/use-context-cluster-index'; +import { useWorkPackages } from '../../remediation/use-work-packages'; +import { useRelatedFindings } from '../related-findings/RelatedFindings'; -export function useFilteredRecords(statusFilterOverride?: string) { +type App = ReturnType; +function useRecordFilterOptions(app: App | undefined, statusFilterOverride?: string): [App['activeSheet'] | undefined, RecordFilterOptions] { const { activeSheet, activeProfile, @@ -16,21 +19,47 @@ export function useFilteredRecords(statusFilterOverride?: string) { pathFindingIds, actionOnly, reviews - } = useApp(); + } = app ?? ({} as Partial); const contextClusters = useContextClusterIndex(); + const remediation = useWorkPackages(); + const workPackages = remediation.status === 'ready' ? remediation.index : undefined; const effectiveStatusFilter = statusFilterOverride ?? statusFilter; - - return useMemo(() => filterAndSortRecords(activeSheet, { - activeProfile, + const options = useMemo(() => ({ + activeProfile: activeProfile!, dataset, search, sortColumn, sortDirection, statusFilter: effectiveStatusFilter, - facetFilters, + facetFilters: facetFilters ?? {}, pathFindingIds, actionOnly, reviews, - contextClusters - }), [activeProfile, activeSheet, actionOnly, contextClusters, dataset, effectiveStatusFilter, facetFilters, pathFindingIds, reviews, search, sortColumn, sortDirection]); + contextClusters, workPackages + } as RecordFilterOptions), [activeProfile, actionOnly, contextClusters, workPackages, dataset, effectiveStatusFilter, facetFilters, pathFindingIds, reviews, search, sortColumn, sortDirection]); + return [activeSheet, options]; +} + +export function useFilteredRecords(statusFilterOverride?: string) { + const [activeSheet, options] = useRecordFilterOptions(useApp(), statusFilterOverride); + const selection = useRelatedFindings()?.selection; + const group = selection?.group; + return useMemo(() => filterAndSortRecords(selection?.sheet ?? activeSheet, group ? { + ...options, search: '', statusFilter: 'all', facetFilters: {}, actionOnly: false, pathFindingIds: group.recordIds + } : options), [activeSheet, options, group, selection?.sheet]); +} + +/** + * Whether a Work record stays visible when one facet filter is replaced. Every other active + * Work filter (status, search, facets, drilldown, My Workspace) applies unchanged; evaluated on demand. + */ +export function useVisibleWithFacet() { + const app = useOptionalApp(); + const [activeSheet, options] = useRecordFilterOptions(app ?? undefined); + const selection = useRelatedFindings()?.selection; + return useCallback((facetId: string, values: string[], recordId: string) => selection + ? selection.group.recordIds.includes(recordId) + : Boolean(app) && filterAndSortRecords(activeSheet, { + ...options, facetFilters: { ...options.facetFilters, [facetId]: { values } } + }).some(record => record.id === recordId), [app, activeSheet, options, selection]); } diff --git a/src/features/search-filter/work-facets.ts b/src/features/search-filter/work-facets.ts index f827ab0..112258d 100644 --- a/src/features/search-filter/work-facets.ts +++ b/src/features/search-filter/work-facets.ts @@ -9,6 +9,8 @@ import { import { filterAndSortRecords, recordFilterKeys, type RecordFilterOptions } from './filter-records'; import { withContextClusterFacet } from '../context-layer/context-cluster-facet'; import { withBusinessImpactFacet } from './business-impact-controls'; +import { withWorkPackageFacet, workPackageCode } from '../../remediation/work-package-index'; +import { comparePublishedClusters, numberedClusterLabel } from '../sidebar/context-cluster-display'; export type WorkFacetOptions = Omit; const cache = createSheetDerivationCache(); @@ -22,14 +24,30 @@ 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 = withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy' + const facets = withWorkPackageFacet(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy' ? deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews, 'group-policy') - : baseFacets, options.contextClusters), options.contextClusters); + : baseFacets, options.contextClusters), options.contextClusters), options.workPackages); 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' ? { ...facet, preserveEmptyValues: false } : facet); - return recountFacetsAgainstActiveFilters({ facets: selectableFacets, records, filters: options.facetFilters ?? emptyFilters, + const counted = recountFacetsAgainstActiveFilters({ facets: selectableFacets, records, filters: options.facetFilters ?? emptyFilters, reviews: options.reviews, statusFilter: options.statusFilter, matchesStatus: options.activeProfile.matchesStatus }); + const clusters = options.contextClusters?.publication.clusters; + return counted.map(facet => { + if (facet.id === 'context:cluster') return { ...facet, values: facet.values.map(value => ({ ...value, + label: numberedClusterLabel(value.value, value.label, clusters) })) + .sort((a, b) => comparePublishedClusters(a.value, b.value, clusters)) }; + if (facet.id !== 'remediation:work-package') return facet; + const packages = new Map(options.workPackages?.packages.map(p => [p.id, p])); + return { ...facet, values: facet.values.map(value => { + const p = packages.get(value.value); + return p ? { ...value, label: `${numberedClusterLabel(p.clusterId, p.clusterLabel, clusters)} → ${workPackageCode(p.code)}` } : value; + }).sort((a, b) => { + const left = packages.get(a.value), right = packages.get(b.value); + return Number(!left) - Number(!right) || comparePublishedClusters(left?.clusterId ?? '', right?.clusterId ?? '', clusters) + || (left?.code ?? a.label).localeCompare(right?.code ?? b.label, 'en', { numeric: true }); + }) }; + }); }); } diff --git a/src/features/sidebar/DockableFilterPanel.tsx b/src/features/sidebar/DockableFilterPanel.tsx new file mode 100644 index 0000000..ecb16a9 --- /dev/null +++ b/src/features/sidebar/DockableFilterPanel.tsx @@ -0,0 +1,169 @@ +import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'; +import { createPortal } from 'react-dom'; +import type { ClusterColour } from './context-cluster-display'; + +const edges = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'] as const; +type Edge = typeof edges[number]; +type Bounds = { left: number; top: number; width: number; height: number }; +function resized(bounds: Bounds, edge: Edge, dx: number, dy: number): Bounds { + const minWidth = Math.min(240, window.innerWidth), minHeight = Math.min(120, window.innerHeight); + let { left, top } = bounds; + let right = left + bounds.width, bottom = top + bounds.height; + if (edge.includes('w')) left = Math.max(0, Math.min(left + dx, right - minWidth)); + if (edge.includes('e')) right = Math.min(window.innerWidth, Math.max(right + dx, left + minWidth)); + if (edge.includes('n')) top = Math.max(0, Math.min(top + dy, bottom - minHeight)); + if (edge.includes('s')) bottom = Math.min(window.innerHeight, Math.max(bottom + dy, top + minHeight)); + return { left, top, width: right - left, height: bottom - top }; +} + +export interface FilterPanelGroup { + id: string; + label: string; + values: { value: string; label: string; count: number; clusterColour?: ClusterColour; + section?: { id: string; label: string } }[]; + selected: readonly string[]; + onSelect(value: string): void; + unavailable?: string; + disableEmpty?: boolean; +} + +/** Presentation only: one filter list, docked or floating, with unchanged facet callbacks. */ +export function DockableFilterPanel({ groups }: { groups: FilterPanelGroup[] }) { + const parking = useRef(null); + const panel = useRef(null); + const drag = useRef<{ pointerId: number; x: number; y: number; left: number; top: number; moved: boolean } | null>(null); + const [floating, setFloating] = useState(false); + const [position, setPosition] = useState({ left: 240, top: 140 }); + const [size, setSize] = useState({ width: Math.min(360, window.innerWidth), height: Math.min(600, window.innerHeight * .75) }); + const geometry = useRef({ ...position, ...size }); + geometry.current = { ...position, ...size }; + const sizing = useRef<{ pointerId: number; edge: Edge; x: number; y: number; bounds: Bounds } | null>(null); + const applyBounds = ({ left, top, width, height }: Bounds) => { setPosition({ left, top }); setSize({ width, height }); }; + const [expanded, setExpanded] = useState>({}); + const [overParking, setOverParking] = useState(false); + const previousFloating = useRef(false); + useEffect(() => { + if (previousFloating.current === floating) return; + previousFloating.current = floating; + panel.current?.querySelector('header button')?.focus(); + }, [floating]); + const dock = () => { setFloating(false); setOverParking(false); drag.current = null; sizing.current = null; }; + const inParking = (x: number, y: number) => { + const rect = parking.current?.getBoundingClientRect(); + return !!rect && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; + }; + const clamp = (left: number, top: number) => { + const rect = panel.current?.getBoundingClientRect(); + const detached = panel.current?.classList.contains('is-floating'); + return { + left: Math.max(0, Math.min(left, window.innerWidth - (detached ? rect?.width ?? geometry.current.width : geometry.current.width))), + top: Math.max(0, Math.min(top, window.innerHeight - (detached ? rect?.height ?? geometry.current.height : geometry.current.height))) + }; + }; + useEffect(() => { + const move = (event: PointerEvent) => { + const activeResize = sizing.current; + if (activeResize && event.pointerId === activeResize.pointerId) { + applyBounds(resized(activeResize.bounds, activeResize.edge, event.clientX - activeResize.x, event.clientY - activeResize.y)); + event.preventDefault(); return; + } + const current = drag.current; + if (!current || event.pointerId !== current.pointerId) return; + const dx = event.clientX - current.x, dy = event.clientY - current.y; + if (!current.moved && Math.hypot(dx, dy) < 4) return; + current.moved = true; + setFloating(true); + setPosition(clamp(current.left + dx, current.top + dy)); + setOverParking(inParking(event.clientX, event.clientY)); + event.preventDefault(); + }; + const stop = (event: PointerEvent) => { + if (sizing.current?.pointerId === event.pointerId) { sizing.current = null; return; } + const current = drag.current; + if (!current || event.pointerId !== current.pointerId) return; + if (current.moved && event.type !== 'pointercancel' && inParking(event.clientX, event.clientY)) dock(); + drag.current = null; + setOverParking(false); + }; + const resize = () => { + if (!panel.current?.classList.contains('is-floating') || window.innerWidth <= 1 || window.innerHeight <= 1) return; + // Layout can briefly have zero geometry during browser capture/reparenting. Retain the user's dimensions. + const current = geometry.current; + const width = Math.min(current.width, window.innerWidth), height = Math.min(current.height, window.innerHeight); + applyBounds({ width, height, left: Math.max(0, Math.min(current.left, window.innerWidth - width)), + top: Math.max(0, Math.min(current.top, window.innerHeight - height)) }); + }; + const escape = (event: KeyboardEvent) => { + if (event.key === 'Escape' && panel.current?.contains(document.activeElement)) { event.preventDefault(); dock(); } + }; + window.addEventListener('pointermove', move, { passive: false }); + window.addEventListener('pointerup', stop); + window.addEventListener('pointercancel', stop); + window.addEventListener('resize', resize); + window.addEventListener('keydown', escape); + return () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', stop); + window.removeEventListener('pointercancel', stop); + window.removeEventListener('resize', resize); + window.removeEventListener('keydown', escape); + }; + }, []); + const start = (event: ReactPointerEvent) => { + if (event.button !== 0 || (event.target as HTMLElement).closest('button')) return; + const rect = panel.current?.getBoundingClientRect(); + if (!rect) return; + drag.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, left: rect.left, top: rect.top, moved: false }; + event.preventDefault(); + }; + const content =
+ {floating && edges.map(edge => + +
+ {groups.map(group =>
{ const open = event.currentTarget.open; setExpanded(current => current[group.id] === open ? current : { ...current, [group.id]: open }); }}> + {group.label} + {group.selected.length > 0 && {group.selected.length}} + + {group.unavailable ?

{group.unavailable}

: group.values.map((value, index) =>
+ {value.section && value.section.id !== group.values[index - 1]?.section?.id &&
+ {value.section.label} + {value.clusterColour && }
} +
)} +
)} +
+
; + return
+ {floating ? <>{createPortal(content, document.body)} : content} +
; +} diff --git a/src/features/sidebar/SidebarSecurityDomains.tsx b/src/features/sidebar/SidebarSecurityDomains.tsx index 2acce71..5cf3c1f 100644 --- a/src/features/sidebar/SidebarSecurityDomains.tsx +++ b/src/features/sidebar/SidebarSecurityDomains.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { DockableFilterPanel, type FilterPanelGroup } from './DockableFilterPanel'; import { useApp } from '../../app/AppProvider'; import type { DatasetSheet, ImportedDataset } from '../../domain/dataset'; import type { ImportProfile } from '../../domain/import-profile'; @@ -11,6 +12,11 @@ import type { ContextClusterIndex } from '../context-layer/context-cluster-facet import { useContextClusterIndex } from '../context-layer/use-context-cluster-index'; 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'; +import { workPackageCode, type WorkPackageIndex } from '../../remediation/work-package-index'; +import { WORK_PACKAGE_FACET_ID } from '../../remediation/remediation-engine'; +import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet'; +import { comparePublishedClusters, numberedClusterLabel, publishedClusterColour } from './context-cluster-display'; interface SecurityDomainOptions { sheet: DatasetSheet | undefined; @@ -23,6 +29,7 @@ interface SecurityDomainOptions { search: string; actionOnly: boolean; contextClusters?: ContextClusterIndex; + workPackages?: WorkPackageIndex; } export interface SidebarSecurityDomain { @@ -38,11 +45,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 } = options; + const { sheet, dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, workPackages } = options; if (!sheet) return undefined; const facets = selectWorkFacets(sheet, { - dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters + dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, workPackages }); const domainFacet = facets.find(facet => facet.id === SECURITY_DOMAIN_FACET_ID); if (!domainFacet) return undefined; @@ -74,15 +81,17 @@ export function SidebarSecurityDomains({ sheet }: { sheet: DatasetSheet | undefi setStatusFilter } = useApp(); const contextClusters = useContextClusterIndex(); + const remediation = useWorkPackages(); + const workPackages = remediation.status === 'ready' ? remediation.index : undefined; 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 }).find(item => item.id === BUSINESS_IMPACT_FACET_ID); + pathFindingIds, search, actionOnly, contextClusters, workPackages }).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, contextClusters, sheet, dataset, activeProfile, reviews, facetFilters, pathFindingIds, search, actionOnly]); + }, [engines.report, engines.context, contextClusters, workPackages, sheet, dataset, activeProfile, reviews, facetFilters, pathFindingIds, search, actionOnly]); const summary = useMemo(() => measureNavigationStep('security-domain-summary', () => summarizeSecurityDomains({ sheet, dataset, @@ -93,43 +102,50 @@ export function SidebarSecurityDomains({ sheet }: { sheet: DatasetSheet | undefi pathFindingIds, search, actionOnly, - contextClusters - })), [actionOnly, activeProfile, contextClusters, dataset, facetFilters, pathFindingIds, reviews, search, sheet, statusFilter]); + contextClusters, workPackages + })), [actionOnly, activeProfile, contextClusters, workPackages, dataset, facetFilters, pathFindingIds, reviews, search, sheet, statusFilter]); if (!summary) return null; - const selected = facetFilters[summary.facetId]?.values ?? []; - return ( -
- - {summary.domains.map(domain => { - const active = selected.includes(domain.id); - return ; - })} - {impactCounts &&
- - {impactCounts.map(theme => { - const active = facetFilters[BUSINESS_IMPACT_FACET_ID]?.values?.includes(theme.id) ?? false; - return ; - })} -
} -
- ); + const facets = selectWorkFacets(sheet, { dataset, activeProfile, reviews, statusFilter, facetFilters, + pathFindingIds, search, actionOnly, contextClusters, workPackages }); + const select = (id: string, value: string, deviations = false) => { + if (deviations) setStatusFilter('deviation'); + const selected = facetFilters[id]?.values ?? []; + const next = toggleSecurityDomainSelection(selected, value); + setFacetFilter(id, next.length ? { values: next } : null); + }; + const groups: FilterPanelGroup[] = [{ + id: summary.facetId, label: 'Security Domain', + values: summary.domains.map(domain => ({ value: domain.id, label: domain.label, count: domain.count })), + selected: facetFilters[summary.facetId]?.values ?? [], onSelect: value => select(summary.facetId, value) + }]; + if (engines.report && engines.context) groups.push({ + id: BUSINESS_IMPACT_FACET_ID, label: 'Business Impact', disableEmpty: true, + values: impactCounts?.map(theme => ({ value: theme.id, label: theme.label, count: theme.count })) ?? [], + selected: facetFilters[BUSINESS_IMPACT_FACET_ID]?.values ?? [], onSelect: value => select(BUSINESS_IMPACT_FACET_ID, value, true), + unavailable: impactCounts ? undefined : 'Context is loading or unavailable.' + }); + if (engines.context) groups.push({ + id: CONTEXT_CLUSTER_FACET_ID, label: 'Context Cluster', + values: (facets.find(facet => facet.id === CONTEXT_CLUSTER_FACET_ID)?.values ?? []).map(value => ({ ...value, + label: numberedClusterLabel(value.value, value.label, contextClusters?.publication?.clusters) })).sort((a, b) => + comparePublishedClusters(a.value, b.value, contextClusters?.publication?.clusters)), + selected: facetFilters[CONTEXT_CLUSTER_FACET_ID]?.values ?? [], onSelect: value => select(CONTEXT_CLUSTER_FACET_ID, value), + unavailable: contextClusters ? undefined : 'Context is loading or unavailable.' + }); + if (engines.remediation) groups.push({ + id: WORK_PACKAGE_FACET_ID, label: 'Work Package', + values: (facets.find(facet => facet.id === WORK_PACKAGE_FACET_ID)?.values ?? []).map(value => { + const workPackage = workPackages?.packages.find(candidate => candidate.id === value.value); + const cluster = contextClusters?.publication?.clusters.find(candidate => candidate.id === workPackage?.clusterId); + return { ...value, label: workPackage ? workPackageCode(workPackage.code) : value.label, + section: workPackage ? { id: workPackage.clusterId, label: numberedClusterLabel(workPackage.clusterId, workPackage.clusterLabel, contextClusters?.publication?.clusters) } : undefined, + clusterColour: workPackage ? publishedClusterColour(cluster) : undefined }; + }).sort((a, b) => Number(!a.section) - Number(!b.section) + || comparePublishedClusters(a.section?.id ?? '', b.section?.id ?? '', contextClusters?.publication?.clusters) + || a.label.localeCompare(b.label, 'en', { numeric: true }) || a.value.localeCompare(b.value)), + selected: facetFilters[WORK_PACKAGE_FACET_ID]?.values ?? [], onSelect: value => select(WORK_PACKAGE_FACET_ID, value), + unavailable: workPackages ? undefined : 'Work packages are loading or unavailable.' + }); + return ; } diff --git a/src/features/sidebar/context-cluster-display.ts b/src/features/sidebar/context-cluster-display.ts new file mode 100644 index 0000000..ad6ecca --- /dev/null +++ b/src/features/sidebar/context-cluster-display.ts @@ -0,0 +1,20 @@ +export type ClusterColour = 'critical' | 'high' | 'medium' | 'undetermined'; +type PublishedCluster = { id: string }; +/** One display rule for every consumer of the published Context severity. */ +export function publishedClusterColour(cluster?: { hasEvidence: boolean; severity: ClusterColour }): ClusterColour { + return cluster?.hasEvidence ? cluster.severity : 'undetermined'; +} +/** The published array already has the exact heatmap order, including ties and empty cells. */ +export function comparePublishedClusters(leftId: string, rightId: string, clusters: readonly PublishedCluster[] = []): number { + const position = (id: string) => { const index = clusters.findIndex(cluster => cluster.id === id); return index < 0 ? Number.MAX_SAFE_INTEGER : index; }; + return position(leftId) - position(rightId) || leftId.localeCompare(rightId); +} +/** Visible numbers are positions in this scope's complete heatmap, never catalog identifiers. */ +export function contextClusterNumber(id: string, clusters: readonly PublishedCluster[] = []): string | undefined { + const index = clusters.findIndex(cluster => cluster.id === id); + return index < 0 ? undefined : String(index + 1).padStart(2, '0'); +} +export function numberedClusterLabel(id: string, label: string, clusters: readonly PublishedCluster[] = []) { + const number = contextClusterNumber(id, clusters); + return number && !label.startsWith(`${number} · `) ? `${number} · ${label}` : label; +} \ No newline at end of file diff --git a/src/features/view-bookmarks/ViewBookmarks.tsx b/src/features/view-bookmarks/ViewBookmarks.tsx index 4ecf679..d002364 100644 --- a/src/features/view-bookmarks/ViewBookmarks.tsx +++ b/src/features/view-bookmarks/ViewBookmarks.tsx @@ -119,8 +119,8 @@ export function ViewBookmarks({ mode = 'view', path, onApplyPathSnapshot }: View
{dialogOpen &&
setDialogOpen(false)}>
event.stopPropagation()}> - {mode === 'path' ? 'Name path snapshot' : 'Name view bookmark'} - + {mode === 'path' ? 'Name path snapshot' : 'Name view snapshot'} + setName(event.target.value)} /> {duplicateName && This name is already in use.}
diff --git a/src/importers/maester-html-source-extractor.ts b/src/importers/maester-html-source-extractor.ts index def3db1..6a3ee4f 100644 --- a/src/importers/maester-html-source-extractor.ts +++ b/src/importers/maester-html-source-extractor.ts @@ -38,8 +38,13 @@ function maesterPayload(document: DecodedSourceDocument): MaesterPayload { let attempts = 0; for (const script of document.rawText.matchAll(/]*)>([\s\S]*?)<\/script\s*>/gi)) { const text = script[2]; - const starts = /\btype\s*=\s*["']application\/json["']/i.test(script[1]) - ? [{ start: text.search(/\S/), binding: 'json-data' }] + // Current Maester templates put `const testResults=` in an + // application/json block. Read only the literal after its declaration; + // never execute the loader or any script from the report. + const declaration = /^\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*/.exec(text); + const jsonData = /\btype\s*=\s*["']application\/json["']/i.test(script[1]); + const starts = jsonData + ? [{ start: declaration ? declaration[0].length : text.search(/\S/), binding: declaration?.[1] ?? 'json-data' }] : reportObjects(text); let consumedUntil = -1; for (const { start, binding } of starts) { @@ -53,6 +58,9 @@ function maesterPayload(document: DecodedSourceDocument): MaesterPayload { } try { const embedded = readStaticReportObject(text, start); + if (jsonData && !/^\s*;?\s*$/.test(text.slice(embedded.end))) { + throw new StaticReportLiteralError('Unexpected content after the report data.'); + } consumedUntil = embedded.end; const value = embedded.value; if (Array.isArray(value.Tenants)) { @@ -151,7 +159,7 @@ function testCandidate(fileName: string, test: Record, path: export const maesterHtmlSourceExtractor: SourceExtractor = { id: 'maester-html-source-extractor', - version: '1.2.0', + version: '1.2.1', detect(document) { const payload = maesterPayload(document); const tests = payload.roots.reduce((count, root) => count + (Array.isArray(root.value.Tests) ? root.value.Tests.length : 0), 0); diff --git a/src/main.tsx b/src/main.tsx index 21ff46a..06db2b1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './app/App'; import { AppProvider } from './app/AppProvider'; +import { RelatedFindingsProvider } from './features/related-findings/RelatedFindings'; import '@fontsource-variable/inter/wght.css'; import './styles/layers.css'; import './styles/ui-tokens.css'; @@ -36,7 +37,7 @@ if (import.meta.env.DEV && new URLSearchParams(location.search).get('measureNavi createRoot(document.getElementById('root')!).render( - + ); diff --git a/src/profiles/pingcastle/result-evidence.ts b/src/profiles/pingcastle/result-evidence.ts new file mode 100644 index 0000000..64aac12 --- /dev/null +++ b/src/profiles/pingcastle/result-evidence.ts @@ -0,0 +1,34 @@ +import type { DatasetSheet } from '../../domain/dataset'; +import type { FindingRecord } from '../../domain/finding'; + +// Reviewed native RiskRule detail layouts. These are affected lists, not the +// anchor-linked domain inventories. Header guards fail closed for new variants. +const affectedDetails: Readonly> = { + 'S-OldNtlm': ['GPO', 'Value'], 'P-Delegated': ['DN', 'Reason'], + 'P-AdminLogin': ['DC', 'LoginTime'], 'P-ServiceDomainAdmin': ['DN'], + 'P-ProtectedUsers': ['User'], 'P-UnprotectedOU': ['OU'], 'A-AdminSDHolder': ['DN'], + 'A-CertTempCustomSubject': ['Name', 'CA', 'EnrollmentPrincipals', 'LastModified'], + 'S-DC-SubnetMissing': ['Domain controller', 'ip'], + 'A-AuditDC': ['Type', 'Audit', 'Problem', 'Rationale', 'Domain controller'], + 'A-MinPwdLen': ['GPO'], 'A-DC-Spooler': ['Domain controller'], + 'S-SMB-v1': ['Domain controller'], + 'A-HardenedPaths': ['GPO', 'Key', 'RequireIntegrity', 'RequireMutualAuthentication', 'RequirePrivacy'], + 'A-PreWin2000Other': ['DN'], 'A-WeakRSARootCert2': ['Source', 'Subject', 'Module', 'Expires'], + 'S-PwdNeverExpires': ['DN'], 'A-DsHeuristicsLDAPSecurity': ['Setting', 'Position', 'Value'], + 'A-DnsZoneAUCreateChild': ['DNSZone'], 'A-DCLdapsProtocolAdvanced': ['DC', 'Protocol'], + 'S-FolderOptions': ['Extension', 'Reason'], 'S-DefenderASR': ['Guid', 'Label', 'Reason'], + 'S-FirewallScript': ['Program', 'Reason'], 'S-TerminalServicesGPO': ['GPO', 'Reason'] +}; + +/** Proof from the retained exact rule/import identity and native embedded detail section. */ +export function pingCastleAffectedDetailRow(finding: FindingRecord, sheet: DatasetSheet, row: FindingRecord): boolean { + const ids = finding.fields.filter(field => /^(?:Source Rule ID|Rule ID|Risk ID)$/i.test(field.label.trim()) && field.value.trim()); + const distinct = [...new Set(ids.map(field => field.value.trim()))]; + if (distinct.length !== 1 || !finding.importFileId) return false; + const ruleId = distinct[0]; const headers = affectedDetails[ruleId]; + if (!headers || sheet.id !== `pingcastle-section-${finding.importFileId}::rule-details-${ruleId}`) return false; + const linkedIds = finding.fields.filter(field => field.label === 'Verwandte Daten').flatMap(field => field.value.split(',').map(value => value.trim())); + if (!linkedIds.includes(`${finding.importFileId}::rule-details-${ruleId}`)) return false; + if (sheet.analysis.headers.length !== headers.length || headers.some((header, index) => sheet.analysis.headers[index] !== header)) return false; + return row.fields.some(field => field.value.trim() !== '') && sheet.analysis.records.includes(row); +} diff --git a/src/profiles/purple-knight/result-evidence.ts b/src/profiles/purple-knight/result-evidence.ts new file mode 100644 index 0000000..9d6f13c --- /dev/null +++ b/src/profiles/purple-knight/result-evidence.ts @@ -0,0 +1,41 @@ +import type { DatasetSheet } from '../../domain/dataset'; +import type { FindingRecord } from '../../domain/finding'; + +// Reviewed Dummy result layouts: indicator-specific affected objects, including +// observations of changes. Red denotes scanner-reported affected evidence. +const layouts: Readonly> = { + SI000084: ['DomainName', 'Ignored'], SI000079: ['Access', 'DistinguishedName', 'Ignored'], + SI000080: ['Identity', 'Access', 'DistinguishedName', 'Ignored'], + SI000151: ['DistinguishedName', 'SupportedEncryptionTypes', 'EventTimestamp', 'Ignored'], + SI000055: ['Attribute', 'DistinguishedName', 'EventTimestamp', 'Ignored'], + SI000096: ['DistinguishedName', 'PasswordPolicyDistinguishedName', 'MaxAge', 'MinAge', 'MinLength', 'ComplexityEnabled', 'History', 'Ignored'], + SI000028: ['DistinguishedName', 'EventTimestamp', 'Ignored'], + SI000029: ['DistinguishedName', 'SamAccountName', 'PasswordLastSet', 'ServicePrincipalName', 'Ignored'], + SI000067: ['DistinguishedName', 'SamAccountName', 'PasswordLastSet', 'ServicePrincipalName', 'Ignored'], + SI000020: ['DistinguishedName', 'SamAccountName', 'Enabled', 'Ignored'], + SI000104: ['DistinguishedName', 'Member', 'Operation', 'EventTimestamp', 'Ignored'], + SI000351: ['DistinguishedName', 'UsersWithWriteAccess', 'Ignored'], + SI000034: ['DistinguishedName', 'SamAccountName', 'Type', 'EventTimestamp', 'Ignored'], + SI000095: ['HostName', 'IPAddress', 'NTLMHttpsStatus', 'NTLMHttpStatus', 'Ignored'], + SI000077: ['HostName', 'Ignored'], SI000036: ['HostName', 'Ignored'], + SI000157: ['DistinguishedName', 'CertificateTemplateName', 'SANEnabled', 'CertificateCanBeUsedForAuthentication', 'Published', 'Ignored'], + SI000156: ['DistinguishedName', 'CertificateTemplateName', 'PotentialAbusableProblems', 'Published', 'Ignored'], + SI000011: ['DistinguishedName', 'MachineAccountQuota', 'Ignored'], + SI000302: ['user', 'privilege', 'policy', 'policyName', 'status', 'LinkedOUs', 'Ignored'] +}; + +export function purpleKnightAffectedResultRow(finding: FindingRecord, sheet: DatasetSheet, row: FindingRecord): boolean { + const ids = [...new Set(finding.fields.filter(field => /^(?:Source Rule ID|ShortName)$/i.test(field.label.trim()) && field.value.trim()).map(field => field.value.trim()))]; + if (ids.length !== 1) return false; + const headers = layouts[ids[0]]; + if (!headers || sheet.analysis.headers.length !== headers.length || headers.some((header, index) => sheet.analysis.headers[index] !== header)) return false; + const sourceFile = finding.fields.find(field => /^(?:source: file|source file|quelle: datei)$/i.test(field.label.trim()))?.value.trim() || finding.sourceFile; + if (!sourceFile || sheet.sourceFile !== sourceFile || sheet.sourceTableIndex === undefined) return false; + const indices = finding.fields.filter(field => /^(?:results?|result file)$/i.test(field.label.trim())) + .flatMap(field => [...field.value.matchAll(/source-table:(\d+)(?=\s|$)/g)].map(match => Number(match[1]))); + const nativeIndicatorLink = finding.fields.some(field => /^Result$/i.test(field.label.trim()) && field.value.trim() === ids[0]) + && (sheet.sourceSheet === ids[0] || sheet.name.split(' · ').at(-1) === ids[0]); + if (!indices.includes(sheet.sourceTableIndex) && !nativeIndicatorLink) return false; + const ignored = row.fields.filter(field => field.label === 'Ignored'); + return ignored.length === 1 && /^false$/i.test(ignored[0].value.trim()) && sheet.analysis.records.includes(row); +} diff --git a/src/remediation/FindingWorkPackage.tsx b/src/remediation/FindingWorkPackage.tsx new file mode 100644 index 0000000..c206cc6 --- /dev/null +++ b/src/remediation/FindingWorkPackage.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react'; +import { workPackageCode } from './work-package-label'; +import type { FindingRecord } from '../domain/finding'; +import { useWorkPackages } from './use-work-packages'; +import { useOptionalApp } from '../app/AppProvider'; +import { useVisibleWithFacet } from '../features/search-filter/use-filtered-records'; +import { WORK_PACKAGE_FACET_ID, type WorkPackage } from './remediation-engine'; +import { workRecordKey } from './work-package-index'; + +const field = (record: FindingRecord, label: string) => record.fields.find(f => f.label.trim().toLowerCase() === label)?.value.trim() ?? ''; +/** Names a finding by its source and original rule ID; the title only where no rule ID is retained. */ +function sourceName(record: FindingRecord) { + const source = field(record, 'technical identity') ? 'GPO' : field(record, 'source: scan').split(' · ')[0]; + const rule = field(record, 'source rule id') || record.title; + return source ? `${source} ${rule}` : rule; +} + +export function FindingWorkPackage({ record, visibleRecords, clusterSeverities = {}, clusterLabels = {} }: { record: FindingRecord; visibleRecords?: FindingRecord[]; + clusterLabels?: Record; + clusterSeverities?: Record }) { + const state = useWorkPackages(); + const app = useOptionalApp(); + if (state.status === 'off') return null; + if (state.status !== 'ready') return + Work Package: {state.status === 'loading' ? 'Loading…' : 'Unavailable'}; + const workPackage = state.index.packageFor(record); + if (!workPackage) return state.index.unassigned(record) ? Work Package: Unassigned : null; + const key = workRecordKey(record); + const task = workPackage.tasks.find(candidate => candidate.findingKeys.includes(key)); + const others = task?.redundant ? task.findingKeys.filter(other => other !== key) : []; + const selectPackage = () => app?.setFacetFilter(WORK_PACKAGE_FACET_ID, { values: [workPackage.id] }); + // One flex item beside Source Rule ID: the task line continues the link inline instead of wrapping as its own item. + return + { event.preventDefault(); selectPackage(); }} + title={`${workPackage.title} · ${workPackage.facts.tasks} tasks · ${workPackage.facts.findings} findings · ${workPackage.reason}`}> + + {task && + {/* No ordinal: tasks have no sequence, package and task order are identifiers only. */} + {' '}· One of {workPackage.tasks.length} {workPackage.tasks.length === 1 ? 'task' : 'tasks'} + {others.length > 0 && app && } + } + ; +} + +/** + * Links to the redundant findings of the same task, resolved in the current Work sheet. + * Variant 1 (user decision 2026-10-09): a click sets only the package filter and selects the + * finding; other Work filters stay, and a finding they hide is reported, never revealed silently. + */ +function RedundantFindings({ workPackage, others, onPackage, visibleRecords }: { workPackage: WorkPackage; others: string[]; onPackage(): void; visibleRecords?: FindingRecord[] }) { + const app = useOptionalApp(); + const visibleWith = useVisibleWithFacet(); + const [hidden, setHidden] = useState(); + const sheetRecords = visibleRecords ?? app?.activeSheet?.analysis.records ?? []; + const linked = others.map(other => sheetRecords.find(candidate => workRecordKey(candidate) === other)).filter((r): r is FindingRecord => Boolean(r)); + const missing = others.length - linked.length; + const open = (target: FindingRecord) => { + if (!visibleWith(WORK_PACKAGE_FACET_ID, [workPackage.id], target.id)) { setHidden(sourceName(target)); return; } + setHidden(undefined); + onPackage(); + app?.setSelectedRecord(target.id); + }; + return <> + · Same change as {linked.map((target, index) => + {index > 0 && ', '} + { event.preventDefault(); open(target); }} title="Show this finding in Work">{sourceName(target)} + )}{missing > 0 && `${linked.length ? ', ' : ''}${missing} further ${missing === 1 ? 'finding' : 'findings'} outside this sheet`} + {hidden && · {hidden} is hidden by the current Work filters.} + ; +} diff --git a/src/remediation/WorkPackagesWindow.tsx b/src/remediation/WorkPackagesWindow.tsx new file mode 100644 index 0000000..4ddfe0e --- /dev/null +++ b/src/remediation/WorkPackagesWindow.tsx @@ -0,0 +1,146 @@ +import { Fragment, useMemo, useState } from 'react'; +import { workPackageCode } from './work-package-label'; +import { FloatingDataWindow } from '../components/FloatingDataWindow/FloatingDataWindow'; +import type { WorkPackage, WorkPlatform } from './remediation-engine'; +import type { WorkPackageOverview, WorkPackageOverviewRow } from './work-package-overview'; + +const PLATFORM_LABEL: Record = { + 'windows-host': 'Windows host / GPO', 'active-directory': 'Active Directory', cloud: 'Cloud / M365', other: 'Other' +}; +const SOURCE_LABEL: Record = { + 'cis-cat': 'CIS-CAT', 'group-policy': 'GPO', maester: 'Maester', pingcastle: 'PingCastle', 'purple-knight': 'Purple Knight' +}; +const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info']; +const text = (a: string, b: string) => a.localeCompare(b, 'en', { numeric: true }); + +type Column = 'package' | 'measure' | 'platform' | 'tasks' | 'findings' | 'redundant' | 'contextSeverity' | 'closed' | 'sources'; +const COLUMNS: Array<{ id: Column; label: string; numeric?: boolean }> = [ + { id: 'package', label: 'Package' }, { id: 'measure', label: 'Measure' }, { id: 'platform', label: 'Platform' }, + { id: 'tasks', label: 'Tasks', numeric: true }, { id: 'findings', label: 'Findings', numeric: true }, + { id: 'redundant', label: 'Redundant findings', numeric: true }, { id: 'contextSeverity', label: 'Context severity', numeric: true }, + { id: 'closed', label: 'Closed conditions', numeric: true }, + { id: 'sources', label: 'Sources' } +]; +const compare: Record number> = { + package: (a, b) => text(a.clusterLabel, b.clusterLabel) || text(a.code, b.code), + measure: (a, b) => text(a.title, b.title), + platform: (a, b) => text(PLATFORM_LABEL[a.platform], PLATFORM_LABEL[b.platform]), + tasks: (a, b) => a.facts.tasks - b.facts.tasks, + findings: (a, b) => a.facts.findings - b.facts.findings, + redundant: (a, b) => a.facts.redundantFindings - b.facts.redundantFindings, + contextSeverity: () => 0, + closed: (a, b) => a.facts.conditionsClosed - b.facts.conditionsClosed || a.facts.conditionsTouched - b.facts.conditionsTouched, + sources: (a, b) => text(a.facts.sources.join(), b.facts.sources.join()) +}; + +/** + * Sortable overview of the work packages of the current scope. Source-backed facts only: + * sorting is the viewer's choice, package numbers stay identifiers, never priorities. + */ +export function WorkPackagesWindow({ overview, clusterLabels = {}, clusterSeverities = {}, clusterOrder = [], onSelect, onSelectUnassigned, onSelectRelated, onClose }: { + overview: WorkPackageOverview; clusterLabels?: Record; + clusterSeverities?: Record; + clusterOrder?: readonly string[]; + onSelect(workPackage: WorkPackage): void; onSelectUnassigned(): void; onSelectRelated(): void; onClose(): void; +}) { + const [sort, setSort] = useState<{ column: Column; direction: 'ascending' | 'descending' }>({ column: 'package', direction: 'ascending' }); + const [filters, setFilters] = useState>>({}); + const packageLabel = (p: WorkPackage) => `${clusterLabels[p.clusterId] ?? p.clusterLabel} → ${workPackageCode(p.code)}`; + const packageById = new Map(overview.rows.map(row => [row.workPackage.id, row.workPackage])); + const contextSeverity = (p: WorkPackage) => clusterSeverities[p.clusterId] ?? 'undetermined'; + const contextRank = (p: WorkPackage) => contextSeverity(p) === 'undetermined' ? 0 : SEVERITY_ORDER.length - SEVERITY_ORDER.indexOf(contextSeverity(p)); + const values = (row: WorkPackageOverviewRow): Record => { + const p = row.workPackage; + return { package: p.id, measure: [p.title, ...p.measures.map(m => m.title)].join(' '), platform: PLATFORM_LABEL[p.platform], + tasks: String(row.tasks), findings: String(row.findings), redundant: row.redundantFindings ? String(row.redundantFindings) : '—', + contextSeverity: contextSeverity(p), + closed: p.facts.conditionsTouched ? `${p.facts.conditionsClosed} of ${p.facts.conditionsTouched}` : '—', + sources: p.facts.sources.map(s => SOURCE_LABEL[s] ?? s).join(', ') }; + }; + const terms = (column: Column, value: string) => column === 'sources' ? value.split(', ') : [value]; + const matches = (cells: Record) => Object.entries(filters).every(([column, value]) => + !value || terms(column as Column, cells[column as Column]).includes(value)); + const unassignedCells = Object.fromEntries(COLUMNS.map(c => [c.id, c.id === 'package' ? 'Unassigned' : c.id === 'findings' + ? String(overview.unassigned) : c.id === 'measure' ? 'No package assignment.' : '—'])) as Record; + const showUnassigned = matches(unassignedCells); + const optionsFor = (column: Column) => [...new Set([ + ...overview.rows.flatMap(row => terms(column, values(row)[column])), ...terms(column, unassignedCells[column]) + ])].sort((a, b) => column === 'contextSeverity' + ? (SEVERITY_ORDER.indexOf(a) < 0 ? 99 : SEVERITY_ORDER.indexOf(a)) - (SEVERITY_ORDER.indexOf(b) < 0 ? 99 : SEVERITY_ORDER.indexOf(b)) || text(a, b) + : column === 'package' ? Number(!packageById.has(a)) - Number(!packageById.has(b)) + || text(packageById.has(a) ? packageLabel(packageById.get(a)!) : a, packageById.has(b) ? packageLabel(packageById.get(b)!) : b) + : text(a, b)); + // Cluster groups always follow the published heatmap. Column sorting applies inside a group. + const clusterPosition = (id: string) => { const index = clusterOrder.indexOf(id); return index < 0 ? Number.MAX_SAFE_INTEGER : index; }; + const compareClusters = (a: WorkPackage, b: WorkPackage) => clusterPosition(a.clusterId) - clusterPosition(b.clusterId) + || text(clusterLabels[a.clusterId] ?? a.clusterLabel, clusterLabels[b.clusterId] ?? b.clusterLabel); + const compareRows = (a: WorkPackageOverviewRow, b: WorkPackageOverviewRow) => sort.column === 'package' ? text(packageLabel(a.workPackage), packageLabel(b.workPackage)) + : sort.column === 'contextSeverity' ? contextRank(a.workPackage) - contextRank(b.workPackage) + : sort.column === 'tasks' ? a.tasks - b.tasks + : sort.column === 'findings' ? a.findings - b.findings : sort.column === 'redundant' ? a.redundantFindings - b.redundantFindings + : compare[sort.column](a.workPackage, b.workPackage); + const rows = useMemo(() => overview.rows.filter(row => matches(values(row))).sort((a, b) => + compareClusters(a.workPackage, b.workPackage) || (sort.direction === 'ascending' ? compareRows(a, b) : compareRows(b, a)) + || text(a.workPackage.code, b.workPackage.code)), [overview.rows, sort, filters, clusterLabels, clusterSeverities, clusterOrder]); + const toggle = (column: Column, numeric?: boolean) => setSort(current => current.column === column + ? { column, direction: current.direction === 'ascending' ? 'descending' : 'ascending' } + : { column, direction: numeric ? 'descending' : 'ascending' }); + const redundantFindings = overview.rows.reduce((total, row) => total + row.redundantFindings, 0); + const number = (value: number) => value.toLocaleString('en-US'); + const count = (value: number, singular: string) => `${number(value)} ${singular}${value === 1 ? '' : 's'}`; + return +
+
+
Findings
{number(overview.records)}
+
Assigned
{number(overview.assigned)}
+
Unassigned
+
Packages
{number(overview.rows.length)}
+
Tasks
{number(overview.tasks)}
+
Redundant findings
+
+

One cluster and platform per package · Max. 8 tasks (GPO: 15). Groups follow the heatmap; columns sort within groups.

+ {Object.values(filters).some(value => value?.trim()) &&

+ Table: {count(rows.length, 'package')} of {number(overview.rows.length)}{showUnassigned ? ' + Unassigned' : ''}. + +

} +
+ {COLUMNS.map(column => )} + {rows.map((row, index) => { const p = row.workPackage; return + {(index === 0 || rows[index - 1].workPackage.clusterId !== p.clusterId) && + + } + + + + + + + + + ; })} + {showUnassigned && + + + + } + +
+ + {column.id !== 'measure' && } +
{p.title}{p.measures.length > 1 && + {p.measures.length - 1} further {p.measures.length === 2 ? 'measure' : 'measures'}}{PLATFORM_LABEL[p.platform]}{row.tasks}{row.findings}{row.redundantFindings || '—'} + {contextSeverity(p)[0].toUpperCase() + contextSeverity(p).slice(1)}{p.facts.conditionsTouched ? `${p.facts.conditionsClosed} of ${p.facts.conditionsTouched}` : '—'}{p.facts.sources.map(s => SOURCE_LABEL[s] ?? s).join(', ')}
No package assignment.——{number(overview.unassigned)}————
+
+
; +} diff --git a/src/remediation/intervention-catalog.ts b/src/remediation/intervention-catalog.ts new file mode 100644 index 0000000..c6d209a --- /dev/null +++ b/src/remediation/intervention-catalog.ts @@ -0,0 +1,316 @@ +/** Reviewed against the complete Dummy Work population (both assessments). + * Conditions are published Context results, never imports of its catalogue. + * Broad Context conditions alone cannot establish a shared intervention: source + * setting anchors distinguish the actual change target. No scores or verdicts. + */ +interface Candidate { tool: string; title: string; outcome: string; identityKey?: string; scope?: string } +interface Intervention { + id: string; cluster: string; title: string; reason: string; + conditions?: readonly string[]; settings?: readonly string[]; warnings?: boolean; +} +const entries: Intervention[] = []; +function add(cluster: string, id: string, title: string, reason: string, + conditions: string[] = [], settings: string[] = [], warnings = false) { + entries.push({ cluster, id, title, reason, conditions, settings, warnings }); +} +// Exact published semantic conditions can span scanner evidence and GPO declarations. +add('ad-persistence-indicators', 'admin-protection', 'Review protected administrative object permissions', + 'Review AdminSDHolder permissions and stale adminCount protection together; retain each source assertion.', + ['ad-stale-admincount', 'adminsdholder-permissions-changed']); +add('ad-privileged-account-exposure', 'admin-identities', 'Protect and restrict administrative identities', + 'Review Protected Users membership and use of the built-in Administrator in the same privileged identity inventory.', + ['ad-admins-not-protected-users', 'ad-builtin-admin-used']); +add('ad-privileged-account-exposure', 'directory-write-protection', 'Review privileged directory write protection', + 'Review privileged object permissions and LDAP write mitigations; each source remains a separate technical check.', + ['dsheuristics-ldap-mitigation-off', 'ad-shadow-credentials-privileged']); +add('ad-reconnaissance-exposure', 'anonymous-directory', 'Restrict anonymous and legacy directory read access', + 'Review anonymous LDAP and Pre-Windows 2000 permissions as one directory access change.', + ['anonymous-ldap-bind', 'prewin2000-access-broad', 'prewin2000-membership-changed']); +add('ad-reconnaissance-exposure', 'anonymous-windows', 'Restrict unauthenticated Windows enumeration', + 'Review exposed named pipes and network session enumeration permissions together.', + ['anonymous-named-pipes', 'net-session-enumeration-open']); +add('ad-recovery-gaps', 'object-recovery', 'Prepare directory object recovery', + 'Verify backup freshness, Recycle Bin and accidental deletion protection in the same directory recovery procedure.', + ['ad-recycle-bin-disabled', 'ad-backup-outdated', 'ou-deletion-unprotected']); +add('ad-recovery-gaps', 'dc-resilience', 'Review domain controller topology and redundancy', + 'Review controller redundancy and subnet declarations within the same site topology.', + ['dc-redundancy-missing', 'dc-subnet-declaration-incomplete']); +add('adcs-escalation', 'certificate-templates', 'Review certificate template issuance permissions', + 'Review requester-controlled certificate properties and insecure template configuration together.', + ['adcs-template-requester-san', 'adcs-template-insecure-config']); +add('cloud-app-consent-exposure', 'app-lifecycle', 'Review application and credential lifecycles', + 'Inventory applications, renew expiring credentials and remove unused credentials or applications after review.', + ['entra-app-credentials-expiring', 'entra-app-credentials-stale'], [], true); +add('cloud-app-consent-exposure', 'consent-workflow', 'Restrict application consent and enable approval', + 'Configure who can consent and how users request administrative approval as one consent workflow.', + ['entra-users-can-consent-apps', 'entra-admin-consent-workflow-missing']); +add('cloud-external-access-exposure', 'guest-invitation', 'Restrict guest invitations and allowed domains', + 'Coordinate guest inviter permissions and approved external domains.', + ['entra-guest-invites-unrestricted', 'entra-guest-invite-domains-unrestricted']); +add('cloud-external-access-exposure', 'sharepoint-sharing', 'Restrict SharePoint external sharing', + 'Configure SharePoint sharing scope and approved domain or group restrictions together.', ['sharepoint-external-sharing-broad']); +add('cloud-external-access-exposure', 'self-service-signup', 'Restrict tenant self-service signup', + 'Review email subscription signup and tenant joining by email validation together.', ['entra-self-service-signup-allowed']); +add('cloud-sign-in-protection-gaps', 'risk-policies', 'Coordinate risky user and sign-in policies', + 'Review risky user and sign-in responses together, retaining the individual policy requirements.', [], + ['high risk', 'sign-in risk', 'sign in risk', 'risky sign', 'user risk']); +add('cloud-sign-in-protection-gaps', 'authentication-methods', 'Harden authentication methods', + 'Coordinate phishing-resistant methods, weak method restrictions and FIDO key requirements.', [], + ['phishing-resistant', 'phishing resistant', 'sms', 'fido', 'third-party software']); +add('cloud-sign-in-protection-gaps', 'authenticator-fatigue', 'Configure Authenticator prompts and suspicious activity reporting', + 'Review Authenticator login context, OTP and suspicious activity reporting together.', ['entra-mfa-fatigue-protection-weak'], + ['login context', 'suspicious activity', 'otp']); +add('cloud-sign-in-protection-gaps', 'registration', 'Secure authentication method registration', + 'Coordinate MFA registration requirements and trusted registration locations or devices.', + ['entra-mfa-registration-incomplete', 'entra-mfa-registration-unrestricted'], + ['registration', 'register security']); +add('cloud-sign-in-protection-gaps', 'device-compliance', 'Enforce managed device compliance', + 'Review managed device access, compliance policy coverage and device cleanup together.', [], + ['intune', 'compliant', 'managed device']); +add('cloud-sign-in-protection-gaps', 'legacy-authentication', 'Block legacy authentication', + 'Coordinate Conditional Access restrictions for legacy authentication clients.', [], ['legacy authentication']); +add('cloud-sign-in-protection-gaps', 'conditional-access-integrity', 'Repair Conditional Access references and exclusions', + 'Review deleted objects, fallback exclusions and directory synchronization scoping in the same policy inventory.', [], + ['deleted', 'non-existent', 'nonexistent', 'fallback', 'directory sync', 'directory synchronization']); +add('cloud-sign-in-protection-gaps', 'conditional-access-coverage', 'Review Conditional Access coverage', + 'Review unsupported platforms, application restrictions and uncovered access targets in the same policy rollout.', [], + ['insider risk', 'azure devops', 'unsupported', 'application restrictions', 'application enforced restrictions']); +add('cloud-sign-in-protection-gaps', 'session-controls', 'Configure sign-in session controls', + 'Coordinate persistent browser sessions and sign-in frequency.', [], ['persistent', 'sign-in frequency']); +add('cloud-sign-in-protection-gaps', 'password-protection', 'Configure cloud password protection', + 'Review password protection mode and smart lockout together.', [], ['password protection', 'smart lockout']); +add('credential-relay-exposure', 'name-resolution', 'Disable unnecessary legacy name resolution', + 'Review multicast and NetBIOS name resolution fallback in one network policy rollout.', [], + ['multicast', 'netbios', 'netbt', 'llmnr']); +add('credential-relay-exposure', 'dns-permissions', 'Harden DNS trust and update permissions', + 'Review DNS client configuration, DNS server selection and directory DNS update permissions.', + ['gpo-dns-client-policy-deviates', 'adidns-authenticated-create'], ['dns servers']); +add('credential-relay-exposure', 'proxy-discovery', 'Restrict proxy discovery and proxy authentication', + 'Review WPAD and loopback proxy authentication together.', [], ['wpad', 'proxy']); +add('credential-relay-exposure', 'ldap-transport', 'Require protected LDAP transport', + 'Coordinate LDAP signing, channel binding and domain controller TLS settings.', ['dc-ldaps-legacy-tls'], ['ldap server', 'ldaps']); +add('credential-relay-exposure', 'smb-audit', 'Enable SMB client and server security auditing', + 'Enable the related SMB guest, signing and encryption audit events in one logging rollout.', [], ['audit']); +add('credential-relay-exposure', 'smb-guest', 'Remove insecure SMB guest and mailslot access', + 'Review insecure guest access and remote mailslots on the SMB client and server.', [], ['mailslot', 'insecure guest']); +add('credential-relay-exposure', 'smb-protocol', 'Enforce SMB protocol protection', + 'Coordinate SMB signing, encryption and minimum protocol versions.', [], + ['digitally sign', 'minimum smb', 'minimum version of smb', 'require encryption']); +add('credential-relay-exposure', 'smb-rate', 'Configure SMB authentication rate limiting', + 'Review SMB authentication rate limiter enablement and delay together.', [], ['rate limit']); +add('credential-relay-exposure', 'unc-paths', 'Harden UNC paths', + 'Review mutual authentication and integrity requirements for the retained UNC paths.', [], ['hardened unc', 'hardened paths']); +add('credential-relay-exposure', 'ntlm-protocol', 'Restrict NTLM versions and session security', + 'Coordinate LAN Manager authentication level and NTLM client/server minimum session security.', [], + ['lan manager', 'minimum session security', 'old ntlm', 'ntlmv1']); +add('credential-relay-exposure', 'ntlm-identities', 'Restrict fallback authentication identities', + 'Review LocalSystem NTLM identity, NULL fallback and PKU2U authentication together.', [], ['localsystem', 'local system', 'pku2u']); +add('credential-relay-exposure', 'dc-print-spooler', 'Restrict printing on domain controllers', + 'Review the same domain controller Print Spooler exposure reported by each source.', ['ad-dc-spooler-coercion'], ['print spooler']); +add('data-sharing-exposure', 'cross-device-sync', 'Restrict cross-device synchronization', + 'Review clipboard, activity and message synchronization in the same cross-device data policy.', [], + ['clipboard synchronization', 'user activities', 'message service']); +add('data-sharing-exposure', 'search-data', 'Restrict search and location data exposure', + 'Review encrypted file indexing, online search highlights and location disclosure together.', [], + ['indexing of encrypted', 'search highlights', 'turn off location']); +add('data-sharing-exposure', 'consumer-cloud', 'Restrict consumer cloud identities and content', + 'Coordinate consumer Microsoft account authentication and cloud content policy.', [], + ['microsoft account', 'cloud consumer', 'cloud optimized']); +add('data-sharing-exposure', 'diagnostic-collection', 'Limit diagnostic data collection', + 'Coordinate diagnostic data levels, logs, dumps and telemetry transport.', [], + ['allow diagnostic data', 'diagnostic log', 'dump collection', 'authenticated proxy', 'onesettings auditing']); +add('data-sharing-exposure', 'feedback-reporting', 'Restrict feedback and error reporting', + 'Review support diagnostics, performance tracking, customer experience and error reporting.', [], + ['support diagnostic', 'perftrack', 'customer experience', 'error reporting', 'feedback notification', 'handwriting']); +add('data-sharing-exposure', 'online-personalization', 'Restrict online personalization services', + 'Coordinate online tips, speech, fonts and advertising identifiers.', [], ['online tips', 'online speech', 'font providers', 'advertising id']); +add('data-sharing-exposure', 'http-printing', 'Restrict Internet printing and print downloads', + 'Review HTTP printing, driver downloads and online print ordering together.', [], ['print drivers over http', 'printing over http', 'order prints']); +add('data-sharing-exposure', 'online-wizards', 'Restrict online publishing and registration wizards', + 'Review Internet connection, publishing and registration tasks in one online service policy.', [], + ['internet connection wizard', 'web publishing', 'publish to web', 'registration if']); +add('data-sharing-exposure', 'online-downloads', 'Restrict unsolicited online downloads and notifications', + 'Review metadata applications, push install, attachments, search updates and network notifications.', [], + ['notifications network usage', 'device metadata', 'push to install', 'enclosures', 'search companion']); +add('detection-blind-spots', 'privileged-alerts', 'Enable privileged role assignment and activation alerts', + 'Coordinate privileged role alerts in the same cloud monitoring workflow.', ['entra-privileged-role-alerts-missing']); +add('detection-blind-spots', 'ntlm-audit', 'Enable NTLM usage auditing', + 'Coordinate incoming, outgoing and domain NTLM audit coverage.', ['ntlm-usage-not-audited']); +add('detection-blind-spots', 'powershell-process', 'Enable script and process execution evidence', + 'Coordinate process creation, command-line capture, PowerShell script logging and transcription.', + ['powershell-logging-off', 'process-creation-not-logged']); +add('detection-blind-spots', 'security-log', 'Configure Security event log retention', + 'Coordinate Security log capacity and full-log behavior.', ['security-event-log-small']); +add('detection-blind-spots', 'application-log', 'Configure Application event log retention', + 'Coordinate Application log capacity and full-log behavior.', [], ['application:']); +add('detection-blind-spots', 'setup-system-log', 'Configure Setup and System event log retention', + 'Coordinate Setup and System log capacity and full-log behavior.', [], ['setup:', 'system: control event', 'system: specify']); +add('detection-blind-spots', 'firewall-domain', 'Enable Domain firewall connection logging', + 'Review declared GPO logging and observed dropped/successful connection logging for the Domain profile.', [], ['domainprofile', 'firewall: domain:']); +add('detection-blind-spots', 'firewall-private', 'Enable Private firewall connection logging', + 'Review declared GPO logging and observed dropped/successful connection logging for the Private profile.', [], ['privateprofile', 'firewall: private:']); +add('detection-blind-spots', 'firewall-public', 'Enable Public firewall connection logging', + 'Review declared GPO logging and observed dropped/successful connection logging for the Public profile.', [], ['publicprofile', 'firewall: public:']); +add('detection-blind-spots', 'audit-authentication', 'Enable authentication and logon auditing', + 'Coordinate credential validation, Kerberos, lockout and logon events.', [], + ['audit credential', 'audit kerberos', 'audit account lockout', 'audit other logon']); +add('detection-blind-spots', 'audit-directory', 'Enable directory and account change auditing', + 'Coordinate directory, account and group change events and the domain controller audit policy review.', ['dc-audit-incomplete'], + ['audit directory', 'audit distribution', 'audit other account', 'audit application group', 'audit user account', 'audit group membership']); +add('detection-blind-spots', 'audit-object', 'Enable object and share access auditing', + 'Coordinate file share, removable storage and object access events.', [], ['audit detailed file', 'audit file share', 'audit other object', 'audit removable']); +add('detection-blind-spots', 'audit-policy-system', 'Enable security policy and system auditing', + 'Coordinate policy changes, privilege use and security subsystem events; enable subcategory precedence.', [], + ['audit: force', 'audit other policy', 'audit authorization', 'audit security system', 'audit pnp', 'audit mpssvc', 'audit ipsec', 'audit sensitive privilege']); +add('host-credential-privilege-exposure', 'backup-rights', 'Restrict backup and restore privileges', + 'Review backup/restore rights in declared GPOs and scanner checks together.', [], + ['sebackupprivilege', 'serestoreprivilege', 'back up files', 'restore files']); +add('host-credential-privilege-exposure', 'driver-rights', 'Restrict driver loading privileges', + 'Review the same driver loading right in declared GPOs and scanner evidence.', [], ['seloaddriverprivilege', 'load and unload']); +add('host-credential-privilege-exposure', 'operating-rights', 'Restrict sensitive operating privileges', + 'Review system time, shutdown, scheduling and audit privileges with the privileged rights inventory.', [], + ['seincreasebasepriorityprivilege', 'seremoteshutdownprivilege', 'seauditprivilege', 'change the system time', 'force shutdown', 'shut down the system', 'dangerous user rights']); +add('host-credential-privilege-exposure', 'logon-rights', 'Restrict interactive, batch and service logon rights', + 'Review allow and deny logon assignments together, preserving each access type.', [], + ['allow log on locally', 'deny access', 'deny log on', 'log on as a batch']); +add('host-credential-privilege-exposure', 'uac', 'Harden User Account Control', + 'Coordinate administrator elevation and Admin Approval Mode.', [], ['user account control:']); +add('host-credential-privilege-exposure', 'stored-credentials', 'Restrict stored and reusable credentials', + 'Review automatic logon, saved network/RDP passwords and WinRM RunAs credential storage.', [], + ['automatic logon', 'autoadminlogon', 'saving passwords', 'passwords to be saved', 'password upon connection', 'storage of passwords', 'runas']); +add('host-credential-privilege-exposure', 'credential-components', 'Harden credential providers and delegation', + 'Review LSASS providers, MPR notifications, CredSSP and remote credential delegation.', [], + ['custom ssps', 'mpr notification', 'credential delegation', 'delegation of non-exportable credentials', 'encryption oracle']); +add('host-credential-privilege-exposure', 'print-driver-privileges', 'Restrict print driver installation privileges', + 'Coordinate driver installation, Point and Print updates, queue files and Windows protected printing.', [], + ['point and print', 'driver installation', 'queue-specific', 'protected print']); +add('host-credential-privilege-exposure', 'print-rpc', 'Harden Print RPC access and authentication', + 'Coordinate Print RPC transport, listener authentication, privacy and redirection protection.', [], + ['print spooler to accept', 'rpc', 'redirection guard']); +add('host-credential-privilege-exposure', 'print-tls', 'Require trusted TLS for IPPS printing', + 'Configure IPPS and certificate issuer, name, date and server validation together.', [], ['ipps', 'tls']); +add('kerberoasting-exposure', 'kerberos-encryption', 'Restrict weak Kerberos encryption', + 'Review Kerberos encryption types and weak domain controller encryption support together.', [], ['encryption types', 'rc4', 'des encryption']); +add('kerberoasting-exposure', 'service-identities', 'Strengthen service account credentials', + 'Review service account password policy and managed service account adoption together.', [], ['service account', 'service accounts', 'gmsa']); +add('kerberos-delegation-abuse', 'delegation-permissions', 'Review sensitive delegation permissions', + 'Review delegation rights, privileged identity delegation flags and resource-based delegation ACLs.', + ['gpo-delegation-right-deviates', 'ad-admins-delegatable', 'ad-rbcd-write-on-dc', 'ad-rbcd-write-on-krbtgt'], ['delegation', 'delegated']); +add('kerberos-delegation-abuse', 'computer-registration', 'Restrict computer account creation', + 'Review who can register computer accounts and the machine account quota together.', ['ad-users-can-add-computers'], ['add computer', 'join computer', 'register computer']); +add('lateral-movement-exposure', 'network-logon', 'Restrict network logon rights', + 'Review the same network logon right across declared GPOs and scanner evidence.', [], ['senetworklogonright', 'access this computer']); +add('lateral-movement-exposure', 'firewall-log-files', 'Configure firewall log file capacity', + 'Coordinate firewall log paths and capacities across profiles.', [], ['logging: name', 'logging: size']); +add('lateral-movement-exposure', 'firewall-enforcement', 'Enforce firewall state and inbound rules', + 'Review enabled firewall profiles and inbound defaults together.', [], ['firewall state', 'inbound connections']); +add('lateral-movement-exposure', 'firewall-local-policy', 'Restrict local firewall overrides and notifications', + 'Review local rule merging and notification behavior across profiles.', [], ['display a notification', 'apply local']); +add('lateral-movement-exposure', 'rdp-redirection', 'Restrict RDP device and data redirection', + 'Review RDP redirection channels together, retaining the requirement for each device/data type.', [], ['redirection', 'drive redirection', 'clipboard']); +add('lateral-movement-exposure', 'remote-assistance', 'Restrict Remote Assistance', + 'Coordinate solicited and offered Remote Assistance policy.', [], ['remote assistance']); +add('lateral-movement-exposure', 'rdp-session', 'Restrict RDP session duration and temporary data', + 'Coordinate session limits, single-session policy and temporary folder cleanup.', [], ['time limit', 'single session', 'single remote desktop', 'temporary folders', 'temp folders']); +add('lateral-movement-exposure', 'rdp-authentication', 'Harden RDP authentication and encryption', + 'Coordinate NLA, secure RPC, security layer, encryption and the source-reported Terminal Services policy gaps.', [], + ['network level authentication', 'secure rpc', 'encryption level', 'security layer', 'terminal services']); +add('lateral-movement-exposure', 'winrm', 'Harden WinRM authentication and remote access', + 'Coordinate WinRM client/server authentication, transport and remote management access.', + ['winrm-basic-auth', 'winrm-unencrypted'], ['winrm client', 'disallow digest', 'winrm service', 'remote server management', 'remote shell']); +add('lateral-movement-exposure', 'smb1', 'Remove SMBv1 client and server support', + 'Coordinate removal of the same legacy protocol on client and server.', [], ['smb v1', 'smbv1']); +add('lateral-movement-exposure', 'ip-routing', 'Harden IP routing behavior', + 'Review source routing, ICMP redirects and router discovery together.', [], ['source routing', 'icmp redirect', 'router discovery', 'performrouterdiscovery']); +add('lateral-movement-exposure', 'network-discovery', 'Restrict legacy network discovery and bridging', + 'Review topology discovery, peer networking, NetBIOS releases, bridging and connection sharing.', [], + ['lltd', 'link-layer', 'responder (rspndr)', 'peer-to-peer', 'network bridge', 'internet connection sharing', 'netbios name release']); +add('lateral-movement-exposure', 'network-connections', 'Control network connection configuration', + 'Review simultaneous connections, network location changes and IPv6 transition configuration.', [], + ['network location', "network's location", 'simultaneous connections', 'disabledcomponents']); +add('lateral-movement-exposure', 'tcp-resilience', 'Configure TCP connection resilience', + 'Coordinate retransmission and keep-alive limits.', [], ['retransmission', 'keepalivetime', 'keep alive']); +add('malicious-code-execution-exposure', 'allowlisting', 'Configure application allowlisting rules', + 'Review executable, DLL, MSI and script rules as one application control rollout.', + ['gpo-application-control-deviates', 'gpo-software-restriction-policy-deviates', 'gpo-applocker-policy-deviates'], ['safer', 'srpv2']); +add('malicious-code-execution-exposure', 'asr', 'Configure Attack Surface Reduction', + 'Review ASR rule enablement and source-reported ASR coverage together.', [], ['attack surface reduction', 'asr']); +add('malicious-code-execution-exposure', 'app-installer', 'Restrict App Installer bypasses and execution interfaces', + 'Coordinate App Installer features, validation bypasses, protocol and command-line interfaces.', [], ['app installer', 'windows package manager']); +add('malicious-code-execution-exposure', 'installer-control', 'Restrict user-controlled application installation', + 'Review application installation, sharing and installer user control together.', [], + ['special profiles', 'per-user unsigned packages', 'user control over installs', 'internet explorer security prompt', 'share application data']); +add('malicious-code-execution-exposure', 'exploit-mitigation', 'Enable operating system exploit mitigations', + 'Review DEP, heap, shell protocol, signature padding, log file authentication and boot driver protection.', [], + ['data execution prevention', 'heap termination', 'shell protocol', 'padding', 'clfs', 'boot-start', 'prevent users from modifying settings']); +add('malicious-code-execution-exposure', 'untrusted-files', 'Restrict untrusted file and script execution', + 'Review zone provenance and source-reported script execution boundaries.', + ['mark-of-the-web-suppressed', 'script-engines-reach-internet', 'script-files-run-on-double-click'], + ['zone information', 'folder options', 'script execution', 'script engines']); +add('malicious-code-execution-exposure', 'defender-realtime', 'Enable Defender real-time and behavioral protection', + 'Coordinate real-time scanning, downloaded file checks, behavior/script monitoring and EDR block mode.', [], + ['real-time', 'downloaded files', 'behavior monitoring', 'script scanning', 'edr']); +add('malicious-code-execution-exposure', 'defender-cloud', 'Configure Defender cloud protection and reporting', + 'Review MAPS membership, local overrides, hash submission and reporting together.', [], ['maps', 'watson', 'file hash', 'out-of-box']); +add('malicious-code-execution-exposure', 'defender-scans', 'Configure Defender scan coverage and schedules', + 'Coordinate archive, removable media, email and quick scan coverage.', [], ['packed files', 'packed executables', 'removable drives', 'e-mail', 'quick scan']); +add('malicious-code-execution-exposure', 'defender-policy', 'Restrict local Defender policy modifications', + 'Review local exclusion visibility and user configuration changes.', [], ['exclusions']); +add('malicious-code-execution-exposure', 'defender-ransomware', 'Configure Defender ransomware protections', + 'Coordinate remote encryption and brute force protection modes.', [], ['remote encryption', 'brute force', 'brute-force']); +add('malicious-code-execution-exposure', 'web-application-protection', 'Configure web and unwanted application protection', + 'Review SmartScreen, dangerous site protection and unwanted application blocking together.', [], ['smartscreen', 'dangerous', 'unwanted', 'convert warn verdict to block']); +add('malicious-code-execution-exposure', 'autorun', 'Disable unnecessary Autorun and Autoplay', + 'Coordinate drive Autoplay, non-volume devices and default Autorun behavior.', [], ['autorun', 'autoplay']); +add('password-guessing-exposure', 'password-policy', 'Strengthen domain password policy', + 'Coordinate minimum length and complexity across declared policies and scanner evidence.', + ['password-length-below-14', 'password-length-below-8', 'password-complexity-off', 'ad-privileged-weak-password-policy'], + ['minimumpasswordlength', 'passwordcomplexity', 'minimum password length', 'password complexity', 'weak password policy']); +add('password-guessing-exposure', 'lockout-threshold', 'Configure account lockout thresholds', + 'Review the same lockout threshold in declared GPOs and scanner evidence.', [], ['lockoutbadcount', 'lockout threshold']); +add('password-guessing-exposure', 'lockout-duration', 'Configure lockout duration and reset window', + 'Coordinate lockout duration and counter reset to avoid contradictory settings.', [], + ['lockoutduration', 'resetlockoutcount', 'lockout duration', 'reset account lockout']); +add('password-guessing-exposure', 'password-lifetime', 'Review non-expiring account passwords', + 'Review maximum password age and non-expiring account exceptions together.', + ['domain-admins-password-never-expires', 'ad-password-never-expires', 'ad-privileged-password-never-expires'], + ['maximumpasswordage', 'never expire', 'never expires']); +add('password-guessing-exposure', 'builtin-names', 'Review built-in account naming', + 'Coordinate the built-in Administrator and Guest naming requirements.', [], ['rename administrator', 'rename guest']); +add('password-guessing-exposure', 'kerberos-armoring', 'Configure Kerberos armoring', + 'Coordinate client and domain controller support for Kerberos armoring.', [], ['armoring']); +add('password-guessing-exposure', 'credential-cryptography', 'Harden account credential cryptography', + 'Review SAM password-change encryption and vulnerable Windows Hello key restrictions.', [], ['sam:', 'sam change password', 'roca']); +add('patch-configuration-drift', 'windows-update', 'Configure Windows update deployment', + 'Coordinate update scheduling, restart behavior, preview builds and quality update receipt.', ['cis-windows-update-unmet']); +add('patch-configuration-drift', 'policy-refresh', 'Ensure security policy refresh', + 'Coordinate background security policy processing and reapplication of unchanged policies.', [], ['configure security policy processing']); +add('physical-local-access-exposure', 'session-lock', 'Enforce workstation session locking', + 'Coordinate inactivity, wake passwords, smart card removal and logon hours enforcement.', ['gpo-logon-hours-enforcement-deviates'], + ['machine inactivity', 'password when a computer wakes', 'smart card removal']); +add('physical-local-access-exposure', 'sign-in-privacy', 'Restrict sign-in screen information disclosure', + 'Review account enumeration, network selection, password reveal and input method disclosure.', [], + ['showing account details', 'network selection', 'enumerate connected', 'enumerate administrator', 'last signed-in', 'password reveal', 'copying of user input']); +add('physical-local-access-exposure', 'legal-notice', 'Configure the interactive logon notice', + 'Coordinate the notice title and text as one policy change.', [], ['message text', 'message title']); +add('physical-local-access-exposure', 'lock-screen-features', 'Restrict lock screen applications and features', + 'Coordinate camera, slideshow, notifications and Windows Ink access at the lock screen.', [], ['lock screen', 'windows ink']); +add('physical-local-access-exposure', 'wireless-standby', 'Restrict wireless setup and standby connectivity', + 'Coordinate Windows Connect Now and network connectivity during connected standby.', [], ['windows connect now', 'connected-standby']); +add('physical-local-access-exposure', 'physical-devices', 'Restrict local device access', + 'Review camera access, external DMA devices and pairing requirements in the local device policy.', [], ['use of camera', 'kernel dma', 'pin for pairing']); +add('physical-local-access-exposure', 'local-authentication', 'Harden local authentication methods', + 'Review convenience PIN and biometric anti-spoofing policy together.', [], ['convenience pin', 'anti-spoofing']); + +export const INTERVENTION_CATALOG: readonly Intervention[] = entries; +export function matchIntervention(cluster: string, finding: Candidate, conditionIds: readonly string[]) { + const title = finding.title.toLowerCase(); + const candidates = INTERVENTION_CATALOG.filter(rule => rule.cluster === cluster + && (finding.outcome !== 'warning' || rule.warnings)); + // Specific published semantics take precedence over a term that also occurs + // in a conditional clause of the original rule text (e.g. MFA fatigue). + return candidates.find(rule => rule.conditions?.some(id => conditionIds.includes(id))) + ?? ((Boolean(finding.identityKey && finding.scope) + || ['cis-cat', 'group-policy', 'maester', 'pingcastle', 'purple-knight'].includes(finding.tool)) + ? candidates.find(rule => rule.settings?.some(anchor => title.includes(anchor))) : undefined); +} diff --git a/src/remediation/remediation-engine.ts b/src/remediation/remediation-engine.ts new file mode 100644 index 0000000..fc37149 --- /dev/null +++ b/src/remediation/remediation-engine.ts @@ -0,0 +1,300 @@ +import { matchIntervention } from './intervention-catalog'; + +/** Published input contracts: this engine never imports another engine's internals. */ +export interface PublishedContext { + engine: { version: string }; catalog: { version: string }; repositoryRevision: number; + clusters: Array<{ clusterId: string; label: { en: string } }>; + findings: Array<{ occurrenceId: string; clusterIds: string[]; conditionIds: string[]; clusterConfidence?: Record }>; + settings: Array<{ identityKey: string; scope: string; clusterIds: string[]; conditionIds: string[]; clusterConfidence?: Record }>; +} +export interface RemediationFinding { + key: string; occurrenceId?: string; identityKey?: string; scope?: string; + tool: string; ruleId: string; title: string; outcome: string; + remediation?: string; + /** Native source severity when the Core adapter supplies it; never derived here. */ + severity?: string; +} +export type WorkPlatform = 'windows-host' | 'active-directory' | 'cloud' | 'other'; +export type WorkMeasureMethod = 'catalogued' | 'same-source-remediation' | 'individual'; +/** One change a person carries out. Several findings form one task only when they are proven redundant. */ +export interface WorkTask { id: string; findingKeys: string[]; redundant: boolean; relatedConditionId?: string } +/** A common intervention inside a package; a package may hold several measures of its cluster and platform. */ +export interface WorkMeasure { id: string; title: string; method: WorkMeasureMethod; reason: string; taskIds: string[] } +/** Source-backed facts only: no score, rank or priority. */ +export interface WorkPackageFacts { + tasks: number; findings: number; redundantFindings: number; + highestSeverity?: string; criticalOrHighFindings: number; + conditionsClosed: number; conditionsTouched: number; sources: string[]; +} +export interface WorkPackage { + id: string; code: string; clusterId: string; clusterLabel: string; platform: WorkPlatform; title: string; + findingKeys: string[]; tasks: WorkTask[]; measures: WorkMeasure[]; facts: WorkPackageFacts; + method: WorkMeasureMethod; reason: string; +} +export interface RemediationResult { + engineVersion: string; catalogVersion: string; contextEngineVersion: string; contextCatalogVersion: string; + projectId: string; assessmentIds: string[]; scanRunIds: string[]; repositoryRevision: number; + packages: WorkPackage[]; unassignedFindingKeys: string[]; +} +export const REMEDIATION_ENGINE_VERSION = '2.1.1'; +export const REMEDIATION_CATALOG_VERSION = '1.1.1'; +export const WORK_PACKAGE_FACET_ID = 'remediation:work-package'; +export const UNASSIGNED_WORK_PACKAGE = '__unassigned__'; +/** User decision 2026-10-09: at most eight tasks per package, packed per Context cluster and platform. */ +export const MAX_TASKS_PER_PACKAGE = 8; +/** User decision 2026-10-09: several GPO settings change in one round. A size rule, never an effort or priority. */ +export const MAX_TASKS_PER_HOST_PACKAGE = 15; +export const maxTasksFor = (platform: WorkPlatform) => platform === 'windows-host' ? MAX_TASKS_PER_HOST_PACKAGE : MAX_TASKS_PER_PACKAGE; + +// Rules describe a common intervention, never a new verdict or quantified risk score. +const cloudRules = [ + { title: 'Secure administrative sign-ins', rules: ['MT.1005', 'MT.1024.adminMFAV2', 'CISA.MS.AAD.3.6', 'MT.1014'], + reason: 'Coordinate administrative authentication, device requirements and verified emergency access.' }, + { title: 'Limit Global Administrator privileges', rules: ['CIS.M365.1.1.3', 'CISA.MS.AAD.7.1', 'CISA.MS.AAD.7.2', 'MT.1032'], + reason: 'Review the same role inventory against each retained criterion; use narrower roles where appropriate.' }, + { title: 'Separate administrative identities', rules: ['CIS.M365.1.1.1', 'CISA.MS.AAD.7.3', 'MT.1028'], + reason: 'Coordinate cloud-only administrative identities and separation from everyday mailbox use.' }, + { title: 'Review privileged assignment lifecycles', rules: ['CISA.MS.AAD.7.4', 'MT.1025', 'MT.1030', 'MT.INV.003'], + reason: 'Review permanent, external, unused and inactive privileges; retain emergency and service-account exceptions for clarification.' }, + { title: 'Restrict Azure hierarchy administration', rules: ['MT.1056', 'MT.1064'], + reason: 'Review root-scope privileges and permission to create management groups.' }, + { title: 'Secure privileged application identities', rules: ['MT.1027'], + reason: 'Review application credentials and necessary privileges separately from human administrator authentication.' }, + { title: 'Investigate unusual privileged sign-ins', rules: ['MT.INV.001'], + reason: 'Investigate the retained warning; configuration changes alone do not resolve an investigation.' } +] as const; + +const normalize = (text: string) => text.trim().replace(/\s+/g, ' ').toLowerCase(); +export const scannerFindingKey = (occurrenceId: string) => `scanner:${occurrenceId}`; +export const policyFindingKey = (scope: string, identityKey: string) => `policy:${scope}:${identityKey}`; +const byText = (a: string, b: string) => a.localeCompare(b, 'en', { numeric: true }); +const PLATFORM_ORDER: readonly WorkPlatform[] = ['windows-host', 'active-directory', 'cloud', 'other']; +const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info']; +const SEP = '\u0000'; +const sourceOf = (f: RemediationFinding) => f.tool || (f.identityKey ? 'group-policy' : 'unknown'); +const push = (map: Map, key: K, value: V) => { const list = map.get(key); if (list) list.push(value); else map.set(key, [value]); }; + +/** Hard packing boundary next to the cluster: where the change is made (host/GPO, directory, cloud tenant). */ +export function platformOf(finding: RemediationFinding, clusterId: string): WorkPlatform { + if (clusterId.startsWith('cloud-') || finding.tool === 'maester') return 'cloud'; + if (finding.identityKey || ['cis-cat', 'group-policy'].includes(finding.tool)) return 'windows-host'; + if (['pingcastle', 'purple-knight'].includes(finding.tool)) return 'active-directory'; + return 'other'; +} + +interface Entry { + finding: RemediationFinding; clusterId: string; platform: WorkPlatform; conditions: string[]; + measureKey: string; measure: Omit; +} +interface Task extends WorkTask { + entries: Entry[]; clusterId: string; platform: WorkPlatform; measureKey: string; order: string; conditions: Set; +} + +class Groups { + private parent = new Map(); + find(key: string): string { + const parent = this.parent.get(key); + if (!parent || parent === key) return key; + const root = this.find(parent); + this.parent.set(key, root); + return root; + } + union(a: string, b: string) { const [x, y] = [this.find(a), this.find(b)].sort(byText); if (x !== y) this.parent.set(y, x); } +} + +function measureFor(finding: RemediationFinding, clusterId: string, catalogued: boolean, conditions: string[]) { + const ruleIndex = clusterId === 'cloud-privileged-access-exposure' && finding.tool === 'maester' + ? cloudRules.findIndex(rule => (rule.rules as readonly string[]).includes(finding.ruleId)) : -1; + // Estimated context is not evidence of a shared remediation. A warning also stays an investigation. + const intervention = catalogued ? matchIntervention(clusterId, finding, conditions) : undefined; + const sharedText = finding.outcome !== 'warning' && catalogued + && finding.remediation && normalize(finding.remediation).length >= 40 ? normalize(finding.remediation) : undefined; + const key = ruleIndex >= 0 ? `catalogue:${String(ruleIndex).padStart(3, '0')}` + : intervention ? `intervention:${intervention.id}` + : sharedText ? `source:${finding.tool}:${finding.scope ?? ''}:${sharedText}` : `individual:${finding.key}`; + const rule = ruleIndex >= 0 ? cloudRules[ruleIndex] : intervention; + const method: WorkMeasureMethod = rule ? 'catalogued' : sharedText ? 'same-source-remediation' : 'individual'; + return { key, measure: { title: rule?.title ?? finding.title, method, + reason: rule?.reason ?? (sharedText ? 'The retained source gives the same remediation in the same tool and scope.' + : 'No supported common intervention: address this finding individually.') } }; +} + +/** Balanced chunks of at most `size`: 11 → 6+5 instead of 8+3 at a limit of eight. */ +function chunks(items: T[], size: number): T[][] { + const count = Math.ceil(items.length / size); + const out: T[][] = []; + for (let i = 0, offset = 0; i < count; i++) { + const length = Math.floor(items.length / count) + (i < items.length % count ? 1 : 0); + out.push(items.slice(offset, offset + length)); + offset += length; + } + return out; +} + +/** First-fit decreasing with condition affinity, at the most balanced capacity that needs the fewest packages. */ +function pack(units: Task[][], limit: number): Task[][] { + const total = units.reduce((n, unit) => n + unit.length, 0); + const minimum = Math.ceil(total / limit); + const pieces = units.flatMap(unit => chunks(unit, limit)) + .sort((a, b) => b.length - a.length || byText(a[0].order, b[0].order)); + const largest = Math.max(0, ...pieces.map(piece => piece.length)); + let best: Task[][] = []; + for (let capacity = Math.max(largest, Math.ceil(total / minimum)); capacity <= limit; capacity++) { + const bins: Task[][] = []; + for (const piece of pieces) { + const fits = bins.filter(bin => bin.length + piece.length <= capacity); + const affine = fits.find(bin => bin.some(task => piece.some(other => [...other.conditions].some(c => task.conditions.has(c))))); + const bin = affine ?? fits[0]; + if (bin) bin.push(...piece); else bins.push([...piece]); + } + if (!best.length || bins.length < best.length) best = bins; + if (best.length === minimum) break; + } + // Avoid an artificial one- or two-task tail: move loose tasks (no common measure or relation) from full packages. + const loose = new Set(units.filter(unit => unit.length === 1).map(unit => unit[0])); + for (;;) { + const small = [...best].sort((a, b) => a.length - b.length)[0]; + if (!small || small.length >= 3) break; + const donors = best.filter(bin => bin !== small && bin.length > small.length + 1 && bin.some(task => loose.has(task))); + if (!donors.length) break; + const donor = donors.find(bin => bin.some(task => loose.has(task) && small.some(s => [...s.conditions].some(c => task.conditions.has(c))))) + ?? [...donors].sort((a, b) => b.length - a.length)[0]; + const candidates = donor.filter(task => loose.has(task)).sort((a, b) => byText(b.order, a.order)); + const moved = candidates.find(task => small.some(s => [...s.conditions].some(c => task.conditions.has(c)))) ?? candidates[0]; + donor.splice(donor.indexOf(moved), 1); + small.push(moved); + } + return best.map(bin => [...bin].sort((a, b) => byText(a.order, b.order))); +} + +/** Order independent and exclusive. Original records and Context remain untouched. */ +export function buildWorkPackages(input: { + projectId: string; assessmentIds: string[]; scanRunIds: string[]; + findings: readonly RemediationFinding[]; context: PublishedContext; +}): RemediationResult { + const { context } = input; + const scanner = new Map(context.findings.map(f => [f.occurrenceId, f])); + const policy = new Map(context.settings.map(f => [policyFindingKey(f.scope, f.identityKey), f])); + const labels = new Map(context.clusters.map(c => [c.clusterId, c.label.en])); + const unassigned: string[] = []; + const entries: Entry[] = []; + const records = [...new Map(input.findings.map(f => [f.key, f])).values()].sort((a, b) => byText(a.key, b.key)); + for (const finding of records) { + if (!['fail', 'warning', 'deviation'].includes(finding.outcome)) continue; + const attachment = finding.occurrenceId ? scanner.get(finding.occurrenceId) : policy.get(finding.key); + const clusterId = attachment?.clusterIds[0]; + if (!clusterId || !labels.has(clusterId) || clusterId === 'other-deviations') { unassigned.push(finding.key); continue; } + const confidence = attachment?.clusterConfidence?.[clusterId]; + const catalogued = !confidence || confidence === 'catalogued'; + const conditions = [...(attachment?.conditionIds ?? [])].sort(byText); + const { key, measure } = measureFor(finding, clusterId, catalogued, conditions); + entries.push({ finding, clusterId, platform: platformOf(finding, clusterId), measureKey: key, measure, conditions: catalogued ? conditions : [] }); + } + + // Redundancy: a specific condition that each of several sources reports exactly once is one change. + // Where one source reports several findings (an area or umbrella), tasks stay separate but related. + const byCondition = new Map(); + for (const entry of entries) for (const condition of entry.conditions) push(byCondition, `${entry.clusterId}${SEP}${condition}`, entry); + const redundancy = new Groups(); + const members = new Map(entries.map(e => [e.finding.key, [e]])); + const related = new Map(); + for (const [key, group] of [...byCondition].sort(([a], [b]) => byText(a, b))) { + const sources = group.map(e => sourceOf(e.finding)); + if (new Set(sources).size < 2) continue; + const condition = key.split(SEP)[1]; + if (new Set(sources).size !== sources.length || group.some(e => e.finding.outcome === 'warning')) { + for (const e of group) if (!related.has(e.finding.key)) related.set(e.finding.key, condition); + continue; + } + for (const e of group.slice(1)) { + const [a, b] = [redundancy.find(group[0].finding.key), redundancy.find(e.finding.key)]; + if (a === b) continue; + const joined = [...members.get(a)!, ...members.get(b)!]; + if (new Set(joined.map(x => sourceOf(x.finding))).size !== joined.length) continue; + redundancy.union(a, b); + members.set(redundancy.find(a), joined); + } + } + const taskEntries = new Map(); + for (const e of entries) push(taskEntries, redundancy.find(e.finding.key), e); + const tasks: Task[] = [...taskEntries.values()].map(group => { + const sorted = [...group].sort((a, b) => byText(a.finding.key, b.finding.key)); + const counts = PLATFORM_ORDER.map(p => sorted.filter(e => e.platform === p).length); + const platform = PLATFORM_ORDER[counts.indexOf(Math.max(...counts))]; + const lead = sorted.find(e => e.measure.method === 'catalogued') ?? sorted[0]; + const conditions = new Set(sorted.flatMap(e => e.conditions)); + const relation = sorted.map(e => related.get(e.finding.key)).find(Boolean); + return { id: `task:${sorted[0].finding.key}`, findingKeys: sorted.map(e => e.finding.key), redundant: sorted.length > 1, + ...(relation ? { relatedConditionId: relation } : {}), entries: sorted, clusterId: lead.clusterId, platform, + measureKey: lead.measureKey, conditions, + // Neighbouring order: common measure, umbrella relation, first condition, natural rule number. + // Explicit 0/1 prefixes put present values first; locale collation sorts symbols such as '~' before letters. + order: [lead.measureKey.startsWith('individual:') ? '1' : `0${lead.measureKey}`, relation ? `0${relation}` : '1', + [...conditions][0] ? `0${[...conditions][0]}` : '1', lead.finding.ruleId, lead.finding.key].join(SEP) }; + }); + + // An umbrella finding follows the platform where most of its related findings are changed. + const relationPlatforms = new Map(); + for (const task of tasks) if (task.relatedConditionId) push(relationPlatforms, `${task.clusterId}${SEP}${task.relatedConditionId}`, task); + for (const group of relationPlatforms.values()) { + const counts = PLATFORM_ORDER.map(p => group.filter(t => t.platform === p).length); + const platform = PLATFORM_ORDER[counts.indexOf(Math.max(...counts))]; + for (const task of group) task.platform = platform; + } + const conditionMembers = new Map(); + for (const e of entries) for (const c of e.conditions) push(conditionMembers, c, e.finding.key); + const buckets = new Map(); + for (const task of tasks) push(buckets, `${task.clusterId}${SEP}${PLATFORM_ORDER.indexOf(task.platform)}`, task); + const packages: WorkPackage[] = []; + const numbers = new Map(); + for (const [, bucket] of [...buckets].sort(([a], [b]) => byText(a, b))) { + // Units: tasks of one measure, or related through an umbrella condition, stay together where they fit. + const units = new Groups(); + const firstOf = new Map(); + for (const task of [...bucket].sort((a, b) => byText(a.order, b.order))) { + const links = [task.measureKey.startsWith('individual:') ? '' : `m${SEP}${task.measureKey}`, + task.relatedConditionId ? `r${SEP}${task.relatedConditionId}` : ''].filter(Boolean); + for (const link of links) { const first = firstOf.get(link); if (first) units.union(first, task.id); else firstOf.set(link, task.id); } + } + const grouped = new Map(); + for (const task of bucket) push(grouped, units.find(task.id), task); + const bins = pack([...grouped.values()].map(unit => unit.sort((a, b) => byText(a.order, b.order))), maxTasksFor(bucket[0].platform)) + .sort((a, b) => byText(a[0].order, b[0].order)); + for (const bin of bins) { + const clusterId = bin[0].clusterId; + const number = (numbers.get(clusterId) ?? 0) + 1; + numbers.set(clusterId, number); + const code = `WP-${String(number).padStart(2, '0')}`; + const byMeasure = new Map(); + for (const task of bin) push(byMeasure, task.measureKey, task); + const measures: WorkMeasure[] = [...byMeasure].map(([id, list]) => { + const lead = list[0].entries.find(e => e.measureKey === id) ?? list[0].entries[0]; + return { id, ...lead.measure, taskIds: list.map(t => t.id) }; + }); + const main = [...measures].sort((a, b) => b.taskIds.length - a.taskIds.length + || Number(b.method === 'catalogued') - Number(a.method === 'catalogued') || byText(a.id, b.id))[0]; + const findingKeys = bin.flatMap(t => t.findingKeys); + const inPackage = new Set(findingKeys); + const found = bin.flatMap(t => t.entries); + const touched = [...new Set(found.flatMap(e => e.conditions))]; + const severities = found.map(e => e.finding.severity?.trim().toLowerCase() ?? '').filter(s => SEVERITY_ORDER.includes(s)); + const highestSeverity = SEVERITY_ORDER.find(s => severities.includes(s)); + // Keep existing snapshot/filter identities across the visible AP → WP rename. + packages.push({ id: `${clusterId}:AP-${String(number).padStart(2, '0')}`, code, clusterId, clusterLabel: labels.get(clusterId)!, platform: bin[0].platform, + title: main.title, findingKeys, measures, method: main.method, reason: main.reason, + tasks: bin.map(({ id, findingKeys: keys, redundant, relatedConditionId }) => + ({ id, findingKeys: keys, redundant, ...(relatedConditionId ? { relatedConditionId } : {}) })), + facts: { tasks: bin.length, findings: findingKeys.length, + redundantFindings: bin.filter(t => t.redundant).reduce((n, t) => n + t.findingKeys.length, 0), + ...(highestSeverity ? { highestSeverity } : {}), + criticalOrHighFindings: severities.filter(s => s === 'critical' || s === 'high').length, + conditionsTouched: touched.length, + conditionsClosed: touched.filter(c => conditionMembers.get(c)!.every(k => inPackage.has(k))).length, + sources: [...new Set(found.map(e => sourceOf(e.finding)))].sort(byText) } }); + } + } + return { engineVersion: REMEDIATION_ENGINE_VERSION, catalogVersion: REMEDIATION_CATALOG_VERSION, + contextEngineVersion: context.engine.version, contextCatalogVersion: context.catalog.version, + projectId: input.projectId, assessmentIds: [...new Set(input.assessmentIds)].sort(), scanRunIds: [...new Set(input.scanRunIds)].sort(), + repositoryRevision: context.repositoryRevision, packages, unassignedFindingKeys: unassigned.sort() }; +} diff --git a/src/remediation/remediation-read-port.ts b/src/remediation/remediation-read-port.ts new file mode 100644 index 0000000..84ac61c --- /dev/null +++ b/src/remediation/remediation-read-port.ts @@ -0,0 +1,12 @@ +import type { AssessorRepository } from '../persistence/assessor-repository'; + +export type RemediationReadMethods = Pick; +export interface RemediationReadPort extends RemediationReadMethods { currentRevision(): Promise } +export function remediationReadPortFrom(repository: RemediationReadMethods, revision: () => Promise): RemediationReadPort { + return Object.freeze({ currentRevision: revision, getProject: repository.getProject.bind(repository), + listAssessments: repository.listAssessments.bind(repository), loadAssessmentModel: repository.loadAssessmentModel.bind(repository), + queryUnifiedResults: repository.queryUnifiedResults.bind(repository), listPolicyEvaluations: repository.listPolicyEvaluations.bind(repository), + listCurrentPolicyEvaluationsForMetrics: repository.listCurrentPolicyEvaluationsForMetrics?.bind(repository), + listMicrosoftSecurityBaselineAnalyses: repository.listMicrosoftSecurityBaselineAnalyses.bind(repository) }); +} diff --git a/src/remediation/use-work-packages.ts b/src/remediation/use-work-packages.ts new file mode 100644 index 0000000..2313db5 --- /dev/null +++ b/src/remediation/use-work-packages.ts @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react'; +import { useOptionalApp } from '../app/AppProvider'; +import { useEnginePreferences } from '../app/engine-preferences'; +import { AssessorApiClient } from '../api/assessor-api-client'; +import { preparedAssessmentContext } from '../api/prepared-assessment-context'; +import type { WorkPackagesResponse } from '../api/assessor-api-contract'; +import { workPackageIndex } from './work-package-index'; +import { publishedWorkPackageOverview } from './work-package-overview'; + +const api = new AssessorApiClient(); +let requests = new WeakMap>>(); +export function resetWorkPackageRequestsForTests() { requests = new WeakMap(); } +export function useWorkPackages() { + const app = useOptionalApp(); + const engines = useEnginePreferences(); + const dataset = app?.dataset; + const projectId = app?.targetProjectId; + const assessments = [...(app?.activeAssessmentIds ?? [])].sort(); + const scans = [...(app?.activeScanRunIds ?? [])].sort(); + const key = JSON.stringify([projectId, assessments, scans, engines.context]); + const [state, setState] = useState<{ key: string; dataset: unknown; response?: WorkPackagesResponse; error?: string }>(); + useEffect(() => { + if (!engines.remediation || !dataset || !projectId || !assessments.length) return; + let current = true; + let cache = requests.get(dataset); + if (!cache) { cache = new Map(); requests.set(dataset, cache); } + let request = cache.get(key); + if (!request) { + request = (async () => { + // Consume the published API result. Context off never starts a Context request or computation. + if (engines.context) await preparedAssessmentContext(api, dataset, projectId, assessments, scans); + return api.getWorkPackages(projectId, assessments, scans); + })(); + cache.set(key, request); + request.catch(() => cache!.delete(key)); + } + request.then(response => { if (current) setState({ key, dataset, response }); }, + error => { if (current) setState({ key, dataset, error: error instanceof Error ? error.message : 'Work packages could not be loaded.' }); }); + return () => { current = false; }; + }, [engines.remediation, key, dataset]); + if (!engines.remediation || !dataset || !projectId || !assessments.length) return { status: 'off' as const }; + if (state?.key !== key || state.dataset !== dataset) return { status: 'loading' as const }; + if (state.error) return { status: 'error' as const, message: state.error }; + return state.response?.status === 'ready' ? { status: 'ready' as const, index: workPackageIndex(state.response.result), overview: publishedWorkPackageOverview(state.response.result) } + : { status: 'context-required' as const }; +} diff --git a/src/remediation/work-package-index.ts b/src/remediation/work-package-index.ts new file mode 100644 index 0000000..1a56d40 --- /dev/null +++ b/src/remediation/work-package-index.ts @@ -0,0 +1,42 @@ +import type { FindingRecord } from '../domain/finding'; +import { workPackageCode } from './work-package-label'; +export { workPackageCode } from './work-package-label'; +import type { DynamicFacet } from '../features/search-filter/dynamic-facets'; +import { policyFindingKey, scannerFindingKey, WORK_PACKAGE_FACET_ID, UNASSIGNED_WORK_PACKAGE, type RemediationResult, type WorkPackage } from './remediation-engine'; + +export interface WorkPackageIndex { + facet: DynamicFacet; packages: readonly WorkPackage[]; + packageFor(record: FindingRecord): WorkPackage | undefined; unassigned(record: FindingRecord): boolean; +} +export function workRecordKey(record: FindingRecord): string { + const field = (label: string) => record.fields.find(f => f.label.toLowerCase() === label)?.value.trim() ?? ''; + return record.findingOccurrenceId ? scannerFindingKey(record.findingOccurrenceId) + : field('technical identity') ? policyFindingKey(field('scope'), field('technical identity')) : ''; +} +const indexes = new WeakMap(); +export function workPackageIndex(result: RemediationResult): WorkPackageIndex { + const previous = indexes.get(result); + if (previous) return previous; + const byKey = new Map(result.packages.flatMap(p => p.findingKeys.map(key => [key, p] as const))); + const unassigned = new Set(result.unassignedFindingKeys); + const index: WorkPackageIndex = { packages: result.packages, packageFor: record => byKey.get(workRecordKey(record)), unassigned: record => unassigned.has(workRecordKey(record)), + facet: { id: WORK_PACKAGE_FACET_ID, label: 'Work Package', kind: 'category', source: 'derived', derivedKind: 'work-package', + preserveEmptyValues: false, description: 'Up to eight tasks (fifteen for Windows host/GPO) of one Context cluster and platform; redundant findings of the same change form one task. Package numbers are identifiers, not priorities.', + values: [...result.packages.map(p => ({ value: p.id, label: `${p.clusterLabel} → ${workPackageCode(p.code)}`, count: 0 })), + { value: UNASSIGNED_WORK_PACKAGE, label: 'Unassigned', count: 0 }], + valuesFor: record => { + const p = byKey.get(workRecordKey(record)); + return p ? [p.id] : unassigned.has(workRecordKey(record)) ? [UNASSIGNED_WORK_PACKAGE] : []; + } } + }; + indexes.set(result, index); + return index; +} +/** The slot of the removed Result Message facet, before non-primary facets and Source: Scan. */ +export function withWorkPackageFacet(facets: DynamicFacet[], index?: WorkPackageIndex): DynamicFacet[] { + const retained = facets.filter(f => !/^result[\s_-]*message$/i.test(f.label.trim())); + if (!index) return retained; + const position = retained.findIndex(f => !/^(Result Status|Severity|Category|Security Domain|CIS Category|MITRE ATT&?CK|MITRE D3FEND|Target|Affected Object)$/i.test(f.label)); + const at = position < 0 ? retained.length : position; + return [...retained.slice(0, at), index.facet, ...retained.slice(at)]; +} diff --git a/src/remediation/work-package-label.ts b/src/remediation/work-package-label.ts new file mode 100644 index 0000000..b4a9a1c --- /dev/null +++ b/src/remediation/work-package-label.ts @@ -0,0 +1,4 @@ +/** Display legacy snapshots with the current abbreviation, retaining their stable IDs. */ +export function workPackageCode(code: string): string { + return code.replace(/^AP-/, 'WP-'); +} diff --git a/src/remediation/work-package-overview.ts b/src/remediation/work-package-overview.ts new file mode 100644 index 0000000..66b1866 --- /dev/null +++ b/src/remediation/work-package-overview.ts @@ -0,0 +1,58 @@ +import type { FindingRecord } from '../domain/finding'; +import type { RemediationResult, WorkPackage } from './remediation-engine'; +import { workRecordKey, type WorkPackageIndex } from './work-package-index'; + +export interface WorkPackageOverviewRow { + workPackage: WorkPackage; + tasks: number; + findings: number; + redundantFindings: number; +} +export interface WorkPackageOverview { + rows: WorkPackageOverviewRow[]; + records: number; + assigned: number; + unassigned: number; + outsideSnapshot: number; + tasks: number; + additionalSameChangeFindings: number; +} + +/** Complete published scope; never reduced by Core Work filters. */ +export function publishedWorkPackageOverview(result: RemediationResult): WorkPackageOverview { + const rows = result.packages.map(workPackage => ({ workPackage, tasks: workPackage.facts.tasks, + findings: workPackage.facts.findings, redundantFindings: workPackage.facts.redundantFindings })); + const assigned = rows.reduce((total, row) => total + row.findings, 0); + const tasks = rows.reduce((total, row) => total + row.tasks, 0); + const unassigned = result.unassignedFindingKeys.length; + return { rows, records: assigned + unassigned, assigned, tasks, unassigned, outsideSnapshot: 0, + additionalSameChangeFindings: assigned - tasks }; +} + +/** Read-only display projection of the exact visible Work records and published task identities. */ +export function workPackageOverview(records: readonly FindingRecord[], index: WorkPackageIndex): WorkPackageOverview { + const members = new Map(); + let unassigned = 0, outsideSnapshot = 0; + for (const record of records) { + const workPackage = index.packageFor(record); + if (workPackage) { + const group = members.get(workPackage.id) ?? []; + group.push(record); members.set(workPackage.id, group); + } else if (index.unassigned(record)) unassigned++; + else outsideSnapshot++; + } + const rows = index.packages.flatMap(workPackage => { + const visible = members.get(workPackage.id); + if (!visible?.length) return []; + const keys = new Set(visible.map(workRecordKey)); + const tasks = workPackage.tasks.filter(task => task.findingKeys.some(key => keys.has(key))); + const redundantKeys = new Set(tasks.filter(task => task.redundant && task.findingKeys.filter(key => keys.has(key)).length > 1) + .flatMap(task => task.findingKeys)); + return [{ workPackage, tasks: tasks.length, findings: visible.length, + redundantFindings: visible.filter(record => redundantKeys.has(workRecordKey(record))).length }]; + }); + const assigned = rows.reduce((n, row) => n + row.findings, 0); + const tasks = rows.reduce((n, row) => n + row.tasks, 0); + return { rows, records: records.length, assigned, unassigned, outsideSnapshot, tasks, + additionalSameChangeFindings: assigned - tasks }; +} diff --git a/src/reporting/client-report-projection.ts b/src/reporting/client-report-projection.ts index 9899d5a..dfe1902 100644 --- a/src/reporting/client-report-projection.ts +++ b/src/reporting/client-report-projection.ts @@ -9,11 +9,14 @@ import { } from './report-projection'; import type { UnifiedResultProjection } from '../domain/unified-result'; import { toolProfileDefinitions } from '../profiles/profile-definition-catalog'; -import { securityDomainCatalog, securityDomainIdForRecord, securityDomainIdForUnifiedResult } from '../evaluation/security-domains'; +import { securityDomainBuckets, securityDomainCatalog, securityDomainIdForRecord, securityDomainIdForUnifiedResult } from '../evaluation/security-domains'; import { summarizeWorkspaceResults } from '../features/project-import/datahub-scan-analysis'; import { profileForSheet } from '../features/project-import/workspace-profile'; import { dashboardResultState } from '../features/dashboard/dashboard-result-state'; import { managementWorkspaceDeviationKeys } from './management-business-impact'; +import { riskAcceptanceState } from '../domain/risk-acceptance'; +import { buildReportWorkspaceView } from './report-workspace-view'; +import { reportHeaderFacts } from './report-header-facts'; function field(record: FindingRecord, label: string): string { return record.fields.find(item => item.label.toLocaleLowerCase('en-US') === label.toLocaleLowerCase('en-US'))?.value?.trim() ?? ''; @@ -47,16 +50,44 @@ export interface ClientReportProjectionInput { overallScore?: number; scoreSources?: ReportScoreSource[]; reviews?: Record; + /** Client Info's GPO count of the processed backups, if any. */ + gpoCount?: number; } /** One frozen, filter-independent cut for both HTML files. */ export function clientReportInput(input: ClientReportProjectionInput): ReportProjectionInput { + const workspaceView = buildReportWorkspaceView(input.dataset, input.environment); const projectSheet = input.dataset.sheets.find(sheet => sheet.isProjectSheet); const countDataset = input.workspaceDataset ?? input.dataset; const countSheet = countDataset.sheets.find(sheet => sheet.isProjectSheet); const counts = countSheet ? summarizeWorkspaceResults(countSheet.analysis.records, profileForSheet(countDataset, countSheet)) : undefined; + const otherStates = countSheet ? countSheet.analysis.records.map(record => dashboardResultState(record, profileForSheet(countDataset, countSheet))) : []; + // Same rule as Client Info's risk register (RiskRegister.tsx): one entry per occurrence across all sheets; + // current means Accepted, Scheduled, Review due or Review required. + const riskStates = new Map(); + for (const sheet of input.dataset.sheets) for (const record of sheet.analysis.records) { + const value = input.reviews?.[record.id]?.riskAcceptance; + const latest = value?.revisions.at(-1); + if (!latest) continue; + const occurrenceId = record.findingOccurrenceId ?? latest.targetOccurrenceId ?? record.id; + if (!riskStates.has(occurrenceId)) riskStates.set(occurrenceId, riskAcceptanceState(value, occurrenceId, input.generatedAt.slice(0, 10))); + } + const riskRegister = { recorded: riskStates.size, + current: [...riskStates.values()].filter(state => ['Accepted', 'Scheduled', 'Review due', 'Review required'].includes(state)).length }; const deviationKeys = countSheet ? managementWorkspaceDeviationKeys(countSheet.analysis.records, record => dashboardResultState(record, profileForSheet(countDataset, countSheet)) === 'deviation') : undefined; + // Same Raw Data records, states and classifier as Client Info; the summary-only count sheet lacks + // the Raw Data fields the classifier reads, so domains always come from the full project sheet. + const workspaceDomains = projectSheet ? (() => { + const profile = profileForSheet(input.dataset, projectSheet); + const buckets = securityDomainBuckets(projectSheet.analysis.records, projectSheet.toolId); + return securityDomainCatalog.map(({ id, label }) => { + const states = (buckets.get(id) ?? []).map(record => dashboardResultState(record, profile)); + const passed = states.filter(state => state === 'passed').length; + const deviations = states.filter(state => state === 'deviation').length; + return { domainId: id, domainLabel: label, passed, deviations, assessed: passed + deviations }; + }); + })() : undefined; const policyRecords = (projectSheet?.analysis.records ?? []).filter(record => field(record, 'Category') === 'GPOs'); const policyEntries = policyRecords.flatMap(record => { const entry = policyEntry(record); @@ -112,16 +143,22 @@ export function clientReportInput(input: ClientReportProjectionInput): ReportPro priorityBases: reportPriorityBases(toolProfileDefinitions), overallScore: input.overallScore, scoreSources: input.scoreSources, - workspaceCounts: counts ? { deviations: counts.deviation, assessed: counts.deviation + counts.passed } : undefined, + workspaceCounts: counts ? { deviations: counts.deviation, assessed: counts.deviation + counts.passed, + notRun: counts.notRun, notApplicable: counts.notApplicable, executionErrors: counts.error, + notConfigured: otherStates.filter(state => state === 'not-configured').length, + unknown: otherStates.filter(state => state === 'unknown').length } : undefined, + riskRegister, workspaceDeviationKeys: deviationKeys, + workspaceDomains, + workspaceView, + headerFacts: reportHeaderFacts(input.environment, { + gpoCount: input.gpoCount, + // Client Info infers one Entra tenant from loaded Maester results when no tenant identity is retained. + inferredEntraTenant: workspaceView?.records.some(record => record.toolId === 'maester') + }), environment, policyEntries, reviews: reviewInputs, - technicalPolicySources: policyRecords.map(record => ({ settingId: record.id, sources: record.policySourceEvidence ?? [] })), - technicalFindingFields: input.results.flatMap(result => { - const record = workRecordByOccurrence.get(result.sourceResultId); - return record ? [{ resultProjectionId: result.resultProjectionId, fields: record.fields }] : []; - }), policyScopeLimited: true, provenance: uniqueProvenance, priorityLimitPerDomain: 8 diff --git a/src/reporting/context-management-report.ts b/src/reporting/context-management-report.ts index 01ab395..a5a9d24 100644 --- a/src/reporting/context-management-report.ts +++ b/src/reporting/context-management-report.ts @@ -65,25 +65,40 @@ export function buildContextManagementHtml(projection: ReportProjection): string if (!context) throw new ReportContextRequiredError(); const active = context.clusters.filter(item => countOf(item) > 0); const classified = active.filter(item => item.id !== 'other-deviations'); - const impacts = managementBusinessImpacts(context); + const distribution = managementFindingDistribution(context, projection.workspaceCounts?.deviations ?? context.findingCount, projection.workspaceDeviationKeys); + const themeCount = (id: string) => distribution.themes.find(theme => theme.id === id)!.count; + // One theme order for summary, overview, chart and theme sheets: by count, ties in catalog order. + const impacts = [...managementBusinessImpacts(context)].sort((left, right) => themeCount(right.id) - themeCount(left.id)); // Read the same published workspace summary as Work; technical rows are evidence only. const deviations = projection.workspaceCounts?.deviations ?? projection.coverage.deviations; const assessed = projection.workspaceCounts?.assessed ?? projection.coverage.assessed; - const distribution = managementFindingDistribution(context, projection.workspaceCounts?.deviations ?? context.findingCount, projection.workspaceDeviationKeys); - const themeCount = (id: string) => distribution.themes.find(theme => theme.id === id)!.count; - const distributionRows: Array<[string, number]> = distribution.themes.map(item => [item.label, item.count]); + const distributionRows: Array<[string, number]> = impacts.map(item => [item.label, themeCount(item.id)]); if (distribution.unassigned) distributionRows.push(['Ohne Problemkategorie', distribution.unassigned]); const reviewed = projection.workItems.filter(item => item.status && item.status !== 'open'); const accepted = projection.remediation?.riskAccepted ?? 0; + // Unassessed states follow the Work records, as Client Info and Data Hub count them; older snapshots keep result rows. + const work = projection.workspaceCounts; + const notRun = work?.notRun ?? projection.coverage.notRun; + const executionErrors = work?.executionErrors ?? projection.coverage.executionErrors; + const notApplicable = work?.notApplicable ?? projection.coverage.notApplicable; + const unknown = work?.unknown ?? projection.coverage.unknown; + const risks = projection.riskRegister; + const riskSentence = risks + ? risks.recorded + ? `Im Risikoregister ${risks.recorded === 1 ? 'ist eine Risikoakzeptanz' : `sind ${n(risks.recorded)} Risikoakzeptanzen`} erfasst; ${risks.current === 0 ? 'keine davon ist' : risks.current === 1 ? 'eine davon ist' : `${n(risks.current)} davon sind`} aktuell gültig.` + : 'Im Risikoregister ist keine Risikoakzeptanz erfasst.' + : accepted ? `Für ${n(accepted)} ${accepted === 1 ? 'Abweichung ist' : 'Abweichungen ist'} eine Risikoakzeptanz dokumentiert.` : 'Für keine Abweichung ist eine Risikoakzeptanz dokumentiert.'; const date = new Date(projection.generatedAt).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' }); const sheets: Array<{ title: string; subtitle: string; body: string }> = []; // Summary statements derive only from published counts; nothing is rated or extrapolated. - const ranked = [...distribution.themes].filter(item => item.count > 0).sort((left, right) => right.count - left.count); + const ranked = impacts.map(item => ({ label: item.label, count: themeCount(item.id) })).filter(item => item.count > 0); const critical = classified.filter(item => item.severity === 'critical'); const criticalCount = critical.reduce((sum, item) => sum + countOf(item), 0); const topCategories = [...classified].sort((left, right) => countOf(right) - countOf(left) || left.label.localeCompare(right.label)).slice(0, 3); - const domainRates = projection.domains.filter(item => item.assessed > 0) + // Work-level domain counts reconcile to the headline; older snapshots keep their result-row counts. + const domainTable = projection.workspaceDomains ?? projection.domains; + const domainRates = domainTable.filter(item => item.assessed > 0) .sort((left, right) => right.deviations / right.assessed - left.deviations / left.assessed || right.deviations - left.deviations); const score = projection.overallScore; const scoreText = typeof score === 'number' && Number.isFinite(score) && score >= 0 && score <= 100 @@ -94,20 +109,20 @@ export function buildContextManagementHtml(projection: ReportProjection): string topCategories.length > 1 ? `Am stärksten belegt sind die Problemfelder ${list(topCategories.map(item => `${textOf(item).name} (${n(countOf(item))})`))}.` : '', critical.length ? `${critical.length === 1 ? 'Im Problemfeld' : 'In den Problemfeldern'} mit der Grundschwere Critical – ${list(critical.map(item => textOf(item).name))} – ${criticalCount === 1 ? 'liegt' : 'liegen'} zusammen ${deviationsWord(criticalCount)} vor.` : 'Keinem Problemfeld mit der Grundschwere Critical ist eine Abweichung zugeordnet.', domainRates.length ? `Nach Security Domains zeigt ${domainRates[0].domainLabel} die höchste Abweichungsquote (${n(domainRates[0].deviations)} von ${n(domainRates[0].assessed)}, ${share(domainRates[0].deviations, domainRates[0].assessed)}).` : '', - projection.coverage.notRun || projection.coverage.executionErrors - ? `${n(projection.coverage.notRun)} Prüfungen wurden nicht ausgeführt${projection.coverage.executionErrors ? `, ${n(projection.coverage.executionErrors)} endeten mit einem Ausführungsfehler` : ''}. Ihr Ergebnis ist offen und nicht als bestanden gewertet.` : '' + notRun || executionErrors + ? `${n(notRun)} Prüfungen wurden nicht ausgeführt${executionErrors ? `, ${n(executionErrors)} endeten mit einem Ausführungsfehler` : ''}. Ihr Ergebnis ist offen und nicht als bestanden gewertet.` : '' ].filter(Boolean); sheets.push({ title: 'Security Assessment', subtitle: 'Summary', body: `

Summary

${summary.map(sentence => `

${escapeHtml(sentence)}

`).join('')}

Handlungsfelder

${ranked.length ? `
    ${ranked.map(item => `
  • ${escapeHtml(item.label)} – ${escapeHtml(deviationsWord(item.count))}
  • `).join('')}
` : '

Keine Business-Impact-Zuordnung aus den vorhandenen Kategorien ableitbar.

'}
-

Die folgenden Angaben beschreiben je Thema den möglichen Business Impact, die betroffenen Problemfelder und deren Ansatzpunkte. Originalempfehlungen und Einzelnachweise stehen im technischen Report desselben Assessment-Stands.

+

Die folgenden Angaben beschreiben je Thema den möglichen Business Impact, die betroffenen Problemfelder und deren Ansatzpunkte. Der technische Report desselben Assessment-Stands vertieft die Problemfelder; Einzelnachweise und Originalempfehlungen stehen in ASSESSOR.

Assessment auf einen Blick

${overallScoreChart(projection.overallScore)}${rows(['Kennzahl', 'Wert'], [ ['Bewertete Ergebnisse', n(assessed)], ['Abweichungen', n(deviations)], ['Abweichungsquote', share(deviations, assessed)], -['Belegte Problemfelder', n(classified.length)], ['Nicht ausgeführte Prüfungen', n(projection.coverage.notRun)], -['Ausführungsfehler', n(projection.coverage.executionErrors)]])}
-

Bearbeitungsstand und Entscheidungen

${accepted ? `Für ${n(accepted)} ${accepted === 1 ? 'Abweichung ist' : 'Abweichungen ist'} eine Risikoakzeptanz dokumentiert.` : 'Für keine Abweichung ist eine Risikoakzeptanz dokumentiert.'} ${reviewed.length ? `Für ${n(reviewed.length)} ${reviewed.length === 1 ? 'Abweichung ist' : 'Abweichungen ist'} ein Bearbeitungsstatus erfasst.` : 'Ein Bearbeitungsstatus ist nicht erfasst.'}

Arbeitsstatus und Risikoakzeptanz beweisen keine Behebung. Behebung erfordert einen vergleichbaren Rescan.

+['Belegte Problemfelder', n(classified.length)], ['Nicht ausgeführte Prüfungen', n(notRun)], +['Ausführungsfehler', n(executionErrors)]])}
+

Bearbeitungsstand und Entscheidungen

${riskSentence} ${reviewed.length ? `Für ${n(reviewed.length)} ${reviewed.length === 1 ? 'Abweichung ist' : 'Abweichungen ist'} ein Bearbeitungsstatus erfasst.` : 'Ein Bearbeitungsstatus ist nicht erfasst.'}

Arbeitsstatus und Risikoakzeptanz beweisen keine Behebung. Behebung erfordert einen vergleichbaren Rescan.

Veränderung zum Vergleichsstand

${projection.delta ? rows(['Vergleichsstatus', 'Anzahl'], Object.entries(projection.delta.counts).map(([state, value]) => [state, n(value)])) : '

Kein belegter Assessment-Vergleich in diesem Report-Snapshot vorhanden; eine zeitliche Verbesserung oder Verschlechterung wird nicht behauptet.

'}
` }); sheets.push({ title: 'Möglicher Business Impact', subtitle: 'Themen im Überblick', body: ` @@ -115,7 +130,7 @@ ${summary.map(sentence => `

${escapeHtml(sentence)}

`).join('')}
${rows(['Thema', 'Leitfrage', 'Anzahl', 'Anteil'], impacts.map(item => [item.label, item.clusters.length ? item.question : 'Aus den vorhandenen Context-Kategorien ist hierzu keine Aussage ableitbar.', n(themeCount(item.id)), share(themeCount(item.id), distribution.total)]).concat(distribution.unassigned - ? [['Ohne Problemkategorie', 'Abweichungen ohne belegtes Problemfeld; im technischen Report vollständig enthalten.', n(distribution.unassigned), share(distribution.unassigned, distribution.total)]] : []))}
+ ? [['Ohne Problemkategorie', 'Abweichungen ohne belegtes Problemfeld; im technischen Report gesondert ausgewiesen.', n(distribution.unassigned), share(distribution.unassigned, distribution.total)]] : []))}
${bars('Abweichungen nach Hauptthema', distributionRows)}

Gesamt: ${n(distribution.total)} Abweichungen

${escapeHtml(MANAGEMENT_DOWNSTREAM_CONSEQUENCES)}

@@ -144,15 +159,21 @@ ${clusters.length ? `

Problemfelder

${clusters.map(categoryBlock).join(''

Critical · High · Medium · grau ohne Abweichungen oder mit unbestimmter Grundschwere. Neutral bedeutet nicht sicher: Ein graues Problemfeld ist nicht automatisch unauffällig. Erhobene Konfigurationen und beobachtete Zustände sind unterschiedliche Belegarten.

${context.sharedSettingLevers.length ? `

${n(context.sharedSettingLevers.length)} ${context.sharedSettingLevers.length === 1 ? 'Konfigurationseinstellung erfüllt' : 'Konfigurationseinstellungen erfüllen'} Bedingungen mehrerer Problemfelder. Dies sind gemeinsame Prüfansatzpunkte, keine zugesicherte Wirkung oder Aufwandsbewertung.

` : ''}` }); - const observed = projection.provenance.map(item => item.observedAt).filter((value): value is string => Boolean(value)).sort(); + // Collections are the scan and GPO collection runs; import, processing and evaluation steps are no collection. + // Their date follows the Client Info timeslot: the imported source files' dates, never the import or processing time. + const collections = new Set(projection.provenance.filter(item => item.kind === 'ScanRun').map(item => item.id)).size; + const sourceDays = [...new Set(projection.provenance.flatMap(item => item.sourceDate ? [item.sourceDate] : []).sort().map(dateDe))]; + const collectionWord = collections === 1 ? 'eine Erhebung' : `${n(collections)} Erhebungen`; const unmapped = active.filter(item => item.id !== 'other-deviations' && !impacts.some(theme => theme.clusters.some(cluster => cluster.id === item.id))); sheets.push({ title: 'Untersuchungsumfang', subtitle: 'Datenbasis und Nachvollziehbarkeit', body: `

Untersuchungsumfang und Datenbasis

${projection.environment.map(item => fact(item.key, item.value ?? 'Unbekannt')).join('')}
-

${observed.length ? observed.length === 1 ? `Grundlage ist eine Erhebung vom ${dateDe(observed[0])}.` - : `Grundlage sind ${n(observed.length)} Erhebungen zwischen dem ${dateDe(observed[0])} und dem ${dateDe(observed.at(-1)!)}.` : 'Keine Erhebungszeitpunkte in diesem Snapshot vorhanden.'} -Nicht anwendbar: ${n(projection.coverage.notApplicable)} · sonstige unbewertete Ergebnisse: ${n(projection.coverage.unknown)}.

+

${collections ? `Grundlage ${collections === 1 ? 'ist' : 'sind'} ${collectionWord}${sourceDays.length === 1 ? ` im Timeslot vom ${sourceDays[0]}` + : sourceDays.length > 1 ? ` mit Quelldateien vom ${sourceDays[0]} bis ${sourceDays.at(-1)}` : ''}.` : 'Keine Erhebungen in diesem Snapshot vorhanden.'} +Nicht anwendbar: ${n(notApplicable)}${work?.notConfigured !== undefined ? ` · nicht konfiguriert: ${n(work.notConfigured)}` : ''} · sonstige unbewertete Ergebnisse: ${n(unknown)}.

${unmapped.length ? `

Offene fachliche Zuordnung

${rows(['Context-Kategorie', 'Findings', 'Einordnung'], unmapped.map(item => [item.label, n(countOf(item)), 'Keine Business-Impact-Zuordnung; fachlich prüfen.']))}` : ''} -

Technische Domain-Auswertung

${rows(['Security Domain', 'Bewertet', 'Abweichend', 'Bestanden', 'Abweichungsquote'], projection.domains.map(item => [item.domainLabel, n(item.assessed), n(item.deviations), n(item.passed), item.assessed ? share(item.deviations, item.assessed) : '–']))}

Diese Zahlen beschreiben technische Ergebniszeilen. Sie sind keine Summen der Problemfelder.

+

Technische Domain-Auswertung

${rows(['Security Domain', 'Bewertet', 'Abweichend', 'Bestanden', 'Abweichungsquote'], domainTable.map(item => [item.domainLabel, n(item.assessed), n(item.deviations), n(item.passed), item.assessed ? share(item.deviations, item.assessed) : '–']))}

${projection.workspaceDomains + ? 'Jede bewertete Prüfung zählt in genau einer Security Domain; die Summen entsprechen den bewerteten Ergebnissen und Abweichungen der Summary.' + : 'Diese Zahlen beschreiben technische Ergebniszeilen. Sie sind keine Summen der Problemfelder.'}

Lesehinweise und Aussagegrenzen

    ${MANAGEMENT_READING_NOTES.map(note => `
  • ${escapeHtml(note)}
  • `).join('')}

Nachvollziehbarkeit

Der separate technische Report enthält die Belege und Originalempfehlungen. Beide Dateien wurden aus demselben gespeicherten Assessment-Stand erstellt.

ASSESSOR Einordnung ${escapeHtml(MANAGEMENT_BUSINESS_IMPACT_VERSION)} · Textbausteine ${escapeHtml(MANAGEMENT_REPORT_TEXTS_VERSION)} · Context Engine ${escapeHtml(context.engineVersion)} · Katalog ${escapeHtml(context.catalogVersion)}. Offline erzeugt, ohne LLM und ohne Internetabruf. Aussagen verwenden feste Textbausteine und gespeicherte Ergebnisse. Tatsächliche Geschäftsfolgen, Ausnutzung, Kosten, Verantwortliche und Termine werden nicht behauptet.

` }); diff --git a/src/reporting/management-overview-report.ts b/src/reporting/management-overview-report.ts index 88ae018..4c7c956 100644 --- a/src/reporting/management-overview-report.ts +++ b/src/reporting/management-overview-report.ts @@ -5,7 +5,7 @@ import { statementText } from './report-statement-text'; import { buildContextManagementHtml } from './context-management-report'; /** Offline management report: a factual view of the shared projection. */ -export const MANAGEMENT_OVERVIEW_RENDERER_VERSION = '3.3.0'; +export const MANAGEMENT_OVERVIEW_RENDERER_VERSION = '3.3.1'; const DASH = '—'; const count = (n: number): string => Number.isFinite(n) ? n.toLocaleString('de-DE') : DASH; diff --git a/src/reporting/report-charts.ts b/src/reporting/report-charts.ts new file mode 100644 index 0000000..c7069ee --- /dev/null +++ b/src/reporting/report-charts.ts @@ -0,0 +1,124 @@ +import { escapeHtml } from '../shared/html'; + +/** + * Static SVG/HTML chart primitives of the Technical Report in the ASSESSOR chart language: full + * 360-degree donuts with a neutral track, flat arc ends from 12 o'clock and a narrow mark for a + * positive share below 2 %; flat bars without gradients or shadows. Colours are token references only. + */ +const esc = escapeHtml; +const RING_RADIUS = 48; +const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; + +export const formatCount = (value: number): string => value.toLocaleString('de-DE'); + +/** Whole percentage from the unrounded share; a positive share below 1 % reads `<1 %`. */ +export function formatShare(part: number, whole: number): string { + if (whole <= 0) return '—'; + const share = 100 * part / whole; + if (part > 0 && Math.round(share) === 0) return '<1 %'; + return `${formatCount(Math.round(share))} %`; +} + +function ringArc(share: number, tone: string): string { + if (share <= 0) return ''; + const clamped = Math.min(1, share); + const arc = ``; + // A positive share below 2 % keeps a narrow radial mark so it stays visible at the compact ring size. + const mark = clamped < 0.02 ? `` : ''; + return arc + mark; +} + +/** Count/percentage donut: bold absolute count above the regular-weight share. */ +export function countDonut(title: string, part: number, whole: number, tone: string, basis: string): string { + return `
${esc(title)}
+
+ ${formatCount(part)}${formatShare(part, whole)}
+

${esc(basis)}

`; +} + +export interface DonutSegment { value: number; tone: string; label: string } + +/** + * Segmented distribution donut: categorical arcs in the given order, clockwise from 12 o'clock on the + * same track and stroke as every other donut, closing the ring when they sum to `whole`. The readout + * shows the given count and share; a legend lists every segment with its count. + */ +export function segmentDonut(title: string, segments: DonutSegment[], whole: number, readout: { value: number; share: string }): string { + let start = 0; + const arcs = whole > 0 ? segments.filter(item => item.value > 0).map(item => { + const length = item.value / whole * RING_CIRCUMFERENCE; + const arc = `${esc(`${item.label}: ${formatCount(item.value)}`)}`; + start += length; + return arc; + }).join('') : ''; + return `
${esc(title)}
+
${arcs} + ${formatCount(readout.value)}${esc(readout.share)}
+
    ${segments.filter(item => item.value > 0).map(item => + `
  • ${esc(item.label)} ${formatCount(item.value)}
  • `).join('')}
`; +} + +export function scoreTone(score?: number): string { + return score === undefined ? '--donut-not-run' : score < 50 ? '--sev-critical' : score < 75 ? '--sev-medium' : '--donut-passed'; +} + +/** Score ring on the 0-100 scale with its `/100` semantics instead of an invented count. */ +export function scoreDonut(title: string, score: number | undefined, basis: string, compact = false): string { + const label = score === undefined ? '—' : score > 0 && score < 0.5 ? '<1' : formatCount(Math.round(score)); + return `
${esc(title)}
+
+ ${label}${score === undefined ? 'kein Wert' : '/100'}
+ ${basis ? `

${esc(basis)}

` : ''}
`; +} + +export interface BarSegment { value: number; tone: string; label: string } + +/** One flat stacked bar; widths relative to `scale`, every segment labelled for assistive technology. */ +export function stackedBar(segments: BarSegment[], scale: number): string { + const total = segments.reduce((sum, item) => sum + item.value, 0); + const parts = segments.filter(item => item.value > 0).map(item => + ``).join(''); + return `${total ? parts : ''}`; +} + +export interface BipartiteNode { id: string; label: string; total: number; tone?: string; detail?: string } +export interface BipartiteLink { from: string; to: string; value: number } + +/** + * Pathfinder relation view as a static two-column diagram: Security Domains on the left, the Context + * clusters their deviations belong to on the right. Link width follows the deviation count. + */ +export function bipartiteDiagram(left: BipartiteNode[], right: BipartiteNode[], links: BipartiteLink[]): string { + const row = 24; + const top = 12; + const width = 700; + const leftX = 230; + const rightX = 410; + const height = top * 2 + Math.max(left.length, right.length) * row; + const leftGap = (height - top * 2) / Math.max(1, left.length); + const leftY = new Map(left.map((node, index) => [node.id, top + leftGap * (index + 0.5)])); + const rightY = new Map(right.map((node, index) => [node.id, top + row * (index + 0.5)])); + const tone = new Map(left.map(node => [node.id, node.tone ?? '--graph-edge'])); + const max = Math.max(1, ...links.map(link => link.value)); + const stroke = (value: number) => (1.5 + 12 * Math.sqrt(value / max)).toFixed(2); + const paths = links.filter(link => leftY.has(link.from) && rightY.has(link.to)).map(link => { + const y1 = leftY.get(link.from)!; + const y2 = rightY.get(link.to)!; + const mid = (leftX + rightX) / 2; + return `${esc(`${left.find(node => node.id === link.from)?.label} → ${right.find(node => node.id === link.to)?.label}: ${formatCount(link.value)}`)}`; + }).join(''); + const leftNodes = left.map(node => { + const y = leftY.get(node.id)!; + return ` + ${esc(node.label)} ${formatCount(node.total)}`; + }).join(''); + const rightNodes = right.map(node => { + const y = rightY.get(node.id)!; + return ` + ${esc(node.label)} ${formatCount(node.total)}${node.detail ? ` ${esc(node.detail)}` : ''}`; + }).join(''); + return `${paths}${leftNodes}${rightNodes}`; +} diff --git a/src/reporting/report-css.d.ts b/src/reporting/report-css.d.ts new file mode 100644 index 0000000..c658489 --- /dev/null +++ b/src/reporting/report-css.d.ts @@ -0,0 +1,5 @@ +// Application token files embedded verbatim into standalone reports (bundled by Vite as text). +declare module '*.css?raw' { + const content: string; + export default content; +} diff --git a/src/reporting/report-header-facts.ts b/src/reporting/report-header-facts.ts new file mode 100644 index 0000000..035566c --- /dev/null +++ b/src/reporting/report-header-facts.ts @@ -0,0 +1,51 @@ +import type { EnvironmentSnapshot } from '../domain/environment-context'; +import type { ImportArtifact, ImportProcessingRun } from '../domain/ingestion-persistence'; +import type { StoredGpoBackupInstance } from '../domain/gpo-analysis-persistence'; +import { + clientInfoOverview, cloudInformationTileFacts, numberFact, operatingSystemOverview +} from '../features/dashboard/client-info-facts'; +import type { ReportHeaderFacts } from './report-projection'; + +/** + * The Technical Report's `Information` section: the Client Info overview facts of the same snapshot, + * built by the same React-free functions Client Info renders (Data facts, Domain Information and the + * six environment tiles with their compact overviews). Nothing is derived here beyond that selection. + */ +export function reportHeaderFacts(snapshot: EnvironmentSnapshot | undefined, + options: { gpoCount?: number; inferredEntraTenant?: boolean }): ReportHeaderFacts | undefined { + if (!snapshot) return undefined; + // A partial snapshot reads as empty lists, so every missing fact stays an em dash. + snapshot = { ...snapshot, observations: snapshot.observations ?? [], sections: snapshot.sections ?? [] }; + const overview = clientInfoOverview(snapshot, options); + const tile = (items: T[]) => items.slice(0, 4); + return { + data: overview.data.map(item => ({ label: item.label, value: numberFact(item.value) })), + domainInformation: overview.domainInformation, + groups: [ + { title: 'Resilience', facts: tile(overview.detail.resilience) }, + { title: 'Inventory', facts: tile(overview.inventory) }, + { title: 'Password policy', facts: tile(overview.detail.passwordPolicy) }, + { title: 'Certificates & PKI', facts: tile(overview.detail.certificates) }, + { title: 'Operating systems', facts: operatingSystemOverview(overview.detail.operatingSystems) }, + { title: 'Cloud Information', facts: cloudInformationTileFacts(snapshot).map(area => ({ label: area.label, value: area.values.join(', ') })) } + ] + }; +} + +/** + * The GPO count Client Info shows: per stored GPO backup source, the backup instances of its latest + * processing run, counted only when that run has a status (same rule as the GPO backup status list). + */ +export function reportGpoBackupCount(artifacts: ImportArtifact[], runs: ImportProcessingRun[], + instances: StoredGpoBackupInstance[]): number | undefined { + let processed = 0; + let count = 0; + for (const artifact of artifacts.filter(item => item.kind === 'source-file' && item.relativePath.startsWith('gpo-backups/'))) { + const run = runs.filter(item => item.artifactId === artifact.id) + .sort((left, right) => Date.parse(right.completedAt) - Date.parse(left.completedAt) || right.id.localeCompare(left.id))[0]; + if (!run?.status) continue; + processed += 1; + count += instances.filter(item => item.processingRunId === run.id).length; + } + return processed ? count : undefined; +} diff --git a/src/reporting/report-input-assembler.ts b/src/reporting/report-input-assembler.ts index 295d63e..42283ea 100644 --- a/src/reporting/report-input-assembler.ts +++ b/src/reporting/report-input-assembler.ts @@ -7,6 +7,7 @@ import { dashboardScoreFromStates, type DashboardResultState } from '../features import { securitySourceScores, weightedSecurityScore } from '../domain/security-scoring'; import { securityToolId } from '../scoring/canonical-security-score'; import { clientReportInput } from './client-report-projection'; +import { reportGpoBackupCount } from './report-header-facts'; export interface ReportAssemblyScope { projectId: string; @@ -32,13 +33,17 @@ export async function assembleReportProjectionInput( return model; }, queryUnifiedResults: query => repository.queryUnifiedResults(query), - listPolicyEvaluations: async projectId => ({ records: await repository.listPolicyEvaluations(projectId) }), + // Client Info reads only the current policy evaluations (its overview's current runs); the report's + // Raw Data records use the same selection so every Work record and count matches Client Info. + listPolicyEvaluations: async projectId => ({ records: repository.listCurrentPolicyEvaluationsForMetrics + ? await repository.listCurrentPolicyEvaluationsForMetrics(projectId) + : await repository.listPolicyEvaluations(projectId) }), listMicrosoftSecurityBaselineAnalyses: async baselineId => ({ records: await repository.listMicrosoftSecurityBaselineAnalyses(baselineId) }) }, scope.projectId, scope.scanRunIds, assessmentIds); if (!workspace.dataset) throw new Error('No reportable assessment data in the selected scope.'); - const [results, snapshot, storedReviews, processing, analyses, aggregations, evaluations] = await Promise.all([ + const [results, snapshot, storedReviews, processing, analyses, aggregations, evaluations, artifacts, backupInstances] = await Promise.all([ queryAllUnifiedResults(query => repository.queryUnifiedResults(query), { audience: 'internal-analysis', projectId: scope.projectId, assessmentIds: workspace.assessmentIds, scanRunIds: scope.scanRunIds, @@ -49,7 +54,10 @@ export async function assembleReportProjectionInput( repository.listProjectProcessingRuns(scope.projectId), repository.listGpoPolicyAnalyses(scope.projectId), repository.listPolicySnapshotAggregations(scope.projectId), - repository.listPolicyEvaluations(scope.projectId) + repository.listPolicyEvaluations(scope.projectId), + // The GPO count Client Info shows comes from the processed GPO backup sources of the project. + repository.listImportArtifacts(scope.projectId), + repository.listGpoBackupInstances(scope.projectId) ]); const dataset = workspace.dataset; const summaryWorkspace = await loadCanonicalProjectWorkspace({ @@ -82,7 +90,8 @@ export async function assembleReportProjectionInput( const reviews = hydrateWorkspaceReviews(dataset, storedReviews, {}).reviews; const preliminary = clientReportInput({ project, assessmentIds: workspace.assessmentIds, generatedAt: scope.generatedAt, - results, dataset, workspaceDataset: summaryWorkspace.dataset ?? undefined, environment: snapshot, reviews + results, dataset, workspaceDataset: summaryWorkspace.dataset ?? undefined, environment: snapshot, reviews, + gpoCount: reportGpoBackupCount(artifacts, processing, backupInstances) }); const ratingByTool = new Map(securitySourceScores(dataset.securityScoring?.scans ?? []).map(item => [item.toolId, item])); const resultsByTool = new Map(); @@ -138,7 +147,8 @@ export async function assembleReportProjectionInput( ...workspace.models.flatMap(model => model.importFiles.map(file => ({ kind: `ImportFile: ${file.fileName}`, id: file.id, version: [file.processedWith.profileVersion, file.processedWith.mappingVersion, - file.processedWith.normalizationVersion].filter(Boolean).join(' / '), observedAt: file.importedAt + file.processedWith.normalizationVersion].filter(Boolean).join(' / '), observedAt: file.importedAt, + sourceDate: file.lastModifiedAt ?? file.importedAt }))), ...scopedProcessing.map(run => ({ kind: `ProcessingRun: ${run.status}`, id: run.id, version: run.processors.map(item => `${item.id}@${item.version}`).join(', '), observedAt: run.completedAt })), diff --git a/src/reporting/report-projection.ts b/src/reporting/report-projection.ts index 41a0c84..13402be 100644 --- a/src/reporting/report-projection.ts +++ b/src/reporting/report-projection.ts @@ -3,7 +3,6 @@ import type { ReportContextPublication } from './report-context'; import type { RemediationEvidenceState } from '../domain/timeslot-comparison'; import type { ToolPriorityBasisDefinition } from '../domain/tool-profile-definition'; import type { UnifiedResultProjection } from '../domain/unified-result'; -import type { PolicySourceEvidence, RecordField } from '../domain/finding'; import { correlateReportResults, type ReportDomainConvergence, type ReportRemediationBundle, type ReportSingleSourcePolicyAction } from './report-correlation'; import { severityRank } from '../domain/severity-order'; import { buildReportStatements, type ReportStatement } from './report-narrative'; @@ -18,7 +17,7 @@ import { buildReportStatements, type ReportStatement } from './report-narrative' * every German wording lives in a renderer. It is also platform-neutral: no * clock, no storage, no network and no presentation types. */ -export const REPORT_PROJECTION_VERSION = '1.3.0'; +export const REPORT_PROJECTION_VERSION = '1.5.0'; /* -------------------------------------------------------------------------- */ /* Priority basis */ @@ -102,6 +101,97 @@ export interface ReportFact { value?: string; } +/** One technical environment fact; an unknown value stays an em dash. */ +export interface ReportTileFact { + label: string; + value: string; +} + +/** Technical environment context read from the same normalized observations Client Info reads. */ +export interface ReportEnvironmentFacts { + directory: ReportTileFact[]; + resilience: ReportTileFact[]; + operatingSystems: Array<{ label: string; total?: number; entries: Array<{ name: string; count?: number }> }>; + /** Scanner sections the source could not read (licence or access restriction). */ + restricted: string[]; +} + +/** A Pathfinder ATT&CK relation exactly as the frozen reference recognition states it. */ +export interface ReportAttackRelation { + kind: 'technique' | 'tactic'; + id: string; + title: string; + tactics: string[]; + /** `declared` by the scanner itself, or only `mentioned` in its text. */ + evidence: 'declared' | 'mentioned'; +} + +/** One Work record (Raw Data), reduced to what the Technical Report aggregates. */ +export interface ReportWorkRecord { + id: string; + title: string; + toolId?: string; + toolLabel: string; + /** Client Info result state of the record (deviation, passed, not-configured, ...). */ + state: string; + severity: string; + domainId: string; + /** Check area named by the source: benchmark section, policy section or native category. */ + area?: string; + ruleId?: string; + /** Work counting key (scanner occurrence or GPO scope/identity), shared with the Context publication. */ + countingKey: string; + diagnostics: string[]; + attack: ReportAttackRelation[]; + defenses: Array<{ id: string; title: string; evidence: 'declared' | 'mentioned' }>; + policy?: { type?: string; backups: number; unlinkedBackups: number }; + /** Finding Reader card of the record: head, Problem rows, Description and catalog references. */ + card?: ReportFindingCard; +} + +/** The head of a Finding Reader card: native status, badges and the first-read Problem rows. */ +export interface ReportFindingCard { + status?: { value: string; outcome?: string }; + /** Severity first as its own badge, then `Label: value` badges in Finding Reader order. */ + badges: Array<{ text: string; severity?: string }>; + problem?: { heading: string; rows: Array<{ label: string; value: string; state?: 'deviating' | 'aligned' }> }; + /** MITRE badges the source states on the record (`MITRE ATT&CK: Credential Access`). */ + mitre: string[]; + /** The source description as plain paragraphs. */ + description?: string; + /** Recognized ATT&CK/D3FEND catalog references with the source text that states them. */ + references: Array<{ label: string; text: string; url?: string }>; +} + +/** The Client Info Data tile totals over the same project-sheet records and states as Client Info. */ +export interface ReportClientInfoTotals { + passed: number; + deviations: number; + notConfigured: number; + conflicts: number; + duplicates: number; + assessed: number; + eligible: number; + unassessed: number; + coverage?: number; +} + +/** Client Info overview facts exactly as Client Info shows them, for the report header. */ +export interface ReportHeaderFacts { + data: ReportTileFact[]; + domainInformation: ReportTileFact[]; + groups: Array<{ title: string; facts: ReportTileFact[] }>; +} + +export interface ReportWorkspaceView { + version: string; + /** ATT&CK catalog the Pathfinder relations were resolved against. */ + attackCatalogVersion: string; + totals: ReportClientInfoTotals; + records: ReportWorkRecord[]; + environment?: ReportEnvironmentFacts; +} + export interface ReportScoreSource { toolId: string; /** Versioned ASSESSOR contribution on the 0-100 scale, absent when unavailable. */ @@ -146,6 +236,8 @@ export interface ReportProvenanceEntry { id: string; version?: string; observedAt?: string; + /** Imported source file date (last modified, else imported): the basis of the Client Info timeslot date. */ + sourceDate?: string; } /** Client-owned planning fields. Never proof, always self-reported. */ @@ -192,13 +284,18 @@ export interface ReportProjectionInput { domainOf: (result: UnifiedResultProjection) => string; priorityBases?: Readonly>; scoreSources?: ReportScoreSource[]; - workspaceCounts?: { deviations: number; assessed: number }; + workspaceCounts?: ReportWorkspaceCounts; workspaceDeviationKeys?: string[]; + /** Risk register as Client Info shows it: recorded acceptances and the currently valid ones. */ + riskRegister?: { recorded: number; current: number }; + /** Security Domain counts over the same Work records as `workspaceCounts` (one rolled-up setting = one check). */ + workspaceDomains?: ReportWorkspaceDomain[]; overallScore?: number; environment?: ReportFact[]; + workspaceView?: ReportWorkspaceView; + /** Client Info overview facts for the report header, when supplied. */ + headerFacts?: ReportHeaderFacts; policyEntries?: ReportPolicyEntry[]; - technicalPolicySources?: Array<{ settingId: string; sources: PolicySourceEvidence[] }>; - technicalFindingFields?: Array<{ resultProjectionId: string; fields: RecordField[] }>; policyScopeLimited?: boolean; reviews?: ReportReviewInput[]; diagnostics?: ReportDiagnostic[]; @@ -241,6 +338,26 @@ export interface ReportDomainPriority { }>; } +/** Result states of the Work records, counted as Client Info and Data Hub count them. */ +export interface ReportWorkspaceCounts { + deviations: number; + assessed: number; + notRun?: number; + notApplicable?: number; + executionErrors?: number; + notConfigured?: number; + unknown?: number; +} + +/** Work-level Security Domain counts; they reconcile to `workspaceCounts`. */ +export interface ReportWorkspaceDomain { + domainId: string; + domainLabel: string; + passed: number; + deviations: number; + assessed: number; +} + export interface ReportDomainSummary { domainId: string; domainLabel: string; @@ -352,10 +469,16 @@ export interface ReportProjection { project: { id: string; name: string }; assessmentId?: string; environment: ReportFact[]; + /** Technical cut only: the lean Work records with their Pathfinder relations. */ + workspaceView?: ReportWorkspaceView; + /** Client Info overview facts for the report header, when supplied. */ + headerFacts?: ReportHeaderFacts; overallScore?: number; scoreSources: ReportScoreSource[]; - workspaceCounts?: { deviations: number; assessed: number }; + workspaceCounts?: ReportWorkspaceCounts; workspaceDeviationKeys?: string[]; + riskRegister?: { recorded: number; current: number }; + workspaceDomains?: ReportWorkspaceDomain[]; domains: ReportDomainSummary[]; priorities: ReportDomainPriority[]; gpo?: ReportGpoBlock; @@ -372,8 +495,6 @@ export interface ReportProjection { technicalResults?: UnifiedResultProjection[]; technicalResultDomains?: Record; policyEntries: ReportPolicyEntry[]; - technicalPolicySources?: Array<{ settingId: string; sources: PolicySourceEvidence[] }>; - technicalFindingFields?: Array<{ resultProjectionId: string; fields: RecordField[] }>; technicalReviews?: ReportReviewInput[]; workItems: ReportWorkItem[]; sourcePriorities: ReportSourcePriority[]; @@ -697,10 +818,14 @@ export function buildReportProjection(input: ReportProjectionInput): ReportProje project: input.project, assessmentId: input.assessmentId, environment: input.environment ?? [], + workspaceView: input.workspaceView ? structuredClone(input.workspaceView) : undefined, + headerFacts: input.headerFacts ? structuredClone(input.headerFacts) : undefined, overallScore: input.overallScore, scoreSources: input.scoreSources ?? [], workspaceCounts: input.workspaceCounts ? { ...input.workspaceCounts } : undefined, workspaceDeviationKeys: input.workspaceDeviationKeys ? [...input.workspaceDeviationKeys] : undefined, + workspaceDomains: input.workspaceDomains?.map(item => ({ ...item })), + riskRegister: input.riskRegister ? { ...input.riskRegister } : undefined, domains: domainSummaries, priorities, gpo, @@ -711,8 +836,6 @@ export function buildReportProjection(input: ReportProjectionInput): ReportProje technicalResults: results, technicalResultDomains: Object.fromEntries(results.map(result => [result.resultProjectionId, domainOf(result)])), policyEntries, - technicalPolicySources: input.technicalPolicySources, - technicalFindingFields: input.technicalFindingFields, technicalReviews: reviews, workItems, sourcePriorities, diff --git a/src/reporting/report-read-port.ts b/src/reporting/report-read-port.ts index 646bcfe..6d946a3 100644 --- a/src/reporting/report-read-port.ts +++ b/src/reporting/report-read-port.ts @@ -15,6 +15,8 @@ export type ReportReadMethods = Pick; @@ -26,7 +28,7 @@ export interface ReportReadPort extends ReportReadMethods { const READ_METHODS = [ 'getProject', 'listAssessments', 'loadAssessmentModel', 'queryUnifiedResults', 'listPolicyEvaluations', - 'listMicrosoftSecurityBaselineAnalyses', 'getEnvironmentSnapshot', 'listReviews', 'listProjectProcessingRuns', + 'listMicrosoftSecurityBaselineAnalyses', 'getEnvironmentSnapshot', 'listReviews', 'listProjectProcessingRuns', 'listImportArtifacts', 'listGpoBackupInstances', 'listGpoPolicyAnalyses', 'listPolicySnapshotAggregations', 'listReferenceRuns' ] as const satisfies ReadonlyArray; diff --git a/src/reporting/report-rich-text.ts b/src/reporting/report-rich-text.ts new file mode 100644 index 0000000..7cb65c9 --- /dev/null +++ b/src/reporting/report-rich-text.ts @@ -0,0 +1,73 @@ +import { escapeHtml } from '../shared/html'; + +/** + * Static display of retained source prose (for example a scanner description) in the Technical Report. + * Many exports carry Markdown whose line breaks were lost, so headings, numbered steps, bullet lists, + * emphasis and links arrive on one line. This reader restores their structure for display only: + * every character of text stays, only the Markdown markers become HTML structure. Everything is + * escaped first; a link becomes an anchor only for a plain https URL, otherwise its caption stays text. + */ + +const esc = escapeHtml; + +/** Inline Markdown on already escaped text: **bold** and [caption](url). */ +function inline(text: string): string { + return esc(text) + .replace(/\*\*([^*]+?)\*\*/g, '$1') + .replace(/\[([^\]]+)\]\(([^)]*)\)/g, (_match, caption: string, url: string) => + /^https:\/\/[^\s<>"']+$/i.test(url) ? `${caption}` : caption); +} + +/** Splits an inline numbered list `… 1. a 2. b 3. c` when the numbers run 1, 2, 3 … without gaps. */ +function numberedItems(text: string): { lead: string; items: string[] } | undefined { + const marks = [...text.matchAll(/(?:^|\s)(\d{1,2})\.\s+/g)]; + const start = marks.findIndex(mark => mark[1] === '1'); + if (start < 0) return undefined; + const run = [marks[start]]; + for (const mark of marks.slice(start + 1)) if (Number(mark[1]) === run.length + 1) run.push(mark); + if (run.length < 2) return undefined; + const items = run.map((mark, index) => text.slice(mark.index! + mark[0].length, run[index + 1]?.index ?? text.length).trim()); + return { lead: text.slice(0, run[0].index).trim(), items }; +} + +/** + * Splits an inline bullet list of at least two items: `… * a * b`, or `… - [a](…) - [b](…)` where a dash + * only counts as a marker before a link, so a dash inside a sentence or link caption stays text. + */ +function bulletItems(text: string): { lead: string; items: string[] } | undefined { + for (const marker of [/(?:^|\s)\*\s+(?=\S)/, /(?:^|\s)-\s+(?=\[)/]) { + const parts = text.split(marker); + if (parts.length >= 3) return { lead: parts[0].trim(), items: parts.slice(1).map(item => item.trim()).filter(Boolean) }; + } + return undefined; +} + +function block(text: string): string { + const numbered = numberedItems(text); + if (numbered) return `${numbered.lead ? `

${inline(numbered.lead)}

` : ''}
    ${numbered.items.map(item => `
  1. ${inline(item)}
  2. `).join('')}
`; + const bullets = bulletItems(text); + if (bullets) return `${bullets.lead ? `

${inline(bullets.lead)}

` : ''}
    ${bullets.items.map(item => `
  • ${inline(item)}
  • `).join('')}
`; + return text ? `

${inline(text)}

` : ''; +} + +/** The source text as escaped HTML blocks: paragraphs, `####` sub-headings, numbered and bullet lists. */ +export function reportRichText(text: string): string { + // A paragraph break inside an unclosed link target belongs to that link, not to the prose. + const paragraphs: string[] = []; + for (const paragraph of text.split(/\r?\n\s*\r?\n/)) { + const previous = paragraphs.at(-1); + if (previous !== undefined && /\]\([^)]*$/.test(previous)) paragraphs[paragraphs.length - 1] = `${previous} ${paragraph}`; + else paragraphs.push(paragraph); + } + return paragraphs.map(paragraph => paragraph.trim()).filter(Boolean).flatMap(paragraph => { + // A Markdown heading (`##` to `######`) starts a section; its title runs to a colon or a list marker, + // at most a few words. A single `#` is too common inside URLs and prose to count as a heading. + const sections = paragraph.split(/\s+#{2,6}\s+|^#{2,6}\s+/); + return sections.map((section, index) => { + if (index === 0) return block(section.trim()); + const title = /^([^:*\n]{1,48}?)(?::\s*|\s+(?=[*-]\s)|$)/.exec(section); + if (!title) return block(section.trim()); + return `
${inline(title[1].trim())}
${block(section.slice(title[0].length).trim())}`; + }); + }).join(''); +} diff --git a/src/reporting/report-run.ts b/src/reporting/report-run.ts index c1d176e..34c8718 100644 --- a/src/reporting/report-run.ts +++ b/src/reporting/report-run.ts @@ -83,7 +83,11 @@ export async function createReportRun(port: ReportReadPort, scope: ReportAssembl const { input, revision } = await assembleConsistentReportInput(port, scope); const assessmentIds = scope.assessmentIds ?? (scope.assessmentId ? [scope.assessmentId] : (await port.listAssessments(scope.projectId)).map(item => item.id)); - const context = options.readContext ? await options.readContext({ ...scope, assessmentIds }) : undefined; + // An optional Context (Technical Report) that cannot be read leaves the report without problem fields + // instead of failing it; a required one (Management Report) still fails the run. + const context = options.readContext + ? await options.readContext({ ...scope, assessmentIds }).catch(error => { if (requireContext) throw error; return undefined; }) + : undefined; if (context && (context.projectId !== scope.projectId || JSON.stringify([...context.assessmentIds].sort()) !== JSON.stringify([...assessmentIds].sort()) || JSON.stringify([...context.scanRunIds].sort()) !== JSON.stringify([...(scope.scanRunIds ?? [])].sort()))) throw new ReportContextRequiredError(); diff --git a/src/reporting/report-workspace-view.ts b/src/reporting/report-workspace-view.ts new file mode 100644 index 0000000..88ed968 --- /dev/null +++ b/src/reporting/report-workspace-view.ts @@ -0,0 +1,224 @@ +import type { ImportedDataset } from '../domain/dataset'; +import type { EnvironmentSnapshot } from '../domain/environment-context'; +import type { FindingRecord } from '../domain/finding'; +import { toolIdForRecord } from '../importers/status-normalization'; +import { toolProfileDefinitions } from '../profiles/profile-definition-catalog'; +import { mitreBadgeLabel, normalizeSeverity } from '../profiles/purple-knight/presentation'; +import { policyDiagnosticBadges, policyFindingCategories } from '../profiles/group-policy/categories'; +import { policyProblemSummary } from '../profiles/group-policy/problem-summary'; +import { outcomeForStatusValue } from '../importers/status-normalization'; +import { normalizeNumericValue, plainTextValue } from '../shared/text'; +import { findingFirstRead } from '../features/finding-reader/first-read'; +import { resultStatusFieldFor } from '../features/linked-results/result-message'; +import { recordResultSheets } from '../features/linked-results/record-results'; +import { securityDomainIdForRecord } from '../evaluation/security-domains'; +import { profileForSheet } from '../features/project-import/workspace-profile'; +import { dashboardResultState, dashboardScoreFromStates } from '../features/dashboard/dashboard-result-state'; +import { operatingSystemInventory } from '../features/dashboard/operating-system-inventory'; +import { findingReferences } from '../references/finding-reference-projection'; +import { defaultMitreGraphData } from '../features/mitre-graph/mitre-graph'; +import type { ReportEnvironmentFacts, ReportFindingCard, ReportWorkRecord, ReportWorkspaceView } from './report-projection'; + +/** + * Version of the frozen Work cut inside the Technical Report: one lean entry per Work record (Raw Data) + * with its Client Info state, Security Domain, native check area and the Pathfinder relations + * (ATT&CK, D3FEND) it carries. No raw field dump and no Results tables: the report aggregates. + */ +export const REPORT_WORKSPACE_VIEW_VERSION = '2.1.0'; + +const DASH = '—'; +const field = (record: FindingRecord, label: string) => + record.fields.find(item => item.label.toLowerCase() === label)?.value?.trim() ?? ''; + +/** Same counting key as the management distribution and the Context publication. */ +export function reportWorkCountingKey(record: FindingRecord): string { + return record.findingOccurrenceId ? `scanner:${record.findingOccurrenceId}` + : field(record, 'category') === 'GPOs' ? `gpo:${field(record, 'scope').toLowerCase()}\u0000${field(record, 'technical identity')}` + : `unmapped:${record.id}`; +} + +/** + * The check area the source itself names: the CIS-CAT benchmark section, the GPO policy section, + * otherwise the native category. Never derived from the title or the outcome. + */ +function nativeArea(record: FindingRecord): string | undefined { + if (field(record, 'category') === 'GPOs') return field(record, 'policy section') || undefined; + return field(record, 'benchmark section') || field(record, 'category') || undefined; +} + +/** The Pathfinder relations of one record: the same frozen reference recognition the graph draws. */ +function relations(record: FindingRecord): Pick { + const references = findingReferences(record, defaultMitreGraphData).filter(reference => reference.status === 'active'); + const attack = references.filter(reference => reference.framework === 'mitre-attack').map(reference => ({ + kind: reference.kind === 'tactic' ? 'tactic' as const : 'technique' as const, + id: reference.id, title: reference.title ?? reference.id, tactics: [...reference.tactics], evidence: reference.evidence + })); + const defenses = references.filter(reference => reference.framework === 'mitre-d3fend') + .map(reference => ({ id: reference.id, title: reference.title ?? reference.id, evidence: reference.evidence })); + return { attack, defenses }; +} + +/** + * The statement of a native result text without the evidence appended to it: the text is cut, never + * rephrased, where a Markdown list item, a `➡️` portal pointer, a table, a heading or an inline list + * of at least two ` - ` items begins. A link inside the statement keeps its caption and loses only its + * URL; Markdown emphasis markers are dropped. A short fragment left before the cut goes with it. + */ +export function resultStatement(value: string): string { + const structure = /\s+[-*]\s+\[|\s*➡️|\r?\n\s*(?:[-*|#]|\d+\.\s)/.exec(value)?.index; + const inlineItems = [...value.matchAll(/\s+-\s+/g)]; + const inlineList = inlineItems.length >= 2 ? inlineItems[0].index : undefined; + const cut = Math.min(structure ?? value.length, inlineList ?? value.length); + let statement = value.slice(0, cut).replace(/\[([^\]\n]*)\]\([^)\s]*\)/g, '$1').replace(/\*\*/g, '').trim(); + if (cut < value.length) { + const sentenceEnd = Math.max(statement.lastIndexOf('. '), statement.lastIndexOf('! '), statement.lastIndexOf('? ')); + if (sentenceEnd > 0 && statement.length - sentenceEnd <= 24) statement = statement.slice(0, sentenceEnd + 1); + } + statement = statement.replace(/[\s:,;-]+$/, ''); + return statement || value.trim(); +} + +/** Source prose as plain paragraphs: block tags and blank lines separate paragraphs, all markup is dropped. */ +function plainParagraphs(value: string): string { + return value.split(/<\/?(?:p|div|h[1-6]|li|ul|ol|br)\b[^>]*>|\r?\n\s*\r?\n/i) + .map(plainTextValue).filter(Boolean).join('\n\n'); +} + +const REFERENCE_STATUS: Record = { active: 'Catalog match', deprecated: 'Deprecated', revoked: 'Revoked', 'not-in-catalog': 'Not found in this catalog' }; + +const normalizeLabel = (label: string) => label.trim().replace(/\s+/g, ' ').toLowerCase(); + +/** + * The Finding Reader card head of one record, from the same Core helpers the reader uses: native status, + * the reader's badge fields (category, severity, weight, score, ...) and the first-read Problem rows. + * MITRE badges are left out (the report shows relations in Beziehungen) and no tool is named. + */ +function findingCard(dataset: ImportedDataset, sheet: ImportedDataset['sheets'][number], record: FindingRecord, toolId?: string): ReportFindingCard { + const statusField = resultStatusFieldFor(record, toolId); + const outcome = statusField?.value ? outcomeForStatusValue(toolId, statusField.value) : 'unknown'; + const badges: ReportFindingCard['badges'] = record.fields.filter(item => { + if (!item.value) return false; + const label = normalizeLabel(item.label); + return /^(?:category|severity|weight|score|category score|domain risk level)$/.test(label) + || (/^points?$/.test(label) && normalizeNumericValue(item.value) !== null); + }).map(item => { + if (normalizeLabel(item.label) === 'severity') return { text: item.value, severity: normalizeSeverity(item.value) }; + const scale = /^(?:category score|domain risk level)$/i.test(item.label.trim()) ? ' / 100' : ''; + return { text: `${item.label}: ${item.value}${scale}` }; + }); + badges.push(...policyFindingCategories(record, toolId).map(text => ({ text })), ...policyDiagnosticBadges(record, toolId).map(text => ({ text }))); + let problem: ReportFindingCard['problem']; + if (toolId === 'group-policy') { + const summary = policyProblemSummary(record); + if (summary) { + const rows = [ + ...summary.configured.map(entry => ({ label: 'Configured', value: entry.note ? `${entry.text} (${entry.note})` : entry.text, state: entry.state })), + ...summary.expected.map(value => ({ label: 'Expected', value })), + ...(summary.additional.length ? [{ label: 'Additional entries', value: summary.additional.join(', ') }] : []), + ...(summary.missing.length ? [{ label: 'Missing entries', value: summary.missing.join(', ') }] : []) + ]; + if (rows.length) problem = { heading: summary.heading, rows }; + } + } else { + const firstRead = findingFirstRead(record, toolId, recordResultSheets(record, dataset.sheets, sheet.id, toolId), outcome); + // The card head shows the statement rows (Result, Affected, Expected/Found); Impact is explanatory prose. + const rows = firstRead?.rows.filter(row => row.label !== 'Impact') + .map(row => row.label === 'Result' ? { ...row, value: resultStatement(row.value) } : { ...row }) ?? []; + if (firstRead && rows.length) problem = { heading: firstRead.heading, rows }; + } + const mitre = record.fields.filter(item => item.value && mitreBadgeLabel(item.label)).map(item => `${mitreBadgeLabel(item.label)}: ${item.value}`); + const description = record.fields.find(item => normalizeLabel(item.label) === 'description' && item.value.trim()); + const references = (record.referenceRecognition?.references ?? []).map(reference => ({ + label: [`${reference.framework === 'mitre-attack' ? 'ATT&CK' : 'D3FEND'} ${reference.id}`, reference.title, + reference.evidence === 'declared' ? 'Declared' : 'Mentioned', REFERENCE_STATUS[reference.status]].filter(Boolean).join(' · '), + text: reference.source.text, + url: reference.url && /^https:\/\/(?:attack\.mitre\.org|d3fend\.mitre\.org)\//.test(reference.url) ? reference.url : undefined + })); + return { status: statusField?.value ? { value: statusField.value, outcome } : undefined, badges, problem, mitre, + description: description ? plainParagraphs(description.value) || undefined : undefined, references }; +} + +function workRecord(dataset: ImportedDataset, sheet: ImportedDataset['sheets'][number], record: FindingRecord): ReportWorkRecord { + const profile = profileForSheet(dataset, sheet); + const toolId = toolIdForRecord(record, sheet.toolId ?? dataset.profileId); + const severityField = record.fields.find(item => /^(severity|criticality|priority)$/i.test(item.label) && item.value); + const sources = record.policySourceEvidence ?? []; + const isPolicy = field(record, 'category') === 'GPOs'; + const state = dashboardResultState(record, profile); + return { + id: record.id, + title: record.title, + toolId, + toolLabel: toolProfileDefinitions.find(item => item.id === toolId)?.name ?? (isPolicy ? 'GPOs' : toolId ?? 'Unknown'), + state, + severity: normalizeSeverity(severityField?.value), + domainId: securityDomainIdForRecord(record, toolId), + area: nativeArea(record), + ruleId: field(record, 'source rule id') || undefined, + countingKey: reportWorkCountingKey(record), + diagnostics: policyDiagnosticBadges(record, toolId), + ...relations(record), + // Only deviations open a card in the report; the cut stays lean for every other state. + card: state === 'deviation' ? findingCard(dataset, sheet, record, toolId) : undefined, + policy: isPolicy ? { + type: field(record, 'policy type') || undefined, + backups: new Set(sources.flatMap(source => source.backupInstanceId ? [source.backupInstanceId] : [])).size, + unlinkedBackups: new Set(sources.flatMap(source => source.backupInstanceId && source.linkStatus === 'not-linked' + ? [source.backupInstanceId] : [])).size + } : undefined + }; +} + +const observed = (snapshot: EnvironmentSnapshot | undefined, property: string): string => { + const value = snapshot?.observations.find(item => item.property === property && item.state === 'observed'); + const raw = value?.normalizedValue ?? value?.originalValue; + return raw === undefined || raw === null || raw === '' ? DASH : typeof raw === 'object' ? JSON.stringify(raw) : String(raw); +}; + +/** Technical environment context from the same normalized observations Client Info reads. */ +function environmentFacts(snapshot?: EnvironmentSnapshot): ReportEnvironmentFacts | undefined { + if (!snapshot || !Array.isArray(snapshot.observations)) return undefined; + const dcOperatingSystems = [...new Set(snapshot.observations.filter(item => item.state === 'observed' + && item.property === 'operating_system.name' && item.subject.type === 'computer-information.domain-controllers') + .map(item => String(item.normalizedValue ?? item.originalValue ?? '')).filter(Boolean))]; + return { + directory: [ + { label: 'Domain', value: observed(snapshot, 'domain.fqdn') }, + { label: 'Forest', value: observed(snapshot, 'forest.name') }, + { label: 'Domain functional level', value: observed(snapshot, 'domain.functional_level') }, + { label: 'Forest functional level', value: observed(snapshot, 'forest.functional_level') }, + { label: 'Domain controllers', value: observed(snapshot, 'domain.controller_count') }, + { label: 'DC OS', value: dcOperatingSystems.length ? dcOperatingSystems.join(', ') : DASH }, + { label: 'Schema version', value: observed(snapshot, 'forest.schema_version') } + ], + resilience: [ + { label: 'AD Recycle Bin', value: observed(snapshot, 'domain.recycle_bin_enabled') }, + { label: 'Last AD backup', value: observed(snapshot, 'backup.last_detected_at') }, + { label: 'krbtgt password', value: observed(snapshot, 'kerberos.krbtgt.password_last_changed') }, + { label: 'Legacy LAPS', value: observed(snapshot, 'laps.legacy.installation_date') }, + { label: 'Windows LAPS', value: observed(snapshot, 'laps.windows.installation_date') } + ], + operatingSystems: operatingSystemInventory(snapshot).map(group => ({ + label: group.label, total: group.total, + entries: group.entries.map(entry => ({ name: entry.name, count: entry.count })) + })), + restricted: (snapshot.sections ?? []).filter(section => ['license-restricted', 'access-denied'].includes(section.state)) + .map(section => section.label) + }; +} + +/** Frozen Work cut for the Technical Report; reads the shared Core projections only. */ +export function buildReportWorkspaceView(dataset: ImportedDataset, snapshot?: EnvironmentSnapshot): ReportWorkspaceView | undefined { + const sheet = dataset.sheets.find(item => item.isProjectSheet); + if (!sheet) return undefined; + const profile = profileForSheet(dataset, sheet); + const totals = dashboardScoreFromStates(sheet.analysis.records.map(record => dashboardResultState(record, profile)), sheet.analysis.records); + return { + version: REPORT_WORKSPACE_VIEW_VERSION, + attackCatalogVersion: defaultMitreGraphData.version, + totals: { passed: totals.passed, deviations: totals.deviations, notConfigured: totals.notConfigured, conflicts: totals.conflicts, + duplicates: totals.duplicates, assessed: totals.assessed, eligible: totals.eligible, unassessed: totals.unassessed, coverage: totals.coverage }, + records: sheet.analysis.records.map(record => workRecord(dataset, sheet, record)), + environment: environmentFacts(snapshot) + }; +} diff --git a/src/reporting/technical-report-model.ts b/src/reporting/technical-report-model.ts new file mode 100644 index 0000000..01b0bd3 --- /dev/null +++ b/src/reporting/technical-report-model.ts @@ -0,0 +1,135 @@ +import type { ReportContextPublication } from './report-context'; +import type { ReportFindingCard, ReportProjection, ReportWorkRecord } from './report-projection'; +import { managementCategoryTexts } from './management-report-texts'; + +/** + * Technical Report model (user decisions 2026-10-07): a report, not a finding list, with ASSESSOR as + * the single source. Scan tools are not shown or compared separately. Every number is aggregated from + * the same Work records, result states and Security Domain classifier as Client Info, so headline, + * domain and problem-field counts reconcile to the Client Info totals. Context and Pathfinder + * relations are read as published; nothing is reclassified, ranked or scored again. + */ +export const TECHNICAL_REPORT_MODEL_VERSION = '2.1.0'; + + +export interface CountedLabel { key: string; label: string; count: number } + +export interface TechnicalDomain { + id: string; + label: string; + passed: number; + deviations: number; + assessed: number; +} + +export interface TechnicalCluster { + id: string; + label: string; + germanName?: string; + /** Sachverhalt of the category from the versioned report texts, else the catalog description. */ + situation: string; + lever?: string; + severity: string; + count: number; + domains: CountedLabel[]; + /** The Work records behind the problem field, reduced to title and record severity (most severe first). */ + findings: TechnicalFinding[]; +} + +export interface TechnicalFinding { title: string; severity: string; ruleId?: string; card?: ReportFindingCard } + +const FINDING_SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info', 'unknown']; + +export interface TechnicalReportModel { + version: string; + client: string; + generatedAt: string; + totals: { + deviations: number; passed: number; assessed: number; records: number; + notConfigured: number; notRun: number; notApplicable: number; errors: number; unknown: number; + overallScore?: number; + }; + domains: TechnicalDomain[]; + context?: { + catalogVersion: string; engineVersion: string; vocabularyVersion: string; severityCatalogVersion: string; revision: number; + clusters: TechnicalCluster[]; + populated: TechnicalCluster[]; + empty: string[]; + /** Exclusive cluster counts; with `Ohne Problemkategorie` they sum to the Client Info deviation total. */ + partitionTotal: number; + domainLinks: Array<{ domainId: string; clusterId: string; count: number }>; + }; + riskRegister?: { recorded: number; current: number }; +} + +function count(items: readonly T[], key: (item: T) => string | undefined, label: (key: string) => string = item => item): CountedLabel[] { + const map = new Map(); + items.forEach(item => { + const value = key(item); + if (value) map.set(value, (map.get(value) ?? 0) + 1); + }); + return [...map].map(([value, amount]) => ({ key: value, label: label(value), count: amount })) + .sort((left, right) => right.count - left.count || left.label.localeCompare(right.label, 'de')); +} + +const rank = (severity: string) => { + const index = FINDING_SEVERITY_ORDER.indexOf(severity); + return index < 0 ? FINDING_SEVERITY_ORDER.length : index; +}; + +function contextModel(context: ReportContextPublication, deviations: ReportWorkRecord[], domainLabel: (id: string) => string) { + const byKey = new Map(deviations.map(record => [record.countingKey, record])); + const clusters: TechnicalCluster[] = context.clusters.map(cluster => { + const records = (cluster.findingKeys ?? []).flatMap(key => byKey.get(key) ? [byKey.get(key)!] : []); + const text = managementCategoryTexts[cluster.id]; + return { + id: cluster.id, label: cluster.label, germanName: text?.name, situation: text?.situation ?? cluster.description, + lever: text?.lever, severity: cluster.severity, count: records.length, + domains: count(records, record => record.domainId, domainLabel), + findings: records.map(record => ({ title: record.title, severity: record.severity, ruleId: record.ruleId, card: record.card })) + .sort((left, right) => rank(left.severity) - rank(right.severity) || left.title.localeCompare(right.title, 'en')) + }; + }); + // ASSESSOR is authoritative: problem fields keep the exact order of the published Context heatmap. + const populated = clusters.filter(cluster => cluster.count > 0); + return { + catalogVersion: context.catalogVersion, engineVersion: context.engineVersion, + vocabularyVersion: context.vocabularyVersion, severityCatalogVersion: context.severityCatalogVersion, + revision: context.repositoryRevision, + /** Every published problem field in heatmap order, including those without deviations. */ + clusters, + populated, + empty: clusters.filter(cluster => cluster.count === 0).map(cluster => cluster.label), + partitionTotal: populated.reduce((sum, cluster) => sum + cluster.count, 0), + domainLinks: populated.flatMap(cluster => cluster.domains.map(domain => ({ domainId: domain.key, clusterId: cluster.id, count: domain.count }))) + }; +} + +export function buildTechnicalReportModel(projection: ReportProjection): TechnicalReportModel { + const view = projection.workspaceView; + const records = view?.records ?? []; + const deviations = records.filter(record => record.state === 'deviation'); + const of = (state: string) => records.filter(record => record.state === state).length; + const domainLabel = (id: string) => projection.workspaceDomains?.find(item => item.domainId === id)?.domainLabel + ?? projection.domains.find(item => item.domainId === id)?.domainLabel ?? id; + const context = projection.context ? contextModel(projection.context, deviations, domainLabel) : undefined; + const domainIds = projection.workspaceDomains?.map(item => item.domainId) ?? [...new Set(records.map(record => record.domainId))]; + return { + version: TECHNICAL_REPORT_MODEL_VERSION, + client: projection.project.name, + generatedAt: projection.generatedAt, + totals: { + deviations: deviations.length, passed: of('passed'), assessed: deviations.length + of('passed'), records: records.length, + notConfigured: of('not-configured'), notRun: of('not-run'), notApplicable: of('not-applicable'), errors: of('error'), unknown: of('unknown'), + overallScore: projection.overallScore + }, + domains: domainIds.map(id => { + const inDomain = records.filter(record => record.domainId === id); + const passed = inDomain.filter(record => record.state === 'passed').length; + const failed = inDomain.filter(record => record.state === 'deviation').length; + return { id, label: domainLabel(id), passed, deviations: failed, assessed: passed + failed }; + }), + context, + riskRegister: projection.riskRegister ? { ...projection.riskRegister } : undefined + }; +} diff --git a/src/reporting/technical-report.ts b/src/reporting/technical-report.ts index 2883123..e0ead87 100644 --- a/src/reporting/technical-report.ts +++ b/src/reporting/technical-report.ts @@ -1,167 +1,462 @@ -import type { JsonValue } from '../domain/canonical-model'; -import type { PolicySourceEvidence, RecordField } from '../domain/finding'; -import type { ReportProjection, ReportReviewInput } from './report-projection'; -import type { UnifiedResultProjection } from '../domain/unified-result'; +/// +import type { ReportProjection } from './report-projection'; import { escapeHtml } from '../shared/html'; -import { EVIDENCE_ROLE_LABELS, STATEMENT_TITLES, statementText } from './report-statement-text'; +import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release'; +import { REPORT_INTER_FONT } from './report-font'; +import { REPORT_ASSESSOR_LOGO } from './report-brand'; +import { reportRichText } from './report-rich-text'; +import { bipartiteDiagram, countDonut, formatCount, formatShare, scoreDonut, scoreTone, stackedBar, type BarSegment } from './report-charts'; +import { + buildTechnicalReportModel, TECHNICAL_REPORT_MODEL_VERSION, type TechnicalCluster, type TechnicalFinding, type TechnicalReportModel +} from './technical-report-model'; +import uiTokens from '../styles/ui-tokens.css?raw'; +import legacyTokens from '../styles/tokens.css?raw'; -export const TECHNICAL_REPORT_RENDERER_VERSION = '1.1.0'; +/** + * Technical Report (Report B), rebuilt on the user decisions of 2026-10-07: a technical report that + * explains the assessment in depth instead of listing every finding, with ASSESSOR as its single + * source (scan tools are neither named nor compared). Problem fields (Context), Pathfinder relations, + * Security Domains, the GPO baseline comparison and coverage, each with graphics in the ASSESSOR + * application language. Static German HTML: no script, no external resource; the tokens are the + * application's own files. + */ +export const TECHNICAL_REPORT_RENDERER_VERSION = '4.2.1'; -const plain = (value: unknown): string => value === undefined || value === null || value === '' - ? '—' : escapeHtml(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)); -const number = (value: number): string => escapeHtml(value.toLocaleString('de-DE')); -const when = (value: string): string => Number.isFinite(Date.parse(value)) - ? escapeHtml(new Date(value).toLocaleString('de-DE')) : plain(value); +const DASH = '—'; +const esc = escapeHtml; +const num = formatCount; +const when = (value?: string): string => value && Number.isFinite(Date.parse(value)) + ? esc(new Date(value).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })) + : DASH; +const plural = (count: number, one: string, many: string) => `${num(count)} ${count === 1 ? one : many}`; -function table(headers: string[], rows: string[][]): string { - return `
${headers.map(label => ``).join('')} - ${rows.map(row => `${row.map(cell => ``).join('')}`).join('')}
${escapeHtml(label)}
${cell}
`; +/** Categorical chart tones of the six Security Domains, in catalog order. */ +const DOMAIN_TONES = ['--viz-2', '--viz-5', '--viz-3', '--viz-4', '--viz-1', '--viz-6']; +const SEVERITY_TONES: Record = { + critical: '--sev-critical', high: '--sev-high', medium: '--sev-medium', low: '--sev-low', info: '--sev-info', unknown: '--sev-unknown', + undetermined: '--sev-unknown' +}; +const SEVERITY_LABELS: Record = { + critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', info: 'Info', unknown: 'Ohne Angabe', undetermined: 'Undetermined' +}; + +const badge = (severity: string) => + `${esc(SEVERITY_LABELS[severity] ?? severity)}`; +const legend = (items: Array<{ tone: string; label: string; value?: number }>) => `
    ${items.map(item => + `
  • ${esc(item.label)}${item.value === undefined ? '' : ` ${num(item.value)}`}
  • `).join('')}
`; +const facts = (items: Array<[string, string]>, single = false) => `
${items.map(([label, value]) => + `
${esc(label)}
${value}
`).join('')}
`; +const section = (id: string, number: number, title: string, lead: string, body: string) => + `
${number}

${esc(title)}

+ ${lead ? `

${lead}

` : ''}${body}
`; + +/** Every check by its Client Info result state, in a fixed order. */ +function resultStates(model: TechnicalReportModel): BarSegment[] { + const t = model.totals; + return [ + { value: t.deviations, tone: '--donut-deviation', label: 'Deviation' }, + { value: t.passed, tone: '--donut-passed', label: 'Passed' }, + { value: t.notConfigured, tone: '--donut-not-run', label: 'Not configured' }, + { value: t.notRun, tone: '--graph-unknown', label: 'Not run' }, + { value: t.unknown, tone: '--graph-category', label: 'Ohne Ergebnis' }, + { value: t.notApplicable, tone: '--donut-not-applicable', label: 'Not applicable' }, + { value: t.errors, tone: '--donut-error', label: 'Error' } + ]; } -function sourceValue(value: JsonValue, markedFields: readonly string[], markedItems: readonly string[]): string { - if (Array.isArray(value)) { - const members = `
    ${value.map(item => `
  • ${sourceValue(item, [], [])}
  • `).join('')}
`; - return markedFields.includes('value') && !markedItems.length ? `
${members}
` : members; +/** + * The six Security Domains as in Client Info: catalog order, each with its deviation count and an amount + * bar in the current Overall Score tone, whose width is the domain's share of all displayed deviations. + */ +function domainList(model: TechnicalReportModel): string { + const total = model.domains.reduce((sum, domain) => sum + domain.deviations, 0); + const tone = scoreTone(model.totals.overallScore); + return `
Security Domains
${model.domains.map(domain => `
+
${esc(domain.label)}
+
${stackedBar([{ value: domain.deviations, tone, label: 'Abweichungen' }], total)}
+
${num(domain.deviations)}
`).join('')}
`; +} + +function overview(model: TechnicalReportModel): string { + const t = model.totals; + const context = model.context; + const critical = context?.populated.filter(cluster => cluster.severity === 'critical') ?? []; + const criticalCount = critical.reduce((sum, cluster) => sum + cluster.count, 0); + const keyFacts: Array<[string, string]> = [ + ...(context ? [ + ['Problemfelder mit Abweichungen', num(context.populated.length)], + ['davon Base Severity Critical', num(critical.length)] + ] as Array<[string, string]> : []), + ...(model.riskRegister ? [['Akzeptierte Risiken (gültig)', num(model.riskRegister.current)]] as Array<[string, string]> : []) + ]; + const lead = t.assessed + ? `Von ${num(t.assessed)} bewerteten Prüfungen weichen ${num(t.deviations)} vom Soll ab (${formatShare(t.deviations, t.assessed)}). + ${critical.length ? `${num(criticalCount)} davon liegen in Problemfeldern mit Base Severity Critical: ${critical.map(cluster => esc(cluster.label)).join(', ')}.` : ''}` + : 'Im gewählten Stand liegen keine bewerteten Prüfungen vor.'; + return section('lagebild', 2, 'Lagebild', lead, `
+ ${scoreDonut('Overall Score', t.overallScore, '')} + ${countDonut('Abweichungen', t.deviations, t.assessed, '--donut-deviation', '')} + ${domainList(model)} +
+ ${keyFacts.length ? `
${keyFacts.map(([label, value]) => `
${esc(label)}
${value}
`).join('')}
` : ''}`); +} + +const hex = (severity: string) => + ``; +const OUTCOME_TONES: Record = { fail: '--outcome-fail', pass: '--outcome-pass' }; + +/** + * A finding line: Work severity hexagon and title. A click opens the head of its Finding Reader card: + * status, badges, Context problem field, Source Rule ID and the first-read Problem rows. + */ +function findingLine(finding: TechnicalFinding, cluster: TechnicalCluster): string { + const card = finding.card; + const status = card?.status; + const badges = card?.badges.map(item => item.severity + ? `${esc(item.text)}` + : `${esc(item.text)}`).join('') ?? ''; + const problem = card?.problem; + const outcome = OUTCOME_TONES[status?.outcome ?? ''] ?? '--border-strong'; + return `
  • ${hex(finding.severity)}${esc(finding.title)} +
    +
    ${hex(finding.severity)}

    ${esc(finding.title)}

    +
    ${status ? `${esc(status.value)}` : ''}${badges}
    + ${card?.mitre.length ? `
    ${card.mitre.map(item => `${esc(item)}`).join('')}
    ` : ''} + Context ${esc(cluster.label)}
    +
    + ${finding.ruleId ? `
    Source Rule ID${esc(finding.ruleId)}
    ` : ''} + ${problem ? `
    ${esc(problem.heading)}
    ${problem.rows.map(row => + `
    ${esc(row.label)}
    ${esc(row.value)}
    `).join('')}
  • ` : ''} + + ${card?.description ? `
    Description
    ${reportRichText(card.description)}
    ` : ''} + ${card?.references.map(reference => `
    ${esc(reference.label)} +

    ${esc(reference.text)}

    ${reference.url ? `

    ${esc(reference.url)}

    ` : ''}
    `).join('') ?? ''} + `; +} + +/** One problem field as an ASSESSOR list row: severity stripe, disclosure, title, count; the details open below it. */ +function clusterRow(cluster: TechnicalCluster, max: number, total: number): string { + const tone = SEVERITY_TONES[cluster.severity] ?? '--sev-unknown'; + const head = `${esc(cluster.label)}${cluster.germanName ? `${esc(cluster.germanName)}` : ''} + ${badge(cluster.severity)} + ${stackedBar([{ value: cluster.count, tone: '--donut-deviation', label: 'Abweichungen' }], max)} + ${num(cluster.count)}${formatShare(cluster.count, total)}`; + if (!cluster.count) return `
    ${head}
    `; + return `
    ${head} +
    +

    ${esc(cluster.situation)}

    + ${cluster.lever ? `
    Technischer Ansatzpunkt

    ${esc(cluster.lever)}

    ` : ''}
    +

    Security Domains: ${cluster.domains.map(domain => `${esc(domain.label)} ${num(domain.count)}`).join(' · ')}

    +
      ${cluster.findings.map(finding => findingLine(finding, cluster)).join('')}
    +
    `; +} + +function clusterList(model: TechnicalReportModel): string { + const context = model.context!; + const total = model.totals.deviations; + const max = Math.max(1, ...context.clusters.map(cluster => cluster.count)); + const remainder = total - context.partitionTotal; + const plain = (label: string, count: number, share: string, extra = '') => `
    + ${label}${num(count)}${share}
    `; + return `
    + + ${context.clusters.map(cluster => clusterRow(cluster, max, total)).join('')} + ${remainder > 0 ? plain('Ohne Problemkategorie', remainder, formatShare(remainder, total)) : ''} + ${plain('Summe', total, '100 %', ' pf--sum')} +
    `; +} + +function problemFields(model: TechnicalReportModel): string { + if (!model.context) { + return section('problemfelder', 4, 'Problemfelder', 'Für diesen Datenstand liegen keine Problemfelder vor.', ''); } - if (value && typeof value === 'object') { - const instruction = `
    ${Object.entries(value).map(([key, item]) => { - const marked = markedFields.includes(key) || (markedFields.includes('value') && key === 'value'); - return `
    ${escapeHtml(key)}
    ${sourceValue(item, [], markedItems)}
    `; - }).join('')}
    `; - return markedFields.includes('value') && !Object.hasOwn(value, 'value') && !markedItems.length - ? `
    ${instruction}
    ` : instruction; + const context = model.context; + const lead = `${num(model.totals.deviations)} Abweichungen in ${num(context.populated.length)} Problemfeldern.`; + return section('problemfelder', 4, 'Problemfelder', lead, clusterList(model)); +} + +function relationsSection(model: TechnicalReportModel): string { + const parts: string[] = []; + const context = model.context; + if (context) { + const left = model.domains.filter(domain => domain.deviations > 0).map(domain => ({ + id: domain.id, label: domain.label, total: domain.deviations, + tone: DOMAIN_TONES[model.domains.indexOf(domain)] ?? '--graph-category' + })); + const right = context.clusters.map(cluster => ({ id: cluster.id, label: cluster.label, total: cluster.count, + tone: SEVERITY_TONES[cluster.severity] ?? '--sev-unknown', + detail: undefined })); + parts.push(`
    ${bipartiteDiagram(left, right, context.domainLinks.map(link => ({ from: link.domainId, to: link.clusterId, value: link.count })))}
    `); } - return markedFields.includes('value') ? `${plain(value)}` : plain(value); + const domains = model.domains.filter(domain => domain.deviations > 0); + const largestDomain = [...domains].sort((left, right) => right.deviations - left.deviations)[0]; + const largestCluster = context ? [...context.populated].sort((left, right) => right.count - left.count)[0] : undefined; + const lead = largestDomain ? `Die ${num(model.totals.deviations)} Abweichungen verteilen sich auf ${num(domains.length)} Security Domains${context ? ` und ${num(context.populated.length)} Problemfelder` : ''}. + Den größten Anteil trägt ${esc(largestDomain.label)} (${num(largestDomain.deviations)}, ${formatShare(largestDomain.deviations, model.totals.deviations)})${largestCluster + ? `; das am stärksten belegte Problemfeld ist ${esc(largestCluster.label)} (${num(largestCluster.count)})` : ''}.` : ''; + return section('beziehungen', 3, 'Beziehungen (Pathfinder)', lead, parts.join('')); } -function sourceSections(sources: PolicySourceEvidence[]): string { - if (!sources.length) return '

    Keine einzelnen GPO-Quellanweisungen in diesem Snapshot verfügbar.

    '; - return sources.map(source => `
    -
    GPO Backup ${plain(source.gpoId)} · ${plain(source.nativeBackupId)}
    -
    Backup-Instanz
    ${plain(source.backupInstanceId)}
    -
    Evidence
    ${plain(source.sourceReference)}
    -
    Quellreihenfolge
    ${plain(source.sequence)}
    - ${source.values.map((value, index) => `
    Anweisung ${number(index + 1)} - ${sourceValue(value, source.deviationFields?.[index] ?? [], source.deviationItems?.[index] ?? [])}
    `).join('')} -
    `).join(''); +function basisSection(model: TechnicalReportModel, projection: ReportProjection): string { + const t = model.totals; + const files = projection.provenance.filter(item => item.kind.startsWith('ImportFile: ')); + const dates = files.map(item => item.sourceDate ?? item.observedAt).filter((value): value is string => Boolean(value && Number.isFinite(Date.parse(value)))).sort(); + const basis: Array<[string, string]> = [ + ['Erhebungszeitraum', dates.length ? (when(dates[0]) === when(dates.at(-1)) ? when(dates[0]) : `${when(dates[0])} – ${when(dates.at(-1))}`) : DASH], + ['Importierte Berichte', num(files.length)], + ['Records gesamt', num(t.records)], + ['Bewertet', num(t.assessed)], + ['Nicht bewertet', num(t.records - t.assessed)] + ]; + const context = model.context; + const versions: Array<[string, string]> = [ + ['ASSESSOR', esc(ASSESSOR_PRODUCT_VERSION)], + ['Erstellt', when(model.generatedAt)], + ...(context ? [['Datenstand', `Revision ${esc(String(context.revision))}`]] as Array<[string, string]> : []), + ['Berichtsmodell / Renderer', `${esc(TECHNICAL_REPORT_MODEL_VERSION)} / ${esc(TECHNICAL_REPORT_RENDERER_VERSION)}`], + ['Report-Projektion / Work-Ausschnitt', `${esc(projection.version)} / ${esc(projection.workspaceView?.version ?? DASH)}`], + ...(context ? [ + ['Context Engine', esc(context.engineVersion)], + ['Context-Katalog / Vokabular / Severity', `${esc(context.catalogVersion)} / ${esc(context.vocabularyVersion)} / ${esc(context.severityCatalogVersion)}`] + ] as Array<[string, string]> : []) + ]; + return section('datenbasis', 5, 'Datenbasis und Datenstand', '', `

    Datenbasis der Erhebung

    ${facts(basis).replace('class="facts"', 'class="facts facts--row"')} +

    Alle Prüfungen nach Ergebnis

    ${stackedBar(resultStates(model), t.records)}${legend(resultStates(model).filter(item => item.value > 0).map(item => ({ tone: item.tone, label: item.label, value: item.value })))}
    +

    Datenstand und Versionen

    ${facts(versions).replace('class="facts"', 'class="facts facts--four"')}
    `); } -function resultDetail(result: UnifiedResultProjection, domainLabel: string, review?: ReportReviewInput, fields?: RecordField[]): string { - const actual = result.policyActualValue; - const expected = result.policyBaselineValue; - const variants = result.policyBaselineVariantAssessment; - return `
    -

    ${plain(result.title)}

    ${plain(result.sourceTool)} · ${plain(result.severityNormalized)} · ${plain(result.outcomeNormalized)}
    -
    Security Domain
    ${escapeHtml(domainLabel)}
    -
    Source Rule ID
    ${plain(result.sourceRuleId)}
    -
    Technische Identität
    ${plain(result.policyIdentityKey ?? result.canonicalDefinitionId)}
    -
    Scope
    ${plain(result.policyScope ?? result.affectedAsset)}
    -
    Result Status
    ${plain(result.policyClassification ?? result.outcomeNormalized)}
    -
    Baseline
    ${plain(result.policyBaselineLabel)}
    - ${result.description ? `

    ${plain(result.description)}

    ` : ''} - ${result.recommendation ? `

    Empfehlung: ${plain(result.recommendation)}

    ` : ''} - ${actual !== undefined || expected !== undefined ? table(['Konfiguriert', 'Erwartet', 'Vergleich'], [[plain(actual), plain(expected), plain(result.policyComparisonRelation)]]) - : '

    Diese Quelle liefert hier keinen strukturierten Ist-/Sollwert. Es wird keiner abgeleitet.

    '} - ${variants ? `
    Baseline-Varianten
    ${plain(variants)}
    ` : ''} - ${result.policyDiagnosticCodes?.length ? `

    Diagnostics: ${result.policyDiagnosticCodes.map(plain).join(', ')}

    ` : ''} - ${result.frameworkReferences.length ? `
    Framework-Referenzen (exakte Quellwerte)
    ${table(['Namespace', 'Referenz'], - result.frameworkReferences.map(reference => [plain(reference.namespace), plain(reference.value)]))}` : ''} - ${fields?.length ? `
    Originale technische Felder
    ${table(['Feld', 'Wert'], fields.map(field => [plain(field.label), plain(field.value)]))}` : ''} - ${review?.nextStep ? `

    Geplanter Remediation-Schritt (unverifizierte Selbstauskunft): ${plain(review.nextStep)}

    ` : ''} -

    Result-ID ${plain(result.resultProjectionId)} · ScanRun ${plain(result.scanRunId)} · ImportArtifact ${plain(result.importArtifactId)} · Source Locator ${plain(result.sourceLocator)}

    -
    `; +const STYLE = ` +*{box-sizing:border-box} +html{background:var(--surface-panel)} +body{margin:0;color:var(--text-primary);background:var(--surface-panel);font-family:var(--font-ui);font-size:var(--type-body-size);line-height:1.55;font-variant-numeric:tabular-nums} +@page{size:A4;margin:12mm} +.report{box-sizing:border-box;width:210mm;max-width:100%;margin:0 auto;background:var(--surface-panel);padding:12mm;display:grid;gap:var(--space-8)} +.report-section{gap:var(--space-3)} +.donut-row{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);gap:var(--segment-gap)} +.lage-row{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:var(--space-5);align-items:start} +/* Ring size ratio Overall Score : Abweichungen = 2.5 : 2 (user decision). */ +.lage-row>.donut-tile:nth-child(1) .ring{width:175px;height:175px} +.lage-row>.donut-tile:nth-child(1) .ring-readout strong{font-size:36px} +.lage-row>.donut-tile:nth-child(2) .ring{width:140px;height:140px} +.lage-row>.donut-tile:nth-child(2) .ring-readout strong{font-size:30px} +.lage-row .donut-tile{flex:none;padding:0} +.domains{margin:0;display:grid;gap:var(--space-2);align-content:start} +.domains figcaption{font-size:var(--donut-title-size);font-weight:var(--fw-semibold)} +.domains dl{display:grid;grid-template-columns:auto minmax(48px,1fr) 32px;column-gap:var(--space-3);margin:0;font-size:var(--type-table-body-size)} +.domain-row{display:contents} +.domain-row>*{display:flex;align-items:center;padding:5px 0;border-bottom:var(--bw-hairline) solid var(--border-subtle)} +.domain-row dd{margin:0} +.domain-row dt{white-space:nowrap} +.domain-bar .bar{height:8px} +.domain-bar{min-width:0} +.domain-num{justify-content:flex-end;font-weight:var(--fw-semibold)} +.donut-legend{display:grid;gap:2px;margin:0;padding:0;list-style:none;color:var(--text-secondary);font-size:var(--type-table-body-size);line-height:1.3} +.donut-legend li{display:flex;align-items:center;gap:var(--space-1);white-space:nowrap} +.donut-legend i{display:inline-block;width:10px;height:10px} +.donut-legend b{color:var(--text-primary);font-weight:var(--fw-semibold)} +.donut-row .donut-tile{flex:none;padding:0} +.facts--row{grid-template-columns:repeat(auto-fit,minmax(0,1fr))} +.facts--row div{border-bottom:0} +.facts--four{grid-template-columns:repeat(4,minmax(0,1fr))} +.report-top{display:flex;align-items:center;gap:var(--space-3)} +.report-top img{width:48px;height:48px} +.brand-copy{display:grid;gap:var(--space-1)} +.wordmark{font-size:var(--type-h4-size);font-weight:var(--fw-bold);letter-spacing:.045em;line-height:1} +.version{color:var(--text-muted);font-size:var(--type-meta-size);letter-spacing:var(--ls-caps);line-height:1} +.title-block h1{margin:0;font-size:var(--type-h2-size);font-weight:var(--fw-semibold);line-height:1.1} +.title-block p{margin:var(--space-2) 0 0;color:var(--text-secondary)} +.toc{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);gap:var(--space-4);margin:0;padding:var(--space-3) 0;list-style:none;border-top:var(--bw-hairline) solid var(--border-subtle);border-bottom:var(--bw-hairline) solid var(--border-subtle)} +.toc a{display:grid;gap:2px;color:var(--text-primary);font-size:var(--type-table-body-size);font-weight:var(--fw-semibold);line-height:1.3;text-decoration:none} +.toc a span{color:var(--marking-color);font-size:var(--type-h4-size)} +.toc a:hover{color:var(--text-link)} +.report-section{display:grid;gap:var(--space-4)} +.section-head{display:flex;align-items:baseline;gap:var(--space-3);padding-bottom:var(--space-2);border-bottom:var(--bw-hairline) solid var(--border-subtle)} +.section-number{color:var(--marking-color);font-size:var(--type-h2-size);font-weight:var(--fw-semibold);line-height:1} +h2{margin:0;font-size:var(--type-h2-size);font-weight:var(--fw-semibold);line-height:1.15} +h3{margin:0 0 var(--space-2);font-size:var(--type-h3-size);font-weight:var(--fw-semibold)} +h4{margin:var(--space-4) 0 var(--space-2);font-size:var(--type-h4-size);font-weight:var(--fw-semibold)} +.lead{margin:0;max-width:110ch} +.note{margin:var(--space-2) 0;color:var(--text-secondary);font-size:var(--type-table-body-size)} +p{margin:0 0 var(--space-3)} +.tile,.block{min-width:0;overflow-x:auto} +.tile-row{display:flex;flex-wrap:wrap;gap:var(--segment-gap);align-items:stretch} +.tile--facts{flex:1 1 var(--tile-content-width)} +.tile--grow{flex:1 1 var(--tile-content-width)} +.card-grid{display:grid;gap:var(--segment-gap)} +.card-grid--three{grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--tile-content-width)),1fr))} +.donut-tile{margin:0;padding:var(--tile-inset);display:grid;justify-items:center;align-content:start;gap:var(--space-2);flex:0 0 var(--tile-compact-width)} +.donut-tile figcaption{font-size:var(--donut-title-size);font-weight:var(--fw-semibold);text-align:center} +.donut-tile--compact{padding:0;border:0;background:transparent;flex:none} +.donut-tile--compact figcaption:empty{display:none} +.ring{position:relative;width:var(--donut-ring-size);height:var(--donut-ring-size)} +.donut-tile--compact .ring{width:72px;height:72px} +.ring svg{width:100%;height:100%;display:block} +.ring-track{fill:none;stroke:var(--donut-track);stroke-width:var(--donut-ring-stroke)} +.ring-value{fill:none;stroke-width:var(--donut-ring-stroke);stroke-linecap:butt} +.ring-mark{stroke-width:2;stroke-linecap:butt} +.ring-readout{position:absolute;inset:0;display:grid;place-content:center;justify-items:center;line-height:1.1;text-align:center} +.ring-readout strong{font-size:var(--donut-value-size);font-weight:var(--fw-bold)} +.ring-readout span{font-size:var(--donut-percent-size);color:var(--text-secondary)} +.donut-tile--compact .ring-readout strong{font-size:var(--type-h4-size)} +.donut-tile--compact .ring-readout span{font-size:var(--type-badge-size)} +.donut-basis{margin:0;color:var(--text-secondary);font-size:var(--type-table-body-size);text-align:center} +.facts{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr));gap:0;margin:0} +.hf-strip{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:2px var(--space-8);margin:0;padding-bottom:var(--space-2);border-bottom:var(--bw-hairline) solid var(--border-subtle)} +.hf-strip div{display:flex;align-items:baseline;gap:var(--space-2);line-height:1.3} +.hf-strip dd{margin:0;font-size:var(--type-h4-size);font-weight:var(--fw-semibold)} +.hf-strip dt{color:var(--text-secondary);font-size:var(--type-table-body-size)} +.hf-groups{columns:2;column-gap:var(--space-8)} +#information{gap:var(--space-3)} +.hf-group{break-inside:avoid;margin-bottom:var(--space-2)} +.hf-group h3{margin:0 0 2px;line-height:1.3;font-size:var(--type-table-body-size);font-weight:var(--fw-semibold)} +.hf-rows{display:grid;margin:0;column-gap:var(--space-8)} +.hf-rows div{display:flex;justify-content:space-between;align-items:baseline;gap:var(--space-3);padding:1px 0;border-bottom:var(--bw-hairline) solid var(--border-subtle);min-width:0;font-size:var(--type-table-body-size);line-height:1.2} +.hf-rows dt{color:var(--text-secondary)} +.hf-rows dd{margin:0;text-align:right;overflow-wrap:anywhere} +.facts--single{grid-template-columns:1fr} +.facts div{padding:var(--space-2) 0;border-bottom:var(--bw-hairline) solid var(--tile-divider);min-width:0} +.facts dt{color:var(--text-secondary);font-size:var(--type-table-body-size)} +.facts dd{margin:0;font-size:var(--type-body-size);overflow-wrap:anywhere} +table.data{width:100%;border-collapse:collapse;font-size:var(--type-table-body-size)} +table.data caption{text-align:left;font-weight:var(--fw-semibold);font-size:var(--type-h4-size);padding:var(--space-3) 0 var(--space-2)} +table.data th,table.data td{padding:var(--space-2) var(--space-3) var(--space-2) 0;border-bottom:var(--bw-hairline) solid var(--tile-divider);text-align:left;vertical-align:top} +table.data thead th{color:var(--text-secondary);font-size:var(--type-table-head-size);font-weight:var(--fw-semibold);text-transform:uppercase;letter-spacing:var(--ls-caps);white-space:nowrap} +table.data tbody th{font-weight:var(--fw-semibold)} +table.data tfoot th,table.data tfoot td{border-bottom:0} +table.data .num{text-align:right;white-space:nowrap} +table.compact th,table.compact td{padding-top:var(--space-1);padding-bottom:var(--space-1)} +.bar-cell{width:34%;min-width:160px;vertical-align:middle} +.bar{display:flex;width:100%;height:12px;background:var(--donut-track)} +.bar-seg{display:block;height:100%} +.legend{display:flex;flex-wrap:wrap;gap:var(--space-2) var(--space-4);margin:var(--space-2) 0 0;padding:0;list-style:none;font-size:var(--type-table-body-size);color:var(--text-secondary)} +.legend li{display:inline-flex;align-items:center;gap:var(--space-1)} +.legend i{display:inline-block;width:10px;height:10px} +.sev-badge{display:inline-block;padding:1px 6px;color:var(--sev-on);font-size:var(--type-badge-size);font-weight:var(--fw-semibold);text-transform:uppercase;letter-spacing:var(--ls-caps);white-space:nowrap} +.sub{display:block;color:var(--text-secondary);font-size:var(--type-meta-size);font-weight:var(--fw-regular)} +.mono{font-family:var(--font-path);font-size:var(--type-table-body-size);overflow-wrap:anywhere} +.muted{color:var(--text-muted)} +.nowrap{white-space:nowrap;overflow-wrap:normal} +a{color:var(--text-link);text-decoration:none} +.pf-list{display:grid;border-top:var(--bw-hairline) solid var(--border-subtle)} +.pf{border-bottom:var(--bw-hairline) solid var(--border-subtle);border-left:6px solid var(--pf-tone,transparent)} +/* An open problem field carries its severity stripe a short way into its top and bottom separators, framing the expanded field. */ +details.pf[open]{position:relative} +details.pf[open]::before,details.pf[open]::after{content:'';position:absolute;left:-6px;width:40px;height:6px;background:var(--pf-tone)} +details.pf[open]::before{top:-1px} +details.pf[open]::after{bottom:-1px} +.pf-row{display:grid;grid-template-columns:22px minmax(0,1fr) 108px 20% 56px 52px;gap:var(--space-3);align-items:center;padding:var(--space-2) var(--space-2) var(--space-2) 0;list-style:none;font-size:var(--type-table-body-size)} +summary.pf-row{cursor:pointer} +summary.pf-row::-webkit-details-marker{display:none} +.pf-header{white-space:nowrap;color:var(--text-secondary);font-size:var(--type-table-head-size);font-weight:var(--fw-semibold);text-transform:uppercase;letter-spacing:var(--ls-caps);border-left:6px solid transparent} +.pf-toggle{justify-self:center;width:0;height:0} +details.pf .pf-toggle{border-style:solid;border-width:5px 0 5px 8px;border-color:transparent transparent transparent var(--pf-tone)} +details.pf[open] .pf-toggle{transform:rotate(90deg)} +.pf-title b{font-size:var(--type-h4-size);font-weight:var(--fw-semibold)} +.pf-title .sub{font-size:var(--type-table-body-size)} +.pf-num{text-align:right;white-space:nowrap} +.pf--plain .pf-title b{font-size:var(--type-table-body-size)} +.pf--sum{border-bottom:0} +.pf--empty .pf-title b{font-weight:var(--fw-regular)} +.pf-body{padding:0 var(--space-2) var(--space-3) calc(22px + var(--space-3))} +.pf-findings{margin:var(--space-3) 0 0;padding:var(--space-2) 0 0;list-style:none;border-top:var(--bw-hairline) solid var(--border-subtle);display:grid;gap:1px;font-size:var(--type-table-body-size);line-height:1.3} +.finding>summary{display:grid;grid-template-columns:14px minmax(0,1fr);gap:var(--space-2);align-items:start;cursor:pointer;list-style:none} +.finding>summary::-webkit-details-marker{display:none} +.finding>summary:hover span,.finding[open]>summary span{color:var(--text-link)} +.finding-card{margin:var(--space-2) 0 var(--space-3) calc(14px + var(--space-2));padding:var(--space-3) 0;border-top:var(--bw-hairline) solid var(--border-subtle);border-bottom:var(--bw-hairline) solid var(--border-subtle);display:grid;gap:var(--space-3)} +.fc-head{display:grid;grid-template-columns:26px minmax(0,1fr);gap:var(--space-3);align-items:start} +.fc-head .hex{width:26px;height:30px;margin:0} +.fc-head h4{margin:0 0 var(--space-2);font-size:var(--type-h4-size);font-weight:var(--fw-semibold);line-height:1.35} +.fc-badges{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2)} +.fc-status{display:inline-flex;align-items:center;gap:4px;color:var(--fc-tone);font-size:var(--type-meta-size)} +.fc-status::before{content:'';width:5px;height:5px;border-radius:50%;background:var(--fc-tone)} +.meta-badge,.context-badge{display:inline-flex;align-items:center;gap:var(--space-1);padding:2px 6px;border:var(--bw-hairline) solid var(--border-strong);font-size:var(--type-badge-size);font-weight:var(--fw-semibold);text-transform:uppercase;letter-spacing:var(--ls-caps);white-space:nowrap} +.context-badge{margin-top:var(--space-2);color:var(--text-secondary)} +.context-badge i{width:7px;height:7px;border-radius:50%;background:var(--graph-category)} +.context-badge b{color:var(--text-primary);font-size:var(--type-meta-size)} +.fc-body{display:grid;grid-template-columns:minmax(120px,max-content) minmax(0,1fr);gap:var(--space-4);align-items:start} +.fc-rule span{display:block;font-size:var(--type-table-body-size);font-weight:var(--fw-semibold);text-transform:uppercase;letter-spacing:var(--ls-caps)} +.fc-rule code{font-family:var(--font-path);font-size:var(--type-table-body-size)} +.fc-problem{grid-column:2;padding:var(--space-2) var(--space-3);border-left:var(--marker-width) solid var(--fc-tone)} +.fc-rule+.fc-problem{grid-column:auto} +.fc-problem>span{display:block;margin-bottom:var(--space-1);font-size:var(--type-table-body-size);font-weight:var(--fw-semibold);text-transform:uppercase;letter-spacing:var(--ls-caps)} +.fc-problem dl{display:grid;gap:2px;margin:0} +.fc-problem div{display:grid;grid-template-columns:90px minmax(0,1fr);gap:var(--space-3)} +.fc-problem dt{color:var(--text-secondary)} +.fc-problem dd{margin:0;white-space:pre-line;overflow-wrap:anywhere} +.fc-problem div[data-state="deviating"] dd{color:var(--outcome-fail)} +.fc-problem div[data-state="aligned"] dd{color:var(--outcome-pass)} +.fc-head .fc-badges+.fc-badges{margin-top:var(--space-2)} +.fc-more{border:var(--bw-hairline) solid var(--border-subtle)} +.fc-more>summary{display:flex;align-items:center;gap:var(--space-2);padding:var(--space-2) var(--space-3);cursor:pointer;list-style:none;font-size:var(--type-table-body-size);font-weight:var(--fw-semibold)} +.fc-more>summary::-webkit-details-marker{display:none} +.fc-more>summary::before{content:'';border-style:solid;border-width:4px 0 4px 7px;border-color:transparent transparent transparent currentColor} +.fc-more[open]>summary::before{transform:rotate(90deg)} +.fc-more:not(.fc-ref)>summary{text-transform:uppercase;letter-spacing:var(--ls-caps)} +.fc-more p{margin:0;padding:0 var(--space-3) var(--space-3);font-size:var(--type-table-body-size);white-space:pre-line;overflow-wrap:anywhere} +.rich-text{display:grid;gap:var(--space-2);padding:0 var(--space-3) var(--space-3);font-size:var(--type-table-body-size);line-height:1.5;overflow-wrap:anywhere} +.rich-text p{margin:0;padding:0;white-space:normal} +.rich-text h5{margin:var(--space-2) 0 0;font-size:var(--type-table-body-size);font-weight:var(--fw-semibold)} +.rich-text ol,.rich-text ul{margin:0;padding-left:var(--space-5);display:grid;gap:2px} +.rich-text b{font-weight:var(--fw-semibold)} +.fc-more+.fc-more{margin-top:calc(-1 * var(--space-2))} +.hex{display:block;width:14px;height:16px;margin-top:1px;clip-path:polygon(50% 0,94% 24%,94% 76%,50% 100%,6% 76%,6% 24%)} +.cluster-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:var(--space-1) var(--space-2);margin-bottom:var(--space-2)} +.cluster-head h3{margin:0;font-size:var(--type-h4-size)} +.cluster-head .sub{display:inline;font-size:var(--type-table-body-size)} +.cluster-count{margin-left:auto;color:var(--text-secondary);font-size:var(--type-table-body-size);white-space:nowrap} +.cluster-count strong{color:var(--text-primary);font-size:var(--type-h4-size)} +.cluster-body{display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4)} +.cluster-body{font-size:var(--type-table-body-size);line-height:1.5} +.cluster-body p{margin:0} +.cluster-domains{margin:var(--space-2) 0 0;color:var(--text-secondary);font-size:var(--type-table-body-size)} +.next-step{margin:0;padding:0 0 0 var(--space-3);border-left:var(--marker-width) solid var(--next-step-accent)} +.next-step span{display:block;font-size:var(--type-table-body-size);font-weight:var(--fw-semibold);color:var(--text-secondary)} +.next-step p{margin:0} +.split{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,460px),1fr));gap:var(--space-6)} +.scroll{overflow-x:auto} +.flow{display:block;width:100%;height:auto} +.flow-link{fill:none;opacity:.55} +.flow-label{fill:var(--text-primary);font-size:13px;font-family:var(--font-ui)} +.flow-count{fill:var(--text-secondary);font-weight:var(--fw-semibold)} +.flow-detail{fill:var(--text-muted)} +ul.plain{margin:0;padding-left:var(--space-5)} +ul.plain li{margin-bottom:var(--space-2)} +.report-foot{color:var(--text-muted);font-size:var(--type-meta-size);border-top:var(--bw-hairline) solid var(--border-subtle);padding-top:var(--space-3)} +@media (max-width:640px){.report{padding:var(--space-4)}.cluster-body{grid-template-columns:1fr}.donut-row{grid-auto-flow:row;grid-template-columns:repeat(2,minmax(0,1fr))}.lage-row{grid-template-columns:repeat(2,minmax(0,1fr))}.domains{grid-column:1/-1}.hf-groups{columns:1}.hf-strip{grid-template-columns:repeat(3,minmax(0,1fr))}.pf-row{grid-template-columns:22px minmax(0,1fr) 80px 44px 44px}.pf-bar{display:none}} +@media print{.report{width:auto;margin:0;padding:0;zoom:1}.toc{display:none}.donut-tile,.pf-row,.cluster-body,.pf-findings li,.hf-group,tr{break-inside:avoid}details.pf::details-content{content-visibility:visible;display:block}details.pf .pf-toggle{transform:rotate(90deg)}.report-section>.section-head{break-after:avoid}} +`; + +/** Client Info overview facts as the first section: Data facts, Domain Information and the environment tiles. */ +function informationSection(projection: ReportProjection): string { + const header = projection.headerFacts; + if (!header) return ''; + const row = (item: { label: string; value: string }) => `
    ${esc(item.label)}
    ${esc(item.value)}
    `; + return section('information', 1, 'Information', '', `
    ${header.data.map(item => + `
    ${esc(item.value)}
    ${esc(item.label)}
    `).join('')}
    +

    Domain Information

    ${header.domainInformation.map(row).join('')}
    ${header.groups.map(group => `

    ${esc(group.title)}

    ${group.facts.map(row).join('')}
    `).join('')}
    `); } +/** The complete standalone Technical Report. Rendering depends on the frozen projection only. */ export function buildTechnicalReportHtml(projection: ReportProjection): string { - const results = projection.technicalResults ?? []; - const labels = new Map(projection.domains.map(domain => [domain.domainId, domain.domainLabel])); - const domainOf = (result: UnifiedResultProjection) => projection.technicalResultDomains?.[result.resultProjectionId] ?? 'unclassified'; - const deviations = results.filter(result => result.isDeviation); - const severityOrder = ['critical', 'high', 'medium', 'low', 'info', 'unknown']; - const grouped = [...new Set(deviations.map(domainOf))].map(domainId => ({ - domainId, label: labels.get(domainId) ?? domainId, - items: deviations.filter(result => domainOf(result) === domainId) - .sort((a, b) => severityOrder.indexOf(a.severityNormalized) - severityOrder.indexOf(b.severityNormalized) - || a.title.localeCompare(b.title)) - })); - const policySources = new Map((projection.technicalPolicySources ?? []).map(item => [item.settingId, item.sources])); - const reviews = new Map((projection.technicalReviews ?? []).map(item => [item.resultProjectionId, item])); - const fields = new Map((projection.technicalFindingFields ?? []).map(item => [item.resultProjectionId, item.fields])); - const neutral = new Set(['Not configured', 'No baseline rule', 'Not comparable']); - const policyRows = projection.policyEntries.map(item => [ - plain(item.title), plain(item.scope), plain(item.identity), - `${plain(item.verdict)}${neutral.has(item.verdict) ? ' · score-neutral' : ''}`, - item.conflict ? 'Ja' : 'Nein', item.duplicate ? 'Ja' : 'Nein' - ]); - const provenanceRows = projection.provenance.map(item => [plain(item.kind), plain(item.id), plain(item.version), plain(item.observedAt)]); - const diagnosticRows = projection.diagnostics.map(item => [plain(item.code), plain(item.reason), plain(item.origin), number(item.count)]); - // Evidence chain of the Management Report's key statements: every number traced to its result IDs. - const statementEvidence = (projection.statements ?? []).map(statement => { - const { text, note } = statementText(statement); - return `

    ${escapeHtml(STATEMENT_TITLES[statement.id])}

    ${escapeHtml(text)}

    ${note ? `

    ${escapeHtml(note)}

    ` : ''} -${table(['Zählgruppe', 'Security Domain', 'Anzahl', 'Result-IDs'], statement.evidence.map(item => [ - plain(EVIDENCE_ROLE_LABELS[item.role]), plain(item.domainId ? labels.get(item.domainId) ?? item.domainId : 'Alle'), - number(item.ids.length), item.ids.length ? plain(item.ids.join(', ')) : '—' -]))}
    `; - }).join(''); + const model = buildTechnicalReportModel(projection); + const sections = [ + informationSection(projection), overview(model), relationsSection(model), problemFields(model), basisSection(model, projection) + ].filter(Boolean); + const toc = [...sections.join('').matchAll(/
    (\d+)<\/span>

    ([^<]+)<\/h2>/g)] + .map(match => `
  • ${match[2]}${match[3]}
  • `).join(''); return ` - -ASSESSOR · Technical Report · ${escapeHtml(projection.project.name)} + +Technical Report · ${esc(model.client)} -

    ASSESSOR · Technical Report

    ${escapeHtml(projection.project.name)}

    -

    Assessment ${plain(projection.assessmentId)} · Stand ${when(projection.generatedAt)}

    -

    Frozen Snapshot des gewählten Timeslots. GPO-Aussagen beruhen auf Backupvergleichen; der effektive Endpunktzustand ist nicht verifiziert.

    -

    Scope und Provenienz

    -${provenanceRows.length ? table(['Art', 'ID', 'Version', 'Beobachtet'], provenanceRows) : '

    Keine Provenienz übermittelt.

    '}
    -

    Result-Inventar

    ${number(projection.coverage.total)} Ergebnisse · ${number(projection.coverage.deviations)} Deviations · ${number(projection.coverage.passed)} Passed · ${number(projection.coverage.notRun)} Not run

    -${table(['Severity', 'Anzahl'], Object.entries(projection.inventory.bySeverity).map(([label, count]) => [plain(label), number(count)]))} -${table(['Outcome', 'Anzahl'], Object.entries(projection.inventory.byOutcome).map(([label, count]) => [plain(label), number(count)]))} -${table(['Security Domain', 'Anzahl'], Object.entries(projection.inventory.byDomain).map(([id, count]) => [plain(labels.get(id) ?? id), number(count)]))} -${table(['Source Tool', 'Anzahl'], Object.entries(projection.inventory.bySource).map(([tool, count]) => [plain(tool), number(count)]))}
    -

    Belege der Kernaussagen

    Jede Kernaussage des Management Reports mit den Result-IDs hinter ihren Zahlen. Die IDs stehen im Anhang im vollständigen Result-Index.

    -${statementEvidence || '

    Keine Kernaussagen in diesem Snapshot.

    '}
    -${projection.context ? `

    Context-Belege des Management Reports

    Katalog ${escapeHtml(projection.context.catalogVersion)} · Kategorie-Grundschwere ${escapeHtml(projection.context.severityCatalogVersion)}. Mehrfachzuordnungen werden nicht addiert; erkannte und geschätzte Zuordnungen bleiben abgeleiteter Kontext.

    ${table(['Problemkategorie', 'Base Severity', 'Scanner-Findings', 'GPO-Abweichungen', 'Katalogisiert / erkannt / geschätzt', 'Result-IDs'], projection.context.clusters.map(item => [plain(item.label), plain(item.severity), number(item.scanner), number(item.gpo), plain(`${item.confidence.catalogued} / ${item.confidence.recognized} / ${item.confidence.estimated}`), plain(item.resultIds.join(', '))]))}
    ` : ''} -

    Findings im Detail

    ${grouped.length ? grouped.map(group => `

    ${escapeHtml(group.label)}

    ${group.items.map(item => resultDetail(item, group.label, reviews.get(item.resultProjectionId), fields.get(item.resultProjectionId))).join('')}
    `).join('') : '

    Keine Deviations in diesem Snapshot.

    '}
    -

    GPO Policy Register

    Conflicts und Duplicates sind separate Diagnostics, keine Ersatz-Verdicts. Not configured, No baseline rule und Not comparable sind score-neutral.

    -${projection.gpo ? table(['Verdict / Diagnostic', 'Anzahl'], [ - ['Deviation', projection.gpo.counts.Fail], ['Passed', projection.gpo.counts.Pass], - ['Not configured · score-neutral', projection.gpo.counts['Not configured']], - ['No baseline rule · score-neutral', projection.gpo.counts['No baseline rule']], - ['Not comparable · score-neutral', projection.gpo.counts['Not comparable']], - ['Review required · unklar', projection.gpo.counts['Review required']], - ['Conflicts', projection.gpo.conflicts], ['Duplicates', projection.gpo.duplicates] -].map(([label, count]) => [plain(label), number(Number(count))])) : ''} -${policyRows.length ? table(['Setting', 'Scope', 'Technische Identität', 'Verdict', 'Conflicts', 'Duplicates'], policyRows) : '

    Keine gerollten GPO Policy Settings im gewählten Assessment vorhanden.

    '} -${projection.policyEntries.map(item => `

    ${plain(item.title)}

    ${sourceSections(policySources.get(item.id) ?? [])}
    `).join('')}
    -

    Konvergenz-Nachweis

    -${table(['Security Domain', 'Quellen mit Deviations', 'Quellen', 'Evidence'], projection.convergence.map(item => [ - plain(labels.get(item.domainId) ?? item.domainId), number(item.sourceCount), - plain(item.sources.filter(source => source.deviations > 0).map(source => source.toolId).join(', ')), - item.evidence === 'shared-framework-reference' ? 'Gemeinsame exakte Framework-Referenz' : 'Nur Domain-Nähe; Validierung erforderlich' -]))} -${projection.bundles.length ? table(['Framework-Referenz', 'Source Tools', 'Result-IDs'], projection.bundles.map(item => [ - plain(item.key), plain(item.toolIds.join(', ')), plain(item.members.join(', ')) -])) : ''}
    -

    Scope-Grenzen

    -

    GPO-Vergleiche gelten nur für das importierte Backup-Set. Ohne RSoP bzw. Group Policy Results wird kein wirksamer Endpoint-Zustand behauptet.

    -

    not_run ist kein Pass. Nicht ausgeführte Checks und unbekannte Werte werden weder als bestanden noch als Deviation gezählt.

    -

    Diagnostics

    ${diagnosticRows.length ? table(['Code', 'Resolver-Grund', 'Herkunft', 'Anzahl'], diagnosticRows) : '

    Keine aggregierten Diagnostics übermittelt.

    '}
    -

    Anhang: vollständiger Result-Index

    -${table(['Result-ID', 'Source Tool', 'Source Rule ID', 'Titel', 'Outcome', 'Severity', 'Security Domain'], results.map(item => [plain(item.resultProjectionId), plain(item.sourceTool), plain(item.sourceRuleId), plain(item.title), plain(item.outcomeNormalized), plain(item.severityNormalized), plain(labels.get(domainOf(item)) ?? domainOf(item))]))}
    -`; +@font-face{font-family:"Inter Variable";src:url('${REPORT_INTER_FONT}') format('woff2');font-weight:100 900;font-style:normal;font-display:swap} +${uiTokens} +${legacyTokens} +${STYLE} +
    +
    ASSESSOR Logo
    ASSESSOR${esc(ASSESSOR_PRODUCT_VERSION)}
    +

    Technical Report

    ${esc(model.client)} · Stand ${when(model.generatedAt)} · ${num(model.totals.assessed)} bewertete Prüfungen

    + +${sections.join('\n')} +
    ASSESSOR ${esc(ASSESSOR_PRODUCT_VERSION)} · Vertraulich
    +
    `; } diff --git a/src/styles/base.css b/src/styles/base.css index b018b07..44f5cad 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -29,14 +29,14 @@ button, .btn, .icon-btn, .tab-btn, .bookmark-btn, .results-btn { line-height: 1.15; } .select, .input { - min-height: var(--control-height); border: var(--border-hair) solid var(--line); border-radius: 0; + min-height: var(--control-height); border: var(--border-hair) solid var(--line); border-radius: var(--radius-sm); color: var(--text); background-color: var(--panel-raised); font-size: var(--type-button-size); font-weight: var(--fw-regular); } /* Verbindliche Buttonform: eine Hoehe, ein Radius, ein waagerechtes Polster. Senkrecht traegt der Knopf kein eigenes Polster - die Hoehe kommt aus --btn-h, damit ueberall gleich wenig Luft ueber und unter der Schrift steht. */ .btn { display: inline-flex; align-items: center; justify-content: center; min-height: var(--btn-h); padding: 0 var(--btn-pad-x); } -.select, .input { padding: var(--legacy-space-1) var(--legacy-space-3); } +.select, .input { padding: var(--legacy-space-1) var(--legacy-space-3); border-radius: var(--radius-sm); } .icon-btn, .bookmark-btn, .results-btn { display: inline-grid; place-items: center; min-width: var(--btn-h); min-height: var(--btn-h); padding: 0 6px; } .btn:hover, .icon-btn:hover, .tab-btn:hover, .bookmark-btn:hover, .results-btn:hover { border-color: var(--button-border); background: var(--button-bg-hover); color: var(--button-text); } .select:hover, .input:hover { border-color: var(--line-strong); background-color: var(--panel-hover); } diff --git a/src/styles/client-security.css b/src/styles/client-security.css new file mode 100644 index 0000000..a1ea7ca --- /dev/null +++ b/src/styles/client-security.css @@ -0,0 +1,73 @@ +.client-security-panel { --domain-tone: var(--graph-category); --flow-width: 1.5; min-width: 0; } +.client-security-header { display: flex; align-items: baseline; gap: var(--space-3); margin-bottom: var(--space-3); } +.client-security-header h2 { margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); } +.client-security-header > span { color: var(--text-secondary); font-size: var(--type-table-body-size); } +.client-security-map { margin-bottom: var(--space-4); border-bottom: var(--border-hair) solid var(--tile-border); } +.client-security-map > summary { display: flex; align-items: center; gap: var(--space-2); min-height: var(--control-h); list-style: none; cursor: pointer; font-size: var(--type-table-body-size); } +.client-security-map > summary::-webkit-details-marker { display: none; } +.client-security-map[open] > summary .triangle-icon { transform: rotate(-90deg); } +.client-security-map-scroll { overflow-x: auto; } +.client-security-map-scroll > svg { display: block; width: 1040px; height: auto; max-width: 100%; min-width: 900px; margin-block: var(--space-3); overflow: visible; } +.client-security-map text { fill: var(--text-primary); font-family: inherit; font-size: var(--type-table-body-size); } +.client-security-map tspan { fill: var(--text-secondary); } +.client-security-flow { fill: none; stroke: var(--domain-tone); stroke-width: var(--flow-width); opacity: .5; } +.client-security-domain-marker { fill: var(--domain-tone); } +.client-security-table { border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); } +.client-security-columns, +.client-security-group-toggle { display: grid; grid-template-columns: var(--space-4) minmax(280px, 1fr) 130px minmax(80px, 160px) 70px 70px; align-items: center; gap: var(--space-3); } +.client-security-columns { padding: var(--space-3) var(--tile-inset); color: var(--text-secondary); font-size: var(--type-label-size); } +.client-security-columns > :nth-last-child(-n + 2) { text-align: right; } +.client-security-group + .client-security-group { border-top: var(--border-hair) solid var(--border-subtle); } +.client-security-group-toggle { width: 100%; min-height: var(--control-h); height: auto; padding: var(--space-3) var(--tile-inset); border: 0; border-radius: 0; color: var(--text-primary); background: transparent; text-align: left; font-size: var(--type-table-body-size); font-weight: var(--fw-regular); cursor: pointer; } +.client-security-group-toggle:hover, +.client-security-finding-toggle:hover { background: var(--surface-hover); } +.client-security-group-toggle > strong { text-align: right; font-size: inherit; font-weight: var(--fw-semibold); } +.client-security-group-toggle > span:last-child { text-align: right; } +.client-security-category { display: flex; align-items: center; gap: var(--space-3); min-width: 0; font-weight: var(--fw-semibold); overflow-wrap: anywhere; } +.client-security-category small { font-size: inherit; font-weight: var(--fw-regular); color: var(--text-secondary); } +.client-security-marker { --security-tone: var(--text-secondary); display: inline-block; width: var(--space-2); height: var(--space-4); flex: 0 0 var(--space-2); background: var(--security-tone); fill: var(--security-tone); } +.client-security-marker[data-severity="critical"], .client-security-severity[data-severity="critical"] { --security-tone: var(--sev-critical); } +.client-security-marker[data-severity="high"], .client-security-severity[data-severity="high"] { --security-tone: var(--sev-high); } +.client-security-marker[data-severity="medium"], .client-security-severity[data-severity="medium"] { --security-tone: var(--sev-medium); } +.client-security-marker[data-severity="low"], .client-security-severity[data-severity="low"] { --security-tone: var(--sev-low); } +.client-security-marker[data-severity="info"], .client-security-severity[data-severity="info"] { --security-tone: var(--sev-info); } +.client-security-severity { justify-self: start; padding: var(--space-1) var(--space-2); color: var(--sev-ink); background: var(--security-tone, var(--surface-panel)); font-size: var(--type-label-size); } +.client-security-severity[data-severity="unknown"], .client-security-severity[data-severity="undetermined"] { color: var(--text-secondary); } +.client-security-share-bar { display: block; height: var(--space-3); background: var(--donut-track); } +.client-security-share-bar > i { display: block; width: var(--share); height: 100%; background: var(--donut-deviation); } +.client-security-group-content { padding: 0 var(--tile-inset) var(--space-3) calc(var(--tile-inset) + var(--space-4) + var(--space-3)); } +.client-security-group-content[hidden] { display: none; } +.client-security-group-content > p { margin: var(--space-2) 0; } +.client-security-category-context, +.client-security-finding-detail { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-5); } +.client-security-category-context h3, +.client-security-finding-detail h4 { margin: var(--space-2) 0; font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); } +.client-security-category-context p { margin: 0; font-size: var(--type-body-size); line-height: var(--lh-prose); } +.client-security-domain-counts { color: var(--text-secondary); font-size: var(--type-table-body-size); } +.client-security-group-content > ul { padding: 0; margin: var(--space-3) 0 0; list-style: none; } +.client-security-finding { border-top: var(--border-hair) solid var(--border-subtle); } +.client-security-finding-toggle { display: flex; align-items: center; gap: var(--space-2); width: 100%; min-height: var(--control-h); height: auto; padding: var(--space-2); border: 0; border-radius: 0; color: var(--text-primary); background: transparent; text-align: left; font-size: var(--type-table-body-size); font-weight: var(--fw-regular); cursor: pointer; } +.client-security-finding-toggle > strong { min-width: 0; overflow-wrap: anywhere; font-size: inherit; font-weight: var(--fw-bold); } +.client-security-finding-detail { padding: var(--space-3) var(--space-5); font-size: var(--type-body-size); } +.client-security-finding-detail > div { min-width: 0; } +.client-security-source { margin-bottom: var(--space-3); } +.client-security-source > span { display: block; margin-bottom: var(--space-1); color: var(--text-secondary); font-size: var(--type-table-body-size); } +.client-security-source pre { max-width: 100%; white-space: pre-wrap; overflow-wrap: anywhere; } +.client-security-source > details > summary { cursor: pointer; } +.client-security-group-toggle:focus-visible, +.client-security-finding-toggle:focus-visible, +.client-security-map > summary:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(-1 * var(--bw-emphasis)); } +@container (max-width: 1000px) { + .client-security-columns, + .client-security-group-toggle { grid-template-columns: var(--space-4) minmax(0, 1fr) 110px 65px 55px; gap: var(--space-2); } + .client-security-columns > :nth-child(4), + .client-security-group-toggle > :nth-child(4) { display: none; } +} +@container (max-width: 760px) { + .client-security-category-context, + .client-security-finding-detail { grid-template-columns: 1fr; } + .client-security-columns, + .client-security-group-toggle { grid-template-columns: var(--space-4) minmax(0, 1fr) 90px 50px; } + .client-security-columns > :last-child, + .client-security-group-toggle > :last-child { display: none; } +} diff --git a/src/styles/components.css b/src/styles/components.css index 4f5b44f..235bed0 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -6,6 +6,40 @@ mark, .search-highlight { font-weight: 600; } +/* Exact same-change groups use the shared Results identity palette, never status colors. */ +.related-finding-ring { position: absolute; top: var(--space-2); right: var(--space-2); display: inline-grid; place-items: center; width: var(--btn-h); height: var(--btn-h); padding: 0; border: 0; background: transparent; cursor: pointer; flex: 0 0 auto; } +.related-finding-ring.is-inline { position: relative; top: auto; right: auto; } +.related-finding-ring > span { display: block; width: var(--related-finding-ring-size); height: var(--related-finding-ring-size); border: var(--bw-emphasis) solid var(--related-color); border-radius: var(--radius-pill); background: transparent; } +.related-finding-ring[aria-pressed="true"] > span { background: var(--related-color); } +.related-finding-ring:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--space-1); } +.record-card-with-group { position: relative; isolation: isolate; } +.record-card-with-group > .related-finding-ring { z-index: 2; } +.record-card-with-group .record-card { width: 100%; } +.record-card-with-group:has(.related-finding-ring) .record-title { padding-right: var(--btn-h); } +.record-card-with-group.is-group-marked, .data-tile[data-group-marked="true"], tr[data-group-marked="true"] { outline: var(--border-hair) solid var(--marking-color); outline-offset: calc(-1 * var(--border-hair)); } +.related-findings-focus { display: flex; flex: 0 0 auto; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--panel-inset); color: var(--text-primary); background: var(--surface-app); border-bottom: var(--border-hair) solid var(--line); } +.related-findings-focus .btn { margin-left: auto; } +.reader-heading { position: relative; } +.reader-heading > .related-finding-ring { position: absolute; top: 0; right: 0; } +.reader-heading:has(> .related-finding-ring) { padding-right: calc(var(--btn-h) + var(--space-2)); } +.table-scroll td:last-child:has(.related-finding-ring) { position: relative; padding-right: calc(var(--btn-h) + var(--space-2)); } +.table-scroll td:last-child > .related-finding-ring { position: absolute; top: 0; right: 0; } +.related-raw-record { position: relative; } +.related-raw-record:has(.related-finding-ring) { padding-right: var(--btn-h); } +.obsidian-graph-inspector-title { position: relative; } +.obsidian-graph-inspector-title > .related-finding-ring { position: absolute; top: 0; right: 0; } +.obsidian-graph-inspector-title:has(> .related-finding-ring) { padding-right: var(--btn-h); } +.graph-related-ring { overflow: visible; } +.results-window-related-slot { display: grid; place-items: center; flex: 0 0 var(--btn-h); width: var(--btn-h); } +.raw-related-findings { display: grid; gap: var(--space-2); padding: var(--panel-inset); border-bottom: var(--border-hair) solid var(--line); } +.raw-related-findings .related-raw-record { min-height: var(--btn-h); padding-right: calc(var(--btn-h) + var(--space-2)); } +.obsidian-graph-stage > .related-findings-focus { position: absolute; top: calc(var(--btn-h) + var(--space-6)); left: var(--space-3); right: var(--space-3); z-index: 3; } +.dataset-view-host { height: 100%; min-height: 0; } +.dataset-view-host[hidden] { display: none; } +.dataset-view-host:has(> .related-findings-focus), .viewer.app-area-work:has(> .related-findings-focus) { display: flex; flex-direction: column; } +.dataset-view-host:has(> .related-findings-focus) > .records-layout, .viewer.app-area-work:has(> .related-findings-focus) > .records-layout { flex: 1; height: auto; min-height: 0; } +.dataset-view-host:has(> .related-findings-focus) > .viewer-scroll, .dataset-view-host:has(> .related-findings-focus) > .table-scroll, .viewer.app-area-work:has(> .related-findings-focus) > .viewer-scroll, .viewer.app-area-work:has(> .related-findings-focus) > .table-scroll { flex: 1; min-height: 0; } + .empty-state { display: grid; place-items: center; min-height: 55vh; padding: 40px; color: var(--muted); text-align: center; } .empty-state-brand { min-height: 100%; } .work-filter-empty { padding: var(--panel-inset); color: var(--muted); } @@ -63,6 +97,10 @@ mark, .search-highlight { .finding-card h3 { margin: 0; color: var(--heading); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); line-height: 1.42; overflow-wrap: normal; word-break: normal; hyphens: manual; } .workspace-label { display: inline-flex; align-items: center; justify-content: center; margin-left: 6px; padding: 1px 6px; border: 1px solid var(--border-default); border-radius: 0; color: var(--text-primary); background: var(--surface-input); font-size: var(--ui-label-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; vertical-align: middle; } .finding-badges, .mitre-line { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } +.mitre-disclosure { display: flex; align-items: flex-start; gap: var(--space-2); margin-top: var(--space-2); } +.mitre-disclosure .mitre-line { margin-top: 0; } +.mitre-disclosure-toggle { display: inline-grid; place-items: center; flex: 0 0 auto; border: 0; background: transparent; color: var(--text-secondary); } +.mitre-disclosure-toggle:hover { background: transparent; color: var(--text-primary); } .finding-card-actions { display: grid; align-content: start; gap: 8px; } .bookmark-btn { color: var(--button-text); border: 1px solid var(--button-border); border-radius: var(--btn-radius); background: var(--button-bg); } .bookmark-btn[aria-pressed="true"] { border-color: var(--button-border); color: var(--button-marked-text); background: var(--button-marked-bg); font-weight: 600; } @@ -106,6 +144,8 @@ mark, .search-highlight { .reader-heading h2 { margin: 0; color: var(--heading); font-size: var(--type-detail-title-size); font-weight: var(--ui-strong-weight); line-height: 1.3; letter-spacing: 0; overflow-wrap: normal; word-break: normal; hyphens: manual; } /* The action row starts in the title column, so context badges align with the finding badges above (2026-10-01). */ .reader-heading-actions { display: flex; flex-wrap: wrap; grid-column: 2 / -1; align-items: flex-start; justify-content: flex-end; gap: 10px; align-self: center; } +.reader-primary-actions { display: flex; flex-flow: row nowrap; align-items: center; gap: var(--space-3); flex: 0 0 auto; } +.reader-primary-actions > a, .reader-primary-actions > button { flex: 0 0 auto; white-space: nowrap; } /* Context cluster badges (Phase 13E): one step larger than metadata badges, neutral outline, no status colour. Several badges stack one below the other; each carries the context marker dot (user decision 2026-10-01). */ .context-cluster-badges { display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-2); min-width: 0; margin-right: auto; } @@ -155,7 +195,6 @@ mark, .search-highlight { .risk-acceptance-review-actions { display: flex; gap: var(--space-3); justify-content: flex-end; } @media (max-width: 620px) { .accepted-risk-trigger { grid-column: 2; grid-row: 2; justify-self: start; } .risk-acceptance-grid { grid-template-columns: minmax(0, 1fr); } } /* Der PingCastle-Score-Erlaeuterungsabsatz wurde samt Markup entfernt. */ -.bookmark-label { color: var(--text); font-size: var(--ui-control-size); font-weight: var(--ui-label-weight); } .results-detail-btn { min-width: 136px; } .dashboard-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 28px auto 24px; } .dashboard-grid label, .review-grid > label { display: grid; gap: 7px; color: var(--heading); font-size: var(--ui-value-size); font-weight: var(--fw-semibold); letter-spacing: 0; text-transform: none; } @@ -165,6 +204,47 @@ mark, .search-highlight { Regel, also einen Satz. Versalien waeren dafuer unlesbar. */ .reader-summary h3 { margin-bottom: var(--legacy-space-5); color: var(--heading); font-size: var(--type-h3-size); text-transform: none; } .reader-summary h4 { margin: 0 0 var(--space-2); color: var(--heading); font-size: var(--ui-reading-size); text-transform: uppercase; } +.reader-source-rule { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--space-3) var(--space-5); } +.finding-work-package { color: var(--text); font-family: var(--font-ui); font-size: var(--type-body-size); font-weight: var(--fw-regular); text-transform: none; background: transparent; border: 0; padding: 0; } +a.finding-work-package { text-decoration: underline; text-underline-offset: var(--space-1); } +.finding-work-package__group, +.reader-source-rule > .finding-work-package { display: block; flex: 1 1 0px; min-width: 0; margin-left: auto; text-align: right; overflow-wrap: anywhere; } +.work-package-cluster-colour.finding-work-package__colour { display: inline-block; vertical-align: baseline; margin-inline-end: var(--space-2); } +.finding-work-package__detail { color: var(--muted); font-size: var(--type-table-body-size); } +.finding-work-package__finding { color: var(--text); text-decoration: underline; text-underline-offset: var(--space-1); } +.finding-work-package__finding:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--space-1); } +.floating-data-window-body:has(> .work-packages) { overflow: hidden; } +.btn.work-packages-button, .btn.work-packages-button:hover { border-color: var(--button-accent); } +.btn.work-packages-button[aria-pressed="true"], .btn.work-packages-button[aria-pressed="true"]:hover { border-color: var(--button-accent); color: var(--button-marked-text); background: var(--button-marked-bg); font-weight: var(--fw-semibold); } +.floating-data-window-head.is-marked { border-left: var(--marker-width) solid var(--marking-color); } +.client-env-facts.work-packages__facts { grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; } +.client-env-facts.work-packages__facts > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); min-height: 0; padding: var(--space-2) var(--space-3); } +.client-env-facts.work-packages__facts > div:not(:nth-child(3n)) { border-right: var(--border-hair) solid var(--client-info-line); } +.client-env-facts.work-packages__facts dt { font-weight: var(--fw-regular); } +.work-packages__facts .work-packages__select { font-size: var(--type-body-size); } +.work-packages { display: flex; flex-direction: column; height: 100%; gap: var(--space-3); min-width: 0; min-height: 0; } +.work-packages__lead { margin: 0; font-size: var(--type-body-size); line-height: 1.55; } +.work-packages__identity { display: flex; align-items: baseline; gap: var(--space-2); } +.work-packages__identity .work-package-cluster-colour { flex: 0 0 var(--space-3); align-self: center; } +.work-packages__note, .work-packages__meta { margin: 0; color: var(--muted); font-size: var(--type-table-body-size); overflow-wrap: break-word; } +.work-packages__scroll { min-width: 0; min-height: 0; flex: 1; overflow: auto; } +.work-packages__table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: var(--type-table-body-size); } +.work-packages__table th, .work-packages__table td { font-size: var(--type-table-body-size); padding: var(--space-2) var(--space-3); border-bottom: var(--border-hair) solid var(--tile-divider); text-align: left; vertical-align: top; overflow-wrap: break-word; } +.work-packages__table thead { position: sticky; top: 0; z-index: 2; background: var(--surface-panel-raised); } +.work-packages__table thead th { position: static; white-space: nowrap; background: var(--surface-panel-raised); } +.work-packages__filter { display: block; width: 100%; min-width: var(--space-8); margin-top: var(--space-2); font: inherit; color: var(--text); background: var(--surface-input); border: var(--border-hair) solid var(--line-strong); padding: var(--space-1) var(--space-2); } +.work-packages__filter:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); } +.work-packages__table tbody th { position: static; top: auto; z-index: auto; background: transparent; font-weight: var(--fw-regular); } +.work-packages__table .work-packages__cluster th { background: var(--surface-panel-raised); color: var(--heading); font-weight: var(--fw-semibold); border-top: var(--border-hair) solid var(--line-strong); } +.work-packages__table [data-work-package-row] > th { padding-inline-start: var(--space-8); } +.work-packages__sort, .work-packages__select { padding: 0; border: 0; background: transparent; font-family: var(--font-ui); font-size: var(--type-table-body-size); text-align: left; cursor: pointer; } +.work-packages__sort { color: var(--muted); font-weight: var(--fw-semibold); } +.work-packages__table th[aria-sort="ascending"] .work-packages__sort, .work-packages__table th[aria-sort="descending"] .work-packages__sort { color: var(--heading); } +.work-packages__table th[aria-sort="ascending"] .work-packages__sort::after { content: " ↑"; } +.work-packages__table th[aria-sort="descending"] .work-packages__sort::after { content: " ↓"; } +.work-packages__select { color: var(--text); font-weight: var(--fw-regular); text-decoration: underline; text-underline-offset: var(--space-1); white-space: nowrap; } +.work-packages__lead .work-packages__select { font-size: inherit; } +.work-packages__sort:focus-visible, .work-packages__select:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--space-1); } /* Native section elements share the same reading rhythm as ordinary fields. */ .reader-summary > :is(div, section) { padding: 0 0 var(--space-4); margin: 0 0 var(--space-4); border-bottom: var(--border-hair) solid var(--line); } .reader-summary > :is(div, section):last-child { padding-bottom: 0; margin-bottom: 0; border-bottom: 0; } @@ -429,7 +509,7 @@ mark, .search-highlight { /* Eine einzige Hoechsthoehe fuer jeden Zustand: eine zusaetzliche Regel fuer das geoeffnete Fenster liess die Darstellung bei jedem Wechsel zwischen Verbund und freier Lage springen. Die Groesse aendert ausschliesslich die Nutzerin. */ -.results-window { --results-action-width: 84px; --result-edge: var(--marker-width); --cluster-bar-h: 34px; position: fixed; display: grid; grid-template-rows: auto minmax(0,1fr); width: min(1040px,78vw); height: auto; min-width: 440px; max-width: 100vw; max-height: calc(100vh - 144px); border: var(--border-hair) solid var(--line-strong); border-radius: var(--legacy-radius-sm); color: var(--text); background: var(--bg); overflow: visible; } +.results-window { --results-action-width: 84px; --result-edge: var(--marker-width); --cluster-bar-h: 34px; position: fixed; display: grid; grid-template-rows: auto minmax(0,1fr); width: min(1040px,78vw); height: auto; min-width: 440px; max-width: 100vw; max-height: calc(100vh - 144px); border: var(--border-hair) solid var(--line-strong); border-radius: var(--radius-md); color: var(--text); background: var(--bg); overflow: visible; } /* Nur ein geoeffnetes Fenster schwebt. Die minimierten Miniaturen bilden einen zusammenhaengenden Stapel; ein Schatten je Zeile haette zwischen den Zeilen dunkle Baender erzeugt statt der einen Haarlinie, die sie trennen soll. */ @@ -605,18 +685,26 @@ mark, .search-highlight { die Kopfzeile sonst auf die ungekuerzte Breite des Fundnamens wachsen (nowrap-Text hat als min-content seine volle Laenge) und sprengte damit die Fensterbreite, statt den Namen per Ellipsis zu kuerzen. */ -.results-window-head { display: flex; align-items: stretch; justify-content: space-between; gap: 0; min-height: 32px; min-width: 0; padding: 0 8px 0 0; color: var(--heading); background: var(--panel-soft); border-bottom: 1px solid var(--line); cursor: move; touch-action: none; user-select: none; } +.results-window-head { display: flex; align-items: stretch; justify-content: space-between; gap: 0; min-height: 32px; min-width: 0; padding: 0 8px 0 0; color: var(--heading); background: var(--panel-soft); border-bottom: 1px solid var(--line); cursor: move; touch-action: none; user-select: none; border-radius: var(--radius-md) var(--radius-md) 0 0; } /* Nur die beiden wiederholten Resulttitel verwenden die kompakte H4-Stufe; Finding- und Tabellenhierarchie ausserhalb des Resultfensters bleibt gleich. */ -.results-window-head h2 { display: flex; align-items: stretch; gap: 10px; min-width: 0; margin: 0; color: var(--heading); font-size: var(--type-h4-size); font-weight: var(--ui-strong-weight); line-height: 1.35; } +.results-window-head h2 { display: flex; flex: 1 1 0; align-items: stretch; gap: 10px; min-width: 0; margin: 0; color: var(--heading); font-size: var(--type-h4-size); font-weight: var(--ui-strong-weight); line-height: 1.35; } .results-window-result-summary { display: inline-flex; align-items: center; align-self: center; flex: 0 0 auto; gap: 5px; padding-left: 8px; color: var(--heading); font-family: var(--font-mono); font-variant-numeric: tabular-nums; white-space: nowrap; } .results-window-toggle, .results-window-toggle-icon { display: grid; place-items: center; width: 20px; height: 20px; flex: 0 0 auto; color: var(--result-color); } .results-window-toggle { padding: 0; border: 0; border-radius: var(--btn-radius); background: transparent; } .results-window-toggle:hover { color: var(--result-color); background: var(--panel-hover); } .results-window-toggle .triangle-icon, .results-window-toggle-icon .triangle-icon { width: 16px; height: 16px; } -.results-window-name { display: flex; align-items: center; min-width: 0; padding: 5px 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.results-window-name { display: block; align-self: center; min-width: 0; padding: 5px 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .results-window-actions { display: flex; align-items: center; flex: 0 0 auto; gap: 6px; padding: 0 0 0 8px; } .results-window-action { align-self: center; min-width: var(--results-action-width); } +.results-window-export { width: var(--btn-h); min-width: var(--btn-h); padding: 0; display: grid; place-items: center; } +.results-window-export-all, .results-window-export-all:hover { color: var(--results-export-all); } +.results-window-export-selected, .results-window-export-selected:hover { color: var(--results-export-selected); } +.results-window-export-selected:disabled { color: var(--text-secondary); cursor: default; } +.result-table-selection { text-align: left; padding: var(--space-2) 0; color: var(--text-secondary); font-size: var(--type-table-body-size); } +.result-table-selection label { display: inline-flex; align-items: center; gap: var(--space-2); cursor: pointer; } +.result-table-selection input { accent-color: var(--marking-color); } +.results-export-error { color: var(--text-primary); } .results-window-close { align-self: center; display: grid; place-items: center; width: 22px; height: 22px; padding: 0; border: 0; border-radius: var(--btn-radius); color: var(--muted); background: transparent; } .results-window-close:hover { color: var(--heading); background: var(--panel-hover); } /* Ergebnisfenster: Kopfzeile oben, genau ein Container darunter - dieselbe @@ -758,6 +846,12 @@ table tbody tr[data-row-marked="true"] > :is(th, td) { width: min(100%, var(--donut-ring-size)); aspect-ratio: 1; } +.donut-metric-card__basis { + color: var(--donut-label); + font-size: var(--type-table-body-size); + font-weight: var(--fw-regular); + text-align: center; +} .donut-metric-card__plot svg { display: block; width: 100%; @@ -824,76 +918,53 @@ table tbody tr[data-row-marked="true"] > :is(th, td) { .donut-metric-card--low { color: var(--low); } .donut-metric-card--info { color: var(--info); } -/* Work uses the same Security Domain catalog and filter as Client Info. */ -.sidebar-domain-rack { - display: flex; - flex-direction: column; - gap: 0; - flex: 0 1 auto; - min-height: 0; - margin: var(--space-2) var(--space-2) var(--space-2); - padding: var(--space-3) 0 0; - overflow-y: auto; - border: 0; - border-top: var(--border-hair) solid var(--border-subtle); - border-radius: 0; - background: transparent; - box-shadow: none; - scrollbar-color: var(--border-strong) transparent; - scrollbar-width: thin; -} -.sidebar-domain-rack h2 { - margin: 0 var(--space-2) var(--space-2); - color: var(--text-primary); - font-size: var(--type-h4-size); - font-weight: var(--fw-semibold); -} -.sidebar-domain-button { - position: relative; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: var(--space-2); - width: 100%; - min-height: var(--btn-h); - padding: var(--space-2) var(--btn-pad-x); - border: 0; - border-radius: var(--btn-radius); - color: var(--text-secondary); - background: transparent; - font: inherit; - text-align: left; - cursor: pointer; -} -.sidebar-domain-button:hover { color: var(--text-primary); background: var(--surface-hover); } -.sidebar-domain-button span { - min-width: 0; - font-size: var(--type-table-body-size); - font-weight: var(--fw-regular); - line-height: var(--lh-ui); - overflow-wrap: normal; - word-break: normal; -} -.sidebar-domain-button strong { - color: var(--text-secondary); - font-family: var(--font-mono); - font-size: var(--type-table-body-size); - font-weight: var(--fw-regular); - font-variant-numeric: tabular-nums; -} -.sidebar-domain-button[aria-pressed="true"] { - color: var(--text-primary); - background: var(--surface-selected); -} -.sidebar-domain-button[aria-pressed="true"] span, -.sidebar-domain-button[aria-pressed="true"] strong { font-weight: var(--fw-semibold); } -.sidebar-domain-button[aria-pressed="true"]::before { - position: absolute; - inset: 0 auto 0 0; - width: var(--marker-width); - background: var(--selection-marker); - content: ""; -} +/* Compact Work facet rack: neutral theme roles, shared information typography. */ +.work-filter-parking { order: 1; min-height: 0; margin: var(--space-2); overflow: auto; } +.work-filter-panel { min-width: 0; border: var(--border-hair) solid var(--border-subtle); background: var(--surface-panel); color: var(--text-primary); border-radius: var(--radius-md); } +.work-filter-panel.is-floating { position: fixed; z-index: var(--z-floating-filters); max-width: 100vw; max-height: 100vh; display: flex; flex-direction: column; } +.work-filter-resize { position: absolute; z-index: 2; border: 0; padding: 0; background: transparent; touch-action: none; } +.work-filter-resize-n, .work-filter-resize-s { left: var(--space-2); right: var(--space-2); height: var(--space-2); cursor: ns-resize; } +.work-filter-resize-n { top: calc(-1 * var(--space-1)); } +.work-filter-resize-s { bottom: calc(-1 * var(--space-1)); } +.work-filter-resize-e, .work-filter-resize-w { top: var(--space-2); bottom: var(--space-2); width: var(--space-2); cursor: ew-resize; } +.work-filter-resize-e { right: calc(-1 * var(--space-1)); } +.work-filter-resize-w { left: calc(-1 * var(--space-1)); } +.work-filter-resize-ne, .work-filter-resize-nw, .work-filter-resize-se, .work-filter-resize-sw { width: var(--space-3); height: var(--space-3); } +.work-filter-resize-ne, .work-filter-resize-se { right: calc(-1 * var(--space-1)); } +.work-filter-resize-nw, .work-filter-resize-sw { left: calc(-1 * var(--space-1)); } +.work-filter-resize-ne, .work-filter-resize-nw { top: calc(-1 * var(--space-1)); } +.work-filter-resize-se, .work-filter-resize-sw { bottom: calc(-1 * var(--space-1)); } +.work-filter-resize-ne, .work-filter-resize-sw { cursor: nesw-resize; } +.work-filter-resize-nw, .work-filter-resize-se { cursor: nwse-resize; } +.work-filter-panel-head { display: flex; align-items: center; justify-content: space-between; padding: var(--space-1) var(--space-2); background: var(--surface-panel-raised); border-bottom: var(--border-hair) solid var(--border-subtle); cursor: grab; touch-action: none; user-select: none; font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); border-radius: var(--radius-md) var(--radius-md) 0 0; } +.work-filter-panel-head button { height: var(--btn-h); padding: 0 var(--btn-pad-x); border: 0; border-radius: var(--btn-radius); background: transparent; color: var(--text-primary); cursor: pointer; } +.work-filter-panel-list { min-height: 0; overflow-y: auto; } +.work-filter-group { border-bottom: var(--border-hair) solid var(--border-subtle); } +.work-filter-group:last-child { border-bottom: 0; } +.work-filter-group summary { display: flex; align-items: center; gap: var(--space-1); padding: var(--space-1) var(--space-2); min-height: var(--btn-h); list-style: none; cursor: pointer; background: var(--surface-panel-raised); font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); } +.work-filter-group summary::-webkit-details-marker { display: none; } +.work-filter-group summary::before { content: '▸'; } +.work-filter-group[open] summary::before { content: '▾'; } +.work-filter-icon { flex: none; font-size: var(--type-button-size); } +.work-filter-selected { margin-left: auto; color: var(--marking-color); font-size: var(--type-table-body-size); } +.work-filter-value { position: relative; display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: var(--space-2); width: 100%; min-height: var(--btn-h); padding: var(--space-1) var(--space-2) var(--space-1) var(--space-6); border: 0; border-top: var(--border-hair) solid var(--border-subtle); border-radius: 0; background: transparent; color: var(--text-secondary); text-align: left; font-size: var(--type-table-body-size); font-weight: var(--fw-regular); line-height: var(--lh-ui); cursor: pointer; } +.work-filter-value span { overflow-wrap: anywhere; } +.work-package-cluster-head { display: grid; grid-template-columns: minmax(0, 1fr) var(--space-3); align-items: center; gap: var(--space-2); padding: var(--space-2); border-top: var(--border-hair) solid var(--border-subtle); color: var(--text-primary); font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); line-height: var(--lh-ui); } +.work-package-cluster-head > span:first-child { overflow-wrap: anywhere; } +.work-package-cluster-colour { display: block; width: var(--space-3); height: var(--space-3); pointer-events: none; } +.work-package-cluster-colour.severity-critical { background: var(--sev-critical); } +.work-package-cluster-colour.severity-high { background: var(--sev-high); } +.work-package-cluster-colour.severity-medium { background: var(--sev-medium); } +.work-package-cluster-colour.severity-undetermined { background: var(--sev-unknown); } +.work-filter-value strong { font-size: inherit; font-weight: var(--fw-regular); font-variant-numeric: tabular-nums; } +.work-filter-value[aria-pressed="true"] { color: var(--text-primary); background: var(--surface-selected); } +.work-filter-value[aria-pressed="true"]::before { content: ''; position: absolute; inset: 0 auto 0 0; width: var(--marker-width); background: var(--selection-marker); } +.work-filter-value:disabled { opacity: .55; cursor: default; } +.work-filter-value:hover:not(:disabled),.work-filter-group summary:hover,.work-filter-panel-head button:hover { background: var(--surface-hover); color: var(--text-primary); } +.work-filter-panel :focus-visible,.work-filter-park-button:focus-visible { outline: var(--marker-width) solid var(--focus-ring); outline-offset: -2px; } +.work-filter-empty { margin: 0; padding: var(--space-2); font-size: var(--type-table-body-size); color: var(--text-secondary); } +.work-filter-park-button { width: 100%; min-height: var(--btn-h); padding: var(--space-2); border: var(--border-hair) dashed var(--border-strong); border-radius: var(--btn-radius); background: transparent; color: var(--text-secondary); font-size: var(--type-button-size); font-weight: var(--fw-semibold); cursor: pointer; } +.work-filter-parking.is-target { outline: var(--marker-width) solid var(--marking-color); } @media (prefers-reduced-motion: reduce) { .donut-metric-card__progress { animation: none; } @@ -943,7 +1014,7 @@ table tbody tr[data-row-marked="true"] > :is(th, td) { /* Shared operation visibility: stable header, scrollable disclosure and real task status. */ .activity-minimized { display: flex; align-items: center; gap: var(--space-2); position: fixed; z-index: 100; right: var(--panel-inset); bottom: var(--panel-inset); max-width: calc(100vw - var(--panel-inset) * 2); } -.activity-center { position: fixed; z-index: 100; right: var(--panel-inset); bottom: var(--panel-inset); width: min(var(--activity-panel-width), calc(100vw - var(--panel-inset) * 2)); max-height: 40vh; display: flex; flex-direction: column; padding: var(--panel-inset); border: var(--bw-hairline) solid var(--border-default); background: var(--surface-panel); color: var(--text-primary); } +.activity-center { position: fixed; z-index: 100; right: var(--panel-inset); bottom: var(--panel-inset); width: min(var(--activity-panel-width), calc(100vw - var(--panel-inset) * 2)); max-height: 40vh; display: flex; flex-direction: column; padding: var(--panel-inset); border: var(--bw-hairline) solid var(--border-default); background: var(--surface-panel); color: var(--text-primary); border-radius: var(--radius-md); } .activity-center-header { display: flex; align-items: start; justify-content: space-between; gap: var(--space-2); flex: none; } .activity-header-actions, .activity-compact-toggle { display: inline-flex; align-items: center; gap: var(--space-2); } .activity-reload svg { display: block; width: var(--space-4); height: var(--space-4); } diff --git a/src/styles/datahub-redesign.css b/src/styles/datahub-redesign.css index a524ed2..4eebd86 100644 --- a/src/styles/datahub-redesign.css +++ b/src/styles/datahub-redesign.css @@ -1,6 +1,6 @@ /* Datahub rack layout: centered client history and reference modules. */ .datahub-page > .datahub-rack-module { width: min(1180px, 100%); margin-inline: auto; } -.datahub-rack-module { box-sizing: border-box; margin: 0 0 var(--legacy-space-4); border: var(--border-hair) solid var(--line); color: var(--text); background: var(--panel); } +.datahub-rack-module { box-sizing: border-box; margin: 0 0 var(--legacy-space-4); border: var(--border-hair) solid var(--line); border-radius: var(--radius-md); color: var(--text); background: var(--panel); } .datahub-history-module { overflow: hidden; } .datahub-history-scroll { overflow-x: auto; scrollbar-color: var(--line-strong) var(--surface-app); scrollbar-width: thin; } .datahub-history-grid { display: flex; width: 100%; min-width: 1180px; background: var(--surface-app); } diff --git a/src/styles/datahub.css b/src/styles/datahub.css index 884430d..a99eb28 100644 --- a/src/styles/datahub.css +++ b/src/styles/datahub.css @@ -98,6 +98,7 @@ /* Datahub work-panel alignment and scrolling */ .viewer.app-area-data-hub, .viewer.app-area-dashboard, .viewer.app-area-db-structure { overflow-x: hidden; overflow-y: auto; background: var(--surface-app); scrollbar-color: var(--line-strong) var(--surface-app); scrollbar-width: thin; } .viewer.app-area-data-hub { scrollbar-gutter: stable; } +.viewer.app-area-dashboard { overflow: visible; background: transparent; } .datahub-page { box-sizing: border-box; min-height: 100%; background: var(--surface-app); } .datahub-card { border-radius: var(--tile-radius); box-shadow: var(--tile-card-shadow); } .tenant-editor, .tenant-file-table, .tenant-import-pending { border-radius: var(--legacy-radius-sm); box-shadow: var(--shadow); } @@ -310,7 +311,7 @@ button.dashboard-status-metric[aria-pressed="true"] { border-color: var(--tile-s min-height: 0; padding: var(--space-3); border: var(--border-hair) solid var(--tile-border); - border-radius: var(--radius-sm); + border-radius: var(--tile-radius); color: var(--text); background: var(--tile-surface); box-shadow: none; @@ -337,7 +338,7 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select align-self: stretch; padding: 0; border: var(--border-hair) solid var(--tile-border); - border-radius: var(--radius-sm); + border-radius: var(--tile-radius); color: var(--text); background: var(--tile-surface); box-shadow: none; @@ -368,7 +369,7 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select .client-info-card-extra { display: grid; align-content: start; min-width: 0; padding: 0 var(--space-5) var(--space-4); border-top: var(--border-hair) solid var(--line); } /* Keep the six overview tiles in one row; narrower panels scroll horizontally. */ -.client-environment-grid { display: grid; grid-template-columns: repeat(6, minmax(var(--tile-environment-min-width), 1fr)); align-items: start; gap: var(--segment-gap); min-width: 0; margin-top: var(--segment-gap); overflow-x: auto; } +.client-environment-grid { display: grid; grid-template-columns: repeat(6, minmax(var(--tile-environment-min-width), 1fr)); align-items: start; gap: var(--segment-gap); min-width: 0; margin-top: var(--segment-gap); overflow-x: auto; background: var(--tile-surface); box-shadow: none; } .client-environment-grid > .client-env-group, .client-environment-grid > .client-os-inventory { display: flex; flex-direction: column; min-width: 0; aspect-ratio: 1; container: environment-tile / inline-size; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); overflow: auto; } /* The container query measures the inner width (tile minus padding). Measured 2026-10-01: four facts @@ -420,9 +421,13 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select /* Nicht-modales Datenfenster: dieselbe flache Fenstergrammatik, Drag-/Resize- Geometrie und Maximierungslogik wie Results, ohne dessen Finding-spezifische Titelleisten- und Clustersemantik zu duplizieren. */ -.floating-data-window { position: fixed; z-index: 2600; display: grid; grid-template-rows: auto minmax(0, 1fr); min-width: 520px; min-height: 280px; border: var(--border-hair) solid var(--line-strong); border-radius: var(--legacy-radius-sm); color: var(--text); background: var(--bg); box-shadow: var(--shadow-overlay); overflow: visible; } -.floating-data-window-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); min-height: 38px; padding-inline-start: var(--space-4); border-bottom: var(--border-hair) solid var(--line); color: var(--heading); background: var(--panel-raised); cursor: move; touch-action: none; user-select: none; } +.floating-data-window { position: fixed; z-index: 2600; display: grid; grid-template-rows: auto minmax(0, 1fr); min-width: 520px; min-height: 280px; border: var(--border-hair) solid var(--line-strong); border-radius: var(--radius-md); color: var(--text); background: var(--bg); box-shadow: var(--shadow-overlay); overflow: visible; } +.floating-data-window-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); min-height: 38px; padding-inline-start: var(--space-4); border-bottom: var(--border-hair) solid var(--line); color: var(--heading); background: var(--panel-raised); cursor: move; touch-action: none; user-select: none; border-radius: var(--radius-md) var(--radius-md) 0 0; } .floating-data-window.is-maximized .floating-data-window-head { cursor: default; } +.floating-data-window.is-minimized { min-width: 0; min-height: 0; grid-template-rows: auto; } +.floating-data-window.is-minimized .floating-data-window-head { cursor: default; } +.floating-data-window-body[hidden] { display: none; } +.floating-data-window-restore { border: 0; padding: 0; color: inherit; background: transparent; font: inherit; text-align: left; cursor: pointer; } .floating-data-window-head h2 { min-width: 0; margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); line-height: 1.35; } .floating-data-window-actions { display: flex; align-self: stretch; } .floating-data-window-actions button { display: grid; place-items: center; width: 38px; padding: 0; border: 0; border-left: var(--border-hair) solid var(--line); border-radius: 0; color: var(--heading); background: transparent; cursor: pointer; } @@ -482,7 +487,7 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select .client-env-table-dialog--resizable .results-window-resize { touch-action: none; } .client-env-table-dialog--resizable.is-resized .client-env-table-dialog-body { grid-template-rows: auto minmax(0, 1fr) auto; } .client-env-table-dialog--resizable.is-resized .client-env-table-scroll { max-height: none; min-height: 0; } -.client-env-table-dialog-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: var(--space-3) var(--panel-inset); border-bottom: var(--border-hair) solid var(--line); background: var(--surface-panel-raised); } +.client-env-table-dialog-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: var(--space-3) var(--panel-inset); border-bottom: var(--border-hair) solid var(--line); background: var(--surface-panel-raised); border-radius: var(--radius-md) var(--radius-md) 0 0; } .client-env-table-dialog-head > div { min-width: 0; } .client-env-table-dialog-head p { margin: 0; color: var(--muted); font-size: var(--type-table-body-size); } .client-env-table-dialog-head h2 { margin: var(--space-1) 0 0; color: var(--heading); font-size: var(--type-detail-title-size); font-weight: var(--fw-semibold); overflow-wrap: anywhere; } @@ -503,7 +508,7 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select .client-env-table-pagination .btn { min-height: var(--btn-h); } @media (max-width: 620px) { - .floating-data-window { min-width: 0; min-height: 0; } + .floating-data-window { min-width: 0; min-height: 0; border-radius: var(--radius-md); } .floating-data-window .results-window-resize { display: none; } } @@ -546,6 +551,23 @@ button.client-info-bar-row:focus-visible { outline: var(--border-hair) solid var .client-info-legend strong { color: var(--heading); font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; } /* Client Info security posture dashboard */ +.client-info-tabs { display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden; color: var(--text-primary); background: var(--surface-shell); } +.client-info-tablist { display: flex; flex: 0 0 auto; flex-wrap: wrap; align-items: start; gap: var(--space-2); padding: 0; } +.client-info-tab { position: relative; min-width: var(--client-info-tab-width); height: var(--client-info-tab-height); padding: 0 var(--btn-pad-x); border: 0; border-radius: var(--btn-radius) var(--btn-radius) 0 0; color: var(--text-secondary); background: var(--surface-app); box-shadow: none; font: var(--fw-regular) var(--type-body-size)/1 var(--font-ui); cursor: pointer; } +.client-info-tab:first-child { border-top-left-radius: var(--panel-radius); } +.client-info-tab[aria-selected="false"] { height: calc(var(--client-info-tab-height) - var(--client-info-tab-gap)); } +.client-info-tab[aria-selected="true"] { z-index: 1; color: var(--text-primary); box-shadow: none; } +.client-info-tab:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(-1 * var(--bw-emphasis)); } +.client-info-tabpanel { flex: 1; min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; background: var(--surface-app); scrollbar-color: var(--line-strong) var(--surface-app); scrollbar-width: thin; } +.client-info-tabpanel[hidden] { display: none; } +.client-info-view-host { height: 100%; min-height: 0; } +.client-info-view-host[hidden] { display: none; } +.risk-entry-with-group { position: relative; } +.risk-entry-with-group:has(.related-finding-ring) > button:not(.related-finding-ring) > span { padding-right: var(--btn-h); } +.client-risk-register-list .risk-entry-with-group > .related-finding-ring { position: absolute; top: var(--space-2); right: var(--space-2); z-index: 2; display: inline-grid; place-items: center; width: var(--btn-h); height: var(--btn-h); min-width: 0; padding: 0; border: 0; border-radius: 0; outline: 0; box-shadow: none; background: transparent; } +.client-risk-register-list .risk-entry-with-group > .related-finding-ring:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); } +.risk-register-title { position: relative; } +.risk-register-title:has(.related-finding-ring) { padding-right: var(--btn-h); } .dashboard-page.client-command-center { --client-info-text: var(--heading); --client-info-muted: var(--muted); @@ -563,7 +585,8 @@ button.client-info-bar-row:focus-visible { outline: var(--border-hair) solid var .client-command-center .tone-passed { --chart-color: var(--client-info-green); } .client-command-center .tone-unknown { --chart-color: var(--client-info-neutral); } .client-glyph { display: block; width: var(--space-8); height: var(--space-8); fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; } -.client-overview-header { display: grid; grid-template-columns: auto auto minmax(240px, 1fr); align-items: center; gap: var(--space-8); min-width: 0; color: var(--client-info-muted); } +.client-overview-header { display: flex; flex-wrap: wrap; align-items: center; column-gap: var(--space-8); row-gap: var(--space-3); min-width: 0; color: var(--client-info-muted); } +.client-overview-tools { display: flex; flex: 1 1 auto; justify-content: flex-end; align-items: center; flex-wrap: wrap; gap: var(--space-3); min-width: 0; } .client-overview-identity, .client-overview-assessment, .client-overview-source { display: flex; align-items: center; gap: var(--space-3); min-width: 0; font-size: var(--type-table-body-size); } @@ -573,14 +596,16 @@ button.client-info-bar-row:focus-visible { outline: var(--border-hair) solid var .client-overview-source { justify-self: end; } .client-overview-source select, .client-overview-assessment select { min-width: 132px; min-height: var(--control-h); padding-inline: var(--space-3); border: var(--border-hair) solid var(--client-info-line); border-radius: var(--btn-radius); color: var(--client-info-text); background: var(--client-info-surface); font: inherit; } -.client-report-actions { grid-column: 1 / -1; display: flex; justify-content: flex-end; flex-wrap: wrap; gap: var(--space-2); } +.client-report-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: var(--space-2); } .client-report-error { grid-column: 1 / -1; margin: 0; color: var(--sev-critical); font-size: var(--type-table-body-size); } -.client-overview-main { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: stretch; gap: var(--segment-gap); min-width: 0; } +.client-overview-main { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 1fr); align-items: stretch; gap: var(--segment-gap); min-width: 0; background: transparent; box-shadow: none; } +.client-overview-summary { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: var(--segment-gap); min-width: 0; border: 0; background: transparent; } .client-posture { position: relative; display: grid; grid-template-columns: minmax(300px, .82fr) minmax(430px, 1.18fr); gap: var(--segment-gap); min-width: 0; border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); } .client-overall, .client-domains, -.client-priority > section { min-width: 0; border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); color: var(--client-info-text); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); } +.client-priority > section { min-width: 0; border: 0; color: var(--client-info-text); background: transparent; box-shadow: none; } +.client-domain-information { min-width: 0; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); color: var(--client-info-text); background: var(--tile-surface); box-shadow: none; } .client-posture-connectors { position: absolute; z-index: 2; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; } .client-posture-connectors path { fill: none; stroke: var(--text-muted); stroke-width: 1.2; vector-effect: non-scaling-stroke; } .client-overall, @@ -588,6 +613,7 @@ button.client-info-bar-row:focus-visible { outline: var(--border-hair) solid var .client-overall { display: grid; align-content: start; justify-items: center; gap: var(--space-2); min-width: 0; padding: var(--tile-inset); } .client-overall-head > h2, .client-domains h2, +.client-domain-information h2, .client-priority h2 { margin: 0; color: var(--client-info-text); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); line-height: 1.3; } .client-overall-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); width: 100%; min-width: 0; } .client-overall-head > h2 { color: var(--client-info-text); } @@ -630,7 +656,6 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var .client-business-impact-column:hover:not(:disabled) { background: var(--button-bg-hover); } .client-business-impact-column:focus-visible { outline: var(--marker-width) solid var(--focus-ring); outline-offset: var(--space-1); } .client-business-impact-column:disabled { opacity: 1; cursor: default; } -.sidebar-business-impact { margin-top: var(--space-4); padding-top: var(--space-3); border-top: 1px solid var(--line); } .client-business-impact-column strong { color: var(--client-info-text); font-size: var(--type-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; } .client-business-impact-track { display: flex; align-items: flex-end; height: calc(var(--space-8) * 3); } .client-business-impact-track i { display: block; width: 100%; height: var(--share); background: var(--chart-tone-emphasis); } @@ -643,7 +668,7 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var .client-domains li > button > b, .client-domains li > div > b { position: relative; z-index: 1; grid-column: 4; grid-row: 1; color: var(--client-info-text); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; text-align: right; } -.client-priority { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-content: stretch; align-items: stretch; gap: var(--segment-gap); min-width: 0; } +.client-priority { display: grid; grid-template-columns: minmax(0, 1fr); align-content: stretch; align-items: stretch; gap: var(--segment-gap); min-width: 0; border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); } .client-priority > section { padding: var(--tile-inset); } .client-domain-information dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--border-hair); margin: var(--space-3) 0 0; padding: var(--border-hair); background: var(--client-info-line); } .client-domain-information > header h2 { font-size: var(--type-h4-size); font-weight: var(--fw-semibold); } @@ -765,10 +790,12 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var @container (max-width: 1300px) { .client-overview-main { grid-template-columns: 1fr; } + .client-overview-summary { grid-template-columns: 1fr; } .risk-register-detail-groups { grid-template-columns: 1fr; } } @container (max-width: 760px) { - .client-overview-header { grid-template-columns: 1fr; gap: var(--space-3); } + .client-overview-header { gap: var(--space-3); } + .client-overview-tools { justify-content: flex-start; } .client-overview-source { justify-self: start; } .client-report-actions { justify-content: flex-start; } .client-posture { grid-template-columns: 1fr; } @@ -849,7 +876,7 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var .db-structure-persistence i { width: 8px; height: 8px; background: var(--muted); } .db-structure-persistence.is-postgresql i { background: var(--success); } .db-structure-summary { display: grid; grid-template-columns: repeat(auto-fill, var(--tile-compact-width)); justify-content: start; gap: var(--segment-gap); margin-top: var(--space-5); padding: var(--segment-inset); border: 0; border-radius: var(--segment-radius); background: var(--segment-surface); box-shadow: none; } -.db-structure-summary article { display: grid; align-content: space-between; gap: var(--space-2); min-height: 148px; padding: var(--space-4); border: var(--border-hair) solid var(--tile-border); border-radius: 0; background: var(--tile-surface); box-shadow: none; } +.db-structure-summary article { display: grid; align-content: space-between; gap: var(--space-2); min-height: 148px; padding: var(--space-4); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: none; } .db-structure-summary article > span { color: var(--muted); font-size: var(--type-label-size); font-weight: var(--fw-semibold); letter-spacing: var(--ui-label-tracking); text-transform: uppercase; } .db-structure-summary article > strong { color: var(--heading); font-size: var(--type-h2-size); font-weight: var(--fw-semibold); line-height: 1; font-variant-numeric: tabular-nums; } .db-structure-summary article > small { color: var(--muted); font-size: var(--type-meta-size); line-height: 1.4; } @@ -865,7 +892,7 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var .db-structure-layer-header p { margin: 0; color: var(--muted); font-size: var(--type-body-size); line-height: 1.5; } .db-structure-layer-header > strong { flex: 0 0 auto; color: var(--muted); font-size: var(--type-label-size); font-weight: var(--fw-semibold); } .db-structure-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--tile-content-width)), var(--tile-content-width))); justify-content: start; align-items: start; gap: var(--segment-gap); } -.db-structure-card { min-width: 0; border: var(--border-hair) solid var(--tile-border); border-radius: 0; background: var(--tile-surface); box-shadow: none; } +.db-structure-card { min-width: 0; border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: none; } .db-structure-card.is-expanded { grid-column: 1 / -1; width: 100%; } .db-structure-card-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); width: 100%; min-height: 112px; padding: var(--space-4); border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer; } .db-structure-card-header:hover { background: var(--tile-surface-hover); } @@ -901,7 +928,7 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var } @media (max-width: 560px) { .db-structure-summary { grid-template-columns: minmax(0, 1fr); } - .db-structure-summary article { min-height: 128px; } + .db-structure-summary article { min-height: 128px; border-radius: var(--tile-radius); } .db-structure-toolbar, .db-structure-layer-header { align-items: start; flex-direction: column; } .db-structure-grid { grid-template-columns: minmax(0, 1fr); } .db-structure-column-head { display: none; } @@ -1711,6 +1738,9 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var position: static; grid-column: 1 / -1; width: auto; + min-width: 0; + white-space: normal; + overflow-wrap: anywhere; } .tenant-files-empty { margin: 0; @@ -1902,7 +1932,7 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var .db-engine-flow__edges > path.is-related { stroke: var(--marking-color); } .db-engine-flow__edges > path.is-planned { stroke-dasharray: 6 5; } .db-engine-flow__edges marker path { fill: var(--line-strong); } -.db-engine-flow__node { position: absolute; display: flex; flex-direction: column; justify-content: center; gap: var(--space-2); width: 220px; min-height: 100px; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: 0; background: var(--tile-surface); color: var(--text); text-align: left; cursor: pointer; } +.db-engine-flow__node { position: absolute; display: flex; flex-direction: column; justify-content: center; gap: var(--space-2); width: 220px; min-height: 100px; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); color: var(--text); text-align: left; cursor: pointer; } .db-engine-flow__node.is-engine { outline: var(--bw-emphasis) solid var(--muted); outline-offset: -2px; border-left: var(--marker-width) solid var(--muted); } .db-engine-flow__node.is-related { border-color: var(--line-strong); } .db-engine-flow__node[aria-pressed="true"] { outline: var(--bw-emphasis) solid var(--marking-color); outline-offset: -2px; border-left-color: var(--marking-color); } @@ -1943,9 +1973,9 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var .db-engine-flow th, .db-engine-flow td { padding: var(--space-3); text-align: left; vertical-align: top; border-bottom: var(--border-hair) solid var(--line); } .db-engine-flow th { font-weight: var(--fw-semibold); } @media (max-width: 1050px) { .db-engine-flow__inspector { grid-template-columns: minmax(0, 1fr); } } -.db-flow__step { position: relative; z-index: 1; display: grid; align-content: start; justify-self: center; gap: var(--space-2); width: 100%; max-width: var(--tile-content-width); min-width: 0; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-left: var(--marker-width) solid var(--line-strong); border-radius: 0; background: var(--tile-surface); } +.db-flow__step { position: relative; z-index: 1; display: grid; align-content: start; justify-self: center; gap: var(--space-2); width: 100%; max-width: var(--tile-content-width); min-width: 0; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-left: var(--marker-width) solid var(--line-strong); border-radius: var(--tile-radius); background: var(--tile-surface); } .db-flow__step[data-selected="true"] { outline: var(--bw-emphasis) solid var(--marking-color); outline-offset: calc(-1 * var(--bw-emphasis)); border-left-color: var(--marking-color); } -.db-flow__step { cursor: pointer; } +.db-flow__step { cursor: pointer; border-radius: var(--tile-radius); } .db-flow__select { display: flex; align-items: baseline; gap: var(--space-2); padding: 0; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; } .db-flow__select:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--space-1); } .db-flow__step > header { display: flex; align-items: baseline; gap: var(--space-2); } diff --git a/src/styles/import-workflow.css b/src/styles/import-workflow.css index 7101b0a..1b5689b 100644 --- a/src/styles/import-workflow.css +++ b/src/styles/import-workflow.css @@ -1,5 +1,5 @@ .dialog-backdrop { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 24px; background: var(--surface-scrim); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); } -.dialog-window { width: min(500px, 100%); max-height: calc(100vh - 48px); display: flex; flex-direction: column; border: 1px solid var(--line-strong); border-radius: 14px; background: var(--panel); box-shadow: var(--shadow); } +.dialog-window { width: min(500px, 100%); max-height: calc(100vh - 48px); display: flex; flex-direction: column; border: 1px solid var(--line-strong); border-radius: var(--radius-md); background: var(--panel); box-shadow: var(--shadow); } .dialog-window > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 20px; border-bottom: 1px solid var(--line); } .dialog-window > header h2 { margin: 0; font-size: var(--ui-heading-size); } .dialog-window > header button { width: 24px; height: 24px; border: 1px solid var(--button-border); border-radius: var(--btn-radius); color: var(--button-text); background: var(--button-bg); font-size: var(--ui-heading-size); cursor: pointer; display: grid; place-items: center; } diff --git a/src/styles/layout.css b/src/styles/layout.css index 0e1b395..9c6b60d 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -9,7 +9,7 @@ .brand-logo { position: relative; display: block; flex: 0 0 48px; width: 48px; height: 48px; } .brand-logo img { position: absolute; inset: 0; display: block; width: 48px; height: 48px; object-fit: contain; transition: opacity .16s ease; } .app-header-panel { position: relative; display: flex; flex: 1 1 0; flex-direction: column; min-width: 0; min-height: 112px; padding: 6px 10px; background: transparent; overflow: hidden; } -.app-header-panel, .viewer { border-radius: 10px; } +.app-header-panel, .viewer { border-radius: var(--panel-radius); } .app-header-panel-work { padding: 3px 0 0; } .work-header-row { display: flex; align-items: center; gap: 18px; min-width: 0; } .work-header-row .reader-toolbar { flex: 1 1 auto; } @@ -42,7 +42,7 @@ .app-shell > main { position: relative; display: grid; grid-template-columns: 218px minmax(0, 1fr); min-height: 0; transition: grid-template-columns .18s ease; } .app-shell.sidebar-collapsed > main { grid-template-columns: 0 minmax(0, 1fr); } aside { position: relative; z-index: 10; min-width: 0; min-height: 0; background: var(--sidebar); overflow: visible; } -.app-sidebar { display: flex; flex-direction: column; min-height: 100%; padding: 0; } +.app-sidebar { display: flex; flex-direction: column; height: 100%; min-height: 0; padding: 0; } .sidebar-theme-panel { margin-top: auto; padding: 14px 12px; } .theme-switcher { display: flex; align-items: center; justify-content: space-between; gap: 4px; padding: 4px; border: 1px solid var(--button-border); border-radius: 10px; background: var(--surface-input); } .theme-switcher-btn { display: grid; place-items: center; flex: 1 1 0; height: 28px; padding: 0; border: 1px solid transparent; border-radius: var(--btn-radius); color: var(--button-text); background: transparent; box-shadow: none; font-size: var(--ui-body-size); line-height: 1; transition: background-color .12s ease, color .12s ease, border-color .12s ease; cursor: pointer; } @@ -82,6 +82,7 @@ aside { position: relative; z-index: 10; min-width: 0; min-height: 0; background .sidebar-toggle { position: absolute; left: 0; top: 10px; z-index: 30; display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--btn-radius); color: var(--button-text); background: transparent; cursor: pointer; } .sidebar-toggle:hover { color: var(--text-primary); background: transparent; } .workspace { display: grid; grid-template-rows: minmax(0, 1fr); min-width: 0; min-height: 0; background: var(--sidebar); margin: 0; padding: 0 12px 12px 0; overflow: hidden; } +.workspace.workspace-dashboard { overflow: visible; } .workspace.graph-workspace { grid-template-rows: minmax(0, 1fr); } .workbar { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; min-width: 0; margin: 9px 0 0; padding: 8px 16px; border: 0; background: transparent; overflow: hidden; } .toolbar:disabled, .workbar:disabled { opacity: .5; } @@ -115,6 +116,7 @@ aside { position: relative; z-index: 10; min-width: 0; min-height: 0; background .viewer { position: relative; grid-row: 1; min-width: 0; min-height: 0; margin-top: 10px; background: var(--surface-app); overflow: hidden; } .pathfinder-view-host { min-width: 0; min-height: 0; height: 100%; } .viewer.is-joined-top { border-top-left-radius: 0; } +.viewer.app-area-dashboard { margin-top: calc(10px - var(--client-info-tab-height)); background: transparent; overflow: visible; } .area-landing { display: grid; place-items: center; min-height: 100%; padding: 36px; } .area-landing-card { width: min(620px, 100%); padding: 38px 42px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); box-shadow: var(--shadow); text-align: center; } .area-kicker { display: block; margin-bottom: 10px; color: var(--muted); font-size: var(--ui-label-size); font-weight: var(--ui-label-weight); letter-spacing: var(--ui-label-tracking); text-transform: uppercase; } @@ -131,9 +133,9 @@ aside { position: relative; z-index: 10; min-width: 0; min-height: 0; background @media (max-width: 1200px) { .status-line-tools { position: static; width: min(520px, 100%); margin-left: auto; transform: none; } } @media (max-width: 1050px) { .workbar { grid-template-columns: minmax(0, 1fr); } .workbar-action { grid-column: 1; justify-content: flex-end; } .records-layout { grid-template-columns: minmax(320px, 42%) minmax(0, 1fr); } } @media (max-width: 760px) { .app-shell > main { grid-template-columns: 0 minmax(0, 1fr); } .app-shell > main > aside { visibility: hidden; } .records-layout { grid-template-columns: 1fr; } .record-list { max-height: 280px; border-right: 0; border-bottom: 1px solid var(--line); } .tile-grid { grid-template-columns: minmax(0, 1fr); } } -.app-sidebar { display: flex; flex-direction: column; min-height: 100%; padding: 0; } +.app-sidebar { display: flex; flex-direction: column; height: 100%; min-height: 0; padding: 0; } .sidebar-primary-actions { order: 0; } -.sidebar-domain-rack { order: 1; } + .sidebar-theme-panel { order: 2; margin-top: auto; padding: 14px 12px; border-top: 1px solid var(--line); } .connection-indicator { display: inline-flex; align-items: center; gap: 3px; flex: 0 0 auto; } .connection-state-square { display: block; width: 9px; height: 9px; border-radius: 0; opacity: .2; } diff --git a/src/styles/legacy-views.css b/src/styles/legacy-views.css index 11e623a..36747a5 100644 --- a/src/styles/legacy-views.css +++ b/src/styles/legacy-views.css @@ -99,13 +99,13 @@ button, input, select, textarea { font-family: inherit; } cursor: pointer; transition: background .12s ease, border-color .12s ease; } -.record-card:first-child { - border-top-left-radius: var(--radius-sm); - border-top-right-radius: var(--radius-sm); +.record-card-with-group:first-child > .record-card { + border-top-left-radius: 0; + border-top-right-radius: 0; } -.record-card:last-child { - border-bottom-left-radius: var(--radius-sm); - border-bottom-right-radius: var(--radius-sm); +.record-card-with-group:last-child > .record-card { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; border-bottom: 0; } .record-card::before { diff --git a/src/styles/ui-tokens.css b/src/styles/ui-tokens.css index 9220f0b..fb1fc52 100644 --- a/src/styles/ui-tokens.css +++ b/src/styles/ui-tokens.css @@ -22,11 +22,16 @@ @layer tokens { :root { + /* Uppermost normal UI layer, including raised Results windows. */ + --z-floating-filters: 2147483647; /* ---------- Typografie ---------- */ /* Inter wird lokal mit dem Client-Build ausgeliefert. Auch Datenwerte und Rohansichten verwenden dieselbe Familie; Hierarchie entsteht nicht durch wechselnde Schriften, sondern ausschliesslich durch Groesse und Gewicht. */ --activity-panel-width: 560px; + /* User-approved distinct Results export icon roles; no selection/status meaning. */ + --results-export-all: #2684C8; + --results-export-selected: #B67B12; --filter-search-figure-size: 88px; --pathfinder-details-width: clamp(270px, 21vw, 340px); --pathfinder-stage-min-width: 360px; @@ -60,6 +65,7 @@ --segment-surface: transparent; --segment-border: transparent; --segment-radius: 10px; + --panel-radius: 10px; --segment-inset: var(--space-3); --segment-gap: var(--space-3); --tile-surface: #323238; @@ -68,7 +74,7 @@ /* Trennlinien innerhalb einer Kachel (Faktenzellen). */ --tile-divider: var(--border-subtle); --tile-selected-border: var(--marking-color); - --tile-radius: var(--radius-sm); + --tile-radius: var(--radius-md); --tile-inset: var(--space-4); --tile-compact-width: 186px; --tile-double-compact-width: calc(var(--tile-compact-width) * 2); @@ -80,6 +86,10 @@ /* Client Info uses the same theme and semantic status roles as Work. Compatibility aliases retain existing chart consumers, not a local palette. */ --client-info-canvas: var(--surface-app); + /* User-approved Client Info tabs: taller, with a gap under inactive tabs. */ + --client-info-tab-width: 150px; + --client-info-tab-height: calc(var(--btn-h) + var(--space-2)); + --client-info-tab-gap: var(--space-1); --client-info-surface: var(--surface-input); --client-info-surface-raised: var(--tile-surface); --client-info-surface-hover: var(--tile-surface-hover); @@ -115,6 +125,8 @@ --donut-rack-radius: var(--segment-radius); --donut-card-radius: var(--tile-radius); --donut-card-min-h: 196px; + --related-finding-ring-size: 16px; + --related-color: var(--line-strong); /* Overridden by the shared Results identity palette. */ --donut-ring-size: 128px; --donut-ring-stroke: 12px; --donut-progress-dash: 0 100; @@ -197,8 +209,8 @@ /* ---------- Form ---------- */ --radius-xs: 0px; /* Badges, Chips */ - --radius-sm: 0px; /* Inputs, Selects, Tiles, Zeilen */ - --radius-md: 0px; /* Overlays: Dialog, Floating Window, Popover */ + --radius-sm: 3px; /* Inputs, Selects, Tiles, Zeilen */ + --radius-md: 4px; /* Overlays: Dialog, Floating Window, Popover */ /* ---------- Verbindliche Buttonform ---------- Gilt fuer jede Schaltflaeche der App, unabhaengig von ihrer Farbe @@ -206,7 +218,7 @@ Innenabstand oben und unten. Keine Ansicht setzt eigene Buttonhoehen, -radien oder -polster. Ausgenommen sind nur zusammengesetzte Reiter (View-Tabs, Sidebar-Navigation), die als Gruppe eine eigene Form haben. */ - --btn-radius: 4px; + --btn-radius: 3px; --btn-h: 28px; --btn-pad-x: 12px; /* Groessere Radien existieren nicht. Panels und Shell: 0. diff --git a/tests/app/app-version.test.ts b/tests/app/app-version.test.ts index cbad3f4..d5612c8 100644 --- a/tests/app/app-version.test.ts +++ b/tests/app/app-version.test.ts @@ -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.16.5'); + expect(ASSESSOR_PRODUCT_VERSION).toBe('2.24.2'); expect(app).toContain("'db-structure': 'Data Flow'"); expect(app).toContain("['db-structure', 'Data Flow']"); expect(app).not.toContain("'DB Structure'"); diff --git a/tests/architecture/core-engine-boundary.test.ts b/tests/architecture/core-engine-boundary.test.ts index 8ae449f..7163f9d 100644 --- a/tests/architecture/core-engine-boundary.test.ts +++ b/tests/architecture/core-engine-boundary.test.ts @@ -56,20 +56,22 @@ const allSources = () => [...sourceFiles(join(root, 'src')), ...sourceFiles(join * readers), the type-only API contract and the server wiring of both engines. */ const EXTENSION_POINTS: Record = { + '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'] }, - 'src/api/assessor-api-contract.ts': { engine: 'Context Engine', typeOnly: true }, - 'src/features/actionlist/ActionlistControls.tsx': { engine: 'Context Engine' }, + 'src/api/assessor-api-contract.ts': { engine: ['Context Engine', 'Remediation Engine'], typeOnly: true }, + 'src/features/actionlist/ActionlistControls.tsx': { engine: ['Context Engine', 'Remediation Engine'] }, + 'src/features/finding-reader/FindingReader.tsx': { engine: ['Remediation Engine', 'Context 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' }, - 'src/features/search-filter/SearchFilterBar.tsx': { engine: 'Context Engine' }, - 'src/features/search-filter/use-filtered-records.ts': { engine: 'Context Engine' }, - 'src/features/search-filter/work-facets.ts': { engine: 'Context Engine' }, - 'src/features/sidebar/SidebarSecurityDomains.tsx': { engine: 'Context Engine' }, + 'src/features/search-filter/filter-records.ts': { engine: ['Context Engine', 'Remediation Engine'] }, + 'src/features/search-filter/SearchFilterBar.tsx': { engine: ['Context Engine', 'Remediation Engine'] }, + 'src/features/search-filter/use-filtered-records.ts': { engine: ['Context Engine', 'Remediation Engine'] }, + 'src/features/search-filter/work-facets.ts': { engine: ['Context Engine', 'Remediation Engine'] }, + 'src/features/sidebar/SidebarSecurityDomains.tsx': { engine: ['Context Engine', 'Remediation Engine'] }, 'server/assessor-http-server.ts': { engine: 'any' }, - 'server/index.ts': { engine: 'Report Engine' } + 'server/index.ts': { engine: ['Report Engine', 'Remediation Engine'] } }; // Core areas that hold data, evaluation and rules. No file in them may reach any engine, diff --git a/tests/assessment-context/context-engine.test.ts b/tests/assessment-context/context-engine.test.ts index fa13444..177a499 100644 --- a/tests/assessment-context/context-engine.test.ts +++ b/tests/assessment-context/context-engine.test.ts @@ -98,7 +98,7 @@ describe('context engine', () => { it('derives declared facts per GPO backup and aggregates conditions without assuming precedence', () => { const result = context(declared); - expect(result.engine.version).toBe('1.11.0'); + expect(result.engine.version).toBe('1.11.1'); expect(result.coverage).toEqual({ observations: 8, semanticallyMapped: 6, notSemanticallyMapped: 1, unresolvedIdentity: 1, scannerFindings: 0, scannerMapped: 0, scannerNotInCatalog: 0, scannerGuardRejected: 0, gpoEvaluatedSettings: 0, gpoMappedByVerdict: 0, gpoUncatalogued: 0, scannerMappedBySection: 0, scannerUncatalogued: 0, scannerWithDeclaredReferences: 0, scannerWithCisControlArea: 0, scannerMappedByDeclaredTechnique: 0, scannerRecognized: 0, scannerEstimated: 0, gpoRecognized: 0, gpoEstimated: 0, uncataloguedWithoutRecognition: 0 }); @@ -433,7 +433,7 @@ describe('context engine CIS control areas', () => { ['9.2 Private Profile', 'x', '1', '0', '?'], ['9.1.1 Ensure synthetic setting', '0', '1', '0', '0%']]), scores('run-client', [['9.1 Firewall: Domain', '3', '0', '0', '100%']]) ]); - expect(result.engine.version).toBe('1.11.0'); + expect(result.engine.version).toBe('1.11.1'); const area = (id: string) => result.cisControlAreas.find(item => item.id === id); expect(area('cis:run-server:9.1')).toEqual({ id: 'cis:run-server:9.1', scanRunId: 'run-server', sectionId: '9.1', parentSectionId: '9', title: 'Domain Profile', findings: 3, fail: 2, pass: 0, other: 1, reported: { pass: 0, fail: 7 } }); @@ -518,6 +518,33 @@ describe('context password guessing exposure', () => { sourceTool: tool, sourceRuleId: ruleId, outcomeNormalized: outcome, title }) as unknown as UnifiedResultProjection; + it('counts the ASSESSOR deviation of a value-semantic setting in the heatmap, as Work and Client Info count it', () => { + // Synthetic regression (Dummy 2026-10-05): MinimumPasswordLength had a declared GPO value and a Fail verdict, + // was assigned to its cluster, but the heatmap total omitted it (474 instead of 477). + const { observations, analyses } = fixture([ + { id: 'obs-len', backup: 'backup-default-domain', target: account('MinimumPasswordLength'), values: [{ kind: 'scalar', raw: '7' }] } + ]); + const identity = observations[0].policyIdentityKey!; + const verdict = (outcome: string, isDeviation: boolean) => ({ + resultProjectionId: `policy-evaluation:${outcome}`, sourceKind: 'policy-evaluation', sourceResultId: `evaluation-${outcome}`, + policyScope: 'computer', policyIdentityKey: identity, outcomeNormalized: outcome, isDeviation, title: 'MinimumPasswordLength' + }) as unknown as UnifiedResultProjection; + const build = (evaluations: UnifiedResultProjection[]) => buildAssessmentContext({ + projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z', repositoryRevision: 1, + observations, analyses, evaluations, catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG + }); + const failed = build([verdict('fail', true)]); + expect(failed.declaredDeviations).toEqual([{ scope: 'computer', identityKey: identity, resultProjectionIds: ['policy-evaluation:fail'] }]); + expect(contextHeatmapFindingCount(failed)).toBe(1); + const cell = contextHeatmap(failed).find(item => item.cluster.clusterId === 'password-guessing-exposure')!; + expect(cell.gpoDeviations).toBe(1); + expect(contextHeatmap(failed).reduce((sum, item) => sum + item.gpoDeviations, 0)).toBe(1); + // A passed verdict is no deviation. + const passed = build([verdict('pass', false)]); + expect(passed.declaredDeviations).toEqual([]); + expect(contextHeatmapFindingCount(passed)).toBe(0); + }); + it('reads exact account policy integers from GPO backups and links the password findings of every scanner', () => { const { observations, analyses } = fixture([ { id: 'obs-len', backup: 'backup-default-domain', target: account('MinimumPasswordLength'), values: [{ kind: 'scalar', raw: '7' }] }, diff --git a/tests/assessment-context/context-heatmap-ui.test.tsx b/tests/assessment-context/context-heatmap-ui.test.tsx index d4f97e4..9545a9f 100644 --- a/tests/assessment-context/context-heatmap-ui.test.tsx +++ b/tests/assessment-context/context-heatmap-ui.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom import { act } from 'react'; +import { readFileSync } from 'node:fs'; import { createRoot, type Root } from 'react-dom/client'; import { beforeEach, afterEach, expect, it, vi } from 'vitest'; import { useApp } from '../../src/app/AppProvider'; @@ -43,7 +44,18 @@ it.each(['dark', 'light'])('shows only severity, category and total findings and const tile = host.querySelector('[data-cluster="cloud-privileged-access-exposure"]')!; expect(tile.dataset.severity).toBe('critical'); expect(tile.children).toHaveLength(3); - expect(tile.textContent).toContain('Base Severity'); + const contour = readFileSync('src/features/context-layer/context-heatmap.css', 'utf8'); + expect(contour).toContain('min-height: var(--tile-h)'); + expect(contour).not.toContain('aspect-ratio:'); + expect(contour).toContain('padding: var(--tile-inset)'); + expect(contour).not.toContain('clip-path:'); + expect(contour).not.toContain('--context-hex-width'); + expect(tile.querySelector('.context-heatmap__number')?.textContent).toBe('01'); + expect(tile.querySelector('strong')?.textContent).toBe('Cloud Privileged Access Exposure'); + expect(tile.querySelector('.context-heatmap__category')?.textContent).toBe('Critical'); + expect(tile.textContent).not.toContain('Base Severity'); + expect(host.querySelector('.context-heatmap__key')).toBeNull(); + expect(host.textContent).not.toContain('Badges: Base Severity'); expect(tile.textContent).toContain('2 findings'); expect(host.textContent).not.toMatch(/supporting conditions|recognized|estimated|assigned deviations|Severity catalog/); await act(async () => tile.click()); @@ -55,7 +67,41 @@ it.each(['dark', 'light'])('shows only severity, category and total findings and expect(ranks).toEqual([...ranks].sort((a, b) => a - b)); }); -it.each(['dark', 'light'])('shows the unique findings total directly beside the title without the old subtitle or legend in %s', async theme => { +it.each(['dark', 'light'])('moves an empty High category into the High group when populated and opens its exact findings in %s', async theme => { + document.documentElement.dataset.theme = theme; + const context = syntheticHeatmapContext(); + const clusterId = 'email-spoofing-phishing-exposure'; + const cluster = context.clusters.find(item => item.clusterId === clusterId)!; + const conditionIds = new Set(cluster.conditions.map(item => item.conditionId)); + cluster.satisfied = 0; + context.observations = context.observations.filter(item => !conditionIds.has(item.conditionId)); + const onOpenCluster = vi.fn(); + await act(async () => root.render()); + let tile = host.querySelector(`[data-cluster="${clusterId}"]`)!; + expect(tile.dataset.severity).toBe('neutral'); + expect(tile.querySelector('.context-heatmap__category')?.textContent).toBe('High'); + expect(tile.textContent).toContain('0 findings'); + let cells = [...host.querySelectorAll('.context-heatmap__cell')]; + const lastMediumIndex = cells.reduce((last, item, index) => item.dataset.severity === 'medium' ? index : last, -1); + expect(cells.indexOf(tile)).toBeGreaterThan(lastMediumIndex); + const conditionId = cluster.conditions[0].conditionId; + const populated = { ...context, observations: [...context.observations, { + id: 'synthetic-email', conditionId, tool: 'purple-knight' as const, + sourceRuleId: 'SYNTHETIC', ruleText: 'Synthetic email deviation', matchedBy: 'rule-id' as const, + resultProjectionId: 'synthetic-email', occurrenceId: 'synthetic-email', outcome: 'fail' as const, + assertion: 'observed-by-scanner' as const + }] }; + await act(async () => root.render()); + tile = host.querySelector(`[data-cluster="${clusterId}"]`)!; + expect(tile.dataset.severity).toBe('high'); + expect(tile.textContent).toContain('1 finding'); + cells = [...host.querySelectorAll('.context-heatmap__cell')]; + expect(cells.indexOf(tile)).toBeLessThan(cells.findIndex(item => item.dataset.severity === 'medium')); + await act(async () => tile.click()); + expect(onOpenCluster).toHaveBeenCalledExactlyOnceWith(clusterId); +}); + +it.each(['dark', 'light'])('shows only the heading and unique findings total without the removed badge explanation in %s', async theme => { document.documentElement.dataset.theme = theme; const context = reassignedHeatmapContext(); const otherCondition = context.clusters.find(item => item.clusterId === 'adcs-escalation')!.conditions[0].conditionId; @@ -122,23 +168,30 @@ it('opens Other with the same count as its actual Work membership after GPO reas expect(records.filter(record => facet.valuesFor!(record).includes('other-deviations'))).toHaveLength(2); }); -it('toggles the real Client Info extension without changing any Core content or making requests while off', async () => { +it('toggles the real Client Info extension without changing Core Overview content or Security totals or making requests while off', async () => { const context = syntheticHeatmapContext(); const original = JSON.stringify(useApp().dataset); const request = vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(context); setEngineEnabled('context', false); await act(async () => root.render()); - const core = () => { const clone = host.cloneNode(true) as HTMLElement; clone.querySelector('.context-heatmap')?.remove(); clone.querySelector('.client-business-impact')?.remove(); return clone.innerHTML; }; + // Security now has its own published Context categories. Compare the original Overview + // surface in full, and independently preserve the authoritative Security total. + const core = () => { const clone = host.querySelector('[role="tabpanel"]')!.cloneNode(true) as HTMLElement; clone.querySelector('.context-heatmap')?.remove(); clone.querySelector('.client-business-impact')?.remove(); return clone.innerHTML; }; + const securityTotal = () => host.querySelector('.client-security-header')!.textContent; const baseline = core(); + const securityBaseline = securityTotal(); + expect(securityBaseline).toContain('1 deviations'); expect(request).not.toHaveBeenCalled(); await act(async () => setEngineEnabled('context', true)); expect(request).toHaveBeenCalledTimes(1); expect(request).toHaveBeenCalledWith('synthetic-client', ['synthetic-timeslot'], ['synthetic-run']); expect(host.querySelectorAll('.context-heatmap__cell')).toHaveLength(22); expect(core()).toBe(baseline); + expect(securityTotal()).toBe(securityBaseline); await act(async () => setEngineEnabled('context', false)); expect(host.querySelector('.context-heatmap')).toBeNull(); expect(core()).toBe(baseline); + expect(securityTotal()).toBe(securityBaseline); expect(JSON.stringify(useApp().dataset)).toBe(original); expect(useApp().setFacetFilter).not.toHaveBeenCalled(); await act(async () => setEngineEnabled('context', true)); diff --git a/tests/client-deviation-percentage-browser-check.mjs b/tests/client-deviation-percentage-browser-check.mjs new file mode 100644 index 0000000..a5acf39 --- /dev/null +++ b/tests/client-deviation-percentage-browser-check.mjs @@ -0,0 +1,84 @@ +// Synthetic-only presentation check; PLAYWRIGHT_MODULE points to installed Playwright. +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { build } from 'esbuild'; + +const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href); +const main = await readFile('src/main.tsx', 'utf8'); +const styles = [...main.matchAll(/^import '([^']+\.css)';/gm)].map(([, path]) => + `import ${JSON.stringify(path.startsWith('.') ? './src/' + path.slice(2) : path)};`).join('\n'); +const bundle = await build({ + stdin: { contents: `${styles} + import React from 'react'; + import { createRoot } from 'react-dom/client'; + import { DonutMetricCard } from './src/components/DonutMetricCard/DonutMetricCard'; + import { DatahubHistoryOverview } from './src/features/project-import/DatahubHistoryOverview'; + const root = createRoot(document.getElementById('root')); + window.renderSynthetic = percentage => root.render(
    +

    Data

    {}}/>
    +
    +
    +
    ); + window.renderDatahub = () => root.render({}} onShow={()=>{}} + onOpen={()=>{}} onSelectProject={()=>{}}/>); + window.renderSynthetic(20);`, resolveDir: process.cwd(), loader: 'tsx' }, + jsx: 'automatic', bundle: true, write: false, outdir: 'synthetic', format: 'iife', + loader: { '.woff2': 'dataurl', '.woff': 'dataurl' } +}); +const server = createServer((request, response) => { + const file = bundle.outputFiles.find(file => request.url === '/' + file.path.split(/[\\/]/).at(-1)); + response.setHeader('Content-Type', file ? (request.url.endsWith('.css') ? 'text/css' : 'text/javascript') : 'text/html'); + response.end(file?.contents ?? '
    '); +}); +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +let browser; +try { + browser = await chromium.launch({ headless: true, channel: 'chrome' }); + const page = await browser.newPage({ viewport: { width: 500, height: 450 } }); + await page.goto(`http://127.0.0.1:${server.address().port}/`); + const donut = page.locator('.client-priority-total'); + await donut.waitFor(); + for (const theme of ['dark', 'light']) { + await page.evaluate(theme => { document.documentElement.dataset.theme = theme; }, theme); + assert.equal(await donut.locator('strong').textContent(), '2'); + assert.equal(await donut.locator('.donut-metric-card__values > small').textContent(), '20%'); + assert.equal(await donut.locator('.donut-metric-card__basis').textContent(), 'Of assessed checks'); + const fit = await donut.evaluate(element => { + const basis = element.querySelector('.donut-metric-card__basis'); + const plot = element.querySelector('.donut-metric-card__plot'); + return { font: getComputedStyle(basis).fontSize, fits: basis.scrollWidth <= basis.clientWidth, + separated: basis.getBoundingClientRect().top >= plot.getBoundingClientRect().bottom - 1 }; + }); + assert.equal(fit.font, '14px'); assert.ok(fit.fits); assert.ok(fit.separated); + if (process.env.SCREENSHOT_DIR) await page.screenshot({ path: join(process.env.SCREENSHOT_DIR, `deviation-percentage-${theme}.png`) }); + } + await page.evaluate(() => window.renderSynthetic(null)); + await page.waitForFunction(() => document.querySelector('.donut-metric-card__values > small')?.textContent === '—'); + assert.equal(await donut.locator('strong').textContent(), '0'); + assert.equal(await donut.locator('.donut-metric-card__progress').count(), 0); + await page.setViewportSize({ width: 1280, height: 650 }); + await page.evaluate(() => window.renderDatahub()); + const rack = page.locator('.datahub-reference-controls'); + await rack.waitFor(); + for (const theme of ['dark', 'light']) { + await page.evaluate(theme => { document.documentElement.dataset.theme = theme; }, theme); + for (const [label, percentage, count] of [['Results All','100%','2,194'],['Deviation','58%','155'],['Passed','42%','113'],['Not Applicable','<1%','1'],['Not Run','<1%','1'],['Error','<1%','9']]) { + const card = rack.locator(`[aria-label^="${label}:"]`); + assert.equal(await card.locator('strong').textContent(), count); + assert.equal(await card.locator('.donut-metric-card__values > small').textContent(), percentage); + const caption = card.locator('.donut-metric-card__basis'); + assert.equal(await caption.textContent(), ['Deviation', 'Passed'].includes(label) ? 'Of assessed checks' : 'Of all records'); + assert.ok(await caption.evaluate(element => element.scrollWidth <= element.clientWidth)); + } + if (process.env.SCREENSHOT_DIR) await page.screenshot({ path: join(process.env.SCREENSHOT_DIR, `datahub-percentages-${theme}.png`) }); + } + console.log('Synthetic Chrome percentage presentation: 20% and count 2, caption fits in both themes; unavailable denominator retains count 0 with dash.'); +} finally { await browser?.close(); await new Promise(resolve => server.close(resolve)); } diff --git a/tests/components/engines-control.test.tsx b/tests/components/engines-control.test.tsx index 91af26b..596b318 100644 --- a/tests/components/engines-control.test.tsx +++ b/tests/components/engines-control.test.tsx @@ -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('input')]; - expect(checks.map(input => input.checked)).toEqual([true, true, true]); - expect(checks.map(input => input.disabled)).toEqual([true, false, false]); + expect(checks.map(input => input.checked)).toEqual([true, true, true, true]); + expect(checks.map(input => input.disabled)).toEqual([true, false, false, false]); await act(async () => { checks[0].click(); checks[1].click(); }); - expect(readEnginePreferences()).toEqual({ core: true, context: false, report: true }); - checks[2].focus(); + expect(readEnginePreferences()).toEqual({ core: true, context: false, report: true, remediation: true }); + checks[3].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 }); + expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true }); setEngineEnabled('report', false); setEngineEnabled('core', false); resetEnginePreferencesForTests(); - expect(readEnginePreferences()).toEqual({ core: true, context: true, report: false }); + expect(readEnginePreferences()).toEqual({ core: true, context: true, report: false, remediation: true }); localStorage.setItem(ENGINE_PREFERENCES_KEY, '{"core":false,"context":false}'); resetEnginePreferencesForTests(); - expect(readEnginePreferences()).toEqual({ core: true, context: false, report: true }); + expect(readEnginePreferences()).toEqual({ core: true, context: false, report: true, remediation: 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 }); + expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: 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 }); + expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true }); expect(() => setEngineEnabled('context', false)).not.toThrow(); expect(readEnginePreferences().context).toBe(false); }); diff --git a/tests/design-language-check.mjs b/tests/design-language-check.mjs index 212aee1..e09f5d1 100644 --- a/tests/design-language-check.mjs +++ b/tests/design-language-check.mjs @@ -77,8 +77,8 @@ const prohibitions = [ matches: line => /border-radius:\s*([5-9]|[1-9][0-9])px|border-radius:\s*[0-9.]+rem/.test(line), remedy: 'Erlaubt sind --radius-xs (2px), --radius-sm (3px) und --radius-md (4px); Panels und Shell tragen 0.', budgets: new Map([ - ['import-workflow.css', 4], - ['layout.css', 3] + ['import-workflow.css', 3], + ['layout.css', 2] ]) }, { diff --git a/tests/dummy-package-cluster-audit.mjs b/tests/dummy-package-cluster-audit.mjs new file mode 100644 index 0000000..05df448 --- /dev/null +++ b/tests/dummy-package-cluster-audit.mjs @@ -0,0 +1,23 @@ +// Dummy-only identity audit through the existing application API; no import or Core write. +import assert from 'node:assert/strict'; +const base='http://127.0.0.1:4173/api/v1'; +const get=async path=>{const response=await fetch(base+path);if(!response.ok)throw new Error(path+': '+response.status+' '+await response.text());return response.json();}; +const project=await get('/projects/project-7cbcb7ad');assert.equal(project.name,'Dummy'); +const assessments=await get('/projects/'+project.id+'/assessments'); +const query=new URLSearchParams();assessments.forEach(a=>query.append('assessmentIds',a.id)); +const context=await get('/projects/'+project.id+'/assessment-context?'+query); +const response=await get('/projects/'+project.id+'/work-packages?'+query);assert.equal(response.status,'ready'); +const snapshot=response.result;assert.equal(snapshot.repositoryRevision,context.repositoryRevision); +const labels=new Map(context.clusters.map(cluster=>[cluster.clusterId,cluster.label.en])); +const primary=new Map([...context.findings.map(f=>['scanner:'+f.occurrenceId,f.clusterIds[0]]), + ...context.settings.map(s=>['policy:'+s.scope+':'+s.identityKey,s.clusterIds[0]])]); +const assigned=new Set(),counts={};let tasks=0; +for(const p of snapshot.packages){ + assert.equal(p.clusterLabel,labels.get(p.clusterId),'Cluster label mismatch'); + for(const key of p.findingKeys){assert.equal(primary.get(key),p.clusterId,'Wrong cluster for '+key);assert.equal(assigned.has(key),false,'Duplicate package membership');assigned.add(key);} + assert.deepEqual([...new Set(p.tasks.flatMap(task=>task.findingKeys))].sort(),[...p.findingKeys].sort(),'Task/package evidence mismatch'); + counts[p.clusterId]??={packages:0,findings:0,tasks:0};counts[p.clusterId].packages++;counts[p.clusterId].findings+=p.findingKeys.length;counts[p.clusterId].tasks+=p.tasks.length;tasks+=p.tasks.length; +} +for(const key of snapshot.unassignedFindingKeys)assert.equal(assigned.has(key),false,'Assigned and Unassigned overlap'); +const after=await get('/projects/'+project.id+'/assessment-context?'+query);assert.equal(after.repositoryRevision,context.repositoryRevision); +console.log(JSON.stringify({packages:snapshot.packages.length,assigned:assigned.size,tasks,unassigned:snapshot.unassignedFindingKeys.length,allIndividualClusterIdsMatch:true,repositoryRevisionUnchanged:true,counts},null,2)); diff --git a/tests/dummy-results-audit.mjs b/tests/dummy-results-audit.mjs new file mode 100644 index 0000000..b967bfe --- /dev/null +++ b/tests/dummy-results-audit.mjs @@ -0,0 +1,46 @@ +// Read-only full Dummy scanner Results inspection; never reads another project. +import { build } from 'esbuild'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; +const bundle = await build({ stdin: { resolveDir: process.cwd(), loader: 'ts', contents: ` +import { canonicalModelsToDataset } from './src/features/project-import/canonical-model-to-dataset'; +import { recordResultSheets } from './src/features/linked-results/record-results'; +import { resultOutcomeFor, resultSheetRowToneFor } from './src/features/linked-results/result-message'; +import { toolIdForRecord } from './src/importers/status-normalization'; +import { inlineResultTableSheets } from './src/features/linked-results/inline-result-tables'; +import { resultSectionRowClassName } from './src/features/linked-results/result-message'; +import { loadCanonicalProjectWorkspace } from './src/features/project-import/load-canonical-project-workspace'; +import { AssessorApiClient } from './src/api/assessor-api-client'; +const base='http://127.0.0.1:4173/api/v1'; +const get=async path=>{const response=await fetch(base+path);if(!response.ok)throw new Error(path+': '+response.status);return response.json();}; +const project=await get('/projects/project-7cbcb7ad');if(project.name!=='Dummy')throw new Error('Only Dummy permitted'); +const assessments=await get('/projects/'+project.id+'/assessments'); +const models=[]; for(const a of assessments) models.push(await get('/assessments/'+a.id+'/model')); +const api=new AssessorApiClient({baseUrl:base,describeActivity:()=>undefined}); +const workspace=await loadCanonicalProjectWorkspace({ + listProjectAssessments:async()=>assessments,loadAssessmentModel:async id=>models.find(m=>m.assessment.id===id), + queryUnifiedResults:q=>api.queryUnifiedResults(q),listPolicyEvaluations:id=>api.listPolicyEvaluations(id), + getPolicyAnalysisOverview:id=>api.getPolicyAnalysisOverview(id) +},project.id,undefined,assessments.map(a=>a.id),{includeHistoricalScores:false,includeBaselineGuidance:false}); +const dataset=workspace.dataset; const counts={}; const findings=[]; +for(const sheet of dataset.sheets.filter(s=>s.isProjectSheet))for(const record of sheet.analysis.records){ + const tool=toolIdForRecord(record,sheet.toolId); const tables=recordResultSheets(record,dataset.sheets,sheet.id,tool); + const inline=tool==='maester'?inlineResultTableSheets(record):[]; + const key=tool??'mixed';counts[key]??={findings:0,tables:0,rows:0};counts[key].findings++;counts[key].tables+=tables.length; + for(const table of tables)counts[key].rows+=table.analysis.records.length; + for(const table of inline) { counts[key].inlineRows=(counts[key].inlineRows??0)+table.analysis.records.length; + for(const row of table.analysis.records){const tone=resultSectionRowClassName(tool,table.analysis.headers,row.fields.map(f=>f.value))??'neutral';counts[key][tone]=(counts[key][tone]??0)+1;} } + if(tables.length) findings.push({tool,id:record.id,title:record.title,outcome:resultOutcomeFor(record,tool), + binding:tool==='purple-knight'?{source:record.sourceFile,fields:record.fields.filter(f=>/file|datei|results?/i.test(f.label)),tables:tables.map(t=>({file:t.sourceFile,index:t.sourceTableIndex}))}:undefined, + rule:record.fields.filter(f=>/rule id|risk id|shortname/i.test(f.label)).map(f=>[f.label,f.value]), + links:record.fields.filter(f=>f.label==='Verwandte Daten').map(f=>f.value), + tables:tables.map(t=>({id:t.id,name:t.name,headers:t.analysis.headers,rows:t.analysis.records.length, + samples:t.analysis.records.slice(0,2).map(r=>r.fields.map(f=>f.value)), + tones:t.analysis.records.reduce((a,r)=>{const tone=resultSheetRowToneFor(tool,r,resultOutcomeFor(record,tool),{finding:record,sheet:t})??'neutral';a[tone]=(a[tone]??0)+1;return a;},{})}))}); +} +console.log(JSON.stringify({counts,findings},null,2)); +` }, bundle:true,platform:'node',format:'esm',packages:'external',write:false }); +await mkdir('node_modules/.cache',{recursive:true}); +const target=resolve('node_modules/.cache/dummy-results-audit.mjs');await writeFile(target,bundle.outputFiles[0].contents); +await import(pathToFileURL(target).href); diff --git a/tests/dummy-results-excel-audit.mjs b/tests/dummy-results-excel-audit.mjs new file mode 100644 index 0000000..1257e87 --- /dev/null +++ b/tests/dummy-results-excel-audit.mjs @@ -0,0 +1,42 @@ +// Read-only audit of the full Dummy through the normal Results presentation and Excel exporter. +import { build } from 'esbuild'; +import { mkdir, writeFile, readFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +import { resolve } from 'node:path'; +const previous=await readFile('tests/dummy-results-audit.mjs','utf8'); +const loader=previous.split('contents: `')[1].split('const dataset=workspace.dataset;')[0]; +const source=`${loader} +import React from 'react';import {renderToStaticMarkup} from 'react-dom/server'; +import {JSDOM} from 'jsdom';import {read,utils} from 'xlsx'; +import {FloatingResultsWindow} from './src/features/linked-results/FloatingResultsWindow'; +import {resultSectionsFor} from './src/features/linked-results/result-message'; +import {resultsExcelContent} from './src/features/linked-results/results-excel-content'; +import {buildResultsWindowExcel} from './src/exports/results-window-excel'; +const dom=new JSDOM(''); +Object.assign(globalThis,{window:dom.window,document:dom.window.document,DOMParser:dom.window.DOMParser,Node:dom.window.Node}); +const counts={};let maxRows=0; +for(const sheet of workspace.dataset.sheets.filter(s=>s.isProjectSheet))for(const record of sheet.analysis.records){ + const tool=toolIdForRecord(record,sheet.toolId);const sheets=recordResultSheets(record,workspace.dataset.sheets,sheet.id,tool);const sections=resultSectionsFor(record,tool); + if(!sheets.length&&!sections.length)continue; + const before=JSON.stringify({sheets,sections}); + const html=renderToStaticMarkup({}} onClose={()=>{}}/>); + const host=document.createElement('div');host.innerHTML=html; + const content=resultsExcelContent(host.querySelector('.results-window-body'),record.title,sections,sheets); + const full=read(buildResultsWindowExcel(content),{type:'array'}); + const exportedNames=full.SheetNames.filter(name=>/^Results(?: \\d+)?$/.test(name)); + if(exportedNames.length!==content.tables.length)throw new Error('Table loss: '+record.id); + for(let index=0;index/^Results(?: \\d+)?$/.test(name)).length!==1)throw new Error('Subset loss');} + if(JSON.stringify({sheets,sections})!==before)throw new Error('Source mutation'); + counts[tool]??={findings:0,tables:0,rows:0};counts[tool].findings++;counts[tool].tables+=content.tables.length;counts[tool].rows+=content.tables.reduce((n,t)=>n+Math.max(0,t.rows.length-1),0); +} +console.log(JSON.stringify({counts,maxRows,allCellsUnchanged:true},null,2));dom.window.close();`; +const bundle=await build({stdin:{resolveDir:process.cwd(),loader:'tsx',contents:source},jsx:'automatic',bundle:true,platform:'node',format:'esm',packages:'external',write:false}); +await mkdir('node_modules/.cache',{recursive:true});const target=resolve('node_modules/.cache/dummy-results-excel-audit.mjs');await writeFile(target,bundle.outputFiles[0].contents);await import(pathToFileURL(target).href); diff --git a/tests/exports/results-excel-format.test.ts b/tests/exports/results-excel-format.test.ts new file mode 100644 index 0000000..a39f6bb --- /dev/null +++ b/tests/exports/results-excel-format.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment jsdom +import { CFB, read, utils } from 'xlsx'; +import { describe, expect, it } from 'vitest'; +import { buildResultsWindowExcel } from '../../src/exports/results-window-excel'; + +const content = { title: 'Affected synthetic servers', text: 'Source prose', sourceSections: [{ title: 'Evidence', value: 'Original source' }], tables: [ + { id: 'first', title: '1 Domain controllers', rows: [['Host', 'Value'], ['DUMMY-DC', '=literal']] }, + { id: 'second', title: '2 Accounts', rows: [['Account'], ['000123']] } +] }; + +describe.each([undefined, new Set(['second'])])('Results Excel title and order (%s)', selected => { + it('starts with actual Results and writes a larger bold white-on-black merged title above column headers', () => { + const bytes = buildResultsWindowExcel(content, selected); + const workbook = read(bytes, { type: 'array', cellStyles: true }); + const expected = selected ? ['Results', 'Export info'] : ['Results', 'Results 02', 'Source text', 'Export info']; + expect(workbook.SheetNames).toEqual(expected); + const archive = CFB.read(new Uint8Array(bytes), { type: 'array' }); + const xml = (path: string) => new DOMParser().parseFromString(new TextDecoder().decode(new Uint8Array(CFB.find(archive, `/${path}`).content)), 'application/xml'); + const styles = xml('xl/styles.xml'); + expect(styles.querySelector('parsererror')).toBeNull(); + for (let index = 0; index < workbook.SheetNames.length; index++) { + const name = workbook.SheetNames[index]; const sheet = workbook.Sheets[name]; + const title = sheet.A1.v as string; + const table = (selected ? content.tables.slice(1) : content.tables)[index]; + const isTable = index < (selected ? 1 : 2); + expect(title).toBe(isTable ? `${content.title}\n${table.title}` : content.title); + if (isTable) expect(utils.sheet_to_json(sheet, { header: 1, defval: '' }).slice(1, 1 + table.rows.length)).toEqual(table.rows); + expect(sheet['!autofilter']!.ref).toMatch(/^A2:/); + const part = xml(`xl/worksheets/sheet${index + 1}.xml`); + const cell = part.querySelector('c[r="A1"]')!; + const xf = styles.querySelector('cellXfs')!.children[Number(cell.getAttribute('s'))]; + const font = styles.querySelector('fonts')!.children[Number(xf.getAttribute('fontId'))]; + expect(font.querySelector('b')).not.toBeNull(); + expect(font.querySelector('sz')!.getAttribute('val')).toBe('16'); + expect(font.querySelector('color')!.getAttribute('rgb')).toBe('FFFFFFFF'); + const fill = styles.querySelector('fills')!.children[Number(xf.getAttribute('fillId'))]; + expect(fill.querySelector('patternFill')!.getAttribute('patternType')).toBe('solid'); + expect(fill.querySelector('fgColor')!.getAttribute('rgb')).toBe('FF000000'); + expect(xf.querySelector('alignment')!.getAttribute('wrapText')).toBe('1'); + expect(Number(part.querySelector('row[r="1"]')!.getAttribute('ht'))).toBeGreaterThanOrEqual(48); + if (isTable && table.rows[0].length > 1) expect(sheet['!merges']).toEqual([{ s: { r: 0, c: 0 }, e: { r: 0, c: 1 } }]); + } + if (!selected) { expect(workbook.Sheets.Results.B3.v).toBe('=literal'); expect(workbook.Sheets.Results.B3.f).toBeUndefined(); expect(workbook.Sheets.Results.A6.v).toBe(content.text); expect(workbook.Sheets.Results['!autofilter']?.ref).toBe('A2:B3'); } + }); +}); + +it('keeps a text-only Result first and gives its complete long finding title enough heading height', () => { + const title = 'Long synthetic finding '.repeat(12); + const bytes = buildResultsWindowExcel({ title, text: 'Entire Result text', tables: [], sourceSections: [] }); + const workbook = read(bytes, { type: 'array', cellStyles: true }); + expect(workbook.SheetNames).toEqual(['Results', 'Export info']); + expect(workbook.Sheets.Results.A1.v).toBe(title); + expect(workbook.Sheets.Results.A3.v).toBe('Entire Result text'); + const archive = CFB.read(new Uint8Array(bytes), { type: 'array' }); + const part = new DOMParser().parseFromString(new TextDecoder().decode(new Uint8Array(CFB.find(archive, '/xl/worksheets/sheet1.xml').content)), 'application/xml'); + expect(part.querySelector('c[r="A1"]')!.hasAttribute('s')).toBe(true); + expect(Number(part.querySelector('row[r="1"]')!.getAttribute('ht'))).toBeGreaterThan(48); +}); + +it('writes the original styled finding heading on every auxiliary sheet including long-text parts', () => { + const bytes = buildResultsWindowExcel({ ...content, findingContext: [{section:'Description',label:'Description',value:'Complete description '.repeat(2000)}] }); + const workbook = read(bytes, {type:'array'}); + expect(workbook.SheetNames).toEqual(['Results','Results 02','Source text','Finding context','Export info','Long text']); + const archive = CFB.read(new Uint8Array(bytes), {type:'array'}); + const xml = (path:string) => new DOMParser().parseFromString(new TextDecoder().decode(new Uint8Array(CFB.find(archive, `/${path}`).content)), 'application/xml'); + const styles = xml('xl/styles.xml'); + workbook.SheetNames.forEach((name,index) => { + expect(workbook.Sheets[name].A1.v).toContain(content.title); + const part=xml(`xl/worksheets/sheet${index+1}.xml`); + const xf=styles.querySelector('cellXfs')!.children[Number(part.querySelector('c[r="A1"]')!.getAttribute('s'))]; + const font=styles.querySelector('fonts')!.children[Number(xf.getAttribute('fontId'))]; + const fill=styles.querySelector('fills')!.children[Number(xf.getAttribute('fillId'))]; + expect(font.querySelector('b')).not.toBeNull(); + expect(font.querySelector('color')!.getAttribute('rgb')).toBe('FFFFFFFF'); + expect(fill.querySelector('fgColor')!.getAttribute('rgb')).toBe('FF000000'); + }); +}); diff --git a/tests/features/client-business-impact.test.tsx b/tests/features/client-business-impact.test.tsx index 8a6e0d0..305c0de 100644 --- a/tests/features/client-business-impact.test.tsx +++ b/tests/features/client-business-impact.test.tsx @@ -17,7 +17,7 @@ import { BUSINESS_IMPACT_FACET_ID } from '../../src/features/search-filter/busin import type { ImportedDataset } from '../../src/domain/dataset'; import { selectWorkFacets } from '../../src/features/search-filter/work-facets'; import { DynamicFilterControls } from '../../src/features/search-filter/DynamicFilterControls'; -vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() })); +vi.mock('../../src/app/AppProvider', () => { const useApp = vi.fn(); return { useApp, useOptionalApp: useApp }; }); vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => Synthetic assessment })); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); delete document.documentElement.dataset.theme; }); @@ -88,8 +88,8 @@ it.each(['dark', 'light'])('places four disjoint theme bars below Security Domai expect(JSON.stringify(dataset)).toBe(before); await act(async () => setEngineEnabled('context', true)); await act(async () => root.render()); - const sidebarTheme = host.querySelector('.sidebar-business-impact button') as HTMLButtonElement; - expect(host.querySelectorAll('.sidebar-business-impact button')).toHaveLength(4); + const sidebarTheme = host.querySelector('[data-filter-id="report:business-impact"] button') as HTMLButtonElement; + expect(host.querySelectorAll('[data-filter-id="report:business-impact"] button')).toHaveLength(4); expect(sidebarTheme.textContent).toBe('Control & trust2'); await act(async () => sidebarTheme.click()); expect(app.setFacetFilter).toHaveBeenLastCalledWith(BUSINESS_IMPACT_FACET_ID, selected); @@ -105,7 +105,7 @@ it.each(['dark', 'light'])('places four disjoint theme bars below Security Domai app.facetFilters = { [BUSINESS_IMPACT_FACET_ID]: selected, [CONTEXT_CLUSTER_FACET_ID]: contextSelection }; await act(async () => root.render(workFilters())); expect(host.querySelector('select[aria-label="Context Cluster"]')?.value).toBe('adcs-escalation'); - const activeTheme = host.querySelector('.sidebar-business-impact button') as HTMLButtonElement; + const activeTheme = host.querySelector('[data-filter-id="report:business-impact"] button') as HTMLButtonElement; expect(activeTheme.getAttribute('aria-pressed')).toBe('true'); await act(async () => activeTheme.click()); expect(app.setFacetFilter).toHaveBeenLastCalledWith(BUSINESS_IMPACT_FACET_ID, null); @@ -117,6 +117,6 @@ it.each(['dark', 'light'])('places four disjoint theme bars below Security Domai expect(app.setFacetFilter).toHaveBeenLastCalledWith(BUSINESS_IMPACT_FACET_ID, selected); await act(async () => root.render()); await act(async () => setEngineEnabled('report', false)); - expect(host.querySelector('.sidebar-business-impact')).toBeNull(); + expect(host.querySelector('[data-filter-id="report:business-impact"]')).toBeNull(); } finally { act(() => root.unmount()); host.remove(); } }); diff --git a/tests/features/client-deviation-percentage.test.tsx b/tests/features/client-deviation-percentage.test.tsx new file mode 100644 index 0000000..f0bd5b8 --- /dev/null +++ b/tests/features/client-deviation-percentage.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useApp } from '../../src/app/AppProvider'; +import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences'; +import { AssessorApiClient } from '../../src/api/assessor-api-client'; +import { DashboardPanel } from '../../src/features/dashboard/DashboardPanel'; +import { makeSheet } from '../../src/importers/sheet-factory'; +import { genericProfile } from '../../src/profiles/generic/generic-profile'; +import { clientFactsSnapshot, clientFactsState } from '../fixtures/client-info-facts'; +import { donutArcPath } from '../../src/components/DonutMetricCard/donut-arc'; + +vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() })); +vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => Synthetic timeslot })); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren(); localStorage.clear(); resetEnginePreferencesForTests(); }); + +describe.each(['dark', 'light'])('Client Info assessed deviation percentage (%s)', theme => { + it('keeps 2 deviations at 20% after adding 90 unassessed records and diagnostic flags, and opens the same Work filter', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + document.documentElement.dataset.theme = theme; + localStorage.clear(); resetEnginePreferencesForTests(); + setEngineEnabled('context', false); setEngineEnabled('report', false); + vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue(clientFactsSnapshot); + vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never); + const rows = [ + ['Name', 'Status', 'Conflict', 'Duplicates'], + ['Deviation A', 'Failed', '', ''], ['Deviation B', 'Investigate', '', ''], + ...Array.from({ length: 8 }, (_, index) => [`Passed ${index}`, 'Passed', '', '']) + ]; + const state = clientFactsState(); + const statusFilter = vi.fn(); + const openWork = vi.fn(); + const renderState = (input: string[][]) => { + const sheet = makeSheet('Synthetic', input, '', 'synthetic.csv', { isProjectSheet: true }); + vi.mocked(useApp).mockReturnValue({ ...state, dataset: { ...state.dataset, sheets: [sheet] }, + activeSheet: sheet, activeProfile: genericProfile, setStatusFilter: statusFilter + } as unknown as ReturnType); + return JSON.stringify(useApp().dataset); + }; + const container = document.createElement('div'); document.body.append(container); + const root = createRoot(container); + try { + const firstInput = renderState(rows); + await act(async () => root.render()); + const overviewTab = container.querySelector('[role="tab"][aria-selected="true"]')!; + expect(overviewTab.textContent).toBe('Overview'); + const securityTab = Array.from(container.querySelectorAll('[role="tab"]')).find(tab => tab.textContent === 'Security')!; + const overviewPanel = container.querySelector('[role="tabpanel"]')!; + await act(async () => securityTab.click()); + expect(overviewPanel.hidden).toBe(true); + const securityPanel = container.querySelector('[role="tabpanel"]:not([hidden])')!; + expect(securityPanel.querySelector('h2')?.textContent).toBe('Security findings'); + await act(async () => overviewTab.click()); + expect(overviewPanel.hidden).toBe(false); + expect(JSON.stringify(useApp().dataset)).toBe(firstInput); + const check = () => { + const donut = container.querySelector('.client-priority-total')!; + const dataTile = donut.closest('.client-priority')!; + expect(dataTile.querySelector('h2')?.textContent).toBe('Data'); + expect(dataTile.querySelector('.client-priority-context')).not.toBeNull(); + expect(dataTile.querySelectorAll('.client-data-fact')).toHaveLength(6); + expect(dataTile.querySelector('.client-overall')).toBeNull(); + expect(dataTile.querySelector('.client-domains')).toBeNull(); + expect(donut.querySelector('strong')?.textContent).toBe('2'); + expect(donut.querySelector('.donut-metric-card__values > small')?.textContent).toBe('20%'); + expect(donut.querySelector('.donut-metric-card__basis')?.textContent).toBe('Of assessed checks'); + expect(donut.querySelector('.donut-metric-card__progress')?.getAttribute('d')).toBe(donutArcPath(20)); + return donut; + }; + check(); + const score = container.querySelector('.client-overall-ring')?.textContent; + expect(JSON.stringify(useApp().dataset)).toBe(firstInput); + const unassessed = ['Not configured', 'No baseline rule', 'Not comparable', 'Review required', 'Not run', 'Error', 'Unknown', 'Not applicable', 'Skipped']; + const extended = rows.map(row => [...row]); + extended[1][2] = 'Yes'; extended[1][3] = 'Yes'; extended[3][2] = 'Yes'; + extended.push(...Array.from({ length: 90 }, (_, index) => [`Unassessed ${index}`, unassessed[index % unassessed.length], '', ''])); + const secondInput = renderState(extended); + await act(async () => root.render()); + const donut = check(); + expect(container.querySelector('.client-overall-ring')?.textContent).toBe(score); + expect(container.querySelector('.client-priority-context')?.textContent).toContain('8Passed'); + await act(async () => donut.click()); + expect(statusFilter).toHaveBeenCalledWith('deviation'); + expect(openWork).toHaveBeenCalledOnce(); + expect(JSON.stringify(useApp().dataset)).toBe(secondInput); + } finally { await act(async () => root.unmount()); } + }); + + it('shows an unavailable percentage while preserving zero deviations when nothing is assessed', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + document.documentElement.dataset.theme = theme; + localStorage.clear(); resetEnginePreferencesForTests(); + setEngineEnabled('context', false); setEngineEnabled('report', false); + vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue(clientFactsSnapshot); + vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never); + const state = clientFactsState(); + const sheet = makeSheet('Synthetic', [['Name', 'Status'], ['Missing', 'Not configured']], '', 'synthetic.csv', { isProjectSheet: true }); + vi.mocked(useApp).mockReturnValue({ ...state, dataset: { ...state.dataset, sheets: [sheet] }, activeSheet: sheet } as unknown as ReturnType); + const container = document.createElement('div'); const root = createRoot(container); + try { + await act(async () => root.render()); + const donut = container.querySelector('.client-priority-total')!; + expect(donut.querySelector('strong')?.textContent).toBe('0'); + expect(donut.querySelector('.donut-metric-card__values > small')?.textContent).toBe('—'); + expect(donut.querySelector('.donut-metric-card__progress')).toBeNull(); + } finally { await act(async () => root.unmount()); } + }); +}); diff --git a/tests/features/client-info-action-colors.test.tsx b/tests/features/client-info-action-colors.test.tsx index db11b71..2a2a6ad 100644 --- a/tests/features/client-info-action-colors.test.tsx +++ b/tests/features/client-info-action-colors.test.tsx @@ -34,6 +34,9 @@ it.each(['dark', 'light'])('removes only the Client Information button and keeps await act(async () => root.render()); const actions = [...host.querySelectorAll('.client-report-actions > button')]; expect(actions.map(button => button.textContent)).toEqual(['Management Report', 'Technical Report']); + const tools = host.querySelector('.client-overview-tools')!; + expect(Array.from(tools.children).map(child => child.className)).toEqual(['client-report-actions', 'client-overview-source']); + expect(host.querySelector('.client-overview-header > .client-report-actions')).toBeNull(); expect(host.querySelector('[aria-label="Open Client Information results"]')).toBeNull(); expect(actions.every(button => button.classList.contains('client-information-action'))).toBe(true); expect(host.querySelectorAll('.client-information-action')).toHaveLength(2); diff --git a/tests/features/client-info-tabs.test.tsx b/tests/features/client-info-tabs.test.tsx new file mode 100644 index 0000000..816e7d2 --- /dev/null +++ b/tests/features/client-info-tabs.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { readFileSync } from 'node:fs'; +import { createRoot } from 'react-dom/client'; +import { describe, expect, it, vi } from 'vitest'; +import { ClientInfoTabs } from '../../src/features/dashboard/ClientInfoTabs'; + +describe.each(['dark', 'light'])('Client Info attached tabs (%s)', theme => { + it('keeps the tablist outside the scrolling panel and preserves the scrolled Overview through a tab switch', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + document.documentElement.dataset.theme = theme; + const style = document.createElement('style'); + style.textContent = readFileSync('src/styles/datahub.css', 'utf8') + readFileSync('src/styles/layout.css', 'utf8'); document.head.append(style); + const host = document.createElement('div'); document.body.append(host); + host.className = 'workspace workspace-dashboard'; + const root = createRoot(host); + try { + await act(async () => root.render(

    Synthetic row {i}

    ) }, + { id: 'security', label: 'Security' } + ]}/>
    )); + const wrapper = host.querySelector('.client-info-tabs')!; + const tablist = host.querySelector('[role="tablist"]')!; + const panels = host.querySelectorAll('[role="tabpanel"]'); + const tabs = host.querySelectorAll('[role="tab"]'); + expect(getComputedStyle(wrapper).height).toBe('100%'); + expect(getComputedStyle(wrapper).overflow).toBe('hidden'); + expect(getComputedStyle(tablist).flex).toBe('0 0 auto'); + expect(getComputedStyle(tablist).paddingTop).toBe('0px'); + expect(getComputedStyle(tabs[0]).borderTopLeftRadius).toBe('var(--panel-radius)'); + expect(getComputedStyle(tabs[1]).borderTopLeftRadius).not.toBe('var(--panel-radius)'); + expect(readFileSync('src/styles/layout.css', 'utf8')).toContain('.app-header-panel, .viewer { border-radius: var(--panel-radius); }'); + expect(readFileSync('src/styles/ui-tokens.css', 'utf8')).toContain('--panel-radius: 10px;'); + expect(getComputedStyle(host).overflow).toBe('visible'); + expect(getComputedStyle(host.querySelector('.viewer')!).overflow).toBe('visible'); + expect(readFileSync('src/styles/layout.css', 'utf8')).toMatch(/\.viewer\.app-area-dashboard\s*\{[^}]*margin-top:\s*calc\(10px - var\(--client-info-tab-height\)\)/); + expect(getComputedStyle(panels[0]).overflowY).toBe('auto'); + expect(panels[0].contains(tablist)).toBe(false); + panels[0].scrollTop = 400; + await act(async () => panels[0].dispatchEvent(new Event('scroll'))); + expect(tabs[0].getAttribute('aria-selected')).toBe('true'); + await act(async () => tabs[1].click()); + expect(panels[1].hidden).toBe(false); + await act(async () => tabs[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }))); + expect(panels[0].hidden).toBe(false); + expect(panels[0].scrollTop).toBe(400); + expect(getComputedStyle(tablist).display).toBe('flex'); + } finally { await act(async () => root.unmount()); host.remove(); style.remove(); vi.unstubAllGlobals(); } + }); + it('starts on Overview, opens an empty Security panel and preserves Overview through mouse and keyboard switches', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + document.documentElement.dataset.theme = theme; + const container = document.createElement('div'); document.body.append(container); + const root = createRoot(container); + try { + await act(async () => root.render( }, + { id: 'security', label: 'Security' }, + { id: 'future', label: 'Future', content: 'Synthetic future panel' } + ]}/>)); + const tabs = Array.from(container.querySelectorAll('[role="tab"]')); + const panels = Array.from(container.querySelectorAll('[role="tabpanel"]')); + expect(tabs.map(tab => tab.className)).toEqual(Array(3).fill('client-info-tab')); + expect(tabs.map(tab => tab.getAttribute('aria-selected'))).toEqual(['true', 'false', 'false']); + const input = container.querySelector('input')!; + input.value = 'changed'; + await act(async () => tabs[1].click()); + expect(panels.map(panel => panel.hidden)).toEqual([true, false, true]); + expect(panels[1].childNodes).toHaveLength(0); + expect(tabs[1].getAttribute('aria-controls')).toBe(panels[1].id); + await act(async () => tabs[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))); + expect(panels[2].hidden).toBe(false); + expect(document.activeElement).toBe(tabs[2]); + await act(async () => tabs[2].dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }))); + expect(panels[0].hidden).toBe(false); + expect(input.value).toBe('changed'); + expect(tabs.map(tab => tab.tabIndex)).toEqual([0, -1, -1]); + await act(async () => tabs[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }))); + expect(panels[2].hidden).toBe(false); + } finally { await act(async () => root.unmount()); container.remove(); vi.unstubAllGlobals(); } + }); +}); diff --git a/tests/features/client-info-workspace.test.tsx b/tests/features/client-info-workspace.test.tsx index fc9de5d..706c22d 100644 --- a/tests/features/client-info-workspace.test.tsx +++ b/tests/features/client-info-workspace.test.tsx @@ -57,21 +57,32 @@ it.each(['dark', 'light'])('restores the previous Client Info layout, results, C try { await act(async () => root.render()); const left = host.querySelector('.client-posture')!; + const overview = host.querySelector('.client-overview-main')!; + expect(overview.children).toHaveLength(2); + const summary = overview.children[0]; + expect(summary.classList.contains('client-overview-summary')).toBe(true); + for (const selector of ['.client-overall', '.client-domains', '.client-priority-findings']) { + expect(summary.querySelector(selector)).not.toBeNull(); + } + expect(overview.children[1].classList.contains('client-domain-information')).toBe(true); + expect(summary.querySelector('.client-domain-information')).toBeNull(); expect(host.querySelector('.client-analysis-column')).toBeNull(); expect(host.querySelector('.client-priority-summary')).toBeNull(); const right = host.querySelector('.client-priority')!; + // Data belongs to the summary tile, not the shell's separately painted sidebar. + expect(right.matches('aside')).toBe(false); expect(left.querySelector('.client-overall')).not.toBeNull(); expect(left.querySelectorAll('.client-domains li')).toHaveLength(6); expect(right.querySelectorAll('.client-data-fact')).toHaveLength(6); expect(host.querySelectorAll('.client-environment-grid > section')).toHaveLength(6); const cloud = host.querySelector('[aria-label="Cloud Information"]')!; expect([...cloud.querySelectorAll('dd')].map(item => item.textContent)).toEqual(Array(4).fill('Not reported')); - expect(host.querySelector('.client-environment-grid')?.parentElement).toBe(host.firstElementChild); - expect(host.querySelector('.client-risk-register')?.parentElement).toBe(host.firstElementChild); + expect(host.querySelector('.client-environment-grid')?.parentElement).toBe(overview.parentElement); + expect(host.querySelector('.client-risk-register')?.parentElement).toBe(overview.parentElement); const coreCounts = [...host.querySelectorAll('.client-domain-score, .client-domains b, .client-priority-context, .client-overall-ring, .client-overall-coverage')].map(el => el.textContent); await act(async () => setEngineEnabled('context', true)); expect(host.querySelectorAll('.context-heatmap__cell')).toHaveLength(22); - expect(host.querySelector('.context-heatmap')?.parentElement).toBe(host.firstElementChild); + expect(host.querySelector('.context-heatmap')?.parentElement).toBe(overview.parentElement); expect(left.querySelector('.context-heatmap')).toBeNull(); expect(right.querySelector('.context-heatmap')).toBeNull(); expect([...host.querySelectorAll('.client-domain-score, .client-domains b, .client-priority-context, .client-overall-ring, .client-overall-coverage')].map(el => el.textContent)).toEqual(coreCounts); diff --git a/tests/features/client-report-buttons.test.tsx b/tests/features/client-report-buttons.test.tsx index c74d150..65a078d 100644 --- a/tests/features/client-report-buttons.test.tsx +++ b/tests/features/client-report-buttons.test.tsx @@ -15,7 +15,7 @@ import { makeSheet } from '../../src/importers/sheet-factory'; import { browserDownloadAdapter } from '../../src/platform/browser/browser-download-adapter'; import { genericProfile } from '../../src/profiles/generic/generic-profile'; -vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() })); +vi.mock('../../src/app/AppProvider', () => { const useApp = vi.fn(); return { useApp, useOptionalApp: useApp }; }); vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => Dummy timeslot })); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -108,9 +108,18 @@ describe('Client Info report buttons', () => { expect(management.text).not.toMatch(/Scanner A|Scanner B|maester/i); expect(management.text).not.toContain('DUMMY-RULE-123'); expect(management.text).not.toContain('dummy-artifact-secret'); - expect(technical.text).toContain('DUMMY-RULE-123'); - expect(technical.text).toContain('dummy-artifact-secret'); - expect(technical.text).toContain('vollständiger Result-Index'); + // Technical Report (rebuilt 2026-10-08): ASSESSOR as the single source. Every Work record deviation of the + // timeslot appears once as a finding line, independent of the Source filter; no scan tool, artifact or + // result identifier leaves through it. + const technicalReport = document.createElement('div'); + technicalReport.innerHTML = technical.text!; + expect([...technicalReport.querySelectorAll('section.report-section h2')].map(item => item.textContent)) + .toEqual(expect.arrayContaining(['Lagebild', 'Beziehungen (Pathfinder)', 'Problemfelder', 'Datenbasis und Datenstand'])); + expect([...technicalReport.querySelectorAll('details.finding summary')].map(item => item.textContent).sort()) + .toEqual(['Other source row', 'Visible filtered row']); + expect(technical.text).not.toMatch(/Scanner A|Scanner B/); + expect(technical.text).not.toContain('dummy-artifact-secret'); + expect(technical.text).not.toContain('DUMMY-RULE-123'); await act(async () => setEngineEnabled('context', false)); await click('Management Report'); expect(scopes).toHaveLength(2); diff --git a/tests/features/client-security-panel.test.tsx b/tests/features/client-security-panel.test.tsx new file mode 100644 index 0000000..4158b26 --- /dev/null +++ b/tests/features/client-security-panel.test.tsx @@ -0,0 +1,150 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { readFileSync } from 'node:fs'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useApp } from '../../src/app/AppProvider'; +import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences'; +import { AssessorApiClient } from '../../src/api/assessor-api-client'; +import { DashboardPanel } from '../../src/features/dashboard/DashboardPanel'; +import { ClientSecurityPanel } from '../../src/features/dashboard/ClientSecurityPanel'; +import { makeSheet } from '../../src/importers/sheet-factory'; +import { genericProfile } from '../../src/profiles/generic/generic-profile'; +import { clientFactsSnapshot, clientFactsState } from '../fixtures/client-info-facts'; +import { emptyHeatmapContext } from '../assessment-context/context-heatmap-fixture'; + +vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() })); +vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => Synthetic timeslot })); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren(); localStorage.clear(); resetEnginePreferencesForTests(); }); + +describe.each(['light', 'dark'])('Security findings tab (%s)', theme => { + it('opens a bold finding without scanner metadata and places Description directly after Affected', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + document.documentElement.dataset.theme = theme; + const sheet = makeSheet('Synthetic', [['Name', 'Status', 'Severity', 'Result Message', 'Number of Results', 'Description', 'Impact', 'Remediation', 'Source Rule ID'], + ['DC Sync rights', 'Failed', 'Critical', 'Retained result.', '3', 'Retained result.', 'Retained impact.', 'Retained fix.', 'SYNTHETIC-001'] + ], '', 'synthetic.csv', { isProjectSheet: true }); + const record = sheet.analysis.records[0]; + const dataset = { fileName: 'Synthetic', fileSize: 0, lastModified: 0, format: 'csv', rawText: '', profileId: 'purple-knight', scanTool: 'Purple Knight', sheets: [sheet] }; + const original = JSON.stringify(record); + const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); + const style = document.createElement('style'); + style.textContent = readFileSync('src/styles/ui-tokens.css', 'utf8') + readFileSync('src/styles/client-security.css', 'utf8'); + document.head.append(style); + try { + await act(async () => root.render()); + await act(async () => host.querySelector('.client-security-group-toggle')!.click()); + const toggle = host.querySelector('.client-security-finding-toggle')!; + expect(toggle.textContent).toBe('DC Sync rights'); + expect(toggle.querySelector('small')).toBeNull(); + expect(toggle.querySelector('strong')!.textContent).toBe('DC Sync rights'); + expect(getComputedStyle(toggle.querySelector('strong')!).fontWeight).toBe('var(--fw-bold)'); + await act(async () => toggle.click()); + const labels = [...host.querySelectorAll('.client-security-finding-detail > div:first-child .client-security-source > span')].map(item => item.textContent); + expect(labels).toContain('Affected'); + expect(labels[labels.indexOf('Affected') + 1]).toBe('Description'); + expect(labels.indexOf('Description')).toBeLessThan(labels.indexOf('Impact')); + expect(host.textContent).toContain('Retained fix.'); + expect(JSON.stringify(record)).toBe(original); + } finally { await act(async () => root.unmount()); style.remove(); } + }); + + it('keeps all 105 deviations in heatmap order, reconciles domains and opens the original explanation and remediation without changing Work', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); + document.documentElement.dataset.theme = theme; + resetEnginePreferencesForTests(); + const state = clientFactsState(); + const sheet = makeSheet('Synthetic', [['Name', 'Status', 'Severity', 'Description', 'Remediation'], + ...Array.from({ length: 105 }, (_, index) => [`Control ${String(index).padStart(3, '0')}`, 'Failed', 'High', '

    Synthetic retained explanation.

    ', 'Synthetic exact remediation.']), + ['Passed control', 'Passed', 'Critical', 'Not a deviation', ''], ['Unassessed control', 'Not configured', '', '', ''] + ], '', 'synthetic.csv', { isProjectSheet: true }); + const context = emptyHeatmapContext(); + sheet.analysis.records.slice(0, 105).forEach((record, index) => { + const occurrenceId = `synthetic-occurrence-${index}`; record.findingOccurrenceId = occurrenceId; + const clusterId = index < 104 ? 'cloud-privileged-access-exposure' : 'credential-relay-exposure'; + const conditionId = context.clusters.find(cluster => cluster.clusterId === clusterId)!.conditions[0].conditionId; + context.observations.push({ id: occurrenceId, conditionId, occurrenceId, resultProjectionId: occurrenceId, + tool: 'purple-knight', sourceRuleId: 'SYNTHETIC', ruleText: record.title, matchedBy: 'rule-id', outcome: 'fail', assertion: 'observed-by-scanner' }); + context.findings.push({ occurrenceId, resultProjectionId: occurrenceId, tool: 'purple-knight', sourceRuleId: 'SYNTHETIC', + observationIds: [occurrenceId], conditionIds: [conditionId], clusterIds: [clusterId], declaredReferenceIds: [], cisControlAreaIds: [] }); + }); + const openWork = vi.fn(), setFilter = vi.fn(); + vi.mocked(useApp).mockReturnValue({ ...state, dataset: { ...state.dataset, sheets: [sheet] }, activeSheet: sheet, + activeProfile: genericProfile, setFacetFilter: setFilter } as unknown as ReturnType); + const original = JSON.stringify(useApp().dataset); + vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue(clientFactsSnapshot); + vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never); + vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(context); + const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); + const style = document.createElement('style'); + style.textContent = readFileSync('src/styles/base.css', 'utf8') + readFileSync('src/styles/client-security.css', 'utf8'); + document.head.append(style); + try { + await act(async () => root.render()); + const tabs = host.querySelectorAll('[role="tab"]'); + await act(async () => tabs[1].click()); + const panel = host.querySelector('[role="tabpanel"]:not([hidden])')!; + expect(panel.textContent).toContain('105 deviations'); + const groups = [...panel.querySelectorAll('.client-security-group')]; + expect(groups[0].dataset.cluster).toBe('cloud-privileged-access-exposure'); + expect(groups.map(group => group.dataset.cluster)).toEqual([...host.querySelectorAll('.context-heatmap__cell')].map(cell => cell.dataset.cluster)); + expect(groups.map(group => Number(group.querySelector('.client-security-group-toggle > strong')!.textContent)).reduce((sum, count) => sum + count, 0)).toBe(105); + expect(panel.querySelector('[data-cluster="unassigned"]')).toBeNull(); + const map = panel.querySelector('.client-security-map')!; + expect(map.open).toBe(false); + map.open = true; + expect(getComputedStyle(map.querySelector('.client-security-map-scroll > svg')!).height).toBe('auto'); + expect(getComputedStyle(map.querySelector('summary > svg')!).width).not.toBe('1040px'); + expect(panel.querySelectorAll('.client-security-flow')).not.toHaveLength(0); + expect(panel.querySelector('svg[role="img"]')!.textContent).toContain('104'); + const category = groups[0].querySelector('.client-security-group-toggle')!; + expect(category.getAttribute('aria-expanded')).toBe('false'); + await act(async () => category.click()); + expect(groups[0].textContent).toContain('Technical approach'); + expect(groups[0].querySelectorAll('.client-security-finding')).toHaveLength(104); + expect(groups[0].textContent).not.toContain('Passed control'); + const finding = groups[0].querySelector('.client-security-finding-toggle')!; + await act(async () => finding.click()); + expect(finding.getAttribute('aria-expanded')).toBe('true'); + expect(groups[0].textContent).toContain('Synthetic retained explanation.'); + expect(groups[0].textContent).toContain('Synthetic exact remediation.'); + await act(async () => tabs[0].click()); await act(async () => tabs[1].click()); + expect(category.getAttribute('aria-expanded')).toBe('true'); + expect(finding.getAttribute('aria-expanded')).toBe('true'); + await act(async () => finding.click()); + expect(groups[0].querySelector('.client-security-finding-detail')).toBeNull(); + expect(setFilter).not.toHaveBeenCalled(); expect(openWork).not.toHaveBeenCalled(); + expect(JSON.stringify(useApp().dataset)).toBe(original); + await act(async () => setEngineEnabled('report', false)); + expect(panel.textContent).not.toContain('Technical approach'); + expect(panel.textContent).toContain('105 deviations'); + await act(async () => setEngineEnabled('context', false)); + expect(panel.querySelector('.client-security-map')).toBeNull(); + expect(panel.textContent).toContain('105 deviations'); + expect(panel.textContent).toContain('All findings'); + } finally { await act(async () => root.unmount()); style.remove(); } + }); + + it('counts a rolled-up GPO once and keeps distinct stored baseline fixes separate', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + document.documentElement.dataset.theme = theme; + const sheet = makeSheet('Synthetic GPO', [['Name', 'Status', 'Category'], ['Stored setting', 'Fail', 'GPOs']], '', 'synthetic.csv', { isProjectSheet: true }); + const record = sheet.analysis.records[0]; + record.policyBaselineComparisons = ['A', 'B'].map(name => ({ baseline: `Synthetic baseline ${name}`, status: 'Fail', summary: 'Stored comparison', context: '', scopeNotice: '', explanation: '', + remediation: `Retained fix ${name}`, recommendations: [{ template: `Template ${name}`, settingId: 'Synthetic exact setting', comparison: 'Fail', expected: name, additional: [], missing: [] }] })); + const dataset = { fileName: 'Synthetic', fileSize: 0, lastModified: 0, format: 'csv', rawText: '', profileId: 'generic', scanTool: 'Generic', sheets: [sheet] }; + const original = JSON.stringify(record); + const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); + try { + await act(async () => root.render()); + await act(async () => host.querySelector('.client-security-group-toggle')!.click()); + await act(async () => host.querySelector('.client-security-finding-toggle')!.click()); + expect(host.querySelectorAll('.client-security-finding')).toHaveLength(1); + expect(host.textContent).toContain('Retained fix A'); expect(host.textContent).toContain('Retained fix B'); + expect(JSON.stringify(record)).toBe(original); + } finally { await act(async () => root.unmount()); } + }); +}); diff --git a/tests/features/dashboard-layout.test.ts b/tests/features/dashboard-layout.test.ts index e5e6173..9fe1c15 100644 --- a/tests/features/dashboard-layout.test.ts +++ b/tests/features/dashboard-layout.test.ts @@ -9,6 +9,16 @@ const component = readFileSync(new URL('../../src/features/dashboard/DashboardPa const styles = readFileSync(new URL('../../src/styles/datahub.css', import.meta.url), 'utf8'); describe('dashboard reference layout', () => { + it('gives Data its own shared-theme tile alongside the score/domain tile', () => { + expect(styles).toMatch(/\.client-overview-summary\s*\{[^}]*border:\s*0;[^}]*background:\s*transparent;/s); + for (const selector of ['client-posture', 'client-priority']) { + const rule = styles.match(new RegExp(`\\.${selector}\\s*\\{([^}]*)\\}`))?.[1]; + expect(rule).toContain('border: var(--border-hair) solid var(--tile-border)'); + expect(rule).toContain('border-radius: var(--tile-radius)'); + expect(rule).toContain('background: var(--tile-surface)'); + } + }); + it('uses the requested summary-to-action client information hierarchy', () => { expect(component).toContain('className="client-overview-header"'); expect(component).toContain(''); @@ -30,8 +40,8 @@ describe('dashboard reference layout', () => { expect(styles).not.toContain('.client-overview-fact'); expect(styles).toMatch(/\.client-data-facts\s*\{[^}]*grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\);/s); expect(styles).toMatch(/\.client-data-fact\s*\{[^}]*background:\s*transparent;/s); - expect(styles).toMatch(/\.client-overview-main\s*\{[^}]*grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\);[^}]*align-items:\s*stretch;/s); - expect(styles).toMatch(/\.client-priority\s*\{[^}]*grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\);[^}]*align-content:\s*stretch;[^}]*align-items:\s*stretch;/s); + expect(styles).toMatch(/\.client-overview-main\s*\{[^}]*grid-template-columns:\s*minmax\(0, 3fr\) minmax\(0, 1fr\);[^}]*align-items:\s*stretch;/s); + expect(styles).toMatch(/\.client-priority\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\);[^}]*align-content:\s*stretch;[^}]*align-items:\s*stretch;/s); expect(styles).not.toContain('.client-assessment-gaps'); expect(styles).toMatch(/@container \(max-width: 760px\)[\s\S]*?\.client-priority\s*\{[^}]*grid-template-columns:\s*1fr;/s); expect(styles).toMatch(/\.client-posture\s*\{[^}]*grid-template-columns:\s*minmax\(300px, \.82fr\) minmax\(430px, 1\.18fr\);/s); @@ -103,7 +113,8 @@ describe('dashboard reference layout', () => { expect(component).toContain(""); expect(component).toContain('className="client-priority-context"'); expect(component).toContain('className="datahub-reference-knob client-priority-total"'); - expect(component).toContain('percentage={share(deviations, total)} tone="deviation"'); + expect(component).toContain('percentage={assessmentPercentages.deviation}'); + expect(component).toContain('percentageBasisLabel="Of assessed checks"'); expect(component.indexOf('className="client-priority-findings"')).toBeLessThan(component.indexOf('className="client-domain-information"')); expect(component).not.toContain('criticalSeverity'); expect(component).not.toContain('highSeverity'); diff --git a/tests/features/datahub-assessed-percentages.test.tsx b/tests/features/datahub-assessed-percentages.test.tsx new file mode 100644 index 0000000..0686ac1 --- /dev/null +++ b/tests/features/datahub-assessed-percentages.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, expect, it, vi } from 'vitest'; +import { DataHub } from '../../src/features/project-import/DataHub'; + +const api = vi.hoisted(() => ({ health: vi.fn(), getMitreCatalog: vi.fn(), listProjects: vi.fn(), listProjectImportFiles: vi.fn(), + listProjectAssessmentHistory: vi.fn(), listProjectGpoBackupStatus: vi.fn(), getTimeslotResultMetrics: vi.fn(), queryUnifiedResultFacets: vi.fn() })); +vi.mock('../../src/api/assessor-api-client', () => ({ AssessorApiClient: class { + health = api.health; getMitreCatalog = api.getMitreCatalog; listProjects = api.listProjects; listProjectImportFiles = api.listProjectImportFiles; + listProjectAssessmentHistory = api.listProjectAssessmentHistory; listProjectGpoBackupStatus = api.listProjectGpoBackupStatus; + getTimeslotResultMetrics = api.getTimeslotResultMetrics; queryUnifiedResultFacets = api.queryUnifiedResultFacets; +} })); +vi.mock('../../src/features/project-import/MicrosoftBaselineLibrary', () => ({ MicrosoftBaselineLibrary: () => null })); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren(); }); + +it.each(['dark', 'light'])('uses the assessed basis through actual client selection, retains counts and other population shares (%s)', async theme => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + document.documentElement.dataset.theme = theme; + const date = '2026-10-06T12:00:00Z'; + api.health.mockResolvedValue({ status: 'ok', repositoryRevision: 900 }); + api.getMitreCatalog.mockResolvedValue({ catalog: { version: 'synthetic', tacticOrder: [], techniques: [], entities: [] } }); + api.listProjects.mockResolvedValue(['alpha', 'beta', 'empty'].map(id => ({ id, name: id, attributes: {}, createdAt: date, updatedAt: date }))); + api.listProjectImportFiles.mockResolvedValue([]); + api.listProjectGpoBackupStatus.mockResolvedValue({ records: [] }); + api.queryUnifiedResultFacets.mockResolvedValue({ total: 0, facets: [] }); + api.listProjectAssessmentHistory.mockImplementation(async (id: string) => ({ version: 1, projectId: id, + assessments: [{ id: `${id}-slot`, projectId: id, name: 'Synthetic slot', createdAt: date, updatedAt: date, effectiveAt: date }], + scanRuns: [{ id: `${id}-run`, assessmentId: `${id}-slot`, sequence: 1, label: 'Synthetic run', importedAt: date, tool: { id: 'dummy', name: 'Synthetic' } }] + })); + const metrics = { all: 2194, deviation: 155, passed: 113, notApplicable: 1, notRun: 1, error: 9 }; + const original = JSON.stringify(metrics); + api.getTimeslotResultMetrics.mockImplementation(async (id: string) => id === 'empty' + ? { all: 7, deviation: 0, passed: 0, notApplicable: 1, notRun: 1, error: 5 } + : id === 'beta' ? { ...metrics, all: 10_000 } : metrics); + const container = document.createElement('div'); document.body.append(container); + const root = createRoot(container); + const card = (label: string) => container.querySelector(`[aria-label^="${label}:"]`)!; + const percent = (label: string) => card(label).querySelector('.donut-metric-card__values > small')?.textContent; + const select = async (id: string) => act(async () => container.querySelector(`[aria-label="Mandant ${id} auswählen"]`)!.click()); + try { + await act(async () => root.render()); + expect(card('Deviation').querySelector('strong')?.textContent).toBe('—'); + await select('alpha'); + expect(percent('Deviation')).toBe('58%'); expect(percent('Passed')).toBe('42%'); + expect(card('Deviation').querySelector('strong')?.textContent).toBe('155'); + expect(card('Passed').querySelector('strong')?.textContent).toBe('113'); + expect(card('Results All').querySelector('strong')?.textContent).toBe('2,194'); + expect(percent('Results All')).toBe('100%'); + for (const label of ['Deviation', 'Passed']) expect(card(label).querySelector('.donut-metric-card__basis')?.textContent).toBe('Of assessed checks'); + for (const label of ['Results All', 'Not Applicable', 'Not Run', 'Error']) expect(card(label).querySelector('.donut-metric-card__basis')?.textContent).toBe('Of all records'); + for (const label of ['Not Applicable', 'Not Run', 'Error']) expect(percent(label)).toBe('<1%'); + await select('beta'); + expect(percent('Deviation')).toBe('58%'); expect(percent('Passed')).toBe('42%'); + expect(card('Deviation').querySelector('strong')?.textContent).toBe('155'); + expect(card('Results All').querySelector('strong')?.textContent).toBe('10,000'); + await select('empty'); + for (const label of ['Deviation', 'Passed']) { + expect(card(label).querySelector('strong')?.textContent).toBe('0'); + expect(percent(label)).toBe('—'); + expect(card(label).querySelector('.donut-metric-card__progress')).toBeNull(); + } + expect(percent('Not Run')).toBe('14%'); expect(percent('Error')).toBe('71%'); + expect(JSON.stringify(metrics)).toBe(original); + } finally { await act(async () => root.unmount()); } +}); diff --git a/tests/features/datahub-donut-loading-interaction.test.tsx b/tests/features/datahub-donut-loading-interaction.test.tsx index 31080bb..c6b56c6 100644 --- a/tests/features/datahub-donut-loading-interaction.test.tsx +++ b/tests/features/datahub-donut-loading-interaction.test.tsx @@ -47,9 +47,9 @@ it('shows loading instead of zero after selecting Dummy, then the loaded status act(() => complete()); expect(container.querySelector('[aria-label="Selected client result status"]')?.getAttribute('aria-busy')).toBe('false'); - expect(container.querySelector('.donut-metric-card[aria-label="Results All: 1,833, 100%"]')).not.toBeNull(); - expect(container.querySelector('.donut-metric-card[aria-label="Deviation: 477, 26%"]')).not.toBeNull(); - const smallResult = container.querySelector('.donut-metric-card[aria-label="Not Applicable: 4, <1%"]'); + expect(container.querySelector('.donut-metric-card[aria-label="Results All: 1,833, 100%, Of all records"]')).not.toBeNull(); + expect(container.querySelector('.donut-metric-card[aria-label="Deviation: 477, 63%, Of assessed checks"]')).not.toBeNull(); + const smallResult = container.querySelector('.donut-metric-card[aria-label="Not Applicable: 4, <1%, Of all records"]'); expect(smallResult).not.toBeNull(); expect(smallResult?.querySelector('.donut-small-arc-marker')).not.toBeNull(); expect(smallResult?.querySelector('.donut-metric-card__progress')?.getAttribute('d')).toBe(donutArcPath(4 / 1833 * 100)); diff --git a/tests/features/datahub-metrics-prefetch.test.tsx b/tests/features/datahub-metrics-prefetch.test.tsx index 0d8ebb2..93425ea 100644 --- a/tests/features/datahub-metrics-prefetch.test.tsx +++ b/tests/features/datahub-metrics-prefetch.test.tsx @@ -53,22 +53,22 @@ it('preloads during overview loading, shares pending requests, reuses counts on expect(readout()).toBe('Results All: Loading…'); expect(api.getTimeslotResultMetrics).toHaveBeenCalledTimes(2); await act(async () => select('beta')); - expect(readout()).toBe('Results All: 40, 100%'); + expect(readout()).toBe('Results All: 40, 100%, Of all records'); await act(async () => alphaMetrics.resolve(totals(20))); - expect(readout()).toBe('Results All: 40, 100%'); // Late alpha must not overwrite beta. + expect(readout()).toBe('Results All: 40, 100%, Of all records'); // Late alpha must not overwrite beta. await act(async () => select('alpha')); - expect(readout()).toBe('Results All: 20, 100%'); + expect(readout()).toBe('Results All: 20, 100%, Of all records'); expect(api.getTimeslotResultMetrics).toHaveBeenCalledTimes(2); await act(async () => root.unmount()); root = createRoot(host); await act(async () => root.render(mount())); await act(async () => select('alpha')); - expect(readout()).toBe('Results All: 20, 100%'); + expect(readout()).toBe('Results All: 20, 100%, Of all records'); expect(api.getTimeslotResultMetrics).toHaveBeenCalledTimes(2); revision++; api.getTimeslotResultMetrics.mockImplementation(async (id: string) => totals(id === 'alpha' ? 22 : 44)); await act(async () => globalThis.dispatchEvent(new Event('assessor:datahub-changed'))); expect(api.getTimeslotResultMetrics).toHaveBeenCalledTimes(4); - expect(readout()).toBe('Results All: 22, 100%'); + expect(readout()).toBe('Results All: 22, 100%, Of all records'); } finally { await act(async () => root.unmount()); } }); diff --git a/tests/features/finding-context-layer.test.tsx b/tests/features/finding-context-layer.test.tsx index 20338dd..53ac995 100644 --- a/tests/features/finding-context-layer.test.tsx +++ b/tests/features/finding-context-layer.test.tsx @@ -75,6 +75,8 @@ beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); localStorage.clear(); resetEnginePreferencesForTests(); + // Isolate the Context on/off contract from other optional engine output. + setEngineEnabled('remediation', false); resetAssessmentContextRequestsForTests(); vi.mocked(useApp).mockReturnValue({ targetProjectId: 'dummy-project', activeAssessmentIds: ['dummy-assessment'], dataset, setFacetFilter: vi.fn() diff --git a/tests/features/finding-reader-actions.test.tsx b/tests/features/finding-reader-actions.test.tsx new file mode 100644 index 0000000..e112226 --- /dev/null +++ b/tests/features/finding-reader-actions.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment jsdom +import { readFileSync } from 'node:fs'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { describe, expect, it, vi } from 'vitest'; +import { FindingReader } from '../../src/features/finding-reader/FindingReader'; +import { makeSheet } from '../../src/importers/sheet-factory'; +import { defaultReview } from '../../src/domain/review'; + +const css = readFileSync('src/styles/components.css', 'utf8'); +const record = makeSheet('Synthetic', [ + ['Name', 'Result', 'Severity', 'Description', 'ShortName'], + ['Synthetic LDAP signing finding', 'IOE Found', 'High', 'Synthetic evidence', 'SI000160'] +], '', 'synthetic.csv', { toolId: 'purple-knight' }).analysis.records[0]; + +describe.each(['dark', 'light'])('Finding actions (%s)', theme => { + it('keeps ordered icon-only actions together through narrow resizing, bookmark changes and Results opening', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + document.documentElement.dataset.theme = theme; + const style = document.createElement('style'); style.textContent = css; document.head.append(style); + const host = document.createElement('div'); document.body.append(host); + const root = createRoot(host); + const onReview = vi.fn(); const onResults = vi.fn(); + const render = async (include: boolean, resultsOpen = false) => { + await act(async () => root.render()); + }; + try { + await render(false); + for (const width of [1200, 620, 320]) { + host.style.width = `${width}px`; + window.dispatchEvent(new Event('resize')); + const group = host.querySelector('.reader-primary-actions')!; + expect([...group.children].map(element => element.tagName)).toEqual(['A', 'BUTTON', 'BUTTON']); + expect(group.textContent).toBe('AI searchResults'); + expect(getComputedStyle(group).flexFlow).toBe('row nowrap'); + expect(getComputedStyle(group).flex).toBe('0 0 auto'); + for (const action of group.children) expect(getComputedStyle(action).whiteSpace).toBe('nowrap'); + } + const bookmark = host.querySelector('.bookmark-btn')!; + expect(bookmark.getAttribute('aria-label')).toBe('Bookmark'); + expect(bookmark.textContent).toBe(''); + expect(bookmark.querySelector('svg')).not.toBeNull(); + expect(bookmark.getAttribute('aria-pressed')).toBe('false'); + await act(async () => bookmark.click()); + expect(onReview).toHaveBeenLastCalledWith({ include: true }); + await render(true); + expect(bookmark.getAttribute('aria-pressed')).toBe('true'); + await act(async () => bookmark.click()); + expect(onReview).toHaveBeenLastCalledWith({ include: false }); + await act(async () => host.querySelector('.results-detail-btn')!.click()); + expect(onResults).toHaveBeenCalledTimes(1); + await render(true, true); + expect(host.querySelector('.results-detail-btn')!.classList.contains('is-open')).toBe(true); + expect(host.querySelector('.reader-primary-actions')!.textContent).toBe('AI searchResults'); + } finally { + await act(async () => root.unmount()); host.remove(); style.remove(); vi.unstubAllGlobals(); + } + }); +}); diff --git a/tests/features/finding-reader-mitre-disclosure.test.tsx b/tests/features/finding-reader-mitre-disclosure.test.tsx new file mode 100644 index 0000000..8f36722 --- /dev/null +++ b/tests/features/finding-reader-mitre-disclosure.test.tsx @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { describe, expect, it, vi } from 'vitest'; +import { FindingReader } from '../../src/features/finding-reader/FindingReader'; +import { makeSheet } from '../../src/importers/sheet-factory'; + +const original = makeSheet('Synthetic', [ + ['Name', 'Result', 'Severity', 'MITRE ATT&CK', 'MITRE D3FEND'], + ['Synthetic finding', 'IOE Found', 'High', 'Privilege Escalation', 'Harden - System Configuration Permissions'] +], '', 'synthetic.csv', { toolId: 'purple-knight' }).analysis.records[0]; + +describe.each(['dark', 'light'])('MITRE badge disclosure (%s)', theme => { + it('starts collapsed, toggles only the badges and resets for another finding and on return', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + document.documentElement.dataset.theme = theme; + const host = document.createElement('div'); document.body.append(host); + const root = createRoot(host); + const render = async (id: string, search = '') => { + await act(async () => root.render()); + }; + try { + await render('first'); + const toggle = () => host.querySelector('.mitre-disclosure-toggle')!; + expect(toggle().getAttribute('aria-expanded')).toBe('false'); + expect(toggle().textContent).toBe(''); + expect(toggle().querySelector('.triangle-icon-right')).not.toBeNull(); + expect(host.querySelector('.mitre-line')).toBeNull(); + expect(host.querySelector('.finding-badges')!.textContent).toContain('High'); + toggle().focus(); + await act(async () => toggle().click()); + expect(document.activeElement).toBe(toggle()); + expect(toggle().getAttribute('aria-expanded')).toBe('true'); + expect(toggle().querySelector('.triangle-icon-down')).not.toBeNull(); + expect([...host.querySelectorAll('.mitre-badge')].map(badge => badge.textContent)).toEqual([ + 'MITRE ATT&CK: Privilege Escalation', 'MITRE D3FEND: Harden - System Configuration Permissions' + ]); + await render('first', 'Privilege'); + expect(toggle().getAttribute('aria-expanded')).toBe('true'); + expect(host.querySelector('.mitre-badge mark')!.textContent).toBe('Privilege'); + await act(async () => toggle().click()); + expect(host.querySelector('.mitre-line')).toBeNull(); + await act(async () => toggle().click()); + await render('second'); + expect(toggle().getAttribute('aria-expanded')).toBe('false'); + expect(host.querySelector('.mitre-line')).toBeNull(); + await render('first'); + expect(toggle().getAttribute('aria-expanded')).toBe('false'); + await act(async () => root.render()); + expect(host.querySelector('.mitre-disclosure')).toBeNull(); + } finally { await act(async () => root.unmount()); host.remove(); vi.unstubAllGlobals(); } + }); +}); diff --git a/tests/features/finding-reader.test.ts b/tests/features/finding-reader.test.ts index 4a45caa..94dafa1 100644 --- a/tests/features/finding-reader.test.ts +++ b/tests/features/finding-reader.test.ts @@ -552,7 +552,7 @@ describe('selectFindingFields', () => { position: 0, recordCount: 1, onPrevious() {}, onNext() {}, onReview() {} })); expect(html).toContain('data-source-rule-id="true"'); - expect(html).toContain('

    Source Rule ID

    MT.1011

    '); + expect(html).toContain('

    Source Rule ID

    MT.1011'); }); it('presents the retained Maester fields in the native report hierarchy without duplicating the ID in the title', () => { diff --git a/tests/features/finding-work-package-alignment.test.tsx b/tests/features/finding-work-package-alignment.test.tsx new file mode 100644 index 0000000..1ba253f --- /dev/null +++ b/tests/features/finding-work-package-alignment.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +import { readFileSync } from 'node:fs'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { describe, expect, it, vi } from 'vitest'; +import { useContextClusterIndex } from '../../src/features/context-layer/use-context-cluster-index'; +import { FindingReader } from '../../src/features/finding-reader/FindingReader'; +import { makeSheet } from '../../src/importers/sheet-factory'; +import { useWorkPackages } from '../../src/remediation/use-work-packages'; +import { workRecordKey } from '../../src/remediation/work-package-index'; + +vi.mock('../../src/features/context-layer/use-context-cluster-index', () => ({ useContextClusterIndex: vi.fn(() => undefined) })); +vi.mock('../../src/remediation/use-work-packages', () => ({ useWorkPackages: vi.fn() })); + +describe.each(['dark', 'light'])('Reader Work Package alignment (%s)', theme => { + it('keeps packages and task details right aligned with or without Source Rule ID, including unavailable and unassigned states', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + document.documentElement.dataset.theme = theme; + const style = document.createElement('style'); + style.textContent = readFileSync('src/styles/components.css', 'utf8'); document.head.append(style); + const host = document.createElement('div'); document.body.append(host); + const root = createRoot(host); + try { + for (const withSource of [true, false]) { + const record = makeSheet('Synthetic', [withSource ? ['Name', 'Status', 'Source Rule ID'] : ['Name', 'Status'], + withSource ? ['Synthetic deviation', 'Failed', 'RULE-01'] : ['Synthetic deviation', 'Failed']], '', 'synthetic.csv').analysis.records[0]; + const original = JSON.stringify(record); + const workPackage = { id: 'synthetic', code: 'WP-01', title: 'Synthetic package', reason: 'Synthetic', + clusterId: 'synthetic-cluster', clusterLabel: 'Synthetic long cluster name for wrapping', facts: { tasks: 6, findings: 6 }, + tasks: Array.from({ length: 6 }, (_, i) => ({ id: `task-${i}`, findingKeys: i ? [] : [workRecordKey(record)] })) }; + for (const state of [ + { status: 'ready', index: { packageFor: () => workPackage } }, + { status: 'loading' }, + { status: 'ready', index: { packageFor: () => undefined, unassigned: () => true } } + ]) { + vi.mocked(useWorkPackages).mockReturnValue(state as never); + await act(async () => root.render()); + const row = host.querySelector('.reader-source-rule')!; + const packageItem = row.querySelector('.finding-work-package__group, .finding-work-package')!; + for (const width of [1200, 620, 320]) { + host.style.width = `${width}px`; + expect(getComputedStyle(packageItem).textAlign).toBe('right'); + expect(getComputedStyle(packageItem).overflowWrap).toBe('anywhere'); + expect(getComputedStyle(packageItem).flex).toBe('1 1 0px'); + } + expect(Boolean(row.querySelector('code'))).toBe(withSource); + if (row.querySelector('[data-work-package-id]')) { + expect(packageItem.textContent).toContain('One of 6 tasks'); + for (const severity of ['critical', 'high', 'medium', 'undetermined']) { + vi.mocked(useContextClusterIndex).mockReturnValue({ publication: { clusters: [{ id: 'synthetic-cluster', label: workPackage.clusterLabel, hasEvidence: severity !== 'undetermined', severity }] } } as never); + await act(async () => root.render()); + const square = row.querySelector('[data-finding-work-package-colour]')!; + expect(square.getAttribute('data-finding-work-package-colour')).toBe(severity); + expect(square.classList.contains('severity-' + severity)).toBe(true); + expect(square.parentElement?.getAttribute('data-work-package-id')).toBe('synthetic'); + expect(getComputedStyle(square).display).toBe('inline-block'); + expect(row.textContent).toContain('One of 6 tasks'); + } + await act(async () => row.querySelector('a')!.click()); + } + expect(JSON.stringify(record)).toBe(original); + } + } + } finally { vi.mocked(useContextClusterIndex).mockReturnValue(undefined); await act(async () => root.unmount()); host.remove(); style.remove(); vi.unstubAllGlobals(); } + }); +}); diff --git a/tests/features/gpo-folder-import-diagnostics.test.tsx b/tests/features/gpo-folder-import-diagnostics.test.tsx new file mode 100644 index 0000000..321644b --- /dev/null +++ b/tests/features/gpo-folder-import-diagnostics.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AssessorApiClient } from '../../src/api/assessor-api-client'; +import { GpoFolderImport } from '../../src/features/project-import/GpoFolderImport'; + +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +const privateName = 'Synthetic confidential customer'; +const error = (name: string) => new DOMException(`C:/Customers/${privateName}/backup.xml`, name); + +describe.each(['dark', 'light'])('GPO folder click diagnostics (%s)', theme => { + it.each([ + ['enumeration', 'Folder enumeration: NotFoundError (1)'], + ['access', 'File access: NotAllowedError (1)'], + ['reading', 'File reading: NotReadableError (1)'], + ['size', 'File reading: SizeMismatch (1)'], + ['unknown', 'File reading: UnknownError (1)'], + ['empty', 'contains no files'], + ['selection', 'Folder selection: SecurityError (1)'], + ])('reports %s without exposing customer data or uploading', async (scenario, expected) => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const submit = vi.spyOn(AssessorApiClient.prototype, 'submitGpoBackup'); + const picker = vi.fn(async () => { + if (scenario === 'selection') throw error('SecurityError'); + return { + kind: 'directory', name: privateName, + async *values() { + if (scenario === 'enumeration') throw error('NotFoundError'); + if (scenario === 'empty') return; + yield { kind: 'file', name: 'confidential.xml', getFile: async () => { + if (scenario === 'access') throw error('NotAllowedError'); + return { size: 1, arrayBuffer: async () => { + if (scenario === 'size') return new ArrayBuffer(0); + throw error(scenario === 'unknown' ? privateName : 'NotReadableError'); + } }; + } }; + } + }; + }); + vi.stubGlobal('showDirectoryPicker', picker); + const container = document.createElement('div'); + container.setAttribute('data-theme', theme); + const root = createRoot(container); + try { + await act(async () => root.render()); + const button = Array.from(container.querySelectorAll('button')).find(item => item.textContent === 'Import GPO Folder')!; + await act(async () => button.click()); + expect(picker).toHaveBeenCalledWith({ mode: 'read' }); + const message = container.querySelector('[role="alert"]')?.textContent; + expect(message).toContain(expected); + expect(message).toContain('Import GPO ZIP'); + expect(message).not.toContain(privateName); + expect(message).not.toContain('confidential.xml'); + expect(message).not.toContain('C:/Customers'); + expect(submit).not.toHaveBeenCalled(); + expect(button.disabled).toBe(false); + } finally { await act(async () => root.unmount()); } + }); + + it('cancels folder selection quietly and permits another attempt', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const submit = vi.spyOn(AssessorApiClient.prototype, 'submitGpoBackup'); + vi.stubGlobal('showDirectoryPicker', vi.fn().mockRejectedValue(error('AbortError'))); + const container = document.createElement('div'); + const root = createRoot(container); + try { + await act(async () => root.render()); + const button = container.querySelector('button')!; + await act(async () => button.click()); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(button.disabled).toBe(false); + expect(submit).not.toHaveBeenCalled(); + } finally { await act(async () => root.unmount()); } + }); + + it('reports unreadable files through the directory-input fallback', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('showDirectoryPicker', undefined); + const submit = vi.spyOn(AssessorApiClient.prototype, 'submitGpoBackup'); + const container = document.createElement('div'); + const root = createRoot(container); + try { + await act(async () => root.render()); + const input = container.querySelector('input[type="file"]')!; + const open = vi.spyOn(input as HTMLInputElement, 'click'); + await act(async () => container.querySelector('button')!.click()); + expect(open).toHaveBeenCalledOnce(); + Object.defineProperty(input, 'files', { value: [{ + webkitRelativePath: 'Synthetic/backup.xml', size: 1, + arrayBuffer: async () => { throw error('NotFoundError'); } + }] }); + await act(async () => input.dispatchEvent(new Event('change', { bubbles: true }))); + const message = container.querySelector('[role="alert"]')?.textContent; + expect(message).toContain('File reading: NotFoundError (1)'); + expect(message).not.toContain(privateName); + expect(submit).not.toHaveBeenCalled(); + } finally { await act(async () => root.unmount()); } + }); +}); diff --git a/tests/features/related-findings.test.tsx b/tests/features/related-findings.test.tsx new file mode 100644 index 0000000..c3e5bb9 --- /dev/null +++ b/tests/features/related-findings.test.tsx @@ -0,0 +1,227 @@ +// @vitest-environment jsdom +import { act, useState } from 'react'; +import { readFileSync } from 'node:fs'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useApp } from '../../src/app/AppProvider'; +import { initialAppState } from '../../src/app/app-state'; +import { RelatedFindingsProvider, useRelatedViewRestore } from '../../src/features/related-findings/RelatedFindings'; +import { RiskRegister } from '../../src/features/dashboard/RiskRegister'; +import { appendRiskAcceptanceReview } from '../../src/domain/risk-acceptance'; +import { defaultReview } from '../../src/domain/review'; +import { riskCommand } from '../domain/risk-acceptance-fixture'; +import { ObsidianGraphView } from '../../src/features/mitre-graph/ObsidianGraphView'; +import { DatasetViewer } from '../../src/features/dataset-viewer/DatasetViewer'; +import { makeSheet } from '../../src/importers/sheet-factory'; +import { genericProfile } from '../../src/profiles/generic/generic-profile'; +import { useWorkPackages } from '../../src/remediation/use-work-packages'; +import { workRecordKey } from '../../src/remediation/work-package-index'; + +vi.mock('../../src/app/AppProvider', () => { const useApp = vi.fn(); return { useApp, useOptionalApp: useApp }; }); +vi.mock('../../src/remediation/use-work-packages', () => ({ useWorkPackages: vi.fn() })); +vi.mock('../../src/features/context-layer/use-context-cluster-index', () => ({ useContextClusterIndex: () => undefined })); +vi.mock('../../src/features/finding-reader/FindingContext', () => ({ FindingContext: () => null, FindingContextBadges: () => null, FindingContextSection: () => null })); +vi.mock('../../src/features/search-filter/SearchFilterBar', () => ({ SearchFilterBar: () => null, SearchTextControl: () => null })); +vi.mock('../../src/features/view-bookmarks/ViewBookmarks', () => ({ ViewBookmarks: () => null })); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +describe.each(['light', 'dark'])('same-change rings (%s)', theme => { + it('focuses the exact group in Pathfinder, keeps the ring size independent of zoom, and restores graph selection and camera', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0)); + vi.stubGlobal('cancelAnimationFrame', clearTimeout); + document.documentElement.dataset.theme = theme; + const sheet = makeSheet('Synthetic graph', [['Name', 'Status', 'Severity', 'Category'], + ['Declared SMB', 'Failed', 'High', 'Synthetic'], ['Observed SMB', 'Passed', 'High', 'Synthetic'], ['Unrelated', 'Failed', 'Low', 'Other']], '', 'synthetic.csv', { isProjectSheet: true }); + const records = sheet.analysis.records; + records.forEach((record, index) => { record.findingOccurrenceId = `synthetic-graph-${index}`; }); + const pkg = { tasks: [{ id: 'same-smb', redundant: true, findingKeys: records.slice(0,2).map(workRecordKey) }] }; + vi.mocked(useWorkPackages).mockReturnValue({ status: 'ready', index: { packages: [pkg], facet: { id: 'remediation:work-package', label: 'Work Package', values: [], valuesFor: () => [] } } } as never); + const initial = { ...initialAppState, dataset: { sheets: [sheet], profileId: 'generic' }, activeSheet: sheet, + activeSheetId: sheet.id, workScopeId: 'synthetic-graph', activeProfile: genericProfile, titleColumn: 'Name', statusFilter: 'all', selectedRecordId: records[2].id } as unknown as ReturnType; + function Harness() { + const [state, setState] = useState(initial); + vi.mocked(useApp).mockReturnValue({ ...state, setSelectedRecord: id => setState(s => ({ ...s, selectedRecordId: id })), + applyViewBookmark: saved => setState(s => ({ ...s, ...saved })), resetFilters: vi.fn(), setFacetFilter: vi.fn() }); + return ; + } + const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); + try { + await act(async () => root.render()); + const graph = host.querySelector('.obsidian-graph-surface')!; + await act(async () => graph.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 800, clientX: 100, clientY: 100 }))); + const unrelated = host.querySelector('[aria-label="Unrelated"]')!; + await act(async () => unrelated.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }))); + const beforeCamera = graph.querySelector('g[transform]')!.getAttribute('transform'); + const zoom = Number(beforeCamera!.match(/scale\(([^)]+)/)![1]); + const counterZoom = Number(graph.querySelector('.graph-related-ring')!.getAttribute('transform')!.match(/scale\(([^)]+)/)![1]); + expect(zoom * counterZoom).toBeCloseTo(1); + expect(unrelated.getAttribute('aria-pressed')).toBe('true'); + await act(async () => host.querySelector('.graph-related-ring .related-finding-ring')!.click()); + expect(host.querySelectorAll('.obsidian-node-finding')).toHaveLength(2); + expect(host.querySelectorAll('.graph-related-ring .related-finding-ring[aria-pressed="true"]')).toHaveLength(2); + await act(async () => { host.querySelector('.graph-related-ring .related-finding-ring')!.click(); await new Promise(resolve => setTimeout(resolve, 20)); }); + expect(host.querySelectorAll('.obsidian-node-finding')).toHaveLength(2); + expect(graph.querySelector('g[transform]')!.getAttribute('transform')).toBe(beforeCamera); + expect(host.querySelector('[aria-label="Unrelated"]')!.getAttribute('aria-pressed')).toBe('true'); + } finally { await act(async () => root.unmount()); host.remove(); } + }); + it('opens the complete group from Accepted Risks and restores the register without changing its decision or count', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0)); + vi.stubGlobal('cancelAnimationFrame', clearTimeout); + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { configurable: true, value() {} }); + document.documentElement.dataset.theme = theme; + const sheet = makeSheet('Synthetic', [['Name', 'Status', 'Severity'], ['Declared SMB', 'Failed', 'High'], ['Observed SMB', 'Failed', 'High']], '', 'synthetic.csv', { isProjectSheet: true }); + const records = sheet.analysis.records; + records.forEach((record, index) => { record.findingOccurrenceId = `synthetic-occurrence-${index}`; }); + const today = new Date().toISOString().slice(0,10); + const risk = appendRiskAcceptanceReview(undefined, records[0].findingOccurrenceId!, riskCommand('accept', today), `${today}T10:00:00.000Z`); + const reviews = { [records[0].id]: { ...defaultReview(), riskAcceptance: risk.riskAcceptance } }; + const pkg = { id: 'package', code: 'WP-01', title: 'Synthetic', clusterLabel: 'Synthetic', facts: { tasks: 1, findings: 2 }, + tasks: [{ id: 'same-smb', redundant: true, findingKeys: records.map(workRecordKey) }] }; + vi.mocked(useWorkPackages).mockReturnValue({ status: 'ready', index: { packages: [pkg], packageFor: () => pkg, + unassigned: () => false, facet: { id: 'remediation:work-package', label: 'Work Package', values: [], valuesFor: () => [] } } } as never); + const app = { ...initialAppState, workScopeId: 'synthetic-risk', dataset: { sheets: [sheet], profileId: 'generic', fileName: 'Synthetic' }, + activeSheet: sheet, activeSheetId: sheet.id, activeProfile: genericProfile, reviews, view: 'records', statusFilter: 'all', selectedRecordId: records[0].id, + setSelectedRecord: vi.fn(), applyViewBookmark: vi.fn(), resetFilters: vi.fn(), setView: vi.fn(), updateReview: vi.fn() } as unknown as ReturnType; + vi.mocked(useApp).mockReturnValue(app); + const original = JSON.stringify(reviews); + function Views() { + const [work, setWork] = useState(false); + useRelatedViewRestore(() => () => setWork(work)); + return <>

    + {work && }; + } + const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); + try { + await act(async () => root.render()); + expect(host.querySelectorAll('.risk-entry-with-group')).toHaveLength(1); + expect(host.querySelector('.client-risk-register-navigation header')!.textContent).toContain('1 recorded'); + const details = host.querySelector('.risk-register-detail-group')!; + details.open = true; + await act(async () => host.querySelector('.risk-entry-with-group .related-finding-ring')!.click()); + expect(host.querySelector('[data-testid="risk-view"]')!.hidden).toBe(true); + expect(host.querySelectorAll('.record-card')).toHaveLength(2); + await act(async () => host.querySelector('.record-list .related-finding-ring')!.click()); + expect(host.querySelector('[data-testid="risk-view"]')!.hidden).toBe(false); + expect(host.querySelectorAll('.risk-entry-with-group')).toHaveLength(1); + expect(details.open).toBe(true); + expect(JSON.stringify(reviews)).toBe(original); + expect(app.updateReview).not.toHaveBeenCalled(); + } finally { await act(async () => root.unmount()); host.remove(); } + }); + it('isolates every exact group member, fills their rings, and restores filters, selected finding, view and scroll without editing reviews', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0)); + vi.stubGlobal('cancelAnimationFrame', clearTimeout); + vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener() {}, removeEventListener() {} })); + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { configurable: true, value: function(this: HTMLElement, options: ScrollToOptions | number = {}) { this.scrollTop = typeof options === 'number' ? options : options.top ?? 0; } }); + document.documentElement.dataset.theme = theme; + const sheet = makeSheet('Synthetic', [['Name','Status','Severity','Scope','Technical Identity'], + ['SMB declaration','Failed','High','synthetic','smb-a'], ['SMB observation','Passed','Low','synthetic','smb-b'], + ['SMB declaration','Failed','High','synthetic','unrelated'], ['Other change','Failed','Low','synthetic','other']], '', 'synthetic.csv'); + sheet.isProjectSheet = true; + const records = sheet.analysis.records; + const sourceSheet = { ...sheet, id: 'source', isProjectSheet: false, analysis: { ...sheet.analysis, records: [{ ...records[0], id: 'source-alias' }] } }; + const workPackage = { id: 'package', code: 'WP-01', clusterLabel: 'Synthetic', title: 'Synthetic', facts: { tasks: 2, findings: 3 }, + tasks: [{ id: 'same-smb', redundant: true, findingKeys: records.slice(0,2).map(workRecordKey) }, + { id: 'unrelated', redundant: false, findingKeys: [workRecordKey(records[2])] }], findingKeys: records.slice(0,3).map(workRecordKey) }; + const index = { packages: [workPackage], packageFor: (r: typeof records[0]) => workPackage.findingKeys.includes(workRecordKey(r)) ? workPackage : undefined, + unassigned: () => false, facet: { id: 'remediation:work-package', label: 'Work Package', kind: 'category', values: [], valuesFor: () => [] } }; + vi.mocked(useWorkPackages).mockReturnValue({ status: 'ready', index } as never); + const original = JSON.stringify(records); + const style = document.createElement('style'); + style.textContent = readFileSync('src/styles/components.css', 'utf8') + readFileSync('src/styles/legacy-views.css', 'utf8'); + document.head.append(style); + const reviews = { [records[0].id]: { include: true, decision: 'open', note: '' } }; + let app: ReturnType; + const initial = { ...initialAppState, theme, dataset: { sheets: [sheet, sourceSheet], fileName: 'Synthetic', profileId: 'generic' }, + activeSheetId: sheet.id, activeSheet: sheet, activeProfile: genericProfile, workScopeId: 'synthetic', + view: 'records', search: 'SMB declaration', statusFilter: 'all', actionOnly: true, reviews, + pathFindingIds: [records[0].id], selectedRecordId: records[0].id } as unknown as ReturnType; + const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); + function Harness() { + const [state, setState] = useState(initial); + app = { ...state, setSelectedRecord: id => setState(s => ({ ...s, selectedRecordId: id })), + setActiveSheet: id => setState(s => ({ ...s, activeSheetId: id, activeSheet: id === 'source' ? sourceSheet : sheet, selectedRecordId: id === 'source' ? 'source-alias' : records[0].id })), + setView: view => setState(s => ({ ...s, view })), applyViewBookmark: saved => setState(s => ({ ...s, ...saved })), + setFacetFilter: (id, value) => setState(s => { + const facetFilters = { ...s.facetFilters }; + if (value) facetFilters[id] = value; else delete facetFilters[id]; + return { ...s, facetFilters }; + }), + updateReview: vi.fn(), resetFilters: () => setState(s => ({ ...s, search: '', actionOnly: false, pathFindingIds: [], facetFilters: {} })) }; + vi.mocked(useApp).mockImplementation(() => app); + return ; + } + try { + await act(async () => root.render()); + const list = host.querySelector('.record-list')!; + list.scrollTop = 184; list.scrollLeft = 17; + expect(host.querySelectorAll('.record-card')).toHaveLength(1); + await act(async () => host.querySelector('.record-list .related-finding-ring')!.click()); + expect(host.querySelectorAll('.record-card')).toHaveLength(2); + expect(host.querySelectorAll('.record-list .related-finding-ring[aria-pressed="true"]')).toHaveLength(2); + const activeRing = host.querySelector('.record-card.active')!.parentElement!.querySelector('.related-finding-ring')!; + expect(Number(getComputedStyle(activeRing).zIndex)).toBeGreaterThan(Number(getComputedStyle(host.querySelector('.record-card.active')!).zIndex)); + const rings = host.querySelectorAll('.record-list .related-finding-ring'); + expect(rings[0].style.getPropertyValue('--related-color')).toBe(rings[1].style.getPropertyValue('--related-color')); + await act(async () => app!.setSelectedRecord(records[1].id)); + for (const ring of host.querySelectorAll('.record-list .related-finding-ring')) { + expect(ring.getAttribute('aria-pressed')).toBe('true'); + expect(ring.style.getPropertyValue('--related-color')).toBe(rings[0].style.getPropertyValue('--related-color')); + expect(getComputedStyle(ring).zIndex).toBe('2'); + } + expect(host.querySelector('.record-card.active')!.parentElement!.querySelector('.related-finding-ring[aria-pressed="true"]')).not.toBeNull(); + await act(async () => app!.setView('columns')); + expect(host.querySelectorAll('.data-tile')).toHaveLength(2); + expect(host.querySelectorAll('.data-tile .related-finding-ring[aria-pressed="true"]')).toHaveLength(2); + await act(async () => app!.setView('table')); + expect(host.querySelectorAll('tbody .related-finding-ring')).toHaveLength(2); + await act(async () => app!.setView('raw')); + expect(host.querySelectorAll('.related-raw-record .related-finding-ring')).toHaveLength(2); + await act(async () => { host.querySelector('.related-finding-ring')!.click(); await new Promise(resolve => setTimeout(resolve, 20)); }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); + expect(app!.view).toBe('records'); expect(app!.selectedRecordId).toBe(initial.selectedRecordId); + expect(app!.search).toBe(initial.search); expect(app!.statusFilter).toBe(initial.statusFilter); + expect(app!.actionOnly).toBe(true); expect(app!.pathFindingIds).toEqual(initial.pathFindingIds); + expect(app!.facetFilters).toEqual(initial.facetFilters); expect(app!.reviews).toBe(reviews); + expect(host.querySelectorAll('.record-card')).toHaveLength(1); + expect(host.querySelector('.record-list')!.scrollTop).toBe(184); + expect(host.querySelector('.record-list')!.scrollLeft).toBe(17); + expect(JSON.stringify(records)).toBe(original); + await act(async () => app!.resetFilters()); + expect(host.querySelectorAll('.record-card')).toHaveLength(4); + expect(host.querySelectorAll('.record-list .related-finding-ring')).toHaveLength(2); + const unrelated = host.querySelector(`[data-record-id="${records[2].id}"]`)!; + expect(unrelated.querySelector('.related-finding-ring')).toBeNull(); + await act(async () => host.querySelector('.record-list .related-finding-ring')!.click()); + expect(host.querySelectorAll('.record-card')).toHaveLength(2); + await act(async () => host.querySelector('.record-list .related-finding-ring')!.click()); + await act(async () => app!.setActiveSheet('source')); + expect(host.querySelectorAll('.record-card')).toHaveLength(1); + expect(host.querySelector('.record-list .related-finding-ring')).not.toBeNull(); + await act(async () => host.querySelector('.record-list .related-finding-ring')!.click()); + expect(host.querySelectorAll('.record-card')).toHaveLength(2); + const peerLink = host.querySelector(`[data-work-package-finding="${records[1].id}"]`)!; + expect(peerLink).not.toBeNull(); + await act(async () => peerLink.click()); + expect(app!.selectedRecordId).toBe(records[1].id); + expect(host.querySelector('[data-work-package-hidden]')).toBeNull(); + await act(async () => host.querySelector('.record-list .related-finding-ring')!.click()); + expect(app!.activeSheetId).toBe('source'); + expect(app!.selectedRecordId).toBe('source-alias'); + expect(host.querySelectorAll('.record-card')).toHaveLength(1); + await act(async () => app!.setActiveSheet(sheet.id)); + await act(async () => host.querySelector('.record-list .related-finding-ring')!.click()); + vi.mocked(useWorkPackages).mockReturnValue({ status: 'off' } as never); + await act(async () => root.render()); + expect(host.querySelector('.related-finding-ring')).toBeNull(); + expect(host.querySelectorAll('.record-card')).toHaveLength(4); + } finally { await act(async () => root.unmount()); host.remove(); style.remove(); } + }); +}); diff --git a/tests/features/result-window-layout.test.ts b/tests/features/result-window-layout.test.ts index 2ae9c23..6c8dd8b 100644 --- a/tests/features/result-window-layout.test.ts +++ b/tests/features/result-window-layout.test.ts @@ -108,7 +108,7 @@ describe('Result-Fenster-Cluster', () => { onToggleMinimize: () => undefined })); - expect(markup).toContain('Synthetic result'); + expect(markup).toContain('Synthetic result'); expect(markup).not.toContain('results-window-minimize'); }); it('zeigt hinter dem aktiven Fenster nur schmale vertikale Farbreiter und bleibt im Viewport', () => { diff --git a/tests/features/results-deviation-evidence.test.tsx b/tests/features/results-deviation-evidence.test.tsx new file mode 100644 index 0000000..b9e7a9c --- /dev/null +++ b/tests/features/results-deviation-evidence.test.tsx @@ -0,0 +1,98 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useApp } from '../../src/app/AppProvider'; +import { useFilteredRecords } from '../../src/features/search-filter/use-filtered-records'; +import { DatasetViewer } from '../../src/features/dataset-viewer/DatasetViewer'; +import { FloatingResultsWindow } from '../../src/features/linked-results/FloatingResultsWindow'; +import { makeSheet } from '../../src/importers/sheet-factory'; +import { resultSheetRowToneFor } from '../../src/features/linked-results/result-message'; + +vi.mock('../../src/app/AppProvider', () => { const useApp = vi.fn(); return { useApp, useOptionalApp: useApp }; }); +vi.mock('../../src/features/search-filter/use-filtered-records', () => ({ useFilteredRecords: vi.fn() })); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView'); }); + +function smbFixture() { + const findings = makeSheet('Synthetic Work', [ + ['Title', 'Status', 'Source Rule ID', 'Verwandte Daten', 'Source: Scan'], + ['SMB v1 activated on 1 DC', 'Matched', 'S-SMB-v1', 'synthetic-file::rule-details-S-SMB-v1,synthetic-file::domaincontrollersdetail', 'PingCastle · synthetic'] + ], '', 'synthetic.html', { toolId: 'pingcastle', isProjectSheet: true }); + const finding = findings.analysis.records[0]; finding.importFileId = 'synthetic-file'; + const detail = makeSheet('PingCastle · Details', [['Domain controller'], ['DUMMY-DC']], '', 'synthetic.html', + { id: 'pingcastle-section-synthetic-file::rule-details-S-SMB-v1' }); + const inventory = makeSheet('PingCastle · Domain controllers', [['Domain controller', 'SMB v1'], ['DUMMY-OTHER', 'NO']], '', 'synthetic.html', + { id: 'pingcastle-section-synthetic-file::domaincontrollersdetail' }); + return { findings, finding, detail, inventory }; +} + +describe.each(['dark', 'light'])('Results affected evidence (%s)', theme => { + it('opens SMB Results through Work, marks only the exact affected list and retains proof when Work filters hide the finding', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { value: vi.fn(), configurable: true }); + document.documentElement.dataset.theme = theme; + const { findings, finding, detail, inventory } = smbFixture(); + const before = JSON.stringify([finding, detail, inventory]); + vi.mocked(useApp).mockReturnValue({ dataset: { sheets: [findings, detail, inventory], rawText: '', profileId: 'pingcastle' }, + activeSheet: findings, view: 'records', search: '', titleColumn: 'Title', selectedRecordId: finding.id, + reviews: {}, facetFilters: {}, statusFilter: 'all', actionOnly: false, + setView: vi.fn(), setSelectedRecord: vi.fn(), updateReview: vi.fn(), resetFilters: vi.fn() + } as unknown as ReturnType); + vi.mocked(useFilteredRecords).mockReturnValue([finding]); + const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); + try { + await act(async () => root.render()); + await act(async () => [...host.querySelectorAll('button')].find(button => button.textContent === 'Results')!.click()); + const red = host.querySelectorAll('.rich-text-table-row--violation'); + expect(red).toHaveLength(1); expect(red[0].textContent).toBe('DUMMY-DC'); + const other = [...host.querySelectorAll('tbody tr')].find(row => row.textContent?.includes('DUMMY-OTHER'))!; + expect(other.classList.contains('rich-text-table-row--violation')).toBe(false); + vi.mocked(useFilteredRecords).mockReturnValue([]); + await act(async () => root.render()); + expect(host.querySelector('.rich-text-table-row--violation')!.textContent).toBe('DUMMY-DC'); + expect(JSON.stringify([finding, detail, inventory])).toBe(before); + } finally { await act(async () => root.unmount()); host.remove(); } + }); + + it('colors source-marked Maester inline rows and leaves missing/conflicting status or misleading names neutral', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); + const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); + const value = '| Object | Status |\n| --- | --- |\n| DUMMY-BAD | ❌ Failed |\n| DUMMY-GOOD | ✅ Passed |\n| Fail | Unknown |\n| DUMMY-UNKNOWN | Investigate |\n| ✅ DUMMY-CONFLICT | ❌ Failed |'; + try { + await act(async () => root.render()); + expect(host.querySelectorAll('.rich-text-table-row--violation')).toHaveLength(1); + expect(host.querySelectorAll('.rich-text-table-row--match')).toHaveLength(1); + expect(host.querySelectorAll('tbody tr:not([class])')).toHaveLength(3); + } finally { await act(async () => root.unmount()); host.remove(); } + }); +}); + +it('fails closed on unknown rules, foreign imports, changed layouts, missing scope and non-fail outcomes', () => { + const { finding, detail, inventory } = smbFixture(); const row = detail.analysis.records[0]; + const tone = (f = finding, s = detail, outcome: 'fail' | 'pass' | 'warning' = 'fail') => resultSheetRowToneFor('pingcastle', row, outcome, { finding: f, sheet: s }); + expect(tone()).toBe('violation'); expect(tone(finding, detail, 'pass')).toBeUndefined(); expect(tone(finding, detail, 'warning')).toBeUndefined(); + expect(tone({ ...finding, importFileId: 'foreign' })).toBeUndefined(); + expect(tone({ ...finding, importFileId: undefined })).toBeUndefined(); + expect(tone({ ...finding, fields: finding.fields.map(field => field.label === 'Source Rule ID' ? { ...field, value: 'S-Unknown' } : field) })).toBeUndefined(); + expect(tone(finding, { ...detail, analysis: { ...detail.analysis, headers: ['Unknown'] } })).toBeUndefined(); + expect(tone(finding, inventory)).toBeUndefined(); +}); + +it('marks only native-linked, non-ignored Purple Knight affected rows and retains neutral ambiguous/foreign evidence', () => { + const findings = makeSheet('Synthetic', [['Name', 'Status', 'Source Rule ID', 'Result', 'Source: File'], + ['Synthetic spooler', 'IOE Found', 'SI000077', 'SI000077', 'synthetic-pk.html']], '', 'synthetic-pk.html'); + const finding = findings.analysis.records[0]; + const evidence = makeSheet('Purple Knight · SI000077', [['HostName', 'Ignored'], ['DUMMY-DC', 'False'], ['DUMMY-IGNORED', 'True']], '', 'synthetic-pk.html', { sourceTableIndex: 3 }); + const tone = (sheet = evidence, row = evidence.analysis.records[0], outcome: 'pass' | 'fail' = 'fail') => + resultSheetRowToneFor('purple-knight', row, outcome, { finding, sheet }); + expect(tone()).toBe('violation'); + expect(tone(evidence, evidence.analysis.records[1])).toBeUndefined(); + expect(tone(evidence, evidence.analysis.records[0], 'pass')).toBeUndefined(); + expect(tone({ ...evidence, sourceFile: 'foreign.html' })).toBeUndefined(); + expect(tone({ ...evidence, name: 'Similar spooler title', sourceSheet: 'Other' })).toBeUndefined(); + expect(tone({ ...evidence, analysis: { ...evidence.analysis, headers: ['HostName', 'Unknown'] } })).toBeUndefined(); +}); diff --git a/tests/features/results-excel-export.test.tsx b/tests/features/results-excel-export.test.tsx new file mode 100644 index 0000000..bc71d43 --- /dev/null +++ b/tests/features/results-excel-export.test.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { read, utils } from 'xlsx'; +import { FloatingResultsWindow } from '../../src/features/linked-results/FloatingResultsWindow'; +import { makeSheet } from '../../src/importers/sheet-factory'; +import { buildResultsWindowExcel } from '../../src/exports/results-window-excel'; +import { resultsExcelContent } from '../../src/features/linked-results/results-excel-content'; +import { downloadBrowserBlob } from '../../src/platform/browser/browser-download-adapter'; + +vi.mock('../../src/platform/browser/browser-download-adapter', () => ({ downloadBrowserBlob: vi.fn() })); +vi.mock('../../src/app/AppProvider', () => ({ useOptionalApp: () => undefined })); +afterEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); }); + +describe.each(['dark', 'light'])('complete Results Excel (%s)', theme => { + it('keeps every linked, Markdown and HTML row; exports all content or exactly the checked tables', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); + document.documentElement.dataset.theme = theme; + const data = Array.from({ length: 1001 }, (_, index) => [`OBJECT-${index + 1}`, index === 1000 ? '=literal-evidence' : String(index)]); + const sheet = makeSheet('Full evidence', [['Object', 'Value'], ...data], '', 'synthetic.xlsx'); + const markdown = '| Object | Status |\n| --- | --- |\n' + data.map(([name]) => `| ${name} | Failed |`).join('\n'); + const html = '

    Original source note

    ' + data.map(([name]) => ``).join('') + '
    Object
    ${name}
    Hidden technical source
    '; + const sections = [{ title: 'Markdown evidence', value: markdown }, { title: 'HTML evidence', value: html }, { title: 'Plain evidence', value: 'Complete result prose' }]; + const before = JSON.stringify({ sheet, sections }); + const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); + try { + await act(async () => root.render( {}} onClose={() => {}} onToggleBookmark={() => {}}/>)); + const tables = [...host.querySelectorAll('table')]; + expect(tables).toHaveLength(3); + expect(tables.map(table => table.tBodies[0].rows.length)).toEqual([1001, 1001, 1001]); + const all = host.querySelector('[aria-label="Export entire Results to Excel"]')!; + const selected = host.querySelector('[aria-label="Export selected tables to Excel"]')!; + expect(all.disabled).toBe(false); expect(selected.disabled).toBe(true); + expect(all.previousElementSibling?.querySelector('svg')).not.toBeNull(); + await act(async () => all.click()); + expect(downloadBrowserBlob).toHaveBeenCalledOnce(); + expect(vi.mocked(downloadBrowserBlob).mock.calls[0][1]).toMatch(/-all\.xlsx$/); + const snapshot = resultsExcelContent(host.querySelector('.results-window-body')!, 'Full synthetic result', sections); + const full = read(buildResultsWindowExcel(snapshot), { type: 'array' }); + const tableNames = full.SheetNames.filter(name => /^Results(?: \d+)?$/.test(name)); + expect(tableNames).toHaveLength(3); + for (const name of tableNames) { + const rows = utils.sheet_to_json(full.Sheets[name], { header: 1 }); + expect(rows).toHaveLength(name === 'Results' ? 1006 : 1003); expect(rows[1002][0]).toBe('OBJECT-1001'); + expect(rows[0][0]).toContain('Full synthetic result'); + } + expect(full.Sheets[tableNames[2]].B1003.t).toBe('s'); expect(full.Sheets[tableNames[2]].B1003.f).toBeUndefined(); + expect(full.Sheets.Results.A1006.v).toContain('Complete result prose'); + expect(full.SheetNames).not.toContain('Results text'); + const overflow = utils.sheet_to_json(full.Sheets['Long text'], { header: 1 }).slice(2); + const sourceValue = full.Sheets['Source text'].B4.v; + expect(sourceValue === html ? sourceValue : overflow.filter(row => row[0] === 'Source text' && row[1] === '4' && row[2] === '2').map(row => row[4]).join('')).toBe(html); + expect(full.Sheets.Results.A1006.v).not.toContain('Select table for Excel export'); + const checkboxes = [...host.querySelectorAll('input[type="checkbox"]')]; + await act(async () => checkboxes[1].click()); + expect(selected.disabled).toBe(false); + await act(async () => selected.click()); + expect(vi.mocked(downloadBrowserBlob).mock.calls.at(-1)![1]).toMatch(/-selected\.xlsx$/); + const subset = read(buildResultsWindowExcel(snapshot, new Set([tables[1].dataset.resultTableId!])), { type: 'array' }); + expect(subset.SheetNames).toHaveLength(2); expect(subset.SheetNames).not.toContain('Source text'); + expect(utils.sheet_to_json(subset.Sheets[subset.SheetNames[0]], { header: 1 })).toHaveLength(1003); + expect(subset.SheetNames[1]).toBe('Export info'); + await act(async () => checkboxes[1].click()); expect(selected.disabled).toBe(true); + expect(JSON.stringify({ sheet, sections })).toBe(before); + } finally { await act(async () => root.unmount()); host.remove(); } + }, 30000); +}); + +it('preserves long Unicode cells, literal formulas, leading zeros and duplicate invalid sheet names without loss', () => { + const long = 'x'.repeat(32766) + '🧪' + 'tail'.repeat(1000); + const content = { title: 'Synthetic', text: long, sourceSections: [], tables: [ + { id: 'a', title: 'Duplicate/[name]'.repeat(4), rows: [['Value'], ['0000123'], ['=1+1'], [long]] }, + { id: 'b', title: 'Duplicate/[name]'.repeat(4), rows: [['Value'], ['Second']] } + ] }; + const workbook = read(buildResultsWindowExcel(content), { type: 'array' }); + expect(new Set(workbook.SheetNames).size).toBe(workbook.SheetNames.length); + expect(workbook.SheetNames.every(name => name.length <= 31 && !/[\\/?:*\[\]]/.test(name))).toBe(true); + const table = workbook.Sheets[workbook.SheetNames[0]]; + expect(table.A3.v).toBe('0000123'); expect(table.A4.v).toBe('=1+1'); expect(table.A4.f).toBeUndefined(); + const parts = utils.sheet_to_json(workbook.Sheets['Long text'], { header: 1 }).slice(2); + expect(parts.filter(row => row[0] === 'Results' && row[1] === '5').map(row => row[4]).join('')).toBe(long); + expect(parts.filter(row => row[0] === 'Results' && row[1] === '8').map(row => row[4]).join('')).toBe(long); + expect(() => buildResultsWindowExcel(content, new Set())).toThrow('Select at least one table'); +}); + +it('keeps the complete export active for a text-only result', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); + const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); + try { + await act(async () => root.render( {}} onClose={() => {}}/>)); + const all = host.querySelector('[aria-label="Export entire Results to Excel"]')!; + expect(all.disabled).toBe(false); + await act(async () => all.click()); expect(downloadBrowserBlob).toHaveBeenCalledOnce(); + expect(host.querySelector('[aria-label="Export selected tables to Excel"]')!.disabled).toBe(true); + } finally { await act(async () => root.unmount()); host.remove(); } +}); + +it('isolates table selection per Results window and disables selective export when a selected table disappears', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); + const sheet = makeSheet('Synthetic', [['Value'], ['Evidence']], '', 'synthetic.csv'); + const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); + const window = (id: string, sheets = [sheet]) => {}} onClose={() => {}}/>; + try { + await act(async () => root.render(<>{window('first')}{window('second')})); + const dialogs = host.querySelectorAll('[role="dialog"]'); + await act(async () => dialogs[0].querySelector('input')!.click()); + const selected = (dialog: Element) => dialog.querySelector('[aria-label="Export selected tables to Excel"]')!; + expect(selected(dialogs[0]).disabled).toBe(false); expect(selected(dialogs[1]).disabled).toBe(true); + await act(async () => root.render(<>{window('first', [])}{window('second')})); + expect(selected(dialogs[0]).disabled).toBe(true); + } finally { await act(async () => root.unmount()); host.remove(); } +}); + +it('retains completely empty evidence rows in the spreadsheet extent', () => { + const bytes = buildResultsWindowExcel({ title: 'Empty rows', text: '', sourceSections: [], tables: [ + { id: 'blank', title: 'Evidence', rows: [['Value'], [''], ['Evidence'], ['']] } + ] }); + const workbook = read(bytes, { type: 'array' }); + expect(utils.sheet_to_json(workbook.Sheets.Results, { header: 1, defval: '' })).toEqual([['Empty rows\nEvidence'], ['Value'], [''], ['Evidence'], ['']]); +}); diff --git a/tests/features/results-finding-context-export.test.tsx b/tests/features/results-finding-context-export.test.tsx new file mode 100644 index 0000000..72b4c3e --- /dev/null +++ b/tests/features/results-finding-context-export.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, expect, it, vi } from 'vitest'; +import { read, utils } from 'xlsx'; +import { FloatingResultsWindow } from '../../src/features/linked-results/FloatingResultsWindow'; +import { resultsFindingContext } from '../../src/features/linked-results/results-excel-content'; +import { buildResultsWindowExcel } from '../../src/exports/results-window-excel'; +import { makeSheet } from '../../src/importers/sheet-factory'; +import { downloadBrowserBlob } from '../../src/platform/browser/browser-download-adapter'; +vi.mock('../../src/platform/browser/browser-download-adapter', () => ({ downloadBrowserBlob: vi.fn() })); +vi.mock('../../src/app/AppProvider', () => ({ useOptionalApp: () => undefined })); +afterEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); delete document.documentElement.dataset.theme; }); +const description = 'Checks certificate templates allowing a subjectAltName in the CSR.'; +const result = 'Found 17 published templates on which unprivileged users can write properties.'; +const impact = 'Controlling templates allows issuance of an arbitrary certificate. References Original reference'; +const finding = makeSheet('Synthetic indicator', [['Name','Description','Result Message','Number of Results','Likelihood of Compromise','Result'], + ['Certificate templates',description,result,'17',impact,'Failed']], '', 'synthetic.csv').analysis.records[0]; +const expected = [['Description','Description',description],['Problem','Result',result],['Problem','Affected','17'],['Problem','Impact',impact]]; +async function workbook(blob: Blob) { + const bytes = await new Promise((resolve,reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as ArrayBuffer); reader.onerror = reject; reader.readAsArrayBuffer(blob); }); + return read(bytes,{type:'array'}); +} +it.each(['light','dark'])('includes the complete original finding context in actual all and selected downloads (%s)', async theme => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT',true);vi.stubGlobal('ResizeObserver',class { observe() {} disconnect() {} }); + document.documentElement.dataset.theme=theme; + const sheets = [makeSheet('Objects',[['Object'],['DUMMY-01'],['DUMMY-02']],'','synthetic.csv'),makeSheet('Second table',[['Other'],['DUMMY-03']],'','synthetic.csv')]; + const before=JSON.stringify({finding,sheets});const host=document.createElement('div');document.body.append(host);const root=createRoot(host); + try { + await act(async () => root.render({}} onClose={()=>{}}/>)); + await act(async () => host.querySelector('[aria-label="Export entire Results to Excel"]')!.click()); + const all=await workbook(vi.mocked(downloadBrowserBlob).mock.calls.at(-1)![0]); + expect(all.SheetNames.slice(0,2)).toEqual(['Results','Results 02']); + expect(utils.sheet_to_json(all.Sheets['Finding context'],{header:1}).slice(2)).toEqual(expected); + expect(all.Sheets['Finding context'].A1.v).toBe('Certificate templates'); + await act(async () => host.querySelector('input[type="checkbox"]')!.click()); + await act(async () => host.querySelector('[aria-label="Export selected tables to Excel"]')!.click()); + const subset=await workbook(vi.mocked(downloadBrowserBlob).mock.calls.at(-1)![0]); + expect(subset.SheetNames).toHaveLength(3);expect(subset.SheetNames[1]).toBe('Finding context'); + expect(utils.sheet_to_json(subset.Sheets['Finding context'],{header:1}).slice(2)).toEqual(expected); + expect(subset.SheetNames).not.toContain(all.SheetNames[1]); + expect(JSON.stringify({finding,sheets})).toBe(before); + } finally {await act(async()=>root.unmount());host.remove();} +}); +it('preserves long descriptions completely and handles missing context without inventing numbers',()=>{ + const long='Long description 🧪 '.repeat(2500);const copy={...finding,fields:finding.fields.map(field=>field.label==='Description'?{...field,value:long}:field)}; + const context=resultsFindingContext(copy,'purple-knight',[],'fail'); + const wb=read(buildResultsWindowExcel({title:'Text only',text:'Result',tables:[],sourceSections:[],findingContext:context}),{type:'array'}); + expect(wb.SheetNames[0]).toBe('Results'); + const parts=utils.sheet_to_json(wb.Sheets['Long text'],{header:1}).slice(2); + expect(parts.filter(row=>row[0]==='Finding context'&&row[1]==='3'&&row[2]==='3').map(row=>row[4]).join('')).toBe(long); + expect(resultsFindingContext(undefined)).toEqual([]); + const generic={...finding,fields:[{label:'Description',value:'Original',index:0},{label:'Result',value:'Failed',index:1}]}; + expect(resultsFindingContext(generic,'unknown')).toEqual([{section:'Description',label:'Description',value:'Original'}]); +}); +it('retains the existing GPO problem comparison and backup scope without re-evaluation',()=>{ + const policy={...finding,fields:[{label:'Description',value:'Original policy explanation',index:0},{label:'Status',value:'Fail',index:1}], + policyBaselineComparisons:[{baseline:'Synthetic baseline',status:'Fail',summary:'Stored comparison',context:'Backup only',scopeNotice:'Not effective host state',explanation:'Source',remediation:'Original guidance', + recommendations:[{template:'Synthetic',settingId:'s1',comparison:'different',expected:'Disabled',additional:[],missing:[]}]}]}; + const before=JSON.stringify(policy);const context=resultsFindingContext(policy,'group-policy',[],'fail'); + expect(context).toContainEqual({section:'Description',label:'Description',value:'Original policy explanation'}); + expect(context).toContainEqual({section:'Problem',label:'Expected',value:'Disabled'}); + expect(context.some(row=>row.label==='Affected')).toBe(false); + expect(JSON.stringify(policy)).toBe(before); +}); diff --git a/tests/features/results-header-actions.test.tsx b/tests/features/results-header-actions.test.tsx new file mode 100644 index 0000000..079d020 --- /dev/null +++ b/tests/features/results-header-actions.test.tsx @@ -0,0 +1,38 @@ +// @vitest-environment jsdom +import { act, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { expect, it, vi } from 'vitest'; +import { FloatingResultsWindow } from '../../src/features/linked-results/FloatingResultsWindow'; +import { makeSheet } from '../../src/importers/sheet-factory'; +vi.mock('../../src/app/AppProvider', () => ({useOptionalApp:()=>undefined})); +vi.mock('../../src/features/related-findings/RelatedFindings',()=>({RelatedFindingRing:({record}:{record:{id:string}})=>record.id==='grouped'?:null})); +it.each(['light','dark'])('keeps title and actions separate with a stable optional group slot through minimize/restore (%s)',async theme=>{ + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT',true);vi.stubGlobal('ResizeObserver',class{observe(){}disconnect(){}}); + document.documentElement.dataset.theme=theme; + const record=makeSheet('Synthetic',[['Name'],['Evidence']],'','synthetic.csv').analysis.records[0]; + const before=JSON.stringify(record);const bookmark=vi.fn(),open=vi.fn(),close=vi.fn(); + const host=document.createElement('div');document.body.append(host);const root=createRoot(host); + const long='Unexpected principal with metadata edit permission on a GPO linked to a critical AD container '.repeat(8); + function Example(){const [minimized,setMinimized]=useState(true);return <>{['grouped','ungrouped'].map((id,index)=>setMinimized(value=>!value)} onActivate={()=>{}} onClose={close} onToggleBookmark={bookmark} onOpenRecord={open}/>)}} + try{ + await act(async()=>root.render()); + const windows=()=>[...host.querySelectorAll('.results-window')]; + for(const node of windows()){ + expect(node.querySelector('.results-window-actions .results-window-related-slot')).not.toBeNull(); + expect(node.querySelector('.results-window-head > .related-finding-ring')).toBeNull(); + expect(node.querySelector('.results-window-actions')?.lastElementChild?.classList.contains('results-window-close')).toBe(true); + expect(node.querySelector('.results-window-close')?.previousElementSibling?.classList.contains('results-window-related-slot')).toBe(true); + } + expect(windows()[0].querySelector('.results-window-related-slot button')).not.toBeNull(); + expect(windows()[1].querySelector('.results-window-related-slot')?.children).toHaveLength(0); + expect(windows()[1].querySelector('.results-window-name')?.getAttribute('title')).toBe(long.trim()); + await act(async()=>windows()[1].querySelector('.results-window-bookmark')!.click());expect(bookmark).toHaveBeenCalledOnce(); + await act(async()=>windows()[1].querySelector('.results-window-action')!.click());expect(open).toHaveBeenCalledOnce(); + await act(async()=>windows()[1].querySelector('.results-window-toggle')!.click()); + expect(windows().every(node=>!node.classList.contains('is-minimized'))).toBe(true); + expect(windows().every(node=>node.querySelector('.results-window-actions')?.lastElementChild?.classList.contains('results-window-close'))).toBe(true); + expect(windows()[0].querySelector('.results-window-related-slot button')).not.toBeNull(); + await act(async()=>windows()[1].querySelector('.results-window-close')!.click());expect(close).toHaveBeenCalledOnce(); + expect(JSON.stringify(record)).toBe(before); + }finally{await act(async()=>root.unmount());host.remove();vi.unstubAllGlobals();delete document.documentElement.dataset.theme;} +}); diff --git a/tests/features/security-domain-filter-interaction.test.tsx b/tests/features/security-domain-filter-interaction.test.tsx index d36f660..c217789 100644 --- a/tests/features/security-domain-filter-interaction.test.tsx +++ b/tests/features/security-domain-filter-interaction.test.tsx @@ -82,7 +82,7 @@ it('uses the Work sidebar domains as the same visible single-select facet as the row('Inactive account'), row('Firewall disabled', 'Fail', 'Low'), row('Backup unavailable') ])); try { - const buttons = [...ui.container.querySelectorAll('.sidebar-domain-button')]; + const buttons = [...ui.container.querySelectorAll('.work-filter-group:first-child .work-filter-value')]; expect(buttons.map(button => button.querySelector('span')?.textContent)).toEqual(securityDomainCatalog.map(domain => domain.label)); const identity = buttons.find(button => button.textContent?.includes('Identity & Authentication'))!; const recovery = buttons.find(button => button.textContent?.includes('Monitoring & Recovery'))!; diff --git a/tests/features/source-selection-stays-in-view.test.tsx b/tests/features/source-selection-stays-in-view.test.tsx index d7bd0fa..f451314 100644 --- a/tests/features/source-selection-stays-in-view.test.tsx +++ b/tests/features/source-selection-stays-in-view.test.tsx @@ -12,7 +12,7 @@ import type { ImportedDataset } from '../../src/domain/dataset'; import type { EnvironmentSnapshot } from '../../src/domain/environment-context'; import type { DynamicFacetFilter } from '../../src/features/search-filter/dynamic-facets'; -vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() })); +vi.mock('../../src/app/AppProvider', () => { const useApp = vi.fn(); return { useApp, useOptionalApp: useApp }; }); vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => Synthetic timeslot })); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); @@ -189,12 +189,12 @@ describe('source selection preserves the current view', () => { const header = container.querySelector('.client-priority-findings > header'); expect(header?.querySelector('h2')?.textContent).toBe('Data'); expect(header?.textContent).toContain('Deviations'); - expect(header?.querySelector('.client-priority-total')?.getAttribute('aria-label')).toBe('Deviations: 2, 67%'); + expect(header?.querySelector('.client-priority-total')?.getAttribute('aria-label')).toBe('Deviations: 2, 67%, Of assessed checks'); expect(header?.textContent).not.toContain('Critical'); expect(header?.textContent).not.toContain('High'); const priorityTiles = container.querySelectorAll('.client-priority > section'); expect(priorityTiles[0]?.classList.contains('client-priority-findings')).toBe(true); - expect(priorityTiles[1]?.classList.contains('client-domain-information')).toBe(true); + expect(container.querySelector('.client-overview-main > .client-domain-information')).not.toBeNull(); const dataFacts = priorityTiles[0]?.querySelector('.client-data-facts'); expect(dataFacts?.querySelectorAll('.client-data-fact')).toHaveLength(6); expect(dataFacts?.querySelectorAll('.client-glyph')).toHaveLength(6); diff --git a/tests/features/tile-segment-grammar.test.ts b/tests/features/tile-segment-grammar.test.ts index 7fa68a0..f97b899 100644 --- a/tests/features/tile-segment-grammar.test.ts +++ b/tests/features/tile-segment-grammar.test.ts @@ -22,7 +22,7 @@ describe('binding segment and tile grammar', () => { '--segment-surface:', '--segment-border:', '--segment-radius: 10px;', '--segment-inset: var(--space-3);', '--segment-gap: var(--space-3);', '--tile-surface:', '--tile-surface-hover:', '--tile-border:', - '--tile-selected-border: var(--marking-color);', '--tile-radius: var(--radius-sm);', + '--tile-selected-border: var(--marking-color);', '--tile-radius: var(--radius-md);', '--tile-compact-width: 186px;', '--tile-content-width: 358px;', '--tile-content-min-height: 223px;', '--tile-card-shadow:' @@ -51,9 +51,9 @@ describe('binding segment and tile grammar', () => { expect(tileContract).toMatch(/\.donut-metric-rack\s*\{[^}]*grid-template-columns:\s*repeat\(auto-fill, var\(--tile-compact-width\)\);[^}]*justify-content:\s*start;/s); expect(tileContract).toMatch(/\.datahub-reference-controls,\s*\.datahub-tenant-card-rack\s*\{[^}]*grid-template-columns:\s*repeat\(6, minmax\(0, 1fr\)\);/s); expect(components).toMatch(/\.donut-metric-card\s*\{[^}]*border-radius:\s*var\(--tile-radius\);[^}]*background:\s*var\(--tile-surface\);[^}]*box-shadow:\s*var\(--tile-card-shadow\);/s); - expect(components).toMatch(/\.sidebar-domain-rack\s*\{[^}]*border-top:\s*var\(--border-hair\) solid var\(--border-subtle\);[^}]*background:\s*transparent;/s); - expect(components).toMatch(/\.sidebar-domain-button\s*\{[^}]*border-radius:\s*var\(--btn-radius\);[^}]*background:\s*transparent;/s); - expect(components).toMatch(/\.sidebar-domain-button\[aria-pressed="true"\]\s*\{[^}]*background:\s*var\(--surface-selected\);/s); + expect(components).toMatch(/\.work-filter-panel\s*\{[^}]*border:\s*var\(--border-hair\) solid var\(--border-subtle\);[^}]*background:\s*var\(--surface-panel\);/s); + expect(components).toMatch(/\.work-filter-value\s*\{[^}]*border-radius:\s*0;[^}]*background:\s*transparent;/s); + expect(components).toMatch(/\.work-filter-value\[aria-pressed="true"\]\s*\{[^}]*background:\s*var\(--surface-selected\);/s); expect(datahubRedesign).toMatch(/\.datahub-tenant-card-rack\s*\{[^}]*repeat\(auto-fill, var\(--tile-compact-width\)\);[^}]*justify-content:\s*start;/s); expect(datahubRedesign).toMatch(/\.datahub-tenant-card\.is-selected\s*\{[^}]*border-color:\s*var\(--tile-selected-border\);[^}]*outline:\s*calc\(var\(--bw-emphasis\) \+ var\(--bw-hairline\)\) solid var\(--tile-selected-border\);/s); @@ -62,7 +62,7 @@ describe('binding segment and tile grammar', () => { expect(datahub).toMatch(/\.dashboard-status-rack\s*\{[^}]*padding:\s*var\(--segment-inset\);[^}]*background:\s*var\(--segment-surface\);/s); expect(datahub).toMatch(/\.dashboard-status-metric\s*\{[^}]*border-radius:\s*var\(--tile-radius\);[^}]*box-shadow:\s*var\(--tile-card-shadow\);/s); expect(datahub).toMatch(/\.dashboard-distribution-grid\s*\{[^}]*border-radius:\s*var\(--segment-radius\);/s); - expect(datahub).toMatch(/\.db-structure-card\s*\{[^}]*border:\s*var\(--border-hair\) solid var\(--tile-border\);[^}]*border-radius:\s*0;[^}]*background:\s*var\(--tile-surface\);[^}]*box-shadow:\s*none;/s); + expect(datahub).toMatch(/\.db-structure-card\s*\{[^}]*border:\s*var\(--border-hair\) solid var\(--tile-border\);[^}]*border-radius:\s*var\(--tile-radius\);[^}]*background:\s*var\(--tile-surface\);[^}]*box-shadow:\s*none;/s); expect(layout).toMatch(/\.tile-view-scroll\s*\{[^}]*padding:\s*var\(--panel-inset\);/s); expect(legacy).toMatch(/\.tile-grid\s*\{[^}]*justify-content:\s*start;[^}]*border-radius:\s*var\(--segment-radius\);/s); diff --git a/tests/features/work-filter-panel.test.tsx b/tests/features/work-filter-panel.test.tsx new file mode 100644 index 0000000..18967a7 --- /dev/null +++ b/tests/features/work-filter-panel.test.tsx @@ -0,0 +1,182 @@ +// @vitest-environment jsdom +import { act, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, expect, it, vi } from 'vitest'; +import { DockableFilterPanel, type FilterPanelGroup } from '../../src/features/sidebar/DockableFilterPanel'; +import { readFileSync } from 'node:fs'; +import { FloatingResultsWindow } from '../../src/features/linked-results/FloatingResultsWindow'; + +vi.mock('../../src/app/AppProvider', () => ({ useOptionalApp: () => undefined })); + +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren(); }); +const labels = ['Security Domain', 'Business Impact', 'Context Cluster', 'Work Package']; +const ids = ['derived:security-domain', 'report:business-impact', 'context:cluster', 'remediation:work-package']; +const rectangle = (left: number, top: number, width: number, height: number) => ({ left, top, right: left + width, bottom: top + height, width, height, x: left, y: top, toJSON() {} }); + +it.each(['light', 'dark'])('uses content height after closing floating groups and retains the resize limit on reopening (%s)', async theme => { + document.documentElement.dataset.theme = theme; + const ui = await mount(); + try { + await act(async () => ui.panel().querySelector('[aria-label="Detach filters"]')!.click()); + const limit = ui.panel().style.maxHeight; + for (const open of [true, false, true]) { + const group = ui.panel().querySelector('details')!; + await act(async () => { group.open = open; group.dispatchEvent(new Event('toggle')); }); + expect(group.open).toBe(open); + expect(ui.panel().style.height).toBe('auto'); + expect(ui.panel().style.maxHeight).toBe(limit); + expect(ui.panel().querySelectorAll('.work-filter-resize')).toHaveLength(8); + } + } finally { await act(async () => ui.root.unmount()); ui.host.remove(); delete document.documentElement.dataset.theme; } +}); + +it.each(['dark', 'light'])('resizes every edge and corner in both directions, preserving filters and opposite anchors (%s)', async theme => { + document.documentElement.dataset.theme = theme; + const ui = await mount(); + try { + await act(async () => ui.panel().querySelector('.work-filter-value')!.click()); + const filters = ui.host.querySelector('output')!.textContent; + await act(async () => ui.panel().querySelector('[aria-label="Detach filters"]')!.click()); + expect(ui.panel().querySelectorAll('.work-filter-resize')).toHaveLength(8); + const geometryBeforeCapture = ui.panel().getAttribute('style'); + ui.panel().getBoundingClientRect = () => rectangle(0, 0, 0, 0); + await act(async () => window.dispatchEvent(new Event('resize'))); + expect(ui.panel().getAttribute('style')).toBe(geometryBeforeCapture); + const viewportWidth = window.innerWidth, viewportHeight = window.innerHeight; + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1 }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 1 }); + try { + await act(async () => window.dispatchEvent(new Event('resize'))); + expect(ui.panel().getAttribute('style')).toBe(geometryBeforeCapture); + } finally { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: viewportWidth }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: viewportHeight }); + } + for (const edge of ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw']) { + for (const direction of [-1, 1]) { + ui.panel().getBoundingClientRect = () => rectangle(300, 200, 360, 300); + const dx = edge.includes('w') ? -direction * 30 : edge.includes('e') ? direction * 30 : 0; + const dy = edge.includes('n') ? -direction * 30 : edge.includes('s') ? direction * 30 : 0; + await act(async () => pointer(ui.panel().querySelector(`.work-filter-resize-${edge}`)!, 'pointerdown', 300, 200)); + await act(async () => pointer(window, 'pointermove', 300 + dx, 200 + dy)); + const { style } = ui.panel(); + expect(parseFloat(style.width)).toBe(edge.includes('e') || edge.includes('w') ? 360 + direction * 30 : 360); + expect(parseFloat(style.maxHeight)).toBe(edge.includes('n') || edge.includes('s') ? 300 + direction * 30 : 300); + expect(parseFloat(style.left)).toBe(edge.includes('w') ? 300 + dx : 300); + expect(parseFloat(style.top)).toBe(edge.includes('n') ? 200 + dy : 200); + await act(async () => pointer(window, 'pointerup', 0, 150)); + expect(ui.panel().classList.contains('is-floating')).toBe(true); // Resize release over the parking area never docks. + expect(ui.host.querySelector('output')!.textContent).toBe(filters); + } + } + ui.panel().getBoundingClientRect = () => rectangle(300, 200, 360, 300); + await act(async () => pointer(ui.panel().querySelector('.work-filter-resize-nw')!, 'pointerdown', 300, 200)); + await act(async () => pointer(window, 'pointermove', 3000, 3000)); + expect(ui.panel().style.width).toBe('240px'); expect(ui.panel().style.maxHeight).toBe('120px'); + await act(async () => pointer(window, 'pointercancel', 3000, 3000)); + await act(async () => ui.panel().querySelector('[aria-label="Dock filters"]')!.click()); + expect(ui.host.querySelector('output')!.textContent).toBe(filters); + expect(ui.panel().querySelectorAll('.work-filter-resize')).toHaveLength(0); + } finally { await act(async () => ui.root.unmount()); delete document.documentElement.dataset.theme; } +}); + +it('places floating filters in the uppermost normal UI layer, independent of Results activation order', () => { + const css = readFileSync('src/styles/components.css', 'utf8'); + const tokens = readFileSync('src/styles/ui-tokens.css', 'utf8'); + expect(css).toMatch(/\.work-filter-panel\.is-floating\s*\{[^}]*z-index:\s*var\(--z-floating-filters\)/); + expect(tokens).toContain('--z-floating-filters: 2147483647'); +}); + +it.each(['dark', 'light'])('keeps detached filters above repeatedly activated Results without changing filter selection (%s)', async theme => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); + document.documentElement.dataset.theme = theme; + const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); + function Harness() { + const [zIndex, setZIndex] = useState(2100); + const [selected, setSelected] = useState(false); + return <> setZIndex(current => current + 1)} onClose={() => {}}/> + setSelected(current => !current) }]}/>; + } + try { + await act(async () => root.render()); + await act(async () => host.querySelector('[aria-label="Detach filters"]')!.click()); + const upperLayer = Number(readFileSync('src/styles/ui-tokens.css', 'utf8').match(/--z-floating-filters:\s*(\d+)/)![1]); + for (let index = 0; index < 4; index++) { + await act(async () => pointer(host.querySelector('.results-window')!, 'pointerdown', 700, 400)); + expect(Number(host.querySelector('.results-window')!.style.zIndex)).toBeLessThan(upperLayer); + expect(document.body.querySelector('.work-filter-panel.is-floating')).not.toBeNull(); + } + await act(async () => document.body.querySelector('.work-filter-panel.is-floating .work-filter-value')!.click()); + expect(document.body.querySelector('.work-filter-panel.is-floating [aria-pressed="true"]')).not.toBeNull(); + } finally { await act(async () => root.unmount()); host.remove(); delete document.documentElement.dataset.theme; } +}); +function pointer(target: EventTarget, type: string, x: number, y: number) { + const event = new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y, button: 0 }); + Object.defineProperty(event, 'pointerId', { value: 1 }); target.dispatchEvent(event); +} +async function mount() { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); + function Harness() { + const [selected, setSelected] = useState>({ 'other-filter': ['keep'] }); + const groups: FilterPanelGroup[] = ids.map((id, index) => ({ id, label: labels[index], + values: [{ value: `value-${index}`, label: `Synthetic ${labels[index]}`, count: 3 }], selected: selected[id] ?? [], + onSelect: value => setSelected(current => ({ ...current, [id]: current[id]?.includes(value) ? [] : [value] })) })); + return <>{JSON.stringify(selected)}; + } + await act(async () => root.render()); + const park = host.querySelector('.work-filter-parking')!; + park.getBoundingClientRect = () => rectangle(0, 140, 210, 250); + const panel = () => document.querySelector('.work-filter-panel')!; + panel().getBoundingClientRect = () => rectangle(0, 140, 210, 250); + return { host, root, panel, park }; +} +it.each(['dark', 'light'])('keeps four compact disclosures and exact filter selections through detach and click-to-dock (%s)', async theme => { + document.documentElement.dataset.theme = theme; + const ui = await mount(); + try { + expect([...ui.panel().querySelectorAll('summary')].map(item => item.textContent)).toEqual(labels.map(label => `▤${label}`)); + expect([...ui.panel().querySelectorAll('details')].every(item => !item.open)).toBe(true); + const details = ui.panel().querySelector('details')!; + await act(async () => { details.open = true; details.dispatchEvent(new Event('toggle')); }); + for (const group of ui.panel().querySelectorAll('details')) { + await act(async () => { group.open = true; group.dispatchEvent(new Event('toggle')); }); + await act(async () => group.querySelector('.work-filter-value')!.click()); + } + expect(ui.host.querySelector('output')?.textContent).toContain('"other-filter":["keep"]'); + expect(ui.panel().querySelectorAll('[aria-pressed="true"]')).toHaveLength(4); + await act(async () => ui.panel().querySelector('[aria-label="Detach filters"]')!.click()); + expect(ui.host.querySelector('.work-filter-panel')).toBeNull(); + expect(ui.panel().classList.contains('is-floating')).toBe(true); + expect(document.activeElement?.getAttribute('aria-label')).toBe('Dock filters'); + expect(ui.panel().querySelector('details')?.open).toBe(true); + expect(ui.panel().querySelectorAll('[aria-pressed="true"]')).toHaveLength(4); + await act(async () => ui.panel().querySelector('[aria-label="Dock filters"]')!.click()); + expect(ui.host.querySelector('.work-filter-panel')).toBeTruthy(); + expect(document.activeElement?.getAttribute('aria-label')).toBe('Detach filters'); + expect(ui.panel().querySelector('details')?.open).toBe(true); + await act(async () => ui.panel().querySelector('.work-filter-value')!.click()); + expect(ui.panel().querySelector('[aria-pressed="false"]')).toBeTruthy(); + } finally { await act(async () => ui.root.unmount()); delete document.documentElement.dataset.theme; } +}); +it('detaches by dragging, moves freely, and snaps back when dropped in its parking position', async () => { + const ui = await mount(); + try { + await act(async () => pointer(ui.panel().querySelector('header')!, 'pointerdown', 70, 150)); + await act(async () => pointer(window, 'pointermove', 470, 250)); + expect(ui.panel().classList.contains('is-floating')).toBe(true); + expect(ui.panel().style.left).toBe('400px'); + await act(async () => pointer(window, 'pointerup', 470, 250)); + ui.panel().getBoundingClientRect = () => rectangle(400, 240, 360, 250); + await act(async () => pointer(ui.panel().querySelector('header')!, 'pointerdown', 470, 250)); + await act(async () => pointer(window, 'pointermove', 70, 150)); + expect(ui.park.classList.contains('is-target')).toBe(true); + await act(async () => pointer(window, 'pointerup', 70, 150)); + expect(ui.host.querySelector('.work-filter-panel')).toBeTruthy(); + expect(document.querySelector('.work-filter-panel.is-floating')).toBeNull(); + expect(ui.park.classList.contains('is-target')).toBe(false); + } finally { await act(async () => ui.root.unmount()); } +}); diff --git a/tests/features/work-package-heatmap.test.tsx b/tests/features/work-package-heatmap.test.tsx new file mode 100644 index 0000000..5f3400b --- /dev/null +++ b/tests/features/work-package-heatmap.test.tsx @@ -0,0 +1,114 @@ +// @vitest-environment jsdom +import { act, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { expect, it, vi } from 'vitest'; +import { useApp } from '../../src/app/AppProvider'; +import { SidebarSecurityDomains } from '../../src/features/sidebar/SidebarSecurityDomains'; +import { comparePublishedClusters, contextClusterNumber } from '../../src/features/sidebar/context-cluster-display'; +import { contextClusterSeverities } from '../../src/assessment-context/context-cluster-severity'; +import { makeSheet } from '../../src/importers/sheet-factory'; +import { genericProfile } from '../../src/profiles/generic/generic-profile'; + +vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() })); +vi.mock('../../src/app/engine-preferences', () => ({ useEnginePreferences: () => ({ context: true, remediation: true, report: false }) })); +const fixtures = vi.hoisted(() => ({ + packages: [ { id: 'p1', code: 'WP-01', clusterLabel: 'AD Persistence Indicators', clusterId: 'ad-persistence-indicators' }, { id: 'p2', code: 'WP-02', clusterLabel: 'AD Persistence Indicators', clusterId: 'ad-persistence-indicators' }, + { id: 'p3', code: 'WP-01', clusterLabel: 'AD Privileged Account Exposure', clusterId: 'ad-privileged-account-exposure' }, + { id: 'p0', code: 'WP-01', clusterLabel: 'Credential Relay Exposure', clusterId: 'credential-relay-exposure' } ], + clusters: [ { id: 'ad-privileged-account-exposure', severity: 'critical', hasEvidence: true }, + { id: 'ad-persistence-indicators', severity: 'high', hasEvidence: true } ], + facets: [ { id: 'derived:security-domain', values: [] }, { id: 'context:cluster', values: [ { value: 'ad-persistence-indicators', label: 'AD Persistence Indicators', count: 8 }, + { value: 'ad-privileged-account-exposure', label: 'AD Privileged Account Exposure', count: 9 } ] }, + { id: 'remediation:work-package', values: [ { value: 'p2', label: 'AD Persistence Indicators → WP-02', count: 2 }, + { value: 'p1', label: 'AD Persistence Indicators → WP-01', count: 6 }, + { value: 'p3', label: 'AD Privileged Account Exposure → WP-01', count: 9 }, + { value: 'p0', label: 'Credential Relay Exposure → WP-01', count: 1 }, + { value: '__unassigned__', label: 'Unassigned', count: 3 } ] } ] +})); +vi.mock('../../src/features/context-layer/use-context-cluster-index', () => ({ useContextClusterIndex: () => ({ publication: { clusters: fixtures.clusters } }) })); +vi.mock('../../src/remediation/use-work-packages', () => ({ useWorkPackages: () => ({ status: 'ready', index: { packages: fixtures.packages } }) })); +vi.mock('../../src/features/search-filter/work-facets', () => ({ selectWorkFacets: () => fixtures.facets })); + +it('numbers the complete published heatmap in order and renumbers when its scope order changes', () => { + const publication = contextClusterSeverities.map(cluster => ({ id: cluster.clusterId })).reverse(); + const numbers = publication.map(cluster => contextClusterNumber(cluster.id, publication)); + expect(numbers.every(Boolean)).toBe(true); + expect(new Set(numbers).size).toBe(numbers.length); + expect(numbers).toEqual(publication.map((_, index) => String(index + 1).padStart(2, '0'))); + expect(contextClusterNumber(publication[0].id, publication)).toBe('01'); + expect(contextClusterNumber(publication[0].id, [...publication].reverse())).toBe('22'); + expect(contextClusterNumber('ad-persistence-indicators')).toBeUndefined(); +}); + +it.each(['light', 'dark'])('orders both actual filters by displayed Context severity, retaining numeric AP order and stable identifiers (%s)', async theme => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); document.documentElement.dataset.theme = theme; + const saved = structuredClone(fixtures); + const medium = 'ad-reconnaissance-exposure', neutral = 'cloud-privileged-access-exposure'; + fixtures.clusters.push({id:'credential-relay-exposure',severity:'high',hasEvidence:true}, + {id:medium,severity:'medium',hasEvidence:true},{id:neutral,severity:'critical',hasEvidence:false}); + // Published heatmap order is authoritative, including equal-severity ties. + [fixtures.clusters[1], fixtures.clusters[2]] = [fixtures.clusters[2], fixtures.clusters[1]]; + fixtures.packages.push({id:'p4',code:'WP-01',clusterId:medium,clusterLabel:'AD Reconnaissance Exposure'}, + {id:'p5',code:'WP-01',clusterId:neutral,clusterLabel:'Cloud Privileged Access Exposure'}); + fixtures.facets[1].values.push({value:'credential-relay-exposure',label:'Credential Relay Exposure',count:1}, + {value:medium,label:'AD Reconnaissance Exposure',count:4},{value:neutral,label:'Cloud Privileged Access Exposure',count:1}); + fixtures.facets[2].values.push({value:'p4',label:'AD Reconnaissance Exposure → WP-01',count:4}, + {value:'p5',label:'Cloud Privileged Access Exposure → WP-01',count:1}); + const host=document.createElement('div');document.body.append(host);const root=createRoot(host); + const select=vi.fn();const sheet=makeSheet('Synthetic',[['Name'],['Original']],'','synthetic.csv'); + vi.mocked(useApp).mockReturnValue({dataset:null,activeProfile:genericProfile,reviews:{},statusFilter:'all',facetFilters:{},search:'',actionOnly:false,setFacetFilter:select} as unknown as ReturnType); + try { + await act(async()=>root.render()); + const expected=['01 · AD Privileged Account Exposure','02 · Credential Relay Exposure','03 · AD Persistence Indicators','04 · AD Reconnaissance Exposure','05 · Cloud Privileged Access Exposure']; + expect([...host.querySelectorAll('[data-filter-id="context:cluster"] .work-filter-value > span')].map(node=>node.textContent)).toEqual(expected); + expect([...host.querySelectorAll('.work-package-cluster-head > span:first-child')].map(node=>node.textContent)).toEqual(expected); + const rows=[...host.querySelectorAll('[data-filter-id="remediation:work-package"] .work-filter-value')]; + expect(rows.map(row=>row.getAttribute('aria-label'))).toEqual([ + expected[0]+' → WP-01: 9',expected[1]+' → WP-01: 1',expected[2]+' → WP-01: 6',expected[2]+' → WP-02: 2',expected[3]+' → WP-01: 4',expected[4]+' → WP-01: 1','Unassigned: 3']); + await act(async()=>rows[3].click());expect(select).toHaveBeenLastCalledWith('remediation:work-package',{values:['p2']}); + expect([...host.querySelectorAll('.work-package-cluster-colour')].map(node=>node.className)).toEqual(['critical','high','high','medium','undetermined'].map(value=>'work-package-cluster-colour severity-'+value)); + expect(comparePublishedClusters('credential-relay-exposure','ad-persistence-indicators',fixtures.clusters as never)).toBeLessThan(0); + }finally{await act(async()=>root.unmount());host.remove();Object.assign(fixtures,saved);vi.unstubAllGlobals();delete document.documentElement.dataset.theme;} +}); + +it.each(['light', 'dark'])('shows one published cluster colour and matching cluster numbers across package selection and floating panels (%s)', async theme => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); document.documentElement.dataset.theme = theme; + const sheet = makeSheet('Synthetic', [['Name', 'Severity'], ['Declaration', 'Critical'], ['Observation', 'Low']], '', 'synthetic.csv'); + const original = JSON.stringify(sheet.analysis.records); + const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); + function Harness() { + const [facetFilters, setFilters] = useState['facetFilters']>({}); + vi.mocked(useApp).mockReturnValue({ dataset: null, activeProfile: genericProfile, reviews: {}, statusFilter: 'all', + facetFilters, search: '', actionOnly: false, setFacetFilter: (id, value) => setFilters(current => { + const next = { ...current }; if (value) next[id] = value; else delete next[id]; return next; + }) } as ReturnType); + return ; + } + try { + await act(async () => root.render()); + const packageRows = () => Array.from(document.querySelectorAll('[data-filter-id="remediation:work-package"] .work-filter-value')); + expect(packageRows().map(row => row.querySelector('span')?.textContent)).toEqual([ + 'WP-01', 'WP-01', 'WP-02', 'WP-01', 'Unassigned']); + expect(Array.from(document.querySelectorAll('.work-package-cluster-colour')).map(marker => marker.className)).toEqual([ + 'work-package-cluster-colour severity-critical', 'work-package-cluster-colour severity-high', 'work-package-cluster-colour severity-undetermined']); + expect(document.querySelectorAll('.work-package-heatmap-segment')).toHaveLength(0); + expect(document.querySelector('[data-filter-id="context:cluster"] .work-filter-value span')?.textContent).toBe('01 · AD Privileged Account Exposure'); + expect(packageRows()[1].querySelector('strong')?.textContent).toBe('6'); + await act(async () => packageRows()[0].click()); + expect(packageRows()[0].getAttribute('aria-pressed')).toBe('true'); + expect(packageRows()[0].querySelector('span')?.textContent).toBe('WP-01'); + await act(async () => document.querySelector('[aria-label="Detach filters"]')!.click()); + expect(document.querySelector('.work-filter-panel.is-floating')).not.toBeNull(); + expect(packageRows()[0].getAttribute('aria-pressed')).toBe('true'); + expect(packageRows().every(row => row.querySelectorAll('.work-package-cluster-colour').length === 0)).toBe(true); + expect(Array.from(document.querySelectorAll('.work-package-cluster-head > span:first-child')).map(node => node.textContent)).toEqual(['01 · AD Privileged Account Exposure', '02 · AD Persistence Indicators', 'Credential Relay Exposure']); + expect(packageRows()[0].getAttribute('aria-label')).toBe('01 · AD Privileged Account Exposure → WP-01: 9'); + await act(async () => packageRows()[0].click()); + expect(packageRows().every(row => row.getAttribute('aria-pressed') === 'false')).toBe(true); + await act(async () => packageRows()[4].click()); + expect(packageRows()[4].getAttribute('aria-pressed')).toBe('true'); + await act(async () => document.querySelector('[aria-label="Dock filters"]')!.click()); + expect(packageRows()[4].getAttribute('aria-pressed')).toBe('true'); + expect(JSON.stringify(sheet.analysis.records)).toBe(original); + } finally { await act(async () => root.unmount()); host.remove(); vi.unstubAllGlobals(); delete document.documentElement.dataset.theme; } +}); diff --git a/tests/finding-reader-actions-browser-check.mjs b/tests/finding-reader-actions-browser-check.mjs new file mode 100644 index 0000000..c111cc7 --- /dev/null +++ b/tests/finding-reader-actions-browser-check.mjs @@ -0,0 +1,81 @@ +// Synthetic-only geometry acceptance. PLAYWRIGHT_MODULE points to installed Playwright. +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { pathToFileURL } from 'node:url'; +import { build } from 'esbuild'; + +const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href); +const main = await readFile('src/main.tsx', 'utf8'); +const styles = [...main.matchAll(/^import '([^']+\.css)';/gm)].map(([, path]) => + `import ${JSON.stringify(path.startsWith('.') ? './src/' + path.slice(2) : path)};`).join('\n'); +const bundle = await build({ + stdin: { contents: `${styles} + import React from 'react'; + import { createRoot } from 'react-dom/client'; + import { FindingReader } from './src/features/finding-reader/FindingReader'; + import { makeSheet } from './src/importers/sheet-factory'; + const record = makeSheet('Synthetic', [['Name','Result','Description','ShortName','MITRE ATT&CK','MITRE D3FEND'], + ['Synthetic LDAP signing finding','IOE Found','Synthetic evidence','SI000160','Privilege Escalation','Harden']], '', 'synthetic.csv', {toolId:'purple-knight'}).analysis.records[0]; + const root = createRoot(document.getElementById('root')); + let include = false; let resultsOpen = false; + function render() { root.render({}} onNext={()=>{}} onReview={patch=>{include=patch.include;render();}} + onResults={()=>{resultsOpen=true;render();}} resultsOpen={resultsOpen}/>); } + render();`, resolveDir: process.cwd(), loader: 'tsx' }, + jsx: 'automatic', bundle: true, write: false, outdir: 'synthetic', format: 'iife', + loader: { '.woff2': 'dataurl', '.woff': 'dataurl', '.png': 'dataurl', '.svg': 'dataurl' } +}); +const server = createServer((request, response) => { + const file = bundle.outputFiles.find(file => request.url === '/' + file.path.split(/[\\/]/).at(-1)); + response.setHeader('Content-Type', file ? (request.url.endsWith('.css') ? 'text/css' : 'text/javascript') : 'text/html'); + response.end(file?.contents ?? '
    '); +}); +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +let browser; +try { + browser = await chromium.launch({ headless: true, channel: 'chrome' }); + const page = await browser.newPage(); + await page.goto(`http://127.0.0.1:${server.address().port}/`); + await page.locator('.reader-primary-actions').waitFor(); + for (const theme of ['dark', 'light']) { + await page.evaluate(theme => { document.documentElement.dataset.theme = theme; }, theme); + const disclosure = page.getByRole('button', { name: 'MITRE ATT&CK and D3FEND information', exact: true }); + assert.equal(await disclosure.getAttribute('aria-expanded'), 'false'); + assert.equal(await page.locator('.mitre-badge').count(), 0); + await disclosure.focus(); + await page.keyboard.press('Enter'); + await page.locator('.mitre-badge').first().waitFor(); + assert.equal(await page.locator('.mitre-badge').count(), 2); + const surface = await page.locator('.mitre-disclosure').evaluate(element => { + const css = getComputedStyle(element); const button = getComputedStyle(element.querySelector('button')); + return { background: css.backgroundColor, border: css.borderTopWidth, shadow: css.boxShadow, + buttonBackground: button.backgroundColor, buttonBorder: button.borderTopWidth }; + }); + assert.deepEqual(surface, { background: 'rgba(0, 0, 0, 0)', border: '0px', shadow: 'none', + buttonBackground: 'rgba(0, 0, 0, 0)', buttonBorder: '0px' }); + await page.keyboard.press('Space'); + await page.waitForFunction(() => !document.querySelector('.mitre-badge')); + for (const width of [1200, 620, 320]) { + await page.setViewportSize({ width, height: 900 }); + const geometry = await page.locator('.reader-primary-actions').evaluate(group => [...group.children].map(action => { + const rect = action.getBoundingClientRect(); + return { x: rect.x, y: rect.y, right: rect.right, height: rect.height, text: action.textContent }; + })); + assert.deepEqual(geometry.map(action => action.text), ['AI search', '', 'Results']); + assert.ok(geometry.every(action => Math.abs(action.y - geometry[0].y) < 1)); + assert.ok(geometry[0].right < geometry[1].x && geometry[1].right < geometry[2].x); + assert.ok(geometry[0].x >= 0 && geometry[2].right <= width); + } + const bookmark = page.getByRole('button', { name: 'Bookmark', exact: true }); + const previous = await bookmark.getAttribute('aria-pressed'); + await bookmark.click(); + await page.waitForFunction(previous => document.querySelector('.bookmark-btn').getAttribute('aria-pressed') !== previous, previous); + await page.getByRole('button', { name: 'Results', exact: true }).click(); + assert.ok(await page.locator('.results-detail-btn').evaluate(button => button.classList.contains('is-open'))); + } + console.log('Finding actions: both themes, 1200/620/320px, icon-only bookmark, Results and frameless MITRE disclosure with Enter/Space passed.'); +} finally { + await browser?.close(); await new Promise(resolve => server.close(resolve)); +} diff --git a/tests/gpo-folder-diagnostics-browser-check.mjs b/tests/gpo-folder-diagnostics-browser-check.mjs new file mode 100644 index 0000000..b57dbc1 --- /dev/null +++ b/tests/gpo-folder-diagnostics-browser-check.mjs @@ -0,0 +1,72 @@ +// Synthetic-only browser acceptance; never connects to ASSESSOR or its database. +// PLAYWRIGHT_MODULE points to an installed Playwright package; SCREENSHOT_DIR is optional. +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { build } from 'esbuild'; + +const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href); +const main = await readFile('src/main.tsx', 'utf8'); +const styles = [...main.matchAll(/^import '([^']+\.css)';/gm)].map(([, path]) => + `import ${JSON.stringify(path.startsWith('.') ? './src/' + path.slice(2) : path)};`).join('\n'); +const bundle = await build({ + stdin: { contents: `${styles} + import React from 'react'; + import { createRoot } from 'react-dom/client'; + import { GpoFolderImport } from './src/features/project-import/GpoFolderImport'; + window.showDirectoryPicker = async function() { + if (this !== window) throw new Error('Incorrect picker receiver'); + return { kind:'directory', name:'Synthetic confidential customer', async *values() { + throw new DOMException('C:/Customers/Synthetic confidential customer', 'NotFoundError'); + }}; + }; + createRoot(document.getElementById('root')).render( +
    + +
    );`, resolveDir: process.cwd(), loader: 'tsx' }, + jsx: 'automatic', bundle: true, write: false, outdir: 'synthetic', format: 'iife', + loader: { '.woff2': 'dataurl', '.woff': 'dataurl' } +}); +const server = createServer((request, response) => { + const file = bundle.outputFiles.find(file => request.url === '/' + file.path.split(/[\\/]/).at(-1)); + response.setHeader('Content-Type', file ? (request.url.endsWith('.css') ? 'text/css' : 'text/javascript') : 'text/html'); + response.end(file?.contents ?? '
    '); +}); +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +let browser; +try { + browser = await chromium.launch({ headless: true, channel: 'chrome' }); + const page = await browser.newPage({ viewport: { width: 700, height: 450 } }); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto(`http://127.0.0.1:${server.address().port}/`); + await page.getByRole('button', { name: 'Import GPO Folder', exact: true }).click(); + const alert = page.getByRole('alert'); + await alert.waitFor(); + const message = await alert.textContent(); + assert.ok(message.includes('Folder enumeration: NotFoundError (1)')); + assert.ok(!message.includes('Synthetic confidential customer')); + assert.ok(!message.includes('C:/Customers')); + for (const theme of ['dark', 'light']) { + await page.evaluate(theme => { document.documentElement.dataset.theme = theme; }, theme); + for (const width of [700, 380]) { + await page.setViewportSize({ width, height: 450 }); + const geometry = await alert.evaluate(element => ({ + width: element.clientWidth, scroll: element.scrollWidth, + whiteSpace: getComputedStyle(element).whiteSpace, + right: element.getBoundingClientRect().right + })); + assert.equal(geometry.whiteSpace, 'normal'); + assert.ok(geometry.scroll <= geometry.width + 1, JSON.stringify(geometry)); + assert.ok(geometry.right <= width, JSON.stringify(geometry)); + if (process.env.SCREENSHOT_DIR) await page.screenshot({ path: join(process.env.SCREENSHOT_DIR, `gpo-diagnostics-${theme}-${width}.png`) }); + } + } + assert.deepEqual(errors, []); + console.log('Synthetic Chrome GPO diagnostics: private error details hidden; complete wrapped message in both themes at 700px and 380px.'); +} finally { + await browser?.close(); + await new Promise(resolve => server.close(resolve)); +} diff --git a/tests/importers/__snapshots__/import-compatibility.test.ts.snap b/tests/importers/__snapshots__/import-compatibility.test.ts.snap new file mode 100644 index 0000000..b03aeae --- /dev/null +++ b/tests/importers/__snapshots__/import-compatibility.test.ts.snap @@ -0,0 +1,19499 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`protected scanner import contracts (synthetic data only) > 'cis-cat-future-controls' retains recognition, preview and approved canonical results in browser and backend 1`] = ` +{ + "approved": { + "recordCount": 1, + "selected": true, + }, + "definitions": [ + { + "canonicalKey": "cis-cat:2.1.1", + "category": undefined, + "description": "Synthetic future recommendation without system data.", + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": "Enable the future setting after testing.", + "title": "2.1.1 Ensure the synthetic future setting is enabled", + }, + ], + "evidence": [], + "findings": [ + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "2.1.1", + "sourceFieldKeys": [ + "rule-id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "2.1.1 Ensure the synthetic future setting is enabled", + "sourceFieldKeys": [ + "benchmark-item", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Fail", + "sourceFieldKeys": [ + "result", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "1.0", + "sourceFieldKeys": [ + "weight", + ], + "targetRole": "score", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic future recommendation without system data.", + "sourceFieldKeys": [ + "description", + ], + "targetRole": "description", + }, + { + "decisionSource": "automatic", + "normalizedValue": "The setting reduces a synthetic future risk.", + "sourceFieldKeys": [ + "rationale", + ], + "targetRole": "description", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Enable the future setting after testing.", + "sourceFieldKeys": [ + "remediation", + ], + "targetRole": "recommendation", + }, + { + "decisionSource": "automatic", + "normalizedValue": "- URL: SYNTHETIC-CONTROL-3", + "sourceFieldKeys": [ + "references", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "automatic", + "normalizedValue": "### CIS Critical Security Controls V8.1 + +- Control 8: Synthetic Audit Log Management + +### Synthetic Framework V1.0 + +- SF-1: Synthetic Framework Requirement", + "sourceFieldKeys": [ + "control-mappings", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "source-table:1 "Assessment Evidence · Synthetic Future Item" +source-table:3 "CIS Critical Security Controls V8.1 · CIS Control Information" +source-table:4 "Synthetic Framework V1.0 · Synthetic Framework Information"", + "sourceFieldKeys": [ + "results", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "\`\`\`xml +fail +\`\`\`", + "sourceFieldKeys": [ + "rule-result-xml", + ], + "targetRole": "additional", + }, + ], + "originalFields": [ + { + "columnIndex": 1, + "key": "rule-id", + "label": "Rule ID", + "path": "#detail-syn-211", + "rawValue": "2.1.1", + "value": "2.1.1", + }, + { + "columnIndex": 1, + "key": "benchmark-item", + "label": "Benchmark Item", + "path": "#detail-syn-211", + "rawValue": "2.1.1 Ensure the synthetic future setting is enabled", + "value": "2.1.1 Ensure the synthetic future setting is enabled", + }, + { + "columnIndex": 2, + "key": "result", + "label": "Result", + "path": "#detail-syn-211", + "rawValue": "Fail", + "value": "Fail", + }, + { + "columnIndex": 0, + "key": "weight", + "label": "Weight", + "path": "#detail-syn-211", + "rawValue": "1.0", + "value": "1.0", + }, + { + "columnIndex": undefined, + "key": "description", + "label": "Description", + "path": "#detail-syn-211", + "rawValue": "Synthetic future recommendation without system data.", + "value": "Synthetic future recommendation without system data.", + }, + { + "columnIndex": undefined, + "key": "rationale", + "label": "Rationale", + "path": "#detail-syn-211", + "rawValue": "The setting reduces a synthetic future risk.", + "value": "The setting reduces a synthetic future risk.", + }, + { + "columnIndex": undefined, + "key": "remediation", + "label": "Remediation", + "path": "#detail-syn-211", + "rawValue": "Enable the future setting after testing.", + "value": "Enable the future setting after testing.", + }, + { + "columnIndex": undefined, + "key": "references", + "label": "References", + "path": "#detail-syn-211", + "rawValue": "- URL: SYNTHETIC-CONTROL-3", + "value": "- URL: SYNTHETIC-CONTROL-3", + }, + { + "columnIndex": undefined, + "key": "control-mappings", + "label": "Control Mappings", + "path": "#detail-syn-211", + "rawValue": "### CIS Critical Security Controls V8.1 + +- Control 8: Synthetic Audit Log Management + +### Synthetic Framework V1.0 + +- SF-1: Synthetic Framework Requirement", + "value": "### CIS Critical Security Controls V8.1 + +- Control 8: Synthetic Audit Log Management + +### Synthetic Framework V1.0 + +- SF-1: Synthetic Framework Requirement", + }, + { + "columnIndex": undefined, + "key": "results", + "label": "Results", + "path": "#detail-syn-211", + "rawValue": "source-table:1 "Assessment Evidence · Synthetic Future Item" +source-table:3 "CIS Critical Security Controls V8.1 · CIS Control Information" +source-table:4 "Synthetic Framework V1.0 · Synthetic Framework Information"", + "value": { + "fragmentId": "detail-syn-211", + "tables": [ + { + "label": "Assessment Evidence · Synthetic Future Item", + "tableIndex": 1, + }, + { + "label": "CIS Critical Security Controls V8.1 · CIS Control Information", + "tableIndex": 3, + }, + { + "label": "Synthetic Framework V1.0 · Synthetic Framework Information", + "tableIndex": 4, + }, + ], + }, + }, + { + "columnIndex": undefined, + "key": "rule-result-xml", + "label": "Rule Result XML", + "path": "#detail-syn-211", + "rawValue": "\`\`\`xml +fail +\`\`\`", + "value": { + "fragmentId": "detail-syn-211", + "ruleResultXml": "fail", + }, + }, + ], + "outcome": { + "normalizedValue": "fail", + "originalValue": "Fail", + }, + "score": { + "normalizedValue": 1, + "originalValue": "1.0", + }, + "severity": { + "normalizedValue": "unknown", + "originalValue": "", + }, + "sourceIdentity": { + "canonicalRuleId": "2.1.1", + "originalRuleId": "2.1.1", + "originalTitle": "2.1.1 Ensure the synthetic future setting is enabled", + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": "#detail-syn-211", + "objectPath": undefined, + "rowIndex": 1, + }, + "status": { + "normalizedValue": "failed", + "originalValue": "Fail", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Fail", + }, + }, + ], + "observations": [ + { + "attributes": {}, + "label": "Benchmark", + "normalizedValue": "Synthetic CIS Benchmark with future control mappings", + "observedAt": undefined, + "originalValue": "Synthetic CIS Benchmark with future control mappings", + "property": "benchmark.name", + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/title", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "assessment.future-controls.synthetic.html", + "label": "CIS-CAT benchmark assessment", + "type": "benchmark-assessment", + }, + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + }, + "valueType": "string", + "version": 1, + }, + ], + "preview": { + "mappingsToConfirm": 0, + "recordCount": 1, + "selected": true, + }, + "recognition": "cis-cat", + "reportSections": [ + { + "attributes": { + "observationCount": 1, + }, + "detailLinks": undefined, + "label": "CIS-CAT assessment summary", + "modalDetails": undefined, + "ordinal": 0, + "parentSectionKey": undefined, + "prose": undefined, + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/summary", + "rowIndex": undefined, + }, + "state": "observed", + "tables": [], + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + }, + "version": 1, + }, + ], + "scores": [ + { + "assessed": 1, + "assessmentId": "assessment-protected-dummy-import", + "comparisonKey": undefined, + "contentSha256": "37b78d6489bb55e082426dcd7dbc456154709a24a231cf3a54fb43fdb85e3c16", + "deviations": 1, + "eligible": 1, + "evaluationScopeKey": "["[\\"2.1.1\\",\\"evaluated\\"]"]", + "importFileIds": [ + "file-protected-dummy-import-0", + ], + "issues": [ + "Scan identity or time not verified", + "Scanner version unavailable", + ], + "method": "pass-rate", + "nativeProperty": undefined, + "nativeValue": undefined, + "observedAt": undefined, + "occurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + ], + "openOccurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + ], + "passed": 0, + "policy": { + "defaultWeight": 1, + "version": "1.0.0", + "weights": { + "cis-cat": 1, + "group-policy": 1, + "maester": 1, + "pingcastle": 1, + "purple-knight": 1, + }, + }, + "processingKey": "[null,"cis-cat","1.4.0","1.3.0","1.3.0","1.0.0"]", + "resultSignature": "["[\\"2.1.1\\",null,\\"fail\\"]"]", + "runIds": [ + "run-protected-dummy-import", + ], + "score": 0, + "toolId": "cis-cat", + "toolName": "CIS-CAT Pro", + }, + ], + "tables": [ + { + "columnCount": 3, + "hidden": undefined, + "index": 0, + "name": "assessmentResultTable", + "rowCount": 1, + "rows": undefined, + }, + { + "columnCount": 2, + "hidden": undefined, + "index": 1, + "name": "Synthetic Future Item", + "rowCount": 1, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Name", + "value": "Setting", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Value", + "value": "Disabled", + }, + ], + "highlighted": false, + "index": 0, + }, + ], + }, + { + "columnCount": 2, + "hidden": undefined, + "index": 2, + "name": "XML Evidence", + "rowCount": 1, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Name", + "value": "Duplicated in XML", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Value", + "value": "Ignored as linked table", + }, + ], + "highlighted": false, + "index": 0, + }, + ], + }, + { + "columnCount": 2, + "hidden": undefined, + "index": 3, + "name": "Tabelle 4", + "rowCount": 1, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Control", + "value": "Safeguard", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Manage synthetic audit logs.", + "value": "8.1", + }, + ], + "highlighted": false, + "index": 0, + }, + ], + }, + { + "columnCount": 2, + "hidden": undefined, + "index": 4, + "name": "Tabelle 5", + "rowCount": 0, + "rows": [], + }, + ], +} +`; + +exports[`protected scanner import contracts (synthetic data only) > 'cis-cat-html' retains recognition, preview and approved canonical results in browser and backend 1`] = ` +{ + "approved": { + "recordCount": 2, + "selected": true, + }, + "definitions": [ + { + "canonicalKey": "cis-cat:1.1.1", + "category": undefined, + "description": "Synthetic recommendation without system data.", + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": "Enable the setting after testing. + +\`\`\` +Computer Configuration\\Synthetic Policy +\`\`\` + +### Impact + +Validate compatibility before deployment.", + "title": "1.1.1 Ensure the synthetic setting is enabled", + }, + { + "canonicalKey": "cis-cat:1.1.2", + "category": undefined, + "description": "Synthetic audit recommendation.", + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": "Enable synthetic auditing.", + "title": "1.1.2 Ensure the synthetic audit setting is enabled", + }, + ], + "evidence": [], + "findings": [ + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "1.1.1", + "sourceFieldKeys": [ + "rule-id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "1.1.1 Ensure the synthetic setting is enabled", + "sourceFieldKeys": [ + "benchmark-item", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Fail", + "sourceFieldKeys": [ + "result", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "1.0", + "sourceFieldKeys": [ + "weight", + ], + "targetRole": "score", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic recommendation without system data.", + "sourceFieldKeys": [ + "description", + ], + "targetRole": "description", + }, + { + "decisionSource": "automatic", + "normalizedValue": "The setting reduces a synthetic risk.", + "sourceFieldKeys": [ + "rationale", + ], + "targetRole": "description", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Enable the setting after testing. + +\`\`\` +Computer Configuration\\Synthetic Policy +\`\`\` + +### Impact + +Validate compatibility before deployment.", + "sourceFieldKeys": [ + "remediation", + ], + "targetRole": "recommendation", + }, + { + "decisionSource": "automatic", + "normalizedValue": "- URL: SYNTHETIC-CONTROL-1", + "sourceFieldKeys": [ + "references", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "automatic", + "normalizedValue": "### CIS Controls V7.0 + +- Control 1: Synthetic Inventory + +### CIS Critical Security Controls V8.0 + +- Control 2: Synthetic Assets", + "sourceFieldKeys": [ + "control-mappings", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "source-table:5 "Assessment Evidence · Complex Check" +source-table:6 "Assessment Evidence · Synthetic Item" +source-table:8 "CIS Controls V7.0 · CIS Control Information" +source-table:9 "CIS Critical Security Controls V8.0 · CIS Control Information"", + "sourceFieldKeys": [ + "results", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "\`\`\`xml +fail +\`\`\`", + "sourceFieldKeys": [ + "rule-result-xml", + ], + "targetRole": "additional", + }, + ], + "originalFields": [ + { + "columnIndex": 1, + "key": "rule-id", + "label": "Rule ID", + "path": "#detail-syn-111", + "rawValue": "1.1.1", + "value": "1.1.1", + }, + { + "columnIndex": 1, + "key": "benchmark-item", + "label": "Benchmark Item", + "path": "#detail-syn-111", + "rawValue": "1.1.1 Ensure the synthetic setting is enabled", + "value": "1.1.1 Ensure the synthetic setting is enabled", + }, + { + "columnIndex": 2, + "key": "result", + "label": "Result", + "path": "#detail-syn-111", + "rawValue": "Fail", + "value": "Fail", + }, + { + "columnIndex": 0, + "key": "weight", + "label": "Weight", + "path": "#detail-syn-111", + "rawValue": "1.0", + "value": "1.0", + }, + { + "columnIndex": undefined, + "key": "description", + "label": "Description", + "path": "#detail-syn-111", + "rawValue": "Synthetic recommendation without system data.", + "value": "Synthetic recommendation without system data.", + }, + { + "columnIndex": undefined, + "key": "rationale", + "label": "Rationale", + "path": "#detail-syn-111", + "rawValue": "The setting reduces a synthetic risk.", + "value": "The setting reduces a synthetic risk.", + }, + { + "columnIndex": undefined, + "key": "remediation", + "label": "Remediation", + "path": "#detail-syn-111", + "rawValue": "Enable the setting after testing. + +\`\`\` +Computer Configuration\\Synthetic Policy +\`\`\` + +### Impact + +Validate compatibility before deployment.", + "value": "Enable the setting after testing. + +\`\`\` +Computer Configuration\\Synthetic Policy +\`\`\` + +### Impact + +Validate compatibility before deployment.", + }, + { + "columnIndex": undefined, + "key": "references", + "label": "References", + "path": "#detail-syn-111", + "rawValue": "- URL: SYNTHETIC-CONTROL-1", + "value": "- URL: SYNTHETIC-CONTROL-1", + }, + { + "columnIndex": undefined, + "key": "control-mappings", + "label": "Control Mappings", + "path": "#detail-syn-111", + "rawValue": "### CIS Controls V7.0 + +- Control 1: Synthetic Inventory + +### CIS Critical Security Controls V8.0 + +- Control 2: Synthetic Assets", + "value": "### CIS Controls V7.0 + +- Control 1: Synthetic Inventory + +### CIS Critical Security Controls V8.0 + +- Control 2: Synthetic Assets", + }, + { + "columnIndex": undefined, + "key": "results", + "label": "Results", + "path": "#detail-syn-111", + "rawValue": "source-table:5 "Assessment Evidence · Complex Check" +source-table:6 "Assessment Evidence · Synthetic Item" +source-table:8 "CIS Controls V7.0 · CIS Control Information" +source-table:9 "CIS Critical Security Controls V8.0 · CIS Control Information"", + "value": { + "fragmentId": "detail-syn-111", + "tables": [ + { + "label": "Assessment Evidence · Complex Check", + "tableIndex": 5, + }, + { + "label": "Assessment Evidence · Synthetic Item", + "tableIndex": 6, + }, + { + "label": "CIS Controls V7.0 · CIS Control Information", + "tableIndex": 8, + }, + { + "label": "CIS Critical Security Controls V8.0 · CIS Control Information", + "tableIndex": 9, + }, + ], + }, + }, + { + "columnIndex": undefined, + "key": "rule-result-xml", + "label": "Rule Result XML", + "path": "#detail-syn-111", + "rawValue": "\`\`\`xml +fail +\`\`\`", + "value": { + "fragmentId": "detail-syn-111", + "ruleResultXml": "fail", + }, + }, + ], + "outcome": { + "normalizedValue": "fail", + "originalValue": "Fail", + }, + "score": { + "normalizedValue": 1, + "originalValue": "1.0", + }, + "severity": { + "normalizedValue": "unknown", + "originalValue": "", + }, + "sourceIdentity": { + "canonicalRuleId": "1.1.1", + "originalRuleId": "1.1.1", + "originalTitle": "1.1.1 Ensure the synthetic setting is enabled", + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": "#detail-syn-111", + "objectPath": undefined, + "rowIndex": 1, + }, + "status": { + "normalizedValue": "failed", + "originalValue": "Fail", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Fail", + }, + }, + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "1.1.2", + "sourceFieldKeys": [ + "rule-id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "1.1.2 Ensure the synthetic audit setting is enabled", + "sourceFieldKeys": [ + "benchmark-item", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Pass", + "sourceFieldKeys": [ + "result", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "1.0", + "sourceFieldKeys": [ + "weight", + ], + "targetRole": "score", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic audit recommendation.", + "sourceFieldKeys": [ + "description", + ], + "targetRole": "description", + }, + { + "decisionSource": "automatic", + "normalizedValue": "The setting records synthetic events.", + "sourceFieldKeys": [ + "rationale", + ], + "targetRole": "description", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Enable synthetic auditing.", + "sourceFieldKeys": [ + "remediation", + ], + "targetRole": "recommendation", + }, + { + "decisionSource": "automatic", + "normalizedValue": "- URL: SYNTHETIC-CONTROL-2", + "sourceFieldKeys": [ + "references", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "automatic", + "normalizedValue": "source-table:10 "Assessment Evidence · Complex Check" +source-table:11 "Assessment Evidence · Synthetic Audit Item"", + "sourceFieldKeys": [ + "results", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "\`\`\`xml +pass +\`\`\`", + "sourceFieldKeys": [ + "rule-result-xml", + ], + "targetRole": "additional", + }, + ], + "originalFields": [ + { + "columnIndex": 1, + "key": "rule-id", + "label": "Rule ID", + "path": "#detail-syn-112", + "rawValue": "1.1.2", + "value": "1.1.2", + }, + { + "columnIndex": 1, + "key": "benchmark-item", + "label": "Benchmark Item", + "path": "#detail-syn-112", + "rawValue": "1.1.2 Ensure the synthetic audit setting is enabled", + "value": "1.1.2 Ensure the synthetic audit setting is enabled", + }, + { + "columnIndex": 2, + "key": "result", + "label": "Result", + "path": "#detail-syn-112", + "rawValue": "Pass", + "value": "Pass", + }, + { + "columnIndex": 0, + "key": "weight", + "label": "Weight", + "path": "#detail-syn-112", + "rawValue": "1.0", + "value": "1.0", + }, + { + "columnIndex": undefined, + "key": "description", + "label": "Description", + "path": "#detail-syn-112", + "rawValue": "Synthetic audit recommendation.", + "value": "Synthetic audit recommendation.", + }, + { + "columnIndex": undefined, + "key": "rationale", + "label": "Rationale", + "path": "#detail-syn-112", + "rawValue": "The setting records synthetic events.", + "value": "The setting records synthetic events.", + }, + { + "columnIndex": undefined, + "key": "remediation", + "label": "Remediation", + "path": "#detail-syn-112", + "rawValue": "Enable synthetic auditing.", + "value": "Enable synthetic auditing.", + }, + { + "columnIndex": undefined, + "key": "references", + "label": "References", + "path": "#detail-syn-112", + "rawValue": "- URL: SYNTHETIC-CONTROL-2", + "value": "- URL: SYNTHETIC-CONTROL-2", + }, + { + "columnIndex": undefined, + "key": "results", + "label": "Results", + "path": "#detail-syn-112", + "rawValue": "source-table:10 "Assessment Evidence · Complex Check" +source-table:11 "Assessment Evidence · Synthetic Audit Item"", + "value": { + "fragmentId": "detail-syn-112", + "tables": [ + { + "label": "Assessment Evidence · Complex Check", + "tableIndex": 10, + }, + { + "label": "Assessment Evidence · Synthetic Audit Item", + "tableIndex": 11, + }, + ], + }, + }, + { + "columnIndex": undefined, + "key": "rule-result-xml", + "label": "Rule Result XML", + "path": "#detail-syn-112", + "rawValue": "\`\`\`xml +pass +\`\`\`", + "value": { + "fragmentId": "detail-syn-112", + "ruleResultXml": "pass", + }, + }, + ], + "outcome": { + "normalizedValue": "pass", + "originalValue": "Pass", + }, + "score": { + "normalizedValue": 1, + "originalValue": "1.0", + }, + "severity": { + "normalizedValue": "unknown", + "originalValue": "", + }, + "sourceIdentity": { + "canonicalRuleId": "1.1.2", + "originalRuleId": "1.1.2", + "originalTitle": "1.1.2 Ensure the synthetic audit setting is enabled", + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": "#detail-syn-112", + "objectPath": undefined, + "rowIndex": 2, + }, + "status": { + "normalizedValue": "passed", + "originalValue": "Pass", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Pass", + }, + }, + ], + "observations": [ + { + "attributes": {}, + "label": "Assessed host", + "normalizedValue": "SYNTHETIC-HOST-01", + "observedAt": undefined, + "originalValue": "SYNTHETIC-HOST-01", + "property": "host.name", + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/cover/host", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "assessment.synthetic.html", + "label": "CIS-CAT assessed system", + "type": "assessed-system", + }, + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": {}, + "label": "IP address", + "normalizedValue": "203.0.113.10", + "observedAt": undefined, + "originalValue": "203.0.113.10", + "property": "host.ip_address", + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/cover/ip-address", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "assessment.synthetic.html", + "label": "CIS-CAT assessed system", + "type": "assessed-system", + }, + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": {}, + "label": "Benchmark", + "normalizedValue": "Synthetic CIS Benchmark", + "observedAt": undefined, + "originalValue": "Synthetic CIS Benchmark", + "property": "benchmark.name", + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/cover/benchmark", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "assessment.synthetic.html", + "label": "CIS-CAT benchmark assessment", + "type": "benchmark-assessment", + }, + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": {}, + "label": "Benchmark version", + "normalizedValue": "2.0.0", + "observedAt": undefined, + "originalValue": "2.0.0", + "property": "benchmark.version", + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/cover/benchmark-version", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "assessment.synthetic.html", + "label": "CIS-CAT benchmark assessment", + "type": "benchmark-assessment", + }, + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": {}, + "label": "Benchmark profile", + "normalizedValue": "Level 1 - Synthetic Member Server", + "observedAt": undefined, + "originalValue": "Level 1 - Synthetic Member Server", + "property": "benchmark.profile", + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/cover/profile", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "assessment.synthetic.html", + "label": "CIS-CAT benchmark assessment", + "type": "benchmark-assessment", + }, + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": {}, + "label": "Report generation date", + "normalizedValue": "Friday, July 17 2026 08:00:00", + "observedAt": undefined, + "originalValue": "Friday, July 17 2026 08:00:00", + "property": "report.generated_at", + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/cover/generated-at", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "assessment.synthetic.html", + "label": "CIS-CAT benchmark assessment", + "type": "benchmark-assessment", + }, + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": {}, + "label": "Assessment duration", + "normalizedValue": "1 minutes, 5 seconds", + "observedAt": undefined, + "originalValue": "1 minutes, 5 seconds", + "property": "benchmark.assessment_duration", + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/cover/duration", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "assessment.synthetic.html", + "label": "CIS-CAT benchmark assessment", + "type": "benchmark-assessment", + }, + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": {}, + "label": "CIS-CAT engine version", + "normalizedValue": "4.61.0", + "observedAt": undefined, + "originalValue": "4.61.0", + "property": "report.engine_version", + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/cover/engine", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "assessment.synthetic.html", + "label": "CIS-CAT benchmark assessment", + "type": "benchmark-assessment", + }, + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": {}, + "label": "Benchmark score", + "normalizedValue": 50, + "observedAt": undefined, + "originalValue": 50, + "property": "benchmark.score", + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/summary/gauge", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "assessment.synthetic.html", + "label": "CIS-CAT benchmark assessment", + "type": "benchmark-assessment", + }, + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": {}, + "label": "Recommendations pass", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "benchmark.result_count.pass", + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/summary/pass", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "assessment.synthetic.html", + "label": "CIS-CAT benchmark assessment", + "type": "benchmark-assessment", + }, + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": {}, + "label": "Recommendations fail", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "benchmark.result_count.fail", + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/summary/fail", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "assessment.synthetic.html", + "label": "CIS-CAT benchmark assessment", + "type": "benchmark-assessment", + }, + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": {}, + "label": "Recommendations error", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "benchmark.result_count.error", + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/summary/error", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "assessment.synthetic.html", + "label": "CIS-CAT benchmark assessment", + "type": "benchmark-assessment", + }, + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": {}, + "label": "Recommendations unknown", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "benchmark.result_count.unknown", + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/summary/unknown", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "assessment.synthetic.html", + "label": "CIS-CAT benchmark assessment", + "type": "benchmark-assessment", + }, + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": {}, + "label": "Recommendations manual", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "benchmark.result_count.manual", + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/summary/manual", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "assessment.synthetic.html", + "label": "CIS-CAT benchmark assessment", + "type": "benchmark-assessment", + }, + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": {}, + "label": "Recommendations excepted", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "benchmark.result_count.excepted", + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/summary/excepted", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "assessment.synthetic.html", + "label": "CIS-CAT benchmark assessment", + "type": "benchmark-assessment", + }, + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": {}, + "label": "Scored recommendations", + "normalizedValue": 2, + "observedAt": undefined, + "originalValue": 2, + "property": "benchmark.scored_total", + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/summary/scored-total", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "assessment.synthetic.html", + "label": "CIS-CAT benchmark assessment", + "type": "benchmark-assessment", + }, + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": {}, + "label": "Scored recommendations passed", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "benchmark.scored_passed", + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/summary/scored-passed", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "assessment.synthetic.html", + "label": "CIS-CAT benchmark assessment", + "type": "benchmark-assessment", + }, + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": {}, + "label": "Account Policies", + "normalizedValue": 50, + "observedAt": undefined, + "originalValue": 50, + "property": "benchmark.section_score.1", + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/tables/2/rows/1", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "1", + "label": "Account Policies", + "type": "benchmark-section", + }, + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": {}, + "label": "Local Policies", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "benchmark.section_score.2", + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/tables/2/rows/3", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "2", + "label": "Local Policies", + "type": "benchmark-section", + }, + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + "valueType": "number", + "version": 1, + }, + ], + "preview": { + "mappingsToConfirm": 1, + "recordCount": 2, + "selected": true, + }, + "recognition": "cis-cat", + "reportSections": [ + { + "attributes": { + "observationCount": 19, + }, + "detailLinks": undefined, + "label": "CIS-CAT assessment summary", + "modalDetails": undefined, + "ordinal": 0, + "parentSectionKey": undefined, + "prose": undefined, + "sectionKey": "cis-cat-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/summary", + "rowIndex": undefined, + }, + "state": "observed", + "tables": [], + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + "version": 1, + }, + { + "attributes": { + "rowCount": 3, + "sourceTableIndex": 2, + }, + "detailLinks": undefined, + "label": "Benchmark section scores", + "modalDetails": undefined, + "ordinal": 1, + "parentSectionKey": undefined, + "prose": undefined, + "sectionKey": "cis-cat-section-scores", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": undefined, + }, + "state": "observed", + "tables": [ + { + "headers": [ + "Description", + "Pass", + "Fail", + "Error", + "Unkn.", + "Man.", + "Exc.", + "Score", + "Max", + "Percent", + ], + "name": "Section scores", + "rows": [ + [ + "1 Account Policies", + "1", + "1", + "0", + "0", + "0", + "0", + "1.0", + "2.0", + "50%", + ], + [ + "1.1 Password Policy", + "1", + "0", + "0", + "0", + "0", + "0", + "1.0", + "1.0", + "100%", + ], + [ + "2 Local Policies", + "0", + "0", + "0", + "0", + "0", + "0", + "0.0", + "0.0", + "0%", + ], + ], + "sourceTableIndex": 2, + }, + ], + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + "version": "4.61.0", + }, + "version": 1, + }, + ], + "scores": [ + { + "assessed": 2, + "assessmentId": "assessment-protected-dummy-import", + "comparisonKey": "["cis-cat","synthetic-host-01","Synthetic CIS Benchmark"]", + "contentSha256": "7a53bf85f76972ca3b37e67fea805d0f6eaeddb9fda2e2086b040649f50808be", + "deviations": 1, + "eligible": 2, + "evaluationScopeKey": "["[\\"1.1.1\\",\\"evaluated\\"]","[\\"1.1.2\\",\\"evaluated\\"]"]", + "importFileIds": [ + "file-protected-dummy-import-0", + ], + "issues": [ + "Scan identity or time not verified", + ], + "method": "native", + "nativeProperty": "benchmark.score", + "nativeValue": 50, + "observedAt": undefined, + "occurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + "occurrence-protected-dummy-import-0-1", + ], + "openOccurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + ], + "passed": 1, + "policy": { + "defaultWeight": 1, + "version": "1.0.0", + "weights": { + "cis-cat": 1, + "group-policy": 1, + "maester": 1, + "pingcastle": 1, + "purple-knight": 1, + }, + }, + "processingKey": "["4.61.0","cis-cat","1.4.0","1.3.0","1.3.0","1.0.0"]", + "resultSignature": "["[\\"1.1.1\\",null,\\"fail\\"]","[\\"1.1.2\\",null,\\"pass\\"]"]", + "runIds": [ + "run-protected-dummy-import", + ], + "score": 50, + "toolId": "cis-cat", + "toolName": "CIS-CAT Pro", + }, + ], + "tables": [ + { + "columnCount": 3, + "hidden": undefined, + "index": 4, + "name": "assessmentResultTable", + "rowCount": 2, + "rows": undefined, + }, + { + "columnCount": 10, + "hidden": undefined, + "index": 2, + "name": "Tabelle 3", + "rowCount": 3, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Description", + "value": "1 Account Policies", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Pass", + "value": "1", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Fail", + "value": "1", + }, + { + "columnIndex": 3, + "key": "column-3", + "label": "Error", + "value": "0", + }, + { + "columnIndex": 4, + "key": "column-4", + "label": "Unkn.", + "value": "0", + }, + { + "columnIndex": 5, + "key": "column-5", + "label": "Man.", + "value": "0", + }, + { + "columnIndex": 6, + "key": "column-6", + "label": "Exc.", + "value": "0", + }, + { + "columnIndex": 7, + "key": "column-7", + "label": "Score", + "value": "1.0", + }, + { + "columnIndex": 8, + "key": "column-8", + "label": "Max", + "value": "2.0", + }, + { + "columnIndex": 9, + "key": "column-9", + "label": "Percent", + "value": "50%", + }, + ], + "highlighted": false, + "index": 0, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Description", + "value": "1.1 Password Policy", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Pass", + "value": "1", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Fail", + "value": "0", + }, + { + "columnIndex": 3, + "key": "column-3", + "label": "Error", + "value": "0", + }, + { + "columnIndex": 4, + "key": "column-4", + "label": "Unkn.", + "value": "0", + }, + { + "columnIndex": 5, + "key": "column-5", + "label": "Man.", + "value": "0", + }, + { + "columnIndex": 6, + "key": "column-6", + "label": "Exc.", + "value": "0", + }, + { + "columnIndex": 7, + "key": "column-7", + "label": "Score", + "value": "1.0", + }, + { + "columnIndex": 8, + "key": "column-8", + "label": "Max", + "value": "1.0", + }, + { + "columnIndex": 9, + "key": "column-9", + "label": "Percent", + "value": "100%", + }, + ], + "highlighted": false, + "index": 1, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Description", + "value": "2 Local Policies", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Pass", + "value": "0", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Fail", + "value": "0", + }, + { + "columnIndex": 3, + "key": "column-3", + "label": "Error", + "value": "0", + }, + { + "columnIndex": 4, + "key": "column-4", + "label": "Unkn.", + "value": "0", + }, + { + "columnIndex": 5, + "key": "column-5", + "label": "Man.", + "value": "0", + }, + { + "columnIndex": 6, + "key": "column-6", + "label": "Exc.", + "value": "0", + }, + { + "columnIndex": 7, + "key": "column-7", + "label": "Score", + "value": "0.0", + }, + { + "columnIndex": 8, + "key": "column-8", + "label": "Max", + "value": "0.0", + }, + { + "columnIndex": 9, + "key": "column-9", + "label": "Percent", + "value": "0%", + }, + ], + "highlighted": false, + "index": 2, + }, + ], + }, + { + "columnCount": 3, + "hidden": undefined, + "index": 0, + "name": "Assessment Summary", + "rowCount": 1, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Benchmark", + "value": "Synthetic CIS Benchmark", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Profile", + "value": "Level 1", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Score", + "value": "50", + }, + ], + "highlighted": false, + "index": 0, + }, + ], + }, + { + "columnCount": 4, + "hidden": undefined, + "index": 6, + "name": "Synthetic Item", + "rowCount": 1, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Name", + "value": "Setting", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Type", + "value": "String", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Status", + "value": "Exists", + }, + { + "columnIndex": 3, + "key": "column-3", + "label": "Value", + "value": "Disabled", + }, + ], + "highlighted": false, + "index": 0, + }, + ], + }, + { + "columnCount": 4, + "hidden": undefined, + "index": 11, + "name": "Synthetic Audit Item", + "rowCount": 1, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Name", + "value": "Setting", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Type", + "value": "String", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Status", + "value": "Exists", + }, + { + "columnIndex": 3, + "key": "column-3", + "label": "Value", + "value": "Enabled", + }, + ], + "highlighted": false, + "index": 0, + }, + ], + }, + { + "columnCount": 4, + "hidden": undefined, + "index": 12, + "name": "Synthetic Audit Item", + "rowCount": 1, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Name", + "value": "Setting", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Type", + "value": "String", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Status", + "value": "Exists", + }, + { + "columnIndex": 3, + "key": "column-3", + "label": "Value", + "value": "Enabled", + }, + ], + "highlighted": false, + "index": 0, + }, + ], + }, + { + "columnCount": 2, + "hidden": undefined, + "index": 7, + "name": "XML Evidence", + "rowCount": 1, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Name", + "value": "Duplicated in XML", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Value", + "value": "Ignored as linked table", + }, + ], + "highlighted": false, + "index": 0, + }, + ], + }, + { + "columnCount": 2, + "hidden": undefined, + "index": 8, + "name": "Tabelle 9", + "rowCount": 4, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Feld", + "value": "Control:", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Wert", + "value": "Maintain a synthetic inventory.", + }, + ], + "highlighted": false, + "index": 0, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Feld", + "value": "Subcontrol:", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Wert", + "value": "1.1", + }, + ], + "highlighted": false, + "index": 1, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Feld", + "value": "Label:", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Wert", + "value": "Synthetic Inventory", + }, + ], + "highlighted": false, + "index": 2, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Feld", + "value": "Description:", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Wert", + "value": "", + }, + ], + "highlighted": false, + "index": 3, + }, + ], + }, + { + "columnCount": 2, + "hidden": undefined, + "index": 9, + "name": "Tabelle 10", + "rowCount": 4, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Feld", + "value": "Control:", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Wert", + "value": "Manage synthetic assets.", + }, + ], + "highlighted": false, + "index": 0, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Feld", + "value": "Safeguard:", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Wert", + "value": "2.1", + }, + ], + "highlighted": false, + "index": 1, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Feld", + "value": "Label:", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Wert", + "value": "Synthetic Assets", + }, + ], + "highlighted": false, + "index": 2, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Feld", + "value": "Implementation Group:", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Wert", + "value": "IG-1", + }, + ], + "highlighted": false, + "index": 3, + }, + ], + }, + { + "columnCount": 2, + "hidden": undefined, + "index": 5, + "name": "Complex Check", + "rowCount": 1, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Feld 1", + "value": "AND", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Feld 2", + "value": "Criterion: Synthetic setting is enabled", + }, + ], + "highlighted": false, + "index": 0, + }, + ], + }, + { + "columnCount": 2, + "hidden": undefined, + "index": 10, + "name": "Complex Check", + "rowCount": 1, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Feld 1", + "value": "AND", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Feld 2", + "value": "Criterion: Synthetic audit setting is enabled", + }, + ], + "highlighted": false, + "index": 0, + }, + ], + }, + { + "columnCount": 7, + "hidden": undefined, + "index": 1, + "name": "Tabelle 2", + "rowCount": 0, + "rows": [], + }, + { + "columnCount": 2, + "hidden": undefined, + "index": 3, + "name": "Selected Profiles", + "rowCount": 1, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Profile", + "value": "Level 1", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Selected", + "value": "Yes", + }, + ], + "highlighted": false, + "index": 0, + }, + ], + }, + ], +} +`; + +exports[`protected scanner import contracts (synthetic data only) > 'cis-cat-summary' retains recognition, preview and approved canonical results in browser and backend 1`] = ` +{ + "approved": { + "recordCount": 8, + "selected": true, + }, + "definitions": [ + { + "canonicalKey": "cis-cat:1.0", + "category": undefined, + "description": undefined, + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "1.1.1 Ensure 'Enforce password history' is set to '24 or more password(s)'", + }, + { + "canonicalKey": "unresolved:protected-dummy-import:0:candidate-sheet-901e489c-8", + "category": undefined, + "description": undefined, + "frameworkReferences": [], + "identity": { + "durable": false, + "evidence": [ + "Keine stabile native Regel-ID oder bestätigte lokale Mappingregel vorhanden.", + ], + "strategy": "unresolved", + "version": 1, + }, + "recommendation": undefined, + "title": "2.3.11.5 Ensure 'Network security: Force logoff when logon hours expire' is set to 'Enabled'", + }, + ], + "evidence": [], + "findings": [ + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "1.0", + "sourceFieldKeys": [ + "column-0", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "1.1.1 Ensure 'Enforce password history' is set to '24 or more password(s)'", + "sourceFieldKeys": [ + "column-1", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Pass", + "sourceFieldKeys": [ + "column-2", + ], + "targetRole": "status", + }, + ], + "originalFields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "w", + "path": "table:nth-of-type(2) tr:nth-of-type(4) > :nth-child(1)", + "rawValue": "1.0", + "value": "1.0", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Benchmark Item", + "path": "table:nth-of-type(2) tr:nth-of-type(4) > :nth-child(2)", + "rawValue": "1.1.1 Ensure 'Enforce password history' is set to '24 or more password(s)'", + "value": "1.1.1 Ensure 'Enforce password history' is set to '24 or more password(s)'", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Result", + "path": "table:nth-of-type(2) tr:nth-of-type(4) > :nth-child(3)", + "rawValue": "Pass", + "value": "Pass", + }, + ], + "outcome": { + "normalizedValue": "pass", + "originalValue": "Pass", + }, + "score": undefined, + "severity": { + "normalizedValue": "unknown", + "originalValue": "", + }, + "sourceIdentity": { + "canonicalRuleId": "1.0", + "originalRuleId": "1.0", + "originalTitle": "1.1.1 Ensure 'Enforce password history' is set to '24 or more password(s)'", + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 3, + }, + "status": { + "normalizedValue": "passed", + "originalValue": "Pass", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Pass", + }, + }, + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "1.0", + "sourceFieldKeys": [ + "column-0", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "1.1.2 Ensure 'Maximum password age' is set to '365 or fewer days'", + "sourceFieldKeys": [ + "column-1", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Pass", + "sourceFieldKeys": [ + "column-2", + ], + "targetRole": "status", + }, + ], + "originalFields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "w", + "path": "table:nth-of-type(2) tr:nth-of-type(5) > :nth-child(1)", + "rawValue": "1.0", + "value": "1.0", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Benchmark Item", + "path": "table:nth-of-type(2) tr:nth-of-type(5) > :nth-child(2)", + "rawValue": "1.1.2 Ensure 'Maximum password age' is set to '365 or fewer days'", + "value": "1.1.2 Ensure 'Maximum password age' is set to '365 or fewer days'", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Result", + "path": "table:nth-of-type(2) tr:nth-of-type(5) > :nth-child(3)", + "rawValue": "Pass", + "value": "Pass", + }, + ], + "outcome": { + "normalizedValue": "pass", + "originalValue": "Pass", + }, + "score": undefined, + "severity": { + "normalizedValue": "unknown", + "originalValue": "", + }, + "sourceIdentity": { + "canonicalRuleId": "1.0", + "originalRuleId": "1.0", + "originalTitle": "1.1.2 Ensure 'Maximum password age' is set to '365 or fewer days'", + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 4, + }, + "status": { + "normalizedValue": "passed", + "originalValue": "Pass", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Pass", + }, + }, + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "1.0", + "sourceFieldKeys": [ + "column-0", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "1.1.4 Ensure 'Minimum password length' is set to '14 or more character(s)'", + "sourceFieldKeys": [ + "column-1", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Fail", + "sourceFieldKeys": [ + "column-2", + ], + "targetRole": "status", + }, + ], + "originalFields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "w", + "path": "table:nth-of-type(2) tr:nth-of-type(6) > :nth-child(1)", + "rawValue": "1.0", + "value": "1.0", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Benchmark Item", + "path": "table:nth-of-type(2) tr:nth-of-type(6) > :nth-child(2)", + "rawValue": "1.1.4 Ensure 'Minimum password length' is set to '14 or more character(s)'", + "value": "1.1.4 Ensure 'Minimum password length' is set to '14 or more character(s)'", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Result", + "path": "table:nth-of-type(2) tr:nth-of-type(6) > :nth-child(3)", + "rawValue": "Fail", + "value": "Fail", + }, + ], + "outcome": { + "normalizedValue": "fail", + "originalValue": "Fail", + }, + "score": undefined, + "severity": { + "normalizedValue": "unknown", + "originalValue": "", + }, + "sourceIdentity": { + "canonicalRuleId": "1.0", + "originalRuleId": "1.0", + "originalTitle": "1.1.4 Ensure 'Minimum password length' is set to '14 or more character(s)'", + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 5, + }, + "status": { + "normalizedValue": "failed", + "originalValue": "Fail", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Fail", + }, + }, + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "1.0", + "sourceFieldKeys": [ + "column-0", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "1.2.1 Ensure 'Account lockout duration' is set to '15 or more minute(s)'", + "sourceFieldKeys": [ + "column-1", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Fail", + "sourceFieldKeys": [ + "column-2", + ], + "targetRole": "status", + }, + ], + "originalFields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "w", + "path": "table:nth-of-type(2) tr:nth-of-type(7) > :nth-child(1)", + "rawValue": "1.0", + "value": "1.0", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Benchmark Item", + "path": "table:nth-of-type(2) tr:nth-of-type(7) > :nth-child(2)", + "rawValue": "1.2.1 Ensure 'Account lockout duration' is set to '15 or more minute(s)'", + "value": "1.2.1 Ensure 'Account lockout duration' is set to '15 or more minute(s)'", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Result", + "path": "table:nth-of-type(2) tr:nth-of-type(7) > :nth-child(3)", + "rawValue": "Fail", + "value": "Fail", + }, + ], + "outcome": { + "normalizedValue": "fail", + "originalValue": "Fail", + }, + "score": undefined, + "severity": { + "normalizedValue": "unknown", + "originalValue": "", + }, + "sourceIdentity": { + "canonicalRuleId": "1.0", + "originalRuleId": "1.0", + "originalTitle": "1.2.1 Ensure 'Account lockout duration' is set to '15 or more minute(s)'", + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 6, + }, + "status": { + "normalizedValue": "failed", + "originalValue": "Fail", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Fail", + }, + }, + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "1.0", + "sourceFieldKeys": [ + "column-0", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "1.2.2 Ensure 'Reset account lockout counter' is set to '15 or more minute(s)'", + "sourceFieldKeys": [ + "column-1", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Pass", + "sourceFieldKeys": [ + "column-2", + ], + "targetRole": "status", + }, + ], + "originalFields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "w", + "path": "table:nth-of-type(2) tr:nth-of-type(8) > :nth-child(1)", + "rawValue": "1.0", + "value": "1.0", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Benchmark Item", + "path": "table:nth-of-type(2) tr:nth-of-type(8) > :nth-child(2)", + "rawValue": "1.2.2 Ensure 'Reset account lockout counter' is set to '15 or more minute(s)'", + "value": "1.2.2 Ensure 'Reset account lockout counter' is set to '15 or more minute(s)'", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Result", + "path": "table:nth-of-type(2) tr:nth-of-type(8) > :nth-child(3)", + "rawValue": "Pass", + "value": "Pass", + }, + ], + "outcome": { + "normalizedValue": "pass", + "originalValue": "Pass", + }, + "score": undefined, + "severity": { + "normalizedValue": "unknown", + "originalValue": "", + }, + "sourceIdentity": { + "canonicalRuleId": "1.0", + "originalRuleId": "1.0", + "originalTitle": "1.2.2 Ensure 'Reset account lockout counter' is set to '15 or more minute(s)'", + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 7, + }, + "status": { + "normalizedValue": "passed", + "originalValue": "Pass", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Pass", + }, + }, + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "column-0", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "2.3.11.5 Ensure 'Network security: Force logoff when logon hours expire' is set to 'Enabled'", + "sourceFieldKeys": [ + "column-1", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Manual", + "sourceFieldKeys": [ + "column-2", + ], + "targetRole": "status", + }, + ], + "originalFields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "w", + "path": "table:nth-of-type(2) tr:nth-of-type(9) > :nth-child(1)", + "rawValue": "", + "value": "", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Benchmark Item", + "path": "table:nth-of-type(2) tr:nth-of-type(9) > :nth-child(2)", + "rawValue": "2.3.11.5 Ensure 'Network security: Force logoff when logon hours expire' is set to 'Enabled'", + "value": "2.3.11.5 Ensure 'Network security: Force logoff when logon hours expire' is set to 'Enabled'", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Result", + "path": "table:nth-of-type(2) tr:nth-of-type(9) > :nth-child(3)", + "rawValue": "Manual", + "value": "Manual", + }, + ], + "outcome": { + "normalizedValue": "informational", + "originalValue": "Manual", + }, + "score": undefined, + "severity": { + "normalizedValue": "unknown", + "originalValue": "", + }, + "sourceIdentity": { + "canonicalRuleId": undefined, + "originalRuleId": undefined, + "originalTitle": "2.3.11.5 Ensure 'Network security: Force logoff when logon hours expire' is set to 'Enabled'", + "tool": { + "id": "cis-cat", + "name": "CIS-CAT Pro", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 8, + }, + "status": { + "normalizedValue": "manual", + "originalValue": "Manual", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Manual", + }, + }, + ], + "observations": [], + "preview": { + "mappingsToConfirm": 0, + "recordCount": 8, + "selected": true, + }, + "recognition": "cis-cat", + "reportSections": [], + "scores": [ + { + "assessed": 5, + "assessmentId": "assessment-protected-dummy-import", + "comparisonKey": undefined, + "contentSha256": "94f28719869dbfc1fe0d2ee22032284bfdb01de93d3ef561faa555eb783f6650", + "deviations": 2, + "eligible": 6, + "evaluationScopeKey": undefined, + "importFileIds": [ + "file-protected-dummy-import-0", + ], + "issues": [ + "Incomplete assessment", + "Scan identity or time not verified", + "Scanner version unavailable", + ], + "method": "pass-rate", + "nativeProperty": undefined, + "nativeValue": undefined, + "observedAt": undefined, + "occurrenceIds": [ + "occurrence-protected-dummy-import-0-2", + "occurrence-protected-dummy-import-0-3", + "occurrence-protected-dummy-import-0-4", + "occurrence-protected-dummy-import-0-5", + "occurrence-protected-dummy-import-0-6", + "occurrence-protected-dummy-import-0-7", + ], + "openOccurrenceIds": [ + "occurrence-protected-dummy-import-0-4", + "occurrence-protected-dummy-import-0-5", + ], + "passed": 3, + "policy": { + "defaultWeight": 1, + "version": "1.0.0", + "weights": { + "cis-cat": 1, + "group-policy": 1, + "maester": 1, + "pingcastle": 1, + "purple-knight": 1, + }, + }, + "processingKey": "[null,"cis-cat","1.4.0","1.3.0","1.3.0","1.0.0"]", + "resultSignature": undefined, + "runIds": [ + "run-protected-dummy-import", + ], + "score": 60, + "toolId": "cis-cat", + "toolName": "CIS-CAT Pro", + }, + ], + "tables": [ + { + "columnCount": 3, + "hidden": undefined, + "index": 1, + "name": "assessmentResultTable", + "rowCount": 8, + "rows": undefined, + }, + { + "columnCount": 7, + "hidden": undefined, + "index": 0, + "name": "Tabelle 1", + "rowCount": 0, + "rows": [], + }, + ], +} +`; + +exports[`protected scanner import contracts (synthetic data only) > 'generic-csv' retains recognition, preview and approved canonical results in browser and backend 1`] = ` +{ + "approved": { + "recordCount": 2, + "selected": true, + }, + "definitions": [ + { + "canonicalKey": "unresolved:protected-dummy-import:0:candidate-sheet-e270d1f8-1", + "category": undefined, + "description": undefined, + "frameworkReferences": [], + "identity": { + "durable": false, + "evidence": [ + "Keine stabile native Regel-ID oder bestätigte lokale Mappingregel vorhanden.", + ], + "strategy": "unresolved", + "version": 1, + }, + "recommendation": undefined, + "title": "Synthetic weak policy", + }, + { + "canonicalKey": "unresolved:protected-dummy-import:0:candidate-sheet-e270d1f8-2", + "category": undefined, + "description": undefined, + "frameworkReferences": [], + "identity": { + "durable": false, + "evidence": [ + "Keine stabile native Regel-ID oder bestätigte lokale Mappingregel vorhanden.", + ], + "strategy": "unresolved", + "version": 1, + }, + "recommendation": undefined, + "title": "Synthetic legacy protocol", + }, + ], + "evidence": [], + "findings": [ + { + "affectedAsset": { + "normalizedValue": "dc01", + "originalValue": "dc01", + }, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic weak policy", + "sourceFieldKeys": [ + "column-0", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "High", + "sourceFieldKeys": [ + "column-1", + ], + "targetRole": "severity", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Open", + "sourceFieldKeys": [ + "column-2", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "dc01", + "sourceFieldKeys": [ + "column-3", + ], + "targetRole": "asset", + }, + ], + "originalFields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Title", + "path": undefined, + "rawValue": "Synthetic weak policy", + "value": "Synthetic weak policy", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Severity", + "path": undefined, + "rawValue": "High", + "value": "High", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Status", + "path": undefined, + "rawValue": "Open", + "value": "Open", + }, + { + "columnIndex": 3, + "key": "column-3", + "label": "Asset", + "path": undefined, + "rawValue": "dc01", + "value": "dc01", + }, + ], + "outcome": { + "normalizedValue": "unknown", + "originalValue": "Open", + }, + "score": undefined, + "severity": { + "normalizedValue": "high", + "originalValue": "High", + }, + "sourceIdentity": { + "canonicalRuleId": undefined, + "originalRuleId": undefined, + "originalTitle": "Synthetic weak policy", + "tool": { + "id": "generic", + "name": "Tabular Data", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 1, + }, + "status": { + "normalizedValue": "open", + "originalValue": "Open", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Open", + }, + }, + { + "affectedAsset": { + "normalizedValue": "server01", + "originalValue": "server01", + }, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic legacy protocol", + "sourceFieldKeys": [ + "column-0", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Medium", + "sourceFieldKeys": [ + "column-1", + ], + "targetRole": "severity", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Closed", + "sourceFieldKeys": [ + "column-2", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "server01", + "sourceFieldKeys": [ + "column-3", + ], + "targetRole": "asset", + }, + ], + "originalFields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Title", + "path": undefined, + "rawValue": "Synthetic legacy protocol", + "value": "Synthetic legacy protocol", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Severity", + "path": undefined, + "rawValue": "Medium", + "value": "Medium", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Status", + "path": undefined, + "rawValue": "Closed", + "value": "Closed", + }, + { + "columnIndex": 3, + "key": "column-3", + "label": "Asset", + "path": undefined, + "rawValue": "server01", + "value": "server01", + }, + ], + "outcome": { + "normalizedValue": "unknown", + "originalValue": "Closed", + }, + "score": undefined, + "severity": { + "normalizedValue": "medium", + "originalValue": "Medium", + }, + "sourceIdentity": { + "canonicalRuleId": undefined, + "originalRuleId": undefined, + "originalTitle": "Synthetic legacy protocol", + "tool": { + "id": "generic", + "name": "Tabular Data", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 2, + }, + "status": { + "normalizedValue": "resolved", + "originalValue": "Closed", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Closed", + }, + }, + ], + "observations": [], + "preview": { + "mappingsToConfirm": 0, + "recordCount": 2, + "selected": true, + }, + "recognition": "unknown", + "reportSections": [], + "scores": [ + { + "assessed": 0, + "assessmentId": "assessment-protected-dummy-import", + "comparisonKey": undefined, + "contentSha256": "071b95c719a9ce99b8221f8eb2c26d8ca06fc428467bb296dfb1967774ba32b5", + "deviations": 0, + "eligible": 2, + "evaluationScopeKey": undefined, + "importFileIds": [ + "file-protected-dummy-import-0", + ], + "issues": [ + "Evidence present, not scoreable", + "Incomplete assessment", + "Scan identity or time not verified", + "Scanner version unavailable", + ], + "method": "unavailable", + "nativeProperty": undefined, + "nativeValue": undefined, + "observedAt": undefined, + "occurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + "occurrence-protected-dummy-import-0-1", + ], + "openOccurrenceIds": [], + "passed": 0, + "policy": { + "defaultWeight": 1, + "version": "1.0.0", + "weights": { + "cis-cat": 1, + "group-policy": 1, + "maester": 1, + "pingcastle": 1, + "purple-knight": 1, + }, + }, + "processingKey": "[null,"generic","1.0.0","1.0.0","1.0.0","1.0.0"]", + "resultSignature": undefined, + "runIds": [ + "run-protected-dummy-import", + ], + "score": undefined, + "toolId": "generic", + "toolName": "Tabular Data", + }, + ], + "tables": [ + { + "columnCount": 4, + "hidden": undefined, + "index": 0, + "name": "CSV", + "rowCount": 2, + "rows": undefined, + }, + ], +} +`; + +exports[`protected scanner import contracts (synthetic data only) > 'maester-bare-json' retains recognition, preview and approved canonical results in browser and backend 1`] = ` +{ + "approved": { + "recordCount": 2, + "selected": true, + }, + "definitions": [ + { + "canonicalKey": "maester:mt.99001", + "category": undefined, + "description": undefined, + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Dummy failed check", + }, + { + "canonicalKey": "maester:mt.99002", + "category": undefined, + "description": undefined, + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Dummy passed check", + }, + ], + "evidence": [ + { + "finding": { + "canonicalRuleId": "MT.99001", + "originalRuleId": "MT.99001", + "originalTitle": "Dummy failed check", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "kind": "text", + "sourceFieldKeys": [ + "TestResult", + ], + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[0].ResultDetail.TestResult", + "rowIndex": undefined, + }, + "value": "Synthetic evidence only", + }, + ], + "findings": [ + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.99001", + "sourceFieldKeys": [ + "Id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Dummy failed check", + "sourceFieldKeys": [ + "Title", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Failed", + "sourceFieldKeys": [ + "Result", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "High", + "sourceFieldKeys": [ + "Severity", + ], + "targetRole": "severity", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic evidence only", + "sourceFieldKeys": [ + "TestResult", + ], + "targetRole": "evidence", + }, + ], + "originalFields": [ + { + "columnIndex": undefined, + "key": "Id", + "label": "ID", + "path": "$.Tests[0].Id", + "rawValue": "MT.99001", + "value": "MT.99001", + }, + { + "columnIndex": undefined, + "key": "Title", + "label": "Title", + "path": "$.Tests[0].Title", + "rawValue": "Dummy failed check", + "value": "Dummy failed check", + }, + { + "columnIndex": undefined, + "key": "Result", + "label": "Result", + "path": "$.Tests[0].Result", + "rawValue": "Failed", + "value": "Failed", + }, + { + "columnIndex": undefined, + "key": "Severity", + "label": "Severity", + "path": "$.Tests[0].Severity", + "rawValue": "High", + "value": "High", + }, + { + "columnIndex": undefined, + "key": "TestResult", + "label": "ResultDetail", + "path": "$.Tests[0].ResultDetail.TestResult", + "rawValue": "Synthetic evidence only", + "value": "Synthetic evidence only", + }, + ], + "outcome": { + "normalizedValue": "fail", + "originalValue": "Failed", + }, + "score": undefined, + "severity": { + "normalizedValue": "high", + "originalValue": "High", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.99001", + "originalRuleId": "MT.99001", + "originalTitle": "Dummy failed check", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[0]", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "failed", + "originalValue": "Failed", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Failed", + }, + }, + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.99002", + "sourceFieldKeys": [ + "Id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Dummy passed check", + "sourceFieldKeys": [ + "Title", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Passed", + "sourceFieldKeys": [ + "Result", + ], + "targetRole": "status", + }, + ], + "originalFields": [ + { + "columnIndex": undefined, + "key": "Id", + "label": "ID", + "path": "$.Tests[1].Id", + "rawValue": "MT.99002", + "value": "MT.99002", + }, + { + "columnIndex": undefined, + "key": "Title", + "label": "Title", + "path": "$.Tests[1].Title", + "rawValue": "Dummy passed check", + "value": "Dummy passed check", + }, + { + "columnIndex": undefined, + "key": "Result", + "label": "Result", + "path": "$.Tests[1].Result", + "rawValue": "Passed", + "value": "Passed", + }, + ], + "outcome": { + "normalizedValue": "pass", + "originalValue": "Passed", + }, + "score": undefined, + "severity": { + "normalizedValue": "unknown", + "originalValue": "", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.99002", + "originalRuleId": "MT.99002", + "originalTitle": "Dummy passed check", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[1]", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "passed", + "originalValue": "Passed", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Passed", + }, + }, + ], + "observations": [ + { + "attributes": { + "declared": false, + "sourceField": "Result", + }, + "label": "Reported overall result", + "normalizedValue": "Failed", + "observedAt": undefined, + "originalValue": "Failed", + "property": "maester.result", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Result", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "enum", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "TenantId", + }, + "label": "Entra tenant ID", + "normalizedValue": "dummy-tenant", + "observedAt": undefined, + "originalValue": "dummy-tenant", + "property": "entra.tenant_id", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.TenantId", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "TenantName", + }, + "label": "Entra tenant name", + "normalizedValue": "Dummy", + "observedAt": undefined, + "originalValue": "Dummy", + "property": "entra.tenant_name", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.TenantName", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "TotalCount", + }, + "label": "Reported test count", + "normalizedValue": 2, + "observedAt": undefined, + "originalValue": 2, + "property": "maester.tests.declared_total", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.TotalCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "FailedCount", + }, + "label": "Reported Failed count", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.declared_failed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.FailedCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "PassedCount", + }, + "label": "Reported Passed count", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.declared_passed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.PassedCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "EndOfJson", + }, + "label": "End Of Json", + "normalizedValue": "EndOfJson", + "observedAt": undefined, + "originalValue": "EndOfJson", + "property": "maester.source.EndOfJson", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.EndOfJson", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "", + }, + "label": "Counted total tests", + "normalizedValue": 2, + "observedAt": undefined, + "originalValue": 2, + "property": "maester.tests.actual_total", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Failed", + }, + "label": "Counted failed tests", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.actual_failed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Passed", + }, + "label": "Counted passed tests", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.actual_passed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Error", + }, + "label": "Counted error tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_error", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Investigate", + }, + "label": "Counted investigate tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_investigate", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Skipped", + }, + "label": "Counted skipped tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_skipped", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "NotRun", + }, + "label": "Counted notrun tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_notrun", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + ], + "preview": { + "mappingsToConfirm": 0, + "recordCount": 2, + "selected": true, + }, + "recognition": "maester", + "reportSections": [ + { + "attributes": { + "kind": "maester-report-context", + }, + "detailLinks": undefined, + "label": "Maester report context", + "modalDetails": undefined, + "ordinal": 0, + "parentSectionKey": undefined, + "prose": undefined, + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$", + "rowIndex": undefined, + }, + "state": "observed", + "tables": [ + { + "headers": [ + "Property", + "Original value", + ], + "name": "Report metadata", + "rows": [ + [ + "Result", + "Failed", + ], + [ + "TenantId", + "dummy-tenant", + ], + [ + "TenantName", + "Dummy", + ], + [ + "TotalCount", + 2, + ], + [ + "FailedCount", + 1, + ], + [ + "PassedCount", + 1, + ], + [ + "EndOfJson", + "EndOfJson", + ], + ], + }, + ], + "tool": { + "id": "maester", + "name": "Maester", + }, + "version": 1, + }, + ], + "scores": [ + { + "assessed": 2, + "assessmentId": "assessment-protected-dummy-import", + "comparisonKey": "["maester","dummy-tenant","default"]", + "contentSha256": "a7ba4d0acb70c35f8bc09e9beb54c9b299f672c3037ce2fe4a17c216d2bf25c3", + "deviations": 1, + "eligible": 2, + "evaluationScopeKey": "["[\\"MT.99001\\",\\"evaluated\\"]","[\\"MT.99002\\",\\"evaluated\\"]"]", + "importFileIds": [ + "file-protected-dummy-import-0", + ], + "issues": [ + "Scan identity or time not verified", + "Scanner version unavailable", + ], + "method": "pass-rate", + "nativeProperty": undefined, + "nativeValue": undefined, + "observedAt": undefined, + "occurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + "occurrence-protected-dummy-import-0-1", + ], + "openOccurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + ], + "passed": 1, + "policy": { + "defaultWeight": 1, + "version": "1.0.0", + "weights": { + "cis-cat": 1, + "group-policy": 1, + "maester": 1, + "pingcastle": 1, + "purple-knight": 1, + }, + }, + "processingKey": "[null,"maester","1.2.0","1.1.0","1.0.0","1.0.0"]", + "resultSignature": "["[\\"MT.99001\\",null,\\"fail\\"]","[\\"MT.99002\\",null,\\"pass\\"]"]", + "runIds": [ + "run-protected-dummy-import", + ], + "score": 50, + "toolId": "maester", + "toolName": "Maester", + }, + ], + "tables": [ + { + "columnCount": 1, + "hidden": undefined, + "index": 0, + "name": "HTML", + "rowCount": 1, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Hinweis", + "value": "Synthetic Dummy data only. No customer data.", + }, + ], + "highlighted": false, + "index": 0, + }, + ], + }, + ], +} +`; + +exports[`protected scanner import contracts (synthetic data only) > 'maester-csv' retains recognition, preview and approved canonical results in browser and backend 1`] = ` +{ + "approved": { + "recordCount": 2, + "selected": true, + }, + "definitions": [ + { + "canonicalKey": "maester:mt.1001", + "category": "CIS, Entra", + "description": "Checks whether the synthetic tenant requires multifactor authentication.", + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Ensure multifactor authentication is configured", + }, + { + "canonicalKey": "maester:mt.1002", + "category": "CIS, Entra", + "description": "Checks whether legacy authentication is blocked.", + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Ensure legacy authentication is blocked", + }, + ], + "evidence": [ + { + "finding": { + "canonicalRuleId": "MT.1001", + "originalRuleId": "MT.1001", + "originalTitle": "Ensure multifactor authentication is configured", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "kind": "text", + "sourceFieldKeys": [ + "column-8", + ], + "sourcePosition": { + "cellReference": undefined, + "columnIndex": 8, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 1, + }, + "value": "The synthetic policy does not cover all users.", + }, + { + "finding": { + "canonicalRuleId": "MT.1002", + "originalRuleId": "MT.1002", + "originalTitle": "Ensure legacy authentication is blocked", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "kind": "text", + "sourceFieldKeys": [ + "column-8", + ], + "sourcePosition": { + "cellReference": undefined, + "columnIndex": 8, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 2, + }, + "value": "The synthetic policy blocks legacy authentication.", + }, + ], + "findings": [ + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.1001", + "sourceFieldKeys": [ + "column-0", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Ensure multifactor authentication is configured", + "sourceFieldKeys": [ + "column-1", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Failed", + "sourceFieldKeys": [ + "column-2", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "High", + "sourceFieldKeys": [ + "column-3", + ], + "targetRole": "severity", + }, + { + "decisionSource": "automatic", + "normalizedValue": "CIS, Entra", + "sourceFieldKeys": [ + "column-4", + ], + "targetRole": "category", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Maester Security Tests", + "sourceFieldKeys": [ + "column-5", + ], + "targetRole": "category", + }, + { + "decisionSource": "automatic", + "normalizedValue": "00:00:01", + "sourceFieldKeys": [ + "column-6", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Checks whether the synthetic tenant requires multifactor authentication.", + "sourceFieldKeys": [ + "column-7", + ], + "targetRole": "description", + }, + { + "decisionSource": "automatic", + "normalizedValue": "The synthetic policy does not cover all users.", + "sourceFieldKeys": [ + "column-8", + ], + "targetRole": "evidence", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "column-9", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "column-10", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "column-11", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "MT.1001: Ensure multifactor authentication is configured", + "sourceFieldKeys": [ + "column-12", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "https://maester.dev/docs/tests/MT.1001", + "sourceFieldKeys": [ + "column-13", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "automatic", + "normalizedValue": "MT.1001.Tests.ps1", + "sourceFieldKeys": [ + "column-14", + ], + "targetRole": "rule-id", + }, + ], + "originalFields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "ID", + "path": undefined, + "rawValue": "MT.1001", + "value": "MT.1001", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Title", + "path": undefined, + "rawValue": "Ensure multifactor authentication is configured", + "value": "Ensure multifactor authentication is configured", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Result", + "path": undefined, + "rawValue": "Failed", + "value": "Failed", + }, + { + "columnIndex": 3, + "key": "column-3", + "label": "Severity", + "path": undefined, + "rawValue": "High", + "value": "High", + }, + { + "columnIndex": 4, + "key": "column-4", + "label": "Tag", + "path": undefined, + "rawValue": "CIS, Entra", + "value": "CIS, Entra", + }, + { + "columnIndex": 5, + "key": "column-5", + "label": "Block", + "path": undefined, + "rawValue": "Maester Security Tests", + "value": "Maester Security Tests", + }, + { + "columnIndex": 6, + "key": "column-6", + "label": "Duration", + "path": undefined, + "rawValue": "00:00:01", + "value": "00:00:01", + }, + { + "columnIndex": 7, + "key": "column-7", + "label": "Description", + "path": undefined, + "rawValue": "Checks whether the synthetic tenant requires multifactor authentication.", + "value": "Checks whether the synthetic tenant requires multifactor authentication.", + }, + { + "columnIndex": 8, + "key": "column-8", + "label": "ResultDetail", + "path": undefined, + "rawValue": "The synthetic policy does not cover all users.", + "value": "The synthetic policy does not cover all users.", + }, + { + "columnIndex": 9, + "key": "column-9", + "label": "TestSkipped", + "path": undefined, + "rawValue": "", + "value": "", + }, + { + "columnIndex": 10, + "key": "column-10", + "label": "SkippedReason", + "path": undefined, + "rawValue": "", + "value": "", + }, + { + "columnIndex": 11, + "key": "column-11", + "label": "ErrorMessage", + "path": undefined, + "rawValue": "", + "value": "", + }, + { + "columnIndex": 12, + "key": "column-12", + "label": "Name", + "path": undefined, + "rawValue": "MT.1001: Ensure multifactor authentication is configured", + "value": "MT.1001: Ensure multifactor authentication is configured", + }, + { + "columnIndex": 13, + "key": "column-13", + "label": "HelpUrl", + "path": undefined, + "rawValue": "https://maester.dev/docs/tests/MT.1001", + "value": "https://maester.dev/docs/tests/MT.1001", + }, + { + "columnIndex": 14, + "key": "column-14", + "label": "TestScriptFile", + "path": undefined, + "rawValue": "MT.1001.Tests.ps1", + "value": "MT.1001.Tests.ps1", + }, + ], + "outcome": { + "normalizedValue": "fail", + "originalValue": "Failed", + }, + "score": undefined, + "severity": { + "normalizedValue": "high", + "originalValue": "High", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.1001", + "originalRuleId": "MT.1001", + "originalTitle": "Ensure multifactor authentication is configured", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 1, + }, + "status": { + "normalizedValue": "failed", + "originalValue": "Failed", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Failed", + }, + }, + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.1002", + "sourceFieldKeys": [ + "column-0", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Ensure legacy authentication is blocked", + "sourceFieldKeys": [ + "column-1", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Passed", + "sourceFieldKeys": [ + "column-2", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Medium", + "sourceFieldKeys": [ + "column-3", + ], + "targetRole": "severity", + }, + { + "decisionSource": "automatic", + "normalizedValue": "CIS, Entra", + "sourceFieldKeys": [ + "column-4", + ], + "targetRole": "category", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Maester Security Tests", + "sourceFieldKeys": [ + "column-5", + ], + "targetRole": "category", + }, + { + "decisionSource": "automatic", + "normalizedValue": "00:00:01", + "sourceFieldKeys": [ + "column-6", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Checks whether legacy authentication is blocked.", + "sourceFieldKeys": [ + "column-7", + ], + "targetRole": "description", + }, + { + "decisionSource": "automatic", + "normalizedValue": "The synthetic policy blocks legacy authentication.", + "sourceFieldKeys": [ + "column-8", + ], + "targetRole": "evidence", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "column-9", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "column-10", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "column-11", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "MT.1002: Ensure legacy authentication is blocked", + "sourceFieldKeys": [ + "column-12", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "https://maester.dev/docs/tests/MT.1002", + "sourceFieldKeys": [ + "column-13", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "automatic", + "normalizedValue": "MT.1002.Tests.ps1", + "sourceFieldKeys": [ + "column-14", + ], + "targetRole": "rule-id", + }, + ], + "originalFields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "ID", + "path": undefined, + "rawValue": "MT.1002", + "value": "MT.1002", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Title", + "path": undefined, + "rawValue": "Ensure legacy authentication is blocked", + "value": "Ensure legacy authentication is blocked", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Result", + "path": undefined, + "rawValue": "Passed", + "value": "Passed", + }, + { + "columnIndex": 3, + "key": "column-3", + "label": "Severity", + "path": undefined, + "rawValue": "Medium", + "value": "Medium", + }, + { + "columnIndex": 4, + "key": "column-4", + "label": "Tag", + "path": undefined, + "rawValue": "CIS, Entra", + "value": "CIS, Entra", + }, + { + "columnIndex": 5, + "key": "column-5", + "label": "Block", + "path": undefined, + "rawValue": "Maester Security Tests", + "value": "Maester Security Tests", + }, + { + "columnIndex": 6, + "key": "column-6", + "label": "Duration", + "path": undefined, + "rawValue": "00:00:01", + "value": "00:00:01", + }, + { + "columnIndex": 7, + "key": "column-7", + "label": "Description", + "path": undefined, + "rawValue": "Checks whether legacy authentication is blocked.", + "value": "Checks whether legacy authentication is blocked.", + }, + { + "columnIndex": 8, + "key": "column-8", + "label": "ResultDetail", + "path": undefined, + "rawValue": "The synthetic policy blocks legacy authentication.", + "value": "The synthetic policy blocks legacy authentication.", + }, + { + "columnIndex": 9, + "key": "column-9", + "label": "TestSkipped", + "path": undefined, + "rawValue": "", + "value": "", + }, + { + "columnIndex": 10, + "key": "column-10", + "label": "SkippedReason", + "path": undefined, + "rawValue": "", + "value": "", + }, + { + "columnIndex": 11, + "key": "column-11", + "label": "ErrorMessage", + "path": undefined, + "rawValue": "", + "value": "", + }, + { + "columnIndex": 12, + "key": "column-12", + "label": "Name", + "path": undefined, + "rawValue": "MT.1002: Ensure legacy authentication is blocked", + "value": "MT.1002: Ensure legacy authentication is blocked", + }, + { + "columnIndex": 13, + "key": "column-13", + "label": "HelpUrl", + "path": undefined, + "rawValue": "https://maester.dev/docs/tests/MT.1002", + "value": "https://maester.dev/docs/tests/MT.1002", + }, + { + "columnIndex": 14, + "key": "column-14", + "label": "TestScriptFile", + "path": undefined, + "rawValue": "MT.1002.Tests.ps1", + "value": "MT.1002.Tests.ps1", + }, + ], + "outcome": { + "normalizedValue": "pass", + "originalValue": "Passed", + }, + "score": undefined, + "severity": { + "normalizedValue": "medium", + "originalValue": "Medium", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.1002", + "originalRuleId": "MT.1002", + "originalTitle": "Ensure legacy authentication is blocked", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 2, + }, + "status": { + "normalizedValue": "passed", + "originalValue": "Passed", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Passed", + }, + }, + ], + "observations": [ + { + "attributes": { + "clientInformationArea": "cloud-access", + "clientInformationMappingVersion": "1.0.0", + "informationKind": "reported-test-evidence", + "inventoryComplete": false, + "sourceField": "table-result-cell", + "sourceOutcome": "Failed", + "sourceRuleId": "MT.1001", + "sourceTableIndex": 0, + }, + "label": "Ensure multifactor authentication is configured", + "normalizedValue": undefined, + "observedAt": undefined, + "originalValue": "The synthetic policy does not cover all users.", + "property": "cloud.conditional_access.mt.1001.reported_evidence", + "sectionKey": "maester-client-information-0-0", + "source": { + "cellReference": undefined, + "columnIndex": 8, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 1, + }, + "state": "observed", + "subject": { + "identity": undefined, + "label": undefined, + "type": "entra-tenant", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "string", + "version": 1, + }, + ], + "preview": { + "mappingsToConfirm": 0, + "recordCount": 2, + "selected": true, + }, + "recognition": "maester", + "reportSections": [ + { + "attributes": { + "clientInformationArea": "cloud-access", + "clientInformationMappingVersion": "1.0.0", + "informationKind": "reported-test-evidence", + "inventoryComplete": false, + "sourceField": "table-result-cell", + "sourceOutcome": "Failed", + "sourceRuleId": "MT.1001", + "sourceTableIndex": 0, + }, + "detailLinks": undefined, + "label": "Ensure multifactor authentication is configured", + "modalDetails": undefined, + "ordinal": 0, + "parentSectionKey": undefined, + "prose": undefined, + "sectionKey": "maester-client-information-0-0", + "source": { + "cellReference": undefined, + "columnIndex": 8, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 1, + }, + "state": "observed", + "tables": [], + "tool": { + "id": "maester", + "name": "Maester", + }, + "version": 1, + }, + ], + "scores": [ + { + "assessed": 2, + "assessmentId": "assessment-protected-dummy-import", + "comparisonKey": undefined, + "contentSha256": "81da04c9a4b0e65b674c1dc2d34d04812b7f9a3289b3801e5741260600feef03", + "deviations": 1, + "eligible": 2, + "evaluationScopeKey": "["[\\"MT.1001\\",\\"evaluated\\"]","[\\"MT.1002\\",\\"evaluated\\"]"]", + "importFileIds": [ + "file-protected-dummy-import-0", + ], + "issues": [ + "Scan identity or time not verified", + "Scanner version unavailable", + ], + "method": "pass-rate", + "nativeProperty": undefined, + "nativeValue": undefined, + "observedAt": undefined, + "occurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + "occurrence-protected-dummy-import-0-1", + ], + "openOccurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + ], + "passed": 1, + "policy": { + "defaultWeight": 1, + "version": "1.0.0", + "weights": { + "cis-cat": 1, + "group-policy": 1, + "maester": 1, + "pingcastle": 1, + "purple-knight": 1, + }, + }, + "processingKey": "[null,"maester","1.2.0","1.1.0","1.0.0","1.0.0"]", + "resultSignature": "["[\\"MT.1001\\",null,\\"fail\\"]","[\\"MT.1002\\",null,\\"pass\\"]"]", + "runIds": [ + "run-protected-dummy-import", + ], + "score": 50, + "toolId": "maester", + "toolName": "Maester", + }, + ], + "tables": [ + { + "columnCount": 15, + "hidden": undefined, + "index": 0, + "name": "CSV", + "rowCount": 2, + "rows": undefined, + }, + ], +} +`; + +exports[`protected scanner import contracts (synthetic data only) > 'maester-current-html' retains recognition, preview and approved canonical results in browser and backend 1`] = ` +{ + "approved": { + "recordCount": 2, + "selected": true, + }, + "definitions": [ + { + "canonicalKey": "maester:mt.99001", + "category": undefined, + "description": undefined, + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Dummy failed check", + }, + { + "canonicalKey": "maester:mt.99002", + "category": undefined, + "description": undefined, + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Dummy passed check", + }, + ], + "evidence": [ + { + "finding": { + "canonicalRuleId": "MT.99001", + "originalRuleId": "MT.99001", + "originalTitle": "Dummy failed check", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "kind": "text", + "sourceFieldKeys": [ + "TestResult", + ], + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[0].ResultDetail.TestResult", + "rowIndex": undefined, + }, + "value": "Synthetic evidence only", + }, + ], + "findings": [ + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.99001", + "sourceFieldKeys": [ + "Id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Dummy failed check", + "sourceFieldKeys": [ + "Title", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Failed", + "sourceFieldKeys": [ + "Result", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "High", + "sourceFieldKeys": [ + "Severity", + ], + "targetRole": "severity", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic evidence only", + "sourceFieldKeys": [ + "TestResult", + ], + "targetRole": "evidence", + }, + ], + "originalFields": [ + { + "columnIndex": undefined, + "key": "Id", + "label": "ID", + "path": "$.Tests[0].Id", + "rawValue": "MT.99001", + "value": "MT.99001", + }, + { + "columnIndex": undefined, + "key": "Title", + "label": "Title", + "path": "$.Tests[0].Title", + "rawValue": "Dummy failed check", + "value": "Dummy failed check", + }, + { + "columnIndex": undefined, + "key": "Result", + "label": "Result", + "path": "$.Tests[0].Result", + "rawValue": "Failed", + "value": "Failed", + }, + { + "columnIndex": undefined, + "key": "Severity", + "label": "Severity", + "path": "$.Tests[0].Severity", + "rawValue": "High", + "value": "High", + }, + { + "columnIndex": undefined, + "key": "TestResult", + "label": "ResultDetail", + "path": "$.Tests[0].ResultDetail.TestResult", + "rawValue": "Synthetic evidence only", + "value": "Synthetic evidence only", + }, + ], + "outcome": { + "normalizedValue": "fail", + "originalValue": "Failed", + }, + "score": undefined, + "severity": { + "normalizedValue": "high", + "originalValue": "High", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.99001", + "originalRuleId": "MT.99001", + "originalTitle": "Dummy failed check", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[0]", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "failed", + "originalValue": "Failed", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Failed", + }, + }, + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.99002", + "sourceFieldKeys": [ + "Id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Dummy passed check", + "sourceFieldKeys": [ + "Title", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Passed", + "sourceFieldKeys": [ + "Result", + ], + "targetRole": "status", + }, + ], + "originalFields": [ + { + "columnIndex": undefined, + "key": "Id", + "label": "ID", + "path": "$.Tests[1].Id", + "rawValue": "MT.99002", + "value": "MT.99002", + }, + { + "columnIndex": undefined, + "key": "Title", + "label": "Title", + "path": "$.Tests[1].Title", + "rawValue": "Dummy passed check", + "value": "Dummy passed check", + }, + { + "columnIndex": undefined, + "key": "Result", + "label": "Result", + "path": "$.Tests[1].Result", + "rawValue": "Passed", + "value": "Passed", + }, + ], + "outcome": { + "normalizedValue": "pass", + "originalValue": "Passed", + }, + "score": undefined, + "severity": { + "normalizedValue": "unknown", + "originalValue": "", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.99002", + "originalRuleId": "MT.99002", + "originalTitle": "Dummy passed check", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[1]", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "passed", + "originalValue": "Passed", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Passed", + }, + }, + ], + "observations": [ + { + "attributes": { + "declared": false, + "sourceField": "Result", + }, + "label": "Reported overall result", + "normalizedValue": "Failed", + "observedAt": undefined, + "originalValue": "Failed", + "property": "maester.result", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Result", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "enum", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "TenantId", + }, + "label": "Entra tenant ID", + "normalizedValue": "dummy-tenant", + "observedAt": undefined, + "originalValue": "dummy-tenant", + "property": "entra.tenant_id", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.TenantId", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "TenantName", + }, + "label": "Entra tenant name", + "normalizedValue": "Dummy", + "observedAt": undefined, + "originalValue": "Dummy", + "property": "entra.tenant_name", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.TenantName", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "TotalCount", + }, + "label": "Reported test count", + "normalizedValue": 2, + "observedAt": undefined, + "originalValue": 2, + "property": "maester.tests.declared_total", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.TotalCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "FailedCount", + }, + "label": "Reported Failed count", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.declared_failed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.FailedCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "PassedCount", + }, + "label": "Reported Passed count", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.declared_passed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.PassedCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "EndOfJson", + }, + "label": "End Of Json", + "normalizedValue": "EndOfJson", + "observedAt": undefined, + "originalValue": "EndOfJson", + "property": "maester.source.EndOfJson", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.EndOfJson", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "", + }, + "label": "Counted total tests", + "normalizedValue": 2, + "observedAt": undefined, + "originalValue": 2, + "property": "maester.tests.actual_total", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Failed", + }, + "label": "Counted failed tests", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.actual_failed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Passed", + }, + "label": "Counted passed tests", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.actual_passed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Error", + }, + "label": "Counted error tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_error", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Investigate", + }, + "label": "Counted investigate tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_investigate", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Skipped", + }, + "label": "Counted skipped tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_skipped", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "NotRun", + }, + "label": "Counted notrun tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_notrun", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + ], + "preview": { + "mappingsToConfirm": 0, + "recordCount": 2, + "selected": true, + }, + "recognition": "maester", + "reportSections": [ + { + "attributes": { + "kind": "maester-report-context", + }, + "detailLinks": undefined, + "label": "Maester report context", + "modalDetails": undefined, + "ordinal": 0, + "parentSectionKey": undefined, + "prose": undefined, + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$", + "rowIndex": undefined, + }, + "state": "observed", + "tables": [ + { + "headers": [ + "Property", + "Original value", + ], + "name": "Report metadata", + "rows": [ + [ + "Result", + "Failed", + ], + [ + "TenantId", + "dummy-tenant", + ], + [ + "TenantName", + "Dummy", + ], + [ + "TotalCount", + 2, + ], + [ + "FailedCount", + 1, + ], + [ + "PassedCount", + 1, + ], + [ + "EndOfJson", + "EndOfJson", + ], + ], + }, + ], + "tool": { + "id": "maester", + "name": "Maester", + }, + "version": 1, + }, + ], + "scores": [ + { + "assessed": 2, + "assessmentId": "assessment-protected-dummy-import", + "comparisonKey": "["maester","dummy-tenant","default"]", + "contentSha256": "f3b54871281d8a15e54098cd99d4fc1ab02e7f06b0e779c63632a5971effd918", + "deviations": 1, + "eligible": 2, + "evaluationScopeKey": "["[\\"MT.99001\\",\\"evaluated\\"]","[\\"MT.99002\\",\\"evaluated\\"]"]", + "importFileIds": [ + "file-protected-dummy-import-0", + ], + "issues": [ + "Scan identity or time not verified", + "Scanner version unavailable", + ], + "method": "pass-rate", + "nativeProperty": undefined, + "nativeValue": undefined, + "observedAt": undefined, + "occurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + "occurrence-protected-dummy-import-0-1", + ], + "openOccurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + ], + "passed": 1, + "policy": { + "defaultWeight": 1, + "version": "1.0.0", + "weights": { + "cis-cat": 1, + "group-policy": 1, + "maester": 1, + "pingcastle": 1, + "purple-knight": 1, + }, + }, + "processingKey": "[null,"maester","1.2.0","1.1.0","1.0.0","1.0.0"]", + "resultSignature": "["[\\"MT.99001\\",null,\\"fail\\"]","[\\"MT.99002\\",null,\\"pass\\"]"]", + "runIds": [ + "run-protected-dummy-import", + ], + "score": 50, + "toolId": "maester", + "toolName": "Maester", + }, + ], + "tables": [ + { + "columnCount": 1, + "hidden": undefined, + "index": 0, + "name": "HTML", + "rowCount": 1, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Hinweis", + "value": "Synthetic Dummy data only. No customer data.", + }, + ], + "highlighted": false, + "index": 0, + }, + ], + }, + ], +} +`; + +exports[`protected scanner import contracts (synthetic data only) > 'maester-legacy-html' retains recognition, preview and approved canonical results in browser and backend 1`] = ` +{ + "approved": { + "recordCount": 2, + "selected": true, + }, + "definitions": [ + { + "canonicalKey": "maester:mt.9001", + "category": "["Synthetic","Entra"]", + "description": "Checks a synthetic policy without tenant data.", + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Ensure the synthetic policy is configured", + }, + { + "canonicalKey": "maester:mt.9002", + "category": "["Synthetic"]", + "description": "Checks a second synthetic control.", + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Ensure the synthetic control is enabled", + }, + ], + "evidence": [ + { + "finding": { + "canonicalRuleId": "MT.9001", + "originalRuleId": "MT.9001", + "originalTitle": "Ensure the synthetic policy is configured", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "kind": "text", + "sourceFieldKeys": [ + "TestResult", + ], + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[0].ResultDetail.TestResult", + "rowIndex": undefined, + }, + "value": "The synthetic policy needs review.", + }, + { + "finding": { + "canonicalRuleId": "MT.9001", + "originalRuleId": "MT.9001", + "originalTitle": "Ensure the synthetic policy is configured", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "kind": "text", + "sourceFieldKeys": [ + "ScriptBlock", + ], + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[0].ScriptBlock", + "rowIndex": undefined, + }, + "value": "Describe 'synthetic test' { It 'is deterministic' { $true | Should -BeTrue } }", + }, + { + "finding": { + "canonicalRuleId": "MT.9002", + "originalRuleId": "MT.9002", + "originalTitle": "Ensure the synthetic control is enabled", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "kind": "text", + "sourceFieldKeys": [ + "TestResult", + ], + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[1].ResultDetail.TestResult", + "rowIndex": undefined, + }, + "value": "The synthetic control is enabled.", + }, + ], + "findings": [ + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.9001", + "sourceFieldKeys": [ + "Id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Ensure the synthetic policy is configured", + "sourceFieldKeys": [ + "Title", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Failed", + "sourceFieldKeys": [ + "Result", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "High", + "sourceFieldKeys": [ + "Severity", + ], + "targetRole": "severity", + }, + { + "decisionSource": "automatic", + "normalizedValue": "["Synthetic","Entra"]", + "sourceFieldKeys": [ + "Tag", + ], + "targetRole": "category", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Maester Security Tests", + "sourceFieldKeys": [ + "Block", + ], + "targetRole": "category", + }, + { + "decisionSource": "automatic", + "normalizedValue": "00:00:01", + "sourceFieldKeys": [ + "Duration", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Checks a synthetic policy without tenant data.", + "sourceFieldKeys": [ + "TestDescription", + ], + "targetRole": "description", + }, + { + "decisionSource": "automatic", + "normalizedValue": "The synthetic policy needs review.", + "sourceFieldKeys": [ + "TestResult", + ], + "targetRole": "evidence", + }, + { + "decisionSource": "automatic", + "normalizedValue": "false", + "sourceFieldKeys": [ + "TestSkipped", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "SkippedReason", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "ErrorRecord", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "MT.9001: Ensure the synthetic policy is configured", + "sourceFieldKeys": [ + "Name", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "https://maester.dev/docs/tests/MT.9001", + "sourceFieldKeys": [ + "HelpUrl", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "automatic", + "normalizedValue": "MT.9001.Tests.ps1", + "sourceFieldKeys": [ + "ScriptBlockFile", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "user-confirmed", + "normalizedValue": "Describe 'synthetic test' { It 'is deterministic' { $true | Should -BeTrue } }", + "sourceFieldKeys": [ + "ScriptBlock", + ], + "targetRole": "evidence", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Entra", + "sourceFieldKeys": [ + "Service", + ], + "targetRole": "additional", + }, + { + "decisionSource": "user-confirmed", + "normalizedValue": "1", + "sourceFieldKeys": [ + "Index", + ], + "targetRole": "score", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic policy", + "sourceFieldKeys": [ + "TestTitle", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "false", + "sourceFieldKeys": [ + "TestInvestigate", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "High", + "sourceFieldKeys": [ + "Severity", + ], + "targetRole": "severity", + }, + ], + "originalFields": [ + { + "columnIndex": undefined, + "key": "Id", + "label": "ID", + "path": "$.Tests[0].Id", + "rawValue": "MT.9001", + "value": "MT.9001", + }, + { + "columnIndex": undefined, + "key": "Title", + "label": "Title", + "path": "$.Tests[0].Title", + "rawValue": "Ensure the synthetic policy is configured", + "value": "Ensure the synthetic policy is configured", + }, + { + "columnIndex": undefined, + "key": "Result", + "label": "Result", + "path": "$.Tests[0].Result", + "rawValue": "Failed", + "value": "Failed", + }, + { + "columnIndex": undefined, + "key": "Severity", + "label": "Severity", + "path": "$.Tests[0].Severity", + "rawValue": "High", + "value": "High", + }, + { + "columnIndex": undefined, + "key": "Tag", + "label": "Tag", + "path": "$.Tests[0].Tag", + "rawValue": "["Synthetic","Entra"]", + "value": [ + "Synthetic", + "Entra", + ], + }, + { + "columnIndex": undefined, + "key": "Block", + "label": "Block", + "path": "$.Tests[0].Block", + "rawValue": "Maester Security Tests", + "value": "Maester Security Tests", + }, + { + "columnIndex": undefined, + "key": "Duration", + "label": "Duration", + "path": "$.Tests[0].Duration", + "rawValue": "00:00:01", + "value": "00:00:01", + }, + { + "columnIndex": undefined, + "key": "TestDescription", + "label": "Description", + "path": "$.Tests[0].ResultDetail.TestDescription", + "rawValue": "Checks a synthetic policy without tenant data.", + "value": "Checks a synthetic policy without tenant data.", + }, + { + "columnIndex": undefined, + "key": "TestResult", + "label": "ResultDetail", + "path": "$.Tests[0].ResultDetail.TestResult", + "rawValue": "The synthetic policy needs review.", + "value": "The synthetic policy needs review.", + }, + { + "columnIndex": undefined, + "key": "TestSkipped", + "label": "TestSkipped", + "path": "$.Tests[0].ResultDetail.TestSkipped", + "rawValue": "false", + "value": false, + }, + { + "columnIndex": undefined, + "key": "SkippedReason", + "label": "SkippedReason", + "path": "$.Tests[0].ResultDetail.SkippedReason", + "rawValue": "", + "value": "", + }, + { + "columnIndex": undefined, + "key": "ErrorRecord", + "label": "ErrorMessage", + "path": "$.Tests[0].ErrorRecord", + "rawValue": "", + "value": null, + }, + { + "columnIndex": undefined, + "key": "Name", + "label": "Name", + "path": "$.Tests[0].Name", + "rawValue": "MT.9001: Ensure the synthetic policy is configured", + "value": "MT.9001: Ensure the synthetic policy is configured", + }, + { + "columnIndex": undefined, + "key": "HelpUrl", + "label": "HelpUrl", + "path": "$.Tests[0].HelpUrl", + "rawValue": "https://maester.dev/docs/tests/MT.9001", + "value": "https://maester.dev/docs/tests/MT.9001", + }, + { + "columnIndex": undefined, + "key": "ScriptBlockFile", + "label": "TestScriptFile", + "path": "$.Tests[0].ScriptBlockFile", + "rawValue": "MT.9001.Tests.ps1", + "value": "MT.9001.Tests.ps1", + }, + { + "columnIndex": undefined, + "key": "ScriptBlock", + "label": "TestScript", + "path": "$.Tests[0].ScriptBlock", + "rawValue": "Describe 'synthetic test' { It 'is deterministic' { $true | Should -BeTrue } }", + "value": "Describe 'synthetic test' { It 'is deterministic' { $true | Should -BeTrue } }", + }, + { + "columnIndex": undefined, + "key": "Service", + "label": "Service", + "path": "$.Tests[0].ResultDetail.Service", + "rawValue": "Entra", + "value": "Entra", + }, + { + "columnIndex": undefined, + "key": "Index", + "label": "Index", + "path": "$.Tests[0].Index", + "rawValue": "1", + "value": 1, + }, + { + "columnIndex": undefined, + "key": "TestTitle", + "label": "ResultDetail.TestTitle", + "path": "$.Tests[0].ResultDetail.TestTitle", + "rawValue": "Synthetic policy", + "value": "Synthetic policy", + }, + { + "columnIndex": undefined, + "key": "TestInvestigate", + "label": "ResultDetail.TestInvestigate", + "path": "$.Tests[0].ResultDetail.TestInvestigate", + "rawValue": "false", + "value": false, + }, + { + "columnIndex": undefined, + "key": "Severity", + "label": "ResultDetail.Severity", + "path": "$.Tests[0].ResultDetail.Severity", + "rawValue": "High", + "value": "High", + }, + ], + "outcome": { + "normalizedValue": "fail", + "originalValue": "Failed", + }, + "score": { + "normalizedValue": 1, + "originalValue": 1, + }, + "severity": { + "normalizedValue": "high", + "originalValue": "High", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.9001", + "originalRuleId": "MT.9001", + "originalTitle": "Ensure the synthetic policy is configured", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[0]", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "failed", + "originalValue": "Failed", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Failed", + }, + }, + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.9002", + "sourceFieldKeys": [ + "Id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Ensure the synthetic control is enabled", + "sourceFieldKeys": [ + "Title", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Passed", + "sourceFieldKeys": [ + "Result", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Medium", + "sourceFieldKeys": [ + "Severity", + ], + "targetRole": "severity", + }, + { + "decisionSource": "automatic", + "normalizedValue": "["Synthetic"]", + "sourceFieldKeys": [ + "Tag", + ], + "targetRole": "category", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Maester Security Tests", + "sourceFieldKeys": [ + "Block", + ], + "targetRole": "category", + }, + { + "decisionSource": "automatic", + "normalizedValue": "00:00:01", + "sourceFieldKeys": [ + "Duration", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Checks a second synthetic control.", + "sourceFieldKeys": [ + "TestDescription", + ], + "targetRole": "description", + }, + { + "decisionSource": "automatic", + "normalizedValue": "The synthetic control is enabled.", + "sourceFieldKeys": [ + "TestResult", + ], + "targetRole": "evidence", + }, + { + "decisionSource": "automatic", + "normalizedValue": "false", + "sourceFieldKeys": [ + "TestSkipped", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "MT.9002: Ensure the synthetic control is enabled", + "sourceFieldKeys": [ + "Name", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "https://maester.dev/docs/tests/MT.9002", + "sourceFieldKeys": [ + "HelpUrl", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "automatic", + "normalizedValue": "MT.9002.Tests.ps1", + "sourceFieldKeys": [ + "ScriptBlockFile", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Entra", + "sourceFieldKeys": [ + "Service", + ], + "targetRole": "additional", + }, + { + "decisionSource": "user-confirmed", + "normalizedValue": "2", + "sourceFieldKeys": [ + "Index", + ], + "targetRole": "score", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Medium", + "sourceFieldKeys": [ + "Severity", + ], + "targetRole": "severity", + }, + ], + "originalFields": [ + { + "columnIndex": undefined, + "key": "Id", + "label": "ID", + "path": "$.Tests[1].Id", + "rawValue": "MT.9002", + "value": "MT.9002", + }, + { + "columnIndex": undefined, + "key": "Title", + "label": "Title", + "path": "$.Tests[1].Title", + "rawValue": "Ensure the synthetic control is enabled", + "value": "Ensure the synthetic control is enabled", + }, + { + "columnIndex": undefined, + "key": "Result", + "label": "Result", + "path": "$.Tests[1].Result", + "rawValue": "Passed", + "value": "Passed", + }, + { + "columnIndex": undefined, + "key": "Severity", + "label": "Severity", + "path": "$.Tests[1].Severity", + "rawValue": "Medium", + "value": "Medium", + }, + { + "columnIndex": undefined, + "key": "Tag", + "label": "Tag", + "path": "$.Tests[1].Tag", + "rawValue": "["Synthetic"]", + "value": [ + "Synthetic", + ], + }, + { + "columnIndex": undefined, + "key": "Block", + "label": "Block", + "path": "$.Tests[1].Block", + "rawValue": "Maester Security Tests", + "value": "Maester Security Tests", + }, + { + "columnIndex": undefined, + "key": "Duration", + "label": "Duration", + "path": "$.Tests[1].Duration", + "rawValue": "00:00:01", + "value": "00:00:01", + }, + { + "columnIndex": undefined, + "key": "TestDescription", + "label": "Description", + "path": "$.Tests[1].ResultDetail.TestDescription", + "rawValue": "Checks a second synthetic control.", + "value": "Checks a second synthetic control.", + }, + { + "columnIndex": undefined, + "key": "TestResult", + "label": "ResultDetail", + "path": "$.Tests[1].ResultDetail.TestResult", + "rawValue": "The synthetic control is enabled.", + "value": "The synthetic control is enabled.", + }, + { + "columnIndex": undefined, + "key": "TestSkipped", + "label": "TestSkipped", + "path": "$.Tests[1].ResultDetail.TestSkipped", + "rawValue": "false", + "value": false, + }, + { + "columnIndex": undefined, + "key": "Name", + "label": "Name", + "path": "$.Tests[1].Name", + "rawValue": "MT.9002: Ensure the synthetic control is enabled", + "value": "MT.9002: Ensure the synthetic control is enabled", + }, + { + "columnIndex": undefined, + "key": "HelpUrl", + "label": "HelpUrl", + "path": "$.Tests[1].HelpUrl", + "rawValue": "https://maester.dev/docs/tests/MT.9002", + "value": "https://maester.dev/docs/tests/MT.9002", + }, + { + "columnIndex": undefined, + "key": "ScriptBlockFile", + "label": "TestScriptFile", + "path": "$.Tests[1].ScriptBlockFile", + "rawValue": "MT.9002.Tests.ps1", + "value": "MT.9002.Tests.ps1", + }, + { + "columnIndex": undefined, + "key": "Service", + "label": "Service", + "path": "$.Tests[1].ResultDetail.Service", + "rawValue": "Entra", + "value": "Entra", + }, + { + "columnIndex": undefined, + "key": "Index", + "label": "Index", + "path": "$.Tests[1].Index", + "rawValue": "2", + "value": 2, + }, + { + "columnIndex": undefined, + "key": "Severity", + "label": "ResultDetail.Severity", + "path": "$.Tests[1].ResultDetail.Severity", + "rawValue": "Medium", + "value": "Medium", + }, + ], + "outcome": { + "normalizedValue": "pass", + "originalValue": "Passed", + }, + "score": { + "normalizedValue": 2, + "originalValue": 2, + }, + "severity": { + "normalizedValue": "medium", + "originalValue": "Medium", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.9002", + "originalRuleId": "MT.9002", + "originalTitle": "Ensure the synthetic control is enabled", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[1]", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "passed", + "originalValue": "Passed", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Passed", + }, + }, + ], + "observations": [ + { + "attributes": { + "declared": false, + "sourceField": "Result", + }, + "label": "Reported overall result", + "normalizedValue": "Failed", + "observedAt": undefined, + "originalValue": "Failed", + "property": "maester.result", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Result", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "$", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "enum", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "FailedCount", + }, + "label": "Reported Failed count", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.declared_failed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.FailedCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "$", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "PassedCount", + }, + "label": "Reported Passed count", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.declared_passed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.PassedCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "$", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "TotalCount", + }, + "label": "Reported test count", + "normalizedValue": 2, + "observedAt": undefined, + "originalValue": 2, + "property": "maester.tests.declared_total", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.TotalCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "$", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "Blocks", + }, + "label": "Blocks", + "normalizedValue": [], + "observedAt": undefined, + "originalValue": [], + "property": "maester.source.Blocks", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Blocks", + "rowIndex": undefined, + }, + "state": "empty", + "subject": { + "identity": "$", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "object", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "EndOfJson", + }, + "label": "End Of Json", + "normalizedValue": true, + "observedAt": undefined, + "originalValue": true, + "property": "maester.source.EndOfJson", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.EndOfJson", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "$", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "boolean", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "", + }, + "label": "Counted total tests", + "normalizedValue": 2, + "observedAt": undefined, + "originalValue": 2, + "property": "maester.tests.actual_total", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "$", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Failed", + }, + "label": "Counted failed tests", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.actual_failed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "$", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Passed", + }, + "label": "Counted passed tests", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.actual_passed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "$", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Error", + }, + "label": "Counted error tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_error", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "$", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Investigate", + }, + "label": "Counted investigate tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_investigate", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "$", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Skipped", + }, + "label": "Counted skipped tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_skipped", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "$", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "NotRun", + }, + "label": "Counted notrun tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_notrun", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "$", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + ], + "preview": { + "mappingsToConfirm": 0, + "recordCount": 2, + "selected": true, + }, + "recognition": "maester", + "reportSections": [ + { + "attributes": { + "kind": "maester-report-context", + }, + "detailLinks": undefined, + "label": "Maester report context", + "modalDetails": undefined, + "ordinal": 0, + "parentSectionKey": undefined, + "prose": undefined, + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$", + "rowIndex": undefined, + }, + "state": "observed", + "tables": [ + { + "headers": [ + "Property", + "Original value", + ], + "name": "Report metadata", + "rows": [ + [ + "Result", + "Failed", + ], + [ + "FailedCount", + 1, + ], + [ + "PassedCount", + 1, + ], + [ + "TotalCount", + 2, + ], + [ + "Blocks", + [], + ], + [ + "EndOfJson", + true, + ], + ], + }, + ], + "tool": { + "id": "maester", + "name": "Maester", + }, + "version": 1, + }, + ], + "scores": [ + { + "assessed": 2, + "assessmentId": "assessment-protected-dummy-import", + "comparisonKey": undefined, + "contentSha256": "6ac9213fc3a98c05795408f465b8a343df6e5264869c2ea75ad1bede96c74744", + "deviations": 1, + "eligible": 2, + "evaluationScopeKey": "["[\\"MT.9001\\",\\"evaluated\\"]","[\\"MT.9002\\",\\"evaluated\\"]"]", + "importFileIds": [ + "file-protected-dummy-import-0", + ], + "issues": [ + "Scan identity or time not verified", + "Scanner version unavailable", + ], + "method": "pass-rate", + "nativeProperty": undefined, + "nativeValue": undefined, + "observedAt": undefined, + "occurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + "occurrence-protected-dummy-import-0-1", + ], + "openOccurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + ], + "passed": 1, + "policy": { + "defaultWeight": 1, + "version": "1.0.0", + "weights": { + "cis-cat": 1, + "group-policy": 1, + "maester": 1, + "pingcastle": 1, + "purple-knight": 1, + }, + }, + "processingKey": "[null,"maester","1.2.0","1.1.0","1.0.0","1.0.0"]", + "resultSignature": "["[\\"MT.9001\\",null,\\"fail\\"]","[\\"MT.9002\\",null,\\"pass\\"]"]", + "runIds": [ + "run-protected-dummy-import", + ], + "score": 50, + "toolId": "maester", + "toolName": "Maester", + }, + ], + "tables": [ + { + "columnCount": 0, + "hidden": undefined, + "index": 0, + "name": "HTML", + "rowCount": 0, + "rows": [], + }, + ], +} +`; + +exports[`protected scanner import contracts (synthetic data only) > 'maester-let' retains recognition, preview and approved canonical results in browser and backend 1`] = ` +{ + "approved": { + "recordCount": 2, + "selected": true, + }, + "definitions": [ + { + "canonicalKey": "maester:mt.99001", + "category": undefined, + "description": undefined, + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Dummy failed check", + }, + { + "canonicalKey": "maester:mt.99002", + "category": undefined, + "description": undefined, + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Dummy passed check", + }, + ], + "evidence": [ + { + "finding": { + "canonicalRuleId": "MT.99001", + "originalRuleId": "MT.99001", + "originalTitle": "Dummy failed check", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "kind": "text", + "sourceFieldKeys": [ + "TestResult", + ], + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[0].ResultDetail.TestResult", + "rowIndex": undefined, + }, + "value": "Synthetic evidence only", + }, + ], + "findings": [ + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.99001", + "sourceFieldKeys": [ + "Id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Dummy failed check", + "sourceFieldKeys": [ + "Title", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Failed", + "sourceFieldKeys": [ + "Result", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "High", + "sourceFieldKeys": [ + "Severity", + ], + "targetRole": "severity", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic evidence only", + "sourceFieldKeys": [ + "TestResult", + ], + "targetRole": "evidence", + }, + ], + "originalFields": [ + { + "columnIndex": undefined, + "key": "Id", + "label": "ID", + "path": "$.Tests[0].Id", + "rawValue": "MT.99001", + "value": "MT.99001", + }, + { + "columnIndex": undefined, + "key": "Title", + "label": "Title", + "path": "$.Tests[0].Title", + "rawValue": "Dummy failed check", + "value": "Dummy failed check", + }, + { + "columnIndex": undefined, + "key": "Result", + "label": "Result", + "path": "$.Tests[0].Result", + "rawValue": "Failed", + "value": "Failed", + }, + { + "columnIndex": undefined, + "key": "Severity", + "label": "Severity", + "path": "$.Tests[0].Severity", + "rawValue": "High", + "value": "High", + }, + { + "columnIndex": undefined, + "key": "TestResult", + "label": "ResultDetail", + "path": "$.Tests[0].ResultDetail.TestResult", + "rawValue": "Synthetic evidence only", + "value": "Synthetic evidence only", + }, + ], + "outcome": { + "normalizedValue": "fail", + "originalValue": "Failed", + }, + "score": undefined, + "severity": { + "normalizedValue": "high", + "originalValue": "High", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.99001", + "originalRuleId": "MT.99001", + "originalTitle": "Dummy failed check", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[0]", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "failed", + "originalValue": "Failed", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Failed", + }, + }, + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.99002", + "sourceFieldKeys": [ + "Id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Dummy passed check", + "sourceFieldKeys": [ + "Title", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Passed", + "sourceFieldKeys": [ + "Result", + ], + "targetRole": "status", + }, + ], + "originalFields": [ + { + "columnIndex": undefined, + "key": "Id", + "label": "ID", + "path": "$.Tests[1].Id", + "rawValue": "MT.99002", + "value": "MT.99002", + }, + { + "columnIndex": undefined, + "key": "Title", + "label": "Title", + "path": "$.Tests[1].Title", + "rawValue": "Dummy passed check", + "value": "Dummy passed check", + }, + { + "columnIndex": undefined, + "key": "Result", + "label": "Result", + "path": "$.Tests[1].Result", + "rawValue": "Passed", + "value": "Passed", + }, + ], + "outcome": { + "normalizedValue": "pass", + "originalValue": "Passed", + }, + "score": undefined, + "severity": { + "normalizedValue": "unknown", + "originalValue": "", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.99002", + "originalRuleId": "MT.99002", + "originalTitle": "Dummy passed check", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[1]", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "passed", + "originalValue": "Passed", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Passed", + }, + }, + ], + "observations": [ + { + "attributes": { + "declared": false, + "sourceField": "Result", + }, + "label": "Reported overall result", + "normalizedValue": "Failed", + "observedAt": undefined, + "originalValue": "Failed", + "property": "maester.result", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Result", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "enum", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "TenantId", + }, + "label": "Entra tenant ID", + "normalizedValue": "dummy-tenant", + "observedAt": undefined, + "originalValue": "dummy-tenant", + "property": "entra.tenant_id", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.TenantId", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "TenantName", + }, + "label": "Entra tenant name", + "normalizedValue": "Dummy", + "observedAt": undefined, + "originalValue": "Dummy", + "property": "entra.tenant_name", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.TenantName", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "TotalCount", + }, + "label": "Reported test count", + "normalizedValue": 2, + "observedAt": undefined, + "originalValue": 2, + "property": "maester.tests.declared_total", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.TotalCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "FailedCount", + }, + "label": "Reported Failed count", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.declared_failed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.FailedCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "PassedCount", + }, + "label": "Reported Passed count", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.declared_passed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.PassedCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "EndOfJson", + }, + "label": "End Of Json", + "normalizedValue": "EndOfJson", + "observedAt": undefined, + "originalValue": "EndOfJson", + "property": "maester.source.EndOfJson", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.EndOfJson", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "", + }, + "label": "Counted total tests", + "normalizedValue": 2, + "observedAt": undefined, + "originalValue": 2, + "property": "maester.tests.actual_total", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Failed", + }, + "label": "Counted failed tests", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.actual_failed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Passed", + }, + "label": "Counted passed tests", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.actual_passed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Error", + }, + "label": "Counted error tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_error", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Investigate", + }, + "label": "Counted investigate tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_investigate", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Skipped", + }, + "label": "Counted skipped tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_skipped", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "NotRun", + }, + "label": "Counted notrun tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_notrun", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + ], + "preview": { + "mappingsToConfirm": 0, + "recordCount": 2, + "selected": true, + }, + "recognition": "maester", + "reportSections": [ + { + "attributes": { + "kind": "maester-report-context", + }, + "detailLinks": undefined, + "label": "Maester report context", + "modalDetails": undefined, + "ordinal": 0, + "parentSectionKey": undefined, + "prose": undefined, + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$", + "rowIndex": undefined, + }, + "state": "observed", + "tables": [ + { + "headers": [ + "Property", + "Original value", + ], + "name": "Report metadata", + "rows": [ + [ + "Result", + "Failed", + ], + [ + "TenantId", + "dummy-tenant", + ], + [ + "TenantName", + "Dummy", + ], + [ + "TotalCount", + 2, + ], + [ + "FailedCount", + 1, + ], + [ + "PassedCount", + 1, + ], + [ + "EndOfJson", + "EndOfJson", + ], + ], + }, + ], + "tool": { + "id": "maester", + "name": "Maester", + }, + "version": 1, + }, + ], + "scores": [ + { + "assessed": 2, + "assessmentId": "assessment-protected-dummy-import", + "comparisonKey": "["maester","dummy-tenant","default"]", + "contentSha256": "4e46592635b226f410e8a139f5844e4cde6d1d59bb726984f21666d652104f46", + "deviations": 1, + "eligible": 2, + "evaluationScopeKey": "["[\\"MT.99001\\",\\"evaluated\\"]","[\\"MT.99002\\",\\"evaluated\\"]"]", + "importFileIds": [ + "file-protected-dummy-import-0", + ], + "issues": [ + "Scan identity or time not verified", + "Scanner version unavailable", + ], + "method": "pass-rate", + "nativeProperty": undefined, + "nativeValue": undefined, + "observedAt": undefined, + "occurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + "occurrence-protected-dummy-import-0-1", + ], + "openOccurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + ], + "passed": 1, + "policy": { + "defaultWeight": 1, + "version": "1.0.0", + "weights": { + "cis-cat": 1, + "group-policy": 1, + "maester": 1, + "pingcastle": 1, + "purple-knight": 1, + }, + }, + "processingKey": "[null,"maester","1.2.0","1.1.0","1.0.0","1.0.0"]", + "resultSignature": "["[\\"MT.99001\\",null,\\"fail\\"]","[\\"MT.99002\\",null,\\"pass\\"]"]", + "runIds": [ + "run-protected-dummy-import", + ], + "score": 50, + "toolId": "maester", + "toolName": "Maester", + }, + ], + "tables": [ + { + "columnCount": 1, + "hidden": undefined, + "index": 0, + "name": "HTML", + "rowCount": 1, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Hinweis", + "value": "Synthetic Dummy data only. No customer data.", + }, + ], + "highlighted": false, + "index": 0, + }, + ], + }, + ], +} +`; + +exports[`protected scanner import contracts (synthetic data only) > 'maester-literal-html' retains recognition, preview and approved canonical results in browser and backend 1`] = ` +{ + "approved": { + "recordCount": 6, + "selected": true, + }, + "definitions": [ + { + "canonicalKey": "maester:mt.9901", + "category": "["Synthetic","Identity"]", + "description": "Synthetic description", + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Synthetic failed test", + }, + { + "canonicalKey": "maester:mt.9902", + "category": undefined, + "description": undefined, + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Synthetic investigate test", + }, + { + "canonicalKey": "maester:mt.9903", + "category": undefined, + "description": undefined, + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Synthetic passed test", + }, + { + "canonicalKey": "maester:mt.9904", + "category": undefined, + "description": undefined, + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Synthetic skipped test", + }, + { + "canonicalKey": "maester:mt.9905", + "category": undefined, + "description": undefined, + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Synthetic not-run test", + }, + { + "canonicalKey": "maester:mt.9906", + "category": undefined, + "description": undefined, + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Synthetic error test", + }, + ], + "evidence": [ + { + "finding": { + "canonicalRuleId": "MT.9901", + "originalRuleId": "MT.9901", + "originalTitle": "Synthetic failed test", + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + }, + "kind": "text", + "sourceFieldKeys": [ + "TestResult", + ], + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[0].ResultDetail.TestResult", + "rowIndex": undefined, + }, + "value": "

    Synthetic evidence

    ", + }, + { + "finding": { + "canonicalRuleId": "MT.9901", + "originalRuleId": "MT.9901", + "originalTitle": "Synthetic failed test", + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + }, + "kind": "text", + "sourceFieldKeys": [ + "ScriptBlock", + ], + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[0].ScriptBlock", + "rowIndex": undefined, + }, + "value": "Write-Output 'inert text' +@{ Key = "value" }", + }, + { + "finding": { + "canonicalRuleId": "MT.9902", + "originalRuleId": "MT.9902", + "originalTitle": "Synthetic investigate test", + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + }, + "kind": "text", + "sourceFieldKeys": [ + "TestResult", + ], + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[1].ResultDetail.TestResult", + "rowIndex": undefined, + }, + "value": "Manual review", + }, + { + "finding": { + "canonicalRuleId": "MT.9903", + "originalRuleId": "MT.9903", + "originalTitle": "Synthetic passed test", + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + }, + "kind": "text", + "sourceFieldKeys": [ + "TestResult", + ], + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[2].ResultDetail.TestResult", + "rowIndex": undefined, + }, + "value": "Synthetic success", + }, + { + "finding": { + "canonicalRuleId": "MT.9906", + "originalRuleId": "MT.9906", + "originalTitle": "Synthetic error test", + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + }, + "kind": "object", + "sourceFieldKeys": [ + "ErrorRecord", + ], + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[5].ErrorRecord", + "rowIndex": undefined, + }, + "value": [ + { + "Message": "Synthetic execution error", + }, + ], + }, + ], + "findings": [ + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.9901", + "sourceFieldKeys": [ + "Id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic failed test", + "sourceFieldKeys": [ + "Title", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Failed", + "sourceFieldKeys": [ + "Result", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "High", + "sourceFieldKeys": [ + "Severity", + ], + "targetRole": "severity", + }, + { + "decisionSource": "automatic", + "normalizedValue": "["Synthetic","Identity"]", + "sourceFieldKeys": [ + "Tag", + ], + "targetRole": "category", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic block", + "sourceFieldKeys": [ + "Block", + ], + "targetRole": "category", + }, + { + "decisionSource": "automatic", + "normalizedValue": "00:00:01", + "sourceFieldKeys": [ + "Duration", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic description", + "sourceFieldKeys": [ + "TestDescription", + ], + "targetRole": "description", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic evidence", + "sourceFieldKeys": [ + "TestResult", + ], + "targetRole": "evidence", + }, + { + "decisionSource": "automatic", + "normalizedValue": "false", + "sourceFieldKeys": [ + "TestSkipped", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "https://maester.dev/docs/tests/MT.9901", + "sourceFieldKeys": [ + "HelpUrl", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "user-confirmed", + "normalizedValue": "Write-Output 'inert text' +@{ Key = "value" }", + "sourceFieldKeys": [ + "ScriptBlock", + ], + "targetRole": "evidence", + }, + { + "decisionSource": "user-confirmed", + "normalizedValue": "0", + "sourceFieldKeys": [ + "FutureField", + ], + "targetRole": "score", + }, + { + "decisionSource": "automatic", + "normalizedValue": "High", + "sourceFieldKeys": [ + "Severity", + ], + "targetRole": "severity", + }, + { + "decisionSource": "automatic", + "normalizedValue": "{"retained":true}", + "sourceFieldKeys": [ + "FutureDetail", + ], + "targetRole": "additional", + }, + ], + "originalFields": [ + { + "columnIndex": undefined, + "key": "Id", + "label": "ID", + "path": "$.Tests[0].Id", + "rawValue": "MT.9901", + "value": "MT.9901", + }, + { + "columnIndex": undefined, + "key": "Title", + "label": "Title", + "path": "$.Tests[0].Title", + "rawValue": "Synthetic failed test", + "value": "Synthetic failed test", + }, + { + "columnIndex": undefined, + "key": "Result", + "label": "Result", + "path": "$.Tests[0].Result", + "rawValue": "Failed", + "value": "Failed", + }, + { + "columnIndex": undefined, + "key": "Severity", + "label": "Severity", + "path": "$.Tests[0].Severity", + "rawValue": "High", + "value": "High", + }, + { + "columnIndex": undefined, + "key": "Tag", + "label": "Tag", + "path": "$.Tests[0].Tag", + "rawValue": "["Synthetic","Identity"]", + "value": [ + "Synthetic", + "Identity", + ], + }, + { + "columnIndex": undefined, + "key": "Block", + "label": "Block", + "path": "$.Tests[0].Block", + "rawValue": "Synthetic block", + "value": "Synthetic block", + }, + { + "columnIndex": undefined, + "key": "Duration", + "label": "Duration", + "path": "$.Tests[0].Duration", + "rawValue": "00:00:01", + "value": "00:00:01", + }, + { + "columnIndex": undefined, + "key": "TestDescription", + "label": "Description", + "path": "$.Tests[0].ResultDetail.TestDescription", + "rawValue": "Synthetic description", + "value": "

    Synthetic description

    ", + }, + { + "columnIndex": undefined, + "key": "TestResult", + "label": "ResultDetail", + "path": "$.Tests[0].ResultDetail.TestResult", + "rawValue": "Synthetic evidence", + "value": "

    Synthetic evidence

    ", + }, + { + "columnIndex": undefined, + "key": "TestSkipped", + "label": "TestSkipped", + "path": "$.Tests[0].ResultDetail.TestSkipped", + "rawValue": "false", + "value": false, + }, + { + "columnIndex": undefined, + "key": "HelpUrl", + "label": "HelpUrl", + "path": "$.Tests[0].HelpUrl", + "rawValue": "https://maester.dev/docs/tests/MT.9901", + "value": "https://maester.dev/docs/tests/MT.9901", + }, + { + "columnIndex": undefined, + "key": "ScriptBlock", + "label": "TestScript", + "path": "$.Tests[0].ScriptBlock", + "rawValue": "Write-Output 'inert text' +@{ Key = "value" }", + "value": "Write-Output 'inert text' +@{ Key = "value" }", + }, + { + "columnIndex": undefined, + "key": "FutureField", + "label": "FutureField", + "path": "$.Tests[0].FutureField", + "rawValue": "0", + "value": 0, + }, + { + "columnIndex": undefined, + "key": "Severity", + "label": "ResultDetail.Severity", + "path": "$.Tests[0].ResultDetail.Severity", + "rawValue": "High", + "value": "High", + }, + { + "columnIndex": undefined, + "key": "FutureDetail", + "label": "ResultDetail.FutureDetail", + "path": "$.Tests[0].ResultDetail.FutureDetail", + "rawValue": "{"retained":true}", + "value": { + "retained": true, + }, + }, + ], + "outcome": { + "normalizedValue": "fail", + "originalValue": "Failed", + }, + "score": { + "normalizedValue": 0, + "originalValue": 0, + }, + "severity": { + "normalizedValue": "high", + "originalValue": "High", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.9901", + "originalRuleId": "MT.9901", + "originalTitle": "Synthetic failed test", + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[0]", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "failed", + "originalValue": "Failed", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Failed", + }, + }, + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.9902", + "sourceFieldKeys": [ + "Id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic investigate test", + "sourceFieldKeys": [ + "Title", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Investigate", + "sourceFieldKeys": [ + "Result", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Medium", + "sourceFieldKeys": [ + "Severity", + ], + "targetRole": "severity", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Manual review", + "sourceFieldKeys": [ + "TestResult", + ], + "targetRole": "evidence", + }, + ], + "originalFields": [ + { + "columnIndex": undefined, + "key": "Id", + "label": "ID", + "path": "$.Tests[1].Id", + "rawValue": "MT.9902", + "value": "MT.9902", + }, + { + "columnIndex": undefined, + "key": "Title", + "label": "Title", + "path": "$.Tests[1].Title", + "rawValue": "Synthetic investigate test", + "value": "Synthetic investigate test", + }, + { + "columnIndex": undefined, + "key": "Result", + "label": "Result", + "path": "$.Tests[1].Result", + "rawValue": "Investigate", + "value": "Investigate", + }, + { + "columnIndex": undefined, + "key": "Severity", + "label": "Severity", + "path": "$.Tests[1].Severity", + "rawValue": "Medium", + "value": "Medium", + }, + { + "columnIndex": undefined, + "key": "TestResult", + "label": "ResultDetail", + "path": "$.Tests[1].ResultDetail.TestResult", + "rawValue": "Manual review", + "value": "Manual review", + }, + ], + "outcome": { + "normalizedValue": "warning", + "originalValue": "Investigate", + }, + "score": undefined, + "severity": { + "normalizedValue": "medium", + "originalValue": "Medium", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.9902", + "originalRuleId": "MT.9902", + "originalTitle": "Synthetic investigate test", + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[1]", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "investigate", + "originalValue": "Investigate", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Investigate", + }, + }, + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.9903", + "sourceFieldKeys": [ + "Id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic passed test", + "sourceFieldKeys": [ + "Title", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Passed", + "sourceFieldKeys": [ + "Result", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Low", + "sourceFieldKeys": [ + "Severity", + ], + "targetRole": "severity", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic success", + "sourceFieldKeys": [ + "TestResult", + ], + "targetRole": "evidence", + }, + ], + "originalFields": [ + { + "columnIndex": undefined, + "key": "Id", + "label": "ID", + "path": "$.Tests[2].Id", + "rawValue": "MT.9903", + "value": "MT.9903", + }, + { + "columnIndex": undefined, + "key": "Title", + "label": "Title", + "path": "$.Tests[2].Title", + "rawValue": "Synthetic passed test", + "value": "Synthetic passed test", + }, + { + "columnIndex": undefined, + "key": "Result", + "label": "Result", + "path": "$.Tests[2].Result", + "rawValue": "Passed", + "value": "Passed", + }, + { + "columnIndex": undefined, + "key": "Severity", + "label": "Severity", + "path": "$.Tests[2].Severity", + "rawValue": "Low", + "value": "Low", + }, + { + "columnIndex": undefined, + "key": "TestResult", + "label": "ResultDetail", + "path": "$.Tests[2].ResultDetail.TestResult", + "rawValue": "Synthetic success", + "value": "Synthetic success", + }, + ], + "outcome": { + "normalizedValue": "pass", + "originalValue": "Passed", + }, + "score": undefined, + "severity": { + "normalizedValue": "low", + "originalValue": "Low", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.9903", + "originalRuleId": "MT.9903", + "originalTitle": "Synthetic passed test", + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[2]", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "passed", + "originalValue": "Passed", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Passed", + }, + }, + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.9904", + "sourceFieldKeys": [ + "Id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic skipped test", + "sourceFieldKeys": [ + "Title", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Skipped", + "sourceFieldKeys": [ + "Result", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Info", + "sourceFieldKeys": [ + "Severity", + ], + "targetRole": "severity", + }, + { + "decisionSource": "automatic", + "normalizedValue": "true", + "sourceFieldKeys": [ + "TestSkipped", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic missing permission", + "sourceFieldKeys": [ + "SkippedReason", + ], + "targetRole": "additional", + }, + ], + "originalFields": [ + { + "columnIndex": undefined, + "key": "Id", + "label": "ID", + "path": "$.Tests[3].Id", + "rawValue": "MT.9904", + "value": "MT.9904", + }, + { + "columnIndex": undefined, + "key": "Title", + "label": "Title", + "path": "$.Tests[3].Title", + "rawValue": "Synthetic skipped test", + "value": "Synthetic skipped test", + }, + { + "columnIndex": undefined, + "key": "Result", + "label": "Result", + "path": "$.Tests[3].Result", + "rawValue": "Skipped", + "value": "Skipped", + }, + { + "columnIndex": undefined, + "key": "Severity", + "label": "Severity", + "path": "$.Tests[3].Severity", + "rawValue": "Info", + "value": "Info", + }, + { + "columnIndex": undefined, + "key": "TestSkipped", + "label": "TestSkipped", + "path": "$.Tests[3].ResultDetail.TestSkipped", + "rawValue": "true", + "value": true, + }, + { + "columnIndex": undefined, + "key": "SkippedReason", + "label": "SkippedReason", + "path": "$.Tests[3].ResultDetail.SkippedReason", + "rawValue": "Synthetic missing permission", + "value": "Synthetic missing permission", + }, + ], + "outcome": { + "normalizedValue": "not_run", + "originalValue": "Skipped", + }, + "score": undefined, + "severity": { + "normalizedValue": "info", + "originalValue": "Info", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.9904", + "originalRuleId": "MT.9904", + "originalTitle": "Synthetic skipped test", + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[3]", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "not-run", + "originalValue": "Skipped", + }, + "technicalExecutionState": { + "normalizedValue": "not-run", + "originalValue": "Skipped", + }, + }, + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.9905", + "sourceFieldKeys": [ + "Id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic not-run test", + "sourceFieldKeys": [ + "Title", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "NotRun", + "sourceFieldKeys": [ + "Result", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic disabled test", + "sourceFieldKeys": [ + "SkippedReason", + ], + "targetRole": "additional", + }, + ], + "originalFields": [ + { + "columnIndex": undefined, + "key": "Id", + "label": "ID", + "path": "$.Tests[4].Id", + "rawValue": "MT.9905", + "value": "MT.9905", + }, + { + "columnIndex": undefined, + "key": "Title", + "label": "Title", + "path": "$.Tests[4].Title", + "rawValue": "Synthetic not-run test", + "value": "Synthetic not-run test", + }, + { + "columnIndex": undefined, + "key": "Result", + "label": "Result", + "path": "$.Tests[4].Result", + "rawValue": "NotRun", + "value": "NotRun", + }, + { + "columnIndex": undefined, + "key": "SkippedReason", + "label": "SkippedReason", + "path": "$.Tests[4].ResultDetail.SkippedReason", + "rawValue": "Synthetic disabled test", + "value": "Synthetic disabled test", + }, + ], + "outcome": { + "normalizedValue": "not_run", + "originalValue": "NotRun", + }, + "score": undefined, + "severity": { + "normalizedValue": "unknown", + "originalValue": "", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.9905", + "originalRuleId": "MT.9905", + "originalTitle": "Synthetic not-run test", + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[4]", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "not-run", + "originalValue": "NotRun", + }, + "technicalExecutionState": { + "normalizedValue": "not-run", + "originalValue": "NotRun", + }, + }, + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.9906", + "sourceFieldKeys": [ + "Id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic error test", + "sourceFieldKeys": [ + "Title", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Error", + "sourceFieldKeys": [ + "Result", + ], + "targetRole": "status", + }, + { + "decisionSource": "user-confirmed", + "normalizedValue": "[{"Message":"Synthetic execution error"}]", + "sourceFieldKeys": [ + "ErrorRecord", + ], + "targetRole": "evidence", + }, + ], + "originalFields": [ + { + "columnIndex": undefined, + "key": "Id", + "label": "ID", + "path": "$.Tests[5].Id", + "rawValue": "MT.9906", + "value": "MT.9906", + }, + { + "columnIndex": undefined, + "key": "Title", + "label": "Title", + "path": "$.Tests[5].Title", + "rawValue": "Synthetic error test", + "value": "Synthetic error test", + }, + { + "columnIndex": undefined, + "key": "Result", + "label": "Result", + "path": "$.Tests[5].Result", + "rawValue": "Error", + "value": "Error", + }, + { + "columnIndex": undefined, + "key": "ErrorRecord", + "label": "ErrorMessage", + "path": "$.Tests[5].ErrorRecord", + "rawValue": "[{"Message":"Synthetic execution error"}]", + "value": [ + { + "Message": "Synthetic execution error", + }, + ], + }, + ], + "outcome": { + "normalizedValue": "execution_error", + "originalValue": "Error", + }, + "score": undefined, + "severity": { + "normalizedValue": "unknown", + "originalValue": "", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.9906", + "originalRuleId": "MT.9906", + "originalTitle": "Synthetic error test", + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[5]", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "error", + "originalValue": "Error", + }, + "technicalExecutionState": { + "normalizedValue": "failed", + "originalValue": "Error", + }, + }, + ], + "observations": [ + { + "attributes": { + "declared": false, + "sourceField": "Result", + }, + "label": "Reported overall result", + "normalizedValue": "Failed", + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": "Failed", + "property": "maester.result", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Result", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "enum", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "TotalCount", + }, + "label": "Reported test count", + "normalizedValue": 5, + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": 5, + "property": "maester.tests.declared_total", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.TotalCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "FailedCount", + }, + "label": "Reported Failed count", + "normalizedValue": 0, + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": 0, + "property": "maester.tests.declared_failed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.FailedCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "PassedCount", + }, + "label": "Reported Passed count", + "normalizedValue": 1, + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": 1, + "property": "maester.tests.declared_passed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.PassedCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "InvestigateCount", + }, + "label": "Reported Investigate count", + "normalizedValue": 1, + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": 1, + "property": "maester.tests.declared_investigate", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.InvestigateCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "ErrorCount", + }, + "label": "Reported Error count", + "normalizedValue": 1, + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": 1, + "property": "maester.tests.declared_error", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.ErrorCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "SkippedCount", + }, + "label": "Reported Skipped count", + "normalizedValue": 1, + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": 1, + "property": "maester.tests.declared_skipped", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.SkippedCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "NotRunCount", + }, + "label": "Reported NotRun count", + "normalizedValue": 1, + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": 1, + "property": "maester.tests.declared_notrun", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.NotRunCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "TenantId", + }, + "label": "Entra tenant ID", + "normalizedValue": "synthetic-tenant-id", + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": "synthetic-tenant-id", + "property": "entra.tenant_id", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.TenantId", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "TenantName", + }, + "label": "Entra tenant name", + "normalizedValue": "Synthetic tenant", + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": "Synthetic tenant", + "property": "entra.tenant_name", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.TenantName", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "ExecutedAt", + }, + "label": "Scan time", + "normalizedValue": "2026-09-09T08:00:00Z", + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": "2026-09-09T08:00:00Z", + "property": "report.generated_at", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.ExecutedAt", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "datetime", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "CurrentVersion", + }, + "label": "Maester version", + "normalizedValue": "1.0.0", + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": "1.0.0", + "property": "report.engine_version", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.CurrentVersion", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "TotalDuration", + }, + "label": "Scan duration", + "normalizedValue": "00:00:10", + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": "00:00:10", + "property": "maester.duration", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.TotalDuration", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "duration", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "SystemInfo", + }, + "label": "System Info", + "normalizedValue": { + "Available": true, + "Count": 0, + "Nullable": null, + "OS": "Synthetic OS", + }, + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": { + "Available": true, + "Count": 0, + "Nullable": null, + "OS": "Synthetic OS", + }, + "property": "maester.source.SystemInfo", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.SystemInfo", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "object", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "PowerShellInfo", + }, + "label": "Power Shell Info", + "normalizedValue": { + "Version": "7.4", + }, + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": { + "Version": "7.4", + }, + "property": "maester.source.PowerShellInfo", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.PowerShellInfo", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "object", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "LoadedModules", + }, + "label": "Loaded Modules", + "normalizedValue": [], + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": [], + "property": "maester.source.LoadedModules", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.LoadedModules", + "rowIndex": undefined, + }, + "state": "empty", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "object", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "FutureMetadata", + }, + "label": "Future Metadata", + "normalizedValue": { + "nested": [ + "retained", + ], + }, + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": { + "nested": [ + "retained", + ], + }, + "property": "maester.source.FutureMetadata", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.FutureMetadata", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "object", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "Blocks", + }, + "label": "Blocks", + "normalizedValue": [ + { + "Name": "Synthetic block", + }, + ], + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": [ + { + "Name": "Synthetic block", + }, + ], + "property": "maester.source.Blocks", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Blocks", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "object", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "EndOfJson", + }, + "label": "End Of Json", + "normalizedValue": "EndOfJson", + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": "EndOfJson", + "property": "maester.source.EndOfJson", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.EndOfJson", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "scope": "scanner-runtime", + "sourceField": "PowerShellInfo.Version", + }, + "label": "PowerShell version", + "normalizedValue": "7.4", + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": "7.4", + "property": "maester.powershell.version", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.PowerShellInfo.Version", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "", + }, + "label": "Counted total tests", + "normalizedValue": 6, + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": 6, + "property": "maester.tests.actual_total", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Failed", + }, + "label": "Counted failed tests", + "normalizedValue": 1, + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": 1, + "property": "maester.tests.actual_failed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Passed", + }, + "label": "Counted passed tests", + "normalizedValue": 1, + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": 1, + "property": "maester.tests.actual_passed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Error", + }, + "label": "Counted error tests", + "normalizedValue": 1, + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": 1, + "property": "maester.tests.actual_error", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Investigate", + }, + "label": "Counted investigate tests", + "normalizedValue": 1, + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": 1, + "property": "maester.tests.actual_investigate", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Skipped", + }, + "label": "Counted skipped tests", + "normalizedValue": 1, + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": 1, + "property": "maester.tests.actual_skipped", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "NotRun", + }, + "label": "Counted notrun tests", + "normalizedValue": 1, + "observedAt": "2026-09-09T08:00:00.000Z", + "originalValue": 1, + "property": "maester.tests.actual_notrun", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "synthetic-tenant-id", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "valueType": "number", + "version": 1, + }, + ], + "preview": { + "mappingsToConfirm": 0, + "recordCount": 6, + "selected": true, + }, + "recognition": "maester", + "reportSections": [ + { + "attributes": { + "kind": "maester-report-context", + }, + "detailLinks": undefined, + "label": "Maester report context", + "modalDetails": undefined, + "ordinal": 0, + "parentSectionKey": undefined, + "prose": undefined, + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$", + "rowIndex": undefined, + }, + "state": "observed", + "tables": [ + { + "headers": [ + "Property", + "Original value", + ], + "name": "Report metadata", + "rows": [ + [ + "Result", + "Failed", + ], + [ + "TotalCount", + 5, + ], + [ + "FailedCount", + 0, + ], + [ + "PassedCount", + 1, + ], + [ + "InvestigateCount", + 1, + ], + [ + "ErrorCount", + 1, + ], + [ + "SkippedCount", + 1, + ], + [ + "NotRunCount", + 1, + ], + [ + "TenantId", + "synthetic-tenant-id", + ], + [ + "TenantName", + "Synthetic tenant", + ], + [ + "ExecutedAt", + "2026-09-09T08:00:00Z", + ], + [ + "CurrentVersion", + "1.0.0", + ], + [ + "TotalDuration", + "00:00:10", + ], + [ + "SystemInfo", + { + "Available": true, + "Count": 0, + "Nullable": null, + "OS": "Synthetic OS", + }, + ], + [ + "PowerShellInfo", + { + "Version": "7.4", + }, + ], + [ + "LoadedModules", + [], + ], + [ + "FutureMetadata", + { + "nested": [ + "retained", + ], + }, + ], + [ + "Blocks", + [ + { + "Name": "Synthetic block", + }, + ], + ], + [ + "EndOfJson", + "EndOfJson", + ], + ], + }, + ], + "tool": { + "id": "maester", + "name": "Maester", + "version": "1.0.0", + }, + "version": 1, + }, + ], + "scores": [ + { + "assessed": 3, + "assessmentId": "assessment-protected-dummy-import", + "comparisonKey": "["maester","synthetic-tenant-id","default"]", + "contentSha256": "e501f53e7344cb114ed47a882f3d0f5e794d9990ba8320571a9b0d809a5b7738", + "deviations": 2, + "eligible": 6, + "evaluationScopeKey": "["[\\"MT.9901\\",\\"evaluated\\"]","[\\"MT.9902\\",\\"evaluated\\"]","[\\"MT.9903\\",\\"evaluated\\"]","[\\"MT.9904\\",\\"not_run\\"]","[\\"MT.9905\\",\\"not_run\\"]","[\\"MT.9906\\",\\"execution_error\\"]"]", + "importFileIds": [ + "file-protected-dummy-import-0", + ], + "issues": [ + "Incomplete assessment", + "Source integrity diagnostic", + ], + "method": "pass-rate", + "nativeProperty": undefined, + "nativeValue": undefined, + "observedAt": "2026-09-09T08:00:00Z", + "occurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + "occurrence-protected-dummy-import-0-1", + "occurrence-protected-dummy-import-0-2", + "occurrence-protected-dummy-import-0-3", + "occurrence-protected-dummy-import-0-4", + "occurrence-protected-dummy-import-0-5", + ], + "openOccurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + "occurrence-protected-dummy-import-0-1", + ], + "passed": 1, + "policy": { + "defaultWeight": 1, + "version": "1.0.0", + "weights": { + "cis-cat": 1, + "group-policy": 1, + "maester": 1, + "pingcastle": 1, + "purple-knight": 1, + }, + }, + "processingKey": "["1.0.0","maester","1.2.0","1.1.0","1.0.0","1.0.0"]", + "resultSignature": "["[\\"MT.9901\\",null,\\"fail\\"]","[\\"MT.9902\\",null,\\"warning\\"]","[\\"MT.9903\\",null,\\"pass\\"]","[\\"MT.9904\\",null,\\"not_run\\"]","[\\"MT.9905\\",null,\\"not_run\\"]","[\\"MT.9906\\",null,\\"execution_error\\"]"]", + "runIds": [ + "run-protected-dummy-import", + ], + "score": 33.333333333333336, + "toolId": "maester", + "toolName": "Maester", + }, + ], + "tables": [ + { + "columnCount": 0, + "hidden": undefined, + "index": 0, + "name": "HTML", + "rowCount": 0, + "rows": [], + }, + ], +} +`; + +exports[`protected scanner import contracts (synthetic data only) > 'maester-multi-tenant' retains recognition, preview and approved canonical results in browser and backend 1`] = ` +{ + "approved": { + "recordCount": 2, + "selected": true, + }, + "definitions": [ + { + "canonicalKey": "maester:mt.9101", + "category": undefined, + "description": undefined, + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Synthetic tenant one", + }, + { + "canonicalKey": "maester:mt.9102", + "category": undefined, + "description": undefined, + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Synthetic tenant two", + }, + ], + "evidence": [], + "findings": [ + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.9101", + "sourceFieldKeys": [ + "Id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic tenant one", + "sourceFieldKeys": [ + "Title", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Passed", + "sourceFieldKeys": [ + "Result", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Low", + "sourceFieldKeys": [ + "Severity", + ], + "targetRole": "severity", + }, + ], + "originalFields": [ + { + "columnIndex": undefined, + "key": "Id", + "label": "ID", + "path": "$.Tenants[0].Tests[0].Id", + "rawValue": "MT.9101", + "value": "MT.9101", + }, + { + "columnIndex": undefined, + "key": "Title", + "label": "Title", + "path": "$.Tenants[0].Tests[0].Title", + "rawValue": "Synthetic tenant one", + "value": "Synthetic tenant one", + }, + { + "columnIndex": undefined, + "key": "Result", + "label": "Result", + "path": "$.Tenants[0].Tests[0].Result", + "rawValue": "Passed", + "value": "Passed", + }, + { + "columnIndex": undefined, + "key": "Severity", + "label": "Severity", + "path": "$.Tenants[0].Tests[0].Severity", + "rawValue": "Low", + "value": "Low", + }, + ], + "outcome": { + "normalizedValue": "pass", + "originalValue": "Passed", + }, + "score": undefined, + "severity": { + "normalizedValue": "low", + "originalValue": "Low", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.9101", + "originalRuleId": "MT.9101", + "originalTitle": "Synthetic tenant one", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[0].Tests[0]", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "passed", + "originalValue": "Passed", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Passed", + }, + }, + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.9102", + "sourceFieldKeys": [ + "Id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic tenant two", + "sourceFieldKeys": [ + "Title", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Failed", + "sourceFieldKeys": [ + "Result", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "High", + "sourceFieldKeys": [ + "Severity", + ], + "targetRole": "severity", + }, + ], + "originalFields": [ + { + "columnIndex": undefined, + "key": "Id", + "label": "ID", + "path": "$.Tenants[1].Tests[0].Id", + "rawValue": "MT.9102", + "value": "MT.9102", + }, + { + "columnIndex": undefined, + "key": "Title", + "label": "Title", + "path": "$.Tenants[1].Tests[0].Title", + "rawValue": "Synthetic tenant two", + "value": "Synthetic tenant two", + }, + { + "columnIndex": undefined, + "key": "Result", + "label": "Result", + "path": "$.Tenants[1].Tests[0].Result", + "rawValue": "Failed", + "value": "Failed", + }, + { + "columnIndex": undefined, + "key": "Severity", + "label": "Severity", + "path": "$.Tenants[1].Tests[0].Severity", + "rawValue": "High", + "value": "High", + }, + ], + "outcome": { + "normalizedValue": "fail", + "originalValue": "Failed", + }, + "score": undefined, + "severity": { + "normalizedValue": "high", + "originalValue": "High", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.9102", + "originalRuleId": "MT.9102", + "originalTitle": "Synthetic tenant two", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[1].Tests[0]", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "failed", + "originalValue": "Failed", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Failed", + }, + }, + ], + "observations": [ + { + "attributes": { + "declared": false, + "sourceField": "EndOfJson", + }, + "label": "End Of Json", + "normalizedValue": true, + "observedAt": undefined, + "originalValue": true, + "property": "maester.source.EndOfJson", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.EndOfJson", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "$", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "boolean", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "TenantId", + }, + "label": "Entra tenant ID", + "normalizedValue": "dummy-one", + "observedAt": undefined, + "originalValue": "dummy-one", + "property": "entra.tenant_id", + "sectionKey": "maester-context-1", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[0].TenantId", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-one", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "Result", + }, + "label": "Reported overall result", + "normalizedValue": "Passed", + "observedAt": undefined, + "originalValue": "Passed", + "property": "maester.result", + "sectionKey": "maester-context-1", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[0].Result", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-one", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "enum", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "EndOfJson", + }, + "label": "End Of Json", + "normalizedValue": true, + "observedAt": undefined, + "originalValue": true, + "property": "maester.source.EndOfJson", + "sectionKey": "maester-context-1", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[0].EndOfJson", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-one", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "boolean", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "", + }, + "label": "Counted total tests", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.actual_total", + "sectionKey": "maester-context-1", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[0].Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-one", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Failed", + }, + "label": "Counted failed tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_failed", + "sectionKey": "maester-context-1", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[0].Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-one", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Passed", + }, + "label": "Counted passed tests", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.actual_passed", + "sectionKey": "maester-context-1", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[0].Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-one", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Error", + }, + "label": "Counted error tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_error", + "sectionKey": "maester-context-1", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[0].Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-one", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Investigate", + }, + "label": "Counted investigate tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_investigate", + "sectionKey": "maester-context-1", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[0].Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-one", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Skipped", + }, + "label": "Counted skipped tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_skipped", + "sectionKey": "maester-context-1", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[0].Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-one", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "NotRun", + }, + "label": "Counted notrun tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_notrun", + "sectionKey": "maester-context-1", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[0].Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-one", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "TenantId", + }, + "label": "Entra tenant ID", + "normalizedValue": "dummy-two", + "observedAt": undefined, + "originalValue": "dummy-two", + "property": "entra.tenant_id", + "sectionKey": "maester-context-2", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[1].TenantId", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-two", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "Result", + }, + "label": "Reported overall result", + "normalizedValue": "Failed", + "observedAt": undefined, + "originalValue": "Failed", + "property": "maester.result", + "sectionKey": "maester-context-2", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[1].Result", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-two", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "enum", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "EndOfJson", + }, + "label": "End Of Json", + "normalizedValue": true, + "observedAt": undefined, + "originalValue": true, + "property": "maester.source.EndOfJson", + "sectionKey": "maester-context-2", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[1].EndOfJson", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-two", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "boolean", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "", + }, + "label": "Counted total tests", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.actual_total", + "sectionKey": "maester-context-2", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[1].Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-two", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Failed", + }, + "label": "Counted failed tests", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.actual_failed", + "sectionKey": "maester-context-2", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[1].Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-two", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Passed", + }, + "label": "Counted passed tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_passed", + "sectionKey": "maester-context-2", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[1].Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-two", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Error", + }, + "label": "Counted error tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_error", + "sectionKey": "maester-context-2", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[1].Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-two", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Investigate", + }, + "label": "Counted investigate tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_investigate", + "sectionKey": "maester-context-2", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[1].Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-two", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Skipped", + }, + "label": "Counted skipped tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_skipped", + "sectionKey": "maester-context-2", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[1].Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-two", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "NotRun", + }, + "label": "Counted notrun tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_notrun", + "sectionKey": "maester-context-2", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[1].Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-two", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + ], + "preview": { + "mappingsToConfirm": 0, + "recordCount": 2, + "selected": true, + }, + "recognition": "maester", + "reportSections": [ + { + "attributes": { + "kind": "maester-report-context", + }, + "detailLinks": undefined, + "label": "Maester report context", + "modalDetails": undefined, + "ordinal": 0, + "parentSectionKey": undefined, + "prose": undefined, + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$", + "rowIndex": undefined, + }, + "state": "observed", + "tables": [ + { + "headers": [ + "Property", + "Original value", + ], + "name": "Report metadata", + "rows": [ + [ + "EndOfJson", + true, + ], + ], + }, + ], + "tool": { + "id": "maester", + "name": "Maester", + }, + "version": 1, + }, + { + "attributes": { + "kind": "maester-report-context", + }, + "detailLinks": undefined, + "label": "Maester report context", + "modalDetails": undefined, + "ordinal": 1, + "parentSectionKey": undefined, + "prose": undefined, + "sectionKey": "maester-context-1", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[0]", + "rowIndex": undefined, + }, + "state": "observed", + "tables": [ + { + "headers": [ + "Property", + "Original value", + ], + "name": "Report metadata", + "rows": [ + [ + "TenantId", + "dummy-one", + ], + [ + "Result", + "Passed", + ], + [ + "EndOfJson", + true, + ], + ], + }, + ], + "tool": { + "id": "maester", + "name": "Maester", + }, + "version": 1, + }, + { + "attributes": { + "kind": "maester-report-context", + }, + "detailLinks": undefined, + "label": "Maester report context", + "modalDetails": undefined, + "ordinal": 2, + "parentSectionKey": undefined, + "prose": undefined, + "sectionKey": "maester-context-2", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tenants[1]", + "rowIndex": undefined, + }, + "state": "observed", + "tables": [ + { + "headers": [ + "Property", + "Original value", + ], + "name": "Report metadata", + "rows": [ + [ + "TenantId", + "dummy-two", + ], + [ + "Result", + "Failed", + ], + [ + "EndOfJson", + true, + ], + ], + }, + ], + "tool": { + "id": "maester", + "name": "Maester", + }, + "version": 1, + }, + ], + "scores": [ + { + "assessed": 2, + "assessmentId": "assessment-protected-dummy-import", + "comparisonKey": undefined, + "contentSha256": "2a080e0facc21626b51b91305e0163382d63e6bc24a8d26d0312300d580afe56", + "deviations": 1, + "eligible": 2, + "evaluationScopeKey": "["[\\"MT.9101\\",\\"evaluated\\"]","[\\"MT.9102\\",\\"evaluated\\"]"]", + "importFileIds": [ + "file-protected-dummy-import-0", + ], + "issues": [ + "Scan identity or time not verified", + "Scanner version unavailable", + ], + "method": "pass-rate", + "nativeProperty": undefined, + "nativeValue": undefined, + "observedAt": undefined, + "occurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + "occurrence-protected-dummy-import-0-1", + ], + "openOccurrenceIds": [ + "occurrence-protected-dummy-import-0-1", + ], + "passed": 1, + "policy": { + "defaultWeight": 1, + "version": "1.0.0", + "weights": { + "cis-cat": 1, + "group-policy": 1, + "maester": 1, + "pingcastle": 1, + "purple-knight": 1, + }, + }, + "processingKey": "[null,"maester","1.2.0","1.1.0","1.0.0","1.0.0"]", + "resultSignature": "["[\\"MT.9101\\",null,\\"pass\\"]","[\\"MT.9102\\",null,\\"fail\\"]"]", + "runIds": [ + "run-protected-dummy-import", + ], + "score": 50, + "toolId": "maester", + "toolName": "Maester", + }, + ], + "tables": [ + { + "columnCount": 1, + "hidden": undefined, + "index": 0, + "name": "HTML", + "rowCount": 1, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Hinweis", + "value": "Synthetic regression fixture only. No customer data.", + }, + ], + "highlighted": false, + "index": 0, + }, + ], + }, + ], +} +`; + +exports[`protected scanner import contracts (synthetic data only) > 'maester-var' retains recognition, preview and approved canonical results in browser and backend 1`] = ` +{ + "approved": { + "recordCount": 2, + "selected": true, + }, + "definitions": [ + { + "canonicalKey": "maester:mt.99001", + "category": undefined, + "description": undefined, + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Dummy failed check", + }, + { + "canonicalKey": "maester:mt.99002", + "category": undefined, + "description": undefined, + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Dummy passed check", + }, + ], + "evidence": [ + { + "finding": { + "canonicalRuleId": "MT.99001", + "originalRuleId": "MT.99001", + "originalTitle": "Dummy failed check", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "kind": "text", + "sourceFieldKeys": [ + "TestResult", + ], + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[0].ResultDetail.TestResult", + "rowIndex": undefined, + }, + "value": "Synthetic evidence only", + }, + ], + "findings": [ + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.99001", + "sourceFieldKeys": [ + "Id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Dummy failed check", + "sourceFieldKeys": [ + "Title", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Failed", + "sourceFieldKeys": [ + "Result", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "High", + "sourceFieldKeys": [ + "Severity", + ], + "targetRole": "severity", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic evidence only", + "sourceFieldKeys": [ + "TestResult", + ], + "targetRole": "evidence", + }, + ], + "originalFields": [ + { + "columnIndex": undefined, + "key": "Id", + "label": "ID", + "path": "$.Tests[0].Id", + "rawValue": "MT.99001", + "value": "MT.99001", + }, + { + "columnIndex": undefined, + "key": "Title", + "label": "Title", + "path": "$.Tests[0].Title", + "rawValue": "Dummy failed check", + "value": "Dummy failed check", + }, + { + "columnIndex": undefined, + "key": "Result", + "label": "Result", + "path": "$.Tests[0].Result", + "rawValue": "Failed", + "value": "Failed", + }, + { + "columnIndex": undefined, + "key": "Severity", + "label": "Severity", + "path": "$.Tests[0].Severity", + "rawValue": "High", + "value": "High", + }, + { + "columnIndex": undefined, + "key": "TestResult", + "label": "ResultDetail", + "path": "$.Tests[0].ResultDetail.TestResult", + "rawValue": "Synthetic evidence only", + "value": "Synthetic evidence only", + }, + ], + "outcome": { + "normalizedValue": "fail", + "originalValue": "Failed", + }, + "score": undefined, + "severity": { + "normalizedValue": "high", + "originalValue": "High", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.99001", + "originalRuleId": "MT.99001", + "originalTitle": "Dummy failed check", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[0]", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "failed", + "originalValue": "Failed", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Failed", + }, + }, + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.99002", + "sourceFieldKeys": [ + "Id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Dummy passed check", + "sourceFieldKeys": [ + "Title", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Passed", + "sourceFieldKeys": [ + "Result", + ], + "targetRole": "status", + }, + ], + "originalFields": [ + { + "columnIndex": undefined, + "key": "Id", + "label": "ID", + "path": "$.Tests[1].Id", + "rawValue": "MT.99002", + "value": "MT.99002", + }, + { + "columnIndex": undefined, + "key": "Title", + "label": "Title", + "path": "$.Tests[1].Title", + "rawValue": "Dummy passed check", + "value": "Dummy passed check", + }, + { + "columnIndex": undefined, + "key": "Result", + "label": "Result", + "path": "$.Tests[1].Result", + "rawValue": "Passed", + "value": "Passed", + }, + ], + "outcome": { + "normalizedValue": "pass", + "originalValue": "Passed", + }, + "score": undefined, + "severity": { + "normalizedValue": "unknown", + "originalValue": "", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.99002", + "originalRuleId": "MT.99002", + "originalTitle": "Dummy passed check", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests[1]", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "passed", + "originalValue": "Passed", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Passed", + }, + }, + ], + "observations": [ + { + "attributes": { + "declared": false, + "sourceField": "Result", + }, + "label": "Reported overall result", + "normalizedValue": "Failed", + "observedAt": undefined, + "originalValue": "Failed", + "property": "maester.result", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Result", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "enum", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "TenantId", + }, + "label": "Entra tenant ID", + "normalizedValue": "dummy-tenant", + "observedAt": undefined, + "originalValue": "dummy-tenant", + "property": "entra.tenant_id", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.TenantId", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "TenantName", + }, + "label": "Entra tenant name", + "normalizedValue": "Dummy", + "observedAt": undefined, + "originalValue": "Dummy", + "property": "entra.tenant_name", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.TenantName", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "TotalCount", + }, + "label": "Reported test count", + "normalizedValue": 2, + "observedAt": undefined, + "originalValue": 2, + "property": "maester.tests.declared_total", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.TotalCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "FailedCount", + }, + "label": "Reported Failed count", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.declared_failed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.FailedCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": true, + "sourceField": "PassedCount", + }, + "label": "Reported Passed count", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.declared_passed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.PassedCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "declared": false, + "sourceField": "EndOfJson", + }, + "label": "End Of Json", + "normalizedValue": "EndOfJson", + "observedAt": undefined, + "originalValue": "EndOfJson", + "property": "maester.source.EndOfJson", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.EndOfJson", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "", + }, + "label": "Counted total tests", + "normalizedValue": 2, + "observedAt": undefined, + "originalValue": 2, + "property": "maester.tests.actual_total", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Failed", + }, + "label": "Counted failed tests", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.actual_failed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Passed", + }, + "label": "Counted passed tests", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "maester.tests.actual_passed", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Error", + }, + "label": "Counted error tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_error", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Investigate", + }, + "label": "Counted investigate tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_investigate", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "Skipped", + }, + "label": "Counted skipped tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_skipped", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "derivation": "count-test-entries", + "derived": true, + "status": "NotRun", + }, + "label": "Counted notrun tests", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": 0, + "property": "maester.tests.actual_notrun", + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Tests", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "dummy-tenant", + "type": "maester-report", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "number", + "version": 1, + }, + ], + "preview": { + "mappingsToConfirm": 0, + "recordCount": 2, + "selected": true, + }, + "recognition": "maester", + "reportSections": [ + { + "attributes": { + "kind": "maester-report-context", + }, + "detailLinks": undefined, + "label": "Maester report context", + "modalDetails": undefined, + "ordinal": 0, + "parentSectionKey": undefined, + "prose": undefined, + "sectionKey": "maester-context-0", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$", + "rowIndex": undefined, + }, + "state": "observed", + "tables": [ + { + "headers": [ + "Property", + "Original value", + ], + "name": "Report metadata", + "rows": [ + [ + "Result", + "Failed", + ], + [ + "TenantId", + "dummy-tenant", + ], + [ + "TenantName", + "Dummy", + ], + [ + "TotalCount", + 2, + ], + [ + "FailedCount", + 1, + ], + [ + "PassedCount", + 1, + ], + [ + "EndOfJson", + "EndOfJson", + ], + ], + }, + ], + "tool": { + "id": "maester", + "name": "Maester", + }, + "version": 1, + }, + ], + "scores": [ + { + "assessed": 2, + "assessmentId": "assessment-protected-dummy-import", + "comparisonKey": "["maester","dummy-tenant","default"]", + "contentSha256": "82590fee427e97fd3951e0fa4541af5d11325292bccf2e3aefe099df12d84b65", + "deviations": 1, + "eligible": 2, + "evaluationScopeKey": "["[\\"MT.99001\\",\\"evaluated\\"]","[\\"MT.99002\\",\\"evaluated\\"]"]", + "importFileIds": [ + "file-protected-dummy-import-0", + ], + "issues": [ + "Scan identity or time not verified", + "Scanner version unavailable", + ], + "method": "pass-rate", + "nativeProperty": undefined, + "nativeValue": undefined, + "observedAt": undefined, + "occurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + "occurrence-protected-dummy-import-0-1", + ], + "openOccurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + ], + "passed": 1, + "policy": { + "defaultWeight": 1, + "version": "1.0.0", + "weights": { + "cis-cat": 1, + "group-policy": 1, + "maester": 1, + "pingcastle": 1, + "purple-knight": 1, + }, + }, + "processingKey": "[null,"maester","1.2.0","1.1.0","1.0.0","1.0.0"]", + "resultSignature": "["[\\"MT.99001\\",null,\\"fail\\"]","[\\"MT.99002\\",null,\\"pass\\"]"]", + "runIds": [ + "run-protected-dummy-import", + ], + "score": 50, + "toolId": "maester", + "toolName": "Maester", + }, + ], + "tables": [ + { + "columnCount": 1, + "hidden": undefined, + "index": 0, + "name": "HTML", + "rowCount": 1, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Hinweis", + "value": "Synthetic Dummy data only. No customer data.", + }, + ], + "highlighted": false, + "index": 0, + }, + ], + }, + ], +} +`; + +exports[`protected scanner import contracts (synthetic data only) > 'maester-xlsx' retains recognition, preview and approved canonical results in browser and backend 1`] = ` +{ + "approved": { + "recordCount": 2, + "selected": true, + }, + "definitions": [ + { + "canonicalKey": "maester:mt.1001", + "category": "CIS, Entra", + "description": "Checks whether the synthetic tenant requires multifactor authentication.", + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Ensure multifactor authentication is configured", + }, + { + "canonicalKey": "maester:mt.1002", + "category": "CIS, Entra", + "description": "Checks whether legacy authentication is blocked.", + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Ensure legacy authentication is blocked", + }, + ], + "evidence": [ + { + "finding": { + "canonicalRuleId": "MT.1001", + "originalRuleId": "MT.1001", + "originalTitle": "Ensure multifactor authentication is configured", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "kind": "text", + "sourceFieldKeys": [ + "column-8", + ], + "sourcePosition": { + "cellReference": "I2", + "columnIndex": 8, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 1, + }, + "value": "The synthetic policy does not cover all users.", + }, + { + "finding": { + "canonicalRuleId": "MT.1002", + "originalRuleId": "MT.1002", + "originalTitle": "Ensure legacy authentication is blocked", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "kind": "text", + "sourceFieldKeys": [ + "column-8", + ], + "sourcePosition": { + "cellReference": "I3", + "columnIndex": 8, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 2, + }, + "value": "The synthetic policy blocks legacy authentication.", + }, + ], + "findings": [ + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.1001", + "sourceFieldKeys": [ + "column-0", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Ensure multifactor authentication is configured", + "sourceFieldKeys": [ + "column-1", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Failed", + "sourceFieldKeys": [ + "column-2", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "High", + "sourceFieldKeys": [ + "column-3", + ], + "targetRole": "severity", + }, + { + "decisionSource": "automatic", + "normalizedValue": "CIS, Entra", + "sourceFieldKeys": [ + "column-4", + ], + "targetRole": "category", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Maester Security Tests", + "sourceFieldKeys": [ + "column-5", + ], + "targetRole": "category", + }, + { + "decisionSource": "automatic", + "normalizedValue": "00:00:01", + "sourceFieldKeys": [ + "column-6", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Checks whether the synthetic tenant requires multifactor authentication.", + "sourceFieldKeys": [ + "column-7", + ], + "targetRole": "description", + }, + { + "decisionSource": "automatic", + "normalizedValue": "The synthetic policy does not cover all users.", + "sourceFieldKeys": [ + "column-8", + ], + "targetRole": "evidence", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "column-9", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "column-10", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "column-11", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "MT.1001: Ensure multifactor authentication is configured", + "sourceFieldKeys": [ + "column-12", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "https://maester.dev/docs/tests/MT.1001", + "sourceFieldKeys": [ + "column-13", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "automatic", + "normalizedValue": "MT.1001.Tests.ps1", + "sourceFieldKeys": [ + "column-14", + ], + "targetRole": "rule-id", + }, + ], + "originalFields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "ID", + "path": "A2", + "rawValue": "MT.1001", + "value": "MT.1001", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Title", + "path": "B2", + "rawValue": "Ensure multifactor authentication is configured", + "value": "Ensure multifactor authentication is configured", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Result", + "path": "C2", + "rawValue": "Failed", + "value": "Failed", + }, + { + "columnIndex": 3, + "key": "column-3", + "label": "Severity", + "path": "D2", + "rawValue": "High", + "value": "High", + }, + { + "columnIndex": 4, + "key": "column-4", + "label": "Tag", + "path": "E2", + "rawValue": "CIS, Entra", + "value": "CIS, Entra", + }, + { + "columnIndex": 5, + "key": "column-5", + "label": "Block", + "path": "F2", + "rawValue": "Maester Security Tests", + "value": "Maester Security Tests", + }, + { + "columnIndex": 6, + "key": "column-6", + "label": "Duration", + "path": "G2", + "rawValue": "00:00:01", + "value": "00:00:01", + }, + { + "columnIndex": 7, + "key": "column-7", + "label": "Description", + "path": "H2", + "rawValue": "Checks whether the synthetic tenant requires multifactor authentication.", + "value": "Checks whether the synthetic tenant requires multifactor authentication.", + }, + { + "columnIndex": 8, + "key": "column-8", + "label": "ResultDetail", + "path": "I2", + "rawValue": "The synthetic policy does not cover all users.", + "value": "The synthetic policy does not cover all users.", + }, + { + "columnIndex": 9, + "key": "column-9", + "label": "TestSkipped", + "path": "J2", + "rawValue": "", + "value": "", + }, + { + "columnIndex": 10, + "key": "column-10", + "label": "SkippedReason", + "path": "K2", + "rawValue": "", + "value": "", + }, + { + "columnIndex": 11, + "key": "column-11", + "label": "ErrorMessage", + "path": "L2", + "rawValue": "", + "value": "", + }, + { + "columnIndex": 12, + "key": "column-12", + "label": "Name", + "path": "M2", + "rawValue": "MT.1001: Ensure multifactor authentication is configured", + "value": "MT.1001: Ensure multifactor authentication is configured", + }, + { + "columnIndex": 13, + "key": "column-13", + "label": "HelpUrl", + "path": "N2", + "rawValue": "https://maester.dev/docs/tests/MT.1001", + "value": "https://maester.dev/docs/tests/MT.1001", + }, + { + "columnIndex": 14, + "key": "column-14", + "label": "TestScriptFile", + "path": "O2", + "rawValue": "MT.1001.Tests.ps1", + "value": "MT.1001.Tests.ps1", + }, + ], + "outcome": { + "normalizedValue": "fail", + "originalValue": "Failed", + }, + "score": undefined, + "severity": { + "normalizedValue": "high", + "originalValue": "High", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.1001", + "originalRuleId": "MT.1001", + "originalTitle": "Ensure multifactor authentication is configured", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 1, + }, + "status": { + "normalizedValue": "failed", + "originalValue": "Failed", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Failed", + }, + }, + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "MT.1002", + "sourceFieldKeys": [ + "column-0", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Ensure legacy authentication is blocked", + "sourceFieldKeys": [ + "column-1", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Passed", + "sourceFieldKeys": [ + "column-2", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Medium", + "sourceFieldKeys": [ + "column-3", + ], + "targetRole": "severity", + }, + { + "decisionSource": "automatic", + "normalizedValue": "CIS, Entra", + "sourceFieldKeys": [ + "column-4", + ], + "targetRole": "category", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Maester Security Tests", + "sourceFieldKeys": [ + "column-5", + ], + "targetRole": "category", + }, + { + "decisionSource": "automatic", + "normalizedValue": "00:00:01", + "sourceFieldKeys": [ + "column-6", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Checks whether legacy authentication is blocked.", + "sourceFieldKeys": [ + "column-7", + ], + "targetRole": "description", + }, + { + "decisionSource": "automatic", + "normalizedValue": "The synthetic policy blocks legacy authentication.", + "sourceFieldKeys": [ + "column-8", + ], + "targetRole": "evidence", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "column-9", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "column-10", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "column-11", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "MT.1002: Ensure legacy authentication is blocked", + "sourceFieldKeys": [ + "column-12", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "https://maester.dev/docs/tests/MT.1002", + "sourceFieldKeys": [ + "column-13", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "automatic", + "normalizedValue": "MT.1002.Tests.ps1", + "sourceFieldKeys": [ + "column-14", + ], + "targetRole": "rule-id", + }, + ], + "originalFields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "ID", + "path": "A3", + "rawValue": "MT.1002", + "value": "MT.1002", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Title", + "path": "B3", + "rawValue": "Ensure legacy authentication is blocked", + "value": "Ensure legacy authentication is blocked", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Result", + "path": "C3", + "rawValue": "Passed", + "value": "Passed", + }, + { + "columnIndex": 3, + "key": "column-3", + "label": "Severity", + "path": "D3", + "rawValue": "Medium", + "value": "Medium", + }, + { + "columnIndex": 4, + "key": "column-4", + "label": "Tag", + "path": "E3", + "rawValue": "CIS, Entra", + "value": "CIS, Entra", + }, + { + "columnIndex": 5, + "key": "column-5", + "label": "Block", + "path": "F3", + "rawValue": "Maester Security Tests", + "value": "Maester Security Tests", + }, + { + "columnIndex": 6, + "key": "column-6", + "label": "Duration", + "path": "G3", + "rawValue": "00:00:01", + "value": "00:00:01", + }, + { + "columnIndex": 7, + "key": "column-7", + "label": "Description", + "path": "H3", + "rawValue": "Checks whether legacy authentication is blocked.", + "value": "Checks whether legacy authentication is blocked.", + }, + { + "columnIndex": 8, + "key": "column-8", + "label": "ResultDetail", + "path": "I3", + "rawValue": "The synthetic policy blocks legacy authentication.", + "value": "The synthetic policy blocks legacy authentication.", + }, + { + "columnIndex": 9, + "key": "column-9", + "label": "TestSkipped", + "path": "J3", + "rawValue": "", + "value": "", + }, + { + "columnIndex": 10, + "key": "column-10", + "label": "SkippedReason", + "path": "K3", + "rawValue": "", + "value": "", + }, + { + "columnIndex": 11, + "key": "column-11", + "label": "ErrorMessage", + "path": "L3", + "rawValue": "", + "value": "", + }, + { + "columnIndex": 12, + "key": "column-12", + "label": "Name", + "path": "M3", + "rawValue": "MT.1002: Ensure legacy authentication is blocked", + "value": "MT.1002: Ensure legacy authentication is blocked", + }, + { + "columnIndex": 13, + "key": "column-13", + "label": "HelpUrl", + "path": "N3", + "rawValue": "https://maester.dev/docs/tests/MT.1002", + "value": "https://maester.dev/docs/tests/MT.1002", + }, + { + "columnIndex": 14, + "key": "column-14", + "label": "TestScriptFile", + "path": "O3", + "rawValue": "MT.1002.Tests.ps1", + "value": "MT.1002.Tests.ps1", + }, + ], + "outcome": { + "normalizedValue": "pass", + "originalValue": "Passed", + }, + "score": undefined, + "severity": { + "normalizedValue": "medium", + "originalValue": "Medium", + }, + "sourceIdentity": { + "canonicalRuleId": "MT.1002", + "originalRuleId": "MT.1002", + "originalTitle": "Ensure legacy authentication is blocked", + "tool": { + "id": "maester", + "name": "Maester", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 2, + }, + "status": { + "normalizedValue": "passed", + "originalValue": "Passed", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Passed", + }, + }, + ], + "observations": [ + { + "attributes": { + "clientInformationArea": "cloud-access", + "clientInformationMappingVersion": "1.0.0", + "informationKind": "reported-test-evidence", + "inventoryComplete": false, + "sourceField": "table-result-cell", + "sourceOutcome": "Failed", + "sourceRuleId": "MT.1001", + "sourceTableIndex": 0, + }, + "label": "Ensure multifactor authentication is configured", + "normalizedValue": undefined, + "observedAt": undefined, + "originalValue": "The synthetic policy does not cover all users.", + "property": "cloud.conditional_access.mt.1001.reported_evidence", + "sectionKey": "maester-client-information-0-0", + "source": { + "cellReference": "I2", + "columnIndex": 8, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 1, + }, + "state": "observed", + "subject": { + "identity": undefined, + "label": undefined, + "type": "entra-tenant", + }, + "tool": { + "id": "maester", + "name": "Maester", + }, + "valueType": "string", + "version": 1, + }, + ], + "preview": { + "mappingsToConfirm": 0, + "recordCount": 2, + "selected": true, + }, + "recognition": "maester", + "reportSections": [ + { + "attributes": { + "clientInformationArea": "cloud-access", + "clientInformationMappingVersion": "1.0.0", + "informationKind": "reported-test-evidence", + "inventoryComplete": false, + "sourceField": "table-result-cell", + "sourceOutcome": "Failed", + "sourceRuleId": "MT.1001", + "sourceTableIndex": 0, + }, + "detailLinks": undefined, + "label": "Ensure multifactor authentication is configured", + "modalDetails": undefined, + "ordinal": 0, + "parentSectionKey": undefined, + "prose": undefined, + "sectionKey": "maester-client-information-0-0", + "source": { + "cellReference": "I2", + "columnIndex": 8, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 1, + }, + "state": "observed", + "tables": [], + "tool": { + "id": "maester", + "name": "Maester", + }, + "version": 1, + }, + ], + "scores": [ + { + "assessed": 2, + "assessmentId": "assessment-protected-dummy-import", + "comparisonKey": undefined, + "contentSha256": "e2b365a28f59819734e7e491c4df375da9c22ce2c0ca2930f8a2122094d9547c", + "deviations": 1, + "eligible": 2, + "evaluationScopeKey": "["[\\"MT.1001\\",\\"evaluated\\"]","[\\"MT.1002\\",\\"evaluated\\"]"]", + "importFileIds": [ + "file-protected-dummy-import-0", + ], + "issues": [ + "Scan identity or time not verified", + "Scanner version unavailable", + ], + "method": "pass-rate", + "nativeProperty": undefined, + "nativeValue": undefined, + "observedAt": undefined, + "occurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + "occurrence-protected-dummy-import-0-1", + ], + "openOccurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + ], + "passed": 1, + "policy": { + "defaultWeight": 1, + "version": "1.0.0", + "weights": { + "cis-cat": 1, + "group-policy": 1, + "maester": 1, + "pingcastle": 1, + "purple-knight": 1, + }, + }, + "processingKey": "[null,"maester","1.2.0","1.1.0","1.0.0","1.0.0"]", + "resultSignature": "["[\\"MT.1001\\",null,\\"fail\\"]","[\\"MT.1002\\",null,\\"pass\\"]"]", + "runIds": [ + "run-protected-dummy-import", + ], + "score": 50, + "toolId": "maester", + "toolName": "Maester", + }, + ], + "tables": [ + { + "columnCount": 15, + "hidden": undefined, + "index": 0, + "name": "Tests", + "rowCount": 2, + "rows": undefined, + }, + ], +} +`; + +exports[`protected scanner import contracts (synthetic data only) > 'pingcastle-html' retains recognition, preview and approved canonical results in browser and backend 1`] = ` +{ + "approved": { + "recordCount": 1, + "selected": true, + }, + "definitions": [ + { + "canonicalKey": "pingcastle:s-synthetic", + "category": "Stale Objects", + "description": "A synthetic privileged account has not signed in recently.", + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": "Review and disable accounts that are no longer required.", + "title": "Synthetic inactive privileged account", + }, + ], + "evidence": [], + "findings": [ + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "S-Synthetic", + "sourceFieldKeys": [ + "risk-id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic inactive privileged account", + "sourceFieldKeys": [ + "title", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Matched", + "sourceFieldKeys": [ + "status", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "A synthetic privileged account has not signed in recently.", + "sourceFieldKeys": [ + "description", + ], + "targetRole": "description", + }, + { + "decisionSource": "automatic", + "normalizedValue": "This placeholder contains no real directory data.", + "sourceFieldKeys": [ + "technical-explanation", + ], + "targetRole": "description", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Review and disable accounts that are no longer required.", + "sourceFieldKeys": [ + "solution", + ], + "targetRole": "recommendation", + }, + { + "decisionSource": "user-confirmed", + "normalizedValue": "25 points if the occurence is greater than or equals than 25 then 20 points if present", + "sourceFieldKeys": [ + "points-scale", + ], + "targetRole": "description", + }, + { + "decisionSource": "automatic", + "normalizedValue": "20", + "sourceFieldKeys": [ + "points", + ], + "targetRole": "score", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Stale Objects", + "sourceFieldKeys": [ + "category", + ], + "targetRole": "category", + }, + { + "decisionSource": "user-confirmed", + "normalizedValue": "20", + "sourceFieldKeys": [ + "global-score", + ], + "targetRole": "score", + }, + { + "decisionSource": "user-confirmed", + "normalizedValue": "20", + "sourceFieldKeys": [ + "category-score", + ], + "targetRole": "score", + }, + ], + "originalFields": [ + { + "columnIndex": undefined, + "key": "risk-id", + "label": "Rule ID", + "path": "html/rules/S-Synthetic", + "rawValue": "S-Synthetic", + "value": "S-Synthetic", + }, + { + "columnIndex": undefined, + "key": "title", + "label": "Title", + "path": "html/rules/S-Synthetic", + "rawValue": "Synthetic inactive privileged account", + "value": "Synthetic inactive privileged account", + }, + { + "columnIndex": undefined, + "key": "status", + "label": "Status", + "path": "html/rules/S-Synthetic", + "rawValue": "Matched", + "value": "Matched", + }, + { + "columnIndex": undefined, + "key": "description", + "label": "Description", + "path": "html/rules/S-Synthetic", + "rawValue": "A synthetic privileged account has not signed in recently.", + "value": "A synthetic privileged account has not signed in recently.", + }, + { + "columnIndex": undefined, + "key": "technical-explanation", + "label": "Technical explanation", + "path": "html/rules/S-Synthetic", + "rawValue": "This placeholder contains no real directory data.", + "value": "This placeholder contains no real directory data.", + }, + { + "columnIndex": undefined, + "key": "solution", + "label": "Advised solution", + "path": "html/rules/S-Synthetic", + "rawValue": "Review and disable accounts that are no longer required.", + "value": "Review and disable accounts that are no longer required.", + }, + { + "columnIndex": undefined, + "key": "points-scale", + "label": "Points Scale", + "path": "html/rules/S-Synthetic", + "rawValue": "25 points if the occurence is greater than or equals than 25 then 20 points if present", + "value": "25 points if the occurence is greater than or equals than 25 then 20 points if present", + }, + { + "columnIndex": undefined, + "key": "points", + "label": "Points", + "path": "html/rules/S-Synthetic", + "rawValue": "20", + "value": "20", + }, + { + "columnIndex": undefined, + "key": "category", + "label": "Category", + "path": "html/rules/S-Synthetic", + "rawValue": "Stale Objects", + "value": "Stale Objects", + }, + { + "columnIndex": undefined, + "key": "global-score", + "label": "Domain Risk Level", + "path": "html/risk-score", + "rawValue": "20", + "value": "20", + }, + { + "columnIndex": undefined, + "key": "category-score", + "label": "Category Score", + "path": "html/risk-score/stale-objects", + "rawValue": "20", + "value": "20", + }, + ], + "outcome": { + "normalizedValue": "fail", + "originalValue": "Matched", + }, + "score": { + "normalizedValue": 20, + "originalValue": "20", + }, + "severity": { + "normalizedValue": "medium", + "originalValue": 20, + }, + "sourceIdentity": { + "canonicalRuleId": "S-Synthetic", + "originalRuleId": "S-Synthetic", + "originalTitle": "Synthetic inactive privileged account", + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/rules/S-Synthetic", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "failed", + "originalValue": "Matched", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Matched", + }, + }, + ], + "observations": [ + { + "attributes": { + "maximum": 100, + }, + "label": "Domain Risk Level", + "normalizedValue": 20, + "observedAt": undefined, + "originalValue": "20", + "property": "risk.domain", + "sectionKey": undefined, + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/risk-score", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "type": "active-directory-environment", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "maximum": 100, + }, + "label": "Stale Objects score", + "normalizedValue": 20, + "observedAt": undefined, + "originalValue": "20", + "property": "risk.stale_objects", + "sectionKey": undefined, + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/risk-score/stale-objects", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "type": "active-directory-environment", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceTableIndex": 0, + }, + "label": "Risk Scores row count", + "normalizedValue": 4, + "observedAt": undefined, + "originalValue": 4, + "property": "inventory.report.overview.risk.scores.row_count", + "sectionKey": "report-overview", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/sections/report-overview/tables/0/row-count", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "report-overview", + "label": "Report overview", + "type": "report-section", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceTableIndex": 0, + "tableName": "Risk Scores", + }, + "label": "Category", + "normalizedValue": "Stale Objects", + "observedAt": undefined, + "originalValue": "Stale Objects", + "property": "pingcastle.report.overview.risk.scores.category", + "sectionKey": "report-overview", + "source": { + "cellReference": undefined, + "columnIndex": 0, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 1, + }, + "state": "observed", + "subject": { + "identity": "Stale Objects", + "label": "Stale Objects", + "type": "pingcastle-table-row", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "sourceTableIndex": 0, + "tableName": "Risk Scores", + }, + "label": "Score", + "normalizedValue": 20, + "observedAt": undefined, + "originalValue": "20", + "property": "pingcastle.report.overview.risk.scores.score", + "sectionKey": "report-overview", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 1, + }, + "state": "observed", + "subject": { + "identity": "Stale Objects", + "label": "Stale Objects", + "type": "pingcastle-table-row", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceTableIndex": 0, + "tableName": "Risk Scores", + }, + "label": "Rules matched", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": "1", + "property": "pingcastle.report.overview.risk.scores.rules.matched", + "sectionKey": "report-overview", + "source": { + "cellReference": undefined, + "columnIndex": 2, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 1, + }, + "state": "observed", + "subject": { + "identity": "Stale Objects", + "label": "Stale Objects", + "type": "pingcastle-table-row", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceTableIndex": 0, + "tableName": "Risk Scores", + }, + "label": "Category", + "normalizedValue": "Privileged Accounts", + "observedAt": undefined, + "originalValue": "Privileged Accounts", + "property": "pingcastle.report.overview.risk.scores.category", + "sectionKey": "report-overview", + "source": { + "cellReference": undefined, + "columnIndex": 0, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 2, + }, + "state": "observed", + "subject": { + "identity": "Privileged Accounts", + "label": "Privileged Accounts", + "type": "pingcastle-table-row", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "sourceTableIndex": 0, + "tableName": "Risk Scores", + }, + "label": "Score", + "normalizedValue": 10, + "observedAt": undefined, + "originalValue": "10", + "property": "pingcastle.report.overview.risk.scores.score", + "sectionKey": "report-overview", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 2, + }, + "state": "observed", + "subject": { + "identity": "Privileged Accounts", + "label": "Privileged Accounts", + "type": "pingcastle-table-row", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceTableIndex": 0, + "tableName": "Risk Scores", + }, + "label": "Rules matched", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": "1", + "property": "pingcastle.report.overview.risk.scores.rules.matched", + "sectionKey": "report-overview", + "source": { + "cellReference": undefined, + "columnIndex": 2, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 2, + }, + "state": "observed", + "subject": { + "identity": "Privileged Accounts", + "label": "Privileged Accounts", + "type": "pingcastle-table-row", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceTableIndex": 0, + "tableName": "Risk Scores", + }, + "label": "Category", + "normalizedValue": "Trusts", + "observedAt": undefined, + "originalValue": "Trusts", + "property": "pingcastle.report.overview.risk.scores.category", + "sectionKey": "report-overview", + "source": { + "cellReference": undefined, + "columnIndex": 0, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 3, + }, + "state": "observed", + "subject": { + "identity": "Trusts", + "label": "Trusts", + "type": "pingcastle-table-row", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "sourceTableIndex": 0, + "tableName": "Risk Scores", + }, + "label": "Score", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": "0", + "property": "pingcastle.report.overview.risk.scores.score", + "sectionKey": "report-overview", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 3, + }, + "state": "observed", + "subject": { + "identity": "Trusts", + "label": "Trusts", + "type": "pingcastle-table-row", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceTableIndex": 0, + "tableName": "Risk Scores", + }, + "label": "Rules matched", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": "0", + "property": "pingcastle.report.overview.risk.scores.rules.matched", + "sectionKey": "report-overview", + "source": { + "cellReference": undefined, + "columnIndex": 2, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 3, + }, + "state": "observed", + "subject": { + "identity": "Trusts", + "label": "Trusts", + "type": "pingcastle-table-row", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceTableIndex": 0, + "tableName": "Risk Scores", + }, + "label": "Category", + "normalizedValue": "Anomalies", + "observedAt": undefined, + "originalValue": "Anomalies", + "property": "pingcastle.report.overview.risk.scores.category", + "sectionKey": "report-overview", + "source": { + "cellReference": undefined, + "columnIndex": 0, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 4, + }, + "state": "observed", + "subject": { + "identity": "Anomalies", + "label": "Anomalies", + "type": "pingcastle-table-row", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "sourceTableIndex": 0, + "tableName": "Risk Scores", + }, + "label": "Score", + "normalizedValue": 5, + "observedAt": undefined, + "originalValue": "5", + "property": "pingcastle.report.overview.risk.scores.score", + "sectionKey": "report-overview", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 4, + }, + "state": "observed", + "subject": { + "identity": "Anomalies", + "label": "Anomalies", + "type": "pingcastle-table-row", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceTableIndex": 0, + "tableName": "Risk Scores", + }, + "label": "Rules matched", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": "1", + "property": "pingcastle.report.overview.risk.scores.rules.matched", + "sectionKey": "report-overview", + "source": { + "cellReference": undefined, + "columnIndex": 2, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 4, + }, + "state": "observed", + "subject": { + "identity": "Anomalies", + "label": "Anomalies", + "type": "pingcastle-table-row", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceTableIndex": 1, + }, + "label": "Domain information row count", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": 1, + "property": "inventory.report.overview.domain.information.row_count", + "sectionKey": "report-overview", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/sections/report-overview/tables/1/row-count", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "report-overview", + "label": "Report overview", + "type": "report-section", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceTableIndex": 1, + "tableName": "Domain information", + }, + "label": "Domain", + "normalizedValue": "synthetic.example", + "observedAt": undefined, + "originalValue": "synthetic.example", + "property": "domain.fqdn", + "sectionKey": "report-overview", + "source": { + "cellReference": undefined, + "columnIndex": 0, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 1, + }, + "state": "observed", + "subject": { + "identity": "synthetic.example", + "label": "synthetic.example", + "type": "pingcastle-table-row", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "sourceTableIndex": 1, + "tableName": "Domain information", + }, + "label": "Netbios Name", + "normalizedValue": "SYNTHETIC", + "observedAt": undefined, + "originalValue": "SYNTHETIC", + "property": "domain.netbios_name", + "sectionKey": "report-overview", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 1, + }, + "state": "observed", + "subject": { + "identity": "synthetic.example", + "label": "synthetic.example", + "type": "pingcastle-table-row", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "sourceTableIndex": 1, + "tableName": "Domain information", + }, + "label": "Domain functional level", + "normalizedValue": 2016, + "observedAt": undefined, + "originalValue": "2016", + "property": "domain.functional_level", + "sectionKey": "report-overview", + "source": { + "cellReference": undefined, + "columnIndex": 2, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 1, + }, + "state": "observed", + "subject": { + "identity": "synthetic.example", + "label": "synthetic.example", + "type": "pingcastle-table-row", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceTableIndex": 1, + "tableName": "Domain information", + }, + "label": "Number of DC", + "normalizedValue": 2, + "observedAt": undefined, + "originalValue": "2", + "property": "domain.controller_count", + "sectionKey": "report-overview", + "source": { + "cellReference": undefined, + "columnIndex": 3, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 1, + }, + "state": "observed", + "subject": { + "identity": "synthetic.example", + "label": "synthetic.example", + "type": "pingcastle-table-row", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "number", + "version": 1, + }, + ], + "preview": { + "mappingsToConfirm": 0, + "recordCount": 1, + "selected": true, + }, + "recognition": "pingcastle", + "reportSections": [ + { + "attributes": { + "tableCount": 2, + }, + "detailLinks": undefined, + "label": "Report overview", + "modalDetails": undefined, + "ordinal": 0, + "parentSectionKey": undefined, + "prose": undefined, + "sectionKey": "report-overview", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/report-overview", + "rowIndex": undefined, + }, + "state": "observed", + "tables": [ + { + "headers": [ + "Category", + "Score", + "Rules matched", + ], + "name": "Risk Scores", + "presentation": "inline", + "rows": [ + [ + "Stale Objects", + "20", + "1", + ], + [ + "Privileged Accounts", + "10", + "1", + ], + [ + "Trusts", + "0", + "0", + ], + [ + "Anomalies", + "5", + "1", + ], + ], + "sourceTableIndex": 0, + }, + { + "headers": [ + "Domain", + "Netbios Name", + "Domain functional level", + "Number of DC", + ], + "name": "Domain information", + "presentation": "inline", + "rows": [ + [ + "synthetic.example", + "SYNTHETIC", + "2016", + "2", + ], + ], + "sourceTableIndex": 1, + }, + ], + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "version": 1, + }, + { + "attributes": { + "derivedColumn": "Extracted unique rules", + }, + "detailLinks": undefined, + "label": "PingCastle rule coverage", + "modalDetails": undefined, + "ordinal": 1, + "parentSectionKey": undefined, + "prose": undefined, + "sectionKey": "pingcastle-rule-coverage", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "html/indicator-headings", + "rowIndex": undefined, + }, + "state": "observed", + "tables": [ + { + "headers": [ + "Category", + "Declared matched", + "Declared catalog size", + "Extracted unique rules", + "Original heading", + ], + "name": "Declared and extracted rules", + "rows": [ + [ + "Stale Objects", + "1", + "1", + 1, + "Stale Objects rule details [1 rule matched on a total of 1]", + ], + ], + }, + ], + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "version": 1, + }, + ], + "scores": [ + { + "assessed": 1, + "assessmentId": "assessment-protected-dummy-import", + "comparisonKey": "["pingcastle","synthetic.example","default"]", + "contentSha256": "9d2d7efbe6ba8c670dd7ceea85ba9c57166b71bebb98f9000bb63e506677eb10", + "deviations": 1, + "eligible": 1, + "evaluationScopeKey": "["[\\"S-Synthetic\\",\\"evaluated\\"]"]", + "importFileIds": [ + "file-protected-dummy-import-0", + ], + "issues": [ + "Scan identity or time not verified", + "Scanner version unavailable", + ], + "method": "inverted-native", + "nativeProperty": "risk.domain", + "nativeValue": 20, + "observedAt": undefined, + "occurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + ], + "openOccurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + ], + "passed": 0, + "policy": { + "defaultWeight": 1, + "version": "1.0.0", + "weights": { + "cis-cat": 1, + "group-policy": 1, + "maester": 1, + "pingcastle": 1, + "purple-knight": 1, + }, + }, + "processingKey": "[null,"pingcastle","1.5.0","1.2.0","1.1.0","1.0.0"]", + "resultSignature": "["[\\"S-Synthetic\\",null,\\"fail\\"]"]", + "runIds": [ + "run-protected-dummy-import", + ], + "score": 80, + "toolId": "pingcastle", + "toolName": "PingCastle", + }, + ], + "tables": [ + { + "columnCount": 3, + "hidden": undefined, + "index": 0, + "name": "Risk Scores", + "rowCount": 4, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Category", + "value": "Stale Objects", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Score", + "value": "20", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Rules matched", + "value": "1", + }, + ], + "highlighted": false, + "index": 0, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Category", + "value": "Privileged Accounts", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Score", + "value": "10", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Rules matched", + "value": "1", + }, + ], + "highlighted": false, + "index": 1, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Category", + "value": "Trusts", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Score", + "value": "0", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Rules matched", + "value": "0", + }, + ], + "highlighted": false, + "index": 2, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Category", + "value": "Anomalies", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Score", + "value": "5", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Rules matched", + "value": "1", + }, + ], + "highlighted": false, + "index": 3, + }, + ], + }, + { + "columnCount": 4, + "hidden": undefined, + "index": 1, + "name": "Domain information", + "rowCount": 1, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Domain", + "value": "synthetic.example", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Netbios Name", + "value": "SYNTHETIC", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Domain functional level", + "value": "2016", + }, + { + "columnIndex": 3, + "key": "column-3", + "label": "Number of DC", + "value": "2", + }, + ], + "highlighted": false, + "index": 0, + }, + ], + }, + ], +} +`; + +exports[`protected scanner import contracts (synthetic data only) > 'pingcastle-xml' retains recognition, preview and approved canonical results in browser and backend 1`] = ` +{ + "approved": { + "recordCount": 1, + "selected": true, + }, + "definitions": [ + { + "canonicalKey": "pingcastle:s-synthetic", + "category": "StaleObjects", + "description": undefined, + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Synthetic inactive privileged account", + }, + ], + "evidence": [ + { + "finding": { + "canonicalRuleId": "S-Synthetic", + "originalRuleId": "S-Synthetic", + "originalTitle": "Synthetic inactive privileged account", + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + }, + "kind": "text", + "sourceFieldKeys": [ + "Notice", + ], + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/Notice[5]", + "rowIndex": undefined, + }, + "value": "Synthetic matched-rule evidence without directory data.", + }, + { + "finding": { + "canonicalRuleId": "S-Synthetic", + "originalRuleId": "S-Synthetic", + "originalTitle": "Synthetic inactive privileged account", + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + }, + "kind": "text", + "sourceFieldKeys": [ + "Details", + ], + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/Details[7]/string[1]", + "rowIndex": undefined, + }, + "value": "Synthetic detail without tenant identifiers. +Synthetic setting: Enabled +Synthetic values: Alpha, Beta", + }, + ], + "findings": [ + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "S-Synthetic", + "sourceFieldKeys": [ + "risk-id", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic inactive privileged account", + "sourceFieldKeys": [ + "rationale", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Matched", + "sourceFieldKeys": [ + "status", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "StaleObjects", + "sourceFieldKeys": [ + "Category", + ], + "targetRole": "category", + }, + { + "decisionSource": "automatic", + "normalizedValue": "InactiveUserOrComputer", + "sourceFieldKeys": [ + "Model", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "20", + "sourceFieldKeys": [ + "Points", + ], + "targetRole": "score", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic matched-rule evidence without directory data.", + "sourceFieldKeys": [ + "Notice", + ], + "targetRole": "evidence", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic detail without tenant identifiers. +Synthetic setting: Enabled +Synthetic values: Alpha, Beta", + "sourceFieldKeys": [ + "Details", + ], + "targetRole": "evidence", + }, + { + "decisionSource": "user-confirmed", + "normalizedValue": "20", + "sourceFieldKeys": [ + "global-score", + ], + "targetRole": "score", + }, + { + "decisionSource": "user-confirmed", + "normalizedValue": "20", + "sourceFieldKeys": [ + "category-score", + ], + "targetRole": "score", + }, + ], + "originalFields": [ + { + "columnIndex": undefined, + "key": "risk-id", + "label": "Rule ID", + "path": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/RiskId[4]", + "rawValue": "S-Synthetic", + "value": "S-Synthetic", + }, + { + "columnIndex": undefined, + "key": "rationale", + "label": "Title", + "path": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/Rationale[6]", + "rawValue": "Synthetic inactive privileged account", + "value": "Synthetic inactive privileged account", + }, + { + "columnIndex": undefined, + "key": "status", + "label": "Status", + "path": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/RiskId[4]", + "rawValue": "Matched", + "value": "Matched", + }, + { + "columnIndex": undefined, + "key": "Category", + "label": "Category", + "path": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/Category[2]", + "rawValue": "StaleObjects", + "value": "StaleObjects", + }, + { + "columnIndex": undefined, + "key": "Model", + "label": "Model", + "path": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/Model[3]", + "rawValue": "InactiveUserOrComputer", + "value": "InactiveUserOrComputer", + }, + { + "columnIndex": undefined, + "key": "Points", + "label": "Points", + "path": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/Points[1]", + "rawValue": "20", + "value": "20", + }, + { + "columnIndex": undefined, + "key": "Notice", + "label": "Evidence", + "path": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/Notice[5]", + "rawValue": "Synthetic matched-rule evidence without directory data.", + "value": "Synthetic matched-rule evidence without directory data.", + }, + { + "columnIndex": undefined, + "key": "Details", + "label": "Details", + "path": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/Details[7]/string[1]", + "rawValue": "Synthetic detail without tenant identifiers. +Synthetic setting: Enabled +Synthetic values: Alpha, Beta", + "value": "Synthetic detail without tenant identifiers. +Synthetic setting: Enabled +Synthetic values: Alpha, Beta", + }, + { + "columnIndex": undefined, + "key": "global-score", + "label": "Domain Risk Level", + "path": "HealthcheckData/GlobalScore[3]", + "rawValue": "20", + "value": "20", + }, + { + "columnIndex": undefined, + "key": "category-score", + "label": "Category Score", + "path": "HealthcheckData/StaleObjectsScore[4]", + "rawValue": "20", + "value": "20", + }, + ], + "outcome": { + "normalizedValue": "fail", + "originalValue": "Matched", + }, + "score": { + "normalizedValue": 20, + "originalValue": "20", + }, + "severity": { + "normalizedValue": "medium", + "originalValue": 20, + }, + "sourceIdentity": { + "canonicalRuleId": "S-Synthetic", + "originalRuleId": "S-Synthetic", + "originalTitle": "Synthetic inactive privileged account", + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/RiskId[4]", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "failed", + "originalValue": "Matched", + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": "Matched", + }, + }, + ], + "observations": [ + { + "attributes": { + "xmlPath": "HealthcheckData/PingCastleVersion[1]", + }, + "label": "PingCastleVersion", + "normalizedValue": "3.5.1.33", + "observedAt": undefined, + "originalValue": "3.5.1.33", + "property": "pingcastle.xml.healthcheckdata.pingcastleversion.1", + "sectionKey": "HealthcheckData", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "HealthcheckData/PingCastleVersion[1]", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "HealthcheckData/PingCastleVersion[1]", + "type": "pingcastle-xml-node", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "xmlPath": "HealthcheckData/DomainFQDN[2]", + }, + "label": "DomainFQDN", + "normalizedValue": "synthetic.example", + "observedAt": undefined, + "originalValue": "synthetic.example", + "property": "pingcastle.xml.healthcheckdata.domainfqdn.2", + "sectionKey": "HealthcheckData", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "HealthcheckData/DomainFQDN[2]", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "HealthcheckData/DomainFQDN[2]", + "type": "pingcastle-xml-node", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "xmlPath": "HealthcheckData/GlobalScore[3]", + }, + "label": "GlobalScore", + "normalizedValue": 20, + "observedAt": undefined, + "originalValue": "20", + "property": "pingcastle.xml.healthcheckdata.globalscore.3", + "sectionKey": "HealthcheckData", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "HealthcheckData/GlobalScore[3]", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "HealthcheckData/GlobalScore[3]", + "type": "pingcastle-xml-node", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "xmlPath": "HealthcheckData/StaleObjectsScore[4]", + }, + "label": "StaleObjectsScore", + "normalizedValue": 20, + "observedAt": undefined, + "originalValue": "20", + "property": "pingcastle.xml.healthcheckdata.staleobjectsscore.4", + "sectionKey": "HealthcheckData", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "HealthcheckData/StaleObjectsScore[4]", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "HealthcheckData/StaleObjectsScore[4]", + "type": "pingcastle-xml-node", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "xmlPath": "HealthcheckData/PrivilegiedGroupScore[5]", + }, + "label": "PrivilegiedGroupScore", + "normalizedValue": 10, + "observedAt": undefined, + "originalValue": "10", + "property": "pingcastle.xml.healthcheckdata.privilegiedgroupscore.5", + "sectionKey": "HealthcheckData", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "HealthcheckData/PrivilegiedGroupScore[5]", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "HealthcheckData/PrivilegiedGroupScore[5]", + "type": "pingcastle-xml-node", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "xmlPath": "HealthcheckData/TrustScore[6]", + }, + "label": "TrustScore", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": "0", + "property": "pingcastle.xml.healthcheckdata.trustscore.6", + "sectionKey": "HealthcheckData", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "HealthcheckData/TrustScore[6]", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "HealthcheckData/TrustScore[6]", + "type": "pingcastle-xml-node", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "xmlPath": "HealthcheckData/AnomalyScore[7]", + }, + "label": "AnomalyScore", + "normalizedValue": 5, + "observedAt": undefined, + "originalValue": "5", + "property": "pingcastle.xml.healthcheckdata.anomalyscore.7", + "sectionKey": "HealthcheckData", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "HealthcheckData/AnomalyScore[7]", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "HealthcheckData/AnomalyScore[7]", + "type": "pingcastle-xml-node", + }, + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "valueType": "number", + "version": 1, + }, + ], + "preview": { + "mappingsToConfirm": 0, + "recordCount": 1, + "selected": true, + }, + "recognition": "pingcastle", + "reportSections": [ + { + "attributes": { + "representation": "xml-hierarchy", + }, + "detailLinks": undefined, + "label": "PingCastle HealthcheckData", + "modalDetails": undefined, + "ordinal": 0, + "parentSectionKey": undefined, + "prose": undefined, + "sectionKey": "HealthcheckData", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "HealthcheckData", + "rowIndex": undefined, + }, + "state": "observed", + "tables": [ + { + "headers": [ + "Path", + "Value", + ], + "name": "HealthcheckData context", + "rows": [ + [ + "HealthcheckData/PingCastleVersion[1]", + "3.5.1.33", + ], + [ + "HealthcheckData/DomainFQDN[2]", + "synthetic.example", + ], + [ + "HealthcheckData/GlobalScore[3]", + "20", + ], + [ + "HealthcheckData/StaleObjectsScore[4]", + "20", + ], + [ + "HealthcheckData/PrivilegiedGroupScore[5]", + "10", + ], + [ + "HealthcheckData/TrustScore[6]", + "0", + ], + [ + "HealthcheckData/AnomalyScore[7]", + "5", + ], + ], + }, + ], + "tool": { + "id": "pingcastle", + "name": "PingCastle", + }, + "version": 1, + }, + ], + "scores": [ + { + "assessed": 1, + "assessmentId": "assessment-protected-dummy-import", + "comparisonKey": undefined, + "contentSha256": "24b56411296eeea60bf5201b64a3828b39b85d88a6950f3d2c8c681804cb326c", + "deviations": 1, + "eligible": 1, + "evaluationScopeKey": "["[\\"S-Synthetic\\",\\"evaluated\\"]"]", + "importFileIds": [ + "file-protected-dummy-import-0", + ], + "issues": [ + "Evidence present, not scoreable", + "Scan identity or time not verified", + "Scanner version unavailable", + ], + "method": "unavailable", + "nativeProperty": undefined, + "nativeValue": undefined, + "observedAt": undefined, + "occurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + ], + "openOccurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + ], + "passed": 0, + "policy": { + "defaultWeight": 1, + "version": "1.0.0", + "weights": { + "cis-cat": 1, + "group-policy": 1, + "maester": 1, + "pingcastle": 1, + "purple-knight": 1, + }, + }, + "processingKey": "[null,"pingcastle","1.5.0","1.2.0","1.1.0","1.0.0"]", + "resultSignature": "["[\\"S-Synthetic\\",null,\\"fail\\"]"]", + "runIds": [ + "run-protected-dummy-import", + ], + "score": undefined, + "toolId": "pingcastle", + "toolName": "PingCastle", + }, + ], + "tables": [ + { + "columnCount": 3, + "hidden": undefined, + "index": 0, + "name": "HealthcheckData", + "rowCount": 29, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "", + }, + ], + "highlighted": false, + "index": 0, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/PingCastleVersion[1]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "3.5.1.33", + }, + ], + "highlighted": false, + "index": 1, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/DomainFQDN[2]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "synthetic.example", + }, + ], + "highlighted": false, + "index": 2, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/GlobalScore[3]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "20", + }, + ], + "highlighted": false, + "index": 3, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/StaleObjectsScore[4]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "20", + }, + ], + "highlighted": false, + "index": 4, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/PrivilegiedGroupScore[5]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "10", + }, + ], + "highlighted": false, + "index": 5, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/TrustScore[6]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "0", + }, + ], + "highlighted": false, + "index": 6, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/AnomalyScore[7]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "5", + }, + ], + "highlighted": false, + "index": 7, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/RiskRules[8]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "", + }, + ], + "highlighted": false, + "index": 8, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "", + }, + ], + "highlighted": false, + "index": 9, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/Points[1]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "20", + }, + ], + "highlighted": false, + "index": 10, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/Category[2]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "StaleObjects", + }, + ], + "highlighted": false, + "index": 11, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/Model[3]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "InactiveUserOrComputer", + }, + ], + "highlighted": false, + "index": 12, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/RiskId[4]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "S-Synthetic", + }, + ], + "highlighted": false, + "index": 13, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/Notice[5]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "Synthetic matched-rule evidence without directory data.", + }, + ], + "highlighted": false, + "index": 14, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/Rationale[6]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "Synthetic inactive privileged account", + }, + ], + "highlighted": false, + "index": 15, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/Details[7]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "", + }, + ], + "highlighted": false, + "index": 16, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/Details[7]/string[1]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "Synthetic detail without tenant identifiers.", + }, + ], + "highlighted": false, + "index": 17, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/ExtraDetails[8]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "", + }, + ], + "highlighted": false, + "index": 18, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/ExtraDetails[8]/ExtraDetail[1]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "", + }, + ], + "highlighted": false, + "index": 19, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/ExtraDetails[8]/ExtraDetail[1]/DetailItems[1]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "", + }, + ], + "highlighted": false, + "index": 20, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/ExtraDetails[8]/ExtraDetail[1]/DetailItems[1]/DetailItemBase[1]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "xsi:type="TextDetailItem"; xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "", + }, + ], + "highlighted": false, + "index": 21, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/ExtraDetails[8]/ExtraDetail[1]/DetailItems[1]/DetailItemBase[1]/Name[1]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "Synthetic setting", + }, + ], + "highlighted": false, + "index": 22, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/ExtraDetails[8]/ExtraDetail[1]/DetailItems[1]/DetailItemBase[1]/Value[2]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "Enabled", + }, + ], + "highlighted": false, + "index": 23, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/ExtraDetails[8]/ExtraDetail[1]/DetailItems[1]/DetailItemBase[2]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "xsi:type="ListDetailItem"; xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "", + }, + ], + "highlighted": false, + "index": 24, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/ExtraDetails[8]/ExtraDetail[1]/DetailItems[1]/DetailItemBase[2]/Name[1]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "Synthetic values", + }, + ], + "highlighted": false, + "index": 25, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/ExtraDetails[8]/ExtraDetail[1]/DetailItems[1]/DetailItemBase[2]/Values[2]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "", + }, + ], + "highlighted": false, + "index": 26, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/ExtraDetails[8]/ExtraDetail[1]/DetailItems[1]/DetailItemBase[2]/Values[2]/string[1]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "Alpha", + }, + ], + "highlighted": false, + "index": 27, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Pfad", + "value": "HealthcheckData/RiskRules[8]/HealthcheckRiskRule[1]/ExtraDetails[8]/ExtraDetail[1]/DetailItems[1]/DetailItemBase[2]/Values[2]/string[2]", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Attribut", + "value": "", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Text", + "value": "Beta", + }, + ], + "highlighted": false, + "index": 28, + }, + ], + }, + ], +} +`; + +exports[`protected scanner import contracts (synthetic data only) > 'purple-knight-html' retains recognition, preview and approved canonical results in browser and backend 1`] = ` +{ + "approved": { + "recordCount": 2, + "selected": true, + }, + "definitions": [ + { + "canonicalKey": "purple-knight:si900001", + "category": "Synthetic Category", + "description": "A synthetic description.", + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": "Apply the synthetic remediation.", + "title": "Synthetic privileged group exposure", + }, + { + "canonicalKey": "purple-knight:si900002", + "category": "Synthetic Category", + "description": "A second synthetic description.", + "frameworkReferences": [], + "identity": { + "durable": true, + "evidence": [ + "Werkzeugkennung und gemappte native Regel-ID sind vorhanden.", + ], + "strategy": "tool-rule-id", + "version": 1, + }, + "recommendation": undefined, + "title": "Synthetic secure configuration", + }, + ], + "evidence": [], + "findings": [ + { + "affectedAsset": { + "normalizedValue": "AD", + "originalValue": [ + "AD", + ], + }, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "SI900001", + "sourceFieldKeys": [ + "ShortName", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic privileged group exposure", + "sourceFieldKeys": [ + "Name", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "1.0.0", + "sourceFieldKeys": [ + "Version", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "AD", + "sourceFieldKeys": [ + "Targets", + ], + "targetRole": "asset", + }, + { + "decisionSource": "automatic", + "normalizedValue": "IOE Found", + "sourceFieldKeys": [ + "Status", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Runtime", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "10", + "sourceFieldKeys": [ + "Score", + ], + "targetRole": "score", + }, + { + "decisionSource": "automatic", + "normalizedValue": "High", + "sourceFieldKeys": [ + "Severity", + ], + "targetRole": "severity", + }, + { + "decisionSource": "automatic", + "normalizedValue": "10", + "sourceFieldKeys": [ + "Weight", + ], + "targetRole": "score", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic Category", + "sourceFieldKeys": [ + "CategoryID", + ], + "targetRole": "category", + }, + { + "decisionSource": "automatic", + "normalizedValue": "T0000", + "sourceFieldKeys": [ + "Framework_1", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Framework_2", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Framework_3", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Framework_4", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Framework_5", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Framework_6", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Framework_7", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Framework_8", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Framework_9", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Framework_10", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "automatic", + "normalizedValue": "A synthetic description.", + "sourceFieldKeys": [ + "Description", + ], + "targetRole": "description", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "LikelihoodOfCompromise", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic exposure found.", + "sourceFieldKeys": [ + "ResultMessage", + ], + "targetRole": "additional", + }, + { + "decisionSource": "user-confirmed", + "normalizedValue": "1", + "sourceFieldKeys": [ + "TotalResultsCount", + ], + "targetRole": "score", + }, + { + "decisionSource": "automatic", + "normalizedValue": "SI900001", + "sourceFieldKeys": [ + "Result", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Apply the synthetic remediation.", + "sourceFieldKeys": [ + "Remediation", + ], + "targetRole": "recommendation", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Completed", + "sourceFieldKeys": [ + "source-ResIndicator-State", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "F", + "sourceFieldKeys": [ + "source-ResIndicator-Grade", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "true", + "sourceFieldKeys": [ + "source-ResIndicator-IsEvaluated", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "false", + "sourceFieldKeys": [ + "source-ResIndicator-IsPassed", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "true", + "sourceFieldKeys": [ + "source-ResIndicator-IsFailed", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "IOE", + "sourceFieldKeys": [ + "source-ResIndicator-ExposureType", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "false", + "sourceFieldKeys": [ + "source-ResIndicator-IsFailedToRun", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "false", + "sourceFieldKeys": [ + "source-ResIndicator-IsAborted", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "00000000-0000-4000-8000-000000000001", + "sourceFieldKeys": [ + "source-ResIndicator-UUID", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "user-confirmed", + "normalizedValue": "1", + "sourceFieldKeys": [ + "source-ResIndicator-CategoryID", + ], + "targetRole": "score", + }, + { + "decisionSource": "automatic", + "normalizedValue": "[{"Name":"MITRE ATT&CK","Tags":["T0000"]}]", + "sourceFieldKeys": [ + "source-ResIndicator-SecurityFrameworks", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "true", + "sourceFieldKeys": [ + "source-ResIndicator-Selected", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "false", + "sourceFieldKeys": [ + "source-ResIndicator-CustomIndicator", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "["Synthetic"]", + "sourceFieldKeys": [ + "source-ResIndicator-DataSources", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Failed", + "sourceFieldKeys": [ + "source-ExecutionResult-Status", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "source-ExecutionResult-Filename", + ], + "targetRole": "additional", + }, + ], + "originalFields": [ + { + "columnIndex": 0, + "key": "ShortName", + "label": "ShortName", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator.ShortName", + "rawValue": "SI900001", + "value": "SI900001", + }, + { + "columnIndex": 1, + "key": "Name", + "label": "Name", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator.Name", + "rawValue": "Synthetic privileged group exposure", + "value": "Synthetic privileged group exposure", + }, + { + "columnIndex": 2, + "key": "Version", + "label": "SI version", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator.Version", + "rawValue": "1.0.0", + "value": "1.0.0", + }, + { + "columnIndex": 3, + "key": "Targets", + "label": "Target", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator.Targets", + "rawValue": "AD", + "value": [ + "AD", + ], + }, + { + "columnIndex": 4, + "key": "Status", + "label": "Status", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"]", + "rawValue": "IOE Found", + "value": { + "ExecutionResult": { + "Filename": null, + "Remediation": "

    Apply the synthetic remediation.

    ", + "ResultMessage": "Synthetic exposure found.", + "Score": 10, + "Status": "Failed", + }, + "IndicatorReportObjects": [ + [ + "Object: Synthetic value", + "EventTimestamp: 5/27/2026 12:37:57 PM", + "Ignored: False", + ], + ], + "ResIndicator": { + "CategoryID": 1, + "CustomIndicator": false, + "DataSources": [ + "Synthetic", + ], + "Description": "

    A synthetic description.

    ", + "ExposureType": "IOE", + "Grade": "F", + "IsAborted": false, + "IsEvaluated": true, + "IsFailed": true, + "IsFailedToRun": false, + "IsPassed": false, + "Name": "Synthetic privileged group exposure", + "SecurityFrameworks": [ + { + "Name": "MITRE ATT&CK", + "Tags": [ + "T0000", + ], + }, + ], + "Selected": true, + "Severity": "High", + "ShortName": "SI900001", + "State": "Completed", + "Targets": [ + "AD", + ], + "UUID": "00000000-0000-4000-8000-000000000001", + "Version": "1.0.0", + "Weight": 10, + }, + "TotalResultsCount": 1, + }, + }, + { + "columnIndex": 5, + "key": "Runtime", + "label": "Runtime", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator", + "rawValue": "", + "value": { + "CategoryID": 1, + "CustomIndicator": false, + "DataSources": [ + "Synthetic", + ], + "Description": "

    A synthetic description.

    ", + "ExposureType": "IOE", + "Grade": "F", + "IsAborted": false, + "IsEvaluated": true, + "IsFailed": true, + "IsFailedToRun": false, + "IsPassed": false, + "Name": "Synthetic privileged group exposure", + "SecurityFrameworks": [ + { + "Name": "MITRE ATT&CK", + "Tags": [ + "T0000", + ], + }, + ], + "Selected": true, + "Severity": "High", + "ShortName": "SI900001", + "State": "Completed", + "Targets": [ + "AD", + ], + "UUID": "00000000-0000-4000-8000-000000000001", + "Version": "1.0.0", + "Weight": 10, + }, + }, + { + "columnIndex": 6, + "key": "Score", + "label": "Score", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ExecutionResult.Score", + "rawValue": "10", + "value": 10, + }, + { + "columnIndex": 7, + "key": "Severity", + "label": "Severity", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator.Severity", + "rawValue": "High", + "value": "High", + }, + { + "columnIndex": 8, + "key": "Weight", + "label": "Weight", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator.Weight", + "rawValue": "10", + "value": 10, + }, + { + "columnIndex": 9, + "key": "CategoryID", + "label": "Category", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator", + "rawValue": "Synthetic Category", + "value": { + "CategoryID": 1, + "CustomIndicator": false, + "DataSources": [ + "Synthetic", + ], + "Description": "

    A synthetic description.

    ", + "ExposureType": "IOE", + "Grade": "F", + "IsAborted": false, + "IsEvaluated": true, + "IsFailed": true, + "IsFailedToRun": false, + "IsPassed": false, + "Name": "Synthetic privileged group exposure", + "SecurityFrameworks": [ + { + "Name": "MITRE ATT&CK", + "Tags": [ + "T0000", + ], + }, + ], + "Selected": true, + "Severity": "High", + "ShortName": "SI900001", + "State": "Completed", + "Targets": [ + "AD", + ], + "UUID": "00000000-0000-4000-8000-000000000001", + "Version": "1.0.0", + "Weight": 10, + }, + }, + { + "columnIndex": 10, + "key": "Framework_1", + "label": "MITRE ATT&CK_1", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator.SecurityFrameworks[0].Tags[0]", + "rawValue": "T0000", + "value": "T0000", + }, + { + "columnIndex": 11, + "key": "Framework_2", + "label": "MITRE ATT&CK_2", + "path": undefined, + "rawValue": "", + "value": null, + }, + { + "columnIndex": 12, + "key": "Framework_3", + "label": "MITRE ATT&CK_3", + "path": undefined, + "rawValue": "", + "value": null, + }, + { + "columnIndex": 13, + "key": "Framework_4", + "label": "MITRE ATT&CK_4", + "path": undefined, + "rawValue": "", + "value": null, + }, + { + "columnIndex": 14, + "key": "Framework_5", + "label": "ANSSI_1", + "path": undefined, + "rawValue": "", + "value": null, + }, + { + "columnIndex": 15, + "key": "Framework_6", + "label": "ANSSI_2", + "path": undefined, + "rawValue": "", + "value": null, + }, + { + "columnIndex": 16, + "key": "Framework_7", + "label": "ANSSI_3", + "path": undefined, + "rawValue": "", + "value": null, + }, + { + "columnIndex": 17, + "key": "Framework_8", + "label": "ANSSI_4", + "path": undefined, + "rawValue": "", + "value": null, + }, + { + "columnIndex": 18, + "key": "Framework_9", + "label": "MITRE D3FEND_1", + "path": undefined, + "rawValue": "", + "value": null, + }, + { + "columnIndex": 19, + "key": "Framework_10", + "label": "MITRE D3FEND_2", + "path": undefined, + "rawValue": "", + "value": null, + }, + { + "columnIndex": 20, + "key": "Description", + "label": "Description", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator.Description", + "rawValue": "A synthetic description.", + "value": "

    A synthetic description.

    ", + }, + { + "columnIndex": 21, + "key": "LikelihoodOfCompromise", + "label": "Likelihood of compromise", + "path": undefined, + "rawValue": "", + "value": null, + }, + { + "columnIndex": 22, + "key": "ResultMessage", + "label": "Result message", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ExecutionResult.ResultMessage", + "rawValue": "Synthetic exposure found.", + "value": "Synthetic exposure found.", + }, + { + "columnIndex": 23, + "key": "TotalResultsCount", + "label": "Number of results", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].TotalResultsCount", + "rawValue": "1", + "value": 1, + }, + { + "columnIndex": 24, + "key": "Result", + "label": "Result", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].IndicatorReportObjects", + "rawValue": "SI900001", + "value": [ + [ + "Object: Synthetic value", + "EventTimestamp: 5/27/2026 12:37:57 PM", + "Ignored: False", + ], + ], + }, + { + "columnIndex": 25, + "key": "Remediation", + "label": "Remediation", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ExecutionResult.Remediation", + "rawValue": "Apply the synthetic remediation.", + "value": "

    Apply the synthetic remediation.

    ", + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-State", + "label": "Source ResIndicator.State", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator["State"]", + "rawValue": "Completed", + "value": "Completed", + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-Grade", + "label": "Source ResIndicator.Grade", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator["Grade"]", + "rawValue": "F", + "value": "F", + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-IsEvaluated", + "label": "Source ResIndicator.IsEvaluated", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator["IsEvaluated"]", + "rawValue": "true", + "value": true, + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-IsPassed", + "label": "Source ResIndicator.IsPassed", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator["IsPassed"]", + "rawValue": "false", + "value": false, + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-IsFailed", + "label": "Source ResIndicator.IsFailed", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator["IsFailed"]", + "rawValue": "true", + "value": true, + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-ExposureType", + "label": "Source ResIndicator.ExposureType", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator["ExposureType"]", + "rawValue": "IOE", + "value": "IOE", + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-IsFailedToRun", + "label": "Source ResIndicator.IsFailedToRun", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator["IsFailedToRun"]", + "rawValue": "false", + "value": false, + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-IsAborted", + "label": "Source ResIndicator.IsAborted", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator["IsAborted"]", + "rawValue": "false", + "value": false, + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-UUID", + "label": "Source ResIndicator.UUID", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator["UUID"]", + "rawValue": "00000000-0000-4000-8000-000000000001", + "value": "00000000-0000-4000-8000-000000000001", + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-CategoryID", + "label": "Source ResIndicator.CategoryID", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator["CategoryID"]", + "rawValue": "1", + "value": 1, + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-SecurityFrameworks", + "label": "Source ResIndicator.SecurityFrameworks", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator["SecurityFrameworks"]", + "rawValue": "[{"Name":"MITRE ATT&CK","Tags":["T0000"]}]", + "value": [ + { + "Name": "MITRE ATT&CK", + "Tags": [ + "T0000", + ], + }, + ], + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-Selected", + "label": "Source ResIndicator.Selected", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator["Selected"]", + "rawValue": "true", + "value": true, + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-CustomIndicator", + "label": "Source ResIndicator.CustomIndicator", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator["CustomIndicator"]", + "rawValue": "false", + "value": false, + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-DataSources", + "label": "Source ResIndicator.DataSources", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ResIndicator["DataSources"]", + "rawValue": "["Synthetic"]", + "value": [ + "Synthetic", + ], + }, + { + "columnIndex": undefined, + "key": "source-ExecutionResult-Status", + "label": "Source ExecutionResult.Status", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ExecutionResult["Status"]", + "rawValue": "Failed", + "value": "Failed", + }, + { + "columnIndex": undefined, + "key": "source-ExecutionResult-Filename", + "label": "Source ExecutionResult.Filename", + "path": "$.Category_1["00000000-0000-4000-8000-000000000001"].ExecutionResult["Filename"]", + "rawValue": "", + "value": null, + }, + ], + "outcome": { + "normalizedValue": "fail", + "originalValue": { + "ExecutionResult": { + "Filename": null, + "Remediation": "

    Apply the synthetic remediation.

    ", + "ResultMessage": "Synthetic exposure found.", + "Score": 10, + "Status": "Failed", + }, + "IndicatorReportObjects": [ + [ + "Object: Synthetic value", + "EventTimestamp: 5/27/2026 12:37:57 PM", + "Ignored: False", + ], + ], + "ResIndicator": { + "CategoryID": 1, + "CustomIndicator": false, + "DataSources": [ + "Synthetic", + ], + "Description": "

    A synthetic description.

    ", + "ExposureType": "IOE", + "Grade": "F", + "IsAborted": false, + "IsEvaluated": true, + "IsFailed": true, + "IsFailedToRun": false, + "IsPassed": false, + "Name": "Synthetic privileged group exposure", + "SecurityFrameworks": [ + { + "Name": "MITRE ATT&CK", + "Tags": [ + "T0000", + ], + }, + ], + "Selected": true, + "Severity": "High", + "ShortName": "SI900001", + "State": "Completed", + "Targets": [ + "AD", + ], + "UUID": "00000000-0000-4000-8000-000000000001", + "Version": "1.0.0", + "Weight": 10, + }, + "TotalResultsCount": 1, + }, + }, + "score": { + "normalizedValue": 10, + "originalValue": 10, + }, + "severity": { + "normalizedValue": "high", + "originalValue": "High", + }, + "sourceIdentity": { + "canonicalRuleId": "SI900001", + "originalRuleId": "SI900001", + "originalTitle": "Synthetic privileged group exposure", + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Category_1["00000000-0000-4000-8000-000000000001"]", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "failed", + "originalValue": { + "ExecutionResult": { + "Filename": null, + "Remediation": "

    Apply the synthetic remediation.

    ", + "ResultMessage": "Synthetic exposure found.", + "Score": 10, + "Status": "Failed", + }, + "IndicatorReportObjects": [ + [ + "Object: Synthetic value", + "EventTimestamp: 5/27/2026 12:37:57 PM", + "Ignored: False", + ], + ], + "ResIndicator": { + "CategoryID": 1, + "CustomIndicator": false, + "DataSources": [ + "Synthetic", + ], + "Description": "

    A synthetic description.

    ", + "ExposureType": "IOE", + "Grade": "F", + "IsAborted": false, + "IsEvaluated": true, + "IsFailed": true, + "IsFailedToRun": false, + "IsPassed": false, + "Name": "Synthetic privileged group exposure", + "SecurityFrameworks": [ + { + "Name": "MITRE ATT&CK", + "Tags": [ + "T0000", + ], + }, + ], + "Selected": true, + "Severity": "High", + "ShortName": "SI900001", + "State": "Completed", + "Targets": [ + "AD", + ], + "UUID": "00000000-0000-4000-8000-000000000001", + "Version": "1.0.0", + "Weight": 10, + }, + "TotalResultsCount": 1, + }, + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": { + "ExecutionResult": { + "Filename": null, + "Remediation": "

    Apply the synthetic remediation.

    ", + "ResultMessage": "Synthetic exposure found.", + "Score": 10, + "Status": "Failed", + }, + "IndicatorReportObjects": [ + [ + "Object: Synthetic value", + "EventTimestamp: 5/27/2026 12:37:57 PM", + "Ignored: False", + ], + ], + "ResIndicator": { + "CategoryID": 1, + "CustomIndicator": false, + "DataSources": [ + "Synthetic", + ], + "Description": "

    A synthetic description.

    ", + "ExposureType": "IOE", + "Grade": "F", + "IsAborted": false, + "IsEvaluated": true, + "IsFailed": true, + "IsFailedToRun": false, + "IsPassed": false, + "Name": "Synthetic privileged group exposure", + "SecurityFrameworks": [ + { + "Name": "MITRE ATT&CK", + "Tags": [ + "T0000", + ], + }, + ], + "Selected": true, + "Severity": "High", + "ShortName": "SI900001", + "State": "Completed", + "Targets": [ + "AD", + ], + "UUID": "00000000-0000-4000-8000-000000000001", + "Version": "1.0.0", + "Weight": 10, + }, + "TotalResultsCount": 1, + }, + }, + }, + { + "affectedAsset": { + "normalizedValue": "AD", + "originalValue": [ + "AD", + ], + }, + "fieldMappings": [ + { + "decisionSource": "automatic", + "normalizedValue": "SI900002", + "sourceFieldKeys": [ + "ShortName", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic secure configuration", + "sourceFieldKeys": [ + "Name", + ], + "targetRole": "title", + }, + { + "decisionSource": "automatic", + "normalizedValue": "1.0.0", + "sourceFieldKeys": [ + "Version", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "automatic", + "normalizedValue": "AD", + "sourceFieldKeys": [ + "Targets", + ], + "targetRole": "asset", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Pass", + "sourceFieldKeys": [ + "Status", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Runtime", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "0", + "sourceFieldKeys": [ + "Score", + ], + "targetRole": "score", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Medium", + "sourceFieldKeys": [ + "Severity", + ], + "targetRole": "severity", + }, + { + "decisionSource": "automatic", + "normalizedValue": "5", + "sourceFieldKeys": [ + "Weight", + ], + "targetRole": "score", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic Category", + "sourceFieldKeys": [ + "CategoryID", + ], + "targetRole": "category", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Framework_1", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Framework_2", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Framework_3", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Framework_4", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Framework_5", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Framework_6", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Framework_7", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Framework_8", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Framework_9", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Framework_10", + ], + "targetRole": "framework-reference", + }, + { + "decisionSource": "automatic", + "normalizedValue": "A second synthetic description.", + "sourceFieldKeys": [ + "Description", + ], + "targetRole": "description", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "LikelihoodOfCompromise", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Synthetic check passed.", + "sourceFieldKeys": [ + "ResultMessage", + ], + "targetRole": "additional", + }, + { + "decisionSource": "user-confirmed", + "normalizedValue": "0", + "sourceFieldKeys": [ + "TotalResultsCount", + ], + "targetRole": "score", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Result", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "Remediation", + ], + "targetRole": "recommendation", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Completed", + "sourceFieldKeys": [ + "source-ResIndicator-State", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "A", + "sourceFieldKeys": [ + "source-ResIndicator-Grade", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "true", + "sourceFieldKeys": [ + "source-ResIndicator-IsEvaluated", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "true", + "sourceFieldKeys": [ + "source-ResIndicator-IsPassed", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "false", + "sourceFieldKeys": [ + "source-ResIndicator-IsFailed", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "None", + "sourceFieldKeys": [ + "source-ResIndicator-ExposureType", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "false", + "sourceFieldKeys": [ + "source-ResIndicator-IsFailedToRun", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "false", + "sourceFieldKeys": [ + "source-ResIndicator-IsAborted", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "00000000-0000-4000-8000-000000000002", + "sourceFieldKeys": [ + "source-ResIndicator-UUID", + ], + "targetRole": "rule-id", + }, + { + "decisionSource": "user-confirmed", + "normalizedValue": "1", + "sourceFieldKeys": [ + "source-ResIndicator-CategoryID", + ], + "targetRole": "score", + }, + { + "decisionSource": "automatic", + "normalizedValue": "[]", + "sourceFieldKeys": [ + "source-ResIndicator-SecurityFrameworks", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "true", + "sourceFieldKeys": [ + "source-ResIndicator-Selected", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "false", + "sourceFieldKeys": [ + "source-ResIndicator-CustomIndicator", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "["Synthetic"]", + "sourceFieldKeys": [ + "source-ResIndicator-DataSources", + ], + "targetRole": "additional", + }, + { + "decisionSource": "automatic", + "normalizedValue": "Pass", + "sourceFieldKeys": [ + "source-ExecutionResult-Status", + ], + "targetRole": "status", + }, + { + "decisionSource": "automatic", + "normalizedValue": "", + "sourceFieldKeys": [ + "source-ExecutionResult-Filename", + ], + "targetRole": "additional", + }, + ], + "originalFields": [ + { + "columnIndex": 0, + "key": "ShortName", + "label": "ShortName", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator.ShortName", + "rawValue": "SI900002", + "value": "SI900002", + }, + { + "columnIndex": 1, + "key": "Name", + "label": "Name", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator.Name", + "rawValue": "Synthetic secure configuration", + "value": "Synthetic secure configuration", + }, + { + "columnIndex": 2, + "key": "Version", + "label": "SI version", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator.Version", + "rawValue": "1.0.0", + "value": "1.0.0", + }, + { + "columnIndex": 3, + "key": "Targets", + "label": "Target", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator.Targets", + "rawValue": "AD", + "value": [ + "AD", + ], + }, + { + "columnIndex": 4, + "key": "Status", + "label": "Status", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"]", + "rawValue": "Pass", + "value": { + "ExecutionResult": { + "Filename": null, + "Remediation": "", + "ResultMessage": "Synthetic check passed.", + "Score": 0, + "Status": "Pass", + }, + "IndicatorReportObjects": [], + "ResIndicator": { + "CategoryID": 1, + "CustomIndicator": false, + "DataSources": [ + "Synthetic", + ], + "Description": "

    A second synthetic description.

    ", + "ExposureType": "None", + "Grade": "A", + "IsAborted": false, + "IsEvaluated": true, + "IsFailed": false, + "IsFailedToRun": false, + "IsPassed": true, + "Name": "Synthetic secure configuration", + "SecurityFrameworks": [], + "Selected": true, + "Severity": "Medium", + "ShortName": "SI900002", + "State": "Completed", + "Targets": [ + "AD", + ], + "UUID": "00000000-0000-4000-8000-000000000002", + "Version": "1.0.0", + "Weight": 5, + }, + "TotalResultsCount": 0, + }, + }, + { + "columnIndex": 5, + "key": "Runtime", + "label": "Runtime", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator", + "rawValue": "", + "value": { + "CategoryID": 1, + "CustomIndicator": false, + "DataSources": [ + "Synthetic", + ], + "Description": "

    A second synthetic description.

    ", + "ExposureType": "None", + "Grade": "A", + "IsAborted": false, + "IsEvaluated": true, + "IsFailed": false, + "IsFailedToRun": false, + "IsPassed": true, + "Name": "Synthetic secure configuration", + "SecurityFrameworks": [], + "Selected": true, + "Severity": "Medium", + "ShortName": "SI900002", + "State": "Completed", + "Targets": [ + "AD", + ], + "UUID": "00000000-0000-4000-8000-000000000002", + "Version": "1.0.0", + "Weight": 5, + }, + }, + { + "columnIndex": 6, + "key": "Score", + "label": "Score", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ExecutionResult.Score", + "rawValue": "0", + "value": 0, + }, + { + "columnIndex": 7, + "key": "Severity", + "label": "Severity", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator.Severity", + "rawValue": "Medium", + "value": "Medium", + }, + { + "columnIndex": 8, + "key": "Weight", + "label": "Weight", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator.Weight", + "rawValue": "5", + "value": 5, + }, + { + "columnIndex": 9, + "key": "CategoryID", + "label": "Category", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator", + "rawValue": "Synthetic Category", + "value": { + "CategoryID": 1, + "CustomIndicator": false, + "DataSources": [ + "Synthetic", + ], + "Description": "

    A second synthetic description.

    ", + "ExposureType": "None", + "Grade": "A", + "IsAborted": false, + "IsEvaluated": true, + "IsFailed": false, + "IsFailedToRun": false, + "IsPassed": true, + "Name": "Synthetic secure configuration", + "SecurityFrameworks": [], + "Selected": true, + "Severity": "Medium", + "ShortName": "SI900002", + "State": "Completed", + "Targets": [ + "AD", + ], + "UUID": "00000000-0000-4000-8000-000000000002", + "Version": "1.0.0", + "Weight": 5, + }, + }, + { + "columnIndex": 10, + "key": "Framework_1", + "label": "MITRE ATT&CK_1", + "path": undefined, + "rawValue": "", + "value": null, + }, + { + "columnIndex": 11, + "key": "Framework_2", + "label": "MITRE ATT&CK_2", + "path": undefined, + "rawValue": "", + "value": null, + }, + { + "columnIndex": 12, + "key": "Framework_3", + "label": "MITRE ATT&CK_3", + "path": undefined, + "rawValue": "", + "value": null, + }, + { + "columnIndex": 13, + "key": "Framework_4", + "label": "MITRE ATT&CK_4", + "path": undefined, + "rawValue": "", + "value": null, + }, + { + "columnIndex": 14, + "key": "Framework_5", + "label": "ANSSI_1", + "path": undefined, + "rawValue": "", + "value": null, + }, + { + "columnIndex": 15, + "key": "Framework_6", + "label": "ANSSI_2", + "path": undefined, + "rawValue": "", + "value": null, + }, + { + "columnIndex": 16, + "key": "Framework_7", + "label": "ANSSI_3", + "path": undefined, + "rawValue": "", + "value": null, + }, + { + "columnIndex": 17, + "key": "Framework_8", + "label": "ANSSI_4", + "path": undefined, + "rawValue": "", + "value": null, + }, + { + "columnIndex": 18, + "key": "Framework_9", + "label": "MITRE D3FEND_1", + "path": undefined, + "rawValue": "", + "value": null, + }, + { + "columnIndex": 19, + "key": "Framework_10", + "label": "MITRE D3FEND_2", + "path": undefined, + "rawValue": "", + "value": null, + }, + { + "columnIndex": 20, + "key": "Description", + "label": "Description", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator.Description", + "rawValue": "A second synthetic description.", + "value": "

    A second synthetic description.

    ", + }, + { + "columnIndex": 21, + "key": "LikelihoodOfCompromise", + "label": "Likelihood of compromise", + "path": undefined, + "rawValue": "", + "value": null, + }, + { + "columnIndex": 22, + "key": "ResultMessage", + "label": "Result message", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ExecutionResult.ResultMessage", + "rawValue": "Synthetic check passed.", + "value": "Synthetic check passed.", + }, + { + "columnIndex": 23, + "key": "TotalResultsCount", + "label": "Number of results", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].TotalResultsCount", + "rawValue": "0", + "value": 0, + }, + { + "columnIndex": 24, + "key": "Result", + "label": "Result", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].IndicatorReportObjects", + "rawValue": "", + "value": [], + }, + { + "columnIndex": 25, + "key": "Remediation", + "label": "Remediation", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ExecutionResult.Remediation", + "rawValue": "", + "value": "", + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-State", + "label": "Source ResIndicator.State", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator["State"]", + "rawValue": "Completed", + "value": "Completed", + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-Grade", + "label": "Source ResIndicator.Grade", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator["Grade"]", + "rawValue": "A", + "value": "A", + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-IsEvaluated", + "label": "Source ResIndicator.IsEvaluated", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator["IsEvaluated"]", + "rawValue": "true", + "value": true, + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-IsPassed", + "label": "Source ResIndicator.IsPassed", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator["IsPassed"]", + "rawValue": "true", + "value": true, + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-IsFailed", + "label": "Source ResIndicator.IsFailed", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator["IsFailed"]", + "rawValue": "false", + "value": false, + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-ExposureType", + "label": "Source ResIndicator.ExposureType", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator["ExposureType"]", + "rawValue": "None", + "value": "None", + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-IsFailedToRun", + "label": "Source ResIndicator.IsFailedToRun", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator["IsFailedToRun"]", + "rawValue": "false", + "value": false, + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-IsAborted", + "label": "Source ResIndicator.IsAborted", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator["IsAborted"]", + "rawValue": "false", + "value": false, + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-UUID", + "label": "Source ResIndicator.UUID", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator["UUID"]", + "rawValue": "00000000-0000-4000-8000-000000000002", + "value": "00000000-0000-4000-8000-000000000002", + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-CategoryID", + "label": "Source ResIndicator.CategoryID", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator["CategoryID"]", + "rawValue": "1", + "value": 1, + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-SecurityFrameworks", + "label": "Source ResIndicator.SecurityFrameworks", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator["SecurityFrameworks"]", + "rawValue": "[]", + "value": [], + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-Selected", + "label": "Source ResIndicator.Selected", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator["Selected"]", + "rawValue": "true", + "value": true, + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-CustomIndicator", + "label": "Source ResIndicator.CustomIndicator", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator["CustomIndicator"]", + "rawValue": "false", + "value": false, + }, + { + "columnIndex": undefined, + "key": "source-ResIndicator-DataSources", + "label": "Source ResIndicator.DataSources", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ResIndicator["DataSources"]", + "rawValue": "["Synthetic"]", + "value": [ + "Synthetic", + ], + }, + { + "columnIndex": undefined, + "key": "source-ExecutionResult-Status", + "label": "Source ExecutionResult.Status", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ExecutionResult["Status"]", + "rawValue": "Pass", + "value": "Pass", + }, + { + "columnIndex": undefined, + "key": "source-ExecutionResult-Filename", + "label": "Source ExecutionResult.Filename", + "path": "$.Category_1["00000000-0000-4000-8000-000000000002"].ExecutionResult["Filename"]", + "rawValue": "", + "value": null, + }, + ], + "outcome": { + "normalizedValue": "pass", + "originalValue": { + "ExecutionResult": { + "Filename": null, + "Remediation": "", + "ResultMessage": "Synthetic check passed.", + "Score": 0, + "Status": "Pass", + }, + "IndicatorReportObjects": [], + "ResIndicator": { + "CategoryID": 1, + "CustomIndicator": false, + "DataSources": [ + "Synthetic", + ], + "Description": "

    A second synthetic description.

    ", + "ExposureType": "None", + "Grade": "A", + "IsAborted": false, + "IsEvaluated": true, + "IsFailed": false, + "IsFailedToRun": false, + "IsPassed": true, + "Name": "Synthetic secure configuration", + "SecurityFrameworks": [], + "Selected": true, + "Severity": "Medium", + "ShortName": "SI900002", + "State": "Completed", + "Targets": [ + "AD", + ], + "UUID": "00000000-0000-4000-8000-000000000002", + "Version": "1.0.0", + "Weight": 5, + }, + "TotalResultsCount": 0, + }, + }, + "score": { + "normalizedValue": 0, + "originalValue": 0, + }, + "severity": { + "normalizedValue": "medium", + "originalValue": "Medium", + }, + "sourceIdentity": { + "canonicalRuleId": "SI900002", + "originalRuleId": "SI900002", + "originalTitle": "Synthetic secure configuration", + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.Category_1["00000000-0000-4000-8000-000000000002"]", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "passed", + "originalValue": { + "ExecutionResult": { + "Filename": null, + "Remediation": "", + "ResultMessage": "Synthetic check passed.", + "Score": 0, + "Status": "Pass", + }, + "IndicatorReportObjects": [], + "ResIndicator": { + "CategoryID": 1, + "CustomIndicator": false, + "DataSources": [ + "Synthetic", + ], + "Description": "

    A second synthetic description.

    ", + "ExposureType": "None", + "Grade": "A", + "IsAborted": false, + "IsEvaluated": true, + "IsFailed": false, + "IsFailedToRun": false, + "IsPassed": true, + "Name": "Synthetic secure configuration", + "SecurityFrameworks": [], + "Selected": true, + "Severity": "Medium", + "ShortName": "SI900002", + "State": "Completed", + "Targets": [ + "AD", + ], + "UUID": "00000000-0000-4000-8000-000000000002", + "Version": "1.0.0", + "Weight": 5, + }, + "TotalResultsCount": 0, + }, + }, + "technicalExecutionState": { + "normalizedValue": "completed", + "originalValue": { + "ExecutionResult": { + "Filename": null, + "Remediation": "", + "ResultMessage": "Synthetic check passed.", + "Score": 0, + "Status": "Pass", + }, + "IndicatorReportObjects": [], + "ResIndicator": { + "CategoryID": 1, + "CustomIndicator": false, + "DataSources": [ + "Synthetic", + ], + "Description": "

    A second synthetic description.

    ", + "ExposureType": "None", + "Grade": "A", + "IsAborted": false, + "IsEvaluated": true, + "IsFailed": false, + "IsFailedToRun": false, + "IsPassed": true, + "Name": "Synthetic secure configuration", + "SecurityFrameworks": [], + "Selected": true, + "Severity": "Medium", + "ShortName": "SI900002", + "State": "Completed", + "Targets": [ + "AD", + ], + "UUID": "00000000-0000-4000-8000-000000000002", + "Version": "1.0.0", + "Weight": 5, + }, + "TotalResultsCount": 0, + }, + }, + }, + ], + "observations": [ + { + "attributes": { + "sourceField": "IndicatorsFiles", + }, + "label": "IndicatorsFiles", + "normalizedValue": [ + "synthetic-indicator.json", + ], + "observedAt": undefined, + "originalValue": [ + "synthetic-indicator.json", + ], + "property": "purple_knight.source.IndicatorsFiles", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.reportJSON.IndicatorsFiles", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "$.reportJSON", + "type": "purple-knight-report", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "object", + "version": 1, + }, + { + "attributes": { + "sourceField": "reportResultsList", + }, + "label": "reportResultsList", + "normalizedValue": [ + { + "ADType": "AD", + "IsCustom": false, + "ReportCategories": [ + { + "Category": { + "ID": 1, + "Name": "Synthetic Category", + }, + "Indicators": [ + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000002", + ], + }, + ], + "ReportResults": { + "TotalCount": 2, + "TotalScore": 50, + }, + }, + ], + "observedAt": undefined, + "originalValue": [ + { + "ADType": "AD", + "IsCustom": false, + "ReportCategories": [ + { + "Category": { + "ID": 1, + "Name": "Synthetic Category", + }, + "Indicators": [ + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000002", + ], + }, + ], + "ReportResults": { + "TotalCount": 2, + "TotalScore": 50, + }, + }, + ], + "property": "purple_knight.source.reportResultsList", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.reportJSON.reportResultsList", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "$.reportJSON", + "type": "purple-knight-report", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "object", + "version": 1, + }, + { + "attributes": { + "sourceField": "GeneralConfig", + }, + "label": "GeneralConfig", + "normalizedValue": { + "Categories": [ + { + "Description": "Synthetic only", + "ID": 1, + "Name": "Synthetic Category", + "Weight": 1, + }, + ], + }, + "observedAt": undefined, + "originalValue": { + "Categories": [ + { + "Description": "Synthetic only", + "ID": 1, + "Name": "Synthetic Category", + "Weight": 1, + }, + ], + }, + "property": "purple_knight.source.GeneralConfig", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.reportJSON.GeneralConfig", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "$.reportJSON", + "type": "purple-knight-report", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "object", + "version": 1, + }, + { + "attributes": { + "sourceField": "AppVersion", + }, + "label": "AppVersion", + "normalizedValue": "synthetic", + "observedAt": undefined, + "originalValue": "synthetic", + "property": "purple_knight.source.AppVersion", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.reportJSON.AppVersion", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "$.reportJSON", + "type": "purple-knight-report", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "sourceField": "ReportName", + }, + "label": "ReportName", + "normalizedValue": "Purple Knight synthetic report", + "observedAt": undefined, + "originalValue": "Purple Knight synthetic report", + "property": "purple_knight.source.ReportName", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.reportJSON.ReportName", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "$.reportJSON", + "type": "purple-knight-report", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "Tool version", + }, + "label": "Tool version", + "normalizedValue": "synthetic", + "observedAt": undefined, + "originalValue": "synthetic", + "property": "purple_knight.tool_version", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.reportJSON.AppVersion", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "security-assessment.synthetic.html", + "label": "Purple Knight assessment", + "type": "purple-knight-assessment", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "AD: Security posture score", + }, + "label": "Security posture score", + "normalizedValue": 50, + "observedAt": undefined, + "originalValue": 50, + "property": "purple_knight.ad.security_posture_score", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.reportJSON.reportResultsList[0].ReportResults.TotalScore", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "security-assessment.synthetic.html", + "label": "Purple Knight assessment", + "type": "purple-knight-assessment", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "AD: Total indicators", + }, + "label": "Total indicators", + "normalizedValue": 2, + "observedAt": undefined, + "originalValue": 2, + "property": "purple_knight.ad.total_indicators", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.reportJSON.reportResultsList[0].ReportResults.TotalCount", + "rowIndex": undefined, + }, + "state": "observed", + "subject": { + "identity": "security-assessment.synthetic.html", + "label": "Purple Knight assessment", + "type": "purple-knight-assessment", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "number", + "version": 1, + }, + ], + "preview": { + "mappingsToConfirm": 0, + "recordCount": 2, + "selected": true, + }, + "recognition": "purple-knight", + "reportSections": [ + { + "attributes": { + "kind": "purple-knight-report-context", + }, + "detailLinks": undefined, + "label": "PurpleKnight assessment summary", + "modalDetails": undefined, + "ordinal": 0, + "parentSectionKey": undefined, + "prose": undefined, + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.reportJSON", + "rowIndex": undefined, + }, + "state": "observed", + "tables": [ + { + "headers": [ + "Property", + "Original value", + ], + "name": "Report metadata", + "rows": [ + [ + "IndicatorsFiles", + [ + "synthetic-indicator.json", + ], + ], + [ + "reportResultsList", + [ + { + "ADType": "AD", + "IsCustom": false, + "ReportCategories": [ + { + "Category": { + "ID": 1, + "Name": "Synthetic Category", + }, + "Indicators": [ + "00000000-0000-4000-8000-000000000001", + "00000000-0000-4000-8000-000000000002", + ], + }, + ], + "ReportResults": { + "TotalCount": 2, + "TotalScore": 50, + }, + }, + ], + ], + [ + "GeneralConfig", + { + "Categories": [ + { + "Description": "Synthetic only", + "ID": 1, + "Name": "Synthetic Category", + "Weight": 1, + }, + ], + }, + ], + [ + "AppVersion", + "synthetic", + ], + [ + "ReportName", + "Purple Knight synthetic report", + ], + ], + }, + ], + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "version": 1, + }, + ], + "scores": [ + { + "assessed": 2, + "assessmentId": "assessment-protected-dummy-import", + "comparisonKey": undefined, + "contentSha256": "116baa259a13aa48895d8fa1e75139466dd7cd83980b012fe6d2df13702a47fd", + "deviations": 1, + "eligible": 2, + "evaluationScopeKey": "["[\\"SI900001\\",\\"evaluated\\"]","[\\"SI900002\\",\\"evaluated\\"]"]", + "importFileIds": [ + "file-protected-dummy-import-0", + ], + "issues": [ + "Scan identity or time not verified", + "Scanner version unavailable", + ], + "method": "native", + "nativeProperty": "purple_knight.ad.security_posture_score", + "nativeValue": 50, + "observedAt": undefined, + "occurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + "occurrence-protected-dummy-import-0-1", + ], + "openOccurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + ], + "passed": 1, + "policy": { + "defaultWeight": 1, + "version": "1.0.0", + "weights": { + "cis-cat": 1, + "group-policy": 1, + "maester": 1, + "pingcastle": 1, + "purple-knight": 1, + }, + }, + "processingKey": "[null,"purple-knight","1.4.0","1.1.0","1.1.0","1.0.0"]", + "resultSignature": "["[\\"SI900001\\",\\"AD\\",\\"fail\\"]","[\\"SI900002\\",\\"AD\\",\\"pass\\"]"]", + "runIds": [ + "run-protected-dummy-import", + ], + "score": 50, + "toolId": "purple-knight", + "toolName": "Purple Knight for Active Directory", + }, + ], + "tables": [ + { + "columnCount": 3, + "hidden": undefined, + "index": 1, + "name": "SI900001", + "rowCount": 1, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Object", + "value": "Synthetic value", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "EventTimestamp", + "value": "5/27/2026 12:37:57 PM", + }, + { + "columnIndex": 2, + "key": "column-2", + "label": "Ignored", + "value": "False", + }, + ], + "highlighted": false, + "index": 0, + }, + ], + }, + { + "columnCount": 0, + "hidden": undefined, + "index": 0, + "name": "HTML", + "rowCount": 0, + "rows": [], + }, + ], +} +`; + +exports[`protected scanner import contracts (synthetic data only) > 'purple-knight-json' retains recognition, preview and approved canonical results in browser and backend 1`] = ` +{ + "approved": { + "recordCount": 1, + "selected": true, + }, + "definitions": [ + { + "canonicalKey": "unresolved:protected-dummy-import:0:candidate-view-json-root-0", + "category": undefined, + "description": "[{"name":"Assessment","rows":[["Metric","Value"],["Score","72"]]},{"name":"Indicator Results","rows":[["Name","Short Name","Severity","Weight","Category","Result","Results"],["Synthetic indicator A","SYN-001","High","10","Synthetic","IOEs found","SYN-001_results.csv"],["Synthetic indicator B","SYN-002","Low","2","Synthetic","Passed",""]]}]", + "frameworkReferences": [], + "identity": { + "durable": false, + "evidence": [ + "Keine stabile native Regel-ID oder bestätigte lokale Mappingregel vorhanden.", + ], + "strategy": "unresolved", + "version": 1, + }, + "recommendation": undefined, + "title": "Synthetic Purple Knight-like test data. No real assessment data.", + }, + ], + "evidence": [ + { + "finding": { + "canonicalRuleId": undefined, + "originalRuleId": undefined, + "originalTitle": "Synthetic Purple Knight-like test data. No real assessment data.", + "tool": { + "id": "purple-knight", + "name": "PurpleKnight", + }, + }, + "kind": "text", + "sourceFieldKeys": [ + "notice", + ], + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$.notice", + "rowIndex": undefined, + }, + "value": "Synthetic Purple Knight-like test data. No real assessment data.", + }, + ], + "findings": [ + { + "affectedAsset": undefined, + "fieldMappings": [ + { + "decisionSource": "user-confirmed", + "normalizedValue": "Synthetic Purple Knight-like test data. No real assessment data.", + "sourceFieldKeys": [ + "notice", + ], + "targetRole": "evidence", + }, + { + "decisionSource": "user-confirmed", + "normalizedValue": "[{"name":"Assessment","rows":[["Metric","Value"],["Score","72"]]},{"name":"Indicator Results","rows":[["Name","Short Name","Severity","Weight","Category","Result","Results"],["Synthetic indicator A","SYN-001","High","10","Synthetic","IOEs found","SYN-001_results.csv"],["Synthetic indicator B","SYN-002","Low","2","Synthetic","Passed",""]]}]", + "sourceFieldKeys": [ + "sheets", + ], + "targetRole": "description", + }, + ], + "originalFields": [ + { + "columnIndex": undefined, + "key": "notice", + "label": "notice", + "path": "$.notice", + "rawValue": "Synthetic Purple Knight-like test data. No real assessment data.", + "value": "Synthetic Purple Knight-like test data. No real assessment data.", + }, + { + "columnIndex": undefined, + "key": "sheets", + "label": "sheets", + "path": "$.sheets", + "rawValue": "[{"name":"Assessment","rows":[["Metric","Value"],["Score","72"]]},{"name":"Indicator Results","rows":[["Name","Short Name","Severity","Weight","Category","Result","Results"],["Synthetic indicator A","SYN-001","High","10","Synthetic","IOEs found","SYN-001_results.csv"],["Synthetic indicator B","SYN-002","Low","2","Synthetic","Passed",""]]}]", + "value": [ + { + "name": "Assessment", + "rows": [ + [ + "Metric", + "Value", + ], + [ + "Score", + "72", + ], + ], + }, + { + "name": "Indicator Results", + "rows": [ + [ + "Name", + "Short Name", + "Severity", + "Weight", + "Category", + "Result", + "Results", + ], + [ + "Synthetic indicator A", + "SYN-001", + "High", + "10", + "Synthetic", + "IOEs found", + "SYN-001_results.csv", + ], + [ + "Synthetic indicator B", + "SYN-002", + "Low", + "2", + "Synthetic", + "Passed", + "", + ], + ], + }, + ], + }, + ], + "outcome": { + "normalizedValue": "unknown", + "originalValue": "", + }, + "score": undefined, + "severity": { + "normalizedValue": "unknown", + "originalValue": "", + }, + "sourceIdentity": { + "canonicalRuleId": undefined, + "originalRuleId": undefined, + "originalTitle": "Synthetic Purple Knight-like test data. No real assessment data.", + "tool": { + "id": "purple-knight", + "name": "PurpleKnight", + }, + }, + "sourcePosition": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": "$", + "rowIndex": undefined, + }, + "status": { + "normalizedValue": "unknown", + "originalValue": "", + }, + "technicalExecutionState": { + "normalizedValue": "unknown", + "originalValue": "", + }, + }, + ], + "observations": [], + "preview": { + "mappingsToConfirm": 0, + "recordCount": 1, + "selected": true, + }, + "recognition": "purple-knight", + "reportSections": [], + "scores": [ + { + "assessed": 0, + "assessmentId": "assessment-protected-dummy-import", + "comparisonKey": undefined, + "contentSha256": "887200db4e0ec706b74b18ce61dbc59db414ad783c66c919e3e937f0be75a49e", + "deviations": 0, + "eligible": 1, + "evaluationScopeKey": undefined, + "importFileIds": [ + "file-protected-dummy-import-0", + ], + "issues": [ + "Evidence present, not scoreable", + "Incomplete assessment", + "Scan identity or time not verified", + "Scanner version unavailable", + ], + "method": "unavailable", + "nativeProperty": undefined, + "nativeValue": undefined, + "observedAt": undefined, + "occurrenceIds": [ + "occurrence-protected-dummy-import-0-0", + ], + "openOccurrenceIds": [], + "passed": 0, + "policy": { + "defaultWeight": 1, + "version": "1.0.0", + "weights": { + "cis-cat": 1, + "group-policy": 1, + "maester": 1, + "pingcastle": 1, + "purple-knight": 1, + }, + }, + "processingKey": "[null,"purple-knight","1.4.0","1.1.0","1.1.0","1.0.0"]", + "resultSignature": undefined, + "runIds": [ + "run-protected-dummy-import", + ], + "score": undefined, + "toolId": "purple-knight", + "toolName": "PurpleKnight", + }, + ], + "tables": [ + { + "columnCount": 2, + "hidden": undefined, + "index": 0, + "name": "Object", + "rowCount": 2, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Path", + "value": "notice", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Value", + "value": "Synthetic Purple Knight-like test data. No real assessment data.", + }, + ], + "highlighted": false, + "index": 0, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Path", + "value": "sheets", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Value", + "value": "[{"name":"Assessment","rows":[["Metric","Value"],["Score","72"]]},{"name":"Indicator Results","rows":[["Name","Short Name","Severity","Weight","Category","Result","Results"],["Synthetic indicator A","SYN-001","High","10","Synthetic","IOEs found","SYN-001_results.csv"],["Synthetic indicator B","SYN-002","Low","2","Synthetic","Passed",""]]}]", + }, + ], + "highlighted": false, + "index": 1, + }, + ], + }, + ], +} +`; + +exports[`protected scanner import contracts (synthetic data only) > 'purple-knight-summary' retains recognition, preview and approved canonical results in browser and backend 1`] = ` +{ + "approved": { + "recordCount": 0, + "selected": true, + }, + "definitions": [], + "evidence": [], + "findings": [], + "observations": [ + { + "attributes": { + "sourceMetric": "Tool edition", + "sourceTableIndex": 0, + }, + "label": "Tool edition", + "normalizedValue": "Community", + "observedAt": undefined, + "originalValue": "Community", + "property": "purple_knight.tool_edition", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 0, + }, + "state": "observed", + "subject": { + "identity": "assessment-summary.synthetic.csv", + "label": "Purple Knight assessment", + "type": "purple-knight-assessment", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "Generated date", + "sourceTableIndex": 0, + }, + "label": "Report generation date", + "normalizedValue": "2026-01-15 09:30", + "observedAt": undefined, + "originalValue": "2026-01-15 09:30", + "property": "report.generated_at", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 1, + }, + "state": "observed", + "subject": { + "type": "active-directory-environment", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "datetime", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "Selected indicators", + "sourceTableIndex": 0, + }, + "label": "Selected indicators", + "normalizedValue": 120, + "observedAt": undefined, + "originalValue": "120", + "property": "purple_knight.selected_indicators", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 2, + }, + "state": "observed", + "subject": { + "identity": "assessment-summary.synthetic.csv", + "label": "Purple Knight assessment", + "type": "purple-knight-assessment", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "AD: scan duration", + "sourceTableIndex": 0, + }, + "label": "scan duration", + "normalizedValue": "00:00:21", + "observedAt": undefined, + "originalValue": "00:00:21", + "property": "purple_knight.ad.scan_duration", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 3, + }, + "state": "observed", + "subject": { + "identity": "assessment-summary.synthetic.csv", + "label": "Purple Knight assessment", + "type": "purple-knight-assessment", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "AD: forest name", + "sourceTableIndex": 0, + }, + "label": "Forest name", + "normalizedValue": "synthetic.example", + "observedAt": undefined, + "originalValue": "synthetic.example", + "property": "forest.name", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 4, + }, + "state": "observed", + "subject": { + "type": "active-directory-environment", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "AD: number of domains", + "sourceTableIndex": 0, + }, + "label": "Number of domains", + "normalizedValue": 2, + "observedAt": undefined, + "originalValue": "2", + "property": "domain.count", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 5, + }, + "state": "observed", + "subject": { + "type": "active-directory-environment", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "AD: requested by", + "sourceTableIndex": 0, + }, + "label": "requested by", + "normalizedValue": "SYNTHETIC\\Administrator", + "observedAt": undefined, + "originalValue": "SYNTHETIC\\Administrator", + "property": "purple_knight.ad.requested_by", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 6, + }, + "state": "observed", + "subject": { + "identity": "assessment-summary.synthetic.csv", + "label": "Purple Knight assessment", + "type": "purple-knight-assessment", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "string", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "AD: security posture score", + "sourceTableIndex": 0, + }, + "label": "security posture score", + "normalizedValue": 71, + "observedAt": undefined, + "originalValue": "71", + "property": "purple_knight.ad.security_posture_score", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 7, + }, + "state": "observed", + "subject": { + "identity": "assessment-summary.synthetic.csv", + "label": "Purple Knight assessment", + "type": "purple-knight-assessment", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "AD category score: AD Delegation", + "sourceTableIndex": 0, + }, + "label": "AD Delegation", + "normalizedValue": 84, + "observedAt": undefined, + "originalValue": "84", + "property": "purple_knight.ad.category_score.ad_delegation", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 8, + }, + "state": "observed", + "subject": { + "identity": "ad_delegation", + "label": "AD Delegation", + "type": "security-domain", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "AD category score: Account Security", + "sourceTableIndex": 0, + }, + "label": "Account Security", + "normalizedValue": 79, + "observedAt": undefined, + "originalValue": "79", + "property": "purple_knight.ad.category_score.account_security", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 9, + }, + "state": "observed", + "subject": { + "identity": "account_security", + "label": "Account Security", + "type": "security-domain", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "AD category score: AD Infrastructure Security", + "sourceTableIndex": 0, + }, + "label": "AD Infrastructure Security", + "normalizedValue": 92, + "observedAt": undefined, + "originalValue": "92", + "property": "purple_knight.ad.category_score.ad_infrastructure_security", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 10, + }, + "state": "observed", + "subject": { + "identity": "ad_infrastructure_security", + "label": "AD Infrastructure Security", + "type": "security-domain", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "AD category score: Group Policy Security", + "sourceTableIndex": 0, + }, + "label": "Group Policy Security", + "normalizedValue": 97, + "observedAt": undefined, + "originalValue": "97", + "property": "purple_knight.ad.category_score.group_policy_security", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 11, + }, + "state": "observed", + "subject": { + "identity": "group_policy_security", + "label": "Group Policy Security", + "type": "security-domain", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "AD category score: Kerberos Security", + "sourceTableIndex": 0, + }, + "label": "Kerberos Security", + "normalizedValue": 95, + "observedAt": undefined, + "originalValue": "95", + "property": "purple_knight.ad.category_score.kerberos_security", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 12, + }, + "state": "observed", + "subject": { + "identity": "kerberos_security", + "label": "Kerberos Security", + "type": "security-domain", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "AD category score: Hybrid", + "sourceTableIndex": 0, + }, + "label": "Hybrid", + "normalizedValue": 100, + "observedAt": undefined, + "originalValue": "100", + "property": "purple_knight.ad.category_score.hybrid", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 13, + }, + "state": "observed", + "subject": { + "identity": "hybrid", + "label": "Hybrid", + "type": "security-domain", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "AD: Evaluated", + "sourceTableIndex": 0, + }, + "label": "Evaluated", + "normalizedValue": 117, + "observedAt": undefined, + "originalValue": "117", + "property": "purple_knight.ad.evaluated", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 14, + }, + "state": "observed", + "subject": { + "identity": "assessment-summary.synthetic.csv", + "label": "Purple Knight assessment", + "type": "purple-knight-assessment", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "AD: IOEs found", + "sourceTableIndex": 0, + }, + "label": "IOEs found", + "normalizedValue": 17, + "observedAt": undefined, + "originalValue": "17", + "property": "purple_knight.ad.ioes_found", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 15, + }, + "state": "observed", + "subject": { + "identity": "assessment-summary.synthetic.csv", + "label": "Purple Knight assessment", + "type": "purple-knight-assessment", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "AD: Passed", + "sourceTableIndex": 0, + }, + "label": "Passed", + "normalizedValue": 100, + "observedAt": undefined, + "originalValue": "100", + "property": "purple_knight.ad.passed", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 16, + }, + "state": "observed", + "subject": { + "identity": "assessment-summary.synthetic.csv", + "label": "Purple Knight assessment", + "type": "purple-knight-assessment", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "AD: Failed to run", + "sourceTableIndex": 0, + }, + "label": "Failed to run", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": "0", + "property": "purple_knight.ad.failed_to_run", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 17, + }, + "state": "observed", + "subject": { + "identity": "assessment-summary.synthetic.csv", + "label": "Purple Knight assessment", + "type": "purple-knight-assessment", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "AD: Canceled", + "sourceTableIndex": 0, + }, + "label": "Canceled", + "normalizedValue": 0, + "observedAt": undefined, + "originalValue": "0", + "property": "purple_knight.ad.canceled", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 18, + }, + "state": "observed", + "subject": { + "identity": "assessment-summary.synthetic.csv", + "label": "Purple Knight assessment", + "type": "purple-knight-assessment", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "AD: Not selected", + "sourceTableIndex": 0, + }, + "label": "Not selected", + "normalizedValue": 1, + "observedAt": undefined, + "originalValue": "1", + "property": "purple_knight.ad.not_selected", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 19, + }, + "state": "observed", + "subject": { + "identity": "assessment-summary.synthetic.csv", + "label": "Purple Knight assessment", + "type": "purple-knight-assessment", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "number", + "version": 1, + }, + { + "attributes": { + "sourceMetric": "AD: Not relevant", + "sourceTableIndex": 0, + }, + "label": "Not relevant", + "normalizedValue": 2, + "observedAt": undefined, + "originalValue": "2", + "property": "purple_knight.ad.not_relevant", + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": 1, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": 20, + }, + "state": "observed", + "subject": { + "identity": "assessment-summary.synthetic.csv", + "label": "Purple Knight assessment", + "type": "purple-knight-assessment", + }, + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "valueType": "number", + "version": 1, + }, + ], + "preview": { + "mappingsToConfirm": 0, + "recordCount": 0, + "selected": true, + }, + "recognition": "purple-knight", + "reportSections": [ + { + "attributes": { + "metricCount": 20, + "sourceTableIndex": 0, + }, + "detailLinks": undefined, + "label": "Purple Knight assessment summary", + "modalDetails": undefined, + "ordinal": 0, + "parentSectionKey": undefined, + "prose": undefined, + "sectionKey": "purple-knight-assessment-summary", + "source": { + "cellReference": undefined, + "columnIndex": undefined, + "elementSelector": undefined, + "objectPath": undefined, + "rowIndex": undefined, + }, + "state": "observed", + "tables": [ + { + "headers": [ + "Metric", + "Value", + ], + "name": "Assessment summary", + "rows": [ + [ + "Generated date", + "2026-01-15 09:30", + ], + [ + "Selected indicators", + "120", + ], + [ + "AD: scan duration", + "00:00:21", + ], + [ + "AD: forest name", + "synthetic.example", + ], + [ + "AD: number of domains", + "2", + ], + [ + "AD: requested by", + "SYNTHETIC\\Administrator", + ], + [ + "AD: security posture score", + "71", + ], + [ + "AD category score: AD Delegation", + "84", + ], + [ + "AD category score: Account Security", + "79", + ], + [ + "AD category score: AD Infrastructure Security", + "92", + ], + [ + "AD category score: Group Policy Security", + "97", + ], + [ + "AD category score: Kerberos Security", + "95", + ], + [ + "AD category score: Hybrid", + "100", + ], + [ + "AD: Evaluated", + "117", + ], + [ + "AD: IOEs found", + "17", + ], + [ + "AD: Passed", + "100", + ], + [ + "AD: Failed to run", + "0", + ], + [ + "AD: Canceled", + "0", + ], + [ + "AD: Not selected", + "1", + ], + [ + "AD: Not relevant", + "2", + ], + ], + "sourceTableIndex": 0, + }, + ], + "tool": { + "id": "purple-knight", + "name": "Purple Knight for Active Directory", + }, + "version": 1, + }, + ], + "scores": [ + { + "assessed": 0, + "assessmentId": "assessment-protected-dummy-import", + "comparisonKey": "["purple-knight","synthetic.example","default"]", + "contentSha256": "bd991dcc3abdaf374e0ace8d9f4311d07b2e4b8250945e07ab89fd8f1a1ddda0", + "deviations": 0, + "eligible": 0, + "evaluationScopeKey": undefined, + "importFileIds": [ + "file-protected-dummy-import-0", + ], + "issues": [ + "Scan identity or time not verified", + "Scanner version unavailable", + ], + "method": "native", + "nativeProperty": "purple_knight.ad.security_posture_score", + "nativeValue": 71, + "observedAt": undefined, + "occurrenceIds": [], + "openOccurrenceIds": [], + "passed": 0, + "policy": { + "defaultWeight": 1, + "version": "1.0.0", + "weights": { + "cis-cat": 1, + "group-policy": 1, + "maester": 1, + "pingcastle": 1, + "purple-knight": 1, + }, + }, + "processingKey": "[null,"purple-knight","1.4.0","1.1.0","1.1.0","1.0.0"]", + "resultSignature": undefined, + "runIds": [ + "run-protected-dummy-import", + ], + "score": 71, + "toolId": "purple-knight", + "toolName": "Purple Knight for Active Directory", + }, + ], + "tables": [ + { + "columnCount": 2, + "hidden": undefined, + "index": 0, + "name": "CSV", + "rowCount": 20, + "rows": [ + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Tool edition", + "value": "Generated date", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Community", + "value": "2026-01-15 09:30", + }, + ], + "highlighted": false, + "index": 0, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Tool edition", + "value": "Selected indicators", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Community", + "value": "120", + }, + ], + "highlighted": false, + "index": 1, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Tool edition", + "value": "AD: scan duration", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Community", + "value": "00:00:21", + }, + ], + "highlighted": false, + "index": 2, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Tool edition", + "value": "AD: forest name", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Community", + "value": "synthetic.example", + }, + ], + "highlighted": false, + "index": 3, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Tool edition", + "value": "AD: number of domains", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Community", + "value": "2", + }, + ], + "highlighted": false, + "index": 4, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Tool edition", + "value": "AD: requested by", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Community", + "value": "SYNTHETIC\\Administrator", + }, + ], + "highlighted": false, + "index": 5, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Tool edition", + "value": "AD: security posture score", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Community", + "value": "71", + }, + ], + "highlighted": false, + "index": 6, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Tool edition", + "value": "AD category score: AD Delegation", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Community", + "value": "84", + }, + ], + "highlighted": false, + "index": 7, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Tool edition", + "value": "AD category score: Account Security", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Community", + "value": "79", + }, + ], + "highlighted": false, + "index": 8, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Tool edition", + "value": "AD category score: AD Infrastructure Security", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Community", + "value": "92", + }, + ], + "highlighted": false, + "index": 9, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Tool edition", + "value": "AD category score: Group Policy Security", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Community", + "value": "97", + }, + ], + "highlighted": false, + "index": 10, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Tool edition", + "value": "AD category score: Kerberos Security", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Community", + "value": "95", + }, + ], + "highlighted": false, + "index": 11, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Tool edition", + "value": "AD category score: Hybrid", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Community", + "value": "100", + }, + ], + "highlighted": false, + "index": 12, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Tool edition", + "value": "AD: Evaluated", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Community", + "value": "117", + }, + ], + "highlighted": false, + "index": 13, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Tool edition", + "value": "AD: IOEs found", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Community", + "value": "17", + }, + ], + "highlighted": false, + "index": 14, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Tool edition", + "value": "AD: Passed", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Community", + "value": "100", + }, + ], + "highlighted": false, + "index": 15, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Tool edition", + "value": "AD: Failed to run", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Community", + "value": "0", + }, + ], + "highlighted": false, + "index": 16, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Tool edition", + "value": "AD: Canceled", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Community", + "value": "0", + }, + ], + "highlighted": false, + "index": 17, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Tool edition", + "value": "AD: Not selected", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Community", + "value": "1", + }, + ], + "highlighted": false, + "index": 18, + }, + { + "fields": [ + { + "columnIndex": 0, + "key": "column-0", + "label": "Tool edition", + "value": "AD: Not relevant", + }, + { + "columnIndex": 1, + "key": "column-1", + "label": "Community", + "value": "2", + }, + ], + "highlighted": false, + "index": 19, + }, + ], + }, + ], +} +`; diff --git a/tests/importers/import-compatibility.test.ts b/tests/importers/import-compatibility.test.ts new file mode 100644 index 0000000..a15a8b7 --- /dev/null +++ b/tests/importers/import-compatibility.test.ts @@ -0,0 +1,106 @@ +// @vitest-environment jsdom +import { readFileSync, readdirSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import * as XLSX from 'xlsx'; +import contracts from './import-contracts.json'; +import { importFile } from '../../src/importers/importer'; +import { importSource } from '../../src/importers/engine'; +import { nodeImportRuntimeAdapters } from '../../server/node-import-runtime-adapters'; +import { importProfiles } from '../../src/profiles/profile-catalog'; +import { previewEntryFromDataset, confirmEntryMappings } from '../../src/features/import-workflow/import-workflow'; +import { createCanonicalImportBatch } from '../../src/importers/canonical-import'; +import { assertCanonicalAssessmentModel } from '../../src/domain/canonical-validation'; +import { canonicalSecurityScores } from '../../src/scoring/canonical-security-score'; +import type { CanonicalAssessmentModel } from '../../src/domain/canonical-model'; + +// Keep all source data intact. Only generated record links, processing versions and +// import timestamps are excluded: a compatible implementation may change these. +function semanticRecord(record: object) { + const excluded = new Set(['id', 'definitionId', 'occurrenceId', 'scanRunId', 'importFileId', + 'sourceTableId', 'createdAt', 'updatedAt', 'mappingRuleVersion', 'mappingVersion', 'extractorVersion']); + return Object.fromEntries(Object.entries(record).filter(([key]) => !excluded.has(key))); +} +function mapped(value: { originalValue: unknown; normalizedValue: unknown } | undefined) { + return value && { originalValue: value.originalValue, normalizedValue: value.normalizedValue }; +} +function result(model: CanonicalAssessmentModel) { + return { + definitions: model.findingDefinitions.map(semanticRecord), + findings: model.findingOccurrences.map(item => ({ ...semanticRecord(item), + status: mapped(item.status), severity: mapped(item.severity), outcome: mapped(item.outcome), + technicalExecutionState: mapped(item.technicalExecutionState), score: mapped(item.score), + affectedAsset: mapped(item.affectedAsset), + fieldMappings: item.fieldMappings.map(({ provenance, ...mapping }) => ({ ...mapping, + decisionSource: provenance.decisionSource })), + })), + evidence: model.evidence.map(item => ({ ...semanticRecord(item), + finding: model.findingOccurrences.find(finding => finding.id === item.occurrenceId)?.sourceIdentity })), + observations: model.environmentObservations?.map(semanticRecord), + reportSections: model.reportSections?.map(semanticRecord), + scores: canonicalSecurityScores([model]).map(semanticRecord), + tables: model.sourceTables.map(table => ({ name: table.name, index: table.index, + hidden: table.hidden, rows: table.rows, rowCount: table.rowCount, columnCount: table.columnCount })), + }; +} +function input(contract: typeof contracts[number]) { + const text = readFileSync(resolve(contract.fixture), 'utf8').replaceAll('\r\n', '\n'); + expect(createHash('sha256').update(text).digest('hex'), `${contract.id}: protected fixture changed`).toBe(contract.sha256); + if (contract.variant === 'xlsx') { + const sheet = XLSX.read(text, { type: 'string', raw: true }).Sheets.Sheet1; + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, sheet, 'Tests'); + return new Uint8Array(XLSX.write(workbook, { type: 'array', bookType: 'xlsx' })); + } + const transformed = contract.variant === 'bare-json' + ? text.replace(/const testResults\s*=\s*/, '') + : contract.variant === 'let' || contract.variant === 'var' + ? text.replace('const testResults=', `${contract.variant} testResults=`) : text; + return new TextEncoder().encode(transformed); +} + +describe('protected scanner import contracts (synthetic data only)', () => { + it('covers every registered tool and keeps contract identifiers unique', () => { + expect(new Set(contracts.map(item => item.id)).size).toBe(contracts.length); + expect([...new Set(contracts.map(item => item.tool))].sort()).toEqual(importProfiles.map(item => item.id).sort()); + expect(contracts.every(item => item.fixture.includes('.synthetic.'))).toBe(true); + const originalContracts = ['pingcastle-html', 'pingcastle-xml', 'cis-cat-html', 'cis-cat-future-controls', + 'cis-cat-summary', 'maester-csv', 'maester-legacy-html', 'maester-literal-html', 'maester-current-html', + 'purple-knight-html', 'purple-knight-json', 'purple-knight-summary', 'generic-csv', + 'maester-bare-json', 'maester-let', 'maester-var', 'maester-xlsx', 'maester-multi-tenant']; + expect(contracts.map(item => item.id)).toEqual(expect.arrayContaining(originalContracts)); + const fixtures = readdirSync('fixtures', { recursive: true }).map(String) + .filter(path => path.includes('.synthetic.')).map(path => `fixtures/${path.replaceAll('\\', '/')}`); + expect([...new Set(contracts.map(item => item.fixture))].sort()).toEqual(fixtures.sort()); + }); + it('requires the fail-closed compatibility gate before normal tests and production builds', () => { + const pkg = JSON.parse(readFileSync('package.json', 'utf8')); + expect(pkg.scripts.test).toMatch(/^npm run test:imports && /); + expect(pkg.scripts.prebuild).toBe('npm run test:imports'); + expect(pkg.scripts['test:imports']).toBe('node scripts/check-import-contracts.mjs'); + }); + it.each(contracts)('$id retains recognition, preview and approved canonical results in browser and backend', async contract => { + const bytes = input(contract); + const metadata = { name: contract.fileName, sizeBytes: bytes.length, lastModified: 0, mediaType: contract.mediaType }; + const browser = await importFile(new File([bytes], contract.fileName, { type: contract.mediaType }), { profiles: importProfiles }); + const backend = await importSource({ bytes, metadata }, { profiles: importProfiles }, nodeImportRuntimeAdapters); + const outputs = []; + for (const dataset of [browser, backend]) { + expect(dataset.profileId).toBe(contract.tool); + expect(dataset.format).toBe(contract.format); + const preview = previewEntryFromDataset(dataset, { ...metadata, fileName: contract.fileName, + sha256: createHash('sha256').update(bytes).digest('hex') }); + expect(preview.status).not.toBe('error'); + const approved = confirmEntryMappings(preview, importProfiles); + const model = createCanonicalImportBatch('protected-dummy-import', [approved], '2026-10-09T00:00:00Z').model; + assertCanonicalAssessmentModel(model); + expect(model.findingOccurrences).toHaveLength(contract.findings); + outputs.push({ recognition: dataset.recognition?.source.selected.toolId, + preview: { selected: preview.selected, recordCount: preview.recordCount, mappingsToConfirm: preview.mappingsToConfirm }, + approved: { selected: approved.selected, recordCount: approved.recordCount }, ...result(model) }); + } + expect(outputs[1]).toEqual(outputs[0]); + expect(outputs[0]).toMatchSnapshot(); + }); +}); diff --git a/tests/importers/import-contracts.json b/tests/importers/import-contracts.json new file mode 100644 index 0000000..db38f20 --- /dev/null +++ b/tests/importers/import-contracts.json @@ -0,0 +1,200 @@ +[ + { + "id": "pingcastle-html", + "fixture": "fixtures/pingcastle/healthcheck.synthetic.html", + "tool": "pingcastle", + "format": "HTML", + "findings": 1, + "fileName": "healthcheck.synthetic.html", + "mediaType": "text/html", + "sha256": "9d2d7efbe6ba8c670dd7ceea85ba9c57166b71bebb98f9000bb63e506677eb10", + "variant": "original" + }, + { + "id": "pingcastle-xml", + "fixture": "fixtures/pingcastle/healthcheck.synthetic.xml", + "tool": "pingcastle", + "format": "XML", + "findings": 1, + "fileName": "healthcheck.synthetic.xml", + "mediaType": "application/xml", + "sha256": "24b56411296eeea60bf5201b64a3828b39b85d88a6950f3d2c8c681804cb326c", + "variant": "original" + }, + { + "id": "cis-cat-html", + "fixture": "fixtures/cis-cat/assessment.synthetic.html", + "tool": "cis-cat", + "format": "HTML", + "findings": 2, + "fileName": "assessment.synthetic.html", + "mediaType": "text/html", + "sha256": "7a53bf85f76972ca3b37e67fea805d0f6eaeddb9fda2e2086b040649f50808be", + "variant": "original" + }, + { + "id": "cis-cat-future-controls", + "fixture": "fixtures/cis-cat/assessment.future-controls.synthetic.html", + "tool": "cis-cat", + "format": "HTML", + "findings": 1, + "fileName": "assessment.future-controls.synthetic.html", + "mediaType": "text/html", + "sha256": "37b78d6489bb55e082426dcd7dbc456154709a24a231cf3a54fb43fdb85e3c16", + "variant": "original" + }, + { + "id": "cis-cat-summary", + "fixture": "fixtures/cis-cat/summary-and-results.synthetic.html", + "tool": "cis-cat", + "format": "HTML", + "findings": 6, + "fileName": "summary-and-results.synthetic.html", + "mediaType": "text/html", + "sha256": "94f28719869dbfc1fe0d2ee22032284bfdb01de93d3ef561faa555eb783f6650", + "variant": "original" + }, + { + "id": "maester-csv", + "fixture": "fixtures/maester/test-results.synthetic.csv", + "tool": "maester", + "format": "CSV", + "findings": 2, + "fileName": "test-results.synthetic.csv", + "mediaType": "text/csv", + "sha256": "81da04c9a4b0e65b674c1dc2d34d04812b7f9a3289b3801e5741260600feef03", + "variant": "original" + }, + { + "id": "maester-legacy-html", + "fixture": "fixtures/maester/test-results.synthetic.html", + "tool": "maester", + "format": "HTML", + "findings": 2, + "fileName": "test-results.synthetic.html", + "mediaType": "text/html", + "sha256": "6ac9213fc3a98c05795408f465b8a343df6e5264869c2ea75ad1bede96c74744", + "variant": "original" + }, + { + "id": "maester-literal-html", + "fixture": "fixtures/maester/object-literal.synthetic.html", + "tool": "maester", + "format": "HTML", + "findings": 6, + "fileName": "object-literal.synthetic.html", + "mediaType": "text/html", + "sha256": "e501f53e7344cb114ed47a882f3d0f5e794d9990ba8320571a9b0d809a5b7738", + "variant": "original" + }, + { + "id": "maester-current-html", + "fixture": "fixtures/maester/application-json-binding.synthetic.html", + "tool": "maester", + "format": "HTML", + "findings": 2, + "fileName": "application-json-binding.synthetic.html", + "mediaType": "text/html", + "sha256": "f3b54871281d8a15e54098cd99d4fc1ab02e7f06b0e779c63632a5971effd918", + "variant": "original" + }, + { + "id": "purple-knight-html", + "fixture": "fixtures/purple-knight/security-assessment.synthetic.html", + "tool": "purple-knight", + "format": "HTML", + "findings": 2, + "fileName": "security-assessment.synthetic.html", + "mediaType": "text/html", + "sha256": "116baa259a13aa48895d8fa1e75139466dd7cd83980b012fe6d2df13702a47fd", + "variant": "original" + }, + { + "id": "purple-knight-json", + "fixture": "fixtures/purple-knight/indicator-results.synthetic.json", + "tool": "purple-knight", + "format": "JSON", + "findings": 1, + "fileName": "indicator-results.synthetic.json", + "mediaType": "application/json", + "sha256": "887200db4e0ec706b74b18ce61dbc59db414ad783c66c919e3e937f0be75a49e", + "variant": "original" + }, + { + "id": "purple-knight-summary", + "fixture": "fixtures/purple-knight/assessment-summary.synthetic.csv", + "tool": "purple-knight", + "format": "CSV", + "findings": 0, + "fileName": "assessment-summary.synthetic.csv", + "mediaType": "text/csv", + "sha256": "bd991dcc3abdaf374e0ace8d9f4311d07b2e4b8250945e07ab89fd8f1a1ddda0", + "variant": "original" + }, + { + "id": "generic-csv", + "fixture": "fixtures/generic/findings.synthetic.csv", + "tool": "generic", + "format": "CSV", + "findings": 2, + "fileName": "findings.synthetic.csv", + "mediaType": "text/csv", + "sha256": "071b95c719a9ce99b8221f8eb2c26d8ca06fc428467bb296dfb1967774ba32b5", + "variant": "original" + }, + { + "id": "maester-bare-json", + "fixture": "fixtures/maester/application-json-binding.synthetic.html", + "tool": "maester", + "format": "HTML", + "findings": 2, + "fileName": "application-json-binding.synthetic.html", + "mediaType": "text/html", + "sha256": "f3b54871281d8a15e54098cd99d4fc1ab02e7f06b0e779c63632a5971effd918", + "variant": "bare-json" + }, + { + "id": "maester-let", + "fixture": "fixtures/maester/application-json-binding.synthetic.html", + "tool": "maester", + "format": "HTML", + "findings": 2, + "fileName": "application-json-binding.synthetic.html", + "mediaType": "text/html", + "sha256": "f3b54871281d8a15e54098cd99d4fc1ab02e7f06b0e779c63632a5971effd918", + "variant": "let" + }, + { + "id": "maester-var", + "fixture": "fixtures/maester/application-json-binding.synthetic.html", + "tool": "maester", + "format": "HTML", + "findings": 2, + "fileName": "application-json-binding.synthetic.html", + "mediaType": "text/html", + "sha256": "f3b54871281d8a15e54098cd99d4fc1ab02e7f06b0e779c63632a5971effd918", + "variant": "var" + }, + { + "id": "maester-xlsx", + "fixture": "fixtures/maester/test-results.synthetic.csv", + "tool": "maester", + "format": "Excel", + "findings": 2, + "fileName": "TestResults-Dummy.xlsx", + "mediaType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "sha256": "81da04c9a4b0e65b674c1dc2d34d04812b7f9a3289b3801e5741260600feef03", + "variant": "xlsx" + }, + { + "id": "maester-multi-tenant", + "fixture": "fixtures/maester/multi-tenant.synthetic.html", + "tool": "maester", + "format": "HTML", + "findings": 2, + "fileName": "Maester-Dummy-MultiTenant.html", + "mediaType": "text/html", + "sha256": "2a080e0facc21626b51b91305e0163382d63e6bc24a8d26d0312300d580afe56", + "variant": "original" + } +] diff --git a/tests/importers/maester-coverage.test.ts b/tests/importers/maester-coverage.test.ts index 27cc2cc..e7041fb 100644 --- a/tests/importers/maester-coverage.test.ts +++ b/tests/importers/maester-coverage.test.ts @@ -15,6 +15,7 @@ import { processingReadiness } from '../../src/domain/processing-compatibility'; import { mapFindingCandidates } from '../../src/importers/candidate-mapping'; import { maesterProfile } from '../../src/profiles/maester/maester-profile'; import type { SourceExtractionResult } from '../../src/domain/source-extraction'; +import { canonicalSecurityScores } from '../../src/scoring/canonical-security-score'; const htmlImport = (html: string) => importFile(new File([html], 'ReportTemplate.html', { type: 'text/html' }), { profiles: importProfiles }); function canonical(dataset: Awaited>) { @@ -25,6 +26,46 @@ function canonical(dataset: Awaited>) { } describe('Maester variant and context coverage', () => { + it.each(['const', 'let', 'var'])('imports the current Maester application/json data block with a %s binding through preview and server', async declaration => { + const payload = { Result: 'Failed', TotalCount: 2, FailedCount: 1, PassedCount: 1, Score: 99, + Tests: [{ Id: 'MT.99001', Title: 'Dummy failed check', Result: 'Failed', Severity: 'High', + ResultDetail: { TestResult: 'Synthetic evidence only' } }, + { Id: 'MT.99002', Title: 'Dummy passed check', Result: 'Passed' }], EndOfJson: 'EndOfJson' }; + // Public Maester ReportTemplate.html: application/json still wraps the JSON in const testResults=. + const html = `Maester · Dummy
    `; + const bytes = new TextEncoder().encode(html); + for (const imported of [await htmlImport(html), await importSource({ metadata: { + name: 'TestResults-Dummy.html', mediaType: 'text/html', lastModified: 0, sizeBytes: bytes.length }, bytes }, { profiles: importProfiles }, nodeImportRuntimeAdapters)]) { + expect(imported.profileId).toBe('maester'); + expect(imported.extraction?.extractor.version).toBe('1.2.1'); + expect(imported.recognition?.source.requiresConfirmation).toBe(false); + const entry = previewEntryFromDataset(imported, { sha256: 'b'.repeat(64), fileName: imported.fileName, sizeBytes: bytes.length, lastModified: 0 }); + expect(entry.status).not.toBe('error'); + expect(entry.selected).toBe(true); + expect(entry.recordCount).toBe(2); + const model = canonical(imported); + expect(model.findingOccurrences).toHaveLength(2); + expect(model.findingOccurrences.map(item => item.status.normalizedValue)).toEqual(['failed', 'passed']); + expect(model.findingOccurrences.map(item => item.sourcePosition?.objectPath)).toEqual(['$.Tests[0]', '$.Tests[1]']); + expect(model.evidence.map(item => item.value)).toContain('Synthetic evidence only'); + // An arbitrary source field named Score is not a native overall score. + expect(canonicalSecurityScores([model])[0]).toMatchObject({ score: 50, method: 'pass-rate', nativeValue: undefined }); + expect(model.environmentObservations).toContainEqual(expect.objectContaining({ property: 'maester.source.Score', originalValue: 99 })); + } + }); + + it.each([ + 'const testResults=loadResults();', + 'const testResults={Result:"Failed",Tests:[execute()],EndOfJson:true};', + 'const testResults={Result:"Failed",Tests:[null],EndOfJson:true};', + 'const testResults={Result:"Failed",Tests:[],Tests:[{Id:"MT.1"}],EndOfJson:true};', + 'const testResults={Result:"Failed",Tests:[{Id:"MT.1"}],EndOfJson:true}.Tests;', + 'const testResults={Result:"Failed",Tests:[{Id:"MT.1"}],EndOfJson:true};const data={Result:"Failed",Tests:[{Id:"MT.2"}]};', + 'const testResults={Result:"Failed",Tests:[{Id:"MT.1"}],EndOfJson:true};execute();' + ])('rejects invalid or executable application/json payloads: %s', async script => { + await expect(htmlImport(`Maester · Dummy`)).rejects.toThrow(/Maester/); + }); + it('imports all literal tests through the browser and server without running the report', async () => { delete (globalThis as Record).__maesterActiveContentExecuted; const bytes = new TextEncoder().encode(literalFixture); diff --git a/tests/remediation/intervention-catalog.test.ts b/tests/remediation/intervention-catalog.test.ts new file mode 100644 index 0000000..c8f2d0a --- /dev/null +++ b/tests/remediation/intervention-catalog.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import { matchIntervention } from '../../src/remediation/intervention-catalog'; +import { buildWorkPackages, policyFindingKey, scannerFindingKey, type PublishedContext, type RemediationFinding } from '../../src/remediation/remediation-engine'; + +function run(cluster: string, rows: Array<{ tool: string; title: string; conditions?: string[]; outcome?: string; policy?: boolean; confidence?: string }>) { + const findings: RemediationFinding[] = rows.map((r, i) => ({ + key: r.policy ? policyFindingKey('computer', `setting-${i}`) : scannerFindingKey(`occ-${i}`), + ...(r.policy ? { scope: 'computer', identityKey: `setting-${i}` } : { occurrenceId: `occ-${i}` }), + tool: r.tool, ruleId: 'synthetic', title: r.title, outcome: r.outcome ?? 'fail', + })); + const attachments = findings.map((f, i) => ({ occurrenceId: f.occurrenceId!, identityKey: f.identityKey!, scope: f.scope!, + clusterIds: [cluster], conditionIds: rows[i].conditions ?? [], clusterConfidence: { [cluster]: rows[i].confidence ?? 'catalogued' } })); + const context: PublishedContext = { engine: { version: 'published' }, catalog: { version: 'published' }, repositoryRevision: 7, + clusters: [{ clusterId: cluster, label: { en: cluster } }], + findings: attachments.filter(a => a.occurrenceId), settings: attachments.filter(a => a.identityKey) }; + const input = { projectId: 'synthetic', assessmentIds: ['a'], scanRunIds: [], findings, context }; + const before = structuredClone(input); + const result = buildWorkPackages(input); + expect(input).toEqual(before); + expect(buildWorkPackages({ ...input, findings: [...findings].reverse(), context: { + ...context, findings: [...context.findings].reverse(), settings: [...context.settings].reverse() } })).toEqual(result); + return result; +} + +describe('complete intervention catalogue', () => { + it.each([ + ['ad-persistence-indicators', 'ad-stale-admincount', 'adminsdholder-permissions-changed', 'Review protected'], + ['ad-privileged-account-exposure', 'ad-admins-not-protected-users', 'ad-builtin-admin-used', 'Protect and restrict'], + ['ad-reconnaissance-exposure', 'anonymous-ldap-bind', 'prewin2000-membership-changed', 'Restrict anonymous'], + ['ad-recovery-gaps', 'ad-backup-outdated', 'ou-deletion-unprotected', 'Prepare directory'], + ['adcs-escalation', 'adcs-template-requester-san', 'adcs-template-insecure-config', 'Review certificate'], + ['cloud-app-consent-exposure', 'entra-users-can-consent-apps', 'entra-admin-consent-workflow-missing', 'Restrict application'], + ['cloud-external-access-exposure', 'entra-guest-invites-unrestricted', 'entra-guest-invite-domains-unrestricted', 'Restrict guest'], + ['kerberos-delegation-abuse', 'gpo-delegation-right-deviates', 'ad-rbcd-write-on-dc', 'Review sensitive'], + ])('%s combines different source assertions for one intervention', (cluster, first, second, title) => { + const result = run(cluster, [{ tool: 'pingcastle', title: 'Synthetic finding A', conditions: [first] }, + { tool: 'purple-knight', title: 'Synthetic finding B', conditions: [second] }]); + expect(result.packages).toHaveLength(1); + expect(result.packages[0].method).toBe('catalogued'); + expect(result.packages[0].title).toContain(title); + expect(result.packages[0].findingKeys).toHaveLength(2); + }); + + it.each([ + ['cloud-sign-in-protection-gaps', 'Block legacy authentication clients', 'Block legacy authentication for all users', 'Block legacy'], + ['credential-relay-exposure', 'Domain controller: LDAP server signing requirements', 'Domain controller: LDAP server channel binding token requirements', 'Require protected LDAP'], + ['data-sharing-exposure', 'Allow Clipboard synchronization across devices', 'Allow upload of User Activities', 'Restrict cross-device'], + ['host-credential-privilege-exposure', 'Privilege Rights · SeBackupPrivilege', 'Back up files and directories', 'Restrict backup'], + ['kerberoasting-exposure', 'Configure encryption types allowed for Kerberos', 'RC4 or DES encryption type supported by Domain Controllers', 'Restrict weak Kerberos'], + ['lateral-movement-exposure', 'Configure SMB v1 client driver', 'Configure SMB v1 server', 'Remove SMBv1'], + ['malicious-code-execution-exposure', 'Configure Attack Surface Reduction rules', 'Attack Surface Reduction not configured', 'Configure Attack'], + ['patch-configuration-drift', 'Configure security policy processing: Process even if unchanged', 'Configure security policy processing: background refresh', 'Ensure security'], + ['physical-local-access-exposure', 'Interactive logon: Message title for users', 'Interactive logon: Message text for users', 'Configure the interactive'], + ])('%s joins complementary retained setting names', (cluster, first, second, title) => { + const result = run(cluster, [{ tool: 'group-policy', title: first, policy: true }, { tool: 'cis-cat', title: second }]); + expect(result.packages).toHaveLength(1); + expect(result.packages[0].title).toContain(title); + expect(result.packages[0].findingKeys).toHaveLength(2); + }); + + it('joins distinct password source assertions in one measure, split only at the platform boundary', () => { + const result = run('password-guessing-exposure', [ + { tool: '', title: 'Password must meet complexity requirements', conditions: ['password-complexity-off'], policy: true }, + { tool: 'cis-cat', title: "Ensure 'Minimum password length' is set to '14 or more'", conditions: ['password-length-below-14'] }, + { tool: 'pingcastle', title: 'Policy where the password length is less than 8 characters', conditions: ['password-length-below-8'] }, + { tool: 'purple-knight', title: 'Privileged Users with Weak Password Policy', conditions: ['ad-privileged-weak-password-policy'] }, + ]); + expect(result.packages.map(p => [p.platform, p.findingKeys.length])).toEqual([['windows-host', 2], ['active-directory', 2]]); + expect(new Set(result.packages.flatMap(p => p.measures.map(m => m.id)))).toEqual(new Set(['intervention:password-policy'])); + }); + + it('keeps different firewall profiles and their source assertions together only within each profile', () => { + const rows = ['Domain', 'Private', 'Public'].flatMap(profile => [ + { tool: '', title: `WindowsFirewall\\${profile}Profile\\Logging`, conditions: ['gpo-firewall-logging-deviates'], policy: true }, + { tool: 'cis-cat', title: `Windows Firewall: ${profile}: Logging: Log dropped packets`, conditions: ['firewall-logging-off'] }, + { tool: 'cis-cat', title: `Windows Firewall: ${profile}: Logging: Log successful connections`, conditions: ['firewall-logging-off'] }, + ]); + const result = run('detection-blind-spots', rows); + const measures = result.packages.flatMap(p => p.measures); + expect(measures.map(m => m.taskIds.length)).toEqual([3, 3, 3]); + expect(new Set(measures.map(m => m.id)).size).toBe(3); + // Each profile measure stays inside one package of at most eight tasks. + expect(result.packages.every(p => p.tasks.length <= 15)).toBe(true); + }); + + it('does not turn unrelated neighbours or broad network conditions into a common measure', () => { + const result = run('lateral-movement-exposure', [ + { tool: 'cis-cat', title: 'Enable Safe DLL search mode', conditions: ['cis-network-stack-unmet'] }, + { tool: 'cis-cat', title: 'Security log WarningLevel', conditions: ['cis-network-stack-unmet'] }, + { tool: 'pingcastle', title: 'LAPS is not installed', conditions: ['laps-not-deployed'] }, + ]); + expect(result.packages.map(p => p.platform)).toEqual(['windows-host', 'active-directory']); + expect(result.packages.flatMap(p => p.measures).every(m => m.method === 'individual' && m.taskIds.length === 1)).toBe(true); + }); + + it('prefers specific semantics over a term inside a conditional clause', () => { + expect(matchIntervention('cloud-sign-in-protection-gaps', { tool: 'maester', outcome: 'fail', + title: 'If phishing-resistant MFA has not been enforced, show login context information.' }, + ['entra-mfa-fatigue-protection-weak'])?.id).toBe('authenticator-fatigue'); + }); + + it('does not treat estimated context or an unsupported warning as a shared fix', () => { + const result = run('password-guessing-exposure', [ + { tool: 'cis-cat', title: 'Minimum password length', confidence: 'estimated' }, + { tool: 'cis-cat', title: 'Minimum password length', outcome: 'warning' }, + { tool: 'cis-cat', title: 'Minimum password length' }, + ]); + expect(result.packages.flatMap(p => p.measures).map(m => m.taskIds.length)).toEqual([1, 1, 1]); + }); + + it.each([6, 11, 16])('avoids an artificial singleton tail for %s related findings', count => { + const result = run('password-guessing-exposure', Array.from({ length: count }, () => ({ tool: 'cis-cat', title: 'Minimum password length' }))); + expect(result.packages.every(p => p.findingKeys.length >= 2 && p.findingKeys.length <= 15)).toBe(true); + const keys = result.packages.flatMap(p => p.findingKeys); + expect(keys).toHaveLength(count); + expect(new Set(keys).size).toBe(count); + }); +}); diff --git a/tests/remediation/remediation-engine.test.ts b/tests/remediation/remediation-engine.test.ts new file mode 100644 index 0000000..fe590a6 --- /dev/null +++ b/tests/remediation/remediation-engine.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest'; +import { buildWorkPackages, scannerFindingKey, policyFindingKey, maxTasksFor, type PublishedContext, type RemediationFinding } from '../../src/remediation/remediation-engine'; +import { MemoryRemediationStore } from '../../server/remediation/remediation-store'; +import { remediationReadPortFrom } from '../../src/remediation/remediation-read-port'; +import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository'; + +const cloud = 'cloud-privileged-access-exposure'; +const rules = ['MT.1005','MT.1024.adminMFAV2','CISA.MS.AAD.3.6','MT.1014','CIS.M365.1.1.3','CISA.MS.AAD.7.1','CISA.MS.AAD.7.2','MT.1032', + 'CIS.M365.1.1.1','CISA.MS.AAD.7.3','MT.1028','CISA.MS.AAD.7.4','MT.1025','MT.1030','MT.INV.003','MT.1056','MT.1064','MT.1027','MT.INV.001']; +const findings: RemediationFinding[] = rules.map((ruleId, i) => ({ key: scannerFindingKey(`occ-${i}`), occurrenceId: `occ-${i}`, tool: 'maester', ruleId, + title: `Synthetic ${ruleId}`, outcome: ruleId.startsWith('MT.INV') ? 'warning' : 'fail' })); +const context: PublishedContext = { engine: { version: '1.11.1' }, catalog: { version: '1.10.0' }, repositoryRevision: 4, + clusters: [{ clusterId: cloud, label: { en: 'Cloud Privileged Access Exposure' } }, { clusterId: 'other-deviations', label: { en: 'Other Deviations' } }], + findings: findings.map(f => ({ occurrenceId: f.occurrenceId!, clusterIds: [cloud], conditionIds: [] })), settings: [] }; +const input = { projectId: 'synthetic', assessmentIds: ['a'], scanRunIds: ['s'], findings, context }; + +type Row = { tool: string; title?: string; conditions?: string[]; policy?: boolean; severity?: string; outcome?: string }; +/** Synthetic scope with one cluster; checks order independence and immutable inputs on every run. */ +function run(cluster: string, rows: Row[]) { + const fs: RemediationFinding[] = rows.map((r, i) => ({ + key: r.policy ? policyFindingKey('computer', `setting-${i}`) : scannerFindingKey(`occ-${String(i).padStart(2, '0')}`), + ...(r.policy ? { scope: 'computer', identityKey: `setting-${i}` } : { occurrenceId: `occ-${String(i).padStart(2, '0')}` }), + tool: r.tool, ruleId: `R${i}`, title: r.title ?? `Synthetic distinct finding ${i}`, outcome: r.outcome ?? 'fail', ...(r.severity ? { severity: r.severity } : {}) })); + const attachments = fs.map((f, i) => ({ occurrenceId: f.occurrenceId!, identityKey: f.identityKey!, scope: f.scope!, clusterIds: [cluster], conditionIds: rows[i].conditions ?? [] })); + const ctx: PublishedContext = { engine: { version: 'published' }, catalog: { version: 'published' }, repositoryRevision: 7, + clusters: [{ clusterId: cluster, label: { en: cluster } }], + findings: attachments.filter(a => a.occurrenceId), settings: attachments.filter(a => a.identityKey) }; + const scoped = { projectId: 'synthetic', assessmentIds: ['a'], scanRunIds: [], findings: fs, context: ctx }; + const before = structuredClone(scoped); + const result = buildWorkPackages(scoped); + expect(scoped).toEqual(before); + expect(buildWorkPackages({ ...scoped, findings: [...fs].reverse(), context: { ...ctx, findings: [...ctx.findings].reverse(), settings: [...ctx.settings].reverse() } })).toEqual(result); + const keys = result.packages.flatMap(p => p.findingKeys); + expect(new Set(keys).size).toBe(keys.length); + expect(result.packages.every(p => p.tasks.length <= maxTasksFor(p.platform) && p.facts.tasks === p.tasks.length)).toBe(true); + return { result, keys: fs.map(f => f.key) }; +} + +describe('Remediation Engine', () => { + it('keeps the seven reviewed interventions as measures in packages of at most eight tasks with exclusive coverage', () => { + const result = buildWorkPackages(input); + const measures = result.packages.flatMap(p => p.measures).filter(m => m.method === 'catalogued'); + expect(measures.map(m => m.taskIds.length).sort()).toEqual([1,1,2,3,4,4,4]); + expect(new Set(measures.map(m => m.id)).size).toBe(7); + expect(result.packages.every(p => p.tasks.length <= 8 && p.platform === 'cloud')).toBe(true); + expect(result.packages).toHaveLength(3); + expect(new Set(result.packages.flatMap(p => p.findingKeys)).size).toBe(19); + expect(result.unassignedFindingKeys).toEqual([]); + }); + it('is independent of input order and leaves Core, Context, verdicts and source text unchanged', () => { + const before = structuredClone(input); + const first = buildWorkPackages(input); + expect(buildWorkPackages({ ...input, findings: [...findings].reverse(), context: { ...context, findings: [...context.findings].reverse() } })).toEqual(first); + expect(input).toEqual(before); + expect(buildWorkPackages({ ...input, findings: [...findings, findings[0]] })).toEqual(first); + }); + it('splits a larger common intervention evenly and keeps non-deviations out', () => { + const many = Array.from({ length: 12 }, (_, i) => ({ ...findings[0], key: scannerFindingKey(`many-${i}`), occurrenceId: `many-${i}` })); + const expanded = { ...context, findings: many.map(f => ({ occurrenceId: f.occurrenceId!, clusterIds: [cloud], conditionIds: [] })) }; + const result = buildWorkPackages({ ...input, context: expanded, findings: [...many, { ...many[0], key: 'pass', outcome: 'pass' }] }); + expect(result.packages.map(p => p.findingKeys.length)).toEqual([6,6]); + expect(result.packages.flatMap(p => p.findingKeys)).not.toContain('pass'); + }); + it('does not claim a common measure for neighbours or estimated context; unknown context remains visibly unassigned', () => { + const identical = 'Apply the retained source remediation to the same technical setting.'; + const fs = findings.slice(0, 3).map(f => ({ ...f, tool: 'synthetic', remediation: identical })); + const ctx = { ...context, findings: context.findings.map((f, i) => i === 1 ? { ...f, clusterConfidence: { [cloud]: 'estimated' } } : i === 2 ? { ...f, clusterIds: ['other-deviations'] } : f) }; + const result = buildWorkPackages({ ...input, findings: fs, context: ctx }); + expect(result.packages).toHaveLength(1); + // The estimated finding stays individual; identical text alone joins nothing across confidence levels. + expect(result.packages[0].measures.map(m => [m.method, m.taskIds.length])).toEqual([['same-source-remediation', 1], ['individual', 1]]); + expect(result.packages[0].tasks.every(t => !t.redundant)).toBe(true); + expect(result.unassignedFindingKeys).toEqual([fs[2].key]); + }); + it('counts a rolled-up GPO setting once and keeps computer and user scopes separate', () => { + const computer = { key: policyFindingKey('computer', 'setting'), identityKey: 'setting', scope: 'computer', tool: 'group-policy', ruleId: '', title: 'Setting', outcome: 'deviation' }; + const user = { ...computer, key: policyFindingKey('user', 'setting'), scope: 'user' }; + const ctx = { ...context, settings: [computer, user].map(f => ({ identityKey: f.identityKey, scope: f.scope, clusterIds: [cloud], conditionIds: [] })) }; + expect(buildWorkPackages({ ...input, findings: [computer, computer, user], context: ctx }).packages.flatMap(p => p.findingKeys)).toHaveLength(2); + }); + it('keeps storage separate and exposes only declared Core reads', async () => { + const repository = new LocalAssessorRepository(); + const before = repository.revision; + const port = remediationReadPortFrom(repository, async () => repository.revision); + expect('saveProject' in port).toBe(false); + expect('saveReview' in port).toBe(false); + const store = new MemoryRemediationStore(); + await store.save('scope', buildWorkPackages(input)); + expect((await store.read('scope'))?.packages).toHaveLength(3); + expect(repository.revision).toBe(before); + }); +}); + +describe('Remediation packaging 2.0 (user decision 2026-10-09)', () => { + it('merges findings that each source reports once for the same specific condition into one task', () => { + const { result, keys } = run('password-guessing-exposure', [ + { tool: 'cis-cat', conditions: ['password-length-below-14'] }, + { tool: '', policy: true, conditions: ['password-length-below-14', 'password-length-below-8'] }, + { tool: 'pingcastle', conditions: ['password-length-below-8'] }, + ]); + expect(result.packages).toHaveLength(1); + const [task] = result.packages[0].tasks; + expect(result.packages[0].tasks).toHaveLength(1); + expect(task.redundant).toBe(true); + expect([...task.findingKeys].sort()).toEqual([...keys].sort()); + expect(result.packages[0].platform).toBe('windows-host'); + expect(result.packages[0].facts).toMatchObject({ tasks: 1, findings: 3, redundantFindings: 3, conditionsClosed: 2, conditionsTouched: 2 }); + }); + it('never treats an area condition, an umbrella finding or matching titles as redundancy', () => { + const area = run('detection-blind-spots', [ + { tool: 'cis-cat', conditions: ['cis-audit-policy-unmet'] }, { tool: 'cis-cat', conditions: ['cis-audit-policy-unmet'] }, + { tool: 'cis-cat', conditions: ['cis-audit-policy-unmet'] }, + ]).result; + expect(area.packages.flatMap(p => p.tasks).every(t => !t.redundant && t.findingKeys.length === 1)).toBe(true); + const umbrella = run('host-credential-privilege-exposure', [ + { tool: 'purple-knight', conditions: ['dangerous-user-rights-granted'] }, + { tool: 'cis-cat', conditions: ['dangerous-user-rights-granted'] }, { tool: 'cis-cat', conditions: ['dangerous-user-rights-granted'] }, + { tool: 'cis-cat', conditions: ['dangerous-user-rights-granted'] }, + ]).result; + expect(umbrella.packages).toHaveLength(1); + expect(umbrella.packages[0].platform).toBe('windows-host'); + expect(umbrella.packages[0].tasks).toHaveLength(4); + expect(umbrella.packages[0].tasks.every(t => !t.redundant && t.relatedConditionId === 'dangerous-user-rights-granted')).toBe(true); + const titles = run('lateral-movement-exposure', [ + { tool: 'cis-cat', title: 'Identical wording', conditions: ['condition-a'] }, { tool: 'group-policy', policy: true, title: 'Identical wording', conditions: ['condition-b'] }, + ]).result; + expect(titles.packages.flatMap(p => p.tasks)).toHaveLength(2); + }); + it('packs at most eight tasks, balances packages and leaves no artificial single-task tail', () => { + const rows = (n: number) => Array.from({ length: n }, () => ({ tool: 'pingcastle' })); + expect(run('ad-recovery-gaps', rows(20)).result.packages.map(p => p.tasks.length)).toEqual([7, 7, 6]); + expect(run('ad-recovery-gaps', rows(9)).result.packages.map(p => p.tasks.length).sort()).toEqual([4, 5]); + expect(run('ad-recovery-gaps', rows(8)).result.packages.map(p => p.tasks.length)).toEqual([8]); + }); + it('allows fifteen tasks for Windows host/GPO settings only (user decision 2026-10-09)', () => { + expect(maxTasksFor('windows-host')).toBe(15); + expect(maxTasksFor('active-directory')).toBe(8); + expect(maxTasksFor('cloud')).toBe(8); + const host = (n: number) => Array.from({ length: n }, (_, i) => i % 3 ? { tool: 'cis-cat' } : { tool: 'group-policy', policy: true }); + expect(run('data-sharing-exposure', host(15)).result.packages.map(p => [p.platform, p.tasks.length])).toEqual([['windows-host', 15]]); + expect(run('data-sharing-exposure', host(20)).result.packages.map(p => p.tasks.length)).toEqual([10, 10]); + // 31 tasks need three packages; balanced, none near the limit of fifteen. + const sizes = run('data-sharing-exposure', host(31)).result.packages.map(p => p.tasks.length); + expect(sizes).toHaveLength(3); + expect(sizes.reduce((n, size) => n + size, 0)).toBe(31); + expect(Math.max(...sizes) - Math.min(...sizes)).toBeLessThanOrEqual(2); + // The same cluster keeps eight for directory tasks next to fifteen for host settings. + const mixed = run('data-sharing-exposure', [...host(15), ...Array.from({ length: 9 }, () => ({ tool: 'purple-knight' }))]).result; + expect(mixed.packages.map(p => [p.platform, p.tasks.length])).toEqual([['windows-host', 15], ['active-directory', 5], ['active-directory', 4]]); + }); + it('orders tasks with a common measure before individual tasks inside a package', () => { + const { result } = run('cloud-sign-in-protection-gaps', [ + { tool: 'maester', title: 'Unmatched synthetic check A' }, + { tool: 'maester', title: 'Block legacy authentication clients' }, { tool: 'maester', title: 'Block legacy authentication for all users' }, + ]); + expect(result.packages).toHaveLength(1); + expect(result.packages[0].measures.map(m => m.method)).toEqual(['catalogued', 'individual']); + const individual = result.packages[0].measures[1].taskIds[0]; + expect(result.packages[0].tasks.at(-1)?.id).toBe(individual); + }); + it('keeps the cluster and platform boundary hard', () => { + const { result } = run('ad-privileged-account-exposure', [{ tool: 'cis-cat' }, { tool: 'cis-cat' }, { tool: 'pingcastle' }, { tool: 'purple-knight' }]); + expect(result.packages.map(p => [p.platform, p.tasks.length])).toEqual([['windows-host', 2], ['active-directory', 2]]); + expect(result.packages.map(p => p.code)).toEqual(['WP-01', 'WP-02']); + }); + it('reports source-backed facts without a score or rank', () => { + const { result } = run('ad-privileged-account-exposure', [ + { tool: 'pingcastle', severity: 'High', conditions: ['condition-a'] }, { tool: 'purple-knight', severity: 'Critical', conditions: ['condition-b'] }, + { tool: 'pingcastle', severity: 'unknown', conditions: ['condition-b'] }, + ]); + expect(result.packages[0].facts).toEqual({ tasks: 2, findings: 3, redundantFindings: 2, highestSeverity: 'critical', + criticalOrHighFindings: 2, conditionsClosed: 2, conditionsTouched: 2, sources: ['pingcastle', 'purple-knight'] }); + expect(Object.keys(result.packages[0])).not.toContain('score'); + expect(Object.keys(result.packages[0])).not.toContain('priority'); + }); +}); diff --git a/tests/remediation/work-package-interaction.test.tsx b/tests/remediation/work-package-interaction.test.tsx new file mode 100644 index 0000000..e2b8f20 --- /dev/null +++ b/tests/remediation/work-package-interaction.test.tsx @@ -0,0 +1,729 @@ +// @vitest-environment jsdom +import { act, useState } from 'react'; +import { FloatingResultsWindow } from '../../src/features/linked-results/FloatingResultsWindow'; +import { readFileSync } from 'node:fs'; +import { createRoot, type Root } from 'react-dom/client'; +import { beforeEach, afterEach, expect, it, vi } from 'vitest'; +import { useApp } from '../../src/app/AppProvider'; +import { AssessorApiClient } from '../../src/api/assessor-api-client'; +import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences'; +import { SearchFilterBar } from '../../src/features/search-filter/SearchFilterBar'; +import { useFilteredRecords } from '../../src/features/search-filter/use-filtered-records'; +import { FindingReader } from '../../src/features/finding-reader/FindingReader'; +import { RelatedFindingsProvider } from '../../src/features/related-findings/RelatedFindings'; +import { ActionlistControls } from '../../src/features/actionlist/ActionlistControls'; +import { SidebarSecurityDomains } from '../../src/features/sidebar/SidebarSecurityDomains'; +import { useContextClusterIndex } from '../../src/features/context-layer/use-context-cluster-index'; +import { makeSheet } from '../../src/importers/sheet-factory'; +import { genericProfile } from '../../src/profiles/generic/generic-profile'; +import { buildWorkPackages, scannerFindingKey, UNASSIGNED_WORK_PACKAGE, WORK_PACKAGE_FACET_ID, type WorkPackage } from '../../src/remediation/remediation-engine'; +import { resetWorkPackageRequestsForTests } from '../../src/remediation/use-work-packages'; +import { workPackageOverview } from '../../src/remediation/work-package-overview'; +import { workPackageIndex } from '../../src/remediation/work-package-index'; +import { ContextHeatmap } from '../../src/features/context-layer/ContextHeatmap'; +import { contextClusterIndex } from '../../src/features/context-layer/context-cluster-facet'; +import { emptyHeatmapContext } from '../assessment-context/context-heatmap-fixture'; + +vi.mock('../../src/app/AppProvider', () => { const useApp = vi.fn(); return { useApp, useOptionalApp: useApp }; }); +vi.mock('../../src/features/context-layer/use-context-cluster-index', () => ({ useContextClusterIndex: vi.fn(() => undefined) })); +let host: HTMLDivElement; let root: Root; let app: ReturnType; let result: ReturnType; + +it('keeps only the table head sticky when scrolling package row headings', async () => { + const style = document.createElement('style'); + style.textContent = ['base.css', 'components.css'].map(file => readFileSync(`src/styles/${file}`, 'utf8')).join('\n'); + document.head.append(style); + try { + await act(async () => root.render(<>)); + await act(async () => host.querySelector('[data-work-packages-open]')!.click()); + const scroll = document.querySelector('.work-packages__scroll')!; + scroll.scrollTop = 300; + await act(async () => scroll.dispatchEvent(new Event('scroll'))); + expect(getComputedStyle(scroll.querySelector('thead')!).position).toBe('sticky'); + for (const heading of scroll.querySelectorAll('tbody th')) { + expect(getComputedStyle(heading).position).toBe('static'); + expect(getComputedStyle(heading).zIndex).toBe('auto'); + } + } finally { style.remove(); } +}); +function View() { + const records = useFilteredRecords(); + // Work shows the selected record in the Reader, otherwise the first visible one. + const selected = records.find(record => record.id === useApp().selectedRecordId) ?? records[0]; + return <>{records.length}{selected && {}} onNext={() => {}} onReview={() => {}}/>}; +} +it.each(['light', 'dark'])('places yellow-outlined Work Packages before CSV and keeps its outline when opened (%s)', async theme => { + document.documentElement.dataset.theme = theme; + const style = document.createElement('style'); + // JSDOM does not resolve custom colour properties; resolve this shared token for its CSS cascade. + const accent = readFileSync('src/styles/ui-tokens.css', 'utf8').match(/--button-accent:\s*([^;]+);/)![1]; + style.textContent = ['base.css', 'components.css'].map(file => readFileSync(`src/styles/${file}`, 'utf8')).join('\n') + .replaceAll('var(--button-accent)', accent); + const expected = document.createElement('span'); expected.style.borderColor = accent; + document.head.append(style); + try { + await act(async () => root.render()); + const row = host.querySelector('.header-export-row')!; + expect([...row.children].filter(node => node.tagName === 'BUTTON').map(node => node.textContent)) + .toEqual(['Work Packages', 'CSV', 'MD', 'HTML']); + const button = row.querySelector('[data-work-packages-open]')!; + expect(getComputedStyle(button).borderColor).toBe(expected.style.borderColor); + await act(async () => button.click()); + expect(button.getAttribute('aria-pressed')).toBe('true'); + expect(getComputedStyle(button).borderColor).toBe(expected.style.borderColor); + await act(async () => button.click()); + expect(document.querySelector('[data-work-packages-window]')).toBeNull(); + expect(button.getAttribute('aria-pressed')).toBe('false'); + await act(async () => button.click()); + expect(document.querySelector('[data-work-packages-window]')).not.toBeNull(); + await act(async () => document.querySelector('[aria-label="Close Work packages window"]')!.click()); + expect(button.getAttribute('aria-pressed')).toBe('false'); + expect(getComputedStyle(button).borderColor).toBe(expected.style.borderColor); + } finally { style.remove(); delete document.documentElement.dataset.theme; } +}); +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); localStorage.clear(); resetEnginePreferencesForTests(); resetWorkPackageRequestsForTests(); + setEngineEnabled('context', false); + vi.mocked(useContextClusterIndex).mockReturnValue(undefined); + const sheet = makeSheet('Synthetic', [['Title','Status','Severity','Source Rule ID','Result Message','Source: Scan'], + ['Admin sign-in A','Failed','High','MT.1014','Message A','Synthetic'], ['Admin sign-in B','Failed','High','CISA.MS.AAD.3.6','Message B','Synthetic'], + ['Azure hierarchy','Failed','High','MT.1056','Message C','Synthetic']], '', 'synthetic.csv', { isProjectSheet: true }); + sheet.analysis.records.forEach((r, i) => { r.findingOccurrenceId = `occ-${i}`; }); + const fs = sheet.analysis.records.map(r => ({ key: scannerFindingKey(r.findingOccurrenceId!), occurrenceId: r.findingOccurrenceId, tool: 'maester', + ruleId: r.fields[3].value, title: r.title, outcome: 'fail' })); + result = buildWorkPackages({ projectId: 'synthetic', assessmentIds: ['a'], scanRunIds: ['s'], findings: fs, + context: { engine: { version: '1' }, catalog: { version: '1' }, repositoryRevision: 1, + clusters: [{ clusterId: 'cloud-privileged-access-exposure', label: { en: 'Cloud Privileged Access Exposure' } }, + { clusterId: 'cloud-external-access-exposure', label: { en: 'Cloud External Access Exposure' } }], + // The third finding belongs to another cluster, so the package boundary yields one two-finding package. + findings: fs.map((f, i) => ({ occurrenceId: f.occurrenceId!, clusterIds: [i < 2 ? 'cloud-privileged-access-exposure' : 'cloud-external-access-exposure'], conditionIds: [] })), settings: [] } }); + app = { activeSheet: sheet, activeProfile: genericProfile, dataset: { fileName: 'Synthetic', sheets: [sheet] }, targetProjectId: 'synthetic', + activeAssessmentIds: ['a'], activeScanRunIds: ['s'], reviews: {}, search: '', statusFilter: 'all', facetFilters: {}, sortColumn: '', sortDirection: 'asc', + pathFindingIds: [], actionOnly: false, selectedRecordId: '', setFacetFilter: (id: string, filter: unknown) => { + app = { ...app, facetFilters: { ...app.facetFilters } }; if (filter) app.facetFilters[id] = filter as never; else delete app.facetFilters[id]; + vi.mocked(useApp).mockReturnValue(app); root.render(<>); + }, setSelectedRecord: (id: string) => { app = { ...app, selectedRecordId: id }; vi.mocked(useApp).mockReturnValue(app); root.render(<>); }, + setStatusFilter: vi.fn(), setActiveSheet: vi.fn(), resetFilters: vi.fn(), setActionOnly: vi.fn() } as unknown as ReturnType; + vi.mocked(useApp).mockReturnValue(app); + vi.spyOn(AssessorApiClient.prototype, 'getWorkPackages').mockResolvedValue({ status: 'ready', result }); + host = document.createElement('div'); document.body.append(host); root = createRoot(host); +}); +it.each(['light', 'dark'])('minimizes below Source: Scan and restores the retained Work Packages table (%s)', async theme => { + document.documentElement.dataset.theme = theme; + const originalWidth = window.innerWidth; + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1400 }); + try { + await act(async () => root.render(<>)); + const anchor = host.querySelector('[aria-label="Source: Scan"]')!; + expect(anchor).not.toBeNull(); + vi.spyOn(anchor, 'getBoundingClientRect').mockReturnValue({ bottom: 96 } as DOMRect); + await act(async () => host.querySelector('[data-work-packages-open]')!.click()); + const panel = document.querySelector('.floating-data-window')!; + const filter = panel.querySelector('[aria-label="Filter Package"]')!; + await act(async () => { filter.value = filter.options[1].value; filter.dispatchEvent(new Event('change', { bubbles: true })); }); + await act(async () => panel.querySelector('[data-sort-column="findings"]')!.click()); + const before = panel.querySelector('tbody')!.innerHTML; + const selectedFilter = filter.value; + const minimize = () => panel.querySelector('[aria-label="Minimize Work packages window"]')!.click(); + await act(async () => minimize()); + expect(panel.classList.contains('is-minimized')).toBe(true); + expect(panel.style.left).toBe('1064px'); expect(panel.style.top).toBe('104px'); expect(panel.style.width).toBe('320px'); + expect(panel.querySelector('.floating-data-window-body')!.hidden).toBe(true); + expect(panel.querySelector('.results-window-resize')).toBeNull(); + expect(panel.querySelector('.floating-data-window-actions')!.lastElementChild!.getAttribute('aria-label')).toBe('Close Work packages window'); + await act(async () => panel.querySelector('[aria-label="Open Work packages"]')!.click()); + expect(panel.classList.contains('is-minimized')).toBe(false); + expect(panel.style.left).toBe('554px'); expect(panel.style.width).toBe('830px'); + expect(panel.querySelector('.floating-data-window-body')!.hidden).toBe(false); + expect(filter.value).toBe(selectedFilter); expect(panel.querySelector('tbody')!.innerHTML).toBe(before); + await act(async () => panel.querySelector('[aria-label="Maximize Work packages window"]')!.click()); + await act(async () => minimize()); + expect(panel.classList.contains('is-maximized')).toBe(false); + expect(panel.classList.contains('is-minimized')).toBe(true); + await act(async () => panel.querySelector('[aria-label="Restore Work packages window"]')!.click()); + expect(panel.style.left).toBe('554px'); expect(filter.value).toBe(selectedFilter); + await act(async () => minimize()); + await act(async () => panel.querySelector('[aria-label="Close Work packages window"]')!.click()); + expect(document.querySelector('.floating-data-window')).toBeNull(); + } finally { Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth }); delete document.documentElement.dataset.theme; } +}); +afterEach(async () => { await act(async () => root.unmount()); host.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals(); resetEnginePreferencesForTests(); }); + +it.each(['light', 'dark'])('opens Results above Work Packages and brings either clicked window forward while Filters stays above both (%s)', async theme => { + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); + document.documentElement.dataset.theme = theme; + function Windows() { + const [shown, setShown] = useState(false); + return <> + + {shown && {}} onClose={() => setShown(false)}/>}; + } + await act(async () => root.render()); + await act(async () => host.querySelector('[data-work-packages-open]')!.click()); + const packages = document.querySelector('.floating-data-window')!; + await act(async () => [...host.querySelectorAll('button')].find(b => b.textContent === 'Results')!.click()); + const results = host.querySelector('.results-window')!; + expect(Number(results.style.zIndex)).toBeGreaterThan(Number(packages.style.zIndex)); + const activate = async (element: Element) => act(async () => element.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))); + for (let index = 0; index < 3; index++) { + await activate(packages); + expect(Number(packages.style.zIndex)).toBeGreaterThan(Number(results.style.zIndex)); + await activate(results); + expect(Number(results.style.zIndex)).toBeGreaterThan(Number(packages.style.zIndex)); + } + await act(async () => document.querySelector('[aria-label="Detach filters"]')!.click()); + const filterLayer = Number(readFileSync('src/styles/ui-tokens.css', 'utf8').match(/--z-floating-filters:\s*(\d+)/)![1]); + expect(filterLayer).toBeGreaterThan(Number(results.style.zIndex)); + expect(filterLayer).toBeGreaterThan(Number(packages.style.zIndex)); + expect(document.querySelector('.work-filter-panel.is-floating')).not.toBeNull(); + delete document.documentElement.dataset.theme; +}); + +it.each(['light', 'dark'])('shows legacy AP snapshots as WP in every package control and preserves exact selection IDs (%s)', async theme => { + document.documentElement.dataset.theme = theme; + result.packages.forEach(p => { p.code = p.code.replace(/^WP-/, 'AP-'); }); + const before = JSON.stringify(result); + await act(async () => root.render(<>)); + expect(host.querySelector('[data-work-package-id]')!.textContent).toContain('WP-01'); + const dropdown = host.querySelector('[aria-label="Work Package"]')!; + expect(dropdown.textContent).toContain('WP-01'); + expect(dropdown.textContent).not.toContain('AP-'); + await act(async () => host.querySelector('[data-work-packages-open]')!.click()); + const first = document.querySelector('[data-work-package-select]')!; + expect(first.textContent).toBe('WP-01'); + expect(document.querySelector('[data-work-packages-window]')!.textContent).not.toContain('AP-'); + await act(async () => first.click()); + expect(app.facetFilters[WORK_PACKAGE_FACET_ID]).toEqual({ values: [result.packages[0].id] }); + expect(JSON.stringify(result)).toBe(before); + delete document.documentElement.dataset.theme; +}); + +it.each(['light', 'dark'])('uses the actual heatmap positions in groups, both filters and the Reader without renumbering filtered groups (%s)', async theme => { + document.documentElement.dataset.theme = theme; + setEngineEnabled('context', true); + let context = emptyHeatmapContext(); + vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(context); + context.clusters.forEach(cluster => { cluster.satisfied = 1; }); + const sheet = makeSheet('Synthetic heatmap order', [['Title', 'Status', 'Severity', 'Source Rule ID'], + ...Array.from({ length: 10 }, (_, i) => [`Synthetic ${i}`, 'Failed', 'High', `order-${i}`])], '', 'order.csv', { isProjectSheet: true }); + const findings = sheet.analysis.records.map((record, i) => { + record.findingOccurrenceId = `order-${i}`; + const clusterId = i < 9 ? 'cloud-privileged-access-exposure' : 'credential-relay-exposure'; + const conditionId = context.clusters.find(cluster => cluster.clusterId === clusterId)!.conditions[0].conditionId; + context.observations.push({ id: record.id, occurrenceId: record.findingOccurrenceId, resultProjectionId: record.id, + conditionId, tool: 'maester', sourceRuleId: `order-${i}`, ruleText: record.title, outcome: 'fail', + matchedBy: 'rule-id', assertion: 'observed-by-scanner' } as never); + context.findings.push({ occurrenceId: record.findingOccurrenceId, resultProjectionId: record.id, tool: 'maester', sourceRuleId: `order-${i}`, + observationIds: [record.id], conditionIds: [], clusterIds: [clusterId], declaredReferenceIds: [], cisControlAreaIds: [] }); + return { key: scannerFindingKey(record.findingOccurrenceId), occurrenceId: record.findingOccurrenceId, tool: 'maester', ruleId: `order-${i}`, title: record.title, outcome: 'fail' }; + }); + result = buildWorkPackages({ projectId: 'synthetic', assessmentIds: ['a'], scanRunIds: ['s'], findings, context }); + vi.mocked(AssessorApiClient.prototype.getWorkPackages).mockResolvedValue({ status: 'ready', result }); + vi.mocked(useContextClusterIndex).mockReturnValue(contextClusterIndex(context)); + const render = () => root.render(<>); + app = { ...app, activeSheet: sheet, dataset: { ...app.dataset!, fileName: 'Synthetic order', sheets: [sheet] }, setFacetFilter: (id, filter) => { + app = { ...app, facetFilters: { ...app.facetFilters } }; + if (filter) app.facetFilters[id] = filter; else delete app.facetFilters[id]; + vi.mocked(useApp).mockReturnValue(app); render(); + } }; + vi.mocked(useApp).mockReturnValue(app); + const original = JSON.stringify({ context, result, records: sheet.analysis.records }); + await act(async () => render()); + await act(async () => host.querySelector('[data-work-packages-open]')!.click()); + const heatmapLabels = new Map([...host.querySelectorAll('[data-cluster]')].map(cell => [cell.dataset.cluster!, `${cell.querySelector('.context-heatmap__number')!.textContent} · ${cell.querySelector('strong')!.textContent}`])); + expect(heatmapLabels.get('cloud-privileged-access-exposure')).toBe('01 · Cloud Privileged Access Exposure'); + expect(heatmapLabels.get('credential-relay-exposure')).toBe('05 · Credential Relay Exposure'); + const groups = [...document.querySelectorAll('[data-work-package-cluster]')]; + expect(groups.map(group => group.textContent)).toEqual(['01 · Cloud Privileged Access Exposure', '05 · Credential Relay Exposure']); + for (const group of groups) expect(group.textContent).toBe(heatmapLabels.get(group.dataset.workPackageCluster!)); + expect([...document.querySelectorAll('[data-work-package-select]')].map(button => button.textContent)).toEqual(['WP-01', 'WP-02', 'WP-01']); + expect([...host.querySelectorAll('.work-package-cluster-head > span:first-child')].map(node => node.textContent)).toEqual(groups.map(group => group.textContent)); + for (const facetLabel of ['Context Cluster', 'Work Package']) { + const labels = [...host.querySelector(`select[aria-label="${facetLabel}"]`)!.options].slice(1).map(option => option.textContent); + expect(labels[0]).toMatch(/^01 · Cloud Privileged Access Exposure/); + expect(labels.some(label => label?.startsWith('05 · Credential Relay Exposure'))).toBe(true); + } + expect(host.querySelector('[data-work-package-id]')!.textContent).toContain('01 · Cloud Privileged Access Exposure → WP-01'); + const relay = result.packages.find(p => p.clusterId === 'credential-relay-exposure')!; + await act(async () => document.querySelector(`[data-work-package-select="${relay.id}"]`)!.click()); + expect(app.facetFilters[WORK_PACKAGE_FACET_ID]).toEqual({ values: [relay.id] }); + expect(host.querySelector('[data-record-count]')!.textContent).toBe('1'); + expect(host.querySelector('[data-work-package-id]')!.textContent).toContain('05 · Credential Relay Exposure → WP-01'); + expect([...document.querySelectorAll('[data-work-package-cluster]')].map(group => group.textContent)).toEqual(groups.map(group => group.textContent)); + const tableFilter = document.querySelector('[aria-label="Filter Package"]')!; + await act(async () => { tableFilter.value = relay.id; tableFilter.dispatchEvent(new Event('change', { bubbles: true })); }); + expect([...document.querySelectorAll('[data-work-package-cluster]')].map(group => group.textContent)).toEqual(['05 · Credential Relay Exposure']); + expect(JSON.stringify({ context, result, records: sheet.analysis.records })).toBe(original); + // A different published tie order must update every visible position, retaining the selected package ID. + context = { ...context, clusters: [...context.clusters].reverse() }; + vi.mocked(useContextClusterIndex).mockReturnValue(contextClusterIndex(context)); + await act(async () => render()); + const relayTile = host.querySelector('[data-cluster="credential-relay-exposure"]')!; + const relayHeatmapLabel = `${relayTile.querySelector('.context-heatmap__number')!.textContent} · ${relayTile.querySelector('strong')!.textContent}`; + expect(relayHeatmapLabel).not.toBe('05 · Credential Relay Exposure'); + expect(document.querySelector('[data-work-package-cluster]')!.textContent).toBe(relayHeatmapLabel); + expect(tableFilter.value).toBe(relay.id); + expect(host.querySelector('[data-work-package-id]')!.textContent).toContain(`${relayHeatmapLabel} → WP-01`); + delete document.documentElement.dataset.theme; +}); + +it.each(['dark', 'light'])('selects the real Work Package filter, displays the flag beside Source ID and removes Result Message in %s', async theme => { + document.documentElement.dataset.theme = theme; + await act(async () => { root.render(<>); await new Promise(resolve => setTimeout(resolve, 0)); }); + expect(host.querySelector('[data-record-count]')?.textContent).toBe('3'); + expect(host.querySelector('select[aria-label="Result Message"]')).toBeNull(); + const select = host.querySelector('select[aria-label="Work Package"]')!; + const selected = result.packages.find(p => p.findingKeys.length === 2)!; + expect(select.options.namedItem(selected.id)).toBeNull(); + expect([...select.options].find(o => o.value === selected.id)?.textContent).toContain('· 2'); + await act(async () => { select.value = selected.id; select.dispatchEvent(new Event('change', { bubbles: true })); }); + expect(host.querySelector('[data-record-count]')?.textContent).toBe('2'); + const source = host.querySelector('[data-source-rule-id]')!; + expect(source.closest('.reader-summary')).not.toBeNull(); + expect(source.querySelector('h4')?.textContent).toBe('Source Rule ID'); + expect(source.querySelector('code.mono')).not.toBeNull(); + expect(host.querySelector('.reader-heading [data-source-rule-id]')).toBeNull(); + expect(host.querySelector('.finding-badges [data-work-package-id]')).toBeNull(); + const link = source.querySelector('a[data-work-package-id]')!; + expect(link.textContent).toBe(`Cloud Privileged Access Exposure → ${selected.code}`); + expect(link.classList.contains('status-badge')).toBe(false); + const badges = host.querySelector('.finding-badges')!; + expect(badges.firstElementChild?.className).toBe('outcome-mark'); + expect(badges.querySelector('.outcome-mark')?.getAttribute('data-outcome')).toBe('fail'); + expect(badges.querySelector('.severity-badge.severity-high')?.textContent).toBe('High'); + await act(async () => app.setFacetFilter(WORK_PACKAGE_FACET_ID, null)); + expect(host.querySelector('[data-record-count]')?.textContent).toBe('3'); + await act(async () => host.querySelector('a[data-work-package-id]')!.click()); + expect(host.querySelector('[data-record-count]')?.textContent).toBe('2'); + expect(app.facetFilters[WORK_PACKAGE_FACET_ID]).toEqual({ values: [selected.id] }); + expect(AssessorApiClient.prototype.getWorkPackages).toHaveBeenCalledTimes(1); + await act(async () => setEngineEnabled('remediation', false)); + expect(host.querySelector('select[aria-label="Work Package"]')).toBeNull(); + expect(host.querySelector('[data-work-package-id]')).toBeNull(); + expect(app.facetFilters[WORK_PACKAGE_FACET_ID]).toBeUndefined(); + expect(host.querySelector('[data-record-count]')?.textContent).toBe('3'); + delete document.documentElement.dataset.theme; +}); +it('suppresses late output after disabling the engine and makes no new request', async () => { + let resolve!: (value: { status: 'ready'; result: typeof result }) => void; + vi.mocked(AssessorApiClient.prototype.getWorkPackages).mockReturnValue(new Promise(r => { resolve = r; })); + await act(async () => root.render()); + await act(async () => setEngineEnabled('remediation', false)); + await act(async () => resolve({ status: 'ready', result })); + expect(host.querySelector('[data-work-package-id]')).toBeNull(); + expect(host.querySelector('[data-record-count]')?.textContent).toBe('3'); + expect(AssessorApiClient.prototype.getWorkPackages).toHaveBeenCalledTimes(1); +}); + +it('offers a shared non-Cloud package and selects every associated finding through the real Work filter', async () => { + const fs = app.activeSheet!.analysis.records.map((r, i) => ({ key: scannerFindingKey(r.findingOccurrenceId!), + occurrenceId: r.findingOccurrenceId, tool: i === 0 ? 'pingcastle' : 'purple-knight', ruleId: `synthetic-${i}`, + title: r.title, outcome: 'fail' })); + result = buildWorkPackages({ projectId: 'synthetic', assessmentIds: ['a'], scanRunIds: ['s'], findings: fs, + context: { engine: { version: '1' }, catalog: { version: '1' }, repositoryRevision: 1, + clusters: [{ clusterId: 'ad-privileged-account-exposure', label: { en: 'AD Privileged Account Exposure' } }, + { clusterId: 'ad-persistence-indicators', label: { en: 'AD Persistence Indicators' } }], + findings: fs.map((f, i) => ({ occurrenceId: f.occurrenceId!, clusterIds: [i < 2 ? 'ad-privileged-account-exposure' : 'ad-persistence-indicators'], + conditionIds: [i < 2 ? 'ad-admins-not-protected-users' : 'schema-admins-not-empty'] })), settings: [] } }); + vi.mocked(AssessorApiClient.prototype.getWorkPackages).mockResolvedValue({ status: 'ready', result }); + await act(async () => root.render()); + const select = host.querySelector('select[aria-label="Work Package"]')!; + const selected = result.packages.find(p => p.findingKeys.length === 2)!; + expect([...select.options].find(o => o.value === selected.id)?.textContent).toContain('· 2'); + await act(async () => { select.value = selected.id; select.dispatchEvent(new Event('change', { bubbles: true })); }); + expect(host.querySelector('[data-record-count]')?.textContent).toBe('2'); + expect(host.querySelector('[data-work-package-id]')?.textContent).toBe(`AD Privileged Account Exposure → ${selected.code}`); +}); + +it('keeps a selected package from showing unrelated findings while a changed scope loads', async () => { + await act(async () => root.render()); + const select = host.querySelector('select[aria-label="Work Package"]')!; + const selected = result.packages.find(p => p.findingKeys.length === 2)!; + await act(async () => { select.value = selected.id; select.dispatchEvent(new Event('change', { bubbles: true })); }); + expect(host.querySelector('[data-record-count]')?.textContent).toBe('2'); + vi.mocked(AssessorApiClient.prototype.getWorkPackages).mockReturnValue(new Promise(() => {})); + app = { ...app, dataset: { ...app.dataset! } }; + vi.mocked(useApp).mockReturnValue(app); + await act(async () => root.render()); + expect(host.querySelector('[data-record-count]')?.textContent).toBe('0'); + expect(app.facetFilters[WORK_PACKAGE_FACET_ID]).toBeDefined(); +}); + +it.each(['dark', 'light'])('opens the sortable package list from the Work toolbar, selects a package and shows its redundant task in the Reader in %s', async theme => { + document.documentElement.dataset.theme = theme; + const fs = app.activeSheet!.analysis.records.map((r, i) => ({ key: scannerFindingKey(r.findingOccurrenceId!), + occurrenceId: r.findingOccurrenceId, tool: i === 0 ? 'pingcastle' : 'purple-knight', ruleId: r.fields[3].value, + title: r.title, outcome: 'fail', severity: i === 0 ? 'high' : 'critical' })); + // Both sources report the same specific condition once: one redundant task. The third finding sits in another cluster. + result = buildWorkPackages({ projectId: 'synthetic', assessmentIds: ['a'], scanRunIds: ['s'], findings: fs, + context: { engine: { version: '1' }, catalog: { version: '1' }, repositoryRevision: 1, + clusters: [{ clusterId: 'ad-privileged-account-exposure', label: { en: 'AD Privileged Account Exposure' } }, + { clusterId: 'ad-persistence-indicators', label: { en: 'AD Persistence Indicators' } }], + findings: fs.map((f, i) => ({ occurrenceId: f.occurrenceId!, clusterIds: [i < 2 ? 'ad-privileged-account-exposure' : 'ad-persistence-indicators'], + conditionIds: [i < 2 ? 'ad-admins-not-protected-users' : 'ad-stale-admincount'] })), settings: [] } }); + vi.mocked(AssessorApiClient.prototype.getWorkPackages).mockResolvedValue({ status: 'ready', result }); + await act(async () => { root.render(<>); await new Promise(resolve => setTimeout(resolve, 0)); }); + const redundant = result.packages.find(p => p.facts.redundantFindings === 2)!; + expect(redundant.tasks).toHaveLength(1); + expect(host.querySelector('[data-work-packages-open]')?.getAttribute('aria-pressed')).toBe('false'); + await act(async () => host.querySelector('[data-work-packages-open]')!.click()); + expect(host.querySelector('[data-work-packages-open]')?.getAttribute('aria-pressed')).toBe('true'); + expect(document.querySelector('.floating-data-window-head.is-marked')?.textContent).toContain('Work packages'); + await act(async () => document.querySelector('[aria-label="Close Work packages window"]')!.click()); + expect(host.querySelector('[data-work-packages-open]')?.getAttribute('aria-pressed')).toBe('false'); + await act(async () => host.querySelector('[data-work-packages-open]')!.click()); + const window = document.querySelector('[data-work-packages-window]')!; + expect(window.querySelector('th[aria-sort="ascending"] [data-sort-column="package"]')).not.toBeNull(); + expect(window.querySelector(`[data-work-package-select="${redundant.id}"]`)?.textContent).toBe(redundant.code); + expect(window.querySelector(`[data-work-package-select="${redundant.id}"]`)?.getAttribute('aria-label')).toBe(`AD Privileged Account Exposure → ${redundant.code}`); + expect(window.querySelectorAll('[data-work-package-row]')).toHaveLength(2); + expect(window.querySelectorAll('[data-work-package-unassigned]')).toHaveLength(1); + expect(window.querySelector('[data-work-package-select-unassigned]')!.disabled).toBe(true); + await act(async () => window.querySelector('[data-sort-column="findings"]')!.click()); + expect(window.querySelector('th[aria-sort="descending"] [data-sort-column="findings"]')).not.toBeNull(); + expect(window.querySelector('[data-work-package-row]')?.getAttribute('data-work-package-row')).toBe(result.packages.find(p => p.clusterId === 'ad-persistence-indicators')!.id); + expect(window.querySelector('[data-sort-column="severity"]')).toBeNull(); + await act(async () => window.querySelector(`[data-work-package-select="${redundant.id}"]`)!.click()); + expect(document.querySelector('[data-work-packages-window]')).not.toBeNull(); + expect(app.facetFilters[WORK_PACKAGE_FACET_ID]).toEqual({ values: [redundant.id] }); + await act(async () => root.render(<>)); + expect(host.querySelector('[data-work-packages-open]')?.getAttribute('aria-pressed')).toBe('true'); + expect(host.querySelector('[data-record-count]')?.textContent).toBe('2'); + const detail = host.querySelector('[data-work-package-task]')!; + expect(detail.textContent).toContain('One of 1 task'); + expect(detail.textContent).not.toMatch(/Task \d+ of/); + expect(host.querySelector('[data-work-package-redundant]')?.textContent).toBe(' · Same change as Synthetic CISA.MS.AAD.3.6'); + expect(host.querySelector('a[data-work-package-id]')?.textContent).toBe(`AD Privileged Account Exposure → ${redundant.code}`); + await act(async () => setEngineEnabled('remediation', false)); + expect(host.querySelector('[data-work-packages-open]')).toBeNull(); + expect(host.querySelector('[data-work-package-task]')).toBeNull(); + delete document.documentElement.dataset.theme; +}); + +it.each(['dark', 'light'])('offers selectable values without Measure, finding-severity or Critical/High filters (%s)', async theme => { + document.documentElement.dataset.theme = theme; + await act(async () => root.render(<>)); + await act(async () => host.querySelector('[data-work-packages-open]')!.click()); + const panel = document.querySelector('[data-work-packages-window]')!; + const before = JSON.stringify(result); + const filter = async (label: string, value: string) => act(async () => { + const input = panel.querySelector(`select[aria-label="Filter ${label}"]`)!; + value = [...input.options].find(option => option.textContent === value)?.value ?? value; + expect([...input.options].some(option => option.value === value)).toBe(true); + input.value = value; + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + expect(panel.querySelector('input')).toBeNull(); + expect(panel.querySelector('[aria-label="Filter Measure"]')).toBeNull(); + expect(panel.querySelector('[aria-label="Filter Highest finding severity"]')).toBeNull(); + expect(panel.querySelector('[aria-label="Filter Critical/High"]')).toBeNull(); + expect(panel.querySelector('[data-sort-column="severity"]')).toBeNull(); + expect(panel.querySelector('[data-sort-column="criticalHigh"]')).toBeNull(); + const inputs = [...panel.querySelectorAll('.work-packages__filter')]; + expect(inputs).toHaveLength(8); + for (const input of inputs) { + const label = input.getAttribute('aria-label')!.replace('Filter ', ''); + await filter(label, input.options[1].value); + expect(panel.querySelector('tbody tr')).not.toBeNull(); + expect(host.querySelector('[data-record-count]')?.textContent).toBe('3'); + await filter(label, ''); + expect(panel.querySelectorAll('[data-work-package-row]')).toHaveLength(2); + } + await filter('Tasks', '2'); + expect(panel.querySelectorAll('[data-work-package-row]')).toHaveLength(1); + await filter('Package', 'Cloud External Access Exposure → WP-01'); + expect(panel.querySelectorAll('[data-work-package-row]')).toHaveLength(0); + await filter('Tasks', ''); + expect(panel.querySelectorAll('[data-work-package-row]')).toHaveLength(1); + expect(panel.querySelector('[data-work-package-cluster]')?.textContent).toContain('Cloud External Access Exposure'); + await act(async () => [...panel.querySelectorAll('button')].find(b => b.textContent === 'Clear column filters')!.click()); + expect(panel.querySelectorAll('[data-work-package-row]')).toHaveLength(2); + expect(panel.querySelector('[data-work-package-unassigned]')).not.toBeNull(); + expect(panel.querySelector('[data-work-package-filter-count]')).toBeNull(); + expect(JSON.stringify(result)).toBe(before); + expect(app.facetFilters).toEqual({}); + delete document.documentElement.dataset.theme; +}); + +it.each(['dark', 'light'])('keeps numbered groups in published heatmap order through filtering and column sorting (%s)', async theme => { + document.documentElement.dataset.theme = theme; + const clusters = [ + ['credential-relay-exposure', 'Credential Relay Exposure', '01'], + ['cloud-privileged-access-exposure', 'Cloud Privileged Access Exposure', '02'], + ['cloud-sign-in-protection-gaps', 'Cloud Sign-in Protection Gaps', '03'], + ['malicious-code-execution-exposure', 'Malicious Code Execution Exposure', '12'] + ]; + const sheet = makeSheet('Synthetic order', [['Title', 'Status', 'Severity', 'Source Rule ID'], + ...Array.from({ length: 12 }, (_, i) => [`Finding ${i}`, 'Failed', i === 11 ? 'Critical' : 'High', `synthetic-${i}`])], '', 'order.csv', { isProjectSheet: true }); + sheet.analysis.records.forEach((r, i) => { r.findingOccurrenceId = `order-${i}`; }); + const findings = sheet.analysis.records.map((r, i) => ({ key: scannerFindingKey(r.findingOccurrenceId!), occurrenceId: r.findingOccurrenceId, + tool: 'maester', ruleId: `synthetic-${i}`, title: r.title, outcome: 'fail', severity: i === 11 ? 'critical' : 'high' })); + result = buildWorkPackages({ projectId: 'synthetic', assessmentIds: ['a'], scanRunIds: ['s'], findings, + context: { engine: { version: '1' }, catalog: { version: '1' }, repositoryRevision: 1, + clusters: clusters.map(([clusterId, label]) => ({ clusterId, label: { en: label } })), + findings: findings.map((f, i) => ({ occurrenceId: f.occurrenceId!, clusterIds: [clusters[i < 9 ? 0 : i - 8][0]], conditionIds: [] })), settings: [] } }); + app = { ...app, activeSheet: sheet, dataset: { ...app.dataset!, fileName: 'Synthetic order', sheets: [sheet] } }; + vi.mocked(useApp).mockReturnValue(app); + vi.mocked(AssessorApiClient.prototype.getWorkPackages).mockResolvedValue({ status: 'ready', result }); + vi.mocked(useContextClusterIndex).mockReturnValue({ publication: { clusters: [clusters[1], clusters[3], clusters[0], clusters[2]].map(([id, label], i) => ({ id, label, hasEvidence: true, severity: i < 2 ? 'critical' : 'high' })) }, + clusters: [], facet: { id: 'context:cluster', label: 'Context Cluster', kind: 'category', source: 'derived', values: [], valuesFor: () => [] } } as never); + await act(async () => root.render()); + const open = async () => act(async () => host.querySelector('[data-work-packages-open]')!.click()); + const labels = () => [...document.querySelectorAll('[data-work-package-select]')].map(el => el.getAttribute('aria-label')); + const expected = [ + '01 · Cloud Privileged Access Exposure → WP-01', '02 · Malicious Code Execution Exposure → WP-01', + '03 · Credential Relay Exposure → WP-01', '03 · Credential Relay Exposure → WP-02', + '04 · Cloud Sign-in Protection Gaps → WP-01' + ]; + await open(); + expect(labels()).toEqual(expected); + expect([...document.querySelectorAll('[data-work-package-cluster]')].map(el => el.textContent)).toEqual([ + '01 · Cloud Privileged Access Exposure', '02 · Malicious Code Execution Exposure', '03 · Credential Relay Exposure', '04 · Cloud Sign-in Protection Gaps']); + expect([...document.querySelectorAll('[data-work-package-select]')].map(el => el.textContent)).toEqual(['WP-01', 'WP-01', 'WP-01', 'WP-02', 'WP-01']); + const filter = document.querySelector('[aria-label="Filter Package"]')!; + await act(async () => { filter.value = [...filter.options].find(option => option.textContent === expected[1])!.value; filter.dispatchEvent(new Event('change', { bubbles: true })); }); + expect(labels()).toEqual([expected[1]]); + await act(async () => { filter.value = ''; filter.dispatchEvent(new Event('change', { bubbles: true })); }); + expect(labels()).toEqual(expected); + await act(async () => document.querySelector('[data-sort-column="contextSeverity"]')!.click()); + expect(labels()).toEqual(expected); + await act(async () => document.querySelector('[data-sort-column="package"]')!.click()); + expect(labels()).toEqual(expected); + expect(document.querySelector('.work-packages__table tbody tr:last-child')?.hasAttribute('data-work-package-unassigned')).toBe(true); + await act(async () => document.querySelector('[aria-label="Close Work packages window"]')!.click()); + await open(); + expect(labels()).toEqual(expected); + delete document.documentElement.dataset.theme; +}); + +it('updates an open package overview after Context preparation and dataset refresh, suppressing an older late response', async () => { + const contextRequest = vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue({ + findings: [], settings: [], clusters: [], coverage: { uncataloguedWithoutRecognition: 0, scannerFindings: 0 }, + engine: { version: 'synthetic' }, catalog: { version: 'synthetic' }, repositoryRevision: 2 + } as never); + await act(async () => setEngineEnabled('context', true)); + const render = () => root.render(<>); + await act(async () => render()); + await act(async () => host.querySelector('[data-work-packages-open]')!.click()); + expect(document.querySelectorAll('[data-work-package-row]')).toHaveLength(2); + let resolveOld!: (response: { status: 'ready'; result: typeof result }) => void; + let resolveNew!: typeof resolveOld; + vi.mocked(AssessorApiClient.prototype.getWorkPackages) + .mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve; })) + .mockReturnValueOnce(new Promise(resolve => { resolveNew = resolve; })); + app = { ...app, dataset: { ...app.dataset! } }; vi.mocked(useApp).mockReturnValue(app); + await act(async () => render()); + expect(document.querySelector('[data-work-packages-window]')).toBeNull(); + app = { ...app, dataset: { ...app.dataset! } }; vi.mocked(useApp).mockReturnValue(app); + await act(async () => render()); + await act(async () => resolveOld({ status: 'ready', result })); + expect(document.querySelector('[data-work-packages-window]')).toBeNull(); + const p = result.packages.find(p => p.findingKeys.length === 1)!; + const next = { ...result, repositoryRevision: 2, packages: [{ ...p, title: 'Refreshed published measure' }], + unassignedFindingKeys: result.packages.filter(other => other.id !== p.id).flatMap(other => other.findingKeys) }; + await act(async () => resolveNew({ status: 'ready', result: next })); + expect(document.querySelectorAll('[data-work-package-row]')).toHaveLength(1); + expect(document.querySelector('[data-work-packages-window]')?.textContent).toContain('Refreshed published measure'); + expect(document.querySelector('[data-work-package-unassigned-summary]')?.textContent).toBe('2'); + expect(contextRequest).toHaveBeenCalledTimes(3); + expect(AssessorApiClient.prototype.getWorkPackages).toHaveBeenCalledTimes(3); + await act(async () => setEngineEnabled('remediation', false)); + expect(document.querySelector('[data-work-packages-window]')).toBeNull(); + expect(host.querySelector('[data-work-packages-open]')).toBeNull(); +}); + +it.each(['dark', 'light'])('keeps the full published package contents and compact totals across Work search, status, package filters and reopening (%s)', async theme => { + document.documentElement.dataset.theme = theme; + const sheet = makeSheet('Synthetic reconciliation', [['Title', 'Status', 'Severity', 'Source Rule ID', 'Source: Scan'], + ...Array.from({ length: 274 }, (_, i) => [i === 0 ? 'Only matched declaration' : i < 258 ? `Assigned ${i}` : `Unassigned ${i - 258}`, + 'Failed', 'High', `synthetic-${i}`, 'Synthetic'])], '', 'synthetic.csv', { isProjectSheet: true }); + sheet.analysis.records.forEach((record, i) => { record.findingOccurrenceId = `reconcile-${i}`; }); + const keys = sheet.analysis.records.map(record => scannerFindingKey(record.findingOccurrenceId!)); + let nextFinding = 0; + const packages: WorkPackage[] = Array.from({ length: 49 }, (_, packageIndex) => { + const tasks = Array.from({ length: packageIndex < 7 ? 6 : 5 }, (_, taskIndex) => { + const findingKeys = keys.slice(nextFinding, nextFinding + (packageIndex === 0 ? 2 : 1)); + nextFinding += findingKeys.length; + return { id: `task-${packageIndex}-${taskIndex}`, findingKeys, redundant: findingKeys.length > 1 }; + }); + const findingKeys = tasks.flatMap(task => task.findingKeys); + return { id: `package-${packageIndex}`, code: `WP-${String(packageIndex + 1).padStart(2, '0')}`, + clusterId: 'ad-privileged-account-exposure', clusterLabel: 'Synthetic cluster', platform: 'active-directory', + title: 'Synthetic published change', tasks, findingKeys, measures: [], method: 'individual', reason: 'Synthetic published task identities', + facts: { tasks: tasks.length, findings: findingKeys.length, redundantFindings: packageIndex === 0 ? 12 : 0, + highestSeverity: 'high', criticalOrHighFindings: findingKeys.length, conditionsClosed: 0, conditionsTouched: 0, sources: ['Synthetic'] } }; + }); + expect(nextFinding).toBe(258); + result = { ...result, packages, unassignedFindingKeys: keys.slice(258) }; + app = { ...app, activeSheet: sheet, dataset: { ...app.dataset!, fileName: 'Synthetic reconciliation', sheets: [sheet] }, statusFilter: 'deviation', + facetFilters: {}, search: '', reviews: {} }; + vi.mocked(useApp).mockReturnValue(app); + vi.mocked(AssessorApiClient.prototype.getWorkPackages).mockResolvedValue({ status: 'ready', result }); + const originalRecords = JSON.stringify(sheet.analysis.records), originalPackages = JSON.stringify(result); + const openOverview = async () => { + await act(async () => { root.render(<>); await new Promise(resolve => setTimeout(resolve, 0)); }); + if (!document.querySelector('[data-work-packages-window]')) + await act(async () => host.querySelector('[data-work-packages-open]')!.click()); + return document.querySelector('[data-work-packages-window]')!; + }; + let window = await openOverview(); + expect(host.querySelector('[data-record-count]')!.textContent).toBe('274'); + const facts = () => Object.fromEntries([...window.querySelectorAll('.work-packages__facts > div')].map(cell => + [cell.querySelector('dt')!.textContent, cell.querySelector('dd')!.textContent])); + const expectedFacts = { Findings: '274', Assigned: '258', Unassigned: '16', Packages: '49', Tasks: '252', + 'Redundant findings': '12' }; + const originalTable = window.querySelector('tbody')!.innerHTML; + const unchanged = () => { + expect(facts()).toEqual(expectedFacts); + expect(window.querySelectorAll('[data-work-package-row]')).toHaveLength(49); + expect(window.querySelector('tbody')!.innerHTML).toBe(originalTable); + }; + unchanged(); + app = { ...app, search: 'Unassigned' }; vi.mocked(useApp).mockReturnValue(app); + await act(async () => root.render(<>)); + expect(host.querySelector('[data-record-count]')!.textContent).toBe('16'); + unchanged(); + await act(async () => window.querySelector(theme === 'dark' ? '[data-work-package-select-unassigned]' : '[data-work-package-unassigned-summary]')!.click()); + expect(app.facetFilters[WORK_PACKAGE_FACET_ID]).toEqual({ values: [UNASSIGNED_WORK_PACKAGE] }); + expect(app.statusFilter).toBe('deviation'); + expect(app.search).toBe('Unassigned'); + expect(document.querySelector('[data-work-packages-window]')).not.toBeNull(); + window = await openOverview(); + unchanged(); + await act(async () => document.querySelector('[aria-label="Close Work packages window"]')!.click()); + app = { ...app, facetFilters: {}, search: 'Only matched declaration' }; vi.mocked(useApp).mockReturnValue(app); + window = await openOverview(); + expect(host.querySelector('[data-record-count]')!.textContent).toBe('1'); + unchanged(); + app = { ...app, statusFilter: 'passed', search: 'no matching finding' }; vi.mocked(useApp).mockReturnValue(app); + await act(async () => root.render(<>)); + expect(host.querySelector('[data-record-count]')!.textContent).toBe('0'); + unchanged(); + expect(JSON.stringify(sheet.analysis.records)).toBe(originalRecords); + expect(JSON.stringify(result)).toBe(originalPackages); + delete document.documentElement.dataset.theme; +}); + +it('keeps records outside the published package snapshot separately visible in the reconciliation', () => { + const records = app.activeSheet!.analysis.records; + const outside = { ...records[0], id: 'outside-snapshot', findingOccurrenceId: 'outside-snapshot' }; + const before = JSON.stringify(result); + const overview = workPackageOverview([...records, outside], workPackageIndex(result)); + expect(overview.records).toBe(4); + expect(overview.assigned).toBe(3); expect(overview.unassigned).toBe(0); expect(overview.outsideSnapshot).toBe(1); + expect(overview.assigned + overview.unassigned + overview.outsideSnapshot).toBe(overview.records); + expect(JSON.stringify(result)).toBe(before); +}); + +it('opens the redundant finding of the same task with only the package filter changed, and reports a finding hidden by other Work filters', async () => { + const fs = app.activeSheet!.analysis.records.map((r, i) => ({ key: scannerFindingKey(r.findingOccurrenceId!), + occurrenceId: r.findingOccurrenceId, tool: i === 0 ? 'pingcastle' : 'purple-knight', ruleId: r.fields[3].value, title: r.title, outcome: 'fail' })); + result = buildWorkPackages({ projectId: 'synthetic', assessmentIds: ['a'], scanRunIds: ['s'], findings: fs, + context: { engine: { version: '1' }, catalog: { version: '1' }, repositoryRevision: 1, + clusters: [{ clusterId: 'ad-privileged-account-exposure', label: { en: 'AD Privileged Account Exposure' } }, + { clusterId: 'ad-persistence-indicators', label: { en: 'AD Persistence Indicators' } }], + findings: fs.map((f, i) => ({ occurrenceId: f.occurrenceId!, clusterIds: [i < 2 ? 'ad-privileged-account-exposure' : 'ad-persistence-indicators'], + conditionIds: [i < 2 ? 'ad-admins-not-protected-users' : 'ad-stale-admincount'] })), settings: [] } }); + vi.mocked(AssessorApiClient.prototype.getWorkPackages).mockResolvedValue({ status: 'ready', result }); + const [first, second] = app.activeSheet!.analysis.records; + const redundant = result.packages.find(p => p.facts.redundantFindings === 2)!; + await act(async () => { root.render(<>); await new Promise(resolve => setTimeout(resolve, 0)); }); + // Visible case: no package filter yet; the click sets it and selects the other finding. + const link = host.querySelector(`[data-work-package-finding="${second.id}"]`)!; + expect(link.textContent).toBe('Synthetic CISA.MS.AAD.3.6'); + await act(async () => link.click()); + expect(app.facetFilters[WORK_PACKAGE_FACET_ID]).toEqual({ values: [redundant.id] }); + expect(app.selectedRecordId).toBe(second.id); + expect(host.querySelector('[data-record-count]')?.textContent).toBe('2'); + expect(host.querySelector(`[data-work-package-finding="${first.id}"]`)?.textContent).toBe('Synthetic MT.1014'); + // Hidden case: a search keeps only the current finding; the click reports it and changes nothing. + app = { ...app, search: 'Admin sign-in B', facetFilters: {}, selectedRecordId: '' }; + vi.mocked(useApp).mockReturnValue(app); + await act(async () => root.render()); + expect(host.querySelector('[data-record-count]')?.textContent).toBe('1'); + await act(async () => host.querySelector(`[data-work-package-finding="${first.id}"]`)!.click()); + expect(host.querySelector('[data-work-package-hidden]')?.textContent).toBe(' · Synthetic MT.1014 is hidden by the current Work filters.'); + expect(app.facetFilters).toEqual({}); + expect(app.search).toBe('Admin sign-in B'); + expect(app.selectedRecordId).toBe(''); +}); + +it.each(['dark', 'light'])('matches Context severity between Filters and Packages, including GPO and live updates (%s)', async theme => { + document.documentElement.dataset.theme = theme; + const p = result.packages.find(p => p.findingKeys.length === 2)!; + p.platform = 'windows-host'; p.facts.highestSeverity = undefined; + const cluster = { id: p.clusterId, severity: 'critical', hasEvidence: true }; + const index = { publication: { clusters: [cluster] }, clusters: [], facet: { + id: 'context:cluster', label: 'Context Cluster', kind: 'category', source: 'derived', values: [], valuesFor: () => [] + } } as unknown as NonNullable>; + vi.mocked(useContextClusterIndex).mockReturnValue(index); + const render = () => root.render(<>); + await act(async () => render()); + await act(async () => host.querySelector('[data-work-packages-open]')!.click()); + const row = () => document.querySelector('[data-work-package-row="' + p.id + '"]')!; + expect(document.querySelector('[data-work-package-row]')?.getAttribute('data-work-package-row')).toBe(p.id); + expect(row().querySelector('[data-context-severity="critical"]')?.textContent).toBe('Critical'); + expect(document.querySelector('.work-package-cluster-colour.severity-critical')).not.toBeNull(); + expect(document.querySelector('[data-sort-column="severity"]')).toBeNull(); + cluster.severity = 'medium'; + vi.mocked(useContextClusterIndex).mockReturnValue({ ...index }); + await act(async () => render()); + expect(row().querySelector('[data-context-severity="medium"]')?.textContent).toBe('Medium'); + expect(document.querySelector('.work-package-cluster-colour.severity-medium')).not.toBeNull(); + cluster.hasEvidence = false; + await act(async () => render()); + expect(row().querySelector('[data-context-severity="undetermined"]')?.textContent).toBe('Undetermined'); + expect(document.querySelector('.work-package-cluster-colour.severity-undetermined')).not.toBeNull(); + expect(p.facts.highestSeverity).toBeUndefined(); + delete document.documentElement.dataset.theme; +}); + + +it.each(['light', 'dark'])('opens all same-change evidence in Work from the summary and parks Packages with matching cluster squares (%s)', async theme => { + document.documentElement.dataset.theme = theme; + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1400 }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 1200 }); + const p = result.packages.find(p => p.findingKeys.length === 2)!; + p.tasks = [{ ...p.tasks[0], findingKeys: p.findingKeys, redundant: true }]; + p.facts.tasks = 1; p.facts.redundantFindings = 2; + app = { ...app, activeSheetId: app.activeSheet!.id, workScopeId: 'synthetic', search: 'Azure hierarchy', actionOnly: false, + applyViewBookmark: saved => { app = { ...app, ...saved }; vi.mocked(useApp).mockReturnValue(app); } }; + vi.mocked(useApp).mockReturnValue(app); + const render = () => root.render(); + await act(async () => { render(); await new Promise(resolve => setTimeout(resolve, 0)); }); + expect(host.querySelector('[data-record-count]')!.textContent).toBe('1'); + await act(async () => host.querySelector('[data-work-packages-open]')!.click()); + const panel = document.querySelector('.floating-data-window')!; + expect(panel.style.width).toBe('830px'); expect(panel.style.height).toBe('1080px'); + expect(panel.style.left).toBe('554px'); expect(panel.style.top).toBe('60px'); + await act(async () => panel.querySelector('[aria-label="Maximize Work packages window"]')!.click()); + expect(panel.classList.contains('is-maximized')).toBe(true); + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1600 }); + await act(async () => panel.querySelector('[aria-label="Restore Work packages window"]')!.click()); + expect(panel.style.left).toBe('754px'); expect(panel.style.width).toBe('830px'); + await act(async () => panel.querySelector('.floating-data-window-head')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))); + expect(panel.classList.contains('is-maximized')).toBe(true); + await act(async () => panel.querySelector('.floating-data-window-head')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))); + expect(panel.style.left).toBe('754px'); + expect(panel.querySelectorAll('[data-work-package-colour]')).toHaveLength(0); + for (const row of panel.querySelectorAll('[data-work-package-row]')) + expect(row.querySelector('.work-package-cluster-colour')).toBeNull(); + expect(panel.querySelectorAll('.work-packages__cluster .work-package-cluster-colour')).toHaveLength(new Set(result.packages.map(p => p.clusterId)).size); + const before = JSON.stringify(result); + const link = panel.querySelector('[data-work-package-related-summary]')!; + expect(link.textContent).toBe('2'); expect(link.disabled).toBe(false); + await act(async () => { link.click(); render(); }); + expect(document.querySelector('[data-work-packages-window]')).not.toBeNull(); + expect(panel.style.left).toBe('754px'); + expect(app.pathFindingIds).toHaveLength(2); expect(app.search).toBe(''); expect(app.facetFilters).toEqual({}); + expect(app.statusFilter).toBe('all'); expect(app.view).toBe('records'); expect(app.actionOnly).toBe(false); + expect(host.querySelector('[data-record-count]')!.textContent).toBe('2'); + expect(JSON.stringify(result)).toBe(before); + await act(async () => panel.querySelector('[aria-label="Close Work packages window"]')!.click()); + expect(document.querySelector('[data-work-packages-window]')).toBeNull(); + await act(async () => host.querySelector('[data-work-packages-open]')!.click()); + expect(document.querySelector('.floating-data-window')!.style.left).toBe('754px'); + delete document.documentElement.dataset.theme; +}); diff --git a/tests/reporting/client-report-domains.test.ts b/tests/reporting/client-report-domains.test.ts new file mode 100644 index 0000000..cfd2571 --- /dev/null +++ b/tests/reporting/client-report-domains.test.ts @@ -0,0 +1,32 @@ +import { expect, it } from 'vitest'; +import type { ImportedDataset } from '../../src/domain/dataset'; +import type { UnifiedResultProjection } from '../../src/domain/unified-result'; +import { makeSheet } from '../../src/importers/sheet-factory'; +import { securityDomainCatalog } from '../../src/evaluation/security-domains'; +import { clientReportInput } from '../../src/reporting/client-report-projection'; + +// Synthetic data only. Management report domain rows count Work records, as the headline and Client Info do. +it('counts report domain rows on the same Work records as the headline, so their sums reconcile', () => { + const sheet = makeSheet('Synthetic · All Records', [ + ['Title', 'Status', 'Category', 'Source Rule ID', 'Scope', 'Technical Identity', 'Policy type', 'Policy section', 'Source: Scan'], + ['Audit on DCs', 'Matched', 'Anomalies', 'A-AuditDC', '', '', '', '', 'PingCastle · synthetic'], + ['Synthetic user right', 'Deviation', 'GPOs', '', 'computer', 'gpo-policy-identity-synthetic', 'security-template', 'Privilege Rights', 'GPOs · synthetic'] + ], '', 'synthetic', { isProjectSheet: true }); + // One rolled-up GPO setting backed by two per-member comparison results. + const results = ['eval-1', 'eval-2'].map((id, index) => ({ + resultProjectionId: `r${index}`, sourceKind: 'policy-evaluation', sourceResultId: id, sourceTool: 'GPOs', categories: [], + isDeviation: true, policyScope: 'computer', policyIdentityKey: 'gpo-policy-identity-synthetic' + })) as unknown as UnifiedResultProjection[]; + const input = clientReportInput({ + project: { id: 'project-synthetic', name: 'Synthetic' } as never, generatedAt: '2026-10-04T00:00:00.000Z', + results, dataset: { sheets: [sheet] } as unknown as ImportedDataset + }); + const domains = input.workspaceDomains!; + expect(domains.map(domain => domain.domainId)).toEqual(securityDomainCatalog.map(domain => domain.id)); + expect(domains.reduce((sum, domain) => sum + domain.deviations, 0)).toBe(input.workspaceCounts!.deviations); + expect(domains.reduce((sum, domain) => sum + domain.assessed, 0)).toBe(input.workspaceCounts!.assessed); + // The result-row summary counts both comparison rows; the Work-level rows never do. + const resultRows = input.results.filter(result => result.isDeviation).length; + expect(resultRows).toBe(2); + expect(domains.reduce((sum, domain) => sum + domain.assessed, 0)).toBeLessThan(sheet.analysis.records.length + 1); +}); diff --git a/tests/reporting/context-management-report.test.ts b/tests/reporting/context-management-report.test.ts index 188d512..671087f 100644 --- a/tests/reporting/context-management-report.test.ts +++ b/tests/reporting/context-management-report.test.ts @@ -79,9 +79,10 @@ it('renders the offline management cut with confidence, limitations and no hidde expect(html).not.toMatch(/ { @@ -245,3 +246,66 @@ it('places theme evidence tables at full width with narrow numeric columns', () expect(css).toContain('th.num,td.num{width:1%;white-space:nowrap;text-align:right'); expect(css).not.toContain('overflow-wrap:anywhere'); }); + +it('lists the themes in one count order in summary, overview, chart and theme sheets', () => { + const source = projection(); + const [first, second] = source.context!.clusters; + Object.assign(first, { id: 'data-sharing-exposure', scanner: 3, gpo: 0, hasEvidence: true, + findingKeys: ['k1', 'k2', 'k3'], resultIds: ['k1', 'k2', 'k3'] }); + Object.assign(second, { id: 'lateral-movement-exposure', scanner: 5, gpo: 0, hasEvidence: true, + findingKeys: ['k4', 'k5', 'k6', 'k7', 'k8'], resultIds: ['k4', 'k5', 'k6', 'k7', 'k8'] }); + source.context!.findingCount = 8; + const document = new JSDOM(buildContextManagementHtml(source)).window.document; + const themes = managementBusinessImpacts(source.context!); + const counts = new Map(themes.map(item => [item.label, item.findingCount])); + const expected = [...themes].sort((left, right) => right.findingCount - left.findingCount).map(item => item.label); + expect(counts.get(expected[0])).toBe(5); + const overview = [...document.querySelectorAll('.business-overview tbody tr')].map(item => item.firstElementChild?.textContent); + const chart = [...document.querySelectorAll('.bar-row')].map(item => item.firstElementChild?.textContent); + const sheets = [...document.querySelectorAll('.report-sheet')].map(item => item.getAttribute('aria-label')).filter(label => counts.has(label!)); + const actions = [...document.querySelectorAll('.action-fields li')].map(item => item.textContent?.split(' – ')[0]); + expect(overview.slice(0, 4)).toEqual(expected); + expect(chart.slice(0, 4)).toEqual(expected); + expect(sheets).toEqual(expected); + expect(actions).toEqual(expected.filter(label => counts.get(label)! > 0)); + expect(document.querySelector('.two-column p:nth-of-type(2)')?.textContent).toContain(`Den größten Anteil hat ${expected[0]}`); +}); + +it('renders Work-level domain rows that reconcile to the headline counts when the projection provides them', () => { + const source = projection(); + source.workspaceCounts = { deviations: 4, assessed: 7 }; + source.workspaceDomains = [ + { domainId: 'identity', domainLabel: 'Identity & Authentication', passed: 2, deviations: 1, assessed: 3 }, + { domainId: 'hardening', domainLabel: 'System Hardening', passed: 1, deviations: 3, assessed: 4 } + ]; + source.domains = [{ domainId: 'hardening', domainLabel: 'System Hardening', passed: 9, deviations: 9, assessed: 18, evidence: { passed: [], deviations: [] } }]; + const document = new JSDOM(buildContextManagementHtml(source)).window.document; + const heading = [...document.querySelectorAll('h2')].find(item => item.textContent === 'Technische Domain-Auswertung')!; + const rows = [...heading.nextElementSibling!.querySelectorAll('tbody tr')].map(row => [...row.children].map(cell => cell.textContent)); + expect(rows).toEqual([['Identity & Authentication', '3', '1', '2', '33 %'], ['System Hardening', '4', '3', '1', '75 %']]); + expect(document.body.textContent).toContain('System Hardening die höchste Abweichungsquote (3 von 4, 75 %)'); + expect(document.body.textContent).toContain('die Summen entsprechen den bewerteten Ergebnissen und Abweichungen der Summary'); +}); + +it('takes unassessed states, collections and the risk register from the Client Info counting rules', () => { + // Synthetic regression (Dummy 2026-10-05): result rows gave 170 not run and 0 unknown, Work shows 171 and 19; + // processing steps were counted as 38 collections; a draft risk acceptance read as documented acceptance. + const source = projection(); + source.coverage.notRun = 170; source.coverage.unknown = 0; source.coverage.executionErrors = 2; source.coverage.notApplicable = 3; + source.workspaceCounts = { deviations: 4, assessed: 7, notRun: 171, executionErrors: 2, notApplicable: 3, notConfigured: 879, unknown: 19 }; + source.riskRegister = { recorded: 1, current: 0 }; + source.remediation = { selfReported: true, items: 0, byStatus: {}, withoutOwner: 0, withoutDue: 0, riskAccepted: 1 }; + source.provenance = [ + { kind: 'ScanRun', id: 'scan-1', observedAt: '2026-09-08T12:00:00Z' }, + { kind: 'ScanRun', id: 'gpo-collection', observedAt: '2026-09-03T14:00:00Z' }, + { kind: 'ImportFile: synthetic.xml', id: 'file-1', observedAt: '2026-10-04T10:00:00Z', sourceDate: '2026-07-22T08:00:00Z' }, + { kind: 'EvaluationRun: completed', id: 'evaluation-1', observedAt: '2026-10-04T11:00:00Z' } + ]; + const text = new JSDOM(buildContextManagementHtml(source)).window.document.body.textContent!; + expect(text).toContain('171 Prüfungen wurden nicht ausgeführt, 2 endeten mit einem Ausführungsfehler'); + expect(text).toMatch(/Nicht ausgeführte Prüfungen\s*171/); + expect(text).toContain('Nicht anwendbar: 3 · nicht konfiguriert: 879 · sonstige unbewertete Ergebnisse: 19.'); + expect(text).toContain('Grundlage sind 2 Erhebungen im Timeslot vom 22.7.2026.'); + expect(text).toContain('Im Risikoregister ist eine Risikoakzeptanz erfasst; keine davon ist aktuell gültig.'); + expect(text).not.toContain('Risikoakzeptanz dokumentiert'); +}); diff --git a/tests/reporting/report-header-facts.test.ts b/tests/reporting/report-header-facts.test.ts new file mode 100644 index 0000000..fddbbe2 --- /dev/null +++ b/tests/reporting/report-header-facts.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import type { EnvironmentSnapshot } from '../../src/domain/environment-context'; +import { clientInfoOverview, cloudInformationTileFacts, operatingSystemOverview } from '../../src/features/dashboard/client-info-facts'; +import { reportGpoBackupCount, reportHeaderFacts } from '../../src/reporting/report-header-facts'; + +const observation = (property: string, value: unknown, label = property) => ({ + property, label, state: 'observed', normalizedValue: value, originalValue: value, + subject: { type: 'domain' }, tool: { id: 'synthetic' }, sectionKey: 'synthetic' +}); + +const snapshot = { + version: 1, projectId: 'synthetic-project', scanRunIds: ['synthetic-run'], generatedAt: '2026-10-08T00:00:00Z', + coverage: { observed: 0, explicitlyAbsent: 0, empty: 0, restricted: 0, incomplete: 0 }, + observations: [ + observation('domain.fqdn', 'synthetic.example', 'Domain'), + observation('forest.name', 'synthetic.example'), + observation('domain.controller_count', 2, 'DC count'), + observation('domain.recycle_bin_enabled', false, 'Recycle Bin enabled') + ], + sections: [{ + sectionKey: 'sectionUserInformation', label: 'Users', state: 'observed', + tables: [{ name: 'User information', headers: ['Nb User Accounts', 'Nb Enabled', 'Nb Disabled'], rows: [[12, 10, 2]] }] + }] +} as unknown as EnvironmentSnapshot; + +describe('Technical Report Information facts', () => { + it('states exactly the Client Info overview facts of the same snapshot', () => { + const facts = reportHeaderFacts(snapshot, { gpoCount: 3 })!; + const overview = clientInfoOverview(snapshot, { gpoCount: 3 }); + expect(facts.data).toEqual([ + { label: 'AD Domains', value: '1' }, { label: 'Entra Tenant', value: '—' }, { label: 'User Accounts', value: '12' }, + { label: 'Computer Objects', value: '—' }, { label: 'Domain Controllers', value: '2' }, { label: 'GPOs', value: '3' }]); + expect(facts.data.map(item => item.label)).toEqual(overview.data.map(item => item.label)); + // Domain Information without the counts Data already shows, as in Client Info. + expect(facts.domainInformation).toEqual(overview.domainInformation); + expect(facts.domainInformation.some(item => item.label === 'Domain controllers')).toBe(false); + const group = (title: string) => facts.groups.find(item => item.title === title)!.facts; + expect(facts.groups.map(item => item.title)).toEqual( + ['Resilience', 'Inventory', 'Password policy', 'Certificates & PKI', 'Operating systems', 'Cloud Information']); + expect(group('Resilience')).toEqual(overview.detail.resilience.slice(0, 4)); + expect(group('Inventory')).toEqual(overview.inventory.slice(0, 4)); + expect(group('Operating systems')).toEqual(operatingSystemOverview(overview.detail.operatingSystems)); + expect(group('Cloud Information').map(item => item.label)).toEqual(cloudInformationTileFacts(snapshot).map(item => item.label)); + // Unknown facts stay an em dash, never zero. + expect(group('Password policy').every(item => item.value === '—')).toBe(true); + }); + + it('infers one Entra tenant only from loaded Maester results, as Client Info does', () => { + expect(reportHeaderFacts(snapshot, { inferredEntraTenant: true })!.data[1]).toEqual({ label: 'Entra Tenant', value: '1' }); + expect(reportHeaderFacts(undefined, {})).toBeUndefined(); + }); + + it('counts GPOs like the GPO backup list: instances of the latest processed run per backup source', () => { + const artifacts = [ + { id: 'backup-a', kind: 'source-file', relativePath: 'gpo-backups/a.zip' }, + { id: 'backup-b', kind: 'source-file', relativePath: 'gpo-backups/b.zip' }, + { id: 'scan', kind: 'source-file', relativePath: 'scans/report.html' } + ] as never; + const runs = [ + { id: 'run-a-old', artifactId: 'backup-a', status: 'complete', completedAt: '2026-10-01T00:00:00Z' }, + { id: 'run-a-new', artifactId: 'backup-a', status: 'complete', completedAt: '2026-10-02T00:00:00Z' } + ] as never; + const instances = [ + { processingRunId: 'run-a-old' }, { processingRunId: 'run-a-new' }, { processingRunId: 'run-a-new' } + ] as never; + expect(reportGpoBackupCount(artifacts, runs, instances)).toBe(2); + expect(reportGpoBackupCount(artifacts, [], instances)).toBeUndefined(); + }); +}); diff --git a/tests/reporting/report-input-assembler.test.ts b/tests/reporting/report-input-assembler.test.ts index 031579e..f839da8 100644 --- a/tests/reporting/report-input-assembler.test.ts +++ b/tests/reporting/report-input-assembler.test.ts @@ -40,7 +40,9 @@ describe('report input assembly', () => { }]), listGpoPolicyAnalyses: vi.fn(async () => []), listPolicySnapshotAggregations: vi.fn(async () => []), - listPolicyEvaluations: vi.fn(async () => []) + listPolicyEvaluations: vi.fn(async () => []), + listImportArtifacts: vi.fn(async () => []), + listGpoBackupInstances: vi.fn(async () => []) } as unknown as AssessorRepository; const input = await assembleReportProjectionInput(repository, { projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], scanRunIds: ['dummy-run'], diff --git a/tests/reporting/report-narrative.test.ts b/tests/reporting/report-narrative.test.ts index 0b31ce6..86c2c0a 100644 --- a/tests/reporting/report-narrative.test.ts +++ b/tests/reporting/report-narrative.test.ts @@ -6,7 +6,6 @@ import { buildManagementOverviewHtml } from '../../src/reporting/management-over import { buildReportProjection, type ReportProjection } from '../../src/reporting/report-projection'; import { statementText } from '../../src/reporting/report-statement-text'; import type { ReportStatement } from '../../src/reporting/report-narrative'; -import { buildTechnicalReportHtml } from '../../src/reporting/technical-report'; const domains = [ { id: 'identity', label: 'Identity & Access' }, @@ -48,7 +47,7 @@ describe('Report Engine key statements', () => { ...many(2, 'identity', 'not_run'), ...many(1, 'legacy', 'not_applicable'), ...many(1, 'legacy', 'execution_error'), ...many(1, 'cloud', 'informational') ]); - expect(projection.version).toBe('1.3.0'); + expect(projection.version).toBe('1.5.0'); expect(projection.statements.map(item => item.id)).toEqual(['overall', 'focus', 'limits']); expect(statementText(statement(projection, 'overall')).text) .toBe('Im Assessment wurden 14 Ergebnisse bewertet. 5 davon weichen vom Soll ab (36 %).'); @@ -111,17 +110,4 @@ describe('Report Engine key statements', () => { expect(html).not.toMatch(/ { - const projection = project([...many(2, 'identity', 'fail'), ...many(1, 'identity', 'pass'), ...many(1, 'legacy', 'not_run')]); - const html = buildTechnicalReportHtml(projection); - const document = new JSDOM(html).window.document; - const section = [...document.querySelectorAll('section.block')].find(item => item.querySelector('h2')?.textContent === 'Belege der Kernaussagen')!; - expect(section).toBeTruthy(); - for (const item of projection.statements) expect(section.textContent).toContain(statementText(item).text); - for (const id of projection.coverage.evidence.deviations) expect(section.textContent).toContain(id); - expect(section.textContent).toContain(projection.coverage.evidence.notRun[0]); - const index = [...document.querySelectorAll('section.block')].at(-1)!; - expect(index.querySelector('th')?.textContent).toBe('Result-ID'); - expect(index.textContent).toContain(projection.coverage.evidence.notRun[0]); - }); }); diff --git a/tests/reporting/report-run.test.ts b/tests/reporting/report-run.test.ts index 6178ad9..f863ab8 100644 --- a/tests/reporting/report-run.test.ts +++ b/tests/reporting/report-run.test.ts @@ -42,6 +42,8 @@ function dummyRepository(): ReportReadMethods & { saveReview: () => Promise ({ observations: [] })), listReviews: vi.fn(async () => []), listProjectProcessingRuns: vi.fn(async () => []), + listImportArtifacts: vi.fn(async () => []), + listGpoBackupInstances: vi.fn(async () => []), listGpoPolicyAnalyses: vi.fn(async () => []), listPolicySnapshotAggregations: vi.fn(async () => []), saveReview: vi.fn(async () => undefined) @@ -61,7 +63,9 @@ describe('Report Engine run', () => { const run = await createReportRun(port, scope, { requireContext: true, readContext }); expect(run.files.management).toContain('Context-Heatmap'); expect(run.files.management).not.toContain('secret-result-one'); - expect(run.files.technical).toContain('secret-result-one'); + // Result IDs prove the Context counts in the archived projection; the technical HTML aggregates. + expect(run.projection.context?.clusters[0].resultIds).toContain('secret-result-one'); + expect(run.files.technical).not.toContain('secret-result-one'); expect(renderReportFiles(JSON.parse(JSON.stringify(run.projection)))).toEqual(run.files); expect(repository.saveReview).not.toHaveBeenCalled(); const technicalOnly = await createReportRun(port, scope, { requireContext: false }); @@ -78,8 +82,8 @@ describe('Report Engine run', () => { expect((port as unknown as Record).saveReview).toBeUndefined(); expect(Object.isFrozen(port)).toBe(true); expect(Object.keys(port).sort()).toEqual([ - 'currentRevision', 'getEnvironmentSnapshot', 'getProject', 'listAssessments', 'listGpoPolicyAnalyses', - 'listMicrosoftSecurityBaselineAnalyses', 'listPolicyEvaluations', 'listPolicySnapshotAggregations', + 'currentRevision', 'getEnvironmentSnapshot', 'getProject', 'listAssessments', 'listGpoBackupInstances', 'listGpoPolicyAnalyses', + 'listImportArtifacts', 'listMicrosoftSecurityBaselineAnalyses', 'listPolicyEvaluations', 'listPolicySnapshotAggregations', 'listProjectProcessingRuns', 'listReferenceRuns', 'listReviews', 'loadAssessmentModel', 'queryUnifiedResults' ]); }); @@ -111,6 +115,7 @@ describe('Report Engine run', () => { rebuilt.context = reportContext(); rebuilt.managementContextRequired = true; expect(run.files).toEqual(renderReportFiles(rebuilt)); expect(run.files.management).not.toContain('DUMMY-RULE-7'); - expect(run.files.technical).toContain('DUMMY-RULE-7'); + expect(JSON.stringify(run.projection)).toContain('DUMMY-RULE-7'); + expect(run.files.technical).toContain('Technical Report'); }); }); diff --git a/tests/reporting/reporting-boundary.test.ts b/tests/reporting/reporting-boundary.test.ts index fe68b46..c19901d 100644 --- a/tests/reporting/reporting-boundary.test.ts +++ b/tests/reporting/reporting-boundary.test.ts @@ -67,15 +67,20 @@ describe('Report Engine boundary', () => { 'src/reporting/management-overview-report.ts', 'src/reporting/management-report-texts.ts', 'src/reporting/report-brand.ts', + 'src/reporting/report-charts.ts', 'src/reporting/report-context.ts', 'src/reporting/report-correlation.ts', 'src/reporting/report-font.ts', + 'src/reporting/report-header-facts.ts', 'src/reporting/report-input-assembler.ts', 'src/reporting/report-narrative.ts', 'src/reporting/report-projection.ts', 'src/reporting/report-read-port.ts', + 'src/reporting/report-rich-text.ts', 'src/reporting/report-run.ts', 'src/reporting/report-statement-text.ts', + 'src/reporting/report-workspace-view.ts', + 'src/reporting/technical-report-model.ts', 'src/reporting/technical-report.ts' ]); }); diff --git a/tests/reporting/technical-report.test.ts b/tests/reporting/technical-report.test.ts index 9079ee1..07bbf0e 100644 --- a/tests/reporting/technical-report.test.ts +++ b/tests/reporting/technical-report.test.ts @@ -1,44 +1,357 @@ -import { describe, expect, it } from 'vitest'; -import { buildReportProjection } from '../../src/reporting/report-projection'; -import type { UnifiedResultProjection } from '../../src/domain/unified-result'; +import { describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { JSDOM } from 'jsdom'; +import type { ImportedDataset } from '../../src/domain/dataset'; +import { makeSheet } from '../../src/importers/sheet-factory'; +import { buildReportProjection, type ReportProjection, type ReportWorkRecord } from '../../src/reporting/report-projection'; +import { buildReportWorkspaceView, resultStatement } from '../../src/reporting/report-workspace-view'; import { buildTechnicalReportHtml } from '../../src/reporting/technical-report'; +import { reportRichText } from '../../src/reporting/report-rich-text'; +import { buildTechnicalReportModel } from '../../src/reporting/technical-report-model'; +import { reportContext } from './context-report-fixture'; + +// Vitest stubs CSS imports; the production build embeds the token files verbatim (`?raw`). +vi.mock('../../src/styles/ui-tokens.css?raw', () => ({ default: readFileSync('src/styles/ui-tokens.css', 'utf8') })); +vi.mock('../../src/styles/tokens.css?raw', () => ({ default: readFileSync('src/styles/tokens.css', 'utf8') })); + +// Synthetic data only. +const NO_SCRIPT = / = {}): ReportWorkRecord { + return { + id, title: `Synthetic check ${id}`, toolId, toolLabel: toolId, state, severity: 'unknown', domainId, + ruleId: `RULE-${id}`, countingKey: toolId === 'group-policy' ? `gpo:computer\u0000identity-${id}` : `scanner:occ-${id}`, + diagnostics: [], attack: [], defenses: [], ...extra + }; +} + +/** Synthetic Work cut: 12 deviations (9 scanner, 3 GPO), 5 passed, 3 unassessed; Client Info totals derived from the same records. */ +function projection(): ReportProjection { + const records: ReportWorkRecord[] = [ + ...Array.from({ length: 6 }, (_, index) => record(`pk-${index}`, 'purple-knight', 'deviation', 'identity', { + severity: index < 2 ? 'critical' : 'medium', area: 'Account Security', + attack: index < 3 ? [{ kind: 'tactic', id: 'tactic:Credential Access', title: 'Credential Access', tactics: ['Credential Access'], evidence: 'declared' }] : [], + defenses: index === 0 ? [{ id: 'D3-SPP', title: 'D3-SPP · Strong Password Policy', evidence: 'declared' }] : [] + })), + ...Array.from({ length: 3 }, (_, index) => record(`cis-${index}`, 'cis-cat', 'deviation', 'hardening', { + area: 'Local Policies > User Rights Assignment', + attack: index === 0 ? [{ kind: 'technique', id: 'T1110', title: 'Brute Force', tactics: ['Credential Access'], evidence: 'mentioned' }] : [] + })), + ...Array.from({ length: 3 }, (_, index) => record(`gpo-${index}`, 'group-policy', 'deviation', 'hardening', { + area: 'Privilege Rights', policy: { type: 'security-template', backups: 2, unlinkedBackups: index === 0 ? 1 : 0 } + })), + ...Array.from({ length: 4 }, (_, index) => record(`pass-${index}`, 'purple-knight', 'passed', 'identity', { area: 'Account Security' })), + record('gpo-pass', 'group-policy', 'passed', 'hardening', { policy: { type: 'registry-value', backups: 1, unlinkedBackups: 0 } }), + record('not-run', 'maester', 'not-run', 'identity'), + record('not-configured', 'group-policy', 'not-configured', 'hardening', { policy: { type: 'registry-value', backups: 0, unlinkedBackups: 0 } }), + record('na', 'purple-knight', 'not-applicable', 'identity') + ]; + const projection = buildReportProjection({ + generatedAt: '2026-10-07T10:00:00Z', project: { id: 'synthetic-project', name: 'Synthetic ' }, + results: [], domains: DOMAINS.map(item => ({ id: item.domainId, label: item.domainLabel })), domainOf: () => 'identity', + overallScore: 42, + scoreSources: [ + { toolId: 'purple-knight', score: 60, method: 'native', weight: 1, nativeValue: 60, nativeProperty: 'purple_knight.ad.security_posture_score', passed: 4, deviations: 6, assessed: 10, issues: [] }, + { toolId: 'cis-cat', score: 0, method: 'pass-rate', weight: 1, passed: 0, deviations: 3, assessed: 3, issues: ['Incomplete assessment'] }, + { toolId: 'group-policy', score: 25, method: 'pass-rate', weight: 1, passed: 1, deviations: 3, assessed: 4, issues: [] } + ], + workspaceCounts: { deviations: 12, assessed: 17, notRun: 1, notApplicable: 1, notConfigured: 1 }, + workspaceDomains: DOMAINS.map(domain => { + const inDomain = records.filter(item => item.domainId === domain.domainId); + const passed = inDomain.filter(item => item.state === 'passed').length; + const deviations = inDomain.filter(item => item.state === 'deviation').length; + return { ...domain, passed, deviations, assessed: passed + deviations }; + }), + workspaceView: { + version: 'synthetic', attackCatalogVersion: '19.2', + totals: { passed: 5, deviations: 12, notConfigured: 1, conflicts: 0, duplicates: 0, assessed: 17, eligible: 19, unassessed: 2 }, + records, + environment: { directory: [{ label: 'Domain', value: 'synthetic.example' }], resilience: [{ label: 'AD Recycle Bin', value: '—' }], + operatingSystems: [], restricted: [] } + }, + policyEntries: [ + ...Array.from({ length: 3 }, (_, index) => ({ id: `gpo-${index}`, verdict: 'Fail' as const, conflict: false, duplicate: false, + title: 'Synthetic GPO', scope: 'computer', identity: `identity-gpo-${index}`, backupInstanceIds: ['backup-a', 'backup-b'] })), + { id: 'gpo-pass', verdict: 'Pass', conflict: false, duplicate: false, title: 'Synthetic GPO', scope: 'computer', identity: 'identity-gpo-pass', backupInstanceIds: ['backup-a'] }, + { id: 'not-configured', verdict: 'Not configured', conflict: false, duplicate: false, title: 'Synthetic GPO', scope: 'computer', identity: 'identity-nc', backupInstanceIds: [] } + ], + riskRegister: { recorded: 2, current: 1 } + }); + const context = reportContext(); + const pk = records.filter(item => item.toolId === 'purple-knight' && item.state === 'deviation'); + const cis = records.filter(item => item.toolId === 'cis-cat'); + const gpo = records.filter(item => item.toolId === 'group-policy' && item.state === 'deviation'); + // Exclusive partition of 10 of the 12 deviations; two remain without a problem category. + context.clusters = [ + { ...context.clusters[0], id: 'ad-privileged-account-exposure', label: 'AD Privileged Account Exposure', severity: 'critical', + scanner: 6, gpo: 1, findingKeys: [...pk.map(item => item.countingKey), gpo[0].countingKey], resultIds: ['secret-result-one'] }, + { ...context.clusters[0], id: 'lateral-movement-exposure', label: 'Lateral Movement Exposure', severity: 'high', + scanner: 2, gpo: 1, findingKeys: [cis[0].countingKey, cis[1].countingKey, gpo[1].countingKey], resultIds: ['secret-result-two'] }, + { ...context.clusters[0], id: 'email-spoofing-phishing-exposure', label: 'Email Spoofing & Phishing Exposure', severity: 'high', + scanner: 0, gpo: 0, hasEvidence: false, findingKeys: [], resultIds: [] } + ]; + projection.context = context; + return projection; +} + +const render = (source: ReportProjection) => new JSDOM(buildTechnicalReportHtml(source)).window.document; +const sectionText = (document: Document, id: string) => (document.getElementById(id)?.textContent ?? '').replace(/\s+/g, ' '); describe('Technical Report', () => { - it('renders source-backed actual/expected values, exact GPO instructions and all policy states without scripts', () => { - const result = { - resultProjectionId: 'dummy-result', sourceKind: 'policy-evaluation', sourceResultId: 'dummy-source', - projectId: 'dummy-project', collectionRunId: 'dummy-run', sourceTool: 'GPOs', - title: '', sourceRuleId: 'registry:dummy', isDeviation: true, - severityNormalized: 'unknown', outcomeNormalized: 'fail', policyActualValue: 0, policyBaselineValue: 1, - policyComparisonRelation: 'different', policyDiagnosticCodes: ['dummy-conflict'], - frameworkReferences: [{ namespace: 'GPOs', value: 'MITRE T1110' }] - } as unknown as UnifiedResultProjection; - const projection = buildReportProjection({ - generatedAt: '2026-09-23T10:00:00Z', project: { id: 'dummy-project', name: 'Dummy Customer' }, - results: [result], domains: [{ id: 'gpo', label: 'GPO & Baseline' }], domainOf: () => 'gpo', - policyEntries: [{ id: 'dummy-setting', verdict: 'Fail', conflict: true, duplicate: false, - title: 'Dummy Registry Rule', scope: 'machine', identity: 'dummy-key', backupInstanceIds: ['dummy-backup'] }], - technicalPolicySources: [{ settingId: 'dummy-setting', sources: [{ - id: 'dummy-source', backupInstanceId: 'dummy-backup', gpoId: '{DUMMY-GUID}', - nativeBackupId: '{DUMMY-BACKUP}', sourceReference: 'dummy/source', sequence: 2, - values: [{ kind: 'registry-policy', operation: 'set-value', data: 0, typeCode: 'REG_DWORD' }], - deviationFields: [['data']] - }] }] - }); - const html = buildTechnicalReportHtml(projection); - expect(html).toContain(''); - expect(html).toContain('<Dummy Registry Rule>'); - expect(html).not.toContain(''); - expect(html).toContain('Konfiguriert'); - expect(html).toContain('Erwartet'); - expect(html).toContain('dummy/source'); - expect(html).toContain('dummy-backup'); - expect(html).toContain('MITRE T1110'); - expect(html).toContain('
    0
    '); - for (const verdict of ['Deviation', 'Passed', 'Not configured', 'No baseline rule', 'Not comparable', 'Conflicts', 'Duplicates']) { - expect(html).toContain(verdict); + it('keeps the A4 report at its shared readable sizes when opened in a wide browser window', () => { + const document = new JSDOM(buildTechnicalReportHtml(projection())).window.document; + const css = [...document.querySelectorAll('style')].map(style => style.textContent).join('\n'); + // The desktop path previously added zoom:1.2 above 1200px, enlarging every + // font, row and chart. Browser zoom remains the reader's own choice. + expect(css).not.toMatch(/zoom\s*:\s*(?!1\s*[;}])[^;}]+/); + expect(css).not.toMatch(/transform\s*:\s*scale\(/); + expect(css).toContain('width:210mm;max-width:100%'); + expect(css).toContain('--type-body-size: 16px'); + expect(css).toContain('--type-table-body-size: 14px'); + expect(css).toContain('font-size:var(--type-body-size)'); + expect(css).toContain('font-size:var(--type-table-body-size)'); + expect(document.querySelector('meta[name="viewport"]')?.getAttribute('content')).toBe('width=device-width,initial-scale=1'); + }); + + it('is a static German report in the light ASSESSOR token language that loads and executes nothing', () => { + const source = projection(); + const before = JSON.stringify(source); + const html = buildTechnicalReportHtml(source); + expect(html).toContain(''); + expect(html).toContain('--tile-surface'); + expect(html).toContain('font-family:"Inter Variable"'); + expect(html).toContain('Synthetic <Client>'); + expect(html).not.toContain('Synthetic '); + expect(html).not.toMatch(NO_SCRIPT); + expect(JSON.stringify(source)).toBe(before); + const document = new JSDOM(html).window.document; + expect([...document.querySelectorAll('section.report-section h2')].map(item => item.textContent)).toEqual([ + 'Lagebild', 'Beziehungen (Pathfinder)', 'Problemfelder', 'Datenbasis und Datenstand']); + expect(document.querySelectorAll('nav .toc a')).toHaveLength(4); + expect(html).toContain('@page{size:A4'); + }); + + it('treats ASSESSOR as the single source and never names or compares scan tools', () => { + const html = buildTechnicalReportHtml(projection()); + const document = new JSDOM(html).window.document; + const text = document.body.textContent ?? ''; + expect(text).not.toMatch(/Purple Knight|PingCastle|CIS-CAT|Maester|purple-knight|cis-cat|maester|Scanner|Quelle\b|Quellen\b/); + // A rule ID appears only inside the Finding Reader card head of its own finding. + document.querySelectorAll('.finding-card').forEach(card => card.remove()); + expect(document.body.textContent).not.toContain('RULE-'); + }); + + it('reports the Client Info figures: headline and Security Domains come from the same Work records', () => { + const source = projection(); + const model = buildTechnicalReportModel(source); + expect(model.totals).toMatchObject({ deviations: source.workspaceCounts!.deviations, assessed: source.workspaceCounts!.assessed, passed: 5 }); + for (const domain of source.workspaceDomains!) { + expect(model.domains.find(item => item.id === domain.domainId)).toMatchObject({ + passed: domain.passed, deviations: domain.deviations, assessed: domain.assessed }); } - expect(html).toContain('effektive Endpunktzustand ist nicht verifiziert'); - expect(html).not.toMatch(/ sum + item.deviations, 0)).toBe(12); + const document = render(source); + expect(sectionText(document, 'lagebild')).toContain('Von 17 bewerteten Prüfungen weichen 12 vom Soll ab (71 %)'); + expect(document.querySelector('#lagebild .donut-tile strong')?.textContent).toBe('42'); + const basis = Object.fromEntries([...document.querySelectorAll('#datenbasis dl div')].map(item => [item.querySelector('dt')?.textContent, item.querySelector('dd')?.textContent])); + expect(basis).toMatchObject({ 'Records gesamt': '20', Bewertet: '17', 'Nicht bewertet': '3' }); + }); + + it('partitions every Client Info deviation exactly once, in the exact order and counts of the ASSESSOR heatmap', () => { + const source = projection(); + // ASSESSOR is authoritative: the published heatmap order is kept even when counts would sort differently. + source.context!.clusters.reverse(); + const model = buildTechnicalReportModel(source); + expect(model.context!.clusters.map(item => [item.id, item.count])).toEqual([ + ['email-spoofing-phishing-exposure', 0], ['lateral-movement-exposure', 3], ['ad-privileged-account-exposure', 7]]); + expect(model.context!.clusters.map(item => item.id)).toEqual(source.context!.clusters.map(item => item.id)); + for (const cluster of source.context!.clusters) { + expect(model.context!.clusters.find(item => item.id === cluster.id)!.count).toBe(cluster.findingKeys!.length); + } + expect(model.context!.partitionTotal).toBe(10); + expect(model.context!.empty).toEqual(['Email Spoofing & Phishing Exposure']); + const document = render(source); + const rows = [...document.querySelectorAll('#problemfelder .pf-list > .pf:not(.pf--sum)')]; + const counts = rows.map(row => Number(row.querySelector('.pf-num')?.textContent)); + expect(counts).toEqual([0, 3, 7, 2]); + expect(counts.reduce((sum, value) => sum + value, 0)).toBe(12); + expect(rows.at(-1)?.querySelector('.pf-title')?.textContent).toBe('Ohne Problemkategorie'); + // A problem field without deviations is a plain row; populated fields open as an ASSESSOR disclosure row. + expect(rows.map(row => row.tagName)).toEqual(['DIV', 'DETAILS', 'DETAILS', 'DIV']); + expect(document.querySelector('#problemfelder .pf--sum .pf-num')?.textContent).toBe('12'); + }); + + it('opens a finding line to its Finding Reader card: head, MITRE badges, Problem, Description and catalog references', () => { + const source = projection(); + const keys = source.context!.clusters.find(cluster => cluster.id === 'ad-privileged-account-exposure')!.findingKeys!; + const record = source.workspaceView!.records.find(item => item.countingKey === keys[0])!; + record.ruleId = 'SYN-001'; + record.card = { + status: { value: 'Failed', outcome: 'fail' }, + badges: [{ text: 'Weight: 8' }, { text: 'Critical', severity: 'critical' }, { text: 'Category: Synthetic Area' }], + problem: { heading: 'Problem', rows: [{ label: 'Result', value: 'Found 1 synthetic object.' }, { label: 'Affected', value: '1' }] }, + mitre: ['MITRE ATT&CK: Credential Access'], + description: 'First synthetic paragraph.\n\nSecond synthetic paragraph.', + references: [{ label: 'ATT&CK TA0006 · Credential Access · Declared · Catalog match', text: 'Credential Access', url: 'https://attack.mitre.org/tactics/TA0006/' }] + }; + const document = render(source); + const line = [...document.querySelectorAll('#pf-ad-privileged-account-exposure details.finding')] + .find(item => item.querySelector('summary')?.textContent === record.title)!; + expect(line.hasAttribute('open')).toBe(false); + const card = line.querySelector('.finding-card')!; + expect(card.querySelector('h4')?.textContent).toBe(record.title); + expect(card.querySelector('.fc-status')?.textContent).toBe('Failed'); + expect([...card.querySelector('.fc-badges')!.querySelectorAll('.meta-badge, .sev-badge')].map(item => item.textContent)) + .toEqual(['Weight: 8', 'Critical', 'Category: Synthetic Area']); + expect([...card.querySelectorAll('.fc-head .fc-badges')[1].querySelectorAll('.meta-badge')].map(item => item.textContent)) + .toEqual(['MITRE ATT&CK: Credential Access']); + expect(card.querySelector('.context-badge b')?.textContent).toBe('AD Privileged Account Exposure'); + expect(card.querySelector('.fc-rule code')?.textContent).toBe('SYN-001'); + expect([...card.querySelectorAll('.fc-problem dt')].map(item => item.textContent)).toEqual(['Result', 'Affected']); + // Description and each catalog reference are collapsed disclosures below the Problem block, as in the Finding Reader. + const more = [...card.querySelectorAll('details.fc-more')]; + expect(more.map(item => item.querySelector('summary')?.textContent)).toEqual(['Description', 'ATT&CK TA0006 · Credential Access · Declared · Catalog match']); + expect(more.every(item => !item.hasAttribute('open'))).toBe(true); + expect([...more[0].querySelectorAll('.rich-text p')].map(item => item.textContent)).toEqual(['First synthetic paragraph.', 'Second synthetic paragraph.']); + expect(more[1].querySelector('a')?.getAttribute('href')).toBe('https://attack.mitre.org/tactics/TA0006/'); + }); + + it('opens a problem field row to its situation, technical lever, Security Domains and slim finding lines', () => { + const source = projection(); + const document = render(source); + const row = document.getElementById('pf-ad-privileged-account-exposure')!; + expect(row.tagName).toBe('DETAILS'); + expect(row.hasAttribute('open')).toBe(false); + expect(row.querySelector('summary .pf-title b')?.textContent).toBe('AD Privileged Account Exposure'); + expect(row.querySelector('summary .pf-num')?.textContent).toBe('7'); + expect(row.textContent).toContain('Technischer Ansatzpunkt'); + expect(row.querySelector('.cluster-domains')?.textContent).toBe('Security Domains: Identity & Authentication 6 · System Hardening 1'); + // Every Work record of the field appears once, reduced to severity hexagon and title. + const keys = source.context!.clusters.find(cluster => cluster.id === 'ad-privileged-account-exposure')!.findingKeys!; + const titles = source.workspaceView!.records.filter(record => keys.includes(record.countingKey)).map(record => record.title).sort(); + const lines = [...row.querySelectorAll('.pf-findings li')]; + expect(lines.map(line => line.querySelector('summary')?.textContent).sort()).toEqual(titles); + expect(lines.every(line => line.querySelector('summary i.hex'))).toBe(true); + expect(row.querySelector('table')).toBeNull(); + const html = buildTechnicalReportHtml(source); + expect(html).not.toMatch(/secret-result|occ-pk-0/); + }); + + it('aggregates Pathfinder relations as Security Domain to problem field edges without a separate MITRE block', () => { + const source = projection(); + const model = buildTechnicalReportModel(source); + expect(model.context!.domainLinks).toEqual([ + { domainId: 'identity', clusterId: 'ad-privileged-account-exposure', count: 6 }, + { domainId: 'hardening', clusterId: 'ad-privileged-account-exposure', count: 1 }, + { domainId: 'hardening', clusterId: 'lateral-movement-exposure', count: 3 }]); + const document = render(source); + const relations = sectionText(document, 'beziehungen'); + expect(document.querySelectorAll('#beziehungen svg.flow path.flow-link')).toHaveLength(3); + // Every heatmap problem field appears as a node, a field without deviations included. + expect(document.querySelectorAll('#beziehungen svg.flow rect.flow-node')).toHaveLength(2 + 3); + expect([...document.querySelectorAll('#beziehungen svg.flow text')].map(item => item.textContent?.split(' ')[0])) + .toEqual(expect.arrayContaining(['Identity', 'System'])); + expect(relations).not.toMatch(/ATT&CK|D3FEND|MITRE/); + expect(document.querySelectorAll('#beziehungen table')).toHaveLength(0); + }); + + it('keeps every other section when no Context result of the same state is available', () => { + const source = projection(); + delete source.context; + const document = render(source); + expect(sectionText(document, 'problemfelder')).toContain('Für diesen Datenstand liegen keine Problemfelder vor.'); + expect(document.querySelectorAll('section.report-section')).toHaveLength(4); + expect(sectionText(document, 'lagebild')).toContain('Von 17 bewerteten Prüfungen weichen 12'); + }); + + it('opens with the supplied Client Info overview facts as section Information', () => { + const source = projection(); + source.headerFacts = { + data: [{ label: 'AD Domains', value: '1' }, { label: 'GPOs', value: '3' }], + domainInformation: [{ label: 'Domain', value: 'synthetic.example' }, { label: 'Forest', value: 'synthetic.example' }], + groups: [{ title: 'Resilience', facts: [{ label: 'AD Recycle Bin', value: 'Disabled' }] }] + }; + const document = render(source); + const header = document.getElementById('information')!; + expect([...document.querySelectorAll('section.report-section h2')].map(item => item.textContent)).toEqual([ + 'Information', 'Lagebild', 'Beziehungen (Pathfinder)', 'Problemfelder', 'Datenbasis und Datenstand']); + expect([...header.querySelectorAll('.hf-strip > div')].map(cell => cell.textContent)).toEqual(['1AD Domains', '3GPOs']); + const groups = [...header.querySelectorAll('.hf-groups .hf-group')]; + expect(groups.map(group => group.querySelector('h3')?.textContent)).toEqual(['Domain Information', 'Resilience']); + expect([...groups[0].querySelectorAll('.hf-rows > div')].map(cell => cell.textContent)).toEqual(['Domainsynthetic.example', 'Forestsynthetic.example']); + expect(header.querySelector('.tile')).toBeNull(); + }); + + it('shows Overall Score and the deviations as donuts next to the six Security Domains; all checks by result in the data basis', () => { + const source = projection(); + const document = render(source); + const tiles = [...document.querySelectorAll('#lagebild .donut-tile')]; + expect(tiles.map(tile => tile.querySelector('figcaption')?.textContent)).toEqual(['Overall Score', 'Abweichungen']); + // The deviation ring is one plain arc again: no severity split, no legend. + expect(tiles[1].querySelectorAll('circle.ring-value')).toHaveLength(1); + expect(tiles[1].querySelector('.donut-legend')).toBeNull(); + expect(tiles[1].querySelector('.ring-readout strong')?.textContent).toBe(String(source.workspaceCounts!.deviations)); + // Security Domains: every Client Info domain in catalog order with its Client Info deviation count. + const rows = [...document.querySelectorAll('#lagebild figure.domains .domain-row')]; + expect(rows.map(row => [row.querySelector('dt')?.textContent, Number(row.querySelector('.domain-num')?.textContent)])) + .toEqual(source.workspaceDomains!.map(domain => [domain.domainLabel, domain.deviations])); + // All checks by result state sit only in the data basis and sum to every Work record. + expect(document.querySelector('#lagebild figure.checks')).toBeNull(); + const model = buildTechnicalReportModel(source); + const counts = [...document.querySelectorAll('#datenbasis .legend b')].map(item => Number(item.textContent)); + expect(counts.reduce((sum, value) => sum + value, 0)).toBe(model.totals.records); + }); + + it('restores flattened Markdown in a description as headings, steps, lists, emphasis and safe links without losing text', () => { + const html = reportRichText('Synthetic intro text. #### Remediation action: Do this: 1. Open the **Synthetic** portal. 2. Click [Next](https://example.test/next). 3. Finish. ' + + '#### Related links * [Synthetic Guide - Page 2](https://example.test/guide) * [Unsafe](javascript:alert(1)) '); + const document = new JSDOM(`
    ${html}
    `).window.document; + expect([...document.querySelectorAll('h5')].map(item => item.textContent)).toEqual(['Remediation action', 'Related links']); + expect([...document.querySelectorAll('ol li')].map(item => item.textContent)).toEqual(['Open the Synthetic portal.', 'Click Next.', 'Finish.']); + expect(document.querySelector('ol b')?.textContent).toBe('Synthetic'); + expect([...document.querySelectorAll('ul li')].map(item => item.textContent)).toEqual(['Synthetic Guide - Page 2', 'Unsafe) ']); + expect([...document.querySelectorAll('a')].map(item => item.getAttribute('href'))).toEqual(['https://example.test/next', 'https://example.test/guide']); + expect(document.querySelector('script')).toBeNull(); + expect(document.body.textContent).not.toMatch(/\*\*|####|\]\(/); + // A CIS rule number and a single `#` inside a URL stay text. + const plain = new JSDOM(`
    ${reportRichText('1.1.1 (L1) Ensure synthetic setting [Link](https://example.test/a?b=" # "/c)')}
    `).window.document; + expect(plain.querySelectorAll('h5, ol')).toHaveLength(0); + expect(plain.body.textContent).toBe('1.1.1 (L1) Ensure synthetic setting Link'); + }); + + it('keeps only the statement of a result text in the card, without appended evidence or URLs', () => { + expect(resultStatement('Your tenant has 3 resource(s) with access to all subscriptions in this tenant. - [Synthetic A](https://example.test/a) - [Synthetic B](https://example.test/b)')) + .toBe('Your tenant has 3 resource(s) with access to all subscriptions in this tenant.'); + expect(resultStatement('You have disabled a synthetic setting. ➡️ Open [Recommendation](https://example.test/r) in the portal.')) + .toBe('You have disabled a synthetic setting.'); + expect(resultStatement('Your tenant does not have [diagnostic settings](https://example.test/d) configured for all logs:\n| Log | State |')) + .toBe('Your tenant does not have diagnostic settings configured for all logs'); + expect(resultStatement('8 user(s) are inactive - Synthetic One with Role A - Synthetic Two with Role B')).toBe('8 user(s) are inactive'); + expect(resultStatement('Found 1 synthetic endpoint that may support NTLM authentication.')).toBe('Found 1 synthetic endpoint that may support NTLM authentication.'); + }); + + it('builds the lean Work cut with native area, counting key and the frozen Pathfinder references', () => { + const sheet = makeSheet('Project', [['Title', 'Category', 'Status', 'Benchmark section', 'Source Rule ID'], + ['Synthetic rule', 'CIS', 'Fail', 'Local Policies > User Rights Assignment', '2.2.2']], '', 'synthetic.csv', { isProjectSheet: true }); + const item = sheet.analysis.records[0]; + item.findingOccurrenceId = 'synthetic-occurrence'; + item.referenceRecognition = { references: [ + { framework: 'mitre-attack', id: 'tactic:Credential Access', title: 'Credential Access', kind: 'tactic', tactics: ['Credential Access'], + status: 'active', evidence: 'declared', matchedBy: 'exact-title', catalogVersion: '19.2', catalogSha256: 'x', + source: { fieldKey: 'k', fieldLabel: 'l', valuePath: '', representation: 'value', start: 0, end: 1, offsetUnit: 'utf16', text: 'C' } }, + { framework: 'mitre-attack', id: 'T1003', title: 'Retired', kind: 'technique', tactics: [], status: 'revoked', evidence: 'declared', + matchedBy: 'id', catalogVersion: '19.2', catalogSha256: 'x', + source: { fieldKey: 'k', fieldLabel: 'l', valuePath: '', representation: 'value', start: 0, end: 1, offsetUnit: 'utf16', text: 'T' } } + ] } as unknown as NonNullable; + const view = buildReportWorkspaceView({ sheets: [sheet] } as unknown as ImportedDataset)!; + expect(view.records[0]).toMatchObject({ + area: 'Local Policies > User Rights Assignment', ruleId: '2.2.2', countingKey: 'scanner:synthetic-occurrence', + attack: [{ kind: 'tactic', title: 'Credential Access', evidence: 'declared' }], defenses: [] + }); + expect(view.records[0]).not.toHaveProperty('fields'); }); }); diff --git a/tests/results-evidence-browser-check.mjs b/tests/results-evidence-browser-check.mjs new file mode 100644 index 0000000..81c4f32 --- /dev/null +++ b/tests/results-evidence-browser-check.mjs @@ -0,0 +1,46 @@ +// Synthetic browser check of the actual Results window; no datastore mutation. +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { pathToFileURL } from 'node:url'; +import { build } from 'esbuild'; +const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href); +const main = await readFile('src/main.tsx', 'utf8'); +const styles = [...main.matchAll(/^import '([^']+\.css)';/gm)].map(([, path]) => + `import ${JSON.stringify(path.startsWith('.') ? './src/' + path.slice(2) : path)};`).join('\n'); +const bundle = await build({ stdin: { resolveDir: process.cwd(), loader: 'tsx', contents: `${styles} +import React from 'react'; import {createRoot} from 'react-dom/client'; +import {FloatingResultsWindow} from './src/features/linked-results/FloatingResultsWindow'; +import {makeSheet} from './src/importers/sheet-factory'; +const finding=makeSheet('Synthetic',[['Title','Status','Source Rule ID','Verwandte Daten'], + ['Synthetic SMBv1 finding','Matched','S-SMB-v1','synthetic::rule-details-S-SMB-v1']], '', 'synthetic.html').analysis.records[0];finding.importFileId='synthetic'; +const own=makeSheet('PingCastle · Details',[['Domain controller'],['DUMMY-DC']], '', 'synthetic.html',{id:'pingcastle-section-synthetic::rule-details-S-SMB-v1'}); +const inventory=makeSheet('PingCastle · Inventory',[['Domain controller','SMB v1'],['DUMMY-OTHER','NO']], '', 'synthetic.html'); +const root=createRoot(document.getElementById('root')); +window.renderSynthetic=tool=>root.render({}} onClose={()=>{}}/>); +window.renderSynthetic('pingcastle');` }, jsx:'automatic',bundle:true,write:false,outdir:'synthetic',format:'iife', +loader:{'.woff2':'dataurl','.woff':'dataurl','.png':'dataurl','.svg':'dataurl'} }); +const server=createServer((req,res)=>{const file=bundle.outputFiles.find(file=>req.url==='/'+file.path.split(/[\\/]/).at(-1)); +res.setHeader('Content-Type',file?(req.url.endsWith('.css')?'text/css':'text/javascript'):'text/html'); +res.end(file?.contents??'
    ');}); +await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));let browser; +try { browser=await chromium.launch({headless:true,channel:'chrome'});const page=await browser.newPage({viewport:{width:1280,height:900}}); +page.on('pageerror', error=>console.error(error.message)); +await page.goto('http://127.0.0.1:'+server.address().port+'/'); +for(const theme of ['dark','light'])for(const tool of ['pingcastle','maester']) { + await page.evaluate(({theme,tool})=>{document.documentElement.dataset.theme=theme;window.renderSynthetic(tool);},{theme,tool}); + try { await page.waitForFunction(tool=>document.querySelector('.rich-text-table-row--violation')?.textContent?.includes(tool==='pingcastle'?'DUMMY-DC':'DUMMY-BAD'),tool,{timeout:5000}); } + catch(error) { console.error(tool, await page.locator('tbody tr').evaluateAll(rows=>rows.map(row=>({text:row.textContent,cls:row.className})))); throw error; } + const red=await page.locator('.rich-text-table-row--violation td').first().evaluate(cell=>({actual:getComputedStyle(cell).backgroundColor, + token:getComputedStyle(document.documentElement).getPropertyValue('--violation-highlight-bg').trim(),text:cell.textContent})); + const tokenColor=await page.evaluate(token=>{const element=document.createElement('span');element.style.backgroundColor=token;document.body.append(element);const result=getComputedStyle(element).backgroundColor;element.remove();return result;},red.token); + assert.equal(red.actual,tokenColor); + const neutral=await page.locator('tbody tr').filter({hasText:tool==='pingcastle'?'DUMMY-OTHER':'DUMMY-UNKNOWN'}).first().evaluate(row=>row.classList.contains('rich-text-table-row--violation')); + assert.equal(neutral,false); + if(tool==='maester')assert.equal(await page.locator('.rich-text-table-row--match').count(),1); +} +console.log('Actual Results window: red affected evidence, neutral inventory/unknown rows and explicit pass rows verified in both themes.'); +} finally {await browser?.close();await new Promise(resolve=>server.close(resolve));} diff --git a/tests/results-excel-browser-check.mjs b/tests/results-excel-browser-check.mjs new file mode 100644 index 0000000..eafa602 --- /dev/null +++ b/tests/results-excel-browser-check.mjs @@ -0,0 +1,59 @@ +// Actual browser downloads from synthetic Results; no datastore access or mutation. +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { pathToFileURL } from 'node:url'; +import { build } from 'esbuild'; +import { read, utils } from 'xlsx'; +const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href); +const main = await readFile('src/main.tsx', 'utf8'); +const styles = [...main.matchAll(/^import '([^']+\.css)';/gm)].map(([, path]) => `import ${JSON.stringify(path.startsWith('.') ? './src/' + path.slice(2) : path)};`).join('\n'); +const bundle = await build({ stdin: { resolveDir: process.cwd(), loader: 'tsx', contents: `${styles} +import React from 'react';import {createRoot} from 'react-dom/client'; +import {FloatingResultsWindow} from './src/features/linked-results/FloatingResultsWindow'; +import {makeSheet} from './src/importers/sheet-factory'; +const rows=Array.from({length:1001},(_,i)=>['OBJECT-'+(i+1),'000'+i]); +const sheet=makeSheet('Synthetic linked',[['Object','Value'],...rows],'','synthetic.xlsx'); +const finding=makeSheet('Synthetic indicator',[['Name','Description','Result Message','Number of Results','Likelihood of Compromise'],['Complete synthetic evidence','Original certificate description','Found 17 affected templates','17','Original impact explanation']],'','synthetic.csv').analysis.records[0]; +const root=createRoot(document.getElementById('root')); +root.render('| '+name+' | Failed |').join('\\n')}, + {title:'Native HTML evidence',value:''+rows.map(([name])=>'').join('')+'
    Object
    '+name+'
    '}]} + color="" zIndex={1} offset={0} search="OBJECT-1001" onActivate={()=>{}} onClose={()=>{}} onToggleBookmark={()=>{}}/>);` }, jsx:'automatic',bundle:true,write:false,outdir:'synthetic',format:'iife',loader:{'.woff2':'dataurl','.woff':'dataurl','.png':'dataurl','.svg':'dataurl'} }); +const server=createServer((req,res)=>{const file=bundle.outputFiles.find(file=>req.url==='/'+file.path.split(/[\\/]/).at(-1));res.setHeader('Content-Type',file?(req.url.endsWith('.css')?'text/css':'text/javascript'):'text/html');res.end(file?.contents??'
    ');}); +await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));let browser; +async function workbook(download) {const stream=await download.createReadStream();const chunks=[];for await(const chunk of stream)chunks.push(chunk);return read(Buffer.concat(chunks),{type:'buffer'});} +try { + browser=await chromium.launch({headless:true,channel:'chrome'}); + const page=await browser.newPage({viewport:{width:1280,height:900}}); + const errors=[];page.on('pageerror',error=>errors.push(error.message)); + await page.goto('http://127.0.0.1:'+server.address().port+'/'); + await page.waitForSelector('table'); + const all=page.getByRole('button',{name:'Export entire Results to Excel',exact:true}); + const selected=page.getByRole('button',{name:'Export selected tables to Excel',exact:true}); + for(const theme of ['dark','light']) { + await page.evaluate(theme=>document.documentElement.dataset.theme=theme,theme); + assert.deepEqual(await page.locator('table').evaluateAll(tables=>tables.map(table=>table.tBodies[0].rows.length)),[1001,1001,1001]); + assert.equal(await all.isEnabled(),true);assert.equal(await selected.isEnabled(),false); + const grey=await selected.evaluate(button=>getComputedStyle(button).color); + const entire=page.waitForEvent('download');await all.click();const full=await workbook(await entire); + const tableNames=full.SheetNames.filter(name=>/^Results(?: \d+)?$/.test(name));assert.equal(tableNames.length,3); + assert.deepEqual(full.SheetNames.slice(0,3),tableNames); + assert.match(full.Sheets[tableNames[0]].A1.v,/Complete synthetic evidence/); + for(const name of tableNames){const rows=utils.sheet_to_json(full.Sheets[name],{header:1});assert.equal(rows.length,name==='Results'?1006:1003);assert.equal(rows[1002][0],'OBJECT-1001');} + assert.match(full.Sheets.Results.A1006.v,/Complete prose/);assert.equal(full.SheetNames.includes('Results text'),false); + for(const name of full.SheetNames)assert.match(full.Sheets[name].A1.v,/Complete synthetic evidence/); + assert.equal(full.Sheets['Finding context'].C3.v,'Original certificate description'); + assert.equal(full.Sheets['Finding context'].C5.v,'17'); + await page.getByRole('checkbox',{name:'Select table for Excel export'}).nth(1).check(); + assert.equal(await selected.isEnabled(),true); + const colors=await page.locator('.results-window-export').evaluateAll(buttons=>buttons.map(button=>getComputedStyle(button).color)); + assert.notEqual(colors[0],colors[1]);assert.notEqual(colors[1],grey); + const subsetDownload=page.waitForEvent('download');await selected.click();const subset=await workbook(await subsetDownload); + assert.equal(subset.SheetNames.length,3);assert.equal(subset.SheetNames[0],'Results');assert.equal(subset.SheetNames[1],'Finding context');assert.equal(subset.SheetNames[2],'Export info');assert.equal(subset.Sheets[subset.SheetNames[0]].A1003.v,'OBJECT-1001'); + assert.deepEqual(utils.sheet_to_json(subset.Sheets['Finding context'],{header:1}),utils.sheet_to_json(full.Sheets['Finding context'],{header:1})); + await page.getByRole('checkbox',{name:'Select table for Excel export'}).nth(1).uncheck();assert.equal(await selected.isEnabled(),false); + } + assert.deepEqual(errors,[]); + console.log('Both themes: all 1001 rows shown and downloaded, entire Results text included, selective export exact, distinct icon colors and disabled state verified.'); +} finally {await browser?.close();await new Promise(resolve=>server.close(resolve));} diff --git a/tests/results-header-browser-check.mjs b/tests/results-header-browser-check.mjs new file mode 100644 index 0000000..ea25f7c --- /dev/null +++ b/tests/results-header-browser-check.mjs @@ -0,0 +1,41 @@ +// Isolated synthetic Results headers; no backend or running app mutation. +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import {createServer} from 'node:http'; +import {pathToFileURL} from 'node:url'; +import {build} from 'esbuild'; +const {chromium}=await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href); +const main=await readFile('src/main.tsx','utf8'); +const styles=[...main.matchAll(/^import '([^']+\.css)';/gm)].map(([,path])=>`import ${JSON.stringify(path.startsWith('.')?'./src/'+path.slice(2):path)};`).join('\n'); +const bundle=await build({stdin:{resolveDir:process.cwd(),loader:'tsx',contents:`${styles} +import React,{useState} from 'react';import {createRoot} from 'react-dom/client'; +import {FloatingResultsWindow} from './src/features/linked-results/FloatingResultsWindow'; +function Example(){const [minimized,setMinimized]=useState(true);return <>{['SMBv1 is enabled on Domain Controllers','Unexpected principal with metadata edit permission on a GPO linked to a critical AD container '.repeat(8)].map((title,index)=>setMinimized(value=>!value)} search="" onActivate={()=>{}} onClose={()=>{}} onToggleBookmark={()=>{}} onOpenRecord={()=>{}}/>)}};createRoot(document.getElementById('root')).render();`},jsx:'automatic',bundle:true,write:false,outdir:'synthetic',format:'iife',loader:{'.woff2':'dataurl','.woff':'dataurl','.png':'dataurl','.svg':'dataurl'}}); +const server=createServer((req,res)=>{const file=bundle.outputFiles.find(file=>req.url==='/'+file.path.split(/[\\/]/).at(-1));res.setHeader('Content-Type',file?(req.url.endsWith('.css')?'text/css':'text/javascript'):'text/html');res.end(file?.contents??'
    ');}); +await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));let browser; +try{ + browser=await chromium.launch({headless:true,channel:'chrome'});const page=await browser.newPage(); + for(const width of [760,440])for(const theme of ['dark','light']){ + await page.setViewportSize({width:1000,height:900});await page.goto('http://127.0.0.1:'+server.address().port+'/?width='+width);await page.waitForSelector('.results-window'); + await page.evaluate(theme=>{document.documentElement.dataset.theme=theme; + // A synthetic marker exercises the existing ring's CSS geometry; production group logic is covered separately. + const slot=document.querySelector('.results-window-related-slot');slot.innerHTML=''; + },theme); + const boxes=await page.locator('.results-window').evaluateAll(nodes=>nodes.map(node=>{ + const head=node.querySelector('.results-window-head'),title=node.querySelector('.results-window-name'),actions=node.querySelector('.results-window-actions'); + const rect=element=>{const r=element.getBoundingClientRect();return{left:r.left,right:r.right,top:r.top,bottom:r.bottom}}; + return{head:rect(head),title:rect(title),actions:rect(actions),controls:[...actions.querySelectorAll('button:not(:disabled)')].map(button=>({name:button.getAttribute('aria-label')??button.textContent,rect:rect(button),hit:document.elementFromPoint(button.getBoundingClientRect().x+button.clientWidth/2,button.getBoundingClientRect().y+button.clientHeight/2)?.closest('button')===button})),clipped:title.scrollWidth>title.clientWidth,ellipsis:getComputedStyle(title).textOverflow,tooltip:title.title}; + })); + assert.ok(Math.abs(boxes[0].actions.left-boxes[1].actions.left)<1); + assert.equal(await page.locator('.results-window-actions').evaluateAll(nodes=>nodes.every(node=>node.lastElementChild.classList.contains('results-window-close'))),true); + assert.equal(await page.locator('.results-window').evaluateAll(nodes=>nodes.every(node=>{ + const close=node.querySelector('.results-window-close').getBoundingClientRect(); + return [...node.querySelectorAll('.results-window-actions button')].every(button=>button.getBoundingClientRect().right<=close.right); + })),true); + for(const box of boxes){assert.ok(box.title.right<=box.actions.left);for(const control of box.controls){assert.ok(control.rect.right<=box.head.right);assert.equal(control.hit,true,JSON.stringify({width,theme,control,boxes}));}} + assert.equal(boxes[1].clipped,true);assert.equal(boxes[1].ellipsis,'ellipsis');assert.ok(boxes[1].tooltip.length>600); + await page.locator('.results-window-toggle').nth(1).click();assert.equal(await page.locator('.results-window.is-minimized').count(),0); + await page.locator('.results-window-toggle').nth(1).click();assert.equal(await page.locator('.results-window.is-minimized').count(),2); + } + console.log('Both themes at 760/440px Results widths: matching action positions, title ellipsis, full tooltip, all controls hit-testable, minimize/restore preserved.'); +}finally{await browser?.close();await new Promise(resolve=>server.close(resolve));} diff --git a/tests/server/assessor-http-server.test.ts b/tests/server/assessor-http-server.test.ts index 52f3edb..582106d 100644 --- a/tests/server/assessor-http-server.test.ts +++ b/tests/server/assessor-http-server.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createAssessorHttpServer } from '../../server/assessor-http-server'; import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository'; import { FileSystemReportArchive, reportArchiveProjectDirectory } from '../../server/reporting/report-archive'; +import { REPORT_PROJECTION_VERSION } from '../../src/reporting/report-projection'; const timestamp = '2026-07-17T08:00:00.000Z'; const project = { id: 'project-1', name: 'Project 1', createdAt: timestamp, updatedAt: timestamp, attributes: {} }; @@ -59,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.16.5', apiVersion: 1, repositoryRevision: 0, persistence: 'memory' + status: 'ok', application: 'ASSESSOR', productVersion: '2.24.2', apiVersion: 1, repositoryRevision: 0, persistence: 'memory' }); expect(health.headers.get('access-control-allow-origin')).toBeNull(); }); @@ -76,6 +77,20 @@ describe('local ASSESSOR HTTP server', () => { await expect(foreignOrigin.json()).resolves.toMatchObject({ error: { code: 'cross_origin_rejected' } }); }); + it('persists work package flags independently and leaves the complete Core unchanged', async () => { + await mutate('/api/v1/projects', project); + await mutate('/api/v1/assessments', assessment); + const packagesUrl = `${baseUrl}/api/v1/projects/${project.id}/work-packages?assessmentIds=${assessment.id}`; + const coreBefore = await (await fetch(`${baseUrl}/api/v1/projects/${project.id}/export`)).text(); + expect((await fetch(`${baseUrl}/api/v1/projects/${project.id}/work-packages`)).status).toBe(400); + expect(await (await fetch(packagesUrl)).json()).toEqual({ status: 'context-required' }); + await fetch(`${baseUrl}/api/v1/projects/${project.id}/assessment-context?assessmentIds=${assessment.id}`); + const packages = await (await fetch(packagesUrl)).json(); + expect(packages).toMatchObject({ status: 'ready', result: { projectId: project.id, assessmentIds: [assessment.id], packages: [] } }); + expect(await (await fetch(packagesUrl)).json()).toEqual(packages); + expect(await (await fetch(`${baseUrl}/api/v1/projects/${project.id}/export`)).text()).toBe(coreBefore); + }); + it('exposes repository operations and preserves atomic import sessions across HTTP calls', async () => { expect((await mutate('/api/v1/projects', project)).status).toBe(200); expect((await mutate('/api/v1/assessments', assessment)).status).toBe(200); @@ -142,7 +157,7 @@ describe('local ASSESSOR HTTP server', () => { const report = await fetch(`${baseUrl}/api/v1/projects/${project.id}/report-projection?assessmentIds=${assessment.id}&scanRunIds=${scanRun.id}`); expect(report.status).toBe(200); await expect(report.json()).resolves.toMatchObject({ - version: '1.3.0', project: { id: project.id }, assessmentId: assessment.id, + version: REPORT_PROJECTION_VERSION, project: { id: project.id }, assessmentId: assessment.id, environment: expect.arrayContaining([{ key: 'Domain' }, { key: 'Forest' }, { key: 'Domain Controller' }]), coverage: { total: 0 }, diagnostics: expect.any(Array), provenance: expect.any(Array) }); @@ -164,6 +179,16 @@ describe('local ASSESSOR HTTP server', () => { expect(archived.projection.context?.repositoryRevision).toBe(run.repositoryRevision); expect(archived.projection.context?.assessmentIds).toEqual([assessment.id]); expect(archived.manifest).toMatchObject({ projectId: project.id, scope: { assessmentIds: [assessment.id], scanRunIds: [scanRun.id] } }); + // The Technical Report reads the same published Context, so its problem fields match the Management Report. + const technicalRun = await mutate(`/api/v1/projects/${project.id}/report-runs`, + { kind: 'technical', assessmentIds: [assessment.id], scanRunIds: [scanRun.id] }); + expect(technicalRun.status).toBe(200); + const technical = await technicalRun.json(); + expect(technical).toMatchObject({ kind: 'technical', fileName: expect.stringMatching(/^assessor-technical-report-\d{4}-\d{2}-\d{2}\.html$/) }); + const technicalArchive = await new FileSystemReportArchive(archiveDirectory).read(project.id, technical.runId); + expect(technicalArchive.technical).toBe(technical.html); + expect(technicalArchive.projection.context?.repositoryRevision).toBe(technical.repositoryRevision); + expect(technical.html).toContain('id="problemfelder"'); expect((await mutate(`/api/v1/projects/${project.id}/report-runs`, { kind: 'raw' })).status).toBe(400); // Context layer (Phase 13E): read-only, scoped to the selected timeslot and cached per revision. @@ -179,6 +204,14 @@ describe('local ASSESSOR HTTP server', () => { }); const cachedContext = await (await fetch(`${baseUrl}/api/v1/projects/${project.id}/assessment-context?assessmentIds=${assessment.id}`)).json(); expect(cachedContext.generatedAt).toBe(assessmentContext.generatedAt); + // Work package flags are scoped, persisted separately and leave the entire Core export unchanged. + const coreBefore = await (await fetch(`${baseUrl}/api/v1/projects/${project.id}/export`)).text(); + expect((await fetch(`${baseUrl}/api/v1/projects/${project.id}/work-packages`)).status).toBe(400); + const packagesUrl = `${baseUrl}/api/v1/projects/${project.id}/work-packages?assessmentIds=${assessment.id}`; + const packages = await (await fetch(packagesUrl)).json(); + expect(packages).toMatchObject({ status: 'ready', result: { projectId: project.id, assessmentIds: [assessment.id], packages: [] } }); + expect(await (await fetch(packagesUrl)).json()).toEqual(packages); + expect(await (await fetch(`${baseUrl}/api/v1/projects/${project.id}/export`)).text()).toBe(coreBefore); const deletedProject = await fetch(`${baseUrl}/api/v1/projects/${project.id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1', Origin: baseUrl }, @@ -234,6 +267,21 @@ describe('local ASSESSOR HTTP server', () => { await expect(projects.json()).resolves.toEqual([project]); }); + it('accepts the current Maester HTML wrapper through the upload/preview API with synthetic Dummy data', async () => { + const bytes = Buffer.from('Maester Dummy', 'utf8'); + const response = await mutate('/api/v1/import-sources/parse', { + fileName: 'TestResults-Dummy.html', sizeBytes: bytes.length, lastModified: 0, + mediaType: 'text/html', bytesBase64: bytes.toString('base64') + }); + expect(response.status).toBe(200); + const imported = await response.json(); + expect(imported).toMatchObject({ format: 'HTML', profileId: 'maester', + extraction: { extractor: { id: 'maester-html-source-extractor', version: '1.2.1' } } }); + expect(imported.extraction.candidates).toHaveLength(1); + expect(imported.extraction.candidates[0].source.objectPath).toBe('$.Tests[0]'); + // Includes cold HTML-runtime/worker startup on a synchronized Windows drive. + }, 15_000); + it('parses an uploaded source through the backend API', async () => { const bytes = Buffer.from('Rule ID;Title;Severity\nCSV-1;Server import;High', 'utf8'); const response = await mutate('/api/v1/import-sources/parse', { diff --git a/tests/work-package-layout-browser-check.mjs b/tests/work-package-layout-browser-check.mjs new file mode 100644 index 0000000..e07a920 --- /dev/null +++ b/tests/work-package-layout-browser-check.mjs @@ -0,0 +1,43 @@ +// Synthetic display only; no backend, datastore or running application changes. +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { pathToFileURL } from 'node:url'; +import { build } from 'esbuild'; +const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href); +const main = await readFile('src/main.tsx', 'utf8'); +const styles = [...main.matchAll(/^import '([^']+\.css)';/gm)].map(([, path]) => `import ${JSON.stringify(path.startsWith('.') ? './src/' + path.slice(2) : path)};`).join('\n'); +const bundle = await build({ stdin: { resolveDir: process.cwd(), loader: 'tsx', contents: `${styles} +import React,{useState} from 'react';import {createRoot} from 'react-dom/client'; +import {DockableFilterPanel} from './src/features/sidebar/DockableFilterPanel'; +function Example(){const [selected,setSelected]=useState([]);return
    setSelected(selected.includes(value)?[]:[value]),values:[ +{value:'a',label:'AP-01',count:6,section:{id:'one',label:'01 · Credential Relay Exposure'},clusterColour:'critical'}, +{value:'b',label:'AP-02',count:2,section:{id:'one',label:'01 · Credential Relay Exposure'},clusterColour:'critical'}, +{value:'c',label:'AP-01',count:999,section:{id:'four',label:'04 · AD Privileged Account Exposure'},clusterColour:'high'}, +{value:'u',label:'Unassigned',count:3}]}]}/>
    };createRoot(document.getElementById('root')).render();` }, jsx:'automatic',bundle:true,write:false,outdir:'synthetic',format:'iife',loader:{'.woff2':'dataurl','.woff':'dataurl','.png':'dataurl','.svg':'dataurl'} }); +const server=createServer((req,res)=>{const file=bundle.outputFiles.find(file=>req.url==='/'+file.path.split(/[\\/]/).at(-1));res.setHeader('Content-Type',file?(req.url.endsWith('.css')?'text/css':'text/javascript'):'text/html');res.end(file?.contents??'
    ');}); +await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));let browser; +try { + browser=await chromium.launch({headless:true,channel:'chrome'}); + const page=await browser.newPage({viewport:{width:1000,height:800}}); + await page.goto('http://127.0.0.1:'+server.address().port+'/'); + await page.locator('summary').click(); + for(const theme of ['dark','light']) { + await page.evaluate(theme=>document.documentElement.dataset.theme=theme,theme); + const heads=page.locator('.work-package-cluster-head');assert.equal(await heads.count(),2); + for(const head of await heads.all()) { + const layout=await head.evaluate(node=>{const text=node.children[0].getBoundingClientRect(),marker=node.children[1].getBoundingClientRect();return {right:marker.left>=text.right,square:marker.width===marker.height,width:marker.width,color:getComputedStyle(node.children[1]).backgroundColor};}); + assert.equal(layout.right,true);assert.equal(layout.square,true);assert.ok(layout.width>0); + assert.notEqual(layout.color,'rgba(0, 0, 0, 0)'); + } + assert.equal(await page.locator('.work-filter-value .work-package-cluster-colour').count(),0); + const rights=await page.locator('.work-filter-value strong').evaluateAll(nodes=>nodes.map(node=>node.getBoundingClientRect().right)); + assert.ok(Math.max(...rights)-Math.min(...rights)<1); + const row=page.getByRole('button',{name:'01 · Credential Relay Exposure → AP-02: 2',exact:true}); + await row.focus();await page.keyboard.press('Space');assert.equal(await row.getAttribute('aria-pressed'),'true'); + await page.getByRole('button',{name:'Detach filters',exact:true}).click();assert.equal(await row.getAttribute('aria-pressed'),'true'); + await page.getByRole('button',{name:'Dock filters',exact:true}).first().click(); + await row.click();assert.equal(await row.getAttribute('aria-pressed'),'false'); + } + console.log('Both themes: cluster headers, right-hand colour squares, aligned counts and keyboard selection across docking verified.'); +} finally {await browser?.close();await new Promise(resolve=>server.close(resolve));}