From 4344020dd1d56b7d944d475f128cb8a2c227ff59 Mon Sep 17 00:00:00 2001 From: StellaOps Bot Date: Mon, 15 Dec 2025 09:03:36 +0200 Subject: [PATCH] update audit bundle and vex decision schemas, add keyboard shortcuts for triage --- docs/schemas/audit-bundle-index.schema.json | 2 +- docs/schemas/vex-decision.schema.json | 1 + docs/ui/triage.md | 50 +++ .../src/app/core/api/audit-bundles.models.ts | 7 +- .../src/app/core/api/evidence.models.ts | 24 +- .../evidence/evidence-panel.component.html | 22 +- .../evidence/evidence-panel.component.scss | 60 ++-- .../evidence/evidence-panel.component.spec.ts | 72 +++++ .../evidence/evidence-panel.component.ts | 61 ++-- .../keyboard-shortcuts.service.spec.ts | 98 ++++++ .../triage/triage-workspace.component.html | 188 +++++++++-- .../triage/triage-workspace.component.scss | 78 +++++ .../triage/triage-workspace.component.spec.ts | 82 ++++- .../triage/triage-workspace.component.ts | 303 +++++++++++++++++- .../vex-decision-modal.component.spec.ts | 6 + .../triage/vex-decision-modal.component.ts | 26 +- 16 files changed, 975 insertions(+), 105 deletions(-) create mode 100644 docs/ui/triage.md create mode 100644 src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.spec.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/triage/services/keyboard-shortcuts.service.spec.ts diff --git a/docs/schemas/audit-bundle-index.schema.json b/docs/schemas/audit-bundle-index.schema.json index f0fef6ab9..ad02e1e57 100644 --- a/docs/schemas/audit-bundle-index.schema.json +++ b/docs/schemas/audit-bundle-index.schema.json @@ -179,7 +179,7 @@ }, "status": { "type": "string", - "enum": ["NOT_AFFECTED", "AFFECTED_MITIGATED", "AFFECTED_UNMITIGATED", "FIXED"], + "enum": ["NOT_AFFECTED", "UNDER_INVESTIGATION", "AFFECTED_MITIGATED", "AFFECTED_UNMITIGATED", "FIXED"], "description": "VEX status" }, "path": { diff --git a/docs/schemas/vex-decision.schema.json b/docs/schemas/vex-decision.schema.json index 760407ad5..7f89b87f0 100644 --- a/docs/schemas/vex-decision.schema.json +++ b/docs/schemas/vex-decision.schema.json @@ -32,6 +32,7 @@ "type": "string", "enum": [ "NOT_AFFECTED", + "UNDER_INVESTIGATION", "AFFECTED_MITIGATED", "AFFECTED_UNMITIGATED", "FIXED" diff --git a/docs/ui/triage.md b/docs/ui/triage.md new file mode 100644 index 000000000..fd12bc59e --- /dev/null +++ b/docs/ui/triage.md @@ -0,0 +1,50 @@ +# Triage Workspace + +The triage workspace (`/triage/artifacts/:artifactId`) is optimized for high-frequency analyst workflows: navigate findings, inspect reachability and signed evidence, and record VEX decisions with minimal mouse interaction. + +## Keyboard shortcuts + +Shortcuts are ignored while typing in `input`, `textarea`, `select`, or any `contenteditable` region. + +| Shortcut | Action | +| --- | --- | +| `J` | Jump to first incomplete evidence pane for the selected finding. | +| `Y` | Copy the selected attestation payload to the clipboard. | +| `R` | Cycle reachability view: path list → compact graph → textual proof. | +| `/` | Switch to the Reachability tab and focus the search box. | +| `S` | Toggle deterministic sort for the findings list. | +| `A` | Quick VEX: open the VEX modal with status “Affected (unmitigated)”. | +| `N` | Quick VEX: open the VEX modal with status “Not affected”. | +| `U` | Quick VEX: open the VEX modal with status “Under investigation”. | +| `?` | Toggle the keyboard help overlay. | +| `↑` / `↓` | Select previous / next finding. | +| `←` / `→` | Switch to previous / next evidence tab. | +| `Enter` | Open the VEX modal for the selected finding. | +| `Esc` | Close overlays (keyboard help, reachability drawer, attestation detail). | + +## Evidence completeness (`J`) + +`J` navigates to the first incomplete evidence area for the selected finding using this order: + +1. Missing VEX decision → opens the VEX modal. +2. Reachability is `unknown` → switches to the Reachability tab. +3. Missing signed evidence → switches to the Attestations tab. +4. Otherwise, shows “All evidence complete”. + +## Deterministic sort (`S`) + +When deterministic sort is enabled, findings are sorted by: + +1. Reachability (reachable → unknown → unreachable → missing) +2. Severity +3. Age (modified/published date) +4. Component (PURL) + +Ties break by CVE and internal vulnerability ID to keep ordering stable. + +## Related docs + +- `docs/ui/advisories-and-vex.md` +- `docs/ui/reachability-overlays.md` +- `docs/ui/vulnerability-explorer.md` + diff --git a/src/Web/StellaOps.Web/src/app/core/api/audit-bundles.models.ts b/src/Web/StellaOps.Web/src/app/core/api/audit-bundles.models.ts index 4039b1fdf..fd013c27d 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/audit-bundles.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/audit-bundles.models.ts @@ -31,7 +31,12 @@ export interface BundleArtifact { readonly attestation?: BundleArtifactAttestationRef; } -export type BundleVexStatus = 'NOT_AFFECTED' | 'AFFECTED_MITIGATED' | 'AFFECTED_UNMITIGATED' | 'FIXED'; +export type BundleVexStatus = + | 'NOT_AFFECTED' + | 'UNDER_INVESTIGATION' + | 'AFFECTED_MITIGATED' + | 'AFFECTED_UNMITIGATED' + | 'FIXED'; export interface BundleVexDecisionEntry { readonly decisionId: string; diff --git a/src/Web/StellaOps.Web/src/app/core/api/evidence.models.ts b/src/Web/StellaOps.Web/src/app/core/api/evidence.models.ts index c0b0a00b8..c2fb842bc 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/evidence.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/evidence.models.ts @@ -143,8 +143,13 @@ export interface AocChainEntry { readonly parentHash?: string; } -// VEX Decision types (based on docs/schemas/vex-decision.schema.json) -export type VexStatus = 'NOT_AFFECTED' | 'AFFECTED_MITIGATED' | 'AFFECTED_UNMITIGATED' | 'FIXED'; +// VEX Decision types (based on docs/schemas/vex-decision.schema.json) +export type VexStatus = + | 'NOT_AFFECTED' + | 'UNDER_INVESTIGATION' + | 'AFFECTED_MITIGATED' + | 'AFFECTED_UNMITIGATED' + | 'FIXED'; export type VexJustificationType = | 'CODE_NOT_PRESENT' @@ -202,13 +207,14 @@ export interface VexDecision { } // VEX status summary for UI display -export interface VexStatusSummary { - readonly notAffected: number; - readonly affectedMitigated: number; - readonly affectedUnmitigated: number; - readonly fixed: number; - readonly total: number; -} +export interface VexStatusSummary { + readonly notAffected: number; + readonly underInvestigation: number; + readonly affectedMitigated: number; + readonly affectedUnmitigated: number; + readonly fixed: number; + readonly total: number; +} // VEX conflict indicator export interface VexConflict { diff --git a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.html b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.html index 110e8d24a..7574caa91 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.html +++ b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.html @@ -760,15 +760,19 @@ -
-
- {{ vexStatusSummary().notAffected }} - Not Affected -
-
- {{ vexStatusSummary().affectedMitigated }} - Mitigated -
+
+
+ {{ vexStatusSummary().notAffected }} + Not Affected +
+
+ {{ vexStatusSummary().underInvestigation }} + Under Investigation +
+
+ {{ vexStatusSummary().affectedMitigated }} + Mitigated +
{{ vexStatusSummary().affectedUnmitigated }} Unmitigated diff --git a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.scss b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.scss index cf3c58c67..8f51d69fb 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.scss @@ -1450,20 +1450,29 @@ $color-text-muted: #6b7280; text-align: center; } - &--not-affected { - background: #f0fdf4; - border-color: #86efac; - - .vex-summary-card__count { - color: #15803d; - } - } - - &--mitigated { - background: #fef9c3; - border-color: #fde047; - - .vex-summary-card__count { + &--not-affected { + background: #f0fdf4; + border-color: #86efac; + + .vex-summary-card__count { + color: #15803d; + } + } + + &--under-investigation { + background: #f5f3ff; + border-color: #c4b5fd; + + .vex-summary-card__count { + color: #6d28d9; + } + } + + &--mitigated { + background: #fef9c3; + border-color: #fde047; + + .vex-summary-card__count { color: #a16207; } } @@ -1616,15 +1625,20 @@ $color-text-muted: #6b7280; font-size: 0.75rem; font-weight: 600; - &.vex-status--not-affected { - background: #dcfce7; - color: #15803d; - } - - &.vex-status--mitigated { - background: #fef3c7; - color: #92400e; - } + &.vex-status--not-affected { + background: #dcfce7; + color: #15803d; + } + + &.vex-status--under-investigation { + background: #f5f3ff; + color: #6d28d9; + } + + &.vex-status--mitigated { + background: #fef3c7; + color: #92400e; + } &.vex-status--unmitigated { background: #fee2e2; diff --git a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.spec.ts new file mode 100644 index 000000000..53750545f --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.spec.ts @@ -0,0 +1,72 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import type { EvidenceApi } from '../../core/api/evidence.client'; +import { EVIDENCE_API } from '../../core/api/evidence.client'; +import type { EvidenceData, VexDecision, VexStatus } from '../../core/api/evidence.models'; +import { EvidencePanelComponent } from './evidence-panel.component'; + +function createVexDecision(status: VexStatus, id: string): VexDecision { + return { + id, + vulnerabilityId: 'CVE-2024-0001', + subject: { type: 'IMAGE', name: 'asset-web-prod', digest: { sha256: 'sha' } }, + status, + justificationType: 'OTHER', + createdBy: { id: 'u-1', displayName: 'User' }, + createdAt: '2025-12-01T00:00:00Z', + evidenceRefs: [], + }; +} + +describe('EvidencePanelComponent', () => { + let fixture: ComponentFixture; + let component: EvidencePanelComponent; + + beforeEach(async () => { + const api = jasmine.createSpyObj('EvidenceApi', [ + 'getEvidenceForAdvisory', + 'getObservation', + 'getLinkset', + 'getPolicyEvidence', + 'downloadRawDocument', + ]); + + await TestBed.configureTestingModule({ + imports: [EvidencePanelComponent], + providers: [{ provide: EVIDENCE_API, useValue: api }], + }).compileComponents(); + + fixture = TestBed.createComponent(EvidencePanelComponent); + component = fixture.componentInstance; + }); + + it('includes UNDER_INVESTIGATION in the VEX status summary', () => { + const data: EvidenceData = { + advisoryId: 'CVE-2024-0001', + observations: [], + hasConflicts: false, + conflictCount: 0, + vexDecisions: [ + createVexDecision('NOT_AFFECTED', 'd-1'), + createVexDecision('UNDER_INVESTIGATION', 'd-2'), + createVexDecision('AFFECTED_MITIGATED', 'd-3'), + ], + }; + + fixture.componentRef.setInput('advisoryId', 'CVE-2024-0001'); + fixture.componentRef.setInput('evidenceData', data); + fixture.detectChanges(); + + expect(component.vexStatusSummary().underInvestigation).toBe(1); + + component.activeTab.set('vex'); + fixture.detectChanges(); + + const countEl = fixture.nativeElement.querySelector( + '.vex-summary-card--under-investigation .vex-summary-card__count' + ) as HTMLElement | null; + expect(countEl).not.toBeNull(); + expect(countEl?.textContent?.trim()).toBe('1'); + }); +}); + diff --git a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.ts index fe07bd166..f3e8c4540 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-panel.component.ts @@ -195,16 +195,17 @@ export class EvidencePanelComponent { readonly showPermalink = signal(false); readonly permalinkCopied = signal(false); - readonly vexStatusSummary = computed((): VexStatusSummary => { - const decisions = this.vexDecisions(); - return { - notAffected: decisions.filter((d) => d.status === 'NOT_AFFECTED').length, - affectedMitigated: decisions.filter((d) => d.status === 'AFFECTED_MITIGATED').length, - affectedUnmitigated: decisions.filter((d) => d.status === 'AFFECTED_UNMITIGATED').length, - fixed: decisions.filter((d) => d.status === 'FIXED').length, - total: decisions.length, - }; - }); + readonly vexStatusSummary = computed((): VexStatusSummary => { + const decisions = this.vexDecisions(); + return { + notAffected: decisions.filter((d) => d.status === 'NOT_AFFECTED').length, + underInvestigation: decisions.filter((d) => d.status === 'UNDER_INVESTIGATION').length, + affectedMitigated: decisions.filter((d) => d.status === 'AFFECTED_MITIGATED').length, + affectedUnmitigated: decisions.filter((d) => d.status === 'AFFECTED_UNMITIGATED').length, + fixed: decisions.filter((d) => d.status === 'FIXED').length, + total: decisions.length, + }; + }); // Permalink computed value readonly permalink = computed(() => { @@ -440,30 +441,34 @@ export class EvidencePanelComponent { } // VEX helpers - getVexStatusLabel(status: VexStatus): string { - switch (status) { - case 'NOT_AFFECTED': - return 'Not Affected'; - case 'AFFECTED_MITIGATED': - return 'Affected (Mitigated)'; - case 'AFFECTED_UNMITIGATED': - return 'Affected (Unmitigated)'; - case 'FIXED': + getVexStatusLabel(status: VexStatus): string { + switch (status) { + case 'NOT_AFFECTED': + return 'Not Affected'; + case 'UNDER_INVESTIGATION': + return 'Under Investigation'; + case 'AFFECTED_MITIGATED': + return 'Affected (Mitigated)'; + case 'AFFECTED_UNMITIGATED': + return 'Affected (Unmitigated)'; + case 'FIXED': return 'Fixed'; default: return status; } } - getVexStatusClass(status: VexStatus): string { - switch (status) { - case 'NOT_AFFECTED': - return 'vex-status--not-affected'; - case 'AFFECTED_MITIGATED': - return 'vex-status--mitigated'; - case 'AFFECTED_UNMITIGATED': - return 'vex-status--unmitigated'; - case 'FIXED': + getVexStatusClass(status: VexStatus): string { + switch (status) { + case 'NOT_AFFECTED': + return 'vex-status--not-affected'; + case 'UNDER_INVESTIGATION': + return 'vex-status--under-investigation'; + case 'AFFECTED_MITIGATED': + return 'vex-status--mitigated'; + case 'AFFECTED_UNMITIGATED': + return 'vex-status--unmitigated'; + case 'FIXED': return 'vex-status--fixed'; default: return ''; diff --git a/src/Web/StellaOps.Web/src/app/features/triage/services/keyboard-shortcuts.service.spec.ts b/src/Web/StellaOps.Web/src/app/features/triage/services/keyboard-shortcuts.service.spec.ts new file mode 100644 index 000000000..0900258d1 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/triage/services/keyboard-shortcuts.service.spec.ts @@ -0,0 +1,98 @@ +import { TestBed } from '@angular/core/testing'; + +import { KeyboardShortcutsService } from './keyboard-shortcuts.service'; + +describe('KeyboardShortcutsService', () => { + let service: KeyboardShortcutsService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(KeyboardShortcutsService); + }); + + it('runs registered shortcuts and prevents default', () => { + const action = jasmine.createSpy('action'); + const cleanup = service.register({ + key: 'k', + description: 'Test shortcut', + category: 'utility', + action, + }); + + const event = new KeyboardEvent('keydown', { key: 'k', bubbles: true, cancelable: true }); + const dispatched = document.dispatchEvent(event); + + expect(action).toHaveBeenCalledTimes(1); + expect(dispatched).toBeFalse(); + expect(event.defaultPrevented).toBeTrue(); + + cleanup(); + }); + + it('ignores repeated keydown events', () => { + const action = jasmine.createSpy('action'); + const cleanup = service.register({ + key: 'k', + description: 'Test shortcut', + category: 'utility', + action, + }); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', repeat: true, bubbles: true, cancelable: true })); + expect(action).not.toHaveBeenCalled(); + + cleanup(); + }); + + it('ignores shortcuts while typing in editable elements', () => { + const action = jasmine.createSpy('action'); + const cleanup = service.register({ + key: 'k', + description: 'Test shortcut', + category: 'utility', + action, + }); + + const tags = ['input', 'textarea', 'select'] as const; + for (const tag of tags) { + const element = document.createElement(tag); + document.body.appendChild(element); + try { + const event = new KeyboardEvent('keydown', { key: 'k', bubbles: true, cancelable: true }); + const dispatched = element.dispatchEvent(event); + expect(action).not.toHaveBeenCalled(); + expect(dispatched).toBeTrue(); + expect(event.defaultPrevented).toBeFalse(); + } finally { + document.body.removeChild(element); + } + } + + cleanup(); + }); + + it('ignores shortcuts inside contenteditable containers', () => { + const action = jasmine.createSpy('action'); + const cleanup = service.register({ + key: 'k', + description: 'Test shortcut', + category: 'utility', + action, + }); + + const host = document.createElement('div'); + host.setAttribute('contenteditable', 'true'); + const child = document.createElement('span'); + host.appendChild(child); + document.body.appendChild(host); + try { + child.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', bubbles: true, cancelable: true })); + expect(action).not.toHaveBeenCalled(); + } finally { + document.body.removeChild(host); + } + + cleanup(); + }); +}); + diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.html b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.html index f8bb64770..ac9f88930 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.html +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.html @@ -38,12 +38,18 @@ } @else if (findings().length === 0) {
No findings for this artifact.
} @else { -
- @for (finding of findings(); track finding.vuln.vulnId) { +
+ @for (finding of findings(); track finding.vuln.vulnId; let i = $index) {