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:
@@ -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" },
|
||||
|
||||
@@ -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"
|
||||
@@ -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" },
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user