tests fixes and some product advisories tunes ups

This commit is contained in:
master
2026-01-30 07:57:43 +02:00
parent 644887997c
commit 55744f6a39
345 changed files with 26290 additions and 2267 deletions

View File

@@ -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": [

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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 },

View File

@@ -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) => {

View File

@@ -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);
});
});
});

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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> {

View File

@@ -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> {

View 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);
}
}

View 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;
}

View File

@@ -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));
}
}

View File

@@ -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.
*/

View File

@@ -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.
*/

View File

@@ -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 {

View File

@@ -1,4 +1,4 @@
@use '../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Degraded Mode Banner Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Envelope Hashes Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Export Actions Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Graph Mini Map Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* VEX Merge Explanation Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Witness Path Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.console-profile {
display: flex;

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.console-status {
display: flex;

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.cvss-receipt {
display: grid;

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.sources-dashboard {
padding: var(--space-6);

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.doctor-dashboard {
padding: var(--space-6);

View File

@@ -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');
}

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Evidence Export Dialog Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Evidence Graph Panel Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Evidence Node Card Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Evidence Thread List Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Evidence Thread View Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Evidence Timeline Panel Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Evidence Transcript Panel Component Styles

View File

@@ -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;

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.approval-queue {
display: flex;

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.exception-center {
display: flex;

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.exception-dashboard {
display: flex;

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.detail-container {
display: flex;

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.draft-inline {
display: flex;

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.exception-wizard {
display: flex;

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.feed-mirror-page {
display: grid;

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.bulk-triage-view {
font-family: var(--font-family-base);

View File

@@ -10,7 +10,7 @@ import {
import {
ScoreBucket,
BUCKET_DISPLAY,
BucketDisplayConfig,
BucketDisplayInfo,
} from '../../core/api/scoring.models';
import { ScoredFinding } from './findings-list.component';

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.findings-list {
display: flex;

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.graph-explorer {
display: flex;

View File

@@ -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);

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Audit Pack Export Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Export Options Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Diff Table Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Explainer Step Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Explainer Timeline Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Node Diff Table Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Pinned Item Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Pinned Panel Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Call Path Mini Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Confidence Bar Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Reachability Diff View Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
:host {
display: block;

View File

@@ -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', () => {

View File

@@ -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',

View File

@@ -1,4 +1,4 @@
@use '../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Proof Detail Panel Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.proof-chain-container {
display: flex;

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Confidence Breakdown Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Proof Studio Container Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* What-If Slider Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Proof Ledger View Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Reachability Explain Widget Styles

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.release-flow {
display: grid;

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.risk-dashboard {
display: grid;

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.attestation-panel {
border: 1px solid var(--color-border-secondary);

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.scan-detail {
display: grid;

View File

@@ -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: '',
};

View File

@@ -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.

View File

@@ -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.
*/

View File

@@ -1,4 +1,4 @@
@use '../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.aoc-dashboard {
display: grid;

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Causal Lanes Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Critical Path Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Event Detail Panel Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Evidence Links Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Export Button Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Timeline Filter Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
/**
* Timeline Page Component Styles

View File

@@ -1,4 +1,4 @@
@use '../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.case-header {
display: flex;

View File

@@ -1,4 +1,4 @@
@use '../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
// SPDX-License-Identifier: BUSL-1.1
// Sprint: SPRINT_3600_0002_0001

View File

@@ -1,4 +1,4 @@
@use '../../../../../../styles/tokens/breakpoints' as *;
@use 'tokens/breakpoints' as *;
.verdict-ladder {
position: relative;

View File

@@ -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');

View File

@@ -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());

View File

@@ -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,

View File

@@ -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';

View File

@@ -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', () => {

View File

@@ -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';

View File

@@ -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)">&times;</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>

View File

@@ -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;
}
}
}

View File

@@ -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');
});
});

View File

@@ -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;
}

View File

@@ -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