update audit bundle and vex decision schemas, add keyboard shortcuts for triage

This commit is contained in:
StellaOps Bot
2025-12-15 09:03:36 +02:00
parent b058dbe031
commit 4344020dd1
16 changed files with 975 additions and 105 deletions

View File

@@ -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": {

View File

@@ -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
View 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`

View File

@@ -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;

View File

@@ -143,8 +143,13 @@ export interface AocChainEntry {
readonly parentHash?: string; readonly parentHash?: string;
} }
// 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'
@@ -202,13 +207,14 @@ 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 affectedMitigated: number; readonly underInvestigation: number;
readonly affectedUnmitigated: number; readonly affectedMitigated: number;
readonly fixed: number; readonly affectedUnmitigated: number;
readonly total: number; readonly fixed: number;
} readonly total: number;
}
// VEX conflict indicator // VEX conflict indicator
export interface VexConflict { export interface VexConflict {

View File

@@ -760,15 +760,19 @@
</header> </header>
<!-- Status Summary Cards --> <!-- Status Summary Cards -->
<div class="vex-panel__summary"> <div class="vex-panel__summary">
<div class="vex-summary-card vex-summary-card--not-affected"> <div class="vex-summary-card vex-summary-card--not-affected">
<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--mitigated"> <div class="vex-summary-card vex-summary-card--under-investigation">
<span class="vex-summary-card__count">{{ vexStatusSummary().affectedMitigated }}</span> <span class="vex-summary-card__count">{{ vexStatusSummary().underInvestigation }}</span>
<span class="vex-summary-card__label">Mitigated</span> <span class="vex-summary-card__label">Under Investigation</span>
</div> </div>
<div class="vex-summary-card vex-summary-card--mitigated">
<span class="vex-summary-card__count">{{ vexStatusSummary().affectedMitigated }}</span>
<span class="vex-summary-card__label">Mitigated</span>
</div>
<div class="vex-summary-card vex-summary-card--unmitigated"> <div class="vex-summary-card vex-summary-card--unmitigated">
<span class="vex-summary-card__count">{{ vexStatusSummary().affectedUnmitigated }}</span> <span class="vex-summary-card__count">{{ vexStatusSummary().affectedUnmitigated }}</span>
<span class="vex-summary-card__label">Unmitigated</span> <span class="vex-summary-card__label">Unmitigated</span>

View File

@@ -1450,20 +1450,29 @@ $color-text-muted: #6b7280;
text-align: center; text-align: center;
} }
&--not-affected { &--not-affected {
background: #f0fdf4; background: #f0fdf4;
border-color: #86efac; border-color: #86efac;
.vex-summary-card__count { .vex-summary-card__count {
color: #15803d; color: #15803d;
} }
} }
&--mitigated { &--under-investigation {
background: #fef9c3; background: #f5f3ff;
border-color: #fde047; border-color: #c4b5fd;
.vex-summary-card__count { .vex-summary-card__count {
color: #6d28d9;
}
}
&--mitigated {
background: #fef9c3;
border-color: #fde047;
.vex-summary-card__count {
color: #a16207; color: #a16207;
} }
} }
@@ -1616,15 +1625,20 @@ $color-text-muted: #6b7280;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
&.vex-status--not-affected { &.vex-status--not-affected {
background: #dcfce7; background: #dcfce7;
color: #15803d; color: #15803d;
} }
&.vex-status--mitigated { &.vex-status--under-investigation {
background: #fef3c7; background: #f5f3ff;
color: #92400e; color: #6d28d9;
} }
&.vex-status--mitigated {
background: #fef3c7;
color: #92400e;
}
&.vex-status--unmitigated { &.vex-status--unmitigated {
background: #fee2e2; background: #fee2e2;

View File

@@ -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');
});
});

View File

@@ -195,16 +195,17 @@ export class EvidencePanelComponent {
readonly showPermalink = signal(false); readonly showPermalink = signal(false);
readonly permalinkCopied = signal(false); readonly permalinkCopied = signal(false);
readonly vexStatusSummary = computed((): VexStatusSummary => { readonly vexStatusSummary = computed((): VexStatusSummary => {
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,
affectedMitigated: decisions.filter((d) => d.status === 'AFFECTED_MITIGATED').length, underInvestigation: decisions.filter((d) => d.status === 'UNDER_INVESTIGATION').length,
affectedUnmitigated: decisions.filter((d) => d.status === 'AFFECTED_UNMITIGATED').length, affectedMitigated: decisions.filter((d) => d.status === 'AFFECTED_MITIGATED').length,
fixed: decisions.filter((d) => d.status === 'FIXED').length, affectedUnmitigated: decisions.filter((d) => d.status === 'AFFECTED_UNMITIGATED').length,
total: decisions.length, fixed: decisions.filter((d) => d.status === 'FIXED').length,
}; total: decisions.length,
}); };
});
// Permalink computed value // Permalink computed value
readonly permalink = computed(() => { readonly permalink = computed(() => {
@@ -440,30 +441,34 @@ export class EvidencePanelComponent {
} }
// VEX helpers // VEX helpers
getVexStatusLabel(status: VexStatus): string { getVexStatusLabel(status: VexStatus): string {
switch (status) { switch (status) {
case 'NOT_AFFECTED': case 'NOT_AFFECTED':
return 'Not Affected'; return 'Not Affected';
case 'AFFECTED_MITIGATED': case 'UNDER_INVESTIGATION':
return 'Affected (Mitigated)'; return 'Under Investigation';
case 'AFFECTED_UNMITIGATED': case 'AFFECTED_MITIGATED':
return 'Affected (Unmitigated)'; return 'Affected (Mitigated)';
case 'FIXED': case 'AFFECTED_UNMITIGATED':
return 'Affected (Unmitigated)';
case 'FIXED':
return 'Fixed'; return 'Fixed';
default: default:
return status; return status;
} }
} }
getVexStatusClass(status: VexStatus): string { getVexStatusClass(status: VexStatus): string {
switch (status) { switch (status) {
case 'NOT_AFFECTED': case 'NOT_AFFECTED':
return 'vex-status--not-affected'; return 'vex-status--not-affected';
case 'AFFECTED_MITIGATED': case 'UNDER_INVESTIGATION':
return 'vex-status--mitigated'; return 'vex-status--under-investigation';
case 'AFFECTED_UNMITIGATED': case 'AFFECTED_MITIGATED':
return 'vex-status--unmitigated'; return 'vex-status--mitigated';
case 'FIXED': case 'AFFECTED_UNMITIGATED':
return 'vex-status--unmitigated';
case 'FIXED':
return 'vex-status--fixed'; return 'vex-status--fixed';
default: default:
return ''; return '';

View File

@@ -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();
});
});

View File

@@ -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
<h3>Reachability</h3> id="triage-panel-reachability"
<p class="hint"> class="section"
Status: <strong>{{ selectedVuln()!.vuln.reachabilityStatus || 'unknown' }}</strong> role="tabpanel"
&middot; score {{ selectedVuln()!.vuln.reachabilityScore ?? 0 }} aria-labelledby="triage-tab-reachability"
</p> >
<button type="button" class="btn btn--secondary" (click)="openReachabilityDrawer()" [disabled]="!selectedVuln()!.component"> <header class="reachability-header">
View call paths <div>
</button> <h3>Reachability</h3>
<p class="hint">
Status: <strong>{{ selectedVuln()!.vuln.reachabilityStatus || 'unknown' }}</strong>
&middot; score {{ selectedVuln()!.vuln.reachabilityScore ?? 0 }}
</p>
</div>
<button
type="button"
class="btn btn--secondary"
(click)="openReachabilityDrawer()"
[disabled]="!selectedVuln()!.component"
>
View call paths
</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>

View File

@@ -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);
}

View File

@@ -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();
})); }));
}); });

View File

@@ -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,9 +301,11 @@ 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);
this.activeTab.set('overview'); if (options?.resetTab ?? true) {
this.activeTab.set('overview');
}
} }
toggleBulkSelection(vulnId: string): void { toggleBulkSelection(vulnId: string): void {
@@ -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

View File

@@ -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');
});
}); });

View File

@@ -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,16 +161,22 @@ 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.justificationType.set(existing.justificationType);
this.justificationText.set(existing.justificationText ?? '');
this.environmentsText.set(existing.scope?.environments?.join(', ') ?? '');
this.projectsText.set(existing.scope?.projects?.join(', ') ?? '');
this.notBefore.set(toLocalDateTimeValue(existing.validFor?.notBefore ?? new Date().toISOString()));
this.notAfter.set(toLocalDateTimeValue(existing.validFor?.notAfter ?? ''));
this.evidenceRefs.set(existing.evidenceRefs ?? []);
return;
}
this.status.set(existing.status); const initialStatus = this.initialStatus();
this.justificationType.set(existing.justificationType); if (initialStatus) {
this.justificationText.set(existing.justificationText ?? ''); this.status.set(initialStatus);
this.environmentsText.set(existing.scope?.environments?.join(', ') ?? ''); }
this.projectsText.set(existing.scope?.projects?.join(', ') ?? '');
this.notBefore.set(toLocalDateTimeValue(existing.validFor?.notBefore ?? new Date().toISOString()));
this.notAfter.set(toLocalDateTimeValue(existing.validFor?.notAfter ?? ''));
this.evidenceRefs.set(existing.evidenceRefs ?? []);
}); });
} }