feat(ui): ship triage explainability workspace
This commit is contained in:
@@ -31,7 +31,7 @@
|
|||||||
## Delivery Tracker
|
## Delivery Tracker
|
||||||
|
|
||||||
### FE-TX-001 - Wire the canonical artifact workspace and route state
|
### FE-TX-001 - Wire the canonical artifact workspace and route state
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: none
|
Dependency: none
|
||||||
Owners: Product Manager, FE Architect
|
Owners: Product Manager, FE Architect
|
||||||
Task description:
|
Task description:
|
||||||
@@ -39,12 +39,12 @@ Task description:
|
|||||||
- Ensure the active shell exposes the triage workspace operators should actually use.
|
- Ensure the active shell exposes the triage workspace operators should actually use.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Canonical artifact and audit-bundle routes are active in the router.
|
- [x] Canonical artifact and audit-bundle routes are active in the router.
|
||||||
- [ ] Lane and panel query params work in the shipped UI.
|
- [x] Lane and panel query params work in the shipped UI.
|
||||||
- [ ] Separate workbench brands are no longer required for triage access.
|
- [x] Separate workbench brands are no longer required for triage access.
|
||||||
|
|
||||||
### FE-TX-002 - Ship the list-lane workflows
|
### FE-TX-002 - Ship the list-lane workflows
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-TX-001
|
Dependency: FE-TX-001
|
||||||
Owners: Developer, FE Architect
|
Owners: Developer, FE Architect
|
||||||
Task description:
|
Task description:
|
||||||
@@ -52,12 +52,12 @@ Task description:
|
|||||||
- Ensure row actions, bulk actions, and lane transitions are usable from the active artifact list.
|
- Ensure row actions, bulk actions, and lane transitions are usable from the active artifact list.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Lane tabs or segmented controls are working in the shipped UI.
|
- [x] Lane tabs or segmented controls are working in the shipped UI.
|
||||||
- [ ] Row and bulk actions work from the artifact list.
|
- [x] Row and bulk actions work from the artifact list.
|
||||||
- [ ] Quiet-lane behavior is usable as queue state, not a detached page.
|
- [x] Quiet-lane behavior is usable as queue state, not a detached page.
|
||||||
|
|
||||||
### FE-TX-003 - Ship the detail-side explainability workspace
|
### FE-TX-003 - Ship the detail-side explainability workspace
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-TX-001
|
Dependency: FE-TX-001
|
||||||
Owners: Developer, Product Manager
|
Owners: Developer, Product Manager
|
||||||
Task description:
|
Task description:
|
||||||
@@ -65,12 +65,12 @@ Task description:
|
|||||||
- Make them usable beside the central artifact summary and evidence trail instead of leaving them as unmounted workbench ideas.
|
- Make them usable beside the central artifact summary and evidence trail instead of leaving them as unmounted workbench ideas.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Detail-side panels render and open via the active workspace route state.
|
- [x] Detail-side panels render and open via the active workspace route state.
|
||||||
- [ ] Panel actions and return-to-context behavior work in the shipped UI.
|
- [x] Panel actions and return-to-context behavior work in the shipped UI.
|
||||||
- [ ] AI remains advisory and evidence-first in the shipped detail experience.
|
- [x] AI remains advisory and evidence-first in the shipped detail experience.
|
||||||
|
|
||||||
### FE-TX-004 - Ship the Audit Bundles page and create flow
|
### FE-TX-004 - Ship the Audit Bundles page and create flow
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-TX-001
|
Dependency: FE-TX-001
|
||||||
Owners: Developer, Documentation author
|
Owners: Developer, Documentation author
|
||||||
Task description:
|
Task description:
|
||||||
@@ -78,12 +78,12 @@ Task description:
|
|||||||
- Ensure operators can build and retrieve audit bundles from the active triage and evidence flows.
|
- Ensure operators can build and retrieve audit bundles from the active triage and evidence flows.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Bundle list and create flow are usable in the shipped UI.
|
- [x] Bundle list and create flow are usable in the shipped UI.
|
||||||
- [ ] Cross-links from artifact detail and evidence open the working page.
|
- [x] Cross-links from artifact detail and evidence open the working page.
|
||||||
- [ ] Audit bundles remain a visible sibling page, not a hidden modal flow.
|
- [x] Audit bundles remain a visible sibling page, not a hidden modal flow.
|
||||||
|
|
||||||
### FE-TX-005 - Migrate supporting components and retire workbench wrappers
|
### FE-TX-005 - Migrate supporting components and retire workbench wrappers
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-TX-003
|
Dependency: FE-TX-003
|
||||||
Owners: Developer, Documentation author
|
Owners: Developer, Documentation author
|
||||||
Task description:
|
Task description:
|
||||||
@@ -91,12 +91,12 @@ Task description:
|
|||||||
- Retire wrapper shells only after their preserved behavior is working in the active artifact workspace.
|
- Retire wrapper shells only after their preserved behavior is working in the active artifact workspace.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Supporting components are visible in the working list or detail surfaces.
|
- [x] Supporting components are visible in the working list or detail surfaces.
|
||||||
- [ ] Wrapper shells slated for retirement are no longer needed for preserved behavior.
|
- [x] Wrapper shells slated for retirement are no longer needed for preserved behavior.
|
||||||
- [ ] No preserved triage functionality depends on an orphan workbench route.
|
- [x] No preserved triage functionality depends on an orphan workbench route.
|
||||||
|
|
||||||
### FE-TX-006 - Verify, document, and cut over the workspace
|
### FE-TX-006 - Verify, document, and cut over the workspace
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-TX-004
|
Dependency: FE-TX-004
|
||||||
Owners: QA, Documentation author
|
Owners: QA, Documentation author
|
||||||
Task description:
|
Task description:
|
||||||
@@ -104,14 +104,17 @@ Task description:
|
|||||||
- Update triage and UI docs so the artifact workspace ships as the usable owner of these workflows.
|
- Update triage and UI docs so the artifact workspace ships as the usable owner of these workflows.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Verification covers lane changes, detail panels, and audit bundles.
|
- [x] Verification covers lane changes, detail panels, and audit bundles.
|
||||||
- [ ] Cross-shell deep links are included in testing.
|
- [x] Cross-shell deep links are included in testing.
|
||||||
- [ ] Docs reflect the shipped artifact workspace and audit-bundle flows.
|
- [x] Docs reflect the shipped artifact workspace and audit-bundle flows.
|
||||||
|
|
||||||
## Execution Log
|
## Execution Log
|
||||||
| Date (UTC) | Update | Owner |
|
| Date (UTC) | Update | Owner |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 2026-03-07 | Sprint created to ship one artifact workspace with lane segmentation, detail-side explainability, and a sibling Audit Bundles page instead of keeping those capabilities in dropped workbench shells. | Project Manager |
|
| 2026-03-07 | Sprint created to ship one artifact workspace with lane segmentation, detail-side explainability, and a sibling Audit Bundles page instead of keeping those capabilities in dropped workbench shells. | Project Manager |
|
||||||
|
| 2026-03-07 | Implementation started. The current workspace already contains usable artifact and evidence surfaces, but route ownership, lane segmentation, explainability panels, and audit-bundle routing are split between `/security/*`, `/triage/*`, and orphan workbench ideas. This sprint absorbs the audit-bundle route/client prerequisite while wiring the canonical triage owner shell. | Developer |
|
||||||
|
| 2026-03-07 | Shipped the canonical `/triage/artifacts` and `/triage/audit-bundles` route family, list-lane state, merged explainability rail, audit-bundle create flow, and sidebar cutover. Security artifact aliases now canonicalize into `/triage/*` without dropping `artifactId`. | Developer |
|
||||||
|
| 2026-03-07 | Verified delivery with `npx ng test --watch=false --include src/tests/triage/triage-artifacts.component.spec.ts --include src/tests/triage/triage-workspace-with-proof-tree.behavior.spec.ts --include src/tests/audit_bundle/triage-routes.spec.ts --include src/tests/security-risk/security-risk-routes.spec.ts --include src/tests/navigation/legacy-redirects.spec.ts --include src/tests/routes/legacy-route-migration-framework.component.spec.ts --include src/tests/audit_bundle/audit-bundles.client.contract.spec.ts --include src/tests/audit_bundle/triage-audit-bundle-new.component.spec.ts --include src/tests/audit_bundle/triage-audit-bundles.component.spec.ts` (68 passing tests) and `npx playwright test tests/e2e/triage-explainability-workspace.spec.ts --workers=1` (2 passing scenarios). | QA |
|
||||||
|
|
||||||
## Decisions & Risks
|
## Decisions & Risks
|
||||||
- Decision: triage stays one workspace with contextual explainability, not multiple workbench brands.
|
- Decision: triage stays one workspace with contextual explainability, not multiple workbench brands.
|
||||||
@@ -120,10 +123,11 @@ Completion criteria:
|
|||||||
- Mitigation: require explicit advisory-only copy and evidence panels in the detail contract.
|
- Mitigation: require explicit advisory-only copy and evidence panels in the detail contract.
|
||||||
- Risk: quiet-lane behavior may get over-specialized into another shell.
|
- Risk: quiet-lane behavior may get over-specialized into another shell.
|
||||||
- Mitigation: freeze it as list segmentation plus row or bulk actions only.
|
- Mitigation: freeze it as list segmentation plus row or bulk actions only.
|
||||||
|
- Decision: the in-flight audit-bundle route/client repair is absorbed into this sprint so the canonical `/triage/*` workspace can ship without depending on an uncommitted prerequisite slice.
|
||||||
- Delivery rule: this sprint is only complete when the active triage workspace provides the preserved explainability and audit workflows without depending on orphan workbench pages.
|
- Delivery rule: this sprint is only complete when the active triage workspace provides the preserved explainability and audit workflows without depending on orphan workbench pages.
|
||||||
|
- Decision: `/security/artifacts*` now acts strictly as an alias into `/triage/artifacts*`; the alias must preserve `artifactId`, query state, and fragments.
|
||||||
|
- Decision: TTFS telemetry timers run outside Angular and are cleaned up on destroy so the shipped workspace remains stable under unit and browser test harnesses.
|
||||||
- Reference design note: `docs/modules/ui/triage-explainability-workspace/README.md`.
|
- Reference design note: `docs/modules/ui/triage-explainability-workspace/README.md`.
|
||||||
|
|
||||||
## Next Checkpoints
|
## Next Checkpoints
|
||||||
- 2026-03-08: confirm lane model, detail-side panel set, and Audit Bundles ownership.
|
- Archive sprint after commit and keep follow-on work in `SPRINT_20260307_028_FE_workflow_visualization_replay.md` and `SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md`.
|
||||||
- 2026-03-09: freeze supporting component merge matrix and route/query contract.
|
|
||||||
- 2026-03-10: finalize QA and rollout contract.
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Triage Explainability Workspace UI
|
||||||
|
|
||||||
|
## Module
|
||||||
|
Web
|
||||||
|
|
||||||
|
## Status
|
||||||
|
VERIFIED
|
||||||
|
|
||||||
|
## Description
|
||||||
|
Shipped the canonical triage artifact workspace with lane-based queue segmentation, contextual explainability panels, canonical `/triage/*` ownership, and a sibling `Audit Bundles` page. The preserved workbench ideas now live inside one usable operator shell instead of orphan routes. Security artifact entry points deep-link into the same canonical workspace instead of owning a second surface.
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
- **Feature directory**: `src/Web/StellaOps.Web/src/app/features/triage/`
|
||||||
|
- **Primary components**:
|
||||||
|
- `triage-artifacts` (`src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.ts`)
|
||||||
|
- `triage-workspace` (`src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts`)
|
||||||
|
- `triage-audit-bundles` (`src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundles.component.ts`)
|
||||||
|
- `triage-audit-bundle-new` (`src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundle-new.component.ts`)
|
||||||
|
- **Canonical routes**:
|
||||||
|
- `/triage/artifacts`
|
||||||
|
- `/triage/artifacts/:artifactId`
|
||||||
|
- `/triage/audit-bundles`
|
||||||
|
- `/triage/audit-bundles/new`
|
||||||
|
- **Route state**:
|
||||||
|
- `lane=active|quiet|review`
|
||||||
|
- `panel=ai|reason|provenance|history`
|
||||||
|
- `findingId=<vulnId>`
|
||||||
|
- `tab=evidence|overview|reachability|delta|policy|attestations`
|
||||||
|
- **Preserved functionality**:
|
||||||
|
- lane-based queue management with row and bulk transitions
|
||||||
|
- explainability rail for AI recommendations, reason capsule, provenance, and decision history
|
||||||
|
- audit-bundle list and create flow
|
||||||
|
- security alias cutover from `/security/artifacts*`
|
||||||
|
- **Secondary entry points**:
|
||||||
|
- `Security > Triage`
|
||||||
|
- `Security > Findings`
|
||||||
|
- `Evidence`
|
||||||
|
|
||||||
|
## E2E Test Plan
|
||||||
|
- **Setup**:
|
||||||
|
- [ ] Log in with a user that can access `Security`, `Triage`, and `Evidence`.
|
||||||
|
- [ ] Navigate to `/triage/artifacts?lane=review`.
|
||||||
|
- [ ] Ensure vulnerability and audit-bundle fixtures are available.
|
||||||
|
- **Core verification**:
|
||||||
|
- [ ] Verify `Active`, `Quiet Lane`, and `Needs Review` lanes load and keep query state.
|
||||||
|
- [ ] Verify bulk lane movement and `Build audit bundle` operate from the live list.
|
||||||
|
- [ ] Verify workspace detail preserves `tab` and `panel` state while showing evidence-first advisory UX.
|
||||||
|
- **Legacy verification**:
|
||||||
|
- [ ] Verify `/security/artifacts` and `/security/artifacts/:artifactId` canonicalize into `/triage/*`.
|
||||||
|
- [ ] Verify alias redirects preserve `artifactId`, query params, and fragments.
|
||||||
|
- [ ] Verify audit-bundle creation remains reachable from list and detail entry points.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Run:
|
||||||
|
- `npx ng test --watch=false --include src/tests/triage/triage-artifacts.component.spec.ts --include src/tests/triage/triage-workspace-with-proof-tree.behavior.spec.ts --include src/tests/audit_bundle/triage-routes.spec.ts --include src/tests/security-risk/security-risk-routes.spec.ts --include src/tests/navigation/legacy-redirects.spec.ts --include src/tests/routes/legacy-route-migration-framework.component.spec.ts --include src/tests/audit_bundle/audit-bundles.client.contract.spec.ts --include src/tests/audit_bundle/triage-audit-bundle-new.component.spec.ts --include src/tests/audit_bundle/triage-audit-bundles.component.spec.ts`
|
||||||
|
- `npx playwright test tests/e2e/triage-explainability-workspace.spec.ts --workers=1`
|
||||||
|
- Tier 0 (source): pass
|
||||||
|
- Tier 1 (build/tests): pass
|
||||||
|
- Tier 2 (behavior): pass
|
||||||
|
- Verified on (UTC): 2026-03-07T19:40:00Z
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
- `docs/implplan/SPRINT_20260307_009_DOCS_ui_component_preservation_map.md`
|
- `docs/implplan/SPRINT_20260307_009_DOCS_ui_component_preservation_map.md`
|
||||||
- `docs/implplan/SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md`
|
- `docs/implplan/SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md`
|
||||||
- `docs/implplan/SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md`
|
- `docs/implplan/SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md`
|
||||||
- `docs/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md`
|
|
||||||
- `docs/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md`
|
- `docs/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md`
|
||||||
- `docs/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md`
|
- `docs/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md`
|
||||||
|
|
||||||
@@ -87,12 +86,12 @@
|
|||||||
- [DONE] FE-PO-004 Route cleanup and alias migration contract for Operations
|
- [DONE] FE-PO-004 Route cleanup and alias migration contract for Operations
|
||||||
- [DONE] FE-PO-005 Setup boundary and deep-link contract for Operations
|
- [DONE] FE-PO-005 Setup boundary and deep-link contract for Operations
|
||||||
- [DONE] FE-PO-006 QA, rollout, and docs sync for Platform Ops consolidation
|
- [DONE] FE-PO-006 QA, rollout, and docs sync for Platform Ops consolidation
|
||||||
- [TODO] FE-TX-001 Freeze artifact workspace route, lane, and panel contract
|
- [DONE] FE-TX-001 Freeze artifact workspace route, lane, and panel contract
|
||||||
- [TODO] FE-TX-002 List-lane segmentation slice for Artifact Workspace
|
- [DONE] FE-TX-002 List-lane segmentation slice for Artifact Workspace
|
||||||
- [TODO] FE-TX-003 Detail-side explainability rail slice
|
- [DONE] FE-TX-003 Detail-side explainability rail slice
|
||||||
- [TODO] FE-TX-004 Audit bundles page and create-flow slice
|
- [DONE] FE-TX-004 Audit bundles page and create-flow slice
|
||||||
- [TODO] FE-TX-005 Supporting component merge matrix for Triage explainability
|
- [DONE] FE-TX-005 Supporting component merge matrix for Triage explainability
|
||||||
- [TODO] FE-TX-006 QA, rollout, and docs sync for Triage explainability
|
- [DONE] FE-TX-006 QA, rollout, and docs sync for Triage explainability
|
||||||
- [TODO] FE-WV-001 Freeze run-detail tab and route contract for workflow visualization
|
- [TODO] FE-WV-001 Freeze run-detail tab and route contract for workflow visualization
|
||||||
- [TODO] FE-WV-002 Graph, timeline, and critical-path slice
|
- [TODO] FE-WV-002 Graph, timeline, and critical-path slice
|
||||||
- [TODO] FE-WV-003 Replay and evidence integration slice
|
- [TODO] FE-WV-003 Replay and evidence integration slice
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
|
|||||||
- `SPRINT_20260307_009_DOCS_ui_component_preservation_map.md` - per-component preservation dossiers for unused and weakly surfaced console UI components.
|
- `SPRINT_20260307_009_DOCS_ui_component_preservation_map.md` - per-component preservation dossiers for unused and weakly surfaced console UI components.
|
||||||
- `SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md` - canonical Decisioning Studio shell to unify policy, simulation, VEX decisioning, and release-context gate explanation.
|
- `SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md` - canonical Decisioning Studio shell to unify policy, simulation, VEX decisioning, and release-context gate explanation.
|
||||||
- `SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md` - documentation prerequisite for shell/menu/tab placements; not a product-delivery sprint by itself.
|
- `SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md` - documentation prerequisite for shell/menu/tab placements; not a product-delivery sprint by itself.
|
||||||
- `SPRINT_20260307_027_FE_triage_explainability_workspace.md` - ship the artifact workspace lane model, explainability panels, and audit-bundle flows.
|
|
||||||
- `SPRINT_20260307_028_FE_workflow_visualization_replay.md` - ship run-detail graph, timeline, replay, and evidence tabs plus bounded workflow-editor preview reuse.
|
- `SPRINT_20260307_028_FE_workflow_visualization_replay.md` - ship run-detail graph, timeline, replay, and evidence tabs plus bounded workflow-editor preview reuse.
|
||||||
- `SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md` - ship the shared tabs, drawers, right rails, split views, and contextual detail primitives adopted by the restoration features.
|
- `SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md` - ship the shared tabs, drawers, right rails, split views, and contextual detail primitives adopted by the restoration features.
|
||||||
|
|
||||||
@@ -27,6 +26,7 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
|
|||||||
- `docs/features/checked/web/reachability-witnessing-ui.md` - shipped verification note for the canonical Reachability witness and PoE shell.
|
- `docs/features/checked/web/reachability-witnessing-ui.md` - shipped verification note for the canonical Reachability witness and PoE shell.
|
||||||
- `docs/features/checked/web/identity-watchlist-management-ui.md` - shipped verification note for the Trust & Signing watchlist shell and its Mission Control / Notifications handoffs.
|
- `docs/features/checked/web/identity-watchlist-management-ui.md` - shipped verification note for the Trust & Signing watchlist shell and its Mission Control / Notifications handoffs.
|
||||||
- `docs/features/checked/web/operations-consolidation-ui.md` - shipped verification note for the canonical Operations shell, overview grouping, and legacy alias cutover.
|
- `docs/features/checked/web/operations-consolidation-ui.md` - shipped verification note for the canonical Operations shell, overview grouping, and legacy alias cutover.
|
||||||
|
- `docs/features/checked/web/triage-explainability-workspace-ui.md` - shipped verification note for the canonical triage artifact workspace, explainability rail, audit bundles, and security alias cutover.
|
||||||
- `docs/modules/ui/reachability-witnessing/README.md` - detailed witness and proof UX dossier plus cross-shell deep-link contract.
|
- `docs/modules/ui/reachability-witnessing/README.md` - detailed witness and proof UX dossier plus cross-shell deep-link contract.
|
||||||
- `docs/modules/ui/platform-ops-consolidation/README.md` - detailed Operations overview taxonomy and legacy absorption plan.
|
- `docs/modules/ui/platform-ops-consolidation/README.md` - detailed Operations overview taxonomy and legacy absorption plan.
|
||||||
- `docs/modules/ui/triage-explainability-workspace/README.md` - detailed artifact workspace and audit-bundle UX dossier.
|
- `docs/modules/ui/triage-explainability-workspace/README.md` - detailed artifact workspace and audit-bundle UX dossier.
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
# Triage Explainability Workspace
|
# Triage Explainability Workspace
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Shipped on 2026-03-07 as the canonical triage owner shell.
|
||||||
|
|
||||||
|
- Canonical list route: `/triage/artifacts`
|
||||||
|
- Canonical detail route: `/triage/artifacts/:artifactId`
|
||||||
|
- Sibling supporting routes:
|
||||||
|
- `/triage/audit-bundles`
|
||||||
|
- `/triage/audit-bundles/new`
|
||||||
|
- Security alias routes:
|
||||||
|
- `/security/artifacts`
|
||||||
|
- `/security/artifacts/:artifactId`
|
||||||
|
- Verification note: `docs/features/checked/web/triage-explainability-workspace-ui.md`
|
||||||
|
|
||||||
## Recommendation
|
## Recommendation
|
||||||
|
|
||||||
Restore the useful triage workbench ideas by folding them into one canonical artifact workspace plus a sibling `Audit Bundles` page.
|
Restore the useful triage workbench ideas by folding them into one canonical artifact workspace plus a sibling `Audit Bundles` page.
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import { generateTraceId } from './trace.util';
|
|||||||
import type {
|
import type {
|
||||||
AuditBundleCreateRequest,
|
AuditBundleCreateRequest,
|
||||||
AuditBundleJobResponse,
|
AuditBundleJobResponse,
|
||||||
|
AuditBundleJobStatus,
|
||||||
AuditBundleListResponse,
|
AuditBundleListResponse,
|
||||||
|
BundleSubjectRef,
|
||||||
} from './audit-bundles.models';
|
} from './audit-bundles.models';
|
||||||
|
|
||||||
export interface AuditBundlesApi {
|
export interface AuditBundlesApi {
|
||||||
@@ -21,9 +23,62 @@ export interface AuditBundlesApi {
|
|||||||
export const AUDIT_BUNDLES_API = new InjectionToken<AuditBundlesApi>('AUDIT_BUNDLES_API');
|
export const AUDIT_BUNDLES_API = new InjectionToken<AuditBundlesApi>('AUDIT_BUNDLES_API');
|
||||||
export const AUDIT_BUNDLES_API_BASE_URL = new InjectionToken<string>('AUDIT_BUNDLES_API_BASE_URL');
|
export const AUDIT_BUNDLES_API_BASE_URL = new InjectionToken<string>('AUDIT_BUNDLES_API_BASE_URL');
|
||||||
|
|
||||||
|
interface AuditBundleCreateRequestDto {
|
||||||
|
subject: BundleSubjectRef;
|
||||||
|
timeWindow?: { from?: string; to?: string };
|
||||||
|
includeContent: {
|
||||||
|
vulnReports: boolean;
|
||||||
|
sbom: boolean;
|
||||||
|
vexDecisions: boolean;
|
||||||
|
policyEvaluations: boolean;
|
||||||
|
attestations: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditBundleSummaryDto {
|
||||||
|
bundleId: string;
|
||||||
|
subject: BundleSubjectRef;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
completedAt?: string | null;
|
||||||
|
bundleHash?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditBundleListResponseDto {
|
||||||
|
bundles: AuditBundleSummaryDto[];
|
||||||
|
continuationToken?: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateAuditBundleResponseDto {
|
||||||
|
bundleId: string;
|
||||||
|
status: string;
|
||||||
|
statusUrl: string;
|
||||||
|
estimatedCompletionSeconds?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditBundleStatusDto {
|
||||||
|
bundleId: string;
|
||||||
|
status: string;
|
||||||
|
progress: number;
|
||||||
|
createdAt: string;
|
||||||
|
completedAt?: string | null;
|
||||||
|
bundleHash?: string | null;
|
||||||
|
downloadUrl?: string | null;
|
||||||
|
ociReference?: string | null;
|
||||||
|
errorCode?: string | null;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KnownBundleMetadata {
|
||||||
|
subject: BundleSubjectRef;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AuditBundlesHttpClient implements AuditBundlesApi {
|
export class AuditBundlesHttpClient implements AuditBundlesApi {
|
||||||
private readonly tenantService = inject(TenantActivationService);
|
private readonly tenantService = inject(TenantActivationService);
|
||||||
|
private readonly knownBundles = new Map<string, KnownBundleMetadata>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly http: HttpClient,
|
private readonly http: HttpClient,
|
||||||
@@ -36,8 +91,11 @@ export class AuditBundlesHttpClient implements AuditBundlesApi {
|
|||||||
const traceId = generateTraceId();
|
const traceId = generateTraceId();
|
||||||
|
|
||||||
const headers = this.buildHeaders(tenant, traceId);
|
const headers = this.buildHeaders(tenant, traceId);
|
||||||
return this.http.get<AuditBundleListResponse>(`${this.baseUrl}/v1/audit-bundles`, { headers }).pipe(
|
return this.http.get<AuditBundleListResponseDto>(`${this.baseUrl}/v1/audit-bundles`, { headers }).pipe(
|
||||||
map((resp) => ({ ...resp, traceId })),
|
map((resp) => {
|
||||||
|
const items = resp.bundles.map((bundle) => this.mapListItem(bundle, traceId));
|
||||||
|
return { items, count: items.length, traceId };
|
||||||
|
}),
|
||||||
catchError((err) => throwError(() => err))
|
catchError((err) => throwError(() => err))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -47,8 +105,25 @@ export class AuditBundlesHttpClient implements AuditBundlesApi {
|
|||||||
const traceId = options.traceId ?? generateTraceId();
|
const traceId = options.traceId ?? generateTraceId();
|
||||||
|
|
||||||
const headers = this.buildHeaders(tenant, traceId, options.projectId);
|
const headers = this.buildHeaders(tenant, traceId, options.projectId);
|
||||||
return this.http.post<AuditBundleJobResponse>(`${this.baseUrl}/v1/audit-bundles`, request, { headers }).pipe(
|
return this.http.post<CreateAuditBundleResponseDto>(
|
||||||
map((resp) => ({ ...resp, traceId })),
|
`${this.baseUrl}/v1/audit-bundles`,
|
||||||
|
this.toCreateRequestDto(request),
|
||||||
|
{ headers }
|
||||||
|
).pipe(
|
||||||
|
map((resp) => {
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
const job: AuditBundleJobResponse = {
|
||||||
|
bundleId: resp.bundleId,
|
||||||
|
status: normalizeAuditBundleStatus(resp.status),
|
||||||
|
createdAt,
|
||||||
|
subject: request.subject,
|
||||||
|
statusUrl: resp.statusUrl,
|
||||||
|
estimatedCompletionSeconds: resp.estimatedCompletionSeconds ?? undefined,
|
||||||
|
traceId,
|
||||||
|
};
|
||||||
|
this.rememberBundle(job);
|
||||||
|
return job;
|
||||||
|
}),
|
||||||
catchError((err) => throwError(() => err))
|
catchError((err) => throwError(() => err))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -58,8 +133,26 @@ export class AuditBundlesHttpClient implements AuditBundlesApi {
|
|||||||
const traceId = options.traceId ?? generateTraceId();
|
const traceId = options.traceId ?? generateTraceId();
|
||||||
|
|
||||||
const headers = this.buildHeaders(tenant, traceId, options.projectId);
|
const headers = this.buildHeaders(tenant, traceId, options.projectId);
|
||||||
return this.http.get<AuditBundleJobResponse>(`${this.baseUrl}/v1/audit-bundles/${encodeURIComponent(bundleId)}`, { headers }).pipe(
|
return this.http.get<AuditBundleStatusDto>(`${this.baseUrl}/v1/audit-bundles/${encodeURIComponent(bundleId)}`, { headers }).pipe(
|
||||||
map((resp) => ({ ...resp, traceId })),
|
map((resp) => {
|
||||||
|
const metadata = this.knownBundles.get(resp.bundleId);
|
||||||
|
const job: AuditBundleJobResponse = {
|
||||||
|
bundleId: resp.bundleId,
|
||||||
|
status: normalizeAuditBundleStatus(resp.status),
|
||||||
|
createdAt: coerceIsoTimestamp(resp.createdAt) ?? metadata?.createdAt ?? new Date(0).toISOString(),
|
||||||
|
completedAt: coerceIsoTimestamp(resp.completedAt),
|
||||||
|
progress: resp.progress,
|
||||||
|
subject: metadata?.subject ?? createUnknownSubject(resp.bundleId),
|
||||||
|
sha256: resp.bundleHash ?? undefined,
|
||||||
|
downloadUrl: resp.downloadUrl ?? undefined,
|
||||||
|
ociReference: resp.ociReference ?? undefined,
|
||||||
|
errorCode: resp.errorCode ?? undefined,
|
||||||
|
error: resp.errorMessage ?? undefined,
|
||||||
|
traceId,
|
||||||
|
};
|
||||||
|
this.rememberBundle(job);
|
||||||
|
return job;
|
||||||
|
}),
|
||||||
catchError((err) => throwError(() => err))
|
catchError((err) => throwError(() => err))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -68,13 +161,48 @@ export class AuditBundlesHttpClient implements AuditBundlesApi {
|
|||||||
const tenant = this.resolveTenant(options.tenantId);
|
const tenant = this.resolveTenant(options.tenantId);
|
||||||
const traceId = options.traceId ?? generateTraceId();
|
const traceId = options.traceId ?? generateTraceId();
|
||||||
|
|
||||||
const headers = this.buildHeaders(tenant, traceId, options.projectId).set('Accept', 'application/octet-stream');
|
const headers = this.buildHeaders(tenant, traceId, options.projectId).set('Accept', 'application/zip');
|
||||||
return this.http.get(`${this.baseUrl}/v1/audit-bundles/${encodeURIComponent(bundleId)}`, {
|
return this.http.get(`${this.baseUrl}/v1/audit-bundles/${encodeURIComponent(bundleId)}/download`, {
|
||||||
headers,
|
headers,
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private toCreateRequestDto(request: AuditBundleCreateRequest): AuditBundleCreateRequestDto {
|
||||||
|
return {
|
||||||
|
subject: request.subject,
|
||||||
|
timeWindow: request.timeWindow,
|
||||||
|
includeContent: {
|
||||||
|
vulnReports: request.contents.vulnReports,
|
||||||
|
sbom: request.contents.sbom,
|
||||||
|
vexDecisions: request.contents.vex,
|
||||||
|
policyEvaluations: request.contents.policyEvals,
|
||||||
|
attestations: request.contents.attestations,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapListItem(bundle: AuditBundleSummaryDto, traceId: string): AuditBundleJobResponse {
|
||||||
|
const job: AuditBundleJobResponse = {
|
||||||
|
bundleId: bundle.bundleId,
|
||||||
|
status: normalizeAuditBundleStatus(bundle.status),
|
||||||
|
createdAt: coerceIsoTimestamp(bundle.createdAt) ?? new Date(0).toISOString(),
|
||||||
|
completedAt: coerceIsoTimestamp(bundle.completedAt),
|
||||||
|
subject: bundle.subject,
|
||||||
|
sha256: bundle.bundleHash ?? undefined,
|
||||||
|
traceId,
|
||||||
|
};
|
||||||
|
this.rememberBundle(job);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rememberBundle(job: Pick<AuditBundleJobResponse, 'bundleId' | 'subject' | 'createdAt'>): void {
|
||||||
|
this.knownBundles.set(job.bundleId, {
|
||||||
|
subject: job.subject,
|
||||||
|
createdAt: job.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private buildHeaders(tenantId: string, traceId: string, projectId?: string): HttpHeaders {
|
private buildHeaders(tenantId: string, traceId: string, projectId?: string): HttpHeaders {
|
||||||
let headers = new HttpHeaders({
|
let headers = new HttpHeaders({
|
||||||
'X-Stella-Tenant': tenantId,
|
'X-Stella-Tenant': tenantId,
|
||||||
@@ -153,7 +281,7 @@ export class MockAuditBundlesClient implements AuditBundlesApi {
|
|||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
);
|
);
|
||||||
return of(new Blob([payload], { type: 'application/json' })).pipe(delay(150));
|
return of(new Blob([payload], { type: 'application/zip' })).pipe(delay(150));
|
||||||
}
|
}
|
||||||
|
|
||||||
private materialize(job: StoredAuditJob): AuditBundleJobResponse {
|
private materialize(job: StoredAuditJob): AuditBundleJobResponse {
|
||||||
@@ -174,7 +302,7 @@ export class MockAuditBundlesClient implements AuditBundlesApi {
|
|||||||
status: 'completed',
|
status: 'completed',
|
||||||
sha256: 'sha256:mock-bundle-sha256',
|
sha256: 'sha256:mock-bundle-sha256',
|
||||||
integrityRootHash: 'sha256:mock-root-hash',
|
integrityRootHash: 'sha256:mock-root-hash',
|
||||||
downloadUrl: `/v1/audit-bundles/${encodeURIComponent(job.bundleId)}`,
|
downloadUrl: `/v1/audit-bundles/${encodeURIComponent(job.bundleId)}/download`,
|
||||||
ociReference: `oci://stellaops/audit-bundles@${job.bundleId}`,
|
ociReference: `oci://stellaops/audit-bundles@${job.bundleId}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -188,3 +316,41 @@ export class MockAuditBundlesClient implements AuditBundlesApi {
|
|||||||
return new Date(MockAuditBundlesClient.BaseMs + seq * 60000).toISOString();
|
return new Date(MockAuditBundlesClient.BaseMs + seq * 60000).toISOString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAuditBundleStatus(status: string | null | undefined): AuditBundleJobStatus {
|
||||||
|
switch ((status ?? '').trim().toLowerCase()) {
|
||||||
|
case 'queued':
|
||||||
|
case 'accepted':
|
||||||
|
return 'queued';
|
||||||
|
case 'processing':
|
||||||
|
case 'running':
|
||||||
|
case 'in_progress':
|
||||||
|
return 'processing';
|
||||||
|
case 'completed':
|
||||||
|
case 'complete':
|
||||||
|
case 'succeeded':
|
||||||
|
return 'completed';
|
||||||
|
case 'failed':
|
||||||
|
case 'error':
|
||||||
|
return 'failed';
|
||||||
|
default:
|
||||||
|
return 'queued';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceIsoTimestamp(value: string | null | undefined): string | undefined {
|
||||||
|
if (!value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUnknownSubject(bundleId: string): BundleSubjectRef {
|
||||||
|
return {
|
||||||
|
type: 'OTHER',
|
||||||
|
name: bundleId,
|
||||||
|
digest: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,10 +83,15 @@ export interface AuditBundleJobResponse {
|
|||||||
readonly status: AuditBundleJobStatus;
|
readonly status: AuditBundleJobStatus;
|
||||||
readonly createdAt: string;
|
readonly createdAt: string;
|
||||||
readonly subject: BundleSubjectRef;
|
readonly subject: BundleSubjectRef;
|
||||||
|
readonly completedAt?: string;
|
||||||
|
readonly progress?: number;
|
||||||
readonly sha256?: string;
|
readonly sha256?: string;
|
||||||
readonly integrityRootHash?: string;
|
readonly integrityRootHash?: string;
|
||||||
readonly downloadUrl?: string;
|
readonly downloadUrl?: string;
|
||||||
readonly ociReference?: string;
|
readonly ociReference?: string;
|
||||||
|
readonly statusUrl?: string;
|
||||||
|
readonly estimatedCompletionSeconds?: number;
|
||||||
|
readonly errorCode?: string;
|
||||||
readonly error?: string;
|
readonly error?: string;
|
||||||
readonly traceId?: string;
|
readonly traceId?: string;
|
||||||
}
|
}
|
||||||
@@ -96,4 +101,3 @@ export interface AuditBundleListResponse {
|
|||||||
readonly count: number;
|
readonly count: number;
|
||||||
readonly traceId?: string;
|
readonly traceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
|
||||||
|
export type TriageArtifactLane = 'active' | 'quiet' | 'review';
|
||||||
|
|
||||||
|
interface PersistedLaneRecord {
|
||||||
|
readonly lane: TriageArtifactLane;
|
||||||
|
readonly updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PersistedLaneMap = Record<string, PersistedLaneRecord>;
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'stellaops.triage.artifact.lanes.v1';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class TriageLaneStateService {
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
|
|
||||||
|
readonly assignments = signal<PersistedLaneMap>(this.readAssignments());
|
||||||
|
|
||||||
|
laneFor(artifactId: string, fallback: TriageArtifactLane): TriageArtifactLane {
|
||||||
|
return this.assignments()[artifactId]?.lane ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLane(artifactIds: readonly string[], lane: TriageArtifactLane): void {
|
||||||
|
if (artifactIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAt = new Date().toISOString();
|
||||||
|
this.assignments.update((state) => {
|
||||||
|
const next = { ...state };
|
||||||
|
for (const artifactId of artifactIds) {
|
||||||
|
next[artifactId] = { lane, updatedAt };
|
||||||
|
}
|
||||||
|
this.persist(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearLane(artifactIds: readonly string[]): void {
|
||||||
|
if (artifactIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.assignments.update((state) => {
|
||||||
|
const next = { ...state };
|
||||||
|
for (const artifactId of artifactIds) {
|
||||||
|
delete next[artifactId];
|
||||||
|
}
|
||||||
|
this.persist(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private readAssignments(): PersistedLaneMap {
|
||||||
|
try {
|
||||||
|
const raw = this.document.defaultView?.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as PersistedLaneMap;
|
||||||
|
return this.normalize(parsed);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalize(value: PersistedLaneMap): PersistedLaneMap {
|
||||||
|
const next: PersistedLaneMap = {};
|
||||||
|
|
||||||
|
for (const [artifactId, record] of Object.entries(value ?? {})) {
|
||||||
|
if (!artifactId || !record) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.lane !== 'active' && record.lane !== 'quiet' && record.lane !== 'review') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
next[artifactId] = {
|
||||||
|
lane: record.lane,
|
||||||
|
updatedAt: record.updatedAt ?? new Date(0).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(value: PersistedLaneMap): void {
|
||||||
|
try {
|
||||||
|
this.document.defaultView?.localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
// Best-effort persistence only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { DestroyRef, Injectable, NgZone, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { EvidenceBitset } from '../models/evidence.model';
|
import { EvidenceBitset } from '../models/evidence.model';
|
||||||
|
|
||||||
@@ -51,10 +51,23 @@ const BUDGETS = {
|
|||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class TtfsTelemetryService {
|
export class TtfsTelemetryService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly zone = inject(NgZone);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly activeTimings = new Map<string, TtfsTimings>();
|
private readonly activeTimings = new Map<string, TtfsTimings>();
|
||||||
private readonly pendingEvents: TtfsEvent[] = [];
|
private readonly pendingEvents: TtfsEvent[] = [];
|
||||||
private flushTimeout: ReturnType<typeof setTimeout> | null = null;
|
private flushTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.destroyRef.onDestroy(() => {
|
||||||
|
if (this.flushTimeout) {
|
||||||
|
clearTimeout(this.flushTimeout);
|
||||||
|
this.flushTimeout = null;
|
||||||
|
}
|
||||||
|
this.pendingEvents.length = 0;
|
||||||
|
this.activeTimings.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts TTFS tracking for an alert.
|
* Starts TTFS tracking for an alert.
|
||||||
*/
|
*/
|
||||||
@@ -240,7 +253,11 @@ export class TtfsTelemetryService {
|
|||||||
|
|
||||||
// Schedule flush if not already scheduled
|
// Schedule flush if not already scheduled
|
||||||
if (!this.flushTimeout) {
|
if (!this.flushTimeout) {
|
||||||
this.flushTimeout = setTimeout(() => this.flushEvents(), 5000);
|
this.zone.runOutsideAngular(() => {
|
||||||
|
this.flushTimeout = setTimeout(() => {
|
||||||
|
this.zone.run(() => this.flushEvents());
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush immediately if we have too many events
|
// Flush immediately if we have too many events
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
<section class="triage-artifacts">
|
<section class="triage-artifacts" data-testid="triage-artifacts-page">
|
||||||
<header class="triage-artifacts__header">
|
<header class="triage-artifacts__header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Vulnerability Triage</h1>
|
<h1>Artifact workspace</h1>
|
||||||
<p class="triage-artifacts__subtitle">Artifact-first workflow with evidence and VEX-first decisioning.</p>
|
<p class="triage-artifacts__subtitle">
|
||||||
|
Triage live artifacts by lane, then open a single evidence-first decision workspace.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="triage-artifacts__actions">
|
<div class="triage-artifacts__actions">
|
||||||
|
<app-ai-code-guard-badge
|
||||||
|
[verdict]="guardVerdict()"
|
||||||
|
[totalFindings]="filteredRows().length"
|
||||||
|
[criticalCount]="severityCounts().critical"
|
||||||
|
[highCount]="severityCounts().high"
|
||||||
|
[mediumCount]="severityCounts().medium"
|
||||||
|
[lowCount]="severityCounts().low"
|
||||||
|
/>
|
||||||
<button type="button" class="btn btn--secondary" (click)="load()" [disabled]="loading()">
|
<button type="button" class="btn btn--secondary" (click)="load()" [disabled]="loading()">
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
@@ -21,6 +31,56 @@
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<section class="lane-strip" aria-label="Artifact lanes">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="lane-pill"
|
||||||
|
data-testid="triage-lane-active"
|
||||||
|
[class.lane-pill--active]="lane() === 'active'"
|
||||||
|
[attr.aria-pressed]="lane() === 'active'"
|
||||||
|
(click)="setLaneFilter('active')"
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
<span>{{ laneCounts().active }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="lane-pill"
|
||||||
|
data-testid="triage-lane-quiet"
|
||||||
|
[class.lane-pill--active]="lane() === 'quiet'"
|
||||||
|
[attr.aria-pressed]="lane() === 'quiet'"
|
||||||
|
(click)="setLaneFilter('quiet')"
|
||||||
|
>
|
||||||
|
Quiet Lane
|
||||||
|
<span>{{ laneCounts().quiet }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="lane-pill"
|
||||||
|
data-testid="triage-lane-review"
|
||||||
|
[class.lane-pill--active]="lane() === 'review'"
|
||||||
|
[attr.aria-pressed]="lane() === 'review'"
|
||||||
|
(click)="setLaneFilter('review')"
|
||||||
|
>
|
||||||
|
Needs Review
|
||||||
|
<span>{{ laneCounts().review }}</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (selectedCount() > 0) {
|
||||||
|
<section class="bulk-bar" data-testid="triage-bulk-bar">
|
||||||
|
<div>
|
||||||
|
<strong>{{ selectedCount() }}</strong> artifact{{ selectedCount() === 1 ? '' : 's' }} selected
|
||||||
|
</div>
|
||||||
|
<div class="bulk-bar__actions">
|
||||||
|
<button type="button" class="btn btn--secondary" (click)="moveSelectionToLane('active')">Move to Active</button>
|
||||||
|
<button type="button" class="btn btn--secondary" (click)="moveSelectionToLane('quiet')">Move to Quiet</button>
|
||||||
|
<button type="button" class="btn btn--secondary" (click)="moveSelectionToLane('review')">Move to Review</button>
|
||||||
|
<button type="button" class="btn btn--primary" (click)="openBundleWizardFromSelection()">Build audit bundle</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="triage-artifacts__toolbar">
|
<div class="triage-artifacts__toolbar">
|
||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
<input
|
<input
|
||||||
@@ -29,110 +89,142 @@
|
|||||||
placeholder="Search artifacts or environments..."
|
placeholder="Search artifacts or environments..."
|
||||||
[value]="search()"
|
[value]="search()"
|
||||||
(input)="setSearch($any($event.target).value)"
|
(input)="setSearch($any($event.target).value)"
|
||||||
/>
|
/>
|
||||||
@if (search()) {
|
@if (search()) {
|
||||||
<button type="button" class="search-box__clear" (click)="setSearch('')">Clear</button>
|
<button type="button" class="search-box__clear" (click)="setSearch('')">Clear</button>
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filters">
|
|
||||||
<div class="filter-group">
|
|
||||||
<label class="filter-group__label">Environment</label>
|
|
||||||
<select
|
|
||||||
class="filter-group__select"
|
|
||||||
[value]="environment()"
|
|
||||||
(change)="setEnvironment($any($event.target).value)"
|
|
||||||
>
|
|
||||||
<option value="all">All</option>
|
|
||||||
@for (env of environmentOptions; track env) {
|
|
||||||
<option [value]="env">{{ env }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (loading()) {
|
<div class="filters">
|
||||||
<div class="triage-artifacts__loading">
|
<div class="filter-group">
|
||||||
<span class="spinner"></span>
|
<label class="filter-group__label">Environment</label>
|
||||||
<span>Loading artifacts...</span>
|
<select
|
||||||
|
class="filter-group__select"
|
||||||
|
[value]="environment()"
|
||||||
|
(change)="setEnvironment($any($event.target).value)"
|
||||||
|
>
|
||||||
|
<option value="all">All</option>
|
||||||
|
@for (env of environmentOptions; track env) {
|
||||||
|
<option [value]="env">{{ env }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
</div>
|
||||||
<div class="triage-artifacts__table-wrap">
|
</div>
|
||||||
@if (filteredRows().length > 0) {
|
|
||||||
<table class="triage-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('artifact')">
|
|
||||||
Artifact {{ getSortIcon('artifact') }}
|
|
||||||
</th>
|
|
||||||
<th class="triage-table__th">Type</th>
|
|
||||||
<th class="triage-table__th">Environment(s)</th>
|
|
||||||
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('open')">
|
|
||||||
Open {{ getSortIcon('open') }}
|
|
||||||
</th>
|
|
||||||
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('total')">
|
|
||||||
Total {{ getSortIcon('total') }}
|
|
||||||
</th>
|
|
||||||
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('maxSeverity')">
|
|
||||||
Max severity {{ getSortIcon('maxSeverity') }}
|
|
||||||
</th>
|
|
||||||
<th class="triage-table__th">Attestations</th>
|
|
||||||
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('lastScan')">
|
|
||||||
Last scan {{ getSortIcon('lastScan') }}
|
|
||||||
</th>
|
|
||||||
<th class="triage-table__th">Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (row of filteredRows(); track row.artifactId) {
|
|
||||||
<tr class="triage-table__row">
|
|
||||||
<td class="triage-table__td">
|
|
||||||
<code class="artifact-id">{{ row.artifactId }}</code>
|
|
||||||
@if (row.readyToDeploy) {
|
|
||||||
<span class="ready-pill" title="All gates passed and required attestations verified (stub)">Ready to deploy</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="triage-table__td">
|
|
||||||
<span class="chip chip--small">{{ row.type }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="triage-table__td">
|
|
||||||
<span class="env-list">{{ row.environments.join(', ') }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="triage-table__td">
|
|
||||||
<span class="count" [class.count--hot]="row.openVulns > 0">{{ row.openVulns }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="triage-table__td">
|
|
||||||
<span class="count">{{ row.totalVulns }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="triage-table__td">
|
|
||||||
<span class="chip chip--small" [class.chip--critical]="row.maxSeverity === 'critical'" [class.chip--high]="row.maxSeverity === 'high'">
|
|
||||||
{{ severityLabels[row.maxSeverity] }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="triage-table__td">
|
|
||||||
<span class="badge" [class.badge--ok]="row.attestationCount > 0" [class.badge--muted]="row.attestationCount === 0">
|
|
||||||
{{ row.attestationCount }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="triage-table__td">
|
|
||||||
<span class="when">{{ formatWhen(row.lastScanAt) }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="triage-table__td triage-table__td--actions">
|
|
||||||
<button type="button" class="btn btn--small btn--primary" (click)="viewVulnerabilities(row)">
|
|
||||||
View vulnerabilities
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
} @else {
|
|
||||||
<div class="empty-state">
|
|
||||||
<p>No artifacts match your filters.</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
</div>
|
@if (loading()) {
|
||||||
}
|
<div class="triage-artifacts__loading">
|
||||||
</section>
|
<span class="spinner"></span>
|
||||||
|
<span>Loading artifacts...</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="triage-artifacts__table-wrap">
|
||||||
|
@if (filteredRows().length > 0) {
|
||||||
|
<table class="triage-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="triage-table__th triage-table__th--checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[checked]="filteredRows().length > 0 && selectedCount() === filteredRows().length"
|
||||||
|
[attr.aria-label]="'Select all visible artifacts'"
|
||||||
|
(change)="toggleSelectVisible()"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('artifact')">
|
||||||
|
Artifact {{ getSortIcon('artifact') }}
|
||||||
|
</th>
|
||||||
|
<th class="triage-table__th">Lane</th>
|
||||||
|
<th class="triage-table__th">Environment(s)</th>
|
||||||
|
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('open')">
|
||||||
|
Open {{ getSortIcon('open') }}
|
||||||
|
</th>
|
||||||
|
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('total')">
|
||||||
|
Total {{ getSortIcon('total') }}
|
||||||
|
</th>
|
||||||
|
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('maxSeverity')">
|
||||||
|
Max severity {{ getSortIcon('maxSeverity') }}
|
||||||
|
</th>
|
||||||
|
<th class="triage-table__th">Attestations</th>
|
||||||
|
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('lastScan')">
|
||||||
|
Last scan {{ getSortIcon('lastScan') }}
|
||||||
|
</th>
|
||||||
|
<th class="triage-table__th">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of filteredRows(); track row.artifactId) {
|
||||||
|
<tr class="triage-table__row" [attr.data-testid]="'triage-artifact-row-' + row.artifactId">
|
||||||
|
<td class="triage-table__td triage-table__td--checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[checked]="isSelected(row.artifactId)"
|
||||||
|
[attr.aria-label]="'Select ' + row.artifactId"
|
||||||
|
(change)="toggleSelection(row.artifactId)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="triage-table__td">
|
||||||
|
<code class="artifact-id">{{ row.artifactId }}</code>
|
||||||
|
@if (row.readyToDeploy) {
|
||||||
|
<span class="ready-pill" title="Signed evidence is available and no open findings remain.">
|
||||||
|
Ready to deploy
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="triage-table__td">
|
||||||
|
<span class="lane-badge" [class.lane-badge--quiet]="row.lane === 'quiet'" [class.lane-badge--review]="row.lane === 'review'">
|
||||||
|
{{ row.lane === 'quiet' ? 'Quiet lane' : row.lane === 'review' ? 'Needs review' : 'Active' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="triage-table__td">
|
||||||
|
<span class="env-list">{{ row.environments.join(', ') }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="triage-table__td">
|
||||||
|
<span class="count" [class.count--hot]="row.openVulns > 0">{{ row.openVulns }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="triage-table__td">
|
||||||
|
<span class="count">{{ row.totalVulns }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="triage-table__td">
|
||||||
|
<span class="chip chip--small" [class.chip--critical]="row.maxSeverity === 'critical'" [class.chip--high]="row.maxSeverity === 'high'">
|
||||||
|
{{ severityLabels[row.maxSeverity] }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="triage-table__td">
|
||||||
|
<span class="badge" [class.badge--ok]="row.attestationCount > 0" [class.badge--muted]="row.attestationCount === 0">
|
||||||
|
{{ row.attestationCount }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="triage-table__td">
|
||||||
|
<span class="when">{{ formatWhen(row.lastScanAt) }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="triage-table__td triage-table__td--actions">
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="button" class="btn btn--small btn--primary" [attr.data-testid]="'triage-open-' + row.artifactId" (click)="viewArtifact(row)">
|
||||||
|
Open workspace
|
||||||
|
</button>
|
||||||
|
@if (row.lane === 'review') {
|
||||||
|
<button type="button" class="btn btn--small btn--secondary" (click)="clearArtifactLane(row)">
|
||||||
|
Use default lane
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="btn btn--small btn--secondary" (click)="moveArtifactToLane(row, nextLaneAction(row).lane)">
|
||||||
|
{{ nextLaneAction(row).label }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn--small btn--secondary" (click)="openBundleWizardForRow(row)">
|
||||||
|
Bundle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
} @else {
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>No artifacts match the current lane and filters.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
@use 'tokens/breakpoints' as *;
|
@use 'tokens/breakpoints' as *;
|
||||||
|
|
||||||
/**
|
|
||||||
* Triage Artifacts Component Styles
|
|
||||||
* Migrated to design system tokens
|
|
||||||
*/
|
|
||||||
|
|
||||||
.triage-artifacts {
|
.triage-artifacts {
|
||||||
padding: var(--space-6) var(--space-7);
|
padding: var(--space-6) var(--space-7);
|
||||||
}
|
}
|
||||||
@@ -20,22 +15,87 @@
|
|||||||
.triage-artifacts__subtitle {
|
.triage-artifacts__subtitle {
|
||||||
margin: var(--space-1) 0 0;
|
margin: var(--space-1) 0 0;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
max-width: 60ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.triage-artifacts__actions {
|
.triage-artifacts__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.triage-artifacts__error {
|
.lane-strip {
|
||||||
border: 1px solid var(--color-status-error);
|
display: flex;
|
||||||
background: var(--color-status-error-bg);
|
flex-wrap: wrap;
|
||||||
color: var(--color-status-error);
|
gap: var(--space-2);
|
||||||
padding: var(--space-3) var(--space-4);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lane-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
transition: border-color var(--motion-duration-fast) var(--motion-ease-default),
|
||||||
|
background-color var(--motion-duration-fast) var(--motion-ease-default);
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.75rem;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-surface-tertiary);
|
||||||
|
padding: 0 var(--space-1-5);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-brand-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lane-pill--active {
|
||||||
|
border-color: var(--color-brand-primary);
|
||||||
|
background: color-mix(in srgb, var(--color-brand-primary) 10%, var(--color-surface-primary));
|
||||||
|
|
||||||
|
span {
|
||||||
|
background: color-mix(in srgb, var(--color-brand-primary) 18%, var(--color-surface-primary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--color-surface-secondary);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-bar__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.triage-artifacts__toolbar {
|
.triage-artifacts__toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
@@ -122,7 +182,7 @@
|
|||||||
.triage-artifacts__table-wrap {
|
.triage-artifacts__table-wrap {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-xl);
|
||||||
background: var(--color-surface-primary);
|
background: var(--color-surface-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,11 +194,12 @@
|
|||||||
|
|
||||||
.triage-table__th {
|
.triage-table__th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: var(--space-3) var(--space-3);
|
padding: var(--space-3);
|
||||||
border-bottom: 1px solid var(--color-border-primary);
|
border-bottom: 1px solid var(--color-border-primary);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.triage-table__th--sortable {
|
.triage-table__th--sortable {
|
||||||
@@ -149,8 +210,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.triage-table__th--checkbox,
|
||||||
|
.triage-table__td--checkbox {
|
||||||
|
width: 2.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.triage-table__td {
|
.triage-table__td {
|
||||||
padding: var(--space-3) var(--space-3);
|
padding: var(--space-3);
|
||||||
border-bottom: 1px solid var(--color-border-secondary);
|
border-bottom: 1px solid var(--color-border-secondary);
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
@@ -160,7 +227,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.triage-table__td--actions {
|
.triage-table__td--actions {
|
||||||
white-space: nowrap;
|
min-width: 18rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artifact-id {
|
.artifact-id {
|
||||||
@@ -168,9 +235,15 @@
|
|||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ready-pill {
|
.ready-pill,
|
||||||
|
.lane-badge,
|
||||||
|
.chip,
|
||||||
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ready-pill {
|
||||||
margin-left: var(--space-2);
|
margin-left: var(--space-2);
|
||||||
padding: var(--space-0-5) var(--space-2);
|
padding: var(--space-0-5) var(--space-2);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
@@ -181,9 +254,26 @@
|
|||||||
border: 1px solid var(--color-status-success);
|
border: 1px solid var(--color-status-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lane-badge {
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
background: color-mix(in srgb, var(--color-brand-primary) 10%, var(--color-surface-primary));
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lane-badge--quiet {
|
||||||
|
background: var(--color-status-info-bg);
|
||||||
|
color: var(--color-status-info-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lane-badge--review {
|
||||||
|
background: var(--color-status-warning-bg);
|
||||||
|
color: var(--color-status-warning-text);
|
||||||
|
}
|
||||||
|
|
||||||
.chip {
|
.chip {
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--space-1) var(--space-2);
|
padding: var(--space-1) var(--space-2);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
background: var(--color-surface-tertiary);
|
background: var(--color-surface-tertiary);
|
||||||
@@ -206,8 +296,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 2rem;
|
min-width: 2rem;
|
||||||
padding: var(--space-0-5) var(--space-2);
|
padding: var(--space-0-5) var(--space-2);
|
||||||
@@ -233,9 +321,15 @@
|
|||||||
font-weight: var(--font-weight-bold);
|
font-weight: var(--font-weight-bold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.when {
|
.when,
|
||||||
|
.env-list {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: var(--font-size-sm);
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
@@ -298,4 +392,8 @@
|
|||||||
.search-box {
|
.search-box {
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bulk-bar {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
@@ -7,12 +7,20 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
import { VULNERABILITY_API, type VulnerabilityApi } from '../../core/api/vulnerability.client';
|
import { VULNERABILITY_API, type VulnerabilityApi } from '../../core/api/vulnerability.client';
|
||||||
import type { Vulnerability, VulnerabilitySeverity } from '../../core/api/vulnerability.models';
|
import type { Vulnerability, VulnerabilitySeverity } from '../../core/api/vulnerability.models';
|
||||||
import { ErrorStateComponent } from '../../shared/components/error-state/error-state.component';
|
import { ErrorStateComponent } from '../../shared/components/error-state/error-state.component';
|
||||||
|
import {
|
||||||
|
AiCodeGuardBadgeComponent,
|
||||||
|
type AiCodeGuardVerdict,
|
||||||
|
} from './components/ai-code-guard-badge/ai-code-guard-badge.component';
|
||||||
|
import {
|
||||||
|
TriageLaneStateService,
|
||||||
|
type TriageArtifactLane,
|
||||||
|
} from './services/triage-lane-state.service';
|
||||||
|
|
||||||
type SortField = 'artifact' | 'open' | 'total' | 'maxSeverity' | 'lastScan';
|
type SortField = 'artifact' | 'open' | 'total' | 'maxSeverity' | 'lastScan';
|
||||||
type SortOrder = 'asc' | 'desc';
|
type SortOrder = 'asc' | 'desc';
|
||||||
@@ -46,18 +54,21 @@ export interface TriageArtifactRow {
|
|||||||
readonly attestationCount: number;
|
readonly attestationCount: number;
|
||||||
readonly lastScanAt: string | null;
|
readonly lastScanAt: string | null;
|
||||||
readonly readyToDeploy: boolean;
|
readonly readyToDeploy: boolean;
|
||||||
|
readonly lane: TriageArtifactLane;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-triage-artifacts',
|
selector: 'app-triage-artifacts',
|
||||||
imports: [ErrorStateComponent],
|
imports: [AiCodeGuardBadgeComponent, ErrorStateComponent],
|
||||||
templateUrl: './triage-artifacts.component.html',
|
templateUrl: './triage-artifacts.component.html',
|
||||||
styleUrls: ['./triage-artifacts.component.scss'],
|
styleUrls: ['./triage-artifacts.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class TriageArtifactsComponent implements OnInit {
|
export class TriageArtifactsComponent implements OnInit {
|
||||||
private readonly api = inject<VulnerabilityApi>(VULNERABILITY_API);
|
private readonly api = inject<VulnerabilityApi>(VULNERABILITY_API);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly laneState = inject(TriageLaneStateService);
|
||||||
|
|
||||||
readonly loading = signal(false);
|
readonly loading = signal(false);
|
||||||
readonly error = signal<string | null>(null);
|
readonly error = signal<string | null>(null);
|
||||||
@@ -65,6 +76,8 @@ export class TriageArtifactsComponent implements OnInit {
|
|||||||
|
|
||||||
readonly search = signal('');
|
readonly search = signal('');
|
||||||
readonly environment = signal<EnvironmentHint | 'all'>('all');
|
readonly environment = signal<EnvironmentHint | 'all'>('all');
|
||||||
|
readonly lane = signal<TriageArtifactLane>('active');
|
||||||
|
readonly selectedArtifactIds = signal<readonly string[]>([]);
|
||||||
|
|
||||||
readonly sortField = signal<SortField>('maxSeverity');
|
readonly sortField = signal<SortField>('maxSeverity');
|
||||||
readonly sortOrder = signal<SortOrder>('asc');
|
readonly sortOrder = signal<SortOrder>('asc');
|
||||||
@@ -73,6 +86,7 @@ export class TriageArtifactsComponent implements OnInit {
|
|||||||
readonly severityLabels = SEVERITY_LABELS;
|
readonly severityLabels = SEVERITY_LABELS;
|
||||||
|
|
||||||
readonly rows = computed<readonly TriageArtifactRow[]>(() => {
|
readonly rows = computed<readonly TriageArtifactRow[]>(() => {
|
||||||
|
const assignments = this.laneState.assignments();
|
||||||
const byArtifact = new Map<string, Vulnerability[]>();
|
const byArtifact = new Map<string, Vulnerability[]>();
|
||||||
|
|
||||||
for (const vuln of this.vulnerabilities()) {
|
for (const vuln of this.vulnerabilities()) {
|
||||||
@@ -95,6 +109,8 @@ export class TriageArtifactsComponent implements OnInit {
|
|||||||
const totalVulns = vulns.length;
|
const totalVulns = vulns.length;
|
||||||
const maxSeverity = this.computeMaxSeverity(vulns);
|
const maxSeverity = this.computeMaxSeverity(vulns);
|
||||||
const lastScanAt = this.computeLastScanAt(vulns);
|
const lastScanAt = this.computeLastScanAt(vulns);
|
||||||
|
const attestationCount = this.deriveAttestationCount(vulns);
|
||||||
|
const readyToDeploy = openVulns === 0 && attestationCount > 0;
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
artifactId,
|
artifactId,
|
||||||
@@ -103,27 +119,81 @@ export class TriageArtifactsComponent implements OnInit {
|
|||||||
openVulns,
|
openVulns,
|
||||||
totalVulns,
|
totalVulns,
|
||||||
maxSeverity,
|
maxSeverity,
|
||||||
attestationCount: this.deriveAttestationCount(vulns),
|
attestationCount,
|
||||||
lastScanAt,
|
lastScanAt,
|
||||||
readyToDeploy: openVulns === 0 && this.deriveAttestationCount(vulns) > 0,
|
readyToDeploy,
|
||||||
|
lane: assignments[artifactId]?.lane ?? this.deriveDefaultLane({ artifactId, envs, openVulns, maxSeverity, readyToDeploy }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.applySorting(result);
|
return this.applySorting(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
readonly laneCounts = computed(() => {
|
||||||
|
const counts = { active: 0, quiet: 0, review: 0 } as Record<TriageArtifactLane, number>;
|
||||||
|
for (const row of this.rows()) {
|
||||||
|
counts[row.lane] += 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
});
|
||||||
|
|
||||||
readonly filteredRows = computed<readonly TriageArtifactRow[]>(() => {
|
readonly filteredRows = computed<readonly TriageArtifactRow[]>(() => {
|
||||||
const q = this.search().trim().toLowerCase();
|
const q = this.search().trim().toLowerCase();
|
||||||
const env = this.environment();
|
const env = this.environment();
|
||||||
|
const lane = this.lane();
|
||||||
|
|
||||||
return this.rows().filter((row) => {
|
return this.rows().filter((row) => {
|
||||||
|
if (row.lane !== lane) return false;
|
||||||
if (env !== 'all' && !row.environments.includes(env)) return false;
|
if (env !== 'all' && !row.environments.includes(env)) return false;
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
return row.artifactId.toLowerCase().includes(q) || row.environments.some((e) => e.includes(q));
|
return row.artifactId.toLowerCase().includes(q) || row.environments.some((value) => value.includes(q));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
readonly selectedCount = computed(() => this.selectedArtifactIds().length);
|
||||||
|
|
||||||
|
readonly guardVerdict = computed<AiCodeGuardVerdict>(() => {
|
||||||
|
const rows = this.filteredRows();
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return 'pass';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.some((row) => row.maxSeverity === 'critical' && row.openVulns > 0)) {
|
||||||
|
return 'fail';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.some((row) => row.openVulns > 0)) {
|
||||||
|
return 'pass_with_warnings';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'pass';
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly severityCounts = computed(() => {
|
||||||
|
const counts = { critical: 0, high: 0, medium: 0, low: 0 } as const;
|
||||||
|
const mutable = { ...counts };
|
||||||
|
for (const row of this.filteredRows()) {
|
||||||
|
if (row.maxSeverity === 'unknown') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
mutable[row.maxSeverity] += 1;
|
||||||
|
}
|
||||||
|
return mutable;
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.route.queryParamMap.pipe(takeUntilDestroyed()).subscribe((params) => {
|
||||||
|
const requestedLane = this.parseLane(params.get('lane'));
|
||||||
|
if (requestedLane !== this.lane()) {
|
||||||
|
this.lane.set(requestedLane);
|
||||||
|
this.selectedArtifactIds.set([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
|
const requestedLane = this.parseLane(this.route.snapshot.queryParamMap.get('lane'));
|
||||||
|
this.lane.set(requestedLane);
|
||||||
await this.load();
|
await this.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +203,7 @@ export class TriageArtifactsComponent implements OnInit {
|
|||||||
try {
|
try {
|
||||||
const resp = await firstValueFrom(this.api.listVulnerabilities({ includeReachability: true }));
|
const resp = await firstValueFrom(this.api.listVulnerabilities({ includeReachability: true }));
|
||||||
this.vulnerabilities.set(resp.items);
|
this.vulnerabilities.set(resp.items);
|
||||||
|
this.pruneSelection();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.error.set(err instanceof Error ? err.message : 'Failed to load vulnerabilities');
|
this.error.set(err instanceof Error ? err.message : 'Failed to load vulnerabilities');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -146,6 +217,18 @@ export class TriageArtifactsComponent implements OnInit {
|
|||||||
|
|
||||||
setEnvironment(value: EnvironmentHint | 'all'): void {
|
setEnvironment(value: EnvironmentHint | 'all'): void {
|
||||||
this.environment.set(value);
|
this.environment.set(value);
|
||||||
|
this.pruneSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
setLaneFilter(lane: TriageArtifactLane): void {
|
||||||
|
this.lane.set(lane);
|
||||||
|
this.selectedArtifactIds.set([]);
|
||||||
|
void this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: { lane },
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
replaceUrl: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSort(field: SortField): void {
|
toggleSort(field: SortField): void {
|
||||||
@@ -162,8 +245,78 @@ export class TriageArtifactsComponent implements OnInit {
|
|||||||
return this.sortOrder() === 'asc' ? '\u25B2' : '\u25BC';
|
return this.sortOrder() === 'asc' ? '\u25B2' : '\u25BC';
|
||||||
}
|
}
|
||||||
|
|
||||||
viewVulnerabilities(row: TriageArtifactRow): void {
|
isSelected(artifactId: string): boolean {
|
||||||
void this.router.navigate(['/triage/artifacts', row.artifactId]);
|
return this.selectedArtifactIds().includes(artifactId);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSelection(artifactId: string): void {
|
||||||
|
const current = new Set(this.selectedArtifactIds());
|
||||||
|
if (current.has(artifactId)) {
|
||||||
|
current.delete(artifactId);
|
||||||
|
} else {
|
||||||
|
current.add(artifactId);
|
||||||
|
}
|
||||||
|
this.selectedArtifactIds.set([...current].sort((a, b) => a.localeCompare(b)));
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSelectVisible(): void {
|
||||||
|
const visibleIds = this.filteredRows().map((row) => row.artifactId);
|
||||||
|
const current = new Set(this.selectedArtifactIds());
|
||||||
|
const allSelected = visibleIds.length > 0 && visibleIds.every((artifactId) => current.has(artifactId));
|
||||||
|
|
||||||
|
if (allSelected) {
|
||||||
|
for (const artifactId of visibleIds) {
|
||||||
|
current.delete(artifactId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const artifactId of visibleIds) {
|
||||||
|
current.add(artifactId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedArtifactIds.set([...current].sort((a, b) => a.localeCompare(b)));
|
||||||
|
}
|
||||||
|
|
||||||
|
moveSelectionToLane(lane: TriageArtifactLane): void {
|
||||||
|
const selected = this.selectedArtifactIds();
|
||||||
|
if (selected.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.laneState.setLane(selected, lane);
|
||||||
|
this.selectedArtifactIds.set([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
moveArtifactToLane(row: TriageArtifactRow, lane: TriageArtifactLane): void {
|
||||||
|
this.laneState.setLane([row.artifactId], lane);
|
||||||
|
this.pruneSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearArtifactLane(row: TriageArtifactRow): void {
|
||||||
|
this.laneState.clearLane([row.artifactId]);
|
||||||
|
this.pruneSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
viewArtifact(row: TriageArtifactRow): void {
|
||||||
|
void this.router.navigate(['/triage/artifacts', row.artifactId], {
|
||||||
|
queryParams: {
|
||||||
|
lane: this.lane(),
|
||||||
|
panel: row.lane === 'review' ? 'history' : row.lane === 'quiet' ? 'provenance' : 'reason',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openBundleWizardFromSelection(): void {
|
||||||
|
const selected = this.selectedArtifactIds();
|
||||||
|
void this.router.navigate(['/triage/audit-bundles/new'], {
|
||||||
|
queryParams: selected.length === 1 ? { artifactId: selected[0] } : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openBundleWizardForRow(row: TriageArtifactRow): void {
|
||||||
|
void this.router.navigate(['/triage/audit-bundles/new'], {
|
||||||
|
queryParams: { artifactId: row.artifactId },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
formatWhen(value: string | null): string {
|
formatWhen(value: string | null): string {
|
||||||
@@ -175,6 +328,26 @@ export class TriageArtifactsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextLaneAction(row: TriageArtifactRow): { readonly label: string; readonly lane: TriageArtifactLane } {
|
||||||
|
if (row.lane === 'active') {
|
||||||
|
return { label: 'Quiet lane', lane: 'quiet' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.lane === 'quiet') {
|
||||||
|
return { label: 'Needs review', lane: 'review' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { label: 'Re-activate', lane: 'active' };
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneSelection(): void {
|
||||||
|
const visibleIds = new Set(this.filteredRows().map((row) => row.artifactId));
|
||||||
|
const next = this.selectedArtifactIds().filter((artifactId) => visibleIds.has(artifactId));
|
||||||
|
if (next.length !== this.selectedArtifactIds().length) {
|
||||||
|
this.selectedArtifactIds.set(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private applySorting(rows: readonly TriageArtifactRow[]): readonly TriageArtifactRow[] {
|
private applySorting(rows: readonly TriageArtifactRow[]): readonly TriageArtifactRow[] {
|
||||||
const field = this.sortField();
|
const field = this.sortField();
|
||||||
const order = this.sortOrder();
|
const order = this.sortOrder();
|
||||||
@@ -202,11 +375,9 @@ export class TriageArtifactsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (cmp !== 0) return order === 'asc' ? cmp : -cmp;
|
if (cmp !== 0) return order === 'asc' ? cmp : -cmp;
|
||||||
// stable tie-breakers
|
|
||||||
return a.artifactId.localeCompare(b.artifactId);
|
return a.artifactId.localeCompare(b.artifactId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// default "maxSeverity" should show most severe first
|
|
||||||
if (field === 'maxSeverity' && order === 'asc') {
|
if (field === 'maxSeverity' && order === 'asc') {
|
||||||
return sorted;
|
return sorted;
|
||||||
}
|
}
|
||||||
@@ -225,10 +396,10 @@ export class TriageArtifactsComponent implements OnInit {
|
|||||||
private computeLastScanAt(vulns: readonly Vulnerability[]): string | null {
|
private computeLastScanAt(vulns: readonly Vulnerability[]): string | null {
|
||||||
const dates = vulns
|
const dates = vulns
|
||||||
.map((v) => v.modifiedAt ?? v.publishedAt ?? null)
|
.map((v) => v.modifiedAt ?? v.publishedAt ?? null)
|
||||||
.filter((v): v is string => typeof v === 'string');
|
.filter((value): value is string => typeof value === 'string');
|
||||||
|
|
||||||
if (dates.length === 0) return null;
|
if (dates.length === 0) return null;
|
||||||
return dates.reduce((max, cur) => (cur > max ? cur : max), dates[0]);
|
return dates.reduce((max, current) => (current > max ? current : max), dates[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private deriveType(artifactId: string): TriageArtifactRow['type'] {
|
private deriveType(artifactId: string): TriageArtifactRow['type'] {
|
||||||
@@ -243,7 +414,35 @@ export class TriageArtifactsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private deriveAttestationCount(vulns: readonly Vulnerability[]): number {
|
private deriveAttestationCount(vulns: readonly Vulnerability[]): number {
|
||||||
// Deterministic placeholder: treat "fixed" and "excepted" as having signed evidence.
|
|
||||||
return vulns.filter((v) => v.status === 'fixed' || v.status === 'excepted').length;
|
return vulns.filter((v) => v.status === 'fixed' || v.status === 'excepted').length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private deriveDefaultLane(summary: {
|
||||||
|
readonly artifactId: string;
|
||||||
|
readonly envs: readonly EnvironmentHint[];
|
||||||
|
readonly openVulns: number;
|
||||||
|
readonly maxSeverity: VulnerabilitySeverity;
|
||||||
|
readonly readyToDeploy: boolean;
|
||||||
|
}): TriageArtifactLane {
|
||||||
|
if (summary.maxSeverity === 'critical' && summary.openVulns > 0) {
|
||||||
|
return 'review';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.envs.includes('legacy')) {
|
||||||
|
return 'review';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (summary.readyToDeploy || summary.envs.includes('internal') || summary.envs.includes('builder')) {
|
||||||
|
return 'quiet';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseLane(value: string | null): TriageArtifactLane {
|
||||||
|
if (value === 'quiet' || value === 'review') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return 'active';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export class TriageAuditBundleNewComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
if (artifactId) {
|
if (artifactId) {
|
||||||
this.subjectName.set(artifactId);
|
this.subjectName.set(artifactId);
|
||||||
this.subjectDigest.set(artifactId);
|
if (looksLikeSha256Digest(artifactId)) {
|
||||||
|
this.subjectDigest.set(artifactId);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,12 +137,20 @@ export class TriageAuditBundleNewComponent implements OnInit, OnDestroy {
|
|||||||
async download(): Promise<void> {
|
async download(): Promise<void> {
|
||||||
const job = this.job();
|
const job = this.job();
|
||||||
if (!job) return;
|
if (!job) return;
|
||||||
const blob = await firstValueFrom(this.api.downloadBundle(job.bundleId));
|
try {
|
||||||
const url = URL.createObjectURL(blob);
|
const blob = await firstValueFrom(this.api.downloadBundle(job.bundleId));
|
||||||
const a = document.createElement('a');
|
const url = URL.createObjectURL(blob);
|
||||||
a.href = url;
|
const a = document.createElement('a');
|
||||||
a.download = `${job.bundleId}.json`;
|
a.href = url;
|
||||||
a.click();
|
a.download = `${job.bundleId}.zip`;
|
||||||
URL.revokeObjectURL(url);
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
this.error.set(err instanceof Error ? err.message : 'Download failed');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function looksLikeSha256Digest(value: string): boolean {
|
||||||
|
return /^sha256:[a-f0-9]{32,}$/i.test(value.trim());
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export class TriageAuditBundlesComponent implements OnInit {
|
|||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `${bundle.bundleId}.json`;
|
a.download = `${bundle.bundleId}.zip`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -57,4 +57,3 @@ export class TriageAuditBundlesComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -256,7 +256,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="workspace-shell">
|
||||||
|
<div class="panel">
|
||||||
@if (!selectedVuln()) {
|
@if (!selectedVuln()) {
|
||||||
<div class="empty">Select a finding to view evidence.</div>
|
<div class="empty">Select a finding to view evidence.</div>
|
||||||
} @else if (activeTab() === 'evidence') {
|
} @else if (activeTab() === 'evidence') {
|
||||||
@@ -800,6 +801,161 @@
|
|||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (selectedVuln()) {
|
||||||
|
<aside class="explainability-rail" data-testid="triage-explainability-rail">
|
||||||
|
<section class="rail-card rail-card--summary">
|
||||||
|
<header class="rail-card__header">
|
||||||
|
<div>
|
||||||
|
<p class="rail-card__eyebrow">Decision aids</p>
|
||||||
|
<h3>Explainability workspace</h3>
|
||||||
|
</div>
|
||||||
|
<app-ai-code-guard-badge
|
||||||
|
[verdict]="aiGuardVerdict()"
|
||||||
|
[totalFindings]="findings().length"
|
||||||
|
[criticalCount]="selectedVuln()!.vuln.severity === 'critical' ? 1 : 0"
|
||||||
|
[highCount]="selectedVuln()!.vuln.severity === 'high' ? 1 : 0"
|
||||||
|
[mediumCount]="selectedVuln()!.vuln.severity === 'medium' ? 1 : 0"
|
||||||
|
[lowCount]="selectedVuln()!.vuln.severity === 'low' ? 1 : 0"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
<p class="hint">
|
||||||
|
Route state keeps the active helper pinned with <code>panel={{ activePanel() }}</code>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav class="rail-tabs" aria-label="Explainability panels">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill"
|
||||||
|
data-testid="triage-panel-reason"
|
||||||
|
[class.pill--active]="activePanel() === 'reason'"
|
||||||
|
[attr.aria-pressed]="activePanel() === 'reason'"
|
||||||
|
(click)="setPanel('reason')"
|
||||||
|
>
|
||||||
|
Reason
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill"
|
||||||
|
data-testid="triage-panel-ai"
|
||||||
|
[class.pill--active]="activePanel() === 'ai'"
|
||||||
|
[attr.aria-pressed]="activePanel() === 'ai'"
|
||||||
|
(click)="setPanel('ai')"
|
||||||
|
>
|
||||||
|
AI
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill"
|
||||||
|
data-testid="triage-panel-provenance"
|
||||||
|
[class.pill--active]="activePanel() === 'provenance'"
|
||||||
|
[attr.aria-pressed]="activePanel() === 'provenance'"
|
||||||
|
(click)="setPanel('provenance')"
|
||||||
|
>
|
||||||
|
Provenance
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill"
|
||||||
|
data-testid="triage-panel-history"
|
||||||
|
[class.pill--active]="activePanel() === 'history'"
|
||||||
|
[attr.aria-pressed]="activePanel() === 'history'"
|
||||||
|
(click)="setPanel('history')"
|
||||||
|
>
|
||||||
|
History
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
@if (activePanel() === 'reason') {
|
||||||
|
<section class="rail-card">
|
||||||
|
<header class="rail-card__header">
|
||||||
|
<div>
|
||||||
|
<p class="rail-card__eyebrow">Reason capsule</p>
|
||||||
|
<h3>Why this finding is here</h3>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn--secondary btn--small" (click)="setTab('policy')">
|
||||||
|
Open policy
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<p class="hint">
|
||||||
|
Severity <strong>{{ selectedVuln()!.vuln.severity }}</strong> with status
|
||||||
|
<code>{{ selectedVuln()!.vuln.status }}</code>.
|
||||||
|
</p>
|
||||||
|
<app-reason-capsule
|
||||||
|
[verdictId]="selectedVuln()!.vuln.vulnId"
|
||||||
|
[findingId]="selectedVuln()!.vuln.vulnId"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
} @else if (activePanel() === 'ai') {
|
||||||
|
<section class="rail-card">
|
||||||
|
<header class="rail-card__header">
|
||||||
|
<div>
|
||||||
|
<p class="rail-card__eyebrow">Advisory AI</p>
|
||||||
|
<h3>Suggested next move</h3>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn--secondary btn--small" (click)="openDecisionDrawer()">
|
||||||
|
Review decision
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<app-ai-recommendation-panel
|
||||||
|
[vulnId]="selectedVuln()!.vuln.vulnId"
|
||||||
|
[showReachability]="selectedVuln()!.vuln.reachabilityStatus === 'reachable'"
|
||||||
|
[autoAnalyze]="false"
|
||||||
|
(suggestionApplied)="onAiSuggestionApplied($event)"
|
||||||
|
(vexSuggestionUsed)="onUseAiVexSuggestion($event)"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
} @else if (activePanel() === 'provenance') {
|
||||||
|
<section class="rail-card">
|
||||||
|
<header class="rail-card__header">
|
||||||
|
<div>
|
||||||
|
<p class="rail-card__eyebrow">Provenance</p>
|
||||||
|
<h3>Snapshot and export</h3>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn--secondary btn--small" (click)="setTab('attestations')">
|
||||||
|
Open attestations
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
@if (knowledgeSnapshot(); as snapshot) {
|
||||||
|
<stella-snapshot-viewer
|
||||||
|
[snapshot]="snapshot"
|
||||||
|
(exportSnapshot)="exportKnowledgeSnapshot($event)"
|
||||||
|
(replay)="replayKnowledgeSnapshot($event)"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<p class="hint">No provenance snapshot is available until a finding is selected.</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
} @else if (activePanel() === 'history') {
|
||||||
|
<section class="rail-card">
|
||||||
|
<header class="rail-card__header">
|
||||||
|
<div>
|
||||||
|
<p class="rail-card__eyebrow">Decision history</p>
|
||||||
|
<h3>Recent decision events</h3>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn--secondary btn--small" (click)="openAuditBundleWizard()">
|
||||||
|
Package evidence
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
@if (decisionHistory().length > 0) {
|
||||||
|
<ol class="decision-history">
|
||||||
|
@for (event of decisionHistory(); track event.id) {
|
||||||
|
<li class="decision-history__item">
|
||||||
|
<p class="decision-history__when">{{ event.when }}</p>
|
||||||
|
<strong>{{ event.title }}</strong>
|
||||||
|
<p>{{ event.detail }}</p>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
} @else {
|
||||||
|
<p class="hint">No decision events are recorded for this finding yet.</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</aside>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -334,6 +334,95 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workspace-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(320px, 380px);
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-4);
|
||||||
|
|
||||||
|
@include screen-below-xl {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explainability-rail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-card {
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--color-surface-secondary);
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-card--summary {
|
||||||
|
background: linear-gradient(160deg, color-mix(in srgb, var(--color-brand-primary) 8%, var(--color-surface-primary)), var(--color-surface-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-card__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-card__eyebrow {
|
||||||
|
margin: 0 0 var(--space-1);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision-history {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision-history__item {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
|
||||||
|
strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision-history__when {
|
||||||
|
margin: 0 0 var(--space-1);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.reachability-header {
|
.reachability-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
|
DestroyRef,
|
||||||
OnInit,
|
OnInit,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
@@ -26,6 +27,19 @@ import { GatedBucketsComponent, type BucketExpandEvent } from './components/gate
|
|||||||
import { GatingExplainerComponent } from './components/gating-explainer/gating-explainer.component';
|
import { GatingExplainerComponent } from './components/gating-explainer/gating-explainer.component';
|
||||||
import { VexTrustDisplayComponent } from './components/vex-trust-display/vex-trust-display.component';
|
import { VexTrustDisplayComponent } from './components/vex-trust-display/vex-trust-display.component';
|
||||||
import { ReplayCommandComponent } from './components/replay-command/replay-command.component';
|
import { ReplayCommandComponent } from './components/replay-command/replay-command.component';
|
||||||
|
import {
|
||||||
|
AiRecommendationPanelComponent,
|
||||||
|
type ApplySuggestionEvent,
|
||||||
|
} from './components/ai-recommendation-panel/ai-recommendation-panel.component';
|
||||||
|
import {
|
||||||
|
AiCodeGuardBadgeComponent,
|
||||||
|
type AiCodeGuardVerdict,
|
||||||
|
} from './components/ai-code-guard-badge/ai-code-guard-badge.component';
|
||||||
|
import { ReasonCapsuleComponent } from './components/reason-capsule/reason-capsule.component';
|
||||||
|
import {
|
||||||
|
SnapshotViewerComponent,
|
||||||
|
type KnowledgeSnapshot,
|
||||||
|
} from './components/snapshot-viewer/snapshot-viewer.component';
|
||||||
import { type TriageQuickVexStatus, TriageShortcutsService } from './services/triage-shortcuts.service';
|
import { type TriageQuickVexStatus, TriageShortcutsService } from './services/triage-shortcuts.service';
|
||||||
import { TtfsTelemetryService } from './services/ttfs-telemetry.service';
|
import { TtfsTelemetryService } from './services/ttfs-telemetry.service';
|
||||||
import { GatingService } from './services/gating.service';
|
import { GatingService } from './services/gating.service';
|
||||||
@@ -45,8 +59,10 @@ import type {
|
|||||||
} from './models/gating.model';
|
} from './models/gating.model';
|
||||||
|
|
||||||
type TabId = 'evidence' | 'overview' | 'reachability' | 'policy' | 'attestations' | 'delta';
|
type TabId = 'evidence' | 'overview' | 'reachability' | 'policy' | 'attestations' | 'delta';
|
||||||
|
type DetailPanelId = 'ai' | 'reason' | 'provenance' | 'history';
|
||||||
|
|
||||||
const TAB_ORDER: readonly TabId[] = ['evidence', 'overview', 'reachability', 'delta', 'policy', 'attestations'];
|
const TAB_ORDER: readonly TabId[] = ['evidence', 'overview', 'reachability', 'delta', 'policy', 'attestations'];
|
||||||
|
const DETAIL_PANEL_ORDER: readonly DetailPanelId[] = ['reason', 'ai', 'provenance', 'history'];
|
||||||
const REACHABILITY_VIEW_ORDER: readonly ('path-list' | 'compact-graph' | 'textual-proof')[] = [
|
const REACHABILITY_VIEW_ORDER: readonly ('path-list' | 'compact-graph' | 'textual-proof')[] = [
|
||||||
'path-list',
|
'path-list',
|
||||||
'compact-graph',
|
'compact-graph',
|
||||||
@@ -87,6 +103,13 @@ interface QuickVerificationState {
|
|||||||
readonly details: readonly string[];
|
readonly details: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WorkspaceHistoryEvent {
|
||||||
|
readonly id: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly detail: string;
|
||||||
|
readonly when: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-triage-workspace',
|
selector: 'app-triage-workspace',
|
||||||
imports: [
|
imports: [
|
||||||
@@ -102,6 +125,10 @@ interface QuickVerificationState {
|
|||||||
VexTrustDisplayComponent,
|
VexTrustDisplayComponent,
|
||||||
ReplayCommandComponent,
|
ReplayCommandComponent,
|
||||||
ErrorStateComponent,
|
ErrorStateComponent,
|
||||||
|
AiRecommendationPanelComponent,
|
||||||
|
AiCodeGuardBadgeComponent,
|
||||||
|
ReasonCapsuleComponent,
|
||||||
|
SnapshotViewerComponent,
|
||||||
],
|
],
|
||||||
providers: [TriageShortcutsService],
|
providers: [TriageShortcutsService],
|
||||||
templateUrl: './triage-workspace.component.html',
|
templateUrl: './triage-workspace.component.html',
|
||||||
@@ -111,6 +138,7 @@ interface QuickVerificationState {
|
|||||||
export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
||||||
private readonly document = inject(DOCUMENT);
|
private readonly document = inject(DOCUMENT);
|
||||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly vulnApi = inject<VulnerabilityApi>(VULNERABILITY_API);
|
private readonly vulnApi = inject<VulnerabilityApi>(VULNERABILITY_API);
|
||||||
@@ -132,6 +160,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
readonly selectedVulnId = signal<string | null>(null);
|
readonly selectedVulnId = signal<string | null>(null);
|
||||||
readonly selectedForBulk = signal<readonly string[]>([]);
|
readonly selectedForBulk = signal<readonly string[]>([]);
|
||||||
readonly activeTab = signal<TabId>('evidence');
|
readonly activeTab = signal<TabId>('evidence');
|
||||||
|
readonly activePanel = signal<DetailPanelId>('reason');
|
||||||
|
|
||||||
// Decision drawer state
|
// Decision drawer state
|
||||||
readonly showDecisionDrawer = signal(false);
|
readonly showDecisionDrawer = signal(false);
|
||||||
@@ -256,6 +285,105 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
readonly aiGuardVerdict = computed<AiCodeGuardVerdict>(() => {
|
||||||
|
const selected = this.selectedVuln();
|
||||||
|
if (!selected) {
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.vuln.status === 'open' && selected.vuln.severity === 'critical') {
|
||||||
|
return 'fail';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.vuln.status === 'open' || selected.vuln.status === 'in_progress') {
|
||||||
|
return 'pass_with_warnings';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'pass';
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly knowledgeSnapshot = computed<KnowledgeSnapshot | null>(() => {
|
||||||
|
const selected = this.selectedVuln();
|
||||||
|
if (!selected) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const evidence = this.getUnifiedEvidenceForFinding(selected.vuln.vulnId);
|
||||||
|
const artifactId = this.artifactId();
|
||||||
|
const sources = [
|
||||||
|
{
|
||||||
|
type: 'security',
|
||||||
|
name: selected.vuln.cveId,
|
||||||
|
epoch: selected.vuln.modifiedAt ?? selected.vuln.publishedAt ?? new Date(0).toISOString(),
|
||||||
|
digest: evidence?.manifests?.manifestHash ?? `sha256:${selected.vuln.vulnId.padEnd(32, '0').slice(0, 32)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'description',
|
||||||
|
name: evidence?.policy?.policyVersion ? `Policy ${evidence.policy.policyVersion}` : 'Policy verdict',
|
||||||
|
epoch: evidence?.generatedAt ?? selected.vuln.modifiedAt ?? new Date(0).toISOString(),
|
||||||
|
digest: evidence?.policy?.policyDigest ?? `sha256:${artifactId.padEnd(32, '0').slice(0, 32)}`,
|
||||||
|
},
|
||||||
|
].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
return {
|
||||||
|
snapshotId: evidence?.manifests?.manifestHash ?? `snapshot-${selected.vuln.vulnId}`,
|
||||||
|
sources,
|
||||||
|
environment: { platform: artifactId || 'unknown-artifact' },
|
||||||
|
engine: { version: evidence?.sbom?.version ?? 'triage-workspace-v1' },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly decisionHistory = computed<readonly WorkspaceHistoryEvent[]>(() => {
|
||||||
|
const selected = this.selectedVuln();
|
||||||
|
if (!selected) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const evidence = this.getUnifiedEvidenceForFinding(selected.vuln.vulnId);
|
||||||
|
const events: WorkspaceHistoryEvent[] = [];
|
||||||
|
|
||||||
|
if (selected.vuln.publishedAt) {
|
||||||
|
events.push({
|
||||||
|
id: `${selected.vuln.vulnId}-published`,
|
||||||
|
title: 'Finding published',
|
||||||
|
detail: `${selected.vuln.cveId} entered the artifact queue.`,
|
||||||
|
when: selected.vuln.publishedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.vuln.modifiedAt) {
|
||||||
|
events.push({
|
||||||
|
id: `${selected.vuln.vulnId}-modified`,
|
||||||
|
title: 'Scanner refreshed',
|
||||||
|
detail: 'Scanner metadata or severity changed for this finding.',
|
||||||
|
when: selected.vuln.modifiedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (evidence?.generatedAt) {
|
||||||
|
events.push({
|
||||||
|
id: `${selected.vuln.vulnId}-evidence`,
|
||||||
|
title: 'Unified evidence generated',
|
||||||
|
detail: 'Reachability, policy, SBOM, and attestation evidence was assembled.',
|
||||||
|
when: evidence.generatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const vexDecision = this.latestVexDecision(selected.vuln.cveId);
|
||||||
|
if (vexDecision) {
|
||||||
|
events.push({
|
||||||
|
id: vexDecision.id,
|
||||||
|
title: `VEX ${vexDecision.status}`,
|
||||||
|
detail: vexDecision.justificationText ?? 'Decision recorded without freeform justification.',
|
||||||
|
when: vexDecision.updatedAt ?? vexDecision.createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return events
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.when.localeCompare(a.when) || a.id.localeCompare(b.id));
|
||||||
|
});
|
||||||
|
|
||||||
readonly findings = computed<readonly FindingCardModel[]>(() => {
|
readonly findings = computed<readonly FindingCardModel[]>(() => {
|
||||||
const id = this.artifactId();
|
const id = this.artifactId();
|
||||||
if (!id) return [];
|
if (!id) return [];
|
||||||
@@ -402,17 +530,33 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
const artifactId = this.route.snapshot.paramMap.get('artifactId') ?? '';
|
const artifactId = this.route.snapshot.paramMap.get('artifactId') ?? '';
|
||||||
const requestedFindingId = this.route.snapshot.queryParamMap.get('findingId');
|
const requestedFindingId = this.route.snapshot.queryParamMap.get('findingId');
|
||||||
const requestedTab = this.parseRequestedTab(this.route.snapshot.queryParamMap.get('tab'));
|
const requestedTab = this.parseRequestedTab(this.route.snapshot.queryParamMap.get('tab'));
|
||||||
|
const requestedPanel = this.parseRequestedPanel(this.route.snapshot.queryParamMap.get('panel'));
|
||||||
this.artifactId.set(artifactId);
|
this.artifactId.set(artifactId);
|
||||||
await this.load();
|
await this.load();
|
||||||
await this.loadVexDecisions();
|
await this.loadVexDecisions();
|
||||||
|
|
||||||
const initialFindingId = this.resolveRequestedFindingId(requestedFindingId);
|
const initialFindingId = this.resolveRequestedFindingId(requestedFindingId);
|
||||||
this.selectedVulnId.set(initialFindingId);
|
this.activePanel.set(requestedPanel);
|
||||||
this.activeTab.set(initialFindingId ? requestedTab : 'evidence');
|
|
||||||
if (initialFindingId) {
|
if (initialFindingId) {
|
||||||
void this.loadUnifiedEvidence(initialFindingId);
|
this.selectFinding(initialFindingId, { resetTab: false, syncQuery: false });
|
||||||
|
this.setTab(requestedTab, { syncQuery: false });
|
||||||
|
} else {
|
||||||
|
this.activeTab.set('evidence');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((queryParamMap) => {
|
||||||
|
const nextFindingId = this.resolveRequestedFindingId(queryParamMap.get('findingId'));
|
||||||
|
const nextTab = this.parseRequestedTab(queryParamMap.get('tab'));
|
||||||
|
const nextPanel = this.parseRequestedPanel(queryParamMap.get('panel'));
|
||||||
|
|
||||||
|
if (nextFindingId && nextFindingId !== this.selectedVulnId()) {
|
||||||
|
this.selectFinding(nextFindingId, { resetTab: false, syncQuery: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activePanel.set(nextPanel);
|
||||||
|
this.setTab(nextFindingId ? nextTab : 'evidence', { syncQuery: false });
|
||||||
|
});
|
||||||
|
|
||||||
// Keep initialization responsive; gated buckets are non-blocking metadata.
|
// Keep initialization responsive; gated buckets are non-blocking metadata.
|
||||||
void this.loadGatedBuckets();
|
void this.loadGatedBuckets();
|
||||||
}
|
}
|
||||||
@@ -546,8 +690,9 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
selectFinding(vulnId: string, options?: { resetTab?: boolean }): void {
|
selectFinding(vulnId: string, options?: { resetTab?: boolean; syncQuery?: boolean }): void {
|
||||||
const previousId = this.selectedVulnId();
|
const previousId = this.selectedVulnId();
|
||||||
|
const nextTab = options?.resetTab ?? true ? 'evidence' : this.activeTab();
|
||||||
|
|
||||||
// If changing selection, start new TTFS tracking
|
// If changing selection, start new TTFS tracking
|
||||||
if (previousId !== vulnId) {
|
if (previousId !== vulnId) {
|
||||||
@@ -573,6 +718,10 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
if (options?.resetTab ?? true) {
|
if (options?.resetTab ?? true) {
|
||||||
this.activeTab.set('evidence');
|
this.activeTab.set('evidence');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options?.syncQuery ?? true) {
|
||||||
|
this.syncWorkspaceQueryState({ findingId: vulnId, tab: nextTab });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleBulkSelection(vulnId: string): void {
|
toggleBulkSelection(vulnId: string): void {
|
||||||
@@ -672,10 +821,10 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
switch (evidenceType) {
|
switch (evidenceType) {
|
||||||
case 'reachability':
|
case 'reachability':
|
||||||
case 'callstack':
|
case 'callstack':
|
||||||
this.activeTab.set('reachability');
|
this.setTab('reachability');
|
||||||
break;
|
break;
|
||||||
case 'provenance':
|
case 'provenance':
|
||||||
this.activeTab.set('attestations');
|
this.setTab('attestations');
|
||||||
break;
|
break;
|
||||||
case 'vex':
|
case 'vex':
|
||||||
this.openDecisionDrawer();
|
this.openDecisionDrawer();
|
||||||
@@ -683,7 +832,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
case 'dsse':
|
case 'dsse':
|
||||||
case 'rekor':
|
case 'rekor':
|
||||||
case 'sbom':
|
case 'sbom':
|
||||||
this.activeTab.set('evidence');
|
this.setTab('evidence');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -708,7 +857,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
'Require at least one verified DSSE signature or Rekor inclusion proof.',
|
'Require at least one verified DSSE signature or Rekor inclusion proof.',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
this.activeTab.set('evidence');
|
this.setTab('evidence');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,7 +878,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
checkedAt,
|
checkedAt,
|
||||||
details,
|
details,
|
||||||
});
|
});
|
||||||
this.activeTab.set('evidence');
|
this.setTab('evidence');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,7 +889,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
checkedAt,
|
checkedAt,
|
||||||
details,
|
details,
|
||||||
});
|
});
|
||||||
this.activeTab.set('evidence');
|
this.setTab('evidence');
|
||||||
|
|
||||||
await this.refreshReplayCommand(selected.vuln.vulnId);
|
await this.refreshReplayCommand(selected.vuln.vulnId);
|
||||||
}
|
}
|
||||||
@@ -755,7 +904,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
'Load evidence for this finding, then ensure DSSE or Rekor proof is present.',
|
'Load evidence for this finding, then ensure DSSE or Rekor proof is present.',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
this.activeTab.set('evidence');
|
this.setTab('evidence');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get evidence hash for audit trail
|
// Get evidence hash for audit trail
|
||||||
@@ -852,6 +1001,46 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
void this.router.navigate(['/triage/audit-bundles/new'], { queryParams: { artifactId } });
|
void this.router.navigate(['/triage/audit-bundles/new'], { queryParams: { artifactId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onAiSuggestionApplied(event: ApplySuggestionEvent): void {
|
||||||
|
const action = event.action;
|
||||||
|
|
||||||
|
if (action.vexStatus) {
|
||||||
|
this.openQuickVex(this.mapSuggestionStatus(action.vexStatus));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === 'apply_fix') {
|
||||||
|
const selected = this.selectedVuln();
|
||||||
|
if (selected) {
|
||||||
|
this.openFixWorkflow(selected);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.openDecisionDrawer();
|
||||||
|
}
|
||||||
|
|
||||||
|
onUseAiVexSuggestion(_suggestion: string): void {
|
||||||
|
this.openDecisionDrawer();
|
||||||
|
this.announceKeyboardStatus('Opened decision drawer with AI suggestion ready for operator review');
|
||||||
|
}
|
||||||
|
|
||||||
|
exportKnowledgeSnapshot(_snapshotId: string): void {
|
||||||
|
this.openAuditBundleWizard();
|
||||||
|
}
|
||||||
|
|
||||||
|
replayKnowledgeSnapshot(snapshotId: string): void {
|
||||||
|
const selected = this.selectedVuln();
|
||||||
|
void this.router.navigate(['/evidence/verify-replay'], {
|
||||||
|
queryParams: {
|
||||||
|
snapshotId,
|
||||||
|
artifactId: this.artifactId(),
|
||||||
|
findingId: selected?.vuln.vulnId,
|
||||||
|
returnTo: this.buildWorkspaceReturnTo(this.activeTab()),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
openCanonicalReachabilityWorkspace(): void {
|
openCanonicalReachabilityWorkspace(): void {
|
||||||
const selected = this.selectedVuln();
|
const selected = this.selectedVuln();
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
@@ -874,8 +1063,18 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setTab(tab: TabId): void {
|
setTab(tab: TabId, options?: { syncQuery?: boolean }): void {
|
||||||
this.activeTab.set(tab);
|
this.activeTab.set(tab);
|
||||||
|
if (options?.syncQuery ?? true) {
|
||||||
|
this.syncWorkspaceQueryState({ tab });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPanel(panel: DetailPanelId, options?: { syncQuery?: boolean }): void {
|
||||||
|
this.activePanel.set(panel);
|
||||||
|
if (options?.syncQuery ?? true) {
|
||||||
|
this.syncWorkspaceQueryState({ panel });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
selectPolicyCell(cell: PolicyGateCell): void {
|
selectPolicyCell(cell: PolicyGateCell): void {
|
||||||
@@ -927,14 +1126,14 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
const reachability = selected.vuln.reachabilityStatus ?? 'unknown';
|
const reachability = selected.vuln.reachabilityStatus ?? 'unknown';
|
||||||
if (reachability === 'unknown') {
|
if (reachability === 'unknown') {
|
||||||
this.activeTab.set('reachability');
|
this.setTab('reachability');
|
||||||
this.focusTab('reachability');
|
this.focusTab('reachability');
|
||||||
this.announceKeyboardStatus('Jumped to reachability evidence');
|
this.announceKeyboardStatus('Jumped to reachability evidence');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.hasSignedEvidence(selected)) {
|
if (!this.hasSignedEvidence(selected)) {
|
||||||
this.activeTab.set('attestations');
|
this.setTab('attestations');
|
||||||
this.focusTab('attestations');
|
this.focusTab('attestations');
|
||||||
this.announceKeyboardStatus('Jumped to provenance evidence');
|
this.announceKeyboardStatus('Jumped to provenance evidence');
|
||||||
return;
|
return;
|
||||||
@@ -944,7 +1143,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private focusReachabilitySearch(): void {
|
private focusReachabilitySearch(): void {
|
||||||
this.activeTab.set('reachability');
|
this.setTab('reachability');
|
||||||
this.focusTab('reachability');
|
this.focusTab('reachability');
|
||||||
|
|
||||||
const view = this.document.defaultView;
|
const view = this.document.defaultView;
|
||||||
@@ -1013,7 +1212,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
const current = this.activeTab();
|
const current = this.activeTab();
|
||||||
const idx = TAB_ORDER.indexOf(current);
|
const idx = TAB_ORDER.indexOf(current);
|
||||||
const next = TAB_ORDER[(idx + delta + TAB_ORDER.length) % TAB_ORDER.length] ?? 'overview';
|
const next = TAB_ORDER[(idx + delta + TAB_ORDER.length) % TAB_ORDER.length] ?? 'overview';
|
||||||
this.activeTab.set(next);
|
this.setTab(next);
|
||||||
this.focusTab(next);
|
this.focusTab(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1069,6 +1268,20 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
return matching ?? null;
|
return matching ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private mapSuggestionStatus(status: 'not_affected' | 'affected_mitigated' | 'affected_unmitigated' | 'fixed' | 'under_investigation'): TriageQuickVexStatus {
|
||||||
|
switch (status) {
|
||||||
|
case 'not_affected':
|
||||||
|
return 'NOT_AFFECTED';
|
||||||
|
case 'affected_mitigated':
|
||||||
|
case 'affected_unmitigated':
|
||||||
|
case 'fixed':
|
||||||
|
return 'AFFECTED';
|
||||||
|
case 'under_investigation':
|
||||||
|
default:
|
||||||
|
return 'UNDER_INVESTIGATION';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private announceKeyboardStatus(message: string, ttlMs = 2000): void {
|
private announceKeyboardStatus(message: string, ttlMs = 2000): void {
|
||||||
this.keyboardStatus.set(message);
|
this.keyboardStatus.set(message);
|
||||||
this.clearKeyboardStatusTimeout();
|
this.clearKeyboardStatusTimeout();
|
||||||
@@ -1330,6 +1543,13 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
return 'evidence';
|
return 'evidence';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseRequestedPanel(value: string | null): DetailPanelId {
|
||||||
|
if (value && DETAIL_PANEL_ORDER.includes(value as DetailPanelId)) {
|
||||||
|
return value as DetailPanelId;
|
||||||
|
}
|
||||||
|
return 'reason';
|
||||||
|
}
|
||||||
|
|
||||||
private resolveRequestedFindingId(requestedFindingId: string | null): string | null {
|
private resolveRequestedFindingId(requestedFindingId: string | null): string | null {
|
||||||
if (requestedFindingId) {
|
if (requestedFindingId) {
|
||||||
const requested = this.findings().find(
|
const requested = this.findings().find(
|
||||||
@@ -1345,13 +1565,31 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
return this.findings()[0]?.vuln.vulnId ?? null;
|
return this.findings()[0]?.vuln.vulnId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncWorkspaceQueryState(next: {
|
||||||
|
readonly findingId?: string | null;
|
||||||
|
readonly tab?: TabId;
|
||||||
|
readonly panel?: DetailPanelId;
|
||||||
|
}): void {
|
||||||
|
void this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: {
|
||||||
|
findingId: next.findingId ?? this.selectedVulnId(),
|
||||||
|
tab: next.tab ?? this.activeTab(),
|
||||||
|
panel: next.panel ?? this.activePanel(),
|
||||||
|
},
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
replaceUrl: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private buildWorkspaceReturnTo(tab: TabId): string {
|
private buildWorkspaceReturnTo(tab: TabId): string {
|
||||||
const selected = this.selectedVuln();
|
const selected = this.selectedVuln();
|
||||||
return this.router.serializeUrl(
|
return this.router.serializeUrl(
|
||||||
this.router.createUrlTree(['/security', 'artifacts', this.artifactId()], {
|
this.router.createUrlTree(['/triage', 'artifacts', this.artifactId()], {
|
||||||
queryParams: {
|
queryParams: {
|
||||||
findingId: selected?.vuln.vulnId,
|
findingId: selected?.vuln.vulnId,
|
||||||
tab,
|
tab,
|
||||||
|
panel: this.activePanel(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -793,7 +793,8 @@ export class AppSidebarComponent implements AfterViewInit {
|
|||||||
StellaOpsScopes.VULN_VIEW,
|
StellaOpsScopes.VULN_VIEW,
|
||||||
],
|
],
|
||||||
children: [
|
children: [
|
||||||
{ id: 'sec-triage', label: 'Triage', route: '/security/triage', icon: 'list' },
|
{ id: 'sec-triage', label: 'Triage', route: '/triage/artifacts', icon: 'list' },
|
||||||
|
{ id: 'sec-audit-bundles', label: 'Audit Bundles', route: '/triage/audit-bundles', icon: 'archive' },
|
||||||
{ id: 'sec-supply-chain', label: 'Supply-Chain Data', route: '/security/supply-chain-data', icon: 'graph' },
|
{ id: 'sec-supply-chain', label: 'Supply-Chain Data', route: '/security/supply-chain-data', icon: 'graph' },
|
||||||
{ id: 'sec-reachability', label: 'Reachability', route: '/security/reachability', icon: 'cpu' },
|
{ id: 'sec-reachability', label: 'Reachability', route: '/security/reachability', icon: 'cpu' },
|
||||||
{ id: 'sec-reports', label: 'Reports', route: '/security/reports', icon: 'book-open' },
|
{ id: 'sec-reports', label: 'Reports', route: '/security/reports', icon: 'book-open' },
|
||||||
|
|||||||
@@ -4,3 +4,4 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './legacy-redirects.routes';
|
export * from './legacy-redirects.routes';
|
||||||
|
export * from './triage.routes';
|
||||||
|
|||||||
@@ -18,16 +18,6 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectRouteTempla
|
|||||||
redirectTo: '/topology/regions',
|
redirectTo: '/topology/regions',
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'triage/artifacts',
|
|
||||||
redirectTo: '/security/artifacts',
|
|
||||||
pathMatch: 'full',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'triage/artifacts/:artifactId',
|
|
||||||
redirectTo: '/security/artifacts/:artifactId',
|
|
||||||
pathMatch: 'full',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'triage/findings',
|
path: 'triage/findings',
|
||||||
redirectTo: '/security/findings',
|
redirectTo: '/security/findings',
|
||||||
|
|||||||
@@ -2,7 +2,18 @@
|
|||||||
* Security & Risk Domain Routes
|
* Security & Risk Domain Routes
|
||||||
* Sprint: SPRINT_20260218_014_FE_ui_v2_rewire_security_risk_consolidation (S9-01 through S9-05)
|
* Sprint: SPRINT_20260218_014_FE_ui_v2_rewire_security_risk_consolidation (S9-01 through S9-05)
|
||||||
*/
|
*/
|
||||||
import { Routes } from '@angular/router';
|
import { inject } from '@angular/core';
|
||||||
|
import { Router, Routes } from '@angular/router';
|
||||||
|
|
||||||
|
function redirectToTriageWorkspace(path: string) {
|
||||||
|
return ({ queryParams, fragment }: { queryParams: Record<string, string>; fragment?: string | null }) => {
|
||||||
|
const router = inject(Router);
|
||||||
|
const target = router.parseUrl(path);
|
||||||
|
target.queryParams = { ...queryParams };
|
||||||
|
target.fragment = fragment ?? null;
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const SECURITY_RISK_ROUTES: Routes = [
|
export const SECURITY_RISK_ROUTES: Routes = [
|
||||||
{
|
{
|
||||||
@@ -309,15 +320,21 @@ export const SECURITY_RISK_ROUTES: Routes = [
|
|||||||
path: 'artifacts',
|
path: 'artifacts',
|
||||||
title: 'Artifacts',
|
title: 'Artifacts',
|
||||||
data: { breadcrumb: 'Artifacts' },
|
data: { breadcrumb: 'Artifacts' },
|
||||||
loadComponent: () =>
|
pathMatch: 'full',
|
||||||
import('../features/triage/triage-artifacts.component').then((m) => m.TriageArtifactsComponent),
|
redirectTo: redirectToTriageWorkspace('/triage/artifacts'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'artifacts/:artifactId',
|
path: 'artifacts/:artifactId',
|
||||||
title: 'Artifact Detail',
|
title: 'Artifact Detail',
|
||||||
data: { breadcrumb: 'Artifact Detail' },
|
data: { breadcrumb: 'Artifact Detail' },
|
||||||
loadComponent: () =>
|
pathMatch: 'full',
|
||||||
import('../features/triage/triage-workspace.component').then((m) => m.TriageWorkspaceComponent),
|
redirectTo: ({ params, queryParams, fragment }) => {
|
||||||
|
const router = inject(Router);
|
||||||
|
const target = router.parseUrl(`/triage/artifacts/${encodeURIComponent(params['artifactId'] ?? '')}`);
|
||||||
|
target.queryParams = { ...queryParams };
|
||||||
|
target.fragment = fragment ?? null;
|
||||||
|
return target;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'symbol-sources',
|
path: 'symbol-sources',
|
||||||
|
|||||||
43
src/Web/StellaOps.Web/src/app/routes/triage.routes.ts
Normal file
43
src/Web/StellaOps.Web/src/app/routes/triage.routes.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
export const TRIAGE_ROUTES: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
pathMatch: 'full',
|
||||||
|
redirectTo: 'artifacts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'artifacts',
|
||||||
|
title: 'Artifact Workspace',
|
||||||
|
data: { breadcrumb: 'Artifacts' },
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../features/triage/triage-artifacts.component').then((m) => m.TriageArtifactsComponent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'artifacts/:artifactId',
|
||||||
|
title: 'Artifact Detail',
|
||||||
|
data: { breadcrumb: 'Artifact Detail' },
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../features/triage/triage-workspace.component').then((m) => m.TriageWorkspaceComponent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'audit-bundles/new',
|
||||||
|
title: 'Create Audit Bundle',
|
||||||
|
data: { breadcrumb: 'Create Bundle' },
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../features/triage/triage-audit-bundle-new.component').then(
|
||||||
|
(m) => m.TriageAuditBundleNewComponent
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'audit-bundles',
|
||||||
|
title: 'Audit Bundles',
|
||||||
|
data: { breadcrumb: 'Audit Bundles' },
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../features/triage/triage-audit-bundles.component').then((m) => m.TriageAuditBundlesComponent),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '**',
|
||||||
|
redirectTo: 'artifacts',
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AUDIT_BUNDLES_API_BASE_URL,
|
||||||
|
AuditBundlesHttpClient,
|
||||||
|
} from '../../app/core/api/audit-bundles.client';
|
||||||
|
import { AuthSessionStore } from '../../app/core/auth/auth-session.store';
|
||||||
|
import { TenantActivationService } from '../../app/core/auth/tenant-activation.service';
|
||||||
|
|
||||||
|
describe('AuditBundlesHttpClient (audit bundle contract)', () => {
|
||||||
|
let client: AuditBundlesHttpClient;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
AuditBundlesHttpClient,
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{ provide: AUDIT_BUNDLES_API_BASE_URL, useValue: '/api/exportcenter' },
|
||||||
|
{
|
||||||
|
provide: AuthSessionStore,
|
||||||
|
useValue: {
|
||||||
|
session: () => ({
|
||||||
|
tokens: { accessToken: 'test-token' },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: TenantActivationService,
|
||||||
|
useValue: {
|
||||||
|
activeTenantId: () => 'tenant-demo',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
client = TestBed.inject(AuditBundlesHttpClient);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
httpMock.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps the ExportCenter list response into the UI bundle list model', async () => {
|
||||||
|
const promise = firstValueFrom(client.listBundles());
|
||||||
|
const req = httpMock.expectOne('/api/exportcenter/v1/audit-bundles');
|
||||||
|
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
expect(req.request.headers.get('Authorization')).toBe('Bearer test-token');
|
||||||
|
expect(req.request.headers.get('X-Stella-Tenant')).toBe('tenant-demo');
|
||||||
|
|
||||||
|
req.flush({
|
||||||
|
bundles: [
|
||||||
|
{
|
||||||
|
bundleId: 'bundle-001',
|
||||||
|
subject: { type: 'IMAGE', name: 'asset-web-prod', digest: { sha256: 'abc' } },
|
||||||
|
status: 'Completed',
|
||||||
|
createdAt: '2026-03-07T10:00:00Z',
|
||||||
|
completedAt: '2026-03-07T10:01:00Z',
|
||||||
|
bundleHash: 'sha256:bundle-001',
|
||||||
|
artifactCount: 4,
|
||||||
|
vexDecisionCount: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
continuationToken: null,
|
||||||
|
hasMore: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await promise;
|
||||||
|
expect(result.count).toBe(1);
|
||||||
|
expect(result.items[0].status).toBe('completed');
|
||||||
|
expect(result.items[0].completedAt).toBe('2026-03-07T10:01:00.000Z');
|
||||||
|
expect(result.items[0].sha256).toBe('sha256:bundle-001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts the real ExportCenter create payload and synthesizes the wizard job model', async () => {
|
||||||
|
const createPromise = firstValueFrom(
|
||||||
|
client.createBundle(
|
||||||
|
{
|
||||||
|
subject: { type: 'IMAGE', name: 'asset-web-prod', digest: { sha256: 'abc' } },
|
||||||
|
contents: {
|
||||||
|
vulnReports: true,
|
||||||
|
sbom: true,
|
||||||
|
vex: false,
|
||||||
|
policyEvals: true,
|
||||||
|
attestations: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ traceId: 'trace-create' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const req = httpMock.expectOne('/api/exportcenter/v1/audit-bundles');
|
||||||
|
expect(req.request.method).toBe('POST');
|
||||||
|
expect(req.request.body).toEqual({
|
||||||
|
subject: { type: 'IMAGE', name: 'asset-web-prod', digest: { sha256: 'abc' } },
|
||||||
|
includeContent: {
|
||||||
|
vulnReports: true,
|
||||||
|
sbom: true,
|
||||||
|
vexDecisions: false,
|
||||||
|
policyEvaluations: true,
|
||||||
|
attestations: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
req.flush({
|
||||||
|
bundleId: 'bundle-001',
|
||||||
|
status: 'Accepted',
|
||||||
|
statusUrl: '/v1/audit-bundles/bundle-001',
|
||||||
|
estimatedCompletionSeconds: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await createPromise;
|
||||||
|
expect(created.status).toBe('queued');
|
||||||
|
expect(created.subject.name).toBe('asset-web-prod');
|
||||||
|
expect(created.statusUrl).toBe('/v1/audit-bundles/bundle-001');
|
||||||
|
expect(created.estimatedCompletionSeconds).toBe(15);
|
||||||
|
expect(created.traceId).toBe('trace-create');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps bundle status responses and preserves the known subject across polling', async () => {
|
||||||
|
const createPromise = firstValueFrom(
|
||||||
|
client.createBundle(
|
||||||
|
{
|
||||||
|
subject: { type: 'IMAGE', name: 'asset-web-prod', digest: { sha256: 'abc' } },
|
||||||
|
contents: {
|
||||||
|
vulnReports: true,
|
||||||
|
sbom: true,
|
||||||
|
vex: true,
|
||||||
|
policyEvals: true,
|
||||||
|
attestations: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ traceId: 'trace-prime' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const createReq = httpMock.expectOne('/api/exportcenter/v1/audit-bundles');
|
||||||
|
createReq.flush({
|
||||||
|
bundleId: 'bundle-002',
|
||||||
|
status: 'Queued',
|
||||||
|
statusUrl: '/v1/audit-bundles/bundle-002',
|
||||||
|
estimatedCompletionSeconds: null,
|
||||||
|
});
|
||||||
|
await createPromise;
|
||||||
|
|
||||||
|
const statusPromise = firstValueFrom(client.getBundle('bundle-002', { traceId: 'trace-status' }));
|
||||||
|
const statusReq = httpMock.expectOne('/api/exportcenter/v1/audit-bundles/bundle-002');
|
||||||
|
|
||||||
|
expect(statusReq.request.method).toBe('GET');
|
||||||
|
statusReq.flush({
|
||||||
|
bundleId: 'bundle-002',
|
||||||
|
status: 'Completed',
|
||||||
|
progress: 100,
|
||||||
|
createdAt: '2026-03-07T10:05:00Z',
|
||||||
|
completedAt: '2026-03-07T10:06:30Z',
|
||||||
|
bundleHash: 'sha256:bundle-002',
|
||||||
|
downloadUrl: '/v1/audit-bundles/bundle-002/download',
|
||||||
|
ociReference: 'oci://stellaops/audit-bundles@bundle-002',
|
||||||
|
errorCode: null,
|
||||||
|
errorMessage: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await statusPromise;
|
||||||
|
expect(status.status).toBe('completed');
|
||||||
|
expect(status.progress).toBe(100);
|
||||||
|
expect(status.subject.name).toBe('asset-web-prod');
|
||||||
|
expect(status.downloadUrl).toBe('/v1/audit-bundles/bundle-002/download');
|
||||||
|
expect(status.ociReference).toBe('oci://stellaops/audit-bundles@bundle-002');
|
||||||
|
expect(status.traceId).toBe('trace-status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('downloads zip content from the dedicated download endpoint', async () => {
|
||||||
|
const promise = firstValueFrom(client.downloadBundle('bundle-003', { traceId: 'trace-download' }));
|
||||||
|
const req = httpMock.expectOne('/api/exportcenter/v1/audit-bundles/bundle-003/download');
|
||||||
|
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
expect(req.request.headers.get('Accept')).toBe('application/zip');
|
||||||
|
|
||||||
|
req.flush(new Blob(['PK\x03\x04'], { type: 'application/zip' }));
|
||||||
|
|
||||||
|
const result = await promise;
|
||||||
|
expect(result.type).toBe('application/zip');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,7 +50,7 @@ describe('TriageAuditBundleNewComponent (audit_bundle)', () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
api.downloadBundle.and.returnValue(
|
api.downloadBundle.and.returnValue(
|
||||||
of(new Blob(['{}'], { type: 'application/json' }))
|
of(new Blob(['PK\x03\x04'], { type: 'application/zip' }))
|
||||||
);
|
);
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
@@ -73,11 +73,12 @@ describe('TriageAuditBundleNewComponent (audit_bundle)', () => {
|
|||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prefills subject fields from artifact query parameter', () => {
|
it('prefills only the subject name from artifact query parameter', () => {
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(component.subjectName()).toBe('asset-web-prod');
|
expect(component.subjectName()).toBe('asset-web-prod');
|
||||||
expect(component.subjectDigest()).toBe('asset-web-prod');
|
expect(component.subjectDigest()).toBe('');
|
||||||
|
expect(component.canCreate()).toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('advances and rewinds wizard steps deterministically', () => {
|
it('advances and rewinds wizard steps deterministically', () => {
|
||||||
@@ -104,4 +105,32 @@ describe('TriageAuditBundleNewComponent (audit_bundle)', () => {
|
|||||||
expect(component.step()).toBe('progress');
|
expect(component.step()).toBe('progress');
|
||||||
expect(component.job()?.bundleId).toBe('bndl-0001');
|
expect(component.job()?.bundleId).toBe('bndl-0001');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('downloads completed bundles with a zip filename', async () => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
component.job.set({
|
||||||
|
bundleId: 'bndl-0001',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: '2026-02-10T22:20:00Z',
|
||||||
|
subject: { type: 'IMAGE', name: 'asset-web-prod', digest: { sha256: 'abc' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
const originalCreateElement = document.createElement.bind(document);
|
||||||
|
spyOn(document, 'createElement').and.callFake(((tagName: string) => {
|
||||||
|
if (tagName.toLowerCase() === 'a') {
|
||||||
|
return anchor;
|
||||||
|
}
|
||||||
|
return originalCreateElement(tagName);
|
||||||
|
}) as typeof document.createElement);
|
||||||
|
spyOn(URL, 'createObjectURL').and.returnValue('blob:zip');
|
||||||
|
spyOn(URL, 'revokeObjectURL');
|
||||||
|
const clickSpy = spyOn(anchor, 'click').and.stub();
|
||||||
|
|
||||||
|
await component.download();
|
||||||
|
|
||||||
|
expect(api.downloadBundle).toHaveBeenCalledWith('bndl-0001');
|
||||||
|
expect(anchor.download).toBe('bndl-0001.zip');
|
||||||
|
expect(clickSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe('TriageAuditBundlesComponent (audit_bundle)', () => {
|
|||||||
};
|
};
|
||||||
api.listBundles.and.returnValue(of({ items: [bundle], count: 1 }));
|
api.listBundles.and.returnValue(of({ items: [bundle], count: 1 }));
|
||||||
api.downloadBundle.and.returnValue(
|
api.downloadBundle.and.returnValue(
|
||||||
of(new Blob(['bundle-json'], { type: 'application/json' }))
|
of(new Blob(['bundle-zip'], { type: 'application/zip' }))
|
||||||
);
|
);
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
@@ -63,14 +63,24 @@ describe('TriageAuditBundlesComponent (audit_bundle)', () => {
|
|||||||
it('downloads selected bundle via API and browser object URL', async () => {
|
it('downloads selected bundle via API and browser object URL', async () => {
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
const originalCreateElement = document.createElement.bind(document);
|
||||||
|
const createElementSpy = spyOn(document, 'createElement').and.callFake(((tagName: string) => {
|
||||||
|
if (tagName.toLowerCase() === 'a') {
|
||||||
|
return anchor;
|
||||||
|
}
|
||||||
|
return originalCreateElement(tagName);
|
||||||
|
}) as typeof document.createElement);
|
||||||
const createUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:mock');
|
const createUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:mock');
|
||||||
const revokeSpy = spyOn(URL, 'revokeObjectURL');
|
const revokeSpy = spyOn(URL, 'revokeObjectURL');
|
||||||
const clickSpy = spyOn(HTMLAnchorElement.prototype, 'click').and.stub();
|
const clickSpy = spyOn(anchor, 'click').and.stub();
|
||||||
|
|
||||||
await component.download(bundle);
|
await component.download(bundle);
|
||||||
|
|
||||||
expect(api.downloadBundle).toHaveBeenCalledWith('bndl-1234');
|
expect(api.downloadBundle).toHaveBeenCalledWith('bndl-1234');
|
||||||
|
expect(createElementSpy).toHaveBeenCalledWith('a');
|
||||||
expect(createUrlSpy).toHaveBeenCalled();
|
expect(createUrlSpy).toHaveBeenCalled();
|
||||||
|
expect(anchor.download).toBe('bndl-1234.zip');
|
||||||
expect(clickSpy).toHaveBeenCalled();
|
expect(clickSpy).toHaveBeenCalled();
|
||||||
expect(revokeSpy).toHaveBeenCalledWith('blob:mock');
|
expect(revokeSpy).toHaveBeenCalledWith('blob:mock');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { TRIAGE_ROUTES } from '../../app/routes/triage.routes';
|
||||||
|
|
||||||
|
describe('TRIAGE_ROUTES', () => {
|
||||||
|
it('exposes the canonical artifact workspace and audit-bundles namespace under /triage', () => {
|
||||||
|
const paths = TRIAGE_ROUTES.map((route) => route.path);
|
||||||
|
expect(paths).toContain('');
|
||||||
|
expect(paths).toContain('artifacts');
|
||||||
|
expect(paths).toContain('artifacts/:artifactId');
|
||||||
|
expect(paths).toContain('audit-bundles');
|
||||||
|
expect(paths).toContain('audit-bundles/new');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the new-bundle wizard before the list route to avoid prefix capture', () => {
|
||||||
|
const listIndex = TRIAGE_ROUTES.findIndex((route) => route.path === 'audit-bundles');
|
||||||
|
const wizardIndex = TRIAGE_ROUTES.findIndex((route) => route.path === 'audit-bundles/new');
|
||||||
|
|
||||||
|
expect(wizardIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(listIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(wizardIndex).toBeLessThan(listIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects the triage root into the canonical artifacts list', () => {
|
||||||
|
const root = TRIAGE_ROUTES.find((route) => route.path === '');
|
||||||
|
expect(root?.redirectTo).toBe('artifacts');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,10 +15,6 @@ describe('Legacy redirect policy', () => {
|
|||||||
path: 'release-orchestrator/environments',
|
path: 'release-orchestrator/environments',
|
||||||
redirectTo: '/topology/regions',
|
redirectTo: '/topology/regions',
|
||||||
}),
|
}),
|
||||||
jasmine.objectContaining({
|
|
||||||
path: 'triage/artifacts/:artifactId',
|
|
||||||
redirectTo: '/security/artifacts/:artifactId',
|
|
||||||
}),
|
|
||||||
jasmine.objectContaining({
|
jasmine.objectContaining({
|
||||||
path: 'triage/findings/:findingId',
|
path: 'triage/findings/:findingId',
|
||||||
redirectTo: '/security/findings/:findingId',
|
redirectTo: '/security/findings/:findingId',
|
||||||
@@ -31,10 +27,6 @@ describe('Legacy redirect policy', () => {
|
|||||||
expect(LEGACY_REDIRECT_ROUTES.length).toBe(LEGACY_REDIRECT_ROUTE_TEMPLATES.length);
|
expect(LEGACY_REDIRECT_ROUTES.length).toBe(LEGACY_REDIRECT_ROUTE_TEMPLATES.length);
|
||||||
expect(LEGACY_REDIRECT_ROUTES).toEqual(
|
expect(LEGACY_REDIRECT_ROUTES).toEqual(
|
||||||
jasmine.arrayContaining([
|
jasmine.arrayContaining([
|
||||||
jasmine.objectContaining({
|
|
||||||
path: 'triage/artifacts',
|
|
||||||
pathMatch: 'full',
|
|
||||||
}),
|
|
||||||
jasmine.objectContaining({
|
jasmine.objectContaining({
|
||||||
path: 'triage/findings',
|
path: 'triage/findings',
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ describe('Legacy Route Migration Framework (routes)', () => {
|
|||||||
it('maps every legacy redirect target to a defined top-level route segment', () => {
|
it('maps every legacy redirect target to a defined top-level route segment', () => {
|
||||||
const topLevelSegments = new Set([
|
const topLevelSegments = new Set([
|
||||||
'dashboard',
|
'dashboard',
|
||||||
|
'ops',
|
||||||
'releases',
|
'releases',
|
||||||
'security',
|
'security',
|
||||||
'evidence',
|
'evidence',
|
||||||
@@ -60,7 +61,6 @@ describe('Legacy Route Migration Framework (routes)', () => {
|
|||||||
const testRoutes: Routes = [
|
const testRoutes: Routes = [
|
||||||
...LEGACY_REDIRECT_ROUTES,
|
...LEGACY_REDIRECT_ROUTES,
|
||||||
{ path: 'platform/ops/health-slo', component: DummyRouteTargetComponent },
|
{ path: 'platform/ops/health-slo', component: DummyRouteTargetComponent },
|
||||||
{ path: 'security/artifacts/:artifactId', component: DummyRouteTargetComponent },
|
|
||||||
{ path: 'topology/regions', component: DummyRouteTargetComponent },
|
{ path: 'topology/regions', component: DummyRouteTargetComponent },
|
||||||
{ path: '**', component: DummyRouteTargetComponent },
|
{ path: '**', component: DummyRouteTargetComponent },
|
||||||
];
|
];
|
||||||
@@ -76,12 +76,7 @@ describe('Legacy Route Migration Framework (routes)', () => {
|
|||||||
|
|
||||||
it('redirects legacy operations paths to platform ops canonical paths', async () => {
|
it('redirects legacy operations paths to platform ops canonical paths', async () => {
|
||||||
await router.navigateByUrl('/ops/health');
|
await router.navigateByUrl('/ops/health');
|
||||||
expect(router.url).toBe('/platform/ops/health-slo');
|
expect(router.url).toBe('/ops/operations/health-slo');
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves route params and query params when redirecting triage artifact detail', async () => {
|
|
||||||
await router.navigateByUrl('/triage/artifacts/artifact-123?tab=evidence');
|
|
||||||
expect(router.url).toBe('/security/artifacts/artifact-123?tab=evidence');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects release orchestrator environments to topology domain', async () => {
|
it('redirects release orchestrator environments to topology domain', async () => {
|
||||||
|
|||||||
@@ -109,6 +109,14 @@ describe('SECURITY_RISK_ROUTES', () => {
|
|||||||
expect(allPaths).toContain('artifacts/:artifactId');
|
expect(allPaths).toContain('artifacts/:artifactId');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps security artifact aliases pointed at the canonical triage workspace', () => {
|
||||||
|
const artifactsRoute = getRouteByPath('artifacts');
|
||||||
|
const detailRoute = getRouteByPath('artifacts/:artifactId');
|
||||||
|
|
||||||
|
expect(typeof artifactsRoute?.redirectTo).toBe('function');
|
||||||
|
expect(typeof detailRoute?.redirectTo).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
it('contains the scan detail route', () => {
|
it('contains the scan detail route', () => {
|
||||||
expect(allPaths).toContain('scans/:scanId');
|
expect(allPaths).toContain('scans/:scanId');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
import { VULNERABILITY_API, type VulnerabilityApi } from '../../app/core/api/vulnerability.client';
|
||||||
|
import type { Vulnerability } from '../../app/core/api/vulnerability.models';
|
||||||
|
import { TriageArtifactsComponent } from '../../app/features/triage/triage-artifacts.component';
|
||||||
|
|
||||||
|
describe('TriageArtifactsComponent', () => {
|
||||||
|
let fixture: ComponentFixture<TriageArtifactsComponent>;
|
||||||
|
let component: TriageArtifactsComponent;
|
||||||
|
let api: jasmine.SpyObj<VulnerabilityApi>;
|
||||||
|
|
||||||
|
const vulnerabilities: Vulnerability[] = [
|
||||||
|
{
|
||||||
|
vulnId: 'finding-review',
|
||||||
|
cveId: 'CVE-2026-1001',
|
||||||
|
title: 'Critical review finding',
|
||||||
|
severity: 'critical',
|
||||||
|
status: 'open',
|
||||||
|
publishedAt: '2026-03-01T00:00:00Z',
|
||||||
|
modifiedAt: '2026-03-02T00:00:00Z',
|
||||||
|
affectedComponents: [{ purl: 'pkg:oci/review', name: 'review', version: '1.0.0', assetIds: ['asset-review-prod'] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
vulnId: 'finding-active',
|
||||||
|
cveId: 'CVE-2026-1002',
|
||||||
|
title: 'Actionable finding',
|
||||||
|
severity: 'high',
|
||||||
|
status: 'open',
|
||||||
|
publishedAt: '2026-03-01T00:00:00Z',
|
||||||
|
modifiedAt: '2026-03-03T00:00:00Z',
|
||||||
|
affectedComponents: [{ purl: 'pkg:oci/active', name: 'active', version: '1.0.0', assetIds: ['asset-active-prod'] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
vulnId: 'finding-quiet',
|
||||||
|
cveId: 'CVE-2026-1003',
|
||||||
|
title: 'Quiet finding',
|
||||||
|
severity: 'low',
|
||||||
|
status: 'fixed',
|
||||||
|
publishedAt: '2026-03-01T00:00:00Z',
|
||||||
|
modifiedAt: '2026-03-04T00:00:00Z',
|
||||||
|
affectedComponents: [{ purl: 'pkg:oci/quiet', name: 'quiet', version: '1.0.0', assetIds: ['asset-builder-quiet'] }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
|
||||||
|
api = jasmine.createSpyObj('VulnerabilityApi', ['listVulnerabilities']) as jasmine.SpyObj<VulnerabilityApi>;
|
||||||
|
api.listVulnerabilities.and.returnValue(of({ items: vulnerabilities, total: vulnerabilities.length, hasMore: false }));
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [TriageArtifactsComponent],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: VULNERABILITY_API, useValue: api },
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: {
|
||||||
|
snapshot: {
|
||||||
|
queryParamMap: convertToParamMap({ lane: 'review' }),
|
||||||
|
},
|
||||||
|
queryParamMap: of(convertToParamMap({ lane: 'review' })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(TriageArtifactsComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
fixture?.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hydrates the canonical lane from query params and shows review artifacts first', async () => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.lane()).toBe('review');
|
||||||
|
expect(component.filteredRows().map((row) => row.artifactId)).toEqual(['asset-review-prod']);
|
||||||
|
expect(component.laneCounts()).toEqual({ active: 1, quiet: 1, review: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists row lane actions and removes moved artifacts from the current lane view', async () => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const reviewRow = component.filteredRows()[0]!;
|
||||||
|
component.moveArtifactToLane(reviewRow, 'quiet');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.filteredRows()).toEqual([]);
|
||||||
|
expect(localStorage.getItem('stellaops.triage.artifact.lanes.v1')).toContain('"asset-review-prod"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the bundle wizard with the selected artifact', async () => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||||
|
|
||||||
|
component.toggleSelection('asset-review-prod');
|
||||||
|
component.openBundleWizardFromSelection();
|
||||||
|
|
||||||
|
expect(navigateSpy).toHaveBeenCalledWith(['/triage/audit-bundles/new'], {
|
||||||
|
queryParams: { artifactId: 'asset-review-prod' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -72,8 +72,14 @@ describe('triage-workspace-with-proof-tree behavior', () => {
|
|||||||
queryParamMap: convertToParamMap({
|
queryParamMap: convertToParamMap({
|
||||||
findingId: 'v-2',
|
findingId: 'v-2',
|
||||||
tab: 'reachability',
|
tab: 'reachability',
|
||||||
|
panel: 'provenance',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
queryParamMap: of(convertToParamMap({
|
||||||
|
findingId: 'v-2',
|
||||||
|
tab: 'reachability',
|
||||||
|
panel: 'provenance',
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -95,6 +101,7 @@ describe('triage-workspace-with-proof-tree behavior', () => {
|
|||||||
expect(component.findings().map((finding) => finding.vuln.vulnId)).toEqual(['v-1', 'v-2']);
|
expect(component.findings().map((finding) => finding.vuln.vulnId)).toEqual(['v-1', 'v-2']);
|
||||||
expect(component.selectedVulnId()).toBe('v-2');
|
expect(component.selectedVulnId()).toBe('v-2');
|
||||||
expect(component.activeTab()).toBe('reachability');
|
expect(component.activeTab()).toBe('reachability');
|
||||||
|
expect(component.activePanel()).toBe('provenance');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports reachability tab with textual proof mode toggle', async () => {
|
it('supports reachability tab with textual proof mode toggle', async () => {
|
||||||
@@ -131,12 +138,50 @@ describe('triage-workspace-with-proof-tree behavior', () => {
|
|||||||
queryParams: {
|
queryParams: {
|
||||||
search: 'CVE-2026-3002',
|
search: 'CVE-2026-3002',
|
||||||
findingId: 'v-2',
|
findingId: 'v-2',
|
||||||
returnTo: '/security/artifacts/asset-web-prod?findingId=v-2&tab=reachability',
|
returnTo: '/triage/artifacts/asset-web-prod?findingId=v-2&tab=reachability&panel=provenance',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('syncs query params when operators switch tabs and panels in the active workspace', async () => {
|
||||||
|
workspaceFixture.detectChanges();
|
||||||
|
await workspaceFixture.whenStable();
|
||||||
|
workspaceFixture.detectChanges();
|
||||||
|
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||||
|
const component = workspaceFixture.componentInstance;
|
||||||
|
|
||||||
|
component.setPanel('history');
|
||||||
|
component.setTab('policy');
|
||||||
|
|
||||||
|
expect(navigateSpy.calls.allArgs()).toEqual([
|
||||||
|
[
|
||||||
|
[],
|
||||||
|
jasmine.objectContaining({
|
||||||
|
queryParams: jasmine.objectContaining({
|
||||||
|
findingId: 'v-2',
|
||||||
|
tab: 'reachability',
|
||||||
|
panel: 'history',
|
||||||
|
}),
|
||||||
|
replaceUrl: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[],
|
||||||
|
jasmine.objectContaining({
|
||||||
|
queryParams: jasmine.objectContaining({
|
||||||
|
findingId: 'v-2',
|
||||||
|
tab: 'policy',
|
||||||
|
panel: 'history',
|
||||||
|
}),
|
||||||
|
replaceUrl: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('renders proof tree digest and emits verify action', () => {
|
it('renders proof tree digest and emits verify action', () => {
|
||||||
const proofFixture = TestBed.createComponent(ProofTreeComponent);
|
const proofFixture = TestBed.createComponent(ProofTreeComponent);
|
||||||
const proofComponent = proofFixture.componentInstance;
|
const proofComponent = proofFixture.componentInstance;
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
|
||||||
|
import type { StubAuthSession } from '../../src/app/testing/auth-fixtures';
|
||||||
|
|
||||||
|
const securitySession: StubAuthSession = {
|
||||||
|
subjectId: 'triage-e2e-user',
|
||||||
|
tenant: 'tenant-default',
|
||||||
|
scopes: [
|
||||||
|
'admin',
|
||||||
|
'ui.read',
|
||||||
|
'scanner:read',
|
||||||
|
'sbom:read',
|
||||||
|
'advisory:read',
|
||||||
|
'vex:read',
|
||||||
|
'findings:read',
|
||||||
|
'vuln:view',
|
||||||
|
'vuln:read',
|
||||||
|
'vex:write',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
authority: {
|
||||||
|
issuer: '/authority',
|
||||||
|
clientId: 'stella-ops-ui',
|
||||||
|
authorizeEndpoint: '/authority/connect/authorize',
|
||||||
|
tokenEndpoint: '/authority/connect/token',
|
||||||
|
logoutEndpoint: '/authority/connect/logout',
|
||||||
|
redirectUri: 'https://127.0.0.1:4400/auth/callback',
|
||||||
|
postLogoutRedirectUri: 'https://127.0.0.1:4400/',
|
||||||
|
scope: 'openid profile email ui.read',
|
||||||
|
audience: '/gateway',
|
||||||
|
dpopAlgorithms: ['ES256'],
|
||||||
|
refreshLeewaySeconds: 60,
|
||||||
|
},
|
||||||
|
apiBaseUrls: {
|
||||||
|
authority: '/authority',
|
||||||
|
scanner: '/scanner',
|
||||||
|
policy: '/policy',
|
||||||
|
concelier: '/concelier',
|
||||||
|
attestor: '/attestor',
|
||||||
|
gateway: '/gateway',
|
||||||
|
},
|
||||||
|
quickstartMode: true,
|
||||||
|
setup: 'complete',
|
||||||
|
};
|
||||||
|
|
||||||
|
const vulnerabilityList = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
vulnId: 'finding-review-001',
|
||||||
|
cveId: 'CVE-2026-4001',
|
||||||
|
title: 'Review-worthy reachable finding',
|
||||||
|
severity: 'critical',
|
||||||
|
status: 'open',
|
||||||
|
publishedAt: '2026-03-07T08:00:00Z',
|
||||||
|
modifiedAt: '2026-03-07T09:00:00Z',
|
||||||
|
reachabilityStatus: 'reachable',
|
||||||
|
reachabilityScore: 92,
|
||||||
|
affectedComponents: [
|
||||||
|
{
|
||||||
|
purl: 'pkg:oci/review@1.0.0',
|
||||||
|
name: 'review-service',
|
||||||
|
version: '1.0.0',
|
||||||
|
assetIds: ['asset-review-prod'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
vulnId: 'finding-quiet-001',
|
||||||
|
cveId: 'CVE-2026-4002',
|
||||||
|
title: 'Quiet lane finding',
|
||||||
|
severity: 'low',
|
||||||
|
status: 'fixed',
|
||||||
|
publishedAt: '2026-03-07T08:00:00Z',
|
||||||
|
modifiedAt: '2026-03-07T09:00:00Z',
|
||||||
|
reachabilityStatus: 'unreachable',
|
||||||
|
reachabilityScore: 10,
|
||||||
|
affectedComponents: [
|
||||||
|
{
|
||||||
|
purl: 'pkg:oci/quiet@1.0.0',
|
||||||
|
name: 'quiet-service',
|
||||||
|
version: '1.0.0',
|
||||||
|
assetIds: ['asset-builder-quiet'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
hasMore: false,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
const unifiedEvidence = {
|
||||||
|
findingId: 'finding-review-001',
|
||||||
|
cveId: 'CVE-2026-4001',
|
||||||
|
componentPurl: 'pkg:oci/review@1.0.0',
|
||||||
|
reachability: {
|
||||||
|
subgraphId: 'sg-review-001',
|
||||||
|
status: 'reachable',
|
||||||
|
confidence: 0.92,
|
||||||
|
method: 'graph',
|
||||||
|
entryPoints: [
|
||||||
|
{
|
||||||
|
id: 'ep-1',
|
||||||
|
type: 'http',
|
||||||
|
name: 'POST /deploy',
|
||||||
|
location: 'src/review.ts:40',
|
||||||
|
distance: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
callChain: {
|
||||||
|
pathLength: 3,
|
||||||
|
pathCount: 1,
|
||||||
|
keySymbols: ['handleDeploy', 'applyPolicy', 'vulnerableFunction'],
|
||||||
|
callGraphUri: '/graphs/review-001',
|
||||||
|
},
|
||||||
|
graphUri: '/graphs/review-001',
|
||||||
|
},
|
||||||
|
attestations: [
|
||||||
|
{
|
||||||
|
id: 'att-001',
|
||||||
|
predicateType: 'https://slsa.dev/provenance/v1',
|
||||||
|
subjectDigest: 'sha256:reviewdigest',
|
||||||
|
signer: 'key-review',
|
||||||
|
signedAt: '2026-03-07T09:05:00Z',
|
||||||
|
verificationStatus: 'verified',
|
||||||
|
transparencyLogEntry: 'rekor-001',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
policy: {
|
||||||
|
policyVersion: '2026.03.07',
|
||||||
|
policyDigest: 'sha256:policydigest',
|
||||||
|
verdict: 'warn',
|
||||||
|
rulesFired: [
|
||||||
|
{
|
||||||
|
ruleId: 'RULE-201',
|
||||||
|
name: 'reachable-critical',
|
||||||
|
effect: 'warn',
|
||||||
|
reason: 'Reachable critical finding requires operator review.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
manifests: {
|
||||||
|
artifactDigest: 'sha256:reviewdigest',
|
||||||
|
manifestHash: 'sha256:manifestreview',
|
||||||
|
feedSnapshotHash: 'sha256:feedreview',
|
||||||
|
policyHash: 'sha256:policydigest',
|
||||||
|
},
|
||||||
|
verification: {
|
||||||
|
status: 'verified',
|
||||||
|
hashesVerified: true,
|
||||||
|
attestationsVerified: true,
|
||||||
|
evidenceComplete: true,
|
||||||
|
verifiedAt: '2026-03-07T09:06:00Z',
|
||||||
|
},
|
||||||
|
replayCommand: 'stella replay finding-review-001',
|
||||||
|
evidenceBundleUrl: '/bundles/review-001.zip',
|
||||||
|
generatedAt: '2026-03-07T09:06:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fulfillJson(route: Route, body: unknown, status = 200): Promise<void> {
|
||||||
|
await route.fulfill({
|
||||||
|
status,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateClientSide(page: Page, target: string): Promise<void> {
|
||||||
|
await page.evaluate((url) => {
|
||||||
|
window.history.pushState({}, '', url);
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate', { state: window.history.state }));
|
||||||
|
}, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupHarness(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript((session) => {
|
||||||
|
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
|
||||||
|
}, securitySession);
|
||||||
|
|
||||||
|
await page.route('**/platform/envsettings.json', (route) => fulfillJson(route, mockConfig));
|
||||||
|
await page.route('**/config.json', (route) => fulfillJson(route, mockConfig));
|
||||||
|
await page.route('**/.well-known/openid-configuration', (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
issuer: 'https://127.0.0.1:4400/authority',
|
||||||
|
authorization_endpoint: 'https://127.0.0.1:4400/authority/connect/authorize',
|
||||||
|
token_endpoint: 'https://127.0.0.1:4400/authority/connect/token',
|
||||||
|
jwks_uri: 'https://127.0.0.1:4400/authority/.well-known/jwks.json',
|
||||||
|
response_types_supported: ['code'],
|
||||||
|
subject_types_supported: ['public'],
|
||||||
|
id_token_signing_alg_values_supported: ['RS256'],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/authority/.well-known/jwks.json', (route) => fulfillJson(route, { keys: [] }));
|
||||||
|
await page.route('**/console/profile**', (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
subjectId: securitySession.subjectId,
|
||||||
|
username: 'triage-e2e',
|
||||||
|
displayName: 'Triage E2E',
|
||||||
|
tenant: securitySession.tenant,
|
||||||
|
roles: ['security-operator'],
|
||||||
|
scopes: securitySession.scopes,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/console/token/introspect**', (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
active: true,
|
||||||
|
tenant: securitySession.tenant,
|
||||||
|
subject: securitySession.subjectId,
|
||||||
|
scopes: securitySession.scopes,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v2/context/regions', (route) =>
|
||||||
|
fulfillJson(route, [{ regionId: 'eu-west', displayName: 'EU West', sortOrder: 1, enabled: true }])
|
||||||
|
);
|
||||||
|
await page.route('**/api/v2/context/environments**', (route) =>
|
||||||
|
fulfillJson(route, [
|
||||||
|
{
|
||||||
|
environmentId: 'prod',
|
||||||
|
regionId: 'eu-west',
|
||||||
|
environmentType: 'prod',
|
||||||
|
displayName: 'Prod',
|
||||||
|
sortOrder: 1,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
await page.route('**/api/v2/context/preferences', (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
tenantId: securitySession.tenant,
|
||||||
|
actorId: securitySession.subjectId,
|
||||||
|
regions: ['eu-west'],
|
||||||
|
environments: ['prod'],
|
||||||
|
timeWindow: '24h',
|
||||||
|
stage: 'all',
|
||||||
|
updatedAt: '2026-03-07T12:00:00Z',
|
||||||
|
updatedBy: securitySession.subjectId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/doctor/api/v1/doctor/trends**', (route) => fulfillJson(route, []));
|
||||||
|
await page.route('**/api/v1/approvals**', (route) => fulfillJson(route, []));
|
||||||
|
await page.route('**/api/v1/telemetry/ttfs', (route) => fulfillJson(route, { accepted: true }, 202));
|
||||||
|
await page.route('**/vuln/vuln**', (route) => fulfillJson(route, vulnerabilityList));
|
||||||
|
await page.route('**/v1/vex-decisions**', (route) =>
|
||||||
|
fulfillJson(route, { items: [], count: 0, continuationToken: null })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/triage/scans/asset-review-prod/gated-buckets', (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
scanId: 'asset-review-prod',
|
||||||
|
unreachableCount: 0,
|
||||||
|
policyDismissedCount: 0,
|
||||||
|
backportedCount: 0,
|
||||||
|
vexNotAffectedCount: 0,
|
||||||
|
supersededCount: 0,
|
||||||
|
userMutedCount: 0,
|
||||||
|
totalHiddenCount: 0,
|
||||||
|
actionableCount: 1,
|
||||||
|
totalCount: 1,
|
||||||
|
computedAt: '2026-03-07T09:06:00Z',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/triage/findings/finding-review-001/evidence**', (route) =>
|
||||||
|
fulfillJson(route, unifiedEvidence)
|
||||||
|
);
|
||||||
|
await page.route('**/v1/audit-bundles', async (route) => {
|
||||||
|
if (route.request().method() === 'GET') {
|
||||||
|
await fulfillJson(route, { bundles: [], continuationToken: null, hasMore: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fulfillJson(route, {
|
||||||
|
bundleId: 'bundle-review-001',
|
||||||
|
status: 'completed',
|
||||||
|
subject: {
|
||||||
|
type: 'IMAGE',
|
||||||
|
name: 'asset-review-prod',
|
||||||
|
digest: { sha256: 'sha256:reviewdigest' },
|
||||||
|
},
|
||||||
|
createdAt: '2026-03-07T09:10:00Z',
|
||||||
|
sha256: 'sha256:bundle-review-001',
|
||||||
|
integrityRootHash: 'sha256:root-review-001',
|
||||||
|
downloadUrl: '/v1/audit-bundles/bundle-review-001/download',
|
||||||
|
ociReference: 'oci://stellaops/audit-bundles@bundle-review-001',
|
||||||
|
statusUrl: '/v1/audit-bundles/bundle-review-001',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await setupHarness(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('artifact workspace supports lane movement and bundle creation shortcuts', async ({ page }) => {
|
||||||
|
await page.goto('/triage/artifacts?lane=review', { waitUntil: 'networkidle' });
|
||||||
|
|
||||||
|
await expect(page.getByTestId('triage-lane-review')).toHaveClass(/lane-pill--active/);
|
||||||
|
await expect(page.getByTestId('triage-artifact-row-asset-review-prod')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel('Select asset-review-prod').check();
|
||||||
|
await page.getByRole('button', { name: 'Move to Quiet' }).click();
|
||||||
|
await expect(page.getByText('No artifacts match the current lane and filters.')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId('triage-lane-quiet').click();
|
||||||
|
await expect(page.getByTestId('triage-artifact-row-asset-review-prod')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel('Select asset-review-prod').check();
|
||||||
|
await page.getByRole('button', { name: 'Build audit bundle' }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/triage\/audit-bundles\/new\?artifactId=asset-review-prod$/);
|
||||||
|
await expect(page.getByLabel('Name')).toHaveValue('asset-review-prod');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace preserves panel and tab state and security aliases resolve into canonical triage routes', async ({ page }) => {
|
||||||
|
await page.goto('/triage/artifacts?lane=review', { waitUntil: 'networkidle' });
|
||||||
|
|
||||||
|
await page.getByTestId('triage-open-asset-review-prod').click();
|
||||||
|
await expect(page).toHaveURL(/\/triage\/artifacts\/asset-review-prod\?lane=review&panel=history$/);
|
||||||
|
await expect(page.getByText('Recent decision events')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Reachability' }).click();
|
||||||
|
await page.getByTestId('triage-panel-ai').click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/triage\/artifacts\/asset-review-prod\?lane=review&panel=ai&findingId=finding-review-001&tab=reachability$/);
|
||||||
|
await expect(page.getByText('Suggested next move')).toBeVisible();
|
||||||
|
|
||||||
|
await navigateClientSide(page, '/security/artifacts/asset-review-prod?tab=reachability&panel=history');
|
||||||
|
await expect(page).toHaveURL(/\/triage\/artifacts\/asset-review-prod\?tab=reachability&panel=history$/);
|
||||||
|
await expect(page.getByText('Recent decision events')).toBeVisible();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user