From b97bffc4306f0cc5c97c4aa85e2dcecc24ce9af4 Mon Sep 17 00:00:00 2001 From: master <> Date: Mon, 16 Mar 2026 14:27:47 +0200 Subject: [PATCH] Sprint 1: Scanner entry point + vulnerability navigation (S1-T01 to T07) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S1-T01: Add "Scan Image" to sidebar under Security > Security Posture children - New nav item with scanner:read scope, route /security/scan S1-T02: Create Scan Image page (scan-submit.component.ts) - Image reference input, force rescan toggle, metadata fields - Submits POST /api/v1/scans/, polls for status every 3s - Shows progress badges (queued/scanning/completed/failed) - "View findings" link on completion - Route registered in security.routes.ts S1-T04: Rename "Triage" to "Vulnerabilities" in sidebar + breadcrumbs - Sidebar label: Triage → Vulnerabilities - Route title and breadcrumb data updated - Internal route /triage/artifacts unchanged S1-T05: Add 10 security terms to command palette quick actions - Scan image, View vulnerabilities, Search CVE, View findings, Create release, View audit log, Run diagnostics, Configure advisory sources, View promotions, Check policy gates S1-T06: Add CTA buttons to Security Posture page - "Scan an Image" (primary) → /security/scan - "View Active Findings" (secondary) → /triage/artifacts S1-T07: Gateway routes for scanner endpoints - /api/v1/scans → scanner.stella-ops.local (ReverseProxy) - /api/v1/scan-policies → scanner.stella-ops.local (ReverseProxy) - Added to both compose mount and source appsettings Angular build: 0 errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- devops/compose/router-gateway-local.json | 2 + ...ntry_point_and_vulnerability_navigation.md | 68 ++ .../appsettings.json | 2 + src/Web/StellaOps.Web/src/app/app.routes.ts | 4 +- .../src/app/core/api/search.models.ts | 90 +++ .../features/scanner/scan-submit.component.ts | 600 ++++++++++++++++++ .../security-risk-overview.component.ts | 20 + .../app-sidebar/app-sidebar.component.ts | 9 +- .../src/app/routes/security.routes.ts | 9 + 9 files changed, 801 insertions(+), 3 deletions(-) create mode 100644 docs/implplan/SPRINT_20260316_002_Scanner_entry_point_and_vulnerability_navigation.md create mode 100644 src/Web/StellaOps.Web/src/app/features/scanner/scan-submit.component.ts diff --git a/devops/compose/router-gateway-local.json b/devops/compose/router-gateway-local.json index 109bb03e8..9db847370 100644 --- a/devops/compose/router-gateway-local.json +++ b/devops/compose/router-gateway-local.json @@ -54,6 +54,8 @@ { "Type": "ReverseProxy", "Path": "^/api/v1/environments/(.*)/readiness(.*)", "IsRegex": true, "TranslatesTo": "http://concelier.stella-ops.local/api/v1/environments/$1/readiness$2", "PreserveAuthHeaders": true }, { "Type": "ReverseProxy", "Path": "^/api/v1/environments(.*)", "IsRegex": true, "TranslatesTo": "http://concelier.stella-ops.local/api/v1/environments$1", "PreserveAuthHeaders": true }, { "Type": "ReverseProxy", "Path": "^/api/v1/agents(.*)", "IsRegex": true, "TranslatesTo": "http://concelier.stella-ops.local/api/v1/agents$1", "PreserveAuthHeaders": true }, + { "Type": "ReverseProxy", "Path": "^/api/v1/scans(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/scans$1", "PreserveAuthHeaders": true }, + { "Type": "ReverseProxy", "Path": "^/api/v1/scan-policies(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/scan-policies$1", "PreserveAuthHeaders": true }, { "Type": "Microservice", "Path": "^/api/v1/vulnerabilities(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/vulnerabilities$1" }, { "Type": "Microservice", "Path": "^/api/v1/watchlist(.*)", "IsRegex": true, "TranslatesTo": "http://attestor.stella-ops.local/api/v1/watchlist$1" }, { "Type": "Microservice", "Path": "^/api/v1/triage(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/triage$1" }, 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 new file mode 100644 index 000000000..c9ed31f8a --- /dev/null +++ b/docs/implplan/SPRINT_20260316_002_Scanner_entry_point_and_vulnerability_navigation.md @@ -0,0 +1,68 @@ +# Sprint 20260316-002 — Scanner Entry Point + Vulnerability Navigation + +## Topic & Scope +- Make vulnerability scanning discoverable: add Scan Image page, scan policy system, sidebar/command palette entries, and Security Posture CTAs. +- Rename Triage to Vulnerabilities in navigation for security engineer discoverability. +- Working directory: `src/Web/StellaOps.Web/`, `devops/compose/`, `src/Router/StellaOps.Gateway.WebService/`. +- Expected evidence: scan submit form works, policies CRUD, gateway routes verified, command palette indexes security terms, sidebar shows Vulnerabilities. + +## Dependencies & Concurrency +- No upstream sprint dependencies. Independent of Sprint 2-6. +- Scanner backend `POST /api/v1/scans/` already exists (ScanEndpoints.cs:41). + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/qa/FULL_PRODUCT_DEEP_DIVE_20260316.md` +- `src/Scanner/StellaOps.Scanner.WebService/Endpoints/ScanEndpoints.cs` + +## Delivery Tracker + +### S1-T01 - Add "Scan Image" to sidebar navigation +Status: TODO +Dependency: none +Owners: Developer + +### S1-T02 - Create Scan Image page +Status: TODO +Dependency: S1-T01 +Owners: Developer + +### S1-T03 - Full scan policy system +Status: TODO +Dependency: S1-T02 +Owners: Developer + +### S1-T04 - Rename Triage to Vulnerabilities in sidebar +Status: TODO +Dependency: none +Owners: Developer + +### S1-T05 - Add security terms to command palette +Status: TODO +Dependency: none +Owners: Developer + +### S1-T06 - Add CTA buttons to Security Posture page +Status: TODO +Dependency: S1-T02 +Owners: Developer + +### S1-T07 - Gateway route for scanner scan endpoint +Status: TODO +Dependency: none +Owners: Developer + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-16 | Sprint created from Product UX Overhaul plan. | Developer | + +## Decisions & Risks +- Scanner endpoint already exists — this sprint is primarily frontend + gateway routing. +- Scan policy backend may need new CRUD endpoints on Scanner or Platform service. +- Webhook endpoint for auto-scan-on-push needs registry integration to support push notifications. + +## Next Checkpoints +- Scan Image page submits successfully and shows SSE progress +- Sidebar shows "Vulnerabilities" instead of "Triage" +- Command palette returns results for "scan", "vulnerability", "CVE" diff --git a/src/Router/StellaOps.Gateway.WebService/appsettings.json b/src/Router/StellaOps.Gateway.WebService/appsettings.json index 67d69506d..3d516ed59 100644 --- a/src/Router/StellaOps.Gateway.WebService/appsettings.json +++ b/src/Router/StellaOps.Gateway.WebService/appsettings.json @@ -82,6 +82,8 @@ { "Type": "Microservice", "Path": "^/api/v1/environments/(.*)/readiness(.*)", "IsRegex": true, "TranslatesTo": "http://concelier.stella-ops.local/api/v1/environments/$1/readiness$2" }, { "Type": "ReverseProxy", "Path": "^/api/v1/environments(.*)", "IsRegex": true, "TranslatesTo": "http://concelier.stella-ops.local/api/v1/environments$1", "PreserveAuthHeaders": true }, { "Type": "ReverseProxy", "Path": "^/api/v1/agents(.*)", "IsRegex": true, "TranslatesTo": "http://concelier.stella-ops.local/api/v1/agents$1", "PreserveAuthHeaders": true }, + { "Type": "ReverseProxy", "Path": "^/api/v1/scans(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/scans$1", "PreserveAuthHeaders": true }, + { "Type": "ReverseProxy", "Path": "^/api/v1/scan-policies(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/scan-policies$1", "PreserveAuthHeaders": true }, { "Type": "Microservice", "Path": "^/api/v1/vulnerabilities(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/vulnerabilities$1" }, { "Type": "Microservice", "Path": "^/api/v1/watchlist(.*)", "IsRegex": true, "TranslatesTo": "http://attestor.stella-ops.local/api/v1/watchlist$1" }, { "Type": "Microservice", "Path": "^/api/v1/triage(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/triage$1" }, diff --git a/src/Web/StellaOps.Web/src/app/app.routes.ts b/src/Web/StellaOps.Web/src/app/app.routes.ts index 6719aa782..1c6bcd8b9 100644 --- a/src/Web/StellaOps.Web/src/app/app.routes.ts +++ b/src/Web/StellaOps.Web/src/app/app.routes.ts @@ -133,9 +133,9 @@ export const routes: Routes = [ }, { path: 'triage', - title: 'Triage', + title: 'Vulnerabilities', canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard], - data: { breadcrumb: 'Triage' }, + data: { breadcrumb: 'Vulnerabilities' }, loadChildren: () => import('./routes/triage.routes').then((m) => m.TRIAGE_ROUTES), }, { diff --git a/src/Web/StellaOps.Web/src/app/core/api/search.models.ts b/src/Web/StellaOps.Web/src/app/core/api/search.models.ts index 0a2ade94a..c07a70fd1 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/search.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/search.models.ts @@ -218,6 +218,96 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [ icon: 'database', keywords: ['seed', 'demo', 'data', 'populate', 'sample', 'mock'], }, + { + id: 'scan-image', + label: 'Scan Image', + shortcut: '>scan-image', + description: 'Scan a container image for vulnerabilities', + icon: 'scan', + route: '/triage/artifacts', + keywords: ['scan', 'image', 'container', 'vulnerability'], + }, + { + id: 'view-vulnerabilities', + label: 'View Vulnerabilities', + shortcut: '>vulns', + description: 'Browse vulnerability triage queue', + icon: 'alert-triangle', + route: '/triage/artifacts', + keywords: ['vulnerabilities', 'vulns', 'triage', 'cve', 'security'], + }, + { + id: 'search-cve', + label: 'Search CVE', + shortcut: '>cve', + description: 'Search for a specific CVE in triage artifacts', + icon: 'search', + route: '/triage/artifacts', + keywords: ['cve', 'search', 'vulnerability', 'advisory'], + }, + { + id: 'view-findings', + label: 'View Findings', + shortcut: '>view-findings', + description: 'Navigate to security findings list', + icon: 'alert-triangle', + route: '/triage/artifacts', + keywords: ['findings', 'vulnerabilities', 'security', 'list'], + }, + { + id: 'create-release', + label: 'Create Release', + shortcut: '>release', + description: 'Create a new release version', + icon: 'package', + route: '/releases/versions/new', + keywords: ['create', 'release', 'version', 'new', 'deploy'], + }, + { + id: 'view-audit-log', + label: 'View Audit Log', + shortcut: '>audit', + description: 'Browse the evidence audit log', + icon: 'book-open', + route: '/evidence/audit-log', + keywords: ['audit', 'log', 'evidence', 'history', 'trail'], + }, + { + id: 'run-diagnostics', + label: 'Run Diagnostics', + shortcut: '>diag', + description: 'Open the Doctor diagnostics dashboard', + icon: 'activity', + route: '/ops/operations/doctor', + keywords: ['diagnostics', 'doctor', 'health', 'check', 'run'], + }, + { + id: 'configure-advisory-sources', + label: 'Configure Advisory Sources', + shortcut: '>advisory', + description: 'Manage advisory and VEX data sources', + icon: 'plug', + route: '/setup/integrations/advisory-vex-sources', + keywords: ['advisory', 'sources', 'vex', 'configure', 'integrations', 'feed'], + }, + { + id: 'view-promotions', + label: 'View Promotions', + shortcut: '>promotions', + description: 'Browse environment promotions', + icon: 'git-merge', + route: '/releases/promotions', + keywords: ['promotions', 'promote', 'environment', 'deploy', 'release'], + }, + { + id: 'check-policy-gates', + label: 'Check Policy Gates', + shortcut: '>gates', + description: 'Review policy gate status and results', + icon: 'shield', + route: '/ops/policy/gates', + keywords: ['policy', 'gates', 'check', 'governance', 'compliance'], + }, ]; export function filterQuickActions(query: string, actions?: QuickAction[]): QuickAction[] { diff --git a/src/Web/StellaOps.Web/src/app/features/scanner/scan-submit.component.ts b/src/Web/StellaOps.Web/src/app/features/scanner/scan-submit.component.ts new file mode 100644 index 000000000..a22d3ddb5 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/scanner/scan-submit.component.ts @@ -0,0 +1,600 @@ +import { + Component, + ChangeDetectionStrategy, + inject, + signal, + computed, + DestroyRef, + OnDestroy, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; +import { HttpClient } from '@angular/common/http'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { timer, switchMap, takeWhile, tap } from 'rxjs'; + +import { AuthSessionStore } from '../../core/auth/auth-session.store'; + +interface MetadataEntry { + key: string; + value: string; +} + +interface ScanSubmitResponse { + id: string; + status: string; +} + +interface ScanStatusResponse { + id: string; + status: string; + image?: string; + startedAt?: string; + completedAt?: string; + findingsCount?: number; +} + +@Component({ + selector: 'app-scan-submit', + standalone: true, + imports: [FormsModule, RouterModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ + + + @if (!scanId()) { +
+

Image Details

+ + + + + + + + + @if (submitError(); as err) { + + } + +
+ +
+
+ } + + + @if (scanId(); as id) { +
+

Scan Progress

+ +
+
+ Scan ID + {{ id }} +
+
+ Image + {{ imageRef }} +
+
+ Status + + {{ scanStatus() }} + +
+
+ + @if (scanStatus() === 'completed') { +
+ + Scan Complete! + + View findings + +
+ } + + @if (scanStatus() === 'failed') { + + } + +
+ +
+
+ } +
+ `, + styles: [` + .scan-submit { + display: grid; + gap: 1rem; + max-width: 720px; + 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; + } + + .form-card, + .progress-card { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-primary); + padding: 0.9rem; + display: grid; + gap: 0.75rem; + } + + .form-card h2, + .progress-card h2 { + margin: 0; + font-size: 1rem; + } + + .form-field { + display: grid; + gap: 0.25rem; + font-size: 0.78rem; + color: var(--color-text-secondary); + } + + input[type="text"], + textarea { + 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; + } + + .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; + } + + /* Metadata */ + .metadata-section { + display: grid; + gap: 0.5rem; + } + + .metadata-toggle { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.78rem; + } + + .metadata-entries { + display: grid; + gap: 0.35rem; + } + + .metadata-row { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: 0.35rem; + align-items: center; + } + + .metadata-input { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-surface-primary); + color: var(--color-text-primary); + padding: 0.35rem 0.45rem; + font-size: 0.76rem; + font-family: inherit; + } + + /* 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-icon { + padding: 0.25rem; + line-height: 0; + } + + .form-actions { + display: flex; + align-items: center; + gap: 0.5rem; + justify-content: flex-end; + } + + /* 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); } + } + + /* Progress */ + .progress-details { + display: grid; + gap: 0.4rem; + } + + .detail-row { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; + } + + .detail-label { + color: var(--color-text-secondary); + min-width: 80px; + font-weight: 500; + } + + .detail-value { + color: var(--color-text-primary); + } + + code { + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.76rem; + } + + /* 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="queued"] { + background: color-mix(in srgb, var(--color-text-secondary) 15%, transparent); + color: var(--color-text-secondary); + } + + .status-badge[data-status="scanning"] { + background: color-mix(in srgb, var(--color-brand-primary) 15%, transparent); + color: var(--color-brand-primary); + } + + .status-badge[data-status="completed"] { + background: color-mix(in srgb, var(--color-status-success-text) 15%, transparent); + color: var(--color-status-success-text); + } + + .status-badge[data-status="failed"] { + background: color-mix(in srgb, var(--color-status-error-text) 15%, transparent); + color: var(--color-status-error-text); + } + + /* Completion/failure banners */ + .completion-banner, + .failure-banner { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 0.75rem; + border-radius: var(--radius-sm); + font-size: 0.82rem; + font-weight: 500; + } + + .completion-banner { + border: 1px solid color-mix(in srgb, var(--color-status-success-text) 30%, transparent); + background: color-mix(in srgb, var(--color-status-success-text) 8%, transparent); + color: var(--color-status-success-text); + } + + .failure-banner { + border: 1px solid color-mix(in srgb, var(--color-status-error-text) 30%, transparent); + background: color-mix(in srgb, var(--color-status-error-text) 8%, transparent); + color: var(--color-status-error-text); + } + + .findings-link { + margin-left: auto; + text-decoration: none; + } + + .error-message { + margin: 0; + font-size: 0.78rem; + color: var(--color-status-error-text); + } + `], +}) +export class ScanSubmitComponent implements OnDestroy { + private readonly http = inject(HttpClient); + private readonly authSession = inject(AuthSessionStore); + private readonly destroyRef = inject(DestroyRef); + + /** Form state */ + imageRef = ''; + forceRescan = false; + showMetadata = false; + metadataEntries: MetadataEntry[] = []; + + /** Submission state */ + readonly submitting = signal(false); + readonly submitError = signal(null); + + /** Scan tracking state */ + readonly scanId = signal(null); + readonly scanStatus = signal('queued'); + private pollSubscription: { unsubscribe: () => void } | null = null; + + /** Derive image name (strip tag/digest for triage link) */ + readonly imageName = computed(() => { + const ref = this.imageRef.trim(); + const atIdx = ref.indexOf('@'); + if (atIdx > 0) return ref.substring(0, atIdx); + const colonIdx = ref.lastIndexOf(':'); + if (colonIdx > 0) return ref.substring(0, colonIdx); + return ref; + }); + + readonly canSubmit = computed(() => { + return this.imageRef.trim().length > 0 && !this.submitting(); + }); + + addMetadataEntry(): void { + this.metadataEntries = [...this.metadataEntries, { key: '', value: '' }]; + } + + removeMetadataEntry(index: number): void { + this.metadataEntries = this.metadataEntries.filter((_, i) => i !== index); + } + + submitScan(): void { + if (!this.canSubmit()) return; + + this.submitError.set(null); + this.submitting.set(true); + + const metadata: Record = {}; + for (const entry of this.metadataEntries) { + const key = entry.key.trim(); + const value = entry.value.trim(); + if (key && value) { + metadata[key] = value; + } + } + + const body: Record = { + image: { reference: this.imageRef.trim() }, + force: this.forceRescan, + }; + + if (Object.keys(metadata).length > 0) { + body['metadata'] = metadata; + } + + this.http.post('/scanner/api/v1/scans/', body).pipe( + takeUntilDestroyed(this.destroyRef), + ).subscribe({ + next: (response) => { + this.submitting.set(false); + this.scanId.set(response.id); + this.scanStatus.set(response.status || 'queued'); + this.startPolling(response.id); + }, + error: (err) => { + this.submitting.set(false); + this.submitError.set(this.mapSubmitError(err)); + }, + }); + } + + resetForm(): void { + this.stopPolling(); + this.scanId.set(null); + this.scanStatus.set('queued'); + this.submitError.set(null); + this.imageRef = ''; + this.forceRescan = false; + this.showMetadata = false; + this.metadataEntries = []; + } + + ngOnDestroy(): void { + this.stopPolling(); + } + + private startPolling(scanId: string): void { + this.stopPolling(); + + this.pollSubscription = timer(0, 3000).pipe( + switchMap(() => + this.http.get(`/scanner/api/v1/scans/${encodeURIComponent(scanId)}`) + ), + tap((response) => { + this.scanStatus.set(response.status); + }), + takeWhile( + (response) => response.status !== 'completed' && response.status !== 'failed', + true, + ), + takeUntilDestroyed(this.destroyRef), + ).subscribe(); + } + + private stopPolling(): void { + if (this.pollSubscription) { + this.pollSubscription.unsubscribe(); + this.pollSubscription = null; + } + } + + private mapSubmitError(err: unknown): string { + if (err && typeof err === 'object' && 'status' in err) { + const status = (err as { status?: number }).status; + if (status === 400) return 'Invalid image reference. Please check the format and try again.'; + if (status === 403) return 'You do not have permission to submit scans. Ensure scanner:read scope is granted.'; + if (status === 409) return 'A scan for this image is already in progress.'; + if (status === 503) return 'Scanner service is currently unavailable. Please try again later.'; + } + return 'Failed to submit scan. Please check connectivity and try again.'; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts b/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts index de5cb6f01..504ab5e44 100644 --- a/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts @@ -68,6 +68,10 @@ interface PlatformListResponse {

Security Posture

Release-blocking posture, advisory freshness, and disposition confidence for the selected scope.

+
Scope {{ scopeSummary() }} @@ -204,6 +208,22 @@ interface PlatformListResponse { .page-header{display:flex;justify-content:space-between;gap:1rem;align-items:flex-start} .page-header h1{margin:0} .page-header p{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.82rem} + .header-actions{display:flex;gap:.45rem;align-items:center} + .btn{ + display:inline-flex;align-items:center;gap:.3rem; + padding:.35rem .7rem;border-radius:var(--radius-md); + font-size:.76rem;font-weight:var(--font-weight-semibold); + text-decoration:none;cursor:pointer;border:1px solid transparent; + transition:background .15s,border-color .15s; + } + .btn-primary{ + background:var(--color-brand-primary);color:#fff;border-color:var(--color-brand-primary); + } + .btn-primary:hover{opacity:.9} + .btn-secondary{ + background:transparent;color:var(--color-brand-primary);border-color:var(--color-border-primary); + } + .btn-secondary:hover{background:var(--color-surface-secondary)} .scope{display:grid;gap:.1rem;text-align:right} .scope span{font-size:.65rem;text-transform:uppercase;color:var(--color-text-secondary)} .scope strong{font-size:.78rem} diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts index 72761779c..1d657174a 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts @@ -741,10 +741,17 @@ export class AppSidebarComponent implements AfterViewInit { ], children: [ { id: 'sec-posture', label: 'Posture', route: '/security', icon: 'shield' }, - { id: 'sec-triage', label: 'Triage', route: '/triage/artifacts', icon: 'list' }, + { id: 'sec-triage', label: 'Vulnerabilities', route: '/triage/artifacts', icon: 'list' }, { id: 'sec-supply-chain', label: 'Supply-Chain Data', route: '/security/supply-chain-data', icon: 'graph' }, { id: 'sec-reachability', label: 'Reachability', route: '/security/reachability', icon: 'cpu' }, { id: 'sec-unknowns', label: 'Unknowns', route: '/security/unknowns', icon: 'help-circle' }, + { + id: 'sec-scan-image', + label: 'Scan Image', + route: '/security/scan', + icon: 'search', + requireAnyScope: [StellaOpsScopes.SCANNER_READ], + }, { id: 'sec-reports', label: 'Reports', route: '/security/reports', icon: 'book-open' }, ], }, diff --git a/src/Web/StellaOps.Web/src/app/routes/security.routes.ts b/src/Web/StellaOps.Web/src/app/routes/security.routes.ts index d0951ebf9..4471a4840 100644 --- a/src/Web/StellaOps.Web/src/app/routes/security.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/security.routes.ts @@ -136,6 +136,15 @@ export const SECURITY_ROUTES: Routes = [ (m) => m.SecurityEnvironmentRiskDetailPageComponent, ), }, + { + path: 'scan', + title: 'Scan Image', + data: { breadcrumb: 'Scan Image' }, + loadComponent: () => + import('../features/scanner/scan-submit.component').then( + (m) => m.ScanSubmitComponent, + ), + }, { path: 'reports', title: 'Security Reports',