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:
master
2025-12-18 13:15:13 +02:00
parent 7d5250238c
commit 00d2c99af9
118 changed files with 13463 additions and 151 deletions

View File

@@ -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',
},
],
});
}
}

View File

@@ -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';
}
}

View 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);
}
}

View 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();
}