Compare commits

...

2 Commits

Author SHA1 Message Date
Michael b2f46a0040 Anpassung Mitre Catalog Update
Deploy / Test, build & deploy (push) Successful in 3m59s Details
2026-09-28 10:30:26 +02:00
Michael ad4e3831e0 neuer Goldstandart :) 2026-09-28 09:55:07 +02:00
186 changed files with 8871 additions and 1400 deletions

View File

@ -47,7 +47,7 @@
- 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. - 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. - 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.
- Keep the current neutral theme surfaces, transparent segments, flat tiles, subtle separators, local Inter typography, shared 20px panel inset and shared control geometry. Reuse existing tokens and components. Do not reinterpret Ableton as a new palette, a literal copy of another application, or a return to obsolete UI dimensions. - Keep the current neutral theme surfaces, transparent segments, flat tiles, subtle separators, local Inter typography, shared 20px panel inset and shared control geometry. Reuse existing tokens and components. Do not reinterpret Ableton as a new palette, a literal copy of another application, or a return to obsolete UI dimensions.
- Use the shared yellow/amber roles for primary actions and persistent selection, blue for keyboard focus, and semantic status colors for the established status and data-visualization roles. A tool or graph-category color does not authorize a new selection or button color. No gradients, decorative shadows, glass effects, hover lift, generic rounded dashboard cards, new navigation shell, new logo, or Client-Info-only visual theme. - Use the shared yellow/amber roles for primary actions and persistent selection, with the explicitly blue table-row reading marker documented below; blue remains the keyboard-focus role elsewhere. Use semantic status colors for the established status and data-visualization roles. A tool or graph-category color does not authorize a new selection or button color. No gradients, decorative shadows, glass effects, hover lift, generic rounded dashboard cards, new navigation shell, new logo, or Client-Info-only visual theme. The 2026-09-25 user rollback removes the short-lived donut-ring shadow exception; arcs remain solid colors without gradients.
- Improve quick comprehension through grouping, alignment, readable labels, a small set of relevant overview values and existing detail/disclosure patterns. Never shrink body text or structured data below their agreed roles to fit more content, stretch sparse tiles across unused space, or invent metrics to populate a layout. - Improve quick comprehension through grouping, alignment, readable labels, a small set of relevant overview values and existing detail/disclosure patterns. Never shrink body text or structured data below their agreed roles to fit more content, stretch sparse tiles across unused space, or invent metrics to populate a layout.
- All mockups must use the approved views as actual visual references, preserve the existing shell and metric meanings, and clearly identify synthetic example data. A rejected generated image is not a design reference. The three generic Client Info mockups rejected on 2026-09-18 are explicitly not approved. - All mockups must use the approved views as actual visual references, preserve the existing shell and metric meanings, and clearly identify synthetic example data. A rejected generated image is not a design reference. The three generic Client Info mockups rejected on 2026-09-18 are explicitly not approved.
- Visual acceptance: a Client Info proposal or implementation must be recognizable as the same application as Data Hub, Work and Pathfinder even without its logo. Check the shared surfaces, typography, buttons, selection, spacing and donut geometry at normal application zoom; inspect both themes for implementation. Distinguish a documented rule, a proposal and an implemented/verified UI change. - Visual acceptance: a Client Info proposal or implementation must be recognizable as the same application as Data Hub, Work and Pathfinder even without its logo. Check the shared surfaces, typography, buttons, selection, spacing and donut geometry at normal application zoom; inspect both themes for implementation. Distinguish a documented rule, a proposal and an implemented/verified UI change.
@ -69,11 +69,32 @@
- Apply the inset exactly once on the view's root content container. Split panes and edge-to-edge structures belong inside that inset and must not add a second outer inset. - Apply the inset exactly once on the view's root content container. Split panes and edge-to-edge structures belong inside that inset and must not add a second outer inset.
- Keep the 20px outer inset at responsive breakpoints unless the user explicitly changes this standard. - Keep the 20px outer inset at responsive breakpoints unless the user explicitly changes this standard.
## UI Finding Reader first read
- 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.
- Maester's native test result (ResultDetail) stays in Results only (2026-09-25 decision); its first read shows the source-marked row counts and the https documentation link. GPO findings use the GPO problem summary rules above.
- Explicit user correction 2026-09-25: when a scanner's first-read remediation explicitly introduces a PowerShell command, render that command in its own readable, contained block beneath the explanatory sentence. Keep the original command text complete and copyable, do not rewrite its parameters or split the remaining remediation before the original paragraph boundary, and retain the full source field unchanged.
## UI Pathfinder graph text and details
- Explicit user correction 2026-09-25 supersedes the earlier Pathfinder `Off`/`Labels`/`Text` modes and in-graph node cards: node text starts on; the toolbar has only the `Text` toggle for turning that text off or on. Selecting, deselecting, marking, extracting, or hovering a node must not change the text setting. `Reset view` restores text on. Keep graph nodes and their compact text in the graph, while node information and actions appear only in the right `Details` panel. Do not render hover or selected-node cards over the graph.
- Explicit user extension 2026-09-25: the right `Details` panel shows the same severity-colored Finding hexagon as the Work tile next to a Finding title. Add concise, source-backed context there when available: native source status, tool, exact `Source Rule ID`, category and MITRE ATT&CK IDs. Omit missing fields; do not place a Finding hexagon or Finding facts on relation nodes. Keep the full Finding and evidence available through the existing Work and Results actions.
- Explicit user correction 2026-09-25: the Pathfinder action is named `Extract Path`. It is available only for a selected graph node and isolates that node, its directly connected nodes, and only their connecting edges. A filter or `Mark all` branch alone must not trigger extraction of the whole graph or broaden the selected path. `Exit Extract Path` restores the prior graph selection and view. This supersedes the earlier broad-branch `Extract` behavior; `Mark all` keeps its separate branch semantics.
- Explicit user correction 2026-09-25: Pathfinder Details facts must give values a readable column at the normal inspector width. Do not cap that column to a fraction of its own auto width or break ordinary words and numeric values inside a line. Wrap multiword values at spaces; allow long unbroken technical identifiers to scroll within their own value area without clipping adjacent content.
## UI DB Structure table flow
- Explicit user correction 2026-09-25: every table button in the DB Structure data-flow diagram opens that table's columns and relationships in the shared floating popup, instead of adding an inline detail section below the diagram. Referencing-table buttons inside the popup navigate to that table within the same window; Close and Escape dismiss it and leave the flow intact. This supersedes only the inline-detail placement from the DB-Structure flow implementation. The separate layered table dictionary retains its disclosure behavior.
- Explicit user correction 2026-09-25: DB Structure omits the redundant introductory `Database dictionary`/`Relational schema` copy block above the metrics and the explanatory sentence beneath `Data flow`. Keep the schema status and refresh action, metric tiles, flow heading, table buttons, popup and dictionary disclosure.
## UI Work filter context ## UI Work filter context
- Explicit user extension 2026-09-18: the Work toolbar's `XLSX`, `MD` and `HTML` buttons export the complete current filtered/sorted Work record set, including the same linked and embedded Results used by the Results window. Marking alone does not select export rows; `My Workspace` restricts them only when active, alongside ordinary filters and drilldowns. Markdown and HTML use exactly two content columns, `Soll-Zustand` and `Abweichungen`, with source-backed remediation/verification, original technical facts, scoped GPO provenance, and explicit missing-evidence notices. Preserve neutral verdicts and review-versus-rescan boundaries. Build the frozen projection only on export, not on navigation. Oversized Excel cells use explicitly identified continuation rows of the same finding without truncation. Explicit user corrections 2026-09-24: `XLSX` replaces the former `CSV` button and mirrors the Work finding reader instead of the two-column layout: an `Übersicht` sheet with the export context, per-tool counts and the review/GPO reading boundaries, then one sheet per tool in Work order with one row per finding. Columns are Nr, Finding, Status, Severity, Badges and Source Rule ID, then the reader's own fields under their reader labels in reader order (reusing `selectFindingFields`, e.g. Description, Advised solution, Configured customer value, Expected baseline value, GPO sources), then Hinweise for status-specific neutral notes, Results (the Results window content including missing-evidence notices), Additional Information, Review and Record ID; empty optional columns are omitted per sheet. Cells are literal strings (never formulas); the status cell is highlighted only when Work's own Deviation predicate matches. This view snapshot is separate from the curated Arbeitsgrundlage in `docs/REPORT_STRATEGY.md`. - Explicit user extension 2026-09-18: the Work toolbar's `XLSX`, `MD` and `HTML` buttons export the complete current filtered/sorted Work record set, including the same linked and embedded Results used by the Results window. Marking alone does not select export rows; `My Workspace` restricts them only when active, alongside ordinary filters and drilldowns. Markdown and HTML use exactly two content columns, `Soll-Zustand` and `Abweichungen`, with source-backed remediation/verification, original technical facts, scoped GPO provenance, and explicit missing-evidence notices. Preserve neutral verdicts and review-versus-rescan boundaries. Build the frozen projection only on export, not on navigation. Oversized Excel cells use explicitly identified continuation rows of the same finding without truncation. Explicit user corrections 2026-09-24: `XLSX` replaces the former `CSV` button and mirrors the Work finding reader instead of the two-column layout: an `Übersicht` sheet with the export context, per-tool counts and the review/GPO reading boundaries, then one sheet per tool in Work order with one row per finding. Columns are Nr, Finding, Status, Severity, Badges and Source Rule ID, then the reader's own fields under their reader labels in reader order (reusing `selectFindingFields`, e.g. Description, Advised solution, Configured customer value, Expected baseline value, GPO sources), then Hinweise for status-specific neutral notes, Results (the Results window content including missing-evidence notices), Additional Information, Review and Record ID; empty optional columns are omitted per sheet. Cells are literal strings (never formulas); the status cell is highlighted only when Work's own Deviation predicate matches. This view snapshot is separate from the curated Arbeitsgrundlage in `docs/REPORT_STRATEGY.md`.
- A unique `Source: Scan` selection is a semantic tool context even when Work remains on the project-wide `All Records` sheet. Visible and active facets must follow the selected tool context rather than the aggregate sheet profile. - A unique `Source: Scan` selection is a semantic tool context even when Work remains on the project-wide `All Records` sheet. Visible and active facets must follow the selected tool context rather than the aggregate sheet profile.
- Explicit user correction 2026-09-25: changing `Source: Scan` inside Work, including back to `All`, starts the newly selected source with the clean Work default. Clear the previous search, status override, other facets, record-ID/path restriction and `My Workspace` restriction, restore the standard `Deviation` status and normal sort, then apply the selected source. Keep the current Work view mode and current client/timeslot. This applies to an in-Work source change, not to an incoming Client Info or Pathfinder drilldown before the user changes Source.
- For a GPO-only Work context, whether reached through the dedicated GPO sheet or `Source: Scan = GPOs`, show only `Result Status`, the available GPO facets `Baseline`, `Comparison`, `Conflict`, `Description source`, `Diagnostics`, `Duplicates`, `Imported`, `Policy section`, `Policy type`, and the permanent final `Source: Scan` control. Do not show CIS, severity, category, MITRE, target, result-message, review, or scanner-specific facets. - For a GPO-only Work context, whether reached through the dedicated GPO sheet or `Source: Scan = GPOs`, show only `Result Status`, the available GPO facets `Baseline`, `Comparison`, `Conflict`, `Description source`, `Diagnostics`, `Duplicates`, `Imported`, `Policy section`, `Policy type`, and the permanent final `Source: Scan` control. Do not show CIS, severity, category, MITRE, target, result-message, review, or scanner-specific facets.
- Explicit user correction 2026-09-17 supersedes only the category exclusion in the preceding GPO-context rule: `Category` is a permanent GPO Work filter in both supported entry paths, including when its only current value is `GPOs`. Keep it directly after `Result Status`. Severity, CIS, MITRE, target, result-message, review and scanner-specific facets remain excluded from the GPO-only context; `Source: Scan` remains last. - Explicit user correction 2026-09-17 supersedes only the category exclusion in the preceding GPO-context rule: `Category` is a permanent GPO Work filter in both supported entry paths, including when its only current value is `GPOs`. Keep it directly after `Result Status`. Severity, CIS, MITRE, target, result-message, review and scanner-specific facets remain excluded from the GPO-only context; `Source: Scan` remains last.
- Explicit user extension 2026-09-17: Work also exposes a distinct `Security Domain` facet immediately after `Category`. It must use the exact shared Client Info security-domain catalog and record assignment, never a second filter-only categorization. It is available in mixed Work and both GPO entry paths; a pure GPO scope maps to `GPO & Baseline`. Domain selection and Client Info drilldown must therefore select the same record identities, while ordinary active Work filters continue to apply. `Source: Scan` remains last. - Explicit user extension 2026-09-17: Work also exposes a distinct `Security Domain` facet immediately after `Category`. It must use the exact shared Client Info security-domain catalog and record assignment, never a second filter-only categorization. It is available in mixed Work and both GPO entry paths; a pure GPO scope maps to `GPO & Baseline`. Domain selection and Client Info drilldown must therefore select the same record identities, while ordinary active Work filters continue to apply. `Source: Scan` remains last.
@ -87,6 +108,8 @@
## UI Result DB filter consistency ## UI Result DB filter consistency
- Explicit user decision 2026-09-25: Result DB API and JSONL retain the actionable per-tool finding, public rule ID when validated, result/severity, available scanner guidance, and the GPO comparison's concrete technical Ist-/Sollwert and baseline. Do not invent an Istwert absent from the source. Concrete person, account, tenant, customer and system identities remain outside this disclosure projection. Non-public system SIDs use stable result-local placeholders throughout nested policy values and baseline variants; public Windows SIDs remain readable. CIS-CAT control numbers and verified public policy guidance must not be mistaken for private IPs or accounts. Keep complete originals in internal analysis.
- Explicit user correction 2026-09-20: in linked CIS-CAT Results evidence, an evaluated source row must follow the actual finding outcome. Highlight the confirmed configured row with the shared green match treatment when the finding outcome is `Pass`, and use the red violation treatment only when the outcome is `Fail`; unknown, neutral or unavailable outcomes must not be guessed into either state. The criterion row remains the separately identified expected state. - Explicit user correction 2026-09-20: in linked CIS-CAT Results evidence, an evaluated source row must follow the actual finding outcome. Highlight the confirmed configured row with the shared green match treatment when the finding outcome is `Pass`, and use the red violation treatment only when the outcome is `Fail`; unknown, neutral or unavailable outcomes must not be guessed into either state. The criterion row remains the separately identified expected state.
- Explicit user correction 2026-09-18: Result DB uses the shared Work vocabulary for its GPO `Result Status` filter and details: `Deviation`, `Passed`, `Not configured`, `Conflicts`, and `Duplicates`; `Review required` remains an independent unresolved result. Stored classification/query values stay unchanged. Status facet counts must honor all other active filters, including Deviation; only their own status selection is excluded to keep alternatives reachable. Selecting or clearing a status must never silently reset Deviation. Selected zero-hit values stay visible and removable. This supersedes the earlier Result DB exception that ignored and automatically cleared Deviation. Result DB still counts individual comparisons, while Work counts rolled-up settings; no extra public observation types or invented statuses are introduced. - Explicit user correction 2026-09-18: Result DB uses the shared Work vocabulary for its GPO `Result Status` filter and details: `Deviation`, `Passed`, `Not configured`, `Conflicts`, and `Duplicates`; `Review required` remains an independent unresolved result. Stored classification/query values stay unchanged. Status facet counts must honor all other active filters, including Deviation; only their own status selection is excluded to keep alternatives reachable. Selecting or clearing a status must never silently reset Deviation. Selected zero-hit values stay visible and removable. This supersedes the earlier Result DB exception that ignored and automatically cleared Deviation. Result DB still counts individual comparisons, while Work counts rolled-up settings; no extra public observation types or invented statuses are introduced.
@ -103,11 +126,15 @@
- Explicit user decision 2026-09-17: render that inline attribution as one flat table with the columns `GPO source`, `Operation`, `Data`, `Type`, and `Evidence`, one row per exact backup identity, a compact status marker, and expandable `Show source details`. The expansion retains full GPO/backup identity, source-local sequence, safe evidence reference, key, value name and every original instruction. Bold Operation and Data. Mark `Conflicting contribution` only when the retained current baseline variants independently prove that single source member differs; mark alignment only when every retained variant independently matches. Do not derive source markers from historical evaluations, multiple instructions inside one backup, GPO precedence or endpoint effectiveness. - Explicit user decision 2026-09-17: render that inline attribution as one flat table with the columns `GPO source`, `Operation`, `Data`, `Type`, and `Evidence`, one row per exact backup identity, a compact status marker, and expandable `Show source details`. The expansion retains full GPO/backup identity, source-local sequence, safe evidence reference, key, value name and every original instruction. Bold Operation and Data. Mark `Conflicting contribution` only when the retained current baseline variants independently prove that single source member differs; mark alignment only when every retained variant independently matches. Do not derive source markers from historical evaluations, multiple instructions inside one backup, GPO precedence or endpoint effectiveness.
- Explicit user correction 2026-09-17 supersedes the flat table presentation above: show each exact GPO backup identity again as a complete, vertically stacked source section with Evidence, source-local sequence and every original instruction immediately visible. Use a restrained red background and red leading accent for a source section only when the retained current baseline variants independently prove that single source member differs. Keep aligned, unassessed and ambiguous sections neutral; retain the same historical, precedence and endpoint-effectiveness boundaries. - Explicit user correction 2026-09-17 supersedes the flat table presentation above: show each exact GPO backup identity again as a complete, vertically stacked source section with Evidence, source-local sequence and every original instruction immediately visible. Use a restrained red background and red leading accent for a source section only when the retained current baseline variants independently prove that single source member differs. Keep aligned, unassessed and ambiguous sections neutral; retain the same historical, precedence and endpoint-effectiveness boundaries.
- Explicit user correction 2026-09-17 supersedes the whole-section red treatment above: keep every GPO source section neutral and apply the restrained red background only to the exact displayed field value proven different from every retained current baseline variant. Highlight `Operation` for an operation mismatch, `Data` for a data mismatch and `Type code` for a registry-type mismatch; several fields may be marked only when each is independently proven. Do not highlight a whole source, heading, path, key, value name or conflict badge, and do not guess a field when variants disagree about the cause. - Explicit user correction 2026-09-17 supersedes the whole-section red treatment above: keep every GPO source section neutral and apply the restrained red background only to the exact displayed field value proven different from every retained current baseline variant. Highlight `Operation` for an operation mismatch, `Data` for a data mismatch and `Type code` for a registry-type mismatch; several fields may be marked only when each is independently proven. Do not highlight a whole source, heading, path, key, value name or conflict badge, and do not guess a field when variants disagree about the cause.
- Explicit user correction 2026-09-25 supersedes the always-visible source sections above in one case only: when the Finding Reader's first-read problem summary is shown and several GPO backups retain literally the same instruction (registry key/value-name letter case aside), the complete source sections sit in a collapsed `Show the N identical source instructions` disclosure below the source count and notice. The problem summary states the shared value and names every contributing backup; nothing is removed, and field-specific red value markers are unchanged inside the disclosure. A later 2026-09-25 correction extends this to different instructions: the visible value overview names every distinct instruction with its contributing backups, and the complete sections sit in a collapsed `Show the N source instructions` disclosure. A single source and historical or unstructured comparisons keep every source section immediately visible.
- Explicit user decision 2026-09-25: the GPO first-read `Problem` block and the separate `Next step` block each carry one leading marker at the shared `--marker-width`. Problem uses the outcome color (`--outcome-fail` for Fail, `--outcome-pass` for Pass, neutral otherwise); Next step uses the orange `--next-step-accent` role, which reuses the existing `--sev-high` orange. No other status fill.
- Explicit user extension 2026-09-17: every proven GPO source-value deviation must receive the same restrained red value treatment. Keep registry-policy differences field-specific for `Operation`, `Data` and `Type code`, including simultaneous differences. For other comparable GPO value kinds, or when all retained variants prove a difference but no one field is the common cause, mark the displayed source value as a whole without asserting a narrower cause. Review-required, unassessed and aligned values remain neutral; never color the whole source section, heading, evidence path or diagnostic badge. - Explicit user extension 2026-09-17: every proven GPO source-value deviation must receive the same restrained red value treatment. Keep registry-policy differences field-specific for `Operation`, `Data` and `Type code`, including simultaneous differences. For other comparable GPO value kinds, or when all retained variants prove a difference but no one field is the common cause, mark the displayed source value as a whole without asserting a narrower cause. Review-required, unassessed and aligned values remain neutral; never color the whole source section, heading, evidence path or diagnostic badge.
- Explicit user decision 2026-09-25 supersedes only the neutral treatment of aligned values in the preceding rules: wrong settings are red and correct settings are green, so right and wrong are visible at a glance. Green uses the same restrained value treatment with the success color and requires the same proof as red in the opposite direction: a whole value matching every retained current baseline variant, or a principal-set member present in the expected value of every retained variant. A value or member that matches only some templates, review-required, unassessed and historical values stay neutral; never guess from GPO precedence, endpoint effectiveness or an inferred role. The rule applies to the GPO source sections, the Finding Reader problem summary and the GPO conflict/duplicate variants in Results (a variant is colored only when every one of its sources has the same proven relation). Apply it to other scanner Results only where the source proves correctness per value or row; CIS-CAT keeps its existing evaluated-row treatment.
- Explicit user correction 2026-09-17: a principal-set is a collection comparison, not one indivisible value block. Mark only configured principals absent from every retained current baseline variant. A principal also present in the expected baseline remains neutral even when neighboring principals deviate. If the deviation consists only of a missing expected principal, do not color an existing configured principal as its substitute; the expected-baseline block remains the evidence for that absence. - Explicit user correction 2026-09-17: a principal-set is a collection comparison, not one indivisible value block. Mark only configured principals absent from every retained current baseline variant. A principal also present in the expected baseline remains neutral even when neighboring principals deviate. If the deviation consists only of a missing expected principal, do not color an existing configured principal as its substitute; the expected-baseline block remains the evidence for that absence.
- For versioned member comparisons, derive the Conflict flag from the retained authoritative customer state, not from a set-value-only subset of original instructions. Reduced-identical contributions from multiple backups may be Duplicate; multiple earlier instructions inside a single backup are not duplicate GPOs or independent current conflicts. Keep these diagnostic flags separate from the policy verdict and preserve historical evaluation readiness. - For versioned member comparisons, derive the Conflict flag from the retained authoritative customer state, not from a set-value-only subset of original instructions. Reduced-identical contributions from multiple backups may be Duplicate; multiple earlier instructions inside a single backup are not duplicate GPOs or independent current conflicts. Keep these diagnostic flags separate from the policy verdict and preserve historical evaluation readiness.
- An additional indeterminate GPO must not hide an independently proven difference between other GPO backups. Retain that diagnostic only when distinct, unambiguous backup contributions of the same technical setting prove different supported signatures also present in retained compared values. Exclude the whole uncertain backup from that proof; its earlier writes may remain in retained values and must not be mistaken for separate GPOs. The overall verdict remains Review required. - An additional indeterminate GPO must not hide an independently proven difference between other GPO backups. Retain that diagnostic only when distinct, unambiguous backup contributions of the same technical setting prove different supported signatures also present in retained compared values. Exclude the whole uncertain backup from that proof; its earlier writes may remain in retained values and must not be mistaken for separate GPOs. The overall verdict remains Review required.
- Explicit user decision 2026-09-25: a GPO backup whose own `GPReport.xml` lists no enabled link to the domain or an OU is shown as `Not linked` (source badge and note, `No effect` line in the problem summary when every contributing GPO is unlinked, count in GPO Processing). Retain only link counts, never OU or domain names. It is a backup-report fact, not effective device state, and it changes neither verdicts, conflict/duplicate flags nor the score. Backups processed before metadata parser 1.4.0 show no link status until reprocessed. The measured decision of the same date not to introduce a per-GPO target role stands; no role is derived from links beyond this presentation.
- The operational GPO assessment is misconfiguration-first and limited to customer settings that are actually configured, technically comparable and in the explicitly applicable Microsoft-baseline scope. Do not turn every recommended but absent baseline setting into an operational finding, remediation obligation or score penalty. A complete-baseline rollout is a separate, explicitly commissioned hardening track. - The operational GPO assessment is misconfiguration-first and limited to customer settings that are actually configured, technically comparable and in the explicitly applicable Microsoft-baseline scope. Do not turn every recommended but absent baseline setting into an operational finding, remediation obligation or score penalty. A complete-baseline rollout is a separate, explicitly commissioned hardening track.
- `Not configured` remains a score-neutral policy verdict. `Duplicates` and `Conflicts` remain visible, mutually distinct diagnostic categories on a policy setting; they are not alternative verdicts that can suppress `Passed`, `Fail` or `Review required`. - `Not configured` remains a score-neutral policy verdict. `Duplicates` and `Conflicts` remain visible, mutually distinct diagnostic categories on a policy setting; they are not alternative verdicts that can suppress `Passed`, `Fail` or `Review required`.
- Roll member comparisons up once per technical policy-setting identity. If at least one independently comparable, in-scope configured member value is a valid `Fail`, the whole policy setting verdict is `Fail`, while its conflict/duplicate flags remain visible. If every applicable member passes, the policy verdict is `Passed`. If member applicability, precedence or comparison is unresolved, use `Review required` rather than inventing a fail. Source multiplicity, duplicate rows and diagnostic flags must never multiply the setting's score weight. - Roll member comparisons up once per technical policy-setting identity. If at least one independently comparable, in-scope configured member value is a valid `Fail`, the whole policy setting verdict is `Fail`, while its conflict/duplicate flags remain visible. If every applicable member passes, the policy verdict is `Passed`. If member applicability, precedence or comparison is unresolved, use `Review required` rather than inventing a fail. Source multiplicity, duplicate rows and diagnostic flags must never multiply the setting's score weight.
@ -115,6 +142,11 @@
- Keep absent recommended settings available under `Not configured` for transparent inspection, but exclude them from Priority Findings, default remediation scope and score unless the user explicitly starts a separate baseline-hardening scope. - Keep absent recommended settings available under `Not configured` for transparent inspection, but exclude them from Priority Findings, default remediation scope and score unless the user explicitly starts a separate baseline-hardening scope.
- `Not configured` is scoped to the analyzed GPO backup set: if an applicable baseline recommendation has no observed configured value in that set, keep it filterable as `Not configured` even when the import is a selected subset. Retain a scope-limit diagnostic and never turn this into a domain-wide or effective-device absence claim without complete collection evidence or RSoP/GP Results. - `Not configured` is scoped to the analyzed GPO backup set: if an applicable baseline recommendation has no observed configured value in that set, keep it filterable as `Not configured` even when the import is a selected subset. Retain a scope-limit diagnostic and never turn this into a domain-wide or effective-device absence claim without complete collection evidence or RSoP/GP Results.
## UI table row marking
- Explicit user decision 2026-09-25: clicking any data row in an application table marks the entire row so its cells remain visually connected during horizontal scrolling. This includes native tables in Work, Client Info and Results windows and the row-based Result DB table. Header and virtualization spacer rows are not selectable.
- Keep one reading marker per table until another data row in that table is clicked. Explicit user correction 2026-09-25 supersedes the yellow row color only: use the dedicated blue `--table-row-marking` and its translucent surface in both themes. This row-reading exception does not change the yellow selection role of tiles and controls or the separate blue keyboard-focus role. Preserve existing pass/fail/evidence colors and text. Marking must not replace an existing row action such as opening details, following a link or resizing columns. Virtualized Work Grid rows restore their marker when scrolled out and back.
## UI segments and tiles ## UI segments and tiles
- Treat segments and tiles as a binding two-level visual grammar independent of their content. A segment is a transparent grouped layout; a tile is a flat, square-cornered surface inside it. - Treat segments and tiles as a binding two-level visual grammar independent of their content. A segment is a transparent grouped layout; a tile is a flat, square-cornered surface inside it.
@ -122,10 +154,12 @@
- Segments are visually transparent and have no visible border or shadow. They retain `--segment-radius`, one uniform inset, and one uniform gap as shared layout geometry. - Segments are visually transparent and have no visible border or shadow. They retain `--segment-radius`, one uniform inset, and one uniform gap as shared layout geometry.
- Tiles use the common white or theme-equivalent surface without a visible normal-state border, radius, or shadow. Hover changes the surface only and must not add a decorative contour, lift, or translation. - Tiles use the common white or theme-equivalent surface without a visible normal-state border, radius, or shadow. Hover changes the surface only and must not add a decorative contour, lift, or translation.
- Explicit user correction 2026-09-20: in the light theme, tile surfaces use the shared neutral gray `--tile-surface` instead of pure or near white to reduce glare; the current approved value is `#F5F5F7`. Keep the surrounding app surface, input surface and established hierarchy distinct; do not reproduce this with view-local overrides. - Explicit user correction 2026-09-20: in the light theme, tile surfaces use the shared neutral gray `--tile-surface` instead of pure or near white to reduce glare; the current approved value is `#F5F5F7`. Keep the surrounding app surface, input surface and established hierarchy distinct; do not reproduce this with view-local overrides.
- Explicit user correction 2026-09-25 supersedes the no-visible-border rule above for the light theme only: because the approved light tile surface and app background differ only slightly and neither color may change, every tile carries one neutral 1px hairline through the shared `--tile-border` token (`#DCDDE3`). The dark theme keeps `--tile-border: transparent`, where the surface luminance step separates tiles. Keep every surface color unchanged. The hairline is a neutral contour, not a status, selection or focus signal: persistent yellow selection and blue keyboard focus stay distinct, hover changes only the surface (`--tile-surface-hover`, slightly lighter) and keeps the same contour, and shadows, lift and gradients remain excluded. Dividers inside a tile use `--tile-divider`; do not darken the global `--border-subtle`, which also draws the approved Work list separators. Apply the contour only through these tokens, never through view-local values.
- Explicit user correction 2026-09-25: the Client Info `Data` tile's nested `Deviations` donut action has no visible rectangular contour. Keep the outer `Data` tile's shared contour; the donut uses the transparent shared segment-border role in both themes. Its blue keyboard focus outline remains available.
- Status color belongs only in a small accent line, marker, data visualization, or selected contour. Never fill the complete tile with a status color. - Status color belongs only in a small accent line, marker, data visualization, or selected contour. Never fill the complete tile with a status color.
- Keyboard focus and selected tiles use the shared functional focus or selected outline. Action tiles and add tiles use the same flat geometry as content tiles. - Keyboard focus and selected tiles use the shared functional focus or selected outline. Action tiles and add tiles use the same flat geometry as content tiles.
- Explicit user correction 2026-09-20: the persistent yellow outline of the selected Data Hub tenant tile is one hairline thicker than the shared 2px selection outline, composed from `--bw-emphasis` plus `--bw-hairline`. This is a scoped Data Hub tenant-selection exception; keyboard focus remains the separate shared blue 2px outline, and no other tile-selection width changes. - Explicit user correction 2026-09-20: the persistent yellow outline of the selected Data Hub tenant tile is one hairline thicker than the shared 2px selection outline, composed from `--bw-emphasis` plus `--bw-hairline`. This is a scoped Data Hub tenant-selection exception; keyboard focus remains the separate shared blue 2px outline, and no other tile-selection width changes.
- The persistent marking and selection color is always the shared yellow `--marking-color` in both themes. Purple must not be used for selected contours or markers. Blue remains reserved for keyboard focus, while green, red, orange, and severity colors remain domain status signals rather than selection colors. - The persistent marking and selection color is the shared yellow `--marking-color` in both themes, except for the explicitly blue table-row reading marker documented above. Purple must not be used for selected contours or markers. Blue is otherwise reserved for keyboard focus, while green, red, orange, and severity colors remain domain status signals rather than selection colors.
- Keep unused segment space intentionally empty: lay tiles out from the start at their shared compact or content width and never stretch a partial final row merely to fill the container. - Keep unused segment space intentionally empty: lay tiles out from the start at their shared compact or content width and never stretch a partial final row merely to fill the container.
- Explicit user correction 2026-09-20: the Data Hub result-donut row and the client/add-tile row below share the exact same six-column track geometry at wide widths. Every column start, width and gap must align vertically without cumulative horizontal offset; all six result donuts remain on one line. At narrower widths both rows return to the same compact responsive tile contract. - Explicit user correction 2026-09-20: the Data Hub result-donut row and the client/add-tile row below share the exact same six-column track geometry at wide widths. Every column start, width and gap must align vertically without cumulative horizontal offset; all six result donuts remain on one line. At narrower widths both rows return to the same compact responsive tile contract.
- Tile content must never cross the tile boundary or cover a neighboring tile. Nothing may be clipped, clamped, truncated, or replaced by an ellipsis. Text and actions reflow inside the tile; media scales within the available inner area. If content does not fit, enlarge or reflow the tile. - Tile content must never cross the tile boundary or cover a neighboring tile. Nothing may be clipped, clamped, truncated, or replaced by an ellipsis. Text and actions reflow inside the tile; media scales within the available inner area. If content does not fit, enlarge or reflow the tile.
@ -135,36 +169,46 @@
## UI donut charts ## UI donut charts
- Every donut chart uses the same full 360-degree geometry: a continuous neutral-gray track and a colored clockwise value arc starting at 12 o'clock. - Every donut chart uses the same full 360-degree geometry: a continuous neutral-gray track and a colored clockwise value arc starting at 12 o'clock.
- Explicit user correction 2026-09-25 supersedes the 2026-09-24 image-reference presentation: restore the prior donut design with titles outside the rings, the prior compact Data Hub ring size, and no ring shadow. Preserve each metric's count/percentage or score semantics and the meaning of segmented distributions. Keep the later explicit loading-state behavior so pending values are not shown as zero.
- Donut arcs use flat ends and the shared `--donut-ring-stroke`, `--donut-track`, value, and typography tokens. Do not introduce 270-degree gauges, open gaps, rounded arc caps, or local substitute geometry. - Donut arcs use flat ends and the shared `--donut-ring-stroke`, `--donut-track`, value, and typography tokens. Do not introduce 270-degree gauges, open gaps, rounded arc caps, or local substitute geometry.
- Explicit user correction 2026-09-25: calculate every donut arc from the unrounded share. A positive share below 2% also receives a narrow, flat, same-tone radial mark so it remains visible at the compact ring size; a true zero has no mark. A positive share that rounds below 1% is labeled `<1%`, never `0%`. Keep the exact share in the arc and existing count/score semantics in the readout.
- Explicit user correction 2026-09-20: every count/percentage donut places the absolute count as the bold first line and the percentage as a regular-weight second line below it. Every donut readout, including score rings, keeps a visible safety gap from the inner edge of the ring at every supported ring size and value width. A score ring such as Overall Score retains its score-specific `/100` semantics instead of inventing an absolute count. - Explicit user correction 2026-09-20: every count/percentage donut places the absolute count as the bold first line and the percentage as a regular-weight second line below it. Every donut readout, including score rings, keeps a visible safety gap from the inner edge of the ring at every supported ring size and value width. A score ring such as Overall Score retains its score-specific `/100` semantics instead of inventing an absolute count.
- Center the complete two-line readout as one unit inside the ring without letting either line shift horizontally. Keep every text element fully within the clear inner area and never let it touch or overlap the ring at any supported value width. - Center the complete two-line readout as one unit inside the ring without letting either line shift horizontally. Keep every text element fully within the clear inner area and never let it touch or overlap the ring at any supported value width.
- Multi-segment distributions retain their categorical segments, but use the same track, stroke proportion, start position, centered value treatment, and shared tokens as single-value donuts. - Multi-segment distributions retain their categorical segments, but use the same track, stroke proportion, start position, centered value treatment, and shared tokens as single-value donuts.
## UI Client Info dashboard ## UI Client Info dashboard
- Explicit user correction 2026-09-26: the proposed Work-style dashboard rearrangement was rejected and fully reverted. Preserve the existing tile layout, Overall Score, domain connections and Results popups. Reduce information only by removing duplicate overview facts within the existing tiles: Domain, Forest, both functional levels and DC OS stay in Domain Information; NetBIOS, schema version, domain creation and Entra identity stay in Directory; aggregate counts stay in Data. Remove repeated counts from Directory, Domain Information and Inventory only when they match the same fact already shown elsewhere; retain differing or non-numeric source values. Equal values for distinct facts (for example Domain and Forest) are not duplicates. Directory and Inventory Results retain their complete facts and original tables. This supersedes only earlier requirements to repeat those facts in several overview tiles.
- Explicit user correction 2026-09-20: the former Client Info `Priority Findings` tile is named `Data`, sits to the left of `Domain Information`, and shows the total `Deviations` value plus the separate additional-result-state row only. Do not show duplicate `Critical` or `High` summary values in that tile. The remaining deviation value occupies the full header width and is a keyboard-accessible button that opens Work with the standard `Deviation` filter. - Explicit user correction 2026-09-20: the former Client Info `Priority Findings` tile is named `Data`, sits to the left of `Domain Information`, and shows the total `Deviations` value plus the separate additional-result-state row only. Do not show duplicate `Critical` or `High` summary values in that tile. The remaining deviation value occupies the full header width and is a keyboard-accessible button that opens Work with the standard `Deviation` filter.
- Explicit user correction 2026-09-18 supersedes the previous Client Info visual exception: `.client-command-center` follows the same Ableton-inspired ASSESSOR language as the approved Data Hub, Work and Pathfinder. Client Info may organize its own domain content, but has no exception for palette, surfaces, tiles, typography, controls, selection, branding, spacing or chart geometry. Existing `--client-info-*` roles are legacy implementation details, not authority for a separate visual theme; reuse the shared roles when bringing this view into conformity. - Explicit user correction 2026-09-18 supersedes the previous Client Info visual exception: `.client-command-center` follows the same Ableton-inspired ASSESSOR language as the approved Data Hub, Work and Pathfinder. Client Info may organize its own domain content, but has no exception for palette, surfaces, tiles, typography, controls, selection, branding, spacing or chart geometry. Existing `--client-info-*` roles are legacy implementation details, not authority for a separate visual theme; reuse the shared roles when bringing this view into conformity.
- Treat Client Info as one connected executive security-posture canvas, not as a collection of unrelated report cards or a spreadsheet. Its stable reading order is environment facts, overall posture, security domains, priority findings and assessment gaps, then source evidence. - Treat Client Info as one connected executive security-posture canvas, not as a collection of unrelated report cards or a spreadsheet. Its stable reading order is overall posture, security domains, Data and Domain Information, then source evidence.
- Keep environment facts in a compact evidence rail, using the same neutral theme-aware tiles as Data Hub. Wrap at readable widths instead of forcing one tiny-text row. The former mandatory dark-tile treatment is superseded by the 2026-09-18 correction. - Keep environment facts in a compact evidence rail, using the same neutral theme-aware tiles as Data Hub. Wrap at readable widths instead of forcing one tiny-text row. The former mandatory dark-tile treatment is superseded by the 2026-09-18 correction.
- Explicit user correction 2026-09-20 supersedes the separate evidence rail above: AD Domains, Entra Tenant, User Accounts, Computer Objects, Domain Controllers and GPOs live inside the `Data` tile as one flat 3-by-2 fact grid, retaining their existing glyphs. Do not render a separate top-level Environment Facts tile row. The six facts are not nested tiles; use transparent cells and subtle separators inside `Data`. - Explicit user correction 2026-09-20 supersedes the separate evidence rail above: AD Domains, Entra Tenant, User Accounts, Computer Objects, Domain Controllers and GPOs live inside the `Data` tile as one flat 3-by-2 fact grid, retaining their existing glyphs. Do not render a separate top-level Environment Facts tile row. The six facts are not nested tiles; use transparent cells and subtle separators inside `Data`.
- Present Overall Score and Security Domains together on one shared posture surface. Overall Score is the dominant visual anchor; domain scores form a visibly connected branch or spine beside it and must not look like a conventional data table. - Present Overall Score and Security Domains together on one shared posture surface. Overall Score is the dominant visual anchor; domain scores form a visibly connected branch or spine beside it and must not look like a conventional data table.
- Explicit user correction 2026-09-20: at wide desktop widths, the posture surface and action rail each use exactly one half of the available row width. This supersedes the earlier two-to-one proportion. Every tile aligned in one row must always share the same height and bottom edge; never use intrinsic-height staggering within a row. This also supersedes the earlier instruction to keep the posture surface shorter than the action rail. - Explicit user correction 2026-09-20: at wide desktop widths, the posture surface and action rail each use exactly one half of the available row width. This supersedes the earlier two-to-one proportion. Every tile aligned in one row must always share the same height and bottom edge; never use intrinsic-height staggering within a row. This also supersedes the earlier instruction to keep the posture surface shorter than the action rail.
- Explicit user correction 2026-09-20: the top of the Client Info action rail is an exact 50/50 row. `Domain Information` sits on the left and `Priority Findings` on the right with identical height; `Assessment Gaps` spans the full action-rail width below. Domain Information contains every fact from the Environment Detail `Directory` group—Domain, NetBIOS name, Forest, Domains, domain functional level, forest functional level, schema version, domain created and Entra ID—plus the separately requested domain-controller count. All values come only from the same normalized Environment Snapshot. Missing facts remain an em dash and must never be inferred as zero. - Explicit user correction 2026-09-25 supersedes the earlier action-rail geometry: `Data` sits to the left of `Domain Information` in an exact 50/50 row with identical height. The separate `Assessment Gaps` tile is removed; the authoritative `Gaps` count and its Work drilldown remain in `Data`. The adjacent posture surface has the same bottom edge as this row. Domain Information contains every fact from the Environment Detail `Directory` group—Domain, NetBIOS name, Forest, Domains, domain functional level, forest functional level, schema version, domain created and Entra ID—plus the separately requested domain-controller count. All values come only from the same normalized Environment Snapshot. Missing facts remain an em dash and must never be inferred as zero.
- Explicit user correction 2026-09-25: In `Domain Information`, show the Domain first across the full width, then pair NetBIOS name with Forest, Domains with Domain controllers, Domain functional level with Forest functional level, DC OS with Schema version, and Domain created with Entra ID. This changes only presentation order; keep the source-backed values and unknown-value semantics.
- Explicit user correction 2026-09-20: the Overall Score block does not repeat a textual score status below the ring and does not repeat the critical-finding count already present in Priority Findings. Assessment Coverage uses at most 70% of the Overall zone and never exceeds the shared compact tile-content width; it must end before the curved domain connectors, which may never cross its label, bar or supporting count. - Explicit user correction 2026-09-20: the Overall Score block does not repeat a textual score status below the ring and does not repeat the critical-finding count already present in Priority Findings. Assessment Coverage uses at most 70% of the Overall zone and never exceeds the shared compact tile-content width; it must end before the curved domain connectors, which may never cross its label, bar or supporting count.
- Explicit user correction 2026-09-20: the Overall Score block also omits the secondary `Unassessed` and `Lowest domain pass rate` fields completely, including their divider container. `Unassessed controls` remains visible only in Assessment Gaps; domain pass rates remain visible on their individual Security Domain nodes. - Explicit user correction 2026-09-20: the Overall Score block also omits the secondary `Unassessed` and `Lowest domain pass rate` fields completely, including their divider container. The unassessed count remains visible as `Gaps` in Data; domain pass rates remain visible on their individual Security Domain nodes.
- Explicit user decision 2026-09-18: preserve the thin curved fan connections from Overall Score to every displayed Security Domain during the Ableton-style alignment. They are a binding visual feature, not optional decoration. Derive their endpoints from the rendered ring and domain nodes and remeasure on resize/content changes. Preserve the existing stacked narrow-screen fallback; never keep desktop coordinates on a stacked layout. - Explicit user decision 2026-09-18: preserve the thin curved fan connections from Overall Score to every displayed Security Domain during the Ableton-style alignment. They are a binding visual feature, not optional decoration. Derive their endpoints from the rendered ring and domain nodes and remeasure on resize/content changes. Preserve the existing stacked narrow-screen fallback; never keep desktop coordinates on a stacked layout.
- Explicit user correction 2026-09-20: Security Domain rows show the domain name in the shared detail-title role without the former passed/not-configured/duplicate/conflict/review metadata line underneath. The score node and right-hand deviation or review count remain. The Overall Score ring is a keyboard-accessible entry to Work's clean remediation default: clear prior search, source, domain, severity, record-ID and workspace filters, select `Deviation`, and open the standard Work overview. - Explicit user correction 2026-09-25: Security Domain rows are compact enough for all eight domains to fit beside Data without clipping. Domain names use the shared H3 role; the score node and right-hand deviation or review count remain, without the former metadata line. The Overall Score ring is a keyboard-accessible entry to Work's clean remediation default: clear prior search, source, domain, severity, record-ID and workspace filters, select `Deviation`, and open the standard Work overview.
- Explicit user correction 2026-09-20: Every displayed Security Domain row carries a subtle amount bar in its own shared grid column between the longest rendered domain label and the widest rendered amount. Keep the shared gap on both sides so a bar can never grow into text or behind a number. All bars share one right edge and extend left from that edge. The later color correction supersedes the preceding categorical-color decision: every amount bar uses the exact current semantic color of the Overall Score donut and changes with that score's critical, medium, passed, or unknown tone. Do not use selection, focus, per-domain score, or a separate palette. Bar width is that row's displayed deviation amount divided by the sum of the displayed domain amounts; review-only rows use their already-displayed review amount. Keep the exact right-aligned number visible as the authoritative value, make the bar decorative and hidden from assistive technology, and never change counts, scoring, classification, or drilldown semantics to produce the visualization. - Explicit user correction 2026-09-20: Every displayed Security Domain row carries a subtle amount bar in its own shared grid column between the longest rendered domain label and the widest rendered amount. Keep the shared gap on both sides so a bar can never grow into text or behind a number. All bars share one right edge and extend left from that edge. The later color correction supersedes the preceding categorical-color decision: every amount bar uses the exact current semantic color of the Overall Score donut and changes with that score's critical, medium, passed, or unknown tone. Do not use selection, focus, per-domain score, or a separate palette. Bar width is that row's displayed deviation amount divided by the sum of the displayed domain amounts; review-only rows use their already-displayed review amount. Keep the exact right-aligned number visible as the authoritative value, make the bar decorative and hidden from assistive technology, and never change counts, scoring, classification, or drilldown semantics to produce the visualization.
- Explicit user correction 2026-09-25: Within each Client Info Security Domain row, center the domain name, amount bar and amount number vertically on the center of that row's score circle. They belong to one grid row; do not let the circle span an unused second row that shifts the other elements upward.
- Explicit user correction 2026-09-20 supersedes the transparent Client-Info donut treatment above: the total `Deviations` donut in Client Info's `Data` tile uses the exact Data Hub `datahub-reference-knob` visual variant, including its tile surface, dimensions, spacing, title and hover state. Data Hub and Client Info derive its count and percentage denominator from the same selected mixed-timeslot project sheet, including assessment-bound GPO/policy observations as well as scanner records; never calculate the Data Hub value from scanner run IDs alone. The complete donut remains a keyboard-accessible action to Work with the `Deviation` filter. - Explicit user correction 2026-09-20 supersedes the transparent Client-Info donut treatment above: the total `Deviations` donut in Client Info's `Data` tile uses the exact Data Hub `datahub-reference-knob` visual variant, including its tile surface, dimensions, spacing, title and hover state. Data Hub and Client Info derive its count and percentage denominator from the same selected mixed-timeslot project sheet, including assessment-bound GPO/policy observations as well as scanner records; never calculate the Data Hub value from scanner run IDs alone. The complete donut remains a keyboard-accessible action to Work with the `Deviation` filter.
- Explicit user correction 2026-09-25 supersedes only the Client Info `Data` donut dimensions and spacing in the preceding rule: expand its ring to the largest size that fits above the result states and environment facts without increasing the shared tile height or clipping content. Keep the Data Hub donut component, external title, colors, value semantics, hover state and Work interaction.
- Explicit user extension 2026-09-20: Client Info's Operating systems table shows PingCastle's row-bound `Nb Active`, `Nb Inactive`, `Nb Enabled` and `Nb Disabled` counts beside `Nb OS`. Join each value only to the exact retained report, source table and row of that operating-system entry; never derive one state from another, borrow a value from a different row or add overlapping inventories. Missing values render as an em dash. Other scanners and older imports remain visible without invented state counts. - Explicit user extension 2026-09-20: Client Info's Operating systems table shows PingCastle's row-bound `Nb Active`, `Nb Inactive`, `Nb Enabled` and `Nb Disabled` counts beside `Nb OS`. Join each value only to the exact retained report, source table and row of that operating-system entry; never derive one state from another, borrow a value from a different row or add overlapping inventories. Missing values render as an em dash. Other scanners and older imports remain visible without invented state counts.
- Keep Client Info immediately scannable: Environment Detail starts collapsed with its evidence/restriction counts visible; the Accepted Risks master/detail summary remains visible, while its complete context, controls, approval, evidence and decision history are individually expandable. Disclosure must remain keyboard accessible and must not change source data, scoring, risk decisions or drilldown identity. - Keep Client Info immediately scannable: Environment Detail starts collapsed with its evidence/restriction counts visible; the Accepted Risks master/detail summary remains visible, while its complete context, controls, approval, evidence and decision history are individually expandable. Disclosure must remain keyboard accessible and must not change source data, scoring, risk decisions or drilldown identity.
- Explicit user correction 2026-09-20: every Environment Detail tile heading, including Source Overview and Operating systems, carries one restrained existing ASSESSOR line glyph before its title. All fact groups reuse Source Overview's list spacing and row padding for one consistent, airy rhythm; fixed tile heights, the four-column grid and internal scrolling remain unchanged. - Explicit user correction 2026-09-20: every Environment Detail tile heading, including Source Overview and Operating systems, carries one restrained existing ASSESSOR line glyph before its title. All fact groups reuse Source Overview's list spacing and row padding for one consistent, airy rhythm; fixed tile heights, the four-column grid and internal scrolling remain unchanged.
- Explicit user correction 2026-09-20: Accepted Risks adopts the Data Hub `Manage Client` master/detail interaction and geometry. The fixed-width, independently scrolling navigation on the left owns the register heading, count, selection and compact state/date rows; the equally tall information pane on the right owns the selected risk, summary and expandable detail groups. Yellow marks persistent selection, risk state remains a separate text-and-dot signal, Arrow Up/Down/Home/End move the selected risk, and `Open finding` opens that exact record in Work. At narrow widths the panes stack without horizontal overflow. - Explicit user correction 2026-09-20: Accepted Risks adopts the Data Hub `Manage Client` master/detail interaction and geometry. The fixed-width, independently scrolling navigation on the left owns the register heading, count, selection and compact state/date rows; the equally tall information pane on the right owns the selected risk, summary and expandable detail groups. Yellow marks persistent selection, risk state remains a separate text-and-dot signal, Arrow Up/Down/Home/End move the selected risk, and `Open finding` opens that exact record in Work. At narrow widths the panes stack without horizontal overflow.
- Explicit user correction 2026-09-20: Accepted Risks adopts the Data Hub `Manage Client` master/detail interaction and geometry. The fixed-width, independently scrolling navigation on the left owns the register heading, count, selection and compact state/date rows; the equally tall information pane on the right owns the selected risk, summary and expandable detail groups. Yellow marks persistent selection, risk state remains a separate text-and-dot signal, Arrow Up/Down/Home/End move the selected risk, and `Open finding` opens that exact record in Work. At narrow widths the panes stack without horizontal overflow. - Explicit user correction 2026-09-20: Accepted Risks adopts the Data Hub `Manage Client` master/detail interaction and geometry. The fixed-width, independently scrolling navigation on the left owns the register heading, count, selection and compact state/date rows; the equally tall information pane on the right owns the selected risk, summary and expandable detail groups. Yellow marks persistent selection, risk state remains a separate text-and-dot signal, Arrow Up/Down/Home/End move the selected risk, and `Open finding` opens that exact record in Work. At narrow widths the panes stack without horizontal overflow.
- Explicit user correction 2026-09-20: Accepted Risks reuses the Data Hub client-detail geometry one-to-one: a fixed compact selection rail at the left using `clamp(420px, 27vw, 520px)`, the information surface at the right, and the established stacked narrow-screen fallback. Do not reintroduce a proportional wide list column. - Explicit user correction 2026-09-20: Accepted Risks reuses the Data Hub client-detail geometry one-to-one: a fixed compact selection rail at the left using `clamp(420px, 27vw, 520px)`, the information surface at the right, and the established stacked narrow-screen fallback. Do not reintroduce a proportional wide list column.
- Keep Priority Findings and Assessment Gaps in a dedicated action rail. Use status color only for finding markers, score arcs, score nodes, and progress bars; avoid large colored fills and nested card stacks. - Keep Data and Domain Information in the dedicated action rail. Use status color only for finding markers, score arcs, score nodes, and progress bars; avoid large colored fills and nested card stacks.
- Explicit user correction 2026-09-20 supersedes the standalone Source Overview rail: source scores live inside the expanded `Environment Detail` as one compact plain list with separators, never as tiles. Preserve every source's actual versioned ASSESSOR score contribution, method label, native context and Work drilldown. A run/file-bound native score takes priority when supported and pass rate is the fallback; PingCastle uses the explicit `100 - native risk` convention and retains raw risk as secondary context. Never insert a raw lower-is-better risk value directly into the overall scale. Follow `docs/SCORING_MODEL.md`; Security Domain values are pass rates, not a decomposition of the mixed-method index. - Explicit user correction 2026-09-20 supersedes the standalone Source Overview rail: source scores live inside the expanded `Environment Detail` as one compact plain list with separators, never as tiles. Preserve every source's actual versioned ASSESSOR score contribution, method label, native context and Work drilldown. A run/file-bound native score takes priority when supported and pass rate is the fallback; PingCastle uses the explicit `100 - native risk` convention and retains raw risk as secondary context. Never insert a raw lower-is-better risk value directly into the overall scale. Follow `docs/SCORING_MODEL.md`; Security Domain values are pass rates, not a decomposition of the mixed-method index.
- Explicit user extension 2026-09-20 supersedes only the preceding container geometry: all expanded Environment Detail groups form an equal four-column tile grid. `Source Overview` occupies one group tile while its individual source entries remain the agreed plain separated list, never individual source tiles. The six fact groups and Operating systems each occupy one tile. Every tile is exactly three quarters of the currently rendered Overall Score tile height; derive that height from the rendered element and keep overflow inside the affected tile with a scrollbar. Preserve four columns on narrow layouts through horizontal grid scrolling rather than changing tile width or column count. - Explicit user extension 2026-09-20 supersedes only the preceding container geometry: all expanded Environment Detail groups form an equal four-column tile grid. `Source Overview` occupies one group tile while its individual source entries remain the agreed plain separated list, never individual source tiles. The six fact groups and Operating systems each occupy one tile. Every tile is exactly three quarters of the currently rendered Overall Score tile height; derive that height from the rendered element and keep overflow inside the affected tile with a scrollbar. Preserve four columns on narrow layouts through horizontal grid scrolling rather than changing tile width or column count.
- Explicit user correction 2026-09-25 supersedes the preceding Environment Detail disclosure and tile geometry: remove the disclosure heading and keep all eight group tiles permanently visible. Use compact square tiles at the shared compact width, wrapping with available space. Show only one or two source-backed overview facts per tile; complete facts, source contributions, original sections, observations and operating-system tables remain available through the tile's `Results` action. Keep source drilldowns and scoring methodology reachable from Source Overview Results. Never clip tile content or shrink informational labels below 14px and values below 16px.
- Explicit user correction 2026-09-25 supersedes the Source Overview tile in the preceding rule: remove that tile entirely, leaving seven permanent square environment tiles. Keep the full source-score contributions, source-linked Work drilldowns, restricted-section explanation, original scanner evidence and scoring methodology accessible from a compact `Results` action beside `Overall Score`. Directory's compact preview shows the source-backed domain count so its value fits without splitting the domain name; the full name remains in Domain Information and Directory Results.
- Explicit user correction 2026-09-25 supersedes the seven tiles' compact width and single-fact preview above: use twice the shared compact tile edge and show the initial source-backed hard facts in a space-efficient two-column layout. Inventory shows all eight facts, including enabled/disabled accounts and the assessed host, directly in its tile. Directory shows its first six facts; its remaining facts are visible in Domain Information and Directory Results. Source metrics shows up to eight available metrics, while Results retains the complete list. Operating systems shows available group totals and first source rows; its full table remains in Results. Keep 14px information labels, 16px values, natural growth for longer content and no clipping.
- Explicit user correction 2026-09-25 supersedes the earlier H3 Security Domain names and larger Domain Information/Environment tile headings: every Client Info dashboard tile except Overall Score follows Data's 16px title/value and 14px informational-label roles. Bring Data's own 12px informational labels up to the binding 14px minimum. Give each fact in Domain Information and the seven environment tiles a separate flat cell with the same subtle separators as Data; each Security Domain row also receives a subtle cell contour. Preserve the current source-backed content, interactions, semantic chart colors, 14px minimum for information and natural growth without clipping. The Accepted Risks master/detail workspace remains its separately agreed Work-style reading surface.
- Explicit user correction 2026-09-20: Source Overview never renders a `Tabular Data` tile. Generic tabular imports remain available through their supported data and filter paths, but they are not a security-scoring source tile. - Explicit user correction 2026-09-20: Source Overview never renders a `Tabular Data` tile. Generic tabular imports remain available through their supported data and filter paths, but they are not a security-scoring source tile.
- Unknown or unavailable facts render as an em dash and never as an inferred zero. Keep the dashboard English-only and omit explanatory prose that does not support an immediate first-meeting statement or action. - Unknown or unavailable facts render as an em dash and never as an inferred zero. Keep the dashboard English-only and omit explanatory prose that does not support an immediate first-meeting statement or action.
- The content hierarchy, score semantics, source provenance and drilldowns remain binding. Their layout must use the shared ASSESSOR components and tokens; a connected briefing layout does not permit a dedicated palette or geometry. Source Overview, Environment Detail and Accepted Risks obey this same rule, including their collapsed, expanded and selected states. - The content hierarchy, score semantics, source provenance and drilldowns remain binding. Their layout must use the shared ASSESSOR components and tokens; a connected briefing layout does not permit a dedicated palette or geometry. Source Overview, Environment Detail and Accepted Risks obey this same rule, including their collapsed, expanded and selected states.
@ -183,6 +227,14 @@
## Navigation and rendering performance ## Navigation and rendering performance
- Explicit user correction 2026-09-26: `Catalog references` starts collapsed for a current client, with a small green dot before its heading and before `Up to date` / `Update successful`. Pending work or a failed check/update opens it automatically and uses an orange heading dot; checking, processing and empty clients never get a green success signal. ATT&CK/D3FEND versions remain visible while collapsed. Keep the success message open after updating and retain manual disclosure and keyboard operation. Center the Timeslots heading and add action on the same horizontal line with equal top/bottom spacing.
- Explicit user extension 2026-09-26: Timeslot entries name Purple Knight scopes explicitly as `Purple Knight for Active Directory`, `Purple Knight for Entra ID`, or the existing combined name. Use canonical scope names first, otherwise already retained file metadata or run/file-local environment observations. Never guess from file names, finding titles, other scans or another scanner. This is a display projection only; stored scanner identity, Result DB disclosure and scoring remain unchanged. A historical scan without retained scope facts keeps its source name until reimport supplies them.
- Explicit user correction 2026-09-26: `Catalog references` omits its generic explanation paragraph and states the next action plainly: `Please update` for pending work, `Up to date` with `No action needed.` for a current client, and `Update successful` only after successful execution and a fresh status check with no remaining work. Keep ATT&CK and D3FEND catalog versions visible. The action is now named `Update references`; this supersedes the earlier `Recognize references` caption while retaining its left-side placement and red-when-needed/yellow-otherwise color rule. Empty, checking and failure states must not claim success.
- Explicit user correction 2026-09-26: `Catalog references` belongs in the Data Hub client navigation on the left, directly above `Timeslots`, including while client master data is being edited. The `Recognize references` button is red when recognition is pending or retry is needed, otherwise it uses the standard yellow action treatment. This is an explicit color exception for this reference action; other action and selection colors remain unchanged. Pending references also participate in the client card's processing status (yellow point and highlighted Manage); an actual recognition failure uses the existing red error state. Successful recognition refreshes the card without hiding unrelated import or GPO issues.
- Explicit user correction 2026-09-25: begin preloading Data Hub's six result summaries while the client overview metadata is loading, before a client is selected. Reuse completed and in-flight summaries across selections and Data Hub revisits. Keep speculative requests and retained values bounded, prioritize a selected scope, and key reuse by repository revision plus exact project/assessment/scan scope. Refresh on import/data/GPO changes; stale responses must never overwrite another selection. Load summaries only, not complete source models into the browser.
- Treat area switches as a performance-sensitive path. Measure the first visit and repeated visits separately after the workspace has loaded; distinguish main-thread rendering from asynchronous API readiness. Use the local procedure in `docs/architecture/navigation-performance.md`. - Treat area switches as a performance-sensitive path. Measure the first visit and repeated visits separately after the workspace has loaded; distinguish main-thread rendering from asynchronous API readiness. Use the local procedure in `docs/architecture/navigation-performance.md`.
- Share expensive filtering, sorting and facet counts between their consumers. Component-local `useMemo` does not survive an area unmount. Use bounded, dataset/sheet-owned caches with immutable input identities and all semantic dependencies (profile, dataset, reviews, filters, search, sort and timeslot/path scope); never cache solely by a reusable entity ID. Verify invalidation as well as reuse. - Share expensive filtering, sorting and facet counts between their consumers. Component-local `useMemo` does not survive an area unmount. Use bounded, dataset/sheet-owned caches with immutable input identities and all semantic dependencies (profile, dataset, reviews, filters, search, sort and timeslot/path scope); never cache solely by a reusable entity ID. Verify invalidation as well as reuse.
- Build raw text, detailed result projections, graph layouts and other large derived content only when the corresponding view needs it. Virtualized views must not serialize or derive presentation fields for every offscreen record. The initial render must also be bounded by the viewport plus overscan. - Build raw text, detailed result projections, graph layouts and other large derived content only when the corresponding view needs it. Virtualized views must not serialize or derive presentation fields for every offscreen record. The initial render must also be bounded by the viewport plus overscan.
@ -215,6 +267,14 @@
- Use `npm test` and `npm run build` as the baseline verification commands for relevant changes. - Use `npm test` and `npm run build` as the baseline verification commands for relevant changes.
- Preserve unrelated user changes and keep implementation-plan updates factual and concise. - Preserve unrelated user changes and keep implementation-plan updates factual and concise.
## Client Info table interaction correction (2026-09-25)
- All Client Info Results categories use the same table interaction: retained base tables, including historical imports without presentation metadata, are immediately readable inline. Do not replace them with repeated `Open table` buttons or an `Additional source tables` gate.
- Preserve original column values and native section/table headings; do not add invented `Table N of M` titles. Historical flattened source prose belongs in a separate `Source text` disclosure, with the retained text still accessible.
- Explicit user correction 2026-09-25 supersedes the `Source text` disclosure above: omit that generated control and historical flattened prose from Client Info Results. Preserve the stored source text. Never display importer-generated `Tabelle N`, `Table N`, or ordinal suffixes as native headings, whether they arrive through table title, name, subsection observations, row-count labels, or a linked popup/inline target. If no native table heading is retained, show the complete table under its section heading without inventing a replacement. Structured native explanatory prose remains visible.
- Native links inside table cells open their exact target as a popup; native links outside tables expand their target inline. Resolve all links within the exact scan-run and import-file identity, and retain every table in a linked target. Missing historical link metadata must never be reconstructed from table order, counts, or guessed group names.
- Apply the shared 14px table/field-label and 16px value/prose roles, 20px window inset, compact fact cells and contained horizontal table scrolling in both themes. Regression tests must open each category through its actual Results action.
## Privacy-safe testing ## Privacy-safe testing
- Treat all real tenant, customer, project, scan, finding, and imported-file data as sensitive. - Treat all real tenant, customer, project, scan, finding, and imported-file data as sensitive.

File diff suppressed because one or more lines are too long

27
docs/MITRE_CATALOG.md Normal file
View File

@ -0,0 +1,27 @@
# Enterprise ATT&CK catalog in ASSESSOR
Pathfinder uses the compact, versioned default catalog at `src/assets/data/mitre-enterprise-v19.2.json`. It was generated from MITRE's official Enterprise ATT&CK STIX 2.1 bundle:
- Source: https://github.com/mitre-attack/attack-stix-data/blob/master/enterprise-attack/enterprise-attack-19.2.json
- Source SHA-256: `DC1639CAA5501D720E280CF1CBD8FBE009884A0C9B3E6E9ED9D0C25166C3D8F4`
- Collection version: 19.2; 15 active tactics, 697 active techniques and subtechniques, 1,057 active groups, campaigns and software entries.
To refresh the repo asset, download a **version-pinned** official Enterprise STIX bundle and run `node --experimental-strip-types scripts/update-mitre-asset.mts <path-to-bundle>`. Review the generated version, counts and graph tests before changing the default import. The converter follows the STIX matrix tactic order and omits revoked or deprecated objects and relationships.
The Data Hub has three actions:
- **Update from MITRE** fetches the official latest Enterprise STIX bundle from MITRE's `attack-stix-data` repository, validates and converts it, and activates it only when its collection version is newer than the active shared catalog. The compact result is saved atomically at `<ASSESSOR_ARTIFACT_ROOT>/reference-data/mitre-enterprise.json`, outside the public web root. The backend serves that catalog after restart, and Pathfinder receives an immediate update event in the current browser. The browser's custom override is cleared after a successful update check.
- **Download from MITRE** fetches the original latest Enterprise STIX JSON from that same MITRE source and offers it as `enterprise-attack-v<version>.json`. Downloading does not activate or persist it. The backend proxy keeps the browser same-origin and validates the source before sending it. The source is capped at 80 MiB and the request times out after 120 seconds.
- **Import JSON file** is the manual fallback. It accepts an official Enterprise STIX bundle or the compact ASSESSOR JSON format. A custom matrix is stored in browser `localStorage`, overrides the shared catalog only in that browser, and is shown as `Custom v<version>`. **Use active catalog** removes this override. The open Pathfinder graph updates after either action.
The shared catalog is read through `GET /api/v1/reference-data/mitre-attack`; the guarded `POST /api/v1/reference-data/mitre-attack/refresh` updates it, and `GET /api/v1/reference-data/mitre-attack/source` sends the latest validated original file. No PostgreSQL schema or existing assessment data is changed. MITRE availability is required for update and download; the bundled v19.2 catalog and manual import remain available offline. A shared catalog update does not remove another browser's deliberate local override.
## Reference recognition (2.8.0)
ATT&CK updates now also retain the complete versioned recognition catalog, including
historical entries, alongside the active graph in the same atomic file. Stored scanner
reference runs remain frozen until the explicit client action **Recognize references**.
The bundled D3FEND catalog and reference storage are documented in
`docs/REFERENCE_RECOGNITION.md`. Migration 023 adds this independent derived storage;
the update/download buttons themselves do not rewrite assessment data. Manual browser
matrix overrides remain graph fallbacks and do not select backend recognition catalogs.

View File

@ -0,0 +1,133 @@
# Phase 13D: ATT&CK and D3FEND references
## Scope and semantics
The initial implementation recognizes Enterprise ATT&CK technique/sub-technique IDs,
tactic IDs and unambiguous exact catalog titles, plus D3FEND defensive-technique IDs
and unambiguous exact titles. It processes retained `OriginalField` values using one
framework-specific engine, independently of the scanner. A framework-named header
declares its context; IDs in other fields are merely **mentioned**. Titles are only
matched in declared context. Whitespace and case normalization is allowed; fuzzy
matching, translations and LLM classification are not.
`active`, `deprecated`, `revoked` and `not-in-catalog` describe the lookup result.
An unknown ID is not claimed to be invalid. Placeholder text creates no reference.
An ambiguous title (for example PowerShell in current and historical ATT&CK entries)
does not select either ID. Unmatched original content remains available in the source
fields. No ATT&CK-to-D3FEND defensive inference or automatic correlation is performed.
The derived references never enter `FindingDefinition.frameworkReferences`, the
comparison identity engine, source guidance, severity, verdict, score, Security Domain,
review state or the external Result DB disclosure. Existing report-correlation semantics
remain separate. A shared reference describes a shared topic, not a proven common cause.
## Catalogs and reproducibility
- `attack-reference-v19.2.json`: 873 technique/tactic entries, including historical
deprecated and revoked entries. Source: MITRE's version-pinned Enterprise STIX 19.2.
- `d3fend-reference-v1.6.0.json`: 272 defensive technique entries (`D3-…`).
Source: <https://d3fend.mitre.org/ontologies/d3fend/1.6.0/d3fend.json>.
D3A/D3E entities and inferred cross-framework relationships are outside this slice.
- Each asset contains its original download URL, source SHA-256 and MITRE copyright/
license notice. Snapshot SHA-256 covers the complete canonical JSON catalog used by
recognition, including entries, version, provenance and notice.
- `scripts/update-reference-assets.mts` regenerates the compact assets from official
source files. Recognition and reprocessing require no external network.
- The existing **Update from MITRE** downloads ATT&CK and atomically stores both its
graph and complete recognition catalog. An equal-version refresh can fill in missing
recognition metadata from an older ASSESSOR installation. A central update makes a
new catalog available; it does not silently replace previous reference runs.
- D3FEND is bundled at 1.6.0 in this initial release; no D3FEND runtime update UI has
been added. **Download from MITRE** and manual **Import JSON file** remain ATT&CK
matrix actions. The manual browser override affects the displayed matrix and transient
previews; stored recognition always uses the backend's versioned catalogs.
## Storage and execution
Migration `029_reference_recognition` adds immutable public `assessor_reference_catalogs`,
append-only client/assessment/scan-bound `assessor_reference_runs` and their catalog
membership table. 021/022 are already reserved in the canonical local datastore by
timeslot-comparison and obligation work; those migrations are not modified here.
No separate client databases are created in this phase.
The normal browser batch-import transaction includes its reference run and catalog
snapshots. A recognition failure therefore rolls back the batch instead of reporting a
partially completed import. The low-level staging API and pre-existing scans can be
indexed with the explicit client action below; the UI never silently fabricates a stored
run for them.
**Data Hub → client → Catalog references → Update references** processes all current
scanner runs in that client across its timeslots, from their retained fields. The button
shows available catalog versions and pending scan count. Its guarded endpoint is
`POST /api/v1/projects/:projectId/references/recognize`; status is available through
`GET /api/v1/projects/:projectId/references`. Concurrent requests for one client share
one job; unchanged input, catalogs and recognizer version are idempotent and do not
bump repository revision. Individual scans are committed atomically; after interruption,
retry skips scans already completed. This initial synchronous route is not a background
job scheduler; very large clients can require a later queued implementation.
Each result retains occurrence and file identity, source file hash if available, source
position, field key/label/path, JSON value pointer, exact matched substring and offsets.
Offsets are zero-based UTF-16 indices into the retained `rawValue`, when present, or the
individual string leaf of `value`; the end is exclusive. Each run retains recognizer
version, timestamp, source-input hash and exact catalog hashes. Older runs remain
exportable. Project export includes every referenced compact catalog snapshot, allowing
reference history to be read without the central catalog. Original full STIX/JSON-LD
downloads are not needed to replay recognition against these exact compact inputs.
## Presentation and boundaries
The Data Hub client overview includes reference readiness across every current timeslot.
Pending recognition or an outdated recognizer/catalog produces the existing yellow
update point and highlighted **Manage**, including before selecting the client. An
unavailable status remains a review notice; checking is neutral and never claims readiness.
An actual recognition failure is red. Existing scanner/GPO failures retain priority.
Status summaries load separately from overview metadata with at most three concurrent
requests, refresh on import/data changes and successful catalog updates, and reject stale
responses after a newer panel result. There is no added polling or persistent job state.
**Catalog references** is in the left client navigation immediately above **Timeslots**,
including while editing client master data. By explicit user decision on 2026-09-26,
**Update references** is red when work or retry is needed and yellow otherwise.
The generic explanation is omitted. The first line says **Please update**, **Up to date**
with **No action needed.**, or **Update successful** after execution and a fresh status
check confirms no remaining work. ATT&CK and D3FEND versions stay visible. Empty clients,
loading and failures use explicit separate messages and never claim success.
The section starts collapsed for a current client and opens automatically for pending
work or a failed status/update. Small green dots precede the heading and confirmed
current/success state; required action uses orange and unknown/empty/processing states
stay neutral. Catalog versions remain outside the disclosure and visible while collapsed.
After updating, the success message remains open; manual and keyboard toggling is retained.
Successful recognition updates the client card and its loaded workspace. A failed status
request offers **Retry status**. The navigation scrolls as one area below the client header
so the added controls do not make timeslots inaccessible on short screens.
Pathfinder consumes persisted active references and frozen tactic membership, with
declared ATT&CK mapped as native and mentioned ATT&CK mapped as derived. Unknown,
revoked and deprecated matches are visible in the Finding Reader but do not form active
graph relations. Unindexed stored occurrences remain unmapped until explicitly indexed.
Transient, unsaved previews use the same pure recognizer with their selected matrix.
Original raw references remain readable. The Finding Reader adds a provenance disclosure
after explanatory content; its Problem/Next step, Results and work tracking are unchanged.
Broader 13D work (other frameworks, profile-declared ambiguous contexts, standalone
reference search/filtering, technical identity resolution, correlation UI, catalog-version
selection and per-client database migration) remains planned separately.
## Verification
Synthetic domain, full batch HTTP import, client-button/Reader interaction and Pathfinder
tests cover declared/mentioned evidence, unknown IDs, exact offsets, nested values,
historical/ambiguous entries, client isolation, idempotence, source immutability and
catalog updates without retrospective changes. The opt-in PostgreSQL test uses only
generated synthetic IDs in the canonical datastore and deletes that exact test project.
No tenant report contents are inspected.
Verified release: **2.8.0**. Both production builds, UI/API version contract, local-only
and design checks pass. Synthetic Light/Dark checks include a 480px Reader. The full
suite has 1,569 passing and 14 skipped tests, with six pre-existing failures across
GPO evaluation visibility (2), Security Domain navigation (1), AppLocker markup (1),
Result DB scope source-text expectations (1) and dashboard cache source-text expectations
(1). Their root causes are not all resolved by this separate reference implementation.
The PostgreSQL integration check additionally verifies source-complete history export
after a synthetic scan replacement. No existing customer scans were reprocessed.

View File

@ -39,6 +39,8 @@ Clarification 2026-09-17 (baseline analyses of the written form): The Microsoft
Clarification 2026-09-17 (written security-template section and key): The Security Template parser folded the section name to lower case and discarded the written form, so `[Privilege Rights]` was retained only as `privilege rights`. The parser now keeps the written section and key; the case-folded forms stay internal to grammar selection, grouping and the identity digest, which keeps existing observation and identity IDs stable. The native target resolver stores the written section and key in the retained target, matching what the registry resolver already did, so Work shows `Privilege Rights - SeCreateTokenPrivilege` and the value stays searchable against Microsoft documentation and external tables. This changes persisted parsing output, so it is an append-only processing-version change, not a presentation change: `gpo-security-template-inf` 1.1.0 -> 1.2.0, `gpo-backup-policy-analysis` 2.2.3 -> 2.2.4 and the GPO job processor 2.2.3 -> 2.2.4. Historical imports keep their retained values unchanged and are marked for reprocessing: Data Hub reports an affected GPO backup as `Reprocessing required` with the analyzer versions and offers the existing reprocess action on the stored source, so no upload is needed. Reprocessing precedes re-evaluation, and prior runs remain retained as history. No SQL migration and no evaluator change. Clarification 2026-09-17 (written security-template section and key): The Security Template parser folded the section name to lower case and discarded the written form, so `[Privilege Rights]` was retained only as `privilege rights`. The parser now keeps the written section and key; the case-folded forms stay internal to grammar selection, grouping and the identity digest, which keeps existing observation and identity IDs stable. The native target resolver stores the written section and key in the retained target, matching what the registry resolver already did, so Work shows `Privilege Rights - SeCreateTokenPrivilege` and the value stays searchable against Microsoft documentation and external tables. This changes persisted parsing output, so it is an append-only processing-version change, not a presentation change: `gpo-security-template-inf` 1.1.0 -> 1.2.0, `gpo-backup-policy-analysis` 2.2.3 -> 2.2.4 and the GPO job processor 2.2.3 -> 2.2.4. Historical imports keep their retained values unchanged and are marked for reprocessing: Data Hub reports an affected GPO backup as `Reprocessing required` with the analyzer versions and offers the existing reprocess action on the stored source, so no upload is needed. Reprocessing precedes re-evaluation, and prior runs remain retained as history. No SQL migration and no evaluator change.
Clarification 2026-09-25 (GPO link facts): The GPO metadata parser retains, from each backup's own `GPReport.xml`, the number of `LinksTo` entries and how many are enabled (never the linked OU or domain names). A backup with no enabled link is presented as `Not linked`: the Finding Reader marks its source sections and states `No effect` in the problem summary when every contributing GPO is unlinked, and GPO Processing shows the count. This is a backup-report fact at backup time, not a claim about effective device state, and verdicts and score are unchanged. Because parsing now retains a new fact, it is an append-only processing-version change: `gpo-backup-metadata-xml` 1.2.0 -> 1.4.0 (the Microsoft baseline template variant keeps 1.3.0, so baseline packages need no re-analysis), `gpo-backup-policy-analysis` 2.2.4 -> 2.2.5 and the GPO job processor 2.2.4 -> 2.2.5. Historical imports are marked `Reprocessing required` and the existing reprocess action on the stored source adds the link facts; no upload, SQL migration or evaluator change is required. Until then no link status is shown.
Clarification 2026-09-17 (Not comparable verdict): A retained customer setting whose delivery mechanism has no comparable technical policy identity - today always a Group Policy Preference - reaches Work under the score-neutral verdict `Not comparable` instead of being dropped. It is never resolved into a policy identity and never compared with a Microsoft baseline. The unified read projection additionally exposes the already retained preference CSE, action and item-level targeting status so that context stays readable in the agreed GPO columns; no new column, facet or persisted field is introduced. Snapshot-aggregation review hints that restate an unresolved identity already reported by their source analysis are no longer emitted a second time. All of this projects facts the importer already retained, so no processing-manifest version, source reimport, evaluator change or SQL migration is required. Clarification 2026-09-17 (Not comparable verdict): A retained customer setting whose delivery mechanism has no comparable technical policy identity - today always a Group Policy Preference - reaches Work under the score-neutral verdict `Not comparable` instead of being dropped. It is never resolved into a policy identity and never compared with a Microsoft baseline. The unified read projection additionally exposes the already retained preference CSE, action and item-level targeting status so that context stays readable in the agreed GPO columns; no new column, facet or persisted field is introduced. Snapshot-aggregation review hints that restate an unresolved identity already reported by their source analysis are no longer emitted a second time. All of this projects facts the importer already retained, so no processing-manifest version, source reimport, evaluator change or SQL migration is required.
Clarification 2026-09-17 (No baseline rule verdict): Work now projects every retained customer policy state under exactly one verdict. A resolved technical identity with no applicable Microsoft baseline recommendation in the compared baseline set receives the score-neutral verdict `No baseline rule`; a retained comparison that yields no safe verdict stays `Review required` instead of being dropped. `Conflicts`/`Duplicates` remain diagnostic flags on the setting's own row and no longer replace its verdict, so their counts and the Result-DB consistency projection follow the flag rather than the row status. Observations that already evidence an evaluated comparison, including child values of a registry-key requirement, are never reported a second time; unresolved identities keep their separate analysis-gap reporting. This is a read-time projection over already retained observations, aggregations and evaluations: no evaluator version change, source reimport, processing-manifest update or SQL migration is required, and no persisted record is rewritten. Clarification 2026-09-17 (No baseline rule verdict): Work now projects every retained customer policy state under exactly one verdict. A resolved technical identity with no applicable Microsoft baseline recommendation in the compared baseline set receives the score-neutral verdict `No baseline rule`; a retained comparison that yields no safe verdict stays `Review required` instead of being dropped. `Conflicts`/`Duplicates` remain diagnostic flags on the setting's own row and no longer replace its verdict, so their counts and the Result-DB consistency projection follow the flag rather than the row status. Observations that already evidence an evaluated comparison, including child values of a registry-key requirement, are never reported a second time; unresolved identities keep their separate analysis-gap reporting. This is a read-time projection over already retained observations, aggregations and evaluations: no evaluator version change, source reimport, processing-manifest update or SQL migration is required, and no persisted record is rewritten.
@ -65,3 +67,12 @@ Structured Accepted Risk decisions retain every original revision and its target
When a match is absent or ambiguous, the old review remains on the retained previous scan. The replacement records an unresolved count and Data Hub shows a persistent review-required state. The reimport is still auditable and does not silently discard or guess customer data. When a match is absent or ambiguous, the old review remains on the retained previous scan. The replacement records an unresolved count and Data Hub shows a persistent review-required state. The reimport is still auditable and does not silently discard or guess customer data.
Both direct reimport and adoption of an already imported byte-identical replacement execute review transfer and scan supersession in the same repository transaction. The exact source SHA-256 multiset, assessment, and scanner must match before replacement is accepted. Both direct reimport and adoption of an already imported byte-identical replacement execute review transfer and scan supersession in the same repository transaction. The exact source SHA-256 multiset, assessment, and scanner must match before replacement is accepted.
# Reference derivation (Phase 13D, 2026-09-26)
ATT&CK/D3FEND recognition is an independent, versioned derivation over retained original
fields. It never alters canonical Finding identity, scanner outcome, source mapping or
reviews. Catalog or recognizer changes therefore use **Recognize references** in the
client's Data Hub and append a new derivation; they do not require reuploading source
files. Historical reference runs and their exact catalog snapshots remain retained.
If an extractor never retained a required source field, recognition cannot recover it;
the existing extractor-version/reimport policy still applies in that case.

4
package-lock.json generated
View File

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

View File

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

212
scripts/gpo-role-impact.ts Normal file
View File

@ -0,0 +1,212 @@
/**
* GPO target-role impact measurement — prints NUMBERS ONLY.
*
* Question: how many current GPO Fail findings would change if every GPO were
* compared only with the Microsoft baseline templates of its target role, and
* how many GPOs are not linked at all?
*
* Privacy contract (read before running):
* - Reads only the one project whose ID you pass. No project list is printed
* unless you run `--list` yourself.
* - Everything stays in memory. No file is written.
* - Output is a fixed set of counters. No GPO names, GUIDs, paths, setting
* names, values, SIDs, OU names or tenant identifiers are printed.
* - Any error is reported only as its error type, never with its message.
* - Read-only: it calls GET endpoints of the local backend and runs one SELECT
* against the canonical local PostgreSQL container. It changes nothing.
*
* Usage (from the repository root, backend running on 127.0.0.1:4173):
* npx vite-node scripts/gpo-role-impact.ts --list
* npx vite-node scripts/gpo-role-impact.ts project-xxxxxxxx
*/
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { policyComparisonDisposition } from '../src/domain/policy-comparison-disposition';
import { baselineTemplateContext } from '../src/evaluation/baseline-template-context';
import { policyWorkVerdictPriority } from '../src/evaluation/policy-work-results';
import { readVerifiedZipFiles, inventoryZipArchive } from '../server/gpo/zip-inventory';
const API = 'http://127.0.0.1:4173/api/v1';
const CONTAINER = 'assessor-2_0-postgres-1';
const DEFAULT_DC_POLICY = '{6AC1786C-016F-11D2-945F-00C04FB984F9}';
type Role = 'Domain Controller' | 'all' | 'unlinked' | 'unknown';
async function get<T>(path: string): Promise<T> {
const response = await fetch(API + path);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<T>;
}
function sql(query: string): string[] {
return execFileSync('docker', ['exec', CONTAINER, 'psql', '-U', 'assessor', '-d', 'assessor', '-At', '-c', query],
{ encoding: 'utf8', maxBuffer: 512 * 1024 * 1024 }).split('\n').filter(Boolean);
}
function artifactRoot(): string {
const line = readFileSync('.env.postgres', 'utf8').split(/\r?\n/).find(item => item.startsWith('ASSESSOR_ARTIFACT_ROOT='));
if (!line) throw new Error('artifact root not configured');
return line.slice('ASSESSOR_ARTIFACT_ROOT='.length).trim();
}
function decode(bytes: Uint8Array): string {
if (bytes[0] === 0xff && bytes[1] === 0xfe) return new TextDecoder('utf-16le').decode(bytes);
if (bytes[0] === 0xfe && bytes[1] === 0xff) return new TextDecoder('utf-16be').decode(bytes);
return new TextDecoder('utf-8').decode(bytes);
}
/** Role only from unambiguous evidence in GPReport.xml; everything else stays unknown. */
function roleFromReport(report: string): { role: Role; wmiFilter: boolean } {
const text = report.replace(/<(\/?)[A-Za-z0-9]+:/g, '<$1');
const guid = /<Identifier[^>]*>\s*<Identifier[^>]*>([^<]+)</.exec(text)?.[1] ?? /<Identifier>([^<]+)<\/Identifier>/.exec(text)?.[1] ?? '';
const links = [...text.matchAll(/<LinksTo>([\s\S]*?)<\/LinksTo>/g)].map(match => ({
path: /<SOMPath>([^<]*)<\/SOMPath>/.exec(match[1])?.[1] ?? '',
enabled: !/<Enabled>\s*false\s*<\/Enabled>/i.test(match[1])
})).filter(link => link.enabled);
const wmiFilter = /<FilterName>[^<]+<\/FilterName>/.test(text);
if (guid.trim().toUpperCase() === DEFAULT_DC_POLICY) return { role: 'Domain Controller', wmiFilter };
if (!links.length) return { role: 'unlinked', wmiFilter };
if (links.every(link => /\/Domain Controllers$/i.test(link.path.trim()))) return { role: 'Domain Controller', wmiFilter };
// A link to the domain itself (no OU segment) applies to every role.
if (links.some(link => !link.path.includes('/'))) return { role: 'all', wmiFilter };
return { role: 'unknown', wmiFilter };
}
async function list(): Promise<void> {
// Shown only on your own screen; do not paste this list.
const projects = await get<Array<{ id: string; name: string }>>('/projects');
for (const project of projects) console.log(project.id, ' ', project.name);
}
async function measure(projectId: string): Promise<void> {
if (!/^project-[a-z0-9]+$/.test(projectId)) throw new Error('invalid project id');
const overview = await get<{ current: { evaluationRunIds: string[] } }>(`/projects/${projectId}/policy-analysis-overview`);
const runIds = new Set(overview.current.evaluationRunIds);
const evaluations = (await get<{ records: any[] }>(`/projects/${projectId}/policy-evaluations`)).records.filter(item => runIds.has(item.run.id));
const aggregations = (await get<{ records: any[] } | any[]>(`/projects/${projectId}/policy-snapshot-aggregations`));
const aggregationRecords: any[] = Array.isArray(aggregations) ? aggregations : aggregations.records;
// Backup instance -> backup folder, backup folder -> role from its GPReport.xml.
const instanceFolder = new Map<string, string>();
for (const line of sql(`select json_build_object('id', instance_id, 'data', data)::text from assessor_gpo_backup_instances where project_id = '${projectId}'`)) {
const item = JSON.parse(line);
const folder = JSON.stringify(item.data).match(/\{[0-9A-Fa-f-]{36}\}/)?.[0];
if (folder) instanceFolder.set(item.id, folder.toUpperCase());
}
const folderRole = new Map<string, { role: Role; wmiFilter: boolean }>();
const root = artifactRoot();
for (const line of sql(`select storage_key || '|' || coalesce(relative_path, '') from assessor_import_artifacts where project_id = '${projectId}' and storage_kind = 'backend-controlled'`)) {
const [key, relativePath] = line.split('|');
if (!/^[0-9a-f-]{36}$/i.test(key)) continue;
let bytes: Uint8Array;
try { bytes = readFileSync(join(root, 'objects', `${key}.bin`)); } catch { continue; }
const reports: Array<{ path: string; bytes: Uint8Array }> = [];
if (bytes[0] === 0x50 && bytes[1] === 0x4b) {
const paths = inventoryZipArchive(bytes).entries.filter(entry => entry.kind === 'file' && /(^|\/)gpreport\.xml$/i.test(entry.relativePath)).map(entry => entry.relativePath);
if (paths.length) for (const file of readVerifiedZipFiles(bytes, paths, undefined, 256 * 1024 * 1024)) reports.push({ path: file.entry.relativePath, bytes: file.bytes });
} else if (/(^|[\\/])gpreport\.xml$/i.test(relativePath)) reports.push({ path: relativePath, bytes });
for (const report of reports) {
const folder = report.path.match(/\{[0-9A-Fa-f-]{36}\}/)?.[0];
if (folder) folderRole.set(folder.toUpperCase(), roleFromReport(decode(report.bytes)));
}
}
const templateRoles = new Map<string, string | undefined>();
for (const baselineId of new Set(evaluations.flatMap(item => item.results.map((result: any) => result.baselineId)))) {
const response = await get<{ records?: any[] }>(`/reference-data/microsoft-security-baselines/${encodeURIComponent(String(baselineId))}/analysis`);
for (const record of response.records ?? [response]) for (const setting of (record.analysis ?? record).settings ?? []) {
templateRoles.set(setting.id, baselineTemplateContext([...new Set<string>((setting.template?.names ?? []).map((name: any) => name.value))])?.role);
}
}
const statusOf = (comparison: any) => policyComparisonDisposition(comparison) === 'not-configured' ? 'Not configured'
: policyComparisonDisposition(comparison) === 'misconfigured' ? 'Fail'
: comparison.baselineVariantAssessment?.decision === 'all-pass' && comparison.classification === 'configured_match' ? 'Pass' : 'Review required';
const rank: Record<string, number> = { ...policyWorkVerdictPriority, 'No effect': -1, 'Not applicable': -2 };
const top = (items: string[]) => items.reduce((left, right) => (rank[right] ?? 0) > (rank[left] ?? 0) ? right : left);
// Per setting: comparison relations grouped by recognized template role (upper bound of role dependence).
const relationsByTemplateRole = new Map<string, Map<string, Set<string>>>();
const settings = new Map<string, { roles: Set<Role>; current: string[]; simulated: string[] }>();
let variants = 0, variantsWithoutRole = 0;
for (const evaluation of evaluations) {
const aggregation = aggregationRecords.find(item => item.run.id === evaluation.run.aggregationRunId);
const observationRoles = new Map<string, Set<Role>>();
for (const state of aggregation?.states ?? []) for (const observation of state.observationIds) {
const roles = observationRoles.get(observation) ?? new Set<Role>();
for (const instance of state.backupInstanceIds) roles.add(folderRole.get(instanceFolder.get(instance) ?? '')?.role ?? 'unknown');
observationRoles.set(observation, roles);
}
for (const comparison of evaluation.results) {
const key = JSON.stringify([evaluation.run.aggregationRunId, comparison.identity.scope, comparison.identity.id]);
const roles = new Set<Role>(comparison.observationIds.flatMap((id: string) => [...(observationRoles.get(id) ?? [])]));
const row = settings.get(key) ?? { roles: new Set<Role>(), current: [], simulated: [] };
roles.forEach(role => row.roles.add(role));
const current = statusOf(comparison);
let simulated = current;
const byRole = relationsByTemplateRole.get(key) ?? new Map<string, Set<string>>();
for (const variant of comparison.baselineVariantAssessment?.variants ?? []) {
variants++;
const templateRole = templateRoles.get(variant.settingId);
if (!templateRole) variantsWithoutRole++;
const relations = byRole.get(templateRole ?? 'unrecognized') ?? new Set<string>();
relations.add(variant.comparator.relation);
byRole.set(templateRole ?? 'unrecognized', relations);
}
relationsByTemplateRole.set(key, byRole);
if (current !== 'Not configured' && roles.size === 1 && roles.has('unlinked')) simulated = 'No effect';
else if (current !== 'Not configured' && roles.size === 1 && roles.has('Domain Controller') && comparison.baselineVariantAssessment) {
// Unknown template roles are kept: an unrecognized template could still apply.
const kept = comparison.baselineVariantAssessment.variants.filter((variant: any) => {
const role = templateRoles.get(variant.settingId);
return role === undefined || role === 'Domain Controller';
});
simulated = !kept.length ? 'Not applicable'
: kept.every((variant: any) => variant.comparator.relation === 'match') ? 'Pass'
: kept.every((variant: any) => variant.comparator.relation === 'different') ? 'Fail' : 'Review required';
}
row.current.push(current);
row.simulated.push(simulated);
settings.set(key, row);
}
}
const rows = [...settings.entries()].map(([key, row]) => {
const real = row.simulated.filter(status => status !== 'Not applicable');
// A Fail is role-dependent when every template of at least one role matches (or it has
// matching templates of an unrecognized role): the verdict could change with the target role.
const byRole = relationsByTemplateRole.get(key) ?? new Map<string, Set<string>>();
const roleMatches = [...byRole.entries()].filter(([role, relations]) => role !== 'unrecognized'
&& relations.size === 1 && relations.has('match')).length;
const unrecognizedMatch = byRole.get('unrecognized')?.has('match') ?? false;
const dependence = roleMatches ? 'role-dependent (a recognized role matches)'
: unrecognizedMatch ? 'possibly role-dependent (only unrecognized templates match)'
: 'role-independent (no template matches)';
return { roles: row.roles, current: top(row.current), simulated: real.length ? top(real) : 'Not applicable', dependence };
});
const count = (values: string[]) => values.reduce<Record<string, number>>((result, value) => (result[value] = (result[value] ?? 0) + 1, result), {});
const changes = count(rows.filter(row => row.current !== row.simulated).map(row => `${row.current} -> ${row.simulated}`));
const gpoRoles = count([...new Set(instanceFolder.values())].map(folder => folderRole.get(folder)?.role ?? 'no GPReport.xml'));
const failRoles = count(rows.filter(row => row.current === 'Fail').map(row => [...row.roles].sort().join('+') || 'no customer source'));
console.log('=== GPO role impact (numbers only) ===');
console.log('GPO backups by derived role ', JSON.stringify(gpoRoles));
console.log('GPO backups with a WMI filter ', [...folderRole.values()].filter(item => item.wmiFilter).length);
console.log('Settings (rolled up) ', rows.length);
console.log('Current verdicts ', JSON.stringify(count(rows.map(row => row.current))));
console.log('Verdicts with target role ', JSON.stringify(count(rows.map(row => row.simulated))));
console.log('Changed settings ', JSON.stringify(changes));
console.log('Current Fail by contributing roles ', JSON.stringify(failRoles));
console.log('Baseline variants without template role', `${variantsWithoutRole} of ${variants}`);
console.log('Current Fail by role dependence ', JSON.stringify(count(rows.filter(row => row.current === 'Fail').map(row => row.dependence))));
}
const argument = process.argv[2];
if (!argument) console.log('Usage: npx vite-node scripts/gpo-role-impact.ts --list | <project-id>');
(argument === '--list' ? list() : argument ? measure(argument) : Promise.resolve())
.catch((error: unknown) => {
// Never print an error message: it could contain source content.
console.log('Stopped:', error instanceof Error ? error.constructor.name : typeof error);
process.exitCode = 1;
});

View File

@ -0,0 +1,10 @@
import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { convertEnterpriseStix } from '../src/features/mitre-graph/mitre-stix.ts';
const input = process.argv[2];
if (!input) throw new Error('Usage: node --experimental-strip-types scripts/update-mitre-asset.mts <official Enterprise ATT&CK STIX JSON>');
const graph = convertEnterpriseStix(JSON.parse(await readFile(input, 'utf8')));
const output = resolve(`src/assets/data/mitre-enterprise-v${graph.version}.json`);
await writeFile(output, JSON.stringify(graph) + '\n');
console.log(`${output}: ${graph.tacticOrder.length} tactics, ${graph.techniques.length} techniques, ${graph.entities.length} entities`);

View File

@ -0,0 +1,15 @@
import { readFile, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { attackReferenceCatalog, d3fendReferenceCatalog } from '../src/references/catalog-converters.ts';
const [attackPath, d3fendPath] = process.argv.slice(2);
if (!attackPath || !d3fendPath) throw new Error('Usage: node --experimental-strip-types scripts/update-reference-assets.mts <Enterprise STIX> <D3FEND JSON-LD>');
for (const [path, convert, sourceUrl, name] of [
[attackPath, attackReferenceCatalog, 'https://raw.githubusercontent.com/mitre-attack/attack-stix-data/master/enterprise-attack/enterprise-attack-19.2.json', 'attack-reference'],
[d3fendPath, d3fendReferenceCatalog, 'https://d3fend.mitre.org/ontologies/d3fend/1.6.0/d3fend.json', 'd3fend-reference']
] as const) {
const bytes = await readFile(path);
const catalog = convert(JSON.parse(bytes.toString('utf8')), sourceUrl, createHash('sha256').update(bytes).digest('hex'));
const output = `src/assets/data/${name}-v${catalog.version}.json`;
await writeFile(output, JSON.stringify(catalog) + '\n');
console.log(`${output}: ${catalog.entries.length} entries`);
}

View File

@ -45,11 +45,16 @@ import type { GpoAutomaticAnalysisService } from './gpo/gpo-automatic-analysis-s
import type { MicrosoftSecurityBaselineSelectionContext } from '../src/domain/microsoft-security-baseline'; import type { MicrosoftSecurityBaselineSelectionContext } from '../src/domain/microsoft-security-baseline';
import { listProjectGpoBackupStatus } from './gpo/gpo-backup-status-service'; import { listProjectGpoBackupStatus } from './gpo/gpo-backup-status-service';
import type { BackendArtifactStore } from './artifacts/backend-artifact-store'; import type { BackendArtifactStore } from './artifacts/backend-artifact-store';
import { MitreCatalogError, type MitreCatalogService } from './reference-data/mitre-catalog-service';
import { ReferenceRecognitionService, deriveReferenceRun } from './reference-data/reference-recognition-service';
import { directImportExecutor, type ImportExecutor } from './import-executor'; import { directImportExecutor, type ImportExecutor } from './import-executor';
import { createCanonicalImportBatch } from '../src/importers/canonical-import'; import { createCanonicalImportBatch } from '../src/importers/canonical-import';
import { processingScopeIssues } from '../src/domain/processing-compatibility'; import { processingScopeIssues } from '../src/domain/processing-compatibility';
import { ASSESSOR_PRODUCT_VERSION } from '../src/domain/assessor-release'; import { ASSESSOR_PRODUCT_VERSION } from '../src/domain/assessor-release';
import { assembleReportProjectionInput } from '../src/features/reports/report-input-assembler'; import { assembleReportProjectionInput } from '../src/features/reports/report-input-assembler';
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';
import { buildReportProjection } from '../src/domain/report-projection'; import { buildReportProjection } from '../src/domain/report-projection';
import { compareReimportSourceHashes } from '../src/domain/reimport-source'; import { compareReimportSourceHashes } from '../src/domain/reimport-source';
import { import {
@ -136,6 +141,7 @@ export interface AuditEvent {
export interface AssessorHttpServerOptions { export interface AssessorHttpServerOptions {
artifactStore?: BackendArtifactStore; artifactStore?: BackendArtifactStore;
mitreCatalogService?: MitreCatalogService;
repository: AssessorRepository; repository: AssessorRepository;
staticRoot: string; staticRoot: string;
releaseId?: string; releaseId?: string;
@ -377,6 +383,7 @@ function importFailureMessage(error: unknown): string {
} }
function statusForError(error: unknown): { status: number; code: string; message: string } { function statusForError(error: unknown): { status: number; code: string; message: string } {
if (error instanceof HttpError) return error; if (error instanceof HttpError) return error;
if (error instanceof MitreCatalogError) return { status: 502, code: error.code, message: error.message };
if (error instanceof ImportError) return { status: 422, code: error.diagnostics[0]?.code ?? 'import_rejected', message: error.message }; if (error instanceof ImportError) return { status: 422, code: error.diagnostics[0]?.code ?? 'import_rejected', message: error.message };
if (error instanceof RepositoryNotFoundError) return { status: 404, code: 'repository_not_found', message: error.message }; if (error instanceof RepositoryNotFoundError) return { status: 404, code: 'repository_not_found', message: error.message };
if (error instanceof RepositoryConflictError || error instanceof ImportSessionStateError) return { status: 409, code: 'repository_conflict', message: error.message }; if (error instanceof RepositoryConflictError || error instanceof ImportSessionStateError) return { status: 409, code: 'repository_conflict', message: error.message };
@ -417,12 +424,15 @@ async function serveStatic(request: IncomingMessage, response: ServerResponse, s
export function createAssessorHttpServer(options: AssessorHttpServerOptions): Server { export function createAssessorHttpServer(options: AssessorHttpServerOptions): Server {
const repository = options.repository; const repository = options.repository;
const referenceRecognition = new ReferenceRecognitionService(repository, options.mitreCatalogService
? () => options.mitreCatalogService!.recognitionCatalogs() : undefined);
const maximumBodyBytes = options.maximumBodyBytes ?? DEFAULT_MAXIMUM_BODY_BYTES; const maximumBodyBytes = options.maximumBodyBytes ?? DEFAULT_MAXIMUM_BODY_BYTES;
const now = options.now ?? (() => new Date()); const now = options.now ?? (() => new Date());
const importExecutor = options.importExecutor ?? directImportExecutor; const importExecutor = options.importExecutor ?? directImportExecutor;
const previewTtlMs = options.previewTtlMs ?? DEFAULT_PREVIEW_TTL_MS; const previewTtlMs = options.previewTtlMs ?? DEFAULT_PREVIEW_TTL_MS;
const importSessions = new Map<string, ImportSession>(); const importSessions = new Map<string, ImportSession>();
const importBatchPreviews = new Map<string, CachedImportBatchPreview>(); const importBatchPreviews = new Map<string, CachedImportBatchPreview>();
const timeslotMetricsCache = new Map<string, Promise<DatahubTimeslotResultMetrics | null>>();
const auditEvents: AuditEvent[] = []; const auditEvents: AuditEvent[] = [];
let requestSequence = 0; let requestSequence = 0;
@ -451,6 +461,35 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
}; };
status = 200; sendJson(response, status, result, requestId); return; status = 200; sendJson(response, status, result, requestId); return;
} }
if (segments.join('/') === 'reference-data/mitre-attack' && method === 'GET') {
if (!options.mitreCatalogService) throw new HttpError(503, 'mitre_catalog_unavailable', 'Der MITRE-Katalog ist nicht konfiguriert.');
status = 200; sendJson(response, status, await options.mitreCatalogService.current(), requestId); return;
}
if (segments[0] === 'projects' && segments[1] && segments[2] === 'references' && segments.length === 3 && method === 'GET') {
status = 200; sendJson(response, status, await referenceRecognition.status(segments[1]), requestId); return;
}
if (segments[0] === 'projects' && segments[1] && segments[2] === 'references' && segments[3] === 'recognize' && segments.length === 4 && method === 'POST') {
await readJson(request, maximumBodyBytes);
status = 200; sendJson(response, status, await referenceRecognition.reprocess(segments[1]), requestId); return;
}
if (segments.join('/') === 'reference-data/mitre-attack/refresh' && method === 'POST') {
if (!options.mitreCatalogService) throw new HttpError(503, 'mitre_catalog_unavailable', 'Der MITRE-Katalog ist nicht konfiguriert.');
await readJson<Record<string, never>>(request, maximumBodyBytes);
status = 200; sendJson(response, status, await options.mitreCatalogService.refresh(), requestId); return;
}
if (segments.join('/') === 'reference-data/mitre-attack/source' && method === 'GET') {
if (!options.mitreCatalogService) throw new HttpError(503, 'mitre_catalog_unavailable', 'Der MITRE-Katalog ist nicht konfiguriert.');
const source = await options.mitreCatalogService.officialSource();
status = 200;
response.statusCode = status;
response.setHeader('Content-Type', 'application/json; charset=utf-8');
response.setHeader('Content-Disposition', `attachment; filename="enterprise-attack-v${source.catalog.version}.json"`);
response.setHeader('Content-Length', source.bytes.byteLength);
response.setHeader('Cache-Control', 'no-store');
response.setHeader('X-Assessor-Request-Id', requestId);
response.end(source.bytes);
return;
}
if (method === 'GET' && segments.length === 1 && segments[0] === 'audit-events') { if (method === 'GET' && segments.length === 1 && segments[0] === 'audit-events') {
status = 200; sendJson(response, status, auditEvents.slice().reverse(), requestId); return; status = 200; sendJson(response, status, auditEvents.slice().reverse(), requestId); return;
} }
@ -645,7 +684,9 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
if (!body.replacesScanRunId && processingScopeIssues([...processingFiles, ...canonical.model.importFiles]).length) { if (!body.replacesScanRunId && processingScopeIssues([...processingFiles, ...canonical.model.importFiles]).length) {
throw new HttpError(409, 'incompatible_processing_versions', 'Der gewählte Timeslot enthält unbekannte oder semantisch inkompatible Verarbeitungsstände. Bitte alle betroffenen Dateien mit dem aktuellen Profil in einen neuen Timeslot reimportieren.'); throw new HttpError(409, 'incompatible_processing_versions', 'Der gewählte Timeslot enthält unbekannte oder semantisch inkompatible Verarbeitungsstände. Bitte alle betroffenen Dateien mit dem aktuellen Profil in einen neuen Timeslot reimportieren.');
} }
const committed = await repository.commitCanonicalImport({ ...canonical, replacesScanRunId: body.replacesScanRunId }); const referenceCatalogs = await referenceRecognition.catalogs();
canonical.model.referenceRuns = canonical.model.scanRuns.map(scan => deriveReferenceRun(canonical.model, scan.id, referenceCatalogs, importedAt));
const committed = await repository.commitCanonicalImport({ ...canonical, referenceCatalogs, replacesScanRunId: body.replacesScanRunId });
const fingerprints = await repository.findImportFingerprints(body.entries.map(entry => entry.sha256)); const fingerprints = await repository.findImportFingerprints(body.entries.map(entry => entry.sha256));
importBatchPreviews.delete(body.batchId); importBatchPreviews.delete(body.batchId);
const result: ImportBatchCompleteResponse = { const result: ImportBatchCompleteResponse = {
@ -1005,6 +1046,39 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
}); });
status = 200; sendJson(response, status, buildReportProjection(input), requestId); return; status = 200; sendJson(response, status, buildReportProjection(input), requestId); return;
} }
if (segments[0] === 'projects' && segments[2] === 'timeslot-result-metrics' && segments.length === 3 && method === 'GET') {
if (!await repository.getProject(segments[1])) throw new RepositoryNotFoundError(`Projekt ${segments[1]} ist nicht vorhanden.`);
const assessmentIds = url.searchParams.getAll('assessmentIds');
const scanRunIds = url.searchParams.getAll('scanRunIds');
if (!assessmentIds.length) throw new HttpError(400, 'missing_assessment_scope', 'At least one assessment ID is required.');
const cacheKey = JSON.stringify([repository.revision, segments[1], [...assessmentIds].sort(), [...scanRunIds].sort()]);
let metrics = timeslotMetricsCache.get(cacheKey);
if (!metrics) {
metrics = (async () => {
const { dataset } = await loadCanonicalProjectWorkspace({
listProjectAssessments: projectId => repository.listAssessments(projectId),
loadAssessmentModel: async assessmentId => {
const model = await repository.loadAssessmentModel(assessmentId);
if (!model) throw new RepositoryNotFoundError(`Assessment ${assessmentId} ist nicht vorhanden.`);
return model;
},
queryUnifiedResults: query => repository.queryUnifiedResults(query),
listPolicyEvaluations: async projectId => ({ records: await repository.listPolicyEvaluations(projectId) }),
listCurrentPolicyEvaluationsForMetrics: repository.listCurrentPolicyEvaluationsForMetrics
? async projectId => ({ records: await repository.listCurrentPolicyEvaluationsForMetrics!(projectId) }) : undefined,
getPolicyAnalysisOverview: options.gpoPolicyAnalysisOverviewService
? projectId => options.gpoPolicyAnalysisOverviewService!.get(projectId) : undefined
}, segments[1], scanRunIds.length ? scanRunIds : undefined, assessmentIds,
{ includeHistoricalScores: false, includeBaselineGuidance: false, summaryOnly: true });
const sheet = dataset?.sheets.find(candidate => candidate.isProjectSheet) ?? dataset?.sheets[0];
return sheet ? summarizeWorkspaceResults(sheet.analysis.records, profileForSheet(dataset, sheet)) : null;
})();
if (timeslotMetricsCache.size >= 24) timeslotMetricsCache.clear();
timeslotMetricsCache.set(cacheKey, metrics);
void metrics.catch(() => timeslotMetricsCache.delete(cacheKey));
}
status = 200; sendJson(response, status, await metrics, requestId); return;
}
if (segments.length === 1 && segments[0] === 'assessments' && method === 'POST') { if (segments.length === 1 && segments[0] === 'assessments' && method === 'POST') {
const assessment = await readJson<Assessment>(request, maximumBodyBytes); const assessment = await readJson<Assessment>(request, maximumBodyBytes);
await repository.saveAssessment(assessment); await repository.saveAssessment(assessment);

View File

@ -252,6 +252,7 @@ export function ingestGpoBackupContainer(bytes: Uint8Array, options: GpoBackupIn
collection: { status: 'observed', collectedAt: options.discoveredAt, evidence: 'backend-receipt' }, collection: { status: 'observed', collectedAt: options.discoveredAt, evidence: 'backend-receipt' },
backupSet: { id: 'gpo-backup-set:' + options.sourceArtifactId, scope: 'selected-gpos', evidence: 'user-selection' }, backupSet: { id: 'gpo-backup-set:' + options.sourceArtifactId, scope: 'selected-gpos', evidence: 'user-selection' },
backupCreatedAt: aggregate.status === 'invalid' ? undefined : aggregate.backupCreatedAt, backupCreatedAt: aggregate.status === 'invalid' ? undefined : aggregate.backupCreatedAt,
...(aggregate.links ? { links: { ...aggregate.links, evidence: 'gp-report' as const } } : {}),
metadataStatus: aggregate.status === 'invalid' ? 'unreadable' : aggregate.status, metadataStatus: aggregate.status === 'invalid' ? 'unreadable' : aggregate.status,
sections: { sections: {
computer: entries.some(entry => entry.scope === 'computer' && entry.role !== 'backup-metadata' && entry.disposition !== 'blocked') computer: entries.some(entry => entry.scope === 'computer' && entry.role !== 'backup-metadata' && entry.disposition !== 'blocked')

View File

@ -9,7 +9,7 @@ import type { BackendArtifactStore } from '../artifacts/backend-artifact-store';
import { GPO_BACKUP_ANALYZER_VERSION } from './gpo-backup-submission-service'; import { GPO_BACKUP_ANALYZER_VERSION } from './gpo-backup-submission-service';
import type { GpoBackupWorkerExecutor } from './gpo-inventory-executor'; import type { GpoBackupWorkerExecutor } from './gpo-inventory-executor';
export const GPO_BACKUP_JOB_PROCESSOR_VERSION = '2.2.4'; export const GPO_BACKUP_JOB_PROCESSOR_VERSION = '2.2.5';
export interface GpoBackupJobProcessorOptions { export interface GpoBackupJobProcessorOptions {
leaseDurationMs?: number; leaseDurationMs?: number;

View File

@ -79,7 +79,10 @@ export async function listProjectGpoBackupStatus(
for (const artifact of artifacts.filter(item => item.kind === 'source-file' && item.relativePath.startsWith('gpo-backups/'))) { for (const artifact of artifacts.filter(item => item.kind === 'source-file' && item.relativePath.startsWith('gpo-backups/'))) {
const job = latestByUpdatedAt(jobs.filter(item => item.kind === 'analyze-gpo-backup' && item.artifactId === artifact.id)); const job = latestByUpdatedAt(jobs.filter(item => item.kind === 'analyze-gpo-backup' && item.artifactId === artifact.id));
const run = latestRun(runs.filter(item => item.artifactId === artifact.id)); const run = latestRun(runs.filter(item => item.artifactId === artifact.id));
const gpoCount = run ? instances.filter(item => item.processingRunId === run.id).length : 0; const runInstances = run ? instances.filter(item => item.processingRunId === run.id) : [];
const gpoCount = runInstances.length;
// Only backups processed with retained link facts can be counted; older ones stay unknown.
const linkFacts = runInstances.filter(item => item.instance.links);
// Availability changes independently of the repository revision; do not cache it. // Availability changes independently of the repository revision; do not cache it.
const sourceAvailability = await gpoSourceAvailability(artifact, artifactStore); const sourceAvailability = await gpoSourceAvailability(artifact, artifactStore);
records.push({ records.push({
@ -102,7 +105,8 @@ export async function listProjectGpoBackupStatus(
processingStatus: run?.status, processingStatus: run?.status,
completedAt: run?.completedAt, completedAt: run?.completedAt,
gpoCount, gpoCount,
backupInstanceCount: gpoCount backupInstanceCount: gpoCount,
...(linkFacts.length ? { notLinkedGpoCount: linkFacts.filter(item => item.instance.links!.status === 'not-linked').length } : {})
}); });
} }
records.sort((left, right) => Date.parse(right.submittedAt) - Date.parse(left.submittedAt) || right.artifactId.localeCompare(left.artifactId)); records.sort((left, right) => Date.parse(right.submittedAt) - Date.parse(left.submittedAt) || right.artifactId.localeCompare(left.artifactId));

View File

@ -7,7 +7,7 @@ import type { BackendArtifactStore } from '../artifacts/backend-artifact-store';
import type { GpoInventoryExecutor } from './gpo-inventory-executor'; import type { GpoInventoryExecutor } from './gpo-inventory-executor';
import { gpoSourceAvailability, requireGpoSource } from './gpo-source-availability'; import { gpoSourceAvailability, requireGpoSource } from './gpo-source-availability';
export const GPO_BACKUP_ANALYZER_VERSION = 'gpo-backup-policy-analysis:2.2.4'; export const GPO_BACKUP_ANALYZER_VERSION = 'gpo-backup-policy-analysis:2.2.5';
export interface SubmitGpoBackupRequest { export interface SubmitGpoBackupRequest {
restoreArtifactId?: string; restoreArtifactId?: string;

View File

@ -5,7 +5,9 @@ import { resolveImportResourcePolicy, validateDecodedTextResources } from '../..
import { ImportError } from '../../src/shared/errors'; import { ImportError } from '../../src/shared/errors';
import type { VerifiedZipFile } from './zip-inventory'; import type { VerifiedZipFile } from './zip-inventory';
const parser = { id: 'gpo-backup-metadata-xml', version: '1.2.0' } as const; // 1.4.0 retains GPReport.xml link counts (user decision 2026-09-25). The template-name
// variant used by the Microsoft baseline analyzer keeps its own 1.3.0 contract.
const parser = { id: 'gpo-backup-metadata-xml', version: '1.4.0' } as const;
const maximumMetadataBytes = 64 * 1024 * 1024; const maximumMetadataBytes = 64 * 1024 * 1024;
const guidPattern = /^\{?([a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})\}?$/i; const guidPattern = /^\{?([a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})\}?$/i;
const dnsPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i; const dnsPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
@ -103,6 +105,10 @@ export function parseGpoBackupMetadataXml(
let secretIndicatorStatus: GpoBackupMetadataDocument['secretIndicatorStatus'] = 'not-detected'; let secretIndicatorStatus: GpoBackupMetadataDocument['secretIndicatorStatus'] = 'not-detected';
// Retain only bounded identity fields, never the full report tree or secret values. // Retain only bounded identity fields, never the full report tree or secret values.
const stack: Array<{ name: string; field?: string; selected: boolean; nested: boolean; text: string }> = []; const stack: Array<{ name: string; field?: string; selected: boolean; nested: boolean; text: string }> = [];
// Link facts: counts only, never the linked SOM path. Read only from a GPReport root.
let rootName: string | undefined;
const links: Array<{ enabled: boolean }> = [];
let linkEnabledText: string | undefined;
const xml = new SaxesParser({ xmlns: true }); const xml = new SaxesParser({ xmlns: true });
xml.on('error', () => parserError('gpo-metadata-xml-invalid', 'Die GPO-Metadatendatei ist kein wohlgeformtes XML.')); xml.on('error', () => parserError('gpo-metadata-xml-invalid', 'Die GPO-Metadatendatei ist kein wohlgeformtes XML.'));
xml.on('doctype', () => parserError('gpo-metadata-external-reference-rejected', 'DTD ist in GPO-Metadaten nicht zulässig.')); xml.on('doctype', () => parserError('gpo-metadata-external-reference-rejected', 'DTD ist in GPO-Metadaten nicht zulässig.'));
@ -116,6 +122,9 @@ export function parseGpoBackupMetadataXml(
diagnostics.add('gpo-secret-indicator-present'); diagnostics.add('gpo-secret-indicator-present');
} }
const path = [...stack.map(item => item.name), name].join('/'); const path = [...stack.map(item => item.name), name].join('/');
rootName ??= name;
if (!includeTemplateNames && path === 'gpo/linksto') links.push({ enabled: true });
if (!includeTemplateNames && path === 'gpo/linksto/enabled') linkEnabledText = '';
const core = 'grouppolicybackupscheme/grouppolicyobject/grouppolicycoresettings/'; const core = 'grouppolicybackupscheme/grouppolicyobject/grouppolicycoresettings/';
const templateName = includeTemplateNames && [core + 'displayname', 'gpo/name', const templateName = includeTemplateNames && [core + 'displayname', 'gpo/name',
'backupinst/gpodisplayname', 'backupinst/gponame', 'backupinst/displayname'].includes(path); 'backupinst/gpodisplayname', 'backupinst/gponame', 'backupinst/displayname'].includes(path);
@ -126,6 +135,7 @@ export function parseGpoBackupMetadataXml(
stack.push({ name, field, selected: field !== undefined, nested: false, text: '' }); stack.push({ name, field, selected: field !== undefined, nested: false, text: '' });
}); });
const onText = (value: string) => { const onText = (value: string) => {
if (linkEnabledText !== undefined) linkEnabledText = (linkEnabledText + value).slice(0, 16);
const current = stack[stack.length - 1]; const current = stack[stack.length - 1];
if (current?.selected) current.text = (current.text + value).slice(0, 2049); if (current?.selected) current.text = (current.text + value).slice(0, 2049);
}; };
@ -133,6 +143,11 @@ export function parseGpoBackupMetadataXml(
xml.on('cdata', onText); xml.on('cdata', onText);
xml.on('closetag', () => { xml.on('closetag', () => {
const current = stack.pop()!; const current = stack.pop()!;
if (current.name === 'enabled' && linkEnabledText !== undefined) {
// Only an explicit false disables a link; an unreadable value keeps the link counted as enabled.
if (links.length && linkEnabledText.trim().toLocaleLowerCase('en-US') === 'false') links[links.length - 1].enabled = false;
linkEnabledText = undefined;
}
if (!current.selected) return; if (!current.selected) return;
const value = current.text.trim(); const value = current.text.trim();
if (!value || current.text.length > 2048 || current.nested) { if (!value || current.text.length > 2048 || current.nested) {
@ -158,6 +173,11 @@ export function parseGpoBackupMetadataXml(
version: 1, version: 1,
parser: includeTemplateNames ? { ...parser, version: '1.3.0' } : parser, parser: includeTemplateNames ? { ...parser, version: '1.3.0' } : parser,
...(includeTemplateNames ? { templateNames: [...new Set(values.get('templatename') ?? [])].sort() } : {}), ...(includeTemplateNames ? { templateNames: [...new Set(values.get('templatename') ?? [])].sort() } : {}),
...(!includeTemplateNames && rootName === 'gpo' ? { links: {
status: links.some(link => link.enabled) ? 'linked' as const : 'not-linked' as const,
linkCount: links.length,
enabledLinkCount: links.filter(link => link.enabled).length
} } : {}),
sourcePath: file.entry.relativePath, sourcePath: file.entry.relativePath,
sourceSha256: file.entry.sha256, sourceSha256: file.entry.sha256,
status: invalid ? 'invalid' : gpoId && backupId && domainKey && backupCreatedAt ? 'complete' : 'partial', status: invalid ? 'invalid' : gpoId && backupId && domainKey && backupCreatedAt ? 'complete' : 'partial',

View File

@ -24,6 +24,7 @@ import { AiOverviewService } from './ai/ai-overview-service';
import { createRegulatoryRagRuntime } from './regulatory/regulatory-rag-runtime'; import { createRegulatoryRagRuntime } from './regulatory/regulatory-rag-runtime';
import { RegulatoryMappingService } from './regulatory/regulatory-mapping-service'; import { RegulatoryMappingService } from './regulatory/regulatory-mapping-service';
import { RegulatoryCorpusUploadService } from './regulatory/regulatory-corpus-upload-service'; import { RegulatoryCorpusUploadService } from './regulatory/regulatory-corpus-upload-service';
import { MitreCatalogService } from './reference-data/mitre-catalog-service';
function boundedInteger(value: string | undefined, fallback: number, minimum: number, maximum: number, name: string): number { function boundedInteger(value: string | undefined, fallback: number, minimum: number, maximum: number, name: string): number {
if (value === undefined || value.trim() === '') return fallback; if (value === undefined || value.trim() === '') return fallback;
@ -106,6 +107,7 @@ async function main(): Promise<void> {
}); });
await regulatoryCorpusUploadService.initialize(); await regulatoryCorpusUploadService.initialize();
const artifactStore = new FileSystemBackendArtifactStore(artifactRoot); const artifactStore = new FileSystemBackendArtifactStore(artifactRoot);
const mitreCatalogService = new MitreCatalogService(join(artifactRoot, 'reference-data', 'mitre-enterprise.json'));
const gpoBackupSubmissionService = new GpoBackupSubmissionService( const gpoBackupSubmissionService = new GpoBackupSubmissionService(
runtime.repository, runtime.repository,
artifactStore, artifactStore,
@ -153,6 +155,7 @@ async function main(): Promise<void> {
); );
const server = createAssessorHttpServer({ const server = createAssessorHttpServer({
artifactStore, artifactStore,
mitreCatalogService,
repository: runtime.repository, repository: runtime.repository,
persistence: runtime.persistence, persistence: runtime.persistence,
staticRoot, staticRoot,

View File

@ -26,6 +26,7 @@ import regulatoryMappingBatches from './migrations/027_regulatory_mapping_batche
import scopedRiskAcceptances from './migrations/028_scoped_risk_acceptances.sql?raw'; import scopedRiskAcceptances from './migrations/028_scoped_risk_acceptances.sql?raw';
import nativePolicyTargets from './migrations/019_native_policy_targets.sql?raw'; import nativePolicyTargets from './migrations/019_native_policy_targets.sql?raw';
import baselineDrivenPolicyEvaluation from './migrations/020_baseline_driven_policy_evaluation.sql?raw'; import baselineDrivenPolicyEvaluation from './migrations/020_baseline_driven_policy_evaluation.sql?raw';
import referenceRecognition from './migrations/029_reference_recognition.sql?raw';
export interface DatabaseMigration { export interface DatabaseMigration {
version: string; version: string;
@ -61,5 +62,6 @@ export const databaseMigrations: readonly DatabaseMigration[] = [
{ version: '025', name: 'regulatory-mapping', sql: regulatoryMapping }, { version: '025', name: 'regulatory-mapping', sql: regulatoryMapping },
{ version: '026', name: 'global-regulatory-mapping', sql: globalRegulatoryMapping }, { version: '026', name: 'global-regulatory-mapping', sql: globalRegulatoryMapping },
{ version: '027', name: 'regulatory-mapping-batches', sql: regulatoryMappingBatches }, { version: '027', name: 'regulatory-mapping-batches', sql: regulatoryMappingBatches },
{ version: '028', name: 'scoped-risk-acceptances', sql: scopedRiskAcceptances } { version: '028', name: 'scoped-risk-acceptances', sql: scopedRiskAcceptances },
{ version: '029', name: 'reference-recognition', sql: referenceRecognition }
]; ];

View File

@ -0,0 +1,21 @@
-- Renumbered from 023 on experimental: 021-028 are taken by CVE, AI overview and regulatory migrations.
-- Immutable public catalog snapshots; client runs reference their exact content hash.
CREATE TABLE assessor_reference_catalogs (
sha256 text PRIMARY KEY CHECK (sha256 ~ '^[a-f0-9]{64}$'),
data jsonb NOT NULL CHECK (jsonb_typeof(data) = 'object')
);
CREATE TABLE assessor_reference_runs (
id text PRIMARY KEY CHECK (btrim(id) <> ''),
project_id text NOT NULL REFERENCES assessor_projects(id) ON DELETE CASCADE,
assessment_id text NOT NULL REFERENCES assessor_assessments(id) ON DELETE CASCADE,
scan_run_id text NOT NULL REFERENCES assessor_scan_runs(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL,
data jsonb NOT NULL CHECK (jsonb_typeof(data) = 'object')
);
CREATE INDEX assessor_reference_runs_scan_idx ON assessor_reference_runs(scan_run_id, created_at DESC, id DESC);
CREATE INDEX assessor_reference_runs_project_idx ON assessor_reference_runs(project_id);
CREATE TABLE assessor_reference_run_catalogs (
run_id text NOT NULL REFERENCES assessor_reference_runs(id) ON DELETE CASCADE,
catalog_sha256 text NOT NULL REFERENCES assessor_reference_catalogs(sha256),
PRIMARY KEY (run_id, catalog_sha256)
);

View File

@ -40,6 +40,9 @@ import type { RegulatoryMappingBatchRun, RegulatoryMappingDecision, RegulatoryMa
import { regulatoryMappingBatchRunValidationErrors, regulatoryMappingDecisionValidationErrors, regulatoryMappingRunValidationErrors } from '../../src/domain/regulatory-mapping'; import { regulatoryMappingBatchRunValidationErrors, regulatoryMappingDecisionValidationErrors, regulatoryMappingRunValidationErrors } from '../../src/domain/regulatory-mapping';
import { cveEnrichmentValidationErrors, cveIdFromRuleId, normalizeCveId } from '../../src/domain/cve-enrichment'; import { cveEnrichmentValidationErrors, cveIdFromRuleId, normalizeCveId } from '../../src/domain/cve-enrichment';
import { storedPolicyFindingRunValidationErrors } from '../../src/domain/policy-finding'; import { storedPolicyFindingRunValidationErrors } from '../../src/domain/policy-finding';
import { latestReferenceRuns, type ReferenceRecognitionRun, type ReferenceCatalogSnapshot } from '../../src/domain/reference-recognition';
import { validateReferenceRun } from '../../src/persistence/reference-run-validation';
import { referenceHash } from '../reference-data/reference-catalogs';
import { import {
environmentProfileValidationErrors, environmentProfileValidationErrors,
storedPolicySnapshotAggregationValidationErrors storedPolicySnapshotAggregationValidationErrors
@ -519,7 +522,7 @@ export class PostgresAssessorRepository implements AssessorRepository {
}); });
} }
private async assessmentModel(queryable: Queryable, assessmentId: string): Promise<CanonicalAssessmentModel | null> { private async assessmentModel(queryable: Queryable, assessmentId: string, includeSuperseded = false): Promise<CanonicalAssessmentModel | null> {
const assessment = dataRow(await queryable.query<DataRow<Assessment>>( const assessment = dataRow(await queryable.query<DataRow<Assessment>>(
'SELECT data FROM assessor_assessments WHERE id = $1', [assessmentId] 'SELECT data FROM assessor_assessments WHERE id = $1', [assessmentId]
)); ));
@ -530,31 +533,31 @@ export class PostgresAssessorRepository implements AssessorRepository {
if (!project) return null; if (!project) return null;
const runs = await queryable.query<DataRow<ScanRun>>( const runs = await queryable.query<DataRow<ScanRun>>(
`SELECT data FROM assessor_scan_runs `SELECT data FROM assessor_scan_runs
WHERE assessment_id = $1 AND data->>'supersededByScanRunId' IS NULL ORDER BY id`, [assessmentId] WHERE assessment_id = $1 AND ($2::boolean OR data->>'supersededByScanRunId' IS NULL) ORDER BY id`, [assessmentId, includeSuperseded]
); );
const files = await queryable.query<DataRow<ImportFileRecord>>( const files = await queryable.query<DataRow<ImportFileRecord>>(
`SELECT f.data FROM assessor_import_files f JOIN assessor_scan_runs r ON r.id = f.scan_run_id `SELECT f.data FROM assessor_import_files f JOIN assessor_scan_runs r ON r.id = f.scan_run_id
WHERE r.assessment_id = $1 AND r.data->>'supersededByScanRunId' IS NULL ORDER BY f.id`, [assessmentId] WHERE r.assessment_id = $1 AND ($2::boolean OR r.data->>'supersededByScanRunId' IS NULL) ORDER BY f.id`, [assessmentId, includeSuperseded]
); );
const tables = await queryable.query<DataRow<SourceTableRecord>>( const tables = await queryable.query<DataRow<SourceTableRecord>>(
`SELECT t.data FROM assessor_source_tables t JOIN assessor_import_files f ON f.id = t.import_file_id JOIN assessor_scan_runs r ON r.id = f.scan_run_id `SELECT t.data FROM assessor_source_tables t JOIN assessor_import_files f ON f.id = t.import_file_id JOIN assessor_scan_runs r ON r.id = f.scan_run_id
WHERE r.assessment_id = $1 AND r.data->>'supersededByScanRunId' IS NULL ORDER BY t.id`, [assessmentId] WHERE r.assessment_id = $1 AND ($2::boolean OR r.data->>'supersededByScanRunId' IS NULL) ORDER BY t.id`, [assessmentId, includeSuperseded]
); );
const reportSections = await queryable.query<DataRow<ScannerReportSection>>( const reportSections = await queryable.query<DataRow<ScannerReportSection>>(
`SELECT s.data FROM assessor_scanner_report_sections s JOIN assessor_scan_runs r ON r.id = s.scan_run_id `SELECT s.data FROM assessor_scanner_report_sections s JOIN assessor_scan_runs r ON r.id = s.scan_run_id
WHERE r.assessment_id = $1 AND r.data->>'supersededByScanRunId' IS NULL ORDER BY s.ordinal, s.id`, [assessmentId] WHERE r.assessment_id = $1 AND ($2::boolean OR r.data->>'supersededByScanRunId' IS NULL) ORDER BY s.ordinal, s.id`, [assessmentId, includeSuperseded]
); );
const environmentObservations = await queryable.query<DataRow<EnvironmentObservation>>( const environmentObservations = await queryable.query<DataRow<EnvironmentObservation>>(
`SELECT o.data FROM assessor_environment_observations o JOIN assessor_scan_runs r ON r.id = o.scan_run_id `SELECT o.data FROM assessor_environment_observations o JOIN assessor_scan_runs r ON r.id = o.scan_run_id
WHERE r.assessment_id = $1 AND r.data->>'supersededByScanRunId' IS NULL ORDER BY o.property_key, o.id`, [assessmentId] WHERE r.assessment_id = $1 AND ($2::boolean OR r.data->>'supersededByScanRunId' IS NULL) ORDER BY o.property_key, o.id`, [assessmentId, includeSuperseded]
); );
const definitions = await queryable.query<DataRow<FindingDefinition>>( const definitions = await queryable.query<DataRow<FindingDefinition>>(
`SELECT DISTINCT d.data, d.id FROM assessor_finding_definitions d JOIN assessor_finding_occurrences o ON o.definition_id = d.id JOIN assessor_scan_runs r ON r.id = o.scan_run_id `SELECT DISTINCT d.data, d.id FROM assessor_finding_definitions d JOIN assessor_finding_occurrences o ON o.definition_id = d.id JOIN assessor_scan_runs r ON r.id = o.scan_run_id
WHERE r.assessment_id = $1 AND r.data->>'supersededByScanRunId' IS NULL ORDER BY d.id`, [assessmentId] WHERE r.assessment_id = $1 AND ($2::boolean OR r.data->>'supersededByScanRunId' IS NULL) ORDER BY d.id`, [assessmentId, includeSuperseded]
); );
const occurrences = await queryable.query<DataRow<FindingOccurrence>>( const occurrences = await queryable.query<DataRow<FindingOccurrence>>(
`SELECT o.data FROM assessor_finding_occurrences o JOIN assessor_scan_runs r ON r.id = o.scan_run_id `SELECT o.data FROM assessor_finding_occurrences o JOIN assessor_scan_runs r ON r.id = o.scan_run_id
WHERE r.assessment_id = $1 AND r.data->>'supersededByScanRunId' IS NULL ORDER BY o.id`, [assessmentId] WHERE r.assessment_id = $1 AND ($2::boolean OR r.data->>'supersededByScanRunId' IS NULL) ORDER BY o.id`, [assessmentId, includeSuperseded]
); );
const occurrenceRows = dataRows(occurrences); const occurrenceRows = dataRows(occurrences);
const cveIds = [...new Set(occurrenceRows.flatMap(occurrence => { const cveIds = [...new Set(occurrenceRows.flatMap(occurrence => {
@ -568,21 +571,24 @@ export class PostgresAssessorRepository implements AssessorRepository {
: null; : null;
const evidence = await queryable.query<DataRow<EvidenceRecord>>( const evidence = await queryable.query<DataRow<EvidenceRecord>>(
`SELECT e.data FROM assessor_evidence e JOIN assessor_finding_occurrences o ON o.id = e.occurrence_id JOIN assessor_scan_runs r ON r.id = o.scan_run_id `SELECT e.data FROM assessor_evidence e JOIN assessor_finding_occurrences o ON o.id = e.occurrence_id JOIN assessor_scan_runs r ON r.id = o.scan_run_id
WHERE r.assessment_id = $1 AND r.data->>'supersededByScanRunId' IS NULL ORDER BY e.id`, [assessmentId] WHERE r.assessment_id = $1 AND ($2::boolean OR r.data->>'supersededByScanRunId' IS NULL) ORDER BY e.id`, [assessmentId, includeSuperseded]
); );
const reviews = await queryable.query<DataRow<FindingReview>>(` const reviews = await queryable.query<DataRow<FindingReview>>(`
SELECT DISTINCT rv.data, rv.id FROM assessor_reviews rv SELECT DISTINCT rv.data, rv.id FROM assessor_reviews rv
WHERE EXISTS ( WHERE EXISTS (
SELECT 1 FROM assessor_finding_occurrences o JOIN assessor_scan_runs r ON r.id = o.scan_run_id SELECT 1 FROM assessor_finding_occurrences o JOIN assessor_scan_runs r ON r.id = o.scan_run_id
WHERE r.assessment_id = $1 AND r.data->>'supersededByScanRunId' IS NULL AND (rv.occurrence_id = o.id OR rv.definition_id = o.definition_id) WHERE r.assessment_id = $1 AND ($2::boolean OR r.data->>'supersededByScanRunId' IS NULL) AND (rv.occurrence_id = o.id OR rv.definition_id = o.definition_id)
) ORDER BY rv.id ) ORDER BY rv.id
`, [assessmentId]); `, [assessmentId, includeSuperseded]);
const diagnostics = await queryable.query<DataRow<ImportDiagnostic>>( const diagnostics = await queryable.query<DataRow<ImportDiagnostic>>(
`SELECT d.data FROM assessor_import_diagnostics d JOIN assessor_import_files f ON f.id = d.import_file_id JOIN assessor_scan_runs r ON r.id = f.scan_run_id `SELECT d.data FROM assessor_import_diagnostics d JOIN assessor_import_files f ON f.id = d.import_file_id JOIN assessor_scan_runs r ON r.id = f.scan_run_id
WHERE r.assessment_id = $1 AND r.data->>'supersededByScanRunId' IS NULL ORDER BY d.id`, [assessmentId] WHERE r.assessment_id = $1 AND ($2::boolean OR r.data->>'supersededByScanRunId' IS NULL) ORDER BY d.id`, [assessmentId, includeSuperseded]
); );
return { return {
schemaVersion: 1, project, assessment, schemaVersion: 1, project, assessment,
referenceRuns: latestReferenceRuns(dataRows(await queryable.query<DataRow<ReferenceRecognitionRun>>(
'SELECT DISTINCT ON (scan_run_id) data FROM assessor_reference_runs WHERE assessment_id = $1 ORDER BY scan_run_id, created_at DESC, id DESC', [assessmentId]
))).filter(run => dataRows(runs).some(scan => scan.id === run.scanRunId)),
scanRuns: dataRows(runs), importFiles: dataRows(files), sourceTables: dataRows(tables), scanRuns: dataRows(runs), importFiles: dataRows(files), sourceTables: dataRows(tables),
reportSections: dataRows(reportSections), environmentObservations: dataRows(environmentObservations), reportSections: dataRows(reportSections), environmentObservations: dataRows(environmentObservations),
findingDefinitions: dataRows(definitions), findingOccurrences: occurrenceRows, findingDefinitions: dataRows(definitions), findingOccurrences: occurrenceRows,
@ -840,6 +846,40 @@ export class PostgresAssessorRepository implements AssessorRepository {
`, [ruleKey])); `, [ruleKey]));
} }
private async persistReferenceRun(client: PoolClient, run: ReferenceRecognitionRun, catalogs: ReferenceCatalogSnapshot[], model: CanonicalAssessmentModel | null): Promise<boolean> {
const existing = dataRow(await client.query<DataRow<ReferenceRecognitionRun>>('SELECT data FROM assessor_reference_runs WHERE id = $1', [run.id]));
if (existing) {
if (canonicalJson(existing) !== canonicalJson(run)) throw new RepositoryConflictError('Referenzläufe sind unveränderlich.');
return false;
}
validateReferenceRun(run, catalogs, model);
for (const catalog of catalogs) {
const { sha256, ...content } = catalog;
if (referenceHash(content) !== sha256) throw new RepositoryValidationError('Katalog-Hash stimmt nicht überein.');
const stored = dataRow(await client.query<DataRow<ReferenceCatalogSnapshot>>('SELECT data FROM assessor_reference_catalogs WHERE sha256 = $1', [sha256]));
if (stored && canonicalJson(stored) !== canonicalJson(catalog)) throw new RepositoryConflictError('Katalog-Snapshot ist unveränderlich.');
if (!stored) await client.query('INSERT INTO assessor_reference_catalogs (sha256, data) VALUES ($1, $2::jsonb)', [sha256, json(catalog)]);
}
await client.query('INSERT INTO assessor_reference_runs (id, project_id, assessment_id, scan_run_id, created_at, data) VALUES ($1, $2, $3, $4, $5, $6::jsonb)',
[run.id, run.projectId, run.assessmentId, run.scanRunId, run.createdAt, json(run)]);
for (const hash of run.catalogHashes) await client.query('INSERT INTO assessor_reference_run_catalogs (run_id, catalog_sha256) VALUES ($1, $2)', [run.id, hash]);
return true;
}
async saveReferenceRun(run: ReferenceRecognitionRun, catalogs: ReferenceCatalogSnapshot[]): Promise<void> {
await this.mutate(async client => this.persistReferenceRun(client, run, catalogs, await this.assessmentModel(client, run.assessmentId)), undefined, Boolean);
}
async listReferenceRuns(projectId: string): Promise<ReferenceRecognitionRun[]> {
return dataRows(await this.pool.query<DataRow<ReferenceRecognitionRun>>('SELECT data FROM assessor_reference_runs WHERE project_id = $1 ORDER BY created_at, id', [projectId]));
}
async listReferenceCatalogs(projectId: string): Promise<ReferenceCatalogSnapshot[]> {
return dataRows(await this.pool.query<DataRow<ReferenceCatalogSnapshot>>(`SELECT c.data FROM assessor_reference_catalogs c
WHERE EXISTS (SELECT 1 FROM assessor_reference_run_catalogs rc JOIN assessor_reference_runs r ON r.id = rc.run_id
WHERE r.project_id = $1 AND rc.catalog_sha256 = c.sha256) ORDER BY c.sha256`, [projectId]));
}
async beginImport(start: ImportSessionStart): Promise<ImportSession> { async beginImport(start: ImportSessionStart): Promise<ImportSession> {
if (!start.sessionId.trim()) throw new RepositoryValidationError('Eine Importsitzung benötigt eine ID.'); if (!start.sessionId.trim()) throw new RepositoryValidationError('Eine Importsitzung benötigt eine ID.');
if (this.activeImportSessions.has(start.sessionId)) throw new RepositoryConflictError(`Importsitzung ${start.sessionId} ist bereits aktiv.`); if (this.activeImportSessions.has(start.sessionId)) throw new RepositoryConflictError(`Importsitzung ${start.sessionId} ist bereits aktiv.`);
@ -1112,6 +1152,7 @@ export class PostgresAssessorRepository implements AssessorRepository {
fingerprint.firstImportedAt, fingerprint.lastImportedAt, fingerprint.importCount, json(fingerprint)]); fingerprint.firstImportedAt, fingerprint.lastImportedAt, fingerprint.importCount, json(fingerprint)]);
} }
const stored = await this.assessmentModel(client, model.assessment.id); const stored = await this.assessmentModel(client, model.assessment.id);
for (const referenceRun of model.referenceRuns ?? []) await this.persistReferenceRun(client, referenceRun, input.referenceCatalogs ?? [], stored);
if (!stored) throw new RepositoryValidationError('Das importierte Assessment konnte nicht aufgebaut werden.'); if (!stored) throw new RepositoryValidationError('Das importierte Assessment konnte nicht aufgebaut werden.');
try { assertCanonicalAssessmentModel(stored); } try { assertCanonicalAssessmentModel(stored); }
catch (error) { throw new RepositoryValidationError(error instanceof Error ? error.message : 'Importmodell ist ungültig.'); } catch (error) { throw new RepositoryValidationError(error instanceof Error ? error.message : 'Importmodell ist ungültig.'); }
@ -2068,6 +2109,55 @@ export class PostgresAssessorRepository implements AssessorRepository {
)); ));
} }
async listCurrentPolicyEvaluationsForMetrics(projectId: string): Promise<StoredPolicyEvaluation[]> {
// Match activePolicyRecords in policy-analysis-overview using small run
// headers. Full historical evaluation JSON can be tens of megabytes and
// is unrelated to the selected timeslot's six result counts.
type RunHeader = { id: string; completedAt: string; artifactId?: string;
supersedesAnalysisRunId?: string; environmentProfileId?: string;
sourceAnalysisRunIds?: string[]; snapshotId?: string; aggregationRunId?: string };
const [profiles, analyses, aggregations, evaluations] = await Promise.all([
this.pool.query<{ id: string; supersedes: string | null } & QueryResultRow>(
"SELECT id, data->>'supersedesProfileId' AS supersedes FROM assessor_environment_profiles WHERE project_id = $1", [projectId]),
this.pool.query<{ run: RunHeader } & QueryResultRow>(
"SELECT data->'run' AS run FROM assessor_policy_identity_analysis_runs WHERE project_id = $1", [projectId]),
this.pool.query<{ run: RunHeader } & QueryResultRow>(
"SELECT data->'run' AS run FROM assessor_policy_snapshot_aggregation_runs WHERE project_id = $1", [projectId]),
this.pool.query<{ run: RunHeader } & QueryResultRow>(
"SELECT data->'run' AS run FROM assessor_policy_evaluation_runs WHERE project_id = $1", [projectId])
]);
const latestByKey = (runs: RunHeader[], key: (run: RunHeader) => string) => {
const latest = new Map<string, RunHeader>();
for (const run of runs) {
const identity = key(run);
const previous = latest.get(identity);
if (!previous || run.completedAt > previous.completedAt
|| (run.completedAt === previous.completedAt && run.id < previous.id)) latest.set(identity, run);
}
return [...latest.values()];
};
const supersededProfiles = new Set(profiles.rows.map(row => row.supersedes).filter(Boolean));
const activeProfileIds = new Set(profiles.rows.filter(row => !supersededProfiles.has(row.id)).map(row => row.id));
const supersededAnalyses = new Set(analyses.rows.map(row => row.run.supersedesAnalysisRunId).filter(Boolean));
const activeAnalyses = latestByKey(analyses.rows.map(row => row.run)
.filter(run => !supersededAnalyses.has(run.id)), run => run.artifactId ?? '');
const activeAnalysisIds = new Set(activeAnalyses.map(run => run.id));
const activeAggregations = latestByKey(aggregations.rows.map(row => row.run)
.filter(run => activeProfileIds.has(run.environmentProfileId ?? '')
&& Boolean(run.sourceAnalysisRunIds?.length)
&& run.sourceAnalysisRunIds!.every(id => activeAnalysisIds.has(id))),
run => `${run.environmentProfileId}\u0000${run.snapshotId}`);
const activeAggregationIds = new Set(activeAggregations.map(run => run.id));
const currentIds = latestByKey(evaluations.rows.map(row => row.run)
.filter(run => activeAggregationIds.has(run.aggregationRunId ?? '')),
run => run.aggregationRunId ?? '').map(run => run.id);
if (!currentIds.length) return [];
return dataRows(await this.pool.query<DataRow<StoredPolicyEvaluation>>(
'SELECT data FROM assessor_policy_evaluation_runs WHERE project_id = $1 AND id = ANY($2::text[]) ORDER BY completed_at, id',
[projectId, currentIds]
));
}
async savePolicyFindingRun(record: StoredPolicyFindingRun): Promise<void> { async savePolicyFindingRun(record: StoredPolicyFindingRun): Promise<void> {
const errors = storedPolicyFindingRunValidationErrors(record); const errors = storedPolicyFindingRunValidationErrors(record);
if (errors.length) throw new RepositoryValidationError(errors.join(' ')); if (errors.length) throw new RepositoryValidationError(errors.join(' '));
@ -2689,7 +2779,7 @@ export class PostgresAssessorRepository implements AssessorRepository {
const project = await this.getProject(projectId); const project = await this.getProject(projectId);
if (!project) throw new RepositoryNotFoundError(`Projekt ${projectId} ist nicht vorhanden.`); if (!project) throw new RepositoryNotFoundError(`Projekt ${projectId} ist nicht vorhanden.`);
const assessments = await this.listAssessments(projectId); const assessments = await this.listAssessments(projectId);
const models = (await Promise.all(assessments.map(assessment => this.loadAssessmentModel(assessment.id)))) const models = (await Promise.all(assessments.map(assessment => this.assessmentModel(this.pool, assessment.id, true))))
.filter((model): model is CanonicalAssessmentModel => model !== null); .filter((model): model is CanonicalAssessmentModel => model !== null);
const policyFindingRuns = await this.listPolicyFindingRuns(projectId); const policyFindingRuns = await this.listPolicyFindingRuns(projectId);
const uniqueDefinitions = new Map([ const uniqueDefinitions = new Map([
@ -2698,6 +2788,8 @@ export class PostgresAssessorRepository implements AssessorRepository {
].map(item => [item.id, item])); ].map(item => [item.id, item]));
return { return {
version: 1, project, assessments, version: 1, project, assessments,
referenceRuns: await this.listReferenceRuns(projectId),
referenceCatalogs: await this.listReferenceCatalogs(projectId),
scanRuns: models.flatMap(model => model.scanRuns), scanRuns: models.flatMap(model => model.scanRuns),
importFiles: models.flatMap(model => model.importFiles), importFiles: models.flatMap(model => model.importFiles),
sourceTables: models.flatMap(model => model.sourceTables), sourceTables: models.flatMap(model => model.sourceTables),

View File

@ -0,0 +1,145 @@
import { createHash, randomUUID } from 'node:crypto';
import { attackReferenceCatalog } from '../../src/references/catalog-converters';
import type { ReferenceCatalogSnapshot } from '../../src/domain/reference-recognition';
import { bundledReferenceCatalogs, referenceHash, referenceSnapshot } from './reference-catalogs';
import { lstat, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
import { dirname, isAbsolute } from 'node:path';
import { defaultMitreGraphData, validateMitreMatrixData, type MitreGraphData } from '../../src/features/mitre-graph/mitre-graph';
import { convertEnterpriseStix } from '../../src/features/mitre-graph/mitre-stix';
const OFFICIAL_ENTERPRISE_STIX_URL = 'https://raw.githubusercontent.com/mitre-attack/attack-stix-data/master/enterprise-attack/enterprise-attack.json';
const MAXIMUM_SOURCE_BYTES = 80 * 1024 * 1024;
const MAXIMUM_CATALOG_BYTES = 5 * 1024 * 1024;
export class MitreCatalogError extends Error {
constructor(readonly code: string, message: string) {
super(message);
this.name = 'MitreCatalogError';
}
}
export interface MitreCatalogState {
referenceCatalog?: ReferenceCatalogSnapshot;
version: 1;
catalog: MitreGraphData;
origin: 'bundled' | 'mitre';
}
function compareVersions(left: string, right: string): number {
const a = left.split('.').map(Number);
const b = right.split('.').map(Number);
return (a[0] - b[0]) || (a[1] - b[1]);
}
export class MitreCatalogService {
private refreshInProgress?: Promise<MitreCatalogState & { updated: boolean }>;
constructor(
private readonly catalogPath: string,
private readonly fetcher: typeof fetch = globalThis.fetch.bind(globalThis)
) {
if (!catalogPath || !isAbsolute(catalogPath)) throw new Error('An absolute MITRE catalog storage path is required.');
}
async current(): Promise<MitreCatalogState> {
try {
const information = await stat(this.catalogPath);
if (!information.isFile() || information.size > MAXIMUM_CATALOG_BYTES) throw new Error('Stored MITRE catalog is not a regular bounded file.');
const stored = JSON.parse(await readFile(this.catalogPath, 'utf8'));
const catalog = validateMitreMatrixData(stored);
const referenceCatalog = stored.referenceCatalog as ReferenceCatalogSnapshot | undefined;
if (referenceCatalog) {
const { sha256, ...content } = referenceCatalog;
if (content.framework !== 'mitre-attack' || content.version !== catalog.version || referenceHash(content) !== sha256) throw new Error('Stored reference catalog is invalid.');
}
return compareVersions(catalog.version, defaultMitreGraphData.version) >= 0
? { version: 1, catalog, origin: 'mitre', ...(referenceCatalog ? { referenceCatalog } : {}) }
: { version: 1, catalog: defaultMitreGraphData, origin: 'bundled' };
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
return { version: 1, catalog: defaultMitreGraphData, origin: 'bundled' };
}
throw error;
}
}
async recognitionCatalogs(): Promise<ReferenceCatalogSnapshot[]> {
const state = await this.current();
const attack = state.referenceCatalog ?? bundledReferenceCatalogs[0];
if (attack.version !== state.catalog.version) throw new MitreCatalogError('mitre_reference_update_required', 'Bitte zuerst Update from MITRE ausführen, um den vollständigen Referenzkatalog zu laden.');
return [attack, bundledReferenceCatalogs[1]];
}
async officialSource(): Promise<{ bytes: Buffer; catalog: MitreGraphData; referenceCatalog: ReferenceCatalogSnapshot }> {
let response: Response;
try {
response = await this.fetcher(OFFICIAL_ENTERPRISE_STIX_URL, {
headers: { Accept: 'application/json' }, cache: 'no-store', redirect: 'error',
signal: AbortSignal.timeout(120_000)
});
} catch {
throw new MitreCatalogError('mitre_unreachable', 'MITRE konnte nicht erreicht werden. Bitte später erneut versuchen oder die Datei manuell importieren.');
}
if (!response.ok || !response.body) throw new MitreCatalogError('mitre_unavailable', `MITRE lieferte keine nutzbare Datei (HTTP ${response.status}).`);
const declaredSize = Number(response.headers.get('content-length'));
if (Number.isFinite(declaredSize) && declaredSize > MAXIMUM_SOURCE_BYTES) {
await response.body.cancel();
throw new MitreCatalogError('mitre_source_too_large', 'Die MITRE-Datei überschreitet das Größenlimit.');
}
const chunks: Buffer[] = [];
const reader = response.body.getReader();
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > MAXIMUM_SOURCE_BYTES) throw new MitreCatalogError('mitre_source_too_large', 'Die MITRE-Datei überschreitet das Größenlimit.');
chunks.push(Buffer.from(value));
}
} catch (error) {
await reader.cancel().catch(() => undefined);
if (error instanceof MitreCatalogError) throw error;
throw new MitreCatalogError('mitre_download_failed', 'Der MITRE-Download wurde unterbrochen.');
}
const bytes = Buffer.concat(chunks, size);
try {
const raw = JSON.parse(bytes.toString('utf8'));
return { bytes, catalog: validateMitreMatrixData(convertEnterpriseStix(raw)),
referenceCatalog: referenceSnapshot(attackReferenceCatalog(raw, OFFICIAL_ENTERPRISE_STIX_URL, createHash('sha256').update(bytes).digest('hex'))) };
} catch {
throw new MitreCatalogError('mitre_source_invalid', 'Die MITRE-Datei enthält keinen gültigen Enterprise-ATT&CK-STIX-Katalog.');
}
}
refresh(): Promise<MitreCatalogState & { updated: boolean }> {
if (this.refreshInProgress) return this.refreshInProgress;
this.refreshInProgress = this.performRefresh().finally(() => { this.refreshInProgress = undefined; });
return this.refreshInProgress;
}
private async performRefresh(): Promise<MitreCatalogState & { updated: boolean }> {
const latest = await this.officialSource();
const current = await this.current();
const comparison = compareVersions(latest.catalog.version, current.catalog.version);
if (comparison < 0 || (comparison === 0 && current.referenceCatalog)) {
return { ...current, updated: false };
}
const directory = dirname(this.catalogPath);
await mkdir(directory, { recursive: true, mode: 0o700 });
const directoryInfo = await lstat(directory);
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()) {
throw new MitreCatalogError('mitre_storage_unavailable', 'Der private MITRE-Speicher ist nicht verfügbar.');
}
const temporaryPath = `${this.catalogPath}.${randomUUID()}.tmp`;
try {
await writeFile(temporaryPath, JSON.stringify({ ...latest.catalog, referenceCatalog: latest.referenceCatalog }), { mode: 0o600 });
await rename(temporaryPath, this.catalogPath);
} finally {
await unlink(temporaryPath).catch(error => {
if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') throw error;
});
}
return { version: 1, catalog: latest.catalog, referenceCatalog: latest.referenceCatalog, origin: 'mitre', updated: comparison > 0 };
}
}

View File

@ -0,0 +1,11 @@
import { createHash } from 'node:crypto';
import attack from '../../src/assets/data/attack-reference-v19.2.json';
import d3fend from '../../src/assets/data/d3fend-reference-v1.6.0.json';
import type { ReferenceCatalog, ReferenceCatalogSnapshot } from '../../src/domain/reference-recognition';
import { canonicalJson } from '../../src/shared/canonical-json';
export const referenceHash = (value: unknown): string => createHash('sha256').update(canonicalJson(value)).digest('hex');
export function referenceSnapshot(catalog: ReferenceCatalog): ReferenceCatalogSnapshot {
return { ...catalog, sha256: referenceHash(catalog) };
}
export const bundledReferenceCatalogs = [attack, d3fend].map(catalog => referenceSnapshot(catalog as ReferenceCatalog));

View File

@ -0,0 +1,66 @@
import type { CanonicalAssessmentModel } from '../../src/domain/canonical-model';
import { createReferenceRecognizer, latestReferenceRuns, REFERENCE_RECOGNIZER_VERSION, type ReferenceCatalogSnapshot, type ReferenceRecognitionRun } from '../../src/domain/reference-recognition';
import type { AssessorRepository } from '../../src/persistence/assessor-repository';
import { RepositoryNotFoundError } from '../../src/persistence/repository-errors';
import { bundledReferenceCatalogs, referenceHash } from './reference-catalogs';
export function deriveReferenceRun(model: CanonicalAssessmentModel, scanRunId: string, catalogs: ReferenceCatalogSnapshot[], createdAt: string): ReferenceRecognitionRun {
const occurrences = model.findingOccurrences.filter(item => item.scanRunId === scanRunId).sort((a, b) => a.id.localeCompare(b.id));
const files = new Map(model.importFiles.map(file => [file.id, file]));
const inputSha256 = referenceHash(occurrences.map(item => ({ id: item.id, importFileId: item.importFileId,
sourceFileSha256: files.get(item.importFileId)?.sha256, sourcePosition: item.sourcePosition, fields: item.originalFields })));
const catalogHashes = catalogs.map(catalog => catalog.sha256).sort();
const id = 'references-' + referenceHash([model.project.id, model.assessment.id, scanRunId, REFERENCE_RECOGNIZER_VERSION, inputSha256, catalogHashes]);
const recognize = createReferenceRecognizer(catalogs);
return { schemaVersion: 1, id, projectId: model.project.id, assessmentId: model.assessment.id, scanRunId,
recognizerVersion: REFERENCE_RECOGNIZER_VERSION, inputSha256, catalogHashes, createdAt,
results: occurrences.map(item => ({ occurrenceId: item.id, importFileId: item.importFileId,
sourceFileSha256: files.get(item.importFileId)?.sha256, sourcePosition: item.sourcePosition, references: recognize(item.originalFields) })) };
}
export class ReferenceRecognitionService {
private active = new Map<string, Promise<{ processedScans: number; unchangedScans: number; references: number }>>();
constructor(private readonly repository: AssessorRepository,
readonly catalogs: () => Promise<ReferenceCatalogSnapshot[]> = async () => bundledReferenceCatalogs) {}
async status(projectId: string) {
if (!await this.repository.getProject(projectId)) throw new RepositoryNotFoundError('Client nicht vorhanden.');
const scans = (await this.repository.listProjectScanRuns(projectId)).filter(scan => !scan.supersededByScanRunId);
const runs = latestReferenceRuns(await this.repository.listReferenceRuns(projectId));
const catalogs = await this.catalogs();
const hashes = catalogs.map(catalog => catalog.sha256).sort().join(':');
const indexed = scans.filter(scan => runs.some(run => run.scanRunId === scan.id));
return { totalScans: scans.length, indexedScans: indexed.length,
pendingScans: scans.filter(scan => !runs.some(run => run.scanRunId === scan.id && run.recognizerVersion === REFERENCE_RECOGNIZER_VERSION && [...run.catalogHashes].sort().join(':') === hashes)).length,
catalogs: catalogs.map(({ framework, version, sha256 }) => ({ framework, version, sha256 })) };
}
reprocess(projectId: string) {
let task = this.active.get(projectId);
if (!task) {
task = this.performReprocess(projectId).finally(() => this.active.delete(projectId));
this.active.set(projectId, task);
}
return task;
}
private async performReprocess(projectId: string) {
if (!await this.repository.getProject(projectId)) throw new RepositoryNotFoundError('Client nicht vorhanden.');
const catalogs = await this.catalogs();
const existing = new Map((await this.repository.listReferenceRuns(projectId)).map(run => [run.id, run]));
let processedScans = 0, unchangedScans = 0, references = 0;
for (const assessment of await this.repository.listAssessments(projectId)) {
const model = await this.repository.loadAssessmentModel(assessment.id);
if (!model) continue;
for (const scan of model.scanRuns.filter(item => !item.supersededByScanRunId)) {
const latestTimestamp = Math.max(0, ...[...existing.values()].filter(run => run.scanRunId === scan.id).map(run => Date.parse(run.createdAt)));
const run = deriveReferenceRun(model, scan.id, catalogs, new Date(Math.max(Date.now(), latestTimestamp + 1)).toISOString());
references += run.results.reduce((sum, item) => sum + item.references.length, 0);
if (existing.has(run.id)) { unchangedScans++; continue; }
await this.repository.saveReferenceRun(run, catalogs);
processedScans++;
}
}
return { processedScans, unchangedScans, references };
}
}

View File

@ -9,6 +9,7 @@ import type {
UnifiedResultQueryResult UnifiedResultQueryResult
} from '../domain/unified-result'; } from '../domain/unified-result';
import type { ProjectDataExport } from '../persistence/assessor-repository'; import type { ProjectDataExport } from '../persistence/assessor-repository';
import type { DatahubTimeslotResultMetrics } from '../features/project-import/datahub-scan-analysis';
import type { EnvironmentSnapshot, EnvironmentSnapshotQuery } from '../domain/environment-context'; import type { EnvironmentSnapshot, EnvironmentSnapshotQuery } from '../domain/environment-context';
import type { ImportedDataset } from '../domain/dataset'; import type { ImportedDataset } from '../domain/dataset';
import type { ReportProjection } from '../domain/report-projection'; import type { ReportProjection } from '../domain/report-projection';
@ -20,6 +21,7 @@ import {
ASSESSOR_MUTATION_HEADER, ASSESSOR_MUTATION_HEADER,
type AssessorApiErrorBody, type AssessorApiErrorBody,
type AssessorHealthResponse, type AssessorHealthResponse,
type MitreCatalogResponse,
type GpoBackupSubmissionResponse, type GpoBackupSubmissionResponse,
type GpoBackupStatusResponse, type GpoBackupStatusResponse,
type GpoBackupRetryResponse, type GpoBackupRetryResponse,
@ -153,6 +155,36 @@ export class AssessorApiClient {
return this.request('/health'); return this.request('/health');
} }
getReferenceRecognitionStatus(projectId: string): Promise<import('./assessor-api-contract').ReferenceRecognitionStatus> {
return this.request(`/projects/${encodeURIComponent(projectId)}/references`);
}
recognizeReferences(projectId: string): Promise<import('./assessor-api-contract').ReferenceRecognitionResponse> {
return this.request(`/projects/${encodeURIComponent(projectId)}/references/recognize`, { method: 'POST', body: '{}' });
}
getMitreCatalog(): Promise<MitreCatalogResponse> {
return this.request('/reference-data/mitre-attack');
}
refreshMitreCatalog(): Promise<MitreCatalogResponse & { updated: boolean }> {
return this.request('/reference-data/mitre-attack/refresh', { method: 'POST', body: '{}' });
}
async downloadLatestMitreSource(): Promise<{ blob: Blob; fileName: string }> {
const response = await this.fetcher(`${this.baseUrl}/reference-data/mitre-attack/source`, {
headers: { Accept: 'application/json' }
});
if (!response.ok) {
const body = await response.json().catch(() => null) as AssessorApiErrorBody | null;
throw new AssessorApiError(body?.error?.message ?? `MITRE-Download fehlgeschlagen (${response.status}).`,
response.status, body?.error?.code ?? 'mitre_download_failed', body?.error?.requestId);
}
const fileName = response.headers.get('content-disposition')?.match(/filename="(enterprise-attack-v\d+\.\d+\.json)"/)?.[1]
?? 'enterprise-attack.json';
return { blob: await response.blob(), fileName };
}
getDatabaseStructure(): Promise<DbStructureResponse> { getDatabaseStructure(): Promise<DbStructureResponse> {
return this.request('/db-structure'); return this.request('/db-structure');
} }
@ -269,6 +301,11 @@ export class AssessorApiClient {
return this.request(unifiedQueryPath('/results/facets', query)); return this.request(unifiedQueryPath('/results/facets', query));
} }
getTimeslotResultMetrics(projectId: string, scanRunIds: string[], assessmentIds: string[]): Promise<DatahubTimeslotResultMetrics | null> {
return this.request(unifiedQueryPath(`/projects/${encodeURIComponent(projectId)}/timeslot-result-metrics`,
{ scanRunIds, assessmentIds }));
}
getUnifiedResultById(resultProjectionId: string): Promise<UnifiedResultProjection> { getUnifiedResultById(resultProjectionId: string): Promise<UnifiedResultProjection> {
return this.request(`/results/${encodeURIComponent(resultProjectionId)}`); return this.request(`/results/${encodeURIComponent(resultProjectionId)}`);
} }

View File

@ -25,9 +25,17 @@ export type {
RegulatoryMappingDecisionCreateRequest, RegulatoryMappingDecisionCreateRequest,
RegulatoryMappingResponse RegulatoryMappingResponse
} from '../domain/regulatory-mapping'; } from '../domain/regulatory-mapping';
import type { MitreGraphData } from '../features/mitre-graph/mitre-graph';
export const ASSESSOR_API_PREFIX = '/api/v1'; export const ASSESSOR_API_PREFIX = '/api/v1';
export const ASSESSOR_MUTATION_HEADER = 'X-Assessor-Request'; export const ASSESSOR_MUTATION_HEADER = 'X-Assessor-Request';
export interface MitreCatalogResponse {
version: 1;
catalog: MitreGraphData;
origin: 'bundled' | 'mitre';
updated?: boolean;
}
export interface AssessorHealthResponse { export interface AssessorHealthResponse {
status: 'ok'; status: 'ok';
application: 'ASSESSOR'; application: 'ASSESSOR';
@ -170,6 +178,8 @@ export interface GpoBackupStatusRecord {
gpoCount: number; gpoCount: number;
/** @deprecated Compatibility alias for gpoCount; it never counts tenant backup snapshots. */ /** @deprecated Compatibility alias for gpoCount; it never counts tenant backup snapshots. */
backupInstanceCount: number; backupInstanceCount: number;
/** GPOs whose own backup report states no enabled link; absent until link facts are retained. */
notLinkedGpoCount?: number;
} }
export interface ProjectAssessmentHistoryResponse { export interface ProjectAssessmentHistoryResponse {
@ -208,3 +218,14 @@ export interface PolicySnapshotAggregationListResponse { version: 1; projectId:
export type MicrosoftSecurityBaselineSelectionResponse = MicrosoftSecurityBaselineSelection; export type MicrosoftSecurityBaselineSelectionResponse = MicrosoftSecurityBaselineSelection;
export type ImportBatchPreviewResponse = ImportBatchPreview; export type ImportBatchPreviewResponse = ImportBatchPreview;
export interface ReferenceRecognitionStatus {
totalScans: number;
indexedScans: number;
pendingScans: number;
catalogs: Array<{ framework: import('../domain/reference-recognition').ReferenceFramework; version: string; sha256: string }>;
}
export interface ReferenceRecognitionResponse {
processedScans: number;
unchangedScans: number;
references: number;
}

View File

@ -25,6 +25,7 @@ import { PathfinderFilterTrail } from '../features/mitre-graph/PathfinderFilterT
import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release'; import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';
import { hasSelectedTimeslotScope } from './work-scope'; import { hasSelectedTimeslotScope } from './work-scope';
import { AboutDialog } from '../features/about/AboutDialog'; import { AboutDialog } from '../features/about/AboutDialog';
import { installTableRowMarking } from './table-row-marking';
type AppArea = 'db-structure' | 'data-hub' | 'dashboard' | 'graph' | 'work' | 'results'; type AppArea = 'db-structure' | 'data-hub' | 'dashboard' | 'graph' | 'work' | 'results';
@ -61,6 +62,7 @@ const NAV_AREAS = [
export function App() { export function App() {
const app = useApp(); const app = useApp();
useEffect(() => installTableRowMarking(document), []);
const [activeArea, setActiveArea] = useState<AppArea>('data-hub'); const [activeArea, setActiveArea] = useState<AppArea>('data-hub');
const [activeProjectName, setActiveProjectName] = useState<string>(); const [activeProjectName, setActiveProjectName] = useState<string>();
const [dataHubClientView, setDataHubClientView] = useState(false); const [dataHubClientView, setDataHubClientView] = useState(false);
@ -149,13 +151,13 @@ export function App() {
{/* Die Sidebar traegt ausschliesslich Navigation. Der Serverzustand steht {/* Die Sidebar traegt ausschliesslich Navigation. Der Serverzustand steht
einheitlich als kompakte Rot-/Gruen-Anzeige im Bereichsheader. */} einheitlich als kompakte Rot-/Gruen-Anzeige im Bereichsheader. */}
<section className="sidebar-theme-panel"><div className="theme-switcher" role="group" aria-label="Controls and color scheme"> <section className="sidebar-theme-panel"><div className="theme-switcher" role="group" aria-label="Controls and color scheme">
<button type="button" className="sidebar-toggle-inline" onClick={toggleSidebar} title="Collapse sidebar" aria-label="Collapse sidebar"><TriangleIcon direction="left" /></button> <button type="button" className="sidebar-toggle-inline" onClick={toggleSidebar} title="Collapse sidebar" aria-label="Collapse sidebar" aria-expanded="true"><TriangleIcon direction="left" /></button>
<button type="button" className={`theme-switcher-btn${theme === 'dark' ? ' active' : ''}`} onClick={() => setTheme('dark')} title="Dark Mode" aria-label="Dark Mode">◔</button> <button type="button" className={`theme-switcher-btn${theme === 'dark' ? ' active' : ''}`} onClick={() => setTheme('dark')} title="Dark Mode" aria-label="Dark Mode">◔</button>
<button type="button" className={`theme-switcher-btn${theme === 'system' ? ' active' : ''}`} onClick={() => setTheme('system')} title="System mode" aria-label="System mode">▣</button> <button type="button" className={`theme-switcher-btn${theme === 'system' ? ' active' : ''}`} onClick={() => setTheme('system')} title="System mode" aria-label="System mode">▣</button>
<button type="button" className={`theme-switcher-btn${theme === 'light' ? ' active' : ''}`} onClick={() => setTheme('light')} title="Light Mode" aria-label="Light Mode">☀</button> <button type="button" className={`theme-switcher-btn${theme === 'light' ? ' active' : ''}`} onClick={() => setTheme('light')} title="Light Mode" aria-label="Light Mode">☀</button>
</div></section> </div></section>
</div></aside> </div></aside>
{sidebarCollapsed && <button className="sidebar-toggle" type="button" onClick={toggleSidebar} title="Expand sidebar" aria-label="Expand sidebar"><TriangleIcon direction="right" /></button>} {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={`workspace workspace-${activeArea}`}>
<section className={`viewer app-area app-area-${activeArea}${activeArea === NAV_AREAS[0][0] ? ' is-joined-top' : ''}${activeArea === 'work' ? ` view-${view}` : ''}`}> <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' && <DatasetViewer/>}

View File

@ -16,8 +16,11 @@ import { genericProfile } from '../profiles/generic/generic-profile';
import { importProfiles } from '../profiles/profile-catalog'; import { importProfiles } from '../profiles/profile-catalog';
import { stableProjectId } from '../shared/identifiers'; import { stableProjectId } from '../shared/identifiers';
import { combineDatasets } from '../features/project-import/project-import'; import { combineDatasets } from '../features/project-import/project-import';
import { setServerMitreMatrix } from '../features/mitre-graph/mitre-graph';
import { loadCanonicalProjectWorkspace } from '../features/project-import/load-canonical-project-workspace'; import { loadCanonicalProjectWorkspace } from '../features/project-import/load-canonical-project-workspace';
import { takeWorkspaceLoad, workspaceScopeKey } from '../features/project-import/workspace-load-handoff'; import { takeWorkspaceLoad, workspaceScopeKey } from '../features/project-import/workspace-load-handoff';
import { profileForDataset, profileForSheet } from '../features/project-import/workspace-profile';
export { profileForSheet } from '../features/project-import/workspace-profile';
import { import {
confirmEntryMappings, createBrowserFingerprintStore, createImportReport, enterImportPreview, failedPreviewEntry, confirmEntryMappings, createBrowserFingerprintStore, createImportReport, enterImportPreview, failedPreviewEntry,
LAST_IMPORT_REPORT_STORAGE_KEY, preparePreviewEntriesForReview, previewEntryFromDataset, recognitionOverridesFromResult, sha256File LAST_IMPORT_REPORT_STORAGE_KEY, preparePreviewEntriesForReview, previewEntryFromDataset, recognitionOverridesFromResult, sha256File
@ -77,47 +80,7 @@ interface AppContextValue extends AppState {
const AppContext = createContext<AppContextValue | null>(null); const AppContext = createContext<AppContextValue | null>(null);
const mixedProfileCache = new WeakMap<ImportedDataset, ImportProfile>(); const profileFor = profileForDataset;
function mixedProfileFor(dataset: ImportedDataset): ImportProfile {
const cached = mixedProfileCache.get(dataset);
if (cached) return cached;
const toolProfiles = [...new Set(dataset.sheets.map(sheet => sheet.toolId).filter((id): id is string => Boolean(id)))]
.map(id => profiles.find(profile => profile.id === id))
.filter((profile): profile is ImportProfile => Boolean(profile && profile.id !== 'generic'));
const hasPolicyResults = dataset.sheets.some(sheet => sheet.toolId === 'group-policy');
if (toolProfiles.length < 2 && !hasPolicyResults) return toolProfiles[0] ?? genericProfile;
const statusProfiles = [genericProfile, ...toolProfiles];
const statusFilters = [...new Map(statusProfiles.flatMap(profile => profile.presentation.statusFilters)
.map(filter => [filter.value, filter])).values()];
const mixed: ImportProfile = {
...genericProfile,
id: 'multiple',
name: 'Multiple Tools',
version: toolProfiles.map(profile => `${profile.id}@${profile.version}`).sort().join('+'),
matchesStatus: (record, status) => statusProfiles
.filter(profile => profile.presentation.statusFilters.some(filter => filter.value === status))
.some(profile => profile.matchesStatus(record, status)),
presentation: { ...genericProfile.presentation, statusFilters }
};
mixedProfileCache.set(dataset, mixed);
return mixed;
}
function profileFor(dataset: ImportedDataset | null): ImportProfile {
if (!dataset) return genericProfile;
if (dataset.profileId === 'generic' && new Set(dataset.sheets.map(sheet => sheet.toolId).filter(Boolean)).size > 1) {
return mixedProfileFor(dataset);
}
return profiles.find(profile => profile.id === dataset.profileId) ?? genericProfile;
}
export function profileForSheet(dataset: ImportedDataset | null, sheet?: DatasetSheet): ImportProfile {
return sheet?.toolId
? profiles.find(profile => profile.id === sheet.toolId) ?? genericProfile
: profileFor(dataset);
}
function initialSheetStatus(_sheet: DatasetSheet | undefined, _defaultStatus: string): string { function initialSheetStatus(_sheet: DatasetSheet | undefined, _defaultStatus: string): string {
return 'deviation'; return 'deviation';
@ -148,6 +111,13 @@ export function AppProvider({ children }: PropsWithChildren) {
return { ...initialAppState, theme: stored }; return { ...initialAppState, theme: stored };
}); });
useEffect(() => {
let active = true;
void assessorApi.getMitreCatalog().then(state => {
if (active) setServerMitreMatrix(state.catalog);
}).catch(() => undefined);
return () => { active = false; };
}, []);
useEffect(() => { useEffect(() => {
if (!workScopeId) return; if (!workScopeId) return;
const persist = () => { void reviewRepository.save(workScopeId, state.reviews); }; const persist = () => { void reviewRepository.save(workScopeId, state.reviews); };

View File

@ -0,0 +1,20 @@
const MARKED_ROW_ATTRIBUTE = 'data-row-marked';
const SELECTABLE_ROW = 'table tbody tr:not([aria-hidden="true"]), [role="table"] [role="row"]:not(.results-browser-head)';
/** Keep one reading marker per table, including tables rendered in floating portals. */
export function installTableRowMarking(documentRoot: Document): () => void {
const markClickedRow = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
const row = target.closest(SELECTABLE_ROW);
const table = row?.closest('table, [role="table"]');
if (!row || !table) return;
table.querySelectorAll(`[${MARKED_ROW_ATTRIBUTE}="true"]`).forEach(marked => {
if (marked !== row) marked.removeAttribute(MARKED_ROW_ATTRIBUTE);
});
row.setAttribute(MARKED_ROW_ATTRIBUTE, 'true');
};
documentRoot.addEventListener('click', markClickedRow, true);
return () => documentRoot.removeEventListener('click', markClickedRow, true);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,4 @@
import { useState } from 'react';
import type { DatasetSheet } from '../../domain/dataset'; import type { DatasetSheet } from '../../domain/dataset';
import type { FindingRecord } from '../../domain/finding'; import type { FindingRecord } from '../../domain/finding';
import { highlightText } from '../HighlightText/highlight-text'; import { highlightText } from '../HighlightText/highlight-text';
@ -18,6 +19,7 @@ interface Props {
const TABLE_ROW_HEIGHT = 44; const TABLE_ROW_HEIGHT = 44;
export function DataTable({ sheet, records, search }: Props) { export function DataTable({ sheet, records, search }: Props) {
const [markedRecordId, setMarkedRecordId] = useState<string | null>(null);
const rowWindow = useVirtualRows(records.length, TABLE_ROW_HEIGHT, 12); const rowWindow = useVirtualRows(records.length, TABLE_ROW_HEIGHT, 12);
const columnCount = sheet.analysis.headers.length + 1; const columnCount = sheet.analysis.headers.length + 1;
// Berechnete Geometrie der Virtualisierung als benannte Werte statt eines // Berechnete Geometrie der Virtualisierung als benannte Werte statt eines
@ -38,7 +40,8 @@ export function DataTable({ sheet, records, search }: Props) {
<tbody> <tbody>
{rowWindow.paddingTop > 0 && <tr aria-hidden="true"><td colSpan={columnCount} style={topSpacerStyle} /></tr>} {rowWindow.paddingTop > 0 && <tr aria-hidden="true"><td colSpan={columnCount} style={topSpacerStyle} /></tr>}
{records.slice(rowWindow.startIndex, rowWindow.endIndex).map(record => ( {records.slice(rowWindow.startIndex, rowWindow.endIndex).map(record => (
<tr key={record.id}> <tr key={record.id} data-row-marked={markedRecordId === record.id ? 'true' : undefined}
onClick={() => setMarkedRecordId(record.id)}>
<td className="row-index">{record.index + 1}</td> <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 => <td key={`${record.id}-${field.index}`}>{highlightText(field.value, search)}</td>)}
</tr> </tr>
@ -49,4 +52,3 @@ export function DataTable({ sheet, records, search }: Props) {
</div> </div>
); );
} }

View File

@ -1,4 +1,4 @@
import type { CSSProperties } from 'react'; import { DonutSmallArcMarker, donutArcPath, donutPercentage, donutPercentageLabel } from './donut-arc';
export type DonutMetricTone = export type DonutMetricTone =
| 'all' | 'all'
@ -15,9 +15,10 @@ export type DonutMetricTone =
interface DonutMetricCardProps { interface DonutMetricCardProps {
label: string; label: string;
value: number; value: number | null;
percentage: number; percentage: number;
tone: DonutMetricTone; tone: DonutMetricTone;
pendingLabel?: string;
valueSuffix?: string; valueSuffix?: string;
showPercentage?: boolean; showPercentage?: boolean;
className?: string; className?: string;
@ -30,30 +31,32 @@ export function DonutMetricCard({
value, value,
percentage, percentage,
tone, tone,
pendingLabel = 'No data',
valueSuffix = '', valueSuffix = '',
showPercentage = true, showPercentage = true,
className, className,
active, active,
onClick onClick
}: DonutMetricCardProps) { }: DonutMetricCardProps) {
const boundedPercentage = Math.min(100, Math.max(0, Math.round(percentage))); const boundedPercentage = donutPercentage(percentage);
const dashArray = String(boundedPercentage) + ' ' + String(100 - boundedPercentage); const percentageLabel = donutPercentageLabel(boundedPercentage);
const progressStyle = { '--donut-progress-dash': dashArray } as CSSProperties;
const classes = ['donut-metric-card', 'donut-metric-card--' + tone, className].filter(Boolean).join(' '); const classes = ['donut-metric-card', 'donut-metric-card--' + tone, className].filter(Boolean).join(' ');
const ariaLabel = label + ': ' + value.toLocaleString('en-US') + ', ' + boundedPercentage + '%'; const ariaLabel = value === null ? label + ': ' + pendingLabel
const displayedValue = value.toLocaleString('en-US') + valueSuffix; : label + ': ' + value.toLocaleString('en-US') + ', ' + percentageLabel;
const displayedValue = value === null ? '—' : value.toLocaleString('en-US') + valueSuffix;
const content = <> const content = <>
<h3 className="donut-metric-card__title">{label}</h3> <h3 className="donut-metric-card__title">{label}</h3>
<div className="donut-metric-card__plot"> <div className="donut-metric-card__plot">
<svg viewBox="0 0 120 120" aria-hidden="true"> <svg viewBox="0 0 120 120" aria-hidden="true">
<circle className="donut-metric-card__track" cx="60" cy="60" r="48" pathLength="100" /> <circle className="donut-metric-card__track" cx="60" cy="60" r="48" pathLength="100" />
<circle key={label + '-' + value + '-' + boundedPercentage} className="donut-metric-card__progress" cx="60" cy="60" r="48" pathLength="100" strokeDasharray={dashArray} style={progressStyle}/> {value !== null && boundedPercentage > 0 && <path key={label + '-' + value + '-' + boundedPercentage} className="donut-metric-card__progress" d={donutArcPath(boundedPercentage)}/>}
{value !== null && <DonutSmallArcMarker percentage={boundedPercentage}/>}
</svg> </svg>
<div className="donut-metric-card__values"> <div className="donut-metric-card__values">
<div className={`donut-metric-card__primary${displayedValue.length >= 5 ? ' is-compact' : ''}`}> <div className={`donut-metric-card__primary${displayedValue.length >= 5 ? ' is-compact' : ''}`}>
<strong>{displayedValue}</strong> <strong>{displayedValue}</strong>
</div> </div>
{showPercentage && <small>{boundedPercentage}%</small>} {value === null ? <small>{pendingLabel}</small> : showPercentage && <small>{percentageLabel}</small>}
</div> </div>
</div> </div>
</>; </>;

View File

@ -0,0 +1,48 @@
/** Keep the painted arc proportional even when its whole-percent label rounds down. */
export function donutPercentage(percentage: number): number {
return Number.isFinite(percentage) ? Math.min(100, Math.max(0, percentage)) : 0;
}
export function donutPercentageLabel(percentage: number): string {
const value = donutPercentage(percentage);
return value > 0 && value < 0.5 ? '<1%' : `${Math.round(value)}%`;
}
export function donutDashArray(percentage: number): string {
const value = donutPercentage(percentage);
return `${value} ${100 - value}`;
}
/** A filled annular sector gives the arc a straight radial edge at each end. */
export function donutArcPath(percentage: number, offset = 0): string {
const value = donutPercentage(percentage);
if (value <= 0) return '';
const point = (radius: number, degrees: number) => {
const angle = degrees * Math.PI / 180;
const coordinate = (value: number) => Math.round(value * 1_000_000) / 1_000_000;
return `${coordinate(60 + radius * Math.cos(angle))} ${coordinate(60 + radius * Math.sin(angle))}`;
};
const start = offset * 3.6;
const end = start + value * 3.6;
const outerStart = point(54, start);
const innerStart = point(42, start);
if (value >= 100) {
const middle = start + 180;
return `M ${outerStart} A 54 54 0 0 1 ${point(54, middle)} A 54 54 0 0 1 ${outerStart} `
+ `L ${innerStart} A 42 42 0 0 0 ${point(42, middle)} A 42 42 0 0 0 ${innerStart} Z`;
}
const largeArc = value > 50 ? 1 : 0;
return `M ${outerStart} A 54 54 0 ${largeArc} 1 ${point(54, end)} `
+ `L ${point(42, end)} A 42 42 0 ${largeArc} 0 ${innerStart} Z`;
}
/** A narrow radial tick makes an exact, sub-2% flat-ended arc legible. */
export function DonutSmallArcMarker({ percentage, offset = 0, className = '' }: {
percentage: number; offset?: number; className?: string;
}) {
const value = donutPercentage(percentage);
if (value <= 0 || value >= 2) return null;
const tangentialWidth = Math.max(3, 2 * Math.PI * 48 * value / 100);
return <rect className={`donut-small-arc-marker ${className}`.trim()} x="102" y="60"
width="12" height={tangentialWidth} transform={offset ? `rotate(${offset * 3.6} 60 60)` : undefined}/>;
}

View File

@ -30,16 +30,15 @@ export function FloatingDataWindow({ title, children, onClose }: FloatingDataWin
const dragRef = useRef<{ pointerId: number; startX: number; startY: number; left: number; top: number } | null>(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 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 [maximized, setMaximized] = useState(false);
const [position, setPosition] = useState(() => ({ const workspaceTop = () => document.querySelector<HTMLElement>('.topbar')?.getBoundingClientRect().bottom ?? FALLBACK_WORKSPACE_TOP;
left: Math.max(VIEWPORT_MARGIN, (window.innerWidth - Math.min(1100, window.innerWidth * 0.78)) / 2),
top: Math.max(FALLBACK_WORKSPACE_TOP + VIEWPORT_MARGIN, (window.innerHeight - Math.min(720, window.innerHeight * 0.76)) / 2)
}));
const [size, setSize] = useState(() => ({ const [size, setSize] = useState(() => ({
width: Math.min(1100, window.innerWidth - VIEWPORT_MARGIN * 2), width: Math.min(1100, window.innerWidth - VIEWPORT_MARGIN * 2),
height: Math.min(720, window.innerHeight - FALLBACK_WORKSPACE_TOP - VIEWPORT_MARGIN * 2) height: Math.min(720, window.innerHeight - workspaceTop() - VIEWPORT_MARGIN * 2)
}));
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)
})); }));
const workspaceTop = () => document.querySelector<HTMLElement>('.topbar')?.getBoundingClientRect().bottom ?? FALLBACK_WORKSPACE_TOP;
const clampPosition = (left: number, top: number, width = size.width, height = size.height) => ({ 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)), 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)) top: Math.max(workspaceTop() + VIEWPORT_MARGIN, Math.min(top, window.innerHeight - height - VIEWPORT_MARGIN))
@ -47,17 +46,20 @@ export function FloatingDataWindow({ title, children, onClose }: FloatingDataWin
useEffect(() => { useEffect(() => {
closeRef.current?.focus(); closeRef.current?.focus();
}, []);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return; if (event.key !== 'Escape') return;
event.preventDefault(); event.preventDefault();
onClose(); onClose();
}; };
const onResize = () => { const onResize = () => {
setSize(current => ({ const nextSize = {
width: Math.min(current.width, window.innerWidth - VIEWPORT_MARGIN * 2), width: Math.min(size.width, window.innerWidth - VIEWPORT_MARGIN * 2),
height: Math.min(current.height, window.innerHeight - workspaceTop() - VIEWPORT_MARGIN * 2) height: Math.min(size.height, window.innerHeight - workspaceTop() - VIEWPORT_MARGIN * 2)
})); };
setPosition(current => clampPosition(current.left, current.top)); setSize(nextSize);
setPosition(current => clampPosition(current.left, current.top, nextSize.width, nextSize.height));
}; };
window.addEventListener('keydown', onKeyDown); window.addEventListener('keydown', onKeyDown);
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
@ -65,7 +67,7 @@ export function FloatingDataWindow({ title, children, onClose }: FloatingDataWin
window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('resize', onResize); window.removeEventListener('resize', onResize);
}; };
}, [onClose]); }, [onClose, size.width, size.height]);
const startDrag = (event: ReactPointerEvent<HTMLElement>) => { const startDrag = (event: ReactPointerEvent<HTMLElement>) => {
if (maximized || (event.target as HTMLElement).closest('button')) return; if (maximized || (event.target as HTMLElement).closest('button')) return;
@ -98,14 +100,16 @@ export function FloatingDataWindow({ title, children, onClose }: FloatingDataWin
const dx = event.clientX - resize.startX; const dx = event.clientX - resize.startX;
const dy = event.clientY - resize.startY; const dy = event.clientY - resize.startY;
let { left, top, width, height } = resize; let { left, top, width, height } = resize;
if (resize.direction.includes('e')) width = Math.max(MIN_WIDTH, Math.min(resize.width + dx, window.innerWidth - resize.left - VIEWPORT_MARGIN)); const minWidth = Math.min(MIN_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2);
if (resize.direction.includes('s')) height = Math.max(MIN_HEIGHT, Math.min(resize.height + dy, window.innerHeight - resize.top - VIEWPORT_MARGIN)); const minHeight = Math.min(MIN_HEIGHT, window.innerHeight - workspaceTop() - VIEWPORT_MARGIN * 2);
if (resize.direction.includes('e')) width = Math.max(minWidth, Math.min(resize.width + dx, window.innerWidth - resize.left - VIEWPORT_MARGIN));
if (resize.direction.includes('s')) height = Math.max(minHeight, Math.min(resize.height + dy, window.innerHeight - resize.top - VIEWPORT_MARGIN));
if (resize.direction.includes('w')) { if (resize.direction.includes('w')) {
width = Math.max(MIN_WIDTH, Math.min(resize.width - dx, resize.left + resize.width - VIEWPORT_MARGIN)); width = Math.max(minWidth, Math.min(resize.width - dx, resize.left + resize.width - VIEWPORT_MARGIN));
left = resize.left + resize.width - width; left = resize.left + resize.width - width;
} }
if (resize.direction.includes('n')) { if (resize.direction.includes('n')) {
height = Math.max(MIN_HEIGHT, Math.min(resize.height - dy, resize.top + resize.height - workspaceTop() - VIEWPORT_MARGIN)); height = Math.max(minHeight, Math.min(resize.height - dy, resize.top + resize.height - workspaceTop() - VIEWPORT_MARGIN));
top = resize.top + resize.height - height; top = resize.top + resize.height - height;
} }
setPosition({ left, top }); setPosition({ left, top });

View File

@ -1,5 +1,6 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import { highlightText } from '../HighlightText/highlight-text'; import { highlightText } from '../HighlightText/highlight-text';
import { annotateWindowsCodes } from '../../shared/windows-codes';
const pathLabel = /^(?:(?:source|registry|file|folder|directory|binary)\s+)?path$/i; const pathLabel = /^(?:(?:source|registry|file|folder|directory|binary)\s+)?path$/i;
const placeholder = /^(?:–|—|-|not configured|not available|none)$/i; const placeholder = /^(?:–|—|-|not configured|not available|none)$/i;
@ -16,6 +17,12 @@ export function isWholePath(value: string): boolean {
|| /^[^\s<>:\\/]+[\\/][^<>\r\n]+\.[a-z0-9]{1,12}(?:#.*)?$/i.test(text); || /^[^\s<>:\\/]+[\\/][^<>\r\n]+\.[a-z0-9]{1,12}(?:#.*)?$/i.test(text);
} }
/** Plain text with the documented meaning after each standard Windows code (SID, user right, registry type). */
function annotated(text: string, search: string) {
return annotateWindowsCodes(text).map((segment, index) => <Fragment key={index}>{highlightText(segment.text, search)}
{segment.meaning && <span className="windows-code-meaning"> ({segment.meaning})</span>}</Fragment>);
}
/** Inert text, including imported markup. Path labels stay in the UI typeface. */ /** Inert text, including imported markup. Path labels stay in the UI typeface. */
export function PathText({ value, search = '', label = '' }: { value: string; search?: string; label?: string }) { export function PathText({ value, search = '', label = '' }: { value: string; search?: string; label?: string }) {
const renderPath = (path: string) => <code className="technical-path">{highlightText(path, search)}</code>; const renderPath = (path: string) => <code className="technical-path">{highlightText(path, search)}</code>;
@ -29,6 +36,6 @@ export function PathText({ value, search = '', label = '' }: { value: string; se
if (labeled && !placeholder.test(labeled[2].trim()) && (isPathField(labeled[1].split(':')[0]) || isWholePath(labeled[2]))) { if (labeled && !placeholder.test(labeled[2].trim()) && (isPathField(labeled[1].split(':')[0]) || isWholePath(labeled[2]))) {
return <Fragment key={index}>{highlightText(labeled[1], search)}{renderPath(labeled[2])}</Fragment>; return <Fragment key={index}>{highlightText(labeled[1], search)}{renderPath(labeled[2])}</Fragment>;
} }
return <Fragment key={index}>{isWholePath(line) ? renderPath(line) : highlightText(line, search)}</Fragment>; return <Fragment key={index}>{isWholePath(line) ? renderPath(line) : annotated(line, search)}</Fragment>;
})}</>; })}</>;
} }

View File

@ -6,6 +6,7 @@ import { resultStatusFieldFor } from '../../features/linked-results/result-messa
import { highlightText } from '../HighlightText/highlight-text'; import { highlightText } from '../HighlightText/highlight-text';
import { BookmarkIcon } from '../BookmarkIcon'; import { BookmarkIcon } from '../BookmarkIcon';
import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy'; import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
import { cisCategoryRecordTitle } from '../../profiles/cis-cat/cis-benchmark-section-titles';
import { policyFindingCategories } from '../../profiles/group-policy/categories'; import { policyFindingCategories } from '../../profiles/group-policy/categories';
import { measureNavigationStep } from '../../app/navigation-diagnostics'; import { measureNavigationStep } from '../../app/navigation-diagnostics';
@ -14,6 +15,8 @@ export interface TileBadge {
value: string; value: string;
text: string; text: string;
severity?: string; severity?: string;
/** Hover text, for example the report title of a CIS section. */
title?: string;
} }
export function tileBadges(record: FindingRecord, fallbackToolId?: string): TileBadge[] { export function tileBadges(record: FindingRecord, fallbackToolId?: string): TileBadge[] {
@ -33,7 +36,11 @@ export function tileBadges(record: FindingRecord, fallbackToolId?: string): Tile
const text = /^(Weight|Score)$/.test(label) ? `${label}: ${field.value}` : field.value; const text = /^(Weight|Score)$/.test(label) ? `${label}: ${field.value}` : field.value;
return [{ label, value: field.value, text, ...(label === 'Severity' ? { severity: normalizeSeverity(field.value) } : {}) }]; return [{ label, value: field.value, text, ...(label === 'Severity' ? { severity: normalizeSeverity(field.value) } : {}) }];
}); });
const cisBadges = cisBenchmarkCategories(record, fallbackToolId).map(value => ({ label: 'CIS Category', value, text: value })); // Tiles stay compact: the CIS number is the badge, the section title from the run's report is its hover text.
const cisBadges = cisBenchmarkCategories(record, fallbackToolId).map(value => {
const title = cisCategoryRecordTitle(record, value);
return { label: 'CIS Category', value, text: value, ...(title ? { title } : {}) };
});
const gpoBadges = policyFindingCategories(record, fallbackToolId).map(value => ({ label: 'GPO Category', value, text: value })); const gpoBadges = policyFindingCategories(record, fallbackToolId).map(value => ({ label: 'GPO Category', value, text: value }));
const scopeBadges = toolIdForRecord(record, fallbackToolId) === 'group-policy' const scopeBadges = toolIdForRecord(record, fallbackToolId) === 'group-policy'
? record.fields.filter(field => field.label.trim().toLowerCase() === 'scope' && field.value.trim()) ? record.fields.filter(field => field.label.trim().toLowerCase() === 'scope' && field.value.trim())
@ -110,7 +117,7 @@ function TileCardComponent({ record, title, bookmarked, resultColor, toolId, sea
plus Glyph statt gefuellter Flaeche. Sonst sehen Passed, Failed plus Glyph statt gefuellter Flaeche. Sonst sehen Passed, Failed
und Investigate in der Kachelansicht identisch aus. */ und Investigate in der Kachelansicht identisch aus. */
? <span className="outcome-mark" data-badge-role="status" data-outcome={outcomeForStatusValue(recordToolId, badge.value)} key={`${badge.label}:${badge.value}`}>{highlightText(badge.text, search)}</span> ? <span className="outcome-mark" data-badge-role="status" data-outcome={outcomeForStatusValue(recordToolId, badge.value)} key={`${badge.label}:${badge.value}`}>{highlightText(badge.text, search)}</span>
: <span className={`reader-severity${badge.severity ? ` severity-${badge.severity}` : ''}${badge.label === 'More' ? ' tile-badge-overflow' : ''}`} data-badge-role={badge.label === 'CIS Category' || badge.label === 'GPO Category' ? 'category' : badge.label.toLocaleLowerCase('en-US')} key={`${badge.label}:${badge.value}`}>{highlightText(badge.text, search)}</span>)} : <span className={`reader-severity${badge.severity ? ` severity-${badge.severity}` : ''}${badge.label === 'More' ? ' tile-badge-overflow' : ''}`} data-badge-role={badge.label === 'CIS Category' || badge.label === 'GPO Category' ? 'category' : badge.label.toLocaleLowerCase('en-US')} title={badge.title} key={`${badge.label}:${badge.value}`}>{highlightText(badge.text, search)}</span>)}
</div> </div>
</div> </div>
<div className="data-tile-actions"> <div className="data-tile-actions">

View File

@ -337,6 +337,8 @@ export interface ImportDiagnostic {
} }
export interface CanonicalAssessmentModel { export interface CanonicalAssessmentModel {
/** Latest stored reference derivation per scan; canonical source facts remain unchanged. */
referenceRuns?: import('./reference-recognition').ReferenceRecognitionRun[];
schemaVersion: 1; schemaVersion: 1;
project: AssessmentProject; project: AssessmentProject;
assessment: Assessment; assessment: Assessment;

View File

@ -38,6 +38,8 @@ export interface ResultVariant {
value: string; value: string;
typeLabel: string; typeLabel: string;
sources: ResultVariantSource[]; sources: ResultVariantSource[];
/** Proven only when every source of the variant has the same retained current baseline relation. */
baselineState?: 'deviating' | 'aligned';
} }
export interface ResultVariantComparison { export interface ResultVariantComparison {
@ -81,6 +83,8 @@ export interface DatasetSheet {
export interface ImportedDataset { export interface ImportedDataset {
/** Run-bound scoring evidence, prepared once while projecting the canonical workspace. */ /** Run-bound scoring evidence, prepared once while projecting the canonical workspace. */
securityScoring?: SecurityScoreContext; securityScoring?: SecurityScoreContext;
/** Benchmark section titles per scan run and section number, read from retained report tables. */
benchmarkSectionTitles?: Map<string, Map<string, string>>;
fileName: string; fileName: string;
fileSize: number; fileSize: number;
lastModified: number; lastModified: number;

View File

@ -18,6 +18,10 @@ export interface ScannerReportTable {
sourceTableIndex?: number; sourceTableIndex?: number;
headers: string[]; headers: string[];
rows: JsonValue[][]; rows: JsonValue[][];
presentation?: 'inline' | 'modal' | 'linked-inline';
targetId?: string;
title?: string;
cellLinks?: Array<{ rowIndex: number; columnIndex: number; targetId: string }>;
} }
export interface ScannerReportSection { export interface ScannerReportSection {
@ -33,6 +37,8 @@ export interface ScannerReportSection {
state: EnvironmentCollectionState; state: EnvironmentCollectionState;
prose?: string; prose?: string;
tables: ScannerReportTable[]; tables: ScannerReportTable[];
detailLinks?: Array<{ targetId: string; label: string; prose?: string }>;
modalDetails?: Array<{ targetId: string; title: string; prose?: string; interactiveGraph?: boolean }>;
source: SourcePosition; source: SourcePosition;
extractorVersion: string; extractorVersion: string;
attributes: Record<string, JsonValue>; attributes: Record<string, JsonValue>;

View File

@ -12,6 +12,8 @@ export interface PolicySourceEvidence {
gpoId?: string; gpoId?: string;
nativeBackupId?: string; nativeBackupId?: string;
sourceReference: string; sourceReference: string;
/** Link status of the GPO backup at backup time; absent when not retained. */
linkStatus?: 'linked' | 'not-linked';
sequence?: number; sequence?: number;
values: JsonValue[]; values: JsonValue[];
/** Presentation of a retained current comparison, never endpoint effectiveness. */ /** Presentation of a retained current comparison, never endpoint effectiveness. */
@ -20,6 +22,33 @@ export interface PolicySourceEvidence {
deviationFields?: PolicySourceDeviationField[][]; deviationFields?: PolicySourceDeviationField[][];
/** Per retained collection value, configured members absent from every current baseline variant. */ /** Per retained collection value, configured members absent from every current baseline variant. */
deviationItems?: string[][]; deviationItems?: string[][];
/** Per retained collection value, configured members present in every current baseline variant. */
alignedItems?: string[][];
}
/** One retained baseline variant comparison, formatted from the stored comparator only. */
export interface PolicyBaselineRecommendation {
template: string;
settingId: string;
comparison: string;
expected: string;
registryType?: string;
additional: string[];
missing: string[];
}
/** One stored comparison per baseline package; the rollup never merges them into a consensus value. */
export interface PolicyBaselineComparison {
baseline: string;
status: string;
summary: string;
context: string;
scopeNotice: string;
/** Original imported baseline explanation text, when available. */
explanation: string;
/** Remediation text of this package's stored comparison; empty when none can be derived. */
remediation: string;
recommendations: PolicyBaselineRecommendation[];
} }
export interface RecordField { export interface RecordField {
@ -29,6 +58,7 @@ export interface RecordField {
} }
export interface FindingRecord { export interface FindingRecord {
referenceRecognition?: import('./reference-recognition').FindingReferenceRecognition;
id: string; id: string;
/** Stable database identity of the projected canonical finding occurrence. */ /** Stable database identity of the projected canonical finding occurrence. */
findingOccurrenceId?: string; findingOccurrenceId?: string;
@ -37,11 +67,15 @@ export interface FindingRecord {
/** Exact source scope for native report metrics; never inferred from display names. */ /** Exact source scope for native report metrics; never inferred from display names. */
scanRunId?: string; scanRunId?: string;
importFileId?: string; importFileId?: string;
/** Benchmark section titles of this record's own scan run, by section number (presentation only). */
benchmarkSectionTitles?: ReadonlyMap<string, string>;
/** Presentation-only provenance of Fail comparisons, using the Processing tile's exact backup identity. */ /** Presentation-only provenance of Fail comparisons, using the Processing tile's exact backup identity. */
policyFailingBackupSources?: Array<{ id: string; label: string }>; policyFailingBackupSources?: Array<{ id: string; label: string }>;
policySourceEvidence?: PolicySourceEvidence[]; policySourceEvidence?: PolicySourceEvidence[];
/** Individual baseline memberships of one rolled-up technical policy setting. */ /** Individual baseline memberships of one rolled-up technical policy setting. */
policyBaselineLabels?: string[]; policyBaselineLabels?: string[];
/** Structured current variant comparisons for compact reading; absent for historical or incomplete comparisons. */
policyBaselineComparisons?: PolicyBaselineComparison[];
/** Original row identities allow earlier baseline-specific notes to remain readable. */ /** Original row identities allow earlier baseline-specific notes to remain readable. */
policyReviewMembers?: Array<{ id: string; baseline: string }>; policyReviewMembers?: Array<{ id: string; baseline: string }>;
index: number; index: number;

View File

@ -65,6 +65,12 @@ export interface GpoBackupInstance {
collection: GpoCollectionEvidence; collection: GpoCollectionEvidence;
backupSet: GpoBackupSetMembership; backupSet: GpoBackupSetMembership;
backupCreatedAt?: string; backupCreatedAt?: string;
/**
* Link facts from the backup's own GPReport.xml at backup time (counts only). Absent
* when no single report states them or the backup was processed before metadata 1.4.0.
* Never a claim about effective application on devices.
*/
links?: { status: 'linked' | 'not-linked'; linkCount: number; enabledLinkCount: number; evidence: 'gp-report' };
metadataStatus: 'complete' | 'partial' | 'missing' | 'unreadable'; metadataStatus: 'complete' | 'partial' | 'missing' | 'unreadable';
sections: { sections: {
computer: GpoSectionStatus; computer: GpoSectionStatus;
@ -142,6 +148,14 @@ export function gpoBackupInstanceValidationErrors(instance: GpoBackupInstance):
} }
if (instance.identity.status === 'unresolved' && !instance.identity.reasonCode.trim()) errors.push('Eine unaufgelöste GPO-Identität benötigt einen Grundcode.'); if (instance.identity.status === 'unresolved' && !instance.identity.reasonCode.trim()) errors.push('Eine unaufgelöste GPO-Identität benötigt einen Grundcode.');
if (instance.domain.status === 'resolved' && !instance.domain.key.trim()) errors.push('Eine aufgelöste Domänenidentität benötigt einen technischen Schlüssel.'); if (instance.domain.status === 'resolved' && !instance.domain.key.trim()) errors.push('Eine aufgelöste Domänenidentität benötigt einen technischen Schlüssel.');
if (instance.links) {
const { status, linkCount, enabledLinkCount, evidence } = instance.links;
if (evidence !== 'gp-report' || !Number.isSafeInteger(linkCount) || !Number.isSafeInteger(enabledLinkCount)
|| enabledLinkCount < 0 || enabledLinkCount > linkCount
|| status !== (enabledLinkCount > 0 ? 'linked' : 'not-linked')) {
errors.push('Die GPO-Verknüpfungsangaben sind widersprüchlich.');
}
}
if (instance.collection.status === 'observed' && !validTimestamp(instance.collection.collectedAt)) errors.push('Der Erhebungszeitpunkt ist ungültig.'); if (instance.collection.status === 'observed' && !validTimestamp(instance.collection.collectedAt)) errors.push('Der Erhebungszeitpunkt ist ungültig.');
if (instance.backupCreatedAt && !validTimestamp(instance.backupCreatedAt)) errors.push('Der Backupzeitpunkt ist ungültig.'); if (instance.backupCreatedAt && !validTimestamp(instance.backupCreatedAt)) errors.push('Der Backupzeitpunkt ist ungültig.');
if (!validTimestamp(instance.discoveredAt)) errors.push('Der Erkennungszeitpunkt ist ungültig.'); if (!validTimestamp(instance.discoveredAt)) errors.push('Der Erkennungszeitpunkt ist ungültig.');

View File

@ -2,7 +2,7 @@ import type { GpoSecretIndicatorStatus } from './gpo-ingestion';
export interface GpoBackupMetadataDocument { export interface GpoBackupMetadataDocument {
version: 1; version: 1;
parser: { id: 'gpo-backup-metadata-xml'; version: '1.0.0' | '1.1.0' | '1.2.0' | '1.3.0' }; parser: { id: 'gpo-backup-metadata-xml'; version: '1.0.0' | '1.1.0' | '1.2.0' | '1.3.0' | '1.4.0' };
/** Only requested by the Microsoft template analyzer; never inferred from paths. */ /** Only requested by the Microsoft template analyzer; never inferred from paths. */
templateNames?: string[]; templateNames?: string[];
sourcePath: string; sourcePath: string;
@ -12,10 +12,22 @@ export interface GpoBackupMetadataDocument {
backupId?: string; backupId?: string;
domainKey?: string; domainKey?: string;
backupCreatedAt?: string; backupCreatedAt?: string;
/**
* Link facts of a GPReport.xml (parser 1.4.0). Only counts are retained, never the
* linked OU or domain names. Absent for other metadata files and earlier parsers.
*/
links?: GpoBackupLinkEvidence;
secretIndicatorStatus: GpoSecretIndicatorStatus; secretIndicatorStatus: GpoSecretIndicatorStatus;
diagnosticCodes: string[]; diagnosticCodes: string[];
} }
/** Whether the backup's own report lists an enabled link to a domain or OU at backup time. */
export interface GpoBackupLinkEvidence {
status: 'linked' | 'not-linked';
linkCount: number;
enabledLinkCount: number;
}
export interface GpoBackupMetadataAggregate { export interface GpoBackupMetadataAggregate {
version: 1; version: 1;
status: 'complete' | 'partial' | 'invalid'; status: 'complete' | 'partial' | 'invalid';
@ -24,6 +36,8 @@ export interface GpoBackupMetadataAggregate {
domainKey?: string; domainKey?: string;
backupCreatedAt?: string; backupCreatedAt?: string;
sourcePaths: string[]; sourcePaths: string[];
/** Present only when exactly one report of the backup states its links. */
links?: GpoBackupLinkEvidence;
diagnosticCodes: string[]; diagnosticCodes: string[];
} }
@ -47,6 +61,9 @@ export function aggregateGpoBackupMetadata(documents: GpoBackupMetadataDocument[
const domainKey = field('domainKey'); const domainKey = field('domainKey');
const backupCreatedAt = field('backupCreatedAt'); const backupCreatedAt = field('backupCreatedAt');
const complete = Boolean(gpoId && backupId && domainKey && backupCreatedAt); const complete = Boolean(gpoId && backupId && domainKey && backupCreatedAt);
// Several reports with link facts cannot be ordered; no link status is claimed then.
const linkDocuments = documents.filter(document => document.links);
if (linkDocuments.length > 1) diagnostics.add('gpo-metadata-links-ambiguous');
if (!gpoId) diagnostics.add('gpo-metadata-gpo-id-missing'); if (!gpoId) diagnostics.add('gpo-metadata-gpo-id-missing');
if (!backupId) diagnostics.add('gpo-metadata-backup-id-missing'); if (!backupId) diagnostics.add('gpo-metadata-backup-id-missing');
if (!domainKey) diagnostics.add('gpo-metadata-domain-missing'); if (!domainKey) diagnostics.add('gpo-metadata-domain-missing');
@ -59,6 +76,7 @@ export function aggregateGpoBackupMetadata(documents: GpoBackupMetadataDocument[
domainKey, domainKey,
backupCreatedAt, backupCreatedAt,
sourcePaths: [...new Set(documents.map(document => document.sourcePath))].sort(), sourcePaths: [...new Set(documents.map(document => document.sourcePath))].sort(),
...(linkDocuments.length === 1 ? { links: linkDocuments[0].links } : {}),
diagnosticCodes: [...diagnostics].sort() diagnosticCodes: [...diagnostics].sort()
}; };
} }

View File

@ -0,0 +1,163 @@
import type { JsonValue, OriginalField, SourcePosition } from './canonical-model';
/** Independent derivation: these references never become FindingDefinition identity keys. */
export const REFERENCE_RECOGNIZER_VERSION = '1.0.0';
export type ReferenceFramework = 'mitre-attack' | 'mitre-d3fend';
export interface ReferenceCatalogEntry {
id: string;
title: string;
kind: 'technique' | 'tactic';
state: 'active' | 'deprecated' | 'revoked';
url: string;
tactics: string[];
}
export interface ReferenceCatalog {
schemaVersion: 1;
framework: ReferenceFramework;
version: string;
sourceUrl: string;
sourceSha256: string;
license: string;
entries: ReferenceCatalogEntry[];
}
export interface ReferenceCatalogSnapshot extends ReferenceCatalog {
/** SHA-256 of canonicalJson(catalog), including every recognition input and the notice. */
sha256: string;
}
export interface RecognizedReference {
framework: ReferenceFramework;
id: string;
title?: string;
kind?: 'technique' | 'tactic';
url?: string;
tactics: string[];
status: 'active' | 'deprecated' | 'revoked' | 'not-in-catalog';
evidence: 'declared' | 'mentioned';
matchedBy: 'id' | 'exact-title';
catalogVersion: string;
catalogSha256: string;
source: {
fieldKey: string;
fieldLabel: string;
fieldPath?: string;
/** JSON pointer within value; rawValue is authoritative when present. */
valuePath: string;
representation: 'rawValue' | 'value';
start: number;
end: number;
offsetUnit: 'utf16';
text: string;
};
}
export interface ReferenceOccurrenceResult {
occurrenceId: string;
importFileId: string;
sourceFileSha256?: string;
sourcePosition: SourcePosition;
references: RecognizedReference[];
}
export interface ReferenceRecognitionRun {
schemaVersion: 1;
id: string;
projectId: string;
assessmentId: string;
scanRunId: string;
recognizerVersion: string;
inputSha256: string;
catalogHashes: string[];
createdAt: string;
results: ReferenceOccurrenceResult[];
}
export interface FindingReferenceRecognition {
runId: string;
recognizerVersion: string;
createdAt: string;
references: RecognizedReference[];
}
/** Header semantics are framework-specific, never scanner-specific. */
export function declaredReferenceFramework(label: string): ReferenceFramework | undefined {
const value = label.trim().toLowerCase().replace(/[_-]+/g, ' ');
if (/\b(?:d3fend|mitre\s*(?:d3fend|defen[cs]e))\b/.test(value)) return 'mitre-d3fend';
if (/\b(?:att&?ck|mitre\s*(?:att&?ck|attack))\b/.test(value)) return 'mitre-attack';
return undefined;
}
function* strings(value: JsonValue, path = ''): Generator<{ text: string; path: string }> {
if (typeof value === 'string') yield { text: value, path };
else if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) yield* strings(value[i], `${path}/${i}`);
} else if (value && typeof value === 'object') {
for (const [key, item] of Object.entries(value)) yield* strings(item, `${path}/${key.replace(/~/g, '~0').replace(/\//g, '~1')}`);
}
}
const normalizeTitle = (value: string) => value.trim().replace(/\s+/g, ' ').toLowerCase();
/** Build indexes once per run, then process every source field offline. */
export function createReferenceRecognizer(catalogs: readonly ReferenceCatalogSnapshot[]) {
const indexes = catalogs.map(catalog => {
const titles = new Map<string, ReferenceCatalogEntry[]>();
for (const entry of catalog.entries) {
const key = normalizeTitle(entry.title);
titles.set(key, [...(titles.get(key) ?? []), entry]);
}
return { catalog, ids: new Map(catalog.entries.map(entry => [entry.id.toUpperCase(), entry])), titles };
});
return (fields: readonly OriginalField[]): RecognizedReference[] => {
const results: RecognizedReference[] = [];
for (const field of fields) {
const declared = declaredReferenceFramework(field.label);
const representation = typeof field.rawValue === 'string' ? 'rawValue' : 'value';
for (const { text, path } of strings(representation === 'rawValue' ? field.rawValue! : field.value)) {
for (const { catalog, ids, titles } of indexes) {
const evidence = declared === catalog.framework ? 'declared' : 'mentioned';
const occupied: Array<[number, number]> = [];
const add = (id: string, start: number, end: number, matchedBy: 'id' | 'exact-title', entry?: ReferenceCatalogEntry) => {
occupied.push([start, end]);
results.push({ framework: catalog.framework, id: entry?.id ?? id,
...(entry ? { title: entry.title, kind: entry.kind, url: entry.url } : {}),
tactics: entry?.tactics ?? [], status: entry?.state ?? 'not-in-catalog', evidence, matchedBy,
catalogVersion: catalog.version, catalogSha256: catalog.sha256,
source: { fieldKey: field.key, fieldLabel: field.label, ...(field.path ? { fieldPath: field.path } : {}),
valuePath: path, representation, start, end, offsetUnit: 'utf16', text: text.slice(start, end) } });
};
// Unicode-aware boundaries prevent partial IDs, embedded host names and longer dotted numbers.
const pattern = catalog.framework === 'mitre-attack'
? /(?<![\p{L}\p{N}_.-])(?:TA\d{4}|T\d{4}(?:\.\d{3})?)(?![\p{L}\p{N}_]|[.-][\p{L}\p{N}])/giu
: /(?<![\p{L}\p{N}_-])D3-[A-Z][A-Z0-9]*(?![\p{L}\p{N}_-])/giu;
for (const match of text.matchAll(pattern)) {
const id = match[0].toUpperCase();
add(id, match.index!, match.index! + match[0].length, 'id', ids.get(id));
}
if (evidence !== 'declared') continue;
// Only whole, explicitly delimited catalog titles; no fuzzy matches or prose inference.
for (const fragment of text.matchAll(/[^;,|/\r\n]+/g)) {
const pieces = [...fragment[0].matchAll(/[^\u2013\u2014]+/g)];
const candidates = [{ text: fragment[0], start: fragment.index! },
...pieces.map(piece => ({ text: piece[0], start: fragment.index! + piece.index! }))];
const separator = fragment[0].lastIndexOf(' - ');
if (separator >= 0) candidates.push({ text: fragment[0].slice(separator + 3), start: fragment.index! + separator + 3 });
for (const candidate of candidates) {
const trimmed = candidate.text.trim();
const start = candidate.start + candidate.text.indexOf(trimmed);
const end = start + trimmed.length;
if (!trimmed || occupied.some(([a, b]) => start < b && end > a)) continue;
const entries = titles.get(normalizeTitle(trimmed));
if (entries?.length === 1) add(entries[0].id, start, end, 'exact-title', entries[0]);
}
}
}
}
}
return results;
};
}
export function latestReferenceRuns(runs: readonly ReferenceRecognitionRun[]): ReferenceRecognitionRun[] {
const latest = new Map<string, ReferenceRecognitionRun>();
for (const run of runs) {
const previous = latest.get(run.scanRunId);
if (!previous || `${run.createdAt}\0${run.id}` > `${previous.createdAt}\0${previous.id}`) latest.set(run.scanRunId, run);
}
return [...latest.values()];
}

View File

@ -77,6 +77,7 @@ const publicDomainRoles = new Set([
class PolicyValueRedactor { class PolicyValueRedactor {
private readonly principals = new Map<string, string>(); private readonly principals = new Map<string, string>();
private readonly systemPrincipals = new Map<string, string>();
private domainToken(_identity: string): string { private domainToken(_identity: string): string {
return '[DOMAIN]'; return '[DOMAIN]';
@ -91,11 +92,24 @@ class PolicyValueRedactor {
return token; return token;
} }
private systemPrincipalToken(identity: string): string {
const key = identity.toLocaleLowerCase('en-US');
const existing = this.systemPrincipals.get(key);
if (existing) return existing;
const token = `[SYSTEM_PRINCIPAL_${this.systemPrincipals.size + 1}]`;
this.systemPrincipals.set(key, token);
return token;
}
text(value: string, redactAccountNames = true): string { text(value: string, redactAccountNames = true): string {
let redacted = value; let redacted = value;
redacted = redacted.replace(/(\*?)S-1-5-21-(\d+)-(\d+)-(\d+)-(\d+(?:-\d+)*)/giu, redacted = redacted.replace(/(\*?)S-1-\d+(?:-\d+){2,}\b/giu, (match, marker: string) => {
(_match, marker: string, first: string, second: string, third: string, rid: string) => const sid = match.slice(marker.length);
`${marker}S-1-5-21-${this.domainToken(`${first}-${second}-${third}`)}-${rid}`); const domain = /^S-1-5-21-(\d+)-(\d+)-(\d+)-(\d+(?:-\d+)*)$/iu.exec(sid);
if (domain) return `${marker}S-1-5-21-${this.domainToken(`${domain[1]}-${domain[2]}-${domain[3]}`)}-${domain[4]}`;
if (isPublicWindowsSid(sid)) return match;
return `${marker}${this.systemPrincipalToken(sid)}`;
});
redacted = redacted.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu, redacted = redacted.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu,
match => this.principalToken(match)); match => this.principalToken(match));
if (redactAccountNames) { if (redactAccountNames) {
@ -177,6 +191,13 @@ function safeBaselineVariantAssessment(
} }
const narrativeTools = new Set(['cis-cat', 'maester', 'pingcastle', 'purple-knight']); const narrativeTools = new Set(['cis-cat', 'maester', 'pingcastle', 'purple-knight']);
const publicCisPolicyPathPairs = new Set([
'configuration\\administrative', 'templates\\system', 'sensors\\turn'
]);
function isPublicWindowsSid(sid: string): boolean {
return /^(?:S-1-5-32-\d+|S-1-5-(?:18|19|20|11|90-0|82-0)|S-1-1-0)$/iu.test(sid);
}
function escapeRegExp(value: string): string { function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@ -221,18 +242,26 @@ function redactNarrative(item: UnifiedResultProjection, value?: string): string
.replace(/&gt;/giu, '>') .replace(/&gt;/giu, '>')
.replace(/&quot;/giu, '"') .replace(/&quot;/giu, '"')
.replace(/&#39;/giu, "'"); .replace(/&#39;/giu, "'");
const cisRuleId = disclosureTool(item).id === 'cis-cat' ? safePublicRuleId(item) : undefined;
redacted = redacted redacted = redacted
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu, '[email]') .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/giu, '[email]')
.replace(/\bS-1-5-21(?:-\d+){3,}\b/giu, '[sid]') .replace(/\bS-1-\d+(?:-\d+){2,}\b/giu, match => isPublicWindowsSid(match) ? match : '[sid]')
.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/giu, '[id]') .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/giu, '[id]')
.replace(/\b(?:CN|OU|DC)=[^,\r\n]+(?:,(?:CN|OU|DC)=[^,\r\n]+)+/giu, '[directory-object]') .replace(/\b(?:CN|OU|DC)=[^,\r\n]+(?:,(?:CN|OU|DC)=[^,\r\n]+)+/giu, '[directory-object]')
.replace(/\b[A-Za-z0-9._-]+\\[A-Za-z0-9._$-]+\b/gu, '[account]') .replace(/\b[A-Za-z0-9._-]+\\[A-Za-z0-9._$-]+\b/gu, (match, offset: number, input: string) => {
const start = input.lastIndexOf('\n', offset) + 1;
const end = input.indexOf('\n', offset);
const line = input.slice(start, end < 0 ? undefined : end).trim();
return cisRuleId && /^(?:Computer|User) Configuration\\Administrative Templates\\/iu.test(line)
&& publicCisPolicyPathPairs.has(match.toLocaleLowerCase('en-US'))
? match : '[account]';
})
.replace(/\\\\[^\s\\]+\\[^\r\n]*/gu, '[network-path]') .replace(/\\\\[^\s\\]+\\[^\r\n]*/gu, '[network-path]')
.replace(/\b[A-Za-z]:\\[^\r\n]*/gu, '[path]') .replace(/\b[A-Za-z]:\\[^\r\n]*/gu, '[path]')
.replace(/\b(?:https?|ldap|ldaps):\/\/[^\s<>()]+/giu, '[url]') .replace(/\b(?:https?|ldap|ldaps):\/\/[^\s<>()]+/giu, '[url]')
.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/gu, '[ip]') .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/gu, match => cisRuleId === match ? match : '[ip]')
.replace(/\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\b/giu, '[mac]') .replace(/\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\b/giu, '[mac]')
.replace(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/giu, '[domain]') .replace(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/giu, match => cisRuleId && /\.(?:admx|adml)$/iu.test(match) ? match : '[domain]')
.replace(/\b(?:[\p{L}\p{N}&.'-]+\s+){0,4}(?:GmbH|AG|SE|KG|OHG|UG|Inc\.?|Ltd\.?|LLC|Corp(?:oration)?\.?|Limited)\b/giu, '[organization]'); .replace(/\b(?:[\p{L}\p{N}&.'-]+\s+){0,4}(?:GmbH|AG|SE|KG|OHG|UG|Inc\.?|Ltd\.?|LLC|Corp(?:oration)?\.?|Limited)\b/giu, '[organization]');
for (const literal of privateLiterals(item)) { for (const literal of privateLiterals(item)) {
redacted = redacted.replace(new RegExp(escapeRegExp(literal), 'giu'), '[redacted]'); redacted = redacted.replace(new RegExp(escapeRegExp(literal), 'giu'), '[redacted]');

View File

@ -31,7 +31,11 @@ export interface ScannerReportSectionCandidate {
ordinal: number; ordinal: number;
state: EnvironmentCollectionState; state: EnvironmentCollectionState;
prose?: string; prose?: string;
tables: Array<{ name: string; sourceTableIndex?: number; headers: string[]; rows: JsonValue[][] }>; tables: Array<{ name: string; sourceTableIndex?: number; headers: string[]; rows: JsonValue[][];
presentation?: 'inline' | 'modal' | 'linked-inline'; targetId?: string; title?: string;
cellLinks?: Array<{ rowIndex: number; columnIndex: number; targetId: string }> }>;
detailLinks?: Array<{ targetId: string; label: string; prose?: string }>;
modalDetails?: Array<{ targetId: string; title: string; prose?: string; interactiveGraph?: boolean }>;
source: SourceLocator; source: SourceLocator;
attributes: Record<string, JsonValue>; attributes: Record<string, JsonValue>;
} }

View File

@ -144,6 +144,8 @@ export interface ToolProfileDefinition {
schemaVersion: 1; schemaVersion: 1;
id: string; id: string;
name: string; name: string;
/** Further scan-run names the import assigns to this profile, for example scope-specific names. */
runNames?: string[];
version: string; version: string;
processing: ToolProcessingContract; processing: ToolProcessingContract;
securityScoring: ToolSecurityScoringDefinition; securityScoring: ToolSecurityScoringDefinition;
@ -213,6 +215,48 @@ export interface ToolProfileDefinition {
resultSheetCriterionRowPatterns?: RegExp[]; resultSheetCriterionRowPatterns?: RegExp[];
/** Darstellung einer vom Quellbericht als ausgewertet markierten Belegzeile. */ /** Darstellung einer vom Quellbericht als ausgewertet markierten Belegzeile. */
resultSheetEvaluatedRowTone?: 'violation'; resultSheetEvaluatedRowTone?: 'violation';
/**
* Explicit per-row status markers written by the source itself (for example a
* check mark in a status cell). A row is colored only when its cells prove exactly
* one of both states; nothing is inferred from other values.
*/
resultSheetRowStatusCells?: { match: RegExp[]; violation: RegExp[] };
/**
* First-read layout of the Finding Reader (user decision 2026-09-25): which retained
* source fields state the problem, its impact and the next step. Only declared
* fields are used; a missing field removes its line instead of inventing text.
*/
firstRead?: {
/** Concrete result statement of the scanner for this environment. */
resultHeaders?: RegExp[];
/** Explicit count of affected results. */
affectedHeaders?: RegExp[];
/** Count linked result-table rows when no explicit count exists. */
affectedFromResults?: boolean;
/** Count rows the source itself marks as passed or failed (`resultSheetRowStatusCells`). */
statusRowCounts?: boolean;
/** Show the source's criterion and evaluated rows as expected and found. */
criterionEvidence?: boolean;
impactHeaders?: RegExp[];
nextStepHeaders?: RegExp[];
/** Retained https documentation link offered as the next step when no remediation text exists. */
nextStepLinkHeaders?: RegExp[];
/**
* Native remediation subsection inside another retained field, used when no dedicated
* remediation field exists. The section runs from the native heading to the end of the field;
* the text before the heading stays in place as background.
*/
nextStepSection?: { headers: RegExp[]; heading: RegExp };
/**
* Native reference heading the scanner appends to its impact text. The impact row ends before it;
* the retained remainder is shown with the references.
*/
impactReferenceMarker?: RegExp;
/** Explanatory text shown below the next step. */
backgroundHeaders?: RegExp[];
/** Documentation and references shown after the background. */
referenceHeaders?: RegExp[];
};
}; };
comparisonKeys: CanonicalFieldRole[][]; comparisonKeys: CanonicalFieldRole[][];
} }

View File

@ -119,6 +119,8 @@ export interface UnifiedResultProjection {
policyValues?: JsonValue[]; policyValues?: JsonValue[];
policyDiagnosticCodes?: string[]; policyDiagnosticCodes?: string[];
policyBackupInstanceId?: string; policyBackupInstanceId?: string;
/** Link status of the contributing GPO backup from its own GPReport.xml; absent when not retained. */
policyBackupLinkStatus?: 'linked' | 'not-linked';
/** /**
* Retained Group Policy Preference context. A preference is an instruction with its own * Retained Group Policy Preference context. A preference is an instruction with its own
* action, item-level targeting and apply-once semantics, so it is never reduced to a * action, item-level targeting and apply-once semantics, so it is never reduced to a
@ -810,6 +812,7 @@ export function createPolicyObservationUnifiedResult(source: PolicyObservationUn
policyValues: observation.values as unknown as JsonValue[], policyValues: observation.values as unknown as JsonValue[],
policyDiagnosticCodes: diagnosticCodes, policyDiagnosticCodes: diagnosticCodes,
policyBackupInstanceId: backup.instance.id, policyBackupInstanceId: backup.instance.id,
...(backup.instance.links ? { policyBackupLinkStatus: backup.instance.links.status } : {}),
policyPreferenceCse: observation.preference?.cse, policyPreferenceCse: observation.preference?.cse,
policyPreferenceAction: observation.preference?.operation, policyPreferenceAction: observation.preference?.operation,
policyPreferenceTargeting: observation.preference?.targeting.status, policyPreferenceTargeting: observation.preference?.targeting.status,

View File

@ -158,14 +158,16 @@ export function scoreDonut(score: number | undefined, caption: string): string {
: 0; : 0;
const dash = `${(circumference * ratio).toFixed(3)} ${(circumference * (1 - ratio)).toFixed(3)}`; const dash = `${(circumference * ratio).toFixed(3)} ${(circumference * (1 - ratio)).toFixed(3)}`;
const display = typeof score === 'number' && Number.isFinite(score) const display = typeof score === 'number' && Number.isFinite(score)
? Math.round(score).toLocaleString('de-DE') ? score > 0 && score < 0.5 ? '<1' : Math.round(score).toLocaleString('de-DE')
: DASH; : DASH;
const hasScore = display !== DASH; const hasScore = display !== DASH;
return `<figure class="donut"> return `<figure class="donut">
<svg viewBox="0 0 128 128" role="img" aria-label="${escapeHtml(caption)} ${hasScore ? `${display} von 100` : 'nicht verfügbar'}"> <svg viewBox="0 0 128 128" role="img" aria-label="${escapeHtml(caption)} ${hasScore ? `${display} von 100` : 'nicht verfügbar'}">
<circle class="donut__track" cx="64" cy="64" r="${radius}"></circle> <circle class="donut__track" cx="64" cy="64" r="${radius}"></circle>
${hasScore ? `<circle class="donut__value" cx="64" cy="64" r="${radius}" ${hasScore ? `<circle class="donut__value" cx="64" cy="64" r="${radius}"
stroke-dasharray="${dash}" transform="rotate(-90 64 64)"></circle>` : ''} stroke-dasharray="${dash}" transform="rotate(-90 64 64)"></circle>${ratio > 0 && ratio < 0.02
? `<rect class="donut__small-arc" x="112" y="${(64 - Math.max(3, circumference * ratio) / 2).toFixed(3)}" width="12" height="${Math.max(3, circumference * ratio).toFixed(3)}" transform="rotate(-90 64 64)"></rect>`
: ''}` : ''}
</svg> </svg>
<figcaption class="donut__readout"> <figcaption class="donut__readout">
<span class="donut__number">${escapeHtml(display)}${hasScore ? '<span class="donut__unit">/100</span>' : ''}</span> <span class="donut__number">${escapeHtml(display)}${hasScore ? '<span class="donut__unit">/100</span>' : ''}</span>
@ -315,6 +317,7 @@ strong{font-weight:600}
.donut circle{fill:none;stroke-width:14px;stroke-linecap:butt} .donut circle{fill:none;stroke-width:14px;stroke-linecap:butt}
.donut__track{stroke:var(--donut-track)} .donut__track{stroke:var(--donut-track)}
.donut__value{stroke:var(--donut-all)} .donut__value{stroke:var(--donut-all)}
.donut__small-arc{fill:var(--donut-all)}
.donut__readout{position:absolute;inset:18px;display:flex;flex-direction:column;align-items:center; .donut__readout{position:absolute;inset:18px;display:flex;flex-direction:column;align-items:center;
justify-content:center;gap:2px;text-align:center} justify-content:center;gap:2px;text-align:center}
.donut__number{font-size:26px;font-weight:700;color:var(--donut-value);line-height:1;font-variant-numeric:tabular-nums} .donut__number{font-size:26px;font-weight:700;color:var(--donut-value);line-height:1;font-variant-numeric:tabular-nums}

View File

@ -6,6 +6,7 @@ import { cisBenchmarkCategories } from '../profiles/cis-cat/cis-benchmark-hierar
import { policyDiagnosticBadges, policyFindingCategories } from '../profiles/group-policy/categories'; import { policyDiagnosticBadges, policyFindingCategories } from '../profiles/group-policy/categories';
import { policyComparisonDisplayLines, policyValueName } from '../profiles/group-policy/presentation'; import { policyComparisonDisplayLines, policyValueName } from '../profiles/group-policy/presentation';
import { mitreBadgeLabel } from '../profiles/purple-knight/presentation'; import { mitreBadgeLabel } from '../profiles/purple-knight/presentation';
import { firstReadConsumedFields, firstReadSectionSplit } from '../features/finding-reader/first-read';
import type { WorkExportBlock } from './work-view-export'; import type { WorkExportBlock } from './work-view-export';
/** The Work finding reader's content as plain export sections, in reader order. */ /** The Work finding reader's content as plain export sections, in reader order. */
@ -40,6 +41,7 @@ function reviewText(record: FindingRecord, stored?: Review): string {
export function readerViewFor(record: FindingRecord, title: string, toolId: string | undefined, export function readerViewFor(record: FindingRecord, title: string, toolId: string | undefined,
hasStructuredResults: boolean, sourceEvidence: WorkExportBlock[], review?: Review): WorkReaderView { hasStructuredResults: boolean, sourceEvidence: WorkExportBlock[], review?: Review): WorkReaderView {
const selected = selectFindingFields(record, title, hasStructuredResults, toolId); const selected = selectFindingFields(record, title, hasStructuredResults, toolId);
const sectionSplit = toolId !== 'group-policy' ? firstReadSectionSplit(record, toolId) : undefined;
const gpo = toolId === 'group-policy'; const gpo = toolId === 'group-policy';
const severityField = record.fields.find(field => /^(severity|criticality|priority)$/i.test(field.label) && field.value); const severityField = record.fields.find(field => /^(severity|criticality|priority)$/i.test(field.label) && field.value);
const badges = [ const badges = [
@ -66,6 +68,12 @@ export function readerViewFor(record: FindingRecord, title: string, toolId: stri
value: policyComparisonDisplayLines(field.value).map(line => line.text).join('\n') }; value: policyComparisonDisplayLines(field.value).map(line => line.text).join('\n') };
}), }),
...selected.summary.map(field => ({ label: field.label, value: field.value, rich: !plain(field) })), ...selected.summary.map(field => ({ label: field.label, value: field.value, rich: !plain(field) })),
// The interactive first-read block consumes these original fields to avoid
// repetition. The XLSX contract still exports their source labels and full
// values after the open background fields, in the same reading sequence.
...firstReadConsumedFields(record, toolId).map(field => ({ label: field.label, value: field.value, rich: !plain(field) })),
// A native remediation subsection split off another field is the reader's Next step; its background stays in the summary.
...(sectionSplit ? [{ label: 'Next step', value: sectionSplit.section, rich: !plain(sectionSplit.field) }] : []),
...selected.optionalFields.map(field => ({ label: field.label, value: field.value, rich: !/xml/i.test(field.label) })) ...selected.optionalFields.map(field => ({ label: field.label, value: field.value, rich: !/xml/i.test(field.label) }))
]; ];
return { severity: severityField?.value ?? '', badges, sourceRuleId: selected.sourceRuleId?.value ?? '', sections, return { severity: severityField?.value ?? '', badges, sourceRuleId: selected.sourceRuleId?.value ?? '', sections,

View File

@ -17,6 +17,7 @@ import { operatingSystemInventory, type OperatingSystemInventoryGroup } from './
import { useApp } from '../../app/AppProvider'; import { useApp } from '../../app/AppProvider';
import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace'; import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace';
import { DonutMetricCard } from '../../components/DonutMetricCard/DonutMetricCard'; import { DonutMetricCard } from '../../components/DonutMetricCard/DonutMetricCard';
import { DonutSmallArcMarker, donutArcPath, donutPercentageLabel } from '../../components/DonutMetricCard/donut-arc';
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState'; import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
import { TriangleIcon } from '../../components/TriangleIcon'; import { TriangleIcon } from '../../components/TriangleIcon';
import type { ImportedDataset } from '../../domain/dataset'; import type { ImportedDataset } from '../../domain/dataset';
@ -24,7 +25,7 @@ import type { EnvironmentObservation, EnvironmentSnapshot } from '../../domain/e
import type { FindingRecord } from '../../domain/finding'; import type { FindingRecord } from '../../domain/finding';
import type { ImportProfile } from '../../domain/import-profile'; import type { ImportProfile } from '../../domain/import-profile';
import type { Review } from '../../domain/review'; import type { Review } from '../../domain/review';
import { compareSecurityScores, weightedSecurityScore, type SecurityScoreContext, type SecuritySourceScore } from '../../domain/security-scoring'; import { weightedSecurityScore, type SecurityScoreContext, type SecuritySourceScore } from '../../domain/security-scoring';
import { dashboardSecuritySources } from './dashboard-security-score'; import { dashboardSecuritySources } from './dashboard-security-score';
import { SECURITY_DOMAIN_FACET_ID, dashboardDomainAssignment, securityDomainCatalog } from './security-domains'; import { SECURITY_DOMAIN_FACET_ID, dashboardDomainAssignment, securityDomainCatalog } from './security-domains';
import { ScoringMethodology } from './ScoringMethodology'; import { ScoringMethodology } from './ScoringMethodology';
@ -72,14 +73,15 @@ interface DashboardStatusMetricProps {
} }
function DashboardStatusMetric({ label, value, percentage, tone, large = false, helper, active, onClick }: DashboardStatusMetricProps) { function DashboardStatusMetric({ label, value, percentage, tone, large = false, helper, active, onClick }: DashboardStatusMetricProps) {
const roundedPercentage = Math.min(100, Math.max(0, Math.round(percentage))); const percentageLabel = donutPercentageLabel(percentage);
const content = <> const content = <>
<div className={`dashboard-status-ring dashboard-status-ring-${tone}`}> <div className={`dashboard-status-ring dashboard-status-ring-${tone}`}>
<svg viewBox="0 0 120 120" aria-hidden="true"> <svg viewBox="0 0 120 120" aria-hidden="true">
<circle className="dashboard-status-ring-track" cx="60" cy="60" r="48" pathLength="100" /> <circle className="dashboard-status-ring-track" cx="60" cy="60" r="48" pathLength="100" />
<circle className="dashboard-status-ring-progress" cx="60" cy="60" r="48" pathLength="100" strokeDasharray={`${roundedPercentage} ${100 - roundedPercentage}`} /> {percentage > 0 && <path className="dashboard-status-ring-progress" d={donutArcPath(percentage)} />}
<DonutSmallArcMarker percentage={percentage}/>
</svg> </svg>
<span><span className={`dashboard-donut-primary${value.toLocaleString('en-US').length >= 5 ? ' is-compact' : ''}`}><strong>{value.toLocaleString('en-US')}</strong><small>{roundedPercentage}%</small></span></span> <span><span className={`dashboard-donut-primary${value.toLocaleString('en-US').length >= 5 ? ' is-compact' : ''}`}><strong>{value.toLocaleString('en-US')}</strong><small>{percentageLabel}</small></span></span>
</div> </div>
<div className="dashboard-status-copy"> <div className="dashboard-status-copy">
<span>{label}</span> <span>{label}</span>
@ -88,7 +90,7 @@ function DashboardStatusMetric({ label, value, percentage, tone, large = false,
</>; </>;
const className = `dashboard-status-metric${large ? ' dashboard-status-metric-large' : ''}`; const className = `dashboard-status-metric${large ? ' dashboard-status-metric-large' : ''}`;
return onClick return onClick
? <button className={className} type="button" aria-label={`${label}: ${value.toLocaleString('en-US')}, ${roundedPercentage}%`} aria-pressed={Boolean(active)} onClick={onClick}>{content}</button> ? <button className={className} type="button" aria-label={`${label}: ${value.toLocaleString('en-US')}, ${percentageLabel}`} aria-pressed={Boolean(active)} onClick={onClick}>{content}</button>
: <article className={className}>{content}</article>; : <article className={className}>{content}</article>;
} }
@ -385,16 +387,10 @@ function RiskProfile({ sourceFacet, categoryFacet, severityFacet, records, profi
const percentage = share(severity.count, severityTotal); const percentage = share(severity.count, severityTotal);
const offset = severityOffset; const offset = severityOffset;
severityOffset += percentage; severityOffset += percentage;
return <circle return <g key={severity.value}><path
className={`dashboard-risk-donut-segment severity-${severity.value}`} className={`dashboard-risk-donut-segment severity-${severity.value}`}
cx="60" d={donutArcPath(percentage, offset)}
cy="60" /><DonutSmallArcMarker percentage={percentage} offset={offset} className={`dashboard-risk-donut-marker severity-${severity.value}`}/></g>;
r="48"
key={severity.value}
pathLength="100"
strokeDasharray={`${percentage} ${100 - percentage}`}
strokeDashoffset={-offset}
/>;
})} })}
</svg> </svg>
<span><span className="dashboard-donut-primary"><strong>{severityTotal.toLocaleString('en-US')}</strong><small>100%</small></span></span> <span><span className="dashboard-donut-primary"><strong>{severityTotal.toLocaleString('en-US')}</strong><small>100%</small></span></span>
@ -730,14 +726,21 @@ export function dashboardDomainInformation(snapshot?: EnvironmentSnapshot): Dash
const entra = observationFor(snapshot, 'entra.tenant_name') const entra = observationFor(snapshot, 'entra.tenant_name')
? observationFact(snapshot, 'entra.tenant_name', 'Entra tenant') ? observationFact(snapshot, 'entra.tenant_name', 'Entra tenant')
: observationFact(snapshot, 'entra.configuration_detected', 'Entra ID configuration detected'); : 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 [ return [
{ label: 'Domain', value: observationFact(snapshot, 'domain.fqdn', 'Domain') }, { label: 'Domain', value: observationFact(snapshot, 'domain.fqdn', 'Domain') },
{ label: 'NetBIOS name', value: observationFact(snapshot, 'domain.netbios_name', 'Netbios Name') }, { label: 'NetBIOS name', value: observationFact(snapshot, 'domain.netbios_name', 'Netbios Name') },
{ label: 'Forest', value: observationFact(snapshot, 'forest.name') }, { label: 'Forest', value: observationFact(snapshot, 'forest.name') },
{ label: 'Domains', value: numberFact(dashboardEnvironmentDomainCount(snapshot)) }, { label: 'Domains', value: numberFact(dashboardEnvironmentDomainCount(snapshot)) },
{ label: 'Domain functional level', value: observationFact(snapshot, 'domain.functional_level', 'Domain Functional Level') },
{ label: 'Domain controllers', value: observationFact(snapshot, 'domain.controller_count', 'DC count') }, { 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: '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: 'Schema version', value: observationFact(snapshot, 'forest.schema_version', 'Schema version') },
{ label: 'Domain created', value: observationFact(snapshot, 'domain.created_at', 'Creation date') }, { label: 'Domain created', value: observationFact(snapshot, 'domain.created_at', 'Creation date') },
{ label: 'Entra ID', value: entra } { label: 'Entra ID', value: entra }
@ -761,46 +764,31 @@ function DashboardMetricTile({ label, value, tone = 'neutral', emphasis = false,
: <article className={className}>{content}</article>; : <article className={className}>{content}</article>;
} }
/** /** Source-backed summary facts appear in the tiles and remain available in Results. */
* Faktgruppe der Umgebungsdetails. Rein visuell: eine Gruppenregel und darunter function DashboardFactList({ items }: { items: DashboardFact[] }) {
* ein flieszendes Raster gleich breiter Faktzellen. Ohne nebeneinanderstehende return <dl className="client-env-facts">
* Karten kann keine hohe Gruppe ihre Nachbarn in die Hoehe strecken. {items.map(item => <div className={/^\S{17,}$/.test(item.value) ? 'is-wide' : undefined} key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}
*
* Meldet die Quelle in einer Gruppe keinen einzigen Wert, bleibt sie als eine
* Zeile mit ausdruecklicher Aussage stehen und bleibt aufklappbar. Es wird kein
* Wert entfernt, zusammengefasst oder abgeleitet; Unbekanntes bleibt der
* Gedankenstrich der Datenschicht.
*/
function DashboardFactList({ items, id }: { items: DashboardFact[]; id?: string }) {
return <dl className="client-env-facts" id={id}>
{items.map(item => <div key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}
</dl>; </dl>;
} }
function DashboardFactGroup({ title, items, glyph, snapshot, evidenceGroup }: { function DashboardFactGroup({ title, items, overviewItems, glyph, snapshot, evidenceGroup }: {
title: string; items: DashboardFact[]; glyph: DashboardGlyphName; title: string; items: DashboardFact[]; glyph: DashboardGlyphName;
overviewItems?: DashboardFact[];
snapshot?: EnvironmentSnapshot; evidenceGroup: EnvironmentEvidenceGroup; snapshot?: EnvironmentSnapshot; evidenceGroup: EnvironmentEvidenceGroup;
}) { }) {
const unreported = items.length > 0 && items.every(item => item.value === UNKNOWN_FACT); const tileItems = overviewItems ?? (title === 'Directory' ? items.slice(0, 6) : items);
const [expanded, setExpanded] = useState(false);
const listId = useId();
return <section className="client-env-group"> return <section className="client-env-group">
<header className="client-env-group-header"> <header className="client-env-group-header">
<h3 className="client-env-group-title"> <h3 className="client-env-group-title">
<DashboardGlyph name={glyph} /> <DashboardGlyph name={glyph} />
<span>{title}</span> <span>{title}</span>
{unreported && <>
<span className="client-env-group-note">not reported by source</span>
<button className="client-env-group-toggle" type="button" aria-expanded={expanded} aria-controls={listId}
aria-label={`${expanded ? 'Hide' : 'Show'} ${title} fields`} title={`${expanded ? 'Hide' : 'Show'} fields`}
onClick={() => setExpanded(value => !value)}>
<TriangleIcon direction={expanded ? 'down' : 'right'} />
</button>
</>}
</h3> </h3>
<EnvironmentResultsButton title={title}><EnvironmentEvidenceTables snapshot={snapshot} group={evidenceGroup}/></EnvironmentResultsButton> <EnvironmentResultsButton title={title}>
<DashboardFactList items={items}/>
<EnvironmentEvidenceTables snapshot={snapshot} group={evidenceGroup}/>
</EnvironmentResultsButton>
</header> </header>
{(!unreported || expanded) && <DashboardFactList items={items} id={listId}/>} <DashboardFactList items={tileItems}/>
</section>; </section>;
} }
@ -821,8 +809,9 @@ function DashboardDonut({ total, centerLabel, segments }: { total: number; cente
const percentage = share(segment.value, total); const percentage = share(segment.value, total);
const segmentOffset = offset; const segmentOffset = offset;
offset += percentage; offset += percentage;
return <circle className={`client-info-donut-segment tone-${segment.tone}`} cx="60" cy="60" r="48" pathLength="100" key={segment.label} return <g key={segment.label}><path className={`client-info-donut-segment tone-${segment.tone}`}
strokeDasharray={`${percentage} ${100 - percentage}`} strokeDashoffset={-segmentOffset} />; d={donutArcPath(percentage, segmentOffset)} />
<DonutSmallArcMarker percentage={percentage} offset={segmentOffset} className={`client-info-donut-marker tone-${segment.tone}`}/></g>;
})} })}
</svg> </svg>
<span><strong>{total.toLocaleString('en-US')}</strong><small>100%</small><em>{centerLabel}</em></span> <span><strong>{total.toLocaleString('en-US')}</strong><small>100%</small><em>{centerLabel}</em></span>
@ -1151,14 +1140,15 @@ function dashboardScoreTone(score?: number): 'unknown' | 'critical' | 'medium' |
export function DashboardOverallRing({ score, onClick }: { score?: number; onClick?(): void }) { export function DashboardOverallRing({ score, onClick }: { score?: number; onClick?(): void }) {
const value = score ?? 0; const value = score ?? 0;
const scoreLabel = score !== undefined && score > 0 && score < 0.5 ? '<1' : Math.round(value).toString();
const tone = dashboardScoreTone(score); const tone = dashboardScoreTone(score);
const label = `Overall Score: ${score === undefined ? 'No data' : Math.round(score)}`; const label = `Overall Score: ${score === undefined ? 'No data' : scoreLabel}`;
const content = <> const content = <>
<svg viewBox="0 0 120 120" aria-hidden="true"> <svg viewBox="0 0 120 120" aria-hidden="true">
<circle className="client-overall-ring-track" cx="60" cy="60" r="48" pathLength="100" /> <circle className="client-overall-ring-track" cx="60" cy="60" r="48" pathLength="100" />
{score !== undefined && <circle className="client-overall-ring-value" cx="60" cy="60" r="48" pathLength="100" strokeDasharray={`${value} ${100 - value}`} />} {score !== undefined && <><path className="client-overall-ring-value" d={donutArcPath(value)} /><DonutSmallArcMarker percentage={value}/></>}
</svg> </svg>
<span><span className="client-overall-ring-number"><strong>{score === undefined ? '—' : Math.round(score)}</strong>{score !== undefined && <small>/100</small>}</span><em>ASSESSOR</em></span> <span><span className="client-overall-ring-number"><strong>{score === undefined ? '—' : scoreLabel}</strong>{score !== undefined && <small>/100</small>}</span><em>ASSESSOR</em></span>
</>; </>;
return onClick return onClick
? <button className={`client-overall-ring tone-${tone}`} type="button" aria-label={`Open remediation overview in Work. ${label}`} onClick={onClick}>{content}</button> ? <button className={`client-overall-ring tone-${tone}`} type="button" aria-label={`Open remediation overview in Work. ${label}`} onClick={onClick}>{content}</button>
@ -1373,6 +1363,8 @@ export function dashboardAnalysis(
...unclassifiedScore ...unclassifiedScore
} : undefined; } : undefined;
const totalResult = dashboardScoreFromStates(records.map(record => states.get(record) ?? 'unknown'), records); const totalResult = dashboardScoreFromStates(records.map(record => states.get(record) ?? 'unknown'), records);
const gapRecordIds = records.filter(record => !['passed', 'deviation', 'not-applicable'].includes(states.get(record) ?? 'unknown'))
.map(record => record.id);
return { return {
total: records.length, total: records.length,
@ -1386,6 +1378,7 @@ export function dashboardAnalysis(
domains, domains,
unclassifiedDomain, unclassifiedDomain,
totalResult, totalResult,
gapRecordIds,
overallScore: dashboardOverallScore(sourceScores) overallScore: dashboardOverallScore(sourceScores)
}; };
} }
@ -1435,11 +1428,6 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const environmentDetail = useMemo(() => dashboardEnvironmentDetail(environment.snapshot), [environment.snapshot]); const environmentDetail = useMemo(() => dashboardEnvironmentDetail(environment.snapshot), [environment.snapshot]);
const domainInformation = useMemo(() => dashboardDomainInformation(environment.snapshot), [environment.snapshot]); const domainInformation = useMemo(() => dashboardDomainInformation(environment.snapshot), [environment.snapshot]);
const [scoringOpen, setScoringOpen] = useState(false); const [scoringOpen, setScoringOpen] = useState(false);
const [restrictedOpen, setRestrictedOpen] = useState(false);
const overallScoreRef = useRef<HTMLElement | null>(null);
const environmentDetailRef = useRef<HTMLDetailsElement | null>(null);
const scoreComparison = useMemo(() => dataset?.securityScoring?.baseline
? compareSecurityScores(dataset.securityScoring.baseline, dataset.securityScoring.scans) : undefined, [dataset?.securityScoring]);
// Ersatzzaehlung aus Findingtexten, solange die Verarbeitungsliste der // Ersatzzaehlung aus Findingtexten, solange die Verarbeitungsliste der
// Sicherungen noch nicht geantwortet hat. Sie liest jeden Feldwert der // Sicherungen noch nicht geantwortet hat. Sie liest jeden Feldwert der
// GPO-Datensaetze und darf deshalb nicht bei jedem Rendern erneut laufen. // GPO-Datensaetze und darf deshalb nicht bei jedem Rendern erneut laufen.
@ -1448,23 +1436,6 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
[analysis] [analysis]
); );
useLayoutEffect(() => {
const syncEnvironmentTileHeight = () => {
const overallHeight = overallScoreRef.current?.getBoundingClientRect().height;
if (!overallHeight || !environmentDetailRef.current) return;
environmentDetailRef.current.style.setProperty('--client-environment-tile-height', `${overallHeight * 0.75}px`);
};
syncEnvironmentTileHeight();
window.addEventListener('resize', syncEnvironmentTileHeight);
const observer = overallScoreRef.current && typeof ResizeObserver !== 'undefined'
? new ResizeObserver(syncEnvironmentTileHeight) : undefined;
if (overallScoreRef.current && observer) observer.observe(overallScoreRef.current);
return () => {
window.removeEventListener('resize', syncEnvironmentTileHeight);
observer?.disconnect();
};
}, [dataset, activeSheet]);
if (!dataset || !activeSheet) return app.targetProjectId if (!dataset || !activeSheet) return app.targetProjectId
? <div className="dashboard-page panel-is-loading"><h2>Client Info</h2><PanelLoadingState message={app.status}/></div> ? <div className="dashboard-page panel-is-loading"><h2>Client Info</h2><PanelLoadingState message={app.status}/></div>
: <EmptyWorkspace />; : <EmptyWorkspace />;
@ -1485,8 +1456,8 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
} }
const { const {
total, statusFacet, severityFacet, sourceFacet, tools, reports, statusCount, total, statusFacet, severityFacet, sourceFacet, tools, reports,
sourceScores, domains, unclassifiedDomain, totalResult, overallScore sourceScores, domains, unclassifiedDomain, totalResult, gapRecordIds, overallScore
} = analysis; } = analysis;
const deviations = totalResult.deviations; const deviations = totalResult.deviations;
const assessmentCoverage = totalResult.coverage; const assessmentCoverage = totalResult.coverage;
@ -1495,6 +1466,21 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const userAccounts = dashboardEnvironmentTableNumber(environment.snapshot, 'sectionUserInformation', /^nb user accounts$/i); const userAccounts = dashboardEnvironmentTableNumber(environment.snapshot, 'sectionUserInformation', /^nb user accounts$/i);
const computerAccounts = dashboardEnvironmentTableNumber(environment.snapshot, 'sectionComputerInformation', /^nb computer accounts$/i); const computerAccounts = dashboardEnvironmentTableNumber(environment.snapshot, 'sectionComputerInformation', /^nb computer accounts$/i);
const domainControllers = dashboardObservationNumber(environment.snapshot, 'domain.controller_count'); 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 directoryDetailLabels = ['NetBIOS name', 'Schema version', 'Domain created', 'Entra ID'];
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 => !directoryDetailLabels.includes(item.label) && !alreadyInData(item));
const directoryOverview = environmentDetail.directory.filter(item =>
!alreadyInData(item) && !domainOverview.some(shown => shown.label === item.label && shown.value === item.value));
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 maesterSource = sourceScores.find(source => /maester/i.test(source.tool.label));
const entraTenants = dashboardEntraTenantCount(environment.snapshot) const entraTenants = dashboardEntraTenantCount(environment.snapshot)
?? dashboardObservationNumber(environment.snapshot, 'entra.configuration_detected') ?? dashboardObservationNumber(environment.snapshot, 'entra.configuration_detected')
@ -1533,13 +1519,13 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
setView('columns'); setView('columns');
onOpenWork(); onOpenWork();
}; };
const sourceOverviewContent = orderedSourceScores.length ? <ul>{orderedSourceScores.map(source => const sourceOverviewContent = (dismissResults: () => void) => orderedSourceScores.length ? <ul>{orderedSourceScores.map(source =>
<DashboardSourceListItem key={source.tool.id} source={source} native={nativeMetrics} <DashboardSourceListItem key={source.tool.id} source={source} native={nativeMetrics}
onClick={sourceFacet && source.tool.sourceValues.length ? () => jump(() => { onClick={sourceFacet && source.tool.sourceValues.length ? () => { dismissResults(); jump(() => {
setFacetFilter(sourceFacet.id, { values: source.tool.sourceValues }); setFacetFilter(sourceFacet.id, { values: source.tool.sourceValues });
// The source drilldown includes saved GPO review/conflict/duplicate rows. // The source drilldown includes saved GPO review/conflict/duplicate rows.
if (/gpo/i.test(source.tool.label)) setStatusFilter('all'); if (/gpo/i.test(source.tool.label)) setStatusFilter('all');
}) : undefined} /> }); } : undefined} />
)}</ul> : <p className="dashboard-empty">No source results available.</p>; )}</ul> : <p className="dashboard-empty">No source results available.</p>;
const selectedSource = sourceFilter?.values?.length const selectedSource = sourceFilter?.values?.length
? tools.find(tool => tool.sourceValues.length === sourceFilter.values!.length ? tools.find(tool => tool.sourceValues.length === sourceFilter.values!.length
@ -1573,8 +1559,16 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
<div className="client-overview-main"> <div className="client-overview-main">
<section className={`client-posture tone-${overallTone}`} aria-label="Security posture"> <section className={`client-posture tone-${overallTone}`} aria-label="Security posture">
<DashboardPostureConnectors nodeCount={displayedDomains.length} /> <DashboardPostureConnectors nodeCount={displayedDomains.length} />
<section ref={overallScoreRef} className="client-overall" aria-labelledby="client-overall-heading"> <section className="client-overall" aria-labelledby="client-overall-heading">
<h2 id="client-overall-heading">Overall Score</h2> <div className="client-overall-head">
<h2 id="client-overall-heading">Overall Score</h2>
<EnvironmentResultsButton title="Overall Score">{dismissResults => <>
<div className="client-environment-sources">{sourceOverviewContent(dismissResults)}</div>
{environmentDetail.restricted.length > 0 && <p className="client-environment-restricted">Restricted by the source tool, not empty: {environmentDetail.restricted.join(', ')}</p>}
<button className="btn" type="button" onClick={() => { dismissResults(); setScoringOpen(true); }}>Scoring methodology ↗</button>
<DashboardSourceResults sources={orderedSourceScores} snapshot={environment.snapshot}/>
</>}</EnvironmentResultsButton>
</div>
<DashboardOverallRing score={overallScore} onClick={() => jump(() => undefined, false)} /> <DashboardOverallRing score={overallScore} onClick={() => jump(() => undefined, false)} />
<div className="client-overall-coverage"> <div className="client-overall-coverage">
<span><strong>Assessment Coverage</strong><b>{assessmentCoverage === undefined ? '—' : `${assessmentCoverage}%`}</b></span> <span><strong>Assessment Coverage</strong><b>{assessmentCoverage === undefined ? '—' : `${assessmentCoverage}%`}</b></span>
@ -1604,6 +1598,10 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
['duplicates', 'Duplicates', totalResult.duplicates], ['duplicates', 'Duplicates', totalResult.duplicates],
['conflicts', 'Conflicts', totalResult.conflicts] ['conflicts', 'Conflicts', totalResult.conflicts]
] as const).map(([status, label, count]) => <button type="button" key={status} disabled={count === 0} onClick={() => jump(() => setStatusFilter(status))}><strong>{count.toLocaleString('en-US')}</strong><span>{label}</span></button>)} ] as const).map(([status, label, count]) => <button type="button" key={status} disabled={count === 0} onClick={() => jump(() => setStatusFilter(status))}><strong>{count.toLocaleString('en-US')}</strong><span>{label}</span></button>)}
<button type="button" disabled={totalResult.unassessed === 0} onClick={() => jump(() => {
setStatusFilter('all');
setPathFindingIds(gapRecordIds);
})}><strong>{totalResult.unassessed.toLocaleString('en-US')}</strong><span>Gaps</span></button>
</div> </div>
<section className="client-data-facts" aria-label="Environment facts"> <section className="client-data-facts" aria-label="Environment facts">
<DashboardTopFact value={dashboardEnvironmentDomainCount(environment.snapshot)} label="AD Domains" glyph="domain" /> <DashboardTopFact value={dashboardEnvironmentDomainCount(environment.snapshot)} label="AD Domains" glyph="domain" />
@ -1616,69 +1614,22 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
</section> </section>
<section className="client-domain-information" aria-labelledby="client-domain-information-heading"> <section className="client-domain-information" aria-labelledby="client-domain-information-heading">
<h2 id="client-domain-information-heading">Domain Information</h2> <h2 id="client-domain-information-heading">Domain Information</h2>
<dl>{domainInformation.map(item => <div key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}</dl> <dl>{domainOverview.map(item => <div key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}</dl>
</section>
<section className="client-assessment-gaps">
<h2>Assessment Gaps</h2><strong>{totalResult.unassessed.toLocaleString('en-US')}</strong><span>Unassessed controls</span>
{totalResult.reevaluationRequired > 0 && <div className="client-gpo-readiness" role="status">
<h3>GPO re-evaluation required</h3>
<p>{totalResult.reevaluationRequired.toLocaleString('en-US')} saved comparisons await re-evaluation. Open the GPO import in Data Hub and select Re-evaluate to calculate current results.</p>
<button className="btn" type="button" onClick={() => jump(() => setStatusFilter('all'))}>View saved results</button>
</div>}
<dl><div><dt><DashboardGlyph name="clipboard"/>Manual review</dt><dd>{(totalResult.reviewRequired + statusCount('manual')).toLocaleString('en-US')}</dd></div>
{totalResult.reevaluationRequired > 0 && <div><dt><DashboardGlyph name="clipboard"/>GPO re-evaluation</dt><dd>{totalResult.reevaluationRequired.toLocaleString('en-US')}</dd></div>}
<div><dt><DashboardGlyph name="folder"/>Missing evidence</dt><dd>{Math.max(0, totalResult.missingEvidence - statusCount('manual')).toLocaleString('en-US')}</dd></div>
{scoreComparison && <div><dt><DashboardGlyph name="clipboard"/>Awaiting revalidation</dt><dd>{dataset.securityScoring?.historyUnavailable ? '—' : scoreComparison.awaitingRevalidation.reduce((sum, item) => sum + item.occurrenceIds.length, 0).toLocaleString('en-US')}</dd></div>}
</dl>
<button className="btn client-overview-action" type="button" onClick={() => jump(() => setStatusFilter(statusCount('not-run') ? 'not-run' : 'all'))}>Review gaps →</button>
</section> </section>
</aside> </aside>
</div> </div>
{scoringOpen && <ScoringMethodology sources={sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(false)} />} {scoringOpen && <ScoringMethodology sources={sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(false)} />}
<details ref={environmentDetailRef} className="client-environment-detail" aria-labelledby="client-environment-detail-heading"> <div className="client-environment-grid" aria-label="Environment overview">
<summary> <DashboardFactGroup title="Directory" items={environmentDetail.directory} overviewItems={directoryOverview} glyph="domain" snapshot={environment.snapshot} evidenceGroup="directory" />
<TriangleIcon direction="right" />
<h2 id="client-environment-detail-heading">Environment Detail</h2>
<span className="client-env-meta">
<span>{environment.failed ? 'Scanner context could not be loaded' : environment.snapshot
? `${environment.snapshot.observations.length.toLocaleString('en-US')} observations · ${environment.snapshot.sections.length.toLocaleString('en-US')} sections`
: 'Loading scanner context…'}</span>
{environmentDetail.restricted.length > 0 && <span>{environmentDetail.restricted.length.toLocaleString('en-US')} restricted sections</span>}
</span>
</summary>
{environmentDetail.restricted.length > 0 && <button className="btn client-env-restricted-toggle" type="button"
aria-expanded={restrictedOpen} aria-controls="client-environment-restricted"
onClick={() => setRestrictedOpen(value => !value)}>
{environmentDetail.restricted.length.toLocaleString('en-US')} restricted sections
<TriangleIcon direction={restrictedOpen ? 'down' : 'right'} />
</button>}
{restrictedOpen && environmentDetail.restricted.length > 0
&& <p className="client-environment-restricted" id="client-environment-restricted">
Restricted by the source tool, not empty: {environmentDetail.restricted.join(', ')}
</p>}
<div className="client-environment-grid">
<section className="client-environment-sources" aria-labelledby="client-environment-sources-heading">
<header><h3 id="client-environment-sources-heading"><DashboardGlyph name="scan"/><span>Source Overview</span></h3>
<div className="client-environment-source-actions">
<button className="btn" type="button" onClick={() => setScoringOpen(true)}>Scoring methodology ↗</button>
<EnvironmentResultsButton title="Source Overview">
<DashboardSourceResults sources={orderedSourceScores} snapshot={environment.snapshot}/>
</EnvironmentResultsButton>
</div>
</header>
{sourceOverviewContent}
</section>
<DashboardFactGroup title="Directory" items={environmentDetail.directory} glyph="domain" snapshot={environment.snapshot} evidenceGroup="directory" />
<DashboardFactGroup title="Resilience" items={environmentDetail.resilience} glyph="shield" snapshot={environment.snapshot} evidenceGroup="resilience" /> <DashboardFactGroup title="Resilience" items={environmentDetail.resilience} glyph="shield" snapshot={environment.snapshot} evidenceGroup="resilience" />
<DashboardFactGroup title="Inventory" items={environmentDetail.inventory} glyph="computer" snapshot={environment.snapshot} evidenceGroup="inventory" /> <DashboardFactGroup title="Inventory" items={environmentDetail.inventory} overviewItems={inventoryOverview} glyph="computer" snapshot={environment.snapshot} evidenceGroup="inventory" />
<DashboardFactGroup title="Password policy" items={environmentDetail.passwordPolicy} glyph="lock" snapshot={environment.snapshot} evidenceGroup="password-policy" /> <DashboardFactGroup title="Password policy" items={environmentDetail.passwordPolicy} glyph="lock" snapshot={environment.snapshot} evidenceGroup="password-policy" />
<DashboardFactGroup title="Certificates & PKI" items={environmentDetail.certificates} glyph="policy" snapshot={environment.snapshot} evidenceGroup="certificates" /> <DashboardFactGroup title="Certificates & PKI" items={environmentDetail.certificates} glyph="policy" snapshot={environment.snapshot} evidenceGroup="certificates" />
<DashboardFactGroup title="Source metrics" items={environmentDetail.toolContext} glyph="chart" snapshot={environment.snapshot} evidenceGroup="source-metrics" /> <DashboardFactGroup title="Source metrics" items={environmentDetail.toolContext} glyph="chart" snapshot={environment.snapshot} evidenceGroup="source-metrics" />
<OperatingSystemInventory groups={environmentDetail.operatingSystems} icon={<DashboardGlyph name="computer"/>} snapshot={environment.snapshot} /> <OperatingSystemInventory groups={environmentDetail.operatingSystems} icon={<DashboardGlyph name="computer"/>} snapshot={environment.snapshot} />
</div> </div>
</details>
<RiskRegister dataset={dataset} reviews={reviews} scopedRiskAcceptances={app.scopedRiskAcceptances} onOpenFinding={recordId => jump(() => setPathFindingIds([recordId]))} /> <RiskRegister dataset={dataset} reviews={reviews} scopedRiskAcceptances={app.scopedRiskAcceptances} onOpenFinding={recordId => jump(() => setPathFindingIds([recordId]))} />
</div> </div>

View File

@ -1,16 +1,16 @@
import { useState } from 'react'; import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
import type { JsonValue, SourcePosition } from '../../domain/canonical-model'; import { createPortal } from 'react-dom';
import type { EnvironmentObservation, EnvironmentSnapshot, ScannerReportSection, ScannerReportTable } from '../../domain/environment-context'; import type { JsonValue } from '../../domain/canonical-model';
import { environmentEvidenceForGroup, environmentSectionTitle, environmentTableSubsection, type EnvironmentEvidenceGroup } from './environment-result-evidence'; import type { EnvironmentSnapshot, ScannerReportSection, ScannerReportTable } from '../../domain/environment-context';
import { environmentEvidenceForGroup, environmentNativeHeading, environmentSectionTitle, environmentSectionProse, environmentSectionReference, environmentTableTitle, type EnvironmentEvidenceGroup } from './environment-result-evidence';
const PAGE_SIZE = 100; const PAGE_SIZE = 100;
const TABLE_PAGE_SIZES = [10, 25, 100] as const;
function sourcePosition(source: SourcePosition): string { const TABLE_RESIZE_DIRECTIONS = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'] as const;
return [source.objectPath, source.elementSelector, source.cellReference, type TableResizeDirection = typeof TABLE_RESIZE_DIRECTIONS[number];
source.rowIndex === undefined ? undefined : `row ${source.rowIndex}`, const TABLE_VIEWPORT_MARGIN = 8;
source.columnIndex === undefined ? undefined : `column ${source.columnIndex}`] const TABLE_MIN_WIDTH = 360;
.filter(Boolean).join(' · ') || '—'; const TABLE_MIN_HEIGHT = 260;
}
function valueText(value: JsonValue | undefined): string { function valueText(value: JsonValue | undefined): string {
if (value === undefined) return '—'; if (value === undefined) return '—';
@ -19,103 +19,297 @@ function valueText(value: JsonValue | undefined): string {
return JSON.stringify(value); return JSON.stringify(value);
} }
function attributesText(attributes: Record<string, JsonValue>): string { function SourceTableDialog({ table, title, context, onClose, onOpenLink }: {
const visible = Object.fromEntries(Object.entries(attributes).filter(([key]) => key !== 'sourceTableIndex')); table: ScannerReportTable; title: string; context: string;
return Object.keys(visible).length ? JSON.stringify(visible) : '—'; onClose(): void; onOpenLink?: (targetId: string) => void;
} }) {
const titleId = useId();
function EvidenceTable({ table, subsection }: { table: ScannerReportTable; subsection?: string }) { const dialogRef = useRef<HTMLElement>(null);
const [visible, setVisible] = useState(PAGE_SIZE); const searchRef = useRef<HTMLInputElement>(null);
const resizeRef = useRef<{ pointerId: number; direction: TableResizeDirection; startX: number; startY: number;
left: number; top: number; width: number; height: number } | null>(null);
const [geometry, setGeometry] = useState<CSSProperties | null>(null);
const [query, setQuery] = useState('');
const [pageSize, setPageSize] = useState<number>(25);
const [page, setPage] = useState(0);
const width = table.rows.reduce((maximum, row) => Math.max(maximum, row.length), table.headers.length); const width = table.rows.reduce((maximum, row) => Math.max(maximum, row.length), table.headers.length);
const nativeName = /^(?:Tabelle|Table)\s+\d+$/i.test(table.name) ? undefined : table.name; const filteredRows = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
const indexed = table.rows.map((row, rowIndex) => ({ row, rowIndex }));
return needle ? indexed.filter(({ row }) => row.some(value => valueText(value).toLocaleLowerCase().includes(needle))) : indexed;
}, [query, table.rows]);
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pageSize));
const start = page * pageSize;
const shownRows = filteredRows.slice(start, start + pageSize);
const startResize = (direction: TableResizeDirection) => (event: ReactPointerEvent<HTMLSpanElement>) => {
const bounds = dialogRef.current?.getBoundingClientRect();
if (!bounds) return;
resizeRef.current = { pointerId: event.pointerId, direction, startX: event.clientX, startY: event.clientY,
left: bounds.left, top: bounds.top, width: bounds.width, height: bounds.height };
event.currentTarget.setPointerCapture(event.pointerId);
event.preventDefault();
};
const moveResize = (event: ReactPointerEvent<HTMLSpanElement>) => {
const resize = resizeRef.current;
if (!resize || resize.pointerId !== event.pointerId) return;
const dx = event.clientX - resize.startX;
const dy = event.clientY - resize.startY;
let { left, top, width, height } = resize;
const minWidth = Math.min(TABLE_MIN_WIDTH, window.innerWidth - TABLE_VIEWPORT_MARGIN * 2);
const minHeight = Math.min(TABLE_MIN_HEIGHT, window.innerHeight - TABLE_VIEWPORT_MARGIN * 2);
if (resize.direction.includes('e')) width = Math.max(minWidth, Math.min(resize.width + dx, window.innerWidth - resize.left - TABLE_VIEWPORT_MARGIN));
if (resize.direction.includes('s')) height = Math.max(minHeight, Math.min(resize.height + dy, window.innerHeight - resize.top - TABLE_VIEWPORT_MARGIN));
if (resize.direction.includes('w')) {
width = Math.max(minWidth, Math.min(resize.width - dx, resize.left + resize.width - TABLE_VIEWPORT_MARGIN));
left = resize.left + resize.width - width;
}
if (resize.direction.includes('n')) {
height = Math.max(minHeight, Math.min(resize.height - dy, resize.top + resize.height - TABLE_VIEWPORT_MARGIN));
top = resize.top + resize.height - height;
}
setGeometry({ left, top, width, height });
};
const stopResize = (event: ReactPointerEvent<HTMLSpanElement>) => {
if (resizeRef.current?.pointerId !== event.pointerId) return;
resizeRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
};
useEffect(() => {
const onViewportResize = () => {
setGeometry(current => {
if (!current) return current;
const width = Math.min(Number(current.width), window.innerWidth - TABLE_VIEWPORT_MARGIN * 2);
const height = Math.min(Number(current.height), window.innerHeight - TABLE_VIEWPORT_MARGIN * 2);
return { width, height,
left: Math.max(TABLE_VIEWPORT_MARGIN, Math.min(Number(current.left), window.innerWidth - width - TABLE_VIEWPORT_MARGIN)),
top: Math.max(TABLE_VIEWPORT_MARGIN, Math.min(Number(current.top), window.innerHeight - height - TABLE_VIEWPORT_MARGIN)) };
});
};
window.addEventListener('resize', onViewportResize);
return () => window.removeEventListener('resize', onViewportResize);
}, []);
useEffect(() => { searchRef.current?.focus(); }, []);
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onClose();
return;
}
if (event.key === 'Tab' && dialogRef.current) {
const focusable = [...dialogRef.current.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), summary')];
const first = focusable[0];
const last = focusable.at(-1);
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
}
event.stopPropagation();
};
return createPortal(<div className="client-env-table-backdrop" onKeyDown={onKeyDown}
onMouseDown={event => { if (event.target === event.currentTarget) onClose(); }}>
<section ref={dialogRef} className={`client-env-table-dialog client-env-table-dialog--resizable client-env-evidence${geometry ? ' is-resized' : ''}`}
style={geometry ?? undefined} role="dialog" aria-modal="true" aria-labelledby={titleId}>
<header className="client-env-table-dialog-head">
<div><p>{context}</p><h2 id={titleId}>{title}</h2></div>
<button type="button" aria-label={`Close ${title} table`} onClick={onClose}>×</button>
</header>
<div className="client-env-table-dialog-body">
<div className="client-env-table-toolbar">
<span>{table.rows.length.toLocaleString('en-US')} rows</span>
<input ref={searchRef} type="search" aria-label={`Search ${title} table`} placeholder="Search table"
value={query} onChange={event => { setQuery(event.target.value); setPage(0); }}/>
</div>
{width ? <div className="client-env-evidence-scroll client-env-table-scroll"><table>
<thead><tr>{Array.from({ length: width }, (_, index) =>
<th scope="col" key={index}>{table.headers[index] || `Column ${index + 1}`}</th>)}</tr></thead>
<tbody>{shownRows.map(({ row, rowIndex }) => <tr key={rowIndex}>
{Array.from({ length: width }, (_, index) => {
const targetId = table.cellLinks?.find(link => link.rowIndex === rowIndex && link.columnIndex === index)?.targetId;
return <td key={index}>{targetId && onOpenLink
? <button type="button" className="client-env-table-cell-link" onClick={() => onOpenLink(targetId)}>{valueText(row[index])}</button>
: valueText(row[index])}</td>;
})}
</tr>)}</tbody>
</table></div> : <p>No columns retained in this source table.</p>}
{width > 0 && filteredRows.length === 0 && <p>No rows match this search.</p>}
<div className="client-env-table-pagination">
<span>{filteredRows.length ? `Showing ${start + 1}–${Math.min(start + pageSize, filteredRows.length)} of ${filteredRows.length}` : 'Showing 0 of 0'} rows</span>
<label>Rows per page <select value={pageSize} onChange={event => { setPageSize(Number(event.target.value)); setPage(0); }}>
{TABLE_PAGE_SIZES.map(size => <option key={size} value={size}>{size}</option>)}
</select></label>
<div><button type="button" className="btn" disabled={page === 0} onClick={() => setPage(value => value - 1)}>Previous</button>
<span>{page + 1} / {pageCount}</span>
<button type="button" className="btn" disabled={page + 1 >= pageCount} onClick={() => setPage(value => value + 1)}>Next</button></div>
</div>
</div>
{TABLE_RESIZE_DIRECTIONS.map(direction => <span key={direction}
className={`results-window-resize results-window-resize-${direction}`} aria-hidden="true"
onPointerDown={startResize(direction)} onPointerMove={moveResize} onPointerUp={stopResize} onPointerCancel={stopResize}/>)}
</section>
</div>, document.body);
}
function SourceInfoDialog({ title, context, prose, interactiveGraph, children, onClose }: {
title: string; context: string; prose?: string; interactiveGraph?: boolean; children?: ReactNode; onClose(): void;
}) {
const titleId = useId();
const closeRef = useRef<HTMLButtonElement>(null);
const dialogRef = useRef<HTMLElement>(null);
useEffect(() => { closeRef.current?.focus(); }, []);
return createPortal(<div className="client-env-table-backdrop"
onKeyDown={event => {
if (event.key === 'Escape') { event.stopPropagation(); onClose(); }
if (event.key === 'Tab' && dialogRef.current) {
const focusable = [...dialogRef.current.querySelectorAll<HTMLElement>('button:not(:disabled), input, select, summary')];
const first = focusable[0]; const last = focusable.at(-1);
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
}
event.stopPropagation();
}}
onMouseDown={event => { if (event.target === event.currentTarget) onClose(); }}>
<section ref={dialogRef} className="client-env-table-dialog client-env-evidence" role="dialog" aria-modal="true" aria-labelledby={titleId}>
<header className="client-env-table-dialog-head"><div><p>{context}</p><h2 id={titleId}>{title}</h2></div>
<button ref={closeRef} type="button" aria-label={`Close ${title} detail`} onClick={onClose}>×</button></header>
<div className="client-env-table-dialog-body">
{prose && <p>{prose}</p>}
{interactiveGraph && <p>The interactive graph is available in the original PingCastle report.</p>}
{children}
</div>
</section>
</div>, document.body);
}
function EvidenceTable({ table, title, onOpenLink }: {
table: ScannerReportTable; title: string;
onOpenLink(targetId: string): void;
}) {
const [query, setQuery] = useState('');
const [pageSize, setPageSize] = useState<number>(25);
const [page, setPage] = useState(0);
const width = table.rows.reduce((maximum, row) => Math.max(maximum, row.length), table.headers.length);
const needle = query.trim().toLocaleLowerCase();
const filteredRows = needle ? table.rows.map((row, rowIndex) => ({ row, rowIndex }))
.filter(({ row }) => row.some(value => valueText(value).toLocaleLowerCase().includes(needle)))
: table.rows.map((row, rowIndex) => ({ row, rowIndex }));
const start = page * pageSize;
const shownRows = filteredRows.slice(start, start + pageSize);
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pageSize));
return <section className="client-env-evidence-table"> return <section className="client-env-evidence-table">
<h5>{subsection || nativeName || 'Source table'} <span>{subsection && nativeName && nativeName !== subsection ? `${nativeName} · ` : ''}{table.rows.length.toLocaleString('en-US')} rows</span></h5> {title && <h5>{title}</h5>}
{width ? <div className="client-env-evidence-scroll"><table> <div className="client-env-table-toolbar">
<thead><tr>{Array.from({ length: width }, (_, index) => <input type="search" aria-label={title ? `Search ${title} table` : 'Search table'} placeholder="Search table" value={query}
<th scope="col" key={index}>{table.headers[index] || `Column ${index + 1}`}</th>)}</tr></thead> onChange={event => { setQuery(event.target.value); setPage(0); }}/></div>
<tbody>{table.rows.slice(0, visible).map((row, rowIndex) => <tr key={rowIndex}> {width > 0 && <div className="client-env-evidence-scroll client-env-table-inline-scroll"><table><thead><tr>
{Array.from({ length: width }, (_, index) => <td key={index}>{valueText(row[index])}</td>)} {Array.from({ length: width }, (_, index) => <th scope="col" key={index}>{table.headers[index] || `Column ${index + 1}`}</th>)}
</tr>)}</tbody> </tr></thead><tbody>{shownRows.map(({ row, rowIndex }) => <tr key={rowIndex}>
</table></div> : <p>No columns retained in this source table.</p>} {Array.from({ length: width }, (_, columnIndex) => {
{visible < table.rows.length && <button type="button" className="btn" onClick={() => setVisible(value => value + PAGE_SIZE)}> const targetId = table.cellLinks?.find(link => link.rowIndex === rowIndex && link.columnIndex === columnIndex)?.targetId;
Show more rows ({Math.min(visible, table.rows.length).toLocaleString('en-US')} of {table.rows.length.toLocaleString('en-US')}) return <td key={columnIndex}>{targetId
</button>} ? <button type="button" className="client-env-table-cell-link" aria-haspopup="dialog" onClick={() => onOpenLink(targetId)}>{valueText(row[columnIndex])}</button>
: valueText(row[columnIndex])}</td>;
})}</tr>)}</tbody></table></div>}
{width > 0 && !filteredRows.length && <p>No rows match this search.</p>}
{width > 0 && <div className="client-env-table-pagination"><span>{filteredRows.length
? `Showing ${start + 1}–${Math.min(start + pageSize, filteredRows.length)} of ${filteredRows.length}` : 'Showing 0 of 0'} rows</span>
<label>Rows per page <select value={pageSize} onChange={event => { setPageSize(Number(event.target.value)); setPage(0); }}>
{TABLE_PAGE_SIZES.map(size => <option key={size} value={size}>{size}</option>)}
</select></label><div><button type="button" className="btn" disabled={page === 0} onClick={() => setPage(value => value - 1)}>Previous</button>
<span>{page + 1} / {pageCount}</span><button type="button" className="btn" disabled={page + 1 >= pageCount}
onClick={() => setPage(value => value + 1)}>Next</button></div></div>}
</section>; </section>;
} }
function EvidenceSection({ section, snapshot }: { section: ScannerReportSection; snapshot: EnvironmentSnapshot }) { function EvidenceSection({ section, snapshot, linkedSections }: {
section: ScannerReportSection; snapshot: EnvironmentSnapshot;
linkedSections?: Map<string, ScannerReportSection>;
}) {
const title = environmentSectionTitle(snapshot, section);
const prose = environmentSectionProse(section, title);
const titles = section.tables.map(table => environmentTableTitle(snapshot, section, table));
const hasUnstructuredTables = section.tables.some(table => !table.presentation);
const linkedSection = (targetId: string) => linkedSections?.get(environmentSectionReference(section, targetId));
const [modalStack, setModalStack] = useState<string[]>([]);
const [openInlineIds, setOpenInlineIds] = useState<string[]>([]);
const triggerRef = useRef<HTMLElement | null>(null);
const openModal = (targetId: string) => { if (!modalStack.length) triggerRef.current = document.activeElement as HTMLElement; setModalStack(ids => [...ids, targetId]); };
const openModalId = modalStack.at(-1);
const modalIndexes = openModalId ? section.tables.flatMap((table, index) => table.targetId === openModalId ? [index] : []) : [];
const modalIndex = modalIndexes.length === 1 ? modalIndexes[0] : -1;
const modalDetail = section.modalDetails?.find(detail => detail.targetId === openModalId);
const linkedChild = openModalId ? linkedSection(openModalId) : undefined;
const linkedChildTitle = environmentNativeHeading(section.detailLinks?.find(link => link.targetId === openModalId)?.label)
|| (linkedChild && environmentSectionTitle(snapshot, linkedChild)) || title;
const closeModal = () => { setModalStack(ids => ids.slice(0, -1)); if (modalStack.length === 1) triggerRef.current?.focus(); };
const renderTable = (table: ScannerReportTable, index: number) => {
return <EvidenceTable table={table} title={titles[index]}
onOpenLink={openModal} key={`${table.sourceTableIndex ?? index}:${table.name}`}/>;
};
return <section className="client-env-evidence-section"> return <section className="client-env-evidence-section">
<h4>{environmentSectionTitle(snapshot, section)}</h4> <h4>{title}</h4>
<div className="client-env-evidence-scroll"><table className="client-env-evidence-meta"><tbody> {prose && !hasUnstructuredTables && <p className="client-env-evidence-prose">{prose}</p>}
<tr><th scope="row">Tool</th><td>{section.tool.name} · {section.tool.version ?? 'version not reported'}</td></tr> {section.tables.map((table, index) => table.presentation === 'modal'
<tr><th scope="row">Section key</th><td>{section.sectionKey}</td></tr> || table.presentation === 'linked-inline' && section.detailLinks?.some(link => link.targetId === table.targetId)
<tr><th scope="row">State</th><td>{section.state}</td></tr> ? null : renderTable(table, index))}
<tr><th scope="row">Source</th><td>{sourcePosition(section.source)}</td></tr> {section.detailLinks?.map(link => {
<tr><th scope="row">Scan run</th><td>{section.scanRunId}</td></tr> const open = openInlineIds.includes(link.targetId);
<tr><th scope="row">Import file</th><td>{section.importFileId}</td></tr> const linkedIndexes = section.tables.flatMap((table, index) => table.targetId === link.targetId ? [index] : []);
<tr><th scope="row">Extractor</th><td>{section.extractorVersion}</td></tr> return <div className="client-env-evidence-linked" key={link.targetId}>
<tr><th scope="row">Attributes</th><td>{attributesText(section.attributes)}</td></tr> <button type="button" className="client-env-table-detail-link" aria-expanded={open}
<tr><th scope="row">Section ID</th><td>{section.id}</td></tr> onClick={() => setOpenInlineIds(ids => open ? ids.filter(id => id !== link.targetId) : [...ids, link.targetId])}>{environmentNativeHeading(link.label) || title}</button>
<tr><th scope="row">Parent / order</th><td>{section.parentSectionKey ?? '—'} · {section.ordinal}</td></tr> {open && <div className="client-env-evidence-linked-body">
</tbody></table></div> {link.prose && <p>{link.prose}</p>}
{section.prose !== undefined && <p className="client-env-evidence-prose">{section.prose === '' ? '""' : section.prose}</p>} {linkedIndexes.map(index => renderTable(section.tables[index], index))}
{section.tables.map((table, index) => <EvidenceTable table={table} subsection={environmentTableSubsection(snapshot, section, table)} {linkedSection(link.targetId)
key={`${table.sourceTableIndex ?? index}:${table.name}`}/>)} && <EvidenceSection section={linkedSection(link.targetId)!} snapshot={snapshot}
</section>; linkedSections={linkedSections}/>}
} </div>}
</div>;
function ObservationTable({ observations }: { observations: EnvironmentObservation[] }) { })}
const [visible, setVisible] = useState(PAGE_SIZE); {modalIndex >= 0 && <SourceTableDialog table={section.tables[modalIndex]}
return <section className="client-env-evidence-section"> title={titles[modalIndex] || title}
<h4>Retained observations <span>{observations.length.toLocaleString('en-US')}</span></h4> context={title}
<div className="client-env-evidence-scroll"><table><thead><tr> onClose={closeModal} onOpenLink={openModal}/>}
<th scope="col">Observation ID</th><th scope="col">Tool</th><th scope="col">Subject</th><th scope="col">Property</th> {modalIndexes.length > 1 && <SourceInfoDialog title={titles[modalIndexes[0]] || title} context={title} onClose={closeModal}>
<th scope="col">Original value</th><th scope="col">Normalized value</th><th scope="col">Value type</th><th scope="col">State</th> {modalIndexes.map(index => renderTable(section.tables[index], index))}
<th scope="col">Section</th><th scope="col">Source</th><th scope="col">Scan run</th> </SourceInfoDialog>}
<th scope="col">Import file</th><th scope="col">Observed at</th><th scope="col">Mapping</th><th scope="col">Attributes</th> {modalIndex < 0 && modalDetail && <SourceInfoDialog title={environmentNativeHeading(modalDetail.title) || title} context={title}
</tr></thead><tbody>{observations.slice(0, visible).map(observation => <tr key={observation.id}> prose={modalDetail.prose} interactiveGraph={modalDetail.interactiveGraph} onClose={closeModal}/>}
<td>{observation.id}</td><td>{observation.tool.name} ({observation.tool.id}) · {observation.tool.version ?? 'version not reported'}</td> {modalIndexes.length === 0 && !modalDetail && linkedChild && <SourceInfoDialog title={linkedChildTitle} context={title} onClose={closeModal}>
<td>{[observation.subject.type, observation.subject.label, observation.subject.identity].filter(Boolean).join(' · ')}</td> <EvidenceSection section={linkedChild} snapshot={snapshot} linkedSections={linkedSections}/>
<td>{observation.label}<br/><span>{observation.property}</span></td> </SourceInfoDialog>}
<td>{valueText(observation.originalValue)}</td><td>{valueText(observation.normalizedValue)}</td>
<td>{observation.valueType}</td><td>{observation.state}</td><td>{observation.sectionKey ?? '—'}</td><td>{sourcePosition(observation.source)}</td>
<td>{observation.scanRunId}</td><td>{observation.importFileId}</td><td>{observation.observedAt ?? '—'}</td><td>{observation.mappingVersion}</td>
<td>{attributesText(observation.attributes)}</td>
</tr>)}</tbody></table></div>
{visible < observations.length && <button type="button" className="btn" onClick={() => setVisible(value => value + PAGE_SIZE)}>
Show more observations ({visible.toLocaleString('en-US')} of {observations.length.toLocaleString('en-US')})
</button>}
</section>; </section>;
} }
export function EnvironmentEvidenceTables({ snapshot, group }: { snapshot?: EnvironmentSnapshot; group: EnvironmentEvidenceGroup }) { export function EnvironmentEvidenceTables({ snapshot, group }: { snapshot?: EnvironmentSnapshot; group: EnvironmentEvidenceGroup }) {
const [visibleSections, setVisibleSections] = useState(PAGE_SIZE); const [visibleSections, setVisibleSections] = useState(PAGE_SIZE);
if (!snapshot) return <p className="client-env-evidence-empty">No retained scanner context available for this selection.</p>; if (!snapshot) return <p className="client-env-evidence-empty">No retained scanner context available for this selection.</p>;
const { sections, observations } = environmentEvidenceForGroup(snapshot, group); const { sections } = environmentEvidenceForGroup(snapshot, group);
if (!sections.length && !observations.length && group !== 'all') const linkedSections = new Map<string, ScannerReportSection>();
return <p className="client-env-evidence-empty">No retained source facts for this category.</p>; for (const section of sections) {
const key = environmentSectionReference(section, section.sectionKey);
if (sections.filter(candidate => environmentSectionReference(candidate, candidate.sectionKey) === key).length === 1)
linkedSections.set(key, section);
}
const linkedKeys = new Set(sections.flatMap(section => [
...(section.detailLinks?.map(link => environmentSectionReference(section, link.targetId)) ?? []),
...section.tables.flatMap(table => table.cellLinks?.map(link => environmentSectionReference(section, link.targetId)) ?? [])
]));
const topSections = sections.filter(section => {
const key = environmentSectionReference(section, section.sectionKey);
return !linkedKeys.has(key) || !linkedSections.has(key);
});
if (!sections.length) return <p className="client-env-evidence-empty">No original report sections for this category.</p>;
return <div className="client-env-evidence"> return <div className="client-env-evidence">
{group === 'all' && <section className="client-env-evidence-section"> {sections.length > 0 && <div className="client-env-evidence-block">
<h3>Snapshot scope</h3><div className="client-env-evidence-scroll"><table className="client-env-evidence-meta"><tbody> {topSections.slice(0, visibleSections).map(section => <EvidenceSection section={section} snapshot={snapshot}
<tr><th scope="row">Project</th><td>{snapshot.projectId}</td></tr> linkedSections={linkedSections} key={section.id}/>)}
<tr><th scope="row">Assessment</th><td>{snapshot.assessmentId ?? '—'}</td></tr> {visibleSections < topSections.length && <button type="button" className="btn" onClick={() => setVisibleSections(value => value + PAGE_SIZE)}>
<tr><th scope="row">Scan runs</th><td>{snapshot.scanRunIds.join(', ') || '—'}</td></tr> Show more sections ({visibleSections.toLocaleString('en-US')} of {topSections.length.toLocaleString('en-US')})
<tr><th scope="row">Generated at</th><td>{snapshot.generatedAt}</td></tr>
<tr><th scope="row">Observed</th><td>{snapshot.coverage.observed}</td></tr>
<tr><th scope="row">Explicitly absent</th><td>{snapshot.coverage.explicitlyAbsent}</td></tr>
<tr><th scope="row">Empty</th><td>{snapshot.coverage.empty}</td></tr>
<tr><th scope="row">Restricted</th><td>{snapshot.coverage.restricted}</td></tr>
<tr><th scope="row">Incomplete</th><td>{snapshot.coverage.incomplete}</td></tr>
<tr><th scope="row">Snapshot version</th><td>{snapshot.version}</td></tr>
</tbody></table></div>
</section>}
{group === 'all' && !sections.length && !observations.length
&& <p className="client-env-evidence-empty">No retained report sections or observations in this snapshot.</p>}
{sections.length > 0 && <div className="client-env-evidence-block"><h3>Original report sections <span>{sections.length.toLocaleString('en-US')}</span></h3>
{sections.slice(0, visibleSections).map(section => <EvidenceSection section={section} snapshot={snapshot} key={section.id}/>)}
{visibleSections < sections.length && <button type="button" className="btn" onClick={() => setVisibleSections(value => value + PAGE_SIZE)}>
Show more sections ({visibleSections.toLocaleString('en-US')} of {sections.length.toLocaleString('en-US')})
</button>}</div>} </button>}</div>}
{observations.length > 0 && <ObservationTable observations={observations}/>}
</div>; </div>;
} }

View File

@ -2,7 +2,9 @@ import { useRef, useState, type ReactNode } from 'react';
import { FloatingDataWindow } from '../../components/FloatingDataWindow/FloatingDataWindow'; import { FloatingDataWindow } from '../../components/FloatingDataWindow/FloatingDataWindow';
/** One interaction for every Environment Detail tile, including OS inventory. */ /** One interaction for every Environment Detail tile, including OS inventory. */
export function EnvironmentResultsButton({ title, children }: { title: string; children: ReactNode }) { export function EnvironmentResultsButton({ title, children }: {
title: string; children: ReactNode | ((dismiss: () => void) => ReactNode);
}) {
const [windowOpen, setWindowOpen] = useState(false); const [windowOpen, setWindowOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
const closeWindow = () => { const closeWindow = () => {
@ -13,6 +15,8 @@ export function EnvironmentResultsButton({ title, children }: { title: string; c
<button ref={triggerRef} className={`btn results-btn client-environment-results-button${windowOpen ? ' is-open' : ''}`} <button ref={triggerRef} className={`btn results-btn client-environment-results-button${windowOpen ? ' is-open' : ''}`}
type="button" onClick={() => setWindowOpen(true)} aria-haspopup="dialog" aria-expanded={windowOpen} type="button" onClick={() => setWindowOpen(true)} aria-haspopup="dialog" aria-expanded={windowOpen}
aria-label={`Open ${title} results`} title="Open results">Results</button> aria-label={`Open ${title} results`} title="Open results">Results</button>
{windowOpen && <FloatingDataWindow title={title} onClose={closeWindow}>{children}</FloatingDataWindow>} {windowOpen && <FloatingDataWindow title={title} onClose={closeWindow}>
{typeof children === 'function' ? children(() => setWindowOpen(false)) : children}
</FloatingDataWindow>}
</>; </>;
} }

View File

@ -9,7 +9,7 @@ const quantity = (value?: number) => value === undefined ? '—' : value.toLocal
function OperatingSystemGroups({ groups, inventories }: { groups: OperatingSystemInventoryGroup[]; inventories: string[] }) { function OperatingSystemGroups({ groups, inventories }: { groups: OperatingSystemInventoryGroup[]; inventories: string[] }) {
return <div className="client-os-groups">{groups.map(group => <section className="client-os-group" key={group.label}> return <div className="client-os-groups">{groups.map(group => <section className="client-os-group" key={group.label}>
<h4><span>{group.label}</span><span aria-label={`${group.label} total`}>{quantity(group.total)}</span></h4> <h4><span>{group.label}</span><span aria-label={`${group.label} total`}>{quantity(group.total)}</span></h4>
{group.entries.length ? <table><thead><tr> {group.entries.length ? <div className="client-env-evidence-scroll"><table><thead><tr>
<th scope="col">Operating system</th> <th scope="col">Operating system</th>
{inventories.length > 1 && <th className="client-os-source" scope="col">Inventory</th>} {inventories.length > 1 && <th className="client-os-source" scope="col">Inventory</th>}
<th className="client-os-count" scope="col">Devices</th> <th className="client-os-count" scope="col">Devices</th>
@ -26,7 +26,7 @@ function OperatingSystemGroups({ groups, inventories }: { groups: OperatingSyste
<td className="client-os-count">{quantity(entry.inactive)}</td> <td className="client-os-count">{quantity(entry.inactive)}</td>
<td className="client-os-count">{quantity(entry.enabled)}</td> <td className="client-os-count">{quantity(entry.enabled)}</td>
<td className="client-os-count">{quantity(entry.disabled)}</td> <td className="client-os-count">{quantity(entry.disabled)}</td>
</tr>)}</tbody></table> </tr>)}</tbody></table></div>
: <p className="client-os-empty">No classified inventory available</p>} : <p className="client-os-empty">No classified inventory available</p>}
</section>)}</div>; </section>)}</div>;
} }
@ -35,6 +35,9 @@ export function OperatingSystemInventory({ groups, icon, snapshot }: {
groups: OperatingSystemInventoryGroup[]; icon?: ReactNode; snapshot?: EnvironmentSnapshot; groups: OperatingSystemInventoryGroup[]; icon?: ReactNode; snapshot?: EnvironmentSnapshot;
}) { }) {
const inventories = [...new Set(groups.flatMap(group => group.entries.map(entry => entry.inventory)))]; 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, 8 - summary.length))
.map(entry => ({ label: entry.name, value: quantity(entry.count) }));
return <section className="client-os-inventory" aria-label="Operating systems"> return <section className="client-os-inventory" aria-label="Operating systems">
<header> <header>
<h3>{icon}<span>Operating systems</span></h3> <h3>{icon}<span>Operating systems</span></h3>
@ -43,6 +46,10 @@ export function OperatingSystemInventory({ groups, icon, snapshot }: {
<EnvironmentEvidenceTables snapshot={snapshot} group="operating-systems"/> <EnvironmentEvidenceTables snapshot={snapshot} group="operating-systems"/>
</EnvironmentResultsButton> </EnvironmentResultsButton>
</header> </header>
<OperatingSystemGroups groups={groups} inventories={inventories} /> <dl className="client-env-facts">
{summary.length || systems.length ? [...summary, ...systems].map((item, index) => <div key={`${item.label}:${index}`}>
<dt>{item.label}</dt><dd>{item.value}</dd>
</div>) : <div><dt>Inventory</dt><dd>—</dd></div>}
</dl>
</section>; </section>;
} }

View File

@ -8,11 +8,47 @@ export interface EnvironmentEvidence {
observations: EnvironmentObservation[]; observations: EnvironmentObservation[];
} }
export interface SectionEvidence {
tables: EnvironmentObservation[][];
sectionObservations: EnvironmentObservation[];
}
/** Match only exact retained provenance; ambiguous section or table identities stay unassigned. */
export function partitionEnvironmentObservations(
sections: ScannerReportSection[], observations: EnvironmentObservation[]
): { bySection: Map<string, SectionEvidence>; unassigned: EnvironmentObservation[] } {
const bySection = new Map(sections.map(section => [section.id, {
tables: section.tables.map(() => [] as EnvironmentObservation[]),
sectionObservations: [] as EnvironmentObservation[]
}]));
const unassigned: EnvironmentObservation[] = [];
for (const observation of observations) {
const matches = sections.filter(section => section.scanRunId === observation.scanRunId
&& section.importFileId === observation.importFileId && section.sectionKey === observation.sectionKey);
if (matches.length !== 1) { unassigned.push(observation); continue; }
const section = matches[0];
const target = bySection.get(section.id)!;
const tableIndex = observation.attributes.sourceTableIndex;
if (typeof tableIndex === 'number') {
const tableMatches = section.tables.flatMap((table, index) => table.sourceTableIndex === tableIndex ? [index] : []);
if (tableMatches.length === 1) { target.tables[tableMatches[0]].push(observation); continue; }
}
target.sectionObservations.push(observation);
}
return { bySection, unassigned };
}
interface SubsectionLabels { interface SubsectionLabels {
explicit: Set<string>; explicit: Set<string>;
rowCount: Set<string>; rowCount: Set<string>;
} }
/** Import-generated ordinals are provenance, never native report headings. */
export function environmentNativeHeading(value?: string): string {
const heading = (value ?? '').trim().replace(/\s*[·–—]\s*(?:Tabelle|Table)\s+\d+(?:\s+(?:of|von)\s+\d+)?$/i, '').trim();
return /^(?:Tabelle|Table)\s+\d+(?:\s+(?:of|von)\s+\d+)?$/i.test(heading) ? '' : heading;
}
const subsectionCache = new WeakMap<EnvironmentSnapshot, Map<string, SubsectionLabels>>(); const subsectionCache = new WeakMap<EnvironmentSnapshot, Map<string, SubsectionLabels>>();
const subsectionKey = (runId: string, fileId: string, sectionKey: string, tableIndex: number) => const subsectionKey = (runId: string, fileId: string, sectionKey: string, tableIndex: number) =>
`${runId}\u0000${fileId}\u0000${sectionKey}\u0000${tableIndex}`; `${runId}\u0000${fileId}\u0000${sectionKey}\u0000${tableIndex}`;
@ -28,9 +64,10 @@ function indexedSubsections(snapshot: EnvironmentSnapshot): Map<string, Subsecti
let labels = index.get(key); let labels = index.get(key);
if (!labels) { labels = { explicit: new Set(), rowCount: new Set() }; index.set(key, labels); } if (!labels) { labels = { explicit: new Set(), rowCount: new Set() }; index.set(key, labels); }
const explicit = observation.attributes.subsectionLabel; const explicit = observation.attributes.subsectionLabel;
if (typeof explicit === 'string' && explicit.trim()) labels.explicit.add(explicit.trim()); const nativeExplicit = typeof explicit === 'string' ? environmentNativeHeading(explicit) : '';
if (/\.row_count$/.test(observation.property) && observation.subject.label?.trim()) if (nativeExplicit) labels.explicit.add(nativeExplicit);
labels.rowCount.add(observation.subject.label.trim()); const rowLabel = environmentNativeHeading(observation.subject.label);
if (/\.row_count$/.test(observation.property) && rowLabel) labels.rowCount.add(rowLabel);
} }
subsectionCache.set(snapshot, index); subsectionCache.set(snapshot, index);
return index; return index;
@ -48,15 +85,43 @@ export function environmentTableSubsection(
return labels.rowCount.size === 1 ? [...labels.rowCount][0] : undefined; return labels.rowCount.size === 1 ? [...labels.rowCount][0] : undefined;
} }
/** Native labels only: a shared subsection must not replace several different table names. */
export function environmentTableTitle(snapshot: EnvironmentSnapshot, section: ScannerReportSection, table: ScannerReportTable): string {
const title = environmentNativeHeading(table.title);
if (title) return title;
const subsection = environmentTableSubsection(snapshot, section, table);
const shared = subsection && section.tables.filter(candidate => environmentTableSubsection(snapshot, section, candidate) === subsection).length > 1;
const nativeName = environmentNativeHeading(table.name);
return (shared ? nativeName || subsection : subsection || nativeName) || '';
}
export function environmentSectionProse(section: ScannerReportSection, title: string): string | undefined {
if (!section.prose) return section.prose;
const withoutLinks = (section.detailLinks ?? []).reduce((prose, link) => {
const label = link.label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return prose.replace(new RegExp(`${label}(?:\\s*\\[\\d+\\])?`, 'g'), '').trim();
}, section.prose);
const heading = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return title ? withoutLinks.replace(new RegExp(`^${heading}(?=\\s|$)`, 'i'), '').trim() : withoutLinks;
}
/** HTML ids are local to one original report, never global across scans or files. */
export function environmentSectionReference(section: Pick<ScannerReportSection, 'scanRunId' | 'importFileId'>, sectionKey: string): string {
return `${section.scanRunId}\u0000${section.importFileId}\u0000${sectionKey}`;
}
/** A section labelled with its first subsection must not appear to own all later siblings. */ /** A section labelled with its first subsection must not appear to own all later siblings. */
export function environmentSectionTitle(snapshot: EnvironmentSnapshot, section: ScannerReportSection): string { export function environmentSectionTitle(snapshot: EnvironmentSnapshot, section: ScannerReportSection): string {
const sourceHeading = /^(.+?)\s+This section\b/i.exec(section.prose ?? '')?.[1]?.trim();
if (sourceHeading && sourceHeading.replace(/[^a-z0-9]/gi, '').toLowerCase()
=== section.sectionKey.replace(/^section/i, '').replace(/[^a-z0-9]/gi, '').toLowerCase()) return sourceHeading;
const subsectionLabels = new Set(section.tables const subsectionLabels = new Set(section.tables
.map(table => environmentTableSubsection(snapshot, section, table)).filter((label): label is string => Boolean(label))); .map(table => environmentTableSubsection(snapshot, section, table)).filter((label): label is string => Boolean(label)));
if (subsectionLabels.size > 1 && subsectionLabels.has(section.label)) { if (subsectionLabels.size > 1 && subsectionLabels.has(section.label)) {
const fromKey = section.sectionKey.replace(/^section/i, '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').trim(); const fromKey = section.sectionKey.replace(/^section/i, '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').trim();
if (fromKey && fromKey.toLocaleLowerCase('en-US') !== section.label.toLocaleLowerCase('en-US')) return fromKey; if (fromKey && fromKey.toLocaleLowerCase('en-US') !== section.label.toLocaleLowerCase('en-US')) return fromKey;
} }
return section.label || section.sectionKey; return environmentNativeHeading(section.label) || environmentNativeHeading(section.sectionKey);
} }
const osTable = (table: ScannerReportTable) => /operating\s+systems?/i.test(table.name); const osTable = (table: ScannerReportTable) => /operating\s+systems?/i.test(table.name);
@ -105,11 +170,24 @@ export function environmentEvidenceForGroup(
group: EnvironmentEvidenceGroup group: EnvironmentEvidenceGroup
): EnvironmentEvidence { ): EnvironmentEvidence {
if (!snapshot) return { sections: [], observations: [] }; if (!snapshot) return { sections: [], observations: [] };
return { const sections = snapshot.sections.flatMap(section => {
sections: snapshot.sections.flatMap(section => {
const selected = sectionForGroup(section, group); const selected = sectionForGroup(section, group);
return selected ? [selected] : []; return selected ? [selected] : [];
}), });
// Keep native linked child sections in the same category as their exact parent report.
const selectedIds = new Set(sections.map(section => section.id));
for (let index = 0; index < sections.length; index += 1) {
const section = sections[index];
const targets = new Set([
...(section.detailLinks?.map(link => link.targetId) ?? []),
...section.tables.flatMap(table => table.cellLinks?.map(link => link.targetId) ?? [])
]);
snapshot.sections.filter(candidate => candidate.scanRunId === section.scanRunId
&& candidate.importFileId === section.importFileId && targets.has(candidate.sectionKey))
.forEach(candidate => { if (!selectedIds.has(candidate.id)) { selectedIds.add(candidate.id); sections.push(candidate); } });
}
return {
sections,
observations: group === 'all' ? snapshot.observations observations: group === 'all' ? snapshot.observations
: snapshot.observations.filter(observation => observationInGroup(observation, group)) : snapshot.observations.filter(observation => observationInGroup(observation, group))
}; };

View File

@ -8,7 +8,7 @@ export const securityDomainCatalog = [
{ id: 'privilege', label: 'Privilege & Attack Paths', glyph: 'shield', pattern: /privileg|admin|delegat|control\s*path|attack\s*path|tier\s*0|pim/i }, { id: 'privilege', label: 'Privilege & Attack Paths', glyph: 'shield', pattern: /privileg|admin|delegat|control\s*path|attack\s*path|tier\s*0|pim/i },
{ id: 'authentication', label: 'Authentication & Access', glyph: 'lock', pattern: /authenticat|\bmfa\b|conditional\s*access|kerberos|sign[ -]?in|break[ -]?glass|\bldaps?\b|channel\s*binding|anonymous\s*binding|rootdse|tls\s*1\.[01]/i }, { id: 'authentication', label: 'Authentication & Access', glyph: 'lock', pattern: /authenticat|\bmfa\b|conditional\s*access|kerberos|sign[ -]?in|break[ -]?glass|\bldaps?\b|channel\s*binding|anonymous\s*binding|rootdse|tls\s*1\.[01]/i },
{ id: 'infrastructure', label: 'AD Infrastructure & PKI', glyph: 'server', pattern: /domain\s*controller|forest|\btrust|certificate|\bpki\b|\bdns\b|replication|schema|organizational\s*units?|\bous?\b.*accidental\s*deletion|\bntfrs\b|\bsysvol\b/i }, { id: 'infrastructure', label: 'AD Infrastructure & PKI', glyph: 'server', pattern: /domain\s*controller|forest|\btrust|certificate|\bpki\b|\bdns\b|replication|schema|organizational\s*units?|\bous?\b.*accidental\s*deletion|\bntfrs\b|\bsysvol\b/i },
{ id: 'hardening', label: 'System Hardening', glyph: 'code', pattern: /cis-cat|benchmark|hardening|hardened\s*paths?|firewall|print\s*spooler|\bspooler\b|attack\s*surface|\basr\b|defender|operating\s*system|obsolete\s+os|unsupported\s+(?:os|operating\s*system)|script\s*engines?|\bcve-\d+/i }, { id: 'hardening', label: 'System Hardening', glyph: 'code', pattern: /cis-cat|benchmark|hardening|hardened\s*paths?|firewall|print\s*spooler|\bspooler\b|attack\s*surface|\basr\b|defender|operating\s*system|obsolete\s+os|unsupported\s+(?:os|operating\s*system)|script\s*engines?|\bcve-\d+|lock\s*screen|\bsmb\s*v?1\b|(?:ip\s*)?source\s*routing|icmp\s*redirect|keep\s*alive|netbios\s*name\s*release|router\s*discovery|tcp\s*(?:max\s*data\s*)?retransmissions?|kernel\s*dma\s*protection|nonamereleaseondemand|performrouterdiscovery|tcpmaxdataretransmissions|disableipsourcerouting|enableicmpredirect|keepalivetime/i },
{ id: 'gpo', label: 'GPO & Baseline', glyph: 'policy', pattern: /\bgpo\b|group\s*policy|baseline|policy\s*(?:setting|deviation|conflict)|duplicates?/i }, { id: 'gpo', label: 'GPO & Baseline', glyph: 'policy', pattern: /\bgpo\b|group\s*policy|baseline|policy\s*(?:setting|deviation|conflict)|duplicates?/i },
{ id: 'cloud', label: 'Cloud & Applications', glyph: 'cloud', pattern: /\bcloud\b|\bentra\b|application|\bapps?\b|exchange|teams|sharepoint|guest|intune|purview/i }, { id: 'cloud', label: 'Cloud & Applications', glyph: 'cloud', pattern: /\bcloud\b|\bentra\b|application|\bapps?\b|exchange|teams|sharepoint|guest|intune|purview/i },
{ id: 'recovery', label: 'Monitoring & Recovery', glyph: 'chart', pattern: /audit|monitor|logging|\bwef\b|backup|recover|recycle\s*bin|sensor|event\s*log/i } { id: 'recovery', label: 'Monitoring & Recovery', glyph: 'chart', pattern: /audit|monitor|logging|\bwef\b|backup|recover|recycle\s*bin|sensor|event\s*log/i }

View File

@ -11,6 +11,8 @@ import type { DatasetSheet } from '../../domain/dataset';
import type { FindingRecord } from '../../domain/finding'; import type { FindingRecord } from '../../domain/finding';
import { resultReferenceValues } from '../../profiles/purple-knight/result-linker'; import { resultReferenceValues } from '../../profiles/purple-knight/result-linker';
import { maesterFindingTitle } from '../../profiles/maester/presentation'; import { maesterFindingTitle } from '../../profiles/maester/presentation';
import { cisBenchmarkRuleId } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
import { cisSectionHeading } from '../../profiles/cis-cat/cis-benchmark-section-titles';
import { pingCastleSectionIds } from '../../profiles/pingcastle/pingcastle-section-links'; import { pingCastleSectionIds } from '../../profiles/pingcastle/pingcastle-section-links';
import { recordResultSheets } from '../linked-results/record-results'; import { recordResultSheets } from '../linked-results/record-results';
import { resultMessageFor, resultOutcomeFor, resultSectionsFor, type ResultSection } from '../linked-results/result-message'; import { resultMessageFor, resultOutcomeFor, resultSectionsFor, type ResultSection } from '../linked-results/result-message';
@ -210,9 +212,10 @@ export function DatasetViewer() {
const recordTitle = (record: FindingRecord) => { const recordTitle = (record: FindingRecord) => {
const candidate = recordTitleFor(record, titleColumn, activeSheet.toolId ?? dataset.profileId); const candidate = recordTitleFor(record, titleColumn, activeSheet.toolId ?? dataset.profileId);
return toolIdForRecord(record, activeSheet.toolId ?? dataset.profileId) === 'maester' const recordToolId = toolIdForRecord(record, activeSheet.toolId ?? dataset.profileId);
? maesterFindingTitle(record, candidate) if (recordToolId === 'maester') return maesterFindingTitle(record, candidate);
: candidate; // CIS headings name their benchmark section instead of the rule number, which stays in Source Rule ID.
return recordToolId === 'cis-cat' ? cisSectionHeading(record, cisBenchmarkRuleId(record, recordToolId), candidate) : candidate;
}; };
const selected = records.find(record => record.id === selectedRecordId) ?? records[0]; const selected = records.find(record => record.id === selectedRecordId) ?? records[0];
const toolId = activeSheet.toolId ?? dataset.profileId; const toolId = activeSheet.toolId ?? dataset.profileId;
@ -364,7 +367,7 @@ export function DatasetViewer() {
/> />
</div> </div>
) : view === 'table' ? ( ) : view === 'table' ? (
<DataTable sheet={activeSheet} records={records} search={search} /> <DataTable key={activeSheet.id} sheet={activeSheet} records={records} search={search} />
) : ( ) : (
<RawDataView records={records} raw={activeSheet.raw || dataset.rawText} <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(search || app.actionOnly || app.statusFilter !== 'all' || Object.keys(app.facetFilters).length)} search={search}/>

View File

@ -4,6 +4,8 @@ import type { DbStructureResponse, DbStructureTable } from '../../domain/db-stru
import { TriangleIcon } from '../../components/TriangleIcon'; import { TriangleIcon } from '../../components/TriangleIcon';
import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState'; import { PanelLoadingState } from '../../components/PanelLoadingState/PanelLoadingState';
import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog'; import { BackendConnectionDialog } from '../../components/BackendConnectionDialog/BackendConnectionDialog';
import { FloatingDataWindow } from '../../components/FloatingDataWindow/FloatingDataWindow';
import { buildDbStructureFlow, buildDbStructureViews, dbStructureNeighbours, dbStructureShortName, type DbFlowStep } from './db-structure-flow';
const assessorApi = new AssessorApiClient(); const assessorApi = new AssessorApiClient();
@ -56,7 +58,12 @@ function DbStructureTableCard({ table, isOpen, onToggle }: { table: DbStructureT
</span> </span>
<span className="db-structure-card-toggle" aria-hidden="true"><TriangleIcon direction={isOpen ? 'down' : 'right'} /></span> <span className="db-structure-card-toggle" aria-hidden="true"><TriangleIcon direction={isOpen ? 'down' : 'right'} /></span>
</button> </button>
{isOpen && <div className="db-structure-card-body" id={detailsId}> {isOpen && <div id={detailsId}><DbStructureTableDetails table={table} /></div>}
</article>;
}
function DbStructureTableDetails({ table }: { table: DbStructureTable }) {
return <div className="db-structure-card-body">
<section className="db-structure-card-section" aria-label={`Columns in ${table.name}`}> <section className="db-structure-card-section" aria-label={`Columns in ${table.name}`}>
<div className="db-structure-detail-heading"><h4>Columns</h4><span>{table.columns.length.toLocaleString('en-US')}</span></div> <div className="db-structure-detail-heading"><h4>Columns</h4><span>{table.columns.length.toLocaleString('en-US')}</span></div>
<div className="db-structure-column-head" aria-hidden="true"><span>Column</span><span>Type</span><span>Constraint</span></div> <div className="db-structure-column-head" aria-hidden="true"><span>Column</span><span>Type</span><span>Constraint</span></div>
@ -76,10 +83,93 @@ function DbStructureTableCard({ table, isOpen, onToggle }: { table: DbStructureT
</li>)} </li>)}
</ul> : <p>No outgoing relationships.</p>} </ul> : <p>No outgoing relationships.</p>}
</section> </section>
</div>} </div>;
}
function FlowStep({ step, number, selected, related, onSelect, continued, note, references = false }: {
step: DbFlowStep; number?: number; selected: string; related: Map<string, 'outgoing' | 'incoming'>;
onSelect: (name: string) => void; continued: boolean; note?: string;
/** Read-view chips only jump to a table; selection and relation marks stay on the owning step. */
references?: boolean;
}) {
return <article className={`db-flow__step is-${step.lane}${continued ? ' is-continued' : ''}`} aria-label={step.title}>
<header>{number !== undefined && <span className="db-flow__number">{number}</span>}<h4>{step.title}</h4></header>
<p>{step.purpose}</p>
{note && <p className="db-flow__note">{note}</p>}
<ul className="db-flow__tables">{step.resolved.map(table => <li key={table.name}>
<button className={`db-flow__table${references ? ' is-reference' : ''}`} type="button"
aria-pressed={references ? undefined : selected === table.name}
data-related={references ? undefined : related.get(table.name) ?? undefined}
onClick={() => onSelect(!references && selected === table.name ? '' : table.name)}>
{dbStructureShortName(table.name)}
{/* A zero estimate often means "not yet analysed" in PostgreSQL statistics, not an empty table. */}
{(table.approximateRowCount ?? 0) > 0 && <span className="db-flow__rows">~{table.approximateRowCount!.toLocaleString('en-US')}</span>}
</button>
</li>)}</ul>
</article>; </article>;
} }
/** Program-flow view of the schema: from upload to Work, with the two processing paths side by side. */
function DbStructureFlow({ tables }: { tables: DbStructureTable[] }) {
const [selected, setSelected] = useState('');
const { steps, other } = buildDbStructureFlow(tables);
const views = buildDbStructureViews(tables);
const neighbours = selected ? dbStructureNeighbours(tables, selected) : { outgoing: [], incoming: [] };
const related = new Map<string, 'outgoing' | 'incoming'>([
...neighbours.incoming.map(name => [name, 'incoming'] as const),
...neighbours.outgoing.map(name => [name, 'outgoing'] as const)
]);
const step = (id: string) => steps.find(item => item.id === id);
const lane = (name: DbFlowStep['lane']) => steps.filter(item => item.lane === name);
const props = { selected, related, onSelect: setSelected };
const selectedTable = tables.find(table => table.name === selected);
const shared = (id: string, continued: boolean) => {
const item = step(id);
return item ? <FlowStep step={item} {...props} continued={continued} /> : null;
};
return <section className="db-flow" aria-label="Data flow">
<header className="db-flow__header">
<h3>Data flow</h3>
</header>
{shared('foundation', true)}
{shared('upload', false)}
<div className="db-flow__split" aria-hidden="true"><span /><span /><span /></div>
<div className="db-flow__lanes">
{(['scanner', 'gpo'] as const).map(name => <div className={`db-flow__lane is-${name}`} key={name}>
<h4 className="db-flow__lane-title">{name === 'scanner' ? 'Scanner reports' : 'GPO backups'}</h4>
{lane(name).map((item, index) => <FlowStep key={item.id} step={item} number={index + 1} {...props} continued={false} />)}
</div>)}
<div className="db-flow__lane is-reference">
<h4 className="db-flow__lane-title">Reference data</h4>
{lane('reference').map(item => <FlowStep key={item.id} step={item} {...props} continued={false}
note={`Input to GPO step ${lane('gpo').findIndex(gpo => gpo.id === 'gpo-evaluation') + 1} · Baseline evaluation`} />)}
</div>
</div>
<div className="db-flow__merge" aria-hidden="true"><span /><span /><span /></div>
{/* Both paths end in two consumers: the Result DB read view and Work with its reviews. */}
<div className="db-flow__consumers">
{views.map(view => <FlowStep key={view.id} step={{ ...view, lane: 'shared', tables: view.sources }} {...props} continued={false}
references note="Source tables are owned by the steps above." />)}
{shared('review', false)}
</div>
{shared('comparison', false)}
{other.length > 0 && <FlowStep step={{ id: 'other', lane: 'shared', title: 'Other tables', purpose: 'Tables not yet assigned to a processing step.', tables: [], resolved: other }}
{...props} continued={false} />}
{selectedTable && <FloatingDataWindow title={selectedTable.name} onClose={() => setSelected('')}>
<section className="db-flow__detail" aria-label={`Details of ${dbStructureShortName(selectedTable.name)}`}>
<p className="db-structure-card-facts">{selectedTable.columns.length.toLocaleString('en-US')} columns · {selectedTable.foreignKeys.length.toLocaleString('en-US')} relations{selectedTable.approximateRowCount !== null ? ` · ~${selectedTable.approximateRowCount.toLocaleString('en-US')} rows` : ''}</p>
<DbStructureTableDetails table={selectedTable} />
<div className="db-flow__links">
<h4>Referenced by</h4>
{neighbours.incoming.length ? <ul>{neighbours.incoming.map(name => <li key={name}>
<button className="db-flow__table" type="button" onClick={() => setSelected(name)}>{dbStructureShortName(name)}</button></li>)}</ul>
: <p>No table references this table.</p>}
</div>
</section>
</FloatingDataWindow>}
</section>;
}
export function DbStructurePanel({ backendStatus }: DbStructurePanelProps) { export function DbStructurePanel({ backendStatus }: DbStructurePanelProps) {
const [structure, setStructure] = useState<DbStructureResponse | null>(null); const [structure, setStructure] = useState<DbStructureResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -125,23 +215,19 @@ export function DbStructurePanel({ backendStatus }: DbStructurePanelProps) {
return <div className="datahub-page db-structure-page"> return <div className="datahub-page db-structure-page">
{error && <div className="datahub-alert" role="alert"><span>{error}</span><button className="btn" type="button" onClick={() => void load()}>Reload</button></div>} {error && <div className="datahub-alert" role="alert"><span>{error}</span><button className="btn" type="button" onClick={() => void load()}>Reload</button></div>}
{loading && !structure ? <PanelLoadingState message="Loading database structure …"/> : structure && <> {loading && !structure ? <PanelLoadingState message="Loading database structure …"/> : structure && <>
<header className="db-structure-intro"> <div className="db-structure-intro-actions">
<div className="db-structure-intro-copy"> <span className={`db-structure-persistence is-${structure.persistence}`}><i aria-hidden="true" />{structure.persistence === 'postgresql' ? 'PostgreSQL live schema' : 'Memory mode'}</span>
<span className="db-structure-eyebrow">Database dictionary</span> <button className="btn primary" type="button" disabled={loading} onClick={() => void load()}>{loading ? 'Loading …' : 'Refresh schema'}</button>
<h2>Relational schema</h2> </div>
<p>Explore how imported evidence moves through normalization into canonical and result data. Open any table to inspect its columns, constraints and relationships.</p>
</div>
<div className="db-structure-intro-actions">
<span className={`db-structure-persistence is-${structure.persistence}`}><i aria-hidden="true" />{structure.persistence === 'postgresql' ? 'PostgreSQL live schema' : 'Memory mode'}</span>
<button className="btn primary" type="button" disabled={loading} onClick={() => void load()}>{loading ? 'Loading …' : 'Refresh schema'}</button>
</div>
</header>
{metrics && <section className="db-structure-summary" aria-label="Database structure summary"> {metrics && <section className="db-structure-summary" aria-label="Database structure summary">
<article><span>Tables</span><strong>{metrics.tables.toLocaleString('en-US')}</strong><small>Across {groups.length.toLocaleString('en-US')} active layers</small></article> <article><span>Tables</span><strong>{metrics.tables.toLocaleString('en-US')}</strong><small>Across {groups.length.toLocaleString('en-US')} active layers</small></article>
<article><span>Columns</span><strong>{metrics.columns.toLocaleString('en-US')}</strong><small>Typed schema fields</small></article> <article><span>Columns</span><strong>{metrics.columns.toLocaleString('en-US')}</strong><small>Typed schema fields</small></article>
<article><span>Relationships</span><strong>{metrics.relationships.toLocaleString('en-US')}</strong><small>Declared foreign keys</small></article> <article><span>Relationships</span><strong>{metrics.relationships.toLocaleString('en-US')}</strong><small>Declared foreign keys</small></article>
<article><span>Approximate rows</span><strong>{metrics.countedTables > 0 ? metrics.approximateRows.toLocaleString('en-US') : '—'}</strong><small>{metrics.countedTables > 0 ? `Reported by ${metrics.countedTables.toLocaleString('en-US')} tables` : 'No estimates reported'}</small></article> <article><span>Approximate rows</span><strong>{metrics.countedTables > 0 ? metrics.approximateRows.toLocaleString('en-US') : '—'}</strong><small>{metrics.countedTables > 0 ? `Reported by ${metrics.countedTables.toLocaleString('en-US')} tables` : 'No estimates reported'}</small></article>
</section>} </section>}
{structure.tables.length > 0 && <DbStructureFlow tables={structure.tables} />}
<details className="db-structure-dictionary">
<summary><TriangleIcon direction="right" />Table dictionary by layer</summary>
<div className="db-structure-toolbar"> <div className="db-structure-toolbar">
<p className="db-structure-meta">Schema snapshot <time dateTime={structure.generatedAt}>{new Date(structure.generatedAt).toLocaleString('en-US')}</time></p> <p className="db-structure-meta">Schema snapshot <time dateTime={structure.generatedAt}>{new Date(structure.generatedAt).toLocaleString('en-US')}</time></p>
{structure.tables.length > 0 && <button className="btn" type="button" onClick={() => setAllExpanded(!allExpanded)}>{allExpanded ? 'Collapse all' : 'Expand all'}</button>} {structure.tables.length > 0 && <button className="btn" type="button" onClick={() => setAllExpanded(!allExpanded)}>{allExpanded ? 'Collapse all' : 'Expand all'}</button>}
@ -161,6 +247,7 @@ export function DbStructurePanel({ backendStatus }: DbStructurePanelProps) {
</div> </div>
</section>)} </section>)}
</div>} </div>}
</details>
</>} </>}
</div>; </div>;
} }

View File

@ -0,0 +1,104 @@
import type { DbStructureTable } from '../../domain/db-structure';
/**
* Presentation-only process view of the relational schema (user request 2026-09-25):
* which tables each processing step reads and writes, as a program flow chart.
* Assignments name exact tables; a table that is not listed appears under
* "Other tables" so nothing is hidden when the schema grows.
*/
export type DbFlowLane = 'shared' | 'scanner' | 'gpo' | 'reference';
export interface DbFlowStepDefinition {
id: string;
lane: DbFlowLane;
title: string;
purpose: string;
tables: string[];
}
export const DB_FLOW_STEPS: DbFlowStepDefinition[] = [
{ id: 'foundation', lane: 'shared', title: 'Projects & assessments', purpose: 'Client project, timeslots and the repository revision every step belongs to.',
tables: ['projects', 'assessments', 'repository_state'] },
{ id: 'upload', lane: 'shared', title: 'Upload & custody', purpose: 'Original files, their fingerprints and processing jobs; scanner reports and GPO backups enter here.',
tables: ['import_files', 'import_artifacts', 'import_fingerprints', 'ingestion_jobs', 'artifact_deletion_outbox'] },
{ id: 'scanner-processing', lane: 'scanner', title: 'Report processing', purpose: 'One scan run per imported report: extracted tables, report sections and diagnostics.',
tables: ['scan_runs', 'import_processing_runs', 'source_tables', 'scanner_report_sections', 'import_diagnostics'] },
{ id: 'scanner-mapping', lane: 'scanner', title: 'Recognition & mapping', purpose: 'Scanner profiles and comparison rules that turn source rows into canonical roles.',
tables: ['profile_registrations', 'local_comparison_rules'] },
{ id: 'scanner-canonical', lane: 'scanner', title: 'Canonical model', purpose: 'Tool-independent assets, their properties and relations, and environment facts.',
tables: ['canonical_assets', 'asset_properties', 'asset_edges', 'edge_coverage', 'environment_observations'] },
{ id: 'scanner-findings', lane: 'scanner', title: 'Findings & evidence', purpose: 'Rule definitions, occurrences per run, their categories and linked evidence.',
tables: ['finding_definitions', 'finding_occurrences', 'finding_occurrence_categories', 'result_categories', 'evidence'] },
{ id: 'gpo-inventory', lane: 'gpo', title: 'Backup inventory', purpose: 'One record per GPO inside an uploaded backup, with its parsed settings.',
tables: ['gpo_backup_instances'] },
{ id: 'gpo-identity', lane: 'gpo', title: 'Policy identity', purpose: 'Resolves every setting to a technical policy identity.',
tables: ['policy_identity_analysis_runs', 'policy_identity_resolutions', 'policy_identities'] },
{ id: 'gpo-aggregation', lane: 'gpo', title: 'Snapshot aggregation', purpose: 'Combines all GPOs of a backup set into one state per setting.',
tables: ['policy_snapshot_aggregation_runs', 'policy_snapshot_aggregation_sources', 'policy_snapshot_states', 'environment_profiles'] },
{ id: 'gpo-evaluation', lane: 'gpo', title: 'Baseline evaluation', purpose: 'Compares each setting with the Microsoft baseline variants.',
tables: ['policy_evaluation_runs', 'policy_evaluation_results', 'policy_evaluation_evidence', 'policy_evaluation_baseline_sources'] },
{ id: 'gpo-findings', lane: 'gpo', title: 'Policy findings', purpose: 'Deviations published as findings for Work.',
tables: ['policy_finding_runs', 'policy_finding_occurrences'] },
{ id: 'reference-baselines', lane: 'reference', title: 'Microsoft baselines', purpose: 'Reference input: imported baseline packages, their analyses, settings and ADMX catalogs.',
tables: ['microsoft_security_baselines', 'microsoft_security_baseline_analyses', 'microsoft_security_baseline_settings',
'microsoft_security_baseline_classifications', 'admx_catalog_versions'] },
{ id: 'review', lane: 'shared', title: 'Work & review', purpose: 'Client-owned review state and accepted risks for findings of both paths.',
tables: ['reviews'] },
{ id: 'comparison', lane: 'shared', title: 'Comparison over time', purpose: 'Timeslot comparisons and the lifecycle of findings between assessments.',
tables: ['comparisons', 'timeslot_evidence_comparisons', 'finding_obligations', 'finding_obligation_transitions', 'finding_obligation_scope_decisions'] }
];
export interface DbFlowStep extends DbFlowStepDefinition {
resolved: DbStructureTable[];
}
const shortName = (name: string) => name.replace(/^assessor_/, '');
/** Resolves the flow against the live schema; unlisted tables are returned separately. */
export function buildDbStructureFlow(tables: DbStructureTable[]): { steps: DbFlowStep[]; other: DbStructureTable[] } {
const byShortName = new Map(tables.map(table => [shortName(table.name), table]));
const assigned = new Set<string>();
const steps = DB_FLOW_STEPS.map(step => {
const resolved = step.tables.flatMap(name => {
const table = byShortName.get(name);
if (table) assigned.add(table.name);
return table ? [table] : [];
});
return { ...step, resolved };
}).filter(step => step.resolved.length > 0);
return { steps, other: tables.filter(table => !assigned.has(table.name)) };
}
/** Tables referenced by, or referencing, the selected table. */
export function dbStructureNeighbours(tables: DbStructureTable[], selected: string): { outgoing: string[]; incoming: string[] } {
const table = tables.find(item => item.name === selected);
return {
outgoing: [...new Set(table?.foreignKeys.map(key => key.referencedTable).filter(name => name !== selected) ?? [])],
incoming: [...new Set(tables.filter(item => item.name !== selected && item.foreignKeys.some(key => key.referencedTable === selected)).map(item => item.name))]
};
}
export const dbStructureShortName = shortName;
/**
* Read views without tables of their own. They reference tables owned by the steps
* above and are drawn where both processing paths meet.
*/
export interface DbFlowViewDefinition {
id: string;
title: string;
purpose: string;
sources: string[];
}
export const DB_FLOW_VIEWS: DbFlowViewDefinition[] = [
{ id: 'result-db', title: 'Result DB', purpose: 'Read view, no table of its own: combined at query time from scanner findings, environment facts, GPO comparisons, GPO findings and GPO backup consistency.',
sources: ['finding_definitions', 'finding_occurrences', 'finding_occurrence_categories', 'result_categories', 'evidence',
'environment_observations', 'policy_evaluation_results', 'policy_snapshot_states', 'policy_finding_occurrences', 'gpo_backup_instances'] }
];
export function buildDbStructureViews(tables: DbStructureTable[]): Array<DbFlowViewDefinition & { resolved: DbStructureTable[] }> {
const byShortName = new Map(tables.map(table => [shortName(table.name), table]));
return DB_FLOW_VIEWS.map(view => ({ ...view, resolved: view.sources.flatMap(name => byShortName.get(name) ?? []) }))
.filter(view => view.resolved.length > 0);
}

View File

@ -0,0 +1,31 @@
import { RichTextValue } from '../../components/RichTextValue/RichTextValue';
import { highlightText } from '../../components/HighlightText/highlight-text';
import { TriangleIcon } from '../../components/TriangleIcon';
import { powerShellDisplayLines, type FindingFirstRead as Summary } from './first-read';
/** Scanner first read: what the scanner found here, why it matters, and what to do next. */
export function FindingFirstRead({ summary, outcome, search, onResults }: {
summary: Summary; outcome: string; search: string; onResults?: () => void;
}) {
return <>
{summary.rows.length > 0 && <section className="policy-problem finding-first-read" aria-label={summary.heading} data-outcome={outcome}>
<h4>{summary.heading}</h4>
<dl className="policy-problem__values">
{summary.rows.map((row, index) => <div key={`${row.label}-${index}`} data-state={row.state ?? 'neutral'}><dt>{row.label}</dt>
<dd>{row.state
? <span className={`policy-value-mark is-${row.state}`}>{highlightText(row.value, search)}</span>
: <RichTextValue value={row.value} search={search} label={row.label} onResults={onResults} />}</dd></div>)}
</dl>
</section>}
{(summary.nextStep || summary.link) && <section className="policy-next-step finding-first-read" aria-label="Next step">
<h4>Next step</h4>
{summary.nextStep?.lead && <RichTextValue value={summary.nextStep.lead} search={search} label={summary.nextStep.label} onResults={onResults} />}
{summary.nextStep?.command && <pre className="finding-first-read__command"><code>{highlightText(powerShellDisplayLines(summary.nextStep.command).join('\n'), search)}</code></pre>}
{summary.nextStep?.rest && <details className="finding-optional-detail">
<summary><TriangleIcon direction="right" />Full remediation</summary>
<RichTextValue value={summary.nextStep.rest} search={search} label={summary.nextStep.label} onResults={onResults} />
</details>}
{summary.link && <p><a className="btn" href={summary.link.href} target="_blank" rel="noopener noreferrer" referrerPolicy="no-referrer">{summary.link.label}</a></p>}
</section>}
</>;
}

View File

@ -12,6 +12,11 @@ import {
} from '../../domain/scoped-risk-acceptance'; } from '../../domain/scoped-risk-acceptance';
import { RiskAcceptanceDialog } from './RiskAcceptanceDialog'; import { RiskAcceptanceDialog } from './RiskAcceptanceDialog';
import { PolicySourceEvidence } from './PolicySourceEvidence'; import { PolicySourceEvidence } from './PolicySourceEvidence';
import { PolicyBaselineComparison } from './PolicyBaselineComparison';
import { PolicyProblemSummary } from './PolicyProblemSummary';
import { FindingFirstRead } from './FindingFirstRead';
import { findingFirstRead } from './first-read';
import { policyProblemSummary } from '../../profiles/group-policy/problem-summary';
import { normalizeSeverity, mitreBadgeLabel } from '../../profiles/purple-knight/presentation'; import { normalizeSeverity, mitreBadgeLabel } from '../../profiles/purple-knight/presentation';
import { RichTextValue } from '../../components/RichTextValue/RichTextValue'; import { RichTextValue } from '../../components/RichTextValue/RichTextValue';
import { PathText } from '../../components/PathText/PathText'; import { PathText } from '../../components/PathText/PathText';
@ -22,6 +27,7 @@ import { highlightText } from '../../components/HighlightText/highlight-text';
import { BookmarkIcon } from '../../components/BookmarkIcon'; import { BookmarkIcon } from '../../components/BookmarkIcon';
import { TriangleIcon } from '../../components/TriangleIcon'; import { TriangleIcon } from '../../components/TriangleIcon';
import { findingOutcomeMark } from '../linked-results/result-message'; import { findingOutcomeMark } from '../linked-results/result-message';
import { cisCategoryRecordTitle } from '../../profiles/cis-cat/cis-benchmark-section-titles';
import { policyComparisonDisplayLines, policyValueName } from '../../profiles/group-policy/presentation'; import { policyComparisonDisplayLines, policyValueName } from '../../profiles/group-policy/presentation';
import { policyDiagnosticBadges, policyFindingCategories } from '../../profiles/group-policy/categories'; import { policyDiagnosticBadges, policyFindingCategories } from '../../profiles/group-policy/categories';
import { findingWebSearch } from '../../profiles/public-search/search'; import { findingWebSearch } from '../../profiles/public-search/search';
@ -123,6 +129,8 @@ export function FindingReader({ record, title, review: storedReview, previousPol
const { statusField, badges, derivedCategories, mitre, summary, checkTitle, sourceRuleId, optionalFields, remainingFields, policyComparisonFields, policyReadiness } = selectFindingFields(record, title, hasStructuredResults, resolvedToolId); const { statusField, badges, derivedCategories, mitre, summary, checkTitle, sourceRuleId, optionalFields, remainingFields, policyComparisonFields, policyReadiness } = selectFindingFields(record, title, hasStructuredResults, resolvedToolId);
const comparisonOutcome = statusField?.value ? outcomeForStatusValue(resolvedToolId, statusField.value) : 'unknown'; const comparisonOutcome = statusField?.value ? outcomeForStatusValue(resolvedToolId, statusField.value) : 'unknown';
const outcomeMark = findingOutcomeMark(record, toolId); const outcomeMark = findingOutcomeMark(record, toolId);
const problem = resolvedToolId === 'group-policy' && !policyReadiness ? policyProblemSummary(record) : undefined;
const firstRead = resolvedToolId !== 'group-policy' ? findingFirstRead(record, resolvedToolId, linkedSheets, comparisonOutcome) : undefined;
const policyHeading = policyComparisonFields.map(field => policyValueName(field.value)).find(Boolean); const policyHeading = policyComparisonFields.map(field => policyValueName(field.value)).find(Boolean);
const policySearchKeys = policyAnalyzerSearchKeys(record, title, resolvedToolId); const policySearchKeys = policyAnalyzerSearchKeys(record, title, resolvedToolId);
// Nur echte Dateiverweise gehoeren in den Hinweis. Werkzeuge, die ihr // Nur echte Dateiverweise gehoeren in den Hinweis. Werkzeuge, die ihr
@ -157,7 +165,8 @@ export function FindingReader({ record, title, review: storedReview, previousPol
{/* Ergebnisstatus als Outcome, nicht als Meta-Plakette: sonst ist {/* Ergebnisstatus als Outcome, nicht als Meta-Plakette: sonst ist
`Investigate` optisch nicht von `Passed` zu unterscheiden. */} `Investigate` optisch nicht von `Passed` zu unterscheiden. */}
{outcomeMark && <span className="outcome-mark" data-outcome={outcomeMark.outcome}>{highlightText(outcomeMark.label, search)}</span>} {outcomeMark && <span className="outcome-mark" data-outcome={outcomeMark.outcome}>{highlightText(outcomeMark.label, search)}</span>}
{derivedCategories.map(category => <span className="status-badge" data-derived-category="cis" key={category}>{highlightText(category, search)}</span>)} {/* The heading already names the CIS section; each number badge names its section on hover. */}
{derivedCategories.map(category => <span className="status-badge" data-derived-category="cis" title={cisCategoryRecordTitle(record, category)} key={category}>{highlightText(category, search)}</span>)}
{badges.map(field => { {badges.map(field => {
const isSeverity = field.label.toLowerCase() === 'severity'; const isSeverity = field.label.toLowerCase() === 'severity';
const scale = /^(?:category score|domain risk level)$/i.test(field.label.trim()) ? ' / 100' : ''; const scale = /^(?:category score|domain risk level)$/i.test(field.label.trim()) ? ' / 100' : '';
@ -205,12 +214,6 @@ export function FindingReader({ record, title, review: storedReview, previousPol
? { acceptanceId: selectedScopedAcceptance.id, command, expectedRevision: selectedScopedAcceptance.revision } ? { acceptanceId: selectedScopedAcceptance.id, command, expectedRevision: selectedScopedAcceptance.revision }
: { target: { kind: 'device-finding', occurrenceId: selectedDeviceTarget.occurrenceId, deviceKey: selectedDeviceTarget.device.key }, command, expectedRevision: 0 }) : undefined} : { target: { kind: 'device-finding', occurrenceId: selectedDeviceTarget.occurrenceId, deviceKey: selectedDeviceTarget.device.key }, command, expectedRevision: 0 }) : undefined}
onClose={() => setRiskAcceptanceFor('')} />} onClose={() => setRiskAcceptanceFor('')} />}
<section className="dashboard-grid">
<label>Status<select value={review.status} onChange={updateText('status')}>{statuses.map(value => <option key={value}>{value}</option>)}</select></label>
<label>Priority<select value={review.priority} onChange={updateText('priority')}>{priorities.map(value => <option key={value}>{value}</option>)}</select></label>
<label>Owner / Team<input value={review.owner} onChange={updateText('owner')} /></label>
<label>Due Date<input type="date" value={review.due} onChange={updateText('due')} /></label>
</section>
<section className="reader-summary"> <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>} {sourceRuleId && <div data-source-rule-id><h4>Source Rule ID</h4><p><code className="mono">{highlightText(sourceRuleId.value, search)}</code></p></div>}
{Boolean(previousPolicyReviews?.length) && <details className="policy-previous-reviews"> {Boolean(previousPolicyReviews?.length) && <details className="policy-previous-reviews">
@ -226,7 +229,9 @@ export function FindingReader({ record, title, review: storedReview, previousPol
</details>} </details>}
<h3><PathText value={policyHeading || checkTitle?.value || (resolvedToolId === 'group-policy' ? title : 'Finding')} search={search}/></h3> <h3><PathText value={policyHeading || checkTitle?.value || (resolvedToolId === 'group-policy' ? title : 'Finding')} search={search}/></h3>
{policyReadiness && <div className="policy-evaluation-warning" role="status"><h4>Historical comparison</h4><p>{policyReadiness.value}</p></div>} {policyReadiness && <div className="policy-evaluation-warning" role="status"><h4>Historical comparison</h4><p>{policyReadiness.value}</p></div>}
{resolvedToolId === 'group-policy' && Boolean(record.policySourceEvidence?.length) && <PolicySourceEvidence sources={record.policySourceEvidence!} search={search}/>} {problem && <PolicyProblemSummary summary={problem} outcome={comparisonOutcome ?? 'unknown'} search={search}/>}
{firstRead && <FindingFirstRead summary={firstRead} outcome={comparisonOutcome ?? 'unknown'} search={search} onResults={onResults}/>}
{resolvedToolId === 'group-policy' && Boolean(record.policySourceEvidence?.length) && <PolicySourceEvidence sources={record.policySourceEvidence!} search={search} summarized={Boolean(problem)}/>}
{policyComparisonFields.length > 0 && <div className="policy-value-comparison" data-outcome={policyReadiness ? 'historical' : comparisonOutcome}> {policyComparisonFields.length > 0 && <div className="policy-value-comparison" data-outcome={policyReadiness ? 'historical' : comparisonOutcome}>
{policyComparisonFields.map(field => { {policyComparisonFields.map(field => {
const expected = normalizeLabel(field.label) === 'baseline value'; const expected = normalizeLabel(field.label) === 'baseline value';
@ -239,6 +244,8 @@ export function FindingReader({ record, title, review: storedReview, previousPol
</section>; </section>;
})} })}
</div>} </div>}
{resolvedToolId === 'group-policy' && Boolean(record.policyBaselineComparisons?.length)
&& <PolicyBaselineComparison comparisons={record.policyBaselineComparisons!} search={search}/>}
{summary.map(field => <div className={/^remediation(?: guidance| recommendation)?$/.test(normalizeLabel(field.label)) ? 'reader-remediation' : undefined} key={`${field.index}-${field.label}`}><h4>{highlightText(field.label, search)}</h4>{resolvedToolId === 'maester' && normalizeLabel(field.label) === 'id' {summary.map(field => <div className={/^remediation(?: guidance| recommendation)?$/.test(normalizeLabel(field.label)) ? 'reader-remediation' : undefined} key={`${field.index}-${field.label}`}><h4>{highlightText(field.label, search)}</h4>{resolvedToolId === 'maester' && normalizeLabel(field.label) === 'id'
? <p><code className="mono">{highlightText(field.value, search)}</code></p> ? <p><code className="mono">{highlightText(field.value, search)}</code></p>
: resolvedToolId === 'maester' && normalizeLabel(field.label) === 'test details' : resolvedToolId === 'maester' && normalizeLabel(field.label) === 'test details'
@ -253,17 +260,30 @@ export function FindingReader({ record, title, review: storedReview, previousPol
</section> </section>
{regulatoryMapping.open && <RegulatoryMappingPanel response={regulatoryMapping.response} loading={regulatoryMapping.loading} error={regulatoryMapping.error} {regulatoryMapping.open && <RegulatoryMappingPanel response={regulatoryMapping.response} loading={regulatoryMapping.loading} error={regulatoryMapping.error}
onStart={() => void regulatoryMapping.start()} onDecision={(candidate, decision, comment) => void regulatoryMapping.decide(candidate, decision, comment)} />} onStart={() => void regulatoryMapping.start()} onDecision={(candidate, decision, comment) => void regulatoryMapping.decide(candidate, decision, comment)} />}
<section className="review-grid"> {/* Work tracking is opt-in below the finding so the first screen states the problem. */}
{([['prerequisites', 'Prerequisites'], ['access', 'Required Access / Roles'], ['nextStep', 'Next Step'], ['evidence', 'Evidence / Acceptance Criteria'], ['comment', 'Comment']] as Array<[keyof Review, string]>).map(([field, label]) => ( <FindingReferences record={record}/>
<label key={field}>{label}<textarea value={String(review[field])} onChange={updateText(field)} /></label> <details className="reader-tracking">
))} <summary><TriangleIcon direction="right" />Work tracking<span className="reader-tracking__state">
<fieldset className="review-markers"> {[`Status: ${review.status}`, `Priority: ${review.priority}`, review.owner && `Owner: ${review.owner}`, review.due && `Due: ${review.due}`].filter(Boolean).join(' · ')}
<legend>Markers</legend> </span></summary>
{([['markerQuestion', 'Question Needed'], ['markerBlocked', 'Permission Blocked'], ['markerQuickwin', 'Quick Win'], ['markerExternal', 'External Team'], ['markerNotRelevant', 'Not Relevant']] as Array<[keyof Review, string]>).map(([field, label]) => ( <section className="dashboard-grid">
<label key={field}><input type="checkbox" checked={Boolean(review[field])} onChange={event => onReview({ [field]: event.target.checked })} /> {label}</label> <label>Status<select value={review.status} onChange={updateText('status')}>{statuses.map(value => <option key={value}>{value}</option>)}</select></label>
<label>Priority<select value={review.priority} onChange={updateText('priority')}>{priorities.map(value => <option key={value}>{value}</option>)}</select></label>
<label>Owner / Team<input value={review.owner} onChange={updateText('owner')} /></label>
<label>Due Date<input type="date" value={review.due} onChange={updateText('due')} /></label>
</section>
<section className="review-grid">
{([['prerequisites', 'Prerequisites'], ['access', 'Required Access / Roles'], ['nextStep', 'Next Step'], ['evidence', 'Evidence / Acceptance Criteria'], ['comment', 'Comment']] as Array<[keyof Review, string]>).map(([field, label]) => (
<label key={field}>{label}<textarea value={String(review[field])} onChange={updateText(field)} /></label>
))} ))}
</fieldset> <fieldset className="review-markers">
</section> <legend>Markers</legend>
{([['markerQuestion', 'Question Needed'], ['markerBlocked', 'Permission Blocked'], ['markerQuickwin', 'Quick Win'], ['markerExternal', 'External Team'], ['markerNotRelevant', 'Not Relevant']] as Array<[keyof Review, string]>).map(([field, label]) => (
<label key={field}><input type="checkbox" checked={Boolean(review[field])} onChange={event => onReview({ [field]: event.target.checked })} /> {label}</label>
))}
</fieldset>
</section>
</details>
{/* Ergebnistabellen erscheinen ausschliesslich im schwebenden Fenster {/* Ergebnistabellen erscheinen ausschliesslich im schwebenden Fenster
ueber die Results-Schaltflaeche. Inline standen sie ein zweites Mal ueber die Results-Schaltflaeche. Inline standen sie ein zweites Mal
in der Lesespalte und drueckten die fachliche Aussage nach unten. in der Lesespalte und drueckten die fachliche Aussage nach unten.
@ -287,3 +307,4 @@ export function FindingReader({ record, title, review: storedReview, previousPol
</article> </article>
); );
} }
import { FindingReferences } from './FindingReferences';

View File

@ -0,0 +1,30 @@
import type { FindingRecord } from '../../domain/finding';
import { TriangleIcon } from '../../components/TriangleIcon';
import { PathText } from '../../components/PathText/PathText';
const labels = { active: 'Catalog match', deprecated: 'Deprecated', revoked: 'Revoked', 'not-in-catalog': 'Not found in this catalog' };
export function FindingReferences({ record }: { record: FindingRecord }) {
const recognition = record.referenceRecognition;
if (!recognition) return record.findingOccurrenceId ? <section className="reference-recognition"><h4>Catalog references</h4>
<p>Not indexed yet. Use “Recognize references” in this client’s Data Hub.</p></section> : null;
if (!recognition.references.length) return null;
return <section className="reference-recognition" aria-label="Catalog references">
<h4>Catalog references</h4>
<p>Declared: stated by the source. Mentioned: found elsewhere in the finding text.</p>
{recognition.references.map((reference, index) => <details className="finding-optional-detail" key={index}>
<summary><TriangleIcon direction="right"/>{reference.framework === 'mitre-attack' ? 'ATT&CK' : 'D3FEND'} {reference.id}{reference.title ? ` · ${reference.title}` : ''} · {reference.evidence === 'declared' ? 'Declared' : 'Mentioned'} · {labels[reference.status]}</summary>
<table><tbody>
<tr><th>Original text</th><td>{reference.source.text}</td></tr>
<tr><th>Source field</th><td>{reference.source.fieldLabel} · {reference.source.fieldKey}</td></tr>
{reference.source.fieldPath && <tr><th>Source path</th><td><PathText value={reference.source.fieldPath}/></td></tr>}
<tr><th>Text position</th><td>{reference.source.representation}{reference.source.valuePath} · {reference.source.start}–{reference.source.end} (UTF-16; end excluded)</td></tr>
<tr><th>Catalog version</th><td>{reference.catalogVersion}</td></tr>
<tr><th>Catalog SHA-256</th><td>{reference.catalogSha256}</td></tr>
<tr><th>Recognition</th><td>{recognition.recognizerVersion} · {recognition.createdAt} · {reference.matchedBy}</td></tr>
<tr><th>Run</th><td>{recognition.runId}</td></tr>
{reference.url && /^https:\/\/(?:attack\.mitre\.org|d3fend\.mitre\.org)\//.test(reference.url)
&& <tr><th>Catalog entry</th><td><a href={reference.url} target="_blank" rel="noopener noreferrer">{reference.url}</a></td></tr>}
</tbody></table>
</details>)}
</section>;
}

View File

@ -0,0 +1,88 @@
import { Fragment } from 'react';
import type { PolicyBaselineComparison as Comparison, PolicyBaselineRecommendation as Recommendation } from '../../domain/finding';
import { PathText } from '../../components/PathText/PathText';
import { highlightText } from '../../components/HighlightText/highlight-text';
import { TriangleIcon } from '../../components/TriangleIcon';
import { outcomeForStatusValue } from '../../importers/status-normalization';
import { policyBaselineConsensusLine, policyBaselinePackageLabel, summarizePolicyBaselineComparisons } from '../../profiles/group-policy/baseline-comparison-summary';
function Entries({ values, search }: { values: string[]; search: string }) {
return <>{values.map((value, index) => <Fragment key={`${index}-${value}`}>{index > 0 && '\n'}
<PathText value={value} search={search}/></Fragment>)}</>;
}
/** Expected value and literal differences of one stored outcome. */
function OutcomeFacts({ outcome, search, className }: { outcome: Recommendation; search: string; className: string }) {
return <dl className={className}>
<div><dt>Expected</dt><dd className="policy-guidance-text"><PathText value={outcome.expected} search={search}/></dd></div>
{outcome.registryType !== undefined && <div><dt>Registry type</dt><dd>{outcome.registryType}</dd></div>}
{outcome.additional.length > 0 && <div><dt>Additional entries</dt><dd className="policy-guidance-text"><Entries values={outcome.additional} search={search}/></dd></div>}
{outcome.missing.length > 0 && <div><dt>Missing entries</dt><dd className="policy-guidance-text"><Entries values={outcome.missing} search={search}/></dd></div>}
</dl>;
}
/**
* States identical results once and groups variants by their literal expected
* value, naming the packages of every exception. Every stored comparison stays
* in a disclosure, which opens when the comparison results themselves differ.
*/
export function PolicyBaselineComparison({ comparisons, search }: { comparisons: Comparison[]; search: string }) {
const summary = summarizePolicyBaselineComparisons(comparisons);
const grouped = summary.valueGroups.length > 1;
// The grouped remediation is the Next step of the problem summary above.
return <>
<section className="policy-baseline-comparison" aria-label="Baseline comparison"
data-consistent={summary.consistent} data-uniform-outcome={summary.uniformOutcome}>
<div className="policy-baseline-comparison__summary">
<h4>Baseline comparison</h4>
{/* A uniform result and its expected values are already stated in the problem summary. */}
{!summary.uniformOutcome && <p className="policy-baseline-comparison__verdict">{highlightText(policyBaselineConsensusLine(summary), search)}</p>}
{!summary.uniformOutcome && <p className="policy-baseline-comparison__statuses">
{summary.statuses.map(item => `${item.status}: ${item.count}`).join(' · ')}
</p>}
{!summary.uniformOutcome && !grouped && summary.valueGroups[0] && <OutcomeFacts outcome={summary.valueGroups[0].outcome} search={search} className="policy-baseline-comparison__facts"/>}
{!summary.uniformOutcome && grouped && <>
<p className="policy-baseline-comparison__groups-intro">The baseline packages expect different values:</p>
<ul className="policy-baseline-value-groups">
{summary.valueGroups.map((group, index) => <li className="policy-baseline-value-group" key={index}>
<OutcomeFacts outcome={group.outcome} search={search} className="policy-baseline-comparison__facts"/>
<p className="policy-baseline-value-group__packages">
{!summary.uniformOutcome && `${group.outcome.comparison} · `}
{highlightText(policyBaselinePackageLabel(group.packages, index === 0), search)}
</p>
</li>)}
</ul>
</>}
<p className="policy-baseline-comparison__scope">{[...summary.contexts, ...summary.scopeNotices].join(' ')}</p>
</div>
{summary.explanations.map((explanation, index) => <div className="policy-baseline-comparison__explanation" key={index}>
<h4>Description</h4>
<p className="policy-guidance-text"><PathText value={explanation} search={search} label="Description"/></p>
</div>)}
<details className="finding-optional-detail policy-baseline-comparison__all" open={!summary.uniformOutcome}>
<summary><TriangleIcon direction="right"/>All baseline comparisons ({summary.packageCount} {summary.packageCount === 1 ? 'package' : 'packages'}, {summary.variantCount} {summary.variantCount === 1 ? 'variant' : 'variants'})</summary>
<div className="policy-baseline-packages">
{comparisons.map((comparison, comparisonIndex) => <section className="policy-baseline-package" key={`${comparisonIndex}-${comparison.baseline}`}>
<header className="policy-baseline-package__header">
<h5>{highlightText(comparison.baseline, search)}</h5>
<span className="outcome-mark" data-outcome={outcomeForStatusValue('group-policy', comparison.status)}>{comparison.status}</span>
</header>
<ul className="policy-baseline-variants">
{comparison.recommendations.map((item, index) => <li className="policy-baseline-variant" key={`${item.settingId}-${index}`}>
<div className="policy-baseline-variant__head">
<span className="policy-baseline-variant__template">{highlightText(item.template, search)}</span>
<span className="policy-baseline-variant__result outcome-mark" data-relation={item.comparison}
data-outcome={item.comparison === 'Match' ? 'pass' : item.comparison === 'Unavailable' ? 'unknown' : 'fail'}>{item.comparison}</span>
</div>
{/* User correction 2026-09-25: every variant shows its own expected value and result. */}
<OutcomeFacts outcome={item} search={search} className="policy-baseline-variant__values"/>
<span className="policy-baseline-variant__reference">Setting reference: <span>{item.settingId}</span></span>
</li>)}
</ul>
</section>)}
</div>
</details>
</section>
</>;
}

View File

@ -0,0 +1,44 @@
import type { PolicyProblemSummary as Summary } from '../../profiles/group-policy/problem-summary';
import { policyBaselinePackageLabel } from '../../profiles/group-policy/baseline-comparison-summary';
import { PathText } from '../../components/PathText/PathText';
import { highlightText } from '../../components/HighlightText/highlight-text';
/** First-read block: configured versus expected, who sets it, what the flags mean and the next step. */
export function PolicyProblemSummary({ summary, outcome, search }: { summary: Summary; outcome: string; search: string }) {
return <>
<section className="policy-problem" aria-label={summary.heading} data-outcome={outcome}>
<h4>{summary.heading}</h4>
<dl className="policy-problem__values">
{summary.configured.length > 0 && <div><dt>Configured</dt>
<dd>{summary.configured.map((entry, index) => {
// Red: differs from every variant. Green: matches every variant. Neutral: depends on the template.
const content = <><PathText value={entry.text} search={search}/></>;
return <span className="policy-problem__entry" data-state={entry.state ?? 'neutral'} key={index}>
{entry.state ? <span className={`policy-value-mark is-${entry.state}`}>{content}</span> : content}
{entry.note && <span className="policy-problem__note"> · {entry.note}</span>}
</span>;
})}</dd></div>}
{summary.expected.length > 0 && <div><dt>Expected</dt>
<dd>{summary.expected.map((item, index) => <span className="policy-problem__entry" key={index}><PathText value={item} search={search}/></span>)}</dd></div>}
{summary.additional.length > 0 && <div><dt>Additional entries</dt>
<dd>{summary.additional.map((item, index) => <span className="policy-problem__entry" key={index}><PathText value={item} search={search}/></span>)}</dd></div>}
{summary.missing.length > 0 && <div><dt>Missing entries</dt>
<dd>{summary.missing.map((item, index) => <span className="policy-problem__entry" key={index}><PathText value={item} search={search}/></span>)}</dd></div>}
</dl>
{summary.context.map((line, index) => <p className="policy-problem__context" key={index}>{highlightText(line, search)}</p>)}
{summary.flags.map((line, index) => <p className="policy-problem__flag" key={index}>{highlightText(line, search)}</p>)}
</section>
{/* The next step is its own block so its marker stays distinct from the problem outcome. */}
{(summary.nextSteps.length > 0 || summary.nextStepIntro) && <section className="policy-next-step" aria-label="Next step">
<h4>Next step</h4>
{summary.nextStepIntro && <p>{summary.nextStepIntro}</p>}
{summary.nextSteps.map((group, index) => <div className="policy-problem__step" key={index}>
<p className="policy-guidance-text"><PathText value={group.text} search={search} label="Remediation"/></p>
{summary.nextStepPackages && <p className="policy-problem__packages">{highlightText(policyBaselinePackageLabel(group.packages, index === 0 && summary.nextSteps.length > 1), search)}</p>}
</div>)}
{summary.affectedSources.length > 0 && <p className="policy-problem__sources">
In: {highlightText(summary.affectedSources.join(', '), search)}
</p>}
</section>}
</>;
}

View File

@ -2,16 +2,37 @@ import { Fragment } from 'react';
import type { PolicySourceEvidence as SourceEvidence } from '../../domain/finding'; import type { PolicySourceEvidence as SourceEvidence } from '../../domain/finding';
import { PathText } from '../../components/PathText/PathText'; import { PathText } from '../../components/PathText/PathText';
import { highlightText } from '../../components/HighlightText/highlight-text'; import { highlightText } from '../../components/HighlightText/highlight-text';
import { policySourceGroups, policySourceValueLines } from '../../profiles/group-policy/source-evidence'; import { policySourceGroups, policySourceValueGroups, policySourceValueLines } from '../../profiles/group-policy/source-evidence';
import { TriangleIcon } from '../../components/TriangleIcon';
export function PolicySourceEvidence({ sources, search }: { sources: SourceEvidence[]; search: string }) { /**
* One neutral line per retained instruction without the key path. The source sections below show
* the full instruction and carry the only field-specific deviation marker.
*/
function CompactValues({ observations, search }: { observations: SourceEvidence[]; search: string }) {
return <>{observations.flatMap((observation, observationIndex) => observation.values.map((value, valueIndex) => {
const lines = policySourceValueLines(value, observation.deviationFields?.[valueIndex], observation.deviationItems?.[valueIndex])
.filter(line => !/^(?:Key|Value name):/.test(line.text));
return <p className="policy-source-overview__value" key={`${observationIndex}-${valueIndex}`}>{lines.map((line, lineIndex) =>
<Fragment key={lineIndex}>{lineIndex > 0 && ' · '}<PathText value={line.text} search={search}/></Fragment>)}</p>;
}))}</>;
}
/** `summarized`: a problem summary above already states a single shared value and the letter-case note. */
export function PolicySourceEvidence({ sources, search, summarized = false }: { sources: SourceEvidence[]; search: string; summarized?: boolean }) {
const groups = policySourceGroups(sources); const groups = policySourceGroups(sources);
const identified = groups.filter(group => group.identified).length; const identified = groups.filter(group => group.identified).length;
const unassigned = groups.length - identified; const unassigned = groups.length - identified;
return <section className="policy-source-evidence" aria-label="GPO source contributions"> // Overview only when several backups contribute; the complete source sections stay visible below.
<h4>{identified} GPO backup {identified === 1 ? 'source' : 'sources'}{unassigned > 0 ? ` · ${unassigned} unassigned` : ''}</h4> const allValueGroups = groups.length > 1 ? policySourceValueGroups(sources) : [];
<p className="policy-source-notice">Original instructions by GPO. This list is not an execution order between GPOs and does not establish the effective endpoint value.</p> const valueGroups = summarized && allValueGroups.length < 2 ? [] : allValueGroups;
<div className="policy-source-groups"> const caseOnlyKeys = !summarized && valueGroups.some(group => group.keySpellings.length > 1
&& new Set(group.keySpellings.map(key => key.toLowerCase())).size < group.keySpellings.length);
// User correction 2026-09-25: identical instructions of several backups are already stated by the
// problem summary, so their complete sections are available on demand instead of repeated inline.
// Differing instructions stay summarized per value and backup in the overview above the disclosure.
const collapsed = summarized && groups.length > 1;
const sections = <div className="policy-source-groups">
{groups.map(group => { {groups.map(group => {
const relation = group.observations.length === 1 ? group.observations[0].baselineRelation : undefined; const relation = group.observations.length === 1 ? group.observations[0].baselineRelation : undefined;
return <article className={`policy-source-group${relation ? ` is-${relation}` : ''}`} return <article className={`policy-source-group${relation ? ` is-${relation}` : ''}`}
@ -19,24 +40,49 @@ export function PolicySourceEvidence({ sources, search }: { sources: SourceEvide
<header className="policy-source-group__header"> <header className="policy-source-group__header">
<h5>{highlightText(group.label, search)}</h5> <h5>{highlightText(group.label, search)}</h5>
{relation === 'review' && <span className="policy-source-group__badge is-review">Review required</span>} {relation === 'review' && <span className="policy-source-group__badge is-review">Review required</span>}
{group.observations.every(observation => observation.linkStatus === 'not-linked')
&& <span className="policy-source-group__badge is-not-linked">Not linked</span>}
</header> </header>
{group.observations.every(observation => observation.linkStatus === 'not-linked')
&& <p className="policy-source-group__link-note">This GPO has no enabled link to the domain or an OU in its backup report, so its settings apply to no device.</p>}
{group.backupLabel && <p className="policy-source-group__backup"><strong>{highlightText(group.backupLabel, search)}</strong></p>} {group.backupLabel && <p className="policy-source-group__backup"><strong>{highlightText(group.backupLabel, search)}</strong></p>}
{group.observations.map((observation, observationIndex) => <div className="policy-source-observation" key={observation.id}> {group.observations.map((observation, observationIndex) => <div className="policy-source-observation" key={observation.id}>
<p className="policy-source-reference"><PathText value={observation.sourceReference} search={search}/></p> <p className="policy-source-reference"><PathText value={observation.sourceReference} search={search}/></p>
{observation.sequence !== undefined && <p className="policy-source-sequence">Source sequence: {observation.sequence}</p>} {observation.sequence !== undefined && <p className="policy-source-sequence">Source sequence: {observation.sequence}</p>}
{observation.values.map((value, valueIndex) => <p className="policy-guidance-text policy-source-value" key={valueIndex}> {observation.values.map((value, valueIndex) => <p className="policy-guidance-text policy-source-value" key={valueIndex}>
{policySourceValueLines(value, observation.deviationFields?.[valueIndex], observation.deviationItems?.[valueIndex]).map((line, lineIndex) => { {policySourceValueLines(value, observation.deviationFields?.[valueIndex], observation.deviationItems?.[valueIndex],
observation.alignedItems?.[valueIndex], observation.baselineRelation === 'aligned').map((line, lineIndex) => {
const content = line.emphasized const content = line.emphasized
? <strong className="policy-value-emphasis"><PathText value={line.text} search={search}/></strong> ? <strong className="policy-value-emphasis"><PathText value={line.text} search={search}/></strong>
: <PathText value={line.text} search={search}/>; : <PathText value={line.text} search={search}/>;
return <Fragment key={lineIndex}>{lineIndex > 0 && '\n'}{line.deviating return <Fragment key={lineIndex}>{lineIndex > 0 && '\n'}{line.deviating
? <span className="policy-source-deviation-value">{content}</span> : content}</Fragment>; ? <span className="policy-source-deviation-value">{content}</span>
: line.aligned ? <span className="policy-source-aligned-value">{content}</span> : content}</Fragment>;
})} })}
</p>)} </p>)}
{observationIndex < group.observations.length - 1 && <hr/>} {observationIndex < group.observations.length - 1 && <hr/>}
</div>)} </div>)}
</article>; </article>;
})} })}
</div> </div>;
return <section className="policy-source-evidence" aria-label="GPO source contributions" data-collapsed={collapsed}>
<h4>{identified} GPO backup {identified === 1 ? 'source' : 'sources'}{unassigned > 0 ? ` · ${unassigned} unassigned` : ''}</h4>
<p className="policy-source-notice">Original instructions by GPO. This list is not an execution order between GPOs and does not establish the effective endpoint value.</p>
{valueGroups.length > 0 && <div className="policy-source-overview" aria-label="GPO source value overview" data-value-groups={valueGroups.length}>
<p className="policy-source-overview__summary">{valueGroups.length === 1
? `All ${groups.length} sources retain the same instruction`
: `${valueGroups.length} different instructions across ${groups.length} sources`}</p>
<ul className="policy-source-overview__groups">
{valueGroups.map((group, index) => <li className="policy-source-overview__group" key={index}>
<CompactValues observations={group.observations} search={search}/>
{valueGroups.length > 1 && <p className="policy-source-overview__sources">{group.sources.length} of {groups.length}: {highlightText(group.sources.join(', '), search)}</p>}
</li>)}
</ul>
{caseOnlyKeys && <p className="policy-source-overview__note">The registry key is spelled with different letter case in the sources. Windows treats registry key names case-insensitively.</p>}
</div>}
{collapsed ? <details className="finding-optional-detail policy-source-collapse">
<summary><TriangleIcon direction="right" />Show the {groups.length} {allValueGroups.length === 1 ? 'identical ' : ''}source instructions</summary>
{sections}
</details> : sections}
</section>; </section>;
} }

View File

@ -4,7 +4,8 @@ import { normalizeNumericValue } from '../../shared/text';
import { resultSectionFields, resultStatusFieldFor } from '../linked-results/result-message'; import { resultSectionFields, resultStatusFieldFor } from '../linked-results/result-message';
import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy'; import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
import { policyEvidenceResultSheets, POLICY_CONFLICT_DETAILS, POLICY_DUPLICATE_DETAILS } from '../../profiles/group-policy/conflict-presentation'; import { policyEvidenceResultSheets, POLICY_CONFLICT_DETAILS, POLICY_DUPLICATE_DETAILS } from '../../profiles/group-policy/conflict-presentation';
import { maesterExecutionErrorText, maesterTagText, maesterTestResultText } from '../../profiles/maester/presentation'; import { maesterExecutionErrorText, maesterTagText } from '../../profiles/maester/presentation';
import { firstReadConsumedFields, firstReadFieldRank, firstReadImpactSplit, firstReadSectionSplit } from './first-read';
/** /**
* Platform-neutral field selection of the Work finding reader: which record fields form the * Platform-neutral field selection of the Work finding reader: which record fields form the
@ -19,8 +20,9 @@ const normalizeValue = (value: string) => value.trim().replace(/\s+/g, ' ').toLo
* die Filterleiste erreichbar beziehungsweise reine Nachschlagewerte - im * die Filterleiste erreichbar beziehungsweise reine Nachschlagewerte - im
* Finding-Abschnitt drueckten sie die eigentliche Aussage nach unten. * Finding-Abschnitt drueckten sie die eigentliche Aussage nach unten.
*/ */
const isAdditionalOnlyField = (label: string) => { const isAdditionalOnlyField = (label: string, toolId?: string) => {
const normalized = normalizeLabel(label); const normalized = normalizeLabel(label);
if (toolId === 'group-policy' && /^(?:registry path|technical identity|imported|evaluated|diagnostics)$/.test(normalized)) return true;
return /^(?:short\s*name|si version|target|runtime|points scale|rule id|source rule id|policy type|policy section|description source)$/.test(normalized) return /^(?:short\s*name|si version|target|runtime|points scale|rule id|source rule id|policy type|policy section|description source)$/.test(normalized)
|| /^(?:quelle|source):? /.test(normalized) || /^(?:quelle|source):? /.test(normalized)
|| /^anssi \d+$/.test(normalized); || /^anssi \d+$/.test(normalized);
@ -185,7 +187,6 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
}); });
const byLabel = (label: string) => record.fields.find(field => normalizeLabel(field.label) === label && field.value); const byLabel = (label: string) => record.fields.find(field => normalizeLabel(field.label) === label && field.value);
const id = byLabel('id'); const id = byLabel('id');
const detail = byLabel('resultdetail');
const script = byLabel('testscript'); const script = byLabel('testscript');
const category = byLabel('block'); const category = byLabel('block');
const tags = byLabel('tag'); const tags = byLabel('tag');
@ -195,7 +196,6 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
record.fields.filter(field => /^(?:id|result|resultdetail|severity|tag|block|duration|errormessage|name|helpurl|testscriptfile|testscript|index)$/.test(normalizeLabel(field.label))) record.fields.filter(field => /^(?:id|result|resultdetail|severity|tag|block|duration|errormessage|name|helpurl|testscriptfile|testscript|index)$/.test(normalizeLabel(field.label)))
.forEach(field => hiddenFieldIndexes.add(field.index)); .forEach(field => hiddenFieldIndexes.add(field.index));
if (!sourceRuleId && id) maesterPresentationFields.push(displayField(id, 'ID')); if (!sourceRuleId && id) maesterPresentationFields.push(displayField(id, 'ID'));
if (statusField?.value) maesterPresentationFields.push(displayField(statusField, 'Test result', maesterTestResultText(statusField.value, detail?.value)));
if (script) maesterPresentationFields.push(displayField(script, 'Test details', script.value.trim())); if (script) maesterPresentationFields.push(displayField(script, 'Test details', script.value.trim()));
if (category) maesterPresentationFields.push(displayField(category, 'Category')); if (category) maesterPresentationFields.push(displayField(category, 'Category'));
if (tags) maesterPresentationFields.push(displayField(tags, 'Tags', maesterTagText(tags.value))); if (tags) maesterPresentationFields.push(displayField(tags, 'Tags', maesterTagText(tags.value)));
@ -206,11 +206,15 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
const hasPolicySourceEvidence = toolId === 'group-policy' && Boolean(record.policySourceEvidence?.length); const hasPolicySourceEvidence = toolId === 'group-policy' && Boolean(record.policySourceEvidence?.length);
if (hasPolicySourceEvidence) record.fields.filter(field => ['Sources', 'Configured Value'].includes(field.label)) if (hasPolicySourceEvidence) record.fields.filter(field => ['Sources', 'Configured Value'].includes(field.label))
.forEach(field => hiddenFieldIndexes.add(field.index)); .forEach(field => hiddenFieldIndexes.add(field.index));
const hasPolicyBaselineComparisons = toolId === 'group-policy' && Boolean(record.policyBaselineComparisons?.length);
if (hasPolicyBaselineComparisons) record.fields
.filter(field => ['Description', 'Baseline', 'Comparison', 'Baseline value', 'Remediation', 'Conflict', 'Duplicates'].includes(field.label))
.forEach(field => hiddenFieldIndexes.add(field.index));
if (hasPolicyEvidenceResults) record.fields.filter(field => [POLICY_CONFLICT_DETAILS, POLICY_DUPLICATE_DETAILS, 'Sources', 'Configured Value'].includes(field.label)) if (hasPolicyEvidenceResults) record.fields.filter(field => [POLICY_CONFLICT_DETAILS, POLICY_DUPLICATE_DETAILS, 'Sources', 'Configured Value'].includes(field.label))
.forEach(field => hiddenFieldIndexes.add(field.index)); .forEach(field => hiddenFieldIndexes.add(field.index));
const policyComparisonFields = toolId === 'group-policy' && (!hasPolicyEvidenceResults || hasPolicySourceEvidence) ? record.fields.filter(field => { const policyComparisonFields = toolId === 'group-policy' && (!hasPolicyEvidenceResults || hasPolicySourceEvidence) ? record.fields.filter(field => {
const label = normalizeLabel(field.label); const label = normalizeLabel(field.label);
return field.value && ((!hasPolicySourceEvidence && label === 'configured value') || label === 'baseline value'); return field.value && ((!hasPolicySourceEvidence && label === 'configured value') || (!hasPolicyBaselineComparisons && label === 'baseline value'));
}) : []; }) : [];
const policyReadiness = toolId === 'group-policy' const policyReadiness = toolId === 'group-policy'
? record.fields.find(field => normalizeLabel(field.label) === 'evaluation readiness' && field.value) : undefined; ? record.fields.find(field => normalizeLabel(field.label) === 'evaluation readiness' && field.value) : undefined;
@ -223,8 +227,22 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
|| (/^points?$/.test(label) && normalizeNumericValue(field.value) !== null); || (/^points?$/.test(label) && normalizeNumericValue(field.value) !== null);
}); });
const mitre = [...record.fields, ...legacyFramework.fields].filter(field => mitreBadgeLabel(field.label) && field.value); const mitre = [...record.fields, ...legacyFramework.fields].filter(field => mitreBadgeLabel(field.label) && field.value);
if (toolId !== 'group-policy') firstReadConsumedFields(record, toolId).forEach(field => hiddenFieldIndexes.add(field.index));
// A native remediation subsection moved to the next step is not repeated; the text before it stays as background.
// References a scanner appended to its impact text move to the references position.
const sectionSplit = toolId !== 'group-policy' ? firstReadSectionSplit(record, toolId) : undefined;
const impactSplit = toolId !== 'group-policy' ? firstReadImpactSplit(record, toolId) : undefined;
const sectionBackground: RecordField[] = [];
let firstReadIndex = Math.max(-1, ...record.fields.map(field => field.index), ...maesterPresentationFields.map(field => field.index)) + 1;
if (sectionSplit) {
hiddenFieldIndexes.add(sectionSplit.field.index);
if (sectionSplit.background) sectionBackground.push({ ...sectionSplit.field, value: sectionSplit.background, index: firstReadIndex++ });
}
if (impactSplit?.references) sectionBackground.push({ ...impactSplit.field, label: 'References', value: impactSplit.references, index: firstReadIndex++ });
const summaryPriority = (label: string) => { const summaryPriority = (label: string) => {
const normalized = normalizeLabel(label); const normalized = normalizeLabel(label);
const firstReadRank = toolId !== 'group-policy' ? firstReadFieldRank(label, toolId) : undefined;
if (firstReadRank !== undefined) return firstReadRank;
if (normalized === 'conflict' || normalized === 'duplicates') return -1; if (normalized === 'conflict' || normalized === 'duplicates') return -1;
if (toolId === 'maester' && normalized === 'id') return -1; if (toolId === 'maester' && normalized === 'id') return -1;
if (normalized === 'result message') return 0; if (normalized === 'result message') return 0;
@ -245,12 +263,12 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
return 10; return 10;
}; };
const summaryValues = new Set<string>(); const summaryValues = new Set<string>();
const presentationFields = [...record.fields, ...maesterPresentationFields]; const presentationFields = [...record.fields, ...maesterPresentationFields, ...sectionBackground];
const maesterPresentationIndexes = new Set(maesterPresentationFields.map(field => field.index)); const maesterPresentationIndexes = new Set(maesterPresentationFields.map(field => field.index));
const summaryFields = presentationFields const summaryFields = presentationFields
.filter(field => { .filter(field => {
if (hiddenFieldIndexes.has(field.index)) return false; if (hiddenFieldIndexes.has(field.index)) return false;
if (!field.value || field.value === title || badges.some(badge => badge.index === field.index) || mitreBadgeLabel(field.label) || isAdditionalOnlyField(field.label) if (!field.value || field.value === title || badges.some(badge => badge.index === field.index) || mitreBadgeLabel(field.label) || isAdditionalOnlyField(field.label, toolId)
|| (!maesterPresentationIndexes.has(field.index) && isHeadingOrBadgeField(field.label))) return false; || (!maesterPresentationIndexes.has(field.index) && isHeadingOrBadgeField(field.label))) return false;
if (hasStructuredResults && isRedundantDetailField(field.label)) return false; if (hasStructuredResults && isRedundantDetailField(field.label)) return false;
const value = normalizeValue(field.value); const value = normalizeValue(field.value);
@ -270,7 +288,7 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
const displayedTextValues = new Set([title, ...summary.map(field => field.value)].map(normalizeValue)); const displayedTextValues = new Set([title, ...summary.map(field => field.value)].map(normalizeValue));
const remainingFields = [...presentationFields, ...legacyFramework.fields.filter(field => !mitreBadgeLabel(field.label))].filter(field => { const remainingFields = [...presentationFields, ...legacyFramework.fields.filter(field => !mitreBadgeLabel(field.label))].filter(field => {
if (!field.value || displayedIndexes.has(field.index)) return false; if (!field.value || displayedIndexes.has(field.index)) return false;
if (isAdditionalOnlyField(field.label)) return true; if (isAdditionalOnlyField(field.label, toolId)) return true;
if (isHeadingOrBadgeField(field.label)) return false; if (isHeadingOrBadgeField(field.label)) return false;
return !displayedTextValues.has(normalizeValue(field.value)); return !displayedTextValues.has(normalizeValue(field.value));
}); });

View File

@ -0,0 +1,210 @@
import type { DatasetSheet } from '../../domain/dataset';
import type { FindingRecord, RecordField } from '../../domain/finding';
import type { ToolProfileDefinition } from '../../domain/tool-profile-definition';
import type { CanonicalOutcome } from '../../domain/canonical-model';
import { toolProfileDefinitions } from '../../profiles/profile-definition-catalog';
import { resultSheetRowToneFor } from '../linked-results/result-message';
type FirstReadDefinition = NonNullable<ToolProfileDefinition['presentation']['firstRead']>;
export interface FirstReadRow {
label: string;
value: string;
/** Proven by the source itself; otherwise neutral. */
state?: 'deviating' | 'aligned';
}
/** First-read problem statement and next step, built only from retained source values. */
export interface FindingFirstRead {
heading: 'Problem' | 'Summary';
rows: FirstReadRow[];
/** First paragraph of the source remediation; the remaining text is kept complete in `nextStepRest`. */
nextStep?: { label: string; lead: string; command?: string; rest: string };
link?: { label: string; href: string };
}
const normalize = (label: string) => label.trim().replace(/\s+/g, ' ');
export function firstReadDefinition(toolId?: string): FirstReadDefinition | undefined {
if (!toolId) return undefined;
return toolProfileDefinitions.find(profile => profile.id === toolId.toLocaleLowerCase('en-US'))?.presentation.firstRead;
}
function fieldFor(record: FindingRecord, patterns?: RegExp[]): RecordField | undefined {
if (!patterns?.length) return undefined;
return record.fields.find(field => field.value.trim() && patterns.some(pattern => pattern.test(normalize(field.label))));
}
/** Fields rendered by the first read and therefore not repeated as ordinary report fields. */
export function firstReadConsumedFields(record: FindingRecord, toolId?: string): RecordField[] {
const definition = firstReadDefinition(toolId);
if (!definition) return [];
return [definition.resultHeaders, definition.affectedHeaders, definition.impactHeaders, definition.nextStepHeaders]
.map(patterns => fieldFor(record, patterns)).filter((field): field is RecordField => Boolean(field));
}
/**
* A declared native remediation subsection inside another field, used only when no dedicated
* remediation field exists. `background` is the retained text before the native heading.
*/
export function firstReadSectionSplit(record: FindingRecord, toolId?: string):
{ field: RecordField; background: string; section: string } | undefined {
const definition = firstReadDefinition(toolId);
if (!definition?.nextStepSection || fieldFor(record, definition.nextStepHeaders)) return undefined;
const field = fieldFor(record, definition.nextStepSection.headers);
const heading = field ? definition.nextStepSection.heading.exec(field.value) : null;
if (!field || !heading) return undefined;
const section = field.value.slice(heading.index + heading[0].length).trim();
return section ? { field, background: field.value.slice(0, heading.index).trim(), section } : undefined;
}
/** Impact text before a declared native reference heading, and the retained references after it. */
export function firstReadImpactSplit(record: FindingRecord, toolId?: string):
{ field: RecordField; impact: string; references: string } | undefined {
const definition = firstReadDefinition(toolId);
const field = fieldFor(record, definition?.impactHeaders);
const marker = field && definition?.impactReferenceMarker ? definition.impactReferenceMarker.exec(field.value) : null;
if (!field || !marker) return undefined;
const references = field.value.slice(marker.index + marker[0].length).trim();
return { field, impact: field.value.slice(0, marker.index).trim(), references };
}
/**
* Display paragraphs of prose whose paragraph breaks were lost in the export (`privileges.An attacker`).
* Only a sentence end directly followed by a capitalized word gets a break; no character is removed.
*/
export function restoredParagraphs(text: string): string {
return /\n/.test(text) ? text : text.replace(/([a-z][.!?])(?=[A-Z][a-z])/g, '$1\n\n');
}
/** Ordering of the remaining explanatory fields: background first, references last. */
export function firstReadFieldRank(label: string, toolId?: string): number | undefined {
const definition = firstReadDefinition(toolId);
if (!definition) return undefined;
const normalized = normalize(label);
const background = definition.backgroundHeaders?.findIndex(pattern => pattern.test(normalized)) ?? -1;
// Directly after the first read, before scanner metadata such as details, categories and tags.
if (background >= 0) return 1 + background / 100;
const reference = definition.referenceHeaders?.findIndex(pattern => pattern.test(normalized)) ?? -1;
return reference >= 0 ? 40 + reference : undefined;
}
const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`;
const rowText = (row: FindingRecord) => row.fields.map(field => field.value.trim()).filter(Boolean).join(' · ');
/**
* Present an explicitly introduced PowerShell command separately without changing the retained source field.
* The introduction names a command or script within the same sentence and ends with a colon; the command
* starts with a cmdlet (`Verb-Noun`) or a variable assignment (`$name =`).
*/
function separatePowerShellCommand(paragraph: string): { lead: string; command?: string } {
const introduction = /\b(?:[Pp]ower[Ss]hell\s+)?(?:[Cc]ommand|[Ss]cript)\b[^:.]{0,160}:\s*(?=(?:[A-Z][a-z]+-[A-Z][\w-]*\b|\$[A-Za-z_]\w*\s*=))/.exec(paragraph);
if (!introduction || introduction.index === undefined) return { lead: paragraph };
const command = paragraph.slice(introduction.index + introduction[0].length).trim();
return command ? { lead: paragraph.slice(0, introduction.index + introduction[0].trimEnd().length), command } : { lead: paragraph };
}
const CMDLET_START = /^[A-Z][a-z]+-[A-Z][\w-]*\b/;
/**
* Display layout of a PowerShell command whose line breaks were lost in the export. Only whitespace
* at the top nesting level outside quotes changes: a new statement that starts with a cmdlet after a
* closing bracket begins a new line, and a pipeline continues on an indented line after `|`.
* Every other character stays as retained, so the displayed text remains valid, copyable PowerShell.
*/
export function powerShellDisplayLines(command: string): string[] {
// A command that kept its own line breaks is shown exactly as retained.
if (/\n/.test(command)) return command.split(/\r?\n/);
const lines: string[] = [];
let current = '';
let depth = 0;
let quote = '';
let continuation = false;
const flush = () => {
if (current.trim()) lines.push(`${continuation ? ' ' : ''}${current.trim()}`);
current = '';
};
for (let index = 0; index < command.length; index += 1) {
const character = command[index];
if (quote) {
current += character;
if (character === quote && command[index - 1] !== '`') quote = '';
continue;
}
if (character === '"' || character === "'") { quote = character; current += character; continue; }
if ('({['.includes(character)) depth += 1;
if (')}]'.includes(character)) depth = Math.max(0, depth - 1);
if (depth === 0 && character === '|' && command[index + 1] !== '|' && command[index - 1] !== '|') {
current += character;
flush();
continuation = true;
while (/\s/.test(command[index + 1] ?? '')) index += 1;
continue;
}
if (depth === 0 && /\s/.test(character)) {
const previous = current.trimEnd().slice(-1);
const rest = command.slice(index).trimStart();
// Only after a closed expression: a cmdlet name after a plain word can be an argument (`Get-Help Get-ADUser`).
if (previous && /[)\]}]/.test(previous) && CMDLET_START.test(rest)) {
flush();
continuation = false;
index += command.slice(index).length - rest.length - 1;
continue;
}
}
current += character;
}
flush();
return lines;
}
export function findingFirstRead(
record: FindingRecord, toolId: string | undefined, linkedSheets: DatasetSheet[], outcome?: CanonicalOutcome
): FindingFirstRead | undefined {
const definition = firstReadDefinition(toolId);
if (!definition) return undefined;
const rows: FirstReadRow[] = [];
const result = fieldFor(record, definition.resultHeaders);
if (result) rows.push({ label: 'Result', value: result.value.trim() });
const resultRows = linkedSheets.flatMap(sheet => sheet.analysis.records);
if (definition.criterionEvidence) {
const criterion = resultRows.find(row => resultSheetRowToneFor(toolId, row, outcome) === 'criterion');
const evaluated = resultRows.filter(row => row.highlighted);
if (criterion) rows.push({ label: 'Expected', value: rowText(criterion) });
for (const row of evaluated) {
const tone = resultSheetRowToneFor(toolId, row, outcome);
rows.push({ label: 'Found', value: rowText(row),
...(tone === 'violation' ? { state: 'deviating' as const } : tone === 'match' ? { state: 'aligned' as const } : {}) });
}
}
const affected = fieldFor(record, definition.affectedHeaders);
if (affected) rows.push({ label: 'Affected', value: affected.value.trim() });
else if (definition.statusRowCounts && resultRows.length) {
const tones = resultRows.map(row => resultSheetRowToneFor(toolId, row, outcome));
const failed = tones.filter(tone => tone === 'violation').length;
const passed = tones.filter(tone => tone === 'match').length;
if (failed || passed) rows.push({ label: 'Results', value: [failed ? `${failed} of ${resultRows.length} rows marked failed` : '',
passed ? `${passed} marked passed` : ''].filter(Boolean).join(' · '), state: failed ? 'deviating' : 'aligned' });
} else if (definition.affectedFromResults && resultRows.length) {
rows.push({ label: 'Results', value: `${plural(resultRows.length, 'row')} in ${plural(linkedSheets.length, 'table')}` });
}
const impact = fieldFor(record, definition.impactHeaders);
const impactText = impact ? firstReadImpactSplit(record, toolId)?.impact ?? impact.value.trim() : '';
if (impactText) rows.push({ label: 'Impact', value: restoredParagraphs(impactText) });
const field = fieldFor(record, definition.nextStepHeaders);
const section = field ? undefined : firstReadSectionSplit(record, toolId);
const remediation = field ? { label: field.label, value: field.value } : section ? { label: section.field.label, value: section.section } : undefined;
const [lead, ...rest] = remediation ? remediation.value.trim().split(/\n\s*\n/) : [];
const nextStep = remediation ? { label: remediation.label, ...separatePowerShellCommand(lead.trim()), rest: rest.join('\n\n').trim() } : undefined;
const linkField = fieldFor(record, definition.nextStepLinkHeaders);
// Imported URLs are untrusted: only a single plain https link is offered, never script or other schemes.
const href = linkField?.value.trim();
const link = href && /^https:\/\/[^\s<>"']+$/i.test(href) ? { label: 'Documentation', href } : undefined;
if (!rows.length && !nextStep && !link) return undefined;
return { heading: outcome === 'pass' ? 'Summary' : 'Problem', rows, nextStep, link };
}

View File

@ -78,9 +78,14 @@ export function VariantComparisonResult({ comparison, search }: { comparison: Re
<div className="variant-comparison__table-wrap"> <div className="variant-comparison__table-wrap">
<table className="variant-comparison__table"> <table className="variant-comparison__table">
<thead><tr>{!duplicate && <th>Variant</th>}<th>Value</th><th>Type</th><th>Evidence</th></tr></thead> <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}> <tbody>{comparison.variants.map(variant => <tr key={variant.label} data-baseline-state={variant.baselineState ?? 'unassessed'}>
{!duplicate && <th scope="row">{variant.label}</th>} {!duplicate && <th scope="row">{variant.label}</th>}
<td className="variant-comparison__value"><DisplayValue value={variant.value} search={search} /></td> {/* Red: differs from every current baseline variant. Green: matches every one. Otherwise neutral. */}
<td className="variant-comparison__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'
? 'Differs from the baseline' : 'Matches the baseline'}</span>}</td>
<td>{highlighted(variant.typeLabel, search)}</td> <td>{highlighted(variant.typeLabel, search)}</td>
<td><span>{variant.sources.length} {variant.sources.length === 1 ? 'source' : 'sources'}</span> <td><span>{variant.sources.length} {variant.sources.length === 1 ? 'source' : 'sources'}</span>
<SourceList sources={variant.sources} search={search} /></td> <SourceList sources={variant.sources} search={search} /></td>

View File

@ -47,6 +47,14 @@ export function resultSheetRowToneFor(toolId: string | undefined, record: Findin
const presentation = toolProfileDefinitions.find(profile => profile.id === toolId.toLocaleLowerCase('en-US'))?.presentation; 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(); const value = record.fields.filter(field => field.value).map(field => field.value.trim()).join(' ').trim();
if (presentation?.resultSheetCriterionRowPatterns?.some(pattern => pattern.test(value))) return 'criterion'; if (presentation?.resultSheetCriterionRowPatterns?.some(pattern => pattern.test(value))) return 'criterion';
const statusCells = presentation?.resultSheetRowStatusCells;
if (statusCells) {
const cells = record.fields.map(field => field.value.trim()).filter(Boolean);
const match = cells.some(cell => statusCells.match.some(pattern => pattern.test(cell)));
const violation = cells.some(cell => statusCells.violation.some(pattern => pattern.test(cell)));
// Contradicting markers in one row prove neither state.
if (match !== violation) return match ? 'match' : 'violation';
}
if (record.highlighted && presentation?.resultSheetEvaluatedRowTone === 'violation') { if (record.highlighted && presentation?.resultSheetEvaluatedRowTone === 'violation') {
if (findingOutcome === 'pass') return 'match'; if (findingOutcome === 'pass') return 'match';
if (findingOutcome === 'fail') return 'violation'; if (findingOutcome === 'fail') return 'violation';
@ -172,6 +180,8 @@ export function resultStatusFieldFor(record: FindingRecord, fallbackToolId?: str
const field = record.fields.find(candidate => const field = record.fields.find(candidate =>
candidate.value candidate.value
&& header.test(candidate.label.trim()) && header.test(candidate.label.trim())
&& !(/^(?:result|evaluation)$/i.test(candidate.label.trim())
&& /^Security_Assessment_Report_.+\([A-Z]{2}\d{6}\)$/i.test(candidate.value.trim()))
&& !isMessageField(candidate, record, presentation)); && !isMessageField(candidate, record, presentation));
if (field) { if (field) {
const displayLabel = presentation?.statusDisplayRules const displayLabel = presentation?.statusDisplayRules

View File

@ -11,10 +11,8 @@ import { resultMessageFor, resultOutcomeFor, resultSectionsFor } from '../linked
import { RESULT_WINDOW_COLORS } from '../linked-results/result-window-colors'; import { RESULT_WINDOW_COLORS } from '../linked-results/result-window-colors';
import { useFilteredRecords } from '../search-filter/use-filtered-records'; import { useFilteredRecords } from '../search-filter/use-filtered-records';
import { toolIdForRecord } from '../../importers/status-normalization'; import { toolIdForRecord } from '../../importers/status-normalization';
import mitreData from '../../assets/data/mitre-enterprise-v18.1.json'; import { activeMitreMatrix, buildFindingPathItems, MITRE_MATRIX_CHANGED_EVENT, type FindingPathItem, type MitreGraphData } from './mitre-graph';
import { buildFindingPathItems, loadStoredMitreMatrix, type FindingPathItem, type MitreGraphData } from './mitre-graph';
const defaultGraphData = mitreData as MitreGraphData;
const severityLabels = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', info: 'Info', unknown: 'Unknown' } as const; const severityLabels = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', info: 'Info', unknown: 'Unknown' } as const;
type SeverityFilter = 'all' | keyof typeof severityLabels; type SeverityFilter = 'all' | keyof typeof severityLabels;
type OriginFilter = 'all' | 'native' | 'derived'; type OriginFilter = 'all' | 'native' | 'derived';
@ -72,7 +70,12 @@ export function MitreGraphView({ onOpenFinding }: MitreGraphViewProps) {
updateReview updateReview
} = app; } = app;
const [graphData, setGraphData] = useState<MitreGraphData>(() => loadStoredMitreMatrix() ?? defaultGraphData); const [graphData, setGraphData] = useState<MitreGraphData>(activeMitreMatrix);
useEffect(() => {
const refreshMatrix = () => setGraphData(activeMitreMatrix());
globalThis.addEventListener(MITRE_MATRIX_CHANGED_EVENT, refreshMatrix);
return () => globalThis.removeEventListener(MITRE_MATRIX_CHANGED_EVENT, refreshMatrix);
}, []);
const [category, setCategory] = useState('all'); const [category, setCategory] = useState('all');
const [severity, setSeverity] = useState<SeverityFilter>('all'); const [severity, setSeverity] = useState<SeverityFilter>('all');
const [originFilter, setOriginFilter] = useState<OriginFilter>('all'); const [originFilter, setOriginFilter] = useState<OriginFilter>('all');

View File

@ -1,11 +1,10 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent } from 'react';
import { useApp } from '../../app/AppProvider'; import { useApp } from '../../app/AppProvider';
import { TileCard } from '../../components/TileCard/TileCard';
import { FloatingResultsWindow } from '../linked-results/FloatingResultsWindow'; import { FloatingResultsWindow } from '../linked-results/FloatingResultsWindow';
import { linkResultSheets } from '../../profiles/purple-knight/result-linker'; import { linkResultSheets } from '../../profiles/purple-knight/result-linker';
import { linkPingCastleSectionSheets } from '../../profiles/pingcastle/pingcastle-section-links'; import { linkPingCastleSectionSheets } from '../../profiles/pingcastle/pingcastle-section-links';
import { inlineResultTableSheets } from '../linked-results/inline-result-tables'; import { inlineResultTableSheets } from '../linked-results/inline-result-tables';
import { resultMessageFor, resultOutcomeFor, resultSectionsFor } from '../linked-results/result-message'; import { resultMessageFor, resultOutcomeFor, resultSectionsFor, resultStatusFieldFor } from '../linked-results/result-message';
import { RESULT_WINDOW_COLORS } from '../linked-results/result-window-colors'; import { RESULT_WINDOW_COLORS } from '../linked-results/result-window-colors';
import { useFilteredRecords } from '../search-filter/use-filtered-records'; import { useFilteredRecords } from '../search-filter/use-filtered-records';
import { SearchFilterBar, SearchTextControl } from '../search-filter/SearchFilterBar'; import { SearchFilterBar, SearchTextControl } from '../search-filter/SearchFilterBar';
@ -13,18 +12,13 @@ import { deriveDynamicFacets } from '../search-filter/dynamic-facets';
import { ViewBookmarks } from '../view-bookmarks/ViewBookmarks'; import { ViewBookmarks } from '../view-bookmarks/ViewBookmarks';
import type { PathSnapshotState, ViewBookmark } from '../view-bookmarks/view-bookmarks'; import type { PathSnapshotState, ViewBookmark } from '../view-bookmarks/view-bookmarks';
import { toolIdForRecord } from '../../importers/status-normalization'; import { toolIdForRecord } from '../../importers/status-normalization';
import mitreData from '../../assets/data/mitre-enterprise-v18.1.json'; import { toolProfileDefinitions } from '../../profiles/profile-definition-catalog';
import { buildFindingPathItems, loadStoredMitreMatrix, type FindingPathItem, type MitreGraphData } from './mitre-graph'; import { activeMitreMatrix, buildFindingPathItems, MITRE_MATRIX_CHANGED_EVENT, type FindingPathItem, type MitreGraphData } from './mitre-graph';
import { buildObsidianGraph, type ObsidianGraphNode } from './obsidian-graph'; import { buildObsidianGraph, type ObsidianGraphNode } from './obsidian-graph';
import { hiddenPathfinderFacetLabels } from './pathfinder-filter-trail'; import { hiddenPathfinderFacetLabels } from './pathfinder-filter-trail';
import { graphCardConnection } from './graph-card-connection'; import { FILTERED_GRAPH_SELECTION_ID, graphSelectionUnionNodeIds, selectedPathNodeIds } from './category-branch';
import { bestNearbyCardCandidate, graphCardMaximumTetherDistance, graphEdgeCrossingCount, graphLineDistanceToPoint, graphLineSegmentsIntersect, type GraphLineSegment } from './graph-card-edge-avoidance';
import { adaptiveGraphCardScale, minimumReadableGraphCardZoom } from './graph-card-scale';
import { FILTERED_GRAPH_SELECTION_ID, graphSelectionUnionNodeIds } from './category-branch';
import { graphFacetSelectionForNode, relationNodeIdForFacetFilters, type GraphFacetSelection } from './graph-filter-bridge'; import { graphFacetSelectionForNode, relationNodeIdForFacetFilters, type GraphFacetSelection } from './graph-filter-bridge';
import { PathfinderMicroCard, pathfinderMicroCardContract } from './PathfinderMicroCard';
const defaultGraphData = mitreData as MitreGraphData;
const severityLabels = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', info: 'Info', unknown: 'Unknown' } as const; const severityLabels = { critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low', info: 'Info', unknown: 'Unknown' } as const;
const graphNodeKindLabels = { finding: 'Finding', category: 'Category', defense: 'D3FEND', target: 'Target', technique: 'Technique', tactic: 'Tactic' } as const; const graphNodeKindLabels = { finding: 'Finding', category: 'Category', defense: 'D3FEND', target: 'Target', technique: 'Technique', tactic: 'Tactic' } as const;
interface PathSnapshotRequest { id: string; path: PathSnapshotState; } interface PathSnapshotRequest { id: string; path: PathSnapshotState; }
@ -37,7 +31,7 @@ interface ObsidianGraphViewProps {
interface ResultWindowState { recordId: string; toolId?: string; title: string; color: string; zIndex: number; minimized: boolean; } interface ResultWindowState { recordId: string; toolId?: string; title: string; color: string; zIndex: number; minimized: boolean; }
interface CameraState { x: number; y: number; scale: number; } interface CameraState { x: number; y: number; scale: number; }
interface PointerOperation { interface PointerOperation {
kind: 'pan' | 'node' | 'card'; kind: 'pan' | 'node';
pointerId: number; pointerId: number;
moved: boolean; moved: boolean;
nodeId?: string; nodeId?: string;
@ -45,27 +39,11 @@ interface PointerOperation {
startY: number; startY: number;
camera: CameraState; camera: CameraState;
nodeStart?: { x: number; y: number }; nodeStart?: { x: number; y: number };
cardStart?: { x: number; y: number };
} }
interface GraphCardPlacement { interface ExtractViewState { camera: CameraState; pinnedNodeIds: string[]; selectedNodeId: string; markedSelectionIds: string[]; }
node: ObsidianGraphNode;
finding?: FindingPathItem;
x: number;
y: number;
width: number;
height: number;
}
type GraphDisplayMode = 'off' | 'labels' | 'text';
interface ExtractViewState { camera: CameraState; pinnedNodeIds: string[]; selectedNodeId: string; displayMode: GraphDisplayMode; markedSelectionIds: string[]; }
const clamp = (value: number, minimum: number, maximum: number) => Math.min(maximum, Math.max(minimum, value)); const clamp = (value: number, minimum: number, maximum: number) => Math.min(maximum, Math.max(minimum, value));
const graphLabel = (label: string) => label.length > 44 ? `${label.slice(0, 41).trimEnd()}…` : label; const graphLabel = (label: string) => label.length > 44 ? `${label.slice(0, 41).trimEnd()}…` : label;
const graphFindingCardWidth = pathfinderMicroCardContract.finding.width;
const graphFindingCardHeight = pathfinderMicroCardContract.finding.height;
const graphRoleCardWidth = pathfinderMicroCardContract.relation.width;
const graphRoleCardHeight = pathfinderMicroCardContract.relation.height;
const graphCategoryCardWidth = pathfinderMicroCardContract.category.width;
const graphCategoryCardHeight = pathfinderMicroCardContract.category.height;
function fittedCameraFor(graphNodes: Array<{ x: number; y: number }>, width: number, height: number): CameraState { function fittedCameraFor(graphNodes: Array<{ x: number; y: number }>, width: number, height: number): CameraState {
if (!graphNodes.length) return { x: 0, y: 0, scale: 1 }; if (!graphNodes.length) return { x: 0, y: 0, scale: 1 };
const xs = graphNodes.map(node => node.x); const xs = graphNodes.map(node => node.x);
@ -82,22 +60,19 @@ export const ObsidianGraphView = memo(ObsidianGraphContent);
function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPathSnapshotApplied }: ObsidianGraphViewProps) { function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPathSnapshotApplied }: ObsidianGraphViewProps) {
const app = useApp(); const app = useApp();
const { dataset, activeSheet, activeSheetId, activeProfile, titleColumn, reviews, actionOnly, facetFilters, pathFindingIds, search: workSearch, statusFilter, setFacetFilter, setSelectedRecord, setView, toggleWorkspace, updateReview } = app; const { dataset, activeSheet, activeSheetId, activeProfile, titleColumn, reviews, actionOnly, facetFilters, pathFindingIds, search: workSearch, statusFilter, setFacetFilter, setSelectedRecord, setView, toggleWorkspace, updateReview } = app;
const [graphData] = useState<MitreGraphData>(() => loadStoredMitreMatrix() ?? defaultGraphData); const [graphData, setGraphData] = useState<MitreGraphData>(activeMitreMatrix);
useEffect(() => {
const refreshMatrix = () => setGraphData(activeMitreMatrix());
globalThis.addEventListener(MITRE_MATRIX_CHANGED_EVENT, refreshMatrix);
return () => globalThis.removeEventListener(MITRE_MATRIX_CHANGED_EVENT, refreshMatrix);
}, []);
const [selectedNodeId, setSelectedNodeId] = useState(''); const [selectedNodeId, setSelectedNodeId] = useState('');
const [pinnedNodeIds, setPinnedNodeIds] = useState<string[]>([]); const [pinnedNodeIds, setPinnedNodeIds] = useState<string[]>([]);
const [hoveredNodeId, setHoveredNodeId] = useState(''); const [hoveredNodeId, setHoveredNodeId] = useState('');
const [displayMode, setDisplayMode] = useState<GraphDisplayMode>('off'); const [showText, setShowText] = useState(true);
const showCards = displayMode === 'labels';
const showText = displayMode === 'text';
const [camera, setCamera] = useState<CameraState>({ x: 0, y: 0, scale: 1 }); const [camera, setCamera] = useState<CameraState>({ x: 0, y: 0, scale: 1 });
const graphCardScale = adaptiveGraphCardScale(
camera.scale,
pathfinderMicroCardContract.screenScale.minimum,
pathfinderMicroCardContract.screenScale.maximum
).graphScale;
const [dimensions, setDimensions] = useState({ width: 1200, height: 720 }); const [dimensions, setDimensions] = useState({ width: 1200, height: 720 });
const [nodeOverrides, setNodeOverrides] = useState<Record<string, { x: number; y: number }>>({}); const [nodeOverrides, setNodeOverrides] = useState<Record<string, { x: number; y: number }>>({});
const [cardOverrides, setCardOverrides] = useState<Record<string, { x: number; y: number }>>({});
const [markedSelectionIds, setMarkedSelectionIds] = useState<string[]>([]); const [markedSelectionIds, setMarkedSelectionIds] = useState<string[]>([]);
const [extractedSelectionIds, setExtractedSelectionIds] = useState<string[]>([]); const [extractedSelectionIds, setExtractedSelectionIds] = useState<string[]>([]);
const [resultWindows, setResultWindows] = useState<ResultWindowState[]>([]); const [resultWindows, setResultWindows] = useState<ResultWindowState[]>([]);
@ -105,7 +80,6 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
const [resetViewRevision, setResetViewRevision] = useState(0); const [resetViewRevision, setResetViewRevision] = useState(0);
const surfaceRef = useRef<SVGSVGElement>(null); const surfaceRef = useRef<SVGSVGElement>(null);
const pointerOperation = useRef<PointerOperation | null>(null); const pointerOperation = useRef<PointerOperation | null>(null);
const consumedCardClick = useRef('');
const nextColor = useRef(0); const nextColor = useRef(0);
const nextZIndex = useRef(2100); const nextZIndex = useRef(2100);
const lastFittedGraphSignature = useRef(''); const lastFittedGraphSignature = useRef('');
@ -117,9 +91,6 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
const cameraAnimationFrame = useRef<number | undefined>(undefined); const cameraAnimationFrame = useRef<number | undefined>(undefined);
cameraRef.current = camera; cameraRef.current = camera;
const marked = Object.values(reviews).filter(review => review.include).length; const marked = Object.values(reviews).filter(review => review.include).length;
const minimumCardZoom = minimumReadableGraphCardZoom(pinnedNodeIds.length);
const graphCardsSuppressed = showCards && camera.scale < minimumCardZoom;
const renderGraphCards = showCards && !graphCardsSuppressed;
const visibleRecords = useFilteredRecords('deviation'); const visibleRecords = useFilteredRecords('deviation');
const graphFacets = useMemo( const graphFacets = useMemo(
@ -138,19 +109,29 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
[graph, markedSelectionIds] [graph, markedSelectionIds]
); );
const extractedNodeIds = useMemo( const extractedNodeIds = useMemo(
() => extractedSelectionIds.length ? new Set(graphSelectionUnionNodeIds(graph, extractedSelectionIds)) : undefined, () => extractedSelectionIds.length ? new Set(selectedPathNodeIds(graph, extractedSelectionIds[0])) : undefined,
[extractedSelectionIds, graph] [extractedSelectionIds, graph]
); );
const displayedNodes = useMemo( const displayedNodes = useMemo(
() => extractedNodeIds ? nodes.filter(node => extractedNodeIds.has(node.id)) : nodes, () => extractedNodeIds ? nodes.filter(node => extractedNodeIds.has(node.id)) : nodes,
[extractedNodeIds, nodes] [extractedNodeIds, nodes]
); );
const displayedEdges = useMemo(
() => extractedSelectionIds.length
? graph.edges.filter(edge => edge.source === extractedSelectionIds[0] || edge.target === extractedSelectionIds[0])
: graph.edges,
[extractedSelectionIds, graph.edges]
);
const labelOffsets = useMemo(() => { const labelOffsets = useMemo(() => {
const placed: Array<{ left: number; right: number; top: number; bottom: number }> = []; const placed: Array<{ left: number; right: number; top: number; bottom: number }> = [];
const offsets: Record<string, number> = {}; const offsets: Record<string, number> = {};
if (!showText) return offsets; if (!showText) return offsets;
const candidates = [0, -22, 22, -44, 44, -66, 66, -88, 88, -110, 110, -132, 132, -154, 154, -176, 176, -198, 198, -220, 220]; const candidates = [0, -22, 22, -44, 44, -66, 66, -88, 88, -110, 110, -132, 132, -154, 154, -176, 176, -198, 198, -220, 220];
const ordered = [...displayedNodes].sort((left, right) => Number(left.kind === 'finding') - Number(right.kind === 'finding') || left.y - right.y); const ordered = displayedNodes.filter(node => {
const screenX = dimensions.width / 2 + camera.x + node.x * camera.scale;
const screenY = dimensions.height / 2 + camera.y + node.y * camera.scale;
return screenX >= 6 && screenX <= dimensions.width - 6 && screenY >= 6 && screenY <= dimensions.height - 6;
}).sort((left, right) => Number(left.kind === 'finding') - Number(right.kind === 'finding') || left.y - right.y);
for (const node of ordered) { for (const node of ordered) {
const label = graphLabel(node.label); const label = graphLabel(node.label);
const width = Math.max(48, Math.min(352, label.length * 8.6)); const width = Math.max(48, Math.min(352, label.length * 8.6));
@ -172,125 +153,6 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
}, [camera.scale, camera.x, camera.y, dimensions.height, dimensions.width, displayedNodes, showText]); }, [camera.scale, camera.x, camera.y, dimensions.height, dimensions.width, displayedNodes, showText]);
const nodeLookup = useMemo(() => new Map(nodes.map(node => [node.id, node])), [nodes]); const nodeLookup = useMemo(() => new Map(nodes.map(node => [node.id, node])), [nodes]);
const findingLookup = useMemo(() => new Map(items.map(item => [item.id, item])), [items]); const findingLookup = useMemo(() => new Map(items.map(item => [item.id, item])), [items]);
const cardPlacements = useMemo<GraphCardPlacement[]>(() => {
const displayedNodeIdSet = new Set(displayedNodes.map(node => node.id));
const hoverCardNodeId = hoveredNodeId && displayedNodeIdSet.has(hoveredNodeId) ? hoveredNodeId : '';
if ((!renderGraphCards && !hoverCardNodeId) || !displayedNodes.length) return [];
const graphNodes = [...nodeLookup.values()];
const graphCenterX = graphNodes.length
? (Math.min(...graphNodes.map(node => node.x)) + Math.max(...graphNodes.map(node => node.x))) / 2
: 0;
const collisionGap = extractedNodeIds ? 18 : 10;
const occupied: Array<{ left: number; right: number; top: number; bottom: number }> = [];
const cardConnectorSegments: GraphLineSegment[] = [];
const placements: GraphCardPlacement[] = [];
const activeEdgeSegments = graph.edges.flatMap(edge => {
if (!pinnedNodeIds.includes(edge.source) && !pinnedNodeIds.includes(edge.target)) return [];
if (extractedNodeIds && (!extractedNodeIds.has(edge.source) || !extractedNodeIds.has(edge.target))) return [];
const source = nodeLookup.get(edge.source);
const target = nodeLookup.get(edge.target);
return source && target ? [{ x1: source.x, y1: source.y, x2: target.x, y2: target.y }] : [];
});
const labelNodeIds = [...new Set([
...(renderGraphCards ? pinnedNodeIds.filter(nodeId => displayedNodeIdSet.has(nodeId)) : []),
...(hoverCardNodeId ? [hoverCardNodeId] : [])
])];
const orderedNodeIds = labelNodeIds.sort((leftId, rightId) => {
const leftOverride = cardOverrides[leftId] ? 0 : 1;
const rightOverride = cardOverrides[rightId] ? 0 : 1;
if (leftOverride !== rightOverride) return leftOverride - rightOverride;
if (!extractedNodeIds) return 0;
const left = nodeLookup.get(leftId);
const right = nodeLookup.get(rightId);
return (left?.y ?? 0) - (right?.y ?? 0) || (left?.x ?? 0) - (right?.x ?? 0);
});
for (const nodeId of orderedNodeIds) {
if (extractedNodeIds && !extractedNodeIds.has(nodeId)) continue;
const node = nodeLookup.get(nodeId);
if (!node) continue;
const finding = node.itemId ? findingLookup.get(node.itemId) : undefined;
const roleCardWidth = node.kind === 'category' ? graphCategoryCardWidth : graphRoleCardWidth;
const width = (finding ? graphFindingCardWidth : roleCardWidth) * graphCardScale;
const roleCardHeight = node.kind === 'category' ? graphCategoryCardHeight : graphRoleCardHeight;
const height = (finding ? graphFindingCardHeight : roleCardHeight) * graphCardScale;
const placeOnRight = node.x <= graphCenterX;
const fixedGap = node.radius + (finding ? 12 : 9);
const x = placeOnRight ? node.x + fixedGap : node.x - fixedGap - width;
const baseY = node.y - height / 2;
const verticalStep = height + collisionGap;
const override = cardOverrides[node.id];
const maximumOffsetRing = Math.min(8, Math.max(4, Math.ceil(Math.sqrt(orderedNodeIds.length)) + 2));
const verticalOffsets = Array.from({ length: maximumOffsetRing * 2 + 1 }, (_, index) => {
if (index === 0) return 0;
const ring = Math.ceil(index / 2);
return (index % 2 ? ring : -ring) * verticalStep;
});
const laneDirection = placeOnRight ? 1 : -1;
const laneCount = extractedNodeIds ? 4 : 3;
const alternateX = placeOnRight ? node.x - fixedGap - width : node.x + fixedGap;
const candidates = verticalOffsets.flatMap(offset => [
...Array.from({ length: laneCount }, (_, lane) => ({
x: x + laneDirection * lane * (width + collisionGap),
y: baseY + offset
})),
...Array.from({ length: laneCount }, (_, lane) => ({
x: alternateX - laneDirection * lane * (width + collisionGap),
y: baseY + offset
}))
]);
const avoidsCards = (candidate: { x: number; y: number }) => occupied.every(rect => candidate.x + width + collisionGap <= rect.left
|| candidate.x >= rect.right + collisionGap
|| candidate.y + height + collisionGap <= rect.top
|| candidate.y >= rect.bottom + collisionGap);
const avoidsNodes = (candidate: { x: number; y: number }) => graphNodes.every(graphNode => {
const clearance = graphNode.radius + 8;
return candidate.x + width + clearance <= graphNode.x
|| candidate.x >= graphNode.x + clearance
|| candidate.y + height + clearance <= graphNode.y
|| candidate.y >= graphNode.y + clearance;
});
const fits = (candidate: { x: number; y: number }) => avoidsCards(candidate) && avoidsNodes(candidate);
const maximumTetherDistance = graphCardMaximumTetherDistance(width, height);
const nearbyCandidates = candidates.filter(candidate => Math.hypot(candidate.x - x, candidate.y - baseY) <= maximumTetherDistance);
const collisionFreeCandidates = nearbyCandidates.filter(fits);
const connectorPenalty = (candidate: { x: number; y: number }) => {
const connection = graphCardConnection(node, { x: candidate.x, y: candidate.y, width, height });
const connector = { x1: connection.startX, y1: connection.startY, x2: connection.endX, y2: connection.endY };
const cardsCrossedByConnector = occupied.filter(rect => graphEdgeCrossingCount(
{ x: rect.left, y: rect.top },
rect.right - rect.left,
rect.bottom - rect.top,
[connector],
2
) > 0).length;
const connectorsCrossingCard = graphEdgeCrossingCount(candidate, width, height, cardConnectorSegments, 2);
const connectorsCrossed = cardConnectorSegments.filter(existing => graphLineSegmentsIntersect(
connector.x1, connector.y1, connector.x2, connector.y2,
existing.x1, existing.y1, existing.x2, existing.y2
)).length;
const nodesCrossedByConnector = graphNodes.filter(graphNode => graphNode.id !== node.id
&& graphLineDistanceToPoint(connector, graphNode.x, graphNode.y) <= graphNode.radius + 5).length;
return (cardsCrossedByConnector + connectorsCrossingCard) * 5000
+ nodesCrossedByConnector * 2400
+ connectorsCrossed * 900;
};
const automatic = bestNearbyCardCandidate(
collisionFreeCandidates.length ? collisionFreeCandidates : nearbyCandidates,
{ x, y: baseY },
width,
height,
activeEdgeSegments,
connectorPenalty
) ?? { x, y: baseY };
const placedX = override?.x ?? automatic.x;
const placedY = override?.y ?? automatic.y;
occupied.push({ left: placedX, right: placedX + width, top: placedY, bottom: placedY + height });
const placedConnection = graphCardConnection(node, { x: placedX, y: placedY, width, height });
cardConnectorSegments.push({ x1: placedConnection.startX, y1: placedConnection.startY, x2: placedConnection.endX, y2: placedConnection.endY });
placements.push({ node, finding, x: placedX, y: placedY, width, height });
}
return placements;
}, [cardOverrides, displayedNodes, extractedNodeIds, findingLookup, graph.edges, graphCardScale, hoveredNodeId, nodeLookup, pinnedNodeIds, renderGraphCards]);
const selectedNode = nodeLookup.get(selectedNodeId); const selectedNode = nodeLookup.get(selectedNodeId);
const filteredRelationNodeId = useMemo( const filteredRelationNodeId = useMemo(
() => relationNodeIdForFacetFilters(nodes, graphFacets, facetFilters), () => relationNodeIdForFacetFilters(nodes, graphFacets, facetFilters),
@ -319,6 +181,10 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
const selectedFinding = selectedNode?.itemId ? findingLookup.get(selectedNode.itemId) : undefined; const selectedFinding = selectedNode?.itemId ? findingLookup.get(selectedNode.itemId) : undefined;
const detailNode = selectedNode ?? hoveredNode; const detailNode = selectedNode ?? hoveredNode;
const detailFinding = detailNode?.itemId ? findingLookup.get(detailNode.itemId) : undefined; const detailFinding = detailNode?.itemId ? findingLookup.get(detailNode.itemId) : undefined;
const detailToolId = detailFinding ? toolIdForRecord(detailFinding.record, activeSheet?.toolId ?? dataset?.profileId) : undefined;
const detailToolName = toolProfileDefinitions.find(profile => profile.id === detailToolId)?.name;
const detailStatus = detailFinding ? resultStatusFieldFor(detailFinding.record, detailToolId) : undefined;
const detailRuleId = detailFinding?.record.fields.find(field => field.label.trim().toLowerCase() === 'source rule id' && field.value.trim());
const activePathNodeIds = hoveredNodeId ? [hoveredNodeId] : pinnedNodeIds; const activePathNodeIds = hoveredNodeId ? [hoveredNodeId] : pinnedNodeIds;
const pathSnapshot = useMemo<PathSnapshotState>(() => { const pathSnapshot = useMemo<PathSnapshotState>(() => {
const nodeIds = new Set(pinnedNodeIds); const nodeIds = new Set(pinnedNodeIds);
@ -353,16 +219,10 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
const graphFacetSelection = node.kind !== 'finding' && !extractedSelectionIds.length const graphFacetSelection = node.kind !== 'finding' && !extractedSelectionIds.length
? graphFacetSelectionForNode(node, graphFacets) ? graphFacetSelectionForNode(node, graphFacets)
: undefined; : undefined;
if (graphFacetSelection && (!isCurrentlyPinned || displayMode === 'labels')) { if (graphFacetSelection) {
updateGraphFacetSelection(graphFacetSelection, !isCurrentlyPinned); updateGraphFacetSelection(graphFacetSelection, !isCurrentlyPinned);
} }
setPinnedNodeIds(current => { setPinnedNodeIds(current => {
if (displayMode !== 'labels') {
if (node.kind !== 'finding' && !extractedSelectionIds.length) setMarkedSelectionIds([]);
setDisplayMode('labels');
setSelectedNodeId(nodeId);
return [nodeId];
}
const isPinned = current.includes(nodeId); const isPinned = current.includes(nodeId);
const next = isPinned const next = isPinned
? current.filter(id => id !== nodeId) ? current.filter(id => id !== nodeId)
@ -370,7 +230,6 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
? [...current.filter(id => nodeLookup.get(id)?.kind === 'finding'), nodeId] ? [...current.filter(id => nodeLookup.get(id)?.kind === 'finding'), nodeId]
: [...current, nodeId]; : [...current, nodeId];
if (!isPinned && node.kind !== 'finding' && !extractedSelectionIds.length) setMarkedSelectionIds([]); if (!isPinned && node.kind !== 'finding' && !extractedSelectionIds.length) setMarkedSelectionIds([]);
if (isPinned && !next.length) setDisplayMode('off');
setSelectedNodeId(active => isPinned setSelectedNodeId(active => isPinned
? (active === nodeId ? (next.at(-1) ?? '') : active) ? (active === nodeId ? (next.at(-1) ?? '') : active)
: nodeId); : nodeId);
@ -433,11 +292,10 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
useEffect(() => { useEffect(() => {
setNodeOverrides({}); setNodeOverrides({});
setCardOverrides({});
setPinnedNodeIds(current => current.filter(id => graph.nodes.some(node => node.id === id))); setPinnedNodeIds(current => current.filter(id => graph.nodes.some(node => node.id === id)));
setSelectedNodeId(current => graph.nodes.some(node => node.id === current) ? current : ''); setSelectedNodeId(current => graph.nodes.some(node => node.id === current) ? current : '');
setMarkedSelectionIds(current => current.filter(id => id === FILTERED_GRAPH_SELECTION_ID || graph.nodes.some(node => node.id === id))); setMarkedSelectionIds(current => current.filter(id => id === FILTERED_GRAPH_SELECTION_ID || graph.nodes.some(node => node.id === id)));
setExtractedSelectionIds(current => current.filter(id => id === FILTERED_GRAPH_SELECTION_ID || graph.nodes.some(node => node.id === id))); setExtractedSelectionIds(current => current.filter(id => graph.nodes.some(node => node.id === id)));
}, [graphSignature]); }, [graphSignature]);
useEffect(() => { useEffect(() => {
if (active && pathSnapshotRequest) setQueuedPathSnapshot(pathSnapshotRequest); if (active && pathSnapshotRequest) setQueuedPathSnapshot(pathSnapshotRequest);
@ -514,10 +372,6 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
const current = cameraRef.current; const current = cameraRef.current;
animateCameraTo({ ...current, scale: clamp(current.scale * factor, 0.08, 4) }, 280); animateCameraTo({ ...current, scale: clamp(current.scale * factor, 0.08, 4) }, 280);
}; };
const zoomToReadableCards = () => {
const current = cameraRef.current;
animateCameraTo({ ...current, scale: Math.max(current.scale, minimumCardZoom + 0.04) }, 360);
};
const handleWheel = (event: ReactWheelEvent<SVGSVGElement>) => { const handleWheel = (event: ReactWheelEvent<SVGSVGElement>) => {
event.preventDefault(); event.preventDefault();
stopCameraAnimation(); stopCameraAnimation();
@ -540,21 +394,7 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
stopCameraAnimation(); stopCameraAnimation();
event.stopPropagation(); event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId);
const card = cardPlacements.find(placement => placement.node.id === node.id); pointerOperation.current = { kind: 'node', pointerId: event.pointerId, moved: false, nodeId: node.id, startX: event.clientX, startY: event.clientY, camera, nodeStart: { x: node.x, y: node.y } };
pointerOperation.current = { kind: 'node', pointerId: event.pointerId, moved: false, nodeId: node.id, startX: event.clientX, startY: event.clientY, camera, nodeStart: { x: node.x, y: node.y }, cardStart: card ? { x: card.x, y: card.y } : undefined };
};
const startCardDrag = (event: ReactPointerEvent<SVGForeignObjectElement>, placement: GraphCardPlacement) => {
event.stopPropagation();
if (event.button !== 0 || (event.target as Element).closest('button, a, input, select, textarea')) return;
stopCameraAnimation();
event.currentTarget.setPointerCapture(event.pointerId);
setSelectedNodeId(placement.node.id);
pointerOperation.current = {
kind: 'card', pointerId: event.pointerId, moved: false, nodeId: placement.node.id,
startX: event.clientX, startY: event.clientY, camera,
nodeStart: { x: placement.node.x, y: placement.node.y },
cardStart: { x: placement.x, y: placement.y }
};
}; };
const movePointer = (event: ReactPointerEvent<SVGSVGElement>) => { const movePointer = (event: ReactPointerEvent<SVGSVGElement>) => {
const operation = pointerOperation.current; const operation = pointerOperation.current;
@ -570,10 +410,6 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
x: operation.nodeStart!.x + graphDx, x: operation.nodeStart!.x + graphDx,
y: operation.nodeStart!.y + graphDy y: operation.nodeStart!.y + graphDy
} })); } }));
if (operation.cardStart) setCardOverrides(current => ({ ...current, [operation.nodeId!]: {
x: operation.cardStart!.x + graphDx,
y: operation.cardStart!.y + graphDy
} }));
} }
}; };
const endPointer = (event: ReactPointerEvent<SVGSVGElement>) => { const endPointer = (event: ReactPointerEvent<SVGSVGElement>) => {
@ -582,12 +418,6 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
if (operation.kind === 'node' && operation.nodeId && !operation.moved) { if (operation.kind === 'node' && operation.nodeId && !operation.moved) {
togglePinnedNode(operation.nodeId); togglePinnedNode(operation.nodeId);
} }
if (operation.kind === 'card' && operation.nodeId && operation.moved) {
consumedCardClick.current = operation.nodeId;
globalThis.setTimeout(() => {
if (consumedCardClick.current === operation.nodeId) consumedCardClick.current = '';
}, 0);
}
pointerOperation.current = null; pointerOperation.current = null;
}; };
const toggleMarkedSelection = () => { const toggleMarkedSelection = () => {
@ -595,14 +425,13 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
setMarkedSelectionIds(actionSelectionIsMarked ? [] : [...actionSelectionNodeIds]); setMarkedSelectionIds(actionSelectionIsMarked ? [] : [...actionSelectionNodeIds]);
}; };
const extractSelection = () => { const extractSelection = () => {
if (!actionSelectionNodeIds.length) return; if (!selectedNodeId) return;
const expandedNodeIds = graphSelectionUnionNodeIds(graph, actionSelectionNodeIds); const pathNodeIds = selectedPathNodeIds(graph, selectedNodeId);
if (!expandedNodeIds.length) return; if (!pathNodeIds.length) return;
extractViewBefore.current = { camera: cameraRef.current, pinnedNodeIds, selectedNodeId, displayMode, markedSelectionIds }; extractViewBefore.current = { camera: cameraRef.current, pinnedNodeIds, selectedNodeId, markedSelectionIds };
setPinnedNodeIds(expandedNodeIds); setPinnedNodeIds([selectedNodeId]);
setDisplayMode('labels'); setMarkedSelectionIds([]);
setMarkedSelectionIds([...actionSelectionNodeIds]); setExtractedSelectionIds([selectedNodeId]);
setExtractedSelectionIds([...actionSelectionNodeIds]);
}; };
const exitSelectionExtract = () => { const exitSelectionExtract = () => {
setExtractedSelectionIds([]); setExtractedSelectionIds([]);
@ -612,7 +441,6 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
animateCameraTo(savedView.camera, 520); animateCameraTo(savedView.camera, 520);
setPinnedNodeIds(savedView.pinnedNodeIds); setPinnedNodeIds(savedView.pinnedNodeIds);
setSelectedNodeId(savedView.selectedNodeId); setSelectedNodeId(savedView.selectedNodeId);
setDisplayMode(savedView.displayMode);
setMarkedSelectionIds(savedView.markedSelectionIds); setMarkedSelectionIds(savedView.markedSelectionIds);
} }
}; };
@ -623,8 +451,7 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
setMarkedSelectionIds([]); setMarkedSelectionIds([]);
setExtractedSelectionIds([]); setExtractedSelectionIds([]);
setNodeOverrides({}); setNodeOverrides({});
setCardOverrides({}); setShowText(true);
setDisplayMode('off');
extractViewBefore.current = undefined; extractViewBefore.current = undefined;
fittedExtractSignature.current = ''; fittedExtractSignature.current = '';
setResetViewRevision(current => current + 1); setResetViewRevision(current => current + 1);
@ -652,11 +479,9 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
</aside> </aside>
<section className="obsidian-graph-stage" aria-label="Graph workspace"> <section className="obsidian-graph-stage" aria-label="Graph workspace">
<div className="obsidian-graph-toolbar" aria-label="Graph controls"> <div className="obsidian-graph-toolbar" aria-label="Graph controls">
<button className={`btn${displayMode === 'off' ? ' active' : ''}`} type="button" aria-pressed={displayMode === 'off'} title="Hide labels and node text" onClick={() => setDisplayMode('off')}>Off</button> <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${showCards ? ' active' : ''}`} type="button" aria-pressed={showCards} title="Show or hide the currently selected node cards" onClick={() => setDisplayMode(showCards ? 'off' : 'labels')}>Labels</button>
<button className={`btn${showText ? ' active' : ''}`} type="button" aria-pressed={showText} title="Show node text" onClick={() => setDisplayMode('text')}>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> <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>
<span className="obsidian-graph-count">{displayedNodes.length.toLocaleString('en-US')} nodes · {graph.edges.filter(edge => !extractedNodeIds || extractedNodeIds.has(edge.source) && extractedNodeIds.has(edge.target)).length.toLocaleString('en-US')} links</span> <span className="obsidian-graph-count">{displayedNodes.length.toLocaleString('en-US')} nodes · {displayedEdges.length.toLocaleString('en-US')} links</span>
<div className="obsidian-graph-branch-actions" role="group" aria-label="Selected graph branch actions"> <div className="obsidian-graph-branch-actions" role="group" aria-label="Selected graph branch actions">
<span>{actionBranchNode ? graphNodeKindLabels[actionBranchNode.kind] : actionBranchNodeId === FILTERED_GRAPH_SELECTION_ID ? 'Filters' : 'Selection'}</span> <span>{actionBranchNode ? graphNodeKindLabels[actionBranchNode.kind] : actionBranchNodeId === FILTERED_GRAPH_SELECTION_ID ? 'Filters' : 'Selection'}</span>
<button <button
@ -669,10 +494,10 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
<button <button
className={`btn${extractedSelectionIds.length ? ' active' : ''}`} className={`btn${extractedSelectionIds.length ? ' active' : ''}`}
type="button" type="button"
disabled={!actionSelectionNodeIds.length} disabled={!extractedSelectionIds.length && !selectedNodeId}
aria-pressed={Boolean(extractedSelectionIds.length)} aria-pressed={Boolean(extractedSelectionIds.length)}
onClick={extractedSelectionIds.length ? exitSelectionExtract : extractSelection} onClick={extractedSelectionIds.length ? exitSelectionExtract : extractSelection}
>{extractedSelectionIds.length ? 'Exit extract' : 'Extract'}</button> >{extractedSelectionIds.length ? 'Exit Extract Path' : 'Extract Path'}</button>
</div> </div>
</div> </div>
<div className="obsidian-graph-zoom" aria-label="Zoom controls"> <div className="obsidian-graph-zoom" aria-label="Zoom controls">
@ -682,10 +507,9 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
</div> </div>
<svg ref={surfaceRef} className="obsidian-graph-surface" role="img" aria-label="Interactive network of deviation findings, categories, targets, MITRE techniques, tactics and D3FEND mappings" onWheel={handleWheel} onPointerDown={startPan} onPointerMove={movePointer} onPointerUp={endPointer} onPointerCancel={endPointer}> <svg ref={surfaceRef} className="obsidian-graph-surface" role="img" aria-label="Interactive network of deviation findings, categories, targets, MITRE techniques, tactics and D3FEND mappings" onWheel={handleWheel} onPointerDown={startPan} onPointerMove={movePointer} onPointerUp={endPointer} onPointerCancel={endPointer}>
<g transform={`translate(${dimensions.width / 2 + camera.x} ${dimensions.height / 2 + camera.y}) scale(${camera.scale})`}> <g transform={`translate(${dimensions.width / 2 + camera.x} ${dimensions.height / 2 + camera.y}) scale(${camera.scale})`}>
<g className="obsidian-graph-edges" aria-hidden="true">{graph.edges.map(edge => { <g className="obsidian-graph-edges" aria-hidden="true">{displayedEdges.map(edge => {
const source = nodeLookup.get(edge.source); const source = nodeLookup.get(edge.source);
const target = nodeLookup.get(edge.target); const target = nodeLookup.get(edge.target);
if (extractedNodeIds && (!extractedNodeIds.has(edge.source) || !extractedNodeIds.has(edge.target))) return null;
const isDirectPath = activePathNodeIds.includes(edge.source) || activePathNodeIds.includes(edge.target); const isDirectPath = activePathNodeIds.includes(edge.source) || activePathNodeIds.includes(edge.target);
const isMarkedBranch = markedBranchNodeIds.includes(edge.source) && markedBranchNodeIds.includes(edge.target); const isMarkedBranch = markedBranchNodeIds.includes(edge.source) && markedBranchNodeIds.includes(edge.target);
const isMuted = markedBranchNodeIds.length > 0 && !isMarkedBranch && !isDirectPath; const isMuted = markedBranchNodeIds.length > 0 && !isMarkedBranch && !isDirectPath;
@ -693,14 +517,6 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
&& !pinnedNodeIds.includes(edge.source) && !pinnedNodeIds.includes(edge.target); && !pinnedNodeIds.includes(edge.source) && !pinnedNodeIds.includes(edge.target);
return source && target ? <line className={`obsidian-edge-${edge.kind}${isDirectPath ? ' is-direct-path' : ''}${isMarkedBranch ? ' is-branch-path' : ''}${isMuted ? ' is-branch-muted' : ''}${isSelectionMuted ? ' is-selection-muted' : ''}`} key={edge.id} x1={source.x} y1={source.y} x2={target.x} y2={target.y} /> : null; return source && target ? <line className={`obsidian-edge-${edge.kind}${isDirectPath ? ' is-direct-path' : ''}${isMarkedBranch ? ' is-branch-path' : ''}${isMuted ? ' is-branch-muted' : ''}${isSelectionMuted ? ' is-selection-muted' : ''}`} key={edge.id} x1={source.x} y1={source.y} x2={target.x} y2={target.y} /> : null;
})}</g> })}</g>
<g className="obsidian-graph-card-connectors" aria-hidden="true">{cardPlacements.map(placement => {
const connection = graphCardConnection(placement.node, placement);
return <g key={`connector:${placement.node.id}`}>
<line className="obsidian-card-connector-backdrop" x1={connection.startX} y1={connection.startY} x2={connection.endX} y2={connection.endY} />
<line className="obsidian-card-connector" x1={connection.startX} y1={connection.startY} x2={connection.endX} y2={connection.endY} />
<circle className="obsidian-card-connector-anchor" cx={connection.endX} cy={connection.endY} r={2.5} />
</g>;
})}</g>
<g className="obsidian-graph-nodes">{displayedNodes.map(node => { <g className="obsidian-graph-nodes">{displayedNodes.map(node => {
const isSelected = pinnedNodeIds.includes(node.id); const isSelected = pinnedNodeIds.includes(node.id);
const isMarkedBranch = markedBranchNodeIds.includes(node.id); const isMarkedBranch = markedBranchNodeIds.includes(node.id);
@ -732,61 +548,29 @@ function ObsidianGraphContent({ active, onOpenFinding, pathSnapshotRequest, onPa
{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>} {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>}
</g>; </g>;
})}</g> })}</g>
<g className="obsidian-graph-cards">{cardPlacements.map(placement => {
const { node, finding, x, y } = placement;
const cardWidth = finding ? graphFindingCardWidth : node.kind === 'category' ? graphCategoryCardWidth : graphRoleCardWidth;
const cardHeight = finding ? graphFindingCardHeight : node.kind === 'category' ? graphCategoryCardHeight : graphRoleCardHeight;
return <g key={`card:${node.id}`} className="obsidian-graph-card is-selected" transform={`translate(${x} ${y}) scale(${graphCardScale})`}>
<foreignObject
className={`obsidian-node-card-wrap${finding ? ' obsidian-node-matrix-card-wrap' : ''}`}
x={0}
y={0}
width={cardWidth}
height={cardHeight}
onPointerDown={event => startCardDrag(event, placement)}
onClickCapture={event => {
if (consumedCardClick.current !== node.id) return;
consumedCardClick.current = '';
event.preventDefault();
event.stopPropagation();
}}
>
{finding ? <PathfinderMicroCard variant="finding">
<TileCard
record={finding.record}
title={finding.title}
bookmarked={Boolean(reviews[finding.id]?.include)}
resultColor={resultWindowFor(finding.id)?.color}
toolId={activeSheet?.toolId ?? dataset?.profileId}
onOpen={() => togglePinnedNode(node.id)}
onBookmark={() => updateReview(finding.id, { include: !reviews[finding.id]?.include })}
onResults={linksFor(finding).length || sectionsFor(finding).length ? () => openResults(finding) : undefined}
/>
</PathfinderMicroCard> : <PathfinderMicroCard variant="relation" relationKind={node.kind}>
<span className="data-tile-hex" aria-hidden="true" />
<div className="data-tile-content">
<h3>{graphLabel(node.label)}</h3>
<div className="data-tile-badges"><span className="reader-severity">{graphNodeKindLabels[node.kind]}</span></div>
</div>
</PathfinderMicroCard>}
</foreignObject>
</g>;
})}</g>
</g> </g>
</svg> </svg>
{graphCardsSuppressed && <button className="obsidian-graph-card-zoom-hint" type="button" onClick={zoomToReadableCards}>
Zoom in to show {pinnedNodeIds.length.toLocaleString('en-US')} labels
</button>}
{!nodes.length && <div className="obsidian-graph-empty"><strong>No matching graph nodes.</strong><span>Change the shared filters or open a scan with MITRE mappings.</span></div>} {!nodes.length && <div className="obsidian-graph-empty"><strong>No matching graph nodes.</strong><span>Change the shared filters or open a scan with MITRE mappings.</span></div>}
</section> </section>
<aside className={`obsidian-graph-inspector${selectedNode ? ' is-pinned' : ''}`} aria-live="polite"> <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> <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">
<small>{graphNodeKindLabels[detailNode.kind]}</small> <div className={`obsidian-graph-inspector-title${detailFinding ? ` severity-${detailFinding.severity}` : ''}`}>
<strong>{detailNode.label}</strong> {detailFinding && <span className="data-tile-hex" aria-hidden="true" />}
<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> <span>{selectedNode ? `${connectionCount} direct connections` : 'Click the node to keep these details open'}</span>
{detailFinding && <> {detailFinding && <>
<dl><div><dt>Severity</dt><dd>{severityLabels[detailFinding.severity]}</dd></div><div><dt>Mapping</dt><dd>{detailFinding.mappingOrigin}</dd></div><div><dt>Risk</dt><dd>{Math.round(detailFinding.risk)}</dd></div></dl> <dl>
{detailStatus && <div><dt>Source status</dt><dd><span className="outcome-mark" data-outcome={resultOutcomeFor(detailFinding.record, detailToolId)}>{detailStatus.value}</span></dd></div>}
{detailToolName && <div><dt>Tool</dt><dd>{detailToolName}</dd></div>}
{detailRuleId && <div><dt>Source Rule ID</dt><dd>{detailRuleId.value}</dd></div>}
<div><dt>Severity</dt><dd>{severityLabels[detailFinding.severity]}</dd></div>
{detailFinding.categories.length > 0 && <div><dt>Category</dt><dd>{detailFinding.categories.join(', ')}</dd></div>}
{detailFinding.techniqueIds.length > 0 && <div><dt>MITRE ATT&CK</dt><dd>{detailFinding.techniqueIds.join(', ')}</dd></div>}
<div><dt>Mapping</dt><dd>{detailFinding.mappingOrigin}</dd></div>
<div><dt>Graph risk</dt><dd>{Math.round(detailFinding.risk)}</dd></div>
</dl>
</>} </>}
{selectedFinding && <> {selectedFinding && <>
<div className="obsidian-graph-inspector-actions"> <div className="obsidian-graph-inspector-actions">

View File

@ -8,6 +8,17 @@ function oppositeNode(edge: { source: string; target: string }, nodeId: string):
return undefined; return undefined;
} }
/** The visible path of one selected node ends at its direct connections. */
export function selectedPathNodeIds(graph: ObsidianGraphModel, nodeId: string): string[] {
if (!graph.nodes.some(node => node.id === nodeId)) return [];
const selected = new Set<string>([nodeId]);
graph.edges.forEach(edge => {
const neighbor = oppositeNode(edge, nodeId);
if (neighbor) selected.add(neighbor);
});
return graph.nodes.map(node => node.id).filter(id => selected.has(id));
}
export function relationBranchNodeIds(graph: ObsidianGraphModel, rootId: string): string[] { export function relationBranchNodeIds(graph: ObsidianGraphModel, rootId: string): string[] {
const kindById = new Map(graph.nodes.map(node => [node.id, node.kind])); const kindById = new Map(graph.nodes.map(node => [node.id, node.kind]));
const rootKind = kindById.get(rootId); const rootKind = kindById.get(rootId);

View File

@ -1,7 +1,10 @@
import { findingReferences } from '../../references/finding-reference-projection';
import type { FindingRecord, Severity } from '../../domain/finding'; import type { FindingRecord, Severity } from '../../domain/finding';
import { mitreBadgeLabel, normalizeSeverity } from '../../profiles/purple-knight/presentation'; import { normalizeSeverity } from '../../profiles/purple-knight/presentation';
import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy'; import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
import { recordTitleFor } from '../dataset-viewer/record-title'; import { recordTitleFor } from '../dataset-viewer/record-title';
import mitreData from '../../assets/data/mitre-enterprise-v19.2.json';
import { convertEnterpriseStix } from './mitre-stix';
export interface MitreTechnique { export interface MitreTechnique {
id: string; id: string;
@ -52,14 +55,31 @@ export interface FindingPathItem {
} }
const STORAGE_KEY = 'assessor_custom_mitre_matrix'; const STORAGE_KEY = 'assessor_custom_mitre_matrix';
export const defaultMitreGraphData = mitreData as MitreGraphData;
export const MITRE_MATRIX_CHANGED_EVENT = 'assessor:mitre-matrix-changed';
let serverMitreGraphData: MitreGraphData | null = null;
export function setServerMitreMatrix(data: MitreGraphData): void {
const validated = validateMitreMatrixData(data);
const [major, minor] = validated.version.split('.').map(Number);
const [currentMajor, currentMinor] = (serverMitreGraphData?.version ?? defaultMitreGraphData.version).split('.').map(Number);
if (major < currentMajor || (major === currentMajor && minor < currentMinor)) return;
serverMitreGraphData = validated;
globalThis.dispatchEvent?.(new Event(MITRE_MATRIX_CHANGED_EVENT));
}
export function validateMitreMatrixData(raw: unknown): MitreGraphData { export function validateMitreMatrixData(raw: unknown): MitreGraphData {
if (!raw || typeof raw !== 'object') throw new Error('Invalid MITRE matrix format: expected a JSON object.'); if (!raw || typeof raw !== 'object') throw new Error('Invalid MITRE matrix format: expected a JSON object.');
if ('type' in raw && raw.type === 'bundle') return convertEnterpriseStix(raw);
const data = raw as Partial<MitreGraphData>; const data = raw as Partial<MitreGraphData>;
if (typeof data.version !== 'string' || !/^\d+\.\d+$/.test(data.version)) throw new Error('Invalid MITRE matrix: a version is required.');
if (!Array.isArray(data.tacticOrder) || !data.tacticOrder.length) throw new Error('Invalid MITRE matrix: "tacticOrder" array is missing or empty.'); if (!Array.isArray(data.tacticOrder) || !data.tacticOrder.length) throw new Error('Invalid MITRE matrix: "tacticOrder" array is missing or empty.');
if (!Array.isArray(data.techniques)) throw new Error('Invalid MITRE matrix: "techniques" array is missing.'); if (!Array.isArray(data.techniques) || !data.techniques.length) throw new Error('Invalid MITRE matrix: "techniques" array is missing or empty.');
if (data.techniques.some(t => !t || typeof t.id !== 'string' || !/^T\d{4}(?:\.\d{3})?$/.test(t.id) || typeof t.name !== 'string' || !t.name.trim())) {
throw new Error('Invalid MITRE matrix: a technique ID or name is invalid.');
}
return { return {
version: String(data.version ?? 'Updated'), version: data.version,
tacticOrder: data.tacticOrder.map(String), tacticOrder: data.tacticOrder.map(String),
techniques: data.techniques.map(t => ({ techniques: data.techniques.map(t => ({
id: String(t.id), id: String(t.id),
@ -92,12 +112,9 @@ export function clearStoredMitreMatrix(): void {
globalThis.localStorage?.removeItem(STORAGE_KEY); globalThis.localStorage?.removeItem(STORAGE_KEY);
} }
const simplify = (value: string) => value export function activeMitreMatrix(): MitreGraphData {
.trim() return loadStoredMitreMatrix() ?? serverMitreGraphData ?? defaultMitreGraphData;
.toLocaleLowerCase('en-US') }
.replace(/&/g, 'and')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
const numberField = (record: FindingRecord, pattern: RegExp) => { const numberField = (record: FindingRecord, pattern: RegExp) => {
const value = record.fields.find(field => pattern.test(field.label.trim()))?.value ?? ''; const value = record.fields.find(field => pattern.test(field.label.trim()))?.value ?? '';
@ -117,21 +134,14 @@ const severityRisk: Record<Severity, number> = {
export function extractFindingMitreFocus(records: FindingRecord[], tacticOrder: string[]): FindingMitreFocus { export function extractFindingMitreFocus(records: FindingRecord[], tacticOrder: string[]): FindingMitreFocus {
const techniqueIds = new Set<string>(); const techniqueIds = new Set<string>();
const tactics = new Set<string>(); const tactics = new Set<string>();
const tacticLookup = new Map(tacticOrder.map(tactic => [simplify(tactic), tactic])); const graph = { ...defaultMitreGraphData, tacticOrder };
let findingCount = 0; let findingCount = 0;
for (const record of records) { for (const record of records) {
const fields = record.fields.filter(field => mitreBadgeLabel(field.label) && field.value); const references = findingReferences(record, graph).filter(ref => ref.framework === 'mitre-attack' && ref.status === 'active');
if (!fields.length) continue; if (references.length) findingCount++;
findingCount += 1; for (const ref of references) {
for (const field of fields) { if (ref.kind === 'technique') techniqueIds.add(ref.id);
for (const match of field.value.toUpperCase().matchAll(/\bT\d{4}(?:\.\d{3})?\b/g)) techniqueIds.add(match[0]); if (ref.kind === 'tactic') tactics.add(ref.title!);
const fragments = field.value.split(/[,;|/]+/);
for (const fragment of fragments) {
const normalized = simplify(fragment.replace(/\bT\d{4}(?:\.\d{3})?\b/gi, ''));
const tactic = tacticLookup.get(normalized);
if (tactic) tactics.add(tactic);
}
} }
} }
@ -139,30 +149,14 @@ export function extractFindingMitreFocus(records: FindingRecord[], tacticOrder:
} }
export function buildFindingPathItems(records: FindingRecord[], data: MitreGraphData, titleColumn = '', fallbackToolId?: string): FindingPathItem[] { export function buildFindingPathItems(records: FindingRecord[], data: MitreGraphData, titleColumn = '', fallbackToolId?: string): FindingPathItem[] {
const tacticLookup = new Map(data.tacticOrder.map(tactic => [simplify(tactic), tactic]));
const techniqueLookup = new Map(data.techniques.map(technique => [technique.id, technique]));
const headerKinds = new Map<string, string>();
const headerKind = (label: string) => {
let kind = headerKinds.get(label);
if (kind === undefined) { kind = mitreBadgeLabel(label); headerKinds.set(label, kind); }
return kind;
};
return records.map(record => { return records.map(record => {
const mitreFields = record.fields.filter(field => field.value && headerKind(field.label)); const references = findingReferences(record, data).filter(ref => ref.status === 'active');
const attackFields = mitreFields.filter(field => headerKind(field.label) === 'MITRE ATT&CK'); const attackReferences = references.filter(ref => ref.framework === 'mitre-attack');
const defenseFields = mitreFields.filter(field => headerKind(field.label) !== 'MITRE ATT&CK'); const hasNativeMitreField = attackReferences.some(ref => ref.evidence === 'declared');
const hasNativeMitreField = attackFields.length > 0; const techniqueIds = [...new Set(attackReferences.filter(ref => ref.kind === 'technique').map(ref => ref.id))];
const techniqueIds = [...new Set(attackFields.flatMap(field => [...field.value.toUpperCase().matchAll(/\bT\d{4}(?:\.\d{3})?\b/g)].map(match => match[0])))]; const tactics = new Set(attackReferences.flatMap(ref => ref.tactics));
const tactics = new Set<string>(); const orderedTactics = [...data.tacticOrder.filter(tactic => tactics.has(tactic)),
for (const id of techniqueIds) for (const tactic of techniqueLookup.get(id)?.tactics ?? []) tactics.add(tactic); ...[...tactics].filter(tactic => !data.tacticOrder.includes(tactic))];
for (const field of attackFields) {
for (const fragment of field.value.split(/[,;|/]+/)) {
const tactic = tacticLookup.get(simplify(fragment.replace(/\bT\d{4}(?:\.\d{3})?\b/gi, '')));
if (tactic) tactics.add(tactic);
}
}
const orderedTactics = data.tacticOrder.filter(tactic => tactics.has(tactic));
const severityValue = record.fields.find(field => /^(severity|criticality|priority)$/i.test(field.label.trim()))?.value; const severityValue = record.fields.find(field => /^(severity|criticality|priority)$/i.test(field.label.trim()))?.value;
const severity = normalizeSeverity(severityValue); const severity = normalizeSeverity(severityValue);
const weight = numberField(record, /^weight$/i); const weight = numberField(record, /^weight$/i);
@ -177,10 +171,7 @@ export function buildFindingPathItems(records: FindingRecord[], data: MitreGraph
...cisBenchmarkCategories(record, fallbackToolId) ...cisBenchmarkCategories(record, fallbackToolId)
])]; ])];
const category = categories[0] ?? ''; const category = categories[0] ?? '';
const defenses = [...new Set(defenseFields const defenses = [...new Set(references.filter(ref => ref.framework === 'mitre-d3fend').map(ref => `${ref.id} · ${ref.title}`))];
.flatMap(field => field.value.split(/[;|]+/))
.map(value => value.trim())
.filter(Boolean))];
const targets = [...new Set(record.fields const targets = [...new Set(record.fields
.filter(field => /^(?:target|ziel|betroffenes objekt|asset|host|hostname|domain|resource|system)$/i.test(field.label.trim()) && field.value) .filter(field => /^(?:target|ziel|betroffenes objekt|asset|host|hostname|domain|resource|system)$/i.test(field.label.trim()) && field.value)
.flatMap(field => field.value.split(/[;|]+/)) .flatMap(field => field.value.split(/[;|]+/))

View File

@ -0,0 +1,87 @@
import type { MitreGraphData, MitreEntity, MitreTechnique } from './mitre-graph';
interface StixObject {
id?: string;
type?: string;
name?: string;
revoked?: boolean;
x_mitre_deprecated?: boolean;
x_mitre_version?: string;
x_mitre_shortname?: string;
x_mitre_platforms?: string[];
tactic_refs?: string[];
kill_chain_phases?: { kill_chain_name?: string; phase_name?: string }[];
external_references?: { source_name?: string; external_id?: string; url?: string }[];
relationship_type?: string;
source_ref?: string;
target_ref?: string;
}
const active = (item: StixObject) => !item.revoked && !item.x_mitre_deprecated;
const attackReference = (item: StixObject) => item.external_references?.find(reference => reference.source_name === 'mitre-attack');
const byId = (left: { id: string }, right: { id: string }) => left.id.localeCompare(right.id);
/** Convert MITRE's Enterprise ATT&CK STIX 2.1 bundle to the compact Pathfinder catalog. */
export function convertEnterpriseStix(raw: unknown): MitreGraphData {
if (!raw || typeof raw !== 'object' || !('type' in raw) || raw.type !== 'bundle'
|| !('objects' in raw) || !Array.isArray(raw.objects)) {
throw new Error('Expected an Enterprise ATT&CK STIX bundle.');
}
const objects = raw.objects as StixObject[];
const collection = objects.find(item => item.type === 'x-mitre-collection' && item.name === 'Enterprise ATT&CK');
const matrix = objects.find(item => item.type === 'x-mitre-matrix' && item.name === 'Enterprise ATT&CK' && active(item));
if (!collection?.x_mitre_version || !matrix?.tactic_refs?.length) {
throw new Error('Enterprise ATT&CK collection version or tactic matrix is missing.');
}
const tactics = new Map(objects.filter(item => item.type === 'x-mitre-tactic' && active(item)).map(item => [item.id, item]));
const ordered = matrix.tactic_refs.map(id => tactics.get(id));
if (ordered.some(item => !item?.name || !item.x_mitre_shortname)) {
throw new Error('Enterprise ATT&CK matrix references an unavailable tactic.');
}
const tacticOrder = ordered.map(item => item!.name!);
const tacticNames = new Map(ordered.map(item => [item!.x_mitre_shortname!, item!.name!]));
const techniqueStixIds = new Map<string, string>();
const techniques: MitreTechnique[] = objects.filter(item => item.type === 'attack-pattern' && active(item)).flatMap(item => {
const reference = attackReference(item);
if (!item.id || !item.name || !reference?.external_id || !/^T\d{4}(?:\.\d{3})?$/.test(reference.external_id)) return [];
const mappedTactics = [...new Set((item.kill_chain_phases ?? [])
.filter(phase => phase.kill_chain_name === 'mitre-attack')
.map(phase => tacticNames.get(phase.phase_name ?? ''))
.filter((name): name is string => Boolean(name)))];
if (!mappedTactics.length) return [];
techniqueStixIds.set(item.id, reference.external_id);
return [{
id: reference.external_id,
name: item.name,
tactics: tacticOrder.filter(name => mappedTactics.includes(name)),
platforms: Array.isArray(item.x_mitre_platforms) ? item.x_mitre_platforms : [],
parent: reference.external_id.includes('.') ? reference.external_id.split('.')[0] : null,
url: reference.url ?? `https://attack.mitre.org/techniques/${reference.external_id.replace('.', '/')}`,
sourceCount: item.external_references?.length ?? 0
}];
}).sort(byId);
if (!techniques.length) throw new Error('Enterprise ATT&CK bundle contains no active techniques.');
const entityTypes: Record<string, MitreEntity['type']> = {
'intrusion-set': 'group', campaign: 'campaign', malware: 'software', tool: 'software'
};
const entitiesByStixId = new Map<string, MitreEntity>();
for (const item of objects) {
const type = entityTypes[item.type ?? ''];
const reference = attackReference(item);
if (type && active(item) && item.id && item.name && reference?.external_id) {
entitiesByStixId.set(item.id, { id: reference.external_id, name: item.name, type, techniques: [] });
}
}
for (const item of objects) {
if (item.type !== 'relationship' || item.relationship_type !== 'uses' || !active(item)) continue;
const entity = entitiesByStixId.get(item.source_ref ?? '');
const techniqueId = techniqueStixIds.get(item.target_ref ?? '');
if (entity && techniqueId && !entity.techniques.includes(techniqueId)) entity.techniques.push(techniqueId);
}
const entities = [...entitiesByStixId.values()].sort(byId).map(entity => ({
...entity, techniques: entity.techniques.sort()
}));
return { version: collection.x_mitre_version, tacticOrder, techniques, entities };
}

View File

@ -16,20 +16,23 @@ import { BackendConnectionDialog } from '../../components/BackendConnectionDialo
import { MicrosoftBaselineLibrary } from './MicrosoftBaselineLibrary'; import { MicrosoftBaselineLibrary } from './MicrosoftBaselineLibrary';
import { IsoCorpusLibrary } from './IsoCorpusLibrary'; import { IsoCorpusLibrary } from './IsoCorpusLibrary';
import { MitreMatrixLibrary } from './MitreMatrixLibrary'; import { MitreMatrixLibrary } from './MitreMatrixLibrary';
import { clearStoredMitreMatrix, loadStoredMitreMatrix, saveStoredMitreMatrix, validateMitreMatrixData } from '../mitre-graph/mitre-graph'; import { ReferenceRecognitionPanel } from './ReferenceRecognitionPanel';
import { activeMitreMatrix, clearStoredMitreMatrix, loadStoredMitreMatrix, MITRE_MATRIX_CHANGED_EVENT, saveStoredMitreMatrix, setServerMitreMatrix, validateMitreMatrixData } from '../mitre-graph/mitre-graph';
import { downloadBrowserBlob } from '../../platform/browser/browser-download-adapter';
import { TriangleIcon } from '../../components/TriangleIcon'; import { TriangleIcon } from '../../components/TriangleIcon';
import { DatahubHistoryOverview, type DatahubTimeslotSelection } from './DatahubHistoryOverview'; import { DatahubHistoryOverview, type DatahubTimeslotSelection } from './DatahubHistoryOverview';
import { comparableTimeslotSources, summarizeWorkspaceResults, type DatahubTimeslotResultMetrics } from './datahub-scan-analysis'; import { comparableTimeslotSources, type DatahubTimeslotResultMetrics } from './datahub-scan-analysis';
import { datahubScanReadiness, isDatahubScanViewable, type DatahubScanReadiness } from './datahub-scan-readiness'; import { datahubScanReadiness, isDatahubScanViewable, type DatahubScanReadiness } from './datahub-scan-readiness';
import { summarizeScanProcessingReadiness } from './datahub-processing-readiness'; import { summarizeScanProcessingReadiness } from './datahub-processing-readiness';
import { compareReimportSourceHashes } from '../../domain/reimport-source'; import { compareReimportSourceHashes } from '../../domain/reimport-source';
import { loadTimeslotOpenReadiness, type TimeslotOpenReadiness } from './datahub-open-readiness'; import { loadTimeslotOpenReadiness, type TimeslotOpenReadiness } from './datahub-open-readiness';
import { loadCanonicalProjectWorkspace } from './load-canonical-project-workspace'; import { DatahubMetricsCache } from './datahub-metrics-cache';
import { offerWorkspaceLoad, workspaceScopeKey } from './workspace-load-handoff'; import { useReferenceRecognitionStatus } from './use-reference-recognition-status';
import { profileForSheet } from '../../app/AppProvider'; import { datahubScanLabel } from './datahub-scan-labels';
const assessorApi = new AssessorApiClient(); const assessorApi = new AssessorApiClient();
const SHOW_MITRE_MATRIX_LIBRARY = false; const metricsCache = new DatahubMetricsCache(scope => assessorApi.getTimeslotResultMetrics(
scope.projectId, scope.scanRunIds, scope.assessmentIds));
type BackendStatus = 'connected' | 'checking' | 'unavailable'; type BackendStatus = 'connected' | 'checking' | 'unavailable';
interface DataHubProps { interface DataHubProps {
@ -67,7 +70,7 @@ function formatHistoryDate(value: string): string {
function gpoStatusRevision(records: GpoBackupStatusRecord[]): string { function gpoStatusRevision(records: GpoBackupStatusRecord[]): string {
return JSON.stringify(records.map(record => [ return JSON.stringify(records.map(record => [
record.artifactId, record.jobId, record.state, record.updatedAt, record.gpoCount, record.artifactId, record.jobId, record.state, record.updatedAt, record.gpoCount, record.notLinkedGpoCount ?? null,
record.evaluationUpdates?.map(update => [update.evaluationRunId, update.previousVersion, update.currentVersion]) ?? [] record.evaluationUpdates?.map(update => [update.evaluationRunId, update.previousVersion, update.currentVersion]) ?? []
]).sort((left, right) => String(left[0]).localeCompare(String(right[0])))); ]).sort((left, right) => String(left[0]).localeCompare(String(right[0]))));
} }
@ -85,7 +88,8 @@ function groupScanReports(files: ImportFileRecord[], scanRuns: ScanRun[] = []):
scanRunId, scanRunId,
scanRunIds: [scanRunId], scanRunIds: [scanRunId],
assessmentIds: run ? [run.assessmentId] : [], assessmentIds: run ? [run.assessmentId] : [],
tool: run?.tool ?? runFiles[0].detectedTool, tool: { ...(run?.tool ?? runFiles[0].detectedTool),
name: datahubScanLabel(run?.tool ?? runFiles[0].detectedTool, scanRunId, runFiles) },
files: runFiles, files: runFiles,
gpoBackups: [], gpoBackups: [],
sourceCount: 1, sourceCount: 1,
@ -101,6 +105,9 @@ function groupScanReports(files: ImportFileRecord[], scanRuns: ScanRun[] = []):
} }
export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChanged, onDetailViewChanged, backendStatus }: DataHubProps) { export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChanged, onDetailViewChanged, backendStatus }: DataHubProps) {
const [projects, setProjects] = useState<AssessmentProject[]>([]); const [projects, setProjects] = useState<AssessmentProject[]>([]);
const [referenceRefreshKey, setReferenceRefreshKey] = useState(0);
const { states: projectReferenceReadiness, reportStatus: reportReferenceStatus } = useReferenceRecognitionStatus(
projects, backendStatus === 'connected', referenceRefreshKey);
const [projectData, setProjectData] = useState<Record<string, ImportFileRecord[]>>({}); const [projectData, setProjectData] = useState<Record<string, ImportFileRecord[]>>({});
const [projectHistory, setProjectHistory] = useState<Record<string, ProjectAssessmentHistoryResponse>>({}); const [projectHistory, setProjectHistory] = useState<Record<string, ProjectAssessmentHistoryResponse>>({});
const [projectGpoData, setProjectGpoData] = useState<Record<string, GpoBackupStatusRecord[]>>({}); const [projectGpoData, setProjectGpoData] = useState<Record<string, GpoBackupStatusRecord[]>>({});
@ -117,21 +124,28 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [saveFeedback, setSaveFeedback] = useState<SaveFeedback | null>(null); const [saveFeedback, setSaveFeedback] = useState<SaveFeedback | null>(null);
const [mitreStatus, setMitreStatus] = useState(() => loadStoredMitreMatrix()); const [mitreStatus, setMitreStatus] = useState(() => loadStoredMitreMatrix());
const [mitreActiveVersion, setMitreActiveVersion] = useState(() => activeMitreMatrix().version);
const [mitreBusy, setMitreBusy] = useState<'refresh' | 'download' | 'upload' | null>(null);
const [mitreError, setMitreError] = useState<string | null>(null); const [mitreError, setMitreError] = useState<string | null>(null);
const [mitreFeedback, setMitreFeedback] = useState<string | null>(null); const [mitreFeedback, setMitreFeedback] = useState<string | null>(null);
const [scanSelection, setScanSelection] = useState<DatahubTimeslotSelection | null>(null); const [scanSelection, setScanSelection] = useState<DatahubTimeslotSelection | null>(null);
const [timeslotMetrics, setTimeslotMetrics] = useState<DatahubTimeslotResultMetrics | null>(null); const [timeslotMetrics, setTimeslotMetrics] = useState<DatahubTimeslotResultMetrics | null>(null);
const [timeslotMetricsBusy, setTimeslotMetricsBusy] = useState(false); const [timeslotMetricsBusy, setTimeslotMetricsBusy] = useState(false);
const [timeslotMetricsError, setTimeslotMetricsError] = useState<string | null>(null); const [timeslotMetricsError, setTimeslotMetricsError] = useState<string | null>(null);
const [metricsRevision, setMetricsRevision] = useState<number | null>(null);
const [selectedTimeslotIds, setSelectedTimeslotIds] = useState<string[]>([]); const [selectedTimeslotIds, setSelectedTimeslotIds] = useState<string[]>([]);
const [excludedScanRunIds, setExcludedScanRunIds] = useState<string[]>([]); const [excludedScanRunIds, setExcludedScanRunIds] = useState<string[]>([]);
const [expandedTimeslotIds, setExpandedTimeslotIds] = useState<string[]>([]); const [expandedTimeslotIds, setExpandedTimeslotIds] = useState<string[]>([]);
const [timeslotDialogOpen, setTimeslotDialogOpen] = useState(false); const [timeslotDialogOpen, setTimeslotDialogOpen] = useState(false);
const [timeslotDate, setTimeslotDate] = useState(''); const [timeslotDate, setTimeslotDate] = useState('');
const [scanReadiness, setScanReadiness] = useState<Record<string, ScanReadinessState>>({}); const [scanReadiness, setScanReadiness] = useState<Record<string, ScanReadinessState>>({});
const [scanLabels, setScanLabels] = useState<Record<string, string>>({});
const [projectOpenReadiness, setProjectOpenReadiness] = useState<Record<string, Record<string, TimeslotOpenReadiness>>>({}); const [projectOpenReadiness, setProjectOpenReadiness] = useState<Record<string, Record<string, TimeslotOpenReadiness>>>({});
const [detailSection, setDetailSection] = useState<TenantDetailSection>('client-information'); const [detailSection, setDetailSection] = useState<TenantDetailSection>('client-information');
const projectModelsCache = useRef(new Map<string, CanonicalAssessmentModel[]>()); const projectModelsCache = useRef(new Map<string, CanonicalAssessmentModel[]>());
const projectLoadGeneration = useRef(0);
const preferredProjectId = useRef(activeProjectId);
preferredProjectId.current = activeProjectId;
useEffect(() => { useEffect(() => {
onDetailViewChanged?.(form !== null, form?.mode === 'edit' ? form.name.trim() || form.project?.name : undefined); onDetailViewChanged?.(form !== null, form?.mode === 'edit' ? form.name.trim() || form.project?.name : undefined);
@ -139,11 +153,58 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
useEffect(() => () => onDetailViewChanged?.(false), [onDetailViewChanged]); useEffect(() => () => onDetailViewChanged?.(false), [onDetailViewChanged]);
useEffect(() => {
let mounted = true;
void assessorApi.getMitreCatalog().then(state => {
if (!mounted) return;
setServerMitreMatrix(state.catalog);
setMitreActiveVersion(activeMitreMatrix().version);
}).catch(() => undefined);
return () => { mounted = false; };
}, []);
const handleMitreRefresh = async () => {
setMitreBusy('refresh');
setMitreError(null);
setMitreFeedback(null);
try {
const result = await assessorApi.refreshMitreCatalog();
clearStoredMitreMatrix();
setMitreStatus(null);
setServerMitreMatrix(result.catalog);
setMitreActiveVersion(result.catalog.version);
setReferenceRefreshKey(current => current + 1);
setMitreFeedback(result.updated
? `Enterprise ATT&CK v${result.catalog.version} is active in ASSESSOR.`
: `Enterprise ATT&CK v${result.catalog.version} is already current.`);
} catch (error) {
setMitreError(error instanceof Error ? error.message : 'MITRE could not be updated.');
} finally {
setMitreBusy(null);
}
};
const handleMitreDownload = async () => {
setMitreBusy('download');
setMitreError(null);
setMitreFeedback(null);
try {
const source = await assessorApi.downloadLatestMitreSource();
downloadBrowserBlob(source.blob, source.fileName);
setMitreFeedback(`Official MITRE file ${source.fileName} downloaded.`);
} catch (error) {
setMitreError(error instanceof Error ? error.message : 'MITRE could not be downloaded.');
} finally {
setMitreBusy(null);
}
};
const handleMitreUpload = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleMitreUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
setMitreError(null); setMitreError(null);
setMitreFeedback(null); setMitreFeedback(null);
setMitreBusy('upload');
setOperationProgress('Importing MITRE Matrix'); setOperationProgress('Importing MITRE Matrix');
try { try {
const text = await file.text(); const text = await file.text();
@ -151,12 +212,14 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
const validated = validateMitreMatrixData(parsed); const validated = validateMitreMatrixData(parsed);
saveStoredMitreMatrix(validated); saveStoredMitreMatrix(validated);
setMitreStatus(validated); setMitreStatus(validated);
setMitreActiveVersion(validated.version);
setMitreFeedback(`MITRE Matrix version ${validated.version} (${validated.techniques.length} techniques, ${validated.tacticOrder.length} tactics) updated successfully.`); setMitreFeedback(`MITRE Matrix version ${validated.version} (${validated.techniques.length} techniques, ${validated.tacticOrder.length} tactics) updated successfully.`);
globalThis.dispatchEvent(new CustomEvent('assessor:mitre-matrix-changed')); globalThis.dispatchEvent(new Event(MITRE_MATRIX_CHANGED_EVENT));
} catch (err) { } catch (err) {
setMitreError(err instanceof Error ? err.message : 'Error loading the MITRE Matrix JSON.'); setMitreError(err instanceof Error ? err.message : 'Error loading the MITRE Matrix JSON.');
} finally { } finally {
setOperationProgress(undefined); setOperationProgress(undefined);
setMitreBusy(null);
event.target.value = ''; event.target.value = '';
} }
}; };
@ -164,12 +227,16 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
const handleMitreReset = () => { const handleMitreReset = () => {
clearStoredMitreMatrix(); clearStoredMitreMatrix();
setMitreStatus(null); setMitreStatus(null);
setMitreFeedback('Default matrix v18.1 restored.'); setMitreActiveVersion(activeMitreMatrix().version);
setMitreFeedback(`Active catalog v${activeMitreMatrix().version} restored.`);
setMitreError(null); setMitreError(null);
globalThis.dispatchEvent(new CustomEvent('assessor:mitre-matrix-changed')); globalThis.dispatchEvent(new Event(MITRE_MATRIX_CHANGED_EVENT));
}; };
const loadProjects = useCallback(async () => { const loadProjects = useCallback(async () => {
const generation = ++projectLoadGeneration.current;
setMetricsRevision(null);
setTimeslotMetrics(null);
if (backendStatus !== 'connected') { if (backendStatus !== 'connected') {
setProjects([]); setProjects([]);
setProjectData({}); setProjectData({});
@ -181,30 +248,49 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
setError(null); setError(null);
try { try {
projectModelsCache.current.clear(); projectModelsCache.current.clear();
const list = await assessorApi.listProjects(); const [list, health] = await Promise.all([assessorApi.listProjects(), assessorApi.health()]);
const data = await Promise.all(list.map(async project => { const prioritized = [...list].sort((left, right) => Number(right.id === preferredProjectId.current) - Number(left.id === preferredProjectId.current));
const data = await Promise.all(prioritized.map(async project => {
const [files, history, gpoStatus] = await Promise.all([ const [files, history, gpoStatus] = await Promise.all([
assessorApi.listProjectImportFiles(project.id), assessorApi.listProjectImportFiles(project.id),
assessorApi.listProjectAssessmentHistory(project.id), assessorApi.listProjectAssessmentHistory(project.id),
assessorApi.listProjectGpoBackupStatus(project.id) assessorApi.listProjectGpoBackupStatus(project.id)
]); ]);
const openReadiness = await loadTimeslotOpenReadiness(query => assessorApi.queryUnifiedResultFacets(query), project.id, if (generation === projectLoadGeneration.current) {
groupProjectImportFiles(files, history.assessments, history.scanRuns, gpoStatus.records)); const latest = groupProjectImportFiles(files, history.assessments, history.scanRuns, gpoStatus.records)
return { projectId: project.id, files, history, gpoRecords: gpoStatus.records, openReadiness }; .find(group => group.scanRunIds.length);
if (latest) metricsCache.prefetch({ revision: health.repositoryRevision, projectId: project.id,
scanRunIds: latest.scanRunIds, assessmentIds: latest.assessmentIds });
}
return { projectId: project.id, files, history, gpoRecords: gpoStatus.records };
})); }));
if (generation !== projectLoadGeneration.current) return;
setProjects(list); setProjects(list);
setProjectData(Object.fromEntries(data.map(item => [item.projectId, item.files]))); setProjectData(Object.fromEntries(data.map(item => [item.projectId, item.files])));
setProjectHistory(Object.fromEntries(data.map(item => [item.projectId, item.history]))); setProjectHistory(Object.fromEntries(data.map(item => [item.projectId, item.history])));
setProjectGpoData(Object.fromEntries(data.map(item => [item.projectId, item.gpoRecords]))); setProjectGpoData(Object.fromEntries(data.map(item => [item.projectId, item.gpoRecords])));
setProjectOpenReadiness(Object.fromEntries(data.map(item => [item.projectId, item.openReadiness]))); setReferenceRefreshKey(current => current + 1);
setMetricsRevision(health.repositoryRevision);
// Open-readiness checks are independent of the six result metrics. Start
// them after the overview is available so they cannot hold back the rings.
globalThis.setTimeout(() => {
if (generation !== projectLoadGeneration.current) return;
void Promise.all(data.map(async item => ({ projectId: item.projectId,
openReadiness: await loadTimeslotOpenReadiness(query => assessorApi.queryUnifiedResultFacets(query), item.projectId,
groupProjectImportFiles(item.files, item.history.assessments, item.history.scanRuns, item.gpoRecords))
}))).then(entries => {
if (generation === projectLoadGeneration.current) setProjectOpenReadiness(Object.fromEntries(entries.map(item => [item.projectId, item.openReadiness])));
});
}, 0);
} catch (reason) { } catch (reason) {
setError(reason instanceof Error ? reason.message : 'Projects could not be loaded.'); if (generation === projectLoadGeneration.current) setError(reason instanceof Error ? reason.message : 'Projects could not be loaded.');
} finally { } finally {
setLoading(false); if (generation === projectLoadGeneration.current) setLoading(false);
} }
}, [backendStatus]); }, [backendStatus]);
useEffect(() => { void loadProjects(); }, [loadProjects]); useEffect(() => { void loadProjects(); }, [loadProjects]);
useEffect(() => () => { projectLoadGeneration.current++; }, []);
const pendingGpoProjectIds = projects const pendingGpoProjectIds = projects
.filter(project => projectGpoData[project.id]?.some(record => record.state === 'queued' || record.state === 'running')) .filter(project => projectGpoData[project.id]?.some(record => record.state === 'queued' || record.state === 'running'))
.map(project => project.id); .map(project => project.id);
@ -227,6 +313,8 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
return { projectId, records: response.records, openReadiness }; return { projectId, records: response.records, openReadiness };
}))).filter((entry): entry is NonNullable<typeof entry> => entry !== null); }))).filter((entry): entry is NonNullable<typeof entry> => entry !== null);
if (!cancelled && entries.length) { if (!cancelled && entries.length) {
metricsCache.invalidate();
void loadProjects();
setProjectGpoData(current => ({ ...current, ...Object.fromEntries(entries.map(entry => [entry.projectId, entry.records])) })); setProjectGpoData(current => ({ ...current, ...Object.fromEntries(entries.map(entry => [entry.projectId, entry.records])) }));
setProjectOpenReadiness(current => ({ ...current, ...Object.fromEntries(entries.map(entry => [entry.projectId, entry.openReadiness])) })); setProjectOpenReadiness(current => ({ ...current, ...Object.fromEntries(entries.map(entry => [entry.projectId, entry.openReadiness])) }));
} }
@ -239,7 +327,7 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
} }
}, 5_000); }, 5_000);
return () => { cancelled = true; globalThis.clearInterval(timer); }; return () => { cancelled = true; globalThis.clearInterval(timer); };
}, [backendStatus, pendingGpoProjectKey, projectData, projectGpoData, projectHistory]); }, [backendStatus, pendingGpoProjectKey, projectData, projectGpoData, projectHistory, loadProjects]);
useEffect(() => { useEffect(() => {
if (!processingGpoJob) return; if (!processingGpoJob) return;
const record = projectGpoData[processingGpoJob.projectId]?.find(item => const record = projectGpoData[processingGpoJob.projectId]?.find(item =>
@ -250,7 +338,7 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
} }
}, [processingGpoJob, projectGpoData]); }, [processingGpoJob, projectGpoData]);
useEffect(() => { useEffect(() => {
const refresh = () => { void loadProjects(); }; const refresh = () => { metricsCache.invalidate(); void loadProjects(); };
globalThis.addEventListener('assessor:datahub-changed', refresh); globalThis.addEventListener('assessor:datahub-changed', refresh);
globalThis.addEventListener('assessor:gpo-changed', refresh); globalThis.addEventListener('assessor:gpo-changed', refresh);
return () => { return () => {
@ -299,31 +387,23 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
useEffect(() => { useEffect(() => {
const selectedTimeslot = scanSelection?.slots[scanSelection.slots.length - 1]; const selectedTimeslot = scanSelection?.slots[scanSelection.slots.length - 1];
if (!selectedTimeslot) { if (!selectedTimeslot || metricsRevision === null) {
setTimeslotMetrics(null); setTimeslotMetrics(null);
setTimeslotMetricsError(null); setTimeslotMetricsError(null);
setTimeslotMetricsBusy(false); setTimeslotMetricsBusy(Boolean(selectedTimeslot));
return; return;
} }
let cancelled = false; let cancelled = false;
setTimeslotMetrics(null); const scope = { revision: metricsRevision, projectId: scanSelection.projectId,
setTimeslotMetricsBusy(true); scanRunIds: selectedTimeslot.runIds, assessmentIds: selectedTimeslot.assessmentIds };
const cached = metricsCache.peek(scope);
setTimeslotMetrics(cached ?? null);
setTimeslotMetricsBusy(cached === undefined);
setTimeslotMetricsError(null); setTimeslotMetricsError(null);
const workspaceLoad = loadCanonicalProjectWorkspace( void metricsCache.load(scope)
assessorApi, .then(metrics => {
scanSelection.projectId,
selectedTimeslot.runIds,
selectedTimeslot.assessmentIds
);
// Opening this timeslot next reuses the same load instead of repeating it.
offerWorkspaceLoad(workspaceScopeKey(scanSelection.projectId, selectedTimeslot.runIds, selectedTimeslot.assessmentIds), workspaceLoad);
void workspaceLoad
.then(({ dataset }) => {
if (cancelled) return; if (cancelled) return;
const sheet = dataset?.sheets.find(candidate => candidate.isProjectSheet) ?? dataset?.sheets[0]; setTimeslotMetrics(metrics);
setTimeslotMetrics(sheet
? summarizeWorkspaceResults(sheet.analysis.records, profileForSheet(dataset, sheet))
: null);
}) })
.catch(reason => { .catch(reason => {
if (cancelled) return; if (cancelled) return;
@ -332,15 +412,20 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
}) })
.finally(() => { if (!cancelled) setTimeslotMetricsBusy(false); }); .finally(() => { if (!cancelled) setTimeslotMetricsBusy(false); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [projectHistory, scanSelection]); }, [scanSelection, metricsRevision]);
const selectProject = useCallback((projectId: string) => { const selectProject = useCallback((projectId: string) => {
const latest = groupsForProject(projectId).find(group => group.scanRunIds.length); const latest = groupsForProject(projectId).find(group => group.scanRunIds.length);
const cached = latest && metricsRevision !== null ? metricsCache.peek({ revision: metricsRevision, projectId,
scanRunIds: latest.scanRunIds, assessmentIds: latest.assessmentIds }) : undefined;
setTimeslotMetrics(cached ?? null);
setTimeslotMetricsError(null);
setTimeslotMetricsBusy(Boolean(latest) && cached === undefined);
setScanSelection({ setScanSelection({
projectId, projectId,
slots: latest ? [{ id: latest.id, runIds: latest.scanRunIds, assessmentIds: latest.assessmentIds, effectiveAt: latest.fileDate }] : [] slots: latest ? [{ id: latest.id, runIds: latest.scanRunIds, assessmentIds: latest.assessmentIds, effectiveAt: latest.fileDate }] : []
}); });
}, [groupsForProject]); }, [groupsForProject, metricsRevision]);
const editedGroups = useMemo(() => form?.project ? groupsForProject(form.project.id) : [], [form?.project, groupsForProject]); const editedGroups = useMemo(() => form?.project ? groupsForProject(form.project.id) : [], [form?.project, groupsForProject]);
const editedReports = useMemo(() => form?.project const editedReports = useMemo(() => form?.project
@ -350,12 +435,16 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
const projectId = form?.project?.id; const projectId = form?.project?.id;
if (!projectId || editedReports.length === 0) { if (!projectId || editedReports.length === 0) {
setScanReadiness({}); setScanReadiness({});
setScanLabels({});
return; return;
} }
let cancelled = false; let cancelled = false;
setScanLabels({});
setScanReadiness(Object.fromEntries(editedReports.map(report => [report.scanRunId, 'checking']))); setScanReadiness(Object.fromEntries(editedReports.map(report => [report.scanRunId, 'checking'])));
void loadProjectModels(projectId).then(models => { void loadProjectModels(projectId).then(models => {
if (cancelled) return; if (cancelled) return;
setScanLabels(Object.fromEntries(editedReports.map(report => [report.scanRunId,
datahubScanLabel(report.tool, report.scanRunId, report.files, models)])));
setScanReadiness(Object.fromEntries(editedReports.map(report => [ setScanReadiness(Object.fromEntries(editedReports.map(report => [
report.scanRunId, report.scanRunId,
datahubScanReadiness(models, report.scanRunId, report.tool.id) datahubScanReadiness(models, report.scanRunId, report.tool.id)
@ -765,7 +854,10 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
</summary> </summary>
{reports.length === 0 && group.gpoBackups.length === 0 ? <div className="datahub-empty tenant-files-empty"><h3>No sources</h3><p>This timeslot does not contain a scan report or GPO backup yet.</p></div> : <div className="tenant-report-list" role="table" aria-label={`Assessment sources from ${formatTenantDate(group.fileDate)}`}> {reports.length === 0 && group.gpoBackups.length === 0 ? <div className="datahub-empty tenant-files-empty"><h3>No sources</h3><p>This timeslot does not contain a scan report or GPO backup yet.</p></div> : <div className="tenant-report-list" role="table" aria-label={`Assessment sources from ${formatTenantDate(group.fileDate)}`}>
<div className="tenant-report-row tenant-report-head" role="row"><span aria-hidden="true"/><span>Source</span><span>Imported</span><span>Status</span><span>Actions</span></div> <div className="tenant-report-row tenant-report-head" role="row"><span aria-hidden="true"/><span>Source</span><span>Imported</span><span>Status</span><span>Actions</span></div>
{reports.map(report => { {reports.map(sourceReport => {
const report = scanLabels[sourceReport.scanRunId]
? { ...sourceReport, tool: { ...sourceReport.tool, name: scanLabels[sourceReport.scanRunId] } }
: sourceReport;
const readiness = scanReadiness[report.scanRunId] ?? 'checking'; const readiness = scanReadiness[report.scanRunId] ?? 'checking';
const processing = processingByRun.get(report.scanRunId)!; const processing = processingByRun.get(report.scanRunId)!;
const viewable = isDatahubScanViewable(readiness); const viewable = isDatahubScanViewable(readiness);
@ -839,7 +931,12 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
{form.project && <button className="btn primary tenant-navigation-open" type="button" disabled={openDisabled} title={!selectedTimeslots.length ? 'Select at least one timeslot' : selectedTimeslots.length > 1 ? 'Open comparable sources from the selected timeslots' : 'Open selected scan reports'} onClick={openSelectedTimeslots}>Open</button>} {form.project && <button className="btn primary tenant-navigation-open" type="button" disabled={openDisabled} title={!selectedTimeslots.length ? 'Select at least one timeslot' : selectedTimeslots.length > 1 ? 'Open comparable sources from the selected timeslots' : 'Open selected scan reports'} onClick={openSelectedTimeslots}>Open</button>}
<div className="tenant-identity"><h2>{form.mode === 'create' ? 'Create Client' : form.project?.name}</h2>{form.project && <p>{formatTenantDate(form.project.createdAt)}</p>}</div> <div className="tenant-identity"><h2>{form.mode === 'create' ? 'Create Client' : form.project?.name}</h2>{form.project && <p>{formatTenantDate(form.project.createdAt)}</p>}</div>
</section> </section>
{form.mode === 'edit' && filesSection} {form.mode === 'edit' && <div className="tenant-source-navigation">
{form.project && <ReferenceRecognitionPanel key={form.project.id} projectId={form.project.id}
onStatusChanged={reportReferenceStatus}
onChanged={() => onProjectDataChanged?.(form.project!.id)}/>}
{filesSection}
</div>}
</aside> </aside>
<main className="tenant-detail-information"> <main className="tenant-detail-information">
{detailSection === 'client-information' ? <section className="tenant-information-sheet" aria-label="Client information sheet"> {detailSection === 'client-information' ? <section className="tenant-information-sheet" aria-label="Client information sheet">
@ -870,6 +967,7 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
projectHistory={projectHistory} projectHistory={projectHistory}
projectGpoData={projectGpoData} projectGpoData={projectGpoData}
projectOpenReadiness={projectOpenReadiness} projectOpenReadiness={projectOpenReadiness}
projectReferenceReadiness={projectReferenceReadiness}
selection={scanSelection} selection={scanSelection}
timeslotMetrics={timeslotMetrics} timeslotMetrics={timeslotMetrics}
loading={loading} loading={loading}
@ -880,16 +978,20 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
onOpen={(project, scanRunIds, assessmentIds) => onProjectSelected(project, scanRunIds, assessmentIds)} onOpen={(project, scanRunIds, assessmentIds) => onProjectSelected(project, scanRunIds, assessmentIds)}
onSelectProject={selectProject} onSelectProject={selectProject}
/> />
<div className={`datahub-reference-row${SHOW_MITRE_MATRIX_LIBRARY ? '' : ' is-mitre-hidden'}`}> <div className="datahub-reference-row">
<MicrosoftBaselineLibrary/> <MicrosoftBaselineLibrary/>
<IsoCorpusLibrary/> <IsoCorpusLibrary/>
{SHOW_MITRE_MATRIX_LIBRARY && <MitreMatrixLibrary <MitreMatrixLibrary
status={mitreStatus} status={mitreStatus}
activeVersion={mitreActiveVersion}
busy={mitreBusy}
feedback={mitreFeedback} feedback={mitreFeedback}
error={mitreError} error={mitreError}
onRefresh={() => void handleMitreRefresh()}
onDownload={() => void handleMitreDownload()}
onUpload={event => void handleMitreUpload(event)} onUpload={event => void handleMitreUpload(event)}
onReset={handleMitreReset} onReset={handleMitreReset}
/>} />
</div> </div>
</>} </>}
{scanDeleteTarget && <Dialog title="Delete scan report from the database?" onClose={() => !busy && setScanDeleteTarget(null)}><div className="datahub-delete-warning"><strong>{scanDeleteTarget.tool.name}</strong><p>{new Date(scanDeleteTarget.fileDate).toLocaleString('en-US')} · {scanDeleteTarget.fileCount} file{scanDeleteTarget.fileCount === 1 ? '' : 's'}</p><p>The scan report, its findings, evidence, and scan-related mappings will be permanently deleted. Other scan reports for this client remain intact.</p><footer><button className="btn" type="button" disabled={busy} onClick={() => setScanDeleteTarget(null)}>Cancel</button><button className="btn danger" type="button" disabled={busy} onClick={() => void deleteScanRun()}>{busy ? 'Deleting …' : 'Yes, delete scan report'}</button></footer></div></Dialog>} {scanDeleteTarget && <Dialog title="Delete scan report from the database?" onClose={() => !busy && setScanDeleteTarget(null)}><div className="datahub-delete-warning"><strong>{scanDeleteTarget.tool.name}</strong><p>{new Date(scanDeleteTarget.fileDate).toLocaleString('en-US')} · {scanDeleteTarget.fileCount} file{scanDeleteTarget.fileCount === 1 ? '' : 's'}</p><p>The scan report, its findings, evidence, and scan-related mappings will be permanently deleted. Other scan reports for this client remain intact.</p><footer><button className="btn" type="button" disabled={busy} onClick={() => setScanDeleteTarget(null)}>Cancel</button><button className="btn danger" type="button" disabled={busy} onClick={() => void deleteScanRun()}>{busy ? 'Deleting …' : 'Yes, delete scan report'}</button></footer></div></Dialog>}

View File

@ -1,7 +1,7 @@
import type { GpoBackupStatusRecord, ProjectAssessmentHistoryResponse } from '../../api/assessor-api-contract'; import type { GpoBackupStatusRecord, ProjectAssessmentHistoryResponse } from '../../api/assessor-api-contract';
import type { AssessmentProject, ImportFileRecord } from '../../domain/canonical-model'; import type { AssessmentProject, ImportFileRecord } from '../../domain/canonical-model';
import { groupProjectImportFiles } from './datahub-import-groups'; import { groupProjectImportFiles } from './datahub-import-groups';
import { summarizeClientProcessingReadiness } from './datahub-processing-readiness'; import { summarizeClientProcessingReadiness, withReferenceReadiness, type DatahubReferenceReadiness } from './datahub-processing-readiness';
import type { DatahubTimeslotResultMetrics } from './datahub-scan-analysis'; import type { DatahubTimeslotResultMetrics } from './datahub-scan-analysis';
import { DonutMetricCard } from '../../components/DonutMetricCard/DonutMetricCard'; import { DonutMetricCard } from '../../components/DonutMetricCard/DonutMetricCard';
import type { TimeslotOpenReadiness } from './datahub-open-readiness'; import type { TimeslotOpenReadiness } from './datahub-open-readiness';
@ -18,6 +18,7 @@ interface DatahubHistoryOverviewProps {
projectHistory: Record<string, ProjectAssessmentHistoryResponse>; projectHistory: Record<string, ProjectAssessmentHistoryResponse>;
projectGpoData: Record<string, GpoBackupStatusRecord[]>; projectGpoData: Record<string, GpoBackupStatusRecord[]>;
projectOpenReadiness?: Record<string, Record<string, TimeslotOpenReadiness>>; projectOpenReadiness?: Record<string, Record<string, TimeslotOpenReadiness>>;
projectReferenceReadiness?: Record<string, DatahubReferenceReadiness>;
selection: DatahubTimeslotSelection | null; selection: DatahubTimeslotSelection | null;
timeslotMetrics: DatahubTimeslotResultMetrics | null; timeslotMetrics: DatahubTimeslotResultMetrics | null;
loading: boolean; loading: boolean;
@ -35,7 +36,7 @@ function formatDate(value: string): string {
} }
function resultPercentage(value: number, total: number): number { function resultPercentage(value: number, total: number): number {
return total > 0 ? Math.min(100, Math.max(0, Math.round((value / total) * 100))) : 0; return total > 0 ? Math.min(100, Math.max(0, (value / total) * 100)) : 0;
} }
export function DatahubHistoryOverview({ export function DatahubHistoryOverview({
@ -44,6 +45,7 @@ export function DatahubHistoryOverview({
projectHistory, projectHistory,
projectGpoData, projectGpoData,
projectOpenReadiness = {}, projectOpenReadiness = {},
projectReferenceReadiness = {},
selection, selection,
timeslotMetrics, timeslotMetrics,
loading, loading,
@ -54,17 +56,19 @@ export function DatahubHistoryOverview({
onOpen, onOpen,
onSelectProject onSelectProject
}: DatahubHistoryOverviewProps) { }: DatahubHistoryOverviewProps) {
const resultMetrics = timeslotMetrics ?? { all: 0, deviation: 0, passed: 0, notApplicable: 0, notRun: 0, error: 0 }; const resultMetrics = timeslotMetrics ?? { all: null, deviation: null, passed: null, notApplicable: null, notRun: null, error: null };
const pendingLabel = loading || comparisonBusy ? 'Loading…'
: comparisonError ? 'Unavailable' : selection ? 'No results' : 'Select client';
return <> return <>
<section className="datahub-rack-module datahub-status-module" aria-label="Selected client result status" aria-busy={comparisonBusy}> <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"> <div className="datahub-reference-controls donut-metric-rack">
<DonutMetricCard className="datahub-reference-knob" label="Results All" value={resultMetrics.all} percentage={resultMetrics.all > 0 ? 100 : 0} tone="all"/> <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, resultMetrics.all)} tone="deviation"/> <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, resultMetrics.all)} tone="passed"/> <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, resultMetrics.all)} tone="not-applicable"/> <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, resultMetrics.all)} tone="not-run"/> <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, resultMetrics.all)} tone="error"/> <DonutMetricCard className="datahub-reference-knob" label="Error" value={resultMetrics.error} percentage={resultPercentage(resultMetrics.error ?? 0, resultMetrics.all ?? 0)} tone="error" pendingLabel={pendingLabel}/>
</div> </div>
{comparisonError && <em className="datahub-comparison-error" role="alert">{comparisonError}</em>} {comparisonError && <em className="datahub-comparison-error" role="alert">{comparisonError}</em>}
</section> </section>
@ -83,11 +87,11 @@ export function DatahubHistoryOverview({
const latest = groups[0]; const latest = groups[0];
const latestOpenable = groups.find(group => projectOpenReadiness[project.id]?.[group.id]?.canOpen); const latestOpenable = groups.find(group => projectOpenReadiness[project.id]?.[group.id]?.canOpen);
const activeProcessingRunIds = groups.flatMap(group => group.scanRunIds); const activeProcessingRunIds = groups.flatMap(group => group.scanRunIds);
const processingSummary = summarizeClientProcessingReadiness( const processingSummary = withReferenceReadiness(summarizeClientProcessingReadiness(
projectData[project.id] ?? [], projectData[project.id] ?? [],
activeProcessingRunIds, activeProcessingRunIds,
projectGpoData[project.id] ?? [] projectGpoData[project.id] ?? []
); ), projectReferenceReadiness[project.id]);
const selected = selection?.projectId === project.id; const selected = selection?.projectId === project.id;
return <article className={`donut-metric-card donut-metric-card--all datahub-tenant-card${selected ? ' is-selected' : ''}`} key={project.id}> return <article className={`donut-metric-card donut-metric-card--all datahub-tenant-card${selected ? ' is-selected' : ''}`} key={project.id}>
{processingSummary.state !== 'no-data' && <span {processingSummary.state !== 'no-data' && <span

View File

@ -38,7 +38,8 @@ export function GpoStatusRow({ projectId, record, imported, busy, onEvaluate, on
return <div className="tenant-report-row tenant-report-gpo" role="row"> return <div className="tenant-report-row tenant-report-gpo" role="row">
<span className="tenant-gpo-source-marker" aria-label="Policy analysis source">G</span> <span className="tenant-gpo-source-marker" aria-label="Policy analysis source">G</span>
<span className="tenant-report-copy"><b>GPO Backup</b> <span className="tenant-report-copy"><b>GPO Backup</b>
<small>{record.fileName} · {record.gpoCount.toLocaleString('en-US')} GPO{record.gpoCount === 1 ? '' : 's'}</small> <small>{record.fileName} · {record.gpoCount.toLocaleString('en-US')} GPO{record.gpoCount === 1 ? '' : 's'}
{Boolean(record.notLinkedGpoCount) && <span className="gpo-status-row__not-linked" title="No enabled link to the domain or an OU in the backup report; these GPOs apply to no device."> · {record.notLinkedGpoCount!.toLocaleString('en-US')} not linked</span>}</small>
{sourceBlocked && <small role="status">{record.state === 'upload-required' ? missingGpoSourceMessage : unavailableGpoStorageMessage}</small>} {sourceBlocked && <small role="status">{record.state === 'upload-required' ? missingGpoSourceMessage : unavailableGpoStorageMessage}</small>}
{sourceBlocked && outdated && !analysis && <small role="status">Re-evaluation required · {cause}. Retained inputs can still be re-evaluated.</small>} {sourceBlocked && outdated && !analysis && <small role="status">Re-evaluation required · {cause}. Retained inputs can still be re-evaluated.</small>}
{outdated && !sourceBlocked && <small role="status">{analysis ? 'Reprocessing required' : 'Re-evaluation required'} · {cause}. Saved results are historical. {analysis ? 'Reprocess the stored source' : 'Re-evaluate retained inputs'}; no file upload is required for this update.</small>} {outdated && !sourceBlocked && <small role="status">{analysis ? 'Reprocessing required' : 'Re-evaluation required'} · {cause}. Saved results are historical. {analysis ? 'Reprocess the stored source' : 'Re-evaluate retained inputs'}; no file upload is required for this update.</small>}

View File

@ -2,8 +2,12 @@ import type { MitreGraphData } from '../mitre-graph/mitre-graph';
interface MitreMatrixLibraryProps { interface MitreMatrixLibraryProps {
status: MitreGraphData | null; status: MitreGraphData | null;
activeVersion: string;
busy: 'refresh' | 'download' | 'upload' | null;
feedback: string | null; feedback: string | null;
error: string | null; error: string | null;
onRefresh(): void;
onDownload(): void;
onUpload(event: React.ChangeEvent<HTMLInputElement>): void; onUpload(event: React.ChangeEvent<HTMLInputElement>): void;
onReset(): void; onReset(): void;
} }
@ -14,21 +18,32 @@ interface MitreMatrixLibraryProps {
* Wiederherstellung der Standardmatrix erscheint nur, wenn eine eigene Matrix * Wiederherstellung der Standardmatrix erscheint nur, wenn eine eigene Matrix
* geladen ist - ohne sie gaebe es keinen Weg zurueck. * geladen ist - ohne sie gaebe es keinen Weg zurueck.
*/ */
export function MitreMatrixLibrary({ status, feedback, error, onUpload, onReset }: MitreMatrixLibraryProps) { export function MitreMatrixLibrary({ status, activeVersion, busy, feedback, error, onRefresh, onDownload, onUpload, onReset }: MitreMatrixLibraryProps) {
return <section className="datahub-rack-module datahub-reference-module mitre-library"> return <section className="datahub-rack-module datahub-reference-module mitre-library">
<header className="datahub-rack-header"> <header className="datahub-rack-header">
<span className="datahub-grip" aria-hidden="true">⠿</span> <span className="datahub-grip" aria-hidden="true">⠿</span>
<i className="datahub-led" aria-hidden="true"/> <i className="datahub-led" aria-hidden="true"/>
<strong>MITRE ATT&amp;CK MATRIX</strong> <strong>MITRE ATT&amp;CK MATRIX</strong>
<span className="datahub-rack-badge">{status ? `Custom ${status.version}` : 'v18.1 Standard'}</span> <span className="datahub-rack-badge">{status ? `Custom v${status.version}` : `v${activeVersion} Active`}</span>
</header> </header>
<div className="datahub-rack-body"> <div className="datahub-rack-body">
<div className="datahub-rack-actions"> <div className="datahub-rack-actions">
<label className="btn datahub-maintenance-action datahub-mitre-upload-btn"> <button className="btn datahub-maintenance-action" type="button" disabled={busy !== null} onClick={onRefresh}>
<span>Update Matrix (JSON)</span> {busy === 'refresh' ? 'Checking MITRE…' : 'Update from MITRE'}
<input type="file" accept=".json" onChange={onUpload} className="datahub-mitre-file-input"/> </button>
<button className="btn" type="button" disabled={busy !== null} onClick={onDownload}>
{busy === 'download' ? 'Downloading…' : 'Download from MITRE'}
</button>
<label className="btn datahub-mitre-upload-btn" role="button" tabIndex={busy === null ? 0 : -1} aria-disabled={busy !== null}
onKeyDown={event => {
if (busy !== null || (event.key !== 'Enter' && event.key !== ' ')) return;
event.preventDefault();
event.currentTarget.querySelector('input')?.click();
}}>
<span>{busy === 'upload' ? 'Importing…' : 'Import JSON file'}</span>
<input type="file" accept=".json" disabled={busy !== null} onChange={onUpload} className="datahub-mitre-file-input"/>
</label> </label>
{status && <button className="btn" type="button" onClick={onReset}>Standard wiederherstellen</button>} {status && <button className="btn" type="button" disabled={busy !== null} onClick={onReset}>Use active catalog</button>}
</div> </div>
</div> </div>
{feedback && <div className="tenant-save-feedback success" role="status"><span aria-hidden="true">✓</span><strong>{feedback}</strong></div>} {feedback && <div className="tenant-save-feedback success" role="status"><span aria-hidden="true">✓</span><strong>{feedback}</strong></div>}

View File

@ -0,0 +1,95 @@
import { useEffect, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import type { ReferenceRecognitionStatus } from '../../api/assessor-api-contract';
import type { DatahubReferenceReadiness } from './datahub-processing-readiness';
const api = new AssessorApiClient();
export function ReferenceRecognitionPanel({ projectId, onChanged, onStatusChanged }: {
projectId: string;
onChanged?(): void | Promise<void>;
onStatusChanged?(projectId: string, state: DatahubReferenceReadiness): void;
}) {
const [status, setStatus] = useState<ReferenceRecognitionStatus>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [updated, setUpdated] = useState(false);
const [failure, setFailure] = useState<'status' | 'update' | 'workspace'>();
const [retryKey, setRetryKey] = useState(0);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
let cancelled = false;
setStatus(undefined); setError(''); setUpdated(false); setFailure(undefined); setExpanded(false);
onStatusChanged?.(projectId, { state: 'checking' });
api.getReferenceRecognitionStatus(projectId).then(value => {
if (!cancelled) {
setStatus(value);
if (value.pendingScans > 0) setExpanded(true);
onStatusChanged?.(projectId, { state: 'ready', status: value });
}
}).catch(reason => {
if (!cancelled) {
setError(reason instanceof Error ? reason.message : 'Reference status unavailable.');
setFailure('status');
setExpanded(true);
onStatusChanged?.(projectId, { state: 'unavailable' });
}
});
return () => { cancelled = true; };
}, [projectId, onStatusChanged, retryKey]);
const recognize = async () => {
setBusy(true); setError(''); setUpdated(false); setFailure(undefined); setExpanded(true);
onStatusChanged?.(projectId, { state: 'processing' });
let processed = false;
let refreshed = false;
try {
await api.recognizeReferences(projectId);
processed = true;
const latestStatus = await api.getReferenceRecognitionStatus(projectId);
setStatus(latestStatus);
if (latestStatus.pendingScans > 0) setExpanded(true);
onStatusChanged?.(projectId, { state: 'ready', status: latestStatus });
refreshed = true;
setUpdated(latestStatus.pendingScans === 0);
await onChanged?.();
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Reference recognition failed.');
setFailure(!processed ? 'update' : !refreshed ? 'status' : 'workspace');
setExpanded(true);
if (!refreshed) onStatusChanged?.(projectId, { state: processed ? 'unavailable' : 'error' });
} finally { setBusy(false); }
};
const needsUpdate = Boolean(status?.pendingScans);
const statusLabel = busy ? 'Updating references …'
: failure === 'status' ? 'Status unavailable — please retry'
: failure === 'update' ? 'Update failed — please retry'
: failure === 'workspace' ? 'References updated — reopen the client'
: !status ? 'Checking status …'
: status.totalScans === 0 ? 'No scans to update'
: needsUpdate ? 'Please update'
: updated ? 'Update successful' : 'Up to date';
const nextStep = busy || failure || !status ? undefined
: status.totalScans === 0 ? 'Import a scan first.'
: needsUpdate ? `${status.pendingScans} ${status.pendingScans === 1 ? 'scan needs' : 'scans need'} a reference update.`
: 'No action needed.';
const retryStatus = failure === 'status';
const indicator = busy ? 'neutral' : failure || needsUpdate ? 'attention'
: status && status.totalScans > 0 ? 'ready' : 'neutral';
return <section className="reference-recognition" aria-label="Reference recognition">
<details className="reference-recognition-disclosure" open={expanded}
onToggle={event => setExpanded(event.currentTarget.open)}>
<summary aria-label={`Catalog references: ${statusLabel}`} title={statusLabel}>
<h4><span className="reference-recognition-dot" data-state={indicator} aria-hidden="true"/>Catalog references
<span className="reference-recognition-chevron" aria-hidden="true">{expanded ? '▾' : '▸'}</span>
</h4>
</summary>
<p className="reference-recognition-state" role="status"><strong><span className="reference-recognition-dot" data-state={indicator} aria-hidden="true"/>{statusLabel}</strong>{nextStep && <span>{nextStep}</span>}</p>
<button className="btn primary reference-recognition-action" data-attention={needsUpdate || failure === 'update' || retryStatus}
type="button" disabled={busy || (!retryStatus && (!status || status.totalScans === 0))}
onClick={() => retryStatus ? setRetryKey(current => current + 1) : void recognize()}>
{busy ? 'Updating …' : retryStatus ? 'Retry status' : failure === 'update' ? 'Retry update' : 'Update references'}
</button>
{error && <p role="alert">{error}</p>}
</details>
{status && <p className="reference-recognition-versions">{status.catalogs.map(catalog => `${catalog.framework === 'mitre-attack' ? 'ATT&CK' : 'D3FEND'} ${catalog.version}`).join(' · ')}</p>}
</section>;
}

View File

@ -9,6 +9,7 @@ import { cveIdFromRuleId, cweDetailUrl } from '../../domain/cve-enrichment';
import { canonicalSecurityScores } from '../../scoring/canonical-security-score'; import { canonicalSecurityScores } from '../../scoring/canonical-security-score';
import { defaultSecurityScoringPolicy } from '../../domain/security-scoring'; import { defaultSecurityScoringPolicy } from '../../domain/security-scoring';
import { preferredPingCastleReportTitles, preferredPingCastleTitleForOccurrence } from '../../profiles/pingcastle/pingcastle-title-projection'; import { preferredPingCastleReportTitles, preferredPingCastleTitleForOccurrence } from '../../profiles/pingcastle/pingcastle-title-projection';
import { cisBenchmarkSectionTitles } from '../../profiles/cis-cat/cis-benchmark-section-titles';
function text(value: JsonValue): string { function text(value: JsonValue): string {
if (value === null) return ''; if (value === null) return '';
@ -388,7 +389,8 @@ function projectTool(models: CanonicalAssessmentModel[]): ToolIdentity {
export function canonicalModelsToDataset( export function canonicalModelsToDataset(
models: CanonicalAssessmentModel[], models: CanonicalAssessmentModel[],
titleSourceModels: CanonicalAssessmentModel[] = models titleSourceModels: CanonicalAssessmentModel[] = models,
options: { summaryOnly?: boolean } = {}
): ImportedDataset | null { ): ImportedDataset | null {
if (!models.length) return null; if (!models.length) return null;
const project = models[0].project; const project = models[0].project;
@ -397,6 +399,8 @@ export function canonicalModelsToDataset(
const tables = models.flatMap(model => model.sourceTables); const tables = models.flatMap(model => model.sourceTables);
const occurrences = models.flatMap(model => model.findingOccurrences); const occurrences = models.flatMap(model => model.findingOccurrences);
const hasEnvironmentObservations = models.some(model => model.environmentObservations?.length); const hasEnvironmentObservations = models.some(model => model.environmentObservations?.length);
const referenceByOccurrence = new Map(models.flatMap(model => model.referenceRuns ?? []).flatMap(run => run.results.map(result =>
[result.occurrenceId, { runId: run.id, recognizerVersion: run.recognizerVersion, createdAt: run.createdAt, references: result.references }] as const)));
if (!runs.length || (!occurrences.length && !tables.some(table => table.rows?.length) && !hasEnvironmentObservations)) return null; if (!runs.length || (!occurrences.length && !tables.some(table => table.rows?.length) && !hasEnvironmentObservations)) return null;
const fileById = new Map(files.map(file => [file.id, file])); const fileById = new Map(files.map(file => [file.id, file]));
@ -421,20 +425,25 @@ export function canonicalModelsToDataset(
} }
const enrichments = new Map(models.flatMap(model => model.cveEnrichments ?? []).map(record => [record.cveId, record])); const enrichments = new Map(models.flatMap(model => model.cveEnrichments ?? []).map(record => [record.cveId, record]));
const canonicalTitleByOccurrence = new Map<FindingOccurrence, string>(); const canonicalTitleByOccurrence = new Map<FindingOccurrence, string>();
const preferredPingCastleTitles = preferredPingCastleReportTitles(titleSourceModels); if (!options.summaryOnly) {
for (const model of models) { const preferredPingCastleTitles = preferredPingCastleReportTitles(titleSourceModels);
const definitions = new Map(model.findingDefinitions.map(definition => [definition.id, definition])); for (const model of models) {
for (const occurrence of model.findingOccurrences) { const definitions = new Map(model.findingDefinitions.map(definition => [definition.id, definition]));
const title = preferredPingCastleTitleForOccurrence(model, fileById.get(occurrence.importFileId), occurrence, preferredPingCastleTitles) for (const occurrence of model.findingOccurrences) {
?? definitions.get(occurrence.definitionId)?.title; const title = preferredPingCastleTitleForOccurrence(model, fileById.get(occurrence.importFileId), occurrence, preferredPingCastleTitles)
if (title) canonicalTitleByOccurrence.set(occurrence, title); ?? definitions.get(occurrence.definitionId)?.title;
if (title) canonicalTitleByOccurrence.set(occurrence, title);
}
} }
} }
const displayFields = displayFieldsByOccurrence(occurrences, enrichments, canonicalTitleByOccurrence); const displayFields = options.summaryOnly
? new Map(occurrences.map(occurrence => [occurrence.id,
occurrence.originalFields.filter(field => field.label !== linkedSectionFieldLabel)]))
: displayFieldsByOccurrence(occurrences, enrichments, canonicalTitleByOccurrence);
const tableSheets: DatasetSheet[] = []; const tableSheets: DatasetSheet[] = [];
const supportingSheets: DatasetSheet[] = []; const supportingSheets: DatasetSheet[] = [];
for (const table of tables) { for (const table of options.summaryOnly ? [] : tables) {
const tableOccurrences = occurrencesByTableId.get(table.id) ?? []; const tableOccurrences = occurrencesByTableId.get(table.id) ?? [];
if (!tableOccurrences.length && !table.rows?.length) continue; if (!tableOccurrences.length && !table.rows?.length) continue;
const projection = tableOccurrences.length const projection = tableOccurrences.length
@ -461,7 +470,7 @@ export function canonicalModelsToDataset(
(tableOccurrences.length ? tableSheets : supportingSheets).push(projectedSheet); (tableOccurrences.length ? tableSheets : supportingSheets).push(projectedSheet);
} }
for (const file of files) { for (const file of options.summaryOnly ? [] : files) {
const nativeOccurrences = occurrencesByFileId.get(file.id) ?? []; const nativeOccurrences = occurrencesByFileId.get(file.id) ?? [];
if (!nativeOccurrences.length) continue; if (!nativeOccurrences.length) continue;
const headers = headersFor(nativeOccurrences, displayFields); const headers = headersFor(nativeOccurrences, displayFields);
@ -503,10 +512,28 @@ export function canonicalModelsToDataset(
occurrences occurrences
) )
: undefined; : undefined;
for (const sheet of [...(aggregate ? [aggregate] : []), ...tableSheets]) {
for (const record of sheet.analysis.records) {
if (record.findingOccurrenceId) record.referenceRecognition = referenceByOccurrence.get(record.findingOccurrenceId);
}
}
if (aggregate && !occurrences.length) aggregate.analysis.records = []; if (aggregate && !occurrences.length) aggregate.analysis.records = [];
const lastModified = Math.max(...files.map(file => Date.parse(file.lastModifiedAt ?? file.importedAt)), 0); const lastModified = Math.max(...files.map(file => Date.parse(file.lastModifiedAt ?? file.importedAt)), 0);
const sectionSheets = linkedSectionSheets(occurrences); const sectionSheets = options.summaryOnly ? [] : linkedSectionSheets(occurrences);
// The mixed profile chooses status rules from sheet tool identities. Preserve
// those identities without building every detail sheet for a metric request.
const profileSheets = options.summaryOnly
? [...new Set(runs.map(run => run.tool.id))].map(toolId => makeSheet(
`Status profile ${toolId}`, [['Status']], '', project.name, { toolId }
))
: [];
const profileVersions = new Set(files.map(file => file.processedWith.profileVersion).filter(Boolean)); const profileVersions = new Set(files.map(file => file.processedWith.profileVersion).filter(Boolean));
// Each record reads section titles only from its own run's report; the map is shared, not copied.
const benchmarkSectionTitles = cisBenchmarkSectionTitles(models.flatMap(model => model.reportSections ?? []));
if (benchmarkSectionTitles.size) for (const sheet of [...(aggregate ? [aggregate] : []), ...tableSheets]) for (const record of sheet.analysis.records) {
const titles = record.scanRunId ? benchmarkSectionTitles.get(record.scanRunId) : undefined;
if (titles?.size) record.benchmarkSectionTitles = titles;
}
return { return {
fileName: project.name + ' · All Scans', fileName: project.name + ' · All Scans',
@ -514,11 +541,13 @@ export function canonicalModelsToDataset(
lastModified, lastModified,
format: 'Project · ' + tool.name, format: 'Project · ' + tool.name,
rawText: '', rawText: '',
sheets: [...(aggregate ? [aggregate] : []), ...tableSheets, ...supportingSheets, ...sectionSheets], sheets: [...(aggregate ? [aggregate] : []), ...tableSheets, ...supportingSheets, ...sectionSheets, ...profileSheets],
profileId: tool.id === 'unknown' || tool.id === 'multiple' ? 'generic' : tool.id, profileId: tool.id === 'unknown' || tool.id === 'multiple' ? 'generic' : tool.id,
profileVersion: profileVersions.size === 1 ? [...profileVersions][0] : profileVersions.size > 1 ? 'gemischt' : undefined, profileVersion: profileVersions.size === 1 ? [...profileVersions][0] : profileVersions.size > 1 ? 'gemischt' : undefined,
scanTool: tool.name, scanTool: tool.name,
securityScoring: { policy: models[0].assessment.scoringPolicy ?? defaultSecurityScoringPolicy(), scans: canonicalSecurityScores(models) } benchmarkSectionTitles,
securityScoring: { policy: models[0].assessment.scoringPolicy ?? defaultSecurityScoringPolicy(),
scans: options.summaryOnly ? [] : canonicalSecurityScores(models) }
}; };
} }

View File

@ -0,0 +1,79 @@
import type { DatahubTimeslotResultMetrics } from './datahub-scan-analysis';
export interface TimeslotMetricsScope {
revision: number;
projectId: string;
scanRunIds: string[];
assessmentIds: string[];
}
type Metrics = DatahubTimeslotResultMetrics | null;
type Pending = { key: string; scope: TimeslotMetricsScope; promise: Promise<Metrics>; resolve(value: Metrics): void; reject(error: unknown): void };
const keyFor = (scope: TimeslotMetricsScope) => JSON.stringify([
scope.revision, scope.projectId, [...scope.scanRunIds].sort(), [...scope.assessmentIds].sort()
]);
/** Small, revision-bound summaries only; no models, raw fields or persistent browser storage. */
export class DatahubMetricsCache {
private readonly values = new Map<string, Metrics>();
private readonly pending = new Map<string, Pending>();
private queue: Pending[] = [];
private active = 0;
constructor(private readonly fetchMetrics: (scope: TimeslotMetricsScope) => Promise<Metrics>, private readonly capacity = 32) {}
peek(scope: TimeslotMetricsScope): Metrics | undefined {
const key = keyFor(scope);
const value = this.values.get(key);
if (value !== undefined) { this.values.delete(key); this.values.set(key, value); }
return value;
}
load(scope: TimeslotMetricsScope, background = false): Promise<Metrics> {
const cached = this.peek(scope);
if (cached !== undefined) return Promise.resolve(cached);
const key = keyFor(scope);
const existing = this.pending.get(key);
if (existing) {
const index = this.queue.indexOf(existing);
if (!background && index > 0) { this.queue.splice(index, 1); this.queue.unshift(existing); }
return existing.promise;
}
let resolve!: Pending['resolve']; let reject!: Pending['reject'];
const promise = new Promise<Metrics>((done, fail) => { resolve = done; reject = fail; });
const entry = { key, scope, promise, resolve, reject };
this.pending.set(key, entry);
if (background) this.queue.push(entry); else this.queue.unshift(entry);
this.drain();
return promise;
}
prefetch(scope: TimeslotMetricsScope): void {
// Bound speculative work as well as retained results; direct selection can always load.
if (this.queue.length >= this.capacity) return;
void this.load(scope, true).catch(() => undefined);
}
invalidate(): void {
this.values.clear();
this.pending.clear();
for (const entry of this.queue) entry.reject(new Error('Result metrics refreshed.'));
this.queue = [];
}
private drain(): void {
while (this.active < 2 && this.queue.length) {
const entry = this.queue.shift()!;
this.active++;
void Promise.resolve().then(() => this.fetchMetrics(entry.scope)).then(value => {
if (this.pending.get(entry.key) === entry) {
this.values.set(entry.key, value);
while (this.values.size > this.capacity) this.values.delete(this.values.keys().next().value!);
}
entry.resolve(value);
}, entry.reject).finally(() => {
if (this.pending.get(entry.key) === entry) this.pending.delete(entry.key);
this.active--;
this.drain();
});
}
}
}

View File

@ -1,14 +1,45 @@
import type { ImportFileRecord } from '../../domain/canonical-model'; import type { ImportFileRecord } from '../../domain/canonical-model';
import type { GpoBackupStatusRecord } from '../../api/assessor-api-contract'; import type { GpoBackupStatusRecord, ReferenceRecognitionStatus } from '../../api/assessor-api-contract';
import { currentProcessingManifest, processingReadiness, type ProcessingReadinessStatus } from '../../domain/processing-compatibility'; import { currentProcessingManifest, processingReadiness, type ProcessingReadinessStatus } from '../../domain/processing-compatibility';
export type DatahubClientProcessingState = 'ready' | 'processing' | 'update' | 'review' | 'error' | 'no-data'; export type DatahubClientProcessingState = 'ready' | 'checking' | 'processing' | 'update' | 'review' | 'error' | 'no-data';
export type DatahubReferenceReadiness = { state: 'ready'; status: ReferenceRecognitionStatus }
| { state: 'checking' | 'processing' | 'unavailable' | 'error' };
export interface DatahubClientProcessingSummary { export interface DatahubClientProcessingSummary {
state: DatahubClientProcessingState; state: DatahubClientProcessingState;
label: string; label: string;
} }
/** Reference derivation is independent of scanner reimport and GPO evaluation. */
export function withReferenceReadiness(
processing: DatahubClientProcessingSummary,
references?: DatahubReferenceReadiness
): DatahubClientProcessingSummary {
if (!references) return processing;
let reference: DatahubClientProcessingSummary;
switch (references.state) {
case 'ready':
if (!references.status.pendingScans) return processing.state === 'no-data' && references.status.totalScans > 0
? { state: 'ready', label: 'Processing ready across all client timeslots.' } : processing;
reference = { state: 'update', label: `${references.status.pendingScans} ${references.status.pendingScans === 1 ? 'scan requires' : 'scans require'} reference recognition. Open Manage → Catalog references; no file upload needed.` };
break;
case 'error':
reference = { state: 'error', label: 'Reference recognition failed. Open Manage → Catalog references to retry.' };
break;
case 'unavailable':
reference = { state: 'review', label: 'Reference status unavailable. Open Manage → Catalog references to retry the status check.' };
break;
default:
reference = { state: 'checking', label: references.state === 'processing' ? 'Reference recognition in progress.' : 'Checking reference recognition status.' };
}
const priority: Record<DatahubClientProcessingState, number> = { 'no-data': 0, ready: 0, checking: 1, processing: 2, review: 3, update: 4, error: 5 };
if (priority[processing.state] === 0) return reference;
return { state: priority[processing.state] >= priority[reference.state] ? processing.state : reference.state,
label: `${processing.label} ${reference.label}` };
}
const UPDATE_STATES = new Set(['reimport-recommended']); const UPDATE_STATES = new Set(['reimport-recommended']);
const ERROR_STATES = new Set(['incompatible', 'unknown']); const ERROR_STATES = new Set(['incompatible', 'unknown']);
const ATTENTION_STATES = new Set([...UPDATE_STATES, ...ERROR_STATES]); const ATTENTION_STATES = new Set([...UPDATE_STATES, ...ERROR_STATES]);

View File

@ -0,0 +1,23 @@
import type { CanonicalAssessmentModel, ImportFileRecord, ToolIdentity } from '../../domain/canonical-model';
import { PURPLE_KNIGHT_RUN_NAMES } from '../../profiles/purple-knight/run-names';
/** Display-only scope from retained, run-local facts. Never rewrite stored scanner identities. */
export function datahubScanLabel(
tool: ToolIdentity, scanRunId: string, files: ImportFileRecord[], models: CanonicalAssessmentModel[] = []
): string {
if (tool.id !== 'purple-knight') return tool.name;
const knownNames: string[] = Object.values(PURPLE_KNIGHT_RUN_NAMES);
if (knownNames.includes(tool.name)) return tool.name;
const runFiles = files.filter(file => file.scanRunId === scanRunId && file.detectedTool.id === tool.id);
const fileIds = new Set(runFiles.map(file => file.id));
const names = runFiles.map(file => file.detectedTool.name);
const properties = models.flatMap(model => model.environmentObservations ?? [])
.filter(observation => observation.scanRunId === scanRunId && fileIds.has(observation.importFileId) && observation.tool.id === tool.id)
.map(observation => observation.property);
const ad = names.includes(PURPLE_KNIGHT_RUN_NAMES.ad) || names.includes(PURPLE_KNIGHT_RUN_NAMES.combined)
|| properties.some(property => property.startsWith('purple_knight.ad.') || property === 'forest.name');
const entra = names.includes(PURPLE_KNIGHT_RUN_NAMES.entra) || names.includes(PURPLE_KNIGHT_RUN_NAMES.combined)
|| properties.some(property => property.startsWith('purple_knight.entra.') || property === 'entra.tenant_name');
return ad && entra ? PURPLE_KNIGHT_RUN_NAMES.combined : ad ? PURPLE_KNIGHT_RUN_NAMES.ad
: entra ? PURPLE_KNIGHT_RUN_NAMES.entra : tool.name;
}

View File

@ -17,6 +17,7 @@ export interface CanonicalProjectReader {
loadAssessmentModel(assessmentId: string): Promise<CanonicalAssessmentModel>; loadAssessmentModel(assessmentId: string): Promise<CanonicalAssessmentModel>;
queryUnifiedResults?(query?: UnifiedResultQuery): Promise<UnifiedResultQueryResult>; queryUnifiedResults?(query?: UnifiedResultQuery): Promise<UnifiedResultQueryResult>;
listPolicyEvaluations?(projectId: string): Promise<{ records: StoredPolicyEvaluation[] }>; listPolicyEvaluations?(projectId: string): Promise<{ records: StoredPolicyEvaluation[] }>;
listCurrentPolicyEvaluationsForMetrics?(projectId: string): Promise<{ records: StoredPolicyEvaluation[] }>;
getPolicyAnalysisOverview?(projectId: string): Promise<Pick<PolicyAnalysisOverview, 'current'>>; getPolicyAnalysisOverview?(projectId: string): Promise<Pick<PolicyAnalysisOverview, 'current'>>;
listMicrosoftSecurityBaselineAnalyses?(baselineId: string): Promise<{ records: StoredMicrosoftSecurityBaselineAnalysis[] }>; listMicrosoftSecurityBaselineAnalyses?(baselineId: string): Promise<{ records: StoredMicrosoftSecurityBaselineAnalysis[] }>;
} }
@ -27,6 +28,12 @@ export interface CanonicalProjectWorkspace {
assessmentIds: string[]; assessmentIds: string[];
} }
export interface CanonicalWorkspaceLoadOptions {
includeHistoricalScores?: boolean;
includeBaselineGuidance?: boolean;
summaryOnly?: boolean;
}
export function canonicalModelForScanRuns( export function canonicalModelForScanRuns(
model: CanonicalAssessmentModel, model: CanonicalAssessmentModel,
scanRunIds: string[] scanRunIds: string[]
@ -48,6 +55,7 @@ export function canonicalModelForScanRuns(
})); }));
return { return {
...model, ...model,
referenceRuns: model.referenceRuns?.filter(run => selectedIds.has(run.scanRunId)),
scanRuns, scanRuns,
importFiles, importFiles,
sourceTables, sourceTables,
@ -99,7 +107,8 @@ interface PolicyWorkContext {
async function loadPolicyWorkContext( async function loadPolicyWorkContext(
reader: CanonicalProjectReader, reader: CanonicalProjectReader,
projectId: string, projectId: string,
assessmentIds: string[] assessmentIds: string[],
options: CanonicalWorkspaceLoadOptions = {}
): Promise<PolicyWorkContext> { ): Promise<PolicyWorkContext> {
const policyObservations = reader.queryUnifiedResults && assessmentIds.length const policyObservations = reader.queryUnifiedResults && assessmentIds.length
? await queryAllUnifiedResults( ? await queryAllUnifiedResults(
@ -107,10 +116,13 @@ async function loadPolicyWorkContext(
{ audience: 'internal-analysis', projectId, assessmentIds, sourceKind: ['policy-observation'] } { audience: 'internal-analysis', projectId, assessmentIds, sourceKind: ['policy-observation'] }
) )
: []; : [];
const currentMetricEvaluations = options.summaryOnly && reader.listCurrentPolicyEvaluationsForMetrics;
const [evaluationList, policyOverview] = policyObservations.length && reader.listPolicyEvaluations const [evaluationList, policyOverview] = policyObservations.length && reader.listPolicyEvaluations
? await Promise.all([ ? await Promise.all([
reader.listPolicyEvaluations(projectId), currentMetricEvaluations
reader.getPolicyAnalysisOverview?.(projectId) ? currentMetricEvaluations(projectId)
: reader.listPolicyEvaluations(projectId),
currentMetricEvaluations ? undefined : reader.getPolicyAnalysisOverview?.(projectId)
]) ])
: []; : [];
const evaluations = currentPolicyEvaluationsForWork( const evaluations = currentPolicyEvaluationsForWork(
@ -122,7 +134,7 @@ async function loadPolicyWorkContext(
// One request per referenced baseline with bounded concurrency. Each baseline writes // One request per referenced baseline with bounded concurrency. Each baseline writes
// only its own result IDs, so completion order does not change the guidance. Optional // only its own result IDs, so completion order does not change the guidance. Optional
// documentation must not prevent opening otherwise usable assessment results. // documentation must not prevent opening otherwise usable assessment results.
if (reader.listMicrosoftSecurityBaselineAnalyses) { if (options.includeBaselineGuidance !== false && reader.listMicrosoftSecurityBaselineAnalyses) {
const byBaseline = new Map<string, StoredPolicyEvaluation[]>(); const byBaseline = new Map<string, StoredPolicyEvaluation[]>();
for (const evaluation of evaluations) for (const baselineId of new Set(evaluation.results.map(result => result.baselineId))) { for (const evaluation of evaluations) for (const baselineId of new Set(evaluation.results.map(result => result.baselineId))) {
byBaseline.set(baselineId, [...(byBaseline.get(baselineId) ?? []), { byBaseline.set(baselineId, [...(byBaseline.get(baselineId) ?? []), {
@ -154,7 +166,8 @@ export async function loadCanonicalProjectWorkspace(
reader: CanonicalProjectReader, reader: CanonicalProjectReader,
projectId: string, projectId: string,
scanRunIds?: string[], scanRunIds?: string[],
selectedAssessmentIds?: string[] selectedAssessmentIds?: string[],
options: CanonicalWorkspaceLoadOptions = {}
): Promise<CanonicalProjectWorkspace> { ): Promise<CanonicalProjectWorkspace> {
const assessments = await reader.listProjectAssessments(projectId); const assessments = await reader.listProjectAssessments(projectId);
const selectedAssessments = selectedAssessmentIds const selectedAssessments = selectedAssessmentIds
@ -163,7 +176,7 @@ export async function loadCanonicalProjectWorkspace(
// With an explicit assessment scope the policy chain needs no scanner model, so // With an explicit assessment scope the policy chain needs no scanner model, so
// it runs alongside the model load instead of after it. // it runs alongside the model load instead of after it.
const earlyPolicyContext = selectedAssessmentIds const earlyPolicyContext = selectedAssessmentIds
? loadPolicyWorkContext(reader, projectId, [...new Set(selectedAssessments.map(assessment => assessment.id))]) ? loadPolicyWorkContext(reader, projectId, [...new Set(selectedAssessments.map(assessment => assessment.id))], options)
: undefined; : undefined;
// Attach a handler so a model failure first cannot leave this rejection unhandled; // Attach a handler so a model failure first cannot leave this rejection unhandled;
// awaiting the promise below still reports its own failure. // awaiting the promise below still reports its own failure.
@ -181,10 +194,10 @@ export async function loadCanonicalProjectWorkspace(
const assessmentIds = [...new Set((selectedAssessmentIds const assessmentIds = [...new Set((selectedAssessmentIds
? selectedAssessments ? selectedAssessments
: models).map(item => 'assessment' in item ? item.assessment.id : item.id))]; : models).map(item => 'assessment' in item ? item.assessment.id : item.id))];
const policyContext = earlyPolicyContext ?? loadPolicyWorkContext(reader, projectId, assessmentIds); const policyContext = earlyPolicyContext ?? loadPolicyWorkContext(reader, projectId, assessmentIds, options);
void policyContext.catch(() => undefined); void policyContext.catch(() => undefined);
const scannerDataset = canonicalModelsToDataset(models, loadedModels); const scannerDataset = canonicalModelsToDataset(models, loadedModels, { summaryOnly: options.summaryOnly });
if (scannerDataset?.securityScoring) { if (scannerDataset?.securityScoring && options.includeHistoricalScores !== false) {
const currentIds = new Set(assessmentIds); const currentIds = new Set(assessmentIds);
const currentDates = models.map(model => Date.parse(model.assessment.effectiveAt ?? model.assessment.createdAt)).filter(Number.isFinite); const currentDates = models.map(model => Date.parse(model.assessment.effectiveAt ?? model.assessment.createdAt)).filter(Number.isFinite);
const cutoff = currentDates.length ? Math.min(...currentDates) : undefined; const cutoff = currentDates.length ? Math.min(...currentDates) : undefined;

View File

@ -1,4 +1,5 @@
import type { ImportedDataset } from '../../domain/dataset'; import type { ImportedDataset } from '../../domain/dataset';
import type { FindingRecord } from '../../domain/finding';
import type { UnifiedResultProjection } from '../../domain/unified-result'; import type { UnifiedResultProjection } from '../../domain/unified-result';
import { POLICY_EVALUATION_ENGINE, type StoredPolicyEvaluation } from '../../domain/policy-evaluation'; import { POLICY_EVALUATION_ENGINE, type StoredPolicyEvaluation } from '../../domain/policy-evaluation';
import { buildPolicyWorkResults } from '../../evaluation/policy-work-results'; import { buildPolicyWorkResults } from '../../evaluation/policy-work-results';
@ -44,6 +45,20 @@ function notComparableSummary(sources: UnifiedResultProjection[]): string {
].filter(Boolean).join('. ') + '.'; ].filter(Boolean).join('. ') + '.';
} }
/**
* A scanner record carried into the rebuilt project sheet. Only what the extended row layout recomputes
* comes from the rebuilt record (identity, position, field values and their indexes); every other
* property - provenance, recognized references and any future record metadata - stays the original's.
*/
export function withRebuiltLayout(original: FindingRecord, rebuilt: FindingRecord): FindingRecord {
const merged: FindingRecord = { ...original, id: rebuilt.id, index: rebuilt.index, title: rebuilt.title,
fields: rebuilt.fields, filled: rebuilt.filled, meta: rebuilt.meta };
if (rebuilt.sourceFile === undefined) delete merged.sourceFile; else merged.sourceFile = rebuilt.sourceFile;
if (rebuilt.sourceSheet === undefined) delete merged.sourceSheet; else merged.sourceSheet = rebuilt.sourceSheet;
if (rebuilt.highlighted === undefined) delete merged.highlighted; else merged.highlighted = rebuilt.highlighted;
return merged;
}
export function appendPolicyObservationsToDataset( export function appendPolicyObservationsToDataset(
dataset: ImportedDataset | null, dataset: ImportedDataset | null,
projectName: string, projectName: string,
@ -87,6 +102,14 @@ export function appendPolicyObservationsToDataset(
failingBackupSources: policyBackupImpactSources(entry), failingBackupSources: policyBackupImpactSources(entry),
sourceEvidence: policySourceEvidence(entry.sources, sourceEvidence: policySourceEvidence(entry.sources,
previousVersion ? undefined : comparison?.baselineVariantAssessment), previousVersion ? undefined : comparison?.baselineVariantAssessment),
baselineComparisons: (() => {
const retained = comparison ? guidance.get(comparison.id) : undefined;
const variants = retained?.variantComparison;
// Historical or unassessed comparisons keep only their full stored text.
if (!comparison?.baselineVariantAssessment || previousVersion || !variants) return undefined;
return [{ baseline: comparison.baselineLabel, status: entry.status,
summary: baselineVariantSummary(comparison.baselineVariantAssessment), remediation: retained.remediation, ...variants }];
})(),
row: [ row: [
entry.title, 'GPOs', previousVersion ? 'Review required' : entry.status, entry.scope, display(configured), entry.title, 'GPOs', previousVersion ? 'Review required' : entry.status, entry.scope, display(configured),
comparison?.comparator.baseline ? display(comparison.comparator.baseline) comparison?.comparator.baseline ? display(comparison.comparator.baseline)
@ -141,6 +164,7 @@ export function appendPolicyObservationsToDataset(
record.policyFailingBackupSources = policyEntries[index].failingBackupSources; record.policyFailingBackupSources = policyEntries[index].failingBackupSources;
record.policySourceEvidence = policyEntries[index].sourceEvidence; record.policySourceEvidence = policyEntries[index].sourceEvidence;
record.policyBaselineLabels = policyEntries[index].baselineLabels; record.policyBaselineLabels = policyEntries[index].baselineLabels;
record.policyBaselineComparisons = policyEntries[index].baselineComparisons;
if (policyEntries[index].reviewMembers) { if (policyEntries[index].reviewMembers) {
record.id = `gpo-work-setting:${encodeURIComponent(policyEntries[index].entry.settingKey!)}`; record.id = `gpo-work-setting:${encodeURIComponent(policyEntries[index].entry.settingKey!)}`;
record.policyReviewMembers = policyEntries[index].reviewMembers!.map(member => ({ baseline: member.baseline, record.policyReviewMembers = policyEntries[index].reviewMembers!.map(member => ({ baseline: member.baseline,
@ -202,14 +226,12 @@ export function appendPolicyObservationsToDataset(
}) }); }) });
} }
} }
return original ? { ...record, findingOccurrenceId: original.findingOccurrenceId, return original ? withRebuiltLayout(original, record)
findingDefinitionId: original.findingDefinitionId, scanRunId: original.scanRunId, importFileId: original.importFileId,
policyFailingBackupSources: original.policyFailingBackupSources, policySourceEvidence: original.policySourceEvidence,
policyBaselineLabels: original.policyBaselineLabels, policyReviewMembers: original.policyReviewMembers }
: { ...record, ...(policyRecord?.policyReviewMembers ? { id: policyRecord.id, policyReviewMembers: policyRecord.policyReviewMembers } : {}), : { ...record, ...(policyRecord?.policyReviewMembers ? { id: policyRecord.id, policyReviewMembers: policyRecord.policyReviewMembers } : {}),
policyFailingBackupSources: policyEntries[index - first.analysis.records.length]?.failingBackupSources, policyFailingBackupSources: policyEntries[index - first.analysis.records.length]?.failingBackupSources,
policySourceEvidence: policyEntries[index - first.analysis.records.length]?.sourceEvidence, policySourceEvidence: policyEntries[index - first.analysis.records.length]?.sourceEvidence,
policyBaselineLabels: policyEntries[index - first.analysis.records.length]?.baselineLabels }; policyBaselineLabels: policyEntries[index - first.analysis.records.length]?.baselineLabels,
policyBaselineComparisons: policyEntries[index - first.analysis.records.length]?.baselineComparisons };
}); });
return combined; return combined;
})() : first; })() : first;

View File

@ -15,6 +15,7 @@ export interface PolicyWorkRow {
baselineLabels?: string[]; baselineLabels?: string[];
/** Original ADML explanation of this comparison's baseline, used once in the rolled-up description. */ /** Original ADML explanation of this comparison's baseline, used once in the rolled-up description. */
explanation?: string; explanation?: string;
baselineComparisons?: NonNullable<FindingRecord['policyBaselineComparisons']>;
reviewMembers?: Array<{ baseline: string; row: string[] }>; reviewMembers?: Array<{ baseline: string; row: string[] }>;
row: string[]; row: string[];
} }
@ -76,6 +77,7 @@ export function rollupPolicyWorkRows(rows: PolicyWorkRow[], headers: string[]):
return [explanation, `Result across ${packageCount} baseline packages: ${status}`, ...verdicts, ...scopes] return [explanation, `Result across ${packageCount} baseline packages: ${status}`, ...verdicts, ...scopes]
.filter(Boolean).join('\n\n'); .filter(Boolean).join('\n\n');
})() : undefined; })() : undefined;
const completeStoredComparison = group.every(item => item.entry.comparison?.identity.target.kind === 'security-template');
const merged = headers.map(header => { const merged = headers.map(header => {
const values = unique(group.map(item => value(item, header))); const values = unique(group.map(item => value(item, header)));
if (header === 'Status') return status; if (header === 'Status') return status;
@ -89,7 +91,9 @@ export function rollupPolicyWorkRows(rows: PolicyWorkRow[], headers: string[]):
if (header === 'Baseline') return baselineLabels.join('\n'); if (header === 'Baseline') return baselineLabels.join('\n');
if (header === 'Comparison') return `Overall verdict: ${status} · ${packageCount} baseline packages (` if (header === 'Comparison') return `Overall verdict: ${status} · ${packageCount} baseline packages (`
+ verdictGroups.map(([verdict, items]) => `${items.length} ${verdict}`).join(', ') + ')'; + verdictGroups.map(([verdict, items]) => `${items.length} ${verdict}`).join(', ') + ')';
if (header === 'Description') return variantDescription ?? byBaseline(header); if (header === 'Description') return completeStoredComparison && group.every(item => item.baselineComparisons?.length)
? byBaseline(header)
: variantDescription ?? byBaseline(header);
if (header === 'Baseline value' && values.length && (values.length > 1 || group.some(item => !value(item, header)))) { if (header === 'Baseline value' && values.length && (values.length > 1 || group.some(item => !value(item, header)))) {
// Retain each typed value per baseline, never synthesize a consensus requirement. // Retain each typed value per baseline, never synthesize a consensus requirement.
return group.map(item => { return group.map(item => {
@ -102,7 +106,10 @@ export function rollupPolicyWorkRows(rows: PolicyWorkRow[], headers: string[]):
} }
if (header === 'Remediation' && !values.length) return ''; if (header === 'Remediation' && !values.length) return '';
if (header === 'Remediation' && (values.length > 1 || group.some(item => !value(item, header)))) { if (header === 'Remediation' && (values.length > 1 || group.some(item => !value(item, header)))) {
// Identical concrete recommendations are stated once with the baselines that share them. if (completeStoredComparison) {
return 'Establish the applicable baseline template before changing this setting. The recommendations below are separate comparisons.\n\n'
+ byBaseline(header);
}
const concrete = new Map<string, string[]>(); const concrete = new Map<string, string[]>();
for (const item of group) { for (const item of group) {
const text = value(item, header); const text = value(item, header);
@ -116,7 +123,10 @@ export function rollupPolicyWorkRows(rows: PolicyWorkRow[], headers: string[]):
if (header === 'Configured Value' && values.length > 1) return JSON.stringify(sources.flatMap(source => source.policyValues ?? [])); if (header === 'Configured Value' && values.length > 1) return JSON.stringify(sources.flatMap(source => source.policyValues ?? []));
return values.join('\n\n'); return values.join('\n\n');
}); });
return { ...first, row: merged, baselineLabels, // Compact reading only when every member comparison is current and structured.
const baselineComparisons = group.every(item => item.baselineComparisons?.length)
? group.flatMap(item => item.baselineComparisons!) : undefined;
return { ...first, row: merged, baselineLabels, baselineComparisons,
reviewMembers: group.map(item => ({ baseline: item.entry.comparison?.baselineLabel ?? 'Comparison', row: item.row })), reviewMembers: group.map(item => ({ baseline: item.entry.comparison?.baselineLabel ?? 'Comparison', row: item.row })),
importedAt, importedAt,
sourceEvidence: policySourceEvidence(sources, evidenceAssessment), sourceEvidence: policySourceEvidence(sources, evidenceAssessment),

View File

@ -0,0 +1,43 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client';
import type { AssessmentProject } from '../../domain/canonical-model';
import type { DatahubReferenceReadiness } from './datahub-processing-readiness';
const api = new AssessorApiClient();
/** Load only status summaries, with bounded concurrency and no background polling. */
export function useReferenceRecognitionStatus(projects: AssessmentProject[], connected: boolean, refreshKey: number) {
const [states, setStates] = useState<Record<string, DatahubReferenceReadiness>>({});
const pending = useRef(new Map<string, symbol>());
const reportStatus = useCallback((projectId: string, state: DatahubReferenceReadiness) => {
// A panel result must win over an older overview request for the same client.
pending.current.delete(projectId);
setStates(current => ({ ...current, [projectId]: state }));
}, []);
useEffect(() => {
let cancelled = false;
let next = 0;
pending.current.clear();
const queue = connected ? projects.map(project => ({ ...project, token: Symbol() })) : [];
for (const project of queue) pending.current.set(project.id, project.token);
setStates(Object.fromEntries(queue.map(project => [project.id, { state: 'checking' }])));
const loadNext = async () => {
while (!cancelled && next < queue.length) {
const { id: projectId, token } = queue[next++];
if (pending.current.get(projectId) !== token) continue;
let state: DatahubReferenceReadiness;
try { state = { state: 'ready', status: await api.getReferenceRecognitionStatus(projectId) }; }
catch { state = { state: 'unavailable' }; }
if (!cancelled && pending.current.get(projectId) === token) {
pending.current.delete(projectId);
setStates(current => ({ ...current, [projectId]: state }));
}
}
};
for (let worker = 0; worker < Math.min(3, queue.length); worker++) void loadNext();
return () => { cancelled = true; };
}, [projects, connected, refreshKey]);
return { states, reportStatus };
}

View File

@ -0,0 +1,46 @@
import type { DatasetSheet, ImportedDataset } from '../../domain/dataset';
import type { ImportProfile } from '../../domain/import-profile';
import { genericProfile } from '../../profiles/generic/generic-profile';
import { importProfiles } from '../../profiles/profile-catalog';
const mixedProfileCache = new WeakMap<ImportedDataset, ImportProfile>();
function mixedProfileFor(dataset: ImportedDataset): ImportProfile {
const cached = mixedProfileCache.get(dataset);
if (cached) return cached;
const toolProfiles = [...new Set(dataset.sheets.map(sheet => sheet.toolId).filter((id): id is string => Boolean(id)))]
.map(id => importProfiles.find(profile => profile.id === id))
.filter((profile): profile is ImportProfile => Boolean(profile && profile.id !== 'generic'));
const hasPolicyResults = dataset.sheets.some(sheet => sheet.toolId === 'group-policy');
if (toolProfiles.length < 2 && !hasPolicyResults) return toolProfiles[0] ?? genericProfile;
const statusProfiles = [genericProfile, ...toolProfiles];
const statusFilters = [...new Map(statusProfiles.flatMap(profile => profile.presentation.statusFilters)
.map(filter => [filter.value, filter])).values()];
const mixed: ImportProfile = {
...genericProfile,
id: 'multiple',
name: 'Multiple Tools',
version: toolProfiles.map(profile => `${profile.id}@${profile.version}`).sort().join('+'),
matchesStatus: (record, status) => statusProfiles
.filter(profile => profile.presentation.statusFilters.some(filter => filter.value === status))
.some(profile => profile.matchesStatus(record, status)),
presentation: { ...genericProfile.presentation, statusFilters }
};
mixedProfileCache.set(dataset, mixed);
return mixed;
}
export function profileForDataset(dataset: ImportedDataset | null): ImportProfile {
if (!dataset) return genericProfile;
if (dataset.profileId === 'generic' && new Set(dataset.sheets.map(sheet => sheet.toolId).filter(Boolean)).size > 1) {
return mixedProfileFor(dataset);
}
return importProfiles.find(profile => profile.id === dataset.profileId) ?? genericProfile;
}
export function profileForSheet(dataset: ImportedDataset | null, sheet?: DatasetSheet): ImportProfile {
return sheet?.toolId
? importProfiles.find(profile => profile.id === sheet.toolId) ?? genericProfile
: profileForDataset(dataset);
}

View File

@ -82,7 +82,8 @@ export function SearchFilterBar({ hiddenFacetLabels = [] }: SearchFilterBarProps
pathFindingIds, pathFindingIds,
setFacetFilter, setFacetFilter,
resetFilters, resetFilters,
actionOnly actionOnly,
setActionOnly
} = app; } = app;
const countedFacets = useMemo( const countedFacets = useMemo(
@ -97,15 +98,19 @@ export function SearchFilterBar({ hiddenFacetLabels = [] }: SearchFilterBarProps
); );
const defaultStatusFilter = activeProfile.defaults(activeSheet).statusFilter; const defaultStatusFilter = activeProfile.defaults(activeSheet).statusFilter;
const handleFacetFilter = useCallback<typeof setFacetFilter>((facetId, filter) => { const handleFacetFilter = useCallback<typeof setFacetFilter>((facetId, filter) => {
if (facetId === 'provenance:source-scan') {
setActionOnly(false);
resetFilters();
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
if (targetSheet) setActiveSheet(targetSheet.id);
setFacetFilter(facetId, filter);
return;
}
// This explicit provenance drilldown includes retained historical Fail comparisons, // This explicit provenance drilldown includes retained historical Fail comparisons,
// which correctly keep their visible Review required status in Work. // which correctly keep their visible Review required status in Work.
if (facetId === GPO_FAILING_BACKUP_FACET_ID && filter?.values?.length) setStatusFilter('all'); if (facetId === GPO_FAILING_BACKUP_FACET_ID && filter?.values?.length) setStatusFilter('all');
if (facetId === 'provenance:source-scan' && filter?.values?.length) {
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
if (targetSheet) setActiveSheet(targetSheet.id);
}
setFacetFilter(facetId, filter); setFacetFilter(facetId, filter);
}, [activeSheet, dataset, setActiveSheet, setFacetFilter, setStatusFilter]); }, [activeSheet, dataset, resetFilters, setActionOnly, setActiveSheet, setFacetFilter, setStatusFilter]);
useEffect(() => { useEffect(() => {
const contextualFacetIds = new Set(countedFacets.map(facet => facet.id)); const contextualFacetIds = new Set(countedFacets.map(facet => facet.id));

View File

@ -9,6 +9,7 @@ import { SEVERITY_LABEL, isSeverityHeader, normalizeSeverityValue, severityRank
import { clean, normalizeNumericValue } from '../../shared/text'; import { clean, normalizeNumericValue } from '../../shared/text';
import { sourceFileLabel } from '../../shared/source-file-label'; import { sourceFileLabel } from '../../shared/source-file-label';
import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy'; import { cisBenchmarkCategories } from '../../profiles/cis-cat/cis-benchmark-hierarchy';
import { cisBenchmarkSectionTitles, cisCategoryTitle, compareCisCategories } from '../../profiles/cis-cat/cis-benchmark-section-titles';
import { createSheetDerivationCache } from '../../shared/sheet-derivation-cache'; import { createSheetDerivationCache } from '../../shared/sheet-derivation-cache';
import { toolIdForRecord } from '../../importers/status-normalization'; import { toolIdForRecord } from '../../importers/status-normalization';
import { SECURITY_DOMAIN_FACET_ID, securityDomainCatalog, securityDomainIdForRecord, unclassifiedSecurityDomain } from '../dashboard/security-domains'; import { SECURITY_DOMAIN_FACET_ID, securityDomainCatalog, securityDomainIdForRecord, unclassifiedSecurityDomain } from '../dashboard/security-domains';
@ -482,7 +483,19 @@ function getStaticFacets(
const profileFacet = profileStatusFacet(sheet, profile, sheet.toolId === 'group-policy' const profileFacet = profileStatusFacet(sheet, profile, sheet.toolId === 'group-policy'
|| Boolean(dataset?.sheets?.some(item => item.toolId === 'group-policy'))); || Boolean(dataset?.sheets?.some(item => item.toolId === 'group-policy')));
const cisCategoryValues = sheet.analysis.records.flatMap(record => cisBenchmarkCategories(record, profile.id)); const cisCategoryValues = sheet.analysis.records.flatMap(record => cisBenchmarkCategories(record, profile.id));
const cisCategoryFacetValues = categoricalValues(cisCategoryValues); // Section titles come only from the report of each record's own scan run; the value stays the number.
const sectionTitles = dataset?.benchmarkSectionTitles ?? cisBenchmarkSectionTitles(dataset?.extraction?.reportSections);
const runsByCategory = new Map<string, Set<string>>();
if (sectionTitles.size) sheet.analysis.records.forEach(record => cisBenchmarkCategories(record, profile.id).forEach(category => {
const runs = runsByCategory.get(category) ?? new Set<string>();
runsByCategory.set(category, runs.add(record.scanRunId ?? ''));
}));
const cisCategoryFacetValues = categoricalValues(cisCategoryValues)
.map(item => {
const title = cisCategoryTitle(sectionTitles, item.label, runsByCategory.get(item.label) ?? []);
return title ? { ...item, label: `${item.label} · ${title}` } : item;
})
.sort((left, right) => compareCisCategories(left.label, right.label));
const cisCategories = cisCategoryFacetValues.length const cisCategories = cisCategoryFacetValues.length
? { ? {
id: 'derived:cis-benchmark-category', id: 'derived:cis-benchmark-category',

View File

@ -23,6 +23,7 @@ import { normalizeStatusDimensionsFor } from './status-normalization';
import { defaultSecurityScoringPolicy } from '../domain/security-scoring'; import { defaultSecurityScoringPolicy } from '../domain/security-scoring';
import { processingManifest } from '../domain/processing-compatibility'; import { processingManifest } from '../domain/processing-compatibility';
import { escapeJsonUnicodeNulls } from '../shared/json-text'; import { escapeJsonUnicodeNulls } from '../shared/json-text';
import { PURPLE_KNIGHT_RUN_NAMES } from '../profiles/purple-knight/run-names';
export interface CanonicalImportBatch { export interface CanonicalImportBatch {
sessionId: string; sessionId: string;
@ -86,7 +87,7 @@ function toolFor(entry: ImportPreviewEntry): ToolIdentity {
return { return {
id, id,
name: id === 'purple-knight' && purpleKnightScope name: id === 'purple-knight' && purpleKnightScope
? purpleKnightScope === 'entra' ? 'Purple Knight for Entra ID' : 'Purple Knight for Active Directory' ? purpleKnightScope === 'entra' ? PURPLE_KNIGHT_RUN_NAMES.entra : PURPLE_KNIGHT_RUN_NAMES.ad
: detectedName, : detectedName,
...(typeof detectedVersion === 'string' && detectedVersion.trim() ? { version: detectedVersion.trim() } : {}) ...(typeof detectedVersion === 'string' && detectedVersion.trim() ? { version: detectedVersion.trim() } : {})
}; };
@ -97,9 +98,9 @@ function purpleKnightRunTool(entries: ImportPreviewEntry[], fallback: ToolIdenti
const properties = entries.flatMap(entry => entry.dataset?.extraction?.environmentObservations?.map(item => item.property) ?? []); const properties = entries.flatMap(entry => entry.dataset?.extraction?.environmentObservations?.map(item => item.property) ?? []);
const entra = properties.some(property => property.startsWith('purple_knight.entra.') || property === 'entra.tenant_name'); const entra = properties.some(property => property.startsWith('purple_knight.entra.') || property === 'entra.tenant_name');
const ad = properties.some(property => property.startsWith('purple_knight.ad.') || property === 'forest.name'); const ad = properties.some(property => property.startsWith('purple_knight.ad.') || property === 'forest.name');
if (entra && ad) return { ...fallback, name: 'Purple Knight for Active Directory and Entra ID' }; if (entra && ad) return { ...fallback, name: PURPLE_KNIGHT_RUN_NAMES.combined };
if (entra) return { ...fallback, name: 'Purple Knight for Entra ID' }; if (entra) return { ...fallback, name: PURPLE_KNIGHT_RUN_NAMES.entra };
if (ad) return { ...fallback, name: 'Purple Knight for Active Directory' }; if (ad) return { ...fallback, name: PURPLE_KNIGHT_RUN_NAMES.ad };
return fallback; return fallback;
} }
@ -327,6 +328,8 @@ export function createCanonicalImportBatch(
state: section.state, state: section.state,
prose: section.prose, prose: section.prose,
tables: section.tables, tables: section.tables,
detailLinks: section.detailLinks,
modalDetails: section.modalDetails,
source: { source: {
rowIndex: section.source.rowIndex, rowIndex: section.source.rowIndex,
columnIndex: section.source.columnIndex, columnIndex: section.source.columnIndex,

View File

@ -244,7 +244,7 @@ function referenceLabelAt(markup: string, markerEnd: number, fallback: string):
return label || fallback; return label || fallback;
} }
function internalReferences(markup: string): InternalReference[] { function internalReferences(markup: string, deduplicate = true): InternalReference[] {
const references: InternalReference[] = []; const references: InternalReference[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
const pattern = /<i\s+data-pingcastle-target="([a-zA-Z0-9_.:-]+)"\s+data-pingcastle-relation="(anchor|collapse|modal)"\s*>\s*<\/i\s*>/gi; const pattern = /<i\s+data-pingcastle-target="([a-zA-Z0-9_.:-]+)"\s+data-pingcastle-relation="(anchor|collapse|modal)"\s*>\s*<\/i\s*>/gi;
@ -253,7 +253,7 @@ function internalReferences(markup: string): InternalReference[] {
const targetId = match[1]; const targetId = match[1];
const relation = match[2].toLocaleLowerCase('en-US') as InternalReference['relation']; const relation = match[2].toLocaleLowerCase('en-US') as InternalReference['relation'];
const identity = relation + '|' + targetId; const identity = relation + '|' + targetId;
if (seen.has(identity)) continue; if (deduplicate && seen.has(identity)) continue;
seen.add(identity); seen.add(identity);
references.push({ references.push({
index: match.index, index: match.index,
@ -641,7 +641,7 @@ function htmlCandidates(document: DecodedSourceDocument): {
message: 'Der PingCastle-Bericht enthält mehr als ' + maximumRuleCandidates + ' Regelblöcke; die Extraktion wurde begrenzt.', message: 'Der PingCastle-Bericht enthält mehr als ' + maximumRuleCandidates + ' Regelblöcke; die Extraktion wurde begrenzt.',
stage: 'extraction' stage: 'extraction'
}); });
const sections = reportSections(document); const sections = reportSections(document, markup);
const coverage = pingCastleHtmlRuleCoverage(document.file.name, markup, candidates); const coverage = pingCastleHtmlRuleCoverage(document.file.name, markup, candidates);
// Coverage is source metadata, not another set of findings or environment assets. // Coverage is source metadata, not another set of findings or environment assets.
if (coverage.section) sections.push({ ...coverage.section, ordinal: sections.length }); if (coverage.section) sections.push({ ...coverage.section, ordinal: sections.length });
@ -709,12 +709,38 @@ function sectionState(prose: string, tables: ScannerReportSectionCandidate['tabl
return 'empty'; return 'empty';
} }
function reportSections(document: DecodedSourceDocument): ScannerReportSectionCandidate[] { function reportSections(document: DecodedSourceDocument, markup: string): ScannerReportSectionCandidate[] {
const markup = sanitizeHtmlText(document.rawText).text;
const elementIndex = indexHtmlElements(markup); const elementIndex = indexHtmlElements(markup);
const sections = sectionSpans(markup, false, elementIndex); const sections = sectionSpans(markup, false, elementIndex);
const allSections = sectionSpans(markup, true, elementIndex); const allSections = sectionSpans(markup, true, elementIndex);
const tableSpans = findHtmlTableSpans(markup); const tableSpans = findHtmlTableSpans(markup);
const targetSpans = [...elementIndex.byId.entries()].flatMap(([id, element]) => {
const opening = markup.slice(element.start, element.bodyStart);
const className = attributeValue(opening, 'class');
const presentation: 'modal' | 'linked-inline' | undefined = /(?:^|\s)modal(?:\s|$)/i.test(className) ? 'modal'
: /(?:^|\s)collapse(?:\s|$)/i.test(className) && !/^panel/i.test(id) ? 'linked-inline' : undefined;
const span = presentation && elementSpanById(markup, id, elementIndex);
return span ? [{ id, presentation, span, title: targetMarkup(markup, id, elementIndex)?.label }] : [];
});
const tableMetadata = (span: (typeof tableSpans)[number]) => {
const container = targetSpans.filter(target => target.span.start < span.start && target.span.end >= span.end)
.sort((left, right) => (left.span.end - left.span.start) - (right.span.end - right.span.start))[0];
const body = markup.slice(span.bodyStart, span.bodyEnd);
const rowMarkup = [...body.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr\s*>/gi)]
.map(match => match[1]).filter(row => /<td\b/i.test(row));
const cellLinks = rowMarkup.flatMap((row, rowIndex) =>
[...row.matchAll(/<td\b[^>]*>([\s\S]*?)<\/td\s*>/gi)].flatMap((cell, columnIndex) => {
const reference = internalReferences(cell[1])[0];
return reference && targetSpans.some(target => target.id === reference.targetId
&& target.presentation === (reference.relation === 'modal' ? 'modal' : 'linked-inline'))
? [{ rowIndex, columnIndex, targetId: reference.targetId }] : [];
}));
return {
...(container ? { presentation: container.presentation, targetId: container.id,
...(container.presentation === 'modal' ? { title: container.title } : {}) } : { presentation: 'inline' as const }),
...(cellLinks.length ? { cellLinks } : {})
};
};
const rootTableIndexes = tableSpans const rootTableIndexes = tableSpans
.map((span, index) => ({ span, index })) .map((span, index) => ({ span, index }))
.filter(({ span }) => !allSections.some(section => span.start >= section.start && span.end <= section.end)) .filter(({ span }) => !allSections.some(section => span.start >= section.start && span.end <= section.end))
@ -723,7 +749,8 @@ function reportSections(document: DecodedSourceDocument): ScannerReportSectionCa
const rootTables = rootTableIndexes.map(index => { const rootTables = rootTableIndexes.map(index => {
const table = document.tables[index]; const table = document.tables[index];
const matrix = tableMatrix(table); const matrix = tableMatrix(table);
return { name: table.name, sourceTableIndex: table.index, headers: matrix.headers, rows: matrix.rows }; return { name: table.name, sourceTableIndex: table.index, headers: matrix.headers, rows: matrix.rows,
...tableMetadata(tableSpans[index]) };
}); });
const root: ScannerReportSectionCandidate = { const root: ScannerReportSectionCandidate = {
version: 1, sectionKey: 'report-overview', label: 'Report overview', ordinal: 0, version: 1, sectionKey: 'report-overview', label: 'Report overview', ordinal: 0,
@ -740,7 +767,31 @@ function reportSections(document: DecodedSourceDocument): ScannerReportSectionCa
end: child.end - section.bodyStart end: child.end - section.bodyStart
}))); })));
const label = markupText(/<h[1-6]\b[^>]*>([\s\S]*?)<\/h[1-6]\s*>/i.exec(ownMarkup)?.[1] ?? '') || section.sectionKey; const label = markupText(/<h[1-6]\b[^>]*>([\s\S]*?)<\/h[1-6]\s*>/i.exec(ownMarkup)?.[1] ?? '') || section.sectionKey;
const prose = proseText(ownMarkup.replace(/<h[1-6]\b[^>]*>[\s\S]*?<\/h[1-6]\s*>/gi, ' ')).trim(); const sectionTargets = targetSpans.filter(target => target.span.start >= section.bodyStart && target.span.end <= section.bodyEnd);
const references = internalReferences(ownMarkup, false);
const detailLinks = references.filter(reference => reference.relation === 'collapse'
&& sectionTargets.some(target => target.id === reference.targetId && target.presentation === 'linked-inline'))
.filter(reference => {
const absolute = section.bodyStart + reference.index;
return !tableSpans.some(table => absolute >= table.start && absolute < table.end)
&& !sectionTargets.some(target => absolute >= target.span.start && absolute < target.span.end);
}).map(reference => {
const target = sectionTargets.find(candidate => candidate.id === reference.targetId)!;
const targetBody = markup.slice(target.span.bodyStart, target.span.bodyEnd);
return { targetId: reference.targetId, label: reference.label,
prose: proseText(targetBody).trim() || undefined };
});
references.filter(reference => reference.relation === 'collapse'
&& directChildren.some(child => child.sectionKey === reference.targetId)
&& !tableSpans.some(table => section.bodyStart + reference.index >= table.start
&& section.bodyStart + reference.index < table.end)
&& !detailLinks.some(link => link.targetId === reference.targetId))
.forEach(reference => detailLinks.push({ targetId: reference.targetId, label: reference.label, prose: undefined }));
const hiddenRanges = sectionTargets.map(target => ({ ...target.span,
start: target.span.start - section.bodyStart, bodyStart: target.span.bodyStart - section.bodyStart,
bodyEnd: target.span.bodyEnd - section.bodyStart, end: target.span.end - section.bodyStart }));
const prose = proseText(maskRanges(ownMarkup, hiddenRanges)
.replace(/<h[1-6]\b[^>]*>[\s\S]*?<\/h[1-6]\s*>/gi, ' ')).trim();
const tableIndexes = tableSpans const tableIndexes = tableSpans
.map((span, index) => ({ span, index })) .map((span, index) => ({ span, index }))
.filter(({ span }) => span.start >= section.bodyStart && span.end <= section.bodyEnd .filter(({ span }) => span.start >= section.bodyStart && span.end <= section.bodyEnd
@ -750,8 +801,21 @@ function reportSections(document: DecodedSourceDocument): ScannerReportSectionCa
const tables = tableIndexes.map(index => { const tables = tableIndexes.map(index => {
const table = document.tables[index]; const table = document.tables[index];
const matrix = tableMatrix(table); const matrix = tableMatrix(table);
return { name: table.name, sourceTableIndex: table.index, headers: matrix.headers, rows: matrix.rows }; const metadata = tableMetadata(tableSpans[index]);
const linkTitle = metadata.presentation === 'linked-inline'
? detailLinks.find(link => link.targetId === metadata.targetId)?.label : undefined;
return { name: table.name, sourceTableIndex: table.index, headers: matrix.headers, rows: matrix.rows,
...metadata, ...(linkTitle ? { title: linkTitle } : {}) };
}); });
const referencedModalIds = new Set(tables.flatMap(table => table.cellLinks?.map(link => link.targetId) ?? []));
const modalDetails = sectionTargets.filter(target => target.presentation === 'modal'
&& referencedModalIds.has(target.id) && !tables.some(table => 'targetId' in table && table.targetId === target.id))
.map(target => {
const body = markup.slice(target.span.bodyStart, target.span.bodyEnd);
return { targetId: target.id, title: target.title || target.id,
prose: proseText(body).trim() || undefined,
...(/class\s*=\s*["'][^"']*\bnetwork-area\b/i.test(body) ? { interactiveGraph: true } : {}) };
});
return { return {
version: 1, version: 1,
sectionKey: section.sectionKey, sectionKey: section.sectionKey,
@ -761,6 +825,8 @@ function reportSections(document: DecodedSourceDocument): ScannerReportSectionCa
state: sectionState(prose, tables), state: sectionState(prose, tables),
prose: prose || undefined, prose: prose || undefined,
tables, tables,
...(detailLinks.length ? { detailLinks } : {}),
...(modalDetails.length ? { modalDetails } : {}),
source: { fileName: document.file.name, objectPath: `html/sections/${section.sectionKey}` }, source: { fileName: document.file.name, objectPath: `html/sections/${section.sectionKey}` },
attributes: { tableCount: tables.length } attributes: { tableCount: tables.length }
}; };

View File

@ -99,11 +99,8 @@ export function normalizeStatusFor(toolId: string | undefined, value: string): C
return normalizeStatusDimensionsFor(toolId, value).legacy; return normalizeStatusDimensionsFor(toolId, value).legacy;
} }
const toolIdByName = new Map([...toolProfileDefinitions.map(definition => const toolIdByName = new Map(toolProfileDefinitions.flatMap(definition =>
[definition.name.toLocaleLowerCase('en-US'), definition.id] as const), [definition.name, ...(definition.runNames ?? [])].map(name => [name.toLocaleLowerCase('en-US'), definition.id] as const)));
// Scoped run names of the canonical import (see `canonical-import.ts`).
...['purple knight for active directory', 'purple knight for entra id', 'purple knight for active directory and entra id']
.map(name => [name, 'purple-knight'] as const)]);
/** Provenienzspalte der Projektaggregation: "<Werkzeugname> · <Zeitpunkt>". */ /** Provenienzspalte der Projektaggregation: "<Werkzeugname> · <Zeitpunkt>". */
const SCAN_PROVENANCE_LABEL = /^(?:quelle|source):\s*scan$/i; const SCAN_PROVENANCE_LABEL = /^(?:quelle|source):\s*scan$/i;

View File

@ -95,6 +95,7 @@ export interface ImportStagingData {
} }
export interface CanonicalImportCommit { export interface CanonicalImportCommit {
referenceCatalogs?: import('../domain/reference-recognition').ReferenceCatalogSnapshot[];
sessionId: string; sessionId: string;
model: CanonicalAssessmentModel; model: CanonicalAssessmentModel;
fingerprints: ImportFingerprintInput[]; fingerprints: ImportFingerprintInput[];
@ -120,6 +121,8 @@ export interface ImportSession {
} }
export interface ProjectDataExport { export interface ProjectDataExport {
referenceRuns: import('../domain/reference-recognition').ReferenceRecognitionRun[];
referenceCatalogs: import('../domain/reference-recognition').ReferenceCatalogSnapshot[];
version: 1; version: 1;
project: AssessmentProject; project: AssessmentProject;
policyEvaluations: StoredPolicyEvaluation[]; policyEvaluations: StoredPolicyEvaluation[];
@ -193,6 +196,10 @@ export interface ArtifactJobCommitResult {
} }
export interface AssessorRepository { export interface AssessorRepository {
saveReferenceRun(run: import('../domain/reference-recognition').ReferenceRecognitionRun,
catalogs: import('../domain/reference-recognition').ReferenceCatalogSnapshot[]): Promise<void>;
listReferenceRuns(projectId: string): Promise<import('../domain/reference-recognition').ReferenceRecognitionRun[]>;
listReferenceCatalogs(projectId: string): Promise<import('../domain/reference-recognition').ReferenceCatalogSnapshot[]>;
readonly revision: number; readonly revision: number;
listProjects(): Promise<AssessmentProject[]>; listProjects(): Promise<AssessmentProject[]>;
@ -249,6 +256,8 @@ export interface AssessorRepository {
listPolicySnapshotAggregations(projectId: string): Promise<StoredPolicySnapshotAggregation[]>; listPolicySnapshotAggregations(projectId: string): Promise<StoredPolicySnapshotAggregation[]>;
savePolicyEvaluation(record: StoredPolicyEvaluation): Promise<void>; savePolicyEvaluation(record: StoredPolicyEvaluation): Promise<void>;
listPolicyEvaluations(projectId: string): Promise<StoredPolicyEvaluation[]>; listPolicyEvaluations(projectId: string): Promise<StoredPolicyEvaluation[]>;
/** Read-only current-run projection for lightweight result metrics. */
listCurrentPolicyEvaluationsForMetrics?(projectId: string): Promise<StoredPolicyEvaluation[]>;
savePolicyFindingRun(record: StoredPolicyFindingRun): Promise<void>; savePolicyFindingRun(record: StoredPolicyFindingRun): Promise<void>;
listPolicyFindingRuns(projectId: string): Promise<StoredPolicyFindingRun[]>; listPolicyFindingRuns(projectId: string): Promise<StoredPolicyFindingRun[]>;
saveMicrosoftSecurityBaselineClassification(classification: MicrosoftSecurityBaselineClassification): Promise<void>; saveMicrosoftSecurityBaselineClassification(classification: MicrosoftSecurityBaselineClassification): Promise<void>;

View File

@ -51,6 +51,8 @@ import { processingScopeIssues } from '../domain/processing-compatibility';
import { compareReimportSourceHashes } from '../domain/reimport-source'; import { compareReimportSourceHashes } from '../domain/reimport-source';
import { planReimportReviewTransfer, type ReimportReviewTransferSummary } from '../domain/reimport-review-transfer'; import { planReimportReviewTransfer, type ReimportReviewTransferSummary } from '../domain/reimport-review-transfer';
import { canonicalJson } from '../shared/canonical-json'; import { canonicalJson } from '../shared/canonical-json';
import { latestReferenceRuns, type ReferenceCatalogSnapshot, type ReferenceRecognitionRun } from '../domain/reference-recognition';
import { validateReferenceRun } from './reference-run-validation';
import { normalizeComparisonText } from '../comparison/comparison-normalization'; import { normalizeComparisonText } from '../comparison/comparison-normalization';
import { validateLocalComparisonRules } from '../comparison/local-mapping-rules'; import { validateLocalComparisonRules } from '../comparison/local-mapping-rules';
import { import {
@ -98,6 +100,8 @@ import {
} from './repository-errors'; } from './repository-errors';
interface LocalRepositoryState { interface LocalRepositoryState {
referenceRuns: ReferenceRecognitionRun[];
referenceCatalogs: ReferenceCatalogSnapshot[];
projects: AssessmentProject[]; projects: AssessmentProject[];
assessments: Assessment[]; assessments: Assessment[];
scanRuns: ScanRun[]; scanRuns: ScanRun[];
@ -136,6 +140,8 @@ interface LocalRepositoryState {
} }
const emptyState = (): LocalRepositoryState => ({ const emptyState = (): LocalRepositoryState => ({
referenceRuns: [],
referenceCatalogs: [],
projects: [], projects: [],
assessments: [], assessments: [],
scanRuns: [], scanRuns: [],
@ -177,6 +183,22 @@ function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T; return JSON.parse(JSON.stringify(value)) as T;
} }
function appendReferenceRun(state: LocalRepositoryState, run: ReferenceRecognitionRun, catalogs: ReferenceCatalogSnapshot[]): boolean {
const existing = state.referenceRuns.find(item => item.id === run.id);
if (existing) {
if (canonicalJson(existing) !== canonicalJson(run)) throw new RepositoryConflictError('Referenzläufe sind unveränderlich.');
return false;
}
validateReferenceRun(run, catalogs, assessmentModel(state, run.assessmentId));
for (const catalog of catalogs) {
const stored = state.referenceCatalogs.find(item => item.sha256 === catalog.sha256);
if (stored && canonicalJson(stored) !== canonicalJson(catalog)) throw new RepositoryConflictError('Katalog-Snapshot ist unveränderlich.');
if (!stored) state.referenceCatalogs.push(clone(catalog));
}
state.referenceRuns.push(clone(run));
return true;
}
function byId<T extends { id: string }>(items: T[]): T[] { function byId<T extends { id: string }>(items: T[]): T[] {
return items.slice().sort((left, right) => left.id.localeCompare(right.id)); return items.slice().sort((left, right) => left.id.localeCompare(right.id));
} }
@ -226,6 +248,7 @@ function assessmentModel(state: LocalRepositoryState, assessmentId: string): Can
}); });
return { return {
schemaVersion: 1, schemaVersion: 1,
referenceRuns: clone(latestReferenceRuns(state.referenceRuns.filter(run => runIds.has(run.scanRunId)))),
project: clone(project), project: clone(project),
assessment: clone(assessment), assessment: clone(assessment),
scanRuns: byId(scanRuns).map(clone), scanRuns: byId(scanRuns).map(clone),
@ -523,6 +546,20 @@ export class LocalAssessorRepository implements AssessorRepository {
.map(clone).sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id)); .map(clone).sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id));
} }
async saveReferenceRun(run: ReferenceRecognitionRun, catalogs: ReferenceCatalogSnapshot[]): Promise<void> {
const next = clone(this.state);
if (appendReferenceRun(next, run, catalogs)) this.replaceState(next);
}
async listReferenceRuns(projectId: string): Promise<ReferenceRecognitionRun[]> {
return clone(this.state.referenceRuns.filter(run => run.projectId === projectId));
}
async listReferenceCatalogs(projectId: string): Promise<ReferenceCatalogSnapshot[]> {
const hashes = new Set(this.state.referenceRuns.filter(run => run.projectId === projectId).flatMap(run => run.catalogHashes));
return clone(this.state.referenceCatalogs.filter(catalog => hashes.has(catalog.sha256)));
}
async beginImport(start: ImportSessionStart): Promise<ImportSession> { async beginImport(start: ImportSessionStart): Promise<ImportSession> {
if (!start.sessionId.trim()) throw new RepositoryValidationError('Eine Importsitzung benötigt eine ID.'); if (!start.sessionId.trim()) throw new RepositoryValidationError('Eine Importsitzung benötigt eine ID.');
if (this.activeImportSessions.has(start.sessionId)) throw new RepositoryConflictError(`Importsitzung ${start.sessionId} ist bereits aktiv.`); if (this.activeImportSessions.has(start.sessionId)) throw new RepositoryConflictError(`Importsitzung ${start.sessionId} ist bereits aktiv.`);
@ -613,6 +650,7 @@ export class LocalAssessorRepository implements AssessorRepository {
appendUnique(next.findingOccurrences, model.findingOccurrences, 'Finding-Vorkommen'); appendUnique(next.findingOccurrences, model.findingOccurrences, 'Finding-Vorkommen');
appendUnique(next.evidence, model.evidence, 'Evidenz'); appendUnique(next.evidence, model.evidence, 'Evidenz');
appendUnique(next.diagnostics, model.diagnostics, 'Importdiagnose'); appendUnique(next.diagnostics, model.diagnostics, 'Importdiagnose');
for (const referenceRun of model.referenceRuns ?? []) appendReferenceRun(next, referenceRun, input.referenceCatalogs ?? []);
const importedAt = model.scanRuns[0].importedAt; const importedAt = model.scanRuns[0].importedAt;
input.fingerprints.forEach(entry => { input.fingerprints.forEach(entry => {
if (!/^[a-f0-9]{64}$/.test(entry.sha256)) throw new RepositoryValidationError('SHA-256-Fingerabdruck ist ungültig.'); if (!/^[a-f0-9]{64}$/.test(entry.sha256)) throw new RepositoryValidationError('SHA-256-Fingerabdruck ist ungültig.');
@ -1631,6 +1669,8 @@ export class LocalAssessorRepository implements AssessorRepository {
return clone({ return clone({
version: 1, version: 1,
project, project,
referenceRuns: await this.listReferenceRuns(projectId),
referenceCatalogs: await this.listReferenceCatalogs(projectId),
assessments: byId(assessments), assessments: byId(assessments),
scanRuns: byId(scanRuns), scanRuns: byId(scanRuns),
importFiles: byId(importFiles), importFiles: byId(importFiles),
@ -1695,6 +1735,7 @@ export class LocalAssessorRepository implements AssessorRepository {
const processingRunIds = new Set(next.processingRuns.filter(item => artifactIds.has(item.artifactId)).map(item => item.id)); const processingRunIds = new Set(next.processingRuns.filter(item => artifactIds.has(item.artifactId)).map(item => item.id));
next.scanRuns = next.scanRuns.filter(item => item.id !== scanRunId); next.scanRuns = next.scanRuns.filter(item => item.id !== scanRunId);
next.referenceRuns = next.referenceRuns.filter(item => item.scanRunId !== scanRunId);
next.importFiles = next.importFiles.filter(item => !fileIds.has(item.id)); next.importFiles = next.importFiles.filter(item => !fileIds.has(item.id));
next.sourceTables = next.sourceTables.filter(item => !fileIds.has(item.importFileId)); next.sourceTables = next.sourceTables.filter(item => !fileIds.has(item.importFileId));
next.reportSections = next.reportSections.filter(item => item.scanRunId !== scanRunId); next.reportSections = next.reportSections.filter(item => item.scanRunId !== scanRunId);
@ -1767,6 +1808,7 @@ export class LocalAssessorRepository implements AssessorRepository {
next.assessments = next.assessments.filter(item => item.id !== assessmentId); next.assessments = next.assessments.filter(item => item.id !== assessmentId);
next.scanRuns = next.scanRuns.filter(item => !runIds.has(item.id)); next.scanRuns = next.scanRuns.filter(item => !runIds.has(item.id));
next.referenceRuns = next.referenceRuns.filter(item => !runIds.has(item.scanRunId));
next.importFiles = next.importFiles.filter(item => !fileIds.has(item.id)); next.importFiles = next.importFiles.filter(item => !fileIds.has(item.id));
next.sourceTables = next.sourceTables.filter(item => !fileIds.has(item.importFileId)); next.sourceTables = next.sourceTables.filter(item => !fileIds.has(item.importFileId));
next.reportSections = next.reportSections.filter(item => !runIds.has(item.scanRunId)); next.reportSections = next.reportSections.filter(item => !runIds.has(item.scanRunId));
@ -1817,6 +1859,7 @@ export class LocalAssessorRepository implements AssessorRepository {
next.projects = next.projects.filter(item => item.id !== projectId); next.projects = next.projects.filter(item => item.id !== projectId);
next.assessments = next.assessments.filter(item => !assessmentIds.has(item.id)); next.assessments = next.assessments.filter(item => !assessmentIds.has(item.id));
next.scanRuns = next.scanRuns.filter(item => !runIds.has(item.id)); next.scanRuns = next.scanRuns.filter(item => !runIds.has(item.id));
next.referenceRuns = next.referenceRuns.filter(item => !runIds.has(item.scanRunId));
next.importFiles = next.importFiles.filter(item => !fileIds.has(item.id)); next.importFiles = next.importFiles.filter(item => !fileIds.has(item.id));
next.sourceTables = next.sourceTables.filter(item => !fileIds.has(item.importFileId)); next.sourceTables = next.sourceTables.filter(item => !fileIds.has(item.importFileId));
next.reportSections = next.reportSections.filter(item => !runIds.has(item.scanRunId)); next.reportSections = next.reportSections.filter(item => !runIds.has(item.scanRunId));

View File

@ -0,0 +1,29 @@
import type { CanonicalAssessmentModel } from '../domain/canonical-model';
import { createReferenceRecognizer, type ReferenceCatalogSnapshot, type ReferenceRecognitionRun } from '../domain/reference-recognition';
import { canonicalJson } from '../shared/canonical-json';
import { RepositoryValidationError } from './repository-errors';
export function validateReferenceRun(run: ReferenceRecognitionRun, catalogs: ReferenceCatalogSnapshot[], model: CanonicalAssessmentModel | null): void {
const hash = /^[a-f0-9]{64}$/;
if (!model || model.project.id !== run.projectId || model.assessment.id !== run.assessmentId
|| !model.scanRuns.some(scan => scan.id === run.scanRunId) || run.schemaVersion !== 1
|| !run.id || !run.recognizerVersion || !hash.test(run.inputSha256) || !Number.isFinite(Date.parse(run.createdAt))
|| catalogs.length !== 2 || new Set(catalogs.map(catalog => catalog.framework)).size !== 2
|| catalogs.some(catalog => !hash.test(catalog.sha256) || !catalog.entries.length || !catalog.license)
|| canonicalJson([...run.catalogHashes].sort()) !== canonicalJson(catalogs.map(catalog => catalog.sha256).sort())) {
throw new RepositoryValidationError('Ungültiger Referenzlauf oder fremder Scan-/Katalogkontext.');
}
const occurrences = model.findingOccurrences.filter(item => item.scanRunId === run.scanRunId);
const results = new Map(run.results.map(result => [result.occurrenceId, result]));
const recognize = createReferenceRecognizer(catalogs);
if (results.size !== occurrences.length || results.size !== run.results.length) throw new RepositoryValidationError('Unvollständiger Referenzlauf.');
for (const occurrence of occurrences) {
const result = results.get(occurrence.id);
const file = model.importFiles.find(item => item.id === occurrence.importFileId);
if (!result || result.importFileId !== occurrence.importFileId || result.sourceFileSha256 !== file?.sha256
|| canonicalJson(result.sourcePosition) !== canonicalJson(occurrence.sourcePosition)
|| canonicalJson(result.references) !== canonicalJson(recognize(occurrence.originalFields))) {
throw new RepositoryValidationError('Referenzherkunft stimmt nicht mit dem gespeicherten Original überein.');
}
}
}

View File

@ -1,14 +1,18 @@
import type { DownloadAdapter } from '../../exports/download-adapter'; import type { DownloadAdapter } from '../../exports/download-adapter';
export function downloadBrowserBlob(blob: Blob, fileName: string): void {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = fileName;
link.hidden = true;
document.body.appendChild(link);
link.click();
URL.revokeObjectURL(link.href);
link.remove();
}
export const browserDownloadAdapter: DownloadAdapter = { export const browserDownloadAdapter: DownloadAdapter = {
download({ fileName, text, mediaType }) { download({ fileName, text, mediaType }) {
const link = document.createElement('a'); downloadBrowserBlob(new Blob([text], { type: mediaType }), fileName);
link.href = URL.createObjectURL(new Blob([text], { type: mediaType }));
link.download = fileName;
link.hidden = true;
document.body.appendChild(link);
link.click();
URL.revokeObjectURL(link.href);
link.remove();
} }
}; };

View File

@ -0,0 +1,96 @@
import type { ScannerReportSectionCandidate } from '../../domain/source-extraction';
/** Benchmark section titles per scan run (`''` for a single direct import without a run identity). */
export type CisBenchmarkSectionTitles = Map<string, Map<string, string>>;
const SECTION_SCORES_KEY = 'cis-cat-section-scores';
const NUMBERED_TITLE = /^((?:\d+\.)*\d+)\s+(.+)$/;
/**
* Reads the section titles the CIS-CAT report lists in its section-score table
* (`18.10.42 Microsoft Defender Antivirus ...`). A number with differing titles
* inside one run is left out rather than guessed.
*/
export function cisBenchmarkSectionTitles(
sections: ReadonlyArray<Pick<ScannerReportSectionCandidate, 'sectionKey'> & {
scanRunId?: string; tables: ReadonlyArray<{ headers: string[]; rows: ReadonlyArray<ReadonlyArray<unknown>> }>;
}> | undefined
): CisBenchmarkSectionTitles {
const titles: CisBenchmarkSectionTitles = new Map();
const conflicts = new Map<string, Set<string>>();
for (const section of sections ?? []) {
if (section.sectionKey !== SECTION_SCORES_KEY) continue;
const runId = section.scanRunId ?? '';
const run = titles.get(runId) ?? new Map<string, string>();
const runConflicts = conflicts.get(runId) ?? new Set<string>();
for (const table of section.tables) {
const column = table.headers.findIndex(header => /^description$/i.test(header.trim()));
if (column < 0) continue;
for (const row of table.rows) {
const match = NUMBERED_TITLE.exec(String(row[column] ?? '').trim());
if (!match) continue;
const [, id, title] = match;
const known = run.get(id);
if (known !== undefined && known !== title.trim()) runConflicts.add(id);
else run.set(id, title.trim());
}
}
runConflicts.forEach(id => run.delete(id));
titles.set(runId, run);
conflicts.set(runId, runConflicts);
}
return titles;
}
/**
* Title of a derived `CIS <number>` category for the given records. Only a title that every record
* resolves identically from its own run is returned; otherwise the number stands alone.
*/
export function cisCategoryTitle(
titles: CisBenchmarkSectionTitles | undefined,
category: string,
runIds: Iterable<string>
): string | undefined {
const id = /^CIS\s+((?:\d+\.)*\d+)$/.exec(category.trim())?.[1];
if (!id || !titles?.size) return undefined;
const found = new Set<string>();
for (const runId of runIds) {
const title = titles.get(runId)?.get(id);
if (!title) return undefined;
found.add(title);
}
return found.size === 1 ? [...found][0] : undefined;
}
/** Section title of one derived `CIS <number>` category, from the record's own scan-run report. */
export function cisCategoryRecordTitle(record: { benchmarkSectionTitles?: ReadonlyMap<string, string> }, category: string): string | undefined {
const id = /^CIS\s+((?:\d+\.)*\d+)$/.exec(category.trim())?.[1];
return id ? record.benchmarkSectionTitles?.get(id) : undefined;
}
/**
* Heading of a CIS finding with its leading rule number replaced by the title of the section that
* contains the rule, from the record's own run report (`2.2.2 Ensure ...` -> `User Rights Assignment:
* Ensure ...`). The exact number stays in the report's Source Rule ID; without a title the heading is unchanged.
*/
export function cisSectionHeading(
record: { benchmarkSectionTitles?: ReadonlyMap<string, string> }, ruleId: string, heading: string
): string {
const section = ruleId.split('.').slice(0, -1).join('.');
const title = section ? record.benchmarkSectionTitles?.get(section) : undefined;
const rest = heading.startsWith(ruleId) ? heading.slice(ruleId.length) : '';
return title && /^\s+\S/.test(rest) ? `${title}: ${rest.trim()}` : heading;
}
/** Numeric order of `CIS <number>` categories: 2, 2.3, 2.3.10, 17, 18, 18.6, 18.10. */
export function compareCisCategories(left: string, right: string): number {
const parts = (value: string) => (/((?:\d+\.)*\d+)/.exec(value)?.[1] ?? '').split('.').map(Number);
const a = parts(left);
const b = parts(right);
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
if (a[index] === undefined) return -1;
if (b[index] === undefined) return 1;
if (a[index] !== b[index]) return a[index] - b[index];
}
return 0;
}

View File

@ -71,6 +71,14 @@ export const cisCatDefinition: ToolProfileDefinition = {
], ],
normalization: { severity: commonSeverityMappings, status: commonStatusMappings }, normalization: { severity: commonSeverityMappings, status: commonStatusMappings },
presentation: { presentation: {
// The report's own criterion and evaluated rows state expected and found values.
firstRead: {
criterionEvidence: true,
impactHeaders: [/^impact$/i],
nextStepHeaders: [/^remediation$/i],
backgroundHeaders: [/^description$/i, /^rationale$/i],
referenceHeaders: [/^references$/i]
},
titleHeaders: [/^benchmark\s*item$/i, /^title$/i], titleHeaders: [/^benchmark\s*item$/i, /^title$/i],
severityHeaders: [/^(?:severity|level)$/i], severityHeaders: [/^(?:severity|level)$/i],
sortHeaders: [/^recommendation$/i], sortHeaders: [/^recommendation$/i],

Some files were not shown because too many files have changed in this diff Show More