feat: add Attestation Chain and Triage Evidence API clients and models
- Implemented Attestation Chain API client with methods for verifying, fetching, and managing attestation chains. - Created models for Attestation Chain, including DSSE envelope structures and verification results. - Developed Triage Evidence API client for fetching finding evidence, including methods for evidence retrieval by CVE and component. - Added models for Triage Evidence, encapsulating evidence responses, entry points, boundary proofs, and VEX evidence. - Introduced mock implementations for both API clients to facilitate testing and development.
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Attestation Chain API Client
|
||||
* Sprint: SPRINT_4100_0001_0001_triage_models
|
||||
* Provides API client for verifying and fetching attestation chains.
|
||||
*/
|
||||
|
||||
import { Injectable, InjectionToken, inject, signal } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of, map, shareReplay, catchError, throwError } from 'rxjs';
|
||||
|
||||
import {
|
||||
AttestationChain,
|
||||
AttestationNode,
|
||||
AttestationVerifyRequest,
|
||||
AttestationVerifyResult,
|
||||
DsseEnvelope,
|
||||
InTotoStatement,
|
||||
RekorLogEntry,
|
||||
SignerInfo,
|
||||
} from './attestation-chain.models';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { generateTraceId } from './trace.util';
|
||||
|
||||
/**
|
||||
* Attestation Chain API interface.
|
||||
*/
|
||||
export interface AttestationChainApi {
|
||||
/** Verify a DSSE envelope. */
|
||||
verify(request: AttestationVerifyRequest): Observable<AttestationVerifyResult>;
|
||||
|
||||
/** Get attestation chain for a digest. */
|
||||
getChain(digest: string, options?: AttestationQueryOptions): Observable<AttestationChain>;
|
||||
|
||||
/** Get single attestation node by ID. */
|
||||
getNode(nodeId: string, options?: AttestationQueryOptions): Observable<AttestationNode>;
|
||||
|
||||
/** List attestations for a subject digest. */
|
||||
listBySubject(
|
||||
subjectDigest: string,
|
||||
options?: AttestationQueryOptions
|
||||
): Observable<AttestationNode[]>;
|
||||
|
||||
/** Fetch Rekor log entry for an attestation. */
|
||||
getRekorEntry(uuid: string): Observable<RekorLogEntry>;
|
||||
|
||||
/** Download raw DSSE envelope. */
|
||||
downloadEnvelope(nodeId: string): Observable<DsseEnvelope>;
|
||||
}
|
||||
|
||||
export interface AttestationQueryOptions {
|
||||
readonly tenantId?: string;
|
||||
readonly traceId?: string;
|
||||
readonly include_rekor?: boolean;
|
||||
readonly include_cert_chain?: boolean;
|
||||
}
|
||||
|
||||
export const ATTESTATION_CHAIN_API = new InjectionToken<AttestationChainApi>(
|
||||
'ATTESTATION_CHAIN_API'
|
||||
);
|
||||
|
||||
/**
|
||||
* HTTP implementation of the Attestation Chain API.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AttestationChainHttpClient implements AttestationChainApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly tenantService = inject(TenantActivationService, { optional: true });
|
||||
|
||||
private readonly baseUrl = signal('/api/v1/attestor');
|
||||
private readonly rekorUrl = signal('https://rekor.sigstore.dev');
|
||||
|
||||
// Cache for verified chains
|
||||
private readonly chainCache = new Map<string, Observable<AttestationChain>>();
|
||||
private readonly cacheMaxAge = 300_000; // 5 minutes
|
||||
|
||||
verify(request: AttestationVerifyRequest): Observable<AttestationVerifyResult> {
|
||||
const url = `${this.baseUrl()}/verify`;
|
||||
const headers = this.buildHeaders();
|
||||
|
||||
return this.http.post<AttestationVerifyResult>(url, request, { headers }).pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
getChain(digest: string, options?: AttestationQueryOptions): Observable<AttestationChain> {
|
||||
const cacheKey = `chain:${digest}`;
|
||||
|
||||
if (this.chainCache.has(cacheKey)) {
|
||||
return this.chainCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl()}/chains/${encodeURIComponent(digest)}`;
|
||||
const params = this.buildParams(options);
|
||||
const headers = this.buildHeaders(options);
|
||||
|
||||
const request$ = this.http.get<AttestationChain>(url, { params, headers }).pipe(
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
catchError(this.handleError)
|
||||
);
|
||||
|
||||
this.chainCache.set(cacheKey, request$);
|
||||
setTimeout(() => this.chainCache.delete(cacheKey), this.cacheMaxAge);
|
||||
|
||||
return request$;
|
||||
}
|
||||
|
||||
getNode(nodeId: string, options?: AttestationQueryOptions): Observable<AttestationNode> {
|
||||
const url = `${this.baseUrl()}/nodes/${encodeURIComponent(nodeId)}`;
|
||||
const params = this.buildParams(options);
|
||||
const headers = this.buildHeaders(options);
|
||||
|
||||
return this.http.get<AttestationNode>(url, { params, headers }).pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
listBySubject(
|
||||
subjectDigest: string,
|
||||
options?: AttestationQueryOptions
|
||||
): Observable<AttestationNode[]> {
|
||||
const url = `${this.baseUrl()}/subjects/${encodeURIComponent(subjectDigest)}/attestations`;
|
||||
const params = this.buildParams(options);
|
||||
const headers = this.buildHeaders(options);
|
||||
|
||||
return this.http.get<{ items: AttestationNode[] }>(url, { params, headers }).pipe(
|
||||
map((response) => response.items),
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
getRekorEntry(uuid: string): Observable<RekorLogEntry> {
|
||||
const url = `${this.rekorUrl()}/api/v1/log/entries/${encodeURIComponent(uuid)}`;
|
||||
|
||||
return this.http.get<Record<string, unknown>>(url).pipe(
|
||||
map((response) => this.parseRekorResponse(uuid, response)),
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
downloadEnvelope(nodeId: string): Observable<DsseEnvelope> {
|
||||
const url = `${this.baseUrl()}/nodes/${encodeURIComponent(nodeId)}/envelope`;
|
||||
const headers = this.buildHeaders();
|
||||
|
||||
return this.http.get<DsseEnvelope>(url, { headers }).pipe(catchError(this.handleError));
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached chain for a digest.
|
||||
*/
|
||||
invalidateCache(digest?: string): void {
|
||||
if (digest) {
|
||||
this.chainCache.delete(`chain:${digest}`);
|
||||
} else {
|
||||
this.chainCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private parseRekorResponse(uuid: string, response: Record<string, unknown>): RekorLogEntry {
|
||||
// Rekor returns { uuid: { body, integratedTime, logIndex, ... } }
|
||||
const entry = response[uuid] as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
uuid,
|
||||
log_index: entry['logIndex'] as number,
|
||||
log_id: entry['logID'] as string,
|
||||
integrated_time: new Date((entry['integratedTime'] as number) * 1000).toISOString(),
|
||||
signed_entry_timestamp: entry['verification'] as string,
|
||||
inclusion_proof: entry['inclusionProof']
|
||||
? {
|
||||
log_index: (entry['inclusionProof'] as Record<string, unknown>)['logIndex'] as number,
|
||||
root_hash: (entry['inclusionProof'] as Record<string, unknown>)['rootHash'] as string,
|
||||
tree_size: (entry['inclusionProof'] as Record<string, unknown>)['treeSize'] as number,
|
||||
hashes: (entry['inclusionProof'] as Record<string, unknown>)['hashes'] as string[],
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private buildParams(options?: AttestationQueryOptions): HttpParams {
|
||||
let params = new HttpParams();
|
||||
|
||||
if (options?.include_rekor) {
|
||||
params = params.set('include_rekor', 'true');
|
||||
}
|
||||
if (options?.include_cert_chain) {
|
||||
params = params.set('include_cert_chain', 'true');
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
private buildHeaders(options?: AttestationQueryOptions): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
const tenantId = options?.tenantId ?? this.tenantService?.activeTenantId();
|
||||
if (tenantId) {
|
||||
headers['X-Tenant-Id'] = tenantId;
|
||||
}
|
||||
|
||||
const traceId = options?.traceId ?? generateTraceId();
|
||||
headers['X-Trace-Id'] = traceId;
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private handleError(error: unknown): Observable<never> {
|
||||
console.error('[AttestationChainClient] API error:', error);
|
||||
return throwError(() => error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock implementation for testing and development.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AttestationChainMockClient implements AttestationChainApi {
|
||||
private readonly mockChain: AttestationChain = {
|
||||
chain_id: 'chain-mock-001',
|
||||
nodes: [
|
||||
{
|
||||
node_id: 'node-001',
|
||||
type: 'sbom',
|
||||
predicate_type: 'https://spdx.dev/Document',
|
||||
subjects: [
|
||||
{
|
||||
name: 'myapp:1.0.0',
|
||||
digest: { sha256: 'abc123def456...' },
|
||||
},
|
||||
],
|
||||
signer: {
|
||||
key_id: 'keyid:abc123',
|
||||
identity: 'build@example.com',
|
||||
algorithm: 'ecdsa-p256',
|
||||
trusted: true,
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
node_id: 'node-002',
|
||||
type: 'scan',
|
||||
predicate_type: 'https://stellaops.io/attestation/vuln-scan/v1',
|
||||
subjects: [
|
||||
{
|
||||
name: 'myapp:1.0.0',
|
||||
digest: { sha256: 'abc123def456...' },
|
||||
},
|
||||
],
|
||||
signer: {
|
||||
key_id: 'keyid:scanner001',
|
||||
identity: 'scanner@stellaops.io',
|
||||
algorithm: 'ecdsa-p256',
|
||||
trusted: true,
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
parent_id: 'node-001',
|
||||
},
|
||||
],
|
||||
status: 'verified',
|
||||
verified_at: new Date().toISOString(),
|
||||
rekor_entry: {
|
||||
log_index: 12345678,
|
||||
log_id: 'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d',
|
||||
uuid: 'mock-uuid-12345',
|
||||
integrated_time: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
verify(request: AttestationVerifyRequest): Observable<AttestationVerifyResult> {
|
||||
return of({
|
||||
valid: true,
|
||||
status: 'verified' as const,
|
||||
signer: {
|
||||
key_id: 'keyid:mock',
|
||||
identity: 'mock@example.com',
|
||||
trusted: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getChain(digest: string, options?: AttestationQueryOptions): Observable<AttestationChain> {
|
||||
return of({ ...this.mockChain, chain_id: `chain:${digest}` });
|
||||
}
|
||||
|
||||
getNode(nodeId: string, options?: AttestationQueryOptions): Observable<AttestationNode> {
|
||||
const node = this.mockChain.nodes.find((n) => n.node_id === nodeId);
|
||||
return node ? of(node) : throwError(() => new Error(`Node not found: ${nodeId}`));
|
||||
}
|
||||
|
||||
listBySubject(
|
||||
subjectDigest: string,
|
||||
options?: AttestationQueryOptions
|
||||
): Observable<AttestationNode[]> {
|
||||
return of(this.mockChain.nodes);
|
||||
}
|
||||
|
||||
getRekorEntry(uuid: string): Observable<RekorLogEntry> {
|
||||
return of(this.mockChain.rekor_entry!);
|
||||
}
|
||||
|
||||
downloadEnvelope(nodeId: string): Observable<DsseEnvelope> {
|
||||
return of({
|
||||
payloadType: 'YXBwbGljYXRpb24vdm5kLmluLXRvdG8ranNvbg==', // application/vnd.in-toto+json
|
||||
payload: btoa(JSON.stringify({ _type: 'mock', subject: [], predicateType: 'mock' })),
|
||||
signatures: [
|
||||
{
|
||||
keyid: 'keyid:mock',
|
||||
sig: 'mock-signature-base64',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Attestation Chain Models
|
||||
* Sprint: SPRINT_4100_0001_0001_triage_models
|
||||
* DSSE (Dead Simple Signing Envelope) and in-toto model types.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// DSSE Envelope Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* DSSE (Dead Simple Signing Envelope) structure.
|
||||
* @see https://github.com/secure-systems-lab/dsse
|
||||
*/
|
||||
export interface DsseEnvelope {
|
||||
/** Base64-encoded payload type URI. */
|
||||
readonly payloadType: string;
|
||||
/** Base64-encoded payload. */
|
||||
readonly payload: string;
|
||||
/** Array of signatures. */
|
||||
readonly signatures: readonly DsseSignature[];
|
||||
}
|
||||
|
||||
/**
|
||||
* DSSE signature structure.
|
||||
*/
|
||||
export interface DsseSignature {
|
||||
/** Key identifier (fingerprint, URI, or key ID). */
|
||||
readonly keyid: string;
|
||||
/** Base64-encoded signature. */
|
||||
readonly sig: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// in-toto Statement Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* in-toto Statement wrapper (v1.0).
|
||||
* @see https://github.com/in-toto/attestation
|
||||
*/
|
||||
export interface InTotoStatement<T = unknown> {
|
||||
/** Schema version, should be "https://in-toto.io/Statement/v1". */
|
||||
readonly _type: string;
|
||||
/** Subject artifacts this statement is about. */
|
||||
readonly subject: readonly InTotoSubject[];
|
||||
/** Predicate type URI. */
|
||||
readonly predicateType: string;
|
||||
/** Predicate payload (type depends on predicateType). */
|
||||
readonly predicate: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* in-toto Subject (artifact reference).
|
||||
*/
|
||||
export interface InTotoSubject {
|
||||
/** Artifact name or identifier. */
|
||||
readonly name: string;
|
||||
/** Digest map (algorithm → hex value). */
|
||||
readonly digest: Record<string, string>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Attestation Chain Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Attestation chain representing linked evidence.
|
||||
*/
|
||||
export interface AttestationChain {
|
||||
/** Chain identifier (root envelope digest). */
|
||||
readonly chain_id: string;
|
||||
/** Ordered list of attestation nodes in the chain. */
|
||||
readonly nodes: readonly AttestationNode[];
|
||||
/** Chain verification status. */
|
||||
readonly status: AttestationChainStatus;
|
||||
/** When the chain was verified. */
|
||||
readonly verified_at: string;
|
||||
/** Rekor log entry if transparency-logged. */
|
||||
readonly rekor_entry?: RekorLogEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single node in an attestation chain.
|
||||
*/
|
||||
export interface AttestationNode {
|
||||
/** Node identifier (envelope digest). */
|
||||
readonly node_id: string;
|
||||
/** Node type (sbom, scan, vex, policy, witness). */
|
||||
readonly type: AttestationNodeType;
|
||||
/** Predicate type URI from the statement. */
|
||||
readonly predicate_type: string;
|
||||
/** Subject digests this node attests. */
|
||||
readonly subjects: readonly InTotoSubject[];
|
||||
/** Key that signed this node. */
|
||||
readonly signer: SignerInfo;
|
||||
/** When this attestation was created. */
|
||||
readonly created_at: string;
|
||||
/** Parent node ID (for chain ordering). */
|
||||
readonly parent_id?: string;
|
||||
/** Node-specific metadata. */
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attestation node types.
|
||||
*/
|
||||
export type AttestationNodeType =
|
||||
| 'sbom'
|
||||
| 'scan'
|
||||
| 'vex'
|
||||
| 'policy'
|
||||
| 'witness'
|
||||
| 'provenance'
|
||||
| 'custom';
|
||||
|
||||
/**
|
||||
* Signer information.
|
||||
*/
|
||||
export interface SignerInfo {
|
||||
/** Key identifier. */
|
||||
readonly key_id: string;
|
||||
/** Signer identity (email, URI, etc.). */
|
||||
readonly identity?: string;
|
||||
/** Key algorithm (ecdsa-p256, ed25519, rsa-pss). */
|
||||
readonly algorithm?: string;
|
||||
/** Whether the key is from a trusted root. */
|
||||
readonly trusted: boolean;
|
||||
/** Certificate chain if using X.509. */
|
||||
readonly cert_chain?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain verification status.
|
||||
*/
|
||||
export type AttestationChainStatus =
|
||||
| 'verified'
|
||||
| 'signature_invalid'
|
||||
| 'chain_broken'
|
||||
| 'expired'
|
||||
| 'untrusted_signer'
|
||||
| 'pending';
|
||||
|
||||
// ============================================================================
|
||||
// Rekor Integration
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Rekor transparency log entry.
|
||||
*/
|
||||
export interface RekorLogEntry {
|
||||
/** Log index. */
|
||||
readonly log_index: number;
|
||||
/** Log ID (tree ID). */
|
||||
readonly log_id: string;
|
||||
/** Entry UUID. */
|
||||
readonly uuid: string;
|
||||
/** Integrated timestamp (RFC 3339). */
|
||||
readonly integrated_time: string;
|
||||
/** Inclusion proof. */
|
||||
readonly inclusion_proof?: RekorInclusionProof;
|
||||
/** Signed entry timestamp. */
|
||||
readonly signed_entry_timestamp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rekor Merkle tree inclusion proof.
|
||||
*/
|
||||
export interface RekorInclusionProof {
|
||||
/** Log index. */
|
||||
readonly log_index: number;
|
||||
/** Root hash. */
|
||||
readonly root_hash: string;
|
||||
/** Tree size at time of inclusion. */
|
||||
readonly tree_size: number;
|
||||
/** Merkle proof hashes. */
|
||||
readonly hashes: readonly string[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Verification Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Attestation verification request.
|
||||
*/
|
||||
export interface AttestationVerifyRequest {
|
||||
/** DSSE envelope to verify. */
|
||||
readonly envelope: DsseEnvelope;
|
||||
/** Expected predicate type (optional validation). */
|
||||
readonly expected_predicate_type?: string;
|
||||
/** Whether to verify Rekor inclusion. */
|
||||
readonly verify_rekor?: boolean;
|
||||
/** Trusted key IDs for signature verification. */
|
||||
readonly trusted_keys?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attestation verification result.
|
||||
*/
|
||||
export interface AttestationVerifyResult {
|
||||
/** Whether verification succeeded. */
|
||||
readonly valid: boolean;
|
||||
/** Verification status. */
|
||||
readonly status: AttestationChainStatus;
|
||||
/** Parsed statement (if signature valid). */
|
||||
readonly statement?: InTotoStatement;
|
||||
/** Signer information. */
|
||||
readonly signer?: SignerInfo;
|
||||
/** Rekor entry (if verified). */
|
||||
readonly rekor_entry?: RekorLogEntry;
|
||||
/** Error message (if failed). */
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Predicate Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Well-known predicate type URIs.
|
||||
*/
|
||||
export const PredicateTypes = {
|
||||
/** SPDX SBOM. */
|
||||
Spdx: 'https://spdx.dev/Document',
|
||||
/** CycloneDX SBOM. */
|
||||
CycloneDx: 'https://cyclonedx.org/bom',
|
||||
/** SLSA Provenance v1. */
|
||||
SlsaProvenance: 'https://slsa.dev/provenance/v1',
|
||||
/** StellaOps Vulnerability Scan. */
|
||||
VulnScan: 'https://stellaops.io/attestation/vuln-scan/v1',
|
||||
/** StellaOps Reachability Witness. */
|
||||
Witness: 'https://stellaops.io/attestation/witness/v1',
|
||||
/** StellaOps Policy Decision. */
|
||||
PolicyDecision: 'https://stellaops.io/attestation/policy-decision/v1',
|
||||
/** OpenVEX. */
|
||||
OpenVex: 'https://openvex.dev/ns/v0.2.0',
|
||||
} as const;
|
||||
|
||||
export type PredicateType = typeof PredicateTypes[keyof typeof PredicateTypes];
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Decodes base64-encoded DSSE payload.
|
||||
*/
|
||||
export function decodeDssePayload<T>(envelope: DsseEnvelope): T {
|
||||
const decoded = atob(envelope.payload);
|
||||
return JSON.parse(decoded) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the digest from a subject by algorithm preference.
|
||||
*/
|
||||
export function getSubjectDigest(
|
||||
subject: InTotoSubject,
|
||||
preferredAlgorithm: string = 'sha256'
|
||||
): string | undefined {
|
||||
return subject.digest[preferredAlgorithm] ?? Object.values(subject.digest)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a chain is fully verified.
|
||||
*/
|
||||
export function isChainVerified(chain: AttestationChain): boolean {
|
||||
return chain.status === 'verified';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets human-readable status label.
|
||||
*/
|
||||
export function getChainStatusLabel(status: AttestationChainStatus): string {
|
||||
switch (status) {
|
||||
case 'verified':
|
||||
return 'Verified';
|
||||
case 'signature_invalid':
|
||||
return 'Invalid Signature';
|
||||
case 'chain_broken':
|
||||
return 'Chain Broken';
|
||||
case 'expired':
|
||||
return 'Expired';
|
||||
case 'untrusted_signer':
|
||||
return 'Untrusted Signer';
|
||||
case 'pending':
|
||||
return 'Pending Verification';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
351
src/Web/StellaOps.Web/src/app/core/api/triage-evidence.client.ts
Normal file
351
src/Web/StellaOps.Web/src/app/core/api/triage-evidence.client.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Triage Evidence API Client
|
||||
* Sprint: SPRINT_4100_0001_0001_triage_models
|
||||
* Provides API client for fetching finding evidence from Scanner service.
|
||||
*/
|
||||
|
||||
import { Injectable, InjectionToken, inject, signal } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of, map, shareReplay, catchError, throwError } from 'rxjs';
|
||||
|
||||
import {
|
||||
FindingEvidenceResponse,
|
||||
FindingEvidenceRequest,
|
||||
FindingEvidenceListResponse,
|
||||
ComponentRef,
|
||||
ScoreExplanation,
|
||||
VexEvidence,
|
||||
BoundaryProof,
|
||||
EntrypointProof,
|
||||
} from './triage-evidence.models';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { generateTraceId } from './trace.util';
|
||||
|
||||
/**
|
||||
* Triage Evidence API interface.
|
||||
*/
|
||||
export interface TriageEvidenceApi {
|
||||
/** Get evidence for a specific finding. */
|
||||
getFindingEvidence(
|
||||
findingId: string,
|
||||
options?: TriageEvidenceQueryOptions
|
||||
): Observable<FindingEvidenceResponse>;
|
||||
|
||||
/** Get evidence by CVE ID. */
|
||||
getEvidenceByCve(
|
||||
cve: string,
|
||||
options?: TriageEvidenceQueryOptions
|
||||
): Observable<FindingEvidenceListResponse>;
|
||||
|
||||
/** Get evidence by component PURL. */
|
||||
getEvidenceByComponent(
|
||||
purl: string,
|
||||
options?: TriageEvidenceQueryOptions
|
||||
): Observable<FindingEvidenceListResponse>;
|
||||
|
||||
/** List all evidence with pagination. */
|
||||
list(
|
||||
options?: TriageEvidenceQueryOptions & PaginationOptions
|
||||
): Observable<FindingEvidenceListResponse>;
|
||||
|
||||
/** Get score explanation for a finding. */
|
||||
getScoreExplanation(
|
||||
findingId: string,
|
||||
options?: TriageEvidenceQueryOptions
|
||||
): Observable<ScoreExplanation>;
|
||||
|
||||
/** Get VEX evidence for a finding. */
|
||||
getVexEvidence(
|
||||
findingId: string,
|
||||
options?: TriageEvidenceQueryOptions
|
||||
): Observable<VexEvidence | null>;
|
||||
}
|
||||
|
||||
export interface TriageEvidenceQueryOptions {
|
||||
readonly tenantId?: string;
|
||||
readonly projectId?: string;
|
||||
readonly traceId?: string;
|
||||
readonly include_path?: boolean;
|
||||
readonly include_boundary?: boolean;
|
||||
readonly include_vex?: boolean;
|
||||
readonly include_score?: boolean;
|
||||
}
|
||||
|
||||
export interface PaginationOptions {
|
||||
readonly page?: number;
|
||||
readonly page_size?: number;
|
||||
}
|
||||
|
||||
export const TRIAGE_EVIDENCE_API = new InjectionToken<TriageEvidenceApi>('TRIAGE_EVIDENCE_API');
|
||||
|
||||
/**
|
||||
* HTTP implementation of the Triage Evidence API.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TriageEvidenceHttpClient implements TriageEvidenceApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly tenantService = inject(TenantActivationService, { optional: true });
|
||||
|
||||
private readonly baseUrl = signal('/api/v1/scanner');
|
||||
|
||||
// Cache for frequently accessed evidence
|
||||
private readonly evidenceCache = new Map<string, Observable<FindingEvidenceResponse>>();
|
||||
private readonly cacheMaxAge = 60_000; // 1 minute
|
||||
|
||||
getFindingEvidence(
|
||||
findingId: string,
|
||||
options?: TriageEvidenceQueryOptions
|
||||
): Observable<FindingEvidenceResponse> {
|
||||
const cacheKey = this.buildCacheKey('finding', findingId, options);
|
||||
|
||||
if (this.evidenceCache.has(cacheKey)) {
|
||||
return this.evidenceCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl()}/evidence/${encodeURIComponent(findingId)}`;
|
||||
const params = this.buildParams(options);
|
||||
const headers = this.buildHeaders(options);
|
||||
|
||||
const request$ = this.http.get<FindingEvidenceResponse>(url, { params, headers }).pipe(
|
||||
shareReplay({ bufferSize: 1, refCount: true }),
|
||||
catchError(this.handleError)
|
||||
);
|
||||
|
||||
this.evidenceCache.set(cacheKey, request$);
|
||||
setTimeout(() => this.evidenceCache.delete(cacheKey), this.cacheMaxAge);
|
||||
|
||||
return request$;
|
||||
}
|
||||
|
||||
getEvidenceByCve(
|
||||
cve: string,
|
||||
options?: TriageEvidenceQueryOptions
|
||||
): Observable<FindingEvidenceListResponse> {
|
||||
const url = `${this.baseUrl()}/evidence`;
|
||||
const params = this.buildParams({ ...options, cve });
|
||||
const headers = this.buildHeaders(options);
|
||||
|
||||
return this.http.get<FindingEvidenceListResponse>(url, { params, headers }).pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
getEvidenceByComponent(
|
||||
purl: string,
|
||||
options?: TriageEvidenceQueryOptions
|
||||
): Observable<FindingEvidenceListResponse> {
|
||||
const url = `${this.baseUrl()}/evidence`;
|
||||
const params = this.buildParams({ ...options, component_purl: purl });
|
||||
const headers = this.buildHeaders(options);
|
||||
|
||||
return this.http.get<FindingEvidenceListResponse>(url, { params, headers }).pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
list(
|
||||
options?: TriageEvidenceQueryOptions & PaginationOptions
|
||||
): Observable<FindingEvidenceListResponse> {
|
||||
const url = `${this.baseUrl()}/evidence`;
|
||||
const params = this.buildParams(options);
|
||||
const headers = this.buildHeaders(options);
|
||||
|
||||
return this.http.get<FindingEvidenceListResponse>(url, { params, headers }).pipe(
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
getScoreExplanation(
|
||||
findingId: string,
|
||||
options?: TriageEvidenceQueryOptions
|
||||
): Observable<ScoreExplanation> {
|
||||
return this.getFindingEvidence(findingId, { ...options, include_score: true }).pipe(
|
||||
map((evidence) => {
|
||||
if (!evidence.score_explain) {
|
||||
throw new Error(`No score explanation available for finding ${findingId}`);
|
||||
}
|
||||
return evidence.score_explain;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getVexEvidence(
|
||||
findingId: string,
|
||||
options?: TriageEvidenceQueryOptions
|
||||
): Observable<VexEvidence | null> {
|
||||
return this.getFindingEvidence(findingId, { ...options, include_vex: true }).pipe(
|
||||
map((evidence) => evidence.vex ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached evidence for a finding.
|
||||
*/
|
||||
invalidateCache(findingId?: string): void {
|
||||
if (findingId) {
|
||||
// Remove all cache entries for this finding
|
||||
for (const key of this.evidenceCache.keys()) {
|
||||
if (key.includes(findingId)) {
|
||||
this.evidenceCache.delete(key);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.evidenceCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private buildParams(options?: Record<string, unknown>): HttpParams {
|
||||
let params = new HttpParams();
|
||||
|
||||
if (options) {
|
||||
for (const [key, value] of Object.entries(options)) {
|
||||
if (value !== undefined && value !== null && key !== 'tenantId' && key !== 'traceId') {
|
||||
params = params.set(key, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
private buildHeaders(options?: TriageEvidenceQueryOptions): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
const tenantId = options?.tenantId ?? this.tenantService?.activeTenantId();
|
||||
if (tenantId) {
|
||||
headers['X-Tenant-Id'] = tenantId;
|
||||
}
|
||||
|
||||
const traceId = options?.traceId ?? generateTraceId();
|
||||
headers['X-Trace-Id'] = traceId;
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private buildCacheKey(type: string, id: string, options?: TriageEvidenceQueryOptions): string {
|
||||
const opts = JSON.stringify(options ?? {});
|
||||
return `${type}:${id}:${opts}`;
|
||||
}
|
||||
|
||||
private handleError(error: unknown): Observable<never> {
|
||||
console.error('[TriageEvidenceClient] API error:', error);
|
||||
return throwError(() => error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock implementation for testing and development.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TriageEvidenceMockClient implements TriageEvidenceApi {
|
||||
private readonly mockEvidence: FindingEvidenceResponse = {
|
||||
finding_id: 'finding-mock-001',
|
||||
cve: 'CVE-2021-44228',
|
||||
component: {
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1',
|
||||
name: 'log4j-core',
|
||||
version: '2.14.1',
|
||||
type: 'maven',
|
||||
},
|
||||
reachable_path: [
|
||||
'com.example.App.main',
|
||||
'com.example.Service.process',
|
||||
'org.apache.logging.log4j.Logger.log',
|
||||
],
|
||||
entrypoint: {
|
||||
type: 'http_handler',
|
||||
route: '/api/v1/process',
|
||||
method: 'POST',
|
||||
auth: 'required',
|
||||
fqn: 'com.example.Controller.process',
|
||||
},
|
||||
score_explain: {
|
||||
kind: 'stellaops_risk_v1',
|
||||
risk_score: 75.0,
|
||||
contributions: [
|
||||
{
|
||||
factor: 'cvss_base',
|
||||
weight: 5.0,
|
||||
raw_value: 10.0,
|
||||
contribution: 50.0,
|
||||
explanation: 'Critical CVSS base score',
|
||||
source: 'nvd',
|
||||
},
|
||||
{
|
||||
factor: 'reachability',
|
||||
weight: 1.0,
|
||||
raw_value: 25.0,
|
||||
contribution: 25.0,
|
||||
explanation: 'Reachable from HTTP entrypoint',
|
||||
source: 'scan',
|
||||
},
|
||||
],
|
||||
last_seen: new Date().toISOString(),
|
||||
algorithm_version: '1.0.0',
|
||||
summary: 'High risk (75/100) driven by cvss_base and reachability',
|
||||
},
|
||||
last_seen: new Date().toISOString(),
|
||||
attestation_refs: ['dsse:sha256:mock123'],
|
||||
};
|
||||
|
||||
getFindingEvidence(
|
||||
findingId: string,
|
||||
options?: TriageEvidenceQueryOptions
|
||||
): Observable<FindingEvidenceResponse> {
|
||||
return of({ ...this.mockEvidence, finding_id: findingId });
|
||||
}
|
||||
|
||||
getEvidenceByCve(
|
||||
cve: string,
|
||||
options?: TriageEvidenceQueryOptions
|
||||
): Observable<FindingEvidenceListResponse> {
|
||||
return of({
|
||||
items: [{ ...this.mockEvidence, cve }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
});
|
||||
}
|
||||
|
||||
getEvidenceByComponent(
|
||||
purl: string,
|
||||
options?: TriageEvidenceQueryOptions
|
||||
): Observable<FindingEvidenceListResponse> {
|
||||
return of({
|
||||
items: [
|
||||
{
|
||||
...this.mockEvidence,
|
||||
component: { ...this.mockEvidence.component!, purl },
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
});
|
||||
}
|
||||
|
||||
list(
|
||||
options?: TriageEvidenceQueryOptions & PaginationOptions
|
||||
): Observable<FindingEvidenceListResponse> {
|
||||
return of({
|
||||
items: [this.mockEvidence],
|
||||
total: 1,
|
||||
page: options?.page ?? 1,
|
||||
page_size: options?.page_size ?? 20,
|
||||
});
|
||||
}
|
||||
|
||||
getScoreExplanation(
|
||||
findingId: string,
|
||||
options?: TriageEvidenceQueryOptions
|
||||
): Observable<ScoreExplanation> {
|
||||
return of(this.mockEvidence.score_explain!);
|
||||
}
|
||||
|
||||
getVexEvidence(
|
||||
findingId: string,
|
||||
options?: TriageEvidenceQueryOptions
|
||||
): Observable<VexEvidence | null> {
|
||||
return of(null);
|
||||
}
|
||||
}
|
||||
265
src/Web/StellaOps.Web/src/app/core/api/triage-evidence.models.ts
Normal file
265
src/Web/StellaOps.Web/src/app/core/api/triage-evidence.models.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Triage Evidence Models
|
||||
* Sprint: SPRINT_4100_0001_0001_triage_models
|
||||
* Mirrors backend contracts from Scanner.WebService/Contracts/FindingEvidenceContracts.cs
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Core Evidence Response
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Unified evidence response for a finding, combining reachability, boundary,
|
||||
* VEX evidence, and score explanation.
|
||||
*/
|
||||
export interface FindingEvidenceResponse {
|
||||
readonly finding_id: string;
|
||||
readonly cve: string;
|
||||
readonly component?: ComponentRef;
|
||||
readonly reachable_path?: readonly string[];
|
||||
readonly entrypoint?: EntrypointProof;
|
||||
readonly boundary?: BoundaryProof;
|
||||
readonly vex?: VexEvidence;
|
||||
readonly score_explain?: ScoreExplanation;
|
||||
readonly last_seen: string; // ISO 8601
|
||||
readonly expires_at?: string;
|
||||
readonly attestation_refs?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference to a component (package) by PURL and version.
|
||||
*/
|
||||
export interface ComponentRef {
|
||||
readonly purl: string;
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly type: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Entrypoint Proof
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Proof of how code is exposed as an entrypoint.
|
||||
*/
|
||||
export interface EntrypointProof {
|
||||
readonly type: string; // http_handler, grpc_method, cli_command, etc.
|
||||
readonly route?: string;
|
||||
readonly method?: string;
|
||||
readonly auth?: string; // none, optional, required
|
||||
readonly phase?: string; // startup, runtime, shutdown
|
||||
readonly fqn: string;
|
||||
readonly location?: SourceLocation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source file location reference.
|
||||
*/
|
||||
export interface SourceLocation {
|
||||
readonly file: string;
|
||||
readonly line?: number;
|
||||
readonly column?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Boundary Proof
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Boundary proof describing surface exposure and controls.
|
||||
*/
|
||||
export interface BoundaryProof {
|
||||
readonly kind: string;
|
||||
readonly surface?: SurfaceDescriptor;
|
||||
readonly exposure?: ExposureDescriptor;
|
||||
readonly auth?: AuthDescriptor;
|
||||
readonly controls?: readonly ControlDescriptor[];
|
||||
readonly last_seen: string;
|
||||
readonly confidence: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes what attack surface is exposed.
|
||||
*/
|
||||
export interface SurfaceDescriptor {
|
||||
readonly type: string;
|
||||
readonly protocol?: string;
|
||||
readonly port?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes how the surface is exposed.
|
||||
*/
|
||||
export interface ExposureDescriptor {
|
||||
readonly level: string; // public, internal, private
|
||||
readonly internet_facing: boolean;
|
||||
readonly zone?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes authentication requirements.
|
||||
*/
|
||||
export interface AuthDescriptor {
|
||||
readonly required: boolean;
|
||||
readonly type?: string;
|
||||
readonly roles?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes a security control.
|
||||
*/
|
||||
export interface ControlDescriptor {
|
||||
readonly type: string;
|
||||
readonly active: boolean;
|
||||
readonly config?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VEX Evidence
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* VEX (Vulnerability Exploitability eXchange) evidence.
|
||||
*/
|
||||
export interface VexEvidence {
|
||||
readonly status: VexStatus;
|
||||
readonly justification?: string;
|
||||
readonly impact?: string;
|
||||
readonly action?: string;
|
||||
readonly attestation_ref?: string;
|
||||
readonly issued_at?: string;
|
||||
readonly expires_at?: string;
|
||||
readonly source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* VEX status values per OpenVEX specification.
|
||||
*/
|
||||
export type VexStatus = 'not_affected' | 'affected' | 'fixed' | 'under_investigation';
|
||||
|
||||
// ============================================================================
|
||||
// Score Explanation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Score explanation with additive breakdown of risk factors.
|
||||
*/
|
||||
export interface ScoreExplanation {
|
||||
readonly kind: string;
|
||||
readonly risk_score: number;
|
||||
readonly contributions?: readonly ScoreContribution[];
|
||||
readonly last_seen: string;
|
||||
readonly algorithm_version?: string;
|
||||
readonly evidence_ref?: string;
|
||||
readonly summary?: string;
|
||||
readonly modifiers?: readonly ScoreModifier[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual contribution to the risk score.
|
||||
*/
|
||||
export interface ScoreContribution {
|
||||
readonly factor: string;
|
||||
readonly weight: number;
|
||||
readonly raw_value: number;
|
||||
readonly contribution: number;
|
||||
readonly explanation?: string;
|
||||
readonly source?: string;
|
||||
readonly updated_at?: string;
|
||||
readonly confidence?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifier applied to the score after base calculation.
|
||||
*/
|
||||
export interface ScoreModifier {
|
||||
readonly type: string;
|
||||
readonly before: number;
|
||||
readonly after: number;
|
||||
readonly reason?: string;
|
||||
readonly policy_ref?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Well-known score factor names.
|
||||
*/
|
||||
export const ScoreFactors = {
|
||||
CvssBase: 'cvss_base',
|
||||
CvssEnvironmental: 'cvss_environmental',
|
||||
Epss: 'epss',
|
||||
Reachability: 'reachability',
|
||||
GateMultiplier: 'gate_multiplier',
|
||||
VexOverride: 'vex_override',
|
||||
TimeDecay: 'time_decay',
|
||||
ExposureSurface: 'exposure_surface',
|
||||
KnownExploitation: 'known_exploitation',
|
||||
AssetCriticality: 'asset_criticality',
|
||||
} as const;
|
||||
|
||||
export type ScoreFactor = typeof ScoreFactors[keyof typeof ScoreFactors];
|
||||
|
||||
// ============================================================================
|
||||
// Query Interfaces
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Request for finding evidence.
|
||||
*/
|
||||
export interface FindingEvidenceRequest {
|
||||
readonly finding_id?: string;
|
||||
readonly cve?: string;
|
||||
readonly component_purl?: string;
|
||||
readonly include_path?: boolean;
|
||||
readonly include_boundary?: boolean;
|
||||
readonly include_vex?: boolean;
|
||||
readonly include_score?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* List response for multiple findings.
|
||||
*/
|
||||
export interface FindingEvidenceListResponse {
|
||||
readonly items: readonly FindingEvidenceResponse[];
|
||||
readonly total: number;
|
||||
readonly page: number;
|
||||
readonly page_size: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Severity Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Returns severity label based on score.
|
||||
*/
|
||||
export function getSeverityLabel(score: number): 'critical' | 'high' | 'medium' | 'low' | 'minimal' {
|
||||
if (score >= 80) return 'critical';
|
||||
if (score >= 60) return 'high';
|
||||
if (score >= 40) return 'medium';
|
||||
if (score >= 20) return 'low';
|
||||
return 'minimal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns CSS class for severity.
|
||||
*/
|
||||
export function getSeverityClass(score: number): string {
|
||||
return `severity-${getSeverityLabel(score)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if VEX status indicates non-exploitability.
|
||||
*/
|
||||
export function isVexNotAffected(vex?: VexEvidence): boolean {
|
||||
return vex?.status === 'not_affected';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if VEX evidence is still valid (not expired).
|
||||
*/
|
||||
export function isVexValid(vex?: VexEvidence): boolean {
|
||||
if (!vex) return false;
|
||||
if (!vex.expires_at) return true;
|
||||
return new Date(vex.expires_at) > new Date();
|
||||
}
|
||||
Reference in New Issue
Block a user