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:
@@ -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">
|
||||
×
|
||||
</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: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 '?';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 }} ·
|
||||
{{ 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() } }"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user