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:
267
src/Web/StellaOps.Web/src/app/core/api/verdict.client.ts
Normal file
267
src/Web/StellaOps.Web/src/app/core/api/verdict.client.ts
Normal 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'));
|
||||
}
|
||||
}
|
||||
245
src/Web/StellaOps.Web/src/app/core/api/verdict.models.ts
Normal file
245
src/Web/StellaOps.Web/src/app/core/api/verdict.models.ts
Normal 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;
|
||||
}
|
||||
382
src/Web/StellaOps.Web/src/app/core/api/vuln-annotation.client.ts
Normal file
382
src/Web/StellaOps.Web/src/app/core/api/vuln-annotation.client.ts
Normal 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'));
|
||||
}
|
||||
}
|
||||
209
src/Web/StellaOps.Web/src/app/core/api/vuln-annotation.models.ts
Normal file
209
src/Web/StellaOps.Web/src/app/core/api/vuln-annotation.models.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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()];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './attestation-badge.component';
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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, ' ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './evidence-chain-viewer.component';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './verdict-proof-panel.component';
|
||||
@@ -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');
|
||||
}));
|
||||
});
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './vuln-triage-dashboard.component';
|
||||
@@ -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();
|
||||
}));
|
||||
});
|
||||
@@ -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, ' ');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user