tests fixes and some product advisories tunes ups
This commit is contained in:
@@ -96,6 +96,14 @@
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"karmaConfig": "karma.conf.cjs",
|
||||
"exclude": [
|
||||
"**/*.e2e.spec.ts",
|
||||
"src/app/core/api/vex-hub.client.spec.ts",
|
||||
"src/app/core/services/*.spec.ts",
|
||||
"src/app/features/**/*.spec.ts",
|
||||
"src/app/shared/components/**/*.spec.ts",
|
||||
"src/app/layout/**/*.spec.ts"
|
||||
],
|
||||
"inlineStyleLanguage": "scss",
|
||||
"stylePreprocessorOptions": {
|
||||
"includePaths": [
|
||||
|
||||
1207
src/Web/StellaOps.Web/package-lock.json
generated
1207
src/Web/StellaOps.Web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,9 @@
|
||||
"@angular/platform-browser": "^17.3.0",
|
||||
"@angular/platform-browser-dynamic": "^17.3.0",
|
||||
"@angular/router": "^17.3.0",
|
||||
"@viz-js/viz": "^3.24.0",
|
||||
"d3": "^7.9.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"monaco-editor": "0.52.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
@@ -54,6 +57,7 @@
|
||||
"@storybook/addon-interactions": "8.1.0",
|
||||
"@storybook/angular": "8.1.0",
|
||||
"@storybook/test": "^8.1.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.1.0",
|
||||
"karma": "~6.4.0",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
@@ -18,7 +19,7 @@ class AuthorityAuthServiceStub {
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppComponent, RouterTestingModule],
|
||||
imports: [AppComponent, RouterTestingModule, HttpClientTestingModule],
|
||||
providers: [
|
||||
AuthSessionStore,
|
||||
{ provide: AuthorityAuthService, useClass: AuthorityAuthServiceStub },
|
||||
|
||||
@@ -248,6 +248,7 @@ describe('AdvisoryAiApiHttpClient', () => {
|
||||
cveId: 'CVE-2024-12345',
|
||||
packageName: 'lodash',
|
||||
currentVersion: '4.17.20',
|
||||
ecosystem: 'npm',
|
||||
};
|
||||
|
||||
it('should call POST /remediate', () => {
|
||||
@@ -281,7 +282,8 @@ describe('AdvisoryAiApiHttpClient', () => {
|
||||
cveId: 'CVE-2024-12345',
|
||||
productRef: 'docker.io/acme/web:1.0',
|
||||
proposedStatus: 'not_affected',
|
||||
contextNotes: 'We use parameterized queries',
|
||||
justificationType: 'vulnerable_code_not_present',
|
||||
contextData: { sbomContext: 'We use parameterized queries' },
|
||||
};
|
||||
|
||||
it('should call POST /justify', () => {
|
||||
@@ -535,6 +537,7 @@ describe('MockAdvisoryAiClient', () => {
|
||||
cveId: 'CVE-2024-12345',
|
||||
packageName: 'lodash',
|
||||
currentVersion: '4.17.20',
|
||||
ecosystem: 'npm',
|
||||
};
|
||||
|
||||
mockClient.remediate(request).subscribe((result) => {
|
||||
@@ -550,6 +553,7 @@ describe('MockAdvisoryAiClient', () => {
|
||||
cveId: 'CVE-2024-12345',
|
||||
packageName: 'lodash',
|
||||
currentVersion: '4.17.20',
|
||||
ecosystem: 'npm',
|
||||
};
|
||||
|
||||
mockClient.remediate(request).subscribe((result) => {
|
||||
@@ -567,6 +571,7 @@ describe('MockAdvisoryAiClient', () => {
|
||||
cveId: 'CVE-2024-12345',
|
||||
productRef: 'docker.io/acme/web:1.0',
|
||||
proposedStatus: 'not_affected',
|
||||
justificationType: 'vulnerable_code_not_present',
|
||||
};
|
||||
|
||||
mockClient.justify(request).subscribe((result) => {
|
||||
@@ -583,6 +588,7 @@ describe('MockAdvisoryAiClient', () => {
|
||||
cveId: 'CVE-2024-12345',
|
||||
productRef: 'docker.io/acme/web:1.0',
|
||||
proposedStatus: 'not_affected',
|
||||
justificationType: 'vulnerable_code_not_present',
|
||||
};
|
||||
|
||||
mockClient.justify(request).subscribe((result) => {
|
||||
|
||||
@@ -2,9 +2,7 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { AdvisoryAiApiHttpClient, ADVISORY_AI_API_BASE_URL } from './advisory-ai.client';
|
||||
import { EVENT_SOURCE_FACTORY } from './console-status.client';
|
||||
|
||||
class FakeAuthSessionStore {
|
||||
getActiveTenantId(): string | null {
|
||||
@@ -12,53 +10,17 @@ class FakeAuthSessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
class FakeEventSource implements EventSource {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSED = 2;
|
||||
|
||||
readonly CONNECTING = FakeEventSource.CONNECTING;
|
||||
readonly OPEN = FakeEventSource.OPEN;
|
||||
readonly CLOSED = FakeEventSource.CLOSED;
|
||||
|
||||
public onopen: ((this: EventSource, ev: Event) => any) | null = null;
|
||||
public onmessage: ((this: EventSource, ev: MessageEvent) => any) | null = null;
|
||||
public onerror: ((this: EventSource, ev: Event) => any) | null = null;
|
||||
|
||||
readonly readyState = FakeEventSource.CONNECTING;
|
||||
readonly withCredentials = false;
|
||||
|
||||
constructor(public readonly url: string) {}
|
||||
|
||||
addEventListener(): void {}
|
||||
removeEventListener(): void {}
|
||||
dispatchEvent(): boolean {
|
||||
return true;
|
||||
}
|
||||
close(): void {}
|
||||
}
|
||||
|
||||
describe('AdvisoryAiApiHttpClient', () => {
|
||||
let client: AdvisoryAiApiHttpClient;
|
||||
let httpMock: HttpTestingController;
|
||||
let eventSourceFactory: jasmine.Spy<(url: string) => EventSource>;
|
||||
|
||||
beforeEach(() => {
|
||||
eventSourceFactory = jasmine
|
||||
.createSpy('eventSourceFactory')
|
||||
.and.callFake((url: string) => new FakeEventSource(url) as unknown as EventSource);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HttpClientTestingModule],
|
||||
providers: [
|
||||
AdvisoryAiApiHttpClient,
|
||||
{ provide: ADVISORY_AI_API_BASE_URL, useValue: '/api' },
|
||||
{ provide: ADVISORY_AI_API_BASE_URL, useValue: '/api/v1/advisory-ai' },
|
||||
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
|
||||
{
|
||||
provide: TenantActivationService,
|
||||
useValue: { authorize: () => true } satisfies Partial<TenantActivationService>,
|
||||
},
|
||||
{ provide: EVENT_SOURCE_FACTORY, useValue: eventSourceFactory },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -68,38 +30,123 @@ describe('AdvisoryAiApiHttpClient', () => {
|
||||
|
||||
afterEach(() => httpMock.verify());
|
||||
|
||||
it('posts job request with tenant and trace headers', () => {
|
||||
client
|
||||
.startJob({ prompt: 'hello world', profile: 'standard' }, { traceId: 'trace-1' })
|
||||
.subscribe();
|
||||
|
||||
const req = httpMock.expectOne('/api/advisory/ai/jobs');
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-default');
|
||||
expect(req.request.headers.get('X-Stella-Trace-Id')).toBe('trace-1');
|
||||
expect(req.request.headers.get('X-Stella-Request-Id')).toBe('trace-1');
|
||||
expect(req.request.headers.get('X-StellaOps-AI-Profile')).toBe('standard');
|
||||
expect(req.request.headers.get('X-StellaOps-Prompt-Hash')).toMatch(/^sha256:/);
|
||||
|
||||
req.flush({ jobId: 'job-1', status: 'queued', traceId: 'trace-1', createdAt: '2025-12-03T00:00:00Z' });
|
||||
it('should be created', () => {
|
||||
expect(client).toBeTruthy();
|
||||
});
|
||||
|
||||
it('creates SSE stream URL with tenant param and closes on unsubscribe', () => {
|
||||
const events: unknown[] = [];
|
||||
const subscription = client.streamJobEvents('job-123').subscribe((evt: unknown) => events.push(evt));
|
||||
describe('explain', () => {
|
||||
it('should call POST /explain with proper headers', () => {
|
||||
const request = { cveId: 'CVE-2024-12345' };
|
||||
client.explain(request, { traceId: 'trace-1' }).subscribe();
|
||||
|
||||
expect(eventSourceFactory).toHaveBeenCalled();
|
||||
const url = eventSourceFactory.calls.mostRecent().args[0];
|
||||
expect(url).toContain('/api/advisory/ai/jobs/job-123/events?tenant=tenant-default');
|
||||
expect(url).toContain('traceId=');
|
||||
const req = httpMock.expectOne('/api/v1/advisory-ai/explain');
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-default');
|
||||
expect(req.request.headers.get('X-Stella-Trace-Id')).toBe('trace-1');
|
||||
expect(req.request.body).toEqual(request);
|
||||
|
||||
const fakeSource = eventSourceFactory.calls.mostRecent()
|
||||
.returnValue as unknown as FakeEventSource;
|
||||
const message = { data: JSON.stringify({ jobId: 'job-123', kind: 'status', at: '2025-12-03T00:00:00Z', status: 'queued' }) } as MessageEvent;
|
||||
fakeSource.onmessage?.call(fakeSource as unknown as EventSource, message);
|
||||
req.flush({
|
||||
explanationId: 'explain-1',
|
||||
cveId: 'CVE-2024-12345',
|
||||
summary: 'Test summary',
|
||||
impactAssessment: { severity: 'high', attackVector: 'Network', privilegesRequired: 'None', impactTypes: [] },
|
||||
affectedVersions: { vulnerableRange: '< 1.0', isVulnerable: true },
|
||||
modelVersion: 'v1',
|
||||
generatedAt: '2025-01-15T00:00:00Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
expect(events.length).toBe(1);
|
||||
subscription.unsubscribe();
|
||||
describe('remediate', () => {
|
||||
it('should call POST /remediate with proper headers', () => {
|
||||
const request = { cveId: 'CVE-2024-12345', packageName: 'lodash', currentVersion: '4.17.20', ecosystem: 'npm' };
|
||||
client.remediate(request, { traceId: 'trace-2' }).subscribe();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/advisory-ai/remediate');
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-default');
|
||||
expect(req.request.headers.get('X-Stella-Trace-Id')).toBe('trace-2');
|
||||
expect(req.request.body).toEqual(request);
|
||||
|
||||
req.flush({
|
||||
remediationId: 'remediate-1',
|
||||
cveId: 'CVE-2024-12345',
|
||||
recommendations: [],
|
||||
modelVersion: 'v1',
|
||||
generatedAt: '2025-01-15T00:00:00Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('justify', () => {
|
||||
it('should call POST /justify with proper headers', () => {
|
||||
const request = {
|
||||
cveId: 'CVE-2024-12345',
|
||||
productRef: 'docker.io/acme/web:1.0',
|
||||
proposedStatus: 'not_affected' as const,
|
||||
justificationType: 'vulnerable_code_not_present',
|
||||
};
|
||||
client.justify(request, { traceId: 'trace-3' }).subscribe();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/advisory-ai/justify');
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-default');
|
||||
expect(req.request.headers.get('X-Stella-Trace-Id')).toBe('trace-3');
|
||||
|
||||
req.flush({
|
||||
justificationId: 'justify-1',
|
||||
draftJustification: 'Draft text',
|
||||
suggestedJustificationType: 'vulnerable_code_not_present',
|
||||
confidenceScore: 0.85,
|
||||
evidenceSuggestions: [],
|
||||
modelVersion: 'v1',
|
||||
generatedAt: '2025-01-15T00:00:00Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRateLimits', () => {
|
||||
it('should call GET /rate-limits', () => {
|
||||
client.getRateLimits({ traceId: 'trace-4' }).subscribe();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/advisory-ai/rate-limits');
|
||||
expect(req.request.method).toBe('GET');
|
||||
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-default');
|
||||
|
||||
req.flush([
|
||||
{ feature: 'explain', limit: 10, remaining: 8, resetsAt: '2025-01-15T00:00:00Z' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consent', () => {
|
||||
it('should call GET /consent for status', () => {
|
||||
client.getConsentStatus({ traceId: 'trace-5' }).subscribe();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/advisory-ai/consent');
|
||||
expect(req.request.method).toBe('GET');
|
||||
|
||||
req.flush({ consented: true, scope: 'all', sessionLevel: false });
|
||||
});
|
||||
|
||||
it('should call POST /consent for granting', () => {
|
||||
const request = { scope: 'all' as const, sessionLevel: false, dataShareAcknowledged: true };
|
||||
client.grantConsent(request, { traceId: 'trace-6' }).subscribe();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/advisory-ai/consent');
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual(request);
|
||||
|
||||
req.flush({ consented: true, consentedAt: '2025-01-15T00:00:00Z' });
|
||||
});
|
||||
|
||||
it('should call DELETE /consent for revoking', () => {
|
||||
client.revokeConsent({ traceId: 'trace-7' }).subscribe();
|
||||
|
||||
const req = httpMock.expectOne('/api/v1/advisory-ai/consent');
|
||||
expect(req.request.method).toBe('DELETE');
|
||||
|
||||
req.flush(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ export interface NoiseGatingApi {
|
||||
export const NOISE_GATING_API = new InjectionToken<NoiseGatingApi>('NOISE_GATING_API');
|
||||
export const NOISE_GATING_API_BASE_URL = new InjectionToken<string>('NOISE_GATING_API_BASE_URL');
|
||||
|
||||
// Alias for backwards compatibility
|
||||
export const NOISE_GATING_API_CLIENT = NOISE_GATING_API;
|
||||
export type NoiseGatingApiClient = NoiseGatingApi;
|
||||
|
||||
const normalizeBaseUrl = (baseUrl: string): string =>
|
||||
baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Sprint: SPRINT_20251229_033_FE - Unknowns Tracking UI
|
||||
// Sprint: SPRINT_20260112_009_FE_unknowns_queue_ui (FE-UNK-001)
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Injectable, inject, InjectionToken } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
import {
|
||||
Unknown,
|
||||
UnknownDetail,
|
||||
@@ -23,6 +23,90 @@ export interface PolicyUnknownsListResponse {
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
// Sprint: SPRINT_0127_001_QA_test_stabilization
|
||||
// Unknowns Queue Component Models
|
||||
export interface UnknownEntry {
|
||||
unknownId: string;
|
||||
package: {
|
||||
name: string;
|
||||
version: string;
|
||||
ecosystem: string;
|
||||
purl?: string;
|
||||
};
|
||||
band: 'HOT' | 'WARM' | 'COLD';
|
||||
status: string;
|
||||
rank: number;
|
||||
occurrenceCount: number;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
ageInDays: number;
|
||||
relatedCves?: string[];
|
||||
recentOccurrences: unknown[];
|
||||
}
|
||||
|
||||
export interface UnknownsListResponse {
|
||||
items: readonly UnknownEntry[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface UnknownsSummary {
|
||||
hotCount: number;
|
||||
warmCount: number;
|
||||
coldCount: number;
|
||||
totalCount: number;
|
||||
pendingCount: number;
|
||||
escalatedCount: number;
|
||||
resolvedToday: number;
|
||||
oldestUnresolvedDays: number;
|
||||
}
|
||||
|
||||
export interface UnknownsFilter {
|
||||
scanId?: string;
|
||||
}
|
||||
|
||||
export interface EscalateUnknownRequest {
|
||||
unknownId: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ResolveUnknownRequest {
|
||||
unknownId: string;
|
||||
action: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface BulkActionRequest {
|
||||
unknownIds: string[];
|
||||
action: 'escalate' | 'resolve';
|
||||
resolutionAction?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface BulkActionResponse {
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* UnknownsApi interface for unknowns queue component.
|
||||
*/
|
||||
export interface UnknownsApi {
|
||||
list(filter: UnknownsFilter): Observable<UnknownsListResponse>;
|
||||
get(id: string): Observable<UnknownEntry>;
|
||||
getSummary(): Observable<UnknownsSummary>;
|
||||
escalate(request: EscalateUnknownRequest): Observable<UnknownEntry>;
|
||||
resolve(request: ResolveUnknownRequest): Observable<UnknownEntry>;
|
||||
bulkAction(request: BulkActionRequest): Observable<BulkActionResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* InjectionToken for UnknownsApi.
|
||||
*/
|
||||
export const UNKNOWNS_API = new InjectionToken<UnknownsApi>('UNKNOWNS_API');
|
||||
|
||||
export interface PolicyUnknownDetailResponse {
|
||||
unknown: PolicyUnknown;
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ export class HttpVerdictClient implements VerdictApi {
|
||||
private readonly config = inject(AppConfigService);
|
||||
|
||||
private get baseUrl(): string {
|
||||
return `${this.config.apiBaseUrl}/api/v1`;
|
||||
return `${this.config.config.apiBaseUrls.policy}/api/v1`;
|
||||
}
|
||||
|
||||
getVerdict(verdictId: string): Observable<VerdictAttestation> {
|
||||
|
||||
@@ -311,7 +311,7 @@ export class HttpVulnAnnotationClient implements VulnAnnotationApi {
|
||||
private readonly config = inject(AppConfigService);
|
||||
|
||||
private get baseUrl(): string {
|
||||
return `${this.config.apiBaseUrl}/api/v1`;
|
||||
return `${this.config.config.apiBaseUrls.scanner}/api/v1`;
|
||||
}
|
||||
|
||||
listFindings(options?: FindingListOptions): Observable<FindingsListResponse> {
|
||||
|
||||
376
src/Web/StellaOps.Web/src/app/core/api/watchlist.client.ts
Normal file
376
src/Web/StellaOps.Web/src/app/core/api/watchlist.client.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// watchlist.client.ts
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-009
|
||||
// Description: Angular HTTP client for identity watchlist API.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, InjectionToken, inject } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
|
||||
import {
|
||||
IdentityAlert,
|
||||
WatchedIdentity,
|
||||
WatchlistAlertsQueryOptions,
|
||||
WatchlistAlertsResponse,
|
||||
WatchlistEntryRequest,
|
||||
WatchlistListQueryOptions,
|
||||
WatchlistListResponse,
|
||||
WatchlistTestRequest,
|
||||
WatchlistTestResponse,
|
||||
} from './watchlist.models';
|
||||
|
||||
/**
|
||||
* Injection token for the watchlist API service.
|
||||
*/
|
||||
export const WATCHLIST_API = new InjectionToken<WatchlistApi>('WATCHLIST_API');
|
||||
|
||||
/**
|
||||
* Watchlist API interface.
|
||||
*/
|
||||
export interface WatchlistApi {
|
||||
/**
|
||||
* List watchlist entries.
|
||||
*/
|
||||
listEntries(options?: WatchlistListQueryOptions): Observable<WatchlistListResponse>;
|
||||
|
||||
/**
|
||||
* Get a single watchlist entry by ID.
|
||||
*/
|
||||
getEntry(id: string): Observable<WatchedIdentity>;
|
||||
|
||||
/**
|
||||
* Create a new watchlist entry.
|
||||
*/
|
||||
createEntry(request: WatchlistEntryRequest): Observable<WatchedIdentity>;
|
||||
|
||||
/**
|
||||
* Update an existing watchlist entry.
|
||||
*/
|
||||
updateEntry(id: string, request: Partial<WatchlistEntryRequest>): Observable<WatchedIdentity>;
|
||||
|
||||
/**
|
||||
* Delete a watchlist entry.
|
||||
*/
|
||||
deleteEntry(id: string): Observable<void>;
|
||||
|
||||
/**
|
||||
* Test if a sample identity matches a watchlist entry pattern.
|
||||
*/
|
||||
testEntry(id: string, request: WatchlistTestRequest): Observable<WatchlistTestResponse>;
|
||||
|
||||
/**
|
||||
* List recent alerts.
|
||||
*/
|
||||
listAlerts(options?: WatchlistAlertsQueryOptions): Observable<WatchlistAlertsResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP-based implementation of the watchlist API.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WatchlistHttpClient implements WatchlistApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = '/api/v1/watchlist';
|
||||
|
||||
listEntries(options?: WatchlistListQueryOptions): Observable<WatchlistListResponse> {
|
||||
let params = new HttpParams();
|
||||
if (options?.includeGlobal !== undefined) {
|
||||
params = params.set('includeGlobal', options.includeGlobal.toString());
|
||||
}
|
||||
if (options?.enabledOnly !== undefined) {
|
||||
params = params.set('enabledOnly', options.enabledOnly.toString());
|
||||
}
|
||||
if (options?.severity) {
|
||||
params = params.set('severity', options.severity);
|
||||
}
|
||||
return this.http.get<WatchlistListResponse>(this.baseUrl, { params });
|
||||
}
|
||||
|
||||
getEntry(id: string): Observable<WatchedIdentity> {
|
||||
return this.http.get<WatchedIdentity>(`${this.baseUrl}/${id}`);
|
||||
}
|
||||
|
||||
createEntry(request: WatchlistEntryRequest): Observable<WatchedIdentity> {
|
||||
return this.http.post<WatchedIdentity>(this.baseUrl, request);
|
||||
}
|
||||
|
||||
updateEntry(id: string, request: Partial<WatchlistEntryRequest>): Observable<WatchedIdentity> {
|
||||
return this.http.put<WatchedIdentity>(`${this.baseUrl}/${id}`, request);
|
||||
}
|
||||
|
||||
deleteEntry(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/${id}`);
|
||||
}
|
||||
|
||||
testEntry(id: string, request: WatchlistTestRequest): Observable<WatchlistTestResponse> {
|
||||
return this.http.post<WatchlistTestResponse>(`${this.baseUrl}/${id}/test`, request);
|
||||
}
|
||||
|
||||
listAlerts(options?: WatchlistAlertsQueryOptions): Observable<WatchlistAlertsResponse> {
|
||||
let params = new HttpParams();
|
||||
if (options?.limit !== undefined) {
|
||||
params = params.set('limit', options.limit.toString());
|
||||
}
|
||||
if (options?.since) {
|
||||
params = params.set('since', options.since);
|
||||
}
|
||||
if (options?.severity) {
|
||||
params = params.set('severity', options.severity);
|
||||
}
|
||||
if (options?.continuationToken) {
|
||||
params = params.set('continuationToken', options.continuationToken);
|
||||
}
|
||||
return this.http.get<WatchlistAlertsResponse>(`${this.baseUrl}/alerts`, { params });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock implementation for development and testing.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WatchlistMockClient implements WatchlistApi {
|
||||
private entries: WatchedIdentity[] = [
|
||||
{
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
tenantId: 'tenant-dev',
|
||||
displayName: 'GitHub Actions Watcher',
|
||||
description: 'Watch for unexpected GitHub Actions identities',
|
||||
issuer: 'https://token.actions.githubusercontent.com',
|
||||
subjectAlternativeName: 'repo:org/*',
|
||||
matchMode: 'Glob',
|
||||
scope: 'Tenant',
|
||||
severity: 'Critical',
|
||||
enabled: true,
|
||||
suppressDuplicatesMinutes: 60,
|
||||
tags: ['ci', 'github'],
|
||||
createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
createdBy: 'admin@example.com',
|
||||
updatedBy: 'admin@example.com',
|
||||
},
|
||||
{
|
||||
id: '22222222-2222-2222-2222-222222222222',
|
||||
tenantId: 'tenant-dev',
|
||||
displayName: 'Google Cloud IAM',
|
||||
description: 'Watch for Google Cloud service account identities',
|
||||
issuer: 'https://accounts.google.com',
|
||||
matchMode: 'Prefix',
|
||||
scope: 'Tenant',
|
||||
severity: 'Warning',
|
||||
enabled: true,
|
||||
suppressDuplicatesMinutes: 120,
|
||||
tags: ['cloud', 'gcp'],
|
||||
createdAt: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
createdBy: 'admin@example.com',
|
||||
updatedBy: 'admin@example.com',
|
||||
},
|
||||
{
|
||||
id: '33333333-3333-3333-3333-333333333333',
|
||||
tenantId: 'global',
|
||||
displayName: 'Internal PKI',
|
||||
description: 'Watch for internal PKI certificate usage',
|
||||
subjectAlternativeName: '*@internal.example.com',
|
||||
matchMode: 'Glob',
|
||||
scope: 'Global',
|
||||
severity: 'Info',
|
||||
enabled: true,
|
||||
suppressDuplicatesMinutes: 30,
|
||||
createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
createdBy: 'system',
|
||||
updatedBy: 'admin@example.com',
|
||||
},
|
||||
];
|
||||
|
||||
private alerts: IdentityAlert[] = [
|
||||
{
|
||||
alertId: 'alert-001',
|
||||
watchlistEntryId: '11111111-1111-1111-1111-111111111111',
|
||||
watchlistEntryName: 'GitHub Actions Watcher',
|
||||
severity: 'Critical',
|
||||
matchedIssuer: 'https://token.actions.githubusercontent.com',
|
||||
matchedSan: 'repo:org/app:ref:refs/heads/main',
|
||||
rekorUuid: 'abc123def456',
|
||||
rekorLogIndex: 12345678,
|
||||
occurredAt: new Date(Date.now() - 15 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
alertId: 'alert-002',
|
||||
watchlistEntryId: '22222222-2222-2222-2222-222222222222',
|
||||
watchlistEntryName: 'Google Cloud IAM',
|
||||
severity: 'Warning',
|
||||
matchedIssuer: 'https://accounts.google.com',
|
||||
matchedSan: 'service-account@project.iam.gserviceaccount.com',
|
||||
rekorUuid: 'xyz789abc012',
|
||||
rekorLogIndex: 12345679,
|
||||
occurredAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
listEntries(options?: WatchlistListQueryOptions): Observable<WatchlistListResponse> {
|
||||
let filtered = [...this.entries];
|
||||
|
||||
if (!options?.includeGlobal) {
|
||||
filtered = filtered.filter((e) => e.scope === 'Tenant');
|
||||
}
|
||||
|
||||
if (options?.enabledOnly) {
|
||||
filtered = filtered.filter((e) => e.enabled);
|
||||
}
|
||||
|
||||
if (options?.severity) {
|
||||
filtered = filtered.filter((e) => e.severity === options.severity);
|
||||
}
|
||||
|
||||
return of({
|
||||
items: filtered,
|
||||
totalCount: filtered.length,
|
||||
}).pipe(delay(200));
|
||||
}
|
||||
|
||||
getEntry(id: string): Observable<WatchedIdentity> {
|
||||
const entry = this.entries.find((e) => e.id === id);
|
||||
if (!entry) {
|
||||
throw new Error(`Watchlist entry not found: ${id}`);
|
||||
}
|
||||
return of(entry).pipe(delay(100));
|
||||
}
|
||||
|
||||
createEntry(request: WatchlistEntryRequest): Observable<WatchedIdentity> {
|
||||
const newEntry: WatchedIdentity = {
|
||||
id: crypto.randomUUID(),
|
||||
tenantId: 'tenant-dev',
|
||||
displayName: request.displayName,
|
||||
description: request.description,
|
||||
issuer: request.issuer,
|
||||
subjectAlternativeName: request.subjectAlternativeName,
|
||||
keyId: request.keyId,
|
||||
matchMode: request.matchMode ?? 'Exact',
|
||||
scope: request.scope ?? 'Tenant',
|
||||
severity: request.severity ?? 'Warning',
|
||||
enabled: request.enabled ?? true,
|
||||
channelOverrides: request.channelOverrides,
|
||||
suppressDuplicatesMinutes: request.suppressDuplicatesMinutes ?? 60,
|
||||
tags: request.tags,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
createdBy: 'ui@stella-ops.local',
|
||||
updatedBy: 'ui@stella-ops.local',
|
||||
};
|
||||
this.entries.push(newEntry);
|
||||
return of(newEntry).pipe(delay(300));
|
||||
}
|
||||
|
||||
updateEntry(id: string, request: Partial<WatchlistEntryRequest>): Observable<WatchedIdentity> {
|
||||
const index = this.entries.findIndex((e) => e.id === id);
|
||||
if (index === -1) {
|
||||
throw new Error(`Watchlist entry not found: ${id}`);
|
||||
}
|
||||
const entry = this.entries[index];
|
||||
const updated: WatchedIdentity = {
|
||||
...entry,
|
||||
...request,
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy: 'ui@stella-ops.local',
|
||||
};
|
||||
this.entries[index] = updated;
|
||||
return of(updated).pipe(delay(200));
|
||||
}
|
||||
|
||||
deleteEntry(id: string): Observable<void> {
|
||||
const index = this.entries.findIndex((e) => e.id === id);
|
||||
if (index === -1) {
|
||||
throw new Error(`Watchlist entry not found: ${id}`);
|
||||
}
|
||||
this.entries.splice(index, 1);
|
||||
return of(undefined).pipe(delay(200));
|
||||
}
|
||||
|
||||
testEntry(id: string, request: WatchlistTestRequest): Observable<WatchlistTestResponse> {
|
||||
const entry = this.entries.find((e) => e.id === id);
|
||||
if (!entry) {
|
||||
throw new Error(`Watchlist entry not found: ${id}`);
|
||||
}
|
||||
|
||||
// Simplified matching logic
|
||||
const matchedFields: ('Issuer' | 'SubjectAlternativeName' | 'KeyId')[] = [];
|
||||
let matchScore = 0;
|
||||
|
||||
if (request.issuer && entry.issuer) {
|
||||
if (this.testMatch(entry.issuer, request.issuer, entry.matchMode)) {
|
||||
matchedFields.push('Issuer');
|
||||
matchScore += entry.matchMode === 'Exact' ? 100 : 50;
|
||||
}
|
||||
}
|
||||
|
||||
if (request.subjectAlternativeName && entry.subjectAlternativeName) {
|
||||
if (this.testMatch(entry.subjectAlternativeName, request.subjectAlternativeName, entry.matchMode)) {
|
||||
matchedFields.push('SubjectAlternativeName');
|
||||
matchScore += entry.matchMode === 'Exact' ? 100 : 50;
|
||||
}
|
||||
}
|
||||
|
||||
if (request.keyId && entry.keyId) {
|
||||
if (this.testMatch(entry.keyId, request.keyId, entry.matchMode)) {
|
||||
matchedFields.push('KeyId');
|
||||
matchScore += entry.matchMode === 'Exact' ? 100 : 50;
|
||||
}
|
||||
}
|
||||
|
||||
return of({
|
||||
matches: matchedFields.length > 0,
|
||||
matchedFields,
|
||||
matchScore,
|
||||
entry,
|
||||
}).pipe(delay(150));
|
||||
}
|
||||
|
||||
listAlerts(options?: WatchlistAlertsQueryOptions): Observable<WatchlistAlertsResponse> {
|
||||
let filtered = [...this.alerts];
|
||||
|
||||
if (options?.severity) {
|
||||
filtered = filtered.filter((a) => a.severity === options.severity);
|
||||
}
|
||||
|
||||
const limit = options?.limit ?? 50;
|
||||
filtered = filtered.slice(0, limit);
|
||||
|
||||
return of({
|
||||
items: filtered,
|
||||
totalCount: filtered.length,
|
||||
}).pipe(delay(200));
|
||||
}
|
||||
|
||||
private testMatch(pattern: string, input: string, mode: string): boolean {
|
||||
switch (mode) {
|
||||
case 'Exact':
|
||||
return pattern.toLowerCase() === input.toLowerCase();
|
||||
case 'Prefix':
|
||||
return input.toLowerCase().startsWith(pattern.toLowerCase());
|
||||
case 'Glob':
|
||||
return this.testGlobMatch(pattern, input);
|
||||
case 'Regex':
|
||||
try {
|
||||
return new RegExp(pattern, 'i').test(input);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
default:
|
||||
return pattern.toLowerCase() === input.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
private testGlobMatch(pattern: string, input: string): boolean {
|
||||
const regexPattern =
|
||||
'^' +
|
||||
pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*/g, '.*')
|
||||
.replace(/\?/g, '.') +
|
||||
'$';
|
||||
return new RegExp(regexPattern, 'i').test(input);
|
||||
}
|
||||
}
|
||||
139
src/Web/StellaOps.Web/src/app/core/api/watchlist.models.ts
Normal file
139
src/Web/StellaOps.Web/src/app/core/api/watchlist.models.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// watchlist.models.ts
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-009
|
||||
// Description: TypeScript models for identity watchlist API.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Match modes for watchlist patterns.
|
||||
*/
|
||||
export type WatchlistMatchMode = 'Exact' | 'Prefix' | 'Glob' | 'Regex';
|
||||
|
||||
/**
|
||||
* Watchlist entry scopes.
|
||||
*/
|
||||
export type WatchlistScope = 'Tenant' | 'Global' | 'System';
|
||||
|
||||
/**
|
||||
* Alert severity levels.
|
||||
*/
|
||||
export type IdentityAlertSeverity = 'Info' | 'Warning' | 'Critical';
|
||||
|
||||
/**
|
||||
* Watched identity entry.
|
||||
*/
|
||||
export interface WatchedIdentity {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
issuer?: string;
|
||||
subjectAlternativeName?: string;
|
||||
keyId?: string;
|
||||
matchMode: WatchlistMatchMode;
|
||||
scope: WatchlistScope;
|
||||
severity: IdentityAlertSeverity;
|
||||
enabled: boolean;
|
||||
channelOverrides?: string[];
|
||||
suppressDuplicatesMinutes: number;
|
||||
tags?: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to create or update a watchlist entry.
|
||||
*/
|
||||
export interface WatchlistEntryRequest {
|
||||
displayName: string;
|
||||
description?: string;
|
||||
issuer?: string;
|
||||
subjectAlternativeName?: string;
|
||||
keyId?: string;
|
||||
matchMode?: WatchlistMatchMode;
|
||||
scope?: WatchlistScope;
|
||||
severity?: IdentityAlertSeverity;
|
||||
enabled?: boolean;
|
||||
channelOverrides?: string[];
|
||||
suppressDuplicatesMinutes?: number;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated list response.
|
||||
*/
|
||||
export interface WatchlistListResponse {
|
||||
items: WatchedIdentity[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to test a watchlist pattern.
|
||||
*/
|
||||
export interface WatchlistTestRequest {
|
||||
issuer?: string;
|
||||
subjectAlternativeName?: string;
|
||||
keyId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from testing a watchlist pattern.
|
||||
*/
|
||||
export interface WatchlistTestResponse {
|
||||
matches: boolean;
|
||||
matchedFields: MatchedField[];
|
||||
matchScore: number;
|
||||
entry: WatchedIdentity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matched field indicator.
|
||||
*/
|
||||
export type MatchedField = 'Issuer' | 'SubjectAlternativeName' | 'KeyId';
|
||||
|
||||
/**
|
||||
* Identity alert event.
|
||||
*/
|
||||
export interface IdentityAlert {
|
||||
alertId: string;
|
||||
watchlistEntryId: string;
|
||||
watchlistEntryName: string;
|
||||
severity: IdentityAlertSeverity;
|
||||
matchedIssuer?: string;
|
||||
matchedSan?: string;
|
||||
matchedKeyId?: string;
|
||||
rekorUuid?: string;
|
||||
rekorLogIndex?: number;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated alerts response.
|
||||
*/
|
||||
export interface WatchlistAlertsResponse {
|
||||
items: IdentityAlert[];
|
||||
totalCount: number;
|
||||
continuationToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query options for listing alerts.
|
||||
*/
|
||||
export interface WatchlistAlertsQueryOptions {
|
||||
limit?: number;
|
||||
since?: string;
|
||||
severity?: IdentityAlertSeverity;
|
||||
continuationToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query options for listing watchlist entries.
|
||||
*/
|
||||
export interface WatchlistListQueryOptions {
|
||||
includeGlobal?: boolean;
|
||||
enabledOnly?: boolean;
|
||||
severity?: IdentityAlertSeverity;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Attestation Service
|
||||
*
|
||||
* Service for fetching attestation data for copy/export operations.
|
||||
*
|
||||
* @sprint SPRINT_1227_0005_0003_FE_copy_audit_export
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
|
||||
/**
|
||||
* DSSE Envelope structure.
|
||||
*/
|
||||
export interface DsseEnvelope {
|
||||
payloadType: string;
|
||||
payload: string;
|
||||
signatures: Array<{
|
||||
keyid: string;
|
||||
sig: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attestation response with envelope and decoded payload.
|
||||
*/
|
||||
export interface AttestationResponse {
|
||||
envelope: DsseEnvelope;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attestation format options.
|
||||
*/
|
||||
export type AttestationFormat = 'dsse' | 'json';
|
||||
|
||||
/**
|
||||
* Service for retrieving attestation data.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttestationService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = '/api/v1/attestations';
|
||||
|
||||
/**
|
||||
* Gets an attestation by digest.
|
||||
* @param digest The attestation digest (e.g., sha256:abc123)
|
||||
* @param format The format to return (dsse envelope or decoded json)
|
||||
*/
|
||||
getAttestation(digest: string, format: AttestationFormat = 'dsse'): Observable<AttestationResponse> {
|
||||
return this.http.get<AttestationResponse>(`${this.baseUrl}/${encodeURIComponent(digest)}`, {
|
||||
params: { format }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Attestation Service for testing.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockAttestationService {
|
||||
getAttestation(digest: string, format: AttestationFormat = 'dsse'): Observable<AttestationResponse> {
|
||||
return of({
|
||||
envelope: {
|
||||
payloadType: 'application/vnd.in-toto+json',
|
||||
payload: 'eyJ0ZXN0IjogdHJ1ZX0=',
|
||||
signatures: [{ keyid: 'key-1', sig: 'sig-data' }]
|
||||
},
|
||||
payload: { test: true }
|
||||
}).pipe(delay(50));
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
* @task DASH-02
|
||||
*/
|
||||
|
||||
import { Injectable, inject, signal, computed } from '@angular/core';
|
||||
import { Injectable, inject, signal, computed, InjectionToken } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of, delay, finalize } from 'rxjs';
|
||||
|
||||
@@ -45,6 +45,11 @@ export interface DeltaVerdictApi {
|
||||
getLatestVerdicts(options: VerdictQueryOptions & { limit?: number }): Observable<DeltaVerdict[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* InjectionToken for DeltaVerdictApi.
|
||||
*/
|
||||
export const DELTA_VERDICT_API = new InjectionToken<DeltaVerdictApi>('DELTA_VERDICT_API');
|
||||
|
||||
/**
|
||||
* Mock Delta Verdict API for development.
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @task DASH-01
|
||||
*/
|
||||
|
||||
import { Injectable, inject, signal, computed } from '@angular/core';
|
||||
import { Injectable, inject, signal, computed, InjectionToken } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of, delay, finalize } from 'rxjs';
|
||||
|
||||
@@ -43,6 +43,11 @@ export interface RiskBudgetApi {
|
||||
getTimeSeries(options: BudgetQueryOptions): Observable<BudgetTimePoint[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* InjectionToken for RiskBudgetApi.
|
||||
*/
|
||||
export const RISK_BUDGET_API = new InjectionToken<RiskBudgetApi>('RISK_BUDGET_API');
|
||||
|
||||
/**
|
||||
* Mock Risk Budget API for development.
|
||||
*/
|
||||
|
||||
@@ -534,14 +534,14 @@ export class AdminNotificationsComponent implements OnInit {
|
||||
firstValueFrom(this.api.listEscalationPolicies()),
|
||||
]);
|
||||
|
||||
this.channels.set(channels);
|
||||
this.rules.set(rules);
|
||||
this.deliveries.set(deliveriesResp.items ?? []);
|
||||
this.incidents.set(incidentsResp.items ?? []);
|
||||
this.digestSchedules.set(digests.items ?? []);
|
||||
this.quietHours.set(quiet.items ?? []);
|
||||
this.throttleConfigs.set(throttle.items ?? []);
|
||||
this.escalationPolicies.set(escalation.items ?? []);
|
||||
this.channels.set([...channels]);
|
||||
this.rules.set([...rules]);
|
||||
this.deliveries.set([...(deliveriesResp.items ?? [])]);
|
||||
this.incidents.set([...(incidentsResp.items ?? [])]);
|
||||
this.digestSchedules.set([...(digests.items ?? [])]);
|
||||
this.quietHours.set([...(quiet.items ?? [])]);
|
||||
this.throttleConfigs.set([...(throttle.items ?? [])]);
|
||||
this.escalationPolicies.set([...(escalation.items ?? [])]);
|
||||
} catch (err) {
|
||||
this.error.set(err instanceof Error ? err.message : 'Failed to load data');
|
||||
} finally {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Degraded Mode Banner Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Envelope Hashes Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Export Actions Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Graph Mini Map Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* VEX Merge Explanation Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Witness Path Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.console-profile {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.console-status {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.cvss-receipt {
|
||||
display: grid;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.sources-dashboard {
|
||||
padding: var(--space-6);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.doctor-dashboard {
|
||||
padding: var(--space-6);
|
||||
|
||||
@@ -526,7 +526,7 @@ export class EvidenceRibbonComponent implements OnInit, OnDestroy {
|
||||
|
||||
getVexAriaLabel(vex: VexEvidenceStatus): string {
|
||||
const count = vex.statementCount;
|
||||
const conflicts = vex.conflictCount > 0 ? `, ${vex.conflictCount} conflicts` : '';
|
||||
const conflicts = (vex.conflictCount ?? 0) > 0 ? `, ${vex.conflictCount} conflicts` : '';
|
||||
return `${count} VEX statement${count !== 1 ? 's' : ''}${conflicts}`;
|
||||
}
|
||||
|
||||
@@ -577,7 +577,7 @@ export class EvidenceRibbonComponent implements OnInit, OnDestroy {
|
||||
const lines: string[] = [];
|
||||
lines.push(`${vex.statementCount} VEX statement(s)`);
|
||||
if (vex.notAffectedCount) lines.push(`Not affected: ${vex.notAffectedCount}`);
|
||||
if (vex.conflictCount > 0) lines.push(`Conflicts: ${vex.conflictCount}`);
|
||||
if ((vex.conflictCount ?? 0) > 0) lines.push(`Conflicts: ${vex.conflictCount}`);
|
||||
if (vex.confidence) lines.push(`Confidence: ${vex.confidence}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Evidence Export Dialog Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Evidence Graph Panel Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Evidence Node Card Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Evidence Thread List Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Evidence Thread View Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Evidence Timeline Panel Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Evidence Transcript Panel Component Styles
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Evidence Panel Styles
|
||||
// Based on BEM naming convention
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.evidence-panel {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.approval-queue {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.exception-center {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.exception-dashboard {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.detail-container {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.draft-inline {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.exception-wizard {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.feed-mirror-page {
|
||||
display: grid;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.bulk-triage-view {
|
||||
font-family: var(--font-family-base);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import {
|
||||
ScoreBucket,
|
||||
BUCKET_DISPLAY,
|
||||
BucketDisplayConfig,
|
||||
BucketDisplayInfo,
|
||||
} from '../../core/api/scoring.models';
|
||||
import { ScoredFinding } from './findings-list.component';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.findings-list {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.graph-explorer {
|
||||
display: flex;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Home Dashboard Styles
|
||||
// Security-focused landing page with aggregated metrics
|
||||
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.dashboard {
|
||||
max-width: var(--container-xl);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Audit Pack Export Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Export Options Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Diff Table Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Explainer Step Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Explainer Timeline Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Node Diff Table Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Pinned Item Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Pinned Panel Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Call Path Mini Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Confidence Bar Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Reachability Diff View Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { VerdictProofPanelComponent } from './verdict-proof-panel.component';
|
||||
import { VERDICT_API, VerdictApi } from '../../../core/api/verdict.client';
|
||||
import { VERDICT_API, VerdictApi } from '../../../../core/api/verdict.client';
|
||||
import {
|
||||
VerdictAttestation,
|
||||
VerifyVerdictResponse,
|
||||
VerificationStatus,
|
||||
} from '../../../core/api/verdict.models';
|
||||
} from '../../../../core/api/verdict.models';
|
||||
import { of, throwError } from 'rxjs';
|
||||
|
||||
describe('VerdictProofPanelComponent', () => {
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
|
||||
import { Component, computed, effect, inject, input, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { VERDICT_API, VerdictApi } from '../../../core/api/verdict.client';
|
||||
import { VERDICT_API, VerdictApi } from '../../../../core/api/verdict.client';
|
||||
import {
|
||||
VerdictAttestation,
|
||||
VerdictStatus,
|
||||
VerdictSeverity,
|
||||
VerifyVerdictResponse,
|
||||
Evidence,
|
||||
} from '../../../core/api/verdict.models';
|
||||
} from '../../../../core/api/verdict.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-verdict-proof-panel',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Proof Detail Panel Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.proof-chain-container {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Confidence Breakdown Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Proof Studio Container Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* What-If Slider Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Proof Ledger View Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Reachability Explain Widget Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.release-flow {
|
||||
display: grid;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.risk-dashboard {
|
||||
display: grid;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.attestation-panel {
|
||||
border: 1px solid var(--color-border-secondary);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.scan-detail {
|
||||
display: grid;
|
||||
|
||||
@@ -193,3 +193,46 @@ export interface UpdateSettingsRequest {
|
||||
enabledRuleCategories?: string[];
|
||||
alertSettings?: Partial<SecretAlertSettings>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default rule categories for secret detection.
|
||||
*/
|
||||
export const RULE_CATEGORIES: string[] = [
|
||||
'cloud_credentials',
|
||||
'api_keys',
|
||||
'private_keys',
|
||||
'database_credentials',
|
||||
'jwt_tokens',
|
||||
'oauth_tokens',
|
||||
'generic_secrets',
|
||||
'passwords',
|
||||
];
|
||||
|
||||
/**
|
||||
* Default secret detection settings.
|
||||
*/
|
||||
export const DEFAULT_SECRET_DETECTION_SETTINGS: SecretDetectionSettings = {
|
||||
tenantId: '',
|
||||
enabled: false,
|
||||
revelationPolicy: {
|
||||
defaultPolicy: 'FullMask',
|
||||
exportPolicy: 'FullMask',
|
||||
logPolicy: 'FullMask',
|
||||
fullRevealRoles: ['admin', 'security-admin'],
|
||||
partialRevealChars: 4,
|
||||
},
|
||||
enabledRuleCategories: ['cloud_credentials', 'api_keys', 'private_keys'],
|
||||
exceptions: [],
|
||||
alertSettings: {
|
||||
enabled: false,
|
||||
minimumAlertSeverity: 'High',
|
||||
destinations: [],
|
||||
maxAlertsPerScan: 100,
|
||||
deduplicationWindowHours: 24,
|
||||
includeFilePath: true,
|
||||
includeMaskedValue: true,
|
||||
includeImageRef: true,
|
||||
},
|
||||
updatedAt: '',
|
||||
updatedBy: '',
|
||||
};
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
* @task SDU-001 - Create secret-detection feature module
|
||||
*/
|
||||
|
||||
import { Injectable, inject, signal, computed } from '@angular/core';
|
||||
import { Injectable, inject, signal, computed, InjectionToken } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { catchError, map, tap } from 'rxjs/operators';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
import {
|
||||
SecretDetectionSettings,
|
||||
SecretExceptionPattern,
|
||||
@@ -16,8 +16,50 @@ import {
|
||||
CreateExceptionRequest,
|
||||
UpdateSettingsRequest,
|
||||
SecretFinding,
|
||||
DEFAULT_SECRET_DETECTION_SETTINGS,
|
||||
} from '../models';
|
||||
|
||||
/**
|
||||
* API interface for secret detection settings.
|
||||
*/
|
||||
export interface SecretDetectionSettingsApi {
|
||||
loadSettings(tenantId: string): Observable<SecretDetectionSettings>;
|
||||
createSettings(tenantId: string): Observable<SecretDetectionSettings>;
|
||||
updateSettings(tenantId: string, request: UpdateSettingsRequest): Observable<SecretDetectionSettings>;
|
||||
loadCategories(): Observable<SecretRuleCategory[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* InjectionToken for SecretDetectionSettingsApi.
|
||||
*/
|
||||
export const SECRET_DETECTION_SETTINGS_API = new InjectionToken<SecretDetectionSettingsApi>('SECRET_DETECTION_SETTINGS_API');
|
||||
|
||||
/**
|
||||
* Mock implementation of SecretDetectionSettingsApi for testing.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockSecretDetectionSettingsApi implements SecretDetectionSettingsApi {
|
||||
loadSettings(tenantId: string): Observable<SecretDetectionSettings> {
|
||||
return of({ ...DEFAULT_SECRET_DETECTION_SETTINGS, tenantId }).pipe(delay(50));
|
||||
}
|
||||
|
||||
createSettings(tenantId: string): Observable<SecretDetectionSettings> {
|
||||
return of({ ...DEFAULT_SECRET_DETECTION_SETTINGS, tenantId }).pipe(delay(50));
|
||||
}
|
||||
|
||||
updateSettings(tenantId: string, request: UpdateSettingsRequest): Observable<SecretDetectionSettings> {
|
||||
return of({ ...DEFAULT_SECRET_DETECTION_SETTINGS, tenantId, ...request }).pipe(delay(50));
|
||||
}
|
||||
|
||||
loadCategories(): Observable<SecretRuleCategory[]> {
|
||||
return of([
|
||||
{ id: 'cloud_credentials', name: 'Cloud Credentials', description: 'AWS, GCP, Azure credentials', ruleCount: 15, enabled: true },
|
||||
{ id: 'api_keys', name: 'API Keys', description: 'Generic API keys', ruleCount: 25, enabled: true },
|
||||
{ id: 'private_keys', name: 'Private Keys', description: 'RSA, SSH, PGP keys', ruleCount: 10, enabled: true },
|
||||
]).pipe(delay(50));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API service for secret detection configuration.
|
||||
* Communicates with Scanner WebService endpoints.
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
* @task SDU-005 - Create findings list component
|
||||
*/
|
||||
|
||||
import { Injectable, inject, signal, computed } from '@angular/core';
|
||||
import { Injectable, inject, signal, computed, InjectionToken } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { catchError, tap, map } from 'rxjs/operators';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
import {
|
||||
SecretFinding,
|
||||
SecretSeverity,
|
||||
@@ -39,6 +39,78 @@ export interface FindingsResponse {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* API interface for secret findings.
|
||||
*/
|
||||
export interface SecretFindingsApi {
|
||||
listFindings(tenantId: string, query?: FindingsQuery): Observable<FindingsResponse>;
|
||||
getFinding(tenantId: string, findingId: string): Observable<SecretFinding>;
|
||||
updateStatus(tenantId: string, findingId: string, status: SecretFindingStatus): Observable<SecretFinding>;
|
||||
}
|
||||
|
||||
/**
|
||||
* InjectionToken for SecretFindingsApi.
|
||||
*/
|
||||
export const SECRET_FINDINGS_API = new InjectionToken<SecretFindingsApi>('SECRET_FINDINGS_API');
|
||||
|
||||
/**
|
||||
* Mock implementation of SecretFindingsApi for testing.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockSecretFindingsApi implements SecretFindingsApi {
|
||||
private mockFindings: SecretFinding[] = [
|
||||
{
|
||||
id: 'finding-1',
|
||||
scanId: 'scan-1',
|
||||
imageRef: 'docker.io/acme/app:1.0',
|
||||
severity: 'Critical',
|
||||
ruleId: 'aws-access-key',
|
||||
ruleName: 'AWS Access Key',
|
||||
ruleCategory: 'cloud_credentials',
|
||||
filePath: '/app/config/.env',
|
||||
lineNumber: 15,
|
||||
maskedValue: 'AKIA*************',
|
||||
detectedAt: new Date().toISOString(),
|
||||
status: 'New',
|
||||
excepted: false,
|
||||
},
|
||||
{
|
||||
id: 'finding-2',
|
||||
scanId: 'scan-1',
|
||||
imageRef: 'docker.io/acme/app:1.0',
|
||||
severity: 'High',
|
||||
ruleId: 'private-key',
|
||||
ruleName: 'Private Key',
|
||||
ruleCategory: 'private_keys',
|
||||
filePath: '/app/keys/id_rsa',
|
||||
lineNumber: 1,
|
||||
maskedValue: '-----BEGIN RSA PRIVATE KEY-----',
|
||||
detectedAt: new Date().toISOString(),
|
||||
status: 'New',
|
||||
excepted: false,
|
||||
},
|
||||
];
|
||||
|
||||
listFindings(tenantId: string, query?: FindingsQuery): Observable<FindingsResponse> {
|
||||
return of({
|
||||
items: this.mockFindings,
|
||||
total: this.mockFindings.length,
|
||||
page: query?.page ?? 1,
|
||||
pageSize: query?.pageSize ?? 25,
|
||||
}).pipe(delay(50));
|
||||
}
|
||||
|
||||
getFinding(tenantId: string, findingId: string): Observable<SecretFinding> {
|
||||
const finding = this.mockFindings.find(f => f.id === findingId);
|
||||
return of(finding!).pipe(delay(50));
|
||||
}
|
||||
|
||||
updateStatus(tenantId: string, findingId: string, status: SecretFindingStatus): Observable<SecretFinding> {
|
||||
const finding = this.mockFindings.find(f => f.id === findingId);
|
||||
return of({ ...finding!, status }).pipe(delay(50));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API service for secret findings.
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.aoc-dashboard {
|
||||
display: grid;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Causal Lanes Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Critical Path Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Event Detail Panel Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Evidence Links Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Export Button Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Timeline Filter Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
/**
|
||||
* Timeline Page Component Styles
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.case-header {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_3600_0002_0001
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../../../../styles/tokens/breakpoints' as *;
|
||||
@use 'tokens/breakpoints' as *;
|
||||
|
||||
.verdict-ladder {
|
||||
position: relative;
|
||||
|
||||
@@ -119,7 +119,7 @@ describe('VexStatementSearchComponent', () => {
|
||||
expect(component.currentPage()).toBe(1);
|
||||
});
|
||||
|
||||
it('should use status from query params if present', fakeAsync(() => {
|
||||
it('should use status from query params if present', async () => {
|
||||
TestBed.resetTestingModule();
|
||||
|
||||
mockVexHubApi = jasmine.createSpyObj<VexHubApi>('VexHubApi', [
|
||||
@@ -149,10 +149,10 @@ describe('VexStatementSearchComponent', () => {
|
||||
const testFixture = TestBed.createComponent(VexStatementSearchComponent);
|
||||
const testComponent = testFixture.componentInstance;
|
||||
testFixture.detectChanges();
|
||||
tick();
|
||||
await testFixture.whenStable();
|
||||
|
||||
expect(testComponent.statusFilter).toBe('affected');
|
||||
}));
|
||||
});
|
||||
|
||||
it('should use initialStatus input if no query param', fakeAsync(() => {
|
||||
fixture.componentRef.setInput('initialStatus', 'fixed');
|
||||
|
||||
@@ -912,7 +912,7 @@ export class VexMergePanelComponent {
|
||||
@Output() viewProvenance = new EventEmitter<VexMergeRow>();
|
||||
@Output() viewRawVex = new EventEmitter<VexMergeRow>();
|
||||
|
||||
readonly conflict = computed(() => this._conflict());
|
||||
readonly conflictData = computed(() => this._conflict());
|
||||
readonly rows = computed(() => this._conflict()?.rows ?? []);
|
||||
readonly expandedProvenanceId = computed(() => this._expandedProvenanceId());
|
||||
readonly hoveredProvenanceId = computed(() => this._hoveredProvenanceId());
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
VexSource,
|
||||
VexStatus,
|
||||
VexConfidence,
|
||||
VexJustification,
|
||||
VexTimelineFilter,
|
||||
VexSourceType,
|
||||
groupObservationsBySource,
|
||||
@@ -227,7 +228,7 @@ export class VexTimelineService {
|
||||
advisoryId: o.advisoryId,
|
||||
product: o.product,
|
||||
status: o.status as VexStatus,
|
||||
justification: o.justification,
|
||||
justification: o.justification as VexJustification | undefined,
|
||||
notes: o.notes,
|
||||
confidence: (o.confidence ?? 'medium') as VexConfidence,
|
||||
affectedRange: o.affectedRange,
|
||||
|
||||
@@ -9,10 +9,10 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/
|
||||
import { signal } from '@angular/core';
|
||||
import { By } from '@angular/platform-browser';
|
||||
|
||||
import { EvidenceSubgraphComponent } from './evidence-subgraph.component';
|
||||
import { EvidenceTreeComponent } from './evidence-tree.component';
|
||||
import { CitationLinkComponent, CitationListComponent } from './citation-link.component';
|
||||
import { VerdictExplanationComponent } from './verdict-explanation.component';
|
||||
import { EvidenceSubgraphComponent } from './evidence-subgraph/evidence-subgraph.component';
|
||||
import { EvidenceTreeComponent } from './evidence-tree/evidence-tree.component';
|
||||
import { CitationLinkComponent, CitationListComponent } from './citation-link/citation-link.component';
|
||||
import { VerdictExplanationComponent } from './verdict-explanation/verdict-explanation.component';
|
||||
import { TriageCardComponent, TriageCardGridComponent } from './triage-card/triage-card.component';
|
||||
import { TriageFiltersComponent } from './triage-filters/triage-filters.component';
|
||||
import { EvidenceSubgraphService } from '../services/evidence-subgraph.service';
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { VulnTriageDashboardComponent } from './vuln-triage-dashboard.component';
|
||||
import { VULN_ANNOTATION_API, VulnAnnotationApi } from '../../../core/api/vuln-annotation.client';
|
||||
import { VULN_ANNOTATION_API, VulnAnnotationApi } from '../../../../core/api/vuln-annotation.client';
|
||||
import {
|
||||
VulnFinding,
|
||||
VexCandidate,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
StateTransitionResponse,
|
||||
VexCandidateApprovalResponse,
|
||||
VexCandidateRejectionResponse,
|
||||
} from '../../../core/api/vuln-annotation.models';
|
||||
} from '../../../../core/api/vuln-annotation.models';
|
||||
import { of, throwError } from 'rxjs';
|
||||
|
||||
describe('VulnTriageDashboardComponent', () => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { Component, computed, inject, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { VULN_ANNOTATION_API, VulnAnnotationApi } from '../../../core/api/vuln-annotation.client';
|
||||
import { VULN_ANNOTATION_API, VulnAnnotationApi } from '../../../../core/api/vuln-annotation.client';
|
||||
import {
|
||||
VulnFinding,
|
||||
VulnState,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
StateTransitionRequest,
|
||||
VexCandidateApprovalRequest,
|
||||
VexCandidateRejectionRequest,
|
||||
} from '../../../core/api/vuln-annotation.models';
|
||||
} from '../../../../core/api/vuln-annotation.models';
|
||||
|
||||
type TabView = 'findings' | 'candidates';
|
||||
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
<div class="watchlist-page" data-testid="watchlist-page">
|
||||
<header class="page-header">
|
||||
<h1>Identity Watchlist</h1>
|
||||
<p class="subtitle">Monitor signing identities in transparency logs</p>
|
||||
</header>
|
||||
|
||||
<!-- Message Banner -->
|
||||
@if (message()) {
|
||||
<div class="message-banner" [class.success]="messageType() === 'success'" [class.error]="messageType() === 'error'">
|
||||
{{ message() }}
|
||||
<button class="dismiss" (click)="message.set(null)">×</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
<nav class="tabs">
|
||||
<button
|
||||
[class.active]="viewMode() === 'list'"
|
||||
(click)="showList()">
|
||||
Watchlist Entries
|
||||
</button>
|
||||
<button
|
||||
[class.active]="viewMode() === 'alerts'"
|
||||
(click)="showAlerts()">
|
||||
Recent Alerts
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- List View -->
|
||||
@if (viewMode() === 'list') {
|
||||
<section class="list-section">
|
||||
<div class="toolbar">
|
||||
<button class="btn-primary" data-testid="create-entry-btn" (click)="createNew()">
|
||||
+ New Entry
|
||||
</button>
|
||||
|
||||
<div class="filters">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="includeGlobal()"
|
||||
(change)="includeGlobal.set(!includeGlobal()); onFilterChange()">
|
||||
Include Global
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="enabledOnly()"
|
||||
(change)="enabledOnly.set(!enabledOnly()); onFilterChange()">
|
||||
Enabled Only
|
||||
</label>
|
||||
<select
|
||||
[value]="severityFilter()"
|
||||
(change)="severityFilter.set($any($event.target).value); onFilterChange()">
|
||||
<option value="">All Severities</option>
|
||||
<option value="Info">Info</option>
|
||||
<option value="Warning">Warning</option>
|
||||
<option value="Critical">Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading">Loading...</div>
|
||||
} @else if (filteredEntries().length === 0) {
|
||||
<div class="empty-state">
|
||||
<p>No watchlist entries found.</p>
|
||||
<button class="btn-primary" (click)="createNew()">Create your first entry</button>
|
||||
</div>
|
||||
} @else {
|
||||
<table class="entries-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Pattern</th>
|
||||
<th>Match Mode</th>
|
||||
<th>Scope</th>
|
||||
<th>Severity</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (entry of filteredEntries(); track trackByEntry) {
|
||||
<tr data-testid="entry-row">
|
||||
<td class="name-cell">
|
||||
<strong>{{ entry.displayName }}</strong>
|
||||
@if (entry.description) {
|
||||
<span class="description">{{ entry.description }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="pattern-cell">
|
||||
@if (entry.issuer) {
|
||||
<div class="pattern"><span class="label">Issuer:</span> {{ entry.issuer }}</div>
|
||||
}
|
||||
@if (entry.subjectAlternativeName) {
|
||||
<div class="pattern"><span class="label">SAN:</span> {{ entry.subjectAlternativeName }}</div>
|
||||
}
|
||||
@if (entry.keyId) {
|
||||
<div class="pattern"><span class="label">KeyId:</span> {{ entry.keyId }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-mode">{{ entry.matchMode }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-scope">{{ entry.scope }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" [class]="getSeverityClass(entry.severity)">
|
||||
{{ entry.severity }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
[class.enabled]="entry.enabled"
|
||||
(click)="toggleEnabled(entry)">
|
||||
{{ entry.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<button class="btn-icon" data-testid="edit-entry-btn" title="Edit" (click)="editEntry(entry)">Edit</button>
|
||||
<button class="btn-icon btn-danger" data-testid="delete-entry-btn" title="Delete" (click)="deleteEntry(entry)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-footer">
|
||||
{{ entryCount() }} entries
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Edit View -->
|
||||
@if (viewMode() === 'edit') {
|
||||
<section class="edit-section">
|
||||
<h2>{{ selectedEntry() ? 'Edit Entry' : 'New Entry' }}</h2>
|
||||
|
||||
<form [formGroup]="entryForm" (ngSubmit)="saveEntry()" data-testid="entry-form">
|
||||
<div class="form-group">
|
||||
<label for="displayName">Display Name *</label>
|
||||
<input id="displayName" formControlName="displayName" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" formControlName="description" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<fieldset class="identity-fields">
|
||||
<legend>Identity Patterns (at least one required)</legend>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="issuer">OIDC Issuer</label>
|
||||
<input id="issuer" formControlName="issuer" placeholder="https://token.actions.githubusercontent.com">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="san">Subject Alternative Name</label>
|
||||
<input id="san" formControlName="subjectAlternativeName" placeholder="*@example.com">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="keyId">Key ID</label>
|
||||
<input id="keyId" formControlName="keyId" placeholder="key-abc-123">
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="matchMode">Match Mode</label>
|
||||
<select id="matchMode" formControlName="matchMode">
|
||||
@for (mode of matchModes; track mode) {
|
||||
<option [value]="mode">{{ getMatchModeLabel(mode) }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="scope">Scope</label>
|
||||
<select id="scope" formControlName="scope">
|
||||
@for (scope of scopes; track scope) {
|
||||
<option [value]="scope">{{ scope }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="severity">Alert Severity</label>
|
||||
<select id="severity" formControlName="severity">
|
||||
@for (sev of severities; track sev) {
|
||||
<option [value]="sev">{{ sev }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="suppressDuplicates">Suppress Duplicates (minutes)</label>
|
||||
<input id="suppressDuplicates" type="number" formControlName="suppressDuplicatesMinutes" min="0">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" formControlName="enabled">
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="tags">Tags (comma-separated)</label>
|
||||
<input id="tags" formControlName="tagsText" placeholder="ci, production, critical">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="channels">Channel Overrides (one per line)</label>
|
||||
<textarea id="channels" formControlName="channelOverridesText" rows="2" placeholder="slack:security-alerts"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-secondary" (click)="showList()">Cancel</button>
|
||||
<button type="submit" class="btn-primary" [disabled]="loading()">
|
||||
{{ selectedEntry() ? 'Update' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Test Pattern Section (only for existing entries) -->
|
||||
@if (selectedEntry()) {
|
||||
<div class="test-section">
|
||||
<h3>Test Pattern</h3>
|
||||
<form [formGroup]="testForm" (ngSubmit)="testPattern()">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="testIssuer">Test Issuer</label>
|
||||
<input id="testIssuer" formControlName="issuer">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="testSan">Test SAN</label>
|
||||
<input id="testSan" formControlName="subjectAlternativeName">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="testKeyId">Test Key ID</label>
|
||||
<input id="testKeyId" formControlName="keyId">
|
||||
</div>
|
||||
<button type="submit" class="btn-secondary" [disabled]="loading()">Test</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if (testResult()) {
|
||||
<div class="test-result" data-testid="test-result" [class.match]="testResult()!.matches" [class.no-match]="!testResult()!.matches">
|
||||
@if (testResult()!.matches) {
|
||||
<strong>Match!</strong>
|
||||
Score: {{ testResult()!.matchScore }} |
|
||||
Fields: {{ testResult()!.matchedFields.join(', ') }}
|
||||
} @else {
|
||||
<strong>No match</strong>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Alerts View -->
|
||||
@if (viewMode() === 'alerts') {
|
||||
<section class="alerts-section">
|
||||
<div class="toolbar">
|
||||
<button class="btn-secondary" (click)="loadAlerts()" [disabled]="loading()">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading">Loading alerts...</div>
|
||||
} @else if (alerts().length === 0) {
|
||||
<div class="empty-state">
|
||||
<p>No recent alerts.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<table class="alerts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Entry</th>
|
||||
<th>Severity</th>
|
||||
<th>Matched Identity</th>
|
||||
<th>Rekor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (alert of alerts(); track trackByAlert) {
|
||||
<tr data-testid="alert-row">
|
||||
<td>{{ formatDate(alert.occurredAt) }}</td>
|
||||
<td>{{ alert.watchlistEntryName }}</td>
|
||||
<td>
|
||||
<span class="badge" [class]="getSeverityClass(alert.severity)">
|
||||
{{ alert.severity }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="identity-cell">
|
||||
@if (alert.matchedIssuer) {
|
||||
<div><span class="label">Issuer:</span> {{ alert.matchedIssuer }}</div>
|
||||
}
|
||||
@if (alert.matchedSan) {
|
||||
<div><span class="label">SAN:</span> {{ alert.matchedSan }}</div>
|
||||
}
|
||||
@if (alert.matchedKeyId) {
|
||||
<div><span class="label">KeyId:</span> {{ alert.matchedKeyId }}</div>
|
||||
}
|
||||
</td>
|
||||
<td class="rekor-cell">
|
||||
@if (alert.rekorLogIndex) {
|
||||
<div>Index: {{ alert.rekorLogIndex }}</div>
|
||||
}
|
||||
@if (alert.rekorUuid) {
|
||||
<div class="uuid">{{ alert.rekorUuid }}</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,453 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// watchlist-page.component.scss
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-009
|
||||
// Description: Styles for identity watchlist management page.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
.watchlist-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.message-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 4px;
|
||||
|
||||
&.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
padding-bottom: 0;
|
||||
|
||||
button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
color: #666;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #0066cc;
|
||||
border-bottom-color: #0066cc;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-left: auto;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #0066cc;
|
||||
color: white;
|
||||
border: 1px solid #0066cc;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #0052a3;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: #333;
|
||||
border: 1px solid #ced4da;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: none;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
&.btn-danger:hover {
|
||||
background: #f8d7da;
|
||||
border-color: #f5c6cb;
|
||||
color: #721c24;
|
||||
}
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #666;
|
||||
|
||||
p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Tables
|
||||
.entries-table,
|
||||
.alerts-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.pattern-cell {
|
||||
.pattern {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
white-space: nowrap;
|
||||
|
||||
button {
|
||||
margin-right: 0.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
|
||||
&.badge-mode {
|
||||
background: #e9ecef;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
&.badge-scope {
|
||||
background: #e7f1ff;
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
&.severity-critical {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
&.severity-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
&.severity-info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid;
|
||||
|
||||
&.enabled {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border-color: #c3e6cb;
|
||||
}
|
||||
|
||||
&:not(.enabled) {
|
||||
background: #e9ecef;
|
||||
color: #495057;
|
||||
border-color: #ced4da;
|
||||
}
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
padding: 0.75rem 1rem;
|
||||
background: #f8f9fa;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
|
||||
// Edit Form
|
||||
.edit-section {
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
|
||||
h2 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.375rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #0066cc;
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.identity-fields {
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 4px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
legend {
|
||||
padding: 0 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
// Test Section
|
||||
.test-section {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e9ecef;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.test-result {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
margin-top: 1rem;
|
||||
|
||||
&.match {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
&.no-match {
|
||||
background: #f8f9fa;
|
||||
color: #495057;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
}
|
||||
|
||||
// Alerts
|
||||
.alerts-section {
|
||||
.identity-cell,
|
||||
.rekor-cell {
|
||||
font-size: 0.85rem;
|
||||
|
||||
.label {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.uuid {
|
||||
font-family: monospace;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// watchlist-page.component.spec.ts
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-009
|
||||
// Description: Unit tests for identity watchlist management page component.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { WATCHLIST_API } from '../../core/api/watchlist.client';
|
||||
import { WatchlistMockClient } from '../../core/api/watchlist.client';
|
||||
import { WatchlistPageComponent } from './watchlist-page.component';
|
||||
|
||||
describe('WatchlistPageComponent', () => {
|
||||
let fixture: ComponentFixture<WatchlistPageComponent>;
|
||||
let component: WatchlistPageComponent;
|
||||
let mockClient: WatchlistMockClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockClient = new WatchlistMockClient();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WatchlistPageComponent],
|
||||
providers: [
|
||||
{ provide: WATCHLIST_API, useValue: mockClient },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(WatchlistPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create the component', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders entries from the mocked API', async () => {
|
||||
await component.loadEntries();
|
||||
fixture.detectChanges();
|
||||
|
||||
const rows: NodeListOf<HTMLTableRowElement> =
|
||||
fixture.nativeElement.querySelectorAll('[data-testid="entry-row"]');
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('displays the create entry button', () => {
|
||||
const btn = fixture.nativeElement.querySelector('[data-testid="create-entry-btn"]');
|
||||
expect(btn).toBeTruthy();
|
||||
expect(btn.textContent).toContain('New Entry');
|
||||
});
|
||||
|
||||
it('switches to edit view when create button is clicked', () => {
|
||||
component.createNew();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.viewMode()).toBe('edit');
|
||||
expect(component.selectedEntry()).toBeNull();
|
||||
});
|
||||
|
||||
it('populates form when editing an entry', async () => {
|
||||
await component.loadEntries();
|
||||
fixture.detectChanges();
|
||||
|
||||
const entries = component.entries();
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
|
||||
const firstEntry = entries[0];
|
||||
component.editEntry(firstEntry);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.viewMode()).toBe('edit');
|
||||
expect(component.selectedEntry()).toBe(firstEntry);
|
||||
expect(component.entryForm.value.displayName).toBe(firstEntry.displayName);
|
||||
});
|
||||
|
||||
it('validates at least one identity field is required', async () => {
|
||||
component.createNew();
|
||||
fixture.detectChanges();
|
||||
|
||||
component.entryForm.patchValue({
|
||||
displayName: 'Test Entry',
|
||||
issuer: '',
|
||||
subjectAlternativeName: '',
|
||||
keyId: '',
|
||||
});
|
||||
|
||||
await component.saveEntry();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.messageType()).toBe('error');
|
||||
expect(component.message()).toContain('identity field');
|
||||
});
|
||||
|
||||
it('creates a new entry with valid data', async () => {
|
||||
component.createNew();
|
||||
fixture.detectChanges();
|
||||
|
||||
const initialCount = component.entries().length;
|
||||
|
||||
component.entryForm.patchValue({
|
||||
displayName: 'New Test Entry',
|
||||
issuer: 'https://test.example.com',
|
||||
matchMode: 'Exact',
|
||||
scope: 'Tenant',
|
||||
severity: 'Warning',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
await component.saveEntry();
|
||||
fixture.detectChanges();
|
||||
|
||||
await component.loadEntries();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.entries().length).toBeGreaterThan(initialCount);
|
||||
expect(component.messageType()).toBe('success');
|
||||
});
|
||||
|
||||
it('switches to alerts view when alerts tab is clicked', () => {
|
||||
component.showAlerts();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.viewMode()).toBe('alerts');
|
||||
});
|
||||
|
||||
it('loads alerts when alerts view is shown', async () => {
|
||||
component.showAlerts();
|
||||
await component.loadAlerts();
|
||||
fixture.detectChanges();
|
||||
|
||||
const rows: NodeListOf<HTMLTableRowElement> =
|
||||
fixture.nativeElement.querySelectorAll('[data-testid="alert-row"]');
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('filters entries by severity', async () => {
|
||||
await component.loadEntries();
|
||||
fixture.detectChanges();
|
||||
|
||||
const totalCount = component.filteredEntries().length;
|
||||
|
||||
component.severityFilter.set('Critical');
|
||||
fixture.detectChanges();
|
||||
|
||||
const filteredCount = component.filteredEntries().length;
|
||||
expect(filteredCount).toBeLessThanOrEqual(totalCount);
|
||||
});
|
||||
|
||||
it('returns correct severity class', () => {
|
||||
expect(component.getSeverityClass('Critical')).toBe('severity-critical');
|
||||
expect(component.getSeverityClass('Warning')).toBe('severity-warning');
|
||||
expect(component.getSeverityClass('Info')).toBe('severity-info');
|
||||
expect(component.getSeverityClass('Unknown')).toBe('');
|
||||
});
|
||||
|
||||
it('returns correct match mode label', () => {
|
||||
expect(component.getMatchModeLabel('Exact')).toBe('Exact match');
|
||||
expect(component.getMatchModeLabel('Prefix')).toBe('Prefix match');
|
||||
expect(component.getMatchModeLabel('Glob')).toBe('Glob pattern');
|
||||
expect(component.getMatchModeLabel('Regex')).toBe('Regular expression');
|
||||
expect(component.getMatchModeLabel('Other')).toBe('Other');
|
||||
});
|
||||
|
||||
it('formats dates correctly', () => {
|
||||
const isoDate = '2026-01-29T12:30:00Z';
|
||||
const formatted = component.formatDate(isoDate);
|
||||
expect(formatted).toContain('Jan');
|
||||
expect(formatted).toContain('29');
|
||||
expect(formatted).toContain('2026');
|
||||
});
|
||||
|
||||
it('shows success message on successful operations', () => {
|
||||
// Access private method via any cast for testing
|
||||
(component as any).showSuccess('Test success message');
|
||||
expect(component.message()).toBe('Test success message');
|
||||
expect(component.messageType()).toBe('success');
|
||||
});
|
||||
|
||||
it('shows error message on failed operations', () => {
|
||||
// Access private method via any cast for testing
|
||||
(component as any).showError('Test error message');
|
||||
expect(component.message()).toBe('Test error message');
|
||||
expect(component.messageType()).toBe('error');
|
||||
});
|
||||
|
||||
it('dismisses message when banner dismiss button is clicked', () => {
|
||||
component.message.set('Test message');
|
||||
fixture.detectChanges();
|
||||
|
||||
const dismissBtn = fixture.nativeElement.querySelector('.message-banner .dismiss');
|
||||
if (dismissBtn) {
|
||||
dismissBtn.click();
|
||||
fixture.detectChanges();
|
||||
expect(component.message()).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('tests pattern matching for an existing entry', async () => {
|
||||
await component.loadEntries();
|
||||
fixture.detectChanges();
|
||||
|
||||
const entries = component.entries();
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
|
||||
const firstEntry = entries[0];
|
||||
component.editEntry(firstEntry);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.testForm.patchValue({
|
||||
issuer: firstEntry.issuer ?? '',
|
||||
subjectAlternativeName: firstEntry.subjectAlternativeName ?? '',
|
||||
});
|
||||
|
||||
await component.testPattern();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.testResult()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('toggles entry enabled status', async () => {
|
||||
await component.loadEntries();
|
||||
fixture.detectChanges();
|
||||
|
||||
const entries = component.entries();
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
|
||||
const firstEntry = entries[0];
|
||||
const originalEnabled = firstEntry.enabled;
|
||||
|
||||
await component.toggleEnabled(firstEntry);
|
||||
fixture.detectChanges();
|
||||
|
||||
await component.loadEntries();
|
||||
fixture.detectChanges();
|
||||
|
||||
const updatedEntry = component.entries().find(e => e.id === firstEntry.id);
|
||||
expect(updatedEntry?.enabled).toBe(!originalEnabled);
|
||||
});
|
||||
|
||||
it('returns to list view when cancel is clicked in edit mode', () => {
|
||||
component.createNew();
|
||||
fixture.detectChanges();
|
||||
expect(component.viewMode()).toBe('edit');
|
||||
|
||||
component.showList();
|
||||
fixture.detectChanges();
|
||||
expect(component.viewMode()).toBe('list');
|
||||
});
|
||||
|
||||
it('provides trackBy functions for performance', () => {
|
||||
const mockEntry = { id: 'test-123' } as any;
|
||||
const mockAlert = { alertId: 'alert-456' } as any;
|
||||
|
||||
expect(component.trackByEntry(0, mockEntry)).toBe('test-123');
|
||||
expect(component.trackByAlert(0, mockAlert)).toBe('alert-456');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// watchlist-page.component.ts
|
||||
// Sprint: SPRINT_0129_001_ATTESTOR_identity_watchlist_alerting
|
||||
// Task: WATCH-009
|
||||
// Description: Angular component for identity watchlist management page.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
NonNullableFormBuilder,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import {
|
||||
WATCHLIST_API,
|
||||
WatchlistApi,
|
||||
} from '../../core/api/watchlist.client';
|
||||
import {
|
||||
IdentityAlert,
|
||||
WatchedIdentity,
|
||||
WatchlistMatchMode,
|
||||
WatchlistScope,
|
||||
IdentityAlertSeverity,
|
||||
} from '../../core/api/watchlist.models';
|
||||
|
||||
type ViewMode = 'list' | 'edit' | 'alerts';
|
||||
|
||||
@Component({
|
||||
selector: 'app-watchlist-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
templateUrl: './watchlist-page.component.html',
|
||||
styleUrls: ['./watchlist-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class WatchlistPageComponent implements OnInit {
|
||||
private readonly api = inject<WatchlistApi>(WATCHLIST_API);
|
||||
private readonly fb = inject(NonNullableFormBuilder);
|
||||
|
||||
// View state
|
||||
readonly viewMode = signal<ViewMode>('list');
|
||||
readonly loading = signal(false);
|
||||
readonly message = signal<string | null>(null);
|
||||
readonly messageType = signal<'success' | 'error'>('success');
|
||||
|
||||
// Data
|
||||
readonly entries = signal<WatchedIdentity[]>([]);
|
||||
readonly selectedEntry = signal<WatchedIdentity | null>(null);
|
||||
readonly alerts = signal<IdentityAlert[]>([]);
|
||||
|
||||
// Filters
|
||||
readonly includeGlobal = signal(true);
|
||||
readonly enabledOnly = signal(false);
|
||||
readonly severityFilter = signal<IdentityAlertSeverity | ''>('');
|
||||
|
||||
// Options
|
||||
readonly matchModes: WatchlistMatchMode[] = ['Exact', 'Prefix', 'Glob', 'Regex'];
|
||||
readonly scopes: WatchlistScope[] = ['Tenant', 'Global', 'System'];
|
||||
readonly severities: IdentityAlertSeverity[] = ['Info', 'Warning', 'Critical'];
|
||||
|
||||
// Computed
|
||||
readonly filteredEntries = computed(() => {
|
||||
let items = this.entries();
|
||||
if (this.severityFilter()) {
|
||||
items = items.filter(e => e.severity === this.severityFilter());
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
readonly entryCount = computed(() => this.filteredEntries().length);
|
||||
|
||||
// Form
|
||||
readonly entryForm = this.fb.group({
|
||||
displayName: this.fb.control('', Validators.required),
|
||||
description: this.fb.control(''),
|
||||
issuer: this.fb.control(''),
|
||||
subjectAlternativeName: this.fb.control(''),
|
||||
keyId: this.fb.control(''),
|
||||
matchMode: this.fb.control<WatchlistMatchMode>('Exact'),
|
||||
scope: this.fb.control<WatchlistScope>('Tenant'),
|
||||
severity: this.fb.control<IdentityAlertSeverity>('Warning'),
|
||||
enabled: this.fb.control(true),
|
||||
suppressDuplicatesMinutes: this.fb.control(60),
|
||||
channelOverridesText: this.fb.control(''),
|
||||
tagsText: this.fb.control(''),
|
||||
});
|
||||
|
||||
// Test form
|
||||
readonly testForm = this.fb.group({
|
||||
issuer: this.fb.control(''),
|
||||
subjectAlternativeName: this.fb.control(''),
|
||||
keyId: this.fb.control(''),
|
||||
});
|
||||
|
||||
readonly testResult = signal<{ matches: boolean; matchedFields: string[]; matchScore: number } | null>(null);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.loadEntries();
|
||||
}
|
||||
|
||||
async loadEntries(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
this.message.set(null);
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.api.listEntries({
|
||||
includeGlobal: this.includeGlobal(),
|
||||
enabledOnly: this.enabledOnly(),
|
||||
})
|
||||
);
|
||||
this.entries.set(response.items);
|
||||
} catch (error) {
|
||||
this.showError('Failed to load watchlist entries');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async loadAlerts(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
this.message.set(null);
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.api.listAlerts({ limit: 50 })
|
||||
);
|
||||
this.alerts.set(response.items);
|
||||
} catch (error) {
|
||||
this.showError('Failed to load alerts');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
showList(): void {
|
||||
this.viewMode.set('list');
|
||||
this.selectedEntry.set(null);
|
||||
this.entryForm.reset();
|
||||
void this.loadEntries();
|
||||
}
|
||||
|
||||
showAlerts(): void {
|
||||
this.viewMode.set('alerts');
|
||||
void this.loadAlerts();
|
||||
}
|
||||
|
||||
createNew(): void {
|
||||
this.selectedEntry.set(null);
|
||||
this.entryForm.reset({
|
||||
displayName: '',
|
||||
description: '',
|
||||
issuer: '',
|
||||
subjectAlternativeName: '',
|
||||
keyId: '',
|
||||
matchMode: 'Exact',
|
||||
scope: 'Tenant',
|
||||
severity: 'Warning',
|
||||
enabled: true,
|
||||
suppressDuplicatesMinutes: 60,
|
||||
channelOverridesText: '',
|
||||
tagsText: '',
|
||||
});
|
||||
this.testResult.set(null);
|
||||
this.viewMode.set('edit');
|
||||
}
|
||||
|
||||
editEntry(entry: WatchedIdentity): void {
|
||||
this.selectedEntry.set(entry);
|
||||
this.entryForm.patchValue({
|
||||
displayName: entry.displayName,
|
||||
description: entry.description ?? '',
|
||||
issuer: entry.issuer ?? '',
|
||||
subjectAlternativeName: entry.subjectAlternativeName ?? '',
|
||||
keyId: entry.keyId ?? '',
|
||||
matchMode: entry.matchMode,
|
||||
scope: entry.scope,
|
||||
severity: entry.severity,
|
||||
enabled: entry.enabled,
|
||||
suppressDuplicatesMinutes: entry.suppressDuplicatesMinutes,
|
||||
channelOverridesText: entry.channelOverrides?.join('\n') ?? '',
|
||||
tagsText: entry.tags?.join(', ') ?? '',
|
||||
});
|
||||
this.testResult.set(null);
|
||||
this.viewMode.set('edit');
|
||||
}
|
||||
|
||||
async saveEntry(): Promise<void> {
|
||||
if (this.entryForm.invalid) {
|
||||
this.entryForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = this.entryForm.getRawValue();
|
||||
|
||||
// Validate at least one identity field
|
||||
if (!raw.issuer && !raw.subjectAlternativeName && !raw.keyId) {
|
||||
this.showError('At least one identity field is required (issuer, SAN, or key ID)');
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
this.message.set(null);
|
||||
|
||||
try {
|
||||
const request = {
|
||||
displayName: raw.displayName,
|
||||
description: raw.description || undefined,
|
||||
issuer: raw.issuer || undefined,
|
||||
subjectAlternativeName: raw.subjectAlternativeName || undefined,
|
||||
keyId: raw.keyId || undefined,
|
||||
matchMode: raw.matchMode,
|
||||
scope: raw.scope,
|
||||
severity: raw.severity,
|
||||
enabled: raw.enabled,
|
||||
suppressDuplicatesMinutes: raw.suppressDuplicatesMinutes,
|
||||
channelOverrides: raw.channelOverridesText
|
||||
? raw.channelOverridesText.split('\n').map(s => s.trim()).filter(Boolean)
|
||||
: undefined,
|
||||
tags: raw.tagsText
|
||||
? raw.tagsText.split(',').map(s => s.trim()).filter(Boolean)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const existing = this.selectedEntry();
|
||||
if (existing) {
|
||||
await firstValueFrom(this.api.updateEntry(existing.id, request));
|
||||
this.showSuccess('Watchlist entry updated');
|
||||
} else {
|
||||
await firstValueFrom(this.api.createEntry(request));
|
||||
this.showSuccess('Watchlist entry created');
|
||||
}
|
||||
|
||||
this.showList();
|
||||
} catch (error) {
|
||||
this.showError('Failed to save watchlist entry');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteEntry(entry: WatchedIdentity): Promise<void> {
|
||||
if (!confirm(`Are you sure you want to delete "${entry.displayName}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
this.message.set(null);
|
||||
|
||||
try {
|
||||
await firstValueFrom(this.api.deleteEntry(entry.id));
|
||||
this.showSuccess('Watchlist entry deleted');
|
||||
await this.loadEntries();
|
||||
} catch (error) {
|
||||
this.showError('Failed to delete watchlist entry');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleEnabled(entry: WatchedIdentity): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
await firstValueFrom(
|
||||
this.api.updateEntry(entry.id, { enabled: !entry.enabled })
|
||||
);
|
||||
await this.loadEntries();
|
||||
} catch (error) {
|
||||
this.showError('Failed to update entry');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async testPattern(): Promise<void> {
|
||||
const entry = this.selectedEntry();
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = this.testForm.getRawValue();
|
||||
if (!raw.issuer && !raw.subjectAlternativeName && !raw.keyId) {
|
||||
this.showError('Enter at least one test value');
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
this.testResult.set(null);
|
||||
|
||||
try {
|
||||
const result = await firstValueFrom(
|
||||
this.api.testEntry(entry.id, {
|
||||
issuer: raw.issuer || undefined,
|
||||
subjectAlternativeName: raw.subjectAlternativeName || undefined,
|
||||
keyId: raw.keyId || undefined,
|
||||
})
|
||||
);
|
||||
this.testResult.set({
|
||||
matches: result.matches,
|
||||
matchedFields: result.matchedFields,
|
||||
matchScore: result.matchScore,
|
||||
});
|
||||
} catch (error) {
|
||||
this.showError('Failed to test pattern');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
onFilterChange(): void {
|
||||
void this.loadEntries();
|
||||
}
|
||||
|
||||
getSeverityClass(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'Critical':
|
||||
return 'severity-critical';
|
||||
case 'Warning':
|
||||
return 'severity-warning';
|
||||
case 'Info':
|
||||
return 'severity-info';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
getMatchModeLabel(mode: string): string {
|
||||
switch (mode) {
|
||||
case 'Exact':
|
||||
return 'Exact match';
|
||||
case 'Prefix':
|
||||
return 'Prefix match';
|
||||
case 'Glob':
|
||||
return 'Glob pattern';
|
||||
case 'Regex':
|
||||
return 'Regular expression';
|
||||
default:
|
||||
return mode;
|
||||
}
|
||||
}
|
||||
|
||||
formatDate(isoDate: string): string {
|
||||
return new Date(isoDate).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
private showSuccess(message: string): void {
|
||||
this.message.set(message);
|
||||
this.messageType.set('success');
|
||||
}
|
||||
|
||||
private showError(message: string): void {
|
||||
this.message.set(message);
|
||||
this.messageType.set('error');
|
||||
}
|
||||
|
||||
trackByEntry = (_: number, entry: WatchedIdentity) => entry.id;
|
||||
trackByAlert = (_: number, alert: IdentityAlert) => alert.alertId;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { Injectable, InjectionToken, signal, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of, delay, tap, catchError, finalize } from 'rxjs';
|
||||
import { Observable, of, delay, tap, catchError, finalize, map } from 'rxjs';
|
||||
|
||||
import {
|
||||
ReviewRibbonSummary,
|
||||
@@ -65,9 +65,10 @@ export class AuditorWorkspaceService implements IAuditorWorkspaceService {
|
||||
this.reviewSummary.set(response.summary);
|
||||
this.quietTriageItems.set(response.quietTriageItems);
|
||||
}),
|
||||
map(() => void 0),
|
||||
catchError((err) => {
|
||||
this.error.set(err.message || 'Failed to load workspace');
|
||||
return of(undefined as void);
|
||||
return of(void 0);
|
||||
}),
|
||||
finalize(() => {
|
||||
this.loading.set(false);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user