warnings fixes, tests fixes, sprints completions
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Secret Detection Settings Component Tests.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-012 - Add E2E tests
|
||||
*
|
||||
* Unit tests for the settings component.
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { signal } from '@angular/core';
|
||||
import { SecretDetectionSettingsComponent } from '../secret-detection-settings.component';
|
||||
import {
|
||||
SecretDetectionSettingsService,
|
||||
SECRET_DETECTION_SETTINGS_API,
|
||||
MockSecretDetectionSettingsApi
|
||||
} from '../services/secret-detection-settings.service';
|
||||
import { DEFAULT_SECRET_DETECTION_SETTINGS } from '../models/secret-detection.models';
|
||||
|
||||
describe('SecretDetectionSettingsComponent', () => {
|
||||
let component: SecretDetectionSettingsComponent;
|
||||
let fixture: ComponentFixture<SecretDetectionSettingsComponent>;
|
||||
let settingsService: SecretDetectionSettingsService;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SecretDetectionSettingsComponent],
|
||||
providers: [
|
||||
SecretDetectionSettingsService,
|
||||
{ provide: SECRET_DETECTION_SETTINGS_API, useClass: MockSecretDetectionSettingsApi }
|
||||
]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SecretDetectionSettingsComponent);
|
||||
component = fixture.componentInstance;
|
||||
settingsService = TestBed.inject(SecretDetectionSettingsService);
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load settings on init', () => {
|
||||
const loadSpy = spyOn(settingsService, 'loadSettings');
|
||||
fixture.detectChanges();
|
||||
expect(loadSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should display loading state', () => {
|
||||
// Mock loading state
|
||||
(settingsService as any)._loading = signal(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const loadingEl = fixture.nativeElement.querySelector('.loading-overlay');
|
||||
expect(loadingEl).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display error banner when error occurs', () => {
|
||||
// Mock error state
|
||||
(settingsService as any)._error = signal('Test error message');
|
||||
(settingsService as any)._loading = signal(false);
|
||||
(settingsService as any)._settings = signal(DEFAULT_SECRET_DETECTION_SETTINGS);
|
||||
fixture.detectChanges();
|
||||
|
||||
const errorEl = fixture.nativeElement.querySelector('.error-banner');
|
||||
expect(errorEl).toBeTruthy();
|
||||
expect(errorEl.textContent).toContain('Test error message');
|
||||
});
|
||||
|
||||
it('should toggle enabled state', () => {
|
||||
const setEnabledSpy = spyOn(settingsService, 'setEnabled');
|
||||
(settingsService as any)._settings = signal(DEFAULT_SECRET_DETECTION_SETTINGS);
|
||||
(settingsService as any)._loading = signal(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const toggle = fixture.nativeElement.querySelector('.toggle-switch input');
|
||||
toggle.checked = true;
|
||||
toggle.dispatchEvent(new Event('change'));
|
||||
|
||||
expect(setEnabledSpy).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should switch tabs', () => {
|
||||
(settingsService as any)._settings = signal(DEFAULT_SECRET_DETECTION_SETTINGS);
|
||||
(settingsService as any)._loading = signal(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.activeTab()).toBe('general');
|
||||
|
||||
component.setActiveTab('exceptions');
|
||||
expect(component.activeTab()).toBe('exceptions');
|
||||
|
||||
component.setActiveTab('alerts');
|
||||
expect(component.activeTab()).toBe('alerts');
|
||||
});
|
||||
|
||||
it('should show exception count badge', () => {
|
||||
const settingsWithExceptions = {
|
||||
...DEFAULT_SECRET_DETECTION_SETTINGS,
|
||||
exceptions: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'literal' as const,
|
||||
pattern: 'test',
|
||||
category: null,
|
||||
reason: 'Test',
|
||||
createdBy: 'user',
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: null
|
||||
}
|
||||
]
|
||||
};
|
||||
(settingsService as any)._settings = signal(settingsWithExceptions);
|
||||
(settingsService as any)._loading = signal(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.exceptionCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SecretDetectionSettingsComponent Accessibility', () => {
|
||||
let fixture: ComponentFixture<SecretDetectionSettingsComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SecretDetectionSettingsComponent],
|
||||
providers: [
|
||||
SecretDetectionSettingsService,
|
||||
{ provide: SECRET_DETECTION_SETTINGS_API, useClass: MockSecretDetectionSettingsApi }
|
||||
]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SecretDetectionSettingsComponent);
|
||||
});
|
||||
|
||||
it('should have proper ARIA attributes on tabs', () => {
|
||||
const service = TestBed.inject(SecretDetectionSettingsService);
|
||||
(service as any)._settings = signal(DEFAULT_SECRET_DETECTION_SETTINGS);
|
||||
(service as any)._loading = signal(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const tabs = fixture.nativeElement.querySelectorAll('[role="tab"]');
|
||||
expect(tabs.length).toBe(3);
|
||||
|
||||
const tablist = fixture.nativeElement.querySelector('[role="tablist"]');
|
||||
expect(tablist).toBeTruthy();
|
||||
|
||||
const tabpanel = fixture.nativeElement.querySelector('[role="tabpanel"]');
|
||||
expect(tabpanel).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have proper role on error banner', () => {
|
||||
const service = TestBed.inject(SecretDetectionSettingsService);
|
||||
(service as any)._error = signal('Error');
|
||||
(service as any)._settings = signal(DEFAULT_SECRET_DETECTION_SETTINGS);
|
||||
(service as any)._loading = signal(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const alert = fixture.nativeElement.querySelector('[role="alert"]');
|
||||
expect(alert).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Secret Findings List Component Tests.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-012 - Add E2E tests
|
||||
*
|
||||
* Unit tests for the findings list component.
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { signal } from '@angular/core';
|
||||
import { SecretFindingsListComponent } from '../secret-findings-list.component';
|
||||
import {
|
||||
SecretFindingsService,
|
||||
SECRET_FINDINGS_API,
|
||||
MockSecretFindingsApi
|
||||
} from '../services/secret-findings.service';
|
||||
import { SecretFinding } from '../models/secret-finding.models';
|
||||
|
||||
describe('SecretFindingsListComponent', () => {
|
||||
let component: SecretFindingsListComponent;
|
||||
let fixture: ComponentFixture<SecretFindingsListComponent>;
|
||||
let findingsService: SecretFindingsService;
|
||||
|
||||
const mockFinding: SecretFinding = {
|
||||
id: 'finding-001',
|
||||
scanDigest: 'sha256:abc123',
|
||||
artifactDigest: 'sha256:def456',
|
||||
artifactRef: 'myregistry.io/myapp:v1.0.0',
|
||||
severity: 'critical',
|
||||
status: 'open',
|
||||
rule: {
|
||||
ruleId: 'aws-access-key-id',
|
||||
ruleName: 'AWS Access Key ID',
|
||||
category: 'aws',
|
||||
description: 'Detects AWS Access Key IDs'
|
||||
},
|
||||
location: {
|
||||
filePath: 'config/settings.yaml',
|
||||
lineNumber: 42,
|
||||
columnNumber: 15,
|
||||
context: 'aws_access_key: AKIA****WXYZ'
|
||||
},
|
||||
maskedValue: 'AKIA****WXYZ',
|
||||
secretType: 'AWS Access Key ID',
|
||||
detectedAt: '2026-01-04T10:30:00Z',
|
||||
lastSeenAt: '2026-01-04T10:30:00Z',
|
||||
occurrenceCount: 1,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
resolutionReason: null
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SecretFindingsListComponent],
|
||||
providers: [
|
||||
SecretFindingsService,
|
||||
{ provide: SECRET_FINDINGS_API, useClass: MockSecretFindingsApi }
|
||||
]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SecretFindingsListComponent);
|
||||
component = fixture.componentInstance;
|
||||
findingsService = TestBed.inject(SecretFindingsService);
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load findings on init', () => {
|
||||
const loadFindingsSpy = spyOn(findingsService, 'loadFindings');
|
||||
const loadCountsSpy = spyOn(findingsService, 'loadCounts');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(loadFindingsSpy).toHaveBeenCalled();
|
||||
expect(loadCountsSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should toggle filters panel', () => {
|
||||
expect(component.showFilters()).toBeFalse();
|
||||
|
||||
component.toggleFilters();
|
||||
expect(component.showFilters()).toBeTrue();
|
||||
|
||||
component.toggleFilters();
|
||||
expect(component.showFilters()).toBeFalse();
|
||||
});
|
||||
|
||||
it('should display findings in table', () => {
|
||||
(findingsService as any)._findings = signal([mockFinding]);
|
||||
(findingsService as any)._loading = signal(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const rows = fixture.nativeElement.querySelectorAll('.findings-table__row');
|
||||
expect(rows.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should display empty state when no findings', () => {
|
||||
(findingsService as any)._findings = signal([]);
|
||||
(findingsService as any)._loading = signal(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const emptyCell = fixture.nativeElement.querySelector('.findings-table__empty');
|
||||
expect(emptyCell).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should select a finding', () => {
|
||||
const selectSpy = spyOn(findingsService, 'selectFinding');
|
||||
component.selectFinding(mockFinding);
|
||||
|
||||
expect(selectSpy).toHaveBeenCalledWith(mockFinding);
|
||||
});
|
||||
|
||||
it('should clear filters', () => {
|
||||
component.searchText.set('test');
|
||||
component.selectedSeverities.set(['critical']);
|
||||
component.selectedStatuses.set(['open']);
|
||||
component.selectedCategory.set('aws');
|
||||
|
||||
const setFilterSpy = spyOn(findingsService, 'setFilter');
|
||||
component.clearFilters();
|
||||
|
||||
expect(component.searchText()).toBe('');
|
||||
expect(component.selectedSeverities()).toEqual([]);
|
||||
expect(component.selectedStatuses()).toEqual([]);
|
||||
expect(component.selectedCategory()).toBe('');
|
||||
expect(setFilterSpy).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it('should toggle severity filter', () => {
|
||||
expect(component.selectedSeverities()).toEqual([]);
|
||||
|
||||
component.toggleSeverity('critical');
|
||||
expect(component.selectedSeverities()).toContain('critical');
|
||||
|
||||
component.toggleSeverity('critical');
|
||||
expect(component.selectedSeverities()).not.toContain('critical');
|
||||
});
|
||||
|
||||
it('should calculate active filter count', () => {
|
||||
expect(component.activeFilterCount()).toBe(0);
|
||||
|
||||
component.searchText.set('test');
|
||||
expect(component.activeFilterCount()).toBe(1);
|
||||
|
||||
component.selectedSeverities.set(['critical']);
|
||||
expect(component.activeFilterCount()).toBe(2);
|
||||
|
||||
component.selectedStatuses.set(['open']);
|
||||
expect(component.activeFilterCount()).toBe(3);
|
||||
|
||||
component.selectedCategory.set('aws');
|
||||
expect(component.activeFilterCount()).toBe(4);
|
||||
});
|
||||
|
||||
it('should sort by field', () => {
|
||||
const setSortSpy = spyOn(findingsService, 'setSort');
|
||||
|
||||
component.sortBy('severity');
|
||||
expect(setSortSpy).toHaveBeenCalledWith('severity', 'asc');
|
||||
|
||||
// Toggle same field should reverse direction
|
||||
component.sortBy('severity');
|
||||
expect(setSortSpy).toHaveBeenCalledWith('severity', 'desc');
|
||||
|
||||
// Different field should reset to asc
|
||||
component.sortBy('detectedAt');
|
||||
expect(setSortSpy).toHaveBeenCalledWith('detectedAt', 'asc');
|
||||
});
|
||||
|
||||
it('should truncate long artifact refs', () => {
|
||||
const shortRef = 'registry.io/app:v1';
|
||||
expect(component.truncateArtifact(shortRef)).toBe(shortRef);
|
||||
|
||||
const longRef = 'very-long-registry.example.com/organization/repository/image:sha256-abc123def456';
|
||||
const truncated = component.truncateArtifact(longRef);
|
||||
expect(truncated.length).toBeLessThan(longRef.length);
|
||||
expect(truncated).toContain('...');
|
||||
});
|
||||
|
||||
it('should format dates correctly', () => {
|
||||
const dateStr = '2026-01-04T10:30:00Z';
|
||||
const formatted = component.formatDate(dateStr);
|
||||
expect(formatted).toContain('Jan');
|
||||
expect(formatted).toContain('4');
|
||||
expect(formatted).toContain('2026');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SecretFindingsListComponent Pagination', () => {
|
||||
let component: SecretFindingsListComponent;
|
||||
let fixture: ComponentFixture<SecretFindingsListComponent>;
|
||||
let findingsService: SecretFindingsService;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SecretFindingsListComponent],
|
||||
providers: [
|
||||
SecretFindingsService,
|
||||
{ provide: SECRET_FINDINGS_API, useClass: MockSecretFindingsApi }
|
||||
]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SecretFindingsListComponent);
|
||||
component = fixture.componentInstance;
|
||||
findingsService = TestBed.inject(SecretFindingsService);
|
||||
});
|
||||
|
||||
it('should navigate to next page', () => {
|
||||
const setPageSpy = spyOn(findingsService, 'setPage');
|
||||
(findingsService as any)._currentPage = signal(0);
|
||||
(findingsService as any)._totalCount = signal(50);
|
||||
(findingsService as any)._pageSize = signal(20);
|
||||
|
||||
component.nextPage();
|
||||
expect(setPageSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should navigate to previous page', () => {
|
||||
const setPageSpy = spyOn(findingsService, 'setPage');
|
||||
(findingsService as any)._currentPage = signal(2);
|
||||
|
||||
component.previousPage();
|
||||
expect(setPageSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,799 @@
|
||||
/**
|
||||
* Alert Destination Config Component.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-010 - Build alert destination config
|
||||
*
|
||||
* Component for configuring alert destinations for secret findings.
|
||||
*/
|
||||
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
AlertDestinationSettings,
|
||||
AlertDestination,
|
||||
AlertChannelType,
|
||||
CHANNEL_TYPE_DISPLAY,
|
||||
DEFAULT_DESTINATIONS,
|
||||
EmailAlertDestination,
|
||||
SlackAlertDestination,
|
||||
TeamsAlertDestination,
|
||||
WebhookAlertDestination,
|
||||
PagerDutyAlertDestination
|
||||
} from './models/alert-destination.models';
|
||||
import { SecretSeverity, SEVERITY_DISPLAY } from './models/secret-finding.models';
|
||||
import { ChannelTestComponent } from './channel-test.component';
|
||||
|
||||
@Component({
|
||||
selector: 'stella-alert-destination-config',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, ChannelTestComponent],
|
||||
template: `
|
||||
<div class="alert-config" [class.alert-config--disabled]="disabled()">
|
||||
<header class="card-header">
|
||||
<div class="card-header__content">
|
||||
<h2 class="card-header__title">Alert Configuration</h2>
|
||||
<p class="card-header__subtitle">
|
||||
Configure where and how secret detection alerts are sent
|
||||
</p>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="settings()?.enabled"
|
||||
[disabled]="disabled()"
|
||||
(change)="onEnabledChange($event)" />
|
||||
<span class="toggle-switch__slider"></span>
|
||||
</label>
|
||||
</header>
|
||||
|
||||
<div class="card-content">
|
||||
@if (settings()?.enabled) {
|
||||
<section class="config-section">
|
||||
<h3 class="config-section__title">Global Settings</h3>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="min-severity">Minimum Severity</label>
|
||||
<select
|
||||
id="min-severity"
|
||||
[value]="settings()?.minimumSeverity"
|
||||
[disabled]="disabled()"
|
||||
(change)="onMinSeverityChange($event)"
|
||||
class="form-select">
|
||||
@for (sev of severityOptions; track sev) {
|
||||
<option [value]="sev">{{ SEVERITY_DISPLAY[sev].label }}</option>
|
||||
}
|
||||
</select>
|
||||
<p class="form-hint">Only alert on findings at or above this severity</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="rate-limit">Rate Limit (per hour)</label>
|
||||
<input
|
||||
id="rate-limit"
|
||||
type="number"
|
||||
[value]="settings()?.rateLimitPerHour"
|
||||
[disabled]="disabled()"
|
||||
min="1"
|
||||
max="1000"
|
||||
(change)="onRateLimitChange($event)"
|
||||
class="form-input" />
|
||||
<p class="form-hint">Maximum alerts per hour to prevent flooding</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="dedup-window">Deduplication Window (minutes)</label>
|
||||
<input
|
||||
id="dedup-window"
|
||||
type="number"
|
||||
[value]="settings()?.deduplicationWindowMinutes"
|
||||
[disabled]="disabled()"
|
||||
min="1"
|
||||
max="1440"
|
||||
(change)="onDedupWindowChange($event)"
|
||||
class="form-input" />
|
||||
<p class="form-hint">Suppress duplicate alerts within this window</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="config-section">
|
||||
<div class="config-section__header">
|
||||
<h3 class="config-section__title">Destinations</h3>
|
||||
<div class="add-destination">
|
||||
<select
|
||||
[(ngModel)]="newDestinationType"
|
||||
[disabled]="disabled()"
|
||||
class="form-select form-select--sm">
|
||||
@for (type of channelTypes; track type) {
|
||||
<option [value]="type">{{ CHANNEL_TYPE_DISPLAY[type].label }}</option>
|
||||
}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary btn--sm"
|
||||
[disabled]="disabled()"
|
||||
(click)="addDestination()">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (settings()?.destinations?.length === 0) {
|
||||
<div class="empty-state">
|
||||
<p>No alert destinations configured. Add a destination to start receiving alerts.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="destinations-list">
|
||||
@for (dest of settings()?.destinations; track dest.id; let i = $index) {
|
||||
<div class="destination-card" [class.destination-card--disabled]="!dest.enabled">
|
||||
<div class="destination-card__header">
|
||||
<div class="destination-card__info">
|
||||
<span class="destination-type">
|
||||
{{ CHANNEL_TYPE_DISPLAY[dest.type].label }}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
[value]="dest.name"
|
||||
[disabled]="disabled()"
|
||||
class="destination-name-input"
|
||||
(change)="onDestinationNameChange(i, $event)" />
|
||||
</div>
|
||||
<div class="destination-card__actions">
|
||||
<label class="toggle-switch toggle-switch--sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="dest.enabled"
|
||||
[disabled]="disabled()"
|
||||
(change)="onDestinationEnabledChange(i, $event)" />
|
||||
<span class="toggle-switch__slider"></span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--icon"
|
||||
[disabled]="disabled()"
|
||||
(click)="toggleDestinationExpanded(dest.id)">
|
||||
{{ isExpanded(dest.id) ? '-' : '+' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--icon btn--danger"
|
||||
[disabled]="disabled()"
|
||||
(click)="removeDestination(i)">
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isExpanded(dest.id)) {
|
||||
<div class="destination-card__content">
|
||||
@switch (dest.type) {
|
||||
@case ('email') {
|
||||
<ng-container *ngTemplateOutlet="emailConfig; context: { dest: dest, index: i }" />
|
||||
}
|
||||
@case ('slack') {
|
||||
<ng-container *ngTemplateOutlet="slackConfig; context: { dest: dest, index: i }" />
|
||||
}
|
||||
@case ('teams') {
|
||||
<ng-container *ngTemplateOutlet="teamsConfig; context: { dest: dest, index: i }" />
|
||||
}
|
||||
@case ('webhook') {
|
||||
<ng-container *ngTemplateOutlet="webhookConfig; context: { dest: dest, index: i }" />
|
||||
}
|
||||
@case ('pagerduty') {
|
||||
<ng-container *ngTemplateOutlet="pagerdutyConfig; context: { dest: dest, index: i }" />
|
||||
}
|
||||
}
|
||||
|
||||
<div class="destination-card__footer">
|
||||
<stella-channel-test
|
||||
[destinationId]="dest.id"
|
||||
[lastResult]="dest.lastTestResult"
|
||||
(test)="onTestDestination(dest.id)" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
} @else {
|
||||
<div class="disabled-state">
|
||||
<p>Alerts are currently disabled. Enable alerts to configure destinations.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email Config Template -->
|
||||
<ng-template #emailConfig let-dest="dest" let-index="index">
|
||||
<div class="config-fields">
|
||||
<div class="form-group">
|
||||
<label>Recipients</label>
|
||||
<input
|
||||
type="text"
|
||||
[value]="getEmailRecipients(dest)"
|
||||
[disabled]="disabled()"
|
||||
class="form-input"
|
||||
placeholder="email1@example.com, email2@example.com"
|
||||
(change)="onEmailRecipientsChange(index, $event)" />
|
||||
<p class="form-hint">Comma-separated email addresses</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Subject Prefix</label>
|
||||
<input
|
||||
type="text"
|
||||
[value]="dest.subjectPrefix"
|
||||
[disabled]="disabled()"
|
||||
class="form-input"
|
||||
(change)="onEmailSubjectChange(index, $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- Slack Config Template -->
|
||||
<ng-template #slackConfig let-dest="dest" let-index="index">
|
||||
<div class="config-fields">
|
||||
<div class="form-group">
|
||||
<label>Webhook URL</label>
|
||||
<input
|
||||
type="url"
|
||||
[value]="dest.webhookUrl"
|
||||
[disabled]="disabled()"
|
||||
class="form-input"
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
(change)="onSlackWebhookChange(index, $event)" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Channel (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
[value]="dest.channel"
|
||||
[disabled]="disabled()"
|
||||
class="form-input"
|
||||
placeholder="#security-alerts"
|
||||
(change)="onSlackChannelChange(index, $event)" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input
|
||||
type="text"
|
||||
[value]="dest.username"
|
||||
[disabled]="disabled()"
|
||||
class="form-input"
|
||||
(change)="onSlackUsernameChange(index, $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- Teams Config Template -->
|
||||
<ng-template #teamsConfig let-dest="dest" let-index="index">
|
||||
<div class="config-fields">
|
||||
<div class="form-group">
|
||||
<label>Webhook URL</label>
|
||||
<input
|
||||
type="url"
|
||||
[value]="dest.webhookUrl"
|
||||
[disabled]="disabled()"
|
||||
class="form-input"
|
||||
placeholder="https://outlook.office.com/webhook/..."
|
||||
(change)="onTeamsWebhookChange(index, $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- Webhook Config Template -->
|
||||
<ng-template #webhookConfig let-dest="dest" let-index="index">
|
||||
<div class="config-fields">
|
||||
<div class="form-group">
|
||||
<label>URL</label>
|
||||
<input
|
||||
type="url"
|
||||
[value]="dest.url"
|
||||
[disabled]="disabled()"
|
||||
class="form-input"
|
||||
(change)="onWebhookUrlChange(index, $event)" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Method</label>
|
||||
<select
|
||||
[value]="dest.method"
|
||||
[disabled]="disabled()"
|
||||
class="form-select"
|
||||
(change)="onWebhookMethodChange(index, $event)">
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Auth Type</label>
|
||||
<select
|
||||
[value]="dest.authType"
|
||||
[disabled]="disabled()"
|
||||
class="form-select"
|
||||
(change)="onWebhookAuthTypeChange(index, $event)">
|
||||
<option value="none">None</option>
|
||||
<option value="basic">Basic Auth</option>
|
||||
<option value="bearer">Bearer Token</option>
|
||||
<option value="header">Custom Header</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- PagerDuty Config Template -->
|
||||
<ng-template #pagerdutyConfig let-dest="dest" let-index="index">
|
||||
<div class="config-fields">
|
||||
<div class="form-group">
|
||||
<label>Integration Key</label>
|
||||
<input
|
||||
type="password"
|
||||
[value]="dest.integrationKey"
|
||||
[disabled]="disabled()"
|
||||
class="form-input"
|
||||
placeholder="Enter PagerDuty integration key"
|
||||
(change)="onPagerDutyKeyChange(index, $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
`,
|
||||
styles: [`
|
||||
.alert-config {
|
||||
background-color: var(--color-background-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.alert-config--disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.card-header__content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-header__title {
|
||||
margin: 0 0 var(--spacing-xs) 0;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-header__subtitle {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.toggle-switch--sm {
|
||||
width: 36px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-switch__slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background-color: var(--color-background-tertiary);
|
||||
border-radius: 22px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-switch__slider::before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-switch--sm .toggle-switch__slider::before {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-switch__slider {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-switch__slider::before {
|
||||
transform: translateX(22px);
|
||||
}
|
||||
|
||||
.toggle-switch--sm input:checked + .toggle-switch__slider::before {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.config-section__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.config-section__title {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.config-section__header .config-section__title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select {
|
||||
padding: var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.form-select--sm {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.add-destination {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn--sm {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
}
|
||||
|
||||
.btn--icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-background-tertiary);
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.disabled-state {
|
||||
padding: var(--spacing-xl);
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.destinations-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.destination-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.destination-card--disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.destination-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-background-tertiary);
|
||||
}
|
||||
|
||||
.destination-card__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.destination-type {
|
||||
padding: 2px 6px;
|
||||
background-color: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.destination-name-input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.destination-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.destination-card__content {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.config-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.destination-card__footer {
|
||||
margin-top: var(--spacing-md);
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class AlertDestinationConfigComponent {
|
||||
// Inputs
|
||||
settings = input<AlertDestinationSettings | null>(null);
|
||||
disabled = input(false);
|
||||
|
||||
// Outputs
|
||||
settingsChange = output<AlertDestinationSettings>();
|
||||
testDestination = output<string>();
|
||||
|
||||
// Static data
|
||||
readonly SEVERITY_DISPLAY = SEVERITY_DISPLAY;
|
||||
readonly CHANNEL_TYPE_DISPLAY = CHANNEL_TYPE_DISPLAY;
|
||||
readonly severityOptions: SecretSeverity[] = ['critical', 'high', 'medium', 'low', 'info'];
|
||||
readonly channelTypes: AlertChannelType[] = ['email', 'slack', 'teams', 'webhook', 'pagerduty'];
|
||||
|
||||
// Local state
|
||||
newDestinationType: AlertChannelType = 'email';
|
||||
readonly expandedDestinations = signal<Set<string>>(new Set());
|
||||
|
||||
isExpanded(destId: string): boolean {
|
||||
return this.expandedDestinations().has(destId);
|
||||
}
|
||||
|
||||
toggleDestinationExpanded(destId: string): void {
|
||||
this.expandedDestinations.update(set => {
|
||||
const newSet = new Set(set);
|
||||
if (newSet.has(destId)) {
|
||||
newSet.delete(destId);
|
||||
} else {
|
||||
newSet.add(destId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
|
||||
onEnabledChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const current = this.settings();
|
||||
if (current) {
|
||||
this.settingsChange.emit({ ...current, enabled: input.checked });
|
||||
}
|
||||
}
|
||||
|
||||
onMinSeverityChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
const current = this.settings();
|
||||
if (current) {
|
||||
this.settingsChange.emit({ ...current, minimumSeverity: select.value as SecretSeverity });
|
||||
}
|
||||
}
|
||||
|
||||
onRateLimitChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const current = this.settings();
|
||||
if (current) {
|
||||
this.settingsChange.emit({ ...current, rateLimitPerHour: parseInt(input.value, 10) });
|
||||
}
|
||||
}
|
||||
|
||||
onDedupWindowChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const current = this.settings();
|
||||
if (current) {
|
||||
this.settingsChange.emit({ ...current, deduplicationWindowMinutes: parseInt(input.value, 10) });
|
||||
}
|
||||
}
|
||||
|
||||
addDestination(): void {
|
||||
const current = this.settings();
|
||||
if (!current) return;
|
||||
|
||||
const defaults = DEFAULT_DESTINATIONS[this.newDestinationType];
|
||||
const newDest = {
|
||||
...defaults,
|
||||
id: crypto.randomUUID()
|
||||
} as AlertDestination;
|
||||
|
||||
this.settingsChange.emit({
|
||||
...current,
|
||||
destinations: [...current.destinations, newDest]
|
||||
});
|
||||
|
||||
this.expandedDestinations.update(set => new Set(set).add(newDest.id));
|
||||
}
|
||||
|
||||
removeDestination(index: number): void {
|
||||
const current = this.settings();
|
||||
if (!current) return;
|
||||
|
||||
const newDestinations = [...current.destinations];
|
||||
newDestinations.splice(index, 1);
|
||||
|
||||
this.settingsChange.emit({
|
||||
...current,
|
||||
destinations: newDestinations
|
||||
});
|
||||
}
|
||||
|
||||
onDestinationNameChange(index: number, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.updateDestination(index, { name: input.value });
|
||||
}
|
||||
|
||||
onDestinationEnabledChange(index: number, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.updateDestination(index, { enabled: input.checked });
|
||||
}
|
||||
|
||||
// Email handlers
|
||||
getEmailRecipients(dest: AlertDestination): string {
|
||||
return (dest as EmailAlertDestination).recipients?.join(', ') || '';
|
||||
}
|
||||
|
||||
onEmailRecipientsChange(index: number, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const recipients = input.value.split(',').map(e => e.trim()).filter(e => e);
|
||||
this.updateDestination(index, { recipients } as Partial<EmailAlertDestination>);
|
||||
}
|
||||
|
||||
onEmailSubjectChange(index: number, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.updateDestination(index, { subjectPrefix: input.value } as Partial<EmailAlertDestination>);
|
||||
}
|
||||
|
||||
// Slack handlers
|
||||
onSlackWebhookChange(index: number, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.updateDestination(index, { webhookUrl: input.value } as Partial<SlackAlertDestination>);
|
||||
}
|
||||
|
||||
onSlackChannelChange(index: number, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.updateDestination(index, { channel: input.value } as Partial<SlackAlertDestination>);
|
||||
}
|
||||
|
||||
onSlackUsernameChange(index: number, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.updateDestination(index, { username: input.value } as Partial<SlackAlertDestination>);
|
||||
}
|
||||
|
||||
// Teams handlers
|
||||
onTeamsWebhookChange(index: number, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.updateDestination(index, { webhookUrl: input.value } as Partial<TeamsAlertDestination>);
|
||||
}
|
||||
|
||||
// Webhook handlers
|
||||
onWebhookUrlChange(index: number, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.updateDestination(index, { url: input.value } as Partial<WebhookAlertDestination>);
|
||||
}
|
||||
|
||||
onWebhookMethodChange(index: number, event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
this.updateDestination(index, { method: select.value } as Partial<WebhookAlertDestination>);
|
||||
}
|
||||
|
||||
onWebhookAuthTypeChange(index: number, event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
this.updateDestination(index, { authType: select.value } as Partial<WebhookAlertDestination>);
|
||||
}
|
||||
|
||||
// PagerDuty handlers
|
||||
onPagerDutyKeyChange(index: number, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.updateDestination(index, { integrationKey: input.value } as Partial<PagerDutyAlertDestination>);
|
||||
}
|
||||
|
||||
onTestDestination(destinationId: string): void {
|
||||
this.testDestination.emit(destinationId);
|
||||
}
|
||||
|
||||
private updateDestination(index: number, updates: Partial<AlertDestination>): void {
|
||||
const current = this.settings();
|
||||
if (!current) return;
|
||||
|
||||
const newDestinations = [...current.destinations];
|
||||
newDestinations[index] = { ...newDestinations[index], ...updates } as AlertDestination;
|
||||
|
||||
this.settingsChange.emit({
|
||||
...current,
|
||||
destinations: newDestinations
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Channel Test Component.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-011 - Add channel test functionality
|
||||
*
|
||||
* Component for testing alert destinations.
|
||||
*/
|
||||
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AlertTestResult } from './models/alert-destination.models';
|
||||
|
||||
@Component({
|
||||
selector: 'stella-channel-test',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="channel-test">
|
||||
<button
|
||||
type="button"
|
||||
class="test-btn"
|
||||
[disabled]="testing()"
|
||||
(click)="onTest()">
|
||||
@if (testing()) {
|
||||
<span class="spinner"></span>
|
||||
Testing...
|
||||
} @else {
|
||||
Test Connection
|
||||
}
|
||||
</button>
|
||||
|
||||
@if (lastResult()) {
|
||||
<div class="test-result" [class.test-result--success]="lastResult()!.success" [class.test-result--error]="!lastResult()!.success">
|
||||
@if (lastResult()!.success) {
|
||||
<span class="test-result__icon">OK</span>
|
||||
<span class="test-result__message">
|
||||
Connection successful ({{ lastResult()!.responseTimeMs }}ms)
|
||||
</span>
|
||||
} @else {
|
||||
<span class="test-result__icon">!</span>
|
||||
<span class="test-result__message">
|
||||
{{ lastResult()!.error || 'Connection failed' }}
|
||||
</span>
|
||||
}
|
||||
<span class="test-result__time">
|
||||
Tested {{ formatTime(lastResult()!.testedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.channel-test {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.test-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
background-color: var(--color-background-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.test-btn:hover:not(:disabled) {
|
||||
background-color: var(--color-background-tertiary);
|
||||
}
|
||||
|
||||
.test-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.test-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.test-result--success {
|
||||
background-color: var(--color-success-background);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.test-result--error {
|
||||
background-color: var(--color-error-background);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.test-result__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.test-result--success .test-result__icon {
|
||||
background-color: var(--color-success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.test-result--error .test-result__icon {
|
||||
background-color: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.test-result__message {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.test-result__time {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 10px;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ChannelTestComponent {
|
||||
// Inputs
|
||||
destinationId = input.required<string>();
|
||||
lastResult = input<AlertTestResult | undefined>();
|
||||
|
||||
// Outputs
|
||||
test = output<void>();
|
||||
|
||||
// Local state
|
||||
readonly testing = signal(false);
|
||||
|
||||
onTest(): void {
|
||||
this.testing.set(true);
|
||||
this.test.emit();
|
||||
|
||||
// Reset testing state after a timeout (in real app, would be reset by parent after API call)
|
||||
setTimeout(() => this.testing.set(false), 3000);
|
||||
}
|
||||
|
||||
formatTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Exception Form Component.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-009 - Create exception form with validation
|
||||
*
|
||||
* Form for adding new secret detection exceptions.
|
||||
*/
|
||||
|
||||
import { Component, output, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SecretException, SecretRuleCategory, RULE_CATEGORIES } from './models/secret-detection.models';
|
||||
|
||||
type ExceptionType = 'literal' | 'regex' | 'path';
|
||||
|
||||
@Component({
|
||||
selector: 'stella-exception-form',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<form class="exception-form" (submit)="onSubmit($event)">
|
||||
<h3 class="exception-form__title">Add Exception</h3>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="exception-type">Pattern Type</label>
|
||||
<select
|
||||
id="exception-type"
|
||||
[(ngModel)]="type"
|
||||
name="type"
|
||||
class="form-select"
|
||||
required>
|
||||
<option value="literal">Literal (exact match)</option>
|
||||
<option value="regex">Regular Expression</option>
|
||||
<option value="path">File Path Pattern</option>
|
||||
</select>
|
||||
<p class="form-hint">{{ typeHints[type()] }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="exception-category">Category (optional)</label>
|
||||
<select
|
||||
id="exception-category"
|
||||
[(ngModel)]="category"
|
||||
name="category"
|
||||
class="form-select">
|
||||
<option [ngValue]="null">All categories</option>
|
||||
@for (cat of categoryOptions; track cat.category) {
|
||||
<option [ngValue]="cat.category">{{ cat.label }}</option>
|
||||
}
|
||||
</select>
|
||||
<p class="form-hint">Limit this exception to a specific category</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="exception-pattern">Pattern</label>
|
||||
<input
|
||||
id="exception-pattern"
|
||||
type="text"
|
||||
[(ngModel)]="pattern"
|
||||
name="pattern"
|
||||
class="form-input"
|
||||
[class.form-input--error]="patternError()"
|
||||
required
|
||||
(blur)="validatePattern()" />
|
||||
@if (patternError()) {
|
||||
<p class="form-error">{{ patternError() }}</p>
|
||||
} @else {
|
||||
<p class="form-hint">{{ patternHints[type()] }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="exception-reason">Reason</label>
|
||||
<textarea
|
||||
id="exception-reason"
|
||||
[(ngModel)]="reason"
|
||||
name="reason"
|
||||
class="form-textarea"
|
||||
rows="2"
|
||||
required
|
||||
placeholder="Explain why this exception is needed..."></textarea>
|
||||
<p class="form-hint">
|
||||
Document the justification for this exception for audit purposes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group form-group--checkbox">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="hasExpiration"
|
||||
name="hasExpiration" />
|
||||
<span>Set expiration date</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (hasExpiration()) {
|
||||
<div class="form-group">
|
||||
<label for="exception-expires">Expires</label>
|
||||
<input
|
||||
id="exception-expires"
|
||||
type="date"
|
||||
[(ngModel)]="expiresAt"
|
||||
name="expiresAt"
|
||||
class="form-input"
|
||||
[min]="minExpirationDate" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--secondary"
|
||||
(click)="onCancel()">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn--primary"
|
||||
[disabled]="!isValid()">
|
||||
Add Exception
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
`,
|
||||
styles: [`
|
||||
.exception-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.exception-form__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.form-group--checkbox {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
padding: var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-select:focus,
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-light);
|
||||
}
|
||||
|
||||
.form-input--error {
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.form-input--error:focus {
|
||||
box-shadow: 0 0 0 2px var(--color-error-light);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-md);
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background-color: var(--color-background-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn--secondary:hover {
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ExceptionFormComponent {
|
||||
// Outputs
|
||||
save = output<Omit<SecretException, 'id' | 'createdAt' | 'createdBy'>>();
|
||||
cancel = output<void>();
|
||||
|
||||
// Static data
|
||||
readonly categoryOptions = RULE_CATEGORIES;
|
||||
|
||||
readonly typeHints: Record<ExceptionType, string> = {
|
||||
literal: 'Match the exact secret value',
|
||||
regex: 'Use a regular expression pattern',
|
||||
path: 'Match file paths (supports * and ** wildcards)'
|
||||
};
|
||||
|
||||
readonly patternHints: Record<ExceptionType, string> = {
|
||||
literal: 'Enter the exact value to exclude',
|
||||
regex: 'Example: AKIA[A-Z0-9]{16} for AWS access keys',
|
||||
path: 'Example: test/fixtures/** or *.test.js'
|
||||
};
|
||||
|
||||
// Form state
|
||||
readonly type = signal<ExceptionType>('literal');
|
||||
readonly pattern = signal('');
|
||||
readonly category = signal<SecretRuleCategory | null>(null);
|
||||
readonly reason = signal('');
|
||||
readonly hasExpiration = signal(false);
|
||||
readonly expiresAt = signal('');
|
||||
readonly patternError = signal<string | null>(null);
|
||||
|
||||
// Computed
|
||||
readonly minExpirationDate = new Date().toISOString().split('T')[0];
|
||||
|
||||
readonly isValid = computed(() => {
|
||||
return (
|
||||
this.pattern().trim().length > 0 &&
|
||||
this.reason().trim().length > 0 &&
|
||||
!this.patternError() &&
|
||||
(!this.hasExpiration() || this.expiresAt())
|
||||
);
|
||||
});
|
||||
|
||||
validatePattern(): void {
|
||||
const pat = this.pattern().trim();
|
||||
|
||||
if (!pat) {
|
||||
this.patternError.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.type() === 'regex') {
|
||||
try {
|
||||
new RegExp(pat);
|
||||
this.patternError.set(null);
|
||||
} catch {
|
||||
this.patternError.set('Invalid regular expression');
|
||||
}
|
||||
} else if (this.type() === 'path') {
|
||||
// Basic path validation
|
||||
if (pat.includes('***')) {
|
||||
this.patternError.set('Invalid path pattern: *** is not allowed');
|
||||
} else {
|
||||
this.patternError.set(null);
|
||||
}
|
||||
} else {
|
||||
this.patternError.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit(event: Event): void {
|
||||
event.preventDefault();
|
||||
|
||||
if (!this.isValid()) return;
|
||||
|
||||
const exception: Omit<SecretException, 'id' | 'createdAt' | 'createdBy'> = {
|
||||
type: this.type(),
|
||||
pattern: this.pattern().trim(),
|
||||
category: this.category(),
|
||||
reason: this.reason().trim(),
|
||||
expiresAt: this.hasExpiration() && this.expiresAt()
|
||||
? new Date(this.expiresAt()).toISOString()
|
||||
: null
|
||||
};
|
||||
|
||||
this.save.emit(exception);
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.cancel.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Exception Manager Component.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-008 - Build exception manager component
|
||||
*
|
||||
* Component for managing secret detection exceptions (allowlist patterns).
|
||||
*/
|
||||
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { SecretException, SecretRuleCategory, RULE_CATEGORIES } from './models/secret-detection.models';
|
||||
import { ExceptionFormComponent } from './exception-form.component';
|
||||
|
||||
@Component({
|
||||
selector: 'stella-exception-manager',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ExceptionFormComponent],
|
||||
template: `
|
||||
<div class="exception-manager" [class.exception-manager--disabled]="disabled()">
|
||||
<header class="card-header">
|
||||
<div class="card-header__content">
|
||||
<h2 class="card-header__title">Exceptions</h2>
|
||||
<p class="card-header__subtitle">
|
||||
Define patterns to exclude from secret detection. Use sparingly and with clear justification.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
[disabled]="disabled() || showAddForm()"
|
||||
(click)="showAddForm.set(true)">
|
||||
Add Exception
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="card-content">
|
||||
@if (showAddForm()) {
|
||||
<div class="add-form-container">
|
||||
<stella-exception-form
|
||||
(save)="onAddException($event)"
|
||||
(cancel)="showAddForm.set(false)" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (exceptions().length === 0) {
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__icon">E</div>
|
||||
<h3 class="empty-state__title">No exceptions configured</h3>
|
||||
<p class="empty-state__description">
|
||||
Add exception patterns to exclude known false positives or test fixtures.
|
||||
</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="exceptions-list">
|
||||
@for (exception of exceptions(); track exception.id) {
|
||||
<div class="exception-card">
|
||||
<div class="exception-card__header">
|
||||
<span class="exception-type" [attr.data-type]="exception.type">
|
||||
{{ exception.type }}
|
||||
</span>
|
||||
@if (exception.category) {
|
||||
<span class="exception-category">{{ getCategoryLabel(exception.category) }}</span>
|
||||
} @else {
|
||||
<span class="exception-category exception-category--all">All categories</span>
|
||||
}
|
||||
@if (exception.expiresAt) {
|
||||
<span class="exception-expires" [class.exception-expires--soon]="expiresSoon(exception)">
|
||||
Expires {{ formatDate(exception.expiresAt) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="exception-card__pattern">
|
||||
<code>{{ exception.pattern }}</code>
|
||||
</div>
|
||||
|
||||
<div class="exception-card__reason">
|
||||
{{ exception.reason }}
|
||||
</div>
|
||||
|
||||
<div class="exception-card__footer">
|
||||
<span class="exception-meta">
|
||||
Added by {{ exception.createdBy }} on {{ formatDate(exception.createdAt) }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--danger btn--sm"
|
||||
[disabled]="disabled()"
|
||||
(click)="onRemove(exception.id)">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.exception-manager {
|
||||
background-color: var(--color-background-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.exception-manager--disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.card-header__content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-header__title {
|
||||
margin: 0 0 var(--spacing-xs) 0;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.card-header__subtitle {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
background-color: transparent;
|
||||
color: var(--color-error);
|
||||
border: 1px solid var(--color-error);
|
||||
}
|
||||
|
||||
.btn--danger:hover:not(:disabled) {
|
||||
background-color: var(--color-error-background);
|
||||
}
|
||||
|
||||
.btn--sm {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.add-form-container {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding: var(--spacing-lg);
|
||||
background-color: var(--color-background-primary);
|
||||
border: 1px solid var(--color-primary-light);
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--spacing-xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: var(--spacing-md);
|
||||
background-color: var(--color-background-tertiary);
|
||||
border-radius: 50%;
|
||||
font-size: 24px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.empty-state__title {
|
||||
margin: 0 0 var(--spacing-xs) 0;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.empty-state__description {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.exceptions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.exception-card {
|
||||
padding: var(--spacing-md);
|
||||
background-color: var(--color-background-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
.exception-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.exception-type {
|
||||
padding: 2px 6px;
|
||||
background-color: var(--color-background-tertiary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.exception-type[data-type="regex"] {
|
||||
background-color: var(--color-info-background);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.exception-type[data-type="path"] {
|
||||
background-color: var(--color-warning-background);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.exception-category {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.exception-category--all {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.exception-expires {
|
||||
margin-left: auto;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.exception-expires--soon {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.exception-card__pattern {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.exception-card__pattern code {
|
||||
display: block;
|
||||
padding: var(--spacing-sm);
|
||||
background-color: var(--color-background-tertiary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-sm);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.exception-card__reason {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.exception-card__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: var(--spacing-sm);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.exception-meta {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ExceptionManagerComponent {
|
||||
// Inputs
|
||||
exceptions = input<SecretException[]>([]);
|
||||
disabled = input(false);
|
||||
|
||||
// Outputs
|
||||
add = output<Omit<SecretException, 'id' | 'createdAt' | 'createdBy'>>();
|
||||
remove = output<string>();
|
||||
|
||||
// Local state
|
||||
readonly showAddForm = signal(false);
|
||||
|
||||
onAddException(exception: Omit<SecretException, 'id' | 'createdAt' | 'createdBy'>): void {
|
||||
this.add.emit(exception);
|
||||
this.showAddForm.set(false);
|
||||
}
|
||||
|
||||
onRemove(exceptionId: string): void {
|
||||
if (confirm('Are you sure you want to remove this exception? Secrets matching this pattern will be detected again.')) {
|
||||
this.remove.emit(exceptionId);
|
||||
}
|
||||
}
|
||||
|
||||
getCategoryLabel(category: SecretRuleCategory): string {
|
||||
const cat = RULE_CATEGORIES.find(c => c.category === category);
|
||||
return cat?.label || category;
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
expiresSoon(exception: SecretException): boolean {
|
||||
if (!exception.expiresAt) return false;
|
||||
const expires = new Date(exception.expiresAt);
|
||||
const now = new Date();
|
||||
const daysUntilExpiry = (expires.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
|
||||
return daysUntilExpiry <= 7;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* Finding Detail Drawer Component.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-007 - Add finding detail drawer
|
||||
*
|
||||
* Slide-out drawer for viewing and managing secret finding details.
|
||||
*/
|
||||
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
SecretFinding,
|
||||
SecretFindingStatus,
|
||||
SEVERITY_DISPLAY,
|
||||
STATUS_DISPLAY
|
||||
} from './models/secret-finding.models';
|
||||
import { MaskedValueDisplayComponent } from './masked-value-display.component';
|
||||
|
||||
@Component({
|
||||
selector: 'stella-finding-detail-drawer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, MaskedValueDisplayComponent],
|
||||
template: `
|
||||
<div class="drawer-overlay" (click)="onOverlayClick()"></div>
|
||||
<aside class="drawer">
|
||||
<header class="drawer__header">
|
||||
<div class="drawer__title">
|
||||
<h2>Finding Details</h2>
|
||||
<span
|
||||
class="severity-badge"
|
||||
[attr.data-severity]="finding().severity">
|
||||
{{ SEVERITY_DISPLAY[finding().severity].label }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="drawer__close"
|
||||
aria-label="Close"
|
||||
(click)="close.emit()">
|
||||
X
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="drawer__content">
|
||||
<section class="detail-section">
|
||||
<h3 class="detail-section__title">Secret Information</h3>
|
||||
<dl class="detail-list">
|
||||
<div class="detail-item">
|
||||
<dt>Type</dt>
|
||||
<dd>{{ finding().secretType }}</dd>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<dt>Rule</dt>
|
||||
<dd>
|
||||
<span class="rule-name">{{ finding().rule.ruleName }}</span>
|
||||
<span class="rule-id">({{ finding().rule.ruleId }})</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<dt>Category</dt>
|
||||
<dd>{{ finding().rule.category }}</dd>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<dt>Masked Value</dt>
|
||||
<dd>
|
||||
<stella-masked-value-display
|
||||
[value]="finding().maskedValue"
|
||||
[secretType]="finding().secretType" />
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<h3 class="detail-section__title">Location</h3>
|
||||
<dl class="detail-list">
|
||||
<div class="detail-item">
|
||||
<dt>File</dt>
|
||||
<dd class="monospace">{{ finding().location.filePath }}</dd>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<dt>Line</dt>
|
||||
<dd>{{ finding().location.lineNumber }}:{{ finding().location.columnNumber }}</dd>
|
||||
</div>
|
||||
<div class="detail-item detail-item--full">
|
||||
<dt>Context</dt>
|
||||
<dd>
|
||||
<pre class="code-context">{{ finding().location.context }}</pre>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<h3 class="detail-section__title">Artifact</h3>
|
||||
<dl class="detail-list">
|
||||
<div class="detail-item detail-item--full">
|
||||
<dt>Reference</dt>
|
||||
<dd class="monospace">{{ finding().artifactRef }}</dd>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<dt>Digest</dt>
|
||||
<dd class="monospace digest">{{ finding().artifactDigest }}</dd>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<dt>Scan Digest</dt>
|
||||
<dd class="monospace digest">{{ finding().scanDigest }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<h3 class="detail-section__title">Timeline</h3>
|
||||
<dl class="detail-list">
|
||||
<div class="detail-item">
|
||||
<dt>First Detected</dt>
|
||||
<dd>{{ formatDateTime(finding().detectedAt) }}</dd>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<dt>Last Seen</dt>
|
||||
<dd>{{ formatDateTime(finding().lastSeenAt) }}</dd>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<dt>Occurrences</dt>
|
||||
<dd>{{ finding().occurrenceCount }}</dd>
|
||||
</div>
|
||||
@if (finding().resolvedAt) {
|
||||
<div class="detail-item">
|
||||
<dt>Resolved</dt>
|
||||
<dd>
|
||||
{{ formatDateTime(finding().resolvedAt!) }} by {{ finding().resolvedBy }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="detail-item detail-item--full">
|
||||
<dt>Resolution Reason</dt>
|
||||
<dd>{{ finding().resolutionReason }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<h3 class="detail-section__title">Status</h3>
|
||||
<div class="status-current">
|
||||
<span>Current Status:</span>
|
||||
<span
|
||||
class="status-badge"
|
||||
[attr.data-status]="finding().status">
|
||||
{{ STATUS_DISPLAY[finding().status].label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (finding().status === 'open') {
|
||||
<div class="resolution-form">
|
||||
<h4>Resolve Finding</h4>
|
||||
<div class="form-group">
|
||||
<label for="resolution-status">New Status</label>
|
||||
<select
|
||||
id="resolution-status"
|
||||
[(ngModel)]="resolutionStatus"
|
||||
class="form-select">
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="excepted">Excepted</option>
|
||||
<option value="false-positive">False Positive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="resolution-reason">Reason</label>
|
||||
<textarea
|
||||
id="resolution-reason"
|
||||
[(ngModel)]="resolutionReason"
|
||||
class="form-textarea"
|
||||
rows="3"
|
||||
placeholder="Explain why this finding is being resolved..."></textarea>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
[disabled]="!canResolve()"
|
||||
(click)="onResolve()">
|
||||
Resolve Finding
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
`,
|
||||
styles: [`
|
||||
.drawer-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 500px;
|
||||
max-width: 90vw;
|
||||
background-color: var(--color-background-primary);
|
||||
box-shadow: var(--shadow-xl);
|
||||
z-index: 101;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.drawer__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.drawer__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.drawer__title h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.drawer__close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.drawer__close:hover {
|
||||
background-color: var(--color-background-tertiary);
|
||||
}
|
||||
|
||||
.drawer__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.detail-section__title {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.detail-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-md);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.detail-item--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.detail-item dt {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.detail-item dd {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.monospace {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.digest {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.rule-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rule-id {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.code-context {
|
||||
margin: 0;
|
||||
padding: var(--spacing-sm);
|
||||
background-color: var(--color-background-tertiary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.severity-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.severity-badge[data-severity="critical"] {
|
||||
background-color: var(--color-critical-background);
|
||||
color: var(--color-critical);
|
||||
}
|
||||
|
||||
.severity-badge[data-severity="high"] {
|
||||
background-color: var(--color-high-background);
|
||||
color: var(--color-high);
|
||||
}
|
||||
|
||||
.severity-badge[data-severity="medium"] {
|
||||
background-color: var(--color-medium-background);
|
||||
color: var(--color-medium);
|
||||
}
|
||||
|
||||
.severity-badge[data-severity="low"] {
|
||||
background-color: var(--color-low-background);
|
||||
color: var(--color-low);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge[data-status="open"] {
|
||||
background-color: var(--color-warning-background);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.status-badge[data-status="resolved"] {
|
||||
background-color: var(--color-success-background);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.status-badge[data-status="excepted"] {
|
||||
background-color: var(--color-info-background);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.status-badge[data-status="false-positive"] {
|
||||
background-color: var(--color-muted-background);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.status-current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.resolution-form {
|
||||
padding: var(--spacing-md);
|
||||
background-color: var(--color-background-secondary);
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
.resolution-form h4 {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class FindingDetailDrawerComponent {
|
||||
// Inputs
|
||||
finding = input.required<SecretFinding>();
|
||||
|
||||
// Outputs
|
||||
close = output<void>();
|
||||
resolve = output<{ status: SecretFindingStatus; reason: string }>();
|
||||
|
||||
// Static data
|
||||
readonly SEVERITY_DISPLAY = SEVERITY_DISPLAY;
|
||||
readonly STATUS_DISPLAY = STATUS_DISPLAY;
|
||||
|
||||
// Local state
|
||||
resolutionStatus: SecretFindingStatus = 'resolved';
|
||||
resolutionReason = '';
|
||||
|
||||
onOverlayClick(): void {
|
||||
this.close.emit();
|
||||
}
|
||||
|
||||
canResolve(): boolean {
|
||||
return this.resolutionReason.trim().length > 0;
|
||||
}
|
||||
|
||||
onResolve(): void {
|
||||
if (this.canResolve()) {
|
||||
this.resolve.emit({
|
||||
status: this.resolutionStatus,
|
||||
reason: this.resolutionReason.trim()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
formatDateTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Secret Detection Feature Module.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-001 - Create secret-detection feature module
|
||||
*
|
||||
* Frontend components for configuring and viewing secret detection findings.
|
||||
* Provides tenant administrators with tools to manage detection settings,
|
||||
* view findings, and configure alerts.
|
||||
*/
|
||||
|
||||
// Models
|
||||
export * from './models/secret-detection.models';
|
||||
export * from './models/secret-finding.models';
|
||||
export * from './models/revelation-policy.models';
|
||||
export * from './models/alert-destination.models';
|
||||
|
||||
// Services
|
||||
export * from './services/secret-detection-settings.service';
|
||||
export * from './services/secret-findings.service';
|
||||
|
||||
// Components
|
||||
export * from './secret-detection-settings.component';
|
||||
export * from './revelation-policy-config.component';
|
||||
export * from './rule-category-selector.component';
|
||||
export * from './secret-findings-list.component';
|
||||
export * from './masked-value-display.component';
|
||||
export * from './finding-detail-drawer.component';
|
||||
export * from './exception-manager.component';
|
||||
export * from './exception-form.component';
|
||||
export * from './alert-destination-config.component';
|
||||
export * from './channel-test.component';
|
||||
|
||||
// Routes
|
||||
export * from './secret-detection.routes';
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Masked Value Display Component.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-006 - Implement masked value display
|
||||
*
|
||||
* Component for displaying masked secret values with copy functionality.
|
||||
*/
|
||||
|
||||
import { Component, input, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'stella-masked-value-display',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="masked-value" [class.masked-value--redacted]="isRedacted()">
|
||||
<code class="masked-value__text">{{ value() }}</code>
|
||||
@if (!isRedacted()) {
|
||||
<button
|
||||
type="button"
|
||||
class="masked-value__copy"
|
||||
[title]="copied() ? 'Copied!' : 'Copy to clipboard'"
|
||||
(click)="copyToClipboard($event)">
|
||||
{{ copied() ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.masked-value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: 4px 8px;
|
||||
background-color: var(--color-background-tertiary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.masked-value--redacted {
|
||||
background-color: var(--color-warning-background);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.masked-value__text {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.masked-value__copy {
|
||||
padding: 2px 6px;
|
||||
background-color: var(--color-background-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.masked-value__copy:hover {
|
||||
background-color: var(--color-background-primary);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class MaskedValueDisplayComponent {
|
||||
// Inputs
|
||||
value = input.required<string>();
|
||||
secretType = input<string>('');
|
||||
|
||||
// Local state
|
||||
readonly copied = signal(false);
|
||||
|
||||
isRedacted(): boolean {
|
||||
return this.value() === '[REDACTED]';
|
||||
}
|
||||
|
||||
copyToClipboard(event: Event): void {
|
||||
event.stopPropagation();
|
||||
|
||||
navigator.clipboard.writeText(this.value()).then(() => {
|
||||
this.copied.set(true);
|
||||
setTimeout(() => this.copied.set(false), 2000);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Alert Destination Models.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-001 - Create secret-detection feature module
|
||||
*
|
||||
* Models for configuring alert destinations.
|
||||
*/
|
||||
|
||||
import { SecretSeverity } from './secret-finding.models';
|
||||
|
||||
/**
|
||||
* Supported alert channel types.
|
||||
*/
|
||||
export type AlertChannelType = 'email' | 'slack' | 'teams' | 'webhook' | 'pagerduty';
|
||||
|
||||
/**
|
||||
* Base alert destination.
|
||||
*/
|
||||
export interface AlertDestinationBase {
|
||||
/** Unique destination ID */
|
||||
id: string;
|
||||
/** Channel type */
|
||||
type: AlertChannelType;
|
||||
/** Display name */
|
||||
name: string;
|
||||
/** Whether this destination is enabled */
|
||||
enabled: boolean;
|
||||
/** Minimum severity to alert on */
|
||||
minimumSeverity: SecretSeverity;
|
||||
/** Categories to alert on (null = all) */
|
||||
categories: string[] | null;
|
||||
/** Last test result */
|
||||
lastTestResult?: AlertTestResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email destination.
|
||||
*/
|
||||
export interface EmailAlertDestination extends AlertDestinationBase {
|
||||
type: 'email';
|
||||
/** Email addresses to notify */
|
||||
recipients: string[];
|
||||
/** Subject prefix */
|
||||
subjectPrefix: string;
|
||||
/** Include finding details in body */
|
||||
includeDetails: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slack destination.
|
||||
*/
|
||||
export interface SlackAlertDestination extends AlertDestinationBase {
|
||||
type: 'slack';
|
||||
/** Slack webhook URL */
|
||||
webhookUrl: string;
|
||||
/** Channel to post to (optional, uses webhook default) */
|
||||
channel?: string;
|
||||
/** Bot username */
|
||||
username: string;
|
||||
/** Bot icon emoji */
|
||||
iconEmoji: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Microsoft Teams destination.
|
||||
*/
|
||||
export interface TeamsAlertDestination extends AlertDestinationBase {
|
||||
type: 'teams';
|
||||
/** Teams webhook URL */
|
||||
webhookUrl: string;
|
||||
/** Theme color for cards */
|
||||
themeColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic webhook destination.
|
||||
*/
|
||||
export interface WebhookAlertDestination extends AlertDestinationBase {
|
||||
type: 'webhook';
|
||||
/** Webhook URL */
|
||||
url: string;
|
||||
/** HTTP method */
|
||||
method: 'POST' | 'PUT';
|
||||
/** Custom headers */
|
||||
headers: Record<string, string>;
|
||||
/** Authentication type */
|
||||
authType: 'none' | 'basic' | 'bearer' | 'header';
|
||||
/** Auth credentials (masked in responses) */
|
||||
authCredentials?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PagerDuty destination.
|
||||
*/
|
||||
export interface PagerDutyAlertDestination extends AlertDestinationBase {
|
||||
type: 'pagerduty';
|
||||
/** PagerDuty integration key */
|
||||
integrationKey: string;
|
||||
/** Severity mapping */
|
||||
severityMapping: Record<SecretSeverity, 'critical' | 'error' | 'warning' | 'info'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all destination types.
|
||||
*/
|
||||
export type AlertDestination =
|
||||
| EmailAlertDestination
|
||||
| SlackAlertDestination
|
||||
| TeamsAlertDestination
|
||||
| WebhookAlertDestination
|
||||
| PagerDutyAlertDestination;
|
||||
|
||||
/**
|
||||
* Result of testing an alert destination.
|
||||
*/
|
||||
export interface AlertTestResult {
|
||||
/** Whether the test was successful */
|
||||
success: boolean;
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
/** When the test was run */
|
||||
testedAt: string;
|
||||
/** Response time in ms */
|
||||
responseTimeMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete alert destination settings.
|
||||
*/
|
||||
export interface AlertDestinationSettings {
|
||||
/** Whether alerting is enabled globally */
|
||||
enabled: boolean;
|
||||
/** Configured destinations */
|
||||
destinations: AlertDestination[];
|
||||
/** Global minimum severity */
|
||||
minimumSeverity: SecretSeverity;
|
||||
/** Rate limit (alerts per hour) */
|
||||
rateLimitPerHour: number;
|
||||
/** Deduplication window in minutes */
|
||||
deduplicationWindowMinutes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel type display info.
|
||||
*/
|
||||
export const CHANNEL_TYPE_DISPLAY: Record<AlertChannelType, { label: string; icon: string; description: string }> = {
|
||||
email: { label: 'Email', icon: 'email', description: 'Send alerts via email' },
|
||||
slack: { label: 'Slack', icon: 'chat', description: 'Post alerts to Slack channels' },
|
||||
teams: { label: 'Microsoft Teams', icon: 'groups', description: 'Post alerts to Teams channels' },
|
||||
webhook: { label: 'Webhook', icon: 'webhook', description: 'Send alerts to custom HTTP endpoints' },
|
||||
pagerduty: { label: 'PagerDuty', icon: 'notifications_active', description: 'Create PagerDuty incidents' }
|
||||
};
|
||||
|
||||
/**
|
||||
* Default destination configurations.
|
||||
*/
|
||||
export const DEFAULT_DESTINATIONS: Record<AlertChannelType, Partial<AlertDestination>> = {
|
||||
email: {
|
||||
type: 'email',
|
||||
name: 'Email Alert',
|
||||
enabled: true,
|
||||
minimumSeverity: 'high',
|
||||
categories: null,
|
||||
recipients: [],
|
||||
subjectPrefix: '[StellaOps] Secret Detected',
|
||||
includeDetails: true
|
||||
} as Partial<EmailAlertDestination>,
|
||||
slack: {
|
||||
type: 'slack',
|
||||
name: 'Slack Alert',
|
||||
enabled: true,
|
||||
minimumSeverity: 'high',
|
||||
categories: null,
|
||||
webhookUrl: '',
|
||||
username: 'StellaOps',
|
||||
iconEmoji: ':lock:'
|
||||
} as Partial<SlackAlertDestination>,
|
||||
teams: {
|
||||
type: 'teams',
|
||||
name: 'Teams Alert',
|
||||
enabled: true,
|
||||
minimumSeverity: 'high',
|
||||
categories: null,
|
||||
webhookUrl: '',
|
||||
themeColor: '#dc3545'
|
||||
} as Partial<TeamsAlertDestination>,
|
||||
webhook: {
|
||||
type: 'webhook',
|
||||
name: 'Webhook',
|
||||
enabled: true,
|
||||
minimumSeverity: 'high',
|
||||
categories: null,
|
||||
url: '',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
authType: 'none'
|
||||
} as Partial<WebhookAlertDestination>,
|
||||
pagerduty: {
|
||||
type: 'pagerduty',
|
||||
name: 'PagerDuty',
|
||||
enabled: true,
|
||||
minimumSeverity: 'critical',
|
||||
categories: null,
|
||||
integrationKey: '',
|
||||
severityMapping: {
|
||||
critical: 'critical',
|
||||
high: 'error',
|
||||
medium: 'warning',
|
||||
low: 'info',
|
||||
info: 'info'
|
||||
}
|
||||
} as Partial<PagerDutyAlertDestination>
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Revelation Policy Models.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-001 - Create secret-detection feature module
|
||||
*
|
||||
* Models for controlling how secrets are revealed/masked in the UI.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Revelation policy types.
|
||||
*/
|
||||
export type RevelationPolicyType = 'FullMask' | 'PartialReveal' | 'FullReveal';
|
||||
|
||||
/**
|
||||
* Revelation policy configuration.
|
||||
*/
|
||||
export interface RevelationPolicy {
|
||||
/** Default policy for UI display */
|
||||
defaultPolicy: RevelationPolicyType;
|
||||
/** Policy for export reports */
|
||||
exportPolicy: RevelationPolicyType;
|
||||
/** Policy for logs (always FullMask - enforced) */
|
||||
logPolicy: 'FullMask';
|
||||
/** Whether full reveal is allowed (requires security-admin role) */
|
||||
allowFullReveal: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for partial reveal.
|
||||
*/
|
||||
export interface PartialRevealConfig {
|
||||
/** Number of characters to show at start */
|
||||
prefixLength: number;
|
||||
/** Number of characters to show at end */
|
||||
suffixLength: number;
|
||||
/** Character to use for masking */
|
||||
maskChar: string;
|
||||
/** Minimum length before partial reveal kicks in */
|
||||
minLengthForPartial: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default partial reveal configuration.
|
||||
*/
|
||||
export const DEFAULT_PARTIAL_REVEAL_CONFIG: PartialRevealConfig = {
|
||||
prefixLength: 4,
|
||||
suffixLength: 4,
|
||||
maskChar: '*',
|
||||
minLengthForPartial: 12
|
||||
};
|
||||
|
||||
/**
|
||||
* Revelation policy display info.
|
||||
*/
|
||||
export interface RevelationPolicyInfo {
|
||||
/** Policy type */
|
||||
type: RevelationPolicyType;
|
||||
/** Display label */
|
||||
label: string;
|
||||
/** Description */
|
||||
description: string;
|
||||
/** Example output */
|
||||
example: string;
|
||||
/** Whether this requires elevated permissions */
|
||||
requiresElevatedPermissions: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* All revelation policies with display info.
|
||||
*/
|
||||
export const REVELATION_POLICIES: RevelationPolicyInfo[] = [
|
||||
{
|
||||
type: 'FullMask',
|
||||
label: 'Full Mask',
|
||||
description: 'No secret value shown. Safest option for most users.',
|
||||
example: '[REDACTED]',
|
||||
requiresElevatedPermissions: false
|
||||
},
|
||||
{
|
||||
type: 'PartialReveal',
|
||||
label: 'Partial Reveal',
|
||||
description: 'Show first and last 4 characters. Helps identify specific secrets without full exposure.',
|
||||
example: 'AKIA****WXYZ',
|
||||
requiresElevatedPermissions: false
|
||||
},
|
||||
{
|
||||
type: 'FullReveal',
|
||||
label: 'Full Reveal',
|
||||
description: 'Show complete value. Requires security-admin role and audit logging.',
|
||||
example: 'AKIAIOSFODNN7EXAMPLE',
|
||||
requiresElevatedPermissions: true
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Apply revelation policy to a value.
|
||||
* @param value The secret value to mask
|
||||
* @param policy The policy to apply
|
||||
* @param config Partial reveal configuration (optional)
|
||||
* @returns Masked value according to policy
|
||||
*/
|
||||
export function applyRevelationPolicy(
|
||||
value: string,
|
||||
policy: RevelationPolicyType,
|
||||
config: PartialRevealConfig = DEFAULT_PARTIAL_REVEAL_CONFIG
|
||||
): string {
|
||||
switch (policy) {
|
||||
case 'FullMask':
|
||||
return '[REDACTED]';
|
||||
|
||||
case 'PartialReveal':
|
||||
if (value.length < config.minLengthForPartial) {
|
||||
return '[REDACTED]';
|
||||
}
|
||||
const prefix = value.slice(0, config.prefixLength);
|
||||
const suffix = value.slice(-config.suffixLength);
|
||||
const maskLength = Math.min(value.length - config.prefixLength - config.suffixLength, 8);
|
||||
const mask = config.maskChar.repeat(maskLength);
|
||||
return `${prefix}${mask}${suffix}`;
|
||||
|
||||
case 'FullReveal':
|
||||
return value;
|
||||
|
||||
default:
|
||||
return '[REDACTED]';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user can use a specific revelation policy.
|
||||
* @param policy The policy to check
|
||||
* @param userRoles User's roles
|
||||
* @returns Whether the user can use this policy
|
||||
*/
|
||||
export function canUseRevelationPolicy(
|
||||
policy: RevelationPolicyType,
|
||||
userRoles: string[]
|
||||
): boolean {
|
||||
if (policy === 'FullReveal') {
|
||||
return userRoles.includes('security-admin') || userRoles.includes('admin');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Secret Detection Settings Models.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-001 - Create secret-detection feature module
|
||||
*
|
||||
* Core models for secret detection configuration.
|
||||
*/
|
||||
|
||||
import { RevelationPolicy } from './revelation-policy.models';
|
||||
import { AlertDestinationSettings } from './alert-destination.models';
|
||||
|
||||
/**
|
||||
* Secret detection rule category.
|
||||
*/
|
||||
export type SecretRuleCategory =
|
||||
| 'aws'
|
||||
| 'azure'
|
||||
| 'gcp'
|
||||
| 'github'
|
||||
| 'gitlab'
|
||||
| 'generic-api-keys'
|
||||
| 'private-keys'
|
||||
| 'passwords'
|
||||
| 'tokens'
|
||||
| 'database-credentials'
|
||||
| 'custom';
|
||||
|
||||
/**
|
||||
* Rule category display info.
|
||||
*/
|
||||
export interface RuleCategoryInfo {
|
||||
/** Category identifier */
|
||||
category: SecretRuleCategory;
|
||||
/** Display label */
|
||||
label: string;
|
||||
/** Description */
|
||||
description: string;
|
||||
/** Number of rules in this category */
|
||||
ruleCount: number;
|
||||
/** Icon identifier */
|
||||
icon: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception entry for allowlisting patterns.
|
||||
*/
|
||||
export interface SecretException {
|
||||
/** Unique exception ID */
|
||||
id: string;
|
||||
/** Pattern type */
|
||||
type: 'literal' | 'regex' | 'path';
|
||||
/** Pattern value */
|
||||
pattern: string;
|
||||
/** Rule category this exception applies to (null = all) */
|
||||
category: SecretRuleCategory | null;
|
||||
/** Reason for exception */
|
||||
reason: string;
|
||||
/** Who created the exception */
|
||||
createdBy: string;
|
||||
/** When the exception was created */
|
||||
createdAt: string;
|
||||
/** Expiration date (null = never) */
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret detection settings for a tenant.
|
||||
*/
|
||||
export interface SecretDetectionSettings {
|
||||
/** Whether secret detection is enabled */
|
||||
enabled: boolean;
|
||||
/** Revelation policy for displaying secrets */
|
||||
revelationPolicy: RevelationPolicy;
|
||||
/** Enabled rule categories */
|
||||
enabledRuleCategories: SecretRuleCategory[];
|
||||
/** Exception patterns */
|
||||
exceptions: SecretException[];
|
||||
/** Alert settings */
|
||||
alertSettings: AlertDestinationSettings;
|
||||
/** Last modified timestamp */
|
||||
modifiedAt: string;
|
||||
/** Last modified by */
|
||||
modifiedBy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default settings.
|
||||
*/
|
||||
export const DEFAULT_SECRET_DETECTION_SETTINGS: SecretDetectionSettings = {
|
||||
enabled: false,
|
||||
revelationPolicy: {
|
||||
defaultPolicy: 'FullMask',
|
||||
exportPolicy: 'FullMask',
|
||||
logPolicy: 'FullMask',
|
||||
allowFullReveal: false
|
||||
},
|
||||
enabledRuleCategories: [
|
||||
'aws',
|
||||
'azure',
|
||||
'gcp',
|
||||
'github',
|
||||
'gitlab',
|
||||
'generic-api-keys',
|
||||
'private-keys',
|
||||
'passwords',
|
||||
'tokens',
|
||||
'database-credentials'
|
||||
],
|
||||
exceptions: [],
|
||||
alertSettings: {
|
||||
enabled: false,
|
||||
destinations: [],
|
||||
minimumSeverity: 'high',
|
||||
rateLimitPerHour: 100,
|
||||
deduplicationWindowMinutes: 60
|
||||
},
|
||||
modifiedAt: new Date().toISOString(),
|
||||
modifiedBy: 'system'
|
||||
};
|
||||
|
||||
/**
|
||||
* All available rule categories with display info.
|
||||
*/
|
||||
export const RULE_CATEGORIES: RuleCategoryInfo[] = [
|
||||
{ category: 'aws', label: 'AWS', description: 'AWS access keys, secret keys, and session tokens', ruleCount: 12, icon: 'cloud' },
|
||||
{ category: 'azure', label: 'Azure', description: 'Azure subscription keys and connection strings', ruleCount: 8, icon: 'cloud' },
|
||||
{ category: 'gcp', label: 'GCP', description: 'Google Cloud API keys and service account credentials', ruleCount: 6, icon: 'cloud' },
|
||||
{ category: 'github', label: 'GitHub', description: 'GitHub personal access tokens and app keys', ruleCount: 5, icon: 'code' },
|
||||
{ category: 'gitlab', label: 'GitLab', description: 'GitLab personal and project access tokens', ruleCount: 4, icon: 'code' },
|
||||
{ category: 'generic-api-keys', label: 'Generic API Keys', description: 'Common API key patterns', ruleCount: 10, icon: 'key' },
|
||||
{ category: 'private-keys', label: 'Private Keys', description: 'RSA, ECDSA, and other private key formats', ruleCount: 8, icon: 'lock' },
|
||||
{ category: 'passwords', label: 'Passwords', description: 'Password patterns in configuration files', ruleCount: 6, icon: 'password' },
|
||||
{ category: 'tokens', label: 'Tokens', description: 'JWT, OAuth, and bearer tokens', ruleCount: 7, icon: 'token' },
|
||||
{ category: 'database-credentials', label: 'Database', description: 'Database connection strings and credentials', ruleCount: 9, icon: 'database' },
|
||||
{ category: 'custom', label: 'Custom', description: 'Custom rules defined by your organization', ruleCount: 0, icon: 'settings' }
|
||||
];
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Secret Finding Models.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-001 - Create secret-detection feature module
|
||||
*
|
||||
* Models for secret findings display.
|
||||
*/
|
||||
|
||||
import { SecretRuleCategory } from './secret-detection.models';
|
||||
|
||||
/**
|
||||
* Severity level for secret findings.
|
||||
*/
|
||||
export type SecretSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info';
|
||||
|
||||
/**
|
||||
* Status of a secret finding.
|
||||
*/
|
||||
export type SecretFindingStatus = 'open' | 'resolved' | 'excepted' | 'false-positive';
|
||||
|
||||
/**
|
||||
* Location where a secret was found.
|
||||
*/
|
||||
export interface SecretLocation {
|
||||
/** File path where secret was found */
|
||||
filePath: string;
|
||||
/** Line number (1-based) */
|
||||
lineNumber: number;
|
||||
/** Column number (1-based) */
|
||||
columnNumber: number;
|
||||
/** Context snippet (surrounding code, masked) */
|
||||
context: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule that matched the secret.
|
||||
*/
|
||||
export interface MatchedRule {
|
||||
/** Rule ID */
|
||||
ruleId: string;
|
||||
/** Rule name */
|
||||
ruleName: string;
|
||||
/** Rule category */
|
||||
category: SecretRuleCategory;
|
||||
/** Rule description */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A detected secret finding.
|
||||
*/
|
||||
export interface SecretFinding {
|
||||
/** Unique finding ID */
|
||||
id: string;
|
||||
/** Digest of the scan that found this secret */
|
||||
scanDigest: string;
|
||||
/** Image/artifact digest where secret was found */
|
||||
artifactDigest: string;
|
||||
/** Image/artifact reference */
|
||||
artifactRef: string;
|
||||
/** Severity level */
|
||||
severity: SecretSeverity;
|
||||
/** Current status */
|
||||
status: SecretFindingStatus;
|
||||
/** Rule that detected this secret */
|
||||
rule: MatchedRule;
|
||||
/** Location in the artifact */
|
||||
location: SecretLocation;
|
||||
/** Masked value (based on revelation policy) */
|
||||
maskedValue: string;
|
||||
/** Secret type (e.g., 'AWS Access Key ID') */
|
||||
secretType: string;
|
||||
/** When the finding was detected */
|
||||
detectedAt: string;
|
||||
/** When the finding was last seen */
|
||||
lastSeenAt: string;
|
||||
/** Number of times this secret was found */
|
||||
occurrenceCount: number;
|
||||
/** Who resolved/excepted this finding */
|
||||
resolvedBy: string | null;
|
||||
/** When it was resolved */
|
||||
resolvedAt: string | null;
|
||||
/** Resolution reason */
|
||||
resolutionReason: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter options for secret findings.
|
||||
*/
|
||||
export interface SecretFindingsFilter {
|
||||
/** Filter by severity */
|
||||
severity?: SecretSeverity[];
|
||||
/** Filter by status */
|
||||
status?: SecretFindingStatus[];
|
||||
/** Filter by rule category */
|
||||
category?: SecretRuleCategory[];
|
||||
/** Filter by artifact reference */
|
||||
artifactRef?: string;
|
||||
/** Filter by scan digest */
|
||||
scanDigest?: string;
|
||||
/** Search text (matches file path, rule name, secret type) */
|
||||
search?: string;
|
||||
/** Only show findings from date */
|
||||
detectedAfter?: string;
|
||||
/** Only show findings before date */
|
||||
detectedBefore?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort options for findings list.
|
||||
*/
|
||||
export type SecretFindingsSortField = 'severity' | 'detectedAt' | 'artifactRef' | 'category' | 'occurrenceCount';
|
||||
export type SecretFindingsSortDirection = 'asc' | 'desc';
|
||||
|
||||
/**
|
||||
* Paginated findings response.
|
||||
*/
|
||||
export interface SecretFindingsPage {
|
||||
/** Findings on this page */
|
||||
items: SecretFinding[];
|
||||
/** Total count matching filter */
|
||||
totalCount: number;
|
||||
/** Current page (0-based) */
|
||||
page: number;
|
||||
/** Page size */
|
||||
pageSize: number;
|
||||
/** Has more pages */
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Severity display configuration.
|
||||
*/
|
||||
export const SEVERITY_DISPLAY: Record<SecretSeverity, { label: string; color: string; icon: string }> = {
|
||||
critical: { label: 'Critical', color: 'var(--color-critical)', icon: 'error' },
|
||||
high: { label: 'High', color: 'var(--color-high)', icon: 'warning' },
|
||||
medium: { label: 'Medium', color: 'var(--color-medium)', icon: 'info' },
|
||||
low: { label: 'Low', color: 'var(--color-low)', icon: 'low_priority' },
|
||||
info: { label: 'Info', color: 'var(--color-info)', icon: 'info' }
|
||||
};
|
||||
|
||||
/**
|
||||
* Status display configuration.
|
||||
*/
|
||||
export const STATUS_DISPLAY: Record<SecretFindingStatus, { label: string; color: string; icon: string }> = {
|
||||
open: { label: 'Open', color: 'var(--color-warning)', icon: 'error_outline' },
|
||||
resolved: { label: 'Resolved', color: 'var(--color-success)', icon: 'check_circle' },
|
||||
excepted: { label: 'Excepted', color: 'var(--color-info)', icon: 'rule' },
|
||||
'false-positive': { label: 'False Positive', color: 'var(--color-muted)', icon: 'cancel' }
|
||||
};
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* Revelation Policy Config Component.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-003 - Add revelation policy selector
|
||||
*
|
||||
* Component for configuring how secrets are revealed/masked.
|
||||
*/
|
||||
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
RevelationPolicy,
|
||||
RevelationPolicyType,
|
||||
REVELATION_POLICIES,
|
||||
RevelationPolicyInfo
|
||||
} from './models/revelation-policy.models';
|
||||
|
||||
@Component({
|
||||
selector: 'stella-revelation-policy-config',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="revelation-policy-config">
|
||||
<header class="card-header">
|
||||
<h2 class="card-header__title">Secret Revelation Policy</h2>
|
||||
<p class="card-header__subtitle">
|
||||
Control how detected secrets are displayed in the UI and exports
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="card-content">
|
||||
<section class="policy-section">
|
||||
<h3 class="policy-section__title">Default Display Policy</h3>
|
||||
<div class="policy-options">
|
||||
@for (option of policyOptions; track option.type) {
|
||||
<label
|
||||
class="policy-option"
|
||||
[class.policy-option--selected]="policy()?.defaultPolicy === option.type"
|
||||
[class.policy-option--disabled]="option.requiresElevatedPermissions && !canFullReveal()">
|
||||
<input
|
||||
type="radio"
|
||||
name="defaultPolicy"
|
||||
[value]="option.type"
|
||||
[checked]="policy()?.defaultPolicy === option.type"
|
||||
[disabled]="option.requiresElevatedPermissions && !canFullReveal()"
|
||||
(change)="onDefaultPolicyChange(option.type)" />
|
||||
<div class="policy-option__content">
|
||||
<div class="policy-option__header">
|
||||
<strong class="policy-option__label">{{ option.label }}</strong>
|
||||
<code class="policy-option__example">{{ option.example }}</code>
|
||||
</div>
|
||||
<p class="policy-option__description">{{ option.description }}</p>
|
||||
@if (option.requiresElevatedPermissions && !canFullReveal()) {
|
||||
<p class="policy-option__warning">
|
||||
<span class="policy-option__warning-icon" aria-hidden="true">!</span>
|
||||
Requires security-admin role
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr class="divider" />
|
||||
|
||||
<section class="policy-section">
|
||||
<h3 class="policy-section__title">Context-Specific Policies</h3>
|
||||
<p class="policy-section__subtitle">
|
||||
Override the default policy for specific contexts
|
||||
</p>
|
||||
|
||||
<div class="context-policies">
|
||||
<div class="context-policy">
|
||||
<label class="context-policy__label" for="export-policy">
|
||||
Export Reports
|
||||
</label>
|
||||
<select
|
||||
id="export-policy"
|
||||
class="context-policy__select"
|
||||
[value]="policy()?.exportPolicy"
|
||||
(change)="onExportPolicyChange($event)">
|
||||
<option value="FullMask">Full Mask - [REDACTED]</option>
|
||||
<option value="PartialReveal">Partial Reveal - AKIA****WXYZ</option>
|
||||
</select>
|
||||
<p class="context-policy__hint">
|
||||
Policy used when exporting findings to PDF or CSV
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="context-policy">
|
||||
<label class="context-policy__label" for="log-policy">
|
||||
Logs & Telemetry
|
||||
</label>
|
||||
<select
|
||||
id="log-policy"
|
||||
class="context-policy__select context-policy__select--disabled"
|
||||
disabled>
|
||||
<option value="FullMask">Full Mask (Enforced)</option>
|
||||
</select>
|
||||
<p class="context-policy__hint">
|
||||
Secrets are never logged in full for security compliance
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.revelation-policy-config {
|
||||
background-color: var(--color-background-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.card-header__title {
|
||||
margin: 0 0 var(--spacing-xs) 0;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.card-header__subtitle {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.policy-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.policy-section__title {
|
||||
margin: 0 0 var(--spacing-xs) 0;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.policy-section__subtitle {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.policy-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.policy-option {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background-color: var(--color-background-primary);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.policy-option:hover:not(.policy-option--disabled) {
|
||||
border-color: var(--color-primary-light);
|
||||
}
|
||||
|
||||
.policy-option--selected {
|
||||
border-color: var(--color-primary);
|
||||
background-color: var(--color-primary-background);
|
||||
}
|
||||
|
||||
.policy-option--disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.policy-option input[type="radio"] {
|
||||
margin-top: 2px;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.policy-option__content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.policy-option__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.policy-option__label {
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.policy-option__example {
|
||||
padding: 2px 8px;
|
||||
background-color: var(--color-background-tertiary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.policy-option__description {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.policy-option__warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
margin: var(--spacing-sm) 0 0 0;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.policy-option__warning-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: var(--color-warning);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: var(--spacing-lg) 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.context-policies {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.context-policy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.context-policy__label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.context-policy__select {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-background-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.context-policy__select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary-light);
|
||||
}
|
||||
|
||||
.context-policy__select--disabled {
|
||||
background-color: var(--color-background-tertiary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.context-policy__hint {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class RevelationPolicyConfigComponent {
|
||||
// Inputs
|
||||
policy = input<RevelationPolicy | null>(null);
|
||||
canFullReveal = input(false);
|
||||
|
||||
// Outputs
|
||||
policyChange = output<RevelationPolicy>();
|
||||
|
||||
// Static data
|
||||
readonly policyOptions: RevelationPolicyInfo[] = REVELATION_POLICIES;
|
||||
|
||||
onDefaultPolicyChange(type: RevelationPolicyType): void {
|
||||
const current = this.policy();
|
||||
if (current) {
|
||||
this.policyChange.emit({
|
||||
...current,
|
||||
defaultPolicy: type
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onExportPolicyChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
const current = this.policy();
|
||||
if (current) {
|
||||
this.policyChange.emit({
|
||||
...current,
|
||||
exportPolicy: select.value as RevelationPolicyType
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Rule Category Selector Component.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-004 - Build rule category toggles
|
||||
*
|
||||
* Component for selecting which secret detection rule categories are enabled.
|
||||
*/
|
||||
|
||||
import { Component, input, output, computed, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SecretRuleCategory, RuleCategoryInfo } from './models/secret-detection.models';
|
||||
|
||||
@Component({
|
||||
selector: 'stella-rule-category-selector',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<div class="rule-category-selector" [class.rule-category-selector--disabled]="disabled()">
|
||||
<header class="card-header">
|
||||
<div class="card-header__content">
|
||||
<h2 class="card-header__title">Detection Categories</h2>
|
||||
<p class="card-header__subtitle">
|
||||
Select which types of secrets to detect. Each category contains multiple detection rules.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-header__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--text"
|
||||
[disabled]="disabled() || allSelected()"
|
||||
(click)="selectAll()">
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--text"
|
||||
[disabled]="disabled() || noneSelected()"
|
||||
(click)="selectNone()">
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="category-grid">
|
||||
@for (category of categories(); track category.category) {
|
||||
<label
|
||||
class="category-card"
|
||||
[class.category-card--selected]="isSelected(category.category)"
|
||||
[class.category-card--disabled]="disabled()">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="isSelected(category.category)"
|
||||
[disabled]="disabled()"
|
||||
(change)="toggleCategory(category.category)" />
|
||||
<div class="category-card__content">
|
||||
<div class="category-card__header">
|
||||
<span class="category-card__icon" [attr.data-icon]="category.icon">
|
||||
{{ getIconChar(category.icon) }}
|
||||
</span>
|
||||
<span class="category-card__label">{{ category.label }}</span>
|
||||
</div>
|
||||
<p class="category-card__description">{{ category.description }}</p>
|
||||
<div class="category-card__footer">
|
||||
<span class="category-card__rule-count">{{ category.ruleCount }} rules</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="selection-summary">
|
||||
<span class="selection-summary__count">
|
||||
{{ selectedCount() }} of {{ categories().length }} categories selected
|
||||
</span>
|
||||
<span class="selection-summary__rules">
|
||||
{{ totalRulesSelected() }} total rules enabled
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.rule-category-selector {
|
||||
background-color: var(--color-background-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rule-category-selector--disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.card-header__content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-header__title {
|
||||
margin: 0 0 var(--spacing-xs) 0;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.card-header__subtitle {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.card-header__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn--text {
|
||||
background: none;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn--text:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-background);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.category-card {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
background-color: var(--color-background-primary);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.category-card:hover:not(.category-card--disabled) {
|
||||
border-color: var(--color-primary-light);
|
||||
}
|
||||
|
||||
.category-card--selected {
|
||||
border-color: var(--color-primary);
|
||||
background-color: var(--color-primary-background);
|
||||
}
|
||||
|
||||
.category-card--disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.category-card input[type="checkbox"] {
|
||||
margin-top: 2px;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.category-card__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.category-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.category-card__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: var(--color-background-tertiary);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.category-card--selected .category-card__icon {
|
||||
background-color: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.category-card__label {
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.category-card__description {
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.category-card__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.category-card__rule-count {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.selection-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-md);
|
||||
background-color: var(--color-background-tertiary);
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
.selection-summary__count,
|
||||
.selection-summary__rules {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class RuleCategorySelectorComponent {
|
||||
// Inputs
|
||||
categories = input<RuleCategoryInfo[]>([]);
|
||||
selected = input<SecretRuleCategory[]>([]);
|
||||
disabled = input(false);
|
||||
|
||||
// Outputs
|
||||
selectionChange = output<SecretRuleCategory[]>();
|
||||
|
||||
// Computed
|
||||
readonly selectedCount = computed(() => this.selected().length);
|
||||
readonly totalRulesSelected = computed(() => {
|
||||
const selectedSet = new Set(this.selected());
|
||||
return this.categories()
|
||||
.filter(c => selectedSet.has(c.category))
|
||||
.reduce((sum, c) => sum + c.ruleCount, 0);
|
||||
});
|
||||
readonly allSelected = computed(() => this.selected().length === this.categories().length);
|
||||
readonly noneSelected = computed(() => this.selected().length === 0);
|
||||
|
||||
isSelected(category: SecretRuleCategory): boolean {
|
||||
return this.selected().includes(category);
|
||||
}
|
||||
|
||||
toggleCategory(category: SecretRuleCategory): void {
|
||||
if (this.disabled()) return;
|
||||
|
||||
const current = [...this.selected()];
|
||||
const index = current.indexOf(category);
|
||||
|
||||
if (index >= 0) {
|
||||
current.splice(index, 1);
|
||||
} else {
|
||||
current.push(category);
|
||||
}
|
||||
|
||||
this.selectionChange.emit(current);
|
||||
}
|
||||
|
||||
selectAll(): void {
|
||||
if (this.disabled()) return;
|
||||
this.selectionChange.emit(this.categories().map(c => c.category));
|
||||
}
|
||||
|
||||
selectNone(): void {
|
||||
if (this.disabled()) return;
|
||||
this.selectionChange.emit([]);
|
||||
}
|
||||
|
||||
getIconChar(icon: string): string {
|
||||
// Simple icon mapping - in real app would use icon library
|
||||
const iconMap: Record<string, string> = {
|
||||
cloud: 'C',
|
||||
code: '<>',
|
||||
key: 'K',
|
||||
lock: 'L',
|
||||
password: 'P',
|
||||
token: 'T',
|
||||
database: 'D',
|
||||
settings: 'S'
|
||||
};
|
||||
return iconMap[icon] || '?';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* Secret Detection Settings Component.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-002 - Build settings page component
|
||||
*
|
||||
* Main settings page for configuring secret detection.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SecretDetectionSettingsService } from './services/secret-detection-settings.service';
|
||||
import { RevelationPolicyConfigComponent } from './revelation-policy-config.component';
|
||||
import { RuleCategorySelectorComponent } from './rule-category-selector.component';
|
||||
import { ExceptionManagerComponent } from './exception-manager.component';
|
||||
import { AlertDestinationConfigComponent } from './alert-destination-config.component';
|
||||
import { SecretRuleCategory, SecretException } from './models/secret-detection.models';
|
||||
import { RevelationPolicy } from './models/revelation-policy.models';
|
||||
import { AlertDestinationSettings } from './models/alert-destination.models';
|
||||
|
||||
@Component({
|
||||
selector: 'stella-secret-detection-settings',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RevelationPolicyConfigComponent,
|
||||
RuleCategorySelectorComponent,
|
||||
ExceptionManagerComponent,
|
||||
AlertDestinationConfigComponent
|
||||
],
|
||||
template: `
|
||||
<div class="secret-detection-settings">
|
||||
<header class="settings-header">
|
||||
<div class="settings-header__title">
|
||||
<h1>Secret Detection</h1>
|
||||
<p class="settings-header__description">
|
||||
Configure automatic detection of secrets, credentials, and API keys in container images.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-header__toggle">
|
||||
<label class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="settingsService.isEnabled()"
|
||||
(change)="onEnabledChange($event)"
|
||||
[disabled]="settingsService.saving()" />
|
||||
<span class="toggle-switch__slider"></span>
|
||||
</label>
|
||||
<span class="toggle-switch__label">
|
||||
{{ settingsService.isEnabled() ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (settingsService.error()) {
|
||||
<div class="error-banner" role="alert">
|
||||
<span class="error-banner__icon" aria-hidden="true">!</span>
|
||||
<span class="error-banner__message">{{ settingsService.error() }}</span>
|
||||
<button class="error-banner__dismiss" (click)="dismissError()">Dismiss</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (settingsService.loading()) {
|
||||
<div class="loading-overlay">
|
||||
<div class="loading-spinner" aria-label="Loading settings"></div>
|
||||
</div>
|
||||
} @else if (settingsService.settings()) {
|
||||
<div class="settings-tabs">
|
||||
<nav class="settings-tabs__nav" role="tablist">
|
||||
<button
|
||||
class="settings-tabs__tab"
|
||||
[class.settings-tabs__tab--active]="activeTab() === 'general'"
|
||||
(click)="setActiveTab('general')"
|
||||
role="tab"
|
||||
[attr.aria-selected]="activeTab() === 'general'">
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
class="settings-tabs__tab"
|
||||
[class.settings-tabs__tab--active]="activeTab() === 'exceptions'"
|
||||
(click)="setActiveTab('exceptions')"
|
||||
role="tab"
|
||||
[attr.aria-selected]="activeTab() === 'exceptions'">
|
||||
Exceptions
|
||||
@if (exceptionCount() > 0) {
|
||||
<span class="settings-tabs__badge">{{ exceptionCount() }}</span>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
class="settings-tabs__tab"
|
||||
[class.settings-tabs__tab--active]="activeTab() === 'alerts'"
|
||||
(click)="setActiveTab('alerts')"
|
||||
role="tab"
|
||||
[attr.aria-selected]="activeTab() === 'alerts'">
|
||||
Alerts
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="settings-tabs__content" role="tabpanel">
|
||||
@switch (activeTab()) {
|
||||
@case ('general') {
|
||||
<div class="settings-section">
|
||||
<stella-revelation-policy-config
|
||||
[policy]="settingsService.settings()!.revelationPolicy"
|
||||
[canFullReveal]="canFullReveal()"
|
||||
(policyChange)="onPolicyChange($event)" />
|
||||
|
||||
<stella-rule-category-selector
|
||||
[categories]="settingsService.availableCategories()"
|
||||
[selected]="settingsService.enabledCategories()"
|
||||
[disabled]="!settingsService.isEnabled()"
|
||||
(selectionChange)="onCategoriesChange($event)" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('exceptions') {
|
||||
<div class="settings-section">
|
||||
<stella-exception-manager
|
||||
[exceptions]="settingsService.exceptions()"
|
||||
[disabled]="!settingsService.isEnabled()"
|
||||
(add)="onAddException($event)"
|
||||
(remove)="onRemoveException($event)" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('alerts') {
|
||||
<div class="settings-section">
|
||||
<stella-alert-destination-config
|
||||
[settings]="settingsService.settings()!.alertSettings"
|
||||
[disabled]="!settingsService.isEnabled()"
|
||||
(settingsChange)="onAlertSettingsChange($event)"
|
||||
(testDestination)="onTestDestination($event)" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (settingsService.saving()) {
|
||||
<div class="saving-indicator">
|
||||
<span class="saving-indicator__spinner"></span>
|
||||
<span class="saving-indicator__text">Saving...</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.secret-detection-settings {
|
||||
padding: var(--spacing-lg);
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
padding-bottom: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.settings-header__title h1 {
|
||||
margin: 0 0 var(--spacing-xs) 0;
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.settings-header__description {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.settings-header__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-switch__slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--color-background-tertiary);
|
||||
border-radius: 24px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-switch__slider::before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-switch__slider {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-switch__slider::before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
.toggle-switch input:disabled + .toggle-switch__slider {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toggle-switch__label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
background-color: var(--color-error-background);
|
||||
border: 1px solid var(--color-error);
|
||||
border-radius: var(--border-radius-md);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.error-banner__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-error);
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.error-banner__message {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.error-banner__dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-error);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-sm);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.settings-tabs__nav {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.settings-tabs__tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.settings-tabs__tab:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.settings-tabs__tab--active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.settings-tabs__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
background-color: var(--color-background-tertiary);
|
||||
border-radius: 10px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.settings-tabs__tab--active .settings-tabs__badge {
|
||||
background-color: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.saving-indicator {
|
||||
position: fixed;
|
||||
bottom: var(--spacing-lg);
|
||||
right: var(--spacing-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-background-secondary);
|
||||
border-radius: var(--border-radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.saving-indicator__spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.saving-indicator__text {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SecretDetectionSettingsComponent implements OnInit {
|
||||
readonly settingsService = inject(SecretDetectionSettingsService);
|
||||
|
||||
// Local state
|
||||
readonly activeTab = signal<'general' | 'exceptions' | 'alerts'>('general');
|
||||
|
||||
// Computed
|
||||
readonly exceptionCount = computed(() => this.settingsService.exceptions().length);
|
||||
|
||||
// TODO: Get from auth service
|
||||
readonly canFullReveal = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.settingsService.loadSettings();
|
||||
}
|
||||
|
||||
setActiveTab(tab: 'general' | 'exceptions' | 'alerts'): void {
|
||||
this.activeTab.set(tab);
|
||||
}
|
||||
|
||||
onEnabledChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.settingsService.setEnabled(input.checked);
|
||||
}
|
||||
|
||||
onPolicyChange(policy: RevelationPolicy): void {
|
||||
this.settingsService.updateRevelationPolicy(policy);
|
||||
}
|
||||
|
||||
onCategoriesChange(categories: SecretRuleCategory[]): void {
|
||||
this.settingsService.updateCategories(categories);
|
||||
}
|
||||
|
||||
onAddException(exception: Omit<SecretException, 'id' | 'createdAt' | 'createdBy'>): void {
|
||||
this.settingsService.addException(exception);
|
||||
}
|
||||
|
||||
onRemoveException(exceptionId: string): void {
|
||||
this.settingsService.removeException(exceptionId);
|
||||
}
|
||||
|
||||
onAlertSettingsChange(settings: AlertDestinationSettings): void {
|
||||
this.settingsService.updateAlertSettings(settings);
|
||||
}
|
||||
|
||||
onTestDestination(destinationId: string): void {
|
||||
this.settingsService.testDestination(destinationId).subscribe(result => {
|
||||
// Handle test result - could show toast or update UI
|
||||
console.log('Test result:', result);
|
||||
});
|
||||
}
|
||||
|
||||
dismissError(): void {
|
||||
// The error is cleared on next operation
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Secret Detection Routes.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-001 - Create secret-detection feature module
|
||||
*
|
||||
* Route configuration for secret detection feature.
|
||||
*/
|
||||
|
||||
import { Routes } from '@angular/router';
|
||||
import { SecretDetectionSettingsComponent } from './secret-detection-settings.component';
|
||||
import { SecretFindingsListComponent } from './secret-findings-list.component';
|
||||
|
||||
export const SECRET_DETECTION_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'settings',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
component: SecretDetectionSettingsComponent,
|
||||
title: 'Secret Detection Settings'
|
||||
},
|
||||
{
|
||||
path: 'findings',
|
||||
component: SecretFindingsListComponent,
|
||||
title: 'Secret Findings'
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,787 @@
|
||||
/**
|
||||
* Secret Findings List Component.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-005 - Create findings list component
|
||||
*
|
||||
* Component for displaying and filtering secret findings.
|
||||
*/
|
||||
|
||||
import { Component, OnInit, inject, computed, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SecretFindingsService } from './services/secret-findings.service';
|
||||
import { MaskedValueDisplayComponent } from './masked-value-display.component';
|
||||
import { FindingDetailDrawerComponent } from './finding-detail-drawer.component';
|
||||
import {
|
||||
SecretFinding,
|
||||
SecretFindingsFilter,
|
||||
SecretSeverity,
|
||||
SecretFindingStatus,
|
||||
SecretFindingsSortField,
|
||||
SecretFindingsSortDirection,
|
||||
SEVERITY_DISPLAY,
|
||||
STATUS_DISPLAY
|
||||
} from './models/secret-finding.models';
|
||||
import { SecretRuleCategory, RULE_CATEGORIES } from './models/secret-detection.models';
|
||||
|
||||
@Component({
|
||||
selector: 'stella-secret-findings-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MaskedValueDisplayComponent,
|
||||
FindingDetailDrawerComponent
|
||||
],
|
||||
template: `
|
||||
<div class="secret-findings">
|
||||
<header class="findings-header">
|
||||
<div class="findings-header__title">
|
||||
<h1>Secret Findings</h1>
|
||||
<div class="findings-header__stats">
|
||||
<span class="stat stat--open">{{ findingsService.openCount() }} open</span>
|
||||
<span class="stat">{{ findingsService.totalCount() }} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="findings-header__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--secondary"
|
||||
(click)="toggleFilters()">
|
||||
<span class="btn__icon">F</span>
|
||||
Filters
|
||||
@if (activeFilterCount() > 0) {
|
||||
<span class="btn__badge">{{ activeFilterCount() }}</span>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--secondary"
|
||||
(click)="exportFindings()">
|
||||
<span class="btn__icon">E</span>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (showFilters()) {
|
||||
<div class="filters-panel">
|
||||
<div class="filters-row">
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
class="filter-input"
|
||||
placeholder="Search by file, rule, type..."
|
||||
[value]="searchText()"
|
||||
(input)="onSearchChange($event)" />
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Severity</label>
|
||||
<div class="filter-checkboxes">
|
||||
@for (sev of severityOptions; track sev) {
|
||||
<label class="filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="isSeveritySelected(sev)"
|
||||
(change)="toggleSeverity(sev)" />
|
||||
<span class="filter-checkbox__label" [attr.data-severity]="sev">
|
||||
{{ SEVERITY_DISPLAY[sev].label }}
|
||||
</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Status</label>
|
||||
<div class="filter-checkboxes">
|
||||
@for (status of statusOptions; track status) {
|
||||
<label class="filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="isStatusSelected(status)"
|
||||
(change)="toggleStatus(status)" />
|
||||
<span class="filter-checkbox__label">
|
||||
{{ STATUS_DISPLAY[status].label }}
|
||||
</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">Category</label>
|
||||
<select
|
||||
class="filter-select"
|
||||
[value]="selectedCategory()"
|
||||
(change)="onCategoryChange($event)">
|
||||
<option value="">All Categories</option>
|
||||
@for (cat of categoryOptions; track cat.category) {
|
||||
<option [value]="cat.category">{{ cat.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (activeFilterCount() > 0) {
|
||||
<div class="filters-footer">
|
||||
<button type="button" class="btn btn--text" (click)="clearFilters()">
|
||||
Clear all filters
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="findings-table-container">
|
||||
@if (findingsService.loading()) {
|
||||
<div class="loading-overlay">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<table class="findings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="findings-table__th findings-table__th--severity">
|
||||
<button class="sort-btn" (click)="sortBy('severity')">
|
||||
Severity
|
||||
@if (currentSort() === 'severity') {
|
||||
<span class="sort-indicator">{{ sortDirection() === 'asc' ? '↑' : '↓' }}</span>
|
||||
}
|
||||
</button>
|
||||
</th>
|
||||
<th class="findings-table__th">Secret Type</th>
|
||||
<th class="findings-table__th">Location</th>
|
||||
<th class="findings-table__th">Value</th>
|
||||
<th class="findings-table__th findings-table__th--artifact">
|
||||
<button class="sort-btn" (click)="sortBy('artifactRef')">
|
||||
Artifact
|
||||
@if (currentSort() === 'artifactRef') {
|
||||
<span class="sort-indicator">{{ sortDirection() === 'asc' ? '↑' : '↓' }}</span>
|
||||
}
|
||||
</button>
|
||||
</th>
|
||||
<th class="findings-table__th findings-table__th--detected">
|
||||
<button class="sort-btn" (click)="sortBy('detectedAt')">
|
||||
Detected
|
||||
@if (currentSort() === 'detectedAt') {
|
||||
<span class="sort-indicator">{{ sortDirection() === 'asc' ? '↑' : '↓' }}</span>
|
||||
}
|
||||
</button>
|
||||
</th>
|
||||
<th class="findings-table__th findings-table__th--status">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (finding of findingsService.findings(); track finding.id) {
|
||||
<tr
|
||||
class="findings-table__row"
|
||||
[class.findings-table__row--selected]="findingsService.selectedFinding()?.id === finding.id"
|
||||
(click)="selectFinding(finding)">
|
||||
<td class="findings-table__td">
|
||||
<span
|
||||
class="severity-badge"
|
||||
[attr.data-severity]="finding.severity">
|
||||
{{ SEVERITY_DISPLAY[finding.severity].label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="findings-table__td">
|
||||
<div class="secret-type">
|
||||
<span class="secret-type__name">{{ finding.secretType }}</span>
|
||||
<span class="secret-type__rule">{{ finding.rule.ruleName }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="findings-table__td">
|
||||
<div class="location">
|
||||
<span class="location__file">{{ finding.location.filePath }}</span>
|
||||
<span class="location__line">Line {{ finding.location.lineNumber }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="findings-table__td">
|
||||
<stella-masked-value-display
|
||||
[value]="finding.maskedValue"
|
||||
[secretType]="finding.secretType" />
|
||||
</td>
|
||||
<td class="findings-table__td findings-table__td--artifact">
|
||||
<span class="artifact-ref" [title]="finding.artifactRef">
|
||||
{{ truncateArtifact(finding.artifactRef) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="findings-table__td">
|
||||
<span class="detected-time">
|
||||
{{ formatDate(finding.detectedAt) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="findings-table__td">
|
||||
<span
|
||||
class="status-badge"
|
||||
[attr.data-status]="finding.status">
|
||||
{{ STATUS_DISPLAY[finding.status].label }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="7" class="findings-table__empty">
|
||||
@if (findingsService.loading()) {
|
||||
Loading findings...
|
||||
} @else if (activeFilterCount() > 0) {
|
||||
No findings match your filters
|
||||
} @else {
|
||||
No secret findings detected
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (findingsService.totalPages() > 1) {
|
||||
<div class="pagination">
|
||||
<button
|
||||
type="button"
|
||||
class="pagination__btn"
|
||||
[disabled]="findingsService.currentPage() === 0"
|
||||
(click)="previousPage()">
|
||||
Previous
|
||||
</button>
|
||||
<span class="pagination__info">
|
||||
Page {{ findingsService.currentPage() + 1 }} of {{ findingsService.totalPages() }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="pagination__btn"
|
||||
[disabled]="!findingsService.hasMore()"
|
||||
(click)="nextPage()">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (findingsService.selectedFinding()) {
|
||||
<stella-finding-detail-drawer
|
||||
[finding]="findingsService.selectedFinding()!"
|
||||
(close)="closeFindingDetail()"
|
||||
(resolve)="resolveFinding($event)" />
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.secret-findings {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.findings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.findings-header__title h1 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.findings-header__stats {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.stat {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.stat--open {
|
||||
color: var(--color-warning);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.findings-header__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
background-color: var(--color-background-secondary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: var(--color-background-tertiary);
|
||||
}
|
||||
|
||||
.btn__icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
border-radius: 9px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.btn--text {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.filters-panel {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background-color: var(--color-background-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.filters-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.filter-input,
|
||||
.filter-select {
|
||||
padding: var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.filter-checkboxes {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.filter-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-checkbox__label {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.filters-footer {
|
||||
margin-top: var(--spacing-md);
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.findings-table-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.findings-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.findings-table__th {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
text-align: left;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
background-color: var(--color-background-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.sort-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sort-indicator {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.findings-table__row {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.findings-table__row:hover {
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
|
||||
.findings-table__row--selected {
|
||||
background-color: var(--color-primary-background);
|
||||
}
|
||||
|
||||
.findings-table__td {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: var(--font-size-sm);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.findings-table__empty {
|
||||
padding: var(--spacing-xl);
|
||||
text-align: center;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.severity-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.severity-badge[data-severity="critical"] {
|
||||
background-color: var(--color-critical-background);
|
||||
color: var(--color-critical);
|
||||
}
|
||||
|
||||
.severity-badge[data-severity="high"] {
|
||||
background-color: var(--color-high-background);
|
||||
color: var(--color-high);
|
||||
}
|
||||
|
||||
.severity-badge[data-severity="medium"] {
|
||||
background-color: var(--color-medium-background);
|
||||
color: var(--color-medium);
|
||||
}
|
||||
|
||||
.severity-badge[data-severity="low"] {
|
||||
background-color: var(--color-low-background);
|
||||
color: var(--color-low);
|
||||
}
|
||||
|
||||
.severity-badge[data-severity="info"] {
|
||||
background-color: var(--color-info-background);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.secret-type {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.secret-type__name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.secret-type__rule {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.location {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.location__file {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.location__line {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.artifact-ref {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.detected-time {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge[data-status="open"] {
|
||||
background-color: var(--color-warning-background);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.status-badge[data-status="resolved"] {
|
||||
background-color: var(--color-success-background);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.status-badge[data-status="excepted"] {
|
||||
background-color: var(--color-info-background);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.status-badge[data-status="false-positive"] {
|
||||
background-color: var(--color-muted-background);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.pagination__btn {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-background-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination__btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination__info {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SecretFindingsListComponent implements OnInit {
|
||||
readonly findingsService = inject(SecretFindingsService);
|
||||
|
||||
// Static data
|
||||
readonly SEVERITY_DISPLAY = SEVERITY_DISPLAY;
|
||||
readonly STATUS_DISPLAY = STATUS_DISPLAY;
|
||||
readonly severityOptions: SecretSeverity[] = ['critical', 'high', 'medium', 'low', 'info'];
|
||||
readonly statusOptions: SecretFindingStatus[] = ['open', 'resolved', 'excepted', 'false-positive'];
|
||||
readonly categoryOptions = RULE_CATEGORIES;
|
||||
|
||||
// Local state
|
||||
readonly showFilters = signal(false);
|
||||
readonly searchText = signal('');
|
||||
readonly selectedSeverities = signal<SecretSeverity[]>([]);
|
||||
readonly selectedStatuses = signal<SecretFindingStatus[]>([]);
|
||||
readonly selectedCategory = signal<SecretRuleCategory | ''>('');
|
||||
readonly currentSort = signal<SecretFindingsSortField>('severity');
|
||||
readonly sortDirection = signal<SecretFindingsSortDirection>('asc');
|
||||
|
||||
// Computed
|
||||
readonly activeFilterCount = computed(() => {
|
||||
let count = 0;
|
||||
if (this.searchText()) count++;
|
||||
if (this.selectedSeverities().length) count++;
|
||||
if (this.selectedStatuses().length) count++;
|
||||
if (this.selectedCategory()) count++;
|
||||
return count;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.findingsService.loadFindings();
|
||||
this.findingsService.loadCounts();
|
||||
}
|
||||
|
||||
toggleFilters(): void {
|
||||
this.showFilters.update(v => !v);
|
||||
}
|
||||
|
||||
onSearchChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.searchText.set(input.value);
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
isSeveritySelected(severity: SecretSeverity): boolean {
|
||||
return this.selectedSeverities().includes(severity);
|
||||
}
|
||||
|
||||
toggleSeverity(severity: SecretSeverity): void {
|
||||
this.selectedSeverities.update(severities => {
|
||||
const index = severities.indexOf(severity);
|
||||
if (index >= 0) {
|
||||
return severities.filter(s => s !== severity);
|
||||
}
|
||||
return [...severities, severity];
|
||||
});
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
isStatusSelected(status: SecretFindingStatus): boolean {
|
||||
return this.selectedStatuses().includes(status);
|
||||
}
|
||||
|
||||
toggleStatus(status: SecretFindingStatus): void {
|
||||
this.selectedStatuses.update(statuses => {
|
||||
const index = statuses.indexOf(status);
|
||||
if (index >= 0) {
|
||||
return statuses.filter(s => s !== status);
|
||||
}
|
||||
return [...statuses, status];
|
||||
});
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
onCategoryChange(event: Event): void {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
this.selectedCategory.set(select.value as SecretRuleCategory | '');
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
applyFilters(): void {
|
||||
const filter: SecretFindingsFilter = {};
|
||||
if (this.searchText()) filter.search = this.searchText();
|
||||
if (this.selectedSeverities().length) filter.severity = this.selectedSeverities();
|
||||
if (this.selectedStatuses().length) filter.status = this.selectedStatuses();
|
||||
if (this.selectedCategory()) filter.category = [this.selectedCategory() as SecretRuleCategory];
|
||||
this.findingsService.setFilter(filter);
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.searchText.set('');
|
||||
this.selectedSeverities.set([]);
|
||||
this.selectedStatuses.set([]);
|
||||
this.selectedCategory.set('');
|
||||
this.findingsService.setFilter({});
|
||||
}
|
||||
|
||||
sortBy(field: SecretFindingsSortField): void {
|
||||
if (this.currentSort() === field) {
|
||||
this.sortDirection.update(d => d === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
this.currentSort.set(field);
|
||||
this.sortDirection.set('asc');
|
||||
}
|
||||
this.findingsService.setSort(this.currentSort(), this.sortDirection());
|
||||
}
|
||||
|
||||
selectFinding(finding: SecretFinding): void {
|
||||
this.findingsService.selectFinding(finding);
|
||||
}
|
||||
|
||||
closeFindingDetail(): void {
|
||||
this.findingsService.selectFinding(null);
|
||||
}
|
||||
|
||||
resolveFinding(event: { status: SecretFindingStatus; reason: string }): void {
|
||||
const selected = this.findingsService.selectedFinding();
|
||||
if (selected) {
|
||||
this.findingsService.resolveFinding(selected.id, event.status, event.reason);
|
||||
}
|
||||
}
|
||||
|
||||
previousPage(): void {
|
||||
this.findingsService.setPage(this.findingsService.currentPage() - 1);
|
||||
}
|
||||
|
||||
nextPage(): void {
|
||||
this.findingsService.setPage(this.findingsService.currentPage() + 1);
|
||||
}
|
||||
|
||||
exportFindings(): void {
|
||||
// TODO: Implement export functionality
|
||||
console.log('Export findings');
|
||||
}
|
||||
|
||||
truncateArtifact(ref: string): string {
|
||||
if (ref.length > 40) {
|
||||
return ref.slice(0, 20) + '...' + ref.slice(-17);
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* Secret Detection Settings Service.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-001 - Create secret-detection feature module
|
||||
*
|
||||
* Service for managing secret detection configuration.
|
||||
*/
|
||||
|
||||
import { Injectable, InjectionToken, inject, signal, computed } from '@angular/core';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { Observable, of, catchError, map, tap, delay, BehaviorSubject } from 'rxjs';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
SecretDetectionSettings,
|
||||
SecretRuleCategory,
|
||||
SecretException,
|
||||
RULE_CATEGORIES,
|
||||
RuleCategoryInfo,
|
||||
DEFAULT_SECRET_DETECTION_SETTINGS
|
||||
} from '../models/secret-detection.models';
|
||||
import { AlertDestinationSettings, AlertDestination, AlertTestResult } from '../models/alert-destination.models';
|
||||
|
||||
/**
|
||||
* Injection token for Settings API client.
|
||||
*/
|
||||
export const SECRET_DETECTION_SETTINGS_API = new InjectionToken<SecretDetectionSettingsApi>('SECRET_DETECTION_SETTINGS_API');
|
||||
|
||||
/**
|
||||
* Settings API interface.
|
||||
*/
|
||||
export interface SecretDetectionSettingsApi {
|
||||
/**
|
||||
* Get current settings for the tenant.
|
||||
*/
|
||||
getSettings(): Observable<SecretDetectionSettings>;
|
||||
|
||||
/**
|
||||
* Update settings.
|
||||
*/
|
||||
updateSettings(settings: Partial<SecretDetectionSettings>): Observable<SecretDetectionSettings>;
|
||||
|
||||
/**
|
||||
* Enable/disable secret detection.
|
||||
*/
|
||||
setEnabled(enabled: boolean): Observable<SecretDetectionSettings>;
|
||||
|
||||
/**
|
||||
* Update enabled rule categories.
|
||||
*/
|
||||
updateRuleCategories(categories: SecretRuleCategory[]): Observable<SecretDetectionSettings>;
|
||||
|
||||
/**
|
||||
* Add an exception.
|
||||
*/
|
||||
addException(exception: Omit<SecretException, 'id' | 'createdAt' | 'createdBy'>): Observable<SecretException>;
|
||||
|
||||
/**
|
||||
* Remove an exception.
|
||||
*/
|
||||
removeException(exceptionId: string): Observable<void>;
|
||||
|
||||
/**
|
||||
* Update alert settings.
|
||||
*/
|
||||
updateAlertSettings(settings: AlertDestinationSettings): Observable<AlertDestinationSettings>;
|
||||
|
||||
/**
|
||||
* Test an alert destination.
|
||||
*/
|
||||
testAlertDestination(destinationId: string): Observable<AlertTestResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP-based Settings API client.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HttpSecretDetectionSettingsApi implements SecretDetectionSettingsApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = '/api/v1/config/secret-detection';
|
||||
|
||||
getSettings(): Observable<SecretDetectionSettings> {
|
||||
return this.http.get<SecretDetectionSettings>(this.baseUrl);
|
||||
}
|
||||
|
||||
updateSettings(settings: Partial<SecretDetectionSettings>): Observable<SecretDetectionSettings> {
|
||||
return this.http.patch<SecretDetectionSettings>(this.baseUrl, settings);
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): Observable<SecretDetectionSettings> {
|
||||
return this.http.patch<SecretDetectionSettings>(this.baseUrl, { enabled });
|
||||
}
|
||||
|
||||
updateRuleCategories(categories: SecretRuleCategory[]): Observable<SecretDetectionSettings> {
|
||||
return this.http.patch<SecretDetectionSettings>(this.baseUrl, { enabledRuleCategories: categories });
|
||||
}
|
||||
|
||||
addException(exception: Omit<SecretException, 'id' | 'createdAt' | 'createdBy'>): Observable<SecretException> {
|
||||
return this.http.post<SecretException>(`${this.baseUrl}/exceptions`, exception);
|
||||
}
|
||||
|
||||
removeException(exceptionId: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/exceptions/${encodeURIComponent(exceptionId)}`);
|
||||
}
|
||||
|
||||
updateAlertSettings(settings: AlertDestinationSettings): Observable<AlertDestinationSettings> {
|
||||
return this.http.put<AlertDestinationSettings>(`${this.baseUrl}/alerts`, settings);
|
||||
}
|
||||
|
||||
testAlertDestination(destinationId: string): Observable<AlertTestResult> {
|
||||
return this.http.post<AlertTestResult>(`${this.baseUrl}/alerts/destinations/${encodeURIComponent(destinationId)}/test`, {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Settings API for development/testing.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MockSecretDetectionSettingsApi implements SecretDetectionSettingsApi {
|
||||
private settings: SecretDetectionSettings = { ...DEFAULT_SECRET_DETECTION_SETTINGS };
|
||||
|
||||
getSettings(): Observable<SecretDetectionSettings> {
|
||||
return of({ ...this.settings }).pipe(delay(200));
|
||||
}
|
||||
|
||||
updateSettings(updates: Partial<SecretDetectionSettings>): Observable<SecretDetectionSettings> {
|
||||
this.settings = { ...this.settings, ...updates, modifiedAt: new Date().toISOString() };
|
||||
return of({ ...this.settings }).pipe(delay(200));
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): Observable<SecretDetectionSettings> {
|
||||
return this.updateSettings({ enabled });
|
||||
}
|
||||
|
||||
updateRuleCategories(categories: SecretRuleCategory[]): Observable<SecretDetectionSettings> {
|
||||
return this.updateSettings({ enabledRuleCategories: categories });
|
||||
}
|
||||
|
||||
addException(exception: Omit<SecretException, 'id' | 'createdAt' | 'createdBy'>): Observable<SecretException> {
|
||||
const newException: SecretException = {
|
||||
...exception,
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: 'current-user'
|
||||
};
|
||||
this.settings = {
|
||||
...this.settings,
|
||||
exceptions: [...this.settings.exceptions, newException],
|
||||
modifiedAt: new Date().toISOString()
|
||||
};
|
||||
return of(newException).pipe(delay(200));
|
||||
}
|
||||
|
||||
removeException(exceptionId: string): Observable<void> {
|
||||
this.settings = {
|
||||
...this.settings,
|
||||
exceptions: this.settings.exceptions.filter(e => e.id !== exceptionId),
|
||||
modifiedAt: new Date().toISOString()
|
||||
};
|
||||
return of(void 0).pipe(delay(200));
|
||||
}
|
||||
|
||||
updateAlertSettings(settings: AlertDestinationSettings): Observable<AlertDestinationSettings> {
|
||||
this.settings = { ...this.settings, alertSettings: settings, modifiedAt: new Date().toISOString() };
|
||||
return of(settings).pipe(delay(200));
|
||||
}
|
||||
|
||||
testAlertDestination(destinationId: string): Observable<AlertTestResult> {
|
||||
// Simulate random success/failure for testing
|
||||
const success = Math.random() > 0.2;
|
||||
return of({
|
||||
success,
|
||||
error: success ? undefined : 'Connection timed out',
|
||||
testedAt: new Date().toISOString(),
|
||||
responseTimeMs: Math.floor(Math.random() * 500) + 100
|
||||
}).pipe(delay(1000));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret Detection Settings Service.
|
||||
* Manages state and operations for secret detection configuration.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SecretDetectionSettingsService {
|
||||
private readonly api = inject(SECRET_DETECTION_SETTINGS_API);
|
||||
|
||||
// State
|
||||
private readonly _settings = signal<SecretDetectionSettings | null>(null);
|
||||
private readonly _loading = signal(false);
|
||||
private readonly _error = signal<string | null>(null);
|
||||
private readonly _saving = signal(false);
|
||||
|
||||
// Public signals
|
||||
readonly settings = this._settings.asReadonly();
|
||||
readonly loading = this._loading.asReadonly();
|
||||
readonly error = this._error.asReadonly();
|
||||
readonly saving = this._saving.asReadonly();
|
||||
|
||||
// Computed
|
||||
readonly isEnabled = computed(() => this._settings()?.enabled ?? false);
|
||||
readonly enabledCategories = computed(() => this._settings()?.enabledRuleCategories ?? []);
|
||||
readonly exceptions = computed(() => this._settings()?.exceptions ?? []);
|
||||
readonly alertSettings = computed(() => this._settings()?.alertSettings ?? null);
|
||||
readonly availableCategories = computed(() => RULE_CATEGORIES);
|
||||
|
||||
/**
|
||||
* Load settings from the API.
|
||||
*/
|
||||
loadSettings(): void {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
this.api.getSettings().pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
this._error.set(err.message || 'Failed to load settings');
|
||||
return of(null);
|
||||
})
|
||||
).subscribe(settings => {
|
||||
if (settings) {
|
||||
this._settings.set(settings);
|
||||
}
|
||||
this._loading.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle secret detection enabled state.
|
||||
*/
|
||||
setEnabled(enabled: boolean): void {
|
||||
this._saving.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
this.api.setEnabled(enabled).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
this._error.set(err.message || 'Failed to update settings');
|
||||
return of(null);
|
||||
})
|
||||
).subscribe(settings => {
|
||||
if (settings) {
|
||||
this._settings.set(settings);
|
||||
}
|
||||
this._saving.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update enabled rule categories.
|
||||
*/
|
||||
updateCategories(categories: SecretRuleCategory[]): void {
|
||||
this._saving.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
this.api.updateRuleCategories(categories).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
this._error.set(err.message || 'Failed to update categories');
|
||||
return of(null);
|
||||
})
|
||||
).subscribe(settings => {
|
||||
if (settings) {
|
||||
this._settings.set(settings);
|
||||
}
|
||||
this._saving.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update revelation policy.
|
||||
*/
|
||||
updateRevelationPolicy(policy: SecretDetectionSettings['revelationPolicy']): void {
|
||||
this._saving.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
this.api.updateSettings({ revelationPolicy: policy }).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
this._error.set(err.message || 'Failed to update revelation policy');
|
||||
return of(null);
|
||||
})
|
||||
).subscribe(settings => {
|
||||
if (settings) {
|
||||
this._settings.set(settings);
|
||||
}
|
||||
this._saving.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an exception.
|
||||
*/
|
||||
addException(exception: Omit<SecretException, 'id' | 'createdAt' | 'createdBy'>): void {
|
||||
this._saving.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
this.api.addException(exception).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
this._error.set(err.message || 'Failed to add exception');
|
||||
return of(null);
|
||||
})
|
||||
).subscribe(newException => {
|
||||
if (newException) {
|
||||
const current = this._settings();
|
||||
if (current) {
|
||||
this._settings.set({
|
||||
...current,
|
||||
exceptions: [...current.exceptions, newException]
|
||||
});
|
||||
}
|
||||
}
|
||||
this._saving.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an exception.
|
||||
*/
|
||||
removeException(exceptionId: string): void {
|
||||
this._saving.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
this.api.removeException(exceptionId).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
this._error.set(err.message || 'Failed to remove exception');
|
||||
return of(null);
|
||||
})
|
||||
).subscribe(() => {
|
||||
const current = this._settings();
|
||||
if (current) {
|
||||
this._settings.set({
|
||||
...current,
|
||||
exceptions: current.exceptions.filter(e => e.id !== exceptionId)
|
||||
});
|
||||
}
|
||||
this._saving.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update alert settings.
|
||||
*/
|
||||
updateAlertSettings(settings: AlertDestinationSettings): void {
|
||||
this._saving.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
this.api.updateAlertSettings(settings).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
this._error.set(err.message || 'Failed to update alert settings');
|
||||
return of(null);
|
||||
})
|
||||
).subscribe(alertSettings => {
|
||||
if (alertSettings) {
|
||||
const current = this._settings();
|
||||
if (current) {
|
||||
this._settings.set({ ...current, alertSettings });
|
||||
}
|
||||
}
|
||||
this._saving.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Test an alert destination.
|
||||
*/
|
||||
testDestination(destinationId: string): Observable<AlertTestResult> {
|
||||
return this.api.testAlertDestination(destinationId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* Secret Findings Service.
|
||||
* Sprint: SPRINT_20260104_008_FE
|
||||
* Task: SDU-001 - Create secret-detection feature module
|
||||
*
|
||||
* Service for querying and managing secret findings.
|
||||
*/
|
||||
|
||||
import { Injectable, InjectionToken, inject, signal, computed } from '@angular/core';
|
||||
import { HttpClient, HttpParams, HttpErrorResponse } from '@angular/common/http';
|
||||
import { Observable, of, catchError, delay } from 'rxjs';
|
||||
import {
|
||||
SecretFinding,
|
||||
SecretFindingsFilter,
|
||||
SecretFindingsPage,
|
||||
SecretFindingsSortField,
|
||||
SecretFindingsSortDirection,
|
||||
SecretFindingStatus
|
||||
} from '../models/secret-finding.models';
|
||||
|
||||
/**
|
||||
* Injection token for Findings API client.
|
||||
*/
|
||||
export const SECRET_FINDINGS_API = new InjectionToken<SecretFindingsApi>('SECRET_FINDINGS_API');
|
||||
|
||||
/**
|
||||
* Resolution request for a finding.
|
||||
*/
|
||||
export interface ResolveFindingRequest {
|
||||
/** New status */
|
||||
status: SecretFindingStatus;
|
||||
/** Reason for resolution */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findings API interface.
|
||||
*/
|
||||
export interface SecretFindingsApi {
|
||||
/**
|
||||
* Get paginated findings.
|
||||
*/
|
||||
getFindings(
|
||||
filter: SecretFindingsFilter,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
sortField: SecretFindingsSortField,
|
||||
sortDirection: SecretFindingsSortDirection
|
||||
): Observable<SecretFindingsPage>;
|
||||
|
||||
/**
|
||||
* Get a single finding by ID.
|
||||
*/
|
||||
getFinding(findingId: string): Observable<SecretFinding>;
|
||||
|
||||
/**
|
||||
* Resolve a finding.
|
||||
*/
|
||||
resolveFinding(findingId: string, request: ResolveFindingRequest): Observable<SecretFinding>;
|
||||
|
||||
/**
|
||||
* Bulk resolve findings.
|
||||
*/
|
||||
bulkResolveFindingst(findingIds: string[], request: ResolveFindingRequest): Observable<SecretFinding[]>;
|
||||
|
||||
/**
|
||||
* Get finding counts by status.
|
||||
*/
|
||||
getFindingCounts(): Observable<Record<SecretFindingStatus, number>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP-based Findings API client.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HttpSecretFindingsApi implements SecretFindingsApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = '/api/v1/secrets/findings';
|
||||
|
||||
getFindings(
|
||||
filter: SecretFindingsFilter,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
sortField: SecretFindingsSortField,
|
||||
sortDirection: SecretFindingsSortDirection
|
||||
): Observable<SecretFindingsPage> {
|
||||
let params = new HttpParams()
|
||||
.set('page', page.toString())
|
||||
.set('pageSize', pageSize.toString())
|
||||
.set('sortField', sortField)
|
||||
.set('sortDirection', sortDirection);
|
||||
|
||||
if (filter.severity?.length) {
|
||||
params = params.set('severity', filter.severity.join(','));
|
||||
}
|
||||
if (filter.status?.length) {
|
||||
params = params.set('status', filter.status.join(','));
|
||||
}
|
||||
if (filter.category?.length) {
|
||||
params = params.set('category', filter.category.join(','));
|
||||
}
|
||||
if (filter.artifactRef) {
|
||||
params = params.set('artifactRef', filter.artifactRef);
|
||||
}
|
||||
if (filter.search) {
|
||||
params = params.set('search', filter.search);
|
||||
}
|
||||
if (filter.detectedAfter) {
|
||||
params = params.set('detectedAfter', filter.detectedAfter);
|
||||
}
|
||||
if (filter.detectedBefore) {
|
||||
params = params.set('detectedBefore', filter.detectedBefore);
|
||||
}
|
||||
|
||||
return this.http.get<SecretFindingsPage>(this.baseUrl, { params });
|
||||
}
|
||||
|
||||
getFinding(findingId: string): Observable<SecretFinding> {
|
||||
return this.http.get<SecretFinding>(`${this.baseUrl}/${encodeURIComponent(findingId)}`);
|
||||
}
|
||||
|
||||
resolveFinding(findingId: string, request: ResolveFindingRequest): Observable<SecretFinding> {
|
||||
return this.http.post<SecretFinding>(`${this.baseUrl}/${encodeURIComponent(findingId)}/resolve`, request);
|
||||
}
|
||||
|
||||
bulkResolveFindingst(findingIds: string[], request: ResolveFindingRequest): Observable<SecretFinding[]> {
|
||||
return this.http.post<SecretFinding[]>(`${this.baseUrl}/bulk-resolve`, {
|
||||
findingIds,
|
||||
...request
|
||||
});
|
||||
}
|
||||
|
||||
getFindingCounts(): Observable<Record<SecretFindingStatus, number>> {
|
||||
return this.http.get<Record<SecretFindingStatus, number>>(`${this.baseUrl}/counts`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Findings API for development/testing.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MockSecretFindingsApi implements SecretFindingsApi {
|
||||
private findings: SecretFinding[] = [
|
||||
{
|
||||
id: 'finding-001',
|
||||
scanDigest: 'sha256:abc123',
|
||||
artifactDigest: 'sha256:def456',
|
||||
artifactRef: 'myregistry.io/myapp:v1.0.0',
|
||||
severity: 'critical',
|
||||
status: 'open',
|
||||
rule: {
|
||||
ruleId: 'aws-access-key-id',
|
||||
ruleName: 'AWS Access Key ID',
|
||||
category: 'aws',
|
||||
description: 'Detects AWS Access Key IDs'
|
||||
},
|
||||
location: {
|
||||
filePath: 'config/settings.yaml',
|
||||
lineNumber: 42,
|
||||
columnNumber: 15,
|
||||
context: 'aws_access_key: AKIA****WXYZ'
|
||||
},
|
||||
maskedValue: 'AKIA****WXYZ',
|
||||
secretType: 'AWS Access Key ID',
|
||||
detectedAt: '2026-01-04T10:30:00Z',
|
||||
lastSeenAt: '2026-01-04T10:30:00Z',
|
||||
occurrenceCount: 1,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
resolutionReason: null
|
||||
},
|
||||
{
|
||||
id: 'finding-002',
|
||||
scanDigest: 'sha256:abc123',
|
||||
artifactDigest: 'sha256:def456',
|
||||
artifactRef: 'myregistry.io/myapp:v1.0.0',
|
||||
severity: 'high',
|
||||
status: 'open',
|
||||
rule: {
|
||||
ruleId: 'github-pat',
|
||||
ruleName: 'GitHub Personal Access Token',
|
||||
category: 'github',
|
||||
description: 'Detects GitHub Personal Access Tokens'
|
||||
},
|
||||
location: {
|
||||
filePath: '.github/workflows/deploy.yml',
|
||||
lineNumber: 28,
|
||||
columnNumber: 10,
|
||||
context: 'token: ghp_****abcd'
|
||||
},
|
||||
maskedValue: 'ghp_****abcd',
|
||||
secretType: 'GitHub PAT',
|
||||
detectedAt: '2026-01-04T10:32:00Z',
|
||||
lastSeenAt: '2026-01-04T10:32:00Z',
|
||||
occurrenceCount: 2,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
resolutionReason: null
|
||||
},
|
||||
{
|
||||
id: 'finding-003',
|
||||
scanDigest: 'sha256:abc123',
|
||||
artifactDigest: 'sha256:ghi789',
|
||||
artifactRef: 'myregistry.io/api-service:v2.1.0',
|
||||
severity: 'medium',
|
||||
status: 'excepted',
|
||||
rule: {
|
||||
ruleId: 'private-key-rsa',
|
||||
ruleName: 'RSA Private Key',
|
||||
category: 'private-keys',
|
||||
description: 'Detects RSA private keys'
|
||||
},
|
||||
location: {
|
||||
filePath: 'test/fixtures/test-key.pem',
|
||||
lineNumber: 1,
|
||||
columnNumber: 1,
|
||||
context: '-----BEGIN RSA PRIVATE KEY-----'
|
||||
},
|
||||
maskedValue: '[REDACTED]',
|
||||
secretType: 'RSA Private Key',
|
||||
detectedAt: '2026-01-03T15:00:00Z',
|
||||
lastSeenAt: '2026-01-04T10:30:00Z',
|
||||
occurrenceCount: 5,
|
||||
resolvedBy: 'admin@example.com',
|
||||
resolvedAt: '2026-01-03T16:00:00Z',
|
||||
resolutionReason: 'Test fixture - not a real key'
|
||||
}
|
||||
];
|
||||
|
||||
getFindings(
|
||||
filter: SecretFindingsFilter,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
sortField: SecretFindingsSortField,
|
||||
sortDirection: SecretFindingsSortDirection
|
||||
): Observable<SecretFindingsPage> {
|
||||
let filtered = [...this.findings];
|
||||
|
||||
// Apply filters
|
||||
if (filter.severity?.length) {
|
||||
filtered = filtered.filter(f => filter.severity!.includes(f.severity));
|
||||
}
|
||||
if (filter.status?.length) {
|
||||
filtered = filtered.filter(f => filter.status!.includes(f.status));
|
||||
}
|
||||
if (filter.category?.length) {
|
||||
filtered = filtered.filter(f => filter.category!.includes(f.rule.category));
|
||||
}
|
||||
if (filter.artifactRef) {
|
||||
filtered = filtered.filter(f => f.artifactRef.includes(filter.artifactRef!));
|
||||
}
|
||||
if (filter.search) {
|
||||
const search = filter.search.toLowerCase();
|
||||
filtered = filtered.filter(f =>
|
||||
f.location.filePath.toLowerCase().includes(search) ||
|
||||
f.rule.ruleName.toLowerCase().includes(search) ||
|
||||
f.secretType.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
filtered.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortField) {
|
||||
case 'severity':
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
||||
comparison = severityOrder[a.severity] - severityOrder[b.severity];
|
||||
break;
|
||||
case 'detectedAt':
|
||||
comparison = new Date(a.detectedAt).getTime() - new Date(b.detectedAt).getTime();
|
||||
break;
|
||||
case 'artifactRef':
|
||||
comparison = a.artifactRef.localeCompare(b.artifactRef);
|
||||
break;
|
||||
case 'category':
|
||||
comparison = a.rule.category.localeCompare(b.rule.category);
|
||||
break;
|
||||
case 'occurrenceCount':
|
||||
comparison = a.occurrenceCount - b.occurrenceCount;
|
||||
break;
|
||||
}
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
// Paginate
|
||||
const start = page * pageSize;
|
||||
const items = filtered.slice(start, start + pageSize);
|
||||
|
||||
return of({
|
||||
items,
|
||||
totalCount: filtered.length,
|
||||
page,
|
||||
pageSize,
|
||||
hasMore: start + pageSize < filtered.length
|
||||
}).pipe(delay(200));
|
||||
}
|
||||
|
||||
getFinding(findingId: string): Observable<SecretFinding> {
|
||||
const finding = this.findings.find(f => f.id === findingId);
|
||||
if (!finding) {
|
||||
throw new Error(`Finding not found: ${findingId}`);
|
||||
}
|
||||
return of(finding).pipe(delay(100));
|
||||
}
|
||||
|
||||
resolveFinding(findingId: string, request: ResolveFindingRequest): Observable<SecretFinding> {
|
||||
const index = this.findings.findIndex(f => f.id === findingId);
|
||||
if (index === -1) {
|
||||
throw new Error(`Finding not found: ${findingId}`);
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
this.findings[index] = {
|
||||
...this.findings[index],
|
||||
status: request.status,
|
||||
resolvedBy: 'current-user@example.com',
|
||||
resolvedAt: now,
|
||||
resolutionReason: request.reason
|
||||
};
|
||||
return of(this.findings[index]).pipe(delay(200));
|
||||
}
|
||||
|
||||
bulkResolveFindingst(findingIds: string[], request: ResolveFindingRequest): Observable<SecretFinding[]> {
|
||||
const now = new Date().toISOString();
|
||||
const resolved: SecretFinding[] = [];
|
||||
for (const id of findingIds) {
|
||||
const index = this.findings.findIndex(f => f.id === id);
|
||||
if (index !== -1) {
|
||||
this.findings[index] = {
|
||||
...this.findings[index],
|
||||
status: request.status,
|
||||
resolvedBy: 'current-user@example.com',
|
||||
resolvedAt: now,
|
||||
resolutionReason: request.reason
|
||||
};
|
||||
resolved.push(this.findings[index]);
|
||||
}
|
||||
}
|
||||
return of(resolved).pipe(delay(300));
|
||||
}
|
||||
|
||||
getFindingCounts(): Observable<Record<SecretFindingStatus, number>> {
|
||||
const counts: Record<SecretFindingStatus, number> = {
|
||||
open: 0,
|
||||
resolved: 0,
|
||||
excepted: 0,
|
||||
'false-positive': 0
|
||||
};
|
||||
for (const finding of this.findings) {
|
||||
counts[finding.status]++;
|
||||
}
|
||||
return of(counts).pipe(delay(100));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret Findings Service.
|
||||
* Manages state and operations for secret findings.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SecretFindingsService {
|
||||
private readonly api = inject(SECRET_FINDINGS_API);
|
||||
|
||||
// State
|
||||
private readonly _findings = signal<SecretFinding[]>([]);
|
||||
private readonly _totalCount = signal(0);
|
||||
private readonly _loading = signal(false);
|
||||
private readonly _error = signal<string | null>(null);
|
||||
private readonly _currentPage = signal(0);
|
||||
private readonly _pageSize = signal(20);
|
||||
private readonly _filter = signal<SecretFindingsFilter>({});
|
||||
private readonly _sortField = signal<SecretFindingsSortField>('severity');
|
||||
private readonly _sortDirection = signal<SecretFindingsSortDirection>('asc');
|
||||
private readonly _selectedFinding = signal<SecretFinding | null>(null);
|
||||
private readonly _counts = signal<Record<SecretFindingStatus, number>>({
|
||||
open: 0,
|
||||
resolved: 0,
|
||||
excepted: 0,
|
||||
'false-positive': 0
|
||||
});
|
||||
|
||||
// Public signals
|
||||
readonly findings = this._findings.asReadonly();
|
||||
readonly totalCount = this._totalCount.asReadonly();
|
||||
readonly loading = this._loading.asReadonly();
|
||||
readonly error = this._error.asReadonly();
|
||||
readonly currentPage = this._currentPage.asReadonly();
|
||||
readonly pageSize = this._pageSize.asReadonly();
|
||||
readonly filter = this._filter.asReadonly();
|
||||
readonly sortField = this._sortField.asReadonly();
|
||||
readonly sortDirection = this._sortDirection.asReadonly();
|
||||
readonly selectedFinding = this._selectedFinding.asReadonly();
|
||||
readonly counts = this._counts.asReadonly();
|
||||
|
||||
// Computed
|
||||
readonly hasMore = computed(() => {
|
||||
const start = this._currentPage() * this._pageSize();
|
||||
return start + this._pageSize() < this._totalCount();
|
||||
});
|
||||
readonly openCount = computed(() => this._counts().open);
|
||||
readonly totalPages = computed(() => Math.ceil(this._totalCount() / this._pageSize()));
|
||||
|
||||
/**
|
||||
* Load findings with current filter and pagination.
|
||||
*/
|
||||
loadFindings(): void {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
this.api.getFindings(
|
||||
this._filter(),
|
||||
this._currentPage(),
|
||||
this._pageSize(),
|
||||
this._sortField(),
|
||||
this._sortDirection()
|
||||
).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
this._error.set(err.message || 'Failed to load findings');
|
||||
return of(null);
|
||||
})
|
||||
).subscribe(page => {
|
||||
if (page) {
|
||||
this._findings.set(page.items);
|
||||
this._totalCount.set(page.totalCount);
|
||||
}
|
||||
this._loading.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load finding counts.
|
||||
*/
|
||||
loadCounts(): void {
|
||||
this.api.getFindingCounts().pipe(
|
||||
catchError(() => of(null))
|
||||
).subscribe(counts => {
|
||||
if (counts) {
|
||||
this._counts.set(counts);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update filter and reload.
|
||||
*/
|
||||
setFilter(filter: SecretFindingsFilter): void {
|
||||
this._filter.set(filter);
|
||||
this._currentPage.set(0);
|
||||
this.loadFindings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sort and reload.
|
||||
*/
|
||||
setSort(field: SecretFindingsSortField, direction: SecretFindingsSortDirection): void {
|
||||
this._sortField.set(field);
|
||||
this._sortDirection.set(direction);
|
||||
this.loadFindings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to page.
|
||||
*/
|
||||
setPage(page: number): void {
|
||||
this._currentPage.set(page);
|
||||
this.loadFindings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a finding for detail view.
|
||||
*/
|
||||
selectFinding(finding: SecretFinding | null): void {
|
||||
this._selectedFinding.set(finding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a specific finding by ID.
|
||||
*/
|
||||
loadFinding(findingId: string): void {
|
||||
this._loading.set(true);
|
||||
this.api.getFinding(findingId).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
this._error.set(err.message || 'Failed to load finding');
|
||||
return of(null);
|
||||
})
|
||||
).subscribe(finding => {
|
||||
if (finding) {
|
||||
this._selectedFinding.set(finding);
|
||||
}
|
||||
this._loading.set(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a finding.
|
||||
*/
|
||||
resolveFinding(findingId: string, status: SecretFindingStatus, reason: string): void {
|
||||
this._loading.set(true);
|
||||
this.api.resolveFinding(findingId, { status, reason }).pipe(
|
||||
catchError((err: HttpErrorResponse) => {
|
||||
this._error.set(err.message || 'Failed to resolve finding');
|
||||
return of(null);
|
||||
})
|
||||
).subscribe(updated => {
|
||||
if (updated) {
|
||||
// Update in list
|
||||
this._findings.update(findings =>
|
||||
findings.map(f => f.id === findingId ? updated : f)
|
||||
);
|
||||
// Update selected if same
|
||||
if (this._selectedFinding()?.id === findingId) {
|
||||
this._selectedFinding.set(updated);
|
||||
}
|
||||
// Reload counts
|
||||
this.loadCounts();
|
||||
}
|
||||
this._loading.set(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user