up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -41,3 +41,7 @@
| UI-SIG-26-002 | DONE (2025-12-12) | Reachability Why drawer with deterministic call paths/timeline/evidence (MockSignalsClient). |
| UI-SIG-26-003 | DONE (2025-12-12) | SBOM Graph reachability halo overlay + time slider + legend (deterministic overlay state). |
| UI-SIG-26-004 | DONE (2025-12-12) | Reachability Center view (coverage/missing/stale) using deterministic fixture rows; swap to upstream datasets when published. |
| UI-TRIAGE-0215-001 | DONE (2025-12-12) | Triage artifacts list + workspace routes (`/triage/artifacts`, `/triage/artifacts/:artifactId`) with overview/reachability/policy/attestations tabs + signed evidence detail modal. |
| UI-VEX-0215-001 | DONE (2025-12-12) | VEX-first triage modal with scope/validity/evidence/review sections and bulk apply; wired via `src/app/core/api/vex-decisions.client.ts`. |
| UI-AUDIT-0215-001 | DONE (2025-12-12) | Immutable audit bundle button + wizard/history views; download via `GET /v1/audit-bundles/{bundleId}` (`Accept: application/octet-stream`) using `src/app/core/api/audit-bundles.client.ts`. |
| WEB-TRIAGE-0215-001 | DONE (2025-12-12) | Added triage TS models + web SDK clients (VEX decisions, audit bundles, vuln-scan attestation predicate) and fixed `scripts/chrome-path.js` so `npm test` runs on Windows Playwright Chromium. |

View File

@@ -115,7 +115,11 @@ function candidatePaths(rootDir = join(__dirname, '..')) {
if (homePlaywrightBase && existsSync(homePlaywrightBase)) {
homeChromium = readdirSync(homePlaywrightBase)
.filter((d) => d.startsWith('chromium'))
.map((d) => join(homePlaywrightBase, d, 'chrome-linux', 'chrome'));
.flatMap((d) => [
join(homePlaywrightBase, d, 'chrome-linux', 'chrome'),
join(homePlaywrightBase, d, 'chrome-win', 'chrome.exe'),
join(homePlaywrightBase, d, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
]);
}
} catch {
homeChromium = [];

View File

@@ -30,6 +30,12 @@
<a routerLink="/vulnerabilities" routerLinkActive="active">
Vulnerabilities
</a>
<a routerLink="/triage/artifacts" routerLinkActive="active">
Triage
</a>
<a routerLink="/triage/audit-bundles" routerLinkActive="active">
Audit Bundles
</a>
<a routerLink="/graph" routerLinkActive="active">
SBOM Graph
</a>

View File

@@ -56,6 +56,18 @@ import {
VexEvidenceHttpClient,
MockVexEvidenceClient,
} from './core/api/vex-evidence.client';
import {
VEX_DECISIONS_API,
VEX_DECISIONS_API_BASE_URL,
VexDecisionsHttpClient,
MockVexDecisionsClient,
} from './core/api/vex-decisions.client';
import {
AUDIT_BUNDLES_API,
AUDIT_BUNDLES_API_BASE_URL,
AuditBundlesHttpClient,
MockAuditBundlesClient,
} from './core/api/audit-bundles.client';
import {
POLICY_EXCEPTIONS_API,
POLICY_EXCEPTIONS_API_BASE_URL,
@@ -263,6 +275,38 @@ export const appConfig: ApplicationConfig = {
useFactory: (config: AppConfigService, http: VexEvidenceHttpClient, mock: MockVexEvidenceClient) =>
config.config.quickstartMode ? mock : http,
},
{
provide: VEX_DECISIONS_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
},
},
VexDecisionsHttpClient,
MockVexDecisionsClient,
{
provide: VEX_DECISIONS_API,
deps: [AppConfigService, VexDecisionsHttpClient, MockVexDecisionsClient],
useFactory: (config: AppConfigService, http: VexDecisionsHttpClient, mock: MockVexDecisionsClient) =>
config.config.quickstartMode ? mock : http,
},
{
provide: AUDIT_BUNDLES_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
},
},
AuditBundlesHttpClient,
MockAuditBundlesClient,
{
provide: AUDIT_BUNDLES_API,
deps: [AppConfigService, AuditBundlesHttpClient, MockAuditBundlesClient],
useFactory: (config: AppConfigService, http: AuditBundlesHttpClient, mock: MockAuditBundlesClient) =>
config.config.quickstartMode ? mock : http,
},
{
provide: POLICY_EXCEPTIONS_API_BASE_URL,
deps: [AppConfigService],

View File

@@ -183,6 +183,38 @@ export const routes: Routes = [
(m) => m.VulnerabilityExplorerComponent
),
},
{
path: 'triage/artifacts',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/triage/triage-artifacts.component').then(
(m) => m.TriageArtifactsComponent
),
},
{
path: 'triage/artifacts/:artifactId',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/triage/triage-workspace.component').then(
(m) => m.TriageWorkspaceComponent
),
},
{
path: 'triage/audit-bundles',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/triage/triage-audit-bundles.component').then(
(m) => m.TriageAuditBundlesComponent
),
},
{
path: 'triage/audit-bundles/new',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/triage/triage-audit-bundle-new.component').then(
(m) => m.TriageAuditBundleNewComponent
),
},
{
path: 'vulnerabilities/:vulnId',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],

View File

@@ -0,0 +1,60 @@
// Types based on docs/schemas/attestation-vuln-scan.schema.json
export type InTotoStatementType = 'https://in-toto.io/Statement/v0.1';
export type VulnScanPredicateType = 'https://stella.ops/predicates/vuln-scan/v1';
export interface AttestationSubject {
readonly name: string;
readonly digest: Record<string, string>;
}
export interface ScannerInfo {
readonly name: string;
readonly version: string;
}
export interface ScannerDbInfo {
readonly lastUpdatedAt?: string;
}
export interface SeverityCounts {
readonly CRITICAL?: number;
readonly HIGH?: number;
readonly MEDIUM?: number;
readonly LOW?: number;
}
export interface FindingReport {
readonly mediaType: string;
readonly location: string;
readonly digest: Record<string, string>;
}
export interface VulnScanPredicate {
readonly scanner: ScannerInfo;
readonly scannerDb?: ScannerDbInfo;
readonly scanStartedAt: string;
readonly scanCompletedAt: string;
readonly severityCounts: SeverityCounts;
readonly findingReport: FindingReport;
}
export interface AttestationSigner {
readonly name: string;
readonly keyId: string;
}
export interface AttestationMeta {
readonly statementId: string;
readonly createdAt: string;
readonly signer: AttestationSigner;
}
export interface VulnScanAttestation {
readonly _type: InTotoStatementType;
readonly predicateType: VulnScanPredicateType;
readonly subject: readonly AttestationSubject[];
readonly predicate: VulnScanPredicate;
readonly attestationMeta: AttestationMeta;
}

View File

@@ -0,0 +1,193 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Inject, Injectable, InjectionToken, inject } from '@angular/core';
import { Observable, of, delay, map, catchError, throwError } from 'rxjs';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { generateTraceId } from './trace.util';
import type {
AuditBundleCreateRequest,
AuditBundleJobResponse,
AuditBundleListResponse,
} from './audit-bundles.models';
export interface AuditBundlesApi {
listBundles(): Observable<AuditBundleListResponse>;
createBundle(request: AuditBundleCreateRequest, options?: { traceId?: string; tenantId?: string; projectId?: string }): Observable<AuditBundleJobResponse>;
getBundle(bundleId: string, options?: { traceId?: string; tenantId?: string; projectId?: string }): Observable<AuditBundleJobResponse>;
downloadBundle(bundleId: string, options?: { traceId?: string; tenantId?: string; projectId?: string }): Observable<Blob>;
}
export const AUDIT_BUNDLES_API = new InjectionToken<AuditBundlesApi>('AUDIT_BUNDLES_API');
export const AUDIT_BUNDLES_API_BASE_URL = new InjectionToken<string>('AUDIT_BUNDLES_API_BASE_URL');
@Injectable({ providedIn: 'root' })
export class AuditBundlesHttpClient implements AuditBundlesApi {
private readonly tenantService = inject(TenantActivationService);
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
@Inject(AUDIT_BUNDLES_API_BASE_URL) private readonly baseUrl: string
) {}
listBundles(): Observable<AuditBundleListResponse> {
const tenant = this.resolveTenant();
const traceId = generateTraceId();
if (!this.tenantService.authorize('audit', 'read', ['audit:read'], this.tenantService.activeProjectId() ?? undefined, traceId)) {
return throwError(() => new Error('Unauthorized: missing audit:read scope'));
}
const headers = this.buildHeaders(tenant, traceId);
return this.http.get<AuditBundleListResponse>(`${this.baseUrl}/v1/audit-bundles`, { headers }).pipe(
map((resp) => ({ ...resp, traceId })),
catchError((err) => throwError(() => err))
);
}
createBundle(request: AuditBundleCreateRequest, options: { traceId?: string; tenantId?: string; projectId?: string } = {}): Observable<AuditBundleJobResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('audit', 'write', ['audit:write'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing audit:write scope'));
}
const headers = this.buildHeaders(tenant, traceId, options.projectId);
return this.http.post<AuditBundleJobResponse>(`${this.baseUrl}/v1/audit-bundles`, request, { headers }).pipe(
map((resp) => ({ ...resp, traceId })),
catchError((err) => throwError(() => err))
);
}
getBundle(bundleId: string, options: { traceId?: string; tenantId?: string; projectId?: string } = {}): Observable<AuditBundleJobResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('audit', 'read', ['audit:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing audit:read scope'));
}
const headers = this.buildHeaders(tenant, traceId, options.projectId);
return this.http.get<AuditBundleJobResponse>(`${this.baseUrl}/v1/audit-bundles/${encodeURIComponent(bundleId)}`, { headers }).pipe(
map((resp) => ({ ...resp, traceId })),
catchError((err) => throwError(() => err))
);
}
downloadBundle(bundleId: string, options: { traceId?: string; tenantId?: string; projectId?: string } = {}): Observable<Blob> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('audit', 'read', ['audit:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing audit:read scope'));
}
const headers = this.buildHeaders(tenant, traceId, options.projectId).set('Accept', 'application/octet-stream');
return this.http.get(`${this.baseUrl}/v1/audit-bundles/${encodeURIComponent(bundleId)}`, {
headers,
responseType: 'blob',
});
}
private buildHeaders(tenantId: string, traceId: string, projectId?: string): HttpHeaders {
let headers = new HttpHeaders({
'X-Stella-Tenant': tenantId,
'X-Stella-Trace-Id': traceId,
Accept: 'application/json',
});
if (projectId) headers = headers.set('X-Stella-Project', projectId);
const session = this.authSession.session();
if (session?.tokens.accessToken) {
headers = headers.set('Authorization', `Bearer ${session.tokens.accessToken}`);
}
return headers;
}
private resolveTenant(tenantId?: string): string {
const tenant = tenantId ?? this.tenantService.activeTenantId();
if (!tenant) throw new Error('AuditBundlesHttpClient requires an active tenant identifier.');
return tenant;
}
}
interface StoredAuditJob extends AuditBundleJobResponse {
readonly createdAtMs: number;
}
@Injectable({ providedIn: 'root' })
export class MockAuditBundlesClient implements AuditBundlesApi {
private readonly store: StoredAuditJob[] = [];
listBundles(): Observable<AuditBundleListResponse> {
const traceId = generateTraceId();
const items = [...this.store]
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : a.bundleId.localeCompare(b.bundleId)))
.map((job) => this.materialize(job));
return of({ items, count: items.length, traceId }).pipe(delay(150));
}
createBundle(request: AuditBundleCreateRequest, options: { traceId?: string } = {}): Observable<AuditBundleJobResponse> {
const traceId = options.traceId ?? generateTraceId();
const createdAt = new Date().toISOString();
const bundleId = this.allocateId();
const job: StoredAuditJob = {
bundleId,
status: 'queued',
createdAt,
subject: request.subject,
createdAtMs: Date.now(),
traceId,
};
this.store.push(job);
return of(this.materialize(job)).pipe(delay(200));
}
getBundle(bundleId: string): Observable<AuditBundleJobResponse> {
const job = this.store.find((j) => j.bundleId === bundleId);
if (!job) return throwError(() => new Error('Bundle not found'));
return of(this.materialize(job)).pipe(delay(150));
}
downloadBundle(bundleId: string): Observable<Blob> {
const job = this.store.find((j) => j.bundleId === bundleId);
if (!job) return throwError(() => new Error('Bundle not found'));
const payload = JSON.stringify(
{
bundleId: job.bundleId,
createdAt: job.createdAt,
subject: job.subject,
note: 'Mock bundle payload. Use /v1/audit-bundles/{bundleId} for real ZIP/OCI output.',
},
null,
2
);
return of(new Blob([payload], { type: 'application/json' })).pipe(delay(150));
}
private materialize(job: StoredAuditJob): AuditBundleJobResponse {
const elapsedMs = Date.now() - job.createdAtMs;
if (elapsedMs < 500) return job;
if (elapsedMs < 1500) return { ...job, status: 'processing' };
return {
...job,
status: 'completed',
sha256: 'sha256:mock-bundle-sha256',
integrityRootHash: 'sha256:mock-root-hash',
downloadUrl: `/v1/audit-bundles/${encodeURIComponent(job.bundleId)}`,
ociReference: `oci://stellaops/audit-bundles@${job.bundleId}`,
};
}
private allocateId(): string {
const seq = this.store.length + 1;
return `bndl-${seq.toString().padStart(4, '0')}`;
}
}

View File

@@ -0,0 +1,94 @@
export type AuditBundleApiVersion = 'stella.ops/v1';
export type AuditBundleKind = 'AuditBundleIndex';
export interface BundleActorRef {
readonly id: string;
readonly displayName: string;
}
export type BundleSubjectType = 'IMAGE' | 'REPO' | 'SBOM' | 'OTHER';
export interface BundleSubjectRef {
readonly type: BundleSubjectType;
readonly name: string;
readonly digest: Record<string, string>;
}
export type BundleArtifactType = 'VULN_REPORT' | 'SBOM' | 'VEX' | 'POLICY_EVAL' | 'OTHER';
export interface BundleArtifactAttestationRef {
readonly path: string;
readonly digest: Record<string, string>;
}
export interface BundleArtifact {
readonly id: string;
readonly type: BundleArtifactType;
readonly source: string;
readonly path: string;
readonly mediaType: string;
readonly digest: Record<string, string>;
readonly attestation?: BundleArtifactAttestationRef;
}
export type BundleVexStatus = 'NOT_AFFECTED' | 'AFFECTED_MITIGATED' | 'AFFECTED_UNMITIGATED' | 'FIXED';
export interface BundleVexDecisionEntry {
readonly decisionId: string;
readonly vulnerabilityId: string;
readonly status: BundleVexStatus;
readonly path: string;
readonly digest: Record<string, string>;
}
export interface BundleIntegrity {
readonly rootHash: string;
readonly hashAlgorithm: string;
}
export interface AuditBundleIndex {
readonly apiVersion: AuditBundleApiVersion;
readonly kind: AuditBundleKind;
readonly bundleId: string;
readonly createdAt: string;
readonly createdBy: BundleActorRef;
readonly subject: BundleSubjectRef;
readonly timeWindow?: { from?: string; to?: string };
readonly artifacts: readonly BundleArtifact[];
readonly vexDecisions?: readonly BundleVexDecisionEntry[];
readonly integrity?: BundleIntegrity;
}
export interface AuditBundleCreateRequest {
readonly subject: BundleSubjectRef;
readonly timeWindow?: { from?: string; to?: string };
readonly contents: {
readonly vulnReports: boolean;
readonly sbom: boolean;
readonly vex: boolean;
readonly policyEvals: boolean;
readonly attestations: boolean;
};
}
export type AuditBundleJobStatus = 'queued' | 'processing' | 'completed' | 'failed';
export interface AuditBundleJobResponse {
readonly bundleId: string;
readonly status: AuditBundleJobStatus;
readonly createdAt: string;
readonly subject: BundleSubjectRef;
readonly sha256?: string;
readonly integrityRootHash?: string;
readonly downloadUrl?: string;
readonly ociReference?: string;
readonly error?: string;
readonly traceId?: string;
}
export interface AuditBundleListResponse {
readonly items: readonly AuditBundleJobResponse[];
readonly count: number;
readonly traceId?: string;
}

View File

@@ -0,0 +1,227 @@
import { HttpClient, HttpHeaders, HttpParams, HttpResponse } from '@angular/common/http';
import { Inject, Injectable, InjectionToken, inject } from '@angular/core';
import { Observable, of, delay, map, catchError, throwError } from 'rxjs';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { generateTraceId } from './trace.util';
import type { VexDecision } from './evidence.models';
import type {
VexDecisionCreateRequest,
VexDecisionPatchRequest,
VexDecisionQueryOptions,
VexDecisionsResponse,
} from './vex-decisions.models';
export interface VexDecisionsApi {
listDecisions(options?: VexDecisionQueryOptions): Observable<VexDecisionsResponse>;
createDecision(request: VexDecisionCreateRequest, options?: VexDecisionQueryOptions): Observable<VexDecision>;
patchDecision(decisionId: string, request: VexDecisionPatchRequest, options?: VexDecisionQueryOptions): Observable<VexDecision>;
}
export const VEX_DECISIONS_API = new InjectionToken<VexDecisionsApi>('VEX_DECISIONS_API');
export const VEX_DECISIONS_API_BASE_URL = new InjectionToken<string>('VEX_DECISIONS_API_BASE_URL');
@Injectable({ providedIn: 'root' })
export class VexDecisionsHttpClient implements VexDecisionsApi {
private readonly tenantService = inject(TenantActivationService);
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
@Inject(VEX_DECISIONS_API_BASE_URL) private readonly baseUrl: string
) {}
listDecisions(options: VexDecisionQueryOptions = {}): Observable<VexDecisionsResponse> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('vex', 'read', ['vex:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing vex:read scope'));
}
const headers = this.buildHeaders(tenant, options.projectId, traceId, options.ifNoneMatch);
const params = this.buildQueryParams(options);
return this.http
.get<VexDecisionsResponse>(`${this.baseUrl}/v1/vex-decisions`, { headers, params, observe: 'response' })
.pipe(
map((resp: HttpResponse<VexDecisionsResponse>) => ({
...(resp.body ?? { items: [], count: 0, continuationToken: null }),
etag: resp.headers.get('ETag') ?? undefined,
traceId,
})),
catchError((err) => throwError(() => err))
);
}
createDecision(request: VexDecisionCreateRequest, options: VexDecisionQueryOptions = {}): Observable<VexDecision> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('vex', 'write', ['vex:write'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing vex:write scope'));
}
const headers = this.buildHeaders(tenant, options.projectId, traceId);
return this.http
.post<VexDecision>(`${this.baseUrl}/v1/vex-decisions`, request, { headers, observe: 'response' })
.pipe(
map((resp: HttpResponse<VexDecision>) => ({
...(resp.body as VexDecision),
updatedAt: resp.body?.updatedAt ?? resp.body?.createdAt,
})),
catchError((err) => throwError(() => err))
);
}
patchDecision(decisionId: string, request: VexDecisionPatchRequest, options: VexDecisionQueryOptions = {}): Observable<VexDecision> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('vex', 'write', ['vex:write'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing vex:write scope'));
}
const headers = this.buildHeaders(tenant, options.projectId, traceId);
return this.http
.patch<VexDecision>(
`${this.baseUrl}/v1/vex-decisions/${encodeURIComponent(decisionId)}`,
request,
{ headers, observe: 'response' }
)
.pipe(
map((resp: HttpResponse<VexDecision>) => resp.body as VexDecision),
catchError((err) => throwError(() => err))
);
}
private buildHeaders(tenantId: string, projectId?: string, traceId?: string, ifNoneMatch?: string): HttpHeaders {
let headers = new HttpHeaders({
'X-Stella-Tenant': tenantId,
'X-Stella-Trace-Id': traceId ?? generateTraceId(),
Accept: 'application/json',
});
if (projectId) headers = headers.set('X-Stella-Project', projectId);
if (ifNoneMatch) headers = headers.set('If-None-Match', ifNoneMatch);
const session = this.authSession.session();
if (session?.tokens.accessToken) {
headers = headers.set('Authorization', `Bearer ${session.tokens.accessToken}`);
}
return headers;
}
private buildQueryParams(options: VexDecisionQueryOptions): HttpParams {
let params = new HttpParams();
if (options.vulnerabilityId) params = params.set('vulnerabilityId', options.vulnerabilityId);
if (options.subjectName) params = params.set('subjectName', options.subjectName);
if (options.subjectDigest) params = params.set('subjectDigest', options.subjectDigest);
if (options.status) params = params.set('status', options.status);
if (options.limit) params = params.set('limit', String(options.limit));
if (options.continuationToken) params = params.set('continuationToken', options.continuationToken);
return params;
}
private resolveTenant(tenantId?: string): string {
const tenant = tenantId ?? this.tenantService.activeTenantId();
if (!tenant) throw new Error('VexDecisionsHttpClient requires an active tenant identifier.');
return tenant;
}
}
@Injectable({ providedIn: 'root' })
export class MockVexDecisionsClient implements VexDecisionsApi {
private readonly store: VexDecision[] = [
{
id: '2f76d3d4-1c4f-4c0f-8b4d-b4bdbb7e2b11',
vulnerabilityId: 'CVE-2021-45046',
subject: {
type: 'IMAGE',
name: 'asset-internal-001',
digest: { sha256: 'internal-001' },
},
status: 'NOT_AFFECTED',
justificationType: 'VULNERABLE_CODE_NOT_IN_EXECUTE_PATH',
justificationText: 'Reachability evidence indicates the vulnerable code is not in an execute path for this artifact.',
scope: { environments: ['dev'], projects: ['internal'] },
validFor: { notBefore: '2025-12-01T00:00:00Z', notAfter: '2026-06-01T00:00:00Z' },
evidenceRefs: [
{ type: 'TICKET', title: 'Triage note', url: 'https://tracker.local/TICKET-123' },
],
createdBy: { id: 'user-demo', displayName: 'Demo User' },
createdAt: '2025-12-01T00:00:00Z',
updatedAt: '2025-12-01T00:00:00Z',
},
];
listDecisions(options: VexDecisionQueryOptions = {}): Observable<VexDecisionsResponse> {
const traceId = options.traceId ?? generateTraceId();
let items = [...this.store];
if (options.vulnerabilityId) {
items = items.filter((d) => d.vulnerabilityId === options.vulnerabilityId);
}
if (options.subjectName) {
items = items.filter((d) => d.subject.name === options.subjectName);
}
if (options.status) {
items = items.filter((d) => d.status === options.status);
}
items.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : a.id.localeCompare(b.id)));
const limited = typeof options.limit === 'number' ? items.slice(0, options.limit) : items;
return of({
items: limited,
count: limited.length,
continuationToken: null,
traceId,
}).pipe(delay(150));
}
createDecision(request: VexDecisionCreateRequest, options: VexDecisionQueryOptions = {}): Observable<VexDecision> {
const createdAt = new Date().toISOString();
const decision: VexDecision = {
id: this.allocateId(),
vulnerabilityId: request.vulnerabilityId,
subject: request.subject,
status: request.status,
justificationType: request.justificationType,
justificationText: request.justificationText,
evidenceRefs: request.evidenceRefs,
scope: request.scope,
validFor: request.validFor,
createdBy: { id: 'user-demo', displayName: 'Demo User' },
createdAt,
updatedAt: createdAt,
};
this.store.push(decision);
return of(decision).pipe(delay(200));
}
patchDecision(decisionId: string, request: VexDecisionPatchRequest): Observable<VexDecision> {
const existing = this.store.find((d) => d.id === decisionId);
if (!existing) return throwError(() => new Error('Decision not found'));
const updated: VexDecision = {
...existing,
...request,
updatedAt: new Date().toISOString(),
};
const idx = this.store.findIndex((d) => d.id === decisionId);
this.store[idx] = updated;
return of(updated).pipe(delay(200));
}
private allocateId(): string {
const seq = this.store.length + 1;
return `00000000-0000-0000-0000-${seq.toString().padStart(12, '0')}`;
}
}

View File

@@ -0,0 +1,52 @@
import type {
VexDecision,
VexEvidenceRef,
VexJustificationType,
VexScope,
VexStatus,
VexSubjectRef,
VexValidFor,
} from './evidence.models';
export interface VexDecisionQueryOptions {
readonly tenantId?: string;
readonly projectId?: string;
readonly traceId?: string;
readonly ifNoneMatch?: string;
readonly vulnerabilityId?: string;
readonly subjectName?: string;
readonly subjectDigest?: string;
readonly status?: VexStatus;
readonly limit?: number;
readonly continuationToken?: string;
}
export interface VexDecisionsResponse {
readonly items: readonly VexDecision[];
readonly count: number;
readonly continuationToken: string | null;
readonly etag?: string;
readonly traceId?: string;
}
export interface VexDecisionCreateRequest {
readonly vulnerabilityId: string;
readonly subject: VexSubjectRef;
readonly status: VexStatus;
readonly justificationType: VexJustificationType;
readonly justificationText?: string;
readonly evidenceRefs?: readonly VexEvidenceRef[];
readonly scope?: VexScope;
readonly validFor?: VexValidFor;
}
export interface VexDecisionPatchRequest {
readonly status?: VexStatus;
readonly justificationType?: VexJustificationType;
readonly justificationText?: string;
readonly evidenceRefs?: readonly VexEvidenceRef[];
readonly scope?: VexScope;
readonly validFor?: VexValidFor;
readonly supersedesDecisionId?: string;
}

View File

@@ -18,6 +18,15 @@ import { RouterLink } from '@angular/router';
<a routerLink="/orchestrator/jobs" class="orch-job-detail__back">&larr; Back to Jobs</a>
<h1 class="orch-job-detail__title">Job Detail</h1>
<p class="orch-job-detail__id">ID: {{ jobId }}</p>
<div class="orch-job-detail__actions">
<a
routerLink="/triage/audit-bundles/new"
[queryParams]="{ jobId: jobId }"
class="orch-job-detail__btn"
>
Create immutable audit bundle
</a>
</div>
</header>
<div class="orch-job-detail__placeholder">
@@ -63,6 +72,29 @@ import { RouterLink } from '@angular/router';
font-family: monospace;
}
.orch-job-detail__actions {
margin-top: 1rem;
display: flex;
justify-content: center;
}
.orch-job-detail__btn {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
padding: 0.5rem 0.85rem;
border: 1px solid #d1d5db;
background: #fff;
color: #111827;
text-decoration: none;
font-weight: 600;
&:hover {
background: #f9fafb;
}
}
.orch-job-detail__placeholder {
padding: 3rem;
background: #f9fafb;

View File

@@ -1,25 +1,32 @@
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { PolicyApiService } from '../services/policy-api.service';
import { SimulationResult } from '../models/policy.models';
import { PolicyApiService } from '../services/policy-api.service';
import jsPDF from './jspdf.stub';
@Component({
selector: 'app-policy-explain',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="expl" [attr.aria-busy]="loading">
<header class="expl__header" *ngIf="result">
<div>
<p class="expl__eyebrow">Policy Studio · Explain</p>
<p class="expl__eyebrow">Policy Studio &middot; Explain</p>
<h1>Run {{ result.runId }}</h1>
<p class="expl__lede">Policy {{ result.policyId }} · Version {{ result.policyVersion }}</p>
<p class="expl__lede">Policy {{ result.policyId }} &middot; Version {{ result.policyVersion }}</p>
</div>
<div class="expl__meta">
<a
routerLink="/triage/audit-bundles/new"
[queryParams]="{ runId: result.runId, policyId: result.policyId }"
class="expl__btn"
>
Create immutable audit bundle
</a>
<button type="button" (click)="exportJson()">Export JSON</button>
<button type="button" (click)="exportPdf()">Export PDF</button>
</div>
@@ -33,7 +40,7 @@ import jsPDF from './jspdf.stub';
</header>
<ol>
<li *ngFor="let e of result.explainTrace">
<strong>Step {{ e.step }} · {{ e.ruleName }}</strong>
<strong>Step {{ e.step }} &middot; {{ e.ruleName }}</strong>
<span>Matched: {{ e.matched }}</span>
<span>Priority: {{ e.priority }}</span>
<div class="expl__json">
@@ -50,7 +57,8 @@ import jsPDF from './jspdf.stub';
</header>
<ul>
<li *ngFor="let f of sortedFindings">
{{ f.componentPurl }} · {{ f.advisoryId }} · {{ f.status }} · {{ f.severity.band | titlecase }}
{{ f.componentPurl }} &middot; {{ f.advisoryId }} &middot; {{ f.status }} &middot;
{{ f.severity.band | titlecase }}
</li>
</ul>
</section>
@@ -64,7 +72,9 @@ import jsPDF from './jspdf.stub';
.expl__header { display: flex; justify-content: space-between; align-items: center; }
.expl__eyebrow { margin: 0; color: #22d3ee; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.8rem; }
.expl__lede { margin: 0.2rem 0 0; color: #94a3b8; }
.expl__meta { display: flex; gap: 0.5rem; }
.expl__meta { display: flex; gap: 0.5rem; align-items: center; }
.expl__btn { display: inline-flex; align-items: center; border: 1px solid #1f2937; border-radius: 8px; padding: 0.35rem 0.65rem; color: #e5e7eb; text-decoration: none; background: #0b1224; }
.expl__btn:hover { border-color: #22d3ee; }
.expl__grid { display: grid; grid-template-columns: 2fr 1fr; gap: 1rem; margin-top: 1rem; }
.card { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; }
ol { margin: 0.5rem 0 0; padding-left: 1.25rem; }
@@ -132,3 +142,4 @@ export class PolicyExplainComponent {
doc.save(`policy-explain-${this.result.runId}.pdf`);
}
}

View File

@@ -0,0 +1,134 @@
<section class="triage-artifacts">
<header class="triage-artifacts__header">
<div>
<h1>Vulnerability Triage</h1>
<p class="triage-artifacts__subtitle">Artifact-first workflow with evidence and VEX-first decisioning.</p>
</div>
<div class="triage-artifacts__actions">
<button type="button" class="btn btn--secondary" (click)="load()" [disabled]="loading()">
Refresh
</button>
</div>
</header>
@if (error()) {
<div class="triage-artifacts__error" role="alert">
<strong>Error:</strong> {{ error() }}
</div>
}
<div class="triage-artifacts__toolbar">
<div class="search-box">
<input
type="text"
class="search-box__input"
placeholder="Search artifacts or environments..."
[value]="search()"
(input)="setSearch($any($event.target).value)"
/>
@if (search()) {
<button type="button" class="search-box__clear" (click)="setSearch('')">Clear</button>
}
</div>
<div class="filters">
<div class="filter-group">
<label class="filter-group__label">Environment</label>
<select
class="filter-group__select"
[value]="environment()"
(change)="setEnvironment($any($event.target).value)"
>
<option value="all">All</option>
@for (env of environmentOptions; track env) {
<option [value]="env">{{ env }}</option>
}
</select>
</div>
</div>
</div>
@if (loading()) {
<div class="triage-artifacts__loading">
<span class="spinner"></span>
<span>Loading artifacts...</span>
</div>
} @else {
<div class="triage-artifacts__table-wrap">
<table class="triage-table" *ngIf="filteredRows().length > 0; else emptyState">
<thead>
<tr>
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('artifact')">
Artifact {{ getSortIcon('artifact') }}
</th>
<th class="triage-table__th">Type</th>
<th class="triage-table__th">Environment(s)</th>
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('open')">
Open {{ getSortIcon('open') }}
</th>
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('total')">
Total {{ getSortIcon('total') }}
</th>
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('maxSeverity')">
Max severity {{ getSortIcon('maxSeverity') }}
</th>
<th class="triage-table__th">Attestations</th>
<th class="triage-table__th triage-table__th--sortable" (click)="toggleSort('lastScan')">
Last scan {{ getSortIcon('lastScan') }}
</th>
<th class="triage-table__th">Action</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows(); track row.artifactId) {
<tr class="triage-table__row">
<td class="triage-table__td">
<code class="artifact-id">{{ row.artifactId }}</code>
@if (row.readyToDeploy) {
<span class="ready-pill" title="All gates passed and required attestations verified (stub)">Ready to deploy</span>
}
</td>
<td class="triage-table__td">
<span class="chip chip--small">{{ row.type }}</span>
</td>
<td class="triage-table__td">
<span class="env-list">{{ row.environments.join(', ') }}</span>
</td>
<td class="triage-table__td">
<span class="count" [class.count--hot]="row.openVulns > 0">{{ row.openVulns }}</span>
</td>
<td class="triage-table__td">
<span class="count">{{ row.totalVulns }}</span>
</td>
<td class="triage-table__td">
<span class="chip chip--small" [class.chip--critical]="row.maxSeverity === 'critical'" [class.chip--high]="row.maxSeverity === 'high'">
{{ severityLabels[row.maxSeverity] }}
</span>
</td>
<td class="triage-table__td">
<span class="badge" [class.badge--ok]="row.attestationCount > 0" [class.badge--muted]="row.attestationCount === 0">
{{ row.attestationCount }}
</span>
</td>
<td class="triage-table__td">
<span class="when">{{ formatWhen(row.lastScanAt) }}</span>
</td>
<td class="triage-table__td triage-table__td--actions">
<button type="button" class="btn btn--small btn--primary" (click)="viewVulnerabilities(row)">
View vulnerabilities
</button>
</td>
</tr>
}
</tbody>
</table>
<ng-template #emptyState>
<div class="empty-state">
<p>No artifacts match your filters.</p>
</div>
</ng-template>
</div>
}
</section>

View File

@@ -0,0 +1,250 @@
.triage-artifacts {
padding: 1.5rem 1.75rem;
}
.triage-artifacts__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.triage-artifacts__subtitle {
margin: 0.25rem 0 0;
color: #6b7280;
}
.triage-artifacts__actions {
display: flex;
gap: 0.5rem;
}
.triage-artifacts__error {
border: 1px solid #fecaca;
background: #fef2f2;
color: #991b1b;
padding: 0.75rem 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.triage-artifacts__toolbar {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.search-box {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
min-width: min(520px, 100%);
}
.search-box__input {
width: 100%;
padding: 0.6rem 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.search-box__clear {
border: 1px solid #e5e7eb;
background: #fff;
border-radius: 8px;
padding: 0.35rem 0.6rem;
cursor: pointer;
}
.filters {
display: flex;
gap: 1rem;
flex-wrap: wrap;
align-items: center;
}
.filter-group__label {
display: block;
font-size: 0.75rem;
color: #6b7280;
margin-bottom: 0.25rem;
}
.filter-group__select {
padding: 0.45rem 0.6rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.triage-artifacts__loading {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 2rem 0;
color: #6b7280;
}
.spinner {
width: 1.2rem;
height: 1.2rem;
border: 2px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.triage-artifacts__table-wrap {
overflow: auto;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #fff;
}
.triage-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.triage-table__th {
text-align: left;
padding: 0.8rem 0.9rem;
border-bottom: 1px solid #e5e7eb;
color: #374151;
font-weight: 600;
user-select: none;
}
.triage-table__th--sortable {
cursor: pointer;
}
.triage-table__td {
padding: 0.75rem 0.9rem;
border-bottom: 1px solid #f3f4f6;
vertical-align: middle;
}
.triage-table__row:hover {
background: #f9fafb;
}
.triage-table__td--actions {
white-space: nowrap;
}
.artifact-id {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.85rem;
}
.ready-pill {
display: inline-flex;
align-items: center;
margin-left: 0.5rem;
padding: 0.15rem 0.45rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
background: #dcfce7;
color: #166534;
border: 1px solid #bbf7d0;
}
.chip {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: 999px;
background: #e5e7eb;
color: #111827;
font-weight: 600;
}
.chip--small {
font-size: 0.75rem;
}
.chip--critical {
background: #fee2e2;
color: #991b1b;
}
.chip--high {
background: #ffedd5;
color: #9a3412;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
border: 1px solid #e5e7eb;
}
.badge--ok {
background: #dcfce7;
border-color: #bbf7d0;
color: #166534;
}
.badge--muted {
background: #f3f4f6;
color: #6b7280;
}
.count--hot {
color: #b91c1c;
font-weight: 700;
}
.when {
color: #6b7280;
font-size: 0.82rem;
}
.btn {
border-radius: 8px;
padding: 0.45rem 0.75rem;
border: 1px solid transparent;
cursor: pointer;
font-weight: 600;
}
.btn--secondary {
border-color: #d1d5db;
background: #fff;
color: #111827;
}
.btn--primary {
background: #2563eb;
color: #fff;
}
.btn--small {
padding: 0.35rem 0.6rem;
font-size: 0.82rem;
}
.empty-state {
padding: 1.5rem;
color: #6b7280;
}

View File

@@ -0,0 +1,63 @@
import { ComponentFixture, TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
import { of } from 'rxjs';
import { VULNERABILITY_API, type VulnerabilityApi } from '../../core/api/vulnerability.client';
import type { Vulnerability } from '../../core/api/vulnerability.models';
import { TriageArtifactsComponent } from './triage-artifacts.component';
describe('TriageArtifactsComponent', () => {
let fixture: ComponentFixture<TriageArtifactsComponent>;
let component: TriageArtifactsComponent;
let api: jasmine.SpyObj<VulnerabilityApi>;
beforeEach(async () => {
api = jasmine.createSpyObj<VulnerabilityApi>('VulnerabilityApi', ['listVulnerabilities']);
const vulns: Vulnerability[] = [
{
vulnId: 'v-1',
cveId: 'CVE-2024-0001',
title: 'Test',
severity: 'critical',
status: 'open',
affectedComponents: [
{ purl: 'pkg:x', name: 'x', version: '1', assetIds: ['asset-web-prod'] },
],
},
{
vulnId: 'v-2',
cveId: 'CVE-2024-0002',
title: 'Test2',
severity: 'high',
status: 'fixed',
affectedComponents: [
{ purl: 'pkg:y', name: 'y', version: '1', assetIds: ['asset-web-prod', 'asset-api-prod'] },
],
},
];
api.listVulnerabilities.and.returnValue(of({ items: vulns, total: vulns.length }));
await TestBed.configureTestingModule({
imports: [TriageArtifactsComponent],
providers: [{ provide: VULNERABILITY_API, useValue: api }],
}).compileComponents();
fixture = TestBed.createComponent(TriageArtifactsComponent);
component = fixture.componentInstance;
});
it('aggregates vulnerabilities per artifact', fakeAsync(() => {
fixture.detectChanges();
flushMicrotasks();
const rows = component.rows();
const ids = rows.map((r) => r.artifactId).sort();
expect(ids).toEqual(['asset-api-prod', 'asset-web-prod']);
const web = rows.find((r) => r.artifactId === 'asset-web-prod')!;
expect(web.totalVulns).toBe(2);
expect(web.openVulns).toBe(1);
}));
});

View File

@@ -0,0 +1,249 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { Router } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { VULNERABILITY_API, type VulnerabilityApi } from '../../core/api/vulnerability.client';
import type { Vulnerability, VulnerabilitySeverity } from '../../core/api/vulnerability.models';
type SortField = 'artifact' | 'open' | 'total' | 'maxSeverity' | 'lastScan';
type SortOrder = 'asc' | 'desc';
const SEVERITY_LABELS: Record<VulnerabilitySeverity, string> = {
critical: 'Critical',
high: 'High',
medium: 'Medium',
low: 'Low',
unknown: 'Unknown',
};
const SEVERITY_ORDER: Record<VulnerabilitySeverity, number> = {
critical: 0,
high: 1,
medium: 2,
low: 3,
unknown: 4,
};
const ENVIRONMENT_HINTS = ['prod', 'dev', 'staging', 'internal', 'legacy', 'builder'] as const;
type EnvironmentHint = (typeof ENVIRONMENT_HINTS)[number];
export interface TriageArtifactRow {
readonly artifactId: string;
readonly type: 'container-image' | 'repository' | 'other';
readonly environments: readonly EnvironmentHint[];
readonly openVulns: number;
readonly totalVulns: number;
readonly maxSeverity: VulnerabilitySeverity;
readonly attestationCount: number;
readonly lastScanAt: string | null;
readonly readyToDeploy: boolean;
}
@Component({
selector: 'app-triage-artifacts',
standalone: true,
imports: [CommonModule],
templateUrl: './triage-artifacts.component.html',
styleUrls: ['./triage-artifacts.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TriageArtifactsComponent implements OnInit {
private readonly api = inject<VulnerabilityApi>(VULNERABILITY_API);
private readonly router = inject(Router);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly vulnerabilities = signal<readonly Vulnerability[]>([]);
readonly search = signal('');
readonly environment = signal<EnvironmentHint | 'all'>('all');
readonly sortField = signal<SortField>('maxSeverity');
readonly sortOrder = signal<SortOrder>('asc');
readonly environmentOptions = ENVIRONMENT_HINTS;
readonly severityLabels = SEVERITY_LABELS;
readonly rows = computed<readonly TriageArtifactRow[]>(() => {
const byArtifact = new Map<string, Vulnerability[]>();
for (const vuln of this.vulnerabilities()) {
for (const component of vuln.affectedComponents) {
for (const assetId of component.assetIds) {
const list = byArtifact.get(assetId);
if (list) {
list.push(vuln);
} else {
byArtifact.set(assetId, [vuln]);
}
}
}
}
const result: TriageArtifactRow[] = [];
for (const [artifactId, vulns] of byArtifact.entries()) {
const envs = this.deriveEnvironments(artifactId);
const openVulns = vulns.filter((v) => v.status === 'open' || v.status === 'in_progress').length;
const totalVulns = vulns.length;
const maxSeverity = this.computeMaxSeverity(vulns);
const lastScanAt = this.computeLastScanAt(vulns);
result.push({
artifactId,
type: this.deriveType(artifactId),
environments: envs,
openVulns,
totalVulns,
maxSeverity,
attestationCount: this.deriveAttestationCount(vulns),
lastScanAt,
readyToDeploy: openVulns === 0 && this.deriveAttestationCount(vulns) > 0,
});
}
return this.applySorting(result);
});
readonly filteredRows = computed<readonly TriageArtifactRow[]>(() => {
const q = this.search().trim().toLowerCase();
const env = this.environment();
return this.rows().filter((row) => {
if (env !== 'all' && !row.environments.includes(env)) return false;
if (!q) return true;
return row.artifactId.toLowerCase().includes(q) || row.environments.some((e) => e.includes(q));
});
});
async ngOnInit(): Promise<void> {
await this.load();
}
async load(): Promise<void> {
this.loading.set(true);
this.error.set(null);
try {
const resp = await firstValueFrom(this.api.listVulnerabilities({ includeReachability: true }));
this.vulnerabilities.set(resp.items);
} catch (err) {
this.error.set(err instanceof Error ? err.message : 'Failed to load vulnerabilities');
} finally {
this.loading.set(false);
}
}
setSearch(value: string): void {
this.search.set(value);
}
setEnvironment(value: EnvironmentHint | 'all'): void {
this.environment.set(value);
}
toggleSort(field: SortField): void {
if (this.sortField() === field) {
this.sortOrder.set(this.sortOrder() === 'asc' ? 'desc' : 'asc');
return;
}
this.sortField.set(field);
this.sortOrder.set('asc');
}
getSortIcon(field: SortField): string {
if (this.sortField() !== field) return '';
return this.sortOrder() === 'asc' ? '\u25B2' : '\u25BC';
}
viewVulnerabilities(row: TriageArtifactRow): void {
void this.router.navigate(['/triage/artifacts', row.artifactId]);
}
formatWhen(value: string | null): string {
if (!value) return '\u2014';
try {
return new Date(value).toLocaleString();
} catch {
return value;
}
}
private applySorting(rows: readonly TriageArtifactRow[]): readonly TriageArtifactRow[] {
const field = this.sortField();
const order = this.sortOrder();
const sorted = [...rows].sort((a, b) => {
let cmp = 0;
switch (field) {
case 'artifact':
cmp = a.artifactId.localeCompare(b.artifactId);
break;
case 'open':
cmp = a.openVulns - b.openVulns;
break;
case 'total':
cmp = a.totalVulns - b.totalVulns;
break;
case 'maxSeverity':
cmp = SEVERITY_ORDER[a.maxSeverity] - SEVERITY_ORDER[b.maxSeverity];
break;
case 'lastScan':
cmp = (a.lastScanAt ?? '').localeCompare(b.lastScanAt ?? '');
break;
default:
cmp = 0;
}
if (cmp !== 0) return order === 'asc' ? cmp : -cmp;
// stable tie-breakers
return a.artifactId.localeCompare(b.artifactId);
});
// default "maxSeverity" should show most severe first
if (field === 'maxSeverity' && order === 'asc') {
return sorted;
}
return sorted;
}
private computeMaxSeverity(vulns: readonly Vulnerability[]): VulnerabilitySeverity {
let best: VulnerabilitySeverity = 'unknown';
for (const v of vulns) {
if (SEVERITY_ORDER[v.severity] < SEVERITY_ORDER[best]) best = v.severity;
}
return best;
}
private computeLastScanAt(vulns: readonly Vulnerability[]): string | null {
const dates = vulns
.map((v) => v.modifiedAt ?? v.publishedAt ?? null)
.filter((v): v is string => typeof v === 'string');
if (dates.length === 0) return null;
return dates.reduce((max, cur) => (cur > max ? cur : max), dates[0]);
}
private deriveType(artifactId: string): TriageArtifactRow['type'] {
if (artifactId.startsWith('asset-')) return 'container-image';
return 'other';
}
private deriveEnvironments(artifactId: string): readonly EnvironmentHint[] {
const id = artifactId.toLowerCase();
const envs = ENVIRONMENT_HINTS.filter((env) => id.includes(env));
return envs.length > 0 ? envs : ['prod'];
}
private deriveAttestationCount(vulns: readonly Vulnerability[]): number {
// Deterministic placeholder: treat "fixed" and "excepted" as having signed evidence.
return vulns.filter((v) => v.status === 'fixed' || v.status === 'excepted').length;
}
}

View File

@@ -0,0 +1,62 @@
<div class="modal">
<div class="modal__backdrop" (click)="onClose()"></div>
<div class="modal__container" role="dialog" aria-modal="true" aria-label="Attestation detail">
<header class="modal__header">
<div>
<h2>Attestation</h2>
<p class="modal__subtitle">
<code>{{ attestation().attestationId }}</code>
&middot; {{ attestation().type }}
&middot;
<span [class.ok]="attestation().verified" [class.bad]="!attestation().verified">
{{ attestation().verified ? 'Verified' : 'Unverified' }}
</span>
</p>
</div>
<button type="button" class="modal__close" (click)="onClose()">Close</button>
</header>
<div class="modal__body">
<section class="section">
<h3>Summary</h3>
<dl class="kv">
<div>
<dt>Subject</dt>
<dd><code>{{ attestation().subject }}</code></dd>
</div>
<div>
<dt>Predicate</dt>
<dd><code>{{ attestation().predicateType }}</code></dd>
</div>
<div>
<dt>Signer</dt>
<dd>
<code>{{ attestation().signer.keyId }}</code>
<span class="trust" [class.trust--ok]="attestation().signer.trusted" [class.trust--bad]="!attestation().signer.trusted">
{{ attestation().signer.trusted ? 'Trusted' : 'Untrusted' }}
</span>
</dd>
</div>
<div>
<dt>Created</dt>
<dd>{{ attestation().createdAt }}</dd>
</div>
</dl>
@if (attestation().predicateSummary) {
<p class="hint">{{ attestation().predicateSummary }}</p>
}
</section>
<section class="section">
<h3>Verify</h3>
<p class="hint">Use CLI verification to reproduce the check offline:</p>
<pre class="cmd">{{ verifyCommand() }}</pre>
</section>
<section class="section">
<h3>Raw JSON</h3>
<pre class="json">{{ attestation().raw | json }}</pre>
</section>
</div>
</div>
</div>

View File

@@ -0,0 +1,139 @@
.modal {
position: fixed;
inset: 0;
z-index: 225;
display: grid;
place-items: center;
}
.modal__backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.65);
backdrop-filter: blur(2px);
}
.modal__container {
position: relative;
width: min(900px, calc(100% - 2rem));
max-height: calc(100vh - 2rem);
overflow: auto;
border-radius: 12px;
background: #0b1224;
color: #e5e7eb;
border: 1px solid #1f2937;
display: grid;
grid-template-rows: auto 1fr;
}
.modal__header {
padding: 1rem 1.25rem;
border-bottom: 1px solid #1f2937;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.modal__subtitle {
margin: 0.35rem 0 0;
color: #94a3b8;
font-size: 0.85rem;
}
.modal__close {
border: 1px solid #334155;
background: transparent;
color: #e5e7eb;
border-radius: 8px;
padding: 0.35rem 0.65rem;
cursor: pointer;
}
.modal__body {
padding: 1rem 1.25rem;
overflow: auto;
}
.section + .section {
margin-top: 1.15rem;
padding-top: 1.15rem;
border-top: 1px solid rgba(148, 163, 184, 0.18);
}
h3 {
margin: 0 0 0.6rem;
font-size: 0.95rem;
color: #e2e8f0;
}
.kv {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1.25rem;
margin: 0;
}
.kv dt {
font-size: 0.75rem;
color: #94a3b8;
}
.kv dd {
margin: 0.15rem 0 0;
}
.hint {
margin: 0.6rem 0 0;
color: #94a3b8;
font-size: 0.85rem;
}
.cmd {
margin: 0.5rem 0 0;
padding: 0.75rem;
border-radius: 10px;
border: 1px solid #334155;
background: #0f172a;
overflow: auto;
}
.json {
margin: 0.5rem 0 0;
padding: 0.85rem;
border-radius: 10px;
border: 1px solid #334155;
background: #0f172a;
overflow: auto;
max-height: 320px;
font-size: 0.82rem;
}
.trust {
margin-left: 0.5rem;
font-size: 0.75rem;
padding: 0.15rem 0.45rem;
border-radius: 999px;
border: 1px solid #334155;
}
.trust--ok {
background: rgba(34, 197, 94, 0.15);
border-color: rgba(34, 197, 94, 0.35);
color: #86efac;
}
.trust--bad {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.35);
color: #fca5a5;
}
.ok {
color: #86efac;
}
.bad {
color: #fca5a5;
}

View File

@@ -0,0 +1,32 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TriageAttestationDetailModalComponent } from './triage-attestation-detail-modal.component';
describe('TriageAttestationDetailModalComponent', () => {
let fixture: ComponentFixture<TriageAttestationDetailModalComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TriageAttestationDetailModalComponent],
}).compileComponents();
fixture = TestBed.createComponent(TriageAttestationDetailModalComponent);
fixture.componentRef.setInput('attestation', {
attestationId: 'att-1',
type: 'VULN_SCAN',
subject: 'asset-web-prod',
predicateType: 'stella.ops/predicates/vuln-scan/v1',
signer: { keyId: 'key-1', trusted: true },
createdAt: '2025-12-01T00:00:00Z',
verified: true,
raw: { hello: 'world' },
});
});
it('renders', () => {
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Attestation');
expect(fixture.nativeElement.textContent).toContain('att-1');
});
});

View File

@@ -0,0 +1,41 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
export interface TriageAttestationSigner {
readonly keyId: string;
readonly trusted: boolean;
}
export interface TriageAttestationDetail {
readonly attestationId: string;
readonly type: string;
readonly subject: string;
readonly predicateType: string;
readonly signer: TriageAttestationSigner;
readonly createdAt: string;
readonly verified: boolean;
readonly predicateSummary?: string;
readonly raw: unknown;
}
@Component({
selector: 'app-triage-attestation-detail-modal',
standalone: true,
imports: [CommonModule],
templateUrl: './triage-attestation-detail-modal.component.html',
styleUrls: ['./triage-attestation-detail-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TriageAttestationDetailModalComponent {
readonly attestation = input.required<TriageAttestationDetail>();
readonly close = output<void>();
onClose(): void {
this.close.emit();
}
verifyCommand(): string {
return `stella attest verify --id ${this.attestation().attestationId}`;
}
}

View File

@@ -0,0 +1,106 @@
<section class="wizard">
<header class="wizard__header">
<div>
<a class="back" routerLink="/triage/audit-bundles">&larr; Audit bundles</a>
<h1>Create audit bundle</h1>
<p class="subtitle">Build an immutable, signed evidence pack for a specific subject.</p>
</div>
</header>
@if (error()) {
<div class="error" role="alert">{{ error() }}</div>
}
<div class="steps">
<span class="step" [class.step--active]="step()==='subject'">1. Subject</span>
<span class="step" [class.step--active]="step()==='contents'">2. Contents</span>
<span class="step" [class.step--active]="step()==='review'">3. Review</span>
<span class="step" [class.step--active]="step()==='progress'">4. Progress</span>
</div>
@if (step() === 'subject') {
<div class="panel">
<h2>Subject</h2>
<div class="row">
<label class="field">
<span class="field__label">Type</span>
<select class="field__control" [value]="subjectType()" (change)="subjectType.set($any($event.target).value)">
<option value="IMAGE">IMAGE</option>
<option value="REPO">REPO</option>
<option value="SBOM">SBOM</option>
<option value="OTHER">OTHER</option>
</select>
</label>
<label class="field field--grow">
<span class="field__label">Name</span>
<input class="field__control" placeholder="registry/app@sha256:..." [value]="subjectName()" (input)="subjectName.set($any($event.target).value)" />
</label>
<label class="field field--grow">
<span class="field__label">Digest (sha256)</span>
<input class="field__control" placeholder="sha256:..." [value]="subjectDigest()" (input)="subjectDigest.set($any($event.target).value)" />
</label>
</div>
<h3>Time window (optional)</h3>
<div class="row">
<label class="field">
<span class="field__label">From (ISO-8601)</span>
<input class="field__control" placeholder="2025-11-14T00:00:00Z" [value]="from()" (input)="from.set($any($event.target).value)" />
</label>
<label class="field">
<span class="field__label">To (ISO-8601)</span>
<input class="field__control" placeholder="2025-11-21T09:05:00Z" [value]="to()" (input)="to.set($any($event.target).value)" />
</label>
</div>
</div>
}
@if (step() === 'contents') {
<div class="panel">
<h2>Contents</h2>
<label class="check"><input type="checkbox" [checked]="includeVulnReports()" (change)="includeVulnReports.set(!includeVulnReports())" /> Vulnerability reports</label>
<label class="check"><input type="checkbox" [checked]="includeSbom()" (change)="includeSbom.set(!includeSbom())" /> SBOM</label>
<label class="check"><input type="checkbox" [checked]="includeVex()" (change)="includeVex.set(!includeVex())" /> VEX</label>
<label class="check"><input type="checkbox" [checked]="includePolicyEvals()" (change)="includePolicyEvals.set(!includePolicyEvals())" /> Policy evaluations</label>
<label class="check"><input type="checkbox" [checked]="includeAttestations()" (change)="includeAttestations.set(!includeAttestations())" /> Attestations</label>
<p class="hint">Bundle index conforms to <code>docs/schemas/audit-bundle-index.schema.json</code>.</p>
</div>
}
@if (step() === 'review') {
<div class="panel">
<h2>Review</h2>
<p class="hint">Will generate signed bundle contents and an integrity root hash.</p>
<dl class="kv">
<div><dt>Subject</dt><dd><code>{{ subjectType() }}</code> <code>{{ subjectName() }}</code></dd></div>
<div><dt>Digest</dt><dd><code>{{ subjectDigest() }}</code></dd></div>
</dl>
<button type="button" class="btn btn--primary" (click)="create()" [disabled]="creating() || !canCreate()">
{{ creating() ? 'Creating...' : 'Create bundle' }}
</button>
</div>
}
@if (step() === 'progress') {
<div class="panel">
<h2>Progress</h2>
@if (!job()) {
<p class="hint">Waiting for job...</p>
} @else {
<dl class="kv">
<div><dt>Bundle</dt><dd><code>{{ job()!.bundleId }}</code></dd></div>
<div><dt>Status</dt><dd>{{ job()!.status }}</dd></div>
<div><dt>Hash</dt><dd><code>{{ job()!.sha256 || '-' }}</code></dd></div>
<div><dt>Root</dt><dd><code>{{ job()!.integrityRootHash || '-' }}</code></dd></div>
<div><dt>OCI</dt><dd><code>{{ job()!.ociReference || '-' }}</code></dd></div>
</dl>
<button type="button" class="btn btn--secondary" (click)="download()" [disabled]="job()!.status !== 'completed'">Download</button>
}
</div>
}
<footer class="wizard__footer" *ngIf="step() !== 'progress'">
<button type="button" class="btn btn--secondary" (click)="back()" [disabled]="step()==='subject'">Back</button>
<button type="button" class="btn btn--secondary" (click)="next()" [disabled]="step()==='review'">Next</button>
</footer>
</section>

View File

@@ -0,0 +1,142 @@
.wizard {
padding: 1.5rem 1.75rem;
max-width: 1100px;
}
.wizard__header {
margin-bottom: 1rem;
}
.back {
display: inline-block;
color: #2563eb;
text-decoration: none;
margin-bottom: 0.35rem;
}
.subtitle {
margin: 0.25rem 0 0;
color: #6b7280;
}
.steps {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
margin: 1rem 0;
}
.step {
border: 1px solid #e5e7eb;
background: #fff;
border-radius: 999px;
padding: 0.35rem 0.7rem;
font-weight: 700;
font-size: 0.82rem;
color: #6b7280;
}
.step--active {
border-color: #2563eb;
color: #2563eb;
}
.panel {
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #fff;
padding: 1rem;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-end;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 220px;
}
.field--grow {
flex: 1 1 320px;
}
.field__label {
font-size: 0.75rem;
color: #6b7280;
}
.field__control {
padding: 0.55rem 0.65rem;
border-radius: 8px;
border: 1px solid #e5e7eb;
}
.check {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.35rem 0;
}
.kv {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1.25rem;
margin: 0.75rem 0 0;
}
.kv dt {
font-size: 0.75rem;
color: #6b7280;
}
.kv dd {
margin: 0.15rem 0 0;
}
.hint {
margin: 0.6rem 0 0;
color: #6b7280;
font-size: 0.88rem;
}
.wizard__footer {
display: flex;
gap: 0.6rem;
margin-top: 1rem;
}
.btn {
border-radius: 8px;
padding: 0.45rem 0.75rem;
border: 1px solid transparent;
cursor: pointer;
font-weight: 600;
}
.btn--secondary {
border-color: #d1d5db;
background: #fff;
color: #111827;
}
.btn--primary {
background: #2563eb;
color: #fff;
}
.error {
border: 1px solid #fecaca;
background: #fef2f2;
color: #991b1b;
padding: 0.75rem 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}

View File

@@ -0,0 +1,46 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs';
import { AUDIT_BUNDLES_API, type AuditBundlesApi } from '../../core/api/audit-bundles.client';
import { TriageAuditBundleNewComponent } from './triage-audit-bundle-new.component';
describe('TriageAuditBundleNewComponent', () => {
let fixture: ComponentFixture<TriageAuditBundleNewComponent>;
let api: jasmine.SpyObj<AuditBundlesApi>;
beforeEach(async () => {
api = jasmine.createSpyObj<AuditBundlesApi>('AuditBundlesApi', ['createBundle', 'getBundle', 'downloadBundle', 'listBundles']);
api.createBundle.and.returnValue(of({
bundleId: 'bndl-0001',
status: 'queued',
createdAt: '2025-12-01T00:00:00Z',
subject: { type: 'IMAGE', name: 'asset-web-prod', digest: { sha256: 'x' } },
}));
api.getBundle.and.returnValue(of({
bundleId: 'bndl-0001',
status: 'completed',
createdAt: '2025-12-01T00:00:00Z',
subject: { type: 'IMAGE', name: 'asset-web-prod', digest: { sha256: 'x' } },
sha256: 'sha256:x',
}));
api.downloadBundle.and.returnValue(of(new Blob(['{}'], { type: 'application/json' })));
await TestBed.configureTestingModule({
imports: [RouterTestingModule, TriageAuditBundleNewComponent],
providers: [
{ provide: AUDIT_BUNDLES_API, useValue: api },
{ provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: new Map([['artifactId', 'asset-web-prod']]) } } },
],
}).compileComponents();
fixture = TestBed.createComponent(TriageAuditBundleNewComponent);
});
it('prefills subject name from query param', () => {
fixture.detectChanges();
expect(fixture.componentInstance.subjectName()).toBe('asset-web-prod');
});
});

View File

@@ -0,0 +1,147 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { Subscription, firstValueFrom, timer } from 'rxjs';
import { switchMap, takeWhile } from 'rxjs/operators';
import { AUDIT_BUNDLES_API, type AuditBundlesApi } from '../../core/api/audit-bundles.client';
import type { AuditBundleCreateRequest, AuditBundleJobResponse, BundleSubjectType } from '../../core/api/audit-bundles.models';
type WizardStep = 'subject' | 'contents' | 'review' | 'progress';
@Component({
selector: 'app-triage-audit-bundle-new',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './triage-audit-bundle-new.component.html',
styleUrls: ['./triage-audit-bundle-new.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TriageAuditBundleNewComponent implements OnInit, OnDestroy {
private readonly route = inject(ActivatedRoute);
private readonly api = inject<AuditBundlesApi>(AUDIT_BUNDLES_API);
readonly step = signal<WizardStep>('subject');
readonly error = signal<string | null>(null);
readonly job = signal<AuditBundleJobResponse | null>(null);
readonly creating = signal(false);
readonly subjectType = signal<BundleSubjectType>('IMAGE');
readonly subjectName = signal('');
readonly subjectDigest = signal('');
readonly from = signal('');
readonly to = signal('');
readonly includeVulnReports = signal(true);
readonly includeSbom = signal(true);
readonly includeVex = signal(true);
readonly includePolicyEvals = signal(true);
readonly includeAttestations = signal(true);
private pollSub: Subscription | null = null;
readonly canCreate = computed(() =>
this.subjectName().trim().length > 0 && this.subjectDigest().trim().length > 0
);
async ngOnInit(): Promise<void> {
const query = this.route.snapshot.queryParamMap;
const artifactId = query.get('artifactId');
const jobId = query.get('jobId');
const runId = query.get('runId');
if (artifactId) {
this.subjectName.set(artifactId);
this.subjectDigest.set(artifactId);
return;
}
if (jobId) {
this.subjectType.set('OTHER');
this.subjectName.set(`job:${jobId}`);
return;
}
if (runId) {
this.subjectType.set('OTHER');
this.subjectName.set(`policy-run:${runId}`);
}
}
ngOnDestroy(): void {
this.pollSub?.unsubscribe();
}
next(): void {
const step = this.step();
if (step === 'subject') this.step.set('contents');
else if (step === 'contents') this.step.set('review');
}
back(): void {
const step = this.step();
if (step === 'contents') this.step.set('subject');
else if (step === 'review') this.step.set('contents');
}
async create(): Promise<void> {
if (!this.canCreate()) return;
this.error.set(null);
this.creating.set(true);
const request: AuditBundleCreateRequest = {
subject: {
type: this.subjectType(),
name: this.subjectName().trim(),
digest: { sha256: this.subjectDigest().trim() },
},
timeWindow: this.from() || this.to() ? { from: this.from() || undefined, to: this.to() || undefined } : undefined,
contents: {
vulnReports: this.includeVulnReports(),
sbom: this.includeSbom(),
vex: this.includeVex(),
policyEvals: this.includePolicyEvals(),
attestations: this.includeAttestations(),
},
};
try {
const created = await firstValueFrom(this.api.createBundle(request));
this.job.set(created);
this.step.set('progress');
this.startPolling(created.bundleId);
} catch (err) {
this.error.set(err instanceof Error ? err.message : 'Failed to create bundle');
} finally {
this.creating.set(false);
}
}
private startPolling(bundleId: string): void {
this.pollSub?.unsubscribe();
this.pollSub = timer(0, 350)
.pipe(
switchMap(() => this.api.getBundle(bundleId)),
takeWhile((job) => job.status !== 'completed' && job.status !== 'failed', true)
)
.subscribe({
next: (job) => this.job.set(job),
error: (err) => this.error.set(err instanceof Error ? err.message : 'Polling failed'),
});
}
async download(): Promise<void> {
const job = this.job();
if (!job) return;
const blob = await firstValueFrom(this.api.downloadBundle(job.bundleId));
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${job.bundleId}.json`;
a.click();
URL.revokeObjectURL(url);
}
}

View File

@@ -0,0 +1,57 @@
<section class="audit-bundles">
<header class="audit-bundles__header">
<div>
<h1>Audit bundles</h1>
<p class="subtitle">Immutable, downloadable evidence bundles for audits and incident response.</p>
</div>
<div class="actions">
<a class="btn btn--primary" routerLink="/triage/audit-bundles/new">New bundle</a>
<button type="button" class="btn btn--secondary" (click)="load()" [disabled]="loading()">Refresh</button>
</div>
</header>
@if (error()) {
<div class="error" role="alert">{{ error() }}</div>
}
@if (loading()) {
<div class="loading">Loading bundles...</div>
} @else if (completedBundles().length === 0) {
<div class="empty">No bundles created yet.</div>
} @else {
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th>Bundle</th>
<th>Created</th>
<th>Subject</th>
<th>Status</th>
<th>Hash</th>
<th>OCI</th>
<th></th>
</tr>
</thead>
<tbody>
@for (b of completedBundles(); track b.bundleId) {
<tr>
<td><code>{{ b.bundleId }}</code></td>
<td>{{ b.createdAt }}</td>
<td><code>{{ b.subject.name }}</code></td>
<td>
<span class="badge" [class.badge--ok]="b.status==='completed'" [class.badge--warn]="b.status==='processing'" [class.badge--bad]="b.status==='failed'">
{{ b.status }}
</span>
</td>
<td><code>{{ b.sha256 || '-' }}</code></td>
<td><code>{{ b.ociReference || '-' }}</code></td>
<td>
<button type="button" class="btn btn--secondary" (click)="download(b)" [disabled]="b.status!=='completed'">Download</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>

View File

@@ -0,0 +1,105 @@
.audit-bundles {
padding: 1.5rem 1.75rem;
}
.audit-bundles__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.subtitle {
margin: 0.25rem 0 0;
color: #6b7280;
}
.actions {
display: flex;
gap: 0.6rem;
align-items: center;
}
.error {
border: 1px solid #fecaca;
background: #fef2f2;
color: #991b1b;
padding: 0.75rem 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.loading,
.empty {
padding: 1rem;
color: #6b7280;
}
.table-wrap {
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: auto;
background: #fff;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 0.75rem 0.85rem;
border-bottom: 1px solid #e5e7eb;
text-align: left;
vertical-align: top;
}
.badge {
padding: 0.15rem 0.45rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
border: 1px solid #e5e7eb;
}
.badge--ok {
background: #dcfce7;
border-color: #bbf7d0;
color: #166534;
}
.badge--warn {
background: #ffedd5;
border-color: #fed7aa;
color: #9a3412;
}
.badge--bad {
background: #fee2e2;
border-color: #fecaca;
color: #991b1b;
}
.btn {
border-radius: 8px;
padding: 0.45rem 0.75rem;
border: 1px solid transparent;
cursor: pointer;
font-weight: 600;
text-decoration: none;
display: inline-block;
}
.btn--secondary {
border-color: #d1d5db;
background: #fff;
color: #111827;
}
.btn--primary {
background: #2563eb;
color: #fff;
}

View File

@@ -0,0 +1,30 @@
import { ComponentFixture, TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs';
import { AUDIT_BUNDLES_API, type AuditBundlesApi } from '../../core/api/audit-bundles.client';
import { TriageAuditBundlesComponent } from './triage-audit-bundles.component';
describe('TriageAuditBundlesComponent', () => {
let fixture: ComponentFixture<TriageAuditBundlesComponent>;
let api: jasmine.SpyObj<AuditBundlesApi>;
beforeEach(async () => {
api = jasmine.createSpyObj<AuditBundlesApi>('AuditBundlesApi', ['listBundles', 'downloadBundle', 'createBundle', 'getBundle']);
api.listBundles.and.returnValue(of({ items: [], count: 0 }));
await TestBed.configureTestingModule({
imports: [RouterTestingModule, TriageAuditBundlesComponent],
providers: [{ provide: AUDIT_BUNDLES_API, useValue: api }],
}).compileComponents();
fixture = TestBed.createComponent(TriageAuditBundlesComponent);
});
it('loads bundles on init', fakeAsync(() => {
fixture.detectChanges();
flushMicrotasks();
expect(api.listBundles).toHaveBeenCalled();
}));
});

View File

@@ -0,0 +1,61 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { AUDIT_BUNDLES_API, type AuditBundlesApi } from '../../core/api/audit-bundles.client';
import type { AuditBundleJobResponse } from '../../core/api/audit-bundles.models';
@Component({
selector: 'app-triage-audit-bundles',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './triage-audit-bundles.component.html',
styleUrls: ['./triage-audit-bundles.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TriageAuditBundlesComponent implements OnInit {
private readonly api = inject<AuditBundlesApi>(AUDIT_BUNDLES_API);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly bundles = signal<readonly AuditBundleJobResponse[]>([]);
readonly completedBundles = computed(() =>
this.bundles()
.slice()
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
);
async ngOnInit(): Promise<void> {
await this.load();
}
async load(): Promise<void> {
this.loading.set(true);
this.error.set(null);
try {
const resp = await firstValueFrom(this.api.listBundles());
this.bundles.set(resp.items);
} catch (err) {
this.error.set(err instanceof Error ? err.message : 'Failed to load audit bundles');
} finally {
this.loading.set(false);
}
}
async download(bundle: AuditBundleJobResponse): Promise<void> {
try {
const blob = await firstValueFrom(this.api.downloadBundle(bundle.bundleId));
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${bundle.bundleId}.json`;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
this.error.set(err instanceof Error ? err.message : 'Download failed');
}
}
}

View File

@@ -0,0 +1,287 @@
<section class="triage-workspace">
<header class="triage-workspace__header">
<div>
<a class="back" routerLink="/triage/artifacts">&larr; Back to artifacts</a>
<h1>Artifact triage</h1>
<p class="subtitle">
Artifact: <code>{{ artifactId() }}</code>
&middot; Findings: {{ findings().length }}
</p>
</div>
<div class="actions">
<button type="button" class="btn btn--secondary" (click)="openAuditBundleWizard()">Create immutable audit bundle</button>
<button type="button" class="btn btn--secondary" (click)="load()" [disabled]="loading()">Refresh</button>
</div>
</header>
@if (error()) {
<div class="error" role="alert">{{ error() }}</div>
}
<div class="layout">
<aside class="left">
<div class="left__header">
<h2>Findings</h2>
<div class="bulk">
<button type="button" class="btn btn--secondary" (click)="openBulkVex()" [disabled]="bulkSelectionCount() === 0">
Bulk VEX ({{ bulkSelectionCount() }})
</button>
<button type="button" class="btn btn--ghost" (click)="clearBulkSelection()" [disabled]="bulkSelectionCount() === 0">
Clear
</button>
</div>
</div>
@if (loading()) {
<div class="loading">Loading findings...</div>
} @else if (findings().length === 0) {
<div class="empty">No findings for this artifact.</div>
} @else {
<div class="cards">
@for (finding of findings(); track finding.vuln.vulnId) {
<article
class="card"
[class.card--selected]="selectedVulnId() === finding.vuln.vulnId"
(click)="selectFinding(finding.vuln.vulnId)"
>
<header class="card__header">
<label class="bulk-check" (click)="$event.stopPropagation()">
<input
type="checkbox"
[checked]="selectedForBulk().includes(finding.vuln.vulnId)"
(change)="toggleBulkSelection(finding.vuln.vulnId)"
/>
</label>
<span class="sev" [class.sev--critical]="finding.vuln.severity === 'critical'" [class.sev--high]="finding.vuln.severity === 'high'">
{{ finding.vuln.severity.toUpperCase() }}
</span>
<code class="cve">{{ finding.vuln.cveId }}</code>
</header>
<div class="card__body">
<div class="pkg">
<span class="pkg__name">{{ finding.component?.name }}</span>
<span class="pkg__ver">{{ finding.component?.version }}</span>
</div>
<code class="path">{{ finding.component?.purl }}</code>
<div class="badges">
@if (isNewFinding(finding)) {
<span class="badge badge--new">New</span>
}
@if (getVexBadgeForFinding(finding); as vexBadge) {
<span class="badge badge--vex">{{ vexBadge }}</span>
}
@if (isPolicyBlocked(finding)) {
<span class="badge badge--blocked">Policy: blocked</span>
}
</div>
</div>
<footer class="card__footer" (click)="$event.stopPropagation()">
<button type="button" class="btn btn--ghost" title="Open fix workflow (stub)">Fix PR</button>
<button type="button" class="btn btn--secondary" (click)="openVexForFinding(finding.vuln.vulnId)">VEX</button>
<button type="button" class="btn btn--secondary" (click)="setTab('attestations')">Attach evidence</button>
<button type="button" class="pill" (click)="openAttestationDetail(attestationForFinding(finding))" *ngIf="hasSignedEvidence(finding)">
Signed evidence
</button>
</footer>
</article>
}
</div>
}
</aside>
<section class="right">
<header class="tabs">
<button type="button" class="tab" [class.tab--active]="activeTab() === 'overview'" (click)="setTab('overview')">Overview</button>
<button type="button" class="tab" [class.tab--active]="activeTab() === 'reachability'" (click)="setTab('reachability')">Reachability</button>
<button type="button" class="tab" [class.tab--active]="activeTab() === 'policy'" (click)="setTab('policy')">Policy</button>
<button type="button" class="tab" [class.tab--active]="activeTab() === 'attestations'" (click)="setTab('attestations')">Attestations</button>
</header>
<div class="panel">
@if (!selectedVuln()) {
<div class="empty">Select a finding to view evidence.</div>
} @else if (activeTab() === 'overview') {
<section class="section">
<h3>{{ selectedVuln()!.vuln.cveId }}</h3>
<p class="muted">{{ selectedVuln()!.vuln.title }}</p>
<dl class="kv">
<div>
<dt>Severity</dt>
<dd>{{ selectedVuln()!.vuln.severity }}</dd>
</div>
<div>
<dt>Status</dt>
<dd>{{ selectedVuln()!.vuln.status }}</dd>
</div>
<div>
<dt>Package</dt>
<dd>{{ selectedVuln()!.component?.name }} {{ selectedVuln()!.component?.version }}</dd>
</div>
<div>
<dt>Scanner/DB date</dt>
<dd>{{ selectedVuln()!.vuln.modifiedAt || selectedVuln()!.vuln.publishedAt || '-' }}</dd>
</div>
</dl>
</section>
<section class="section">
<h4>History</h4>
<ul class="timeline">
@if (selectedVuln()!.vuln.publishedAt) {
<li>Published: {{ selectedVuln()!.vuln.publishedAt }}</li>
}
@if (selectedVuln()!.vuln.modifiedAt) {
<li>Modified: {{ selectedVuln()!.vuln.modifiedAt }}</li>
}
</ul>
</section>
<section class="section">
<h4>Current VEX decision</h4>
@if (getVexBadgeForFinding(selectedVuln()!); as vexBadge) {
<p class="hint">{{ vexBadge }}</p>
} @else {
<p class="hint">No VEX decision recorded for this artifact + vulnerability.</p>
}
</section>
} @else if (activeTab() === 'reachability') {
<section class="section">
<h3>Reachability</h3>
<p class="hint">
Status: <strong>{{ selectedVuln()!.vuln.reachabilityStatus || 'unknown' }}</strong>
&middot; score {{ selectedVuln()!.vuln.reachabilityScore ?? 0 }}
</p>
<button type="button" class="btn btn--secondary" (click)="openReachabilityDrawer()" [disabled]="!selectedVuln()!.component">
View call paths
</button>
</section>
} @else if (activeTab() === 'policy') {
<section class="section">
<h3>Policy & gating</h3>
<p class="hint">Deterministic stub: replace with Policy Engine evaluation data.</p>
<table class="matrix">
<thead>
<tr>
<th>Gate</th>
<th>CI Build</th>
<th>Registry Admission</th>
<th>Runtime Admission</th>
</tr>
</thead>
<tbody>
<tr>
<td>Vulnerability gate</td>
@for (cell of vulnerabilityGateCells(); track cell.subjectType) {
<td>
<button type="button" class="cell" [class.pass]="cell.result==='PASS'" [class.warn]="cell.result==='WARN'" [class.fail]="cell.result==='FAIL'" (click)="selectPolicyCell(cell)">
{{ cell.result }}
</button>
</td>
}
</tr>
<tr>
<td>Admission gate</td>
@for (cell of admissionGateCells(); track cell.subjectType) {
<td>
<button type="button" class="cell" [class.pass]="cell.result==='PASS'" [class.warn]="cell.result==='WARN'" [class.fail]="cell.result==='FAIL'" (click)="selectPolicyCell(cell)">
{{ cell.result }}
</button>
</td>
}
</tr>
<tr>
<td>Runtime gate</td>
@for (cell of runtimeGateCells(); track cell.subjectType) {
<td>
<button type="button" class="cell" [class.pass]="cell.result==='PASS'" [class.warn]="cell.result==='WARN'" [class.fail]="cell.result==='FAIL'" (click)="selectPolicyCell(cell)">
{{ cell.result }}
</button>
</td>
}
</tr>
</tbody>
</table>
@if (selectedPolicyCell()) {
<div class="policy-detail">
<h4>{{ selectedPolicyCell()!.gate }} &middot; {{ selectedPolicyCell()!.subjectType }}</h4>
<p class="hint">This gate failed because: {{ selectedPolicyCell()!.explanation }}</p>
<p class="hint">Links: <a href="#" (click)="$event.preventDefault()">Gate definition</a> &middot; <a href="#" (click)="$event.preventDefault()">Recent evaluations</a></p>
</div>
}
</section>
} @else if (activeTab() === 'attestations') {
<section class="section">
<h3>Attestations</h3>
@if (attestationsForSelected().length === 0) {
<p class="hint">No attestations found for this finding.</p>
} @else {
<table class="attestations">
<thead>
<tr>
<th>Type</th>
<th>Subject</th>
<th>Predicate</th>
<th>Signer</th>
<th>Created</th>
<th>Verified</th>
<th></th>
</tr>
</thead>
<tbody>
@for (att of attestationsForSelected(); track att.attestationId) {
<tr>
<td>{{ att.type }}</td>
<td><code>{{ att.subject }}</code></td>
<td><code>{{ att.predicateType }}</code></td>
<td><code>{{ att.signer.keyId }}</code></td>
<td>{{ att.createdAt }}</td>
<td>
<span class="badge" [class.badge--ok]="att.verified" [class.badge--bad]="!att.verified">
{{ att.verified ? 'Verified' : 'Unverified' }}
</span>
</td>
<td>
<button type="button" class="btn btn--secondary" (click)="openAttestationDetail(att)">View</button>
</td>
</tr>
}
</tbody>
</table>
}
</section>
}
</div>
</section>
</div>
@if (showReachabilityDrawer()) {
<app-reachability-why-drawer
[open]="showReachabilityDrawer()"
[component]="reachabilityComponent()"
(close)="closeReachabilityDrawer()"
/>
}
@if (showVexModal()) {
<app-vex-decision-modal
[subject]="{ type: 'IMAGE', name: artifactId(), digest: { sha256: artifactId() } }"
[vulnerabilityIds]="vexTargetVulnerabilityIds()"
[availableAttestationIds]="availableAttestationIds()"
(closed)="closeVexModal()"
(saved)="onVexSaved($event)"
/>
}
@if (attestationModal()) {
<app-triage-attestation-detail-modal
[attestation]="attestationModal()!"
(close)="closeAttestationDetail()"
/>
}
</section>

View File

@@ -0,0 +1,373 @@
.triage-workspace {
padding: 1.5rem 1.75rem;
}
.triage-workspace__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.back {
display: inline-block;
color: #2563eb;
text-decoration: none;
margin-bottom: 0.35rem;
}
.subtitle {
margin: 0.25rem 0 0;
color: #6b7280;
}
.actions {
display: flex;
gap: 0.6rem;
}
.error {
border: 1px solid #fecaca;
background: #fef2f2;
color: #991b1b;
padding: 0.75rem 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.layout {
display: grid;
grid-template-columns: minmax(320px, 420px) 1fr;
gap: 1rem;
min-height: 70vh;
}
.left,
.right {
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #fff;
overflow: hidden;
display: flex;
flex-direction: column;
}
.left__header {
padding: 0.9rem 1rem;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.bulk {
display: flex;
gap: 0.5rem;
align-items: center;
}
.cards {
padding: 0.8rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
overflow: auto;
}
.card {
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 0.8rem 0.85rem;
cursor: pointer;
background: #fff;
}
.card--selected {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
.card__header {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.45rem;
}
.bulk-check input {
transform: translateY(1px);
}
.sev {
font-size: 0.72rem;
font-weight: 800;
padding: 0.18rem 0.45rem;
border-radius: 999px;
background: #f3f4f6;
color: #111827;
}
.sev--critical {
background: #fee2e2;
color: #991b1b;
}
.sev--high {
background: #ffedd5;
color: #9a3412;
}
.cve {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.84rem;
}
.pkg {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.pkg__name {
font-weight: 700;
}
.pkg__ver {
color: #6b7280;
font-size: 0.85rem;
}
.path {
display: block;
margin-top: 0.35rem;
color: #6b7280;
font-size: 0.78rem;
word-break: break-all;
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.5rem;
}
.badge {
font-size: 0.72rem;
font-weight: 700;
border-radius: 999px;
padding: 0.18rem 0.5rem;
background: #f3f4f6;
color: #374151;
}
.badge--new {
background: #dbeafe;
color: #1d4ed8;
}
.badge--vex {
background: #dcfce7;
color: #166534;
}
.badge--blocked {
background: #fee2e2;
color: #991b1b;
}
.card__footer {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-top: 0.75rem;
}
.pill {
border: 1px solid #d1d5db;
border-radius: 999px;
padding: 0.3rem 0.6rem;
background: #fff;
color: #111827;
font-size: 0.78rem;
cursor: pointer;
}
.tabs {
display: flex;
gap: 0.25rem;
padding: 0.75rem;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
}
.tab {
border: 1px solid #e5e7eb;
background: #fff;
border-radius: 999px;
padding: 0.35rem 0.7rem;
cursor: pointer;
font-weight: 600;
font-size: 0.85rem;
}
.tab--active {
border-color: #2563eb;
color: #2563eb;
}
.panel {
padding: 1rem;
overflow: auto;
}
.section + .section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #f3f4f6;
}
.muted {
color: #6b7280;
margin: 0.25rem 0 0.75rem;
}
.hint {
color: #6b7280;
font-size: 0.88rem;
margin: 0.35rem 0 0;
}
.kv {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1.25rem;
margin: 0;
}
.kv dt {
font-size: 0.75rem;
color: #6b7280;
}
.kv dd {
margin: 0.15rem 0 0;
}
.timeline {
margin: 0.25rem 0 0;
padding-left: 1rem;
color: #6b7280;
}
.matrix {
width: 100%;
border-collapse: collapse;
margin-top: 0.75rem;
}
.matrix th,
.matrix td {
border: 1px solid #e5e7eb;
padding: 0.5rem 0.6rem;
text-align: left;
}
.cell {
border-radius: 8px;
border: 1px solid #e5e7eb;
padding: 0.25rem 0.55rem;
cursor: pointer;
font-weight: 700;
background: #fff;
}
.cell.pass {
background: #dcfce7;
border-color: #bbf7d0;
color: #166534;
}
.cell.warn {
background: #ffedd5;
border-color: #fed7aa;
color: #9a3412;
}
.cell.fail {
background: #fee2e2;
border-color: #fecaca;
color: #991b1b;
}
.policy-detail {
margin-top: 0.9rem;
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #f9fafb;
}
.attestations {
width: 100%;
border-collapse: collapse;
margin-top: 0.6rem;
}
.attestations th,
.attestations td {
border-bottom: 1px solid #e5e7eb;
padding: 0.55rem 0.6rem;
text-align: left;
vertical-align: top;
}
.badge--ok {
background: #dcfce7;
border: 1px solid #bbf7d0;
color: #166534;
padding: 0.15rem 0.45rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
}
.badge--bad {
background: #fee2e2;
border: 1px solid #fecaca;
color: #991b1b;
padding: 0.15rem 0.45rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
}
.loading,
.empty {
padding: 1rem;
color: #6b7280;
}
.btn {
border-radius: 8px;
padding: 0.45rem 0.75rem;
border: 1px solid transparent;
cursor: pointer;
font-weight: 600;
}
.btn--secondary {
border-color: #d1d5db;
background: #fff;
color: #111827;
}
.btn--ghost {
border-color: transparent;
background: transparent;
color: #374151;
}

View File

@@ -0,0 +1,67 @@
import { ComponentFixture, TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs';
import { VULNERABILITY_API, type VulnerabilityApi } from '../../core/api/vulnerability.client';
import { VEX_DECISIONS_API, type VexDecisionsApi } from '../../core/api/vex-decisions.client';
import type { Vulnerability } from '../../core/api/vulnerability.models';
import { TriageWorkspaceComponent } from './triage-workspace.component';
describe('TriageWorkspaceComponent', () => {
let fixture: ComponentFixture<TriageWorkspaceComponent>;
let vulnApi: jasmine.SpyObj<VulnerabilityApi>;
let vexApi: jasmine.SpyObj<VexDecisionsApi>;
beforeEach(async () => {
vulnApi = jasmine.createSpyObj<VulnerabilityApi>('VulnerabilityApi', ['listVulnerabilities']);
vexApi = jasmine.createSpyObj<VexDecisionsApi>('VexDecisionsApi', ['listDecisions']);
const vulns: Vulnerability[] = [
{
vulnId: 'v-1',
cveId: 'CVE-2024-0001',
title: 'Test',
severity: 'high',
status: 'open',
affectedComponents: [
{ purl: 'pkg:x', name: 'x', version: '1', assetIds: ['asset-web-prod'] },
],
},
{
vulnId: 'v-2',
cveId: 'CVE-2024-0002',
title: 'Other asset',
severity: 'high',
status: 'open',
affectedComponents: [
{ purl: 'pkg:y', name: 'y', version: '1', assetIds: ['asset-api-prod'] },
],
},
];
vulnApi.listVulnerabilities.and.returnValue(of({ items: vulns, total: vulns.length }));
vexApi.listDecisions.and.returnValue(of({ items: [], count: 0, continuationToken: null }));
await TestBed.configureTestingModule({
imports: [RouterTestingModule, TriageWorkspaceComponent],
providers: [
{ provide: VULNERABILITY_API, useValue: vulnApi },
{ provide: VEX_DECISIONS_API, useValue: vexApi },
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: new Map([['artifactId', 'asset-web-prod']]) } } },
],
}).compileComponents();
fixture = TestBed.createComponent(TriageWorkspaceComponent);
});
it('filters findings by artifactId', fakeAsync(() => {
fixture.detectChanges();
flushMicrotasks();
const component = fixture.componentInstance;
expect(component.findings().length).toBe(1);
expect(component.findings()[0].vuln.vulnId).toBe('v-1');
}));
});

View File

@@ -0,0 +1,421 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import type { SeverityCounts, VulnScanAttestation } from '../../core/api/attestation-vuln-scan.models';
import { VULNERABILITY_API, type VulnerabilityApi } from '../../core/api/vulnerability.client';
import type { AffectedComponent, Vulnerability, VulnerabilitySeverity } from '../../core/api/vulnerability.models';
import type { VexDecision } from '../../core/api/evidence.models';
import { VEX_DECISIONS_API, type VexDecisionsApi } from '../../core/api/vex-decisions.client';
import { ReachabilityWhyDrawerComponent } from '../reachability/reachability-why-drawer.component';
import { VexDecisionModalComponent } from './vex-decision-modal.component';
import {
TriageAttestationDetailModalComponent,
type TriageAttestationDetail,
} from './triage-attestation-detail-modal.component';
type TabId = 'overview' | 'reachability' | 'policy' | 'attestations';
const SEVERITY_ORDER: Record<VulnerabilitySeverity, number> = {
critical: 0,
high: 1,
medium: 2,
low: 3,
unknown: 4,
};
const SUBJECT_TYPE_ORDER: Record<PolicyGateCell['subjectType'], number> = {
'CI Build': 0,
'Registry Admission': 1,
'Runtime Admission': 2,
};
interface FindingCardModel {
readonly vuln: Vulnerability;
readonly component: AffectedComponent;
}
interface PolicyGateCell {
readonly gate: string;
readonly subjectType: 'CI Build' | 'Registry Admission' | 'Runtime Admission';
readonly result: 'PASS' | 'WARN' | 'FAIL';
readonly explanation: string;
}
@Component({
selector: 'app-triage-workspace',
standalone: true,
imports: [
CommonModule,
RouterLink,
ReachabilityWhyDrawerComponent,
VexDecisionModalComponent,
TriageAttestationDetailModalComponent,
],
templateUrl: './triage-workspace.component.html',
styleUrls: ['./triage-workspace.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TriageWorkspaceComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly vulnApi = inject<VulnerabilityApi>(VULNERABILITY_API);
private readonly vexApi = inject<VexDecisionsApi>(VEX_DECISIONS_API);
readonly artifactId = signal<string>('');
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly vulns = signal<readonly Vulnerability[]>([]);
readonly vexDecisions = signal<readonly VexDecision[]>([]);
readonly selectedVulnId = signal<string | null>(null);
readonly selectedForBulk = signal<readonly string[]>([]);
readonly activeTab = signal<TabId>('overview');
readonly showVexModal = signal(false);
readonly vexTargetVulnerabilityIds = signal<readonly string[]>([]);
readonly showReachabilityDrawer = signal(false);
readonly reachabilityComponent = signal<string | null>(null);
readonly attestationModal = signal<TriageAttestationDetail | null>(null);
readonly selectedVuln = computed(() => {
const id = this.selectedVulnId();
return id ? this.findings().find((f) => f.vuln.vulnId === id) ?? null : null;
});
readonly findings = computed<readonly FindingCardModel[]>(() => {
const id = this.artifactId();
if (!id) return [];
const relevant = this.vulns()
.map((vuln) => {
const component = vuln.affectedComponents.find((c) => c.assetIds.includes(id));
return component ? ({ vuln, component } satisfies FindingCardModel) : null;
})
.filter((v): v is FindingCardModel => v !== null);
// deterministic ordering: severity, status, cveId
relevant.sort((a, b) => {
const sev = SEVERITY_ORDER[a.vuln.severity] - SEVERITY_ORDER[b.vuln.severity];
if (sev !== 0) return sev;
const cve = a.vuln.cveId.localeCompare(b.vuln.cveId);
if (cve !== 0) return cve;
return a.vuln.vulnId.localeCompare(b.vuln.vulnId);
});
return relevant;
});
readonly bulkSelectionCount = computed(() => this.selectedForBulk().length);
readonly availableAttestationIds = computed(() => {
const id = this.artifactId();
if (!id) return [];
return this.findings().map((f) => `att-${id}-${f.vuln.vulnId}`).sort((a, b) => a.localeCompare(b));
});
readonly attestationsForSelected = computed<readonly TriageAttestationDetail[]>(() => {
const selected = this.selectedVuln();
const artifactId = this.artifactId();
if (!selected || !artifactId) return [];
const base = this.buildMockAttestation(selected.vuln, artifactId);
return [base];
});
readonly policyCells = computed<readonly PolicyGateCell[]>(() => {
const findings = this.findings();
const hasCriticalOpen = findings.some((f) => f.vuln.severity === 'critical' && f.vuln.status === 'open');
const hasReachableOpen = findings.some((f) => f.vuln.status === 'open' && f.vuln.reachabilityStatus === 'reachable');
const hasAnyOpen = findings.some((f) => f.vuln.status === 'open' || f.vuln.status === 'in_progress');
return [
{
gate: 'Vulnerability gate',
subjectType: 'CI Build',
result: hasCriticalOpen ? 'FAIL' : hasAnyOpen ? 'WARN' : 'PASS',
explanation: hasCriticalOpen
? 'Build blocked: critical vulnerabilities present.'
: hasAnyOpen
? 'Build warning: open vulnerabilities present.'
: 'No open vulnerabilities.',
},
{
gate: 'Admission gate',
subjectType: 'Registry Admission',
result: hasReachableOpen ? 'FAIL' : hasAnyOpen ? 'WARN' : 'PASS',
explanation: hasReachableOpen
? 'Admission blocked: reachable vulnerabilities present.'
: hasAnyOpen
? 'Admission warning: open vulnerabilities present.'
: 'Admission clear.',
},
{
gate: 'Runtime gate',
subjectType: 'Runtime Admission',
result: hasAnyOpen ? 'WARN' : 'PASS',
explanation: hasAnyOpen
? 'Runtime warning: monitor open vulnerabilities and reachability evidence.'
: 'Runtime clear.',
},
];
});
readonly selectedPolicyCell = signal<PolicyGateCell | null>(null);
readonly vulnerabilityGateCells = computed(() =>
this.policyCells()
.filter((c) => c.gate === 'Vulnerability gate')
.slice()
.sort((a, b) => SUBJECT_TYPE_ORDER[a.subjectType] - SUBJECT_TYPE_ORDER[b.subjectType])
);
readonly admissionGateCells = computed(() =>
this.policyCells()
.filter((c) => c.gate === 'Admission gate')
.slice()
.sort((a, b) => SUBJECT_TYPE_ORDER[a.subjectType] - SUBJECT_TYPE_ORDER[b.subjectType])
);
readonly runtimeGateCells = computed(() =>
this.policyCells()
.filter((c) => c.gate === 'Runtime gate')
.slice()
.sort((a, b) => SUBJECT_TYPE_ORDER[a.subjectType] - SUBJECT_TYPE_ORDER[b.subjectType])
);
async ngOnInit(): Promise<void> {
const artifactId = this.route.snapshot.paramMap.get('artifactId') ?? '';
this.artifactId.set(artifactId);
await this.load();
await this.loadVexDecisions();
const first = this.findings()[0]?.vuln.vulnId ?? null;
this.selectedVulnId.set(first);
}
async load(): Promise<void> {
this.loading.set(true);
this.error.set(null);
try {
const resp = await firstValueFrom(this.vulnApi.listVulnerabilities({ includeReachability: true }));
this.vulns.set(resp.items);
} catch (err) {
this.error.set(err instanceof Error ? err.message : 'Failed to load findings');
} finally {
this.loading.set(false);
}
}
async loadVexDecisions(): Promise<void> {
const subjectName = this.artifactId();
if (!subjectName) return;
try {
const resp = await firstValueFrom(this.vexApi.listDecisions({ subjectName, limit: 200 }));
this.vexDecisions.set(resp.items);
} catch {
// Non-fatal: workspace should still render without VEX.
this.vexDecisions.set([]);
}
}
selectFinding(vulnId: string): void {
this.selectedVulnId.set(vulnId);
this.activeTab.set('overview');
}
toggleBulkSelection(vulnId: string): void {
const current = new Set(this.selectedForBulk());
if (current.has(vulnId)) {
current.delete(vulnId);
} else {
current.add(vulnId);
}
this.selectedForBulk.set([...current].sort((a, b) => a.localeCompare(b)));
}
clearBulkSelection(): void {
this.selectedForBulk.set([]);
}
openVexForFinding(vulnId: string): void {
const selected = this.findings().find((f) => f.vuln.vulnId === vulnId);
if (!selected) return;
this.vexTargetVulnerabilityIds.set([selected.vuln.cveId]);
this.showVexModal.set(true);
}
openBulkVex(): void {
const selectedIds = this.selectedForBulk();
if (selectedIds.length === 0) return;
const cves = this.findings()
.filter((f) => selectedIds.includes(f.vuln.vulnId))
.map((f) => f.vuln.cveId)
.sort((a, b) => a.localeCompare(b));
this.vexTargetVulnerabilityIds.set(cves);
this.showVexModal.set(true);
}
closeVexModal(): void {
this.showVexModal.set(false);
this.vexTargetVulnerabilityIds.set([]);
}
onVexSaved(decisions: readonly VexDecision[]): void {
const updated = [...this.vexDecisions(), ...decisions].sort((a, b) => {
const aWhen = a.updatedAt ?? a.createdAt;
const bWhen = b.updatedAt ?? b.createdAt;
const cmp = bWhen.localeCompare(aWhen);
return cmp !== 0 ? cmp : a.id.localeCompare(b.id);
});
this.vexDecisions.set(updated);
this.closeVexModal();
}
getVexBadgeForFinding(finding: FindingCardModel): string | null {
const artifactId = this.artifactId();
if (!artifactId) return null;
const matching = this.vexDecisions()
.filter((d) => d.vulnerabilityId === finding.vuln.cveId && d.subject.name === artifactId)
.sort((a, b) => {
const aWhen = a.updatedAt ?? a.createdAt;
const bWhen = b.updatedAt ?? b.createdAt;
const cmp = bWhen.localeCompare(aWhen);
return cmp !== 0 ? cmp : a.id.localeCompare(b.id);
})[0];
if (!matching) return null;
switch (matching.status) {
case 'NOT_AFFECTED':
return 'VEX: Not affected';
case 'AFFECTED_MITIGATED':
return 'VEX: Mitigated';
case 'AFFECTED_UNMITIGATED':
return 'VEX: Affected';
case 'FIXED':
return 'VEX: Fixed';
default:
return 'VEX';
}
}
isNewFinding(finding: FindingCardModel): boolean {
if (!finding.vuln.publishedAt) return false;
const ageDays = (Date.now() - Date.parse(finding.vuln.publishedAt)) / (1000 * 60 * 60 * 24);
return Number.isFinite(ageDays) && ageDays <= 14;
}
isPolicyBlocked(finding: FindingCardModel): boolean {
return finding.vuln.severity === 'critical' && (finding.vuln.status === 'open' || finding.vuln.status === 'in_progress');
}
openReachabilityDrawer(): void {
const selected = this.selectedVuln();
if (!selected?.component) return;
this.reachabilityComponent.set(selected.component.purl);
this.showReachabilityDrawer.set(true);
}
closeReachabilityDrawer(): void {
this.showReachabilityDrawer.set(false);
this.reachabilityComponent.set(null);
}
openAttestationDetail(attestation: TriageAttestationDetail): void {
this.attestationModal.set(attestation);
}
closeAttestationDetail(): void {
this.attestationModal.set(null);
}
openAuditBundleWizard(): void {
const artifactId = this.artifactId();
void this.router.navigate(['/triage/audit-bundles/new'], { queryParams: { artifactId } });
}
setTab(tab: TabId): void {
this.activeTab.set(tab);
}
selectPolicyCell(cell: PolicyGateCell): void {
this.selectedPolicyCell.set(cell);
}
private buildMockAttestation(vuln: Vulnerability, artifactId: string): TriageAttestationDetail {
const verified = vuln.status !== 'open';
const signer = verified
? { keyId: 'key-trusted-demo', trusted: true }
: { keyId: 'key-untrusted-demo', trusted: false };
const createdAt = vuln.modifiedAt ?? vuln.publishedAt ?? '2025-12-01T00:00:00Z';
const severityCounts: SeverityCounts =
vuln.severity === 'critical'
? { CRITICAL: 1 }
: vuln.severity === 'high'
? { HIGH: 1 }
: vuln.severity === 'medium'
? { MEDIUM: 1 }
: vuln.severity === 'low'
? { LOW: 1 }
: {};
const raw: VulnScanAttestation = {
_type: 'https://in-toto.io/Statement/v0.1',
predicateType: 'https://stella.ops/predicates/vuln-scan/v1',
subject: [{ name: artifactId, digest: { sha256: 'demo' } }],
predicate: {
scanner: { name: 'MockScanner', version: '0.0.1' },
scannerDb: { lastUpdatedAt: '2025-11-20T09:32:00Z' },
scanStartedAt: createdAt,
scanCompletedAt: createdAt,
severityCounts,
findingReport: {
mediaType: 'application/json',
location: `reports/${artifactId}/${vuln.cveId}.json`,
digest: { sha256: 'db569aa8a1b847a922b7d61d276cc2a0ccf99efad0879500b56854b43265c09a' },
},
},
attestationMeta: {
statementId: `att-${artifactId}-${vuln.vulnId}`,
createdAt,
signer: { name: 'ci/mock-signer', keyId: signer.keyId },
},
};
return {
attestationId: `att-${artifactId}-${vuln.vulnId}`,
type: 'VULN_SCAN',
subject: artifactId,
predicateType: raw.predicateType,
signer,
createdAt,
verified,
predicateSummary: `Evidence for ${vuln.cveId} (${vuln.severity}).`,
raw,
};
}
hasSignedEvidence(finding: FindingCardModel): boolean {
// Deterministic stub: treat fixed/excepted as having signed evidence.
return finding.vuln.status === 'fixed' || finding.vuln.status === 'excepted';
}
attestationForFinding(finding: FindingCardModel): TriageAttestationDetail {
const artifactId = this.artifactId();
return this.buildMockAttestation(finding.vuln, artifactId || 'unknown');
}
}

View File

@@ -0,0 +1,163 @@
<div class="modal">
<div class="modal__backdrop" (click)="close()"></div>
<div class="modal__container" role="dialog" aria-modal="true" aria-label="VEX decision">
<header class="modal__header">
<div>
<h2>VEX decision</h2>
<p class="modal__subtitle">
Subject: <code>{{ subject().name }}</code>
@if (isBulk()) {
&middot; applies to {{ vulnerabilityIds().length }} findings
} @else {
&middot; {{ vulnerabilityIds()[0] }}
}
</p>
</div>
<button type="button" class="modal__close" (click)="close()" [disabled]="loading()">Close</button>
</header>
<div class="modal__body">
@if (error()) {
<div class="modal__error" role="alert">{{ error() }}</div>
}
<section class="section">
<h3>Status</h3>
<div class="radio-grid">
@for (opt of statusOptions; track opt.value) {
<label class="radio">
<input type="radio" name="status" [checked]="status() === opt.value" (change)="status.set(opt.value)" />
<span>{{ opt.label }}</span>
</label>
}
</div>
</section>
<section class="section">
<h3>Justification</h3>
<div class="field-row">
<label class="field">
<span class="field__label">Type</span>
<select class="field__control" [value]="justificationType()" (change)="justificationType.set($any($event.target).value)">
@for (opt of justificationOptions; track opt.value) {
<option [value]="opt.value">{{ opt.label }}</option>
}
</select>
</label>
</div>
<label class="field">
<span class="field__label">Text</span>
<textarea class="field__control field__control--textarea" rows="3" [value]="justificationText()" (input)="justificationText.set($any($event.target).value)"></textarea>
</label>
</section>
<section class="section">
<h3>Scope</h3>
<div class="field-row">
<label class="field">
<span class="field__label">Environments</span>
<input class="field__control" placeholder="prod, dev, staging" [value]="environmentsText()" (input)="environmentsText.set($any($event.target).value)" />
</label>
<label class="field">
<span class="field__label">Projects</span>
<input class="field__control" placeholder="payments, platform" [value]="projectsText()" (input)="projectsText.set($any($event.target).value)" />
</label>
</div>
<p class="hint">
Preview:
<code>env={{ scopePreview().environments.join(', ') || 'all' }}</code>
<code>projects={{ scopePreview().projects.join(', ') || 'all' }}</code>
</p>
</section>
<section class="section">
<h3>Validity</h3>
<div class="field-row">
<label class="field">
<span class="field__label">Not before</span>
<input type="datetime-local" class="field__control" [value]="notBefore()" (change)="notBefore.set($any($event.target).value)" />
</label>
<label class="field">
<span class="field__label">Not after</span>
<input type="datetime-local" class="field__control" [value]="notAfter()" (change)="notAfter.set($any($event.target).value)" />
</label>
</div>
@if (validityPreview().warning) {
<p class="warning">{{ validityPreview().warning }}</p>
} @else {
<p class="hint">Tip: prefer short expiries with clear review cadence.</p>
}
</section>
<section class="section">
<h3>Evidence</h3>
<div class="field-row">
<label class="field">
<span class="field__label">Type</span>
<select class="field__control" [value]="evidenceType()" (change)="evidenceType.set($any($event.target).value)">
<option value="PR">PR</option>
<option value="TICKET">Ticket</option>
<option value="DOC">Doc</option>
<option value="COMMIT">Commit</option>
<option value="OTHER">Other</option>
</select>
</label>
<label class="field">
<span class="field__label">Title</span>
<input class="field__control" [value]="evidenceTitle()" (input)="evidenceTitle.set($any($event.target).value)" />
</label>
<label class="field field--grow">
<span class="field__label">URL</span>
<input class="field__control" placeholder="https://..." [value]="evidenceUrl()" (input)="evidenceUrl.set($any($event.target).value)" />
</label>
<button type="button" class="btn btn--secondary" (click)="addEvidence()">Add</button>
</div>
<div class="field-row">
<label class="field field--grow">
<span class="field__label">Attach attestation</span>
<select class="field__control" [value]="selectedAttestationId()" (change)="selectedAttestationId.set($any($event.target).value)">
<option value="">Select...</option>
@for (id of availableAttestationIds(); track id) {
<option [value]="id">{{ id }}</option>
}
</select>
</label>
<button type="button" class="btn btn--secondary" (click)="attachSelectedAttestation()" [disabled]="!selectedAttestationId()">Attach</button>
</div>
@if (evidenceRefs().length === 0) {
<p class="hint">No evidence linked yet.</p>
} @else {
<ul class="evidence-list">
@for (ref of evidenceRefs(); track ref.type + ':' + ref.url) {
<li>
<code>{{ ref.type }}</code>
<a [href]="ref.url" target="_blank" rel="noopener noreferrer">{{ ref.title || ref.url }}</a>
<button type="button" class="link" (click)="removeEvidence(ref)">Remove</button>
</li>
}
</ul>
}
</section>
<section class="section">
<h3>Review</h3>
<p class="hint">Will generate signed attestation on save.</p>
<button type="button" class="btn btn--secondary" (click)="viewRawJson.set(!viewRawJson())">
{{ viewRawJson() ? 'Hide raw JSON' : 'View raw JSON' }}
</button>
@if (viewRawJson()) {
<pre class="json-preview">{{ requestPreview() | json }}</pre>
}
</section>
</div>
<footer class="modal__footer">
<button type="button" class="btn btn--secondary" (click)="close()" [disabled]="loading()">Cancel</button>
<button type="button" class="btn btn--primary" (click)="save()" [disabled]="loading()">
{{ loading() ? 'Saving...' : 'Save decision' }}
</button>
</footer>
</div>
</div>

View File

@@ -0,0 +1,210 @@
.modal {
position: fixed;
inset: 0;
z-index: 220;
display: grid;
place-items: center;
}
.modal__backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.65);
backdrop-filter: blur(2px);
}
.modal__container {
position: relative;
width: min(880px, calc(100% - 2rem));
max-height: calc(100vh - 2rem);
overflow: auto;
border-radius: 12px;
background: #0b1224;
color: #e5e7eb;
border: 1px solid #1f2937;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
display: grid;
grid-template-rows: auto 1fr auto;
}
.modal__header {
padding: 1rem 1.25rem;
border-bottom: 1px solid #1f2937;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.modal__subtitle {
margin: 0.35rem 0 0;
color: #94a3b8;
font-size: 0.85rem;
}
.modal__close {
border: 1px solid #334155;
background: transparent;
color: #e5e7eb;
border-radius: 8px;
padding: 0.35rem 0.65rem;
cursor: pointer;
}
.modal__body {
padding: 1rem 1.25rem;
overflow: auto;
}
.modal__footer {
padding: 0.9rem 1.25rem;
border-top: 1px solid #1f2937;
display: flex;
justify-content: flex-end;
gap: 0.6rem;
}
.modal__error {
border: 1px solid #7f1d1d;
background: rgba(127, 29, 29, 0.2);
padding: 0.6rem 0.75rem;
border-radius: 8px;
margin-bottom: 0.9rem;
}
.section + .section {
margin-top: 1.1rem;
padding-top: 1.1rem;
border-top: 1px solid rgba(148, 163, 184, 0.18);
}
h3 {
margin: 0 0 0.6rem;
font-size: 0.95rem;
color: #e2e8f0;
}
.radio-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.55rem;
}
.radio {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
border: 1px solid #334155;
border-radius: 10px;
background: rgba(15, 23, 42, 0.4);
}
.field-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-end;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 180px;
}
.field--grow {
flex: 1 1 320px;
}
.field__label {
font-size: 0.75rem;
color: #94a3b8;
}
.field__control {
padding: 0.55rem 0.65rem;
border-radius: 8px;
border: 1px solid #334155;
background: #0f172a;
color: #e5e7eb;
}
.field__control--textarea {
resize: vertical;
min-height: 80px;
}
.hint {
margin: 0.5rem 0 0;
font-size: 0.82rem;
color: #94a3b8;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.warning {
margin: 0.5rem 0 0;
color: #fca5a5;
font-size: 0.85rem;
}
.evidence-list {
margin: 0.6rem 0 0;
padding-left: 1rem;
}
.evidence-list li {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
margin: 0.35rem 0;
}
.evidence-list a {
color: #60a5fa;
}
.link {
border: none;
background: transparent;
color: #93c5fd;
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.json-preview {
margin-top: 0.75rem;
padding: 0.85rem;
border-radius: 10px;
border: 1px solid #334155;
background: #0f172a;
color: #e5e7eb;
max-height: 220px;
overflow: auto;
font-size: 0.8rem;
}
.btn {
border-radius: 8px;
padding: 0.45rem 0.75rem;
border: 1px solid transparent;
cursor: pointer;
font-weight: 600;
}
.btn--secondary {
border-color: #334155;
background: transparent;
color: #e5e7eb;
}
.btn--primary {
background: #2563eb;
color: #fff;
}

View File

@@ -0,0 +1,42 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import type { VexDecisionsApi } from '../../core/api/vex-decisions.client';
import { VEX_DECISIONS_API } from '../../core/api/vex-decisions.client';
import { VexDecisionModalComponent } from './vex-decision-modal.component';
describe('VexDecisionModalComponent', () => {
let fixture: ComponentFixture<VexDecisionModalComponent>;
let component: VexDecisionModalComponent;
let api: jasmine.SpyObj<VexDecisionsApi>;
beforeEach(async () => {
api = jasmine.createSpyObj<VexDecisionsApi>('VexDecisionsApi', ['createDecision']);
api.createDecision.and.returnValue(of({
id: 'd-1',
vulnerabilityId: 'CVE-1',
subject: { type: 'IMAGE', name: 'asset-web-prod', digest: { sha256: 'x' } },
status: 'NOT_AFFECTED',
justificationType: 'CODE_NOT_REACHABLE',
createdBy: { id: 'u', displayName: 'User' },
createdAt: '2025-12-01T00:00:00Z',
}));
await TestBed.configureTestingModule({
imports: [VexDecisionModalComponent],
providers: [{ provide: VEX_DECISIONS_API, useValue: api }],
}).compileComponents();
fixture = TestBed.createComponent(VexDecisionModalComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('subject', { type: 'IMAGE', name: 'asset-web-prod', digest: { sha256: 'x' } });
fixture.componentRef.setInput('vulnerabilityIds', ['CVE-1']);
});
it('creates decisions on save', () => {
fixture.detectChanges();
component.save();
expect(api.createDecision).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,268 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
output,
signal,
} from '@angular/core';
import { forkJoin, of } from 'rxjs';
import { catchError, finalize, map } from 'rxjs/operators';
import type {
VexDecision,
VexEvidenceRef,
VexJustificationType,
VexStatus,
VexSubjectRef,
} from '../../core/api/evidence.models';
import { VEX_DECISIONS_API, type VexDecisionsApi } from '../../core/api/vex-decisions.client';
import type { VexDecisionCreateRequest } from '../../core/api/vex-decisions.models';
const STATUS_OPTIONS: readonly { value: VexStatus; label: string }[] = [
{ value: 'NOT_AFFECTED', label: 'Not Affected' },
{ value: 'AFFECTED_MITIGATED', label: 'Affected (mitigated)' },
{ value: 'AFFECTED_UNMITIGATED', label: 'Affected (unmitigated)' },
{ value: 'FIXED', label: 'Fixed' },
];
const JUSTIFICATION_OPTIONS: readonly { value: VexJustificationType; label: string }[] = [
{ value: 'CODE_NOT_PRESENT', label: 'Code not present' },
{ value: 'CODE_NOT_REACHABLE', label: 'Code not reachable' },
{ value: 'VULNERABLE_CODE_NOT_IN_EXECUTE_PATH', label: 'Vulnerable code not in execute path' },
{ value: 'CONFIGURATION_NOT_AFFECTED', label: 'Configuration not affected' },
{ value: 'OS_NOT_AFFECTED', label: 'OS not affected' },
{ value: 'RUNTIME_MITIGATION_PRESENT', label: 'Runtime mitigation present' },
{ value: 'COMPENSATING_CONTROLS', label: 'Compensating controls' },
{ value: 'ACCEPTED_BUSINESS_RISK', label: 'Accepted business risk' },
{ value: 'OTHER', label: 'Other' },
];
function toLocalDateTimeValue(iso: string): string {
try {
const date = new Date(iso);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
} catch {
return '';
}
}
function fromLocalDateTimeValue(value: string): string | undefined {
if (!value) return undefined;
try {
return new Date(value).toISOString();
} catch {
return undefined;
}
}
@Component({
selector: 'app-vex-decision-modal',
standalone: true,
imports: [CommonModule],
templateUrl: './vex-decision-modal.component.html',
styleUrls: ['./vex-decision-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VexDecisionModalComponent {
private readonly api = inject<VexDecisionsApi>(VEX_DECISIONS_API);
readonly subject = input.required<VexSubjectRef>();
readonly vulnerabilityIds = input.required<readonly string[]>();
readonly availableAttestationIds = input<readonly string[]>([]);
readonly existingDecision = input<VexDecision | null>(null);
readonly closed = output<void>();
readonly saved = output<readonly VexDecision[]>();
readonly statusOptions = STATUS_OPTIONS;
readonly justificationOptions = JUSTIFICATION_OPTIONS;
readonly status = signal<VexStatus>('NOT_AFFECTED');
readonly justificationType = signal<VexJustificationType>('VULNERABLE_CODE_NOT_IN_EXECUTE_PATH');
readonly justificationText = signal('');
readonly environmentsText = signal('');
readonly projectsText = signal('');
readonly notBefore = signal<string>(toLocalDateTimeValue(new Date().toISOString()));
readonly notAfter = signal<string>('');
readonly evidenceType = signal<VexEvidenceRef['type']>('TICKET');
readonly evidenceTitle = signal('');
readonly evidenceUrl = signal('');
readonly evidenceRefs = signal<readonly VexEvidenceRef[]>([]);
readonly selectedAttestationId = signal<string>('');
readonly viewRawJson = signal(false);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly isBulk = computed(() => this.vulnerabilityIds().length > 1);
readonly scopePreview = computed(() => {
const envs = this.splitCsv(this.environmentsText());
const projects = this.splitCsv(this.projectsText());
return {
environments: envs,
projects,
};
});
readonly validityPreview = computed(() => {
const notBeforeIso = fromLocalDateTimeValue(this.notBefore());
const notAfterIso = fromLocalDateTimeValue(this.notAfter());
return {
notBefore: notBeforeIso,
notAfter: notAfterIso,
warning: this.computeValidityWarning(notBeforeIso, notAfterIso),
};
});
readonly requestPreview = computed<VexDecisionCreateRequest>(() => {
const scope = this.scopePreview();
const validity = this.validityPreview();
return {
vulnerabilityId: this.vulnerabilityIds()[0] ?? 'UNKNOWN',
subject: this.subject(),
status: this.status(),
justificationType: this.justificationType(),
justificationText: this.justificationText().trim() || undefined,
evidenceRefs: this.evidenceRefs(),
scope: {
environments: scope.environments.length ? scope.environments : undefined,
projects: scope.projects.length ? scope.projects : undefined,
},
validFor: {
notBefore: validity.notBefore,
notAfter: validity.notAfter,
},
};
});
constructor() {
effect(() => {
const existing = this.existingDecision();
if (!existing) return;
this.status.set(existing.status);
this.justificationType.set(existing.justificationType);
this.justificationText.set(existing.justificationText ?? '');
this.environmentsText.set(existing.scope?.environments?.join(', ') ?? '');
this.projectsText.set(existing.scope?.projects?.join(', ') ?? '');
this.notBefore.set(toLocalDateTimeValue(existing.validFor?.notBefore ?? new Date().toISOString()));
this.notAfter.set(toLocalDateTimeValue(existing.validFor?.notAfter ?? ''));
this.evidenceRefs.set(existing.evidenceRefs ?? []);
});
}
close(): void {
if (!this.loading()) this.closed.emit();
}
addEvidence(): void {
const url = this.evidenceUrl().trim();
if (!url) return;
const next: VexEvidenceRef = {
type: this.evidenceType(),
title: this.evidenceTitle().trim() || undefined,
url,
};
const updated = [...this.evidenceRefs(), next].sort((a, b) =>
`${a.type}:${a.url}`.localeCompare(`${b.type}:${b.url}`)
);
this.evidenceRefs.set(updated);
this.evidenceTitle.set('');
this.evidenceUrl.set('');
}
removeEvidence(ref: VexEvidenceRef): void {
const updated = this.evidenceRefs().filter((r) => !(r.type === ref.type && r.url === ref.url));
this.evidenceRefs.set(updated);
}
attachSelectedAttestation(): void {
const id = this.selectedAttestationId();
if (!id) return;
const url = `attestation:${id}`;
const next: VexEvidenceRef = { type: 'OTHER', title: 'Attestation', url };
const updated = [...this.evidenceRefs(), next].sort((a, b) =>
`${a.type}:${a.url}`.localeCompare(`${b.type}:${b.url}`)
);
this.evidenceRefs.set(updated);
this.selectedAttestationId.set('');
}
save(): void {
const vulnerabilityIds = [...this.vulnerabilityIds()].sort((a, b) => a.localeCompare(b));
const preview = this.requestPreview();
const baseRequest: Omit<VexDecisionCreateRequest, 'vulnerabilityId'> = {
subject: preview.subject,
status: preview.status,
justificationType: preview.justificationType,
justificationText: preview.justificationText,
evidenceRefs: preview.evidenceRefs,
scope: preview.scope,
validFor: preview.validFor,
};
this.loading.set(true);
this.error.set(null);
const ops = vulnerabilityIds.map((vulnerabilityId) =>
this.api.createDecision({ ...baseRequest, vulnerabilityId }).pipe(
catchError((err) => {
const message = err instanceof Error ? err.message : 'Failed to create decision';
return of({ error: message } as any);
})
)
);
forkJoin(ops)
.pipe(
map((results) => {
const failures = results.filter((r: any) => 'error' in r);
if (failures.length) {
throw new Error((failures[0] as any).error ?? 'Failed to create decision');
}
return results as readonly VexDecision[];
}),
finalize(() => this.loading.set(false))
)
.subscribe({
next: (decisions) => this.saved.emit(decisions),
error: (err) => this.error.set(err instanceof Error ? err.message : 'Failed to create decision'),
});
}
private splitCsv(text: string): string[] {
return text
.split(',')
.map((v) => v.trim())
.filter((v) => v.length > 0)
.filter((v, idx, arr) => arr.indexOf(v) === idx)
.sort((a, b) => a.localeCompare(b));
}
private computeValidityWarning(notBeforeIso?: string, notAfterIso?: string): string | null {
if (!notBeforeIso || !notAfterIso) return null;
const start = Date.parse(notBeforeIso);
const end = Date.parse(notAfterIso);
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
if (end <= start) return 'Expiry must be after start.';
const days = (end - start) / (1000 * 60 * 60 * 24);
if (days > 365) return 'Long validity window (> 12 months). Consider shorter expiries.';
if (days > 180) return 'Validity window > 6 months. Consider adding review reminders.';
return null;
}
}