Add tests for SBOM generation determinism across multiple formats

- Created `StellaOps.TestKit.Tests` project for unit tests related to determinism.
- Implemented `DeterminismManifestTests` to validate deterministic output for canonical bytes and strings, file read/write operations, and error handling for invalid schema versions.
- Added `SbomDeterminismTests` to ensure identical inputs produce consistent SBOMs across SPDX 3.0.1 and CycloneDX 1.6/1.7 formats, including parallel execution tests.
- Updated project references in `StellaOps.Integration.Determinism` to include the new determinism testing library.
This commit is contained in:
master
2025-12-23 18:56:12 +02:00
committed by StellaOps Bot
parent 7ac70ece71
commit 491e883653
409 changed files with 23797 additions and 17779 deletions

View File

@@ -0,0 +1,267 @@
/**
* Verdict API client for SPRINT_4000_0100_0001 — Reachability Proof Panels UI.
* Provides services for verdict attestations and signature verification.
*/
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { AppConfigService } from '../config/app-config.service';
import {
VerdictAttestation,
VerdictSummary,
ListVerdictsResponse,
ListVerdictsOptions,
VerifyVerdictResponse,
VerdictStatus,
Evidence,
AdvisoryEvidence,
SbomEvidence,
VexEvidence,
ReachabilityEvidence,
PolicyRuleEvidence,
DsseEnvelope,
} from './verdict.models';
// ============================================================================
// Injection Tokens
// ============================================================================
export const VERDICT_API = new InjectionToken<VerdictApi>('VERDICT_API');
// ============================================================================
// API Interface
// ============================================================================
/**
* API interface for verdict operations.
*/
export interface VerdictApi {
getVerdict(verdictId: string): Observable<VerdictAttestation>;
listVerdictsForRun(runId: string, options?: ListVerdictsOptions): Observable<ListVerdictsResponse>;
verifyVerdict(verdictId: string): Observable<VerifyVerdictResponse>;
downloadEnvelope(verdictId: string): Observable<Blob>;
}
// ============================================================================
// Mock Data Fixtures
// ============================================================================
function createMockEvidence(): readonly Evidence[] {
const advisory: AdvisoryEvidence = {
id: 'ev-advisory-001',
type: 'advisory',
timestamp: new Date().toISOString(),
source: 'nvd',
cveId: 'CVE-2024-12345',
severity: 'high',
description: 'Remote code execution vulnerability in example-package',
cvssScore: 8.1,
cvssVector: 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H',
epssScore: 0.42,
references: ['https://nvd.nist.gov/vuln/detail/CVE-2024-12345'],
};
const sbom: SbomEvidence = {
id: 'ev-sbom-001',
type: 'sbom',
timestamp: new Date().toISOString(),
source: 'scanner',
packageName: 'example-package',
packageVersion: '1.2.3',
packagePurl: 'pkg:npm/example-package@1.2.3',
sbomFormat: 'cyclonedx',
sbomDigest: 'sha256:abc123def456...',
};
const vex: VexEvidence = {
id: 'ev-vex-001',
type: 'vex',
timestamp: new Date().toISOString(),
source: 'vendor',
status: 'affected',
justification: 'vulnerable_code_present',
statementId: 'vex-stmt-001',
issuer: 'example-vendor',
};
const reachability: ReachabilityEvidence = {
id: 'ev-reach-001',
type: 'reachability',
timestamp: new Date().toISOString(),
source: 'static-analysis',
isReachable: true,
confidence: 0.87,
method: 'hybrid',
entrypoint: 'main.ts:handleRequest',
sink: 'example-package:vulnerableFunction',
pathLength: 5,
paths: [
{
entrypoint: 'main.ts:handleRequest',
sink: 'example-package:vulnerableFunction',
keyNodes: ['router.ts:dispatch', 'handler.ts:process', 'lib.ts:transform'],
intermediateCount: 2,
},
],
};
const policyRule: PolicyRuleEvidence = {
id: 'ev-rule-001',
type: 'policy_rule',
timestamp: new Date().toISOString(),
source: 'policy-engine',
ruleId: 'rule-critical-cve',
ruleName: 'Block Critical CVEs',
ruleResult: 'fail',
expression: 'severity == "critical" && reachable == true',
message: 'Critical vulnerability is reachable from entrypoint',
};
return [advisory, sbom, vex, reachability, policyRule];
}
function createMockVerdict(verdictId: string): VerdictAttestation {
return {
verdictId,
tenantId: 'tenant-001',
policyRunId: 'run-001',
policyId: 'policy-default',
policyVersion: '1.0.0',
findingId: 'finding-001',
verdictStatus: 'fail',
verdictSeverity: 'high',
verdictScore: 8.1,
evaluatedAt: new Date().toISOString(),
evidenceChain: createMockEvidence(),
envelope: {
payloadType: 'application/vnd.stellaops.verdict+json',
payload: btoa(JSON.stringify({ verdictId, status: 'fail' })),
signatures: [
{
keyid: 'key-001',
sig: 'mock-signature-base64...',
},
],
},
predicateDigest: 'sha256:predicate123...',
determinismHash: 'sha256:determinism456...',
rekorLogIndex: 123456,
createdAt: new Date().toISOString(),
};
}
// ============================================================================
// Mock Implementation
// ============================================================================
@Injectable()
export class MockVerdictClient implements VerdictApi {
private readonly mockDelay = 300;
getVerdict(verdictId: string): Observable<VerdictAttestation> {
return of(createMockVerdict(verdictId)).pipe(delay(this.mockDelay));
}
listVerdictsForRun(runId: string, options?: ListVerdictsOptions): Observable<ListVerdictsResponse> {
const verdicts: VerdictSummary[] = Array.from({ length: 5 }, (_, i) => ({
verdictId: `verdict-${runId}-${i + 1}`,
findingId: `finding-${i + 1}`,
verdictStatus: i % 3 === 0 ? 'fail' : 'pass' as VerdictStatus,
verdictSeverity: i % 2 === 0 ? 'high' : 'medium' as const,
verdictScore: 5 + i,
evaluatedAt: new Date().toISOString(),
determinismHash: `sha256:hash${i}...`,
}));
return of({
verdicts,
pagination: {
total: verdicts.length,
limit: options?.limit ?? 50,
offset: options?.offset ?? 0,
},
}).pipe(delay(this.mockDelay));
}
verifyVerdict(verdictId: string): Observable<VerifyVerdictResponse> {
return of({
verdictId,
signatureValid: true,
verifiedAt: new Date().toISOString(),
verifications: [
{
keyId: 'key-001',
algorithm: 'ed25519',
valid: true,
timestamp: new Date().toISOString(),
issuer: 'stellaops-signer',
},
],
rekorVerification: {
logIndex: 123456,
inclusionProofValid: true,
verifiedAt: new Date().toISOString(),
logId: 'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d',
},
}).pipe(delay(this.mockDelay));
}
downloadEnvelope(verdictId: string): Observable<Blob> {
const envelope = createMockVerdict(verdictId).envelope;
const blob = new Blob([JSON.stringify(envelope, null, 2)], { type: 'application/json' });
return of(blob).pipe(delay(this.mockDelay));
}
}
// ============================================================================
// HTTP Implementation
// ============================================================================
@Injectable()
export class HttpVerdictClient implements VerdictApi {
private readonly http = inject(HttpClient);
private readonly config = inject(AppConfigService);
private get baseUrl(): string {
return `${this.config.apiBaseUrl}/api/v1`;
}
getVerdict(verdictId: string): Observable<VerdictAttestation> {
return this.http.get<VerdictAttestation>(`${this.baseUrl}/verdicts/${verdictId}`).pipe(
catchError(this.handleError)
);
}
listVerdictsForRun(runId: string, options?: ListVerdictsOptions): Observable<ListVerdictsResponse> {
const params: Record<string, string> = {};
if (options?.status) params['status'] = options.status;
if (options?.severity) params['severity'] = options.severity;
if (options?.limit) params['limit'] = String(options.limit);
if (options?.offset) params['offset'] = String(options.offset);
return this.http.get<ListVerdictsResponse>(`${this.baseUrl}/runs/${runId}/verdicts`, { params }).pipe(
catchError(this.handleError)
);
}
verifyVerdict(verdictId: string): Observable<VerifyVerdictResponse> {
return this.http.post<VerifyVerdictResponse>(`${this.baseUrl}/verdicts/${verdictId}/verify`, {}).pipe(
catchError(this.handleError)
);
}
downloadEnvelope(verdictId: string): Observable<Blob> {
return this.http.get(`${this.baseUrl}/verdicts/${verdictId}/envelope`, {
responseType: 'blob',
}).pipe(
catchError(this.handleError)
);
}
private handleError(error: HttpErrorResponse): Observable<never> {
console.error('VerdictApi error:', error);
return throwError(() => new Error(error.message || 'Verdict API error'));
}
}

View File

@@ -0,0 +1,245 @@
/**
* Verdict API models for SPRINT_4000_0100_0001 — Reachability Proof Panels UI.
* Provides types for verdict attestations, evidence chains, and signature verification.
*/
// ============================================================================
// Core Verdict Types
// ============================================================================
/**
* Verdict status enumeration.
*/
export type VerdictStatus = 'pass' | 'fail' | 'warn' | 'error' | 'unknown';
/**
* Verdict severity levels.
*/
export type VerdictSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info' | 'none';
/**
* Signature verification status.
*/
export type SignatureStatus = 'verified' | 'invalid' | 'pending' | 'missing';
/**
* Evidence type enumeration.
*/
export type EvidenceType = 'advisory' | 'sbom' | 'vex' | 'reachability' | 'policy_rule' | 'attestation';
// ============================================================================
// Evidence Models
// ============================================================================
/**
* Base evidence item in the chain.
*/
export interface EvidenceItem {
readonly id: string;
readonly type: EvidenceType;
readonly timestamp: string;
readonly source: string;
readonly digest?: string;
}
/**
* Advisory evidence (CVE information).
*/
export interface AdvisoryEvidence extends EvidenceItem {
readonly type: 'advisory';
readonly cveId: string;
readonly severity: VerdictSeverity;
readonly description: string;
readonly cvssScore?: number;
readonly cvssVector?: string;
readonly epssScore?: number;
readonly references?: readonly string[];
}
/**
* SBOM evidence.
*/
export interface SbomEvidence extends EvidenceItem {
readonly type: 'sbom';
readonly packageName: string;
readonly packageVersion: string;
readonly packagePurl?: string;
readonly sbomFormat: 'spdx' | 'cyclonedx';
readonly sbomDigest: string;
}
/**
* VEX statement evidence.
*/
export interface VexEvidence extends EvidenceItem {
readonly type: 'vex';
readonly status: 'affected' | 'not_affected' | 'fixed' | 'under_investigation';
readonly justification?: string;
readonly justificationText?: string;
readonly statementId: string;
readonly issuer?: string;
}
/**
* Reachability evidence with call path.
*/
export interface ReachabilityEvidence extends EvidenceItem {
readonly type: 'reachability';
readonly isReachable: boolean;
readonly confidence: number;
readonly method: 'static' | 'dynamic' | 'hybrid';
readonly entrypoint?: string;
readonly sink?: string;
readonly pathLength?: number;
readonly paths?: readonly CompressedPath[];
}
/**
* Compressed call path for visualization.
*/
export interface CompressedPath {
readonly entrypoint: string;
readonly sink: string;
readonly keyNodes: readonly string[];
readonly intermediateCount: number;
}
/**
* Policy rule evidence.
*/
export interface PolicyRuleEvidence extends EvidenceItem {
readonly type: 'policy_rule';
readonly ruleId: string;
readonly ruleName: string;
readonly ruleResult: 'pass' | 'fail' | 'skip';
readonly expression?: string;
readonly message?: string;
}
/**
* Union type for all evidence types.
*/
export type Evidence =
| AdvisoryEvidence
| SbomEvidence
| VexEvidence
| ReachabilityEvidence
| PolicyRuleEvidence;
// ============================================================================
// Signature & Attestation Models
// ============================================================================
/**
* Signature verification details.
*/
export interface SignatureVerification {
readonly keyId: string;
readonly algorithm: string;
readonly valid: boolean;
readonly timestamp?: string;
readonly issuer?: string;
}
/**
* Rekor transparency log verification.
*/
export interface RekorVerification {
readonly logIndex: number;
readonly inclusionProofValid: boolean;
readonly verifiedAt: string;
readonly logId?: string;
}
/**
* Complete signature verification response.
*/
export interface VerifyVerdictResponse {
readonly verdictId: string;
readonly signatureValid: boolean;
readonly verifiedAt: string;
readonly verifications: readonly SignatureVerification[];
readonly rekorVerification?: RekorVerification;
}
// ============================================================================
// Verdict Attestation Models
// ============================================================================
/**
* DSSE envelope wrapper.
*/
export interface DsseEnvelope {
readonly payloadType: string;
readonly payload: string;
readonly signatures: readonly {
readonly keyid: string;
readonly sig: string;
}[];
}
/**
* Verdict attestation with evidence chain.
*/
export interface VerdictAttestation {
readonly verdictId: string;
readonly tenantId?: string;
readonly policyRunId: string;
readonly policyId: string;
readonly policyVersion: string;
readonly findingId: string;
readonly verdictStatus: VerdictStatus;
readonly verdictSeverity: VerdictSeverity;
readonly verdictScore?: number;
readonly evaluatedAt: string;
readonly evidenceChain: readonly Evidence[];
readonly envelope?: DsseEnvelope;
readonly predicateDigest?: string;
readonly determinismHash?: string;
readonly rekorLogIndex?: number;
readonly createdAt: string;
}
/**
* Verdict summary (without full envelope).
*/
export interface VerdictSummary {
readonly verdictId: string;
readonly findingId: string;
readonly verdictStatus: VerdictStatus;
readonly verdictSeverity: VerdictSeverity;
readonly verdictScore?: number;
readonly evaluatedAt: string;
readonly determinismHash?: string;
}
/**
* Pagination info for list responses.
*/
export interface PaginationInfo {
readonly total: number;
readonly limit: number;
readonly offset: number;
}
/**
* List verdicts response.
*/
export interface ListVerdictsResponse {
readonly verdicts: readonly VerdictSummary[];
readonly pagination: PaginationInfo;
}
// ============================================================================
// Request Models
// ============================================================================
/**
* List verdicts request options.
*/
export interface ListVerdictsOptions {
readonly status?: VerdictStatus;
readonly severity?: VerdictSeverity;
readonly limit?: number;
readonly offset?: number;
}

View File

@@ -0,0 +1,382 @@
/**
* Vulnerability Annotation API client for SPRINT_4000_0100_0002.
* Provides services for vulnerability triage and VEX candidate management.
*/
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AppConfigService } from '../config/app-config.service';
import {
VulnFinding,
VulnState,
StateTransitionRequest,
StateTransitionResponse,
VexCandidate,
VexCandidateApprovalRequest,
VexCandidateRejectionRequest,
VexStatement,
FindingListOptions,
CandidateListOptions,
FindingsListResponse,
CandidatesListResponse,
TriageSummary,
} from './vuln-annotation.models';
// ============================================================================
// Injection Tokens
// ============================================================================
export const VULN_ANNOTATION_API = new InjectionToken<VulnAnnotationApi>('VULN_ANNOTATION_API');
// ============================================================================
// API Interface
// ============================================================================
/**
* API interface for vulnerability annotation operations.
*/
export interface VulnAnnotationApi {
// Findings
listFindings(options?: FindingListOptions): Observable<FindingsListResponse>;
getFinding(findingId: string): Observable<VulnFinding>;
transitionState(findingId: string, request: StateTransitionRequest): Observable<StateTransitionResponse>;
getTriageSummary(): Observable<TriageSummary>;
// VEX Candidates
listCandidates(options?: CandidateListOptions): Observable<CandidatesListResponse>;
getCandidate(candidateId: string): Observable<VexCandidate>;
approveCandidate(candidateId: string, request: VexCandidateApprovalRequest): Observable<VexStatement>;
rejectCandidate(candidateId: string, request: VexCandidateRejectionRequest): Observable<VexCandidate>;
}
// ============================================================================
// Mock Data Fixtures
// ============================================================================
function createMockFindings(): readonly VulnFinding[] {
return [
{
findingId: 'finding-001',
vulnerabilityId: 'CVE-2024-12345',
packageName: 'lodash',
packageVersion: '4.17.20',
severity: 'critical',
state: 'open',
cvssScore: 9.8,
epssScore: 0.65,
isReachable: true,
reachabilityConfidence: 0.92,
discoveredAt: new Date(Date.now() - 86400000 * 3).toISOString(),
lastUpdatedAt: new Date().toISOString(),
tags: ['npm', 'backend'],
},
{
findingId: 'finding-002',
vulnerabilityId: 'CVE-2024-23456',
packageName: 'express',
packageVersion: '4.18.0',
severity: 'high',
state: 'in_review',
cvssScore: 7.5,
epssScore: 0.35,
isReachable: false,
reachabilityConfidence: 0.88,
discoveredAt: new Date(Date.now() - 86400000 * 5).toISOString(),
lastUpdatedAt: new Date(Date.now() - 86400000).toISOString(),
assignee: 'dev-team',
tags: ['npm', 'api'],
},
{
findingId: 'finding-003',
vulnerabilityId: 'CVE-2024-34567',
packageName: 'axios',
packageVersion: '1.4.0',
severity: 'medium',
state: 'open',
cvssScore: 5.3,
epssScore: 0.12,
isReachable: true,
reachabilityConfidence: 0.75,
discoveredAt: new Date(Date.now() - 86400000 * 2).toISOString(),
lastUpdatedAt: new Date().toISOString(),
},
];
}
function createMockCandidates(): readonly VexCandidate[] {
return [
{
candidateId: 'candidate-001',
findingId: 'finding-002',
vulnerabilityId: 'CVE-2024-23456',
productId: 'stellaops-web',
suggestedStatus: 'not_affected',
suggestedJustification: 'vulnerable_code_not_in_execute_path',
justificationText: 'The vulnerable code path is never executed in our usage pattern',
confidence: 0.89,
source: 'smart_diff',
evidenceDigests: ['sha256:abc123...'],
createdAt: new Date(Date.now() - 86400000).toISOString(),
expiresAt: new Date(Date.now() + 86400000 * 30).toISOString(),
status: 'pending',
},
{
candidateId: 'candidate-002',
findingId: 'finding-003',
vulnerabilityId: 'CVE-2024-34567',
productId: 'stellaops-web',
suggestedStatus: 'affected',
suggestedJustification: 'vulnerable_code_not_present',
confidence: 0.72,
source: 'reachability',
createdAt: new Date(Date.now() - 43200000).toISOString(),
status: 'pending',
},
];
}
// ============================================================================
// Mock Implementation
// ============================================================================
@Injectable()
export class MockVulnAnnotationClient implements VulnAnnotationApi {
private readonly mockDelay = 300;
private findings = [...createMockFindings()];
private candidates = [...createMockCandidates()];
listFindings(options?: FindingListOptions): Observable<FindingsListResponse> {
let filtered = this.findings;
if (options?.state) {
filtered = filtered.filter(f => f.state === options.state);
}
if (options?.severity) {
filtered = filtered.filter(f => f.severity === options.severity);
}
if (options?.isReachable !== undefined) {
filtered = filtered.filter(f => f.isReachable === options.isReachable);
}
const limit = options?.limit ?? 50;
const offset = options?.offset ?? 0;
return of({
items: filtered.slice(offset, offset + limit),
total: filtered.length,
limit,
offset,
}).pipe(delay(this.mockDelay));
}
getFinding(findingId: string): Observable<VulnFinding> {
const finding = this.findings.find(f => f.findingId === findingId);
if (!finding) {
return throwError(() => new Error('Finding not found'));
}
return of(finding).pipe(delay(this.mockDelay));
}
transitionState(findingId: string, request: StateTransitionRequest): Observable<StateTransitionResponse> {
const idx = this.findings.findIndex(f => f.findingId === findingId);
if (idx === -1) {
return throwError(() => new Error('Finding not found'));
}
const previousState = this.findings[idx].state;
this.findings[idx] = { ...this.findings[idx], state: request.targetState, lastUpdatedAt: new Date().toISOString() };
return of({
findingId,
previousState,
currentState: request.targetState,
transitionRecordedAt: new Date().toISOString(),
actorId: 'current-user',
justification: request.justification,
notes: request.notes,
dueDate: request.dueDate,
tags: request.tags,
eventId: `event-${Date.now()}`,
}).pipe(delay(this.mockDelay));
}
getTriageSummary(): Observable<TriageSummary> {
const byState: Record<VulnState, number> = {
open: 0,
in_review: 0,
mitigated: 0,
closed: 0,
false_positive: 0,
deferred: 0,
};
const bySeverity: Record<string, number> = {
critical: 0,
high: 0,
medium: 0,
low: 0,
};
for (const f of this.findings) {
byState[f.state]++;
bySeverity[f.severity]++;
}
return of({
totalFindings: this.findings.length,
byState,
bySeverity,
pendingCandidates: this.candidates.filter(c => c.status === 'pending').length,
}).pipe(delay(this.mockDelay));
}
listCandidates(options?: CandidateListOptions): Observable<CandidatesListResponse> {
let filtered = this.candidates;
if (options?.findingId) {
filtered = filtered.filter(c => c.findingId === options.findingId);
}
if (options?.status) {
filtered = filtered.filter(c => c.status === options.status);
}
const limit = options?.limit ?? 50;
const offset = options?.offset ?? 0;
return of({
items: filtered.slice(offset, offset + limit),
total: filtered.length,
limit,
offset,
}).pipe(delay(this.mockDelay));
}
getCandidate(candidateId: string): Observable<VexCandidate> {
const candidate = this.candidates.find(c => c.candidateId === candidateId);
if (!candidate) {
return throwError(() => new Error('Candidate not found'));
}
return of(candidate).pipe(delay(this.mockDelay));
}
approveCandidate(candidateId: string, request: VexCandidateApprovalRequest): Observable<VexStatement> {
const idx = this.candidates.findIndex(c => c.candidateId === candidateId);
if (idx === -1) {
return throwError(() => new Error('Candidate not found'));
}
const candidate = this.candidates[idx];
this.candidates[idx] = { ...candidate, status: 'approved', reviewedBy: 'current-user', reviewedAt: new Date().toISOString() };
return of({
statementId: `vex-stmt-${Date.now()}`,
vulnerabilityId: candidate.vulnerabilityId,
productId: candidate.productId,
status: request.status,
justification: request.justification,
justificationText: request.justificationText,
timestamp: new Date().toISOString(),
validUntil: request.validUntil,
approvedBy: 'current-user',
sourceCandidate: candidateId,
}).pipe(delay(this.mockDelay));
}
rejectCandidate(candidateId: string, request: VexCandidateRejectionRequest): Observable<VexCandidate> {
const idx = this.candidates.findIndex(c => c.candidateId === candidateId);
if (idx === -1) {
return throwError(() => new Error('Candidate not found'));
}
this.candidates[idx] = {
...this.candidates[idx],
status: 'rejected',
reviewedBy: 'current-user',
reviewedAt: new Date().toISOString(),
};
return of(this.candidates[idx]).pipe(delay(this.mockDelay));
}
}
// ============================================================================
// HTTP Implementation
// ============================================================================
@Injectable()
export class HttpVulnAnnotationClient implements VulnAnnotationApi {
private readonly http = inject(HttpClient);
private readonly config = inject(AppConfigService);
private get baseUrl(): string {
return `${this.config.apiBaseUrl}/api/v1`;
}
listFindings(options?: FindingListOptions): Observable<FindingsListResponse> {
const params: Record<string, string> = {};
if (options?.state) params['state'] = options.state;
if (options?.severity) params['severity'] = options.severity;
if (options?.isReachable !== undefined) params['isReachable'] = String(options.isReachable);
if (options?.limit) params['limit'] = String(options.limit);
if (options?.offset) params['offset'] = String(options.offset);
return this.http.get<FindingsListResponse>(`${this.baseUrl}/findings`, { params }).pipe(
catchError(this.handleError)
);
}
getFinding(findingId: string): Observable<VulnFinding> {
return this.http.get<VulnFinding>(`${this.baseUrl}/findings/${findingId}`).pipe(
catchError(this.handleError)
);
}
transitionState(findingId: string, request: StateTransitionRequest): Observable<StateTransitionResponse> {
return this.http.patch<StateTransitionResponse>(`${this.baseUrl}/findings/${findingId}/state`, request).pipe(
catchError(this.handleError)
);
}
getTriageSummary(): Observable<TriageSummary> {
return this.http.get<TriageSummary>(`${this.baseUrl}/findings/summary`).pipe(
catchError(this.handleError)
);
}
listCandidates(options?: CandidateListOptions): Observable<CandidatesListResponse> {
const params: Record<string, string> = {};
if (options?.findingId) params['findingId'] = options.findingId;
if (options?.status) params['status'] = options.status;
if (options?.limit) params['limit'] = String(options.limit);
if (options?.offset) params['offset'] = String(options.offset);
return this.http.get<CandidatesListResponse>(`${this.baseUrl}/vex/candidates`, { params }).pipe(
catchError(this.handleError)
);
}
getCandidate(candidateId: string): Observable<VexCandidate> {
return this.http.get<VexCandidate>(`${this.baseUrl}/vex/candidates/${candidateId}`).pipe(
catchError(this.handleError)
);
}
approveCandidate(candidateId: string, request: VexCandidateApprovalRequest): Observable<VexStatement> {
return this.http.post<VexStatement>(`${this.baseUrl}/vex/candidates/${candidateId}/approve`, request).pipe(
catchError(this.handleError)
);
}
rejectCandidate(candidateId: string, request: VexCandidateRejectionRequest): Observable<VexCandidate> {
return this.http.post<VexCandidate>(`${this.baseUrl}/vex/candidates/${candidateId}/reject`, request).pipe(
catchError(this.handleError)
);
}
private handleError(error: HttpErrorResponse): Observable<never> {
console.error('VulnAnnotationApi error:', error);
return throwError(() => new Error(error.message || 'Vulnerability annotation API error'));
}
}

View File

@@ -0,0 +1,209 @@
/**
* Vulnerability Annotation API models for SPRINT_4000_0100_0002.
* Provides types for vulnerability triage, VEX candidates, and state transitions.
*/
// ============================================================================
// Vulnerability State Types
// ============================================================================
/**
* Vulnerability lifecycle states.
*/
export type VulnState =
| 'open'
| 'in_review'
| 'mitigated'
| 'closed'
| 'false_positive'
| 'deferred';
/**
* VEX status types.
*/
export type VexStatus =
| 'affected'
| 'not_affected'
| 'fixed'
| 'under_investigation';
/**
* VEX justification types.
*/
export type VexJustification =
| 'component_not_present'
| 'vulnerable_code_not_present'
| 'vulnerable_code_not_in_execute_path'
| 'vulnerable_code_cannot_be_controlled_by_adversary'
| 'inline_mitigations_already_exist';
/**
* VEX candidate status.
*/
export type CandidateStatus = 'pending' | 'approved' | 'rejected';
// ============================================================================
// Finding Models
// ============================================================================
/**
* Vulnerability finding for triage.
*/
export interface VulnFinding {
readonly findingId: string;
readonly vulnerabilityId: string;
readonly packageName: string;
readonly packageVersion: string;
readonly severity: 'critical' | 'high' | 'medium' | 'low';
readonly state: VulnState;
readonly cvssScore?: number;
readonly epssScore?: number;
readonly isReachable?: boolean;
readonly reachabilityConfidence?: number;
readonly discoveredAt: string;
readonly lastUpdatedAt: string;
readonly assignee?: string;
readonly tags?: readonly string[];
}
/**
* State transition request.
*/
export interface StateTransitionRequest {
readonly targetState: VulnState;
readonly justification?: string;
readonly notes?: string;
readonly dueDate?: string;
readonly tags?: readonly string[];
}
/**
* State transition response.
*/
export interface StateTransitionResponse {
readonly findingId: string;
readonly previousState: VulnState;
readonly currentState: VulnState;
readonly transitionRecordedAt: string;
readonly actorId: string;
readonly justification?: string;
readonly notes?: string;
readonly dueDate?: string;
readonly tags?: readonly string[];
readonly eventId?: string;
}
// ============================================================================
// VEX Candidate Models
// ============================================================================
/**
* VEX candidate generated by Smart-Diff.
*/
export interface VexCandidate {
readonly candidateId: string;
readonly findingId: string;
readonly vulnerabilityId: string;
readonly productId: string;
readonly suggestedStatus: VexStatus;
readonly suggestedJustification: VexJustification;
readonly justificationText?: string;
readonly confidence: number;
readonly source: 'smart_diff' | 'reachability' | 'manual';
readonly evidenceDigests?: readonly string[];
readonly createdAt: string;
readonly expiresAt?: string;
readonly status: CandidateStatus;
readonly reviewedBy?: string;
readonly reviewedAt?: string;
}
/**
* VEX candidate approval request.
*/
export interface VexCandidateApprovalRequest {
readonly status: VexStatus;
readonly justification: VexJustification;
readonly justificationText?: string;
readonly validUntil?: string;
}
/**
* VEX candidate rejection request.
*/
export interface VexCandidateRejectionRequest {
readonly reason: string;
readonly notes?: string;
}
/**
* Approved VEX statement.
*/
export interface VexStatement {
readonly statementId: string;
readonly vulnerabilityId: string;
readonly productId: string;
readonly status: VexStatus;
readonly justification: VexJustification;
readonly justificationText?: string;
readonly timestamp: string;
readonly validUntil?: string;
readonly approvedBy: string;
readonly sourceCandidate?: string;
readonly dsseEnvelopeDigest?: string;
}
// ============================================================================
// List & Filter Models
// ============================================================================
/**
* Finding list filter options.
*/
export interface FindingListOptions {
readonly state?: VulnState;
readonly severity?: string;
readonly isReachable?: boolean;
readonly limit?: number;
readonly offset?: number;
}
/**
* Candidate list filter options.
*/
export interface CandidateListOptions {
readonly findingId?: string;
readonly status?: CandidateStatus;
readonly limit?: number;
readonly offset?: number;
}
/**
* Paginated findings response.
*/
export interface FindingsListResponse {
readonly items: readonly VulnFinding[];
readonly total: number;
readonly limit: number;
readonly offset: number;
}
/**
* Paginated candidates response.
*/
export interface CandidatesListResponse {
readonly items: readonly VexCandidate[];
readonly total: number;
readonly limit: number;
readonly offset: number;
}
/**
* Triage summary statistics.
*/
export interface TriageSummary {
readonly totalFindings: number;
readonly byState: Record<VulnState, number>;
readonly bySeverity: Record<string, number>;
readonly pendingCandidates: number;
}

View File

@@ -0,0 +1,153 @@
/**
* Unit tests for AttestationBadgeComponent.
* SPRINT_4000_0100_0001 - Proof Panels UI
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AttestationBadgeComponent } from './attestation-badge.component';
import { SignatureVerification } from '../../../core/api/verdict.models';
describe('AttestationBadgeComponent', () => {
let component: AttestationBadgeComponent;
let fixture: ComponentFixture<AttestationBadgeComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AttestationBadgeComponent],
}).compileComponents();
fixture = TestBed.createComponent(AttestationBadgeComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display unknown status when no verification provided', () => {
fixture.detectChanges();
expect(component.status()).toBe('unknown');
expect(component.statusIcon()).toBe('?');
expect(component.statusLabel()).toBe('Unknown');
});
it('should display verified status correctly', () => {
const verification: SignatureVerification = {
status: 'verified',
keyId: 'key-001',
algorithm: 'ecdsa-p256',
issuer: 'StellaOps Authority',
verifiedAt: '2025-01-15T10:31:00Z',
};
fixture.componentRef.setInput('verification', verification);
fixture.detectChanges();
expect(component.status()).toBe('verified');
expect(component.statusIcon()).toBe('✓');
expect(component.statusLabel()).toBe('Verified');
const badge = fixture.nativeElement.querySelector('.attestation-badge');
expect(badge.classList.contains('status-verified')).toBe(true);
});
it('should display failed status correctly', () => {
const verification: SignatureVerification = {
status: 'failed',
keyId: 'key-001',
message: 'Signature mismatch',
};
fixture.componentRef.setInput('verification', verification);
fixture.detectChanges();
expect(component.status()).toBe('failed');
expect(component.statusIcon()).toBe('✗');
expect(component.statusLabel()).toBe('Verification Failed');
const badge = fixture.nativeElement.querySelector('.attestation-badge');
expect(badge.classList.contains('status-failed')).toBe(true);
});
it('should display pending status correctly', () => {
const verification: SignatureVerification = {
status: 'pending',
};
fixture.componentRef.setInput('verification', verification);
fixture.detectChanges();
expect(component.status()).toBe('pending');
expect(component.statusIcon()).toBe('⏳');
expect(component.statusLabel()).toBe('Pending');
});
it('should not show details by default', () => {
const verification: SignatureVerification = {
status: 'verified',
keyId: 'key-001',
algorithm: 'ecdsa-p256',
};
fixture.componentRef.setInput('verification', verification);
fixture.detectChanges();
const details = fixture.nativeElement.querySelector('.badge-details');
expect(details).toBeFalsy();
});
it('should show details when showDetails is true', () => {
const verification: SignatureVerification = {
status: 'verified',
keyId: 'key-001',
algorithm: 'ecdsa-p256',
issuer: 'StellaOps Authority',
verifiedAt: '2025-01-15T10:31:00Z',
};
fixture.componentRef.setInput('verification', verification);
fixture.componentRef.setInput('showDetails', true);
fixture.detectChanges();
const details = fixture.nativeElement.querySelector('.badge-details');
expect(details).toBeTruthy();
const keyId = details.querySelector('.detail-value');
expect(keyId.textContent).toContain('key-001');
});
it('should display error message for failed verification', () => {
const verification: SignatureVerification = {
status: 'failed',
keyId: 'key-001',
message: 'Public key not found',
};
fixture.componentRef.setInput('verification', verification);
fixture.componentRef.setInput('showDetails', true);
fixture.detectChanges();
const message = fixture.nativeElement.querySelector('.detail-message');
expect(message).toBeTruthy();
expect(message.textContent).toContain('Public key not found');
expect(message.classList.contains('error')).toBe(true);
});
it('should display all verification details', () => {
const verification: SignatureVerification = {
status: 'verified',
keyId: 'key-abc-123',
algorithm: 'ed25519',
issuer: 'Custom CA',
verifiedAt: '2025-01-15T10:31:00Z',
};
fixture.componentRef.setInput('verification', verification);
fixture.componentRef.setInput('showDetails', true);
fixture.detectChanges();
const detailRows = fixture.nativeElement.querySelectorAll('.detail-row');
expect(detailRows.length).toBe(4); // keyId, issuer, algorithm, verifiedAt
});
});

View File

@@ -0,0 +1,173 @@
/**
* AttestationBadgeComponent for SPRINT_4000_0100_0001.
* Displays verification status badge for attestations.
*/
import { Component, input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SignatureVerification, VerificationStatus } from '../../../core/api/verdict.models';
@Component({
selector: 'app-attestation-badge',
standalone: true,
imports: [CommonModule],
template: `
<div class="attestation-badge" [class]="'status-' + status()">
<span class="badge-icon">{{ statusIcon() }}</span>
<span class="badge-label">{{ statusLabel() }}</span>
@if (showDetails() && verification()) {
<div class="badge-details">
@if (verification()!.keyId) {
<div class="detail-row">
<span class="detail-label">Key ID:</span>
<code class="detail-value">{{ verification()!.keyId }}</code>
</div>
}
@if (verification()!.issuer) {
<div class="detail-row">
<span class="detail-label">Issuer:</span>
<span class="detail-value">{{ verification()!.issuer }}</span>
</div>
}
@if (verification()!.algorithm) {
<div class="detail-row">
<span class="detail-label">Algorithm:</span>
<span class="detail-value">{{ verification()!.algorithm }}</span>
</div>
}
@if (verification()!.verifiedAt) {
<div class="detail-row">
<span class="detail-label">Verified:</span>
<span class="detail-value">{{ verification()!.verifiedAt | date:'medium' }}</span>
</div>
}
@if (verification()!.message) {
<div class="detail-message" [class.error]="status() === 'failed'">
{{ verification()!.message }}
</div>
}
</div>
}
</div>
`,
styles: [`
.attestation-badge {
display: inline-flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
}
.attestation-badge.status-verified {
background: #f0fdf4;
border: 1px solid #86efac;
}
.attestation-badge.status-failed {
background: #fef2f2;
border: 1px solid #fca5a5;
}
.attestation-badge.status-pending {
background: #fefce8;
border: 1px solid #fde047;
}
.attestation-badge.status-unknown {
background: #f5f5f5;
border: 1px solid #e0e0e0;
}
.badge-icon {
font-size: 1rem;
}
.badge-label {
font-weight: 500;
}
.status-verified .badge-label { color: #16a34a; }
.status-failed .badge-label { color: #dc2626; }
.status-pending .badge-label { color: #ca8a04; }
.status-unknown .badge-label { color: #666; }
.badge-details {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid currentColor;
opacity: 0.3;
}
.badge-details {
opacity: 1;
border-top-color: var(--border-color, #e0e0e0);
}
.detail-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.25rem;
font-size: 0.75rem;
}
.detail-label {
color: var(--text-muted, #666);
min-width: 60px;
}
.detail-value {
font-family: inherit;
}
code.detail-value {
font-family: monospace;
font-size: 0.7rem;
background: rgba(0, 0, 0, 0.05);
padding: 0.125rem 0.25rem;
border-radius: 2px;
}
.detail-message {
margin-top: 0.5rem;
padding: 0.375rem;
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
font-size: 0.75rem;
}
.detail-message.error {
background: #fef2f2;
color: #dc2626;
}
`],
})
export class AttestationBadgeComponent {
readonly verification = input<SignatureVerification | null>(null);
readonly showDetails = input<boolean>(false);
readonly status = computed<VerificationStatus>(() => {
return this.verification()?.status ?? 'unknown';
});
readonly statusIcon = computed<string>(() => {
const icons: Record<VerificationStatus, string> = {
verified: '✓',
failed: '✗',
pending: '⏳',
unknown: '?',
};
return icons[this.status()];
});
readonly statusLabel = computed<string>(() => {
const labels: Record<VerificationStatus, string> = {
verified: 'Verified',
failed: 'Verification Failed',
pending: 'Pending',
unknown: 'Unknown',
};
return labels[this.status()];
});
}

View File

@@ -0,0 +1 @@
export * from './attestation-badge.component';

View File

@@ -0,0 +1,179 @@
/**
* Unit tests for EvidenceChainViewerComponent.
* SPRINT_4000_0100_0001 - Proof Panels UI
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EvidenceChainViewerComponent } from './evidence-chain-viewer.component';
import { Evidence } from '../../../core/api/verdict.models';
describe('EvidenceChainViewerComponent', () => {
let component: EvidenceChainViewerComponent;
let fixture: ComponentFixture<EvidenceChainViewerComponent>;
const mockEvidence: readonly Evidence[] = [
{
type: 'advisory',
vulnerabilityId: 'CVE-2024-1234',
source: 'nvd',
publishedAt: '2024-06-15T00:00:00Z',
},
{
type: 'sbom',
format: 'spdx-3.0',
digest: 'sha256:abc123...',
createdAt: '2025-01-10T08:00:00Z',
},
{
type: 'vex',
status: 'not_affected',
justification: 'vulnerable_code_not_present',
justificationText: 'The vulnerable code path is not used.',
},
{
type: 'reachability',
isReachable: false,
confidence: 0.95,
callPath: [],
},
{
type: 'policy_rule',
ruleId: 'no-critical-vulns',
outcome: 'pass',
message: 'No critical vulnerabilities found.',
},
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EvidenceChainViewerComponent],
}).compileComponents();
fixture = TestBed.createComponent(EvidenceChainViewerComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display empty state when no evidence', () => {
fixture.componentRef.setInput('evidence', []);
fixture.detectChanges();
const emptyState = fixture.nativeElement.querySelector('.empty-chain');
expect(emptyState).toBeTruthy();
expect(emptyState.textContent).toContain('No evidence items available');
});
it('should display all evidence items', () => {
fixture.componentRef.setInput('evidence', mockEvidence);
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll('.chain-item');
expect(items.length).toBe(5);
});
it('should display advisory evidence correctly', () => {
fixture.componentRef.setInput('evidence', [mockEvidence[0]]);
fixture.detectChanges();
const vulnId = fixture.nativeElement.querySelector('.vuln-id');
expect(vulnId.textContent).toBe('CVE-2024-1234');
const source = fixture.nativeElement.querySelector('.source');
expect(source.textContent).toContain('nvd');
});
it('should display SBOM evidence correctly', () => {
fixture.componentRef.setInput('evidence', [mockEvidence[1]]);
fixture.detectChanges();
const format = fixture.nativeElement.querySelector('.format');
expect(format.textContent).toBe('spdx-3.0');
const digest = fixture.nativeElement.querySelector('.digest');
expect(digest.textContent).toBe('sha256:abc123...');
});
it('should display VEX evidence correctly', () => {
fixture.componentRef.setInput('evidence', [mockEvidence[2]]);
fixture.detectChanges();
const status = fixture.nativeElement.querySelector('.status');
expect(status.textContent.trim()).toBe('NOT_AFFECTED');
const justification = fixture.nativeElement.querySelector('.justification');
expect(justification.textContent).toContain('vulnerable code not present');
const justificationText = fixture.nativeElement.querySelector('.justification-text');
expect(justificationText.textContent).toContain('vulnerable code path is not used');
});
it('should display reachability evidence correctly', () => {
fixture.componentRef.setInput('evidence', [mockEvidence[3]]);
fixture.detectChanges();
const reachable = fixture.nativeElement.querySelector('.reachable');
expect(reachable.textContent).toContain('Not Reachable');
const confidence = fixture.nativeElement.querySelector('.confidence');
expect(confidence.textContent).toContain('95%');
});
it('should display reachable status with warning', () => {
const reachableEvidence: Evidence = {
type: 'reachability',
isReachable: true,
confidence: 0.85,
callPath: ['main', 'processData', 'vulnerableFunction'],
};
fixture.componentRef.setInput('evidence', [reachableEvidence]);
fixture.detectChanges();
const reachable = fixture.nativeElement.querySelector('.reachable');
expect(reachable.textContent).toContain('Reachable');
expect(reachable.classList.contains('is-reachable')).toBe(true);
const callPath = fixture.nativeElement.querySelector('.path-value');
expect(callPath.textContent).toContain('main → processData → vulnerableFunction');
});
it('should display policy rule evidence correctly', () => {
fixture.componentRef.setInput('evidence', [mockEvidence[4]]);
fixture.detectChanges();
const ruleId = fixture.nativeElement.querySelector('.rule-id');
expect(ruleId.textContent).toBe('no-critical-vulns');
const outcome = fixture.nativeElement.querySelector('.outcome');
expect(outcome.textContent.trim()).toBe('PASS');
const message = fixture.nativeElement.querySelector('.message');
expect(message.textContent).toContain('No critical vulnerabilities');
});
it('should get correct type labels', () => {
expect(component.getTypeLabel('advisory')).toBe('Advisory');
expect(component.getTypeLabel('sbom')).toBe('SBOM');
expect(component.getTypeLabel('vex')).toBe('VEX Statement');
expect(component.getTypeLabel('reachability')).toBe('Reachability Analysis');
expect(component.getTypeLabel('policy_rule')).toBe('Policy Rule');
});
it('should format justification correctly', () => {
expect(component.formatJustification('vulnerable_code_not_present'))
.toBe('vulnerable code not present');
expect(component.formatJustification('component_not_present'))
.toBe('component not present');
});
it('should track evidence items correctly', () => {
const item1: Evidence = { type: 'advisory', vulnerabilityId: 'CVE-1', source: 'nvd' };
const item2: Evidence = { type: 'vex', status: 'affected', justification: 'vulnerable_code_present' };
expect(component.trackEvidence(0, item1)).toBe('0-advisory');
expect(component.trackEvidence(1, item2)).toBe('1-vex');
});
});

View File

@@ -0,0 +1,302 @@
/**
* EvidenceChainViewerComponent for SPRINT_4000_0100_0001.
* Displays the chain of evidence for a verdict attestation.
*/
import { Component, input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Evidence, EvidenceType } from '../../../core/api/verdict.models';
@Component({
selector: 'app-evidence-chain-viewer',
standalone: true,
imports: [CommonModule],
template: `
<div class="evidence-chain">
<h4 class="chain-title">Evidence Chain ({{ evidence().length }} items)</h4>
@if (evidence().length === 0) {
<div class="empty-chain">No evidence items available.</div>
} @else {
<div class="chain-timeline">
@for (item of evidence(); track trackEvidence($index, item); let i = $index; let last = $last) {
<div class="chain-item" [class]="'type-' + item.type">
<div class="chain-connector" [class.last]="last">
<div class="connector-line"></div>
<div class="connector-dot" [class]="'dot-' + item.type"></div>
</div>
<div class="chain-content">
<div class="chain-header">
<span class="evidence-type">{{ getTypeLabel(item.type) }}</span>
<span class="evidence-index">#{{ i + 1 }}</span>
</div>
<div class="chain-body">
@switch (item.type) {
@case ('advisory') {
<div class="advisory-evidence">
<div class="vuln-id">{{ item.vulnerabilityId }}</div>
<div class="source">Source: {{ item.source }}</div>
@if (item.publishedAt) {
<div class="date">Published: {{ item.publishedAt | date:'medium' }}</div>
}
</div>
}
@case ('sbom') {
<div class="sbom-evidence">
<div class="format">{{ item.format }}</div>
<div class="digest">{{ item.digest }}</div>
@if (item.createdAt) {
<div class="date">Created: {{ item.createdAt | date:'medium' }}</div>
}
</div>
}
@case ('vex') {
<div class="vex-evidence">
<div class="status" [class]="'status-' + item.status">{{ item.status | uppercase }}</div>
<div class="justification">{{ formatJustification(item.justification) }}</div>
@if (item.justificationText) {
<div class="justification-text">{{ item.justificationText }}</div>
}
</div>
}
@case ('reachability') {
<div class="reachability-evidence">
<div class="reachable" [class.is-reachable]="item.isReachable">
{{ item.isReachable ? '⚠️ Reachable' : '✓ Not Reachable' }}
</div>
<div class="confidence">Confidence: {{ item.confidence | percent }}</div>
@if (item.callPath && item.callPath.length > 0) {
<div class="call-path">
<span class="path-label">Call Path:</span>
<code class="path-value">{{ item.callPath.join(' → ') }}</code>
</div>
}
</div>
}
@case ('policy_rule') {
<div class="policy-evidence">
<div class="rule-id">{{ item.ruleId }}</div>
<div class="outcome" [class]="'outcome-' + item.outcome">
{{ item.outcome | uppercase }}
</div>
@if (item.message) {
<div class="message">{{ item.message }}</div>
}
</div>
}
}
</div>
</div>
</div>
}
</div>
}
</div>
`,
styles: [`
.evidence-chain {
background: var(--panel-bg, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
padding: 1rem;
}
.chain-title {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: 600;
}
.empty-chain {
text-align: center;
padding: 2rem;
color: var(--text-muted, #666);
}
.chain-timeline {
display: flex;
flex-direction: column;
}
.chain-item {
display: flex;
gap: 1rem;
}
.chain-connector {
display: flex;
flex-direction: column;
align-items: center;
width: 24px;
}
.connector-line {
width: 2px;
flex: 1;
background: var(--border-color, #e0e0e0);
}
.chain-connector.last .connector-line {
background: transparent;
}
.connector-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--border-color, #e0e0e0);
flex-shrink: 0;
}
.dot-advisory { background: #dc2626; }
.dot-sbom { background: #2563eb; }
.dot-vex { background: #7c3aed; }
.dot-reachability { background: #ea580c; }
.dot-policy_rule { background: #16a34a; }
.chain-content {
flex: 1;
padding-bottom: 1rem;
}
.chain-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.evidence-type {
font-weight: 600;
font-size: 0.875rem;
}
.evidence-index {
font-size: 0.75rem;
color: var(--text-muted, #666);
}
.chain-body {
background: var(--bg-muted, #f9fafb);
border-radius: 6px;
padding: 0.75rem;
font-size: 0.875rem;
}
.vuln-id, .rule-id {
font-family: monospace;
font-weight: 600;
margin-bottom: 0.25rem;
}
.source, .format, .date {
color: var(--text-muted, #666);
font-size: 0.8125rem;
}
.digest {
font-family: monospace;
font-size: 0.75rem;
word-break: break-all;
color: var(--text-muted, #666);
}
.status {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
.status-affected { background: #fef2f2; color: #dc2626; }
.status-not_affected { background: #f0fdf4; color: #16a34a; }
.status-fixed { background: #eff6ff; color: #2563eb; }
.status-under_investigation { background: #fefce8; color: #ca8a04; }
.justification {
color: var(--text-muted, #666);
}
.justification-text {
margin-top: 0.25rem;
padding: 0.5rem;
background: white;
border-radius: 4px;
font-style: italic;
}
.reachable {
font-weight: 500;
}
.reachable.is-reachable { color: #dc2626; }
.reachable:not(.is-reachable) { color: #16a34a; }
.confidence {
color: var(--text-muted, #666);
margin-top: 0.25rem;
}
.call-path {
margin-top: 0.5rem;
}
.path-label {
font-size: 0.75rem;
color: var(--text-muted, #666);
}
.path-value {
display: block;
margin-top: 0.25rem;
padding: 0.5rem;
background: white;
border-radius: 4px;
font-size: 0.75rem;
overflow-x: auto;
}
.outcome {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
.outcome-pass { background: #f0fdf4; color: #16a34a; }
.outcome-fail { background: #fef2f2; color: #dc2626; }
.outcome-warn { background: #fefce8; color: #ca8a04; }
.outcome-skip { background: #f5f5f5; color: #666; }
.message {
color: var(--text-muted, #666);
font-size: 0.8125rem;
}
`],
})
export class EvidenceChainViewerComponent {
readonly evidence = input.required<readonly Evidence[]>();
trackEvidence(index: number, item: Evidence): string {
return `${index}-${item.type}`;
}
getTypeLabel(type: EvidenceType): string {
const labels: Record<EvidenceType, string> = {
advisory: 'Advisory',
sbom: 'SBOM',
vex: 'VEX Statement',
reachability: 'Reachability Analysis',
policy_rule: 'Policy Rule',
};
return labels[type] || type;
}
formatJustification(justification: string): string {
return justification.replace(/_/g, ' ');
}
}

View File

@@ -0,0 +1 @@
export * from './evidence-chain-viewer.component';

View File

@@ -0,0 +1 @@
export * from './verdict-proof-panel.component';

View File

@@ -0,0 +1,218 @@
/**
* Unit tests for VerdictProofPanelComponent.
* SPRINT_4000_0100_0001 - Proof Panels UI
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { VerdictProofPanelComponent } from './verdict-proof-panel.component';
import { VERDICT_API, VerdictApi } from '../../../core/api/verdict.client';
import {
VerdictAttestation,
VerifyVerdictResponse,
VerificationStatus,
} from '../../../core/api/verdict.models';
import { of, throwError } from 'rxjs';
describe('VerdictProofPanelComponent', () => {
let component: VerdictProofPanelComponent;
let fixture: ComponentFixture<VerdictProofPanelComponent>;
let mockApi: jasmine.SpyObj<VerdictApi>;
const mockVerdict: VerdictAttestation = {
verdictId: 'verdict-001',
policyId: 'policy-001',
policyName: 'Production Policy',
policyVersion: '1.0.0',
targetDigest: 'sha256:abc123...',
targetType: 'container_image',
outcome: 'pass',
createdAt: '2025-01-15T10:30:00Z',
expiresAt: '2025-01-22T10:30:00Z',
evidenceChain: [
{
type: 'advisory',
vulnerabilityId: 'CVE-2024-1234',
source: 'nvd',
publishedAt: '2024-06-15T00:00:00Z',
},
{
type: 'vex',
status: 'not_affected',
justification: 'vulnerable_code_not_present',
},
],
signatures: [
{
keyId: 'key-001',
algorithm: 'ecdsa-p256',
value: 'MEUCIQDf...',
},
],
attestationDigest: 'sha256:def456...',
};
const mockVerification: VerifyVerdictResponse = {
isValid: true,
signatures: [
{
status: 'verified',
keyId: 'key-001',
algorithm: 'ecdsa-p256',
issuer: 'StellaOps Authority',
verifiedAt: '2025-01-15T10:31:00Z',
},
],
verifiedAt: '2025-01-15T10:31:00Z',
};
beforeEach(async () => {
mockApi = jasmine.createSpyObj<VerdictApi>('VerdictApi', [
'getVerdict',
'verifyVerdict',
'downloadEnvelope',
]);
mockApi.getVerdict.and.returnValue(of(mockVerdict));
mockApi.verifyVerdict.and.returnValue(of(mockVerification));
mockApi.downloadEnvelope.and.returnValue(of(new Blob(['test'])));
await TestBed.configureTestingModule({
imports: [VerdictProofPanelComponent],
providers: [{ provide: VERDICT_API, useValue: mockApi }],
}).compileComponents();
fixture = TestBed.createComponent(VerdictProofPanelComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load verdict when verdictId is set', fakeAsync(() => {
fixture.componentRef.setInput('verdictId', 'verdict-001');
fixture.detectChanges();
tick();
expect(mockApi.getVerdict).toHaveBeenCalledWith('verdict-001');
expect(component.verdict()).toEqual(mockVerdict);
expect(component.loading()).toBe(false);
}));
it('should set error when verdict load fails', fakeAsync(() => {
mockApi.getVerdict.and.returnValue(throwError(() => new Error('Not found')));
fixture.componentRef.setInput('verdictId', 'invalid-id');
fixture.detectChanges();
tick();
expect(component.error()).toBe('Failed to load verdict attestation');
expect(component.loading()).toBe(false);
}));
it('should verify verdict when requested', fakeAsync(() => {
fixture.componentRef.setInput('verdictId', 'verdict-001');
fixture.detectChanges();
tick();
component.verifySignatures();
tick();
expect(mockApi.verifyVerdict).toHaveBeenCalledWith('verdict-001');
expect(component.verification()).toEqual(mockVerification);
expect(component.verifying()).toBe(false);
}));
it('should compute signature status correctly', fakeAsync(() => {
fixture.componentRef.setInput('verdictId', 'verdict-001');
fixture.detectChanges();
tick();
component.verifySignatures();
tick();
expect(component.signatureStatus()).toBe('verified');
}));
it('should format outcome as uppercase', fakeAsync(() => {
fixture.componentRef.setInput('verdictId', 'verdict-001');
fixture.detectChanges();
tick();
expect(component.outcomeLabel()).toBe('PASS');
}));
it('should render evidence chain items', fakeAsync(() => {
fixture.componentRef.setInput('verdictId', 'verdict-001');
fixture.detectChanges();
tick();
const evidence = component.verdict()?.evidenceChain;
expect(evidence?.length).toBe(2);
expect(evidence?.[0].type).toBe('advisory');
expect(evidence?.[1].type).toBe('vex');
}));
it('should handle download envelope', fakeAsync(() => {
spyOn(URL, 'createObjectURL').and.returnValue('blob:test');
spyOn(URL, 'revokeObjectURL');
fixture.componentRef.setInput('verdictId', 'verdict-001');
fixture.detectChanges();
tick();
component.downloadEnvelope();
tick();
expect(mockApi.downloadEnvelope).toHaveBeenCalledWith('verdict-001');
expect(component.downloading()).toBe(false);
}));
it('should toggle expanded state', () => {
expect(component.expanded()).toBe(false);
component.toggleExpanded();
expect(component.expanded()).toBe(true);
component.toggleExpanded();
expect(component.expanded()).toBe(false);
});
it('should display failed outcome correctly', fakeAsync(() => {
const failedVerdict: VerdictAttestation = {
...mockVerdict,
outcome: 'fail',
};
mockApi.getVerdict.and.returnValue(of(failedVerdict));
fixture.componentRef.setInput('verdictId', 'verdict-001');
fixture.detectChanges();
tick();
expect(component.outcomeLabel()).toBe('FAIL');
}));
it('should handle verification failure', fakeAsync(() => {
const failedVerification: VerifyVerdictResponse = {
isValid: false,
signatures: [
{
status: 'failed',
keyId: 'key-001',
message: 'Signature mismatch',
},
],
verifiedAt: '2025-01-15T10:31:00Z',
};
mockApi.verifyVerdict.and.returnValue(of(failedVerification));
fixture.componentRef.setInput('verdictId', 'verdict-001');
fixture.detectChanges();
tick();
component.verifySignatures();
tick();
expect(component.signatureStatus()).toBe('failed');
}));
});

View File

@@ -0,0 +1,557 @@
/**
* VerdictProofPanelComponent for SPRINT_4000_0100_0001.
* Main component for visualizing policy verdict proof chains.
*/
import { Component, computed, effect, inject, input, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { VERDICT_API, VerdictApi } from '../../../core/api/verdict.client';
import {
VerdictAttestation,
VerdictStatus,
VerdictSeverity,
VerifyVerdictResponse,
Evidence,
} from '../../../core/api/verdict.models';
@Component({
selector: 'app-verdict-proof-panel',
standalone: true,
imports: [CommonModule],
template: `
<div class="verdict-proof-panel" [class.loading]="loading()">
<!-- Loading State -->
@if (loading()) {
<div class="loading-overlay">
<div class="spinner"></div>
<span>Loading verdict...</span>
</div>
}
<!-- Error State -->
@if (error()) {
<div class="error-banner">
<span class="error-icon">⚠️</span>
<span>{{ error() }}</span>
<button class="retry-btn" (click)="loadVerdict()">Retry</button>
</div>
}
<!-- Verdict Content -->
@if (verdict(); as v) {
<header class="verdict-header">
<div class="verdict-status" [class]="statusClass()">
<span class="status-icon">{{ statusIcon() }}</span>
<span class="status-label">{{ v.verdictStatus | uppercase }}</span>
</div>
<div class="verdict-meta">
<span class="severity-badge" [class]="severityClass()">
{{ v.verdictSeverity }}
</span>
@if (v.verdictScore) {
<span class="score">Score: {{ v.verdictScore | number:'1.1-1' }}</span>
}
<span class="timestamp">{{ v.evaluatedAt | date:'medium' }}</span>
</div>
</header>
<!-- Signature Verification -->
<section class="attestation-section">
<h3>Attestation Verification</h3>
<div class="signature-status" [class]="signatureStatusClass()">
<span class="sig-icon">{{ signatureIcon() }}</span>
<span class="sig-label">{{ signatureLabel() }}</span>
@if (verification()?.rekorVerification; as rekor) {
<span class="rekor-badge" title="Rekor Log Index: {{ rekor.logIndex }}">
📜 Rekor #{{ rekor.logIndex }}
</span>
}
</div>
@if (verifying()) {
<span class="verifying-hint">Verifying signature...</span>
}
</section>
<!-- Evidence Chain -->
<section class="evidence-section">
<h3>Evidence Chain ({{ v.evidenceChain.length }} items)</h3>
<div class="evidence-chain">
@for (evidence of v.evidenceChain; track evidence.id; let i = $index) {
<div class="evidence-item" [class]="'evidence-' + evidence.type">
<div class="evidence-connector" [class.first]="i === 0" [class.last]="i === v.evidenceChain.length - 1">
<span class="connector-line"></span>
<span class="connector-dot"></span>
</div>
<div class="evidence-content">
<div class="evidence-header">
<span class="evidence-type-badge">{{ evidence.type | uppercase }}</span>
<span class="evidence-source">{{ evidence.source }}</span>
</div>
<div class="evidence-body">
@switch (evidence.type) {
@case ('advisory') {
<div class="advisory-evidence">
<strong>{{ getAdvisoryEvidence(evidence).cveId }}</strong>
<p>{{ getAdvisoryEvidence(evidence).description }}</p>
@if (getAdvisoryEvidence(evidence).cvssScore) {
<span class="cvss">CVSS: {{ getAdvisoryEvidence(evidence).cvssScore }}</span>
}
</div>
}
@case ('sbom') {
<div class="sbom-evidence">
<strong>{{ getSbomEvidence(evidence).packageName }}</strong>
<span class="version">v{{ getSbomEvidence(evidence).packageVersion }}</span>
</div>
}
@case ('vex') {
<div class="vex-evidence">
<span class="vex-status" [class]="'vex-' + getVexEvidence(evidence).status">
{{ getVexEvidence(evidence).status | uppercase }}
</span>
@if (getVexEvidence(evidence).justification) {
<span class="justification">{{ getVexEvidence(evidence).justification }}</span>
}
</div>
}
@case ('reachability') {
<div class="reachability-evidence">
<span class="reachable-badge" [class.reachable]="getReachabilityEvidence(evidence).isReachable">
{{ getReachabilityEvidence(evidence).isReachable ? '✓ Reachable' : '✗ Not Reachable' }}
</span>
<span class="confidence">
Confidence: {{ getReachabilityEvidence(evidence).confidence | percent }}
</span>
<span class="method">{{ getReachabilityEvidence(evidence).method }}</span>
</div>
}
@case ('policy_rule') {
<div class="policy-rule-evidence">
<strong>{{ getPolicyRuleEvidence(evidence).ruleName }}</strong>
<span class="rule-result" [class]="'result-' + getPolicyRuleEvidence(evidence).ruleResult">
{{ getPolicyRuleEvidence(evidence).ruleResult | uppercase }}
</span>
</div>
}
}
</div>
<div class="evidence-timestamp">{{ evidence.timestamp | date:'short' }}</div>
</div>
</div>
}
</div>
</section>
<!-- Export Actions -->
<footer class="panel-actions">
<button class="btn-secondary" (click)="downloadEnvelope()" [disabled]="downloading()">
{{ downloading() ? 'Downloading...' : '📥 Download DSSE Envelope' }}
</button>
<button class="btn-secondary" (click)="copyDeterminismHash()" [disabled]="!v.determinismHash">
📋 Copy Determinism Hash
</button>
</footer>
}
</div>
`,
styles: [`
.verdict-proof-panel {
background: var(--panel-bg, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
padding: 1.5rem;
position: relative;
}
.verdict-proof-panel.loading {
min-height: 300px;
}
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.9);
border-radius: 8px;
z-index: 10;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-color, #e0e0e0);
border-top-color: var(--primary-color, #0066cc);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 6px;
color: #dc2626;
}
.retry-btn {
margin-left: auto;
padding: 0.25rem 0.75rem;
background: #dc2626;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.verdict-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.verdict-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.25rem;
font-weight: 600;
}
.verdict-status.status-pass { color: #16a34a; }
.verdict-status.status-fail { color: #dc2626; }
.verdict-status.status-warn { color: #d97706; }
.verdict-status.status-error { color: #9333ea; }
.verdict-meta {
display: flex;
align-items: center;
gap: 1rem;
}
.severity-badge {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.severity-critical { background: #fef2f2; color: #dc2626; }
.severity-high { background: #fff7ed; color: #ea580c; }
.severity-medium { background: #fefce8; color: #ca8a04; }
.severity-low { background: #f0fdf4; color: #16a34a; }
.severity-info { background: #eff6ff; color: #2563eb; }
.attestation-section, .evidence-section {
margin-bottom: 1.5rem;
}
h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.75rem;
color: var(--text-secondary, #666);
}
.signature-status {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: var(--bg-muted, #f5f5f5);
border-radius: 6px;
}
.signature-status.sig-verified { background: #f0fdf4; color: #16a34a; }
.signature-status.sig-invalid { background: #fef2f2; color: #dc2626; }
.signature-status.sig-pending { background: #fefce8; color: #ca8a04; }
.rekor-badge {
margin-left: auto;
padding: 0.25rem 0.5rem;
background: #eff6ff;
color: #2563eb;
border-radius: 4px;
font-size: 0.75rem;
}
.evidence-chain {
display: flex;
flex-direction: column;
}
.evidence-item {
display: flex;
gap: 1rem;
}
.evidence-connector {
display: flex;
flex-direction: column;
align-items: center;
width: 20px;
}
.connector-line {
flex: 1;
width: 2px;
background: var(--border-color, #e0e0e0);
}
.connector-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--primary-color, #0066cc);
border: 2px solid white;
box-shadow: 0 0 0 2px var(--primary-color, #0066cc);
}
.evidence-connector.first .connector-line:first-child { visibility: hidden; }
.evidence-connector.last .connector-line:last-child { visibility: hidden; }
.evidence-content {
flex: 1;
padding: 0.75rem;
margin-bottom: 0.5rem;
background: var(--bg-muted, #f9f9f9);
border-radius: 6px;
border-left: 3px solid var(--primary-color, #0066cc);
}
.evidence-advisory .evidence-content { border-left-color: #dc2626; }
.evidence-sbom .evidence-content { border-left-color: #2563eb; }
.evidence-vex .evidence-content { border-left-color: #7c3aed; }
.evidence-reachability .evidence-content { border-left-color: #059669; }
.evidence-policy_rule .evidence-content { border-left-color: #d97706; }
.evidence-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.evidence-type-badge {
font-size: 0.625rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
background: var(--bg-secondary, #e5e5e5);
border-radius: 4px;
}
.evidence-source {
font-size: 0.75rem;
color: var(--text-muted, #888);
}
.evidence-timestamp {
font-size: 0.75rem;
color: var(--text-muted, #888);
margin-top: 0.5rem;
}
.reachable-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-weight: 500;
}
.reachable-badge.reachable { background: #fef2f2; color: #dc2626; }
.reachable-badge:not(.reachable) { background: #f0fdf4; color: #16a34a; }
.vex-status {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-weight: 500;
}
.vex-affected { background: #fef2f2; color: #dc2626; }
.vex-not_affected { background: #f0fdf4; color: #16a34a; }
.vex-fixed { background: #eff6ff; color: #2563eb; }
.rule-result {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-weight: 500;
}
.result-pass { background: #f0fdf4; color: #16a34a; }
.result-fail { background: #fef2f2; color: #dc2626; }
.result-skip { background: #f5f5f5; color: #666; }
.panel-actions {
display: flex;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.btn-secondary {
padding: 0.5rem 1rem;
background: var(--bg-muted, #f5f5f5);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-hover, #e5e5e5);
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`],
})
export class VerdictProofPanelComponent {
private readonly verdictApi = inject(VERDICT_API);
// Inputs
readonly verdictId = input.required<string>();
// State signals
readonly verdict = signal<VerdictAttestation | null>(null);
readonly verification = signal<VerifyVerdictResponse | null>(null);
readonly loading = signal(false);
readonly verifying = signal(false);
readonly downloading = signal(false);
readonly error = signal<string | null>(null);
// Computed values
readonly statusClass = computed(() => {
const v = this.verdict();
return v ? `status-${v.verdictStatus}` : '';
});
readonly statusIcon = computed(() => {
const status = this.verdict()?.verdictStatus;
switch (status) {
case 'pass': return '✓';
case 'fail': return '✗';
case 'warn': return '⚠';
case 'error': return '⛔';
default: return '?';
}
});
readonly severityClass = computed(() => {
const v = this.verdict();
return v ? `severity-${v.verdictSeverity}` : '';
});
readonly signatureStatusClass = computed(() => {
const v = this.verification();
if (!v) return 'sig-pending';
return v.signatureValid ? 'sig-verified' : 'sig-invalid';
});
readonly signatureIcon = computed(() => {
const v = this.verification();
if (!v) return '⏳';
return v.signatureValid ? '✓' : '✗';
});
readonly signatureLabel = computed(() => {
const v = this.verification();
if (!v) return 'Verification pending';
return v.signatureValid ? 'Signature verified' : 'Signature invalid';
});
constructor() {
// Load verdict when verdictId changes
effect(() => {
const id = this.verdictId();
if (id) {
this.loadVerdict();
}
});
}
loadVerdict(): void {
const id = this.verdictId();
if (!id) return;
this.loading.set(true);
this.error.set(null);
this.verdictApi.getVerdict(id).subscribe({
next: (v) => {
this.verdict.set(v);
this.loading.set(false);
this.verifySignature();
},
error: (err) => {
this.error.set(err.message || 'Failed to load verdict');
this.loading.set(false);
},
});
}
private verifySignature(): void {
const id = this.verdictId();
if (!id) return;
this.verifying.set(true);
this.verdictApi.verifyVerdict(id).subscribe({
next: (v) => {
this.verification.set(v);
this.verifying.set(false);
},
error: () => {
this.verifying.set(false);
},
});
}
downloadEnvelope(): void {
const id = this.verdictId();
if (!id) return;
this.downloading.set(true);
this.verdictApi.downloadEnvelope(id).subscribe({
next: (blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `verdict-${id}-envelope.json`;
a.click();
URL.revokeObjectURL(url);
this.downloading.set(false);
},
error: () => {
this.downloading.set(false);
},
});
}
copyDeterminismHash(): void {
const hash = this.verdict()?.determinismHash;
if (hash) {
navigator.clipboard.writeText(hash);
}
}
// Type guard helpers for template
getAdvisoryEvidence(e: Evidence) { return e as any; }
getSbomEvidence(e: Evidence) { return e as any; }
getVexEvidence(e: Evidence) { return e as any; }
getReachabilityEvidence(e: Evidence) { return e as any; }
getPolicyRuleEvidence(e: Evidence) { return e as any; }
}

View File

@@ -0,0 +1 @@
export * from './vuln-triage-dashboard.component';

View File

@@ -0,0 +1,353 @@
/**
* Unit tests for VulnTriageDashboardComponent.
* SPRINT_4000_0100_0002 - Vulnerability Annotation UI
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { VulnTriageDashboardComponent } from './vuln-triage-dashboard.component';
import { VULN_ANNOTATION_API, VulnAnnotationApi } from '../../../core/api/vuln-annotation.client';
import {
VulnFinding,
VexCandidate,
TriageSummary,
PagedResult,
StateTransitionResponse,
VexCandidateApprovalResponse,
VexCandidateRejectionResponse,
} from '../../../core/api/vuln-annotation.models';
import { of, throwError } from 'rxjs';
describe('VulnTriageDashboardComponent', () => {
let component: VulnTriageDashboardComponent;
let fixture: ComponentFixture<VulnTriageDashboardComponent>;
let mockApi: jasmine.SpyObj<VulnAnnotationApi>;
const mockFindings: readonly VulnFinding[] = [
{
findingId: 'finding-001',
vulnerabilityId: 'CVE-2024-1234',
packageName: 'lodash',
packageVersion: '4.17.20',
severity: 'critical',
state: 'open',
cvssScore: 9.8,
epssScore: 0.45,
isReachable: true,
reachabilityConfidence: 0.9,
firstSeenAt: '2025-01-10T00:00:00Z',
lastSeenAt: '2025-01-15T00:00:00Z',
},
{
findingId: 'finding-002',
vulnerabilityId: 'CVE-2024-5678',
packageName: 'express',
packageVersion: '4.18.0',
severity: 'high',
state: 'in_review',
cvssScore: 7.5,
firstSeenAt: '2025-01-12T00:00:00Z',
lastSeenAt: '2025-01-15T00:00:00Z',
},
];
const mockCandidates: readonly VexCandidate[] = [
{
candidateId: 'candidate-001',
findingId: 'finding-003',
vulnerabilityId: 'CVE-2024-9999',
suggestedStatus: 'not_affected',
suggestedJustification: 'vulnerable_code_not_present',
justificationText: 'The vulnerable code path is not present in our build.',
confidence: 0.85,
source: 'reachability-analysis',
status: 'pending',
createdAt: '2025-01-14T00:00:00Z',
},
];
const mockSummary: TriageSummary = {
totalFindings: 50,
byState: {
open: 20,
in_review: 10,
mitigated: 15,
closed: 3,
false_positive: 2,
},
bySeverity: {
critical: 5,
high: 15,
medium: 20,
low: 10,
},
pendingCandidates: 3,
};
beforeEach(async () => {
mockApi = jasmine.createSpyObj<VulnAnnotationApi>('VulnAnnotationApi', [
'listFindings',
'getTriageSummary',
'transitionState',
'listCandidates',
'approveCandidate',
'rejectCandidate',
]);
mockApi.listFindings.and.returnValue(of({
items: mockFindings,
totalCount: mockFindings.length,
pageIndex: 0,
pageSize: 20,
hasNextPage: false,
hasPreviousPage: false,
}));
mockApi.getTriageSummary.and.returnValue(of(mockSummary));
mockApi.listCandidates.and.returnValue(of({
items: mockCandidates,
totalCount: mockCandidates.length,
pageIndex: 0,
pageSize: 20,
hasNextPage: false,
hasPreviousPage: false,
}));
mockApi.transitionState.and.returnValue(of({
findingId: 'finding-001',
previousState: 'open',
newState: 'in_review',
transitionedAt: '2025-01-15T12:00:00Z',
}));
mockApi.approveCandidate.and.returnValue(of({
candidateId: 'candidate-001',
status: 'approved',
approvedAt: '2025-01-15T12:00:00Z',
}));
mockApi.rejectCandidate.and.returnValue(of({
candidateId: 'candidate-001',
status: 'rejected',
rejectedAt: '2025-01-15T12:00:00Z',
reason: 'Not accurate',
}));
await TestBed.configureTestingModule({
imports: [VulnTriageDashboardComponent, FormsModule],
providers: [{ provide: VULN_ANNOTATION_API, useValue: mockApi }],
}).compileComponents();
fixture = TestBed.createComponent(VulnTriageDashboardComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load data on init', fakeAsync(() => {
fixture.detectChanges();
tick();
expect(mockApi.getTriageSummary).toHaveBeenCalled();
expect(mockApi.listFindings).toHaveBeenCalled();
expect(mockApi.listCandidates).toHaveBeenCalled();
expect(component.summary()).toEqual(mockSummary);
expect(component.findings().length).toBe(2);
expect(component.candidates().length).toBe(1);
}));
it('should display summary cards', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
const summaryCards = fixture.nativeElement.querySelectorAll('.summary-card');
expect(summaryCards.length).toBe(5);
}));
it('should switch tabs', fakeAsync(() => {
fixture.detectChanges();
tick();
expect(component.activeTab()).toBe('findings');
component.setActiveTab('candidates');
expect(component.activeTab()).toBe('candidates');
component.setActiveTab('findings');
expect(component.activeTab()).toBe('findings');
}));
it('should filter findings by state', fakeAsync(() => {
fixture.detectChanges();
tick();
component.stateFilter = 'open';
component.loadFindings();
tick();
expect(mockApi.listFindings).toHaveBeenCalledWith({
state: 'open',
severity: undefined,
});
}));
it('should filter findings by severity', fakeAsync(() => {
fixture.detectChanges();
tick();
component.severityFilter = 'critical';
component.loadFindings();
tick();
expect(mockApi.listFindings).toHaveBeenCalledWith({
state: undefined,
severity: 'critical',
});
}));
it('should open state transition modal', fakeAsync(() => {
fixture.detectChanges();
tick();
const finding = mockFindings[0];
component.openStateTransition(finding);
expect(component.selectedFinding()).toEqual(finding);
}));
it('should close modal', fakeAsync(() => {
fixture.detectChanges();
tick();
component.openStateTransition(mockFindings[0]);
expect(component.selectedFinding()).toBeTruthy();
component.closeModal();
expect(component.selectedFinding()).toBeNull();
}));
it('should submit state transition', fakeAsync(() => {
fixture.detectChanges();
tick();
component.openStateTransition(mockFindings[0]);
component.transitionTargetState = 'in_review';
component.transitionJustification = 'Starting review';
component.transitionNotes = 'Assigned to security team';
component.submitStateTransition();
tick();
expect(mockApi.transitionState).toHaveBeenCalledWith('finding-001', {
targetState: 'in_review',
justification: 'Starting review',
notes: 'Assigned to security team',
});
expect(component.selectedFinding()).toBeNull();
}));
it('should approve VEX candidate', fakeAsync(() => {
fixture.detectChanges();
tick();
const candidate = mockCandidates[0];
component.approveCandidate(candidate);
tick();
expect(mockApi.approveCandidate).toHaveBeenCalledWith('candidate-001', {
status: 'not_affected',
justification: 'vulnerable_code_not_present',
justificationText: 'The vulnerable code path is not present in our build.',
});
}));
it('should reject VEX candidate', fakeAsync(() => {
fixture.detectChanges();
tick();
const candidate = mockCandidates[0];
component.rejectCandidate(candidate);
tick();
expect(mockApi.rejectCandidate).toHaveBeenCalledWith('candidate-001', {
reason: 'Rejected by triage review',
});
}));
it('should format justification correctly', () => {
expect(component.formatJustification('vulnerable_code_not_present'))
.toBe('vulnerable code not present');
expect(component.formatJustification('component_not_present'))
.toBe('component not present');
});
it('should handle loading state', fakeAsync(() => {
fixture.detectChanges();
expect(component.loading()).toBe(true);
tick();
expect(component.loading()).toBe(false);
}));
it('should display findings list', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
const findingCards = fixture.nativeElement.querySelectorAll('.finding-card');
expect(findingCards.length).toBe(2);
}));
it('should display candidates list when tab is active', fakeAsync(() => {
fixture.detectChanges();
tick();
component.setActiveTab('candidates');
fixture.detectChanges();
const candidateCards = fixture.nativeElement.querySelectorAll('.candidate-card');
expect(candidateCards.length).toBe(1);
}));
it('should display empty state when no findings', fakeAsync(() => {
mockApi.listFindings.and.returnValue(of({
items: [],
totalCount: 0,
pageIndex: 0,
pageSize: 20,
hasNextPage: false,
hasPreviousPage: false,
}));
fixture.detectChanges();
tick();
fixture.detectChanges();
const emptyState = fixture.nativeElement.querySelector('.empty-state');
expect(emptyState).toBeTruthy();
expect(emptyState.textContent).toContain('No findings match');
}));
it('should reload summary after state transition', fakeAsync(() => {
fixture.detectChanges();
tick();
// Reset the call count
mockApi.getTriageSummary.calls.reset();
component.openStateTransition(mockFindings[0]);
component.transitionTargetState = 'in_review';
component.submitStateTransition();
tick();
expect(mockApi.getTriageSummary).toHaveBeenCalled();
}));
});

View File

@@ -0,0 +1,646 @@
/**
* VulnTriageDashboardComponent for SPRINT_4000_0100_0002.
* Main dashboard for vulnerability triage and VEX candidate management.
*/
import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { VULN_ANNOTATION_API, VulnAnnotationApi } from '../../../core/api/vuln-annotation.client';
import {
VulnFinding,
VulnState,
VexCandidate,
TriageSummary,
StateTransitionRequest,
VexCandidateApprovalRequest,
VexCandidateRejectionRequest,
} from '../../../core/api/vuln-annotation.models';
type TabView = 'findings' | 'candidates';
@Component({
selector: 'app-vuln-triage-dashboard',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="triage-dashboard">
<!-- Summary Cards -->
@if (summary(); as s) {
<div class="summary-cards">
<div class="summary-card">
<span class="card-value">{{ s.totalFindings }}</span>
<span class="card-label">Total Findings</span>
</div>
<div class="summary-card critical">
<span class="card-value">{{ s.bySeverity['critical'] || 0 }}</span>
<span class="card-label">Critical</span>
</div>
<div class="summary-card high">
<span class="card-value">{{ s.bySeverity['high'] || 0 }}</span>
<span class="card-label">High</span>
</div>
<div class="summary-card open">
<span class="card-value">{{ s.byState['open'] || 0 }}</span>
<span class="card-label">Open</span>
</div>
<div class="summary-card pending">
<span class="card-value">{{ s.pendingCandidates }}</span>
<span class="card-label">VEX Candidates</span>
</div>
</div>
}
<!-- Tab Navigation -->
<div class="tab-nav">
<button
class="tab-btn"
[class.active]="activeTab() === 'findings'"
(click)="setActiveTab('findings')">
Findings ({{ findings().length }})
</button>
<button
class="tab-btn"
[class.active]="activeTab() === 'candidates'"
(click)="setActiveTab('candidates')">
VEX Candidates ({{ candidates().length }})
</button>
</div>
<!-- Filters -->
<div class="filters">
@if (activeTab() === 'findings') {
<select [(ngModel)]="stateFilter" (ngModelChange)="loadFindings()">
<option value="">All States</option>
<option value="open">Open</option>
<option value="in_review">In Review</option>
<option value="mitigated">Mitigated</option>
<option value="closed">Closed</option>
<option value="false_positive">False Positive</option>
<option value="deferred">Deferred</option>
</select>
<select [(ngModel)]="severityFilter" (ngModelChange)="loadFindings()">
<option value="">All Severities</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
}
@if (activeTab() === 'candidates') {
<select [(ngModel)]="candidateStatusFilter" (ngModelChange)="loadCandidates()">
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
}
</div>
<!-- Content -->
@if (loading()) {
<div class="loading">Loading...</div>
} @else {
@if (activeTab() === 'findings') {
<div class="findings-list">
@for (finding of findings(); track finding.findingId) {
<div class="finding-card" [class]="'severity-' + finding.severity">
<div class="finding-header">
<span class="vuln-id">{{ finding.vulnerabilityId }}</span>
<span class="severity-badge" [class]="finding.severity">{{ finding.severity | uppercase }}</span>
<span class="state-badge" [class]="'state-' + finding.state">{{ finding.state | uppercase }}</span>
</div>
<div class="finding-body">
<div class="package-info">
<strong>{{ finding.packageName }}</strong>
<span class="version">v{{ finding.packageVersion }}</span>
</div>
<div class="scores">
@if (finding.cvssScore) {
<span class="score">CVSS: {{ finding.cvssScore | number:'1.1-1' }}</span>
}
@if (finding.epssScore) {
<span class="score">EPSS: {{ finding.epssScore | percent }}</span>
}
</div>
@if (finding.isReachable !== undefined) {
<div class="reachability" [class.reachable]="finding.isReachable">
{{ finding.isReachable ? '⚠️ Reachable' : '✓ Not Reachable' }}
@if (finding.reachabilityConfidence) {
({{ finding.reachabilityConfidence | percent }})
}
</div>
}
</div>
<div class="finding-actions">
<button class="btn-sm" (click)="openStateTransition(finding)">Change State</button>
<button class="btn-sm" (click)="viewDetails(finding)">Details</button>
</div>
</div>
} @empty {
<div class="empty-state">No findings match the current filters.</div>
}
</div>
}
@if (activeTab() === 'candidates') {
<div class="candidates-list">
@for (candidate of candidates(); track candidate.candidateId) {
<div class="candidate-card" [class]="'status-' + candidate.status">
<div class="candidate-header">
<span class="vuln-id">{{ candidate.vulnerabilityId }}</span>
<span class="status-badge" [class]="candidate.status">{{ candidate.status | uppercase }}</span>
<span class="confidence">Confidence: {{ candidate.confidence | percent }}</span>
</div>
<div class="candidate-body">
<div class="suggestion">
<span class="suggested-status" [class]="'vex-' + candidate.suggestedStatus">
{{ candidate.suggestedStatus | uppercase }}
</span>
<span class="justification">{{ formatJustification(candidate.suggestedJustification) }}</span>
</div>
@if (candidate.justificationText) {
<p class="justification-text">{{ candidate.justificationText }}</p>
}
<div class="source">Source: {{ candidate.source }}</div>
</div>
@if (candidate.status === 'pending') {
<div class="candidate-actions">
<button class="btn-approve" (click)="approveCandidate(candidate)">✓ Approve</button>
<button class="btn-reject" (click)="rejectCandidate(candidate)">✗ Reject</button>
</div>
}
</div>
} @empty {
<div class="empty-state">No VEX candidates match the current filters.</div>
}
</div>
}
}
<!-- State Transition Modal -->
@if (selectedFinding()) {
<div class="modal-overlay" (click)="closeModal()">
<div class="modal-content" (click)="$event.stopPropagation()">
<h3>Change State: {{ selectedFinding()!.vulnerabilityId }}</h3>
<div class="form-group">
<label>Target State</label>
<select [(ngModel)]="transitionTargetState">
<option value="open">Open</option>
<option value="in_review">In Review</option>
<option value="mitigated">Mitigated</option>
<option value="closed">Closed</option>
<option value="false_positive">False Positive</option>
<option value="deferred">Deferred</option>
</select>
</div>
<div class="form-group">
<label>Justification</label>
<textarea [(ngModel)]="transitionJustification" rows="3"></textarea>
</div>
<div class="form-group">
<label>Notes</label>
<textarea [(ngModel)]="transitionNotes" rows="2"></textarea>
</div>
<div class="modal-actions">
<button class="btn-cancel" (click)="closeModal()">Cancel</button>
<button class="btn-submit" (click)="submitStateTransition()" [disabled]="transitioning()">
{{ transitioning() ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</div>
}
</div>
`,
styles: [`
.triage-dashboard {
padding: 1.5rem;
}
.summary-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.summary-card {
background: var(--panel-bg, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
padding: 1rem;
text-align: center;
}
.summary-card.critical { border-left: 4px solid #dc2626; }
.summary-card.high { border-left: 4px solid #ea580c; }
.summary-card.open { border-left: 4px solid #2563eb; }
.summary-card.pending { border-left: 4px solid #7c3aed; }
.card-value {
display: block;
font-size: 2rem;
font-weight: 700;
}
.card-label {
font-size: 0.875rem;
color: var(--text-muted, #666);
}
.tab-nav {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.tab-btn {
padding: 0.75rem 1.5rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 1rem;
}
.tab-btn.active {
border-bottom-color: var(--primary-color, #0066cc);
font-weight: 600;
}
.filters {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.filters select {
padding: 0.5rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
}
.loading {
text-align: center;
padding: 2rem;
color: var(--text-muted, #666);
}
.findings-list, .candidates-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.finding-card, .candidate-card {
background: var(--panel-bg, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
padding: 1rem;
}
.finding-card.severity-critical { border-left: 4px solid #dc2626; }
.finding-card.severity-high { border-left: 4px solid #ea580c; }
.finding-card.severity-medium { border-left: 4px solid #ca8a04; }
.finding-card.severity-low { border-left: 4px solid #16a34a; }
.finding-header, .candidate-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.vuln-id {
font-weight: 600;
font-family: monospace;
}
.severity-badge, .state-badge, .status-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.severity-badge.critical { background: #fef2f2; color: #dc2626; }
.severity-badge.high { background: #fff7ed; color: #ea580c; }
.severity-badge.medium { background: #fefce8; color: #ca8a04; }
.severity-badge.low { background: #f0fdf4; color: #16a34a; }
.state-badge.state-open { background: #eff6ff; color: #2563eb; }
.state-badge.state-in_review { background: #fefce8; color: #ca8a04; }
.state-badge.state-mitigated { background: #f0fdf4; color: #16a34a; }
.state-badge.state-closed { background: #f5f5f5; color: #666; }
.state-badge.state-false_positive { background: #faf5ff; color: #7c3aed; }
.state-badge.state-deferred { background: #fff7ed; color: #ea580c; }
.status-badge.pending { background: #fefce8; color: #ca8a04; }
.status-badge.approved { background: #f0fdf4; color: #16a34a; }
.status-badge.rejected { background: #fef2f2; color: #dc2626; }
.confidence {
margin-left: auto;
font-size: 0.875rem;
color: var(--text-muted, #666);
}
.finding-body, .candidate-body {
margin-bottom: 0.75rem;
}
.package-info {
margin-bottom: 0.5rem;
}
.version {
margin-left: 0.5rem;
color: var(--text-muted, #666);
}
.scores {
display: flex;
gap: 1rem;
font-size: 0.875rem;
}
.reachability {
margin-top: 0.5rem;
font-size: 0.875rem;
}
.reachability.reachable { color: #dc2626; }
.reachability:not(.reachable) { color: #16a34a; }
.suggested-status {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.vex-affected { background: #fef2f2; color: #dc2626; }
.vex-not_affected { background: #f0fdf4; color: #16a34a; }
.vex-fixed { background: #eff6ff; color: #2563eb; }
.justification {
margin-left: 0.5rem;
font-size: 0.875rem;
color: var(--text-muted, #666);
}
.justification-text {
margin: 0.5rem 0;
padding: 0.5rem;
background: var(--bg-muted, #f5f5f5);
border-radius: 4px;
font-size: 0.875rem;
}
.source {
font-size: 0.75rem;
color: var(--text-muted, #666);
}
.finding-actions, .candidate-actions {
display: flex;
gap: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.btn-sm {
padding: 0.375rem 0.75rem;
background: var(--bg-muted, #f5f5f5);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.btn-approve {
padding: 0.375rem 0.75rem;
background: #16a34a;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-reject {
padding: 0.375rem 0.75rem;
background: #dc2626;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-muted, #666);
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--panel-bg, #fff);
border-radius: 8px;
padding: 1.5rem;
width: 100%;
max-width: 500px;
}
.modal-content h3 {
margin: 0 0 1rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
}
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.btn-cancel {
padding: 0.5rem 1rem;
background: var(--bg-muted, #f5f5f5);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
cursor: pointer;
}
.btn-submit {
padding: 0.5rem 1rem;
background: var(--primary-color, #0066cc);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`],
})
export class VulnTriageDashboardComponent implements OnInit {
private readonly api = inject(VULN_ANNOTATION_API);
// State signals
readonly activeTab = signal<TabView>('findings');
readonly findings = signal<readonly VulnFinding[]>([]);
readonly candidates = signal<readonly VexCandidate[]>([]);
readonly summary = signal<TriageSummary | null>(null);
readonly loading = signal(false);
readonly selectedFinding = signal<VulnFinding | null>(null);
readonly transitioning = signal(false);
// Filters
stateFilter = '';
severityFilter = '';
candidateStatusFilter = '';
// Form state
transitionTargetState: VulnState = 'in_review';
transitionJustification = '';
transitionNotes = '';
ngOnInit(): void {
this.loadSummary();
this.loadFindings();
this.loadCandidates();
}
setActiveTab(tab: TabView): void {
this.activeTab.set(tab);
}
loadSummary(): void {
this.api.getTriageSummary().subscribe({
next: (s) => this.summary.set(s),
});
}
loadFindings(): void {
this.loading.set(true);
this.api.listFindings({
state: this.stateFilter as VulnState || undefined,
severity: this.severityFilter || undefined,
}).subscribe({
next: (res) => {
this.findings.set(res.items);
this.loading.set(false);
},
error: () => this.loading.set(false),
});
}
loadCandidates(): void {
this.api.listCandidates({
status: this.candidateStatusFilter as any || undefined,
}).subscribe({
next: (res) => this.candidates.set(res.items),
});
}
openStateTransition(finding: VulnFinding): void {
this.selectedFinding.set(finding);
this.transitionTargetState = finding.state === 'open' ? 'in_review' : 'open';
this.transitionJustification = '';
this.transitionNotes = '';
}
closeModal(): void {
this.selectedFinding.set(null);
}
submitStateTransition(): void {
const finding = this.selectedFinding();
if (!finding) return;
this.transitioning.set(true);
const request: StateTransitionRequest = {
targetState: this.transitionTargetState,
justification: this.transitionJustification || undefined,
notes: this.transitionNotes || undefined,
};
this.api.transitionState(finding.findingId, request).subscribe({
next: () => {
this.transitioning.set(false);
this.closeModal();
this.loadFindings();
this.loadSummary();
},
error: () => this.transitioning.set(false),
});
}
viewDetails(finding: VulnFinding): void {
// TODO: Navigate to finding detail view
console.log('View details for:', finding.findingId);
}
approveCandidate(candidate: VexCandidate): void {
const request: VexCandidateApprovalRequest = {
status: candidate.suggestedStatus,
justification: candidate.suggestedJustification,
justificationText: candidate.justificationText,
};
this.api.approveCandidate(candidate.candidateId, request).subscribe({
next: () => {
this.loadCandidates();
this.loadSummary();
},
});
}
rejectCandidate(candidate: VexCandidate): void {
const request: VexCandidateRejectionRequest = {
reason: 'Rejected by triage review',
};
this.api.rejectCandidate(candidate.candidateId, request).subscribe({
next: () => {
this.loadCandidates();
this.loadSummary();
},
});
}
formatJustification(justification: string): string {
return justification.replace(/_/g, ' ');
}
}