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:
@@ -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.
|
||||
|
||||
288
src/Web/StellaOps.Web/src/app/core/api/witness.client.ts
Normal file
288
src/Web/StellaOps.Web/src/app/core/api/witness.client.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
221
src/Web/StellaOps.Web/src/app/core/api/witness.models.ts
Normal file
221
src/Web/StellaOps.Web/src/app/core/api/witness.models.ts
Normal 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',
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}%`;
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
}
|
||||
@@ -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)}%`
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user