up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 07:46:56 +02:00
parent d63af51f84
commit ea970ead2a
302 changed files with 43161 additions and 1534 deletions

View File

@@ -15,26 +15,47 @@ export const routes: Routes = [
(m) => m.TrivyDbSettingsPageComponent
),
},
{
path: 'scans/:scanId',
loadComponent: () =>
import('./features/scans/scan-detail-page.component').then(
(m) => m.ScanDetailPageComponent
),
},
{
path: 'welcome',
loadComponent: () =>
import('./features/welcome/welcome-page.component').then(
(m) => m.WelcomePageComponent
),
},
{
path: 'notify',
loadComponent: () =>
import('./features/notify/notify-panel.component').then(
(m) => m.NotifyPanelComponent
),
{
path: 'scans/:scanId',
loadComponent: () =>
import('./features/scans/scan-detail-page.component').then(
(m) => m.ScanDetailPageComponent
),
},
{
path: 'welcome',
loadComponent: () =>
import('./features/welcome/welcome-page.component').then(
(m) => m.WelcomePageComponent
),
},
{
path: 'notify',
loadComponent: () =>
import('./features/notify/notify-panel.component').then(
(m) => m.NotifyPanelComponent
),
},
{
path: 'exceptions',
loadComponent: () =>
import('./features/exceptions/exception-center.component').then(
(m) => m.ExceptionCenterComponent
),
},
{
path: 'vulnerabilities',
loadComponent: () =>
import('./features/vulnerabilities/vulnerability-explorer.component').then(
(m) => m.VulnerabilityExplorerComponent
),
},
{
path: 'graph',
loadComponent: () =>
import('./features/graph/graph-explorer.component').then(
(m) => m.GraphExplorerComponent
),
},
{
path: 'auth/callback',

View File

@@ -0,0 +1,424 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable } from 'rxjs';
import {
Exception,
ExceptionsQueryOptions,
ExceptionsResponse,
ExceptionStats,
ExceptionStatusTransition,
} from './exception.models';
export interface ExceptionApi {
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse>;
getException(exceptionId: string): Observable<Exception>;
createException(exception: Partial<Exception>): Observable<Exception>;
updateException(exceptionId: string, updates: Partial<Exception>): Observable<Exception>;
deleteException(exceptionId: string): Observable<void>;
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception>;
getStats(): Observable<ExceptionStats>;
}
export const EXCEPTION_API = new InjectionToken<ExceptionApi>('EXCEPTION_API');
export const EXCEPTION_API_BASE_URL = new InjectionToken<string>('EXCEPTION_API_BASE_URL');
@Injectable({ providedIn: 'root' })
export class ExceptionApiHttpClient implements ExceptionApi {
constructor(
private readonly http: HttpClient,
@Inject(EXCEPTION_API_BASE_URL) private readonly baseUrl: string
) {}
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse> {
let params = new HttpParams();
if (options?.status) {
params = params.set('status', options.status);
}
if (options?.severity) {
params = params.set('severity', options.severity);
}
if (options?.search) {
params = params.set('search', options.search);
}
if (options?.sortBy) {
params = params.set('sortBy', options.sortBy);
}
if (options?.sortOrder) {
params = params.set('sortOrder', options.sortOrder);
}
if (options?.limit) {
params = params.set('limit', options.limit.toString());
}
if (options?.continuationToken) {
params = params.set('continuationToken', options.continuationToken);
}
return this.http.get<ExceptionsResponse>(`${this.baseUrl}/exceptions`, {
params,
headers: this.buildHeaders(),
});
}
getException(exceptionId: string): Observable<Exception> {
return this.http.get<Exception>(`${this.baseUrl}/exceptions/${exceptionId}`, {
headers: this.buildHeaders(),
});
}
createException(exception: Partial<Exception>): Observable<Exception> {
return this.http.post<Exception>(`${this.baseUrl}/exceptions`, exception, {
headers: this.buildHeaders(),
});
}
updateException(exceptionId: string, updates: Partial<Exception>): Observable<Exception> {
return this.http.patch<Exception>(`${this.baseUrl}/exceptions/${exceptionId}`, updates, {
headers: this.buildHeaders(),
});
}
deleteException(exceptionId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/exceptions/${exceptionId}`, {
headers: this.buildHeaders(),
});
}
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception> {
return this.http.post<Exception>(
`${this.baseUrl}/exceptions/${transition.exceptionId}/transition`,
{
newStatus: transition.newStatus,
comment: transition.comment,
},
{
headers: this.buildHeaders(),
}
);
}
getStats(): Observable<ExceptionStats> {
return this.http.get<ExceptionStats>(`${this.baseUrl}/exceptions/stats`, {
headers: this.buildHeaders(),
});
}
private buildHeaders(): HttpHeaders {
return new HttpHeaders({
'Content-Type': 'application/json',
});
}
}
/**
* Mock implementation for development and testing.
*/
@Injectable({ providedIn: 'root' })
export class MockExceptionApiService implements ExceptionApi {
private readonly mockExceptions: Exception[] = [
{
schemaVersion: '1.0',
exceptionId: 'exc-001',
tenantId: 'tenant-dev',
name: 'log4j-temporary-exception',
displayName: 'Log4j Temporary Exception',
description: 'Temporary exception for legacy Log4j usage in internal tooling',
status: 'approved',
severity: 'high',
scope: {
type: 'component',
componentPurls: ['pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1'],
vulnIds: ['CVE-2021-44228'],
},
justification: {
template: 'internal-only',
text: 'Internal-only service with no external exposure. Migration planned for Q1.',
},
timebox: {
startDate: '2025-01-01T00:00:00Z',
endDate: '2025-03-31T23:59:59Z',
autoRenew: false,
},
approvals: [
{
approvalId: 'apr-001',
approvedBy: 'security-lead@example.com',
approvedAt: '2024-12-15T10:30:00Z',
comment: 'Approved with condition: migrate before Q2',
},
],
labels: { team: 'platform', priority: 'P2' },
createdBy: 'dev@example.com',
createdAt: '2024-12-10T09:00:00Z',
updatedBy: 'security-lead@example.com',
updatedAt: '2024-12-15T10:30:00Z',
},
{
schemaVersion: '1.0',
exceptionId: 'exc-002',
tenantId: 'tenant-dev',
name: 'openssl-vuln-exception',
displayName: 'OpenSSL Vulnerability Exception',
status: 'pending_review',
severity: 'critical',
scope: {
type: 'asset',
assetIds: ['asset-nginx-prod'],
vulnIds: ['CVE-2024-0001'],
},
justification: {
template: 'compensating-control',
text: 'Compensating controls in place: WAF rules, network segmentation, monitoring.',
},
timebox: {
startDate: '2025-01-15T00:00:00Z',
endDate: '2025-02-15T23:59:59Z',
},
labels: { team: 'infrastructure' },
createdBy: 'ops@example.com',
createdAt: '2025-01-10T14:00:00Z',
},
{
schemaVersion: '1.0',
exceptionId: 'exc-003',
tenantId: 'tenant-dev',
name: 'legacy-crypto-exception',
displayName: 'Legacy Crypto Library',
status: 'draft',
severity: 'medium',
scope: {
type: 'tenant',
tenantId: 'tenant-dev',
},
justification: {
text: 'Migration in progress. ETA: 2 weeks.',
},
timebox: {
startDate: '2025-01-20T00:00:00Z',
endDate: '2025-02-20T23:59:59Z',
},
createdBy: 'dev@example.com',
createdAt: '2025-01-18T11:00:00Z',
},
{
schemaVersion: '1.0',
exceptionId: 'exc-004',
tenantId: 'tenant-dev',
name: 'expired-cert-exception',
displayName: 'Expired Certificate Exception',
status: 'expired',
severity: 'low',
scope: {
type: 'asset',
assetIds: ['asset-test-env'],
},
justification: {
text: 'Test environment only, not production facing.',
},
timebox: {
startDate: '2024-10-01T00:00:00Z',
endDate: '2024-12-31T23:59:59Z',
},
createdBy: 'qa@example.com',
createdAt: '2024-09-25T08:00:00Z',
},
{
schemaVersion: '1.0',
exceptionId: 'exc-005',
tenantId: 'tenant-dev',
name: 'rejected-exception',
displayName: 'Rejected Risk Exception',
status: 'rejected',
severity: 'critical',
scope: {
type: 'global',
},
justification: {
text: 'Blanket exception for all critical vulns.',
},
timebox: {
startDate: '2025-01-01T00:00:00Z',
endDate: '2025-12-31T23:59:59Z',
},
createdBy: 'dev@example.com',
createdAt: '2024-12-20T16:00:00Z',
},
];
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse> {
let filtered = [...this.mockExceptions];
if (options?.status) {
filtered = filtered.filter((e) => e.status === options.status);
}
if (options?.severity) {
filtered = filtered.filter((e) => e.severity === options.severity);
}
if (options?.search) {
const searchLower = options.search.toLowerCase();
filtered = filtered.filter(
(e) =>
e.name.toLowerCase().includes(searchLower) ||
e.displayName?.toLowerCase().includes(searchLower) ||
e.description?.toLowerCase().includes(searchLower)
);
}
const sortBy = options?.sortBy ?? 'createdAt';
const sortOrder = options?.sortOrder ?? 'desc';
filtered.sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'severity':
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
comparison = severityOrder[a.severity] - severityOrder[b.severity];
break;
case 'status':
comparison = a.status.localeCompare(b.status);
break;
case 'updatedAt':
comparison = (a.updatedAt ?? a.createdAt).localeCompare(b.updatedAt ?? b.createdAt);
break;
default:
comparison = a.createdAt.localeCompare(b.createdAt);
}
return sortOrder === 'asc' ? comparison : -comparison;
});
const limit = options?.limit ?? 20;
const items = filtered.slice(0, limit);
return new Observable((subscriber) => {
setTimeout(() => {
subscriber.next({
items,
count: filtered.length,
continuationToken: filtered.length > limit ? 'next-page-token' : null,
});
subscriber.complete();
}, 300);
});
}
getException(exceptionId: string): Observable<Exception> {
return new Observable((subscriber) => {
const exception = this.mockExceptions.find((e) => e.exceptionId === exceptionId);
setTimeout(() => {
if (exception) {
subscriber.next(exception);
} else {
subscriber.error(new Error(`Exception ${exceptionId} not found`));
}
subscriber.complete();
}, 100);
});
}
createException(exception: Partial<Exception>): Observable<Exception> {
return new Observable((subscriber) => {
const newException: Exception = {
schemaVersion: '1.0',
exceptionId: `exc-${Math.random().toString(36).slice(2, 10)}`,
tenantId: 'tenant-dev',
name: exception.name ?? 'new-exception',
status: 'draft',
severity: exception.severity ?? 'medium',
scope: exception.scope ?? { type: 'tenant' },
justification: exception.justification ?? { text: '' },
timebox: exception.timebox ?? {
startDate: new Date().toISOString(),
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
},
createdBy: 'ui@stella-ops.local',
createdAt: new Date().toISOString(),
...exception,
} as Exception;
this.mockExceptions.push(newException);
setTimeout(() => {
subscriber.next(newException);
subscriber.complete();
}, 200);
});
}
updateException(exceptionId: string, updates: Partial<Exception>): Observable<Exception> {
return new Observable((subscriber) => {
const index = this.mockExceptions.findIndex((e) => e.exceptionId === exceptionId);
if (index === -1) {
subscriber.error(new Error(`Exception ${exceptionId} not found`));
return;
}
const updated = {
...this.mockExceptions[index],
...updates,
updatedAt: new Date().toISOString(),
updatedBy: 'ui@stella-ops.local',
};
this.mockExceptions[index] = updated;
setTimeout(() => {
subscriber.next(updated);
subscriber.complete();
}, 200);
});
}
deleteException(exceptionId: string): Observable<void> {
return new Observable((subscriber) => {
const index = this.mockExceptions.findIndex((e) => e.exceptionId === exceptionId);
if (index !== -1) {
this.mockExceptions.splice(index, 1);
}
setTimeout(() => {
subscriber.next();
subscriber.complete();
}, 200);
});
}
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception> {
return this.updateException(transition.exceptionId, {
status: transition.newStatus,
});
}
getStats(): Observable<ExceptionStats> {
return new Observable((subscriber) => {
const byStatus: Record<string, number> = {
draft: 0,
pending_review: 0,
approved: 0,
rejected: 0,
expired: 0,
revoked: 0,
};
const bySeverity: Record<string, number> = {
critical: 0,
high: 0,
medium: 0,
low: 0,
};
this.mockExceptions.forEach((e) => {
byStatus[e.status] = (byStatus[e.status] ?? 0) + 1;
bySeverity[e.severity] = (bySeverity[e.severity] ?? 0) + 1;
});
setTimeout(() => {
subscriber.next({
total: this.mockExceptions.length,
byStatus: byStatus as Record<any, number>,
bySeverity: bySeverity as Record<any, number>,
expiringWithin7Days: 1,
pendingApproval: byStatus['pending_review'],
});
subscriber.complete();
}, 100);
});
}
}

View File

@@ -0,0 +1,116 @@
/**
* Exception Center domain models.
* Represents policy exceptions with workflow lifecycle management.
*/
export type ExceptionStatus =
| 'draft'
| 'pending_review'
| 'approved'
| 'rejected'
| 'expired'
| 'revoked';
export type ExceptionSeverity = 'critical' | 'high' | 'medium' | 'low';
export type ExceptionScope = 'global' | 'tenant' | 'asset' | 'component';
export interface ExceptionJustification {
readonly template?: string;
readonly text: string;
readonly attachments?: readonly string[];
}
export interface ExceptionTimebox {
readonly startDate: string;
readonly endDate: string;
readonly autoRenew?: boolean;
readonly maxRenewals?: number;
readonly renewalCount?: number;
}
export interface ExceptionApproval {
readonly approvalId: string;
readonly approvedBy: string;
readonly approvedAt: string;
readonly comment?: string;
}
export interface ExceptionAuditEntry {
readonly auditId: string;
readonly action: string;
readonly actor: string;
readonly timestamp: string;
readonly details?: Record<string, string>;
readonly previousStatus?: ExceptionStatus;
readonly newStatus?: ExceptionStatus;
}
export interface ExceptionScope {
readonly type: ExceptionScope;
readonly tenantId?: string;
readonly assetIds?: readonly string[];
readonly componentPurls?: readonly string[];
readonly vulnIds?: readonly string[];
}
export interface Exception {
readonly schemaVersion?: string;
readonly exceptionId: string;
readonly tenantId: string;
readonly name: string;
readonly displayName?: string;
readonly description?: string;
readonly status: ExceptionStatus;
readonly severity: ExceptionSeverity;
readonly scope: ExceptionScope;
readonly justification: ExceptionJustification;
readonly timebox: ExceptionTimebox;
readonly policyRuleIds?: readonly string[];
readonly approvals?: readonly ExceptionApproval[];
readonly auditTrail?: readonly ExceptionAuditEntry[];
readonly labels?: Record<string, string>;
readonly metadata?: Record<string, string>;
readonly createdBy: string;
readonly createdAt: string;
readonly updatedBy?: string;
readonly updatedAt?: string;
}
export interface ExceptionsQueryOptions {
readonly status?: ExceptionStatus;
readonly severity?: ExceptionSeverity;
readonly scope?: ExceptionScope;
readonly search?: string;
readonly sortBy?: 'createdAt' | 'updatedAt' | 'name' | 'severity' | 'status';
readonly sortOrder?: 'asc' | 'desc';
readonly limit?: number;
readonly continuationToken?: string;
}
export interface ExceptionsResponse {
readonly items: readonly Exception[];
readonly count: number;
readonly continuationToken?: string | null;
}
export interface ExceptionStatusTransition {
readonly exceptionId: string;
readonly newStatus: ExceptionStatus;
readonly comment?: string;
}
export interface ExceptionKanbanColumn {
readonly status: ExceptionStatus;
readonly label: string;
readonly items: readonly Exception[];
readonly count: number;
}
export interface ExceptionStats {
readonly total: number;
readonly byStatus: Record<ExceptionStatus, number>;
readonly bySeverity: Record<ExceptionSeverity, number>;
readonly expiringWithin7Days: number;
readonly pendingApproval: number;
}

View File

@@ -0,0 +1,316 @@
import { Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay } from 'rxjs';
import {
Vulnerability,
VulnerabilitiesQueryOptions,
VulnerabilitiesResponse,
VulnerabilityStats,
} from './vulnerability.models';
export interface VulnerabilityApi {
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse>;
getVulnerability(vulnId: string): Observable<Vulnerability>;
getStats(): Observable<VulnerabilityStats>;
}
export const VULNERABILITY_API = new InjectionToken<VulnerabilityApi>('VULNERABILITY_API');
const MOCK_VULNERABILITIES: Vulnerability[] = [
{
vulnId: 'vuln-001',
cveId: 'CVE-2021-44228',
title: 'Log4Shell - Remote Code Execution in Apache Log4j',
description: 'Apache Log4j2 2.0-beta9 through 2.15.0 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
severity: 'critical',
cvssScore: 10.0,
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H',
status: 'open',
publishedAt: '2021-12-10T00:00:00Z',
modifiedAt: '2024-06-27T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1',
name: 'log4j-core',
version: '2.14.1',
fixedVersion: '2.17.1',
assetIds: ['asset-web-prod', 'asset-api-prod'],
},
],
references: [
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
'https://logging.apache.org/log4j/2.x/security.html',
],
hasException: false,
},
{
vulnId: 'vuln-002',
cveId: 'CVE-2021-45046',
title: 'Log4j2 Thread Context Message Pattern DoS',
description: 'It was found that the fix to address CVE-2021-44228 in Apache Log4j 2.15.0 was incomplete in certain non-default configurations.',
severity: 'critical',
cvssScore: 9.0,
cvssVector: 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H',
status: 'excepted',
publishedAt: '2021-12-14T00:00:00Z',
modifiedAt: '2023-11-06T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.15.0',
name: 'log4j-core',
version: '2.15.0',
fixedVersion: '2.17.1',
assetIds: ['asset-internal-001'],
},
],
hasException: true,
exceptionId: 'exc-test-001',
},
{
vulnId: 'vuln-003',
cveId: 'CVE-2023-44487',
title: 'HTTP/2 Rapid Reset Attack',
description: 'The HTTP/2 protocol allows a denial of service (server resource consumption) because request cancellation can reset many streams quickly.',
severity: 'high',
cvssScore: 7.5,
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H',
status: 'in_progress',
publishedAt: '2023-10-10T00:00:00Z',
modifiedAt: '2024-05-01T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:golang/golang.org/x/net@0.15.0',
name: 'golang.org/x/net',
version: '0.15.0',
fixedVersion: '0.17.0',
assetIds: ['asset-api-prod', 'asset-worker-prod'],
},
{
purl: 'pkg:npm/nghttp2@1.55.0',
name: 'nghttp2',
version: '1.55.0',
fixedVersion: '1.57.0',
assetIds: ['asset-web-prod'],
},
],
hasException: false,
},
{
vulnId: 'vuln-004',
cveId: 'CVE-2024-21626',
title: 'runc container escape vulnerability',
description: 'runc is a CLI tool for spawning and running containers on Linux. In runc 1.1.11 and earlier, due to an internal file descriptor leak, an attacker could cause a newly-spawned container process to have a working directory in the host filesystem namespace.',
severity: 'high',
cvssScore: 8.6,
cvssVector: 'CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H',
status: 'fixed',
publishedAt: '2024-01-31T00:00:00Z',
modifiedAt: '2024-09-13T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:golang/github.com/opencontainers/runc@1.1.10',
name: 'runc',
version: '1.1.10',
fixedVersion: '1.1.12',
assetIds: ['asset-builder-001'],
},
],
hasException: false,
},
{
vulnId: 'vuln-005',
cveId: 'CVE-2023-38545',
title: 'curl SOCKS5 heap buffer overflow',
description: 'This flaw makes curl overflow a heap based buffer in the SOCKS5 proxy handshake.',
severity: 'high',
cvssScore: 9.8,
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H',
status: 'open',
publishedAt: '2023-10-11T00:00:00Z',
modifiedAt: '2024-06-10T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:deb/debian/curl@7.88.1-10',
name: 'curl',
version: '7.88.1-10',
fixedVersion: '8.4.0',
assetIds: ['asset-web-prod', 'asset-api-prod', 'asset-worker-prod'],
},
],
hasException: false,
},
{
vulnId: 'vuln-006',
cveId: 'CVE-2022-22965',
title: 'Spring4Shell - Spring Framework RCE',
description: 'A Spring MVC or Spring WebFlux application running on JDK 9+ may be vulnerable to remote code execution (RCE) via data binding.',
severity: 'critical',
cvssScore: 9.8,
status: 'wont_fix',
publishedAt: '2022-03-31T00:00:00Z',
modifiedAt: '2024-08-20T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:maven/org.springframework/spring-beans@5.3.17',
name: 'spring-beans',
version: '5.3.17',
fixedVersion: '5.3.18',
assetIds: ['asset-legacy-001'],
},
],
hasException: true,
exceptionId: 'exc-legacy-spring',
},
{
vulnId: 'vuln-007',
cveId: 'CVE-2023-45853',
title: 'MiniZip integer overflow in zipOpenNewFileInZip4_64',
description: 'MiniZip in zlib through 1.3 has an integer overflow and resultant heap-based buffer overflow in zipOpenNewFileInZip4_64.',
severity: 'medium',
cvssScore: 5.3,
status: 'open',
publishedAt: '2023-10-14T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:deb/debian/zlib@1.2.13',
name: 'zlib',
version: '1.2.13',
fixedVersion: '1.3.1',
assetIds: ['asset-web-prod'],
},
],
hasException: false,
},
{
vulnId: 'vuln-008',
cveId: 'CVE-2024-0567',
title: 'GnuTLS certificate verification bypass',
description: 'A vulnerability was found in GnuTLS. The response times to malformed ciphertexts in RSA-PSK ClientKeyExchange differ from response times of ciphertexts with correct PKCS#1 v1.5 padding.',
severity: 'medium',
cvssScore: 5.9,
status: 'open',
publishedAt: '2024-01-16T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:rpm/fedora/gnutls@3.8.2',
name: 'gnutls',
version: '3.8.2',
fixedVersion: '3.8.3',
assetIds: ['asset-internal-001'],
},
],
hasException: false,
},
{
vulnId: 'vuln-009',
cveId: 'CVE-2023-5363',
title: 'OpenSSL POLY1305 MAC implementation corrupts vector registers',
description: 'Issue summary: A bug has been identified in the POLY1305 MAC implementation which corrupts XMM registers on Windows.',
severity: 'low',
cvssScore: 3.7,
status: 'fixed',
publishedAt: '2023-10-24T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:nuget/System.Security.Cryptography.Pkcs@7.0.2',
name: 'System.Security.Cryptography.Pkcs',
version: '7.0.2',
fixedVersion: '8.0.0',
assetIds: ['asset-api-prod'],
},
],
hasException: false,
},
{
vulnId: 'vuln-010',
cveId: 'CVE-2024-24790',
title: 'Go net/netip ParseAddr stack exhaustion',
description: 'The various Is methods (IsLoopback, IsUnspecified, and similar) did not correctly report the status of an empty IP address.',
severity: 'low',
cvssScore: 4.0,
status: 'open',
publishedAt: '2024-06-05T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:golang/stdlib@1.21.10',
name: 'go stdlib',
version: '1.21.10',
fixedVersion: '1.21.11',
assetIds: ['asset-api-prod'],
},
],
hasException: false,
},
];
@Injectable({ providedIn: 'root' })
export class MockVulnerabilityApiService implements VulnerabilityApi {
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse> {
let items = [...MOCK_VULNERABILITIES];
if (options?.severity && options.severity !== 'all') {
items = items.filter((v) => v.severity === options.severity);
}
if (options?.status && options.status !== 'all') {
items = items.filter((v) => v.status === options.status);
}
if (options?.hasException !== undefined) {
items = items.filter((v) => v.hasException === options.hasException);
}
if (options?.search) {
const search = options.search.toLowerCase();
items = items.filter(
(v) =>
v.cveId.toLowerCase().includes(search) ||
v.title.toLowerCase().includes(search) ||
v.description?.toLowerCase().includes(search)
);
}
const total = items.length;
const offset = options?.offset ?? 0;
const limit = options?.limit ?? 50;
items = items.slice(offset, offset + limit);
return of({
items,
total,
hasMore: offset + items.length < total,
}).pipe(delay(200));
}
getVulnerability(vulnId: string): Observable<Vulnerability> {
const vuln = MOCK_VULNERABILITIES.find((v) => v.vulnId === vulnId);
if (!vuln) {
throw new Error(`Vulnerability ${vulnId} not found`);
}
return of(vuln).pipe(delay(100));
}
getStats(): Observable<VulnerabilityStats> {
const vulns = MOCK_VULNERABILITIES;
const stats: VulnerabilityStats = {
total: vulns.length,
bySeverity: {
critical: vulns.filter((v) => v.severity === 'critical').length,
high: vulns.filter((v) => v.severity === 'high').length,
medium: vulns.filter((v) => v.severity === 'medium').length,
low: vulns.filter((v) => v.severity === 'low').length,
unknown: vulns.filter((v) => v.severity === 'unknown').length,
},
byStatus: {
open: vulns.filter((v) => v.status === 'open').length,
fixed: vulns.filter((v) => v.status === 'fixed').length,
wont_fix: vulns.filter((v) => v.status === 'wont_fix').length,
in_progress: vulns.filter((v) => v.status === 'in_progress').length,
excepted: vulns.filter((v) => v.status === 'excepted').length,
},
withExceptions: vulns.filter((v) => v.hasException).length,
criticalOpen: vulns.filter((v) => v.severity === 'critical' && v.status === 'open').length,
};
return of(stats).pipe(delay(150));
}
}

View File

@@ -0,0 +1,50 @@
export type VulnerabilitySeverity = 'critical' | 'high' | 'medium' | 'low' | 'unknown';
export type VulnerabilityStatus = 'open' | 'fixed' | 'wont_fix' | 'in_progress' | 'excepted';
export interface Vulnerability {
readonly vulnId: string;
readonly cveId: string;
readonly title: string;
readonly description?: string;
readonly severity: VulnerabilitySeverity;
readonly cvssScore?: number;
readonly cvssVector?: string;
readonly status: VulnerabilityStatus;
readonly publishedAt?: string;
readonly modifiedAt?: string;
readonly affectedComponents: readonly AffectedComponent[];
readonly references?: readonly string[];
readonly hasException?: boolean;
readonly exceptionId?: string;
}
export interface AffectedComponent {
readonly purl: string;
readonly name: string;
readonly version: string;
readonly fixedVersion?: string;
readonly assetIds: readonly string[];
}
export interface VulnerabilityStats {
readonly total: number;
readonly bySeverity: Record<VulnerabilitySeverity, number>;
readonly byStatus: Record<VulnerabilityStatus, number>;
readonly withExceptions: number;
readonly criticalOpen: number;
}
export interface VulnerabilitiesQueryOptions {
readonly severity?: VulnerabilitySeverity | 'all';
readonly status?: VulnerabilityStatus | 'all';
readonly search?: string;
readonly hasException?: boolean;
readonly limit?: number;
readonly offset?: number;
}
export interface VulnerabilitiesResponse {
readonly items: readonly Vulnerability[];
readonly total: number;
readonly hasMore: boolean;
}

View File

@@ -0,0 +1,489 @@
<div class="exception-center" role="main" aria-label="Exception Center">
<!-- Screen reader announcements (ARIA live region) -->
<div
class="sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
>
{{ screenReaderAnnouncement() }}
</div>
<!-- Keyboard shortcuts hint -->
<div class="keyboard-hints" aria-hidden="true">
<span class="keyboard-hint">
<kbd>X</kbd> Create
</span>
<span class="keyboard-hint">
<kbd>A</kbd> Approve
</span>
<span class="keyboard-hint">
<kbd>R</kbd> Reject
</span>
<span class="keyboard-hint">
<kbd>Esc</kbd> Close
</span>
</div>
<!-- Header -->
<header class="exception-center__header">
<div class="exception-center__title-section">
<h1>Exception Center</h1>
<p class="exception-center__subtitle">Manage policy exceptions with workflow approvals</p>
</div>
<div class="exception-center__actions">
<button
type="button"
class="btn btn--secondary"
(click)="refreshData()"
[disabled]="loading()"
aria-label="Refresh exception list"
>
Refresh
</button>
<button
type="button"
class="btn btn--primary"
(click)="openWizard()"
aria-label="Create new exception (keyboard shortcut: X)"
title="Create Exception (X)"
>
Create Exception
</button>
</div>
</header>
<!-- Stats Bar -->
<div class="exception-center__stats" *ngIf="stats() as s">
<div class="stat-card">
<span class="stat-card__value">{{ s.total }}</span>
<span class="stat-card__label">Total</span>
</div>
<div class="stat-card stat-card--pending">
<span class="stat-card__value">{{ s.pendingApproval }}</span>
<span class="stat-card__label">Pending Review</span>
</div>
<div class="stat-card stat-card--warning">
<span class="stat-card__value">{{ s.expiringWithin7Days }}</span>
<span class="stat-card__label">Expiring Soon</span>
</div>
<div class="stat-card stat-card--approved">
<span class="stat-card__value">{{ s.byStatus['approved'] ?? 0 }}</span>
<span class="stat-card__label">Approved</span>
</div>
</div>
<!-- Message Toast -->
<div
class="exception-center__message"
*ngIf="message() as msg"
[class.exception-center__message--success]="messageType() === 'success'"
[class.exception-center__message--error]="messageType() === 'error'"
>
{{ msg }}
</div>
<!-- Toolbar -->
<div class="exception-center__toolbar">
<!-- View Toggle -->
<div class="view-toggle">
<button
type="button"
class="view-toggle__btn"
[class.view-toggle__btn--active]="viewMode() === 'list'"
(click)="setViewMode('list')"
title="List view"
>
List
</button>
<button
type="button"
class="view-toggle__btn"
[class.view-toggle__btn--active]="viewMode() === 'kanban'"
(click)="setViewMode('kanban')"
title="Kanban view"
>
Kanban
</button>
</div>
<!-- Search -->
<div class="search-box">
<input
type="text"
class="search-box__input"
placeholder="Search exceptions..."
[value]="searchQuery()"
(input)="onSearchInput($event)"
/>
<button
type="button"
class="search-box__clear"
*ngIf="searchQuery()"
(click)="clearSearch()"
>
Clear
</button>
</div>
<!-- Filters -->
<div class="filters">
<div class="filter-group">
<label class="filter-group__label">Status</label>
<select
class="filter-group__select"
[value]="statusFilter()"
(change)="setStatusFilter($any($event.target).value)"
>
<option value="all">All Statuses</option>
<option *ngFor="let status of allStatuses" [value]="status">
{{ statusLabels[status] }}
</option>
</select>
</div>
<div class="filter-group">
<label class="filter-group__label">Severity</label>
<select
class="filter-group__select"
[value]="severityFilter()"
(change)="setSeverityFilter($any($event.target).value)"
>
<option value="all">All Severities</option>
<option *ngFor="let sev of allSeverities" [value]="sev">
{{ severityLabels[sev] }}
</option>
</select>
</div>
</div>
</div>
<!-- Loading State -->
<div class="exception-center__loading" *ngIf="loading()">
<span class="spinner"></span>
<span>Loading exceptions...</span>
</div>
<!-- Main Content -->
<div class="exception-center__content" *ngIf="!loading()">
<!-- List View -->
<div class="list-view" *ngIf="viewMode() === 'list'">
<table class="exception-table" *ngIf="filteredExceptions().length > 0">
<thead>
<tr>
<th class="exception-table__th exception-table__th--sortable" (click)="toggleSort('name')">
Name {{ getSortIcon('name') }}
</th>
<th class="exception-table__th exception-table__th--sortable" (click)="toggleSort('status')">
Status {{ getSortIcon('status') }}
</th>
<th class="exception-table__th exception-table__th--sortable" (click)="toggleSort('severity')">
Severity {{ getSortIcon('severity') }}
</th>
<th class="exception-table__th">Scope</th>
<th class="exception-table__th">Timebox</th>
<th class="exception-table__th exception-table__th--sortable" (click)="toggleSort('createdAt')">
Created {{ getSortIcon('createdAt') }}
</th>
<th class="exception-table__th">Actions</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let exc of filteredExceptions(); trackBy: trackByException"
class="exception-table__row"
[class.exception-table__row--selected]="selectedExceptionId() === exc.exceptionId"
(click)="selectException(exc.exceptionId)"
>
<td class="exception-table__td">
<div class="exception-name">
<span class="exception-name__title">{{ exc.displayName || exc.name }}</span>
<span class="exception-name__id">{{ exc.exceptionId }}</span>
</div>
</td>
<td class="exception-table__td">
<span class="chip" [ngClass]="getStatusClass(exc.status)">
{{ statusLabels[exc.status] }}
</span>
</td>
<td class="exception-table__td">
<span class="chip" [ngClass]="getSeverityClass(exc.severity)">
{{ severityLabels[exc.severity] }}
</span>
</td>
<td class="exception-table__td">
<span class="scope-badge">{{ exc.scope.type }}</span>
</td>
<td class="exception-table__td">
<div class="timebox">
<span>{{ formatDate(exc.timebox.startDate) }}</span>
<span class="timebox__separator">-</span>
<span [class.timebox__expiring]="isExpiringSoon(exc)">
{{ formatDate(exc.timebox.endDate) }}
</span>
</div>
</td>
<td class="exception-table__td">
{{ formatDate(exc.createdAt) }}
</td>
<td class="exception-table__td exception-table__td--actions">
<div class="action-buttons">
<button
*ngFor="let targetStatus of getAvailableTransitions(exc.status)"
type="button"
class="btn btn--small btn--action"
(click)="transitionStatus(exc.exceptionId, targetStatus); $event.stopPropagation()"
>
{{ statusLabels[targetStatus] }}
</button>
<button
type="button"
class="btn btn--small btn--danger"
(click)="deleteException(exc.exceptionId); $event.stopPropagation()"
*ngIf="exc.status === 'draft' || exc.status === 'rejected'"
>
Delete
</button>
</div>
</td>
</tr>
</tbody>
</table>
<div class="empty-state" *ngIf="filteredExceptions().length === 0">
<p>No exceptions found matching your filters.</p>
</div>
</div>
<!-- Kanban View -->
<div class="kanban-view" *ngIf="viewMode() === 'kanban'">
<div
class="kanban-column"
*ngFor="let column of kanbanColumns(); trackBy: trackByColumn"
>
<div class="kanban-column__header">
<h3 class="kanban-column__title">{{ column.label }}</h3>
<span class="kanban-column__count">{{ column.count }}</span>
</div>
<div class="kanban-column__cards">
<div
class="kanban-card"
*ngFor="let exc of column.items; trackBy: trackByException"
[class.kanban-card--selected]="selectedExceptionId() === exc.exceptionId"
(click)="selectException(exc.exceptionId)"
>
<div class="kanban-card__header">
<span class="chip" [ngClass]="getSeverityClass(exc.severity)">
{{ severityLabels[exc.severity] }}
</span>
<span class="kanban-card__expiry" *ngIf="isExpiringSoon(exc)">
Expiring soon
</span>
</div>
<h4 class="kanban-card__title">{{ exc.displayName || exc.name }}</h4>
<p class="kanban-card__description" *ngIf="exc.description">
{{ exc.description | slice:0:80 }}{{ exc.description.length > 80 ? '...' : '' }}
</p>
<div class="kanban-card__meta">
<span class="scope-badge scope-badge--small">{{ exc.scope.type }}</span>
<span class="kanban-card__date">{{ formatDate(exc.createdAt) }}</span>
</div>
<div class="kanban-card__actions">
<button
*ngFor="let targetStatus of getAvailableTransitions(exc.status)"
type="button"
class="btn btn--small btn--action"
(click)="transitionStatus(exc.exceptionId, targetStatus); $event.stopPropagation()"
>
{{ statusLabels[targetStatus] }}
</button>
</div>
</div>
<div class="kanban-column__empty" *ngIf="column.items.length === 0">
No exceptions
</div>
</div>
</div>
</div>
</div>
<!-- Detail Panel (Side Panel) -->
<div class="detail-panel" *ngIf="selectedException() as exc">
<div class="detail-panel__header">
<h2>{{ exc.displayName || exc.name }}</h2>
<button type="button" class="detail-panel__close" (click)="clearSelection()">Close</button>
</div>
<div class="detail-panel__content">
<div class="detail-section">
<h3>Status</h3>
<span class="chip chip--large" [ngClass]="getStatusClass(exc.status)">
{{ statusLabels[exc.status] }}
</span>
</div>
<div class="detail-section">
<h3>Severity</h3>
<span class="chip chip--large" [ngClass]="getSeverityClass(exc.severity)">
{{ severityLabels[exc.severity] }}
</span>
</div>
<div class="detail-section">
<h3>Description</h3>
<p>{{ exc.description || 'No description provided.' }}</p>
</div>
<div class="detail-section">
<h3>Scope</h3>
<div class="scope-details">
<div class="scope-detail">
<span class="scope-detail__label">Type:</span>
<span class="scope-detail__value">{{ exc.scope.type }}</span>
</div>
<div class="scope-detail" *ngIf="exc.scope.vulnIds?.length">
<span class="scope-detail__label">Vulnerabilities:</span>
<span class="scope-detail__value">{{ exc.scope.vulnIds.join(', ') }}</span>
</div>
<div class="scope-detail" *ngIf="exc.scope.componentPurls?.length">
<span class="scope-detail__label">Components:</span>
<span class="scope-detail__value">{{ exc.scope.componentPurls.join(', ') }}</span>
</div>
<div class="scope-detail" *ngIf="exc.scope.assetIds?.length">
<span class="scope-detail__label">Assets:</span>
<span class="scope-detail__value">{{ exc.scope.assetIds.join(', ') }}</span>
</div>
</div>
</div>
<div class="detail-section">
<h3>Justification</h3>
<p>{{ exc.justification.text }}</p>
<span class="chip chip--small" *ngIf="exc.justification.template">
Template: {{ exc.justification.template }}
</span>
</div>
<div class="detail-section">
<h3>Timebox</h3>
<div class="timebox-details">
<div class="timebox-detail">
<span class="timebox-detail__label">Start:</span>
<span class="timebox-detail__value">{{ formatDateTime(exc.timebox.startDate) }}</span>
</div>
<div class="timebox-detail">
<span class="timebox-detail__label">End:</span>
<span class="timebox-detail__value" [class.timebox__expiring]="isExpiringSoon(exc)">
{{ formatDateTime(exc.timebox.endDate) }}
</span>
</div>
<div class="timebox-detail" *ngIf="exc.timebox.autoRenew">
<span class="timebox-detail__label">Auto-renew:</span>
<span class="timebox-detail__value">Yes ({{ exc.timebox.renewalCount || 0 }}/{{ exc.timebox.maxRenewals || 'unlimited' }})</span>
</div>
</div>
</div>
<div class="detail-section" *ngIf="exc.approvals?.length">
<h3>Approvals</h3>
<div class="approval-list">
<div class="approval-item" *ngFor="let approval of exc.approvals">
<div class="approval-item__header">
<span class="approval-item__by">{{ approval.approvedBy }}</span>
<span class="approval-item__date">{{ formatDateTime(approval.approvedAt) }}</span>
</div>
<p class="approval-item__comment" *ngIf="approval.comment">{{ approval.comment }}</p>
</div>
</div>
</div>
<div class="detail-section">
<h3>Metadata</h3>
<div class="metadata-grid">
<div class="metadata-item">
<span class="metadata-item__label">Created by:</span>
<span class="metadata-item__value">{{ exc.createdBy }}</span>
</div>
<div class="metadata-item">
<span class="metadata-item__label">Created at:</span>
<span class="metadata-item__value">{{ formatDateTime(exc.createdAt) }}</span>
</div>
<div class="metadata-item" *ngIf="exc.updatedBy">
<span class="metadata-item__label">Updated by:</span>
<span class="metadata-item__value">{{ exc.updatedBy }}</span>
</div>
<div class="metadata-item" *ngIf="exc.updatedAt">
<span class="metadata-item__label">Updated at:</span>
<span class="metadata-item__value">{{ formatDateTime(exc.updatedAt) }}</span>
</div>
</div>
</div>
<!-- Audit Trail Toggle -->
<div class="detail-section">
<button type="button" class="btn btn--secondary" (click)="toggleAuditPanel()">
{{ showAuditPanel() ? 'Hide' : 'Show' }} Audit Trail
</button>
</div>
<!-- Audit Trail -->
<div class="detail-section" *ngIf="showAuditPanel() && exc.auditTrail?.length">
<h3>Audit Trail</h3>
<div class="audit-list">
<div class="audit-item" *ngFor="let entry of exc.auditTrail; trackBy: trackByAudit">
<div class="audit-item__header">
<span class="audit-item__action">{{ entry.action }}</span>
<span class="audit-item__date">{{ formatDateTime(entry.timestamp) }}</span>
</div>
<div class="audit-item__actor">by {{ entry.actor }}</div>
<div class="audit-item__transition" *ngIf="entry.previousStatus && entry.newStatus">
{{ statusLabels[entry.previousStatus] }} &rarr; {{ statusLabels[entry.newStatus] }}
</div>
</div>
</div>
</div>
<div class="detail-section" *ngIf="showAuditPanel() && !exc.auditTrail?.length">
<p class="empty-audit">No audit entries recorded.</p>
</div>
</div>
<!-- Actions -->
<div class="detail-panel__actions">
<button
*ngFor="let targetStatus of getAvailableTransitions(exc.status)"
type="button"
class="btn btn--primary"
(click)="transitionStatus(exc.exceptionId, targetStatus)"
>
{{ statusLabels[targetStatus] }}
</button>
<button
type="button"
class="btn btn--danger"
(click)="deleteException(exc.exceptionId)"
*ngIf="exc.status === 'draft' || exc.status === 'rejected'"
>
Delete
</button>
</div>
</div>
<!-- Wizard Modal -->
<div class="wizard-modal" *ngIf="showWizard()">
<div class="wizard-modal__backdrop" (click)="closeWizard()"></div>
<div class="wizard-modal__container">
<app-exception-wizard
(created)="onExceptionCreated($event)"
(cancelled)="closeWizard()"
></app-exception-wizard>
</div>
</div>
</div>

View File

@@ -0,0 +1,927 @@
.exception-center {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1.5rem;
min-height: 100vh;
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
}
// Screen reader only - visually hidden but accessible
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
// Keyboard shortcuts hint bar
.keyboard-hints {
display: flex;
justify-content: flex-end;
gap: 1rem;
padding: 0.5rem 1rem;
background: #f1f5f9;
border-radius: 0.5rem;
font-size: 0.75rem;
color: #64748b;
}
.keyboard-hint {
display: flex;
align-items: center;
gap: 0.375rem;
kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 0.375rem;
background: white;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
font-family: ui-monospace, monospace;
font-size: 0.6875rem;
font-weight: 500;
color: #374151;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
// Header
.exception-center__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
color: #1e293b;
}
}
.exception-center__subtitle {
margin: 0.25rem 0 0;
color: #64748b;
font-size: 0.875rem;
}
.exception-center__actions {
display: flex;
gap: 0.5rem;
}
// Stats Bar
.exception-center__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
}
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
background: white;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid #e2e8f0;
&--pending {
border-left: 4px solid #f59e0b;
}
&--warning {
border-left: 4px solid #ef4444;
}
&--approved {
border-left: 4px solid #10b981;
}
}
.stat-card__value {
font-size: 1.5rem;
font-weight: 700;
color: #1e293b;
}
.stat-card__label {
font-size: 0.75rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
// Message Toast
.exception-center__message {
padding: 0.75rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
background: #e0f2fe;
color: #0369a1;
border: 1px solid #7dd3fc;
&--success {
background: #dcfce7;
color: #166534;
border-color: #86efac;
}
&--error {
background: #fef2f2;
color: #991b1b;
border-color: #fca5a5;
}
}
// Toolbar
.exception-center__toolbar {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
padding: 1rem;
background: white;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.view-toggle {
display: flex;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
overflow: hidden;
}
.view-toggle__btn {
padding: 0.5rem 1rem;
border: none;
background: white;
color: #64748b;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f1f5f9;
}
&--active {
background: #4f46e5;
color: white;
&:hover {
background: #4338ca;
}
}
}
.search-box {
display: flex;
flex: 1;
min-width: 200px;
max-width: 400px;
position: relative;
}
.search-box__input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 0.875rem;
outline: none;
transition: border-color 0.2s ease;
&:focus {
border-color: #4f46e5;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
}
.search-box__clear {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
padding: 0.25rem 0.5rem;
border: none;
background: transparent;
color: #64748b;
font-size: 0.75rem;
cursor: pointer;
&:hover {
color: #1e293b;
}
}
.filters {
display: flex;
gap: 1rem;
margin-left: auto;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.filter-group__label {
font-size: 0.75rem;
color: #64748b;
font-weight: 500;
}
.filter-group__select {
padding: 0.5rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 0.875rem;
background: white;
cursor: pointer;
min-width: 140px;
&:focus {
outline: none;
border-color: #4f46e5;
}
}
// Loading
.exception-center__loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 3rem;
color: #64748b;
}
.spinner {
width: 24px;
height: 24px;
border: 3px solid #e2e8f0;
border-top-color: #4f46e5;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// List View
.list-view {
background: white;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.exception-table {
width: 100%;
border-collapse: collapse;
}
.exception-table__th {
padding: 0.75rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
&--sortable {
cursor: pointer;
user-select: none;
&:hover {
color: #4f46e5;
}
}
}
.exception-table__row {
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background: #f8fafc;
}
&--selected {
background: #eef2ff;
&:hover {
background: #e0e7ff;
}
}
}
.exception-table__td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #f1f5f9;
font-size: 0.875rem;
color: #334155;
vertical-align: middle;
&--actions {
text-align: right;
}
}
.exception-name {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.exception-name__title {
font-weight: 500;
color: #1e293b;
}
.exception-name__id {
font-size: 0.75rem;
color: #94a3b8;
font-family: ui-monospace, monospace;
}
// Chips
.chip {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
&--large {
padding: 0.375rem 0.875rem;
font-size: 0.875rem;
}
&--small {
padding: 0.125rem 0.5rem;
font-size: 0.6875rem;
}
}
// Status chips
.status--draft {
background: #f1f5f9;
color: #475569;
}
.status--pending {
background: #fef3c7;
color: #92400e;
}
.status--approved {
background: #dcfce7;
color: #166534;
}
.status--rejected {
background: #fef2f2;
color: #991b1b;
}
.status--expired {
background: #f3f4f6;
color: #6b7280;
}
.status--revoked {
background: #fce7f3;
color: #9d174d;
}
// Severity chips
.severity--critical {
background: #fef2f2;
color: #dc2626;
}
.severity--high {
background: #fff7ed;
color: #ea580c;
}
.severity--medium {
background: #fefce8;
color: #ca8a04;
}
.severity--low {
background: #f0fdf4;
color: #16a34a;
}
// Scope badge
.scope-badge {
display: inline-flex;
padding: 0.25rem 0.5rem;
background: #e0e7ff;
color: #4338ca;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
text-transform: capitalize;
&--small {
padding: 0.125rem 0.375rem;
font-size: 0.6875rem;
}
}
// Timebox
.timebox {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
}
.timebox__separator {
color: #94a3b8;
}
.timebox__expiring {
color: #dc2626;
font-weight: 500;
}
// Action buttons
.action-buttons {
display: flex;
gap: 0.375rem;
justify-content: flex-end;
}
// Buttons
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&--primary {
background: #4f46e5;
color: white;
&:hover:not(:disabled) {
background: #4338ca;
}
}
&--secondary {
background: white;
color: #475569;
border: 1px solid #e2e8f0;
&:hover:not(:disabled) {
background: #f8fafc;
}
}
&--danger {
background: #ef4444;
color: white;
&:hover:not(:disabled) {
background: #dc2626;
}
}
&--small {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: 0.375rem;
}
&--action {
background: #e0e7ff;
color: #4338ca;
&:hover:not(:disabled) {
background: #c7d2fe;
}
}
}
// Kanban View
.kanban-view {
display: grid;
grid-template-columns: repeat(6, minmax(220px, 1fr));
gap: 1rem;
overflow-x: auto;
padding-bottom: 1rem;
}
.kanban-column {
display: flex;
flex-direction: column;
background: #f1f5f9;
border-radius: 0.75rem;
min-height: 400px;
}
.kanban-column__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: white;
border-radius: 0.75rem 0.75rem 0 0;
border-bottom: 1px solid #e2e8f0;
}
.kanban-column__title {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: #1e293b;
}
.kanban-column__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 0.5rem;
background: #e2e8f0;
color: #475569;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.kanban-column__cards {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
overflow-y: auto;
}
.kanban-column__empty {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
color: #94a3b8;
font-size: 0.875rem;
}
.kanban-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.875rem;
background: white;
border-radius: 0.5rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
cursor: pointer;
transition: all 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
&--selected {
ring: 2px solid #4f46e5;
box-shadow: 0 0 0 2px #4f46e5;
}
}
.kanban-card__header {
display: flex;
justify-content: space-between;
align-items: center;
}
.kanban-card__expiry {
font-size: 0.6875rem;
color: #dc2626;
font-weight: 500;
}
.kanban-card__title {
margin: 0;
font-size: 0.875rem;
font-weight: 500;
color: #1e293b;
line-height: 1.3;
}
.kanban-card__description {
margin: 0;
font-size: 0.75rem;
color: #64748b;
line-height: 1.4;
}
.kanban-card__meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.25rem;
}
.kanban-card__date {
font-size: 0.6875rem;
color: #94a3b8;
}
.kanban-card__actions {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid #f1f5f9;
}
// Empty State
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: #64748b;
p {
margin: 0;
}
}
// Detail Panel
.detail-panel {
position: fixed;
top: 0;
right: 0;
width: 420px;
max-width: 100%;
height: 100vh;
background: white;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
z-index: 100;
overflow: hidden;
}
.detail-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1e293b;
}
}
.detail-panel__close {
padding: 0.375rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
background: white;
color: #64748b;
font-size: 0.8125rem;
cursor: pointer;
&:hover {
background: #f1f5f9;
}
}
.detail-panel__content {
flex: 1;
overflow-y: auto;
padding: 1.25rem;
}
.detail-section {
margin-bottom: 1.5rem;
h3 {
margin: 0 0 0.5rem;
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
p {
margin: 0;
font-size: 0.875rem;
color: #334155;
line-height: 1.5;
}
}
.scope-details,
.timebox-details,
.metadata-grid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.scope-detail,
.timebox-detail,
.metadata-item {
display: flex;
gap: 0.5rem;
font-size: 0.875rem;
&__label {
color: #64748b;
min-width: 100px;
}
&__value {
color: #1e293b;
word-break: break-word;
}
}
.approval-list,
.audit-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.approval-item,
.audit-item {
padding: 0.75rem;
background: #f8fafc;
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
}
.approval-item__header,
.audit-item__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.approval-item__by,
.audit-item__action {
font-weight: 500;
color: #1e293b;
font-size: 0.875rem;
}
.approval-item__date,
.audit-item__date {
font-size: 0.75rem;
color: #94a3b8;
}
.approval-item__comment {
margin: 0.5rem 0 0;
font-size: 0.8125rem;
color: #475569;
font-style: italic;
}
.audit-item__actor {
font-size: 0.75rem;
color: #64748b;
}
.audit-item__transition {
margin-top: 0.25rem;
font-size: 0.75rem;
color: #4f46e5;
}
.empty-audit {
color: #94a3b8;
font-style: italic;
}
.detail-panel__actions {
display: flex;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
// Wizard Modal
.wizard-modal {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.wizard-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(4px);
}
.wizard-modal__container {
position: relative;
width: 100%;
max-width: 720px;
max-height: calc(100vh - 3rem);
overflow: hidden;
border-radius: 0.75rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
// Responsive
@media (max-width: 1200px) {
.kanban-view {
grid-template-columns: repeat(3, minmax(220px, 1fr));
}
}
@media (max-width: 768px) {
.exception-center {
padding: 1rem;
}
.exception-center__toolbar {
flex-direction: column;
align-items: stretch;
}
.filters {
margin-left: 0;
flex-wrap: wrap;
}
.search-box {
max-width: none;
}
.kanban-view {
grid-template-columns: 1fr;
}
.detail-panel {
width: 100%;
}
.exception-table {
display: block;
overflow-x: auto;
}
}

View File

@@ -0,0 +1,564 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
HostListener,
OnDestroy,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import {
EXCEPTION_API,
ExceptionApi,
MockExceptionApiService,
} from '../../core/api/exception.client';
import {
Exception,
ExceptionKanbanColumn,
ExceptionSeverity,
ExceptionStats,
ExceptionStatus,
ExceptionsQueryOptions,
} from '../../core/api/exception.models';
import { ExceptionWizardComponent } from './exception-wizard.component';
type ViewMode = 'list' | 'kanban';
type StatusFilter = ExceptionStatus | 'all';
type SeverityFilter = ExceptionSeverity | 'all';
type SortField = 'createdAt' | 'updatedAt' | 'name' | 'severity' | 'status';
type SortOrder = 'asc' | 'desc';
const STATUS_LABELS: Record<ExceptionStatus, string> = {
draft: 'Draft',
pending_review: 'Pending Review',
approved: 'Approved',
rejected: 'Rejected',
expired: 'Expired',
revoked: 'Revoked',
};
const SEVERITY_LABELS: Record<ExceptionSeverity, string> = {
critical: 'Critical',
high: 'High',
medium: 'Medium',
low: 'Low',
};
const KANBAN_COLUMN_ORDER: ExceptionStatus[] = [
'draft',
'pending_review',
'approved',
'rejected',
'expired',
'revoked',
];
@Component({
selector: 'app-exception-center',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, ExceptionWizardComponent],
templateUrl: './exception-center.component.html',
styleUrls: ['./exception-center.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{ provide: EXCEPTION_API, useClass: MockExceptionApiService },
],
})
export class ExceptionCenterComponent implements OnInit, OnDestroy {
private readonly api = inject<ExceptionApi>(EXCEPTION_API);
private readonly formBuilder = inject(NonNullableFormBuilder);
// Screen reader announcements
readonly screenReaderAnnouncement = signal('');
private announcementTimeout?: ReturnType<typeof setTimeout>;
// View state
readonly viewMode = signal<ViewMode>('list');
readonly loading = signal(false);
readonly message = signal<string | null>(null);
readonly messageType = signal<'success' | 'error' | 'info'>('info');
readonly showWizard = signal(false);
// Data
readonly exceptions = signal<Exception[]>([]);
readonly stats = signal<ExceptionStats | null>(null);
readonly selectedExceptionId = signal<string | null>(null);
// Filters & sorting
readonly statusFilter = signal<StatusFilter>('all');
readonly severityFilter = signal<SeverityFilter>('all');
readonly searchQuery = signal('');
readonly sortField = signal<SortField>('createdAt');
readonly sortOrder = signal<SortOrder>('desc');
// Constants for template
readonly statusLabels = STATUS_LABELS;
readonly severityLabels = SEVERITY_LABELS;
readonly allStatuses: ExceptionStatus[] = KANBAN_COLUMN_ORDER;
readonly allSeverities: ExceptionSeverity[] = ['critical', 'high', 'medium', 'low'];
// Computed: filtered and sorted list
readonly filteredExceptions = computed(() => {
let items = [...this.exceptions()];
const status = this.statusFilter();
const severity = this.severityFilter();
const search = this.searchQuery().toLowerCase();
if (status !== 'all') {
items = items.filter((e) => e.status === status);
}
if (severity !== 'all') {
items = items.filter((e) => e.severity === severity);
}
if (search) {
items = items.filter(
(e) =>
e.name.toLowerCase().includes(search) ||
e.displayName?.toLowerCase().includes(search) ||
e.description?.toLowerCase().includes(search)
);
}
return this.sortExceptions(items);
});
// Computed: kanban columns
readonly kanbanColumns = computed<ExceptionKanbanColumn[]>(() => {
const items = this.exceptions();
const severity = this.severityFilter();
const search = this.searchQuery().toLowerCase();
let filtered = items;
if (severity !== 'all') {
filtered = filtered.filter((e) => e.severity === severity);
}
if (search) {
filtered = filtered.filter(
(e) =>
e.name.toLowerCase().includes(search) ||
e.displayName?.toLowerCase().includes(search) ||
e.description?.toLowerCase().includes(search)
);
}
return KANBAN_COLUMN_ORDER.map((status) => {
const columnItems = filtered.filter((e) => e.status === status);
return {
status,
label: STATUS_LABELS[status],
items: columnItems,
count: columnItems.length,
};
});
});
// Computed: selected exception
readonly selectedException = computed(() => {
const id = this.selectedExceptionId();
if (!id) return null;
return this.exceptions().find((e) => e.exceptionId === id) ?? null;
});
// Search form
readonly searchForm = this.formBuilder.group({
query: this.formBuilder.control(''),
});
// Audit view state
readonly showAuditPanel = signal(false);
async ngOnInit(): Promise<void> {
await this.loadData();
this.announceToScreenReader('Exception Center loaded. Press X to create exception, A to approve, R to reject.');
}
ngOnDestroy(): void {
if (this.announcementTimeout) {
clearTimeout(this.announcementTimeout);
}
}
// Keyboard shortcuts
@HostListener('document:keydown', ['$event'])
handleKeyboardShortcut(event: KeyboardEvent): void {
// Ignore shortcuts when typing in form fields
const target = event.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable
) {
return;
}
// Ignore if modifier keys are pressed
if (event.ctrlKey || event.altKey || event.metaKey) {
return;
}
switch (event.key.toLowerCase()) {
case 'x':
event.preventDefault();
this.handleCreateShortcut();
break;
case 'a':
event.preventDefault();
this.handleApproveShortcut();
break;
case 'r':
event.preventDefault();
this.handleRejectShortcut();
break;
case 'escape':
if (this.showWizard()) {
event.preventDefault();
this.closeWizard();
} else if (this.selectedExceptionId()) {
event.preventDefault();
this.clearSelection();
}
break;
}
}
private handleCreateShortcut(): void {
if (this.showWizard()) {
return; // Wizard already open
}
this.openWizard();
this.announceToScreenReader('Exception creation wizard opened');
}
private handleApproveShortcut(): void {
const selected = this.selectedException();
if (!selected) {
this.announceToScreenReader('No exception selected. Select an exception first.');
return;
}
if (selected.status !== 'pending_review') {
this.announceToScreenReader(`Cannot approve exception. Current status is ${STATUS_LABELS[selected.status]}.`);
return;
}
this.approveException(selected.exceptionId);
}
private handleRejectShortcut(): void {
const selected = this.selectedException();
if (!selected) {
this.announceToScreenReader('No exception selected. Select an exception first.');
return;
}
if (selected.status !== 'pending_review') {
this.announceToScreenReader(`Cannot reject exception. Current status is ${STATUS_LABELS[selected.status]}.`);
return;
}
this.rejectException(selected.exceptionId);
}
// Screen reader announcements
private announceToScreenReader(message: string): void {
// Clear any pending announcement
if (this.announcementTimeout) {
clearTimeout(this.announcementTimeout);
}
// Set the announcement (this triggers the ARIA live region)
this.screenReaderAnnouncement.set(message);
// Clear after a short delay to allow re-announcement of same message
this.announcementTimeout = setTimeout(() => {
this.screenReaderAnnouncement.set('');
}, 1000);
}
async loadData(): Promise<void> {
this.loading.set(true);
this.message.set(null);
try {
const [exceptionsResponse, statsResponse] = await Promise.all([
firstValueFrom(this.api.listExceptions()),
firstValueFrom(this.api.getStats()),
]);
this.exceptions.set([...exceptionsResponse.items]);
this.stats.set(statsResponse);
} catch (error) {
this.showMessage(this.toErrorMessage(error), 'error');
} finally {
this.loading.set(false);
}
}
async refreshData(): Promise<void> {
await this.loadData();
}
// View mode
setViewMode(mode: ViewMode): void {
this.viewMode.set(mode);
}
// Filters
setStatusFilter(status: StatusFilter): void {
this.statusFilter.set(status);
}
setSeverityFilter(severity: SeverityFilter): void {
this.severityFilter.set(severity);
}
onSearchInput(event: Event): void {
const input = event.target as HTMLInputElement;
this.searchQuery.set(input.value);
}
clearSearch(): void {
this.searchQuery.set('');
this.searchForm.patchValue({ query: '' });
}
// Sorting
toggleSort(field: SortField): void {
if (this.sortField() === field) {
this.sortOrder.set(this.sortOrder() === 'asc' ? 'desc' : 'asc');
} else {
this.sortField.set(field);
this.sortOrder.set('desc');
}
}
getSortIcon(field: SortField): string {
if (this.sortField() !== field) return '';
return this.sortOrder() === 'asc' ? '↑' : '↓';
}
// Selection
selectException(exceptionId: string): void {
this.selectedExceptionId.set(exceptionId);
this.showAuditPanel.set(false);
}
clearSelection(): void {
this.selectedExceptionId.set(null);
this.showAuditPanel.set(false);
}
// Workflow transitions
async transitionStatus(exceptionId: string, newStatus: ExceptionStatus): Promise<void> {
const exception = this.exceptions().find((e) => e.exceptionId === exceptionId);
const exceptionName = exception?.name ?? 'Exception';
this.loading.set(true);
this.announceToScreenReader(`Processing ${exceptionName}...`);
try {
await firstValueFrom(
this.api.transitionStatus({ exceptionId, newStatus })
);
await this.loadData();
const successMessage = `${exceptionName} transitioned to ${STATUS_LABELS[newStatus]}`;
this.showMessage(successMessage, 'success');
this.announceToScreenReader(successMessage);
} catch (error) {
const errorMessage = this.toErrorMessage(error);
this.showMessage(errorMessage, 'error');
this.announceToScreenReader(`Error: ${errorMessage}`);
} finally {
this.loading.set(false);
}
}
async submitForReview(exceptionId: string): Promise<void> {
this.announceToScreenReader('Submitting exception for review...');
await this.transitionStatus(exceptionId, 'pending_review');
}
async approveException(exceptionId: string): Promise<void> {
this.announceToScreenReader('Approving exception...');
await this.transitionStatus(exceptionId, 'approved');
}
async rejectException(exceptionId: string): Promise<void> {
this.announceToScreenReader('Rejecting exception...');
await this.transitionStatus(exceptionId, 'rejected');
}
async revokeException(exceptionId: string): Promise<void> {
this.announceToScreenReader('Revoking exception...');
await this.transitionStatus(exceptionId, 'revoked');
}
// Delete
async deleteException(exceptionId: string): Promise<void> {
if (!confirm('Are you sure you want to delete this exception?')) {
return;
}
this.loading.set(true);
try {
await firstValueFrom(this.api.deleteException(exceptionId));
if (this.selectedExceptionId() === exceptionId) {
this.clearSelection();
}
await this.loadData();
this.showMessage('Exception deleted', 'success');
} catch (error) {
this.showMessage(this.toErrorMessage(error), 'error');
} finally {
this.loading.set(false);
}
}
// Audit panel
toggleAuditPanel(): void {
this.showAuditPanel.set(!this.showAuditPanel());
}
// Wizard
openWizard(): void {
this.showWizard.set(true);
}
closeWizard(): void {
this.showWizard.set(false);
}
async onExceptionCreated(exception: Exception): Promise<void> {
this.closeWizard();
await this.loadData();
this.showMessage(`Exception "${exception.name}" created`, 'success');
this.selectException(exception.exceptionId);
}
// Helpers
getStatusClass(status: ExceptionStatus): string {
switch (status) {
case 'approved':
return 'status--approved';
case 'pending_review':
return 'status--pending';
case 'rejected':
return 'status--rejected';
case 'expired':
return 'status--expired';
case 'revoked':
return 'status--revoked';
default:
return 'status--draft';
}
}
getSeverityClass(severity: ExceptionSeverity): string {
return `severity--${severity}`;
}
formatDate(dateString: string | undefined): string {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
formatDateTime(dateString: string | undefined): string {
if (!dateString) return '-';
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
isExpiringSoon(exception: Exception): boolean {
const endDate = new Date(exception.timebox.endDate);
const now = new Date();
const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
return endDate <= sevenDaysFromNow && endDate > now;
}
getAvailableTransitions(status: ExceptionStatus): ExceptionStatus[] {
switch (status) {
case 'draft':
return ['pending_review'];
case 'pending_review':
return ['approved', 'rejected'];
case 'approved':
return ['revoked'];
case 'rejected':
return ['draft'];
case 'expired':
return ['draft'];
case 'revoked':
return ['draft'];
default:
return [];
}
}
trackByException = (_: number, item: Exception) => item.exceptionId;
trackByColumn = (_: number, item: ExceptionKanbanColumn) => item.status;
trackByAudit = (_: number, item: { auditId: string }) => item.auditId;
private sortExceptions(items: Exception[]): Exception[] {
const field = this.sortField();
const order = this.sortOrder();
return items.sort((a, b) => {
let comparison = 0;
switch (field) {
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'severity':
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
comparison = severityOrder[a.severity] - severityOrder[b.severity];
break;
case 'status':
comparison = a.status.localeCompare(b.status);
break;
case 'updatedAt':
comparison = (a.updatedAt ?? a.createdAt).localeCompare(
b.updatedAt ?? b.createdAt
);
break;
default:
comparison = a.createdAt.localeCompare(b.createdAt);
}
return order === 'asc' ? comparison : -comparison;
});
}
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
this.message.set(text);
this.messageType.set(type);
setTimeout(() => this.message.set(null), 5000);
}
private toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
return 'Operation failed. Please retry.';
}
}

View File

@@ -0,0 +1,168 @@
<div class="draft-inline">
<header class="draft-inline__header">
<h3>Draft Exception</h3>
<span class="draft-inline__source">from {{ context.sourceLabel }}</span>
</header>
<!-- Error Message -->
<div class="draft-inline__error" *ngIf="error()">
{{ error() }}
</div>
<!-- Scope Summary -->
<div class="draft-inline__scope">
<span class="draft-inline__scope-label">Scope:</span>
<span class="draft-inline__scope-value">{{ scopeSummary() }}</span>
<span class="draft-inline__scope-type">{{ scopeType() }}</span>
</div>
<!-- Vulnerabilities Preview -->
<div class="draft-inline__vulns" *ngIf="context.vulnIds?.length">
<span class="draft-inline__vulns-label">Vulnerabilities:</span>
<div class="draft-inline__vulns-list">
<span class="vuln-chip" *ngFor="let vulnId of context.vulnIds | slice:0:5">
{{ vulnId }}
</span>
<span class="vuln-chip vuln-chip--more" *ngIf="context.vulnIds.length > 5">
+{{ context.vulnIds.length - 5 }} more
</span>
</div>
</div>
<!-- Components Preview -->
<div class="draft-inline__components" *ngIf="context.componentPurls?.length">
<span class="draft-inline__components-label">Components:</span>
<div class="draft-inline__components-list">
<span class="component-chip" *ngFor="let purl of context.componentPurls | slice:0:3">
{{ purl | slice:-40 }}
</span>
<span class="component-chip component-chip--more" *ngIf="context.componentPurls.length > 3">
+{{ context.componentPurls.length - 3 }} more
</span>
</div>
</div>
<form [formGroup]="draftForm" class="draft-inline__form">
<!-- Name -->
<div class="form-row">
<label class="form-label" for="draftName">
Name <span class="required">*</span>
</label>
<input
type="text"
id="draftName"
class="form-input"
formControlName="name"
placeholder="e.g., cve-2021-44228-exception"
/>
</div>
<!-- Severity -->
<div class="form-row">
<label class="form-label">Severity</label>
<div class="severity-chips">
<label
*ngFor="let opt of severityOptions"
class="severity-option"
[class.severity-option--selected]="draftForm.controls.severity.value === opt.value"
>
<input type="radio" [value]="opt.value" formControlName="severity" />
<span class="severity-chip severity-chip--{{ opt.value }}">{{ opt.label }}</span>
</label>
</div>
</div>
<!-- Quick Justification -->
<div class="form-row">
<label class="form-label">Quick Justification</label>
<div class="template-chips">
<button
type="button"
*ngFor="let template of quickTemplates"
class="template-chip"
[class.template-chip--selected]="draftForm.controls.justificationTemplate.value === template.id"
(click)="selectTemplate(template.id)"
>
{{ template.label }}
</button>
</div>
</div>
<!-- Justification Text -->
<div class="form-row">
<label class="form-label" for="justificationText">
Justification <span class="required">*</span>
</label>
<textarea
id="justificationText"
class="form-textarea"
formControlName="justificationText"
rows="3"
placeholder="Explain why this exception is needed..."
></textarea>
<span class="form-hint">Minimum 20 characters</span>
</div>
<!-- Timebox -->
<div class="form-row form-row--inline">
<label class="form-label" for="timeboxDays">Duration</label>
<div class="timebox-quick">
<button type="button" class="timebox-btn" (click)="draftForm.patchValue({ timeboxDays: 7 })">7d</button>
<button type="button" class="timebox-btn" (click)="draftForm.patchValue({ timeboxDays: 14 })">14d</button>
<button type="button" class="timebox-btn" (click)="draftForm.patchValue({ timeboxDays: 30 })">30d</button>
<button type="button" class="timebox-btn" (click)="draftForm.patchValue({ timeboxDays: 90 })">90d</button>
<input
type="number"
id="timeboxDays"
class="form-input form-input--small"
formControlName="timeboxDays"
min="1"
max="365"
/>
<span class="timebox-label">days</span>
</div>
</div>
</form>
<!-- Simulation Toggle -->
<div class="draft-inline__simulation">
<button type="button" class="simulation-toggle" (click)="toggleSimulation()">
{{ showSimulation() ? 'Hide' : 'Show' }} Impact Simulation
</button>
<div class="simulation-result" *ngIf="showSimulation() && simulationResult() as sim">
<div class="simulation-stat">
<span class="simulation-stat__label">Affected Findings:</span>
<span class="simulation-stat__value">~{{ sim.affectedFindings }}</span>
</div>
<div class="simulation-stat">
<span class="simulation-stat__label">Policy Impact:</span>
<span class="simulation-stat__value simulation-stat__value--{{ sim.policyImpact }}">
{{ sim.policyImpact }}
</span>
</div>
<div class="simulation-stat">
<span class="simulation-stat__label">Coverage Estimate:</span>
<span class="simulation-stat__value">{{ sim.coverageEstimate }}</span>
</div>
</div>
</div>
<!-- Actions -->
<footer class="draft-inline__footer">
<button type="button" class="btn btn--text" (click)="cancel()">
Cancel
</button>
<button type="button" class="btn btn--secondary" (click)="expandToFullWizard()">
Full Wizard
</button>
<button
type="button"
class="btn btn--primary"
[disabled]="!canSubmit()"
(click)="submitDraft()"
>
{{ loading() ? 'Creating...' : 'Create Draft' }}
</button>
</footer>
</div>

View File

@@ -0,0 +1,435 @@
.draft-inline {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background: white;
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
// Header
.draft-inline__header {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #f1f5f9;
h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #1e293b;
}
}
.draft-inline__source {
font-size: 0.75rem;
color: #64748b;
}
// Error
.draft-inline__error {
padding: 0.5rem 0.75rem;
background: #fef2f2;
color: #991b1b;
border: 1px solid #fca5a5;
border-radius: 0.375rem;
font-size: 0.8125rem;
}
// Scope
.draft-inline__scope {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #f8fafc;
border-radius: 0.375rem;
font-size: 0.8125rem;
}
.draft-inline__scope-label {
color: #64748b;
font-weight: 500;
}
.draft-inline__scope-value {
color: #1e293b;
flex: 1;
}
.draft-inline__scope-type {
padding: 0.125rem 0.5rem;
background: #e0e7ff;
color: #4338ca;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
}
// Vulnerabilities preview
.draft-inline__vulns,
.draft-inline__components {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.draft-inline__vulns-label,
.draft-inline__components-label {
font-size: 0.75rem;
color: #64748b;
font-weight: 500;
}
.draft-inline__vulns-list,
.draft-inline__components-list {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.vuln-chip {
display: inline-flex;
padding: 0.125rem 0.5rem;
background: #fef2f2;
color: #dc2626;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-family: ui-monospace, monospace;
&--more {
background: #f1f5f9;
color: #64748b;
}
}
.component-chip {
display: inline-flex;
padding: 0.125rem 0.5rem;
background: #f0fdf4;
color: #166534;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-family: ui-monospace, monospace;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&--more {
background: #f1f5f9;
color: #64748b;
}
}
// Form
.draft-inline__form {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.form-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
&--inline {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
}
.form-label {
font-size: 0.75rem;
font-weight: 500;
color: #475569;
}
.required {
color: #ef4444;
}
.form-input {
padding: 0.5rem 0.625rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
font-size: 0.8125rem;
transition: border-color 0.2s ease;
&:focus {
outline: none;
border-color: #4f46e5;
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
}
&--small {
width: 60px;
text-align: center;
}
}
.form-textarea {
padding: 0.5rem 0.625rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
font-size: 0.8125rem;
resize: vertical;
min-height: 60px;
&:focus {
outline: none;
border-color: #4f46e5;
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
}
}
.form-hint {
font-size: 0.6875rem;
color: #94a3b8;
}
// Severity chips
.severity-chips {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.severity-option {
cursor: pointer;
input {
display: none;
}
&--selected .severity-chip {
box-shadow: 0 0 0 2px currentColor;
}
}
.severity-chip {
display: inline-flex;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 500;
transition: box-shadow 0.2s ease;
&--critical {
background: #fef2f2;
color: #dc2626;
}
&--high {
background: #fff7ed;
color: #ea580c;
}
&--medium {
background: #fefce8;
color: #ca8a04;
}
&--low {
background: #f0fdf4;
color: #16a34a;
}
}
// Template chips
.template-chips {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.template-chip {
padding: 0.25rem 0.625rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
background: white;
color: #475569;
font-size: 0.6875rem;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f8fafc;
border-color: #4f46e5;
}
&--selected {
background: #eef2ff;
border-color: #4f46e5;
color: #4f46e5;
}
}
// Timebox
.timebox-quick {
display: flex;
align-items: center;
gap: 0.375rem;
}
.timebox-btn {
padding: 0.25rem 0.5rem;
border: 1px solid #e2e8f0;
border-radius: 0.25rem;
background: white;
color: #64748b;
font-size: 0.6875rem;
cursor: pointer;
&:hover {
background: #f1f5f9;
border-color: #4f46e5;
color: #4f46e5;
}
}
.timebox-label {
font-size: 0.75rem;
color: #64748b;
}
// Simulation
.draft-inline__simulation {
padding-top: 0.75rem;
border-top: 1px solid #f1f5f9;
}
.simulation-toggle {
padding: 0.375rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
background: white;
color: #4f46e5;
font-size: 0.75rem;
cursor: pointer;
&:hover {
background: #eef2ff;
}
}
.simulation-result {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
margin-top: 0.75rem;
padding: 0.75rem;
background: #f8fafc;
border-radius: 0.375rem;
}
.simulation-stat {
display: flex;
flex-direction: column;
gap: 0.125rem;
text-align: center;
}
.simulation-stat__label {
font-size: 0.625rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.simulation-stat__value {
font-size: 0.875rem;
font-weight: 600;
color: #1e293b;
&--high {
color: #dc2626;
}
&--moderate {
color: #f59e0b;
}
&--low {
color: #10b981;
}
}
// Footer
.draft-inline__footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid #f1f5f9;
}
// Buttons
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&--primary {
background: #4f46e5;
color: white;
&:hover:not(:disabled) {
background: #4338ca;
}
}
&--secondary {
background: white;
color: #475569;
border: 1px solid #e2e8f0;
&:hover:not(:disabled) {
background: #f8fafc;
}
}
&--text {
background: transparent;
color: #64748b;
&:hover:not(:disabled) {
color: #1e293b;
}
}
}
// Responsive
@media (max-width: 480px) {
.simulation-result {
grid-template-columns: 1fr;
}
.draft-inline__footer {
flex-direction: column;
}
.severity-chips,
.template-chips {
flex-direction: column;
}
}

View File

@@ -0,0 +1,213 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnInit,
Output,
computed,
inject,
signal,
} from '@angular/core';
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import {
EXCEPTION_API,
ExceptionApi,
MockExceptionApiService,
} from '../../core/api/exception.client';
import {
Exception,
ExceptionScope,
ExceptionSeverity,
} from '../../core/api/exception.models';
export interface ExceptionDraftContext {
readonly vulnIds?: readonly string[];
readonly componentPurls?: readonly string[];
readonly assetIds?: readonly string[];
readonly tenantId?: string;
readonly suggestedName?: string;
readonly suggestedSeverity?: ExceptionSeverity;
readonly sourceType: 'vulnerability' | 'component' | 'asset' | 'graph';
readonly sourceLabel: string;
}
const QUICK_TEMPLATES = [
{ id: 'risk-accepted', label: 'Risk Accepted', text: 'Risk has been reviewed and formally accepted.' },
{ id: 'compensating-control', label: 'Compensating Control', text: 'Compensating controls in place: ' },
{ id: 'false-positive', label: 'False Positive', text: 'This finding is a false positive because: ' },
{ id: 'scheduled-fix', label: 'Scheduled Fix', text: 'Fix scheduled for deployment. Timeline: ' },
] as const;
const SEVERITY_OPTIONS: readonly { value: ExceptionSeverity; label: string }[] = [
{ value: 'critical', label: 'Critical' },
{ value: 'high', label: 'High' },
{ value: 'medium', label: 'Medium' },
{ value: 'low', label: 'Low' },
];
@Component({
selector: 'app-exception-draft-inline',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './exception-draft-inline.component.html',
styleUrls: ['./exception-draft-inline.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{ provide: EXCEPTION_API, useClass: MockExceptionApiService },
],
})
export class ExceptionDraftInlineComponent implements OnInit {
private readonly api = inject<ExceptionApi>(EXCEPTION_API);
private readonly formBuilder = inject(NonNullableFormBuilder);
@Input() context!: ExceptionDraftContext;
@Output() readonly created = new EventEmitter<Exception>();
@Output() readonly cancelled = new EventEmitter<void>();
@Output() readonly openFullWizard = new EventEmitter<ExceptionDraftContext>();
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly showSimulation = signal(false);
readonly quickTemplates = QUICK_TEMPLATES;
readonly severityOptions = SEVERITY_OPTIONS;
readonly draftForm = this.formBuilder.group({
name: this.formBuilder.control('', {
validators: [Validators.required, Validators.minLength(3)],
}),
severity: this.formBuilder.control<ExceptionSeverity>('medium'),
justificationTemplate: this.formBuilder.control('risk-accepted'),
justificationText: this.formBuilder.control('', {
validators: [Validators.required, Validators.minLength(20)],
}),
timeboxDays: this.formBuilder.control(30),
});
readonly scopeType = computed<ExceptionScope>(() => {
if (this.context?.componentPurls?.length) return 'component';
if (this.context?.assetIds?.length) return 'asset';
if (this.context?.tenantId) return 'tenant';
return 'global';
});
readonly scopeSummary = computed(() => {
const ctx = this.context;
const items: string[] = [];
if (ctx?.vulnIds?.length) {
items.push(`${ctx.vulnIds.length} vulnerabilit${ctx.vulnIds.length === 1 ? 'y' : 'ies'}`);
}
if (ctx?.componentPurls?.length) {
items.push(`${ctx.componentPurls.length} component${ctx.componentPurls.length === 1 ? '' : 's'}`);
}
if (ctx?.assetIds?.length) {
items.push(`${ctx.assetIds.length} asset${ctx.assetIds.length === 1 ? '' : 's'}`);
}
if (ctx?.tenantId) {
items.push(`Tenant: ${ctx.tenantId}`);
}
return items.length > 0 ? items.join(', ') : 'Global scope';
});
readonly simulationResult = computed(() => {
if (!this.showSimulation()) return null;
const vulnCount = this.context?.vulnIds?.length ?? 0;
const componentCount = this.context?.componentPurls?.length ?? 0;
return {
affectedFindings: vulnCount * Math.max(1, componentCount),
policyImpact: this.draftForm.controls.severity.value === 'critical' ? 'high' : 'moderate',
coverageEstimate: `~${Math.min(100, vulnCount * 15 + componentCount * 10)}%`,
};
});
readonly canSubmit = computed(() => {
return this.draftForm.valid && !this.loading();
});
ngOnInit(): void {
if (this.context?.suggestedName) {
this.draftForm.patchValue({ name: this.context.suggestedName });
}
if (this.context?.suggestedSeverity) {
this.draftForm.patchValue({ severity: this.context.suggestedSeverity });
}
const defaultTemplate = this.quickTemplates[0];
this.draftForm.patchValue({ justificationText: defaultTemplate.text });
}
selectTemplate(templateId: string): void {
const template = this.quickTemplates.find((t) => t.id === templateId);
if (template) {
this.draftForm.patchValue({
justificationTemplate: templateId,
justificationText: template.text,
});
}
}
toggleSimulation(): void {
this.showSimulation.set(!this.showSimulation());
}
async submitDraft(): Promise<void> {
if (!this.canSubmit()) return;
this.loading.set(true);
this.error.set(null);
try {
const formValue = this.draftForm.getRawValue();
const endDate = new Date();
endDate.setDate(endDate.getDate() + formValue.timeboxDays);
const exception: Partial<Exception> = {
name: formValue.name,
severity: formValue.severity,
status: 'draft',
scope: {
type: this.scopeType(),
tenantId: this.context?.tenantId,
assetIds: this.context?.assetIds ? [...this.context.assetIds] : undefined,
componentPurls: this.context?.componentPurls ? [...this.context.componentPurls] : undefined,
vulnIds: this.context?.vulnIds ? [...this.context.vulnIds] : undefined,
},
justification: {
template: formValue.justificationTemplate,
text: formValue.justificationText,
},
timebox: {
startDate: new Date().toISOString(),
endDate: endDate.toISOString(),
},
};
const created = await firstValueFrom(this.api.createException(exception));
this.created.emit(created);
} catch (err) {
this.error.set(err instanceof Error ? err.message : 'Failed to create exception draft.');
} finally {
this.loading.set(false);
}
}
cancel(): void {
this.cancelled.emit();
}
expandToFullWizard(): void {
this.openFullWizard.emit(this.context);
}
}

View File

@@ -0,0 +1,438 @@
<div class="wizard">
<!-- Progress Steps -->
<nav class="wizard__steps">
<button
*ngFor="let step of steps; let i = index"
type="button"
class="wizard__step"
[class.wizard__step--active]="isStepActive(step)"
[class.wizard__step--completed]="isStepCompleted(step)"
[class.wizard__step--disabled]="!canNavigateToStep(step)"
(click)="goToStep(step)"
[disabled]="!canNavigateToStep(step)"
>
<span class="wizard__step-number">{{ i + 1 }}</span>
<span class="wizard__step-label">{{ getStepLabel(step) }}</span>
</button>
</nav>
<!-- Error Message -->
<div class="wizard__error" *ngIf="error()">
{{ error() }}
</div>
<!-- Step Content -->
<div class="wizard__content">
<!-- Step 1: Basics -->
<div class="wizard__panel" *ngIf="currentStep() === 'basics'">
<h2>Basic Information</h2>
<p class="wizard__description">Provide a name and description for this exception.</p>
<form [formGroup]="basicsForm" class="form">
<div class="form__group">
<label class="form__label" for="name">
Exception Name <span class="form__required">*</span>
</label>
<input
type="text"
id="name"
class="form__input"
formControlName="name"
placeholder="e.g., log4j-legacy-exception"
/>
<span class="form__hint">A unique identifier (3-100 characters, no spaces preferred)</span>
<span class="form__error" *ngIf="basicsForm.controls.name.touched && basicsForm.controls.name.errors?.['required']">
Name is required
</span>
<span class="form__error" *ngIf="basicsForm.controls.name.errors?.['minlength']">
Name must be at least 3 characters
</span>
</div>
<div class="form__group">
<label class="form__label" for="displayName">Display Name</label>
<input
type="text"
id="displayName"
class="form__input"
formControlName="displayName"
placeholder="e.g., Log4j Legacy Exception"
/>
<span class="form__hint">Human-friendly name for display in UI</span>
</div>
<div class="form__group">
<label class="form__label" for="description">Description</label>
<textarea
id="description"
class="form__textarea"
formControlName="description"
rows="3"
placeholder="Briefly describe why this exception is needed..."
></textarea>
<span class="form__hint">Max 500 characters</span>
</div>
<div class="form__group">
<label class="form__label">Severity</label>
<div class="form__radio-group">
<label
*ngFor="let opt of severityOptions"
class="form__radio"
[class.form__radio--selected]="basicsForm.controls.severity.value === opt.value"
>
<input
type="radio"
[value]="opt.value"
formControlName="severity"
/>
<span class="severity-chip severity-chip--{{ opt.value }}">{{ opt.label }}</span>
</label>
</div>
</div>
</form>
</div>
<!-- Step 2: Scope -->
<div class="wizard__panel" *ngIf="currentStep() === 'scope'">
<h2>Exception Scope</h2>
<p class="wizard__description">Define what this exception applies to.</p>
<form [formGroup]="scopeForm" class="form">
<div class="form__group">
<label class="form__label">Scope Type <span class="form__required">*</span></label>
<div class="scope-type-cards">
<button
*ngFor="let type of scopeTypes"
type="button"
class="scope-type-card"
[class.scope-type-card--selected]="scopeForm.controls.type.value === type.value"
(click)="scopeForm.patchValue({ type: type.value })"
>
<span class="scope-type-card__label">{{ type.label }}</span>
<span class="scope-type-card__desc">{{ type.description }}</span>
</button>
</div>
</div>
<!-- Tenant scope -->
<div class="form__group" *ngIf="scopeForm.controls.type.value === 'tenant'">
<label class="form__label" for="tenantId">
Tenant ID <span class="form__required">*</span>
</label>
<input
type="text"
id="tenantId"
class="form__input"
formControlName="tenantId"
placeholder="e.g., tenant-prod-001"
/>
</div>
<!-- Asset scope -->
<div class="form__group" *ngIf="scopeForm.controls.type.value === 'asset'">
<label class="form__label" for="assetIds">
Asset IDs <span class="form__required">*</span>
</label>
<textarea
id="assetIds"
class="form__textarea"
formControlName="assetIds"
rows="3"
placeholder="Enter asset IDs, one per line or comma-separated"
></textarea>
<span class="form__hint">e.g., asset-web-prod, asset-api-prod</span>
</div>
<!-- Component scope -->
<div class="form__group" *ngIf="scopeForm.controls.type.value === 'component'">
<label class="form__label" for="componentPurls">
Component PURLs <span class="form__required">*</span>
</label>
<textarea
id="componentPurls"
class="form__textarea"
formControlName="componentPurls"
rows="3"
placeholder="Enter Package URLs, one per line"
></textarea>
<span class="form__hint">e.g., pkg:maven/org.apache.logging.log4j/log4j-core&#64;2.14.1</span>
</div>
<!-- Vulnerability IDs (for all non-global scopes) -->
<div class="form__group" *ngIf="scopeForm.controls.type.value !== 'global'">
<label class="form__label" for="vulnIds">Vulnerability IDs (Optional)</label>
<textarea
id="vulnIds"
class="form__textarea"
formControlName="vulnIds"
rows="2"
placeholder="CVE-2021-44228, CVE-2021-45046"
></textarea>
<span class="form__hint">Leave empty to apply to all vulnerabilities in scope</span>
</div>
<!-- Scope Preview -->
<div class="scope-preview">
<h3 class="scope-preview__title">Scope Preview</h3>
<ul class="scope-preview__list">
<li *ngFor="let item of scopePreview()">{{ item }}</li>
</ul>
</div>
</form>
</div>
<!-- Step 3: Justification -->
<div class="wizard__panel" *ngIf="currentStep() === 'justification'">
<h2>Justification</h2>
<p class="wizard__description">Explain why this exception is needed.</p>
<form [formGroup]="justificationForm" class="form">
<div class="form__group">
<label class="form__label">Justification Template</label>
<div class="template-cards">
<button
*ngFor="let template of justificationTemplates"
type="button"
class="template-card"
[class.template-card--selected]="justificationForm.controls.template.value === template.id"
(click)="selectTemplate(template.id)"
>
<span class="template-card__name">{{ template.name }}</span>
<span class="template-card__desc">{{ template.description }}</span>
</button>
</div>
</div>
<div class="form__group">
<label class="form__label" for="justificationText">
Justification Text <span class="form__required">*</span>
</label>
<textarea
id="justificationText"
class="form__textarea form__textarea--large"
formControlName="text"
rows="6"
placeholder="Provide a detailed justification..."
></textarea>
<span class="form__hint">Minimum 20 characters. Be specific about the risk mitigation.</span>
<span class="form__error" *ngIf="justificationForm.controls.text.touched && justificationForm.controls.text.errors?.['required']">
Justification text is required
</span>
<span class="form__error" *ngIf="justificationForm.controls.text.errors?.['minlength']">
Justification must be at least 20 characters
</span>
</div>
<div class="form__group">
<label class="form__label" for="attachments">Attachments (Optional)</label>
<textarea
id="attachments"
class="form__textarea"
formControlName="attachments"
rows="2"
placeholder="Links to supporting documents, one per line"
></textarea>
<span class="form__hint">e.g., JIRA ticket URLs, risk assessment documents</span>
</div>
</form>
</div>
<!-- Step 4: Timebox -->
<div class="wizard__panel" *ngIf="currentStep() === 'timebox'">
<h2>Timebox</h2>
<p class="wizard__description">Set the validity period for this exception.</p>
<form [formGroup]="timeboxForm" class="form">
<div class="timebox-presets">
<span class="timebox-presets__label">Quick presets:</span>
<button type="button" class="timebox-preset" (click)="setTimeboxPreset(7)">7 days</button>
<button type="button" class="timebox-preset" (click)="setTimeboxPreset(14)">14 days</button>
<button type="button" class="timebox-preset" (click)="setTimeboxPreset(30)">30 days</button>
<button type="button" class="timebox-preset" (click)="setTimeboxPreset(90)">90 days</button>
</div>
<div class="form__row">
<div class="form__group form__group--half">
<label class="form__label" for="startDate">
Start Date <span class="form__required">*</span>
</label>
<input
type="date"
id="startDate"
class="form__input"
formControlName="startDate"
/>
</div>
<div class="form__group form__group--half">
<label class="form__label" for="endDate">
End Date <span class="form__required">*</span>
</label>
<input
type="date"
id="endDate"
class="form__input"
formControlName="endDate"
/>
</div>
</div>
<div class="timebox-summary" [class.timebox-summary--warning]="timeboxWarning()">
<span class="timebox-summary__duration">Duration: {{ timeboxDays() }} days</span>
<span class="timebox-summary__warning" *ngIf="timeboxWarning()">
{{ timeboxWarning() }}
</span>
</div>
<div class="form__group">
<label class="form__checkbox">
<input type="checkbox" formControlName="autoRenew" />
<span>Enable auto-renewal</span>
</label>
<span class="form__hint">Automatically renew when the exception expires</span>
</div>
<div class="form__group" *ngIf="timeboxForm.controls.autoRenew.value">
<label class="form__label" for="maxRenewals">Maximum Renewals</label>
<input
type="number"
id="maxRenewals"
class="form__input form__input--small"
formControlName="maxRenewals"
min="0"
max="12"
/>
<span class="form__hint">0 = unlimited (not recommended)</span>
</div>
<div class="timebox-guardrails">
<h4>Guardrails</h4>
<ul>
<li [class.guardrail--pass]="timeboxDays() <= 365">
Maximum duration: 365 days
</li>
<li [class.guardrail--pass]="timeboxDays() <= 90" [class.guardrail--warning]="timeboxDays() > 90 && timeboxDays() <= 365">
Recommended: &le;90 days with renewal
</li>
<li [class.guardrail--pass]="!timeboxForm.controls.autoRenew.value || timeboxForm.controls.maxRenewals.value > 0">
Auto-renewal should have a limit
</li>
</ul>
</div>
</form>
</div>
<!-- Step 5: Review -->
<div class="wizard__panel" *ngIf="currentStep() === 'review'">
<h2>Review Exception</h2>
<p class="wizard__description">Review all details before creating the exception.</p>
<div class="review-section">
<h3>Basic Information</h3>
<dl class="review-list">
<dt>Name</dt>
<dd>{{ exceptionPreview().name }}</dd>
<dt>Display Name</dt>
<dd>{{ exceptionPreview().displayName || '-' }}</dd>
<dt>Description</dt>
<dd>{{ exceptionPreview().description || '-' }}</dd>
<dt>Severity</dt>
<dd>
<span class="severity-chip severity-chip--{{ exceptionPreview().severity }}">
{{ exceptionPreview().severity }}
</span>
</dd>
</dl>
<button type="button" class="review-edit" (click)="goToStep('basics')">Edit</button>
</div>
<div class="review-section">
<h3>Scope</h3>
<dl class="review-list">
<dt>Type</dt>
<dd>{{ exceptionPreview().scope?.type }}</dd>
<dt>Details</dt>
<dd>
<ul class="review-scope-list">
<li *ngFor="let item of scopePreview()">{{ item }}</li>
</ul>
</dd>
</dl>
<button type="button" class="review-edit" (click)="goToStep('scope')">Edit</button>
</div>
<div class="review-section">
<h3>Justification</h3>
<dl class="review-list">
<dt>Template</dt>
<dd>{{ selectedTemplate()?.name || 'Custom' }}</dd>
<dt>Text</dt>
<dd class="review-text">{{ exceptionPreview().justification?.text }}</dd>
</dl>
<button type="button" class="review-edit" (click)="goToStep('justification')">Edit</button>
</div>
<div class="review-section">
<h3>Timebox</h3>
<dl class="review-list">
<dt>Start Date</dt>
<dd>{{ exceptionPreview().timebox?.startDate | date:'mediumDate' }}</dd>
<dt>End Date</dt>
<dd>{{ exceptionPreview().timebox?.endDate | date:'mediumDate' }}</dd>
<dt>Duration</dt>
<dd>{{ timeboxDays() }} days</dd>
<dt>Auto-Renew</dt>
<dd>{{ exceptionPreview().timebox?.autoRenew ? 'Yes' : 'No' }}</dd>
</dl>
<button type="button" class="review-edit" (click)="goToStep('timebox')">Edit</button>
</div>
<div class="review-notice">
<p>The exception will be created in <strong>Draft</strong> status. Submit it for review to start the approval workflow.</p>
</div>
</div>
</div>
<!-- Footer Actions -->
<footer class="wizard__footer">
<button
type="button"
class="btn btn--secondary"
(click)="cancel()"
>
Cancel
</button>
<div class="wizard__footer-right">
<button
type="button"
class="btn btn--secondary"
(click)="prevStep()"
*ngIf="currentStep() !== 'basics'"
>
Back
</button>
<button
type="button"
class="btn btn--primary"
(click)="nextStep()"
[disabled]="!canProceed()"
*ngIf="currentStep() !== 'review'"
>
Continue
</button>
<button
type="button"
class="btn btn--primary"
(click)="submitException()"
[disabled]="loading()"
*ngIf="currentStep() === 'review'"
>
{{ loading() ? 'Creating...' : 'Create Exception' }}
</button>
</div>
</footer>
</div>

View File

@@ -0,0 +1,654 @@
.wizard {
display: flex;
flex-direction: column;
height: 100%;
background: white;
border-radius: 0.75rem;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
overflow: hidden;
}
// Progress Steps
.wizard__steps {
display: flex;
justify-content: center;
gap: 0.5rem;
padding: 1.5rem;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.wizard__step {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: none;
background: transparent;
cursor: pointer;
border-radius: 0.5rem;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background: #e2e8f0;
}
&--active {
background: #4f46e5;
color: white;
.wizard__step-number {
background: white;
color: #4f46e5;
}
&:hover {
background: #4338ca;
}
}
&--completed {
.wizard__step-number {
background: #10b981;
color: white;
&::after {
content: '';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
}
.wizard__step-number span {
visibility: hidden;
}
}
&--disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.wizard__step-number {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: #e2e8f0;
color: #64748b;
border-radius: 50%;
font-size: 0.875rem;
font-weight: 600;
}
.wizard__step-label {
font-size: 0.875rem;
font-weight: 500;
}
// Error
.wizard__error {
margin: 1rem 1.5rem 0;
padding: 0.75rem 1rem;
background: #fef2f2;
color: #991b1b;
border: 1px solid #fca5a5;
border-radius: 0.5rem;
font-size: 0.875rem;
}
// Content
.wizard__content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.wizard__panel {
max-width: 640px;
margin: 0 auto;
h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
font-weight: 600;
color: #1e293b;
}
}
.wizard__description {
margin: 0 0 1.5rem;
color: #64748b;
font-size: 0.875rem;
}
// Forms
.form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.form__group {
display: flex;
flex-direction: column;
gap: 0.375rem;
&--half {
flex: 1;
}
}
.form__row {
display: flex;
gap: 1rem;
}
.form__label {
font-size: 0.875rem;
font-weight: 500;
color: #334155;
}
.form__required {
color: #ef4444;
}
.form__input,
.form__textarea {
padding: 0.625rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 0.875rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
&:focus {
outline: none;
border-color: #4f46e5;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
&::placeholder {
color: #94a3b8;
}
}
.form__input--small {
max-width: 120px;
}
.form__textarea {
resize: vertical;
min-height: 80px;
&--large {
min-height: 150px;
}
}
.form__hint {
font-size: 0.75rem;
color: #94a3b8;
}
.form__error {
font-size: 0.75rem;
color: #ef4444;
}
.form__radio-group {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.form__radio {
display: flex;
align-items: center;
cursor: pointer;
input {
display: none;
}
&--selected .severity-chip {
ring: 2px solid currentColor;
box-shadow: 0 0 0 2px currentColor;
}
}
.form__checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
color: #334155;
input {
width: 18px;
height: 18px;
cursor: pointer;
}
}
// Severity chips
.severity-chip {
display: inline-flex;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
font-size: 0.8125rem;
font-weight: 500;
text-transform: capitalize;
&--critical {
background: #fef2f2;
color: #dc2626;
}
&--high {
background: #fff7ed;
color: #ea580c;
}
&--medium {
background: #fefce8;
color: #ca8a04;
}
&--low {
background: #f0fdf4;
color: #16a34a;
}
}
// Scope type cards
.scope-type-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.scope-type-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
padding: 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
background: white;
cursor: pointer;
text-align: left;
transition: all 0.2s ease;
&:hover {
border-color: #c7d2fe;
background: #f8fafc;
}
&--selected {
border-color: #4f46e5;
background: #eef2ff;
.scope-type-card__label {
color: #4f46e5;
}
}
}
.scope-type-card__label {
font-weight: 600;
color: #1e293b;
font-size: 0.9375rem;
}
.scope-type-card__desc {
font-size: 0.75rem;
color: #64748b;
}
// Scope preview
.scope-preview {
margin-top: 1.5rem;
padding: 1rem;
background: #f1f5f9;
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
}
.scope-preview__title {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.scope-preview__list {
margin: 0;
padding-left: 1.25rem;
font-size: 0.875rem;
color: #334155;
li {
margin-bottom: 0.25rem;
}
}
// Template cards
.template-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.template-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
padding: 0.875rem;
border: 2px solid #e2e8f0;
border-radius: 0.5rem;
background: white;
cursor: pointer;
text-align: left;
transition: all 0.2s ease;
&:hover {
border-color: #c7d2fe;
}
&--selected {
border-color: #4f46e5;
background: #eef2ff;
}
}
.template-card__name {
font-weight: 500;
color: #1e293b;
font-size: 0.875rem;
}
.template-card__desc {
font-size: 0.6875rem;
color: #64748b;
line-height: 1.3;
}
// Timebox
.timebox-presets {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.timebox-presets__label {
font-size: 0.875rem;
color: #64748b;
}
.timebox-preset {
padding: 0.375rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
background: white;
color: #475569;
font-size: 0.8125rem;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f1f5f9;
border-color: #4f46e5;
color: #4f46e5;
}
}
.timebox-summary {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: #f0fdf4;
border-radius: 0.5rem;
margin-top: 0.5rem;
&--warning {
background: #fef3c7;
}
}
.timebox-summary__duration {
font-weight: 500;
color: #166534;
.timebox-summary--warning & {
color: #92400e;
}
}
.timebox-summary__warning {
font-size: 0.8125rem;
color: #92400e;
}
.timebox-guardrails {
margin-top: 1.5rem;
padding: 1rem;
background: #f8fafc;
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
h4 {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
ul {
margin: 0;
padding-left: 1.25rem;
font-size: 0.875rem;
color: #64748b;
}
li {
margin-bottom: 0.25rem;
&::marker {
color: #e2e8f0;
}
}
.guardrail--pass {
color: #166534;
&::marker {
color: #10b981;
}
}
.guardrail--warning {
color: #92400e;
&::marker {
color: #f59e0b;
}
}
}
// Review
.review-section {
position: relative;
padding: 1.25rem;
margin-bottom: 1rem;
background: #f8fafc;
border-radius: 0.75rem;
border: 1px solid #e2e8f0;
h3 {
margin: 0 0 0.75rem;
font-size: 0.9375rem;
font-weight: 600;
color: #1e293b;
}
}
.review-list {
margin: 0;
display: grid;
grid-template-columns: 120px 1fr;
gap: 0.5rem;
dt {
font-size: 0.8125rem;
color: #64748b;
}
dd {
margin: 0;
font-size: 0.875rem;
color: #1e293b;
}
}
.review-scope-list {
margin: 0;
padding-left: 1rem;
}
.review-text {
white-space: pre-wrap;
max-height: 100px;
overflow-y: auto;
}
.review-edit {
position: absolute;
top: 1rem;
right: 1rem;
padding: 0.25rem 0.625rem;
border: 1px solid #e2e8f0;
border-radius: 0.25rem;
background: white;
color: #4f46e5;
font-size: 0.75rem;
cursor: pointer;
&:hover {
background: #eef2ff;
}
}
.review-notice {
padding: 1rem;
background: #e0f2fe;
border-radius: 0.5rem;
font-size: 0.875rem;
color: #0369a1;
p {
margin: 0;
}
strong {
font-weight: 600;
}
}
// Footer
.wizard__footer {
display: flex;
justify-content: space-between;
padding: 1rem 1.5rem;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
.wizard__footer-right {
display: flex;
gap: 0.75rem;
}
// Buttons
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.625rem 1.25rem;
border: none;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&--primary {
background: #4f46e5;
color: white;
&:hover:not(:disabled) {
background: #4338ca;
}
}
&--secondary {
background: white;
color: #475569;
border: 1px solid #e2e8f0;
&:hover:not(:disabled) {
background: #f8fafc;
}
}
}
// Responsive
@media (max-width: 640px) {
.wizard__steps {
flex-wrap: wrap;
gap: 0.25rem;
padding: 1rem;
}
.wizard__step-label {
display: none;
}
.scope-type-cards,
.template-cards {
grid-template-columns: 1fr;
}
.form__row {
flex-direction: column;
}
.review-list {
grid-template-columns: 1fr;
dt {
font-weight: 500;
}
}
}

View File

@@ -0,0 +1,453 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
OnInit,
Output,
computed,
inject,
signal,
} from '@angular/core';
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import {
EXCEPTION_API,
ExceptionApi,
MockExceptionApiService,
} from '../../core/api/exception.client';
import {
Exception,
ExceptionJustification,
ExceptionScope,
ExceptionSeverity,
ExceptionTimebox,
} from '../../core/api/exception.models';
type WizardStep = 'basics' | 'scope' | 'justification' | 'timebox' | 'review';
interface JustificationTemplate {
readonly id: string;
readonly name: string;
readonly description: string;
readonly defaultText: string;
readonly requiredFields: readonly string[];
}
const JUSTIFICATION_TEMPLATES: readonly JustificationTemplate[] = [
{
id: 'risk-accepted',
name: 'Risk Accepted',
description: 'The risk has been formally accepted by stakeholders',
defaultText: 'Risk has been reviewed and formally accepted. Rationale: ',
requiredFields: ['approver', 'rationale'],
},
{
id: 'compensating-control',
name: 'Compensating Control',
description: 'Alternative security controls are in place to mitigate the risk',
defaultText: 'Compensating controls in place: ',
requiredFields: ['controls', 'effectiveness'],
},
{
id: 'false-positive',
name: 'False Positive',
description: 'The finding has been determined to be a false positive',
defaultText: 'This finding is a false positive because: ',
requiredFields: ['evidence'],
},
{
id: 'scheduled-fix',
name: 'Scheduled Fix',
description: 'A fix is planned within the timebox period',
defaultText: 'Fix scheduled for deployment. Timeline: ',
requiredFields: ['timeline', 'ticket'],
},
{
id: 'internal-only',
name: 'Internal Only',
description: 'Component is used only in internal/non-production environments',
defaultText: 'This component is used only in internal environments with no external exposure. Environment: ',
requiredFields: ['environment'],
},
{
id: 'custom',
name: 'Custom',
description: 'Provide a custom justification',
defaultText: '',
requiredFields: [],
},
];
const SCOPE_TYPES: readonly { value: ExceptionScope; label: string; description: string }[] = [
{ value: 'global', label: 'Global', description: 'Applies to all assets and components' },
{ value: 'tenant', label: 'Tenant', description: 'Applies to a specific tenant' },
{ value: 'asset', label: 'Asset', description: 'Applies to specific assets' },
{ value: 'component', label: 'Component', description: 'Applies to specific components/PURLs' },
];
const SEVERITY_OPTIONS: readonly { value: ExceptionSeverity; label: string }[] = [
{ value: 'critical', label: 'Critical' },
{ value: 'high', label: 'High' },
{ value: 'medium', label: 'Medium' },
{ value: 'low', label: 'Low' },
];
const MAX_TIMEBOX_DAYS = 365;
const DEFAULT_TIMEBOX_DAYS = 30;
const MIN_TIMEBOX_DAYS = 1;
@Component({
selector: 'app-exception-wizard',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './exception-wizard.component.html',
styleUrls: ['./exception-wizard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{ provide: EXCEPTION_API, useClass: MockExceptionApiService },
],
})
export class ExceptionWizardComponent implements OnInit {
private readonly api = inject<ExceptionApi>(EXCEPTION_API);
private readonly formBuilder = inject(NonNullableFormBuilder);
@Output() readonly created = new EventEmitter<Exception>();
@Output() readonly cancelled = new EventEmitter<void>();
// Wizard state
readonly currentStep = signal<WizardStep>('basics');
readonly loading = signal(false);
readonly error = signal<string | null>(null);
// Constants for template
readonly steps: readonly WizardStep[] = ['basics', 'scope', 'justification', 'timebox', 'review'];
readonly justificationTemplates = JUSTIFICATION_TEMPLATES;
readonly scopeTypes = SCOPE_TYPES;
readonly severityOptions = SEVERITY_OPTIONS;
// Forms for each step
readonly basicsForm = this.formBuilder.group({
name: this.formBuilder.control('', {
validators: [Validators.required, Validators.minLength(3), Validators.maxLength(100)],
}),
displayName: this.formBuilder.control(''),
description: this.formBuilder.control('', {
validators: [Validators.maxLength(500)],
}),
severity: this.formBuilder.control<ExceptionSeverity>('medium'),
});
readonly scopeForm = this.formBuilder.group({
type: this.formBuilder.control<ExceptionScope>('component'),
tenantId: this.formBuilder.control(''),
assetIds: this.formBuilder.control(''),
componentPurls: this.formBuilder.control(''),
vulnIds: this.formBuilder.control(''),
});
readonly justificationForm = this.formBuilder.group({
template: this.formBuilder.control('risk-accepted'),
text: this.formBuilder.control('', {
validators: [Validators.required, Validators.minLength(20)],
}),
attachments: this.formBuilder.control(''),
});
readonly timeboxForm = this.formBuilder.group({
startDate: this.formBuilder.control(this.formatDateForInput(new Date())),
endDate: this.formBuilder.control(this.formatDateForInput(this.addDays(new Date(), DEFAULT_TIMEBOX_DAYS))),
autoRenew: this.formBuilder.control(false),
maxRenewals: this.formBuilder.control(0),
});
// Computed: selected template
readonly selectedTemplate = computed(() => {
const templateId = this.justificationForm.controls.template.value;
return this.justificationTemplates.find((t) => t.id === templateId);
});
// Computed: scope preview
readonly scopePreview = computed(() => {
const scope = this.scopeForm.getRawValue();
const items: string[] = [];
if (scope.type === 'global') {
items.push('All assets and components');
}
if (scope.tenantId) {
items.push(`Tenant: ${scope.tenantId}`);
}
if (scope.assetIds) {
const assets = this.parseList(scope.assetIds);
items.push(`Assets (${assets.length}): ${assets.slice(0, 3).join(', ')}${assets.length > 3 ? '...' : ''}`);
}
if (scope.componentPurls) {
const purls = this.parseList(scope.componentPurls);
items.push(`Components (${purls.length}): ${purls.slice(0, 2).join(', ')}${purls.length > 2 ? '...' : ''}`);
}
if (scope.vulnIds) {
const vulns = this.parseList(scope.vulnIds);
items.push(`Vulnerabilities (${vulns.length}): ${vulns.join(', ')}`);
}
return items.length > 0 ? items : ['No scope defined'];
});
// Computed: timebox validation
readonly timeboxDays = computed(() => {
const timebox = this.timeboxForm.getRawValue();
const start = new Date(timebox.startDate);
const end = new Date(timebox.endDate);
return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
});
readonly timeboxWarning = computed(() => {
const days = this.timeboxDays();
if (days > 90) {
return 'Long exception period (>90 days). Consider shorter timebox with renewal.';
}
if (days > MAX_TIMEBOX_DAYS) {
return `Maximum timebox is ${MAX_TIMEBOX_DAYS} days.`;
}
if (days < MIN_TIMEBOX_DAYS) {
return 'End date must be after start date.';
}
return null;
});
readonly timeboxValid = computed(() => {
const days = this.timeboxDays();
return days >= MIN_TIMEBOX_DAYS && days <= MAX_TIMEBOX_DAYS;
});
// Computed: step completion status
readonly stepStatus = computed(() => ({
basics: this.basicsForm.valid,
scope: this.isScopeValid(),
justification: this.justificationForm.valid,
timebox: this.timeboxValid(),
review: true,
}));
// Computed: can proceed to next step
readonly canProceed = computed(() => {
const step = this.currentStep();
return this.stepStatus()[step];
});
// Computed: exception preview
readonly exceptionPreview = computed<Partial<Exception>>(() => {
const basics = this.basicsForm.getRawValue();
const scope = this.scopeForm.getRawValue();
const justification = this.justificationForm.getRawValue();
const timebox = this.timeboxForm.getRawValue();
return {
name: basics.name,
displayName: basics.displayName || undefined,
description: basics.description || undefined,
severity: basics.severity,
status: 'draft',
scope: this.buildScope(scope),
justification: this.buildJustification(justification),
timebox: this.buildTimebox(timebox),
};
});
ngOnInit(): void {
// Set default template text when template changes
this.justificationForm.controls.template.valueChanges.subscribe((templateId) => {
const template = this.justificationTemplates.find((t) => t.id === templateId);
if (template && !this.justificationForm.controls.text.dirty) {
this.justificationForm.patchValue({ text: template.defaultText });
}
});
// Initialize with default template text
const defaultTemplate = this.justificationTemplates[0];
this.justificationForm.patchValue({ text: defaultTemplate.defaultText });
}
// Navigation
goToStep(step: WizardStep): void {
const currentIndex = this.steps.indexOf(this.currentStep());
const targetIndex = this.steps.indexOf(step);
// Can only go back or to completed steps
if (targetIndex < currentIndex || this.canNavigateToStep(step)) {
this.currentStep.set(step);
}
}
nextStep(): void {
const currentIndex = this.steps.indexOf(this.currentStep());
if (currentIndex < this.steps.length - 1 && this.canProceed()) {
this.currentStep.set(this.steps[currentIndex + 1]);
}
}
prevStep(): void {
const currentIndex = this.steps.indexOf(this.currentStep());
if (currentIndex > 0) {
this.currentStep.set(this.steps[currentIndex - 1]);
}
}
isStepActive(step: WizardStep): boolean {
return this.currentStep() === step;
}
isStepCompleted(step: WizardStep): boolean {
const stepIndex = this.steps.indexOf(step);
const currentIndex = this.steps.indexOf(this.currentStep());
return stepIndex < currentIndex && this.stepStatus()[step];
}
canNavigateToStep(step: WizardStep): boolean {
const stepIndex = this.steps.indexOf(step);
// Can navigate to any previous step or current step
// Can navigate forward only if all previous steps are complete
for (let i = 0; i < stepIndex; i++) {
if (!this.stepStatus()[this.steps[i]]) {
return false;
}
}
return true;
}
getStepLabel(step: WizardStep): string {
const labels: Record<WizardStep, string> = {
basics: 'Basic Info',
scope: 'Scope',
justification: 'Justification',
timebox: 'Timebox',
review: 'Review',
};
return labels[step];
}
// Template selection
selectTemplate(templateId: string): void {
this.justificationForm.patchValue({ template: templateId });
const template = this.justificationTemplates.find((t) => t.id === templateId);
if (template) {
this.justificationForm.patchValue({ text: template.defaultText });
this.justificationForm.controls.text.markAsPristine();
}
}
// Timebox helpers
setTimeboxPreset(days: number): void {
const start = new Date();
const end = this.addDays(start, days);
this.timeboxForm.patchValue({
startDate: this.formatDateForInput(start),
endDate: this.formatDateForInput(end),
});
}
// Form submission
async submitException(): Promise<void> {
if (!this.isFormComplete()) {
this.error.set('Please complete all required fields.');
return;
}
this.loading.set(true);
this.error.set(null);
try {
const exception = this.exceptionPreview();
const created = await firstValueFrom(this.api.createException(exception));
this.created.emit(created);
} catch (err) {
this.error.set(err instanceof Error ? err.message : 'Failed to create exception.');
} finally {
this.loading.set(false);
}
}
cancel(): void {
this.cancelled.emit();
}
// Validation helpers
private isScopeValid(): boolean {
const scope = this.scopeForm.getRawValue();
if (scope.type === 'global') {
return true;
}
if (scope.type === 'tenant' && scope.tenantId) {
return true;
}
if (scope.type === 'asset' && scope.assetIds) {
return this.parseList(scope.assetIds).length > 0;
}
if (scope.type === 'component' && scope.componentPurls) {
return this.parseList(scope.componentPurls).length > 0;
}
return false;
}
private isFormComplete(): boolean {
return (
this.basicsForm.valid &&
this.isScopeValid() &&
this.justificationForm.valid &&
this.timeboxValid()
);
}
// Builder helpers
private buildScope(raw: ReturnType<typeof this.scopeForm.getRawValue>): Exception['scope'] {
return {
type: raw.type,
tenantId: raw.tenantId || undefined,
assetIds: raw.assetIds ? this.parseList(raw.assetIds) : undefined,
componentPurls: raw.componentPurls ? this.parseList(raw.componentPurls) : undefined,
vulnIds: raw.vulnIds ? this.parseList(raw.vulnIds) : undefined,
};
}
private buildJustification(raw: ReturnType<typeof this.justificationForm.getRawValue>): ExceptionJustification {
return {
template: raw.template !== 'custom' ? raw.template : undefined,
text: raw.text,
attachments: raw.attachments ? this.parseList(raw.attachments) : undefined,
};
}
private buildTimebox(raw: ReturnType<typeof this.timeboxForm.getRawValue>): ExceptionTimebox {
return {
startDate: new Date(raw.startDate).toISOString(),
endDate: new Date(raw.endDate).toISOString(),
autoRenew: raw.autoRenew || undefined,
maxRenewals: raw.autoRenew && raw.maxRenewals > 0 ? raw.maxRenewals : undefined,
};
}
// Utility helpers
private parseList(value: string): string[] {
if (!value) return [];
return value
.split(/[\n,;]/)
.map((s) => s.trim())
.filter(Boolean);
}
private formatDateForInput(date: Date): string {
return date.toISOString().split('T')[0];
}
private addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
}

View File

@@ -0,0 +1,310 @@
<div class="graph-explorer">
<!-- Header -->
<header class="graph-explorer__header">
<div class="graph-explorer__title-section">
<h1>Graph Explorer</h1>
<p class="graph-explorer__subtitle">Visualize asset dependencies and vulnerabilities</p>
</div>
<div class="graph-explorer__actions">
<button
type="button"
class="btn btn--secondary"
(click)="loadData()"
[disabled]="loading()"
>
Refresh
</button>
</div>
</header>
<!-- Message Toast -->
<div
class="graph-explorer__message"
*ngIf="message() as msg"
[class.graph-explorer__message--success]="messageType() === 'success'"
[class.graph-explorer__message--error]="messageType() === 'error'"
>
{{ msg }}
</div>
<!-- Toolbar -->
<div class="graph-explorer__toolbar">
<!-- View Toggle -->
<div class="view-toggle">
<button
type="button"
class="view-toggle__btn"
[class.view-toggle__btn--active]="viewMode() === 'hierarchy'"
(click)="setViewMode('hierarchy')"
>
Hierarchy
</button>
<button
type="button"
class="view-toggle__btn"
[class.view-toggle__btn--active]="viewMode() === 'flat'"
(click)="setViewMode('flat')"
>
Flat List
</button>
</div>
<!-- Layer Toggles -->
<div class="layer-toggles">
<label class="layer-toggle">
<input type="checkbox" [checked]="showAssets()" (change)="toggleAssets()" />
<span class="layer-toggle__icon">📦</span>
<span>Assets</span>
</label>
<label class="layer-toggle">
<input type="checkbox" [checked]="showComponents()" (change)="toggleComponents()" />
<span class="layer-toggle__icon">🧩</span>
<span>Components</span>
</label>
<label class="layer-toggle">
<input type="checkbox" [checked]="showVulnerabilities()" (change)="toggleVulnerabilities()" />
<span class="layer-toggle__icon">⚠️</span>
<span>Vulnerabilities</span>
</label>
</div>
<!-- Severity Filter -->
<div class="filter-group">
<label class="filter-group__label">Severity</label>
<select
class="filter-group__select"
[value]="filterSeverity()"
(change)="setSeverityFilter($any($event.target).value)"
>
<option value="all">All</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
</div>
<!-- Loading State -->
<div class="graph-explorer__loading" *ngIf="loading()">
<span class="spinner"></span>
<span>Loading graph data...</span>
</div>
<!-- Main Content -->
<div class="graph-explorer__content" *ngIf="!loading()">
<!-- Hierarchy View -->
<div class="hierarchy-view" *ngIf="viewMode() === 'hierarchy'">
<!-- Assets Layer -->
<div class="graph-layer">
<h3 class="graph-layer__title">
<span class="graph-layer__icon">📦</span>
Assets ({{ assets().length }})
</h3>
<div class="graph-nodes">
<div
*ngFor="let node of assets(); trackBy: trackByNode"
class="graph-node"
[class]="getNodeClass(node)"
(click)="selectNode(node.id)"
>
<span class="graph-node__name">{{ node.name }}</span>
<span class="graph-node__badge" *ngIf="node.vulnCount">
{{ node.vulnCount }} vulns
</span>
</div>
</div>
</div>
<!-- Components Layer -->
<div class="graph-layer">
<h3 class="graph-layer__title">
<span class="graph-layer__icon">🧩</span>
Components ({{ components().length }})
</h3>
<div class="graph-nodes">
<div
*ngFor="let node of components(); trackBy: trackByNode"
class="graph-node"
[class]="getNodeClass(node)"
(click)="selectNode(node.id)"
>
<span class="graph-node__name">{{ node.name }}</span>
<span class="graph-node__version">{{ node.version }}</span>
<span class="graph-node__severity chip" *ngIf="node.severity" [class]="getSeverityClass(node.severity)">
{{ node.severity }}
</span>
<span class="graph-node__exception" *ngIf="node.hasException"></span>
</div>
</div>
</div>
<!-- Vulnerabilities Layer -->
<div class="graph-layer">
<h3 class="graph-layer__title">
<span class="graph-layer__icon">⚠️</span>
Vulnerabilities ({{ vulnerabilities().length }})
</h3>
<div class="graph-nodes">
<div
*ngFor="let node of vulnerabilities(); trackBy: trackByNode"
class="graph-node"
[class]="getNodeClass(node)"
(click)="selectNode(node.id)"
>
<span class="graph-node__name">{{ node.name }}</span>
<span class="graph-node__severity chip" *ngIf="node.severity" [class]="getSeverityClass(node.severity)">
{{ node.severity }}
</span>
<span class="graph-node__exception" *ngIf="node.hasException"></span>
</div>
</div>
</div>
</div>
<!-- Flat View -->
<div class="flat-view" *ngIf="viewMode() === 'flat'">
<table class="node-table">
<thead>
<tr>
<th>Type</th>
<th>Name</th>
<th>Version/ID</th>
<th>Severity</th>
<th>Exception</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let node of filteredNodes(); trackBy: trackByNode"
class="node-table__row"
[class.node-table__row--selected]="selectedNodeId() === node.id"
(click)="selectNode(node.id)"
>
<td>
<span class="node-type-badge node-type-badge--{{ node.type }}">
{{ getNodeTypeIcon(node.type) }} {{ node.type }}
</span>
</td>
<td>{{ node.name }}</td>
<td>{{ node.version || node.purl || '-' }}</td>
<td>
<span class="chip chip--small" *ngIf="node.severity" [class]="getSeverityClass(node.severity)">
{{ node.severity }}
</span>
<span *ngIf="!node.severity">-</span>
</td>
<td>
<span class="exception-indicator" *ngIf="node.hasException">✓ Excepted</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Detail Panel -->
<div class="detail-panel" *ngIf="selectedNode() as node">
<div class="detail-panel__header">
<div class="detail-panel__title">
<span class="detail-panel__icon">{{ getNodeTypeIcon(node.type) }}</span>
<h2>{{ node.name }}</h2>
</div>
<button type="button" class="detail-panel__close" (click)="clearSelection()">Close</button>
</div>
<div class="detail-panel__content">
<!-- Node Info -->
<div class="detail-section">
<div class="detail-grid">
<div class="detail-item">
<span class="detail-item__label">Type</span>
<span class="node-type-badge node-type-badge--{{ node.type }}">
{{ node.type }}
</span>
</div>
<div class="detail-item" *ngIf="node.version">
<span class="detail-item__label">Version</span>
<span class="detail-item__value">{{ node.version }}</span>
</div>
<div class="detail-item" *ngIf="node.severity">
<span class="detail-item__label">Severity</span>
<span class="chip" [class]="getSeverityClass(node.severity)">{{ node.severity }}</span>
</div>
</div>
</div>
<!-- PURL -->
<div class="detail-section" *ngIf="node.purl">
<h4>Package URL</h4>
<code class="purl-display">{{ node.purl }}</code>
</div>
<!-- Exception Status -->
<div class="detail-section" *ngIf="getExceptionBadgeData(node) as badgeData">
<h4>Exception Status</h4>
<app-exception-badge
[data]="badgeData"
[compact]="false"
(viewDetails)="onViewExceptionDetails($event)"
(explain)="onExplainException($event)"
></app-exception-badge>
</div>
<!-- Related Nodes -->
<div class="detail-section">
<h4>Related Nodes ({{ relatedNodes().length }})</h4>
<div class="related-nodes">
<div
*ngFor="let related of relatedNodes(); trackBy: trackByNode"
class="related-node"
(click)="selectNode(related.id)"
>
<span class="related-node__icon">{{ getNodeTypeIcon(related.type) }}</span>
<span class="related-node__name">{{ related.name }}</span>
<span class="chip chip--small" *ngIf="related.severity" [class]="getSeverityClass(related.severity)">
{{ related.severity }}
</span>
</div>
<div class="related-nodes__empty" *ngIf="relatedNodes().length === 0">
No related nodes found
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="detail-panel__actions" *ngIf="!node.hasException && !showExceptionDraft()">
<button
type="button"
class="btn btn--primary"
(click)="startExceptionDraft()"
>
Create Exception
</button>
</div>
<!-- Inline Exception Draft -->
<div class="detail-panel__exception-draft" *ngIf="showExceptionDraft() && exceptionDraftContext()">
<app-exception-draft-inline
[context]="exceptionDraftContext()!"
(created)="onExceptionCreated()"
(cancelled)="cancelExceptionDraft()"
(openFullWizard)="openFullWizard()"
></app-exception-draft-inline>
</div>
</div>
<!-- Exception Explain Modal -->
<div class="explain-modal" *ngIf="showExceptionExplain() && exceptionExplainData()">
<div class="explain-modal__backdrop" (click)="closeExplain()"></div>
<div class="explain-modal__container">
<app-exception-explain
[data]="exceptionExplainData()!"
(close)="closeExplain()"
(viewException)="viewExceptionFromExplain($event)"
></app-exception-explain>
</div>
</div>
</div>

View File

@@ -0,0 +1,689 @@
.graph-explorer {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1.5rem;
min-height: 100vh;
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
}
// Header
.graph-explorer__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
color: #1e293b;
}
}
.graph-explorer__subtitle {
margin: 0.25rem 0 0;
color: #64748b;
font-size: 0.875rem;
}
.graph-explorer__actions {
display: flex;
gap: 0.5rem;
}
// Message Toast
.graph-explorer__message {
padding: 0.75rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
background: #e0f2fe;
color: #0369a1;
border: 1px solid #7dd3fc;
&--success {
background: #dcfce7;
color: #166534;
border-color: #86efac;
}
&--error {
background: #fef2f2;
color: #991b1b;
border-color: #fca5a5;
}
}
// Toolbar
.graph-explorer__toolbar {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
padding: 1rem;
background: white;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.view-toggle {
display: flex;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
overflow: hidden;
}
.view-toggle__btn {
padding: 0.5rem 1rem;
border: none;
background: white;
color: #64748b;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f1f5f9;
}
&--active {
background: #4f46e5;
color: white;
&:hover {
background: #4338ca;
}
}
}
.layer-toggles {
display: flex;
gap: 0.75rem;
padding-left: 1rem;
border-left: 1px solid #e2e8f0;
}
.layer-toggle {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
color: #475569;
cursor: pointer;
input {
width: 16px;
height: 16px;
cursor: pointer;
}
}
.layer-toggle__icon {
font-size: 1rem;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-left: auto;
}
.filter-group__label {
font-size: 0.75rem;
color: #64748b;
font-weight: 500;
}
.filter-group__select {
padding: 0.5rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 0.875rem;
background: white;
cursor: pointer;
min-width: 120px;
&:focus {
outline: none;
border-color: #4f46e5;
}
}
// Loading
.graph-explorer__loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 3rem;
color: #64748b;
}
.spinner {
width: 24px;
height: 24px;
border: 3px solid #e2e8f0;
border-top-color: #4f46e5;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// Hierarchy View
.hierarchy-view {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.graph-layer {
background: white;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
padding: 1rem;
}
.graph-layer__title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 1rem;
font-size: 0.875rem;
font-weight: 600;
color: #475569;
}
.graph-layer__icon {
font-size: 1.25rem;
}
.graph-nodes {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.graph-node {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: #4f46e5;
background: #eef2ff;
}
&.node--selected {
border-color: #4f46e5;
background: #eef2ff;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.2);
}
&.node--excepted {
background: #faf5ff;
border-color: #c4b5fd;
}
&.node--critical {
border-left: 4px solid #dc2626;
}
&.node--high {
border-left: 4px solid #ea580c;
}
&.node--medium {
border-left: 4px solid #ca8a04;
}
&.node--low {
border-left: 4px solid #16a34a;
}
}
.graph-node__name {
font-weight: 500;
color: #1e293b;
}
.graph-node__version {
font-size: 0.75rem;
color: #64748b;
font-family: ui-monospace, monospace;
}
.graph-node__badge {
padding: 0.125rem 0.5rem;
background: #fef2f2;
color: #dc2626;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 500;
}
.graph-node__exception {
color: #7c3aed;
font-weight: bold;
}
// Flat View
.flat-view {
background: white;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.node-table {
width: 100%;
border-collapse: collapse;
th {
padding: 0.75rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #f1f5f9;
font-size: 0.875rem;
color: #334155;
vertical-align: middle;
}
}
.node-table__row {
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background: #f8fafc;
}
&--selected {
background: #eef2ff;
&:hover {
background: #e0e7ff;
}
}
}
.node-type-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
text-transform: capitalize;
&--asset {
background: #dbeafe;
color: #1d4ed8;
}
&--component {
background: #dcfce7;
color: #166534;
}
&--vulnerability {
background: #fef2f2;
color: #dc2626;
}
}
.exception-indicator {
color: #7c3aed;
font-size: 0.75rem;
font-weight: 500;
}
// Chips
.chip {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
text-transform: capitalize;
&--small {
padding: 0.125rem 0.5rem;
font-size: 0.6875rem;
}
}
.severity--critical {
background: #fef2f2;
color: #dc2626;
}
.severity--high {
background: #fff7ed;
color: #ea580c;
}
.severity--medium {
background: #fefce8;
color: #ca8a04;
}
.severity--low {
background: #f0fdf4;
color: #16a34a;
}
// Detail Panel
.detail-panel {
position: fixed;
top: 0;
right: 0;
width: 420px;
max-width: 100%;
height: 100vh;
background: white;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
z-index: 100;
overflow: hidden;
}
.detail-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
.detail-panel__title {
display: flex;
align-items: center;
gap: 0.5rem;
h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1e293b;
}
}
.detail-panel__icon {
font-size: 1.5rem;
}
.detail-panel__close {
padding: 0.375rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
background: white;
color: #64748b;
font-size: 0.8125rem;
cursor: pointer;
&:hover {
background: #f1f5f9;
}
}
.detail-panel__content {
flex: 1;
overflow-y: auto;
padding: 1.25rem;
}
.detail-section {
margin-bottom: 1.5rem;
h4 {
margin: 0 0 0.5rem;
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
}
.detail-grid {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.detail-item__label {
font-size: 0.6875rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.detail-item__value {
font-size: 0.875rem;
color: #1e293b;
}
.purl-display {
display: block;
padding: 0.5rem 0.75rem;
background: #f1f5f9;
border-radius: 0.375rem;
font-size: 0.75rem;
font-family: ui-monospace, monospace;
word-break: break-all;
color: #475569;
}
// Exception Badge
.exception-badge {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: #f3e8ff;
border: 1px solid #c4b5fd;
border-radius: 0.5rem;
}
.exception-badge__icon {
color: #7c3aed;
font-weight: bold;
}
.exception-badge__text {
font-size: 0.875rem;
color: #6d28d9;
}
// Related Nodes
.related-nodes {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.related-node {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #eef2ff;
border-color: #4f46e5;
}
}
.related-node__icon {
font-size: 1rem;
}
.related-node__name {
flex: 1;
font-size: 0.875rem;
color: #1e293b;
}
.related-nodes__empty {
padding: 1rem;
text-align: center;
color: #94a3b8;
font-size: 0.875rem;
}
// Actions
.detail-panel__actions {
display: flex;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
.detail-panel__exception-draft {
border-top: 1px solid #e2e8f0;
padding: 1rem 1.25rem;
background: #fafafa;
}
// Buttons
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&--primary {
background: #4f46e5;
color: white;
&:hover:not(:disabled) {
background: #4338ca;
}
}
&--secondary {
background: white;
color: #475569;
border: 1px solid #e2e8f0;
&:hover:not(:disabled) {
background: #f8fafc;
}
}
}
// Explain Modal
.explain-modal {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.explain-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
}
.explain-modal__container {
position: relative;
z-index: 1;
width: 100%;
max-width: 480px;
animation: modal-appear 0.2s ease-out;
}
@keyframes modal-appear {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
// Responsive
@media (max-width: 768px) {
.graph-explorer {
padding: 1rem;
}
.graph-explorer__toolbar {
flex-direction: column;
align-items: stretch;
}
.layer-toggles {
padding-left: 0;
border-left: none;
padding-top: 0.75rem;
border-top: 1px solid #e2e8f0;
}
.filter-group {
margin-left: 0;
}
.detail-panel {
width: 100%;
}
}

View File

@@ -0,0 +1,387 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
OnInit,
computed,
signal,
} from '@angular/core';
import {
ExceptionDraftContext,
ExceptionDraftInlineComponent,
} from '../exceptions/exception-draft-inline.component';
import {
ExceptionBadgeComponent,
ExceptionBadgeData,
ExceptionExplainComponent,
ExceptionExplainData,
} from '../../shared/components';
export interface GraphNode {
readonly id: string;
readonly type: 'asset' | 'component' | 'vulnerability';
readonly name: string;
readonly purl?: string;
readonly version?: string;
readonly severity?: 'critical' | 'high' | 'medium' | 'low';
readonly vulnCount?: number;
readonly hasException?: boolean;
}
export interface GraphEdge {
readonly source: string;
readonly target: string;
readonly type: 'depends_on' | 'has_vulnerability' | 'child_of';
}
const MOCK_NODES: GraphNode[] = [
{ id: 'asset-web-prod', type: 'asset', name: 'web-prod', vulnCount: 5 },
{ id: 'asset-api-prod', type: 'asset', name: 'api-prod', vulnCount: 3 },
{ id: 'comp-log4j', type: 'component', name: 'log4j-core', purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', version: '2.14.1', severity: 'critical', vulnCount: 2 },
{ id: 'comp-spring', type: 'component', name: 'spring-beans', purl: 'pkg:maven/org.springframework/spring-beans@5.3.17', version: '5.3.17', severity: 'critical', vulnCount: 1, hasException: true },
{ id: 'comp-curl', type: 'component', name: 'curl', purl: 'pkg:deb/debian/curl@7.88.1-10', version: '7.88.1-10', severity: 'high', vulnCount: 1 },
{ id: 'comp-nghttp2', type: 'component', name: 'nghttp2', purl: 'pkg:npm/nghttp2@1.55.0', version: '1.55.0', severity: 'high', vulnCount: 1 },
{ id: 'comp-golang-net', type: 'component', name: 'golang.org/x/net', purl: 'pkg:golang/golang.org/x/net@0.15.0', version: '0.15.0', severity: 'high', vulnCount: 1 },
{ id: 'comp-zlib', type: 'component', name: 'zlib', purl: 'pkg:deb/debian/zlib@1.2.13', version: '1.2.13', severity: 'medium', vulnCount: 1 },
{ id: 'vuln-log4shell', type: 'vulnerability', name: 'CVE-2021-44228', severity: 'critical' },
{ id: 'vuln-log4j-dos', type: 'vulnerability', name: 'CVE-2021-45046', severity: 'critical', hasException: true },
{ id: 'vuln-spring4shell', type: 'vulnerability', name: 'CVE-2022-22965', severity: 'critical', hasException: true },
{ id: 'vuln-http2-reset', type: 'vulnerability', name: 'CVE-2023-44487', severity: 'high' },
{ id: 'vuln-curl-heap', type: 'vulnerability', name: 'CVE-2023-38545', severity: 'high' },
];
const MOCK_EDGES: GraphEdge[] = [
{ source: 'asset-web-prod', target: 'comp-log4j', type: 'depends_on' },
{ source: 'asset-web-prod', target: 'comp-curl', type: 'depends_on' },
{ source: 'asset-web-prod', target: 'comp-nghttp2', type: 'depends_on' },
{ source: 'asset-web-prod', target: 'comp-zlib', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-log4j', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-curl', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-golang-net', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-spring', type: 'depends_on' },
{ source: 'comp-log4j', target: 'vuln-log4shell', type: 'has_vulnerability' },
{ source: 'comp-log4j', target: 'vuln-log4j-dos', type: 'has_vulnerability' },
{ source: 'comp-spring', target: 'vuln-spring4shell', type: 'has_vulnerability' },
{ source: 'comp-nghttp2', target: 'vuln-http2-reset', type: 'has_vulnerability' },
{ source: 'comp-golang-net', target: 'vuln-http2-reset', type: 'has_vulnerability' },
{ source: 'comp-curl', target: 'vuln-curl-heap', type: 'has_vulnerability' },
];
type ViewMode = 'hierarchy' | 'flat';
@Component({
selector: 'app-graph-explorer',
standalone: true,
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent],
templateUrl: './graph-explorer.component.html',
styleUrls: ['./graph-explorer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GraphExplorerComponent implements OnInit {
// View state
readonly loading = signal(false);
readonly message = signal<string | null>(null);
readonly messageType = signal<'success' | 'error' | 'info'>('info');
readonly viewMode = signal<ViewMode>('hierarchy');
// Data
readonly nodes = signal<GraphNode[]>([]);
readonly edges = signal<GraphEdge[]>([]);
readonly selectedNodeId = signal<string | null>(null);
// Exception draft state
readonly showExceptionDraft = signal(false);
// Exception explain state
readonly showExceptionExplain = signal(false);
readonly explainNodeId = signal<string | null>(null);
// Filters
readonly showVulnerabilities = signal(true);
readonly showComponents = signal(true);
readonly showAssets = signal(true);
readonly filterSeverity = signal<'all' | 'critical' | 'high' | 'medium' | 'low'>('all');
// Computed: filtered nodes
readonly filteredNodes = computed(() => {
let items = [...this.nodes()];
const showVulns = this.showVulnerabilities();
const showComps = this.showComponents();
const showAssetNodes = this.showAssets();
const severity = this.filterSeverity();
items = items.filter((n) => {
if (n.type === 'vulnerability' && !showVulns) return false;
if (n.type === 'component' && !showComps) return false;
if (n.type === 'asset' && !showAssetNodes) return false;
if (severity !== 'all' && n.severity && n.severity !== severity) return false;
return true;
});
return items;
});
// Computed: assets
readonly assets = computed(() => {
return this.filteredNodes().filter((n) => n.type === 'asset');
});
// Computed: components
readonly components = computed(() => {
return this.filteredNodes().filter((n) => n.type === 'component');
});
// Computed: vulnerabilities
readonly vulnerabilities = computed(() => {
return this.filteredNodes().filter((n) => n.type === 'vulnerability');
});
// Computed: selected node
readonly selectedNode = computed(() => {
const id = this.selectedNodeId();
if (!id) return null;
return this.nodes().find((n) => n.id === id) ?? null;
});
// Computed: related nodes for selected
readonly relatedNodes = computed(() => {
const selectedId = this.selectedNodeId();
if (!selectedId) return [];
const edgeList = this.edges();
const relatedIds = new Set<string>();
edgeList.forEach((e) => {
if (e.source === selectedId) relatedIds.add(e.target);
if (e.target === selectedId) relatedIds.add(e.source);
});
return this.nodes().filter((n) => relatedIds.has(n.id));
});
// Get exception badge data for a node
getExceptionBadgeData(node: GraphNode): ExceptionBadgeData | null {
if (!node.hasException) return null;
return {
exceptionId: `exc-${node.id}`,
status: 'approved',
severity: node.severity ?? 'medium',
name: `${node.name} Exception`,
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
justificationSummary: 'Risk accepted with compensating controls.',
approvedBy: 'Security Team',
};
}
// Computed: explain data for selected node
readonly exceptionExplainData = computed<ExceptionExplainData | null>(() => {
const nodeId = this.explainNodeId();
if (!nodeId) return null;
const node = this.nodes().find((n) => n.id === nodeId);
if (!node || !node.hasException) return null;
const relatedComps = this.edges()
.filter((e) => e.source === nodeId || e.target === nodeId)
.map((e) => (e.source === nodeId ? e.target : e.source))
.map((id) => this.nodes().find((n) => n.id === id))
.filter((n): n is GraphNode => n !== undefined && n.type === 'component');
return {
exceptionId: `exc-${node.id}`,
name: `${node.name} Exception`,
status: 'approved',
severity: node.severity ?? 'medium',
scope: {
type: node.type === 'vulnerability' ? 'vulnerability' : 'component',
vulnIds: node.type === 'vulnerability' ? [node.name] : undefined,
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
},
justification: {
template: 'risk-accepted',
text: 'Risk accepted with compensating controls in place. The affected item is in a controlled environment.',
},
timebox: {
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
autoRenew: false,
},
approvedBy: 'Security Team',
approvedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
impact: {
affectedFindings: 1,
affectedAssets: 1,
policyOverrides: 1,
},
};
});
// Computed: exception draft context
readonly exceptionDraftContext = computed<ExceptionDraftContext | null>(() => {
const node = this.selectedNode();
if (!node) return null;
if (node.type === 'component') {
const relatedVulns = this.relatedNodes().filter((n) => n.type === 'vulnerability');
return {
componentPurls: node.purl ? [node.purl] : undefined,
vulnIds: relatedVulns.map((v) => v.name),
suggestedName: `${node.name}-exception`,
suggestedSeverity: node.severity ?? 'medium',
sourceType: 'component',
sourceLabel: `${node.name}@${node.version}`,
};
}
if (node.type === 'vulnerability') {
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
return {
vulnIds: [node.name],
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
suggestedName: `${node.name.toLowerCase()}-exception`,
suggestedSeverity: node.severity ?? 'medium',
sourceType: 'vulnerability',
sourceLabel: node.name,
};
}
if (node.type === 'asset') {
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
return {
assetIds: [node.name],
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
suggestedName: `${node.name}-exception`,
sourceType: 'asset',
sourceLabel: node.name,
};
}
return null;
});
ngOnInit(): void {
this.loadData();
}
loadData(): void {
this.loading.set(true);
// Simulate API call
setTimeout(() => {
this.nodes.set([...MOCK_NODES]);
this.edges.set([...MOCK_EDGES]);
this.loading.set(false);
}, 300);
}
// View mode
setViewMode(mode: ViewMode): void {
this.viewMode.set(mode);
}
// Filters
toggleVulnerabilities(): void {
this.showVulnerabilities.set(!this.showVulnerabilities());
}
toggleComponents(): void {
this.showComponents.set(!this.showComponents());
}
toggleAssets(): void {
this.showAssets.set(!this.showAssets());
}
setSeverityFilter(severity: 'all' | 'critical' | 'high' | 'medium' | 'low'): void {
this.filterSeverity.set(severity);
}
// Selection
selectNode(nodeId: string): void {
this.selectedNodeId.set(nodeId);
this.showExceptionDraft.set(false);
}
clearSelection(): void {
this.selectedNodeId.set(null);
this.showExceptionDraft.set(false);
}
// Exception drafting
startExceptionDraft(): void {
this.showExceptionDraft.set(true);
}
cancelExceptionDraft(): void {
this.showExceptionDraft.set(false);
}
onExceptionCreated(): void {
this.showExceptionDraft.set(false);
this.showMessage('Exception draft created successfully', 'success');
this.loadData();
}
openFullWizard(): void {
this.showMessage('Opening full wizard... (would navigate to Exception Center)', 'info');
}
// Exception explain
onViewExceptionDetails(exceptionId: string): void {
this.showMessage(`Navigating to exception ${exceptionId}...`, 'info');
}
onExplainException(exceptionId: string): void {
// Find the node with this exception ID
const node = this.nodes().find((n) => `exc-${n.id}` === exceptionId);
if (node) {
this.explainNodeId.set(node.id);
this.showExceptionExplain.set(true);
}
}
closeExplain(): void {
this.showExceptionExplain.set(false);
this.explainNodeId.set(null);
}
viewExceptionFromExplain(exceptionId: string): void {
this.closeExplain();
this.onViewExceptionDetails(exceptionId);
}
// Helpers
getNodeTypeIcon(type: GraphNode['type']): string {
switch (type) {
case 'asset':
return '📦';
case 'component':
return '🧩';
case 'vulnerability':
return '⚠️';
default:
return '•';
}
}
getSeverityClass(severity: string | undefined): string {
if (!severity) return '';
return `severity--${severity}`;
}
getNodeClass(node: GraphNode): string {
const classes = [`node--${node.type}`];
if (node.severity) classes.push(`node--${node.severity}`);
if (node.hasException) classes.push('node--excepted');
if (this.selectedNodeId() === node.id) classes.push('node--selected');
return classes.join(' ');
}
trackByNode = (_: number, item: GraphNode) => item.id;
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
this.message.set(text);
this.messageType.set(type);
setTimeout(() => this.message.set(null), 5000);
}
}

View File

@@ -0,0 +1,334 @@
<div class="vuln-explorer">
<!-- Header -->
<header class="vuln-explorer__header">
<div class="vuln-explorer__title-section">
<h1>Vulnerability Explorer</h1>
<p class="vuln-explorer__subtitle">Browse and manage vulnerabilities across your assets</p>
</div>
<div class="vuln-explorer__actions">
<button
type="button"
class="btn btn--secondary"
(click)="loadData()"
[disabled]="loading()"
>
Refresh
</button>
</div>
</header>
<!-- Stats Bar -->
<div class="vuln-explorer__stats" *ngIf="stats() as s">
<div class="stat-card stat-card--critical">
<span class="stat-card__value">{{ s.criticalOpen }}</span>
<span class="stat-card__label">Critical Open</span>
</div>
<div class="stat-card stat-card--high">
<span class="stat-card__value">{{ s.bySeverity['high'] }}</span>
<span class="stat-card__label">High</span>
</div>
<div class="stat-card">
<span class="stat-card__value">{{ s.total }}</span>
<span class="stat-card__label">Total</span>
</div>
<div class="stat-card stat-card--excepted">
<span class="stat-card__value">{{ s.withExceptions }}</span>
<span class="stat-card__label">With Exceptions</span>
</div>
</div>
<!-- Message Toast -->
<div
class="vuln-explorer__message"
*ngIf="message() as msg"
[class.vuln-explorer__message--success]="messageType() === 'success'"
[class.vuln-explorer__message--error]="messageType() === 'error'"
>
{{ msg }}
</div>
<!-- Toolbar -->
<div class="vuln-explorer__toolbar">
<!-- Search -->
<div class="search-box">
<input
type="text"
class="search-box__input"
placeholder="Search CVE ID, title, description..."
[value]="searchQuery()"
(input)="onSearchInput($event)"
/>
<button
type="button"
class="search-box__clear"
*ngIf="searchQuery()"
(click)="clearSearch()"
>
Clear
</button>
</div>
<!-- Filters -->
<div class="filters">
<div class="filter-group">
<label class="filter-group__label">Severity</label>
<select
class="filter-group__select"
[value]="severityFilter()"
(change)="setSeverityFilter($any($event.target).value)"
>
<option value="all">All Severities</option>
<option *ngFor="let sev of allSeverities" [value]="sev">
{{ severityLabels[sev] }}
</option>
</select>
</div>
<div class="filter-group">
<label class="filter-group__label">Status</label>
<select
class="filter-group__select"
[value]="statusFilter()"
(change)="setStatusFilter($any($event.target).value)"
>
<option value="all">All Statuses</option>
<option *ngFor="let st of allStatuses" [value]="st">
{{ statusLabels[st] }}
</option>
</select>
</div>
<label class="filter-checkbox">
<input
type="checkbox"
[checked]="showExceptedOnly()"
(change)="toggleExceptedOnly()"
/>
<span>Show with exceptions only</span>
</label>
</div>
</div>
<!-- Loading State -->
<div class="vuln-explorer__loading" *ngIf="loading()">
<span class="spinner"></span>
<span>Loading vulnerabilities...</span>
</div>
<!-- Main Content -->
<div class="vuln-explorer__content" *ngIf="!loading()">
<!-- Vulnerability List -->
<div class="vuln-list">
<table class="vuln-table" *ngIf="filteredVulnerabilities().length > 0">
<thead>
<tr>
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('cveId')">
CVE ID {{ getSortIcon('cveId') }}
</th>
<th class="vuln-table__th">Title</th>
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('severity')">
Severity {{ getSortIcon('severity') }}
</th>
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('cvssScore')">
CVSS {{ getSortIcon('cvssScore') }}
</th>
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('status')">
Status {{ getSortIcon('status') }}
</th>
<th class="vuln-table__th">Components</th>
<th class="vuln-table__th">Actions</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let vuln of filteredVulnerabilities(); trackBy: trackByVuln"
class="vuln-table__row"
[class.vuln-table__row--selected]="selectedVulnId() === vuln.vulnId"
[class.vuln-table__row--excepted]="vuln.hasException"
(click)="selectVulnerability(vuln.vulnId)"
>
<td class="vuln-table__td">
<div class="vuln-cve">
<span class="vuln-cve__id">{{ vuln.cveId }}</span>
<app-exception-badge
*ngIf="getExceptionBadgeData(vuln) as badgeData"
[data]="badgeData"
[compact]="true"
(viewDetails)="onViewExceptionDetails($event)"
(explain)="onExplainException($event)"
></app-exception-badge>
</div>
</td>
<td class="vuln-table__td">
<span class="vuln-title">{{ vuln.title | slice:0:60 }}{{ vuln.title.length > 60 ? '...' : '' }}</span>
</td>
<td class="vuln-table__td">
<span class="chip" [ngClass]="getSeverityClass(vuln.severity)">
{{ severityLabels[vuln.severity] }}
</span>
</td>
<td class="vuln-table__td">
<span class="cvss-score" [class.cvss-score--critical]="(vuln.cvssScore ?? 0) >= 9">
{{ formatCvss(vuln.cvssScore) }}
</span>
</td>
<td class="vuln-table__td">
<span class="chip chip--small" [ngClass]="getStatusClass(vuln.status)">
{{ statusLabels[vuln.status] }}
</span>
</td>
<td class="vuln-table__td">
<span class="component-count">{{ vuln.affectedComponents.length }}</span>
</td>
<td class="vuln-table__td vuln-table__td--actions">
<button
type="button"
class="btn btn--small btn--action"
(click)="startExceptionDraft(vuln); $event.stopPropagation()"
*ngIf="!vuln.hasException"
title="Create exception for this vulnerability"
>
+ Exception
</button>
</td>
</tr>
</tbody>
</table>
<div class="empty-state" *ngIf="filteredVulnerabilities().length === 0">
<p>No vulnerabilities found matching your filters.</p>
</div>
</div>
</div>
<!-- Detail Panel -->
<div class="detail-panel" *ngIf="selectedVulnerability() as vuln">
<div class="detail-panel__header">
<h2>{{ vuln.cveId }}</h2>
<button type="button" class="detail-panel__close" (click)="clearSelection()">Close</button>
</div>
<div class="detail-panel__content">
<!-- Title & Description -->
<div class="detail-section">
<h3>{{ vuln.title }}</h3>
<p class="detail-description">{{ vuln.description }}</p>
</div>
<!-- Severity & CVSS -->
<div class="detail-section detail-section--row">
<div class="detail-item">
<span class="detail-item__label">Severity</span>
<span class="chip chip--large" [ngClass]="getSeverityClass(vuln.severity)">
{{ severityLabels[vuln.severity] }}
</span>
</div>
<div class="detail-item">
<span class="detail-item__label">CVSS Score</span>
<span class="cvss-score cvss-score--large" [class.cvss-score--critical]="(vuln.cvssScore ?? 0) >= 9">
{{ formatCvss(vuln.cvssScore) }}
</span>
</div>
<div class="detail-item">
<span class="detail-item__label">Status</span>
<span class="chip" [ngClass]="getStatusClass(vuln.status)">
{{ statusLabels[vuln.status] }}
</span>
</div>
</div>
<!-- Exception Badge -->
<div class="detail-section" *ngIf="getExceptionBadgeData(vuln) as badgeData">
<h4>Exception Status</h4>
<app-exception-badge
[data]="badgeData"
[compact]="false"
(viewDetails)="onViewExceptionDetails($event)"
(explain)="onExplainException($event)"
></app-exception-badge>
</div>
<!-- Affected Components -->
<div class="detail-section">
<h4>Affected Components ({{ vuln.affectedComponents.length }})</h4>
<div class="affected-components">
<div
class="affected-component"
*ngFor="let comp of vuln.affectedComponents; trackBy: trackByComponent"
>
<div class="affected-component__header">
<span class="affected-component__name">{{ comp.name }}</span>
<span class="affected-component__version">{{ comp.version }}</span>
</div>
<div class="affected-component__purl">{{ comp.purl }}</div>
<div class="affected-component__fix" *ngIf="comp.fixedVersion">
Fixed in: <strong>{{ comp.fixedVersion }}</strong>
</div>
<div class="affected-component__assets">
Assets: {{ comp.assetIds.join(', ') }}
</div>
</div>
</div>
</div>
<!-- References -->
<div class="detail-section" *ngIf="vuln.references?.length">
<h4>References</h4>
<ul class="references-list">
<li *ngFor="let ref of vuln.references">
<a [href]="ref" target="_blank" rel="noopener noreferrer">{{ ref }}</a>
</li>
</ul>
</div>
<!-- Dates -->
<div class="detail-section">
<h4>Timeline</h4>
<div class="timeline">
<div class="timeline-item" *ngIf="vuln.publishedAt">
<span class="timeline-item__label">Published:</span>
<span class="timeline-item__value">{{ formatDate(vuln.publishedAt) }}</span>
</div>
<div class="timeline-item" *ngIf="vuln.modifiedAt">
<span class="timeline-item__label">Last Modified:</span>
<span class="timeline-item__value">{{ formatDate(vuln.modifiedAt) }}</span>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="detail-panel__actions" *ngIf="!vuln.hasException && !showExceptionDraft()">
<button
type="button"
class="btn btn--primary"
(click)="startExceptionDraft()"
>
Create Exception
</button>
</div>
<!-- Inline Exception Draft -->
<div class="detail-panel__exception-draft" *ngIf="showExceptionDraft() && exceptionDraftContext()">
<app-exception-draft-inline
[context]="exceptionDraftContext()!"
(created)="onExceptionCreated()"
(cancelled)="cancelExceptionDraft()"
(openFullWizard)="openFullWizard()"
></app-exception-draft-inline>
</div>
</div>
<!-- Exception Explain Modal -->
<div class="explain-modal" *ngIf="showExceptionExplain() && exceptionExplainData()">
<div class="explain-modal__backdrop" (click)="closeExplain()"></div>
<div class="explain-modal__container">
<app-exception-explain
[data]="exceptionExplainData()!"
(close)="closeExplain()"
(viewException)="viewExceptionFromExplain($event)"
></app-exception-explain>
</div>
</div>
</div>

View File

@@ -0,0 +1,790 @@
.vuln-explorer {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1.5rem;
min-height: 100vh;
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
}
// Header
.vuln-explorer__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
color: #1e293b;
}
}
.vuln-explorer__subtitle {
margin: 0.25rem 0 0;
color: #64748b;
font-size: 0.875rem;
}
.vuln-explorer__actions {
display: flex;
gap: 0.5rem;
}
// Stats Bar
.vuln-explorer__stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
}
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
background: white;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
border: 1px solid #e2e8f0;
&--critical {
border-left: 4px solid #dc2626;
}
&--high {
border-left: 4px solid #ea580c;
}
&--excepted {
border-left: 4px solid #8b5cf6;
}
}
.stat-card__value {
font-size: 1.5rem;
font-weight: 700;
color: #1e293b;
}
.stat-card__label {
font-size: 0.75rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
// Message Toast
.vuln-explorer__message {
padding: 0.75rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
background: #e0f2fe;
color: #0369a1;
border: 1px solid #7dd3fc;
&--success {
background: #dcfce7;
color: #166534;
border-color: #86efac;
}
&--error {
background: #fef2f2;
color: #991b1b;
border-color: #fca5a5;
}
}
// Toolbar
.vuln-explorer__toolbar {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
padding: 1rem;
background: white;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.search-box {
display: flex;
flex: 1;
min-width: 250px;
max-width: 400px;
position: relative;
}
.search-box__input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 0.875rem;
outline: none;
&:focus {
border-color: #4f46e5;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
}
.search-box__clear {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
padding: 0.25rem 0.5rem;
border: none;
background: transparent;
color: #64748b;
font-size: 0.75rem;
cursor: pointer;
&:hover {
color: #1e293b;
}
}
.filters {
display: flex;
gap: 1rem;
margin-left: auto;
align-items: center;
flex-wrap: wrap;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.filter-group__label {
font-size: 0.75rem;
color: #64748b;
font-weight: 500;
}
.filter-group__select {
padding: 0.5rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 0.875rem;
background: white;
cursor: pointer;
min-width: 140px;
&:focus {
outline: none;
border-color: #4f46e5;
}
}
.filter-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: #475569;
cursor: pointer;
input {
width: 16px;
height: 16px;
cursor: pointer;
}
}
// Loading
.vuln-explorer__loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 3rem;
color: #64748b;
}
.spinner {
width: 24px;
height: 24px;
border: 3px solid #e2e8f0;
border-top-color: #4f46e5;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// Vulnerability List
.vuln-list {
background: white;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.vuln-table {
width: 100%;
border-collapse: collapse;
}
.vuln-table__th {
padding: 0.75rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
&--sortable {
cursor: pointer;
user-select: none;
&:hover {
color: #4f46e5;
}
}
}
.vuln-table__row {
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background: #f8fafc;
}
&--selected {
background: #eef2ff;
&:hover {
background: #e0e7ff;
}
}
&--excepted {
background: #faf5ff;
&:hover {
background: #f3e8ff;
}
}
}
.vuln-table__td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #f1f5f9;
font-size: 0.875rem;
color: #334155;
vertical-align: middle;
&--actions {
text-align: right;
}
}
.vuln-cve {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.vuln-cve__id {
font-weight: 600;
font-family: ui-monospace, monospace;
color: #1e293b;
}
.vuln-cve__exception {
font-size: 0.6875rem;
color: #8b5cf6;
font-weight: 500;
}
.vuln-title {
color: #475569;
}
.cvss-score {
font-weight: 600;
font-family: ui-monospace, monospace;
&--critical {
color: #dc2626;
}
&--large {
font-size: 1.25rem;
}
}
.component-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 0.5rem;
background: #f1f5f9;
color: #475569;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
// Chips
.chip {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
&--large {
padding: 0.375rem 0.875rem;
font-size: 0.875rem;
}
&--small {
padding: 0.125rem 0.5rem;
font-size: 0.6875rem;
}
}
// Severity chips
.severity--critical {
background: #fef2f2;
color: #dc2626;
}
.severity--high {
background: #fff7ed;
color: #ea580c;
}
.severity--medium {
background: #fefce8;
color: #ca8a04;
}
.severity--low {
background: #f0fdf4;
color: #16a34a;
}
.severity--unknown {
background: #f1f5f9;
color: #64748b;
}
// Status chips
.status--open {
background: #fef2f2;
color: #dc2626;
}
.status--fixed {
background: #dcfce7;
color: #166534;
}
.status--wont-fix {
background: #f1f5f9;
color: #475569;
}
.status--in-progress {
background: #fef3c7;
color: #92400e;
}
.status--excepted {
background: #f3e8ff;
color: #7c3aed;
}
// Empty State
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: #64748b;
p {
margin: 0;
}
}
// Detail Panel
.detail-panel {
position: fixed;
top: 0;
right: 0;
width: 480px;
max-width: 100%;
height: 100vh;
background: white;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
z-index: 100;
overflow: hidden;
}
.detail-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1e293b;
font-family: ui-monospace, monospace;
}
}
.detail-panel__close {
padding: 0.375rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
background: white;
color: #64748b;
font-size: 0.8125rem;
cursor: pointer;
&:hover {
background: #f1f5f9;
}
}
.detail-panel__content {
flex: 1;
overflow-y: auto;
padding: 1.25rem;
}
.detail-section {
margin-bottom: 1.5rem;
h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
font-weight: 600;
color: #1e293b;
}
h4 {
margin: 0 0 0.5rem;
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
&--row {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
}
}
.detail-description {
margin: 0;
font-size: 0.875rem;
color: #475569;
line-height: 1.6;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.detail-item__label {
font-size: 0.6875rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
// Exception Badge
.exception-badge {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: #f3e8ff;
border: 1px solid #c4b5fd;
border-radius: 0.5rem;
}
.exception-badge__icon {
color: #7c3aed;
font-weight: bold;
}
.exception-badge__text {
flex: 1;
font-size: 0.875rem;
color: #6d28d9;
}
.exception-badge__id {
font-size: 0.75rem;
font-family: ui-monospace, monospace;
color: #8b5cf6;
}
// Affected Components
.affected-components {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.affected-component {
padding: 0.75rem;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
}
.affected-component__header {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.affected-component__name {
font-weight: 500;
color: #1e293b;
}
.affected-component__version {
font-size: 0.75rem;
color: #64748b;
font-family: ui-monospace, monospace;
}
.affected-component__purl {
font-size: 0.6875rem;
color: #64748b;
font-family: ui-monospace, monospace;
word-break: break-all;
margin-bottom: 0.375rem;
}
.affected-component__fix {
font-size: 0.75rem;
color: #166534;
margin-bottom: 0.25rem;
}
.affected-component__assets {
font-size: 0.6875rem;
color: #64748b;
}
// References
.references-list {
margin: 0;
padding-left: 1rem;
font-size: 0.8125rem;
li {
margin-bottom: 0.375rem;
}
a {
color: #4f46e5;
text-decoration: none;
word-break: break-all;
&:hover {
text-decoration: underline;
}
}
}
// Timeline
.timeline {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.timeline-item {
display: flex;
gap: 0.5rem;
font-size: 0.8125rem;
}
.timeline-item__label {
color: #64748b;
min-width: 100px;
}
.timeline-item__value {
color: #1e293b;
}
// Actions
.detail-panel__actions {
display: flex;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
.detail-panel__exception-draft {
border-top: 1px solid #e2e8f0;
padding: 1rem 1.25rem;
background: #fafafa;
}
// Buttons
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&--primary {
background: #4f46e5;
color: white;
&:hover:not(:disabled) {
background: #4338ca;
}
}
&--secondary {
background: white;
color: #475569;
border: 1px solid #e2e8f0;
&:hover:not(:disabled) {
background: #f8fafc;
}
}
&--small {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: 0.375rem;
}
&--action {
background: #e0e7ff;
color: #4338ca;
&:hover:not(:disabled) {
background: #c7d2fe;
}
}
}
// Explain Modal
.explain-modal {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.explain-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
}
.explain-modal__container {
position: relative;
z-index: 1;
width: 100%;
max-width: 480px;
animation: modal-appear 0.2s ease-out;
}
@keyframes modal-appear {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
// Responsive
@media (max-width: 768px) {
.vuln-explorer {
padding: 1rem;
}
.vuln-explorer__toolbar {
flex-direction: column;
align-items: stretch;
}
.filters {
margin-left: 0;
flex-direction: column;
align-items: flex-start;
}
.search-box {
max-width: none;
}
.detail-panel {
width: 100%;
}
.vuln-table {
display: block;
overflow-x: auto;
}
}

View File

@@ -0,0 +1,406 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { firstValueFrom } from 'rxjs';
import {
VULNERABILITY_API,
VulnerabilityApi,
MockVulnerabilityApiService,
} from '../../core/api/vulnerability.client';
import {
Vulnerability,
VulnerabilitySeverity,
VulnerabilityStats,
VulnerabilityStatus,
} from '../../core/api/vulnerability.models';
import {
ExceptionDraftContext,
ExceptionDraftInlineComponent,
} from '../exceptions/exception-draft-inline.component';
import {
ExceptionBadgeComponent,
ExceptionBadgeData,
ExceptionExplainComponent,
ExceptionExplainData,
} from '../../shared/components';
type SeverityFilter = VulnerabilitySeverity | 'all';
type StatusFilter = VulnerabilityStatus | 'all';
type SortField = 'cveId' | 'severity' | 'cvssScore' | 'publishedAt' | 'status';
type SortOrder = 'asc' | 'desc';
const SEVERITY_LABELS: Record<VulnerabilitySeverity, string> = {
critical: 'Critical',
high: 'High',
medium: 'Medium',
low: 'Low',
unknown: 'Unknown',
};
const STATUS_LABELS: Record<VulnerabilityStatus, string> = {
open: 'Open',
fixed: 'Fixed',
wont_fix: "Won't Fix",
in_progress: 'In Progress',
excepted: 'Excepted',
};
const SEVERITY_ORDER: Record<VulnerabilitySeverity, number> = {
critical: 0,
high: 1,
medium: 2,
low: 3,
unknown: 4,
};
@Component({
selector: 'app-vulnerability-explorer',
standalone: true,
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent],
templateUrl: './vulnerability-explorer.component.html',
styleUrls: ['./vulnerability-explorer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{ provide: VULNERABILITY_API, useClass: MockVulnerabilityApiService },
],
})
export class VulnerabilityExplorerComponent implements OnInit {
private readonly api = inject<VulnerabilityApi>(VULNERABILITY_API);
// View state
readonly loading = signal(false);
readonly message = signal<string | null>(null);
readonly messageType = signal<'success' | 'error' | 'info'>('info');
// Data
readonly vulnerabilities = signal<Vulnerability[]>([]);
readonly stats = signal<VulnerabilityStats | null>(null);
readonly selectedVulnId = signal<string | null>(null);
// Filters & sorting
readonly severityFilter = signal<SeverityFilter>('all');
readonly statusFilter = signal<StatusFilter>('all');
readonly searchQuery = signal('');
readonly sortField = signal<SortField>('severity');
readonly sortOrder = signal<SortOrder>('asc');
readonly showExceptedOnly = signal(false);
// Exception draft state
readonly showExceptionDraft = signal(false);
readonly selectedForException = signal<Vulnerability[]>([]);
// Exception explain state
readonly showExceptionExplain = signal(false);
readonly explainExceptionId = signal<string | null>(null);
// Constants for template
readonly severityLabels = SEVERITY_LABELS;
readonly statusLabels = STATUS_LABELS;
readonly allSeverities: VulnerabilitySeverity[] = ['critical', 'high', 'medium', 'low', 'unknown'];
readonly allStatuses: VulnerabilityStatus[] = ['open', 'fixed', 'wont_fix', 'in_progress', 'excepted'];
// Computed: filtered and sorted list
readonly filteredVulnerabilities = computed(() => {
let items = [...this.vulnerabilities()];
const severity = this.severityFilter();
const status = this.statusFilter();
const search = this.searchQuery().toLowerCase();
const exceptedOnly = this.showExceptedOnly();
if (severity !== 'all') {
items = items.filter((v) => v.severity === severity);
}
if (status !== 'all') {
items = items.filter((v) => v.status === status);
}
if (exceptedOnly) {
items = items.filter((v) => v.hasException);
}
if (search) {
items = items.filter(
(v) =>
v.cveId.toLowerCase().includes(search) ||
v.title.toLowerCase().includes(search) ||
v.description?.toLowerCase().includes(search)
);
}
return this.sortVulnerabilities(items);
});
// Computed: selected vulnerability
readonly selectedVulnerability = computed(() => {
const id = this.selectedVulnId();
if (!id) return null;
return this.vulnerabilities().find((v) => v.vulnId === id) ?? null;
});
// Computed: get exception badge data for a vulnerability
getExceptionBadgeData(vuln: Vulnerability): ExceptionBadgeData | null {
if (!vuln.hasException || !vuln.exceptionId) return null;
return {
exceptionId: vuln.exceptionId,
status: 'approved',
severity: vuln.severity === 'unknown' ? 'medium' : vuln.severity,
name: `${vuln.cveId} Exception`,
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
justificationSummary: 'Risk accepted with compensating controls in place.',
approvedBy: 'Security Team',
};
}
// Computed: explain data for selected exception
readonly exceptionExplainData = computed<ExceptionExplainData | null>(() => {
const exceptionId = this.explainExceptionId();
if (!exceptionId) return null;
const vuln = this.vulnerabilities().find((v) => v.exceptionId === exceptionId);
if (!vuln) return null;
return {
exceptionId,
name: `${vuln.cveId} Exception`,
status: 'approved',
severity: vuln.severity === 'unknown' ? 'medium' : vuln.severity,
scope: {
type: 'vulnerability',
vulnIds: [vuln.cveId],
componentPurls: vuln.affectedComponents.map((c) => c.purl),
assetIds: vuln.affectedComponents.flatMap((c) => c.assetIds),
},
justification: {
template: 'risk-accepted',
text: 'Risk accepted with compensating controls in place. The vulnerability affects internal services with restricted network access.',
},
timebox: {
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
autoRenew: false,
},
approvedBy: 'Security Team',
approvedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
impact: {
affectedFindings: vuln.affectedComponents.length,
affectedAssets: [...new Set(vuln.affectedComponents.flatMap((c) => c.assetIds))].length,
policyOverrides: 1,
},
};
});
// Computed: exception draft context
readonly exceptionDraftContext = computed<ExceptionDraftContext | null>(() => {
const selected = this.selectedForException();
if (selected.length === 0) return null;
const vulnIds = selected.map((v) => v.cveId);
const componentPurls = [...new Set(selected.flatMap((v) => v.affectedComponents.map((c) => c.purl)))];
const assetIds = [...new Set(selected.flatMap((v) => v.affectedComponents.flatMap((c) => c.assetIds)))];
const maxSeverity = selected.reduce((max, v) => {
return SEVERITY_ORDER[v.severity] < SEVERITY_ORDER[max] ? v.severity : max;
}, 'low' as VulnerabilitySeverity);
return {
vulnIds,
componentPurls,
assetIds,
suggestedName: selected.length === 1 ? `${selected[0].cveId.toLowerCase()}-exception` : `multi-vuln-exception-${Date.now()}`,
suggestedSeverity: maxSeverity === 'unknown' ? 'medium' : maxSeverity,
sourceType: 'vulnerability',
sourceLabel: selected.length === 1 ? selected[0].cveId : `${selected.length} vulnerabilities`,
};
});
async ngOnInit(): Promise<void> {
await this.loadData();
}
async loadData(): Promise<void> {
this.loading.set(true);
this.message.set(null);
try {
const [vulnsResponse, statsResponse] = await Promise.all([
firstValueFrom(this.api.listVulnerabilities()),
firstValueFrom(this.api.getStats()),
]);
this.vulnerabilities.set([...vulnsResponse.items]);
this.stats.set(statsResponse);
} catch (error) {
this.showMessage(this.toErrorMessage(error), 'error');
} finally {
this.loading.set(false);
}
}
// Filters
setSeverityFilter(severity: SeverityFilter): void {
this.severityFilter.set(severity);
}
setStatusFilter(status: StatusFilter): void {
this.statusFilter.set(status);
}
onSearchInput(event: Event): void {
const input = event.target as HTMLInputElement;
this.searchQuery.set(input.value);
}
clearSearch(): void {
this.searchQuery.set('');
}
toggleExceptedOnly(): void {
this.showExceptedOnly.set(!this.showExceptedOnly());
}
// Sorting
toggleSort(field: SortField): void {
if (this.sortField() === field) {
this.sortOrder.set(this.sortOrder() === 'asc' ? 'desc' : 'asc');
} else {
this.sortField.set(field);
this.sortOrder.set('asc');
}
}
getSortIcon(field: SortField): string {
if (this.sortField() !== field) return '';
return this.sortOrder() === 'asc' ? '↑' : '↓';
}
// Selection
selectVulnerability(vulnId: string): void {
this.selectedVulnId.set(vulnId);
this.showExceptionDraft.set(false);
}
clearSelection(): void {
this.selectedVulnId.set(null);
this.showExceptionDraft.set(false);
}
// Exception drafting
startExceptionDraft(vuln?: Vulnerability): void {
if (vuln) {
this.selectedForException.set([vuln]);
} else if (this.selectedVulnerability()) {
this.selectedForException.set([this.selectedVulnerability()!]);
}
this.showExceptionDraft.set(true);
}
cancelExceptionDraft(): void {
this.showExceptionDraft.set(false);
this.selectedForException.set([]);
}
onExceptionCreated(): void {
this.showExceptionDraft.set(false);
this.selectedForException.set([]);
this.showMessage('Exception draft created successfully', 'success');
this.loadData();
}
// Exception explain
onViewExceptionDetails(exceptionId: string): void {
this.showMessage(`Navigating to exception ${exceptionId}...`, 'info');
}
onExplainException(exceptionId: string): void {
this.explainExceptionId.set(exceptionId);
this.showExceptionExplain.set(true);
}
closeExplain(): void {
this.showExceptionExplain.set(false);
this.explainExceptionId.set(null);
}
viewExceptionFromExplain(exceptionId: string): void {
this.closeExplain();
this.onViewExceptionDetails(exceptionId);
}
openFullWizard(): void {
// In a real app, this would navigate to the Exception Center wizard
// For now, just show a message
this.showMessage('Opening full wizard... (would navigate to Exception Center)', 'info');
}
// Helpers
getSeverityClass(severity: VulnerabilitySeverity): string {
return `severity--${severity}`;
}
getStatusClass(status: VulnerabilityStatus): string {
return `status--${status.replace('_', '-')}`;
}
formatDate(dateString: string | undefined): string {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
formatCvss(score: number | undefined): string {
if (score === undefined) return '-';
return score.toFixed(1);
}
trackByVuln = (_: number, item: Vulnerability) => item.vulnId;
trackByComponent = (_: number, item: { purl: string }) => item.purl;
private sortVulnerabilities(items: Vulnerability[]): Vulnerability[] {
const field = this.sortField();
const order = this.sortOrder();
return items.sort((a, b) => {
let comparison = 0;
switch (field) {
case 'cveId':
comparison = a.cveId.localeCompare(b.cveId);
break;
case 'severity':
comparison = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
break;
case 'cvssScore':
comparison = (b.cvssScore ?? 0) - (a.cvssScore ?? 0);
break;
case 'publishedAt':
comparison = (b.publishedAt ?? '').localeCompare(a.publishedAt ?? '');
break;
case 'status':
comparison = a.status.localeCompare(b.status);
break;
default:
comparison = 0;
}
return order === 'asc' ? comparison : -comparison;
});
}
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
this.message.set(text);
this.messageType.set(type);
setTimeout(() => this.message.set(null), 5000);
}
private toErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
return 'Operation failed. Please retry.';
}
}

View File

@@ -0,0 +1,404 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
computed,
signal,
} from '@angular/core';
export interface ExceptionBadgeData {
readonly exceptionId: string;
readonly status: 'draft' | 'pending_review' | 'approved' | 'rejected' | 'expired' | 'revoked';
readonly severity: 'critical' | 'high' | 'medium' | 'low';
readonly name: string;
readonly endDate: string;
readonly justificationSummary?: string;
readonly approvedBy?: string;
}
@Component({
selector: 'app-exception-badge',
standalone: true,
imports: [CommonModule],
template: `
<div
class="exception-badge"
[class]="badgeClass()"
[class.exception-badge--expanded]="expanded()"
(click)="toggleExpanded()"
(keydown.enter)="toggleExpanded()"
(keydown.space)="toggleExpanded(); $event.preventDefault()"
tabindex="0"
role="button"
[attr.aria-expanded]="expanded()"
[attr.aria-label]="ariaLabel()"
>
<!-- Collapsed View -->
<div class="exception-badge__summary">
<span class="exception-badge__icon">✓</span>
<span class="exception-badge__label">Excepted</span>
<span class="exception-badge__countdown" *ngIf="isExpiringSoon() && countdownText()">
{{ countdownText() }}
</span>
</div>
<!-- Expanded View -->
<div class="exception-badge__details" *ngIf="expanded()">
<div class="exception-badge__header">
<span class="exception-badge__name">{{ data.name }}</span>
<span class="exception-badge__status exception-badge__status--{{ data.status }}">
{{ statusLabel() }}
</span>
</div>
<div class="exception-badge__info">
<div class="exception-badge__row">
<span class="exception-badge__row-label">Severity:</span>
<span class="exception-badge__severity exception-badge__severity--{{ data.severity }}">
{{ data.severity }}
</span>
</div>
<div class="exception-badge__row">
<span class="exception-badge__row-label">Expires:</span>
<span class="exception-badge__expiry" [class.exception-badge__expiry--soon]="isExpiringSoon()">
{{ formatDate(data.endDate) }}
</span>
</div>
<div class="exception-badge__row" *ngIf="data.approvedBy">
<span class="exception-badge__row-label">Approved by:</span>
<span>{{ data.approvedBy }}</span>
</div>
</div>
<div class="exception-badge__justification" *ngIf="data.justificationSummary">
<span class="exception-badge__justification-label">Justification:</span>
<p>{{ data.justificationSummary }}</p>
</div>
<div class="exception-badge__actions">
<button
type="button"
class="exception-badge__action"
(click)="viewDetails.emit(data.exceptionId); $event.stopPropagation()"
>
View Details
</button>
<button
type="button"
class="exception-badge__action exception-badge__action--secondary"
(click)="explain.emit(data.exceptionId); $event.stopPropagation()"
>
Explain
</button>
</div>
</div>
</div>
`,
styles: [`
.exception-badge {
display: inline-flex;
flex-direction: column;
background: #f3e8ff;
border: 1px solid #c4b5fd;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.8125rem;
&:hover {
background: #ede9fe;
border-color: #a78bfa;
}
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.3);
}
&--expanded {
min-width: 280px;
}
&--expired {
background: #f1f5f9;
border-color: #cbd5e1;
}
}
.exception-badge__summary {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
}
.exception-badge__icon {
color: #7c3aed;
font-weight: bold;
}
.exception-badge__label {
color: #6d28d9;
font-weight: 500;
}
.exception-badge__countdown {
padding: 0.125rem 0.375rem;
background: #fef3c7;
color: #92400e;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
}
.exception-badge__details {
padding: 0.75rem;
border-top: 1px solid #c4b5fd;
}
.exception-badge__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.exception-badge__name {
font-weight: 600;
color: #1e293b;
}
.exception-badge__status {
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 500;
&--approved {
background: #dcfce7;
color: #166534;
}
&--pending_review {
background: #fef3c7;
color: #92400e;
}
&--draft {
background: #f1f5f9;
color: #475569;
}
&--expired {
background: #f1f5f9;
color: #6b7280;
}
&--revoked {
background: #fce7f3;
color: #9d174d;
}
}
.exception-badge__info {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-bottom: 0.5rem;
}
.exception-badge__row {
display: flex;
gap: 0.5rem;
font-size: 0.75rem;
}
.exception-badge__row-label {
color: #64748b;
min-width: 80px;
}
.exception-badge__severity {
padding: 0.0625rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
text-transform: capitalize;
&--critical {
background: #fef2f2;
color: #dc2626;
}
&--high {
background: #fff7ed;
color: #ea580c;
}
&--medium {
background: #fefce8;
color: #ca8a04;
}
&--low {
background: #f0fdf4;
color: #16a34a;
}
}
.exception-badge__expiry {
color: #1e293b;
&--soon {
color: #dc2626;
font-weight: 500;
}
}
.exception-badge__justification {
margin-bottom: 0.5rem;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.5);
border-radius: 0.25rem;
}
.exception-badge__justification-label {
display: block;
font-size: 0.6875rem;
color: #64748b;
margin-bottom: 0.25rem;
}
.exception-badge__justification p {
margin: 0;
font-size: 0.75rem;
color: #475569;
line-height: 1.4;
}
.exception-badge__actions {
display: flex;
gap: 0.5rem;
}
.exception-badge__action {
flex: 1;
padding: 0.375rem 0.5rem;
border: none;
border-radius: 0.25rem;
background: #7c3aed;
color: white;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: #6d28d9;
}
&--secondary {
background: white;
color: #7c3aed;
border: 1px solid #c4b5fd;
&:hover {
background: #f3e8ff;
}
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExceptionBadgeComponent implements OnInit, OnDestroy {
@Input({ required: true }) data!: ExceptionBadgeData;
@Input() compact = false;
@Output() readonly viewDetails = new EventEmitter<string>();
@Output() readonly explain = new EventEmitter<string>();
readonly expanded = signal(false);
private countdownInterval?: ReturnType<typeof setInterval>;
private readonly now = signal(new Date());
readonly countdownText = computed(() => {
const endDate = new Date(this.data.endDate);
const current = this.now();
const diffMs = endDate.getTime() - current.getTime();
if (diffMs <= 0) return 'Expired';
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h`;
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
return `${minutes}m`;
});
readonly isExpiringSoon = computed(() => {
const endDate = new Date(this.data.endDate);
const current = this.now();
const sevenDays = 7 * 24 * 60 * 60 * 1000;
return endDate.getTime() - current.getTime() < sevenDays && endDate > current;
});
readonly badgeClass = computed(() => {
const classes = ['exception-badge'];
if (this.data.status === 'expired') classes.push('exception-badge--expired');
return classes.join(' ');
});
readonly statusLabel = computed(() => {
const labels: Record<string, string> = {
draft: 'Draft',
pending_review: 'Pending',
approved: 'Approved',
rejected: 'Rejected',
expired: 'Expired',
revoked: 'Revoked',
};
return labels[this.data.status] || this.data.status;
});
readonly ariaLabel = computed(() => {
return `Exception: ${this.data.name}, status: ${this.statusLabel()}, ${this.expanded() ? 'expanded' : 'collapsed'}`;
});
ngOnInit(): void {
if (this.isExpiringSoon()) {
this.countdownInterval = setInterval(() => {
this.now.set(new Date());
}, 60000); // Update every minute
}
}
ngOnDestroy(): void {
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
}
}
toggleExpanded(): void {
if (!this.compact) {
this.expanded.set(!this.expanded());
}
}
formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
}

View File

@@ -0,0 +1,525 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output,
computed,
} from '@angular/core';
export interface ExceptionExplainData {
readonly exceptionId: string;
readonly name: string;
readonly status: string;
readonly severity: string;
readonly scope: {
readonly type: string;
readonly vulnIds?: readonly string[];
readonly componentPurls?: readonly string[];
readonly assetIds?: readonly string[];
};
readonly justification: {
readonly template?: string;
readonly text: string;
};
readonly timebox: {
readonly startDate: string;
readonly endDate: string;
readonly autoRenew?: boolean;
};
readonly approvedBy?: string;
readonly approvedAt?: string;
readonly impact?: {
readonly affectedFindings: number;
readonly affectedAssets: number;
readonly policyOverrides: number;
};
}
@Component({
selector: 'app-exception-explain',
standalone: true,
imports: [CommonModule],
template: `
<div class="exception-explain">
<header class="exception-explain__header">
<h3>Exception Explanation</h3>
<button
type="button"
class="exception-explain__close"
(click)="close.emit()"
aria-label="Close explanation"
>
&times;
</button>
</header>
<div class="exception-explain__content">
<!-- Summary -->
<section class="explain-section">
<h4>What is this exception?</h4>
<p class="explain-summary">
<strong>{{ data.name }}</strong> is a
<span class="explain-severity explain-severity--{{ data.severity }}">{{ data.severity }}</span>
exception that temporarily
{{ scopeExplanation() }}.
</p>
</section>
<!-- Why it exists -->
<section class="explain-section">
<h4>Why does it exist?</h4>
<div class="explain-justification">
<span class="explain-template" *ngIf="data.justification.template">
{{ templateLabel() }}
</span>
<p>{{ data.justification.text }}</p>
</div>
</section>
<!-- Scope -->
<section class="explain-section">
<h4>What does it cover?</h4>
<ul class="explain-scope">
<li *ngIf="data.scope.vulnIds?.length">
<strong>{{ data.scope.vulnIds.length }}</strong> vulnerabilit{{ data.scope.vulnIds.length === 1 ? 'y' : 'ies' }}:
<span class="explain-items">{{ formatList(data.scope.vulnIds) }}</span>
</li>
<li *ngIf="data.scope.componentPurls?.length">
<strong>{{ data.scope.componentPurls.length }}</strong> component{{ data.scope.componentPurls.length === 1 ? '' : 's' }}
</li>
<li *ngIf="data.scope.assetIds?.length">
<strong>{{ data.scope.assetIds.length }}</strong> asset{{ data.scope.assetIds.length === 1 ? '' : 's' }}:
<span class="explain-items">{{ formatList(data.scope.assetIds) }}</span>
</li>
<li *ngIf="data.scope.type === 'global'">
<strong>Global scope</strong> - applies to all matching findings
</li>
</ul>
</section>
<!-- Impact -->
<section class="explain-section" *ngIf="data.impact">
<h4>What is the impact?</h4>
<div class="explain-impact">
<div class="impact-stat">
<span class="impact-stat__value">{{ data.impact.affectedFindings }}</span>
<span class="impact-stat__label">Findings Suppressed</span>
</div>
<div class="impact-stat">
<span class="impact-stat__value">{{ data.impact.affectedAssets }}</span>
<span class="impact-stat__label">Assets Affected</span>
</div>
<div class="impact-stat">
<span class="impact-stat__value">{{ data.impact.policyOverrides }}</span>
<span class="impact-stat__label">Policy Overrides</span>
</div>
</div>
</section>
<!-- Timeline -->
<section class="explain-section">
<h4>When is it valid?</h4>
<div class="explain-timeline">
<div class="timeline-row">
<span class="timeline-label">Started:</span>
<span>{{ formatDate(data.timebox.startDate) }}</span>
</div>
<div class="timeline-row">
<span class="timeline-label">Expires:</span>
<span [class.timeline-expiring]="isExpiringSoon()">
{{ formatDate(data.timebox.endDate) }}
<span class="timeline-countdown" *ngIf="isExpiringSoon()">
({{ daysRemaining() }} days remaining)
</span>
</span>
</div>
<div class="timeline-row" *ngIf="data.timebox.autoRenew">
<span class="timeline-label">Auto-renew:</span>
<span class="timeline-autorenew">Enabled</span>
</div>
</div>
</section>
<!-- Approval -->
<section class="explain-section" *ngIf="data.approvedBy">
<h4>Who approved it?</h4>
<div class="explain-approval">
<span class="approval-by">{{ data.approvedBy }}</span>
<span class="approval-date" *ngIf="data.approvedAt">
on {{ formatDate(data.approvedAt) }}
</span>
</div>
</section>
<!-- Warning -->
<div class="explain-warning" *ngIf="data.severity === 'critical'">
<span class="explain-warning__icon">⚠️</span>
<p>
This exception suppresses <strong>critical</strong> severity findings.
Ensure compensating controls are in place and review regularly.
</p>
</div>
</div>
<footer class="exception-explain__footer">
<button
type="button"
class="btn btn--secondary"
(click)="close.emit()"
>
Close
</button>
<button
type="button"
class="btn btn--primary"
(click)="viewException.emit(data.exceptionId)"
>
View Full Details
</button>
</footer>
</div>
`,
styles: [`
.exception-explain {
display: flex;
flex-direction: column;
max-width: 480px;
background: white;
border-radius: 0.75rem;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
overflow: hidden;
}
.exception-explain__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
background: #f3e8ff;
border-bottom: 1px solid #c4b5fd;
h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #6d28d9;
}
}
.exception-explain__close {
padding: 0.25rem 0.5rem;
border: none;
background: transparent;
color: #7c3aed;
font-size: 1.25rem;
cursor: pointer;
&:hover {
color: #6d28d9;
}
}
.exception-explain__content {
padding: 1.25rem;
max-height: 60vh;
overflow-y: auto;
}
.explain-section {
margin-bottom: 1.25rem;
h4 {
margin: 0 0 0.5rem;
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
&:last-child {
margin-bottom: 0;
}
}
.explain-summary {
margin: 0;
font-size: 0.9375rem;
line-height: 1.5;
color: #334155;
}
.explain-severity {
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.8125rem;
font-weight: 500;
text-transform: capitalize;
&--critical {
background: #fef2f2;
color: #dc2626;
}
&--high {
background: #fff7ed;
color: #ea580c;
}
&--medium {
background: #fefce8;
color: #ca8a04;
}
&--low {
background: #f0fdf4;
color: #16a34a;
}
}
.explain-justification {
padding: 0.75rem;
background: #f8fafc;
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
p {
margin: 0;
font-size: 0.875rem;
color: #475569;
line-height: 1.5;
}
}
.explain-template {
display: inline-block;
margin-bottom: 0.5rem;
padding: 0.125rem 0.5rem;
background: #e0e7ff;
color: #4338ca;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
}
.explain-scope {
margin: 0;
padding-left: 1.25rem;
font-size: 0.875rem;
color: #475569;
li {
margin-bottom: 0.375rem;
}
}
.explain-items {
color: #64748b;
font-family: ui-monospace, monospace;
font-size: 0.75rem;
}
.explain-impact {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
.impact-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem;
background: #f8fafc;
border-radius: 0.5rem;
text-align: center;
}
.impact-stat__value {
font-size: 1.25rem;
font-weight: 700;
color: #1e293b;
}
.impact-stat__label {
font-size: 0.6875rem;
color: #64748b;
margin-top: 0.25rem;
}
.explain-timeline {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.timeline-row {
display: flex;
gap: 0.5rem;
font-size: 0.875rem;
}
.timeline-label {
color: #64748b;
min-width: 80px;
}
.timeline-expiring {
color: #dc2626;
font-weight: 500;
}
.timeline-countdown {
font-size: 0.75rem;
color: #f59e0b;
}
.timeline-autorenew {
padding: 0.125rem 0.375rem;
background: #dcfce7;
color: #166534;
border-radius: 0.25rem;
font-size: 0.75rem;
}
.explain-approval {
display: flex;
gap: 0.375rem;
font-size: 0.875rem;
}
.approval-by {
font-weight: 500;
color: #1e293b;
}
.approval-date {
color: #64748b;
}
.explain-warning {
display: flex;
gap: 0.5rem;
padding: 0.75rem;
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 0.5rem;
margin-top: 1rem;
p {
margin: 0;
font-size: 0.8125rem;
color: #92400e;
line-height: 1.4;
}
}
.explain-warning__icon {
flex-shrink: 0;
}
.exception-explain__footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&--primary {
background: #7c3aed;
color: white;
&:hover {
background: #6d28d9;
}
}
&--secondary {
background: white;
color: #475569;
border: 1px solid #e2e8f0;
&:hover {
background: #f8fafc;
}
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExceptionExplainComponent {
@Input({ required: true }) data!: ExceptionExplainData;
@Output() readonly close = new EventEmitter<void>();
@Output() readonly viewException = new EventEmitter<string>();
readonly scopeExplanation = computed(() => {
const scope = this.data.scope;
if (scope.type === 'global') {
return 'suppresses findings across all assets and components';
}
const parts: string[] = [];
if (scope.vulnIds?.length) {
parts.push(`${scope.vulnIds.length} vulnerabilit${scope.vulnIds.length === 1 ? 'y' : 'ies'}`);
}
if (scope.componentPurls?.length) {
parts.push(`${scope.componentPurls.length} component${scope.componentPurls.length === 1 ? '' : 's'}`);
}
if (scope.assetIds?.length) {
parts.push(`${scope.assetIds.length} asset${scope.assetIds.length === 1 ? '' : 's'}`);
}
return `suppresses findings for ${parts.join(' across ')}`;
});
readonly templateLabel = computed(() => {
const labels: Record<string, string> = {
'risk-accepted': 'Risk Accepted',
'compensating-control': 'Compensating Control',
'false-positive': 'False Positive',
'scheduled-fix': 'Scheduled Fix',
'internal-only': 'Internal Only',
};
return labels[this.data.justification.template ?? ''] || this.data.justification.template;
});
isExpiringSoon(): boolean {
const endDate = new Date(this.data.timebox.endDate);
const now = new Date();
const sevenDays = 7 * 24 * 60 * 60 * 1000;
return endDate.getTime() - now.getTime() < sevenDays && endDate > now;
}
daysRemaining(): number {
const endDate = new Date(this.data.timebox.endDate);
const now = new Date();
return Math.ceil((endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
}
formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
formatList(items: readonly string[], max = 3): string {
if (items.length <= max) {
return items.join(', ');
}
return `${items.slice(0, max).join(', ')} +${items.length - max} more`;
}
}

View File

@@ -0,0 +1,2 @@
export { ExceptionBadgeComponent, ExceptionBadgeData } from './exception-badge.component';
export { ExceptionExplainComponent, ExceptionExplainData } from './exception-explain.component';

View File

@@ -0,0 +1,193 @@
import {
Exception,
ExceptionStats,
} from '../core/api/exception.models';
/**
* Test fixtures for Exception Center components and services.
*/
export const exceptionDraft: Exception = {
schemaVersion: '1.0',
exceptionId: 'exc-test-001',
tenantId: 'tenant-test',
name: 'test-draft-exception',
displayName: 'Test Draft Exception',
description: 'A draft exception for testing purposes',
status: 'draft',
severity: 'medium',
scope: {
type: 'component',
componentPurls: ['pkg:npm/lodash@4.17.20'],
vulnIds: ['CVE-2021-23337'],
},
justification: {
template: 'risk-accepted',
text: 'Risk accepted for testing environment only.',
},
timebox: {
startDate: '2025-01-01T00:00:00Z',
endDate: '2025-03-31T23:59:59Z',
},
labels: { env: 'test' },
createdBy: 'test@example.com',
createdAt: '2025-01-01T10:00:00Z',
};
export const exceptionPendingReview: Exception = {
schemaVersion: '1.0',
exceptionId: 'exc-test-002',
tenantId: 'tenant-test',
name: 'test-pending-exception',
displayName: 'Test Pending Review Exception',
description: 'An exception awaiting review',
status: 'pending_review',
severity: 'high',
scope: {
type: 'asset',
assetIds: ['asset-web-prod'],
vulnIds: ['CVE-2024-1234'],
},
justification: {
template: 'compensating-control',
text: 'WAF rules in place to mitigate risk.',
},
timebox: {
startDate: '2025-01-15T00:00:00Z',
endDate: '2025-02-15T23:59:59Z',
},
createdBy: 'ops@example.com',
createdAt: '2025-01-10T14:00:00Z',
};
export const exceptionApproved: Exception = {
schemaVersion: '1.0',
exceptionId: 'exc-test-003',
tenantId: 'tenant-test',
name: 'test-approved-exception',
displayName: 'Test Approved Exception',
description: 'An approved exception with audit trail',
status: 'approved',
severity: 'critical',
scope: {
type: 'global',
},
justification: {
text: 'Emergency exception for production hotfix.',
},
timebox: {
startDate: '2025-01-20T00:00:00Z',
endDate: '2025-01-27T23:59:59Z',
autoRenew: false,
},
approvals: [
{
approvalId: 'apr-test-001',
approvedBy: 'security@example.com',
approvedAt: '2025-01-20T09:30:00Z',
comment: 'Approved for emergency hotfix window',
},
],
auditTrail: [
{
auditId: 'aud-001',
action: 'created',
actor: 'dev@example.com',
timestamp: '2025-01-19T16:00:00Z',
},
{
auditId: 'aud-002',
action: 'submitted_for_review',
actor: 'dev@example.com',
timestamp: '2025-01-19T16:05:00Z',
previousStatus: 'draft',
newStatus: 'pending_review',
},
{
auditId: 'aud-003',
action: 'approved',
actor: 'security@example.com',
timestamp: '2025-01-20T09:30:00Z',
previousStatus: 'pending_review',
newStatus: 'approved',
},
],
createdBy: 'dev@example.com',
createdAt: '2025-01-19T16:00:00Z',
updatedBy: 'security@example.com',
updatedAt: '2025-01-20T09:30:00Z',
};
export const exceptionExpired: Exception = {
schemaVersion: '1.0',
exceptionId: 'exc-test-004',
tenantId: 'tenant-test',
name: 'test-expired-exception',
displayName: 'Test Expired Exception',
status: 'expired',
severity: 'low',
scope: {
type: 'tenant',
tenantId: 'tenant-test',
},
justification: {
text: 'Legacy system exception.',
},
timebox: {
startDate: '2024-06-01T00:00:00Z',
endDate: '2024-12-31T23:59:59Z',
},
createdBy: 'admin@example.com',
createdAt: '2024-05-15T08:00:00Z',
};
export const exceptionRejected: Exception = {
schemaVersion: '1.0',
exceptionId: 'exc-test-005',
tenantId: 'tenant-test',
name: 'test-rejected-exception',
displayName: 'Test Rejected Exception',
description: 'Rejected due to insufficient justification',
status: 'rejected',
severity: 'critical',
scope: {
type: 'global',
},
justification: {
text: 'We need this exception.',
},
timebox: {
startDate: '2025-01-01T00:00:00Z',
endDate: '2025-12-31T23:59:59Z',
},
createdBy: 'dev@example.com',
createdAt: '2024-12-20T10:00:00Z',
};
export const allTestExceptions: Exception[] = [
exceptionDraft,
exceptionPendingReview,
exceptionApproved,
exceptionExpired,
exceptionRejected,
];
export const testExceptionStats: ExceptionStats = {
total: 5,
byStatus: {
draft: 1,
pending_review: 1,
approved: 1,
rejected: 1,
expired: 1,
revoked: 0,
},
bySeverity: {
critical: 2,
high: 1,
medium: 1,
low: 1,
},
expiringWithin7Days: 1,
pendingApproval: 1,
};