Add Canonical JSON serialization library with tests and documentation

- Implemented CanonJson class for deterministic JSON serialization and hashing.
- Added unit tests for CanonJson functionality, covering various scenarios including key sorting, handling of nested objects, arrays, and special characters.
- Created project files for the Canonical JSON library and its tests, including necessary package references.
- Added README.md for library usage and API reference.
- Introduced RabbitMqIntegrationFactAttribute for conditional RabbitMQ integration tests.
This commit is contained in:
master
2025-12-19 15:35:00 +02:00
parent 43882078a4
commit 951a38d561
192 changed files with 27550 additions and 2611 deletions

View File

@@ -18,6 +18,45 @@ Design and build the StellaOps web user experience that surfaces backend capabil
- `docs/` — UX specs and mockups (to be added).
- `ops/` — Web deployment manifests for air-gapped environments (future).
## Reachability Drift UI (Sprint 3600)
### Components
- **PathViewerComponent** (`app/features/reachability/components/path-viewer/`) - Interactive call path visualization
- Displays entrypoint → key nodes → sink paths
- Highlights changed nodes with change kind indicators
- Supports collapse/expand for long paths
- **RiskDriftCardComponent** (`app/features/reachability/components/risk-drift-card/`) - Summary card for drift analysis
- Shows newly reachable / mitigated path counts
- Displays associated CVEs
- Action buttons for drill-down
### Models
- `PathNode` - Node in a reachability path with symbol, file, line
- `CompressedPath` - Compact path representation
- `DriftedSink` - Sink with reachability change and cause
- `DriftCause` - Explanation of why reachability changed
### Services
- `DriftApiService` (`app/core/services/drift-api.service.ts`) - API client for drift endpoints
- Mock implementations available for offline development
### Integration Points
- Scan detail page includes PathViewer for reachability visualization
- Drift results linked to DSSE attestations for evidence chain
- Path export supports JSON and SARIF formats
## Witness UI (Sprint 3700) - TODO
### Planned Components
- **WitnessModalComponent** - Modal for viewing witness details
- **PathVisualizationComponent** - Detailed path rendering with gates
- **ConfidenceTierBadgeComponent** - Tier indicators (Confirmed/Likely/Present/Unreachable)
- **GateBadgeComponent** - Auth gate visualization
### Planned Services
- `witness.service.ts` - API client for witness endpoints
- Browser-based Ed25519 signature verification
## Coordination
- Sync with DevEx for project scaffolding and build pipelines.
- Partner with Docs Guild to translate UX decisions into operator guides.

View File

@@ -0,0 +1,288 @@
/**
* Witness API client service.
* Sprint: SPRINT_3700_0005_0001_witness_ui_cli (UI-005)
*/
import { Injectable, InjectionToken, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, of, delay, map } from 'rxjs';
import {
ReachabilityWitness,
WitnessListResponse,
WitnessVerificationResult,
StateFlipSummary,
ConfidenceTier,
PathNode,
GateInfo,
CallPathNode,
} from './witness.models';
export interface WitnessApi {
/**
* Get a witness by ID.
*/
getWitness(witnessId: string): Observable<ReachabilityWitness>;
/**
* List witnesses for a scan.
*/
listWitnesses(
scanId: string,
options?: { page?: number; pageSize?: number; tier?: ConfidenceTier }
): Observable<WitnessListResponse>;
/**
* Verify a witness signature.
*/
verifyWitness(witnessId: string): Observable<WitnessVerificationResult>;
/**
* Get witnesses for a specific vulnerability.
*/
getWitnessesForVuln(vulnId: string): Observable<ReachabilityWitness[]>;
/**
* Get state flip summary for a scan (for PR gates).
*/
getStateFlipSummary(scanId: string): Observable<StateFlipSummary>;
/**
* Download witness as JSON.
*/
downloadWitnessJson(witnessId: string): Observable<Blob>;
/**
* Export witnesses as SARIF.
*/
exportSarif(scanId: string): Observable<Blob>;
}
export const WITNESS_API = new InjectionToken<WitnessApi>('WITNESS_API');
/**
* HTTP implementation of WitnessApi.
*/
@Injectable({ providedIn: 'root' })
export class WitnessHttpClient implements WitnessApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/witnesses';
getWitness(witnessId: string): Observable<ReachabilityWitness> {
return this.http.get<ReachabilityWitness>(`${this.baseUrl}/${witnessId}`);
}
listWitnesses(
scanId: string,
options?: { page?: number; pageSize?: number; tier?: ConfidenceTier }
): Observable<WitnessListResponse> {
let params = new HttpParams().set('scanId', scanId);
if (options?.page) {
params = params.set('page', options.page.toString());
}
if (options?.pageSize) {
params = params.set('pageSize', options.pageSize.toString());
}
if (options?.tier) {
params = params.set('tier', options.tier);
}
return this.http.get<WitnessListResponse>(this.baseUrl, { params });
}
verifyWitness(witnessId: string): Observable<WitnessVerificationResult> {
return this.http.post<WitnessVerificationResult>(
`${this.baseUrl}/${witnessId}/verify`,
{}
);
}
getWitnessesForVuln(vulnId: string): Observable<ReachabilityWitness[]> {
return this.http.get<ReachabilityWitness[]>(`${this.baseUrl}/by-vuln/${vulnId}`);
}
getStateFlipSummary(scanId: string): Observable<StateFlipSummary> {
return this.http.get<StateFlipSummary>(`${this.baseUrl}/state-flips/${scanId}`);
}
downloadWitnessJson(witnessId: string): Observable<Blob> {
return this.http.get(`${this.baseUrl}/${witnessId}/download`, {
responseType: 'blob',
});
}
exportSarif(scanId: string): Observable<Blob> {
return this.http.get(`${this.baseUrl}/export/sarif`, {
params: new HttpParams().set('scanId', scanId),
responseType: 'blob',
});
}
}
// Mock data for development
const MOCK_WITNESSES: ReachabilityWitness[] = [
{
witnessId: 'wit-001',
scanId: 'scan-001',
tenantId: 'tenant-1',
vulnId: 'vuln-001',
cveId: 'CVE-2024-12345',
packageName: 'Newtonsoft.Json',
packageVersion: '12.0.3',
purl: 'pkg:nuget/Newtonsoft.Json@12.0.3',
confidenceTier: 'confirmed',
confidenceScore: 0.95,
isReachable: true,
callPath: [
{ nodeId: 'n1', symbol: 'UserController.GetUser', file: 'Controllers/UserController.cs', line: 42 },
{ nodeId: 'n2', symbol: 'UserService.GetUserById', file: 'Services/UserService.cs', line: 88 },
{ nodeId: 'n3', symbol: 'JsonConvert.DeserializeObject<User>', package: 'Newtonsoft.Json' },
],
entrypoint: {
nodeId: 'n1',
symbol: 'UserController.GetUser',
file: 'Controllers/UserController.cs',
line: 42,
httpRoute: '/api/users/{id}',
httpMethod: 'GET',
},
sink: {
nodeId: 'n3',
symbol: 'JsonConvert.DeserializeObject<User>',
package: 'Newtonsoft.Json',
method: 'DeserializeObject',
},
gates: [
{
gateType: 'auth',
symbol: '[Authorize]',
confidence: 0.95,
description: 'Authorization attribute on controller',
},
],
evidence: {
callGraphHash: 'blake3:a1b2c3d4e5f6...',
surfaceHash: 'sha256:9f8e7d6c5b4a...',
analysisMethod: 'static',
toolVersion: '1.0.0',
},
signature: {
algorithm: 'ed25519',
keyId: 'attestor-stellaops-ed25519',
signature: 'base64...',
verified: true,
verifiedAt: '2025-12-18T10:30:00Z',
},
observedAt: '2025-12-18T10:30:00Z',
vexRecommendation: 'affected',
},
{
witnessId: 'wit-002',
scanId: 'scan-001',
tenantId: 'tenant-1',
vulnId: 'vuln-002',
cveId: 'CVE-2024-12346',
packageName: 'log4net',
packageVersion: '2.0.8',
purl: 'pkg:nuget/log4net@2.0.8',
confidenceTier: 'unreachable',
confidenceScore: 0.9,
isReachable: false,
callPath: [],
gates: [],
evidence: {
callGraphHash: 'blake3:b2c3d4e5f6g7...',
analysisMethod: 'static',
},
observedAt: '2025-12-18T10:30:00Z',
vexRecommendation: 'not_affected',
},
];
/**
* Mock implementation of WitnessApi for development.
*/
@Injectable({ providedIn: 'root' })
export class WitnessMockClient implements WitnessApi {
getWitness(witnessId: string): Observable<ReachabilityWitness> {
const witness = MOCK_WITNESSES.find((w) => w.witnessId === witnessId);
if (!witness) {
throw new Error(`Witness ${witnessId} not found`);
}
return of(witness).pipe(delay(200));
}
listWitnesses(
scanId: string,
options?: { page?: number; pageSize?: number; tier?: ConfidenceTier }
): Observable<WitnessListResponse> {
let filtered = MOCK_WITNESSES.filter((w) => w.scanId === scanId);
if (options?.tier) {
filtered = filtered.filter((w) => w.confidenceTier === options.tier);
}
const page = options?.page ?? 1;
const pageSize = options?.pageSize ?? 20;
const start = (page - 1) * pageSize;
const paged = filtered.slice(start, start + pageSize);
return of({
witnesses: paged,
total: filtered.length,
page,
pageSize,
hasMore: start + pageSize < filtered.length,
}).pipe(delay(200));
}
verifyWitness(witnessId: string): Observable<WitnessVerificationResult> {
return of({
witnessId,
verified: true,
algorithm: 'ed25519',
keyId: 'attestor-stellaops-ed25519',
verifiedAt: new Date().toISOString(),
}).pipe(delay(300));
}
getWitnessesForVuln(vulnId: string): Observable<ReachabilityWitness[]> {
return of(MOCK_WITNESSES.filter((w) => w.vulnId === vulnId)).pipe(delay(200));
}
getStateFlipSummary(scanId: string): Observable<StateFlipSummary> {
return of({
scanId,
hasFlips: false,
newRiskCount: 0,
mitigatedCount: 0,
netChange: 0,
shouldBlockPr: false,
summary: 'No reachability changes',
flips: [],
}).pipe(delay(200));
}
downloadWitnessJson(witnessId: string): Observable<Blob> {
const witness = MOCK_WITNESSES.find((w) => w.witnessId === witnessId);
const json = JSON.stringify(witness, null, 2);
return of(new Blob([json], { type: 'application/json' })).pipe(delay(100));
}
exportSarif(scanId: string): Observable<Blob> {
const sarif = {
$schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
version: '2.1.0',
runs: [
{
tool: { driver: { name: 'StellaOps Reachability', version: '1.0.0' } },
results: [],
},
],
};
return of(new Blob([JSON.stringify(sarif, null, 2)], { type: 'application/json' })).pipe(
delay(100)
);
}
}

View File

@@ -0,0 +1,221 @@
/**
* Witness API models for reachability evidence.
* Sprint: SPRINT_3700_0005_0001_witness_ui_cli (UI-005)
*/
/**
* Confidence tier for reachability assessment.
*/
export type ConfidenceTier = 'confirmed' | 'likely' | 'present' | 'unreachable' | 'unknown';
/**
* Reachability witness evidence.
*/
export interface ReachabilityWitness {
witnessId: string;
scanId: string;
tenantId: string;
vulnId: string;
cveId?: string;
packageName: string;
packageVersion?: string;
purl?: string;
/** Confidence tier for reachability. */
confidenceTier: ConfidenceTier;
/** Confidence score (0.0-1.0). */
confidenceScore: number;
/** Whether the vulnerable code is reachable from entry points. */
isReachable: boolean;
/** Call path from entry point to sink. */
callPath: CallPathNode[];
/** Entry point information. */
entrypoint?: PathNode;
/** Sink (vulnerable method) information. */
sink?: PathNode;
/** Gates encountered along the path. */
gates: GateInfo[];
/** Evidence metadata. */
evidence: WitnessEvidence;
/** Signature information. */
signature?: WitnessSignature;
/** When the witness was created. */
observedAt: string;
/** VEX recommendation based on reachability. */
vexRecommendation?: string;
}
/**
* Node in a call path.
*/
export interface CallPathNode {
nodeId: string;
symbol: string;
file?: string;
line?: number;
package?: string;
isChanged?: boolean;
changeKind?: string;
}
/**
* Detailed path node for entry/sink.
*/
export interface PathNode {
nodeId: string;
symbol: string;
file?: string;
line?: number;
package?: string;
method?: string;
httpRoute?: string;
httpMethod?: string;
}
/**
* Security gate information.
*/
export interface GateInfo {
gateType: 'auth' | 'authz' | 'validation' | 'sanitization' | 'rate-limit' | 'other';
symbol: string;
confidence: number;
description?: string;
file?: string;
line?: number;
}
/**
* Evidence metadata for witness.
*/
export interface WitnessEvidence {
/** Call graph hash. */
callGraphHash?: string;
/** Surface hash. */
surfaceHash?: string;
/** Analysis method. */
analysisMethod: 'static' | 'dynamic' | 'hybrid';
/** Tool version. */
toolVersion?: string;
/** Additional evidence artifacts. */
artifacts?: EvidenceArtifact[];
}
/**
* Evidence artifact reference.
*/
export interface EvidenceArtifact {
type: 'call-graph' | 'sbom' | 'attestation' | 'surface';
hash: string;
algorithm: string;
uri?: string;
}
/**
* Signature information for witness.
*/
export interface WitnessSignature {
algorithm: string;
keyId: string;
signature: string;
verified?: boolean;
verifiedAt?: string;
verificationError?: string;
}
/**
* Witness list response.
*/
export interface WitnessListResponse {
witnesses: ReachabilityWitness[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
}
/**
* Witness verification result.
*/
export interface WitnessVerificationResult {
witnessId: string;
verified: boolean;
algorithm: string;
keyId: string;
verifiedAt: string;
error?: string;
}
/**
* State flip information for PR gates.
*/
export interface StateFlip {
entryMethodKey: string;
sinkMethodKey: string;
wasReachable: boolean;
isReachable: boolean;
flipType: 'became_reachable' | 'became_unreachable';
cveId?: string;
packageName?: string;
}
/**
* State flip summary for PR annotations.
*/
export interface StateFlipSummary {
scanId: string;
previousScanId?: string;
hasFlips: boolean;
newRiskCount: number;
mitigatedCount: number;
netChange: number;
shouldBlockPr: boolean;
summary: string;
flips: StateFlip[];
}
/**
* Confidence tier badge colors.
*/
export const CONFIDENCE_TIER_COLORS: Record<ConfidenceTier, string> = {
confirmed: '#dc3545', // Red - highest risk
likely: '#fd7e14', // Orange
present: '#6c757d', // Gray
unreachable: '#28a745', // Green - no risk
unknown: '#17a2b8', // Blue - needs analysis
};
/**
* Confidence tier labels.
*/
export const CONFIDENCE_TIER_LABELS: Record<ConfidenceTier, string> = {
confirmed: 'Confirmed Reachable',
likely: 'Likely Reachable',
present: 'Present (Unknown Reachability)',
unreachable: 'Unreachable',
unknown: 'Unknown',
};
/**
* VEX recommendation by tier.
*/
export const VEX_RECOMMENDATIONS: Record<ConfidenceTier, string> = {
confirmed: 'affected',
likely: 'under_investigation',
present: 'under_investigation',
unreachable: 'not_affected',
unknown: 'under_investigation',
};

View File

@@ -0,0 +1,149 @@
/**
* PathViewerComponent Unit Tests
* Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
* Task: UI-012
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PathViewerComponent } from './path-viewer.component';
import { CompressedPath, PathNode } from '../../models/path-viewer.models';
describe('PathViewerComponent', () => {
let fixture: ComponentFixture<PathViewerComponent>;
let component: PathViewerComponent;
const mockEntrypoint: PathNode = {
nodeId: 'entry-1',
symbol: 'Program.Main',
file: 'Program.cs',
line: 10,
package: 'MyApp',
isChanged: false
};
const mockSink: PathNode = {
nodeId: 'sink-1',
symbol: 'SqlCommand.Execute',
file: 'DataAccess.cs',
line: 45,
package: 'System.Data',
isChanged: false
};
const mockKeyNode: PathNode = {
nodeId: 'key-1',
symbol: 'UserController.GetUser',
file: 'UserController.cs',
line: 25,
package: 'MyApp.Controllers',
isChanged: true,
changeKind: 'added'
};
const mockPath: CompressedPath = {
entrypoint: mockEntrypoint,
sink: mockSink,
intermediateCount: 5,
keyNodes: [mockKeyNode],
fullPath: ['entry-1', 'mid-1', 'mid-2', 'key-1', 'mid-3', 'sink-1']
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PathViewerComponent],
}).compileComponents();
fixture = TestBed.createComponent(PathViewerComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('path', mockPath);
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display entrypoint and sink nodes', () => {
fixture.detectChanges();
const displayNodes = component.displayNodes();
expect(displayNodes[0]).toEqual(mockEntrypoint);
expect(displayNodes[displayNodes.length - 1]).toEqual(mockSink);
});
it('should include key nodes in display', () => {
fixture.detectChanges();
const displayNodes = component.displayNodes();
expect(displayNodes).toContain(mockKeyNode);
});
it('should compute hidden node count correctly', () => {
fixture.detectChanges();
// intermediateCount (5) - keyNodes.length (1) = 4
expect(component.hiddenNodeCount()).toBe(4);
});
it('should toggle collapsed state', () => {
fixture.detectChanges();
expect(component.collapsed()).toBe(false);
component.toggleCollapse();
expect(component.collapsed()).toBe(true);
component.toggleCollapse();
expect(component.collapsed()).toBe(false);
});
it('should emit nodeClick when node is clicked', () => {
fixture.detectChanges();
const emitSpy = jest.spyOn(component.nodeClick, 'emit');
component.onNodeClick(mockKeyNode);
expect(emitSpy).toHaveBeenCalledWith(mockKeyNode);
});
it('should emit expandRequest when toggling expand', () => {
fixture.detectChanges();
const emitSpy = jest.spyOn(component.expandRequest, 'emit');
component.toggleExpand();
expect(emitSpy).toHaveBeenCalledWith('entry-1');
});
it('should show all nodes when expanded', () => {
fixture.detectChanges();
component.isExpanded.set(true);
const displayNodes = component.displayNodes();
// When expanded, should include all nodes from fullPath
expect(displayNodes.length).toBeGreaterThanOrEqual(3);
});
it('should return 0 hidden nodes when expanded', () => {
fixture.detectChanges();
component.isExpanded.set(true);
expect(component.hiddenNodeCount()).toBe(0);
});
it('should use default title if not provided', () => {
fixture.detectChanges();
expect(component.title()).toBe('Reachability Path');
});
it('should use custom title when provided', () => {
fixture.componentRef.setInput('title', 'Custom Path Title');
fixture.detectChanges();
expect(component.title()).toBe('Custom Path Title');
});
it('should be collapsible by default', () => {
fixture.detectChanges();
expect(component.collapsible()).toBe(true);
});
it('should highlight changes by default', () => {
fixture.detectChanges();
expect(component.highlightChanges()).toBe(true);
});
});

View File

@@ -0,0 +1,190 @@
/**
* RiskDriftCardComponent Unit Tests
* Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
* Task: UI-013
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RiskDriftCardComponent } from './risk-drift-card.component';
import { DriftResult, DriftedSink, DriftSummary } from '../../models/drift.models';
describe('RiskDriftCardComponent', () => {
let fixture: ComponentFixture<RiskDriftCardComponent>;
let component: RiskDriftCardComponent;
const mockSink1: DriftedSink = {
sinkId: 'sink-1',
sinkSymbol: 'SqlCommand.Execute',
driftKind: 'became_reachable',
riskDelta: 0.25,
severity: 'high',
cveId: 'CVE-2021-12345',
pathCount: 2
};
const mockSink2: DriftedSink = {
sinkId: 'sink-2',
sinkSymbol: 'ProcessBuilder.start',
driftKind: 'became_unreachable',
riskDelta: -0.15,
severity: 'critical',
pathCount: 1
};
const mockSink3: DriftedSink = {
sinkId: 'sink-3',
sinkSymbol: 'Runtime.exec',
driftKind: 'became_reachable',
riskDelta: 0.10,
severity: 'medium',
pathCount: 3
};
const mockSummary: DriftSummary = {
totalDrifts: 3,
newlyReachable: 2,
newlyUnreachable: 1,
riskTrend: 'increasing',
baselineScanId: 'scan-base',
currentScanId: 'scan-current'
};
const mockDriftResult: DriftResult = {
id: 'drift-1',
summary: mockSummary,
driftedSinks: [mockSink1, mockSink2, mockSink3],
attestationDigest: 'sha256:abc123',
createdAt: '2025-12-19T12:00:00Z'
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RiskDriftCardComponent],
}).compileComponents();
fixture = TestBed.createComponent(RiskDriftCardComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('drift', mockDriftResult);
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should compute summary from drift', () => {
fixture.detectChanges();
expect(component.summary()).toEqual(mockSummary);
});
it('should detect signed attestation', () => {
fixture.detectChanges();
expect(component.isSigned()).toBe(true);
});
it('should detect unsigned drift when no attestation', () => {
const unsignedDrift = { ...mockDriftResult, attestationDigest: undefined };
fixture.componentRef.setInput('drift', unsignedDrift);
fixture.detectChanges();
expect(component.isSigned()).toBe(false);
});
it('should show upward trend icon for increasing risk', () => {
fixture.detectChanges();
expect(component.trendIcon()).toBe('↑');
});
it('should show downward trend icon for decreasing risk', () => {
const decreasingDrift = {
...mockDriftResult,
summary: { ...mockSummary, riskTrend: 'decreasing' as const }
};
fixture.componentRef.setInput('drift', decreasingDrift);
fixture.detectChanges();
expect(component.trendIcon()).toBe('↓');
});
it('should show stable trend icon for stable risk', () => {
const stableDrift = {
...mockDriftResult,
summary: { ...mockSummary, riskTrend: 'stable' as const }
};
fixture.componentRef.setInput('drift', stableDrift);
fixture.detectChanges();
expect(component.trendIcon()).toBe('→');
});
it('should compute trend CSS class correctly', () => {
fixture.detectChanges();
expect(component.trendClass()).toBe('risk-drift-card__trend--increasing');
});
it('should show max preview sinks (default 3)', () => {
fixture.detectChanges();
expect(component.previewSinks().length).toBeLessThanOrEqual(3);
});
it('should respect custom maxPreviewSinks', () => {
fixture.componentRef.setInput('maxPreviewSinks', 1);
fixture.detectChanges();
expect(component.previewSinks().length).toBe(1);
});
it('should sort preview sinks by severity first', () => {
fixture.detectChanges();
const sinks = component.previewSinks();
// Critical should come before high
const criticalIndex = sinks.findIndex(s => s.severity === 'critical');
const highIndex = sinks.findIndex(s => s.severity === 'high');
if (criticalIndex !== -1 && highIndex !== -1) {
expect(criticalIndex).toBeLessThan(highIndex);
}
});
it('should compute additional sinks count', () => {
fixture.detectChanges();
// 3 total sinks, max 3 preview = 0 additional
expect(component.additionalSinksCount()).toBe(0);
});
it('should compute additional sinks when more than max', () => {
fixture.componentRef.setInput('maxPreviewSinks', 1);
fixture.detectChanges();
// 3 total sinks, max 1 preview = 2 additional
expect(component.additionalSinksCount()).toBe(2);
});
it('should emit viewDetails when view details is clicked', () => {
fixture.detectChanges();
const emitSpy = jest.spyOn(component.viewDetails, 'emit');
component.onViewDetails();
expect(emitSpy).toHaveBeenCalled();
});
it('should emit sinkClick when a sink is clicked', () => {
fixture.detectChanges();
const emitSpy = jest.spyOn(component.sinkClick, 'emit');
component.onSinkClick(mockSink1);
expect(emitSpy).toHaveBeenCalledWith(mockSink1);
});
it('should be non-compact by default', () => {
fixture.detectChanges();
expect(component.compact()).toBe(false);
});
it('should show attestation by default', () => {
fixture.detectChanges();
expect(component.showAttestation()).toBe(true);
});
});

View File

@@ -51,29 +51,49 @@
</p>
<!-- Determinism Evidence Section -->
<section class="determinism-section">
<h2>SBOM Determinism</h2>
@if (scan().determinism) {
<app-determinism-badge [evidence]="scan().determinism ?? null" />
} @else {
<p class="determinism-empty">
No determinism evidence available for this scan.
</p>
}
</section>
<section class="determinism-section">
<h2>SBOM Determinism</h2>
@if (scan().determinism) {
<app-determinism-badge [evidence]="scan().determinism ?? null" />
} @else {
<p class="determinism-empty">
No determinism evidence available for this scan.
</p>
}
</section>
<!-- Entropy Analysis Section -->
<section class="entropy-section">
<h2>Entropy Analysis</h2>
@if (scan().entropy) {
<!-- Policy Banner with thresholds and mitigations -->
<app-entropy-policy-banner [evidence]="scan().entropy ?? null" />
<!-- Detailed entropy visualization -->
<app-entropy-panel [evidence]="scan().entropy ?? null" />
} @else {
<p class="entropy-empty">
No entropy analysis available for this scan.
</p>
}
</section>
</section>
<section class="entropy-section">
<h2>Entropy Analysis</h2>
@if (scan().entropy) {
<!-- Policy Banner with thresholds and mitigations -->
<app-entropy-policy-banner [evidence]="scan().entropy ?? null" />
<!-- Detailed entropy visualization -->
<app-entropy-panel [evidence]="scan().entropy ?? null" />
} @else {
<p class="entropy-empty">
No entropy analysis available for this scan.
</p>
}
</section>
<!-- Reachability Drift Section -->
<!-- Sprint: SPRINT_3600_0004_0001_ui_evidence_chain (UI-010) -->
<section class="reachability-drift-section">
<h2>Reachability Drift</h2>
@if (driftResult()) {
<app-risk-drift-card
[drift]="driftResult()!"
[compact]="false"
[showAttestation]="true"
(viewDetails)="onViewDriftDetails()"
(sinkClick)="onSinkClick($event)"
/>
} @else {
<p class="drift-empty">
No reachability drift detected for this scan.
Drift analysis requires a baseline scan for comparison.
</p>
}
</section>
</section>

View File

@@ -117,3 +117,24 @@
color: #94a3b8;
margin: 0;
}
// Reachability Drift Section
// Sprint: SPRINT_3600_0004_0001_ui_evidence_chain (UI-010)
.reachability-drift-section {
border: 1px solid #1f2933;
border-radius: 8px;
padding: 1.25rem;
background: #111827;
h2 {
margin: 0 0 1rem 0;
font-size: 1.125rem;
color: #e2e8f0;
}
}
.drift-empty {
font-style: italic;
color: #94a3b8;
margin: 0;
}

View File

@@ -11,11 +11,14 @@ import { ScanAttestationPanelComponent } from './scan-attestation-panel.componen
import { DeterminismBadgeComponent } from './determinism-badge.component';
import { EntropyPanelComponent } from './entropy-panel.component';
import { EntropyPolicyBannerComponent } from './entropy-policy-banner.component';
import { PathViewerComponent } from '../reachability/components/path-viewer/path-viewer.component';
import { RiskDriftCardComponent } from '../reachability/components/risk-drift-card/risk-drift-card.component';
import { ScanDetail } from '../../core/api/scanner.models';
import {
scanDetailWithFailedAttestation,
scanDetailWithVerifiedAttestation,
} from '../../testing/scan-fixtures';
import type { PathNode, DriftResult, DriftedSink } from '../reachability/models';
type Scenario = 'verified' | 'failed';
@@ -27,7 +30,15 @@ const SCENARIO_MAP: Record<Scenario, ScanDetail> = {
@Component({
selector: 'app-scan-detail-page',
standalone: true,
imports: [CommonModule, ScanAttestationPanelComponent, DeterminismBadgeComponent, EntropyPanelComponent, EntropyPolicyBannerComponent],
imports: [
CommonModule,
ScanAttestationPanelComponent,
DeterminismBadgeComponent,
EntropyPanelComponent,
EntropyPolicyBannerComponent,
PathViewerComponent,
RiskDriftCardComponent,
],
templateUrl: './scan-detail-page.component.html',
styleUrls: ['./scan-detail-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -36,6 +47,7 @@ export class ScanDetailPageComponent {
private readonly route = inject(ActivatedRoute);
readonly scenario = signal<Scenario>('verified');
readonly driftResult = signal<DriftResult | null>(null);
readonly scan = computed<ScanDetail>(() => {
const current = this.scenario();
@@ -62,4 +74,31 @@ export class ScanDetailPageComponent {
onSelectScenario(next: Scenario): void {
this.scenario.set(next);
}
/**
* Handle node click in path viewer.
* Sprint: SPRINT_3600_0004_0001_ui_evidence_chain (UI-010)
*/
onPathNodeClick(node: PathNode): void {
console.log('Path node clicked:', node);
// TODO: Navigate to source location or show node details
}
/**
* Handle view details click in drift card.
* Sprint: SPRINT_3600_0004_0001_ui_evidence_chain (UI-010)
*/
onViewDriftDetails(): void {
console.log('View drift details requested');
// TODO: Navigate to full drift analysis page
}
/**
* Handle sink click in drift card.
* Sprint: SPRINT_3600_0004_0001_ui_evidence_chain (UI-010)
*/
onSinkClick(sink: DriftedSink): void {
console.log('Sink clicked:', sink);
// TODO: Navigate to sink details or expand path view
}
}

View File

@@ -9,30 +9,34 @@ import {
} from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { VULNERABILITY_API, VulnerabilityApi } from '../../core/api/vulnerability.client';
import {
Vulnerability,
VulnerabilitySeverity,
VulnerabilityStats,
VulnerabilityStatus,
} from '../../core/api/vulnerability.models';
import { VULNERABILITY_API, VulnerabilityApi } from '../../core/api/vulnerability.client';
import {
Vulnerability,
VulnerabilitySeverity,
VulnerabilityStats,
VulnerabilityStatus,
} from '../../core/api/vulnerability.models';
import {
ExceptionDraftContext,
ExceptionDraftInlineComponent,
} from '../exceptions/exception-draft-inline.component';
import {
ExceptionBadgeComponent,
ExceptionBadgeData,
ExceptionExplainComponent,
ExceptionExplainData,
} from '../../shared/components';
import { ReachabilityWhyDrawerComponent } from '../reachability/reachability-why-drawer.component';
import {
ExceptionBadgeComponent,
ExceptionBadgeData,
ExceptionExplainComponent,
ExceptionExplainData,
} from '../../shared/components';
import { ReachabilityWhyDrawerComponent } from '../reachability/reachability-why-drawer.component';
import { WitnessModalComponent } from '../../shared/components/witness-modal.component';
import { ConfidenceTierBadgeComponent } from '../../shared/components/confidence-tier-badge.component';
import { ReachabilityWitness, ConfidenceTier } from '../../core/api/witness.models';
import { WitnessMockClient } from '../../core/api/witness.client';
type SeverityFilter = VulnerabilitySeverity | 'all';
type StatusFilter = VulnerabilityStatus | 'all';
type ReachabilityFilter = 'reachable' | 'unreachable' | 'unknown' | 'all';
type SortField = 'cveId' | 'severity' | 'cvssScore' | 'publishedAt' | 'status';
type SortOrder = 'asc' | 'desc';
type SeverityFilter = VulnerabilitySeverity | 'all';
type StatusFilter = VulnerabilityStatus | 'all';
type ReachabilityFilter = 'reachable' | 'unreachable' | 'unknown' | 'all';
type SortField = 'cveId' | 'severity' | 'cvssScore' | 'publishedAt' | 'status';
type SortOrder = 'asc' | 'desc';
const SEVERITY_LABELS: Record<VulnerabilitySeverity, string> = {
critical: 'Critical',
@@ -42,39 +46,48 @@ const SEVERITY_LABELS: Record<VulnerabilitySeverity, string> = {
unknown: 'Unknown',
};
const STATUS_LABELS: Record<VulnerabilityStatus, string> = {
open: 'Open',
fixed: 'Fixed',
wont_fix: "Won't Fix",
in_progress: 'In Progress',
excepted: 'Excepted',
};
const REACHABILITY_LABELS: Record<Exclude<ReachabilityFilter, 'all'>, string> = {
reachable: 'Reachable',
unreachable: 'Unreachable',
unknown: 'Unknown',
};
const SEVERITY_ORDER: Record<VulnerabilitySeverity, number> = {
critical: 0,
high: 1,
medium: 2,
const STATUS_LABELS: Record<VulnerabilityStatus, string> = {
open: 'Open',
fixed: 'Fixed',
wont_fix: "Won't Fix",
in_progress: 'In Progress',
excepted: 'Excepted',
};
const REACHABILITY_LABELS: Record<Exclude<ReachabilityFilter, 'all'>, string> = {
reachable: 'Reachable',
unreachable: 'Unreachable',
unknown: 'Unknown',
};
const SEVERITY_ORDER: Record<VulnerabilitySeverity, number> = {
critical: 0,
high: 1,
medium: 2,
low: 3,
unknown: 4,
};
@Component({
selector: 'app-vulnerability-explorer',
standalone: true,
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent, ReachabilityWhyDrawerComponent],
templateUrl: './vulnerability-explorer.component.html',
styleUrls: ['./vulnerability-explorer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [],
})
export class VulnerabilityExplorerComponent implements OnInit {
selector: 'app-vulnerability-explorer',
standalone: true,
imports: [
CommonModule,
ExceptionDraftInlineComponent,
ExceptionBadgeComponent,
ExceptionExplainComponent,
ReachabilityWhyDrawerComponent,
WitnessModalComponent,
ConfidenceTierBadgeComponent,
],
templateUrl: './vulnerability-explorer.component.html',
styleUrls: ['./vulnerability-explorer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [],
})
export class VulnerabilityExplorerComponent implements OnInit {
private readonly api = inject<VulnerabilityApi>(VULNERABILITY_API);
private readonly witnessClient = inject(WitnessMockClient);
// View state
readonly loading = signal(false);
@@ -86,55 +99,55 @@ export class VulnerabilityExplorerComponent implements OnInit {
readonly stats = signal<VulnerabilityStats | null>(null);
readonly selectedVulnId = signal<string | null>(null);
// Filters & sorting
readonly severityFilter = signal<SeverityFilter>('all');
readonly statusFilter = signal<StatusFilter>('all');
readonly reachabilityFilter = signal<ReachabilityFilter>('all');
readonly searchQuery = signal('');
readonly sortField = signal<SortField>('severity');
readonly sortOrder = signal<SortOrder>('asc');
readonly showExceptedOnly = signal(false);
// Filters & sorting
readonly severityFilter = signal<SeverityFilter>('all');
readonly statusFilter = signal<StatusFilter>('all');
readonly reachabilityFilter = signal<ReachabilityFilter>('all');
readonly searchQuery = signal('');
readonly sortField = signal<SortField>('severity');
readonly sortOrder = signal<SortOrder>('asc');
readonly showExceptedOnly = signal(false);
// Exception draft state
readonly showExceptionDraft = signal(false);
readonly selectedForException = signal<Vulnerability[]>([]);
// Exception explain state
readonly showExceptionExplain = signal(false);
readonly explainExceptionId = signal<string | null>(null);
// Why drawer state
readonly showWhyDrawer = signal(false);
// Constants for template
readonly severityLabels = SEVERITY_LABELS;
readonly statusLabels = STATUS_LABELS;
readonly reachabilityLabels = REACHABILITY_LABELS;
readonly allSeverities: VulnerabilitySeverity[] = ['critical', 'high', 'medium', 'low', 'unknown'];
readonly allStatuses: VulnerabilityStatus[] = ['open', 'fixed', 'wont_fix', 'in_progress', 'excepted'];
readonly allReachability: Exclude<ReachabilityFilter, 'all'>[] = ['reachable', 'unknown', 'unreachable'];
// Exception explain state
readonly showExceptionExplain = signal(false);
readonly explainExceptionId = signal<string | null>(null);
// Why drawer state
readonly showWhyDrawer = signal(false);
// Constants for template
readonly severityLabels = SEVERITY_LABELS;
readonly statusLabels = STATUS_LABELS;
readonly reachabilityLabels = REACHABILITY_LABELS;
readonly allSeverities: VulnerabilitySeverity[] = ['critical', 'high', 'medium', 'low', 'unknown'];
readonly allStatuses: VulnerabilityStatus[] = ['open', 'fixed', 'wont_fix', 'in_progress', 'excepted'];
readonly allReachability: Exclude<ReachabilityFilter, 'all'>[] = ['reachable', 'unknown', 'unreachable'];
// Computed: filtered and sorted list
readonly filteredVulnerabilities = computed(() => {
let items = [...this.vulnerabilities()];
const severity = this.severityFilter();
const status = this.statusFilter();
const reachability = this.reachabilityFilter();
const search = this.searchQuery().toLowerCase();
const exceptedOnly = this.showExceptedOnly();
readonly filteredVulnerabilities = computed(() => {
let items = [...this.vulnerabilities()];
const severity = this.severityFilter();
const status = this.statusFilter();
const reachability = this.reachabilityFilter();
const search = this.searchQuery().toLowerCase();
const exceptedOnly = this.showExceptedOnly();
if (severity !== 'all') {
items = items.filter((v) => v.severity === severity);
}
if (status !== 'all') {
items = items.filter((v) => v.status === status);
}
if (reachability !== 'all') {
items = items.filter((v) => (v.reachabilityStatus ?? 'unknown') === reachability);
}
if (exceptedOnly) {
items = items.filter((v) => v.hasException);
}
if (status !== 'all') {
items = items.filter((v) => v.status === status);
}
if (reachability !== 'all') {
items = items.filter((v) => (v.reachabilityStatus ?? 'unknown') === reachability);
}
if (exceptedOnly) {
items = items.filter((v) => v.hasException);
}
if (search) {
items = items.filter(
(v) =>
@@ -239,10 +252,10 @@ export class VulnerabilityExplorerComponent implements OnInit {
this.message.set(null);
try {
const [vulnsResponse, statsResponse] = await Promise.all([
firstValueFrom(this.api.listVulnerabilities({ includeReachability: true })),
firstValueFrom(this.api.getStats()),
]);
const [vulnsResponse, statsResponse] = await Promise.all([
firstValueFrom(this.api.listVulnerabilities({ includeReachability: true })),
firstValueFrom(this.api.getStats()),
]);
this.vulnerabilities.set([...vulnsResponse.items]);
this.stats.set(statsResponse);
@@ -258,18 +271,18 @@ export class VulnerabilityExplorerComponent implements OnInit {
this.severityFilter.set(severity);
}
setStatusFilter(status: StatusFilter): void {
this.statusFilter.set(status);
}
setReachabilityFilter(reachability: ReachabilityFilter): void {
this.reachabilityFilter.set(reachability);
}
onSearchInput(event: Event): void {
const input = event.target as HTMLInputElement;
this.searchQuery.set(input.value);
}
setStatusFilter(status: StatusFilter): void {
this.statusFilter.set(status);
}
setReachabilityFilter(reachability: ReachabilityFilter): void {
this.reachabilityFilter.set(reachability);
}
onSearchInput(event: Event): void {
const input = event.target as HTMLInputElement;
this.searchQuery.set(input.value);
}
clearSearch(): void {
this.searchQuery.set('');
@@ -337,17 +350,17 @@ export class VulnerabilityExplorerComponent implements OnInit {
this.showExceptionExplain.set(true);
}
closeExplain(): void {
this.showExceptionExplain.set(false);
this.explainExceptionId.set(null);
}
closeExplain(): void {
this.showExceptionExplain.set(false);
this.explainExceptionId.set(null);
}
viewExceptionFromExplain(exceptionId: string): void {
this.closeExplain();
this.onViewExceptionDetails(exceptionId);
}
openFullWizard(): void {
openFullWizard(): void {
// In a real app, this would navigate to the Exception Center wizard
// For now, just show a message
this.showMessage('Opening full wizard... (would navigate to Exception Center)', 'info');
@@ -371,47 +384,47 @@ export class VulnerabilityExplorerComponent implements OnInit {
});
}
formatCvss(score: number | undefined): string {
if (score === undefined) return '-';
return score.toFixed(1);
}
openWhyDrawer(): void {
this.showWhyDrawer.set(true);
}
closeWhyDrawer(): void {
this.showWhyDrawer.set(false);
}
getReachabilityClass(vuln: Vulnerability): string {
const status = vuln.reachabilityStatus ?? 'unknown';
return `reachability--${status}`;
}
getReachabilityLabel(vuln: Vulnerability): string {
const status = vuln.reachabilityStatus ?? 'unknown';
return REACHABILITY_LABELS[status];
}
getReachabilityTooltip(vuln: Vulnerability): string {
const status = vuln.reachabilityStatus ?? 'unknown';
const score = vuln.reachabilityScore;
const scoreText =
typeof score === 'number' ? ` (confidence ${(score * 100).toFixed(0)}%)` : '';
switch (status) {
case 'reachable':
return `Reachable${scoreText}. Signals indicates a call path reaches at least one affected component.`;
case 'unreachable':
return `Unreachable${scoreText}. Signals found no call path to affected components.`;
default:
return `Unknown${scoreText}. No reachability evidence is available for the affected components.`;
}
}
trackByVuln = (_: number, item: Vulnerability) => item.vulnId;
trackByComponent = (_: number, item: { purl: string }) => item.purl;
formatCvss(score: number | undefined): string {
if (score === undefined) return '-';
return score.toFixed(1);
}
openWhyDrawer(): void {
this.showWhyDrawer.set(true);
}
closeWhyDrawer(): void {
this.showWhyDrawer.set(false);
}
getReachabilityClass(vuln: Vulnerability): string {
const status = vuln.reachabilityStatus ?? 'unknown';
return `reachability--${status}`;
}
getReachabilityLabel(vuln: Vulnerability): string {
const status = vuln.reachabilityStatus ?? 'unknown';
return REACHABILITY_LABELS[status];
}
getReachabilityTooltip(vuln: Vulnerability): string {
const status = vuln.reachabilityStatus ?? 'unknown';
const score = vuln.reachabilityScore;
const scoreText =
typeof score === 'number' ? ` (confidence ${(score * 100).toFixed(0)}%)` : '';
switch (status) {
case 'reachable':
return `Reachable${scoreText}. Signals indicates a call path reaches at least one affected component.`;
case 'unreachable':
return `Unreachable${scoreText}. Signals found no call path to affected components.`;
default:
return `Unknown${scoreText}. No reachability evidence is available for the affected components.`;
}
}
trackByVuln = (_: number, item: Vulnerability) => item.vulnId;
trackByComponent = (_: number, item: { purl: string }) => item.purl;
private sortVulnerabilities(items: Vulnerability[]): Vulnerability[] {
const field = this.sortField();
@@ -448,9 +461,9 @@ export class VulnerabilityExplorerComponent implements OnInit {
setTimeout(() => this.message.set(null), 5000);
}
private toErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
return 'Operation failed. Please retry.';
}
}
private toErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
return 'Operation failed. Please retry.';
}
}

View File

@@ -0,0 +1,148 @@
/**
* Confidence Tier Badge Component.
* Sprint: SPRINT_3700_0005_0001_witness_ui_cli (UI-006)
*
* Displays reachability confidence tier with color coding.
*/
import { Component, input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
ConfidenceTier,
CONFIDENCE_TIER_COLORS,
CONFIDENCE_TIER_LABELS,
VEX_RECOMMENDATIONS,
} from '../../core/api/witness.models';
@Component({
selector: 'app-confidence-tier-badge',
standalone: true,
imports: [CommonModule],
template: `
<span
class="tier-badge"
[class]="tierClass()"
[style.background-color]="tierColor()"
[attr.title]="tooltip()"
[attr.aria-label]="ariaLabel()"
>
<span class="tier-badge__icon" *ngIf="showIcon()">{{ tierIcon() }}</span>
<span class="tier-badge__label">{{ tierLabel() }}</span>
<span class="tier-badge__score" *ngIf="showScore() && score() !== undefined">
{{ formatScore() }}
</span>
</span>
`,
styles: [`
.tier-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
color: #fff;
text-transform: uppercase;
letter-spacing: 0.025em;
cursor: help;
transition: opacity 0.15s, transform 0.15s;
&:hover {
opacity: 0.9;
transform: scale(1.02);
}
}
.tier-badge--confirmed {
background-color: #dc3545;
box-shadow: 0 2px 4px rgba(220, 53, 69, 0.3);
}
.tier-badge--likely {
background-color: #fd7e14;
box-shadow: 0 2px 4px rgba(253, 126, 20, 0.3);
}
.tier-badge--present {
background-color: #6c757d;
box-shadow: 0 2px 4px rgba(108, 117, 125, 0.3);
}
.tier-badge--unreachable {
background-color: #28a745;
box-shadow: 0 2px 4px rgba(40, 167, 69, 0.3);
}
.tier-badge--unknown {
background-color: #17a2b8;
box-shadow: 0 2px 4px rgba(23, 162, 184, 0.3);
}
.tier-badge__icon {
font-size: 0.875rem;
}
.tier-badge__score {
opacity: 0.9;
font-weight: 400;
}
`],
})
export class ConfidenceTierBadgeComponent {
/** Confidence tier. */
tier = input.required<ConfidenceTier>();
/** Optional confidence score (0.0-1.0). */
score = input<number>();
/** Whether to show the icon. */
showIcon = input<boolean>(true);
/** Whether to show the score. */
showScore = input<boolean>(false);
/** Compact mode (shorter label). */
compact = input<boolean>(false);
tierClass = computed(() => `tier-badge tier-badge--${this.tier()}`);
tierColor = computed(() => CONFIDENCE_TIER_COLORS[this.tier()]);
tierLabel = computed(() => {
if (this.compact()) {
return this.tier().toUpperCase();
}
return CONFIDENCE_TIER_LABELS[this.tier()];
});
tierIcon = computed(() => {
const icons: Record<ConfidenceTier, string> = {
confirmed: '⚠️',
likely: '❗',
present: '❓',
unreachable: '✓',
unknown: '?',
};
return icons[this.tier()];
});
tooltip = computed(() => {
const vex = VEX_RECOMMENDATIONS[this.tier()];
const scoreText = this.score() !== undefined
? ` (Score: ${(this.score()! * 100).toFixed(0)}%)`
: '';
return `${CONFIDENCE_TIER_LABELS[this.tier()]}${scoreText}\nVEX Recommendation: ${vex}`;
});
ariaLabel = computed(() =>
`Confidence tier: ${CONFIDENCE_TIER_LABELS[this.tier()]}`
);
formatScore = computed(() => {
const s = this.score();
if (s === undefined) return '';
return `${(s * 100).toFixed(0)}%`;
});
}

View File

@@ -0,0 +1,768 @@
/**
* Evidence Drawer Component.
* Sprint: SPRINT_3850_0001_0001 (Competitive Gap Closure)
* Task: EXP-F-005 - Evidence drawer UI with proof tabs
*
* Displays detailed evidence for a finding including:
* - Proof chain visualization
* - Reachability witness
* - VEX decisions
* - Attestation verification
*/
import { Component, input, output, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { PathVisualizationComponent, PathVisualizationData } from './path-visualization.component';
import { ConfidenceTierBadgeComponent } from './confidence-tier-badge.component';
import { GateBadgeComponent } from './gate-badge.component';
import { GateInfo } from '../../core/api/witness.models';
/**
* Evidence tab types.
*/
export type EvidenceTab = 'summary' | 'proof' | 'reachability' | 'vex' | 'attestation';
/**
* Proof node for the proof chain.
*/
export interface ProofNode {
id: string;
kind: 'input' | 'rule' | 'merge' | 'output';
ruleId?: string;
delta: number;
total: number;
parentIds: string[];
evidenceRefs: string[];
actor?: string;
timestamp: string;
}
/**
* VEX decision for a finding.
*/
export interface VexDecision {
status: 'not_affected' | 'affected' | 'under_investigation' | 'fixed';
justification?: string;
source: string;
sourceVersion?: string;
timestamp: string;
jurisdiction?: string;
confidence: number;
}
/**
* Attestation envelope.
*/
export interface AttestationInfo {
envelopeType: 'DSSE' | 'in-toto';
predicateType: string;
signedAt: string;
keyId: string;
algorithm: string;
verified: boolean;
rekorLogIndex?: number;
rekorLogId?: string;
}
/**
* Evidence drawer data.
*/
export interface EvidenceDrawerData {
findingId: string;
cveId?: string;
packageName: string;
packageVersion?: string;
severity: 'critical' | 'high' | 'medium' | 'low' | 'info';
score?: number;
// Proof chain
proofNodes?: ProofNode[];
proofRootHash?: string;
// Reachability
reachabilityPath?: PathVisualizationData;
confidenceTier?: string;
gates?: GateInfo[];
// VEX
vexDecisions?: VexDecision[];
mergedVexStatus?: string;
// Attestations
attestations?: AttestationInfo[];
// Falsification conditions
falsificationConditions?: string[];
}
@Component({
selector: 'app-evidence-drawer',
standalone: true,
imports: [CommonModule, PathVisualizationComponent, ConfidenceTierBadgeComponent, GateBadgeComponent],
template: `
<div class="evidence-drawer" [class.evidence-drawer--open]="open()">
<div class="evidence-drawer__backdrop" (click)="close.emit()"></div>
<div class="evidence-drawer__panel">
<header class="evidence-drawer__header">
<div class="evidence-drawer__title">
<span class="evidence-drawer__severity" [class]="'evidence-drawer__severity--' + data().severity">
{{ data().severity | uppercase }}
</span>
<h2>{{ data().cveId ?? data().findingId }}</h2>
<span class="evidence-drawer__package">{{ data().packageName }}{{ data().packageVersion ? '@' + data().packageVersion : '' }}</span>
</div>
<button class="evidence-drawer__close" (click)="close.emit()" aria-label="Close">
×
</button>
</header>
<nav class="evidence-drawer__tabs">
<button
*ngFor="let tab of tabs"
class="evidence-drawer__tab"
[class.evidence-drawer__tab--active]="activeTab() === tab.id"
(click)="activeTab.set(tab.id)"
>
{{ tab.label }}
<span class="evidence-drawer__tab-indicator" *ngIf="tab.hasData && tab.hasData()"></span>
</button>
</nav>
<div class="evidence-drawer__content">
<!-- Summary Tab -->
<section *ngIf="activeTab() === 'summary'" class="evidence-drawer__section">
<h3>Finding Summary</h3>
<dl class="evidence-drawer__details">
<dt>Finding ID</dt>
<dd>{{ data().findingId }}</dd>
<dt *ngIf="data().cveId">CVE</dt>
<dd *ngIf="data().cveId">{{ data().cveId }}</dd>
<dt>Package</dt>
<dd>{{ data().packageName }}{{ data().packageVersion ? '@' + data().packageVersion : '' }}</dd>
<dt *ngIf="data().score !== undefined">Score</dt>
<dd *ngIf="data().score !== undefined">{{ data().score | number:'1.1-1' }}</dd>
<dt *ngIf="data().confidenceTier">Confidence</dt>
<dd *ngIf="data().confidenceTier">
<app-confidence-tier-badge [tier]="data().confidenceTier!"></app-confidence-tier-badge>
</dd>
<dt *ngIf="data().mergedVexStatus">VEX Status</dt>
<dd *ngIf="data().mergedVexStatus">
<span class="evidence-drawer__vex-status" [class]="'evidence-drawer__vex-status--' + data().mergedVexStatus">
{{ data().mergedVexStatus | uppercase }}
</span>
</dd>
</dl>
<div class="evidence-drawer__falsification" *ngIf="data().falsificationConditions?.length">
<h4>Falsification Conditions</h4>
<p class="evidence-drawer__falsification-intro">
This finding would be invalid if any of the following conditions are met:
</p>
<ul class="evidence-drawer__falsification-list">
<li *ngFor="let condition of data().falsificationConditions">{{ condition }}</li>
</ul>
</div>
</section>
<!-- Proof Tab -->
<section *ngIf="activeTab() === 'proof'" class="evidence-drawer__section">
<h3>Proof Chain</h3>
<div class="evidence-drawer__proof-root" *ngIf="data().proofRootHash">
<strong>Root Hash:</strong>
<code>{{ data().proofRootHash }}</code>
</div>
<div class="evidence-drawer__proof-nodes" *ngIf="data().proofNodes?.length">
<div
class="evidence-drawer__proof-node"
*ngFor="let node of data().proofNodes"
[class]="'evidence-drawer__proof-node--' + node.kind"
>
<div class="evidence-drawer__proof-node-header">
<span class="evidence-drawer__proof-node-kind">{{ node.kind | uppercase }}</span>
<span class="evidence-drawer__proof-node-id">{{ node.id }}</span>
</div>
<div class="evidence-drawer__proof-node-body">
<span *ngIf="node.ruleId" class="evidence-drawer__proof-node-rule">{{ node.ruleId }}</span>
<span class="evidence-drawer__proof-node-delta" [class.positive]="node.delta > 0" [class.negative]="node.delta < 0">
Δ {{ node.delta >= 0 ? '+' : '' }}{{ node.delta | number:'1.2-2' }}
</span>
<span class="evidence-drawer__proof-node-total">= {{ node.total | number:'1.2-2' }}</span>
</div>
<div class="evidence-drawer__proof-node-refs" *ngIf="node.evidenceRefs.length">
<span *ngFor="let ref of node.evidenceRefs" class="evidence-drawer__proof-node-ref">{{ ref }}</span>
</div>
</div>
</div>
<p *ngIf="!data().proofNodes?.length" class="evidence-drawer__empty">
No proof chain data available.
</p>
</section>
<!-- Reachability Tab -->
<section *ngIf="activeTab() === 'reachability'" class="evidence-drawer__section">
<h3>Reachability Analysis</h3>
<div class="evidence-drawer__reachability-header" *ngIf="data().confidenceTier">
<app-confidence-tier-badge [tier]="data().confidenceTier!"></app-confidence-tier-badge>
<div class="evidence-drawer__gates" *ngIf="data().gates?.length">
<app-gate-badge *ngFor="let gate of data().gates" [gate]="gate"></app-gate-badge>
</div>
</div>
<app-path-visualization
*ngIf="data().reachabilityPath"
[data]="data().reachabilityPath!"
[collapsed]="false"
></app-path-visualization>
<p *ngIf="!data().reachabilityPath" class="evidence-drawer__empty">
No reachability path available.
</p>
</section>
<!-- VEX Tab -->
<section *ngIf="activeTab() === 'vex'" class="evidence-drawer__section">
<h3>VEX Decisions</h3>
<div class="evidence-drawer__vex-merged" *ngIf="data().mergedVexStatus">
<strong>Merged Status:</strong>
<span class="evidence-drawer__vex-status" [class]="'evidence-drawer__vex-status--' + data().mergedVexStatus">
{{ data().mergedVexStatus | uppercase }}
</span>
</div>
<div class="evidence-drawer__vex-decisions" *ngIf="data().vexDecisions?.length">
<div class="evidence-drawer__vex-decision" *ngFor="let vex of data().vexDecisions">
<div class="evidence-drawer__vex-decision-header">
<span class="evidence-drawer__vex-status" [class]="'evidence-drawer__vex-status--' + vex.status">
{{ vex.status | uppercase }}
</span>
<span class="evidence-drawer__vex-source">{{ vex.source }}</span>
<span class="evidence-drawer__vex-confidence">{{ vex.confidence | percent }}</span>
</div>
<div class="evidence-drawer__vex-decision-body">
<p *ngIf="vex.justification">{{ vex.justification }}</p>
<div class="evidence-drawer__vex-meta">
<span *ngIf="vex.jurisdiction">Region: {{ vex.jurisdiction }}</span>
<span>{{ vex.timestamp | date:'short' }}</span>
</div>
</div>
</div>
</div>
<p *ngIf="!data().vexDecisions?.length" class="evidence-drawer__empty">
No VEX decisions available.
</p>
</section>
<!-- Attestation Tab -->
<section *ngIf="activeTab() === 'attestation'" class="evidence-drawer__section">
<h3>Attestations</h3>
<div class="evidence-drawer__attestations" *ngIf="data().attestations?.length">
<div
class="evidence-drawer__attestation"
*ngFor="let att of data().attestations"
[class.evidence-drawer__attestation--verified]="att.verified"
>
<div class="evidence-drawer__attestation-header">
<span class="evidence-drawer__attestation-type">{{ att.envelopeType }}</span>
<span
class="evidence-drawer__attestation-status"
[class.verified]="att.verified"
>
{{ att.verified ? '✓ Verified' : '⚠ Unverified' }}
</span>
</div>
<dl class="evidence-drawer__attestation-details">
<dt>Predicate Type</dt>
<dd><code>{{ att.predicateType }}</code></dd>
<dt>Key ID</dt>
<dd><code>{{ att.keyId }}</code></dd>
<dt>Algorithm</dt>
<dd>{{ att.algorithm }}</dd>
<dt>Signed At</dt>
<dd>{{ att.signedAt | date:'medium' }}</dd>
<dt *ngIf="att.rekorLogIndex">Rekor Log</dt>
<dd *ngIf="att.rekorLogIndex">
Index: {{ att.rekorLogIndex }}
<span *ngIf="att.rekorLogId">({{ att.rekorLogId }})</span>
</dd>
</dl>
</div>
</div>
<p *ngIf="!data().attestations?.length" class="evidence-drawer__empty">
No attestations available.
</p>
</section>
</div>
</div>
</div>
`,
styles: [`
.evidence-drawer {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
pointer-events: none;
}
.evidence-drawer--open {
pointer-events: auto;
}
.evidence-drawer__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity 0.2s;
}
.evidence-drawer--open .evidence-drawer__backdrop {
opacity: 1;
}
.evidence-drawer__panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: min(600px, 90vw);
background: var(--surface-primary, #fff);
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
transform: translateX(100%);
transition: transform 0.3s ease-out;
display: flex;
flex-direction: column;
}
.evidence-drawer--open .evidence-drawer__panel {
transform: translateX(0);
}
.evidence-drawer__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 1.25rem;
border-bottom: 1px solid var(--border-color, #dee2e6);
background: var(--surface-secondary, #f8f9fa);
}
.evidence-drawer__title {
display: flex;
flex-direction: column;
gap: 0.25rem;
h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
}
.evidence-drawer__severity {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 3px;
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
width: fit-content;
}
.evidence-drawer__severity--critical { background: #dc3545; color: #fff; }
.evidence-drawer__severity--high { background: #fd7e14; color: #fff; }
.evidence-drawer__severity--medium { background: #ffc107; color: #212529; }
.evidence-drawer__severity--low { background: #28a745; color: #fff; }
.evidence-drawer__severity--info { background: #17a2b8; color: #fff; }
.evidence-drawer__package {
font-family: var(--font-mono, monospace);
font-size: 0.8125rem;
color: var(--text-secondary, #6c757d);
}
.evidence-drawer__close {
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0.25rem;
color: var(--text-secondary, #6c757d);
&:hover {
color: var(--text-primary, #212529);
}
}
.evidence-drawer__tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border-color, #dee2e6);
overflow-x: auto;
}
.evidence-drawer__tab {
position: relative;
padding: 0.75rem 1rem;
background: none;
border: none;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary, #6c757d);
cursor: pointer;
white-space: nowrap;
transition: color 0.15s, background-color 0.15s;
&:hover {
color: var(--text-primary, #212529);
background: var(--surface-secondary, #f8f9fa);
}
}
.evidence-drawer__tab--active {
color: var(--primary, #007bff);
border-bottom: 2px solid var(--primary, #007bff);
margin-bottom: -1px;
}
.evidence-drawer__tab-indicator {
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--primary, #007bff);
}
.evidence-drawer__content {
flex: 1;
overflow-y: auto;
padding: 1.25rem;
}
.evidence-drawer__section {
h3 {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: 600;
}
h4 {
margin: 1rem 0 0.5rem;
font-size: 0.875rem;
font-weight: 600;
}
}
.evidence-drawer__details {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
margin: 0;
dt {
font-weight: 500;
color: var(--text-secondary, #6c757d);
}
dd {
margin: 0;
}
}
.evidence-drawer__vex-status {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 3px;
font-size: 0.6875rem;
font-weight: 600;
}
.evidence-drawer__vex-status--not_affected { background: #28a745; color: #fff; }
.evidence-drawer__vex-status--affected { background: #dc3545; color: #fff; }
.evidence-drawer__vex-status--under_investigation { background: #ffc107; color: #212529; }
.evidence-drawer__vex-status--fixed { background: #17a2b8; color: #fff; }
.evidence-drawer__falsification {
margin-top: 1.5rem;
padding: 1rem;
background: rgba(253, 126, 20, 0.1);
border-radius: 6px;
border-left: 3px solid #fd7e14;
}
.evidence-drawer__falsification-intro {
font-size: 0.8125rem;
color: var(--text-secondary, #6c757d);
margin: 0 0 0.5rem;
}
.evidence-drawer__falsification-list {
margin: 0;
padding-left: 1.25rem;
font-size: 0.875rem;
li {
margin-bottom: 0.25rem;
}
}
.evidence-drawer__proof-root {
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--surface-secondary, #f8f9fa);
border-radius: 4px;
font-size: 0.8125rem;
code {
font-family: var(--font-mono, monospace);
word-break: break-all;
}
}
.evidence-drawer__proof-nodes {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.evidence-drawer__proof-node {
padding: 0.75rem;
background: var(--surface-secondary, #f8f9fa);
border-radius: 4px;
border-left: 3px solid var(--border-color, #dee2e6);
}
.evidence-drawer__proof-node--input { border-left-color: #6c757d; }
.evidence-drawer__proof-node--rule { border-left-color: #007bff; }
.evidence-drawer__proof-node--merge { border-left-color: #6f42c1; }
.evidence-drawer__proof-node--output { border-left-color: #28a745; }
.evidence-drawer__proof-node-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.evidence-drawer__proof-node-kind {
font-size: 0.625rem;
font-weight: 700;
padding: 0.125rem 0.375rem;
border-radius: 2px;
background: var(--surface-tertiary, #e9ecef);
}
.evidence-drawer__proof-node-id {
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
color: var(--text-secondary, #6c757d);
}
.evidence-drawer__proof-node-body {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.8125rem;
}
.evidence-drawer__proof-node-delta {
font-weight: 600;
&.positive { color: #dc3545; }
&.negative { color: #28a745; }
}
.evidence-drawer__proof-node-refs {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.5rem;
}
.evidence-drawer__proof-node-ref {
font-family: var(--font-mono, monospace);
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
background: var(--surface-tertiary, #e9ecef);
border-radius: 2px;
}
.evidence-drawer__reachability-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.evidence-drawer__gates {
display: flex;
gap: 0.25rem;
}
.evidence-drawer__vex-merged {
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--surface-secondary, #f8f9fa);
border-radius: 4px;
}
.evidence-drawer__vex-decisions {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.evidence-drawer__vex-decision {
padding: 0.75rem;
background: var(--surface-secondary, #f8f9fa);
border-radius: 4px;
}
.evidence-drawer__vex-decision-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.evidence-drawer__vex-source {
font-weight: 500;
font-size: 0.8125rem;
}
.evidence-drawer__vex-confidence {
font-size: 0.75rem;
color: var(--text-secondary, #6c757d);
}
.evidence-drawer__vex-decision-body {
font-size: 0.8125rem;
p {
margin: 0 0 0.5rem;
}
}
.evidence-drawer__vex-meta {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: var(--text-tertiary, #868e96);
}
.evidence-drawer__attestations {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.evidence-drawer__attestation {
padding: 0.75rem;
background: var(--surface-secondary, #f8f9fa);
border-radius: 4px;
border-left: 3px solid var(--border-color, #dee2e6);
}
.evidence-drawer__attestation--verified {
border-left-color: #28a745;
}
.evidence-drawer__attestation-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.evidence-drawer__attestation-type {
font-weight: 600;
font-size: 0.8125rem;
}
.evidence-drawer__attestation-status {
font-size: 0.75rem;
font-weight: 500;
color: #fd7e14;
&.verified {
color: #28a745;
}
}
.evidence-drawer__attestation-details {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.75rem;
font-size: 0.8125rem;
dt {
font-weight: 500;
color: var(--text-secondary, #6c757d);
}
dd {
margin: 0;
code {
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
word-break: break-all;
}
}
}
.evidence-drawer__empty {
text-align: center;
color: var(--text-secondary, #6c757d);
font-style: italic;
padding: 2rem;
}
`],
})
export class EvidenceDrawerComponent {
/** Evidence data to display. */
data = input.required<EvidenceDrawerData>();
/** Whether the drawer is open. */
open = input<boolean>(false);
/** Emitted when the drawer should close. */
close = output<void>();
/** Active tab. */
activeTab = signal<EvidenceTab>('summary');
/** Tab configuration. */
tabs: Array<{ id: EvidenceTab; label: string; hasData?: () => boolean }> = [
{ id: 'summary', label: 'Summary' },
{ id: 'proof', label: 'Proof Chain', hasData: () => !!this.data().proofNodes?.length },
{ id: 'reachability', label: 'Reachability', hasData: () => !!this.data().reachabilityPath },
{ id: 'vex', label: 'VEX', hasData: () => !!this.data().vexDecisions?.length },
{ id: 'attestation', label: 'Attestation', hasData: () => !!this.data().attestations?.length },
];
}

View File

@@ -0,0 +1,144 @@
/**
* Gate Badge Component.
* Sprint: SPRINT_3700_0005_0001_witness_ui_cli (UI-003)
*
* Displays security gate information in the reachability path.
*/
import { Component, input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { GateInfo } from '../../core/api/witness.models';
@Component({
selector: 'app-gate-badge',
standalone: true,
imports: [CommonModule],
template: `
<span
class="gate-badge"
[class]="gateClass()"
[attr.title]="tooltip()"
[attr.aria-label]="ariaLabel()"
>
<span class="gate-badge__icon">{{ gateIcon() }}</span>
<span class="gate-badge__type">{{ gateTypeLabel() }}</span>
<span class="gate-badge__confidence" *ngIf="showConfidence()">
{{ formatConfidence() }}
</span>
</span>
`,
styles: [`
.gate-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
border-radius: 3px;
font-size: 0.6875rem;
font-weight: 500;
border: 1px solid;
cursor: help;
transition: background-color 0.15s;
}
.gate-badge--auth {
background-color: rgba(40, 167, 69, 0.1);
border-color: #28a745;
color: #28a745;
}
.gate-badge--authz {
background-color: rgba(0, 123, 255, 0.1);
border-color: #007bff;
color: #007bff;
}
.gate-badge--validation {
background-color: rgba(253, 126, 20, 0.1);
border-color: #fd7e14;
color: #fd7e14;
}
.gate-badge--sanitization {
background-color: rgba(102, 16, 242, 0.1);
border-color: #6610f2;
color: #6610f2;
}
.gate-badge--rate-limit {
background-color: rgba(108, 117, 125, 0.1);
border-color: #6c757d;
color: #6c757d;
}
.gate-badge--other {
background-color: rgba(23, 162, 184, 0.1);
border-color: #17a2b8;
color: #17a2b8;
}
.gate-badge__icon {
font-size: 0.75rem;
}
.gate-badge__confidence {
opacity: 0.8;
font-size: 0.625rem;
}
`],
})
export class GateBadgeComponent {
/** Gate information. */
gate = input.required<GateInfo>();
/** Whether to show confidence. */
showConfidence = input<boolean>(true);
gateClass = computed(() => `gate-badge gate-badge--${this.gate().gateType}`);
gateIcon = computed(() => {
const icons: Record<string, string> = {
'auth': '🔐',
'authz': '🛡️',
'validation': '✓',
'sanitization': '🧹',
'rate-limit': '⏱️',
'other': '🔒',
};
return icons[this.gate().gateType] ?? '🔒';
});
gateTypeLabel = computed(() => {
const labels: Record<string, string> = {
'auth': 'Auth',
'authz': 'AuthZ',
'validation': 'Validation',
'sanitization': 'Sanitize',
'rate-limit': 'Rate Limit',
'other': 'Gate',
};
return labels[this.gate().gateType] ?? 'Gate';
});
tooltip = computed(() => {
const g = this.gate();
let text = `${g.symbol}`;
if (g.description) {
text += `\n${g.description}`;
}
if (g.file && g.line) {
text += `\n${g.file}:${g.line}`;
}
text += `\nConfidence: ${(g.confidence * 100).toFixed(0)}%`;
return text;
});
ariaLabel = computed(() =>
`Security gate: ${this.gate().symbol}, confidence ${(this.gate().confidence * 100).toFixed(0)}%`
);
formatConfidence = computed(() =>
`${(this.gate().confidence * 100).toFixed(0)}%`
);
}

View File

@@ -3,3 +3,18 @@ export { ExceptionExplainComponent, ExceptionExplainData } from './exception-exp
export { ConfidenceBadgeComponent, ConfidenceBand } from './confidence-badge.component';
export { QuietProvenanceIndicatorComponent } from './quiet-provenance-indicator.component';
export { PolicyPackSelectorComponent } from './policy-pack-selector.component';
// Witness & Reachability components (SPRINT_3700_0005_0001)
export { ConfidenceTierBadgeComponent } from './confidence-tier-badge.component';
export { GateBadgeComponent } from './gate-badge.component';
export { PathVisualizationComponent, PathVisualizationData } from './path-visualization.component';
export { WitnessModalComponent } from './witness-modal.component';
// Risk Drift components (SPRINT_3600_0004_0001)
export { RiskDriftCardComponent, DriftResult, DriftedSink, DriftCause, AssociatedVuln } from './risk-drift-card.component';
// Evidence Drawer (SPRINT_3850_0001_0001)
export { EvidenceDrawerComponent, EvidenceDrawerData, EvidenceTab, ProofNode, VexDecision, AttestationInfo } from './evidence-drawer.component';
// Unknowns UI (SPRINT_3850_0001_0001)
export { UnknownChipComponent, UnknownItem, UnknownType, UnknownTriageAction } from './unknown-chip.component';

View File

@@ -0,0 +1,300 @@
/**
* Path Visualization Component.
* Sprint: SPRINT_3700_0005_0001_witness_ui_cli (UI-002)
*
* Visualizes the call path from entry point to sink.
*/
import { Component, input, output, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CallPathNode, PathNode, GateInfo } from '../../core/api/witness.models';
import { GateBadgeComponent } from './gate-badge.component';
export interface PathVisualizationData {
entrypoint?: PathNode;
sink?: PathNode;
callPath: CallPathNode[];
gates: GateInfo[];
}
@Component({
selector: 'app-path-visualization',
standalone: true,
imports: [CommonModule, GateBadgeComponent],
template: `
<div class="path-viz" [class.path-viz--collapsed]="collapsed()">
<div class="path-viz__header" (click)="toggleCollapsed()">
<span class="path-viz__toggle">{{ collapsed() ? '▶' : '▼' }}</span>
<span class="path-viz__title">Call Path</span>
<span class="path-viz__count">({{ pathLength() }} nodes)</span>
</div>
<div class="path-viz__content" *ngIf="!collapsed()">
<!-- Entry Point -->
<div class="path-viz__node path-viz__node--entry" *ngIf="data().entrypoint">
<div class="path-viz__node-marker">
<span class="path-viz__node-icon">🚪</span>
</div>
<div class="path-viz__node-content">
<div class="path-viz__node-label">ENTRYPOINT</div>
<div class="path-viz__node-symbol">{{ data().entrypoint!.symbol }}</div>
<div class="path-viz__node-location" *ngIf="data().entrypoint!.file">
{{ data().entrypoint!.file }}
<span *ngIf="data().entrypoint!.line">:{{ data().entrypoint!.line }}</span>
</div>
<div class="path-viz__node-route" *ngIf="data().entrypoint!.httpRoute">
{{ data().entrypoint!.httpMethod }} {{ data().entrypoint!.httpRoute }}
</div>
</div>
</div>
<!-- Connector -->
<div class="path-viz__connector" *ngIf="data().entrypoint"></div>
<!-- Intermediate Nodes -->
<ng-container *ngFor="let node of intermediateNodes(); let i = index; let last = last">
<div
class="path-viz__node path-viz__node--intermediate"
[class.path-viz__node--changed]="node.isChanged"
(click)="nodeClick.emit(node)"
>
<div class="path-viz__node-marker">
<span class="path-viz__node-index">{{ i + 1 }}</span>
</div>
<div class="path-viz__node-content">
<div class="path-viz__node-symbol">{{ node.symbol }}</div>
<div class="path-viz__node-location" *ngIf="node.file">
{{ node.file }}
<span *ngIf="node.line">:{{ node.line }}</span>
</div>
<div class="path-viz__node-package" *ngIf="node.package">
📦 {{ node.package }}
</div>
<span class="path-viz__node-changed-badge" *ngIf="node.isChanged">
{{ node.changeKind ?? 'changed' }}
</span>
</div>
</div>
<!-- Gate between nodes -->
<div class="path-viz__gate" *ngIf="getGateAtIndex(i) as gate">
<app-gate-badge [gate]="gate" [showConfidence]="true"></app-gate-badge>
</div>
<!-- Connector -->
<div class="path-viz__connector" *ngIf="!last || data().sink"></div>
</ng-container>
<!-- Sink -->
<div class="path-viz__node path-viz__node--sink" *ngIf="data().sink">
<div class="path-viz__node-marker">
<span class="path-viz__node-icon">🎯</span>
</div>
<div class="path-viz__node-content">
<div class="path-viz__node-label">SINK (TRIGGER METHOD)</div>
<div class="path-viz__node-symbol">{{ data().sink!.symbol }}</div>
<div class="path-viz__node-package" *ngIf="data().sink!.package">
📦 {{ data().sink!.package }}
</div>
</div>
</div>
</div>
</div>
`,
styles: [`
.path-viz {
font-family: var(--font-mono, 'Fira Code', 'Consolas', monospace);
font-size: 0.8125rem;
background: var(--surface-secondary, #f8f9fa);
border-radius: 8px;
padding: 1rem;
}
.path-viz__header {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
margin-bottom: 1rem;
}
.path-viz__toggle {
font-size: 0.75rem;
color: var(--text-secondary, #6c757d);
}
.path-viz__title {
font-weight: 600;
color: var(--text-primary, #212529);
}
.path-viz__count {
color: var(--text-secondary, #6c757d);
font-size: 0.75rem;
}
.path-viz__content {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.path-viz__node {
display: flex;
gap: 0.75rem;
padding: 0.75rem;
background: var(--surface-primary, #fff);
border-radius: 6px;
border: 1px solid var(--border-color, #dee2e6);
width: 100%;
max-width: 500px;
}
.path-viz__node--entry {
border-left: 4px solid #28a745;
}
.path-viz__node--sink {
border-left: 4px solid #dc3545;
}
.path-viz__node--changed {
border-color: #fd7e14;
background: rgba(253, 126, 20, 0.05);
}
.path-viz__node-marker {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--surface-secondary, #f8f9fa);
flex-shrink: 0;
}
.path-viz__node-icon {
font-size: 1rem;
}
.path-viz__node-index {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary, #6c757d);
}
.path-viz__node-content {
flex: 1;
min-width: 0;
}
.path-viz__node-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary, #6c757d);
margin-bottom: 0.25rem;
}
.path-viz__node-symbol {
font-weight: 500;
color: var(--text-primary, #212529);
word-break: break-word;
}
.path-viz__node-location {
font-size: 0.75rem;
color: var(--text-tertiary, #868e96);
margin-top: 0.25rem;
}
.path-viz__node-route {
font-size: 0.75rem;
color: #007bff;
margin-top: 0.25rem;
}
.path-viz__node-package {
font-size: 0.75rem;
color: var(--text-secondary, #6c757d);
margin-top: 0.25rem;
}
.path-viz__node-changed-badge {
display: inline-block;
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
border-radius: 3px;
background: #fd7e14;
color: #fff;
text-transform: uppercase;
margin-top: 0.25rem;
}
.path-viz__connector {
width: 2px;
height: 1.5rem;
background: linear-gradient(
to bottom,
var(--border-color, #dee2e6),
var(--border-color, #dee2e6) 50%,
transparent 50%
);
background-size: 2px 8px;
margin-left: 1rem;
}
.path-viz__gate {
margin: 0.5rem 0 0.5rem 2.5rem;
}
.path-viz--collapsed .path-viz__content {
display: none;
}
`],
})
export class PathVisualizationComponent {
/** Path data to visualize. */
data = input.required<PathVisualizationData>();
/** Whether the visualization is collapsed. */
collapsed = input<boolean>(false);
/** Emitted when a node is clicked. */
nodeClick = output<CallPathNode>();
/** Toggle collapsed state. */
private _collapsed = false;
pathLength = computed(() => this.data().callPath.length);
intermediateNodes = computed(() => {
const d = this.data();
// Filter out entry and sink from call path
return d.callPath.filter((n) => {
if (d.entrypoint && n.nodeId === d.entrypoint.nodeId) return false;
if (d.sink && n.nodeId === d.sink.nodeId) return false;
return true;
});
});
toggleCollapsed(): void {
this._collapsed = !this._collapsed;
}
getGateAtIndex(index: number): GateInfo | undefined {
// Simple heuristic: show gates proportionally along the path
const gates = this.data().gates;
if (gates.length === 0) return undefined;
const pathLen = this.intermediateNodes().length;
if (pathLen === 0) return gates[0];
const gateIndex = Math.floor((index / pathLen) * gates.length);
return gates[gateIndex];
}
}

View File

@@ -0,0 +1,440 @@
/**
* Risk Drift Card Component.
* Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
*
* Displays reachability drift summary with newly reachable and mitigated paths.
*/
import { Component, input, output, computed } from '@angular/core';
import { CommonModule, DecimalPipe } from '@angular/common';
import { PathVisualizationComponent, PathVisualizationData } from './path-visualization.component';
import { ConfidenceTierBadgeComponent } from './confidence-tier-badge.component';
/**
* Drifted sink information.
*/
export interface DriftedSink {
sinkNodeId: string;
symbol: string;
sinkCategory: string;
direction: 'became_reachable' | 'became_unreachable';
cause: DriftCause;
path: PathVisualizationData;
associatedVulns: AssociatedVuln[];
}
/**
* Cause of drift.
*/
export interface DriftCause {
kind: string;
description: string;
changedSymbol?: string;
changedFile?: string;
changedLine?: number;
}
/**
* Associated vulnerability information.
*/
export interface AssociatedVuln {
cveId: string;
epss?: number;
cvss?: number;
vexStatus?: string;
packagePurl?: string;
}
/**
* Drift result between two scans.
*/
export interface DriftResult {
baseScanId: string;
headScanId: string;
newlyReachable: DriftedSink[];
newlyUnreachable: DriftedSink[];
}
@Component({
selector: 'app-risk-drift-card',
standalone: true,
imports: [CommonModule, PathVisualizationComponent, ConfidenceTierBadgeComponent, DecimalPipe],
template: `
<div class="risk-drift-card" [class.risk-drift-card--has-risk]="hasNewRisk()">
<div class="risk-drift-card__header" (click)="toggleExpand()">
<div class="risk-drift-card__title">
<span class="risk-drift-card__icon">{{ hasNewRisk() ? '⚠️' : '✓' }}</span>
<h3>Risk Drift</h3>
</div>
<div class="risk-drift-card__summary">
<span
class="risk-drift-card__badge risk-drift-card__badge--risk"
*ngIf="result().newlyReachable.length > 0"
>
+{{ result().newlyReachable.length }} new paths
</span>
<span
class="risk-drift-card__badge risk-drift-card__badge--mitigated"
*ngIf="result().newlyUnreachable.length > 0"
>
-{{ result().newlyUnreachable.length }} mitigated
</span>
<span
class="risk-drift-card__badge risk-drift-card__badge--neutral"
*ngIf="!hasDrift()"
>
No material drift
</span>
</div>
<button class="risk-drift-card__toggle">
{{ expanded() ? '▲' : '▼' }}
</button>
</div>
<div class="risk-drift-card__content" *ngIf="expanded()">
<!-- Newly Reachable Section -->
<section class="risk-drift-card__section risk-drift-card__section--risk"
*ngIf="result().newlyReachable.length > 0">
<h4 class="risk-drift-card__section-title">
🔴 New Reachable Paths (Requires Attention)
</h4>
<div class="risk-drift-card__sink" *ngFor="let sink of result().newlyReachable">
<div class="risk-drift-card__sink-header">
<span class="risk-drift-card__sink-route">{{ formatRoute(sink) }}</span>
<div class="risk-drift-card__sink-vulns">
<span
class="risk-drift-card__vuln-badge"
*ngFor="let vuln of sink.associatedVulns"
>
{{ vuln.cveId }}
<span class="risk-drift-card__epss" *ngIf="vuln.epss">
(EPSS {{ vuln.epss | number:'1.2-2' }})
</span>
</span>
</div>
</div>
<div class="risk-drift-card__sink-cause">
<strong>Cause:</strong> {{ sink.cause.description }}
<span class="risk-drift-card__sink-location" *ngIf="sink.cause.changedFile">
@ {{ sink.cause.changedFile }}
<span *ngIf="sink.cause.changedLine">:{{ sink.cause.changedLine }}</span>
</span>
</div>
<app-path-visualization
[data]="sink.path"
[collapsed]="true"
></app-path-visualization>
<div class="risk-drift-card__sink-actions">
<button class="risk-drift-card__action" (click)="viewPath.emit(sink)">
View Path
</button>
<button class="risk-drift-card__action" (click)="quarantine.emit(sink)">
Quarantine
</button>
<button class="risk-drift-card__action" (click)="pinVersion.emit(sink)">
Pin Version
</button>
<button class="risk-drift-card__action risk-drift-card__action--secondary"
(click)="addException.emit(sink)">
Add Exception
</button>
</div>
</div>
</section>
<!-- Mitigated Section -->
<section class="risk-drift-card__section risk-drift-card__section--mitigated"
*ngIf="result().newlyUnreachable.length > 0">
<h4 class="risk-drift-card__section-title">
🟢 Mitigated Paths
</h4>
<div class="risk-drift-card__sink risk-drift-card__sink--mitigated"
*ngFor="let sink of result().newlyUnreachable">
<div class="risk-drift-card__sink-header">
<span class="risk-drift-card__sink-route">{{ formatRoute(sink) }}</span>
<div class="risk-drift-card__sink-vulns">
<span
class="risk-drift-card__vuln-badge risk-drift-card__vuln-badge--resolved"
*ngFor="let vuln of sink.associatedVulns"
>
{{ vuln.cveId }} ✓
</span>
</div>
</div>
<div class="risk-drift-card__sink-cause">
<strong>Reason:</strong> {{ sink.cause.description }}
</div>
</div>
</section>
<!-- Scan Info -->
<div class="risk-drift-card__scan-info">
<span>Base: {{ result().baseScanId }}</span>
<span>→</span>
<span>Head: {{ result().headScanId }}</span>
</div>
</div>
</div>
`,
styles: [`
.risk-drift-card {
background: var(--surface-primary, #fff);
border-radius: 8px;
border: 1px solid var(--border-color, #dee2e6);
overflow: hidden;
}
.risk-drift-card--has-risk {
border-color: #dc3545;
box-shadow: 0 2px 8px rgba(220, 53, 69, 0.15);
}
.risk-drift-card__header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
cursor: pointer;
background: var(--surface-secondary, #f8f9fa);
border-bottom: 1px solid var(--border-color, #dee2e6);
}
.risk-drift-card--has-risk .risk-drift-card__header {
background: rgba(220, 53, 69, 0.05);
}
.risk-drift-card__title {
display: flex;
align-items: center;
gap: 0.5rem;
h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
}
.risk-drift-card__icon {
font-size: 1.125rem;
}
.risk-drift-card__summary {
display: flex;
gap: 0.5rem;
flex: 1;
}
.risk-drift-card__badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.risk-drift-card__badge--risk {
background: #dc3545;
color: #fff;
}
.risk-drift-card__badge--mitigated {
background: #28a745;
color: #fff;
}
.risk-drift-card__badge--neutral {
background: var(--surface-tertiary, #e9ecef);
color: var(--text-secondary, #6c757d);
}
.risk-drift-card__toggle {
background: none;
border: none;
font-size: 0.75rem;
color: var(--text-secondary, #6c757d);
cursor: pointer;
padding: 0.25rem 0.5rem;
}
.risk-drift-card__content {
padding: 1.25rem;
}
.risk-drift-card__section {
margin-bottom: 1.5rem;
&:last-of-type {
margin-bottom: 0;
}
}
.risk-drift-card__section-title {
font-size: 0.875rem;
font-weight: 600;
margin: 0 0 1rem;
color: var(--text-primary, #212529);
}
.risk-drift-card__sink {
background: var(--surface-secondary, #f8f9fa);
border-radius: 6px;
padding: 1rem;
margin-bottom: 0.75rem;
&:last-child {
margin-bottom: 0;
}
}
.risk-drift-card__sink--mitigated {
opacity: 0.8;
}
.risk-drift-card__sink-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.5rem;
}
.risk-drift-card__sink-route {
font-family: var(--font-mono, monospace);
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary, #212529);
}
.risk-drift-card__sink-vulns {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.risk-drift-card__vuln-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
border-radius: 3px;
font-size: 0.6875rem;
font-weight: 600;
background: #dc3545;
color: #fff;
}
.risk-drift-card__vuln-badge--resolved {
background: #28a745;
}
.risk-drift-card__epss {
opacity: 0.9;
font-weight: 400;
}
.risk-drift-card__sink-cause {
font-size: 0.8125rem;
color: var(--text-secondary, #6c757d);
margin-bottom: 0.75rem;
}
.risk-drift-card__sink-location {
color: var(--text-tertiary, #868e96);
}
.risk-drift-card__sink-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
flex-wrap: wrap;
}
.risk-drift-card__action {
padding: 0.375rem 0.75rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s;
background: #007bff;
color: #fff;
border: none;
&:hover {
background: #0056b3;
}
}
.risk-drift-card__action--secondary {
background: transparent;
color: var(--text-primary, #212529);
border: 1px solid var(--border-color, #dee2e6);
&:hover {
background: var(--surface-tertiary, #e9ecef);
}
}
.risk-drift-card__scan-info {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color, #dee2e6);
font-size: 0.75rem;
color: var(--text-tertiary, #868e96);
font-family: var(--font-mono, monospace);
}
`],
})
export class RiskDriftCardComponent {
/** Drift result data. */
result = input.required<DriftResult>();
/** Whether the card is expanded. */
expanded = input<boolean>(true);
/** Emitted when user clicks "View Path". */
viewPath = output<DriftedSink>();
/** Emitted when user clicks "Quarantine". */
quarantine = output<DriftedSink>();
/** Emitted when user clicks "Pin Version". */
pinVersion = output<DriftedSink>();
/** Emitted when user clicks "Add Exception". */
addException = output<DriftedSink>();
private _expanded = true;
hasDrift = computed(() =>
this.result().newlyReachable.length > 0 ||
this.result().newlyUnreachable.length > 0
);
hasNewRisk = computed(() => this.result().newlyReachable.length > 0);
toggleExpand(): void {
this._expanded = !this._expanded;
}
formatRoute(sink: DriftedSink): string {
const entrypoint = sink.path.entrypoint?.symbol ?? 'unknown';
const sinkSymbol = sink.path.sink?.symbol ?? sink.symbol;
const pathLen = sink.path.callPath.length;
if (pathLen <= 2) {
return `${entrypoint}${sinkSymbol}`;
}
return `${entrypoint} → ... → ${sinkSymbol}`;
}
}

View File

@@ -0,0 +1,319 @@
/**
* Unknown Chip Component.
* Sprint: SPRINT_3850_0001_0001 (Competitive Gap Closure)
* Task: UNK-004 - UI unknowns chips and triage actions
*
* Displays an epistemic uncertainty indicator with triage actions.
*/
import { Component, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';
/**
* Unknown type categories.
*/
export type UnknownType =
| 'SBOM_GAP'
| 'CVE_UNMATCHED'
| 'FEED_STALE'
| 'ZERO_DAY_WINDOW'
| 'ANALYSIS_LIMIT'
| 'CONFIDENCE_LOW'
| 'NO_VEX';
/**
* Unknown item data.
*/
export interface UnknownItem {
id: string;
type: UnknownType;
description: string;
affectedFindingIds?: string[];
penalty?: number;
resolvable: boolean;
suggestedAction?: string;
metadata?: Record<string, unknown>;
}
/**
* Triage action for an unknown.
*/
export interface UnknownTriageAction {
id: string;
label: string;
icon?: string;
destructive?: boolean;
}
@Component({
selector: 'app-unknown-chip',
standalone: true,
imports: [CommonModule],
template: `
<div
class="unknown-chip"
[class]="'unknown-chip--' + unknown().type.toLowerCase()"
[class.unknown-chip--expanded]="expanded"
>
<button class="unknown-chip__trigger" (click)="toggleExpand()">
<span class="unknown-chip__icon">{{ getIcon() }}</span>
<span class="unknown-chip__label">{{ getLabel() }}</span>
<span class="unknown-chip__penalty" *ngIf="unknown().penalty">
-{{ unknown().penalty | percent }}
</span>
<span class="unknown-chip__chevron">{{ expanded ? '▲' : '▼' }}</span>
</button>
<div class="unknown-chip__dropdown" *ngIf="expanded">
<p class="unknown-chip__description">{{ unknown().description }}</p>
<div class="unknown-chip__affected" *ngIf="unknown().affectedFindingIds?.length">
<strong>Affects:</strong> {{ unknown().affectedFindingIds!.length }} finding(s)
</div>
<div class="unknown-chip__suggested" *ngIf="unknown().suggestedAction">
<strong>Suggested:</strong> {{ unknown().suggestedAction }}
</div>
<div class="unknown-chip__actions">
<button
*ngFor="let action of getActions()"
class="unknown-chip__action"
[class.unknown-chip__action--destructive]="action.destructive"
(click)="handleAction(action)"
>
<span *ngIf="action.icon">{{ action.icon }}</span>
{{ action.label }}
</button>
</div>
</div>
</div>
`,
styles: [`
.unknown-chip {
position: relative;
display: inline-block;
}
.unknown-chip__trigger {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: background-color 0.15s;
}
.unknown-chip--sbom_gap .unknown-chip__trigger {
background: rgba(220, 53, 69, 0.15);
color: #dc3545;
}
.unknown-chip--cve_unmatched .unknown-chip__trigger {
background: rgba(253, 126, 20, 0.15);
color: #fd7e14;
}
.unknown-chip--feed_stale .unknown-chip__trigger {
background: rgba(255, 193, 7, 0.15);
color: #d39e00;
}
.unknown-chip--zero_day_window .unknown-chip__trigger {
background: rgba(220, 53, 69, 0.15);
color: #dc3545;
}
.unknown-chip--analysis_limit .unknown-chip__trigger {
background: rgba(108, 117, 125, 0.15);
color: #6c757d;
}
.unknown-chip--confidence_low .unknown-chip__trigger {
background: rgba(23, 162, 184, 0.15);
color: #17a2b8;
}
.unknown-chip--no_vex .unknown-chip__trigger {
background: rgba(111, 66, 193, 0.15);
color: #6f42c1;
}
.unknown-chip__icon {
font-size: 0.875rem;
}
.unknown-chip__label {
text-transform: uppercase;
letter-spacing: 0.025em;
}
.unknown-chip__penalty {
font-weight: 600;
opacity: 0.8;
}
.unknown-chip__chevron {
font-size: 0.5rem;
opacity: 0.6;
}
.unknown-chip__dropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 100;
min-width: 240px;
max-width: 320px;
margin-top: 0.25rem;
padding: 0.75rem;
background: var(--surface-primary, #fff);
border: 1px solid var(--border-color, #dee2e6);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.unknown-chip__description {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
color: var(--text-primary, #212529);
}
.unknown-chip__affected,
.unknown-chip__suggested {
font-size: 0.75rem;
color: var(--text-secondary, #6c757d);
margin-bottom: 0.5rem;
strong {
font-weight: 600;
}
}
.unknown-chip__actions {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-color, #dee2e6);
}
.unknown-chip__action {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.625rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s;
background: var(--surface-secondary, #f8f9fa);
color: var(--text-primary, #212529);
border: 1px solid var(--border-color, #dee2e6);
&:hover {
background: var(--surface-tertiary, #e9ecef);
}
}
.unknown-chip__action--destructive {
background: rgba(220, 53, 69, 0.1);
color: #dc3545;
border-color: rgba(220, 53, 69, 0.3);
&:hover {
background: rgba(220, 53, 69, 0.2);
}
}
`],
})
export class UnknownChipComponent {
/** Unknown item data. */
unknown = input.required<UnknownItem>();
/** Emitted when a triage action is selected. */
triageAction = output<{ unknown: UnknownItem; action: UnknownTriageAction }>();
/** Whether the dropdown is expanded. */
expanded = false;
toggleExpand(): void {
this.expanded = !this.expanded;
}
getIcon(): string {
const iconMap: Record<UnknownType, string> = {
SBOM_GAP: '📦',
CVE_UNMATCHED: '❓',
FEED_STALE: '⏰',
ZERO_DAY_WINDOW: '🚨',
ANALYSIS_LIMIT: '⚠️',
CONFIDENCE_LOW: '📊',
NO_VEX: '📝',
};
return iconMap[this.unknown().type] ?? '❔';
}
getLabel(): string {
const labelMap: Record<UnknownType, string> = {
SBOM_GAP: 'SBOM Gap',
CVE_UNMATCHED: 'CVE Unmatched',
FEED_STALE: 'Stale Feed',
ZERO_DAY_WINDOW: 'Zero-Day',
ANALYSIS_LIMIT: 'Limit Hit',
CONFIDENCE_LOW: 'Low Conf.',
NO_VEX: 'No VEX',
};
return labelMap[this.unknown().type] ?? this.unknown().type;
}
getActions(): UnknownTriageAction[] {
const baseActions: UnknownTriageAction[] = [
{ id: 'view', label: 'View Details', icon: '👁️' },
];
const typeActions: Record<UnknownType, UnknownTriageAction[]> = {
SBOM_GAP: [
{ id: 'add_to_sbom', label: 'Add to SBOM', icon: '' },
{ id: 'ignore', label: 'Ignore', destructive: true },
],
CVE_UNMATCHED: [
{ id: 'map_cve', label: 'Map CVE', icon: '🔗' },
{ id: 'flag_fp', label: 'Flag as FP', icon: '🚫', destructive: true },
],
FEED_STALE: [
{ id: 'refresh_feed', label: 'Refresh Feed', icon: '🔄' },
{ id: 'acknowledge', label: 'Acknowledge', icon: '✓' },
],
ZERO_DAY_WINDOW: [
{ id: 'monitor', label: 'Monitor', icon: '📡' },
{ id: 'escalate', label: 'Escalate', icon: '🔔' },
],
ANALYSIS_LIMIT: [
{ id: 'increase_depth', label: 'Increase Depth', icon: '📈' },
{ id: 'acknowledge', label: 'Acknowledge', icon: '✓' },
],
CONFIDENCE_LOW: [
{ id: 'verify', label: 'Verify Manually', icon: '🔍' },
{ id: 'accept_risk', label: 'Accept Risk', destructive: true },
],
NO_VEX: [
{ id: 'create_vex', label: 'Create VEX', icon: '📝' },
{ id: 'request_vendor', label: 'Request from Vendor', icon: '📨' },
],
};
return [...baseActions, ...(typeActions[this.unknown().type] ?? [])];
}
handleAction(action: UnknownTriageAction): void {
this.expanded = false;
this.triageAction.emit({ unknown: this.unknown(), action });
}
}

View File

@@ -0,0 +1,517 @@
/**
* Witness Modal Component.
* Sprint: SPRINT_3700_0005_0001_witness_ui_cli (UI-001)
*
* Modal dialog for viewing reachability witness details.
*/
import { Component, input, output, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReachabilityWitness, WitnessVerificationResult } from '../../core/api/witness.models';
import { WitnessMockClient } from '../../core/api/witness.client';
import { ConfidenceTierBadgeComponent } from './confidence-tier-badge.component';
import { PathVisualizationComponent, PathVisualizationData } from './path-visualization.component';
@Component({
selector: 'app-witness-modal',
standalone: true,
imports: [
CommonModule,
ConfidenceTierBadgeComponent,
PathVisualizationComponent,
],
template: `
<div class="witness-modal-backdrop" (click)="close.emit()" *ngIf="isOpen()">
<div class="witness-modal" (click)="$event.stopPropagation()">
<div class="witness-modal__header">
<h2 class="witness-modal__title">Reachability Witness</h2>
<button
class="witness-modal__close"
(click)="close.emit()"
aria-label="Close"
>
×
</button>
</div>
<div class="witness-modal__content" *ngIf="witness()">
<!-- Summary Section -->
<section class="witness-modal__section">
<div class="witness-modal__summary">
<div class="witness-modal__vuln-id">
{{ witness()!.cveId ?? witness()!.vulnId }}
</div>
<app-confidence-tier-badge
[tier]="witness()!.confidenceTier"
[score]="witness()!.confidenceScore"
[showScore]="true"
></app-confidence-tier-badge>
</div>
<div class="witness-modal__package">
{{ witness()!.packageName }}
<span *ngIf="witness()!.packageVersion">@{{ witness()!.packageVersion }}</span>
</div>
<div class="witness-modal__purl" *ngIf="witness()!.purl">
{{ witness()!.purl }}
</div>
</section>
<!-- Path Visualization -->
<section class="witness-modal__section" *ngIf="witness()!.isReachable">
<app-path-visualization
[data]="pathData()"
[collapsed]="false"
></app-path-visualization>
</section>
<!-- Not Reachable Message -->
<section class="witness-modal__section witness-modal__not-reachable" *ngIf="!witness()!.isReachable">
<div class="witness-modal__not-reachable-icon">✓</div>
<div class="witness-modal__not-reachable-text">
No call path found from entry points to vulnerable code.
<br>
This vulnerability is not exploitable in the current configuration.
</div>
</section>
<!-- Evidence Section -->
<section class="witness-modal__section">
<h3 class="witness-modal__section-title">Evidence</h3>
<div class="witness-modal__evidence">
<div class="witness-modal__evidence-row" *ngIf="witness()!.evidence.callGraphHash">
<span class="witness-modal__evidence-label">Call graph:</span>
<code class="witness-modal__evidence-value">{{ witness()!.evidence.callGraphHash }}</code>
</div>
<div class="witness-modal__evidence-row" *ngIf="witness()!.evidence.surfaceHash">
<span class="witness-modal__evidence-label">Surface:</span>
<code class="witness-modal__evidence-value">{{ witness()!.evidence.surfaceHash }}</code>
</div>
<div class="witness-modal__evidence-row">
<span class="witness-modal__evidence-label">Observed:</span>
<span class="witness-modal__evidence-value">{{ formatDate(witness()!.observedAt) }}</span>
</div>
<div class="witness-modal__evidence-row" *ngIf="witness()!.signature">
<span class="witness-modal__evidence-label">Signed by:</span>
<span class="witness-modal__evidence-value">{{ witness()!.signature!.keyId }}</span>
</div>
</div>
</section>
<!-- Signature Section -->
<section class="witness-modal__section" *ngIf="witness()!.signature">
<h3 class="witness-modal__section-title">Signature</h3>
<div class="witness-modal__signature" [class.witness-modal__signature--verified]="verificationResult()?.verified">
<div class="witness-modal__signature-status">
<span class="witness-modal__signature-icon">
{{ verificationResult()?.verified ? '✓' : (verificationResult()?.error ? '✗' : '?') }}
</span>
<span class="witness-modal__signature-text">
{{ verificationResult()?.verified ? 'VERIFIED' : (verificationResult()?.error ? 'FAILED' : 'NOT VERIFIED') }}
</span>
<span class="witness-modal__signature-detail" *ngIf="verificationResult()?.verified">
Signature valid
</span>
<span class="witness-modal__signature-detail witness-modal__signature-detail--error" *ngIf="verificationResult()?.error">
{{ verificationResult()!.error }}
</span>
</div>
<div class="witness-modal__signature-key">
Key ID: {{ witness()!.signature!.keyId }}
</div>
</div>
</section>
<!-- VEX Recommendation -->
<section class="witness-modal__section" *ngIf="witness()!.vexRecommendation">
<h3 class="witness-modal__section-title">VEX Recommendation</h3>
<div class="witness-modal__vex">
{{ vexLabel() }}
</div>
</section>
</div>
<div class="witness-modal__footer">
<button
class="witness-modal__btn witness-modal__btn--secondary"
(click)="verifySignature()"
[disabled]="isVerifying()"
>
{{ isVerifying() ? 'Verifying...' : 'Verify Signature' }}
</button>
<button
class="witness-modal__btn witness-modal__btn--secondary"
(click)="downloadJson()"
>
Download JSON
</button>
<button
class="witness-modal__btn witness-modal__btn--secondary"
(click)="copyWitnessId()"
>
Copy Witness ID
</button>
<button
class="witness-modal__btn witness-modal__btn--primary"
(click)="close.emit()"
>
Close
</button>
</div>
</div>
</div>
`,
styles: [`
.witness-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.witness-modal {
background: var(--surface-primary, #fff);
border-radius: 12px;
width: 90%;
max-width: 700px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
}
.witness-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-color, #dee2e6);
}
.witness-modal__title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: var(--text-primary, #212529);
}
.witness-modal__close {
background: none;
border: none;
font-size: 1.5rem;
color: var(--text-secondary, #6c757d);
cursor: pointer;
padding: 0.25rem;
line-height: 1;
transition: color 0.15s;
&:hover {
color: var(--text-primary, #212529);
}
}
.witness-modal__content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.witness-modal__section {
margin-bottom: 1.5rem;
&:last-child {
margin-bottom: 0;
}
}
.witness-modal__section-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary, #6c757d);
margin: 0 0 0.75rem;
}
.witness-modal__summary {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.witness-modal__vuln-id {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary, #212529);
}
.witness-modal__package {
font-size: 0.875rem;
color: var(--text-secondary, #6c757d);
}
.witness-modal__purl {
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
color: var(--text-tertiary, #868e96);
margin-top: 0.25rem;
}
.witness-modal__not-reachable {
text-align: center;
padding: 2rem;
background: rgba(40, 167, 69, 0.1);
border-radius: 8px;
}
.witness-modal__not-reachable-icon {
font-size: 3rem;
color: #28a745;
margin-bottom: 1rem;
}
.witness-modal__not-reachable-text {
color: #28a745;
font-size: 0.9375rem;
}
.witness-modal__evidence {
background: var(--surface-secondary, #f8f9fa);
border-radius: 6px;
padding: 0.75rem 1rem;
}
.witness-modal__evidence-row {
display: flex;
gap: 0.75rem;
padding: 0.375rem 0;
font-size: 0.8125rem;
&:not(:last-child) {
border-bottom: 1px solid var(--border-color, #dee2e6);
}
}
.witness-modal__evidence-label {
color: var(--text-secondary, #6c757d);
min-width: 100px;
}
.witness-modal__evidence-value {
color: var(--text-primary, #212529);
word-break: break-all;
}
code.witness-modal__evidence-value {
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
background: rgba(0, 0, 0, 0.05);
padding: 0.125rem 0.375rem;
border-radius: 3px;
}
.witness-modal__signature {
padding: 0.75rem 1rem;
border-radius: 6px;
border: 1px solid var(--border-color, #dee2e6);
}
.witness-modal__signature--verified {
border-color: #28a745;
background: rgba(40, 167, 69, 0.05);
}
.witness-modal__signature-status {
display: flex;
align-items: center;
gap: 0.5rem;
}
.witness-modal__signature-icon {
font-size: 1rem;
}
.witness-modal__signature--verified .witness-modal__signature-icon {
color: #28a745;
}
.witness-modal__signature-text {
font-weight: 600;
font-size: 0.8125rem;
}
.witness-modal__signature--verified .witness-modal__signature-text {
color: #28a745;
}
.witness-modal__signature-detail {
font-size: 0.8125rem;
color: var(--text-secondary, #6c757d);
}
.witness-modal__signature-detail--error {
color: #dc3545;
}
.witness-modal__signature-key {
font-size: 0.75rem;
color: var(--text-tertiary, #868e96);
margin-top: 0.375rem;
}
.witness-modal__vex {
display: inline-block;
padding: 0.375rem 0.75rem;
border-radius: 4px;
font-size: 0.8125rem;
font-weight: 500;
text-transform: uppercase;
background: var(--surface-secondary, #f8f9fa);
color: var(--text-primary, #212529);
}
.witness-modal__footer {
display: flex;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color, #dee2e6);
justify-content: flex-end;
}
.witness-modal__btn {
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s, opacity 0.15s;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.witness-modal__btn--primary {
background: #007bff;
color: #fff;
border: none;
&:hover:not(:disabled) {
background: #0056b3;
}
}
.witness-modal__btn--secondary {
background: transparent;
color: var(--text-primary, #212529);
border: 1px solid var(--border-color, #dee2e6);
&:hover:not(:disabled) {
background: var(--surface-secondary, #f8f9fa);
}
}
`],
})
export class WitnessModalComponent {
private readonly witnessClient = inject(WitnessMockClient);
/** Whether the modal is open. */
isOpen = input<boolean>(false);
/** The witness to display. */
witness = input<ReachabilityWitness | null>(null);
/** Emitted when the modal should close. */
close = output<void>();
/** Emitted when user requests to download JSON. */
download = output<string>();
isVerifying = signal(false);
verificationResult = signal<WitnessVerificationResult | null>(null);
pathData = computed((): PathVisualizationData => {
const w = this.witness();
if (!w) {
return { callPath: [], gates: [] };
}
return {
entrypoint: w.entrypoint,
sink: w.sink,
callPath: w.callPath,
gates: w.gates,
};
});
vexLabel = computed(() => {
const vex = this.witness()?.vexRecommendation;
const labels: Record<string, string> = {
affected: 'Affected - Remediation Required',
not_affected: 'Not Affected - No Action Needed',
under_investigation: 'Under Investigation',
fixed: 'Fixed',
};
return labels[vex ?? ''] ?? vex ?? 'Unknown';
});
async verifySignature(): Promise<void> {
const w = this.witness();
if (!w) return;
this.isVerifying.set(true);
try {
const result = await this.witnessClient.verifyWitness(w.witnessId).toPromise();
this.verificationResult.set(result ?? null);
} catch (error) {
this.verificationResult.set({
witnessId: w.witnessId,
verified: false,
algorithm: w.signature?.algorithm ?? 'unknown',
keyId: w.signature?.keyId ?? 'unknown',
verifiedAt: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Verification failed',
});
} finally {
this.isVerifying.set(false);
}
}
async downloadJson(): Promise<void> {
const w = this.witness();
if (!w) return;
try {
const blob = await this.witnessClient.downloadWitnessJson(w.witnessId).toPromise();
if (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `witness-${w.witnessId}.json`;
a.click();
URL.revokeObjectURL(url);
}
} catch (error) {
console.error('Failed to download witness:', error);
}
}
async copyWitnessId(): Promise<void> {
const w = this.witness();
if (!w) return;
try {
await navigator.clipboard.writeText(w.witnessId);
} catch (error) {
console.error('Failed to copy witness ID:', error);
}
}
formatDate(iso: string): string {
return new Date(iso).toLocaleString();
}
}