warnings fixes, tests fixes, sprints completions

This commit is contained in:
Codex Assistant
2026-01-08 08:38:27 +02:00
parent 75611a505f
commit 0b5d786ddb
125 changed files with 14610 additions and 368 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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