finish secrets finding work and audit remarks work save
This commit is contained in:
518
src/Web/StellaOps.Web/e2e/secret-detection.e2e.spec.ts
Normal file
518
src/Web/StellaOps.Web/e2e/secret-detection.e2e.spec.ts
Normal file
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* Secret Detection UI E2E Tests.
|
||||
* Sprint: SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* Task: SDU-012 - E2E tests
|
||||
*
|
||||
* Tests the complete secret detection configuration and viewing flow:
|
||||
* 1. Settings page navigation and toggles
|
||||
* 2. Revelation policy selection
|
||||
* 3. Rule category configuration
|
||||
* 4. Exception management
|
||||
* 5. Alert destination configuration
|
||||
* 6. Findings list and masked value display
|
||||
*/
|
||||
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
|
||||
test.describe('Secret Detection UI', () => {
|
||||
const settingsUrl = '/tenants/test-tenant/secrets/settings';
|
||||
const findingsUrl = '/tenants/test-tenant/secrets/findings';
|
||||
|
||||
test.describe('Settings Page', () => {
|
||||
test('displays settings page with header and tabs', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Verify header is visible
|
||||
const header = page.locator('.settings-header');
|
||||
await expect(header).toBeVisible();
|
||||
await expect(header).toContainText('Secret Detection');
|
||||
|
||||
// Verify enable/disable toggle
|
||||
const toggle = page.locator('mat-slide-toggle');
|
||||
await expect(toggle).toBeVisible();
|
||||
|
||||
// Verify tabs are present
|
||||
const tabs = page.locator('mat-tab-group');
|
||||
await expect(tabs).toBeVisible();
|
||||
await expect(page.locator('role=tab')).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('toggles secret detection on and off', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
const toggle = page.locator('mat-slide-toggle');
|
||||
const initialState = await toggle.getAttribute('class');
|
||||
|
||||
// Toggle the switch
|
||||
await toggle.click();
|
||||
|
||||
// Wait for API call and state change
|
||||
await page.waitForResponse(resp =>
|
||||
resp.url().includes('/secrets/config/settings') && resp.status() === 200
|
||||
);
|
||||
|
||||
// Verify snackbar confirmation
|
||||
const snackbar = page.locator('mat-snack-bar-container');
|
||||
await expect(snackbar).toBeVisible();
|
||||
});
|
||||
|
||||
test('navigates between tabs', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Click Exceptions tab
|
||||
await page.locator('role=tab', { hasText: 'Exceptions' }).click();
|
||||
await expect(page.locator('app-exception-manager')).toBeVisible();
|
||||
|
||||
// Click Alerts tab
|
||||
await page.locator('role=tab', { hasText: 'Alerts' }).click();
|
||||
await expect(page.locator('app-alert-destination-config')).toBeVisible();
|
||||
|
||||
// Back to General tab
|
||||
await page.locator('role=tab', { hasText: 'General' }).click();
|
||||
await expect(page.locator('app-revelation-policy-selector')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Revelation Policy Selector', () => {
|
||||
test('displays all policy options', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Wait for component to load
|
||||
const selector = page.locator('app-revelation-policy-selector');
|
||||
await expect(selector).toBeVisible();
|
||||
|
||||
// Verify all four options are visible
|
||||
await expect(page.locator('.policy-option')).toHaveCount(4);
|
||||
await expect(page.getByText('Fully Masked')).toBeVisible();
|
||||
await expect(page.getByText('Partially Revealed')).toBeVisible();
|
||||
await expect(page.getByText('Full Revelation')).toBeVisible();
|
||||
await expect(page.getByText('Redacted')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows preview for selected policy', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Select partial reveal
|
||||
const partialOption = page.locator('.policy-option', { hasText: 'Partially Revealed' });
|
||||
await partialOption.click();
|
||||
|
||||
// Verify preview is shown
|
||||
const preview = partialOption.locator('.preview code');
|
||||
await expect(preview).toBeVisible();
|
||||
await expect(preview).toContainText('****');
|
||||
});
|
||||
|
||||
test('shows additional controls when masked policy selected', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Select masked policy
|
||||
const maskedOption = page.locator('.policy-option', { hasText: 'Fully Masked' });
|
||||
await maskedOption.click();
|
||||
|
||||
// Verify additional controls appear
|
||||
await expect(maskedOption.locator('.option-controls')).toBeVisible();
|
||||
await expect(maskedOption.locator('mat-form-field', { hasText: 'Mask Character' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('updates preview when mask length changes', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Select masked policy
|
||||
const maskedOption = page.locator('.policy-option', { hasText: 'Fully Masked' });
|
||||
await maskedOption.click();
|
||||
|
||||
// Change mask length
|
||||
const lengthSelect = maskedOption.locator('mat-select', { hasText: /characters/ });
|
||||
await lengthSelect.click();
|
||||
await page.locator('mat-option', { hasText: '16 characters' }).click();
|
||||
|
||||
// Verify preview updated
|
||||
const preview = maskedOption.locator('.preview code');
|
||||
const text = await preview.textContent();
|
||||
expect(text?.length).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Rule Category Toggles', () => {
|
||||
test('displays rule categories grouped by type', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
const toggles = page.locator('app-rule-category-toggles');
|
||||
await expect(toggles).toBeVisible();
|
||||
|
||||
// Verify accordion panels exist
|
||||
const panels = page.locator('mat-expansion-panel');
|
||||
await expect(panels.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows selection count in toolbar', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
const selectionInfo = page.locator('.selection-info');
|
||||
await expect(selectionInfo).toBeVisible();
|
||||
await expect(selectionInfo).toContainText(/\d+ of \d+ categories selected/);
|
||||
});
|
||||
|
||||
test('toggles entire group when group checkbox clicked', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Expand first group
|
||||
const panel = page.locator('mat-expansion-panel').first();
|
||||
await panel.click();
|
||||
|
||||
// Get the group checkbox
|
||||
const groupCheckbox = panel.locator('mat-expansion-panel-header mat-checkbox');
|
||||
|
||||
// Toggle group
|
||||
await groupCheckbox.click();
|
||||
|
||||
// Wait for API call
|
||||
await page.waitForResponse(resp =>
|
||||
resp.url().includes('/secrets/config/settings') && resp.status() === 200
|
||||
);
|
||||
});
|
||||
|
||||
test('select all and clear all buttons work', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Click select all
|
||||
await page.locator('button', { hasText: 'Select All' }).click();
|
||||
|
||||
// Wait for API response
|
||||
await page.waitForResponse(resp =>
|
||||
resp.url().includes('/secrets/config/settings') && resp.status() === 200
|
||||
);
|
||||
|
||||
// Click clear all
|
||||
await page.locator('button', { hasText: 'Clear All' }).click();
|
||||
|
||||
await page.waitForResponse(resp =>
|
||||
resp.url().includes('/secrets/config/settings') && resp.status() === 200
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Exception Manager', () => {
|
||||
test('displays exception list on Exceptions tab', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Navigate to exceptions tab
|
||||
await page.locator('role=tab', { hasText: 'Exceptions' }).click();
|
||||
|
||||
const manager = page.locator('app-exception-manager');
|
||||
await expect(manager).toBeVisible();
|
||||
});
|
||||
|
||||
test('opens create dialog when Add Exception clicked', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
await page.locator('role=tab', { hasText: 'Exceptions' }).click();
|
||||
|
||||
// Click add button
|
||||
await page.locator('button', { hasText: 'Add Exception' }).click();
|
||||
|
||||
// Verify dialog opens
|
||||
const dialog = page.locator('.dialog-overlay');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page.locator('.dialog-card')).toContainText('Create Exception');
|
||||
});
|
||||
|
||||
test('validates required fields in exception form', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
await page.locator('role=tab', { hasText: 'Exceptions' }).click();
|
||||
await page.locator('button', { hasText: 'Add Exception' }).click();
|
||||
|
||||
// Try to submit without required fields
|
||||
const submitButton = page.locator('button[type="submit"]');
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
// Fill required fields
|
||||
await page.locator('input[formcontrolname="name"]').fill('Test Exception');
|
||||
await page.locator('input[formcontrolname="pattern"]').fill('test-pattern');
|
||||
|
||||
// Button should now be enabled
|
||||
await expect(submitButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test('closes dialog when Cancel clicked', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
await page.locator('role=tab', { hasText: 'Exceptions' }).click();
|
||||
await page.locator('button', { hasText: 'Add Exception' }).click();
|
||||
|
||||
// Click cancel
|
||||
await page.locator('button', { hasText: 'Cancel' }).click();
|
||||
|
||||
// Dialog should close
|
||||
const dialog = page.locator('.dialog-overlay');
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Alert Destination Configuration', () => {
|
||||
test('displays alert configuration on Alerts tab', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Navigate to alerts tab
|
||||
await page.locator('role=tab', { hasText: 'Alerts' }).click();
|
||||
|
||||
const config = page.locator('app-alert-destination-config');
|
||||
await expect(config).toBeVisible();
|
||||
await expect(page.getByText('Alert Destinations')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows global settings when alerts enabled', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
await page.locator('role=tab', { hasText: 'Alerts' }).click();
|
||||
|
||||
// Enable alerts if not already
|
||||
const enableToggle = page.locator('app-alert-destination-config mat-slide-toggle');
|
||||
const isChecked = await enableToggle.getAttribute('class');
|
||||
if (!isChecked?.includes('checked')) {
|
||||
await enableToggle.click();
|
||||
}
|
||||
|
||||
// Verify global settings visible
|
||||
await expect(page.locator('.global-settings')).toBeVisible();
|
||||
await expect(page.getByText('Minimum Severity')).toBeVisible();
|
||||
await expect(page.getByText('Rate Limit')).toBeVisible();
|
||||
});
|
||||
|
||||
test('adds new destination when Add Destination clicked', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
await page.locator('role=tab', { hasText: 'Alerts' }).click();
|
||||
|
||||
// Enable alerts
|
||||
const enableToggle = page.locator('app-alert-destination-config mat-slide-toggle');
|
||||
const isChecked = await enableToggle.getAttribute('class');
|
||||
if (!isChecked?.includes('checked')) {
|
||||
await enableToggle.click();
|
||||
}
|
||||
|
||||
// Count initial destinations
|
||||
const initialCount = await page.locator('mat-expansion-panel').count();
|
||||
|
||||
// Add destination
|
||||
await page.locator('button', { hasText: 'Add Destination' }).click();
|
||||
|
||||
// Verify new panel added
|
||||
const newCount = await page.locator('mat-expansion-panel').count();
|
||||
expect(newCount).toBe(initialCount + 1);
|
||||
});
|
||||
|
||||
test('shows different config fields based on destination type', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
await page.locator('role=tab', { hasText: 'Alerts' }).click();
|
||||
|
||||
// Enable alerts and add destination
|
||||
const enableToggle = page.locator('app-alert-destination-config mat-slide-toggle');
|
||||
const isChecked = await enableToggle.getAttribute('class');
|
||||
if (!isChecked?.includes('checked')) {
|
||||
await enableToggle.click();
|
||||
}
|
||||
await page.locator('button', { hasText: 'Add Destination' }).click();
|
||||
|
||||
// Expand the new destination
|
||||
const panel = page.locator('mat-expansion-panel').last();
|
||||
await panel.click();
|
||||
|
||||
// Select Slack type
|
||||
await panel.locator('mat-select', { hasText: 'Type' }).click();
|
||||
await page.locator('mat-option', { hasText: 'Slack' }).click();
|
||||
|
||||
// Verify Slack-specific fields
|
||||
await expect(panel.locator('mat-form-field', { hasText: 'Webhook URL' })).toBeVisible();
|
||||
await expect(panel.locator('mat-form-field', { hasText: 'Channel' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('test button sends test alert', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
await page.locator('role=tab', { hasText: 'Alerts' }).click();
|
||||
|
||||
// Enable alerts
|
||||
const enableToggle = page.locator('app-alert-destination-config mat-slide-toggle');
|
||||
const isChecked = await enableToggle.getAttribute('class');
|
||||
if (!isChecked?.includes('checked')) {
|
||||
await enableToggle.click();
|
||||
}
|
||||
|
||||
// Expand first destination (if exists)
|
||||
const panel = page.locator('mat-expansion-panel').first();
|
||||
if (await panel.isVisible()) {
|
||||
await panel.click();
|
||||
|
||||
// Click test button
|
||||
const testButton = panel.locator('button', { hasText: 'Test' });
|
||||
await testButton.click();
|
||||
|
||||
// Wait for API response
|
||||
await page.waitForResponse(resp =>
|
||||
resp.url().includes('/alerts/test') && resp.status() === 200
|
||||
);
|
||||
|
||||
// Verify snackbar shows result
|
||||
const snackbar = page.locator('mat-snack-bar-container');
|
||||
await expect(snackbar).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Findings List', () => {
|
||||
test('displays findings list with filters', async ({ page }) => {
|
||||
await page.goto(findingsUrl);
|
||||
|
||||
// Verify header
|
||||
await expect(page.getByText('Secret Findings')).toBeVisible();
|
||||
|
||||
// Verify filters
|
||||
await expect(page.locator('.filters')).toBeVisible();
|
||||
await expect(page.locator('mat-form-field', { hasText: 'Severity' })).toBeVisible();
|
||||
await expect(page.locator('mat-form-field', { hasText: 'Status' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('displays table with correct columns', async ({ page }) => {
|
||||
await page.goto(findingsUrl);
|
||||
|
||||
const table = page.locator('table[mat-table]');
|
||||
await expect(table).toBeVisible();
|
||||
|
||||
// Verify column headers
|
||||
await expect(page.locator('th', { hasText: 'Severity' })).toBeVisible();
|
||||
await expect(page.locator('th', { hasText: 'Type' })).toBeVisible();
|
||||
await expect(page.locator('th', { hasText: 'Location' })).toBeVisible();
|
||||
await expect(page.locator('th', { hasText: 'Value' })).toBeVisible();
|
||||
await expect(page.locator('th', { hasText: 'Status' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('filters by severity', async ({ page }) => {
|
||||
await page.goto(findingsUrl);
|
||||
|
||||
// Open severity filter
|
||||
await page.locator('mat-form-field', { hasText: 'Severity' }).click();
|
||||
|
||||
// Select Critical
|
||||
await page.locator('mat-option', { hasText: 'Critical' }).click();
|
||||
|
||||
// Close dropdown
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Wait for filtered results
|
||||
await page.waitForResponse(resp =>
|
||||
resp.url().includes('/findings') && resp.url().includes('severity')
|
||||
);
|
||||
});
|
||||
|
||||
test('pagination works correctly', async ({ page }) => {
|
||||
await page.goto(findingsUrl);
|
||||
|
||||
const paginator = page.locator('mat-paginator');
|
||||
await expect(paginator).toBeVisible();
|
||||
|
||||
// Click next page if available
|
||||
const nextButton = paginator.locator('button[aria-label="Next page"]');
|
||||
if (await nextButton.isEnabled()) {
|
||||
await nextButton.click();
|
||||
|
||||
// Verify page changed
|
||||
await page.waitForResponse(resp =>
|
||||
resp.url().includes('/findings') && resp.url().includes('page=2')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('clicking row navigates to details', async ({ page }) => {
|
||||
await page.goto(findingsUrl);
|
||||
|
||||
// Click first row
|
||||
const firstRow = page.locator('tr[mat-row]').first();
|
||||
if (await firstRow.isVisible()) {
|
||||
await firstRow.click();
|
||||
|
||||
// Verify navigation
|
||||
await expect(page).toHaveURL(/\/findings\/.+/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Masked Value Display', () => {
|
||||
test('displays masked value by default', async ({ page }) => {
|
||||
await page.goto(findingsUrl);
|
||||
|
||||
const maskedDisplay = page.locator('app-masked-value-display').first();
|
||||
if (await maskedDisplay.isVisible()) {
|
||||
const code = maskedDisplay.locator('.value-text');
|
||||
await expect(code).toBeVisible();
|
||||
await expect(code).toContainText(/\*+/);
|
||||
}
|
||||
});
|
||||
|
||||
test('reveal button shows for authorized users', async ({ page }) => {
|
||||
await page.goto(findingsUrl);
|
||||
|
||||
const maskedDisplay = page.locator('app-masked-value-display').first();
|
||||
if (await maskedDisplay.isVisible()) {
|
||||
const revealButton = maskedDisplay.locator('button[mattooltip="Reveal secret value"]');
|
||||
// Button visibility depends on user permissions
|
||||
// Just verify the component structure
|
||||
await expect(maskedDisplay.locator('.value-actions')).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('copy button is disabled when value is masked', async ({ page }) => {
|
||||
await page.goto(findingsUrl);
|
||||
|
||||
const maskedDisplay = page.locator('app-masked-value-display').first();
|
||||
if (await maskedDisplay.isVisible()) {
|
||||
const copyButton = maskedDisplay.locator('button[mattooltip*="Copy"]');
|
||||
if (await copyButton.isVisible()) {
|
||||
// Copy should be disabled when not revealed
|
||||
await expect(copyButton).toBeDisabled();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Accessibility', () => {
|
||||
test('settings page is keyboard navigable', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Tab through elements
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
// Verify focus is visible
|
||||
const focusedElement = await page.locator(':focus');
|
||||
await expect(focusedElement).toBeVisible();
|
||||
});
|
||||
|
||||
test('tabs can be selected with keyboard', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
|
||||
// Focus tab list
|
||||
const tabList = page.locator('mat-tab-group');
|
||||
await tabList.focus();
|
||||
|
||||
// Navigate tabs with arrow keys
|
||||
await page.keyboard.press('ArrowRight');
|
||||
|
||||
// Verify tab changed
|
||||
const activeTab = page.locator('[role="tab"][aria-selected="true"]');
|
||||
await expect(activeTab).toContainText('Exceptions');
|
||||
});
|
||||
|
||||
test('dialogs trap focus', async ({ page }) => {
|
||||
await page.goto(settingsUrl);
|
||||
await page.locator('role=tab', { hasText: 'Exceptions' }).click();
|
||||
await page.locator('button', { hasText: 'Add Exception' }).click();
|
||||
|
||||
// Tab through dialog elements
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
// Focus should stay within dialog
|
||||
const focusedElement = await page.locator(':focus');
|
||||
const dialogCard = page.locator('.dialog-card');
|
||||
await expect(dialogCard.locator(':focus')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* Alert Destination Config Component.
|
||||
*
|
||||
* Configures alert destinations for secret detection findings.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-008 - Implement alert destination configuration
|
||||
* @task SDU-011 - Add channel test functionality
|
||||
*/
|
||||
|
||||
import { Component, Input, Output, EventEmitter, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, FormArray, Validators } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { inject } from '@angular/core';
|
||||
|
||||
import { SecretAlertSettings, SecretAlertDestination } from '../../models';
|
||||
import { SecretDetectionSettingsService } from '../../services';
|
||||
|
||||
@Component({
|
||||
selector: 'app-alert-destination-config',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatCheckboxModule,
|
||||
MatSlideToggleModule,
|
||||
MatExpansionModule,
|
||||
MatChipsModule,
|
||||
MatTooltipModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSnackBarModule,
|
||||
],
|
||||
template: `
|
||||
<div class="alert-config">
|
||||
<header class="config-header">
|
||||
<div class="header-content">
|
||||
<h2>Alert Destinations</h2>
|
||||
<p class="description">
|
||||
Configure where and how secret detection alerts are sent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<mat-slide-toggle
|
||||
[checked]="alertsEnabled()"
|
||||
(change)="onAlertsToggle($event.checked)"
|
||||
color="primary">
|
||||
{{ alertsEnabled() ? 'Alerts Enabled' : 'Alerts Disabled' }}
|
||||
</mat-slide-toggle>
|
||||
</header>
|
||||
|
||||
@if (alertsEnabled()) {
|
||||
<section class="global-settings">
|
||||
<h3>Global Settings</h3>
|
||||
|
||||
<div class="settings-grid">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Minimum Severity</mat-label>
|
||||
<mat-select
|
||||
[value]="settings.minimumSeverity ?? 'medium'"
|
||||
(selectionChange)="onMinSeverityChange($event.value)">
|
||||
<mat-option value="low">Low</mat-option>
|
||||
<mat-option value="medium">Medium</mat-option>
|
||||
<mat-option value="high">High</mat-option>
|
||||
<mat-option value="critical">Critical</mat-option>
|
||||
</mat-select>
|
||||
<mat-hint>Only alert for findings at or above this severity</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Rate Limit (per hour)</mat-label>
|
||||
<input matInput type="number"
|
||||
[value]="settings.rateLimitPerHour ?? 100"
|
||||
(input)="onRateLimitChange($event)"
|
||||
min="1"
|
||||
max="1000">
|
||||
<mat-hint>Maximum alerts per hour per destination</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Deduplication Window (minutes)</mat-label>
|
||||
<input matInput type="number"
|
||||
[value]="settings.deduplicationWindowMinutes ?? 60"
|
||||
(input)="onDedupeWindowChange($event)"
|
||||
min="1"
|
||||
max="1440">
|
||||
<mat-hint>Suppress duplicate alerts within this window</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="destinations-section">
|
||||
<div class="destinations-header">
|
||||
<h3>Destinations</h3>
|
||||
<button mat-stroked-button (click)="addDestination()">
|
||||
<mat-icon>add</mat-icon>
|
||||
Add Destination
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-accordion multi>
|
||||
@for (dest of destinations(); track dest.id; let i = $index) {
|
||||
<mat-expansion-panel>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon [style.color]="getDestinationColor(dest.type)">
|
||||
{{ getDestinationIcon(dest.type) }}
|
||||
</mat-icon>
|
||||
<span>{{ dest.name || getDestinationTypeName(dest.type) }}</span>
|
||||
</mat-panel-title>
|
||||
<mat-panel-description>
|
||||
<mat-chip [class]="dest.enabled ? 'status-enabled' : 'status-disabled'">
|
||||
{{ dest.enabled ? 'Active' : 'Inactive' }}
|
||||
</mat-chip>
|
||||
</mat-panel-description>
|
||||
</mat-expansion-panel-header>
|
||||
|
||||
<div class="destination-form">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Name</mat-label>
|
||||
<input matInput
|
||||
[value]="dest.name"
|
||||
(input)="onDestNameChange(i, $event)"
|
||||
placeholder="Friendly name for this destination">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Type</mat-label>
|
||||
<mat-select
|
||||
[value]="dest.type"
|
||||
(selectionChange)="onDestTypeChange(i, $event.value)">
|
||||
<mat-option value="slack">Slack</mat-option>
|
||||
<mat-option value="teams">Microsoft Teams</mat-option>
|
||||
<mat-option value="email">Email</mat-option>
|
||||
<mat-option value="webhook">Webhook</mat-option>
|
||||
<mat-option value="pagerduty">PagerDuty</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
@switch (dest.type) {
|
||||
@case ('slack') {
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Webhook URL</mat-label>
|
||||
<input matInput
|
||||
[value]="dest.config?.webhookUrl ?? ''"
|
||||
(input)="onDestConfigChange(i, 'webhookUrl', $event)"
|
||||
placeholder="https://hooks.slack.com/services/...">
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Channel</mat-label>
|
||||
<input matInput
|
||||
[value]="dest.config?.channel ?? ''"
|
||||
(input)="onDestConfigChange(i, 'channel', $event)"
|
||||
placeholder="#security-alerts">
|
||||
</mat-form-field>
|
||||
}
|
||||
@case ('teams') {
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Webhook URL</mat-label>
|
||||
<input matInput
|
||||
[value]="dest.config?.webhookUrl ?? ''"
|
||||
(input)="onDestConfigChange(i, 'webhookUrl', $event)"
|
||||
placeholder="https://outlook.office.com/webhook/...">
|
||||
</mat-form-field>
|
||||
}
|
||||
@case ('email') {
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Recipients (comma-separated)</mat-label>
|
||||
<input matInput
|
||||
[value]="dest.config?.recipients ?? ''"
|
||||
(input)="onDestConfigChange(i, 'recipients', $event)"
|
||||
placeholder="security@example.com, devops@example.com">
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Subject Prefix</mat-label>
|
||||
<input matInput
|
||||
[value]="dest.config?.subjectPrefix ?? ''"
|
||||
(input)="onDestConfigChange(i, 'subjectPrefix', $event)"
|
||||
placeholder="[SECRET ALERT]">
|
||||
</mat-form-field>
|
||||
}
|
||||
@case ('webhook') {
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>URL</mat-label>
|
||||
<input matInput
|
||||
[value]="dest.config?.url ?? ''"
|
||||
(input)="onDestConfigChange(i, 'url', $event)"
|
||||
placeholder="https://api.example.com/alerts">
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Authentication Header</mat-label>
|
||||
<input matInput
|
||||
[value]="dest.config?.authHeader ?? ''"
|
||||
(input)="onDestConfigChange(i, 'authHeader', $event)"
|
||||
placeholder="Authorization: Bearer ...">
|
||||
</mat-form-field>
|
||||
}
|
||||
@case ('pagerduty') {
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Integration Key</mat-label>
|
||||
<input matInput
|
||||
[value]="dest.config?.integrationKey ?? ''"
|
||||
(input)="onDestConfigChange(i, 'integrationKey', $event)"
|
||||
placeholder="Events API v2 integration key">
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Service Name</mat-label>
|
||||
<input matInput
|
||||
[value]="dest.config?.serviceName ?? ''"
|
||||
(input)="onDestConfigChange(i, 'serviceName', $event)"
|
||||
placeholder="Secret Detection">
|
||||
</mat-form-field>
|
||||
}
|
||||
}
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Severity Filter</mat-label>
|
||||
<mat-select
|
||||
[value]="dest.severityFilter ?? []"
|
||||
(selectionChange)="onDestSeverityChange(i, $event.value)"
|
||||
multiple>
|
||||
<mat-option value="critical">Critical</mat-option>
|
||||
<mat-option value="high">High</mat-option>
|
||||
<mat-option value="medium">Medium</mat-option>
|
||||
<mat-option value="low">Low</mat-option>
|
||||
</mat-select>
|
||||
<mat-hint>Leave empty for all severities</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="destination-footer">
|
||||
<mat-slide-toggle
|
||||
[checked]="dest.enabled"
|
||||
(change)="onDestEnabledChange(i, $event.checked)">
|
||||
Enabled
|
||||
</mat-slide-toggle>
|
||||
|
||||
<div class="footer-actions">
|
||||
<button mat-stroked-button
|
||||
(click)="testDestination(dest)"
|
||||
[disabled]="testingDestination() === dest.id"
|
||||
matTooltip="Send a test alert to verify configuration">
|
||||
@if (testingDestination() === dest.id) {
|
||||
<mat-spinner diameter="18"></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>send</mat-icon>
|
||||
}
|
||||
Test
|
||||
</button>
|
||||
|
||||
<button mat-button color="warn" (click)="removeDestination(i)">
|
||||
<mat-icon>delete</mat-icon>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
}
|
||||
</mat-accordion>
|
||||
|
||||
@if (destinations().length === 0) {
|
||||
<mat-card class="empty-state">
|
||||
<mat-card-content>
|
||||
<mat-icon>notifications_off</mat-icon>
|
||||
<h3>No Destinations Configured</h3>
|
||||
<p>Add a destination to start receiving alerts.</p>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.alert-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.config-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.header-content h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-content .description {
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.global-settings h3,
|
||||
.destinations-section h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.destinations-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.destinations-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
mat-expansion-panel-header mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.destination-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.destination-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--mat-foreground-divider);
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.footer-actions mat-spinner {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.status-enabled {
|
||||
background-color: #28a745 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.status-disabled {
|
||||
background-color: #6c757d !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.empty-state mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--mat-foreground-hint-text);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
margin: 0;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class AlertDestinationConfigComponent {
|
||||
private readonly settingsService = inject(SecretDetectionSettingsService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
|
||||
@Input() settings: SecretAlertSettings = { enabled: false, destinations: [] };
|
||||
@Input() tenantId = '';
|
||||
@Output() settingsChange = new EventEmitter<SecretAlertSettings>();
|
||||
|
||||
readonly alertsEnabled = signal(false);
|
||||
readonly destinations = signal<SecretAlertDestination[]>([]);
|
||||
readonly testingDestination = signal<string | null>(null);
|
||||
|
||||
ngOnChanges(): void {
|
||||
this.alertsEnabled.set(this.settings.enabled ?? false);
|
||||
this.destinations.set([...(this.settings.destinations ?? [])]);
|
||||
}
|
||||
|
||||
onAlertsToggle(enabled: boolean): void {
|
||||
this.alertsEnabled.set(enabled);
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
onMinSeverityChange(severity: string): void {
|
||||
this.settings = { ...this.settings, minimumSeverity: severity };
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
onRateLimitChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const value = parseInt(input.value, 10);
|
||||
if (!isNaN(value) && value > 0) {
|
||||
this.settings = { ...this.settings, rateLimitPerHour: value };
|
||||
this.emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
onDedupeWindowChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const value = parseInt(input.value, 10);
|
||||
if (!isNaN(value) && value > 0) {
|
||||
this.settings = { ...this.settings, deduplicationWindowMinutes: value };
|
||||
this.emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
addDestination(): void {
|
||||
const newDest: SecretAlertDestination = {
|
||||
id: crypto.randomUUID(),
|
||||
name: '',
|
||||
type: 'webhook',
|
||||
enabled: true,
|
||||
config: {},
|
||||
};
|
||||
this.destinations.update(dests => [...dests, newDest]);
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
removeDestination(index: number): void {
|
||||
this.destinations.update(dests => dests.filter((_, i) => i !== index));
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
onDestNameChange(index: number, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.updateDestination(index, { name: input.value });
|
||||
}
|
||||
|
||||
onDestTypeChange(index: number, type: string): void {
|
||||
this.updateDestination(index, { type, config: {} });
|
||||
}
|
||||
|
||||
onDestConfigChange(index: number, key: string, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const current = this.destinations()[index];
|
||||
this.updateDestination(index, {
|
||||
config: { ...(current.config ?? {}), [key]: input.value },
|
||||
});
|
||||
}
|
||||
|
||||
onDestSeverityChange(index: number, severities: string[]): void {
|
||||
this.updateDestination(index, { severityFilter: severities });
|
||||
}
|
||||
|
||||
onDestEnabledChange(index: number, enabled: boolean): void {
|
||||
this.updateDestination(index, { enabled });
|
||||
}
|
||||
|
||||
private updateDestination(index: number, updates: Partial<SecretAlertDestination>): void {
|
||||
this.destinations.update(dests =>
|
||||
dests.map((d, i) => i === index ? { ...d, ...updates } : d)
|
||||
);
|
||||
this.emitChange();
|
||||
}
|
||||
|
||||
private emitChange(): void {
|
||||
this.settingsChange.emit({
|
||||
...this.settings,
|
||||
enabled: this.alertsEnabled(),
|
||||
destinations: this.destinations(),
|
||||
});
|
||||
}
|
||||
|
||||
getDestinationIcon(type: string): string {
|
||||
const iconMap: Record<string, string> = {
|
||||
slack: 'chat',
|
||||
teams: 'groups',
|
||||
email: 'email',
|
||||
webhook: 'webhook',
|
||||
pagerduty: 'warning',
|
||||
};
|
||||
return iconMap[type] ?? 'notifications';
|
||||
}
|
||||
|
||||
getDestinationColor(type: string): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
slack: '#4A154B',
|
||||
teams: '#6264A7',
|
||||
email: '#1976d2',
|
||||
webhook: '#757575',
|
||||
pagerduty: '#06AC38',
|
||||
};
|
||||
return colorMap[type] ?? '#757575';
|
||||
}
|
||||
|
||||
getDestinationTypeName(type: string): string {
|
||||
const nameMap: Record<string, string> = {
|
||||
slack: 'Slack',
|
||||
teams: 'Microsoft Teams',
|
||||
email: 'Email',
|
||||
webhook: 'Webhook',
|
||||
pagerduty: 'PagerDuty',
|
||||
};
|
||||
return nameMap[type] ?? type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests an alert destination by sending a test notification.
|
||||
* @sprint SDU-011 - Add channel test functionality
|
||||
*/
|
||||
testDestination(destination: SecretAlertDestination): void {
|
||||
if (!this.tenantId || !destination.id) {
|
||||
this.snackBar.open('Unable to test: missing tenant or destination ID', 'Close', {
|
||||
duration: 3000,
|
||||
panelClass: 'snackbar-error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.testingDestination.set(destination.id);
|
||||
|
||||
this.settingsService.testAlertDestination(this.tenantId, destination.id).subscribe({
|
||||
next: (result) => {
|
||||
this.testingDestination.set(null);
|
||||
if (result.success) {
|
||||
this.snackBar.open('Test alert sent successfully!', 'Close', {
|
||||
duration: 3000,
|
||||
panelClass: 'snackbar-success',
|
||||
});
|
||||
} else {
|
||||
this.snackBar.open(`Test failed: ${result.message}`, 'Close', {
|
||||
duration: 5000,
|
||||
panelClass: 'snackbar-error',
|
||||
});
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.testingDestination.set(null);
|
||||
this.snackBar.open('Failed to send test alert', 'Close', {
|
||||
duration: 5000,
|
||||
panelClass: 'snackbar-error',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Barrel export for alert components.
|
||||
*/
|
||||
export * from './alert-destination-config.component';
|
||||
@@ -0,0 +1,502 @@
|
||||
/**
|
||||
* Exception Manager Component.
|
||||
*
|
||||
* Manages secret detection exception patterns (allowlist entries).
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-007 - Implement exception manager
|
||||
*/
|
||||
|
||||
import { Component, Input, inject, OnInit, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
|
||||
import { SecretExceptionService } from '../../services';
|
||||
import { SecretExceptionPattern } from '../../models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-exception-manager',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
MatTableModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatCheckboxModule,
|
||||
MatChipsModule,
|
||||
MatTooltipModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatCardModule,
|
||||
MatSnackBarModule,
|
||||
],
|
||||
template: `
|
||||
<div class="exception-manager">
|
||||
<header class="manager-header">
|
||||
<h2>Exception Patterns</h2>
|
||||
<p class="description">
|
||||
Define patterns to exclude known false positives or intentional test secrets.
|
||||
</p>
|
||||
<button mat-raised-button color="primary" (click)="openCreateDialog()">
|
||||
<mat-icon>add</mat-icon>
|
||||
Add Exception
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading">
|
||||
<mat-spinner diameter="32"></mat-spinner>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (exceptions().length > 0) {
|
||||
<table mat-table [dataSource]="exceptions()">
|
||||
<!-- Name Column -->
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>Name</th>
|
||||
<td mat-cell *matCellDef="let exception">
|
||||
<div class="exception-name">
|
||||
<span>{{ exception.name }}</span>
|
||||
@if (exception.description) {
|
||||
<span class="description">{{ exception.description }}</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Type Column -->
|
||||
<ng-container matColumnDef="matchType">
|
||||
<th mat-header-cell *matHeaderCellDef>Match Type</th>
|
||||
<td mat-cell *matCellDef="let exception">
|
||||
<mat-chip>{{ exception.matchType | titlecase }}</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Pattern Column -->
|
||||
<ng-container matColumnDef="pattern">
|
||||
<th mat-header-cell *matHeaderCellDef>Pattern</th>
|
||||
<td mat-cell *matCellDef="let exception">
|
||||
<code class="pattern-value">{{ exception.pattern }}</code>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Scope Column -->
|
||||
<ng-container matColumnDef="scope">
|
||||
<th mat-header-cell *matHeaderCellDef>Scope</th>
|
||||
<td mat-cell *matCellDef="let exception">
|
||||
@if (exception.ruleIds?.length) {
|
||||
<mat-chip-set>
|
||||
@for (ruleId of exception.ruleIds.slice(0, 2); track ruleId) {
|
||||
<mat-chip>{{ ruleId }}</mat-chip>
|
||||
}
|
||||
@if (exception.ruleIds.length > 2) {
|
||||
<mat-chip>+{{ exception.ruleIds.length - 2 }} more</mat-chip>
|
||||
}
|
||||
</mat-chip-set>
|
||||
} @else {
|
||||
<span class="scope-all">All rules</span>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Status Column -->
|
||||
<ng-container matColumnDef="enabled">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let exception">
|
||||
<mat-chip [class]="exception.enabled ? 'status-enabled' : 'status-disabled'">
|
||||
{{ exception.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let exception">
|
||||
<button mat-icon-button
|
||||
(click)="toggleEnabled(exception)"
|
||||
[matTooltip]="exception.enabled ? 'Disable' : 'Enable'">
|
||||
<mat-icon>{{ exception.enabled ? 'toggle_on' : 'toggle_off' }}</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button
|
||||
(click)="openEditDialog(exception)"
|
||||
matTooltip="Edit">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button
|
||||
(click)="deleteException(exception)"
|
||||
matTooltip="Delete"
|
||||
color="warn">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
</table>
|
||||
} @else if (!loading()) {
|
||||
<mat-card class="empty-state">
|
||||
<mat-card-content>
|
||||
<mat-icon>block</mat-icon>
|
||||
<h3>No Exceptions Defined</h3>
|
||||
<p>Create exception patterns to suppress known false positives.</p>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
@if (showDialog()) {
|
||||
<div class="dialog-overlay" (click)="closeDialog()">
|
||||
<mat-card class="dialog-card" (click)="$event.stopPropagation()">
|
||||
<mat-card-header>
|
||||
<mat-card-title>{{ editingException() ? 'Edit' : 'Create' }} Exception</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<form [formGroup]="exceptionForm" (ngSubmit)="saveException()">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Name</mat-label>
|
||||
<input matInput formControlName="name" placeholder="e.g., Test API Keys">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Description</mat-label>
|
||||
<textarea matInput formControlName="description" rows="2"
|
||||
placeholder="Why this exception exists..."></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Match Type</mat-label>
|
||||
<mat-select formControlName="matchType">
|
||||
<mat-option value="literal">Literal String</mat-option>
|
||||
<mat-option value="regex">Regular Expression</mat-option>
|
||||
<mat-option value="glob">Glob Pattern</mat-option>
|
||||
<mat-option value="prefix">Prefix Match</mat-option>
|
||||
<mat-option value="suffix">Suffix Match</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Pattern</mat-label>
|
||||
<input matInput formControlName="pattern"
|
||||
placeholder="Pattern to match against secret values or paths">
|
||||
<mat-hint>
|
||||
@switch (exceptionForm.get('matchType')?.value) {
|
||||
@case ('regex') { Use a valid regular expression }
|
||||
@case ('glob') { Use * and ? wildcards }
|
||||
@default { Enter the exact text to match }
|
||||
}
|
||||
</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Target</mat-label>
|
||||
<mat-select formControlName="target">
|
||||
<mat-option value="value">Secret Value</mat-option>
|
||||
<mat-option value="path">File Path</mat-option>
|
||||
<mat-option value="both">Both Value and Path</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-checkbox formControlName="enabled">Enabled</mat-checkbox>
|
||||
|
||||
<div class="form-actions">
|
||||
<button mat-button type="button" (click)="closeDialog()">Cancel</button>
|
||||
<button mat-raised-button color="primary" type="submit"
|
||||
[disabled]="exceptionForm.invalid || saving()">
|
||||
@if (saving()) {
|
||||
<mat-spinner diameter="20"></mat-spinner>
|
||||
} @else {
|
||||
{{ editingException() ? 'Update' : 'Create' }}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [`
|
||||
.exception-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.manager-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.manager-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.manager-header .description {
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.manager-header button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.exception-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.exception-name .description {
|
||||
font-size: 12px;
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
}
|
||||
|
||||
.pattern-value {
|
||||
font-size: 13px;
|
||||
padding: 2px 6px;
|
||||
background: var(--mat-background-card);
|
||||
border: 1px solid var(--mat-foreground-divider);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.scope-all {
|
||||
font-style: italic;
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
}
|
||||
|
||||
.status-enabled {
|
||||
background-color: #28a745 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.status-disabled {
|
||||
background-color: #6c757d !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.empty-state mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--mat-foreground-hint-text);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dialog-card {
|
||||
width: 500px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dialog-card form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dialog-card mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ExceptionManagerComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly exceptionService = inject(SecretExceptionService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
|
||||
@Input() exceptions: SecretExceptionPattern[] = [];
|
||||
@Input() tenantId = '';
|
||||
|
||||
readonly loading = this.exceptionService.loading;
|
||||
readonly showDialog = signal(false);
|
||||
readonly editingException = signal<SecretExceptionPattern | null>(null);
|
||||
readonly saving = signal(false);
|
||||
|
||||
readonly displayedColumns = ['name', 'matchType', 'pattern', 'scope', 'enabled', 'actions'];
|
||||
|
||||
exceptionForm: FormGroup = this.fb.group({
|
||||
name: ['', [Validators.required, Validators.maxLength(100)]],
|
||||
description: ['', Validators.maxLength(500)],
|
||||
matchType: ['literal', Validators.required],
|
||||
pattern: ['', Validators.required],
|
||||
target: ['value', Validators.required],
|
||||
enabled: [true],
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.tenantId) {
|
||||
this.loadExceptions();
|
||||
}
|
||||
}
|
||||
|
||||
loadExceptions(): void {
|
||||
this.exceptionService.loadExceptions(this.tenantId).subscribe();
|
||||
}
|
||||
|
||||
openCreateDialog(): void {
|
||||
this.editingException.set(null);
|
||||
this.exceptionForm.reset({
|
||||
matchType: 'literal',
|
||||
target: 'value',
|
||||
enabled: true,
|
||||
});
|
||||
this.showDialog.set(true);
|
||||
}
|
||||
|
||||
openEditDialog(exception: SecretExceptionPattern): void {
|
||||
this.editingException.set(exception);
|
||||
this.exceptionForm.patchValue({
|
||||
name: exception.name,
|
||||
description: exception.description ?? '',
|
||||
matchType: exception.matchType,
|
||||
pattern: exception.pattern,
|
||||
target: exception.target ?? 'value',
|
||||
enabled: exception.enabled,
|
||||
});
|
||||
this.showDialog.set(true);
|
||||
}
|
||||
|
||||
closeDialog(): void {
|
||||
this.showDialog.set(false);
|
||||
this.editingException.set(null);
|
||||
}
|
||||
|
||||
saveException(): void {
|
||||
if (this.exceptionForm.invalid) return;
|
||||
|
||||
this.saving.set(true);
|
||||
const formValue = this.exceptionForm.value;
|
||||
const existing = this.editingException();
|
||||
|
||||
const payload: Partial<SecretExceptionPattern> = {
|
||||
name: formValue.name,
|
||||
description: formValue.description || undefined,
|
||||
matchType: formValue.matchType,
|
||||
pattern: formValue.pattern,
|
||||
target: formValue.target,
|
||||
enabled: formValue.enabled,
|
||||
};
|
||||
|
||||
const operation = existing
|
||||
? this.exceptionService.updateException(existing.id, payload)
|
||||
: this.exceptionService.createException(this.tenantId, payload);
|
||||
|
||||
operation.subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.closeDialog();
|
||||
this.showSuccess(existing ? 'Exception updated' : 'Exception created');
|
||||
this.loadExceptions();
|
||||
},
|
||||
error: () => {
|
||||
this.saving.set(false);
|
||||
this.showError('Failed to save exception');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toggleEnabled(exception: SecretExceptionPattern): void {
|
||||
this.exceptionService.updateException(exception.id, {
|
||||
enabled: !exception.enabled,
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
this.loadExceptions();
|
||||
},
|
||||
error: () => this.showError('Failed to update exception'),
|
||||
});
|
||||
}
|
||||
|
||||
deleteException(exception: SecretExceptionPattern): void {
|
||||
if (!confirm(`Delete exception "${exception.name}"?`)) return;
|
||||
|
||||
this.exceptionService.deleteException(exception.id).subscribe({
|
||||
next: () => {
|
||||
this.showSuccess('Exception deleted');
|
||||
this.loadExceptions();
|
||||
},
|
||||
error: () => this.showError('Failed to delete exception'),
|
||||
});
|
||||
}
|
||||
|
||||
private showSuccess(message: string): void {
|
||||
this.snackBar.open(message, 'Close', {
|
||||
duration: 3000,
|
||||
panelClass: 'snackbar-success',
|
||||
});
|
||||
}
|
||||
|
||||
private showError(message: string): void {
|
||||
this.snackBar.open(message, 'Close', {
|
||||
duration: 5000,
|
||||
panelClass: 'snackbar-error',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Barrel export for exception components.
|
||||
*/
|
||||
export * from './exception-manager.component';
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Barrel export for findings components.
|
||||
*/
|
||||
export * from './secret-findings-list.component';
|
||||
export * from './masked-value-display.component';
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Masked Value Display Component.
|
||||
*
|
||||
* Displays secret values with masking and optional reveal functionality.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-006 - Implement masked value display
|
||||
*/
|
||||
|
||||
import { Component, Input, Output, EventEmitter, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
|
||||
import { SecretFindingsService } from '../../services';
|
||||
import { inject } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-masked-value-display',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
template: `
|
||||
<div class="value-display">
|
||||
<code class="value-text" [class.revealed]="isRevealed()">
|
||||
{{ displayValue() }}
|
||||
</code>
|
||||
|
||||
<div class="value-actions">
|
||||
@if (canReveal && !isRevealed()) {
|
||||
@if (revealing()) {
|
||||
<mat-spinner diameter="16"></mat-spinner>
|
||||
} @else {
|
||||
<button mat-icon-button
|
||||
(click)="reveal()"
|
||||
matTooltip="Reveal secret value"
|
||||
[disabled]="revealing()">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
@if (isRevealed()) {
|
||||
<button mat-icon-button
|
||||
(click)="hide()"
|
||||
matTooltip="Hide secret value">
|
||||
<mat-icon>visibility_off</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button mat-icon-button
|
||||
(click)="copyToClipboard()"
|
||||
[matTooltip]="copied() ? 'Copied!' : 'Copy to clipboard'"
|
||||
[disabled]="!isRevealed()">
|
||||
<mat-icon>{{ copied() ? 'check' : 'content_copy' }}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.value-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.value-text {
|
||||
font-family: 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
padding: 4px 8px;
|
||||
background: var(--mat-background-card);
|
||||
border: 1px solid var(--mat-foreground-divider);
|
||||
border-radius: 4px;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
}
|
||||
|
||||
.value-text.revealed {
|
||||
color: var(--mat-foreground-text);
|
||||
background-color: var(--mat-warn-50);
|
||||
border-color: var(--mat-warn-200);
|
||||
}
|
||||
|
||||
.value-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.value-actions button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.value-actions mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
mat-spinner {
|
||||
margin: 5px;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class MaskedValueDisplayComponent {
|
||||
private readonly findingsService = inject(SecretFindingsService);
|
||||
private readonly clipboard = inject(Clipboard);
|
||||
|
||||
@Input() value = '';
|
||||
@Input() findingId = '';
|
||||
@Input() canReveal = false;
|
||||
|
||||
@Output() revealed = new EventEmitter<string>();
|
||||
|
||||
readonly isRevealed = signal(false);
|
||||
readonly revealedValue = signal<string | null>(null);
|
||||
readonly revealing = signal(false);
|
||||
readonly copied = signal(false);
|
||||
|
||||
readonly displayValue = () => {
|
||||
if (this.isRevealed() && this.revealedValue()) {
|
||||
return this.revealedValue()!;
|
||||
}
|
||||
return this.value || '********';
|
||||
};
|
||||
|
||||
reveal(): void {
|
||||
if (!this.canReveal || !this.findingId) return;
|
||||
|
||||
this.revealing.set(true);
|
||||
|
||||
this.findingsService.revealValue(this.findingId).subscribe({
|
||||
next: (value) => {
|
||||
this.revealedValue.set(value);
|
||||
this.isRevealed.set(true);
|
||||
this.revealing.set(false);
|
||||
this.revealed.emit(value);
|
||||
|
||||
// Auto-hide after 30 seconds for security
|
||||
setTimeout(() => this.hide(), 30000);
|
||||
},
|
||||
error: () => {
|
||||
this.revealing.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.isRevealed.set(false);
|
||||
this.revealedValue.set(null);
|
||||
}
|
||||
|
||||
copyToClipboard(): void {
|
||||
const valueToCopy = this.revealedValue() ?? this.value;
|
||||
if (valueToCopy) {
|
||||
this.clipboard.copy(valueToCopy);
|
||||
this.copied.set(true);
|
||||
|
||||
setTimeout(() => this.copied.set(false), 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
/**
|
||||
* Secret Findings List Component.
|
||||
*
|
||||
* Displays paginated list of detected secrets with filtering and sorting.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-005 - Implement findings list with pagination
|
||||
*/
|
||||
|
||||
import { Component, inject, OnInit, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterModule, ActivatedRoute, Router } from '@angular/router';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatSortModule, Sort } from '@angular/material/sort';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
|
||||
import { SecretFindingsService } from '../../services';
|
||||
import { SecretFinding } from '../../models';
|
||||
import { MaskedValueDisplayComponent } from './masked-value-display.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-secret-findings-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
MatTableModule,
|
||||
MatSortModule,
|
||||
MatPaginatorModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatCardModule,
|
||||
MatTooltipModule,
|
||||
MatMenuModule,
|
||||
MaskedValueDisplayComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="findings-container">
|
||||
<header class="findings-header">
|
||||
<h1>Secret Findings</h1>
|
||||
<div class="header-stats">
|
||||
@if (pagination()) {
|
||||
<span>{{ pagination()!.totalItems }} findings total</span>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<mat-card class="filters-card">
|
||||
<mat-card-content>
|
||||
<div class="filters">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Search</mat-label>
|
||||
<input matInput
|
||||
[value]="searchQuery()"
|
||||
(input)="onSearchChange($event)"
|
||||
placeholder="Search by file, rule, or secret type...">
|
||||
<mat-icon matSuffix>search</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Severity</mat-label>
|
||||
<mat-select
|
||||
[value]="severityFilter()"
|
||||
(selectionChange)="onSeverityChange($event.value)"
|
||||
multiple>
|
||||
<mat-option value="critical">Critical</mat-option>
|
||||
<mat-option value="high">High</mat-option>
|
||||
<mat-option value="medium">Medium</mat-option>
|
||||
<mat-option value="low">Low</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Status</mat-label>
|
||||
<mat-select
|
||||
[value]="statusFilter()"
|
||||
(selectionChange)="onStatusChange($event.value)">
|
||||
<mat-option value="">All</mat-option>
|
||||
<mat-option value="active">Active</mat-option>
|
||||
<mat-option value="resolved">Resolved</mat-option>
|
||||
<mat-option value="excepted">Excepted</mat-option>
|
||||
<mat-option value="suppressed">Suppressed</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<button mat-stroked-button (click)="resetFilters()">
|
||||
<mat-icon>clear</mat-icon>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-overlay">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
<mat-card class="error-card">
|
||||
<mat-card-content>
|
||||
<mat-icon color="warn">error</mat-icon>
|
||||
<span>{{ error() }}</span>
|
||||
<button mat-button color="primary" (click)="reload()">Retry</button>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
@if (findings().length > 0) {
|
||||
<div class="table-container">
|
||||
<table mat-table [dataSource]="findings()" matSort (matSortChange)="onSortChange($event)">
|
||||
<!-- Severity Column -->
|
||||
<ng-container matColumnDef="severity">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Severity</th>
|
||||
<td mat-cell *matCellDef="let finding">
|
||||
<mat-chip [class]="'severity-' + finding.severity">
|
||||
{{ finding.severity | titlecase }}
|
||||
</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Type Column -->
|
||||
<ng-container matColumnDef="ruleId">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Type</th>
|
||||
<td mat-cell *matCellDef="let finding">
|
||||
<div class="rule-info">
|
||||
<span class="rule-name">{{ finding.ruleName ?? finding.ruleId }}</span>
|
||||
@if (finding.category) {
|
||||
<span class="rule-category">{{ finding.category }}</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- File Column -->
|
||||
<ng-container matColumnDef="filePath">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Location</th>
|
||||
<td mat-cell *matCellDef="let finding">
|
||||
<div class="location-info">
|
||||
<code class="file-path" [matTooltip]="finding.filePath">
|
||||
{{ truncatePath(finding.filePath) }}
|
||||
</code>
|
||||
@if (finding.lineNumber) {
|
||||
<span class="line-number">Line {{ finding.lineNumber }}</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Value Column -->
|
||||
<ng-container matColumnDef="maskedValue">
|
||||
<th mat-header-cell *matHeaderCellDef>Value</th>
|
||||
<td mat-cell *matCellDef="let finding">
|
||||
<app-masked-value-display
|
||||
[value]="finding.maskedValue"
|
||||
[findingId]="finding.id"
|
||||
[canReveal]="finding.canReveal">
|
||||
</app-masked-value-display>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Status Column -->
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Status</th>
|
||||
<td mat-cell *matCellDef="let finding">
|
||||
<mat-chip [class]="'status-' + finding.status">
|
||||
{{ finding.status | titlecase }}
|
||||
</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Detected At Column -->
|
||||
<ng-container matColumnDef="detectedAt">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Detected</th>
|
||||
<td mat-cell *matCellDef="let finding">
|
||||
{{ finding.detectedAt | date:'short' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let finding">
|
||||
<button mat-icon-button [matMenuTriggerFor]="actionsMenu" (click)="$event.stopPropagation()">
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
<mat-menu #actionsMenu="matMenu">
|
||||
<button mat-menu-item (click)="viewDetails(finding)">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
<span>View Details</span>
|
||||
</button>
|
||||
@if (finding.status === 'active') {
|
||||
<button mat-menu-item (click)="createException(finding)">
|
||||
<mat-icon>block</mat-icon>
|
||||
<span>Create Exception</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="markResolved(finding)">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
<span>Mark Resolved</span>
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"
|
||||
(click)="viewDetails(row)"
|
||||
class="clickable-row">
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
[length]="pagination()?.totalItems ?? 0"
|
||||
[pageSize]="pagination()?.pageSize ?? 25"
|
||||
[pageIndex]="(pagination()?.page ?? 1) - 1"
|
||||
[pageSizeOptions]="[10, 25, 50, 100]"
|
||||
(page)="onPageChange($event)"
|
||||
showFirstLastButtons>
|
||||
</mat-paginator>
|
||||
} @else if (!loading()) {
|
||||
<mat-card class="empty-state">
|
||||
<mat-card-content>
|
||||
<mat-icon>verified_user</mat-icon>
|
||||
<h2>No Secrets Found</h2>
|
||||
<p>No secret findings match your current filters.</p>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.findings-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.findings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.findings-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-stats {
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
}
|
||||
|
||||
.filters-card {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.filters mat-form-field {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.clickable-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clickable-row:hover {
|
||||
background-color: var(--mat-background-hover);
|
||||
}
|
||||
|
||||
.rule-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rule-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rule-category {
|
||||
font-size: 12px;
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
}
|
||||
|
||||
.location-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
font-size: 13px;
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
font-size: 12px;
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
}
|
||||
|
||||
.severity-critical {
|
||||
background-color: #dc3545 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.severity-high {
|
||||
background-color: #fd7e14 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.severity-medium {
|
||||
background-color: #ffc107 !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
.severity-low {
|
||||
background-color: #6c757d !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background-color: #dc3545 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.status-resolved {
|
||||
background-color: #28a745 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.status-excepted {
|
||||
background-color: #17a2b8 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.status-suppressed {
|
||||
background-color: #6c757d !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
background-color: var(--mat-warn-50);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-card mat-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.empty-state mat-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--mat-foreground-hint-text);
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SecretFindingsListComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly findingsService = inject(SecretFindingsService);
|
||||
|
||||
readonly tenantId = signal('');
|
||||
|
||||
// Expose service signals
|
||||
readonly findings = this.findingsService.findings;
|
||||
readonly pagination = this.findingsService.pagination;
|
||||
readonly loading = this.findingsService.loading;
|
||||
readonly error = this.findingsService.error;
|
||||
|
||||
// Local filter state
|
||||
readonly searchQuery = signal('');
|
||||
readonly severityFilter = signal<string[]>([]);
|
||||
readonly statusFilter = signal('');
|
||||
readonly sortField = signal('detectedAt');
|
||||
readonly sortDirection = signal<'asc' | 'desc'>('desc');
|
||||
|
||||
readonly displayedColumns = [
|
||||
'severity',
|
||||
'ruleId',
|
||||
'filePath',
|
||||
'maskedValue',
|
||||
'status',
|
||||
'detectedAt',
|
||||
'actions',
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
const tenantId = this.route.snapshot.paramMap.get('tenantId') ?? '';
|
||||
this.tenantId.set(tenantId);
|
||||
|
||||
if (tenantId) {
|
||||
this.reload();
|
||||
}
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
const tid = this.tenantId();
|
||||
if (tid) {
|
||||
this.findingsService.loadFindings(tid, {
|
||||
page: 1,
|
||||
pageSize: 25,
|
||||
search: this.searchQuery(),
|
||||
severity: this.severityFilter(),
|
||||
status: this.statusFilter(),
|
||||
sortBy: this.sortField(),
|
||||
sortDirection: this.sortDirection(),
|
||||
}).subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
onSearchChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.searchQuery.set(input.value);
|
||||
this.reloadWithDebounce();
|
||||
}
|
||||
|
||||
onSeverityChange(values: string[]): void {
|
||||
this.severityFilter.set(values);
|
||||
this.reload();
|
||||
}
|
||||
|
||||
onStatusChange(value: string): void {
|
||||
this.statusFilter.set(value);
|
||||
this.reload();
|
||||
}
|
||||
|
||||
onSortChange(sort: Sort): void {
|
||||
this.sortField.set(sort.active);
|
||||
this.sortDirection.set(sort.direction as 'asc' | 'desc' || 'desc');
|
||||
this.reload();
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
const tid = this.tenantId();
|
||||
if (tid) {
|
||||
this.findingsService.loadFindings(tid, {
|
||||
page: event.pageIndex + 1,
|
||||
pageSize: event.pageSize,
|
||||
search: this.searchQuery(),
|
||||
severity: this.severityFilter(),
|
||||
status: this.statusFilter(),
|
||||
sortBy: this.sortField(),
|
||||
sortDirection: this.sortDirection(),
|
||||
}).subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.searchQuery.set('');
|
||||
this.severityFilter.set([]);
|
||||
this.statusFilter.set('');
|
||||
this.reload();
|
||||
}
|
||||
|
||||
viewDetails(finding: SecretFinding): void {
|
||||
this.router.navigate(['findings', finding.id], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
createException(finding: SecretFinding): void {
|
||||
this.router.navigate(['exceptions', 'new'], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { findingId: finding.id },
|
||||
});
|
||||
}
|
||||
|
||||
markResolved(finding: SecretFinding): void {
|
||||
this.findingsService.updateStatus(finding.id, 'resolved').subscribe({
|
||||
next: () => this.reload(),
|
||||
});
|
||||
}
|
||||
|
||||
truncatePath(path: string): string {
|
||||
const maxLen = 40;
|
||||
if (path.length <= maxLen) return path;
|
||||
|
||||
const parts = path.split('/');
|
||||
if (parts.length <= 2) return path;
|
||||
|
||||
return `.../${parts.slice(-2).join('/')}`;
|
||||
}
|
||||
|
||||
private debounceTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
private reloadWithDebounce(): void {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
}
|
||||
this.debounceTimer = setTimeout(() => this.reload(), 300);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Barrel export for all components in the secret detection feature.
|
||||
*/
|
||||
|
||||
// Settings components
|
||||
export * from './settings';
|
||||
|
||||
// Findings components
|
||||
export * from './findings';
|
||||
|
||||
// Exceptions components
|
||||
export * from './exceptions';
|
||||
|
||||
// Alert components
|
||||
export * from './alerts';
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Barrel export for settings components.
|
||||
*/
|
||||
export * from './secret-detection-settings.component';
|
||||
export * from './revelation-policy-selector.component';
|
||||
export * from './rule-category-toggles.component';
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Revelation Policy Selector Component.
|
||||
*
|
||||
* Configures how detected secrets are revealed/masked in different contexts.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-003 - Implement revelation policy selector
|
||||
*/
|
||||
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSliderModule } from '@angular/material/slider';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
|
||||
import { RevelationPolicyConfig } from '../../models';
|
||||
|
||||
type RevelationMode = 'masked' | 'partial' | 'full' | 'redacted';
|
||||
|
||||
@Component({
|
||||
selector: 'app-revelation-policy-selector',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatRadioModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatSliderModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
],
|
||||
template: `
|
||||
<div class="policy-selector">
|
||||
<mat-card class="policy-option" [class.selected]="config.mode === 'masked'">
|
||||
<mat-card-header>
|
||||
<mat-radio-button
|
||||
[checked]="config.mode === 'masked'"
|
||||
(change)="setMode('masked')"
|
||||
name="policy">
|
||||
</mat-radio-button>
|
||||
<mat-card-title>Fully Masked</mat-card-title>
|
||||
<mat-icon matTooltip="Secret values are completely hidden">info</mat-icon>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<p>All secret values are replaced with asterisks or a placeholder.</p>
|
||||
<div class="preview">
|
||||
<code>{{ getMaskedPreview() }}</code>
|
||||
</div>
|
||||
|
||||
@if (config.mode === 'masked') {
|
||||
<div class="option-controls">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Mask Character</mat-label>
|
||||
<input matInput
|
||||
[value]="config.maskChar ?? '*'"
|
||||
(input)="onMaskCharChange($event)"
|
||||
maxlength="1">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Mask Length</mat-label>
|
||||
<mat-select
|
||||
[value]="config.maskLength ?? 8"
|
||||
(selectionChange)="onMaskLengthChange($event.value)">
|
||||
<mat-option [value]="4">4 characters</mat-option>
|
||||
<mat-option [value]="8">8 characters</mat-option>
|
||||
<mat-option [value]="16">16 characters</mat-option>
|
||||
<mat-option [value]="0">Match original length</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="policy-option" [class.selected]="config.mode === 'partial'">
|
||||
<mat-card-header>
|
||||
<mat-radio-button
|
||||
[checked]="config.mode === 'partial'"
|
||||
(change)="setMode('partial')"
|
||||
name="policy">
|
||||
</mat-radio-button>
|
||||
<mat-card-title>Partially Revealed</mat-card-title>
|
||||
<mat-icon matTooltip="Show first/last characters, mask the rest">info</mat-icon>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<p>Show a portion of the secret to aid identification while hiding the full value.</p>
|
||||
<div class="preview">
|
||||
<code>{{ getPartialPreview() }}</code>
|
||||
</div>
|
||||
|
||||
@if (config.mode === 'partial') {
|
||||
<div class="option-controls">
|
||||
<div class="slider-control">
|
||||
<label>Reveal first characters: {{ config.revealFirst ?? 4 }}</label>
|
||||
<mat-slider min="0" max="8" step="1" discrete>
|
||||
<input matSliderThumb
|
||||
[value]="config.revealFirst ?? 4"
|
||||
(valueChange)="onRevealFirstChange($event)">
|
||||
</mat-slider>
|
||||
</div>
|
||||
|
||||
<div class="slider-control">
|
||||
<label>Reveal last characters: {{ config.revealLast ?? 0 }}</label>
|
||||
<mat-slider min="0" max="8" step="1" discrete>
|
||||
<input matSliderThumb
|
||||
[value]="config.revealLast ?? 0"
|
||||
(valueChange)="onRevealLastChange($event)">
|
||||
</mat-slider>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="policy-option" [class.selected]="config.mode === 'full'">
|
||||
<mat-card-header>
|
||||
<mat-radio-button
|
||||
[checked]="config.mode === 'full'"
|
||||
(change)="setMode('full')"
|
||||
name="policy">
|
||||
</mat-radio-button>
|
||||
<mat-card-title>Full Revelation</mat-card-title>
|
||||
<mat-icon matTooltip="Secret values are fully visible - use with caution" color="warn">warning</mat-icon>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<p class="warning-text">
|
||||
<strong>Warning:</strong> Secrets are shown in full. Use only in secure,
|
||||
controlled environments. Requires elevated permissions.
|
||||
</p>
|
||||
<div class="preview">
|
||||
<code>{{ getFullPreview() }}</code>
|
||||
</div>
|
||||
|
||||
@if (config.mode === 'full') {
|
||||
<div class="option-controls">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Require Permission</mat-label>
|
||||
<mat-select
|
||||
[value]="config.requiredPermission ?? 'admin'"
|
||||
(selectionChange)="onPermissionChange($event.value)">
|
||||
<mat-option value="admin">Administrator</mat-option>
|
||||
<mat-option value="security-lead">Security Lead</mat-option>
|
||||
<mat-option value="auditor">Auditor</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="policy-option" [class.selected]="config.mode === 'redacted'">
|
||||
<mat-card-header>
|
||||
<mat-radio-button
|
||||
[checked]="config.mode === 'redacted'"
|
||||
(change)="setMode('redacted')"
|
||||
name="policy">
|
||||
</mat-radio-button>
|
||||
<mat-card-title>Redacted</mat-card-title>
|
||||
<mat-icon matTooltip="Value is completely removed from output">info</mat-icon>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<p>Secret values are not stored or displayed. Only the location and type are recorded.</p>
|
||||
<div class="preview">
|
||||
<code>{{ getRedactedPreview() }}</code>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.policy-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.policy-option {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.policy-option:hover {
|
||||
border-color: var(--mat-primary-100);
|
||||
}
|
||||
|
||||
.policy-option.selected {
|
||||
border-color: var(--mat-primary-500);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.policy-option mat-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.policy-option mat-card-title {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.policy-option p {
|
||||
font-size: 14px;
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.preview {
|
||||
background: var(--mat-background-card);
|
||||
border: 1px solid var(--mat-foreground-divider);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.preview code {
|
||||
color: var(--mat-foreground-text);
|
||||
}
|
||||
|
||||
.option-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--mat-foreground-divider);
|
||||
}
|
||||
|
||||
.slider-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.slider-control label {
|
||||
font-size: 13px;
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: var(--mat-warn-500) !important;
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class RevelationPolicySelectorComponent {
|
||||
@Input() config: RevelationPolicyConfig = { mode: 'masked' };
|
||||
@Output() configChange = new EventEmitter<RevelationPolicyConfig>();
|
||||
|
||||
private readonly sampleSecret = 'ghp_abc123XYZ789secret';
|
||||
|
||||
setMode(mode: RevelationMode): void {
|
||||
this.emitChange({ ...this.config, mode });
|
||||
}
|
||||
|
||||
onMaskCharChange(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const maskChar = input.value || '*';
|
||||
this.emitChange({ ...this.config, maskChar });
|
||||
}
|
||||
|
||||
onMaskLengthChange(length: number): void {
|
||||
this.emitChange({ ...this.config, maskLength: length });
|
||||
}
|
||||
|
||||
onRevealFirstChange(value: number): void {
|
||||
this.emitChange({ ...this.config, revealFirst: value });
|
||||
}
|
||||
|
||||
onRevealLastChange(value: number): void {
|
||||
this.emitChange({ ...this.config, revealLast: value });
|
||||
}
|
||||
|
||||
onPermissionChange(permission: string): void {
|
||||
this.emitChange({ ...this.config, requiredPermission: permission });
|
||||
}
|
||||
|
||||
private emitChange(newConfig: RevelationPolicyConfig): void {
|
||||
this.config = newConfig;
|
||||
this.configChange.emit(newConfig);
|
||||
}
|
||||
|
||||
getMaskedPreview(): string {
|
||||
const char = this.config.maskChar ?? '*';
|
||||
const len = this.config.maskLength ?? 8;
|
||||
const actualLen = len === 0 ? this.sampleSecret.length : len;
|
||||
return char.repeat(actualLen);
|
||||
}
|
||||
|
||||
getPartialPreview(): string {
|
||||
const first = this.config.revealFirst ?? 4;
|
||||
const last = this.config.revealLast ?? 0;
|
||||
const masked = '*'.repeat(8);
|
||||
|
||||
const prefix = this.sampleSecret.substring(0, first);
|
||||
const suffix = last > 0 ? this.sampleSecret.substring(this.sampleSecret.length - last) : '';
|
||||
|
||||
return `${prefix}${masked}${suffix}`;
|
||||
}
|
||||
|
||||
getFullPreview(): string {
|
||||
return this.sampleSecret;
|
||||
}
|
||||
|
||||
getRedactedPreview(): string {
|
||||
return '[REDACTED]';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Rule Category Toggles Component.
|
||||
*
|
||||
* Displays available secret detection rule categories with toggle controls.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-004 - Implement rule category toggles
|
||||
*/
|
||||
|
||||
import { Component, Input, Output, EventEmitter, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
|
||||
import { SecretRuleCategory } from '../../models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-rule-category-toggles',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatCheckboxModule,
|
||||
MatExpansionModule,
|
||||
MatChipsModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatBadgeModule,
|
||||
MatTooltipModule,
|
||||
],
|
||||
template: `
|
||||
<div class="category-toggles">
|
||||
<div class="toolbar">
|
||||
<div class="selection-info">
|
||||
<span>{{ selectedCount() }} of {{ categories.length }} categories selected</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button mat-button (click)="selectAll()">Select All</button>
|
||||
<button mat-button (click)="clearAll()">Clear All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mat-accordion multi>
|
||||
@for (group of groupedCategories(); track group.groupName) {
|
||||
<mat-expansion-panel>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-checkbox
|
||||
[checked]="isGroupSelected(group.groupName)"
|
||||
[indeterminate]="isGroupPartial(group.groupName)"
|
||||
(change)="toggleGroup(group.groupName, $event.checked)"
|
||||
(click)="$event.stopPropagation()">
|
||||
</mat-checkbox>
|
||||
<mat-icon [style.color]="getGroupColor(group.groupName)">
|
||||
{{ getGroupIcon(group.groupName) }}
|
||||
</mat-icon>
|
||||
<span>{{ group.groupName }}</span>
|
||||
</mat-panel-title>
|
||||
<mat-panel-description>
|
||||
{{ getGroupSelectedCount(group.groupName) }} / {{ group.categories.length }} enabled
|
||||
</mat-panel-description>
|
||||
</mat-expansion-panel-header>
|
||||
|
||||
<div class="category-list">
|
||||
@for (category of group.categories; track category.id) {
|
||||
<div class="category-item">
|
||||
<mat-checkbox
|
||||
[checked]="isSelected(category.id)"
|
||||
(change)="toggleCategory(category.id, $event.checked)">
|
||||
<div class="category-info">
|
||||
<span class="category-name">{{ category.name }}</span>
|
||||
<span class="category-description">{{ category.description }}</span>
|
||||
</div>
|
||||
</mat-checkbox>
|
||||
|
||||
<div class="category-meta">
|
||||
<mat-chip-set>
|
||||
<mat-chip [matTooltip]="'Rules in this category'">
|
||||
{{ category.ruleCount }} rules
|
||||
</mat-chip>
|
||||
@if (category.severity) {
|
||||
<mat-chip
|
||||
[class]="'severity-' + category.severity"
|
||||
[matTooltip]="'Default severity'">
|
||||
{{ category.severity }}
|
||||
</mat-chip>
|
||||
}
|
||||
</mat-chip-set>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
}
|
||||
</mat-accordion>
|
||||
|
||||
@if (categories.length === 0) {
|
||||
<div class="empty-state">
|
||||
<mat-icon>category</mat-icon>
|
||||
<p>No rule categories available.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.category-toggles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.selection-info {
|
||||
font-size: 14px;
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
mat-expansion-panel-header mat-checkbox {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
mat-expansion-panel-header mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.category-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
background: var(--mat-background-card);
|
||||
border: 1px solid var(--mat-foreground-divider);
|
||||
}
|
||||
|
||||
.category-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.category-description {
|
||||
font-size: 13px;
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
}
|
||||
|
||||
.category-meta {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.severity-critical {
|
||||
background-color: #dc3545 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.severity-high {
|
||||
background-color: #fd7e14 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.severity-medium {
|
||||
background-color: #ffc107 !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
.severity-low {
|
||||
background-color: #6c757d !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--mat-foreground-hint-text);
|
||||
}
|
||||
|
||||
.empty-state mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class RuleCategoryTogglesComponent {
|
||||
@Input() categories: SecretRuleCategory[] = [];
|
||||
@Input() selected: string[] = [];
|
||||
@Output() selectionChange = new EventEmitter<string[]>();
|
||||
|
||||
private readonly selectedSet = signal(new Set<string>());
|
||||
|
||||
readonly selectedCount = computed(() => this.selectedSet().size);
|
||||
|
||||
readonly groupedCategories = computed(() => {
|
||||
const groups = new Map<string, SecretRuleCategory[]>();
|
||||
|
||||
for (const cat of this.categories) {
|
||||
const groupName = cat.group ?? 'Other';
|
||||
const existing = groups.get(groupName) ?? [];
|
||||
existing.push(cat);
|
||||
groups.set(groupName, existing);
|
||||
}
|
||||
|
||||
return Array.from(groups.entries())
|
||||
.map(([groupName, categories]) => ({ groupName, categories }))
|
||||
.sort((a, b) => a.groupName.localeCompare(b.groupName));
|
||||
});
|
||||
|
||||
ngOnChanges(): void {
|
||||
this.selectedSet.set(new Set(this.selected));
|
||||
}
|
||||
|
||||
isSelected(categoryId: string): boolean {
|
||||
return this.selectedSet().has(categoryId);
|
||||
}
|
||||
|
||||
isGroupSelected(groupName: string): boolean {
|
||||
const group = this.groupedCategories().find(g => g.groupName === groupName);
|
||||
if (!group) return false;
|
||||
return group.categories.every(c => this.isSelected(c.id));
|
||||
}
|
||||
|
||||
isGroupPartial(groupName: string): boolean {
|
||||
const group = this.groupedCategories().find(g => g.groupName === groupName);
|
||||
if (!group) return false;
|
||||
|
||||
const selectedCount = group.categories.filter(c => this.isSelected(c.id)).length;
|
||||
return selectedCount > 0 && selectedCount < group.categories.length;
|
||||
}
|
||||
|
||||
getGroupSelectedCount(groupName: string): number {
|
||||
const group = this.groupedCategories().find(g => g.groupName === groupName);
|
||||
if (!group) return 0;
|
||||
return group.categories.filter(c => this.isSelected(c.id)).length;
|
||||
}
|
||||
|
||||
toggleCategory(categoryId: string, checked: boolean): void {
|
||||
const current = new Set(this.selectedSet());
|
||||
if (checked) {
|
||||
current.add(categoryId);
|
||||
} else {
|
||||
current.delete(categoryId);
|
||||
}
|
||||
this.selectedSet.set(current);
|
||||
this.emitSelection();
|
||||
}
|
||||
|
||||
toggleGroup(groupName: string, checked: boolean): void {
|
||||
const group = this.groupedCategories().find(g => g.groupName === groupName);
|
||||
if (!group) return;
|
||||
|
||||
const current = new Set(this.selectedSet());
|
||||
for (const cat of group.categories) {
|
||||
if (checked) {
|
||||
current.add(cat.id);
|
||||
} else {
|
||||
current.delete(cat.id);
|
||||
}
|
||||
}
|
||||
this.selectedSet.set(current);
|
||||
this.emitSelection();
|
||||
}
|
||||
|
||||
selectAll(): void {
|
||||
const all = new Set(this.categories.map(c => c.id));
|
||||
this.selectedSet.set(all);
|
||||
this.emitSelection();
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.selectedSet.set(new Set());
|
||||
this.emitSelection();
|
||||
}
|
||||
|
||||
private emitSelection(): void {
|
||||
this.selectionChange.emit(Array.from(this.selectedSet()));
|
||||
}
|
||||
|
||||
getGroupIcon(groupName: string): string {
|
||||
const iconMap: Record<string, string> = {
|
||||
'API Keys': 'vpn_key',
|
||||
'Tokens': 'token',
|
||||
'Certificates': 'verified_user',
|
||||
'Passwords': 'password',
|
||||
'Private Keys': 'key',
|
||||
'Credentials': 'lock',
|
||||
'Cloud': 'cloud',
|
||||
'Database': 'storage',
|
||||
'Other': 'category',
|
||||
};
|
||||
return iconMap[groupName] ?? 'category';
|
||||
}
|
||||
|
||||
getGroupColor(groupName: string): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
'API Keys': '#1976d2',
|
||||
'Tokens': '#7b1fa2',
|
||||
'Certificates': '#388e3c',
|
||||
'Passwords': '#d32f2f',
|
||||
'Private Keys': '#f57c00',
|
||||
'Credentials': '#455a64',
|
||||
'Cloud': '#0288d1',
|
||||
'Database': '#5d4037',
|
||||
'Other': '#757575',
|
||||
};
|
||||
return colorMap[groupName] ?? '#757575';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Secret Detection Settings Page Component.
|
||||
*
|
||||
* Main settings page for configuring secret detection per tenant.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-002 - Build settings page component
|
||||
*/
|
||||
|
||||
import { Component, inject, OnInit, computed, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import { SecretDetectionSettingsService } from '../../services';
|
||||
import { RevelationPolicySelectorComponent } from '../settings/revelation-policy-selector.component';
|
||||
import { RuleCategoryTogglesComponent } from '../settings/rule-category-toggles.component';
|
||||
import { ExceptionManagerComponent } from '../exceptions/exception-manager.component';
|
||||
import { AlertDestinationConfigComponent } from '../alerts/alert-destination-config.component';
|
||||
import { RevelationPolicyConfig, SecretAlertSettings } from '../../models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-secret-detection-settings',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatTabsModule,
|
||||
MatSlideToggleModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatSnackBarModule,
|
||||
RevelationPolicySelectorComponent,
|
||||
RuleCategoryTogglesComponent,
|
||||
ExceptionManagerComponent,
|
||||
AlertDestinationConfigComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="settings-container">
|
||||
<header class="settings-header">
|
||||
<div class="title-section">
|
||||
<mat-icon>security</mat-icon>
|
||||
<h1>Secret Detection</h1>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
@if (loading()) {
|
||||
<mat-spinner diameter="24"></mat-spinner>
|
||||
}
|
||||
|
||||
<mat-slide-toggle
|
||||
[checked]="isEnabled()"
|
||||
(change)="onEnabledToggle($event.checked)"
|
||||
color="primary"
|
||||
[disabled]="loading()">
|
||||
{{ isEnabled() ? 'Enabled' : 'Disabled' }}
|
||||
</mat-slide-toggle>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error()) {
|
||||
<mat-card class="error-card">
|
||||
<mat-card-content>
|
||||
<mat-icon color="warn">error</mat-icon>
|
||||
<span>{{ error() }}</span>
|
||||
<button mat-button color="primary" (click)="reload()">
|
||||
Retry
|
||||
</button>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
@if (settings(); as s) {
|
||||
<mat-tab-group animationDuration="0ms">
|
||||
<mat-tab label="General">
|
||||
<div class="tab-content">
|
||||
<section class="section">
|
||||
<h2>Revelation Policy</h2>
|
||||
<p class="section-description">
|
||||
Control how detected secrets are displayed in different contexts.
|
||||
</p>
|
||||
<app-revelation-policy-selector
|
||||
[config]="s.revelationPolicy"
|
||||
(configChange)="onPolicyChange($event)">
|
||||
</app-revelation-policy-selector>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Rule Categories</h2>
|
||||
<p class="section-description">
|
||||
Select which types of secrets to detect.
|
||||
</p>
|
||||
<app-rule-category-toggles
|
||||
[categories]="categories()"
|
||||
[selected]="s.enabledRuleCategories"
|
||||
(selectionChange)="onCategoriesChange($event)">
|
||||
</app-rule-category-toggles>
|
||||
</section>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab label="Exceptions">
|
||||
<div class="tab-content">
|
||||
<app-exception-manager
|
||||
[exceptions]="s.exceptions"
|
||||
[tenantId]="tenantId()">
|
||||
</app-exception-manager>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab label="Alerts">
|
||||
<div class="tab-content">
|
||||
<app-alert-destination-config
|
||||
[settings]="s.alertSettings"
|
||||
[tenantId]="tenantId()"
|
||||
(settingsChange)="onAlertSettingsChange($event)">
|
||||
</app-alert-destination-config>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
} @else if (!loading()) {
|
||||
<mat-card class="empty-state">
|
||||
<mat-card-content>
|
||||
<mat-icon>settings</mat-icon>
|
||||
<h2>No Settings Found</h2>
|
||||
<p>Secret detection has not been configured for this tenant.</p>
|
||||
<button mat-raised-button color="primary" (click)="initializeSettings()">
|
||||
Initialize Settings
|
||||
</button>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.settings-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title-section mat-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--mat-primary-500);
|
||||
}
|
||||
|
||||
.title-section h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
background-color: var(--mat-warn-50);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-card mat-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.empty-state mat-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--mat-foreground-hint-text);
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--mat-foreground-secondary-text);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SecretDetectionSettingsComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly settingsService = inject(SecretDetectionSettingsService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
|
||||
// Get tenant ID from route
|
||||
readonly tenantId = signal('');
|
||||
|
||||
// Expose service signals
|
||||
readonly settings = this.settingsService.settings;
|
||||
readonly categories = this.settingsService.categories;
|
||||
readonly loading = this.settingsService.loading;
|
||||
readonly error = this.settingsService.error;
|
||||
readonly isEnabled = this.settingsService.isEnabled;
|
||||
|
||||
ngOnInit(): void {
|
||||
const tenantId = this.route.snapshot.paramMap.get('tenantId') ?? '';
|
||||
this.tenantId.set(tenantId);
|
||||
|
||||
if (tenantId) {
|
||||
this.reload();
|
||||
}
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
const tid = this.tenantId();
|
||||
if (tid) {
|
||||
this.settingsService.loadSettings(tid).subscribe();
|
||||
this.settingsService.loadCategories().subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
initializeSettings(): void {
|
||||
const tid = this.tenantId();
|
||||
if (tid) {
|
||||
this.settingsService.createSettings(tid).subscribe({
|
||||
next: () => this.showSuccess('Settings initialized'),
|
||||
error: () => this.showError('Failed to initialize settings'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onEnabledToggle(enabled: boolean): void {
|
||||
const tid = this.tenantId();
|
||||
if (tid) {
|
||||
this.settingsService.toggleEnabled(tid, enabled).subscribe({
|
||||
next: () => this.showSuccess(enabled ? 'Secret detection enabled' : 'Secret detection disabled'),
|
||||
error: () => this.showError('Failed to update setting'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onPolicyChange(policy: RevelationPolicyConfig): void {
|
||||
const tid = this.tenantId();
|
||||
if (tid) {
|
||||
this.settingsService.updateSettings(tid, { revelationPolicy: policy }).subscribe({
|
||||
next: () => this.showSuccess('Revelation policy updated'),
|
||||
error: () => this.showError('Failed to update policy'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onCategoriesChange(categoryIds: string[]): void {
|
||||
const tid = this.tenantId();
|
||||
if (tid) {
|
||||
this.settingsService.updateCategories(tid, categoryIds).subscribe({
|
||||
next: () => this.showSuccess('Rule categories updated'),
|
||||
error: () => this.showError('Failed to update categories'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onAlertSettingsChange(alertSettings: SecretAlertSettings): void {
|
||||
const tid = this.tenantId();
|
||||
if (tid) {
|
||||
this.settingsService.updateSettings(tid, { alertSettings }).subscribe({
|
||||
next: () => this.showSuccess('Alert settings updated'),
|
||||
error: () => this.showError('Failed to update alert settings'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private showSuccess(message: string): void {
|
||||
this.snackBar.open(message, 'Close', {
|
||||
duration: 3000,
|
||||
panelClass: 'snackbar-success',
|
||||
});
|
||||
}
|
||||
|
||||
private showError(message: string): void {
|
||||
this.snackBar.open(message, 'Close', {
|
||||
duration: 5000,
|
||||
panelClass: 'snackbar-error',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Secret Detection Feature Module.
|
||||
*
|
||||
* Main barrel export for the secret detection feature.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-001 - Feature module structure
|
||||
*/
|
||||
|
||||
// Models
|
||||
export * from './models';
|
||||
|
||||
// Services
|
||||
export * from './services';
|
||||
|
||||
// Components
|
||||
export * from './components';
|
||||
|
||||
// Routes
|
||||
export * from './secret-detection.routes';
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Models barrel export.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE
|
||||
* @task SDU-001
|
||||
*/
|
||||
export * from './secret-detection.models';
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Secret Detection domain models.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-001 - Create secret-detection feature module
|
||||
*/
|
||||
|
||||
/**
|
||||
* Per-tenant secret detection configuration.
|
||||
*/
|
||||
export interface SecretDetectionSettings {
|
||||
tenantId: string;
|
||||
enabled: boolean;
|
||||
revelationPolicy: RevelationPolicyConfig;
|
||||
enabledRuleCategories: string[];
|
||||
exceptions: SecretExceptionPattern[];
|
||||
alertSettings: SecretAlertSettings;
|
||||
updatedAt: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revelation policy for how secrets are displayed.
|
||||
*/
|
||||
export type SecretRevelationPolicy = 'FullMask' | 'PartialReveal' | 'FullReveal';
|
||||
|
||||
/**
|
||||
* Configuration for revelation policies by context.
|
||||
*/
|
||||
export interface RevelationPolicyConfig {
|
||||
defaultPolicy: SecretRevelationPolicy;
|
||||
exportPolicy: SecretRevelationPolicy;
|
||||
logPolicy: SecretRevelationPolicy;
|
||||
fullRevealRoles: string[];
|
||||
partialRevealChars: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception pattern for allowlisting false positives.
|
||||
*/
|
||||
export interface SecretExceptionPattern {
|
||||
id: string;
|
||||
pattern: string;
|
||||
reason: string;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
expiresAt?: string;
|
||||
ruleIds?: string[];
|
||||
pathFilter?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert configuration for secret findings.
|
||||
*/
|
||||
export interface SecretAlertSettings {
|
||||
enabled: boolean;
|
||||
minimumAlertSeverity: SecretSeverity;
|
||||
destinations: SecretAlertDestination[];
|
||||
maxAlertsPerScan: number;
|
||||
deduplicationWindowHours: number;
|
||||
includeFilePath: boolean;
|
||||
includeMaskedValue: boolean;
|
||||
includeImageRef: boolean;
|
||||
alertMessagePrefix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert destination configuration.
|
||||
*/
|
||||
export interface SecretAlertDestination {
|
||||
id: string;
|
||||
name: string;
|
||||
channelType: AlertChannelType;
|
||||
channelId: string;
|
||||
severityFilter?: SecretSeverity[];
|
||||
ruleCategoryFilter?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert channel types.
|
||||
*/
|
||||
export type AlertChannelType = 'Slack' | 'Teams' | 'Email' | 'Webhook' | 'PagerDuty';
|
||||
|
||||
/**
|
||||
* Severity levels for secret findings.
|
||||
*/
|
||||
export type SecretSeverity = 'Low' | 'Medium' | 'High' | 'Critical';
|
||||
|
||||
/**
|
||||
* Secret finding from a scan.
|
||||
*/
|
||||
export interface SecretFinding {
|
||||
id: string;
|
||||
scanId: string;
|
||||
imageRef: string;
|
||||
severity: SecretSeverity;
|
||||
ruleId: string;
|
||||
ruleName: string;
|
||||
ruleCategory: string;
|
||||
filePath: string;
|
||||
lineNumber: number;
|
||||
maskedValue: string;
|
||||
detectedAt: string;
|
||||
status: SecretFindingStatus;
|
||||
excepted: boolean;
|
||||
exceptionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of a secret finding.
|
||||
*/
|
||||
export type SecretFindingStatus = 'New' | 'Acknowledged' | 'Resolved' | 'FalsePositive';
|
||||
|
||||
/**
|
||||
* Available rule categories for secret detection.
|
||||
*/
|
||||
export interface SecretRuleCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ruleCount: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for creating an exception pattern.
|
||||
*/
|
||||
export interface CreateExceptionRequest {
|
||||
pattern: string;
|
||||
reason: string;
|
||||
expiresAt?: string;
|
||||
ruleIds?: string[];
|
||||
pathFilter?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for updating settings.
|
||||
*/
|
||||
export interface UpdateSettingsRequest {
|
||||
enabled?: boolean;
|
||||
revelationPolicy?: Partial<RevelationPolicyConfig>;
|
||||
enabledRuleCategories?: string[];
|
||||
alertSettings?: Partial<SecretAlertSettings>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Secret Detection Feature Routes.
|
||||
*
|
||||
* Defines routing configuration for the secret detection feature module.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-001 - Feature module structure
|
||||
*/
|
||||
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const SECRET_DETECTION_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'settings',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
loadComponent: () =>
|
||||
import('./components/settings/secret-detection-settings.component')
|
||||
.then(m => m.SecretDetectionSettingsComponent),
|
||||
title: 'Secret Detection Settings',
|
||||
},
|
||||
{
|
||||
path: 'findings',
|
||||
loadComponent: () =>
|
||||
import('./components/findings/secret-findings-list.component')
|
||||
.then(m => m.SecretFindingsListComponent),
|
||||
title: 'Secret Findings',
|
||||
},
|
||||
{
|
||||
path: 'findings/:findingId',
|
||||
loadComponent: () =>
|
||||
import('./components/findings/secret-findings-list.component')
|
||||
.then(m => m.SecretFindingsListComponent),
|
||||
title: 'Finding Details',
|
||||
},
|
||||
{
|
||||
path: 'exceptions',
|
||||
loadComponent: () =>
|
||||
import('./components/exceptions/exception-manager.component')
|
||||
.then(m => m.ExceptionManagerComponent),
|
||||
title: 'Secret Exceptions',
|
||||
},
|
||||
{
|
||||
path: 'exceptions/new',
|
||||
loadComponent: () =>
|
||||
import('./components/exceptions/exception-manager.component')
|
||||
.then(m => m.ExceptionManagerComponent),
|
||||
title: 'New Exception',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Services barrel export.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE
|
||||
* @task SDU-001
|
||||
*/
|
||||
export * from './secret-detection-settings.service';
|
||||
export * from './secret-exception.service';
|
||||
export * from './secret-findings.service';
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Secret Detection Settings API Service.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-001 - Create secret-detection feature module
|
||||
*/
|
||||
|
||||
import { Injectable, inject, signal, computed } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { catchError, map, tap } from 'rxjs/operators';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import {
|
||||
SecretDetectionSettings,
|
||||
SecretExceptionPattern,
|
||||
SecretRuleCategory,
|
||||
CreateExceptionRequest,
|
||||
UpdateSettingsRequest,
|
||||
SecretFinding,
|
||||
} from '../models';
|
||||
|
||||
/**
|
||||
* API service for secret detection configuration.
|
||||
* Communicates with Scanner WebService endpoints.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SecretDetectionSettingsService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = '/api/v1/secrets/config';
|
||||
|
||||
// State signals
|
||||
private readonly _settings = signal<SecretDetectionSettings | null>(null);
|
||||
private readonly _categories = signal<SecretRuleCategory[]>([]);
|
||||
private readonly _loading = signal(false);
|
||||
private readonly _error = signal<string | null>(null);
|
||||
|
||||
// Public readonly signals
|
||||
readonly settings = this._settings.asReadonly();
|
||||
readonly categories = this._categories.asReadonly();
|
||||
readonly loading = this._loading.asReadonly();
|
||||
readonly error = this._error.asReadonly();
|
||||
|
||||
// Computed values
|
||||
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
|
||||
);
|
||||
|
||||
/**
|
||||
* Loads settings for a tenant.
|
||||
*/
|
||||
loadSettings(tenantId: string): Observable<SecretDetectionSettings> {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
return this.http.get<SecretDetectionSettings>(
|
||||
`${this.baseUrl}/settings/${tenantId}`
|
||||
).pipe(
|
||||
tap(settings => {
|
||||
this._settings.set(settings);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._error.set(err.message || 'Failed to load settings');
|
||||
this._loading.set(false);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates default settings for a new tenant.
|
||||
*/
|
||||
createSettings(tenantId: string): Observable<SecretDetectionSettings> {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
return this.http.post<SecretDetectionSettings>(
|
||||
`${this.baseUrl}/settings/${tenantId}`,
|
||||
{}
|
||||
).pipe(
|
||||
tap(settings => {
|
||||
this._settings.set(settings);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._error.set(err.message || 'Failed to create settings');
|
||||
this._loading.set(false);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates settings for a tenant.
|
||||
*/
|
||||
updateSettings(
|
||||
tenantId: string,
|
||||
request: UpdateSettingsRequest
|
||||
): Observable<SecretDetectionSettings> {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
return this.http.put<SecretDetectionSettings>(
|
||||
`${this.baseUrl}/settings/${tenantId}`,
|
||||
request
|
||||
).pipe(
|
||||
tap(settings => {
|
||||
this._settings.set(settings);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._error.set(err.message || 'Failed to update settings');
|
||||
this._loading.set(false);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles secret detection on/off.
|
||||
*/
|
||||
toggleEnabled(tenantId: string, enabled: boolean): Observable<SecretDetectionSettings> {
|
||||
return this.updateSettings(tenantId, { enabled });
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads available rule categories.
|
||||
*/
|
||||
loadCategories(): Observable<SecretRuleCategory[]> {
|
||||
return this.http.get<SecretRuleCategory[]>(
|
||||
`${this.baseUrl}/rules/categories`
|
||||
).pipe(
|
||||
tap(categories => this._categories.set(categories)),
|
||||
catchError(err => {
|
||||
this._error.set(err.message || 'Failed to load categories');
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates enabled rule categories.
|
||||
*/
|
||||
updateCategories(
|
||||
tenantId: string,
|
||||
categoryIds: string[]
|
||||
): Observable<SecretDetectionSettings> {
|
||||
return this.updateSettings(tenantId, { enabledRuleCategories: categoryIds });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a test alert to verify destination configuration.
|
||||
* @sprint SDU-011 - Add channel test functionality
|
||||
*/
|
||||
testAlertDestination(
|
||||
tenantId: string,
|
||||
destinationId: string
|
||||
): Observable<{ success: boolean; message: string }> {
|
||||
return this.http.post<{ success: boolean; message: string }>(
|
||||
`${this.baseUrl}/settings/${tenantId}/alerts/test`,
|
||||
{ destinationId }
|
||||
).pipe(
|
||||
catchError(err => {
|
||||
return of({
|
||||
success: false,
|
||||
message: err.error?.message || err.message || 'Test failed',
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Secret Exception API Service.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-008, SDU-009 - Exception manager
|
||||
*/
|
||||
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { catchError, tap } from 'rxjs/operators';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import {
|
||||
SecretExceptionPattern,
|
||||
CreateExceptionRequest,
|
||||
} from '../models';
|
||||
|
||||
/**
|
||||
* API service for secret exception patterns.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SecretExceptionService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = '/api/v1/secrets/config/exceptions';
|
||||
|
||||
private readonly _exceptions = signal<SecretExceptionPattern[]>([]);
|
||||
private readonly _loading = signal(false);
|
||||
private readonly _error = signal<string | null>(null);
|
||||
|
||||
readonly exceptions = this._exceptions.asReadonly();
|
||||
readonly loading = this._loading.asReadonly();
|
||||
readonly error = this._error.asReadonly();
|
||||
|
||||
/**
|
||||
* Lists all exception patterns for a tenant.
|
||||
*/
|
||||
listExceptions(tenantId: string): Observable<SecretExceptionPattern[]> {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
return this.http.get<{ items: SecretExceptionPattern[] }>(
|
||||
`${this.baseUrl}/${tenantId}`
|
||||
).pipe(
|
||||
tap(response => {
|
||||
this._exceptions.set(response.items);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._error.set(err.message || 'Failed to load exceptions');
|
||||
this._loading.set(false);
|
||||
return of({ items: [] });
|
||||
}),
|
||||
// Transform response
|
||||
tap(() => {}),
|
||||
// Return just the items array
|
||||
tap(response => this._exceptions.set(response.items))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new exception pattern.
|
||||
*/
|
||||
createException(
|
||||
tenantId: string,
|
||||
request: CreateExceptionRequest
|
||||
): Observable<SecretExceptionPattern> {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
return this.http.post<SecretExceptionPattern>(
|
||||
`${this.baseUrl}/${tenantId}`,
|
||||
request
|
||||
).pipe(
|
||||
tap(exception => {
|
||||
// Add to local state
|
||||
this._exceptions.update(current => [...current, exception]);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._error.set(err.message || 'Failed to create exception');
|
||||
this._loading.set(false);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing exception pattern.
|
||||
*/
|
||||
updateException(
|
||||
tenantId: string,
|
||||
exceptionId: string,
|
||||
request: Partial<CreateExceptionRequest>
|
||||
): Observable<SecretExceptionPattern> {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
return this.http.put<SecretExceptionPattern>(
|
||||
`${this.baseUrl}/${tenantId}/${exceptionId}`,
|
||||
request
|
||||
).pipe(
|
||||
tap(updated => {
|
||||
// Update in local state
|
||||
this._exceptions.update(current =>
|
||||
current.map(e => e.id === exceptionId ? updated : e)
|
||||
);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._error.set(err.message || 'Failed to update exception');
|
||||
this._loading.set(false);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an exception pattern.
|
||||
*/
|
||||
deleteException(tenantId: string, exceptionId: string): Observable<void> {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
return this.http.delete<void>(
|
||||
`${this.baseUrl}/${tenantId}/${exceptionId}`
|
||||
).pipe(
|
||||
tap(() => {
|
||||
// Remove from local state
|
||||
this._exceptions.update(current =>
|
||||
current.filter(e => e.id !== exceptionId)
|
||||
);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._error.set(err.message || 'Failed to delete exception');
|
||||
this._loading.set(false);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an exception pattern without saving.
|
||||
*/
|
||||
validatePattern(pattern: string): Observable<{ valid: boolean; error?: string }> {
|
||||
return this.http.post<{ valid: boolean; error?: string }>(
|
||||
`${this.baseUrl}/validate`,
|
||||
{ pattern }
|
||||
).pipe(
|
||||
catchError(() => of({ valid: false, error: 'Validation service unavailable' }))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Secret Findings API Service.
|
||||
*
|
||||
* @sprint SPRINT_20260104_008_FE (Secret Detection UI)
|
||||
* @task SDU-005 - Create findings list component
|
||||
*/
|
||||
|
||||
import { Injectable, inject, signal, computed } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { catchError, tap } from 'rxjs/operators';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import {
|
||||
SecretFinding,
|
||||
SecretSeverity,
|
||||
SecretFindingStatus,
|
||||
} from '../models';
|
||||
|
||||
/**
|
||||
* Query parameters for listing findings.
|
||||
*/
|
||||
export interface FindingsQuery {
|
||||
scanId?: string;
|
||||
imageRef?: string;
|
||||
severity?: SecretSeverity[];
|
||||
status?: SecretFindingStatus[];
|
||||
ruleCategory?: string[];
|
||||
excepted?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated response for findings.
|
||||
*/
|
||||
export interface FindingsResponse {
|
||||
items: SecretFinding[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* API service for secret findings.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SecretFindingsService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = '/api/v1/secrets/findings';
|
||||
|
||||
private readonly _findings = signal<SecretFinding[]>([]);
|
||||
private readonly _selectedFinding = signal<SecretFinding | null>(null);
|
||||
private readonly _loading = signal(false);
|
||||
private readonly _error = signal<string | null>(null);
|
||||
private readonly _total = signal(0);
|
||||
private readonly _page = signal(1);
|
||||
private readonly _pageSize = signal(25);
|
||||
|
||||
readonly findings = this._findings.asReadonly();
|
||||
readonly selectedFinding = this._selectedFinding.asReadonly();
|
||||
readonly loading = this._loading.asReadonly();
|
||||
readonly error = this._error.asReadonly();
|
||||
readonly total = this._total.asReadonly();
|
||||
readonly page = this._page.asReadonly();
|
||||
readonly pageSize = this._pageSize.asReadonly();
|
||||
|
||||
// Computed statistics
|
||||
readonly stats = computed(() => {
|
||||
const findings = this._findings();
|
||||
return {
|
||||
total: findings.length,
|
||||
critical: findings.filter(f => f.severity === 'Critical').length,
|
||||
high: findings.filter(f => f.severity === 'High').length,
|
||||
medium: findings.filter(f => f.severity === 'Medium').length,
|
||||
low: findings.filter(f => f.severity === 'Low').length,
|
||||
excepted: findings.filter(f => f.excepted).length,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Lists findings with optional filters.
|
||||
*/
|
||||
listFindings(tenantId: string, query?: FindingsQuery): Observable<FindingsResponse> {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
|
||||
let params = new HttpParams();
|
||||
if (query?.scanId) params = params.set('scanId', query.scanId);
|
||||
if (query?.imageRef) params = params.set('imageRef', query.imageRef);
|
||||
if (query?.severity?.length) params = params.set('severity', query.severity.join(','));
|
||||
if (query?.status?.length) params = params.set('status', query.status.join(','));
|
||||
if (query?.ruleCategory?.length) params = params.set('ruleCategory', query.ruleCategory.join(','));
|
||||
if (query?.excepted !== undefined) params = params.set('excepted', String(query.excepted));
|
||||
if (query?.page) params = params.set('page', String(query.page));
|
||||
if (query?.pageSize) params = params.set('pageSize', String(query.pageSize));
|
||||
|
||||
return this.http.get<FindingsResponse>(
|
||||
`${this.baseUrl}/${tenantId}`,
|
||||
{ params }
|
||||
).pipe(
|
||||
tap(response => {
|
||||
this._findings.set(response.items);
|
||||
this._total.set(response.total);
|
||||
this._page.set(response.page);
|
||||
this._pageSize.set(response.pageSize);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._error.set(err.message || 'Failed to load findings');
|
||||
this._loading.set(false);
|
||||
return of({ items: [], total: 0, page: 1, pageSize: 25 });
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a single finding by ID.
|
||||
*/
|
||||
getFinding(tenantId: string, findingId: string): Observable<SecretFinding> {
|
||||
this._loading.set(true);
|
||||
|
||||
return this.http.get<SecretFinding>(
|
||||
`${this.baseUrl}/${tenantId}/${findingId}`
|
||||
).pipe(
|
||||
tap(finding => {
|
||||
this._selectedFinding.set(finding);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._error.set(err.message || 'Failed to load finding');
|
||||
this._loading.set(false);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates finding status.
|
||||
*/
|
||||
updateStatus(
|
||||
tenantId: string,
|
||||
findingId: string,
|
||||
status: SecretFindingStatus
|
||||
): Observable<SecretFinding> {
|
||||
return this.http.patch<SecretFinding>(
|
||||
`${this.baseUrl}/${tenantId}/${findingId}/status`,
|
||||
{ status }
|
||||
).pipe(
|
||||
tap(updated => {
|
||||
// Update in local state
|
||||
this._findings.update(current =>
|
||||
current.map(f => f.id === findingId ? updated : f)
|
||||
);
|
||||
if (this._selectedFinding()?.id === findingId) {
|
||||
this._selectedFinding.set(updated);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a finding for detail view.
|
||||
*/
|
||||
selectFinding(finding: SecretFinding | null): void {
|
||||
this._selectedFinding.set(finding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears current selection.
|
||||
*/
|
||||
clearSelection(): void {
|
||||
this._selectedFinding.set(null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user