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
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:
@@ -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',
|
||||
|
||||
424
src/Web/StellaOps.Web/src/app/core/api/exception.client.ts
Normal file
424
src/Web/StellaOps.Web/src/app/core/api/exception.client.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
116
src/Web/StellaOps.Web/src/app/core/api/exception.models.ts
Normal file
116
src/Web/StellaOps.Web/src/app/core/api/exception.models.ts
Normal 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;
|
||||
}
|
||||
316
src/Web/StellaOps.Web/src/app/core/api/vulnerability.client.ts
Normal file
316
src/Web/StellaOps.Web/src/app/core/api/vulnerability.client.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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] }} → {{ 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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@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: ≤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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
×
|
||||
</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`;
|
||||
}
|
||||
}
|
||||
2
src/Web/StellaOps.Web/src/app/shared/components/index.ts
Normal file
2
src/Web/StellaOps.Web/src/app/shared/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ExceptionBadgeComponent, ExceptionBadgeData } from './exception-badge.component';
|
||||
export { ExceptionExplainComponent, ExceptionExplainData } from './exception-explain.component';
|
||||
193
src/Web/StellaOps.Web/src/app/testing/exception-fixtures.ts
Normal file
193
src/Web/StellaOps.Web/src/app/testing/exception-fixtures.ts
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user