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) {