update audit bundle and vex decision schemas, add keyboard shortcuts for triage
This commit is contained in:
@@ -179,7 +179,7 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["NOT_AFFECTED", "AFFECTED_MITIGATED", "AFFECTED_UNMITIGATED", "FIXED"],
|
"enum": ["NOT_AFFECTED", "UNDER_INVESTIGATION", "AFFECTED_MITIGATED", "AFFECTED_UNMITIGATED", "FIXED"],
|
||||||
"description": "VEX status"
|
"description": "VEX status"
|
||||||
},
|
},
|
||||||
"path": {
|
"path": {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"NOT_AFFECTED",
|
"NOT_AFFECTED",
|
||||||
|
"UNDER_INVESTIGATION",
|
||||||
"AFFECTED_MITIGATED",
|
"AFFECTED_MITIGATED",
|
||||||
"AFFECTED_UNMITIGATED",
|
"AFFECTED_UNMITIGATED",
|
||||||
"FIXED"
|
"FIXED"
|
||||||
|
|||||||
50
docs/ui/triage.md
Normal file
50
docs/ui/triage.md
Normal file
@@ -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`
|
||||||
|
|
||||||
@@ -31,7 +31,12 @@ export interface BundleArtifact {
|
|||||||
readonly attestation?: BundleArtifactAttestationRef;
|
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 {
|
export interface BundleVexDecisionEntry {
|
||||||
readonly decisionId: string;
|
readonly decisionId: string;
|
||||||
|
|||||||
@@ -144,7 +144,12 @@ export interface AocChainEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// VEX Decision types (based on docs/schemas/vex-decision.schema.json)
|
// VEX Decision types (based on docs/schemas/vex-decision.schema.json)
|
||||||
export type VexStatus = 'NOT_AFFECTED' | 'AFFECTED_MITIGATED' | 'AFFECTED_UNMITIGATED' | 'FIXED';
|
export type VexStatus =
|
||||||
|
| 'NOT_AFFECTED'
|
||||||
|
| 'UNDER_INVESTIGATION'
|
||||||
|
| 'AFFECTED_MITIGATED'
|
||||||
|
| 'AFFECTED_UNMITIGATED'
|
||||||
|
| 'FIXED';
|
||||||
|
|
||||||
export type VexJustificationType =
|
export type VexJustificationType =
|
||||||
| 'CODE_NOT_PRESENT'
|
| 'CODE_NOT_PRESENT'
|
||||||
@@ -204,6 +209,7 @@ export interface VexDecision {
|
|||||||
// VEX status summary for UI display
|
// VEX status summary for UI display
|
||||||
export interface VexStatusSummary {
|
export interface VexStatusSummary {
|
||||||
readonly notAffected: number;
|
readonly notAffected: number;
|
||||||
|
readonly underInvestigation: number;
|
||||||
readonly affectedMitigated: number;
|
readonly affectedMitigated: number;
|
||||||
readonly affectedUnmitigated: number;
|
readonly affectedUnmitigated: number;
|
||||||
readonly fixed: number;
|
readonly fixed: number;
|
||||||
|
|||||||
@@ -765,6 +765,10 @@
|
|||||||
<span class="vex-summary-card__count">{{ vexStatusSummary().notAffected }}</span>
|
<span class="vex-summary-card__count">{{ vexStatusSummary().notAffected }}</span>
|
||||||
<span class="vex-summary-card__label">Not Affected</span>
|
<span class="vex-summary-card__label">Not Affected</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="vex-summary-card vex-summary-card--under-investigation">
|
||||||
|
<span class="vex-summary-card__count">{{ vexStatusSummary().underInvestigation }}</span>
|
||||||
|
<span class="vex-summary-card__label">Under Investigation</span>
|
||||||
|
</div>
|
||||||
<div class="vex-summary-card vex-summary-card--mitigated">
|
<div class="vex-summary-card vex-summary-card--mitigated">
|
||||||
<span class="vex-summary-card__count">{{ vexStatusSummary().affectedMitigated }}</span>
|
<span class="vex-summary-card__count">{{ vexStatusSummary().affectedMitigated }}</span>
|
||||||
<span class="vex-summary-card__label">Mitigated</span>
|
<span class="vex-summary-card__label">Mitigated</span>
|
||||||
|
|||||||
@@ -1459,6 +1459,15 @@ $color-text-muted: #6b7280;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--under-investigation {
|
||||||
|
background: #f5f3ff;
|
||||||
|
border-color: #c4b5fd;
|
||||||
|
|
||||||
|
.vex-summary-card__count {
|
||||||
|
color: #6d28d9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&--mitigated {
|
&--mitigated {
|
||||||
background: #fef9c3;
|
background: #fef9c3;
|
||||||
border-color: #fde047;
|
border-color: #fde047;
|
||||||
@@ -1621,6 +1630,11 @@ $color-text-muted: #6b7280;
|
|||||||
color: #15803d;
|
color: #15803d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.vex-status--under-investigation {
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #6d28d9;
|
||||||
|
}
|
||||||
|
|
||||||
&.vex-status--mitigated {
|
&.vex-status--mitigated {
|
||||||
background: #fef3c7;
|
background: #fef3c7;
|
||||||
color: #92400e;
|
color: #92400e;
|
||||||
|
|||||||
@@ -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<EvidencePanelComponent>;
|
||||||
|
let component: EvidencePanelComponent;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const api = jasmine.createSpyObj<EvidenceApi>('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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -199,6 +199,7 @@ export class EvidencePanelComponent {
|
|||||||
const decisions = this.vexDecisions();
|
const decisions = this.vexDecisions();
|
||||||
return {
|
return {
|
||||||
notAffected: decisions.filter((d) => d.status === 'NOT_AFFECTED').length,
|
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,
|
affectedMitigated: decisions.filter((d) => d.status === 'AFFECTED_MITIGATED').length,
|
||||||
affectedUnmitigated: decisions.filter((d) => d.status === 'AFFECTED_UNMITIGATED').length,
|
affectedUnmitigated: decisions.filter((d) => d.status === 'AFFECTED_UNMITIGATED').length,
|
||||||
fixed: decisions.filter((d) => d.status === 'FIXED').length,
|
fixed: decisions.filter((d) => d.status === 'FIXED').length,
|
||||||
@@ -444,6 +445,8 @@ export class EvidencePanelComponent {
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case 'NOT_AFFECTED':
|
case 'NOT_AFFECTED':
|
||||||
return 'Not Affected';
|
return 'Not Affected';
|
||||||
|
case 'UNDER_INVESTIGATION':
|
||||||
|
return 'Under Investigation';
|
||||||
case 'AFFECTED_MITIGATED':
|
case 'AFFECTED_MITIGATED':
|
||||||
return 'Affected (Mitigated)';
|
return 'Affected (Mitigated)';
|
||||||
case 'AFFECTED_UNMITIGATED':
|
case 'AFFECTED_UNMITIGATED':
|
||||||
@@ -459,6 +462,8 @@ export class EvidencePanelComponent {
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case 'NOT_AFFECTED':
|
case 'NOT_AFFECTED':
|
||||||
return 'vex-status--not-affected';
|
return 'vex-status--not-affected';
|
||||||
|
case 'UNDER_INVESTIGATION':
|
||||||
|
return 'vex-status--under-investigation';
|
||||||
case 'AFFECTED_MITIGATED':
|
case 'AFFECTED_MITIGATED':
|
||||||
return 'vex-status--mitigated';
|
return 'vex-status--mitigated';
|
||||||
case 'AFFECTED_UNMITIGATED':
|
case 'AFFECTED_UNMITIGATED':
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -38,12 +38,18 @@
|
|||||||
} @else if (findings().length === 0) {
|
} @else if (findings().length === 0) {
|
||||||
<div class="empty">No findings for this artifact.</div>
|
<div class="empty">No findings for this artifact.</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="cards">
|
<div class="cards" role="listbox" aria-label="Findings">
|
||||||
@for (finding of findings(); track finding.vuln.vulnId) {
|
@for (finding of findings(); track finding.vuln.vulnId; let i = $index) {
|
||||||
<article
|
<article
|
||||||
class="card"
|
class="card"
|
||||||
[class.card--selected]="selectedVulnId() === finding.vuln.vulnId"
|
[class.card--selected]="selectedVulnId() === finding.vuln.vulnId"
|
||||||
|
role="option"
|
||||||
|
[attr.data-finding-card]="finding.vuln.vulnId"
|
||||||
|
[attr.aria-selected]="selectedVulnId() === finding.vuln.vulnId"
|
||||||
|
[attr.tabindex]="selectedVulnId() === finding.vuln.vulnId || (!selectedVulnId() && i === 0) ? 0 : -1"
|
||||||
|
[attr.aria-label]="finding.vuln.cveId + ' ' + (finding.component?.name ?? '') + ' ' + (finding.component?.version ?? '')"
|
||||||
(click)="selectFinding(finding.vuln.vulnId)"
|
(click)="selectFinding(finding.vuln.vulnId)"
|
||||||
|
(focus)="selectFinding(finding.vuln.vulnId, { resetTab: false })"
|
||||||
>
|
>
|
||||||
<header class="card__header">
|
<header class="card__header">
|
||||||
<label class="bulk-check" (click)="$event.stopPropagation()">
|
<label class="bulk-check" (click)="$event.stopPropagation()">
|
||||||
@@ -94,18 +100,71 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="right">
|
<section class="right">
|
||||||
<header class="tabs">
|
<header class="tabs" role="tablist" aria-label="Evidence tabs">
|
||||||
<button type="button" class="tab" [class.tab--active]="activeTab() === 'overview'" (click)="setTab('overview')">Overview</button>
|
<button
|
||||||
<button type="button" class="tab" [class.tab--active]="activeTab() === 'reachability'" (click)="setTab('reachability')">Reachability</button>
|
id="triage-tab-overview"
|
||||||
<button type="button" class="tab" [class.tab--active]="activeTab() === 'policy'" (click)="setTab('policy')">Policy</button>
|
type="button"
|
||||||
<button type="button" class="tab" [class.tab--active]="activeTab() === 'attestations'" (click)="setTab('attestations')">Attestations</button>
|
role="tab"
|
||||||
|
class="tab"
|
||||||
|
[class.tab--active]="activeTab() === 'overview'"
|
||||||
|
[attr.aria-selected]="activeTab() === 'overview'"
|
||||||
|
[attr.tabindex]="activeTab() === 'overview' ? 0 : -1"
|
||||||
|
aria-controls="triage-panel-overview"
|
||||||
|
(click)="setTab('overview')"
|
||||||
|
>
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="triage-tab-reachability"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="tab"
|
||||||
|
[class.tab--active]="activeTab() === 'reachability'"
|
||||||
|
[attr.aria-selected]="activeTab() === 'reachability'"
|
||||||
|
[attr.tabindex]="activeTab() === 'reachability' ? 0 : -1"
|
||||||
|
aria-controls="triage-panel-reachability"
|
||||||
|
(click)="setTab('reachability')"
|
||||||
|
>
|
||||||
|
Reachability
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="triage-tab-policy"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="tab"
|
||||||
|
[class.tab--active]="activeTab() === 'policy'"
|
||||||
|
[attr.aria-selected]="activeTab() === 'policy'"
|
||||||
|
[attr.tabindex]="activeTab() === 'policy' ? 0 : -1"
|
||||||
|
aria-controls="triage-panel-policy"
|
||||||
|
(click)="setTab('policy')"
|
||||||
|
>
|
||||||
|
Policy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="triage-tab-attestations"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="tab"
|
||||||
|
[class.tab--active]="activeTab() === 'attestations'"
|
||||||
|
[attr.aria-selected]="activeTab() === 'attestations'"
|
||||||
|
[attr.tabindex]="activeTab() === 'attestations' ? 0 : -1"
|
||||||
|
aria-controls="triage-panel-attestations"
|
||||||
|
(click)="setTab('attestations')"
|
||||||
|
>
|
||||||
|
Attestations
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="panel">
|
<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() === 'overview') {
|
} @else if (activeTab() === 'overview') {
|
||||||
<section class="section">
|
<section
|
||||||
|
id="triage-panel-overview"
|
||||||
|
class="section"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="triage-tab-overview"
|
||||||
|
>
|
||||||
<h3>{{ selectedVuln()!.vuln.cveId }}</h3>
|
<h3>{{ selectedVuln()!.vuln.cveId }}</h3>
|
||||||
<p class="muted">{{ selectedVuln()!.vuln.title }}</p>
|
<p class="muted">{{ selectedVuln()!.vuln.title }}</p>
|
||||||
<dl class="kv">
|
<dl class="kv">
|
||||||
@@ -149,18 +208,94 @@
|
|||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
} @else if (activeTab() === 'reachability') {
|
} @else if (activeTab() === 'reachability') {
|
||||||
<section class="section">
|
<section
|
||||||
|
id="triage-panel-reachability"
|
||||||
|
class="section"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="triage-tab-reachability"
|
||||||
|
>
|
||||||
|
<header class="reachability-header">
|
||||||
|
<div>
|
||||||
<h3>Reachability</h3>
|
<h3>Reachability</h3>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Status: <strong>{{ selectedVuln()!.vuln.reachabilityStatus || 'unknown' }}</strong>
|
Status: <strong>{{ selectedVuln()!.vuln.reachabilityStatus || 'unknown' }}</strong>
|
||||||
· score {{ selectedVuln()!.vuln.reachabilityScore ?? 0 }}
|
· score {{ selectedVuln()!.vuln.reachabilityScore ?? 0 }}
|
||||||
</p>
|
</p>
|
||||||
<button type="button" class="btn btn--secondary" (click)="openReachabilityDrawer()" [disabled]="!selectedVuln()!.component">
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn--secondary"
|
||||||
|
(click)="openReachabilityDrawer()"
|
||||||
|
[disabled]="!selectedVuln()!.component"
|
||||||
|
>
|
||||||
View call paths
|
View call paths
|
||||||
</button>
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="reachability-controls">
|
||||||
|
<label class="sr-only" for="reachability-search">Search within reachability graph</label>
|
||||||
|
<input
|
||||||
|
id="reachability-search"
|
||||||
|
#reachabilitySearchInput
|
||||||
|
type="search"
|
||||||
|
class="reachability-search"
|
||||||
|
placeholder="Search nodes, functions, packages..."
|
||||||
|
[value]="reachabilitySearch()"
|
||||||
|
(input)="reachabilitySearch.set($any($event.target).value)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="reachability-views" role="group" aria-label="Reachability view">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill"
|
||||||
|
[class.pill--active]="reachabilityView() === 'path-list'"
|
||||||
|
[attr.aria-pressed]="reachabilityView() === 'path-list'"
|
||||||
|
(click)="reachabilityView.set('path-list')"
|
||||||
|
>
|
||||||
|
Paths
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill"
|
||||||
|
[class.pill--active]="reachabilityView() === 'compact-graph'"
|
||||||
|
[attr.aria-pressed]="reachabilityView() === 'compact-graph'"
|
||||||
|
(click)="reachabilityView.set('compact-graph')"
|
||||||
|
>
|
||||||
|
Graph
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill"
|
||||||
|
[class.pill--active]="reachabilityView() === 'textual-proof'"
|
||||||
|
[attr.aria-pressed]="reachabilityView() === 'textual-proof'"
|
||||||
|
(click)="reachabilityView.set('textual-proof')"
|
||||||
|
>
|
||||||
|
Proof
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (reachabilitySearch().length > 0) {
|
||||||
|
<p class="hint">Search: <code>{{ reachabilitySearch() }}</code></p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="reachability-view">
|
||||||
|
@if (reachabilityView() === 'path-list') {
|
||||||
|
<p class="hint">Path list view (stub). Use “View call paths” for full evidence.</p>
|
||||||
|
} @else if (reachabilityView() === 'compact-graph') {
|
||||||
|
<p class="hint">Compact graph view (stub). Rendering of graph nodes is provided by Reachability Center.</p>
|
||||||
|
} @else if (reachabilityView() === 'textual-proof') {
|
||||||
|
<p class="hint">Textual proof view (stub). Deterministic proof lines will appear here.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
} @else if (activeTab() === 'policy') {
|
} @else if (activeTab() === 'policy') {
|
||||||
<section class="section">
|
<section
|
||||||
|
id="triage-panel-policy"
|
||||||
|
class="section"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="triage-tab-policy"
|
||||||
|
>
|
||||||
<h3>Policy & gating</h3>
|
<h3>Policy & gating</h3>
|
||||||
<p class="hint">Deterministic stub: replace with Policy Engine evaluation data.</p>
|
<p class="hint">Deterministic stub: replace with Policy Engine evaluation data.</p>
|
||||||
|
|
||||||
@@ -216,7 +351,12 @@
|
|||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
} @else if (activeTab() === 'attestations') {
|
} @else if (activeTab() === 'attestations') {
|
||||||
<section class="section">
|
<section
|
||||||
|
id="triage-panel-attestations"
|
||||||
|
class="section"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="triage-tab-attestations"
|
||||||
|
>
|
||||||
<h3>Attestations</h3>
|
<h3>Attestations</h3>
|
||||||
@if (attestationsForSelected().length === 0) {
|
@if (attestationsForSelected().length === 0) {
|
||||||
<p class="hint">No attestations found for this finding.</p>
|
<p class="hint">No attestations found for this finding.</p>
|
||||||
@@ -273,6 +413,8 @@
|
|||||||
[subject]="{ type: 'IMAGE', name: artifactId(), digest: { sha256: artifactId() } }"
|
[subject]="{ type: 'IMAGE', name: artifactId(), digest: { sha256: artifactId() } }"
|
||||||
[vulnerabilityIds]="vexTargetVulnerabilityIds()"
|
[vulnerabilityIds]="vexTargetVulnerabilityIds()"
|
||||||
[availableAttestationIds]="availableAttestationIds()"
|
[availableAttestationIds]="availableAttestationIds()"
|
||||||
|
[existingDecision]="vexExistingDecision()"
|
||||||
|
[initialStatus]="vexModalInitialStatus()"
|
||||||
(closed)="closeVexModal()"
|
(closed)="closeVexModal()"
|
||||||
(saved)="onVexSaved($event)"
|
(saved)="onVexSaved($event)"
|
||||||
/>
|
/>
|
||||||
@@ -284,4 +426,12 @@
|
|||||||
(close)="closeAttestationDetail()"
|
(close)="closeAttestationDetail()"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (showKeyboardHelp()) {
|
||||||
|
<app-keyboard-help (closed)="showKeyboardHelp.set(false)" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (keyboardStatus(); as status) {
|
||||||
|
<div class="kbd-toast" role="status" aria-live="polite">{{ status }}</div>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -84,6 +84,11 @@
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card:focus-visible {
|
||||||
|
outline: 3px solid rgba(37, 99, 235, 0.35);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.card--selected {
|
.card--selected {
|
||||||
border-color: #2563eb;
|
border-color: #2563eb;
|
||||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||||
@@ -196,6 +201,12 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pill--active {
|
||||||
|
border-color: #2563eb;
|
||||||
|
background: rgba(37, 99, 235, 0.1);
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
@@ -214,6 +225,11 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab:focus-visible {
|
||||||
|
outline: 3px solid rgba(37, 99, 235, 0.35);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.tab--active {
|
.tab--active {
|
||||||
border-color: #2563eb;
|
border-color: #2563eb;
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
@@ -224,6 +240,42 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reachability-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reachability-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reachability-search {
|
||||||
|
min-width: 260px;
|
||||||
|
flex: 1 1 260px;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reachability-views {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reachability-view {
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
padding-top: 0.9rem;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
.section + .section {
|
.section + .section {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
@@ -371,3 +423,29 @@
|
|||||||
color: #374151;
|
color: #374151;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kbd-toast {
|
||||||
|
position: fixed;
|
||||||
|
right: 1.25rem;
|
||||||
|
bottom: 1.25rem;
|
||||||
|
z-index: 230;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
background: rgba(15, 23, 42, 0.95);
|
||||||
|
color: #f9fafb;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ComponentFixture, TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
|
import { ComponentFixture, TestBed, fakeAsync, flush, flushMicrotasks } from '@angular/core/testing';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { RouterTestingModule } from '@angular/router/testing';
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
@@ -24,6 +24,8 @@ describe('TriageWorkspaceComponent', () => {
|
|||||||
title: 'Test',
|
title: 'Test',
|
||||||
severity: 'high',
|
severity: 'high',
|
||||||
status: 'open',
|
status: 'open',
|
||||||
|
reachabilityStatus: 'unknown',
|
||||||
|
reachabilityScore: 45,
|
||||||
affectedComponents: [
|
affectedComponents: [
|
||||||
{ purl: 'pkg:x', name: 'x', version: '1', assetIds: ['asset-web-prod'] },
|
{ purl: 'pkg:x', name: 'x', version: '1', assetIds: ['asset-web-prod'] },
|
||||||
],
|
],
|
||||||
@@ -31,11 +33,23 @@ describe('TriageWorkspaceComponent', () => {
|
|||||||
{
|
{
|
||||||
vulnId: 'v-2',
|
vulnId: 'v-2',
|
||||||
cveId: 'CVE-2024-0002',
|
cveId: 'CVE-2024-0002',
|
||||||
|
title: 'Second',
|
||||||
|
severity: 'high',
|
||||||
|
status: 'open',
|
||||||
|
reachabilityStatus: 'reachable',
|
||||||
|
reachabilityScore: 90,
|
||||||
|
affectedComponents: [
|
||||||
|
{ purl: 'pkg:y', name: 'y', version: '1', assetIds: ['asset-web-prod'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
vulnId: 'v-3',
|
||||||
|
cveId: 'CVE-2024-0003',
|
||||||
title: 'Other asset',
|
title: 'Other asset',
|
||||||
severity: 'high',
|
severity: 'high',
|
||||||
status: 'open',
|
status: 'open',
|
||||||
affectedComponents: [
|
affectedComponents: [
|
||||||
{ purl: 'pkg:y', name: 'y', version: '1', assetIds: ['asset-api-prod'] },
|
{ purl: 'pkg:z', name: 'z', version: '1', assetIds: ['asset-api-prod'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -55,13 +69,73 @@ describe('TriageWorkspaceComponent', () => {
|
|||||||
fixture = TestBed.createComponent(TriageWorkspaceComponent);
|
fixture = TestBed.createComponent(TriageWorkspaceComponent);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
it('filters findings by artifactId', fakeAsync(() => {
|
it('filters findings by artifactId', fakeAsync(() => {
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
flushMicrotasks();
|
flushMicrotasks();
|
||||||
|
|
||||||
const component = fixture.componentInstance;
|
const component = fixture.componentInstance;
|
||||||
expect(component.findings().length).toBe(1);
|
expect(component.findings().length).toBe(2);
|
||||||
expect(component.findings()[0].vuln.vulnId).toBe('v-1');
|
expect(component.findings().map((f) => f.vuln.vulnId)).toEqual(['v-1', 'v-2']);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('toggles deterministic sort with S', fakeAsync(() => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
flushMicrotasks();
|
||||||
|
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
expect(component.findingsSort()).toBe('default');
|
||||||
|
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 's', bubbles: true, cancelable: true }));
|
||||||
|
expect(component.findingsSort()).toBe('deterministic');
|
||||||
|
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 's', bubbles: true, cancelable: true }));
|
||||||
|
expect(component.findingsSort()).toBe('default');
|
||||||
|
|
||||||
|
flush();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('toggles keyboard help with ?', fakeAsync(() => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
flushMicrotasks();
|
||||||
|
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
expect(component.showKeyboardHelp()).toBeFalse();
|
||||||
|
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true, cancelable: true }));
|
||||||
|
expect(component.showKeyboardHelp()).toBeTrue();
|
||||||
|
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true, cancelable: true }));
|
||||||
|
expect(component.showKeyboardHelp()).toBeFalse();
|
||||||
|
|
||||||
|
flush();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('selects next finding with ArrowDown', fakeAsync(() => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
flushMicrotasks();
|
||||||
|
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
expect(component.selectedVulnId()).toBe('v-1');
|
||||||
|
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }));
|
||||||
|
expect(component.selectedVulnId()).toBe('v-2');
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('switches to reachability tab with /', fakeAsync(() => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
flushMicrotasks();
|
||||||
|
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
expect(component.activeTab()).toBe('overview');
|
||||||
|
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true, cancelable: true }));
|
||||||
|
expect(component.activeTab()).toBe('reachability');
|
||||||
|
|
||||||
|
flush();
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ import {
|
|||||||
|
|
||||||
type TabId = 'overview' | 'reachability' | 'policy' | 'attestations';
|
type TabId = 'overview' | 'reachability' | 'policy' | 'attestations';
|
||||||
|
|
||||||
|
const TAB_ORDER: readonly TabId[] = ['overview', 'reachability', 'policy', 'attestations'];
|
||||||
|
const REACHABILITY_VIEW_ORDER: readonly Array<'path-list' | 'compact-graph' | 'textual-proof'> = [
|
||||||
|
'path-list',
|
||||||
|
'compact-graph',
|
||||||
|
'textual-proof',
|
||||||
|
];
|
||||||
|
|
||||||
const SEVERITY_ORDER: Record<VulnerabilitySeverity, number> = {
|
const SEVERITY_ORDER: Record<VulnerabilitySeverity, number> = {
|
||||||
critical: 0,
|
critical: 0,
|
||||||
high: 1,
|
high: 1,
|
||||||
@@ -73,6 +80,7 @@ interface PolicyGateCell {
|
|||||||
})
|
})
|
||||||
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 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);
|
||||||
@@ -96,6 +104,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
readonly showVexModal = signal(false);
|
readonly showVexModal = signal(false);
|
||||||
readonly vexTargetVulnerabilityIds = signal<readonly string[]>([]);
|
readonly vexTargetVulnerabilityIds = signal<readonly string[]>([]);
|
||||||
readonly vexModalInitialStatus = signal<VexStatus | null>(null);
|
readonly vexModalInitialStatus = signal<VexStatus | null>(null);
|
||||||
|
readonly vexExistingDecision = signal<VexDecision | null>(null);
|
||||||
|
|
||||||
readonly showReachabilityDrawer = signal(false);
|
readonly showReachabilityDrawer = signal(false);
|
||||||
readonly reachabilityComponent = signal<string | null>(null);
|
readonly reachabilityComponent = signal<string | null>(null);
|
||||||
@@ -109,6 +118,8 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
readonly findingsSort = signal<'default' | 'deterministic'>('default');
|
readonly findingsSort = signal<'default' | 'deterministic'>('default');
|
||||||
readonly keyboardStatus = signal<string | null>(null);
|
readonly keyboardStatus = signal<string | null>(null);
|
||||||
|
|
||||||
|
private keyboardStatusTimeout: number | null = null;
|
||||||
|
|
||||||
readonly selectedVuln = computed(() => {
|
readonly selectedVuln = computed(() => {
|
||||||
const id = this.selectedVulnId();
|
const id = this.selectedVulnId();
|
||||||
return id ? this.findings().find((f) => f.vuln.vulnId === id) ?? null : null;
|
return id ? this.findings().find((f) => f.vuln.vulnId === id) ?? null : null;
|
||||||
@@ -261,6 +272,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.shortcuts.destroy();
|
this.shortcuts.destroy();
|
||||||
|
this.clearKeyboardStatusTimeout();
|
||||||
}
|
}
|
||||||
|
|
||||||
async load(): Promise<void> {
|
async load(): Promise<void> {
|
||||||
@@ -289,10 +301,12 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
selectFinding(vulnId: string): void {
|
selectFinding(vulnId: string, options?: { resetTab?: boolean }): void {
|
||||||
this.selectedVulnId.set(vulnId);
|
this.selectedVulnId.set(vulnId);
|
||||||
|
if (options?.resetTab ?? true) {
|
||||||
this.activeTab.set('overview');
|
this.activeTab.set('overview');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
toggleBulkSelection(vulnId: string): void {
|
toggleBulkSelection(vulnId: string): void {
|
||||||
const current = new Set(this.selectedForBulk());
|
const current = new Set(this.selectedForBulk());
|
||||||
@@ -310,14 +324,17 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
openVexForFinding(vulnId: string): void {
|
openVexForFinding(vulnId: string): void {
|
||||||
this.vexModalInitialStatus.set(null);
|
this.vexModalInitialStatus.set(null);
|
||||||
|
this.vexExistingDecision.set(null);
|
||||||
const selected = this.findings().find((f) => f.vuln.vulnId === vulnId);
|
const selected = this.findings().find((f) => f.vuln.vulnId === vulnId);
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
|
this.vexExistingDecision.set(this.latestVexDecision(selected.vuln.cveId));
|
||||||
this.vexTargetVulnerabilityIds.set([selected.vuln.cveId]);
|
this.vexTargetVulnerabilityIds.set([selected.vuln.cveId]);
|
||||||
this.showVexModal.set(true);
|
this.showVexModal.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
openBulkVex(): void {
|
openBulkVex(): void {
|
||||||
this.vexModalInitialStatus.set(null);
|
this.vexModalInitialStatus.set(null);
|
||||||
|
this.vexExistingDecision.set(null);
|
||||||
const selectedIds = this.selectedForBulk();
|
const selectedIds = this.selectedForBulk();
|
||||||
if (selectedIds.length === 0) return;
|
if (selectedIds.length === 0) return;
|
||||||
const cves = this.findings()
|
const cves = this.findings()
|
||||||
@@ -333,6 +350,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
this.showVexModal.set(false);
|
this.showVexModal.set(false);
|
||||||
this.vexTargetVulnerabilityIds.set([]);
|
this.vexTargetVulnerabilityIds.set([]);
|
||||||
this.vexModalInitialStatus.set(null);
|
this.vexModalInitialStatus.set(null);
|
||||||
|
this.vexExistingDecision.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
onVexSaved(decisions: readonly VexDecision[]): void {
|
onVexSaved(decisions: readonly VexDecision[]): void {
|
||||||
@@ -363,6 +381,8 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
switch (matching.status) {
|
switch (matching.status) {
|
||||||
case 'NOT_AFFECTED':
|
case 'NOT_AFFECTED':
|
||||||
return 'VEX: Not affected';
|
return 'VEX: Not affected';
|
||||||
|
case 'UNDER_INVESTIGATION':
|
||||||
|
return 'VEX: Under investigation';
|
||||||
case 'AFFECTED_MITIGATED':
|
case 'AFFECTED_MITIGATED':
|
||||||
return 'VEX: Mitigated';
|
return 'VEX: Mitigated';
|
||||||
case 'AFFECTED_UNMITIGATED':
|
case 'AFFECTED_UNMITIGATED':
|
||||||
@@ -417,6 +437,285 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
|
|||||||
this.selectedPolicyCell.set(cell);
|
this.selectedPolicyCell.set(cell);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private compareReachability(a: Vulnerability, b: Vulnerability): number {
|
||||||
|
const order: Record<NonNullable<Vulnerability['reachabilityStatus']>, number> = {
|
||||||
|
reachable: 0,
|
||||||
|
unknown: 1,
|
||||||
|
unreachable: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const aOrder = a.reachabilityStatus ? order[a.reachabilityStatus] : 3;
|
||||||
|
const bOrder = b.reachabilityStatus ? order[b.reachabilityStatus] : 3;
|
||||||
|
if (aOrder !== bOrder) return aOrder - bOrder;
|
||||||
|
|
||||||
|
const aScore = a.reachabilityScore ?? -1;
|
||||||
|
const bScore = b.reachabilityScore ?? -1;
|
||||||
|
if (aScore !== bScore) return bScore - aScore;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private compareAge(a: Vulnerability, b: Vulnerability): number {
|
||||||
|
const aWhen = a.modifiedAt ?? a.publishedAt ?? '';
|
||||||
|
const bWhen = b.modifiedAt ?? b.publishedAt ?? '';
|
||||||
|
|
||||||
|
if (!aWhen && !bWhen) return 0;
|
||||||
|
if (!aWhen) return 1;
|
||||||
|
if (!bWhen) return -1;
|
||||||
|
|
||||||
|
return aWhen.localeCompare(bWhen);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isShortcutOverlayOpen(): boolean {
|
||||||
|
return this.showVexModal() || this.showKeyboardHelp() || this.showReachabilityDrawer() || this.attestationModal() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private jumpToIncompleteEvidencePane(): void {
|
||||||
|
const selected = this.selectedVuln();
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
if (!this.latestVexDecision(selected.vuln.cveId)) {
|
||||||
|
this.announceKeyboardStatus('Missing VEX decision');
|
||||||
|
this.openVexForSelected();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reachability = selected.vuln.reachabilityStatus ?? 'unknown';
|
||||||
|
if (reachability === 'unknown') {
|
||||||
|
this.activeTab.set('reachability');
|
||||||
|
this.focusTab('reachability');
|
||||||
|
this.announceKeyboardStatus('Jumped to reachability evidence');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.hasSignedEvidence(selected)) {
|
||||||
|
this.activeTab.set('attestations');
|
||||||
|
this.focusTab('attestations');
|
||||||
|
this.announceKeyboardStatus('Jumped to provenance evidence');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.announceKeyboardStatus('All evidence complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusReachabilitySearch(): void {
|
||||||
|
this.activeTab.set('reachability');
|
||||||
|
this.focusTab('reachability');
|
||||||
|
|
||||||
|
const view = this.document.defaultView;
|
||||||
|
if (!view) return;
|
||||||
|
|
||||||
|
view.setTimeout(() => {
|
||||||
|
try {
|
||||||
|
this.reachabilitySearchInput?.nativeElement.focus();
|
||||||
|
this.reachabilitySearchInput?.nativeElement.select();
|
||||||
|
} catch {
|
||||||
|
// best-effort focus only
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cycleReachabilityView(): void {
|
||||||
|
const current = this.reachabilityView();
|
||||||
|
const idx = REACHABILITY_VIEW_ORDER.indexOf(current);
|
||||||
|
const next = REACHABILITY_VIEW_ORDER[(idx + 1) % REACHABILITY_VIEW_ORDER.length] ?? 'path-list';
|
||||||
|
this.reachabilityView.set(next);
|
||||||
|
this.announceKeyboardStatus(`Reachability view: ${next.replace('-', ' ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleFindingsSort(): void {
|
||||||
|
const next = this.findingsSort() === 'deterministic' ? 'default' : 'deterministic';
|
||||||
|
this.findingsSort.set(next);
|
||||||
|
this.announceKeyboardStatus(next === 'deterministic' ? 'Applied deterministic sort' : 'Applied default sort');
|
||||||
|
}
|
||||||
|
|
||||||
|
private openQuickVex(status: TriageQuickVexStatus): void {
|
||||||
|
const selected = this.selectedVuln();
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
this.vexModalInitialStatus.set(this.mapQuickVexStatus(status));
|
||||||
|
this.vexExistingDecision.set(null);
|
||||||
|
this.vexTargetVulnerabilityIds.set([selected.vuln.cveId]);
|
||||||
|
this.showVexModal.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private openVexForSelected(): void {
|
||||||
|
const selected = this.selectedVuln();
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
this.vexModalInitialStatus.set(null);
|
||||||
|
this.vexExistingDecision.set(this.latestVexDecision(selected.vuln.cveId));
|
||||||
|
this.vexTargetVulnerabilityIds.set([selected.vuln.cveId]);
|
||||||
|
this.showVexModal.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectRelativeFinding(delta: number): void {
|
||||||
|
const findings = this.findings();
|
||||||
|
if (findings.length === 0) return;
|
||||||
|
|
||||||
|
const selectedId = this.selectedVulnId();
|
||||||
|
const currentIndex = selectedId ? findings.findIndex((f) => f.vuln.vulnId === selectedId) : -1;
|
||||||
|
const baseIndex = currentIndex >= 0 ? currentIndex : 0;
|
||||||
|
const nextIndex = Math.min(findings.length - 1, Math.max(0, baseIndex + delta));
|
||||||
|
const nextId = findings[nextIndex]?.vuln.vulnId ?? null;
|
||||||
|
|
||||||
|
if (!nextId || nextId === selectedId) return;
|
||||||
|
this.selectFinding(nextId, { resetTab: false });
|
||||||
|
this.focusFindingCard(nextId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectRelativeTab(delta: number): void {
|
||||||
|
const current = this.activeTab();
|
||||||
|
const idx = TAB_ORDER.indexOf(current);
|
||||||
|
const next = TAB_ORDER[(idx + delta + TAB_ORDER.length) % TAB_ORDER.length] ?? 'overview';
|
||||||
|
this.activeTab.set(next);
|
||||||
|
this.focusTab(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeOverlays(): void {
|
||||||
|
if (this.showKeyboardHelp()) this.showKeyboardHelp.set(false);
|
||||||
|
if (this.showReachabilityDrawer()) this.closeReachabilityDrawer();
|
||||||
|
if (this.attestationModal()) this.attestationModal.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleKeyboardHelp(): void {
|
||||||
|
this.showKeyboardHelp.update((v) => !v);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async copyDsseAttestation(): Promise<void> {
|
||||||
|
const attestation = this.attestationModal() ?? this.attestationsForSelected()[0] ?? null;
|
||||||
|
if (!attestation) {
|
||||||
|
this.announceKeyboardStatus('No attestation available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = attestation.raw ? JSON.stringify(attestation.raw, null, 2) : `attestation:${attestation.attestationId}`;
|
||||||
|
const ok = await this.copyToClipboard(payload);
|
||||||
|
this.announceKeyboardStatus(ok ? 'Copied attestation to clipboard' : 'Unable to copy to clipboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapQuickVexStatus(status: TriageQuickVexStatus): VexStatus {
|
||||||
|
switch (status) {
|
||||||
|
case 'NOT_AFFECTED':
|
||||||
|
return 'NOT_AFFECTED';
|
||||||
|
case 'UNDER_INVESTIGATION':
|
||||||
|
return 'UNDER_INVESTIGATION';
|
||||||
|
case 'AFFECTED':
|
||||||
|
default:
|
||||||
|
return 'AFFECTED_UNMITIGATED';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private latestVexDecision(vulnerabilityId: string): VexDecision | null {
|
||||||
|
const artifactId = this.artifactId();
|
||||||
|
if (!artifactId) return null;
|
||||||
|
|
||||||
|
const matching = this.vexDecisions()
|
||||||
|
.filter((d) => d.vulnerabilityId === vulnerabilityId && d.subject.name === artifactId)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aWhen = a.updatedAt ?? a.createdAt;
|
||||||
|
const bWhen = b.updatedAt ?? b.createdAt;
|
||||||
|
const cmp = bWhen.localeCompare(aWhen);
|
||||||
|
return cmp !== 0 ? cmp : a.id.localeCompare(b.id);
|
||||||
|
})[0];
|
||||||
|
|
||||||
|
return matching ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private announceKeyboardStatus(message: string, ttlMs = 2000): void {
|
||||||
|
this.keyboardStatus.set(message);
|
||||||
|
this.clearKeyboardStatusTimeout();
|
||||||
|
|
||||||
|
const view = this.document.defaultView;
|
||||||
|
if (!view) return;
|
||||||
|
|
||||||
|
this.keyboardStatusTimeout = view.setTimeout(() => {
|
||||||
|
this.keyboardStatusTimeout = null;
|
||||||
|
this.keyboardStatus.set(null);
|
||||||
|
}, ttlMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearKeyboardStatusTimeout(): void {
|
||||||
|
const view = this.document.defaultView;
|
||||||
|
if (this.keyboardStatusTimeout === null || !view) return;
|
||||||
|
view.clearTimeout(this.keyboardStatusTimeout);
|
||||||
|
this.keyboardStatusTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusTab(tab: TabId): void {
|
||||||
|
const button = this.document.getElementById(`triage-tab-${tab}`);
|
||||||
|
if (!(button instanceof HTMLElement)) return;
|
||||||
|
try {
|
||||||
|
button.focus();
|
||||||
|
} catch {
|
||||||
|
// best-effort focus only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusFindingCard(vulnId: string): void {
|
||||||
|
const selector = `[data-finding-card=\"${this.escapeSelectorValue(vulnId)}\"]`;
|
||||||
|
const element = this.host.nativeElement.querySelector<HTMLElement>(selector);
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
element.scrollIntoView({ block: 'nearest' });
|
||||||
|
} catch {
|
||||||
|
// ignore scroll errors
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
element.focus();
|
||||||
|
} catch {
|
||||||
|
// best-effort focus only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeSelectorValue(value: string): string {
|
||||||
|
const cssEscape = this.document.defaultView?.CSS?.escape;
|
||||||
|
if (typeof cssEscape === 'function') return cssEscape(value);
|
||||||
|
return value.replaceAll('\\', '\\\\').replaceAll('\"', '\\\"');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async copyToClipboard(text: string): Promise<boolean> {
|
||||||
|
const clipboard = this.document.defaultView?.navigator?.clipboard;
|
||||||
|
if (clipboard && typeof clipboard.writeText === 'function') {
|
||||||
|
try {
|
||||||
|
await clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// fall back
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.fallbackCopyToClipboard(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fallbackCopyToClipboard(text: string): boolean {
|
||||||
|
const body = this.document.body;
|
||||||
|
if (!body) return false;
|
||||||
|
|
||||||
|
const textarea = this.document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.setAttribute('readonly', 'true');
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.left = '-9999px';
|
||||||
|
textarea.style.top = '0';
|
||||||
|
body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
ok = this.document.execCommand('copy');
|
||||||
|
} catch {
|
||||||
|
ok = false;
|
||||||
|
} finally {
|
||||||
|
body.removeChild(textarea);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
private buildMockAttestation(vuln: Vulnerability, artifactId: string): TriageAttestationDetail {
|
private buildMockAttestation(vuln: Vulnerability, artifactId: string): TriageAttestationDetail {
|
||||||
const verified = vuln.status !== 'open';
|
const verified = vuln.status !== 'open';
|
||||||
const signer = verified
|
const signer = verified
|
||||||
|
|||||||
@@ -38,5 +38,11 @@ describe('VexDecisionModalComponent', () => {
|
|||||||
component.save();
|
component.save();
|
||||||
expect(api.createDecision).toHaveBeenCalled();
|
expect(api.createDecision).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefills status from initialStatus when provided', () => {
|
||||||
|
fixture.componentRef.setInput('initialStatus', 'UNDER_INVESTIGATION');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(component.status()).toBe('UNDER_INVESTIGATION');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import type { VexDecisionCreateRequest } from '../../core/api/vex-decisions.mode
|
|||||||
|
|
||||||
const STATUS_OPTIONS: readonly { value: VexStatus; label: string }[] = [
|
const STATUS_OPTIONS: readonly { value: VexStatus; label: string }[] = [
|
||||||
{ value: 'NOT_AFFECTED', label: 'Not Affected' },
|
{ value: 'NOT_AFFECTED', label: 'Not Affected' },
|
||||||
|
{ value: 'UNDER_INVESTIGATION', label: 'Under Investigation' },
|
||||||
{ value: 'AFFECTED_MITIGATED', label: 'Affected (mitigated)' },
|
{ value: 'AFFECTED_MITIGATED', label: 'Affected (mitigated)' },
|
||||||
{ value: 'AFFECTED_UNMITIGATED', label: 'Affected (unmitigated)' },
|
{ value: 'AFFECTED_UNMITIGATED', label: 'Affected (unmitigated)' },
|
||||||
{ value: 'FIXED', label: 'Fixed' },
|
{ value: 'FIXED', label: 'Fixed' },
|
||||||
@@ -85,6 +86,7 @@ export class VexDecisionModalComponent {
|
|||||||
readonly vulnerabilityIds = input.required<readonly string[]>();
|
readonly vulnerabilityIds = input.required<readonly string[]>();
|
||||||
readonly availableAttestationIds = input<readonly string[]>([]);
|
readonly availableAttestationIds = input<readonly string[]>([]);
|
||||||
readonly existingDecision = input<VexDecision | null>(null);
|
readonly existingDecision = input<VexDecision | null>(null);
|
||||||
|
readonly initialStatus = input<VexStatus | null>(null);
|
||||||
|
|
||||||
readonly closed = output<void>();
|
readonly closed = output<void>();
|
||||||
readonly saved = output<readonly VexDecision[]>();
|
readonly saved = output<readonly VexDecision[]>();
|
||||||
@@ -159,8 +161,7 @@ export class VexDecisionModalComponent {
|
|||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const existing = this.existingDecision();
|
const existing = this.existingDecision();
|
||||||
if (!existing) return;
|
if (existing) {
|
||||||
|
|
||||||
this.status.set(existing.status);
|
this.status.set(existing.status);
|
||||||
this.justificationType.set(existing.justificationType);
|
this.justificationType.set(existing.justificationType);
|
||||||
this.justificationText.set(existing.justificationText ?? '');
|
this.justificationText.set(existing.justificationText ?? '');
|
||||||
@@ -169,6 +170,13 @@ export class VexDecisionModalComponent {
|
|||||||
this.notBefore.set(toLocalDateTimeValue(existing.validFor?.notBefore ?? new Date().toISOString()));
|
this.notBefore.set(toLocalDateTimeValue(existing.validFor?.notBefore ?? new Date().toISOString()));
|
||||||
this.notAfter.set(toLocalDateTimeValue(existing.validFor?.notAfter ?? ''));
|
this.notAfter.set(toLocalDateTimeValue(existing.validFor?.notAfter ?? ''));
|
||||||
this.evidenceRefs.set(existing.evidenceRefs ?? []);
|
this.evidenceRefs.set(existing.evidenceRefs ?? []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialStatus = this.initialStatus();
|
||||||
|
if (initialStatus) {
|
||||||
|
this.status.set(initialStatus);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user