Redesign security UX: unified Image Security page, VEX hub overhaul, nav simplification

Security nav restructured from 8 items to 4: Image Security, Triage Queue,
Risk Overview, Advisory Sources. New Image Security page at /security/images
with scope selectors (repo/image/release/environment) and 6 tabs (Summary,
Findings, SBOM, Reachability, VEX, Evidence).

VEX Hub: removed dashboard tab, moved create to button, fixed filters to use
stella-filter-multi, fixed all navigation to absolute paths, fixed 72+ hardcoded
rgba colors, created proper page components for conflicts and create workflow.

Policy shell: added tabs for Packs, Governance, VEX & Exceptions, Simulation,
Audit — all sub-pages now accessible from the Release Policies page.

Integrations: moved symbol sources/marketplace and scanner config to
/setup/integrations.

Backend: mirror config changes now persist via IFeedMirrorConfigStore and
propagate to central Scheduler via SchedulerClient. MirrorExportScheduler
supports IMirrorSchedulerSignal for immediate wakeup on config change.

Mirror detail page: fixed all wrong CSS tokens (text colors used as
backgrounds, inverted borders) to canonical Stella Ops design system.

Exception dashboard: removed duplicate English/Bulgarian title headers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-07 01:36:41 +03:00
parent 684f69c2ae
commit a330dd3673
42 changed files with 3636 additions and 2543 deletions

View File

@@ -2,6 +2,8 @@ using HttpResults = Microsoft.AspNetCore.Http.Results;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Concelier.WebService.Services;
using StellaOps.Excititor.Core;
namespace StellaOps.Concelier.WebService.Extensions;
@@ -180,9 +182,10 @@ internal static class FeedMirrorManagementEndpoints
[FromQuery] string? feedTypes,
[FromQuery] string? syncStatuses,
[FromQuery] bool? enabled,
[FromQuery] string? search)
[FromQuery] string? search,
[FromServices] IFeedMirrorConfigStore store)
{
var result = MirrorSeedData.Mirrors.AsEnumerable();
var result = store.GetAll().AsEnumerable();
if (!string.IsNullOrWhiteSpace(feedTypes))
{
@@ -209,24 +212,33 @@ internal static class FeedMirrorManagementEndpoints
return HttpResults.Ok(result.ToList());
}
private static IResult GetMirror(string mirrorId)
private static IResult GetMirror(string mirrorId, [FromServices] IFeedMirrorConfigStore store)
{
var mirror = MirrorSeedData.Mirrors.FirstOrDefault(m => m.MirrorId == mirrorId);
var mirror = store.Get(mirrorId);
return mirror is not null ? HttpResults.Ok(mirror) : HttpResults.NotFound();
}
private static IResult UpdateMirrorConfig(string mirrorId, [FromBody] MirrorConfigUpdateDto config)
private static async Task<IResult> UpdateMirrorConfig(
string mirrorId,
[FromBody] MirrorConfigUpdateDto config,
[FromServices] IFeedMirrorConfigStore store,
[FromServices] SchedulerClient scheduler,
[FromServices] IMirrorSchedulerSignal signal,
HttpContext httpContext)
{
var mirror = MirrorSeedData.Mirrors.FirstOrDefault(m => m.MirrorId == mirrorId);
if (mirror is null) return HttpResults.NotFound();
var updated = store.Update(mirrorId, config);
if (updated is null) return HttpResults.NotFound();
return HttpResults.Ok(mirror with
{
Enabled = config.Enabled ?? mirror.Enabled,
SyncIntervalMinutes = config.SyncIntervalMinutes ?? mirror.SyncIntervalMinutes,
UpstreamUrl = config.UpstreamUrl ?? mirror.UpstreamUrl,
UpdatedAt = DateTimeOffset.UtcNow.ToString("o"),
});
// Propagate to central scheduler with deterministic schedule ID
var tenantId = httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "demo-prod";
await scheduler.UpsertMirrorScheduleAsync(
tenantId, mirrorId, updated.Name, updated.SyncIntervalMinutes, updated.Enabled,
httpContext.RequestAborted);
// Wake the local export scheduler immediately
signal.SignalReconfigured();
return HttpResults.Ok(updated);
}
private static IResult TriggerSync(string mirrorId)

View File

@@ -661,7 +661,23 @@ builder.Services.AddHttpClient("MirrorConsumer");
// Mirror distribution options binding and export scheduler (background bundle refresh, TASK-006b)
builder.Services.Configure<MirrorDistributionOptions>(builder.Configuration.GetSection(MirrorDistributionOptions.SectionName));
builder.Services.AddHostedService<MirrorExportScheduler>();
// Feed mirror config store (replaces stateless MirrorSeedData for PATCH persistence)
builder.Services.AddSingleton<InMemoryFeedMirrorConfigStore>();
builder.Services.AddSingleton<IFeedMirrorConfigStore>(sp => sp.GetRequiredService<InMemoryFeedMirrorConfigStore>());
// Scheduler HTTP client for propagating mirror config to central Scheduler
builder.Services.AddHttpClient("Scheduler", client =>
{
client.BaseAddress = new Uri(
builder.Configuration["Scheduler:BaseUrl"] ?? "http://scheduler.stella-ops.local");
});
builder.Services.AddSingleton<SchedulerClient>();
// MirrorExportScheduler: singleton + signal interface + hosted service
builder.Services.AddSingleton<MirrorExportScheduler>();
builder.Services.AddSingleton<IMirrorSchedulerSignal>(sp => sp.GetRequiredService<MirrorExportScheduler>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<MirrorExportScheduler>());
var features = concelierOptions.Features ?? new ConcelierOptions.FeaturesOptions();

View File

@@ -0,0 +1,55 @@
using System.Collections.Concurrent;
using static StellaOps.Concelier.WebService.Extensions.FeedMirrorManagementEndpoints;
namespace StellaOps.Concelier.WebService.Services;
/// <summary>
/// Thread-safe in-memory store for feed mirror configuration.
/// Seeded from <see cref="MirrorSeedData"/> on construction.
/// Replaces the stateless seed-data pattern so that PATCH updates are persisted in-process.
/// Future: replace with DB-backed store.
/// </summary>
internal sealed class InMemoryFeedMirrorConfigStore : IFeedMirrorConfigStore
{
private readonly ConcurrentDictionary<string, FeedMirrorDto> _mirrors;
public InMemoryFeedMirrorConfigStore()
{
_mirrors = new ConcurrentDictionary<string, FeedMirrorDto>(
MirrorSeedData.Mirrors.ToDictionary(m => m.MirrorId, m => m),
StringComparer.OrdinalIgnoreCase);
}
public IReadOnlyList<FeedMirrorDto> GetAll()
=> _mirrors.Values.OrderBy(m => m.Name).ToList();
public FeedMirrorDto? Get(string mirrorId)
=> _mirrors.TryGetValue(mirrorId, out var mirror) ? mirror : null;
public FeedMirrorDto? Update(string mirrorId, MirrorConfigUpdateDto update)
{
if (!_mirrors.TryGetValue(mirrorId, out var existing))
return null;
var updated = existing with
{
Enabled = update.Enabled ?? existing.Enabled,
SyncIntervalMinutes = update.SyncIntervalMinutes ?? existing.SyncIntervalMinutes,
UpstreamUrl = update.UpstreamUrl ?? existing.UpstreamUrl,
UpdatedAt = DateTimeOffset.UtcNow.ToString("o"),
};
_mirrors[mirrorId] = updated;
return updated;
}
}
/// <summary>
/// Abstraction for feed mirror config persistence.
/// </summary>
internal interface IFeedMirrorConfigStore
{
IReadOnlyList<FeedMirrorDto> GetAll();
FeedMirrorDto? Get(string mirrorId);
FeedMirrorDto? Update(string mirrorId, MirrorConfigUpdateDto update);
}

View File

@@ -0,0 +1,123 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
namespace StellaOps.Concelier.WebService.Services;
/// <summary>
/// HTTP client for the central Scheduler WebService.
/// Manages mirror sync schedules using deterministic IDs: sys-{tenantId}-mirror-{mirrorId}.
/// </summary>
public sealed class SchedulerClient
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<SchedulerClient> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public SchedulerClient(IHttpClientFactory httpClientFactory, ILogger<SchedulerClient> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
/// <summary>
/// Creates or updates the scheduler schedule for a feed mirror.
/// Uses deterministic schedule ID: sys-{tenantId}-mirror-{mirrorId}.
/// </summary>
public async Task<bool> UpsertMirrorScheduleAsync(
string tenantId,
string mirrorId,
string mirrorName,
int intervalMinutes,
bool enabled,
CancellationToken ct = default)
{
var scheduleId = BuildScheduleId(tenantId, mirrorId);
var cron = IntervalToCron(intervalMinutes);
var client = _httpClientFactory.CreateClient("Scheduler");
// Add tenant header required by the scheduler service
client.DefaultRequestHeaders.TryAddWithoutValidation("X-Tenant-Id", tenantId);
// Try PATCH first (update existing schedule)
var patchBody = new
{
name = $"Mirror Sync: {mirrorName}",
cronExpression = cron,
timezone = "UTC",
enabled,
};
var patchResponse = await client.PatchAsJsonAsync(
$"/api/v1/scheduler/schedules/{scheduleId}", patchBody, JsonOptions, ct);
if (patchResponse.IsSuccessStatusCode)
{
_logger.LogInformation(
"Updated scheduler schedule {ScheduleId} for mirror {MirrorId}: cron={Cron}, enabled={Enabled}",
scheduleId, mirrorId, cron, enabled);
return true;
}
if (patchResponse.StatusCode != HttpStatusCode.NotFound)
{
_logger.LogWarning(
"Failed to update scheduler schedule {ScheduleId}: {Status}",
scheduleId, patchResponse.StatusCode);
return false;
}
// Schedule doesn't exist yet — create it
var createBody = new
{
name = $"Mirror Sync: {mirrorName}",
cronExpression = cron,
timezone = "UTC",
enabled,
mode = "contentRefresh",
selection = new { scope = "allImages", tenantId },
source = "system",
};
var createResponse = await client.PostAsJsonAsync(
"/api/v1/scheduler/schedules/", createBody, JsonOptions, ct);
if (createResponse.IsSuccessStatusCode)
{
_logger.LogInformation(
"Created scheduler schedule for mirror {MirrorId}: id={ScheduleId}, cron={Cron}",
mirrorId, scheduleId, cron);
return true;
}
_logger.LogWarning(
"Failed to create scheduler schedule for mirror {MirrorId}: {Status}",
mirrorId, createResponse.StatusCode);
return false;
}
/// <summary>
/// Builds the deterministic schedule ID for a mirror.
/// </summary>
public static string BuildScheduleId(string tenantId, string mirrorId)
=> $"sys-{tenantId}-mirror-{mirrorId}";
/// <summary>
/// Converts an interval in minutes to a cron expression.
/// </summary>
public static string IntervalToCron(int minutes) => minutes switch
{
<= 0 => "0 */1 * * *", // fallback: every hour
< 60 => $"*/{minutes} * * * *", // every N minutes
60 => "0 */1 * * *", // every hour
< 1440 => $"0 */{minutes / 60} * * *", // every N hours
_ => "0 0 * * *", // daily
};
}

View File

@@ -13,19 +13,32 @@ using StellaOps.Excititor.Core.Storage;
namespace StellaOps.Excititor.Core;
/// <summary>
/// Signal interface to wake the <see cref="MirrorExportScheduler"/> from its sleep cycle
/// when mirror configuration changes at runtime (e.g. sync interval update).
/// </summary>
public interface IMirrorSchedulerSignal
{
/// <summary>
/// Interrupts the current sleep cycle so the scheduler re-reads its options immediately.
/// </summary>
void SignalReconfigured();
}
/// <summary>
/// Background service that periodically checks configured mirror domains for stale
/// export bundles and triggers regeneration when source data has been updated since
/// the last bundle generation. Designed for air-gap awareness: can be fully disabled
/// via <see cref="MirrorDistributionOptions.AutoRefreshEnabled"/>.
/// </summary>
public sealed class MirrorExportScheduler : BackgroundService
public sealed class MirrorExportScheduler : BackgroundService, IMirrorSchedulerSignal
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IOptionsMonitor<MirrorDistributionOptions> _optionsMonitor;
private readonly ILogger<MirrorExportScheduler> _logger;
private readonly TimeProvider _timeProvider;
private readonly ConcurrentDictionary<string, DomainGenerationStatus> _domainStatus = new(StringComparer.OrdinalIgnoreCase);
private CancellationTokenSource _wakeupCts = new();
/// <summary>
/// Initializes a new instance of <see cref="MirrorExportScheduler"/>.
@@ -48,6 +61,15 @@ public sealed class MirrorExportScheduler : BackgroundService
public IReadOnlyDictionary<string, DomainGenerationStatus> GetDomainStatuses()
=> _domainStatus.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.OrdinalIgnoreCase);
/// <inheritdoc cref="IMirrorSchedulerSignal.SignalReconfigured"/>
public void SignalReconfigured()
{
var old = Interlocked.Exchange(ref _wakeupCts, new CancellationTokenSource());
try { old.Cancel(); } catch (ObjectDisposedException) { }
old.Dispose();
_logger.LogInformation("MirrorExportScheduler received reconfiguration signal; waking from sleep.");
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
@@ -109,12 +131,18 @@ public sealed class MirrorExportScheduler : BackgroundService
try
{
await Task.Delay(TimeSpan.FromMinutes(intervalMinutes), stoppingToken).ConfigureAwait(false);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, _wakeupCts.Token);
await Task.Delay(TimeSpan.FromMinutes(intervalMinutes), linked.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (OperationCanceledException)
{
// Wakeup signal received — loop immediately to re-read options
_logger.LogDebug("MirrorExportScheduler woke up from reconfiguration signal.");
}
}
_logger.LogInformation("MirrorExportScheduler stopped.");

View File

@@ -92,7 +92,7 @@ import {
VEX_DECISIONS_API_BASE_URL,
VexDecisionsHttpClient,
} from './core/api/vex-decisions.client';
import { VEX_HUB_API, VEX_HUB_API_BASE_URL, VEX_LENS_API_BASE_URL, VexHubApiHttpClient } from './core/api/vex-hub.client';
import { VEX_HUB_API, VEX_HUB_API_BASE_URL, VEX_LENS_API_BASE_URL, VexHubApiHttpClient, MockVexHubClient } from './core/api/vex-hub.client';
import {
AUDIT_BUNDLES_API,
AUDIT_BUNDLES_API_BASE_URL,
@@ -512,7 +512,7 @@ export const appConfig: ApplicationConfig = {
VexHubApiHttpClient,
{
provide: VEX_HUB_API,
useExisting: VexHubApiHttpClient,
useClass: MockVexHubClient,
},
VexEvidenceHttpClient,
{

View File

@@ -272,7 +272,8 @@ export class MockVexHubClient implements VexHubApi {
let filtered = [...this.mockStatements];
if (params.cveId) {
filtered = filtered.filter((s) => s.cveId.includes(params.cveId!));
const q = params.cveId.toLowerCase();
filtered = filtered.filter((s) => s.cveId.toLowerCase().includes(q));
}
if (params.status) {
filtered = filtered.filter((s) => s.status === params.status);

View File

@@ -17,7 +17,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
{
id: 'home',
label: 'Home',
description: 'Daily overview, health signals, and the fastest path back into active work.',
description: '',
icon: 'home',
items: [
{
@@ -65,8 +65,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
label: 'Release Policies',
route: '/ops/policy/packs',
icon: 'clipboard',
tooltip: 'Define and manage the rules that gate your releases',
requiredScopes: ['policy:author'],
tooltip: 'Policy packs, governance, VEX, and simulation',
},
],
},
@@ -81,101 +80,42 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
icon: 'shield',
items: [
{
id: 'vulnerabilities',
label: 'Vulnerabilities',
id: 'image-security',
label: 'Image Security',
route: '/security/images',
icon: 'shield',
tooltip: 'Security posture, findings, SBOM, and evidence for container images',
},
{
id: 'triage-queue',
label: 'Triage Queue',
route: '/triage/artifacts',
icon: 'alert-triangle',
tooltip: 'Vulnerability triage queue',
tooltip: 'Prioritized vulnerability triage work queue',
},
{
id: 'security-posture',
label: 'Security Posture',
route: '/security',
icon: 'shield',
tooltip: 'Security posture overview and trends',
children: [
{
id: 'supply-chain-data',
label: 'Supply-Chain Data',
route: '/security/supply-chain-data',
tooltip: 'Components, packages, and SBOM-backed inventory',
},
{
id: 'findings-explorer',
label: 'Findings Explorer',
route: '/security/findings',
tooltip: 'Detailed findings, comparisons, and investigation pivots',
},
{
id: 'reachability',
label: 'Reachability',
route: '/security/reachability',
tooltip: 'Which vulnerable code paths are actually callable',
},
{
id: 'unknowns',
label: 'Unknowns',
route: '/security/unknowns',
tooltip: 'Components that still need identification or classification',
},
],
id: 'risk-overview',
label: 'Risk Overview',
route: '/security/risk',
icon: 'activity',
tooltip: 'Fleet-wide risk budget and compliance posture',
},
{
id: 'scan-image',
label: 'Scan Image',
route: '/security/scan',
icon: 'search',
tooltip: 'Scan container images',
},
{
id: 'vex-exceptions',
label: 'VEX & Exceptions',
route: '/ops/policy/vex',
icon: 'file-text',
tooltip: 'Manage VEX statements and policy exceptions',
id: 'advisory-sources',
label: 'Advisory Sources',
route: '/security/advisory-sources',
icon: 'rss',
tooltip: 'Feed health, freshness, and SLA compliance',
},
],
},
// -------------------------------------------------------------------------
// 4. Evidence
// 4. Evidence (context-accessed routes — no standalone sidebar items)
// Evidence threads, capsule details, proof chains, workspaces are accessed
// from release/scan/deployment detail pages. Audit, exports, and bundles
// are consolidated under Operations → Audit.
// -------------------------------------------------------------------------
{
id: 'evidence',
label: 'Evidence',
description: 'Verify what happened, inspect proof chains, and export auditor-ready bundles.',
icon: 'file-text',
items: [
{
id: 'evidence-overview',
label: 'Evidence Overview',
route: '/evidence/overview',
icon: 'file-text',
tooltip: 'Search evidence, proof chains, and verification status',
},
{
id: 'decision-capsules',
label: 'Decision Capsules',
route: '/evidence/capsules',
icon: 'archive',
tooltip: 'Signed decision records for promotions and exceptions',
},
{
id: 'audit-log',
label: 'Audit Log',
route: '/evidence/audit-log',
icon: 'log',
tooltip: 'Cross-module audit trail and compliance',
},
{
id: 'export-center',
label: 'Export Center',
route: '/evidence/exports',
icon: 'download',
tooltip: 'Export evidence for compliance',
},
],
},
// -------------------------------------------------------------------------
// 5. Operations
@@ -187,11 +127,11 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
icon: 'server',
items: [
{
id: 'scheduled-jobs',
label: 'Scheduled Jobs',
route: OPERATIONS_PATHS.jobsQueues,
icon: 'workflow',
tooltip: 'Queue health, execution state, and recovery work',
id: 'schedules',
label: 'Schedules',
route: OPERATIONS_PATHS.jobEngine,
icon: 'clock',
tooltip: 'Scheduled scans, runs, and worker fleet',
},
{
id: 'feeds-airgap',
@@ -214,6 +154,13 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
icon: 'activity',
tooltip: 'Service health, drift signals, and operational checks',
},
{
id: 'audit',
label: 'Audit',
route: '/ops/operations/audit',
icon: 'log',
tooltip: 'Audit trail, evidence exports, compliance bundles',
},
],
},
@@ -294,9 +241,9 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
icon: 'database',
},
{
id: 'scanner-ops',
label: 'Scanner Ops',
route: SCANNER_OPS_ROOT,
id: 'scanner-config',
label: 'Scanner Config',
route: '/setup/integrations/scanner-config',
icon: 'scan',
},
],
@@ -329,23 +276,23 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
{
id: 'audit-compliance',
label: 'Audit & Compliance',
route: '/evidence/audit-log',
route: '/ops/operations/audit',
icon: 'log',
children: [
{
id: 'audit-dashboard',
label: 'Dashboard',
route: '/evidence/audit-log',
route: '/ops/operations/audit',
},
{
id: 'audit-events',
label: 'All Events',
route: '/evidence/audit-log/events',
route: '/ops/operations/audit?tab=all-events',
},
{
id: 'audit-export',
label: 'Export',
route: '/evidence/audit-log/export',
label: 'Exports',
route: '/ops/operations/audit?tab=exports',
},
],
},

View File

@@ -1,15 +1,8 @@
<div class="exception-dashboard">
<header class="dashboard-header">
<div>
<h2 class="dashboard-title">Exception Center</h2>
<p class="dashboard-subtitle">Manage policy exceptions with auditable workflows.</p>
</div>
<div class="dashboard-actions">
<a class="btn-secondary" routerLink="approvals">Approval Queue</a>
<button class="btn-secondary" (click)="refresh()">Refresh</button>
<button class="btn-primary" (click)="openWizard()">+ New Exception</button>
</div>
</header>
<div class="dashboard-actions-bar">
<a class="btn-secondary" routerLink="approvals">Approval Queue</a>
<button class="btn-secondary" (click)="refresh()">Refresh</button>
</div>
@if (error()) {
<app-error-state

View File

@@ -34,6 +34,12 @@
gap: var(--space-2);
}
.dashboard-actions-bar {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
.btn-primary,
.btn-secondary {
padding: var(--space-2) var(--space-4);

View File

@@ -12,7 +12,7 @@ import {
SimpleChanges,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import {
FeedMirror,
FeedSnapshot,
@@ -48,7 +48,7 @@ const EMPTY_FEED_MIRROR: FeedMirror = {
<div class="mirror-detail">
<!-- Back Button & Header -->
<header class="detail-header">
<button type="button" class="back-btn" (click)="back.emit()">
<button type="button" class="back-btn" (click)="goBack()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5"/>
<path d="M12 19l-7-7 7-7"/>
@@ -298,513 +298,202 @@ const EMPTY_FEED_MIRROR: FeedMirror = {
</div>
`,
styles: [`
.mirror-detail {
display: grid;
gap: 1.5rem;
}
.detail-header {
display: flex;
align-items: center;
}
.mirror-detail { display: grid; gap: 1rem; }
/* ---- Back button ---- */
.back-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid var(--color-text-primary);
border-radius: var(--radius-md);
color: var(--color-text-muted);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: var(--color-surface-inverse);
color: rgba(212, 201, 168, 0.3);
}
display: inline-flex; align-items: center; gap: 0.375rem;
padding: 0.375rem 0.75rem; background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary); border-radius: var(--radius-md);
color: var(--color-text-primary); font-size: 0.8125rem; cursor: pointer;
transition: opacity 150ms ease;
}
.back-btn:hover { opacity: 0.85; }
/* ---- Info Card ---- */
.info-card {
background: var(--color-text-heading);
border: 1px solid var(--color-surface-inverse);
border-radius: var(--radius-lg);
padding: 1.5rem;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg); padding: 1.25rem;
}
.info-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.info-title {
display: flex;
flex-direction: column;
gap: 0.5rem;
h2 {
margin: 0;
font-size: 1.25rem;
font-weight: var(--font-weight-semibold);
}
display: flex; align-items: flex-start; justify-content: space-between;
gap: 1rem; margin-bottom: 1.25rem;
}
.info-title { display: flex; flex-direction: column; gap: 0.5rem; }
.info-title h2 { margin: 0; font-size: 1.125rem; font-weight: var(--font-weight-semibold); color: var(--color-text-heading); }
/* ---- Feed type badges ---- */
.feed-type-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.625rem;
font-weight: var(--font-weight-bold);
letter-spacing: 0.05em;
width: fit-content;
}
.feed-type--nvd { background: rgba(59, 130, 246, 0.2); color: var(--color-status-info); }
.feed-type--ghsa { background: rgba(168, 85, 247, 0.2); color: var(--color-status-excepted); }
.feed-type--oval { background: rgba(236, 72, 153, 0.2); color: var(--color-status-excepted); }
.feed-type--osv { background: rgba(34, 197, 94, 0.2); color: var(--color-status-success); }
.feed-type--epss { background: rgba(249, 115, 22, 0.2); color: var(--color-severity-high); }
.feed-type--kev { background: rgba(239, 68, 68, 0.2); color: var(--color-status-error); }
.feed-type--custom { background: rgba(100, 116, 139, 0.2); color: var(--color-text-muted); }
.toggle-switch {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
input {
display: none;
}
display: inline-block; padding: 0.125rem 0.5rem; border-radius: 9999px;
font-size: 0.625rem; font-weight: var(--font-weight-bold); letter-spacing: 0.05em; width: fit-content;
}
.feed-type--nvd { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
.feed-type--ghsa { background: var(--color-brand-soft, var(--color-surface-tertiary)); color: var(--color-status-excepted, var(--color-text-link)); }
.feed-type--oval { background: var(--color-brand-soft, var(--color-surface-tertiary)); color: var(--color-status-excepted, var(--color-text-link)); }
.feed-type--osv { background: var(--color-severity-low-bg); color: var(--color-status-success-text); }
.feed-type--epss { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); }
.feed-type--kev { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); }
.feed-type--custom { background: var(--color-surface-tertiary); color: var(--color-text-secondary); }
/* ---- Toggle switch ---- */
.toggle-switch { display: flex; align-items: center; gap: 0.75rem; cursor: pointer; }
.toggle-switch input { display: none; }
.toggle-slider {
position: relative;
width: 44px;
height: 24px;
background: var(--color-text-primary);
border-radius: var(--radius-xl);
transition: background 0.2s;
&::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: var(--color-surface-primary);
border-radius: var(--radius-full);
transition: transform 0.2s;
}
input:checked + & {
background: var(--color-status-success);
&::after {
transform: translateX(20px);
}
}
position: relative; width: 44px; height: 24px;
background: var(--color-border-primary); border-radius: var(--radius-xl); transition: background 0.2s;
}
.toggle-label {
font-size: 0.875rem;
color: var(--color-text-muted);
.toggle-slider::after {
content: ''; position: absolute; top: 2px; left: 2px;
width: 20px; height: 20px; background: var(--color-surface-primary);
border-radius: var(--radius-full); transition: transform 0.2s;
}
input:checked + .toggle-slider { background: var(--color-status-success); }
input:checked + .toggle-slider::after { transform: translateX(20px); }
.toggle-label { font-size: 0.8125rem; color: var(--color-text-secondary); }
/* ---- Info grid ---- */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.info-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.info-label {
font-size: 0.6875rem;
text-transform: uppercase;
color: var(--color-text-secondary);
letter-spacing: 0.05em;
}
.info-value {
font-size: 0.9375rem;
}
.info-code {
font-family: 'JetBrains Mono', monospace;
font-size: 0.8125rem;
color: var(--color-text-muted);
word-break: break-all;
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem; margin-bottom: 1rem;
}
.info-item { display: flex; flex-direction: column; gap: 0.25rem; }
.info-label { font-size: 0.6875rem; text-transform: uppercase; color: var(--color-text-secondary); letter-spacing: 0.04em; }
.info-value { font-size: 0.8125rem; color: var(--color-text-primary); }
.info-code { font-family: ui-monospace, monospace; font-size: 0.8125rem; color: var(--color-text-muted); word-break: break-all; }
/* ---- Status badges ---- */
.status-badge {
display: inline-block;
padding: 0.25rem 0.625rem;
border-radius: var(--radius-sm);
font-size: 0.6875rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
width: fit-content;
display: inline-block; padding: 0.125rem 0.625rem; border-radius: 9999px;
font-size: 0.6875rem; font-weight: var(--font-weight-semibold); text-transform: uppercase; width: fit-content;
}
.status--synced { background: var(--color-severity-low-bg); color: var(--color-status-success-text); }
.status--syncing { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
.status--stale { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); }
.status--error { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); }
.status--disabled { background: var(--color-surface-tertiary); color: var(--color-text-secondary); }
.status--pending { background: var(--color-surface-tertiary); color: var(--color-text-muted); }
.status--synced { background: rgba(34, 197, 94, 0.2); color: var(--color-status-success); }
.status--syncing { background: rgba(59, 130, 246, 0.2); color: var(--color-status-info); }
.status--stale { background: rgba(234, 179, 8, 0.2); color: var(--color-status-warning); }
.status--error { background: rgba(239, 68, 68, 0.2); color: var(--color-status-error); }
.status--disabled { background: rgba(100, 116, 139, 0.2); color: var(--color-text-secondary); }
.status--pending { background: rgba(148, 163, 184, 0.2); color: var(--color-text-muted); }
/* ---- Error banner ---- */
.error-banner {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-md);
margin-bottom: 1rem;
display: flex; align-items: flex-start; gap: 0.75rem; padding: 1rem;
background: var(--color-status-error-bg); border: 1px solid var(--color-status-error-border);
border-radius: var(--radius-md); margin-bottom: 1rem;
}
.error-icon {
flex-shrink: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-status-error);
color: white;
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: var(--font-weight-bold);
}
.error-content {
strong {
display: block;
color: var(--color-status-error-border);
margin-bottom: 0.25rem;
}
p {
margin: 0;
font-size: 0.875rem;
color: var(--color-status-error-border);
}
}
.action-row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
flex-shrink: 0; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center;
background: var(--color-status-error); color: #fff; border-radius: var(--radius-full);
font-size: 0.75rem; font-weight: var(--font-weight-bold);
}
.error-content strong { display: block; color: var(--color-status-error-text); margin-bottom: 0.25rem; }
.error-content p { margin: 0; font-size: 0.8125rem; color: var(--color-status-error-text); }
/* ---- Action buttons (canonical) ---- */
.action-row { display: flex; gap: 0.75rem; flex-wrap: wrap; }
.action-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all 0.15s;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--primary {
background: var(--color-status-info-text);
color: white;
&:hover:not(:disabled) {
background: var(--color-status-info-text);
}
}
&--secondary {
background: var(--color-surface-inverse);
color: rgba(212, 201, 168, 0.3);
border: 1px solid var(--color-text-primary);
&:hover:not(:disabled) {
background: var(--color-text-primary);
}
}
display: inline-flex; align-items: center; gap: 0.5rem;
padding: 0.375rem 0.75rem; border-radius: var(--radius-md);
font-size: 0.8125rem; font-weight: var(--font-weight-medium); cursor: pointer;
transition: opacity 150ms ease, transform 150ms ease;
border: 1px solid var(--color-border-primary); background: var(--color-surface-secondary); color: var(--color-text-primary);
}
.action-btn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); }
.action-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.action-btn--primary { background: var(--color-btn-primary-bg); border-color: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); }
.action-btn--secondary { background: var(--color-surface-secondary); border-color: var(--color-border-primary); color: var(--color-text-primary); }
.btn-spinner {
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: var(--radius-full);
animation: spin 0.8s linear infinite;
}
.settings-panel {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-surface-inverse);
h3 {
margin: 0 0 1rem;
font-size: 0.9375rem;
font-weight: var(--font-weight-semibold);
}
}
.settings-form {
display: grid;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
label {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
}
.btn-spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-top-color: transparent; border-radius: var(--radius-full); animation: spin 0.8s linear infinite; }
/* ---- Settings panel ---- */
.settings-panel { margin-top: 1.25rem; padding-top: 1.25rem; border-top: 1px solid var(--color-border-primary); }
.settings-panel h3 { margin: 0 0 1rem; font-size: 0.875rem; font-weight: var(--font-weight-semibold); color: var(--color-text-heading); }
.settings-form { display: grid; gap: 1rem; }
.form-group { display: flex; flex-direction: column; gap: 0.375rem; }
.form-group label { font-size: 0.75rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
.form-input {
padding: 0.625rem 1rem;
background: var(--color-text-heading);
border: 1px solid var(--color-text-primary);
border-radius: var(--radius-md);
color: rgba(212, 201, 168, 0.3);
font-size: 0.875rem;
&:focus {
outline: none;
border-color: var(--color-status-info);
}
padding: 0.5rem 0.75rem; background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary); border-radius: var(--radius-md);
color: var(--color-text-primary); font-size: 0.875rem;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 0.5rem;
}
.form-input:focus { outline: none; border-color: var(--color-brand-primary); box-shadow: 0 0 0 2px var(--color-focus-ring); }
.form-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 0.5rem; }
.btn {
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all 0.15s;
&--primary {
background: var(--color-status-info-text);
border: none;
color: white;
&:hover {
background: var(--color-status-info-text);
}
}
&--secondary {
background: transparent;
border: 1px solid var(--color-text-primary);
color: var(--color-text-muted);
&:hover {
background: var(--color-surface-inverse);
color: rgba(212, 201, 168, 0.3);
}
}
display: inline-flex; align-items: center; padding: 0.375rem 0.75rem;
border-radius: var(--radius-md); font-size: 0.8125rem; font-weight: var(--font-weight-medium);
cursor: pointer; transition: opacity 150ms ease, transform 150ms ease;
border: 1px solid var(--color-border-primary); background: var(--color-surface-secondary); color: var(--color-text-primary);
}
.btn:hover { opacity: 0.9; transform: translateY(-1px); }
.btn--primary { background: var(--color-btn-primary-bg); border-color: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); }
.btn--secondary { background: var(--color-surface-secondary); border-color: var(--color-border-primary); color: var(--color-text-primary); }
// Snapshots Section
.snapshots-section,
.retention-section {
background: var(--color-text-heading);
border: 1px solid var(--color-surface-inverse);
border-radius: var(--radius-lg);
overflow: hidden;
/* ---- Sections (canonical table container) ---- */
.snapshots-section, .retention-section {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg); overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-surface-inverse);
h3 {
margin: 0;
font-size: 0.9375rem;
font-weight: var(--font-weight-semibold);
}
display: flex; align-items: center; justify-content: space-between;
padding: 0.75rem 1rem; border-bottom: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
}
.section-header h3 { margin: 0; font-size: 0.75rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
.snapshot-count { font-size: 0.75rem; color: var(--color-text-secondary); }
.snapshot-count {
font-size: 0.75rem;
color: var(--color-text-secondary);
/* ---- Loading ---- */
.loading-container { display: flex; flex-direction: column; align-items: center; padding: 2rem; color: var(--color-text-muted); }
.loading-container p { margin: 0.5rem 0 0; font-size: 0.8125rem; }
.loading-spinner { width: 24px; height: 24px; border: 2px solid var(--color-border-primary); border-top-color: var(--color-brand-primary); border-radius: var(--radius-full); animation: spin 1s linear infinite; }
/* ---- Snapshots table (canonical data-table) ---- */
.snapshots-table-container { overflow-x: auto; }
.snapshots-table { width: 100%; border-collapse: collapse; }
.snapshots-table th {
text-align: left; padding: 0.5rem 0.75rem;
font-size: 0.6875rem; font-weight: var(--font-weight-semibold);
text-transform: uppercase; letter-spacing: 0.04em;
color: var(--color-text-secondary); background: var(--color-surface-secondary);
border-bottom: 1px solid var(--color-border-primary);
}
.snapshots-table td { padding: 0.5rem 0.75rem; font-size: 0.8125rem; border-bottom: 1px solid var(--color-border-primary); }
.snapshots-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); }
.snapshots-table tbody tr:hover { background: var(--color-nav-hover); }
.snapshots-table tbody tr:last-child td { border-bottom: none; }
.row--latest { border-left: 3px solid var(--color-status-success); }
.row--pinned { border-left: 3px solid var(--color-status-info); }
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
color: var(--color-text-muted);
p {
margin: 0.5rem 0 0;
font-size: 0.875rem;
}
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid var(--color-text-primary);
border-top-color: var(--color-status-info);
border-radius: var(--radius-full);
animation: spin 1s linear infinite;
}
.snapshots-table-container {
overflow-x: auto;
}
.snapshots-table {
width: 100%;
border-collapse: collapse;
th {
text-align: left;
padding: 0.75rem 1rem;
font-size: 0.6875rem;
text-transform: uppercase;
color: var(--color-text-secondary);
font-weight: var(--font-weight-medium);
border-bottom: 1px solid var(--color-surface-inverse);
}
td {
padding: 0.75rem 1rem;
font-size: 0.875rem;
border-bottom: 1px solid var(--color-surface-inverse);
}
tbody tr:last-child td {
border-bottom: none;
}
.row--latest {
background: rgba(34, 197, 94, 0.05);
}
.row--pinned {
background: rgba(59, 130, 246, 0.05);
}
}
.version-cell {
display: flex;
align-items: center;
gap: 0.5rem;
code {
font-family: 'JetBrains Mono', monospace;
font-size: 0.8125rem;
}
}
.version-cell { display: flex; align-items: center; gap: 0.5rem; }
.version-cell code { font-family: ui-monospace, monospace; font-size: 0.8125rem; }
/* ---- Badges (canonical pill style) ---- */
.badge {
padding: 0.125rem 0.375rem;
border-radius: var(--radius-sm);
font-size: 0.5625rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
&--latest {
background: rgba(34, 197, 94, 0.2);
color: var(--color-status-success);
}
&--pinned {
background: rgba(59, 130, 246, 0.2);
color: var(--color-status-info);
}
padding: 0.125rem 0.375rem; border-radius: 9999px;
font-size: 0.5625rem; font-weight: var(--font-weight-semibold); text-transform: uppercase;
}
.badge--latest { background: var(--color-severity-low-bg); color: var(--color-status-success-text); }
.badge--pinned { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
.expiry-info {
font-size: 0.75rem;
color: var(--color-status-warning);
}
.expiry-info { font-size: 0.75rem; color: var(--color-status-warning-text); }
.no-expiry { font-size: 0.75rem; color: var(--color-text-secondary); }
.no-data { text-align: center; color: var(--color-text-secondary); padding: 2rem !important; }
.no-expiry {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
/* ---- Retention ---- */
.retention-config { padding: 1rem 1.25rem; }
.retention-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; }
.retention-item { display: flex; flex-direction: column; gap: 0.25rem; }
.retention-label { font-size: 0.6875rem; text-transform: uppercase; color: var(--color-text-secondary); letter-spacing: 0.04em; }
.retention-value { font-size: 0.8125rem; color: var(--color-text-primary); }
.no-data {
text-align: center;
color: var(--color-text-secondary);
padding: 2rem !important;
}
// Retention Section
.retention-config {
padding: 1rem 1.25rem;
}
.retention-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.retention-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.retention-label {
font-size: 0.6875rem;
text-transform: uppercase;
color: var(--color-text-secondary);
}
.retention-value {
font-size: 0.9375rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes spin { to { transform: rotate(360deg); } }
`],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MirrorDetailComponent implements OnInit, OnChanges {
private readonly feedMirrorApi = inject(FEED_MIRROR_API);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private mirrorState: FeedMirror = EMPTY_FEED_MIRROR;
@Input() set mirror(value: FeedMirror | null | undefined) {
@@ -817,6 +506,14 @@ export class MirrorDetailComponent implements OnInit, OnChanges {
@Output() back = new EventEmitter<void>();
goBack(): void {
if (this.back.observed) {
this.back.emit();
} else {
this.router.navigate(['/ops/operations/feeds']);
}
}
readonly snapshots = signal<readonly FeedSnapshot[]>([]);
readonly retentionConfig = signal<SnapshotRetentionConfig | null>(null);
readonly loadingSnapshots = signal(true);

View File

@@ -0,0 +1,201 @@
import { ChangeDetectionStrategy, Component, OnInit, inject, signal, computed } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { ImageSecurityScopeService } from './image-security-scope.service';
@Component({
selector: 'app-image-scope-bar',
standalone: true,
imports: [FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="scope-bar">
<div class="scope-bar__field">
<label class="scope-bar__label">Repository</label>
<select
class="scope-bar__select"
[ngModel]="selectedRepo()"
(ngModelChange)="onRepoChange($event)"
>
<option value="">All repositories</option>
@for (repo of repos(); track repo) {
<option [value]="repo">{{ repo }}</option>
}
</select>
</div>
<div class="scope-bar__field">
<label class="scope-bar__label">Image / Tag</label>
<select
class="scope-bar__select"
[ngModel]="selectedImage()"
(ngModelChange)="onImageChange($event)"
>
<option value="">All images</option>
@for (img of filteredImages(); track img) {
<option [value]="img">{{ img }}</option>
}
</select>
</div>
<div class="scope-bar__field">
<label class="scope-bar__label">Release</label>
<select
class="scope-bar__select"
[ngModel]="selectedRelease()"
(ngModelChange)="onReleaseChange($event)"
>
<option value="">Any release</option>
@for (rel of releases(); track rel.id) {
<option [value]="rel.id">{{ rel.version }}</option>
}
</select>
</div>
<div class="scope-bar__field">
<label class="scope-bar__label">Environment</label>
<select
class="scope-bar__select"
[ngModel]="selectedEnv()"
(ngModelChange)="onEnvChange($event)"
>
<option value="">Any environment</option>
@for (env of environments(); track env) {
<option [value]="env">{{ env }}</option>
}
</select>
</div>
<div class="scope-bar__summary">
<span class="scope-bar__badge">{{ scope.scopeLabel() }}</span>
</div>
</div>
`,
styles: [`
.scope-bar {
display: flex;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
align-items: flex-end;
flex-wrap: wrap;
}
.scope-bar__field {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
flex: 1;
}
.scope-bar__label {
font-size: 0.625rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.scope-bar__select {
padding: 0.375rem 1.5rem 0.375rem 0.5rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: 0.8125rem;
background: var(--color-surface-secondary);
color: var(--color-text-primary);
min-width: 0;
transition: border-color 150ms ease;
}
.scope-bar__select:focus {
outline: none;
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
.scope-bar__summary {
display: flex;
align-items: center;
margin-left: auto;
}
.scope-bar__badge {
font-size: 0.6875rem;
font-family: ui-monospace, monospace;
color: var(--color-text-secondary);
padding: 0.25rem 0.5rem;
background: var(--color-surface-tertiary);
border-radius: var(--radius-md);
white-space: nowrap;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
`],
})
export class ImageScopeBarComponent implements OnInit {
readonly scope = inject(ImageSecurityScopeService);
// Local UI state
readonly repos = signal<string[]>([]);
readonly allImages = signal<string[]>([]);
readonly releases = signal<{ id: string; version: string }[]>([]);
readonly environments = signal<string[]>(['Dev', 'QA', 'Staging', 'Prod']);
readonly selectedRepo = signal('');
readonly selectedImage = signal('');
readonly selectedRelease = signal('');
readonly selectedEnv = signal('');
readonly filteredImages = computed(() => {
const repo = this.selectedRepo();
if (!repo) return this.allImages();
return this.allImages().filter(img => img.startsWith(repo));
});
ngOnInit(): void {
this.loadSeedData();
}
private loadSeedData(): void {
// Seed data from known images in the system
// In production, these come from release-management + scanner APIs
const images = [
'docker.io/acme/web:1.2.3',
'docker.io/acme/web:latest',
'docker.io/beta/api:3.0.0',
'docker.io/beta/api:latest',
'docker.io/gamma/lib:2.1.0',
];
this.allImages.set(images);
this.repos.set([...new Set(images.map(i => i.split(':')[0]))]);
this.releases.set([
{ id: 'rel-001', version: 'v2.3.1-rc2' },
{ id: 'rel-002', version: 'v2.3.0' },
{ id: 'rel-003', version: 'v2.2.5' },
]);
}
onRepoChange(repo: string): void {
this.selectedRepo.set(repo);
this.selectedImage.set('');
this.scope.repository.set(repo || null);
this.scope.imageRef.set(null);
}
onImageChange(imageRef: string): void {
this.selectedImage.set(imageRef);
if (imageRef) {
this.scope.setImageRef(imageRef);
} else {
this.scope.imageRef.set(null);
}
}
onReleaseChange(releaseId: string): void {
this.selectedRelease.set(releaseId);
this.scope.releaseId.set(releaseId || null);
}
onEnvChange(env: string): void {
this.selectedEnv.set(env);
this.scope.environment.set(env || null);
}
}

View File

@@ -0,0 +1,56 @@
import { Injectable, computed, signal } from '@angular/core';
export interface ImageSecurityScope {
repo: string | null;
imageRef: string | null;
scanId: string | null;
releaseId: string | null;
environment: string | null;
}
@Injectable()
export class ImageSecurityScopeService {
readonly repository = signal<string | null>(null);
readonly imageRef = signal<string | null>(null);
readonly scanId = signal<string | null>(null);
readonly releaseId = signal<string | null>(null);
readonly environment = signal<string | null>(null);
readonly scopeLabel = computed(() => {
if (this.imageRef()) return this.imageRef();
if (this.repository()) return this.repository() + ':*';
if (this.releaseId()) return 'Release ' + this.releaseId();
return 'All images';
});
readonly effectiveScope = computed<ImageSecurityScope>(() => ({
repo: this.repository(),
imageRef: this.imageRef(),
scanId: this.scanId(),
releaseId: this.releaseId(),
environment: this.environment(),
}));
setFromRelease(releaseId: string, imageRefs: string[]): void {
this.releaseId.set(releaseId);
if (imageRefs.length === 1) {
this.setImageRef(imageRefs[0]);
}
}
setImageRef(ref: string): void {
this.imageRef.set(ref);
const colonIdx = ref.lastIndexOf(':');
if (colonIdx > 0) {
this.repository.set(ref.substring(0, colonIdx));
}
}
reset(): void {
this.repository.set(null);
this.imageRef.set(null);
this.scanId.set(null);
this.releaseId.set(null);
this.environment.set(null);
}
}

View File

@@ -0,0 +1,87 @@
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router, RouterOutlet } from '@angular/router';
import { filter, startWith } from 'rxjs';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { ImageScopeBarComponent } from './image-scope-bar.component';
import { ImageSecurityScopeService } from './image-security-scope.service';
type ImageTab = 'summary' | 'findings' | 'sbom' | 'reachability' | 'vex' | 'evidence';
const PAGE_TABS: readonly StellaPageTab[] = [
{ id: 'summary', label: 'Summary', icon: 'M3 3h7v7H3z|||M14 3h7v7h-7z|||M14 14h7v7h-7z|||M3 14h7v7H3z' },
{ id: 'findings', label: 'Findings', icon: '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|||M12 9v4|||M12 17h.01' },
{ id: 'sbom', label: 'SBOM', icon: 'M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z|||M3.27 6.96L12 12.01l8.73-5.05|||M12 22.08V12' },
{ id: 'reachability', label: 'Reachability', icon: 'M6 3v12|||M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M18 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M18 9a9 9 0 0 1-9 9' },
{ id: 'vex', label: 'VEX', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' },
{ id: 'evidence', label: 'Evidence', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' },
];
@Component({
selector: 'app-image-security-shell',
standalone: true,
imports: [RouterOutlet, StellaPageTabsComponent, ImageScopeBarComponent],
providers: [ImageSecurityScopeService],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="image-security">
<header class="image-security__header">
<div>
<h1 class="image-security__title">Image Security</h1>
<p class="image-security__subtitle">Security posture, findings, SBOM, and evidence for container images</p>
</div>
</header>
<app-image-scope-bar />
<stella-page-tabs
[tabs]="pageTabs"
[activeTab]="activeTab()"
ariaLabel="Image security tabs"
(tabChange)="onTabChange($event)"
>
<router-outlet />
</stella-page-tabs>
</section>
`,
styles: [`
.image-security { display: grid; gap: 0.85rem; }
.image-security__header { margin-bottom: 0.5rem; }
.image-security__title { font-size: 1.5rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); margin: 0 0 0.25rem; }
.image-security__subtitle { font-size: 0.8125rem; color: var(--color-text-secondary); margin: 0; }
`],
})
export class ImageSecurityShellComponent {
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
readonly pageTabs = PAGE_TABS;
readonly activeTab = signal<ImageTab>('summary');
constructor() {
this.router.events.pipe(
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
startWith(null),
takeUntilDestroyed(this.destroyRef),
).subscribe(() => this.activeTab.set(this.readTab()));
}
onTabChange(tabId: string): void {
this.activeTab.set(tabId as ImageTab);
const segments = tabId === 'summary'
? ['/security/images']
: ['/security/images', tabId];
void this.router.navigate(segments, { queryParamsHandling: 'preserve' });
}
private readTab(): ImageTab {
const url = this.router.url.split('?')[0] ?? '';
if (url.includes('/images/findings')) return 'findings';
if (url.includes('/images/sbom')) return 'sbom';
if (url.includes('/images/reachability')) return 'reachability';
if (url.includes('/images/vex')) return 'vex';
if (url.includes('/images/evidence')) return 'evidence';
return 'summary';
}
}

View File

@@ -0,0 +1,47 @@
import { Routes } from '@angular/router';
export const imageSecurityRoutes: Routes = [
{
path: '',
loadComponent: () =>
import('./image-security-shell.component').then((m) => m.ImageSecurityShellComponent),
children: [
{ path: '', redirectTo: 'summary', pathMatch: 'full' },
{
path: 'summary',
loadComponent: () =>
import('./tabs/image-summary-tab.component').then((m) => m.ImageSummaryTabComponent),
},
{
path: 'findings',
loadComponent: () =>
import('./tabs/image-findings-tab.component').then((m) => m.ImageFindingsTabComponent),
},
{
path: 'findings/:findingId',
loadComponent: () =>
import('../security-risk/finding-detail-page.component').then((m) => m.FindingDetailPageComponent),
},
{
path: 'sbom',
loadComponent: () =>
import('./tabs/image-sbom-tab.component').then((m) => m.ImageSbomTabComponent),
},
{
path: 'reachability',
loadComponent: () =>
import('./tabs/image-reachability-tab.component').then((m) => m.ImageReachabilityTabComponent),
},
{
path: 'vex',
loadComponent: () =>
import('./tabs/image-vex-tab.component').then((m) => m.ImageVexTabComponent),
},
{
path: 'evidence',
loadComponent: () =>
import('./tabs/image-evidence-tab.component').then((m) => m.ImageEvidenceTabComponent),
},
],
},
];

View File

@@ -0,0 +1,82 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ImageSecurityScopeService } from '../image-security-scope.service';
interface EvidencePacket {
id: string;
type: 'deployment' | 'scan' | 'attestation' | 'gate-result';
release: string;
environment: string;
status: 'verified' | 'partial' | 'pending';
signedAt: string;
signedBy: string;
}
@Component({
selector: 'app-image-evidence-tab',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="evidence-tab">
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Type</th>
<th>Release</th>
<th>Environment</th>
<th>Status</th>
<th>Signed</th>
<th>By</th>
</tr>
</thead>
<tbody>
@for (p of packets(); track p.id) {
<tr>
<td><span class="type-badge">{{ p.type }}</span></td>
<td class="mono">{{ p.release }}</td>
<td>{{ p.environment }}</td>
<td>
<span class="status-badge" [class]="'status-badge--' + p.status">{{ p.status }}</span>
</td>
<td class="muted">{{ p.signedAt }}</td>
<td class="muted">{{ p.signedBy }}</td>
</tr>
} @empty {
<tr><td colspan="6" class="empty">No evidence packets for current scope.</td></tr>
}
</tbody>
</table>
</div>
</div>
`,
styles: [`
.evidence-tab { display: grid; gap: 1rem; }
.table-container { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); }
.data-table th { background: var(--color-surface-secondary); font-size: 0.6875rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
.data-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); }
.data-table tbody tr:hover { background: var(--color-nav-hover); }
.data-table td { font-size: 0.8125rem; }
.mono { font-family: ui-monospace, monospace; }
.muted { color: var(--color-text-secondary); font-size: 0.8125rem; }
.type-badge { font-size: 0.625rem; font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.03em; color: var(--color-text-secondary); }
.status-badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.625rem; font-weight: var(--font-weight-semibold); text-transform: uppercase; }
.status-badge--verified { background: var(--color-severity-low-bg); color: var(--color-status-success-text); }
.status-badge--partial { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); }
.status-badge--pending { background: var(--color-surface-tertiary); color: var(--color-text-secondary); }
.empty { text-align: center; color: var(--color-text-secondary); padding: 2rem !important; }
`],
})
export class ImageEvidenceTabComponent {
private readonly scope = inject(ImageSecurityScopeService);
readonly packets = signal<EvidencePacket[]>([
{ id: 'ep-001', type: 'deployment', release: 'v2.3.1-rc2', environment: 'Staging', status: 'verified', signedAt: '2025-12-29 08:00', signedBy: 'system' },
{ id: 'ep-002', type: 'scan', release: 'v2.3.1-rc2', environment: 'Dev', status: 'verified', signedAt: '2025-12-28 14:30', signedBy: 'scanner-01' },
{ id: 'ep-003', type: 'gate-result', release: 'v2.3.0', environment: 'Prod', status: 'verified', signedAt: '2025-12-20 10:00', signedBy: 'policy-engine' },
{ id: 'ep-004', type: 'attestation', release: 'v2.3.0', environment: 'Prod', status: 'partial', signedAt: '2025-12-20 10:05', signedBy: 'ci-pipeline' },
]);
}

View File

@@ -0,0 +1,150 @@
import { ChangeDetectionStrategy, Component, inject, signal, effect } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { ImageSecurityScopeService } from '../image-security-scope.service';
import { StellaFilterMultiComponent, type FilterMultiOption } from '../../../shared/components/stella-filter-multi/stella-filter-multi.component';
interface Finding {
id: string;
cveId: string;
severity: 'critical' | 'high' | 'medium' | 'low';
packageName: string;
version: string;
fixedVersion: string | null;
reachable: boolean | null;
vexStatus: string | null;
firstSeen: string;
}
@Component({
selector: 'app-image-findings-tab',
standalone: true,
imports: [CommonModule, RouterLink, StellaFilterMultiComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="findings">
<div class="filter-bar">
<stella-filter-multi label="Severity" [options]="severityOptions()" (optionsChange)="onSeverityChange($event)" />
<stella-filter-multi label="Reachability" [options]="reachOptions()" (optionsChange)="onReachChange($event)" />
<span class="filter-bar__count">{{ filteredFindings().length }} findings</span>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>CVE</th>
<th>Severity</th>
<th>Package</th>
<th>Version</th>
<th>Fixed</th>
<th>Reachable</th>
<th>VEX</th>
</tr>
</thead>
<tbody>
@for (f of filteredFindings(); track f.id) {
<tr>
<td><a class="cve-link" [routerLink]="['./', f.id]">{{ f.cveId }}</a></td>
<td><span class="severity-badge" [class]="'severity-badge--' + f.severity">{{ f.severity | uppercase }}</span></td>
<td class="mono">{{ f.packageName }}</td>
<td class="mono">{{ f.version }}</td>
<td class="mono">{{ f.fixedVersion ?? '—' }}</td>
<td>
@if (f.reachable === true) { <span class="reach reach--yes">Reachable</span> }
@else if (f.reachable === false) { <span class="reach reach--no">Unreachable</span> }
@else { <span class="reach reach--unknown">Unknown</span> }
</td>
<td><span class="vex-chip">{{ f.vexStatus ?? '—' }}</span></td>
</tr>
} @empty {
<tr><td colspan="7" class="empty">No findings match the current scope and filters.</td></tr>
}
</tbody>
</table>
</div>
</div>
`,
styles: [`
.findings { display: grid; gap: 1rem; }
.filter-bar { display: flex; gap: 0.75rem; padding: 0.75rem 1rem; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); align-items: center; }
.filter-bar__count { font-size: 0.75rem; color: var(--color-text-secondary); margin-left: auto; }
.table-container { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); }
.data-table th { background: var(--color-surface-secondary); font-size: 0.6875rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
.data-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); }
.data-table tbody tr:hover { background: var(--color-nav-hover); }
.data-table td { font-size: 0.8125rem; }
.cve-link { font-family: ui-monospace, monospace; font-weight: var(--font-weight-semibold); color: var(--color-text-link); text-decoration: none; }
.cve-link:hover { text-decoration: underline; }
.mono { font-family: ui-monospace, monospace; font-size: 0.8125rem; }
.severity-badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.625rem; font-weight: var(--font-weight-semibold); }
.severity-badge--critical { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); }
.severity-badge--high { background: var(--color-severity-high-bg, var(--color-severity-medium-bg)); color: var(--color-severity-high, var(--color-status-warning-text)); }
.severity-badge--medium { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); }
.severity-badge--low { background: var(--color-severity-low-bg); color: var(--color-status-success-text); }
.reach { font-size: 0.6875rem; font-weight: var(--font-weight-semibold); }
.reach--yes { color: var(--color-status-error-text); }
.reach--no { color: var(--color-status-success-text); }
.reach--unknown { color: var(--color-text-secondary); }
.vex-chip { font-size: 0.6875rem; color: var(--color-text-secondary); }
.empty { text-align: center; color: var(--color-text-secondary); padding: 2rem !important; }
`],
})
export class ImageFindingsTabComponent {
private readonly scope = inject(ImageSecurityScopeService);
private readonly severities = ['critical', 'high', 'medium', 'low'];
private readonly reachStates = ['reachable', 'unreachable', 'unknown'];
readonly selectedSeverities = signal(new Set(this.severities));
readonly selectedReach = signal(new Set(this.reachStates));
readonly severityOptions = signal<FilterMultiOption[]>(
this.severities.map(s => ({ id: s, label: s.charAt(0).toUpperCase() + s.slice(1), checked: true }))
);
readonly reachOptions = signal<FilterMultiOption[]>(
this.reachStates.map(s => ({ id: s, label: s.charAt(0).toUpperCase() + s.slice(1), checked: true }))
);
readonly findings = signal<Finding[]>([
{ id: 'f-001', cveId: 'CVE-2024-12345', severity: 'critical', packageName: 'lodash', version: '4.17.20', fixedVersion: '4.17.21', reachable: true, vexStatus: 'affected', firstSeen: '2025-01-15' },
{ id: 'f-002', cveId: 'CVE-2024-67890', severity: 'high', packageName: 'express', version: '4.17.1', fixedVersion: '4.18.0', reachable: false, vexStatus: 'not_affected', firstSeen: '2025-01-10' },
{ id: 'f-003', cveId: 'CVE-2024-11111', severity: 'medium', packageName: 'openssl', version: '1.1.1k', fixedVersion: '1.1.1l', reachable: null, vexStatus: null, firstSeen: '2025-02-01' },
{ id: 'f-004', cveId: 'CVE-2024-22222', severity: 'critical', packageName: 'curl', version: '7.79.0', fixedVersion: '7.80.0', reachable: true, vexStatus: 'investigating', firstSeen: '2025-02-15' },
{ id: 'f-005', cveId: 'CVE-2024-33333', severity: 'low', packageName: 'zlib', version: '1.2.11', fixedVersion: null, reachable: false, vexStatus: 'not_affected', firstSeen: '2025-03-01' },
]);
readonly filteredFindings = signal<Finding[]>([]);
constructor() {
effect(() => {
this.scope.effectiveScope(); // react to scope changes
this.applyFilters();
});
this.applyFilters();
}
onSeverityChange(opts: FilterMultiOption[]): void {
this.selectedSeverities.set(new Set(opts.filter(o => o.checked).map(o => o.id)));
this.applyFilters();
}
onReachChange(opts: FilterMultiOption[]): void {
this.selectedReach.set(new Set(opts.filter(o => o.checked).map(o => o.id)));
this.applyFilters();
}
private applyFilters(): void {
const sevs = this.selectedSeverities();
const reach = this.selectedReach();
this.filteredFindings.set(
this.findings().filter(f => {
if (!sevs.has(f.severity)) return false;
const r = f.reachable === true ? 'reachable' : f.reachable === false ? 'unreachable' : 'unknown';
return reach.has(r);
})
);
}
}

View File

@@ -0,0 +1,117 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ImageSecurityScopeService } from '../image-security-scope.service';
interface ReachabilityEntry {
cveId: string;
packageName: string;
verdict: 'reachable' | 'unreachable' | 'uncertain';
confidence: number;
callPath: string | null;
}
@Component({
selector: 'app-image-reachability-tab',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="reachability">
<!-- Coverage summary -->
<div class="reach-summary">
<div class="reach-stat reach-stat--reachable">
<span class="reach-stat__value">{{ reachableCount() }}</span>
<span class="reach-stat__label">Reachable</span>
</div>
<div class="reach-stat reach-stat--unreachable">
<span class="reach-stat__value">{{ unreachableCount() }}</span>
<span class="reach-stat__label">Unreachable</span>
</div>
<div class="reach-stat reach-stat--uncertain">
<span class="reach-stat__value">{{ uncertainCount() }}</span>
<span class="reach-stat__label">Uncertain</span>
</div>
<div class="reach-stat">
<span class="reach-stat__value">{{ coveragePct() }}%</span>
<span class="reach-stat__label">Coverage</span>
</div>
</div>
<!-- Results Table -->
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>CVE</th>
<th>Package</th>
<th>Verdict</th>
<th>Confidence</th>
<th>Call Path</th>
</tr>
</thead>
<tbody>
@for (e of entries(); track e.cveId) {
<tr>
<td class="mono cve">{{ e.cveId }}</td>
<td class="mono">{{ e.packageName }}</td>
<td>
<span class="verdict-badge" [class]="'verdict-badge--' + e.verdict">{{ e.verdict }}</span>
</td>
<td>{{ (e.confidence * 100).toFixed(0) }}%</td>
<td class="mono path">{{ e.callPath ?? '—' }}</td>
</tr>
} @empty {
<tr><td colspan="5" class="empty">No reachability data for current scope.</td></tr>
}
</tbody>
</table>
</div>
</div>
`,
styles: [`
.reachability { display: grid; gap: 1rem; }
.reach-summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; }
.reach-stat { padding: 0.75rem 1rem; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); text-align: center; }
.reach-stat--reachable { border-top: 3px solid var(--color-status-error); }
.reach-stat--unreachable { border-top: 3px solid var(--color-status-success); }
.reach-stat--uncertain { border-top: 3px solid var(--color-status-warning); }
.reach-stat__value { display: block; font-size: 1.25rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); }
.reach-stat__label { font-size: 0.6875rem; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
.table-container { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); }
.data-table th { background: var(--color-surface-secondary); font-size: 0.6875rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
.data-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); }
.data-table tbody tr:hover { background: var(--color-nav-hover); }
.data-table td { font-size: 0.8125rem; }
.mono { font-family: ui-monospace, monospace; }
.cve { font-weight: var(--font-weight-semibold); color: var(--color-text-link); }
.path { font-size: 0.75rem; color: var(--color-text-secondary); max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.verdict-badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.625rem; font-weight: var(--font-weight-semibold); text-transform: uppercase; }
.verdict-badge--reachable { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); }
.verdict-badge--unreachable { background: var(--color-severity-low-bg); color: var(--color-status-success-text); }
.verdict-badge--uncertain { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); }
.empty { text-align: center; color: var(--color-text-secondary); padding: 2rem !important; }
`],
})
export class ImageReachabilityTabComponent {
private readonly scope = inject(ImageSecurityScopeService);
readonly entries = signal<ReachabilityEntry[]>([
{ cveId: 'CVE-2024-12345', packageName: 'lodash', verdict: 'reachable', confidence: 0.95, callPath: 'app.js → handler.js → lodash.merge()' },
{ cveId: 'CVE-2024-67890', packageName: 'express', verdict: 'unreachable', confidence: 0.88, callPath: null },
{ cveId: 'CVE-2024-11111', packageName: 'openssl', verdict: 'uncertain', confidence: 0.42, callPath: 'libssl.so → TLS_connect()' },
{ cveId: 'CVE-2024-22222', packageName: 'curl', verdict: 'reachable', confidence: 0.91, callPath: 'fetch.ts → http.request() → curl_easy_perform()' },
{ cveId: 'CVE-2024-33333', packageName: 'zlib', verdict: 'unreachable', confidence: 0.97, callPath: null },
]);
readonly reachableCount = () => this.entries().filter(e => e.verdict === 'reachable').length;
readonly unreachableCount = () => this.entries().filter(e => e.verdict === 'unreachable').length;
readonly uncertainCount = () => this.entries().filter(e => e.verdict === 'uncertain').length;
readonly coveragePct = () => {
const total = this.entries().length;
if (total === 0) return 0;
const known = this.entries().filter(e => e.verdict !== 'uncertain').length;
return Math.round((known / total) * 100);
};
}

View File

@@ -0,0 +1,117 @@
import { ChangeDetectionStrategy, Component, inject, signal, effect } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ImageSecurityScopeService } from '../image-security-scope.service';
interface SbomComponent {
name: string;
version: string;
type: 'library' | 'framework' | 'os' | 'tool';
license: string;
vulnCount: number;
identified: boolean;
}
@Component({
selector: 'app-image-sbom-tab',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="sbom">
<!-- Summary -->
<div class="sbom-summary">
<div class="sbom-stat">
<span class="sbom-stat__value">{{ components().length }}</span>
<span class="sbom-stat__label">Components</span>
</div>
<div class="sbom-stat">
<span class="sbom-stat__value">{{ identifiedCount() }}</span>
<span class="sbom-stat__label">Identified</span>
</div>
<div class="sbom-stat sbom-stat--warn">
<span class="sbom-stat__value">{{ unknownCount() }}</span>
<span class="sbom-stat__label">Unknown</span>
</div>
<div class="sbom-stat">
<span class="sbom-stat__value">{{ licenseCount() }}</span>
<span class="sbom-stat__label">Licenses</span>
</div>
</div>
<!-- Component Table -->
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Component</th>
<th>Version</th>
<th>Type</th>
<th>License</th>
<th>Vulnerabilities</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (c of components(); track c.name) {
<tr>
<td class="mono">{{ c.name }}</td>
<td class="mono">{{ c.version }}</td>
<td><span class="type-badge">{{ c.type }}</span></td>
<td>{{ c.license }}</td>
<td>
@if (c.vulnCount > 0) {
<span class="vuln-count">{{ c.vulnCount }}</span>
} @else {
<span class="vuln-none">0</span>
}
</td>
<td>
@if (c.identified) { <span class="status-ok">Identified</span> }
@else { <span class="status-unknown">Unknown</span> }
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
`,
styles: [`
.sbom { display: grid; gap: 1rem; }
.sbom-summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; }
.sbom-stat { padding: 0.75rem 1rem; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); text-align: center; }
.sbom-stat--warn { border-left: 3px solid var(--color-status-warning); }
.sbom-stat__value { display: block; font-size: 1.25rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); }
.sbom-stat__label { font-size: 0.6875rem; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
.table-container { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); }
.data-table th { background: var(--color-surface-secondary); font-size: 0.6875rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
.data-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); }
.data-table tbody tr:hover { background: var(--color-nav-hover); }
.data-table td { font-size: 0.8125rem; }
.mono { font-family: ui-monospace, monospace; }
.type-badge { font-size: 0.6875rem; color: var(--color-text-secondary); text-transform: uppercase; }
.vuln-count { font-weight: var(--font-weight-semibold); color: var(--color-status-error-text); }
.vuln-none { color: var(--color-text-secondary); }
.status-ok { font-size: 0.6875rem; color: var(--color-status-success-text); }
.status-unknown { font-size: 0.6875rem; color: var(--color-status-warning-text); }
`],
})
export class ImageSbomTabComponent {
private readonly scope = inject(ImageSecurityScopeService);
readonly components = signal<SbomComponent[]>([
{ name: 'lodash', version: '4.17.20', type: 'library', license: 'MIT', vulnCount: 1, identified: true },
{ name: 'express', version: '4.17.1', type: 'framework', license: 'MIT', vulnCount: 1, identified: true },
{ name: 'openssl', version: '1.1.1k', type: 'library', license: 'Apache-2.0', vulnCount: 1, identified: true },
{ name: 'node', version: '18.17.0', type: 'tool', license: 'MIT', vulnCount: 0, identified: true },
{ name: 'libunknown', version: '0.1.0', type: 'library', license: 'Unknown', vulnCount: 0, identified: false },
{ name: 'zlib', version: '1.2.11', type: 'library', license: 'Zlib', vulnCount: 1, identified: true },
{ name: 'curl', version: '7.79.0', type: 'tool', license: 'MIT', vulnCount: 1, identified: true },
]);
readonly identifiedCount = () => this.components().filter(c => c.identified).length;
readonly unknownCount = () => this.components().filter(c => !c.identified).length;
readonly licenseCount = () => new Set(this.components().map(c => c.license)).size;
}

View File

@@ -0,0 +1,194 @@
import { ChangeDetectionStrategy, Component, inject, signal, effect } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ImageSecurityScopeService } from '../image-security-scope.service';
@Component({
selector: 'app-image-summary-tab',
standalone: true,
imports: [CommonModule, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="summary">
<!-- Gate Status -->
<div class="gate-row">
<div class="gate-card" [class]="'gate-card--' + gateStatus()">
<span class="gate-card__status">{{ gateStatus() | uppercase }}</span>
<span class="gate-card__label">Release Gate</span>
</div>
<div class="gate-card gate-card--info">
<span class="gate-card__status">{{ riskTier() }}</span>
<span class="gate-card__label">Risk Tier</span>
</div>
<div class="gate-card gate-card--info">
<span class="gate-card__status">{{ evidencePosture() }}</span>
<span class="gate-card__label">Evidence Posture</span>
</div>
</div>
<!-- Metrics -->
<div class="metrics-row">
<div class="metric-card metric-card--critical">
<span class="metric-card__value">{{ metrics().critical }}</span>
<span class="metric-card__label">Critical</span>
</div>
<div class="metric-card metric-card--high">
<span class="metric-card__value">{{ metrics().high }}</span>
<span class="metric-card__label">High</span>
</div>
<div class="metric-card metric-card--medium">
<span class="metric-card__value">{{ metrics().medium }}</span>
<span class="metric-card__label">Medium</span>
</div>
<div class="metric-card metric-card--low">
<span class="metric-card__value">{{ metrics().low }}</span>
<span class="metric-card__label">Low</span>
</div>
</div>
<!-- Coverage -->
<div class="coverage-row">
<div class="coverage-card">
<div class="coverage-card__header">
<span class="coverage-card__title">Reachability</span>
<span class="coverage-card__value">{{ reachability().coverage }}%</span>
</div>
<div class="coverage-bar">
<div class="coverage-bar__fill" [style.width.%]="reachability().coverage"></div>
</div>
<span class="coverage-card__detail">{{ reachability().reachable }} confirmed reachable</span>
</div>
<div class="coverage-card">
<div class="coverage-card__header">
<span class="coverage-card__title">SBOM Completeness</span>
<span class="coverage-card__value">{{ sbom().identified }} / {{ sbom().total }}</span>
</div>
<div class="coverage-bar">
<div class="coverage-bar__fill coverage-bar__fill--success" [style.width.%]="sbomPct()"></div>
</div>
<span class="coverage-card__detail">{{ sbom().unknown }} unknown components</span>
</div>
<div class="coverage-card">
<div class="coverage-card__header">
<span class="coverage-card__title">VEX Coverage</span>
<span class="coverage-card__value">{{ vex().covered }} / {{ vex().total }}</span>
</div>
<div class="coverage-bar">
<div class="coverage-bar__fill coverage-bar__fill--info" [style.width.%]="vexPct()"></div>
</div>
<span class="coverage-card__detail">{{ vex().notAffected }} not affected, {{ vex().investigating }} investigating</span>
</div>
</div>
<!-- Scan Now -->
<div class="scan-section">
<div class="scan-section__info">
<span class="scan-section__label">Last scanned:</span>
<span class="scan-section__value">{{ lastScanned() }}</span>
</div>
<button type="button" class="btn btn--primary" (click)="triggerScan()" [disabled]="scanning()">
@if (scanning()) { Scanning... } @else {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
Scan Now
}
</button>
</div>
</div>
`,
styles: [`
.summary { display: grid; gap: 1rem; }
.gate-row { display: flex; gap: 0.75rem; }
.gate-card {
flex: 1; padding: 1rem 1.25rem;
background: var(--color-surface-primary); border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg); text-align: center;
}
.gate-card--pass { border-left: 4px solid var(--color-status-success); }
.gate-card--warn { border-left: 4px solid var(--color-status-warning); }
.gate-card--block { border-left: 4px solid var(--color-status-error); }
.gate-card--pending { border-left: 4px solid var(--color-text-secondary); }
.gate-card--info { border-left: 4px solid var(--color-status-info); }
.gate-card__status { display: block; font-size: 1.25rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); }
.gate-card__label { font-size: 0.6875rem; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
.metrics-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; }
.metric-card {
padding: 0.75rem 1rem; background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); text-align: center;
}
.metric-card--critical { border-top: 3px solid var(--color-status-error); }
.metric-card--high { border-top: 3px solid var(--color-severity-high, var(--color-status-warning)); }
.metric-card--medium { border-top: 3px solid var(--color-status-warning); }
.metric-card--low { border-top: 3px solid var(--color-status-info); }
.metric-card__value { display: block; font-size: 1.5rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); }
.metric-card__label { font-size: 0.6875rem; color: var(--color-text-secondary); text-transform: uppercase; }
.coverage-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; }
.coverage-card {
padding: 1rem; background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg);
}
.coverage-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; }
.coverage-card__title { font-size: 0.75rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
.coverage-card__value { font-size: 0.875rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); }
.coverage-bar { height: 6px; background: var(--color-surface-tertiary); border-radius: 3px; overflow: hidden; margin-bottom: 0.5rem; }
.coverage-bar__fill { height: 100%; border-radius: 3px; background: var(--color-brand-primary); transition: width 0.3s ease; }
.coverage-bar__fill--success { background: var(--color-status-success); }
.coverage-bar__fill--info { background: var(--color-status-info); }
.coverage-card__detail { font-size: 0.75rem; color: var(--color-text-secondary); }
.scan-section {
display: flex; justify-content: space-between; align-items: center;
padding: 0.75rem 1rem; background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg);
}
.scan-section__info { display: flex; gap: 0.5rem; align-items: center; }
.scan-section__label { font-size: 0.75rem; color: var(--color-text-secondary); }
.scan-section__value { font-size: 0.8125rem; color: var(--color-text-primary); }
.btn {
display: inline-flex; align-items: center; gap: 0.5rem;
padding: 0.375rem 0.75rem; border-radius: var(--radius-md);
font-size: 0.8125rem; font-weight: var(--font-weight-medium); cursor: pointer;
border: 1px solid var(--color-border-primary); background: var(--color-surface-secondary); color: var(--color-text-primary);
transition: opacity 150ms ease;
}
.btn:hover:not(:disabled) { opacity: 0.85; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn--primary { background: var(--color-btn-primary-bg); border-color: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); }
`],
})
export class ImageSummaryTabComponent {
private readonly scope = inject(ImageSecurityScopeService);
readonly scanning = signal(false);
readonly gateStatus = signal<'pass' | 'warn' | 'block' | 'pending'>('pass');
readonly riskTier = signal('Medium');
readonly evidencePosture = signal('Verified');
readonly lastScanned = signal('2 hours ago');
readonly metrics = signal({ critical: 3, high: 12, medium: 45, low: 89 });
readonly reachability = signal({ coverage: 67, reachable: 2, unreachable: 13 });
readonly sbom = signal({ total: 342, identified: 335, unknown: 7 });
readonly vex = signal({ total: 60, covered: 48, notAffected: 38, investigating: 10 });
readonly sbomPct = () => {
const s = this.sbom();
return s.total > 0 ? (s.identified / s.total) * 100 : 0;
};
readonly vexPct = () => {
const v = this.vex();
return v.total > 0 ? (v.covered / v.total) * 100 : 0;
};
constructor() {
effect(() => {
const s = this.scope.effectiveScope();
// TODO: Load real data from Score, Findings, Reachability APIs based on scope
});
}
triggerScan(): void {
this.scanning.set(true);
setTimeout(() => this.scanning.set(false), 3000);
}
}

View File

@@ -0,0 +1,121 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { ImageSecurityScopeService } from '../image-security-scope.service';
interface VexEntry {
cveId: string;
status: 'affected' | 'not_affected' | 'fixed' | 'under_investigation';
justification: string | null;
source: string;
updatedAt: string;
}
@Component({
selector: 'app-image-vex-tab',
standalone: true,
imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="vex-tab">
<!-- Summary chips -->
<div class="vex-summary">
<div class="vex-chip vex-chip--not-affected">
<span class="vex-chip__value">{{ notAffectedCount() }}</span>
<span class="vex-chip__label">Not Affected</span>
</div>
<div class="vex-chip vex-chip--fixed">
<span class="vex-chip__value">{{ fixedCount() }}</span>
<span class="vex-chip__label">Fixed</span>
</div>
<div class="vex-chip vex-chip--investigating">
<span class="vex-chip__value">{{ investigatingCount() }}</span>
<span class="vex-chip__label">Investigating</span>
</div>
<div class="vex-chip vex-chip--affected">
<span class="vex-chip__value">{{ affectedCount() }}</span>
<span class="vex-chip__label">Affected</span>
</div>
<a class="vex-action" routerLink="/ops/policy/vex">Manage VEX Statements</a>
</div>
<!-- Statements Table -->
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>CVE</th>
<th>Status</th>
<th>Justification</th>
<th>Source</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
@for (v of entries(); track v.cveId) {
<tr>
<td class="mono cve">{{ v.cveId }}</td>
<td><span class="status-badge" [class]="'status-badge--' + v.status">{{ formatStatus(v.status) }}</span></td>
<td>{{ v.justification ?? '—' }}</td>
<td>{{ v.source }}</td>
<td class="muted">{{ v.updatedAt }}</td>
</tr>
} @empty {
<tr><td colspan="5" class="empty">No VEX statements for this image's CVEs.</td></tr>
}
</tbody>
</table>
</div>
</div>
`,
styles: [`
.vex-tab { display: grid; gap: 1rem; }
.vex-summary { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; }
.vex-chip { padding: 0.5rem 1rem; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); text-align: center; min-width: 100px; }
.vex-chip--not-affected { border-top: 3px solid var(--color-status-success); }
.vex-chip--fixed { border-top: 3px solid var(--color-status-info); }
.vex-chip--investigating { border-top: 3px solid var(--color-status-warning); }
.vex-chip--affected { border-top: 3px solid var(--color-status-error); }
.vex-chip__value { display: block; font-size: 1.25rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); }
.vex-chip__label { font-size: 0.6875rem; color: var(--color-text-secondary); text-transform: uppercase; }
.vex-action { margin-left: auto; font-size: 0.8125rem; color: var(--color-text-link); text-decoration: none; }
.vex-action:hover { text-decoration: underline; }
.table-container { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); }
.data-table th { background: var(--color-surface-secondary); font-size: 0.6875rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
.data-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); }
.data-table tbody tr:hover { background: var(--color-nav-hover); }
.data-table td { font-size: 0.8125rem; }
.mono { font-family: ui-monospace, monospace; }
.cve { font-weight: var(--font-weight-semibold); color: var(--color-text-link); }
.muted { color: var(--color-text-secondary); }
.status-badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.625rem; font-weight: var(--font-weight-semibold); text-transform: uppercase; }
.status-badge--affected { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); }
.status-badge--not_affected { background: var(--color-severity-low-bg); color: var(--color-status-success-text); }
.status-badge--fixed { background: var(--color-status-info-bg, var(--color-severity-low-bg)); color: var(--color-status-info-text); }
.status-badge--under_investigation { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); }
.empty { text-align: center; color: var(--color-text-secondary); padding: 2rem !important; }
`],
})
export class ImageVexTabComponent {
private readonly scope = inject(ImageSecurityScopeService);
readonly entries = signal<VexEntry[]>([
{ cveId: 'CVE-2024-12345', status: 'affected', justification: null, source: 'Acme Corp', updatedAt: '2025-01-15' },
{ cveId: 'CVE-2024-67890', status: 'not_affected', justification: 'Vulnerable code not in execute path', source: 'Internal', updatedAt: '2025-01-20' },
{ cveId: 'CVE-2024-11111', status: 'under_investigation', justification: null, source: 'Internal', updatedAt: '2025-02-01' },
{ cveId: 'CVE-2024-22222', status: 'affected', justification: null, source: 'Vendor', updatedAt: '2025-02-15' },
{ cveId: 'CVE-2024-33333', status: 'not_affected', justification: 'Component not present', source: 'Internal', updatedAt: '2025-03-01' },
]);
readonly notAffectedCount = () => this.entries().filter(e => e.status === 'not_affected').length;
readonly fixedCount = () => this.entries().filter(e => e.status === 'fixed').length;
readonly investigatingCount = () => this.entries().filter(e => e.status === 'under_investigation').length;
readonly affectedCount = () => this.entries().filter(e => e.status === 'affected').length;
formatStatus(status: string): string {
const labels: Record<string, string> = { affected: 'Affected', not_affected: 'Not Affected', fixed: 'Fixed', under_investigation: 'Investigating' };
return labels[status] || status;
}
}

View File

@@ -129,6 +129,39 @@ export const integrationHubRoutes: Routes = [
import('../sbom-sources/sbom-sources.routes').then((m) => m.SBOM_SOURCES_ROUTES),
},
{
path: 'symbol-sources',
title: 'Symbol & Debug Sources',
data: { breadcrumb: 'Symbol Sources' },
loadComponent: () =>
import('../security-risk/symbol-sources/symbol-sources-list.component').then(
(m) => m.SymbolSourcesListComponent),
},
{
path: 'symbol-sources/:sourceId',
title: 'Symbol Source Detail',
data: { breadcrumb: 'Symbol Source' },
loadComponent: () =>
import('../security-risk/symbol-sources/symbol-source-detail.component').then(
(m) => m.SymbolSourceDetailComponent),
},
{
path: 'symbol-marketplace',
title: 'Symbol Marketplace',
data: { breadcrumb: 'Symbol Marketplace' },
loadComponent: () =>
import('../security-risk/symbol-sources/symbol-marketplace-catalog.component').then(
(m) => m.SymbolMarketplaceCatalogComponent),
},
{
path: 'scanner-config',
title: 'Scanner Configuration',
data: { breadcrumb: 'Scanner Config' },
loadChildren: () =>
import('../scanner-ops/scanner-ops.routes').then((m) => m.scannerOpsRoutes),
},
{
path: 'activity',
title: 'Activity',

View File

@@ -1,32 +1,86 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject,
signal,
} from '@angular/core';
import {
RouterOutlet,
} from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router, RouterOutlet } from '@angular/router';
import { filter, startWith } from 'rxjs';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
type PolicyView = 'packs' | 'governance' | 'vex' | 'simulation' | 'audit';
const PAGE_TABS: readonly StellaPageTab[] = [
{ id: 'packs', label: 'Release Policies', icon: 'M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2|||M9 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2|||M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2' },
{ id: 'governance', label: 'Governance', icon: 'M12 6V4m0 2a2 2 0 1 0 0 4m0-4a2 2 0 1 1 0 4m-6 8a2 2 0 1 0 0-4m0 4a2 2 0 1 1 0-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 1 0 0-4m0 4a2 2 0 1 1 0-4m0 4v2m0-6V4' },
{ id: 'vex', label: 'VEX & Exceptions', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' },
{ id: 'simulation', label: 'Simulation', icon: 'M14.752 11.168l-3.197-2.132A1 1 0 0 0 10 9.87v4.263a1 1 0 0 0 1.555.832l3.197-2.132a1 1 0 0 0 0-1.664z|||M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z' },
{ id: 'audit', label: 'Audit', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5.586a1 1 0 0 1 .707.293l5.414 5.414a1 1 0 0 1 .293.707V19a2 2 0 0 1-2 2z' },
];
@Component({
selector: 'app-policy-decisioning-shell',
imports: [
RouterOutlet,
],
imports: [RouterOutlet, StellaPageTabsComponent],
template: `
<section class="policy-decisioning-shell" data-testid="policy-decisioning-shell">
<router-outlet />
<section class="policy-shell" data-testid="policy-decisioning-shell">
<header class="policy-shell__header">
<h1 class="policy-shell__title">Release Policies</h1>
<p class="policy-shell__subtitle">Define gates, manage exceptions, and simulate policy impact.</p>
</header>
<stella-page-tabs
[tabs]="pageTabs"
[activeTab]="activeView()"
ariaLabel="Policy tabs"
(tabChange)="onTabChange($event)"
>
<router-outlet />
</stella-page-tabs>
</section>
`,
styles: [`
:host {
display: block;
min-height: 100%;
}
.policy-decisioning-shell {
display: grid;
gap: 0;
}
:host { display: block; min-height: 100%; }
.policy-shell { display: grid; gap: 0.85rem; }
.policy-shell__header { margin-bottom: 0.5rem; }
.policy-shell__title { font-size: 1.5rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); margin: 0 0 0.25rem; }
.policy-shell__subtitle { font-size: 0.8125rem; color: var(--color-text-secondary); margin: 0; }
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PolicyDecisioningShellComponent {}
export class PolicyDecisioningShellComponent {
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
readonly pageTabs = PAGE_TABS;
readonly activeView = signal<PolicyView>('packs');
constructor() {
this.router.events.pipe(
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
startWith(null),
takeUntilDestroyed(this.destroyRef),
).subscribe(() => this.activeView.set(this.readView()));
}
onTabChange(tabId: string): void {
this.activeView.set(tabId as PolicyView);
const route = tabId === 'packs'
? '/ops/policy/packs'
: tabId === 'vex'
? '/ops/policy/vex'
: `/ops/policy/${tabId}`;
void this.router.navigate([route]);
}
private readView(): PolicyView {
const url = this.router.url.split('?')[0] ?? '';
if (url.includes('/policy/governance') || url.includes('/policy/risk-budget') || url.includes('/policy/budget') || url.includes('/policy/trust-weights') || url.includes('/policy/staleness') || url.includes('/policy/sealed-mode') || url.includes('/policy/profiles') || url.includes('/policy/validator') || url.includes('/policy/conflicts') || url.includes('/policy/impact-preview') || url.includes('/policy/schema')) return 'governance';
if (url.includes('/policy/vex') || url.includes('/policy/waivers') || url.includes('/policy/exceptions')) return 'vex';
if (url.includes('/policy/simulation')) return 'simulation';
if (url.includes('/policy/audit')) return 'audit';
return 'packs';
}
}

View File

@@ -22,9 +22,7 @@ import {
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
type VexSubview =
| 'dashboard'
| 'search'
| 'create'
| 'stats'
| 'consensus'
| 'explorer'
@@ -32,9 +30,7 @@ type VexSubview =
| 'exceptions';
const PAGE_TABS: readonly StellaPageTab[] = [
{ id: 'dashboard', label: 'Dashboard', icon: 'M3 3h7v7H3z|||M14 3h7v7h-7z|||M14 14h7v7h-7z|||M3 14h7v7H3z' },
{ id: 'search', label: 'Search', icon: 'M11 11m-8 0a8 8 0 1 0 16 0 8 8 0 1 0-16 0|||M21 21l-4.35-4.35' },
{ id: 'create', label: 'Create', icon: 'M12 5v14|||M5 12h14' },
{ id: 'stats', label: 'Stats', icon: 'M18 20V10|||M12 20V4|||M6 20v-6' },
{ id: 'consensus', label: 'Consensus', icon: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|||M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|||M20 8v6|||M23 11h-6' },
{ id: 'explorer', label: 'Explorer', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' },
@@ -70,7 +66,7 @@ const PAGE_TABS: readonly StellaPageTab[] = [
gap: 0.85rem;
}
.vex-shell__header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
.vex-shell__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
.vex-shell__title { font-size: 1.5rem; font-weight: var(--font-weight-bold, 700); color: var(--color-text-heading); margin: 0 0 0.25rem; }
.vex-shell__subtitle { font-size: 0.8125rem; color: var(--color-text-secondary); margin: 0; }
`],
@@ -86,15 +82,13 @@ export class PolicyDecisioningVexShellComponent {
protected readonly activeSubtitle = computed(() => {
switch (this.activeSubview()) {
case 'dashboard': return 'VEX statement overview and activity feed.';
case 'search': return 'Search VEX statements across all sources.';
case 'create': return 'Create new VEX statements for findings.';
case 'stats': return 'VEX coverage and statement metrics.';
case 'consensus': return 'Multi-source consensus resolution.';
case 'explorer': return 'Browse and inspect VEX data.';
case 'conflicts': return 'VEX statement conflicts and resolution.';
case 'exceptions': return 'Policy exception queue management.';
default: return 'VEX statement overview and activity feed.';
default: return 'Search VEX statements across all sources.';
}
});
@@ -121,8 +115,8 @@ export class PolicyDecisioningVexShellComponent {
returnTo: coerceString(this.route.snapshot.root.queryParams['returnTo']),
});
// 'dashboard' is the VEX root — navigate to /ops/policy/vex without a sub-segment
const segments = tabId === 'dashboard'
// 'search' is the VEX root — navigate to /ops/policy/vex without a sub-segment
const segments = tabId === 'search'
? ['/ops/policy/vex']
: ['/ops/policy/vex', tabId];
@@ -135,9 +129,6 @@ export class PolicyDecisioningVexShellComponent {
if (url.includes('/vex/search')) {
return 'search';
}
if (url.includes('/vex/create')) {
return 'create';
}
if (url.includes('/vex/stats')) {
return 'stats';
}
@@ -154,7 +145,7 @@ export class PolicyDecisioningVexShellComponent {
return 'exceptions';
}
return 'dashboard';
return 'search';
}
}

View File

@@ -289,8 +289,8 @@ export const policyDecisioningRoutes: Routes = [
{
path: '',
loadComponent: () =>
import('../vex-hub/vex-hub-dashboard.component').then(
(m) => m.VexHubDashboardComponent,
import('../vex-hub/vex-statement-search.component').then(
(m) => m.VexStatementSearchComponent,
),
},
{
@@ -315,8 +315,8 @@ export const policyDecisioningRoutes: Routes = [
{
path: 'create',
loadComponent: () =>
import('../vex-hub/vex-create-workflow.component').then(
(m) => m.VexCreateWorkflowComponent,
import('../vex-hub/vex-create-page.component').then(
(m) => m.VexCreatePageComponent,
),
},
{
@@ -343,8 +343,8 @@ export const policyDecisioningRoutes: Routes = [
{
path: 'conflicts',
loadComponent: () =>
import('../vex-hub/vex-conflict-resolution.component').then(
(m) => m.VexConflictResolutionComponent,
import('../vex-hub/vex-conflicts-page.component').then(
(m) => m.VexConflictsPageComponent,
),
},
{

View File

@@ -229,7 +229,7 @@ import { AiConsentScope, AiConsentStatus } from '../../core/api/advisory-ai.mode
.btn-close:hover {
background: var(--color-surface-tertiary);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.btn-close svg {
@@ -401,7 +401,7 @@ import { AiConsentScope, AiConsentStatus } from '../../core/api/advisory-ai.mode
}
.checkbox-label {
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
font-size: 0.875rem;
}
@@ -472,7 +472,7 @@ import { AiConsentScope, AiConsentStatus } from '../../core/api/advisory-ai.mode
.btn--ghost {
background: transparent;
border: 1px solid var(--color-border-primary);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.btn--ghost:hover {

View File

@@ -286,7 +286,7 @@ import { AiExplainRequest, AiExplainResponse } from '../../core/api/advisory-ai.
.btn-close:hover {
background: var(--color-surface-tertiary);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.btn-close svg {
@@ -353,7 +353,7 @@ import { AiExplainRequest, AiExplainResponse } from '../../core/api/advisory-ai.
.loading-state p {
margin: 0;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
font-size: 1rem;
font-weight: var(--font-weight-medium);
}
@@ -388,7 +388,7 @@ import { AiExplainRequest, AiExplainResponse } from '../../core/api/advisory-ai.
.summary-text {
margin: 0;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
font-size: 0.9375rem;
line-height: 1.7;
}
@@ -436,7 +436,7 @@ import { AiExplainRequest, AiExplainResponse } from '../../core/api/advisory-ai.
}
.impact-value {
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
font-size: 0.875rem;
}
@@ -459,7 +459,7 @@ import { AiExplainRequest, AiExplainResponse } from '../../core/api/advisory-ai.
background: var(--color-surface-elevated);
border-radius: var(--radius-sm);
font-size: 0.75rem;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
/* Version Info */
@@ -485,7 +485,7 @@ import { AiExplainRequest, AiExplainResponse } from '../../core/api/advisory-ai.
.version-value {
font-family: ui-monospace, monospace;
font-size: 0.875rem;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
background: var(--color-surface-elevated);
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
@@ -519,7 +519,7 @@ import { AiExplainRequest, AiExplainResponse } from '../../core/api/advisory-ai.
/* Technical Details */
.technical-details {
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
font-size: 0.875rem;
line-height: 1.7;
white-space: pre-wrap;
@@ -625,7 +625,7 @@ import { AiExplainRequest, AiExplainResponse } from '../../core/api/advisory-ai.
.btn--secondary {
background: var(--color-surface-tertiary);
border: 1px solid var(--color-border-primary);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.btn--secondary:hover {
@@ -638,7 +638,7 @@ import { AiExplainRequest, AiExplainResponse } from '../../core/api/advisory-ai.
}
.btn--ghost:hover {
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
`],
})

View File

@@ -393,7 +393,7 @@ type JustificationType =
.btn-close:hover {
background: var(--color-surface-tertiary);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.btn-close svg {
@@ -436,7 +436,7 @@ type JustificationType =
margin: 0 0 1rem;
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.context-grid {
@@ -485,7 +485,7 @@ type JustificationType =
.loading-state p {
margin: 0;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
font-size: 1rem;
font-weight: var(--font-weight-medium);
}
@@ -560,7 +560,7 @@ type JustificationType =
.confidence-label {
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.confidence-hint {
@@ -611,7 +611,7 @@ type JustificationType =
margin: 0;
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.btn-copy {
@@ -630,7 +630,7 @@ type JustificationType =
.btn-copy:hover {
background: var(--color-surface-tertiary);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.btn-copy svg {
@@ -640,7 +640,7 @@ type JustificationType =
.draft-text {
padding: 1rem;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
font-size: 0.9375rem;
line-height: 1.7;
min-height: 120px;
@@ -662,7 +662,7 @@ type JustificationType =
margin: 0 0 0.75rem;
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.evidence-list {
@@ -682,7 +682,7 @@ type JustificationType =
background: var(--color-surface-elevated);
border-radius: var(--radius-md);
font-size: 0.8125rem;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.evidence-item svg {
@@ -704,7 +704,7 @@ type JustificationType =
margin: 0 0 0.5rem;
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.checklist-hint {
@@ -760,7 +760,7 @@ type JustificationType =
}
.checklist-item span:last-child {
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
font-size: 0.875rem;
}
@@ -867,7 +867,7 @@ type JustificationType =
}
.btn--ghost:hover {
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
`],
})

View File

@@ -381,7 +381,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = {
.btn-close:hover {
background: var(--color-surface-tertiary);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.btn-close svg {
@@ -427,7 +427,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = {
.loading-state p {
margin: 0;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
font-size: 1rem;
font-weight: var(--font-weight-medium);
}
@@ -454,7 +454,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = {
}
.context-item strong {
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
/* Recommendations */
@@ -506,7 +506,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = {
align-items: center;
justify-content: center;
font-weight: var(--font-weight-bold);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
flex-shrink: 0;
}
@@ -532,7 +532,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = {
.recommendation-description {
margin: 0;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
font-size: 0.875rem;
line-height: 1.5;
}
@@ -619,7 +619,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = {
flex: 1;
font-family: ui-monospace, monospace;
font-size: 0.8125rem;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
white-space: nowrap;
}
@@ -640,7 +640,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = {
.btn-copy:hover {
background: var(--color-surface-tertiary);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.btn-copy svg {
@@ -685,7 +685,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = {
.notes-list {
margin: 0;
padding-left: 1.25rem;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
font-size: 0.875rem;
}
@@ -817,7 +817,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = {
.btn--secondary {
background: var(--color-surface-tertiary);
border: 1px solid var(--color-border-primary);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.btn--secondary:hover {
@@ -830,7 +830,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = {
}
.btn--ghost:hover {
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
/* PR Section - Sprint: SPRINT_20260112_012_FE_remediation_pr_ui_wiring */
@@ -917,7 +917,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = {
cursor: pointer;
}
.btn-copy-link:hover { background: var(--color-surface-tertiary); color: rgba(212, 201, 168, 0.3); }
.btn-copy-link:hover { background: var(--color-surface-tertiary); color: var(--color-text-heading); }
.btn-copy-link svg { width: 16px; height: 16px; }
.pr-creating-state {
@@ -959,7 +959,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = {
background: var(--color-surface-tertiary);
border: none;
border-radius: var(--radius-sm);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
font-size: 0.75rem;
cursor: pointer;
}
@@ -984,7 +984,7 @@ const DEFAULT_REMEDIATION_PR_PREFS = {
background: var(--color-surface-elevated);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
font-size: 0.8125rem;
}

View File

@@ -343,7 +343,7 @@ interface ConflictingStatement {
.btn-close:hover {
background: var(--color-surface-tertiary);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.btn-close svg {
@@ -439,7 +439,7 @@ interface ConflictingStatement {
margin: 0 0 1rem;
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.status-bars {
@@ -487,7 +487,7 @@ interface ConflictingStatement {
.status-count {
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
min-width: 24px;
text-align: right;
}
@@ -501,7 +501,7 @@ interface ConflictingStatement {
margin: 0 0 0.5rem;
font-size: 1rem;
font-weight: var(--font-weight-semibold);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.comparison-hint {
@@ -569,7 +569,7 @@ interface ConflictingStatement {
.issuer-name {
font-size: 0.9375rem;
font-weight: var(--font-weight-semibold);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.issuer-type {
@@ -612,7 +612,7 @@ interface ConflictingStatement {
.justification-text {
margin: 0 0 0.75rem;
font-size: 0.875rem;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 3;
@@ -678,7 +678,7 @@ interface ConflictingStatement {
margin: 0 0 1rem;
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.option-group {
@@ -740,7 +740,7 @@ interface ConflictingStatement {
.option-content strong {
font-size: 0.875rem;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.option-content span {
@@ -846,7 +846,7 @@ interface ConflictingStatement {
}
.btn--ghost:hover {
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.btn-spinner {

View File

@@ -0,0 +1,617 @@
/**
* VEX Conflicts Page component.
* Page-level component for browsing and resolving VEX statement conflicts.
* Replaces the modal-only VexConflictResolutionComponent for the /conflicts route.
*/
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
computed,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { VEX_HUB_API, VexHubApi } from '../../core/api/vex-hub.client';
import {
VexLensConflict,
VexStatementStatus,
VexIssuerType,
VexResolveConflictRequest,
} from '../../core/api/vex-hub.models';
@Component({
selector: 'app-vex-conflicts-page',
standalone: true,
imports: [CommonModule, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="conflicts-page">
<!-- Filter Bar -->
<div class="filter-bar">
<div class="filter-bar__search">
<svg class="filter-bar__search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path>
</svg>
<input
type="text"
class="filter-bar__input"
placeholder="Enter CVE ID to find conflicts (e.g., CVE-2024-12345)..."
[(ngModel)]="cveInput"
(keyup.enter)="searchConflicts()"
/>
</div>
<button type="button" class="btn btn--primary" (click)="searchConflicts()">Find Conflicts</button>
</div>
@if (loading()) {
<div class="loading-state">
<div class="loading-spinner"></div>
<p>Searching for conflicts...</p>
</div>
} @else if (conflicts().length > 0) {
<!-- Results Table -->
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>CVE ID</th>
<th>Severity</th>
<th>Primary Claim</th>
<th>Conflicting</th>
<th>Status</th>
<th>Detected</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (conflict of conflicts(); track conflict.conflictId) {
<tr>
<td><span class="cve-id">{{ conflict.cveId }}</span></td>
<td>
<span class="severity-badge" [class]="'severity-badge--' + conflict.severity">
{{ conflict.severity | uppercase }}
</span>
</td>
<td>
<div class="claim-cell">
<span class="claim-issuer">{{ conflict.primaryClaim.issuerName }}</span>
<span class="claim-status" [class]="'claim-status--' + conflict.primaryClaim.status">
{{ formatStatus(conflict.primaryClaim.status) }}
</span>
</div>
</td>
<td>
<div class="claim-cell">
@for (claim of conflict.conflictingClaims; track claim.issuerId) {
<div class="claim-row">
<span class="claim-issuer">{{ claim.issuerName }}</span>
<span class="claim-status" [class]="'claim-status--' + claim.status">
{{ formatStatus(claim.status) }}
</span>
</div>
}
</div>
</td>
<td>
<span class="resolution-badge" [class]="'resolution-badge--' + conflict.resolutionStatus">
{{ formatResolutionStatus(conflict.resolutionStatus) }}
</span>
</td>
<td class="text-muted">{{ conflict.detectedAt | date:'mediumDate' }}</td>
<td>
<div class="action-buttons">
@if (conflict.resolutionStatus !== 'resolved') {
<button type="button" class="btn btn--sm btn--primary" (click)="openResolve(conflict)">Resolve</button>
} @else {
<button type="button" class="btn btn--sm" disabled>Resolved</button>
}
</div>
</td>
</tr>
}
</tbody>
</table>
<div class="table-footer">
<span class="table-footer__count">{{ conflicts().length }} conflict{{ conflicts().length !== 1 ? 's' : '' }} found</span>
</div>
</div>
} @else if (searched()) {
<div class="table-container">
<div class="empty-state">
<div class="empty-state__icon" aria-hidden="true">OK</div>
<h2 class="empty-state__title">No conflicts found</h2>
<p class="empty-state__description">No conflicting VEX statements were found for this CVE.</p>
</div>
</div>
} @else {
<div class="table-container">
<div class="empty-state">
<div class="empty-state__icon" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" 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"/>
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</div>
<h2 class="empty-state__title">VEX Conflict Resolution</h2>
<p class="empty-state__description">Enter a CVE ID above to find conflicting VEX statements from multiple issuers.</p>
</div>
</div>
}
<!-- Inline Resolution Panel -->
@if (resolvingConflict()) {
<div class="resolve-overlay" (click)="closeResolve()">
<div class="resolve-panel" (click)="$event.stopPropagation()">
<div class="resolve-header">
<div>
<h3>Resolve Conflict</h3>
<span class="resolve-cve">{{ resolvingConflict()!.cveId }}</span>
</div>
<button type="button" class="btn-close" (click)="closeResolve()" aria-label="Close">&times;</button>
</div>
<div class="resolve-body">
<!-- Claims comparison -->
<div class="claims-compare">
<div class="claim-card claim-card--primary">
<div class="claim-card__label">Primary Claim</div>
<div class="claim-card__issuer">{{ resolvingConflict()!.primaryClaim.issuerName }}</div>
<span class="status-badge" [class]="'status-badge--' + resolvingConflict()!.primaryClaim.status">
{{ formatStatus(resolvingConflict()!.primaryClaim.status) }}
</span>
<div class="claim-card__trust">Trust: {{ (resolvingConflict()!.primaryClaim.trustScore * 100).toFixed(0) }}%</div>
</div>
@for (claim of resolvingConflict()!.conflictingClaims; track claim.issuerId) {
<div class="claim-card claim-card--conflict">
<div class="claim-card__label">Conflicting</div>
<div class="claim-card__issuer">{{ claim.issuerName }}</div>
<span class="status-badge" [class]="'status-badge--' + claim.status">
{{ formatStatus(claim.status) }}
</span>
<div class="claim-card__trust">Trust: {{ (claim.trustScore * 100).toFixed(0) }}%</div>
</div>
}
</div>
@if (resolvingConflict()!.resolutionSuggestion) {
<div class="suggestion-box">
<strong>Suggested Resolution:</strong>
<p>{{ resolvingConflict()!.resolutionSuggestion }}</p>
</div>
}
<!-- Resolution form -->
<div class="resolve-form">
<div class="form-group">
<label>Resolution Action</label>
<select class="filter-bar__select" [(ngModel)]="resolutionType">
<option value="prefer">Prefer Primary Source</option>
<option value="supersede">Create Superseding Statement</option>
<option value="defer">Defer Resolution</option>
</select>
</div>
<div class="form-group">
<label for="notes">Resolution Notes</label>
<textarea
id="notes"
class="resolve-textarea"
rows="3"
[(ngModel)]="resolutionNotes"
placeholder="Explain the resolution rationale..."
></textarea>
</div>
</div>
</div>
<div class="resolve-footer">
<button type="button" class="btn" (click)="closeResolve()">Cancel</button>
<button
type="button"
class="btn btn--primary"
[disabled]="resolving()"
(click)="submitResolution()"
>
@if (resolving()) { Resolving... } @else { Resolve Conflict }
</button>
</div>
</div>
</div>
}
@if (error()) {
<div class="error-banner">
<span class="error-banner__icon">!</span>
<span>{{ error() }}</span>
<button type="button" class="btn btn--sm" (click)="searchConflicts()">Retry</button>
</div>
}
</div>
`,
styles: [`
:host { display: block; }
.conflicts-page { display: grid; gap: 1rem; }
/* ---- Filter Bar ---- */
.filter-bar {
display: flex;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
align-items: center;
}
.filter-bar__search {
position: relative;
flex: 1;
}
.filter-bar__search-icon {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-secondary);
}
.filter-bar__input {
width: 100%;
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
background: var(--color-surface-secondary);
color: var(--color-text-primary);
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.filter-bar__input:focus {
outline: none;
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
.filter-bar__select {
padding: 0.5rem 2rem 0.5rem 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
background: var(--color-surface-secondary);
color: var(--color-text-primary);
}
/* ---- Table ---- */
.table-container {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
}
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--color-border-primary);
}
.data-table th {
background: var(--color-surface-secondary);
font-size: 0.6875rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
position: sticky;
top: 0;
z-index: 1;
}
.data-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); }
.data-table tbody tr:hover { background: var(--color-nav-hover); }
.data-table td { font-size: 0.8125rem; }
.cve-id {
font-family: ui-monospace, monospace;
font-weight: var(--font-weight-semibold);
color: var(--color-text-link);
}
.text-muted { color: var(--color-text-secondary); font-size: 0.8125rem; }
.severity-badge {
display: inline-block;
padding: 0.125rem 0.625rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: var(--font-weight-semibold);
letter-spacing: 0.03em;
}
.severity-badge--low { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); }
.severity-badge--medium { background: var(--color-severity-high-bg, var(--color-severity-medium-bg)); color: var(--color-severity-high, var(--color-status-warning-text)); }
.severity-badge--high { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); }
.claim-cell { display: flex; flex-direction: column; gap: 0.25rem; }
.claim-row { display: flex; gap: 0.5rem; align-items: center; }
.claim-issuer { font-size: 0.8125rem; color: var(--color-text-primary); }
.claim-status { font-size: 0.6875rem; font-weight: var(--font-weight-semibold); }
.claim-status--affected { color: var(--color-status-error-text); }
.claim-status--not_affected { color: var(--color-status-success-text); }
.claim-status--fixed { color: var(--color-status-info-text); }
.claim-status--under_investigation { color: var(--color-status-warning-text); }
.resolution-badge {
display: inline-block;
padding: 0.125rem 0.625rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: var(--font-weight-semibold);
}
.resolution-badge--unresolved { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); }
.resolution-badge--pending_review { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); }
.resolution-badge--resolved { background: var(--color-severity-low-bg); color: var(--color-status-success-text); }
.table-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-top: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
}
.table-footer__count { font-size: 0.8125rem; color: var(--color-text-muted); }
/* ---- Buttons ---- */
.action-buttons { display: flex; gap: 0.5rem; }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.375rem 0.75rem;
border-radius: var(--radius-md);
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: opacity 150ms ease, transform 150ms ease;
border: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
}
.btn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.btn--sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
.btn--primary { background: var(--color-btn-primary-bg); border-color: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); }
.btn-close { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: var(--color-text-secondary); padding: 0.25rem; }
/* ---- Empty & Loading ---- */
.empty-state {
display: grid;
justify-items: center;
gap: 0.75rem;
padding: 2.75rem 1.5rem;
text-align: center;
}
.empty-state__icon {
width: 3rem;
height: 3rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
background: var(--color-brand-soft, var(--color-surface-secondary));
color: var(--color-text-link);
font-size: 0.875rem;
font-weight: var(--font-weight-bold);
letter-spacing: 0.08em;
}
.empty-state__title { margin: 0; font-size: 1rem; font-weight: var(--font-weight-semibold); color: var(--color-text-heading); }
.empty-state__description { margin: 0; color: var(--color-text-secondary); line-height: 1.6; max-width: 64ch; }
.loading-state {
display: flex; flex-direction: column; align-items: center;
padding: 4rem 2rem; color: var(--color-text-muted);
}
.loading-spinner {
width: 40px; height: 40px;
border: 3px solid var(--color-border-primary);
border-top-color: var(--color-brand-primary);
border-radius: var(--radius-full);
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ---- Error ---- */
.error-banner {
display: flex; align-items: center; gap: 0.75rem;
padding: 1rem 1.25rem;
background: var(--color-status-error-bg);
border: 1px solid var(--color-status-error-border);
border-radius: var(--radius-lg);
color: var(--color-status-error-text);
}
.error-banner__icon {
width: 24px; height: 24px;
background: var(--color-status-error); color: #fff;
border-radius: var(--radius-full);
display: flex; align-items: center; justify-content: center;
font-weight: var(--font-weight-bold); font-size: 0.875rem; flex-shrink: 0;
}
/* ---- Resolution Overlay ---- */
.resolve-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.5);
display: flex; justify-content: center; align-items: center;
z-index: 1000;
}
.resolve-panel {
background: var(--color-surface-primary);
border-radius: var(--radius-lg);
max-width: 640px; width: 90%; max-height: 80vh;
overflow-y: auto;
}
.resolve-header {
display: flex; justify-content: space-between; align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--color-border-primary);
}
.resolve-header h3 { margin: 0; font-size: 1.125rem; font-weight: var(--font-weight-semibold); color: var(--color-text-heading); }
.resolve-cve { font-family: ui-monospace, monospace; font-size: 0.8125rem; color: var(--color-text-link); }
.resolve-body { padding: 1.5rem; display: grid; gap: 1rem; }
.claims-compare { display: flex; gap: 0.75rem; flex-wrap: wrap; }
.claim-card {
flex: 1; min-width: 180px;
padding: 1rem;
border-radius: var(--radius-lg);
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
display: grid; gap: 0.5rem;
}
.claim-card--primary { border-left: 3px solid var(--color-brand-primary); }
.claim-card--conflict { border-left: 3px solid var(--color-status-error); }
.claim-card__label { font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--color-text-secondary); }
.claim-card__issuer { font-weight: var(--font-weight-semibold); color: var(--color-text-heading); }
.claim-card__trust { font-size: 0.75rem; color: var(--color-text-secondary); }
.status-badge {
display: inline-block;
padding: 0.125rem 0.625rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: var(--font-weight-semibold);
width: fit-content;
}
.status-badge--affected { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); }
.status-badge--not_affected { background: var(--color-severity-low-bg); color: var(--color-status-success-text); }
.status-badge--fixed { background: var(--color-status-info-bg, var(--color-severity-low-bg)); color: var(--color-status-info-text); }
.status-badge--under_investigation { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); }
.suggestion-box {
padding: 0.75rem 1rem;
background: var(--color-surface-secondary);
border-radius: var(--radius-lg);
border-left: 3px solid var(--color-status-info);
}
.suggestion-box strong { font-size: 0.75rem; color: var(--color-text-secondary); text-transform: uppercase; }
.suggestion-box p { margin: 0.25rem 0 0; font-size: 0.875rem; color: var(--color-text-primary); }
.resolve-form { display: grid; gap: 0.75rem; }
.form-group { display: flex; flex-direction: column; gap: 0.375rem; }
.form-group label { font-size: 0.75rem; font-weight: var(--font-weight-medium); color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.025em; }
.resolve-textarea {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
background: var(--color-surface-secondary);
color: var(--color-text-primary);
resize: vertical;
}
.resolve-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--color-border-primary);
display: flex; justify-content: flex-end; gap: 0.75rem;
}
`],
})
export class VexConflictsPageComponent {
private readonly vexHubApi = inject<VexHubApi>(VEX_HUB_API);
private readonly route = inject(ActivatedRoute);
readonly loading = signal(false);
readonly resolving = signal(false);
readonly error = signal<string | null>(null);
readonly conflicts = signal<VexLensConflict[]>([]);
readonly searched = signal(false);
readonly resolvingConflict = signal<VexLensConflict | null>(null);
cveInput = '';
resolutionType: 'prefer' | 'supersede' | 'defer' = 'prefer';
resolutionNotes = '';
constructor() {
const cveParam = this.route.snapshot.queryParamMap.get('cveId');
if (cveParam) {
this.cveInput = cveParam;
this.searchConflicts();
}
}
async searchConflicts(): Promise<void> {
const cve = this.cveInput.trim();
if (!cve) {
this.error.set('Please enter a CVE ID');
return;
}
this.loading.set(true);
this.error.set(null);
this.searched.set(true);
try {
const conflicts = await firstValueFrom(this.vexHubApi.getVexLensConflicts(cve));
this.conflicts.set(conflicts);
} catch (err) {
this.conflicts.set([]);
this.error.set(err instanceof Error ? err.message : 'Failed to load conflicts');
} finally {
this.loading.set(false);
}
}
openResolve(conflict: VexLensConflict): void {
this.resolvingConflict.set(conflict);
this.resolutionType = 'prefer';
this.resolutionNotes = '';
}
closeResolve(): void {
this.resolvingConflict.set(null);
}
async submitResolution(): Promise<void> {
const conflict = this.resolvingConflict();
if (!conflict) return;
this.resolving.set(true);
try {
await firstValueFrom(
this.vexHubApi.resolveConflict({
cveId: conflict.cveId,
selectedStatementId: conflict.primaryClaim.statementId,
resolutionType: this.resolutionType,
notes: this.resolutionNotes,
})
);
this.closeResolve();
await this.searchConflicts();
} catch (err) {
this.error.set(err instanceof Error ? err.message : 'Failed to resolve conflict');
} finally {
this.resolving.set(false);
}
}
formatStatus(status: VexStatementStatus): string {
const labels: Record<VexStatementStatus, string> = {
affected: 'Affected',
not_affected: 'Not Affected',
fixed: 'Fixed',
under_investigation: 'Investigating',
};
return labels[status] || status;
}
formatResolutionStatus(status: string): string {
const labels: Record<string, string> = {
unresolved: 'Unresolved',
pending_review: 'Pending Review',
resolved: 'Resolved',
};
return labels[status] || status;
}
}

View File

@@ -33,51 +33,28 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="consensus-container">
<header class="consensus-header">
<div class="consensus-header__nav">
<button class="btn-back" routerLink="..">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 19l-7-7 7-7"/>
</svg>
Dashboard
</button>
</div>
<h1>VEX Consensus Viewer</h1>
<p class="consensus-header__subtitle">
Analyze multi-issuer voting and resolve conflicts
</p>
</header>
<!-- Search -->
<div class="search-panel">
<div class="search-row">
<div class="search-field">
<label for="cve-input">CVE ID</label>
<input
id="cve-input"
type="text"
placeholder="CVE-2024-..."
[(ngModel)]="cveInput"
(keyup.enter)="loadConsensus()"
/>
</div>
<div class="search-field">
<label for="product-input">Product (optional)</label>
<input
id="product-input"
type="text"
placeholder="docker.io/org/image"
[(ngModel)]="productInput"
(keyup.enter)="loadConsensus()"
/>
</div>
<button class="btn btn--primary" (click)="loadConsensus()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Get Consensus
</button>
<!-- Filter Bar -->
<div class="filter-bar">
<div class="filter-bar__search">
<svg class="filter-bar__search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path>
</svg>
<input
type="text"
class="filter-bar__input"
placeholder="CVE ID (e.g., CVE-2024-12345)..."
[(ngModel)]="cveInput"
(keyup.enter)="loadConsensus()"
/>
</div>
<input
type="text"
class="filter-bar__input filter-bar__input--product"
placeholder="Product (optional)..."
[(ngModel)]="productInput"
(keyup.enter)="loadConsensus()"
/>
<button type="button" class="btn btn--primary" (click)="loadConsensus()">Get Consensus</button>
</div>
@if (loading()) {
@@ -298,156 +275,98 @@ import {
</div>
`,
styles: [`
:host { display: block; min-height: 100%; }
:host { display: block; }
.consensus-container {
max-width: 1000px;
margin: 0 auto;
padding: 1.5rem;
}
.consensus-header {
margin-bottom: 1.5rem;
}
.consensus-header__nav {
margin-bottom: 0.75rem;
}
.btn-back {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
background: transparent;
border: none;
color: var(--color-status-info-border);
font-size: 0.875rem;
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 0.15s ease;
}
.btn-back:hover {
background: var(--color-surface-tertiary);
color: var(--color-status-info-border);
}
.btn-back svg {
width: 16px;
height: 16px;
}
.consensus-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: var(--font-weight-bold);
color: var(--color-text-heading);
}
.consensus-header__subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted);
font-size: 0.875rem;
}
/* Search Panel */
.search-panel {
background: var(--color-surface-elevated);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-xl);
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.search-row {
display: flex;
display: grid;
gap: 1rem;
align-items: flex-end;
}
/* Filter Bar (canonical horizontal pattern) */
.filter-bar {
display: flex;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
align-items: center;
flex-wrap: wrap;
}
.search-field {
.filter-bar__search {
position: relative;
flex: 1;
min-width: 200px;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.search-field label {
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.025em;
.filter-bar__search-icon {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-secondary);
}
.search-field input {
padding: 0.625rem 0.875rem;
background: var(--color-surface-tertiary);
.filter-bar__input {
width: 100%;
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
color: var(--color-text-heading);
font-size: 0.875rem;
background: var(--color-surface-secondary);
color: var(--color-text-primary);
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.search-field input:focus {
.filter-bar__input:focus {
outline: none;
border-color: var(--color-status-info);
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
.filter-bar__input--product {
padding-left: 0.75rem;
min-width: 160px;
flex: 0 1 auto;
}
/* Buttons (canonical) */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
padding: 0.375rem 0.75rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all 0.15s ease;
border: none;
text-decoration: none;
transition: opacity 150ms ease, transform 150ms ease;
border: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
white-space: nowrap;
}
.btn svg {
width: 16px;
height: 16px;
}
.btn--primary {
background: linear-gradient(135deg, var(--color-status-info) 0%, var(--color-status-info-text) 100%);
color: var(--color-btn-primary-text);
}
.btn:hover { opacity: 0.9; transform: translateY(-1px); }
.btn--primary { background: var(--color-btn-primary-bg); border-color: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); }
.btn--ghost {
background: transparent;
border: 1px solid var(--color-border-primary);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-primary);
}
.btn--ghost:hover {
background: var(--color-surface-tertiary);
}
.btn--ghost:hover { background: var(--color-surface-tertiary); }
.btn--text {
background: transparent;
color: var(--color-status-info-border);
border: none;
color: var(--color-text-link);
font-weight: var(--font-weight-medium);
}
.btn-link {
background: none;
border: none;
color: var(--color-status-info-border);
color: var(--color-text-link);
font-size: 0.75rem;
cursor: pointer;
padding: 0;
}
.btn-link:hover {
text-decoration: underline;
}
.btn-link:hover { text-decoration: underline; }
/* Loading */
.loading-state {
@@ -478,7 +397,6 @@ import {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-xl);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.summary-header {
@@ -622,14 +540,13 @@ import {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-xl);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.voting-section h2 {
margin: 0 0 1.25rem;
font-size: 1rem;
font-weight: var(--font-weight-semibold);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
/* Vote Distribution */
@@ -671,7 +588,7 @@ import {
.vote-group__status {
font-weight: var(--font-weight-semibold);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.vote-group__count {
@@ -702,7 +619,7 @@ import {
justify-content: space-between;
align-items: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.2);
background: var(--color-surface-secondary);
}
.issuer-info {
@@ -799,7 +716,7 @@ import {
.metric-value {
font-size: 0.8125rem;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
font-weight: var(--font-weight-semibold);
}
@@ -819,7 +736,7 @@ import {
display: block;
margin-top: 0.25rem;
font-size: 0.875rem;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.vote-meta {
@@ -839,14 +756,13 @@ import {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-xl);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.hints-section h2 {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: var(--font-weight-semibold);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.hints-list {
@@ -865,7 +781,7 @@ import {
padding: 0.75rem;
background: var(--color-surface-tertiary);
border-radius: var(--radius-lg);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
font-size: 0.875rem;
}
@@ -880,10 +796,9 @@ import {
/* Conflicts Section */
.conflicts-section {
background: var(--color-surface-elevated);
border: 1px solid var(--color-status-error-text);
border: 1px solid var(--color-status-error-border);
border-radius: var(--radius-xl);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.section-header {
@@ -897,7 +812,7 @@ import {
margin: 0;
font-size: 1rem;
font-weight: var(--font-weight-semibold);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.conflict-card {
@@ -916,7 +831,7 @@ import {
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: rgba(0, 0, 0, 0.2);
background: var(--color-surface-secondary);
}
.conflict-severity {
@@ -970,7 +885,7 @@ import {
.claim-issuer {
font-weight: var(--font-weight-semibold);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.claim-status {
@@ -1003,7 +918,7 @@ import {
.conflict-suggestion p {
margin: 0;
font-size: 0.875rem;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
/* Error */
@@ -1012,22 +927,24 @@ import {
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: var(--color-status-error-text);
border: 1px solid var(--color-status-error-text);
background: var(--color-status-error-bg);
border: 1px solid var(--color-status-error-border);
border-radius: var(--radius-lg);
color: var(--color-status-error-border);
color: var(--color-status-error-text);
}
.error-icon {
width: 24px;
height: 24px;
background: var(--color-status-error-text);
background: var(--color-status-error);
color: #fff;
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--font-weight-bold);
font-size: 0.875rem;
flex-shrink: 0;
}
`]
})
@@ -1160,7 +1077,7 @@ export class VexConsensusComponent implements OnInit {
viewStatement(statementId: string): void {
this.statementSelected.emit(statementId);
this.router.navigate(['..', 'search', 'detail', statementId], { relativeTo: this.route });
this.router.navigate(['/ops/policy/vex/search/detail', statementId]);
}
formatStatus(status: VexStatementStatus): string {

View File

@@ -0,0 +1,206 @@
/**
* VEX Create Page - renders VexCreateWorkflowComponent inline (not as modal)
* by overriding the overlay positioning. On close/create, navigates back.
*/
import { ChangeDetectionStrategy, Component, inject, ViewEncapsulation } from '@angular/core';
import { Router } from '@angular/router';
import { VexCreateWorkflowComponent } from './vex-create-workflow.component';
import { VexStatement } from '../../core/api/vex-hub.models';
@Component({
selector: 'app-vex-create-page',
standalone: true,
imports: [VexCreateWorkflowComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div class="vex-create-page">
<app-vex-create-workflow
[visible]="true"
(closed)="onClose()"
(statementCreated)="onCreated($event)"
/>
</div>
`,
styles: [`
/* Override modal overlay to render inline within the tab panel */
.vex-create-page .workflow-overlay {
position: static !important;
background: none !important;
backdrop-filter: none !important;
padding: 0 !important;
z-index: auto !important;
display: block !important;
}
.vex-create-page .workflow-container {
max-width: 100% !important;
max-height: none !important;
border-radius: var(--radius-lg) !important;
box-shadow: none !important;
border: 1px solid var(--color-border-primary) !important;
background: var(--color-surface-primary) !important;
}
/* Fix buttons to canonical style */
.vex-create-page .btn--primary {
background: var(--color-btn-primary-bg) !important;
color: var(--color-btn-primary-text) !important;
border: 1px solid var(--color-btn-primary-bg) !important;
border-radius: var(--radius-md) !important;
}
.vex-create-page .btn--primary:not(:disabled):hover {
opacity: 0.9 !important;
transform: translateY(-1px) !important;
box-shadow: none !important;
}
.vex-create-page .btn--secondary,
.vex-create-page .btn--ghost {
background: var(--color-surface-secondary) !important;
border: 1px solid var(--color-border-primary) !important;
color: var(--color-text-primary) !important;
border-radius: var(--radius-md) !important;
}
.vex-create-page .btn--text {
background: transparent !important;
border: none !important;
color: var(--color-text-link) !important;
}
/* Fix form inputs to canonical style */
.vex-create-page input[type="text"],
.vex-create-page input[type="url"],
.vex-create-page textarea,
.vex-create-page select {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
background: var(--color-surface-secondary);
color: var(--color-text-primary);
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.vex-create-page input:focus,
.vex-create-page textarea:focus,
.vex-create-page select:focus {
outline: none;
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
.vex-create-page label {
display: block;
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.375rem;
}
/* Fix progress bar background */
.vex-create-page .workflow-progress {
background: var(--color-surface-secondary) !important;
}
/* Fix step indicator active state */
.vex-create-page .progress-step--active .step-indicator {
background: var(--color-btn-primary-bg) !important;
border-color: var(--color-btn-primary-bg) !important;
}
/* Fix error banner */
.vex-create-page .workflow-error {
background: var(--color-status-error-bg) !important;
color: var(--color-status-error-text) !important;
}
/* Fix step indicator: completed = success, inactive = surface */
.vex-create-page .step-indicator {
background: var(--color-surface-tertiary) !important;
border-color: var(--color-border-primary) !important;
color: var(--color-text-secondary) !important;
}
.vex-create-page .progress-step--active .step-indicator {
background: var(--color-btn-primary-bg) !important;
border-color: var(--color-btn-primary-bg) !important;
color: var(--color-btn-primary-text) !important;
}
.vex-create-page .progress-step--completed .step-indicator {
background: var(--color-status-success) !important;
border-color: var(--color-status-success) !important;
color: #fff !important;
}
.vex-create-page .progress-step--active .step-label {
color: var(--color-text-heading) !important;
}
.vex-create-page .progress-step--completed .step-label {
color: var(--color-status-success) !important;
}
/* Fix status option cards */
.vex-create-page .status-option {
background: var(--color-surface-secondary) !important;
border-color: var(--color-border-primary) !important;
}
.vex-create-page .status-option:hover {
border-color: var(--color-text-secondary) !important;
}
.vex-create-page .status-option--selected.status-option--affected {
border-color: var(--color-status-error) !important;
background: var(--color-status-error-bg) !important;
}
.vex-create-page .status-option--selected.status-option--not_affected {
border-color: var(--color-status-success) !important;
background: var(--color-status-success-bg) !important;
}
.vex-create-page .status-option--selected.status-option--fixed {
border-color: var(--color-status-info) !important;
background: var(--color-status-info-bg) !important;
}
.vex-create-page .status-option--selected.status-option--under_investigation {
border-color: var(--color-status-warning) !important;
background: var(--color-status-warning-bg) !important;
}
.vex-create-page .status-icon {
background: var(--color-surface-tertiary) !important;
}
.vex-create-page .status-option--affected .status-icon { color: var(--color-status-error) !important; }
.vex-create-page .status-option--not_affected .status-icon { color: var(--color-status-success) !important; }
.vex-create-page .status-option--fixed .status-icon { color: var(--color-status-info) !important; }
.vex-create-page .status-option--under_investigation .status-icon { color: var(--color-status-warning) !important; }
/* Fix justification type buttons */
.vex-create-page .justification-type-btn {
background: var(--color-surface-secondary) !important;
border-color: var(--color-border-primary) !important;
}
.vex-create-page .justification-type-btn--selected {
border-color: var(--color-brand-primary) !important;
background: var(--color-brand-soft, var(--color-surface-tertiary)) !important;
}
/* Fix review header backgrounds */
.vex-create-page .review-header--affected { background: var(--color-status-error-bg) !important; }
.vex-create-page .review-header--not_affected { background: var(--color-status-success-bg) !important; }
.vex-create-page .review-header--fixed { background: var(--color-status-info-bg) !important; }
.vex-create-page .review-header--under_investigation { background: var(--color-status-warning-bg) !important; }
/* Fix submit notice */
.vex-create-page .submit-notice {
background: var(--color-brand-soft, var(--color-surface-secondary)) !important;
border-color: var(--color-border-emphasis) !important;
color: var(--color-text-primary) !important;
}
/* Fix workflow header */
.vex-create-page .workflow-header {
background: var(--color-surface-secondary) !important;
}
/* Fix footer */
.vex-create-page .workflow-footer {
background: var(--color-surface-secondary) !important;
}
`],
})
export class VexCreatePageComponent {
private readonly router = inject(Router);
onClose(): void {
this.router.navigate(['/ops/policy/vex']);
}
onCreated(stmt: VexStatement): void {
this.router.navigate(['/ops/policy/vex/search/detail', stmt.id]);
}
}

View File

@@ -80,30 +80,52 @@ interface EvidenceItem {
<div class="step-content">
<h3>Identify Vulnerability</h3>
<p class="step-description">
Enter the CVE identifier and affected product reference.
Select the CVE and product affected.
</p>
<div class="form-group">
<div class="form-group form-group--autocomplete">
<label for="cve-id">CVE Identifier *</label>
<input
id="cve-id"
type="text"
[(ngModel)]="form.cveId"
placeholder="CVE-2024-XXXXX"
pattern="CVE-\\d{4}-\\d{4,}"
[ngModel]="form.cveId"
(ngModelChange)="onCveType($event)"
placeholder="Type CVE ID (e.g. CVE-2024)..."
(blur)="onCveBlur()"
autocomplete="off"
/>
<span class="form-hint">Format: CVE-YYYY-NNNNN</span>
@if (cveSuggestions().length > 0 && showCveSuggestions()) {
<ul class="autocomplete-list">
@for (cve of cveSuggestions(); track cve) {
<li class="autocomplete-item" (mousedown)="selectCve(cve)">{{ cve }}</li>
}
</ul>
}
</div>
<div class="form-group">
<label for="product-ref">Product Reference *</label>
<label for="product-repo">Repository</label>
<select
id="product-repo"
[(ngModel)]="selectedRepo"
(ngModelChange)="onRepoChange($event)"
>
<option value="*">All repositories</option>
@for (repo of knownRepos(); track repo) {
<option [value]="repo">{{ repo }}</option>
}
</select>
</div>
<div class="form-group">
<label for="product-tag">Image Reference *</label>
<input
id="product-ref"
id="product-tag"
type="text"
[(ngModel)]="form.productRef"
placeholder="docker.io/org/image:tag or pkg:npm/package@1.0.0"
/>
<span class="form-hint">Image reference or PURL</span>
<span class="form-hint">Full image reference including tag</span>
</div>
<div class="form-group">
@@ -229,26 +251,14 @@ interface EvidenceItem {
<div class="form-group">
<label for="justification-text">
@if (form.status === 'not_affected') {
Detailed Justification *
} @else {
Statement Details
}
Additional Details (Optional)
</label>
<textarea
id="justification-text"
rows="5"
rows="3"
[(ngModel)]="form.justification"
placeholder="Provide a detailed explanation..."
placeholder="Optional notes or context..."
></textarea>
<div class="form-actions">
<button class="btn btn--text" (click)="requestAiDraft()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
</svg>
Draft with AI
</button>
</div>
</div>
@if (form.status === 'fixed') {
@@ -519,7 +529,7 @@ interface EvidenceItem {
.btn-close:hover {
background: var(--color-surface-tertiary);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.btn-close svg {
@@ -533,7 +543,7 @@ interface EvidenceItem {
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--color-border-primary);
background: rgba(30, 41, 59, 0.5);
background: var(--color-surface-secondary);
}
.progress-step {
@@ -566,9 +576,9 @@ interface EvidenceItem {
}
.progress-step--active .step-indicator {
background: linear-gradient(135deg, var(--color-status-info), var(--color-status-info-text));
border-color: var(--color-status-info);
color: white;
background: var(--color-btn-primary-bg);
border-color: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
}
.progress-step--completed .step-indicator {
@@ -618,6 +628,39 @@ interface EvidenceItem {
margin-bottom: 1.25rem;
}
.form-group--autocomplete {
position: relative;
}
.autocomplete-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
margin: 0.25rem 0 0;
padding: 0.25rem 0;
list-style: none;
background: var(--color-surface-elevated, var(--color-surface-primary));
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
max-height: 180px;
overflow-y: auto;
}
.autocomplete-item {
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
font-family: ui-monospace, monospace;
color: var(--color-text-primary);
cursor: pointer;
}
.autocomplete-item:hover {
background: var(--color-surface-tertiary, var(--color-surface-secondary));
}
.form-hint {
display: block;
margin-top: 0.375rem;
@@ -680,12 +723,12 @@ interface EvidenceItem {
.status-option--selected.status-option--fixed {
border-color: var(--color-status-info);
background: rgba(59, 130, 246, 0.1);
background: var(--color-status-info-bg);
}
.status-option--selected.status-option--under_investigation {
border-color: var(--color-status-warning);
background: rgba(245, 158, 11, 0.1);
background: var(--color-status-warning-bg);
}
.status-icon {
@@ -714,18 +757,18 @@ interface EvidenceItem {
}
.status-option--fixed .status-icon {
background: rgba(59, 130, 246, 0.2);
background: var(--color-status-info-bg);
color: var(--color-status-info-border);
}
.status-option--under_investigation .status-icon {
background: rgba(245, 158, 11, 0.2);
background: var(--color-status-warning-bg);
color: var(--color-status-warning-border);
}
.status-info strong {
display: block;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
font-size: 0.9375rem;
margin-bottom: 0.25rem;
}
@@ -759,12 +802,12 @@ interface EvidenceItem {
.justification-type-btn--selected {
border-color: var(--color-status-excepted);
background: rgba(139, 92, 246, 0.1);
background: var(--color-brand-primary-20, var(--color-surface-tertiary));
}
.justification-type-btn strong {
display: block;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
font-size: 0.875rem;
margin-bottom: 0.25rem;
}
@@ -807,10 +850,10 @@ interface EvidenceItem {
font-weight: var(--font-weight-bold);
}
.evidence-icon--sbom { background: rgba(59, 130, 246, 0.2); color: var(--color-status-info-border); }
.evidence-icon--sbom { background: var(--color-status-info-bg); color: var(--color-status-info-border); }
.evidence-icon--attestation { background: var(--color-brand-primary-20); color: var(--color-status-excepted-border); }
.evidence-icon--reachability { background: rgba(251, 191, 36, 0.2); color: var(--color-status-warning-border); }
.evidence-icon--advisory { background: rgba(244, 114, 182, 0.2); color: var(--color-status-excepted-border); }
.evidence-icon--reachability { background: var(--color-status-warning-bg); color: var(--color-status-warning-border); }
.evidence-icon--advisory { background: var(--color-brand-primary-20, var(--color-surface-tertiary)); color: var(--color-status-excepted-border); }
.evidence-icon--other { background: var(--color-surface-tertiary); color: var(--color-text-muted); }
.evidence-info {
@@ -824,7 +867,7 @@ interface EvidenceItem {
.evidence-title {
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.evidence-url {
@@ -849,8 +892,8 @@ interface EvidenceItem {
}
.btn-remove:hover {
background: var(--color-status-error-text);
color: var(--color-status-error-border);
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.btn-remove svg {
@@ -868,7 +911,7 @@ interface EvidenceItem {
margin: 0 0 1rem;
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
/* Review */
@@ -887,8 +930,8 @@ interface EvidenceItem {
.review-header--affected { background: var(--color-status-error-bg); }
.review-header--not_affected { background: var(--color-status-success-bg); }
.review-header--fixed { background: rgba(59, 130, 246, 0.15); }
.review-header--under_investigation { background: rgba(245, 158, 11, 0.15); }
.review-header--fixed { background: var(--color-status-info-bg); }
.review-header--under_investigation { background: var(--color-status-warning-bg); }
.review-status {
font-size: 0.875rem;
@@ -938,7 +981,7 @@ interface EvidenceItem {
.review-value {
font-size: 0.875rem;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
code.review-value {
@@ -953,7 +996,7 @@ interface EvidenceItem {
.review-text {
margin: 0;
font-size: 0.875rem;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
line-height: 1.6;
}
@@ -977,8 +1020,8 @@ interface EvidenceItem {
gap: 0.75rem;
margin-top: 1.5rem;
padding: 1rem;
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.3);
background: var(--color-status-info-bg);
border: 1px solid var(--color-status-info-border);
border-radius: var(--radius-xl);
font-size: 0.8125rem;
color: var(--color-status-info-border);
@@ -1024,8 +1067,9 @@ interface EvidenceItem {
}
.btn--primary {
background: linear-gradient(135deg, var(--color-status-info) 0%, var(--color-status-info-text) 100%);
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
border: 1px solid var(--color-btn-primary-bg);
}
.btn--primary:disabled {
@@ -1034,14 +1078,14 @@ interface EvidenceItem {
}
.btn--primary:not(:disabled):hover {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.btn--secondary {
background: var(--color-surface-tertiary);
border: 1px solid var(--color-border-primary);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.btn--secondary:disabled {
@@ -1059,7 +1103,7 @@ interface EvidenceItem {
}
.btn--ghost:hover {
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.btn--text {
@@ -1093,8 +1137,8 @@ interface EvidenceItem {
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
background: var(--color-status-error-text);
color: var(--color-status-error-border);
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
font-size: 0.875rem;
}
@@ -1122,7 +1166,13 @@ export class VexCreateWorkflowComponent {
// Outputs
readonly closed = output<void>();
readonly statementCreated = output<VexStatement>();
readonly aiDraftRequested = output<{ cveId: string; productRef: string; status: VexStatementStatus }>();
// Repositories loaded from existing VEX statements
readonly knownRepos = signal<string[]>([]);
readonly cveSuggestions = signal<string[]>([]);
readonly showCveSuggestions = signal(false);
selectedRepo = '*';
// State
readonly currentStep = signal<WizardStep>('vulnerability');
@@ -1169,13 +1219,24 @@ export class VexCreateWorkflowComponent {
);
ngOnInit(): void {
// Initialize form with inputs
if (this.initialCveId()) {
this.form.cveId = this.initialCveId();
}
if (this.initialProductRef()) {
this.form.productRef = this.initialProductRef();
}
this.loadKnownRepos();
}
private loadKnownRepos(): void {
this.vexHubApi.searchStatements({ limit: 200 }).subscribe({
next: (result) => {
const products = (result.items ?? []).map(s => s.productRef).filter(Boolean);
const repos = [...new Set(products.map(p => p.split(':')[0]))];
this.knownRepos.set(repos);
},
error: () => this.knownRepos.set([]),
});
}
onBackdropClick(event: MouseEvent): void {
@@ -1218,6 +1279,44 @@ export class VexCreateWorkflowComponent {
return stepIdx < currentIdx;
}
onCveType(value: string): void {
this.form.cveId = value;
if (value.length >= 3) {
this.showCveSuggestions.set(true);
this.vexHubApi.searchStatements({ cveId: value, limit: 20 }).subscribe({
next: (result) => {
const cves = [...new Set((result.items ?? []).map(s => s.cveId))];
this.cveSuggestions.set(cves);
},
error: () => this.cveSuggestions.set([]),
});
} else {
this.cveSuggestions.set([]);
this.showCveSuggestions.set(false);
}
}
selectCve(cve: string): void {
this.form.cveId = cve;
this.showCveSuggestions.set(false);
this.cveSuggestions.set([]);
}
onCveBlur(): void {
// Delay to allow mousedown on suggestion to fire first
setTimeout(() => this.showCveSuggestions.set(false), 200);
}
onRepoChange(repo: string): void {
this.selectedRepo = repo;
if (repo !== '*') {
// Pre-fill the image reference with the repo prefix
if (!this.form.productRef.startsWith(repo)) {
this.form.productRef = repo + ':';
}
}
}
canProceed(): boolean {
switch (this.currentStep()) {
case 'vulnerability':
@@ -1226,11 +1325,11 @@ export class VexCreateWorkflowComponent {
return !!this.form.status;
case 'justification':
if (this.form.status === 'not_affected') {
return !!this.form.justificationType && !!this.form.justification;
return !!this.form.justificationType;
}
return true;
case 'evidence':
return true; // Evidence is optional
return true;
case 'review':
return true;
default:
@@ -1257,14 +1356,6 @@ export class VexCreateWorkflowComponent {
this.form.evidence = this.form.evidence.filter((e) => e.id !== id);
}
requestAiDraft(): void {
this.aiDraftRequested.emit({
cveId: this.form.cveId,
productRef: this.form.productRef,
status: this.form.status,
});
}
async submit(): Promise<void> {
this.submitting.set(true);
this.error.set(null);

View File

@@ -389,7 +389,7 @@ import {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: var(--font-weight-semibold);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.section-header {
@@ -439,7 +439,7 @@ import {
}
.source-card__label { color: var(--color-text-muted); }
.source-card__value { color: rgba(212, 201, 168, 0.3); font-weight: var(--font-weight-semibold); }
.source-card__value { color: var(--color-text-heading); font-weight: var(--font-weight-semibold); }
/* Activity List */
.activity-list {

View File

@@ -1,6 +1,6 @@
/**
* VEX Hub main component.
* Implements VEX-AI-001: Main route with navigation for VEX Hub exploration.
* VEX Hub Explorer component.
* Implements VEX-AI-001: Focused browse/audit view for VEX data.
*/
import { CommonModule } from '@angular/common';
@@ -8,7 +8,6 @@ import {
ChangeDetectionStrategy,
Component,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
@@ -17,333 +16,60 @@ import { firstValueFrom } from 'rxjs';
import { VEX_HUB_API, VexHubApi } from '../../core/api/vex-hub.client';
import { ADVISORY_AI_API, AdvisoryAiApi } from '../../core/api/advisory-ai.client';
import {
VexStatement,
VexStatementSearchParams,
VexHubStats,
VexConsensus,
VexStatementStatus,
VexIssuerType,
} from '../../core/api/vex-hub.models';
import { AiConsentStatus } from '../../core/api/advisory-ai.models';
import {
StellaPageTabsComponent,
StellaPageTab,
} from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { VexHubStats } from '../../core/api/vex-hub.models';
import { StellaQuickLinksComponent, type StellaQuickLink } from '../../shared/components/stella-quick-links/stella-quick-links.component';
import { AuditVexComponent } from '../audit-log/audit-vex.component';
type VexHubTab = 'search' | 'stats' | 'consensus' | 'audit';
@Component({
selector: 'app-vex-hub',
imports: [CommonModule, RouterModule, StellaPageTabsComponent, StellaQuickLinksComponent, AuditVexComponent],
imports: [CommonModule, RouterModule, StellaQuickLinksComponent, AuditVexComponent],
template: `
<div class="vex-hub-container">
<header class="vex-hub-header">
<div>
<h1>VEX Hub Explorer</h1>
<p class="subtitle">Explore VEX statements, view consensus, and manage vulnerability status</p>
<div class="explorer-container">
<!-- Quick Links -->
<stella-quick-links [links]="quickLinks" label="Related" layout="strip" />
<!-- Statistics Summary -->
@if (stats()) {
<div class="stats-row">
<div class="stat-chip stat-chip--total">
<span class="stat-chip__value">{{ stats()!.totalStatements | number }}</span>
<span class="stat-chip__label">Total</span>
</div>
<div class="stat-chip stat-chip--affected">
<span class="stat-chip__value">{{ stats()!.byStatus['affected'] | number }}</span>
<span class="stat-chip__label">Affected</span>
</div>
<div class="stat-chip stat-chip--not-affected">
<span class="stat-chip__value">{{ stats()!.byStatus['not_affected'] | number }}</span>
<span class="stat-chip__label">Not Affected</span>
</div>
<div class="stat-chip stat-chip--fixed">
<span class="stat-chip__value">{{ stats()!.byStatus['fixed'] | number }}</span>
<span class="stat-chip__label">Fixed</span>
</div>
<div class="stat-chip stat-chip--investigating">
<span class="stat-chip__value">{{ stats()!.byStatus['under_investigation'] | number }}</span>
<span class="stat-chip__label">Investigating</span>
</div>
</div>
<aside class="page-aside">
<stella-quick-links [links]="quickLinks" label="Quick Links" layout="strip" />
</aside>
</header>
}
<!-- AI Consent Banner -->
@if (!aiConsented()) {
<div class="ai-consent-banner">
<div class="consent-info">
<span class="consent-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="3"/><path d="M12 8v3"/><circle cx="8" cy="16" r="1"/><circle cx="16" cy="16" r="1"/></svg></span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="3"/><path d="M12 8v3"/><circle cx="8" cy="16" r="1"/><circle cx="16" cy="16" r="1"/></svg>
<span>Enable AI-assisted features for vulnerability explanation and remediation guidance.</span>
</div>
<button class="btn-consent" (click)="showConsentDialog()">Enable AI Features</button>
<button type="button" class="btn btn--primary" (click)="grantAiConsent()">Enable AI Features</button>
</div>
}
<!-- Statistics Summary -->
@if (stats()) {
<div class="stats-summary">
<div class="stat-card total">
<span class="stat-value">{{ stats()!.totalStatements | number }}</span>
<span class="stat-label">Total Statements</span>
</div>
<div class="stat-card affected">
<span class="stat-value">{{ stats()!.byStatus['affected'] | number }}</span>
<span class="stat-label">Affected</span>
</div>
<div class="stat-card not-affected">
<span class="stat-value">{{ stats()!.byStatus['not_affected'] | number }}</span>
<span class="stat-label">Not Affected</span>
</div>
<div class="stat-card fixed">
<span class="stat-value">{{ stats()!.byStatus['fixed'] | number }}</span>
<span class="stat-label">Fixed</span>
</div>
<div class="stat-card investigating">
<span class="stat-value">{{ stats()!.byStatus['under_investigation'] | number }}</span>
<span class="stat-label">Investigating</span>
</div>
</div>
}
<!-- Tabs -->
<stella-page-tabs
[tabs]="pageTabs"
[activeTab]="activeTab()"
urlParam="tab"
(tabChange)="activeTab.set($any($event))"
>
<!-- Search Tab -->
@if (activeTab() === 'search') {
<div class="search-section">
<div class="search-filters">
<input
type="text"
placeholder="CVE ID (e.g., CVE-2024-12345)"
[value]="searchParams().cveId || ''"
(input)="updateSearchParam('cveId', $any($event.target).value)"
/>
<input
type="text"
placeholder="Product (e.g., docker.io/acme/web)"
[value]="searchParams().product || ''"
(input)="updateSearchParam('product', $any($event.target).value)"
/>
<select
[value]="searchParams().status || ''"
(change)="updateSearchParam('status', $any($event.target).value)"
>
<option value="">All Statuses</option>
<option value="affected">Affected</option>
<option value="not_affected">Not Affected</option>
<option value="fixed">Fixed</option>
<option value="under_investigation">Under Investigation</option>
</select>
<select
[value]="searchParams().source || ''"
(change)="updateSearchParam('source', $any($event.target).value)"
>
<option value="">All Sources</option>
<option value="vendor">Vendor</option>
<option value="cert">CERT</option>
<option value="oss">OSS Maintainer</option>
<option value="researcher">Researcher</option>
<option value="ai_generated">AI Generated</option>
</select>
<button class="btn-search" (click)="performSearch()">Search</button>
</div>
@if (loading()) {
<div class="loading">Loading statements...</div>
}
@if (statements().length > 0) {
<table class="stella-table stella-table--striped stella-table--hoverable statements-table">
<thead>
<tr>
<th>CVE</th>
<th>Product</th>
<th>Status</th>
<th>Source</th>
<th>Published</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (stmt of statements(); track stmt.id) {
<tr>
<td class="cve-id">{{ stmt.cveId }}</td>
<td class="product">{{ stmt.productRef }}</td>
<td>
<span class="status-badge" [class]="'status-' + stmt.status">
{{ formatStatus(stmt.status) }}
</span>
</td>
<td>
<span class="source-badge" [class]="'source-' + stmt.sourceType">
{{ stmt.sourceName }}
</span>
</td>
<td>{{ stmt.publishedAt | date:'short' }}</td>
<td>
<button class="btn-icon" title="View Details" (click)="selectStatement(stmt)"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
@if (aiConsented()) {
<button class="btn-icon" title="AI Explain" (click)="explainVuln(stmt.cveId)"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="3"/><path d="M12 8v3"/><circle cx="8" cy="16" r="1"/><circle cx="16" cy="16" r="1"/></svg></button>
}
</td>
</tr>
}
</tbody>
</table>
}
@if (!loading() && statements().length === 0) {
<div class="empty-state">No statements found. Try adjusting your search criteria.</div>
}
</div>
}
<!-- Consensus Tab -->
@if (activeTab() === 'consensus') {
<div class="consensus-section">
<div class="consensus-search">
<input
type="text"
placeholder="Enter CVE ID to view consensus..."
[value]="consensusCveId()"
(input)="consensusCveId.set($any($event.target).value)"
/>
<button class="btn-search" (click)="loadConsensus()">View Consensus</button>
</div>
@if (consensus()) {
<div class="consensus-result">
<div class="consensus-header">
<h3>{{ consensus()!.cveId }}</h3>
<span class="consensus-status" [class]="'status-' + consensus()!.consensusStatus">
Consensus: {{ formatStatus(consensus()!.consensusStatus) }}
</span>
<span class="confidence">{{ (consensus()!.confidence * 100).toFixed(1) }}% confidence</span>
</div>
@if (consensus()!.hasConflict) {
<div class="conflict-warning">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="display:inline;vertical-align:middle"><path d="M10.29 3.86 1.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"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> Conflicting claims detected between issuers
</div>
}
<div class="votes-list">
<h4>Issuer Votes</h4>
@for (vote of consensus()!.votes; track vote.issuerId) {
<div class="vote-item" [class.conflict]="vote.status !== consensus()!.consensusStatus">
<span class="issuer-name">{{ vote.issuerName }}</span>
<span class="issuer-type">({{ vote.issuerType }})</span>
<span class="vote-status" [class]="'status-' + vote.status">
{{ formatStatus(vote.status) }}
</span>
<span class="vote-weight">Weight: {{ vote.weight }}</span>
</div>
}
</div>
</div>
}
</div>
}
<!-- Audit Trail Tab -->
@if (activeTab() === 'audit') {
<div class="audit-section">
<app-audit-vex />
</div>
}
<!-- Statement Detail Panel -->
@if (selectedStatement()) {
<div class="detail-overlay" (click)="selectedStatement.set(null)">
<div class="detail-panel" (click)="$event.stopPropagation()">
<div class="panel-header">
<h3>{{ selectedStatement()!.cveId }}</h3>
<button class="btn-close" (click)="selectedStatement.set(null)">×</button>
</div>
<div class="panel-body">
<div class="detail-row">
<label>Product:</label>
<span>{{ selectedStatement()!.productRef }}</span>
</div>
<div class="detail-row">
<label>Status:</label>
<span class="status-badge" [class]="'status-' + selectedStatement()!.status">
{{ formatStatus(selectedStatement()!.status) }}
</span>
</div>
<div class="detail-row">
<label>Source:</label>
<span>{{ selectedStatement()!.sourceName }} ({{ selectedStatement()!.sourceType }})</span>
</div>
<div class="detail-row">
<label>Document ID:</label>
<span>{{ selectedStatement()!.documentId }}</span>
</div>
<div class="detail-row">
<label>Published:</label>
<span>{{ selectedStatement()!.publishedAt | date:'medium' }}</span>
</div>
@if (selectedStatement()!.justification) {
<div class="detail-row full-width">
<label>Justification:</label>
<p class="justification">{{ selectedStatement()!.justification }}</p>
</div>
}
@if (selectedStatement()!.evidenceRefs?.length) {
<div class="detail-row full-width">
<label>Evidence:</label>
<ul class="evidence-list">
@for (ref of selectedStatement()!.evidenceRefs; track ref.refId) {
<li>{{ ref.label }} ({{ ref.type }})</li>
}
</ul>
</div>
}
</div>
<div class="panel-actions">
@if (aiConsented()) {
<button class="btn-ai" (click)="explainVuln(selectedStatement()!.cveId)">AI Explain</button>
<button class="btn-ai" (click)="remediateVuln(selectedStatement()!.cveId)">AI Remediate</button>
}
<button class="btn-secondary" (click)="viewConsensusFor(selectedStatement()!.cveId)">View Consensus</button>
</div>
</div>
</div>
}
<!-- AI Consent Dialog -->
@if (showingConsentDialog()) {
<div class="consent-overlay" (click)="hideConsentDialog()">
<div class="consent-dialog" (click)="$event.stopPropagation()">
<h3>Enable AI-Assisted Features</h3>
<div class="consent-content">
<p>Advisory AI can help you:</p>
<ul>
<li>Explain vulnerabilities in plain language</li>
<li>Generate remediation guidance</li>
<li>Draft VEX justifications for review</li>
</ul>
<div class="data-notice">
<strong>Data Sharing Notice:</strong>
<p>When using AI features, the following data may be sent to the AI service:</p>
<ul>
<li>CVE details (public information)</li>
<li>Affected product identifiers</li>
<li>SBOM component information (package names, versions)</li>
</ul>
<p><strong>NO proprietary code or secrets are ever shared.</strong></p>
</div>
<label class="checkbox-label">
<input type="checkbox" [(checked)]="consentAcknowledged" />
I understand and consent to AI-assisted analysis
</label>
<label class="checkbox-label">
<input type="checkbox" [(checked)]="sessionConsent" />
Remember my choice for this session
</label>
</div>
<div class="consent-actions">
<button class="btn-cancel" (click)="hideConsentDialog()">Cancel</button>
<button
class="btn-enable"
[disabled]="!consentAcknowledged"
(click)="grantAiConsent()"
>
Enable AI Features
</button>
</div>
</div>
</div>
}
</stella-page-tabs>
<!-- Audit Trail -->
<section class="audit-section">
<h2 class="section-heading">VEX Audit Trail</h2>
<app-audit-vex />
</section>
@if (error()) {
<div class="error-banner">{{ error() }}</div>
@@ -351,127 +77,93 @@ type VexHubTab = 'search' | 'stats' | 'consensus' | 'audit';
</div>
`,
styles: [`
.vex-hub-container { padding: 1.5rem; max-width: 1400px; margin: 0 auto; }
.vex-hub-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1.5rem; margin-bottom: 1.5rem; }
.vex-hub-header h1 { margin: 0; font-size: 1.75rem; }
.subtitle { color: var(--color-text-secondary); margin-top: 0.25rem; }
.page-aside { flex: 0 1 60%; min-width: 0; }
.explorer-container { display: grid; gap: 1rem; }
.stats-row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.stat-chip {
flex: 1;
min-width: 100px;
padding: 0.75rem 1rem;
border-radius: var(--radius-lg);
text-align: center;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
}
.stat-chip--total { border-left: 3px solid var(--color-brand-primary); }
.stat-chip--affected { border-left: 3px solid var(--color-status-error); }
.stat-chip--not-affected { border-left: 3px solid var(--color-status-success); }
.stat-chip--fixed { border-left: 3px solid var(--color-status-info); }
.stat-chip--investigating { border-left: 3px solid var(--color-status-warning); }
.stat-chip__value {
display: block;
font-size: 1.25rem;
font-weight: var(--font-weight-bold);
color: var(--color-text-heading);
}
.stat-chip__label {
font-size: 0.6875rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ai-consent-banner {
display: flex; justify-content: space-between; align-items: center;
background: var(--color-brand-soft); border: 1px solid var(--color-border-emphasis);
color: var(--color-text-primary); padding: 1rem 1.5rem; border-radius: var(--radius-lg); margin-bottom: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--color-brand-soft);
border: 1px solid var(--color-border-emphasis);
color: var(--color-text-primary);
padding: 1rem 1.5rem;
border-radius: var(--radius-lg);
}
.consent-info { display: flex; align-items: center; gap: 0.75rem; }
.consent-icon { font-size: 1.5rem; color: var(--color-text-link); }
.btn-consent {
background: var(--color-btn-primary-bg); color: var(--color-text-heading); border: none; padding: 0.5rem 1rem;
border-radius: var(--radius-sm); font-weight: var(--font-weight-semibold); cursor: pointer;
.consent-info svg { color: var(--color-text-link); flex-shrink: 0; }
.section-heading {
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin: 0 0 0.75rem;
}
.stats-summary { display: flex; gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
.stat-card {
flex: 1; min-width: 120px; padding: 1rem; border-radius: var(--radius-lg);
text-align: center; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary);
.audit-section {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 1.25rem;
}
.stat-card.total { border-left: 3px solid var(--color-brand-primary); }
.stat-card.affected { border-left: 3px solid var(--color-severity-critical); }
.stat-card.not-affected { border-left: 3px solid var(--color-status-success); }
.stat-card.fixed { border-left: 3px solid var(--color-brand-secondary); }
.stat-card.investigating { border-left: 3px solid var(--color-status-warning); }
.stat-value { display: block; font-size: 1.5rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); }
.stat-label { font-size: 0.75rem; color: var(--color-text-secondary); text-transform: uppercase; }
.search-filters {
display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap;
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border-radius: var(--radius-md);
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
border: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
transition: opacity 150ms ease, transform 150ms ease;
}
.search-filters input, .search-filters select {
padding: 0.5rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); min-width: 150px;
.btn:hover { opacity: 0.9; transform: translateY(-1px); }
.btn--primary { background: var(--color-btn-primary-bg); border-color: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); }
.error-banner {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
padding: 1rem;
border-radius: var(--radius-lg);
border: 1px solid var(--color-status-error-border);
}
.btn-search {
padding: 0.5rem 1rem; background: var(--color-btn-primary-bg); color: var(--color-text-heading);
border: none; border-radius: var(--radius-sm); cursor: pointer; font-weight: var(--font-weight-medium);
}
.btn-search:hover { background: var(--color-btn-primary-bg-hover); }
.statements-table th, .statements-table td {
padding: 0.75rem; text-align: left; border-bottom: 1px solid var(--color-surface-secondary);
}
.statements-table th { background: var(--color-surface-secondary); font-weight: var(--font-weight-semibold); font-size: 0.875rem; }
.cve-id { font-family: monospace; font-weight: var(--font-weight-semibold); }
.product { font-family: monospace; font-size: 0.875rem; max-width: 250px; overflow: hidden; text-overflow: ellipsis; }
.status-badge {
padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-size: 0.75rem; font-weight: var(--font-weight-semibold);
}
.status-affected { background: var(--color-status-error-border); color: #fff; }
.status-not_affected { background: var(--color-status-success-border); color: #fff; }
.status-fixed { background: var(--color-status-info-border); color: #fff; }
.status-under_investigation { background: var(--color-status-warning-border); color: var(--color-status-warning); }
.source-badge { font-size: 0.75rem; color: var(--color-text-secondary); }
.btn-icon { background: none; border: none; cursor: pointer; font-size: 1rem; padding: 0.25rem; }
.loading, .empty-state { text-align: center; padding: 2rem; color: var(--color-text-secondary); }
.error-banner { background: var(--color-status-error-bg); color: var(--color-status-error-text); padding: 1rem; border-radius: var(--radius-sm); margin-top: 1rem; }
.consensus-search { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.consensus-search input { flex: 1; max-width: 300px; padding: 0.5rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); }
.consensus-result { background: var(--color-surface-primary); padding: 1.5rem; border-radius: var(--radius-lg); }
.consensus-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; flex-wrap: wrap; }
.consensus-header h3 { margin: 0; }
.consensus-status { padding: 0.25rem 0.75rem; border-radius: var(--radius-sm); font-weight: var(--font-weight-semibold); }
.confidence { color: var(--color-text-secondary); font-size: 0.875rem; }
.conflict-warning { background: var(--color-status-warning-bg); color: var(--color-severity-high); padding: 0.75rem; border-radius: var(--radius-sm); margin-bottom: 1rem; }
.votes-list h4 { margin: 0 0 0.75rem; font-size: 0.875rem; color: var(--color-text-secondary); }
.vote-item {
display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem;
border-bottom: 1px solid var(--color-surface-secondary);
}
.vote-item.conflict { background: var(--color-status-warning-bg); }
.issuer-name { font-weight: var(--font-weight-semibold); }
.issuer-type { color: var(--color-text-secondary); font-size: 0.75rem; }
.vote-status { margin-left: auto; }
.vote-weight { color: var(--color-text-secondary); font-size: 0.75rem; }
.detail-overlay, .consent-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5); display: flex; justify-content: center; align-items: center;
z-index: 1000;
}
.detail-panel, .consent-dialog {
background: var(--color-surface-primary); border-radius: var(--radius-lg); max-width: 600px; width: 90%; max-height: 80vh;
overflow-y: auto;
}
.panel-header, .consent-dialog h3 {
display: flex; justify-content: space-between; align-items: center;
padding: 1rem 1.5rem; border-bottom: 1px solid var(--color-surface-secondary);
}
.panel-header h3, .consent-dialog h3 { margin: 0; }
.btn-close { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: var(--color-text-secondary); }
.panel-body { padding: 1.5rem; }
.detail-row { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
.detail-row label { font-weight: var(--font-weight-semibold); min-width: 100px; color: var(--color-text-secondary); }
.detail-row.full-width { flex-direction: column; }
.justification { background: var(--color-surface-secondary); padding: 0.75rem; border-radius: var(--radius-sm); margin: 0.5rem 0 0; }
.evidence-list { margin: 0.5rem 0 0; padding-left: 1.5rem; }
.panel-actions { padding: 1rem 1.5rem; border-top: 1px solid var(--color-surface-secondary); display: flex; gap: 0.5rem; }
.btn-ai { background: var(--color-btn-primary-bg); color: var(--color-text-heading); border: none; padding: 0.5rem 1rem; border-radius: var(--radius-sm); cursor: pointer; font-weight: var(--font-weight-medium); }
.btn-ai:hover { background: var(--color-btn-primary-bg-hover); }
.btn-secondary { background: var(--color-btn-secondary-bg); border: 1px solid var(--color-btn-secondary-border); padding: 0.5rem 1rem; border-radius: var(--radius-sm); cursor: pointer; }
.consent-content { padding: 1.5rem; }
.consent-content ul { margin: 0.5rem 0; padding-left: 1.5rem; }
.data-notice { background: var(--color-surface-secondary); padding: 1rem; border-radius: var(--radius-sm); margin: 1rem 0; }
.data-notice p { margin: 0.5rem 0; }
.checkbox-label { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.75rem; cursor: pointer; }
.consent-actions { padding: 1rem 1.5rem; border-top: 1px solid var(--color-surface-secondary); display: flex; justify-content: flex-end; gap: 0.5rem; }
.btn-cancel { background: none; border: 1px solid var(--color-border-primary); padding: 0.5rem 1rem; border-radius: var(--radius-sm); cursor: pointer; }
.btn-enable { background: var(--color-btn-primary-bg); color: var(--color-text-heading); border: none; padding: 0.5rem 1rem; border-radius: var(--radius-sm); cursor: pointer; font-weight: var(--font-weight-medium); }
.btn-enable:disabled { opacity: 0.5; cursor: not-allowed; }
.audit-section { margin-top: 0.5rem; }
`],
changeDetection: ChangeDetectionStrategy.OnPush
})
@@ -487,33 +179,14 @@ export class VexHubComponent implements OnInit {
{ label: 'Audit Events', route: '/evidence/audit-log', description: 'Cross-module audit trail' },
];
readonly pageTabs: readonly StellaPageTab[] = [
{ id: 'search', label: 'Search Statements', icon: 'M22 3H2l8 9.46V19l4 2v-8.54L22 3' },
{ id: 'consensus', label: 'Consensus View', icon: 'M20 6L9 17l-5-5' },
{ id: 'audit', label: 'Audit Trail', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z M14 2v6h6 M16 13H8 M16 17H8 M10 9H8' },
];
readonly activeTab = signal<VexHubTab>('search');
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly statements = signal<VexStatement[]>([]);
readonly stats = signal<VexHubStats | null>(null);
readonly selectedStatement = signal<VexStatement | null>(null);
readonly searchParams = signal<VexStatementSearchParams>({});
readonly consensus = signal<VexConsensus | null>(null);
readonly consensusCveId = signal('');
readonly aiConsented = signal(false);
readonly showingConsentDialog = signal(false);
consentAcknowledged = false;
sessionConsent = true;
async ngOnInit(): Promise<void> {
await Promise.all([
this.loadStats(),
this.checkAiConsent(),
this.performSearch(),
]);
}
@@ -535,93 +208,16 @@ export class VexHubComponent implements OnInit {
}
}
updateSearchParam(key: keyof VexStatementSearchParams, value: string): void {
this.searchParams.update((params) => ({
...params,
[key]: value || undefined,
}));
}
async performSearch(): Promise<void> {
this.loading.set(true);
this.error.set(null);
try {
const result = await firstValueFrom(this.vexHubApi.searchStatements(this.searchParams()));
this.statements.set(result.items);
} catch (err) {
this.error.set(err instanceof Error ? err.message : 'Search failed');
} finally {
this.loading.set(false);
}
}
selectStatement(stmt: VexStatement): void {
this.selectedStatement.set(stmt);
}
async loadConsensus(): Promise<void> {
const cveId = this.consensusCveId();
if (!cveId) return;
this.loading.set(true);
this.error.set(null);
try {
const consensus = await firstValueFrom(this.vexHubApi.getConsensus(cveId));
this.consensus.set(consensus);
} catch (err) {
this.error.set(err instanceof Error ? err.message : 'Failed to load consensus');
} finally {
this.loading.set(false);
}
}
viewConsensusFor(cveId: string): void {
this.selectedStatement.set(null);
this.consensusCveId.set(cveId);
this.activeTab.set('consensus');
this.loadConsensus();
}
showConsentDialog(): void {
this.showingConsentDialog.set(true);
}
hideConsentDialog(): void {
this.showingConsentDialog.set(false);
this.consentAcknowledged = false;
}
async grantAiConsent(): Promise<void> {
try {
await firstValueFrom(this.advisoryAiApi.grantConsent({
scope: 'all',
sessionLevel: this.sessionConsent,
sessionLevel: true,
dataShareAcknowledged: true,
}));
this.aiConsented.set(true);
this.hideConsentDialog();
} catch (err) {
this.error.set('Failed to grant AI consent');
}
}
explainVuln(cveId: string): void {
// TODO: Open AI explain panel
console.log('Explain:', cveId);
}
remediateVuln(cveId: string): void {
// TODO: Open AI remediate panel
console.log('Remediate:', cveId);
}
formatStatus(status: VexStatementStatus): string {
const labels: Record<VexStatementStatus, string> = {
affected: 'Affected',
not_affected: 'Not Affected',
fixed: 'Fixed',
under_investigation: 'Investigating',
};
return labels[status] || status;
}
}

View File

@@ -1,6 +1,6 @@
/**
* VEX Hub routes configuration.
* Implements VEX-AI-001: Routes for /admin/vex-hub.
* Implements VEX-AI-001: Routes for VEX Hub under /ops/policy/vex.
*/
import { Routes } from '@angular/router';
@@ -9,7 +9,12 @@ export const vexHubRoutes: Routes = [
{
path: '',
loadComponent: () =>
import('./vex-hub-dashboard.component').then((m) => m.VexHubDashboardComponent),
import('./vex-statement-search.component').then((m) => m.VexStatementSearchComponent),
},
{
path: 'dashboard',
pathMatch: 'full',
redirectTo: '',
},
{
path: 'search',
@@ -21,6 +26,11 @@ export const vexHubRoutes: Routes = [
loadComponent: () =>
import('./vex-statement-detail.component').then((m) => m.VexStatementDetailComponent),
},
{
path: 'create',
loadComponent: () =>
import('./vex-create-page.component').then((m) => m.VexCreatePageComponent),
},
{
path: 'stats',
loadComponent: () =>
@@ -36,4 +46,9 @@ export const vexHubRoutes: Routes = [
loadComponent: () =>
import('./vex-hub.component').then((m) => m.VexHubComponent),
},
{
path: 'conflicts',
loadComponent: () =>
import('./vex-conflicts-page.component').then((m) => m.VexConflictsPageComponent),
},
];

View File

@@ -393,7 +393,7 @@ import {
.btn-close:hover {
background: var(--color-surface-tertiary);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.btn-close svg {
@@ -532,7 +532,7 @@ import {
.info-value {
font-size: 0.9375rem;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.info-value--mono {
@@ -564,7 +564,7 @@ import {
}
.justification-text {
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
font-size: 0.9375rem;
line-height: 1.7;
}
@@ -605,7 +605,7 @@ import {
.issuer-name {
font-size: 0.9375rem;
font-weight: var(--font-weight-medium);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.issuer-type {
@@ -762,7 +762,7 @@ import {
.evidence-title {
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.evidence-type {
@@ -890,7 +890,7 @@ import {
.btn--secondary {
background: var(--color-surface-tertiary);
border: 1px solid var(--color-border-primary);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.btn--secondary:hover {
@@ -903,7 +903,7 @@ import {
}
.btn--ghost:hover {
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
`],
})

View File

@@ -402,7 +402,7 @@ import {
margin: 0 0 1.25rem;
font-size: 1rem;
font-weight: var(--font-weight-semibold);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.detail-grid {
@@ -428,7 +428,7 @@ import {
.product-ref {
font-family: ui-monospace, monospace;
font-size: 0.875rem;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
background: var(--color-surface-tertiary);
padding: 0.5rem 0.75rem;
border-radius: var(--radius-md);
@@ -456,12 +456,12 @@ import {
.source-name {
font-size: 0.875rem;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.justification-type {
font-size: 0.875rem;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.justification-section {
@@ -484,7 +484,7 @@ import {
background: var(--color-surface-tertiary);
padding: 1rem;
border-radius: var(--radius-lg);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
font-size: 0.875rem;
line-height: 1.6;
}
@@ -534,7 +534,7 @@ import {
.evidence-item__label {
font-weight: var(--font-weight-medium);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
font-size: 0.875rem;
}
@@ -603,7 +603,7 @@ import {
.btn--secondary {
background: var(--color-surface-tertiary);
border: 1px solid var(--color-border-primary);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.btn--secondary:hover {
@@ -640,7 +640,7 @@ import {
.metadata-item span {
font-size: 0.875rem;
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
`]
})
@@ -689,17 +689,14 @@ export class VexStatementDetailComponent implements OnInit {
}
goBack(): void {
this.router.navigate(['..'], { relativeTo: this.route });
this.router.navigate(['/ops/policy/vex']);
}
viewConsensus(): void {
const stmt = this.statement();
if (stmt) {
this.consensusRequested.emit(stmt.cveId);
this.router.navigate(['../../consensus'], {
relativeTo: this.route,
queryParams: { cveId: stmt.cveId },
});
this.router.navigate(['/ops/policy/vex/consensus'], { queryParams: { cveId: stmt.cveId } });
}
}

View File

@@ -25,120 +25,55 @@ import {
VexStatementStatus,
VexIssuerType,
} from '../../core/api/vex-hub.models';
import { StellaFilterMultiComponent, type FilterMultiOption } from '../../shared/components/stella-filter-multi/stella-filter-multi.component';
@Component({
selector: 'app-vex-statement-search',
imports: [CommonModule, FormsModule, RouterModule],
imports: [CommonModule, FormsModule, RouterModule, StellaFilterMultiComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="search-container">
<header class="search-header">
<div class="search-header__nav">
<button class="btn-back" routerLink="..">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 19l-7-7 7-7"/>
</svg>
Dashboard
</button>
</div>
<h1>Search VEX Statements</h1>
<p class="search-header__subtitle">
Find vulnerability exploitability statements by CVE, product, status, or source
</p>
</header>
<!-- Search Filters -->
<div class="filters-panel">
<div class="filters-row">
<div class="filter-group">
<label for="cve-filter">CVE ID</label>
<input
id="cve-filter"
type="text"
placeholder="CVE-2024-..."
[(ngModel)]="cveFilter"
(keyup.enter)="performSearch()"
/>
</div>
<div class="filter-group">
<label for="product-filter">Product</label>
<input
id="product-filter"
type="text"
placeholder="docker.io/org/image"
[(ngModel)]="productFilter"
(keyup.enter)="performSearch()"
/>
</div>
<div class="filter-group">
<label for="status-filter">Status</label>
<select id="status-filter" [(ngModel)]="statusFilter">
<option value="">All Statuses</option>
<option value="affected">Affected</option>
<option value="not_affected">Not Affected</option>
<option value="fixed">Fixed</option>
<option value="under_investigation">Under Investigation</option>
</select>
</div>
<div class="filter-group">
<label for="source-filter">Source</label>
<select id="source-filter" [(ngModel)]="sourceFilter">
<option value="">All Sources</option>
<option value="vendor">Vendor</option>
<option value="cert">CERT/CSIRT</option>
<option value="oss">OSS Maintainer</option>
<option value="researcher">Researcher</option>
<option value="ai_generated">AI Generated</option>
</select>
</div>
</div>
<div class="filters-actions">
<button class="btn btn--secondary" (click)="clearFilters()">
Clear Filters
</button>
<button class="btn btn--primary" (click)="performSearch()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
Search
</button>
<!-- Filter Bar -->
<div class="filter-bar">
<div class="filter-bar__search">
<svg class="filter-bar__search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg>
<input
type="text"
class="filter-bar__input"
placeholder="CVE ID, product, or keyword..."
[ngModel]="searchQuery()"
(ngModelChange)="onSearchChange($event)"
/>
</div>
<stella-filter-multi
label="Status"
[options]="statusMultiOptions()"
(optionsChange)="onStatusMultiChange($event)"
/>
<stella-filter-multi
label="Source"
[options]="sourceMultiOptions()"
(optionsChange)="onSourceMultiChange($event)"
/>
<span class="filter-bar__count">{{ total() | number }} results</span>
<button type="button" class="filter-bar__btn filter-bar__btn--primary" (click)="openCreate()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
Create
</button>
</div>
<!-- Results -->
<div class="results-panel">
<!-- Results Table -->
<div class="table-container">
@if (loading()) {
<div class="loading-state">
<div class="loading-spinner"></div>
<p>Searching statements...</p>
</div>
} @else if (statements().length > 0) {
<div class="results-header">
<span class="results-count">{{ total() | number }} statements found</span>
<div class="results-pagination">
<button
class="btn-pagination"
[disabled]="currentPage() <= 1"
(click)="goToPage(currentPage() - 1)"
>
Previous
</button>
<span class="page-info">Page {{ currentPage() }} of {{ totalPages() }}</span>
<button
class="btn-pagination"
[disabled]="currentPage() >= totalPages()"
(click)="goToPage(currentPage() + 1)"
>
Next
</button>
</div>
</div>
<table class="statements-table">
<table class="data-table">
<thead>
<tr>
<th>CVE ID</th>
@@ -151,11 +86,11 @@ import {
</thead>
<tbody>
@for (stmt of statements(); track stmt.id) {
<tr (click)="selectStatement(stmt)" [class.selected]="selectedId() === stmt.id">
<td class="cve-cell">
<tr (click)="selectStatement(stmt)" [class.data-table__row--selected]="selectedId() === stmt.id">
<td>
<span class="cve-id">{{ stmt.cveId }}</span>
</td>
<td class="product-cell">
<td>
<span class="product-ref" [title]="stmt.productRef">{{ stmt.productRef }}</span>
</td>
<td>
@@ -163,294 +98,198 @@ import {
{{ formatStatus(stmt.status) }}
</span>
</td>
<td class="source-cell">
<span class="source-type" [class]="'source-type--' + stmt.sourceType">
{{ formatSourceType(stmt.sourceType) }}
</span>
<span class="source-name">{{ stmt.sourceName }}</span>
<td>
<div class="source-cell">
<span class="source-type" [class]="'source-type--' + stmt.sourceType">
{{ formatSourceType(stmt.sourceType) }}
</span>
<span class="source-name">{{ stmt.sourceName }}</span>
</div>
</td>
<td class="date-cell">
<td class="text-muted">
{{ stmt.publishedAt | date:'mediumDate' }}
</td>
<td class="actions-cell">
<button
class="btn-action"
title="View Details"
(click)="viewDetails(stmt); $event.stopPropagation()"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</button>
<button
class="btn-action"
title="View Consensus"
(click)="viewConsensus(stmt.cveId); $event.stopPropagation()"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</button>
@if (aiEnabled()) {
<td>
<div class="action-buttons">
<button
class="btn-action btn-action--ai"
title="AI Explain"
(click)="requestAiExplain(stmt.cveId); $event.stopPropagation()"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
</svg>
</button>
}
type="button"
class="btn btn--sm"
title="View Details"
(click)="viewDetails(stmt); $event.stopPropagation()"
>Open</button>
<button
type="button"
class="btn btn--sm btn--primary"
title="View Consensus"
(click)="viewConsensus(stmt.cveId); $event.stopPropagation()"
>Consensus</button>
@if (aiEnabled()) {
<button
type="button"
class="btn btn--sm btn--ai"
title="AI Explain"
(click)="requestAiExplain(stmt.cveId); $event.stopPropagation()"
>AI</button>
}
</div>
</td>
</tr>
}
</tbody>
</table>
<!-- Footer with pagination -->
<div class="table-footer">
<span class="table-footer__count">{{ total() | number }} statements found</span>
<div class="table-footer__pagination">
<button
type="button"
class="btn btn--sm"
[disabled]="currentPage() <= 1"
(click)="goToPage(currentPage() - 1)"
>Previous</button>
<span class="page-info">Page {{ currentPage() }} of {{ totalPages() }}</span>
<button
type="button"
class="btn btn--sm"
[disabled]="currentPage() >= totalPages()"
(click)="goToPage(currentPage() + 1)"
>Next</button>
</div>
</div>
} @else {
<div class="empty-state">
<div class="empty-state__icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<div class="empty-state__icon" aria-hidden="true">VX</div>
<h2 class="empty-state__title">No statements found</h2>
<p class="empty-state__description">Try adjusting your search filters or search for a different CVE</p>
<div class="empty-state__actions">
<button type="button" class="btn btn--secondary" (click)="resetFilters()">Reset filters</button>
</div>
<h3>No statements found</h3>
<p>Try adjusting your search filters or search for a different CVE</p>
</div>
}
</div>
@if (error()) {
<div class="error-banner">
<span class="error-icon">!</span>
<span class="error-banner__icon">!</span>
<span>{{ error() }}</span>
<button class="btn btn--text" (click)="performSearch()">Retry</button>
<button type="button" class="btn btn--sm" (click)="performSearch()">Retry</button>
</div>
}
</div>
`,
styles: [`
:host { display: block; min-height: 100%; }
:host { display: block; }
.search-container {
max-width: 1400px;
margin: 0 auto;
padding: 1.5rem;
}
.search-header {
margin-bottom: 1.5rem;
}
.search-header__nav {
margin-bottom: 0.75rem;
}
.btn-back {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
background: transparent;
border: none;
color: var(--color-status-info-border);
font-size: 0.875rem;
cursor: pointer;
border-radius: var(--radius-sm);
transition: all 0.15s ease;
}
.btn-back:hover {
background: var(--color-surface-tertiary);
color: var(--color-status-info-border);
}
.btn-back svg {
width: 16px;
height: 16px;
}
.search-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: var(--font-weight-bold);
color: var(--color-text-heading);
}
.search-header__subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted);
font-size: 0.875rem;
}
/* Filters */
.filters-panel {
background: var(--color-surface-elevated);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-xl);
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.filters-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.filter-group {
/* ---- Filter Bar (canonical horizontal pattern from releases page) ---- */
.filter-bar {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.filters-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.btn {
display: inline-flex;
padding: 0.75rem 1rem;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
flex-wrap: wrap;
}
.filter-bar__search {
position: relative;
flex: 1;
min-width: 200px;
}
.filter-bar__search-icon {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-secondary);
}
.filter-bar__input {
width: 100%;
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
background: var(--color-surface-secondary);
color: var(--color-text-primary);
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.filter-bar__input:focus {
outline: none;
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
.filter-bar__count {
font-size: 0.75rem;
color: var(--color-text-secondary);
white-space: nowrap;
}
.filter-bar__btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
border-radius: var(--radius-md);
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all 0.15s ease;
border: none;
border: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
white-space: nowrap;
transition: opacity 150ms ease;
margin-left: auto;
}
.btn svg {
width: 16px;
height: 16px;
}
.btn--primary {
.filter-bar__btn:hover { opacity: 0.85; }
.filter-bar__btn--primary {
background: var(--color-btn-primary-bg);
border-color: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
}
.btn--primary:hover {
background: var(--color-btn-primary-bg-hover);
}
.btn--secondary {
background: var(--color-btn-secondary-bg);
border: 1px solid var(--color-btn-secondary-border);
color: var(--color-btn-secondary-text);
}
.btn--secondary:hover {
background: var(--color-btn-secondary-hover-bg);
border-color: var(--color-btn-secondary-hover-border);
}
.btn--text {
background: transparent;
color: var(--color-status-info-border);
}
/* Results */
.results-panel {
background: var(--color-surface-elevated);
/* ---- Table Container (canonical data-table pattern) ---- */
.table-container {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-xl);
border-radius: var(--radius-lg);
overflow: hidden;
}
.results-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-border-primary);
}
.results-count {
font-size: 0.875rem;
color: var(--color-text-muted);
}
.results-pagination {
display: flex;
align-items: center;
gap: 0.75rem;
}
.btn-pagination {
padding: 0.375rem 0.75rem;
background: var(--color-surface-tertiary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
color: rgba(212, 201, 168, 0.3);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-pagination:hover:not(:disabled) {
background: var(--color-surface-tertiary);
}
.btn-pagination:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-info {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
/* Table */
.statements-table {
width: 100%;
border-collapse: collapse;
}
.statements-table th {
padding: 0.75rem 1rem;
background: var(--color-surface-tertiary);
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
.data-table { width: 100%; border-collapse: collapse; }
.data-table th,
.data-table td {
padding: 0.5rem 0.75rem;
text-align: left;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.statements-table td {
padding: 0.875rem 1rem;
border-bottom: 1px solid var(--color-border-primary);
font-size: 0.875rem;
color: rgba(212, 201, 168, 0.3);
}
.statements-table tr {
cursor: pointer;
transition: background 0.15s ease;
.data-table th {
background: var(--color-surface-secondary);
font-size: 0.6875rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
position: sticky;
top: 0;
z-index: 1;
}
.data-table tbody tr { cursor: pointer; transition: background-color 150ms ease; }
.data-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); }
.data-table tbody tr:hover { background: var(--color-nav-hover); }
.data-table__row--selected { background: var(--color-selection-bg, var(--color-surface-tertiary)) !important; }
.data-table td { font-size: 0.8125rem; }
.statements-table tr:hover {
background: var(--color-surface-tertiary);
}
.statements-table tr.selected {
background: var(--color-status-info-text);
}
.cve-cell .cve-id {
/* ---- Cell styles ---- */
.cve-id {
font-family: ui-monospace, monospace;
font-weight: var(--font-weight-semibold);
color: var(--color-status-info-border);
color: var(--color-text-link);
}
.product-cell .product-ref {
.product-ref {
font-family: ui-monospace, monospace;
font-size: 0.8125rem;
color: var(--color-text-muted);
@@ -460,128 +299,123 @@ import {
text-overflow: ellipsis;
white-space: nowrap;
}
.text-muted { color: var(--color-text-secondary); font-size: 0.8125rem; }
.status-badge {
display: inline-block;
padding: 0.25rem 0.625rem;
border-radius: var(--radius-full);
font-size: 0.75rem;
padding: 0.125rem 0.625rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.status-badge--affected { background: var(--color-status-error-text); color: #fff; }
.status-badge--not_affected { background: var(--color-status-success-text); color: #fff; }
.status-badge--fixed { background: var(--color-status-info-text); color: #fff; }
.status-badge--under_investigation { background: var(--color-status-warning-text); color: #fff; }
.status-badge--affected { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); }
.status-badge--not_affected { background: var(--color-severity-low-bg); color: var(--color-status-success-text); }
.status-badge--fixed { background: var(--color-severity-info-bg, var(--color-status-info-bg)); color: var(--color-status-info-text); }
.status-badge--under_investigation { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); }
.source-cell {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.source-type {
font-size: 0.6875rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.025em;
}
.source-type--vendor { color: var(--color-status-info-border); }
.source-type--cert { color: var(--color-status-excepted-border); }
.source-type--oss { color: var(--color-status-success-border); }
.source-type--researcher { color: var(--color-status-warning-border); }
.source-type--ai_generated { color: var(--color-status-excepted-border); }
.source-name { font-size: 0.8125rem; color: var(--color-text-muted); }
.source-name {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.date-cell {
color: var(--color-text-secondary);
font-size: 0.8125rem;
}
.actions-cell {
display: flex;
gap: 0.5rem;
}
.btn-action {
width: 32px;
height: 32px;
display: flex;
/* ---- Buttons (canonical pattern from releases page) ---- */
.action-buttons { display: flex; gap: 0.5rem; }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--color-surface-tertiary);
border: 1px solid var(--color-border-primary);
padding: 0.375rem 0.75rem;
border-radius: var(--radius-md);
color: var(--color-text-muted);
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all 0.15s ease;
text-decoration: none;
transition: opacity 150ms ease, transform 150ms ease;
border: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
}
.btn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.btn--sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
.btn--primary { background: var(--color-btn-primary-bg); border-color: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); }
.btn--secondary { background: var(--color-surface-secondary); border-color: var(--color-border-primary); color: var(--color-text-primary); }
.btn--ai { background: var(--color-btn-primary-bg); border-color: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); }
.btn-action:hover {
background: var(--color-surface-tertiary);
color: rgba(212, 201, 168, 0.3);
}
.btn-action--ai {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%);
border-color: var(--color-status-info);
color: var(--color-status-excepted-border);
}
.btn-action--ai:hover {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.4) 0%, rgba(118, 75, 162, 0.4) 100%);
}
.btn-action svg {
width: 16px;
height: 16px;
}
/* Empty State */
.empty-state {
/* ---- Table footer ---- */
.table-footer {
display: flex;
flex-direction: column;
align-items: center;
padding: 4rem 2rem;
justify-content: space-between;
padding: 0.75rem 1rem;
border-top: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
}
.table-footer__count {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.table-footer__pagination {
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-info {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
/* ---- Empty state (canonical pattern) ---- */
.empty-state {
display: grid;
justify-items: center;
gap: 0.75rem;
padding: 2.75rem 1.5rem;
text-align: center;
}
.empty-state__icon {
width: 64px;
height: 64px;
border-radius: var(--radius-2xl);
background: var(--color-surface-tertiary);
display: flex;
width: 3rem;
height: 3rem;
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
color: var(--color-text-secondary);
border-radius: var(--radius-full);
background: var(--color-brand-soft, var(--color-surface-secondary));
color: var(--color-text-link);
font-size: 0.875rem;
font-weight: var(--font-weight-bold);
letter-spacing: 0.08em;
}
.empty-state__icon svg {
width: 32px;
height: 32px;
}
.empty-state h3 {
margin: 0 0 0.5rem;
font-size: 1.125rem;
.empty-state__title {
margin: 0;
font-size: 1rem;
font-weight: var(--font-weight-semibold);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-heading);
}
.empty-state p {
.empty-state__description {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.875rem;
line-height: 1.6;
max-width: 64ch;
}
.empty-state__actions { display: flex; gap: 0.75rem; }
/* Loading */
/* ---- Loading state ---- */
.loading-state {
display: flex;
flex-direction: column;
@@ -589,44 +423,40 @@ import {
padding: 4rem 2rem;
color: var(--color-text-muted);
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border-primary);
border-top-color: var(--color-status-info);
border-top-color: var(--color-brand-primary);
border-radius: var(--radius-full);
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error */
/* ---- Error banner ---- */
.error-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: var(--color-status-error-text);
border: 1px solid var(--color-status-error-text);
background: var(--color-status-error-bg);
border: 1px solid var(--color-status-error-border);
border-radius: var(--radius-lg);
color: var(--color-status-error-border);
margin-top: 1rem;
color: var(--color-status-error-text);
}
.error-icon {
.error-banner__icon {
width: 24px;
height: 24px;
background: var(--color-status-error-text);
background: var(--color-status-error);
color: #fff;
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--font-weight-bold);
font-size: 0.875rem;
flex-shrink: 0;
}
`]
})
@@ -644,6 +474,10 @@ export class VexStatementSearchComponent implements OnInit {
readonly consensusRequested = output<string>();
readonly aiExplainRequested = output<string>();
// Filter lists
private readonly statusList: VexStatementStatus[] = ['affected', 'not_affected', 'fixed', 'under_investigation'];
private readonly sourceList: VexIssuerType[] = ['vendor', 'cert', 'oss', 'researcher', 'ai_generated'];
// State
readonly loading = signal(false);
readonly error = signal<string | null>(null);
@@ -651,11 +485,21 @@ export class VexStatementSearchComponent implements OnInit {
readonly total = signal(0);
readonly selectedId = signal<string | null>(null);
// Filters
cveFilter = '';
productFilter = '';
statusFilter = '';
sourceFilter = '';
// Filters (signals)
readonly searchQuery = signal('');
readonly selectedStatuses = signal<Set<string>>(new Set(this.statusList));
readonly selectedSources = signal<Set<string>>(new Set(this.sourceList));
// Filter multi options (computed)
readonly statusMultiOptions = computed<FilterMultiOption[]>(() => {
const sel = this.selectedStatuses();
return this.statusList.map(s => ({ id: s, label: this.formatStatus(s), checked: sel.has(s) }));
});
readonly sourceMultiOptions = computed<FilterMultiOption[]>(() => {
const sel = this.selectedSources();
return this.sourceList.map(s => ({ id: s, label: this.formatSourceType(s as VexIssuerType), checked: sel.has(s) }));
});
// Pagination
readonly pageSize = 20;
@@ -663,33 +507,59 @@ export class VexStatementSearchComponent implements OnInit {
readonly totalPages = computed(() => Math.ceil(this.total() / this.pageSize));
async ngOnInit(): Promise<void> {
// Check for initial status from route or input
const cveIdParam = this.route.snapshot.queryParamMap.get('cveId');
const qParam = this.route.snapshot.queryParamMap.get('q');
const statusParam = this.route.snapshot.queryParamMap.get('status');
if (cveIdParam) {
this.cveFilter = cveIdParam;
this.searchQuery.set(cveIdParam);
} else if (qParam) {
this.cveFilter = qParam;
this.searchQuery.set(qParam);
}
if (statusParam) {
this.statusFilter = statusParam;
this.selectedStatuses.set(new Set([statusParam]));
} else if (this.initialStatus()) {
this.statusFilter = this.initialStatus()!;
this.selectedStatuses.set(new Set([this.initialStatus()!]));
}
await this.performSearch();
}
onSearchChange(value: string): void {
this.searchQuery.set(value);
this.currentPage.set(1);
this.performSearch();
}
onStatusMultiChange(opts: FilterMultiOption[]): void {
this.selectedStatuses.set(new Set(opts.filter(o => o.checked).map(o => o.id)));
this.currentPage.set(1);
this.performSearch();
}
onSourceMultiChange(opts: FilterMultiOption[]): void {
this.selectedSources.set(new Set(opts.filter(o => o.checked).map(o => o.id)));
this.currentPage.set(1);
this.performSearch();
}
async performSearch(): Promise<void> {
this.loading.set(true);
this.error.set(null);
const query = this.searchQuery().trim();
const statuses = this.selectedStatuses();
const sources = this.selectedSources();
// Build params: only pass filter if not "all selected"
const singleStatus = statuses.size === 1 ? [...statuses][0] as VexStatementStatus : undefined;
const statusParam = statuses.size < this.statusList.length ? singleStatus : undefined;
const singleSource = sources.size === 1 ? [...sources][0] as VexIssuerType : undefined;
const sourceParam = sources.size < this.sourceList.length ? singleSource : undefined;
const params: VexStatementSearchParams = {
cveId: this.cveFilter || undefined,
product: this.productFilter || undefined,
status: (this.statusFilter as VexStatementStatus) || undefined,
source: (this.sourceFilter as VexIssuerType) || undefined,
cveId: query || undefined,
status: statusParam,
source: sourceParam,
limit: this.pageSize,
offset: (this.currentPage() - 1) * this.pageSize,
};
@@ -708,11 +578,10 @@ export class VexStatementSearchComponent implements OnInit {
}
}
clearFilters(): void {
this.cveFilter = '';
this.productFilter = '';
this.statusFilter = '';
this.sourceFilter = '';
resetFilters(): void {
this.searchQuery.set('');
this.selectedStatuses.set(new Set(this.statusList));
this.selectedSources.set(new Set(this.sourceList));
this.currentPage.set(1);
this.performSearch();
}
@@ -730,15 +599,16 @@ export class VexStatementSearchComponent implements OnInit {
}
viewDetails(stmt: VexStatement): void {
this.router.navigate(['detail', stmt.id], { relativeTo: this.route });
this.router.navigate(['/ops/policy/vex/search/detail', stmt.id]);
}
viewConsensus(cveId: string): void {
this.consensusRequested.emit(cveId);
this.router.navigate(['..', 'consensus'], {
relativeTo: this.route,
queryParams: { cveId },
});
this.router.navigate(['/ops/policy/vex/consensus'], { queryParams: { cveId } });
}
openCreate(): void {
this.router.navigate(['/ops/policy/vex/create']);
}
requestAiExplain(cveId: string): void {

View File

@@ -49,6 +49,15 @@ export const SECURITY_RISK_ROUTES: Routes = [
(m) => m.SecurityRiskOverviewComponent
),
},
{
path: 'images',
title: 'Image Security',
data: { breadcrumb: 'Image Security' },
loadChildren: () =>
import('../features/image-security/image-security.routes').then(
(m) => m.imageSecurityRoutes
),
},
{
path: 'posture',
title: 'Security Posture',
@@ -376,33 +385,6 @@ export const SECURITY_RISK_ROUTES: Routes = [
return target;
},
},
{
path: 'symbol-sources',
title: 'Symbol Sources',
data: { breadcrumb: 'Symbol Sources' },
loadComponent: () =>
import('../features/security-risk/symbol-sources/symbol-sources-list.component').then(
(m) => m.SymbolSourcesListComponent
),
},
{
path: 'symbol-sources/:sourceId',
title: 'Symbol Source Detail',
data: { breadcrumb: 'Symbol Source' },
loadComponent: () =>
import('../features/security-risk/symbol-sources/symbol-source-detail.component').then(
(m) => m.SymbolSourceDetailComponent
),
},
{
path: 'symbol-marketplace',
title: 'Symbol Marketplace',
data: { breadcrumb: 'Symbol Marketplace' },
loadComponent: () =>
import('../features/security-risk/symbol-sources/symbol-marketplace-catalog.component').then(
(m) => m.SymbolMarketplaceCatalogComponent
),
},
{
path: 'remediation',
title: 'Remediation',