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:
362
src/Web/StellaOps.Web/src/app/core/api/proof.client.ts
Normal file
362
src/Web/StellaOps.Web/src/app/core/api/proof.client.ts
Normal 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}`))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
359
src/Web/StellaOps.Web/src/app/core/api/reachability.client.ts
Normal file
359
src/Web/StellaOps.Web/src/app/core/api/reachability.client.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
322
src/Web/StellaOps.Web/src/app/core/api/unknowns.client.ts
Normal file
322
src/Web/StellaOps.Web/src/app/core/api/unknowns.client.ts
Normal 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}`))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user