Sprint 1: Scanner entry point + vulnerability navigation (S1-T01 to T07)

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) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-16 14:27:47 +02:00
parent 2b1ea0b1da
commit b97bffc430
9 changed files with 801 additions and 3 deletions

View File

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

View File

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

View File

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

View File

@@ -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),
},
{

View File

@@ -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[] {

View File

@@ -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: `
<div class="scan-submit">
<header class="page-header">
<div>
<h1>Scan Image</h1>
<p>Submit a container image for vulnerability scanning.</p>
</div>
</header>
<!-- Scan Form -->
@if (!scanId()) {
<section class="form-card">
<h2>Image Details</h2>
<label class="form-field">
Image reference *
<input
type="text"
[(ngModel)]="imageRef"
placeholder="e.g., registry.stella-ops.local/myapp:v1.2.3"
/>
</label>
<label class="form-field checkbox-row">
<input type="checkbox" [(ngModel)]="forceRescan" />
Force rescan (ignore cached results)
</label>
<!-- Metadata section -->
<div class="metadata-section">
<button
type="button"
class="btn-ghost metadata-toggle"
(click)="showMetadata = !showMetadata"
>
{{ showMetadata ? 'Hide' : 'Show' }} metadata (optional)
<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
<path
[attr.d]="showMetadata ? 'M4 10l4-4 4 4' : 'M4 6l4 4 4-4'"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
</button>
@if (showMetadata) {
<div class="metadata-entries">
@for (entry of metadataEntries; track $index; let idx = $index) {
<div class="metadata-row">
<input
type="text"
[(ngModel)]="entry.key"
placeholder="Key"
class="metadata-input"
/>
<input
type="text"
[(ngModel)]="entry.value"
placeholder="Value"
class="metadata-input"
/>
<button
type="button"
class="btn-ghost btn-icon"
(click)="removeMetadataEntry(idx)"
aria-label="Remove entry"
>
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2"/>
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
</div>
}
<button
type="button"
class="btn-ghost"
(click)="addMetadataEntry()"
>
+ Add entry
</button>
</div>
}
</div>
@if (submitError(); as err) {
<p class="error-message" role="alert">{{ err }}</p>
}
<footer class="form-actions">
<button
type="button"
class="btn-primary"
(click)="submitScan()"
[disabled]="!canSubmit()"
>
@if (submitting()) {
<span class="spinner"></span>
Scanning...
} @else {
Submit Scan
}
</button>
</footer>
</section>
}
<!-- Progress Display -->
@if (scanId(); as id) {
<section class="progress-card">
<h2>Scan Progress</h2>
<div class="progress-details">
<div class="detail-row">
<span class="detail-label">Scan ID</span>
<code class="detail-value">{{ id }}</code>
</div>
<div class="detail-row">
<span class="detail-label">Image</span>
<span class="detail-value">{{ imageRef }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Status</span>
<span class="status-badge" [attr.data-status]="scanStatus()">
{{ scanStatus() }}
</span>
</div>
</div>
@if (scanStatus() === 'completed') {
<div class="completion-banner">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" fill="none" stroke="currentColor" stroke-width="2"/>
<polyline points="22 4 12 14.01 9 11.01" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
<span>Scan Complete!</span>
<a
class="btn-primary findings-link"
[routerLink]="['/triage/artifacts']"
[queryParams]="{ artifact: imageName() }"
>
View findings
</a>
</div>
}
@if (scanStatus() === 'failed') {
<div class="failure-banner" role="alert">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="9" x2="12" y2="13" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="17" x2="12.01" y2="17" stroke="currentColor" stroke-width="2"/>
</svg>
<span>Scan failed. Please check image reference and try again.</span>
</div>
}
<footer class="form-actions">
<button type="button" class="btn-ghost" (click)="resetForm()">
Scan Another Image
</button>
</footer>
</section>
}
</div>
`,
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<string | null>(null);
/** Scan tracking state */
readonly scanId = signal<string | null>(null);
readonly scanStatus = signal<string>('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<string, string> = {};
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<string, unknown> = {
image: { reference: this.imageRef.trim() },
force: this.forceRescan,
};
if (Object.keys(metadata).length > 0) {
body['metadata'] = metadata;
}
this.http.post<ScanSubmitResponse>('/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<ScanStatusResponse>(`/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.';
}
}

View File

@@ -68,6 +68,10 @@ interface PlatformListResponse<T> {
<h1>Security Posture</h1>
<p>Release-blocking posture, advisory freshness, and disposition confidence for the selected scope.</p>
</div>
<div class="header-actions">
<a routerLink="/security/scan" queryParamsHandling="merge" class="btn btn-primary">Scan an Image</a>
<a routerLink="/triage/artifacts" queryParamsHandling="merge" class="btn btn-secondary">View Active Findings</a>
</div>
<div class="scope">
<span>Scope</span>
<strong>{{ scopeSummary() }}</strong>
@@ -204,6 +208,22 @@ interface PlatformListResponse<T> {
.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}

View File

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

View File

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