docs(ops): Complete operations runbooks for Epic 3500

Sprint 3500.0004.0004 (Documentation & Handoff) - T2 DONE

Operations Runbooks Added:
- score-replay-runbook.md: Deterministic replay procedures
- proof-verification-runbook.md: DSSE/Merkle verification ops
- airgap-operations-runbook.md: Offline kit management

CLI Reference Docs:
- reachability-cli-reference.md
- score-proofs-cli-reference.md
- unknowns-cli-reference.md

Air-Gap Guides:
- score-proofs-reachability-airgap-runbook.md

Training Materials:
- score-proofs-concept-guide.md

UI API Clients:
- proof.client.ts
- reachability.client.ts
- unknowns.client.ts

All 5 operations runbooks now complete (reachability, unknowns-queue,
score-replay, proof-verification, airgap-operations).
This commit is contained in:
StellaOps Bot
2025-12-20 22:30:02 +02:00
parent 09c7155f1b
commit 4b3db9ca85
13 changed files with 5630 additions and 12 deletions

View File

@@ -0,0 +1,362 @@
/**
* Proof and Manifest API clients for Sprint 3500.0004.0002 - T6.
* Provides services for scan manifests, proof bundles, and score replay.
*/
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { AppConfigService } from '../config/app-config.service';
import {
ScanManifest,
ManifestHashEntry,
MerkleTree,
MerkleTreeNode,
ProofBundle,
ProofVerificationResult,
ScoreReplayRequest,
ScoreReplayResult,
ScoreBreakdown,
DsseSignature,
} from './proof.models';
// ============================================================================
// Injection Tokens
// ============================================================================
export const MANIFEST_API = new InjectionToken<ManifestApi>('MANIFEST_API');
export const PROOF_BUNDLE_API = new InjectionToken<ProofBundleApi>('PROOF_BUNDLE_API');
export const SCORE_REPLAY_API = new InjectionToken<ScoreReplayApi>('SCORE_REPLAY_API');
// ============================================================================
// API Interfaces
// ============================================================================
/**
* API interface for scan manifest operations.
*/
export interface ManifestApi {
getManifest(scanId: string): Observable<ScanManifest>;
getMerkleTree(scanId: string): Observable<MerkleTree>;
}
/**
* API interface for proof bundle operations.
*/
export interface ProofBundleApi {
getProofBundle(scanId: string): Observable<ProofBundle>;
verifyProofBundle(bundleId: string): Observable<ProofVerificationResult>;
downloadProofBundle(bundleId: string): Observable<Blob>;
}
/**
* API interface for score replay operations.
*/
export interface ScoreReplayApi {
triggerReplay(request: ScoreReplayRequest): Observable<ScoreReplayResult>;
getReplayStatus(replayId: string): Observable<ScoreReplayResult>;
getScoreHistory(scanId: string): Observable<readonly ScoreBreakdown[]>;
}
// ============================================================================
// Mock Data Fixtures
// ============================================================================
const mockManifestHashes: readonly ManifestHashEntry[] = [
{ label: 'SBOM Document', algorithm: 'sha256', value: 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456', source: 'sbom' },
{ label: 'Layer 1', algorithm: 'sha256', value: 'b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef1234567a', source: 'layer' },
{ label: 'Layer 2', algorithm: 'sha256', value: 'c3d4e5f6789012345678901234567890abcdef1234567890abcdef1234567ab2', source: 'layer' },
{ label: 'Layer 3', algorithm: 'sha256', value: 'd4e5f6789012345678901234567890abcdef1234567890abcdef1234567ab2c3', source: 'layer' },
{ label: 'Config', algorithm: 'sha256', value: 'e5f6789012345678901234567890abcdef1234567890abcdef1234567ab2c3d4', source: 'config' },
{ label: 'Composition Manifest', algorithm: 'sha256', value: 'f6789012345678901234567890abcdef1234567890abcdef1234567ab2c3d4e5', source: 'composition' },
];
function buildMockMerkleTree(): MerkleTree {
const leaves: MerkleTreeNode[] = mockManifestHashes.map((h, i) => ({
nodeId: `leaf-${i}`,
hash: h.value,
label: h.label,
isLeaf: true,
isRoot: false,
level: 0,
position: i,
}));
// Build internal nodes (simplified binary tree)
const level1: MerkleTreeNode[] = [
{ nodeId: 'node-1-0', hash: 'int1a2b3c4d5e6f...', isLeaf: false, isRoot: false, level: 1, position: 0, children: [leaves[0], leaves[1]] },
{ nodeId: 'node-1-1', hash: 'int2b3c4d5e6f7...', isLeaf: false, isRoot: false, level: 1, position: 1, children: [leaves[2], leaves[3]] },
{ nodeId: 'node-1-2', hash: 'int3c4d5e6f789...', isLeaf: false, isRoot: false, level: 1, position: 2, children: [leaves[4], leaves[5]] },
];
const level2: MerkleTreeNode[] = [
{ nodeId: 'node-2-0', hash: 'int4d5e6f78901...', isLeaf: false, isRoot: false, level: 2, position: 0, children: [level1[0], level1[1]] },
{ nodeId: 'node-2-1', hash: 'int5e6f7890123...', isLeaf: false, isRoot: false, level: 2, position: 1, children: [level1[2]] },
];
const root: MerkleTreeNode = {
nodeId: 'root',
hash: 'root123456789abcdef...',
label: 'Merkle Root',
isLeaf: false,
isRoot: true,
level: 3,
position: 0,
children: level2,
};
return { root, leafCount: leaves.length, depth: 4 };
}
const mockManifest: ScanManifest = {
manifestId: 'manifest-001',
scanId: 'scan-abc123',
imageDigest: 'sha256:abc123def456789012345678901234567890abcdef1234567890abcdef123456',
createdAt: '2025-12-20T10:00:00Z',
hashes: mockManifestHashes,
merkleRoot: 'sha256:root123456789abcdef1234567890abcdef1234567890abcdef1234567890',
compositionManifestUri: 'oci://registry.stellaops.io/prod/myimage@sha256:abc123/_composition.json',
};
const mockSignatures: readonly DsseSignature[] = [
{
keyId: 'awskms:///arn:aws:kms:us-east-1:123456789:key/key-id',
algorithm: 'ECDSA-P256',
status: 'valid',
signedAt: '2025-12-20T10:05:00Z',
issuer: 'StellaOps Scanner',
},
];
const mockProofBundle: ProofBundle = {
bundleId: 'bundle-001',
scanId: 'scan-abc123',
createdAt: '2025-12-20T10:05:00Z',
merkleRoot: 'sha256:root123456789abcdef1234567890abcdef1234567890abcdef1234567890',
dsseEnvelope: 'eyJ0eXBlIjoiYXBwbGljYXRpb24vdm5kLmRzc2UuZW52ZWxvcGUrand...', // Base64 mock
signatures: mockSignatures,
rekorEntry: {
logId: 'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d',
logIndex: 12345678,
integratedTime: '2025-12-20T10:06:00Z',
logUrl: 'https://rekor.sigstore.dev/api/v1/log/entries/24296fb24b8ad77a...',
bodyHash: 'sha256:body123456789...',
},
verificationStatus: 'verified',
downloadUrl: '/api/v1/scanner/scans/scan-abc123/proofs/bundle-001/download',
};
const mockScoreBreakdown: ScoreBreakdown = {
totalScore: 85.5,
components: [
{ name: 'Vulnerability', weight: 0.4, rawScore: 75.0, weightedScore: 30.0, details: '3 medium, 12 low vulnerabilities' },
{ name: 'License', weight: 0.2, rawScore: 100.0, weightedScore: 20.0, details: 'All licenses approved' },
{ name: 'Determinism', weight: 0.2, rawScore: 100.0, weightedScore: 20.0, details: 'Merkle root verified' },
{ name: 'Provenance', weight: 0.1, rawScore: 90.0, weightedScore: 9.0, details: 'SLSA Level 2' },
{ name: 'Entropy', weight: 0.1, rawScore: 65.0, weightedScore: 6.5, details: '8% opaque ratio' },
],
computedAt: '2025-12-20T10:10:00Z',
};
// ============================================================================
// Mock Service Implementations
// ============================================================================
@Injectable({ providedIn: 'root' })
export class MockManifestApi implements ManifestApi {
getManifest(scanId: string): Observable<ScanManifest> {
return of({ ...mockManifest, scanId }).pipe(delay(200));
}
getMerkleTree(scanId: string): Observable<MerkleTree> {
return of(buildMockMerkleTree()).pipe(delay(300));
}
}
@Injectable({ providedIn: 'root' })
export class MockProofBundleApi implements ProofBundleApi {
getProofBundle(scanId: string): Observable<ProofBundle> {
return of({ ...mockProofBundle, scanId }).pipe(delay(250));
}
verifyProofBundle(bundleId: string): Observable<ProofVerificationResult> {
return of({
bundleId,
verified: true,
merkleRootValid: true,
signatureValid: true,
rekorInclusionValid: true,
verifiedAt: new Date().toISOString(),
}).pipe(delay(500));
}
downloadProofBundle(bundleId: string): Observable<Blob> {
const mockData = new Blob(['mock-proof-bundle-content'], { type: 'application/gzip' });
return of(mockData).pipe(delay(100));
}
}
@Injectable({ providedIn: 'root' })
export class MockScoreReplayApi implements ScoreReplayApi {
triggerReplay(request: ScoreReplayRequest): Observable<ScoreReplayResult> {
const replayId = `replay-${Date.now()}`;
return of({
replayId,
scanId: request.scanId,
status: 'completed' as const,
startedAt: new Date(Date.now() - 5000).toISOString(),
completedAt: new Date().toISOString(),
originalScore: mockScoreBreakdown,
replayedScore: {
...mockScoreBreakdown,
totalScore: 86.0,
computedAt: new Date().toISOString(),
},
drifts: [
{
componentName: 'Vulnerability',
originalScore: 75.0,
replayedScore: 76.25,
delta: 1.25,
driftPercent: 1.67,
significant: false,
},
],
hasDrift: true,
proofBundle: mockProofBundle,
}).pipe(delay(1000));
}
getReplayStatus(replayId: string): Observable<ScoreReplayResult> {
return of({
replayId,
scanId: 'scan-abc123',
status: 'completed' as const,
startedAt: new Date(Date.now() - 5000).toISOString(),
completedAt: new Date().toISOString(),
originalScore: mockScoreBreakdown,
replayedScore: mockScoreBreakdown,
hasDrift: false,
}).pipe(delay(200));
}
getScoreHistory(scanId: string): Observable<readonly ScoreBreakdown[]> {
const history: ScoreBreakdown[] = [];
for (let i = 0; i < 5; i++) {
const date = new Date();
date.setDate(date.getDate() - i);
history.push({
...mockScoreBreakdown,
totalScore: 85.5 - i * 0.5,
computedAt: date.toISOString(),
});
}
return of(history).pipe(delay(300));
}
}
// ============================================================================
// HTTP Client Implementations (for production use)
// ============================================================================
@Injectable({ providedIn: 'root' })
export class ManifestClient implements ManifestApi {
private readonly http = inject(HttpClient);
private readonly config = inject(AppConfigService);
getManifest(scanId: string): Observable<ScanManifest> {
return this.http.get<ScanManifest>(
`${this.config.apiBaseUrl}/scanner/scans/${scanId}/manifest`
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to fetch manifest: ${error.message}`))
)
);
}
getMerkleTree(scanId: string): Observable<MerkleTree> {
return this.http.get<MerkleTree>(
`${this.config.apiBaseUrl}/scanner/scans/${scanId}/manifest/tree`
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to fetch Merkle tree: ${error.message}`))
)
);
}
}
@Injectable({ providedIn: 'root' })
export class ProofBundleClient implements ProofBundleApi {
private readonly http = inject(HttpClient);
private readonly config = inject(AppConfigService);
getProofBundle(scanId: string): Observable<ProofBundle> {
return this.http.get<ProofBundle>(
`${this.config.apiBaseUrl}/scanner/scans/${scanId}/proofs`
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to fetch proof bundle: ${error.message}`))
)
);
}
verifyProofBundle(bundleId: string): Observable<ProofVerificationResult> {
return this.http.post<ProofVerificationResult>(
`${this.config.apiBaseUrl}/scanner/proofs/${bundleId}/verify`,
{}
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to verify proof bundle: ${error.message}`))
)
);
}
downloadProofBundle(bundleId: string): Observable<Blob> {
return this.http.get(
`${this.config.apiBaseUrl}/scanner/proofs/${bundleId}/download`,
{ responseType: 'blob' }
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to download proof bundle: ${error.message}`))
)
);
}
}
@Injectable({ providedIn: 'root' })
export class ScoreReplayClient implements ScoreReplayApi {
private readonly http = inject(HttpClient);
private readonly config = inject(AppConfigService);
triggerReplay(request: ScoreReplayRequest): Observable<ScoreReplayResult> {
return this.http.post<ScoreReplayResult>(
`${this.config.apiBaseUrl}/scanner/scans/${request.scanId}/score/replay`,
request
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to trigger score replay: ${error.message}`))
)
);
}
getReplayStatus(replayId: string): Observable<ScoreReplayResult> {
return this.http.get<ScoreReplayResult>(
`${this.config.apiBaseUrl}/scanner/replays/${replayId}`
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to get replay status: ${error.message}`))
)
);
}
getScoreHistory(scanId: string): Observable<readonly ScoreBreakdown[]> {
return this.http.get<readonly ScoreBreakdown[]>(
`${this.config.apiBaseUrl}/scanner/scans/${scanId}/score/history`
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to get score history: ${error.message}`))
)
);
}
}

View File

@@ -0,0 +1,359 @@
/**
* Reachability API client for Sprint 3500.0004.0002 - T6.
* Provides services for reachability analysis and call graph visualization.
*/
import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
import { inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay, throwError } from 'rxjs';
import { catchError, map, switchMap } from 'rxjs/operators';
import { AppConfigService } from '../config/app-config.service';
import {
CallGraph,
CallGraphNode,
CallGraphEdge,
ReachabilityPath,
ReachabilityExplanation,
ReachabilityAnalysisRequest,
ReachabilitySummary,
ExportGraphRequest,
ExportGraphResult,
ConfidenceBreakdown,
} from './reachability.models';
// ============================================================================
// Injection Token
// ============================================================================
export const REACHABILITY_API = new InjectionToken<ReachabilityApi>('REACHABILITY_API');
// ============================================================================
// API Interface
// ============================================================================
/**
* API interface for reachability analysis operations.
*/
export interface ReachabilityApi {
getExplanation(scanId: string, cveId: string): Observable<ReachabilityExplanation>;
getSummary(scanId: string): Observable<ReachabilitySummary>;
analyze(request: ReachabilityAnalysisRequest): Observable<ReachabilityExplanation>;
getCallGraph(scanId: string): Observable<CallGraph>;
exportGraph(request: ExportGraphRequest): Observable<ExportGraphResult>;
}
// ============================================================================
// Mock Data Fixtures
// ============================================================================
const mockNodes: CallGraphNode[] = [
{
nodeId: 'node-entry-1',
type: 'entrypoint',
name: 'main',
qualifiedName: 'com.example.app.Main.main',
filePath: 'src/main/java/com/example/app/Main.java',
lineNumber: 15,
package: 'com.example.app',
isVulnerable: false,
isEntrypoint: true,
},
{
nodeId: 'node-service-1',
type: 'class',
name: 'UserService',
qualifiedName: 'com.example.app.service.UserService',
filePath: 'src/main/java/com/example/app/service/UserService.java',
lineNumber: 22,
package: 'com.example.app.service',
isVulnerable: false,
isEntrypoint: false,
},
{
nodeId: 'node-method-1',
type: 'method',
name: 'processRequest',
qualifiedName: 'com.example.app.service.UserService.processRequest',
filePath: 'src/main/java/com/example/app/service/UserService.java',
lineNumber: 45,
package: 'com.example.app.service',
isVulnerable: false,
isEntrypoint: false,
},
{
nodeId: 'node-lib-1',
type: 'external',
name: 'deserialize',
qualifiedName: 'com.fasterxml.jackson.databind.ObjectMapper.readValue',
package: 'com.fasterxml.jackson.databind',
isVulnerable: true,
isEntrypoint: false,
metadata: { cveId: 'CVE-2020-36518' },
},
{
nodeId: 'node-method-2',
type: 'method',
name: 'validateInput',
qualifiedName: 'com.example.app.service.UserService.validateInput',
filePath: 'src/main/java/com/example/app/service/UserService.java',
lineNumber: 78,
package: 'com.example.app.service',
isVulnerable: false,
isEntrypoint: false,
},
];
const mockEdges: CallGraphEdge[] = [
{ edgeId: 'edge-1', sourceId: 'node-entry-1', targetId: 'node-service-1', callType: 'direct', confidence: 1.0 },
{ edgeId: 'edge-2', sourceId: 'node-service-1', targetId: 'node-method-1', callType: 'direct', confidence: 1.0 },
{ edgeId: 'edge-3', sourceId: 'node-method-1', targetId: 'node-lib-1', callType: 'direct', confidence: 0.95 },
{ edgeId: 'edge-4', sourceId: 'node-method-1', targetId: 'node-method-2', callType: 'direct', confidence: 1.0 },
];
const mockCallGraph: CallGraph = {
graphId: 'graph-001',
language: 'java',
nodes: mockNodes,
edges: mockEdges,
nodeCount: mockNodes.length,
edgeCount: mockEdges.length,
createdAt: '2025-12-20T08:00:00Z',
digest: 'sha256:graph123456789abcdef...',
};
const mockConfidenceBreakdown: ConfidenceBreakdown = {
overallScore: 0.87,
factors: [
{ factorName: 'Static Analysis', weight: 0.4, score: 0.95, contribution: 0.38, description: 'Call graph extracted via static analysis' },
{ factorName: 'Path Length', weight: 0.2, score: 0.90, contribution: 0.18, description: 'Short path (3 hops) increases confidence' },
{ factorName: 'Call Type', weight: 0.2, score: 0.85, contribution: 0.17, description: 'Direct calls only, no dynamic dispatch' },
{ factorName: 'Code Coverage', weight: 0.2, score: 0.70, contribution: 0.14, description: 'Path partially covered by tests' },
],
computedAt: '2025-12-20T10:00:00Z',
};
const mockPath: ReachabilityPath = {
pathId: 'path-001',
entrypoint: mockNodes[0],
vulnerable: mockNodes[3],
steps: [
{ stepIndex: 0, node: mockNodes[0], confidence: 1.0 },
{ stepIndex: 1, node: mockNodes[1], callType: 'direct', confidence: 1.0 },
{ stepIndex: 2, node: mockNodes[2], callType: 'direct', confidence: 0.95 },
{ stepIndex: 3, node: mockNodes[3], callType: 'direct', confidence: 0.87 },
],
pathLength: 3,
overallConfidence: 0.87,
isShortestPath: true,
};
const mockExplanation: ReachabilityExplanation = {
explanationId: 'explain-001',
cveId: 'CVE-2020-36518',
vulnerablePackage: 'com.fasterxml.jackson.databind:jackson-databind:2.9.8',
vulnerableFunction: 'ObjectMapper.readValue',
verdict: 'reachable',
confidence: mockConfidenceBreakdown,
paths: [mockPath],
callGraph: mockCallGraph,
entrypointsAnalyzed: 5,
shortestPathLength: 3,
analysisTime: '2.3s',
createdAt: '2025-12-20T10:00:00Z',
};
const mockSummary: ReachabilitySummary = {
scanId: 'scan-abc123',
totalCves: 25,
reachableCves: 8,
unreachableCves: 12,
uncertainCves: 3,
noDataCves: 2,
analysisCompletedAt: '2025-12-20T10:00:00Z',
callGraphAvailable: true,
};
// ============================================================================
// Mock Service Implementation
// ============================================================================
@Injectable({ providedIn: 'root' })
export class MockReachabilityApi implements ReachabilityApi {
getExplanation(scanId: string, cveId: string): Observable<ReachabilityExplanation> {
return of({
...mockExplanation,
cveId,
}).pipe(delay(400));
}
getSummary(scanId: string): Observable<ReachabilitySummary> {
return of({
...mockSummary,
scanId,
}).pipe(delay(200));
}
analyze(request: ReachabilityAnalysisRequest): Observable<ReachabilityExplanation> {
const explanation: ReachabilityExplanation = {
...mockExplanation,
cveId: request.cveId,
callGraph: request.includeCallGraph ? mockCallGraph : undefined,
paths: mockExplanation.paths.slice(0, request.maxPaths ?? 10),
};
return of(explanation).pipe(delay(800));
}
getCallGraph(scanId: string): Observable<CallGraph> {
return of(mockCallGraph).pipe(delay(300));
}
exportGraph(request: ExportGraphRequest): Observable<ExportGraphResult> {
if (request.format === 'json') {
return of({
format: 'json',
data: JSON.stringify(mockCallGraph, null, 2),
filename: `call-graph-${request.explanationId}.json`,
}).pipe(delay(200));
}
if (request.format === 'dot') {
const dotContent = `digraph CallGraph {
rankdir=LR;
node [shape=box];
${mockNodes.map(n => `"${n.nodeId}" [label="${n.name}"${n.isVulnerable ? ' color=red' : ''}${n.isEntrypoint ? ' color=green' : ''}];`).join('\n ')}
${mockEdges.map(e => `"${e.sourceId}" -> "${e.targetId}";`).join('\n ')}
}`;
return of({
format: 'dot',
data: dotContent,
filename: `call-graph-${request.explanationId}.dot`,
}).pipe(delay(200));
}
// For PNG/SVG, return a placeholder data URL
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="${request.width ?? 800}" height="${request.height ?? 600}">
<rect width="100%" height="100%" fill="#f5f5f5"/>
<text x="50%" y="50%" text-anchor="middle" fill="#666">Call Graph Visualization</text>
</svg>`;
const dataUrl = `data:image/svg+xml;base64,${btoa(svgContent)}`;
return of({
format: request.format,
dataUrl,
filename: `call-graph-${request.explanationId}.${request.format}`,
}).pipe(delay(400));
}
}
// ============================================================================
// HTTP Client Implementation (for production use)
// ============================================================================
@Injectable({ providedIn: 'root' })
export class ReachabilityClient implements ReachabilityApi {
private readonly http = inject(HttpClient);
private readonly config = inject(AppConfigService);
getExplanation(scanId: string, cveId: string): Observable<ReachabilityExplanation> {
return this.http.get<ReachabilityExplanation>(
`${this.config.apiBaseUrl}/scanner/scans/${scanId}/reachability/${encodeURIComponent(cveId)}`
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to get reachability explanation: ${error.message}`))
)
);
}
getSummary(scanId: string): Observable<ReachabilitySummary> {
return this.http.get<ReachabilitySummary>(
`${this.config.apiBaseUrl}/scanner/scans/${scanId}/reachability/summary`
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to get reachability summary: ${error.message}`))
)
);
}
analyze(request: ReachabilityAnalysisRequest): Observable<ReachabilityExplanation> {
return this.http.post<ReachabilityExplanation>(
`${this.config.apiBaseUrl}/scanner/scans/${request.scanId}/reachability/analyze`,
request
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to analyze reachability: ${error.message}`))
)
);
}
getCallGraph(scanId: string): Observable<CallGraph> {
return this.http.get<CallGraph>(
`${this.config.apiBaseUrl}/scanner/scans/${scanId}/callgraph`
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to get call graph: ${error.message}`))
)
);
}
exportGraph(request: ExportGraphRequest): Observable<ExportGraphResult> {
let params = new HttpParams()
.set('format', request.format);
if (request.highlightPath) {
params = params.set('highlightPath', request.highlightPath);
}
if (request.width) {
params = params.set('width', request.width.toString());
}
if (request.height) {
params = params.set('height', request.height.toString());
}
if (request.format === 'png' || request.format === 'svg') {
return this.http.get(
`${this.config.apiBaseUrl}/reachability/${request.explanationId}/export`,
{ params, responseType: 'blob' }
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to export graph: ${error.message}`))
),
// Convert blob to data URL
switchMap(blob => this.blobToDataUrl(blob, request))
);
}
return this.http.get<{ data: string }>(
`${this.config.apiBaseUrl}/reachability/${request.explanationId}/export`,
{ params }
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to export graph: ${error.message}`))
),
map(response => ({
format: request.format,
data: response.data,
filename: `call-graph-${request.explanationId}.${request.format}`,
}))
);
}
private blobToDataUrl(blob: Blob, request: ExportGraphRequest): Observable<ExportGraphResult> {
return new Observable(observer => {
const reader = new FileReader();
reader.onloadend = () => {
observer.next({
format: request.format,
dataUrl: reader.result as string,
filename: `call-graph-${request.explanationId}.${request.format}`,
});
observer.complete();
};
reader.onerror = () => {
observer.error(new Error('Failed to read export blob'));
};
reader.readAsDataURL(blob);
});
}
}

View File

@@ -0,0 +1,322 @@
/**
* Unknowns Registry API client for Sprint 3500.0004.0002 - T6.
* Provides services for managing unknown packages in the registry.
*/
import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
import { inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { AppConfigService } from '../config/app-config.service';
import {
UnknownEntry,
UnknownsFilter,
UnknownsListResponse,
UnknownsSummary,
EscalateUnknownRequest,
ResolveUnknownRequest,
BulkUnknownsRequest,
BulkUnknownsResult,
UnknownBand,
} from './unknowns.models';
// ============================================================================
// Injection Token
// ============================================================================
export const UNKNOWNS_API = new InjectionToken<UnknownsApi>('UNKNOWNS_API');
// ============================================================================
// API Interface
// ============================================================================
/**
* API interface for unknowns registry operations.
*/
export interface UnknownsApi {
list(filter?: UnknownsFilter): Observable<UnknownsListResponse>;
get(unknownId: string): Observable<UnknownEntry>;
getSummary(): Observable<UnknownsSummary>;
escalate(request: EscalateUnknownRequest): Observable<UnknownEntry>;
resolve(request: ResolveUnknownRequest): Observable<UnknownEntry>;
bulkAction(request: BulkUnknownsRequest): Observable<BulkUnknownsResult>;
}
// ============================================================================
// Mock Data Fixtures
// ============================================================================
const mockUnknowns: UnknownEntry[] = [
{
unknownId: 'unk-001',
package: { name: 'lodash', version: '4.17.15', ecosystem: 'npm', purl: 'pkg:npm/lodash@4.17.15' },
band: 'HOT',
status: 'pending',
rank: 1,
occurrenceCount: 47,
firstSeenAt: '2025-12-15T08:00:00Z',
lastSeenAt: '2025-12-20T09:30:00Z',
ageInDays: 5,
relatedCves: ['CVE-2020-8203', 'CVE-2021-23337'],
recentOccurrences: [
{ scanId: 'scan-001', imageDigest: 'sha256:abc123...', imageName: 'myapp:latest', detectedAt: '2025-12-20T09:30:00Z' },
{ scanId: 'scan-002', imageDigest: 'sha256:def456...', imageName: 'api-service:v1.2', detectedAt: '2025-12-20T08:15:00Z' },
],
},
{
unknownId: 'unk-002',
package: { name: 'requests', version: '2.25.0', ecosystem: 'pypi', purl: 'pkg:pypi/requests@2.25.0' },
band: 'HOT',
status: 'escalated',
rank: 2,
occurrenceCount: 32,
firstSeenAt: '2025-12-10T14:00:00Z',
lastSeenAt: '2025-12-20T07:45:00Z',
ageInDays: 10,
assignee: 'security-team',
notes: 'Investigating potential CVE mapping',
recentOccurrences: [
{ scanId: 'scan-003', imageDigest: 'sha256:ghi789...', imageName: 'ml-worker:latest', detectedAt: '2025-12-20T07:45:00Z' },
],
},
{
unknownId: 'unk-003',
package: { name: 'spring-core', version: '5.3.8', ecosystem: 'maven', purl: 'pkg:maven/org.springframework/spring-core@5.3.8' },
band: 'WARM',
status: 'pending',
rank: 1,
occurrenceCount: 15,
firstSeenAt: '2025-12-01T10:00:00Z',
lastSeenAt: '2025-12-19T16:20:00Z',
ageInDays: 19,
recentOccurrences: [
{ scanId: 'scan-004', imageDigest: 'sha256:jkl012...', imageName: 'backend:v2.0', detectedAt: '2025-12-19T16:20:00Z' },
],
},
{
unknownId: 'unk-004',
package: { name: 'Newtonsoft.Json', version: '12.0.3', ecosystem: 'nuget', purl: 'pkg:nuget/Newtonsoft.Json@12.0.3' },
band: 'WARM',
status: 'pending',
rank: 2,
occurrenceCount: 8,
firstSeenAt: '2025-11-25T09:00:00Z',
lastSeenAt: '2025-12-18T11:30:00Z',
ageInDays: 25,
recentOccurrences: [],
},
{
unknownId: 'unk-005',
package: { name: 'deprecated-pkg', version: '1.0.0', ecosystem: 'npm' },
band: 'COLD',
status: 'pending',
rank: 1,
occurrenceCount: 2,
firstSeenAt: '2025-10-01T08:00:00Z',
lastSeenAt: '2025-11-15T14:00:00Z',
ageInDays: 80,
recentOccurrences: [],
},
];
const mockSummary: UnknownsSummary = {
hotCount: 2,
warmCount: 2,
coldCount: 1,
totalCount: 5,
pendingCount: 4,
escalatedCount: 1,
resolvedToday: 3,
oldestUnresolvedDays: 80,
};
// ============================================================================
// Mock Service Implementation
// ============================================================================
@Injectable({ providedIn: 'root' })
export class MockUnknownsApi implements UnknownsApi {
list(filter?: UnknownsFilter): Observable<UnknownsListResponse> {
let items = [...mockUnknowns];
// Apply filters
if (filter?.band) {
items = items.filter(u => u.band === filter.band);
}
if (filter?.status) {
items = items.filter(u => u.status === filter.status);
}
if (filter?.ecosystem) {
items = items.filter(u => u.package.ecosystem === filter.ecosystem);
}
// Apply sorting
if (filter?.sortBy) {
items.sort((a, b) => {
let comparison = 0;
switch (filter.sortBy) {
case 'rank':
comparison = a.rank - b.rank;
break;
case 'age':
comparison = a.ageInDays - b.ageInDays;
break;
case 'occurrenceCount':
comparison = a.occurrenceCount - b.occurrenceCount;
break;
case 'lastSeen':
comparison = new Date(a.lastSeenAt).getTime() - new Date(b.lastSeenAt).getTime();
break;
}
return filter.sortOrder === 'desc' ? -comparison : comparison;
});
}
// Apply pagination
const offset = filter?.offset ?? 0;
const limit = filter?.limit ?? 50;
const paginatedItems = items.slice(offset, offset + limit);
return of({
items: paginatedItems,
total: items.length,
limit,
offset,
hasMore: offset + limit < items.length,
}).pipe(delay(200));
}
get(unknownId: string): Observable<UnknownEntry> {
const entry = mockUnknowns.find(u => u.unknownId === unknownId);
if (!entry) {
return throwError(() => new Error(`Unknown not found: ${unknownId}`));
}
return of(entry).pipe(delay(100));
}
getSummary(): Observable<UnknownsSummary> {
return of(mockSummary).pipe(delay(150));
}
escalate(request: EscalateUnknownRequest): Observable<UnknownEntry> {
const entry = mockUnknowns.find(u => u.unknownId === request.unknownId);
if (!entry) {
return throwError(() => new Error(`Unknown not found: ${request.unknownId}`));
}
return of({
...entry,
status: 'escalated' as const,
assignee: request.assignTo,
notes: request.reason,
}).pipe(delay(300));
}
resolve(request: ResolveUnknownRequest): Observable<UnknownEntry> {
const entry = mockUnknowns.find(u => u.unknownId === request.unknownId);
if (!entry) {
return throwError(() => new Error(`Unknown not found: ${request.unknownId}`));
}
return of({
...entry,
status: 'resolved' as const,
notes: request.notes,
relatedCves: request.mappedCve ? [request.mappedCve, ...(entry.relatedCves ?? [])] : entry.relatedCves,
}).pipe(delay(300));
}
bulkAction(request: BulkUnknownsRequest): Observable<BulkUnknownsResult> {
return of({
successCount: request.unknownIds.length,
failureCount: 0,
}).pipe(delay(500));
}
}
// ============================================================================
// HTTP Client Implementation (for production use)
// ============================================================================
@Injectable({ providedIn: 'root' })
export class UnknownsClient implements UnknownsApi {
private readonly http = inject(HttpClient);
private readonly config = inject(AppConfigService);
list(filter?: UnknownsFilter): Observable<UnknownsListResponse> {
let params = new HttpParams();
if (filter) {
if (filter.band) params = params.set('band', filter.band);
if (filter.status) params = params.set('status', filter.status);
if (filter.ecosystem) params = params.set('ecosystem', filter.ecosystem);
if (filter.scanId) params = params.set('scanId', filter.scanId);
if (filter.imageDigest) params = params.set('imageDigest', filter.imageDigest);
if (filter.assignee) params = params.set('assignee', filter.assignee);
if (filter.limit) params = params.set('limit', filter.limit.toString());
if (filter.offset) params = params.set('offset', filter.offset.toString());
if (filter.sortBy) params = params.set('sortBy', filter.sortBy);
if (filter.sortOrder) params = params.set('sortOrder', filter.sortOrder);
}
return this.http.get<UnknownsListResponse>(
`${this.config.apiBaseUrl}/policy/unknowns`,
{ params }
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to list unknowns: ${error.message}`))
)
);
}
get(unknownId: string): Observable<UnknownEntry> {
return this.http.get<UnknownEntry>(
`${this.config.apiBaseUrl}/policy/unknowns/${unknownId}`
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to get unknown: ${error.message}`))
)
);
}
getSummary(): Observable<UnknownsSummary> {
return this.http.get<UnknownsSummary>(
`${this.config.apiBaseUrl}/policy/unknowns/summary`
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to get unknowns summary: ${error.message}`))
)
);
}
escalate(request: EscalateUnknownRequest): Observable<UnknownEntry> {
return this.http.post<UnknownEntry>(
`${this.config.apiBaseUrl}/policy/unknowns/${request.unknownId}/escalate`,
request
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to escalate unknown: ${error.message}`))
)
);
}
resolve(request: ResolveUnknownRequest): Observable<UnknownEntry> {
return this.http.post<UnknownEntry>(
`${this.config.apiBaseUrl}/policy/unknowns/${request.unknownId}/resolve`,
request
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to resolve unknown: ${error.message}`))
)
);
}
bulkAction(request: BulkUnknownsRequest): Observable<BulkUnknownsResult> {
return this.http.post<BulkUnknownsResult>(
`${this.config.apiBaseUrl}/policy/unknowns/bulk`,
request
).pipe(
catchError((error: HttpErrorResponse) =>
throwError(() => new Error(`Failed to perform bulk action: ${error.message}`))
)
);
}
}