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:
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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' },
|
||||
]);
|
||||
}
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`],
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">×</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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`],
|
||||
})
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user