Refactor SurfaceCacheValidator to simplify oldest entry calculation

Add global using for Xunit in test project

Enhance ImportValidatorTests with async validation and quarantine checks

Implement FileSystemQuarantineServiceTests for quarantine functionality

Add integration tests for ImportValidator to check monotonicity

Create BundleVersionTests to validate version parsing and comparison logic

Implement VersionMonotonicityCheckerTests for monotonicity checks and activation logic
This commit is contained in:
master
2025-12-16 10:44:00 +02:00
parent b1f40945b7
commit 4391f35d8a
107 changed files with 10844 additions and 287 deletions

View File

@@ -0,0 +1,446 @@
import {
Component,
Input,
Output,
EventEmitter,
HostListener,
signal,
computed,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
export type DecisionStatus = 'affected' | 'not_affected' | 'under_investigation';
export interface DecisionFormData {
status: DecisionStatus;
reasonCode: string;
reasonText?: string;
}
export interface AlertSummary {
id: string;
artifactId: string;
vulnId: string;
severity: string;
}
@Component({
selector: 'app-decision-drawer',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<aside class="decision-drawer" [class.open]="isOpen" role="dialog" aria-labelledby="drawer-title">
<header>
<h3 id="drawer-title">Record Decision</h3>
<button class="close-btn" (click)="close.emit()" aria-label="Close drawer">
&times;
</button>
</header>
<section class="status-selection">
<h4>VEX Status</h4>
<div class="radio-group" role="radiogroup" aria-label="VEX Status">
<label class="radio-option" [class.selected]="formData().status === 'affected'">
<input type="radio" name="status" value="affected"
[checked]="formData().status === 'affected'"
(change)="setStatus('affected')">
<span class="key-hint" aria-hidden="true">A</span>
<span>Affected</span>
</label>
<label class="radio-option" [class.selected]="formData().status === 'not_affected'">
<input type="radio" name="status" value="not_affected"
[checked]="formData().status === 'not_affected'"
(change)="setStatus('not_affected')">
<span class="key-hint" aria-hidden="true">N</span>
<span>Not Affected</span>
</label>
<label class="radio-option" [class.selected]="formData().status === 'under_investigation'">
<input type="radio" name="status" value="under_investigation"
[checked]="formData().status === 'under_investigation'"
(change)="setStatus('under_investigation')">
<span class="key-hint" aria-hidden="true">U</span>
<span>Under Investigation</span>
</label>
</div>
</section>
<section class="reason-selection">
<h4>Reason</h4>
<select [ngModel]="formData().reasonCode"
(ngModelChange)="setReasonCode($event)"
class="reason-select"
aria-label="Select reason">
<option value="">Select reason...</option>
<optgroup label="Not Affected Reasons">
<option value="component_not_present">Component not present</option>
<option value="vulnerable_code_not_present">Vulnerable code not present</option>
<option value="vulnerable_code_not_in_execute_path">Vulnerable code not in execute path</option>
<option value="vulnerable_code_cannot_be_controlled_by_adversary">Vulnerable code cannot be controlled</option>
<option value="inline_mitigations_already_exist">Inline mitigations exist</option>
</optgroup>
<optgroup label="Affected Reasons">
<option value="vulnerable_code_reachable">Vulnerable code is reachable</option>
<option value="exploit_available">Exploit available</option>
</optgroup>
<optgroup label="Investigation">
<option value="requires_further_analysis">Requires further analysis</option>
<option value="waiting_for_vendor">Waiting for vendor response</option>
</optgroup>
</select>
<textarea
[ngModel]="formData().reasonText"
(ngModelChange)="setReasonText($event)"
placeholder="Additional notes (optional)"
rows="3"
class="reason-text"
aria-label="Additional notes">
</textarea>
</section>
<section class="audit-summary">
<h4>Audit Summary</h4>
<dl class="summary-list">
<dt>Alert ID</dt>
<dd>{{ alert?.id ?? '-' }}</dd>
<dt>Artifact</dt>
<dd class="truncate" [title]="alert?.artifactId">{{ alert?.artifactId ?? '-' }}</dd>
<dt>Vulnerability</dt>
<dd>{{ alert?.vulnId ?? '-' }}</dd>
<dt>Evidence Hash</dt>
<dd class="hash">{{ evidenceHash || '-' }}</dd>
<dt>Policy Version</dt>
<dd>{{ policyVersion || '-' }}</dd>
</dl>
</section>
<footer>
<button class="btn btn-secondary" (click)="close.emit()">
Cancel
</button>
<button class="btn btn-primary"
[disabled]="!isValid()"
(click)="submitDecision()">
Record Decision
</button>
</footer>
</aside>
<!-- Backdrop -->
<div class="backdrop" *ngIf="isOpen" (click)="close.emit()" aria-hidden="true"></div>
`,
styles: [`
.decision-drawer {
position: fixed;
right: 0;
top: 0;
bottom: 0;
width: 360px;
background: var(--surface-color, #fff);
border-left: 1px solid var(--border-color, #e0e0e0);
box-shadow: -4px 0 16px rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.3s ease;
z-index: 101;
}
.decision-drawer.open {
transform: translateX(0);
}
.backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.3);
z-index: 100;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
h3 {
margin: 0;
font-size: 18px;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
padding: 4px 8px;
line-height: 1;
color: var(--text-secondary, #666);
}
.close-btn:hover {
color: var(--text-primary, #333);
}
section {
padding: 16px;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
h4 {
margin: 0 0 12px 0;
font-size: 14px;
color: var(--text-secondary, #666);
font-weight: 600;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.radio-option {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.radio-option:hover {
background: var(--surface-variant, #f5f5f5);
}
.radio-option.selected {
border-color: var(--primary-color, #1976d2);
background: var(--primary-bg, #e3f2fd);
}
.radio-option input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.key-hint {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: var(--surface-variant, #f5f5f5);
border-radius: 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary, #666);
}
.radio-option.selected .key-hint {
background: var(--primary-color, #1976d2);
color: white;
}
.reason-select {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
margin-bottom: 8px;
font-size: 14px;
background: var(--surface-color, #fff);
}
.reason-select:focus {
outline: 2px solid var(--primary-color, #1976d2);
outline-offset: 2px;
}
.reason-text {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
resize: vertical;
font-size: 14px;
font-family: inherit;
box-sizing: border-box;
}
.reason-text:focus {
outline: 2px solid var(--primary-color, #1976d2);
outline-offset: 2px;
}
.summary-list {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
font-size: 13px;
margin: 0;
}
.summary-list dt {
color: var(--text-secondary, #666);
}
.summary-list dd {
margin: 0;
color: var(--text-primary, #333);
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
.hash {
font-family: ui-monospace, monospace;
font-size: 11px;
word-break: break-all;
}
footer {
margin-top: auto;
padding: 16px;
display: flex;
gap: 8px;
justify-content: flex-end;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.btn {
padding: 10px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.btn-primary {
background: var(--primary-color, #1976d2);
color: white;
border: none;
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-dark, #1565c0);
}
.btn-secondary {
background: transparent;
border: 1px solid var(--border-color, #e0e0e0);
color: var(--text-primary, #333);
}
.btn-secondary:hover {
background: var(--surface-variant, #f5f5f5);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn:focus {
outline: 2px solid var(--primary-color, #1976d2);
outline-offset: 2px;
}
`]
})
export class DecisionDrawerComponent {
@Input() alert?: AlertSummary;
@Input() isOpen = false;
@Input() evidenceHash = '';
@Input() policyVersion = '';
@Output() close = new EventEmitter<void>();
@Output() decisionSubmit = new EventEmitter<DecisionFormData>();
formData = signal<DecisionFormData>({
status: 'under_investigation',
reasonCode: '',
reasonText: '',
});
@HostListener('document:keydown', ['$event'])
handleKeydown(event: KeyboardEvent): void {
if (!this.isOpen) return;
// Escape to close
if (event.key === 'Escape') {
event.preventDefault();
this.close.emit();
return;
}
// Don't interfere with typing in text fields
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') {
return;
}
// Quick status keys
switch (event.key.toLowerCase()) {
case 'a':
event.preventDefault();
this.setStatus('affected');
break;
case 'n':
event.preventDefault();
this.setStatus('not_affected');
break;
case 'u':
event.preventDefault();
this.setStatus('under_investigation');
break;
}
}
setStatus(status: DecisionStatus): void {
this.formData.update((f) => ({ ...f, status }));
}
setReasonCode(reasonCode: string): void {
this.formData.update((f) => ({ ...f, reasonCode }));
}
setReasonText(reasonText: string): void {
this.formData.update((f) => ({ ...f, reasonText }));
}
isValid(): boolean {
const data = this.formData();
return !!data.status && !!data.reasonCode;
}
submitDecision(): void {
if (this.isValid()) {
this.decisionSubmit.emit(this.formData());
}
}
resetForm(): void {
this.formData.set({
status: 'under_investigation',
reasonCode: '',
reasonText: '',
});
}
}

View File

@@ -0,0 +1,173 @@
import { Component, Input, Output, EventEmitter, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { EvidenceBundle, EvidenceStatus, EvidenceBitset } from '../../models/evidence.model';
@Component({
selector: 'app-evidence-pills',
standalone: true,
imports: [CommonModule],
template: `
<div class="evidence-pills">
<button class="pill"
[class.available]="reachabilityStatus() === 'available'"
[class.loading]="reachabilityStatus() === 'loading'"
[class.unavailable]="reachabilityStatus() === 'unavailable' || reachabilityStatus() === 'error'"
[class.pending]="reachabilityStatus() === 'pending_enrichment'"
(click)="pillClick.emit('reachability')"
[attr.aria-label]="'Reachability: ' + reachabilityStatus()">
<span class="icon">{{ getIcon(reachabilityStatus()) }}</span>
<span class="label">Reachability</span>
</button>
<button class="pill"
[class.available]="callstackStatus() === 'available'"
[class.loading]="callstackStatus() === 'loading'"
[class.unavailable]="callstackStatus() === 'unavailable' || callstackStatus() === 'error'"
[class.pending]="callstackStatus() === 'pending_enrichment'"
(click)="pillClick.emit('callstack')"
[attr.aria-label]="'Call-stack: ' + callstackStatus()">
<span class="icon">{{ getIcon(callstackStatus()) }}</span>
<span class="label">Call-stack</span>
</button>
<button class="pill"
[class.available]="provenanceStatus() === 'available'"
[class.loading]="provenanceStatus() === 'loading'"
[class.unavailable]="provenanceStatus() === 'unavailable' || provenanceStatus() === 'error'"
[class.pending]="provenanceStatus() === 'pending_enrichment'"
(click)="pillClick.emit('provenance')"
[attr.aria-label]="'Provenance: ' + provenanceStatus()">
<span class="icon">{{ getIcon(provenanceStatus()) }}</span>
<span class="label">Provenance</span>
</button>
<button class="pill"
[class.available]="vexStatus() === 'available'"
[class.loading]="vexStatus() === 'loading'"
[class.unavailable]="vexStatus() === 'unavailable' || vexStatus() === 'error'"
[class.pending]="vexStatus() === 'pending_enrichment'"
(click)="pillClick.emit('vex')"
[attr.aria-label]="'VEX: ' + vexStatus()">
<span class="icon">{{ getIcon(vexStatus()) }}</span>
<span class="label">VEX</span>
</button>
<div class="completeness-badge" [attr.aria-label]="'Evidence completeness: ' + completenessScore() + ' of 4'">
{{ completenessScore() }}/4
</div>
</div>
`,
styles: [`
.evidence-pills {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.pill {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 16px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
background: var(--surface-variant, #f5f5f5);
border: 1px solid transparent;
color: var(--text-primary, #333);
}
.pill.available {
background: var(--success-bg, #e8f5e9);
color: var(--success-text, #2e7d32);
border-color: var(--success-border, #a5d6a7);
}
.pill.loading {
background: var(--warning-bg, #fff3e0);
color: var(--warning-text, #ef6c00);
border-color: var(--warning-border, #ffcc80);
}
.pill.unavailable {
background: var(--error-bg, #ffebee);
color: var(--error-text, #c62828);
border-color: var(--error-border, #ef9a9a);
opacity: 0.7;
}
.pill.pending {
background: var(--info-bg, #e3f2fd);
color: var(--info-text, #1565c0);
border-color: var(--info-border, #90caf9);
}
.pill:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.pill:focus {
outline: 2px solid var(--primary-color, #1976d2);
outline-offset: 2px;
}
.icon {
font-size: 14px;
}
.label {
font-weight: 500;
}
.completeness-badge {
margin-left: auto;
font-weight: 600;
color: var(--text-secondary, #666);
padding: 4px 8px;
background: var(--surface-variant, #f5f5f5);
border-radius: 12px;
font-size: 12px;
}
`]
})
export class EvidencePillsComponent {
private _evidence = signal<EvidenceBundle | undefined>(undefined);
@Input()
set evidence(value: EvidenceBundle | undefined) {
this._evidence.set(value);
}
@Output() pillClick = new EventEmitter<'reachability' | 'callstack' | 'provenance' | 'vex'>();
reachabilityStatus = computed(() => this._evidence()?.reachability?.status ?? 'unavailable');
callstackStatus = computed(() => this._evidence()?.callstack?.status ?? 'unavailable');
provenanceStatus = computed(() => this._evidence()?.provenance?.status ?? 'unavailable');
vexStatus = computed(() => this._evidence()?.vex?.status ?? 'unavailable');
completenessScore = computed(() => {
const bundle = this._evidence();
return EvidenceBitset.fromBundle(bundle).completenessScore;
});
getIcon(status: EvidenceStatus): string {
switch (status) {
case 'available':
return '\u2713'; // checkmark
case 'loading':
return '\u23F3'; // hourglass
case 'unavailable':
return '\u2717'; // X mark
case 'error':
return '\u26A0'; // warning
case 'pending_enrichment':
return '\u2026'; // ellipsis
default:
return '?';
}
}
}

View File

@@ -0,0 +1,120 @@
/**
* Evidence status for triage UI.
*/
export type EvidenceStatus = 'available' | 'loading' | 'unavailable' | 'error' | 'pending_enrichment';
/**
* Evidence bundle for an alert.
*/
export interface EvidenceBundle {
alertId: string;
reachability?: EvidenceSection;
callstack?: EvidenceSection;
provenance?: EvidenceSection;
vex?: VexEvidenceSection;
hashes?: EvidenceHashes;
computedAt: string;
}
/**
* Individual evidence section.
*/
export interface EvidenceSection {
status: EvidenceStatus;
hash?: string;
proof?: unknown;
unavailableReason?: string;
}
/**
* VEX evidence section with history.
*/
export interface VexEvidenceSection {
status: EvidenceStatus;
current?: VexStatement;
history?: VexStatement[];
}
/**
* VEX statement summary.
*/
export interface VexStatement {
statementId: string;
status: string;
justification?: string;
impactStatement?: string;
timestamp: string;
issuer?: string;
}
/**
* Evidence hashes for verification.
*/
export interface EvidenceHashes {
combinedHash?: string;
hashes?: string[];
}
/**
* Evidence bitset for completeness tracking.
*/
export class EvidenceBitset {
private static readonly REACHABILITY = 1 << 0;
private static readonly CALLSTACK = 1 << 1;
private static readonly PROVENANCE = 1 << 2;
private static readonly VEX = 1 << 3;
constructor(public value: number = 0) {}
get hasReachability(): boolean {
return (this.value & EvidenceBitset.REACHABILITY) !== 0;
}
get hasCallstack(): boolean {
return (this.value & EvidenceBitset.CALLSTACK) !== 0;
}
get hasProvenance(): boolean {
return (this.value & EvidenceBitset.PROVENANCE) !== 0;
}
get hasVex(): boolean {
return (this.value & EvidenceBitset.VEX) !== 0;
}
/**
* Completeness score (0-4).
*/
get completenessScore(): number {
let score = 0;
if (this.hasReachability) score++;
if (this.hasCallstack) score++;
if (this.hasProvenance) score++;
if (this.hasVex) score++;
return score;
}
static from(evidence: {
reachability?: boolean;
callstack?: boolean;
provenance?: boolean;
vex?: boolean;
}): EvidenceBitset {
let value = 0;
if (evidence.reachability) value |= EvidenceBitset.REACHABILITY;
if (evidence.callstack) value |= EvidenceBitset.CALLSTACK;
if (evidence.provenance) value |= EvidenceBitset.PROVENANCE;
if (evidence.vex) value |= EvidenceBitset.VEX;
return new EvidenceBitset(value);
}
static fromBundle(bundle?: EvidenceBundle): EvidenceBitset {
if (!bundle) return new EvidenceBitset(0);
return EvidenceBitset.from({
reachability: bundle.reachability?.status === 'available',
callstack: bundle.callstack?.status === 'available',
provenance: bundle.provenance?.status === 'available',
vex: bundle.vex?.status === 'available',
});
}
}

View File

@@ -0,0 +1,273 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { EvidenceBitset } from '../models/evidence.model';
/**
* TTFS timing data for an alert.
*/
export interface TtfsTimings {
alertId: string;
alertCreatedAt: number;
ttfsStartAt: number;
skeletonRenderedAt?: number;
firstEvidenceAt?: number;
fullEvidenceAt?: number;
decisionRecordedAt?: number;
clickCount: number;
evidenceBitset: number;
}
/**
* TTFS event for backend ingestion.
*/
interface TtfsEvent {
event_type: string;
alert_id: string;
duration_ms: number;
evidence_type?: string;
completeness_score?: number;
click_count?: number;
decision_status?: string;
phase?: string;
budget?: number;
evidence_bitset?: number;
timestamp: string;
}
/**
* Performance budgets in milliseconds.
*/
const BUDGETS = {
skeleton: 200,
firstEvidence: 500,
fullEvidence: 1500,
clicksToClosure: 6,
} as const;
/**
* Service for tracking Time-to-First-Signal (TTFS) telemetry.
* Measures time from alert creation to first evidence render.
*/
@Injectable({ providedIn: 'root' })
export class TtfsTelemetryService {
private readonly http = inject(HttpClient);
private readonly activeTimings = new Map<string, TtfsTimings>();
private readonly pendingEvents: TtfsEvent[] = [];
private flushTimeout: ReturnType<typeof setTimeout> | null = null;
/**
* Starts TTFS tracking for an alert.
*/
startTracking(alertId: string, alertCreatedAt: Date): void {
const timing: TtfsTimings = {
alertId,
alertCreatedAt: alertCreatedAt.getTime(),
ttfsStartAt: performance.now(),
clickCount: 0,
evidenceBitset: 0,
};
this.activeTimings.set(alertId, timing);
this.queueEvent({
event_type: 'ttfs.start',
alert_id: alertId,
duration_ms: 0,
timestamp: new Date().toISOString(),
});
}
/**
* Records skeleton UI render.
*/
recordSkeletonRender(alertId: string): void {
const timing = this.activeTimings.get(alertId);
if (!timing) return;
timing.skeletonRenderedAt = performance.now();
const duration = timing.skeletonRenderedAt - timing.ttfsStartAt;
this.queueEvent({
event_type: 'ttfs.skeleton',
alert_id: alertId,
duration_ms: duration,
timestamp: new Date().toISOString(),
});
// Check against budget
if (duration > BUDGETS.skeleton) {
this.recordBudgetViolation(alertId, 'skeleton', duration, BUDGETS.skeleton);
}
}
/**
* Records first evidence pill paint (primary TTFS metric).
*/
recordFirstEvidence(alertId: string, evidenceType: string): void {
const timing = this.activeTimings.get(alertId);
if (!timing || timing.firstEvidenceAt) return;
timing.firstEvidenceAt = performance.now();
const duration = timing.firstEvidenceAt - timing.ttfsStartAt;
this.queueEvent({
event_type: 'ttfs.first_evidence',
alert_id: alertId,
duration_ms: duration,
evidence_type: evidenceType,
timestamp: new Date().toISOString(),
});
// Check against budget
if (duration > BUDGETS.firstEvidence) {
this.recordBudgetViolation(alertId, 'first_evidence', duration, BUDGETS.firstEvidence);
}
}
/**
* Records full evidence load complete.
*/
recordFullEvidence(alertId: string, bitset: EvidenceBitset): void {
const timing = this.activeTimings.get(alertId);
if (!timing) return;
timing.fullEvidenceAt = performance.now();
timing.evidenceBitset = bitset.value;
const duration = timing.fullEvidenceAt - timing.ttfsStartAt;
this.queueEvent({
event_type: 'ttfs.full_evidence',
alert_id: alertId,
duration_ms: duration,
completeness_score: bitset.completenessScore,
evidence_bitset: bitset.value,
timestamp: new Date().toISOString(),
});
// Check against budget
if (duration > BUDGETS.fullEvidence) {
this.recordBudgetViolation(alertId, 'full_evidence', duration, BUDGETS.fullEvidence);
}
}
/**
* Records a user interaction (click, keyboard).
*/
recordInteraction(alertId: string, interactionType: string): void {
const timing = this.activeTimings.get(alertId);
if (!timing) return;
timing.clickCount++;
this.queueEvent({
event_type: 'triage.interaction',
alert_id: alertId,
duration_ms: performance.now() - timing.ttfsStartAt,
evidence_type: interactionType,
click_count: timing.clickCount,
timestamp: new Date().toISOString(),
});
}
/**
* Records decision completion and final metrics.
*/
recordDecision(alertId: string, decisionStatus: string): void {
const timing = this.activeTimings.get(alertId);
if (!timing) return;
timing.decisionRecordedAt = performance.now();
const totalDuration = timing.decisionRecordedAt - timing.ttfsStartAt;
this.queueEvent({
event_type: 'decision.recorded',
alert_id: alertId,
duration_ms: totalDuration,
click_count: timing.clickCount,
decision_status: decisionStatus,
evidence_bitset: timing.evidenceBitset,
completeness_score: new EvidenceBitset(timing.evidenceBitset).completenessScore,
timestamp: new Date().toISOString(),
});
// Check clicks-to-closure budget
if (timing.clickCount > BUDGETS.clicksToClosure) {
this.recordBudgetViolation(alertId, 'clicks_to_closure', timing.clickCount, BUDGETS.clicksToClosure);
}
// Cleanup
this.activeTimings.delete(alertId);
// Flush events after decision
this.flushEvents();
}
/**
* Cancels tracking for an alert (e.g., user navigates away).
*/
cancelTracking(alertId: string): void {
this.activeTimings.delete(alertId);
}
/**
* Gets current timing data for an alert.
*/
getTimings(alertId: string): TtfsTimings | undefined {
return this.activeTimings.get(alertId);
}
/**
* Gets current click count for an alert.
*/
getClickCount(alertId: string): number {
return this.activeTimings.get(alertId)?.clickCount ?? 0;
}
private recordBudgetViolation(alertId: string, phase: string, actual: number, budget: number): void {
this.queueEvent({
event_type: 'budget.violation',
alert_id: alertId,
duration_ms: actual,
phase,
budget,
timestamp: new Date().toISOString(),
});
}
private queueEvent(event: TtfsEvent): void {
this.pendingEvents.push(event);
// Schedule flush if not already scheduled
if (!this.flushTimeout) {
this.flushTimeout = setTimeout(() => this.flushEvents(), 5000);
}
// Flush immediately if we have too many events
if (this.pendingEvents.length >= 20) {
this.flushEvents();
}
}
private flushEvents(): void {
if (this.flushTimeout) {
clearTimeout(this.flushTimeout);
this.flushTimeout = null;
}
if (this.pendingEvents.length === 0) return;
const events = [...this.pendingEvents];
this.pendingEvents.length = 0;
// Send to backend
this.http
.post('/api/v1/telemetry/ttfs', { events })
.subscribe({
error: (err) => {
// Log but don't fail - telemetry should be non-blocking
console.warn('Failed to send TTFS telemetry:', err);
},
});
}
}

View File

@@ -100,7 +100,28 @@
</aside>
<section class="right">
<!-- Evidence Pills (Top Strip) -->
@if (selectedVuln()) {
<app-evidence-pills
[evidence]="selectedEvidenceBundle()"
(pillClick)="onEvidencePillClick($event)"
/>
}
<header class="tabs" role="tablist" aria-label="Evidence tabs">
<button
id="triage-tab-evidence"
type="button"
role="tab"
class="tab"
[class.tab--active]="activeTab() === 'evidence'"
[attr.aria-selected]="activeTab() === 'evidence'"
[attr.tabindex]="activeTab() === 'evidence' ? 0 : -1"
aria-controls="triage-panel-evidence"
(click)="setTab('evidence')"
>
Evidence
</button>
<button
id="triage-tab-overview"
type="button"
@@ -158,6 +179,105 @@
<div class="panel">
@if (!selectedVuln()) {
<div class="empty">Select a finding to view evidence.</div>
} @else if (activeTab() === 'evidence') {
<section
id="triage-panel-evidence"
class="section"
role="tabpanel"
aria-labelledby="triage-tab-evidence"
>
<header class="evidence-header">
<div>
<h3>Evidence Summary</h3>
<p class="hint">
{{ selectedVuln()!.vuln.cveId }} &middot;
{{ selectedVuln()!.component?.name }} {{ selectedVuln()!.component?.version }}
</p>
</div>
<button
type="button"
class="btn btn--primary"
(click)="openDecisionDrawer()"
>
Record Decision
</button>
</header>
<!-- Reachability Evidence -->
<section class="evidence-section">
<header class="evidence-section__header">
<h4>Reachability</h4>
<span class="evidence-status" [class.available]="selectedEvidenceBundle()?.reachability?.status === 'available'" [class.loading]="selectedEvidenceBundle()?.reachability?.status === 'loading'" [class.unavailable]="selectedEvidenceBundle()?.reachability?.status === 'unavailable'">
{{ selectedEvidenceBundle()?.reachability?.status ?? 'unknown' }}
</span>
</header>
<div class="evidence-content">
@if (selectedVuln()!.vuln.reachabilityStatus === 'reachable') {
<p>Vulnerable code is <strong>reachable</strong> from application entry points.</p>
<p class="hint">Score: {{ selectedVuln()!.vuln.reachabilityScore ?? 0 }}</p>
<button type="button" class="btn btn--secondary" (click)="openReachabilityDrawer()">View call paths</button>
} @else if (selectedVuln()!.vuln.reachabilityStatus === 'unreachable') {
<p>Vulnerable code is <strong>not reachable</strong> from application entry points.</p>
} @else {
<p class="hint">Reachability analysis pending or unavailable.</p>
}
</div>
</section>
<!-- Call Stack Evidence -->
<section class="evidence-section">
<header class="evidence-section__header">
<h4>Call Stack</h4>
<span class="evidence-status" [class.available]="selectedEvidenceBundle()?.callstack?.status === 'available'" [class.unavailable]="selectedEvidenceBundle()?.callstack?.status !== 'available'">
{{ selectedEvidenceBundle()?.callstack?.status ?? 'unknown' }}
</span>
</header>
<div class="evidence-content">
@if (selectedEvidenceBundle()?.callstack?.status === 'available') {
<p class="hint">Call stack evidence available from reachability analysis.</p>
} @else {
<p class="hint">Call stack evidence unavailable.</p>
}
</div>
</section>
<!-- Provenance Evidence -->
<section class="evidence-section">
<header class="evidence-section__header">
<h4>Provenance</h4>
<span class="evidence-status" [class.available]="selectedEvidenceBundle()?.provenance?.status === 'available'" [class.pending]="selectedEvidenceBundle()?.provenance?.status === 'pending_enrichment'" [class.unavailable]="selectedEvidenceBundle()?.provenance?.status === 'unavailable'">
{{ selectedEvidenceBundle()?.provenance?.status ?? 'unknown' }}
</span>
</header>
<div class="evidence-content">
@if (hasSignedEvidence(selectedVuln()!)) {
<p>Signed attestation available for this artifact.</p>
<button type="button" class="btn btn--secondary" (click)="setTab('attestations')">View attestations</button>
} @else {
<p class="hint">Awaiting signed provenance attestation.</p>
}
</div>
</section>
<!-- VEX Evidence -->
<section class="evidence-section">
<header class="evidence-section__header">
<h4>VEX Decision</h4>
<span class="evidence-status" [class.available]="selectedEvidenceBundle()?.vex?.status === 'available'" [class.unavailable]="selectedEvidenceBundle()?.vex?.status !== 'available'">
{{ selectedEvidenceBundle()?.vex?.status ?? 'unknown' }}
</span>
</header>
<div class="evidence-content">
@if (getVexBadgeForFinding(selectedVuln()!); as vexBadge) {
<p>{{ vexBadge }}</p>
<button type="button" class="btn btn--secondary" (click)="openVexForFinding(selectedVuln()!.vuln.vulnId)">Edit decision</button>
} @else {
<p class="hint">No VEX decision recorded.</p>
<button type="button" class="btn btn--primary" (click)="openDecisionDrawer()">Record decision</button>
}
</div>
</section>
</section>
} @else if (activeTab() === 'overview') {
<section
id="triage-panel-overview"
@@ -408,6 +528,16 @@
/>
}
<!-- Decision Drawer (Pinned Right) -->
<app-decision-drawer
[alert]="selectedAlertSummary()"
[isOpen]="showDecisionDrawer()"
[evidenceHash]="getEvidenceHash()"
[policyVersion]="'1.0.0'"
(close)="closeDecisionDrawer()"
(decisionSubmit)="onDecisionDrawerSubmit($event)"
/>
@if (showVexModal()) {
<app-vex-decision-modal
[subject]="{ type: 'IMAGE', name: artifactId(), digest: { sha256: artifactId() } }"

View File

@@ -423,6 +423,86 @@
color: #374151;
}
.btn--primary {
background: #2563eb;
border-color: #2563eb;
color: #fff;
&:hover {
background: #1d4ed8;
}
}
// Evidence Tab Styles
.evidence-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.evidence-section {
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 0.9rem;
margin-bottom: 0.75rem;
background: #f9fafb;
}
.evidence-section__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.6rem;
h4 {
margin: 0;
font-size: 0.95rem;
}
}
.evidence-content {
p {
margin: 0 0 0.5rem;
font-size: 0.9rem;
}
.btn {
margin-top: 0.5rem;
}
}
.evidence-status {
font-size: 0.72rem;
font-weight: 700;
padding: 0.2rem 0.5rem;
border-radius: 999px;
text-transform: uppercase;
background: #f3f4f6;
color: #6b7280;
&.available {
background: #dcfce7;
color: #166534;
}
&.loading {
background: #fef3c7;
color: #92400e;
}
&.pending {
background: #dbeafe;
color: #1d4ed8;
}
&.unavailable {
background: #fee2e2;
color: #991b1b;
}
}
.sr-only {
position: absolute;
width: 1px;

View File

@@ -20,16 +20,20 @@ import type { VexDecision, VexStatus } from '../../core/api/evidence.models';
import { VEX_DECISIONS_API, type VexDecisionsApi } from '../../core/api/vex-decisions.client';
import { ReachabilityWhyDrawerComponent } from '../reachability/reachability-why-drawer.component';
import { KeyboardHelpComponent } from './components/keyboard-help/keyboard-help.component';
import { EvidencePillsComponent } from './components/evidence-pills/evidence-pills.component';
import { DecisionDrawerComponent, type DecisionFormData } from './components/decision-drawer/decision-drawer.component';
import { type TriageQuickVexStatus, TriageShortcutsService } from './services/triage-shortcuts.service';
import { TtfsTelemetryService } from './services/ttfs-telemetry.service';
import { VexDecisionModalComponent } from './vex-decision-modal.component';
import {
TriageAttestationDetailModalComponent,
type TriageAttestationDetail,
} from './triage-attestation-detail-modal.component';
import { type EvidenceBundle, EvidenceBitset } from './models/evidence.model';
type TabId = 'overview' | 'reachability' | 'policy' | 'attestations';
type TabId = 'evidence' | 'overview' | 'reachability' | 'policy' | 'attestations';
const TAB_ORDER: readonly TabId[] = ['overview', 'reachability', 'policy', 'attestations'];
const TAB_ORDER: readonly TabId[] = ['evidence', 'overview', 'reachability', 'policy', 'attestations'];
const REACHABILITY_VIEW_ORDER: readonly ('path-list' | 'compact-graph' | 'textual-proof')[] = [
'path-list',
'compact-graph',
@@ -72,6 +76,8 @@ interface PolicyGateCell {
KeyboardHelpComponent,
VexDecisionModalComponent,
TriageAttestationDetailModalComponent,
EvidencePillsComponent,
DecisionDrawerComponent,
],
providers: [TriageShortcutsService],
templateUrl: './triage-workspace.component.html',
@@ -86,6 +92,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
private readonly vulnApi = inject<VulnerabilityApi>(VULNERABILITY_API);
private readonly vexApi = inject<VexDecisionsApi>(VEX_DECISIONS_API);
private readonly shortcuts = inject(TriageShortcutsService);
private readonly ttfsTelemetry = inject(TtfsTelemetryService);
@ViewChild('reachabilitySearchInput')
private readonly reachabilitySearchInput?: ElementRef<HTMLInputElement>;
@@ -99,7 +106,11 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
readonly selectedVulnId = signal<string | null>(null);
readonly selectedForBulk = signal<readonly string[]>([]);
readonly activeTab = signal<TabId>('overview');
readonly activeTab = signal<TabId>('evidence');
// Decision drawer state
readonly showDecisionDrawer = signal(false);
readonly currentEvidence = signal<EvidenceBundle | undefined>(undefined);
readonly showVexModal = signal(false);
readonly vexTargetVulnerabilityIds = signal<readonly string[]>([]);
@@ -125,6 +136,50 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
return id ? this.findings().find((f) => f.vuln.vulnId === id) ?? null : null;
});
readonly selectedAlertSummary = computed(() => {
const selected = this.selectedVuln();
if (!selected) return undefined;
return {
id: selected.vuln.vulnId,
artifactId: this.artifactId(),
vulnId: selected.vuln.cveId,
severity: selected.vuln.severity,
};
});
readonly selectedEvidenceBundle = computed<EvidenceBundle | undefined>(() => {
const selected = this.selectedVuln();
if (!selected) return undefined;
// Build mock evidence bundle based on vulnerability data
const vuln = selected.vuln;
return {
alertId: vuln.vulnId,
reachability: {
status: vuln.reachabilityStatus === 'reachable' ? 'available'
: vuln.reachabilityStatus === 'unreachable' ? 'available'
: vuln.reachabilityStatus === 'unknown' ? 'loading'
: 'unavailable',
hash: `reach-${vuln.vulnId}`,
},
callstack: {
status: vuln.reachabilityStatus === 'reachable' ? 'available' : 'unavailable',
},
provenance: {
status: this.hasSignedEvidence(selected) ? 'available' : 'pending_enrichment',
},
vex: {
status: this.getVexBadgeForFinding(selected) ? 'available' : 'unavailable',
current: this.getVexBadgeForFinding(selected) ? {
statementId: `vex-${vuln.vulnId}`,
status: this.getVexBadgeForFinding(selected) ?? 'unknown',
timestamp: new Date().toISOString(),
} : undefined,
},
computedAt: new Date().toISOString(),
};
});
readonly findings = computed<readonly FindingCardModel[]>(() => {
const id = this.artifactId();
if (!id) return [];
@@ -302,9 +357,26 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
}
selectFinding(vulnId: string, options?: { resetTab?: boolean }): void {
const previousId = this.selectedVulnId();
// If changing selection, start new TTFS tracking
if (previousId !== vulnId) {
// Cancel tracking on previous alert if any
if (previousId) {
this.ttfsTelemetry.cancelTracking(previousId);
}
// Start tracking for new alert
const finding = this.findings().find((f) => f.vuln.vulnId === vulnId);
if (finding) {
const alertCreatedAt = finding.vuln.publishedAt ? new Date(finding.vuln.publishedAt) : new Date();
this.ttfsTelemetry.startTracking(vulnId, alertCreatedAt);
}
}
this.selectedVulnId.set(vulnId);
if (options?.resetTab ?? true) {
this.activeTab.set('overview');
this.activeTab.set('evidence');
}
}
@@ -353,6 +425,69 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
this.vexExistingDecision.set(null);
}
// Decision drawer methods
openDecisionDrawer(): void {
const vulnId = this.selectedVulnId();
if (vulnId) {
this.ttfsTelemetry.recordInteraction(vulnId, 'open_drawer');
}
this.showDecisionDrawer.set(true);
}
closeDecisionDrawer(): void {
this.showDecisionDrawer.set(false);
}
onDecisionDrawerSubmit(decision: DecisionFormData): void {
const selected = this.selectedVuln();
if (!selected) return;
const vulnId = selected.vuln.vulnId;
// Record TTFS decision event
this.ttfsTelemetry.recordDecision(vulnId, decision.status);
// Convert to VEX and submit
const vexStatus = this.mapDecisionStatusToVex(decision.status);
this.vexTargetVulnerabilityIds.set([selected.vuln.cveId]);
this.vexModalInitialStatus.set(vexStatus);
this.showVexModal.set(true);
this.closeDecisionDrawer();
}
private mapDecisionStatusToVex(status: DecisionFormData['status']): VexStatus {
switch (status) {
case 'affected':
return 'AFFECTED_UNMITIGATED';
case 'not_affected':
return 'NOT_AFFECTED';
case 'under_investigation':
default:
return 'UNDER_INVESTIGATION';
}
}
onEvidencePillClick(evidenceType: 'reachability' | 'callstack' | 'provenance' | 'vex'): void {
const vulnId = this.selectedVulnId();
if (vulnId) {
this.ttfsTelemetry.recordInteraction(vulnId, `pill_click_${evidenceType}`);
}
// Navigate to relevant evidence section
if (evidenceType === 'reachability') {
this.activeTab.set('reachability');
} else if (evidenceType === 'vex') {
this.openDecisionDrawer();
}
}
// Get evidence hash for audit trail
getEvidenceHash(): string {
const evidence = this.currentEvidence();
return evidence?.hashes?.combinedHash ?? '';
}
onVexSaved(decisions: readonly VexDecision[]): void {
const updated = [...this.vexDecisions(), ...decisions].sort((a, b) => {
const aWhen = a.updatedAt ?? a.createdAt;
@@ -467,7 +602,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
}
private isShortcutOverlayOpen(): boolean {
return this.showVexModal() || this.showKeyboardHelp() || this.showReachabilityDrawer() || this.attestationModal() !== null;
return this.showVexModal() || this.showKeyboardHelp() || this.showReachabilityDrawer() || this.attestationModal() !== null || this.showDecisionDrawer();
}
private jumpToIncompleteEvidencePane(): void {
@@ -576,6 +711,7 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
if (this.showKeyboardHelp()) this.showKeyboardHelp.set(false);
if (this.showReachabilityDrawer()) this.closeReachabilityDrawer();
if (this.attestationModal()) this.attestationModal.set(null);
if (this.showDecisionDrawer()) this.closeDecisionDrawer();
}
private toggleKeyboardHelp(): void {