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
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:
@@ -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. |
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
193
src/Web/StellaOps.Web/src/app/core/api/audit-bundles.client.ts
Normal file
193
src/Web/StellaOps.Web/src/app/core/api/audit-bundles.client.ts
Normal 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')}`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
227
src/Web/StellaOps.Web/src/app/core/api/vex-decisions.client.ts
Normal file
227
src/Web/StellaOps.Web/src/app/core/api/vex-decisions.client.ts
Normal 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')}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,15 @@ import { RouterLink } from '@angular/router';
|
||||
<a routerLink="/orchestrator/jobs" class="orch-job-detail__back">← 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;
|
||||
|
||||
@@ -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 · 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 }} · 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 }} · {{ 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 }} · {{ f.advisoryId }} · {{ f.status }} ·
|
||||
{{ 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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
· {{ attestation().type }}
|
||||
·
|
||||
<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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<section class="wizard">
|
||||
<header class="wizard__header">
|
||||
<div>
|
||||
<a class="back" routerLink="/triage/audit-bundles">← 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
<section class="triage-workspace">
|
||||
<header class="triage-workspace__header">
|
||||
<div>
|
||||
<a class="back" routerLink="/triage/artifacts">← Back to artifacts</a>
|
||||
<h1>Artifact triage</h1>
|
||||
<p class="subtitle">
|
||||
Artifact: <code>{{ artifactId() }}</code>
|
||||
· 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>
|
||||
· 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 }} · {{ 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> · <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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
· applies to {{ vulnerabilityIds().length }} findings
|
||||
} @else {
|
||||
· {{ 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user