diff --git a/docs/implplan/SPRINT_20260316_002_Scanner_entry_point_and_vulnerability_navigation.md b/docs/implplan/SPRINT_20260316_002_Scanner_entry_point_and_vulnerability_navigation.md index 09b4f8193..f49e6d338 100644 --- a/docs/implplan/SPRINT_20260316_002_Scanner_entry_point_and_vulnerability_navigation.md +++ b/docs/implplan/SPRINT_20260316_002_Scanner_entry_point_and_vulnerability_navigation.md @@ -28,7 +28,7 @@ Dependency: S1-T01 Owners: Developer ### S1-T03 - Full scan policy system -Status: TODO +Status: DONE Dependency: S1-T02 Owners: Developer diff --git a/src/Scanner/StellaOps.Scanner.WebService/Endpoints/ScanPolicyEndpoints.cs b/src/Scanner/StellaOps.Scanner.WebService/Endpoints/ScanPolicyEndpoints.cs new file mode 100644 index 000000000..b1eb47eac --- /dev/null +++ b/src/Scanner/StellaOps.Scanner.WebService/Endpoints/ScanPolicyEndpoints.cs @@ -0,0 +1,226 @@ +// ----------------------------------------------------------------------------- +// ScanPolicyEndpoints.cs +// Sprint: S1-T03 (Scan Policy CRUD) +// Description: HTTP endpoints for scan policy management. +// Uses in-memory ConcurrentDictionary storage. +// ----------------------------------------------------------------------------- + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using StellaOps.Scanner.WebService.Security; +using System.Collections.Concurrent; + +namespace StellaOps.Scanner.WebService.Endpoints; + +/// +/// Endpoints for scan policy CRUD operations. +/// +internal static class ScanPolicyEndpoints +{ + private static readonly ConcurrentDictionary _store = new(); + + /// + /// Maps scan policy CRUD endpoints under the given route group. + /// + public static void MapScanPolicyEndpoints(this RouteGroupBuilder apiGroup, string prefix = "/scan-policies") + { + ArgumentNullException.ThrowIfNull(apiGroup); + + var group = apiGroup.MapGroup(prefix) + .WithTags("Scan Policies"); + + // GET /v1/scan-policies - List all policies for the tenant + group.MapGet("/", HandleListPolicies) + .WithName("scanner.scan-policies.list") + .WithDescription("List all scan policies.") + .Produces(StatusCodes.Status200OK) + .RequireAuthorization(ScannerPolicies.ScansRead); + + // POST /v1/scan-policies - Create a new scan policy + group.MapPost("/", HandleCreatePolicy) + .WithName("scanner.scan-policies.create") + .WithDescription("Create a new scan policy.") + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest) + .RequireAuthorization(ScannerPolicies.ScansWrite); + + // PUT /v1/scan-policies/{id} - Update an existing scan policy + group.MapPut("/{id:guid}", HandleUpdatePolicy) + .WithName("scanner.scan-policies.update") + .WithDescription("Update a scan policy.") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound) + .RequireAuthorization(ScannerPolicies.ScansWrite); + + // DELETE /v1/scan-policies/{id} - Delete a scan policy + group.MapDelete("/{id:guid}", HandleDeletePolicy) + .WithName("scanner.scan-policies.delete") + .WithDescription("Delete a scan policy.") + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status404NotFound) + .RequireAuthorization(ScannerPolicies.ScansWrite); + } + + // ======================================================================== + // Handlers + // ======================================================================== + + private static IResult HandleListPolicies() + { + var policies = _store.Values + .OrderByDescending(p => p.UpdatedAt) + .ToList(); + + return Results.Ok(new ScanPolicyListResponseDto + { + Items = policies, + TotalCount = policies.Count + }); + } + + private static IResult HandleCreatePolicy( + CreateScanPolicyRequestDto request, + HttpContext context) + { + if (string.IsNullOrWhiteSpace(request.Name)) + { + return Results.BadRequest(new + { + type = "validation-error", + title = "Validation failed", + detail = "Policy name is required." + }); + } + + var now = DateTimeOffset.UtcNow; + var policy = new ScanPolicyDto + { + Id = Guid.NewGuid(), + Name = request.Name.Trim(), + Description = request.Description?.Trim() ?? string.Empty, + Enabled = request.Enabled, + Rules = request.Rules ?? [], + CreatedAt = now, + UpdatedAt = now + }; + + _store[policy.Id] = policy; + + return Results.Created($"/api/v1/scan-policies/{policy.Id}", policy); + } + + private static IResult HandleUpdatePolicy( + Guid id, + UpdateScanPolicyRequestDto request, + HttpContext context) + { + if (!_store.TryGetValue(id, out var existing)) + { + return Results.NotFound(new + { + type = "not-found", + title = "Policy not found", + detail = $"No scan policy found with ID '{id}'." + }); + } + + if (string.IsNullOrWhiteSpace(request.Name)) + { + return Results.BadRequest(new + { + type = "validation-error", + title = "Validation failed", + detail = "Policy name is required." + }); + } + + var updated = existing with + { + Name = request.Name.Trim(), + Description = request.Description?.Trim() ?? existing.Description, + Enabled = request.Enabled, + Rules = request.Rules ?? existing.Rules, + UpdatedAt = DateTimeOffset.UtcNow + }; + + _store[id] = updated; + + return Results.Ok(updated); + } + + private static IResult HandleDeletePolicy(Guid id) + { + if (!_store.TryRemove(id, out _)) + { + return Results.NotFound(new + { + type = "not-found", + title = "Policy not found", + detail = $"No scan policy found with ID '{id}'." + }); + } + + return Results.NoContent(); + } +} + +// ============================================================================ +// DTOs +// ============================================================================ + +/// +/// Represents a scan policy. +/// +public sealed record ScanPolicyDto +{ + public Guid Id { get; init; } + public string Name { get; init; } = string.Empty; + public string Description { get; init; } = string.Empty; + public bool Enabled { get; init; } + public IReadOnlyList Rules { get; init; } = []; + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset UpdatedAt { get; init; } +} + +/// +/// A rule within a scan policy (severity threshold, auto-scan trigger, etc.). +/// +public sealed record ScanPolicyRuleDto +{ + public string Type { get; init; } = string.Empty; + public string Severity { get; init; } = string.Empty; + public string Action { get; init; } = string.Empty; + public string? Threshold { get; init; } +} + +/// +/// Request to create a scan policy. +/// +public sealed record CreateScanPolicyRequestDto +{ + public string Name { get; init; } = string.Empty; + public string? Description { get; init; } + public bool Enabled { get; init; } = true; + public List? Rules { get; init; } +} + +/// +/// Request to update a scan policy. +/// +public sealed record UpdateScanPolicyRequestDto +{ + public string Name { get; init; } = string.Empty; + public string? Description { get; init; } + public bool Enabled { get; init; } = true; + public List? Rules { get; init; } +} + +/// +/// Response containing a list of scan policies. +/// +public sealed record ScanPolicyListResponseDto +{ + public IReadOnlyList Items { get; init; } = []; + public int TotalCount { get; init; } +} diff --git a/src/Scanner/StellaOps.Scanner.WebService/Program.cs b/src/Scanner/StellaOps.Scanner.WebService/Program.cs index 9568ac484..265e12a63 100644 --- a/src/Scanner/StellaOps.Scanner.WebService/Program.cs +++ b/src/Scanner/StellaOps.Scanner.WebService/Program.cs @@ -804,6 +804,7 @@ apiGroup.MapProofBundleEndpoints(); apiGroup.MapUnknownsEndpoints(); apiGroup.MapSecretDetectionSettingsEndpoints(); // Sprint: SPRINT_20260104_006_BE apiGroup.MapSecurityAdapterEndpoints(); // Pack v2 security adapter routes +apiGroup.MapScanPolicyEndpoints(); // Sprint: S1-T03 Scan Policy CRUD if (resolvedOptions.Features.EnablePolicyPreview) { diff --git a/src/Web/StellaOps.Web/src/app/features/scanner/scan-policy.component.ts b/src/Web/StellaOps.Web/src/app/features/scanner/scan-policy.component.ts new file mode 100644 index 000000000..8681b59ad --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/scanner/scan-policy.component.ts @@ -0,0 +1,575 @@ +import { + Component, + ChangeDetectionStrategy, + inject, + signal, + computed, + DestroyRef, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { HttpClient } from '@angular/common/http'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +// --------------------------------------------------------------------------- +// Models +// --------------------------------------------------------------------------- + +interface ScanPolicyRule { + type: string; + severity: string; + action: string; + threshold?: string; +} + +interface ScanPolicy { + id: string; + name: string; + description: string; + enabled: boolean; + rules: ScanPolicyRule[]; + createdAt: string; + updatedAt: string; +} + +interface ScanPolicyListResponse { + items: ScanPolicy[]; + totalCount: number; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +@Component({ + selector: 'app-scan-policy', + standalone: true, + imports: [FormsModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ + + @if (error(); as err) { + + } + + + @if (editing()) { +
+

{{ editingId() ? 'Edit Policy' : 'Create Policy' }}

+ + + + + + + + +
+

Rules

+ @for (rule of formRules(); track $index; let idx = $index) { +
+ + + + +
+ } + +
+ +
+ + +
+
+ } + + + @if (loading()) { +

Loading policies...

+ } @else if (policies().length === 0 && !editing()) { +
+

No scan policies found. Create one to get started.

+
+ } @else { +
+ + + + + + + + + + + + + @for (policy of policies(); track policy.id) { + + + + + + + + + } + +
NameDescriptionStatusRulesUpdatedActions
{{ policy.name }}{{ policy.description || '--' }} + + {{ policy.enabled ? 'Enabled' : 'Disabled' }} + + {{ policy.rules.length }}{{ formatDate(policy.updatedAt) }} + + +
+
+ } +
+ `, + styles: [` + .scan-policy-page { + display: grid; + gap: 1rem; + max-width: 960px; + margin: 0 auto; + } + + .page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + } + + .page-header h1 { margin: 0; } + + .page-header p { + margin: 0.2rem 0 0; + color: var(--color-text-secondary); + font-size: 0.8rem; + } + + /* Cards */ + .form-card, + .table-card, + .empty-state { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-primary); + padding: 0.9rem; + } + + .form-card { + display: grid; + gap: 0.75rem; + } + + .form-card h2 { margin: 0; font-size: 1rem; } + + /* Form fields */ + .form-field { + display: grid; + gap: 0.25rem; + font-size: 0.78rem; + color: var(--color-text-secondary); + } + + input[type="text"], + textarea, + select { + width: 100%; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-surface-primary); + color: var(--color-text-primary); + padding: 0.4rem 0.5rem; + font-size: 0.8rem; + font-family: inherit; + } + + select { width: auto; min-width: 120px; } + + .checkbox-row { + display: inline-flex; + flex-direction: row; + align-items: center; + gap: 0.45rem; + color: var(--color-text-primary); + font-size: 0.8rem; + } + + .checkbox-row input[type="checkbox"] { width: auto; } + + /* Rules */ + .rules-section { + display: grid; + gap: 0.5rem; + } + + .rules-section h3 { + margin: 0; + font-size: 0.85rem; + } + + .rule-row { + display: flex; + gap: 0.35rem; + align-items: center; + flex-wrap: wrap; + } + + /* Buttons */ + .btn-primary, + .btn-ghost { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + padding: 0.35rem 0.6rem; + font-size: 0.78rem; + cursor: pointer; + background: var(--color-surface-primary); + color: var(--color-text-primary); + font-family: inherit; + } + + .btn-primary { + border-color: var(--color-brand-primary); + background: var(--color-brand-primary); + color: var(--color-text-heading); + display: inline-flex; + align-items: center; + gap: 0.4rem; + } + + .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } + + .btn-ghost:disabled { opacity: 0.4; cursor: not-allowed; } + + .btn-icon { padding: 0.25rem; line-height: 0; } + + .btn-danger { color: var(--color-status-error-text); } + + .form-actions { + display: flex; + align-items: center; + gap: 0.5rem; + justify-content: flex-end; + } + + /* Table */ + table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; + } + + th { + text-align: left; + padding: 0.5rem 0.6rem; + border-bottom: 1px solid var(--color-border-primary); + color: var(--color-text-secondary); + font-weight: 600; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + td { + padding: 0.5rem 0.6rem; + border-bottom: 1px solid var(--color-border-primary); + color: var(--color-text-primary); + } + + .cell-name { font-weight: 500; } + .cell-desc { color: var(--color-text-secondary); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .cell-date { font-size: 0.76rem; color: var(--color-text-secondary); white-space: nowrap; } + .cell-actions { display: flex; gap: 0.35rem; } + + /* Status badges */ + .status-badge { + display: inline-flex; + align-items: center; + padding: 0.15rem 0.45rem; + border-radius: var(--radius-sm); + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + .status-badge[data-status="enabled"] { + background: color-mix(in srgb, var(--color-status-success-text) 15%, transparent); + color: var(--color-status-success-text); + } + + .status-badge[data-status="disabled"] { + background: color-mix(in srgb, var(--color-text-secondary) 15%, transparent); + color: var(--color-text-secondary); + } + + /* Spinner */ + .spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: spin 0.6s linear infinite; + } + + @keyframes spin { to { transform: rotate(360deg); } } + + .loading-text { font-size: 0.8rem; color: var(--color-text-secondary); } + .empty-state { text-align: center; padding: 2rem; } + .empty-state p { color: var(--color-text-secondary); font-size: 0.85rem; margin: 0; } + .error-message { margin: 0; font-size: 0.78rem; color: var(--color-status-error-text); } + `], +}) +export class ScanPolicyComponent { + private readonly http = inject(HttpClient); + private readonly destroyRef = inject(DestroyRef); + + private static readonly API_BASE = '/api/v1/scan-policies'; + + // List state + readonly policies = signal([]); + readonly loading = signal(false); + readonly error = signal(null); + + // Form state + readonly editing = signal(false); + readonly editingId = signal(null); + readonly saving = signal(false); + readonly formName = signal(''); + readonly formDescription = signal(''); + readonly formEnabled = signal(true); + readonly formRules = signal([]); + + constructor() { + this.loadPolicies(); + } + + // --------------------------------------------------------------------------- + // CRUD operations + // --------------------------------------------------------------------------- + + loadPolicies(): void { + this.loading.set(true); + this.error.set(null); + + this.http.get(ScanPolicyComponent.API_BASE).pipe( + takeUntilDestroyed(this.destroyRef), + ).subscribe({ + next: (res) => { + this.policies.set(res.items ?? []); + this.loading.set(false); + }, + error: (err) => { + this.error.set('Failed to load scan policies.'); + this.loading.set(false); + }, + }); + } + + savePolicy(): void { + const name = this.formName().trim(); + if (!name) return; + + this.saving.set(true); + this.error.set(null); + + const body = { + name, + description: this.formDescription().trim(), + enabled: this.formEnabled(), + rules: this.formRules(), + }; + + const id = this.editingId(); + const req$ = id + ? this.http.put(`${ScanPolicyComponent.API_BASE}/${id}`, body) + : this.http.post(ScanPolicyComponent.API_BASE, body); + + req$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: () => { + this.saving.set(false); + this.cancelEdit(); + this.loadPolicies(); + }, + error: () => { + this.saving.set(false); + this.error.set(id ? 'Failed to update policy.' : 'Failed to create policy.'); + }, + }); + } + + deletePolicy(policy: ScanPolicy): void { + this.error.set(null); + + this.http.delete(`${ScanPolicyComponent.API_BASE}/${policy.id}`).pipe( + takeUntilDestroyed(this.destroyRef), + ).subscribe({ + next: () => this.loadPolicies(), + error: () => this.error.set('Failed to delete policy.'), + }); + } + + // --------------------------------------------------------------------------- + // Form helpers + // --------------------------------------------------------------------------- + + startCreate(): void { + this.editingId.set(null); + this.formName.set(''); + this.formDescription.set(''); + this.formEnabled.set(true); + this.formRules.set([]); + this.editing.set(true); + } + + startEdit(policy: ScanPolicy): void { + this.editingId.set(policy.id); + this.formName.set(policy.name); + this.formDescription.set(policy.description); + this.formEnabled.set(policy.enabled); + this.formRules.set(policy.rules.map(r => ({ ...r }))); + this.editing.set(true); + } + + cancelEdit(): void { + this.editing.set(false); + this.editingId.set(null); + } + + addRule(): void { + this.formRules.update(rules => [ + ...rules, + { type: 'severity-threshold', severity: 'critical', action: 'block' }, + ]); + } + + removeRule(index: number): void { + this.formRules.update(rules => rules.filter((_, i) => i !== index)); + } + + updateRule(index: number, field: keyof ScanPolicyRule, value: string): void { + this.formRules.update(rules => + rules.map((r, i) => i === index ? { ...r, [field]: value } : r) + ); + } + + formatDate(iso: string): string { + if (!iso) return '--'; + try { + return new Date(iso).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return iso; + } + } +} diff --git a/src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts b/src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts index b118e8aed..e10fff877 100644 --- a/src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts @@ -119,6 +119,15 @@ export const SECURITY_RISK_ROUTES: Routes = [ (m) => m.ScanSubmitComponent ), }, + { + path: 'scan-policies', + title: 'Scan Policies', + data: { breadcrumb: 'Scan Policies' }, + loadComponent: () => + import('../features/scanner/scan-policy.component').then( + (m) => m.ScanPolicyComponent + ), + }, { path: 'reports', title: 'Security Reports',