WP Tab mit export + Security Export

This commit is contained in:
Michael 2026-10-13 09:05:26 +02:00
parent 614ac1b147
commit 563ad43f9c
47 changed files with 2111 additions and 147 deletions

View File

@ -1,5 +1,178 @@
# ASSESSOR – Umsetzungsplan
## Native Security export interaction and layout corrections (2026-10-13, Phase 7 Report Engine)
User requests isolated popup/page scrolling, popup maximization, compact top facts, category click selection/disclosure and a centered two-thirds report with the Technical Report background. User explicitly retains the no-JavaScript export contract. Changes are confined to the Report Engine renderer and its tests; the Core snapshot adapter and source styles remain unchanged. Native checkbox controls maximize/restore each popup before the rightmost close X. Popup scroll containers contain overscroll, including nested source popups. Top-level popup fact lists use five columns, reducing to three/two on narrow windows. Graph links target a descendant inside each native category details element so the browser reveals the closed ancestor; the target category summary carries the existing amber selection role. The report preserves its logical source width while fitting two thirds of the desktop viewport; narrow windows use the available width. Body, report, Security panel and Findings table use the Technical Report surface-panel background. Renderer version 1.2.0; snapshot schema remains 1.1.0. Existing downloaded reports require a fresh export.
Verified: 13 Report renderer/boundary/invariance and Light/Dark export interaction checks, all 507 mandatory import compatibility checks and both production builds. Concurrent initial import/export tests reached existing timeouts; unchanged sequential repeats pass, without changed timeouts or baselines. Synthetic browser checks exercise native category opening/selection, maximize/restore, compact nine-fact grid, long-popup scroll boundaries and a short nested native-table popup retaining its parent; Light/Dark are checked. Desktop report width measures two thirds of the viewport, the grid measures five columns and nine facts occupy about 120px at default popup width. Fresh Light export confirms identical body/panel/table backgrounds. No database, source values, scoring or import-baseline changes; no full-suite or phase-completion claim.
Version reassessment: backward-compatible correction warrants product patch 2.25.1. Initially the product-version synchronization was pending renewed Platform/Core Tests consent because AGENTS.md explicitly expires the earlier release with the user's subsequent no-JavaScript answer. User renewed `Core-Freigabe: Platform, Core Tests` on 2026-10-13. Package/lock and UI/API version contracts are synchronized to 2.25.1; all 16 version/API/renderer checks pass. Running backend health reports 2.25.1, PostgreSQL and unchanged repository revision 592; app HTTP is 200. Final release gate passes all 507 import compatibility checks and both client/server production builds. Diff check passes; existing client chunk-size warning only. This version step is complete; no full-suite or phase-completion claim, commit, database change or historical-report rewrite.
Design-language and diff checks pass. The local-only word-boundary guard initially flagged the new accessible label `Maximize window`; the label now reads `Maximize popup` and the unchanged guard passes. The permanent synthetic renderer tests additionally click the native maximization checkbox twice, verify both states and the rightmost close action, and protect the fragment target's exact category containment. Temporary preview server and browser tab are removed; synthetic files remain outside the repository.
Change log 2026-10-13: preserve script-free export while adding native maximization, category revelation, scroll containment, compact facts and centered Technical Report surfaces.
## Complete HTML Security view export (2026-10-12, Phase 7 Presentation / Report Engine)
Current release: Presentation, Core Tests, Platform. User requests the complete Security tab with its current width/scaling, ASSESSOR logo, client/assessment and version metadata like the Technical Export, and a dominant VERTRAULICH notice. The existing Security view is rendered through a disposable Core Presentation snapshot adapter at its measured width, using the same components, supplied counts/membership and CSS. A declared read-only SecurityViewReadPort publishes only the complete authorized view markup and metadata to the Report Engine's platform-neutral standalone HTML renderer. The existing Dashboard report extension point remains the sole Core consumer. No new classification, scoring, database access or report schema; the snapshot reads no additional Result DB material beyond what the Security view displays.
This is a separate complete view download, not a replacement for the archived A/B report projections; their content, archive and historical files remain unchanged. It uses native HTML disclosures and no JavaScript, preserving the existing static/offline report contract. The direct-view export explicitly retains the Security tab's original English labels/source text, while the report heading and confidentiality/version notes are German. Finding descriptions/remediation and Results/scoring explanations are embedded even when their application disclosures are closed. Original scanner tables include all retained rows/sections, including modal/linked tables and their source limitation notices, without applying UI pagination. The file embeds the existing logo, local Inter and original view styles, freezes the measured logical CSS-pixel width and current theme/density, and blocks external resources/active content with CSP. No screenshot or invented summary replaces source content. Report-off or a scope change suppresses late output; revision checks reject mixed context or a repository change during export. The final release decision and acceptance are recorded below; no phase-completion claim.
Same-batch user corrections with renewed release: the 2320px logical view must fit a smaller browser window without clipping or an oversized header; tables remain popups like the original; closed triangles point right and open triangles down. Logical graph/table proportions remain frozen, while CSS zoom fits the full view to available width and never enlarges it. The title/confidentiality block uses the shared h2 role instead of the oversized h1 role. Results and Scoring use native HTML popovers outside the scaled main view, with a rightmost close X, Escape/light dismissal, normal 16px reading text, scrolling and native resizing. Exact source cell links open further popovers, retaining the parent; a stable source-reference registry embeds each target and handles linked sections without a cutoff or executing vendor code. Viewport-dependent facts inset/row padding is frozen from the source view. The previously omitted functional-icons stylesheet is embedded verbatim; its right-pointing base transform and the down-pointing open transform are preserved. Security snapshot/renderer 1.1.0 records these corrections; existing downloaded files are not rewritten and require a new export.
Capability release decision: product 2.25.0, synchronized package/lock and UI/API contracts. The Report renderer/assets load only after the export action through the existing pinned Dashboard extension, preserving the report-free client load path. Synthetic Light/Dark clicks verify complete exact counts/membership despite Work/Graph filters, original closed finding details, a full 1000-row native table and its exact linked popup, immutable inputs, revision rejection and Report-off late-output suppression. Renderer tests preserve all original style sources/local assets and reject active markup; existing Security/window/Original-table and Core/Context/Report boundary/invariance, version/API and Technical Report checks pass. Vitest suppresses CSS modules, so export tests explicitly load the unchanged style files; lazy imports are awaited to completion in the click tests. The initial gate hit a five-second timeout during concurrent tests; its unchanged repeat passed. A subsequent build identified an implicit test callback this type, corrected without changing any contract. The final mandatory gate passes all 507 checks and both production builds, including separate lazy report chunks. Browser acceptance uses synthetic files only: a 2320px view fits the 1910px viewport at approximately 0.81 zoom with scrollWidth equal to clientWidth; another observed viewport width also has no horizontal overflow. Light/Dark native Results/scoring popups retain 16px text and window bounds, all 1000 rows are present, a cell-linked detail opens above its parent and Escape closes only that detail, and category/finding triangles change from right to down. CSS hover keeps the supplied chain at 0.5 and dims unrelated paths to 0.08 and unrelated complete rows to 0.18 without script. Design-language, local-only and diff checks pass. No database/schema/import-baseline/scoring change, full-suite or phase-completion claim.
## Neutral Work Packages tab reading surface (2026-10-12, Phase 7 Presentation)
Current release: Presentation, Core Tests, Platform. Explicit user correction: remove the blue table-row reading marker in the Client Info Work Packages tab. Its expanded detail row was picked up by the shared document click handler, covering all contained findings with the blue marking surface. A declarative data-table-row-marking="off" boundary on this panel opts its table and nested detail tables out before the shared handler assigns a marker. Other tables, including the original Work Packages floating window, retain their established marking. No CSS/theme, focus, table geometry, filtering, source data or disclosure change. This specific user correction supersedes the general blue reading-marker convention only inside this tab.
Permanent Light/Dark Dashboard interactions install the real document marking handler, click a package, open the original finding explanation/remediation, click its detail surface and expand Unassigned, verifying no marked row, intact disclosures/source data and retained marking on another table. All 19 targeted tab/marking/boundary checks pass after correcting the new test's expected source text to the exact opened record (the first published package is not assumed to be an administrative package). Patch decision: 2.24.18 synchronized in package/lock and UI/API contracts. All 17 additional version/API/Context-boundary checks and all 507 mandatory import checks pass, along with both production builds, design-language, local-only and diff checks. Existing client chunk-size warning only; no database/import-baseline change, full-suite or phase-completion claim.
## Security and Work Packages filter independence (2026-10-12, Phase 7 Presentation)
Current release: Presentation, Core Tests, Platform. User clarification: preserve both views' existing appearance and interactions; no Work or Graph filter may restrict Security or Work Packages. Security previously shared Overview's Source: Scan facet and active scanner sub-sheet. Its read-only analysis now uses the complete project Work sheet and that sheet's existing profile without Work facets. Category/domain membership, counts, score, Assessment facts and its explanation, scoring methodology and inferred inventory fallbacks all use this same complete assessment analysis. Overview retains its existing source selection and navigation. Work Packages already reads the published complete client/assessment/scan scope and exact project-sheet members; no package logic or visual change is needed. Client/assessment selection and each tab's own local disclosures/table filters retain their existing behavior.
Permanent synthetic Light/Dark Dashboard interactions open both tabs and a package, change Work search/status/action-only, Source: Scan/severity/Context/business-impact/arbitrary facets, Graph finding selection and active source sheet/profile, then verify identical complete Security and Work Packages contents, retained disclosures, no additional package request and no Work navigation/filter mutation or source changes. All 99 targeted UI/derivation/boundary/version/API/report checks pass across the completed runs. Six older layout/fact assertions initially failed: five counted both mounted tabs as one six-fact view, and one pinned the former boolean scoring-dialog state. The tests now retain six facts independently in each tab and the same dialog action with explicit Overview/Security source scope; no visual contract or protected import baseline is weakened. Patch decision: 2.24.17, synchronized package/lock and UI/API version contracts. The mandatory compatibility gate passes all 507 checks and both production builds; design-language, local-only and diff checks pass. No persistence, import-contract, scoring algorithm or style changes, full-suite claim or phase-completion claim.
## Client Info Work Packages tab (2026-10-12, Phase 7 Presentation)
Current user release: Presentation, Core Tests, Platform. Add Work Packages beside Overview and Security in Client Info, using the current client/assessment/scan scope and existing published Remediation API. Extract the existing floating-window table into WorkPackagesTable; the original window retains its wrapper, controls and behavior. Both presentations use identical totals, package/Unassigned rows, column filters, sorting, heatmap groups and selection actions. The new Core Presentation extension point is pinned for Remediation and Context read-only UI consumers. No new package computation, grouping, Core data mutation or persistence is introduced.
Optional tab content mounts on first selection and retains table state through later tab switches. Client/assessment scope changes remount the panel and suppress stale rows while loading. Remediation-off removes the tab, falls back to Overview and suppresses late responses. Context-off consumes only already published package snapshots through the existing hook. The panel includes the existing client/assessment selector. The complete published scope is independent of Work search/status/table filters.
User correction and renewed release: in this new Client Info tab, clicking a WP expands its exact member findings directly under that package row. Each finding opens its original explanation/remediation through the very same ClientSecurityFindingRow used by Security; repeat clicks collapse the package or individual finding. No Work navigation or Work-filter mutation occurs in this panel. Exact published scanner-occurrence/GPO-setting keys resolve against the loaded project Work sheet, independently of a selected scanner sheet; a missing loaded member is disclosed rather than replaced or guessed. Unassigned and same-change summaries also disclose their exact members locally; selecting the Unassigned summary clears only the table's local column filters so its row is visible. Optional renderer/disclosure props keep the original floating Work-Packages window's Work navigation and layout unchanged. This explicit correction supersedes the initially planned Client Info Work navigation, before release of this batch.
Patch decision: 2.24.16, a backward-compatible additional placement of the existing table, synchronized in package/lock and UI/API contracts. Synthetic Light/Dark Dashboard interactions verify lazy tab opening by keyboard, identical published totals despite Work filters, table and disclosure state retention, exact package/Unassigned membership, original finding explanations/remediation, client/assessment switching, loading, Remediation-off and late-response suppression without Context requests or source mutation. Additional source-subset and same-change cases retain exact project-sheet membership and prevent Work navigation. Existing Client Info and floating-window interactions pass. The initial boundary checks found the Context hook also belongs to its registered engine path; both exact allowlists now pin the intended read-only Context hook for this panel. All 71 targeted tab/Security/window/boundary/version/API checks pass. The final mandatory gate passes all 507 import checks and both production builds; design-language, local-only and diff checks pass. Isolated browser acceptance with synthetic data and production components/styles confirms exact package findings directly under their row, original explanation/remediation, retained disclosures after tab switching, repeated-click collapse and local Unassigned expansion in Light/Dark, with no page-level horizontal overflow or Work navigation. The preview-only theme controls overlapped its tabs under shared header CSS; keyboard activation verified both themes without changing production layout. Temporary preview tab/server removed. No database, importer-baseline or scoring changes, full-suite claim or phase-completion claim.
## Context recognition vocabulary 1.2.0 (2026-10-12, Phase 13E Context Engine)
Engine-only change (`src/assessment-context/`, `tests/assessment-context/`); no Core area touched. Analysis: several public scanner deviations stayed in `Other Deviations (not yet catalogued)` because no tier-2 term matched their titles and the stricter tiers 3 to 5 found no second term, close neighbour or category majority. Vocabulary 1.2.0 adds narrowly guarded terms: antivirus scan options and `malware` (excluding mail malware-filter readings) to malicious code execution; inactive DCs and, by explicit user decision 2026-10-12, AD trusts to `ad-recovery-gaps` (excluding delegation trust, Trust Center and zero trust); delegated permissions on AD OUs to privileged AD accounts (excluding Graph/app/consent readings); Defender for Identity honeytokens and VPN integration to detection. User decision 2026-10-12: no new clusters; Entra identity governance (access packages, entitlement management, pending approvals, dynamic group rules) maps to `cloud-privileged-access-exposure`, Managed Identity to `cloud-app-consent-exposure`, the Defender for Identity titles `unsecure account attributes` to `password-guessing-exposure` and `unsecure domain configurations` to `credential-relay-exposure`. A Maester rule family is no longer needed for these findings. Verified: all 75 Context Engine tests including the catalogue precision test (no contradictions) and invariance/boundary tests; a content-free check on the released Dummy client confirms the running backend reads Raw Data (`rawDataRead = true`); the first request returned a transient 500 and the repeat succeeded. Follow-up regression: newly recognized findings still showed `Unassigned` in Work Package because stored work-package snapshots are keyed by context engine and catalogue version, not by vocabulary. Context engine version 1.12.0 is published with vocabulary 1.2.0, and a permanent test requires a new engine version for every vocabulary release. Verified: 161 Context, Remediation, server API and architecture tests; on the Dummy client the rebuilt backend serves a fresh snapshot with context engine 1.12.0. No product-version change for this vocabulary-only step; no phase completion claimed.
## Native Maester category badges and fixed Severity order (2026-10-12, Phase 7 Presentation)
Current user release: Presentation, Core Tests, Platform. The existing Maester Block field was displayed as Category in the reader body, while reader-heading and Work-tile badges only selected literal Category fields. A shared read-only Presentation selector retains an explicit nonempty Category first, otherwise exposes Maester's existing nonempty Block with a Category display label. Exact record source identity takes precedence over the workspace fallback, so mixed-tool Work views receive the same badge. Other tools do not interpret Block as Category; missing/blank values do not invent a badge. Original fields, reader-body hierarchy, Outcome/Severity, imports, Context classifications and stored data remain unchanged. No Core profile/parser or engine change.
Patch decision: 2.24.15 synchronized in package/lock and UI/API contracts. Six permanent synthetic Light/Dark interactions open compact tiles by mouse and Enter in native and mixed workspaces, verify the category on the tile and reader heading/body, preserve source records, retain explicit categories, and exclude missing/non-Maester Block values. All 40 targeted badge/reader/tile tests and 24 version/API/architecture/Reader-action checks pass, along with design-language and local-only checks. The initial mandatory gate passes all 507 import checks and both production builds. An actual temporary Dummy browser tab became unresponsive during navigation, so isolated browser acceptance uses synthetic data with the same production TileCard/FindingReader/styles and disabled application/Context providers, without database access: Maester/Defender is visible on the compact tile and reader in Light/Dark, and Enter opens the reader with the badge intact. Temporary tabs, preview file and server removed. Backend health confirms 2.24.15, PostgreSQL and unchanged repository revision 592.
Same-batch user correction, renewed release Presentation/Core Tests/Platform: the binding order is now Outcome → Severity → all other badges whenever Severity is available, superseding the historical Status/Category/Severity order documented below. Reader Severity precedes even derived CIS categories and preserves native Criticality/Priority aliases already used for severity geometry. Regular tiles move Severity ahead of Category; compact CIS tiles retain Severity in their second visible slot instead of Weight when available, while tiles without Severity keep their former compact fallback. No placeholder is added for unavailable Severity. Two prior ordering assertions are updated to the user's explicitly revised contract, with all values/category coverage retained. Sixteen additional permanent synthetic Light/Dark tile-opening interactions cover PingCastle, Purple Knight, Maester, CIS-CAT, GPO, native severity aliases, source-column order, compact display, numeric metadata and missing Severity; the six Maester interactions also check exact badge order after mouse/Enter activation. All 56 targeted final badge/reader/tile tests pass. Version 2.24.15 completes this related batch. Final isolated browser acceptance in Light/Dark confirms Outcome/Severity in slots one/two on Maester, PingCastle and CIS-CAT compact tiles and their readers, including Enter activation and retained category/Domain Risk Level/Points values. Light restored, temporary tab closed and in-memory preview server stopped. The final mandatory repeat passes all 507 import checks and both production builds; design-language/local-only checks and diff whitespace checks pass. Existing client chunk-size warning only. No database, import-baseline, Context assignment or phase-completion claim.
## Full Security hover chains and assessment explanation (2026-10-12, Phase 7 Presentation)
Same-batch user refinement, renewed release Presentation/Core Tests/Platform: align the Assessment graphic with the existing Engine Dataflow reference. All eight nodes use the shared tile surface, inset, border/radius and typography, with one compact width and minimum height rather than stretched parent cards. A common ten-column grid places the two parent branches over their two/three children. Seven orthogonal connectors use the Dataflow filled triangular SVG marker (7×7, 10×10 viewBox); percentage line positions adapt to width without distorting arrowheads. Keep the existing Dataflow view unchanged. Browser acceptance in both themes at the normal Results-window width measures every node approximately 197×144, keeps 14px data/16px labels and all source counts, and confirms no horizontal overflow. Light restored and temporary tab closed. The preceding sticky-header build repeat passed all 507 mandatory import checks and both builds; the final repeat after this styling refinement passes all 507 mandatory import checks and both production builds. All ten final UI/explanation checks, design-language and local-only checks pass. Existing client chunk-size warning only. Version 2.24.14 completes this related presentation batch; no phase-completion claim.
User release: Presentation, Core Tests, Platform. Extend transient focus to both Domain and Category rows. Hover/focus keeps exactly the seed's directly connected paths and opposite-end rows visible; every unrelated Domain/Category row, including its name, bar, number, marker and separator, uses 0.18 opacity, and unrelated paths retain 0.08. A Category focuses all its incoming Domain links, while a Domain focuses all its outgoing Category links. Shared categories are not assigned exclusively or reclassified; zero-count memberships do not create a connection. Hover takes precedence over keyboard focus, and leave/blur restores the prior focus or normal view. Existing click, keyboard navigation and bulk disclosure behavior is unchanged.
Complete the preceding user request in Assessment facts: preserve the nine authoritative values, add an English explanation of scanner requirements/applicable GPO baselines, usable verdicts, Not configured (requirement exists, configuration absent in backup) and No baseline rule (configuration exists, applicable requirement absent). A neutral box-and-arrow graphic partitions existing eligible Work records into Assessed and Gaps, Assessed into Passed and Deviations, and the existing unassessed population into explicitly stored GPO states and Other gaps. A read-only Presentation helper uses the current source identity/profile verdict matching; missing baseline fields never invent a No baseline rule state. Gaps means no usable verdict under the existing Client Info aggregate, not an asserted security failure or universally missing source evidence. Not applicable remains excluded; conflict/duplicate flags overlap and never add records. Original verdicts, counts, scores, source records and effective-host-state boundary remain unchanged.
Patch decision: 2.24.14 synchronized in package/lock and UI/API contracts. Permanent synthetic Light/Dark acceptance checks Domain/Category whole-row/path opacity, shared Category links, focus/blur restoration and unchanged source/navigation; the Results interaction checks diagram/table equality. Additional synthetic GPO checks reconcile all graphic partitions to authoritative Work counts, keep diagnostic overlap separate, distinguish non-GPO states, respect source scope, preserve records and handle an empty population. Corrected a duplicate declaration introduced while applying the new helper and a conflicting test-local diagram name; no production workaround or weakened baseline. All eight final Security interactions and 24 explanation/version/API/Core/Context-boundary checks pass; design-language and local-only checks pass. Browser acceptance in Light/Dark with Dummy confirms Category Credential Relay focuses exactly three incoming Domains/paths and one Category, while Domain Identity focuses one Domain and its 15 Categories/paths; every other row uses 0.18 opacity. Assessment graphic reconciles 1829 eligible = 759 assessed + 1070 gaps, 759 = 281 passed + 478 deviations, and 1070 = 879 Not configured + 19 No baseline rule + 172 other gaps, with 41% coverage. The eight-node box/arrow graphic retains readable text and has no horizontal overflow. Scope-row headers explicitly remain position:static while scrolling the longer explanation, preventing inherited sticky headers from overlapping; fresh reload confirms the rule and the final ten UI/explanation checks pass. An initial temporary Context request was unavailable; a fresh reload succeeded without preference or database changes. Light restored and temporary tab closed. The first mandatory gate and both builds passed; the repeat after the sticky-row correction also passes all 507 mandatory import checks and both builds. The final Dataflow styling refinement and acceptance are recorded above. Backend health confirms 2.24.14, PostgreSQL and unchanged repository revision 592. No database/import-baseline/scoring changes or phase-completion claim.
## Security Domain focus and category disclosures (2026-10-12, Phase 7 Presentation)
User release: Presentation, Core Tests, Platform. Hovering a Domain row lowers unrelated flow opacity from 0.5 to 0.08; keyboard focus gives the same transient reading aid and leaving/blur restores it. Domain labels/bars/counts share a complete row hit target and accessible button semantics. Clicking or pressing Enter/Space opens all and only categories with a positive supplied count for that Domain, preserves already open unrelated categories, and focuses/scrolls to the first affected category. This reads the existing authoritative membership only; no new classifier, Work filter or stored finding change.
A neutral double-chevron button immediately beside Category opens every category (including zero-count categories) when any are closed, and closes every category when all are open. Its accessible label/expanded state follows that action. Category disclosure state moves into the local panel so ordinary row toggles, exact diagram-category navigation, Domain selection and the shared control agree. Finding disclosures retain their existing independent behavior; the shared overview fold still preserves open categories.
Patch decision: 2.24.13 synchronized in package/lock and UI/API contracts. Permanent Light/Dark synthetic interactions check exact Domain membership including a shared category and zero-count nonmember, unrelated opacity/restoration, mouse/Enter/Space activation, focus/blur, mixed/all disclosure states, source immutability and retained category navigation. Existing category hit-target and button selectors were narrowed to the category class because Domain rows now also expose those controls; their geometry/navigation assertions remain unchanged. All eight final Security interactions and 22 preceding version/API/Core/Context-boundary checks pass; design-language and local-only checks pass. Browser acceptance with Dummy in Light/Dark: Identity focus leaves its 15 paths at 0.5 and dims the other 30 to 0.08; clicking opens its exact 15 categories. System Hardening keyboard activation opens its nine connected categories. The shared button opens all 22 categories and closes all 22; focus departure restores paths. The double-chevron SVG is prevented from flex-shrinking under shared button padding and measures 16px inside the 27px control. Light restored and temporary tab closed. The final repeat after the SVG flex correction passes all 507 mandatory import checks and both production builds; all eight final Security interaction checks also pass, including preventing the icon from shrinking. Existing client chunk-size warning only. Backend health confirms 2.24.13, PostgreSQL and unchanged repository revision 592. No database/import-baseline/scoring changes or phase-completion claim.
## Security information offset and row spacing (2026-10-12, Phase 7 Presentation)
User release: Presentation, Core Tests, Platform. Move the complete right-hand information content, including the six inventory facts, 32px farther right within its existing allocation by applying the shared space-8 leading inset and border-box sizing. Its outer right edge stays aligned to the Findings table and the graph's 2:1 allocation is unchanged. Add 2px padding above and below each detail row, retaining the 14px role and existing 1.2 line height. The user explicitly prefers using the remaining vertical space for readability; source values, groups, scores and navigation are unchanged.
Patch decision: 2.24.12 synchronized in package/lock and UI/API contracts. All 18 targeted Security/version checks plus design-language and local-only checks pass. Permanent Light/Dark rendered acceptance covers the inset and row spacing alongside existing layout/content contracts. Follow-up within the same batch: at viewport widths up to 2000px, use a 16px information inset and 1px top/bottom row padding to reduce additional wrapping. Both desktop ranges retain increased separation. Browser acceptance in Light/Dark with Dummy confirms no horizontal overflow, 32px inset and 2px row padding at 2388px viewport width: facts use 621px inside the retained 664px overview, leaving 43px below. At 1910px, the 16px/1px rules keep every source value visible through normal document flow; wrapped facts use 695px and the common overview separator follows them, with no internal scrollbar or clipping. The graph geometry, shared outer boundary and typography remain unchanged. Light restored, viewport reset and temporary tab closed. The final repeat after the responsive CSS passes all 507 mandatory import checks and both production builds; the final six Security interactions also pass, alongside the 18 targeted checks above. Existing client chunk-size warning only. Backend health confirms 2.24.12, PostgreSQL and unchanged repository revision 592. No database/import-baseline changes or phase-completion claim.
## Align Security score values (2026-10-12, Phase 7 Presentation)
User release: Presentation, Core Tests, Platform. Align the complete Overall Score value, including /100, to the right edge of its grid column like Remediation. The methodology button's wider second column previously left the score at that column's starting edge. A shared rule on the two metric values uses justify-self:end and text-align:right, preserving numbers, label/bar/control placement and scoring semantics.
Patch decision: 2.24.11 synchronized in package/lock and UI/API contracts. All 18 targeted Security/version checks pass; permanent Light/Dark rendered acceptance checks both values' alignment. Design-language and local-only checks pass. All 507 mandatory import checks and both production builds pass; existing client chunk-size warning only. Backend health confirms 2.24.11, PostgreSQL and unchanged repository revision 592. No database/import-baseline changes or full-suite/phase-completion claim.
## Complete scanner table pagination and source limit notices (2026-10-12, Phase 7 Presentation)
Current user release: Ingest, Presentation, Core Tests, Platform. The user explicitly requires all real source rows, including 800 or 1000 entries, to remain reachable and asks to verify the Rows per page selector. Existing HTML resource boundaries allow 250001 rows per table and 500001 total; there is no 100-row import ceiling. The selector slices only the current display page, resets to page one on change/search and computes page count from all matching data rows. Add 250, 500 and 1000 display sizes to both inline and linked-modal source tables while preserving 10/25/100 defaults and pagination above 1000.
A read-only Ingest helper distinguishes only the exact retained scanner enumeration-limit notice, including repeated colspan cells and literal/entity quote variants. The existing Presentation projection consumes its indexed data rows and separate notices, keeping original row indexes for links. React does not parse scanner-specific text. The source notice remains visible outside the table/search/pagination as Source report limitation. Existing imports benefit immediately; stored table rows, values, findings, outcomes, scores, evidence/provenance and import contracts remain untouched. No importer format change, destructive migration, reimport or new engine is introduced. A source report that itself omits records cannot supply them; scanner-side enumeration limits remain explicitly visible.
Patch decision: 2.24.10 synchronized in package/lock and UI/API version contracts. New permanent synthetic import and Light/Dark interaction acceptance covers 100, 800 and 1000 actual rows, traversing all pages of 100, selecting 1000 from a later page, last-row search, exact original notice retention, absence of a phantom 101st data row, original-index linked details and source immutability. A real account row with another populated column is not classified as a notice. Corrected the new synthetic assertion to the established literal " decoding and existing ungrouped pagination label; neither source decoding nor an established baseline was changed. All 44 final importer/UI checks and 17 preceding version/architecture/API checks pass, plus design-language and local-only checks. All 507 mandatory import checks and both production builds pass; existing client chunk-size warning only. Browser Dummy acceptance confirms 10/25/100/250/500/1000 choices and selecting 1000 preserves all available source rows; the 100/800/1000 row load and linked-modal behavior are verified synthetically. The temporary browser client encountered an unrelated incomplete activity status during load; the retained User Accounts results were available and the selector check passed. Temporary tab closed. Backend health confirms 2.24.10, PostgreSQL and unchanged repository revision 592. No database, importer fixture/snapshot or scoring changes; no full-suite/phase-completion claim.
## GPO Results source presentation (2026-10-12, Phase 7 Presentation)
User release: Presentation, Core Tests, Platform. The user extends the removed generated observation table to GPOs in both Overview and Security. Original scanner tables, stored observations/provenance and the separately retained processed-backup inventory remain available and unchanged. The shared top-fact component suppresses only the Field/Original value/Source/Scan run table for GPOs, Computer Objects and User Accounts.
GPO source section prose is displayed in short paragraphs, retaining existing paragraph boundaries and grouping whole sentences up to approximately 500 characters. The presentation has an 85ch maximum reading width, shared 16px prose role and token-based paragraph spacing. No scanner-specific semantic parsing, rewording, translated headings, data extraction or source mutation is introduced. Nested source sections retain the same presentation. A sentence longer than the limit stays whole.
Patch decision: 2.24.9 synchronized in package/lock and UI/API version contracts. All 38 targeted Results/evidence/version checks plus design-language and local-only checks pass. Synthetic Light/Dark interactions exercise both GPO entry points, retained native table and backup inventory, absence of the generated observation table and exact text preservation including command/URL/filename punctuation. All 504 mandatory import checks and both production builds pass; existing client chunk-size warning only. Browser acceptance with Dummy in Light Overview and Dark Security confirms no generated observation header, seven retained scanner tables and ten readable paragraphs with shared 16px prose/16px spacing and approximately 751px reading width. Light restored and temporary tab closed. Backend health confirms 2.24.9, PostgreSQL and unchanged repository revision 592. No database/import-baseline changes or full-suite/phase-completion claim.
## Assessment facts table (2026-10-12, Phase 7 Presentation)
User release for implementation: Presentation, Core Tests, Platform. Replace the nine-item three-column definition list inside Assessment facts with a compact semantic two-column Metric/Value table, retaining all original calculations, number formatting, unknown values and Results-window controls. Row headers and right-aligned values use shared typography, spacing and separators; the table is capped at 640px without stretching across the Results window. Permanent Light/Dark Security interaction acceptance checks all nine rows and the header/row semantics alongside the existing values and navigation.
Patch decision: 2.24.8, synchronized in package/lock and UI/API version contracts. All 18 targeted Security/version tests, 504 mandatory import checks, both production builds, design-language and local-only checks pass. Existing client chunk-size warning only; no database/import-contract changes or full-suite/phase-completion claim. Follow-up user corrections, each with renewed Presentation/Core Tests/Platform consent, remove the generated Field/Original value/Source/Scan run observation table from Computer Objects and User Accounts through their shared fact component, covering both Overview and Security. Native scanner tables, original observations, provenance, overview counts and linked details remain unchanged. Permanent synthetic Light/Dark interactions cover both entry points and retained source evidence. The existing six-control test is scoped to both sets of six controls after the preceding Security composition change; no production workaround. All 44 targeted checks pass across the final runs. Browser Dummy acceptance confirms zero generated tables and two native computer tables/one native user table in both themes, plus nine Assessment facts rows within a 640px table. Light restored and temporary test tab closed. The final CSS keeps row labels in the 14px structured-data role rather than inheriting uppercase column-header styling.
## Final Security diagram composition and category bars (2026-10-12, Phase 7 Presentation)
Current Core release: Presentation, Core Tests, Platform. The user supersedes the previous score placement with the supplied final composition: Overall Score and Remediation stack on the left above the compact Domain block; the middle has shorter flows and an aligned Category table with name, 80px deviation-share track and exact count; the right starts with the existing six icon-bearing inventory facts and retains the compact information tables. The graphic caps its own width at 1280px inside the retained 2:1 allocation, shortening the flows without shrinking 14px text. Category bars use the same authoritative deviation denominator as Domain bars; zero categories retain a zero bar and neutral category marker. Click, Enter and Space still open the exact category.
The previous nine visible assessment totals move to the existing Results-window interaction under `Assessment facts` beneath the left scores. Their original calculations and complete values remain available and tested; no source count or scoring semantics are changed. Inventory reuses the current Client Info fact controls, glyphs, provenance/evidence and GPO backup drilldown. The shared default-open overview disclosure and common outer boundaries remain.
Follow-up user correction in the same unfinished release batch: increase the separation below Remediation and the Assessment facts control by shifting the compact Domain block down 32px, without changing its 28px row spacing, map height, bars or findings. Final browser review uses a freshly reloaded development page to avoid accepting stale CSS.
Further user correction in the same batch: an Inventory row's selected detail must appear above its parent Results window. Source table and source information dialogs now join the existing shared floating-window activation order, including pointer/focus activation, instead of relying on the fixed 2700 backdrop beneath floating windows starting above 3000. Permanent synthetic Light/Dark acceptance opens Inventory, activates it, clicks Schema Administrators, follows a nested member table and opens a prose/graph detail; every child stays above the parent and original source evidence is unchanged. The Security acceptance test waits for the existing Results-close focus-return frame before starting the next independent navigation interaction.
Patch decision: 2.24.7 for this backward-compatible presentation refinement, synchronized in package/lock and UI/API version contracts. Synthetic Light/Dark acceptance checks category-track geometry, exact counts/shares, left score placement, six retained inventory values, assessment Results values and unchanged navigation/disclosure behavior. Verification complete for this batch: all 51 targeted Security/evidence/version/Core/Context checks, all 504 mandatory import checks, both production builds, design-language and local-only checks pass. The first import gate encountered existing importer/HTTP timeouts during concurrent browser work; the unchanged full gate passed on the final sequential retry. Existing client chunk-size warning only; no full-suite or phase-completion claim.
Browser acceptance with Dummy in Light/Dark: selected Schema Administrators details render and receive focus above the activated Inventory window; the shared activation layer is higher than its parent. The final score/domain separation measures 59.6px, with unchanged 28px domain rows, 80px category tracks, 478 deviations and score 36. Compact facts use 554px within the 688px overview. Light restored, viewport reset and temporary tab closed. Backend health confirms 2.24.7, PostgreSQL and unchanged repository revision 592; no database or importer-baseline changes.
Change log 2026-10-12: implement the final referenced composition, shorten horizontal flows, add aligned Category bars/counts, increase score/domain separation and keep selected Inventory details in front.
## Compact domains and shared Security overview disclosure (2026-10-12, Phase 7 Presentation)
Current Core release: Presentation, Core Tests, Platform. The user supersedes domains spread across the full diagram height: six domain rows form one vertically centered block with 28px spacing, a heading, subtle row separators, compact label/bar/count alignment and unchanged authoritative counts/shares. The 2:1 graph/facts allocation and readable report-style facts remain. The shared overview and lower Findings table explicitly use the same border-box outer width and cannot widen through an intrinsic minimum; graph labels retain an inset from the outer edge.
Additional explicit user correction: the `Security Domains → Categories` disclosure folds the whole overview, including graph, both scores and information. A single default-open native details owns that summary and all overview content; the diagram has no independent disclosure. The Findings table stays outside and visible, and its open finding/category state is preserved. Mouse and keyboard activation use native disclosure behavior.
Patch decision: 2.24.6 for this backward-compatible refinement, synchronized in package/lock and UI/API version contracts. Permanent synthetic Light/Dark interactions cover compact row spacing, common outer-width rules, default-open shared disclosure and repeated collapse/reopen with the Findings table outside, alongside retained diagram navigation and source invariance. All 28 targeted UI/version/Core/Context-boundary checks, the final six Security checks, all 504 mandatory import checks, both production builds, design-language and local-only checks pass. Corrected the new computed-style assertion to the browser's `0px` serialization; no production workaround or weakened behavioral assertion. Existing chunk-size warning only; no full-suite or phase-completion claim.
Browser acceptance with Dummy in Light/Dark: six domain rows are 28px apart and centered, with unchanged 112/54/20/245/7/40 counts. Panel and Findings-table left/right boundaries match exactly (zero difference), without horizontal overflow. Overview height remains 688px and readable facts use 633px. Mouse closes graph, scores and information together; Enter and Space reopen them. The lower Findings table remains visible, and an opened 42-finding Credential Relay category retains its state across folding. Light restored, viewport reset and temporary tab closed. Backend health confirms 2.24.6, canonical PostgreSQL and unchanged repository revision 592; no database or importer-baseline changes.
Change log 2026-10-12: compact the domain block, align the overview/table outer boundaries and fold the whole overview with one disclosure.
## Security overview proportions and report sizing (2026-10-12, Phase 7 Presentation)
Current Core release: Presentation, Core Tests, Platform. The user supersedes the fixed-width graph and scrolling facts area: the left two thirds belong to the diagram; the right third contains both score rows above a compact two-column information table, following the Technical Report Information section. The graph measures its allocated width and extends the flows while preserving 14px text, 80px domain tracks and complete category labels. Report-style flexible label/value rows use the existing 14px data role with 1.2 line height; no intrinsic 50/50 split forces short labels and long values into unnecessarily tall rows. The information area uses normal document flow, with no absolute-positioned scrolling container, clipping or hidden-scrollbar workaround. Facts, source values, removed groups/notices and exact category navigation retain the current decisions. Report Engine implementation is inspected as the sizing reference; its output is unchanged.
Patch decision: 2.24.5 for this backward-compatible layout correction, synchronized in package/lock and UI/API version contracts. Synthetic Light/Dark interaction acceptance checks the 2:1 allocation, removal of internal scroll containers and graph resizing without changed counts/bar geometry or category-navigation behavior. All 28 targeted Security/version/Core/Context-boundary checks, all 504 mandatory import checks, both production builds, design-language and local-only checks pass. Existing chunk-size warning only; no full-suite or phase-completion claim.
Browser acceptance in Light/Dark with Dummy: the graph/facts width ratio is 2:1, including measured 1389px/694px zones and two 337px fact columns at the narrower tested desktop width. The complete facts area is 633px high, within the 688px graph zone; wider review measures 614px facts height. There is no internal scroll container, horizontal overflow or text clipping; structured data stays at 14px with 16.8px row line height. Domain counts remain 112/54/20/245/7/40 and Enter on the graph category opens exactly 42 Credential Relay findings and focuses the category disclosure. Light restored, viewport reset and temporary tab closed. Backend health confirms 2.24.5, canonical PostgreSQL and unchanged repository revision 592. No database changes or importer baseline changes.
Change log 2026-10-12: assign two thirds to the graph and one third to compact report-style information beneath both scores; remove inner scroll containers.
## Bound compact Security facts to the diagram (2026-10-12, Phase 7 Presentation)
Current Core release: Presentation, Core Tests, Platform. The user explicitly supersedes the previous stacked/unbounded overview: Security information occupies only the area to the right of the diagram and ends at its bottom separator. The diagram-derived height bounds the entire overview; the fact content has no intrinsic influence on that height. The layout stays beside the graph at narrow widths, with contained scrolling when the available area cannot hold readable source values. The background is transparent; the previous semantic aside unintentionally inherited a sidebar surface and is replaced with a section. Compact label/value rows retain the agreed 14px structured-data role. Certificates & PKI and Source security metrics are removed from the Security overview only, as explicitly requested; the existing Client Info Overview and Results retain them. Six Security Domain bars reuse the exact Core deviation counts, with shares of the same authoritative deviation total and Context-off invariance. Score, remediation, other totals, environment facts and graph/list navigation keep their existing semantics.
Patch decision: 2.24.4 for this backward-compatible layout correction, synchronized in package/lock and UI/API version contracts. Permanent synthetic Light/Dark interactions now verify transparent background, bounded-content layout, removal of the two requested groups, domain-bar totals/equality with Client Info and invariance after disabling Context. All six final Security interactions, 28 targeted UI/version/Core/Context-boundary checks, all 504 mandatory import checks, both final production builds, the design-language check and local-only check pass. The first parallel import-gate attempt timed out in one existing Node importer test; the complete unchanged gate passed on retry without parallel test load. Existing chunk-size warning only. Backend health confirms 2.24.4, PostgreSQL and unchanged repository revision 592. No database/schema changes, no importer baseline changes and no full-suite or phase-completion claim.
Follow-up user correction 2026-10-12 (same unfinished 2.24.4 release batch): use the Technical Report's compact fact selection and independently stacked two-column groups, with the six inventory values preceding their labels in two rows. Resilience, Inventory and Password policy show the same four summary facts as the report, operating systems use the existing shared compact overview, and Results retains the complete facts/evidence. Score and Remediation use flat number/bar rows; Security Domains sits in the right information column with label, 80px track and exact count. This replaces the first compact attempt's shared grid rows and short 32px tracks. No report or data semantics change. Synthetic Light/Dark acceptance compares each displayed summary with the Technical Report fact projection and verifies every bar's exact deviation share. Final browser/build verification follows below.
Final clarification 2026-10-12 supersedes the preceding bar placement: the six Domain labels inside the diagram itself show name, an 80px deviation-share bar and exact count before the category flows. The separate Security Domains information block on the right is removed. The SVG widens from 850 to 920px to keep the 14px labels, bars, counts and full category names separate; its height and the common lower separator are unchanged. Context-off removes the whole diagram while authoritative domain counts remain unchanged in the original Client Info Overview. Permanent Light/Dark interactions now assert the bars inside the SVG, their exact geometry/counts, absence of the redundant right block and preserved mouse/keyboard category navigation.
Additional explicit user correction 2026-10-12: remove the `Restricted by the source tool` notice from this compact Security overview. Retained source restrictions and evidence remain unchanged; an actual environment-loading error still appears. The Security interaction test permanently checks the notice is absent.
Final browser acceptance: Dummy retains 478 deviations, score 36 and domain counts 112/54/20/245/7/40. All six 80px tracks are inside the diagram, labels do not overlap the tracks, and neither the redundant domain block nor the removed restriction notice appears on the right. The full right overview fits its 688px height with scrollHeight equal to clientHeight in both Light and Dark. The compact summaries retain 14px structured-data typography. Final six UI checks pass, including a synthetic license-restricted section; corrected the new fixture's state to the existing `license-restricted` contract after the compiler caught the invalid shorthand. Light restored, viewport reset and temporary tab closed.
Change log 2026-10-12: limited Security hardfacts to the right of the graph above its separator, removed the background/two specified groups, adopted the report's compact fact arrangement and added authoritative Security Domain bars.
## Security overview and category navigation (2026-10-12, Phase 7 Presentation)
Current Core release: Presentation, Core Tests, Platform. The user supersedes the collapsed diagram default: the Security Domains → Categories diagram starts open, while category/finding disclosures retain their existing defaults. Clicking a diagram category or activating it with Enter/Space opens the exact list category, focuses its disclosure and scrolls to it; repeated activation works after manual collapse. This navigation does not modify Work filters or stored data. The SVG is an accessible group containing keyboard-operable category controls.
Beside the diagram on wide panels, the Security tab reuses the existing authoritative Client Info score, assessed/deviation shares, coverage, gaps, conflict/duplicate totals, environment overview and native security facts. Narrow panels stack these zones and retain readable graph/data typography. Overall Score and Remediation use numbers and solid bars; Remediation states the existing deviation count, with no task workflow or new score. Environment facts include directory, inventory, password policy, resilience (including LAPS/WEF), certificates/PKI, operating systems, cloud facts and source security metrics. Existing Results evidence controls are retained; unknown/restricted values remain explicit. Valid risk acceptance counts use the existing risk-register state. Context-off suppresses the diagram/category counts and preserves the Core finding list and hardfacts. Score methodology uses one shared dialog across both tabs.
Patch decision: 2.24.3 for this backward-compatible refinement of the Security workspace, synchronized in package/lock and UI/API version contracts. Verified: six synthetic Light/Dark Security interactions (including exact category navigation and equality with Client Info inventory), 28 targeted UI/version/Core/Context boundary checks, all 504 mandatory import compatibility checks, both production builds and the final updated Frontend build pass. Design-language and local-only checks pass; existing client chunk-size warning remains. No schema/database/source-import changes and no full-suite or phase-completion claim. Actual Dummy production browser acceptance passes in Light/Dark: 478 deviations, Overall Score 36, 759 assessed, 281 passed, 1,070 gaps, 41% coverage, 21 populated categories and four Critical categories agree with Client Info. Mouse and Enter activation open Credential Relay Exposure with its exact 42 findings. Final compact SVG measures 850 by 640 with 14px text; medium panels retain adjacent hardfacts, narrow panels stack without page overflow. Light restored, temporary viewport reset and test tab closed. Backend health is ok with 2.24.3, canonical PostgreSQL and unchanged repository revision 592.
Change log 2026-10-12: default-open Security graph, exact category list navigation and source-backed Security hardfacts overview.
## 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.

4
package-lock.json generated
View File

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

View File

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

View File

@ -6,6 +6,7 @@ export function installTableRowMarking(documentRoot: Document): () => void {
const markClickedRow = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest('[data-table-row-marking="off"]')) return;
const row = target.closest(SELECTABLE_ROW);
const table = row?.closest('table, [role="table"]');
if (!row || !table) return;

View File

@ -17,8 +17,13 @@ import { contextClusterSeverities } from './context-cluster-severity';
* It never produces a pass/fail verdict, a priority or an effective host state, and it never writes.
* GPO backups contribute what they declare; scanners contribute what they observed. Both stay
* separately attributed evidence of the same catalogued conditions.
*
* The version changes with every recognition vocabulary release, because consumers (for example stored work-package
* snapshots) key their results by this version and the catalogue version, not by the vocabulary.
*/
export const ASSESSMENT_CONTEXT_ENGINE = { id: 'assessment-context-engine', version: '1.11.1' } as const;
export const ASSESSMENT_CONTEXT_ENGINE = { id: 'assessment-context-engine', version: '1.12.0' } as const;
/** Vocabulary release the engine version above was published with; a vocabulary change needs a new engine version. */
export const ASSESSMENT_CONTEXT_ENGINE_VOCABULARY = '1.2.0';
export type DeclaredValue =
| { state: 'configured'; value: number }

View File

@ -11,7 +11,7 @@
* catalogued rule text and report section title (tests/assessment-context/context-recognition.test.ts); a term
* whose matches contradict the catalogue is narrowed or removed, never kept for recall.
*/
export const CONTEXT_RECOGNITION_VOCABULARY = { id: 'assessment-context-recognition', version: '1.1.0' } as const;
export const CONTEXT_RECOGNITION_VOCABULARY = { id: 'assessment-context-recognition', version: '1.2.0' } as const;
export interface RecognitionTerm {
id: string;
@ -214,6 +214,25 @@ export const CONTEXT_RECOGNITION_TERMS: RecognitionTerm[] = [
term('physical-local-access-exposure', 'lock-screen', /\block screen\b|\bcamera\b|\bbiometric\w*/i),
term('detection-blind-spots', 'eventlog-path', /\bEventLog\b/),
// Findings measured as unplaced on 2026-10-12 (vocabulary 1.2.0): antivirus scan options, inactive domain controllers
// and trusts (user decision: trusts belong to recovery), delegated permissions on AD OUs, Defender for Identity
// honeytokens and VPN integration.
term('malicious-code-execution-exposure', 'defender-scan', /\b(?:full|quick) scan\b|\bmalware\b|\bscan (?:removable|mapped) (?:network )?drives?\b/i,
/\bmalware filter\w*|\banti-?malware polic\w*|\bcommon attachment\w*/i),
term('ad-recovery-gaps', 'inactive-dc', /\bDCs? inactive\b|\binactive (?:DCs?|domain controllers?)\b/i),
term('ad-recovery-gaps', 'trust', /\btrusts?\b/i, /\bfor delegation\b|\bTrust Center\b|\bzero trust\b/i),
term('ad-privileged-account-exposure', 'ou-delegated-permission', /\bdelegated permissions? on (?:AD )?(?:OUs?|organizational units?)\b|\bpermissions? (?:on|over) (?:AD )?(?:OUs?|organizational units?)\b|\bOU delegation\b/i,
/\bGraph\b|\bapplications?\b|\bapps?\b|\bconsent\b|\bEntra\b|\bAPI\b/i),
term('detection-blind-spots', 'mdi-deception', /\bhoneytokens?\b|\bhoney ?pots?\b|\bVPN integration\b/i),
// Entra identity governance and workload identities map to the existing cloud clusters (user decision 2026-10-12):
// access packages, entitlement catalogs, pending access approvals and dynamic group rule attributes grant access;
// managed identities replace stored application secrets.
term('cloud-privileged-access-exposure', 'identity-governance', /\baccess packages?\b|\bentitlement management\b|\bpending approvals?\b|\bdynamic (?:group|membership) rules?\b/i),
term('cloud-app-consent-exposure', 'managed-identity', /\bmanaged identit(?:y|ies)\b/i, /\bmanaged service accounts?\b/i),
// Defender for Identity posture recommendations name their scope in the title.
term('password-guessing-exposure', 'mdi-account-attributes', /\b(?:unsecure|insecure) account attributes\b/i),
term('credential-relay-exposure', 'mdi-domain-configuration', /\b(?:unsecure|insecure) domain configurations?\b/i),
// Data sharing.
term('data-sharing-exposure', 'telemetry', /\btelemetry\b|\bdiagnostic data\b|\bdata collection\b/i),
term('data-sharing-exposure', 'dlp', /\bDLP\b|\bdata loss prevention\b|\bsensitivity labels?\b|\bretention polic(?:y|ies)\b/i),

View File

@ -3,6 +3,7 @@ import type { FindingRecord } from '../../domain/finding';
import { normalizeSeverity } from '../../profiles/purple-knight/presentation';
import { outcomeForStatusValue, toolIdForRecord } from '../../importers/status-normalization';
import { resultStatusFieldFor } from '../../features/linked-results/result-message';
import { sourceCategoryField } from '../source-category-field';
import { highlightText } from '../HighlightText/highlight-text';
import { BookmarkIcon } from '../BookmarkIcon';
import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
@ -29,7 +30,8 @@ export function tileBadges(record: FindingRecord, fallbackToolId?: string): Tile
{ label: 'Score', pattern: /^score$/i }
];
const badges = requested.flatMap<TileBadge>(({ label, pattern }) => {
const field = label === 'Status' ? resultStatusFieldFor(record, fallbackToolId) : record.fields.find(candidate => {
const field = label === 'Status' ? resultStatusFieldFor(record, fallbackToolId)
: label === 'Category' ? sourceCategoryField(record, fallbackToolId) : record.fields.find(candidate => {
if (!candidate.value) return false;
return pattern.test(candidate.label.trim());
});
@ -47,11 +49,13 @@ export function tileBadges(record: FindingRecord, fallbackToolId?: string): Tile
? record.fields.filter(field => field.label.trim().toLowerCase() === 'scope' && field.value.trim())
.map(field => ({ label: 'Scope', value: field.value.trim(), text: `Scope: ${field.value.trim()}` })) : [];
const orderedBadges = [
...badges.filter(badge => badge.label === 'Status' || badge.label === 'Category'),
...badges.filter(badge => badge.label === 'Status'),
...badges.filter(badge => badge.label === 'Severity'),
...badges.filter(badge => badge.label === 'Category'),
...cisBadges,
...gpoBadges,
...scopeBadges,
...badges.filter(badge => badge.label !== 'Status' && badge.label !== 'Category')
...badges.filter(badge => badge.label !== 'Status' && badge.label !== 'Severity' && badge.label !== 'Category')
];
const key = (badge: TileBadge) => (badge.label === 'Scope' ? badge.text : badge.value).trim().toLocaleLowerCase('en-US');
@ -62,8 +66,8 @@ export function compactTileBadges(badges: TileBadge[], limit?: number): TileBadg
if (!limit || badges.length <= limit) return badges;
const cisBadges = badges.filter(badge => badge.label === 'CIS Category');
const preferred = cisBadges.length
? [badges.find(badge => badge.label === 'Status'), badges.find(badge => badge.label === 'Weight'), cisBadges.at(-1)]
: [badges.find(badge => badge.label === 'Status'), badges.find(badge => badge.label === 'Category'), badges.find(badge => badge.label === 'Severity')];
? [badges.find(badge => badge.label === 'Status'), badges.find(badge => badge.label === 'Severity') ?? badges.find(badge => badge.label === 'Weight'), cisBadges.at(-1)]
: [badges.find(badge => badge.label === 'Status'), badges.find(badge => badge.label === 'Severity'), badges.find(badge => badge.label === 'Category')];
const selected = preferred.filter((badge, index, candidates): badge is TileBadge => Boolean(badge) && candidates.indexOf(badge) === index);
for (const badge of badges) {
if (selected.length >= limit - 1) break;

View File

@ -0,0 +1,12 @@
import type { FindingRecord, RecordField } from '../domain/finding';
import { toolIdForRecord } from '../importers/status-normalization';
/** Read-only display alias for an existing scanner category; preserves the source field/value. */
export function sourceCategoryField(record: FindingRecord, fallbackToolId?: string): RecordField | undefined {
const field = (label: string) => record.fields.find(item => item.label.trim().toLowerCase() === label && item.value.trim());
const category = field('category');
if (category) return category;
if (toolIdForRecord(record, fallbackToolId) !== 'maester') return undefined;
const block = field('block');
return block ? { ...block, label: 'Category' } : undefined;
}

View File

@ -0,0 +1,47 @@
import { useId } from 'react';
import type { dashboardScoreFromStates } from './dashboard-result-state';
type Counts = ReturnType<typeof dashboardScoreFromStates>;
const number = (value: number) => value.toLocaleString('en-US');
function Node({ label, value, detail }: { label: string; value: number; detail?: string }) {
return <div className="client-assessment-node" data-metric={label}><strong>{label}</strong><b>{number(value)}</b>{detail && <span>{detail}</span>}</div>;
}
function Connector({ groups }: { groups: { from: number; to: number[] }[] }) {
const markerId = useId().replace(/:/g, '');
return <svg className="client-assessment-connector" aria-hidden="true">
<defs><marker id={markerId} viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 Z"/></marker></defs>
{groups.map(({ from, to }) => <g key={from}>
<line x1={`${from}%`} x2={`${from}%`} y1="0%" y2="50%"/>
<line x1={`${Math.min(from, ...to)}%`} x2={`${Math.max(from, ...to)}%`} y1="50%" y2="50%"/>
{to.map(position => <line key={position} x1={`${position}%`} x2={`${position}%`} y1="50%" y2="100%" markerEnd={`url(#${markerId})`}/>)}
</g>)}
</svg>;
}
export function AssessmentFactsExplanation({ counts, gpo }: { counts: Counts; gpo: { notConfigured: number; noBaselineRule: number } }) {
const otherGaps = counts.unassessed - gpo.notConfigured - gpo.noBaselineRule;
return <section className="client-assessment-explanation" aria-label="Assessment coverage explanation">
<h3>What can be assessed?</h3>
<p>A setting can only be assessed against a requirement supplied by the scanner or an applicable GPO baseline. A usable comparison result is also required. Assessed counts only Passed and Deviations; a missing verdict is not automatically a deviation.</p>
<p>GPO backups additionally distinguish <strong>Not configured</strong> (a baseline requirement exists, but the setting was not configured in the imported backup) and <strong>No baseline rule</strong> (a setting is configured, but no applicable requirement exists). Both remain visible without a Pass/Fail verdict. Backup configuration does not establish effective host state.</p>
<figure className="client-assessment-diagram" aria-label="Assessment coverage by Work record">
<figcaption>Assessment coverage by Work record</figcaption>
<div className="client-assessment-grid client-assessment-root"><Node label="Eligible controls" value={counts.eligible} detail="Not applicable excluded"/></div>
<Connector groups={[{ from: 50, to: [20, 70] }]}/>
<div className="client-assessment-grid client-assessment-branches">
<Node label="Assessed" value={counts.assessed}/>
<Node label="Gaps" value={counts.unassessed} detail="No usable Pass/Fail verdict"/>
</div>
<Connector groups={[{ from: 20, to: [10, 30] }, { from: 70, to: [50, 70, 90] }]}/>
<div className="client-assessment-grid client-assessment-leaves">
<Node label="Passed" value={counts.passed}/><Node label="Deviations" value={counts.deviations}/>
<Node label="GPO: Not configured" value={gpo.notConfigured} detail="Requirement exists"/>
<Node label="GPO: No baseline rule" value={gpo.noBaselineRule} detail="Configuration exists"/>
<Node label="Other gaps" value={otherGaps} detail="Other missing verdicts"/>
</div>
</figure>
<p>Coverage = Assessed ÷ Eligible controls{counts.coverage === undefined ? ' (no eligible controls).' : ` = ${counts.coverage}%.`} Other gaps include not-run checks, errors, review and other missing verdicts. Each Work record counts once, including each rolled-up GPO setting. Conflicts and duplicates are diagnostic flags that can overlap these groups; they are not additional records.</p>
</section>;
}

View File

@ -4,12 +4,19 @@ export interface ClientInfoTab {
id: string;
label: string;
content?: ReactNode;
/** Defer optional engine views until opened, then keep their local table state. */
lazy?: boolean;
}
/** Shared attached-panel presentation for every Client Info tab. */
export function ClientInfoTabs({ tabs }: { tabs: readonly ClientInfoTab[] }) {
const prefix = useId();
const [selected, setSelected] = useState(tabs[0]?.id);
const [visited, setVisited] = useState<string[]>([]);
const select = (id: string) => {
setSelected(id);
setVisited(current => current.includes(id) ? current : [...current, id]);
};
const active = tabs.some(tab => tab.id === selected) ? selected : tabs[0]?.id;
const navigate = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
let next: number;
@ -19,7 +26,7 @@ export function ClientInfoTabs({ tabs }: { tabs: readonly ClientInfoTab[] }) {
else if (event.key === 'End') next = tabs.length - 1;
else return;
event.preventDefault();
setSelected(tabs[next].id);
select(tabs[next].id);
document.getElementById(`${prefix}-tab-${tabs[next].id}`)?.focus();
};
return <div className="client-info-tabs">
@ -27,11 +34,11 @@ export function ClientInfoTabs({ tabs }: { tabs: readonly ClientInfoTab[] }) {
{tabs.map((tab, index) => <button key={tab.id} type="button" role="tab"
className="client-info-tab" id={`${prefix}-tab-${tab.id}`}
aria-controls={`${prefix}-panel-${tab.id}`} aria-selected={tab.id === active}
tabIndex={tab.id === active ? 0 : -1} onClick={() => setSelected(tab.id)}
tabIndex={tab.id === active ? 0 : -1} onClick={() => select(tab.id)}
onKeyDown={event => navigate(event, index)}>{tab.label}</button>)}
</div>
{tabs.map(tab => <section key={tab.id} className="client-info-tabpanel" role="tabpanel"
id={`${prefix}-panel-${tab.id}`} aria-labelledby={`${prefix}-tab-${tab.id}`}
hidden={tab.id !== active} data-group-scroll={`client-info-${tab.id}`} tabIndex={0}>{tab.content}</section>)}
hidden={tab.id !== active} data-group-scroll={`client-info-${tab.id}`} tabIndex={0}>{!tab.lazy || tab.id === active || visited.includes(tab.id) ? tab.content : null}</section>)}
</div>;
}

View File

@ -1,4 +1,4 @@
import { useId, useMemo, useState, type CSSProperties } from 'react';
import { useId, useMemo, useState, useRef, useLayoutEffect, type CSSProperties, type ReactNode } from 'react';
import type { ImportedDataset } from '../../domain/dataset';
import type { FindingRecord } from '../../domain/finding';
import { TriangleIcon } from '../../components/TriangleIcon';
@ -11,6 +11,7 @@ import { recordResultSheets } from '../linked-results/record-results';
import { policyProblemSummary } from '../../profiles/group-policy/problem-summary';
import { PolicyProblemSummary } from '../finding-reader/PolicyProblemSummary';
import '../../styles/client-security.css';
import { useSecurityExport } from './SecurityExportContext';
export interface SecurityDomainCount { id: string; label: string; count: number }
export interface SecurityFindingGroup {
@ -22,43 +23,96 @@ export interface ClientSecurityPanelProps {
dataset: ImportedDataset; sheetId: string; toolId?: string;
groups: SecurityFindingGroup[]; domains: SecurityDomainCount[]; total: number;
contextStatus: string;
overview?: ReactNode;
metrics?: ReactNode;
actions?: ReactNode;
}
const amount = (value: number) => value.toLocaleString('en-US');
const tones = ['--viz-2', '--viz-5', '--viz-3', '--viz-4', '--viz-1', '--viz-6'];
const flowStyle = (tone: string, strength?: number): CSSProperties => ({ '--domain-tone': `var(${tone})`, '--flow-width': strength } as CSSProperties);
const shareStyle = (share: number): CSSProperties => ({ '--share': `${share}%` } as CSSProperties);
const overviewStyle = (groups: number): CSSProperties => ({ '--security-map-height': `${Math.max(1, groups) * 28 + 24}px` } as CSSProperties);
const severityOrder = ['critical', 'high', 'medium', 'low', 'info', 'unknown'];
const severityOf = (record: FindingRecord) => normalizeSeverity(record.fields.find(field => /^(severity|criticality)$/i.test(field.label))?.value);
const label = (value: string) => value === 'unknown' ? 'Undetermined' : value.charAt(0).toUpperCase() + value.slice(1);
const percent = (part: number, total: number) => !total ? '—' : part > 0 && part / total < .005 ? '<1%' : `${Math.round(100 * part / total)}%`;
/** Counts and membership are supplied by the existing dashboard extension; this view never assigns findings. */
function DomainMap({ domains, groups }: Pick<ClientSecurityPanelProps, 'domains' | 'groups'>) {
function DomainMap({ domains, groups, total, onSelect, onSelectDomain }: Pick<ClientSecurityPanelProps, 'domains' | 'groups' | 'total'> & { onSelect(id: string): void; onSelectDomain(id: string): void }) {
const map = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(920);
type MapFocus = { kind: 'domain' | 'category'; id: string };
const [hovered, setHovered] = useState<MapFocus>();
const [focused, setFocused] = useState<MapFocus>();
const active = hovered ?? focused;
const categoryConnected = (group: SecurityFindingGroup) => !active || (active.kind === 'category'
? group.id === active.id : group.domains.some(domain => domain.id === active.id && domain.count > 0));
const domainConnected = (id: string) => !active || (active.kind === 'domain' ? id === active.id
: groups.find(group => group.id === active.id)?.domains.some(domain => domain.id === id && domain.count > 0));
useLayoutEffect(() => {
const element = map.current;
if (!element) return;
const measure = () => {
const next = element.getBoundingClientRect().width;
if (next > 0) setWidth(next);
};
measure();
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : undefined;
observer?.observe(element);
return () => observer?.disconnect();
}, []);
const height = Math.max(1, groups.length) * 28 + 24;
const leftX = 300, rightX = 490, mid = (leftX + rightX) / 2;
const leftY = new Map(domains.map((domain, index) => [domain.id, 12 + (index + .5) * (height - 24) / domains.length]));
const leftX = 378, rightX = Math.max(leftX + 140, width - 500), mid = (leftX + rightX) / 2;
const domainRowHeight = 28;
const domainTop = (height - Math.max(0, domains.length - 1) * domainRowHeight) / 2 + 32;
const leftY = new Map(domains.map((domain, index) => [domain.id, domainTop + index * domainRowHeight]));
const max = Math.max(1, ...groups.flatMap(group => group.domains.map(domain => domain.count)));
return <details className="client-security-map">
<summary><TriangleIcon direction="right"/>Security Domains → Categories</summary>
<div className="client-security-map-scroll"><svg viewBox={`0 0 1040 ${height}`} role="img" aria-label="Security Domains and the categories of their deviations">
return <div ref={map} className="client-security-map">
<div className="client-security-map-scroll"><svg viewBox={`0 0 ${width} ${height}`} role="group" aria-label="Security Domains and the categories of their deviations">
{groups.flatMap((group, index) => group.domains.filter(domain => domain.count > 0).map(domain => {
const start = leftY.get(domain.id)!; const end = 26 + index * 28;
return <path key={`${domain.id}:${group.id}`} className="client-security-flow" d={`M${leftX} ${start} C${mid} ${start} ${mid} ${end} ${rightX} ${end}`}
const connected = !active || (active.kind === 'domain' ? domain.id === active.id : group.id === active.id);
return <path key={`${domain.id}:${group.id}`} data-domain={domain.id} data-category={group.id} className={`client-security-flow${connected ? '' : ' is-muted'}`} d={`M${leftX} ${start} C${mid} ${start} ${mid} ${end} ${rightX} ${end}`}
style={flowStyle(tones[domains.findIndex(item => item.id === domain.id)] ?? '--graph-category', 1.5 + 12 * Math.sqrt(domain.count / max))}>
<title>{domain.label} → {group.label}: {amount(domain.count)}</title>
</path>;
}))}
{domains.map((domain, index) => <g key={domain.id}>
<text className="client-security-map-domain-heading" x="8" y={domainTop - 24}>Security Domains</text>
{domains.map((domain, index) => <g key={domain.id} className={`client-security-map-domain${domainConnected(domain.id) ? '' : ' is-muted'}`} data-domain={domain.id}
role="button" tabIndex={0} aria-label={`Show categories for ${domain.label}`} aria-disabled={!domain.count}
onMouseEnter={() => setHovered({ kind: 'domain', id: domain.id })} onMouseLeave={() => setHovered(undefined)}
onFocus={() => setFocused({ kind: 'domain', id: domain.id })} onBlur={() => setFocused(undefined)}
onClick={() => onSelectDomain(domain.id)} onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onSelectDomain(domain.id); }
}}>
<rect className="client-security-map-hit" x="8" y={leftY.get(domain.id)! - 14} width={leftX - 8} height="28"/>
<line className="client-security-domain-separator" x1="8" x2={leftX - 14} y1={leftY.get(domain.id)! + 12} y2={leftY.get(domain.id)! + 12}/>
<rect className="client-security-domain-marker" x={leftX - 6} y={leftY.get(domain.id)! - 10} width="6" height="20" style={flowStyle(tones[index] ?? '--graph-category')}/>
<text x={leftX - 14} y={leftY.get(domain.id)! + 5} textAnchor="end">{domain.label} <tspan>{amount(domain.count)}</tspan></text>
<text x="8" y={leftY.get(domain.id)! + 5}>{domain.label}</text>
<g className="client-security-domain-bar" role="img" aria-label={`${domain.count} of ${total} deviations`}>
<rect className="client-security-domain-track" x="248" y={leftY.get(domain.id)! - 4} width="80" height="8"/>
<rect className="client-security-domain-fill" x="248" y={leftY.get(domain.id)! - 4} width={total ? 80 * domain.count / total : 0} height="8"/>
</g>
<text className="client-security-domain-count" x={leftX - 14} y={leftY.get(domain.id)! + 5} textAnchor="end">{amount(domain.count)}</text>
</g>)}
{groups.map((group, index) => <g key={group.id}>
{groups.map((group, index) => <g key={group.id} role="button" tabIndex={0} data-category={group.id} className={`client-security-map-category${categoryConnected(group) ? '' : ' is-muted'}`}
onMouseEnter={() => setHovered({ kind: 'category', id: group.id })} onMouseLeave={() => setHovered(undefined)}
onFocus={() => setFocused({ kind: 'category', id: group.id })} onBlur={() => setFocused(undefined)}
aria-label={`Show ${group.label} in findings`} onClick={() => onSelect(group.id)}
onKeyDown={event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onSelect(group.id); } }}>
<rect className="client-security-map-hit" x={rightX} y={12 + index * 28} width="500" height="28"/>
<line className="client-security-domain-separator" x1={rightX + 14} x2={rightX + 486} y1={40 + index * 28} y2={40 + index * 28}/>
<rect x={rightX} y={16 + index * 28} width="6" height="20" className="client-security-marker" data-severity={group.records.length ? group.severity : 'unknown'}/>
<text x={rightX + 14} y={31 + index * 28}>{group.number ? `${group.number} · ` : ''}{group.label} <tspan>{amount(group.records.length)}</tspan></text>
<text className="client-security-category-label" x={rightX + 14} y={31 + index * 28}>{group.number ? `${group.number} · ` : ''}{group.label}</text>
<g className="client-security-category-bar" role="img" aria-label={`${group.records.length} of ${total} deviations`}>
<rect className="client-security-domain-track" x={rightX + 370} y={22 + index * 28} width="80" height="8"/>
<rect className="client-security-domain-fill" x={rightX + 370} y={22 + index * 28} width={total ? 80 * group.records.length / total : 0} height="8"/>
</g>
<text className="client-security-category-count" x={rightX + 486} y={31 + index * 28} textAnchor="end">{amount(group.records.length)}</text>
</g>)}
</svg></div>
</details>;
</div>;
}
function FindingDetail({ record, dataset, sheetId, toolId }: { record: FindingRecord } & Pick<ClientSecurityPanelProps, 'dataset' | 'sheetId' | 'toolId'>) {
@ -95,9 +149,15 @@ function FindingDetail({ record, dataset, sheetId, toolId }: { record: FindingRe
</div>;
}
function FindingRow(props: { record: FindingRecord } & Pick<ClientSecurityPanelProps, 'dataset' | 'sheetId' | 'toolId'>) {
export function ClientSecurityFindingRow(props: { record: FindingRecord } & Pick<ClientSecurityPanelProps, 'dataset' | 'sheetId' | 'toolId'>) {
const [open, setOpen] = useState(false); const id = useId();
const exported = useSecurityExport();
const severity = severityOf(props.record);
if (exported) return <li className="client-security-finding"><details>
<summary className="client-security-finding-toggle"><TriangleIcon direction="right"/>
<i className="client-security-marker" data-severity={severity} aria-label={label(severity)}/><strong>{props.record.title}</strong></summary>
<FindingDetail {...props}/>
</details></li>;
return <li className="client-security-finding">
<button type="button" className="client-security-finding-toggle" aria-expanded={open} aria-controls={id} onClick={() => setOpen(!open)}>
<TriangleIcon direction={open ? 'down' : 'right'}/><i className="client-security-marker" data-severity={severity} aria-label={label(severity)}/>
@ -107,42 +167,79 @@ function FindingRow(props: { record: FindingRecord } & Pick<ClientSecurityPanelP
</li>;
}
function FindingGroup({ group, ...props }: { group: SecurityFindingGroup } & Pick<ClientSecurityPanelProps, 'dataset' | 'sheetId' | 'toolId' | 'total'>) {
const [open, setOpen] = useState(false); const id = useId();
function FindingGroup({ group, navigation, open, onToggle, ...props }: { group: SecurityFindingGroup; navigation?: { id: string; sequence: number }; open: boolean; onToggle(): void } & Pick<ClientSecurityPanelProps, 'dataset' | 'sheetId' | 'toolId' | 'total'>) {
const id = useId();
const exported = useSecurityExport();
const section = useRef<HTMLElement>(null);
const toggle = useRef<HTMLButtonElement>(null);
useLayoutEffect(() => {
if (navigation?.id !== group.id) return;
toggle.current?.focus({ preventScroll: true });
section.current?.scrollIntoView?.({ block: 'start', behavior: 'instant' });
}, [navigation, group.id]);
const records = useMemo(() => [...group.records].sort((left, right) => severityOrder.indexOf(severityOf(left)) - severityOrder.indexOf(severityOf(right)) || left.title.localeCompare(right.title, 'en') || left.id.localeCompare(right.id)), [group.records]);
const share = props.total ? 100 * records.length / props.total : 0;
return <section className="client-security-group" data-cluster={group.id}>
<button type="button" className="client-security-group-toggle" aria-expanded={open} aria-controls={id} onClick={() => setOpen(!open)}>
const heading = <>
<TriangleIcon direction={open ? 'down' : 'right'}/><span className="client-security-category"><i className="client-security-marker" data-severity={records.length ? group.severity : 'unknown'}/><span>{group.number && <small>{group.number} · </small>}{group.label}</span></span>
<span className="client-security-severity" data-severity={records.length ? group.severity : 'unknown'}>{label(group.severity)}</span>
<span className="client-security-share-bar" role="img" aria-label={`${amount(records.length)} of ${amount(props.total)} deviations`}><i style={shareStyle(share)}/></span>
<strong>{amount(records.length)}</strong><span>{percent(records.length, props.total)}</span>
</button>
<div id={id} hidden={!open} className="client-security-group-content">
{open && <>
</>;
const content = <>
{group.readingName && <p className="muted">{group.readingName}</p>}
{(group.situation || group.lever) && <div className="client-security-category-context">
{group.situation && <div><h3>Category context</h3><p>{group.situation}</p></div>}
{group.lever && <div><h3>Technical approach</h3><p>{group.lever}</p></div>}
</div>}
{group.domains.length > 0 && <p className="client-security-domain-counts">Security Domains: {group.domains.map(domain => `${domain.label} ${amount(domain.count)}`).join(' · ')}</p>}
{records.length ? <ul aria-label={`${group.label} findings`}>{records.map(record => <FindingRow key={record.id} record={record} {...props}/>)}</ul> : <p className="muted">No deviations in this category.</p>}
</>}
</div>
{records.length ? <ul aria-label={`${group.label} findings`}>{records.map(record => <ClientSecurityFindingRow key={record.id} record={record} {...props}/>)}</ul> : <p className="muted">No deviations in this category.</p>}
</>;
if (exported) return <details className="client-security-group" data-cluster={group.id} id={`security-category-${group.id}`}>
<summary className="client-security-group-toggle">{heading}</summary><div className="client-security-group-content">{content}</div>
</details>;
return <section ref={section} className="client-security-group" data-cluster={group.id}>
<button ref={toggle} type="button" className="client-security-group-toggle" aria-expanded={open} aria-controls={id} onClick={onToggle}>{heading}</button>
<div id={id} hidden={!open} className="client-security-group-content">{open && content}</div>
</section>;
}
export function ClientSecurityPanel(props: ClientSecurityPanelProps) {
const exported = useSecurityExport();
const { groups, total, contextStatus } = props;
const [navigation, setNavigation] = useState<{ id: string; sequence: number }>();
const [openGroups, setOpenGroups] = useState<Set<string>>(() => new Set());
const tableId = useId();
const allOpen = groups.length > 0 && groups.every(group => openGroups.has(group.id));
const selectCategories = (ids: string[]) => {
if (!ids.length) return;
setOpenGroups(previous => new Set([...previous, ...ids]));
setNavigation(previous => ({ id: ids[0], sequence: (previous?.sequence ?? 0) + 1 }));
};
return <div className="dashboard-page client-security-panel">
<header className="client-security-header"><h2>Security findings</h2><span>{amount(total)} deviations</span></header>
{contextStatus === 'ready' ? <DomainMap domains={props.domains} groups={groups}/> : <p role={contextStatus === 'error' ? 'alert' : 'status'} className="muted">
<header className="client-security-header"><h2>Security findings</h2><span>{amount(total)} deviations</span>{!exported && props.actions}</header>
<details className="client-security-overview-disclosure" open style={overviewStyle(groups.length)}>
<summary><TriangleIcon direction="right"/>Security Domains → Categories</summary>
<div className="client-security-overview">
<div className="client-security-graph-zone">
<div className="client-security-map-metrics">{props.metrics}</div>
{contextStatus === 'ready' ? <DomainMap domains={props.domains} groups={groups} total={total} onSelect={id => selectCategories([id])}
onSelectDomain={id => selectCategories(groups.filter(group => group.domains.some(domain => domain.id === id && domain.count > 0)).map(group => group.id))}/> : <p role={contextStatus === 'error' ? 'alert' : 'status'} className="muted">
{contextStatus === 'loading' ? 'Loading categories… Findings remain available below.' : contextStatus === 'off' ? 'Enable Context Engine to see categories and the domain map.' : 'Category assignments are unavailable. Findings remain available below.'}
</p>}
</div>
{props.overview}
</div>
</details>
{!total && <p>No deviations in the selected client and source scope.</p>}
<div className="client-security-table" aria-label="Findings by category">
<div className="client-security-columns" aria-hidden="true"><span/><span>Category</span><span>Base severity</span><span>Deviations</span><span>Count</span><span>Share</span></div>
{groups.map(group => <FindingGroup key={group.id} group={group} {...props}/>) }
<div id={tableId} className="client-security-table" aria-label="Findings by category">
<div className="client-security-columns"><span/><span className="client-security-category-heading">Category
<button type="button" className="btn client-security-toggle-all" disabled={!groups.length || Boolean(exported)} aria-expanded={allOpen} aria-controls={tableId}
aria-label={allOpen ? 'Collapse all categories' : 'Expand all categories'} title={allOpen ? 'Collapse all categories' : 'Expand all categories'}
onClick={() => setOpenGroups(allOpen ? new Set() : new Set(groups.map(group => group.id)))}>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d={allOpen ? 'M4 3l4 4 4-4 M4 13l4-4 4 4' : 'M4 7l4-4 4 4 M4 9l4 4 4-4'}/></svg>
</button></span><span>Base severity</span><span>Deviations</span><span>Count</span><span>Share</span></div>
{groups.map(group => <FindingGroup key={group.id} group={group} navigation={navigation} {...props} open={openGroups.has(group.id)}
onToggle={() => setOpenGroups(previous => { const next = new Set(previous); if (next.has(group.id)) next.delete(group.id); else next.add(group.id); return next; })}/>) }
</div>
</div>;
}

View File

@ -0,0 +1,55 @@
import { useMemo, useState } from 'react';
import { useApp } from '../../app/AppProvider';
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
import { useWorkPackages } from '../../remediation/use-work-packages';
import { WorkPackagesTable } from '../../remediation/WorkPackagesWindow';
import { workRecordKey } from '../../remediation/work-package-index';
import type { FindingRecord } from '../../domain/finding';
import { useContextClusterIndex } from '../context-layer/use-context-cluster-index';
import { ClientSecurityFindingRow } from './ClientSecurityPanel';
import { numberedClusterLabel, publishedClusterColour } from '../sidebar/context-cluster-display';
import { WorkTimeslotControl } from '../search-filter/WorkTimeslotControl';
/** Core Presentation extension point consuming the existing published Remediation scope. */
export function ClientWorkPackagesPanel() {
const app = useApp();
const state = useWorkPackages();
const context = useContextClusterIndex();
const [openPackages, setOpenPackages] = useState<Set<string>>(() => new Set());
const [unassignedOpen, setUnassignedOpen] = useState(false);
const [relatedOpen, setRelatedOpen] = useState(false);
const sheet = app.dataset?.sheets.find(candidate => candidate.isProjectSheet) ?? app.activeSheet;
const recordsByKey = useMemo(() => new Map((sheet?.analysis.records ?? []).map(record => [workRecordKey(record), record])), [sheet]);
const recordsFor = (keys: readonly string[]) => [...new Set(keys)].flatMap(key => recordsByKey.has(key) ? [recordsByKey.get(key)!] : []);
const togglePackage = (id: string) => setOpenPackages(previous => {
const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next;
});
const findings = (records: FindingRecord[], expected: number, title: string) => <div className="client-work-package-findings">
<p>{records.length.toLocaleString('en-US')} findings</p>
{app.dataset && sheet && <ul aria-label={`${title} findings`}>{records.map(record => <ClientSecurityFindingRow key={record.id}
record={record} dataset={app.dataset!} sheetId={sheet.id} toolId={sheet.toolId}/>)}</ul>}
{records.length < expected && <p role="status">{expected - records.length} further findings are not available in the loaded Work records.</p>}
</div>;
const unassigned = state.status === 'ready' ? [...recordsByKey.values()].filter(record => state.index.unassigned(record)) : [];
const relatedKeys = state.status === 'ready' ? state.index.packages.flatMap(p => p.tasks.filter(task => task.redundant).flatMap(task => task.findingKeys)) : [];
const relatedCount = state.status === 'ready' ? state.overview.rows.reduce((total, row) => total + row.redundantFindings, 0) : 0;
return <div className="dashboard-page client-work-packages-panel" data-table-row-marking="off">
<header className="client-overview-header">
<div className="client-overview-identity"><span>Client:</span><strong>{app.dataset?.fileName}</strong></div>
<div className="client-overview-assessment"><span>Assessment:</span><WorkTimeslotControl/></div>
</header>
{state.status === 'loading' ? <PanelLoadingState message="Loading work packages…"/>
: state.status === 'ready' ? <WorkPackagesTable overview={state.overview}
clusterOrder={context?.publication.clusters.map(cluster => cluster.id)}
clusterLabels={Object.fromEntries(state.index.packages.map(p => [p.clusterId, numberedClusterLabel(p.clusterId, p.clusterLabel, context?.publication.clusters)]))}
clusterSeverities={Object.fromEntries(state.index.packages.map(p => [p.clusterId, publishedClusterColour(context?.publication.clusters.find(cluster => cluster.id === p.clusterId))]))}
onSelect={p => togglePackage(p.id)} onSelectUnassigned={() => setUnassignedOpen(open => !open)}
onSelectRelated={() => setRelatedOpen(open => !open)} inlineDetails={{ openPackages, unassignedOpen, relatedOpen,
renderPackage: p => findings(recordsFor(p.findingKeys), p.facts.findings, `${p.clusterLabel} ${p.code}`),
renderUnassigned: () => findings(unassigned, state.overview.unassigned, 'Unassigned'),
renderRelated: () => findings(recordsFor(relatedKeys), relatedCount, 'Same-change') }}/>
: <p role={state.status === 'error' ? 'alert' : 'status'}>{state.status === 'error' ? state.message
: state.status === 'context-required' ? 'Enable Context Engine once to prepare work packages for this assessment.'
: 'Work packages are unavailable for the current scope.'}</p>}
</div>;
}

View File

@ -1,5 +1,8 @@
import { readEnginePreferences, useEnginePreferences } from '../../app/engine-preferences';
import { ClientInfoTabs } from './ClientInfoTabs';
import { ClientWorkPackagesPanel } from './ClientWorkPackagesPanel';
import { captureSecurityView } from './capture-security-view';
import { profileForSheet } from '../project-import/workspace-profile';
import { ClientSecurityPanel, type SecurityFindingGroup } from './ClientSecurityPanel';
import { managementCategoryTexts } from '../../reporting/management-report-texts';
import { ClientContextHeatmap } from '../context-layer/ContextHeatmap';
@ -20,9 +23,9 @@ import { EnvironmentEvidenceTables } from './EnvironmentEvidenceTables';
import { environmentEvidenceForGroup, type EnvironmentEvidenceGroup } from './environment-result-evidence';
import type { GpoBackupStatusRecord } from '../../api/assessor-api-contract';
import {
clientInfoOverview, dashboardDomainInformation, dashboardEntraTenantCount, dashboardEnvironmentDetail, dashboardEnvironmentDomainCount,
clientInfoOverview, cloudInformationTileFacts, dashboardDomainInformation, dashboardEntraTenantCount, dashboardEnvironmentDetail, dashboardEnvironmentDomainCount,
dashboardEnvironmentTableNumber, dashboardGpoCount, dashboardObservationNumber, displayValue, numberFact, observationFact, observationFor,
sectionFor, UNKNOWN_FACT, type DashboardFact
sectionFor, operatingSystemOverview, UNKNOWN_FACT, type DashboardFact
} from './client-info-facts';
// Former home of the Client Info fact builders; existing imports keep working.
export { dashboardDomainInformation, dashboardEntraTenantCount, dashboardEnvironmentDetail, type DashboardEnvironmentDetail, type DashboardFact } from './client-info-facts';
@ -41,11 +44,12 @@ import type { Review } from '../../domain/review';
import { weightedSecurityScore, type SecurityScoreContext, type SecuritySourceScore } from '../../domain/security-scoring';
import { dashboardSecuritySources } from './dashboard-security-score';
import { SECURITY_DOMAIN_FACET_ID, dashboardDomainAssignment, securityDomainCatalog } from './security-domains';
import { ScoringMethodology } from './ScoringMethodology';
import { RiskRegister } from './RiskRegister';
import { ScoringMethodology, ScoringMethodologyContent } from './ScoringMethodology';
import { RiskRegister, riskRegisterEntries } from './RiskRegister';
import { deriveDynamicFacets, matchesDynamicFacets, recountFacetsAgainstActiveFilters, sourceScanNavigationSheet, type DynamicFacet, type DynamicFacetFilter, type DynamicFacetValue } from '../search-filter/dynamic-facets';
import { WorkTimeslotControl } from '../search-filter/WorkTimeslotControl';
import { dashboardResultState, dashboardScoreFromStates, type DashboardResultState } from './dashboard-result-state';
import { dashboardResultState, dashboardScoreFromStates, dashboardGpoGapCounts, type DashboardResultState } from './dashboard-result-state';
import { AssessmentFactsExplanation } from './AssessmentFactsExplanation';
export { dashboardResultState } from './dashboard-result-state';
@ -553,8 +557,8 @@ function DashboardMetricTile({ label, value, tone = 'neutral', emphasis = false,
}
/** Source-backed summary facts appear in the tiles and remain available in Results. */
function DashboardFactList({ items }: { items: DashboardFact[] }) {
return <dl className="client-env-facts">
function DashboardFactList({ items, compact = false }: { items: DashboardFact[]; compact?: boolean }) {
return <dl className={compact ? 'client-security-facts' : 'client-env-facts'}>
{items.map(item => <div className={/^\S{17,}$/.test(item.value) ? 'is-wide' : undefined} key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}
</dl>;
}
@ -909,7 +913,7 @@ export function DashboardTopFact({ value, label, glyph, snapshot, evidenceGroup,
trigger={<><DashboardGlyph name={glyph}/><span><strong>{value ?? '—'}</strong><small>{label}</small></span></>}>
<DashboardFactList items={[{ label, value: value === undefined ? UNKNOWN_FACT : String(value) }]}/>
{fallback && <p className="client-env-evidence-empty">{fallback}</p>}
{!!evidence.observations.length && <table className="client-data-fact-evidence"><thead><tr><th>Field</th><th>Original value</th><th>Source</th><th>Scan run</th></tr></thead>
{!['computer-objects', 'user-accounts', 'gpos'].includes(evidenceGroup) && !!evidence.observations.length && <table className="client-data-fact-evidence"><thead><tr><th>Field</th><th>Original value</th><th>Source</th><th>Scan run</th></tr></thead>
<tbody>{evidence.observations.map(item => <tr key={item.id}><td>{item.label}</td><td>{typeof item.originalValue === 'string' ? item.originalValue : JSON.stringify(item.originalValue)}</td><td>{item.tool.name}</td><td>{item.scanRunId}</td></tr>)}</tbody>
</table>}
{!!backupRecords?.length && <><p className="client-env-evidence-empty">All processed client backups · the overview count includes every backup listed below.</p>
@ -1164,11 +1168,15 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const gpoInventory = useGpoInventory(app.targetProjectId);
const sourceFilter = app.facetFilters['provenance:source-scan'];
const analysis = useDashboardAnalysis(activeSheet, dataset, activeProfile, reviews, sourceFilter);
// Client Info Security reads the complete selected assessment, never Work/Graph filters or a source sub-sheet.
const securitySheet = dataset?.sheets.find(sheet => sheet.isProjectSheet) ?? activeSheet;
const securityProfile = securitySheet === activeSheet ? activeProfile : profileForSheet(dataset, securitySheet);
const securityAnalysis = useDashboardAnalysis(securitySheet, dataset, securityProfile, reviews);
const engines = useEnginePreferences();
const impactContext = useAssessmentContext(Boolean(dataset), app.targetProjectId, app.activeAssessmentIds, app.activeScanRunIds, dataset);
const securityView = useMemo(() => {
const records = analysis.records.filter(record => dashboardResultState(record, activeProfile) === 'deviation');
const buckets = dashboardDomainAssignment(records, activeSheet?.toolId);
const records = securityAnalysis.records.filter(record => dashboardResultState(record, securityProfile) === 'deviation');
const buckets = dashboardDomainAssignment(records, securitySheet?.toolId);
const domainCounts = securityDomainCatalog.map(domain => ({ id: domain.id, label: domain.label, count: buckets.get(domain.id)?.length ?? 0 }));
const domainIds = new Map([...buckets].flatMap(([id, members]) => members.map(record => [record.id, id] as const)));
const countsFor = (members: FindingRecord[]) => domainCounts.map(domain => ({ ...domain, count: members.filter(record => domainIds.get(record.id) === domain.id).length })).filter(domain => domain.count > 0);
@ -1188,13 +1196,13 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
situation: impactContext.status === 'ready' ? 'These findings have no matching published category assignment in the current scope.' : undefined,
records: remaining, domains: countsFor(remaining) });
return { groups, domains: domainCounts, total: records.length };
}, [analysis, activeProfile, activeSheet?.toolId, impactContext.status === 'ready' ? impactContext.context : impactContext.status, engines.report]);
}, [securityAnalysis, securityProfile, securitySheet?.toolId, impactContext.status === 'ready' ? impactContext.context : impactContext.status, engines.report]);
const impactDistribution = useMemo(() => {
if (!engines.report || impactContext.status !== 'ready') return undefined;
const keys = managementWorkspaceDeviationKeys(analysis.records, record => dashboardResultState(record, activeProfile) === 'deviation');
return managementFindingDistribution(publishContextReport(impactContext.context), analysis.totalResult.deviations, keys);
}, [impactContext.status === 'ready' ? impactContext.context : undefined, analysis, activeProfile, engines.report]);
const [reportExporting, setReportExporting] = useState<'management' | 'technical' | null>(null);
const [reportExporting, setReportExporting] = useState<'management' | 'technical' | 'security' | null>(null);
const [reportError, setReportError] = useState<string | null>(null);
const reportScopeKey = JSON.stringify([app.targetProjectId, app.activeScanRunIds, app.activeAssessmentIds]);
const reportScopeRef = useRef(reportScopeKey);
@ -1224,7 +1232,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
};
const environmentDetail = useMemo(() => dashboardEnvironmentDetail(environment.snapshot), [environment.snapshot]);
const domainInformation = useMemo(() => dashboardDomainInformation(environment.snapshot), [environment.snapshot]);
const [scoringOpen, setScoringOpen] = useState(false);
const [scoringOpen, setScoringOpen] = useState<'overview' | 'security' | null>(null);
// Ersatzzaehlung aus Findingtexten, solange die Verarbeitungsliste der
// Sicherungen noch nicht geantwortet hat. Sie liest jeden Feldwert der
// GPO-Datensaetze und darf deshalb nicht bei jedem Rendern erneut laufen.
@ -1232,6 +1240,10 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
() => dashboardGpoCount(analysis.sourceScores.find(source => /gpo/i.test(source.tool.label))?.records ?? []),
[analysis]
);
const securityDerivedGpoCount = useMemo(
() => dashboardGpoCount(securityAnalysis.sourceScores.find(source => /gpo/i.test(source.tool.label))?.records ?? []),
[securityAnalysis]
);
if (!dataset || !activeSheet) return app.targetProjectId
? <div className="dashboard-page panel-is-loading"><h2>Client Info</h2><PanelLoadingState message={app.status}/></div>
@ -1280,6 +1292,12 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const displayedDomains = domains;
const domainDistributionTotal = displayedDomains.reduce((sum, domain) => sum + dashboardDomainAmount(domain), 0);
const overallTone = dashboardScoreTone(overallScore);
const securityResult = securityAnalysis.totalResult;
const securityPercentages = assessedResultPercentages(securityResult.passed, securityResult.deviations);
const securityMaesterSource = securityAnalysis.sourceScores.find(source => /maester/i.test(source.tool.label));
const securityGpoCount = gpoInventory.gpoCount ?? securityDerivedGpoCount;
const securityOverview = clientInfoOverview(environment.snapshot, { gpoCount: securityGpoCount, inferredEntraTenant: Boolean(securityMaesterSource?.records.length) });
const securityEntraTenants = securityOverview.data.find(item => item.label === 'Entra Tenant')?.value;
const jump = (apply: () => void, preserveSource = true) => {
resetFilters();
@ -1303,8 +1321,106 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
&& tool.sourceValues.every(value => sourceFilter.values!.includes(value)))?.id ?? 'selection'
: 'all';
const securityPanelView = <ClientSecurityPanel key={reportScopeKey}
dataset={dataset} sheetId={securitySheet!.id} toolId={securitySheet?.toolId} contextStatus={impactContext.status} {...securityView}
actions={<><button className="btn client-information-action" type="button" disabled={!engines.report || Boolean(reportExporting) || !app.targetProjectId || !hasSelectedTimeslotScope(app.activeScanRunIds, app.activeAssessmentIds) || !environment.snapshot || impactContext.status === 'loading'} onClick={() => void exportSecurityReport()}>
{reportExporting === 'security' ? 'Creating Security Export…' : 'HTML Security Export'}</button>
{reportError && <span role="alert">{reportError}</span>}</>}
metrics={<div className="client-security-metrics">
<section className="client-security-score"><h3>Overall Score</h3><strong>{securityAnalysis.overallScore === undefined ? '—' : securityAnalysis.overallScore > 0 && securityAnalysis.overallScore < .5 ? '<1' : Math.round(securityAnalysis.overallScore)}<small>/100</small></strong>
<div className={`client-security-score-bar tone-${dashboardScoreTone(securityAnalysis.overallScore)}`}><i style={shareStyle(securityAnalysis.overallScore ?? 0)}/></div>
<span>ASSESSOR</span><button type="button" className="btn" onClick={() => setScoringOpen('security')}>Scoring methodology ↗</button>
</section>
<section className="client-security-remediation"><h3>Remediation</h3><strong>{securityResult.deviations.toLocaleString('en-US')}</strong>
<span>Deviations · {securityPercentages.deviation === null ? '—' : `${Math.round(securityPercentages.deviation)}%`} of assessed checks</span>
<div className="client-security-share-bar"><i style={shareStyle(securityPercentages.deviation ?? 0)}/></div>
</section>
<EnvironmentResultsButton title="Assessment facts" trigger="Assessment facts"><table className="client-security-totals" aria-label="Assessment facts"><thead><tr><th scope="col">Metric</th><th scope="col">Value</th></tr></thead><tbody>{[
['Assessed', securityResult.assessed], ['Passed', securityResult.passed], ['Gaps', securityResult.unassessed],
['Assessment Coverage', securityResult.coverage === undefined ? undefined : `${securityResult.coverage}%`],
['Conflicts', securityResult.conflicts], ['Duplicates', securityResult.duplicates],
['Categories with deviations', engines.context && impactContext.status === 'ready' ? securityView.groups.filter(group => group.records.length).length : undefined],
['Base severity Critical categories', engines.context && impactContext.status === 'ready' ? securityView.groups.filter(group => group.records.length && group.severity === 'critical').length : undefined],
['Accepted risks (valid)', riskRegisterEntries(dataset, reviews).filter(entry => entry.state === 'Accepted').length]
].map(([label, value]) => <tr key={label}><th scope="row">{label}</th><td>{typeof value === 'number' ? value.toLocaleString('en-US') : value ?? '—'}</td></tr>)}</tbody></table>
<AssessmentFactsExplanation counts={securityResult} gpo={dashboardGpoGapCounts(securityAnalysis.records, securityProfile, securitySheet?.toolId ?? dataset.profileId)}/>
</EnvironmentResultsButton>
</div>}
overview={<section className="client-security-hardfacts" aria-label="Security overview and environment facts">
<div className="client-security-hardfacts-content">
<section className="client-data-facts client-security-inventory" aria-label="Environment facts">
<DashboardTopFact value={dashboardEnvironmentDomainCount(environment.snapshot)} label="AD Domains" glyph="domain" snapshot={environment.snapshot} evidenceGroup="ad-domains" />
<DashboardTopFact value={securityEntraTenants} label="Entra Tenant" glyph="cloud" snapshot={environment.snapshot} evidenceGroup="entra-tenants" fallback={!dashboardEntraTenantCount(environment.snapshot) && !dashboardObservationNumber(environment.snapshot, 'entra.configuration_detected') && securityMaesterSource?.records.length ? "Tenant presence inferred from loaded Maester results; no retained tenant identity is available." : undefined} />
<DashboardTopFact value={userAccounts} label="User Accounts" glyph="user" snapshot={environment.snapshot} evidenceGroup="user-accounts" />
<DashboardTopFact value={computerAccounts} label="Computer Objects" glyph="computer" snapshot={environment.snapshot} evidenceGroup="computer-objects" />
<DashboardTopFact value={domainControllers} label="Domain Controllers" glyph="server" snapshot={environment.snapshot} evidenceGroup="domain-controllers" />
<DashboardTopFact value={securityGpoCount} label="GPOs" glyph="policy" snapshot={environment.snapshot} evidenceGroup="gpos" backupRecords={gpoInventory.records} fallback={securityGpoCount !== undefined && !gpoInventory.records?.length ? "Count derived from GPO identifiers in the loaded findings; no processed backup inventory is available." : undefined} />
</section>
<div className="client-security-fact-groups">{([
[
['Domain Information', domainOverview, 'directory', domainOverview],
['Resilience', environmentDetail.resilience, 'resilience', environmentDetail.resilience.slice(0, 4)],
['Inventory', inventoryOverview, 'inventory', inventoryOverview.slice(0, 4)]
], [
['Password policy', environmentDetail.passwordPolicy, 'password-policy', environmentDetail.passwordPolicy.slice(0, 4)],
['Operating systems', environmentDetail.operatingSystems.flatMap(group => [
{ label: group.label, value: numberFact(group.total) }, ...group.entries.map(entry => ({ label: entry.name, value: numberFact(entry.count) }))
]), 'operating-systems', operatingSystemOverview(environmentDetail.operatingSystems)],
['Cloud Information', cloudInformationTileFacts(environment.snapshot).map(fact => ({ label: fact.label, value: fact.values.join(', ') })), 'all']
]
] as Array<Array<[string, DashboardFact[], EnvironmentEvidenceGroup, DashboardFact[]?]>>).map((column, index) => <div className="client-security-fact-column" key={index}>{column.map(([title, facts, evidenceGroup, summary = facts]) => <section key={title}>
<header className="client-env-group-header"><h3>{title}</h3><EnvironmentResultsButton title={title}>
<DashboardFactList items={facts}/><EnvironmentEvidenceTables snapshot={environment.snapshot} group={evidenceGroup}/>
</EnvironmentResultsButton></header><DashboardFactList compact items={summary}/>
</section>)}{index === 1 && <>
{environment.failed && <p role="alert" className="muted">Environment facts could not be loaded.</p>}
</>}</div>)}
</div>
</div>
</section>}/>;
async function exportSecurityReport() {
if (!readEnginePreferences().report || reportExporting) return;
const scope = reportScopeKey;
setReportExporting('security'); setReportError(null);
try {
const width = document.querySelector('.client-security-panel')?.getBoundingClientRect().width ?? 0;
const captured = captureSecurityView(securityPanelView, width,
<ScoringMethodologyContent sources={securityAnalysis.sourceScores} context={dataset!.securityScoring}/>);
const [{ buildSecurityViewReportHtml }, { REPORT_ENGINE_VERSION }] = await Promise.all([
import('../../reporting/security-view-report'), import('../../reporting/report-run')
]);
const health = await assessorApi.health();
const [project, history] = await Promise.all([
assessorApi.getProject(app.targetProjectId!), assessorApi.listProjectAssessmentHistory(app.targetProjectId!)
]);
const context = impactContext.status === 'ready' ? publishContextReport(impactContext.context) : undefined;
if (context && context.repositoryRevision !== health.repositoryRevision) throw new Error('The assessment data changed. Reload the client before exporting Security.');
const generatedAt = new Date().toLocaleString('de-DE', { timeZone: 'Europe/Berlin' });
const snapshot = Object.freeze({ ...captured, width,
theme: document.documentElement.dataset.theme === 'dark' ? 'dark' as const : 'light' as const,
density: document.documentElement.dataset.density,
client: project.name,
assessments: history.assessments.filter(assessment => app.activeAssessmentIds?.includes(assessment.id))
.map(assessment => assessment.name + (assessment.effectiveAt ? ' · ' + assessment.effectiveAt : '')).join(' · ') || app.activeAssessmentIds?.join(' · ') || '—',
generatedAt, versions: [
['Erstellt', generatedAt], ['Datenstand', 'Revision ' + health.repositoryRevision],
['Report Engine', REPORT_ENGINE_VERSION], ['Context Engine', context?.engineVersion ?? '—'],
['Context-Katalog / Vokabular / Severity', context ? context.catalogVersion + ' / ' + context.vocabularyVersion + ' / ' + context.severityCatalogVersion : '—']
] as Array<[string, string]> });
const html = buildSecurityViewReportHtml({ read: () => snapshot });
const finalHealth = await assessorApi.health();
if (finalHealth.repositoryRevision !== health.repositoryRevision) throw new Error('The assessment data changed during export. Please retry.');
if (!readEnginePreferences().report || scope !== reportScopeRef.current) return;
const safeName = snapshot.client.replace(/[^\p{L}\p{N}._-]+/gu, '-').slice(0, 100);
downloadText(browserDownloadAdapter, 'ASSESSOR-Security-' + safeName + '-' + new Date().toISOString().slice(0, 10) + '.html', html, 'text/html;charset=utf-8');
} catch (reason) {
if (readEnginePreferences().report && scope === reportScopeRef.current) setReportError(reason instanceof Error ? reason.message : 'Security export could not be created.');
} finally { setReportExporting(null); }
}
return (
<ClientInfoTabs tabs={[{ id: 'overview', label: 'Overview', content: <div className="dashboard-page client-command-center">
<><ClientInfoTabs tabs={[{ id: 'overview', label: 'Overview', content: <div className="dashboard-page client-command-center">
<header className="client-overview-header">
<div className="client-overview-identity"><span>Client:</span><strong>{dataset.fileName}</strong></div>
<div className="client-overview-assessment"><span>Assessment:</span><WorkTimeslotControl /></div>
@ -1339,7 +1455,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
<EnvironmentResultsButton title="Overall Score">{dismissResults => <>
<div className="client-environment-sources">{sourceOverviewContent(dismissResults)}</div>
{environmentDetail.restricted.length > 0 && <p className="client-environment-restricted">Restricted by the source tool, not empty: {environmentDetail.restricted.join(', ')}</p>}
<button className="btn" type="button" onClick={() => { dismissResults(); setScoringOpen(true); }}>Scoring methodology ↗</button>
<button className="btn" type="button" onClick={() => { dismissResults(); setScoringOpen('overview'); }}>Scoring methodology ↗</button>
<DashboardSourceResults sources={orderedSourceScores} snapshot={environment.snapshot}/>
</>}</EnvironmentResultsButton>
</div>
@ -1415,8 +1531,6 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
setFacetFilter(CONTEXT_CLUSTER_FACET_ID, { values: [clusterId] });
}, false)} />
{scoringOpen && <ScoringMethodology sources={sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(false)} />}
<div className="client-environment-grid" aria-label="Environment overview">
<DashboardFactGroup title="Resilience" items={environmentDetail.resilience} glyph="shield" snapshot={environment.snapshot} evidenceGroup="resilience" />
<DashboardFactGroup title="Inventory" items={environmentDetail.inventory} overviewItems={inventoryOverview} glyph="computer" snapshot={environment.snapshot} evidenceGroup="inventory" />
@ -1430,7 +1544,8 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
</div>
<RiskRegister dataset={dataset} reviews={reviews} onOpenFinding={recordId => jump(() => setPathFindingIds([recordId]))} />
</div> }, { id: 'security', label: 'Security', content: <ClientSecurityPanel key={reportScopeKey}
dataset={dataset} sheetId={activeSheet.id} toolId={activeSheet.toolId} contextStatus={impactContext.status} {...securityView}/> }]} />
</div> }, { id: 'security', label: 'Security', content: securityPanelView }, ...(engines.remediation ? [{ id: 'work-packages', label: 'Work Packages', lazy: true,
content: <ClientWorkPackagesPanel key={reportScopeKey}/> }] : [])]} />
{scoringOpen && <ScoringMethodology sources={scoringOpen === 'security' ? securityAnalysis.sourceScores : sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(null)} />}</>
);
}

View File

@ -1,16 +1,24 @@
import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useSecurityExport } from './SecurityExportContext';
import { useFloatingWindowLayer } from '../../components/use-floating-window-layer';
import type { JsonValue } from '../../domain/canonical-model';
import type { EnvironmentSnapshot, ScannerReportSection, ScannerReportTable } from '../../domain/environment-context';
import { environmentEvidenceForGroup, environmentNativeHeading, environmentSectionTitle, environmentSectionProse, environmentSectionReference, environmentTableTitle, type EnvironmentEvidenceGroup } from './environment-result-evidence';
import { environmentEvidenceForGroup, environmentNativeHeading, environmentSectionTitle, environmentSectionProse, environmentSectionReference, environmentTableTitle, environmentTablePresentation, type EnvironmentEvidenceGroup } from './environment-result-evidence';
const PAGE_SIZE = 100;
const TABLE_PAGE_SIZES = [10, 25, 100] as const;
const TABLE_PAGE_SIZES = [10, 25, 100, 250, 500, 1000] as const;
const TABLE_RESIZE_DIRECTIONS = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'] as const;
type TableResizeDirection = typeof TABLE_RESIZE_DIRECTIONS[number];
const TABLE_VIEWPORT_MARGIN = 8;
const TABLE_MIN_WIDTH = 360;
const TABLE_MIN_HEIGHT = 260;
const backdropStyle = (layer: number): CSSProperties => ({ zIndex: layer });
function TableNotices({ notices }: { notices: string[] }) {
return notices.length ? <div className="client-env-table-notices" role="note"><strong>Source report limitation</strong>
{notices.map(notice => <p key={notice}>{notice}</p>)}</div> : null;
}
function valueText(value: JsonValue | undefined): string {
if (value === undefined) return '—';
@ -23,6 +31,7 @@ function SourceTableDialog({ table, title, context, onClose, onOpenLink }: {
table: ScannerReportTable; title: string; context: string;
onClose(): void; onOpenLink?: (targetId: string) => void;
}) {
const { layer, activate } = useFloatingWindowLayer();
const titleId = useId();
const dialogRef = useRef<HTMLElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
@ -32,12 +41,13 @@ function SourceTableDialog({ table, title, context, onClose, onOpenLink }: {
const [query, setQuery] = useState('');
const [pageSize, setPageSize] = useState<number>(25);
const [page, setPage] = useState(0);
const width = table.rows.reduce((maximum, row) => Math.max(maximum, row.length), table.headers.length);
const { dataRows, notices } = useMemo(() => environmentTablePresentation(table), [table]);
const width = dataRows.reduce((maximum, { row }) => Math.max(maximum, row.length), table.headers.length);
const filteredRows = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
const indexed = table.rows.map((row, rowIndex) => ({ row, rowIndex }));
const indexed = dataRows;
return needle ? indexed.filter(({ row }) => row.some(value => valueText(value).toLocaleLowerCase().includes(needle))) : indexed;
}, [query, table.rows]);
}, [query, dataRows]);
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pageSize));
const start = page * pageSize;
const shownRows = filteredRows.slice(start, start + pageSize);
@ -109,7 +119,8 @@ function SourceTableDialog({ table, title, context, onClose, onOpenLink }: {
event.stopPropagation();
};
return createPortal(<div className="client-env-table-backdrop" onKeyDown={onKeyDown}
return createPortal(<div className="client-env-table-backdrop" style={backdropStyle(layer)}
onPointerDownCapture={activate} onFocusCapture={activate} onKeyDown={onKeyDown}
onMouseDown={event => { if (event.target === event.currentTarget) onClose(); }}>
<section ref={dialogRef} className={`client-env-table-dialog client-env-table-dialog--resizable client-env-evidence${geometry ? ' is-resized' : ''}`}
style={geometry ?? undefined} role="dialog" aria-modal="true" aria-labelledby={titleId}>
@ -119,10 +130,11 @@ function SourceTableDialog({ table, title, context, onClose, onOpenLink }: {
</header>
<div className="client-env-table-dialog-body">
<div className="client-env-table-toolbar">
<span>{table.rows.length.toLocaleString('en-US')} rows</span>
<span>{dataRows.length.toLocaleString('en-US')} rows</span>
<input ref={searchRef} type="search" aria-label={`Search ${title} table`} placeholder="Search table"
value={query} onChange={event => { setQuery(event.target.value); setPage(0); }}/>
</div>
<TableNotices notices={notices}/>
{width ? <div className="client-env-evidence-scroll client-env-table-scroll"><table>
<thead><tr>{Array.from({ length: width }, (_, index) =>
<th scope="col" key={index}>{table.headers[index] || `Column ${index + 1}`}</th>)}</tr></thead>
@ -156,11 +168,13 @@ function SourceTableDialog({ table, title, context, onClose, onOpenLink }: {
function SourceInfoDialog({ title, context, prose, interactiveGraph, children, onClose }: {
title: string; context: string; prose?: string; interactiveGraph?: boolean; children?: ReactNode; onClose(): void;
}) {
const { layer, activate } = useFloatingWindowLayer();
const titleId = useId();
const closeRef = useRef<HTMLButtonElement>(null);
const dialogRef = useRef<HTMLElement>(null);
useEffect(() => { closeRef.current?.focus(); }, []);
return createPortal(<div className="client-env-table-backdrop"
return createPortal(<div className="client-env-table-backdrop" style={backdropStyle(layer)}
onPointerDownCapture={activate} onFocusCapture={activate}
onKeyDown={event => {
if (event.key === 'Escape') { event.stopPropagation(); onClose(); }
if (event.key === 'Tab' && dialogRef.current) {
@ -184,37 +198,44 @@ function SourceInfoDialog({ title, context, prose, interactiveGraph, children, o
</div>, document.body);
}
function EvidenceTable({ table, title, onOpenLink }: {
function EvidenceTable({ table, title, onOpenLink, exportTargets }: {
table: ScannerReportTable; title: string;
onOpenLink(targetId: string): void;
exportTargets?: ReadonlyMap<string, string>;
}) {
const exported = useSecurityExport();
const [query, setQuery] = useState('');
const [pageSize, setPageSize] = useState<number>(25);
const [page, setPage] = useState(0);
const width = table.rows.reduce((maximum, row) => Math.max(maximum, row.length), table.headers.length);
const { dataRows, notices } = useMemo(() => environmentTablePresentation(table), [table]);
const width = dataRows.reduce((maximum, { row }) => Math.max(maximum, row.length), table.headers.length);
const needle = query.trim().toLocaleLowerCase();
const filteredRows = needle ? table.rows.map((row, rowIndex) => ({ row, rowIndex }))
const filteredRows = needle ? dataRows
.filter(({ row }) => row.some(value => valueText(value).toLocaleLowerCase().includes(needle)))
: table.rows.map((row, rowIndex) => ({ row, rowIndex }));
: dataRows;
const start = page * pageSize;
const shownRows = filteredRows.slice(start, start + pageSize);
const shownRows = exported ? filteredRows : filteredRows.slice(start, start + pageSize);
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pageSize));
return <section className="client-env-evidence-table">
{title && <h5>{title}</h5>}
<div className="client-env-table-toolbar">
{!exported && <div className="client-env-table-toolbar">
<input type="search" aria-label={title ? `Search ${title} table` : 'Search table'} placeholder="Search table" value={query}
onChange={event => { setQuery(event.target.value); setPage(0); }}/></div>
onChange={event => { setQuery(event.target.value); setPage(0); }}/></div>}
<TableNotices notices={notices}/>
{width > 0 && <div className="client-env-evidence-scroll client-env-table-inline-scroll"><table><thead><tr>
{Array.from({ length: width }, (_, index) => <th scope="col" key={index}>{table.headers[index] || `Column ${index + 1}`}</th>)}
</tr></thead><tbody>{shownRows.map(({ row, rowIndex }) => <tr key={rowIndex}>
{Array.from({ length: width }, (_, columnIndex) => {
const targetId = table.cellLinks?.find(link => link.rowIndex === rowIndex && link.columnIndex === columnIndex)?.targetId;
return <td key={columnIndex}>{targetId
const exportTarget = targetId && exportTargets?.get(targetId);
return <td key={columnIndex}>{exportTarget
? <button type="button" className="client-env-table-cell-link" popoverTarget={exportTarget}>{valueText(row[columnIndex])}</button>
: targetId && !exported
? <button type="button" className="client-env-table-cell-link" aria-haspopup="dialog" onClick={() => onOpenLink(targetId)}>{valueText(row[columnIndex])}</button>
: valueText(row[columnIndex])}</td>;
})}</tr>)}</tbody></table></div>}
{width > 0 && !filteredRows.length && <p>No rows match this search.</p>}
{width > 0 && <div className="client-env-table-pagination"><span>{filteredRows.length
{width > 0 && !exported && <div className="client-env-table-pagination"><span>{filteredRows.length
? `Showing ${start + 1}–${Math.min(start + pageSize, filteredRows.length)} of ${filteredRows.length}` : 'Showing 0 of 0'} rows</span>
<label>Rows per page <select value={pageSize} onChange={event => { setPageSize(Number(event.target.value)); setPage(0); }}>
{TABLE_PAGE_SIZES.map(size => <option key={size} value={size}>{size}</option>)}
@ -224,10 +245,25 @@ function EvidenceTable({ table, title, onOpenLink }: {
</section>;
}
function EvidenceSection({ section, snapshot, linkedSections }: {
function ReadableSourceProse({ text }: { text: string }) {
const paragraphs = text.split(/\n\s*\n/u).flatMap(block => {
const result: string[] = [];
for (const sentence of block.split(/(?<=[.!?])\s+(?=[A-Z])/u)) {
const last = result.length - 1;
if (last < 0 || result[last].length + sentence.length > 500) result.push(sentence);
else result[last] += ` ${sentence}`;
}
return result;
});
return <div className="client-env-readable-prose">{paragraphs.map((paragraph, index) => <p key={index}>{paragraph}</p>)}</div>;
}
function EvidenceSection({ section, snapshot, linkedSections, readableProse = false }: {
section: ScannerReportSection; snapshot: EnvironmentSnapshot;
linkedSections?: Map<string, ScannerReportSection>;
readableProse?: boolean;
}) {
const exported = useSecurityExport();
const title = environmentSectionTitle(snapshot, section);
const prose = environmentSectionProse(section, title);
const titles = section.tables.map(table => environmentTableTitle(snapshot, section, table));
@ -249,9 +285,42 @@ function EvidenceSection({ section, snapshot, linkedSections }: {
return <EvidenceTable table={table} title={titles[index]}
onOpenLink={openModal} key={`${table.sourceTableIndex ?? index}:${table.name}`}/>;
};
if (exported) {
const targetIds = new Set([
...section.tables.flatMap(table => table.targetId ? [table.targetId] : []),
...(section.detailLinks?.map(link => link.targetId) ?? []), ...(section.modalDetails?.map(detail => detail.targetId) ?? []),
...section.tables.flatMap(table => table.cellLinks?.map(link => link.targetId) ?? [])
]);
const targets = new Map([...targetIds].filter(id => section.tables.some(table => table.targetId === id)
|| section.modalDetails?.some(detail => detail.targetId === id) || section.detailLinks?.some(link => link.targetId === id) || linkedSection(id))
.map(id => [id, exported.resourceId(environmentSectionReference(section, id))]));
for (const [targetId, popupId] of targets) {
const tables = section.tables.flatMap((table, index) => table.targetId === targetId ? [index] : []);
const detail = section.modalDetails?.find(detail => detail.targetId === targetId);
const link = section.detailLinks?.find(link => link.targetId === targetId);
const child = linkedSection(targetId);
const targetTitle = environmentNativeHeading(detail?.title || link?.label || '') || titles[tables[0]] || (child && environmentSectionTitle(snapshot, child)) || title;
exported.results.set(popupId, { title: targetTitle, content: <>
{(detail?.prose || link?.prose) && <ReadableSourceProse text={detail?.prose || link!.prose!}/>}
{detail?.interactiveGraph && <p>The interactive graph is available in the original PingCastle report.</p>}
{tables.map(index => <EvidenceTable key={index} table={section.tables[index]} title={titles[index]} onOpenLink={() => {}} exportTargets={targets}/>)}
{child && <EvidenceSection section={child} snapshot={snapshot} linkedSections={linkedSections} readableProse={readableProse}/>}
</> });
}
const hiddenTargets = new Set(section.tables.filter(table => table.presentation === 'modal' || table.presentation === 'linked-inline').flatMap(table => table.targetId ? [table.targetId] : []));
return <section className="client-env-evidence-section">
<h4>{title}</h4>{prose && <ReadableSourceProse text={prose}/>}
{section.tables.map((table, index) => table.targetId && hiddenTargets.has(table.targetId) ? null
: <EvidenceTable key={index} table={table} title={titles[index]} onOpenLink={() => {}} exportTargets={targets}/>)}
{[...targets].filter(([targetId]) => hiddenTargets.has(targetId) || section.detailLinks?.some(link => link.targetId === targetId) || section.modalDetails?.some(detail => detail.targetId === targetId))
.map(([targetId, popupId]) => <p key={popupId}><button type="button" className="client-env-table-detail-link" popoverTarget={popupId}>
{section.detailLinks?.find(link => link.targetId === targetId)?.label || section.modalDetails?.find(detail => detail.targetId === targetId)?.title || titles[section.tables.findIndex(table => table.targetId === targetId)] || title}
</button></p>)}
</section>;
}
return <section className="client-env-evidence-section">
<h4>{title}</h4>
{prose && !hasUnstructuredTables && <p className="client-env-evidence-prose">{prose}</p>}
{prose && !hasUnstructuredTables && (readableProse ? <ReadableSourceProse text={prose}/> : <p className="client-env-evidence-prose">{prose}</p>)}
{section.tables.map((table, index) => table.presentation === 'modal'
|| table.presentation === 'linked-inline' && section.detailLinks?.some(link => link.targetId === table.targetId)
? null : renderTable(table, index))}
@ -266,7 +335,7 @@ function EvidenceSection({ section, snapshot, linkedSections }: {
{linkedIndexes.map(index => renderTable(section.tables[index], index))}
{linkedSection(link.targetId)
&& <EvidenceSection section={linkedSection(link.targetId)!} snapshot={snapshot}
linkedSections={linkedSections}/>}
linkedSections={linkedSections} readableProse={readableProse}/>}
</div>}
</div>;
})}
@ -280,12 +349,13 @@ function EvidenceSection({ section, snapshot, linkedSections }: {
{modalIndex < 0 && modalDetail && <SourceInfoDialog title={environmentNativeHeading(modalDetail.title) || title} context={title}
prose={modalDetail.prose} interactiveGraph={modalDetail.interactiveGraph} onClose={closeModal}/>}
{modalIndexes.length === 0 && !modalDetail && linkedChild && <SourceInfoDialog title={linkedChildTitle} context={title} onClose={closeModal}>
<EvidenceSection section={linkedChild} snapshot={snapshot} linkedSections={linkedSections}/>
<EvidenceSection section={linkedChild} snapshot={snapshot} linkedSections={linkedSections} readableProse={readableProse}/>
</SourceInfoDialog>}
</section>;
}
export function EnvironmentEvidenceTables({ snapshot, group }: { snapshot?: EnvironmentSnapshot; group: EnvironmentEvidenceGroup }) {
const exported = useSecurityExport();
const [visibleSections, setVisibleSections] = useState(PAGE_SIZE);
if (!snapshot) return <p className="client-env-evidence-empty">No retained Client Information available for this selection.</p>;
const { sections } = environmentEvidenceForGroup(snapshot, group);
@ -304,10 +374,12 @@ export function EnvironmentEvidenceTables({ snapshot, group }: { snapshot?: Envi
return !linkedKeys.has(key) || !linkedSections.has(key);
});
if (!sections.length) return <p className="client-env-evidence-empty">No original report sections for this category.</p>;
if (exported) return <div className="client-env-evidence">{(topSections.length ? topSections : sections)
.map(section => <EvidenceSection section={section} snapshot={snapshot} linkedSections={linkedSections} readableProse={group === 'gpos'} key={section.id}/>)}</div>;
return <div className="client-env-evidence">
{sections.length > 0 && <div className="client-env-evidence-block">
{topSections.slice(0, visibleSections).map(section => <EvidenceSection section={section} snapshot={snapshot}
linkedSections={linkedSections} key={section.id}/>)}
linkedSections={linkedSections} readableProse={group === 'gpos'} key={section.id}/>)}
{visibleSections < topSections.length && <button type="button" className="btn" onClick={() => setVisibleSections(value => value + PAGE_SIZE)}>
Show more sections ({visibleSections.toLocaleString('en-US')} of {topSections.length.toLocaleString('en-US')})
</button>}</div>}

View File

@ -1,4 +1,5 @@
import { useRef, useState, type ReactNode } from 'react';
import { useId, useRef, useState, type ReactNode } from 'react';
import { useSecurityExport } from './SecurityExportContext';
import { FloatingDataWindow } from '../../components/FloatingDataWindow/FloatingDataWindow';
/** One interaction for every Environment Detail tile, including OS inventory. */
@ -7,11 +8,17 @@ export function EnvironmentResultsButton({ title, children, trigger, triggerClas
trigger?: ReactNode; triggerClassName?: string;
}) {
const [windowOpen, setWindowOpen] = useState(false);
const exported = useSecurityExport(); const exportId = `security-results-${useId()}`;
const triggerRef = useRef<HTMLButtonElement>(null);
const closeWindow = () => {
setWindowOpen(false);
requestAnimationFrame(() => triggerRef.current?.focus());
};
if (exported) {
exported.results.set(exportId, { title, content: typeof children === 'function' ? children(() => {}) : children });
return <button className={triggerClassName ?? 'btn results-btn client-environment-results-button'} type="button" popoverTarget={exportId}
aria-label={`Open ${title} results`} title="Open results">{trigger ?? 'Results'}</button>;
}
return <>
<button ref={triggerRef} className={`${triggerClassName ?? 'btn results-btn client-environment-results-button'}${windowOpen ? ' is-open' : ''}`}
type="button" onClick={() => setWindowOpen(true)} aria-haspopup="dialog" aria-expanded={windowOpen}

View File

@ -11,11 +11,10 @@ interface Props {
const methodNames = { native: 'Native score', 'inverted-native': '100 − native risk', 'pass-rate': '100 × passed / assessed', unavailable: 'Evidence present, not scoreable' };
const number = (value?: number) => value === undefined ? '—' : value.toLocaleString('en-US', { maximumFractionDigits: 2 });
export function ScoringMethodology({ sources, context, onClose }: Props) {
export function ScoringMethodologyContent({ sources, context }: Omit<Props, 'onClose'>) {
const comparison = context?.baseline ? compareSecurityScores(context.baseline, context.scans) : undefined;
const pending = comparison?.awaitingRevalidation.reduce((count, item) => count + item.occurrenceIds.length, 0) ?? 0;
return <Dialog title="Scoring methodology" onClose={onClose}>
<div className="security-scoring-methodology">
return <div className="security-scoring-methodology">
<p>The overall score is a combined assessment index. Each scan uses its validated native score, or the pass rate when the assessed results support that calculation. Native scores and pass rates use different methods; the index is not a percentage of security.</p>
<p>Each source tool has a fixed weight. Its distinct, latest verified scan scopes contribute equally within that weight. Importing the same verified scan again adds no weight. Critical findings and assessment coverage remain separate.</p>
<p>Security Domain numbers are pass rates, not components of the overall index. Not relevant results are excluded; unassessed and execution-error results do not become passed checks.</p>
@ -40,6 +39,9 @@ export function ScoringMethodology({ sources, context, onClose }: Props) {
<p>{pending} previously open findings await revalidation. Historical risks retain their original scan reference and are not reported as resolved because a follow-up scan is missing.</p>
{comparison.awaitingRevalidation.filter(item => item.occurrenceIds.length > 0).map((item, index) => <p key={`${item.assessmentId}-${index}`}>{item.occurrenceIds.length} open findings · Last observed: {item.observedAt ?? '—'}</p>)}
</>}
</div>
</Dialog>;
</div>;
}
export function ScoringMethodology({ onClose, ...props }: Props) {
return <Dialog title="Scoring methodology" onClose={onClose}><ScoringMethodologyContent {...props}/></Dialog>;
}

View File

@ -0,0 +1,9 @@
import { createContext, useContext, type ReactNode } from 'react';
/** Presentation-only read snapshot: no repositories, filters or source mutation. */
export interface SecurityExportPresentation {
results: Map<string, { title: string; content: ReactNode }>;
resourceId(key: string): string;
}
export const SecurityExportContext = createContext<SecurityExportPresentation | null>(null);
export const useSecurityExport = () => useContext(SecurityExportContext);

View File

@ -0,0 +1,83 @@
import { type ReactNode } from 'react';
import { createRoot } from 'react-dom/client';
import { flushSync } from 'react-dom';
import { SecurityExportContext } from './SecurityExportContext';
/** Render the same authorized Core view into a disposable, complete read-only HTML snapshot. */
export function captureSecurityView(view: ReactNode, width: number, appendix: ReactNode) {
const host = document.createElement('div');
host.style.cssText = `position:fixed;left:-100000px;top:0;width:${width}px;visibility:hidden;pointer-events:none;`;
host.setAttribute('aria-hidden', 'true'); document.body.append(host);
const root = createRoot(host);
const resourceIds = new Map<string, string>();
const presentation = { results: new Map<string, { title: string; content: ReactNode }>(), resourceId(key: string) {
if (!resourceIds.has(key)) resourceIds.set(key, `security-native-${resourceIds.size}`);
return resourceIds.get(key)!;
} };
presentation.results.set('security-scoring-methodology', { title: 'Scoring methodology', content: appendix });
const render = (includeResults: boolean) => <SecurityExportContext.Provider value={presentation}>
{view}{includeResults && <section data-security-export-popups="true">
{[...presentation.results].map(([id, result]) => <section popover="auto" id={id} key={id} className="security-export-popup" role="dialog" aria-label={result.title}>
<header><h2>{result.title}</h2><button type="button" className="btn" popoverTarget={id} popoverTargetAction="hide" aria-label={`Close ${result.title}`}>×</button></header>
<div className="security-export-popup-body">{result.content}</div>
</section>)}
</section>}
</SecurityExportContext.Provider>;
try {
flushSync(() => root.render(render(false)));
let previousSize: number;
do {
previousSize = presentation.results.size;
flushSync(() => root.render(render(true)));
} while (presentation.results.size !== previousSize);
const clone = host.cloneNode(true) as HTMLElement;
// Preserve the source view's viewport-dependent facts inset and row rhythm when the file is opened elsewhere.
const facts = host.querySelector('.client-security-hardfacts');
const clonedFacts = clone.querySelector<HTMLElement>('.client-security-hardfacts');
if (facts && clonedFacts) {
const style = getComputedStyle(facts);
if (style.paddingInlineStart) clonedFacts.style.paddingInlineStart = style.paddingInlineStart;
const inset = style.getPropertyValue('--security-facts-center-inset');
if (inset) clonedFacts.style.setProperty('--security-facts-center-inset', inset);
}
const rows = [...host.querySelectorAll('.client-security-facts > div')];
clone.querySelectorAll<HTMLElement>('.client-security-facts > div').forEach((row, index) => {
const style = getComputedStyle(rows[index]);
if (style.paddingTop) row.style.paddingTop = style.paddingTop;
if (style.paddingBottom) row.style.paddingBottom = style.paddingBottom;
});
clone.querySelectorAll('script,iframe,object,embed,form').forEach(element => element.remove());
clone.querySelectorAll('[data-row-marked]').forEach(element => element.removeAttribute('data-row-marked'));
clone.querySelectorAll('button').forEach(button => {
if (button.textContent?.includes('Scoring methodology')) {
button.setAttribute('popovertarget', 'security-scoring-methodology');
} else if (button.classList.contains('client-security-toggle-all')) button.remove();
});
clone.querySelectorAll('.client-security-map [role="button"]').forEach(element => {
const category = element.getAttribute('data-category');
if (category) {
const link = document.createElementNS('http://www.w3.org/2000/svg', 'a');
link.setAttribute('href', `#security-category-${category}`); element.replaceWith(link); link.append(element);
}
element.removeAttribute('role'); element.removeAttribute('tabindex');
});
// Freeze only the supplied graph relationships into CSS: the complete hover chain needs no script.
const focusRules: string[] = [];
for (const [kind, other] of [['domain', 'category'], ['category', 'domain']] as const) {
clone.querySelectorAll(`.client-security-map-${kind}`).forEach(element => {
const id = element.getAttribute(`data-${kind}`)!;
if (!/^[\w-]+$/.test(id)) throw new Error('Unsupported graph identity in Security snapshot.');
const related = new Set([...clone.querySelectorAll(`.client-security-flow[data-${kind}="${id}"]`)].map(path => path.getAttribute(`data-${other}`)!));
if ([...related].some(value => !/^[\w-]+$/.test(value))) throw new Error('Unsupported graph relationship in Security snapshot.');
const scope = `.client-security-map:has(.client-security-map-${kind}[data-${kind}="${id}"]:hover)`;
focusRules.push(`${scope} .client-security-flow:not([data-${kind}="${id}"]){opacity:.08}`,
`${scope} .client-security-map-${kind}:not([data-${kind}="${id}"]){opacity:.18}`,
`${scope} .client-security-map-${other}${[...related].map(value => `:not([data-${other}="${value}"])`).join('')}{opacity:.18}`);
});
}
const style = document.createElement('style'); style.textContent = focusRules.join('\n'); clone.append(style);
const popups = clone.querySelector('[data-security-export-popups]')!;
const popupMarkup = popups.innerHTML; popups.remove();
return { markup: clone.innerHTML, popups: popupMarkup };
} finally { flushSync(() => root.unmount()); host.remove(); }
}

View File

@ -1,6 +1,19 @@
import type { FindingRecord } from '../../domain/finding';
import type { ImportProfile } from '../../domain/import-profile';
import { toolIdForRecord } from '../../importers/status-normalization';
/** Read existing Work verdicts only; never infer a baseline from a missing field. */
export function dashboardGpoGapCounts(records: FindingRecord[], profile: ImportProfile, fallbackTool?: string) {
let notConfigured = 0, noBaselineRule = 0;
for (const record of records) {
if (toolIdForRecord(record, fallbackTool) !== 'group-policy') continue;
const state = dashboardResultState(record, profile);
if (['passed', 'deviation', 'not-applicable'].includes(state)) continue;
if (state === 'not-configured') notConfigured++;
else if (profile.matchesStatus(record, 'no-baseline-rule')) noBaselineRule++;
}
return { notConfigured, noBaselineRule };
}
export type DashboardResultState = 'passed' | 'deviation' | 'not-configured' | 'conflicts' | 'duplicates' | 'not-run' | 'not-applicable' | 'error' | 'unknown' | 'review-required' | 'reevaluation-required';
const recordStateCache = new WeakMap<FindingRecord, { profile: ImportProfile; state: DashboardResultState }>();

View File

@ -1,6 +1,11 @@
import type { EnvironmentObservation, EnvironmentSnapshot, ScannerReportSection, ScannerReportTable } from '../../domain/environment-context';
import { partitionScannerTableRows } from '../../importers/scanner-table-notices';
import { isOperatingSystemEolLabel, isOperatingSystemEolObservation } from './operating-system-inventory';
export function environmentTablePresentation(table: ScannerReportTable) {
return partitionScannerTableRows(table.rows);
}
export type EnvironmentEvidenceGroup = 'all' | 'directory' | 'resilience' | 'inventory'
| 'password-policy' | 'certificates' | 'source-metrics' | 'operating-systems'
| 'ad-domains' | 'entra-tenants' | 'user-accounts' | 'computer-objects' | 'domain-controllers' | 'gpos';

View File

@ -21,6 +21,7 @@ import { RichTextValue } from '../../components/RichTextValue/RichTextValue';
import { PathText } from '../../components/PathText/PathText';
import { normalizeNumericValue } from '../../shared/text';
import { sourceFileLabel } from '../../shared/source-file-label';
import { sourceCategoryField } from '../../components/source-category-field';
import { outcomeForStatusValue, toolIdForRecord } from '../../importers/status-normalization';
import { isFileReference } from '../../profiles/purple-knight/result-linker';
import { highlightText } from '../../components/HighlightText/highlight-text';
@ -270,6 +271,8 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
return /^(?:category|severity|weight|score|category score|domain risk level)$/.test(label)
|| (/^points?$/.test(label) && normalizeNumericValue(field.value) !== null);
});
const sourceCategory = sourceCategoryField(record, toolId);
if (sourceCategory && !badges.some(field => normalizeLabel(field.label) === 'category')) badges.unshift(sourceCategory);
const mitre = [...record.fields, ...legacyFramework.fields].filter(field => mitreBadgeLabel(field.label) && field.value);
// Scanner first read (user decision 2026-09-25): problem, impact and next step are shown above,
// so their fields are not repeated; background precedes references.
@ -442,14 +445,13 @@ export function FindingReader({ record, title, review: storedReview, previousPol
{/* Ergebnisstatus als Outcome, nicht als Meta-Plakette: sonst ist
`Investigate` optisch nicht von `Passed` zu unterscheiden. */}
{statusField?.value && <span className="outcome-mark" data-outcome={outcomeForStatusValue(resolvedToolId, statusField.value)}>{highlightText(statusField.value, search)}</span>}
{severityField && <span className={`severity-badge severity-${severity}`}>{highlightText(severityField.value, search)}</span>}
{/* The heading already names the CIS section; each number badge names its section on hover. */}
{derivedCategories.map(category => <span className="status-badge" data-derived-category="cis" title={cisCategoryRecordTitle(record, category)} key={category}>{highlightText(category, search)}</span>)}
{badges.map(field => {
const isSeverity = field.label.toLowerCase() === 'severity';
{badges.filter(field => !/^(severity|criticality|priority)$/i.test(field.label.trim())).map(field => {
const scale = /^(?:category score|domain risk level)$/i.test(field.label.trim()) ? ' / 100' : '';
const className = isSeverity ? 'severity-badge severity-' + normalizeSeverity(field.value) : 'status-badge';
const value = isSeverity ? field.value : field.label + ': ' + field.value + scale;
return <span className={className} key={field.label}>{highlightText(value, search)}</span>;
const value = field.label + ': ' + field.value + scale;
return <span className="status-badge" key={field.label}>{highlightText(value, search)}</span>;
})}
{policyFindingCategories(record, resolvedToolId).map(category => <span className="status-badge" data-derived-category="gpo" key={category}>{highlightText(category, search)}</span>)}
{policyDiagnosticBadges(record, resolvedToolId).map(diagnostic => <span className="status-badge policy-diagnostic-badge" data-policy-diagnostic={diagnostic.toLowerCase()} key={`policy-diagnostic-${diagnostic}`}>{diagnostic}</span>)}

View File

@ -0,0 +1,19 @@
import type { JsonValue } from '../domain/canonical-model';
/** Interpret retained source rows without altering them, including existing imports.
* HTML spanning notices can be repeated in every cell. Keep original row indexes
* so links and provenance continue to address the original source row.
*/
export function partitionScannerTableRows(rows: JsonValue[][]) {
const dataRows: Array<{ row: JsonValue[]; rowIndex: number }> = [];
const notices: string[] = [];
rows.forEach((row, rowIndex) => {
const populated = row.filter(value => value !== '' && value !== null && value !== undefined);
const first = populated[0];
const notice = typeof first === 'string' && populated.every(value => value === first)
&& /^Output limited to \d+ items\s*[-–—]\s*go to the advanced menu before running the report or add ["']?--no-enum-limit["']? to remove that limit\s*\.?$/i.test(first.trim().replace(/&quot;|&#34;|&#x22;/gi, '"'));
if (notice) { if (!notices.includes(first)) notices.push(first); }
else dataRows.push({ row, rowIndex });
});
return { dataRows, notices };
}

View File

@ -1,4 +1,5 @@
import { Fragment, useMemo, useState } from 'react';
import { Fragment, useId, useMemo, useState, type ReactNode } from 'react';
import { TriangleIcon } from '../components/TriangleIcon';
import { workPackageCode } from './work-package-label';
import { FloatingDataWindow } from '../components/FloatingDataWindow/FloatingDataWindow';
import type { WorkPackage, WorkPlatform } from './remediation-engine';
@ -37,12 +38,27 @@ const compare: Record<Column, (a: WorkPackage, b: WorkPackage) => number> = {
* 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 }: {
export interface WorkPackagesTableProps {
overview: WorkPackageOverview; clusterLabels?: Record<string, string>;
clusterSeverities?: Record<string, 'critical' | 'high' | 'medium' | 'undetermined'>;
clusterOrder?: readonly string[];
onSelect(workPackage: WorkPackage): void; onSelectUnassigned(): void; onSelectRelated(): void; onClose(): void;
}) {
onSelect(workPackage: WorkPackage): void; onSelectUnassigned(): void; onSelectRelated(): void;
inlineDetails?: {
openPackages: ReadonlySet<string>; unassignedOpen: boolean; relatedOpen: boolean;
renderPackage(workPackage: WorkPackage): ReactNode; renderUnassigned(): ReactNode; renderRelated(): ReactNode;
};
}
export function WorkPackagesWindow({ onClose, ...props }: WorkPackagesTableProps & { onClose(): void }) {
return <FloatingDataWindow title="Work packages" onClose={onClose} markedHeader minimizeAnchorSelector='[aria-label="Source: Scan"]' initialBounds={{ width: 830, height: 1080, right: 16, topOffset: 16 }}>
<WorkPackagesTable {...props}/>
</FloatingDataWindow>;
}
/** Same published table in a floating window or an attached Client Info panel. */
export function WorkPackagesTable({ overview, clusterLabels = {}, clusterSeverities = {}, clusterOrder = [], onSelect, onSelectUnassigned, onSelectRelated, inlineDetails }: WorkPackagesTableProps) {
const prefix = useId();
const detailsId = (id: string) => `${prefix}-package-${encodeURIComponent(id)}`;
const [sort, setSort] = useState<{ column: Column; direction: 'ascending' | 'descending' }>({ column: 'package', direction: 'ascending' });
const [filters, setFilters] = useState<Partial<Record<Column, string>>>({});
const packageLabel = (p: WorkPackage) => `${clusterLabels[p.clusterId] ?? p.clusterLabel} → ${workPackageCode(p.code)}`;
@ -88,18 +104,21 @@ export function WorkPackagesWindow({ overview, clusterLabels = {}, clusterSeveri
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 <FloatingDataWindow title="Work packages" onClose={onClose} markedHeader minimizeAnchorSelector='[aria-label="Source: Scan"]' initialBounds={{ width: 830, height: 1080, right: 16, topOffset: 16 }}>
<div className="work-packages" data-work-packages-window>
return <div className="work-packages" data-work-packages-window>
<dl className="client-env-facts work-packages__facts" aria-label="Package totals">
<div><dt>Findings</dt><dd>{number(overview.records)}</dd></div>
<div><dt>Assigned</dt><dd>{number(overview.assigned)}</dd></div>
<div><dt>Unassigned</dt><dd><button type="button" className="work-packages__select" disabled={!overview.unassigned}
onClick={onSelectUnassigned} data-work-package-unassigned-summary>{number(overview.unassigned)}</button></dd></div>
onClick={() => { if (inlineDetails) setFilters({}); onSelectUnassigned(); }} data-work-package-unassigned-summary
aria-expanded={inlineDetails?.unassignedOpen} aria-controls={inlineDetails ? detailsId('unassigned') : undefined}>{number(overview.unassigned)}</button></dd></div>
<div><dt>Packages</dt><dd>{number(overview.rows.length)}</dd></div>
<div><dt>Tasks</dt><dd>{number(overview.tasks)}</dd></div>
<div><dt>Redundant findings</dt><dd><button type="button" className="work-packages__select" disabled={!redundantFindings}
onClick={onSelectRelated} data-work-package-related-summary title="Show all findings belonging to the same-change groups in Work">{number(redundantFindings)}</button></dd></div>
onClick={onSelectRelated} data-work-package-related-summary aria-expanded={inlineDetails?.relatedOpen}
aria-controls={inlineDetails ? detailsId('related') : undefined}
title={inlineDetails ? 'Expand or collapse same-change findings here' : 'Show all findings belonging to the same-change groups in Work'}>{number(redundantFindings)}</button></dd></div>
</dl>
{inlineDetails && <div id={detailsId('related')} hidden={!inlineDetails.relatedOpen}>{inlineDetails.relatedOpen && inlineDetails.renderRelated()}</div>}
<p className="work-packages__note">One cluster and platform per package · Max. 8 tasks (GPO: 15). Groups follow the heatmap; columns sort within groups.</p>
{Object.values(filters).some(value => value?.trim()) && <p className="work-packages__note" role="status" data-work-package-filter-count>
Table: {count(rows.length, 'package')} of {number(overview.rows.length)}{showUnassigned ? ' + Unassigned' : ''}.
@ -123,8 +142,11 @@ export function WorkPackagesWindow({ overview, clusterLabels = {}, clusterSeveri
{clusterLabels[p.clusterId] ?? p.clusterLabel}</span></th>
</tr>}
<tr data-work-package-row={p.id}>
<th scope="row"><span className="work-packages__identity"><button type="button" className="work-packages__select" data-work-package-select={p.id}
onClick={() => onSelect(p)} aria-label={packageLabel(p)} title={`Show ${packageLabel(p)} in Work`}>{workPackageCode(p.code)}</button></span></th>
<th scope="row"><span className="work-packages__identity"><button type="button" className={`work-packages__select${inlineDetails ? ' work-packages__toggle' : ''}`} data-work-package-select={p.id}
onClick={() => onSelect(p)} aria-label={packageLabel(p)}
aria-expanded={inlineDetails ? inlineDetails.openPackages.has(p.id) : undefined} aria-controls={inlineDetails ? detailsId(p.id) : undefined}
title={inlineDetails ? `Expand or collapse ${packageLabel(p)} findings here` : `Show ${packageLabel(p)} in Work`}>
{inlineDetails && <TriangleIcon direction={inlineDetails.openPackages.has(p.id) ? 'down' : 'right'}/>}<span>{workPackageCode(p.code)}</span></button></span></th>
<td>{p.title}{p.measures.length > 1 && <span className="work-packages__meta"> + {p.measures.length - 1} further {p.measures.length === 2 ? 'measure' : 'measures'}</span>}</td>
<td>{PLATFORM_LABEL[p.platform]}</td>
<td>{row.tasks}</td><td>{row.findings}</td><td>{row.redundantFindings || '—'}</td>
@ -132,15 +154,20 @@ export function WorkPackagesWindow({ overview, clusterLabels = {}, clusterSeveri
{contextSeverity(p)[0].toUpperCase() + contextSeverity(p).slice(1)}</span></td>
<td>{p.facts.conditionsTouched ? `${p.facts.conditionsClosed} of ${p.facts.conditionsTouched}` : '—'}</td>
<td>{p.facts.sources.map(s => SOURCE_LABEL[s] ?? s).join(', ')}</td>
</tr></Fragment>; })}
{showUnassigned && <tr data-work-package-unassigned>
</tr>{inlineDetails && <tr data-work-package-details={p.id} hidden={!inlineDetails.openPackages.has(p.id)}><td colSpan={COLUMNS.length}>
<div id={detailsId(p.id)}>{inlineDetails.openPackages.has(p.id) && inlineDetails.renderPackage(p)}</div>
</td></tr>}</Fragment>; })}
{showUnassigned && <><tr data-work-package-unassigned>
<th scope="row"><button type="button" className="work-packages__select" disabled={!overview.unassigned}
data-work-package-select-unassigned onClick={onSelectUnassigned} title="Show Unassigned findings in Work">Unassigned</button></th>
data-work-package-select-unassigned onClick={onSelectUnassigned} aria-expanded={inlineDetails?.unassignedOpen}
aria-controls={inlineDetails ? detailsId('unassigned') : undefined}
title={inlineDetails ? 'Expand or collapse Unassigned findings here' : 'Show Unassigned findings in Work'}>Unassigned</button></th>
<td>No package assignment.</td><td>—</td><td>—</td>
<td>{number(overview.unassigned)}</td><td>—</td><td>—</td><td>—</td><td>—</td>
</tr>}
</tr>{inlineDetails && <tr data-work-package-unassigned-details hidden={!inlineDetails.unassignedOpen}><td colSpan={COLUMNS.length}>
<div id={detailsId('unassigned')}>{inlineDetails.unassignedOpen && inlineDetails.renderUnassigned()}</div>
</td></tr>}</>}
</tbody>
</table></div>
</div>
</FloatingDataWindow>;
</div>;
}

View File

@ -0,0 +1,104 @@
/// <reference path="./report-css.d.ts" />
import { escapeHtml } from '../shared/html';
import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';
import { REPORT_ASSESSOR_LOGO } from './report-brand';
import { REPORT_INTER_FONT } from './report-font';
import uiTokens from '../styles/ui-tokens.css?raw';
import tokens from '../styles/tokens.css?raw';
import base from '../styles/base.css?raw';
import components from '../styles/components.css?raw';
import datahub from '../styles/datahub.css?raw';
import security from '../styles/client-security.css?raw';
import policy from '../styles/policy-analysis.css?raw';
import scoring from '../styles/security-scoring.css?raw';
import icons from '../styles/functional-icons.css?raw';
export const SECURITY_VIEW_SNAPSHOT_VERSION = '1.1.0';
export const SECURITY_HTML_RENDERER_VERSION = '1.2.0';
/** Declared Core Presentation read port; the Report Engine receives only an authorized frozen view. */
export interface SecurityViewReadPort {
read(): Readonly<{
markup: string; popups?: string; width: number; theme: 'light' | 'dark'; density?: string;
client: string; assessments: string; generatedAt: string;
versions: ReadonlyArray<readonly [string, string]>;
}>;
}
export function buildSecurityViewReportHtml(port: SecurityViewReadPort): string {
const snapshot = port.read();
if (!Number.isFinite(snapshot.width) || snapshot.width <= 0) throw new Error('The Security view has no measured width.');
if (/<(?:script|iframe|object|embed|form)\b|<[^>]*\son[a-z]+\s*=|(?:href|src)\s*=\s*["']\s*javascript:/i.test(snapshot.markup + (snapshot.popups ?? ''))) throw new Error('The Security view contains unsupported active content.');
const esc = escapeHtml;
const density = ['compact', 'reading'].includes(snapshot.density ?? '') ? snapshot.density : '';
const pairs = [['ASSESSOR', ASSESSOR_PRODUCT_VERSION], ...snapshot.versions, ['Security snapshot / Renderer', `${SECURITY_VIEW_SNAPSHOT_VERSION} / ${SECURITY_HTML_RENDERER_VERSION}`]];
// A fragment inside native details reveals its ancestors without executable report content.
const markup = snapshot.markup.replace(/href="#security-category-([\w-]+)"/g, 'href="#security-category-content-$1"')
.replace(/(<details\b[^>]*\bid="security-category-([\w-]+)"[^>]*>)([\s\S]*?)(<div class="client-security-group-content")/g,
'$1$3$4 id="security-category-content-$2"');
const popups = (snapshot.popups ?? '').replace(/(<section\b[^>]*class="security-export-popup"[^>]*>\s*<header><h2>[\s\S]*?<\/h2>)/g,
'$1<label class="btn security-export-maximize"><input type="checkbox" aria-label="Maximize popup"><span class="maximize-label">Maximize</span><span class="restore-label">Restore</span></label>');
return `<!doctype html><html lang="de" data-theme="${snapshot.theme === 'dark' ? 'dark' : 'light'}" data-density="${density}"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; font-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
<title>Security Export · ${esc(snapshot.client)} · VERTRAULICH</title><style>
@font-face{font-family:'Inter Variable';src:url('${REPORT_INTER_FONT}') format('woff2');font-weight:100 900;font-display:swap}
${uiTokens}\n${tokens}\n${base}\n${components}\n${datahub}\n${security}\n${policy}\n${scoring}\n${icons}
html,body{background:var(--surface-panel)}
body{overflow:auto;min-height:100vh}
.security-export{width:${snapshot.width}px;max-width:none;zoom:min(1,calc((100vw * 2 / 3) / ${snapshot.width}px));margin:0 auto;color:var(--text-primary);background:var(--surface-panel)}
.security-export .client-security-group:has(>.client-security-group-content:target)>summary{background:var(--surface-selected);border-inline-start:var(--marker-width) solid var(--marking-color)}
.security-export-head,.security-export-results{padding:var(--panel-inset)}
.security-export-brand{display:flex;align-items:center;gap:var(--space-4)}
.security-export-brand img{width:var(--space-8);height:var(--space-8)}
.security-export-brand b{font-size:var(--type-h2-size)}
.security-export-confidential{margin-left:auto;padding:var(--space-2) var(--space-4);border:var(--bw-emphasis) solid var(--text-primary);font-size:var(--type-h2-size);font-weight:var(--fw-bold);letter-spacing:var(--ls-caps)}
.security-export-head h1{margin:var(--space-4) 0 var(--space-2);font-size:var(--type-h2-size)}
.security-export-head h2{font-size:var(--type-h4-size)}
.security-export-versions{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--space-4);margin:var(--space-5) 0}
.security-export-versions>div{padding-bottom:var(--space-3);border-bottom:var(--border-hair) solid var(--border-subtle)}
.security-export-versions dt{color:var(--text-secondary);font-size:var(--type-table-body-size)}
.security-export-versions dd{margin:0;font-size:var(--type-body-size)}
.security-export .client-security-panel{height:auto;min-height:0;overflow:visible;background:var(--surface-panel)}
.security-export .client-security-table{background:var(--surface-panel)}
.security-export summary{list-style:none;cursor:pointer}
.security-export summary::-webkit-details-marker{display:none}
.security-export .client-security-group>summary>svg,.security-export .client-security-finding summary>svg{flex:none}
.security-export .client-security-group[open]>summary>svg,.security-export .client-security-finding details[open]>summary>svg{transform:rotate(-90deg)}
.security-export-result,.security-export-results>details{margin:var(--space-4) 0;border-top:var(--border-hair) solid var(--border-subtle);padding-top:var(--space-3)}
.security-export-results>details>summary{font-weight:var(--fw-semibold);font-size:var(--type-h4-size);padding:var(--space-3) 0}
.security-export-results>details>summary::before{content:'▸ ';color:var(--text-secondary)}
.security-export-results>details[open]>summary::before{content:'▾ '}
.security-export-results .client-env-evidence-scroll{max-height:none;overflow:auto}
.security-export-foot{padding:var(--panel-inset);border-top:var(--border-hair) solid var(--border-subtle);font-weight:var(--fw-semibold)}
.security-export-popup{position:fixed;inset:0;margin:auto;width:min(1100px,calc(100vw - var(--space-8)));min-width:min(520px,calc(100vw - var(--space-8)));max-height:calc(100dvh - var(--space-8));padding:0;border:var(--border-hair) solid var(--line-strong);background:var(--surface-overlay);color:var(--text-primary);box-shadow:var(--shadow-overlay);resize:both;overflow:hidden}
.security-export-popup:popover-open{display:flex;flex-direction:column}
.security-export-popup>header{display:flex;align-items:center;gap:var(--space-4);padding:var(--space-3) var(--panel-inset);border-bottom:var(--border-hair) solid var(--border-subtle);flex:none}
.security-export-popup>header h2{margin:0;font-size:var(--type-h4-size)}
.security-export-popup>header button{flex:none}
.security-export-maximize{margin-left:auto;position:relative;flex:none;display:inline-flex;align-items:center;cursor:pointer}
.security-export-maximize input{position:absolute;inset:0;opacity:0;cursor:pointer;margin:0;width:100%;height:100%}
.security-export-maximize:focus-within{outline:var(--bw-emphasis) solid var(--focus-ring);outline-offset:var(--space-1)}
.security-export-maximize .restore-label{display:none}
.security-export-maximize:has(input:checked) .maximize-label{display:none}
.security-export-maximize:has(input:checked) .restore-label{display:inline}
.security-export-popup:has(>.security-export-popup-body){overscroll-behavior:contain}
.security-export-popup:has(>header input:checked){width:calc(100vw - var(--space-8));height:calc(100dvh - var(--space-8));resize:none}
.security-export-popup-body{overflow:auto;overscroll-behavior:contain;min-height:0;padding:var(--panel-inset);font-size:var(--type-body-size)}
.security-export-popup-body>dl{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:0;margin:0 0 var(--space-4);font-size:var(--type-table-body-size)}
.security-export-popup-body>dl>div{min-width:0;padding:var(--space-2);border:var(--border-hair) solid var(--border-subtle)}
.security-export-popup-body>dl dt{color:var(--text-secondary);font-weight:var(--fw-regular)}
.security-export-popup-body>dl dd{margin:0;overflow-wrap:anywhere}
.security-export-popup .client-env-evidence-scroll{max-height:none}
@media(max-width:900px){.security-export{zoom:min(1,calc((100vw - var(--space-8)) / ${snapshot.width}px))}.security-export-popup-body>dl{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media(max-width:600px){.security-export-popup-body>dl{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media print{.security-export-maximize{display:none}}
@media print{body{overflow:visible}.security-export{margin:0}.security-export-confidential{-webkit-print-color-adjust:exact;print-color-adjust:exact}details::details-content{content-visibility:visible;display:block}summary{break-after:avoid}tr,.client-security-finding-detail{break-inside:avoid}.security-export-popup{display:block;position:static;max-height:none;box-shadow:none;margin:var(--space-5) 0}.security-export-popup>header button{display:none}.security-export-popup-body{overflow:visible}}
</style></head><body><main class="security-export">
<header class="security-export-head"><div class="security-export-brand"><img src="${REPORT_ASSESSOR_LOGO}" alt="ASSESSOR Logo"><div><b>ASSESSOR</b><div>${esc(ASSESSOR_PRODUCT_VERSION)}</div></div><strong class="security-export-confidential">VERTRAULICH</strong></div>
<h1>Security Export</h1><p>${esc(snapshot.client)} · ${esc(snapshot.assessments)}</p><h2>Datenstand und Versionen</h2>
<dl class="security-export-versions">${pairs.map(([label, value]) => `<div><dt>${esc(label)}</dt><dd>${esc(value)}</dd></div>`).join('')}</dl>
<p>Vollständige Security-Ansicht · Proportional an die Fensterbreite angepasst. Kategorien und Findings sind aufklappbar; Results und Originaltabellen öffnen als Popups.</p></header>
${markup}<footer class="security-export-foot">VERTRAULICH · ${esc(snapshot.client)} · ASSESSOR ${esc(ASSESSOR_PRODUCT_VERSION)}</footer>
</main>${popups}</body></html>`;
}

View File

@ -1,16 +1,80 @@
.client-security-panel { --domain-tone: var(--graph-category); --flow-width: 1.5; min-width: 0; }
.client-security-panel { --domain-tone: var(--graph-category); --flow-width: 1.5; --security-map-height: 0px; 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-header > .client-information-action { margin-left: auto; }
.client-security-map { margin: 0; min-width: 0; }
.client-security-graph-zone { position: relative; min-width: 0; }
.client-security-graph-zone .client-security-map { max-width: calc(var(--space-8) * 40); }
.client-security-map-metrics { position: absolute; left: var(--space-2); top: var(--space-1); width: calc(var(--space-8) * 11); max-width: 100%; }
.client-security-overview-disclosure { margin-bottom: var(--space-4); border-bottom: var(--border-hair) solid var(--tile-border); }
.client-security-overview-disclosure > 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-overview-disclosure > summary::-webkit-details-marker { display: none; }
.client-security-overview-disclosure[open] > summary .triangle-icon { transform: rotate(-90deg); }
.client-security-map-scroll { overflow: visible; }
.client-security-map-scroll > svg { display: block; width: 100%; height: var(--security-map-height); margin-block: var(--space-3); overflow: visible; }
.client-security-overview { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: var(--space-5); min-height: calc(var(--security-map-height) + var(--space-6)); align-items: start; container-type: inline-size; }
.client-security-overview > * { min-width: 0; }
.client-security-overview-disclosure, .client-security-overview, .client-security-table { box-sizing: border-box; width: 100%; max-width: 100%; min-width: 0; }
.client-security-hardfacts { --security-facts-center-inset: var(--space-8); min-height: 0; box-sizing: border-box; padding-inline-start: var(--space-8); background: transparent; container: security-facts / inline-size; align-self: center; transform: translateX(calc((max(0px, calc((100cqw - var(--space-5)) * 2 / 3 - var(--space-8) * 40)) + var(--space-5) + var(--security-facts-center-inset)) / -2)); }
.client-security-hardfacts-content { line-height: var(--lh-ui); }
.client-security-map-category { cursor: pointer; }
.client-security-map-hit { fill: transparent; }
.client-security-map-category:hover .client-security-map-hit { fill: var(--surface-hover); }
.client-security-map-category:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); }
.client-security-metrics { display: grid; grid-template-columns: 1fr; gap: var(--space-1); line-height: var(--lh-ui); }
.client-security-metrics h3, .client-security-fact-groups h3 { margin: 0; font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); }
.client-security-metrics section { display: grid; grid-template-columns: 1fr auto; align-content: start; align-items: center; gap: var(--space-1); }
.client-security-metrics section > div { grid-column: 1 / -1; }
.client-security-remediation > span { grid-column: 1 / -1; }
.client-security-metrics strong { font-size: var(--donut-value-size); font-weight: var(--fw-bold); }
.client-security-metrics section > strong { justify-self: end; text-align: right; }
.client-security-metrics small { margin-left: var(--space-2); font-size: var(--type-table-body-size); color: var(--text-secondary); }
.client-security-metrics span { font-size: var(--type-table-body-size); color: var(--text-secondary); }
.client-security-metrics .btn { align-self: start; }
.client-security-score-bar { height: var(--space-3); background: var(--donut-track); }
.client-security-score-bar i { display: block; width: var(--share); height: 100%; background: var(--text-secondary); }
.client-security-score-bar.tone-critical i { background: var(--sev-critical); }
.client-security-score-bar.tone-medium i { background: var(--sev-medium); }
.client-security-score-bar.tone-passed i { background: var(--donut-passed); }
.client-security-totals { width: min(100%, calc(var(--space-8) * 20)); border-collapse: collapse; font-size: var(--type-table-body-size); }
.client-security-totals th, .client-security-totals td { padding: var(--space-2) var(--space-3); border-bottom: var(--border-hair) solid var(--border-subtle); text-align: left; }
.client-security-totals tbody th { position: static; color: var(--text-secondary); font-size: var(--type-table-body-size); font-weight: var(--fw-regular); text-transform: none; letter-spacing: normal; background: transparent; }
.client-security-totals td, .client-security-totals thead th:last-child { text-align: right; font-variant-numeric: tabular-nums; }
.client-security-totals td { font-weight: var(--fw-semibold); }
.client-data-facts.client-security-inventory { margin: 0 0 var(--space-5); }
.client-security-fact-groups { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-5); }
.client-security-fact-column { display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; }
.client-security-fact-groups .client-env-group-header { gap: var(--space-1); justify-content: space-between; }
.client-security-fact-groups h3 { margin: 0; }
.client-security-fact-groups .client-environment-results-button { margin-top: 0; }
.client-security-fact-column > section { min-width: 0; }
.client-security-fact-column > p { margin: 0; font-size: var(--type-table-body-size); }
.client-security-facts { margin: 0; font-size: var(--type-table-body-size); }
.client-security-facts > div { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-2); padding-block: calc(var(--space-1) / 2); line-height: 1.2; border-bottom: var(--border-hair) solid var(--border-subtle); }
.client-security-facts dt { min-width: 0; color: var(--text-secondary); }
.client-security-facts dd { min-width: 0; margin: 0; text-align: right; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
@media (max-width: 2000px) {
.client-security-hardfacts { --security-facts-center-inset: var(--space-4); padding-inline-start: var(--space-4); }
.client-security-facts > div { padding-block: calc(var(--space-1) / 4); }
}
.client-security-map text { fill: var(--text-primary); font-family: inherit; font-size: var(--type-table-body-size); }
.client-security-map .client-security-map-domain-heading { font-weight: var(--fw-semibold); }
.client-security-domain-separator { stroke: var(--border-subtle); stroke-width: var(--border-hair); }
.client-security-domain-track { fill: var(--donut-track); }
.client-security-domain-fill { fill: var(--sev-critical); }
.client-security-domain-count { font-variant-numeric: tabular-nums; }
.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-flow.is-muted { opacity: .08; }
.client-security-map-domain.is-muted, .client-security-map-category.is-muted { opacity: .18; }
.client-security-map-domain { cursor: pointer; }
.client-security-map-domain:hover .client-security-map-hit { fill: var(--surface-hover); }
.client-security-map-domain:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); }
.client-security-category-heading { display: flex; align-items: center; gap: var(--space-2); }
.btn.client-security-toggle-all { width: var(--control-h); padding-inline: 0; border-color: transparent; background: transparent; }
.btn.client-security-toggle-all:hover, .btn.client-security-toggle-all:active, .btn.client-security-toggle-all:disabled { border-color: transparent; background: transparent; }
.client-security-toggle-all svg { flex: none; width: var(--space-4); height: var(--space-4); fill: none; stroke: currentColor; stroke-width: 1.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,
@ -56,7 +120,7 @@
.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)); }
.client-security-overview-disclosure > 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); }
@ -71,3 +135,20 @@
.client-security-columns > :last-child,
.client-security-group-toggle > :last-child { display: none; }
}
.client-assessment-explanation { margin-top: var(--space-6); }
.client-assessment-explanation h3 { margin: 0 0 var(--space-3); font-size: var(--type-h4-size); }
.client-assessment-explanation > p { max-width: 85ch; font-size: var(--type-body-size); line-height: var(--lh-prose); }
.client-assessment-diagram { margin: var(--space-6) 0; max-width: calc(var(--space-8) * 32); }
.client-assessment-diagram figcaption { margin-bottom: var(--space-4); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
.client-assessment-grid { display: grid; grid-template-columns: repeat(10, minmax(0, 1fr)); }
.client-assessment-node { grid-column: span 2; display: flex; flex-direction: column; justify-content: center; gap: var(--space-1); min-width: 0; min-height: calc(var(--space-8) * 4 + var(--space-4)); box-sizing: border-box; margin-inline: var(--space-1); padding: var(--tile-inset); border: var(--border-hair) solid var(--line-strong); border-radius: var(--tile-radius); background: var(--tile-surface); font-size: var(--type-table-body-size); overflow-wrap: anywhere; }
.client-assessment-node strong { font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
.client-assessment-root > .client-assessment-node { grid-column: 5 / span 2; }
.client-assessment-branches > .client-assessment-node:first-child { grid-column: 2 / span 2; }
.client-assessment-branches > .client-assessment-node:last-child { grid-column: 7 / span 2; }
.client-assessment-node b { font-size: var(--type-detail-title-size); font-variant-numeric: tabular-nums; }
.client-assessment-node span { color: var(--text-secondary); }
.client-assessment-connector { display: block; width: 100%; height: calc(var(--space-8) * 2); overflow: visible; }
.client-assessment-connector line { stroke: var(--line-strong); stroke-width: var(--bw-emphasis); }
.client-assessment-connector marker path { fill: var(--line-strong); }

View File

@ -243,6 +243,8 @@ a.finding-work-package { text-decoration: underline; text-underline-offset: var(
.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__toggle { display: inline-flex; align-items: center; gap: var(--space-2); text-decoration: none; }
.client-work-package-findings > ul { margin: var(--space-3) 0; padding: 0; list-style: none; }
.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. */

View File

@ -452,6 +452,8 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select
.client-env-evidence-details summary:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--space-1); }
.client-env-evidence-details[open] .client-env-evidence-scroll { margin-top: var(--space-3); }
.client-env-evidence-prose { white-space: pre-line; }
.client-env-readable-prose { max-width: 85ch; display: grid; gap: var(--space-4); }
.client-env-table-notices { padding: var(--space-3); margin-block: var(--space-3); border: var(--border-hair) solid var(--border-subtle); }
.client-env-evidence-table + .client-env-evidence-table { padding-top: var(--space-4); border-top: var(--border-hair) solid var(--line); }
.floating-data-window-body > .client-env-facts { grid-template-columns: repeat(auto-fit, minmax(min(var(--tile-compact-width), 100%), 1fr)); gap: var(--border-hair); border: var(--border-hair) solid var(--line); background: var(--line); }
.floating-data-window-body > .client-env-facts > div { min-height: 0; padding: var(--space-2); border: 0; background: var(--tile-surface); }
@ -560,6 +562,8 @@ button.client-info-bar-row:focus-visible { outline: var(--border-hair) solid var
.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; }
.dashboard-page.client-work-packages-panel { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.client-work-packages-panel > .work-packages { flex: 1; height: auto; }
.client-info-view-host { height: 100%; min-height: 0; }
.client-info-view-host[hidden] { display: none; }
.risk-entry-with-group { position: relative; }

View File

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

View File

@ -59,6 +59,7 @@ const EXTENSION_POINTS: Record<string, { engine: string | readonly string[]; typ
'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/features/dashboard/ClientWorkPackagesPanel.tsx': { engine: ['Remediation 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'] },

View File

@ -109,7 +109,7 @@ describe('context layer boundary', () => {
});
describe('context layer cannot influence Core score or Result DB', () => {
it('allows only the isolated Client Info heatmap extension and keeps Core calculations free of context', () => {
it('allows only pinned Client Info context presentation extensions and keeps Core calculations free of context', () => {
const guarded = [
...sourceFiles(join(root, 'src', 'features', 'dashboard')),
...sourceFiles(join(root, 'src', 'scoring')),
@ -121,8 +121,10 @@ describe('context layer cannot influence Core score or Result DB', () => {
const contextDirectories = [join(root, 'src', 'assessment-context'), join(root, 'src', 'features', 'context-layer')];
const violations = guarded.flatMap(file => imports(read(file))
.filter(use => use.specifier.startsWith('.') && contextDirectories.some(directory => resolve(dirname(file), use.specifier).startsWith(directory + sep)))
.filter(use => !(posix(file) === 'src/features/dashboard/DashboardPanel.tsx'
&& ['../context-layer/ContextHeatmap', '../context-layer/context-cluster-facet', '../context-layer/use-assessment-context', '../../assessment-context/context-report-publication'].includes(use.specifier)))
.filter(use => !((posix(file) === 'src/features/dashboard/DashboardPanel.tsx'
&& ['../context-layer/ContextHeatmap', '../context-layer/context-cluster-facet', '../context-layer/use-assessment-context', '../../assessment-context/context-report-publication'].includes(use.specifier))
|| (posix(file) === 'src/features/dashboard/ClientWorkPackagesPanel.tsx'
&& use.specifier === '../context-layer/use-context-cluster-index')))
.map(use => `${posix(file)} -> ${use.specifier}`));
expect(violations).toEqual([]);
});

View File

@ -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.1');
expect(result.engine.version).toBe('1.12.0');
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.1');
expect(result.engine.version).toBe('1.12.0');
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 } });

View File

@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { RELEASED_ASSESSMENT_CONTEXT_CATALOG as catalog } from '../../src/assessment-context/context-catalog';
import { buildAssessmentContext } from '../../src/assessment-context/context-engine';
import { CONTEXT_RECOGNITION_TERMS, recognizeClusters } from '../../src/assessment-context/context-recognition';
import { ASSESSMENT_CONTEXT_ENGINE, ASSESSMENT_CONTEXT_ENGINE_VOCABULARY, buildAssessmentContext } from '../../src/assessment-context/context-engine';
import { CONTEXT_RECOGNITION_TERMS, CONTEXT_RECOGNITION_VOCABULARY, recognizeClusters } from '../../src/assessment-context/context-recognition';
// User decision 2026-10-01: the context engine recognizes context on its own, as validly as possible.
// Tier 1 uses ATT&CK techniques the scanner declares; tier 2 suggests clusters from distinctive terms of the rule text.
@ -84,6 +84,38 @@ describe('vocabulary 1.1.0 for security templates, user rights and cloud finding
expect(named('Security defaults not enabled')).toEqual(['cloud-sign-in-protection-gaps']);
});
it('places the public scanner titles measured as unplaced on 2026-10-12 (vocabulary 1.2.0)', () => {
expect(named('Full Scan Removable Drives should be enabled.')).toEqual(['malicious-code-execution-exposure']);
expect(named('Full Scan Mapped Drives should be disabled for performance.')).toEqual(['malicious-code-execution-exposure']);
expect(named('Cleaned Malware should be retained for at least 30 days.')).toEqual(['malicious-code-execution-exposure']);
expect(named('Number of DC inactive: 2')).toEqual(['ad-recovery-gaps']);
// User decision 2026-10-12: trusts belong to recovery.
expect(named('At least one inactive trust has been found: 1')).toEqual(['ad-recovery-gaps']);
expect(named('Risky delegated permission on AD OUs')).toEqual(['ad-privileged-account-exposure']);
expect(named('Set a honeytoken account.')).toEqual(['detection-blind-spots']);
expect(named('Configure VPN integration.')).toEqual(['detection-blind-spots']);
// User decision 2026-10-12: governance and workload identity findings use the existing cloud clusters.
expect(named('Pending approvals for Critical Asset Management should not be present.')).toEqual(['cloud-privileged-access-exposure']);
expect(named('No catalog should contain resources without any associated access packages.')).toEqual(['cloud-privileged-access-exposure']);
expect(named('Review who can change attributes used by dynamic group rules.')).toEqual(['cloud-privileged-access-exposure']);
expect(named('Convert your application(s) to Managed Identity.')).toEqual(['cloud-app-consent-exposure']);
expect(named('Resolve unsecure account attributes.')).toEqual(['password-guessing-exposure']);
expect(named('Resolve unsecure domain configurations.')).toEqual(['credential-relay-exposure']);
// Neighbouring readings stay with their own cluster or unplaced.
expect(named('Ensure the default malware filter policy blocks common attachment types')).not.toContain('malicious-code-execution-exposure');
expect(named('Trust computer and user accounts for delegation')).not.toContain('ad-recovery-gaps');
expect(named('Trusted locations should be configured for Conditional Access')).toEqual([]);
expect(named('Apps with delegated permissions on Microsoft Graph')).not.toContain('ad-privileged-account-exposure');
expect(named('Relatively high number of inactive computer accounts')).toEqual([]);
});
it('publishes every vocabulary release with a new engine version, so stored work-package snapshots refresh (2026-10-12)', () => {
// Regression: vocabulary 1.2.0 placed findings in clusters while the stored snapshot kept them unassigned,
// because that snapshot is keyed by engine and catalogue version only.
expect(ASSESSMENT_CONTEXT_ENGINE_VOCABULARY).toBe(CONTEXT_RECOGNITION_VOCABULARY.version);
expect(ASSESSMENT_CONTEXT_ENGINE.version).toBe('1.12.0');
});
it('keeps conditional access trusted locations apart from Office trusted locations', () => {
expect(named('Trusted locations should be configured for Conditional Access')).toEqual([]);
expect(named(['software', 'policies', 'microsoft', 'office', '16.0', 'excel', 'security', 'trusted locations'].join(String.fromCharCode(92)) + ' · allownetworklocations'))

View File

@ -0,0 +1,48 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AssessmentFactsExplanation } from '../../src/features/dashboard/AssessmentFactsExplanation';
import { dashboardGpoGapCounts, dashboardResultState, dashboardScoreFromStates } from '../../src/features/dashboard/dashboard-result-state';
import { genericProfile } from '../../src/profiles/generic/generic-profile';
import { makeSheet } from '../../src/importers/sheet-factory';
afterEach(() => { vi.unstubAllGlobals(); document.body.replaceChildren(); });
describe.each(['light', 'dark'])('Assessment facts explanation (%s)', theme => {
it('reconciles the graphic with authoritative Work counts and explicit GPO states without changing evidence', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
document.documentElement.dataset.theme = theme;
const sheet = makeSheet('Synthetic', [['Name', 'Status', 'Source: Scan', 'Conflict', 'Duplicates'],
['Passed GPO', 'Pass', 'GPOs', 'Yes', ''], ['Deviating GPO', 'Fail', 'GPOs', '', 'Yes'],
['Unset GPO', 'Not configured', 'GPOs', '', ''], ['No requirement', 'No baseline rule', 'GPOs', '', ''],
['Unexecuted scanner check', 'Not run', 'Generic', '', ''], ['Excluded scanner check', 'Not applicable', 'Generic', '', ''],
['Other unset state', 'Not configured', 'Generic', '', '']
], '', 'synthetic.csv', { isProjectSheet: true });
const records = sheet.analysis.records;
const original = JSON.stringify(records);
const counts = dashboardScoreFromStates(records.map(record => dashboardResultState(record, genericProfile)), records);
const gpo = dashboardGpoGapCounts(records, genericProfile, 'generic');
expect(gpo).toEqual({ notConfigured: 1, noBaselineRule: 1 });
expect(dashboardGpoGapCounts(records.slice(4), genericProfile, 'generic')).toEqual({ notConfigured: 0, noBaselineRule: 0 });
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<AssessmentFactsExplanation counts={counts} gpo={gpo}/>));
const value = (metric: string) => Number(host.querySelector(`[data-metric="${metric}"] b`)!.textContent);
expect(value('Eligible controls')).toBe(6);
expect(value('Assessed')).toBe(2);
expect(value('Passed')).toBe(1); expect(value('Deviations')).toBe(1);
expect(value('Gaps')).toBe(4);
expect(value('GPO: Not configured')).toBe(1); expect(value('GPO: No baseline rule')).toBe(1);
expect(value('Other gaps')).toBe(2);
expect(value('Passed') + value('Deviations')).toBe(counts.assessed);
expect(value('GPO: Not configured') + value('GPO: No baseline rule') + value('Other gaps')).toBe(counts.unassessed);
expect(counts.conflicts).toBe(1); expect(counts.duplicates).toBe(1);
expect(host.textContent).toContain('33%');
expect(host.textContent).toContain('Backup configuration does not establish effective host state');
expect(JSON.stringify(records)).toBe(original);
await act(async () => root.render(<AssessmentFactsExplanation counts={dashboardScoreFromStates([])} gpo={{ notConfigured: 0, noBaselineRule: 0 }}/>));
expect(host.textContent).toContain('no eligible controls'); expect(value('Other gaps')).toBe(0);
} finally { await act(async () => root.unmount()); host.remove(); }
});
});

View File

@ -43,9 +43,10 @@ it.each(['dark', 'light'])('opens the six actual Data facts with their own retai
setView: vi.fn(), setActionOnly: vi.fn(), setActiveSheet: vi.fn()
} as unknown as ReturnType<typeof useApp>);
const tool = { id: 'synthetic', name: 'Synthetic scanner' };
const gpoProse = 'Synthetic scanner explanation. '.repeat(40) + 'Command: auditpol.exe /get /category:*. Preserve the original https://example.invalid/reference and script.js.';
const facts: Array<[string, string | number]> = [
['domain.count', 1], ['domain.fqdn', 'synthetic.example'], ['entra.tenant_name', 'Synthetic tenant'],
['domain.controller_count', 3], ['gpo.count', 277]
['domain.controller_count', 3], ['gpo.count', 277], ['computer.count', 341], ['user.count', 590]
];
const snapshot: EnvironmentSnapshot = { version: 1, projectId: 'synthetic-client', scanRunIds: ['run'],
generatedAt: '2026-10-02T12:00:00Z', coverage: { observed: 5, explicitlyAbsent: 0, empty: 0, restricted: 0, incomplete: 0 },
@ -59,7 +60,8 @@ it.each(['dark', 'light'])('opens the six actual Data facts with their own retai
['sectionGPO', 'GPO source', 'GPO count', 277, 'Synthetic policies']
].map(([key, label, header, count, marker]) => ({ version: 1, id: String(key), scanRunId: 'run', importFileId: 'file', tool,
sectionKey: String(key), label: String(label), ordinal: 1, state: 'observed',
tables: [{ name: String(label), headers: [String(header), 'Original evidence'], rows: [[count, marker]] }],
prose: key === 'sectionGPO' ? gpoProse : undefined,
tables: [{ name: String(label), presentation: 'inline', headers: [String(header), 'Original evidence'], rows: [[count, marker]] }],
source: { rowIndex: 0 }, extractorVersion: '1', attributes: {} }))
};
const original = JSON.stringify(snapshot);
@ -71,26 +73,41 @@ it.each(['dark', 'light'])('opens the six actual Data facts with their own retai
const onOpenWork = vi.fn();
try {
await act(async () => root.render(<DashboardPanel onOpenWork={onOpenWork} />));
expect(host.querySelectorAll('button.client-data-fact')).toHaveLength(6);
expect(host.querySelectorAll('button.client-data-fact')).toHaveLength(12);
for (const [label, count, evidence] of [
['AD Domains', '1', 'synthetic.example'], ['Entra Tenant', '1', 'Synthetic tenant'],
['User Accounts', '590', 'Synthetic users'], ['Computer Objects', '341', 'Synthetic computers'],
['Domain Controllers', '3', 'Synthetic controllers'], ['GPOs', '277', 'Synthetic backup.zip']
]) {
const trigger = host.querySelector<HTMLButtonElement>(`[aria-label="Open ${label} results"]`)!;
const triggers = [...host.querySelectorAll<HTMLButtonElement>(`[aria-label="Open ${label} results"]`)];
expect(triggers).toHaveLength(2);
for (const trigger of triggers) {
expect(trigger.querySelector('strong')!.textContent).toBe(count);
expect(trigger.getAttribute('aria-haspopup')).toBe('dialog');
await act(async () => trigger.click());
const popup = document.querySelector<HTMLElement>('.floating-data-window')!;
expect(popup.querySelector('h2')!.textContent).toBe(label);
expect(popup.textContent).toContain(evidence);
if (label === 'Computer Objects' || label === 'User Accounts' || label === 'GPOs') {
expect(popup.textContent).not.toContain('Original value');
if (label !== 'GPOs') expect(popup.querySelector('table.client-data-fact-evidence')).toBeNull();
expect(popup.querySelector('.client-env-evidence-table table')).not.toBeNull();
expect(popup.textContent).not.toContain('Scan run');
}
if (label === 'User Accounts') expect(popup.textContent).not.toContain('Synthetic computers');
if (label === 'GPOs') expect(popup.textContent).toContain('All processed client backups');
if (label === 'GPOs') {
const paragraphs = [...popup.querySelectorAll('.client-env-readable-prose p')];
expect(paragraphs.length).toBeGreaterThan(1);
expect(paragraphs.map(p => p.textContent).join(' ').replace(/\s+/g, ' ').trim()).toBe(gpoProse.replace(/\s+/g, ' ').trim());
expect(popup.querySelector('.client-env-evidence-table table')!.textContent).toContain('Synthetic policies');
}
expect(trigger.getAttribute('aria-expanded')).toBe('true');
await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })));
expect(document.querySelector('.floating-data-window')).toBeNull();
expect(trigger.getAttribute('aria-expanded')).toBe('false');
expect(document.activeElement).toBe(trigger);
}
}
expect(request).toHaveBeenCalledOnce();
expect(onOpenWork).not.toHaveBeenCalled();

View File

@ -33,7 +33,8 @@ it.each(['populated', 'unavailable', 'non-numeric count'])('shows each overview
expect(host.querySelector('.client-posture')).not.toBeNull();
expect(host.querySelector('.client-posture-connectors')).not.toBeNull();
expect(host.querySelector('.client-priority')).not.toBeNull();
expect(host.querySelectorAll('.client-data-fact')).toHaveLength(6);
expect(host.querySelector('[data-group-scroll="client-info-overview"]')!.querySelectorAll('.client-data-fact')).toHaveLength(6);
expect(host.querySelector('[data-group-scroll="client-info-security"]')!.querySelectorAll('.client-data-fact')).toHaveLength(6);
expect(groups).toHaveLength(5);
const tiles = [...host.querySelectorAll('.client-environment-grid > section')];
expect(tiles).toHaveLength(6);

View File

@ -12,12 +12,76 @@ 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';
import { reportHeaderFacts } from '../../src/reporting/report-header-facts';
vi.mock('../../src/app/AppProvider', () => ({ useApp: vi.fn() }));
vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => <span>Synthetic timeslot</span> }));
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren(); localStorage.clear(); resetEnginePreferencesForTests(); });
describe.each(['light', 'dark'])('Security findings tab (%s)', theme => {
it('dims unrelated domain flows, opens exactly the related categories and toggles all categories without changing findings', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
document.documentElement.dataset.theme = theme;
const sheet = makeSheet('Synthetic', [['Name', 'Status', 'Severity'], ...Array.from({ length: 4 }, (_, index) => [`Synthetic ${index}`, 'Failed', 'High'])], '', 'synthetic.csv', { isProjectSheet: true });
const dataset = { fileName: 'Synthetic', fileSize: 0, lastModified: 0, format: 'csv', rawText: '', profileId: 'generic', scanTool: 'Generic', sheets: [sheet] };
const first = { id: 'identity', label: 'Identity & Authentication', count: 2 };
const second = { id: 'hardening', label: 'System Hardening', count: 2 };
const groups = [
{ id: 'one', label: 'First category', severity: 'high', records: sheet.analysis.records.slice(0, 1), domains: [{ ...first, count: 1 }] },
{ id: 'shared', label: 'Shared category', severity: 'high', records: sheet.analysis.records.slice(1, 3), domains: [{ ...first, count: 1 }, { ...second, count: 1 }] },
{ id: 'other', label: 'Other category', severity: 'high', records: sheet.analysis.records.slice(3), domains: [{ ...second, count: 1 }] },
{ id: 'empty', label: 'Empty category', severity: 'unknown', records: [], domains: [{ ...first, count: 0 }] }
];
const original = JSON.stringify({ dataset, groups });
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
const style = document.createElement('style'); style.textContent = readFileSync('src/styles/client-security.css', 'utf8'); document.head.append(style);
try {
await act(async () => root.render(<ClientSecurityPanel dataset={dataset} sheetId={sheet.id} groups={groups} domains={[first, second]} total={4} contextStatus="ready"/>));
const domain = host.querySelector<SVGGElement>('.client-security-map-domain[data-domain="identity"]')!;
const expanded = () => [...host.querySelectorAll('.client-security-group')].filter(group => group.querySelector('button')!.getAttribute('aria-expanded') === 'true').map(group => group.getAttribute('data-cluster'));
expect(expanded()).toEqual([]);
await act(async () => domain.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })));
expect([...host.querySelectorAll('.client-security-flow.is-muted')].every(flow => flow.getAttribute('data-domain') === 'hardening')).toBe(true);
expect(host.querySelectorAll('.client-security-flow.is-muted')).toHaveLength(2);
expect(getComputedStyle(host.querySelector('.client-security-flow.is-muted')!).opacity).toBe('0.08');
expect(getComputedStyle(host.querySelector('.client-security-flow[data-domain="identity"]')!).opacity).toBe('0.5');
expect(host.querySelector('.client-security-map-domain[data-domain="hardening"]')!.classList.contains('is-muted')).toBe(true);
expect([...host.querySelectorAll('.client-security-map-category.is-muted')].map(e => e.getAttribute('data-category'))).toEqual(['other', 'empty']);
expect(host.querySelector('.client-security-map-category[data-category="shared"]')!.classList.contains('is-muted')).toBe(false);
await act(async () => domain.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })));
expect(host.querySelectorAll('.client-security-flow.is-muted')).toHaveLength(0);
const sharedCategory = host.querySelector('.client-security-map-category[data-category="shared"]')!;
await act(async () => sharedCategory.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })));
expect([...host.querySelectorAll('.client-security-flow:not(.is-muted)')].map(e => e.getAttribute('data-category'))).toEqual(['shared', 'shared']);
expect(host.querySelectorAll('.client-security-map-domain.is-muted')).toHaveLength(0);
expect([...host.querySelectorAll('.client-security-map-category.is-muted')].map(e => e.getAttribute('data-category'))).toEqual(['one', 'other', 'empty']);
expect(getComputedStyle(host.querySelector('.client-security-map-category.is-muted')!).opacity).toBe('0.18');
await act(async () => sharedCategory.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })));
await act(async () => sharedCategory.dispatchEvent(new FocusEvent('focusin', { bubbles: true })));
expect(host.querySelectorAll('.client-security-flow.is-muted')).toHaveLength(2);
await act(async () => sharedCategory.dispatchEvent(new FocusEvent('focusout', { bubbles: true })));
expect(host.querySelectorAll('.client-security-map-category.is-muted')).toHaveLength(0);
await act(async () => domain.dispatchEvent(new MouseEvent('click', { bubbles: true })));
expect(expanded()).toEqual(['one', 'shared']);
const bulk = host.querySelector<HTMLButtonElement>('.client-security-toggle-all')!;
expect(getComputedStyle(bulk.querySelector('svg')!).flexShrink).toBe('0');
expect(bulk.closest('.client-security-category-heading')!.textContent).toBe('Category');
expect(bulk.getAttribute('aria-label')).toBe('Expand all categories');
await act(async () => bulk.click()); expect(expanded()).toEqual(['one', 'shared', 'other', 'empty']);
expect(bulk.getAttribute('aria-label')).toBe('Collapse all categories');
await act(async () => bulk.click()); expect(expanded()).toEqual([]);
for (const key of ['Enter', ' ']) {
await act(async () => domain.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })));
expect(expanded()).toEqual(['one', 'shared']);
await act(async () => bulk.click()); await act(async () => bulk.click()); expect(expanded()).toEqual([]);
}
await act(async () => domain.dispatchEvent(new FocusEvent('focusin', { bubbles: true })));
expect(host.querySelectorAll('.client-security-flow.is-muted')).toHaveLength(2);
await act(async () => domain.dispatchEvent(new FocusEvent('focusout', { bubbles: true })));
expect(host.querySelectorAll('.client-security-flow.is-muted')).toHaveLength(0);
expect(JSON.stringify({ dataset, groups })).toBe(original);
} finally { await act(async () => root.unmount()); style.remove(); }
});
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;
@ -52,7 +116,12 @@ describe.each(['light', 'dark'])('Security findings tab (%s)', theme => {
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() {} });
const resizeCallbacks = new Map<Element, () => void>();
vi.stubGlobal('ResizeObserver', class {
constructor(private callback: () => void) {}
observe(element: Element) { resizeCallbacks.set(element, this.callback); }
disconnect() {}
});
document.documentElement.dataset.theme = theme;
resetEnginePreferencesForTests();
const state = clientFactsState();
@ -74,7 +143,10 @@ describe.each(['light', 'dark'])('Security findings tab (%s)', theme => {
vi.mocked(useApp).mockReturnValue({ ...state, dataset: { ...state.dataset, sheets: [sheet] }, activeSheet: sheet,
activeProfile: genericProfile, setFacetFilter: setFilter } as unknown as ReturnType<typeof useApp>);
const original = JSON.stringify(useApp().dataset);
vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue(clientFactsSnapshot);
vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue({ ...clientFactsSnapshot,
sections: [...clientFactsSnapshot.sections, { ...clientFactsSnapshot.sections[0],
id: 'synthetic-restricted', sectionKey: 'syntheticRestricted', label: 'Synthetic restricted section', state: 'license-restricted', tables: [] }]
});
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);
@ -92,16 +164,139 @@ describe.each(['light', 'dark'])('Security findings tab (%s)', theme => {
expect(groups.map(group => group.dataset.cluster)).toEqual([...host.querySelectorAll<HTMLElement>('.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<HTMLDetailsElement>('.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');
const disclosure = panel.querySelector<HTMLDetailsElement>('.client-security-overview-disclosure')!;
const map = panel.querySelector<HTMLDivElement>('.client-security-map')!;
expect(disclosure.open).toBe(true);
expect(disclosure.style.getPropertyValue('--security-map-height')).toBe(`${groups.length * 28 + 24}px`);
expect(disclosure.querySelector('.client-security-hardfacts')).not.toBeNull();
expect(disclosure.querySelector('.client-security-table')).toBeNull();
expect(panel.querySelectorAll('details.client-security-map')).toHaveLength(0);
await act(async () => disclosure.querySelector('summary')!.click());
expect(disclosure.open).toBe(false);
expect(panel.querySelector('.client-security-table')).not.toBeNull();
await act(async () => disclosure.querySelector('summary')!.click());
expect(disclosure.open).toBe(true);
const overview = panel.querySelector('.client-security-hardfacts')!;
expect(getComputedStyle(overview).paddingInlineStart).toBe('var(--space-8)');
for (const row of overview.querySelectorAll('.client-security-facts > div')) {
expect(getComputedStyle(row).paddingBlock).toBe('calc(var(--space-1) / 2)');
}
expect(disclosure.querySelector('.client-security-graph-zone .client-security-score')).not.toBeNull();
expect(disclosure.querySelector('.client-security-graph-zone .client-security-remediation')).not.toBeNull();
expect(overview.textContent).not.toContain('Overall Score');
expect(overview.textContent).not.toContain('Remediation');
expect(overview.textContent).toContain('Password policy');
expect(overview.textContent).toContain('Cloud Information');
expect(overview.textContent).not.toContain('Certificates & PKI');
expect(overview.textContent).not.toContain('Source security metrics');
expect(overview.textContent).not.toContain('Security Domains');
expect(overview.textContent).not.toContain('Restricted by the source tool');
expect(getComputedStyle(overview).backgroundColor).toBe('rgba(0, 0, 0, 0)');
const boundedFacts = overview.querySelector('.client-security-hardfacts-content')!;
expect(getComputedStyle(boundedFacts).position).not.toBe('absolute');
expect(getComputedStyle(boundedFacts).overflow).not.toBe('auto');
expect(getComputedStyle(panel.querySelector('.client-security-overview')!).gridTemplateColumns).toBe('minmax(0, 2fr) minmax(0, 1fr)');
const columns = [...overview.querySelectorAll('.client-security-fact-column')];
expect(columns).toHaveLength(2);
expect(columns.map(column => [...column.querySelectorAll('h3')].map(h => h.textContent))).toEqual([
['Domain Information', 'Resilience', 'Inventory'],
['Password policy', 'Operating systems', 'Cloud Information']
]);
const report = reportHeaderFacts(clientFactsSnapshot, {})!;
const reportGroups = [{ title: 'Domain Information', facts: report.domainInformation }, ...report.groups]
.filter(group => group.title !== 'Certificates & PKI');
for (const group of reportGroups) {
const section = [...overview.querySelectorAll('.client-security-fact-column > section')]
.find(item => item.querySelector('h3')?.textContent === group.title)!;
expect([...section.querySelectorAll('.client-security-facts > div')].map(row => ({
label: row.querySelector('dt')!.textContent, value: row.querySelector('dd')!.textContent
}))).toEqual(group.facts);
}
const domainRows = [...map.querySelectorAll('.client-security-map-domain')];
expect(domainRows).toHaveLength(6);
const domainPositions = domainRows.map(row => Number(row.querySelector('.client-security-domain-count')!.getAttribute('y')));
expect(domainPositions.slice(1).map((y, index) => y - domainPositions[index])).toEqual([28, 28, 28, 28, 28]);
expect((domainPositions[0] + domainPositions[5]) / 2 - 5).toBe((groups.length * 28 + 24) / 2 + 32);
for (const surface of [disclosure, panel.querySelector('.client-security-overview')!, panel.querySelector('.client-security-table')!]) {
expect(getComputedStyle(surface).width).toBe('100%');
expect(getComputedStyle(surface).maxWidth).toBe('100%');
expect(getComputedStyle(surface).minWidth).toBe('0px');
}
expect(domainRows.reduce((sum, row) => sum + Number(row.querySelector('.client-security-domain-count')!.textContent), 0)).toBe(105);
for (const row of domainRows) {
const count = Number(row.querySelector('.client-security-domain-count')!.textContent);
const bar = row.querySelector('.client-security-domain-bar')!;
expect(bar.getAttribute('aria-label')).toBe(`${count} of 105 deviations`);
expect(Number(bar.querySelector('.client-security-domain-track')!.getAttribute('width'))).toBe(80);
expect(Number(bar.querySelector('.client-security-domain-fill')!.getAttribute('width'))).toBeCloseTo(count / 105 * 80);
}
const domainCountsBefore = domainRows.map(row => row.querySelector('.client-security-domain-count')!.textContent);
const categoryRows = [...map.querySelectorAll('.client-security-map-category')];
expect(categoryRows).toHaveLength(groups.length);
expect(categoryRows.reduce((sum, row) => sum + Number(row.querySelector('.client-security-category-count')!.textContent), 0)).toBe(105);
for (const row of categoryRows) {
const count = Number(row.querySelector('.client-security-category-count')!.textContent);
expect(row.querySelector('.client-security-category-bar')!.getAttribute('aria-label')).toBe(`${count} of 105 deviations`);
expect(Number(row.querySelector('.client-security-domain-track')!.getAttribute('width'))).toBe(80);
expect(Number(row.querySelector('.client-security-domain-fill')!.getAttribute('width'))).toBeCloseTo(count / 105 * 80);
}
expect(disclosure.querySelector('.client-security-remediation > strong')!.textContent).toBe('105');
const originalOverview = host.querySelector('.client-command-center')!;
expect(domainCountsBefore).toEqual([...originalOverview.querySelectorAll('.client-domains > ul b')].map(item => item.textContent));
expect(disclosure.querySelector('.client-security-score > strong')!.textContent).toBe(originalOverview.querySelector('.client-overall-ring-number')!.textContent);
for (const value of disclosure.querySelectorAll('.client-security-metrics section > strong')) {
expect(getComputedStyle(value).justifySelf).toBe('end');
expect(getComputedStyle(value).textAlign).toBe('right');
}
expect(disclosure.querySelector('.client-security-totals')).toBeNull();
await act(async () => disclosure.querySelector<HTMLButtonElement>('[aria-label="Open Assessment facts results"]')!.click());
const totalFacts = [...document.querySelectorAll('.client-security-totals tbody > tr')];
expect(getComputedStyle(totalFacts[0].querySelector('th')!).position).toBe('static');
expect(totalFacts).toHaveLength(9);
expect([...document.querySelectorAll('.client-security-totals thead th')].map(cell => cell.textContent)).toEqual(['Metric', 'Value']);
const factValue = (label: string) => totalFacts.find(fact => fact.querySelector('th[scope="row"]')!.textContent === label)?.querySelector('td')!.textContent;
expect(factValue('Assessed')).toBe('106');
expect(factValue('Passed')).toBe('1');
expect(factValue('Gaps')).toBe('1');
expect(factValue('Categories with deviations')).toBe('2');
const assessmentDiagram = document.querySelector('.client-assessment-diagram')!;
const diagramValue = (metric: string) => assessmentDiagram.querySelector(`[data-metric="${metric}"] b`)!.textContent;
expect(diagramValue('Assessed')).toBe(factValue('Assessed'));
expect(diagramValue('Passed')).toBe(factValue('Passed'));
expect(diagramValue('Gaps')).toBe(factValue('Gaps'));
expect(diagramValue('Deviations')).toBe('105');
expect(document.querySelector('.client-assessment-explanation')!.textContent).toContain('No baseline rule');
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Close Assessment facts window"]')!.click());
await act(async () => new Promise<void>(resolve => requestAnimationFrame(() => resolve())));
const securityFacts = [...overview.querySelectorAll('.client-security-inventory strong')].map(item => item.textContent);
expect(overview.querySelectorAll('.client-security-inventory .client-data-fact')).toHaveLength(6);
expect(securityFacts).toEqual([...originalOverview.querySelectorAll('.client-data-fact strong')].map(item => item.textContent));
const diagram = map.querySelector('.client-security-map-scroll > svg')!;
expect(getComputedStyle(diagram).width).toBe('100%');
expect(getComputedStyle(map.querySelector('.client-security-map-scroll')!).overflow).toBe('visible');
vi.spyOn(map, 'getBoundingClientRect').mockReturnValue({ width: 1400 } as DOMRect);
await act(async () => resizeCallbacks.get(map)!());
expect(diagram.getAttribute('viewBox')).toBe(`0 0 1400 ${groups.length * 28 + 24}`);
expect(map.querySelector('.client-security-map-category .client-security-map-hit')!.getAttribute('x')).toBe('900');
expect(map.querySelector('.client-security-domain-track')!.getAttribute('width')).toBe('80');
expect(getComputedStyle(disclosure.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');
expect(panel.querySelector('svg[role="group"]')!.textContent).toContain('104');
const category = groups[0].querySelector<HTMLButtonElement>('.client-security-group-toggle')!;
expect(category.getAttribute('aria-expanded')).toBe('false');
const scroll = vi.fn(); groups[0].scrollIntoView = scroll;
const mapCategory = map.querySelector<SVGGElement>('.client-security-map-category[role="button"]')!;
await act(async () => mapCategory.dispatchEvent(new MouseEvent('click', { bubbles: true })));
expect(category.getAttribute('aria-expanded')).toBe('true');
expect(document.activeElement).toBe(category);
expect(scroll).toHaveBeenCalled();
await act(async () => category.click());
expect(category.getAttribute('aria-expanded')).toBe('false');
await act(async () => mapCategory.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })));
expect(category.getAttribute('aria-expanded')).toBe('true');
await act(async () => category.click());
await act(async () => mapCategory.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })));
expect(category.getAttribute('aria-expanded')).toBe('true');
expect(groups[0].textContent).toContain('Technical approach');
expect(groups[0].querySelectorAll('.client-security-finding')).toHaveLength(104);
expect(groups[0].textContent).not.toContain('Passed control');
@ -124,6 +319,7 @@ describe.each(['light', 'dark'])('Security findings tab (%s)', theme => {
expect(panel.querySelector('.client-security-map')).toBeNull();
expect(panel.textContent).toContain('105 deviations');
expect(panel.textContent).toContain('All findings');
expect([...originalOverview.querySelectorAll('.client-domains > ul b')].map(row => row.textContent)).toEqual(domainCountsBefore);
} finally { await act(async () => root.unmount()); style.remove(); }
});

View File

@ -0,0 +1,234 @@
// @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 { installTableRowMarking } from '../../src/app/table-row-marking';
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
import { AssessorApiClient } from '../../src/api/assessor-api-client';
import type { WorkPackagesResponse } from '../../src/api/assessor-api-contract';
import { DashboardPanel } from '../../src/features/dashboard/DashboardPanel';
import { makeSheet } from '../../src/importers/sheet-factory';
import { buildWorkPackages, scannerFindingKey } from '../../src/remediation/remediation-engine';
import { workRecordKey } from '../../src/remediation/work-package-index';
import { resetWorkPackageRequestsForTests } from '../../src/remediation/use-work-packages';
import { clientFactsSnapshot, clientFactsState } from '../fixtures/client-info-facts';
vi.mock('../../src/app/AppProvider', () => { const useApp = vi.fn(); return { useApp, useOptionalApp: useApp }; });
vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => <span>Synthetic assessment selector</span> }));
vi.mock('../../src/features/context-layer/use-context-cluster-index', () => ({ useContextClusterIndex: () => undefined }));
function fixture(projectId = 'synthetic-project', assessmentId = 'synthetic-assessment') {
const sheet = makeSheet('Synthetic', [['Name', 'Status', 'Severity', 'Source Rule ID', 'Source: Scan', 'Description', 'Remediation'],
['Synthetic admin A', 'Failed', 'High', 'MT.1014', 'Maester', 'Synthetic explanation A', 'Synthetic exact remediation A'],
['Synthetic admin B', 'Failed', 'High', 'CISA.MS.AAD.3.6', 'Maester', 'Synthetic explanation B', 'Synthetic exact remediation B'],
['Synthetic external control', 'Failed', 'Medium', 'MT.1056', 'Maester', 'Synthetic external explanation', 'Synthetic external remediation'],
['Synthetic unassigned control', 'Failed', 'Low', 'SYNTHETIC.UNASSIGNED', 'Maester', 'Synthetic unassigned explanation', 'Synthetic unassigned remediation']
], '', 'synthetic.csv', { isProjectSheet: true });
sheet.analysis.records.forEach((record, index) => { record.findingOccurrenceId = `${projectId}-occ-${index}`; });
const findings = sheet.analysis.records.map(record => ({ key: scannerFindingKey(record.findingOccurrenceId!), occurrenceId: record.findingOccurrenceId,
tool: 'maester', ruleId: record.fields[3].value, title: record.title, outcome: 'fail' }));
const result = buildWorkPackages({ projectId, assessmentIds: [assessmentId], scanRunIds: ['synthetic-run'], findings,
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' } }],
findings: findings.slice(0, 3).map((finding, index) => ({ occurrenceId: finding.occurrenceId!,
clusterIds: [index < 2 ? 'cloud-privileged-access-exposure' : 'cloud-external-access-exposure'], conditionIds: [] })), settings: [] }
});
const app = { ...clientFactsState(), targetProjectId: projectId, activeAssessmentIds: [assessmentId], activeSheet: sheet,
dataset: { fileName: `Synthetic ${projectId}`, sheets: [sheet], scanTool: 'Generic' },
search: 'retained Work search', statusFilter: 'passed', facetFilters: { severity: { values: ['High'] } }, setFacetFilter: vi.fn() };
return { app, result };
}
function setup() {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); localStorage.clear(); resetEnginePreferencesForTests(); resetWorkPackageRequestsForTests();
setEngineEnabled('context', false);
vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue(clientFactsSnapshot);
vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never);
return vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext');
}
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren(); localStorage.clear(); resetEnginePreferencesForTests(); resetWorkPackageRequestsForTests(); });
describe.each(['light', 'dark'])('Client Info Work Packages (%s)', theme => {
it('opens packages and original finding details without marking any Work Packages table row blue', async () => {
setup(); document.documentElement.dataset.theme = theme;
const { app, result } = fixture();
vi.mocked(useApp).mockReturnValue(app as unknown as ReturnType<typeof useApp>);
vi.spyOn(AssessorApiClient.prototype, 'getWorkPackages').mockResolvedValue({ status: 'ready', result });
const original = JSON.stringify(app.dataset); const openWork = vi.fn();
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
const stopMarking = installTableRowMarking(document);
try {
await act(async () => root.render(<DashboardPanel onOpenWork={openWork}/>));
await act(async () => [...host.querySelectorAll<HTMLButtonElement>('[role="tab"]')].find(tab => tab.textContent === 'Work Packages')!.click());
const panel = host.querySelector<HTMLElement>('[data-group-scroll="client-info-work-packages"]')!;
const packageButton = panel.querySelector<HTMLButtonElement>('[data-work-package-select]')!;
await act(async () => packageButton.click());
expect(packageButton.getAttribute('aria-expanded')).toBe('true');
const finding = panel.querySelector<HTMLButtonElement>('.client-security-finding-toggle')!;
await act(async () => finding.click());
expect(finding.getAttribute('aria-expanded')).toBe('true');
const details = panel.querySelector<HTMLElement>('.client-security-finding-detail')!;
const record = app.activeSheet.analysis.records.find(record => record.title === finding.querySelector('strong')!.textContent)!;
expect(details.textContent).toContain(record.fields[5].value);
expect(details.textContent).toContain(record.fields[6].value);
await act(async () => details.click());
expect(panel.querySelector('[data-row-marked="true"]')).toBeNull();
await act(async () => panel.querySelector<HTMLButtonElement>('[data-work-package-unassigned-summary]')!.click());
expect(panel.querySelector('[data-work-package-unassigned-details]')?.textContent).toContain('Synthetic unassigned control');
expect(panel.querySelector('[data-row-marked="true"]')).toBeNull();
// Other tables retain the established reading marker.
const table = document.createElement('table'); table.innerHTML = '<tbody><tr><td>Other table</td></tr></tbody>'; host.append(table);
table.querySelector('td')!.click(); expect(table.querySelector('tr')!.getAttribute('data-row-marked')).toBe('true');
expect(openWork).not.toHaveBeenCalled(); expect(JSON.stringify(app.dataset)).toBe(original);
} finally { stopMarking(); await act(async () => root.unmount()); host.remove(); }
});
it('keeps Security and Work Packages unchanged after all Work/Graph filters and the selected source sheet change', async () => {
setup(); document.documentElement.dataset.theme = theme;
const { app, result } = fixture();
vi.mocked(useApp).mockReturnValue(app as unknown as ReturnType<typeof useApp>);
const packages = vi.spyOn(AssessorApiClient.prototype, 'getWorkPackages').mockResolvedValue({ status: 'ready', result });
const openWork = vi.fn(); const original = JSON.stringify(app.dataset);
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
const render = () => root.render(<DashboardPanel onOpenWork={openWork}/>);
const tab = (name: string) => [...host.querySelectorAll<HTMLButtonElement>('[role="tab"]')].find(button => button.textContent === name)!;
try {
await act(async () => render()); await act(async () => tab('Security').click());
const security = host.querySelector<HTMLElement>('[data-group-scroll="client-info-security"]')!;
await act(async () => security.querySelector<HTMLButtonElement>('.client-security-group-toggle')!.click());
expect(security.querySelectorAll('.client-security-finding-toggle')).toHaveLength(4);
expect(security.querySelector('.client-security-remediation strong')?.textContent).toBe('4');
const securityContent = security.textContent;
await act(async () => tab('Work Packages').click());
const wp = host.querySelector<HTMLElement>('[data-group-scroll="client-info-work-packages"]')!;
await act(async () => wp.querySelector<HTMLButtonElement>('[data-work-package-select]')!.click());
const wpContent = wp.textContent;
const subset = makeSheet('Selected source subset', [['Name', 'Status'], ['Only a source subset', 'Passed']], '', 'subset.csv');
const changed = { ...app, activeSheet: subset, activeProfile: { ...app.activeProfile, matchesStatus: () => false },
search: 'no matching title', statusFilter: 'passed', actionOnly: true, pathFindingIds: ['absent graph member'],
facetFilters: { 'provenance:source-scan': { values: ['Absent scanner'] }, severity: { values: ['Critical'] },
'context:cluster': { values: ['absent cluster'] }, 'report:business-impact': { values: ['absent impact'] },
'mitre:technique': { values: ['T0000'] }, arbitrary: { values: ['absent'] } } };
vi.mocked(useApp).mockReturnValue(changed as unknown as ReturnType<typeof useApp>);
await act(async () => render());
expect(wp.textContent).toBe(wpContent); expect(packages).toHaveBeenCalledTimes(1);
await act(async () => tab('Security').click());
expect(security.textContent).toBe(securityContent);
expect(security.querySelectorAll('.client-security-finding-toggle')).toHaveLength(4);
expect(openWork).not.toHaveBeenCalled(); expect(app.setFacetFilter).not.toHaveBeenCalled();
expect(JSON.stringify(app.dataset)).toBe(original);
expect(changed.facetFilters['provenance:source-scan'].values).toEqual(['Absent scanner']);
} finally { await act(async () => root.unmount()); host.remove(); }
});
it('opens the third tab lazily, retains filters and expands exact package findings and their original details locally', async () => {
const context = setup(); document.documentElement.dataset.theme = theme;
const { app, result } = fixture(); vi.mocked(useApp).mockReturnValue(app as unknown as ReturnType<typeof useApp>);
const packages = vi.spyOn(AssessorApiClient.prototype, 'getWorkPackages').mockResolvedValue({ status: 'ready', result });
const original = JSON.stringify({ app, result }); const openWork = vi.fn();
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<DashboardPanel onOpenWork={openWork}/>));
const tabs = [...host.querySelectorAll<HTMLButtonElement>('[role="tab"]')];
expect(tabs.map(tab => tab.textContent)).toEqual(['Overview', 'Security', 'Work Packages']);
expect(packages).not.toHaveBeenCalled();
await act(async () => tabs[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })));
const panel = host.querySelector<HTMLElement>('[data-group-scroll="client-info-work-packages"]')!;
expect(panel.hidden).toBe(false); expect(document.activeElement).toBe(tabs[2]);
expect(packages).toHaveBeenCalledWith('synthetic-project', ['synthetic-assessment'], ['synthetic-run']);
expect(context).not.toHaveBeenCalled();
expect(panel.querySelector('.floating-data-window')).toBeNull();
expect(panel.querySelectorAll('[data-work-package-row]')).toHaveLength(result.packages.length);
expect(panel.querySelector('[aria-label="Package totals"]')?.textContent).toContain('Findings4');
const filter = panel.querySelector<HTMLSelectElement>('[aria-label="Filter Package"]')!;
const selectedPackage = result.packages[0];
await act(async () => { filter.value = selectedPackage.id; filter.dispatchEvent(new Event('change', { bubbles: true })); });
expect(panel.querySelectorAll('[data-work-package-row]')).toHaveLength(1);
await act(async () => panel.querySelector<HTMLButtonElement>('[data-sort-column="findings"]')!.click());
await act(async () => tabs[0].click());
await act(async () => tabs[2].click());
expect(filter.value).toBe(selectedPackage.id); expect(packages).toHaveBeenCalledTimes(1);
expect(panel.querySelector('[aria-sort="descending"] button')?.textContent).toBe('Findings');
await act(async () => panel.querySelector<HTMLButtonElement>(`[data-work-package-select="${selectedPackage.id}"]`)!.click());
const packageButton = panel.querySelector<HTMLButtonElement>(`[data-work-package-select="${selectedPackage.id}"]`)!;
const detailRow = panel.querySelector<HTMLTableRowElement>(`[data-work-package-details="${selectedPackage.id}"]`)!;
expect(packageButton.getAttribute('aria-expanded')).toBe('true'); expect(detailRow.hidden).toBe(false);
expect(detailRow.previousElementSibling?.getAttribute('data-work-package-row')).toBe(selectedPackage.id);
const expected = app.activeSheet.analysis.records.filter(record => selectedPackage.findingKeys.includes(workRecordKey(record)));
expect([...detailRow.querySelectorAll('.client-security-finding-toggle strong')].map(title => title.textContent)).toEqual(expected.map(record => record.title));
const finding = detailRow.querySelector<HTMLButtonElement>('.client-security-finding-toggle')!;
await act(async () => finding.click());
expect(finding.getAttribute('aria-expanded')).toBe('true');
expect(detailRow.textContent).toContain(expected[0].fields[5].value);
expect(detailRow.textContent).toContain(expected[0].fields[6].value);
await act(async () => tabs[0].click()); await act(async () => tabs[2].click());
expect(packageButton.getAttribute('aria-expanded')).toBe('true'); expect(finding.getAttribute('aria-expanded')).toBe('true');
await act(async () => packageButton.click());
expect(detailRow.hidden).toBe(true); expect(detailRow.querySelector('.client-security-finding-toggle')).toBeNull();
await act(async () => panel.querySelector<HTMLButtonElement>('[data-work-package-unassigned-summary]')!.click());
expect(panel.querySelector('[data-work-package-unassigned-details]')?.textContent).toContain('Synthetic unassigned control');
expect(filter.value).toBe('');
expect(panel.querySelector('[title*="in Work"]')).toBeNull();
expect(app.setFacetFilter).not.toHaveBeenCalled(); expect(openWork).not.toHaveBeenCalled();
expect(JSON.stringify({ app, result })).toBe(original);
} finally { await act(async () => root.unmount()); host.remove(); }
});
it('clears the old scope while loading another client/assessment and suppresses late packages when Remediation is disabled', async () => {
const context = setup(); document.documentElement.dataset.theme = theme;
const first = fixture(), second = fixture('synthetic-other-client', 'synthetic-other-assessment');
vi.mocked(useApp).mockReturnValue(first.app as unknown as ReturnType<typeof useApp>);
let finish!: (response: WorkPackagesResponse) => void;
const packages = vi.spyOn(AssessorApiClient.prototype, 'getWorkPackages').mockResolvedValueOnce({ status: 'ready', result: first.result })
.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
const render = () => root.render(<DashboardPanel onOpenWork={() => {}}/>);
try {
await act(async () => render());
await act(async () => [...host.querySelectorAll<HTMLButtonElement>('[role="tab"]')].find(tab => tab.textContent === 'Work Packages')!.click());
expect(host.querySelectorAll('[data-work-package-row]')).toHaveLength(first.result.packages.length);
vi.mocked(useApp).mockReturnValue(second.app as unknown as ReturnType<typeof useApp>);
await act(async () => render());
expect(packages).toHaveBeenLastCalledWith('synthetic-other-client', ['synthetic-other-assessment'], ['synthetic-run']);
expect(host.querySelector('[data-work-package-row]')).toBeNull();
expect(host.textContent).toContain('Loading work packages');
await act(async () => setEngineEnabled('remediation', false));
expect([...host.querySelectorAll('[role="tab"]')].map(tab => tab.textContent)).toEqual(['Overview', 'Security']);
expect(host.querySelector('[role="tabpanel"]:not([hidden])')?.getAttribute('data-group-scroll')).toBe('client-info-overview');
await act(async () => finish({ status: 'ready', result: second.result }));
expect(host.querySelector('[data-work-package-row]')).toBeNull();
expect(packages).toHaveBeenCalledTimes(2); expect(context).not.toHaveBeenCalled();
} finally { await act(async () => root.unmount()); host.remove(); }
});
it('reads all exact package and same-change members from the project Work sheet even when another source sheet is selected', async () => {
setup(); document.documentElement.dataset.theme = theme;
const { app, result } = fixture();
const grouped = result.packages.find(p => p.findingKeys.length === 2)!;
grouped.tasks = [{ id: 'synthetic-same-change', findingKeys: [...grouped.findingKeys], redundant: true }];
grouped.facts.tasks = 1; grouped.facts.redundantFindings = 2;
const sourceSheet = makeSheet('Synthetic source subset', [['Name', 'Status'], ['A different source row', 'Passed']], '', 'source.csv');
app.dataset.sheets.push(sourceSheet); app.activeSheet = sourceSheet;
vi.mocked(useApp).mockReturnValue(app as unknown as ReturnType<typeof useApp>);
vi.spyOn(AssessorApiClient.prototype, 'getWorkPackages').mockResolvedValue({ status: 'ready', result });
const original = JSON.stringify({ app, result }); const openWork = vi.fn();
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<DashboardPanel onOpenWork={openWork}/>));
await act(async () => [...host.querySelectorAll<HTMLButtonElement>('[role="tab"]')].find(tab => tab.textContent === 'Work Packages')!.click());
await act(async () => host.querySelector<HTMLButtonElement>(`[data-work-package-select="${grouped.id}"]`)!.click());
const details = host.querySelector(`[data-work-package-details="${grouped.id}"]`)!;
expect([...details.querySelectorAll('.client-security-finding-toggle strong')].map(title => title.textContent).sort()).toEqual(['Synthetic admin A', 'Synthetic admin B']);
expect(details.textContent).not.toContain('A different source row');
await act(async () => host.querySelector<HTMLButtonElement>('[data-work-package-related-summary]')!.click());
const related = host.querySelector<HTMLElement>('[data-work-package-related-summary]')!;
const relatedDetails = document.getElementById(related.getAttribute('aria-controls')!)!;
expect(relatedDetails.hidden).toBe(false);
expect(relatedDetails.querySelectorAll('.client-security-finding-toggle')).toHaveLength(2);
await act(async () => related.click()); expect(relatedDetails.hidden).toBe(true);
expect(app.setFacetFilter).not.toHaveBeenCalled(); expect(openWork).not.toHaveBeenCalled();
expect(JSON.stringify({ app, result })).toBe(original);
} finally { await act(async () => root.unmount()); host.remove(); }
});
});

View File

@ -6,6 +6,8 @@ import { dashboardCisCatSeverityRemainder, dashboardDomainInformation, dashboard
import { securityDomainCatalog } from '../../src/features/dashboard/security-domains';
const component = readFileSync(new URL('../../src/features/dashboard/DashboardPanel.tsx', import.meta.url), 'utf8');
const overviewComponent = component.slice(component.indexOf('<><ClientInfoTabs tabs=')).split("}, { id: 'security'")[0];
const securityComponent = component.slice(component.indexOf('const securityPanelView ='), component.indexOf('async function exportSecurityReport'));
const styles = readFileSync(new URL('../../src/styles/datahub.css', import.meta.url), 'utf8');
describe('dashboard reference layout', () => {
@ -22,7 +24,7 @@ describe('dashboard reference layout', () => {
it('uses the requested summary-to-action client information hierarchy', () => {
expect(component).toContain('className="client-overview-header"');
expect(component).toContain('<WorkTimeslotControl />');
expect(component.match(/<DashboardTopFact/g)).toHaveLength(6);
expect(overviewComponent.match(/<DashboardTopFact/g)).toHaveLength(6);
expect(component).toContain('className="client-overview-main"');
expect(component).toContain('Overall Score');
expect(component).toContain('Security Domains');
@ -84,7 +86,8 @@ describe('dashboard reference layout', () => {
expect(styles).toMatch(/\.client-environment-sources ul\s*\{[^}]*list-style:\s*none;/s);
expect(styles).toMatch(/\.client-environment-sources li > button\s*\{[^}]*cursor:\s*pointer;/s);
expect(styles).not.toContain('.client-source-overview');
expect(component).toContain('dismissResults(); setScoringOpen(true);');
expect(component).toContain("dismissResults(); setScoringOpen('overview');");
expect(component).toContain("setScoringOpen('security')");
expect(styles).toMatch(/\.client-overall-ring-track\s*\{\s*stroke:\s*var\(--donut-track\);/);
expect(component).toContain('<span className="client-domain-copy"><strong>{domain.label}</strong></span>');
expect(component).toContain('className="client-domain-amount-bar" aria-hidden="true"');
@ -131,7 +134,8 @@ describe('dashboard reference layout', () => {
.forEach(label => expect(component).toContain(`'${label}'`));
expect(styles).toMatch(/\.client-priority-context\s*\{[^}]*grid-template-columns:\s*repeat\(5, minmax\(0, 1fr\)\);/s);
expect(component.indexOf('className="client-data-facts"')).toBeGreaterThan(component.indexOf('className="client-priority-context"'));
expect((component.match(/<DashboardTopFact /g) ?? [])).toHaveLength(6);
expect((overviewComponent.match(/<DashboardTopFact /g) ?? [])).toHaveLength(6);
expect((securityComponent.match(/<DashboardTopFact /g) ?? [])).toHaveLength(6);
expect(styles).not.toContain('.client-priority-findings ol');
expect(styles).not.toContain('.client-priority-findings { display: grid;');
expect(styles).toMatch(/\.client-domain-information > header h2\s*\{[^}]*font-size:\s*var\(--type-h4-size\);/s);

View File

@ -41,6 +41,99 @@ const snapshot: EnvironmentSnapshot = {
afterEach(() => { vi.unstubAllGlobals(); document.body.replaceChildren(); });
describe.each(['light', 'dark'])('Complete scanner table pagination (%s)', theme => {
it.each([100, 800, 1000])('keeps %i real rows reachable in inline and linked tables when changing the page size', async count => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
document.documentElement.dataset.theme = theme;
const notice = 'Output limited to 100 items - go to the advanced menu before running the report or add "--no-enum-limit" to remove that limit';
const rows = Array.from({ length: count }, (_, index) => [`Synthetic-${index + 1}`, '2026-01-01']);
if (count === 100) rows.unshift([notice, notice]);
const data: EnvironmentSnapshot = { ...snapshot, sections: [{ ...section('sectionUserInformation', 'User Information', []), tables: [
{ name: 'Account analysis', presentation: 'inline', headers: ['Name', 'Creation'], rows,
cellLinks: [{ rowIndex: rows.length - 1, columnIndex: 0, targetId: 'accounts' }] },
{ name: 'Account details', title: 'Account details', presentation: 'modal', targetId: 'accounts', headers: ['Name', 'Creation'], rows }
] }] };
const original = JSON.stringify(data);
const container = document.createElement('div'); document.body.append(container); const root = createRoot(container);
const changeSize = async (scope: Element, size: number) => {
const select = scope.querySelector<HTMLSelectElement>('select')!;
await act(async () => { select.value = String(size); select.dispatchEvent(new Event('change', { bubbles: true })); });
};
const checkPages = async (scope: Element) => {
await changeSize(scope, 100);
expect(scope.querySelectorAll('tbody tr')).toHaveLength(100);
const pages = Math.ceil(count / 100);
for (let page = 1; page < pages; page++) await act(async () => [...scope.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Next')!.click());
expect(scope.querySelector('tbody')!.textContent).toContain(`Synthetic-${count}`);
expect([...scope.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Next')!.disabled).toBe(true);
await changeSize(scope, 1000);
expect(scope.querySelectorAll('tbody tr')).toHaveLength(count);
expect(scope.querySelector('.client-env-table-pagination')!.textContent).toContain(`Showing 1–${count} of ${count} rows`);
expect(scope.querySelector('tbody')!.textContent).not.toContain('Output limited');
expect(scope.querySelectorAll('[role="note"]')).toHaveLength(count === 100 ? 1 : 0);
if (count === 100) expect(scope.querySelector('[role="note"]')!.textContent).toContain(notice);
};
try {
await act(async () => root.render(<EnvironmentResultsButton title="User Accounts"><EnvironmentEvidenceTables snapshot={data} group="user-accounts"/></EnvironmentResultsButton>));
await act(async () => container.querySelector<HTMLButtonElement>('button')!.click());
const inline = document.querySelector('.client-env-evidence-table')!;
await checkPages(inline);
await act(async () => inline.querySelector<HTMLButtonElement>('.client-env-table-cell-link')!.click());
const dialog = document.querySelector('.client-env-table-dialog')!;
await checkPages(dialog);
const search = dialog.querySelector<HTMLInputElement>('input[type="search"]')!;
await act(async () => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(search, `Synthetic-${count}`); search.dispatchEvent(new Event('input', { bubbles: true })); });
expect(dialog.querySelectorAll('tbody tr')).toHaveLength(1);
expect(dialog.querySelector('tbody')!.textContent).toContain(`Synthetic-${count}`);
expect(JSON.stringify(data)).toBe(original);
} finally { await act(async () => root.unmount()); container.remove(); }
});
});
describe.each(['light', 'dark'])('Inventory detail layering (%s)', theme => {
it('opens an activated Inventory row and its nested table above the Inventory window, including prose details', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
document.documentElement.dataset.theme = theme;
const adminSection: ScannerReportSection = { ...section('sectionAdminGroups', 'Admin Groups', []),
tables: [
{ name: 'Admin groups', presentation: 'inline', headers: ['Group'], rows: [['Schema Administrators'], ['Operator graph']],
cellLinks: [{ rowIndex: 0, columnIndex: 0, targetId: 'schema' }, { rowIndex: 1, columnIndex: 0, targetId: 'graph' }] },
{ name: 'Schema Administrators', title: 'Schema Administrators', presentation: 'modal', targetId: 'schema',
headers: ['Account'], rows: [['Synthetic member']], cellLinks: [{ rowIndex: 0, columnIndex: 0, targetId: 'member' }] },
{ name: 'Member', title: 'Member', presentation: 'modal', targetId: 'member', headers: ['Account'], rows: [['Retained member evidence']] }
], modalDetails: [{ targetId: 'graph', title: 'Operator graph', prose: 'Retained graph detail.', interactiveGraph: true }] };
const data = { ...snapshot, sections: [adminSection] };
const original = JSON.stringify(data);
const container = document.createElement('div'); document.body.append(container); const root = createRoot(container);
try {
await act(async () => root.render(<EnvironmentResultsButton title="Inventory"><EnvironmentEvidenceTables snapshot={data} group="inventory"/></EnvironmentResultsButton>));
await act(async () => container.querySelector<HTMLButtonElement>('button')!.click());
const inventory = document.querySelector<HTMLElement>('.floating-data-window')!;
await act(async () => inventory.dispatchEvent(new Event('pointerdown', { bubbles: true })));
expect(Number(inventory.style.zIndex)).toBeGreaterThan(2700);
await act(async () => inventory.querySelector<HTMLButtonElement>('.client-env-table-cell-link')!.click());
const aboveInventory = () => {
const backdrop = document.querySelector<HTMLElement>('.client-env-table-backdrop')!;
expect(Number(backdrop.style.zIndex)).toBeGreaterThan(Number(inventory.style.zIndex));
expect(backdrop.contains(document.activeElement)).toBe(true);
};
aboveInventory();
expect(document.querySelector('.client-env-table-dialog h2')!.textContent).toBe('Schema Administrators');
await act(async () => document.querySelector<HTMLButtonElement>('.client-env-table-dialog .client-env-table-cell-link')!.click());
expect(document.querySelector('.client-env-table-dialog h2')!.textContent).toBe('Member');
await act(async () => document.querySelector('.client-env-table-dialog')!.dispatchEvent(new Event('pointerdown', { bubbles: true })));
aboveInventory();
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Close Member table"]')!.click());
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Close Schema Administrators table"]')!.click());
expect(document.querySelector('.floating-data-window')).toBe(inventory);
await act(async () => inventory.querySelectorAll<HTMLButtonElement>('.client-env-table-cell-link')[1].click());
aboveInventory();
expect(document.querySelector('.client-env-table-dialog')!.textContent).toContain('Retained graph detail.');
expect(JSON.stringify(data)).toBe(original);
} finally { await act(async () => root.unmount()); container.remove(); }
});
});
describe('Environment Results source evidence', () => {
it('opens Directory Results and native details without generated labels or legacy source-text controls', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);

View File

@ -0,0 +1,66 @@
// @vitest-environment jsdom
import { act, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { FindingRecord } from '../../src/domain/finding';
import { TileCard } from '../../src/components/TileCard/TileCard';
import { FindingReader } from '../../src/features/finding-reader/FindingReader';
afterEach(() => { vi.unstubAllGlobals(); document.body.replaceChildren(); });
describe.each(['light', 'dark'])('Finding badge order (%s)', theme => {
it.each([
['pingcastle', 'Severity'], ['purple-knight', 'Severity'], ['maester', 'Severity'],
['cis-cat', 'Severity'], ['group-policy', 'Severity'], ['generic', 'Criticality'], ['generic', 'Priority']
])('keeps Outcome then %s severity (%s) ahead of native/derived categories and numeric metadata when opening a compact tile', async (toolId, severityLabel) => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
document.documentElement.dataset.theme = theme;
const record: FindingRecord = {
id: 'synthetic-badge-order', index: 0, title: '2.2.2 Ensure a synthetic setting', filled: [], meta: [],
fields: [['Domain Risk Level', '100'], ['Points', '20'], ['Category', 'Synthetic category'],
['Weight', '8'], ['Score', '73'], ['Status', 'Fail'], [severityLabel, 'Medium'],
['Rule ID', '2.2.2'], ['Baseline', 'Windows Server 2022 Security Baseline'], ['Scope', 'computer']]
.map(([label, value], index) => ({ label, value, index }))
};
const original = JSON.stringify(record);
function Reading() {
const [open, setOpen] = useState(false);
return open ? <FindingReader record={record} title={record.title} toolId={toolId} linkedSheets={[]}
resultReferences={[]} search="" position={0} recordCount={1} onPrevious={() => {}} onNext={() => {}} onReview={() => {}}/>
: <TileCard record={record} title={record.title} toolId={toolId} badgeLimit={3} bookmarked={false} onOpen={() => setOpen(true)} onBookmark={() => {}}/>;
}
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<Reading/>));
const tileBadges = [...host.querySelector('.data-tile-badges')!.children];
expect(tileBadges[0].getAttribute('data-badge-role')).toBe('status');
expect(tileBadges[1].getAttribute('data-badge-role')).toBe('severity');
expect(tileBadges[1].textContent).toBe('Medium');
expect(tileBadges[2].getAttribute('data-badge-role')).toBe('category');
await act(async () => host.querySelector<HTMLElement>('.data-tile')!.click());
const readerBadges = [...host.querySelector('.finding-badges')!.children];
expect(readerBadges[0].classList.contains('outcome-mark')).toBe(true);
expect(readerBadges[1].classList.contains('severity-medium')).toBe(true);
expect(readerBadges[1].textContent).toBe('Medium');
expect(readerBadges.filter(badge => badge.classList.contains('severity-badge'))).toHaveLength(1);
expect(readerBadges.slice(2).some(badge => badge.textContent === 'Domain Risk Level: 100 / 100')).toBe(true);
expect(readerBadges.slice(2).some(badge => badge.textContent === 'Points: 20')).toBe(true);
expect(JSON.stringify(record)).toBe(original);
} finally { await act(async () => root.unmount()); host.remove(); }
});
it('omits unavailable Severity without creating a placeholder or hiding the category', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
document.documentElement.dataset.theme = theme;
const record: FindingRecord = { id: 'synthetic-no-severity', index: 0, title: 'Synthetic finding', filled: [], meta: [],
fields: [{ label: 'Status', value: 'Failed', index: 0 }, { label: 'Category', value: 'Synthetic category', index: 1 }] };
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<><TileCard record={record} title={record.title} bookmarked={false} onOpen={() => {}} onBookmark={() => {}}/>
<FindingReader record={record} title={record.title} linkedSheets={[]} resultReferences={[]} search="" position={0} recordCount={1}
onPrevious={() => {}} onNext={() => {}} onReview={() => {}}/></>));
expect([...host.querySelector('.data-tile-badges')!.children].map(b => b.textContent)).toEqual(['Failed', 'Synthetic category']);
expect([...host.querySelector('.finding-badges')!.children].map(b => b.textContent)).toEqual(['Failed', 'Category: Synthetic category']);
} finally { await act(async () => root.unmount()); host.remove(); }
});
});

View File

@ -0,0 +1,76 @@
// @vitest-environment jsdom
import { act, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { FindingRecord } from '../../src/domain/finding';
import { TileCard } from '../../src/components/TileCard/TileCard';
import { FindingReader } from '../../src/features/finding-reader/FindingReader';
const record: FindingRecord = {
id: 'synthetic-removable-scan', index: 0, title: 'Synthetic removable drive scan check', filled: [], meta: [],
fields: [['Source: Scan', 'Maester'], ['Source Rule ID', 'MT.1154'], ['Result', 'Failed'],
['Severity', 'High'], ['Block', 'Maester/Defender'], ['Tag', '["Maester","Defender","MT.1154"]'],
['Description', 'Synthetic scanner description']].map(([label, value], index) => ({ label, value, index }))
};
const noop = () => {};
function WorkReading({ finding, toolId }: { finding: FindingRecord; toolId: string }) {
const [opened, setOpened] = useState(false);
return opened ? <><button onClick={() => setOpened(false)}>Back to tiles</button>
<FindingReader record={finding} title={finding.title} toolId={toolId} linkedSheets={[]} resultReferences={[]}
search="" position={0} recordCount={1} onPrevious={noop} onNext={noop} onReview={noop}/></>
: <TileCard record={finding} title={finding.title} toolId={toolId} badgeLimit={3} bookmarked={false}
onOpen={() => setOpened(true)} onBookmark={noop}/>;
}
afterEach(() => { vi.unstubAllGlobals(); document.body.replaceChildren(); });
describe.each(['light', 'dark'])('Native Maester category badges (%s)', theme => {
it.each(['maester', 'generic'])('keeps the category visible when opening compact tiles by mouse and keyboard in a %s workspace', async toolId => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
document.documentElement.dataset.theme = theme;
const original = JSON.stringify(record);
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<WorkReading finding={record} toolId={toolId}/>));
for (const activation of ['mouse', 'keyboard']) {
const tile = host.querySelector<HTMLElement>('.data-tile')!;
expect(tile.querySelector('[data-badge-role="category"]')?.textContent).toBe('Maester/Defender');
expect(tile.querySelector('[data-badge-role="status"]')?.textContent).toBe('Failed');
expect([...tile.querySelector('.data-tile-badges')!.children].map(badge => badge.textContent)).toEqual(['Failed', 'High', 'Maester/Defender']);
await act(async () => {
if (activation === 'mouse') tile.click();
else tile.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
});
const badges = host.querySelector('.finding-badges')!;
expect([...badges.querySelectorAll('.status-badge')].map(badge => badge.textContent)).toEqual(['Category: Maester/Defender']);
expect(badges.querySelector('.severity-badge')?.textContent).toBe('High');
expect(badges.querySelector('.outcome-mark')?.textContent).toBe('Failed');
expect([...badges.children].map(badge => badge.textContent)).toEqual(['Failed', 'High', 'Category: Maester/Defender']);
expect(host.querySelector('.reader-summary')?.textContent).toContain('CategoryMaester/Defender');
expect(JSON.stringify(record)).toBe(original);
await act(async () => host.querySelector<HTMLButtonElement>('button')!.click());
}
} finally { await act(async () => root.unmount()); host.remove(); }
});
it('preserves explicit categories and does not turn missing or non-Maester Block fields into categories', 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 cases = [
{ finding: { ...record, fields: [...record.fields, { label: 'Category', value: 'Explicit scanner category', index: 8 }] }, toolId: 'maester', category: 'Explicit scanner category' },
{ finding: { ...record, fields: record.fields.map(field => field.label === 'Block' ? { ...field, value: ' ' } : field) }, toolId: 'maester', category: undefined },
{ finding: { ...record, fields: record.fields.filter(field => field.label !== 'Source: Scan') }, toolId: 'purple-knight', category: undefined }
];
try {
for (const { finding, toolId, category } of cases) {
await act(async () => root.render(<WorkReading key={`${toolId}:${category}`} finding={finding} toolId={toolId}/>));
expect(host.querySelector('[data-badge-role="category"]')?.textContent).toBe(category);
await act(async () => host.querySelector<HTMLElement>('.data-tile')!.click());
const categoryBadge = [...host.querySelectorAll('.finding-badges .status-badge')].find(badge => badge.textContent?.startsWith('Category:'));
expect(categoryBadge?.textContent).toBe(category ? `Category: ${category}` : undefined);
}
} finally { await act(async () => root.unmount()); host.remove(); }
});
});

View File

@ -0,0 +1,144 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, 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 { resetAssessmentContextRequestsForTests } from '../../src/features/context-layer/use-assessment-context';
import { DashboardPanel } from '../../src/features/dashboard/DashboardPanel';
import { browserDownloadAdapter } from '../../src/platform/browser/browser-download-adapter';
import { makeSheet } from '../../src/importers/sheet-factory';
import { emptyHeatmapContext } from '../assessment-context/context-heatmap-fixture';
import { clientFactsSnapshot, clientFactsState } from '../fixtures/client-info-facts';
vi.mock('../../src/app/AppProvider', () => { const useApp = vi.fn(); return { useApp, useOptionalApp: useApp }; });
vi.mock('../../src/features/search-filter/WorkTimeslotControl', () => ({ WorkTimeslotControl: () => <span>Synthetic assessment</span> }));
// Vitest suppresses CSS modules in jsdom; use the actual style source for the export assertion.
vi.mock('../../src/styles/ui-tokens.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/ui-tokens.css', 'utf8') }));
vi.mock('../../src/styles/tokens.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/tokens.css', 'utf8') }));
vi.mock('../../src/styles/base.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/base.css', 'utf8') }));
vi.mock('../../src/styles/components.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/components.css', 'utf8') }));
vi.mock('../../src/styles/datahub.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/datahub.css', 'utf8') }));
vi.mock('../../src/styles/client-security.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/client-security.css', 'utf8') }));
vi.mock('../../src/styles/policy-analysis.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/policy-analysis.css', 'utf8') }));
vi.mock('../../src/styles/security-scoring.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/security-scoring.css', 'utf8') }));
vi.mock('../../src/styles/functional-icons.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/functional-icons.css', 'utf8') }));
function setup(theme: string) {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); localStorage.clear(); resetEnginePreferencesForTests(); resetAssessmentContextRequestsForTests();
document.documentElement.dataset.theme = theme; document.documentElement.dataset.density = 'reading';
const sheet = makeSheet('Synthetic full assessment', [['Name', 'Status', 'Severity', 'Description', 'Remediation', 'Source: Scan'],
['Synthetic privileged A', 'Failed', 'High', '<p>Original explanation A.</p>', 'Original remediation A.', 'Maester'],
['Synthetic privileged B', 'Failed', 'Medium', 'Original explanation B.', 'Original remediation B.', 'Purple Knight'],
['Synthetic passed', 'Passed', 'High', 'Passed evidence', '', 'Maester'],
['Synthetic unassessed', 'Not configured', '', 'No verdict', '', 'GPOs']
], '', 'synthetic.csv', { isProjectSheet: true });
const context = emptyHeatmapContext();
const cluster = context.clusters.find(item => item.clusterId === 'cloud-privileged-access-exposure')!;
sheet.analysis.records.slice(0, 2).forEach((record, index) => {
const occurrenceId = `synthetic-${index}`; record.findingOccurrenceId = occurrenceId;
context.observations.push({ id: occurrenceId, conditionId: cluster.conditions[0].conditionId, occurrenceId, resultProjectionId: occurrenceId,
tool: 'maester', sourceRuleId: 'SYNTHETIC', ruleText: record.title, matchedBy: 'rule-id', outcome: 'fail', assertion: 'observed-by-scanner' });
context.findings.push({ occurrenceId, resultProjectionId: occurrenceId, tool: 'maester', sourceRuleId: 'SYNTHETIC', observationIds: [occurrenceId],
conditionIds: [cluster.conditions[0].conditionId], clusterIds: [cluster.clusterId], declaredReferenceIds: [], cisControlAreaIds: [] });
});
const snapshot = structuredClone(clientFactsSnapshot);
snapshot.sections[0].tables.push({ name: 'Synthetic 1000 rows', headers: ['Native account'], rows: Array.from({ length: 1000 }, (_, index) => [`Native row ${index}`]), cellLinks: [{ rowIndex: 0, columnIndex: 0, targetId: 'modal' }] });
snapshot.sections[0].tables.push({ name: 'Synthetic linked detail', headers: ['Native detail'], rows: [['Last modal evidence']], presentation: 'modal', targetId: 'modal' });
const app = { ...clientFactsState(), activeSheet: sheet, dataset: { fileName: 'Synthetic <client>', sheets: [sheet], scanTool: 'Generic' },
search: 'absent Work search', statusFilter: 'passed', actionOnly: true, pathFindingIds: ['absent Graph row'],
facetFilters: { 'provenance:source-scan': { values: ['Absent source'] } } };
vi.mocked(useApp).mockReturnValue(app as unknown as ReturnType<typeof useApp>);
vi.spyOn(AssessorApiClient.prototype, 'getEnvironmentSnapshot').mockResolvedValue(snapshot);
vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never);
vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(context);
const health = vi.spyOn(AssessorApiClient.prototype, 'health').mockResolvedValue({ repositoryRevision: 1, productVersion: 'synthetic' } as never);
vi.spyOn(AssessorApiClient.prototype, 'getProject').mockResolvedValue({ name: 'Synthetic <client>' } as never);
vi.spyOn(AssessorApiClient.prototype, 'listProjectAssessmentHistory').mockResolvedValue({ assessments: [{ id: 'synthetic-assessment', name: 'Synthetic timeslot', effectiveAt: '2026-10-12' }], scanRuns: [] } as never);
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
return { width: this.classList.contains('client-security-map') ? 1280 : 2320, height: 800, x: 0, y: 0, left: 0, top: 0, right: 2320, bottom: 800, toJSON() {} } as DOMRect;
});
const download = vi.spyOn(browserDownloadAdapter, 'download').mockImplementation(() => {});
return { app, snapshot, context, health, download };
}
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.replaceChildren(); localStorage.clear(); resetEnginePreferencesForTests(); resetAssessmentContextRequestsForTests(); });
describe.each(['light', 'dark'])('Complete Security HTML export (%s)', theme => {
it('exports the complete actual tab including closed details, all native rows, branding, confidentiality and exact scope without changing Work', async () => {
const fixture = setup(theme); const before = JSON.stringify(fixture); const openWork = vi.fn();
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<DashboardPanel onOpenWork={openWork}/>));
await act(async () => [...host.querySelectorAll<HTMLButtonElement>('[role="tab"]')].find(tab => tab.textContent === 'Security')!.click());
const panel = host.querySelector('.client-security-panel')!;
const originalSvg = panel.querySelector('.client-security-map svg')!.outerHTML;
expect(panel.querySelector('.client-security-finding-detail')).toBeNull();
const button = [...panel.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'HTML Security Export')!;
expect(button.disabled).toBe(false);
await act(async () => { button.click(); await vi.dynamicImportSettled(); });
expect(fixture.download).toHaveBeenCalledTimes(1);
const output = fixture.download.mock.calls[0][0];
if (process.env.ASSESSOR_SECURITY_EXPORT_PREVIEW_DIR) {
mkdirSync(process.env.ASSESSOR_SECURITY_EXPORT_PREVIEW_DIR, { recursive: true });
writeFileSync(join(process.env.ASSESSOR_SECURITY_EXPORT_PREVIEW_DIR, `synthetic-security-${theme}.html`), output.text);
}
expect(output.fileName).toMatch(/^ASSESSOR-Security-Synthetic-client-.*\.html$/);
const report = new DOMParser().parseFromString(output.text, 'text/html');
expect(report.documentElement.dataset.theme).toBe(theme); expect(report.documentElement.dataset.density).toBe('reading');
expect(report.querySelector('title')?.textContent).toContain('Synthetic <client>');
expect(report.querySelector('.security-export-confidential')?.textContent).toBe('VERTRAULICH');
expect(report.querySelector('img[alt="ASSESSOR Logo"]')?.getAttribute('src')).toMatch(/^data:image/);
expect(report.querySelector('style')?.textContent).toContain('width:2320px');
expect(/minmax\(0,\s*2fr\)\s+minmax\(0,\s*1fr\)/.test(report.querySelector('style')?.textContent ?? '')).toBe(true);
expect(report.querySelector('.client-security-remediation strong')?.textContent).toBe('2');
expect(report.querySelectorAll('.client-security-group')).toHaveLength(panel.querySelectorAll('.client-security-group').length);
expect(report.querySelectorAll('.client-security-finding')).toHaveLength(2);
for (const phrase of ['Original explanation A.', 'Original remediation B.', 'Native row 999', 'Last modal evidence', 'Revision 1', 'Synthetic timeslot', 'Assessment facts', 'Scoring methodology', 'Datenstand und Versionen']) expect(report.body.textContent).toContain(phrase);
const exportedSvg = report.querySelector('.client-security-map svg')!;
const nativeTable = [...report.querySelectorAll('table')].find(table => table.querySelector('th')?.textContent === 'Native account')!;
expect(nativeTable.querySelectorAll('tbody tr')).toHaveLength(1000);
const nestedTrigger = nativeTable.querySelector<HTMLButtonElement>('button[popovertarget]')!;
expect(nestedTrigger.textContent).toBe('Native row 0');
expect(report.getElementById(nestedTrigger.getAttribute('popovertarget')!)?.textContent).toContain('Last modal evidence');
const resultsTrigger = report.querySelector<HTMLButtonElement>('[aria-label="Open Assessment facts results"]')!;
const popup = report.getElementById(resultsTrigger.getAttribute('popovertarget')!)!;
expect(popup.getAttribute('popover')).toBe('auto'); expect(popup.getAttribute('role')).toBe('dialog');
expect(popup.closest('.security-export')).toBeNull();
expect(popup.querySelector('header')?.lastElementChild?.getAttribute('popovertargetaction')).toBe('hide');
expect(report.querySelector('style')?.textContent?.includes('zoom:min(1,calc(')).toBe(true);
expect(report.querySelector('style')?.textContent?.includes('.triangle-icon-right { transform: rotate(180deg); }')).toBe(true);
expect(report.querySelector('style')?.textContent?.includes('transform:rotate(-90deg)')).toBe(true);
expect(exportedSvg.getAttribute('viewBox')).toBe(new DOMParser().parseFromString(originalSvg, 'text/html').querySelector('svg')!.getAttribute('viewBox'));
expect(exportedSvg.querySelectorAll('path')).toHaveLength(panel.querySelectorAll('.client-security-map path').length);
expect(report.querySelectorAll('script,iframe,link[src],img[src^="http"]')).toHaveLength(0);
expect(report.querySelector('[http-equiv="Content-Security-Policy"]')?.getAttribute('content')).toContain("default-src 'none'");
expect(report.querySelector('.client-security-group')?.tagName).toBe('DETAILS');
expect(fixture.health).toHaveBeenCalledTimes(2); expect(openWork).not.toHaveBeenCalled();
expect(panel.querySelector('.client-security-finding-detail')).toBeNull();
expect(JSON.stringify(fixture)).toBe(before);
} finally { await act(async () => root.unmount()); host.remove(); }
});
it('blocks mixed revisions and suppresses a late export when Report Engine is switched off', async () => {
const fixture = setup(theme); let finish!: (value: never) => void;
fixture.health.mockResolvedValueOnce({ repositoryRevision: 2 } as never);
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
try {
await act(async () => root.render(<DashboardPanel onOpenWork={() => {}}/>));
await act(async () => [...host.querySelectorAll<HTMLButtonElement>('[role="tab"]')].find(tab => tab.textContent === 'Security')!.click());
const clickExport = () => [...host.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'HTML Security Export')!.click();
await act(async () => { clickExport(); await vi.dynamicImportSettled(); });
expect(fixture.download).not.toHaveBeenCalled(); expect(host.textContent).toContain('Reload the client');
fixture.health.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
await act(async () => { clickExport(); await vi.dynamicImportSettled(); });
await act(async () => setEngineEnabled('report', false));
await act(async () => finish({ repositoryRevision: 1 } as never));
expect(fixture.download).not.toHaveBeenCalled();
expect([...host.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'HTML Security Export')!.disabled).toBe(true);
} finally { await act(async () => root.unmount()); host.remove(); }
});
});

View File

@ -59,11 +59,11 @@ describe('tileBadges', () => {
expect(tileBadges({ ...other, fields: [] }, 'group-policy')).toEqual([]);
});
it('uses the fixed legacy order independently of source-column order', () => {
it('places Severity immediately after Status independently of source-column order', () => {
expect(tileBadges(record).map(badge => badge.text)).toEqual([
'IOE Found',
'Entra ID',
'Low',
'Entra ID',
'Weight: 2',
'Score: 86'
]);
@ -130,11 +130,11 @@ describe('tileBadges', () => {
]);
});
it('ordnet abgeleitete Kategorien vor Severity, Weight und Score ein', () => {
it('places derived categories after Status and Severity, before Weight and Score', () => {
const cisRecord: FindingRecord = { ...record, title: '2.2.2 Ensure a synthetic user right' };
expect(tileBadges(cisRecord, 'cis-cat').map(badge => badge.text)).toEqual([
'IOE Found', 'Entra ID', 'CIS 2', 'CIS 2.2', 'Low', 'Weight: 2', 'Score: 86'
'IOE Found', 'Low', 'Entra ID', 'CIS 2', 'CIS 2.2', 'Weight: 2', 'Score: 86'
]);
});

View File

@ -7,6 +7,7 @@ import { confirmEntryMappings, previewEntryFromDataset } from '../../src/feature
import { createCanonicalImportBatch } from '../../src/importers/canonical-import';
import { operatingSystemInventory, operatingSystemEolCount } from '../../src/features/dashboard/operating-system-inventory';
import { importFile } from '../../src/importers/importer';
import { partitionScannerTableRows } from '../../src/importers/scanner-table-notices';
import { importProfiles } from '../../src/profiles/profile-catalog';
import type { EnvironmentSnapshot } from '../../src/domain/environment-context';
@ -15,6 +16,24 @@ async function importPingCastle(content: string, fileName: string, type: string)
}
describe('PingCastle native finding extraction', () => {
it.each([100, 800, 1000])('retains every one of %i native account rows and separates the scanner limit notice without changing source rows', async count => {
const notice = 'Output limited to 100 items - go to the advanced menu before running the report or add "--no-enum-limit" to remove that limit';
const nativeRows = Array.from({ length: count }, (_, index) => `<tr><td>Synthetic-${index + 1}</td><td>2026-01-01</td></tr>`).join('');
const section = `<div id="sectionUserInformation"><h1>User Information</h1><table aria-label="Account analysis"><tr><th>Name</th><th>Creation</th></tr>${nativeRows}<tr><td colspan="2">${notice}</td></tr></table></div>`;
const dataset = await importPingCastle(pingCastleHtml.replace('</main>', section + '</main>'), 'ad_hc_large.synthetic.html', 'text/html');
const table = dataset.extraction!.reportSections!.find(item => item.sectionKey === 'sectionUserInformation')!.tables[0];
const original = JSON.stringify(table);
const projected = partitionScannerTableRows(table.rows);
expect(table.rows).toHaveLength(count + 1);
const retainedNotice = notice.replaceAll('"', '&quot;');
expect(table.rows.at(-1)).toEqual([retainedNotice, retainedNotice]);
expect(projected.dataRows).toHaveLength(count);
expect(projected.dataRows.at(-1)!.row[0]).toBe(`Synthetic-${count}`);
expect(projected.notices).toEqual([retainedNotice]);
expect(JSON.stringify(table)).toBe(original);
const realAccount = [notice, '2026-01-01'];
expect(partitionScannerTableRows([realAccount]).dataRows[0].row).toBe(realAccount);
});
it('retains an explicit EOL OS total without altering native scores or findings', async () => {
const section = '<div id="sectionComputerInformation"><h1>Computer Information</h1><h2>Account analysis</h2>'
+ '<table aria-label="Account analysis"><tr><th>EOL OS</th></tr><tr><td>2</td></tr></table></div>';

View File

@ -80,6 +80,7 @@ describe('Report Engine boundary', () => {
'src/reporting/report-run.ts',
'src/reporting/report-statement-text.ts',
'src/reporting/report-workspace-view.ts',
'src/reporting/security-view-report.ts',
'src/reporting/technical-report-model.ts',
'src/reporting/technical-report.ts'
]);

View File

@ -0,0 +1,65 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
import { buildSecurityViewReportHtml, type SecurityViewReadPort } from '../../src/reporting/security-view-report';
vi.mock('../../src/styles/ui-tokens.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/ui-tokens.css', 'utf8') }));
vi.mock('../../src/styles/tokens.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/tokens.css', 'utf8') }));
vi.mock('../../src/styles/base.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/base.css', 'utf8') }));
vi.mock('../../src/styles/components.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/components.css', 'utf8') }));
vi.mock('../../src/styles/datahub.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/datahub.css', 'utf8') }));
vi.mock('../../src/styles/client-security.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/client-security.css', 'utf8') }));
vi.mock('../../src/styles/policy-analysis.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/policy-analysis.css', 'utf8') }));
vi.mock('../../src/styles/security-scoring.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/security-scoring.css', 'utf8') }));
vi.mock('../../src/styles/functional-icons.css?raw', async () => ({ default: (await import('node:fs')).readFileSync('src/styles/functional-icons.css', 'utf8') }));
const snapshot = Object.freeze({ markup: '<div class="client-security-panel"><details><summary>Native finding</summary>Original source text</details></div>',
width: 1910, theme: 'light' as const, client: 'Synthetic <client>', assessments: 'Synthetic assessment', generatedAt: '12.10.2026',
versions: [['Datenstand', 'Revision 1']] as const });
const port: SecurityViewReadPort = { read: () => snapshot };
describe('Standalone Security view renderer', () => {
it.each(['light', 'dark'] as const)('retains native script-free category navigation and independent maximizable popups (%s)', theme => {
const markup = '<a href="#security-category-synthetic">Show synthetic category</a><div style="height:1400px">Synthetic overview</div><details class="client-security-group" id="security-category-synthetic"><summary>Synthetic category</summary><div class="client-security-group-content">Original synthetic finding</div></details><button popovertarget="synthetic-popup">Results</button><div style="height:1400px">Synthetic footer</div>';
const popups = '<section popover="auto" id="synthetic-popup" class="security-export-popup" role="dialog" aria-label="Synthetic results"><header><h2>Synthetic results</h2><button class="btn" popovertarget="synthetic-popup" popovertargetaction="hide" aria-label="Close synthetic results">×</button></header><div class="security-export-popup-body"><dl>' + Array.from({ length: 9 }, (_, i) => `<div><dt>Fact ${i}</dt><dd>Original ${i}</dd></div>`).join('') + '</dl><div style="height:1600px">Synthetic long evidence</div><p>Evidence end</p></div></section>';
const html = buildSecurityViewReportHtml({ read: () => ({ ...snapshot, theme, markup, popups }) });
expect(html).not.toMatch(/<script|\son[a-z]+=/i);
expect(html).toContain('href="#security-category-content-synthetic"');
expect(html).toContain('<div class="client-security-group-content" id="security-category-content-synthetic">');
expect(html).toContain('overscroll-behavior:contain');
expect(html).toContain('100vw * 2 / 3');
expect(html).toContain('html,body{background:var(--surface-panel)}');
expect(html).toContain('grid-template-columns:repeat(5,minmax(0,1fr))');
expect(html).toContain('aria-label="Maximize popup"');
expect(html.indexOf('aria-label="Maximize popup"')).toBeLessThan(html.indexOf('aria-label="Close synthetic results"'));
expect(html).toContain('Original synthetic finding');
const dom = new JSDOM(html.replace(/<style>[\s\S]*?<\/style>/g, ''));
const doc = dom.window.document;
const input = doc.querySelector<HTMLInputElement>('.security-export-maximize input')!;
input.click(); expect(input.checked).toBe(true);
input.click(); expect(input.checked).toBe(false);
const link = doc.querySelector<HTMLAnchorElement>('a')!;
const target = doc.getElementById(link.hash.slice(1))!;
expect(target.closest('details')?.id).toBe('security-category-synthetic');
expect(target.textContent).toBe('Original synthetic finding');
expect(doc.querySelector('.security-export-popup>header')?.lastElementChild?.textContent).toBe('×');
dom.window.close();
if (process.env.ASSESSOR_REPORT_PREVIEW_DIR) writeFileSync(`${process.env.ASSESSOR_REPORT_PREVIEW_DIR}/security-${theme}.html`, html);
});
it('embeds the unchanged application styles, local assets and frozen authorized snapshot with no script or external resource', () => {
const before = JSON.stringify(snapshot); const html = buildSecurityViewReportHtml(port);
for (const file of ['ui-tokens', 'tokens', 'base', 'components', 'datahub', 'client-security', 'policy-analysis', 'security-scoring', 'functional-icons']) {
expect(html.includes(readFileSync(`src/styles/${file}.css`, 'utf8'))).toBe(true);
}
expect(html).toContain(snapshot.markup); expect(html).toContain('Synthetic &lt;client&gt;');
expect(html).toContain('width:1910px'); expect(html).toContain('VERTRAULICH');
expect(html).toContain('data:font/woff2;base64,'); expect(html).toContain('data:image/png;base64,');
expect(html).not.toMatch(/<script|<(?:img|link)\b[^>]*(?:src|href)=["']https?:/i);
expect(JSON.stringify(snapshot)).toBe(before);
});
it('rejects an unavailable width and active source markup instead of creating a partial or executable export', () => {
expect(() => buildSecurityViewReportHtml({ read: () => ({ ...snapshot, width: 0 }) })).toThrow('measured width');
for (const markup of ['<script>malicious()</script>', '<img onerror="malicious()">', '<a href="javascript:malicious()">bad</a>']) {
expect(() => buildSecurityViewReportHtml({ read: () => ({ ...snapshot, markup }) })).toThrow('active content');
}
});
});

View File

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