Backup
This commit is contained in:
parent
250c213fc1
commit
614ac1b147
|
|
@ -10,6 +10,7 @@ export const WHOLE_REPOSITORY = 'Gesamtes Repository';
|
|||
|
||||
// Engine paths precede the Core areas they sit in.
|
||||
export const ENGINE_PATHS = [
|
||||
['Remediation Engine', ['src/remediation/', 'server/remediation/', 'tests/remediation/']],
|
||||
['Context Engine', ['src/assessment-context/', 'src/features/context-layer/', 'tests/assessment-context/',
|
||||
'tests/features/finding-context-layer.test.tsx', 'tests/features/pathfinder-context-layer.test.tsx',
|
||||
'tests/features/context-cluster-facet.test.tsx', 'tests/context-browser-check.mjs']],
|
||||
|
|
|
|||
28
AGENTS.md
28
AGENTS.md
|
|
@ -1,5 +1,21 @@
|
|||
# ASSESSOR project guidance
|
||||
|
||||
## Protected scanner imports: crown jewels (2026-10-09)
|
||||
|
||||
- Explicit user decision: a working scanner import is a permanent compatibility contract. Internal corrections and additive format support are permitted; previously supported input must retain its recognition, approved finding identities, original values, outcomes, severity, evidence, provenance and scoring semantics. Do not replace a working variant with a newer one or introduce a separate engine to bypass this protection.
|
||||
- `tests/importers/import-contracts.json` registers protected synthetic formats; `tests/importers/import-compatibility.test.ts` and its committed snapshots check browser and backend recognition, preview, explicit mapping approval and canonical results. Fixtures are checksum-protected (line endings normalized). Customer reports must never be used to create these baselines; use synthetic/Dummy inputs.
|
||||
- Add a synthetic contract for each newly supported format/variant and retain every previous contract. Existing fixture hashes, contract identifiers, assertions and expected results must not be removed, weakened or updated merely to make a regression pass. A deliberate change to an established import contract requires explicit user agreement for the exact behavior and current Core consent for every affected area; document the reason and migration/compatibility implications in the implementation plan. Adding a new contract does not authorize changing an old one.
|
||||
- `npm run test:imports` is the mandatory compatibility gate, including the scanner import, shared scoring and import transport/worker suites. It runs in CI mode: missing or changed snapshots fail and are never automatically accepted. `npm test` and `npm run build` run this gate first; do not bypass it with direct build commands for a release. Run the gate for shared helper, dependency and Platform changes as well as parser changes. Any failure blocks the release; fixing a regression means restoring the supported behavior, not replacing its baseline.
|
||||
- Keep this protection inside Core Ingest/Core Tests/Platform/Governance; do not add a nested Core or scanner-specific worker. Synthetic contract coverage protects registered variants, not unknown future vendor formats. New profiles must add a contract, and new public format variants need additive compatibility coverage before release.
|
||||
|
||||
## Remediation Engine (2026-10-08)
|
||||
|
||||
- User-authorized work packages are derived from published Context results and original Work deviations through a declared read-only Core port. A rolled-up GPO setting counts once per scope; result evidence rows are never tasks.
|
||||
- Explicit user decision 2026-10-09 (replaces the former five-finding limit): a package stays inside exactly one Context cluster and one platform (Windows host/GPO, Active Directory, Cloud/M365) and contains at most eight tasks; explicit user decision 2026-10-09 (later the same day): a Windows host/GPO package contains at most fifteen tasks, because several GPO settings are changed in one change round. These limits are package-size rules, never an effort assessment, priority or security-impact score. A task is one Work finding, except that findings proven redundant because they target the same change target (the same exact technical setting or object identity, or the same published Context condition) form one task and count once. Every redundant finding stays individually listed as evidence of that task, keeps its own source attribution (GPO declaration and scanner observation stay separate), and Work, Client Info, score and Result DB counts stay unchanged. Redundancy is never inferred from title, wording or text similarity.
|
||||
- The engine owns `src/remediation/`, `server/remediation/`, `tests/remediation/` and the separately migrated `assessor_remediation` schema. Core findings carry no new database column; their displayed assignment is a derived flag. Preserve original verdicts, scores, reviews, risk acceptances and Result DB data.
|
||||
- Show `Context Cluster → AP-01` next to Source ID in Finding Reader. Work Package replaces the Result Message filter; source message values remain preserved. Package numbers are identifiers, never priorities or quantified security impact.
|
||||
- Remediation is an independently optional engine in the Engines menu. Context-off consumes only existing package snapshots and never starts Context computation. Disabling Remediation clears its Work filter and suppresses late output. No task workflow, owners or deadlines are part of this feature.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
- Read `docs/IMPLEMENTATION_PLAN.md` before architecture changes, broad refactors, persistence changes, importer/profile work, or server-readiness work.
|
||||
|
|
@ -48,6 +64,14 @@
|
|||
- Tool-independent check: run `npm test` before every commit, whichever agent or person made the change; the architecture and invariance tests below are the only Core/Engine checks that apply to every tool.
|
||||
- `tests/architecture/core-engine-boundary.test.ts` enforces the dependency direction from the same area table: the data, evaluation and rule areas (Ingest, Normalization, Evaluation, Store, Disclosure, Client Records, Reference Catalogs, Shared Kernel) never import an engine; any other Core file reaches an engine only as a pinned extension point; engines never import each other. Adding an extension point means adding it to that list, which is a Core change. Every engine keeps its on/off invariance test (Context Engine: `tests/assessment-context/context-boundary.test.ts`; Report Engine: `tests/reporting/report-engine-invariance.test.ts`).
|
||||
|
||||
## Report numbers
|
||||
|
||||
- Explicit user decision 2026-10-05, binding for the Management Report and the Technical Report: (1) the data basis is the Raw Data of the Work records, counted once per Work record (a rolled-up GPO setting counts once), never per result row, per-member comparison or Result DB row; (2) every number Client Info displays is the authoritative value for the report. Deviations, assessed, passed, gaps, Security Domain values and Overall Score in a report equal Client Info for the same client and timeslot, using the same records, result states and Security Domain classifier.
|
||||
- Report figures Client Info does not show (Context problem fields, themes, heatmap) must reconcile to the Client Info totals: their exclusive partition, including `Other Deviations (not yet catalogued)` or `Ohne Problemkategorie`, sums to the Client Info deviation count, and the same deviations carry the same number on every sheet.
|
||||
- The Technical Report may list more evidence rows than deviations (for example several comparison rows behind one GPO setting), but marks them as evidence; its headline counts follow Client Info.
|
||||
- An archived report keeps the numbers of its snapshot; after a reimport it may legitimately differ from the current Client Info. Result DB and Data Hub counting rules are not report sources.
|
||||
- Every mismatch fix adds a synthetic test that compares the report figure with the Client Info figure for the same records.
|
||||
|
||||
## Context layer (Phase 13E)
|
||||
|
||||
- Latest explicit user decision 2026-10-01 supersedes the preceding permanent-Context decision: the Work toolbar has an `Engines` button at the former Context-button position. It stays marked blue, including on hover and while the popup is closed (an explicit colour-role exception for this control only). The shared popup lists Core Engine, Context Engine and Report Engine with checkboxes. All default to active; Core Engine is always checked and cannot be disabled. User choices for optional engines are stored locally under the shared Core-owned engine-activation contract. Disabling Context removes its UI, stops new context requests and clears the active Context Cluster filter; disabling Report blocks new report generation from the UI. Already submitted backend work is not cancelled by this local preference; its late UI output is suppressed. Cluster badges still select the exact Work filter, and the Context detail section still starts collapsed. Engine independence and Core invariance remain binding.
|
||||
|
|
@ -76,6 +100,8 @@
|
|||
|
||||
## Binding ASSESSOR design language
|
||||
|
||||
- Explicit user design principle 2026-10-10: the close X is always the rightmost control in a window/dialog/panel header action bar. No group ring, badge or other action may follow it visually or in DOM/keyboard order. Optional same-change rings precede the close X; preserve their stable reserved slot so title length and ring availability never shift the other header actions. Permanent Results acceptance covers minimized and expanded windows, both themes and narrow window widths.
|
||||
|
||||
- Explicit user correction 2026-09-18: the current, user-approved Data Hub, Work, and Pathfinder screens are the binding visual references for ASSESSOR's Ableton-inspired design language. This applies to every view, explicitly including Client Info, and to proposals, generated images, prototypes, and production implementation.
|
||||
- Before any visual task, read `.claude/skills/assessor-ui/SKILL.md` and the relevant current components and `src/styles/ui-tokens.css`. The approved views and these current project agreements take precedence over older skill examples, copied asset templates, and superseded Client Info exceptions.
|
||||
- Use Data Hub for flat square tiles, full-circle donuts, compact action buttons and branding; Work for list/detail hierarchy, typography, badges and reading surfaces; Pathfinder for stable filter/work/detail zones and compact controls. Preserve these approved views as references; do not redesign them as a side effect of Client Info work.
|
||||
|
|
@ -104,6 +130,8 @@
|
|||
|
||||
## UI Finding Reader first read
|
||||
|
||||
- Explicit user decision 2026-10-10: Finding Reader actions always form one horizontal, non-wrapping group in this order: `AI search`, bookmark icon, `Results`. Context badges and narrow viewports may move the whole group, never split or reorder its actions. Existing search availability and Results enablement remain unchanged. Bookmark actions everywhere use only the shared icon, with no visible `Bookmark` label; accessible names and tooltips remain. Snapshot naming uses `Snapshot`, not `Bookmark`. Keep a permanent synthetic interaction acceptance test covering both themes, narrow widths, bookmark toggling and Results activation.
|
||||
|
||||
- Explicit user decision 2026-09-25: every Finding Reader states within seconds what is wrong and what to do. Directly below the setting or rule heading, a `Problem` block (`Summary` for a passed result) with the outcome-colored marker and a separate `Next step` block with the orange `--next-step-accent` marker precede all explanatory text; both markers use `--marker-width`. Background (description, technical explanation, rationale) follows openly, references and documentation after it; work tracking and Additional Information stay collapsed at the end.
|
||||
- Scanner profiles declare their first-read fields in `presentation.firstRead`; React components never name scanner-specific fields. Use only retained source values: a missing field removes its line and nothing is summarized, rephrased or invented. Counts come from explicit source counts or linked result tables; a result row is red or green only where the source marks it (CIS-CAT criterion/evaluated rows with the finding outcome, `resultSheetRowStatusCells` markers). Split a long remediation only at a paragraph boundary and keep the rest complete behind `Full remediation`. An imported documentation link is offered only as a plain https URL with `noopener noreferrer`.
|
||||
- Explicit user decision 2026-09-25: every documented standard Windows code shown as text carries its meaning directly after it, in muted regular text: well-known SIDs, standardized domain RIDs (`S-1-5-21-<domain>-500/512/...`; the domain part is never resolved), User Rights Assignment constants (`SeNetworkLogonRight` -> `Access this computer from the network`) and registry type codes (`4` -> `REG_DWORD`). The single list lives in `src/shared/windows-codes.ts` and is applied centrally by `PathText`; the original code stays unchanged and authoritative, unknown or customer-specific codes stay plain, and a meaning already stated next to the code is not repeated.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,579 @@
|
|||
# ASSESSOR – Umsetzungsplan
|
||||
|
||||
## Security finding reading hierarchy (2026-10-11, Phase 7 Presentation)
|
||||
|
||||
Current Core release: Presentation, Core Tests, Platform. User correction: Security finding headings use the shared Bold 700 token; the scanner/rule subtitle is removed from these compact rows. The original Description appears immediately after Affected and before Impact, including when its text equals Result. Findings without an Affected field retain Description after Result, or first if Result is absent; no empty source fields are invented. Imported fields, tool identities, verdicts and counts remain unchanged. GPO baseline explanations retain their existing dedicated presentation. Permanent synthetic Light/Dark disclosure interaction verifies the exact order, heading weight, absent scanner subtitle and unchanged source data. All six Security UI checks, 17 UI/API version and Core-boundary checks, and design-language check pass. Patch decision: 2.24.2 for this backward-compatible correction, with shared package/lock and UI/API version contracts synchronized. All 504 mandatory import compatibility checks and both production builds pass (existing client chunk-size warning only); local-only check passes. Actual Dummy browser acceptance in Light/Dark confirms Bold 700 finding headings, no scanner subtitles, and Result → Affected → Description → Impact. Light restored and temporary test tab closed. No full-suite, phase-completion or backend-restart claim. Concurrent work preserved.
|
||||
|
||||
Change log 2026-10-11: clarified the compact Security finding title and source explanation order.
|
||||
|
||||
## Restore compact rectangular Context heatmap (2026-10-11, Phase 7 Presentation)
|
||||
|
||||
User explicitly supersedes square tiles: restore compact four-sided rectangles while preserving the current number, severity, full title and count hierarchy. Registered Context CSS removes the 1:1 aspect ratio and returns to automatic content height with the shared tile-height minimum; width, inset, radius, colours and exact Work clicks remain unchanged. Empty categories retain their neutral surface and sort after populated Critical/High/Medium categories. A High category with new evidence uses the High fill and moves into the populated High group. Permanent synthetic Light/Dark interaction now verifies that exact empty-to-populated transition and click identity.
|
||||
|
||||
Current Core release: Platform, Core Tests. Patch decision: 2.24.1 for the verified backward-compatible contour correction; package/lock and UI/API version contracts synchronized. The Overview invariance check now compares the full original Overview surface and separately preserves the Security total; the concurrent Security workspace intentionally renders published Context categories, and its permanent interaction checks retain all 105 findings with Context on/off. Verified: all 19 heatmap/Context-boundary/Security interactions, 12 UI/API version checks, design-language check, all 504 mandatory import checks and both final production builds pass (existing client chunk-size warning only). Corrected the new test to use the existing TypeScript target rather than changing compiler settings. Actual Dummy Light/Dark acceptance confirms all 22 tiles are compact 260 by 172px rectangles without text overflow, retain 478 findings, and leave the empty High category neutral at position 21. The synthetic empty-to-populated transition proves it moves into High with its fill and exact Work click. Actual keyboard activation opens the exact 42 Credential Relay findings. Normal-width review has no tile/grid overflow. Light restored, viewport reset; backend health confirms 2.24.1, canonical PostgreSQL and unchanged repository revision 592. No Core presentation source change, database mutation or full-suite/phase-completion claim. Concurrent work preserved.
|
||||
|
||||
Change log 2026-10-11: restored compact rectangular Context tiles with retained labels and verified empty/populated High ordering.
|
||||
|
||||
## Client Info Security findings workspace (2026-10-11, Phase 7 Presentation)
|
||||
|
||||
Current Core release: Presentation, Core Tests, Platform. User requests a compact Defender-like findings view in the existing Security tab, without Work Packages or task workflow. Collapsed categories follow the exact published Context heatmap order and retain severity, counts and deviation shares. Category disclosure reuses the Technical Report situation/technical-approach texts and authoritative Security Domain classifier. A collapsible two-column domain/category diagram uses those same counts. Each finding opens the retained source explanation and remediation through the existing Reader helpers; structured GPO comparisons preserve individual baseline recommendations. Missing guidance is explicitly unavailable, never invented.
|
||||
|
||||
DashboardPanel remains the existing pinned Core extension to Context/Report publications; the new presentation component has no engine imports or persistence. One Raw Data Work record counts once, never one Results evidence row. The source filter and client/timeslot scope match Client Info. Unmatched publication identities are explicitly visible as unassigned, not guessed. Context-off retains the Core findings list and suppresses the diagram/categories; Report-off suppresses report-only category wording. All groups/findings start collapsed and retain disclosure state on tab switches. No 100-row limit, data changes, filter writes or navigation on disclosure.
|
||||
|
||||
Minor release decision: 2.24.0 for this backward-compatible capability, following concurrent 2.23.27; package/lock and version contracts synchronized. Synthetic Light/Dark acceptance covers all 105 deviations, exact heatmap order, domain flows, two-level disclosure, retained source text, tab-state preservation and Context-off Core invariance. Verified: 39 targeted UI/version/engine-boundary checks plus the final four Security interactions pass, all 504 mandatory import compatibility checks and both final production builds pass (existing client chunk-size warning only), design-language and local-only checks pass. Browser review with Dummy confirms all 478 deviations across 22 categories in exact heatmap order, 19 Cloud Privileged Access findings, 45 domain/category flows summing to 478, and mouse/Enter disclosure with retained explanation and remediation in Light/Dark. Browser review found two inherited icon-size issues in the diagram; the chart selector now excludes the disclosure triangle and explicitly restores intrinsic SVG height. The synthetic interaction test includes real global styles and checks those boundaries. Corrected the new GPO test fixture to supply the full ImportedDataset shape; no production workaround or importer baseline change. Final production browser review passes in Light/Dark: the diagram measures 1040 by 640, its disclosure triangle remains 12 by 12 and graph text remains 14px; the 45 links still sum to 478 and the panel has no horizontal overflow at normal width. Enter opens the category and individual native finding, and switching Overview/Security preserves the open explanation/remediation. Light restored and temporary browser tab closed. No backend restart or database mutation by this task; concurrent work preserved. No full-suite or phase-completion claim.
|
||||
|
||||
Change log 2026-10-11: populated the Security tab with category/domain overview and compact expandable findings.
|
||||
|
||||
## Square Context heatmap tiles (2026-10-11, Phase 7 Presentation)
|
||||
|
||||
User requests returning to four-sided tiles and trying squares, explicitly superseding the hexagon contour. Registered Context CSS restores the existing 260px environment tile width and shared tile inset/radius, with a 1:1 aspect ratio and no clipping. Full severity fills, numbers, complete titles, counts, published sequence and exact Work cluster clicks remain unchanged. No Context data or Core presentation source changes. Permanent Light/Dark UI acceptance replaces the superseded hexagon geometry with square aspect ratio, standard inset and absence of clip paths; existing interaction/invariance checks remain.
|
||||
|
||||
Current Core release: Platform, Core Tests. Patch decision: 2.23.27 following the completed 2.23.26 batch; the concurrent capability release subsequently advanced the shared product version to 2.24.0, which is preserved. Verified: 13 Context UI/boundary checks, 12 current UI/API version checks, design-language check, all 504 mandatory import checks and both production builds pass (existing chunk-size warning only). Actual Dummy Light/Dark browser review confirms all 22 tiles are 260px squares with shared 4px corners, no clipping or text overflow, and clickable corners. Keyboard focus remains visible; Enter on Credential Relay Exposure opens exactly its 42 Work findings. Normal-width review also confirms squares and no horizontal grid overflow. Light restored, viewport override reset. Local backend runs the verified 2.24.0 build against the existing canonical PostgreSQL datastore, repository revision 592. No database mutation or full-suite/phase-completion claim; concurrent work preserved.
|
||||
|
||||
Change log 2026-10-11: replaced the superseded hexagon contour with square Context heatmap tiles, retaining published contents and exact Work interactions.
|
||||
|
||||
## Data restored as a separate Client Info tile (2026-10-11, Phase 7 Presentation)
|
||||
|
||||
Current Core release: Presentation, Core Tests, Platform. User correction explicitly supersedes the preceding two-tile summary decision: Data becomes its own tile again, containing the existing deviation donut, additional result states and six environment facts. Overall Score and Security Domains retain one common tile; Domain Information retains its existing tile. The summary wrapper becomes transparent and each tile uses the shared theme surface, border and rounded radius. Existing geometry, values and filter interactions are preserved.
|
||||
|
||||
Patch decision: 2.23.26 for this verified layout correction; package, lock and UI/API version contracts synchronized. The 26 targeted layout, theme grammar, tab, dashboard and synthetic deviation/filter checks pass. Verified: all 504 mandatory import checks (including the API version contract), six UI version/Core-boundary checks, design-language and local-only checks, and both production builds pass (existing client chunk-size warning only). Actual Dummy browser review confirms three separate neutral tiles in Light/Dark: score/domains, Data and Domain Information. Data retains the shared 4px corners, all six environment facts and the original 478 deviations / 63% plus additional status counts. Light theme restored and temporary browser tab closed. No full-suite or phase-completion claim. Concurrent work is preserved; no backend restart or database changes are part of this task.
|
||||
|
||||
Change log 2026-10-11: separated Data from the score/domain tile while keeping all Data content together.
|
||||
|
||||
## Hexagonal Context heatmap tiles (2026-10-11, Phase 7 Presentation)
|
||||
|
||||
User requests hexagons, superseding the previous cut-corner outline for Context tiles only. The registered Context stylesheet uses a flat-top six-sided contour with shared small corner rounding, a regular hexagon aspect ratio and a central 50-percent text-safe region. The width adds two existing spacing units to the existing environment tile width to keep full names and severity readable without shrinking typography. Grid wrapping, full severity fills, numbers, counts, published order, cluster IDs and exact Work drilldowns are preserved; no honeycomb reordering or domain changes. The polygon fallback retains all six sides when CSS shape is unavailable.
|
||||
|
||||
Current Core release: Platform, Core Tests. Retain concurrent 2.23.26 for the shared unfinished visual-refinement release batch; its package/lock and UI/API contracts are synchronized. All 25 Context UI/boundary and version/API checks passed. Permanent Light/Dark interactions also assert the six vertices, central padding and geometric ratio. Verified: design-language/local-only checks, all 504 mandatory import checks and both production builds (existing chunk-size warning only). Owned local backend restarted on the same canonical PostgreSQL; health confirms 2.23.26 and unchanged revision 592. Actual Dummy Light/Dark acceptance confirms 22 equal regular hexagons, rounded vertices, complete text inside the central region and no text overflow. Clipped corners are not hit-testable; the centre is clickable. Visible keyboard focus and Enter on Credential Relay Exposure open exactly its 42 Work findings. Normal browser width retains wrapping without grid or cell overflow. Light/Dark screenshots saved, temporary viewport reset, Light Client Info restored. No Core presentation source or stored finding data changed for this request; no phase-completion or full-suite claim.
|
||||
|
||||
## Rounded cut corner on Context tiles (2026-10-11, Phase 7 Presentation)
|
||||
|
||||
User explicitly confirms cutting the bottom-right corner diagonally and rounding the other corners and transitions. This supersedes the rectangle-only contour for Context heatmap tiles. The change is confined to the registered Context Engine stylesheet: existing spacing/radius tokens define a 64 by 32 cut with rounded joins, preserving dimensions, colour, text positions and cluster ordering. The shape clips hit testing as well as paint; keyboard focus moves inside the tile so clipping cannot remove the focus indicator. Older shape-function implementations receive the diagonal polygon fallback.
|
||||
|
||||
Version reassessment: retain the current concurrent 2.23.25 for this isolated contour refinement; no separate capability or product-contract batch and no Core/version files changed. Verified: 13 existing heatmap interaction and Context-boundary tests, design-language checks, all 504 mandatory import checks and both production builds (existing chunk-size warning only). Actual Dummy Light/Dark acceptance confirms the rounded shape on all 22 tiles, full visible titles, an unclipped inset keyboard focus, a non-clickable cut corner and a clickable tile centre. Enter on Credential Relay Exposure opens its exact 42 Work findings. The existing CSS shape implementation is used in the browser; no fallback was needed. No backend restart or database mutation by this task; concurrent work preserved.
|
||||
|
||||
Change log 2026-10-11: rounded the Context tile outline and cut out its bottom-right corner without changing the content layout.
|
||||
|
||||
## Restore full Context severity fills (2026-10-11, Phase 7 Presentation)
|
||||
|
||||
Current Core release: Presentation, Core Tests, Platform. User correction explicitly supersedes the neutral heatmap tile surfaces approved in the preceding batch. Restore full Critical/High/Medium severity fills while preserving the new layout: separate number, top-right severity, complete title and larger count. Keep rectangles and existing geometry. Empty/undetermined clusters remain neutral. All tile text uses the existing severity ink on coloured surfaces; neutral tiles retain theme ink. Hover keeps the full severity fill and adds the existing outline treatment. No numbering, counts, click targets, engine or data changes.
|
||||
|
||||
User follow-up removes the Badges: Base Severity caption completely, including its unused style and description reference. Existing header and Light/Dark click tests now permanently assert its absence while retaining severity text on each tile. These follow-up files are registered Context Engine paths. Patch decision remains 2.23.24 for this same unfinished batch, following concurrent 2.23.23; version contracts synchronized. Verified: 44 heatmap/package/Context-boundary interactions, the updated eight heatmap tests, UI/API version contracts and design-language/local-only checks. Actual Dummy browser acceptance confirms full Critical/High/Medium fills, matching severity ink on numbers/titles/counts, neutral empty/undetermined surfaces and no text overflow in Light/Dark. The first gated build passed all 504 import checks and both production builds. Final gated build after caption removal passed all 504 import checks and both production builds (existing chunk-size warning only); the removed caption is absent from the production bundle. No backend restart or database mutation by this task; concurrent workspace work preserved.
|
||||
|
||||
Change log 2026-10-11: restored full-tile severity colour while retaining the separated number/title/count hierarchy.
|
||||
|
||||
## Leading Client Info tab follows the panel corner (2026-10-11, Phase 7 Presentation)
|
||||
|
||||
Current Core release: Presentation, Platform, Core Tests. User correction changes only the first tab's top-left corner to the original 10px panel radius; all other tab corners, dimensions, positions and interactions stay unchanged. Shared --panel-radius expresses the existing panel geometry without altering it. Permanent Light/Dark tab interactions check the first corner uses that same value, the second tab remains unchanged, and scrolling/mouse/keyboard switching retain their contracts. Moving the existing literal into the token also tightens the design check's legacy-radius allowance from three to two.
|
||||
|
||||
Retain 2.23.25 for the same unfinished two-tile release batch. Browser review exposed the generic aside sidebar surface behind Data; its layout wrapper is now a div, removing sidebar background/z-index semantics from dashboard content while preserving the named heading and controls. Verified: 30 final tab/layout/version checks, design-language and local-only checks; all 504 mandatory import checks and both final production builds passed. Actual Dummy browser acceptance in Light and Dark confirms the first tab top-left is 10px, its top-right and both second-tab top corners remain 3px; Data has a transparent div wrapper and shares the summary surface. Exactly two outer tiles remain. Final Light/Dark screenshots saved, temporary wide viewport reset, Light restored. No full-suite or phase-completion claim.
|
||||
|
||||
## Two top-level Client Info tiles in both themes (2026-10-11, Phase 7 Presentation)
|
||||
|
||||
Current Core release: Presentation, Platform, Core Tests. User correction: Overall Score, Security Domains / Business Impact and Data share one exterior tile; Domain Information is the second tile, in Light and Dark. A shared summary container owns the border, radius and surface; its internal sections no longer have exterior frames. Existing content, metrics, connectors, Results and Work drilldowns remain intact. Narrow layouts stack sections inside the same two tile boundaries. This supersedes the previous four individually bordered Light sections.
|
||||
|
||||
Patch decision: 2.23.25 after preserving concurrent 2.23.24. Permanent synthetic Light/Dark Dashboard interactions assert exactly two top-level groups, all three summary areas, separate Domain Information, retained counts, Results evidence and Context drilldown. Updated old layout expectations to the explicit grouping decision and corrected stale parent assertions for the existing tabpanel wrapper. Verified: 50 distinct targeted layout, interaction, version and architecture checks, design-language and local-only checks, all 504 mandatory import checks and both production builds (existing chunk-size warning only). Local backend health confirms 2.23.25, canonical PostgreSQL and unchanged repository revision 592. Final browser acceptance passed in Light/Dark: the three summary sections and Data wrapper are transparent, their common tile owns the theme surface and single exterior border, and Domain Information is the separate second tile. Normal-width check retains two groups without horizontal content overflow. No phase-completion or full-suite claim.
|
||||
|
||||
## Compact Filters and WP presentation corrections (2026-10-11, Phase 7 Presentation)
|
||||
|
||||
Current request released Presentation, Platform and Core Tests before implementation, including renewed consent for Results activation and tab alignment. Floating filters use content height capped by the user's resize height; closing groups removes the empty remainder and reopening retains the cap. All eight resize handles, selections, docking and the uppermost filter layer remain. The Work Packages toolbar button toggles its window on repeated clicks. Results and shared floating data windows now use one activation sequence: opening or clicking either brings it forward; keyboard focus also activates it. Existing Results activation callbacks, cluster geometry, minimization and the uppermost filter layer remain. Client Info moves its viewer up by the existing tab height and removes the extra tablist top inset, so the content panel starts at the original shared viewer top; the tablist remains outside scrolling content. Only cluster headings retain colour squares in the package table; the Reader and Filters keep their established cluster markers. Visible codes use WP instead of AP across engine output, Reader, both filter paths and package table. Legacy snapshots display WP through a read-only formatter; historical package IDs remain stable, requiring no database migration or reassignment. Counts and package membership are unchanged.
|
||||
|
||||
User-described tile shadows were contrasting gutter surfaces: measured tile and grid box shadows were already none. The Client Info overview and environment grids now paint existing gutters with the shared tile surface, preserving widths, gaps, borders and typography. This is a specific user correction to those gutter surfaces; shared transparent-segment tokens remain unchanged. Browser review found the workspace clipping the elevated tabs despite correct measured alignment. Dashboard-only workspace/viewer overflow now permits the tab strip above the original content edge; scrolling stays inside the tabpanel, and other views keep their clipping contracts. Permanent Light/Dark acceptance includes both ancestor boundaries.
|
||||
|
||||
Patch decision: 2.23.23 after preserving concurrent 2.23.22; package, lock and UI/API version contracts synchronized. Permanent Light/Dark interactions cover opening Results above Work Packages, repeatedly activating either window, Filters above both, toolbar close/reopen, compact filter height with retained resize limit, exact legacy snapshot selection IDs and heading-only colour squares. Tab interaction acceptance retains mouse/keyboard switching and scrolled state, verifies removal of the top inset and the dashboard-only viewer offset. Verified: 57 tab/filter/package/version checks, 50 final ancestor-clipping/interactions/architecture checks, additional engine/related/tile/Results layout checks and design/local-only checks. Corrected the new test's missing sheet prop and JSDOM ResizeObserver; no production workaround or scanner baseline changes. A gate run hit the existing 5-second import test timeout under parallel load; the unchanged gate passed all 504 checks when rerun alone, including the final clipping correction. Both final production builds passed (existing client chunk-size warning only). Restarted the owned local backend; health confirms 2.23.23, PostgreSQL and unchanged repository revision 592. Browser acceptance passed on Dummy: original content edge approximately 134px is retained in both themes, both tab centres are visibly hit-testable, switching to empty Security and back preserves that edge, and both dashboard ancestors allow the full tab height. Work confirms WP-01 labels, 20 coloured cluster headings and zero row squares, the second toolbar click closes the window, Results opens above the parked WP window, and Filters remains at its uppermost layer. Expanding packages reaches the retained 600px cap; closing the group shrinks the floating panel to approximately 158px with no blank remainder. Actual Light/Dark screenshots saved; Light Overview restored. No phase-completion or full-suite claim.
|
||||
|
||||
Change log 2026-10-11: compacted closed floating filters, toggled Work Packages, renamed visible AP codes to WP with stable IDs, retained table squares only on cluster headings, and removed contrasting Client Info gutters.
|
||||
|
||||
## Neutral Context heatmap tiles (2026-10-11, Phase 7 Presentation)
|
||||
|
||||
Current Core release: Presentation, Core Tests, Platform. User approved neutral tiles with an isolated cluster number at top left, a compact coloured Base Severity badge at top right, the full cluster name and a larger finding count. This explicitly supersedes full-tile severity fills and repeating Base Severity on every tile. One shared explanation beside the heading identifies the badge meaning. Empty and undetermined clusters retain neutral badges; counts, source semantics, exact cluster click targets and the heatmap's authoritative sequence are unchanged.
|
||||
|
||||
Uses existing theme surfaces, severity colours, Inter roles, shared tile radius/inset and a wider existing tile size so labels wrap less. Grid tiles retain equal heights; all text remains visible and native buttons retain keyboard activation/focus. No new palette, shadows, decorative stripes or truncation. Existing Light/Dark interaction tests were adapted for the approved hierarchy and keep the exact cluster interaction, on/off invariance and full-publication numbering checks.
|
||||
|
||||
Patch decision: 2.23.22 following concurrent 2.23.21 work; version contracts synchronized. Verified: 52 targeted heatmap/package/architecture/Context-boundary checks, two UI/API version contracts, design-language and local-only checks, all 504 mandatory import checks and both production builds. The first build caught a concurrently introduced missing test prop; after that parallel correction the complete gated build passed. Existing chunk-size warning remains. Actual Dummy browser acceptance at 2.23.22 passed in Light/Dark: all 22 tiles retain the complete names and equal heights without horizontal content overflow, 16px titles and 32px counts, neutral surfaces with coloured badges, and neutral empty/undetermined badges. Visible keyboard focus and Enter activation open exactly the 19 findings of cluster 01; the Work filter retains its matching number and count. Concurrent changes preserved; no backend restart or database mutation by this task.
|
||||
|
||||
Change log 2026-10-11: moved Context severity colour into compact badges and separated cluster number, title and finding count.
|
||||
|
||||
## Heatmap positions govern cluster numbering and AP grouping (2026-10-11, Phase 7 Presentation)
|
||||
|
||||
Current Core release: Presentation, Core Tests, Platform. Explicit user correction supersedes the previous permanent display-number list and independent package ordering. The current client/timeslot's complete published Context heatmap sequence is authoritative: its first cluster is visibly 01, its second 02, including exact published ties and grey/empty cells. Removed the duplicate fixed catalog-number list and the separate severity sort. Technical cluster/package identities, source findings and package membership remain unchanged. Without a Context publication, labels remain unnumbered rather than claiming a heatmap position.
|
||||
|
||||
The heatmap displays these positions; both sidebar filters, toolbar dropdowns, Work Packages and Reader AP links use the same complete publication order and labels. Work/search/table filters never renumber the remaining clusters. The package table shows one coloured cluster heading, then indented AP codes. Cluster groups always stay in heatmap order; column sorting operates within each group. Full cluster/AP names remain available in accessible labels and table filter options. The table's package filter now stores exact package IDs so a changed published display number cannot invalidate the selection.
|
||||
|
||||
Patch decision: 2.23.20 from concurrent 2.23.19; package/lock/UI/API contracts synchronized. Permanent synthetic Light/Dark interaction acceptance renders the actual heatmap, both filter paths, package table and Reader together: Cloud Privileged Access Exposure is 01 and Credential Relay Exposure is 05; selecting/filtering the latter retains 05, and changing published tie order updates all views while retaining the same selected package ID. Other checks preserve grouping, numeric AP order, empty/missing publication handling, counts, exact filter targets, colours, sorting and window interactions. Superseded fixed-number expectations were replaced to match the explicit user correction; scanner/import baselines are unchanged. Verified: 51 targeted UI/version/architecture/Context-boundary tests, design-language and local-only checks. All 504 mandatory import checks and both production builds passed; the existing client chunk-size warning remains. Browser acceptance passed on Dummy at 2.23.20 in Light and Dark: all 20 package group headings match the corresponding full heatmap positions and sidebar headings, with Cloud Privileged Access Exposure at 01 and Credential Relay Exposure at 05. Selecting AP-01 displays the matching 01 Reader link and keeps all 20 groups in the open package window. No backend restart, database write, full-suite or phase-completion claim; concurrent workspace work preserved.
|
||||
|
||||
Change log 2026-10-11: made published heatmap positions the common cluster numbering and grouped the package table by those positions.
|
||||
|
||||
## Radius-only refinement (2026-10-11, Phase 7 Presentation)
|
||||
|
||||
Current Core release: Presentation, Platform, Core Tests. User approval explicitly supersedes the earlier square-tile agreement only for corner radii: tiles and popup exteriors use 4px, ordinary buttons and inputs use 3px. Existing transparent segments, shell geometry, circular controls, joined finding rows, table cells, separators and Context colour squares retain their geometry. No content, navigation, layout, spacing, font, colour, border width, shadow, overflow, interaction or data changes are authorized by this request.
|
||||
|
||||
Shared radius tokens and the existing container/control radius declarations implement the change; popup headers follow the exterior corners without clipping or new overflow rules. Data Hub reference modules also receive the same exterior radius. Each own CSS mutation is audited before/after: removing only radius declarations yields identical CSS. The full stylesheet comparison also preserves concurrent independent package-table changes in components.css. The existing tile grammar acceptance is updated to the explicitly approved shared tile radius, retaining all surface, geometry and shadow assertions; the legacy large-radius allowance decreases by one for the corrected common dialog. Patch decision: 2.23.19 from concurrent 2.23.18, then retained the concurrent 2.23.20 release and synchronized version contracts. A newly concurrent Remediation test fixture needed its existing dataset metadata spread to satisfy the production type check; its behavioral assertions remain unchanged. Verified: 42 final tile/package/version checks, the prior two actual Reader checks, design and local-only checks. Browser comparisons confirm identical tile dimensions, padding, fonts, backgrounds, text colours and border widths in Light/Dark, with only 0px to 4px radius; the common dialog likewise retains identical measured dimensions and style roles while changing 14px to 4px. All 504 mandatory import checks and both final production builds passed at 2.23.20. Final Light/Dark browser checks and screenshots passed; Data Hub reference-module dimensions and all non-radius style roles are identical before/after. Controls resolve to 3px; tiles, reference modules and common dialogs resolve to 4px. UI/backend 2.23.20 use the unchanged canonical PostgreSQL revision 592. No full-suite or phase-completion claim.
|
||||
|
||||
Change log 2026-10-11: applied only the approved subtle corner radii to shared tiles, controls and popup exteriors.
|
||||
|
||||
## Work Packages minimized parking (2026-10-11, Phase 7 Presentation)
|
||||
|
||||
Current Core release: Presentation, Core Tests, Platform. Work Packages adds a minimize icon before Maximize and Close. Minimizing from normal or maximized mode parks a compact 320px title strip at the top right, 8px below the actual Source: Scan filter, with a toolbar fallback when the filter is absent and viewport clamping. Close stays rightmost. The shared floating window offers this optional capability only when an anchor selector is supplied; existing Client Info windows retain their controls and behavior.
|
||||
|
||||
The minimized body stays mounted but hidden, keeping table filters, sort and data intact. Click the parked title or Restore icon to reopen at the normal right parked bounds; header double-click also restores it. While minimized, dragging/resizing and maximization controls are unavailable. Existing explicit Close/Escape and engine unavailability apply. No package assignment, source, scoring, export or stored data changes.
|
||||
|
||||
Patch decision: 2.23.18 from concurrent 2.23.17; package/lock/UI/API contracts synchronized. Permanent Light/Dark actual-toolbar interactions cover Source: Scan anchoring, normal/maximized minimization, title/icon restore, retained filter/sort/table contents, hidden body, absence of resize handles and rightmost Close. Verified: 37 targeted package/shared-dialog/version/architecture tests, design-language and local-only checks. All 504 mandatory import checks and both production builds passed; the existing client chunk-size warning remains. No backend restart, database change, full-suite or phase-completion claim; concurrent workspace changes preserved.
|
||||
|
||||
Change log 2026-10-11: added Work Packages minimization to a restorable title strip below Source: Scan.
|
||||
|
||||
## Reader AP Context colour square (2026-10-11, Phase 7 Presentation)
|
||||
|
||||
Current Core release: Presentation, Platform, Core Tests. The AP link beside Source Rule ID starts with the same 12px square as Filters and Work Packages, using the shared published Context severity. The Reader passes this read-only presentation value to Remediation; Remediation does not import the Context Engine. Its existing Core extension point now also pins the published Context reader. The shared Context index accepts an absent optional application scope without requesting data, preserving standalone Reader use; valid application scope, shared requests and last-ready values stay unchanged. Unknown/unavailable colour is neutral; package/task link semantics and right alignment remain unchanged.
|
||||
|
||||
Patch decision: 2.23.17 from concurrent 2.23.16; package/lock and UI/API contracts synchronized. Permanent actual Reader interactions cover all four Context colours, source-ID present/absent, both themes, wrapping roles and the AP click. Verified: 54 Reader/package/Context/architecture checks, 12 additional Client Info regression checks, 12 UI/API version checks, design-language and local-only checks, all 504 mandatory import checks and both production builds. Local backend restarted at 2.23.17 against the unchanged canonical PostgreSQL repository revision 592. Actual browser acceptance verified the orange published Credential Relay AP-03 square before the right-aligned link in Light/Dark, with the existing six-task label and source ID intact; screenshots saved. No full-suite or phase-completion claim.
|
||||
|
||||
Change log 2026-10-11: prefixed Finding Reader AP links with the shared published Context colour square.
|
||||
|
||||
## Work Packages right parking and persistent interaction (2026-10-11, Phase 7 Presentation)
|
||||
|
||||
Current Core release: Presentation, Core Tests, Platform. User correction supersedes the former left parking and close-on-selection behavior: Work Packages opens 16px from the right viewport edge below the toolbar and returns there after leaving maximized mode, through either the header button or header double-click. Its 830px preferred width and 1080px preferred height remain viewport-clamped. The shared floating window accepts an optional right inset; other windows keep their existing defaults. Manual dragging/resizing remain available.
|
||||
|
||||
Package selection, both Unassigned links and the Redundant findings summary retain the open window while applying their existing exact Work filter or evidence selection. Table sorting, filtering and totals stay available; explicit Close/Escape and engine unavailability still remove the window. No package membership, source values, scoring or stored data changes.
|
||||
|
||||
Patch decision: 2.23.16 from 2.23.15; package/lock/UI/API contracts synchronized. Permanent Light/Dark actual interactions verify right opening/reopening, maximize/restore by button and double-click after viewport width changes, retained visibility after package, both Unassigned and redundant-evidence selections, unchanged source data and existing shared Client Info dialogs. Verified: 35 targeted interaction/version/architecture tests, design-language and local-only checks. All 504 mandatory import checks and both production builds passed; the existing client chunk-size warning remains. No backend restart, database change, full-suite or phase-completion claim; concurrent workspace changes preserved.
|
||||
|
||||
Change log 2026-10-11: parked Work Packages on the right on opening/restoring and kept it visible through its Work selections.
|
||||
|
||||
## Work Packages toolbar position and outline (2026-10-11, Phase 7 Presentation)
|
||||
|
||||
Current Core release: Presentation, Core Tests, Platform. User correction places the package overview button immediately before CSV, renames its visible label to Work Packages and keeps a yellow border through the shared button-accent token in both themes, including hover and the open state. Existing engine availability, overview interactions and export behavior remain unchanged.
|
||||
|
||||
Patch decision: 2.23.15 from 2.23.14; package/lock/UI/API contracts synchronized. Permanent actual-toolbar Light/Dark interaction acceptance covers label, DOM order and the shared yellow border before opening, while open and after closing. JSDOM resolves only the shared accent token for its CSS cascade assertion. Verified: 24 targeted interaction/version tests, design-language checks and all 504 mandatory import checks. Local-only check and both production builds passed; the existing client chunk-size warning remains. No backend restart, database change, full-suite or phase-completion claim. Concurrent workspace changes preserved.
|
||||
|
||||
Change log 2026-10-11: moved and renamed Work Packages and added its persistent yellow outline.
|
||||
|
||||
## Package colours, parked geometry and same-change overview (2026-10-11, Phase 7 Presentation)
|
||||
|
||||
Current Core release: Presentation, Platform, Core Tests. Every Package row starts with the shared 12px Context colour square, using the same published severity as Filters and its Context severity column. Unassigned keeps its existing separate presentation. Work packages supplies an optional initial geometry to the shared floating window: 830px width, up to 1080px height, 16px left inset and 16px below the toolbar, clamped to the available workspace. Other floating views retain their previous defaults. Every fresh opening restores this parked geometry; drag, resize and maximize remain available.
|
||||
|
||||
The Redundant findings number opens the complete evidence population of all published redundant tasks in Work, including each task's counterpart findings. It uses the existing exact task identities and related-finding groups, clears an active single-group focus and incompatible Work filters, selects the project Work sheet and Reports view, and closes Packages. Latest user correction: the headline is Redundant findings and counts every member of the published redundant tasks, matching the population opened in Work (for example twelve rather than six additional findings). Records, reviews, package/source facts, outcomes and scores stay unchanged.
|
||||
|
||||
Patch decision: 2.23.13 from 2.23.12; concurrent workspace work advances the shared version to 2.23.14, which is retained with synchronized package/lock and UI/API version contracts. Permanent Light/Dark package interactions check the initial bounds, colour parity and the actual summary-to-Work interaction with source data unchanged. Verified: 32 Package/related-finding/architecture checks, 14 UI/API version and Reader alignment checks, design-language/local-only checks, all 504 mandatory import checks and both production builds. App/backend run 2.23.13 against canonical PostgreSQL revision 592 unchanged. Actual browser acceptance pending. User correction supersedes the earlier additional-only headline: the engine view now shows the complete redundant-finding count under Redundant findings; 39 interaction/version checks, all 504 import checks and both production builds passed for the corrected count. Actual Light/Dark browser acceptance confirms twelve Redundant findings and a click opens exactly twelve Work records. Reopening Packages keeps all 49 packages despite the twelve-record Work drilldown. Default bounds in both themes are width 830px, left 16px, top 16px below the toolbar and available workspace height; square colours match the published high/critical values. Light restored; screenshot saved. App/backend run the shared 2.23.14 against canonical PostgreSQL revision 592 unchanged. No phase-completion claim.
|
||||
|
||||
Change log 2026-10-11: added Context squares, a dedicated parked window default and a Work drilldown for all redundant evidence.
|
||||
|
||||
## Context severity order in both sidebar filters (2026-10-11, Phase 7 Presentation)
|
||||
|
||||
Current user release: Presentation, Core Tests, Platform. User correction supersedes the earlier identifier-first sidebar order: Context Cluster and Work Package filters now sort by the Context heatmap's displayed severity, Critical / High / Medium / Undetermined. Stable two-digit cluster identifiers break equal-severity ties; packages remain grouped by exact published cluster ID and numerically ordered AP code. Missing/no-evidence Context publication stays neutral, and Unassigned remains last. Numbers are identifiers and are never renumbered by severity or filtering. No package assignment, source values, verdicts, scoring, counts or engine logic changes.
|
||||
|
||||
Both filters share a Presentation comparator reading only the published Context cluster severity and evidence flag; source-finding severity is not substituted. Permanent actual-sidebar Light/Dark interaction acceptance covers Critical-before-01, numeric ties/APs, medium, a Critical catalog entry without evidence sorted neutral, missing publication, exact package-filter click, unchanged original records, Unassigned and docking.
|
||||
|
||||
Patch decision: 2.23.14 from concurrent 2.23.13, synchronized package/lock/UI/API contracts. Verified: 61 distinct sidebar/filter/dock/remediation/version/architecture tests, all 504 mandatory import checks including API version, design-language and local-only checks. Dummy API identity audit succeeded after an initial transient Context HTTP 500: all-assessment scope contains 54 packages, 477 assigned findings, 465 tasks and one Unassigned. Every individual package finding matches its Context primary cluster ID and label, task evidence equals package evidence, package membership is exclusive and repository revision is unchanged. This audit scope differs from the screenshot's 274-finding selection; visible screenshot AP sums 16 / 23 / 36 / 37 / 7 / 6 reconcile to the corresponding heatmap cells. No wrong assignment was detected in the audited scope; the screenshots alone do not validate each member of their selected scope. Both production builds passed; the existing client chunk-size warning remains. No backend restart, Core data write, full-suite or phase-completion claim; unrelated workspace changes preserved.
|
||||
|
||||
Change log 2026-10-11: aligned Context/AP sidebar ordering with published Context severity and audited exact Dummy package membership.
|
||||
|
||||
## Complete Packages scope and compact facts (2026-10-11, Phase 7 Presentation)
|
||||
|
||||
Current Core release: Presentation, Platform, Core Tests. User correction supersedes the earlier Work-filter projection: Packages displays the complete published Remediation snapshot for the selected client/assessment scope, independent of Work search, status, facets and workspace filters. Only the table's own column filters reduce its rows; headline totals remain the full scope. The hook publishes this read-only overview alongside its existing index. Actual scope or dataset changes still refresh the published results.
|
||||
|
||||
Removed the complete Highest finding severity and Critical/High columns. Kept shared published Context severity, numerical cluster/AP order, Unassigned selection and yellow open/header indicators. Replaced explanatory paragraphs with six facts in the shared Client Info fact-table style and one short package-size note. Findings = assigned + Unassigned; additional same-change findings = assigned findings minus tasks. Original finding/source/scoring semantics and stored data remain untouched.
|
||||
|
||||
Patch decision: 2.23.12 from 2.23.11; package, lock and UI/API version contracts synchronized. Verified so far: 62 Remediation interaction/engine/catalog/architecture checks, 20 version/Finding integration checks, design-language and local-only checks, and all 504 mandatory import checks. Permanent Light/Dark interaction coverage checks identical full table content and six totals with a Work search selecting 16 findings, one same-change member, an empty Passed view, and the Unassigned facet after reopening. Both production builds passed. Actual Light/Dark browser acceptance confirms the compact fact table and nine remaining columns. All 49 package rows and their complete cell contents stay identical across a Print spooler search (four visible Work records), Passed status (265 Work records), and the Purple Knight source filter. The table-local Cloud/M365 filter reduces rows to 23 while the six full-scope totals remain unchanged; clearing it restores all 49. App/backend run 2.23.12 against canonical PostgreSQL revision 592 unchanged. Existing client chunk-size warning remains; no full-suite or phase-completion claim.
|
||||
|
||||
Change log 2026-10-11: decoupled Packages content from Work filters, removed both unwanted columns and condensed the header facts.
|
||||
|
||||
## Packages active button and header marker (2026-10-10, Phase 7 Presentation)
|
||||
|
||||
Current release: Presentation, Platform, Core Tests. The Packages toolbar button carries the shared yellow marked state while its window is rendered, including hover. Closing with X, Escape or selecting a package clears the visible active state. The Work packages header receives a left yellow marker using the shared marker-width (8px) and marking-color tokens. A default-off FloatingDataWindow header option applies it only to Work packages. Existing number order, filters and source facts remain unchanged.
|
||||
|
||||
Patch decision: 2.23.11 from 2.23.10; version contracts synchronized. Existing Light/Dark package interaction acceptance now checks inactive/open/closed/reopened/selected states and the marked window header. Verified: 19 package interaction checks, five architecture checks, UI/API version contracts, all 504 mandatory import checks, both production builds, design-language and local-only checks passed. Actual Light/Dark browser acceptance confirms the open button and header marker share the yellow token, marker-width is the shared 8px token, X and Escape clear the active button and reopening restores it. The screenshot documents both markers together. App/backend run 2.23.11 with canonical PostgreSQL revision 592 unchanged. Existing chunk-size warning remains; no full-suite or phase-completion claim.
|
||||
|
||||
Change log 2026-10-10: linked Packages active indication to its window and added the standard header marker.
|
||||
|
||||
## Package number order and fewer column filters (2026-10-10, Phase 7 Presentation)
|
||||
|
||||
User correction explicitly supersedes the earlier default Context-severity ordering: packages open in ascending displayed cluster identifier order (01, 02, 03, ...), then AP identifier order within the cluster. Clicking Package uses this same numbered label, rather than alphabetical cluster names. Severity sorting remains an explicit column-header action only. Remove the Highest finding severity and Critical/High filter controls while retaining their displayed facts and sort buttons; Measure remains without a filter. Context severity, identifiers and engine/source calculations are unchanged.
|
||||
|
||||
Implementation stays within the registered Remediation Engine view and interaction tests. Current Core release: Platform, Core Tests for patch/version contracts. Patch decision: 2.23.9 from 2.23.8; concurrent workspace work advances the shared release to 2.23.10, which is retained. Nineteen interaction tests passed, including permanent Light/Dark regressions for 01/AP-01, 01/AP-02, 02, 03, 12 order on open, filter/clear, manual severity sorting followed by Package sorting, and reopening. The two removed controls are absent while both columns remain. Verified: all 504 mandatory import checks, both production builds, UI/API version contracts, five architecture checks, design-language and local-only checks passed. A TypeScript fixture-shape error was repaired before the successful full build. Actual browser acceptance in Light and Dark confirms both unwanted filters absent with their columns retained; all 49 real package labels are numerically ascending, beginning 01/AP-01 through AP-04, then 02/AP-01 through AP-03, then 03. The app opens by Package ascending. Light restored. App/backend run 2.23.10 with canonical PostgreSQL revision 592 unchanged. Existing client chunk-size warning remains; no full-suite or phase-completion claim.
|
||||
|
||||
Change log 2026-10-10: made cluster/AP number order the package default and removed two unwanted filters.
|
||||
|
||||
## Close X always at the right edge (2026-10-10, Phase 7 Presentation/Governance)
|
||||
|
||||
Current user release: Presentation, Governance, Core Tests, Platform. Binding user design principle: close X is always the last/rightmost header action, including visual and DOM/keyboard order. Added this permanent agreement to AGENTS.md. Results now place the stable optional same-change-ring slot before the close button, correcting the preceding trailing-ring placement while preserving aligned actions, full title tooltips and ellipsis. No group identity, filtering, source value, export scope or engine behavior changes.
|
||||
|
||||
Patch decision: 2.23.10 from concurrent 2.23.9, synchronized package/lock/UI/API contracts. Permanent actual Results acceptance verifies X-last and ring-before-X in minimized and expanded windows, with/without a group and both themes; existing minimize/restore and close callbacks remain covered. Isolated actual Chrome acceptance verifies the rightmost geometry and hit-testable controls in both themes at 440/760px window widths. Verified: 27 targeted Results/group/version/architecture tests, all 504 mandatory import checks including API version, both production builds, design-language and local-only checks. The first build encountered a concurrent incomplete Work Package test fixture; that separate fixture was corrected concurrently, and the complete gated build then passed. Existing client chunk-size warning remains. No backend/datastore restart, full-suite or phase-completion claim; unrelated changes preserved.
|
||||
|
||||
Change log 2026-10-10: made close X the rightmost Results header action and established the rule in project governance.
|
||||
|
||||
## Package column selections and published Context severity (2026-10-10, Phase 7 Presentation)
|
||||
|
||||
Current user release: Presentation, Platform, Core Tests. This corrects the preceding package-table implementation: column filters offer actual distinct column values through selects; Measure has no filter. Source selection matches each individual source in multi-source packages. Context severity now comes from the same published cluster and shared display rule as the Work Package filter; it is explicitly separate from original Highest finding severity and defaults to Critical-first ordering. Cluster identifiers, Work counts, Unassigned and source facts remain unchanged. This supersedes the prior free-text filter and default finding-severity sort decisions.
|
||||
|
||||
The overlap was caused by the global sticky th rule applying to every body row heading. Only the table head now sticks; body headings explicitly remain static. A permanent rendered interaction test loads the real stylesheet and scrolls the package table. Real Sidebar/Packages interaction tests cover published severity updates, GPO packages without source severity, neutral no-evidence clusters, selectable values and no Measure filter in both themes.
|
||||
|
||||
Patch batch: 2.23.8, shared with the concurrent Results-header refinement. Verified so far: 75 targeted remediation, presentation, architecture and UI/API-version tests across the focused run and corrected scroll-test rerun; all 504 import compatibility checks, design-language and local-only checks pass. Both production builds passed. Actual browser acceptance in Light and Dark confirms all 49 package Context severities match their 20 sidebar clusters, six GPO packages retain a displayed High Context severity despite absent original finding severity, Critical selection yields 11 packages, High selection 34 and GPO source selection six. Header remains at the viewport top after 5,912 pixels of Light and 3,860 pixels of Dark scrolling while body headings scroll away. No Measure filter exists. Light, empty column filters and default Context Critical-first ordering restored. App and backend report 2.23.8; canonical PostgreSQL repository revision remains 592. Existing chunk-size warning remains. No full-suite or phase-completion claim; no import contract, engine calculation, persistence or source severity changes.
|
||||
|
||||
Change log 2026-10-10: corrected package selection filters, sticky row-heading overlap and Context severity consistency.
|
||||
|
||||
## Stable Results header actions with long titles (2026-10-10, Phase 7 Presentation)
|
||||
|
||||
Current user release: Presentation, Core Tests, Platform. User screenshot shows minimized Results rows whose long title displaces actions. The title heading now owns only the flexible remaining width, and the title uses a real text ellipsis with its complete text in the native tooltip. Actions retain their fixed non-wrapping area. An equal-width trailing slot holds the optional existing same-change ring, so rows with and without a proven group align identically; no group or ring is invented for unrelated findings. Existing ring click/focus semantics, controls, export behavior, minimize/restore and source data remain unchanged. Both expanded and minimized headers use the same layout.
|
||||
|
||||
Patch decision: 2.23.8 from 2.23.7, synchronized package/lock/UI/API contracts. Verified so far: 43 targeted Results layout, actual bookmark/To Report/minimize/restore/close interactions, export and existing real group-focus checks. Isolated actual Chrome acceptance in both themes at 440/760px Results widths proves identical action positions, title truncation/full tooltip, contained and hit-testable enabled controls, and minimize/restore. The browser's marker is a synthetic geometry fixture; permanent actual group interactions remain separately covered. Existing viewport-under-620px global positioning override was observed to place stacked windows at the same top coordinate; it is separate from the reported normal Work header issue and remains outside this scoped correction. Final verification: 49 targeted Results/group/export/version/architecture checks, all 504 mandatory import checks including API version, both production builds, design-language and local-only checks passed. Existing client chunk-size warning remains. No backend/datastore restart, full-suite or phase-completion claim; concurrent workspace changes preserved.
|
||||
|
||||
Change log 2026-10-10: constrained Results title width and reserved a stable trailing group-ring slot to keep header actions aligned.
|
||||
|
||||
## Results workbook tab names and headings on every sheet (2026-10-10, Phase 7 Disclosure)
|
||||
|
||||
Current user release: Disclosure, Core Tests, Platform. User correction: the first Results workbook tab is Results; remove the separate Results text tab; every worksheet carries the finding heading. The first exported table now uses Results, with further exported tables named Results 02, Results 03, etc., including selected-table exports. Each table retains its original table heading inside the larger bold white-on-black finding title band. Text-only results also use Results. Complete non-table result prose is retained below the first table instead of a separate sheet; its original data filter ends before that text. Selected exports retain only their selected evidence tables and the existing finding context.
|
||||
|
||||
All auxiliary worksheets now receive the same finding heading and two-row title/header structure: Source text, Finding context, Export info and Long text. Physical row splitting reserves title/header and prose footer rows, repeats the title/header for split sheets and never silently loses evidence. Long-value references now use the shifted physical row coordinates; Long text parts remain literal and complete. A title exceeding Excel's cell limit is preserved through the existing long-value mechanism; the Long text sheet points to that complete title instead of duplicating an over-limit cell. Original values, counts, Context/Problem information and export scopes remain unchanged. No Presentation files, profiles, source data, Result DB policy, backend lifecycle or datastore changes.
|
||||
|
||||
Patch decision: 2.23.7 from concurrent 2.23.6, synchronized package/lock/UI/API contracts. Targeted export checks pass for full/selected/text-only paths, all original 1,001 rows, prose retention, original context, literal formula-looking values and complete Unicode long-text parts. Final OOXML acceptance checks every sheet's original finding title, bold white font and black fill, including Long text. Verified: 20 targeted export/context/version/architecture tests, actual full and selected Chrome downloads in both themes, all 504 mandatory import checks including the API-version contract, both production builds, design-language and local-only checks. Existing client chunk-size warning remains. No full-suite or phase-completion claim; unrelated workspace changes preserved.
|
||||
|
||||
Change log 2026-10-10: named Results tabs consistently, integrated the separate result prose and added the finding heading to every worksheet.
|
||||
|
||||
## Persistent, filterable and numbered package table (2026-10-10, Phase 7 Presentation)
|
||||
|
||||
Current user release: Presentation, Platform, Core Tests. The package overview uses the same permanent Context identifiers as the Work filter, supplied through the existing Core extension point; Remediation does not import Context internals or invent assignments. Highest severity now defaults to descending (Critical first), with stable cluster/package identifiers as tie-breaks. Every column has an independent accessible filter; filters combine, numeric comparisons are supported and a clear action restores the table. Column filters refine the table only, with a separate visible row count; the authoritative Work partition and task totals stay unchanged. Unassigned remains available from the summary and is subject to explicit table filters in the table. The table owns both scroll axes and its opaque header remains sticky within that viewport, including the filters.
|
||||
|
||||
The suspected engine refresh defect was not reproduced: synthetic actual-view acceptance proves a new dataset prepares Context before requesting packages, refreshes an already open overview, suppresses the previous dataset's late response and hides all package output with Remediation disabled. Existing context-off snapshot and task/record invariance contracts remain unchanged. Missing numbers were a presentation omission, fixed by reusing the approved numbered label function. No database, assignment, scoring, engine eligibility or source-data changes.
|
||||
|
||||
Verified: 32 focused actual-view, remediation and architecture checks, the UI version contract, all 504 mandatory import/API-version checks, both production builds, design-language and local-only checks. Patch decision: 2.23.6 from concurrent 2.23.5, version contracts synchronized. Actual browser acceptance confirms all six Critical packages precede High, stable numbered labels, severity filtering to six of 49 packages and cluster 13 filtering to two of 49, with unchanged Work totals of 274 = 258 + 16. In both themes the header stays at the table viewport top while vertically scrolling (measured zero offset, including over 4,400 pixels of scrolling in Dark); all ten filters remain in that sticky header and its background is opaque. Light Mode, default Critical-first order and empty column filters restored. The previously loaded browser used 2.23.3; the new client and restarted backend both report 2.23.6. Canonical PostgreSQL volume retained, repository revision unchanged at 592. Existing client chunk-size warning remains; no full-suite or phase-completion claim.
|
||||
|
||||
Change log 2026-10-10: added sticky column filters, Critical-first sorting and shared Context identifiers to the work-package overview.
|
||||
|
||||
## Finding context in every Results Excel export (2026-10-10, Phase 7 Presentation/Disclosure)
|
||||
|
||||
Current user release: Presentation, Disclosure, Core Tests, Platform. User request: include the finding Description and Problem information to explain the exported Results, for both entire and selected-table downloads. A Finding context sheet now accompanies both export paths when explanatory information exists. Actual Results tables remain first, or Results text for a text-only finding; context and other auxiliary sheets follow. The context has the finding title in the existing larger bold white-on-black title band and structured Section / Field / Value columns.
|
||||
|
||||
Presentation reuses the Finding Reader's declared profile mappings and existing GPO problem-summary projection from the opening finding. Description and declared background fields, Result, Affected and Impact retain original native values (including complete impact reference text); Reader evidence statements without a dedicated native field use its existing presentation. GPO context retains Configured, Expected, Additional/Missing entries, Context and Flags from stored comparisons. Unknown profiles accept only explicitly named description/result-message/affected/impact fields; a bare Result without a separate status field is not reinterpreted. No export-row count substitutes for a native Affected value, and selective export retains the complete finding context while restricting evidence tables. Missing values are omitted, never invented. Existing long-value preservation covers context cells, including Unicode boundaries. Work, graph and Reader Results share the same export path. No importer, verdict, scoring, database, engine or Result DB-policy change.
|
||||
|
||||
Patch decision: 2.23.5 from 2.23.4, synchronized package/lock/UI/API contracts. Verified: 19 distinct focused actual-download, export-format, long-text, GPO projection, version and architecture tests; all 504 mandatory import checks including API version; design-language and local-only checks; actual Chrome full/selected downloads in both themes retaining all 1,001 evidence rows and identical original finding context; both production builds passed. Synthetic acceptance proves native Affected 17 remains 17 when two or one evidence rows/tables are exported, missing context stays absent and original records remain unchanged. Existing client chunk-size warning remains. No full-suite or phase-completion claim; unrelated workspace changes preserved and no backend/datastore restart.
|
||||
|
||||
Change log 2026-10-10: added original Description and Problem context to both Results XLSX export scopes.
|
||||
|
||||
## Grouped Work Package sidebar (2026-10-10, Phase 7 Presentation)
|
||||
|
||||
Current user release: Presentation, Core Tests, Platform. User-approved refinement supersedes the per-AP bottom colour stripe: show each Context cluster once as a neutral heading with its existing solid severity colour in a small square at the right, then compact AP rows with right-aligned finding counts. Sort permanent cluster identifiers numerically from 01 and package codes numerically from AP-01. Preserve stable identifiers when a cluster is absent; never renumber the filtered population. Unassigned remains selectable at the end. The Context Cluster facet follows the same identifier order.
|
||||
|
||||
Presentation consumes exact published package cluster IDs, cluster labels and AP codes; no title parsing, new assignment, engine computation or source mutation. The generic dockable panel retains its original callbacks, selected values, disclosure state and keyboard controls; full cluster/package accessible names distinguish repeated AP codes. Published cluster colour stays independent of the amber selection marker, and missing cluster evidence stays neutral. Existing resize/dock behavior and all other filter groups remain intact.
|
||||
|
||||
Patch decision: 2.23.4 from concurrent 2.23.3, synchronized package/lock/UI/API contracts. Verified: 25 focused filter/selection/docking/architecture/UI-version checks, all 504 mandatory import checks including API version, both production builds, design-language and local-only checks, and isolated actual Chrome geometry and keyboard acceptance in both themes. Synthetic actual-sidebar acceptance covers shuffled clusters/APs, first identifier 01, retained counts, one colour per heading, absent-evidence neutral, Unassigned, toggling off and dock/undock selection. Existing client chunk-size warning remains. No backend restart, datastore mutation, full-suite or phase-completion claim; unrelated workspace work preserved.
|
||||
|
||||
Change log 2026-10-10: grouped Work Package filters by numbered Context cluster and replaced bottom stripes with right-hand colour squares.
|
||||
|
||||
## Complete and reconciled work package overview (2026-10-10, Phase 7 Presentation / Remediation display)
|
||||
|
||||
Current user release: Presentation, Platform, Core Tests. User requests an explanation of the visible 274 / 258 / 252 discrepancy and inclusion of Unassigned. The existing overview omitted the published Unassigned bucket and displayed whole-scope package counts independently of active Work filters. It now consumes the exact displayed Work records through the existing read-only extension point, projects each existing package's visible findings and touched published task identities, and states a complete partition: assigned + published Unassigned + records outside the package snapshot = current Work view. Outside-snapshot records are explicitly retained, without guessing a new assignment or changing engine eligibility. Original packages, tasks, identity evidence, counts outside this view, verdicts and stored snapshots remain unchanged. Full-package measures, severity, sources and condition facts retain their original values and are labelled as such.
|
||||
|
||||
Unassigned is always visible as a separate table row, with its finding count and no invented task/package count; the count at the top also selects the exact existing Work Package Unassigned facet. Both paths preserve other Work filters and close the overview. Package counts exclude Unassigned. Additional same-change findings are explained separately from unique tasks. Synthetic actual-view acceptance reproduces 49 packages, 274 records = 258 assigned + 16 Unassigned, and 252 tasks with six additional same-change findings, in both themes; it tests both Unassigned click paths, zero/only-Unassigned, search narrowing to one group member, immutable original records/snapshots and outside-snapshot reconciliation. Existing limits of eight tasks, fifteen for Windows host/GPO, and source-backed redundancy rules remain binding.
|
||||
|
||||
Verified: 29 focused actual-view, remediation and architecture checks, the UI version contract, all 504 mandatory import/API-version checks, both production builds, design-language and local-only checks. Patch decision: 2.23.3 from 2.23.2; package/lock/UI/API version contracts synchronized. Read-only inspection of the existing OpenTAS snapshot at repository revision 592 confirms 49 packages, 258 findings, 252 tasks and 16 Unassigned. Browser acceptance confirms the complete 274 = 258 + 16 partition, both Unassigned click paths selecting exactly 16 Work findings, the narrowed overview showing 0 packages/0 tasks plus 16 Unassigned, and readable Light/Dark rendering; Light Mode and the full overview restored. No full-suite or phase-completion claim; unrelated workspace work preserved. Existing client chunk-size warning remains.
|
||||
|
||||
The subsequently verified concurrent sidebar refinement at 2.23.4 is retained. The restarted local backend reports 2.23.4 with canonical PostgreSQL persistence and unchanged repository revision 592.
|
||||
|
||||
Change log 2026-10-10: included Unassigned and explained complete current-view finding/task counts in the package overview.
|
||||
|
||||
## Results Excel headings and sheet order (2026-10-10, Phase 7 Presentation/Disclosure)
|
||||
|
||||
Current user release: Disclosure, Core Tests, Platform. User decision: each exported Results table begins with a larger bold white heading on a solid black background, and actual Results start at worksheet one; auxiliary information follows all result tables. Both entire and selected Results XLSX exports follow this order. A text-only Result uses its Results text as the first worksheet with the same styled finding heading.
|
||||
|
||||
Each table now starts with a merged title band containing the finding name and table heading, Inter 16pt bold, white text and solid black fill, wrapped with a taller first row. Column headers occupy row two; filtering starts there and excludes the title. Data begins at row three. The table split capacity reserves both title and column-header rows and repeats them per part. Long-value coordinates reflect their physical output cells. Table order, literal strings, exact source cells, empty evidence rows, complete long-text parts and selective-export scope stay preserved. Results text, original section text, export manifest and any long-text parts follow the actual tables. No source data, import contract, Result DB policy, presentation component, persistence or backend lifecycle changes.
|
||||
|
||||
The existing SheetJS writer ignores font/fill cell styles, so the export applies the approved title font/fill/XF directly to its generated OOXML using SheetJS's existing CFB ZIP API. It changes only the generated styles part and the first-row style references; XML is UTF-8 encoded. No new dependency, imported-file rewriting or browser-specific API in Disclosure. Permanent acceptance inspects the final ZIP's exact font, fill and style references, merged heading geometry, larger row height, column-filter range, worksheet order and unchanged table values for both complete and selective exports. Existing 1,001-row real-view tests continue with the added title row. Verified: 15 targeted formatting/export/version/architecture checks, all 504 mandatory import checks including the API-version contract, both production builds, design-language and local-only checks, actual complete/selected browser downloads in both themes, and a read-only full Dummy round-trip retaining all 2,420 tables and 10,208 evidence rows. Long headings receive height according to their wrapped length, within Excel's physical row-height limit. Existing React list-key and client chunk-size warnings remain. No full-suite or phase-completion claim. Patch decision: 2.23.2 from current concurrent workspace 2.23.1; package/lock/UI/API contracts synchronized. Unrelated concurrent changes preserved; no phase-completion claim.
|
||||
|
||||
Change log 2026-10-10: formatted Results XLSX title bands and moved actual Results tables ahead of auxiliary sheets.
|
||||
|
||||
## Cluster identifiers and freely resizable filters (2026-10-10, Phase 7 Presentation)
|
||||
|
||||
Current user release: Presentation, Platform, Core Tests. Explicit user correction supersedes the previous package Severity distribution: each assigned AP now uses one solid colour from its published Context cluster severity, independent of the original findings' severities. Missing/unavailable cluster evidence stays neutral. All current clusters have permanent two-digit display identifiers, shared by the Context Cluster sidebar and their AP rows; filtering, sorting and package selection never renumber them. Numbers identify clusters, never priorities. Existing published read-only results, package boundaries, facet counts, original values and engine independence remain unchanged.
|
||||
|
||||
Floating filters resize from all four edges and corners, with anchored opposite edges, viewport bounds and minimum usable dimensions. Keyboard arrows operate the same grips. Resize gestures never trigger docking; docking, disclosures and exact filter selections remain intact. The shared uppermost normal UI layer keeps filters above activated Results windows. Resize events use stored geometry and ignore temporary 1-by-1 browser capture viewports; permanent synthetic interaction acceptance covers both capture cases, every edge/corner in both directions, docking and repeated activation of real Results windows in both themes.
|
||||
|
||||
Initial patch decision: 2.23.1 from concurrent 2.23.0; the subsequent concurrent Results Excel patch 2.23.2 is retained. Final verification at 2.23.2: 25 focused actual-sidebar, resize/dock/filter, Results stacking, architecture and UI-version checks; all 504 mandatory import/API-version checks; both production builds; design-language and local-only checks. Browser acceptance on synthetic Dummy confirms 54 assigned AP rows with one cluster colour each, matching stable Context numbers, all eight keyboard resize grips, reversible AP selection, hit-testing above newly activated Results and unchanged panel dimensions across screenshots, in both themes. Light Mode restored. Canonical PostgreSQL revision remains 592. Existing client chunk-size warning remains; unrelated concurrent work preserved; no full-suite or phase-completion claim.
|
||||
|
||||
Change log 2026-10-10: replaced package distributions with numbered Context cluster colours and added eight-direction floating filter resizing above Results.
|
||||
|
||||
## Complete Results and Excel exports (2026-10-10, Phase 7 Presentation)
|
||||
|
||||
Current user release: Presentation, Ingest, Disclosure, Core Tests, Platform. User decision: Results exposes every available row; one always-active share icon exports the entire Results content to Excel, and a differently coloured second share icon exports exactly the selected tables. Each Results table has a checkbox; selective export is neutral and disabled until at least one current table is selected. The user explicitly approves distinct export icon colours as an exception to the general button colour role. Selection is local to each Results window; it never changes findings, filters, bookmarks, source values or Result DB disclosure.
|
||||
|
||||
All linked and Markdown rows were already rendered without a 100-row cutoff. No such cutoff or native truncation warning was found in the current complete Dummy. Native HTML tables now also render all retained rows through the shared inert Results table component. No importer contract, source recognition, resource-safety limit, outcome, score or persistence behaviour is changed. An upstream report that omits source rows cannot be reconstructed by presentation/export; its original text is retained, not silently rewritten.
|
||||
|
||||
The complete XLSX contains Results text, original section values and every rendered table; linked table cells come from their original retained source values. GPO variant evidence and collapsed technical details are included. Selective XLSX contains exactly the checked tables with an export manifest. Literal strings retain leading zeros and do not become formulas. Values beyond Excel's 32,767-character limit are preserved in ordered Long text parts without splitting UTF-16 surrogate pairs; tables exceeding Excel's physical row limit split with repeated headers. Export errors produce no partial download. Export reads only the current Results content, never broadens a Result DB query, and uses the existing local browser download adapter.
|
||||
|
||||
Read-only full Dummy round-trip verification: 763 Results with content, 2,420 rendered tables and 10,208 evidence rows across CIS-CAT, Maester, Purple Knight, PingCastle and GPO. Every exported table/row/cell is retained and source objects remain unchanged; selective exports contain exactly one checked table. Largest current Dummy table: 78 rows. Permanent synthetic interaction and real browser download acceptance separately cover linked, Markdown and native HTML tables beyond 100 rows (1,001 rows each), entire prose/source text, explicit selection state, both themes, long Unicode cells and literal formulas. Verified: 90 distinct targeted Results/GPO/CIS-CAT/table/path/version/API/boundary tests, all 504 mandatory import checks, both production builds, design-language and local-only checks, final read-only full Dummy round-trip, and real browser downloads of linked/Markdown/native HTML tables in both themes. Existing React list-key and client chunk-size warnings remain. The specific reported 100-row notice remains unreproduced and needs the affected finding/report to locate its origin; no claim that an unidentified upstream limitation was removed. Minor capability release decision: 2.23.0 from concurrent workspace version 2.22.1; package/lock/UI/API contracts synchronized. Unrelated concurrent changes preserved; no phase-completion claim.
|
||||
|
||||
Change log 2026-10-10: added complete and selected Results XLSX exports and native HTML Results tables without presentation row truncation.
|
||||
|
||||
## Compact Client Info controls, package severity colours and complete group markers (2026-10-10, Phase 7 Presentation)
|
||||
|
||||
Current user release: Presentation, Platform, Core Tests. The three related UI refinements are batched as patch 2.22.1 from 2.22.0. Report buttons precede Source in a shared flexible control row, eliminating the full-width report-only row at suitable viewport widths. Narrow layouts wrap without hiding controls. Work Package sidebar rows show flat proportional segments in existing Severity colours, with readable tooltip/accessibility counts. They count each original Work record under the same filters as the self-excluding Work Package facet, retain unknown severities as unknown and leave package IDs, tasks, ordering, verdicts and selection semantics unchanged. Docked and floating lists share the same rendering. Severity distribution is a display of existing findings, not a new package priority or risk score.
|
||||
|
||||
All members of an active same-change group use the active group's identical filled point, independently of the currently selected reading card. Cards now isolate their stacking context so the reading surface cannot cover their sibling ring. Actual-view synthetic acceptance checks selecting another member while all member points stay filled with the same colour and remain above the card, then restores the original view. Earlier group/Result DB disclosure contracts remain binding. Verified: 41 focused group, heatmap, Client Info control/source, filter-panel, architecture and UI-version checks; all 504 mandatory import/API-version checks; both production builds; design-language and local-only checks. Browser acceptance on synthetic Dummy in both themes verifies all three Print Spooler member points filled identically and hit-testable after changing the reading selection, return to hollow rings, 54 package heatmaps in original Severity colours, and Report buttons to the left of Source with vertically aligned centres (difference below 0.02px). Backend healthy at 2.22.1, canonical PostgreSQL and unchanged repository revision 592. Existing client chunk-size warning remains; no full-suite or phase-completion claim. Unrelated workspace changes remain preserved.
|
||||
|
||||
Change log 2026-10-10: compacted the report/source controls, added package Severity proportions and hardened complete same-change group markers.
|
||||
|
||||
## Related-finding rings and reversible group focus (2026-10-10, Phase 7 Presentation)
|
||||
|
||||
Current user release: Presentation, Platform, Core Tests, Client Records. User decision: mark findings proven to target the same change with a small hollow ring at their upper-right edge, using the Results identity palette. Clicking a ring fills all rings of that exact group and shows the complete group; clicking any filled ring restores the preceding view. This groups redundant members of one published Remediation task, never every task in a package, a shared title or text similarity. Existing Same change as links retain their separate package-filter interaction.
|
||||
|
||||
Implemented: a Core Presentation focus coordinator consumes published Remediation results through an explicitly pinned read-only extension point. Work records from the current client/timeslot aggregate resolve source-sheet aliases by their established identities. Group focus temporarily overrides search/status/facets/path/My Workspace for display without changing original values, verdicts, scores, reviews, bookmarks, risk decisions or Result DB rows. The original filters, sheet, sort, selection, view, navigation area, scroll positions and registered local view state are restored. Scope changes clear focus; engine-off restores the original display and suppresses rings. Rings cover shared list/tile cards, Finding Reader, Work grid, structured Raw Data and original-source navigation, Pathfinder nodes/details/matrix and finding-bound Results windows. Accepted Risks rings open the complete group in Work, retaining Client Info for the return; risk-register counts and decisions retain their authoritative semantics. During group focus the Reader receives the displayed aggregate records, allowing its existing Same change as links to select visible peers across sources; outside focus those links keep their existing filter restrictions. Result DB evidence projections retain their separate disclosure contract and are not original Finding cards.
|
||||
|
||||
Verified minor capability release: 2.22.0 after the concurrent 2.21.9 Results-evidence correction, with synchronized package/lock/UI/API version contracts. Six permanent synthetic actual-view interactions cover both themes: list/tile/grid/raw, passed peers despite existing filters, source aliases and Reader peer links, exact prior filters/view/selection/scroll, engine-off, unchanged reviews/risk counts, Accepted Risks return, and Pathfinder selection/camera with zoom-independent rings. Related Reader, card, tile, risk, dashboard, tab, package, engine-boundary and version checks pass; all 504 mandatory import checks and both production builds pass. Design-language and local-only security checks pass. Browser verification on Dummy confirms hollow and filled rings in both themes, all three cross-scanner Print Spooler members, return to the previous Work list and 478 Pathfinder findings after group focus. Pathfinder rings retain a 28px click target and 16px glyph at overview zoom; the camera returns within 0.004px (synthetic camera restoration is exact). Backend healthy at 2.22.0 with canonical PostgreSQL and unchanged repository revision 592. Existing client chunk-size warning remains. The shared finite Results palette repeats after sixteen group colours; the click always selects the exact task identity. Existing workspace work remains preserved; no full-suite or phase-completion claim.
|
||||
|
||||
Change log 2026-10-10: added same-change finding rings and reversible group focus across interactive original-finding views.
|
||||
|
||||
## Client Info tabs remain visible while scrolling (2026-10-10, Phase 7 Presentation)
|
||||
|
||||
Current user release: Presentation, Platform, Core Tests. User decision: Client Info tabs must remain visible when scrolling. The shared tab container now occupies the available viewer height; its non-shrinking tablist stays outside the independently scrolling active panel. Existing tab heights, flush left alignment, inactive gap and shadow-free surfaces remain. Mounted panels retain their scroll position through mouse and keyboard tab switching. No metric, source-data or engine behavior changes.
|
||||
|
||||
Patch decision: 2.21.8 from 2.21.7, synchronized package/lock/UI/API version contracts. Verified: nine focused Client Info/tab/UI-version tests, all 504 mandatory import checks including API-version contracts, both production builds, design-language and local-only checks. Permanent synthetic interaction acceptance covers panel scrolling and Security/Home switching in both themes. Browser verification on Dummy at the panel end confirms the tablist top remains 133.98px, the outer viewer stays at scrollTop zero and Overview retains scrollTop 2365.67px after switching to Security and back; both themes checked. Light/Overview restored. Backend healthy at 2.21.8 with PostgreSQL and unchanged repository revision 592. Existing client chunk-size warning remains; no full-suite or phase-completion claim.
|
||||
|
||||
Change log 2026-10-10: kept Client Info tabs visible by moving scrolling into their associated panels.
|
||||
|
||||
## Results: proven affected evidence (2026-10-10, Phase 7 Presentation)
|
||||
|
||||
Current user release: Presentation, Ingest, Core Tests, Platform. User request: red Results backgrounds only where the native report proves affected evidence; use the whole Dummy. Existing GPO per-value comparison, CIS-CAT evaluated-row semantics and all protected scanner import contracts remain binding. Red denotes scanner-reported affected evidence, never a claim that every contextual cell is an incorrect configuration.
|
||||
|
||||
Read-only full Dummy audit through the current workspace port: 885 scanner findings (411 CIS-CAT, 318 Maester, 119 Purple Knight, 37 PingCastle) plus 948 derived GPO Work records; 2,391 scanner linked-table deliveries and 292 Maester inline rows. Exact reviewed native rule/layout guards now mark only PingCastle's own import/rule-bound embedded Details (23 Dummy rules, 90 rows; additionally synthetic S-SMB-v1) and Purple Knight's exact indicator-bound source tables with Ignored=False (20 layouts, 46 rows). PingCastle's 394 linked inventory/context rows stay neutral. Unknown rules, new headers, missing/foreign scope, ignored objects and non-fail parent outcomes fail closed. No title similarity, arbitrary boolean or score inference. New scanner formats are not introduced; parser/recognition/canonical/scoring semantics and existing source data are unchanged. Existing securityScoring declarations remain authoritative: native PingCastle overall risk and existing Purple Knight/Maester policies retain priority/fallback through the shared engine.
|
||||
|
||||
Maester's inline Markdown and HTML-wrapped Markdown result tables now receive the existing profile status colors through a generic RichTextValue callback: explicit status columns or source-written glyphs only, contradictory/missing markers neutral. Dummy: 230 fail rows red, 35 pass rows green, 27 neutral. Existing CIS-CAT colors retain 87 violations, 123 matches, 466 criterion and 327 highlighted rows; 8,382 contextual rows stay neutral. GPO values keep their existing per-value/variant proofs. Results retain their opening finding as read-only UI context so changing Work filters cannot lose the marking in an already open window. No data write, reimport, migration or backend restart.
|
||||
|
||||
Permanent synthetic actual Work → Results interaction acceptance covers S-SMB-v1, neutral inventory, finding hidden by filters, retained originals and both themes. Additional actual Results and negative checks cover inline mixed Maester status, contradictory glyphs, names that look like statuses, exact Purple Knight identity/ignored/source guards and unknown/foreign PingCastle layouts. Verified: 116 distinct targeted Results/CIS-CAT/GPO/graph/path/version/boundary tests, all 504 mandatory import checks, both production builds, local-only checks and actual Results browser color acceptance in both themes. The latest global design-language check is blocked by an unrelated concurrent inline style in src/features/related-findings/RelatedFindings.tsx; that main-thread change remains untouched. Existing client chunk-size warning remains. Patch decision: 2.21.9 from current 2.21.8, package/lock/UI/API version contracts synchronized. Concurrent workspace changes preserved; no full-suite or phase completion claim.
|
||||
|
||||
Change log 2026-10-10: added source-proven red affected-evidence rows and inline native status colors to Results.
|
||||
|
||||
## Finding Reader Work Package alignment (2026-10-10, Phase 7 Presentation)
|
||||
|
||||
Current user release: Presentation, Platform, Core Tests. User decision: Work Package information always aligns to the Reader's right content edge, including wrapped package names, task details and redundant-finding links. The existing Source Rule ID stays on the left. The common source/package row now also hosts records without a Source Rule ID; ready, loading/unavailable and unassigned package states all use the same right alignment and safe text wrapping. The Remediation Engine and its selection/filter behavior are unchanged. Permanent synthetic actual-Reader acceptance covers both themes, with/without Source Rule ID, ready/loading/unassigned states, retained record values and package-link clicks. Patch decision: 2.21.7 from the concurrent workspace version 2.21.6, preserving the separate MITRE release. Verified: 34 targeted Reader/package/UI-version tests, all 504 mandatory import checks including API-version contracts, both production builds, design-language and local-only checks. Browser verification on synthetic Dummy in light and dark confirms the wrapped package/task block reaches exactly the source row's right content edge; Source Rule ID remains left. Light restored. Backend healthy at 2.21.7 with canonical PostgreSQL and unchanged repository revision 592. Existing client chunk-size warning remains; no full-suite or phase-completion claim.
|
||||
|
||||
## Finding Reader MITRE disclosure (2026-10-10, Phase 7 Presentation)
|
||||
|
||||
Current release: Presentation, Core Tests, Platform. User decision: MITRE ATT&CK and D3FEND information starts collapsed; a small triangle only reveals/hides the existing badges, with no box, border, extra heading or panel. The keyed disclosure resets on finding changes, including returning to a previously opened finding. Search/review updates for the same finding preserve an explicitly opened disclosure. Findings with no MITRE badges show no toggle. Original badge values, other badges, Context and primary actions remain unchanged.
|
||||
|
||||
Implemented using the shared TriangleIcon, a native accessible button and transparent token-based styles. Permanent synthetic actual-Reader interaction tests cover default collapse, opening/closing, retained source values, search highlighting, finding change/return and no-information state in both themes. Browser acceptance covers Enter/Space, transparent surfaces without borders/shadows, and retained action alignment at 1200/620/320px. Final verification: 25 focused Reader tests plus 17 UI-version/Context tests and the browser check pass; all 504 mandatory import tests (including API-version contracts), both production builds, design-language and local-only checks pass. Patch decision: 2.21.6 from current workspace 2.21.5, synchronized package/lock/UI/API version contracts. Existing client chunk-size warning remains. Concurrent Client Info changes preserved; no backend restart, full-suite or phase-completion claim.
|
||||
|
||||
Change log 2026-10-10: made MITRE badges an initially collapsed, frameless triangle disclosure.
|
||||
|
||||
## Finding Reader action group (2026-10-10, Phase 7 Presentation)
|
||||
|
||||
Current user release: Presentation, Governance, Core Tests, Platform. Binding decision added to AGENTS.md: AI search, bookmark icon and Results always stay in one horizontal non-wrapping group in that order; Context badges may wrap outside that group. Bookmark actions carry no visible label, while accessible names/tooltips remain. View snapshot naming now says Snapshot. Existing search availability, Results enablement and review semantics remain unchanged.
|
||||
|
||||
Implemented in the existing FindingReader and shared styles with a dedicated action group, icon-only accessible bookmark button and shared spacing. Permanent synthetic interaction acceptance covers both themes, resizing, toggle on/off and Results opening. The standalone synthetic browser geometry check verifies 1200/620/320px in both themes, same vertical alignment, ordered separation and viewport containment. Final verification: 58 targeted Reader/Context/Bookmark/UI/API tests pass, all 504 mandatory import compatibility/scoring/transport tests pass, both production builds and design-language/local-only checks pass. Version decision: patch 2.21.4 from current 2.21.3 for this backward-compatible correction; package/lock/UI/API contracts synchronized. Existing client chunk-size warning remains. No full-suite or phase-completion claim. Concurrent workspace changes preserved; no runtime restart requested in this side conversation.
|
||||
|
||||
Change log 2026-10-10: permanently anchored the ordered Reader action group and removed visible Bookmark wording.
|
||||
|
||||
## Client Info tab spacing correction (2026-10-10, Phase 7 Presentation)
|
||||
|
||||
Current user release: Presentation, Platform, Core Tests. The user explicitly supersedes the shadow/recess decisions below: Client Info tabs become taller, the first tab aligns with the panel's left edge, and every inactive tab leaves a visible gap below its lower edge instead of any shadow. Active tabs stay connected to their panel. Shared tokens define 36px active height and a 4px gap (32px inactive height), with aligned top edges, the existing neutral chrome and the unchanged 20px content inset. The common ClientInfoTabs presentation applies this to all future Client Info tabs. Removed the obsolete shadow and recess tokens in both themes. No new tab content or finding/data semantics. Patch decision: 2.21.5 from the concurrent workspace version 2.21.4; preserve the separate Reader release. Verified: all 504 mandatory import checks, eight targeted Client Info/tab/UI/API version tests, both production builds, design-language and local-only checks. Browser verification on synthetic Dummy in light/dark measures 36px active height, 32px inactive height and a 4px inactive gap; the first tab and panel have the same left coordinate, and both tabs have no shadow. Selecting Security transfers the gap to Overview while Security joins its empty panel; keyboard Home restores Overview. Light/Overview restored. Backend healthy at 2.21.5 with canonical PostgreSQL and unchanged repository revision 592. Existing client chunk-size warning remains; no full-suite or phase-completion claim.
|
||||
|
||||
## Client Info attached tabs (2026-10-10, Phase 7 Presentation)
|
||||
|
||||
Current user release: Presentation, Platform, Core Tests. User-approved standard for existing and future Client Info tabs: attached tabs above their associated panel, Overview first and initially selected, Security initially an empty panel. The shared `ClientInfoTabs` component owns presentation, ARIA tab/panel associations and Arrow/Home/End navigation; Overview stays mounted and hidden when another panel is selected, preserving its local state and existing figures/drilldowns. New Client Info tabs use this same component.
|
||||
|
||||
Explicit user design exception: inactive Client Info tabs alone receive subtle inset shading at their lower edge to appear behind the active panel. The active tab joins its panel without shading. This narrowly supersedes the general no-button-shadow rule for this edge only; shared theme surfaces, Inter, control geometry and panel inset remain binding. No separate panel, tile or donut shadow is authorized.
|
||||
|
||||
Version decision: patch 2.21.2 from 2.21.1 for a backward-compatible Client Info navigation/presentation refinement, with package, lock entry and UI/API version contracts synchronized. Verified: 20 focused UI/layout/version tests, including actual DashboardPanel switching and retained figures in both themes; all 504 mandatory import compatibility/scoring/transport tests; both production builds; design-language and local-only checks. Initial concurrent checks hit existing import-test deadlines; the complete unchanged import gate passed when repeated alone. The final chrome-surface correction also passed the complete gate and both builds. Browser verification on synthetic Dummy in light and dark: attached tab geometry, inactive lower-edge shading, active tab without shadow, empty Security panel, mouse/keyboard switching and return to Overview (Overall Score 36, deviations 478 retained). Light-theme Overview restored. Backend restarted on the canonical PostgreSQL datastore, healthy at 2.21.2 with repository revision 592 unchanged. Existing client chunk-size warning remains. No full-suite or phase-completion claim.
|
||||
|
||||
Follow-up user correction 2026-10-10 (renewed Presentation, Platform, Core Tests release): the initial edge shading was too subtle, especially in dark mode. Inactive tabs now have a two-pixel reduced height, aligned to the same panel baseline, and a broader, stronger inset lower-edge shadow. Theme-specific values of the same edge-shadow token keep it legible in both themes; active tabs keep the full shared control height and no shadow. This is the agreed refinement of the existing narrow shadow exception and remains the standard for future Client Info tabs. Patch version 2.21.3, package/lock/UI/API contracts synchronized. Verified: all 504 mandatory import tests, eight targeted tab/DashboardPanel/UI/API version tests, both production builds, design-language and local-only checks. Browser checks on synthetic Dummy in both themes measure active height 28px versus inactive 26px with the same bottom edge; switching tabs transfers the recess/shadow to the newly inactive tab and keyboard Home returns to Overview. Dark/light shadow values verified, Overview/light restored. Backend healthy at 2.21.3 on canonical PostgreSQL, repository revision 592 unchanged. Existing bundle warning remains; no full-suite or phase-completion claim.
|
||||
|
||||
## Protected scanner import contracts (2026-10-09, Phase 13A Ingest)
|
||||
|
||||
User decision: working scanner imports are crown jewels; protect established behavior permanently and add new formats without replacing old ones. Current explicit Core release: Ingest, Core Tests, Platform, Governance. Use only Dummy/synthetic inputs. No additional engine, nested Core or Maester worker is introduced.
|
||||
|
||||
Implemented: `tests/importers/import-contracts.json` registers 18 variants across all five current profiles, using all existing synthetic scanner fixtures and a new synthetic Maester multi-tenant fixture. Normalized fixture checksums prevent unnoticed replacement; the inventory test preserves initial contract IDs and requires coverage of every profile and synthetic fixture. Browser and Node imports must agree through recognition, preview, mapping approval and canonical creation. Readable committed snapshots protect identity, original values, field roles, outcomes/execution states, severity, evidence and its finding association, exact source positions, source tables, report sections, environment observations and scoring. Generated record links/import timestamps and processing versions are excluded where applicable, without recursively changing retained native objects. Existing targeted behavioral assertions remain required alongside snapshots.
|
||||
|
||||
Release enforcement: `scripts/check-import-contracts.mjs` runs the importer/scoring and HTTP/worker suites plus GPO/GPP ingestion/parser checks in CI mode. Missing/changed snapshots fail; no update arguments are forwarded. `npm test` runs this gate before the full suite; `npm run build` invokes it through prebuild. AGENTS.md now makes compatibility and non-bypass binding, including helper/dependency/Platform changes. `docs/IMPORT_COMPATIBILITY.md` describes safe additive registration, deliberate change approval and coverage limits. Existing expectations may not be regenerated merely to pass a regression.
|
||||
|
||||
Verification: the 18-format contract suite passes (20 checks). The complete mandatory import/GPO gate passes 504 tests in 68 files, both standalone through npm test and automatically before the production build. Both client and server production builds pass; the existing client chunk-size warning remains. Deliberately modifying a synthetic fixture, changing an expected canonical result, and removing snapshot contents all cause nonzero exits; CI mode does not generate a missing baseline. Every mutated file was restored byte-for-byte. A first integrated gate attempt caught the Maester HTTP cold-worker test exceeding its 5-second limit on this synchronized Windows drive; only that test gets a bounded 15-second window, retaining every semantic assertion. The full suite has 2032 passed, 15 skipped and one failed test: `tests/features/datahub-centered-layout.test.ts:89`, an existing literal JSX expectation incompatible with the previously modified Data Hub percentage controls. The test and Presentation behavior were not changed in this batch. Full release remains blocked by that unrelated failure; no clean full-suite or phase-completion claim. Local-only and design checks pass independently. Backend health is healthy on the canonical PostgreSQL datastore after its automatic build restart.
|
||||
|
||||
Limits/risks: registered variants and behavioral suites protect known inputs, not unknown future vendor formats. An exploratory raw Maester JSON shape selected generic recognition; it was not silently made a supported Maester contract or repaired in this scope. There is no configured external CI provider; future CI must use the same required entry points. Repository writers can deliberately alter gates, so Governance consent/review remains necessary. No customer data, production import commit, datastore migration or parser runtime modification in this batch. Phase 13A remains open.
|
||||
|
||||
Version decision: this batch started at 2.20.2 and makes no product version increment: it adds tests, test/release tooling and governance/documentation without changing application import semantics or the deployment contract. During final checks a concurrent workspace change advanced package and UI/API version contracts to 2.21.0; live health confirms 2.21.0. That change is preserved and is not attributed to this protection batch. Historical reports remain unchanged.
|
||||
|
||||
Change log 2026-10-09: established additive scanner import contracts, immutable synthetic references, browser/backend result baselines and a mandatory failure-blocking import gate in test/build entry points.
|
||||
|
||||
## Maester HTML data binding correction (2026-10-09, Phase 13A Ingest)
|
||||
|
||||
Current user release: Ingest, Core Tests, Platform. User constraint: use only Dummy/synthetic data; do not request or read customer reports. Public upstream evidence: [Maester report data loader](https://github.com/maester365/maester/blob/main/report/src/lib/reportData.ts) and [ReportTemplate.html](https://github.com/maester365/maester/blob/main/powershell/assets/ReportTemplate.html), checked 2026-10-09. The template embeds `const testResults=<JSON>` inside `<script id="maester-report-data" type="application/json">`; its loader strips the declaration before parsing. ASSESSOR previously assumed this script type contained a bare object, reproducing `maester-payload-invalid` with synthetic input.
|
||||
|
||||
Implemented: the Maester source extractor strips only an anchored const/let/var assignment prefix in the data block and passes the following object to the existing bounded static-literal reader. No report JavaScript is executed. Trailing content other than an optional semicolon is rejected in application/json blocks, so a second payload or expression cannot silently produce a partial import. Existing legacy executable-script bindings and bare JSON blocks remain supported. Extractor version 1.2.1 records the extraction change; profile mapping, normalization and comparison compatibility are unchanged. Original values, object paths, outcomes and report metadata remain preserved.
|
||||
|
||||
Scoring review: the public source exposes test outcomes and report counts, with no declared native numeric overall score. The profile's existing explicit `securityScoring.native.kind=unavailable` remains valid. A synthetic arbitrary Score=99 is retained as source metadata but produces the shared calculated 50% pass rate for one passed and one failed check. Native-priority, missing/invalid/conflicting-value and safe-fallback contracts pass in the existing shared scoring suite.
|
||||
|
||||
Verified: the three new declaration variants failed before the implementation; 86 targeted tests across six files pass afterwards, covering browser/server import, selected non-error preview with two Work records, canonical approval, provenance, legacy formats, invalid/executable/duplicate/trailing payload rejection, scoring, HTTP upload/preview and UI/API version. Both production builds and Local-only check pass; existing client chunk-size warning remains. The running production parse route accepts `fixtures/maester/application-json-binding.synthetic.html` as HTML / Maester, extractor 1.2.1, two tests. No import commit, customer-data read or database migration was performed. No full-suite or phase-completion claim.
|
||||
|
||||
Version decision: backward-compatible importer correction 2.20.2 from the current workspace 2.20.1; package, root lock entry, UI/API contracts synchronized. Live health confirms 2.20.2 with the canonical PostgreSQL datastore. Historical reports remain unchanged.
|
||||
|
||||
Change log 2026-10-09: fixed Maester application/json report blocks containing a static variable declaration, using only synthetic Dummy data.
|
||||
|
||||
## Work: dockable filter panel (2026-10-09, Phase 7 Presentation)
|
||||
|
||||
Current user release: Presentation, Core Tests, Platform. User decision: remove the former Security Domains / Possible Business Impact sidebar lists and replace them with a compact, expandable rack for Security Domain, Business Impact, Context Cluster and Work Package, following the supplied row hierarchy reference within the existing ASSESSOR design language. The panel moves freely like Results and docks again either by clicking its dock control / parking slot or by dropping its header into that slot.
|
||||
|
||||
Implemented: `DockableFilterPanel` renders one controlled set of disclosure groups, with shared neutral theme surfaces, 14px information text, amber selection and blue keyboard focus. The existing pinned sidebar extension passes the authoritative self-excluding Work facet counts and exact filter IDs. Single selection / repeat-click clearing, other active filters, and Business Impact's deviation scope are preserved. Context, Business Impact and Work Package groups follow their existing engine availability contracts; optional-engine disablement removes their UI. Detaching and docking preserve selected values and expanded groups. Pointer listeners survive the dock-to-portal transition; position is viewport-clamped and the vacated sidebar slot marks a valid drop. Buttons provide keyboard-accessible detach/dock; Escape inside the panel docks it, with focus restored to the panel action. The toolbar facets remain available. No persistence, import, evaluation or engine logic change; no complete plan phase is newly declared complete.
|
||||
|
||||
Verified: 67 targeted synthetic tests pass across 13 files (filter selection, both themes, disclosure retention, click docking, drag detachment/movement/drop docking, existing domain/Business Impact/package interactions, engine boundaries and UI/API version contracts). Both production builds, design-language and Local-only checks pass; existing client bundle-size warning remains. Browser verification on Dummy in the built application: light/dark layouts, 478 deviations, all four collapsed groups, expanded Domain/Business Impact lists, detach by button, free header drag and drop-to-sidebar docking with both expanded groups preserved. Live backend health confirms 2.20.0 and canonical PostgreSQL. No full-suite or phase-completion claim.
|
||||
|
||||
Version decision: backward-compatible Presentation capability release 2.20.0, from the current 2.19.0 workspace version; package, root lock entry and UI/API contract tests synchronized. The earlier Technical Report screen-size correction is included in this batch; historical report snapshots remain unchanged.
|
||||
|
||||
Change log 2026-10-09: replaced the old sidebar filter lists with the compact dockable Work filter rack.
|
||||
|
||||
## Technical Report: natural screen size (2026-10-09, Phase 7 Report Engine)
|
||||
|
||||
User correction: the Technical Report appears too large. Removed the renderer's automatic `zoom:1.2` at screen widths of 1200px and above. The screen document now uses the shared 16px reading and 14px structured-data roles at their natural size; A4 width, responsive wrapping and print geometry are preserved. Renderer version: 4.2.1. Existing archived HTML snapshots retain their original presentation; newly generated reports use the correction.
|
||||
|
||||
Verification: 14 synthetic Technical Report tests and the Report Engine invariance test pass, including a permanent check against automatic magnification and reduced information typography. Both production builds pass (existing client chunk-size warning). No Core source changes. Product-version reassessment: this isolated, low-impact renderer correction is recorded by the renderer patch; the product remains 2.18.0, with no new capability or deployment contract. Browser/OS zoom remains controlled by the reader and can still enlarge the display.
|
||||
|
||||
Change log 2026-10-09: removed automatic desktop magnification from the Technical Report.
|
||||
|
||||
## Remediation packaging 2.0: fewer, fuller work packages (planned 2026-10-09, Phase 13E engine extension / Work Phase 7)
|
||||
|
||||
Status: steps 0–7 implemented and verified on 2026-10-09 (details below); no whole plan phase is newly declared complete. Problem: the Dummy produces 159 packages for 477 findings (24×1, 34×2, 46×3, 28×4, 27×5) because every intervention becomes its own package inside its cluster, unmatched findings become individual packages and the same change reported by several sources counts several times.
|
||||
|
||||
User decisions 2026-10-09 (Governance rule recorded in AGENTS.md under the same date): (1) at most eight tasks per package; a task is one Work finding, and findings proven redundant through the same change target count as one task; (2) a package stays inside exactly one Context cluster; (3) variant A: security potential is shown only as sortable, source-backed package facts. No computed ranking, score or priority; AP numbers remain identifiers.
|
||||
|
||||
Work steps:
|
||||
|
||||
0. Measurement baseline (engine only, Dummy only). Count per tool which retained change-target fields exist for deviations (GPO technical identity/scope, published Context condition IDs, Raw Data `Registry path`, `Policy section`, CIS-CAT `Benchmark section`, native categories). Result decides which redundancy anchors are usable. Record current package metrics as the comparison baseline.
|
||||
1. Redundancy (engine). Build tasks from findings in this order: (a) same published Context condition ID inside the cluster; (b) same exact technical identity (GPO identity/scope, or an exact retained registry path + value name a scanner states). Never title, wording or similarity. GPO declaration and scanner observation stay separately attributed evidence of one task. Every finding keeps exactly one package (exclusive), the task carries all finding keys.
|
||||
2. Change surface (engine). Each task gets one surface inside its cluster from: the intervention catalogue entry (new `surface` attribute), else GPO `Policy type`/`Policy section`, else CIS-CAT `Benchmark section` top level, else the tool's native category, else a per-tool fallback surface. Versioned table in `src/remediation/`.
|
||||
3. Packing (engine). Per cluster and surface, interventions are atomic units; pack deterministically (first-fit decreasing, fixed key order) into packages of at most eight tasks. Never split one Context condition across packages when it fits; split oversized interventions evenly (11 → 6+5). Unmatched tasks fill packages of the same cluster and surface; a single-task package remains only when no package of that cluster and surface exists. No cross-surface "other" packages. Package title from the dominant intervention, otherwise the surface name.
|
||||
4. Package facts (engine output, displayed by Presentation). Tasks, findings, redundant findings, highest native severity, count of Critical/High findings, closed Context conditions `n of m` (a condition counts as closed when all its in-scope deviations lie in this package), contributing scanners/GPO. Facts only; sorting is a viewer action.
|
||||
5. Core adapter (requires `Core-Freigabe: Platform`). The Work-record adapter in `server/assessor-http-server.ts` additionally passes native severity and the already retained change-target fields from step 0 to the engine. No new Core field, no reimport.
|
||||
6. Presentation (requires `Core-Freigabe: Presentation`). Work Package facet text says eight tasks; a compact sortable package list (facts as columns, row selects the exact package filter) via a registered extension point; Finding Reader shows `Cluster → AP-nn · Task k` and, for a redundant finding, the other findings of the same task as links. Approved Work design language, both themes, no new colours.
|
||||
7. Versions and storage. Remediation engine/catalogue 2.0.0; existing JSONB snapshot table is reused, old snapshots are bypassed by the version key, no migration. Product version: minor release (requires `Core-Freigabe: Platform, Core Tests`).
|
||||
|
||||
Step 0 result (2026-10-09, read-only measurement on the canonical datastore, Dummy only, Core revision unchanged; throwaway script outside the repository, nothing persisted). 478 deviations, 477 in Context clusters, all `catalogued`. Retained change-target fields: GPO 25/25 technical identity, 9 registry path, 16 policy section; CIS-CAT 323/323 `Benchmark section` and `Benchmark Item`, no registry path; PingCastle, Purple Knight and Maester only native category and severity. No registry path is shared between findings. Native severity exists for all scanner findings (CIS-CAT `unknown`), not for GPO settings. Baseline: 159 packages, sizes 24×1, 34×2, 46×3, 28×4, 27×5.
|
||||
|
||||
Plan corrections from step 0:
|
||||
- Step 1: a shared Context condition is a redundancy anchor only when it is specific and every source contributes at most one finding. The CIS area conditions (`cis-*-unmet`, up to 20 different settings each) are surfaces, never redundancy. Umbrella groups, where one scanner finding covers several distinct settings of another source (e.g. Purple Knight dangerous user rights vs. three CIS user-right settings, ASR, PowerShell logging), stay separate tasks and are kept in the same package with a visible relation. Dummy: 10 real redundancy groups (22 findings → 10 tasks), 477 → 465 tasks. Redundancy is therefore a transparency gain, not the main reduction lever. CIS-CAT ↔ GPO links currently exist only where the Context catalogue maps both (password length, lockout threshold); further exact links (e.g. user rights) belong in reviewed Context catalogue entries, not in a Remediation title match.
|
||||
- Steps 2/3: a hard surface boundary fragments too much (cluster × surface: 109 packages, 26 single-task groups). The hard boundary becomes cluster × platform (Windows host/GPO incl. CIS-CAT, Active Directory, Cloud/M365); the detailed surface (intervention, CIS section, GPO section, native category) only orders tasks so that neighbours stay together. Estimate with max eight: 75 packages, 4 unavoidable single-task groups (cluster only: 66, no single-task group).
|
||||
|
||||
Steps 1–4 implemented (2026-10-09, user approval of cluster × platform; engine paths only, no Core release used): Remediation Engine 2.0.0, intervention catalogue unchanged 1.1.1. Packages now carry `platform`, `tasks` (redundant findings form one task; umbrella findings stay separate tasks with `relatedConditionId`), `measures` (the former common-intervention grouping, unchanged semantics: estimated context and unsupported warnings never share a measure) and `facts`. Packing: units are tasks of one measure or one umbrella relation; an umbrella finding follows the majority platform of its relation; balanced chunks; first-fit decreasing with condition affinity at the most balanced capacity; loose tasks rebalance a one- or two-task tail. `severity` is an optional engine input; the Core adapter does not pass it yet (step 5), so `highestSeverity` stays absent in live snapshots until then. Facet description and Reader tooltip (engine files) name tasks and the eight-task limit.
|
||||
|
||||
Verified offline against the complete Dummy (read-only, same adapter shape as the server): 477 findings in 76 packages (task sizes 4×1, 6×2, 2×3, 4×4, 9×5, 8×6, 10×7, 33×8), every finding exactly once, maximum eight tasks, 10 redundant tasks with 22 findings, 10 umbrella-related tasks, 58 packages close at least one Context condition, one Unassigned. The four single-task packages are the only task of their cluster and platform. Tests: rewritten engine/catalogue acceptance (measures instead of package counts where packing now combines measures), new synthetic packaging tests (redundancy merge, area/umbrella/title negatives, eight-task maximum, balanced split, no tail, hard platform boundary, facts without score/rank), adapted Work filter interaction (two-finding package via a second cluster); 51 focused tests incl. architecture, engine menu and product version pass; full suite 1,994 passed, 15 skipped, 1 failure in `datahub-centered-layout` caused by an unrelated uncommitted Data Hub change; local-only and design checks and both production builds pass. The running backend still serves engine 1.1.1 until restart; stored 1.1.1 snapshots are bypassed by the version key. No product version change in this engine-only batch; it is decided together with steps 5–7.
|
||||
|
||||
Steps 5–7 implemented (2026-10-09, explicit current Core release: Platform, Presentation, Core Tests). Step 5: the Work-record adapter in `server/assessor-http-server.ts` passes the Core-normalized native severity (GPO settings carry none); no new Core field, no reimport. Step 6: Work toolbar `Packages` button (existing Remediation extension point `ActionlistControls`, visible only while the engine is on and ready) opens the shared floating window `WorkPackagesWindow` with sortable fact columns (Package, Measure, Platform, Tasks, Findings, Redundant findings, Highest severity, Critical/High, Closed conditions, Sources; `aria-sort`, default identifier order, numeric columns start descending); selecting a package replaces only the Work Package facet and closes the window. The Finding Reader keeps the package link beside Source Rule ID and continues it inline with `Task k of n` and, for a redundant task, `Same change as <source> <rule ID>` named from the loaded Work records (unresolved ones are counted, never guessed). Shared tokens only; the plan's "links" to redundant findings are plain text in this release. Step 7: product version 2.19.0 (backward-compatible capability release) in package.json, root lock entry and both UI/API version contracts.
|
||||
|
||||
Verified: 63 focused tests (engine, catalogue, real Work filter/toolbar/window/Reader interaction in both themes incl. engine-off removal, architecture boundary, engine menu, UI/API version) pass; the full suite has only the unrelated `datahub-centered-layout` failure; local-only and design checks and both production builds pass. The dev runner restarted the backend automatically on the rebuilt server: health reports 2.19.0; the live Dummy API returns engine 2.0.0 with 76 packages, 477 findings, maximum eight tasks, 10 redundant tasks and severity facts on 28 packages, identical to the offline measurement. Browser check on the Dummy (dark and light): toolbar button, 76 rows, Critical/High sort, package selection filters Work to the package's four findings, Reader shows `Task 2 of 3 · Same change as PingCastle A-CertTempCustomSubject` beside Source Rule ID; dark theme restored. No Store, scoring, verdict or Result DB change.
|
||||
|
||||
Change log 2026-10-09: Remediation packaging 2.0 (redundant tasks, cluster × platform packing, max eight tasks, source facts), sortable package list and Reader task line; ASSESSOR 2.19.0.
|
||||
|
||||
Redundant-finding links (2026-10-09, explicit current Core release: Presentation; user decision variant 1). In the Reader task line every redundant finding of the same task is a link resolved in the current Work sheet. A click replaces only the Work Package facet with the task's package and selects that finding; status, search, other facets, drilldown and My Workspace stay. If those filters would still hide the finding, the Reader states `<source> <rule ID> is hidden by the current Work filters.` and changes nothing. Findings outside the current sheet are counted, not linked. The on-demand visibility check `useVisibleWithFacet` lives in the existing Core hook `use-filtered-records.ts` (already a pinned Remediation/Context extension point), so the Remediation Engine never imports the Context Engine; the links render in a guarded child component only with an app context. Verified: new permanent interaction test (visible jump with only the package filter set; hidden case with notice and unchanged filters/selection), full suite with only the unrelated `datahub-centered-layout` failure, design/local-only checks and both builds; live on the Dummy: SI000157 ↔ A-CertTempCustomSubject in both directions, and with Severity = Critical the notice appears while filters and Reader stay unchanged. Version: the workspace meanwhile carries 2.20.0 from a parallel Presentation release; this refinement is not separately versioned here because no Platform/Core Tests release was given; reassess with the next batch.
|
||||
|
||||
Version decision 2026-10-09 (explicit current Core release: Platform, Core Tests): patch 2.20.1 for the backward-compatible redundant-finding link refinement, from the current 2.20.0. package.json, root lock entry and both UI/API version contract tests synchronized; version contract tests and both production builds pass; backend health reports 2.20.1.
|
||||
|
||||
Windows host/GPO package limit 15 (2026-10-09, explicit current Core release: Governance, Platform, Core Tests; user chose variant 3 with fifteen). AGENTS.md now states at most eight tasks per package and at most fifteen for Windows host/GPO packages, as a package-size rule that is never an effort assessment, priority or security-impact score. Remediation Engine 2.1.0: `maxTasksFor(platform)`; packing, balanced chunks and the anti-tail rule use the platform limit; Active Directory and Cloud stay at eight. Facet description and package window text name both limits. Measured beforehand on a scratch copy (8 → 76, 10 → 66, 12 → 60, 15 → 54 packages); live Dummy after release: 54 packages (host 31, mostly 12–14 tasks, three unavoidable single-task host packages; AD 12 with max 6; Cloud 11 with max 8), all 477 findings exactly once. Tests: engine/catalogue limits adapted, new permanent test for 15 vs. 8 in the same cluster and balanced host splits; focused suites, version contracts, architecture, design/local-only checks and both builds pass. Full suite: `datahub-centered-layout` (unrelated uncommitted change) plus two load-sensitive timeouts (`node-import-engine`, `sheet-selection-cost`) that pass when run alone. Version: minor 2.21.0 from the current 2.20.2 (parallel releases in between), package/lock/UI/API contracts synchronized; backend health reports 2.21.0.
|
||||
|
||||
Task line without ordinal and task order fix (2026-10-09; engine paths, version under explicit release Platform, Core Tests). The Reader no longer shows `Task k of n`, which implied a sequence that does not exist; it states `One of n tasks`. Remediation Engine 2.1.1 fixes the in-package order: the intended "individual tasks last" marker `~` sorted before letters under locale collation, so individual tasks came first; explicit 0/1 prefixes now place common measures first and individual tasks last. New permanent test for that order; the Reader interaction test asserts the absent ordinal. Live Dummy: 54 packages (host 31, AD 12, Cloud 11), 477 findings, individual tasks last in every package. Version: patch 2.21.1; package/lock/UI/API contracts synchronized, version tests and both builds (`build:client`, `build:server`) pass; backend health reports 2.21.1. Note: `npm run build` now runs a `prebuild` import-contract suite added in parallel; its `node-import-engine` test timed out under load (5.6 s of 5 s) and passes alone, so it blocked `npm run build` but not the builds themselves. Full suite before the version step: only `datahub-centered-layout` and the load-sensitive `sheet-selection-cost` failed.
|
||||
|
||||
Acceptance: synthetic tests for every redundancy anchor and its negative case (similar text, different identity → no redundancy), maximum eight tasks, condition non-splitting, even splits, exclusivity, deterministic order, immutable inputs, fact correctness; real Work interaction test for selecting a package from the list and from the Reader link; permanent Core invariance (Work, Client Info, score, Result DB unchanged, Core export byte-identical); Dummy metrics compared with the baseline (package count, single-task share, size distribution, redundant findings merged). Target is a clear reduction of packages and single-task packages; the exact number is a measurement, not a promise.
|
||||
|
||||
Risks: redundancy coverage depends on retained change-target fields (step 0); a catalogue surface misassignment produces a technically odd package and must be corrected through the versioned table; larger packages make a package less atomic for change management.
|
||||
|
||||
## Technical Report in production (2026-10-08, Phase 7 Report Engine)
|
||||
|
||||
Explicit current Core release: Presentation, Platform, Core Tests. The user reviewed the Technical Report as a Dummy preview over many correction rounds and asked to implement it. The accepted form is recorded in `docs/REPORT_STRATEGY.md`, Report B, "Current form": DIN A4; chapters Information, Lagebild, Beziehungen (Pathfinder), Problemfelder, Datenbasis und Datenstand; ASSESSOR as the single source; Context heatmap order; no MITRE section; problem-field rows with finding lines and Finding Reader card heads; state-only wording.
|
||||
|
||||
Implemented. Report Engine: renderer 4.2.0, model 2.1.0, projection 1.5.0, Work cut 2.1.0 (per deviation the Finding Reader card head from the reader's own first-read and badge helpers), `report-rich-text.ts` (display-only Markdown restoration, escaped, https links only), `report-header-facts.ts` (Information facts and the GPO count of the GPO backup list), two read-only port methods (`listImportArtifacts`, `listGpoBackupInstances`), and an optional Context that cannot be read leaves the Technical Report without problem fields instead of failing it. Presentation: the Client Info fact builders moved unchanged from `DashboardPanel.tsx` into the React-free `src/features/dashboard/client-info-facts.ts`; Client Info, its Operating systems and Cloud Information tiles and the report read the same functions (former exports re-exported). Platform: the report-run route passes the published Context to the technical run as well; Management still requires it.
|
||||
|
||||
Decision change (user agreement in this release): this supersedes the earlier documented decision that the technical run does not compute Context on its own, and closes the open item recorded under "Technical Report rebuild" below.
|
||||
|
||||
Verified: the production `createReportRun` path against the canonical PostgreSQL datastore (read-only, Dummy only) renders exactly the reviewed preview (byte-identical apart from the product and projection version), with 21 problem fields, 478 finding lines and cards and Information facts equal to Client Info. Full `npm test`: 1,988 passed, 15 optional skips, 2 failures outside this scope (the pre-existing Data Hub centred-layout expectation, and `sheet-selection-cost`, which passes alone and exceeds its 5 s limit only under full-suite load). New acceptance tests: Information facts equal Client Info, GPO count rule, Markdown restoration, finding card, Lagebild domains, technical HTTP run with Context. Both production builds pass.
|
||||
|
||||
Version decision: backward-compatible capability release 2.18.0 (package, root lock entry, UI/API version contracts). Historical reports keep the versions they documented.
|
||||
|
||||
Change log 2026-10-08: Technical Report finalised and moved into production; Client Info fact builders shared without React; technical report run reads the published Context; ASSESSOR 2.18.0.
|
||||
|
||||
## Finding Reader: original Source Rule ID position and plain work-package link (2026-10-08, Work Phase 7 / existing Remediation extension)
|
||||
|
||||
Explicit current Core release: Presentation, Core Tests, Platform. User's screenshot is the position reference: Source Rule ID remains a heading and original code below the existing heading separator, with the package beside this block as ordinary text and a link. Restored the original Source Rule ID block in reader-summary; removed only the added source/package badges from finding-badges. Fail/Passed outcome markers, Severity, Weight, Category, Score, MITRE, Context and all existing header controls retain their existing markup and styles. Work-package output, including loading/unavailable/unassigned states, uses regular text rather than a badge; an assigned package is a native underlined link (Inter 16px/400, no border or uppercase transform). Clicking or pressing Enter replaces only the Work Package facet with the exact package ID and preserves other filters. Findings without Source Rule ID still receive the plain package extension; engine-off removes it.
|
||||
|
||||
Verified: 65 focused engine, real filter/Reader, source-ID, CIS, architecture and UI version acceptance tests pass; the API health/version and independent package-store acceptance tests also pass. Permanent synthetic real-UI tests check original placement, one source ID, plain native link, preserved Fail/Severity markup, link-driven exact filtering and engine-off in both themes. Live Dummy keyboard activation filters AP-02 to its two records while preserving the Context selection. The user's exact AdminSDHolder finding (SI000055) was inspected in Light and Dark: original Critical/Fail/Weight/Category/Score/MITRE/Context display plus Source Rule ID and AP-01 link. Both screenshots are saved in the task artifact directory; original Light preference restored. Local-only/design checks and both production builds pass. Earlier full-suite baseline remains three unrelated Data Hub/Report expectation failures; no commit or whole-phase completion is claimed.
|
||||
|
||||
Version decision: patch 2.17.1 closes both this presentation correction and the previously deferred complete-Dummy grouping correction. Package, root lock entry and UI/API version contracts are synchronized. Backend restarted with the verified build on 4173; standard frontend remains 5173. Canonical datastore and Core revision 571 remain unchanged. No Governance, Store, scoring, import or client-owned record change. Historical version decisions below remain intact.
|
||||
|
||||
Change log 2026-10-08: restored established Finding Reader metadata position; replaced work-package badge by an accessible plain-text filter link; preserved all existing outcome and badge presentation; completed patch release verification under the explicit release.
|
||||
|
||||
## Remediation correction: complete Dummy intervention coverage (2026-10-08, Phase 13E engine extension / Work Phase 7)
|
||||
|
||||
User correction: use Dummy completely. This supersedes the Cloud-only catalogue limitation recorded below; cluster membership alone still never establishes a common measure. Read both complete Dummy assessments through the existing read APIs: 478 deviations (CIS-CAT 323, Maester 73, PingCastle 37, Purple Knight 20, rolled-up GPO settings 25), with no missing scanner occurrence. Reviewed common technical interventions across all 20 assigned problem fields, retaining the one uncategorised deviation as Unassigned. Catalogue rules use specific published Context conditions or retained technical setting names; specific semantics take precedence over incidental terms in conditional source text. Broad Context conditions are refined by the actual technical target. Recognized/estimated Context alone remains insufficient, unsupported warnings stay individual, and reviewed application credential expiry warnings enter the application lifecycle review.
|
||||
|
||||
Implemented entirely inside the registered Remediation Engine paths: intervention catalogue and engine 1.1.1, immutable/exclusive deterministic assignments, maximum five findings, and a 3+3 split instead of an artificial 5+1 tail. GPO inputs without a scanner tool name are recognized by their declared technical identity and scope; no Core adapter change or persistence migration. Separate GPO declarations and observed scanner assertions remain separate tasks within a common measure. Old snapshots are preserved and bypassed by the engine/catalogue version key.
|
||||
|
||||
Live complete-Dummy result: 477 assigned findings in 159 packages, 135 packages with multiple findings (34×2, 46×3, 28×4, 27×5) and 24 supported individual exceptions; one Unassigned. Examples: AD administrator protection four tasks, certificate template review three, application credentials four, application consent four, password strength five, and firewall logging three per profile. Measures do not introduce a risk/effort score or assert a quantified security gain. Source Rule ID position and plain-text package presentation remain a separate outstanding Core Presentation correction.
|
||||
|
||||
Verification: complete Dummy Context and package API publication/readback on the canonical PostgreSQL datastore; entire Core project export before/after is byte-identical (SHA-256 comparison), revision remains 571. Synthetic acceptance covers cross-source interventions, GPO/scanner joint measures including missing GPO tool names, exact profile separation, unrelated neighbours, warnings, estimated assignments, precedence, bounded splits, deterministic order and immutable inputs. The actual Work filter interaction permanently covers a non-Cloud shared package. All 42 focused engine, interaction, architecture and UI version tests pass. Full suite before the final GPO adapter-shape correction: 1,984 passed, 15 skipped, the same three existing Data Hub/Report expectation failures documented below; final correction passed the focused acceptance checks. Local-only and design checks pass; both final production builds pass. No whole plan phase or milestone is newly declared complete.
|
||||
|
||||
Version reassessment: this is a backward-compatible correction, so the next product release should be patch 2.17.1. Product package/lock/UI/API contracts remain 2.17.0 because the current user message provides no Platform/Core Tests release; previous Core release has expired under AGENTS.md. The independently versioned engine fix is running and verified. No silent Core write or release-file edit was made. Operational limit: the first cold Context request after restart failed once; a subsequent complete read succeeded. Cold Context preparation remains slow and was not refactored here. Standard frontend remains 127.0.0.1:5173, backend 4173.
|
||||
|
||||
Final browser acceptance at the standard frontend: Dummy opens with 478 deviations and Overall Score 36. The AD Privileged Account Exposure Work facet offers AP sizes 4/2/1/1/1; selecting AP-01 displays exactly its four findings from PingCastle and Purple Knight. The stored result is deeply identical on a second API read (PostgreSQL JSONB property order can differ). The full-Dummy offline and live partitions agree, including the GPO/scanner packages. Screenshot saved in the task artifact directory. Source Rule ID/plain-text placement is still explicitly outstanding, not claimed fixed.
|
||||
|
||||
Change log 2026-10-08: replaced predominantly singleton remediation grouping with a complete-Dummy-reviewed intervention catalogue; fixed GPO recognition from the existing published adapter contract; added permanent synthetic engine and real-filter regression acceptance. Preserved unrelated working-tree changes and historical decisions below.
|
||||
|
||||
## Remediation Engine: Work package flags (2026-10-08, engine extension to Phase 13E / Work Phase 7)
|
||||
|
||||
User scope: meaningful packages within a Context cluster, at most five original Work findings, a `Context Cluster → AP-01` flag next to Source ID, and Work Package replacing the Result Message filter. No task-management subsystem. Explicit Core release covers Store, Presentation, Platform, Core Tests and Governance for this request.
|
||||
|
||||
Implemented: independent, platform-neutral Remediation Engine and intervention catalogue 1.0.0; declared read-only Core port and PostgreSQL reader with `default_transaction_read_only=on`; own `assessor_remediation.package_snapshots` schema. No finding column, verdict, score, review, risk acceptance or Result DB modification. Snapshots are keyed by project, assessment/scan scope, Core revision and both engines' catalogue/engine versions. Reimports or changed scopes produce new derived assignments; AP numbers identify packages within that snapshot, not permanent workflow records or priorities. Presentation/API integrations are registered extension points.
|
||||
|
||||
Grouping decision: exact Maester rule IDs support seven reviewed Cloud Privileged Access interventions. Otherwise, only identical retained remediation text within the same tool/scope and catalogued primary Context cluster supports a shared package. Recognized/estimated Context alone is insufficient. Unsupported interventions stay individual; uncategorised deviations are visibly Unassigned. Packages are exclusive, reproducible and split at five unique Work records; a rolled-up GPO setting counts once per technical identity/scope. This is a conservative grouping rule, not a calculated security-impact or effort score. Broader intervention catalogue coverage remains a known limitation and may be extended through reviewed engine rules.
|
||||
|
||||
Interaction: Remediation defaults active in Engines and can be disabled independently. Off clears its filter, removes its output and suppresses late responses. With Context off, only a stored snapshot is consumed; no Context request starts. Report off does not disable packages. Counts respect the other Work filters, and only reachable packages are offered. A selected package shows no unrelated records while a new scope loads. Original Result Message data remains available in the finding; only its filter is removed.
|
||||
|
||||
Persistence verification: canonical container `assessor-2_0-postgres-1`, database `assessor`, volume `assessor-2_0_assessor-postgres-data`, port 5432 reused. Before migration, a full custom-format backup was created and verified with `pg_restore --list` (386 entries), then copied to the task artifact directory as `assessor-before-remediation-20261008.dump`, SHA-256 `25d8c1be4759d9ade5017d599bd17c3768838e8fb5e0e681690eb900acc391bb`. Migration created only the engine schema/table. Dummy remains Core revision 571: 477 assigned deviations plus one Unassigned; Cloud Privileged Access Exposure has seven packages with 4/4/3/4/2/1/1 findings. The stored flags work without Context/Report enabled. Client Info remains 478 deviations and score 36.
|
||||
|
||||
Validation: synthetic engine tests cover the Cloud intervention partition, exclusivity, maximum five, deterministic order, immutable inputs, warning/estimated separation, rolled-up GPO identity and the read-only port. Real filter/Reader interactions cover both themes, independent off, late output and loading. API acceptance compares the complete Core project export before/after publication and snapshot retrieval. Architecture, UI/API product-version contracts, local-only and design checks pass. Desktop Dummy flag/filter and engine-off interactions were inspected in the browser. Full-suite run: 1,956 passed, 15 skipped, three pre-existing failures in Data Hub layout and obsolete Report expectations (`client-report-buttons`, HTTP report projection 1.3.0 versus current 1.4.0); they are not silently treated as passing. Final recheck: all 43 targeted engine, loading, architecture, engine-menu, Reader/CIS and UI version tests pass; both final production builds pass. The separate API version/whole-Core-export acceptance checks also pass. An individual Dummy Maester scan Context scope was verified separately at revision 571. No overall plan phase or milestone is newly marked complete.
|
||||
|
||||
Operational risk observed in final browser QA: a cold Dummy opening took over a minute while preparing the existing Context Engine. The client eventually reached Ready with unchanged counts. Stored package retrieval and the final seven-option Cloud package filter were verified after loading; Context performance was not refactored in this request. Light/Dark screenshots were saved in the task artifact directory.
|
||||
|
||||
Version decision: backward-compatible capability release 2.17.0, synchronized through package.json, package-lock and the existing UI/API contracts; both production builds passed. This supersedes the earlier deferred patch reassessment for this material batch. Historical reports and earlier decision entries retain their documented versions.
|
||||
|
||||
Change log 2026-10-08: introduced Remediation Engine, backed-up separate persistence, package flag and replacement Work facet; registered the engine boundaries and permanent behavioral acceptance checks. Preserved concurrent Report/Data Hub work. A pre-existing ReportFindingCard declaration was corrected to allow an absent outcome already handled by its producer, without changing Report behavior.
|
||||
|
||||
## Technical Report rebuilt as a report (2026-10-07, Phase 7)
|
||||
|
||||
Explicit user request: a Technical Report that goes technically deeper than the Management Report, rebuilt from scratch, as a report rather than an enumeration of all findings, using only categorisations and clusterings ASSESSOR already has, without Internet or LLM, without redundant information, with graphics in the binding ASSESSOR design language, and evaluating how Pathfinder relations can support it. The previous Technical Report (renderer 3.0.0, Client Info/Work/Result clone) is removed. Report Engine only (`src/reporting/`, `tests/reporting/`); no Core change in this step.
|
||||
|
||||
Implemented: renderer 4.0.0 with nine sections (Lagebild, Problemfelder, Beziehungen (Pathfinder), Security Domains, Quellen und Bewertungsmodell, GPO-Baseline-Vergleich, Abdeckung und Aussagegrenzen, Umgebung, Datenbasis und Reproduzierbarkeit); `technical-report-model.ts` 1.0.0 aggregates every figure from the same Work records, result states and Security Domain classifier as Client Info; `report-charts.ts` provides full 360-degree donuts, flat bars and a static source-to-problem-field diagram, all token-based. Projection 1.4.0 replaces the heavy Work cut (fields, Results tables, environment Results) by a lean Work cut 2.0.0 per record: state, domain, native area, rule ID, counting key and the frozen Pathfinder ATT&CK/D3FEND references; unused technical inputs (`environmentTiles`, `technicalPolicySources`, `technicalFindingFields`) are removed. Management renderer 3.3.1 now points to ASSESSOR for individual evidence and original recommendations, because the new Technical Report no longer lists them.
|
||||
|
||||
Pathfinder evaluation (Dummy, revision 571): every one of the 478 deviations has a primary Context cluster (21 populated, partition sum 478); 13 problem fields are corroborated by at least two independent sources. ATT&CK relations are sparse: 20 deviations carry scanner-declared tactics (Purple Knight), 19 only mention techniques (PingCastle), 439 carry none; D3FEND is declared for 11. The report therefore uses the Context edges of the Pathfinder graph as its main relation view and shows ATT&CK/D3FEND as labelled, declared-versus-mentioned supporting evidence without implying an attack path.
|
||||
|
||||
Verified: 72 Report Engine tests including new synthetic acceptance tests (Client Info headline and domain reconciliation, exclusive problem-field partition with `Ohne Problemkategorie`, lead checks instead of a finding list, no Result IDs in HTML, Pathfinder aggregation, GPO score-neutral labels, operation without Context); server and app typecheck and both production builds pass. A Dummy rendering through the read-only report port was reviewed in the browser. Full `npm test`: 1,942 passed, 15 skipped, 3 failed: two Core Tests (`tests/features/client-report-buttons.test.tsx`, `tests/server/assessor-http-server.test.ts`) still assert the superseded report content and projection 1.3.0 and need Core Tests release; `tests/features/datahub-centered-layout.test.ts` belongs to the pre-existing Data Hub working-tree changes.
|
||||
|
||||
Follow-up user correction (same day, Report Engine only): ASSESSOR is the single source; scan tools are neither named nor compared. Removed: Overall Score composition per tool, per-tool source tiles, source chips, multi-source corroboration, lead checks per tool, the domain x tool matrix, per-tool coverage, the imported-report and diagnostic tables. The Pathfinder diagram now connects Security Domains with problem fields; problem-field cards show only name, Base Severity, count, situation, technical lever and Security Domains. Renderer 4.1.0, model 2.0.0. Verified: 68 Report Engine tests including a permanent check that no scan-tool name appears, server typecheck and a Dummy rendering reviewed in the browser.
|
||||
|
||||
Requested next and blocked on Core consent: the Client Info facts (Data facts AD Domains, Entra Tenant, User Accounts, Computer Objects, Domain Controllers, GPOs; Domain Information; the environment tiles Resilience, Inventory, Password policy, Certificates & PKI, Operating systems, Cloud Information) belong in the report header. Their builders live inside `src/features/dashboard/DashboardPanel.tsx` (Presentation) and the GPO count comes from the GPO inventory API; reusing exactly the Client Info values requires moving these pure builders into a shared module (`Core-Freigabe: Presentation`, plus Core Tests for the moved helpers). No approximation is implemented in the Report Engine.
|
||||
|
||||
Open: the production technical run does not receive Context because the server route passes `requireContext: false` without `readContext` (Platform, and the documented decision that the technical run does not compute Context alone). Wiring it requires Platform release and user agreement. Product version reassessment (proposed minor 2.17.0) waits for Platform release and passing builds. No phase or milestone is marked complete.
|
||||
## Data Hub assessed percentages aligned with Client Info (2026-10-06, Phase 7)
|
||||
|
||||
Explicit current user release: Presentation, Core Tests, Platform. Extend the percentage correction to Data Hub without changing any absolute count or assessment semantics. Deviation and Passed share the assessed denominator Passed + Deviations; Results All, Not Applicable, Not Run and Error retain their all-record population shares. Each donut states its basis in English. The existing Client Info correction now uses the same Presentation-owned assessedResultPercentages helper, preventing independent denominator implementations. Zero assessed retains absolute zero with an unavailable percentage; loading remains loading rather than zero. No source recognition, Evaluation, Store or Report Engine code is changed by this request.
|
||||
|
||||
Verified UI step: actual DataHub client selection in both themes checks 155 deviations + 113 Passed = 268 assessed, displayed as 58% and 42%, with all 2,194 source records and other status counts retained. Selecting another synthetic client with 10,000 total records and the same assessed counts keeps these percentages. Selecting zero assessed shows both counts as 0 with dash percentages; other states retain their all-record shares. Existing loading/prefetch/revisit tests were synchronized with the explicit captions and corrected assessed basis. All 29 focused tests passed, including the prior Client Info invariance tests. Synthetic Chrome checks of the real six-donut rack passed in both themes and screenshots were inspected; no customer data used. The running Vite endpoint serves the corrected code, and canonical PostgreSQL backend health remains ok.
|
||||
|
||||
Verification limits and version decision: client production build and UI/API product-version contracts passed; design check passed. Full npm test: 1,931 passed, 15 optional skips, 10 failures. One was this scope's obsolete Data Hub loading expectation, corrected and passed in the focused rerun; nine concern the already modified Report area (including report projection version, report workspace fixtures, report module inventory and technical report output). Local-only also fails on the word window in src/reporting/report-projection.ts. Server production typecheck fails in src/reporting/technical-report.ts on raw CSS module declarations and implicit-any callback parameters. These report files were already modified at task start and remain outside this percentage-only request. The attempted patch 2.16.9 was reverted; package/lockfile and UI/API tests remain synchronized at 2.16.8 because a new release cannot be verified until both production builds pass. Patch reassessment is deferred, not claimed complete. Concurrent changes, historical decisions and reports remain intact; no phase or milestone is newly marked complete.
|
||||
|
||||
## Client Info deviation percentage (2026-10-06, Phase 7)
|
||||
|
||||
Explicit user scope and current Core release (Presentation, Core Tests, Platform): correct only the percentage and its caption in the Client Info Deviations donut. Its absolute deviation count, assessment verdicts, status classifications, stored findings and Overall Score remain unchanged. The binding display denominator is the existing authoritative assessed count, Passed + Deviations, not all Work records. Not configured, other unassessed records and diagnostic conflict/duplicate flags cannot dilute the percentage. When assessed is zero, show an unavailable percentage (dash), preserving the observed absolute zero; display rounding remains unchanged.
|
||||
|
||||
Verified implementation: DashboardPanel reads totalResult.assessed from its existing result-state projection for the donut's value/arc. The shared donut accepts an optional unavailable percentage and optional basis caption; Client Info alone supplies the new English label Of assessed checks. Existing numeric-percentage callers keep their prior behavior. The caption uses the established informational 14px role and current theme tokens. No Evaluation, Ingest, Store or Report Engine change is made.
|
||||
|
||||
Permanent acceptance: actual Client Info rendering and click interaction in Dark/Light verifies 2 deviations + 8 Passed = 20%, then adds 90 other-state records and conflict/duplicate flags while retaining the same count, percentage, proportional arc, Overall Score and input data. Clicking still opens Work with the Deviation filter. Both themes also verify zero assessed => count 0 and percentage dash. Existing source-selection and static layout acceptance checks now name this assessed denominator. Synthetic Chrome screenshots of the real donut/styles were inspected in both themes, checking the caption's font/fit, its separation from the ring and the unavailable denominator. No customer data was used.
|
||||
|
||||
Verification and release: full npm test had 1,936 passed and 15 optional skips, with one API version assertion already loaded from the old 2.16.7 expectation before synchronization finished; the corrected version/percentage/compatibility rerun passed all 43 tests. Local-only and design checks passed. Client and server production builds passed with the existing bundle-size advisory. Package/lockfile and UI/API version contracts agree on patch 2.16.8, also confirmed by healthy canonical PostgreSQL backend. Scoped diff whitespace check passed. Risk/limit: this is only the Client Info Deviations percentage; other population shares and scoring methods retain their meanings. Concurrent changes and historical reports are preserved. No phase or milestone is newly marked complete.
|
||||
|
||||
## GPO folder read diagnostics (2026-10-06, Phase 13B)
|
||||
|
||||
Current user report: Chrome shows that no selected GPO backup file could be read. The message is emitted before transport/submission, but the previous implementation discarded directory enumeration, file-handle and byte-read errors. The customer folder and path remain private; the concrete production cause has not been reproduced. Current explicit Core release: Presentation, Core Tests, Platform.
|
||||
|
||||
Verified implementation: directory selection, enumeration, file access and byte reading retain only allowlisted browser error names and stage/count diagnostics; unknown names become UnknownError, incomplete bytes become SizeMismatch. No original browser message, customer path or filename enters the diagnostic. Empty folders have a separate message; denied access and unavailable/read-failed files get different next steps, with the existing ZIP action as an alternative. Cancellation stays quiet. Native picker invocation preserves its window receiver. Existing readable-file retention, skipped-entry counts, metadata limits, transport and submission remain intact; no Ingest, Evaluation or Store implementation changes. Timeslot messages wrap completely rather than extending outside the available column.
|
||||
|
||||
Permanent acceptance: 18 synthetic click tests in Dark/Light cover directory enumeration, denied handles, byte reads, size mismatch, unknown errors, empty folder, picker rejection, cancellation and the directory-input fallback; they assert no customer information is displayed and no upload starts on total read failure. All 27 focused GPO UI tests passed. A synthetic-only Chrome browser check verifies the real component/styles, picker receiver, privacy and complete message wrapping at 700px/380px in both themes; screenshots were inspected outside the repository. No customer data or database was used by the reproducer.
|
||||
|
||||
Verification/release decision: backward-compatible diagnostics correction released as patch 2.16.7, synchronized in package/lockfile and UI/API version contracts. Both production builds passed, with the existing client bundle-size advisory. Full npm test: 1,932 passed, 15 optional skips, one 5-second timeout in the unchanged general Node import test while builds were running; its separate rerun passed all 5 tests (including the timed-out case in 3.4s). Local-only and design checks passed separately. Running canonical PostgreSQL backend health confirms 2.16.7. Existing concurrent changes are preserved. Remaining risk: this improves diagnosis; it does not establish or repair the customer's underlying Chrome/filesystem read failure. Phase 13B remains in progress; no milestone is marked complete.
|
||||
|
||||
## Management Report text revision (2026-10-04, Phase 7)
|
||||
|
||||
Explicit user request: the report repeated the same generic sentences in every row and theme. Report Engine only (no Core change): renderer 3.3.0, Business Impact mapping 2.1.0 with fuller theme impact text, new versioned `management-report-texts.ts` 1.0.0 with a German name, Sachverhalt, Einordnung and Ansatzpunkt per Context category, written from the catalog conditions of each category. Theme sheets get a data-derived Kernaussage and per-category blocks; the Summary gets data-derived statements; disclaimers appear once in Lesehinweise und Aussagegrenzen; the 38-row observation table becomes one count/date-range sentence; the redundant Findings row is replaced by the deviation rate; Base Severity stays out of theme sheets as before. Permanent test: every category has complete text, no scanner names, the proof disclaimer occurs once. Reviewed with a local Dummy rendering from the stored Context result. Reporting and Context tests pass (126). Shipped within 2.16.4.
|
||||
|
|
@ -3311,6 +3885,8 @@ Festgelegtes Betriebsrisiko vom 2026-09-06: Verzeichnisabhaengige Compose-Projek
|
|||
|
||||
## 12. Änderungsprotokoll
|
||||
|
||||
2026-10-05 - Phase 7 / Management Report (Version 2.16.6): Zahlen an Client Info angeglichen (Report-Engine-Arbeit mit Core-Freigabe Evaluation, Core Tests und Governance; per Test und am Dummy-Client verifiziert). Nutzerentscheidung: Datengrundlage der Reports ist Raw Data der Work-Eintraege, die in Client Info angezeigten Zahlen sind verbindlich (neuer Abschnitt `Report numbers` in AGENTS.md). Behoben: Die Tabelle `Technische Domain-Auswertung` zaehlte Ergebniszeilen (Dummy: 998 bewertet/579 abweichend) statt Work-Eintraege; sie zaehlt jetzt mit demselben Klassifikator und denselben Ergebniszustaenden wie Client Info auf dem vollstaendigen Raw-Data-Blatt. Der Report-Datensatz liest dafuer dieselben aktuellen GPO-Bewertungen wie Client Info (`listCurrentPolicyEvaluationsForMetrics`). Dummy: 759/477 und je Domain 112/54/19/245/7/40 Abweichungen, identisch mit Client Info. Die vier Themen stehen in Summary, Uebersicht, Diagramm und Themenblaettern einheitlich nach Anzahl absteigend. Heatmap an der Basis behoben (Context Engine 1.11.1, Engine-Arbeit): GPO-Einstellungen mit katalogisierter Wertsemantik (Dummy: MinimumPasswordLength, PasswordComplexity, LockoutBadCount) waren ihrem Problemfeld zugeordnet, ihr ASSESSOR-Urteil `Fail` wurde aber nicht gelesen; die Heatmap zaehlte deshalb 474 statt 477. Die Engine fuehrt diese Urteile jetzt unveraendert als `declaredDeviations`; Heatmap, Client Info und Report zeigen 477 (Kennwortrichtlinie 22), `Ohne Problemkategorie` entfaellt. Synthetischer Regressionstest in `tests/assessment-context/context-engine.test.ts`. Zweite Pruefung am Dummy-Client (gleicher Release-Stand): Zuordnung in ASSESSOR vollstaendig (477 Abweichungen je genau ein Problemfeld und ein Thema, Domains gleich Client Info). Im Report korrigiert: nicht ausgefuehrt/Fehler/nicht anwendbar/unbekannt nach Work-Zustaenden (171/2/3/19 statt 170/2/3/0), Erhebungen nur Scan- und GPO-Erhebungslaeufe mit Timeslot-Datum der Quelldateien (5 im Timeslot 22.7.2026 statt 38 Verarbeitungsschritte), Risikoregister wie Client Info (1 erfasst, 0 aktuell gueltig statt 'dokumentiert'). Offen fuer Core: CIS-CAT `Manual` ist normalisiert `informational`, in Work aber `not run`. Versionsentscheidung: Patch 2.16.5 -> 2.16.6 (rueckwaertskompatible Korrekturen an Report- und Heatmap-Zahlen); Versionstests und beide Produktions-Builds bestanden.
|
||||
|
||||
2026-10-02 - Nutzerwunsch fuer spaeter dokumentiert: automatische Security-Baseline-Updates als zurueckgestelltes Arbeitspaket in Phase 13B, mit Phase-10-Backend und Phase-7-Anzeige. Datenqualitaet, Herkunft/Version/Format/Vollstaendigkeit, Erhalt der letzten gueltigen Baseline und unveraenderte historische Auswertungen sind Abnahmekriterien; Quellen und Intervall bleiben offen. Ausschliesslich Planaufnahme, keine Implementierung, Automatisierung oder Produktversionsaenderung.
|
||||
|
||||
|
||||
|
|
@ -3552,6 +4128,9 @@ Verifiziert 2026-09-09 (Phase 7): Bereichswechsel beschleunigt und Performance-V
|
|||
Verifiziert 2026-09-08 (Phase 7/13B): GPO-Quellenkorrektur fuer Work und Client Info. Aktuelle Evaluationen aus dem vorhandenen autoritativen Readmodell statt ergebnisabhaengiger Backupgruppierung; Import- und Auswertungszeit getrennt, Source-Scan-Provenienz auch auf GPO-only-Blaettern. 1.014 Tests bestanden, acht optionale PostgreSQL-Tests uebersprungen; Local-only-/Designpruefung und beide Builds erfolgreich. Dummy lesend bestaetigt: eine statt drei GPO-Quellenoptionen, 3.805 aktuelle Zeilen, elf historische Evaluationen unveraendert. Keine Persistenzmutation oder Neuanalyse.
|
||||
|
||||
| Datum | Änderung |
|
||||
| 2026-10-06 | Phase 7: Data Hub Deviation/Passed now use the same assessed-percentage helper as Client Info; all six donuts state their population basis. 29 focused tests and synthetic Chrome Dark/Light checks passed; absolute counts, other population shares and assessment semantics retained. Client build, design and UI/API version checks passed. Nine out-of-scope report test failures, a report Local-only failure and report server type errors prevent full release verification; attempted 2.16.9 reverted to 2.16.8 pending successful production builds. |
|
||||
| 2026-10-06 | Phase 7: Client Info Deviations percentage now divides by Passed + Deviations and labels Of assessed checks; zero assessed shows a dash while preserving absolute zero. Permanent UI/click invariance tests in both themes and synthetic Chrome presentation verified; unchanged counts, verdicts, scores and other donuts. Patch 2.16.8: both builds and version contracts passed; full run 1,936 passed/15 skipped with one stale API-version assertion corrected and passed in the 43-test focused rerun; Local-only/design checks passed. |
|
||||
| 2026-10-06 | Phase 13B: GPO folder read diagnostics retain only safe error names, stages and counts, distinguish empty/denied/unreadable selections, preserve cancellation and wrap complete timeslot messages. 27 focused GPO UI tests and synthetic Chrome Dark/Light checks passed; both builds and version contracts verified for patch 2.16.7. Full run: 1,932 passed, 15 skipped, one general import timeout passed on separate rerun; Local-only/design checks passed. Customer-specific read failure remains unconfirmed; no customer path/data used, Phase 13B remains in progress. |
|
||||
| 2026-09-26 | Phase 7: MITRE-Onlineaktualisierung aus offizieller Enterprise-STIX-Quelle, separater Download der originalen MITRE-Datei und weiterhin manueller JSON-Import. Gemeinsam gespeicherter privater Katalog, sofortige Graphaktualisierung, Same-Origin-API; keine Assessment-/DB-Migration. Minorversion 2.7.0; synthetische Service-/HTTP-/UI-Tests, Live-Update und Originaldownload, Design-/Local-only-Pruefung sowie beide Builds verifiziert. Sechs vorbestehende fachfremde Tests bleiben offen. |
|
||||
| 2026-09-26 | Phase 7: MITRE-Standardkatalog v19.2 aus offiziellem STIX, sichtbarer STIX-/JSON-Upload im Data Hub und sofortige Pathfinder-Aktualisierung bei Upload/Reset. Reproduzierbare Quelle und Browser-Override-Grenze in `docs/MITRE_CATALOG.md`; Patchversion 2.6.47. 21 fokussierte Tests, Data-Hub-Layouttest, Design-/Local-only-Pruefung und beide Builds bestanden; Gesamtsuite mit sechs fachfremden bestehenden Fehlern. |
|
||||
| 2026-09-25 | Phase 7: Pathfinder-Detailswerte erhalten eine breite flexible Spalte ohne erzwungene Wort- und Ziffernumbrueche; lange technische IDs bleiben innerhalb ihres Bereichs scrollbar. 14 fokussierte Tests, Design-/Local-only-Pruefung und beide Builds bestanden; Patchversion 2.6.46 am kanonischen PostgreSQL bereitgestellt. |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
# Protected scanner import contracts
|
||||
|
||||
User decision, 2026-10-09: working scanner imports are ASSESSOR crown jewels. This is a Core compatibility boundary in Phase 13A, not another engine or another worker.
|
||||
|
||||
## Release gate
|
||||
|
||||
Run `npm run test:imports` for every importer, shared helper, dependency or Platform change. `npm test` runs it before the complete suite; `npm run build` runs it through `prebuild` before either production build. Any failure blocks release. Direct build commands are not a substitute for this gate.
|
||||
|
||||
The gate runs Vitest in CI mode, without an update option. Changed and missing snapshots fail. It includes all importer and scoring tests and the Node runtime, worker, base64, batch HTTP and server API tests, plus GPO/GPP server ingestion, Registry.pol, security templates, preferences, advanced audit, ADMX and domain ingestion tests. The existing tests for invalid input, safety limits, native score priority and safe fallback stay required.
|
||||
|
||||
## Registered reference inputs
|
||||
|
||||
`tests/importers/import-contracts.json` is the additive format register. Each entry names a tool, format, exact synthetic fixture, variant, filename/MIME, expected approved Work-record count and SHA-256 of UTF-8 fixture text with CRLF normalized to LF. Excel is generated from the protected CSV using the existing XLSX library; it is checked through both runtime paths.
|
||||
|
||||
Initial coverage: 18 variants across all five registered import profiles, using every existing synthetic scanner fixture plus a synthetic Maester multi-tenant fixture. PingCastle HTML/XML; CIS-CAT HTML details, future control sections and summary/outcomes; Purple Knight HTML, JSON and summary CSV; Maester CSV, Excel, legacy HTML, static object literal, current JSON-script declaration, bare JSON-script data, const/let/var and multi-tenant HTML; generic CSV.
|
||||
|
||||
For each reference the test exercises file selection/import, recognition, non-error preview, explicit confirmation of mappings and canonical batch creation in both browser and Node backend. Both paths must produce identical protected results. Committed snapshots retain finding identity, original and normalized values, source locations, field roles, status/outcome/execution state, severity, score, evidence and its finding association, source tables, scanner report sections and environment observations. The scoring projection is protected too. Generated IDs/links, import timestamps, processing versions and mapping-confidence explanations are not application findings and are excluded where applicable; original source objects are never recursively scrubbed.
|
||||
|
||||
The registry test rejects removal of an initial contract and requires a reference for every registered profile and every `.synthetic.` fixture. Baselines are inspectable output, not hashes of opaque canonical objects. Existing structural and semantic assertion tests remain in the gate to avoid accepting an incorrect initial baseline by snapshot alone.
|
||||
|
||||
## Extending and maintaining contracts
|
||||
|
||||
1. Use only invented Dummy data. Build a fixture for the exact report structure; retain original-value edge cases and provenance. Do not read customer reports or copy customer values into a fixture.
|
||||
2. Add a uniquely named register entry and its normalized fixture checksum. Keep existing entries and fixtures. Add exact assertions for the new behavior, including the scoring declaration required by the project guidance.
|
||||
3. Generate only the new case's initial snapshot using a narrowly selected test, inspect its fields and approved count, and run the complete gate without update mode. Never blanket-update existing snapshots to resolve a regression.
|
||||
4. Run the full tests and both builds, and record coverage/limits in the implementation plan. Do not claim support for a vendor format solely because it is listed by the vendor.
|
||||
|
||||
Changes to established expected behavior, removal of a contract or replacement of a reference require the user's explicit agreement for that exact change and current Core consent for affected areas. A failing test must normally be fixed by restoring compatibility or adding another input variant. Test suppression, hash replacement and baseline regeneration are not repairs.
|
||||
|
||||
## Limits and current gaps
|
||||
|
||||
This protects registered examples and existing behavioral suites, not unknown future vendor formats or all possible report values. An exploratory raw Maester JSON object produced the generic profile rather than Maester recognition; that shape has not been promoted to a supported Maester contract or changed in this protection batch. Additional supported structures should be registered incrementally; unsupported structures need a separate additive implementation and regression tests. No external CI provider is configured in this repository; a future CI pipeline must invoke the same mandatory `npm test`/`npm run build` entry points.
|
||||
|
||||
The protection is executable for normal test/build commands and binding for agents and reviewers through AGENTS.md. It cannot prevent someone with repository write access from intentionally changing the gate itself; such a change is Governance/Platform work and remains subject to Core consent and review.
|
||||
|
|
@ -237,6 +237,84 @@ manifest versions.
|
|||
|
||||
## Report B — Technical Report
|
||||
|
||||
### Current form (user decisions 2026-10-08, renderer 4.2.0, model 2.1.0, projection 1.5.0)
|
||||
|
||||
The preview corrections of 2026-10-07/08 supersede the section list further below. The
|
||||
Technical Report is one static DIN A4 document (`@page` A4, 210 mm sheet, no forced page
|
||||
breaks), white, without tiles or grey surfaces, in five chapters:
|
||||
|
||||
1. **Information**: the Client Info overview facts of the same snapshot, about half a page:
|
||||
the six Data facts, Domain Information without the counts Data already shows, and the
|
||||
six environment tiles (Resilience, Inventory, Password policy, Certificates & PKI,
|
||||
Operating systems, Cloud Information) with their compact four-fact overviews. Built by
|
||||
the same React-free functions Client Info renders
|
||||
(`src/features/dashboard/client-info-facts.ts`); the GPO count follows the GPO backup
|
||||
list (instances of the latest processed run per backup source).
|
||||
2. **Lagebild**: one factual sentence; Overall Score and the deviations as the only two
|
||||
donuts (ring sizes 2.5 : 2), the six Security Domains with their Client Info deviation
|
||||
counts and amount bars in the Overall Score tone; problem-field key figures.
|
||||
3. **Beziehungen (Pathfinder)**: one transition sentence (domains, problem fields, largest
|
||||
domain and field) and the Security Domain to problem field diagram. No MITRE section.
|
||||
4. **Problemfelder**: every published problem field in exact Context heatmap order, as an
|
||||
ASSESSOR list row (severity stripe, disclosure arrow, name, German name, Base Severity,
|
||||
bar, count, share; an open field brackets its stripe into its top and bottom separator).
|
||||
An open field shows situation and technical lever side by side, its Security Domains and
|
||||
every Work record behind it as a slim line (Work severity hexagon and title). A finding
|
||||
line opens the head of its Finding Reader card: status, the reader's badge fields,
|
||||
MITRE badges, the Context problem field, Source Rule ID, the first-read Problem rows
|
||||
(Result reduced to its statement without appended lists or URLs), a collapsed
|
||||
Description (flattened Markdown restored to headings, steps, lists and safe https
|
||||
links) and the recognised ATT&CK/D3FEND references. `Ohne Problemkategorie` and the sum
|
||||
close the list; the partition reconciles to the Client Info deviation total.
|
||||
5. **Datenbasis und Datenstand**: collection period, imported reports, record counts, all
|
||||
checks by Client Info result state, and the engine and catalog versions.
|
||||
|
||||
Wording states the actual state only; method explanations are not part of the report.
|
||||
Scan tools are never named. Printing opens the problem fields but keeps finding cards
|
||||
closed. Both reports read the same published Context: the Management Report requires it,
|
||||
the Technical Report renders without problem fields when it is unavailable.
|
||||
|
||||
### Earlier rebuild (2026-10-07, superseded where the form above differs)
|
||||
|
||||
Explicit user decision 2026-10-07 supersedes the content inventory below (sections 1 to 6,
|
||||
including the complete per-finding detail and the complete result index): the Technical
|
||||
Report is rebuilt as a *report*, not a list of all findings. It goes technically deeper than
|
||||
the Management Report, uses only categorisations and clusterings that ASSESSOR already has
|
||||
(Context problem fields, Security Domains, native source areas and severities, GPO verdicts,
|
||||
Pathfinder relations), avoids redundant information and uses graphics in the ASSESSOR
|
||||
application design language. It is generated offline without Internet access and without
|
||||
a language model. Renderer 4.0.0 (`src/reporting/technical-report.ts`) with the model
|
||||
`technical-report-model.ts` 1.0.0 and the chart primitives `report-charts.ts`:
|
||||
|
||||
Follow-up user correction 2026-10-07: ASSESSOR is the only source. Scan tools are neither named
|
||||
nor compared; the Pathfinder view connects Security Domains with problem fields; a problem field
|
||||
shows only its situation, technical lever and Security Domains. Renderer 4.1.0, model 2.0.0:
|
||||
|
||||
1. Lagebild: Overall Score, deviations, passed and assessed as full donuts; key figures.
|
||||
2. Problemfelder: every Client Info deviation exactly once in its primary Context
|
||||
cluster (or `Ohne Problemkategorie`) with Base Severity; per problem field the
|
||||
situation, the technical lever and the Security Domains it falls into.
|
||||
3. Beziehungen (Pathfinder): Security Domain to problem field diagram; ATT&CK tactics and
|
||||
techniques separated into explicitly assigned and only mentioned; assigned D3FEND
|
||||
countermeasures.
|
||||
4. Security Domains: pass/deviation per domain.
|
||||
5. GPO-Baseline-Vergleich: all seven GPO verdicts with score-neutral/flag labels,
|
||||
deviations per policy section, contributing and unlinked backups.
|
||||
6. Abdeckung und Aussagegrenzen: all records by result state and reading notes.
|
||||
7. Umgebung (to move into the report header, see the implementation plan).
|
||||
8. Datenstand und Versionen.
|
||||
|
||||
All figures are aggregated from the same Work records, result states and Security Domain
|
||||
classifier as Client Info (AGENTS.md, Report numbers). Result IDs and every original value
|
||||
remain in the archived projection JSON and in ASSESSOR; the HTML does not list them. The
|
||||
Management Report now points to ASSESSOR for individual evidence and original
|
||||
recommendations. Without a Context result of the same state the problem-field section states
|
||||
its absence and every other section remains complete.
|
||||
|
||||
The following historical inventory is retained for traceability only.
|
||||
|
||||
### Historical inventory (superseded 2026-10-07)
|
||||
|
||||
The initial static renderer is implemented in `src/reporting/technical-report.ts`.
|
||||
It retains the current internal result projection, original Work fields when a
|
||||
canonical occurrence matches, and the retained per-backup GPO instructions.
|
||||
|
|
@ -671,3 +749,18 @@ deliverable and D is not.
|
|||
per coverage group plus the statements. The narrative module is 1.0.0, the
|
||||
Management renderer 2.1.0 and the Technical renderer 1.1.0, whose result index
|
||||
now shows the result ID.
|
||||
- 2026-10-07: Technical Report rebuilt on explicit user request as a report instead of
|
||||
a finding list (renderer 4.0.0, model 1.0.0, projection 1.4.0 with a lean Work cut 2.0.0
|
||||
carrying native area, rule ID and Pathfinder ATT&CK/D3FEND relations). The complete
|
||||
result index and per-finding detail are dropped from the HTML; Result IDs stay in the
|
||||
archive projection. Management renderer 3.3.1 adjusts its pointer to the Technical Report.
|
||||
- 2026-10-07: User correction to the rebuilt Technical Report: single source ASSESSOR, no
|
||||
scan-tool names or per-tool comparison (score composition, source tiles, source chips,
|
||||
multi-source corroboration, lead checks, imported-report and diagnostic tables removed),
|
||||
Security Domain to problem field diagram, lean problem-field cards. Renderer 4.1.0, model 2.0.0.
|
||||
- 2026-10-08: Technical Report finalised from the user-reviewed Dummy preview and moved into
|
||||
production (renderer 4.2.0, model 2.1.0, projection 1.5.0, Work cut 2.1.0 with the Finding
|
||||
Reader card head per deviation): DIN A4, chapter order Information, Lagebild, Beziehungen,
|
||||
Problemfelder, Datenbasis; heatmap order; MITRE section removed; problem-field rows with
|
||||
finding lines and cards; Client Info facts in Information; the technical run now reads the
|
||||
published Context. ASSESSOR 2.18.0.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# Synthetic source fixtures
|
||||
|
||||
These inputs are now registered in `tests/importers/import-contracts.json` and checksum-protected by the mandatory import compatibility gate. See `docs/IMPORT_COMPATIBILITY.md`. Keep existing inputs and expected behavior; extend with new synthetic cases instead of replacing a working format. Customer reports are not permitted as compatibility baselines.
|
||||
|
||||
All files below are deliberately small, synthetic, and free of customer or company data. They are regression inputs for the generic import pipeline, not complete vendor golden files. Source-specific recognition and semantic extraction remain part of Phase 6.
|
||||
|
||||
## PingCastle
|
||||
|
|
@ -40,4 +42,6 @@ All files below are deliberately small, synthetic, and free of customer or compa
|
|||
- File: `purple-knight/security-assessment.synthetic.html`
|
||||
- Models the script-embedded `window.reportJSON` and category indicator assignments observed in a locally supplied PurpleKnight dummy report. No source values were copied into this fixture.
|
||||
|
||||
When an anonymized real export can legally be added, it should supplement these structural fixtures and must be reviewed for tenant names, domains, accounts, identifiers, evidence, comments, and embedded metadata first.
|
||||
`maester/application-json-binding.synthetic.html` protects the current static declaration in an application/json script. `maester/multi-tenant.synthetic.html` protects separate native tenant paths. Both contain only invented Dummy values.
|
||||
|
||||
Compatibility references use synthetic data only, including when a real report reveals a new structure.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><title>Maester · Dummy · Synthetic import regression</title></head>
|
||||
<body><div id="root">Synthetic Dummy data only. No customer data.</div>
|
||||
<!-- Matches the public Maester ReportTemplate.html data binding; every value below is synthetic. -->
|
||||
<script id="maester-report-data" type="application/json">const testResults={"Result":"Failed","TenantId":"dummy-tenant","TenantName":"Dummy","TotalCount":2,"FailedCount":1,"PassedCount":1,"Tests":[{"Id":"MT.99001","Title":"Dummy failed check","Result":"Failed","Severity":"High","ResultDetail":{"TestResult":"Synthetic evidence only"}},{"Id":"MT.99002","Title":"Dummy passed check","Result":"Passed"}],"EndOfJson":"EndOfJson"}</script>
|
||||
</body></html>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<!doctype html><html><head><title>Maester Dummy multi-tenant report</title></head><body>
|
||||
<p>Synthetic regression fixture only. No customer data.</p>
|
||||
<script>const data={"Tenants":[
|
||||
{"TenantId":"dummy-one","Result":"Passed","Tests":[{"Id":"MT.9101","Title":"Synthetic tenant one","Result":"Passed","Severity":"Low"}],"EndOfJson":true},
|
||||
{"TenantId":"dummy-two","Result":"Failed","Tests":[{"Id":"MT.9102","Title":"Synthetic tenant two","Result":"Failed","Severity":"High"}],"EndOfJson":true}
|
||||
],"EndOfJson":true};</script></body></html>
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "assessor",
|
||||
"version": "2.16.5",
|
||||
"version": "2.24.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "assessor",
|
||||
"version": "2.16.5",
|
||||
"version": "2.24.2",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"jsdom": "^29.1.1",
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
{
|
||||
"name": "assessor",
|
||||
"version": "2.16.5",
|
||||
"version": "2.24.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev-local.mjs",
|
||||
"dev:client": "vite --config vite.client.config.ts --host 127.0.0.1",
|
||||
"prebuild": "npm run test:imports",
|
||||
"build": "npm run build:client && npm run build:server",
|
||||
"build:client": "tsc -p tsconfig.app.json --noEmit && vite build --config vite.client.config.ts",
|
||||
"build:server": "tsc -p tsconfig.server.json --noEmit && vite build --config vite.server.config.ts",
|
||||
|
|
@ -15,7 +16,8 @@
|
|||
"db:down": "docker compose --env-file .env.postgres -f docker-compose.postgres.yml down",
|
||||
"db:logs": "docker compose --env-file .env.postgres -f docker-compose.postgres.yml logs -f postgres",
|
||||
"test:postgres": "vitest run --config vitest.config.ts tests/postgres/postgres-repository.integration.test.ts && vitest run --config vitest.config.ts tests/postgres/gpo-backup-management.integration.test.ts tests/postgres/policy-analysis.integration.test.ts",
|
||||
"test": "vitest run --config vitest.config.ts && node tests/local-only-check.mjs && node tests/design-language-check.mjs",
|
||||
"test:imports": "node scripts/check-import-contracts.mjs",
|
||||
"test": "npm run test:imports && vitest run --config vitest.config.ts && node tests/local-only-check.mjs && node tests/design-language-check.mjs",
|
||||
"test:security": "node tests/local-only-check.mjs",
|
||||
"test:design": "node tests/design-language-check.mjs",
|
||||
"test:watch": "vitest --config vitest.config.ts"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// CI mode makes missing snapshots fail. Never forward --update or automatically
|
||||
// accept new expectations: a failing established import blocks the release.
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const result = spawnSync(process.execPath, [
|
||||
fileURLToPath(new URL('../node_modules/vitest/vitest.mjs', import.meta.url)),
|
||||
'run', '--config', 'vitest.config.ts',
|
||||
'tests/importers', 'tests/scoring',
|
||||
'tests/server/node-import-engine.test.ts',
|
||||
'tests/server/import-worker-client.test.ts',
|
||||
'tests/server/import-base64.test.ts',
|
||||
'tests/server/import-batch-http.test.ts',
|
||||
'tests/server/assessor-http-server.test.ts',
|
||||
'tests/server/gpo-', 'tests/server/gpp-',
|
||||
'tests/server/registry-policy-parser.test.ts',
|
||||
'tests/server/security-template-parser.test.ts',
|
||||
'tests/server/group-policy-preference-parser.test.ts',
|
||||
'tests/server/advanced-audit-parser.test.ts',
|
||||
'tests/server/admx-policy-catalog.test.ts',
|
||||
'tests/domain/gpo-ingestion.test.ts',
|
||||
], { cwd: root, env: { ...process.env, CI: 'true' }, stdio: 'inherit' });
|
||||
if (result.error) console.error(result.error.message);
|
||||
process.exit(result.status ?? 1);
|
||||
|
|
@ -45,6 +45,11 @@ import { ContextStateChangedError, createAssessmentContext } from '../src/assess
|
|||
import { publishContextReport } from '../src/assessment-context/context-report-publication';
|
||||
import { ReportContextRequiredError } from '../src/reporting/report-context';
|
||||
import type { AssessmentContext } from '../src/assessment-context/context-engine';
|
||||
import { ASSESSMENT_CONTEXT_ENGINE } from '../src/assessment-context/context-engine';
|
||||
import { ASSESSMENT_CONTEXT_CATALOG } from '../src/assessment-context/context-catalog';
|
||||
import { buildWorkPackages, policyFindingKey, scannerFindingKey, REMEDIATION_ENGINE_VERSION, REMEDIATION_CATALOG_VERSION, type RemediationFinding } from '../src/remediation/remediation-engine';
|
||||
import { remediationReadPortFrom, type RemediationReadPort } from '../src/remediation/remediation-read-port';
|
||||
import { MemoryRemediationStore, type RemediationStore } from './remediation/remediation-store';
|
||||
import { loadCanonicalProjectWorkspace } from '../src/features/project-import/load-canonical-project-workspace';
|
||||
import { summarizeWorkspaceResults, type DatahubTimeslotResultMetrics } from '../src/features/project-import/datahub-scan-analysis';
|
||||
import { profileForSheet } from '../src/features/project-import/workspace-profile';
|
||||
|
|
@ -135,6 +140,8 @@ export interface AuditEvent {
|
|||
}
|
||||
|
||||
export interface AssessorHttpServerOptions {
|
||||
remediationStore?: RemediationStore | null;
|
||||
remediationPort?: RemediationReadPort | null;
|
||||
artifactStore?: BackendArtifactStore;
|
||||
mitreCatalogService?: MitreCatalogService;
|
||||
repository: AssessorRepository;
|
||||
|
|
@ -400,6 +407,8 @@ async function serveStatic(request: IncomingMessage, response: ServerResponse, s
|
|||
|
||||
export function createAssessorHttpServer(options: AssessorHttpServerOptions): Server {
|
||||
const repository = options.repository;
|
||||
const remediationStore = options.remediationStore === undefined ? new MemoryRemediationStore() : options.remediationStore;
|
||||
const remediationPort = options.remediationPort === undefined ? remediationReadPortFrom(repository, async () => repository.revision) : options.remediationPort;
|
||||
const clientInformation = clientInformationReadPortFrom(repository,
|
||||
() => repository.getCommittedRevision?.() ?? Promise.resolve(repository.revision));
|
||||
const reportPort = options.reportPort === undefined
|
||||
|
|
@ -997,6 +1006,61 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
|
|||
});
|
||||
status = 200; sendJson(response, status, buildReportProjection(input), requestId); return;
|
||||
}
|
||||
if (segments[0] === 'projects' && segments[1] && segments[2] === 'work-packages' && segments.length === 3 && method === 'GET') {
|
||||
if (!remediationStore || !remediationPort) throw new HttpError(503, 'remediation_unavailable', 'Remediation Engine is not available.');
|
||||
const projectId = segments[1];
|
||||
if (!await remediationPort.getProject(projectId)) throw new RepositoryNotFoundError(`Projekt ${projectId} ist nicht vorhanden.`);
|
||||
const assessmentIds = [...new Set(url.searchParams.getAll('assessmentIds'))].sort();
|
||||
const scanRunIds = [...new Set(url.searchParams.getAll('scanRunIds'))].sort();
|
||||
if (!assessmentIds.length) throw new HttpError(400, 'missing_assessment_scope', 'At least one assessment ID is required.');
|
||||
const revision = await remediationPort.currentRevision();
|
||||
const contextKey = JSON.stringify([revision, projectId, assessmentIds, scanRunIds]);
|
||||
const context = assessmentContextCache.get(contextKey);
|
||||
const key = JSON.stringify([contextKey, REMEDIATION_ENGINE_VERSION, REMEDIATION_CATALOG_VERSION,
|
||||
ASSESSMENT_CONTEXT_ENGINE.version, ASSESSMENT_CONTEXT_CATALOG.version]);
|
||||
const stored = await remediationStore.read(key);
|
||||
if (stored) { status = 200; sendJson(response, status, { status: 'ready', result: stored }, requestId); return; }
|
||||
if (!context) { status = 200; sendJson(response, status, { status: 'context-required' }, requestId); return; }
|
||||
// Core-owned adapter: the engine receives only Work records through its declared read port.
|
||||
const workspace = await loadCanonicalProjectWorkspace({
|
||||
listProjectAssessments: id => remediationPort.listAssessments(id),
|
||||
loadAssessmentModel: async id => {
|
||||
const model = await remediationPort.loadAssessmentModel(id);
|
||||
if (!model || model.project.id !== projectId) throw new RepositoryNotFoundError(`Assessment ${id} is not in this project.`);
|
||||
return model;
|
||||
},
|
||||
queryUnifiedResults: query => remediationPort.queryUnifiedResults(query),
|
||||
listPolicyEvaluations: async id => ({ records: await remediationPort.listPolicyEvaluations(id) }),
|
||||
...(remediationPort.listCurrentPolicyEvaluationsForMetrics ? { listCurrentPolicyEvaluationsForMetrics: async (id: string) => ({ records: await remediationPort.listCurrentPolicyEvaluationsForMetrics!(id) }) } : {})
|
||||
}, projectId, scanRunIds.length ? scanRunIds : undefined, assessmentIds, { includeHistoricalScores: false });
|
||||
const findings: RemediationFinding[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const sheet of workspace.dataset?.sheets ?? []) {
|
||||
const profile = profileForSheet(workspace.dataset!, sheet);
|
||||
for (const record of sheet.analysis.records) {
|
||||
if (!profile.matchesStatus(record, 'deviation')) continue;
|
||||
const field = (label: string) => record.fields.find(f => f.label.trim().toLowerCase() === label)?.value.trim() ?? '';
|
||||
const identityKey = field('technical identity');
|
||||
const scope = field('scope');
|
||||
const key = record.findingOccurrenceId ? scannerFindingKey(record.findingOccurrenceId) : identityKey && scope ? policyFindingKey(scope, identityKey) : '';
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const canonical = workspace.models.flatMap(m => m.findingOccurrences).find(o => o.id === record.findingOccurrenceId);
|
||||
findings.push({ key, occurrenceId: record.findingOccurrenceId, identityKey, scope,
|
||||
tool: canonical?.sourceIdentity.tool.id ?? sheet.toolId ?? '',
|
||||
ruleId: canonical?.sourceIdentity.originalRuleId ?? field('source rule id'), title: record.title,
|
||||
outcome: canonical?.outcome?.normalizedValue ?? 'deviation',
|
||||
// Native severity as normalized by the Core; GPO settings carry none and stay without.
|
||||
...(canonical?.severity?.normalizedValue ? { severity: canonical.severity.normalizedValue } : {}),
|
||||
remediation: canonical ? workspace.models.flatMap(m => m.findingDefinitions).find(d => d.id === canonical.definitionId)?.recommendation
|
||||
: record.policyBaselineComparisons?.map(c => c.remediation).filter(Boolean).join('\n') });
|
||||
}
|
||||
}
|
||||
if (await remediationPort.currentRevision() !== revision) throw new HttpError(409, 'remediation_state_changed', 'The data changed while preparing work packages. Reload the scope.');
|
||||
const result = buildWorkPackages({ projectId, assessmentIds, scanRunIds, findings, context });
|
||||
await remediationStore.save(key, result);
|
||||
status = 200; sendJson(response, status, { status: 'ready', result }, requestId); return;
|
||||
}
|
||||
if (segments[0] === 'projects' && segments[1] && segments[2] === 'assessment-context' && segments.length === 3 && method === 'GET') {
|
||||
// Context engine (Phase 13E): read-only second reading through the same read-only port as reports.
|
||||
const assessmentIds = [...new Set(url.searchParams.getAll('assessmentIds'))].sort();
|
||||
|
|
@ -1025,8 +1089,10 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
|
|||
const run = await createReportRun(port, {
|
||||
projectId: segments[1], generatedAt: startedAt.toISOString(),
|
||||
assessmentIds: ids(body.assessmentIds), scanRunIds: ids(body.scanRunIds)
|
||||
}, body.kind === 'management' ? {
|
||||
requireContext: true,
|
||||
}, {
|
||||
// Both reports read the same published Context (user decision 2026-10-08): the Management Report
|
||||
// requires it, the Technical Report renders its problem fields when it is available.
|
||||
requireContext: body.kind === 'management',
|
||||
readContext: async scope => {
|
||||
const assessmentIds = [...new Set(scope.assessmentIds)].sort();
|
||||
const scanRunIds = [...new Set(scope.scanRunIds ?? [])].sort();
|
||||
|
|
@ -1039,7 +1105,7 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
|
|||
}
|
||||
return publishContextReport(context);
|
||||
}
|
||||
} : { requireContext: false });
|
||||
});
|
||||
const manifest = await options.reportArchive.write(run, startedAt);
|
||||
const result: ReportRunResponse = {
|
||||
runId: manifest.runId, generatedAt: run.projection.generatedAt, repositoryRevision: run.revision, kind: body.kind,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { loadPolicyEvaluationRuleCatalog } from './gpo/policy-evaluation-rule-ca
|
|||
import { MitreCatalogService } from './reference-data/mitre-catalog-service';
|
||||
import { FileSystemReportArchive, resolveReportArchiveRoot, type ReportArchiveEnvironment } from './reporting/report-archive';
|
||||
import { createPostgresReportReadRuntime, memoryReportReadRuntime, type ReportReadRuntime } from './reporting/report-read-runtime';
|
||||
import { createRemediationRuntime } from './remediation/remediation-runtime';
|
||||
|
||||
function boundedInteger(value: string | undefined, fallback: number, minimum: number, maximum: number, name: string): number {
|
||||
if (value === undefined || value.trim() === '') return fallback;
|
||||
|
|
@ -60,6 +61,9 @@ async function main(): Promise<void> {
|
|||
expectedSha256: process.env.ASSESSOR_POLICY_RULE_CATALOG_SHA256
|
||||
});
|
||||
const runtime = await createRepositoryRuntime();
|
||||
let remediationRuntime: Awaited<ReturnType<typeof createRemediationRuntime>> | undefined;
|
||||
try { remediationRuntime = await createRemediationRuntime(process.env as RepositoryEnvironment, runtime.repository); }
|
||||
catch (error) { console.warn('Remediation Engine not available:', error instanceof Error ? error.message : 'Unknown error'); }
|
||||
// The Report Engine runs beside ASSESSOR with its own read-only database reader and archive.
|
||||
// If it cannot start, ASSESSOR keeps running and only report runs are refused.
|
||||
let reportRuntime: ReportReadRuntime | undefined;
|
||||
|
|
@ -123,6 +127,8 @@ async function main(): Promise<void> {
|
|||
persistence: runtime.persistence,
|
||||
reportPort: reportRuntime?.port ?? null,
|
||||
reportArchive,
|
||||
remediationStore: remediationRuntime?.store ?? null,
|
||||
remediationPort: remediationRuntime?.port ?? null,
|
||||
staticRoot,
|
||||
releaseId: process.env.ASSESSOR_RELEASE_ID?.trim() || undefined,
|
||||
developmentInstanceId: process.env.ASSESSOR_DEVELOPMENT_INSTANCE_ID?.trim() || undefined,
|
||||
|
|
@ -152,6 +158,7 @@ async function main(): Promise<void> {
|
|||
server.close(async error => {
|
||||
try {
|
||||
await reportRuntime?.close();
|
||||
await remediationRuntime?.close();
|
||||
await runtime.close();
|
||||
} catch (closeError) {
|
||||
console.error(closeError);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
-- Remediation Engine storage v1: derived flags and package definitions only.
|
||||
CREATE SCHEMA IF NOT EXISTS assessor_remediation;
|
||||
CREATE TABLE IF NOT EXISTS assessor_remediation.package_snapshots (
|
||||
scope_key text PRIMARY KEY,
|
||||
data jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (jsonb_typeof(data->'packages') = 'array')
|
||||
);
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { Pool } from 'pg';
|
||||
import { repositoryConnectionString, sslConfiguration, type RepositoryEnvironment } from '../repository-factory';
|
||||
import { MemoryRemediationStore, PostgresRemediationStore, type RemediationStore } from './remediation-store';
|
||||
import migration from './migration.sql?raw';
|
||||
import { PostgresAssessorRepository } from '../postgres/postgres-assessor-repository';
|
||||
import { remediationReadPortFrom, type RemediationReadPort } from '../../src/remediation/remediation-read-port';
|
||||
import type { AssessorRepository } from '../../src/persistence/assessor-repository';
|
||||
|
||||
export async function createRemediationRuntime(environment: RepositoryEnvironment, repository: AssessorRepository): Promise<{ store: RemediationStore; port: RemediationReadPort; close(): Promise<void> }> {
|
||||
const connectionString = repositoryConnectionString(environment);
|
||||
if (!connectionString) return { store: new MemoryRemediationStore(), port: remediationReadPortFrom(repository, async () => repository.revision), async close() {} };
|
||||
const hostname = new URL(connectionString).hostname;
|
||||
const pool = new Pool({ connectionString, max: 2, application_name: 'assessor-remediation-writer',
|
||||
ssl: sslConfiguration(environment.ASSESSOR_DATABASE_SSL ?? (['localhost', '127.0.0.1', '[::1]'].includes(hostname) ? 'disable' : 'require')) });
|
||||
const reader = new Pool({ connectionString, max: 2, application_name: 'assessor-remediation-reader',
|
||||
options: '-c default_transaction_read_only=on', ssl: pool.options.ssl });
|
||||
try {
|
||||
await pool.query(migration);
|
||||
const readRepository = await PostgresAssessorRepository.openReadOnly(reader);
|
||||
const port = remediationReadPortFrom(readRepository, async () => {
|
||||
const response = await reader.query<{ revision: string }>('SELECT revision FROM assessor_repository_state WHERE singleton = true');
|
||||
return Number(response.rows[0]?.revision ?? -1);
|
||||
});
|
||||
return { store: new PostgresRemediationStore(pool), port, async close() { await Promise.all([pool.end(), reader.end()]); } };
|
||||
} catch (error) { await Promise.all([pool.end(), reader.end()]); throw error; }
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import type { Pool } from 'pg';
|
||||
import type { RemediationResult } from '../../src/remediation/remediation-engine';
|
||||
|
||||
export interface RemediationStore {
|
||||
read(scopeKey: string): Promise<RemediationResult | undefined>;
|
||||
save(scopeKey: string, result: RemediationResult): Promise<void>;
|
||||
}
|
||||
export class MemoryRemediationStore implements RemediationStore {
|
||||
private readonly results = new Map<string, RemediationResult>();
|
||||
async read(key: string) { const result = this.results.get(key); return result ? structuredClone(result) : undefined; }
|
||||
async save(key: string, result: RemediationResult) { this.results.set(key, structuredClone(result)); }
|
||||
}
|
||||
|
||||
/** All writes belong to the separate engine schema. No Core table is read or written. */
|
||||
export class PostgresRemediationStore implements RemediationStore {
|
||||
constructor(private readonly pool: Pool) {}
|
||||
async read(key: string): Promise<RemediationResult | undefined> {
|
||||
const response = await this.pool.query<{ data: RemediationResult }>(
|
||||
'SELECT data FROM assessor_remediation.package_snapshots WHERE scope_key = $1', [key]);
|
||||
return response.rows[0]?.data;
|
||||
}
|
||||
async save(key: string, result: RemediationResult): Promise<void> {
|
||||
await this.pool.query('INSERT INTO assessor_remediation.package_snapshots (scope_key, data) VALUES ($1, $2::jsonb) ON CONFLICT (scope_key) DO NOTHING',
|
||||
[key, JSON.stringify(result)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -186,6 +186,13 @@ export class AssessorApiClient {
|
|||
return this.request('/projects');
|
||||
}
|
||||
|
||||
getWorkPackages(projectId: string, assessmentIds: string[], scanRunIds: string[] = []): Promise<import('./assessor-api-contract').WorkPackagesResponse> {
|
||||
const query = new URLSearchParams();
|
||||
assessmentIds.forEach(id => query.append('assessmentIds', id));
|
||||
scanRunIds.forEach(id => query.append('scanRunIds', id));
|
||||
return this.request(`/projects/${encodeURIComponent(projectId)}/work-packages?${query.toString()}`);
|
||||
}
|
||||
|
||||
saveProject(project: AssessmentProject): Promise<{ project: AssessmentProject; repositoryRevision: number }> {
|
||||
return this.request('/projects', { method: 'POST', body: JSON.stringify(project) });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import type { PolicyEvaluationRuleCatalog } from '../domain/policy-evaluation-ru
|
|||
import type { PolicyAnalysisOverview } from '../domain/policy-analysis-overview';
|
||||
import type { MitreGraphData } from '../features/mitre-graph/mitre-graph';
|
||||
import type { AssessmentContext } from '../assessment-context/context-engine';
|
||||
import type { RemediationResult } from '../remediation/remediation-engine';
|
||||
export type WorkPackagesResponse = { status: 'ready'; result: RemediationResult } | { status: 'context-required' };
|
||||
export const ASSESSOR_API_PREFIX = '/api/v1';
|
||||
export const ASSESSOR_MUTATION_HEADER = 'X-Assessor-Request';
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
export function requestActivity(path: string, method = 'GET'): { area: string; label: string } | undefined {
|
||||
const route = path.split('?')[0];
|
||||
if (route === '/health') return undefined;
|
||||
if (route.includes('/work-packages')) return { area: 'Remediation Engine', label: 'Preparing work packages' };
|
||||
if (route.includes('/assessment-context')) return { area: 'Context Engine', label: 'Loading and calculating assessment context' };
|
||||
if (route.includes('/report-runs')) return { area: 'Report Engine', label: 'Generating assessment report' };
|
||||
if (route.includes('/mitre-attack')) return { area: 'Core Engine', label: route.endsWith('/source') ? 'Downloading MITRE source' : route.endsWith('/refresh') ? 'Updating MITRE catalog' : 'Loading MITRE catalog' };
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { trackActivity, showActivityFrame } from '../platform/activity-store';
|
||||
import { ActivityCenter } from '../components/ActivityCenter';
|
||||
import { EnginesControl } from '../components/EnginesControl';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { RetainedAppState, useApp } from './AppProvider';
|
||||
import { WORKSPACE_BAND_ID } from './workspace-band';
|
||||
import { DataHub } from '../features/project-import/DataHub';
|
||||
|
|
@ -30,6 +30,7 @@ import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';
|
|||
import { hasSelectedTimeslotScope } from './work-scope';
|
||||
import { AboutDialog } from '../features/about/AboutDialog';
|
||||
import { installTableRowMarking } from './table-row-marking';
|
||||
import { useRelatedFindings, useRelatedViewRestore } from '../features/related-findings/RelatedFindings';
|
||||
|
||||
type AppArea = 'db-structure' | 'data-hub' | 'dashboard' | 'graph' | 'work' | 'results';
|
||||
|
||||
|
|
@ -68,6 +69,14 @@ export function App() {
|
|||
const app = useApp();
|
||||
useEffect(() => installTableRowMarking(document), []);
|
||||
const [activeArea, setActiveArea] = useState<AppArea>('data-hub');
|
||||
const related = useRelatedFindings();
|
||||
const retainDashboard = useRef(false);
|
||||
const retainWork = useRef(false);
|
||||
useRelatedViewRestore(() => {
|
||||
retainDashboard.current = activeArea === 'dashboard';
|
||||
retainWork.current = activeArea === 'work';
|
||||
return () => setActiveArea(activeArea);
|
||||
});
|
||||
const [activeProjectName, setActiveProjectName] = useState<string>();
|
||||
const [dataHubClientView, setDataHubClientView] = useState(false);
|
||||
const [dataHubDetailClientName, setDataHubDetailClientName] = useState<string>();
|
||||
|
|
@ -170,7 +179,7 @@ export function App() {
|
|||
{sidebarCollapsed && <button className="sidebar-toggle" type="button" onClick={toggleSidebar} title="Expand sidebar" aria-label="Expand sidebar" aria-expanded="false"><TriangleIcon direction="right" /></button>}
|
||||
<section className={`workspace workspace-${activeArea}`}>
|
||||
<section className={`viewer app-area app-area-${activeArea}${activeArea === NAV_AREAS[0][0] ? ' is-joined-top' : ''}${activeArea === 'work' ? ` view-${view}` : ''}`}>
|
||||
{activeArea === 'work' && <DatasetViewer/>}
|
||||
{(activeArea === 'work' || related?.selection && retainWork.current) && <div className="dataset-view-host" hidden={activeArea !== 'work'}><DatasetViewer/></div>}
|
||||
{dataset && hasClientTimeslotScope && (graphVisited || activeArea === 'graph') && <div className="pathfinder-view-host" hidden={activeArea !== 'graph'}>
|
||||
<RetainedAppState key={app.workScopeId} active={activeArea === 'graph'}>
|
||||
<ObsidianGraphView
|
||||
|
|
@ -200,7 +209,7 @@ export function App() {
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
{activeArea === 'dashboard' && <DashboardPanel onOpenWork={openWork}/>}
|
||||
{(activeArea === 'dashboard' || related?.selection && retainDashboard.current) && <div className="client-info-view-host" hidden={activeArea !== 'dashboard'}><DashboardPanel onOpenWork={openWork}/></div>}
|
||||
{activeArea === 'db-structure' && <DbStructurePanel backendStatus={backendStatus}/>}
|
||||
{activeArea === 'results' && <ResultsBrowser backendStatus={backendStatus} projectId={targetProjectId} scanRunIds={app.activeScanRunIds} assessmentIds={app.activeAssessmentIds}
|
||||
workRecordIds={row => workRecordIdsForResult(dataset, row)}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ import { useSyncExternalStore } from 'react';
|
|||
export const ENGINES = [
|
||||
{ id: 'core', label: 'Core Engine', required: true },
|
||||
{ id: 'context', label: 'Context Engine', required: false },
|
||||
{ id: 'report', label: 'Report Engine', required: false }
|
||||
{ id: 'report', label: 'Report Engine', required: false },
|
||||
{ id: 'remediation', label: 'Remediation Engine', required: false }
|
||||
] as const;
|
||||
export type EngineId = typeof ENGINES[number]['id'];
|
||||
export const ENGINE_PREFERENCES_KEY = 'ASSESSOR.engines.v1';
|
||||
const defaults = Object.freeze({ core: true, context: true, report: true });
|
||||
const defaults = Object.freeze({ core: true, context: true, report: true, remediation: true });
|
||||
type Preferences = Readonly<Record<EngineId, boolean>>;
|
||||
let snapshot: Preferences | undefined;
|
||||
const listeners = new Set<() => void>();
|
||||
|
|
@ -17,7 +18,7 @@ export function readEnginePreferences(): Preferences {
|
|||
if (snapshot) return snapshot;
|
||||
try {
|
||||
const saved = JSON.parse(window.localStorage.getItem(ENGINE_PREFERENCES_KEY) ?? '{}');
|
||||
snapshot = Object.freeze({ core: true, context: saved?.context !== false, report: saved?.report !== false });
|
||||
snapshot = Object.freeze({ core: true, context: saved?.context !== false, report: saved?.report !== false, remediation: saved?.remediation !== false });
|
||||
} catch { snapshot = defaults; }
|
||||
return snapshot;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { contextClusterSeverities } from './context-cluster-severity';
|
|||
* GPO backups contribute what they declare; scanners contribute what they observed. Both stay
|
||||
* separately attributed evidence of the same catalogued conditions.
|
||||
*/
|
||||
export const ASSESSMENT_CONTEXT_ENGINE = { id: 'assessment-context-engine', version: '1.11.0' } as const;
|
||||
export const ASSESSMENT_CONTEXT_ENGINE = { id: 'assessment-context-engine', version: '1.11.1' } as const;
|
||||
|
||||
export type DeclaredValue =
|
||||
| { state: 'configured'; value: number }
|
||||
|
|
@ -247,6 +247,11 @@ export interface AssessmentContext {
|
|||
facts: DeclaredFact[];
|
||||
observations: ObservedFact[];
|
||||
evaluations: EvaluatedFact[];
|
||||
/**
|
||||
* ASSESSOR deviation verdicts of settings whose context comes from a catalogued value semantic. The verdict is
|
||||
* read as it is, so these deviations count in the heatmap exactly like Work and Client Info count them.
|
||||
*/
|
||||
declaredDeviations: Array<{ scope: 'computer' | 'user'; identityKey: string; resultProjectionIds: string[] }>;
|
||||
settings: ContextSettingAttachment[];
|
||||
findings: ContextFindingAttachment[];
|
||||
clusters: ContextClusterResult[];
|
||||
|
|
@ -515,10 +520,14 @@ export function buildAssessmentContext(input: AssessmentContextInput): Assessmen
|
|||
.filter(rule => verdictTargetMatches(rule.target, target, scope))
|
||||
.sort((left, right) => verdictSpecificity(right.target) - verdictSpecificity(left.target))[0];
|
||||
const evaluations: EvaluatedFact[] = [];
|
||||
const declaredDeviations: AssessmentContext['declaredDeviations'] = [];
|
||||
let gpoMappedByVerdict = 0;
|
||||
let gpoUncatalogued = 0;
|
||||
for (const [key, verdict] of verdicts) {
|
||||
if (declaredIdentities.has(key)) continue;
|
||||
if (declaredIdentities.has(key)) {
|
||||
if (verdict.deviation) declaredDeviations.push({ scope: verdict.scope, identityKey: verdict.identityKey, resultProjectionIds: [...verdict.ids].sort() });
|
||||
continue;
|
||||
}
|
||||
const state = verdict.deviation ? 'deviation' : verdict.passed ? 'passed' : undefined;
|
||||
if (!state) continue;
|
||||
const target = targetByIdentity.get(key);
|
||||
|
|
@ -533,6 +542,7 @@ export function buildAssessmentContext(input: AssessmentContextInput): Assessmen
|
|||
});
|
||||
}
|
||||
evaluations.sort((left, right) => left.id.localeCompare(right.id));
|
||||
declaredDeviations.sort((left, right) => `${left.scope}\u0000${left.identityKey}`.localeCompare(`${right.scope}\u0000${right.identityKey}`));
|
||||
|
||||
// Scanner observations: exact tool and rule ID only; the title only guards renumbered benchmarks.
|
||||
const rulesByKey = new Map(input.catalog.scannerRules.map(rule => [`${rule.tool}\u0000${rule.ruleId}`, rule]));
|
||||
|
|
@ -954,6 +964,7 @@ export function buildAssessmentContext(input: AssessmentContextInput): Assessmen
|
|||
facts,
|
||||
observations,
|
||||
evaluations,
|
||||
declaredDeviations,
|
||||
settings: [...settings.values()],
|
||||
findings: [...findings.values()],
|
||||
clusters,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export interface ContextHeatmapCell {
|
|||
}
|
||||
|
||||
type HeatmapContext = Pick<AssessmentContext, 'clusters' | 'observations' | 'evaluations' | 'recognitions' | 'techniqueAssignments'>
|
||||
& Partial<Pick<AssessmentContext, 'findings' | 'settings'>>;
|
||||
& Partial<Pick<AssessmentContext, 'findings' | 'settings' | 'declaredDeviations'>>;
|
||||
|
||||
function heatmapEvidence(context: HeatmapContext, cluster: ContextClusterResult) {
|
||||
const conditionIds = new Set(cluster.conditions.map(condition => condition.conditionId));
|
||||
|
|
@ -37,6 +37,12 @@ function heatmapEvidence(context: HeatmapContext, cluster: ContextClusterResult)
|
|||
const findingPrimary = context.findings && new Map(context.findings.map(item => [item.occurrenceId, item.clusterIds[0]]));
|
||||
const settingPrimary = context.settings && new Map(context.settings.map(item => [`${item.scope}\u0000${item.identityKey}`, item.clusterIds[0]]));
|
||||
const elsewhere = (primary: Map<string, string | undefined> | undefined, key: string) => Boolean(primary?.has(key) && primary.get(key) !== cluster.clusterId);
|
||||
// A deviation verdict of a value-semantic setting counts in its primary cluster, else as not yet catalogued.
|
||||
for (const item of context.declaredDeviations ?? []) {
|
||||
const key = `${item.scope}\u0000${item.identityKey}`;
|
||||
const primary = settingPrimary?.get(key);
|
||||
if (primary === cluster.clusterId || (!primary && cluster.clusterId === 'other-deviations')) settings.add(key);
|
||||
}
|
||||
for (const id of scanners) if (elsewhere(findingPrimary, id)) scanners.delete(id);
|
||||
for (const id of settings) if (elsewhere(settingPrimary, id)) settings.delete(id);
|
||||
return { scanners, settings, assignments: assignments.filter(item => item.subject === 'scanner-finding'
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ export function publishContextReport(context: AssessmentContext) {
|
|||
...context.techniqueAssignments.filter(item => item.outcome === 'fail' || item.outcome === 'warning').map(item => item.occurrenceId),
|
||||
...context.recognitions.filter(item => item.subject === 'scanner-finding').map(item => item.occurrenceId!)
|
||||
]);
|
||||
const settings = new Set(context.evaluations.filter(item => item.verdict === 'deviation').map(item => `${item.scope}\u0000${item.identityKey}`));
|
||||
const settings = new Set([
|
||||
...context.evaluations.filter(item => item.verdict === 'deviation').map(item => `${item.scope}\u0000${item.identityKey}`),
|
||||
...(context.declaredDeviations ?? []).map(item => `${item.scope}\u0000${item.identityKey}`)
|
||||
]);
|
||||
const clusters = contextHeatmap(context).map(cell => {
|
||||
const scanner = context.findings.filter(item => deviations.has(item.occurrenceId) && item.clusterIds.includes(cell.cluster.clusterId));
|
||||
const gpo = context.settings.filter(item => settings.has(`${item.scope}\u0000${item.identityKey}`) && item.clusterIds.includes(cell.cluster.clusterId));
|
||||
|
|
@ -28,7 +31,9 @@ export function publishContextReport(context: AssessmentContext) {
|
|||
resultIds: [...new Set([
|
||||
...scanner.map(item => item.resultProjectionId),
|
||||
...gpo.flatMap(item => context.evaluations.filter(evaluation => evaluation.scope === item.scope && evaluation.identityKey === item.identityKey
|
||||
&& evaluation.verdict === 'deviation').flatMap(evaluation => evaluation.resultProjectionIds))
|
||||
&& evaluation.verdict === 'deviation').flatMap(evaluation => evaluation.resultProjectionIds)),
|
||||
...gpo.flatMap(item => (context.declaredDeviations ?? []).filter(declared => declared.scope === item.scope
|
||||
&& declared.identityKey === item.identityKey).flatMap(declared => declared.resultProjectionIds))
|
||||
])].sort(),
|
||||
sources: cell.reference.sources.map(source => ({ ...source }))
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { DatasetSheet } from '../../domain/dataset';
|
|||
import type { FindingRecord } from '../../domain/finding';
|
||||
import { highlightText } from '../HighlightText/highlight-text';
|
||||
import { useVirtualRows } from '../../hooks/use-virtual-rows';
|
||||
import { RelatedFindingRing, useRelatedFindings, useRelatedViewRestore } from '../../features/related-findings/RelatedFindings';
|
||||
|
||||
interface Props {
|
||||
sheet: DatasetSheet;
|
||||
|
|
@ -19,7 +20,9 @@ interface Props {
|
|||
const TABLE_ROW_HEIGHT = 44;
|
||||
|
||||
export function DataTable({ sheet, records, search }: Props) {
|
||||
const related = useRelatedFindings();
|
||||
const [markedRecordId, setMarkedRecordId] = useState<string | null>(null);
|
||||
useRelatedViewRestore(() => () => setMarkedRecordId(markedRecordId));
|
||||
const rowWindow = useVirtualRows(records.length, TABLE_ROW_HEIGHT, 12);
|
||||
const columnCount = sheet.analysis.headers.length + 1;
|
||||
// Berechnete Geometrie der Virtualisierung als benannte Werte statt eines
|
||||
|
|
@ -29,7 +32,7 @@ export function DataTable({ sheet, records, search }: Props) {
|
|||
const bottomSpacerStyle = { height: rowWindow.paddingBottom, padding: 0, border: 0 };
|
||||
|
||||
return (
|
||||
<div className="table-scroll" ref={rowWindow.containerRef}>
|
||||
<div className="table-scroll" ref={rowWindow.containerRef} data-group-scroll="work-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -41,9 +44,10 @@ export function DataTable({ sheet, records, search }: Props) {
|
|||
{rowWindow.paddingTop > 0 && <tr aria-hidden="true"><td colSpan={columnCount} style={topSpacerStyle} /></tr>}
|
||||
{records.slice(rowWindow.startIndex, rowWindow.endIndex).map(record => (
|
||||
<tr key={record.id} data-row-marked={markedRecordId === record.id ? 'true' : undefined}
|
||||
data-group-marked={related?.selection?.group.recordIds.includes(record.id) || undefined}
|
||||
onClick={() => setMarkedRecordId(record.id)}>
|
||||
<td className="row-index">{record.index + 1}</td>
|
||||
{record.fields.map(field => <td key={`${record.id}-${field.index}`}>{highlightText(field.value, search)}</td>)}
|
||||
{record.fields.map((field, index) => <td key={`${record.id}-${field.index}`}>{highlightText(field.value, search)}{index === record.fields.length - 1 && <RelatedFindingRing record={record} inline/>}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
{rowWindow.paddingBottom > 0 && <tr aria-hidden="true"><td colSpan={columnCount} style={bottomSpacerStyle} /></tr>}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ export type DonutMetricTone =
|
|||
interface DonutMetricCardProps {
|
||||
label: string;
|
||||
value: number | null;
|
||||
percentage: number;
|
||||
percentage: number | null;
|
||||
percentageBasisLabel?: string;
|
||||
tone: DonutMetricTone;
|
||||
pendingLabel?: string;
|
||||
valueSuffix?: string;
|
||||
|
|
@ -30,6 +31,7 @@ export function DonutMetricCard({
|
|||
label,
|
||||
value,
|
||||
percentage,
|
||||
percentageBasisLabel,
|
||||
tone,
|
||||
pendingLabel = 'No data',
|
||||
valueSuffix = '',
|
||||
|
|
@ -38,11 +40,11 @@ export function DonutMetricCard({
|
|||
active,
|
||||
onClick
|
||||
}: DonutMetricCardProps) {
|
||||
const boundedPercentage = donutPercentage(percentage);
|
||||
const percentageLabel = donutPercentageLabel(boundedPercentage);
|
||||
const boundedPercentage = donutPercentage(percentage ?? 0);
|
||||
const percentageLabel = percentage === null ? '—' : donutPercentageLabel(boundedPercentage);
|
||||
const classes = ['donut-metric-card', 'donut-metric-card--' + tone, className].filter(Boolean).join(' ');
|
||||
const ariaLabel = value === null ? label + ': ' + pendingLabel
|
||||
: label + ': ' + value.toLocaleString('en-US') + ', ' + percentageLabel;
|
||||
: label + ': ' + value.toLocaleString('en-US') + ', ' + percentageLabel + (percentageBasisLabel ? ', ' + percentageBasisLabel : '');
|
||||
const displayedValue = value === null ? '—' : value.toLocaleString('en-US') + valueSuffix;
|
||||
const content = <>
|
||||
<h3 className="donut-metric-card__title">{label}</h3>
|
||||
|
|
@ -59,6 +61,7 @@ export function DonutMetricCard({
|
|||
{value === null ? <small>{pendingLabel}</small> : showPercentage && <small>{percentageLabel}</small>}
|
||||
</div>
|
||||
</div>
|
||||
{percentageBasisLabel && <span className="donut-metric-card__basis">{percentageBasisLabel}</span>}
|
||||
</>;
|
||||
|
||||
return onClick
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
/** Display shares of the authoritative assessed population; other states add no weight. */
|
||||
export function assessedResultPercentages(passed: number, deviations: number): {
|
||||
passed: number | null;
|
||||
deviation: number | null;
|
||||
} {
|
||||
const assessed = passed + deviations;
|
||||
return assessed > 0
|
||||
? { passed: (passed / assessed) * 100, deviation: (deviations / assessed) * 100 }
|
||||
: { passed: null, deviation: null };
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { memo } from 'react';
|
||||
import { RelatedFindingRing, useRelatedFindings } from '../../features/related-findings/RelatedFindings';
|
||||
import type { FindingRecord } from '../../domain/finding';
|
||||
import { normalizeSeverity } from '../../profiles/purple-knight/presentation';
|
||||
import { outcomeForStatusValue, toolIdForRecord } from '../../importers/status-normalization';
|
||||
|
|
@ -22,6 +23,7 @@ interface Props {
|
|||
}
|
||||
|
||||
function FindingCardComponent({ record, title, bookmarked, selected, toolId, search, onOpen }: Props) {
|
||||
const groupMarked = useRelatedFindings()?.selection?.group.recordIds.includes(record.id);
|
||||
const severityField = record.fields.find(field => /^(severity|criticality|priority)$/i.test(field.label) && field.value);
|
||||
const severity = normalizeSeverity(severityField?.value);
|
||||
const statusField = findingCardStatus(record, toolId);
|
||||
|
|
@ -29,6 +31,7 @@ function FindingCardComponent({ record, title, bookmarked, selected, toolId, sea
|
|||
const outcome = outcomeForStatusValue(resolvedToolId, statusField?.value);
|
||||
const diagnostics = policyDiagnosticBadges(record, resolvedToolId);
|
||||
return (
|
||||
<div data-record-id={record.id} className={`record-card-with-group${groupMarked ? ' is-group-marked' : ''}`}>
|
||||
<button
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
className={`record-card severity-${severity}${bookmarked ? ' is-workspace' : ''}${selected ? ' active' : ''}`}
|
||||
|
|
@ -42,6 +45,8 @@ function FindingCardComponent({ record, title, bookmarked, selected, toolId, sea
|
|||
</span>}
|
||||
|
||||
</button>
|
||||
<RelatedFindingRing record={record}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export const FindingCard = memo(FindingCardComponent, (previous, next) =>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useId, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useFloatingWindowLayer } from '../use-floating-window-layer';
|
||||
|
||||
const MIN_WIDTH = 520;
|
||||
const MIN_HEIGHT = 280;
|
||||
|
|
@ -11,6 +12,9 @@ const RESIZE_DIRECTIONS: ResizeDirection[] = ['n', 's', 'e', 'w', 'ne', 'nw', 's
|
|||
|
||||
interface FloatingDataWindowProps {
|
||||
title: string;
|
||||
markedHeader?: boolean;
|
||||
minimizeAnchorSelector?: string;
|
||||
initialBounds?: { width: number; height: number; left?: number; right?: number; topOffset: number };
|
||||
children: ReactNode;
|
||||
onClose(): void;
|
||||
}
|
||||
|
|
@ -23,26 +27,40 @@ function WindowIcon({ maximized }: { maximized: boolean }) {
|
|||
</svg>;
|
||||
}
|
||||
|
||||
export function FloatingDataWindow({ title, children, onClose }: FloatingDataWindowProps) {
|
||||
export function FloatingDataWindow({ title, children, onClose, markedHeader = false, initialBounds, minimizeAnchorSelector }: FloatingDataWindowProps) {
|
||||
const { layer, activate } = useFloatingWindowLayer();
|
||||
const titleId = useId();
|
||||
const windowRef = useRef<HTMLElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
const dragRef = useRef<{ pointerId: number; startX: number; startY: number; left: number; top: number } | null>(null);
|
||||
const resizeRef = useRef<{ pointerId: number; direction: ResizeDirection; startX: number; startY: number; left: number; top: number; width: number; height: number } | null>(null);
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
const workspaceTop = () => document.querySelector<HTMLElement>('.topbar')?.getBoundingClientRect().bottom ?? FALLBACK_WORKSPACE_TOP;
|
||||
const [size, setSize] = useState(() => ({
|
||||
width: Math.min(1100, window.innerWidth - VIEWPORT_MARGIN * 2),
|
||||
height: Math.min(720, window.innerHeight - workspaceTop() - VIEWPORT_MARGIN * 2)
|
||||
width: Math.min(initialBounds?.width ?? 1100, window.innerWidth - (initialBounds?.right ?? initialBounds?.left ?? VIEWPORT_MARGIN) - VIEWPORT_MARGIN),
|
||||
height: Math.min(initialBounds?.height ?? 720, window.innerHeight - workspaceTop() - (initialBounds?.topOffset ?? VIEWPORT_MARGIN) - VIEWPORT_MARGIN)
|
||||
}));
|
||||
const [position, setPosition] = useState(() => ({
|
||||
left: Math.max(VIEWPORT_MARGIN, (window.innerWidth - size.width) / 2),
|
||||
top: Math.max(workspaceTop() + VIEWPORT_MARGIN, (window.innerHeight - size.height) / 2)
|
||||
left: initialBounds?.right !== undefined ? Math.max(VIEWPORT_MARGIN, window.innerWidth - size.width - initialBounds.right)
|
||||
: initialBounds?.left ?? Math.max(VIEWPORT_MARGIN, (window.innerWidth - size.width) / 2),
|
||||
top: initialBounds ? workspaceTop() + initialBounds.topOffset : Math.max(workspaceTop() + VIEWPORT_MARGIN, (window.innerHeight - size.height) / 2)
|
||||
}));
|
||||
const clampPosition = (left: number, top: number, width = size.width, height = size.height) => ({
|
||||
left: Math.max(VIEWPORT_MARGIN, Math.min(left, window.innerWidth - width - VIEWPORT_MARGIN)),
|
||||
top: Math.max(workspaceTop() + VIEWPORT_MARGIN, Math.min(top, window.innerHeight - height - VIEWPORT_MARGIN))
|
||||
});
|
||||
const toggleMaximized = () => {
|
||||
if (maximized && initialBounds?.right !== undefined) {
|
||||
setPosition(clampPosition(window.innerWidth - size.width - initialBounds.right, workspaceTop() + initialBounds.topOffset));
|
||||
}
|
||||
setMaximized(value => !value);
|
||||
};
|
||||
const restoreMinimized = () => {
|
||||
setPosition(clampPosition(window.innerWidth - size.width - (initialBounds?.right ?? VIEWPORT_MARGIN),
|
||||
workspaceTop() + (initialBounds?.topOffset ?? VIEWPORT_MARGIN)));
|
||||
setMinimized(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
closeRef.current?.focus();
|
||||
|
|
@ -70,7 +88,7 @@ export function FloatingDataWindow({ title, children, onClose }: FloatingDataWin
|
|||
}, [onClose, size.width, size.height]);
|
||||
|
||||
const startDrag = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (maximized || (event.target as HTMLElement).closest('button')) return;
|
||||
if (maximized || minimized || (event.target as HTMLElement).closest('button')) return;
|
||||
dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, ...position };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
|
|
@ -122,34 +140,47 @@ export function FloatingDataWindow({ title, children, onClose }: FloatingDataWin
|
|||
};
|
||||
|
||||
const top = workspaceTop() + VIEWPORT_MARGIN;
|
||||
const style = maximized
|
||||
const parkedWidth = Math.min(320, window.innerWidth - VIEWPORT_MARGIN * 2);
|
||||
const anchorBottom = minimizeAnchorSelector ? document.querySelector(minimizeAnchorSelector)?.getBoundingClientRect().bottom : undefined;
|
||||
const bounds = minimized
|
||||
? { left: Math.max(VIEWPORT_MARGIN, window.innerWidth - parkedWidth - (initialBounds?.right ?? VIEWPORT_MARGIN)),
|
||||
top: Math.min(window.innerHeight - 40 - VIEWPORT_MARGIN, (anchorBottom ?? workspaceTop()) + VIEWPORT_MARGIN), width: parkedWidth }
|
||||
: maximized
|
||||
? { left: VIEWPORT_MARGIN, top, width: window.innerWidth - VIEWPORT_MARGIN * 2, height: window.innerHeight - top - VIEWPORT_MARGIN }
|
||||
: { ...position, ...size };
|
||||
const style = { ...bounds, zIndex: layer };
|
||||
|
||||
return createPortal(<section
|
||||
ref={windowRef}
|
||||
className={`floating-data-window${maximized ? ' is-maximized' : ''}`}
|
||||
className={`floating-data-window${maximized ? ' is-maximized' : ''}${minimized ? ' is-minimized' : ''}`}
|
||||
style={style as CSSProperties}
|
||||
onPointerDownCapture={activate}
|
||||
onFocusCapture={activate}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<header className="floating-data-window-head"
|
||||
onDoubleClick={() => setMaximized(value => !value)}
|
||||
<header className={`floating-data-window-head${markedHeader ? ' is-marked' : ''}`}
|
||||
onDoubleClick={minimized ? restoreMinimized : toggleMaximized}
|
||||
onPointerDown={startDrag} onPointerMove={moveDrag} onPointerUp={stopDrag} onPointerCancel={stopDrag}>
|
||||
<h2 id={titleId}>{title}</h2>
|
||||
<h2 id={titleId}>{minimized ? <button type="button" className="floating-data-window-restore" onClick={restoreMinimized}
|
||||
aria-label={`Open ${title}`}>{title}</button> : title}</h2>
|
||||
<div className="floating-data-window-actions">
|
||||
<button type="button" onClick={() => setMaximized(value => !value)}
|
||||
{minimizeAnchorSelector && <button type="button" onClick={minimized ? restoreMinimized : () => { setMaximized(false); setMinimized(true); }}
|
||||
title={minimized ? 'Restore window' : 'Minimize window'} aria-label={`${minimized ? 'Restore' : 'Minimize'} ${title} window`}>
|
||||
{minimized ? <WindowIcon maximized={false}/> : <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 18h14"/></svg>}
|
||||
</button>}
|
||||
{!minimized && <button type="button" onClick={toggleMaximized}
|
||||
title={maximized ? 'Restore window' : 'Maximize window'} aria-label={`${maximized ? 'Restore' : 'Maximize'} ${title} window`}>
|
||||
<WindowIcon maximized={maximized} />
|
||||
</button>
|
||||
</button>}
|
||||
<button ref={closeRef} type="button" onClick={onClose} title="Close" aria-label={`Close ${title} window`}>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M6 6l12 12M18 6 6 18" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="floating-data-window-body client-command-center">{children}</div>
|
||||
{!maximized && RESIZE_DIRECTIONS.map(direction => <span key={direction}
|
||||
<div className="floating-data-window-body client-command-center" hidden={minimized}>{children}</div>
|
||||
{!maximized && !minimized && RESIZE_DIRECTIONS.map(direction => <span key={direction}
|
||||
className={`results-window-resize results-window-resize-${direction}`}
|
||||
onPointerDown={startResize(direction)} onPointerMove={moveResize} onPointerUp={stopResize} onPointerCancel={stopResize} />)}
|
||||
</section>, document.body);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
|
||||
import { useId, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
|
||||
import { useResultTableSelection } from './ResultTableSelection';
|
||||
|
||||
const MIN_COLUMN_WIDTH = 72;
|
||||
const MAX_COLUMN_WIDTH = 720;
|
||||
|
|
@ -15,6 +16,7 @@ interface Props {
|
|||
children?: ReactNode;
|
||||
enabled?: boolean;
|
||||
className?: string;
|
||||
sourceSheetId?: string;
|
||||
}
|
||||
|
||||
function clampColumnWidth(width: number): number {
|
||||
|
|
@ -52,7 +54,9 @@ function fittedColumnWidth(table: HTMLTableElement, columnIndex: number): number
|
|||
return clampColumnWidth(preferred);
|
||||
}
|
||||
|
||||
export function ResizableResultTable({ headers, children, enabled = false, className = 'rich-text-table' }: Props) {
|
||||
export function ResizableResultTable({ headers, children, enabled = false, className = 'rich-text-table', sourceSheetId }: Props) {
|
||||
const tableId = useId();
|
||||
const selection = useResultTableSelection();
|
||||
const columnCount = headers.length;
|
||||
const tableRef = useRef<HTMLTableElement>(null);
|
||||
const dragRef = useRef<ColumnDrag | null>(null);
|
||||
|
|
@ -103,8 +107,11 @@ export function ResizableResultTable({ headers, children, enabled = false, class
|
|||
};
|
||||
|
||||
return (
|
||||
<table ref={tableRef} className={`${className}${enabled ? ' is-column-resizable' : ''}${appliedWidths.length ? ' has-custom-column-widths' : ''}`} style={tableStyle}>
|
||||
{enabled && <caption className="result-column-instructions">Column widths can be changed by dragging a header separator or fitted by double-clicking it.</caption>}
|
||||
<table ref={tableRef} className={`${className}${enabled ? ' is-column-resizable' : ''}${appliedWidths.length ? ' has-custom-column-widths' : ''}`} data-result-table-id={selection ? tableId : undefined} data-result-sheet-id={sourceSheetId} style={tableStyle}>
|
||||
{(selection || enabled) && <caption className={selection ? 'result-table-selection' : 'result-column-instructions'}>
|
||||
{selection && <label><input type="checkbox" checked={selection.selected.has(tableId)} onChange={() => selection.toggle(tableId)} /> Select table for Excel export</label>}
|
||||
{enabled && <span className="result-column-instructions">Column widths can be changed by dragging a header separator or fitted by double-clicking it.</span>}
|
||||
</caption>}
|
||||
{appliedWidths.length > 0 && <colgroup>{appliedWidths.map((width, index) => <col key={index} style={columnWidthStyle(width)} />)}</colgroup>}
|
||||
<thead><tr>{headers.map((header, index) => <th key={index}>{header}{enabled && index < columnCount - 1 && <span
|
||||
className="result-column-resizer"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
|
||||
export interface ResultTableSelectionState {
|
||||
selected: ReadonlySet<string>;
|
||||
toggle(id: string): void;
|
||||
}
|
||||
const SelectionContext = createContext<ResultTableSelectionState | undefined>(undefined);
|
||||
|
||||
export function ResultTableSelectionProvider({ value, children }: { value: ResultTableSelectionState; children: ReactNode }) {
|
||||
return <SelectionContext.Provider value={value}>{children}</SelectionContext.Provider>;
|
||||
}
|
||||
|
||||
export function useResultTableSelection() { return useContext(SelectionContext); }
|
||||
|
|
@ -16,10 +16,11 @@ interface Props {
|
|||
onResults?: () => void;
|
||||
resizableTables?: boolean;
|
||||
label?: string;
|
||||
tableRowClassName?: (headers: string[], cells: string[]) => string | undefined;
|
||||
}
|
||||
|
||||
const formattedTags = /<\/?(?:p|br|b|strong|i|em|u|ul|ol|li|a)\b/i;
|
||||
const encodedTags = /<\/?(?:p|br|b|strong|i|em|u|ul|ol|li|a)\b/i;
|
||||
const formattedTags = /<\/?(?:p|br|b|strong|i|em|u|ul|ol|li|a|table|thead|tbody|tr|th|td)\b/i;
|
||||
const encodedTags = /<\/?(?:p|br|b|strong|i|em|u|ul|ol|li|a|table|thead|tbody|tr|th|td)\b/i;
|
||||
const discardedTags = new Set(['script', 'style', 'iframe', 'object', 'embed', 'svg', 'math']);
|
||||
|
||||
/** Preserve inline HTML in a Markdown table without executing imported markup. */
|
||||
|
|
@ -134,7 +135,7 @@ function renderTextWithTriggers(text: string, key: string, search?: string, onRe
|
|||
return <Fragment key={key}>{parts}</Fragment>;
|
||||
}
|
||||
|
||||
function renderNode(node: Node, key: string, insideAnchor = false, search?: string, onResults?: () => void): ReactNode {
|
||||
function renderNode(node: Node, key: string, insideAnchor = false, search?: string, onResults?: () => void, resizableTables = false, tableRowClassName?: Props['tableRowClassName']): ReactNode {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
if (insideAnchor) return highlightText(node.textContent ?? '', search);
|
||||
return <Fragment key={key}>{renderInline(parseInline(node.textContent ?? ''), key, search, onResults)}</Fragment>;
|
||||
|
|
@ -145,15 +146,32 @@ function renderNode(node: Node, key: string, insideAnchor = false, search?: stri
|
|||
const tag = element.tagName.toLowerCase();
|
||||
if (discardedTags.has(tag)) return null;
|
||||
|
||||
if (tag === 'table') {
|
||||
const table = element as HTMLTableElement;
|
||||
const rows = Array.from(table.rows);
|
||||
const headerRow = table.tHead?.rows[0] ?? (rows[0]?.querySelector('th') ? rows[0] : undefined);
|
||||
const headers = headerRow ? Array.from(headerRow.cells).map(cell => cell.textContent ?? '')
|
||||
: Array.from({ length: rows.reduce((count, row) => Math.max(count, row.cells.length), 0) }, (_, index) => `Column ${index + 1}`);
|
||||
return <div className="rich-text-table-wrap" key={key}>
|
||||
<ResizableResultTable enabled={resizableTables} headers={headers}>
|
||||
{rows.filter(row => row !== headerRow).map((row, index) => {
|
||||
const values = Array.from(row.cells).map(cell => cell.textContent ?? '');
|
||||
return <tr key={index} className={tableRowClassName?.(headers, values)}>{Array.from(row.cells).map((cell, column) =>
|
||||
<td key={column}>{Array.from(cell.childNodes).map((child, childIndex) => renderNode(child, `${key}-${index}-${column}-${childIndex}`, false, search, onResults, resizableTables, tableRowClassName))}</td>)}</tr>;
|
||||
})}
|
||||
</ResizableResultTable>
|
||||
</div>;
|
||||
}
|
||||
|
||||
if (tag === 'p' || tag === 'div') {
|
||||
const markdown = tableMarkdown(node);
|
||||
const blocks = markdown === null ? [] : parseMarkdownBlocks(markdown);
|
||||
if (blocks.some(block => block.kind === 'table')) {
|
||||
return <Fragment key={key}>{blocks.map((block, index) => renderBlock(block, `${key}-${index}`, search, onResults))}</Fragment>;
|
||||
return <Fragment key={key}>{blocks.map((block, index) => renderBlock(block, `${key}-${index}`, search, onResults, resizableTables, tableRowClassName))}</Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
const children = Array.from(element.childNodes).map((child, index) => renderNode(child, `${key}-${index}`, insideAnchor || tag === 'a', search, onResults));
|
||||
const children = Array.from(element.childNodes).map((child, index) => renderNode(child, `${key}-${index}`, insideAnchor || tag === 'a', search, onResults, resizableTables, tableRowClassName));
|
||||
switch (tag) {
|
||||
case 'p': return <p key={key}>{children}</p>;
|
||||
case 'br': return <br key={key} />;
|
||||
|
|
@ -204,7 +222,7 @@ function renderInline(tokens: InlineToken[], key: string, search?: string, onRes
|
|||
});
|
||||
}
|
||||
|
||||
function renderBlock(block: MarkdownBlock, key: string, search?: string, onResults?: () => void, resizableTables = false): ReactNode {
|
||||
function renderBlock(block: MarkdownBlock, key: string, search?: string, onResults?: () => void, resizableTables = false, tableRowClassName?: Props['tableRowClassName']): ReactNode {
|
||||
switch (block.kind) {
|
||||
case 'heading': {
|
||||
const Tag = (block.level <= 2 ? 'h5' : 'h6') as 'h5' | 'h6';
|
||||
|
|
@ -215,7 +233,7 @@ function renderBlock(block: MarkdownBlock, key: string, search?: string, onResul
|
|||
: <ul key={key}>{block.items.map((item, index) => <li key={`${key}-${index}`}>{renderInline(item, `${key}-${index}`, search, onResults)}</li>)}</ul>;
|
||||
case 'table': return <div className="rich-text-table-wrap" key={key}>
|
||||
<ResizableResultTable enabled={resizableTables} headers={block.table.headers.map((header, index) => renderInline(parseInline(header), `${key}-h-${index}`, search, onResults))}>
|
||||
{block.table.rows.map((row, rowIndex) => <tr key={rowIndex}>
|
||||
{block.table.rows.map((row, rowIndex) => <tr key={rowIndex} className={tableRowClassName?.(block.table.headers, row)}>
|
||||
{row.map((cell, cellIndex) => <td key={cellIndex}>{renderInline(parseInline(cell), `${key}-${rowIndex}-${cellIndex}`, search, onResults)}</td>)}
|
||||
</tr>)}
|
||||
</ResizableResultTable>
|
||||
|
|
@ -225,7 +243,7 @@ function renderBlock(block: MarkdownBlock, key: string, search?: string, onResul
|
|||
}
|
||||
}
|
||||
|
||||
export function RichTextValue({ value, search, onResults, resizableTables = false, label }: Props) {
|
||||
export function RichTextValue({ value, search, onResults, resizableTables = false, label, tableRowClassName }: Props) {
|
||||
if (isPathField(label) || isWholePath(value)) return <p><PathText value={value} search={search} label={label} /></p>;
|
||||
let source = value;
|
||||
if (encodedTags.test(source)) source = decodeEntities(source);
|
||||
|
|
@ -243,12 +261,12 @@ export function RichTextValue({ value, search, onResults, resizableTables = fals
|
|||
const markdown = tableMarkdown(documentNode.body);
|
||||
const blocks = markdown === null ? [] : parseMarkdownBlocks(markdown);
|
||||
if (blocks.some(block => block.kind === 'table')) {
|
||||
return <div className="rich-text-value">{blocks.map((block, index) => renderBlock(block, String(index), search, onResults, resizableTables))}</div>;
|
||||
return <div className="rich-text-value">{blocks.map((block, index) => renderBlock(block, String(index), search, onResults, resizableTables, tableRowClassName))}</div>;
|
||||
}
|
||||
return <div className="rich-text-value">{Array.from(documentNode.body.childNodes).map((node, index) => renderNode(node, String(index), false, search, onResults))}</div>;
|
||||
return <div className="rich-text-value">{Array.from(documentNode.body.childNodes).map((node, index) => renderNode(node, String(index), false, search, onResults, resizableTables, tableRowClassName))}</div>;
|
||||
}
|
||||
if (hasMarkdown(source)) {
|
||||
return <div className="rich-text-value">{parseMarkdownBlocks(source).map((block, index) => renderBlock(block, String(index), search, onResults, resizableTables))}</div>;
|
||||
return <div className="rich-text-value">{parseMarkdownBlocks(source).map((block, index) => renderBlock(block, String(index), search, onResults, resizableTables, tableRowClassName))}</div>;
|
||||
}
|
||||
return <p>{renderTextWithTriggers(source, 'plain', search, onResults)}</p>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hie
|
|||
import { cisCategoryRecordTitle } from '../../profiles/cis-cat/cis-benchmark-section-titles';
|
||||
import { policyFindingCategories } from '../../profiles/group-policy/categories';
|
||||
import { measureNavigationStep } from '../../app/navigation-diagnostics';
|
||||
import { RelatedFindingRing, useRelatedFindings } from '../../features/related-findings/RelatedFindings';
|
||||
|
||||
export interface TileBadge {
|
||||
label: string;
|
||||
|
|
@ -87,6 +88,7 @@ interface Props {
|
|||
}
|
||||
|
||||
function TileCardComponent({ record, title, bookmarked, resultColor, toolId, search, badgeLimit, onOpen, onBookmark, onResults }: Props) {
|
||||
const groupMarked = useRelatedFindings()?.selection?.group.recordIds.includes(record.id);
|
||||
const severityField = record.fields.find(field => /^(severity|criticality|priority)$/i.test(field.label) && field.value);
|
||||
const severity = normalizeSeverity(severityField?.value);
|
||||
const recordToolId = toolIdForRecord(record, toolId);
|
||||
|
|
@ -103,6 +105,7 @@ function TileCardComponent({ record, title, bookmarked, resultColor, toolId, sea
|
|||
<article
|
||||
className={`data-tile severity-${severity}${bookmarked ? ' is-bookmarked' : ''}`}
|
||||
data-tool={recordToolId}
|
||||
data-group-marked={groupMarked || undefined}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={onOpen}
|
||||
|
|
@ -121,6 +124,7 @@ function TileCardComponent({ record, title, bookmarked, resultColor, toolId, sea
|
|||
</div>
|
||||
</div>
|
||||
<div className="data-tile-actions">
|
||||
<RelatedFindingRing record={record} inline/>
|
||||
<button className="reader-bookmark-btn tile-bookmark-btn" type="button" aria-pressed={bookmarked} title={bookmarked ? 'Remove bookmark' : 'Mark as bookmark'} onClick={event => {
|
||||
event.stopPropagation();
|
||||
onBookmark();
|
||||
|
|
@ -143,4 +147,3 @@ export const TileCard = memo(TileCardComponent, (previous, next) =>
|
|||
previous.badgeLimit === next.badgeLimit &&
|
||||
Boolean(previous.onResults) === Boolean(next.onResults)
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
// One activation order for floating data windows and Results; Filters has its own uppermost layer.
|
||||
let nextLayer = 3000;
|
||||
export function useFloatingWindowLayer(activation?: number) {
|
||||
const [layer, setLayer] = useState(() => ++nextLayer);
|
||||
const previous = useRef(activation);
|
||||
const activate = () => setLayer(++nextLayer);
|
||||
useEffect(() => {
|
||||
if (previous.current === activation) return;
|
||||
previous.current = activation;
|
||||
activate();
|
||||
}, [activation]);
|
||||
return { layer, activate };
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import * as XLSX from 'xlsx';
|
||||
|
||||
// SheetJS exposes CFB on the namespace in ESM and on the default object in Node CJS.
|
||||
const CFB = Reflect.get(XLSX, 'CFB') ?? Reflect.get(XLSX, 'default').CFB;
|
||||
|
||||
/** Add the approved title style to our generated XLSX; never edits imported files. */
|
||||
export function formatResultsExcelTitles(bytes: ArrayBuffer, titleSheetIndexes: number[]): ArrayBuffer {
|
||||
if (!titleSheetIndexes.length) return bytes;
|
||||
const archive = CFB.read(new Uint8Array(bytes), { type: 'array' });
|
||||
const decoder = new TextDecoder(); const encoder = new TextEncoder();
|
||||
const readPart = (path: string): string => {
|
||||
const entry = CFB.find(archive, `/${path}`);
|
||||
if (!entry?.content) throw new Error(`Missing generated Excel part: ${path}`);
|
||||
return decoder.decode(new Uint8Array(entry.content));
|
||||
};
|
||||
const writePart = (path: string, xml: string) => CFB.utils.cfb_add(archive, `/${path}`, encoder.encode(xml));
|
||||
let styles = readPart('xl/styles.xml');
|
||||
function appendStyle(tag: string, child: string): number {
|
||||
const opening = new RegExp(`<${tag} count="(\\d+)">`);
|
||||
const match = opening.exec(styles);
|
||||
if (!match || !styles.includes(`</${tag}>`)) throw new Error(`Missing generated Excel styles: ${tag}`);
|
||||
const index = Number(match[1]);
|
||||
styles = styles.replace(opening, `<${tag} count="${index + 1}">`).replace(`</${tag}>`, `${child}</${tag}>`);
|
||||
return index;
|
||||
}
|
||||
const fontId = appendStyle('fonts', '<font><b/><sz val="16"/><color rgb="FFFFFFFF"/><name val="Inter"/></font>');
|
||||
const fillId = appendStyle('fills', '<fill><patternFill patternType="solid"><fgColor rgb="FF000000"/><bgColor indexed="64"/></patternFill></fill>');
|
||||
const styleId = appendStyle('cellXfs', `<xf numFmtId="0" fontId="${fontId}" fillId="${fillId}" borderId="0" xfId="0" applyFont="1" applyFill="1" applyAlignment="1"><alignment vertical="center" wrapText="1"/></xf>`);
|
||||
writePart('xl/styles.xml', styles);
|
||||
for (const index of titleSheetIndexes) {
|
||||
const path = `xl/worksheets/sheet${index}.xml`;
|
||||
const xml = readPart(path);
|
||||
let found = false;
|
||||
const styled = xml.replace(/<row\b([^>]*\br="1"[^>]*)>([\s\S]*?)<\/row>/, (_, attributes: string, cells: string) => {
|
||||
found = true;
|
||||
return `<row${attributes}>${cells.replace(/<c\b([^>]*)>/g, (_cell, cellAttributes: string) => `<c${cellAttributes.replace(/\s+s="[^"]*"/, '')} s="${styleId}">`)}</row>`;
|
||||
});
|
||||
if (!found) throw new Error('Missing generated Excel title row.');
|
||||
writePart(path, styled);
|
||||
}
|
||||
const output = CFB.write(archive, { type: 'array', fileType: 'zip', compression: true });
|
||||
return new Uint8Array(output).buffer;
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { utils, write } from 'xlsx';
|
||||
import { formatResultsExcelTitles } from './results-excel-format';
|
||||
|
||||
export const RESULTS_EXCEL_MEDIA_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
export interface ResultsExcelTable { id: string; title: string; rows: string[][] }
|
||||
export interface ResultsExcelContent { title: string; text: string; tables: ResultsExcelTable[]; sourceSections: { title: string; value: string }[];
|
||||
findingContext?: { section: string; label: string; value: string }[] }
|
||||
const CELL_LIMIT = 32767;
|
||||
const SHEET_ROWS = 1048576;
|
||||
const SHEET_COLUMNS = 16384;
|
||||
|
||||
/** Export the current Results view only. No Result DB query or additional disclosure. */
|
||||
export function buildResultsWindowExcel(content: ResultsExcelContent, selected?: ReadonlySet<string>): ArrayBuffer {
|
||||
const workbook = utils.book_new();
|
||||
const names = new Set<string>();
|
||||
const titleSheetIndexes: number[] = [];
|
||||
const longText: string[][] = [['Sheet', 'Row', 'Column', 'Part', 'Text']];
|
||||
function uniqueName(label: string) {
|
||||
const base = label.replace(/[\\/?:*\[\]\x00-\x1f]/g, ' ').replace(/^'+|'+$/g, '').trim() || 'Table';
|
||||
let name = base.slice(0, 31); let suffix = 1;
|
||||
while (names.has(name.toLowerCase())) { const tail = ` (${++suffix})`; name = base.slice(0, 31 - tail.length) + tail; }
|
||||
names.add(name.toLowerCase()); return name;
|
||||
}
|
||||
function literal(value: string, sheet: string, row: number, column: number) {
|
||||
if (value.length <= CELL_LIMIT) return value;
|
||||
for (let offset = 0, part = 1; offset < value.length; part++) {
|
||||
let end = Math.min(value.length, offset + CELL_LIMIT);
|
||||
if (end < value.length && /[\uD800-\uDBFF]/.test(value[end - 1])) end--;
|
||||
longText.push([sheet, String(row), String(column), String(part), value.slice(offset, end)]);
|
||||
offset = end;
|
||||
}
|
||||
return `Complete value in Long text: ${sheet}, row ${row}, column ${column}`;
|
||||
}
|
||||
function append(title: string, rows: string[][], overflow = true, displayTitle = content.title, resultText?: string) {
|
||||
// Repeat the header in each part; Excel's physical limits must never silently discard data.
|
||||
const header = rows[0] ?? []; const data = rows.slice(1);
|
||||
if (rows.some(row => row.length > SHEET_COLUMNS)) throw new Error('Too many columns for Excel; no partial export was created.');
|
||||
const headingRows = 2;
|
||||
const footer = resultText ? [Array<string>(rows[0]?.length || 1).fill(''), ['Result text'], [resultText]] : [];
|
||||
const rowsPerPart = SHEET_ROWS - headingRows - footer.length;
|
||||
const parts = Math.max(1, Math.ceil(data.length / rowsPerPart));
|
||||
for (let part = 0; part < parts; part++) {
|
||||
const name = uniqueName(part > 0 ? `${title} part ${part + 1}` : title);
|
||||
const columns = rows.reduce((maximum, row) => Math.max(maximum, row.length), 1);
|
||||
const band = [[displayTitle, ...Array<string>(columns - 1).fill('')]];
|
||||
const partData = data.slice(part * rowsPerPart, (part + 1) * rowsPerPart);
|
||||
const values = [...band, header, ...partData, ...(part === parts - 1 ? footer : [])];
|
||||
const sheet = utils.aoa_to_sheet(values.map((row, rowIndex) => (row.length ? row : ['']).map((value, column) => overflow ? literal(value, name, rowIndex + 1, column + 1) : value)));
|
||||
sheet['!cols'] = header.map(() => ({ wch: 35 }));
|
||||
if (displayTitle !== undefined) {
|
||||
const wrappedLines = displayTitle.split('\n').reduce((lines, text) => lines + Math.max(1, Math.ceil(text.length / (columns * 24))), 0);
|
||||
sheet['!rows'] = [{ hpt: Math.min(409, Math.max(48, wrappedLines * 20 + 8)) }];
|
||||
if (columns > 1) sheet['!merges'] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: columns - 1 } }];
|
||||
titleSheetIndexes.push(workbook.SheetNames.length + 1);
|
||||
}
|
||||
if (sheet['!ref']) {
|
||||
const range = utils.decode_range(sheet['!ref']); range.s.r = headingRows - 1; range.e.r = partData.length + 1;
|
||||
sheet['!autofilter'] = { ref: utils.encode_range(range) };
|
||||
}
|
||||
utils.book_append_sheet(workbook, sheet, name);
|
||||
}
|
||||
}
|
||||
const tables = content.tables.filter(table => !selected || selected.has(table.id));
|
||||
if (selected && !tables.length) throw new Error('Select at least one table.');
|
||||
for (const [index, table] of tables.entries()) append(index === 0 ? 'Results' : `Results ${String(index + 1).padStart(2, '0')}`, table.rows, true, `${content.title}\n${table.title}`, !selected && index === 0 ? content.text : undefined);
|
||||
if (!selected) {
|
||||
if (!tables.length) append('Results', [['Content'], [content.text]]);
|
||||
if (content.sourceSections.length) append('Source text', [['Section', 'Original value'], ...content.sourceSections.map(section => [section.title, section.value])]);
|
||||
}
|
||||
if (content.findingContext?.length) append('Finding context', [['Section', 'Field', 'Value'],
|
||||
...content.findingContext.map(row => [row.section, row.label, row.value])], true, content.title);
|
||||
append('Export info', [['Property', 'Value'], ['Finding', content.title], ['Scope', selected ? 'Selected tables' : 'Entire Results view'], ['Tables', String(tables.length)],
|
||||
['Long values', 'Values exceeding one Excel cell are preserved completely in Long text, in part order.'],
|
||||
...tables.map((table, index) => [`Table ${index + 1}`, `${table.title}: ${Math.max(0, table.rows.length - 1)} data rows`])]);
|
||||
if (longText.length > 1) append('Long text', longText, false, content.title.length <= CELL_LIMIT ? content.title : 'Complete finding title in Export info / Long text');
|
||||
// Literal string cells retain leading zeros and formula-looking evidence without execution.
|
||||
return formatResultsExcelTitles(write(workbook, { type: 'array', bookType: 'xlsx', compression: true }) as ArrayBuffer, titleSheetIndexes);
|
||||
}
|
||||
|
|
@ -8,18 +8,28 @@ import { browserDownloadAdapter } from '../../platform/browser/browser-download-
|
|||
import { useFilteredRecords } from '../search-filter/use-filtered-records';
|
||||
import { selectWorkFacets } from '../search-filter/work-facets';
|
||||
import { useContextClusterIndex } from '../context-layer/use-context-cluster-index';
|
||||
import { useRelatedFindings } from '../related-findings/RelatedFindings';
|
||||
import { useWorkPackages } from '../../remediation/use-work-packages';
|
||||
import { WorkPackagesWindow } from '../../remediation/WorkPackagesWindow';
|
||||
import { UNASSIGNED_WORK_PACKAGE, WORK_PACKAGE_FACET_ID } from '../../remediation/remediation-engine';
|
||||
import { numberedClusterLabel, publishedClusterColour } from '../sidebar/context-cluster-display';
|
||||
|
||||
export function ActionlistControls() {
|
||||
const app = useApp();
|
||||
const records = useFilteredRecords();
|
||||
const contextClusters = useContextClusterIndex();
|
||||
const remediation = useWorkPackages();
|
||||
const related = useRelatedFindings();
|
||||
const workPackages = remediation.status === 'ready' ? remediation.index : undefined;
|
||||
const packageOverview = remediation.status === 'ready' ? remediation.overview : undefined;
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [packagesOpen, setPackagesOpen] = useState(false);
|
||||
const exportView = async (format: 'csv' | 'md' | 'html') => {
|
||||
if (!app.dataset || !app.activeSheet || !records.length) return;
|
||||
const activity = beginActivity('Core Engine', `Preparing Work ${format.toUpperCase()} export`);
|
||||
try {
|
||||
await showActivityFrame();
|
||||
const facets = selectWorkFacets(app.activeSheet, { ...app, contextClusters });
|
||||
const facets = selectWorkFacets(app.activeSheet, { ...app, contextClusters, workPackages });
|
||||
const statusLabel = app.activeProfile.presentation.statusFilters.find(filter => filter.value === app.statusFilter)?.label ?? app.statusFilter;
|
||||
const filters = Object.entries(app.facetFilters).map(([id, filter]) => {
|
||||
const facet = facets.find(item => item.id === id);
|
||||
|
|
@ -41,8 +51,21 @@ export function ActionlistControls() {
|
|||
} catch { activity.fail(); setFailed(true); } finally { activity.complete(); }
|
||||
};
|
||||
return <div className="header-export-row" aria-label="Export current view including Results">
|
||||
{/* Remediation Engine extension point: packages appear only while the engine is on and its result is ready. */}
|
||||
{workPackages && <button className="btn export-btn work-packages-button" type="button" data-work-packages-open
|
||||
aria-pressed={Boolean(packageOverview && packagesOpen)}
|
||||
title={`Show all work packages and Unassigned for this assessment`} onClick={() => setPackagesOpen(open => !open)}>Work Packages</button>}
|
||||
{(['csv', 'md', 'html'] as const).map(format => <button key={format} className="btn export-btn" type="button" disabled={!records.length}
|
||||
title={`Export current view including Results (${records.length} records)`} onClick={() => exportView(format)}>{format.toUpperCase()}</button>)}
|
||||
{packageOverview && packagesOpen && <WorkPackagesWindow overview={packageOverview}
|
||||
clusterOrder={contextClusters?.publication.clusters.map(cluster => cluster.id)}
|
||||
clusterLabels={Object.fromEntries(workPackages!.packages.map(p => [p.clusterId, numberedClusterLabel(p.clusterId, p.clusterLabel, contextClusters?.publication.clusters)]))}
|
||||
clusterSeverities={Object.fromEntries(workPackages!.packages.map(p => [p.clusterId,
|
||||
publishedClusterColour(contextClusters?.publication.clusters.find(cluster => cluster.id === p.clusterId))]))}
|
||||
onSelectRelated={() => related?.showAllInWork()}
|
||||
onClose={() => setPackagesOpen(false)}
|
||||
onSelectUnassigned={() => app.setFacetFilter(WORK_PACKAGE_FACET_ID, { values: [UNASSIGNED_WORK_PACKAGE] })}
|
||||
onSelect={workPackage => app.setFacetFilter(WORK_PACKAGE_FACET_ID, { values: [workPackage.id] })}/>}
|
||||
{failed && <Dialog title="Export unavailable" onClose={() => setFailed(false)}><p>The current view could not be exported completely. No partial file was downloaded.</p></Dialog>}
|
||||
</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,14 +30,17 @@ export function ContextHeatmap({ context, onOpenCluster }: { context: Assessment
|
|||
<span className="context-heatmap__total" aria-label="Unique findings represented by the heatmap">Findings: {findingCount.toLocaleString('en-US')}</span>
|
||||
</header>
|
||||
<div className="context-heatmap__grid" aria-label="Context clusters">
|
||||
{cells.map(({ cluster: item, reference, hasEvidence, scannerDeviations, gpoDeviations }) => <button
|
||||
{cells.map(({ cluster: item, reference, hasEvidence, scannerDeviations, gpoDeviations }, index) => <button
|
||||
key={item.clusterId} type="button" className="context-heatmap__cell"
|
||||
data-cluster={item.clusterId} data-severity={hasEvidence ? reference.severity : 'neutral'}
|
||||
aria-label={`Open ${item.label.en} findings in Work (${scannerDeviations + gpoDeviations})`}
|
||||
onClick={() => onOpenCluster(item.clusterId)}>
|
||||
<span className="context-heatmap__category">Base Severity · {contextSeverityLabels[reference.severity]}</span>
|
||||
<span className="context-heatmap__top">
|
||||
<span className="context-heatmap__number">{String(index + 1).padStart(2, '0')}</span>
|
||||
<span className="context-heatmap__category">{contextSeverityLabels[reference.severity]}</span>
|
||||
</span>
|
||||
<strong>{item.label.en}</strong>
|
||||
<span className="context-heatmap__amount">{(scannerDeviations + gpoDeviations).toLocaleString('en-US')} {scannerDeviations + gpoDeviations === 1 ? 'finding' : 'findings'}</span>
|
||||
<span className="context-heatmap__amount"><span className="context-heatmap__count">{(scannerDeviations + gpoDeviations).toLocaleString('en-US')}</span>{' '}<span>{scannerDeviations + gpoDeviations === 1 ? 'finding' : 'findings'}</span></span>
|
||||
</button>)}
|
||||
</div>
|
||||
</section>;
|
||||
|
|
|
|||
|
|
@ -7,19 +7,24 @@
|
|||
.context-heatmap [data-severity="high"] { --context-heat: var(--sev-high); }
|
||||
.context-heatmap [data-severity="medium"] { --context-heat: var(--sev-medium); }
|
||||
.context-heatmap__grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--tile-compact-width)), var(--tile-compact-width)));
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--tile-environment-min-width)), var(--tile-environment-min-width)));
|
||||
gap: var(--space-2); margin-top: var(--space-3); grid-auto-rows: 1fr;
|
||||
}
|
||||
.context-heatmap__cell {
|
||||
display: flex; flex-direction: column; align-items: start; gap: var(--space-2);
|
||||
padding: var(--space-3); min-width: 0; min-height: calc(var(--tile-h) * .75); height: auto;
|
||||
border: var(--bw-emphasis) solid transparent; border-radius: var(--radius-sm);
|
||||
background: var(--context-heat, var(--surface-panel-raised)); color: var(--sev-ink);
|
||||
display: flex; flex-direction: column; align-items: start; gap: var(--space-3);
|
||||
padding: var(--tile-inset); min-width: 0; min-height: var(--tile-h); height: auto;
|
||||
border: var(--bw-emphasis) solid transparent; border-radius: var(--tile-radius);
|
||||
background: var(--context-heat, var(--tile-surface)); color: var(--sev-ink);
|
||||
font-family: inherit; text-align: left; cursor: pointer; overflow-wrap: anywhere;
|
||||
|
||||
}
|
||||
.context-heatmap__cell[data-severity="neutral"], .context-heatmap__cell[data-severity="undetermined"] { color: var(--text-primary); }
|
||||
.context-heatmap__cell:hover { border-color: currentColor; }
|
||||
.context-heatmap__cell:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--bw-emphasis); }
|
||||
.context-heatmap__cell:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(var(--bw-emphasis) * -2); }
|
||||
.context-heatmap__cell strong { font-size: var(--type-body-size); font-weight: var(--fw-semibold); line-height: var(--lh-ui); }
|
||||
.context-heatmap__category { font-size: var(--type-table-body-size); font-weight: var(--fw-regular); }
|
||||
.context-heatmap__amount { margin-top: auto; font-size: var(--type-body-size); font-weight: var(--fw-semibold); }
|
||||
.context-heatmap__top { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); width: 100%; }
|
||||
.context-heatmap__number { color: inherit; font-size: var(--type-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
|
||||
.context-heatmap__category { padding: var(--space-1) var(--space-2); border-radius: var(--radius-xs); background: var(--context-heat, var(--surface-input)); color: var(--sev-ink); font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); line-height: var(--lh-ui); }
|
||||
.context-heatmap__cell[data-severity="neutral"] .context-heatmap__category, .context-heatmap__cell[data-severity="undetermined"] .context-heatmap__category { color: var(--text-secondary); }
|
||||
.context-heatmap__amount { display: flex; align-items: baseline; gap: var(--space-2); margin-top: auto; color: inherit; font-size: var(--type-table-body-size); font-weight: var(--fw-regular); }
|
||||
.context-heatmap__count { color: inherit; font-size: var(--type-h2-size); font-weight: var(--fw-semibold); line-height: var(--lh-ui); font-variant-numeric: tabular-nums; }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useApp } from '../../app/AppProvider';
|
||||
import { useOptionalApp } from '../../app/AppProvider';
|
||||
import type { AssessmentContextResponse } from '../../api/assessor-api-contract';
|
||||
import { contextClusterIndex, type ContextClusterIndex } from './context-cluster-facet';
|
||||
import { useAssessmentContext } from './use-assessment-context';
|
||||
|
|
@ -8,9 +8,9 @@ const lastReadyByScope = new Map<string, AssessmentContextResponse>();
|
|||
|
||||
/** The context facet index for Work, or undefined while nothing is loaded yet. */
|
||||
export function useContextClusterIndex(): ContextClusterIndex | undefined {
|
||||
const app = useApp();
|
||||
const state = useAssessmentContext(true, app.targetProjectId, app.activeAssessmentIds, app.activeScanRunIds, app.dataset);
|
||||
const scope = JSON.stringify([app.targetProjectId, [...(app.activeAssessmentIds ?? [])].sort(), [...(app.activeScanRunIds ?? [])].sort()]);
|
||||
const app = useOptionalApp();
|
||||
const state = useAssessmentContext(Boolean(app), app?.targetProjectId, app?.activeAssessmentIds, app?.activeScanRunIds, app?.dataset);
|
||||
const scope = JSON.stringify([app?.targetProjectId, [...(app?.activeAssessmentIds ?? [])].sort(), [...(app?.activeScanRunIds ?? [])].sort()]);
|
||||
if (state.status === 'ready') {
|
||||
lastReadyByScope.set(scope, state.context);
|
||||
while (lastReadyByScope.size > 4) lastReadyByScope.delete(lastReadyByScope.keys().next().value!);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import { useId, useState, type ReactNode, type KeyboardEvent } from 'react';
|
||||
|
||||
export interface ClientInfoTab {
|
||||
id: string;
|
||||
label: string;
|
||||
content?: ReactNode;
|
||||
}
|
||||
|
||||
/** 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 active = tabs.some(tab => tab.id === selected) ? selected : tabs[0]?.id;
|
||||
const navigate = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
let next: number;
|
||||
if (event.key === 'ArrowRight') next = (index + 1) % tabs.length;
|
||||
else if (event.key === 'ArrowLeft') next = (index + tabs.length - 1) % tabs.length;
|
||||
else if (event.key === 'Home') next = 0;
|
||||
else if (event.key === 'End') next = tabs.length - 1;
|
||||
else return;
|
||||
event.preventDefault();
|
||||
setSelected(tabs[next].id);
|
||||
document.getElementById(`${prefix}-tab-${tabs[next].id}`)?.focus();
|
||||
};
|
||||
return <div className="client-info-tabs">
|
||||
<div className="client-info-tablist" role="tablist" aria-label="Client Info">
|
||||
{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)}
|
||||
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>)}
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import { useId, useMemo, useState, type CSSProperties } from 'react';
|
||||
import type { ImportedDataset } from '../../domain/dataset';
|
||||
import type { FindingRecord } from '../../domain/finding';
|
||||
import { TriangleIcon } from '../../components/TriangleIcon';
|
||||
import { RichTextValue } from '../../components/RichTextValue/RichTextValue';
|
||||
import { toolIdForRecord } from '../../importers/status-normalization';
|
||||
import { normalizeSeverity } from '../../profiles/purple-knight/presentation';
|
||||
import { selectFindingFields } from '../finding-reader/FindingReader';
|
||||
import { findingFirstRead } from '../finding-reader/first-read';
|
||||
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';
|
||||
|
||||
export interface SecurityDomainCount { id: string; label: string; count: number }
|
||||
export interface SecurityFindingGroup {
|
||||
id: string; number?: string; label: string; severity: string;
|
||||
situation?: string; lever?: string; readingName?: string;
|
||||
records: FindingRecord[]; domains: SecurityDomainCount[];
|
||||
}
|
||||
export interface ClientSecurityPanelProps {
|
||||
dataset: ImportedDataset; sheetId: string; toolId?: string;
|
||||
groups: SecurityFindingGroup[]; domains: SecurityDomainCount[]; total: number;
|
||||
contextStatus: string;
|
||||
}
|
||||
|
||||
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 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'>) {
|
||||
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 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">
|
||||
{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}`}
|
||||
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}>
|
||||
<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>
|
||||
</g>)}
|
||||
{groups.map((group, index) => <g key={group.id}>
|
||||
<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>
|
||||
</g>)}
|
||||
</svg></div>
|
||||
</details>;
|
||||
}
|
||||
|
||||
function FindingDetail({ record, dataset, sheetId, toolId }: { record: FindingRecord } & Pick<ClientSecurityPanelProps, 'dataset' | 'sheetId' | 'toolId'>) {
|
||||
const resolvedTool = toolIdForRecord(record, toolId ?? dataset.profileId);
|
||||
const policy = resolvedTool === 'group-policy' ? policyProblemSummary(record) : undefined;
|
||||
if (policy) return <div className="client-security-finding-detail"><PolicyProblemSummary summary={policy} outcome="fail" search=""/></div>;
|
||||
const first = findingFirstRead(record, resolvedTool, recordResultSheets(record, dataset.sheets, sheetId, resolvedTool));
|
||||
const fields = selectFindingFields(record, record.title, false, resolvedTool);
|
||||
const sourceFields = [...fields.summary, ...fields.remainingFields];
|
||||
const description = record.fields.find(field => /^description$/i.test(field.label.trim()) && field.value.trim());
|
||||
const rows = [...(first?.rows ?? [])];
|
||||
if (description) {
|
||||
const affectedIndex = rows.findIndex(row => row.label === 'Affected');
|
||||
const resultIndex = rows.findIndex(row => row.label === 'Result');
|
||||
rows.splice(affectedIndex >= 0 ? affectedIndex + 1 : resultIndex + 1, 0, { label: 'Description', value: description.value });
|
||||
}
|
||||
const explained = new Set(rows.map(row => row.value.trim()));
|
||||
const explanation = sourceFields.filter(field => /^(technical explanation|impact|result message)$/i.test(field.label) && !explained.has(field.value.trim()));
|
||||
const guidance = sourceFields.filter(field => /^(?:remediation(?: guidance| recommendation)?|advised solution|recommendation|solution|fix(?: text)?)$/i.test(field.label));
|
||||
return <div className="client-security-finding-detail">
|
||||
<div><h4>What it means</h4>
|
||||
{rows.map((row, index) => <div className="client-security-source" key={`${row.label}:${index}`}><span>{row.label}</span><RichTextValue value={row.value}/></div>)}
|
||||
{explanation.map(field => <div className="client-security-source" key={`${field.index}:${field.label}`}><span>{field.label}</span><RichTextValue value={field.value}/></div>)}
|
||||
{!rows.length && !explanation.length && <p className="muted">No explanation is available in this imported finding.</p>}
|
||||
</div>
|
||||
<div><h4>How to remediate</h4>
|
||||
{first?.nextStep ? <div className="client-security-source"><span>{first.nextStep.label}</span><RichTextValue value={first.nextStep.lead}/>
|
||||
{first.nextStep.command && <pre>{first.nextStep.command}</pre>}
|
||||
{first.nextStep.rest && <details><summary>Full guidance</summary><RichTextValue value={first.nextStep.rest}/></details>}
|
||||
</div> : guidance.map(field => <div className="client-security-source" key={`${field.index}:${field.label}`}><span>{field.label}</span><RichTextValue value={field.value}/></div>)}
|
||||
{!first?.nextStep && !guidance.length && <p className="muted">No remediation guidance is available in this imported finding.</p>}
|
||||
{first?.link && <a href={first.link.href} target="_blank" rel="noreferrer">{first.link.label}</a>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function FindingRow(props: { record: FindingRecord } & Pick<ClientSecurityPanelProps, 'dataset' | 'sheetId' | 'toolId'>) {
|
||||
const [open, setOpen] = useState(false); const id = useId();
|
||||
const severity = severityOf(props.record);
|
||||
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)}/>
|
||||
<strong>{props.record.title}</strong>
|
||||
</button>
|
||||
<div id={id} hidden={!open}>{open && <FindingDetail {...props}/>}</div>
|
||||
</li>;
|
||||
}
|
||||
|
||||
function FindingGroup({ group, ...props }: { group: SecurityFindingGroup } & Pick<ClientSecurityPanelProps, 'dataset' | 'sheetId' | 'toolId' | 'total'>) {
|
||||
const [open, setOpen] = useState(false); const id = useId();
|
||||
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)}>
|
||||
<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 && <>
|
||||
{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>
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function ClientSecurityPanel(props: ClientSecurityPanelProps) {
|
||||
const { groups, total, contextStatus } = props;
|
||||
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">
|
||||
{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>}
|
||||
{!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>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -1,27 +1,17 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import { type ClientInformationArea } from '../../domain/client-information';
|
||||
import { cloudInformationFacts, cloudInformationOverview } from '../../domain/cloud-information';
|
||||
import { CLOUD_INFORMATION_AREAS, cloudInformationTileFacts } from './client-info-facts';
|
||||
import type { EnvironmentSnapshot, EnvironmentSnapshotQuery } from '../../domain/environment-context';
|
||||
import { ClientInformationBrowser } from './ClientInformationBrowser';
|
||||
import { EnvironmentResultsButton } from './EnvironmentResultsButton';
|
||||
import { EnvironmentEvidenceTables } from './EnvironmentEvidenceTables';
|
||||
|
||||
const areas: Array<{ id: ClientInformationArea; label: string }> = [
|
||||
{ id: 'cloud-access', label: 'Authentication & CA' },
|
||||
{ id: 'cloud-roles', label: 'Role assignments' },
|
||||
{ id: 'cloud-apps', label: 'App consent' },
|
||||
{ id: 'cloud-sharing', label: 'External access' }
|
||||
];
|
||||
const areas = CLOUD_INFORMATION_AREAS;
|
||||
|
||||
/** Concrete reported facts only; never derives configuration from test outcomes. */
|
||||
export function CloudInformationTile({ snapshot, scope, icon }: {
|
||||
snapshot: EnvironmentSnapshot | null | undefined; scope?: EnvironmentSnapshotQuery; icon?: ReactNode;
|
||||
}) {
|
||||
const cloudFacts = cloudInformationFacts(snapshot);
|
||||
const facts = areas.map(area => {
|
||||
const values = cloudInformationOverview(cloudFacts, area.id);
|
||||
return { ...area, values: values.length ? values : ['Not reported'] };
|
||||
});
|
||||
const facts = cloudInformationTileFacts(snapshot);
|
||||
return <section className="client-env-group" aria-label="Cloud Information">
|
||||
<header className="client-env-group-header">
|
||||
<h3 className="client-env-group-title">{icon}<span>Cloud Information</span></h3>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { readEnginePreferences, useEnginePreferences } from '../../app/engine-preferences';
|
||||
import { ClientInfoTabs } from './ClientInfoTabs';
|
||||
import { ClientSecurityPanel, type SecurityFindingGroup } from './ClientSecurityPanel';
|
||||
import { managementCategoryTexts } from '../../reporting/management-report-texts';
|
||||
import { ClientContextHeatmap } from '../context-layer/ContextHeatmap';
|
||||
import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet';
|
||||
import { useAssessmentContext } from '../context-layer/use-assessment-context';
|
||||
|
|
@ -16,10 +19,17 @@ import { CloudInformationTile } from './CloudInformationTile';
|
|||
import { EnvironmentEvidenceTables } from './EnvironmentEvidenceTables';
|
||||
import { environmentEvidenceForGroup, type EnvironmentEvidenceGroup } from './environment-result-evidence';
|
||||
import type { GpoBackupStatusRecord } from '../../api/assessor-api-contract';
|
||||
import { operatingSystemInventory, type OperatingSystemInventoryGroup } from './operating-system-inventory';
|
||||
import {
|
||||
clientInfoOverview, dashboardDomainInformation, dashboardEntraTenantCount, dashboardEnvironmentDetail, dashboardEnvironmentDomainCount,
|
||||
dashboardEnvironmentTableNumber, dashboardGpoCount, dashboardObservationNumber, displayValue, numberFact, observationFact, observationFor,
|
||||
sectionFor, 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';
|
||||
import { useApp } from '../../app/AppProvider';
|
||||
import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace';
|
||||
import { DonutMetricCard } from '../../components/DonutMetricCard/DonutMetricCard';
|
||||
import { assessedResultPercentages } from '../../components/DonutMetricCard/assessed-result-percentages';
|
||||
import { DonutSmallArcMarker, donutArcPath, donutPercentageLabel } from '../../components/DonutMetricCard/donut-arc';
|
||||
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
|
||||
import { TriangleIcon } from '../../components/TriangleIcon';
|
||||
|
|
@ -214,14 +224,6 @@ const overviewProperties: Array<[string, string]> = [
|
|||
['report.engine_version', 'PingCastle engine']
|
||||
];
|
||||
|
||||
function displayValue(observation: EnvironmentObservation): string {
|
||||
if (observation.state === 'explicitly-absent') return observation.originalValue === false ? 'Not detected' : 'Not present';
|
||||
const value = observation.normalizedValue ?? observation.originalValue;
|
||||
if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled';
|
||||
if (value === null || value === '') return 'No value';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function useEnvironmentSnapshot(projectId?: string, scanRunIds?: string[]) {
|
||||
const [snapshot, setSnapshot] = useState<EnvironmentSnapshot>();
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
|
@ -438,11 +440,6 @@ interface DashboardEnvironmentSummary {
|
|||
resilienceFacts: Array<{ label: string; value: string }>;
|
||||
}
|
||||
|
||||
function observationFor(snapshot: EnvironmentSnapshot | undefined, property: string, label?: string, sectionKey?: string) {
|
||||
return snapshot?.observations.find(observation => observation.property === property
|
||||
|| Boolean(label && observation.label === label && (!sectionKey || observation.sectionKey === sectionKey)));
|
||||
}
|
||||
|
||||
function observationNumber(snapshot: EnvironmentSnapshot | undefined, property: string, label?: string, sectionKey?: string): number {
|
||||
const observation = observationFor(snapshot, property, label, sectionKey);
|
||||
const value = observation?.normalizedValue ?? observation?.originalValue;
|
||||
|
|
@ -459,10 +456,6 @@ function sectionObservationCount(snapshot: EnvironmentSnapshot | undefined, sect
|
|||
return snapshot?.observations.filter(observation => observation.sectionKey === sectionKey).length ?? 0;
|
||||
}
|
||||
|
||||
function sectionFor(snapshot: EnvironmentSnapshot | undefined, sectionKey: string) {
|
||||
return snapshot?.sections.find(section => section.sectionKey === sectionKey);
|
||||
}
|
||||
|
||||
function sectionTableCount(snapshot: EnvironmentSnapshot | undefined, sectionKey: string): number {
|
||||
return sectionFor(snapshot, sectionKey)?.tables.length ?? 0;
|
||||
}
|
||||
|
|
@ -542,203 +535,6 @@ export function dashboardEnvironmentSummary(snapshot?: EnvironmentSnapshot): Das
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Umgebungsdetails der geladenen Client Info.
|
||||
*
|
||||
* Die Zone beantwortet, was ausserhalb der Befunde ueber die Umgebung bekannt
|
||||
* ist: Verzeichnis, Bestand, Passwortrichtlinie, PKI, Wiederanlauf und die
|
||||
* werkzeugeigenen Kennzahlen. Unbekanntes bleibt ein Gedankenstrich - eine
|
||||
* fehlende Angabe wird nie als Null dargestellt.
|
||||
*/
|
||||
export interface DashboardEnvironmentDetail {
|
||||
operatingSystems: OperatingSystemInventoryGroup[];
|
||||
directory: DashboardFact[];
|
||||
inventory: DashboardFact[];
|
||||
passwordPolicy: DashboardFact[];
|
||||
certificates: DashboardFact[];
|
||||
resilience: DashboardFact[];
|
||||
toolContext: DashboardFact[];
|
||||
restricted: string[];
|
||||
}
|
||||
|
||||
export interface DashboardFact {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const UNKNOWN_FACT = '—';
|
||||
|
||||
/**
|
||||
* PingCastle schreibt den Hilfetext einer Zelle als weitere Attribute in
|
||||
* denselben Text ("Default Domain Policy " data-bs-html=..."). Fuer die
|
||||
* Anzeige bleibt der Wert vor dem Attributrest stehen; der vollstaendige
|
||||
* Originalwert bleibt in der Beobachtung erhalten.
|
||||
*/
|
||||
function factText(value: string | undefined): string {
|
||||
const cleaned = (value ?? '').replace(/\s*"\s*data-bs[\s\S]*$/i, '').trim();
|
||||
return cleaned || UNKNOWN_FACT;
|
||||
}
|
||||
|
||||
function observationFact(snapshot: EnvironmentSnapshot | undefined, property: string, label?: string): string {
|
||||
const observation = observationFor(snapshot, property, label);
|
||||
return observation ? factText(displayValue(observation)) : UNKNOWN_FACT;
|
||||
}
|
||||
|
||||
function optionalObservationNumber(snapshot: EnvironmentSnapshot | undefined, property: string, label?: string, sectionKey?: string): number | undefined {
|
||||
const observation = observationFor(snapshot, property, label, sectionKey);
|
||||
if (!observation) return undefined;
|
||||
const value = observation.normalizedValue ?? observation.originalValue;
|
||||
const numeric = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : undefined;
|
||||
}
|
||||
|
||||
function numberFact(value: number | undefined): string {
|
||||
return value === undefined ? UNKNOWN_FACT : value.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
function environmentTable(snapshot: EnvironmentSnapshot | undefined, sectionKey: string, tableName: RegExp) {
|
||||
return sectionFor(snapshot, sectionKey)?.tables.find(table => tableName.test(table.name.trim()));
|
||||
}
|
||||
|
||||
/** Erster Zeilenwert einer benannten Tabelle unter ihrer Spaltenueberschrift. */
|
||||
function environmentTableValue(
|
||||
snapshot: EnvironmentSnapshot | undefined, sectionKey: string, tableName: RegExp, header: RegExp
|
||||
): string {
|
||||
const table = environmentTable(snapshot, sectionKey, tableName);
|
||||
const column = table?.headers.findIndex(value => header.test(value.trim())) ?? -1;
|
||||
if (!table || column < 0) return UNKNOWN_FACT;
|
||||
return factText(table.rows[0]?.[column] === null || table.rows[0]?.[column] === undefined ? '' : String(table.rows[0][column]));
|
||||
}
|
||||
|
||||
function environmentTableRowCount(snapshot: EnvironmentSnapshot | undefined, sectionKey: string, tableName: RegExp): number | undefined {
|
||||
return environmentTable(snapshot, sectionKey, tableName)?.rows.length;
|
||||
}
|
||||
|
||||
function restrictedSections(snapshot: EnvironmentSnapshot | undefined): string[] {
|
||||
return (snapshot?.sections ?? [])
|
||||
.filter(section => ['license-restricted', 'access-denied'].includes(section.state))
|
||||
.map(section => section.label);
|
||||
}
|
||||
|
||||
export function dashboardEntraTenantCount(snapshot?: EnvironmentSnapshot): number | undefined {
|
||||
const observed = (snapshot?.observations ?? []).filter(observation => observation.state === 'observed');
|
||||
const ids = new Set(observed.filter(observation => observation.property === 'entra.tenant_id')
|
||||
.map(observation => displayValue(observation).trim()).filter(Boolean));
|
||||
if (ids.size) return ids.size;
|
||||
const names = new Set(observed.filter(observation => observation.property === 'entra.tenant_name')
|
||||
.map(observation => observation.subject.identity?.trim() || displayValue(observation).trim()).filter(Boolean));
|
||||
return names.size || undefined;
|
||||
}
|
||||
|
||||
export function dashboardEnvironmentDetail(snapshot?: EnvironmentSnapshot): DashboardEnvironmentDetail {
|
||||
const userTable = (header: RegExp) => dashboardEnvironmentTableNumber(snapshot, 'sectionUserInformation', header);
|
||||
const categoryScores = (snapshot?.observations ?? [])
|
||||
.filter(observation => /^purple_knight\.(?:ad|entra)\.category_score\./.test(observation.property))
|
||||
.map(observation => ({
|
||||
label: `Purple Knight ${observation.property.startsWith('purple_knight.entra.') ? 'Entra' : 'AD'} · ${observation.label}`,
|
||||
value: numberFact(optionalObservationNumber(snapshot, observation.property))
|
||||
}));
|
||||
const entraDirectoryValue = observationFor(snapshot, 'entra.tenant_name')
|
||||
? observationFact(snapshot, 'entra.tenant_name', 'Entra tenant')
|
||||
: observationFact(snapshot, 'entra.configuration_detected', 'Entra ID configuration detected');
|
||||
return {
|
||||
operatingSystems: operatingSystemInventory(snapshot),
|
||||
directory: [
|
||||
{ label: 'Domain', value: observationFact(snapshot, 'domain.fqdn', 'Domain') },
|
||||
{ label: 'NetBIOS name', value: observationFact(snapshot, 'domain.netbios_name', 'Netbios Name') },
|
||||
{ label: 'Forest', value: observationFact(snapshot, 'forest.name') },
|
||||
{ label: 'Domains', value: numberFact(dashboardEnvironmentDomainCount(snapshot)) },
|
||||
{ label: 'Domain functional level', value: observationFact(snapshot, 'domain.functional_level', 'Domain Functional Level') },
|
||||
{ label: 'Forest functional level', value: observationFact(snapshot, 'forest.functional_level', 'Forest Functional Level') },
|
||||
{ label: 'Schema version', value: observationFact(snapshot, 'forest.schema_version', 'Schema version') },
|
||||
{ label: 'Domain created', value: observationFact(snapshot, 'domain.created_at', 'Creation date') },
|
||||
{ label: 'Entra ID', value: entraDirectoryValue }
|
||||
],
|
||||
inventory: [
|
||||
{ label: 'User accounts', value: numberFact(userTable(/^nb user accounts$/i)) },
|
||||
{ label: 'Enabled / disabled', value: `${numberFact(userTable(/^nb enabled$/i))} / ${numberFact(userTable(/^nb disabled$/i))}` },
|
||||
{ label: 'Password never expires', value: numberFact(userTable(/^nb pwd never expire$/i)) },
|
||||
{ label: 'Computer objects', value: numberFact(dashboardEnvironmentTableNumber(snapshot, 'sectionComputerInformation', /^nb computer accounts$/i)) },
|
||||
{ label: 'Domain controllers', value: observationFact(snapshot, 'domain.controller_count', 'DC count') },
|
||||
{ label: 'Admin groups', value: numberFact(environmentTableRowCount(snapshot, 'sectionAdminGroups', /^admin groups list$/i)) },
|
||||
{ label: 'Trusts', value: numberFact(environmentTableRowCount(snapshot, 'sectionTrustsdetails', /^trusts list$/i)) },
|
||||
{ label: 'Assessed host', value: observationFact(snapshot, 'host.name') }
|
||||
],
|
||||
passwordPolicy: [
|
||||
{ label: 'Policy', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^policy name$/i) },
|
||||
{ label: 'Complexity', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^complexity$/i) },
|
||||
{ label: 'Minimum length', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^min password length$/i) },
|
||||
{ label: 'History', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^password history$/i) },
|
||||
{ label: 'Maximum age', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^max password age$/i) },
|
||||
{ label: 'Lockout threshold', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^lockout threshold$/i) }
|
||||
],
|
||||
certificates: [
|
||||
{ label: 'Certificate templates', value: numberFact(environmentTableRowCount(snapshot, 'sectionPKI', /^certificate templates list$/i)) },
|
||||
{ label: 'Certificates', value: numberFact(environmentTableRowCount(snapshot, 'sectionPKI', /^certificates list$/i)) },
|
||||
{ label: 'DC certificates', value: numberFact(environmentTableRowCount(snapshot, 'sectionPKI', /^dc certificates list$/i)) },
|
||||
{ label: 'PKI delegations', value: numberFact(environmentTableRowCount(snapshot, 'sectionPKI', /^delegations list$/i)) }
|
||||
],
|
||||
resilience: [
|
||||
{ label: 'AD Recycle Bin', value: observationFact(snapshot, 'domain.recycle_bin_enabled', 'Recycle Bin enabled') },
|
||||
{ label: 'Last AD backup', value: observationFact(snapshot, 'backup.last_detected_at', 'Last detected AD backup') },
|
||||
{ label: 'krbtgt password', value: observationFact(snapshot, 'kerberos.krbtgt.password_last_changed', 'krbtgt password last changed') },
|
||||
{ label: 'krbtgt version', value: observationFact(snapshot, 'kerberos.krbtgt.version', 'krbtgt version') },
|
||||
{ label: 'Legacy LAPS', value: observationFact(snapshot, 'laps.legacy.installation_date', 'Legacy LAPS installation date') },
|
||||
{ label: 'Windows LAPS', value: observationFact(snapshot, 'laps.windows.installation_date', 'Windows LAPS installation date') },
|
||||
{ label: 'WEF configurations', value: observationFact(snapshot, 'logging.wef.configuration_count', 'WEF configuration count') }
|
||||
],
|
||||
toolContext: [
|
||||
{ label: 'PingCastle engine', value: observationFact(snapshot, 'report.engine_version', 'PingCastle engine version') },
|
||||
{ label: 'PingCastle domain risk', value: numberFact(optionalObservationNumber(snapshot, 'risk.domain') ?? optionalObservationNumber(snapshot, 'risk.global', 'Domain Risk Level')) },
|
||||
{ label: 'Purple Knight AD posture', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.ad.security_posture_score')) },
|
||||
{ label: 'Purple Knight AD IOEs', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.ad.ioes_found')) },
|
||||
{ label: 'Purple Knight AD evaluated', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.ad.evaluated')) },
|
||||
{ label: 'Purple Knight Entra tenant', value: observationFact(snapshot, 'entra.tenant_name', 'Entra tenant') },
|
||||
{ label: 'Purple Knight Entra posture', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.entra.security_posture_score')) },
|
||||
{ label: 'Purple Knight Entra IOEs', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.entra.ioes_found')) },
|
||||
{ label: 'Purple Knight Entra evaluated', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.entra.evaluated')) },
|
||||
...categoryScores,
|
||||
{ label: 'CIS-CAT benchmark', value: observationFact(snapshot, 'benchmark.name') },
|
||||
{ label: 'CIS-CAT profile', value: observationFact(snapshot, 'benchmark.profile') },
|
||||
{ label: 'CIS-CAT score', value: numberFact(optionalObservationNumber(snapshot, 'benchmark.score')) },
|
||||
...(snapshot?.observations ?? [])
|
||||
.filter(observation => observation.tool.id === 'maester' && ['entra.tenant_id', 'entra.tenant_name', 'report.generated_at', 'report.engine_version', 'maester.duration', 'maester.tests.actual_total', 'maester.runner.host', 'maester.runner.os', 'maester.powershell.version'].includes(observation.property))
|
||||
.map(observation => ({ label: observation.label.startsWith('Maester') ? observation.label : `Maester ${observation.label}`, value: observation.state === 'observed' ? factText(displayValue(observation)) : UNKNOWN_FACT }))
|
||||
],
|
||||
restricted: restrictedSections(snapshot)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Kompakter, quellengestuetzter Verzeichniskontext fuer die erste Leselinie.
|
||||
* Die Werte verwenden dieselben normalisierten Beobachtungen wie Environment
|
||||
* Detail; fehlende Scannerangaben bleiben ausdruecklich unbekannt.
|
||||
*/
|
||||
export function dashboardDomainInformation(snapshot?: EnvironmentSnapshot): DashboardFact[] {
|
||||
const entra = observationFor(snapshot, 'entra.tenant_name')
|
||||
? observationFact(snapshot, 'entra.tenant_name', 'Entra tenant')
|
||||
: observationFact(snapshot, 'entra.configuration_detected', 'Entra ID configuration detected');
|
||||
const dcOperatingSystems = [...new Set((snapshot?.observations ?? [])
|
||||
.filter(observation => observation.state === 'observed'
|
||||
&& observation.property === 'operating_system.name'
|
||||
&& observation.subject.type === 'computer-information.domain-controllers')
|
||||
.map(observation => factText(displayValue(observation)))
|
||||
.filter(value => value !== UNKNOWN_FACT))];
|
||||
return [
|
||||
{ label: 'Domain', value: observationFact(snapshot, 'domain.fqdn', 'Domain') },
|
||||
{ label: 'NetBIOS name', value: observationFact(snapshot, 'domain.netbios_name', 'Netbios Name') },
|
||||
{ label: 'Forest', value: observationFact(snapshot, 'forest.name') },
|
||||
{ label: 'Domains', value: numberFact(dashboardEnvironmentDomainCount(snapshot)) },
|
||||
{ label: 'Domain controllers', value: observationFact(snapshot, 'domain.controller_count', 'DC count') },
|
||||
{ label: 'Domain functional level', value: observationFact(snapshot, 'domain.functional_level', 'Domain Functional Level') },
|
||||
{ label: 'Forest functional level', value: observationFact(snapshot, 'forest.functional_level', 'Forest Functional Level') },
|
||||
{ label: 'DC OS', value: dcOperatingSystems.length ? dcOperatingSystems.join(', ') : UNKNOWN_FACT },
|
||||
{ label: 'Schema version', value: observationFact(snapshot, 'forest.schema_version', 'Schema version') },
|
||||
{ label: 'Domain created', value: observationFact(snapshot, 'domain.created_at', 'Creation date') },
|
||||
{ label: 'Entra ID', value: entra }
|
||||
];
|
||||
}
|
||||
|
||||
interface DashboardMetricTileProps {
|
||||
label: string;
|
||||
value: string;
|
||||
|
|
@ -970,42 +766,6 @@ function dashboardFieldValue(record: FindingRecord, pattern: RegExp): string | u
|
|||
return record.fields.find(field => pattern.test(field.label.trim()))?.value;
|
||||
}
|
||||
|
||||
function dashboardObservationNumber(snapshot: EnvironmentSnapshot | undefined, property: string): number | undefined {
|
||||
const observation = snapshot?.observations.find(item => item.property === property && item.state === 'observed');
|
||||
const value = observation?.normalizedValue ?? observation?.originalValue;
|
||||
const number = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(number) ? number : undefined;
|
||||
}
|
||||
|
||||
function dashboardEnvironmentTableNumber(snapshot: EnvironmentSnapshot | undefined, sectionKey: string, header: RegExp): number | undefined {
|
||||
const values = snapshot?.sections
|
||||
.filter(section => section.sectionKey === sectionKey)
|
||||
.flatMap(section => section.tables.flatMap(table => {
|
||||
const column = table.headers.findIndex(value => header.test(value.trim()));
|
||||
if (column < 0) return [];
|
||||
return table.rows.flatMap(row => {
|
||||
const numeric = Number(row[column]);
|
||||
return Number.isFinite(numeric) ? [numeric] : [];
|
||||
});
|
||||
})) ?? [];
|
||||
return values.length ? values.reduce((sum, value) => sum + value, 0) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zahl der Domaenen. Nennt eine Quelle sie ausdruecklich (Purple Knight:
|
||||
* "AD: number of domains"), gilt diese Angabe; sonst bleibt die Zahl der
|
||||
* verschiedenen beobachteten Domaenennamen die belegbare Untergrenze.
|
||||
*/
|
||||
function dashboardEnvironmentDomainCount(snapshot: EnvironmentSnapshot | undefined): number | undefined {
|
||||
const declared = dashboardObservationNumber(snapshot, 'domain.count');
|
||||
if (declared !== undefined) return declared;
|
||||
const domains = new Set(snapshot?.observations
|
||||
.filter(item => item.property === 'domain.fqdn' && item.state === 'observed')
|
||||
.map(item => String(item.normalizedValue ?? item.originalValue).trim().toLocaleLowerCase('en-US'))
|
||||
.filter(Boolean));
|
||||
return domains.size || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zahl der erfassten Gruppenrichtlinien aus den verarbeiteten Sicherungen.
|
||||
*
|
||||
|
|
@ -1035,15 +795,6 @@ function useGpoInventory(projectId?: string): { gpoCount?: number; snapshots?: n
|
|||
return inventory;
|
||||
}
|
||||
|
||||
function dashboardGpoCount(records: FindingRecord[]): number | undefined {
|
||||
const identifiers = new Set<string>();
|
||||
const pattern = /\{?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}?/gi;
|
||||
records.forEach(record => [record.sourceFile, ...record.fields.map(field => field.value)].filter(Boolean).forEach(value => {
|
||||
String(value).match(pattern)?.forEach(identifier => identifiers.add(identifier.toLocaleLowerCase('en-US')));
|
||||
}));
|
||||
return identifiers.size || undefined;
|
||||
}
|
||||
|
||||
function DashboardGlyph({ name }: { name: DashboardGlyphName }) {
|
||||
const content = name === 'user' ? <><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3-7 8-7s8 3 8 7"/></>
|
||||
: name === 'computer' ? <><rect x="3" y="4" width="18" height="13"/><path d="M8 21h8M12 17v4"/></>
|
||||
|
|
@ -1414,12 +1165,35 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
|||
const sourceFilter = app.facetFilters['provenance:source-scan'];
|
||||
const analysis = useDashboardAnalysis(activeSheet, dataset, activeProfile, reviews, sourceFilter);
|
||||
const engines = useEnginePreferences();
|
||||
const impactContext = useAssessmentContext(Boolean(dataset) && engines.report, app.targetProjectId, app.activeAssessmentIds, app.activeScanRunIds, dataset);
|
||||
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 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);
|
||||
const assigned = new Set<string>();
|
||||
const keys = new Map(records.map(record => [record.id, managementWorkspaceDeviationKeys([record], () => true)[0]]));
|
||||
const groups: SecurityFindingGroup[] = impactContext.status === 'ready' ? publishContextReport(impactContext.context).clusters.map((cluster, index) => {
|
||||
const members = new Set(cluster.findingKeys);
|
||||
const matches = records.filter(record => !assigned.has(record.id) && members.has(keys.get(record.id)!));
|
||||
matches.forEach(record => assigned.add(record.id));
|
||||
const text = engines.report ? managementCategoryTexts[cluster.id] : undefined;
|
||||
return { id: cluster.id, number: String(index + 1).padStart(2, '0'), label: cluster.label, severity: cluster.severity,
|
||||
readingName: text?.name, situation: text?.situation ?? cluster.description, lever: text?.lever,
|
||||
records: matches, domains: countsFor(matches) };
|
||||
}) : [];
|
||||
const remaining = records.filter(record => !assigned.has(record.id));
|
||||
if (remaining.length) groups.push({ id: 'unassigned', label: impactContext.status === 'ready' ? 'Unassigned findings' : 'All findings', severity: 'unknown',
|
||||
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]);
|
||||
const impactDistribution = useMemo(() => {
|
||||
if (impactContext.status !== 'ready') return undefined;
|
||||
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]);
|
||||
}, [impactContext.status === 'ready' ? impactContext.context : undefined, analysis, activeProfile, engines.report]);
|
||||
const [reportExporting, setReportExporting] = useState<'management' | 'technical' | null>(null);
|
||||
const [reportError, setReportError] = useState<string | null>(null);
|
||||
const reportScopeKey = JSON.stringify([app.targetProjectId, app.activeScanRunIds, app.activeAssessmentIds]);
|
||||
|
|
@ -1468,29 +1242,21 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
|||
sourceScores, domains, totalResult, gapRecordIds, overallScore
|
||||
} = analysis;
|
||||
const deviations = totalResult.deviations;
|
||||
const assessmentPercentages = assessedResultPercentages(totalResult.passed, deviations);
|
||||
const assessmentCoverage = totalResult.coverage;
|
||||
const nativePingCastleRisk = dashboardObservationNumber(environment.snapshot, 'risk.domain')
|
||||
?? dashboardObservationNumber(environment.snapshot, 'risk.global');
|
||||
const userAccounts = dashboardEnvironmentTableNumber(environment.snapshot, 'sectionUserInformation', /^nb user accounts$/i);
|
||||
const computerAccounts = dashboardEnvironmentTableNumber(environment.snapshot, 'sectionComputerInformation', /^nb computer accounts$/i);
|
||||
const domainControllers = dashboardObservationNumber(environment.snapshot, 'domain.controller_count');
|
||||
// Give repeated overview facts one home; Results retains the full category.
|
||||
// Compare only the same named fact, never unrelated facts with equal values.
|
||||
const countOverview = new Map([
|
||||
['Domains', numberFact(dashboardEnvironmentDomainCount(environment.snapshot))],
|
||||
['Domain controllers', numberFact(domainControllers)],
|
||||
['User accounts', numberFact(userAccounts)],
|
||||
['Computer objects', numberFact(computerAccounts)]
|
||||
]);
|
||||
const alreadyInData = (item: DashboardFact) => countOverview.get(item.label) === item.value;
|
||||
const domainOverview = domainInformation.filter(item => !alreadyInData(item));
|
||||
const inventoryOverview = environmentDetail.inventory.filter(item =>
|
||||
!alreadyInData(item) && !domainOverview.some(shown => shown.label === item.label && shown.value === item.value));
|
||||
const maesterSource = sourceScores.find(source => /maester/i.test(source.tool.label));
|
||||
const entraTenants = dashboardEntraTenantCount(environment.snapshot)
|
||||
?? dashboardObservationNumber(environment.snapshot, 'entra.configuration_detected')
|
||||
?? (maesterSource?.records.length ? 1 : undefined);
|
||||
const gpoCount = gpoInventory.gpoCount ?? derivedGpoCount;
|
||||
// The overview facts come from the same React-free builder the Technical Report reads.
|
||||
const overview = clientInfoOverview(environment.snapshot, { gpoCount, inferredEntraTenant: Boolean(maesterSource?.records.length) });
|
||||
const dataFact = (label: string) => overview.data.find(item => item.label === label)?.value;
|
||||
const userAccounts = dataFact('User Accounts');
|
||||
const computerAccounts = dataFact('Computer Objects');
|
||||
const domainControllers = dataFact('Domain Controllers');
|
||||
const entraTenants = dataFact('Entra Tenant');
|
||||
const domainOverview = overview.domainInformation;
|
||||
const inventoryOverview = overview.inventory;
|
||||
const nativeMetrics: DashboardNativeMetrics = {
|
||||
pingCastleRisk: nativePingCastleRisk,
|
||||
purpleKnightPosture: dashboardObservationNumber(environment.snapshot, 'purple_knight.ad.security_posture_score'),
|
||||
|
|
@ -1538,17 +1304,11 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
|||
: 'all';
|
||||
|
||||
return (
|
||||
<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>
|
||||
<label className="client-overview-source"><span>Source:</span><select value={selectedSource} onChange={event => {
|
||||
const tool = tools.find(candidate => candidate.id === event.currentTarget.value);
|
||||
if (!sourceFacet) return;
|
||||
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
|
||||
if (targetSheet) app.setActiveSheet(targetSheet.id);
|
||||
setFacetFilter(sourceFacet.id, tool ? { values: tool.sourceValues } : null);
|
||||
}}><option value="all">Scan · All</option>{selectedSource === 'selection' && <option value="selection">Scan · Selection</option>}{tools.map(tool => <option key={tool.id} value={tool.id}>{tool.label}</option>)}</select></label>
|
||||
<div className="client-overview-tools">
|
||||
<div className="client-report-actions" aria-label="Assessment reports">
|
||||
<button className="btn client-information-action" type="button" disabled={!engines.report || Boolean(reportExporting) || !app.targetProjectId || !hasSelectedTimeslotScope(app.activeScanRunIds, app.activeAssessmentIds)} onClick={() => void exportReport('management')}>
|
||||
{reportExporting === 'management' ? 'Creating Management Report…' : 'Management Report'}
|
||||
|
|
@ -1557,11 +1317,20 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
|||
{reportExporting === 'technical' ? 'Creating Technical Report…' : 'Technical Report'}
|
||||
</button>
|
||||
</div>
|
||||
<label className="client-overview-source"><span>Source:</span><select value={selectedSource} onChange={event => {
|
||||
const tool = tools.find(candidate => candidate.id === event.currentTarget.value);
|
||||
if (!sourceFacet) return;
|
||||
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
|
||||
if (targetSheet) app.setActiveSheet(targetSheet.id);
|
||||
setFacetFilter(sourceFacet.id, tool ? { values: tool.sourceValues } : null);
|
||||
}}><option value="all">Scan · All</option>{selectedSource === 'selection' && <option value="selection">Scan · Selection</option>}{tools.map(tool => <option key={tool.id} value={tool.id}>{tool.label}</option>)}</select></label>
|
||||
</div>
|
||||
{!engines.report && <span>Report Engine is off</span>}
|
||||
{engines.report && reportError && <p className="client-report-error" role="alert">{reportError}</p>}
|
||||
</header>
|
||||
|
||||
<div className="client-overview-main">
|
||||
<div className="client-overview-summary">
|
||||
<section className={`client-posture tone-${overallTone}`} aria-label="Security posture">
|
||||
<DashboardPostureConnectors nodeCount={displayedDomains.length} />
|
||||
<section className="client-overall" aria-labelledby="client-overall-heading">
|
||||
|
|
@ -1599,9 +1368,9 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
|||
</section>
|
||||
</section>
|
||||
|
||||
<aside className="client-priority" aria-labelledby="client-priority-heading">
|
||||
<div className="client-priority" aria-labelledby="client-priority-heading">
|
||||
<section className="client-priority-findings">
|
||||
<header><h2 id="client-priority-heading">Data</h2><DonutMetricCard className="datahub-reference-knob client-priority-total" label="Deviations" value={deviations} percentage={share(deviations, total)} tone="deviation" onClick={() => jump(() => undefined)} /></header>
|
||||
<header><h2 id="client-priority-heading">Data</h2><DonutMetricCard className="datahub-reference-knob client-priority-total" label="Deviations" value={deviations} percentage={assessmentPercentages.deviation} percentageBasisLabel="Of assessed checks" tone="deviation" onClick={() => jump(() => undefined)} /></header>
|
||||
<div className="client-priority-context" aria-label="Additional result states">
|
||||
{([
|
||||
['passed', 'Passed', totalResult.passed],
|
||||
|
|
@ -1623,6 +1392,8 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
|||
<DashboardTopFact value={gpoCount} label="GPOs" glyph="policy" snapshot={environment.snapshot} evidenceGroup="gpos" backupRecords={gpoInventory.records} fallback={gpoCount !== undefined && !gpoInventory.records?.length ? "Count derived from GPO identifiers in the loaded findings; no processed backup inventory is available." : undefined} />
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<section className="client-domain-information" aria-labelledby="client-domain-information-heading">
|
||||
<header className="client-env-group-header">
|
||||
<h2 id="client-domain-information-heading">Domain Information</h2>
|
||||
|
|
@ -1633,7 +1404,6 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
|||
</header>
|
||||
<dl>{domainOverview.map(item => <div key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}</dl>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<ClientContextHeatmap onOpenCluster={clusterId => jump(() => {
|
||||
|
|
@ -1660,6 +1430,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
|||
</div>
|
||||
|
||||
<RiskRegister dataset={dataset} reviews={reviews} onOpenFinding={recordId => jump(() => setPathFindingIds([recordId]))} />
|
||||
</div>
|
||||
</div> }, { id: 'security', label: 'Security', content: <ClientSecurityPanel key={reportScopeKey}
|
||||
dataset={dataset} sheetId={activeSheet.id} toolId={activeSheet.toolId} contextStatus={impactContext.status} {...securityView}/> }]} />
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { EnvironmentSnapshot } from '../../domain/environment-context';
|
|||
import { EnvironmentResultsButton } from './EnvironmentResultsButton';
|
||||
import { EnvironmentEvidenceTables } from './EnvironmentEvidenceTables';
|
||||
import type { OperatingSystemInventoryGroup } from './operating-system-inventory';
|
||||
import { operatingSystemOverview } from './client-info-facts';
|
||||
|
||||
const quantity = (value?: number) => value === undefined ? '—' : value.toLocaleString('en-US');
|
||||
|
||||
|
|
@ -35,11 +36,7 @@ export function OperatingSystemInventory({ groups, icon, snapshot }: {
|
|||
groups: OperatingSystemInventoryGroup[]; icon?: ReactNode; snapshot?: EnvironmentSnapshot;
|
||||
}) {
|
||||
const inventories = [...new Set(groups.flatMap(group => group.entries.map(entry => entry.inventory)))];
|
||||
const summary = groups.filter(group => group.total !== undefined).map(group => ({ label: group.label, value: quantity(group.total) }));
|
||||
const systems = groups.flatMap(group => group.entries).slice(0, Math.max(0, 4 - summary.length))
|
||||
.map(entry => ({ label: entry.name, value: quantity(entry.count) }));
|
||||
const inventoryFacts = summary.length || systems.length ? [...summary, ...systems] : [{ label: 'Inventory', value: '—' }];
|
||||
const facts = inventoryFacts;
|
||||
const facts = operatingSystemOverview(groups);
|
||||
return <section className="client-os-inventory" aria-label="Operating systems">
|
||||
<header>
|
||||
<h3>{icon}<span>Operating systems</span></h3>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { FindingRecord } from '../../domain/finding';
|
|||
import { riskAcceptanceState, type RiskAcceptanceRevision, type RiskAcceptanceDetails } from '../../domain/risk-acceptance';
|
||||
import type { Review } from '../../domain/review';
|
||||
import { TriangleIcon } from '../../components/TriangleIcon';
|
||||
import { RelatedFindingRing, useRelatedViewRestore } from '../related-findings/RelatedFindings';
|
||||
|
||||
export interface RiskRegisterEntry {
|
||||
id: string;
|
||||
|
|
@ -83,6 +84,8 @@ export function RiskRegister({ dataset, reviews, onOpenFinding }: {
|
|||
}) {
|
||||
const entries = riskRegisterEntries(dataset, reviews);
|
||||
const [requestedId, setRequestedId] = useState('');
|
||||
useRelatedViewRestore(() => () => setRequestedId(requestedId));
|
||||
const recordFor = (entry: RiskRegisterEntry) => dataset.sheets.flatMap(sheet => sheet.analysis.records).find(record => record.id === entry.recordId);
|
||||
const selected = entries.find(entry => entry.id === requestedId) ?? entries[0];
|
||||
const currentCount = entries.filter(entry => ['Accepted', 'Scheduled', 'Review due', 'Review required'].includes(entry.state)).length;
|
||||
|
||||
|
|
@ -93,20 +96,20 @@ export function RiskRegister({ dataset, reviews, onOpenFinding }: {
|
|||
<div><h2 id="client-risk-register-heading">Accepted Risks</h2><span>Risk register</span></div>
|
||||
<strong>{entries.length.toLocaleString('en-US')} recorded · {currentCount.toLocaleString('en-US')} current</strong>
|
||||
</header>
|
||||
<nav className="client-risk-register-list" aria-label="Accepted risks">
|
||||
{entries.map((entry, index) => <button key={entry.id} type="button" className={`is-${stateTone(entry.state)}`} aria-pressed={entry.id === selected?.id} onClick={() => setRequestedId(entry.id)} onKeyDown={event => {
|
||||
<nav className="client-risk-register-list" aria-label="Accepted risks" data-group-scroll="accepted-risks">
|
||||
{entries.map((entry, index) => <div className="risk-entry-with-group" key={entry.id}><button type="button" className={`is-${stateTone(entry.state)}`} aria-pressed={entry.id === selected?.id} onClick={() => setRequestedId(entry.id)} onKeyDown={event => {
|
||||
const targetIndex = event.key === 'Home' ? 0 : event.key === 'End' ? entries.length - 1
|
||||
: event.key === 'ArrowUp' ? Math.max(0, index - 1)
|
||||
: event.key === 'ArrowDown' ? Math.min(entries.length - 1, index + 1) : index;
|
||||
if (targetIndex === index || !['Home', 'End', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
setRequestedId(entries[targetIndex].id);
|
||||
event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>('button')[targetIndex]?.focus();
|
||||
event.currentTarget.closest('nav')?.querySelectorAll<HTMLButtonElement>('.risk-entry-with-group > button:not(.related-finding-ring)')[targetIndex]?.focus();
|
||||
}}>
|
||||
<span><strong>{entry.title}</strong><small>{entry.source}</small></span>
|
||||
<em>{entry.state}</em>
|
||||
<time dateTime={entry.latest.details.reviewOn || entry.latest.recordedAt}>Review {displayDate(entry.latest.details.reviewOn)}</time>
|
||||
</button>)}
|
||||
</button>{recordFor(entry) && <RelatedFindingRing record={recordFor(entry)!} onGroupOpen={() => onOpenFinding?.(entry.recordId)}/>}</div>)}
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="client-risk-register-information">
|
||||
|
|
@ -115,7 +118,7 @@ export function RiskRegister({ dataset, reviews, onOpenFinding }: {
|
|||
<div><strong>Risk Information</strong><span>{selected.source}</span></div>
|
||||
<div className="risk-register-actions">{onOpenFinding && <button className="btn primary" type="button" onClick={() => onOpenFinding(selected.recordId)}>Open finding</button>}<strong className={`is-${stateTone(selected.state)}`}>{selected.state}</strong></div>
|
||||
</header>
|
||||
<section className="risk-register-title"><span>Finding</span><h2>{selected.title}</h2></section>
|
||||
<section className="risk-register-title"><span>Finding</span><h2>{selected.title}</h2>{recordFor(selected) && <RelatedFindingRing record={recordFor(selected)!} onGroupOpen={() => onOpenFinding?.(selected.recordId)}/>}</section>
|
||||
<dl className="risk-register-summary">
|
||||
<DetailField label="Initial risk" value={selected.latest.details.initialRisk} />
|
||||
<DetailField label="Residual risk" value={selected.latest.details.residualRisk} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,348 @@
|
|||
import type { EnvironmentObservation, EnvironmentSnapshot } from '../../domain/environment-context';
|
||||
import type { FindingRecord } from '../../domain/finding';
|
||||
import { cloudInformationFacts, cloudInformationOverview } from '../../domain/cloud-information';
|
||||
import type { ClientInformationArea } from '../../domain/client-information';
|
||||
import { operatingSystemInventory, type OperatingSystemInventoryGroup } from './operating-system-inventory';
|
||||
|
||||
/**
|
||||
* Client Info overview facts without React: the Environment Detail groups, Domain Information, the Data
|
||||
* counts and the compact tile overviews. Client Info and the Technical Report read the same functions,
|
||||
* so the report states exactly the facts Client Info shows for the same snapshot.
|
||||
*/
|
||||
|
||||
export function displayValue(observation: EnvironmentObservation): string {
|
||||
if (observation.state === 'explicitly-absent') return observation.originalValue === false ? 'Not detected' : 'Not present';
|
||||
const value = observation.normalizedValue ?? observation.originalValue;
|
||||
if (typeof value === 'boolean') return value ? 'Enabled' : 'Disabled';
|
||||
if (value === null || value === '') return 'No value';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function sectionFor(snapshot: EnvironmentSnapshot | undefined, sectionKey: string) {
|
||||
return snapshot?.sections.find(section => section.sectionKey === sectionKey);
|
||||
}
|
||||
|
||||
export function observationFor(snapshot: EnvironmentSnapshot | undefined, property: string, label?: string, sectionKey?: string) {
|
||||
return snapshot?.observations.find(observation => observation.property === property
|
||||
|| Boolean(label && observation.label === label && (!sectionKey || observation.sectionKey === sectionKey)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Umgebungsdetails der geladenen Client Info.
|
||||
*
|
||||
* Die Zone beantwortet, was ausserhalb der Befunde ueber die Umgebung bekannt
|
||||
* ist: Verzeichnis, Bestand, Passwortrichtlinie, PKI, Wiederanlauf und die
|
||||
* werkzeugeigenen Kennzahlen. Unbekanntes bleibt ein Gedankenstrich - eine
|
||||
* fehlende Angabe wird nie als Null dargestellt.
|
||||
*/
|
||||
export interface DashboardEnvironmentDetail {
|
||||
operatingSystems: OperatingSystemInventoryGroup[];
|
||||
directory: DashboardFact[];
|
||||
inventory: DashboardFact[];
|
||||
passwordPolicy: DashboardFact[];
|
||||
certificates: DashboardFact[];
|
||||
resilience: DashboardFact[];
|
||||
toolContext: DashboardFact[];
|
||||
restricted: string[];
|
||||
}
|
||||
|
||||
export interface DashboardFact {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const UNKNOWN_FACT = '—';
|
||||
|
||||
/**
|
||||
* PingCastle schreibt den Hilfetext einer Zelle als weitere Attribute in
|
||||
* denselben Text ("Default Domain Policy " data-bs-html=..."). Fuer die
|
||||
* Anzeige bleibt der Wert vor dem Attributrest stehen; der vollstaendige
|
||||
* Originalwert bleibt in der Beobachtung erhalten.
|
||||
*/
|
||||
export function factText(value: string | undefined): string {
|
||||
const cleaned = (value ?? '').replace(/\s*"\s*data-bs[\s\S]*$/i, '').trim();
|
||||
return cleaned || UNKNOWN_FACT;
|
||||
}
|
||||
|
||||
export function observationFact(snapshot: EnvironmentSnapshot | undefined, property: string, label?: string): string {
|
||||
const observation = observationFor(snapshot, property, label);
|
||||
return observation ? factText(displayValue(observation)) : UNKNOWN_FACT;
|
||||
}
|
||||
|
||||
export function optionalObservationNumber(snapshot: EnvironmentSnapshot | undefined, property: string, label?: string, sectionKey?: string): number | undefined {
|
||||
const observation = observationFor(snapshot, property, label, sectionKey);
|
||||
if (!observation) return undefined;
|
||||
const value = observation.normalizedValue ?? observation.originalValue;
|
||||
const numeric = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : undefined;
|
||||
}
|
||||
|
||||
export function numberFact(value: number | undefined): string {
|
||||
return value === undefined ? UNKNOWN_FACT : value.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
function environmentTable(snapshot: EnvironmentSnapshot | undefined, sectionKey: string, tableName: RegExp) {
|
||||
return sectionFor(snapshot, sectionKey)?.tables.find(table => tableName.test(table.name.trim()));
|
||||
}
|
||||
|
||||
/** Erster Zeilenwert einer benannten Tabelle unter ihrer Spaltenueberschrift. */
|
||||
function environmentTableValue(
|
||||
snapshot: EnvironmentSnapshot | undefined, sectionKey: string, tableName: RegExp, header: RegExp
|
||||
): string {
|
||||
const table = environmentTable(snapshot, sectionKey, tableName);
|
||||
const column = table?.headers.findIndex(value => header.test(value.trim())) ?? -1;
|
||||
if (!table || column < 0) return UNKNOWN_FACT;
|
||||
return factText(table.rows[0]?.[column] === null || table.rows[0]?.[column] === undefined ? '' : String(table.rows[0][column]));
|
||||
}
|
||||
|
||||
function environmentTableRowCount(snapshot: EnvironmentSnapshot | undefined, sectionKey: string, tableName: RegExp): number | undefined {
|
||||
return environmentTable(snapshot, sectionKey, tableName)?.rows.length;
|
||||
}
|
||||
|
||||
function restrictedSections(snapshot: EnvironmentSnapshot | undefined): string[] {
|
||||
return (snapshot?.sections ?? [])
|
||||
.filter(section => ['license-restricted', 'access-denied'].includes(section.state))
|
||||
.map(section => section.label);
|
||||
}
|
||||
|
||||
export function dashboardEntraTenantCount(snapshot?: EnvironmentSnapshot): number | undefined {
|
||||
const observed = (snapshot?.observations ?? []).filter(observation => observation.state === 'observed');
|
||||
const ids = new Set(observed.filter(observation => observation.property === 'entra.tenant_id')
|
||||
.map(observation => displayValue(observation).trim()).filter(Boolean));
|
||||
if (ids.size) return ids.size;
|
||||
const names = new Set(observed.filter(observation => observation.property === 'entra.tenant_name')
|
||||
.map(observation => observation.subject.identity?.trim() || displayValue(observation).trim()).filter(Boolean));
|
||||
return names.size || undefined;
|
||||
}
|
||||
|
||||
export function dashboardEnvironmentDetail(snapshot?: EnvironmentSnapshot): DashboardEnvironmentDetail {
|
||||
const userTable = (header: RegExp) => dashboardEnvironmentTableNumber(snapshot, 'sectionUserInformation', header);
|
||||
const categoryScores = (snapshot?.observations ?? [])
|
||||
.filter(observation => /^purple_knight\.(?:ad|entra)\.category_score\./.test(observation.property))
|
||||
.map(observation => ({
|
||||
label: `Purple Knight ${observation.property.startsWith('purple_knight.entra.') ? 'Entra' : 'AD'} · ${observation.label}`,
|
||||
value: numberFact(optionalObservationNumber(snapshot, observation.property))
|
||||
}));
|
||||
const entraDirectoryValue = observationFor(snapshot, 'entra.tenant_name')
|
||||
? observationFact(snapshot, 'entra.tenant_name', 'Entra tenant')
|
||||
: observationFact(snapshot, 'entra.configuration_detected', 'Entra ID configuration detected');
|
||||
return {
|
||||
operatingSystems: operatingSystemInventory(snapshot),
|
||||
directory: [
|
||||
{ label: 'Domain', value: observationFact(snapshot, 'domain.fqdn', 'Domain') },
|
||||
{ label: 'NetBIOS name', value: observationFact(snapshot, 'domain.netbios_name', 'Netbios Name') },
|
||||
{ label: 'Forest', value: observationFact(snapshot, 'forest.name') },
|
||||
{ label: 'Domains', value: numberFact(dashboardEnvironmentDomainCount(snapshot)) },
|
||||
{ label: 'Domain functional level', value: observationFact(snapshot, 'domain.functional_level', 'Domain Functional Level') },
|
||||
{ label: 'Forest functional level', value: observationFact(snapshot, 'forest.functional_level', 'Forest Functional Level') },
|
||||
{ label: 'Schema version', value: observationFact(snapshot, 'forest.schema_version', 'Schema version') },
|
||||
{ label: 'Domain created', value: observationFact(snapshot, 'domain.created_at', 'Creation date') },
|
||||
{ label: 'Entra ID', value: entraDirectoryValue }
|
||||
],
|
||||
inventory: [
|
||||
{ label: 'User accounts', value: numberFact(userTable(/^nb user accounts$/i)) },
|
||||
{ label: 'Enabled / disabled', value: `${numberFact(userTable(/^nb enabled$/i))} / ${numberFact(userTable(/^nb disabled$/i))}` },
|
||||
{ label: 'Password never expires', value: numberFact(userTable(/^nb pwd never expire$/i)) },
|
||||
{ label: 'Computer objects', value: numberFact(dashboardEnvironmentTableNumber(snapshot, 'sectionComputerInformation', /^nb computer accounts$/i)) },
|
||||
{ label: 'Domain controllers', value: observationFact(snapshot, 'domain.controller_count', 'DC count') },
|
||||
{ label: 'Admin groups', value: numberFact(environmentTableRowCount(snapshot, 'sectionAdminGroups', /^admin groups list$/i)) },
|
||||
{ label: 'Trusts', value: numberFact(environmentTableRowCount(snapshot, 'sectionTrustsdetails', /^trusts list$/i)) },
|
||||
{ label: 'Assessed host', value: observationFact(snapshot, 'host.name') }
|
||||
],
|
||||
passwordPolicy: [
|
||||
{ label: 'Policy', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^policy name$/i) },
|
||||
{ label: 'Complexity', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^complexity$/i) },
|
||||
{ label: 'Minimum length', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^min password length$/i) },
|
||||
{ label: 'History', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^password history$/i) },
|
||||
{ label: 'Maximum age', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^max password age$/i) },
|
||||
{ label: 'Lockout threshold', value: environmentTableValue(snapshot, 'sectionPasswordPolicies', /^password policies list$/i, /^lockout threshold$/i) }
|
||||
],
|
||||
certificates: [
|
||||
{ label: 'Certificate templates', value: numberFact(environmentTableRowCount(snapshot, 'sectionPKI', /^certificate templates list$/i)) },
|
||||
{ label: 'Certificates', value: numberFact(environmentTableRowCount(snapshot, 'sectionPKI', /^certificates list$/i)) },
|
||||
{ label: 'DC certificates', value: numberFact(environmentTableRowCount(snapshot, 'sectionPKI', /^dc certificates list$/i)) },
|
||||
{ label: 'PKI delegations', value: numberFact(environmentTableRowCount(snapshot, 'sectionPKI', /^delegations list$/i)) }
|
||||
],
|
||||
resilience: [
|
||||
{ label: 'AD Recycle Bin', value: observationFact(snapshot, 'domain.recycle_bin_enabled', 'Recycle Bin enabled') },
|
||||
{ label: 'Last AD backup', value: observationFact(snapshot, 'backup.last_detected_at', 'Last detected AD backup') },
|
||||
{ label: 'krbtgt password', value: observationFact(snapshot, 'kerberos.krbtgt.password_last_changed', 'krbtgt password last changed') },
|
||||
{ label: 'krbtgt version', value: observationFact(snapshot, 'kerberos.krbtgt.version', 'krbtgt version') },
|
||||
{ label: 'Legacy LAPS', value: observationFact(snapshot, 'laps.legacy.installation_date', 'Legacy LAPS installation date') },
|
||||
{ label: 'Windows LAPS', value: observationFact(snapshot, 'laps.windows.installation_date', 'Windows LAPS installation date') },
|
||||
{ label: 'WEF configurations', value: observationFact(snapshot, 'logging.wef.configuration_count', 'WEF configuration count') }
|
||||
],
|
||||
toolContext: [
|
||||
{ label: 'PingCastle engine', value: observationFact(snapshot, 'report.engine_version', 'PingCastle engine version') },
|
||||
{ label: 'PingCastle domain risk', value: numberFact(optionalObservationNumber(snapshot, 'risk.domain') ?? optionalObservationNumber(snapshot, 'risk.global', 'Domain Risk Level')) },
|
||||
{ label: 'Purple Knight AD posture', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.ad.security_posture_score')) },
|
||||
{ label: 'Purple Knight AD IOEs', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.ad.ioes_found')) },
|
||||
{ label: 'Purple Knight AD evaluated', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.ad.evaluated')) },
|
||||
{ label: 'Purple Knight Entra tenant', value: observationFact(snapshot, 'entra.tenant_name', 'Entra tenant') },
|
||||
{ label: 'Purple Knight Entra posture', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.entra.security_posture_score')) },
|
||||
{ label: 'Purple Knight Entra IOEs', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.entra.ioes_found')) },
|
||||
{ label: 'Purple Knight Entra evaluated', value: numberFact(optionalObservationNumber(snapshot, 'purple_knight.entra.evaluated')) },
|
||||
...categoryScores,
|
||||
{ label: 'CIS-CAT benchmark', value: observationFact(snapshot, 'benchmark.name') },
|
||||
{ label: 'CIS-CAT profile', value: observationFact(snapshot, 'benchmark.profile') },
|
||||
{ label: 'CIS-CAT score', value: numberFact(optionalObservationNumber(snapshot, 'benchmark.score')) },
|
||||
...(snapshot?.observations ?? [])
|
||||
.filter(observation => observation.tool.id === 'maester' && ['entra.tenant_id', 'entra.tenant_name', 'report.generated_at', 'report.engine_version', 'maester.duration', 'maester.tests.actual_total', 'maester.runner.host', 'maester.runner.os', 'maester.powershell.version'].includes(observation.property))
|
||||
.map(observation => ({ label: observation.label.startsWith('Maester') ? observation.label : `Maester ${observation.label}`, value: observation.state === 'observed' ? factText(displayValue(observation)) : UNKNOWN_FACT }))
|
||||
],
|
||||
restricted: restrictedSections(snapshot)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Kompakter, quellengestuetzter Verzeichniskontext fuer die erste Leselinie.
|
||||
* Die Werte verwenden dieselben normalisierten Beobachtungen wie Environment
|
||||
* Detail; fehlende Scannerangaben bleiben ausdruecklich unbekannt.
|
||||
*/
|
||||
export function dashboardDomainInformation(snapshot?: EnvironmentSnapshot): DashboardFact[] {
|
||||
const entra = observationFor(snapshot, 'entra.tenant_name')
|
||||
? observationFact(snapshot, 'entra.tenant_name', 'Entra tenant')
|
||||
: observationFact(snapshot, 'entra.configuration_detected', 'Entra ID configuration detected');
|
||||
const dcOperatingSystems = [...new Set((snapshot?.observations ?? [])
|
||||
.filter(observation => observation.state === 'observed'
|
||||
&& observation.property === 'operating_system.name'
|
||||
&& observation.subject.type === 'computer-information.domain-controllers')
|
||||
.map(observation => factText(displayValue(observation)))
|
||||
.filter(value => value !== UNKNOWN_FACT))];
|
||||
return [
|
||||
{ label: 'Domain', value: observationFact(snapshot, 'domain.fqdn', 'Domain') },
|
||||
{ label: 'NetBIOS name', value: observationFact(snapshot, 'domain.netbios_name', 'Netbios Name') },
|
||||
{ label: 'Forest', value: observationFact(snapshot, 'forest.name') },
|
||||
{ label: 'Domains', value: numberFact(dashboardEnvironmentDomainCount(snapshot)) },
|
||||
{ label: 'Domain controllers', value: observationFact(snapshot, 'domain.controller_count', 'DC count') },
|
||||
{ label: 'Domain functional level', value: observationFact(snapshot, 'domain.functional_level', 'Domain Functional Level') },
|
||||
{ label: 'Forest functional level', value: observationFact(snapshot, 'forest.functional_level', 'Forest Functional Level') },
|
||||
{ label: 'DC OS', value: dcOperatingSystems.length ? dcOperatingSystems.join(', ') : UNKNOWN_FACT },
|
||||
{ label: 'Schema version', value: observationFact(snapshot, 'forest.schema_version', 'Schema version') },
|
||||
{ label: 'Domain created', value: observationFact(snapshot, 'domain.created_at', 'Creation date') },
|
||||
{ label: 'Entra ID', value: entra }
|
||||
];
|
||||
}
|
||||
|
||||
export function dashboardObservationNumber(snapshot: EnvironmentSnapshot | undefined, property: string): number | undefined {
|
||||
const observation = snapshot?.observations.find(item => item.property === property && item.state === 'observed');
|
||||
const value = observation?.normalizedValue ?? observation?.originalValue;
|
||||
const number = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(number) ? number : undefined;
|
||||
}
|
||||
|
||||
export function dashboardEnvironmentTableNumber(snapshot: EnvironmentSnapshot | undefined, sectionKey: string, header: RegExp): number | undefined {
|
||||
const values = snapshot?.sections
|
||||
.filter(section => section.sectionKey === sectionKey)
|
||||
.flatMap(section => section.tables.flatMap(table => {
|
||||
const column = table.headers.findIndex(value => header.test(value.trim()));
|
||||
if (column < 0) return [];
|
||||
return table.rows.flatMap(row => {
|
||||
const numeric = Number(row[column]);
|
||||
return Number.isFinite(numeric) ? [numeric] : [];
|
||||
});
|
||||
})) ?? [];
|
||||
return values.length ? values.reduce((sum, value) => sum + value, 0) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zahl der Domaenen. Nennt eine Quelle sie ausdruecklich (Purple Knight:
|
||||
* "AD: number of domains"), gilt diese Angabe; sonst bleibt die Zahl der
|
||||
* verschiedenen beobachteten Domaenennamen die belegbare Untergrenze.
|
||||
*/
|
||||
export function dashboardEnvironmentDomainCount(snapshot: EnvironmentSnapshot | undefined): number | undefined {
|
||||
const declared = dashboardObservationNumber(snapshot, 'domain.count');
|
||||
if (declared !== undefined) return declared;
|
||||
const domains = new Set(snapshot?.observations
|
||||
.filter(item => item.property === 'domain.fqdn' && item.state === 'observed')
|
||||
.map(item => String(item.normalizedValue ?? item.originalValue).trim().toLocaleLowerCase('en-US'))
|
||||
.filter(Boolean));
|
||||
return domains.size || undefined;
|
||||
}
|
||||
|
||||
export function dashboardGpoCount(records: FindingRecord[]): number | undefined {
|
||||
const identifiers = new Set<string>();
|
||||
const pattern = /\{?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}?/gi;
|
||||
records.forEach(record => [record.sourceFile, ...record.fields.map(field => field.value)].filter(Boolean).forEach(value => {
|
||||
String(value).match(pattern)?.forEach(identifier => identifiers.add(identifier.toLocaleLowerCase('en-US')));
|
||||
}));
|
||||
return identifiers.size || undefined;
|
||||
}
|
||||
|
||||
const quantity = (value?: number) => value === undefined ? UNKNOWN_FACT : value.toLocaleString('en-US');
|
||||
|
||||
/** The compact Operating systems tile: group totals first, then the first systems, at most four facts. */
|
||||
export function operatingSystemOverview(groups: OperatingSystemInventoryGroup[]): DashboardFact[] {
|
||||
const summary = groups.filter(group => group.total !== undefined).map(group => ({ label: group.label, value: quantity(group.total) }));
|
||||
const systems = groups.flatMap(group => group.entries).slice(0, Math.max(0, 4 - summary.length))
|
||||
.map(entry => ({ label: entry.name, value: quantity(entry.count) }));
|
||||
return summary.length || systems.length ? [...summary, ...systems] : [{ label: 'Inventory', value: UNKNOWN_FACT }];
|
||||
}
|
||||
|
||||
export const CLOUD_INFORMATION_AREAS: Array<{ id: ClientInformationArea; label: string }> = [
|
||||
{ id: 'cloud-access', label: 'Authentication & CA' },
|
||||
{ id: 'cloud-roles', label: 'Role assignments' },
|
||||
{ id: 'cloud-apps', label: 'App consent' },
|
||||
{ id: 'cloud-sharing', label: 'External access' }
|
||||
];
|
||||
|
||||
/** The Cloud Information tile: concrete reported facts per area, `Not reported` otherwise. */
|
||||
export function cloudInformationTileFacts(snapshot: EnvironmentSnapshot | null | undefined): Array<{ id: ClientInformationArea; label: string; values: string[] }> {
|
||||
const cloudFacts = cloudInformationFacts(snapshot);
|
||||
return CLOUD_INFORMATION_AREAS.map(area => {
|
||||
const values = cloudInformationOverview(cloudFacts, area.id);
|
||||
return { ...area, values: values.length ? values : ['Not reported'] };
|
||||
});
|
||||
}
|
||||
|
||||
export interface ClientInfoOverview {
|
||||
/** The six Data facts in Client Info order. */
|
||||
data: Array<{ label: string; value?: number }>;
|
||||
/** Domain Information without the counts already shown identically in Data. */
|
||||
domainInformation: DashboardFact[];
|
||||
/** The Inventory tile overview without facts already shown in Data or Domain Information. */
|
||||
inventory: DashboardFact[];
|
||||
detail: DashboardEnvironmentDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Client Info overview of one snapshot. `gpoCount` is the processed-backup count when available
|
||||
* (the GUID fallback otherwise); `inferredEntraTenant` is true when loaded Maester results imply a tenant
|
||||
* without a retained tenant identity.
|
||||
*/
|
||||
export function clientInfoOverview(snapshot: EnvironmentSnapshot | undefined, options: { gpoCount?: number; inferredEntraTenant?: boolean } = {}): ClientInfoOverview {
|
||||
const detail = dashboardEnvironmentDetail(snapshot);
|
||||
const domainInformation = dashboardDomainInformation(snapshot);
|
||||
const userAccounts = dashboardEnvironmentTableNumber(snapshot, 'sectionUserInformation', /^nb user accounts$/i);
|
||||
const computerAccounts = dashboardEnvironmentTableNumber(snapshot, 'sectionComputerInformation', /^nb computer accounts$/i);
|
||||
const domainControllers = dashboardObservationNumber(snapshot, 'domain.controller_count');
|
||||
const domainCount = dashboardEnvironmentDomainCount(snapshot);
|
||||
// Give repeated overview facts one home; Results retains the full category.
|
||||
// Compare only the same named fact, never unrelated facts with equal values.
|
||||
const countOverview = new Map([
|
||||
['Domains', numberFact(domainCount)],
|
||||
['Domain controllers', numberFact(domainControllers)],
|
||||
['User accounts', numberFact(userAccounts)],
|
||||
['Computer objects', numberFact(computerAccounts)]
|
||||
]);
|
||||
const alreadyInData = (item: DashboardFact) => countOverview.get(item.label) === item.value;
|
||||
const domainOverview = domainInformation.filter(item => !alreadyInData(item));
|
||||
const inventoryOverview = detail.inventory.filter(item =>
|
||||
!alreadyInData(item) && !domainOverview.some(shown => shown.label === item.label && shown.value === item.value));
|
||||
const entraTenants = dashboardEntraTenantCount(snapshot)
|
||||
?? dashboardObservationNumber(snapshot, 'entra.configuration_detected')
|
||||
?? (options.inferredEntraTenant ? 1 : undefined);
|
||||
return {
|
||||
data: [
|
||||
{ label: 'AD Domains', value: domainCount },
|
||||
{ label: 'Entra Tenant', value: entraTenants },
|
||||
{ label: 'User Accounts', value: userAccounts },
|
||||
{ label: 'Computer Objects', value: computerAccounts },
|
||||
{ label: 'Domain Controllers', value: domainControllers },
|
||||
{ label: 'GPOs', value: options.gpoCount }
|
||||
],
|
||||
domainInformation: domainOverview,
|
||||
inventory: inventoryOverview,
|
||||
detail
|
||||
};
|
||||
}
|
||||
|
|
@ -56,27 +56,10 @@ const TILE_ROW_HEIGHT = TILE_MIN_HEIGHT + TILE_GAP;
|
|||
// Enthielten Violet und Purple; beide ersetzt, weil violett als UI-Rolle
|
||||
// vollstaendig entfernt ist (Nutzerentscheidung 2026-09-20, AGENTS.md
|
||||
// "UI segments and tiles").
|
||||
const resultColors = [
|
||||
'#0e7490', // Cyan
|
||||
'#059669', // Emerald
|
||||
'#dc2626', // Red
|
||||
'#2563eb', // Blue
|
||||
'#ea580c', // Orange
|
||||
'#0d9488', // Teal
|
||||
'#be185d', // Rose
|
||||
'#16a34a', // Green
|
||||
'#d946ef', // Magenta
|
||||
'#b45309', // Amber
|
||||
'#4f46e5', // Indigo
|
||||
'#ca8a04', // Gold
|
||||
'#0369a1', // Sky Blue
|
||||
'#78350f', // Brown
|
||||
'#65a30d', // Lime
|
||||
'#475569' // Slate
|
||||
];
|
||||
|
||||
interface OpenResultWindow {
|
||||
recordId: string;
|
||||
finding: FindingRecord;
|
||||
/** Werkzeugprofil des einzelnen Findings, auch in generischen Sammelblaettern. */
|
||||
toolId?: string;
|
||||
title: string;
|
||||
|
|
@ -91,12 +74,16 @@ interface OpenResultWindow {
|
|||
detached: boolean;
|
||||
}
|
||||
|
||||
import { resultColors } from '../linked-results/result-colors';
|
||||
import { RelatedFindingsFocus, useRelatedFindings } from '../related-findings/RelatedFindings';
|
||||
import { RawDataView } from './RawDataView';
|
||||
import { measureNavigationStep } from '../../app/navigation-diagnostics';
|
||||
|
||||
export function DatasetViewer() {
|
||||
const app = useApp();
|
||||
const { dataset, activeSheet, view, setView, search, titleColumn, selectedRecordId, setSelectedRecord, reviews, updateReview } = app;
|
||||
const related = useRelatedFindings();
|
||||
const { dataset, activeSheet: sourceSheet, view, setView, search, titleColumn, selectedRecordId, setSelectedRecord, reviews, updateReview } = app;
|
||||
const activeSheet = related?.selection?.sheet ?? sourceSheet;
|
||||
const [resultWindows, setResultWindows] = useState<OpenResultWindow[]>([]);
|
||||
const [clusterPos, setClusterPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const [clusterWidth, setClusterWidth] = useState<number>(760);
|
||||
|
|
@ -276,6 +263,7 @@ export function DatasetViewer() {
|
|||
...prepareForNewClusterWindow(current),
|
||||
{
|
||||
recordId: record.id,
|
||||
finding: record,
|
||||
toolId: recordToolId,
|
||||
title: sheets.length ? `Results (${total.toLocaleString('en-US')}) \u2013 ${recordTitle(record)}` : `Result \u2013 ${recordTitle(record)}`,
|
||||
sheets,
|
||||
|
|
@ -332,6 +320,7 @@ export function DatasetViewer() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<RelatedFindingsFocus/>
|
||||
<span ref={parkingAnchorRef} className="results-parking-anchor" aria-hidden="true" />
|
||||
{!records.length ? (
|
||||
<FilterEmptyState className="work-filter-empty" title="No results match the current filters"
|
||||
|
|
@ -339,7 +328,7 @@ export function DatasetViewer() {
|
|||
The selected sheet contains {activeSheet.analysis.records.length.toLocaleString('en-US')} saved results. GPO comparisons awaiting review or re-evaluation are available under Result Status: All.
|
||||
</FilterEmptyState>
|
||||
) : view === 'columns' ? (
|
||||
<div className="viewer-scroll tile-view-scroll" ref={setTileScrollNode}><div className="tile-grid" style={tileGridStyle}>
|
||||
<div className="viewer-scroll tile-view-scroll" data-group-scroll="work-tiles" ref={setTileScrollNode}><div className="tile-grid" style={tileGridStyle}>
|
||||
{tileWindow.paddingTop > 0 && <div aria-hidden="true" style={tileTopSpacerStyle} />}
|
||||
{records.slice(tileWindow.startIndex * tileColumns, tileWindow.endIndex * tileColumns).map(record => (
|
||||
<TileCard key={record.id} record={record} title={recordTitle(record)} bookmarked={Boolean(reviews[record.id]?.include)} resultColor={resultColor(record.id)} toolId={activeSheet?.toolId ?? dataset?.profileId} search={search} onOpen={() => {
|
||||
|
|
@ -351,7 +340,7 @@ export function DatasetViewer() {
|
|||
</div></div>
|
||||
) : view === 'records' && selected ? (
|
||||
<div className="records-layout">
|
||||
<div className="record-list" ref={recordWindow.containerRef}>
|
||||
<div className="record-list" data-group-scroll="work-list" ref={recordWindow.containerRef}>
|
||||
{recordWindow.paddingTop > 0 && <div aria-hidden="true" style={recordTopSpacerStyle} />}
|
||||
{records.slice(recordWindow.startIndex, recordWindow.endIndex).map(record => <FindingCard key={record.id} record={record} title={recordTitle(record)} bookmarked={Boolean(reviews[record.id]?.include)} selected={record.id === selected.id} toolId={activeSheet?.toolId ?? dataset?.profileId} search={search} onOpen={() => setSelectedRecord(record.id)} />)}
|
||||
{recordWindow.paddingBottom > 0 && <div aria-hidden="true" style={recordBottomSpacerStyle} />}
|
||||
|
|
@ -380,7 +369,7 @@ export function DatasetViewer() {
|
|||
<DataTable key={activeSheet.id} sheet={activeSheet} records={records} search={search} />
|
||||
) : (
|
||||
<RawDataView records={records} raw={activeSheet.raw || dataset.rawText}
|
||||
filtered={Boolean(search || app.actionOnly || app.statusFilter !== 'all' || Object.keys(app.facetFilters).length)} search={search}/>
|
||||
filtered={Boolean(related?.selection || search || app.actionOnly || app.statusFilter !== 'all' || Object.keys(app.facetFilters).length)} search={search}/>
|
||||
)}
|
||||
|
||||
{clusterDocked && dockSlot && createPortal(
|
||||
|
|
@ -434,7 +423,8 @@ export function DatasetViewer() {
|
|||
title={item.title}
|
||||
sheets={item.sheets}
|
||||
toolId={item.toolId}
|
||||
outcome={windowRecord ? resultOutcomeFor(windowRecord, toolId) : undefined}
|
||||
outcome={resultOutcomeFor(windowRecord ?? item.finding, toolId)}
|
||||
finding={windowRecord ?? item.finding}
|
||||
sections={item.sections}
|
||||
color={item.color}
|
||||
zIndex={item.zIndex}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,24 @@
|
|||
import { memo, useMemo } from 'react';
|
||||
import type { FindingRecord } from '../../domain/finding';
|
||||
import { highlightText } from '../../components/HighlightText/highlight-text';
|
||||
import { RelatedFindingRing, useRelatedFindings } from '../related-findings/RelatedFindings';
|
||||
|
||||
/** Building the complete text belongs exclusively to the Raw Data view. */
|
||||
export const RawDataView = memo(function RawDataView({ records, raw, filtered, search }: {
|
||||
records: FindingRecord[]; raw: string; filtered: boolean; search: string;
|
||||
}) {
|
||||
const related = useRelatedFindings();
|
||||
const text = useMemo(() => filtered
|
||||
? records.map(record => [`# Record ${record.index + 1}`,
|
||||
...record.fields.filter(field => field.value).map(field => `${field.label}: ${field.value}`)].join('\n')).join('\n\n')
|
||||
: raw, [filtered, raw, records]);
|
||||
return <div className="viewer-scroll"><pre className="raw-view">{highlightText(text, search)}</pre></div>;
|
||||
return <div className="viewer-scroll" data-group-scroll="work-raw">
|
||||
{!filtered && related?.groups.size ? <div className="raw-related-findings" aria-label="Related findings">
|
||||
{records.filter(record => related.groups.has(record.id)).map(record => <div className="related-raw-record" key={record.id}><span>{record.title}</span><RelatedFindingRing record={record}/></div>)}
|
||||
</div> : null}
|
||||
{filtered && related?.groups.size ? records.map(record => <div className="related-raw-record" key={record.id}>
|
||||
<RelatedFindingRing record={record}/>
|
||||
<pre className="raw-view">{highlightText([`# Record ${record.index + 1}`, ...record.fields.filter(field => field.value).map(field => `${field.label}: ${field.value}`)].join('\n'), search)}</pre>
|
||||
</div>) : <pre className="raw-view">{highlightText(text, search)}</pre>}
|
||||
</div>;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ import { PolicyBaselineComparison } from './PolicyBaselineComparison';
|
|||
import { PolicyProblemSummary } from './PolicyProblemSummary';
|
||||
import { FindingFirstRead } from './FindingFirstRead';
|
||||
import { FindingContext, FindingContextBadges } from './FindingContext';
|
||||
import { useContextClusterIndex } from '../context-layer/use-context-cluster-index';
|
||||
import { numberedClusterLabel, publishedClusterColour } from '../sidebar/context-cluster-display';
|
||||
import { FindingWorkPackage } from '../../remediation/FindingWorkPackage';
|
||||
import { RelatedFindingRing, useRelatedFindings } from '../related-findings/RelatedFindings';
|
||||
import { findingFirstRead, firstReadConsumedFields, firstReadFieldRank, firstReadImpactSplit, firstReadSectionSplit } from './first-read';
|
||||
import { policyProblemSummary } from '../../profiles/group-policy/problem-summary';
|
||||
import { normalizeSeverity, mitreBadgeLabel } from '../../profiles/purple-knight/presentation';
|
||||
|
|
@ -383,7 +387,19 @@ export function policyAnalyzerSearchKeys(record: FindingRecord, title: string, t
|
|||
return keys;
|
||||
}
|
||||
|
||||
function MitreBadgeDisclosure({ fields, search }: { fields: RecordField[]; search: string }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return <div className="mitre-disclosure">
|
||||
<button className="mitre-disclosure-toggle" type="button" aria-label="MITRE ATT&CK and D3FEND information"
|
||||
title={expanded ? 'Hide MITRE information' : 'Show MITRE information'} aria-expanded={expanded}
|
||||
onClick={() => setExpanded(value => !value)}><TriangleIcon direction={expanded ? 'down' : 'right'}/></button>
|
||||
{expanded && <div className="mitre-line">{fields.map(field => <span className="mitre-badge" key={field.label}>{mitreBadgeLabel(field.label)}: {highlightText(field.value, search)}</span>)}</div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function FindingReader({ record, title, review: storedReview, previousPolicyReviews, linkedSheets, resultReferences, search, position, recordCount, toolId, onPrevious, onNext, onReview, onRiskAcceptance, onResults, resultsOpen, resultColor }: Props) {
|
||||
const relatedSelection = useRelatedFindings()?.selection;
|
||||
const contextClusters = useContextClusterIndex();
|
||||
const resultButtonStyle = resultsOpen && resultColor ? ({ '--result-color': resultColor } as CSSProperties) : undefined;
|
||||
const [openLinkedResultsFor, setOpenLinkedResultsFor] = useState('');
|
||||
const [riskAcceptanceFor, setRiskAcceptanceFor] = useState('');
|
||||
|
|
@ -409,7 +425,7 @@ export function FindingReader({ record, title, review: storedReview, previousPol
|
|||
const riskState = review.riskAcceptance ? riskAcceptanceState(review.riskAcceptance, record.findingOccurrenceId) : undefined;
|
||||
|
||||
return (
|
||||
<article className="finding-reader">
|
||||
<article className="finding-reader" data-group-scroll="work-reader">
|
||||
<nav className="reader-nav" aria-label="Record navigation">
|
||||
<div>
|
||||
<button type="button" onClick={onPrevious} disabled={position <= 0} aria-label="Previous record"><TriangleIcon direction="left" /></button>
|
||||
|
|
@ -418,6 +434,7 @@ export function FindingReader({ record, title, review: storedReview, previousPol
|
|||
<span title={record.sourceFile ?? record.sourceSheet}>Inbox / Record / {sourceFileLabel(record.sourceFile) || record.sourceSheet || 'Finding'}</span>
|
||||
</nav>
|
||||
<div className="reader-heading">
|
||||
<RelatedFindingRing record={record} inline/>
|
||||
<span className={`reader-title-hex severity-${severity}`} aria-hidden="true" />
|
||||
<div>
|
||||
<h2><PathText value={title} search={search}/></h2>
|
||||
|
|
@ -440,7 +457,7 @@ export function FindingReader({ record, title, review: storedReview, previousPol
|
|||
<span className="status-badge" data-policy-scope key="policy-scope">Scope: {highlightText(field.value, search)}</span>
|
||||
))}
|
||||
</div>
|
||||
{mitre.length > 0 && <div className="mitre-line">{mitre.map(field => <span className="mitre-badge" key={field.label}>{mitreBadgeLabel(field.label)}: {highlightText(field.value, search)}</span>)}</div>}
|
||||
{mitre.length > 0 && <MitreBadgeDisclosure key={record.id} fields={mitre} search={search}/>}
|
||||
</div>
|
||||
<button className={`btn accepted-risk-trigger${riskState === 'Draft' ? ' is-draft' : ''}`} data-risk-acceptance-state={riskState?.toLowerCase() ?? 'not-recorded'} type="button" aria-haspopup="dialog" onClick={() => setRiskAcceptanceFor(record.id)}>
|
||||
Accepted Risk{riskState
|
||||
|
|
@ -449,17 +466,23 @@ export function FindingReader({ record, title, review: storedReview, previousPol
|
|||
</button>
|
||||
<div className="reader-heading-actions">
|
||||
<FindingContextBadges record={record} isPolicySetting={resolvedToolId === 'group-policy'}/>
|
||||
<div className="reader-primary-actions" role="group" aria-label="Finding actions">
|
||||
{webSearch && <a className="btn" href={webSearch.url} target="_blank" rel="noopener noreferrer" referrerPolicy="no-referrer" data-search-specificity={webSearch.specificity} title={`${webSearch.specificity === 'topic' ? 'Topic search — ' : ''}${webSearch.query}`}>AI search</a>}
|
||||
<span className="bookmark-label">Bookmark</span>
|
||||
<button className="bookmark-btn" type="button" aria-pressed={review.include} onClick={() => onReview({ include: !review.include })}><BookmarkIcon filled={review.include} /></button>
|
||||
<button className="bookmark-btn" type="button" aria-label="Bookmark" title="Bookmark" aria-pressed={review.include} onClick={() => onReview({ include: !review.include })}><BookmarkIcon filled={review.include} /></button>
|
||||
{/* Gleiche Kennzeichnung wie auf den Kacheln: ist das Ergebnisfenster
|
||||
zu diesem Fund offen, traegt die Schaltflaeche den Fensterton. */}
|
||||
<button className={`btn results-detail-btn results-btn${resultsOpen ? ' is-open' : ''}`} style={resultButtonStyle} type="button" disabled={!onResults} onClick={onResults}>Results</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{riskAcceptanceFor === record.id && <RiskAcceptanceDialog key={record.id} title={title} occurrenceId={record.findingOccurrenceId} value={review.riskAcceptance} onSave={onRiskAcceptance} onClose={() => setRiskAcceptanceFor('')} />}
|
||||
<section className="reader-summary">
|
||||
{sourceRuleId && <div data-source-rule-id><h4>Source Rule ID</h4><p><code className="mono">{highlightText(sourceRuleId.value, search)}</code></p></div>}
|
||||
<div className="reader-source-rule" data-source-rule-id={sourceRuleId ? true : undefined}>
|
||||
{sourceRuleId && <div><h4>Source Rule ID</h4><p><code className="mono">{highlightText(sourceRuleId.value, search)}</code></p></div>}
|
||||
<FindingWorkPackage record={record} visibleRecords={relatedSelection?.sheet?.analysis.records}
|
||||
clusterLabels={Object.fromEntries((contextClusters?.publication.clusters ?? []).map(cluster => [cluster.id, numberedClusterLabel(cluster.id, cluster.label, contextClusters?.publication.clusters)]))}
|
||||
clusterSeverities={Object.fromEntries((contextClusters?.publication.clusters ?? []).map(cluster => [cluster.id, publishedClusterColour(cluster)]))}/>
|
||||
</div>
|
||||
{Boolean(previousPolicyReviews?.length) && <details className="policy-previous-reviews">
|
||||
<summary>Previous baseline-specific reviews ({previousPolicyReviews!.length})</summary>
|
||||
<p>These earlier notes remain attached to their original comparisons. Different decisions and risk acceptances are not automatically applied to this combined setting.</p>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
import { useEffect, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from 'react';
|
||||
import type { DatasetSheet } from '../../domain/dataset';
|
||||
import type { FindingRecord } from '../../domain/finding';
|
||||
import type { CanonicalOutcome } from '../../domain/canonical-model';
|
||||
import { LinkedResults } from './LinkedResults';
|
||||
import { promotedResultSheetTitlesFor, resultSheetRowHighlightedFor, resultSheetRowToneFor, resultSheetTitleFor, showResultSheetTitlesFor, type ResultSection } from './result-message';
|
||||
import { RelatedFindingRing } from '../related-findings/RelatedFindings';
|
||||
import { promotedResultSheetTitlesFor, resultSectionRowClassName, resultSheetRowHighlightedFor, resultSheetRowToneFor, resultSheetTitleFor, showResultSheetTitlesFor, type ResultSection } from './result-message';
|
||||
import { BookmarkIcon } from '../../components/BookmarkIcon';
|
||||
import { TriangleIcon } from '../../components/TriangleIcon';
|
||||
import { ResultTableSelectionProvider } from '../../components/ResizableResultTable/ResultTableSelection';
|
||||
import { buildResultsWindowExcel, RESULTS_EXCEL_MEDIA_TYPE } from '../../exports/results-window-excel';
|
||||
import { downloadBrowserBlob } from '../../platform/browser/browser-download-adapter';
|
||||
import { resultsExcelContent, resultsFindingContext } from './results-excel-content';
|
||||
import { RESULT_CLUSTER_ROW_HEIGHT, RESULT_CLUSTER_VIEWPORT_MARGIN, resultClusterCoverHeight } from './result-window-layout';
|
||||
import { useFloatingWindowLayer } from '../../components/use-floating-window-layer';
|
||||
|
||||
const MIN_WIDTH = 440;
|
||||
const MIN_HEIGHT = 220;
|
||||
|
|
@ -43,6 +50,7 @@ interface Props {
|
|||
toolId?: string;
|
||||
/** Fachliches Ergebnis des Findings steuert die Farbe ausgewerteter Belegzeilen. */
|
||||
outcome?: CanonicalOutcome;
|
||||
finding?: FindingRecord;
|
||||
/** Ergebnistexte des Datensatzes (siehe `resultSectionsFor`). */
|
||||
sections?: ResultSection[];
|
||||
color: string;
|
||||
|
|
@ -105,18 +113,55 @@ export function ClusterSlotIcon() {
|
|||
);
|
||||
}
|
||||
|
||||
function ResultsShareIcon({ selective = false }: { selective?: boolean }) {
|
||||
return <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
||||
<path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4"/>{selective && <path d="m1 20 2 2 4-4"/>}
|
||||
</svg>;
|
||||
}
|
||||
|
||||
export function FloatingResultsWindow({
|
||||
id, title, sheets, toolId, outcome, sections = [], color, zIndex, offset, search, onActivate, onClose,
|
||||
id, title, sheets, toolId, outcome, finding, sections = [], color, zIndex, offset, search, onActivate, onClose,
|
||||
onOpenRecord, actionLabel = 'To Report', actionTitle = 'Jump to this finding’s record',
|
||||
minimized = false, dimmed = false, onToggleMinimize, detached = false, onDetach, clusterIndex, isClusterBottom = false, clusterCount = 0,
|
||||
clusterPosition, clusterWidth, isClusterTop = false, onMoveCluster, onResizeClusterWidth,
|
||||
bookmarked = false, onToggleBookmark, allClusterBookmarked = false, onBookmarkCluster, onCloseCluster, onDockCluster
|
||||
}: Props) {
|
||||
const titleParts = splitTitle(title);
|
||||
const { layer, activate } = useFloatingWindowLayer(zIndex);
|
||||
const showLinkedSheetTitles = showResultSheetTitlesFor(toolId);
|
||||
const promotedTitles = promotedResultSheetTitlesFor(toolId, sections, sheets);
|
||||
const visibleSections = sections.filter((_, index) => !promotedTitles.consumedSectionIndexes.has(index));
|
||||
const windowRef = useRef<HTMLElement>(null);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedTables, setSelectedTables] = useState<ReadonlySet<string>>(new Set());
|
||||
const [exportError, setExportError] = useState('');
|
||||
const toggleTable = (tableId: string) => setSelectedTables(previous => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(tableId)) next.delete(tableId); else next.add(tableId);
|
||||
return next;
|
||||
});
|
||||
useEffect(() => { setSelectedTables(new Set()); }, [id]);
|
||||
useEffect(() => {
|
||||
const available = new Set([...bodyRef.current?.querySelectorAll<HTMLTableElement>('table') ?? []].map(table => table.dataset.resultTableId));
|
||||
setSelectedTables(previous => {
|
||||
const retained = new Set([...previous].filter(tableId => available.has(tableId)));
|
||||
return retained.size === previous.size ? previous : retained;
|
||||
});
|
||||
}, [sheets, sections]);
|
||||
const exportExcel = (selective: boolean) => {
|
||||
if (!bodyRef.current) return;
|
||||
try {
|
||||
const content = resultsExcelContent(bodyRef.current, titleParts.name || title, sections, sheets);
|
||||
content.findingContext = resultsFindingContext(finding, toolId, sheets, outcome);
|
||||
const bytes = buildResultsWindowExcel(content, selective ? selectedTables : undefined);
|
||||
const stem = (titleParts.name || id).replace(/[<>:"/\\|?*\x00-\x1f]/g, '-').slice(0, 100) || 'result';
|
||||
downloadBrowserBlob(new Blob([bytes], { type: RESULTS_EXCEL_MEDIA_TYPE }), `assessor-results-${stem}-${selective ? 'selected' : 'all'}.xlsx`);
|
||||
setExportError('');
|
||||
} catch {
|
||||
setExportError('Excel export could not be completed. No partial file was downloaded.');
|
||||
}
|
||||
};
|
||||
const dragRef = useRef<{ pointerId: number; lastX: number; lastY: number; startLeft: number; startTop: number; detachedDuringDrag: boolean } | null>(null);
|
||||
const resizeRef = useRef<{ pointerId: number; dir: ResizeDirection; startX: number; startY: number; startLeft: number; startTop: number; startWidth: number; startHeight: number } | null>(null);
|
||||
const restoredByTitleClickRef = useRef(false);
|
||||
|
|
@ -387,14 +432,14 @@ export function FloatingResultsWindow({
|
|||
...(clusterCoverHeight > 0 ? { minHeight: clusterCoverHeight } : {}),
|
||||
maxWidth: maximized ? maximizedWidth : availableWidth,
|
||||
maxHeight: maximized ? maximizedHeight : Math.max(availableHeight, clusterCoverHeight),
|
||||
zIndex: minimized ? zIndex + (clusterIndex ?? 0) : zIndex + 300,
|
||||
zIndex: layer,
|
||||
'--result-color': color
|
||||
} as unknown as CSSProperties;
|
||||
|
||||
// Der Fundname steht zusaetzlich zur Kennzeichnung in der Kopfleiste, damit
|
||||
// er auch im minimierten Zustand (Rumpf ausgeblendet) sichtbar bleibt.
|
||||
return (
|
||||
<section ref={windowRef} className={`results-window${minimized ? ' is-minimized' : ''}${maximized ? ' is-maximized' : ''}${isClusterBottom ? ' is-cluster-bottom' : ''}${dimmed ? ' is-dimmed' : ''}`} style={style} role="dialog" aria-modal="false" aria-label={title} onPointerDown={onActivate}>
|
||||
<section ref={windowRef} className={`results-window${minimized ? ' is-minimized' : ''}${maximized ? ' is-maximized' : ''}${isClusterBottom ? ' is-cluster-bottom' : ''}${dimmed ? ' is-dimmed' : ''}`} style={style} role="dialog" aria-modal="false" aria-label={title} onPointerDownCapture={activate} onFocusCapture={activate} onPointerDown={onActivate}>
|
||||
{isClusterTop && (
|
||||
<div className="results-cluster-bar" onPointerDown={startDrag} onPointerMove={moveDrag} onPointerUp={stopDrag} onPointerCancel={stopDrag}>
|
||||
<button className="results-cluster-slot" type="button" onClick={onDockCluster} disabled={!onDockCluster} title="Results in die Filterzeile einklappen" aria-label="Results in die Filterzeile einklappen" aria-expanded="true"><ClusterSlotIcon /></button>
|
||||
|
|
@ -440,7 +485,7 @@ export function FloatingResultsWindow({
|
|||
)}
|
||||
<span>{resultCount(titleParts.badge)}</span>
|
||||
</span>
|
||||
<span className="results-window-name">{titleParts.name}</span>
|
||||
<span className="results-window-name" title={titleParts.name}>{titleParts.name}</span>
|
||||
</h2>
|
||||
<div className="results-window-actions">
|
||||
{onToggleBookmark && (
|
||||
|
|
@ -454,11 +499,16 @@ export function FloatingResultsWindow({
|
|||
<BookmarkIcon filled={bookmarked} size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button className="btn results-window-export results-window-export-all" type="button" onClick={() => exportExcel(false)} title="Export entire Results to Excel" aria-label="Export entire Results to Excel"><ResultsShareIcon /></button>
|
||||
<button className="btn results-window-export results-window-export-selected" type="button" onClick={() => exportExcel(true)} disabled={!selectedTables.size} title="Export selected tables to Excel" aria-label="Export selected tables to Excel"><ResultsShareIcon selective /></button>
|
||||
{onOpenRecord && <button className="btn results-window-action" type="button" onClick={onOpenRecord} title={actionTitle}>{actionLabel}</button>}
|
||||
<span className="results-window-related-slot">{finding && <RelatedFindingRing record={finding} inline/>}</span>
|
||||
{onClose && <button className="results-window-close" type="button" onClick={onClose} title="Close" aria-label="Close results window"><svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M6 6l12 12M18 6 6 18" /></svg></button>}
|
||||
</div>
|
||||
</header>
|
||||
<div className="results-window-body"><LinkedResults sheets={sheets} search={search} heading={titleParts.name} sections={visibleSections} showSheetTitles={showLinkedSheetTitles} sheetTitle={sheet => resultSheetTitleFor(toolId, sheet.name)} sheetSubtitle={sheet => promotedTitles.tableTitles.get(sheet.id)} rowHighlighted={record => resultSheetRowHighlightedFor(toolId, record)} rowTone={record => resultSheetRowToneFor(toolId, record, outcome)} resizableTables={tablesResizable} /></div>
|
||||
<div className="results-window-body" ref={bodyRef}>
|
||||
{exportError && <p role="alert" className="results-export-error" data-export-exclude>{exportError}</p>}
|
||||
<ResultTableSelectionProvider value={{ selected: selectedTables, toggle: toggleTable }}><LinkedResults sheets={sheets} search={search} heading={titleParts.name} sections={visibleSections} showSheetTitles={showLinkedSheetTitles} sheetTitle={sheet => resultSheetTitleFor(toolId, sheet.name)} sheetSubtitle={sheet => promotedTitles.tableTitles.get(sheet.id)} rowHighlighted={record => resultSheetRowHighlightedFor(toolId, record)} rowTone={(record, sheet) => resultSheetRowToneFor(toolId, record, outcome, { finding, sheet })} sectionRowClassName={(headers, cells) => resultSectionRowClassName(toolId, headers, cells)} resizableTables={tablesResizable} /></ResultTableSelectionProvider></div>
|
||||
{minimized && onResizeClusterWidth ? (
|
||||
<>
|
||||
<span className="results-window-resize results-window-resize-w" onPointerDown={beginClusterResize('w')} onPointerMove={moveResize} onPointerUp={stopResize} onPointerCancel={stopResize} />
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ function highlighted(value: string, search: string): ReactNode {
|
|||
* Tabellenform mit klebendem Kopf und Unterstreichungen und sahen dadurch je
|
||||
* nach Werkzeug unterschiedlich aus.
|
||||
*/
|
||||
export function LinkedResults({ sheets, search = '', heading, sections = [], showSheetTitles = true, sheetTitle, sheetSubtitle, rowHighlighted, rowTone, resizableTables = false }: { sheets: DatasetSheet[]; search?: string; heading?: string; sections?: ResultSection[]; showSheetTitles?: boolean; sheetTitle?: (sheet: DatasetSheet) => string | undefined; sheetSubtitle?: (sheet: DatasetSheet) => string | undefined; rowHighlighted?: (record: DatasetSheet['analysis']['records'][number]) => boolean; rowTone?: (record: DatasetSheet['analysis']['records'][number]) => ResultSheetRowTone | undefined; resizableTables?: boolean }) {
|
||||
export function LinkedResults({ sheets, search = '', heading, sections = [], showSheetTitles = true, sheetTitle, sheetSubtitle, rowHighlighted, rowTone, sectionRowClassName, resizableTables = false }: { sheets: DatasetSheet[]; search?: string; heading?: string; sections?: ResultSection[]; showSheetTitles?: boolean; sheetTitle?: (sheet: DatasetSheet) => string | undefined; sheetSubtitle?: (sheet: DatasetSheet) => string | undefined; rowHighlighted?: (record: DatasetSheet['analysis']['records'][number]) => boolean; rowTone?: (record: DatasetSheet['analysis']['records'][number], sheet: DatasetSheet) => ResultSheetRowTone | undefined; sectionRowClassName?: (headers: string[], cells: string[]) => string | undefined; resizableTables?: boolean }) {
|
||||
if (!sheets.length && !sections.length) return <div className="linked-results-empty">No matching result file found automatically.</div>;
|
||||
const variantComparison = sheets.find(sheet => sheet.resultVariantComparison)?.resultVariantComparison;
|
||||
return (
|
||||
|
|
@ -82,7 +82,7 @@ export function LinkedResults({ sheets, search = '', heading, sections = [], sho
|
|||
<h4 className="result-block__title">{section.title}</h4>
|
||||
{section.diagnostic
|
||||
? <MaesterErrorResult error={section.diagnostic} search={search} />
|
||||
: <RichTextValue value={section.value} search={search} resizableTables={resizableTables} />}
|
||||
: <RichTextValue value={section.value} search={search} resizableTables={resizableTables} tableRowClassName={sectionRowClassName} />}
|
||||
</section>
|
||||
))}
|
||||
{variantComparison && <VariantComparisonResult comparison={variantComparison} search={search} />}
|
||||
|
|
@ -98,9 +98,9 @@ export function LinkedResults({ sheets, search = '', heading, sections = [], sho
|
|||
{displaySubtitle && <h5 className="result-block__subtitle">{displaySubtitle}</h5>}
|
||||
{sheet.resultPreamble && <p className="result-block__preamble"><PathText value={sheet.resultPreamble} search={search} /></p>}
|
||||
<div className="rich-text-table-wrap">
|
||||
<ResizableResultTable enabled={resizableTables} headers={headers} className={`rich-text-table${sheet.resultEmphasisColumns ? ' result-evidence-table' : ''}`}>
|
||||
<ResizableResultTable enabled={resizableTables} headers={headers} sourceSheetId={sheet.id} className={`rich-text-table${sheet.resultEmphasisColumns ? ' result-evidence-table' : ''}`}>
|
||||
{sheet.analysis.records.map(record => {
|
||||
const tone = rowTone?.(record);
|
||||
const tone = rowTone?.(record, sheet);
|
||||
return <tr key={record.id} className={tone ? `rich-text-table-row--${tone}` : record.highlighted || rowHighlighted?.(record) ? 'rich-text-table-row--highlight' : undefined}>{headers.map(header => {
|
||||
const value = record.fields.find(field => field.label === header)?.value ?? '';
|
||||
return <td key={`${record.id}-${header}`} className={sheet.resultEmphasisColumns?.includes(header) ? 'result-evidence-difference' : undefined}><PathText value={value} search={search} label={header} /></td>;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import type { ResultVariantComparison, ResultVariantSource } from '../../domain/dataset';
|
||||
import { PathText } from '../../components/PathText/PathText';
|
||||
import { ResizableResultTable } from '../../components/ResizableResultTable/ResizableResultTable';
|
||||
|
||||
function highlighted(value: string, search: string): ReactNode {
|
||||
if (!search) return value;
|
||||
|
|
@ -76,12 +77,11 @@ export function VariantComparisonResult({ comparison, search }: { comparison: Re
|
|||
<h4>{duplicate ? 'Observed value' : 'Observed variants'}</h4>
|
||||
<p>{duplicate ? 'The following value and type were found in every listed source.' : 'The following values or types were found for the affected setting.'}</p>
|
||||
<div className="variant-comparison__table-wrap">
|
||||
<table className="variant-comparison__table">
|
||||
<thead><tr>{!duplicate && <th>Variant</th>}<th>Value</th><th>Type</th><th>Evidence</th></tr></thead>
|
||||
<tbody>{comparison.variants.map(variant => <tr key={variant.label} data-baseline-state={variant.baselineState ?? 'unassessed'}>
|
||||
<ResizableResultTable className="variant-comparison__table" headers={[...(!duplicate ? ['Variant'] : []), 'Value', 'Type', 'Evidence']}>
|
||||
{comparison.variants.map(variant => <tr key={variant.label} data-baseline-state={variant.baselineState ?? 'unassessed'}>
|
||||
{!duplicate && <th scope="row">{variant.label}</th>}
|
||||
{/* Red: differs from every current baseline variant. Green: matches every one. Otherwise neutral. */}
|
||||
<td className="variant-comparison__value">{variant.baselineState
|
||||
<td className="variant-comparison__value" data-export-value={variant.value}>{variant.baselineState
|
||||
? <span className={`policy-value-mark is-${variant.baselineState}`}><DisplayValue value={variant.value} search={search} /></span>
|
||||
: <DisplayValue value={variant.value} search={search} />}
|
||||
{variant.baselineState && <span className="variant-comparison__value-note">{variant.baselineState === 'deviating'
|
||||
|
|
@ -89,8 +89,8 @@ export function VariantComparisonResult({ comparison, search }: { comparison: Re
|
|||
<td>{highlighted(variant.typeLabel, search)}</td>
|
||||
<td><span>{variant.sources.length} {variant.sources.length === 1 ? 'source' : 'sources'}</span>
|
||||
<SourceList sources={variant.sources} search={search} /></td>
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
</tr>)}
|
||||
</ResizableResultTable>
|
||||
</div>
|
||||
</section>
|
||||
<TechnicalSources comparison={comparison} search={search} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
/** Shared identity palette for Results windows and related-finding groups. */
|
||||
export const resultColors = [
|
||||
'#0e7490', '#059669', '#dc2626', '#2563eb', '#ea580c', '#0d9488', '#be185d', '#16a34a',
|
||||
'#d946ef', '#b45309', '#4f46e5', '#ca8a04', '#0369a1', '#78350f', '#65a30d', '#475569'
|
||||
];
|
||||
|
|
@ -4,6 +4,8 @@ import type { CanonicalOutcome } from '../../domain/canonical-model';
|
|||
import { outcomeForStatusValue, toolIdForRecord } from '../../importers/status-normalization';
|
||||
import { toolProfileDefinitions } from '../../profiles/profile-definition-catalog';
|
||||
import { parseMaesterExecutionError, type MaesterExecutionError } from './maester-error';
|
||||
import { pingCastleAffectedDetailRow } from '../../profiles/pingcastle/result-evidence';
|
||||
import { purpleKnightAffectedResultRow } from '../../profiles/purple-knight/result-evidence';
|
||||
|
||||
/** Ein benannter Ergebnisabschnitt des Results-Fensters. */
|
||||
export interface ResultSection {
|
||||
|
|
@ -41,8 +43,17 @@ export function resultSheetRowHighlightedFor(toolId: string | undefined, record:
|
|||
}
|
||||
export type ResultSheetRowTone = 'criterion' | 'match' | 'violation' | 'highlight';
|
||||
|
||||
/** Inline evidence: status words require a status column; glyphs are source-written markers. */
|
||||
export function resultSectionRowClassName(toolId: string | undefined, headers: string[], cells: string[]): string | undefined {
|
||||
const fields = cells.map((value, index) => ({ value, label: headers[index] ?? '', index }))
|
||||
.filter(field => /^(?:status|result|test result|outcome|evaluation)$/i.test(field.label.trim())
|
||||
|| /^(?:✅|✔️?|❌|✖️?|:white_check_mark:|:heavy_check_mark:|:x:|:heavy_multiplication_x:)(?:\s|$)/u.test(field.value.trim()));
|
||||
const tone = resultSheetRowToneFor(toolId, { id: 'inline-status', index: 0, title: '', fields, filled: [], meta: [] });
|
||||
return tone ? `rich-text-table-row--${tone}` : undefined;
|
||||
}
|
||||
|
||||
/** Profilgesteuerte semantische Farbe einer Belegzeile. */
|
||||
export function resultSheetRowToneFor(toolId: string | undefined, record: FindingRecord, findingOutcome?: CanonicalOutcome): ResultSheetRowTone | undefined {
|
||||
export function resultSheetRowToneFor(toolId: string | undefined, record: FindingRecord, findingOutcome?: CanonicalOutcome, context?: { finding?: FindingRecord; sheet: DatasetSheet }): ResultSheetRowTone | undefined {
|
||||
if (!toolId) return record.highlighted ? 'highlight' : undefined;
|
||||
const presentation = toolProfileDefinitions.find(profile => profile.id === toolId.toLocaleLowerCase('en-US'))?.presentation;
|
||||
const value = record.fields.filter(field => field.value).map(field => field.value.trim()).join(' ').trim();
|
||||
|
|
@ -60,6 +71,8 @@ export function resultSheetRowToneFor(toolId: string | undefined, record: Findin
|
|||
if (findingOutcome === 'fail') return 'violation';
|
||||
return undefined;
|
||||
}
|
||||
if (toolId === 'pingcastle' && findingOutcome === 'fail' && context?.finding && pingCastleAffectedDetailRow(context.finding, context.sheet, record)) return 'violation';
|
||||
if (toolId === 'purple-knight' && findingOutcome === 'fail' && context?.finding && purpleKnightAffectedResultRow(context.finding, context.sheet, record)) return 'violation';
|
||||
if (record.highlighted || presentation?.resultSheetHighlightedRowPatterns?.some(pattern => pattern.test(value))) return 'highlight';
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import type { ResultsExcelContent } from '../../exports/results-window-excel';
|
||||
import type { ResultSection } from './result-message';
|
||||
import type { DatasetSheet } from '../../domain/dataset';
|
||||
import type { FindingRecord } from '../../domain/finding';
|
||||
import type { CanonicalOutcome } from '../../domain/canonical-model';
|
||||
import { findingFirstRead, firstReadDefinition } from '../finding-reader/first-read';
|
||||
import { policyProblemSummary } from '../../profiles/group-policy/problem-summary';
|
||||
|
||||
/** Reuse the Reader's declared source mappings; never infer affected counts from exported rows. */
|
||||
export function resultsFindingContext(finding?: FindingRecord, toolId?: string, sheets: DatasetSheet[] = [], outcome?: CanonicalOutcome): NonNullable<ResultsExcelContent['findingContext']> {
|
||||
if (!finding) return [];
|
||||
const definition = firstReadDefinition(toolId);
|
||||
const descriptions = finding.fields.filter(field => field.value.trim() &&
|
||||
(/^description$/i.test(field.label.trim()) || definition?.backgroundHeaders?.some(pattern => pattern.test(field.label.trim()))));
|
||||
const problem = findingFirstRead(finding, toolId, sheets, outcome);
|
||||
const rows = descriptions.map(field => ({ section: 'Description', label: field.label, value: field.value }));
|
||||
const policy = toolId === 'group-policy' ? policyProblemSummary(finding) : undefined;
|
||||
if (policy) {
|
||||
for (const entry of policy.configured) rows.push({ section: policy.heading, label: 'Configured', value: entry.text + (entry.note ? ` · ${entry.note}` : '') });
|
||||
for (const [label, values] of [['Expected', policy.expected], ['Additional entries', policy.additional], ['Missing entries', policy.missing], ['Context', policy.context], ['Flags', policy.flags]] as const) {
|
||||
for (const value of values) if (value) rows.push({ section: policy.heading, label, value });
|
||||
}
|
||||
}
|
||||
for (const row of problem?.rows ?? []) {
|
||||
const patterns = row.label === 'Result' ? definition?.resultHeaders : row.label === 'Affected' ? definition?.affectedHeaders : row.label === 'Impact' ? definition?.impactHeaders : undefined;
|
||||
const original = finding.fields.find(field => field.value.trim() && patterns?.some(pattern => pattern.test(field.label.trim())));
|
||||
rows.push({ section: problem!.heading, label: row.label, value: original?.value ?? row.value });
|
||||
}
|
||||
// Unknown profiles can still carry explicitly named explanatory fields. A bare Result status is not a problem statement.
|
||||
if (!definition) for (const field of finding.fields) {
|
||||
if (!field.value.trim()) continue;
|
||||
const label = field.label.trim();
|
||||
if (/^(?:result message|affected|impact)$/i.test(label) || /^result$/i.test(label) && finding.fields.some(item => /^status$/i.test(item.label.trim()) && item.value.trim())) {
|
||||
rows.push({ section: 'Problem', label, value: field.value });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function cleanClone(element: Element): Element {
|
||||
const clone = element.cloneNode(true) as Element;
|
||||
clone.querySelectorAll('caption, input, button, .result-column-resizer, .windows-code-meaning, [data-export-exclude]').forEach(node => node.remove());
|
||||
clone.querySelectorAll('br').forEach(node => node.replaceWith('\n'));
|
||||
return clone;
|
||||
}
|
||||
|
||||
/** Snapshot every rendered row, including offscreen rows and collapsed technical details. */
|
||||
export function resultsExcelContent(root: HTMLElement, title: string, sourceSections: ResultSection[], sheets: DatasetSheet[] = []): ResultsExcelContent {
|
||||
const tables = [...root.querySelectorAll<HTMLTableElement>('table')].map((table, index) => {
|
||||
const block = table.closest('.result-block, .variant-comparison');
|
||||
const name = (block?.querySelector('.result-block__subtitle') ?? block?.querySelector('.result-block__title, h3'))?.textContent?.trim() || `Table ${index + 1}`;
|
||||
const source = sheets.find(sheet => sheet.id === table.dataset.resultSheetId);
|
||||
const headers = source?.analysis.headers.filter(header => source.analysis.records.some(record => record.fields.find(field => field.label === header)?.value));
|
||||
const sourceRows = source && headers ? [headers, ...source.analysis.records.map(record => headers.map(header => record.fields.find(field => field.label === header)?.value ?? ''))] : undefined;
|
||||
return { id: table.dataset.resultTableId ?? '', title: `${index + 1} ${name}`,
|
||||
rows: sourceRows ?? [...table.rows].map(row => [...row.cells].map(cell => cell.getAttribute('data-export-value') ?? cleanClone(cell).textContent ?? '')) };
|
||||
});
|
||||
const clone = cleanClone(root);
|
||||
clone.querySelectorAll('table').forEach((table, index) => table.replaceWith(`\n[Table ${index + 1}: ${tables[index].title}]\n`));
|
||||
clone.querySelectorAll('p, h3, h4, h5, h6, li, dt, dd, pre, summary, section').forEach(node => node.append('\n'));
|
||||
return { title, text: clone.textContent ?? '', tables, sourceSections: sourceSections.map(({ title, value }) => ({ title, value })) };
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { linkResultSheets } from '../../profiles/purple-knight/result-linker';
|
|||
import { linkPingCastleSectionSheets } from '../../profiles/pingcastle/pingcastle-section-links';
|
||||
import { inlineResultTableSheets } from '../linked-results/inline-result-tables';
|
||||
import { resultMessageFor, resultOutcomeFor, resultSectionsFor } from '../linked-results/result-message';
|
||||
import { RelatedFindingsFocus, useRelatedFindings, useRelatedViewRestore } from '../related-findings/RelatedFindings';
|
||||
import { useFilteredRecords } from '../search-filter/use-filtered-records';
|
||||
import { toolIdForRecord } from '../../importers/status-normalization';
|
||||
import { activeMitreMatrix, buildFindingPathItems, MITRE_MATRIX_CHANGED_EVENT, type FindingPathItem, type MitreGraphData } from './mitre-graph';
|
||||
|
|
@ -103,16 +104,18 @@ export function MitreGraphView({ onOpenFinding }: MitreGraphViewProps) {
|
|||
const nextColor = useRef(0);
|
||||
const nextZIndex = useRef(2100);
|
||||
|
||||
const related = useRelatedFindings();
|
||||
useRelatedViewRestore(() => () => { setSelectedId(selectedId); setPathSelection(pathSelection); setCategory(category); setSeverity(severity); setOriginFilter(originFilter); setSearchValue(search); setShowFilters(showFilters); });
|
||||
const visibleRecords = useFilteredRecords();
|
||||
const items = useMemo(() => buildFindingPathItems(visibleRecords, graphData, titleColumn), [graphData, titleColumn, visibleRecords]);
|
||||
const categories = useMemo(() => [...new Set(items.map(item => item.category))].sort((left, right) => left.localeCompare(right)), [items]);
|
||||
const normalizedSearch = search.trim().toLocaleLowerCase('en-US');
|
||||
const filtered = useMemo(() => items
|
||||
.filter(item => category === 'all' || item.category === category)
|
||||
.filter(item => severity === 'all' || item.severity === severity)
|
||||
.filter(item => originFilter === 'all' || item.mappingOrigin === originFilter)
|
||||
.filter(item => !normalizedSearch || `${item.title} ${item.techniqueIds.join(' ')} ${item.tactics.join(' ')}`.toLocaleLowerCase('en-US').includes(normalizedSearch))
|
||||
.sort((left, right) => right.risk - left.risk || left.title.localeCompare(right.title)), [category, items, normalizedSearch, originFilter, severity]);
|
||||
.filter(item => related?.selection || category === 'all' || item.category === category)
|
||||
.filter(item => related?.selection || severity === 'all' || item.severity === severity)
|
||||
.filter(item => related?.selection || originFilter === 'all' || item.mappingOrigin === originFilter)
|
||||
.filter(item => related?.selection || !normalizedSearch || `${item.title} ${item.techniqueIds.join(' ')} ${item.tactics.join(' ')}`.toLocaleLowerCase('en-US').includes(normalizedSearch))
|
||||
.sort((left, right) => right.risk - left.risk || left.title.localeCompare(right.title)), [category, items, normalizedSearch, originFilter, severity, related?.selection]);
|
||||
|
||||
const mappedTactics = useMemo(() => graphData.tacticOrder.filter(tactic => filtered.some(item => item.primaryTactic === tactic)), [filtered, graphData.tacticOrder]);
|
||||
const unmapped = filtered.filter(item => item.primaryTactic === 'Unmapped');
|
||||
|
|
@ -245,6 +248,7 @@ export function MitreGraphView({ onOpenFinding }: MitreGraphViewProps) {
|
|||
|
||||
return (
|
||||
<div className="finding-paths">
|
||||
<RelatedFindingsFocus/>
|
||||
{/* Kopfzeile und Filter stehen im Kopfcontainer der Shell, nicht im Panel.
|
||||
Das Panel traegt ausschliesslich den Inhalt - dieselbe Aufteilung wie
|
||||
in Work, Datahub, Result DB und DB-Struktur. */}
|
||||
|
|
@ -313,7 +317,7 @@ export function MitreGraphView({ onOpenFinding }: MitreGraphViewProps) {
|
|||
) : (
|
||||
<div className="finding-path-layout">
|
||||
<main className="finding-path-main">
|
||||
<div className="finding-path-scroll" ref={scrollRef}>
|
||||
<div className="finding-path-scroll" data-group-scroll="matrix-scroll" ref={scrollRef}>
|
||||
<section className="finding-tactic-board" ref={boardRef} aria-label="Findings by ATT&CK tactic">
|
||||
<svg className="finding-path-svg-canvas">
|
||||
<defs>
|
||||
|
|
@ -471,7 +475,7 @@ export function MitreGraphView({ onOpenFinding }: MitreGraphViewProps) {
|
|||
const finding = items.find(candidate => candidate.id === item.recordId);
|
||||
const sheets = finding ? linksFor(finding) : [];
|
||||
const sections = finding ? sectionsFor(finding) : [];
|
||||
return <FloatingResultsWindow key={item.recordId} id={`graph-results-${item.recordId}`} title={item.title} sheets={sheets} toolId={item.toolId} outcome={finding ? resultOutcomeFor(finding.record, activeSheet?.toolId ?? dataset?.profileId) : undefined} sections={sections} color={item.color} zIndex={item.zIndex} offset={index * 24} search={workSearch} onActivate={() => activateWindow(item.recordId)} onClose={() => setResultWindows(current => current.filter(candidate => candidate.recordId !== item.recordId))} onOpenRecord={finding ? () => openFinding(finding) : undefined} actionLabel="To Report" actionTitle="Jump to this finding’s record in the Report" minimized={item.minimized} onToggleMinimize={() => toggleMinimize(item.recordId)} />;
|
||||
return <FloatingResultsWindow key={item.recordId} id={`graph-results-${item.recordId}`} title={item.title} sheets={sheets} toolId={item.toolId} finding={finding?.record} outcome={finding ? resultOutcomeFor(finding.record, activeSheet?.toolId ?? dataset?.profileId) : undefined} sections={sections} color={item.color} zIndex={item.zIndex} offset={index * 24} search={workSearch} onActivate={() => activateWindow(item.recordId)} onClose={() => setResultWindows(current => current.filter(candidate => candidate.recordId !== item.recordId))} onOpenRecord={finding ? () => openFinding(finding) : undefined} actionLabel="To Report" actionTitle="Jump to this finding’s record in the Report" minimized={item.minimized} onToggleMinimize={() => toggleMinimize(item.recordId)} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { createPortal } from 'react-dom';
|
|||
import { FilterEmptyState } from '../../components/FilterEmptyState/FilterEmptyState';
|
||||
import { searchAllFindings } from '../search-filter/search-all-findings';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent } from 'react';
|
||||
import { RelatedFindingRing, RelatedFindingsFocus, useRelatedFindings, useRelatedViewRestore } from '../related-findings/RelatedFindings';
|
||||
import { useApp } from '../../app/AppProvider';
|
||||
import { FloatingResultsWindow } from '../linked-results/FloatingResultsWindow';
|
||||
import { linkResultSheets } from '../../profiles/purple-knight/result-linker';
|
||||
|
|
@ -105,6 +106,17 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
|
|||
cameraRef.current = camera;
|
||||
const marked = Object.values(reviews).filter(review => review.include).length;
|
||||
|
||||
const related = useRelatedFindings();
|
||||
const restoringGroup = useRef(false);
|
||||
useRelatedViewRestore(() => () => {
|
||||
stopCameraAnimation();
|
||||
setHoveredNodeId(hoveredNodeId);
|
||||
restoringGroup.current = true;
|
||||
setCamera(camera); setPinnedNodeIds(pinnedNodeIds); setSelectedNodeId(selectedNodeId);
|
||||
setMarkedSelectionIds(markedSelectionIds); setExtractedSelectionIds(extractedSelectionIds); setNodeOverrides(nodeOverrides);
|
||||
setShowText(showText);
|
||||
requestAnimationFrame(() => { restoringGroup.current = false; });
|
||||
});
|
||||
const visibleRecords = useFilteredRecords('deviation');
|
||||
// Context layer (Phase 13E): clusters become hubs only while the layer is on; off, the graph is unchanged.
|
||||
const contextIndex = useContextClusterIndex();
|
||||
|
|
@ -132,7 +144,7 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
|
|||
[graph, markedSelectionIds]
|
||||
);
|
||||
const extractedNodeIds = useMemo(
|
||||
() => extractedSelectionIds.length ? new Set(selectedPathNodeIds(graph, extractedSelectionIds[0])) : undefined,
|
||||
() => !related?.selection && extractedSelectionIds.length ? new Set(selectedPathNodeIds(graph, extractedSelectionIds[0])) : undefined,
|
||||
[extractedSelectionIds, graph]
|
||||
);
|
||||
const displayedNodes = useMemo(
|
||||
|
|
@ -316,6 +328,7 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (related?.selection || restoringGroup.current) return;
|
||||
setNodeOverrides({});
|
||||
setPinnedNodeIds(current => current.filter(id => graph.nodes.some(node => node.id === id)));
|
||||
setSelectedNodeId(current => graph.nodes.some(node => node.id === current) ? current : '');
|
||||
|
|
@ -335,6 +348,7 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
|
|||
onPathSnapshotApplied?.();
|
||||
}, [active, graph.nodes, graphSignature, onPathSnapshotApplied, queuedPathSnapshot]);
|
||||
useEffect(() => {
|
||||
if (restoringGroup.current) { lastFittedGraphSignature.current = graphSignature; return; }
|
||||
if (!active || lastFittedGraphSignature.current === graphSignature) return;
|
||||
const frame = globalThis.requestAnimationFrame(() => {
|
||||
const rect = surfaceRef.current?.getBoundingClientRect();
|
||||
|
|
@ -347,6 +361,7 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
|
|||
}, [active, fitGraphTo, graphSignature]);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
if (restoringGroup.current) { fittedExtractSignature.current = extractedSelectionIds.join('|'); return; }
|
||||
const extractSignature = extractedSelectionIds.join('|');
|
||||
if (!extractSignature) {
|
||||
fittedExtractSignature.current = '';
|
||||
|
|
@ -501,9 +516,10 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
|
|||
<div className="obsidian-graph-layout">
|
||||
<aside className={`obsidian-graph-inspector${selectedNode ? ' is-pinned' : ''}`} aria-live="polite">
|
||||
<header><strong>Details</strong>{selectedNode && <button className="obsidian-inspector-close" type="button" onClick={() => togglePinnedNode(selectedNode.id)} aria-label="Close details">×</button>}</header>
|
||||
{detailNode ? <div className="obsidian-graph-inspector-content">
|
||||
{detailNode ? <div className="obsidian-graph-inspector-content" data-group-scroll="graph-inspector">
|
||||
<div className={`obsidian-graph-inspector-title${detailFinding ? ` severity-${detailFinding.severity}` : ''}`}>
|
||||
{detailFinding && <span className="data-tile-hex" aria-hidden="true" />}
|
||||
{detailFinding && <RelatedFindingRing record={detailFinding.record} inline/>}
|
||||
<div><small>{graphNodeKindLabels[detailNode.kind]}</small><strong>{detailNode.label}</strong></div>
|
||||
</div>
|
||||
<span>{selectedNode ? `${connectionCount} direct connections` : 'Click the node to keep these details open'}</span>
|
||||
|
|
@ -535,7 +551,8 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
|
|||
</>}
|
||||
</div> : <div className="obsidian-graph-inspector-empty"><strong>Select a node</strong><span>Hover for a preview or click to keep details and actions visible.</span></div>}
|
||||
</aside>
|
||||
<section className="obsidian-graph-stage" aria-label="Graph workspace">
|
||||
<section className="obsidian-graph-stage" data-group-scroll="graph-stage" aria-label="Graph workspace">
|
||||
<RelatedFindingsFocus/>
|
||||
<div className="obsidian-graph-toolbar" aria-label="Graph controls">
|
||||
<button className={`btn${showText ? ' active' : ''}`} type="button" aria-pressed={showText} title="Show or hide node text" onClick={() => setShowText(current => !current)}>Text</button>
|
||||
<button className="btn obsidian-graph-reset-view" type="button" title="Clear all marked nodes and restore the default graph view" onClick={resetGraphView}><span aria-hidden="true">○</span>Reset view</button>
|
||||
|
|
@ -601,6 +618,7 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
|
|||
return <g key={node.id} className={`obsidian-node obsidian-node-${node.kind}${node.severity ? ` severity-${node.severity}` : ''}${isSelected ? ' is-selected' : ''}${isMarkedBranch ? ' is-branch-marked' : ''}${isMuted ? ' is-branch-muted' : ''}${isSelectionMuted ? ' is-selection-muted' : ''}`} transform={`translate(${node.x} ${node.y})`} tabIndex={0} role="button" aria-pressed={isSelected} aria-label={node.label} onPointerDown={event => startNodeDrag(event, node)} onPointerEnter={() => setHoveredNodeId(node.id)} onPointerLeave={() => setHoveredNodeId(current => current === node.id ? '' : current)} onDoubleClick={() => finding && openFinding(finding)} onKeyDown={event => { if (event.key === 'Enter' || event.key === ' ') togglePinnedNode(node.id); }}>
|
||||
{isMarkedBranch && !isSelected && <circle className="obsidian-node-branch-ring" r={node.radius + 4} />}
|
||||
{isSelected && <circle className="obsidian-node-selection-ring" r={node.radius + 5} />}
|
||||
{finding && related?.groups.get(finding.id) && <foreignObject transform={`scale(${1 / camera.scale})`} x={node.radius * camera.scale + 4} y={-node.radius * camera.scale - 20} width="28" height="28" className="graph-related-ring"><RelatedFindingRing record={finding.record} inline/></foreignObject>}
|
||||
{node.kind !== 'finding' && <circle className="obsidian-node-role-ring" r={node.radius + 4} />}
|
||||
<circle className="obsidian-node-core" r={node.radius} />
|
||||
{showInlineLabel && <g transform={`scale(${1 / camera.scale})`}><text className="obsidian-node-inline-label" x={(labelOnLeft ? -1 : 1) * (node.radius * camera.scale + 6)} y={labelOffsetY + 4} textAnchor={labelOnLeft ? 'end' : 'start'}>{labelText}</text></g>}
|
||||
|
|
@ -617,7 +635,7 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
|
|||
const finding = items.find(candidate => candidate.id === item.recordId);
|
||||
const sheets = finding ? linksFor(finding) : [];
|
||||
const sections = finding ? sectionsFor(finding) : [];
|
||||
return <FloatingResultsWindow key={item.recordId} id={`graph-results-${item.recordId}`} title={item.title} sheets={sheets} toolId={item.toolId} outcome={finding ? resultOutcomeFor(finding.record, activeSheet?.toolId ?? dataset?.profileId) : undefined} sections={sections} color={item.color} zIndex={item.zIndex} offset={index * 24} search={workSearch} onActivate={() => activateWindow(item.recordId)} onClose={() => setResultWindows(current => current.filter(candidate => candidate.recordId !== item.recordId))} onOpenRecord={finding ? () => openFinding(finding) : undefined} actionLabel="To Report" actionTitle="Jump to this finding’s record in the Report" minimized={item.minimized} onToggleMinimize={() => setResultWindows(current => current.map(candidate => candidate.recordId === item.recordId ? { ...candidate, minimized: !candidate.minimized } : candidate))} />;
|
||||
return <FloatingResultsWindow key={item.recordId} id={`graph-results-${item.recordId}`} title={item.title} sheets={sheets} toolId={item.toolId} finding={finding?.record} outcome={finding ? resultOutcomeFor(finding.record, activeSheet?.toolId ?? dataset?.profileId) : undefined} sections={sections} color={item.color} zIndex={item.zIndex} offset={index * 24} search={workSearch} onActivate={() => activateWindow(item.recordId)} onClose={() => setResultWindows(current => current.filter(candidate => candidate.recordId !== item.recordId))} onOpenRecord={finding ? () => openFinding(finding) : undefined} actionLabel="To Report" actionTitle="Jump to this finding’s record in the Report" minimized={item.minimized} onToggleMinimize={() => setResultWindows(current => current.map(candidate => candidate.recordId === item.recordId ? { ...candidate, minimized: !candidate.minimized } : candidate))} />;
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { groupProjectImportFiles } from './datahub-import-groups';
|
|||
import { summarizeClientProcessingReadiness, withReferenceReadiness, type DatahubReferenceReadiness } from './datahub-processing-readiness';
|
||||
import type { DatahubTimeslotResultMetrics } from './datahub-scan-analysis';
|
||||
import { DonutMetricCard } from '../../components/DonutMetricCard/DonutMetricCard';
|
||||
import { assessedResultPercentages } from '../../components/DonutMetricCard/assessed-result-percentages';
|
||||
import type { TimeslotOpenReadiness } from './datahub-open-readiness';
|
||||
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
|
||||
|
||||
|
|
@ -57,18 +58,19 @@ export function DatahubHistoryOverview({
|
|||
onSelectProject
|
||||
}: DatahubHistoryOverviewProps) {
|
||||
const resultMetrics = timeslotMetrics ?? { all: null, deviation: null, passed: null, notApplicable: null, notRun: null, error: null };
|
||||
const assessmentPercentages = assessedResultPercentages(resultMetrics.passed ?? 0, resultMetrics.deviation ?? 0);
|
||||
const pendingLabel = loading || comparisonBusy ? 'Loading…'
|
||||
: comparisonError ? 'Unavailable' : selection ? 'No results' : 'Select client';
|
||||
|
||||
return <>
|
||||
<section className="datahub-rack-module datahub-status-module" aria-label="Selected client result status" aria-busy={loading || comparisonBusy}>
|
||||
<div className="datahub-reference-controls donut-metric-rack">
|
||||
<DonutMetricCard className="datahub-reference-knob" label="Results All" value={resultMetrics.all} percentage={resultMetrics.all !== null ? 100 : 0} tone="all" pendingLabel={pendingLabel}/>
|
||||
<DonutMetricCard className="datahub-reference-knob" label="Deviation" value={resultMetrics.deviation} percentage={resultPercentage(resultMetrics.deviation ?? 0, resultMetrics.all ?? 0)} tone="deviation" pendingLabel={pendingLabel}/>
|
||||
<DonutMetricCard className="datahub-reference-knob" label="Passed" value={resultMetrics.passed} percentage={resultPercentage(resultMetrics.passed ?? 0, resultMetrics.all ?? 0)} tone="passed" pendingLabel={pendingLabel}/>
|
||||
<DonutMetricCard className="datahub-reference-knob" label="Not Applicable" value={resultMetrics.notApplicable} percentage={resultPercentage(resultMetrics.notApplicable ?? 0, resultMetrics.all ?? 0)} tone="not-applicable" pendingLabel={pendingLabel}/>
|
||||
<DonutMetricCard className="datahub-reference-knob" label="Not Run" value={resultMetrics.notRun} percentage={resultPercentage(resultMetrics.notRun ?? 0, resultMetrics.all ?? 0)} tone="not-run" pendingLabel={pendingLabel}/>
|
||||
<DonutMetricCard className="datahub-reference-knob" label="Error" value={resultMetrics.error} percentage={resultPercentage(resultMetrics.error ?? 0, resultMetrics.all ?? 0)} tone="error" pendingLabel={pendingLabel}/>
|
||||
<DonutMetricCard className="datahub-reference-knob" label="Results All" value={resultMetrics.all} percentage={resultMetrics.all !== null ? 100 : 0} percentageBasisLabel="Of all records" tone="all" pendingLabel={pendingLabel}/>
|
||||
<DonutMetricCard className="datahub-reference-knob" label="Deviation" value={resultMetrics.deviation} percentage={assessmentPercentages.deviation} percentageBasisLabel="Of assessed checks" tone="deviation" pendingLabel={pendingLabel}/>
|
||||
<DonutMetricCard className="datahub-reference-knob" label="Passed" value={resultMetrics.passed} percentage={assessmentPercentages.passed} percentageBasisLabel="Of assessed checks" tone="passed" pendingLabel={pendingLabel}/>
|
||||
<DonutMetricCard className="datahub-reference-knob" label="Not Applicable" value={resultMetrics.notApplicable} percentage={resultPercentage(resultMetrics.notApplicable ?? 0, resultMetrics.all ?? 0)} percentageBasisLabel="Of all records" tone="not-applicable" pendingLabel={pendingLabel}/>
|
||||
<DonutMetricCard className="datahub-reference-knob" label="Not Run" value={resultMetrics.notRun} percentage={resultPercentage(resultMetrics.notRun ?? 0, resultMetrics.all ?? 0)} percentageBasisLabel="Of all records" tone="not-run" pendingLabel={pendingLabel}/>
|
||||
<DonutMetricCard className="datahub-reference-knob" label="Error" value={resultMetrics.error} percentage={resultPercentage(resultMetrics.error ?? 0, resultMetrics.all ?? 0)} percentageBasisLabel="Of all records" tone="error" pendingLabel={pendingLabel}/>
|
||||
</div>
|
||||
{comparisonError && <em className="datahub-comparison-error" role="alert">{comparisonError}</em>}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -51,6 +51,33 @@ interface SelectedBrowserFile {
|
|||
interface CollectedBrowserDirectory {
|
||||
files: SelectedBrowserFile[];
|
||||
unreadableEntries: number;
|
||||
failures: FolderReadFailure[];
|
||||
}
|
||||
|
||||
interface FolderReadFailure {
|
||||
stage: 'Folder enumeration' | 'File access' | 'File reading' | 'Folder selection';
|
||||
code: string;
|
||||
}
|
||||
|
||||
function folderReadFailure(stage: FolderReadFailure['stage'], reason: unknown): FolderReadFailure {
|
||||
// Browser messages may contain customer paths. Retain only known error names.
|
||||
const name = reason instanceof Error || reason instanceof DOMException ? reason.name : '';
|
||||
const known = ['NotFoundError', 'NotAllowedError', 'SecurityError', 'NotReadableError', 'AbortError'];
|
||||
return { stage, code: known.includes(name) ? name : 'UnknownError' };
|
||||
}
|
||||
|
||||
function folderReadMessage(failures: readonly FolderReadFailure[]): string {
|
||||
if (!failures.length) return 'The selected GPO folder contains no files. Select the extracted backup folder containing files, or use Import GPO ZIP.';
|
||||
const counts = new Map<string, number>();
|
||||
for (const failure of failures) {
|
||||
const key = `${failure.stage}: ${failure.code}`;
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
const detail = [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([key, count]) => `${key} (${count})`).join('; ');
|
||||
const denied = failures.some(failure => ['NotAllowedError', 'SecurityError'].includes(failure.code));
|
||||
return `Chrome/browser could not read the selected GPO folder. ${detail}. ${denied
|
||||
? 'Select the folder again and allow read access.'
|
||||
: 'Check that the backup files are locally available and readable, then select the folder again.'} Alternatively, use Import GPO ZIP. No upload was started.`;
|
||||
}
|
||||
|
||||
const stateLabels: Record<GpoBackupStatusRecord['state'], string> = {
|
||||
|
|
@ -114,7 +141,8 @@ export function captureGpoFolderFiles(files: readonly File[]): GpoFolderFileSour
|
|||
|
||||
export async function captureReadableGpoSelectedFiles(
|
||||
entries: readonly SelectedBrowserFile[]
|
||||
): Promise<{ sources: GpoFolderFileSource[]; unreadableEntries: number }> {
|
||||
): Promise<{ sources: GpoFolderFileSource[]; unreadableEntries: number; failures: FolderReadFailure[] }> {
|
||||
const failures: FolderReadFailure[] = [];
|
||||
validateGpoFolderSourceMetadata(entries.map(entry => ({
|
||||
relativePath: entry.relativePath,
|
||||
sizeBytes: entry.file.size
|
||||
|
|
@ -122,18 +150,22 @@ export async function captureReadableGpoSelectedFiles(
|
|||
const captured: Array<GpoFolderFileSource | undefined> = await Promise.all(entries.map(async ({ file, relativePath }): Promise<GpoFolderFileSource | undefined> => {
|
||||
try {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
if (bytes.byteLength !== file.size) return undefined;
|
||||
if (bytes.byteLength !== file.size) {
|
||||
failures.push({ stage: 'File reading', code: 'SizeMismatch' });
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
relativePath,
|
||||
sizeBytes: file.size,
|
||||
readBytes: async () => bytes
|
||||
};
|
||||
} catch {
|
||||
} catch (reason) {
|
||||
failures.push(folderReadFailure('File reading', reason));
|
||||
return undefined;
|
||||
}
|
||||
}));
|
||||
const sources = captured.filter((source): source is GpoFolderFileSource => source !== undefined);
|
||||
return { sources, unreadableEntries: entries.length - sources.length };
|
||||
return { sources, unreadableEntries: entries.length - sources.length, failures };
|
||||
}
|
||||
|
||||
export async function collectGpoDirectoryFiles(
|
||||
|
|
@ -141,6 +173,7 @@ export async function collectGpoDirectoryFiles(
|
|||
): Promise<CollectedBrowserDirectory> {
|
||||
const selected: SelectedBrowserFile[] = [];
|
||||
let unreadableEntries = 0;
|
||||
const failures: FolderReadFailure[] = [];
|
||||
const visit = async (directory: BrowserFileSystemDirectoryHandle, parentPath: string): Promise<void> => {
|
||||
try {
|
||||
for await (const entry of directory.values()) {
|
||||
|
|
@ -149,17 +182,19 @@ export async function collectGpoDirectoryFiles(
|
|||
else {
|
||||
try {
|
||||
selected.push({ file: await entry.getFile(), relativePath });
|
||||
} catch {
|
||||
} catch (reason) {
|
||||
unreadableEntries += 1;
|
||||
failures.push(folderReadFailure('File access', reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (reason) {
|
||||
unreadableEntries += 1;
|
||||
failures.push(folderReadFailure('Folder enumeration', reason));
|
||||
}
|
||||
};
|
||||
await visit(root, root.name);
|
||||
return { files: selected, unreadableEntries };
|
||||
return { files: selected, unreadableEntries, failures };
|
||||
}
|
||||
|
||||
export function GpoFolderImport({
|
||||
|
|
@ -300,7 +335,7 @@ export function GpoFolderImport({
|
|||
try {
|
||||
if (files.some(file => !file.webkitRelativePath)) throw new Error('Please select a complete GPO backup folder.');
|
||||
const captured = await captureReadableGpoSelectedFiles(files.map(file => ({ file, relativePath: file.webkitRelativePath })));
|
||||
if (!captured.sources.length) throw new Error('Keine Datei des ausgewaehlten GPO-Ordners konnte gelesen werden.');
|
||||
if (!captured.sources.length) throw new Error(folderReadMessage(captured.failures));
|
||||
const transport = await createGpoFolderTransport(captured.sources);
|
||||
const envelopeBytes = new Uint8Array(transport.bytes.byteLength);
|
||||
envelopeBytes.set(transport.bytes);
|
||||
|
|
@ -329,12 +364,18 @@ export function GpoFolderImport({
|
|||
setProgress('Preparing GPO backup');
|
||||
setFeedback(null);
|
||||
try {
|
||||
const directory = await picker({ mode: 'read' });
|
||||
let directory: BrowserFileSystemDirectoryHandle;
|
||||
try {
|
||||
directory = await picker.call(globalThis, { mode: 'read' });
|
||||
} catch (reason) {
|
||||
if (reason instanceof DOMException && reason.name === 'AbortError') throw reason;
|
||||
throw new Error(folderReadMessage([folderReadFailure('Folder selection', reason)]));
|
||||
}
|
||||
const collected = await collectGpoDirectoryFiles(directory);
|
||||
if (!collected.files.length) throw new Error('Keine Datei des ausgewaehlten GPO-Backupordners konnte gelesen werden.');
|
||||
if (!collected.files.length) throw new Error(folderReadMessage(collected.failures));
|
||||
const captured = await captureReadableGpoSelectedFiles(collected.files);
|
||||
const unreadableEntries = collected.unreadableEntries + captured.unreadableEntries;
|
||||
if (!captured.sources.length) throw new Error('Keine Datei des ausgewaehlten GPO-Backupordners konnte gelesen werden.');
|
||||
if (!captured.sources.length) throw new Error(folderReadMessage([...collected.failures, ...captured.failures]));
|
||||
const transport = await createGpoFolderTransport(captured.sources);
|
||||
const envelopeBytes = new Uint8Array(transport.bytes.byteLength);
|
||||
envelopeBytes.set(transport.bytes);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type PropsWithChildren } from 'react';
|
||||
import { useApp } from '../../app/AppProvider';
|
||||
import type { SavedViewState } from '../../app/app-state';
|
||||
import type { FindingRecord } from '../../domain/finding';
|
||||
import type { DatasetSheet } from '../../domain/dataset';
|
||||
import { useWorkPackages } from '../../remediation/use-work-packages';
|
||||
import { workRecordKey } from '../../remediation/work-package-index';
|
||||
import { resultColors } from '../linked-results/result-colors';
|
||||
|
||||
export interface RelatedFindingGroup { id: string; recordIds: string[]; color: string; }
|
||||
interface Selection { group: RelatedFindingGroup; sheet?: DatasetSheet; before: SavedViewState; scope: string; dataset: unknown; }
|
||||
interface GroupContext {
|
||||
groups: ReadonlyMap<string, RelatedFindingGroup>;
|
||||
selection?: Selection;
|
||||
toggle(group: RelatedFindingGroup, recordId: string): void;
|
||||
showAllInWork(): void;
|
||||
registerView(capture: () => () => void): () => void;
|
||||
}
|
||||
const Context = createContext<GroupContext | undefined>(undefined);
|
||||
export const useRelatedFindings = () => useContext(Context);
|
||||
|
||||
/** Views retain their own UI state; the focus coordinator only stores restore callbacks. */
|
||||
export function useRelatedViewRestore(capture: () => () => void) {
|
||||
const context = useRelatedFindings();
|
||||
const latest = useRef(capture); latest.current = capture;
|
||||
useEffect(() => context?.registerView(() => latest.current()), [context?.registerView]);
|
||||
}
|
||||
|
||||
/** Presentation-only focus. Published exact change-target identities remain authoritative. */
|
||||
export function RelatedFindingsProvider({ children }: PropsWithChildren) {
|
||||
const app = useApp();
|
||||
const packages = useWorkPackages();
|
||||
const scope = app.workScopeId;
|
||||
const workSheet = app.dataset?.sheets.find(sheet => sheet.isProjectSheet) ?? app.activeSheet;
|
||||
const [selection, setSelection] = useState<Selection>();
|
||||
const positions = useRef<Array<{ id: string; top: number; left: number }>>([]);
|
||||
const restore = useRef(false);
|
||||
const views = useRef(new Set<() => () => void>());
|
||||
const viewRestorers = useRef<Array<() => void>>([]);
|
||||
const registerView = useMemo(() => (capture: () => () => void) => {
|
||||
views.current.add(capture);
|
||||
return () => { views.current.delete(capture); };
|
||||
}, []);
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, RelatedFindingGroup>();
|
||||
if (packages.status !== 'ready') return map;
|
||||
const byKey = new Map<string, string[]>();
|
||||
const aliases = new Map<string, string[]>();
|
||||
for (const sheet of app.dataset?.sheets ?? []) for (const record of sheet.analysis.records) {
|
||||
const key = workRecordKey(record);
|
||||
if (key) aliases.set(key, [...(aliases.get(key) ?? []), record.id]);
|
||||
}
|
||||
for (const record of workSheet?.analysis.records ?? []) {
|
||||
const key = workRecordKey(record);
|
||||
if (key) byKey.set(key, [...(byKey.get(key) ?? []), record.id]);
|
||||
}
|
||||
const tasks = packages.index.packages.flatMap(p => p.tasks).filter(t => t.redundant)
|
||||
.sort((a, b) => a.id.localeCompare(b.id, 'en'));
|
||||
tasks.forEach((task, index) => {
|
||||
const recordIds = [...new Set(task.findingKeys.flatMap(key => byKey.get(key) ?? []))];
|
||||
if (recordIds.length < 2) return;
|
||||
const group = { id: task.id, recordIds, color: resultColors[index % resultColors.length] };
|
||||
recordIds.forEach(id => map.set(id, group));
|
||||
task.findingKeys.flatMap(key => aliases.get(key) ?? []).forEach(id => map.set(id, group));
|
||||
});
|
||||
return map;
|
||||
}, [packages.status === 'ready' ? packages.index : undefined, workSheet, app.dataset]);
|
||||
const current = selection?.scope === scope && selection.dataset === app.dataset && packages.status === 'ready'
|
||||
&& groups.get(selection.group.recordIds[0])?.id === selection.group.id ? selection : undefined;
|
||||
const finish = () => {
|
||||
if (selection?.scope === scope && selection.dataset === app.dataset) {
|
||||
app.applyViewBookmark(selection.before);
|
||||
viewRestorers.current.forEach(restoreView => restoreView());
|
||||
restore.current = true;
|
||||
}
|
||||
setSelection(undefined);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (selection && !current) finish();
|
||||
}, [Boolean(current), selection, scope, app.dataset]);
|
||||
useLayoutEffect(() => {
|
||||
if (!restore.current || selection) return;
|
||||
restore.current = false;
|
||||
// Restore after Reader selection effects and virtual-list recalculation.
|
||||
const frame = requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
for (const position of positions.current) {
|
||||
const node = Array.from(document.querySelectorAll<HTMLElement>('[data-group-scroll]'))
|
||||
.find(el => el.dataset.groupScroll === position.id);
|
||||
if (!node) continue;
|
||||
node.scrollTop = position.top; node.scrollLeft = position.left;
|
||||
node.dispatchEvent(new Event('scroll'));
|
||||
}
|
||||
}));
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [selection]);
|
||||
const toggle = (group: RelatedFindingGroup, recordId: string) => {
|
||||
if (current?.group.id === group.id) { finish(); return; }
|
||||
if (!current) {
|
||||
positions.current = Array.from(document.querySelectorAll<HTMLElement>('[data-group-scroll]'))
|
||||
.map(node => ({ id: node.dataset.groupScroll!, top: node.scrollTop, left: node.scrollLeft }));
|
||||
viewRestorers.current = Array.from(views.current, capture => capture());
|
||||
}
|
||||
const before = current?.before ?? {
|
||||
activeSheetId: app.activeSheetId, view: app.view, search: app.search, sortColumn: app.sortColumn,
|
||||
sortDirection: app.sortDirection, titleColumn: app.titleColumn, statusFilter: app.statusFilter,
|
||||
facetFilters: structuredClone(app.facetFilters), pathFindingIds: [...app.pathFindingIds],
|
||||
actionOnly: app.actionOnly, selectedRecordId: app.selectedRecordId
|
||||
};
|
||||
setSelection({ group, sheet: workSheet, before, scope, dataset: app.dataset });
|
||||
const clicked = app.dataset?.sheets.flatMap(sheet => sheet.analysis.records).find(record => record.id === recordId);
|
||||
const selected = workSheet?.analysis.records.find(record => record.id === recordId || clicked && workRecordKey(record) === workRecordKey(clicked));
|
||||
app.setSelectedRecord(selected?.id ?? recordId);
|
||||
};
|
||||
const showAllInWork = () => {
|
||||
const recordIds = [...new Set([...groups.values()].flatMap(group => group.recordIds))];
|
||||
if (!workSheet || !recordIds.length) return;
|
||||
setSelection(undefined);
|
||||
restore.current = false;
|
||||
app.applyViewBookmark({ activeSheetId: workSheet.id, view: 'records', search: '', statusFilter: 'all',
|
||||
facetFilters: {}, pathFindingIds: recordIds, actionOnly: false, selectedRecordId: recordIds[0],
|
||||
sortColumn: app.sortColumn, sortDirection: app.sortDirection, titleColumn: app.titleColumn });
|
||||
};
|
||||
return <Context.Provider value={{ groups, selection: current, toggle, registerView, showAllInWork }}>{children}</Context.Provider>;
|
||||
}
|
||||
|
||||
export function RelatedFindingRing({ record, inline = false, onGroupOpen }: { record: FindingRecord; inline?: boolean; onGroupOpen?(): void }) {
|
||||
const context = useRelatedFindings();
|
||||
// Every displayed member uses the active group's colour and filled state, including the reading selection.
|
||||
const group = context?.selection?.group.recordIds.includes(record.id) ? context.selection.group : context?.groups.get(record.id);
|
||||
if (!group) return null;
|
||||
const active = context?.selection?.group.id === group.id;
|
||||
const style = { '--related-color': group.color } as CSSProperties;
|
||||
const label = active ? 'Restore previous view' : `Show ${group.recordIds.length} findings for the same change`;
|
||||
return <button type="button" className={`related-finding-ring${inline ? ' is-inline' : ''}`}
|
||||
style={style} data-related-record={record.id}
|
||||
data-related-group={group.id} aria-pressed={Boolean(active)} aria-label={label} title={label}
|
||||
onPointerDown={event => event.stopPropagation()} onKeyDown={event => event.stopPropagation()}
|
||||
onClick={event => { event.stopPropagation(); context!.toggle(group, record.id); if (!active) onGroupOpen?.(); }}><span aria-hidden="true" /></button>;
|
||||
}
|
||||
|
||||
export function RelatedFindingsFocus() {
|
||||
const context = useRelatedFindings();
|
||||
if (!context?.selection) return null;
|
||||
return <div className="related-findings-focus" role="status">
|
||||
<span>{context.selection.group.recordIds.length} related findings · Same change</span>
|
||||
<button className="btn" type="button" onClick={() => context.toggle(context.selection!.group, context.selection!.group.recordIds[0])}>Restore previous view</button>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -92,8 +92,8 @@ function renderFacet(
|
|||
aria-label={facet.label}
|
||||
>
|
||||
{multipleSeverities && <option value={MULTI_SEVERITY_VALUE}>{compactSeveritySelection(selectedValues)}</option>}
|
||||
<option value="">{facet.label}: All{facet.derivedKind === 'context-cluster' ? ` (${facet.allCount ?? 0})` : ''}</option>
|
||||
{facet.values.map(value => <option key={value.value} value={value.value} disabled={facet.derivedKind === 'context-cluster' && value.count === 0}>{value.label} · {value.count}</option>)}
|
||||
<option value="">{facet.label}: All{facet.derivedKind === 'context-cluster' || facet.derivedKind === 'work-package' ? ` (${facet.allCount ?? 0})` : ''}</option>
|
||||
{facet.values.map(value => <option key={value.value} value={value.value} disabled={(facet.derivedKind === 'context-cluster' || facet.derivedKind === 'work-package') && value.count === 0}>{value.label} · {value.count}</option>)}
|
||||
</select>;
|
||||
}
|
||||
const active = Boolean(selected?.minimum || selected?.maximum);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { DynamicFilterControls } from './DynamicFilterControls';
|
|||
import { selectWorkFacets } from './work-facets';
|
||||
import { useContextClusterIndex } from '../context-layer/use-context-cluster-index';
|
||||
import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet';
|
||||
import { useWorkPackages } from '../../remediation/use-work-packages';
|
||||
import { WORK_PACKAGE_FACET_ID } from '../../remediation/remediation-engine';
|
||||
import { measureNavigationStep } from '../../app/navigation-diagnostics';
|
||||
export { WorkTimeslotControl } from './WorkTimeslotControl';
|
||||
|
||||
|
|
@ -92,11 +94,13 @@ export function SearchFilterBar({ hiddenFacetLabels = [] }: SearchFilterBarProps
|
|||
const engines = useEnginePreferences();
|
||||
// The facet uses the shared context for the current scope.
|
||||
const contextClusters = useContextClusterIndex();
|
||||
const remediation = useWorkPackages();
|
||||
const workPackages = remediation.status === 'ready' ? remediation.index : undefined;
|
||||
const countedFacets = useMemo(
|
||||
() => measureNavigationStep('filter-counts', () => selectWorkFacets(activeSheet, {
|
||||
activeProfile, search, statusFilter, facetFilters, dataset, actionOnly, reviews, pathFindingIds, contextClusters
|
||||
activeProfile, search, statusFilter, facetFilters, dataset, actionOnly, reviews, pathFindingIds, contextClusters, workPackages
|
||||
})),
|
||||
[actionOnly, activeProfile, activeSheet, contextClusters, dataset, facetFilters, pathFindingIds, reviews, search, statusFilter]
|
||||
[actionOnly, activeProfile, activeSheet, contextClusters, workPackages, dataset, facetFilters, pathFindingIds, reviews, search, statusFilter]
|
||||
);
|
||||
const visibleFacets = useMemo(
|
||||
() => countedFacets.filter(facet => !hiddenFacetLabels.includes(facet.label)),
|
||||
|
|
@ -123,6 +127,7 @@ export function SearchFilterBar({ hiddenFacetLabels = [] }: SearchFilterBarProps
|
|||
Object.keys(facetFilters).forEach(facetId => {
|
||||
// Keep the selected context filter while its context is loading.
|
||||
if (facetId === CONTEXT_CLUSTER_FACET_ID && engines.context) return;
|
||||
if (facetId === WORK_PACKAGE_FACET_ID && engines.remediation && remediation.status === 'loading') return;
|
||||
if (!contextualFacetIds.has(facetId)) setFacetFilter(facetId, null);
|
||||
});
|
||||
countedFacets.forEach(facet => {
|
||||
|
|
@ -137,7 +142,7 @@ export function SearchFilterBar({ hiddenFacetLabels = [] }: SearchFilterBarProps
|
|||
if (statusFilter !== 'all' && statusFacet && !statusFacet.values.some(value => value.value === statusFilter)) {
|
||||
setStatusFilter('all');
|
||||
}
|
||||
}, [engines.context, countedFacets, facetFilters, setFacetFilter, setStatusFilter, statusFilter]);
|
||||
}, [engines.context, engines.remediation, remediation.status, countedFacets, facetFilters, setFacetFilter, setStatusFilter, statusFilter]);
|
||||
|
||||
return (
|
||||
<div className="search-tools">
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export interface DynamicFacet {
|
|||
minimum?: number;
|
||||
maximum?: number;
|
||||
confidence?: RecognitionConfidenceLevel;
|
||||
derivedKind?: 'cis-benchmark-category' | 'policy-failing-backup' | 'security-domain' | 'policy-baseline' | 'context-cluster';
|
||||
derivedKind?: 'cis-benchmark-category' | 'policy-failing-backup' | 'security-domain' | 'policy-baseline' | 'context-cluster' | 'work-package';
|
||||
/** Record values of a facet derived outside the sheet (context layer); only present while that layer is on. */
|
||||
valuesFor?: (record: FindingRecord) => string[];
|
||||
description?: string;
|
||||
|
|
@ -167,7 +167,7 @@ const labelPriority: Array<{ pattern: RegExp; priority: number }> = [
|
|||
{ pattern: /^mitre\s*att&?ck$/i, priority: 88 },
|
||||
{ pattern: /^mitre\s*d3fend$/i, priority: 86 },
|
||||
{ pattern: /^(?:target|ziel|betroffenes objekt|asset)$/i, priority: 84 },
|
||||
{ pattern: /^result\s*message$/i, priority: 82 }
|
||||
{ pattern: /^work\s*package$/i, priority: 82 }
|
||||
];
|
||||
|
||||
function facetPriority(label: string, role: CanonicalFieldRole | undefined): number {
|
||||
|
|
@ -220,7 +220,7 @@ function facetValueKey(severityScale: boolean | undefined, value: string): strin
|
|||
|
||||
function recordFacetKey(facet: DynamicFacet, value: string): string {
|
||||
// Provenance identities are exact: never case-fold or whitespace-normalize IDs.
|
||||
return facet.derivedKind === 'policy-failing-backup' ? value : facetValueKey(facet.severityScale, value);
|
||||
return facet.derivedKind === 'policy-failing-backup' || facet.derivedKind === 'work-package' ? value : facetValueKey(facet.severityScale, value);
|
||||
}
|
||||
|
||||
function failingGpoBackupFacet(records: FindingRecord[]): DynamicFacet | null {
|
||||
|
|
@ -376,6 +376,7 @@ function fieldFacets(sheet: DatasetSheet, dataset: ImportedDataset | null | unde
|
|||
if (role === 'score' || normalized(column.header) === 'score') return [];
|
||||
if (role && narrativeRoles.has(role)) return [];
|
||||
if (narrativeHeaders.test(clean(column.header))) return [];
|
||||
if (/^result[\s_-]*message$/i.test(clean(column.header))) return [];
|
||||
// Auch ueber die Ueberschrift, da die Rollenerkennung die Statusspalte
|
||||
// nicht immer sicher erkennt - sonst steht sie doppelt in der Leiste,
|
||||
// einmal als "Ergebnisstatus" und einmal als roher Spaltenfilter.
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { createSheetDerivationCache } from '../../shared/sheet-derivation-cache'
|
|||
import { withContextClusterFacet, type ContextClusterIndex } from '../context-layer/context-cluster-facet';
|
||||
import { withBusinessImpactFacet } from './business-impact-controls';
|
||||
import { readEnginePreferences } from '../../app/engine-preferences';
|
||||
import { withWorkPackageFacet, type WorkPackageIndex } from '../../remediation/work-package-index';
|
||||
|
||||
export interface RecordFilterOptions {
|
||||
activeProfile: ImportProfile;
|
||||
|
|
@ -28,6 +29,7 @@ export interface RecordFilterOptions {
|
|||
reviews: Record<string, Review>;
|
||||
/** Context layer facet (Phase 13E); absent while the layer is off, so Work filters exactly as without it. */
|
||||
contextClusters?: ContextClusterIndex;
|
||||
workPackages?: WorkPackageIndex;
|
||||
}
|
||||
|
||||
const cachedRecords = createSheetDerivationCache<FindingRecord[]>();
|
||||
|
|
@ -37,7 +39,7 @@ export function recordFilterKeys(options: RecordFilterOptions): readonly unknown
|
|||
return [options.activeProfile, options.dataset, options.reviews, options.search,
|
||||
options.sortColumn, options.sortDirection, options.statusFilter, options.actionOnly,
|
||||
options.pathFindingIds?.length ? options.pathFindingIds : undefined,
|
||||
JSON.stringify(options.facetFilters ?? {}), options.contextClusters, readEnginePreferences()];
|
||||
JSON.stringify(options.facetFilters ?? {}), options.contextClusters, options.workPackages, readEnginePreferences()];
|
||||
}
|
||||
|
||||
export function filterAndSortRecords(sheet: DatasetSheet | undefined, options: RecordFilterOptions): FindingRecord[] {
|
||||
|
|
@ -47,12 +49,15 @@ export function filterAndSortRecords(sheet: DatasetSheet | undefined, options: R
|
|||
|
||||
function deriveFilteredRecords(sheet: DatasetSheet, options: RecordFilterOptions): FindingRecord[] {
|
||||
if (!sheet) return [];
|
||||
// A selected package must never briefly display unrelated records while its new scope loads.
|
||||
if (readEnginePreferences().remediation && options.facetFilters?.['remediation:work-package']?.values?.length
|
||||
&& !options.workPackages) return [];
|
||||
const normalizedSearch = options.search.trim().toLocaleLowerCase('en-US');
|
||||
const baseFacets = deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews);
|
||||
const contextToolId = selectedFacetContextToolId(sheet, baseFacets, options.facetFilters, options.reviews);
|
||||
const facets = withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy'
|
||||
const facets = withWorkPackageFacet(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy'
|
||||
? deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews, 'group-policy')
|
||||
: baseFacets, options.contextClusters), options.contextClusters);
|
||||
: baseFacets, options.contextClusters), options.contextClusters), options.workPackages);
|
||||
const pathFindingIds = options.pathFindingIds?.length ? new Set(options.pathFindingIds) : undefined;
|
||||
const filtered = sheet.analysis.records.filter(record => {
|
||||
if (pathFindingIds && !pathFindingIds.has(record.id)) return false;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useApp } from '../../app/AppProvider';
|
||||
import { filterAndSortRecords } from './filter-records';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useApp, useOptionalApp } from '../../app/AppProvider';
|
||||
import { filterAndSortRecords, type RecordFilterOptions } from './filter-records';
|
||||
import { useContextClusterIndex } from '../context-layer/use-context-cluster-index';
|
||||
import { useWorkPackages } from '../../remediation/use-work-packages';
|
||||
import { useRelatedFindings } from '../related-findings/RelatedFindings';
|
||||
|
||||
export function useFilteredRecords(statusFilterOverride?: string) {
|
||||
type App = ReturnType<typeof useApp>;
|
||||
function useRecordFilterOptions(app: App | undefined, statusFilterOverride?: string): [App['activeSheet'] | undefined, RecordFilterOptions] {
|
||||
const {
|
||||
activeSheet,
|
||||
activeProfile,
|
||||
|
|
@ -16,21 +19,47 @@ export function useFilteredRecords(statusFilterOverride?: string) {
|
|||
pathFindingIds,
|
||||
actionOnly,
|
||||
reviews
|
||||
} = useApp();
|
||||
} = app ?? ({} as Partial<App>);
|
||||
const contextClusters = useContextClusterIndex();
|
||||
const remediation = useWorkPackages();
|
||||
const workPackages = remediation.status === 'ready' ? remediation.index : undefined;
|
||||
const effectiveStatusFilter = statusFilterOverride ?? statusFilter;
|
||||
|
||||
return useMemo(() => filterAndSortRecords(activeSheet, {
|
||||
activeProfile,
|
||||
const options = useMemo(() => ({
|
||||
activeProfile: activeProfile!,
|
||||
dataset,
|
||||
search,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
statusFilter: effectiveStatusFilter,
|
||||
facetFilters,
|
||||
facetFilters: facetFilters ?? {},
|
||||
pathFindingIds,
|
||||
actionOnly,
|
||||
reviews,
|
||||
contextClusters
|
||||
}), [activeProfile, activeSheet, actionOnly, contextClusters, dataset, effectiveStatusFilter, facetFilters, pathFindingIds, reviews, search, sortColumn, sortDirection]);
|
||||
contextClusters, workPackages
|
||||
} as RecordFilterOptions), [activeProfile, actionOnly, contextClusters, workPackages, dataset, effectiveStatusFilter, facetFilters, pathFindingIds, reviews, search, sortColumn, sortDirection]);
|
||||
return [activeSheet, options];
|
||||
}
|
||||
|
||||
export function useFilteredRecords(statusFilterOverride?: string) {
|
||||
const [activeSheet, options] = useRecordFilterOptions(useApp(), statusFilterOverride);
|
||||
const selection = useRelatedFindings()?.selection;
|
||||
const group = selection?.group;
|
||||
return useMemo(() => filterAndSortRecords(selection?.sheet ?? activeSheet, group ? {
|
||||
...options, search: '', statusFilter: 'all', facetFilters: {}, actionOnly: false, pathFindingIds: group.recordIds
|
||||
} : options), [activeSheet, options, group, selection?.sheet]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a Work record stays visible when one facet filter is replaced. Every other active
|
||||
* Work filter (status, search, facets, drilldown, My Workspace) applies unchanged; evaluated on demand.
|
||||
*/
|
||||
export function useVisibleWithFacet() {
|
||||
const app = useOptionalApp();
|
||||
const [activeSheet, options] = useRecordFilterOptions(app ?? undefined);
|
||||
const selection = useRelatedFindings()?.selection;
|
||||
return useCallback((facetId: string, values: string[], recordId: string) => selection
|
||||
? selection.group.recordIds.includes(recordId)
|
||||
: Boolean(app) && filterAndSortRecords(activeSheet, {
|
||||
...options, facetFilters: { ...options.facetFilters, [facetId]: { values } }
|
||||
}).some(record => record.id === recordId), [app, activeSheet, options, selection]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import {
|
|||
import { filterAndSortRecords, recordFilterKeys, type RecordFilterOptions } from './filter-records';
|
||||
import { withContextClusterFacet } from '../context-layer/context-cluster-facet';
|
||||
import { withBusinessImpactFacet } from './business-impact-controls';
|
||||
import { withWorkPackageFacet, workPackageCode } from '../../remediation/work-package-index';
|
||||
import { comparePublishedClusters, numberedClusterLabel } from '../sidebar/context-cluster-display';
|
||||
|
||||
export type WorkFacetOptions = Omit<RecordFilterOptions, 'sortColumn' | 'sortDirection'>;
|
||||
const cache = createSheetDerivationCache<DynamicFacet[]>();
|
||||
|
|
@ -22,14 +24,30 @@ export function selectWorkFacets(sheet: DatasetSheet | undefined, options: WorkF
|
|||
return cache(sheet, recordFilterKeys(query), () => {
|
||||
const baseFacets = deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews);
|
||||
const contextToolId = selectedFacetContextToolId(sheet, baseFacets, options.facetFilters, options.reviews);
|
||||
const facets = withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy'
|
||||
const facets = withWorkPackageFacet(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy'
|
||||
? deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews, 'group-policy')
|
||||
: baseFacets, options.contextClusters), options.contextClusters);
|
||||
: baseFacets, options.contextClusters), options.contextClusters), options.workPackages);
|
||||
const records = filterAndSortRecords(sheet, { ...query, statusFilter: 'all', facetFilters: emptyFilters });
|
||||
// The engine retains its complete vocabulary; the Work control offers only reachable clusters.
|
||||
const selectableFacets = facets.map(facet => facet.derivedKind === 'context-cluster'
|
||||
? { ...facet, preserveEmptyValues: false } : facet);
|
||||
return recountFacetsAgainstActiveFilters({ facets: selectableFacets, records, filters: options.facetFilters ?? emptyFilters,
|
||||
const counted = recountFacetsAgainstActiveFilters({ facets: selectableFacets, records, filters: options.facetFilters ?? emptyFilters,
|
||||
reviews: options.reviews, statusFilter: options.statusFilter, matchesStatus: options.activeProfile.matchesStatus });
|
||||
const clusters = options.contextClusters?.publication.clusters;
|
||||
return counted.map(facet => {
|
||||
if (facet.id === 'context:cluster') return { ...facet, values: facet.values.map(value => ({ ...value,
|
||||
label: numberedClusterLabel(value.value, value.label, clusters) }))
|
||||
.sort((a, b) => comparePublishedClusters(a.value, b.value, clusters)) };
|
||||
if (facet.id !== 'remediation:work-package') return facet;
|
||||
const packages = new Map(options.workPackages?.packages.map(p => [p.id, p]));
|
||||
return { ...facet, values: facet.values.map(value => {
|
||||
const p = packages.get(value.value);
|
||||
return p ? { ...value, label: `${numberedClusterLabel(p.clusterId, p.clusterLabel, clusters)} → ${workPackageCode(p.code)}` } : value;
|
||||
}).sort((a, b) => {
|
||||
const left = packages.get(a.value), right = packages.get(b.value);
|
||||
return Number(!left) - Number(!right) || comparePublishedClusters(left?.clusterId ?? '', right?.clusterId ?? '', clusters)
|
||||
|| (left?.code ?? a.label).localeCompare(right?.code ?? b.label, 'en', { numeric: true });
|
||||
}) };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { ClusterColour } from './context-cluster-display';
|
||||
|
||||
const edges = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'] as const;
|
||||
type Edge = typeof edges[number];
|
||||
type Bounds = { left: number; top: number; width: number; height: number };
|
||||
function resized(bounds: Bounds, edge: Edge, dx: number, dy: number): Bounds {
|
||||
const minWidth = Math.min(240, window.innerWidth), minHeight = Math.min(120, window.innerHeight);
|
||||
let { left, top } = bounds;
|
||||
let right = left + bounds.width, bottom = top + bounds.height;
|
||||
if (edge.includes('w')) left = Math.max(0, Math.min(left + dx, right - minWidth));
|
||||
if (edge.includes('e')) right = Math.min(window.innerWidth, Math.max(right + dx, left + minWidth));
|
||||
if (edge.includes('n')) top = Math.max(0, Math.min(top + dy, bottom - minHeight));
|
||||
if (edge.includes('s')) bottom = Math.min(window.innerHeight, Math.max(bottom + dy, top + minHeight));
|
||||
return { left, top, width: right - left, height: bottom - top };
|
||||
}
|
||||
|
||||
export interface FilterPanelGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
values: { value: string; label: string; count: number; clusterColour?: ClusterColour;
|
||||
section?: { id: string; label: string } }[];
|
||||
selected: readonly string[];
|
||||
onSelect(value: string): void;
|
||||
unavailable?: string;
|
||||
disableEmpty?: boolean;
|
||||
}
|
||||
|
||||
/** Presentation only: one filter list, docked or floating, with unchanged facet callbacks. */
|
||||
export function DockableFilterPanel({ groups }: { groups: FilterPanelGroup[] }) {
|
||||
const parking = useRef<HTMLDivElement>(null);
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
const drag = useRef<{ pointerId: number; x: number; y: number; left: number; top: number; moved: boolean } | null>(null);
|
||||
const [floating, setFloating] = useState(false);
|
||||
const [position, setPosition] = useState({ left: 240, top: 140 });
|
||||
const [size, setSize] = useState({ width: Math.min(360, window.innerWidth), height: Math.min(600, window.innerHeight * .75) });
|
||||
const geometry = useRef({ ...position, ...size });
|
||||
geometry.current = { ...position, ...size };
|
||||
const sizing = useRef<{ pointerId: number; edge: Edge; x: number; y: number; bounds: Bounds } | null>(null);
|
||||
const applyBounds = ({ left, top, width, height }: Bounds) => { setPosition({ left, top }); setSize({ width, height }); };
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [overParking, setOverParking] = useState(false);
|
||||
const previousFloating = useRef(false);
|
||||
useEffect(() => {
|
||||
if (previousFloating.current === floating) return;
|
||||
previousFloating.current = floating;
|
||||
panel.current?.querySelector<HTMLButtonElement>('header button')?.focus();
|
||||
}, [floating]);
|
||||
const dock = () => { setFloating(false); setOverParking(false); drag.current = null; sizing.current = null; };
|
||||
const inParking = (x: number, y: number) => {
|
||||
const rect = parking.current?.getBoundingClientRect();
|
||||
return !!rect && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
||||
};
|
||||
const clamp = (left: number, top: number) => {
|
||||
const rect = panel.current?.getBoundingClientRect();
|
||||
const detached = panel.current?.classList.contains('is-floating');
|
||||
return {
|
||||
left: Math.max(0, Math.min(left, window.innerWidth - (detached ? rect?.width ?? geometry.current.width : geometry.current.width))),
|
||||
top: Math.max(0, Math.min(top, window.innerHeight - (detached ? rect?.height ?? geometry.current.height : geometry.current.height)))
|
||||
};
|
||||
};
|
||||
useEffect(() => {
|
||||
const move = (event: PointerEvent) => {
|
||||
const activeResize = sizing.current;
|
||||
if (activeResize && event.pointerId === activeResize.pointerId) {
|
||||
applyBounds(resized(activeResize.bounds, activeResize.edge, event.clientX - activeResize.x, event.clientY - activeResize.y));
|
||||
event.preventDefault(); return;
|
||||
}
|
||||
const current = drag.current;
|
||||
if (!current || event.pointerId !== current.pointerId) return;
|
||||
const dx = event.clientX - current.x, dy = event.clientY - current.y;
|
||||
if (!current.moved && Math.hypot(dx, dy) < 4) return;
|
||||
current.moved = true;
|
||||
setFloating(true);
|
||||
setPosition(clamp(current.left + dx, current.top + dy));
|
||||
setOverParking(inParking(event.clientX, event.clientY));
|
||||
event.preventDefault();
|
||||
};
|
||||
const stop = (event: PointerEvent) => {
|
||||
if (sizing.current?.pointerId === event.pointerId) { sizing.current = null; return; }
|
||||
const current = drag.current;
|
||||
if (!current || event.pointerId !== current.pointerId) return;
|
||||
if (current.moved && event.type !== 'pointercancel' && inParking(event.clientX, event.clientY)) dock();
|
||||
drag.current = null;
|
||||
setOverParking(false);
|
||||
};
|
||||
const resize = () => {
|
||||
if (!panel.current?.classList.contains('is-floating') || window.innerWidth <= 1 || window.innerHeight <= 1) return;
|
||||
// Layout can briefly have zero geometry during browser capture/reparenting. Retain the user's dimensions.
|
||||
const current = geometry.current;
|
||||
const width = Math.min(current.width, window.innerWidth), height = Math.min(current.height, window.innerHeight);
|
||||
applyBounds({ width, height, left: Math.max(0, Math.min(current.left, window.innerWidth - width)),
|
||||
top: Math.max(0, Math.min(current.top, window.innerHeight - height)) });
|
||||
};
|
||||
const escape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && panel.current?.contains(document.activeElement)) { event.preventDefault(); dock(); }
|
||||
};
|
||||
window.addEventListener('pointermove', move, { passive: false });
|
||||
window.addEventListener('pointerup', stop);
|
||||
window.addEventListener('pointercancel', stop);
|
||||
window.addEventListener('resize', resize);
|
||||
window.addEventListener('keydown', escape);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', move);
|
||||
window.removeEventListener('pointerup', stop);
|
||||
window.removeEventListener('pointercancel', stop);
|
||||
window.removeEventListener('resize', resize);
|
||||
window.removeEventListener('keydown', escape);
|
||||
};
|
||||
}, []);
|
||||
const start = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (event.button !== 0 || (event.target as HTMLElement).closest('button')) return;
|
||||
const rect = panel.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
drag.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, left: rect.left, top: rect.top, moved: false };
|
||||
event.preventDefault();
|
||||
};
|
||||
const content = <section ref={panel} className={`work-filter-panel${floating ? ' is-floating' : ''}`}
|
||||
aria-label="Work filters" style={floating ? { ...position, width: size.width, height: 'auto', maxHeight: size.height } : undefined}>
|
||||
{floating && edges.map(edge => <button key={edge} type="button" className={`work-filter-resize work-filter-resize-${edge}`}
|
||||
aria-label={`Resize filters ${edge}`} onPointerDown={event => {
|
||||
if (event.button !== 0) return;
|
||||
const rect = panel.current!.getBoundingClientRect();
|
||||
sizing.current = { pointerId: event.pointerId, edge, x: event.clientX, y: event.clientY,
|
||||
bounds: { left: rect.left, top: rect.top, width: rect.width, height: rect.height } };
|
||||
event.stopPropagation(); event.preventDefault();
|
||||
}} onKeyDown={event => {
|
||||
const step = event.shiftKey ? 30 : 10;
|
||||
const dx = event.key === 'ArrowLeft' ? -step : event.key === 'ArrowRight' ? step : 0;
|
||||
const dy = event.key === 'ArrowUp' ? -step : event.key === 'ArrowDown' ? step : 0;
|
||||
if (!dx && !dy) return;
|
||||
event.preventDefault(); const rect = panel.current!.getBoundingClientRect();
|
||||
applyBounds(resized({ left: rect.left, top: rect.top, width: rect.width, height: rect.height }, edge, dx, dy));
|
||||
}}/>)}
|
||||
<header className="work-filter-panel-head" onPointerDown={start}>
|
||||
<span>Filters</span>
|
||||
<button type="button" aria-label={floating ? 'Dock filters' : 'Detach filters'} title={floating ? 'Dock filters' : 'Detach filters'}
|
||||
onClick={() => {
|
||||
if (floating) dock();
|
||||
else { const rect = panel.current?.getBoundingClientRect();
|
||||
const width = Math.min(size.width, window.innerWidth), height = Math.min(size.height, window.innerHeight);
|
||||
applyBounds({ width, height, left: Math.max(0, Math.min((rect?.right ?? 240) + 8, window.innerWidth - width)),
|
||||
top: Math.max(0, Math.min(rect?.top ?? 140, window.innerHeight - height)) }); setFloating(true); }
|
||||
}}>{floating ? '↙' : '↗'}</button>
|
||||
</header>
|
||||
<div className="work-filter-panel-list">
|
||||
{groups.map(group => <details key={group.id} data-filter-id={group.id} className="work-filter-group" open={expanded[group.id] ?? false}
|
||||
onToggle={event => { const open = event.currentTarget.open; setExpanded(current => current[group.id] === open ? current : { ...current, [group.id]: open }); }}>
|
||||
<summary><span className="work-filter-icon" aria-hidden="true">▤</span><span>{group.label}</span>
|
||||
{group.selected.length > 0 && <span className="work-filter-selected" aria-label={`${group.selected.length} selected`}>{group.selected.length}</span>}
|
||||
</summary>
|
||||
{group.unavailable ? <p className="work-filter-empty">{group.unavailable}</p> : group.values.map((value, index) => <div key={value.value}>
|
||||
{value.section && value.section.id !== group.values[index - 1]?.section?.id && <div className="work-package-cluster-head">
|
||||
<span>{value.section.label}</span>
|
||||
{value.clusterColour && <span className={`work-package-cluster-colour severity-${value.clusterColour}`} role="img"
|
||||
aria-label={`Context cluster colour · ${value.clusterColour}`} title={`Context cluster · ${value.clusterColour}`}/>}</div>}
|
||||
<button
|
||||
className="work-filter-value" type="button" aria-label={`${value.section ? `${value.section.label} → ` : ''}${value.label}: ${value.count.toLocaleString('en-US')}`}
|
||||
aria-pressed={group.selected.includes(value.value)} disabled={group.disableEmpty && value.count === 0 && !group.selected.includes(value.value)}
|
||||
onClick={() => group.onSelect(value.value)}><span>{value.label}</span><strong>{value.count.toLocaleString('en-US')}</strong>
|
||||
</button></div>)}
|
||||
</details>)}
|
||||
</div>
|
||||
</section>;
|
||||
return <div ref={parking} className={`work-filter-parking${overParking ? ' is-target' : ''}`}>
|
||||
{floating ? <><button className="work-filter-park-button" type="button" onClick={dock}>Dock filters</button>{createPortal(content, document.body)}</> : content}
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { useMemo } from 'react';
|
||||
import { DockableFilterPanel, type FilterPanelGroup } from './DockableFilterPanel';
|
||||
import { useApp } from '../../app/AppProvider';
|
||||
import type { DatasetSheet, ImportedDataset } from '../../domain/dataset';
|
||||
import type { ImportProfile } from '../../domain/import-profile';
|
||||
|
|
@ -11,6 +12,11 @@ import type { ContextClusterIndex } from '../context-layer/context-cluster-facet
|
|||
import { useContextClusterIndex } from '../context-layer/use-context-cluster-index';
|
||||
import { businessImpactControls, BUSINESS_IMPACT_FACET_ID } from '../search-filter/business-impact-controls';
|
||||
import { useEnginePreferences } from '../../app/engine-preferences';
|
||||
import { useWorkPackages } from '../../remediation/use-work-packages';
|
||||
import { workPackageCode, type WorkPackageIndex } from '../../remediation/work-package-index';
|
||||
import { WORK_PACKAGE_FACET_ID } from '../../remediation/remediation-engine';
|
||||
import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet';
|
||||
import { comparePublishedClusters, numberedClusterLabel, publishedClusterColour } from './context-cluster-display';
|
||||
|
||||
interface SecurityDomainOptions {
|
||||
sheet: DatasetSheet | undefined;
|
||||
|
|
@ -23,6 +29,7 @@ interface SecurityDomainOptions {
|
|||
search: string;
|
||||
actionOnly: boolean;
|
||||
contextClusters?: ContextClusterIndex;
|
||||
workPackages?: WorkPackageIndex;
|
||||
}
|
||||
|
||||
export interface SidebarSecurityDomain {
|
||||
|
|
@ -38,11 +45,11 @@ export interface SidebarSecurityDomainSummary {
|
|||
|
||||
/** Uses the exact shared Client Info facet, ordering and self-excluding counts. */
|
||||
export function summarizeSecurityDomains(options: SecurityDomainOptions): SidebarSecurityDomainSummary | undefined {
|
||||
const { sheet, dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters } = options;
|
||||
const { sheet, dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, workPackages } = options;
|
||||
if (!sheet) return undefined;
|
||||
|
||||
const facets = selectWorkFacets(sheet, {
|
||||
dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters
|
||||
dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, workPackages
|
||||
});
|
||||
const domainFacet = facets.find(facet => facet.id === SECURITY_DOMAIN_FACET_ID);
|
||||
if (!domainFacet) return undefined;
|
||||
|
|
@ -74,15 +81,17 @@ export function SidebarSecurityDomains({ sheet }: { sheet: DatasetSheet | undefi
|
|||
setStatusFilter
|
||||
} = useApp();
|
||||
const contextClusters = useContextClusterIndex();
|
||||
const remediation = useWorkPackages();
|
||||
const workPackages = remediation.status === 'ready' ? remediation.index : undefined;
|
||||
const engines = useEnginePreferences();
|
||||
const impactCounts = useMemo(() => {
|
||||
if (!engines.report || !engines.context || !contextClusters) return undefined;
|
||||
const facet = selectWorkFacets(sheet, { dataset, activeProfile, reviews, statusFilter: 'deviation', facetFilters,
|
||||
pathFindingIds, search, actionOnly, contextClusters }).find(item => item.id === BUSINESS_IMPACT_FACET_ID);
|
||||
pathFindingIds, search, actionOnly, contextClusters, workPackages }).find(item => item.id === BUSINESS_IMPACT_FACET_ID);
|
||||
if (!facet) return undefined;
|
||||
const counts = new Map(facet.values.map(value => [value.value, value.count]));
|
||||
return businessImpactControls.map(theme => ({ ...theme, count: counts.get(theme.id) ?? 0 }));
|
||||
}, [engines.report, engines.context, contextClusters, sheet, dataset, activeProfile, reviews, facetFilters, pathFindingIds, search, actionOnly]);
|
||||
}, [engines.report, engines.context, contextClusters, workPackages, sheet, dataset, activeProfile, reviews, facetFilters, pathFindingIds, search, actionOnly]);
|
||||
const summary = useMemo(() => measureNavigationStep('security-domain-summary', () => summarizeSecurityDomains({
|
||||
sheet,
|
||||
dataset,
|
||||
|
|
@ -93,43 +102,50 @@ export function SidebarSecurityDomains({ sheet }: { sheet: DatasetSheet | undefi
|
|||
pathFindingIds,
|
||||
search,
|
||||
actionOnly,
|
||||
contextClusters
|
||||
})), [actionOnly, activeProfile, contextClusters, dataset, facetFilters, pathFindingIds, reviews, search, sheet, statusFilter]);
|
||||
contextClusters, workPackages
|
||||
})), [actionOnly, activeProfile, contextClusters, workPackages, dataset, facetFilters, pathFindingIds, reviews, search, sheet, statusFilter]);
|
||||
if (!summary) return null;
|
||||
|
||||
const selected = facetFilters[summary.facetId]?.values ?? [];
|
||||
return (
|
||||
<section className="sidebar-domain-rack" aria-labelledby="sidebar-domain-heading">
|
||||
<h2 id="sidebar-domain-heading">Security Domains</h2>
|
||||
{summary.domains.map(domain => {
|
||||
const active = selected.includes(domain.id);
|
||||
return <button
|
||||
aria-label={`${domain.label}: ${domain.count.toLocaleString('en-US')}`}
|
||||
aria-pressed={active}
|
||||
className="sidebar-domain-button"
|
||||
key={domain.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = toggleSecurityDomainSelection(selected, domain.id);
|
||||
setFacetFilter(summary.facetId, next.length ? { values: next } : null);
|
||||
}}
|
||||
>
|
||||
<span>{domain.label}</span>
|
||||
<strong>{domain.count.toLocaleString('en-US')}</strong>
|
||||
</button>;
|
||||
})}
|
||||
{impactCounts && <section className="sidebar-business-impact" aria-labelledby="sidebar-business-impact-heading">
|
||||
<h2 id="sidebar-business-impact-heading">Possible Business Impact</h2>
|
||||
{impactCounts.map(theme => {
|
||||
const active = facetFilters[BUSINESS_IMPACT_FACET_ID]?.values?.includes(theme.id) ?? false;
|
||||
return <button type="button" className="sidebar-domain-button" key={theme.id} aria-pressed={active}
|
||||
aria-label={`${theme.label}: ${theme.count.toLocaleString('en-US')}`} disabled={!active && theme.count === 0}
|
||||
onClick={() => {
|
||||
setStatusFilter('deviation');
|
||||
setFacetFilter(BUSINESS_IMPACT_FACET_ID, active ? null : { values: [theme.id] });
|
||||
}}><span>{theme.label}</span><strong>{theme.count.toLocaleString('en-US')}</strong></button>;
|
||||
})}
|
||||
</section>}
|
||||
</section>
|
||||
);
|
||||
const facets = selectWorkFacets(sheet, { dataset, activeProfile, reviews, statusFilter, facetFilters,
|
||||
pathFindingIds, search, actionOnly, contextClusters, workPackages });
|
||||
const select = (id: string, value: string, deviations = false) => {
|
||||
if (deviations) setStatusFilter('deviation');
|
||||
const selected = facetFilters[id]?.values ?? [];
|
||||
const next = toggleSecurityDomainSelection(selected, value);
|
||||
setFacetFilter(id, next.length ? { values: next } : null);
|
||||
};
|
||||
const groups: FilterPanelGroup[] = [{
|
||||
id: summary.facetId, label: 'Security Domain',
|
||||
values: summary.domains.map(domain => ({ value: domain.id, label: domain.label, count: domain.count })),
|
||||
selected: facetFilters[summary.facetId]?.values ?? [], onSelect: value => select(summary.facetId, value)
|
||||
}];
|
||||
if (engines.report && engines.context) groups.push({
|
||||
id: BUSINESS_IMPACT_FACET_ID, label: 'Business Impact', disableEmpty: true,
|
||||
values: impactCounts?.map(theme => ({ value: theme.id, label: theme.label, count: theme.count })) ?? [],
|
||||
selected: facetFilters[BUSINESS_IMPACT_FACET_ID]?.values ?? [], onSelect: value => select(BUSINESS_IMPACT_FACET_ID, value, true),
|
||||
unavailable: impactCounts ? undefined : 'Context is loading or unavailable.'
|
||||
});
|
||||
if (engines.context) groups.push({
|
||||
id: CONTEXT_CLUSTER_FACET_ID, label: 'Context Cluster',
|
||||
values: (facets.find(facet => facet.id === CONTEXT_CLUSTER_FACET_ID)?.values ?? []).map(value => ({ ...value,
|
||||
label: numberedClusterLabel(value.value, value.label, contextClusters?.publication?.clusters) })).sort((a, b) =>
|
||||
comparePublishedClusters(a.value, b.value, contextClusters?.publication?.clusters)),
|
||||
selected: facetFilters[CONTEXT_CLUSTER_FACET_ID]?.values ?? [], onSelect: value => select(CONTEXT_CLUSTER_FACET_ID, value),
|
||||
unavailable: contextClusters ? undefined : 'Context is loading or unavailable.'
|
||||
});
|
||||
if (engines.remediation) groups.push({
|
||||
id: WORK_PACKAGE_FACET_ID, label: 'Work Package',
|
||||
values: (facets.find(facet => facet.id === WORK_PACKAGE_FACET_ID)?.values ?? []).map(value => {
|
||||
const workPackage = workPackages?.packages.find(candidate => candidate.id === value.value);
|
||||
const cluster = contextClusters?.publication?.clusters.find(candidate => candidate.id === workPackage?.clusterId);
|
||||
return { ...value, label: workPackage ? workPackageCode(workPackage.code) : value.label,
|
||||
section: workPackage ? { id: workPackage.clusterId, label: numberedClusterLabel(workPackage.clusterId, workPackage.clusterLabel, contextClusters?.publication?.clusters) } : undefined,
|
||||
clusterColour: workPackage ? publishedClusterColour(cluster) : undefined };
|
||||
}).sort((a, b) => Number(!a.section) - Number(!b.section)
|
||||
|| comparePublishedClusters(a.section?.id ?? '', b.section?.id ?? '', contextClusters?.publication?.clusters)
|
||||
|| a.label.localeCompare(b.label, 'en', { numeric: true }) || a.value.localeCompare(b.value)),
|
||||
selected: facetFilters[WORK_PACKAGE_FACET_ID]?.values ?? [], onSelect: value => select(WORK_PACKAGE_FACET_ID, value),
|
||||
unavailable: workPackages ? undefined : 'Work packages are loading or unavailable.'
|
||||
});
|
||||
return <DockableFilterPanel groups={groups}/>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
export type ClusterColour = 'critical' | 'high' | 'medium' | 'undetermined';
|
||||
type PublishedCluster = { id: string };
|
||||
/** One display rule for every consumer of the published Context severity. */
|
||||
export function publishedClusterColour(cluster?: { hasEvidence: boolean; severity: ClusterColour }): ClusterColour {
|
||||
return cluster?.hasEvidence ? cluster.severity : 'undetermined';
|
||||
}
|
||||
/** The published array already has the exact heatmap order, including ties and empty cells. */
|
||||
export function comparePublishedClusters(leftId: string, rightId: string, clusters: readonly PublishedCluster[] = []): number {
|
||||
const position = (id: string) => { const index = clusters.findIndex(cluster => cluster.id === id); return index < 0 ? Number.MAX_SAFE_INTEGER : index; };
|
||||
return position(leftId) - position(rightId) || leftId.localeCompare(rightId);
|
||||
}
|
||||
/** Visible numbers are positions in this scope's complete heatmap, never catalog identifiers. */
|
||||
export function contextClusterNumber(id: string, clusters: readonly PublishedCluster[] = []): string | undefined {
|
||||
const index = clusters.findIndex(cluster => cluster.id === id);
|
||||
return index < 0 ? undefined : String(index + 1).padStart(2, '0');
|
||||
}
|
||||
export function numberedClusterLabel(id: string, label: string, clusters: readonly PublishedCluster[] = []) {
|
||||
const number = contextClusterNumber(id, clusters);
|
||||
return number && !label.startsWith(`${number} · `) ? `${number} · ${label}` : label;
|
||||
}
|
||||
|
|
@ -119,8 +119,8 @@ export function ViewBookmarks({ mode = 'view', path, onApplyPathSnapshot }: View
|
|||
</div>
|
||||
{dialogOpen && <div className="view-bookmark-dialog-backdrop" role="presentation" onMouseDown={() => setDialogOpen(false)}>
|
||||
<form className="popover view-bookmark-dialog" role="dialog" aria-modal="true" aria-labelledby="view-bookmark-dialog-title" onSubmit={save} onMouseDown={event => event.stopPropagation()}>
|
||||
<strong id="view-bookmark-dialog-title">{mode === 'path' ? 'Name path snapshot' : 'Name view bookmark'}</strong>
|
||||
<label htmlFor="view-bookmark-name">{mode === 'path' ? 'Path snapshot name' : 'Bookmark name'}</label>
|
||||
<strong id="view-bookmark-dialog-title">{mode === 'path' ? 'Name path snapshot' : 'Name view snapshot'}</strong>
|
||||
<label htmlFor="view-bookmark-name">{mode === 'path' ? 'Path snapshot name' : 'Snapshot name'}</label>
|
||||
<input id="view-bookmark-name" className="input" autoFocus maxLength={32} value={name} onChange={event => setName(event.target.value)} />
|
||||
{duplicateName && <small role="alert">This name is already in use.</small>}
|
||||
<footer>
|
||||
|
|
|
|||
|
|
@ -38,8 +38,13 @@ function maesterPayload(document: DecodedSourceDocument): MaesterPayload {
|
|||
let attempts = 0;
|
||||
for (const script of document.rawText.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi)) {
|
||||
const text = script[2];
|
||||
const starts = /\btype\s*=\s*["']application\/json["']/i.test(script[1])
|
||||
? [{ start: text.search(/\S/), binding: 'json-data' }]
|
||||
// Current Maester templates put `const testResults=<JSON>` in an
|
||||
// application/json block. Read only the literal after its declaration;
|
||||
// never execute the loader or any script from the report.
|
||||
const declaration = /^\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*/.exec(text);
|
||||
const jsonData = /\btype\s*=\s*["']application\/json["']/i.test(script[1]);
|
||||
const starts = jsonData
|
||||
? [{ start: declaration ? declaration[0].length : text.search(/\S/), binding: declaration?.[1] ?? 'json-data' }]
|
||||
: reportObjects(text);
|
||||
let consumedUntil = -1;
|
||||
for (const { start, binding } of starts) {
|
||||
|
|
@ -53,6 +58,9 @@ function maesterPayload(document: DecodedSourceDocument): MaesterPayload {
|
|||
}
|
||||
try {
|
||||
const embedded = readStaticReportObject(text, start);
|
||||
if (jsonData && !/^\s*;?\s*$/.test(text.slice(embedded.end))) {
|
||||
throw new StaticReportLiteralError('Unexpected content after the report data.');
|
||||
}
|
||||
consumedUntil = embedded.end;
|
||||
const value = embedded.value;
|
||||
if (Array.isArray(value.Tenants)) {
|
||||
|
|
@ -151,7 +159,7 @@ function testCandidate(fileName: string, test: Record<string, JsonValue>, path:
|
|||
|
||||
export const maesterHtmlSourceExtractor: SourceExtractor = {
|
||||
id: 'maester-html-source-extractor',
|
||||
version: '1.2.0',
|
||||
version: '1.2.1',
|
||||
detect(document) {
|
||||
const payload = maesterPayload(document);
|
||||
const tests = payload.roots.reduce((count, root) => count + (Array.isArray(root.value.Tests) ? root.value.Tests.length : 0), 0);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { StrictMode } from 'react';
|
|||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './app/App';
|
||||
import { AppProvider } from './app/AppProvider';
|
||||
import { RelatedFindingsProvider } from './features/related-findings/RelatedFindings';
|
||||
import '@fontsource-variable/inter/wght.css';
|
||||
import './styles/layers.css';
|
||||
import './styles/ui-tokens.css';
|
||||
|
|
@ -36,7 +37,7 @@ if (import.meta.env.DEV && new URLSearchParams(location.search).get('measureNavi
|
|||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<AppProvider>
|
||||
<App />
|
||||
<RelatedFindingsProvider><App /></RelatedFindingsProvider>
|
||||
</AppProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import type { DatasetSheet } from '../../domain/dataset';
|
||||
import type { FindingRecord } from '../../domain/finding';
|
||||
|
||||
// Reviewed native RiskRule detail layouts. These are affected lists, not the
|
||||
// anchor-linked domain inventories. Header guards fail closed for new variants.
|
||||
const affectedDetails: Readonly<Record<string, readonly string[]>> = {
|
||||
'S-OldNtlm': ['GPO', 'Value'], 'P-Delegated': ['DN', 'Reason'],
|
||||
'P-AdminLogin': ['DC', 'LoginTime'], 'P-ServiceDomainAdmin': ['DN'],
|
||||
'P-ProtectedUsers': ['User'], 'P-UnprotectedOU': ['OU'], 'A-AdminSDHolder': ['DN'],
|
||||
'A-CertTempCustomSubject': ['Name', 'CA', 'EnrollmentPrincipals', 'LastModified'],
|
||||
'S-DC-SubnetMissing': ['Domain controller', 'ip'],
|
||||
'A-AuditDC': ['Type', 'Audit', 'Problem', 'Rationale', 'Domain controller'],
|
||||
'A-MinPwdLen': ['GPO'], 'A-DC-Spooler': ['Domain controller'],
|
||||
'S-SMB-v1': ['Domain controller'],
|
||||
'A-HardenedPaths': ['GPO', 'Key', 'RequireIntegrity', 'RequireMutualAuthentication', 'RequirePrivacy'],
|
||||
'A-PreWin2000Other': ['DN'], 'A-WeakRSARootCert2': ['Source', 'Subject', 'Module', 'Expires'],
|
||||
'S-PwdNeverExpires': ['DN'], 'A-DsHeuristicsLDAPSecurity': ['Setting', 'Position', 'Value'],
|
||||
'A-DnsZoneAUCreateChild': ['DNSZone'], 'A-DCLdapsProtocolAdvanced': ['DC', 'Protocol'],
|
||||
'S-FolderOptions': ['Extension', 'Reason'], 'S-DefenderASR': ['Guid', 'Label', 'Reason'],
|
||||
'S-FirewallScript': ['Program', 'Reason'], 'S-TerminalServicesGPO': ['GPO', 'Reason']
|
||||
};
|
||||
|
||||
/** Proof from the retained exact rule/import identity and native embedded detail section. */
|
||||
export function pingCastleAffectedDetailRow(finding: FindingRecord, sheet: DatasetSheet, row: FindingRecord): boolean {
|
||||
const ids = finding.fields.filter(field => /^(?:Source Rule ID|Rule ID|Risk ID)$/i.test(field.label.trim()) && field.value.trim());
|
||||
const distinct = [...new Set(ids.map(field => field.value.trim()))];
|
||||
if (distinct.length !== 1 || !finding.importFileId) return false;
|
||||
const ruleId = distinct[0]; const headers = affectedDetails[ruleId];
|
||||
if (!headers || sheet.id !== `pingcastle-section-${finding.importFileId}::rule-details-${ruleId}`) return false;
|
||||
const linkedIds = finding.fields.filter(field => field.label === 'Verwandte Daten').flatMap(field => field.value.split(',').map(value => value.trim()));
|
||||
if (!linkedIds.includes(`${finding.importFileId}::rule-details-${ruleId}`)) return false;
|
||||
if (sheet.analysis.headers.length !== headers.length || headers.some((header, index) => sheet.analysis.headers[index] !== header)) return false;
|
||||
return row.fields.some(field => field.value.trim() !== '') && sheet.analysis.records.includes(row);
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import type { DatasetSheet } from '../../domain/dataset';
|
||||
import type { FindingRecord } from '../../domain/finding';
|
||||
|
||||
// Reviewed Dummy result layouts: indicator-specific affected objects, including
|
||||
// observations of changes. Red denotes scanner-reported affected evidence.
|
||||
const layouts: Readonly<Record<string, readonly string[]>> = {
|
||||
SI000084: ['DomainName', 'Ignored'], SI000079: ['Access', 'DistinguishedName', 'Ignored'],
|
||||
SI000080: ['Identity', 'Access', 'DistinguishedName', 'Ignored'],
|
||||
SI000151: ['DistinguishedName', 'SupportedEncryptionTypes', 'EventTimestamp', 'Ignored'],
|
||||
SI000055: ['Attribute', 'DistinguishedName', 'EventTimestamp', 'Ignored'],
|
||||
SI000096: ['DistinguishedName', 'PasswordPolicyDistinguishedName', 'MaxAge', 'MinAge', 'MinLength', 'ComplexityEnabled', 'History', 'Ignored'],
|
||||
SI000028: ['DistinguishedName', 'EventTimestamp', 'Ignored'],
|
||||
SI000029: ['DistinguishedName', 'SamAccountName', 'PasswordLastSet', 'ServicePrincipalName', 'Ignored'],
|
||||
SI000067: ['DistinguishedName', 'SamAccountName', 'PasswordLastSet', 'ServicePrincipalName', 'Ignored'],
|
||||
SI000020: ['DistinguishedName', 'SamAccountName', 'Enabled', 'Ignored'],
|
||||
SI000104: ['DistinguishedName', 'Member', 'Operation', 'EventTimestamp', 'Ignored'],
|
||||
SI000351: ['DistinguishedName', 'UsersWithWriteAccess', 'Ignored'],
|
||||
SI000034: ['DistinguishedName', 'SamAccountName', 'Type', 'EventTimestamp', 'Ignored'],
|
||||
SI000095: ['HostName', 'IPAddress', 'NTLMHttpsStatus', 'NTLMHttpStatus', 'Ignored'],
|
||||
SI000077: ['HostName', 'Ignored'], SI000036: ['HostName', 'Ignored'],
|
||||
SI000157: ['DistinguishedName', 'CertificateTemplateName', 'SANEnabled', 'CertificateCanBeUsedForAuthentication', 'Published', 'Ignored'],
|
||||
SI000156: ['DistinguishedName', 'CertificateTemplateName', 'PotentialAbusableProblems', 'Published', 'Ignored'],
|
||||
SI000011: ['DistinguishedName', 'MachineAccountQuota', 'Ignored'],
|
||||
SI000302: ['user', 'privilege', 'policy', 'policyName', 'status', 'LinkedOUs', 'Ignored']
|
||||
};
|
||||
|
||||
export function purpleKnightAffectedResultRow(finding: FindingRecord, sheet: DatasetSheet, row: FindingRecord): boolean {
|
||||
const ids = [...new Set(finding.fields.filter(field => /^(?:Source Rule ID|ShortName)$/i.test(field.label.trim()) && field.value.trim()).map(field => field.value.trim()))];
|
||||
if (ids.length !== 1) return false;
|
||||
const headers = layouts[ids[0]];
|
||||
if (!headers || sheet.analysis.headers.length !== headers.length || headers.some((header, index) => sheet.analysis.headers[index] !== header)) return false;
|
||||
const sourceFile = finding.fields.find(field => /^(?:source: file|source file|quelle: datei)$/i.test(field.label.trim()))?.value.trim() || finding.sourceFile;
|
||||
if (!sourceFile || sheet.sourceFile !== sourceFile || sheet.sourceTableIndex === undefined) return false;
|
||||
const indices = finding.fields.filter(field => /^(?:results?|result file)$/i.test(field.label.trim()))
|
||||
.flatMap(field => [...field.value.matchAll(/source-table:(\d+)(?=\s|$)/g)].map(match => Number(match[1])));
|
||||
const nativeIndicatorLink = finding.fields.some(field => /^Result$/i.test(field.label.trim()) && field.value.trim() === ids[0])
|
||||
&& (sheet.sourceSheet === ids[0] || sheet.name.split(' · ').at(-1) === ids[0]);
|
||||
if (!indices.includes(sheet.sourceTableIndex) && !nativeIndicatorLink) return false;
|
||||
const ignored = row.fields.filter(field => field.label === 'Ignored');
|
||||
return ignored.length === 1 && /^false$/i.test(ignored[0].value.trim()) && sheet.analysis.records.includes(row);
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { useState } from 'react';
|
||||
import { workPackageCode } from './work-package-label';
|
||||
import type { FindingRecord } from '../domain/finding';
|
||||
import { useWorkPackages } from './use-work-packages';
|
||||
import { useOptionalApp } from '../app/AppProvider';
|
||||
import { useVisibleWithFacet } from '../features/search-filter/use-filtered-records';
|
||||
import { WORK_PACKAGE_FACET_ID, type WorkPackage } from './remediation-engine';
|
||||
import { workRecordKey } from './work-package-index';
|
||||
|
||||
const field = (record: FindingRecord, label: string) => record.fields.find(f => f.label.trim().toLowerCase() === label)?.value.trim() ?? '';
|
||||
/** Names a finding by its source and original rule ID; the title only where no rule ID is retained. */
|
||||
function sourceName(record: FindingRecord) {
|
||||
const source = field(record, 'technical identity') ? 'GPO' : field(record, 'source: scan').split(' · ')[0];
|
||||
const rule = field(record, 'source rule id') || record.title;
|
||||
return source ? `${source} ${rule}` : rule;
|
||||
}
|
||||
|
||||
export function FindingWorkPackage({ record, visibleRecords, clusterSeverities = {}, clusterLabels = {} }: { record: FindingRecord; visibleRecords?: FindingRecord[];
|
||||
clusterLabels?: Record<string, string>;
|
||||
clusterSeverities?: Record<string, 'critical' | 'high' | 'medium' | 'undetermined'> }) {
|
||||
const state = useWorkPackages();
|
||||
const app = useOptionalApp();
|
||||
if (state.status === 'off') return null;
|
||||
if (state.status !== 'ready') return <span className="finding-work-package" data-work-package-state={state.status}
|
||||
title={state.status === 'error' ? state.message : state.status === 'context-required' ? 'Enable Context once to prepare packages for this scope.' : undefined}>
|
||||
Work Package: {state.status === 'loading' ? 'Loading…' : 'Unavailable'}</span>;
|
||||
const workPackage = state.index.packageFor(record);
|
||||
if (!workPackage) return state.index.unassigned(record) ? <span className="finding-work-package" data-work-package-state="unassigned">Work Package: Unassigned</span> : null;
|
||||
const key = workRecordKey(record);
|
||||
const task = workPackage.tasks.find(candidate => candidate.findingKeys.includes(key));
|
||||
const others = task?.redundant ? task.findingKeys.filter(other => other !== key) : [];
|
||||
const selectPackage = () => app?.setFacetFilter(WORK_PACKAGE_FACET_ID, { values: [workPackage.id] });
|
||||
// One flex item beside Source Rule ID: the task line continues the link inline instead of wrapping as its own item.
|
||||
return <span className="finding-work-package__group">
|
||||
<a className="finding-work-package" data-work-package-id={workPackage.id}
|
||||
href={`#work-package=${encodeURIComponent(workPackage.id)}`} onClick={event => { event.preventDefault(); selectPackage(); }}
|
||||
title={`${workPackage.title} · ${workPackage.facts.tasks} tasks · ${workPackage.facts.findings} findings · ${workPackage.reason}`}>
|
||||
<span aria-hidden="true" data-finding-work-package-colour={clusterSeverities[workPackage.clusterId] ?? 'undetermined'}
|
||||
className={`work-package-cluster-colour finding-work-package__colour severity-${clusterSeverities[workPackage.clusterId] ?? 'undetermined'}`} />
|
||||
{clusterLabels[workPackage.clusterId] ?? workPackage.clusterLabel} → {workPackageCode(workPackage.code)}
|
||||
</a>
|
||||
{task && <span className="finding-work-package__detail" data-work-package-task={task.id}>
|
||||
{/* No ordinal: tasks have no sequence, package and task order are identifiers only. */}
|
||||
{' '}· One of {workPackage.tasks.length} {workPackage.tasks.length === 1 ? 'task' : 'tasks'}
|
||||
{others.length > 0 && app && <RedundantFindings key={record.id} workPackage={workPackage} others={others} onPackage={selectPackage} visibleRecords={visibleRecords}/>}
|
||||
</span>}
|
||||
</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Links to the redundant findings of the same task, resolved in the current Work sheet.
|
||||
* Variant 1 (user decision 2026-10-09): a click sets only the package filter and selects the
|
||||
* finding; other Work filters stay, and a finding they hide is reported, never revealed silently.
|
||||
*/
|
||||
function RedundantFindings({ workPackage, others, onPackage, visibleRecords }: { workPackage: WorkPackage; others: string[]; onPackage(): void; visibleRecords?: FindingRecord[] }) {
|
||||
const app = useOptionalApp();
|
||||
const visibleWith = useVisibleWithFacet();
|
||||
const [hidden, setHidden] = useState<string>();
|
||||
const sheetRecords = visibleRecords ?? app?.activeSheet?.analysis.records ?? [];
|
||||
const linked = others.map(other => sheetRecords.find(candidate => workRecordKey(candidate) === other)).filter((r): r is FindingRecord => Boolean(r));
|
||||
const missing = others.length - linked.length;
|
||||
const open = (target: FindingRecord) => {
|
||||
if (!visibleWith(WORK_PACKAGE_FACET_ID, [workPackage.id], target.id)) { setHidden(sourceName(target)); return; }
|
||||
setHidden(undefined);
|
||||
onPackage();
|
||||
app?.setSelectedRecord(target.id);
|
||||
};
|
||||
return <>
|
||||
<span data-work-package-redundant> · Same change as {linked.map((target, index) => <span key={target.id}>
|
||||
{index > 0 && ', '}
|
||||
<a className="finding-work-package__finding" data-work-package-finding={target.id} href={`#work-record=${encodeURIComponent(target.id)}`}
|
||||
onClick={event => { event.preventDefault(); open(target); }} title="Show this finding in Work">{sourceName(target)}</a>
|
||||
</span>)}{missing > 0 && `${linked.length ? ', ' : ''}${missing} further ${missing === 1 ? 'finding' : 'findings'} outside this sheet`}</span>
|
||||
{hidden && <span role="status" data-work-package-hidden> · {hidden} is hidden by the current Work filters.</span>}
|
||||
</>;
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { workPackageCode } from './work-package-label';
|
||||
import { FloatingDataWindow } from '../components/FloatingDataWindow/FloatingDataWindow';
|
||||
import type { WorkPackage, WorkPlatform } from './remediation-engine';
|
||||
import type { WorkPackageOverview, WorkPackageOverviewRow } from './work-package-overview';
|
||||
|
||||
const PLATFORM_LABEL: Record<WorkPlatform, string> = {
|
||||
'windows-host': 'Windows host / GPO', 'active-directory': 'Active Directory', cloud: 'Cloud / M365', other: 'Other'
|
||||
};
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
'cis-cat': 'CIS-CAT', 'group-policy': 'GPO', maester: 'Maester', pingcastle: 'PingCastle', 'purple-knight': 'Purple Knight'
|
||||
};
|
||||
const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info'];
|
||||
const text = (a: string, b: string) => a.localeCompare(b, 'en', { numeric: true });
|
||||
|
||||
type Column = 'package' | 'measure' | 'platform' | 'tasks' | 'findings' | 'redundant' | 'contextSeverity' | 'closed' | 'sources';
|
||||
const COLUMNS: Array<{ id: Column; label: string; numeric?: boolean }> = [
|
||||
{ id: 'package', label: 'Package' }, { id: 'measure', label: 'Measure' }, { id: 'platform', label: 'Platform' },
|
||||
{ id: 'tasks', label: 'Tasks', numeric: true }, { id: 'findings', label: 'Findings', numeric: true },
|
||||
{ id: 'redundant', label: 'Redundant findings', numeric: true }, { id: 'contextSeverity', label: 'Context severity', numeric: true },
|
||||
{ id: 'closed', label: 'Closed conditions', numeric: true },
|
||||
{ id: 'sources', label: 'Sources' }
|
||||
];
|
||||
const compare: Record<Column, (a: WorkPackage, b: WorkPackage) => number> = {
|
||||
package: (a, b) => text(a.clusterLabel, b.clusterLabel) || text(a.code, b.code),
|
||||
measure: (a, b) => text(a.title, b.title),
|
||||
platform: (a, b) => text(PLATFORM_LABEL[a.platform], PLATFORM_LABEL[b.platform]),
|
||||
tasks: (a, b) => a.facts.tasks - b.facts.tasks,
|
||||
findings: (a, b) => a.facts.findings - b.facts.findings,
|
||||
redundant: (a, b) => a.facts.redundantFindings - b.facts.redundantFindings,
|
||||
contextSeverity: () => 0,
|
||||
closed: (a, b) => a.facts.conditionsClosed - b.facts.conditionsClosed || a.facts.conditionsTouched - b.facts.conditionsTouched,
|
||||
sources: (a, b) => text(a.facts.sources.join(), b.facts.sources.join())
|
||||
};
|
||||
|
||||
/**
|
||||
* Sortable overview of the work packages of the current scope. Source-backed facts only:
|
||||
* sorting is the viewer's choice, package numbers stay identifiers, never priorities.
|
||||
*/
|
||||
export function WorkPackagesWindow({ overview, clusterLabels = {}, clusterSeverities = {}, clusterOrder = [], onSelect, onSelectUnassigned, onSelectRelated, onClose }: {
|
||||
overview: WorkPackageOverview; clusterLabels?: Record<string, string>;
|
||||
clusterSeverities?: Record<string, 'critical' | 'high' | 'medium' | 'undetermined'>;
|
||||
clusterOrder?: readonly string[];
|
||||
onSelect(workPackage: WorkPackage): void; onSelectUnassigned(): void; onSelectRelated(): void; onClose(): void;
|
||||
}) {
|
||||
const [sort, setSort] = useState<{ column: Column; direction: 'ascending' | 'descending' }>({ column: 'package', direction: 'ascending' });
|
||||
const [filters, setFilters] = useState<Partial<Record<Column, string>>>({});
|
||||
const packageLabel = (p: WorkPackage) => `${clusterLabels[p.clusterId] ?? p.clusterLabel} → ${workPackageCode(p.code)}`;
|
||||
const packageById = new Map(overview.rows.map(row => [row.workPackage.id, row.workPackage]));
|
||||
const contextSeverity = (p: WorkPackage) => clusterSeverities[p.clusterId] ?? 'undetermined';
|
||||
const contextRank = (p: WorkPackage) => contextSeverity(p) === 'undetermined' ? 0 : SEVERITY_ORDER.length - SEVERITY_ORDER.indexOf(contextSeverity(p));
|
||||
const values = (row: WorkPackageOverviewRow): Record<Column, string> => {
|
||||
const p = row.workPackage;
|
||||
return { package: p.id, measure: [p.title, ...p.measures.map(m => m.title)].join(' '), platform: PLATFORM_LABEL[p.platform],
|
||||
tasks: String(row.tasks), findings: String(row.findings), redundant: row.redundantFindings ? String(row.redundantFindings) : '—',
|
||||
contextSeverity: contextSeverity(p),
|
||||
closed: p.facts.conditionsTouched ? `${p.facts.conditionsClosed} of ${p.facts.conditionsTouched}` : '—',
|
||||
sources: p.facts.sources.map(s => SOURCE_LABEL[s] ?? s).join(', ') };
|
||||
};
|
||||
const terms = (column: Column, value: string) => column === 'sources' ? value.split(', ') : [value];
|
||||
const matches = (cells: Record<Column, string>) => Object.entries(filters).every(([column, value]) =>
|
||||
!value || terms(column as Column, cells[column as Column]).includes(value));
|
||||
const unassignedCells = Object.fromEntries(COLUMNS.map(c => [c.id, c.id === 'package' ? 'Unassigned' : c.id === 'findings'
|
||||
? String(overview.unassigned) : c.id === 'measure' ? 'No package assignment.' : '—'])) as Record<Column, string>;
|
||||
const showUnassigned = matches(unassignedCells);
|
||||
const optionsFor = (column: Column) => [...new Set([
|
||||
...overview.rows.flatMap(row => terms(column, values(row)[column])), ...terms(column, unassignedCells[column])
|
||||
])].sort((a, b) => column === 'contextSeverity'
|
||||
? (SEVERITY_ORDER.indexOf(a) < 0 ? 99 : SEVERITY_ORDER.indexOf(a)) - (SEVERITY_ORDER.indexOf(b) < 0 ? 99 : SEVERITY_ORDER.indexOf(b)) || text(a, b)
|
||||
: column === 'package' ? Number(!packageById.has(a)) - Number(!packageById.has(b))
|
||||
|| text(packageById.has(a) ? packageLabel(packageById.get(a)!) : a, packageById.has(b) ? packageLabel(packageById.get(b)!) : b)
|
||||
: text(a, b));
|
||||
// Cluster groups always follow the published heatmap. Column sorting applies inside a group.
|
||||
const clusterPosition = (id: string) => { const index = clusterOrder.indexOf(id); return index < 0 ? Number.MAX_SAFE_INTEGER : index; };
|
||||
const compareClusters = (a: WorkPackage, b: WorkPackage) => clusterPosition(a.clusterId) - clusterPosition(b.clusterId)
|
||||
|| text(clusterLabels[a.clusterId] ?? a.clusterLabel, clusterLabels[b.clusterId] ?? b.clusterLabel);
|
||||
const compareRows = (a: WorkPackageOverviewRow, b: WorkPackageOverviewRow) => sort.column === 'package' ? text(packageLabel(a.workPackage), packageLabel(b.workPackage))
|
||||
: sort.column === 'contextSeverity' ? contextRank(a.workPackage) - contextRank(b.workPackage)
|
||||
: sort.column === 'tasks' ? a.tasks - b.tasks
|
||||
: sort.column === 'findings' ? a.findings - b.findings : sort.column === 'redundant' ? a.redundantFindings - b.redundantFindings
|
||||
: compare[sort.column](a.workPackage, b.workPackage);
|
||||
const rows = useMemo(() => overview.rows.filter(row => matches(values(row))).sort((a, b) =>
|
||||
compareClusters(a.workPackage, b.workPackage) || (sort.direction === 'ascending' ? compareRows(a, b) : compareRows(b, a))
|
||||
|| text(a.workPackage.code, b.workPackage.code)), [overview.rows, sort, filters, clusterLabels, clusterSeverities, clusterOrder]);
|
||||
const toggle = (column: Column, numeric?: boolean) => setSort(current => current.column === column
|
||||
? { column, direction: current.direction === 'ascending' ? 'descending' : 'ascending' }
|
||||
: { column, direction: numeric ? 'descending' : 'ascending' });
|
||||
const redundantFindings = overview.rows.reduce((total, row) => total + row.redundantFindings, 0);
|
||||
const number = (value: number) => value.toLocaleString('en-US');
|
||||
const count = (value: number, singular: string) => `${number(value)} ${singular}${value === 1 ? '' : 's'}`;
|
||||
return <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>
|
||||
<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>
|
||||
<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>
|
||||
</dl>
|
||||
<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' : ''}.
|
||||
<button type="button" className="work-packages__select" onClick={() => setFilters({})}>Clear column filters</button>
|
||||
</p>}
|
||||
<div className="work-packages__scroll" tabIndex={0} aria-label="Work packages table"><table className="work-packages__table">
|
||||
<thead><tr>{COLUMNS.map(column => <th key={column.id} scope="col"
|
||||
aria-sort={sort.column === column.id ? sort.direction : 'none'}>
|
||||
<button type="button" className="work-packages__sort" data-sort-column={column.id}
|
||||
onClick={() => toggle(column.id, column.numeric)}>{column.label}</button>
|
||||
{column.id !== 'measure' && <select className="work-packages__filter" aria-label={`Filter ${column.label}`}
|
||||
value={filters[column.id] ?? ''} onChange={event => setFilters(current => ({ ...current, [column.id]: event.target.value }))}>
|
||||
<option value="">All</option>
|
||||
{optionsFor(column.id).map(value => <option key={value} value={value}>{column.id === 'contextSeverity'
|
||||
? value[0].toUpperCase() + value.slice(1) : column.id === 'package' && packageById.has(value) ? packageLabel(packageById.get(value)!) : value}</option>)}
|
||||
</select>}
|
||||
</th>)}</tr></thead>
|
||||
<tbody>{rows.map((row, index) => { const p = row.workPackage; return <Fragment key={p.id}>
|
||||
{(index === 0 || rows[index - 1].workPackage.clusterId !== p.clusterId) && <tr className="work-packages__cluster" data-work-package-cluster={p.clusterId}>
|
||||
<th colSpan={COLUMNS.length}><span className="work-packages__identity"><span aria-hidden="true" className={`work-package-cluster-colour severity-${contextSeverity(p)}`}/>
|
||||
{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>
|
||||
<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>
|
||||
<td data-context-severity={contextSeverity(p)}><span className={`severity-badge severity-${contextSeverity(p) === 'undetermined' ? 'unknown' : contextSeverity(p)}`}>
|
||||
{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>
|
||||
<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>
|
||||
<td>No package assignment.</td><td>—</td><td>—</td>
|
||||
<td>{number(overview.unassigned)}</td><td>—</td><td>—</td><td>—</td><td>—</td>
|
||||
</tr>}
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</FloatingDataWindow>;
|
||||
}
|
||||
|
|
@ -0,0 +1,316 @@
|
|||
/** Reviewed against the complete Dummy Work population (both assessments).
|
||||
* Conditions are published Context results, never imports of its catalogue.
|
||||
* Broad Context conditions alone cannot establish a shared intervention: source
|
||||
* setting anchors distinguish the actual change target. No scores or verdicts.
|
||||
*/
|
||||
interface Candidate { tool: string; title: string; outcome: string; identityKey?: string; scope?: string }
|
||||
interface Intervention {
|
||||
id: string; cluster: string; title: string; reason: string;
|
||||
conditions?: readonly string[]; settings?: readonly string[]; warnings?: boolean;
|
||||
}
|
||||
const entries: Intervention[] = [];
|
||||
function add(cluster: string, id: string, title: string, reason: string,
|
||||
conditions: string[] = [], settings: string[] = [], warnings = false) {
|
||||
entries.push({ cluster, id, title, reason, conditions, settings, warnings });
|
||||
}
|
||||
// Exact published semantic conditions can span scanner evidence and GPO declarations.
|
||||
add('ad-persistence-indicators', 'admin-protection', 'Review protected administrative object permissions',
|
||||
'Review AdminSDHolder permissions and stale adminCount protection together; retain each source assertion.',
|
||||
['ad-stale-admincount', 'adminsdholder-permissions-changed']);
|
||||
add('ad-privileged-account-exposure', 'admin-identities', 'Protect and restrict administrative identities',
|
||||
'Review Protected Users membership and use of the built-in Administrator in the same privileged identity inventory.',
|
||||
['ad-admins-not-protected-users', 'ad-builtin-admin-used']);
|
||||
add('ad-privileged-account-exposure', 'directory-write-protection', 'Review privileged directory write protection',
|
||||
'Review privileged object permissions and LDAP write mitigations; each source remains a separate technical check.',
|
||||
['dsheuristics-ldap-mitigation-off', 'ad-shadow-credentials-privileged']);
|
||||
add('ad-reconnaissance-exposure', 'anonymous-directory', 'Restrict anonymous and legacy directory read access',
|
||||
'Review anonymous LDAP and Pre-Windows 2000 permissions as one directory access change.',
|
||||
['anonymous-ldap-bind', 'prewin2000-access-broad', 'prewin2000-membership-changed']);
|
||||
add('ad-reconnaissance-exposure', 'anonymous-windows', 'Restrict unauthenticated Windows enumeration',
|
||||
'Review exposed named pipes and network session enumeration permissions together.',
|
||||
['anonymous-named-pipes', 'net-session-enumeration-open']);
|
||||
add('ad-recovery-gaps', 'object-recovery', 'Prepare directory object recovery',
|
||||
'Verify backup freshness, Recycle Bin and accidental deletion protection in the same directory recovery procedure.',
|
||||
['ad-recycle-bin-disabled', 'ad-backup-outdated', 'ou-deletion-unprotected']);
|
||||
add('ad-recovery-gaps', 'dc-resilience', 'Review domain controller topology and redundancy',
|
||||
'Review controller redundancy and subnet declarations within the same site topology.',
|
||||
['dc-redundancy-missing', 'dc-subnet-declaration-incomplete']);
|
||||
add('adcs-escalation', 'certificate-templates', 'Review certificate template issuance permissions',
|
||||
'Review requester-controlled certificate properties and insecure template configuration together.',
|
||||
['adcs-template-requester-san', 'adcs-template-insecure-config']);
|
||||
add('cloud-app-consent-exposure', 'app-lifecycle', 'Review application and credential lifecycles',
|
||||
'Inventory applications, renew expiring credentials and remove unused credentials or applications after review.',
|
||||
['entra-app-credentials-expiring', 'entra-app-credentials-stale'], [], true);
|
||||
add('cloud-app-consent-exposure', 'consent-workflow', 'Restrict application consent and enable approval',
|
||||
'Configure who can consent and how users request administrative approval as one consent workflow.',
|
||||
['entra-users-can-consent-apps', 'entra-admin-consent-workflow-missing']);
|
||||
add('cloud-external-access-exposure', 'guest-invitation', 'Restrict guest invitations and allowed domains',
|
||||
'Coordinate guest inviter permissions and approved external domains.',
|
||||
['entra-guest-invites-unrestricted', 'entra-guest-invite-domains-unrestricted']);
|
||||
add('cloud-external-access-exposure', 'sharepoint-sharing', 'Restrict SharePoint external sharing',
|
||||
'Configure SharePoint sharing scope and approved domain or group restrictions together.', ['sharepoint-external-sharing-broad']);
|
||||
add('cloud-external-access-exposure', 'self-service-signup', 'Restrict tenant self-service signup',
|
||||
'Review email subscription signup and tenant joining by email validation together.', ['entra-self-service-signup-allowed']);
|
||||
add('cloud-sign-in-protection-gaps', 'risk-policies', 'Coordinate risky user and sign-in policies',
|
||||
'Review risky user and sign-in responses together, retaining the individual policy requirements.', [],
|
||||
['high risk', 'sign-in risk', 'sign in risk', 'risky sign', 'user risk']);
|
||||
add('cloud-sign-in-protection-gaps', 'authentication-methods', 'Harden authentication methods',
|
||||
'Coordinate phishing-resistant methods, weak method restrictions and FIDO key requirements.', [],
|
||||
['phishing-resistant', 'phishing resistant', 'sms', 'fido', 'third-party software']);
|
||||
add('cloud-sign-in-protection-gaps', 'authenticator-fatigue', 'Configure Authenticator prompts and suspicious activity reporting',
|
||||
'Review Authenticator login context, OTP and suspicious activity reporting together.', ['entra-mfa-fatigue-protection-weak'],
|
||||
['login context', 'suspicious activity', 'otp']);
|
||||
add('cloud-sign-in-protection-gaps', 'registration', 'Secure authentication method registration',
|
||||
'Coordinate MFA registration requirements and trusted registration locations or devices.',
|
||||
['entra-mfa-registration-incomplete', 'entra-mfa-registration-unrestricted'],
|
||||
['registration', 'register security']);
|
||||
add('cloud-sign-in-protection-gaps', 'device-compliance', 'Enforce managed device compliance',
|
||||
'Review managed device access, compliance policy coverage and device cleanup together.', [],
|
||||
['intune', 'compliant', 'managed device']);
|
||||
add('cloud-sign-in-protection-gaps', 'legacy-authentication', 'Block legacy authentication',
|
||||
'Coordinate Conditional Access restrictions for legacy authentication clients.', [], ['legacy authentication']);
|
||||
add('cloud-sign-in-protection-gaps', 'conditional-access-integrity', 'Repair Conditional Access references and exclusions',
|
||||
'Review deleted objects, fallback exclusions and directory synchronization scoping in the same policy inventory.', [],
|
||||
['deleted', 'non-existent', 'nonexistent', 'fallback', 'directory sync', 'directory synchronization']);
|
||||
add('cloud-sign-in-protection-gaps', 'conditional-access-coverage', 'Review Conditional Access coverage',
|
||||
'Review unsupported platforms, application restrictions and uncovered access targets in the same policy rollout.', [],
|
||||
['insider risk', 'azure devops', 'unsupported', 'application restrictions', 'application enforced restrictions']);
|
||||
add('cloud-sign-in-protection-gaps', 'session-controls', 'Configure sign-in session controls',
|
||||
'Coordinate persistent browser sessions and sign-in frequency.', [], ['persistent', 'sign-in frequency']);
|
||||
add('cloud-sign-in-protection-gaps', 'password-protection', 'Configure cloud password protection',
|
||||
'Review password protection mode and smart lockout together.', [], ['password protection', 'smart lockout']);
|
||||
add('credential-relay-exposure', 'name-resolution', 'Disable unnecessary legacy name resolution',
|
||||
'Review multicast and NetBIOS name resolution fallback in one network policy rollout.', [],
|
||||
['multicast', 'netbios', 'netbt', 'llmnr']);
|
||||
add('credential-relay-exposure', 'dns-permissions', 'Harden DNS trust and update permissions',
|
||||
'Review DNS client configuration, DNS server selection and directory DNS update permissions.',
|
||||
['gpo-dns-client-policy-deviates', 'adidns-authenticated-create'], ['dns servers']);
|
||||
add('credential-relay-exposure', 'proxy-discovery', 'Restrict proxy discovery and proxy authentication',
|
||||
'Review WPAD and loopback proxy authentication together.', [], ['wpad', 'proxy']);
|
||||
add('credential-relay-exposure', 'ldap-transport', 'Require protected LDAP transport',
|
||||
'Coordinate LDAP signing, channel binding and domain controller TLS settings.', ['dc-ldaps-legacy-tls'], ['ldap server', 'ldaps']);
|
||||
add('credential-relay-exposure', 'smb-audit', 'Enable SMB client and server security auditing',
|
||||
'Enable the related SMB guest, signing and encryption audit events in one logging rollout.', [], ['audit']);
|
||||
add('credential-relay-exposure', 'smb-guest', 'Remove insecure SMB guest and mailslot access',
|
||||
'Review insecure guest access and remote mailslots on the SMB client and server.', [], ['mailslot', 'insecure guest']);
|
||||
add('credential-relay-exposure', 'smb-protocol', 'Enforce SMB protocol protection',
|
||||
'Coordinate SMB signing, encryption and minimum protocol versions.', [],
|
||||
['digitally sign', 'minimum smb', 'minimum version of smb', 'require encryption']);
|
||||
add('credential-relay-exposure', 'smb-rate', 'Configure SMB authentication rate limiting',
|
||||
'Review SMB authentication rate limiter enablement and delay together.', [], ['rate limit']);
|
||||
add('credential-relay-exposure', 'unc-paths', 'Harden UNC paths',
|
||||
'Review mutual authentication and integrity requirements for the retained UNC paths.', [], ['hardened unc', 'hardened paths']);
|
||||
add('credential-relay-exposure', 'ntlm-protocol', 'Restrict NTLM versions and session security',
|
||||
'Coordinate LAN Manager authentication level and NTLM client/server minimum session security.', [],
|
||||
['lan manager', 'minimum session security', 'old ntlm', 'ntlmv1']);
|
||||
add('credential-relay-exposure', 'ntlm-identities', 'Restrict fallback authentication identities',
|
||||
'Review LocalSystem NTLM identity, NULL fallback and PKU2U authentication together.', [], ['localsystem', 'local system', 'pku2u']);
|
||||
add('credential-relay-exposure', 'dc-print-spooler', 'Restrict printing on domain controllers',
|
||||
'Review the same domain controller Print Spooler exposure reported by each source.', ['ad-dc-spooler-coercion'], ['print spooler']);
|
||||
add('data-sharing-exposure', 'cross-device-sync', 'Restrict cross-device synchronization',
|
||||
'Review clipboard, activity and message synchronization in the same cross-device data policy.', [],
|
||||
['clipboard synchronization', 'user activities', 'message service']);
|
||||
add('data-sharing-exposure', 'search-data', 'Restrict search and location data exposure',
|
||||
'Review encrypted file indexing, online search highlights and location disclosure together.', [],
|
||||
['indexing of encrypted', 'search highlights', 'turn off location']);
|
||||
add('data-sharing-exposure', 'consumer-cloud', 'Restrict consumer cloud identities and content',
|
||||
'Coordinate consumer Microsoft account authentication and cloud content policy.', [],
|
||||
['microsoft account', 'cloud consumer', 'cloud optimized']);
|
||||
add('data-sharing-exposure', 'diagnostic-collection', 'Limit diagnostic data collection',
|
||||
'Coordinate diagnostic data levels, logs, dumps and telemetry transport.', [],
|
||||
['allow diagnostic data', 'diagnostic log', 'dump collection', 'authenticated proxy', 'onesettings auditing']);
|
||||
add('data-sharing-exposure', 'feedback-reporting', 'Restrict feedback and error reporting',
|
||||
'Review support diagnostics, performance tracking, customer experience and error reporting.', [],
|
||||
['support diagnostic', 'perftrack', 'customer experience', 'error reporting', 'feedback notification', 'handwriting']);
|
||||
add('data-sharing-exposure', 'online-personalization', 'Restrict online personalization services',
|
||||
'Coordinate online tips, speech, fonts and advertising identifiers.', [], ['online tips', 'online speech', 'font providers', 'advertising id']);
|
||||
add('data-sharing-exposure', 'http-printing', 'Restrict Internet printing and print downloads',
|
||||
'Review HTTP printing, driver downloads and online print ordering together.', [], ['print drivers over http', 'printing over http', 'order prints']);
|
||||
add('data-sharing-exposure', 'online-wizards', 'Restrict online publishing and registration wizards',
|
||||
'Review Internet connection, publishing and registration tasks in one online service policy.', [],
|
||||
['internet connection wizard', 'web publishing', 'publish to web', 'registration if']);
|
||||
add('data-sharing-exposure', 'online-downloads', 'Restrict unsolicited online downloads and notifications',
|
||||
'Review metadata applications, push install, attachments, search updates and network notifications.', [],
|
||||
['notifications network usage', 'device metadata', 'push to install', 'enclosures', 'search companion']);
|
||||
add('detection-blind-spots', 'privileged-alerts', 'Enable privileged role assignment and activation alerts',
|
||||
'Coordinate privileged role alerts in the same cloud monitoring workflow.', ['entra-privileged-role-alerts-missing']);
|
||||
add('detection-blind-spots', 'ntlm-audit', 'Enable NTLM usage auditing',
|
||||
'Coordinate incoming, outgoing and domain NTLM audit coverage.', ['ntlm-usage-not-audited']);
|
||||
add('detection-blind-spots', 'powershell-process', 'Enable script and process execution evidence',
|
||||
'Coordinate process creation, command-line capture, PowerShell script logging and transcription.',
|
||||
['powershell-logging-off', 'process-creation-not-logged']);
|
||||
add('detection-blind-spots', 'security-log', 'Configure Security event log retention',
|
||||
'Coordinate Security log capacity and full-log behavior.', ['security-event-log-small']);
|
||||
add('detection-blind-spots', 'application-log', 'Configure Application event log retention',
|
||||
'Coordinate Application log capacity and full-log behavior.', [], ['application:']);
|
||||
add('detection-blind-spots', 'setup-system-log', 'Configure Setup and System event log retention',
|
||||
'Coordinate Setup and System log capacity and full-log behavior.', [], ['setup:', 'system: control event', 'system: specify']);
|
||||
add('detection-blind-spots', 'firewall-domain', 'Enable Domain firewall connection logging',
|
||||
'Review declared GPO logging and observed dropped/successful connection logging for the Domain profile.', [], ['domainprofile', 'firewall: domain:']);
|
||||
add('detection-blind-spots', 'firewall-private', 'Enable Private firewall connection logging',
|
||||
'Review declared GPO logging and observed dropped/successful connection logging for the Private profile.', [], ['privateprofile', 'firewall: private:']);
|
||||
add('detection-blind-spots', 'firewall-public', 'Enable Public firewall connection logging',
|
||||
'Review declared GPO logging and observed dropped/successful connection logging for the Public profile.', [], ['publicprofile', 'firewall: public:']);
|
||||
add('detection-blind-spots', 'audit-authentication', 'Enable authentication and logon auditing',
|
||||
'Coordinate credential validation, Kerberos, lockout and logon events.', [],
|
||||
['audit credential', 'audit kerberos', 'audit account lockout', 'audit other logon']);
|
||||
add('detection-blind-spots', 'audit-directory', 'Enable directory and account change auditing',
|
||||
'Coordinate directory, account and group change events and the domain controller audit policy review.', ['dc-audit-incomplete'],
|
||||
['audit directory', 'audit distribution', 'audit other account', 'audit application group', 'audit user account', 'audit group membership']);
|
||||
add('detection-blind-spots', 'audit-object', 'Enable object and share access auditing',
|
||||
'Coordinate file share, removable storage and object access events.', [], ['audit detailed file', 'audit file share', 'audit other object', 'audit removable']);
|
||||
add('detection-blind-spots', 'audit-policy-system', 'Enable security policy and system auditing',
|
||||
'Coordinate policy changes, privilege use and security subsystem events; enable subcategory precedence.', [],
|
||||
['audit: force', 'audit other policy', 'audit authorization', 'audit security system', 'audit pnp', 'audit mpssvc', 'audit ipsec', 'audit sensitive privilege']);
|
||||
add('host-credential-privilege-exposure', 'backup-rights', 'Restrict backup and restore privileges',
|
||||
'Review backup/restore rights in declared GPOs and scanner checks together.', [],
|
||||
['sebackupprivilege', 'serestoreprivilege', 'back up files', 'restore files']);
|
||||
add('host-credential-privilege-exposure', 'driver-rights', 'Restrict driver loading privileges',
|
||||
'Review the same driver loading right in declared GPOs and scanner evidence.', [], ['seloaddriverprivilege', 'load and unload']);
|
||||
add('host-credential-privilege-exposure', 'operating-rights', 'Restrict sensitive operating privileges',
|
||||
'Review system time, shutdown, scheduling and audit privileges with the privileged rights inventory.', [],
|
||||
['seincreasebasepriorityprivilege', 'seremoteshutdownprivilege', 'seauditprivilege', 'change the system time', 'force shutdown', 'shut down the system', 'dangerous user rights']);
|
||||
add('host-credential-privilege-exposure', 'logon-rights', 'Restrict interactive, batch and service logon rights',
|
||||
'Review allow and deny logon assignments together, preserving each access type.', [],
|
||||
['allow log on locally', 'deny access', 'deny log on', 'log on as a batch']);
|
||||
add('host-credential-privilege-exposure', 'uac', 'Harden User Account Control',
|
||||
'Coordinate administrator elevation and Admin Approval Mode.', [], ['user account control:']);
|
||||
add('host-credential-privilege-exposure', 'stored-credentials', 'Restrict stored and reusable credentials',
|
||||
'Review automatic logon, saved network/RDP passwords and WinRM RunAs credential storage.', [],
|
||||
['automatic logon', 'autoadminlogon', 'saving passwords', 'passwords to be saved', 'password upon connection', 'storage of passwords', 'runas']);
|
||||
add('host-credential-privilege-exposure', 'credential-components', 'Harden credential providers and delegation',
|
||||
'Review LSASS providers, MPR notifications, CredSSP and remote credential delegation.', [],
|
||||
['custom ssps', 'mpr notification', 'credential delegation', 'delegation of non-exportable credentials', 'encryption oracle']);
|
||||
add('host-credential-privilege-exposure', 'print-driver-privileges', 'Restrict print driver installation privileges',
|
||||
'Coordinate driver installation, Point and Print updates, queue files and Windows protected printing.', [],
|
||||
['point and print', 'driver installation', 'queue-specific', 'protected print']);
|
||||
add('host-credential-privilege-exposure', 'print-rpc', 'Harden Print RPC access and authentication',
|
||||
'Coordinate Print RPC transport, listener authentication, privacy and redirection protection.', [],
|
||||
['print spooler to accept', 'rpc', 'redirection guard']);
|
||||
add('host-credential-privilege-exposure', 'print-tls', 'Require trusted TLS for IPPS printing',
|
||||
'Configure IPPS and certificate issuer, name, date and server validation together.', [], ['ipps', 'tls']);
|
||||
add('kerberoasting-exposure', 'kerberos-encryption', 'Restrict weak Kerberos encryption',
|
||||
'Review Kerberos encryption types and weak domain controller encryption support together.', [], ['encryption types', 'rc4', 'des encryption']);
|
||||
add('kerberoasting-exposure', 'service-identities', 'Strengthen service account credentials',
|
||||
'Review service account password policy and managed service account adoption together.', [], ['service account', 'service accounts', 'gmsa']);
|
||||
add('kerberos-delegation-abuse', 'delegation-permissions', 'Review sensitive delegation permissions',
|
||||
'Review delegation rights, privileged identity delegation flags and resource-based delegation ACLs.',
|
||||
['gpo-delegation-right-deviates', 'ad-admins-delegatable', 'ad-rbcd-write-on-dc', 'ad-rbcd-write-on-krbtgt'], ['delegation', 'delegated']);
|
||||
add('kerberos-delegation-abuse', 'computer-registration', 'Restrict computer account creation',
|
||||
'Review who can register computer accounts and the machine account quota together.', ['ad-users-can-add-computers'], ['add computer', 'join computer', 'register computer']);
|
||||
add('lateral-movement-exposure', 'network-logon', 'Restrict network logon rights',
|
||||
'Review the same network logon right across declared GPOs and scanner evidence.', [], ['senetworklogonright', 'access this computer']);
|
||||
add('lateral-movement-exposure', 'firewall-log-files', 'Configure firewall log file capacity',
|
||||
'Coordinate firewall log paths and capacities across profiles.', [], ['logging: name', 'logging: size']);
|
||||
add('lateral-movement-exposure', 'firewall-enforcement', 'Enforce firewall state and inbound rules',
|
||||
'Review enabled firewall profiles and inbound defaults together.', [], ['firewall state', 'inbound connections']);
|
||||
add('lateral-movement-exposure', 'firewall-local-policy', 'Restrict local firewall overrides and notifications',
|
||||
'Review local rule merging and notification behavior across profiles.', [], ['display a notification', 'apply local']);
|
||||
add('lateral-movement-exposure', 'rdp-redirection', 'Restrict RDP device and data redirection',
|
||||
'Review RDP redirection channels together, retaining the requirement for each device/data type.', [], ['redirection', 'drive redirection', 'clipboard']);
|
||||
add('lateral-movement-exposure', 'remote-assistance', 'Restrict Remote Assistance',
|
||||
'Coordinate solicited and offered Remote Assistance policy.', [], ['remote assistance']);
|
||||
add('lateral-movement-exposure', 'rdp-session', 'Restrict RDP session duration and temporary data',
|
||||
'Coordinate session limits, single-session policy and temporary folder cleanup.', [], ['time limit', 'single session', 'single remote desktop', 'temporary folders', 'temp folders']);
|
||||
add('lateral-movement-exposure', 'rdp-authentication', 'Harden RDP authentication and encryption',
|
||||
'Coordinate NLA, secure RPC, security layer, encryption and the source-reported Terminal Services policy gaps.', [],
|
||||
['network level authentication', 'secure rpc', 'encryption level', 'security layer', 'terminal services']);
|
||||
add('lateral-movement-exposure', 'winrm', 'Harden WinRM authentication and remote access',
|
||||
'Coordinate WinRM client/server authentication, transport and remote management access.',
|
||||
['winrm-basic-auth', 'winrm-unencrypted'], ['winrm client', 'disallow digest', 'winrm service', 'remote server management', 'remote shell']);
|
||||
add('lateral-movement-exposure', 'smb1', 'Remove SMBv1 client and server support',
|
||||
'Coordinate removal of the same legacy protocol on client and server.', [], ['smb v1', 'smbv1']);
|
||||
add('lateral-movement-exposure', 'ip-routing', 'Harden IP routing behavior',
|
||||
'Review source routing, ICMP redirects and router discovery together.', [], ['source routing', 'icmp redirect', 'router discovery', 'performrouterdiscovery']);
|
||||
add('lateral-movement-exposure', 'network-discovery', 'Restrict legacy network discovery and bridging',
|
||||
'Review topology discovery, peer networking, NetBIOS releases, bridging and connection sharing.', [],
|
||||
['lltd', 'link-layer', 'responder (rspndr)', 'peer-to-peer', 'network bridge', 'internet connection sharing', 'netbios name release']);
|
||||
add('lateral-movement-exposure', 'network-connections', 'Control network connection configuration',
|
||||
'Review simultaneous connections, network location changes and IPv6 transition configuration.', [],
|
||||
['network location', "network's location", 'simultaneous connections', 'disabledcomponents']);
|
||||
add('lateral-movement-exposure', 'tcp-resilience', 'Configure TCP connection resilience',
|
||||
'Coordinate retransmission and keep-alive limits.', [], ['retransmission', 'keepalivetime', 'keep alive']);
|
||||
add('malicious-code-execution-exposure', 'allowlisting', 'Configure application allowlisting rules',
|
||||
'Review executable, DLL, MSI and script rules as one application control rollout.',
|
||||
['gpo-application-control-deviates', 'gpo-software-restriction-policy-deviates', 'gpo-applocker-policy-deviates'], ['safer', 'srpv2']);
|
||||
add('malicious-code-execution-exposure', 'asr', 'Configure Attack Surface Reduction',
|
||||
'Review ASR rule enablement and source-reported ASR coverage together.', [], ['attack surface reduction', 'asr']);
|
||||
add('malicious-code-execution-exposure', 'app-installer', 'Restrict App Installer bypasses and execution interfaces',
|
||||
'Coordinate App Installer features, validation bypasses, protocol and command-line interfaces.', [], ['app installer', 'windows package manager']);
|
||||
add('malicious-code-execution-exposure', 'installer-control', 'Restrict user-controlled application installation',
|
||||
'Review application installation, sharing and installer user control together.', [],
|
||||
['special profiles', 'per-user unsigned packages', 'user control over installs', 'internet explorer security prompt', 'share application data']);
|
||||
add('malicious-code-execution-exposure', 'exploit-mitigation', 'Enable operating system exploit mitigations',
|
||||
'Review DEP, heap, shell protocol, signature padding, log file authentication and boot driver protection.', [],
|
||||
['data execution prevention', 'heap termination', 'shell protocol', 'padding', 'clfs', 'boot-start', 'prevent users from modifying settings']);
|
||||
add('malicious-code-execution-exposure', 'untrusted-files', 'Restrict untrusted file and script execution',
|
||||
'Review zone provenance and source-reported script execution boundaries.',
|
||||
['mark-of-the-web-suppressed', 'script-engines-reach-internet', 'script-files-run-on-double-click'],
|
||||
['zone information', 'folder options', 'script execution', 'script engines']);
|
||||
add('malicious-code-execution-exposure', 'defender-realtime', 'Enable Defender real-time and behavioral protection',
|
||||
'Coordinate real-time scanning, downloaded file checks, behavior/script monitoring and EDR block mode.', [],
|
||||
['real-time', 'downloaded files', 'behavior monitoring', 'script scanning', 'edr']);
|
||||
add('malicious-code-execution-exposure', 'defender-cloud', 'Configure Defender cloud protection and reporting',
|
||||
'Review MAPS membership, local overrides, hash submission and reporting together.', [], ['maps', 'watson', 'file hash', 'out-of-box']);
|
||||
add('malicious-code-execution-exposure', 'defender-scans', 'Configure Defender scan coverage and schedules',
|
||||
'Coordinate archive, removable media, email and quick scan coverage.', [], ['packed files', 'packed executables', 'removable drives', 'e-mail', 'quick scan']);
|
||||
add('malicious-code-execution-exposure', 'defender-policy', 'Restrict local Defender policy modifications',
|
||||
'Review local exclusion visibility and user configuration changes.', [], ['exclusions']);
|
||||
add('malicious-code-execution-exposure', 'defender-ransomware', 'Configure Defender ransomware protections',
|
||||
'Coordinate remote encryption and brute force protection modes.', [], ['remote encryption', 'brute force', 'brute-force']);
|
||||
add('malicious-code-execution-exposure', 'web-application-protection', 'Configure web and unwanted application protection',
|
||||
'Review SmartScreen, dangerous site protection and unwanted application blocking together.', [], ['smartscreen', 'dangerous', 'unwanted', 'convert warn verdict to block']);
|
||||
add('malicious-code-execution-exposure', 'autorun', 'Disable unnecessary Autorun and Autoplay',
|
||||
'Coordinate drive Autoplay, non-volume devices and default Autorun behavior.', [], ['autorun', 'autoplay']);
|
||||
add('password-guessing-exposure', 'password-policy', 'Strengthen domain password policy',
|
||||
'Coordinate minimum length and complexity across declared policies and scanner evidence.',
|
||||
['password-length-below-14', 'password-length-below-8', 'password-complexity-off', 'ad-privileged-weak-password-policy'],
|
||||
['minimumpasswordlength', 'passwordcomplexity', 'minimum password length', 'password complexity', 'weak password policy']);
|
||||
add('password-guessing-exposure', 'lockout-threshold', 'Configure account lockout thresholds',
|
||||
'Review the same lockout threshold in declared GPOs and scanner evidence.', [], ['lockoutbadcount', 'lockout threshold']);
|
||||
add('password-guessing-exposure', 'lockout-duration', 'Configure lockout duration and reset window',
|
||||
'Coordinate lockout duration and counter reset to avoid contradictory settings.', [],
|
||||
['lockoutduration', 'resetlockoutcount', 'lockout duration', 'reset account lockout']);
|
||||
add('password-guessing-exposure', 'password-lifetime', 'Review non-expiring account passwords',
|
||||
'Review maximum password age and non-expiring account exceptions together.',
|
||||
['domain-admins-password-never-expires', 'ad-password-never-expires', 'ad-privileged-password-never-expires'],
|
||||
['maximumpasswordage', 'never expire', 'never expires']);
|
||||
add('password-guessing-exposure', 'builtin-names', 'Review built-in account naming',
|
||||
'Coordinate the built-in Administrator and Guest naming requirements.', [], ['rename administrator', 'rename guest']);
|
||||
add('password-guessing-exposure', 'kerberos-armoring', 'Configure Kerberos armoring',
|
||||
'Coordinate client and domain controller support for Kerberos armoring.', [], ['armoring']);
|
||||
add('password-guessing-exposure', 'credential-cryptography', 'Harden account credential cryptography',
|
||||
'Review SAM password-change encryption and vulnerable Windows Hello key restrictions.', [], ['sam:', 'sam change password', 'roca']);
|
||||
add('patch-configuration-drift', 'windows-update', 'Configure Windows update deployment',
|
||||
'Coordinate update scheduling, restart behavior, preview builds and quality update receipt.', ['cis-windows-update-unmet']);
|
||||
add('patch-configuration-drift', 'policy-refresh', 'Ensure security policy refresh',
|
||||
'Coordinate background security policy processing and reapplication of unchanged policies.', [], ['configure security policy processing']);
|
||||
add('physical-local-access-exposure', 'session-lock', 'Enforce workstation session locking',
|
||||
'Coordinate inactivity, wake passwords, smart card removal and logon hours enforcement.', ['gpo-logon-hours-enforcement-deviates'],
|
||||
['machine inactivity', 'password when a computer wakes', 'smart card removal']);
|
||||
add('physical-local-access-exposure', 'sign-in-privacy', 'Restrict sign-in screen information disclosure',
|
||||
'Review account enumeration, network selection, password reveal and input method disclosure.', [],
|
||||
['showing account details', 'network selection', 'enumerate connected', 'enumerate administrator', 'last signed-in', 'password reveal', 'copying of user input']);
|
||||
add('physical-local-access-exposure', 'legal-notice', 'Configure the interactive logon notice',
|
||||
'Coordinate the notice title and text as one policy change.', [], ['message text', 'message title']);
|
||||
add('physical-local-access-exposure', 'lock-screen-features', 'Restrict lock screen applications and features',
|
||||
'Coordinate camera, slideshow, notifications and Windows Ink access at the lock screen.', [], ['lock screen', 'windows ink']);
|
||||
add('physical-local-access-exposure', 'wireless-standby', 'Restrict wireless setup and standby connectivity',
|
||||
'Coordinate Windows Connect Now and network connectivity during connected standby.', [], ['windows connect now', 'connected-standby']);
|
||||
add('physical-local-access-exposure', 'physical-devices', 'Restrict local device access',
|
||||
'Review camera access, external DMA devices and pairing requirements in the local device policy.', [], ['use of camera', 'kernel dma', 'pin for pairing']);
|
||||
add('physical-local-access-exposure', 'local-authentication', 'Harden local authentication methods',
|
||||
'Review convenience PIN and biometric anti-spoofing policy together.', [], ['convenience pin', 'anti-spoofing']);
|
||||
|
||||
export const INTERVENTION_CATALOG: readonly Intervention[] = entries;
|
||||
export function matchIntervention(cluster: string, finding: Candidate, conditionIds: readonly string[]) {
|
||||
const title = finding.title.toLowerCase();
|
||||
const candidates = INTERVENTION_CATALOG.filter(rule => rule.cluster === cluster
|
||||
&& (finding.outcome !== 'warning' || rule.warnings));
|
||||
// Specific published semantics take precedence over a term that also occurs
|
||||
// in a conditional clause of the original rule text (e.g. MFA fatigue).
|
||||
return candidates.find(rule => rule.conditions?.some(id => conditionIds.includes(id)))
|
||||
?? ((Boolean(finding.identityKey && finding.scope)
|
||||
|| ['cis-cat', 'group-policy', 'maester', 'pingcastle', 'purple-knight'].includes(finding.tool))
|
||||
? candidates.find(rule => rule.settings?.some(anchor => title.includes(anchor))) : undefined);
|
||||
}
|
||||
|
|
@ -0,0 +1,300 @@
|
|||
import { matchIntervention } from './intervention-catalog';
|
||||
|
||||
/** Published input contracts: this engine never imports another engine's internals. */
|
||||
export interface PublishedContext {
|
||||
engine: { version: string }; catalog: { version: string }; repositoryRevision: number;
|
||||
clusters: Array<{ clusterId: string; label: { en: string } }>;
|
||||
findings: Array<{ occurrenceId: string; clusterIds: string[]; conditionIds: string[]; clusterConfidence?: Record<string, string> }>;
|
||||
settings: Array<{ identityKey: string; scope: string; clusterIds: string[]; conditionIds: string[]; clusterConfidence?: Record<string, string> }>;
|
||||
}
|
||||
export interface RemediationFinding {
|
||||
key: string; occurrenceId?: string; identityKey?: string; scope?: string;
|
||||
tool: string; ruleId: string; title: string; outcome: string;
|
||||
remediation?: string;
|
||||
/** Native source severity when the Core adapter supplies it; never derived here. */
|
||||
severity?: string;
|
||||
}
|
||||
export type WorkPlatform = 'windows-host' | 'active-directory' | 'cloud' | 'other';
|
||||
export type WorkMeasureMethod = 'catalogued' | 'same-source-remediation' | 'individual';
|
||||
/** One change a person carries out. Several findings form one task only when they are proven redundant. */
|
||||
export interface WorkTask { id: string; findingKeys: string[]; redundant: boolean; relatedConditionId?: string }
|
||||
/** A common intervention inside a package; a package may hold several measures of its cluster and platform. */
|
||||
export interface WorkMeasure { id: string; title: string; method: WorkMeasureMethod; reason: string; taskIds: string[] }
|
||||
/** Source-backed facts only: no score, rank or priority. */
|
||||
export interface WorkPackageFacts {
|
||||
tasks: number; findings: number; redundantFindings: number;
|
||||
highestSeverity?: string; criticalOrHighFindings: number;
|
||||
conditionsClosed: number; conditionsTouched: number; sources: string[];
|
||||
}
|
||||
export interface WorkPackage {
|
||||
id: string; code: string; clusterId: string; clusterLabel: string; platform: WorkPlatform; title: string;
|
||||
findingKeys: string[]; tasks: WorkTask[]; measures: WorkMeasure[]; facts: WorkPackageFacts;
|
||||
method: WorkMeasureMethod; reason: string;
|
||||
}
|
||||
export interface RemediationResult {
|
||||
engineVersion: string; catalogVersion: string; contextEngineVersion: string; contextCatalogVersion: string;
|
||||
projectId: string; assessmentIds: string[]; scanRunIds: string[]; repositoryRevision: number;
|
||||
packages: WorkPackage[]; unassignedFindingKeys: string[];
|
||||
}
|
||||
export const REMEDIATION_ENGINE_VERSION = '2.1.1';
|
||||
export const REMEDIATION_CATALOG_VERSION = '1.1.1';
|
||||
export const WORK_PACKAGE_FACET_ID = 'remediation:work-package';
|
||||
export const UNASSIGNED_WORK_PACKAGE = '__unassigned__';
|
||||
/** User decision 2026-10-09: at most eight tasks per package, packed per Context cluster and platform. */
|
||||
export const MAX_TASKS_PER_PACKAGE = 8;
|
||||
/** User decision 2026-10-09: several GPO settings change in one round. A size rule, never an effort or priority. */
|
||||
export const MAX_TASKS_PER_HOST_PACKAGE = 15;
|
||||
export const maxTasksFor = (platform: WorkPlatform) => platform === 'windows-host' ? MAX_TASKS_PER_HOST_PACKAGE : MAX_TASKS_PER_PACKAGE;
|
||||
|
||||
// Rules describe a common intervention, never a new verdict or quantified risk score.
|
||||
const cloudRules = [
|
||||
{ title: 'Secure administrative sign-ins', rules: ['MT.1005', 'MT.1024.adminMFAV2', 'CISA.MS.AAD.3.6', 'MT.1014'],
|
||||
reason: 'Coordinate administrative authentication, device requirements and verified emergency access.' },
|
||||
{ title: 'Limit Global Administrator privileges', rules: ['CIS.M365.1.1.3', 'CISA.MS.AAD.7.1', 'CISA.MS.AAD.7.2', 'MT.1032'],
|
||||
reason: 'Review the same role inventory against each retained criterion; use narrower roles where appropriate.' },
|
||||
{ title: 'Separate administrative identities', rules: ['CIS.M365.1.1.1', 'CISA.MS.AAD.7.3', 'MT.1028'],
|
||||
reason: 'Coordinate cloud-only administrative identities and separation from everyday mailbox use.' },
|
||||
{ title: 'Review privileged assignment lifecycles', rules: ['CISA.MS.AAD.7.4', 'MT.1025', 'MT.1030', 'MT.INV.003'],
|
||||
reason: 'Review permanent, external, unused and inactive privileges; retain emergency and service-account exceptions for clarification.' },
|
||||
{ title: 'Restrict Azure hierarchy administration', rules: ['MT.1056', 'MT.1064'],
|
||||
reason: 'Review root-scope privileges and permission to create management groups.' },
|
||||
{ title: 'Secure privileged application identities', rules: ['MT.1027'],
|
||||
reason: 'Review application credentials and necessary privileges separately from human administrator authentication.' },
|
||||
{ title: 'Investigate unusual privileged sign-ins', rules: ['MT.INV.001'],
|
||||
reason: 'Investigate the retained warning; configuration changes alone do not resolve an investigation.' }
|
||||
] as const;
|
||||
|
||||
const normalize = (text: string) => text.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
export const scannerFindingKey = (occurrenceId: string) => `scanner:${occurrenceId}`;
|
||||
export const policyFindingKey = (scope: string, identityKey: string) => `policy:${scope}:${identityKey}`;
|
||||
const byText = (a: string, b: string) => a.localeCompare(b, 'en', { numeric: true });
|
||||
const PLATFORM_ORDER: readonly WorkPlatform[] = ['windows-host', 'active-directory', 'cloud', 'other'];
|
||||
const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info'];
|
||||
const SEP = '\u0000';
|
||||
const sourceOf = (f: RemediationFinding) => f.tool || (f.identityKey ? 'group-policy' : 'unknown');
|
||||
const push = <K, V>(map: Map<K, V[]>, key: K, value: V) => { const list = map.get(key); if (list) list.push(value); else map.set(key, [value]); };
|
||||
|
||||
/** Hard packing boundary next to the cluster: where the change is made (host/GPO, directory, cloud tenant). */
|
||||
export function platformOf(finding: RemediationFinding, clusterId: string): WorkPlatform {
|
||||
if (clusterId.startsWith('cloud-') || finding.tool === 'maester') return 'cloud';
|
||||
if (finding.identityKey || ['cis-cat', 'group-policy'].includes(finding.tool)) return 'windows-host';
|
||||
if (['pingcastle', 'purple-knight'].includes(finding.tool)) return 'active-directory';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
finding: RemediationFinding; clusterId: string; platform: WorkPlatform; conditions: string[];
|
||||
measureKey: string; measure: Omit<WorkMeasure, 'id' | 'taskIds'>;
|
||||
}
|
||||
interface Task extends WorkTask {
|
||||
entries: Entry[]; clusterId: string; platform: WorkPlatform; measureKey: string; order: string; conditions: Set<string>;
|
||||
}
|
||||
|
||||
class Groups {
|
||||
private parent = new Map<string, string>();
|
||||
find(key: string): string {
|
||||
const parent = this.parent.get(key);
|
||||
if (!parent || parent === key) return key;
|
||||
const root = this.find(parent);
|
||||
this.parent.set(key, root);
|
||||
return root;
|
||||
}
|
||||
union(a: string, b: string) { const [x, y] = [this.find(a), this.find(b)].sort(byText); if (x !== y) this.parent.set(y, x); }
|
||||
}
|
||||
|
||||
function measureFor(finding: RemediationFinding, clusterId: string, catalogued: boolean, conditions: string[]) {
|
||||
const ruleIndex = clusterId === 'cloud-privileged-access-exposure' && finding.tool === 'maester'
|
||||
? cloudRules.findIndex(rule => (rule.rules as readonly string[]).includes(finding.ruleId)) : -1;
|
||||
// Estimated context is not evidence of a shared remediation. A warning also stays an investigation.
|
||||
const intervention = catalogued ? matchIntervention(clusterId, finding, conditions) : undefined;
|
||||
const sharedText = finding.outcome !== 'warning' && catalogued
|
||||
&& finding.remediation && normalize(finding.remediation).length >= 40 ? normalize(finding.remediation) : undefined;
|
||||
const key = ruleIndex >= 0 ? `catalogue:${String(ruleIndex).padStart(3, '0')}`
|
||||
: intervention ? `intervention:${intervention.id}`
|
||||
: sharedText ? `source:${finding.tool}:${finding.scope ?? ''}:${sharedText}` : `individual:${finding.key}`;
|
||||
const rule = ruleIndex >= 0 ? cloudRules[ruleIndex] : intervention;
|
||||
const method: WorkMeasureMethod = rule ? 'catalogued' : sharedText ? 'same-source-remediation' : 'individual';
|
||||
return { key, measure: { title: rule?.title ?? finding.title, method,
|
||||
reason: rule?.reason ?? (sharedText ? 'The retained source gives the same remediation in the same tool and scope.'
|
||||
: 'No supported common intervention: address this finding individually.') } };
|
||||
}
|
||||
|
||||
/** Balanced chunks of at most `size`: 11 → 6+5 instead of 8+3 at a limit of eight. */
|
||||
function chunks<T>(items: T[], size: number): T[][] {
|
||||
const count = Math.ceil(items.length / size);
|
||||
const out: T[][] = [];
|
||||
for (let i = 0, offset = 0; i < count; i++) {
|
||||
const length = Math.floor(items.length / count) + (i < items.length % count ? 1 : 0);
|
||||
out.push(items.slice(offset, offset + length));
|
||||
offset += length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** First-fit decreasing with condition affinity, at the most balanced capacity that needs the fewest packages. */
|
||||
function pack(units: Task[][], limit: number): Task[][] {
|
||||
const total = units.reduce((n, unit) => n + unit.length, 0);
|
||||
const minimum = Math.ceil(total / limit);
|
||||
const pieces = units.flatMap(unit => chunks(unit, limit))
|
||||
.sort((a, b) => b.length - a.length || byText(a[0].order, b[0].order));
|
||||
const largest = Math.max(0, ...pieces.map(piece => piece.length));
|
||||
let best: Task[][] = [];
|
||||
for (let capacity = Math.max(largest, Math.ceil(total / minimum)); capacity <= limit; capacity++) {
|
||||
const bins: Task[][] = [];
|
||||
for (const piece of pieces) {
|
||||
const fits = bins.filter(bin => bin.length + piece.length <= capacity);
|
||||
const affine = fits.find(bin => bin.some(task => piece.some(other => [...other.conditions].some(c => task.conditions.has(c)))));
|
||||
const bin = affine ?? fits[0];
|
||||
if (bin) bin.push(...piece); else bins.push([...piece]);
|
||||
}
|
||||
if (!best.length || bins.length < best.length) best = bins;
|
||||
if (best.length === minimum) break;
|
||||
}
|
||||
// Avoid an artificial one- or two-task tail: move loose tasks (no common measure or relation) from full packages.
|
||||
const loose = new Set(units.filter(unit => unit.length === 1).map(unit => unit[0]));
|
||||
for (;;) {
|
||||
const small = [...best].sort((a, b) => a.length - b.length)[0];
|
||||
if (!small || small.length >= 3) break;
|
||||
const donors = best.filter(bin => bin !== small && bin.length > small.length + 1 && bin.some(task => loose.has(task)));
|
||||
if (!donors.length) break;
|
||||
const donor = donors.find(bin => bin.some(task => loose.has(task) && small.some(s => [...s.conditions].some(c => task.conditions.has(c)))))
|
||||
?? [...donors].sort((a, b) => b.length - a.length)[0];
|
||||
const candidates = donor.filter(task => loose.has(task)).sort((a, b) => byText(b.order, a.order));
|
||||
const moved = candidates.find(task => small.some(s => [...s.conditions].some(c => task.conditions.has(c)))) ?? candidates[0];
|
||||
donor.splice(donor.indexOf(moved), 1);
|
||||
small.push(moved);
|
||||
}
|
||||
return best.map(bin => [...bin].sort((a, b) => byText(a.order, b.order)));
|
||||
}
|
||||
|
||||
/** Order independent and exclusive. Original records and Context remain untouched. */
|
||||
export function buildWorkPackages(input: {
|
||||
projectId: string; assessmentIds: string[]; scanRunIds: string[];
|
||||
findings: readonly RemediationFinding[]; context: PublishedContext;
|
||||
}): RemediationResult {
|
||||
const { context } = input;
|
||||
const scanner = new Map(context.findings.map(f => [f.occurrenceId, f]));
|
||||
const policy = new Map(context.settings.map(f => [policyFindingKey(f.scope, f.identityKey), f]));
|
||||
const labels = new Map(context.clusters.map(c => [c.clusterId, c.label.en]));
|
||||
const unassigned: string[] = [];
|
||||
const entries: Entry[] = [];
|
||||
const records = [...new Map(input.findings.map(f => [f.key, f])).values()].sort((a, b) => byText(a.key, b.key));
|
||||
for (const finding of records) {
|
||||
if (!['fail', 'warning', 'deviation'].includes(finding.outcome)) continue;
|
||||
const attachment = finding.occurrenceId ? scanner.get(finding.occurrenceId) : policy.get(finding.key);
|
||||
const clusterId = attachment?.clusterIds[0];
|
||||
if (!clusterId || !labels.has(clusterId) || clusterId === 'other-deviations') { unassigned.push(finding.key); continue; }
|
||||
const confidence = attachment?.clusterConfidence?.[clusterId];
|
||||
const catalogued = !confidence || confidence === 'catalogued';
|
||||
const conditions = [...(attachment?.conditionIds ?? [])].sort(byText);
|
||||
const { key, measure } = measureFor(finding, clusterId, catalogued, conditions);
|
||||
entries.push({ finding, clusterId, platform: platformOf(finding, clusterId), measureKey: key, measure, conditions: catalogued ? conditions : [] });
|
||||
}
|
||||
|
||||
// Redundancy: a specific condition that each of several sources reports exactly once is one change.
|
||||
// Where one source reports several findings (an area or umbrella), tasks stay separate but related.
|
||||
const byCondition = new Map<string, Entry[]>();
|
||||
for (const entry of entries) for (const condition of entry.conditions) push(byCondition, `${entry.clusterId}${SEP}${condition}`, entry);
|
||||
const redundancy = new Groups();
|
||||
const members = new Map(entries.map(e => [e.finding.key, [e]]));
|
||||
const related = new Map<string, string>();
|
||||
for (const [key, group] of [...byCondition].sort(([a], [b]) => byText(a, b))) {
|
||||
const sources = group.map(e => sourceOf(e.finding));
|
||||
if (new Set(sources).size < 2) continue;
|
||||
const condition = key.split(SEP)[1];
|
||||
if (new Set(sources).size !== sources.length || group.some(e => e.finding.outcome === 'warning')) {
|
||||
for (const e of group) if (!related.has(e.finding.key)) related.set(e.finding.key, condition);
|
||||
continue;
|
||||
}
|
||||
for (const e of group.slice(1)) {
|
||||
const [a, b] = [redundancy.find(group[0].finding.key), redundancy.find(e.finding.key)];
|
||||
if (a === b) continue;
|
||||
const joined = [...members.get(a)!, ...members.get(b)!];
|
||||
if (new Set(joined.map(x => sourceOf(x.finding))).size !== joined.length) continue;
|
||||
redundancy.union(a, b);
|
||||
members.set(redundancy.find(a), joined);
|
||||
}
|
||||
}
|
||||
const taskEntries = new Map<string, Entry[]>();
|
||||
for (const e of entries) push(taskEntries, redundancy.find(e.finding.key), e);
|
||||
const tasks: Task[] = [...taskEntries.values()].map(group => {
|
||||
const sorted = [...group].sort((a, b) => byText(a.finding.key, b.finding.key));
|
||||
const counts = PLATFORM_ORDER.map(p => sorted.filter(e => e.platform === p).length);
|
||||
const platform = PLATFORM_ORDER[counts.indexOf(Math.max(...counts))];
|
||||
const lead = sorted.find(e => e.measure.method === 'catalogued') ?? sorted[0];
|
||||
const conditions = new Set(sorted.flatMap(e => e.conditions));
|
||||
const relation = sorted.map(e => related.get(e.finding.key)).find(Boolean);
|
||||
return { id: `task:${sorted[0].finding.key}`, findingKeys: sorted.map(e => e.finding.key), redundant: sorted.length > 1,
|
||||
...(relation ? { relatedConditionId: relation } : {}), entries: sorted, clusterId: lead.clusterId, platform,
|
||||
measureKey: lead.measureKey, conditions,
|
||||
// Neighbouring order: common measure, umbrella relation, first condition, natural rule number.
|
||||
// Explicit 0/1 prefixes put present values first; locale collation sorts symbols such as '~' before letters.
|
||||
order: [lead.measureKey.startsWith('individual:') ? '1' : `0${lead.measureKey}`, relation ? `0${relation}` : '1',
|
||||
[...conditions][0] ? `0${[...conditions][0]}` : '1', lead.finding.ruleId, lead.finding.key].join(SEP) };
|
||||
});
|
||||
|
||||
// An umbrella finding follows the platform where most of its related findings are changed.
|
||||
const relationPlatforms = new Map<string, Task[]>();
|
||||
for (const task of tasks) if (task.relatedConditionId) push(relationPlatforms, `${task.clusterId}${SEP}${task.relatedConditionId}`, task);
|
||||
for (const group of relationPlatforms.values()) {
|
||||
const counts = PLATFORM_ORDER.map(p => group.filter(t => t.platform === p).length);
|
||||
const platform = PLATFORM_ORDER[counts.indexOf(Math.max(...counts))];
|
||||
for (const task of group) task.platform = platform;
|
||||
}
|
||||
const conditionMembers = new Map<string, string[]>();
|
||||
for (const e of entries) for (const c of e.conditions) push(conditionMembers, c, e.finding.key);
|
||||
const buckets = new Map<string, Task[]>();
|
||||
for (const task of tasks) push(buckets, `${task.clusterId}${SEP}${PLATFORM_ORDER.indexOf(task.platform)}`, task);
|
||||
const packages: WorkPackage[] = [];
|
||||
const numbers = new Map<string, number>();
|
||||
for (const [, bucket] of [...buckets].sort(([a], [b]) => byText(a, b))) {
|
||||
// Units: tasks of one measure, or related through an umbrella condition, stay together where they fit.
|
||||
const units = new Groups();
|
||||
const firstOf = new Map<string, string>();
|
||||
for (const task of [...bucket].sort((a, b) => byText(a.order, b.order))) {
|
||||
const links = [task.measureKey.startsWith('individual:') ? '' : `m${SEP}${task.measureKey}`,
|
||||
task.relatedConditionId ? `r${SEP}${task.relatedConditionId}` : ''].filter(Boolean);
|
||||
for (const link of links) { const first = firstOf.get(link); if (first) units.union(first, task.id); else firstOf.set(link, task.id); }
|
||||
}
|
||||
const grouped = new Map<string, Task[]>();
|
||||
for (const task of bucket) push(grouped, units.find(task.id), task);
|
||||
const bins = pack([...grouped.values()].map(unit => unit.sort((a, b) => byText(a.order, b.order))), maxTasksFor(bucket[0].platform))
|
||||
.sort((a, b) => byText(a[0].order, b[0].order));
|
||||
for (const bin of bins) {
|
||||
const clusterId = bin[0].clusterId;
|
||||
const number = (numbers.get(clusterId) ?? 0) + 1;
|
||||
numbers.set(clusterId, number);
|
||||
const code = `WP-${String(number).padStart(2, '0')}`;
|
||||
const byMeasure = new Map<string, Task[]>();
|
||||
for (const task of bin) push(byMeasure, task.measureKey, task);
|
||||
const measures: WorkMeasure[] = [...byMeasure].map(([id, list]) => {
|
||||
const lead = list[0].entries.find(e => e.measureKey === id) ?? list[0].entries[0];
|
||||
return { id, ...lead.measure, taskIds: list.map(t => t.id) };
|
||||
});
|
||||
const main = [...measures].sort((a, b) => b.taskIds.length - a.taskIds.length
|
||||
|| Number(b.method === 'catalogued') - Number(a.method === 'catalogued') || byText(a.id, b.id))[0];
|
||||
const findingKeys = bin.flatMap(t => t.findingKeys);
|
||||
const inPackage = new Set(findingKeys);
|
||||
const found = bin.flatMap(t => t.entries);
|
||||
const touched = [...new Set(found.flatMap(e => e.conditions))];
|
||||
const severities = found.map(e => e.finding.severity?.trim().toLowerCase() ?? '').filter(s => SEVERITY_ORDER.includes(s));
|
||||
const highestSeverity = SEVERITY_ORDER.find(s => severities.includes(s));
|
||||
// Keep existing snapshot/filter identities across the visible AP → WP rename.
|
||||
packages.push({ id: `${clusterId}:AP-${String(number).padStart(2, '0')}`, code, clusterId, clusterLabel: labels.get(clusterId)!, platform: bin[0].platform,
|
||||
title: main.title, findingKeys, measures, method: main.method, reason: main.reason,
|
||||
tasks: bin.map(({ id, findingKeys: keys, redundant, relatedConditionId }) =>
|
||||
({ id, findingKeys: keys, redundant, ...(relatedConditionId ? { relatedConditionId } : {}) })),
|
||||
facts: { tasks: bin.length, findings: findingKeys.length,
|
||||
redundantFindings: bin.filter(t => t.redundant).reduce((n, t) => n + t.findingKeys.length, 0),
|
||||
...(highestSeverity ? { highestSeverity } : {}),
|
||||
criticalOrHighFindings: severities.filter(s => s === 'critical' || s === 'high').length,
|
||||
conditionsTouched: touched.length,
|
||||
conditionsClosed: touched.filter(c => conditionMembers.get(c)!.every(k => inPackage.has(k))).length,
|
||||
sources: [...new Set(found.map(e => sourceOf(e.finding)))].sort(byText) } });
|
||||
}
|
||||
}
|
||||
return { engineVersion: REMEDIATION_ENGINE_VERSION, catalogVersion: REMEDIATION_CATALOG_VERSION,
|
||||
contextEngineVersion: context.engine.version, contextCatalogVersion: context.catalog.version,
|
||||
projectId: input.projectId, assessmentIds: [...new Set(input.assessmentIds)].sort(), scanRunIds: [...new Set(input.scanRunIds)].sort(),
|
||||
repositoryRevision: context.repositoryRevision, packages, unassignedFindingKeys: unassigned.sort() };
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import type { AssessorRepository } from '../persistence/assessor-repository';
|
||||
|
||||
export type RemediationReadMethods = Pick<AssessorRepository, 'getProject' | 'listAssessments' | 'loadAssessmentModel'
|
||||
| 'queryUnifiedResults' | 'listPolicyEvaluations' | 'listCurrentPolicyEvaluationsForMetrics' | 'listMicrosoftSecurityBaselineAnalyses'>;
|
||||
export interface RemediationReadPort extends RemediationReadMethods { currentRevision(): Promise<number> }
|
||||
export function remediationReadPortFrom(repository: RemediationReadMethods, revision: () => Promise<number>): RemediationReadPort {
|
||||
return Object.freeze({ currentRevision: revision, getProject: repository.getProject.bind(repository),
|
||||
listAssessments: repository.listAssessments.bind(repository), loadAssessmentModel: repository.loadAssessmentModel.bind(repository),
|
||||
queryUnifiedResults: repository.queryUnifiedResults.bind(repository), listPolicyEvaluations: repository.listPolicyEvaluations.bind(repository),
|
||||
listCurrentPolicyEvaluationsForMetrics: repository.listCurrentPolicyEvaluationsForMetrics?.bind(repository),
|
||||
listMicrosoftSecurityBaselineAnalyses: repository.listMicrosoftSecurityBaselineAnalyses.bind(repository) });
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useOptionalApp } from '../app/AppProvider';
|
||||
import { useEnginePreferences } from '../app/engine-preferences';
|
||||
import { AssessorApiClient } from '../api/assessor-api-client';
|
||||
import { preparedAssessmentContext } from '../api/prepared-assessment-context';
|
||||
import type { WorkPackagesResponse } from '../api/assessor-api-contract';
|
||||
import { workPackageIndex } from './work-package-index';
|
||||
import { publishedWorkPackageOverview } from './work-package-overview';
|
||||
|
||||
const api = new AssessorApiClient();
|
||||
let requests = new WeakMap<object, Map<string, Promise<WorkPackagesResponse>>>();
|
||||
export function resetWorkPackageRequestsForTests() { requests = new WeakMap(); }
|
||||
export function useWorkPackages() {
|
||||
const app = useOptionalApp();
|
||||
const engines = useEnginePreferences();
|
||||
const dataset = app?.dataset;
|
||||
const projectId = app?.targetProjectId;
|
||||
const assessments = [...(app?.activeAssessmentIds ?? [])].sort();
|
||||
const scans = [...(app?.activeScanRunIds ?? [])].sort();
|
||||
const key = JSON.stringify([projectId, assessments, scans, engines.context]);
|
||||
const [state, setState] = useState<{ key: string; dataset: unknown; response?: WorkPackagesResponse; error?: string }>();
|
||||
useEffect(() => {
|
||||
if (!engines.remediation || !dataset || !projectId || !assessments.length) return;
|
||||
let current = true;
|
||||
let cache = requests.get(dataset);
|
||||
if (!cache) { cache = new Map(); requests.set(dataset, cache); }
|
||||
let request = cache.get(key);
|
||||
if (!request) {
|
||||
request = (async () => {
|
||||
// Consume the published API result. Context off never starts a Context request or computation.
|
||||
if (engines.context) await preparedAssessmentContext(api, dataset, projectId, assessments, scans);
|
||||
return api.getWorkPackages(projectId, assessments, scans);
|
||||
})();
|
||||
cache.set(key, request);
|
||||
request.catch(() => cache!.delete(key));
|
||||
}
|
||||
request.then(response => { if (current) setState({ key, dataset, response }); },
|
||||
error => { if (current) setState({ key, dataset, error: error instanceof Error ? error.message : 'Work packages could not be loaded.' }); });
|
||||
return () => { current = false; };
|
||||
}, [engines.remediation, key, dataset]);
|
||||
if (!engines.remediation || !dataset || !projectId || !assessments.length) return { status: 'off' as const };
|
||||
if (state?.key !== key || state.dataset !== dataset) return { status: 'loading' as const };
|
||||
if (state.error) return { status: 'error' as const, message: state.error };
|
||||
return state.response?.status === 'ready' ? { status: 'ready' as const, index: workPackageIndex(state.response.result), overview: publishedWorkPackageOverview(state.response.result) }
|
||||
: { status: 'context-required' as const };
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import type { FindingRecord } from '../domain/finding';
|
||||
import { workPackageCode } from './work-package-label';
|
||||
export { workPackageCode } from './work-package-label';
|
||||
import type { DynamicFacet } from '../features/search-filter/dynamic-facets';
|
||||
import { policyFindingKey, scannerFindingKey, WORK_PACKAGE_FACET_ID, UNASSIGNED_WORK_PACKAGE, type RemediationResult, type WorkPackage } from './remediation-engine';
|
||||
|
||||
export interface WorkPackageIndex {
|
||||
facet: DynamicFacet; packages: readonly WorkPackage[];
|
||||
packageFor(record: FindingRecord): WorkPackage | undefined; unassigned(record: FindingRecord): boolean;
|
||||
}
|
||||
export function workRecordKey(record: FindingRecord): string {
|
||||
const field = (label: string) => record.fields.find(f => f.label.toLowerCase() === label)?.value.trim() ?? '';
|
||||
return record.findingOccurrenceId ? scannerFindingKey(record.findingOccurrenceId)
|
||||
: field('technical identity') ? policyFindingKey(field('scope'), field('technical identity')) : '';
|
||||
}
|
||||
const indexes = new WeakMap<RemediationResult, WorkPackageIndex>();
|
||||
export function workPackageIndex(result: RemediationResult): WorkPackageIndex {
|
||||
const previous = indexes.get(result);
|
||||
if (previous) return previous;
|
||||
const byKey = new Map(result.packages.flatMap(p => p.findingKeys.map(key => [key, p] as const)));
|
||||
const unassigned = new Set(result.unassignedFindingKeys);
|
||||
const index: WorkPackageIndex = { packages: result.packages, packageFor: record => byKey.get(workRecordKey(record)), unassigned: record => unassigned.has(workRecordKey(record)),
|
||||
facet: { id: WORK_PACKAGE_FACET_ID, label: 'Work Package', kind: 'category', source: 'derived', derivedKind: 'work-package',
|
||||
preserveEmptyValues: false, description: 'Up to eight tasks (fifteen for Windows host/GPO) of one Context cluster and platform; redundant findings of the same change form one task. Package numbers are identifiers, not priorities.',
|
||||
values: [...result.packages.map(p => ({ value: p.id, label: `${p.clusterLabel} → ${workPackageCode(p.code)}`, count: 0 })),
|
||||
{ value: UNASSIGNED_WORK_PACKAGE, label: 'Unassigned', count: 0 }],
|
||||
valuesFor: record => {
|
||||
const p = byKey.get(workRecordKey(record));
|
||||
return p ? [p.id] : unassigned.has(workRecordKey(record)) ? [UNASSIGNED_WORK_PACKAGE] : [];
|
||||
} }
|
||||
};
|
||||
indexes.set(result, index);
|
||||
return index;
|
||||
}
|
||||
/** The slot of the removed Result Message facet, before non-primary facets and Source: Scan. */
|
||||
export function withWorkPackageFacet(facets: DynamicFacet[], index?: WorkPackageIndex): DynamicFacet[] {
|
||||
const retained = facets.filter(f => !/^result[\s_-]*message$/i.test(f.label.trim()));
|
||||
if (!index) return retained;
|
||||
const position = retained.findIndex(f => !/^(Result Status|Severity|Category|Security Domain|CIS Category|MITRE ATT&?CK|MITRE D3FEND|Target|Affected Object)$/i.test(f.label));
|
||||
const at = position < 0 ? retained.length : position;
|
||||
return [...retained.slice(0, at), index.facet, ...retained.slice(at)];
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/** Display legacy snapshots with the current abbreviation, retaining their stable IDs. */
|
||||
export function workPackageCode(code: string): string {
|
||||
return code.replace(/^AP-/, 'WP-');
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import type { FindingRecord } from '../domain/finding';
|
||||
import type { RemediationResult, WorkPackage } from './remediation-engine';
|
||||
import { workRecordKey, type WorkPackageIndex } from './work-package-index';
|
||||
|
||||
export interface WorkPackageOverviewRow {
|
||||
workPackage: WorkPackage;
|
||||
tasks: number;
|
||||
findings: number;
|
||||
redundantFindings: number;
|
||||
}
|
||||
export interface WorkPackageOverview {
|
||||
rows: WorkPackageOverviewRow[];
|
||||
records: number;
|
||||
assigned: number;
|
||||
unassigned: number;
|
||||
outsideSnapshot: number;
|
||||
tasks: number;
|
||||
additionalSameChangeFindings: number;
|
||||
}
|
||||
|
||||
/** Complete published scope; never reduced by Core Work filters. */
|
||||
export function publishedWorkPackageOverview(result: RemediationResult): WorkPackageOverview {
|
||||
const rows = result.packages.map(workPackage => ({ workPackage, tasks: workPackage.facts.tasks,
|
||||
findings: workPackage.facts.findings, redundantFindings: workPackage.facts.redundantFindings }));
|
||||
const assigned = rows.reduce((total, row) => total + row.findings, 0);
|
||||
const tasks = rows.reduce((total, row) => total + row.tasks, 0);
|
||||
const unassigned = result.unassignedFindingKeys.length;
|
||||
return { rows, records: assigned + unassigned, assigned, tasks, unassigned, outsideSnapshot: 0,
|
||||
additionalSameChangeFindings: assigned - tasks };
|
||||
}
|
||||
|
||||
/** Read-only display projection of the exact visible Work records and published task identities. */
|
||||
export function workPackageOverview(records: readonly FindingRecord[], index: WorkPackageIndex): WorkPackageOverview {
|
||||
const members = new Map<string, FindingRecord[]>();
|
||||
let unassigned = 0, outsideSnapshot = 0;
|
||||
for (const record of records) {
|
||||
const workPackage = index.packageFor(record);
|
||||
if (workPackage) {
|
||||
const group = members.get(workPackage.id) ?? [];
|
||||
group.push(record); members.set(workPackage.id, group);
|
||||
} else if (index.unassigned(record)) unassigned++;
|
||||
else outsideSnapshot++;
|
||||
}
|
||||
const rows = index.packages.flatMap(workPackage => {
|
||||
const visible = members.get(workPackage.id);
|
||||
if (!visible?.length) return [];
|
||||
const keys = new Set(visible.map(workRecordKey));
|
||||
const tasks = workPackage.tasks.filter(task => task.findingKeys.some(key => keys.has(key)));
|
||||
const redundantKeys = new Set(tasks.filter(task => task.redundant && task.findingKeys.filter(key => keys.has(key)).length > 1)
|
||||
.flatMap(task => task.findingKeys));
|
||||
return [{ workPackage, tasks: tasks.length, findings: visible.length,
|
||||
redundantFindings: visible.filter(record => redundantKeys.has(workRecordKey(record))).length }];
|
||||
});
|
||||
const assigned = rows.reduce((n, row) => n + row.findings, 0);
|
||||
const tasks = rows.reduce((n, row) => n + row.tasks, 0);
|
||||
return { rows, records: records.length, assigned, unassigned, outsideSnapshot, tasks,
|
||||
additionalSameChangeFindings: assigned - tasks };
|
||||
}
|
||||
|
|
@ -9,11 +9,14 @@ import {
|
|||
} from './report-projection';
|
||||
import type { UnifiedResultProjection } from '../domain/unified-result';
|
||||
import { toolProfileDefinitions } from '../profiles/profile-definition-catalog';
|
||||
import { securityDomainCatalog, securityDomainIdForRecord, securityDomainIdForUnifiedResult } from '../evaluation/security-domains';
|
||||
import { securityDomainBuckets, securityDomainCatalog, securityDomainIdForRecord, securityDomainIdForUnifiedResult } from '../evaluation/security-domains';
|
||||
import { summarizeWorkspaceResults } from '../features/project-import/datahub-scan-analysis';
|
||||
import { profileForSheet } from '../features/project-import/workspace-profile';
|
||||
import { dashboardResultState } from '../features/dashboard/dashboard-result-state';
|
||||
import { managementWorkspaceDeviationKeys } from './management-business-impact';
|
||||
import { riskAcceptanceState } from '../domain/risk-acceptance';
|
||||
import { buildReportWorkspaceView } from './report-workspace-view';
|
||||
import { reportHeaderFacts } from './report-header-facts';
|
||||
|
||||
function field(record: FindingRecord, label: string): string {
|
||||
return record.fields.find(item => item.label.toLocaleLowerCase('en-US') === label.toLocaleLowerCase('en-US'))?.value?.trim() ?? '';
|
||||
|
|
@ -47,16 +50,44 @@ export interface ClientReportProjectionInput {
|
|||
overallScore?: number;
|
||||
scoreSources?: ReportScoreSource[];
|
||||
reviews?: Record<string, Review>;
|
||||
/** Client Info's GPO count of the processed backups, if any. */
|
||||
gpoCount?: number;
|
||||
}
|
||||
|
||||
/** One frozen, filter-independent cut for both HTML files. */
|
||||
export function clientReportInput(input: ClientReportProjectionInput): ReportProjectionInput {
|
||||
const workspaceView = buildReportWorkspaceView(input.dataset, input.environment);
|
||||
const projectSheet = input.dataset.sheets.find(sheet => sheet.isProjectSheet);
|
||||
const countDataset = input.workspaceDataset ?? input.dataset;
|
||||
const countSheet = countDataset.sheets.find(sheet => sheet.isProjectSheet);
|
||||
const counts = countSheet ? summarizeWorkspaceResults(countSheet.analysis.records, profileForSheet(countDataset, countSheet)) : undefined;
|
||||
const otherStates = countSheet ? countSheet.analysis.records.map(record => dashboardResultState(record, profileForSheet(countDataset, countSheet))) : [];
|
||||
// Same rule as Client Info's risk register (RiskRegister.tsx): one entry per occurrence across all sheets;
|
||||
// current means Accepted, Scheduled, Review due or Review required.
|
||||
const riskStates = new Map<string, string>();
|
||||
for (const sheet of input.dataset.sheets) for (const record of sheet.analysis.records) {
|
||||
const value = input.reviews?.[record.id]?.riskAcceptance;
|
||||
const latest = value?.revisions.at(-1);
|
||||
if (!latest) continue;
|
||||
const occurrenceId = record.findingOccurrenceId ?? latest.targetOccurrenceId ?? record.id;
|
||||
if (!riskStates.has(occurrenceId)) riskStates.set(occurrenceId, riskAcceptanceState(value, occurrenceId, input.generatedAt.slice(0, 10)));
|
||||
}
|
||||
const riskRegister = { recorded: riskStates.size,
|
||||
current: [...riskStates.values()].filter(state => ['Accepted', 'Scheduled', 'Review due', 'Review required'].includes(state)).length };
|
||||
const deviationKeys = countSheet ? managementWorkspaceDeviationKeys(countSheet.analysis.records, record =>
|
||||
dashboardResultState(record, profileForSheet(countDataset, countSheet)) === 'deviation') : undefined;
|
||||
// Same Raw Data records, states and classifier as Client Info; the summary-only count sheet lacks
|
||||
// the Raw Data fields the classifier reads, so domains always come from the full project sheet.
|
||||
const workspaceDomains = projectSheet ? (() => {
|
||||
const profile = profileForSheet(input.dataset, projectSheet);
|
||||
const buckets = securityDomainBuckets(projectSheet.analysis.records, projectSheet.toolId);
|
||||
return securityDomainCatalog.map(({ id, label }) => {
|
||||
const states = (buckets.get(id) ?? []).map(record => dashboardResultState(record, profile));
|
||||
const passed = states.filter(state => state === 'passed').length;
|
||||
const deviations = states.filter(state => state === 'deviation').length;
|
||||
return { domainId: id, domainLabel: label, passed, deviations, assessed: passed + deviations };
|
||||
});
|
||||
})() : undefined;
|
||||
const policyRecords = (projectSheet?.analysis.records ?? []).filter(record => field(record, 'Category') === 'GPOs');
|
||||
const policyEntries = policyRecords.flatMap(record => {
|
||||
const entry = policyEntry(record);
|
||||
|
|
@ -112,16 +143,22 @@ export function clientReportInput(input: ClientReportProjectionInput): ReportPro
|
|||
priorityBases: reportPriorityBases(toolProfileDefinitions),
|
||||
overallScore: input.overallScore,
|
||||
scoreSources: input.scoreSources,
|
||||
workspaceCounts: counts ? { deviations: counts.deviation, assessed: counts.deviation + counts.passed } : undefined,
|
||||
workspaceCounts: counts ? { deviations: counts.deviation, assessed: counts.deviation + counts.passed,
|
||||
notRun: counts.notRun, notApplicable: counts.notApplicable, executionErrors: counts.error,
|
||||
notConfigured: otherStates.filter(state => state === 'not-configured').length,
|
||||
unknown: otherStates.filter(state => state === 'unknown').length } : undefined,
|
||||
riskRegister,
|
||||
workspaceDeviationKeys: deviationKeys,
|
||||
workspaceDomains,
|
||||
workspaceView,
|
||||
headerFacts: reportHeaderFacts(input.environment, {
|
||||
gpoCount: input.gpoCount,
|
||||
// Client Info infers one Entra tenant from loaded Maester results when no tenant identity is retained.
|
||||
inferredEntraTenant: workspaceView?.records.some(record => record.toolId === 'maester')
|
||||
}),
|
||||
environment,
|
||||
policyEntries,
|
||||
reviews: reviewInputs,
|
||||
technicalPolicySources: policyRecords.map(record => ({ settingId: record.id, sources: record.policySourceEvidence ?? [] })),
|
||||
technicalFindingFields: input.results.flatMap(result => {
|
||||
const record = workRecordByOccurrence.get(result.sourceResultId);
|
||||
return record ? [{ resultProjectionId: result.resultProjectionId, fields: record.fields }] : [];
|
||||
}),
|
||||
policyScopeLimited: true,
|
||||
provenance: uniqueProvenance,
|
||||
priorityLimitPerDomain: 8
|
||||
|
|
|
|||
|
|
@ -65,25 +65,40 @@ export function buildContextManagementHtml(projection: ReportProjection): string
|
|||
if (!context) throw new ReportContextRequiredError();
|
||||
const active = context.clusters.filter(item => countOf(item) > 0);
|
||||
const classified = active.filter(item => item.id !== 'other-deviations');
|
||||
const impacts = managementBusinessImpacts(context);
|
||||
const distribution = managementFindingDistribution(context, projection.workspaceCounts?.deviations ?? context.findingCount, projection.workspaceDeviationKeys);
|
||||
const themeCount = (id: string) => distribution.themes.find(theme => theme.id === id)!.count;
|
||||
// One theme order for summary, overview, chart and theme sheets: by count, ties in catalog order.
|
||||
const impacts = [...managementBusinessImpacts(context)].sort((left, right) => themeCount(right.id) - themeCount(left.id));
|
||||
// Read the same published workspace summary as Work; technical rows are evidence only.
|
||||
const deviations = projection.workspaceCounts?.deviations ?? projection.coverage.deviations;
|
||||
const assessed = projection.workspaceCounts?.assessed ?? projection.coverage.assessed;
|
||||
const distribution = managementFindingDistribution(context, projection.workspaceCounts?.deviations ?? context.findingCount, projection.workspaceDeviationKeys);
|
||||
const themeCount = (id: string) => distribution.themes.find(theme => theme.id === id)!.count;
|
||||
const distributionRows: Array<[string, number]> = distribution.themes.map(item => [item.label, item.count]);
|
||||
const distributionRows: Array<[string, number]> = impacts.map(item => [item.label, themeCount(item.id)]);
|
||||
if (distribution.unassigned) distributionRows.push(['Ohne Problemkategorie', distribution.unassigned]);
|
||||
const reviewed = projection.workItems.filter(item => item.status && item.status !== 'open');
|
||||
const accepted = projection.remediation?.riskAccepted ?? 0;
|
||||
// Unassessed states follow the Work records, as Client Info and Data Hub count them; older snapshots keep result rows.
|
||||
const work = projection.workspaceCounts;
|
||||
const notRun = work?.notRun ?? projection.coverage.notRun;
|
||||
const executionErrors = work?.executionErrors ?? projection.coverage.executionErrors;
|
||||
const notApplicable = work?.notApplicable ?? projection.coverage.notApplicable;
|
||||
const unknown = work?.unknown ?? projection.coverage.unknown;
|
||||
const risks = projection.riskRegister;
|
||||
const riskSentence = risks
|
||||
? risks.recorded
|
||||
? `Im Risikoregister ${risks.recorded === 1 ? 'ist eine Risikoakzeptanz' : `sind ${n(risks.recorded)} Risikoakzeptanzen`} erfasst; ${risks.current === 0 ? 'keine davon ist' : risks.current === 1 ? 'eine davon ist' : `${n(risks.current)} davon sind`} aktuell gültig.`
|
||||
: 'Im Risikoregister ist keine Risikoakzeptanz erfasst.'
|
||||
: accepted ? `Für ${n(accepted)} ${accepted === 1 ? 'Abweichung ist' : 'Abweichungen ist'} eine Risikoakzeptanz dokumentiert.` : 'Für keine Abweichung ist eine Risikoakzeptanz dokumentiert.';
|
||||
const date = new Date(projection.generatedAt).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' });
|
||||
const sheets: Array<{ title: string; subtitle: string; body: string }> = [];
|
||||
|
||||
// Summary statements derive only from published counts; nothing is rated or extrapolated.
|
||||
const ranked = [...distribution.themes].filter(item => item.count > 0).sort((left, right) => right.count - left.count);
|
||||
const ranked = impacts.map(item => ({ label: item.label, count: themeCount(item.id) })).filter(item => item.count > 0);
|
||||
const critical = classified.filter(item => item.severity === 'critical');
|
||||
const criticalCount = critical.reduce((sum, item) => sum + countOf(item), 0);
|
||||
const topCategories = [...classified].sort((left, right) => countOf(right) - countOf(left) || left.label.localeCompare(right.label)).slice(0, 3);
|
||||
const domainRates = projection.domains.filter(item => item.assessed > 0)
|
||||
// Work-level domain counts reconcile to the headline; older snapshots keep their result-row counts.
|
||||
const domainTable = projection.workspaceDomains ?? projection.domains;
|
||||
const domainRates = domainTable.filter(item => item.assessed > 0)
|
||||
.sort((left, right) => right.deviations / right.assessed - left.deviations / left.assessed || right.deviations - left.deviations);
|
||||
const score = projection.overallScore;
|
||||
const scoreText = typeof score === 'number' && Number.isFinite(score) && score >= 0 && score <= 100
|
||||
|
|
@ -94,20 +109,20 @@ export function buildContextManagementHtml(projection: ReportProjection): string
|
|||
topCategories.length > 1 ? `Am stärksten belegt sind die Problemfelder ${list(topCategories.map(item => `${textOf(item).name} (${n(countOf(item))})`))}.` : '',
|
||||
critical.length ? `${critical.length === 1 ? 'Im Problemfeld' : 'In den Problemfeldern'} mit der Grundschwere Critical – ${list(critical.map(item => textOf(item).name))} – ${criticalCount === 1 ? 'liegt' : 'liegen'} zusammen ${deviationsWord(criticalCount)} vor.` : 'Keinem Problemfeld mit der Grundschwere Critical ist eine Abweichung zugeordnet.',
|
||||
domainRates.length ? `Nach Security Domains zeigt ${domainRates[0].domainLabel} die höchste Abweichungsquote (${n(domainRates[0].deviations)} von ${n(domainRates[0].assessed)}, ${share(domainRates[0].deviations, domainRates[0].assessed)}).` : '',
|
||||
projection.coverage.notRun || projection.coverage.executionErrors
|
||||
? `${n(projection.coverage.notRun)} Prüfungen wurden nicht ausgeführt${projection.coverage.executionErrors ? `, ${n(projection.coverage.executionErrors)} endeten mit einem Ausführungsfehler` : ''}. Ihr Ergebnis ist offen und nicht als bestanden gewertet.` : ''
|
||||
notRun || executionErrors
|
||||
? `${n(notRun)} Prüfungen wurden nicht ausgeführt${executionErrors ? `, ${n(executionErrors)} endeten mit einem Ausführungsfehler` : ''}. Ihr Ergebnis ist offen und nicht als bestanden gewertet.` : ''
|
||||
].filter(Boolean);
|
||||
|
||||
sheets.push({ title: 'Security Assessment', subtitle: 'Summary', body: `
|
||||
<div class="two-column"><div><h2>Summary</h2>
|
||||
${summary.map(sentence => `<p>${escapeHtml(sentence)}</p>`).join('')}
|
||||
<section class="action-fields"><h3>Handlungsfelder</h3>${ranked.length ? `<ul>${ranked.map(item => `<li>${escapeHtml(item.label)} – ${escapeHtml(deviationsWord(item.count))}</li>`).join('')}</ul>` : '<p>Keine Business-Impact-Zuordnung aus den vorhandenen Kategorien ableitbar.</p>'}</section>
|
||||
<p>Die folgenden Angaben beschreiben je Thema den möglichen Business Impact, die betroffenen Problemfelder und deren Ansatzpunkte. Originalempfehlungen und Einzelnachweise stehen im technischen Report desselben Assessment-Stands.</p></div>
|
||||
<p>Die folgenden Angaben beschreiben je Thema den möglichen Business Impact, die betroffenen Problemfelder und deren Ansatzpunkte. Der technische Report desselben Assessment-Stands vertieft die Problemfelder; Einzelnachweise und Originalempfehlungen stehen in ASSESSOR.</p></div>
|
||||
<div><h2>Assessment auf einen Blick</h2>${overallScoreChart(projection.overallScore)}${rows(['Kennzahl', 'Wert'], [
|
||||
['Bewertete Ergebnisse', n(assessed)], ['Abweichungen', n(deviations)], ['Abweichungsquote', share(deviations, assessed)],
|
||||
['Belegte Problemfelder', n(classified.length)], ['Nicht ausgeführte Prüfungen', n(projection.coverage.notRun)],
|
||||
['Ausführungsfehler', n(projection.coverage.executionErrors)]])}</div></div>
|
||||
<div class="two-column"><div><h2>Bearbeitungsstand und Entscheidungen</h2><p>${accepted ? `Für ${n(accepted)} ${accepted === 1 ? 'Abweichung ist' : 'Abweichungen ist'} eine Risikoakzeptanz dokumentiert.` : 'Für keine Abweichung ist eine Risikoakzeptanz dokumentiert.'} ${reviewed.length ? `Für ${n(reviewed.length)} ${reviewed.length === 1 ? 'Abweichung ist' : 'Abweichungen ist'} ein Bearbeitungsstatus erfasst.` : 'Ein Bearbeitungsstatus ist nicht erfasst.'}</p><p class="note">Arbeitsstatus und Risikoakzeptanz beweisen keine Behebung. Behebung erfordert einen vergleichbaren Rescan.</p></div>
|
||||
['Belegte Problemfelder', n(classified.length)], ['Nicht ausgeführte Prüfungen', n(notRun)],
|
||||
['Ausführungsfehler', n(executionErrors)]])}</div></div>
|
||||
<div class="two-column"><div><h2>Bearbeitungsstand und Entscheidungen</h2><p>${riskSentence} ${reviewed.length ? `Für ${n(reviewed.length)} ${reviewed.length === 1 ? 'Abweichung ist' : 'Abweichungen ist'} ein Bearbeitungsstatus erfasst.` : 'Ein Bearbeitungsstatus ist nicht erfasst.'}</p><p class="note">Arbeitsstatus und Risikoakzeptanz beweisen keine Behebung. Behebung erfordert einen vergleichbaren Rescan.</p></div>
|
||||
<div><h2>Veränderung zum Vergleichsstand</h2>${projection.delta ? rows(['Vergleichsstatus', 'Anzahl'], Object.entries(projection.delta.counts).map(([state, value]) => [state, n(value)])) : '<p>Kein belegter Assessment-Vergleich in diesem Report-Snapshot vorhanden; eine zeitliche Verbesserung oder Verschlechterung wird nicht behauptet.</p>'}</div></div>` });
|
||||
|
||||
sheets.push({ title: 'Möglicher Business Impact', subtitle: 'Themen im Überblick', body: `
|
||||
|
|
@ -115,7 +130,7 @@ ${summary.map(sentence => `<p>${escapeHtml(sentence)}</p>`).join('')}
|
|||
<div class="business-overview">${rows(['Thema', 'Leitfrage', 'Anzahl', 'Anteil'], impacts.map(item => [item.label,
|
||||
item.clusters.length ? item.question : 'Aus den vorhandenen Context-Kategorien ist hierzu keine Aussage ableitbar.',
|
||||
n(themeCount(item.id)), share(themeCount(item.id), distribution.total)]).concat(distribution.unassigned
|
||||
? [['Ohne Problemkategorie', 'Abweichungen ohne belegtes Problemfeld; im technischen Report vollständig enthalten.', n(distribution.unassigned), share(distribution.unassigned, distribution.total)]] : []))}</div>
|
||||
? [['Ohne Problemkategorie', 'Abweichungen ohne belegtes Problemfeld; im technischen Report gesondert ausgewiesen.', n(distribution.unassigned), share(distribution.unassigned, distribution.total)]] : []))}</div>
|
||||
${bars('Abweichungen nach Hauptthema', distributionRows)}
|
||||
<p><strong>Gesamt: ${n(distribution.total)} Abweichungen</strong></p>
|
||||
<p class="note">${escapeHtml(MANAGEMENT_DOWNSTREAM_CONSEQUENCES)}</p>
|
||||
|
|
@ -144,15 +159,21 @@ ${clusters.length ? `<h2>Problemfelder</h2>${clusters.map(categoryBlock).join(''
|
|||
<p class="note">Critical · High · Medium · grau ohne Abweichungen oder mit unbestimmter Grundschwere. Neutral bedeutet nicht sicher: Ein graues Problemfeld ist nicht automatisch unauffällig. Erhobene Konfigurationen und beobachtete Zustände sind unterschiedliche Belegarten.</p>
|
||||
${context.sharedSettingLevers.length ? `<p>${n(context.sharedSettingLevers.length)} ${context.sharedSettingLevers.length === 1 ? 'Konfigurationseinstellung erfüllt' : 'Konfigurationseinstellungen erfüllen'} Bedingungen mehrerer Problemfelder. Dies sind gemeinsame Prüfansatzpunkte, keine zugesicherte Wirkung oder Aufwandsbewertung.</p>` : ''}` });
|
||||
|
||||
const observed = projection.provenance.map(item => item.observedAt).filter((value): value is string => Boolean(value)).sort();
|
||||
// Collections are the scan and GPO collection runs; import, processing and evaluation steps are no collection.
|
||||
// Their date follows the Client Info timeslot: the imported source files' dates, never the import or processing time.
|
||||
const collections = new Set(projection.provenance.filter(item => item.kind === 'ScanRun').map(item => item.id)).size;
|
||||
const sourceDays = [...new Set(projection.provenance.flatMap(item => item.sourceDate ? [item.sourceDate] : []).sort().map(dateDe))];
|
||||
const collectionWord = collections === 1 ? 'eine Erhebung' : `${n(collections)} Erhebungen`;
|
||||
const unmapped = active.filter(item => item.id !== 'other-deviations' && !impacts.some(theme => theme.clusters.some(cluster => cluster.id === item.id)));
|
||||
sheets.push({ title: 'Untersuchungsumfang', subtitle: 'Datenbasis und Nachvollziehbarkeit', body: `
|
||||
<h2>Untersuchungsumfang und Datenbasis</h2><dl class="facts">${projection.environment.map(item => fact(item.key, item.value ?? 'Unbekannt')).join('')}</dl>
|
||||
<p>${observed.length ? observed.length === 1 ? `Grundlage ist eine Erhebung vom ${dateDe(observed[0])}.`
|
||||
: `Grundlage sind ${n(observed.length)} Erhebungen zwischen dem ${dateDe(observed[0])} und dem ${dateDe(observed.at(-1)!)}.` : 'Keine Erhebungszeitpunkte in diesem Snapshot vorhanden.'}
|
||||
Nicht anwendbar: ${n(projection.coverage.notApplicable)} · sonstige unbewertete Ergebnisse: ${n(projection.coverage.unknown)}.</p>
|
||||
<p>${collections ? `Grundlage ${collections === 1 ? 'ist' : 'sind'} ${collectionWord}${sourceDays.length === 1 ? ` im Timeslot vom ${sourceDays[0]}`
|
||||
: sourceDays.length > 1 ? ` mit Quelldateien vom ${sourceDays[0]} bis ${sourceDays.at(-1)}` : ''}.` : 'Keine Erhebungen in diesem Snapshot vorhanden.'}
|
||||
Nicht anwendbar: ${n(notApplicable)}${work?.notConfigured !== undefined ? ` · nicht konfiguriert: ${n(work.notConfigured)}` : ''} · sonstige unbewertete Ergebnisse: ${n(unknown)}.</p>
|
||||
${unmapped.length ? `<h2>Offene fachliche Zuordnung</h2>${rows(['Context-Kategorie', 'Findings', 'Einordnung'], unmapped.map(item => [item.label, n(countOf(item)), 'Keine Business-Impact-Zuordnung; fachlich prüfen.']))}` : ''}
|
||||
<h2>Technische Domain-Auswertung</h2>${rows(['Security Domain', 'Bewertet', 'Abweichend', 'Bestanden', 'Abweichungsquote'], projection.domains.map(item => [item.domainLabel, n(item.assessed), n(item.deviations), n(item.passed), item.assessed ? share(item.deviations, item.assessed) : '–']))}<p class="note">Diese Zahlen beschreiben technische Ergebniszeilen. Sie sind keine Summen der Problemfelder.</p>
|
||||
<h2>Technische Domain-Auswertung</h2>${rows(['Security Domain', 'Bewertet', 'Abweichend', 'Bestanden', 'Abweichungsquote'], domainTable.map(item => [item.domainLabel, n(item.assessed), n(item.deviations), n(item.passed), item.assessed ? share(item.deviations, item.assessed) : '–']))}<p class="note">${projection.workspaceDomains
|
||||
? 'Jede bewertete Prüfung zählt in genau einer Security Domain; die Summen entsprechen den bewerteten Ergebnissen und Abweichungen der Summary.'
|
||||
: 'Diese Zahlen beschreiben technische Ergebniszeilen. Sie sind keine Summen der Problemfelder.'}</p>
|
||||
<h2>Lesehinweise und Aussagegrenzen</h2><ul class="reading-notes">${MANAGEMENT_READING_NOTES.map(note => `<li>${escapeHtml(note)}</li>`).join('')}</ul>
|
||||
<h2>Nachvollziehbarkeit</h2><p>Der separate technische Report enthält die Belege und Originalempfehlungen. Beide Dateien wurden aus demselben gespeicherten Assessment-Stand erstellt.</p>
|
||||
<p class="note">ASSESSOR Einordnung ${escapeHtml(MANAGEMENT_BUSINESS_IMPACT_VERSION)} · Textbausteine ${escapeHtml(MANAGEMENT_REPORT_TEXTS_VERSION)} · Context Engine ${escapeHtml(context.engineVersion)} · Katalog ${escapeHtml(context.catalogVersion)}. Offline erzeugt, ohne LLM und ohne Internetabruf. Aussagen verwenden feste Textbausteine und gespeicherte Ergebnisse. Tatsächliche Geschäftsfolgen, Ausnutzung, Kosten, Verantwortliche und Termine werden nicht behauptet.</p>` });
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { statementText } from './report-statement-text';
|
|||
import { buildContextManagementHtml } from './context-management-report';
|
||||
|
||||
/** Offline management report: a factual view of the shared projection. */
|
||||
export const MANAGEMENT_OVERVIEW_RENDERER_VERSION = '3.3.0';
|
||||
export const MANAGEMENT_OVERVIEW_RENDERER_VERSION = '3.3.1';
|
||||
const DASH = '—';
|
||||
const count = (n: number): string => Number.isFinite(n) ? n.toLocaleString('de-DE') : DASH;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
import { escapeHtml } from '../shared/html';
|
||||
|
||||
/**
|
||||
* Static SVG/HTML chart primitives of the Technical Report in the ASSESSOR chart language: full
|
||||
* 360-degree donuts with a neutral track, flat arc ends from 12 o'clock and a narrow mark for a
|
||||
* positive share below 2 %; flat bars without gradients or shadows. Colours are token references only.
|
||||
*/
|
||||
const esc = escapeHtml;
|
||||
const RING_RADIUS = 48;
|
||||
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
|
||||
|
||||
export const formatCount = (value: number): string => value.toLocaleString('de-DE');
|
||||
|
||||
/** Whole percentage from the unrounded share; a positive share below 1 % reads `<1 %`. */
|
||||
export function formatShare(part: number, whole: number): string {
|
||||
if (whole <= 0) return '—';
|
||||
const share = 100 * part / whole;
|
||||
if (part > 0 && Math.round(share) === 0) return '<1 %';
|
||||
return `${formatCount(Math.round(share))} %`;
|
||||
}
|
||||
|
||||
function ringArc(share: number, tone: string): string {
|
||||
if (share <= 0) return '';
|
||||
const clamped = Math.min(1, share);
|
||||
const arc = `<circle class="ring-value" cx="60" cy="60" r="${RING_RADIUS}" style="stroke:var(${tone})"
|
||||
stroke-dasharray="${(clamped * RING_CIRCUMFERENCE).toFixed(3)} ${RING_CIRCUMFERENCE.toFixed(3)}" transform="rotate(-90 60 60)"/>`;
|
||||
// A positive share below 2 % keeps a narrow radial mark so it stays visible at the compact ring size.
|
||||
const mark = clamped < 0.02 ? `<line class="ring-mark" x1="60" y1="${60 - RING_RADIUS - 6}" x2="60" y2="${60 - RING_RADIUS + 6}" style="stroke:var(${tone})"/>` : '';
|
||||
return arc + mark;
|
||||
}
|
||||
|
||||
/** Count/percentage donut: bold absolute count above the regular-weight share. */
|
||||
export function countDonut(title: string, part: number, whole: number, tone: string, basis: string): string {
|
||||
return `<figure class="donut-tile"><figcaption>${esc(title)}</figcaption>
|
||||
<div class="ring"><svg viewBox="0 0 120 120" aria-hidden="true"><circle class="ring-track" cx="60" cy="60" r="${RING_RADIUS}"/>${ringArc(whole > 0 ? part / whole : 0, tone)}</svg>
|
||||
<span class="ring-readout"><strong>${formatCount(part)}</strong><span>${formatShare(part, whole)}</span></span></div>
|
||||
<p class="donut-basis">${esc(basis)}</p></figure>`;
|
||||
}
|
||||
|
||||
export interface DonutSegment { value: number; tone: string; label: string }
|
||||
|
||||
/**
|
||||
* Segmented distribution donut: categorical arcs in the given order, clockwise from 12 o'clock on the
|
||||
* same track and stroke as every other donut, closing the ring when they sum to `whole`. The readout
|
||||
* shows the given count and share; a legend lists every segment with its count.
|
||||
*/
|
||||
export function segmentDonut(title: string, segments: DonutSegment[], whole: number, readout: { value: number; share: string }): string {
|
||||
let start = 0;
|
||||
const arcs = whole > 0 ? segments.filter(item => item.value > 0).map(item => {
|
||||
const length = item.value / whole * RING_CIRCUMFERENCE;
|
||||
const arc = `<circle class="ring-value" cx="60" cy="60" r="${RING_RADIUS}" style="stroke:var(${item.tone})"
|
||||
stroke-dasharray="${length.toFixed(3)} ${RING_CIRCUMFERENCE.toFixed(3)}" stroke-dashoffset="${(-start).toFixed(3)}" transform="rotate(-90 60 60)"><title>${esc(`${item.label}: ${formatCount(item.value)}`)}</title></circle>`;
|
||||
start += length;
|
||||
return arc;
|
||||
}).join('') : '';
|
||||
return `<figure class="donut-tile"><figcaption>${esc(title)}</figcaption>
|
||||
<div class="ring"><svg viewBox="0 0 120 120" role="img" aria-label="${esc(segments.map(item => `${item.label} ${formatCount(item.value)}`).join(', '))}"><circle class="ring-track" cx="60" cy="60" r="${RING_RADIUS}"/>${arcs}</svg>
|
||||
<span class="ring-readout"><strong>${formatCount(readout.value)}</strong><span>${esc(readout.share)}</span></span></div>
|
||||
<ul class="donut-legend">${segments.filter(item => item.value > 0).map(item =>
|
||||
`<li><i style="background:var(${item.tone})"></i>${esc(item.label)} <b>${formatCount(item.value)}</b></li>`).join('')}</ul></figure>`;
|
||||
}
|
||||
|
||||
export function scoreTone(score?: number): string {
|
||||
return score === undefined ? '--donut-not-run' : score < 50 ? '--sev-critical' : score < 75 ? '--sev-medium' : '--donut-passed';
|
||||
}
|
||||
|
||||
/** Score ring on the 0-100 scale with its `/100` semantics instead of an invented count. */
|
||||
export function scoreDonut(title: string, score: number | undefined, basis: string, compact = false): string {
|
||||
const label = score === undefined ? '—' : score > 0 && score < 0.5 ? '<1' : formatCount(Math.round(score));
|
||||
return `<figure class="donut-tile${compact ? ' donut-tile--compact' : ''}"><figcaption>${esc(title)}</figcaption>
|
||||
<div class="ring"><svg viewBox="0 0 120 120" aria-hidden="true"><circle class="ring-track" cx="60" cy="60" r="${RING_RADIUS}"/>${score === undefined ? '' : ringArc(score / 100, scoreTone(score))}</svg>
|
||||
<span class="ring-readout"><strong>${label}</strong><span>${score === undefined ? 'kein Wert' : '/100'}</span></span></div>
|
||||
${basis ? `<p class="donut-basis">${esc(basis)}</p>` : ''}</figure>`;
|
||||
}
|
||||
|
||||
export interface BarSegment { value: number; tone: string; label: string }
|
||||
|
||||
/** One flat stacked bar; widths relative to `scale`, every segment labelled for assistive technology. */
|
||||
export function stackedBar(segments: BarSegment[], scale: number): string {
|
||||
const total = segments.reduce((sum, item) => sum + item.value, 0);
|
||||
const parts = segments.filter(item => item.value > 0).map(item =>
|
||||
`<span class="bar-seg" style="width:${(100 * item.value / Math.max(1, scale)).toFixed(3)}%;background:var(${item.tone})" title="${esc(`${item.label}: ${formatCount(item.value)}`)}"></span>`).join('');
|
||||
return `<span class="bar" role="img" aria-label="${esc(segments.map(item => `${item.label} ${formatCount(item.value)}`).join(', '))}">${total ? parts : ''}</span>`;
|
||||
}
|
||||
|
||||
export interface BipartiteNode { id: string; label: string; total: number; tone?: string; detail?: string }
|
||||
export interface BipartiteLink { from: string; to: string; value: number }
|
||||
|
||||
/**
|
||||
* Pathfinder relation view as a static two-column diagram: Security Domains on the left, the Context
|
||||
* clusters their deviations belong to on the right. Link width follows the deviation count.
|
||||
*/
|
||||
export function bipartiteDiagram(left: BipartiteNode[], right: BipartiteNode[], links: BipartiteLink[]): string {
|
||||
const row = 24;
|
||||
const top = 12;
|
||||
const width = 700;
|
||||
const leftX = 230;
|
||||
const rightX = 410;
|
||||
const height = top * 2 + Math.max(left.length, right.length) * row;
|
||||
const leftGap = (height - top * 2) / Math.max(1, left.length);
|
||||
const leftY = new Map(left.map((node, index) => [node.id, top + leftGap * (index + 0.5)]));
|
||||
const rightY = new Map(right.map((node, index) => [node.id, top + row * (index + 0.5)]));
|
||||
const tone = new Map(left.map(node => [node.id, node.tone ?? '--graph-edge']));
|
||||
const max = Math.max(1, ...links.map(link => link.value));
|
||||
const stroke = (value: number) => (1.5 + 12 * Math.sqrt(value / max)).toFixed(2);
|
||||
const paths = links.filter(link => leftY.has(link.from) && rightY.has(link.to)).map(link => {
|
||||
const y1 = leftY.get(link.from)!;
|
||||
const y2 = rightY.get(link.to)!;
|
||||
const mid = (leftX + rightX) / 2;
|
||||
return `<path class="flow-link" d="M${leftX} ${y1.toFixed(1)} C${mid} ${y1.toFixed(1)} ${mid} ${y2.toFixed(1)} ${rightX} ${y2.toFixed(1)}"
|
||||
style="stroke:var(${tone.get(link.from)});stroke-width:${stroke(link.value)}"><title>${esc(`${left.find(node => node.id === link.from)?.label} → ${right.find(node => node.id === link.to)?.label}: ${formatCount(link.value)}`)}</title></path>`;
|
||||
}).join('');
|
||||
const leftNodes = left.map(node => {
|
||||
const y = leftY.get(node.id)!;
|
||||
return `<rect class="flow-node" x="${leftX - 6}" y="${(y - 11).toFixed(1)}" width="6" height="22" style="fill:var(${node.tone ?? '--graph-category'})"/>
|
||||
<text class="flow-label" x="${leftX - 14}" y="${(y + 5).toFixed(1)}" text-anchor="end">${esc(node.label)} <tspan class="flow-count">${formatCount(node.total)}</tspan></text>`;
|
||||
}).join('');
|
||||
const rightNodes = right.map(node => {
|
||||
const y = rightY.get(node.id)!;
|
||||
return `<rect class="flow-node" x="${rightX}" y="${(y - 9).toFixed(1)}" width="6" height="18" style="fill:var(${node.tone ?? '--context-marker'})"/>
|
||||
<text class="flow-label" x="${rightX + 14}" y="${(y + 5).toFixed(1)}">${esc(node.label)} <tspan class="flow-count">${formatCount(node.total)}</tspan>${node.detail ? ` <tspan class="flow-detail">${esc(node.detail)}</tspan>` : ''}</text>`;
|
||||
}).join('');
|
||||
return `<svg class="flow" viewBox="0 0 ${width} ${height}" role="img" aria-label="Security Domains und die Problemfelder ihrer Abweichungen">${paths}${leftNodes}${rightNodes}</svg>`;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
// Application token files embedded verbatim into standalone reports (bundled by Vite as text).
|
||||
declare module '*.css?raw' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import type { EnvironmentSnapshot } from '../domain/environment-context';
|
||||
import type { ImportArtifact, ImportProcessingRun } from '../domain/ingestion-persistence';
|
||||
import type { StoredGpoBackupInstance } from '../domain/gpo-analysis-persistence';
|
||||
import {
|
||||
clientInfoOverview, cloudInformationTileFacts, numberFact, operatingSystemOverview
|
||||
} from '../features/dashboard/client-info-facts';
|
||||
import type { ReportHeaderFacts } from './report-projection';
|
||||
|
||||
/**
|
||||
* The Technical Report's `Information` section: the Client Info overview facts of the same snapshot,
|
||||
* built by the same React-free functions Client Info renders (Data facts, Domain Information and the
|
||||
* six environment tiles with their compact overviews). Nothing is derived here beyond that selection.
|
||||
*/
|
||||
export function reportHeaderFacts(snapshot: EnvironmentSnapshot | undefined,
|
||||
options: { gpoCount?: number; inferredEntraTenant?: boolean }): ReportHeaderFacts | undefined {
|
||||
if (!snapshot) return undefined;
|
||||
// A partial snapshot reads as empty lists, so every missing fact stays an em dash.
|
||||
snapshot = { ...snapshot, observations: snapshot.observations ?? [], sections: snapshot.sections ?? [] };
|
||||
const overview = clientInfoOverview(snapshot, options);
|
||||
const tile = <T>(items: T[]) => items.slice(0, 4);
|
||||
return {
|
||||
data: overview.data.map(item => ({ label: item.label, value: numberFact(item.value) })),
|
||||
domainInformation: overview.domainInformation,
|
||||
groups: [
|
||||
{ title: 'Resilience', facts: tile(overview.detail.resilience) },
|
||||
{ title: 'Inventory', facts: tile(overview.inventory) },
|
||||
{ title: 'Password policy', facts: tile(overview.detail.passwordPolicy) },
|
||||
{ title: 'Certificates & PKI', facts: tile(overview.detail.certificates) },
|
||||
{ title: 'Operating systems', facts: operatingSystemOverview(overview.detail.operatingSystems) },
|
||||
{ title: 'Cloud Information', facts: cloudInformationTileFacts(snapshot).map(area => ({ label: area.label, value: area.values.join(', ') })) }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The GPO count Client Info shows: per stored GPO backup source, the backup instances of its latest
|
||||
* processing run, counted only when that run has a status (same rule as the GPO backup status list).
|
||||
*/
|
||||
export function reportGpoBackupCount(artifacts: ImportArtifact[], runs: ImportProcessingRun[],
|
||||
instances: StoredGpoBackupInstance[]): number | undefined {
|
||||
let processed = 0;
|
||||
let count = 0;
|
||||
for (const artifact of artifacts.filter(item => item.kind === 'source-file' && item.relativePath.startsWith('gpo-backups/'))) {
|
||||
const run = runs.filter(item => item.artifactId === artifact.id)
|
||||
.sort((left, right) => Date.parse(right.completedAt) - Date.parse(left.completedAt) || right.id.localeCompare(left.id))[0];
|
||||
if (!run?.status) continue;
|
||||
processed += 1;
|
||||
count += instances.filter(item => item.processingRunId === run.id).length;
|
||||
}
|
||||
return processed ? count : undefined;
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { dashboardScoreFromStates, type DashboardResultState } from '../features
|
|||
import { securitySourceScores, weightedSecurityScore } from '../domain/security-scoring';
|
||||
import { securityToolId } from '../scoring/canonical-security-score';
|
||||
import { clientReportInput } from './client-report-projection';
|
||||
import { reportGpoBackupCount } from './report-header-facts';
|
||||
|
||||
export interface ReportAssemblyScope {
|
||||
projectId: string;
|
||||
|
|
@ -32,13 +33,17 @@ export async function assembleReportProjectionInput(
|
|||
return model;
|
||||
},
|
||||
queryUnifiedResults: query => repository.queryUnifiedResults(query),
|
||||
listPolicyEvaluations: async projectId => ({ records: await repository.listPolicyEvaluations(projectId) }),
|
||||
// Client Info reads only the current policy evaluations (its overview's current runs); the report's
|
||||
// Raw Data records use the same selection so every Work record and count matches Client Info.
|
||||
listPolicyEvaluations: async projectId => ({ records: repository.listCurrentPolicyEvaluationsForMetrics
|
||||
? await repository.listCurrentPolicyEvaluationsForMetrics(projectId)
|
||||
: await repository.listPolicyEvaluations(projectId) }),
|
||||
listMicrosoftSecurityBaselineAnalyses: async baselineId => ({
|
||||
records: await repository.listMicrosoftSecurityBaselineAnalyses(baselineId)
|
||||
})
|
||||
}, scope.projectId, scope.scanRunIds, assessmentIds);
|
||||
if (!workspace.dataset) throw new Error('No reportable assessment data in the selected scope.');
|
||||
const [results, snapshot, storedReviews, processing, analyses, aggregations, evaluations] = await Promise.all([
|
||||
const [results, snapshot, storedReviews, processing, analyses, aggregations, evaluations, artifacts, backupInstances] = await Promise.all([
|
||||
queryAllUnifiedResults(query => repository.queryUnifiedResults(query), {
|
||||
audience: 'internal-analysis', projectId: scope.projectId,
|
||||
assessmentIds: workspace.assessmentIds, scanRunIds: scope.scanRunIds,
|
||||
|
|
@ -49,7 +54,10 @@ export async function assembleReportProjectionInput(
|
|||
repository.listProjectProcessingRuns(scope.projectId),
|
||||
repository.listGpoPolicyAnalyses(scope.projectId),
|
||||
repository.listPolicySnapshotAggregations(scope.projectId),
|
||||
repository.listPolicyEvaluations(scope.projectId)
|
||||
repository.listPolicyEvaluations(scope.projectId),
|
||||
// The GPO count Client Info shows comes from the processed GPO backup sources of the project.
|
||||
repository.listImportArtifacts(scope.projectId),
|
||||
repository.listGpoBackupInstances(scope.projectId)
|
||||
]);
|
||||
const dataset = workspace.dataset;
|
||||
const summaryWorkspace = await loadCanonicalProjectWorkspace({
|
||||
|
|
@ -82,7 +90,8 @@ export async function assembleReportProjectionInput(
|
|||
const reviews = hydrateWorkspaceReviews(dataset, storedReviews, {}).reviews;
|
||||
const preliminary = clientReportInput({
|
||||
project, assessmentIds: workspace.assessmentIds, generatedAt: scope.generatedAt,
|
||||
results, dataset, workspaceDataset: summaryWorkspace.dataset ?? undefined, environment: snapshot, reviews
|
||||
results, dataset, workspaceDataset: summaryWorkspace.dataset ?? undefined, environment: snapshot, reviews,
|
||||
gpoCount: reportGpoBackupCount(artifacts, processing, backupInstances)
|
||||
});
|
||||
const ratingByTool = new Map(securitySourceScores(dataset.securityScoring?.scans ?? []).map(item => [item.toolId, item]));
|
||||
const resultsByTool = new Map<string, typeof results>();
|
||||
|
|
@ -138,7 +147,8 @@ export async function assembleReportProjectionInput(
|
|||
...workspace.models.flatMap(model => model.importFiles.map(file => ({
|
||||
kind: `ImportFile: ${file.fileName}`, id: file.id,
|
||||
version: [file.processedWith.profileVersion, file.processedWith.mappingVersion,
|
||||
file.processedWith.normalizationVersion].filter(Boolean).join(' / '), observedAt: file.importedAt
|
||||
file.processedWith.normalizationVersion].filter(Boolean).join(' / '), observedAt: file.importedAt,
|
||||
sourceDate: file.lastModifiedAt ?? file.importedAt
|
||||
}))),
|
||||
...scopedProcessing.map(run => ({ kind: `ProcessingRun: ${run.status}`, id: run.id,
|
||||
version: run.processors.map(item => `${item.id}@${item.version}`).join(', '), observedAt: run.completedAt })),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type { ReportContextPublication } from './report-context';
|
|||
import type { RemediationEvidenceState } from '../domain/timeslot-comparison';
|
||||
import type { ToolPriorityBasisDefinition } from '../domain/tool-profile-definition';
|
||||
import type { UnifiedResultProjection } from '../domain/unified-result';
|
||||
import type { PolicySourceEvidence, RecordField } from '../domain/finding';
|
||||
import { correlateReportResults, type ReportDomainConvergence, type ReportRemediationBundle, type ReportSingleSourcePolicyAction } from './report-correlation';
|
||||
import { severityRank } from '../domain/severity-order';
|
||||
import { buildReportStatements, type ReportStatement } from './report-narrative';
|
||||
|
|
@ -18,7 +17,7 @@ import { buildReportStatements, type ReportStatement } from './report-narrative'
|
|||
* every German wording lives in a renderer. It is also platform-neutral: no
|
||||
* clock, no storage, no network and no presentation types.
|
||||
*/
|
||||
export const REPORT_PROJECTION_VERSION = '1.3.0';
|
||||
export const REPORT_PROJECTION_VERSION = '1.5.0';
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Priority basis */
|
||||
|
|
@ -102,6 +101,97 @@ export interface ReportFact {
|
|||
value?: string;
|
||||
}
|
||||
|
||||
/** One technical environment fact; an unknown value stays an em dash. */
|
||||
export interface ReportTileFact {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Technical environment context read from the same normalized observations Client Info reads. */
|
||||
export interface ReportEnvironmentFacts {
|
||||
directory: ReportTileFact[];
|
||||
resilience: ReportTileFact[];
|
||||
operatingSystems: Array<{ label: string; total?: number; entries: Array<{ name: string; count?: number }> }>;
|
||||
/** Scanner sections the source could not read (licence or access restriction). */
|
||||
restricted: string[];
|
||||
}
|
||||
|
||||
/** A Pathfinder ATT&CK relation exactly as the frozen reference recognition states it. */
|
||||
export interface ReportAttackRelation {
|
||||
kind: 'technique' | 'tactic';
|
||||
id: string;
|
||||
title: string;
|
||||
tactics: string[];
|
||||
/** `declared` by the scanner itself, or only `mentioned` in its text. */
|
||||
evidence: 'declared' | 'mentioned';
|
||||
}
|
||||
|
||||
/** One Work record (Raw Data), reduced to what the Technical Report aggregates. */
|
||||
export interface ReportWorkRecord {
|
||||
id: string;
|
||||
title: string;
|
||||
toolId?: string;
|
||||
toolLabel: string;
|
||||
/** Client Info result state of the record (deviation, passed, not-configured, ...). */
|
||||
state: string;
|
||||
severity: string;
|
||||
domainId: string;
|
||||
/** Check area named by the source: benchmark section, policy section or native category. */
|
||||
area?: string;
|
||||
ruleId?: string;
|
||||
/** Work counting key (scanner occurrence or GPO scope/identity), shared with the Context publication. */
|
||||
countingKey: string;
|
||||
diagnostics: string[];
|
||||
attack: ReportAttackRelation[];
|
||||
defenses: Array<{ id: string; title: string; evidence: 'declared' | 'mentioned' }>;
|
||||
policy?: { type?: string; backups: number; unlinkedBackups: number };
|
||||
/** Finding Reader card of the record: head, Problem rows, Description and catalog references. */
|
||||
card?: ReportFindingCard;
|
||||
}
|
||||
|
||||
/** The head of a Finding Reader card: native status, badges and the first-read Problem rows. */
|
||||
export interface ReportFindingCard {
|
||||
status?: { value: string; outcome?: string };
|
||||
/** Severity first as its own badge, then `Label: value` badges in Finding Reader order. */
|
||||
badges: Array<{ text: string; severity?: string }>;
|
||||
problem?: { heading: string; rows: Array<{ label: string; value: string; state?: 'deviating' | 'aligned' }> };
|
||||
/** MITRE badges the source states on the record (`MITRE ATT&CK: Credential Access`). */
|
||||
mitre: string[];
|
||||
/** The source description as plain paragraphs. */
|
||||
description?: string;
|
||||
/** Recognized ATT&CK/D3FEND catalog references with the source text that states them. */
|
||||
references: Array<{ label: string; text: string; url?: string }>;
|
||||
}
|
||||
|
||||
/** The Client Info Data tile totals over the same project-sheet records and states as Client Info. */
|
||||
export interface ReportClientInfoTotals {
|
||||
passed: number;
|
||||
deviations: number;
|
||||
notConfigured: number;
|
||||
conflicts: number;
|
||||
duplicates: number;
|
||||
assessed: number;
|
||||
eligible: number;
|
||||
unassessed: number;
|
||||
coverage?: number;
|
||||
}
|
||||
|
||||
/** Client Info overview facts exactly as Client Info shows them, for the report header. */
|
||||
export interface ReportHeaderFacts {
|
||||
data: ReportTileFact[];
|
||||
domainInformation: ReportTileFact[];
|
||||
groups: Array<{ title: string; facts: ReportTileFact[] }>;
|
||||
}
|
||||
|
||||
export interface ReportWorkspaceView {
|
||||
version: string;
|
||||
/** ATT&CK catalog the Pathfinder relations were resolved against. */
|
||||
attackCatalogVersion: string;
|
||||
totals: ReportClientInfoTotals;
|
||||
records: ReportWorkRecord[];
|
||||
environment?: ReportEnvironmentFacts;
|
||||
}
|
||||
|
||||
export interface ReportScoreSource {
|
||||
toolId: string;
|
||||
/** Versioned ASSESSOR contribution on the 0-100 scale, absent when unavailable. */
|
||||
|
|
@ -146,6 +236,8 @@ export interface ReportProvenanceEntry {
|
|||
id: string;
|
||||
version?: string;
|
||||
observedAt?: string;
|
||||
/** Imported source file date (last modified, else imported): the basis of the Client Info timeslot date. */
|
||||
sourceDate?: string;
|
||||
}
|
||||
|
||||
/** Client-owned planning fields. Never proof, always self-reported. */
|
||||
|
|
@ -192,13 +284,18 @@ export interface ReportProjectionInput {
|
|||
domainOf: (result: UnifiedResultProjection) => string;
|
||||
priorityBases?: Readonly<Record<string, ReportPriorityBasis>>;
|
||||
scoreSources?: ReportScoreSource[];
|
||||
workspaceCounts?: { deviations: number; assessed: number };
|
||||
workspaceCounts?: ReportWorkspaceCounts;
|
||||
workspaceDeviationKeys?: string[];
|
||||
/** Risk register as Client Info shows it: recorded acceptances and the currently valid ones. */
|
||||
riskRegister?: { recorded: number; current: number };
|
||||
/** Security Domain counts over the same Work records as `workspaceCounts` (one rolled-up setting = one check). */
|
||||
workspaceDomains?: ReportWorkspaceDomain[];
|
||||
overallScore?: number;
|
||||
environment?: ReportFact[];
|
||||
workspaceView?: ReportWorkspaceView;
|
||||
/** Client Info overview facts for the report header, when supplied. */
|
||||
headerFacts?: ReportHeaderFacts;
|
||||
policyEntries?: ReportPolicyEntry[];
|
||||
technicalPolicySources?: Array<{ settingId: string; sources: PolicySourceEvidence[] }>;
|
||||
technicalFindingFields?: Array<{ resultProjectionId: string; fields: RecordField[] }>;
|
||||
policyScopeLimited?: boolean;
|
||||
reviews?: ReportReviewInput[];
|
||||
diagnostics?: ReportDiagnostic[];
|
||||
|
|
@ -241,6 +338,26 @@ export interface ReportDomainPriority {
|
|||
}>;
|
||||
}
|
||||
|
||||
/** Result states of the Work records, counted as Client Info and Data Hub count them. */
|
||||
export interface ReportWorkspaceCounts {
|
||||
deviations: number;
|
||||
assessed: number;
|
||||
notRun?: number;
|
||||
notApplicable?: number;
|
||||
executionErrors?: number;
|
||||
notConfigured?: number;
|
||||
unknown?: number;
|
||||
}
|
||||
|
||||
/** Work-level Security Domain counts; they reconcile to `workspaceCounts`. */
|
||||
export interface ReportWorkspaceDomain {
|
||||
domainId: string;
|
||||
domainLabel: string;
|
||||
passed: number;
|
||||
deviations: number;
|
||||
assessed: number;
|
||||
}
|
||||
|
||||
export interface ReportDomainSummary {
|
||||
domainId: string;
|
||||
domainLabel: string;
|
||||
|
|
@ -352,10 +469,16 @@ export interface ReportProjection {
|
|||
project: { id: string; name: string };
|
||||
assessmentId?: string;
|
||||
environment: ReportFact[];
|
||||
/** Technical cut only: the lean Work records with their Pathfinder relations. */
|
||||
workspaceView?: ReportWorkspaceView;
|
||||
/** Client Info overview facts for the report header, when supplied. */
|
||||
headerFacts?: ReportHeaderFacts;
|
||||
overallScore?: number;
|
||||
scoreSources: ReportScoreSource[];
|
||||
workspaceCounts?: { deviations: number; assessed: number };
|
||||
workspaceCounts?: ReportWorkspaceCounts;
|
||||
workspaceDeviationKeys?: string[];
|
||||
riskRegister?: { recorded: number; current: number };
|
||||
workspaceDomains?: ReportWorkspaceDomain[];
|
||||
domains: ReportDomainSummary[];
|
||||
priorities: ReportDomainPriority[];
|
||||
gpo?: ReportGpoBlock;
|
||||
|
|
@ -372,8 +495,6 @@ export interface ReportProjection {
|
|||
technicalResults?: UnifiedResultProjection[];
|
||||
technicalResultDomains?: Record<string, string>;
|
||||
policyEntries: ReportPolicyEntry[];
|
||||
technicalPolicySources?: Array<{ settingId: string; sources: PolicySourceEvidence[] }>;
|
||||
technicalFindingFields?: Array<{ resultProjectionId: string; fields: RecordField[] }>;
|
||||
technicalReviews?: ReportReviewInput[];
|
||||
workItems: ReportWorkItem[];
|
||||
sourcePriorities: ReportSourcePriority[];
|
||||
|
|
@ -697,10 +818,14 @@ export function buildReportProjection(input: ReportProjectionInput): ReportProje
|
|||
project: input.project,
|
||||
assessmentId: input.assessmentId,
|
||||
environment: input.environment ?? [],
|
||||
workspaceView: input.workspaceView ? structuredClone(input.workspaceView) : undefined,
|
||||
headerFacts: input.headerFacts ? structuredClone(input.headerFacts) : undefined,
|
||||
overallScore: input.overallScore,
|
||||
scoreSources: input.scoreSources ?? [],
|
||||
workspaceCounts: input.workspaceCounts ? { ...input.workspaceCounts } : undefined,
|
||||
workspaceDeviationKeys: input.workspaceDeviationKeys ? [...input.workspaceDeviationKeys] : undefined,
|
||||
workspaceDomains: input.workspaceDomains?.map(item => ({ ...item })),
|
||||
riskRegister: input.riskRegister ? { ...input.riskRegister } : undefined,
|
||||
domains: domainSummaries,
|
||||
priorities,
|
||||
gpo,
|
||||
|
|
@ -711,8 +836,6 @@ export function buildReportProjection(input: ReportProjectionInput): ReportProje
|
|||
technicalResults: results,
|
||||
technicalResultDomains: Object.fromEntries(results.map(result => [result.resultProjectionId, domainOf(result)])),
|
||||
policyEntries,
|
||||
technicalPolicySources: input.technicalPolicySources,
|
||||
technicalFindingFields: input.technicalFindingFields,
|
||||
technicalReviews: reviews,
|
||||
workItems,
|
||||
sourcePriorities,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ export type ReportReadMethods = Pick<AssessorRepository,
|
|||
| 'getEnvironmentSnapshot'
|
||||
| 'listReviews'
|
||||
| 'listProjectProcessingRuns'
|
||||
| 'listImportArtifacts'
|
||||
| 'listGpoBackupInstances'
|
||||
| 'listGpoPolicyAnalyses'
|
||||
| 'listPolicySnapshotAggregations'
|
||||
| 'listReferenceRuns'>;
|
||||
|
|
@ -26,7 +28,7 @@ export interface ReportReadPort extends ReportReadMethods {
|
|||
|
||||
const READ_METHODS = [
|
||||
'getProject', 'listAssessments', 'loadAssessmentModel', 'queryUnifiedResults', 'listPolicyEvaluations',
|
||||
'listMicrosoftSecurityBaselineAnalyses', 'getEnvironmentSnapshot', 'listReviews', 'listProjectProcessingRuns',
|
||||
'listMicrosoftSecurityBaselineAnalyses', 'getEnvironmentSnapshot', 'listReviews', 'listProjectProcessingRuns', 'listImportArtifacts', 'listGpoBackupInstances',
|
||||
'listGpoPolicyAnalyses', 'listPolicySnapshotAggregations', 'listReferenceRuns'
|
||||
] as const satisfies ReadonlyArray<keyof ReportReadMethods>;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
import { escapeHtml } from '../shared/html';
|
||||
|
||||
/**
|
||||
* Static display of retained source prose (for example a scanner description) in the Technical Report.
|
||||
* Many exports carry Markdown whose line breaks were lost, so headings, numbered steps, bullet lists,
|
||||
* emphasis and links arrive on one line. This reader restores their structure for display only:
|
||||
* every character of text stays, only the Markdown markers become HTML structure. Everything is
|
||||
* escaped first; a link becomes an anchor only for a plain https URL, otherwise its caption stays text.
|
||||
*/
|
||||
|
||||
const esc = escapeHtml;
|
||||
|
||||
/** Inline Markdown on already escaped text: **bold** and [caption](url). */
|
||||
function inline(text: string): string {
|
||||
return esc(text)
|
||||
.replace(/\*\*([^*]+?)\*\*/g, '<b>$1</b>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]*)\)/g, (_match, caption: string, url: string) =>
|
||||
/^https:\/\/[^\s<>"']+$/i.test(url) ? `<a href="${url}" rel="noopener noreferrer">${caption}</a>` : caption);
|
||||
}
|
||||
|
||||
/** Splits an inline numbered list `… 1. a 2. b 3. c` when the numbers run 1, 2, 3 … without gaps. */
|
||||
function numberedItems(text: string): { lead: string; items: string[] } | undefined {
|
||||
const marks = [...text.matchAll(/(?:^|\s)(\d{1,2})\.\s+/g)];
|
||||
const start = marks.findIndex(mark => mark[1] === '1');
|
||||
if (start < 0) return undefined;
|
||||
const run = [marks[start]];
|
||||
for (const mark of marks.slice(start + 1)) if (Number(mark[1]) === run.length + 1) run.push(mark);
|
||||
if (run.length < 2) return undefined;
|
||||
const items = run.map((mark, index) => text.slice(mark.index! + mark[0].length, run[index + 1]?.index ?? text.length).trim());
|
||||
return { lead: text.slice(0, run[0].index).trim(), items };
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits an inline bullet list of at least two items: `… * a * b`, or `… - [a](…) - [b](…)` where a dash
|
||||
* only counts as a marker before a link, so a dash inside a sentence or link caption stays text.
|
||||
*/
|
||||
function bulletItems(text: string): { lead: string; items: string[] } | undefined {
|
||||
for (const marker of [/(?:^|\s)\*\s+(?=\S)/, /(?:^|\s)-\s+(?=\[)/]) {
|
||||
const parts = text.split(marker);
|
||||
if (parts.length >= 3) return { lead: parts[0].trim(), items: parts.slice(1).map(item => item.trim()).filter(Boolean) };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function block(text: string): string {
|
||||
const numbered = numberedItems(text);
|
||||
if (numbered) return `${numbered.lead ? `<p>${inline(numbered.lead)}</p>` : ''}<ol>${numbered.items.map(item => `<li>${inline(item)}</li>`).join('')}</ol>`;
|
||||
const bullets = bulletItems(text);
|
||||
if (bullets) return `${bullets.lead ? `<p>${inline(bullets.lead)}</p>` : ''}<ul>${bullets.items.map(item => `<li>${inline(item)}</li>`).join('')}</ul>`;
|
||||
return text ? `<p>${inline(text)}</p>` : '';
|
||||
}
|
||||
|
||||
/** The source text as escaped HTML blocks: paragraphs, `####` sub-headings, numbered and bullet lists. */
|
||||
export function reportRichText(text: string): string {
|
||||
// A paragraph break inside an unclosed link target belongs to that link, not to the prose.
|
||||
const paragraphs: string[] = [];
|
||||
for (const paragraph of text.split(/\r?\n\s*\r?\n/)) {
|
||||
const previous = paragraphs.at(-1);
|
||||
if (previous !== undefined && /\]\([^)]*$/.test(previous)) paragraphs[paragraphs.length - 1] = `${previous} ${paragraph}`;
|
||||
else paragraphs.push(paragraph);
|
||||
}
|
||||
return paragraphs.map(paragraph => paragraph.trim()).filter(Boolean).flatMap(paragraph => {
|
||||
// A Markdown heading (`##` to `######`) starts a section; its title runs to a colon or a list marker,
|
||||
// at most a few words. A single `#` is too common inside URLs and prose to count as a heading.
|
||||
const sections = paragraph.split(/\s+#{2,6}\s+|^#{2,6}\s+/);
|
||||
return sections.map((section, index) => {
|
||||
if (index === 0) return block(section.trim());
|
||||
const title = /^([^:*\n]{1,48}?)(?::\s*|\s+(?=[*-]\s)|$)/.exec(section);
|
||||
if (!title) return block(section.trim());
|
||||
return `<h5>${inline(title[1].trim())}</h5>${block(section.slice(title[0].length).trim())}`;
|
||||
});
|
||||
}).join('');
|
||||
}
|
||||
|
|
@ -83,7 +83,11 @@ export async function createReportRun(port: ReportReadPort, scope: ReportAssembl
|
|||
const { input, revision } = await assembleConsistentReportInput(port, scope);
|
||||
const assessmentIds = scope.assessmentIds ?? (scope.assessmentId ? [scope.assessmentId]
|
||||
: (await port.listAssessments(scope.projectId)).map(item => item.id));
|
||||
const context = options.readContext ? await options.readContext({ ...scope, assessmentIds }) : undefined;
|
||||
// An optional Context (Technical Report) that cannot be read leaves the report without problem fields
|
||||
// instead of failing it; a required one (Management Report) still fails the run.
|
||||
const context = options.readContext
|
||||
? await options.readContext({ ...scope, assessmentIds }).catch(error => { if (requireContext) throw error; return undefined; })
|
||||
: undefined;
|
||||
if (context && (context.projectId !== scope.projectId
|
||||
|| JSON.stringify([...context.assessmentIds].sort()) !== JSON.stringify([...assessmentIds].sort())
|
||||
|| JSON.stringify([...context.scanRunIds].sort()) !== JSON.stringify([...(scope.scanRunIds ?? [])].sort()))) throw new ReportContextRequiredError();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,224 @@
|
|||
import type { ImportedDataset } from '../domain/dataset';
|
||||
import type { EnvironmentSnapshot } from '../domain/environment-context';
|
||||
import type { FindingRecord } from '../domain/finding';
|
||||
import { toolIdForRecord } from '../importers/status-normalization';
|
||||
import { toolProfileDefinitions } from '../profiles/profile-definition-catalog';
|
||||
import { mitreBadgeLabel, normalizeSeverity } from '../profiles/purple-knight/presentation';
|
||||
import { policyDiagnosticBadges, policyFindingCategories } from '../profiles/group-policy/categories';
|
||||
import { policyProblemSummary } from '../profiles/group-policy/problem-summary';
|
||||
import { outcomeForStatusValue } from '../importers/status-normalization';
|
||||
import { normalizeNumericValue, plainTextValue } from '../shared/text';
|
||||
import { findingFirstRead } from '../features/finding-reader/first-read';
|
||||
import { resultStatusFieldFor } from '../features/linked-results/result-message';
|
||||
import { recordResultSheets } from '../features/linked-results/record-results';
|
||||
import { securityDomainIdForRecord } from '../evaluation/security-domains';
|
||||
import { profileForSheet } from '../features/project-import/workspace-profile';
|
||||
import { dashboardResultState, dashboardScoreFromStates } from '../features/dashboard/dashboard-result-state';
|
||||
import { operatingSystemInventory } from '../features/dashboard/operating-system-inventory';
|
||||
import { findingReferences } from '../references/finding-reference-projection';
|
||||
import { defaultMitreGraphData } from '../features/mitre-graph/mitre-graph';
|
||||
import type { ReportEnvironmentFacts, ReportFindingCard, ReportWorkRecord, ReportWorkspaceView } from './report-projection';
|
||||
|
||||
/**
|
||||
* Version of the frozen Work cut inside the Technical Report: one lean entry per Work record (Raw Data)
|
||||
* with its Client Info state, Security Domain, native check area and the Pathfinder relations
|
||||
* (ATT&CK, D3FEND) it carries. No raw field dump and no Results tables: the report aggregates.
|
||||
*/
|
||||
export const REPORT_WORKSPACE_VIEW_VERSION = '2.1.0';
|
||||
|
||||
const DASH = '—';
|
||||
const field = (record: FindingRecord, label: string) =>
|
||||
record.fields.find(item => item.label.toLowerCase() === label)?.value?.trim() ?? '';
|
||||
|
||||
/** Same counting key as the management distribution and the Context publication. */
|
||||
export function reportWorkCountingKey(record: FindingRecord): string {
|
||||
return record.findingOccurrenceId ? `scanner:${record.findingOccurrenceId}`
|
||||
: field(record, 'category') === 'GPOs' ? `gpo:${field(record, 'scope').toLowerCase()}\u0000${field(record, 'technical identity')}`
|
||||
: `unmapped:${record.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The check area the source itself names: the CIS-CAT benchmark section, the GPO policy section,
|
||||
* otherwise the native category. Never derived from the title or the outcome.
|
||||
*/
|
||||
function nativeArea(record: FindingRecord): string | undefined {
|
||||
if (field(record, 'category') === 'GPOs') return field(record, 'policy section') || undefined;
|
||||
return field(record, 'benchmark section') || field(record, 'category') || undefined;
|
||||
}
|
||||
|
||||
/** The Pathfinder relations of one record: the same frozen reference recognition the graph draws. */
|
||||
function relations(record: FindingRecord): Pick<ReportWorkRecord, 'attack' | 'defenses'> {
|
||||
const references = findingReferences(record, defaultMitreGraphData).filter(reference => reference.status === 'active');
|
||||
const attack = references.filter(reference => reference.framework === 'mitre-attack').map(reference => ({
|
||||
kind: reference.kind === 'tactic' ? 'tactic' as const : 'technique' as const,
|
||||
id: reference.id, title: reference.title ?? reference.id, tactics: [...reference.tactics], evidence: reference.evidence
|
||||
}));
|
||||
const defenses = references.filter(reference => reference.framework === 'mitre-d3fend')
|
||||
.map(reference => ({ id: reference.id, title: reference.title ?? reference.id, evidence: reference.evidence }));
|
||||
return { attack, defenses };
|
||||
}
|
||||
|
||||
/**
|
||||
* The statement of a native result text without the evidence appended to it: the text is cut, never
|
||||
* rephrased, where a Markdown list item, a `➡️` portal pointer, a table, a heading or an inline list
|
||||
* of at least two ` - ` items begins. A link inside the statement keeps its caption and loses only its
|
||||
* URL; Markdown emphasis markers are dropped. A short fragment left before the cut goes with it.
|
||||
*/
|
||||
export function resultStatement(value: string): string {
|
||||
const structure = /\s+[-*]\s+\[|\s*➡️|\r?\n\s*(?:[-*|#]|\d+\.\s)/.exec(value)?.index;
|
||||
const inlineItems = [...value.matchAll(/\s+-\s+/g)];
|
||||
const inlineList = inlineItems.length >= 2 ? inlineItems[0].index : undefined;
|
||||
const cut = Math.min(structure ?? value.length, inlineList ?? value.length);
|
||||
let statement = value.slice(0, cut).replace(/\[([^\]\n]*)\]\([^)\s]*\)/g, '$1').replace(/\*\*/g, '').trim();
|
||||
if (cut < value.length) {
|
||||
const sentenceEnd = Math.max(statement.lastIndexOf('. '), statement.lastIndexOf('! '), statement.lastIndexOf('? '));
|
||||
if (sentenceEnd > 0 && statement.length - sentenceEnd <= 24) statement = statement.slice(0, sentenceEnd + 1);
|
||||
}
|
||||
statement = statement.replace(/[\s:,;-]+$/, '');
|
||||
return statement || value.trim();
|
||||
}
|
||||
|
||||
/** Source prose as plain paragraphs: block tags and blank lines separate paragraphs, all markup is dropped. */
|
||||
function plainParagraphs(value: string): string {
|
||||
return value.split(/<\/?(?:p|div|h[1-6]|li|ul|ol|br)\b[^>]*>|\r?\n\s*\r?\n/i)
|
||||
.map(plainTextValue).filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
const REFERENCE_STATUS: Record<string, string> = { active: 'Catalog match', deprecated: 'Deprecated', revoked: 'Revoked', 'not-in-catalog': 'Not found in this catalog' };
|
||||
|
||||
const normalizeLabel = (label: string) => label.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
|
||||
/**
|
||||
* The Finding Reader card head of one record, from the same Core helpers the reader uses: native status,
|
||||
* the reader's badge fields (category, severity, weight, score, ...) and the first-read Problem rows.
|
||||
* MITRE badges are left out (the report shows relations in Beziehungen) and no tool is named.
|
||||
*/
|
||||
function findingCard(dataset: ImportedDataset, sheet: ImportedDataset['sheets'][number], record: FindingRecord, toolId?: string): ReportFindingCard {
|
||||
const statusField = resultStatusFieldFor(record, toolId);
|
||||
const outcome = statusField?.value ? outcomeForStatusValue(toolId, statusField.value) : 'unknown';
|
||||
const badges: ReportFindingCard['badges'] = record.fields.filter(item => {
|
||||
if (!item.value) return false;
|
||||
const label = normalizeLabel(item.label);
|
||||
return /^(?:category|severity|weight|score|category score|domain risk level)$/.test(label)
|
||||
|| (/^points?$/.test(label) && normalizeNumericValue(item.value) !== null);
|
||||
}).map(item => {
|
||||
if (normalizeLabel(item.label) === 'severity') return { text: item.value, severity: normalizeSeverity(item.value) };
|
||||
const scale = /^(?:category score|domain risk level)$/i.test(item.label.trim()) ? ' / 100' : '';
|
||||
return { text: `${item.label}: ${item.value}${scale}` };
|
||||
});
|
||||
badges.push(...policyFindingCategories(record, toolId).map(text => ({ text })), ...policyDiagnosticBadges(record, toolId).map(text => ({ text })));
|
||||
let problem: ReportFindingCard['problem'];
|
||||
if (toolId === 'group-policy') {
|
||||
const summary = policyProblemSummary(record);
|
||||
if (summary) {
|
||||
const rows = [
|
||||
...summary.configured.map(entry => ({ label: 'Configured', value: entry.note ? `${entry.text} (${entry.note})` : entry.text, state: entry.state })),
|
||||
...summary.expected.map(value => ({ label: 'Expected', value })),
|
||||
...(summary.additional.length ? [{ label: 'Additional entries', value: summary.additional.join(', ') }] : []),
|
||||
...(summary.missing.length ? [{ label: 'Missing entries', value: summary.missing.join(', ') }] : [])
|
||||
];
|
||||
if (rows.length) problem = { heading: summary.heading, rows };
|
||||
}
|
||||
} else {
|
||||
const firstRead = findingFirstRead(record, toolId, recordResultSheets(record, dataset.sheets, sheet.id, toolId), outcome);
|
||||
// The card head shows the statement rows (Result, Affected, Expected/Found); Impact is explanatory prose.
|
||||
const rows = firstRead?.rows.filter(row => row.label !== 'Impact')
|
||||
.map(row => row.label === 'Result' ? { ...row, value: resultStatement(row.value) } : { ...row }) ?? [];
|
||||
if (firstRead && rows.length) problem = { heading: firstRead.heading, rows };
|
||||
}
|
||||
const mitre = record.fields.filter(item => item.value && mitreBadgeLabel(item.label)).map(item => `${mitreBadgeLabel(item.label)}: ${item.value}`);
|
||||
const description = record.fields.find(item => normalizeLabel(item.label) === 'description' && item.value.trim());
|
||||
const references = (record.referenceRecognition?.references ?? []).map(reference => ({
|
||||
label: [`${reference.framework === 'mitre-attack' ? 'ATT&CK' : 'D3FEND'} ${reference.id}`, reference.title,
|
||||
reference.evidence === 'declared' ? 'Declared' : 'Mentioned', REFERENCE_STATUS[reference.status]].filter(Boolean).join(' · '),
|
||||
text: reference.source.text,
|
||||
url: reference.url && /^https:\/\/(?:attack\.mitre\.org|d3fend\.mitre\.org)\//.test(reference.url) ? reference.url : undefined
|
||||
}));
|
||||
return { status: statusField?.value ? { value: statusField.value, outcome } : undefined, badges, problem, mitre,
|
||||
description: description ? plainParagraphs(description.value) || undefined : undefined, references };
|
||||
}
|
||||
|
||||
function workRecord(dataset: ImportedDataset, sheet: ImportedDataset['sheets'][number], record: FindingRecord): ReportWorkRecord {
|
||||
const profile = profileForSheet(dataset, sheet);
|
||||
const toolId = toolIdForRecord(record, sheet.toolId ?? dataset.profileId);
|
||||
const severityField = record.fields.find(item => /^(severity|criticality|priority)$/i.test(item.label) && item.value);
|
||||
const sources = record.policySourceEvidence ?? [];
|
||||
const isPolicy = field(record, 'category') === 'GPOs';
|
||||
const state = dashboardResultState(record, profile);
|
||||
return {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
toolId,
|
||||
toolLabel: toolProfileDefinitions.find(item => item.id === toolId)?.name ?? (isPolicy ? 'GPOs' : toolId ?? 'Unknown'),
|
||||
state,
|
||||
severity: normalizeSeverity(severityField?.value),
|
||||
domainId: securityDomainIdForRecord(record, toolId),
|
||||
area: nativeArea(record),
|
||||
ruleId: field(record, 'source rule id') || undefined,
|
||||
countingKey: reportWorkCountingKey(record),
|
||||
diagnostics: policyDiagnosticBadges(record, toolId),
|
||||
...relations(record),
|
||||
// Only deviations open a card in the report; the cut stays lean for every other state.
|
||||
card: state === 'deviation' ? findingCard(dataset, sheet, record, toolId) : undefined,
|
||||
policy: isPolicy ? {
|
||||
type: field(record, 'policy type') || undefined,
|
||||
backups: new Set(sources.flatMap(source => source.backupInstanceId ? [source.backupInstanceId] : [])).size,
|
||||
unlinkedBackups: new Set(sources.flatMap(source => source.backupInstanceId && source.linkStatus === 'not-linked'
|
||||
? [source.backupInstanceId] : [])).size
|
||||
} : undefined
|
||||
};
|
||||
}
|
||||
|
||||
const observed = (snapshot: EnvironmentSnapshot | undefined, property: string): string => {
|
||||
const value = snapshot?.observations.find(item => item.property === property && item.state === 'observed');
|
||||
const raw = value?.normalizedValue ?? value?.originalValue;
|
||||
return raw === undefined || raw === null || raw === '' ? DASH : typeof raw === 'object' ? JSON.stringify(raw) : String(raw);
|
||||
};
|
||||
|
||||
/** Technical environment context from the same normalized observations Client Info reads. */
|
||||
function environmentFacts(snapshot?: EnvironmentSnapshot): ReportEnvironmentFacts | undefined {
|
||||
if (!snapshot || !Array.isArray(snapshot.observations)) return undefined;
|
||||
const dcOperatingSystems = [...new Set(snapshot.observations.filter(item => item.state === 'observed'
|
||||
&& item.property === 'operating_system.name' && item.subject.type === 'computer-information.domain-controllers')
|
||||
.map(item => String(item.normalizedValue ?? item.originalValue ?? '')).filter(Boolean))];
|
||||
return {
|
||||
directory: [
|
||||
{ label: 'Domain', value: observed(snapshot, 'domain.fqdn') },
|
||||
{ label: 'Forest', value: observed(snapshot, 'forest.name') },
|
||||
{ label: 'Domain functional level', value: observed(snapshot, 'domain.functional_level') },
|
||||
{ label: 'Forest functional level', value: observed(snapshot, 'forest.functional_level') },
|
||||
{ label: 'Domain controllers', value: observed(snapshot, 'domain.controller_count') },
|
||||
{ label: 'DC OS', value: dcOperatingSystems.length ? dcOperatingSystems.join(', ') : DASH },
|
||||
{ label: 'Schema version', value: observed(snapshot, 'forest.schema_version') }
|
||||
],
|
||||
resilience: [
|
||||
{ label: 'AD Recycle Bin', value: observed(snapshot, 'domain.recycle_bin_enabled') },
|
||||
{ label: 'Last AD backup', value: observed(snapshot, 'backup.last_detected_at') },
|
||||
{ label: 'krbtgt password', value: observed(snapshot, 'kerberos.krbtgt.password_last_changed') },
|
||||
{ label: 'Legacy LAPS', value: observed(snapshot, 'laps.legacy.installation_date') },
|
||||
{ label: 'Windows LAPS', value: observed(snapshot, 'laps.windows.installation_date') }
|
||||
],
|
||||
operatingSystems: operatingSystemInventory(snapshot).map(group => ({
|
||||
label: group.label, total: group.total,
|
||||
entries: group.entries.map(entry => ({ name: entry.name, count: entry.count }))
|
||||
})),
|
||||
restricted: (snapshot.sections ?? []).filter(section => ['license-restricted', 'access-denied'].includes(section.state))
|
||||
.map(section => section.label)
|
||||
};
|
||||
}
|
||||
|
||||
/** Frozen Work cut for the Technical Report; reads the shared Core projections only. */
|
||||
export function buildReportWorkspaceView(dataset: ImportedDataset, snapshot?: EnvironmentSnapshot): ReportWorkspaceView | undefined {
|
||||
const sheet = dataset.sheets.find(item => item.isProjectSheet);
|
||||
if (!sheet) return undefined;
|
||||
const profile = profileForSheet(dataset, sheet);
|
||||
const totals = dashboardScoreFromStates(sheet.analysis.records.map(record => dashboardResultState(record, profile)), sheet.analysis.records);
|
||||
return {
|
||||
version: REPORT_WORKSPACE_VIEW_VERSION,
|
||||
attackCatalogVersion: defaultMitreGraphData.version,
|
||||
totals: { passed: totals.passed, deviations: totals.deviations, notConfigured: totals.notConfigured, conflicts: totals.conflicts,
|
||||
duplicates: totals.duplicates, assessed: totals.assessed, eligible: totals.eligible, unassessed: totals.unassessed, coverage: totals.coverage },
|
||||
records: sheet.analysis.records.map(record => workRecord(dataset, sheet, record)),
|
||||
environment: environmentFacts(snapshot)
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import type { ReportContextPublication } from './report-context';
|
||||
import type { ReportFindingCard, ReportProjection, ReportWorkRecord } from './report-projection';
|
||||
import { managementCategoryTexts } from './management-report-texts';
|
||||
|
||||
/**
|
||||
* Technical Report model (user decisions 2026-10-07): a report, not a finding list, with ASSESSOR as
|
||||
* the single source. Scan tools are not shown or compared separately. Every number is aggregated from
|
||||
* the same Work records, result states and Security Domain classifier as Client Info, so headline,
|
||||
* domain and problem-field counts reconcile to the Client Info totals. Context and Pathfinder
|
||||
* relations are read as published; nothing is reclassified, ranked or scored again.
|
||||
*/
|
||||
export const TECHNICAL_REPORT_MODEL_VERSION = '2.1.0';
|
||||
|
||||
|
||||
export interface CountedLabel { key: string; label: string; count: number }
|
||||
|
||||
export interface TechnicalDomain {
|
||||
id: string;
|
||||
label: string;
|
||||
passed: number;
|
||||
deviations: number;
|
||||
assessed: number;
|
||||
}
|
||||
|
||||
export interface TechnicalCluster {
|
||||
id: string;
|
||||
label: string;
|
||||
germanName?: string;
|
||||
/** Sachverhalt of the category from the versioned report texts, else the catalog description. */
|
||||
situation: string;
|
||||
lever?: string;
|
||||
severity: string;
|
||||
count: number;
|
||||
domains: CountedLabel[];
|
||||
/** The Work records behind the problem field, reduced to title and record severity (most severe first). */
|
||||
findings: TechnicalFinding[];
|
||||
}
|
||||
|
||||
export interface TechnicalFinding { title: string; severity: string; ruleId?: string; card?: ReportFindingCard }
|
||||
|
||||
const FINDING_SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info', 'unknown'];
|
||||
|
||||
export interface TechnicalReportModel {
|
||||
version: string;
|
||||
client: string;
|
||||
generatedAt: string;
|
||||
totals: {
|
||||
deviations: number; passed: number; assessed: number; records: number;
|
||||
notConfigured: number; notRun: number; notApplicable: number; errors: number; unknown: number;
|
||||
overallScore?: number;
|
||||
};
|
||||
domains: TechnicalDomain[];
|
||||
context?: {
|
||||
catalogVersion: string; engineVersion: string; vocabularyVersion: string; severityCatalogVersion: string; revision: number;
|
||||
clusters: TechnicalCluster[];
|
||||
populated: TechnicalCluster[];
|
||||
empty: string[];
|
||||
/** Exclusive cluster counts; with `Ohne Problemkategorie` they sum to the Client Info deviation total. */
|
||||
partitionTotal: number;
|
||||
domainLinks: Array<{ domainId: string; clusterId: string; count: number }>;
|
||||
};
|
||||
riskRegister?: { recorded: number; current: number };
|
||||
}
|
||||
|
||||
function count<T>(items: readonly T[], key: (item: T) => string | undefined, label: (key: string) => string = item => item): CountedLabel[] {
|
||||
const map = new Map<string, number>();
|
||||
items.forEach(item => {
|
||||
const value = key(item);
|
||||
if (value) map.set(value, (map.get(value) ?? 0) + 1);
|
||||
});
|
||||
return [...map].map(([value, amount]) => ({ key: value, label: label(value), count: amount }))
|
||||
.sort((left, right) => right.count - left.count || left.label.localeCompare(right.label, 'de'));
|
||||
}
|
||||
|
||||
const rank = (severity: string) => {
|
||||
const index = FINDING_SEVERITY_ORDER.indexOf(severity);
|
||||
return index < 0 ? FINDING_SEVERITY_ORDER.length : index;
|
||||
};
|
||||
|
||||
function contextModel(context: ReportContextPublication, deviations: ReportWorkRecord[], domainLabel: (id: string) => string) {
|
||||
const byKey = new Map(deviations.map(record => [record.countingKey, record]));
|
||||
const clusters: TechnicalCluster[] = context.clusters.map(cluster => {
|
||||
const records = (cluster.findingKeys ?? []).flatMap(key => byKey.get(key) ? [byKey.get(key)!] : []);
|
||||
const text = managementCategoryTexts[cluster.id];
|
||||
return {
|
||||
id: cluster.id, label: cluster.label, germanName: text?.name, situation: text?.situation ?? cluster.description,
|
||||
lever: text?.lever, severity: cluster.severity, count: records.length,
|
||||
domains: count(records, record => record.domainId, domainLabel),
|
||||
findings: records.map(record => ({ title: record.title, severity: record.severity, ruleId: record.ruleId, card: record.card }))
|
||||
.sort((left, right) => rank(left.severity) - rank(right.severity) || left.title.localeCompare(right.title, 'en'))
|
||||
};
|
||||
});
|
||||
// ASSESSOR is authoritative: problem fields keep the exact order of the published Context heatmap.
|
||||
const populated = clusters.filter(cluster => cluster.count > 0);
|
||||
return {
|
||||
catalogVersion: context.catalogVersion, engineVersion: context.engineVersion,
|
||||
vocabularyVersion: context.vocabularyVersion, severityCatalogVersion: context.severityCatalogVersion,
|
||||
revision: context.repositoryRevision,
|
||||
/** Every published problem field in heatmap order, including those without deviations. */
|
||||
clusters,
|
||||
populated,
|
||||
empty: clusters.filter(cluster => cluster.count === 0).map(cluster => cluster.label),
|
||||
partitionTotal: populated.reduce((sum, cluster) => sum + cluster.count, 0),
|
||||
domainLinks: populated.flatMap(cluster => cluster.domains.map(domain => ({ domainId: domain.key, clusterId: cluster.id, count: domain.count })))
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTechnicalReportModel(projection: ReportProjection): TechnicalReportModel {
|
||||
const view = projection.workspaceView;
|
||||
const records = view?.records ?? [];
|
||||
const deviations = records.filter(record => record.state === 'deviation');
|
||||
const of = (state: string) => records.filter(record => record.state === state).length;
|
||||
const domainLabel = (id: string) => projection.workspaceDomains?.find(item => item.domainId === id)?.domainLabel
|
||||
?? projection.domains.find(item => item.domainId === id)?.domainLabel ?? id;
|
||||
const context = projection.context ? contextModel(projection.context, deviations, domainLabel) : undefined;
|
||||
const domainIds = projection.workspaceDomains?.map(item => item.domainId) ?? [...new Set(records.map(record => record.domainId))];
|
||||
return {
|
||||
version: TECHNICAL_REPORT_MODEL_VERSION,
|
||||
client: projection.project.name,
|
||||
generatedAt: projection.generatedAt,
|
||||
totals: {
|
||||
deviations: deviations.length, passed: of('passed'), assessed: deviations.length + of('passed'), records: records.length,
|
||||
notConfigured: of('not-configured'), notRun: of('not-run'), notApplicable: of('not-applicable'), errors: of('error'), unknown: of('unknown'),
|
||||
overallScore: projection.overallScore
|
||||
},
|
||||
domains: domainIds.map(id => {
|
||||
const inDomain = records.filter(record => record.domainId === id);
|
||||
const passed = inDomain.filter(record => record.state === 'passed').length;
|
||||
const failed = inDomain.filter(record => record.state === 'deviation').length;
|
||||
return { id, label: domainLabel(id), passed, deviations: failed, assessed: passed + failed };
|
||||
}),
|
||||
context,
|
||||
riskRegister: projection.riskRegister ? { ...projection.riskRegister } : undefined
|
||||
};
|
||||
}
|
||||
|
|
@ -1,167 +1,462 @@
|
|||
import type { JsonValue } from '../domain/canonical-model';
|
||||
import type { PolicySourceEvidence, RecordField } from '../domain/finding';
|
||||
import type { ReportProjection, ReportReviewInput } from './report-projection';
|
||||
import type { UnifiedResultProjection } from '../domain/unified-result';
|
||||
/// <reference path="./report-css.d.ts" />
|
||||
import type { ReportProjection } from './report-projection';
|
||||
import { escapeHtml } from '../shared/html';
|
||||
import { EVIDENCE_ROLE_LABELS, STATEMENT_TITLES, statementText } from './report-statement-text';
|
||||
import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';
|
||||
import { REPORT_INTER_FONT } from './report-font';
|
||||
import { REPORT_ASSESSOR_LOGO } from './report-brand';
|
||||
import { reportRichText } from './report-rich-text';
|
||||
import { bipartiteDiagram, countDonut, formatCount, formatShare, scoreDonut, scoreTone, stackedBar, type BarSegment } from './report-charts';
|
||||
import {
|
||||
buildTechnicalReportModel, TECHNICAL_REPORT_MODEL_VERSION, type TechnicalCluster, type TechnicalFinding, type TechnicalReportModel
|
||||
} from './technical-report-model';
|
||||
import uiTokens from '../styles/ui-tokens.css?raw';
|
||||
import legacyTokens from '../styles/tokens.css?raw';
|
||||
|
||||
export const TECHNICAL_REPORT_RENDERER_VERSION = '1.1.0';
|
||||
/**
|
||||
* Technical Report (Report B), rebuilt on the user decisions of 2026-10-07: a technical report that
|
||||
* explains the assessment in depth instead of listing every finding, with ASSESSOR as its single
|
||||
* source (scan tools are neither named nor compared). Problem fields (Context), Pathfinder relations,
|
||||
* Security Domains, the GPO baseline comparison and coverage, each with graphics in the ASSESSOR
|
||||
* application language. Static German HTML: no script, no external resource; the tokens are the
|
||||
* application's own files.
|
||||
*/
|
||||
export const TECHNICAL_REPORT_RENDERER_VERSION = '4.2.1';
|
||||
|
||||
const plain = (value: unknown): string => value === undefined || value === null || value === ''
|
||||
? '—' : escapeHtml(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value));
|
||||
const number = (value: number): string => escapeHtml(value.toLocaleString('de-DE'));
|
||||
const when = (value: string): string => Number.isFinite(Date.parse(value))
|
||||
? escapeHtml(new Date(value).toLocaleString('de-DE')) : plain(value);
|
||||
const DASH = '—';
|
||||
const esc = escapeHtml;
|
||||
const num = formatCount;
|
||||
const when = (value?: string): string => value && Number.isFinite(Date.parse(value))
|
||||
? esc(new Date(value).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' }))
|
||||
: DASH;
|
||||
const plural = (count: number, one: string, many: string) => `${num(count)} ${count === 1 ? one : many}`;
|
||||
|
||||
function table(headers: string[], rows: string[][]): string {
|
||||
return `<div class="table-scroll"><table><thead><tr>${headers.map(label => `<th>${escapeHtml(label)}</th>`).join('')}</tr></thead>
|
||||
<tbody>${rows.map(row => `<tr>${row.map(cell => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
|
||||
/** Categorical chart tones of the six Security Domains, in catalog order. */
|
||||
const DOMAIN_TONES = ['--viz-2', '--viz-5', '--viz-3', '--viz-4', '--viz-1', '--viz-6'];
|
||||
const SEVERITY_TONES: Record<string, string> = {
|
||||
critical: '--sev-critical', high: '--sev-high', medium: '--sev-medium', low: '--sev-low', info: '--sev-info', unknown: '--sev-unknown',
|
||||
undetermined: '--sev-unknown'
|
||||
};
|
||||
const SEVERITY_LABELS: Record<string, string> = {
|
||||
critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', info: 'Info', unknown: 'Ohne Angabe', undetermined: 'Undetermined'
|
||||
};
|
||||
|
||||
const badge = (severity: string) =>
|
||||
`<span class="sev-badge" style="background:var(${SEVERITY_TONES[severity] ?? '--sev-unknown'})">${esc(SEVERITY_LABELS[severity] ?? severity)}</span>`;
|
||||
const legend = (items: Array<{ tone: string; label: string; value?: number }>) => `<ul class="legend">${items.map(item =>
|
||||
`<li><i style="background:var(${item.tone})"></i>${esc(item.label)}${item.value === undefined ? '' : ` <b>${num(item.value)}</b>`}</li>`).join('')}</ul>`;
|
||||
const facts = (items: Array<[string, string]>, single = false) => `<dl class="facts${single ? ' facts--single' : ''}">${items.map(([label, value]) =>
|
||||
`<div><dt>${esc(label)}</dt><dd>${value}</dd></div>`).join('')}</dl>`;
|
||||
const section = (id: string, number: number, title: string, lead: string, body: string) =>
|
||||
`<section class="report-section" id="${id}"><header class="section-head"><span class="section-number">${number}</span><h2>${esc(title)}</h2></header>
|
||||
${lead ? `<p class="lead">${lead}</p>` : ''}${body}</section>`;
|
||||
|
||||
/** Every check by its Client Info result state, in a fixed order. */
|
||||
function resultStates(model: TechnicalReportModel): BarSegment[] {
|
||||
const t = model.totals;
|
||||
return [
|
||||
{ value: t.deviations, tone: '--donut-deviation', label: 'Deviation' },
|
||||
{ value: t.passed, tone: '--donut-passed', label: 'Passed' },
|
||||
{ value: t.notConfigured, tone: '--donut-not-run', label: 'Not configured' },
|
||||
{ value: t.notRun, tone: '--graph-unknown', label: 'Not run' },
|
||||
{ value: t.unknown, tone: '--graph-category', label: 'Ohne Ergebnis' },
|
||||
{ value: t.notApplicable, tone: '--donut-not-applicable', label: 'Not applicable' },
|
||||
{ value: t.errors, tone: '--donut-error', label: 'Error' }
|
||||
];
|
||||
}
|
||||
|
||||
function sourceValue(value: JsonValue, markedFields: readonly string[], markedItems: readonly string[]): string {
|
||||
if (Array.isArray(value)) {
|
||||
const members = `<ul class="values">${value.map(item => `<li class="${typeof item === 'string' && markedItems.includes(item) ? 'different' : ''}">${sourceValue(item, [], [])}</li>`).join('')}</ul>`;
|
||||
return markedFields.includes('value') && !markedItems.length ? `<div class="different">${members}</div>` : members;
|
||||
/**
|
||||
* The six Security Domains as in Client Info: catalog order, each with its deviation count and an amount
|
||||
* bar in the current Overall Score tone, whose width is the domain's share of all displayed deviations.
|
||||
*/
|
||||
function domainList(model: TechnicalReportModel): string {
|
||||
const total = model.domains.reduce((sum, domain) => sum + domain.deviations, 0);
|
||||
const tone = scoreTone(model.totals.overallScore);
|
||||
return `<figure class="domains"><figcaption>Security Domains</figcaption><dl>${model.domains.map(domain => `<div class="domain-row">
|
||||
<dt>${esc(domain.label)}</dt>
|
||||
<dd class="domain-bar">${stackedBar([{ value: domain.deviations, tone, label: 'Abweichungen' }], total)}</dd>
|
||||
<dd class="domain-num">${num(domain.deviations)}</dd></div>`).join('')}</dl></figure>`;
|
||||
}
|
||||
|
||||
function overview(model: TechnicalReportModel): string {
|
||||
const t = model.totals;
|
||||
const context = model.context;
|
||||
const critical = context?.populated.filter(cluster => cluster.severity === 'critical') ?? [];
|
||||
const criticalCount = critical.reduce((sum, cluster) => sum + cluster.count, 0);
|
||||
const keyFacts: Array<[string, string]> = [
|
||||
...(context ? [
|
||||
['Problemfelder mit Abweichungen', num(context.populated.length)],
|
||||
['davon Base Severity Critical', num(critical.length)]
|
||||
] as Array<[string, string]> : []),
|
||||
...(model.riskRegister ? [['Akzeptierte Risiken (gültig)', num(model.riskRegister.current)]] as Array<[string, string]> : [])
|
||||
];
|
||||
const lead = t.assessed
|
||||
? `Von <b>${num(t.assessed)}</b> bewerteten Prüfungen weichen <b>${num(t.deviations)}</b> vom Soll ab (${formatShare(t.deviations, t.assessed)}).
|
||||
${critical.length ? `<b>${num(criticalCount)}</b> davon liegen in Problemfeldern mit Base Severity Critical: ${critical.map(cluster => esc(cluster.label)).join(', ')}.` : ''}`
|
||||
: 'Im gewählten Stand liegen keine bewerteten Prüfungen vor.';
|
||||
return section('lagebild', 2, 'Lagebild', lead, `<div class="lage-row">
|
||||
${scoreDonut('Overall Score', t.overallScore, '')}
|
||||
${countDonut('Abweichungen', t.deviations, t.assessed, '--donut-deviation', '')}
|
||||
${domainList(model)}
|
||||
</div>
|
||||
${keyFacts.length ? `<div class="tile"><dl class="facts facts--row">${keyFacts.map(([label, value]) => `<div><dt>${esc(label)}</dt><dd>${value}</dd></div>`).join('')}</dl></div>` : ''}`);
|
||||
}
|
||||
|
||||
const hex = (severity: string) =>
|
||||
`<i class="hex" style="background:var(${SEVERITY_TONES[severity] ?? '--sev-unknown'})" title="${esc(SEVERITY_LABELS[severity] ?? severity)}"></i>`;
|
||||
const OUTCOME_TONES: Record<string, string> = { fail: '--outcome-fail', pass: '--outcome-pass' };
|
||||
|
||||
/**
|
||||
* A finding line: Work severity hexagon and title. A click opens the head of its Finding Reader card:
|
||||
* status, badges, Context problem field, Source Rule ID and the first-read Problem rows.
|
||||
*/
|
||||
function findingLine(finding: TechnicalFinding, cluster: TechnicalCluster): string {
|
||||
const card = finding.card;
|
||||
const status = card?.status;
|
||||
const badges = card?.badges.map(item => item.severity
|
||||
? `<span class="sev-badge" style="background:var(${SEVERITY_TONES[item.severity] ?? '--sev-unknown'})">${esc(item.text)}</span>`
|
||||
: `<span class="meta-badge">${esc(item.text)}</span>`).join('') ?? '';
|
||||
const problem = card?.problem;
|
||||
const outcome = OUTCOME_TONES[status?.outcome ?? ''] ?? '--border-strong';
|
||||
return `<li><details class="finding"><summary>${hex(finding.severity)}<span>${esc(finding.title)}</span></summary>
|
||||
<div class="finding-card">
|
||||
<header class="fc-head">${hex(finding.severity)}<div><h4>${esc(finding.title)}</h4>
|
||||
<div class="fc-badges">${status ? `<span class="fc-status" style="--fc-tone:var(${outcome})">${esc(status.value)}</span>` : ''}${badges}</div>
|
||||
${card?.mitre.length ? `<div class="fc-badges">${card.mitre.map(item => `<span class="meta-badge">${esc(item)}</span>`).join('')}</div>` : ''}
|
||||
<span class="context-badge"><i></i>Context <b>${esc(cluster.label)}</b></span></div></header>
|
||||
<div class="fc-body">
|
||||
${finding.ruleId ? `<div class="fc-rule"><span>Source Rule ID</span><code>${esc(finding.ruleId)}</code></div>` : ''}
|
||||
${problem ? `<section class="fc-problem" style="--fc-tone:var(${outcome})"><span>${esc(problem.heading)}</span><dl>${problem.rows.map(row =>
|
||||
`<div${row.state ? ` data-state="${row.state}"` : ''}><dt>${esc(row.label)}</dt><dd>${esc(row.value)}</dd></div>`).join('')}</dl></section>` : ''}
|
||||
</div>
|
||||
${card?.description ? `<details class="fc-more"><summary>Description</summary><div class="rich-text">${reportRichText(card.description)}</div></details>` : ''}
|
||||
${card?.references.map(reference => `<details class="fc-more fc-ref"><summary>${esc(reference.label)}</summary>
|
||||
<p>${esc(reference.text)}</p>${reference.url ? `<p><a href="${esc(reference.url)}" rel="noopener noreferrer">${esc(reference.url)}</a></p>` : ''}</details>`).join('') ?? ''}
|
||||
</div></details></li>`;
|
||||
}
|
||||
|
||||
/** One problem field as an ASSESSOR list row: severity stripe, disclosure, title, count; the details open below it. */
|
||||
function clusterRow(cluster: TechnicalCluster, max: number, total: number): string {
|
||||
const tone = SEVERITY_TONES[cluster.severity] ?? '--sev-unknown';
|
||||
const head = `<span class="pf-title"><b>${esc(cluster.label)}</b>${cluster.germanName ? `<span class="sub">${esc(cluster.germanName)}</span>` : ''}</span>
|
||||
<span class="pf-sev">${badge(cluster.severity)}</span>
|
||||
<span class="pf-bar">${stackedBar([{ value: cluster.count, tone: '--donut-deviation', label: 'Abweichungen' }], max)}</span>
|
||||
<span class="pf-num">${num(cluster.count)}</span><span class="pf-num">${formatShare(cluster.count, total)}</span>`;
|
||||
if (!cluster.count) return `<div class="pf pf--empty" style="--pf-tone:var(${tone})"><div class="pf-row"><span class="pf-toggle"></span>${head}</div></div>`;
|
||||
return `<details class="pf" id="pf-${esc(cluster.id)}" style="--pf-tone:var(${tone})"><summary class="pf-row"><span class="pf-toggle" aria-hidden="true"></span>${head}</summary>
|
||||
<div class="pf-body">
|
||||
<div class="cluster-body"><p>${esc(cluster.situation)}</p>
|
||||
${cluster.lever ? `<div class="next-step"><span>Technischer Ansatzpunkt</span><p>${esc(cluster.lever)}</p></div>` : ''}</div>
|
||||
<p class="cluster-domains">Security Domains: ${cluster.domains.map(domain => `${esc(domain.label)} <b>${num(domain.count)}</b>`).join(' · ')}</p>
|
||||
<ul class="pf-findings">${cluster.findings.map(finding => findingLine(finding, cluster)).join('')}</ul>
|
||||
</div></details>`;
|
||||
}
|
||||
|
||||
function clusterList(model: TechnicalReportModel): string {
|
||||
const context = model.context!;
|
||||
const total = model.totals.deviations;
|
||||
const max = Math.max(1, ...context.clusters.map(cluster => cluster.count));
|
||||
const remainder = total - context.partitionTotal;
|
||||
const plain = (label: string, count: number, share: string, extra = '') => `<div class="pf pf--plain${extra}"><div class="pf-row"><span class="pf-toggle"></span>
|
||||
<span class="pf-title"><b>${label}</b></span><span class="pf-sev"></span><span class="pf-bar"></span><span class="pf-num">${num(count)}</span><span class="pf-num">${share}</span></div></div>`;
|
||||
return `<div class="pf-list">
|
||||
<div class="pf-row pf-header" aria-hidden="true"><span class="pf-toggle"></span><span class="pf-title">Problemfeld</span><span class="pf-sev">Base Severity</span><span class="pf-bar">Abweichungen</span><span class="pf-num">Anzahl</span><span class="pf-num">Anteil</span></div>
|
||||
${context.clusters.map(cluster => clusterRow(cluster, max, total)).join('')}
|
||||
${remainder > 0 ? plain('Ohne Problemkategorie', remainder, formatShare(remainder, total)) : ''}
|
||||
${plain('Summe', total, '100 %', ' pf--sum')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function problemFields(model: TechnicalReportModel): string {
|
||||
if (!model.context) {
|
||||
return section('problemfelder', 4, 'Problemfelder', 'Für diesen Datenstand liegen keine Problemfelder vor.', '');
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const instruction = `<dl class="instruction">${Object.entries(value).map(([key, item]) => {
|
||||
const marked = markedFields.includes(key) || (markedFields.includes('value') && key === 'value');
|
||||
return `<div><dt>${escapeHtml(key)}</dt><dd class="${marked ? 'different' : ''}">${sourceValue(item, [], markedItems)}</dd></div>`;
|
||||
}).join('')}</dl>`;
|
||||
return markedFields.includes('value') && !Object.hasOwn(value, 'value') && !markedItems.length
|
||||
? `<div class="different">${instruction}</div>` : instruction;
|
||||
const context = model.context;
|
||||
const lead = `<b>${num(model.totals.deviations)}</b> Abweichungen in <b>${num(context.populated.length)}</b> Problemfeldern.`;
|
||||
return section('problemfelder', 4, 'Problemfelder', lead, clusterList(model));
|
||||
}
|
||||
|
||||
function relationsSection(model: TechnicalReportModel): string {
|
||||
const parts: string[] = [];
|
||||
const context = model.context;
|
||||
if (context) {
|
||||
const left = model.domains.filter(domain => domain.deviations > 0).map(domain => ({
|
||||
id: domain.id, label: domain.label, total: domain.deviations,
|
||||
tone: DOMAIN_TONES[model.domains.indexOf(domain)] ?? '--graph-category'
|
||||
}));
|
||||
const right = context.clusters.map(cluster => ({ id: cluster.id, label: cluster.label, total: cluster.count,
|
||||
tone: SEVERITY_TONES[cluster.severity] ?? '--sev-unknown',
|
||||
detail: undefined }));
|
||||
parts.push(`<div class="scroll">${bipartiteDiagram(left, right, context.domainLinks.map(link => ({ from: link.domainId, to: link.clusterId, value: link.count })))}</div>`);
|
||||
}
|
||||
return markedFields.includes('value') ? `<span class="different">${plain(value)}</span>` : plain(value);
|
||||
const domains = model.domains.filter(domain => domain.deviations > 0);
|
||||
const largestDomain = [...domains].sort((left, right) => right.deviations - left.deviations)[0];
|
||||
const largestCluster = context ? [...context.populated].sort((left, right) => right.count - left.count)[0] : undefined;
|
||||
const lead = largestDomain ? `Die <b>${num(model.totals.deviations)}</b> Abweichungen verteilen sich auf <b>${num(domains.length)}</b> Security Domains${context ? ` und <b>${num(context.populated.length)}</b> Problemfelder` : ''}.
|
||||
Den größten Anteil trägt <b>${esc(largestDomain.label)}</b> (${num(largestDomain.deviations)}, ${formatShare(largestDomain.deviations, model.totals.deviations)})${largestCluster
|
||||
? `; das am stärksten belegte Problemfeld ist <b>${esc(largestCluster.label)}</b> (${num(largestCluster.count)})` : ''}.` : '';
|
||||
return section('beziehungen', 3, 'Beziehungen (Pathfinder)', lead, parts.join(''));
|
||||
}
|
||||
|
||||
function sourceSections(sources: PolicySourceEvidence[]): string {
|
||||
if (!sources.length) return '<p class="limit">Keine einzelnen GPO-Quellanweisungen in diesem Snapshot verfügbar.</p>';
|
||||
return sources.map(source => `<section class="source">
|
||||
<h5>GPO Backup ${plain(source.gpoId)} · ${plain(source.nativeBackupId)}</h5>
|
||||
<dl class="facts"><div><dt>Backup-Instanz</dt><dd>${plain(source.backupInstanceId)}</dd></div>
|
||||
<div><dt>Evidence</dt><dd>${plain(source.sourceReference)}</dd></div>
|
||||
<div><dt>Quellreihenfolge</dt><dd>${plain(source.sequence)}</dd></div></dl>
|
||||
${source.values.map((value, index) => `<div class="source-value"><span>Anweisung ${number(index + 1)}</span>
|
||||
${sourceValue(value, source.deviationFields?.[index] ?? [], source.deviationItems?.[index] ?? [])}</div>`).join('')}
|
||||
</section>`).join('');
|
||||
function basisSection(model: TechnicalReportModel, projection: ReportProjection): string {
|
||||
const t = model.totals;
|
||||
const files = projection.provenance.filter(item => item.kind.startsWith('ImportFile: '));
|
||||
const dates = files.map(item => item.sourceDate ?? item.observedAt).filter((value): value is string => Boolean(value && Number.isFinite(Date.parse(value)))).sort();
|
||||
const basis: Array<[string, string]> = [
|
||||
['Erhebungszeitraum', dates.length ? (when(dates[0]) === when(dates.at(-1)) ? when(dates[0]) : `${when(dates[0])} – ${when(dates.at(-1))}`) : DASH],
|
||||
['Importierte Berichte', num(files.length)],
|
||||
['Records gesamt', num(t.records)],
|
||||
['Bewertet', num(t.assessed)],
|
||||
['Nicht bewertet', num(t.records - t.assessed)]
|
||||
];
|
||||
const context = model.context;
|
||||
const versions: Array<[string, string]> = [
|
||||
['ASSESSOR', esc(ASSESSOR_PRODUCT_VERSION)],
|
||||
['Erstellt', when(model.generatedAt)],
|
||||
...(context ? [['Datenstand', `Revision ${esc(String(context.revision))}`]] as Array<[string, string]> : []),
|
||||
['Berichtsmodell / Renderer', `${esc(TECHNICAL_REPORT_MODEL_VERSION)} / ${esc(TECHNICAL_REPORT_RENDERER_VERSION)}`],
|
||||
['Report-Projektion / Work-Ausschnitt', `${esc(projection.version)} / ${esc(projection.workspaceView?.version ?? DASH)}`],
|
||||
...(context ? [
|
||||
['Context Engine', esc(context.engineVersion)],
|
||||
['Context-Katalog / Vokabular / Severity', `${esc(context.catalogVersion)} / ${esc(context.vocabularyVersion)} / ${esc(context.severityCatalogVersion)}`]
|
||||
] as Array<[string, string]> : [])
|
||||
];
|
||||
return section('datenbasis', 5, 'Datenbasis und Datenstand', '', `<div class="block"><h3>Datenbasis der Erhebung</h3>${facts(basis).replace('class="facts"', 'class="facts facts--row"')}
|
||||
<h4>Alle Prüfungen nach Ergebnis</h4>${stackedBar(resultStates(model), t.records)}${legend(resultStates(model).filter(item => item.value > 0).map(item => ({ tone: item.tone, label: item.label, value: item.value })))}</div>
|
||||
<div class="block"><h3>Datenstand und Versionen</h3>${facts(versions).replace('class="facts"', 'class="facts facts--four"')}</div>`);
|
||||
}
|
||||
|
||||
function resultDetail(result: UnifiedResultProjection, domainLabel: string, review?: ReportReviewInput, fields?: RecordField[]): string {
|
||||
const actual = result.policyActualValue;
|
||||
const expected = result.policyBaselineValue;
|
||||
const variants = result.policyBaselineVariantAssessment;
|
||||
return `<article class="finding">
|
||||
<header><h4>${plain(result.title)}</h4><span>${plain(result.sourceTool)} · ${plain(result.severityNormalized)} · ${plain(result.outcomeNormalized)}</span></header>
|
||||
<dl class="facts"><div><dt>Security Domain</dt><dd>${escapeHtml(domainLabel)}</dd></div>
|
||||
<div><dt>Source Rule ID</dt><dd>${plain(result.sourceRuleId)}</dd></div>
|
||||
<div><dt>Technische Identität</dt><dd>${plain(result.policyIdentityKey ?? result.canonicalDefinitionId)}</dd></div>
|
||||
<div><dt>Scope</dt><dd>${plain(result.policyScope ?? result.affectedAsset)}</dd></div>
|
||||
<div><dt>Result Status</dt><dd>${plain(result.policyClassification ?? result.outcomeNormalized)}</dd></div>
|
||||
<div><dt>Baseline</dt><dd>${plain(result.policyBaselineLabel)}</dd></div></dl>
|
||||
${result.description ? `<p>${plain(result.description)}</p>` : ''}
|
||||
${result.recommendation ? `<p><strong>Empfehlung:</strong> ${plain(result.recommendation)}</p>` : ''}
|
||||
${actual !== undefined || expected !== undefined ? table(['Konfiguriert', 'Erwartet', 'Vergleich'], [[plain(actual), plain(expected), plain(result.policyComparisonRelation)]])
|
||||
: '<p class="limit">Diese Quelle liefert hier keinen strukturierten Ist-/Sollwert. Es wird keiner abgeleitet.</p>'}
|
||||
${variants ? `<h5>Baseline-Varianten</h5><pre>${plain(variants)}</pre>` : ''}
|
||||
${result.policyDiagnosticCodes?.length ? `<p><strong>Diagnostics:</strong> ${result.policyDiagnosticCodes.map(plain).join(', ')}</p>` : ''}
|
||||
${result.frameworkReferences.length ? `<h5>Framework-Referenzen (exakte Quellwerte)</h5>${table(['Namespace', 'Referenz'],
|
||||
result.frameworkReferences.map(reference => [plain(reference.namespace), plain(reference.value)]))}` : ''}
|
||||
${fields?.length ? `<h5>Originale technische Felder</h5>${table(['Feld', 'Wert'], fields.map(field => [plain(field.label), plain(field.value)]))}` : ''}
|
||||
${review?.nextStep ? `<p><strong>Geplanter Remediation-Schritt (unverifizierte Selbstauskunft):</strong> ${plain(review.nextStep)}</p>` : ''}
|
||||
<p class="meta">Result-ID ${plain(result.resultProjectionId)} · ScanRun ${plain(result.scanRunId)} · ImportArtifact ${plain(result.importArtifactId)} · Source Locator ${plain(result.sourceLocator)}</p>
|
||||
</article>`;
|
||||
const STYLE = `
|
||||
*{box-sizing:border-box}
|
||||
html{background:var(--surface-panel)}
|
||||
body{margin:0;color:var(--text-primary);background:var(--surface-panel);font-family:var(--font-ui);font-size:var(--type-body-size);line-height:1.55;font-variant-numeric:tabular-nums}
|
||||
@page{size:A4;margin:12mm}
|
||||
.report{box-sizing:border-box;width:210mm;max-width:100%;margin:0 auto;background:var(--surface-panel);padding:12mm;display:grid;gap:var(--space-8)}
|
||||
.report-section{gap:var(--space-3)}
|
||||
.donut-row{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);gap:var(--segment-gap)}
|
||||
.lage-row{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:var(--space-5);align-items:start}
|
||||
/* Ring size ratio Overall Score : Abweichungen = 2.5 : 2 (user decision). */
|
||||
.lage-row>.donut-tile:nth-child(1) .ring{width:175px;height:175px}
|
||||
.lage-row>.donut-tile:nth-child(1) .ring-readout strong{font-size:36px}
|
||||
.lage-row>.donut-tile:nth-child(2) .ring{width:140px;height:140px}
|
||||
.lage-row>.donut-tile:nth-child(2) .ring-readout strong{font-size:30px}
|
||||
.lage-row .donut-tile{flex:none;padding:0}
|
||||
.domains{margin:0;display:grid;gap:var(--space-2);align-content:start}
|
||||
.domains figcaption{font-size:var(--donut-title-size);font-weight:var(--fw-semibold)}
|
||||
.domains dl{display:grid;grid-template-columns:auto minmax(48px,1fr) 32px;column-gap:var(--space-3);margin:0;font-size:var(--type-table-body-size)}
|
||||
.domain-row{display:contents}
|
||||
.domain-row>*{display:flex;align-items:center;padding:5px 0;border-bottom:var(--bw-hairline) solid var(--border-subtle)}
|
||||
.domain-row dd{margin:0}
|
||||
.domain-row dt{white-space:nowrap}
|
||||
.domain-bar .bar{height:8px}
|
||||
.domain-bar{min-width:0}
|
||||
.domain-num{justify-content:flex-end;font-weight:var(--fw-semibold)}
|
||||
.donut-legend{display:grid;gap:2px;margin:0;padding:0;list-style:none;color:var(--text-secondary);font-size:var(--type-table-body-size);line-height:1.3}
|
||||
.donut-legend li{display:flex;align-items:center;gap:var(--space-1);white-space:nowrap}
|
||||
.donut-legend i{display:inline-block;width:10px;height:10px}
|
||||
.donut-legend b{color:var(--text-primary);font-weight:var(--fw-semibold)}
|
||||
.donut-row .donut-tile{flex:none;padding:0}
|
||||
.facts--row{grid-template-columns:repeat(auto-fit,minmax(0,1fr))}
|
||||
.facts--row div{border-bottom:0}
|
||||
.facts--four{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
.report-top{display:flex;align-items:center;gap:var(--space-3)}
|
||||
.report-top img{width:48px;height:48px}
|
||||
.brand-copy{display:grid;gap:var(--space-1)}
|
||||
.wordmark{font-size:var(--type-h4-size);font-weight:var(--fw-bold);letter-spacing:.045em;line-height:1}
|
||||
.version{color:var(--text-muted);font-size:var(--type-meta-size);letter-spacing:var(--ls-caps);line-height:1}
|
||||
.title-block h1{margin:0;font-size:var(--type-h2-size);font-weight:var(--fw-semibold);line-height:1.1}
|
||||
.title-block p{margin:var(--space-2) 0 0;color:var(--text-secondary)}
|
||||
.toc{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);gap:var(--space-4);margin:0;padding:var(--space-3) 0;list-style:none;border-top:var(--bw-hairline) solid var(--border-subtle);border-bottom:var(--bw-hairline) solid var(--border-subtle)}
|
||||
.toc a{display:grid;gap:2px;color:var(--text-primary);font-size:var(--type-table-body-size);font-weight:var(--fw-semibold);line-height:1.3;text-decoration:none}
|
||||
.toc a span{color:var(--marking-color);font-size:var(--type-h4-size)}
|
||||
.toc a:hover{color:var(--text-link)}
|
||||
.report-section{display:grid;gap:var(--space-4)}
|
||||
.section-head{display:flex;align-items:baseline;gap:var(--space-3);padding-bottom:var(--space-2);border-bottom:var(--bw-hairline) solid var(--border-subtle)}
|
||||
.section-number{color:var(--marking-color);font-size:var(--type-h2-size);font-weight:var(--fw-semibold);line-height:1}
|
||||
h2{margin:0;font-size:var(--type-h2-size);font-weight:var(--fw-semibold);line-height:1.15}
|
||||
h3{margin:0 0 var(--space-2);font-size:var(--type-h3-size);font-weight:var(--fw-semibold)}
|
||||
h4{margin:var(--space-4) 0 var(--space-2);font-size:var(--type-h4-size);font-weight:var(--fw-semibold)}
|
||||
.lead{margin:0;max-width:110ch}
|
||||
.note{margin:var(--space-2) 0;color:var(--text-secondary);font-size:var(--type-table-body-size)}
|
||||
p{margin:0 0 var(--space-3)}
|
||||
.tile,.block{min-width:0;overflow-x:auto}
|
||||
.tile-row{display:flex;flex-wrap:wrap;gap:var(--segment-gap);align-items:stretch}
|
||||
.tile--facts{flex:1 1 var(--tile-content-width)}
|
||||
.tile--grow{flex:1 1 var(--tile-content-width)}
|
||||
.card-grid{display:grid;gap:var(--segment-gap)}
|
||||
.card-grid--three{grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--tile-content-width)),1fr))}
|
||||
.donut-tile{margin:0;padding:var(--tile-inset);display:grid;justify-items:center;align-content:start;gap:var(--space-2);flex:0 0 var(--tile-compact-width)}
|
||||
.donut-tile figcaption{font-size:var(--donut-title-size);font-weight:var(--fw-semibold);text-align:center}
|
||||
.donut-tile--compact{padding:0;border:0;background:transparent;flex:none}
|
||||
.donut-tile--compact figcaption:empty{display:none}
|
||||
.ring{position:relative;width:var(--donut-ring-size);height:var(--donut-ring-size)}
|
||||
.donut-tile--compact .ring{width:72px;height:72px}
|
||||
.ring svg{width:100%;height:100%;display:block}
|
||||
.ring-track{fill:none;stroke:var(--donut-track);stroke-width:var(--donut-ring-stroke)}
|
||||
.ring-value{fill:none;stroke-width:var(--donut-ring-stroke);stroke-linecap:butt}
|
||||
.ring-mark{stroke-width:2;stroke-linecap:butt}
|
||||
.ring-readout{position:absolute;inset:0;display:grid;place-content:center;justify-items:center;line-height:1.1;text-align:center}
|
||||
.ring-readout strong{font-size:var(--donut-value-size);font-weight:var(--fw-bold)}
|
||||
.ring-readout span{font-size:var(--donut-percent-size);color:var(--text-secondary)}
|
||||
.donut-tile--compact .ring-readout strong{font-size:var(--type-h4-size)}
|
||||
.donut-tile--compact .ring-readout span{font-size:var(--type-badge-size)}
|
||||
.donut-basis{margin:0;color:var(--text-secondary);font-size:var(--type-table-body-size);text-align:center}
|
||||
.facts{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr));gap:0;margin:0}
|
||||
.hf-strip{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:2px var(--space-8);margin:0;padding-bottom:var(--space-2);border-bottom:var(--bw-hairline) solid var(--border-subtle)}
|
||||
.hf-strip div{display:flex;align-items:baseline;gap:var(--space-2);line-height:1.3}
|
||||
.hf-strip dd{margin:0;font-size:var(--type-h4-size);font-weight:var(--fw-semibold)}
|
||||
.hf-strip dt{color:var(--text-secondary);font-size:var(--type-table-body-size)}
|
||||
.hf-groups{columns:2;column-gap:var(--space-8)}
|
||||
#information{gap:var(--space-3)}
|
||||
.hf-group{break-inside:avoid;margin-bottom:var(--space-2)}
|
||||
.hf-group h3{margin:0 0 2px;line-height:1.3;font-size:var(--type-table-body-size);font-weight:var(--fw-semibold)}
|
||||
.hf-rows{display:grid;margin:0;column-gap:var(--space-8)}
|
||||
.hf-rows div{display:flex;justify-content:space-between;align-items:baseline;gap:var(--space-3);padding:1px 0;border-bottom:var(--bw-hairline) solid var(--border-subtle);min-width:0;font-size:var(--type-table-body-size);line-height:1.2}
|
||||
.hf-rows dt{color:var(--text-secondary)}
|
||||
.hf-rows dd{margin:0;text-align:right;overflow-wrap:anywhere}
|
||||
.facts--single{grid-template-columns:1fr}
|
||||
.facts div{padding:var(--space-2) 0;border-bottom:var(--bw-hairline) solid var(--tile-divider);min-width:0}
|
||||
.facts dt{color:var(--text-secondary);font-size:var(--type-table-body-size)}
|
||||
.facts dd{margin:0;font-size:var(--type-body-size);overflow-wrap:anywhere}
|
||||
table.data{width:100%;border-collapse:collapse;font-size:var(--type-table-body-size)}
|
||||
table.data caption{text-align:left;font-weight:var(--fw-semibold);font-size:var(--type-h4-size);padding:var(--space-3) 0 var(--space-2)}
|
||||
table.data th,table.data td{padding:var(--space-2) var(--space-3) var(--space-2) 0;border-bottom:var(--bw-hairline) solid var(--tile-divider);text-align:left;vertical-align:top}
|
||||
table.data thead th{color:var(--text-secondary);font-size:var(--type-table-head-size);font-weight:var(--fw-semibold);text-transform:uppercase;letter-spacing:var(--ls-caps);white-space:nowrap}
|
||||
table.data tbody th{font-weight:var(--fw-semibold)}
|
||||
table.data tfoot th,table.data tfoot td{border-bottom:0}
|
||||
table.data .num{text-align:right;white-space:nowrap}
|
||||
table.compact th,table.compact td{padding-top:var(--space-1);padding-bottom:var(--space-1)}
|
||||
.bar-cell{width:34%;min-width:160px;vertical-align:middle}
|
||||
.bar{display:flex;width:100%;height:12px;background:var(--donut-track)}
|
||||
.bar-seg{display:block;height:100%}
|
||||
.legend{display:flex;flex-wrap:wrap;gap:var(--space-2) var(--space-4);margin:var(--space-2) 0 0;padding:0;list-style:none;font-size:var(--type-table-body-size);color:var(--text-secondary)}
|
||||
.legend li{display:inline-flex;align-items:center;gap:var(--space-1)}
|
||||
.legend i{display:inline-block;width:10px;height:10px}
|
||||
.sev-badge{display:inline-block;padding:1px 6px;color:var(--sev-on);font-size:var(--type-badge-size);font-weight:var(--fw-semibold);text-transform:uppercase;letter-spacing:var(--ls-caps);white-space:nowrap}
|
||||
.sub{display:block;color:var(--text-secondary);font-size:var(--type-meta-size);font-weight:var(--fw-regular)}
|
||||
.mono{font-family:var(--font-path);font-size:var(--type-table-body-size);overflow-wrap:anywhere}
|
||||
.muted{color:var(--text-muted)}
|
||||
.nowrap{white-space:nowrap;overflow-wrap:normal}
|
||||
a{color:var(--text-link);text-decoration:none}
|
||||
.pf-list{display:grid;border-top:var(--bw-hairline) solid var(--border-subtle)}
|
||||
.pf{border-bottom:var(--bw-hairline) solid var(--border-subtle);border-left:6px solid var(--pf-tone,transparent)}
|
||||
/* An open problem field carries its severity stripe a short way into its top and bottom separators, framing the expanded field. */
|
||||
details.pf[open]{position:relative}
|
||||
details.pf[open]::before,details.pf[open]::after{content:'';position:absolute;left:-6px;width:40px;height:6px;background:var(--pf-tone)}
|
||||
details.pf[open]::before{top:-1px}
|
||||
details.pf[open]::after{bottom:-1px}
|
||||
.pf-row{display:grid;grid-template-columns:22px minmax(0,1fr) 108px 20% 56px 52px;gap:var(--space-3);align-items:center;padding:var(--space-2) var(--space-2) var(--space-2) 0;list-style:none;font-size:var(--type-table-body-size)}
|
||||
summary.pf-row{cursor:pointer}
|
||||
summary.pf-row::-webkit-details-marker{display:none}
|
||||
.pf-header{white-space:nowrap;color:var(--text-secondary);font-size:var(--type-table-head-size);font-weight:var(--fw-semibold);text-transform:uppercase;letter-spacing:var(--ls-caps);border-left:6px solid transparent}
|
||||
.pf-toggle{justify-self:center;width:0;height:0}
|
||||
details.pf .pf-toggle{border-style:solid;border-width:5px 0 5px 8px;border-color:transparent transparent transparent var(--pf-tone)}
|
||||
details.pf[open] .pf-toggle{transform:rotate(90deg)}
|
||||
.pf-title b{font-size:var(--type-h4-size);font-weight:var(--fw-semibold)}
|
||||
.pf-title .sub{font-size:var(--type-table-body-size)}
|
||||
.pf-num{text-align:right;white-space:nowrap}
|
||||
.pf--plain .pf-title b{font-size:var(--type-table-body-size)}
|
||||
.pf--sum{border-bottom:0}
|
||||
.pf--empty .pf-title b{font-weight:var(--fw-regular)}
|
||||
.pf-body{padding:0 var(--space-2) var(--space-3) calc(22px + var(--space-3))}
|
||||
.pf-findings{margin:var(--space-3) 0 0;padding:var(--space-2) 0 0;list-style:none;border-top:var(--bw-hairline) solid var(--border-subtle);display:grid;gap:1px;font-size:var(--type-table-body-size);line-height:1.3}
|
||||
.finding>summary{display:grid;grid-template-columns:14px minmax(0,1fr);gap:var(--space-2);align-items:start;cursor:pointer;list-style:none}
|
||||
.finding>summary::-webkit-details-marker{display:none}
|
||||
.finding>summary:hover span,.finding[open]>summary span{color:var(--text-link)}
|
||||
.finding-card{margin:var(--space-2) 0 var(--space-3) calc(14px + var(--space-2));padding:var(--space-3) 0;border-top:var(--bw-hairline) solid var(--border-subtle);border-bottom:var(--bw-hairline) solid var(--border-subtle);display:grid;gap:var(--space-3)}
|
||||
.fc-head{display:grid;grid-template-columns:26px minmax(0,1fr);gap:var(--space-3);align-items:start}
|
||||
.fc-head .hex{width:26px;height:30px;margin:0}
|
||||
.fc-head h4{margin:0 0 var(--space-2);font-size:var(--type-h4-size);font-weight:var(--fw-semibold);line-height:1.35}
|
||||
.fc-badges{display:flex;flex-wrap:wrap;align-items:center;gap:var(--space-2)}
|
||||
.fc-status{display:inline-flex;align-items:center;gap:4px;color:var(--fc-tone);font-size:var(--type-meta-size)}
|
||||
.fc-status::before{content:'';width:5px;height:5px;border-radius:50%;background:var(--fc-tone)}
|
||||
.meta-badge,.context-badge{display:inline-flex;align-items:center;gap:var(--space-1);padding:2px 6px;border:var(--bw-hairline) solid var(--border-strong);font-size:var(--type-badge-size);font-weight:var(--fw-semibold);text-transform:uppercase;letter-spacing:var(--ls-caps);white-space:nowrap}
|
||||
.context-badge{margin-top:var(--space-2);color:var(--text-secondary)}
|
||||
.context-badge i{width:7px;height:7px;border-radius:50%;background:var(--graph-category)}
|
||||
.context-badge b{color:var(--text-primary);font-size:var(--type-meta-size)}
|
||||
.fc-body{display:grid;grid-template-columns:minmax(120px,max-content) minmax(0,1fr);gap:var(--space-4);align-items:start}
|
||||
.fc-rule span{display:block;font-size:var(--type-table-body-size);font-weight:var(--fw-semibold);text-transform:uppercase;letter-spacing:var(--ls-caps)}
|
||||
.fc-rule code{font-family:var(--font-path);font-size:var(--type-table-body-size)}
|
||||
.fc-problem{grid-column:2;padding:var(--space-2) var(--space-3);border-left:var(--marker-width) solid var(--fc-tone)}
|
||||
.fc-rule+.fc-problem{grid-column:auto}
|
||||
.fc-problem>span{display:block;margin-bottom:var(--space-1);font-size:var(--type-table-body-size);font-weight:var(--fw-semibold);text-transform:uppercase;letter-spacing:var(--ls-caps)}
|
||||
.fc-problem dl{display:grid;gap:2px;margin:0}
|
||||
.fc-problem div{display:grid;grid-template-columns:90px minmax(0,1fr);gap:var(--space-3)}
|
||||
.fc-problem dt{color:var(--text-secondary)}
|
||||
.fc-problem dd{margin:0;white-space:pre-line;overflow-wrap:anywhere}
|
||||
.fc-problem div[data-state="deviating"] dd{color:var(--outcome-fail)}
|
||||
.fc-problem div[data-state="aligned"] dd{color:var(--outcome-pass)}
|
||||
.fc-head .fc-badges+.fc-badges{margin-top:var(--space-2)}
|
||||
.fc-more{border:var(--bw-hairline) solid var(--border-subtle)}
|
||||
.fc-more>summary{display:flex;align-items:center;gap:var(--space-2);padding:var(--space-2) var(--space-3);cursor:pointer;list-style:none;font-size:var(--type-table-body-size);font-weight:var(--fw-semibold)}
|
||||
.fc-more>summary::-webkit-details-marker{display:none}
|
||||
.fc-more>summary::before{content:'';border-style:solid;border-width:4px 0 4px 7px;border-color:transparent transparent transparent currentColor}
|
||||
.fc-more[open]>summary::before{transform:rotate(90deg)}
|
||||
.fc-more:not(.fc-ref)>summary{text-transform:uppercase;letter-spacing:var(--ls-caps)}
|
||||
.fc-more p{margin:0;padding:0 var(--space-3) var(--space-3);font-size:var(--type-table-body-size);white-space:pre-line;overflow-wrap:anywhere}
|
||||
.rich-text{display:grid;gap:var(--space-2);padding:0 var(--space-3) var(--space-3);font-size:var(--type-table-body-size);line-height:1.5;overflow-wrap:anywhere}
|
||||
.rich-text p{margin:0;padding:0;white-space:normal}
|
||||
.rich-text h5{margin:var(--space-2) 0 0;font-size:var(--type-table-body-size);font-weight:var(--fw-semibold)}
|
||||
.rich-text ol,.rich-text ul{margin:0;padding-left:var(--space-5);display:grid;gap:2px}
|
||||
.rich-text b{font-weight:var(--fw-semibold)}
|
||||
.fc-more+.fc-more{margin-top:calc(-1 * var(--space-2))}
|
||||
.hex{display:block;width:14px;height:16px;margin-top:1px;clip-path:polygon(50% 0,94% 24%,94% 76%,50% 100%,6% 76%,6% 24%)}
|
||||
.cluster-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:var(--space-1) var(--space-2);margin-bottom:var(--space-2)}
|
||||
.cluster-head h3{margin:0;font-size:var(--type-h4-size)}
|
||||
.cluster-head .sub{display:inline;font-size:var(--type-table-body-size)}
|
||||
.cluster-count{margin-left:auto;color:var(--text-secondary);font-size:var(--type-table-body-size);white-space:nowrap}
|
||||
.cluster-count strong{color:var(--text-primary);font-size:var(--type-h4-size)}
|
||||
.cluster-body{display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4)}
|
||||
.cluster-body{font-size:var(--type-table-body-size);line-height:1.5}
|
||||
.cluster-body p{margin:0}
|
||||
.cluster-domains{margin:var(--space-2) 0 0;color:var(--text-secondary);font-size:var(--type-table-body-size)}
|
||||
.next-step{margin:0;padding:0 0 0 var(--space-3);border-left:var(--marker-width) solid var(--next-step-accent)}
|
||||
.next-step span{display:block;font-size:var(--type-table-body-size);font-weight:var(--fw-semibold);color:var(--text-secondary)}
|
||||
.next-step p{margin:0}
|
||||
.split{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,460px),1fr));gap:var(--space-6)}
|
||||
.scroll{overflow-x:auto}
|
||||
.flow{display:block;width:100%;height:auto}
|
||||
.flow-link{fill:none;opacity:.55}
|
||||
.flow-label{fill:var(--text-primary);font-size:13px;font-family:var(--font-ui)}
|
||||
.flow-count{fill:var(--text-secondary);font-weight:var(--fw-semibold)}
|
||||
.flow-detail{fill:var(--text-muted)}
|
||||
ul.plain{margin:0;padding-left:var(--space-5)}
|
||||
ul.plain li{margin-bottom:var(--space-2)}
|
||||
.report-foot{color:var(--text-muted);font-size:var(--type-meta-size);border-top:var(--bw-hairline) solid var(--border-subtle);padding-top:var(--space-3)}
|
||||
@media (max-width:640px){.report{padding:var(--space-4)}.cluster-body{grid-template-columns:1fr}.donut-row{grid-auto-flow:row;grid-template-columns:repeat(2,minmax(0,1fr))}.lage-row{grid-template-columns:repeat(2,minmax(0,1fr))}.domains{grid-column:1/-1}.hf-groups{columns:1}.hf-strip{grid-template-columns:repeat(3,minmax(0,1fr))}.pf-row{grid-template-columns:22px minmax(0,1fr) 80px 44px 44px}.pf-bar{display:none}}
|
||||
@media print{.report{width:auto;margin:0;padding:0;zoom:1}.toc{display:none}.donut-tile,.pf-row,.cluster-body,.pf-findings li,.hf-group,tr{break-inside:avoid}details.pf::details-content{content-visibility:visible;display:block}details.pf .pf-toggle{transform:rotate(90deg)}.report-section>.section-head{break-after:avoid}}
|
||||
`;
|
||||
|
||||
/** Client Info overview facts as the first section: Data facts, Domain Information and the environment tiles. */
|
||||
function informationSection(projection: ReportProjection): string {
|
||||
const header = projection.headerFacts;
|
||||
if (!header) return '';
|
||||
const row = (item: { label: string; value: string }) => `<div><dt>${esc(item.label)}</dt><dd>${esc(item.value)}</dd></div>`;
|
||||
return section('information', 1, 'Information', '', `<dl class="hf-strip">${header.data.map(item =>
|
||||
`<div><dd>${esc(item.value)}</dd><dt>${esc(item.label)}</dt></div>`).join('')}</dl>
|
||||
<div class="hf-groups"><div class="hf-group"><h3>Domain Information</h3><dl class="hf-rows">${header.domainInformation.map(row).join('')}</dl></div>${header.groups.map(group => `<div class="hf-group"><h3>${esc(group.title)}</h3><dl class="hf-rows">${group.facts.map(row).join('')}</dl></div>`).join('')}</div>`);
|
||||
}
|
||||
|
||||
/** The complete standalone Technical Report. Rendering depends on the frozen projection only. */
|
||||
export function buildTechnicalReportHtml(projection: ReportProjection): string {
|
||||
const results = projection.technicalResults ?? [];
|
||||
const labels = new Map(projection.domains.map(domain => [domain.domainId, domain.domainLabel]));
|
||||
const domainOf = (result: UnifiedResultProjection) => projection.technicalResultDomains?.[result.resultProjectionId] ?? 'unclassified';
|
||||
const deviations = results.filter(result => result.isDeviation);
|
||||
const severityOrder = ['critical', 'high', 'medium', 'low', 'info', 'unknown'];
|
||||
const grouped = [...new Set(deviations.map(domainOf))].map(domainId => ({
|
||||
domainId, label: labels.get(domainId) ?? domainId,
|
||||
items: deviations.filter(result => domainOf(result) === domainId)
|
||||
.sort((a, b) => severityOrder.indexOf(a.severityNormalized) - severityOrder.indexOf(b.severityNormalized)
|
||||
|| a.title.localeCompare(b.title))
|
||||
}));
|
||||
const policySources = new Map((projection.technicalPolicySources ?? []).map(item => [item.settingId, item.sources]));
|
||||
const reviews = new Map((projection.technicalReviews ?? []).map(item => [item.resultProjectionId, item]));
|
||||
const fields = new Map((projection.technicalFindingFields ?? []).map(item => [item.resultProjectionId, item.fields]));
|
||||
const neutral = new Set(['Not configured', 'No baseline rule', 'Not comparable']);
|
||||
const policyRows = projection.policyEntries.map(item => [
|
||||
plain(item.title), plain(item.scope), plain(item.identity),
|
||||
`${plain(item.verdict)}${neutral.has(item.verdict) ? ' · score-neutral' : ''}`,
|
||||
item.conflict ? 'Ja' : 'Nein', item.duplicate ? 'Ja' : 'Nein'
|
||||
]);
|
||||
const provenanceRows = projection.provenance.map(item => [plain(item.kind), plain(item.id), plain(item.version), plain(item.observedAt)]);
|
||||
const diagnosticRows = projection.diagnostics.map(item => [plain(item.code), plain(item.reason), plain(item.origin), number(item.count)]);
|
||||
// Evidence chain of the Management Report's key statements: every number traced to its result IDs.
|
||||
const statementEvidence = (projection.statements ?? []).map(statement => {
|
||||
const { text, note } = statementText(statement);
|
||||
return `<section class="source"><h3>${escapeHtml(STATEMENT_TITLES[statement.id])}</h3><p>${escapeHtml(text)}</p>${note ? `<p class="limit">${escapeHtml(note)}</p>` : ''}
|
||||
${table(['Zählgruppe', 'Security Domain', 'Anzahl', 'Result-IDs'], statement.evidence.map(item => [
|
||||
plain(EVIDENCE_ROLE_LABELS[item.role]), plain(item.domainId ? labels.get(item.domainId) ?? item.domainId : 'Alle'),
|
||||
number(item.ids.length), item.ids.length ? plain(item.ids.join(', ')) : '—'
|
||||
]))}</section>`;
|
||||
}).join('');
|
||||
const model = buildTechnicalReportModel(projection);
|
||||
const sections = [
|
||||
informationSection(projection), overview(model), relationsSection(model), problemFields(model), basisSection(model, projection)
|
||||
].filter(Boolean);
|
||||
const toc = [...sections.join('').matchAll(/<section class="report-section" id="([^"]+)"><header class="section-head"><span class="section-number">(\d+)<\/span><h2>([^<]+)<\/h2>/g)]
|
||||
.map(match => `<li><a href="#${match[1]}"><span>${match[2]}</span>${match[3]}</a></li>`).join('');
|
||||
return `<!doctype html>
|
||||
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>ASSESSOR · Technical Report · ${escapeHtml(projection.project.name)}</title>
|
||||
<html lang="de" data-theme="light"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Technical Report · ${esc(model.client)}</title>
|
||||
<style>
|
||||
:root{font-family:Inter,system-ui,sans-serif;color:#20212a;background:#f1f1f4;font-size:16px}
|
||||
*{box-sizing:border-box}body{max-width:1240px;margin:0 auto;padding:20px}h1{font-size:32px}h2{font-size:24px;margin:0 0 16px}
|
||||
h3{font-size:18px}h4{font-size:16px;margin:0}h5{font-size:14px;margin:0 0 8px}p{line-height:1.5}
|
||||
.head,.block,.finding,.source{background:#f5f5f7;padding:20px;margin:0 0 16px}.head{border-top:4px solid #e2a024}
|
||||
.block{margin-top:24px}.finding,.source{background:#fff;border-left:2px solid #d6d6dc;break-inside:avoid}
|
||||
.finding header{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;border-bottom:1px solid #d6d6dc;padding-bottom:12px}
|
||||
.finding header span,.meta,.limit{color:#555967;font-size:14px}.facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 24px}
|
||||
.facts div{min-width:0}.facts dt{font-size:14px;color:#555967}.facts dd{margin:2px 0 0;overflow-wrap:anywhere}
|
||||
.table-scroll{overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:14px}th,td{text-align:left;vertical-align:top;border-bottom:1px solid #d6d6dc;padding:8px;overflow-wrap:anywhere}
|
||||
th{text-transform:uppercase;font-weight:600;font-size:14px}pre{font:14px/1.5 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
.instruction{margin:8px 0}.instruction div{display:grid;grid-template-columns:140px 1fr;gap:8px;padding:4px 0}.instruction dt{font-weight:600}.instruction dd{margin:0;overflow-wrap:anywhere}
|
||||
.different{background:#fce9e9;border-left:3px solid #bd3f45;padding:2px 6px}.values{margin:0;padding-left:20px}.source-value{border-top:1px solid #d6d6dc;padding-top:8px}
|
||||
@media(max-width:700px){.facts{grid-template-columns:1fr}.instruction div{grid-template-columns:1fr}}
|
||||
@media print{:root{background:#fff}body{max-width:none;padding:0}.block,.head{background:#fff}.finding,.source{background:#fff}h2,h3,h4{break-after:avoid}}
|
||||
</style></head><body>
|
||||
<header class="head"><p>ASSESSOR · Technical Report</p><h1>${escapeHtml(projection.project.name)}</h1>
|
||||
<p>Assessment ${plain(projection.assessmentId)} · Stand ${when(projection.generatedAt)}</p>
|
||||
<p class="limit">Frozen Snapshot des gewählten Timeslots. GPO-Aussagen beruhen auf Backupvergleichen; der effektive Endpunktzustand ist nicht verifiziert.</p></header>
|
||||
<section class="block"><h2>Scope und Provenienz</h2>
|
||||
${provenanceRows.length ? table(['Art', 'ID', 'Version', 'Beobachtet'], provenanceRows) : '<p class="limit">Keine Provenienz übermittelt.</p>'}</section>
|
||||
<section class="block"><h2>Result-Inventar</h2><p>${number(projection.coverage.total)} Ergebnisse · ${number(projection.coverage.deviations)} Deviations · ${number(projection.coverage.passed)} Passed · ${number(projection.coverage.notRun)} Not run</p>
|
||||
${table(['Severity', 'Anzahl'], Object.entries(projection.inventory.bySeverity).map(([label, count]) => [plain(label), number(count)]))}
|
||||
${table(['Outcome', 'Anzahl'], Object.entries(projection.inventory.byOutcome).map(([label, count]) => [plain(label), number(count)]))}
|
||||
${table(['Security Domain', 'Anzahl'], Object.entries(projection.inventory.byDomain).map(([id, count]) => [plain(labels.get(id) ?? id), number(count)]))}
|
||||
${table(['Source Tool', 'Anzahl'], Object.entries(projection.inventory.bySource).map(([tool, count]) => [plain(tool), number(count)]))}</section>
|
||||
<section class="block"><h2>Belege der Kernaussagen</h2><p class="limit">Jede Kernaussage des Management Reports mit den Result-IDs hinter ihren Zahlen. Die IDs stehen im Anhang im vollständigen Result-Index.</p>
|
||||
${statementEvidence || '<p class="limit">Keine Kernaussagen in diesem Snapshot.</p>'}</section>
|
||||
${projection.context ? `<section class="block"><h2>Context-Belege des Management Reports</h2><p>Katalog ${escapeHtml(projection.context.catalogVersion)} · Kategorie-Grundschwere ${escapeHtml(projection.context.severityCatalogVersion)}. Mehrfachzuordnungen werden nicht addiert; erkannte und geschätzte Zuordnungen bleiben abgeleiteter Kontext.</p>${table(['Problemkategorie', 'Base Severity', 'Scanner-Findings', 'GPO-Abweichungen', 'Katalogisiert / erkannt / geschätzt', 'Result-IDs'], projection.context.clusters.map(item => [plain(item.label), plain(item.severity), number(item.scanner), number(item.gpo), plain(`${item.confidence.catalogued} / ${item.confidence.recognized} / ${item.confidence.estimated}`), plain(item.resultIds.join(', '))]))}</section>` : ''}
|
||||
<section class="block"><h2>Findings im Detail</h2>${grouped.length ? grouped.map(group => `<section><h3>${escapeHtml(group.label)}</h3>${group.items.map(item => resultDetail(item, group.label, reviews.get(item.resultProjectionId), fields.get(item.resultProjectionId))).join('')}</section>`).join('') : '<p>Keine Deviations in diesem Snapshot.</p>'}</section>
|
||||
<section class="block"><h2>GPO Policy Register</h2><p class="limit">Conflicts und Duplicates sind separate Diagnostics, keine Ersatz-Verdicts. Not configured, No baseline rule und Not comparable sind score-neutral.</p>
|
||||
${projection.gpo ? table(['Verdict / Diagnostic', 'Anzahl'], [
|
||||
['Deviation', projection.gpo.counts.Fail], ['Passed', projection.gpo.counts.Pass],
|
||||
['Not configured · score-neutral', projection.gpo.counts['Not configured']],
|
||||
['No baseline rule · score-neutral', projection.gpo.counts['No baseline rule']],
|
||||
['Not comparable · score-neutral', projection.gpo.counts['Not comparable']],
|
||||
['Review required · unklar', projection.gpo.counts['Review required']],
|
||||
['Conflicts', projection.gpo.conflicts], ['Duplicates', projection.gpo.duplicates]
|
||||
].map(([label, count]) => [plain(label), number(Number(count))])) : ''}
|
||||
${policyRows.length ? table(['Setting', 'Scope', 'Technische Identität', 'Verdict', 'Conflicts', 'Duplicates'], policyRows) : '<p class="limit">Keine gerollten GPO Policy Settings im gewählten Assessment vorhanden.</p>'}
|
||||
${projection.policyEntries.map(item => `<section class="source"><h3>${plain(item.title)}</h3>${sourceSections(policySources.get(item.id) ?? [])}</section>`).join('')}</section>
|
||||
<section class="block"><h2>Konvergenz-Nachweis</h2>
|
||||
${table(['Security Domain', 'Quellen mit Deviations', 'Quellen', 'Evidence'], projection.convergence.map(item => [
|
||||
plain(labels.get(item.domainId) ?? item.domainId), number(item.sourceCount),
|
||||
plain(item.sources.filter(source => source.deviations > 0).map(source => source.toolId).join(', ')),
|
||||
item.evidence === 'shared-framework-reference' ? 'Gemeinsame exakte Framework-Referenz' : 'Nur Domain-Nähe; Validierung erforderlich'
|
||||
]))}
|
||||
${projection.bundles.length ? table(['Framework-Referenz', 'Source Tools', 'Result-IDs'], projection.bundles.map(item => [
|
||||
plain(item.key), plain(item.toolIds.join(', ')), plain(item.members.join(', '))
|
||||
])) : ''}</section>
|
||||
<section class="block"><h2>Scope-Grenzen</h2>
|
||||
<p>GPO-Vergleiche gelten nur für das importierte Backup-Set. Ohne RSoP bzw. Group Policy Results wird kein wirksamer Endpoint-Zustand behauptet.</p>
|
||||
<p><code>not_run</code> ist kein Pass. Nicht ausgeführte Checks und unbekannte Werte werden weder als bestanden noch als Deviation gezählt.</p></section>
|
||||
<section class="block"><h2>Diagnostics</h2>${diagnosticRows.length ? table(['Code', 'Resolver-Grund', 'Herkunft', 'Anzahl'], diagnosticRows) : '<p>Keine aggregierten Diagnostics übermittelt.</p>'}</section>
|
||||
<section class="block"><h2>Anhang: vollständiger Result-Index</h2>
|
||||
${table(['Result-ID', 'Source Tool', 'Source Rule ID', 'Titel', 'Outcome', 'Severity', 'Security Domain'], results.map(item => [plain(item.resultProjectionId), plain(item.sourceTool), plain(item.sourceRuleId), plain(item.title), plain(item.outcomeNormalized), plain(item.severityNormalized), plain(labels.get(domainOf(item)) ?? domainOf(item))]))}</section>
|
||||
</body></html>`;
|
||||
@font-face{font-family:"Inter Variable";src:url('${REPORT_INTER_FONT}') format('woff2');font-weight:100 900;font-style:normal;font-display:swap}
|
||||
${uiTokens}
|
||||
${legacyTokens}
|
||||
${STYLE}
|
||||
</style></head><body><main class="report">
|
||||
<header class="report-top"><img src="${REPORT_ASSESSOR_LOGO}" alt="ASSESSOR Logo"><div class="brand-copy"><span class="wordmark">ASSESSOR</span><span class="version">${esc(ASSESSOR_PRODUCT_VERSION)}</span></div></header>
|
||||
<div class="title-block"><h1>Technical Report</h1><p>${esc(model.client)} · Stand ${when(model.generatedAt)} · ${num(model.totals.assessed)} bewertete Prüfungen</p></div>
|
||||
<nav aria-label="Inhalt"><ol class="toc">${toc}</ol></nav>
|
||||
${sections.join('\n')}
|
||||
<footer class="report-foot">ASSESSOR ${esc(ASSESSOR_PRODUCT_VERSION)} · Vertraulich</footer>
|
||||
</main></body></html>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,14 +29,14 @@ button, .btn, .icon-btn, .tab-btn, .bookmark-btn, .results-btn {
|
|||
line-height: 1.15;
|
||||
}
|
||||
.select, .input {
|
||||
min-height: var(--control-height); border: var(--border-hair) solid var(--line); border-radius: 0;
|
||||
min-height: var(--control-height); border: var(--border-hair) solid var(--line); border-radius: var(--radius-sm);
|
||||
color: var(--text); background-color: var(--panel-raised); font-size: var(--type-button-size); font-weight: var(--fw-regular);
|
||||
}
|
||||
/* Verbindliche Buttonform: eine Hoehe, ein Radius, ein waagerechtes Polster.
|
||||
Senkrecht traegt der Knopf kein eigenes Polster - die Hoehe kommt aus
|
||||
--btn-h, damit ueberall gleich wenig Luft ueber und unter der Schrift steht. */
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; min-height: var(--btn-h); padding: 0 var(--btn-pad-x); }
|
||||
.select, .input { padding: var(--legacy-space-1) var(--legacy-space-3); }
|
||||
.select, .input { padding: var(--legacy-space-1) var(--legacy-space-3); border-radius: var(--radius-sm); }
|
||||
.icon-btn, .bookmark-btn, .results-btn { display: inline-grid; place-items: center; min-width: var(--btn-h); min-height: var(--btn-h); padding: 0 6px; }
|
||||
.btn:hover, .icon-btn:hover, .tab-btn:hover, .bookmark-btn:hover, .results-btn:hover { border-color: var(--button-border); background: var(--button-bg-hover); color: var(--button-text); }
|
||||
.select:hover, .input:hover { border-color: var(--line-strong); background-color: var(--panel-hover); }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
.client-security-panel { --domain-tone: var(--graph-category); --flow-width: 1.5; min-width: 0; }
|
||||
.client-security-header { display: flex; align-items: baseline; gap: var(--space-3); margin-bottom: var(--space-3); }
|
||||
.client-security-header h2 { margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
|
||||
.client-security-header > span { color: var(--text-secondary); font-size: var(--type-table-body-size); }
|
||||
.client-security-map { margin-bottom: var(--space-4); border-bottom: var(--border-hair) solid var(--tile-border); }
|
||||
.client-security-map > summary { display: flex; align-items: center; gap: var(--space-2); min-height: var(--control-h); list-style: none; cursor: pointer; font-size: var(--type-table-body-size); }
|
||||
.client-security-map > summary::-webkit-details-marker { display: none; }
|
||||
.client-security-map[open] > summary .triangle-icon { transform: rotate(-90deg); }
|
||||
.client-security-map-scroll { overflow-x: auto; }
|
||||
.client-security-map-scroll > svg { display: block; width: 1040px; height: auto; max-width: 100%; min-width: 900px; margin-block: var(--space-3); overflow: visible; }
|
||||
.client-security-map text { fill: var(--text-primary); font-family: inherit; font-size: var(--type-table-body-size); }
|
||||
.client-security-map tspan { fill: var(--text-secondary); }
|
||||
.client-security-flow { fill: none; stroke: var(--domain-tone); stroke-width: var(--flow-width); opacity: .5; }
|
||||
.client-security-domain-marker { fill: var(--domain-tone); }
|
||||
.client-security-table { border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); }
|
||||
.client-security-columns,
|
||||
.client-security-group-toggle { display: grid; grid-template-columns: var(--space-4) minmax(280px, 1fr) 130px minmax(80px, 160px) 70px 70px; align-items: center; gap: var(--space-3); }
|
||||
.client-security-columns { padding: var(--space-3) var(--tile-inset); color: var(--text-secondary); font-size: var(--type-label-size); }
|
||||
.client-security-columns > :nth-last-child(-n + 2) { text-align: right; }
|
||||
.client-security-group + .client-security-group { border-top: var(--border-hair) solid var(--border-subtle); }
|
||||
.client-security-group-toggle { width: 100%; min-height: var(--control-h); height: auto; padding: var(--space-3) var(--tile-inset); border: 0; border-radius: 0; color: var(--text-primary); background: transparent; text-align: left; font-size: var(--type-table-body-size); font-weight: var(--fw-regular); cursor: pointer; }
|
||||
.client-security-group-toggle:hover,
|
||||
.client-security-finding-toggle:hover { background: var(--surface-hover); }
|
||||
.client-security-group-toggle > strong { text-align: right; font-size: inherit; font-weight: var(--fw-semibold); }
|
||||
.client-security-group-toggle > span:last-child { text-align: right; }
|
||||
.client-security-category { display: flex; align-items: center; gap: var(--space-3); min-width: 0; font-weight: var(--fw-semibold); overflow-wrap: anywhere; }
|
||||
.client-security-category small { font-size: inherit; font-weight: var(--fw-regular); color: var(--text-secondary); }
|
||||
.client-security-marker { --security-tone: var(--text-secondary); display: inline-block; width: var(--space-2); height: var(--space-4); flex: 0 0 var(--space-2); background: var(--security-tone); fill: var(--security-tone); }
|
||||
.client-security-marker[data-severity="critical"], .client-security-severity[data-severity="critical"] { --security-tone: var(--sev-critical); }
|
||||
.client-security-marker[data-severity="high"], .client-security-severity[data-severity="high"] { --security-tone: var(--sev-high); }
|
||||
.client-security-marker[data-severity="medium"], .client-security-severity[data-severity="medium"] { --security-tone: var(--sev-medium); }
|
||||
.client-security-marker[data-severity="low"], .client-security-severity[data-severity="low"] { --security-tone: var(--sev-low); }
|
||||
.client-security-marker[data-severity="info"], .client-security-severity[data-severity="info"] { --security-tone: var(--sev-info); }
|
||||
.client-security-severity { justify-self: start; padding: var(--space-1) var(--space-2); color: var(--sev-ink); background: var(--security-tone, var(--surface-panel)); font-size: var(--type-label-size); }
|
||||
.client-security-severity[data-severity="unknown"], .client-security-severity[data-severity="undetermined"] { color: var(--text-secondary); }
|
||||
.client-security-share-bar { display: block; height: var(--space-3); background: var(--donut-track); }
|
||||
.client-security-share-bar > i { display: block; width: var(--share); height: 100%; background: var(--donut-deviation); }
|
||||
.client-security-group-content { padding: 0 var(--tile-inset) var(--space-3) calc(var(--tile-inset) + var(--space-4) + var(--space-3)); }
|
||||
.client-security-group-content[hidden] { display: none; }
|
||||
.client-security-group-content > p { margin: var(--space-2) 0; }
|
||||
.client-security-category-context,
|
||||
.client-security-finding-detail { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-5); }
|
||||
.client-security-category-context h3,
|
||||
.client-security-finding-detail h4 { margin: var(--space-2) 0; font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); }
|
||||
.client-security-category-context p { margin: 0; font-size: var(--type-body-size); line-height: var(--lh-prose); }
|
||||
.client-security-domain-counts { color: var(--text-secondary); font-size: var(--type-table-body-size); }
|
||||
.client-security-group-content > ul { padding: 0; margin: var(--space-3) 0 0; list-style: none; }
|
||||
.client-security-finding { border-top: var(--border-hair) solid var(--border-subtle); }
|
||||
.client-security-finding-toggle { display: flex; align-items: center; gap: var(--space-2); width: 100%; min-height: var(--control-h); height: auto; padding: var(--space-2); border: 0; border-radius: 0; color: var(--text-primary); background: transparent; text-align: left; font-size: var(--type-table-body-size); font-weight: var(--fw-regular); cursor: pointer; }
|
||||
.client-security-finding-toggle > strong { min-width: 0; overflow-wrap: anywhere; font-size: inherit; font-weight: var(--fw-bold); }
|
||||
.client-security-finding-detail { padding: var(--space-3) var(--space-5); font-size: var(--type-body-size); }
|
||||
.client-security-finding-detail > div { min-width: 0; }
|
||||
.client-security-source { margin-bottom: var(--space-3); }
|
||||
.client-security-source > span { display: block; margin-bottom: var(--space-1); color: var(--text-secondary); font-size: var(--type-table-body-size); }
|
||||
.client-security-source pre { max-width: 100%; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.client-security-source > details > summary { cursor: pointer; }
|
||||
.client-security-group-toggle:focus-visible,
|
||||
.client-security-finding-toggle:focus-visible,
|
||||
.client-security-map > summary:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(-1 * var(--bw-emphasis)); }
|
||||
@container (max-width: 1000px) {
|
||||
.client-security-columns,
|
||||
.client-security-group-toggle { grid-template-columns: var(--space-4) minmax(0, 1fr) 110px 65px 55px; gap: var(--space-2); }
|
||||
.client-security-columns > :nth-child(4),
|
||||
.client-security-group-toggle > :nth-child(4) { display: none; }
|
||||
}
|
||||
@container (max-width: 760px) {
|
||||
.client-security-category-context,
|
||||
.client-security-finding-detail { grid-template-columns: 1fr; }
|
||||
.client-security-columns,
|
||||
.client-security-group-toggle { grid-template-columns: var(--space-4) minmax(0, 1fr) 90px 50px; }
|
||||
.client-security-columns > :last-child,
|
||||
.client-security-group-toggle > :last-child { display: none; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue