From efa33efdbc0630e82b917ef8669f69f0afe09584 Mon Sep 17 00:00:00 2001 From: master <> Date: Mon, 16 Mar 2026 16:08:22 +0200 Subject: [PATCH] Sprint 2+3+5: Registry search, workflow chain, unified security data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint 2 — Registry image search (S2-T01/T02/T03): Harbor plugin: SearchRepositoriesAsync + ListArtifactsAsync calling Harbor /api/v2.0/search and /api/v2.0/projects/*/repositories/*/artifacts Platform endpoint: GET /api/v1/registries/images/search proxies to Harbor fixture, returns aggregated RegistryImage[] response Frontend: release-management.client.ts now calls /api/v1/registries/* instead of the nonexistent /api/registry/* path Gateway route: /api/v1/registries → platform (ReverseProxy) Sprint 3 — Workflow chain links (S3-T01/T02/T03/T05): S3-T01: Integration detail health tab shows "Scan your first image" CTA after successful registry connection test S3-T02: Scan submit page already had "View findings" link (verified) S3-T03: Triage findings detail shows "Check policy gates" banner after recording a VEX decision S3-T05: Promotions list + detail show "Review blocking finding" link when promotion is blocked by gate failure Sprint 5 — Unified security data (S5-T01): Security Posture now queries VULNERABILITY_API for triage stats Risk Posture card shows real finding count from triage (was hardcoded 0) Risk label computed from triage severity breakdown (GUARDED→HIGH) Blocking Items shows critical+high counts from triage "View in Vulnerabilities workspace" drilldown link added Angular build: 0 errors. .NET builds: 0 errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- devops/compose/router-gateway-local.json | 1 + .../HarborConnectorPlugin.cs | 116 +++++++ .../Endpoints/RegistrySearchEndpoints.cs | 285 ++++++++++++++++++ .../StellaOps.Platform.WebService/Program.cs | 12 + .../appsettings.json | 1 + .../app/core/api/release-management.client.ts | 60 +++- .../integration-detail.component.ts | 22 ++ .../promotions/promotion-detail.component.ts | 18 ++ .../promotions/promotions-list.component.ts | 16 + .../security-risk-overview.component.ts | 51 +++- .../findings-detail-page.component.ts | 49 +++ 11 files changed, 617 insertions(+), 14 deletions(-) create mode 100644 src/Platform/StellaOps.Platform.WebService/Endpoints/RegistrySearchEndpoints.cs diff --git a/devops/compose/router-gateway-local.json b/devops/compose/router-gateway-local.json index 9db847370..12897791a 100644 --- a/devops/compose/router-gateway-local.json +++ b/devops/compose/router-gateway-local.json @@ -96,6 +96,7 @@ { "Type": "Microservice", "Path": "^/api/v1/advisory(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory$1" }, { "Type": "Microservice", "Path": "^/api/v1/vex(.*)", "IsRegex": true, "TranslatesTo": "https://vexhub.stella-ops.local/api/v1/vex$1" }, { "Type": "Microservice", "Path": "^/api/v1/doctor/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://doctor-scheduler.stella-ops.local/api/v1/doctor/scheduler$1" }, + { "Type": "ReverseProxy", "Path": "^/api/v1/registries(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/registries$1", "PreserveAuthHeaders": true }, { "Type": "Microservice", "Path": "^/api/v2/context(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/context$1" }, { "Type": "Microservice", "Path": "^/api/v2/releases(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/releases$1" }, diff --git a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Harbor/HarborConnectorPlugin.cs b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Harbor/HarborConnectorPlugin.cs index 9763199b4..68acc0758 100644 --- a/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Harbor/HarborConnectorPlugin.cs +++ b/src/Integrations/__Plugins/StellaOps.Integrations.Plugin.Harbor/HarborConnectorPlugin.cs @@ -160,11 +160,84 @@ public sealed class HarborConnectorPlugin : IIntegrationConnectorPlugin return client; } + /// + /// Searches Harbor repositories matching the given query string. + /// Uses Harbor v2.0 global search or project-scoped repository listing. + /// + public async Task> SearchRepositoriesAsync(IntegrationConfig config, string query, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(query)) + return []; + + using var client = CreateHttpClient(config); + + try + { + // Use Harbor global search endpoint + var response = await client.GetAsync($"/api/v2.0/search?q={Uri.EscapeDataString(query)}", ct); + if (!response.IsSuccessStatusCode) + return []; + + var content = await response.Content.ReadAsStringAsync(ct); + var searchResult = JsonSerializer.Deserialize(content, JsonOptions); + + return (searchResult?.Repository ?? []) + .Select(r => new RepositoryInfo + { + Name = r.RepositoryName ?? string.Empty, + Project = r.ProjectName ?? string.Empty, + Tags = [] + }) + .ToList(); + } + catch + { + return []; + } + } + + /// + /// Lists artifacts for a specific repository within a project. + /// + public async Task> ListArtifactsAsync(IntegrationConfig config, string project, string repository, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(project) || string.IsNullOrWhiteSpace(repository)) + return []; + + using var client = CreateHttpClient(config); + + try + { + var encodedRepo = Uri.EscapeDataString(repository); + var response = await client.GetAsync($"/api/v2.0/projects/{Uri.EscapeDataString(project)}/repositories/{encodedRepo}/artifacts", ct); + if (!response.IsSuccessStatusCode) + return []; + + var content = await response.Content.ReadAsStringAsync(ct); + var artifacts = JsonSerializer.Deserialize>(content, JsonOptions); + + return (artifacts ?? []) + .Select(a => new ArtifactInfo + { + Digest = a.Digest ?? string.Empty, + Tags = (a.Tags ?? []).Select(t => t.Name ?? string.Empty).Where(n => n.Length > 0).ToList(), + PushedAt = a.PushTime + }) + .ToList(); + } + catch + { + return []; + } + } + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + // ── Harbor API DTOs ──────────────────────────────────────────── + private sealed class HarborHealthResponse { public string? Status { get; set; } @@ -176,4 +249,47 @@ public sealed class HarborConnectorPlugin : IIntegrationConnectorPlugin public string Name { get; set; } = string.Empty; public string Status { get; set; } = string.Empty; } + + private sealed class HarborSearchResponse + { + public List? Repository { get; set; } + } + + private sealed class HarborSearchRepository + { + public string? RepositoryName { get; set; } + public string? ProjectName { get; set; } + } + + private sealed class HarborArtifactDto + { + public string? Digest { get; set; } + public List? Tags { get; set; } + public DateTimeOffset? PushTime { get; set; } + } + + private sealed class HarborTagDto + { + public string? Name { get; set; } + } +} + +/// +/// Information about a Harbor repository returned from search. +/// +public sealed class RepositoryInfo +{ + public string Name { get; set; } = string.Empty; + public string Project { get; set; } = string.Empty; + public List Tags { get; set; } = []; +} + +/// +/// Information about a Harbor artifact (image manifest). +/// +public sealed class ArtifactInfo +{ + public string Digest { get; set; } = string.Empty; + public List Tags { get; set; } = []; + public DateTimeOffset? PushedAt { get; set; } } diff --git a/src/Platform/StellaOps.Platform.WebService/Endpoints/RegistrySearchEndpoints.cs b/src/Platform/StellaOps.Platform.WebService/Endpoints/RegistrySearchEndpoints.cs new file mode 100644 index 000000000..3949877b1 --- /dev/null +++ b/src/Platform/StellaOps.Platform.WebService/Endpoints/RegistrySearchEndpoints.cs @@ -0,0 +1,285 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using StellaOps.Auth.Abstractions; +using StellaOps.Auth.ServerIntegration; +using StellaOps.Auth.ServerIntegration.Tenancy; +using StellaOps.Platform.WebService.Constants; +using System.Globalization; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace StellaOps.Platform.WebService.Endpoints; + +/// +/// Aggregation endpoint for registry image search. +/// Proxies search to connected registry integrations (Harbor fixture by default) +/// and returns a unified response format for the frontend. +/// +public static class RegistrySearchEndpoints +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public static IEndpointRouteBuilder MapRegistrySearchEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/api/v1/registries") + .WithTags("Registry Search") + .RequireAuthorization(PlatformPolicies.IntegrationsRead) + .RequireTenant(); + + group.MapGet("/images/search", SearchImagesAsync) + .WithName("Registries.SearchImages") + .WithSummary("Search container images across connected registries"); + + group.MapGet("/images/digests", GetImageDigestsAsync) + .WithName("Registries.GetImageDigests") + .WithSummary("Get artifact digests for a specific repository"); + + return app; + } + + private static async Task SearchImagesAsync( + HttpContext ctx, + IHttpClientFactory httpClientFactory, + [FromQuery] string? q, + ILoggerFactory loggerFactory) + { + if (string.IsNullOrWhiteSpace(q) || q.Trim().Length < 2) + { + return Results.Ok(new RegistrySearchResponse + { + Items = [], + TotalCount = 0, + RegistryId = null + }); + } + + var query = q.Trim(); + var logger = loggerFactory.CreateLogger("RegistrySearch"); + + try + { + using var client = httpClientFactory.CreateClient("HarborFixture"); + var response = await client.GetAsync( + $"/api/v2.0/search?q={Uri.EscapeDataString(query)}", + ctx.RequestAborted); + + if (!response.IsSuccessStatusCode) + { + logger.LogWarning( + "Harbor search returned {StatusCode} for query '{Query}'", + response.StatusCode, query); + + return Results.Ok(new RegistrySearchResponse + { + Items = [], + TotalCount = 0, + RegistryId = "harbor-fixture" + }); + } + + var content = await response.Content.ReadAsStringAsync(ctx.RequestAborted); + var searchResult = JsonSerializer.Deserialize(content, JsonOptions); + + var items = (searchResult?.Repository ?? []) + .Select(r => + { + var fullName = r.RepositoryName ?? string.Empty; + var shortName = fullName.Contains('/') + ? fullName.Substring(fullName.LastIndexOf('/') + 1) + : fullName; + + return new RegistryImageItem + { + Name = shortName, + Repository = fullName, + Tags = [], + Digests = [], + }; + }) + .ToList(); + + return Results.Ok(new RegistrySearchResponse + { + Items = items, + TotalCount = items.Count, + RegistryId = "harbor-fixture" + }); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException) + { + logger.LogWarning(ex, "Harbor registry unreachable during image search for query '{Query}'", query); + + return Results.Ok(new RegistrySearchResponse + { + Items = [], + TotalCount = 0, + RegistryId = "harbor-fixture" + }); + } + } + + private static async Task GetImageDigestsAsync( + HttpContext ctx, + IHttpClientFactory httpClientFactory, + [FromQuery] string? repository, + ILoggerFactory loggerFactory) + { + if (string.IsNullOrWhiteSpace(repository)) + { + return Results.BadRequest(new { error = "repository_required" }); + } + + var repo = repository.Trim(); + var logger = loggerFactory.CreateLogger("RegistrySearch"); + // Parse project/repository from the full repository name + var slashIndex = repo.IndexOf('/'); + if (slashIndex < 0) + { + // No project prefix - return empty + return Results.Ok(new RegistryImageItem + { + Name = repo, + Repository = repo, + Tags = [], + Digests = [] + }); + } + + var project = repo.Substring(0, slashIndex); + var repoName = repo.Substring(slashIndex + 1); + + try + { + using var client = httpClientFactory.CreateClient("HarborFixture"); + var encodedRepo = Uri.EscapeDataString(repoName); + var response = await client.GetAsync( + $"/api/v2.0/projects/{Uri.EscapeDataString(project)}/repositories/{encodedRepo}/artifacts", + ctx.RequestAborted); + + if (!response.IsSuccessStatusCode) + { + return Results.Ok(new RegistryImageItem + { + Name = repoName, + Repository = repo, + Tags = [], + Digests = [] + }); + } + + var content = await response.Content.ReadAsStringAsync(ctx.RequestAborted); + var artifacts = JsonSerializer.Deserialize>(content, JsonOptions) ?? []; + + var tags = artifacts + .SelectMany(a => (a.Tags ?? []).Select(t => t.Name ?? string.Empty)) + .Where(t => t.Length > 0) + .Distinct() + .ToList(); + + var digests = artifacts + .SelectMany(a => + { + var artifactTags = (a.Tags ?? []).Select(t => t.Name ?? "untagged").ToList(); + if (artifactTags.Count == 0) artifactTags.Add("untagged"); + + return artifactTags.Select(tag => new DigestEntry + { + Tag = tag, + Digest = a.Digest ?? string.Empty, + PushedAt = a.PushTime?.ToString("O", CultureInfo.InvariantCulture) ?? string.Empty + }); + }) + .ToList(); + + return Results.Ok(new RegistryImageItem + { + Name = repoName, + Repository = repo, + Tags = tags, + Digests = digests + }); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException) + { + logger.LogWarning(ex, "Harbor registry unreachable during digest lookup for '{Repository}'", repo); + + return Results.Ok(new RegistryImageItem + { + Name = repoName, + Repository = repo, + Tags = [], + Digests = [] + }); + } + } + + // ── Response DTOs ────────────────────────────────────────────── + + private sealed class RegistrySearchResponse + { + [JsonPropertyName("items")] + public List Items { get; set; } = []; + + [JsonPropertyName("totalCount")] + public int TotalCount { get; set; } + + [JsonPropertyName("registryId")] + public string? RegistryId { get; set; } + } + + private sealed class RegistryImageItem + { + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("repository")] + public string Repository { get; set; } = string.Empty; + + [JsonPropertyName("tags")] + public List Tags { get; set; } = []; + + [JsonPropertyName("digests")] + public List Digests { get; set; } = []; + } + + private sealed class DigestEntry + { + [JsonPropertyName("tag")] + public string Tag { get; set; } = string.Empty; + + [JsonPropertyName("digest")] + public string Digest { get; set; } = string.Empty; + + [JsonPropertyName("pushedAt")] + public string PushedAt { get; set; } = string.Empty; + } + + // ── Harbor API DTOs ──────────────────────────────────────────── + + private sealed class HarborSearchResult + { + public List? Repository { get; set; } + } + + private sealed class HarborSearchRepository + { + public string? RepositoryName { get; set; } + public string? ProjectName { get; set; } + } + + private sealed class HarborArtifactDto + { + public string? Digest { get; set; } + public List? Tags { get; set; } + public DateTimeOffset? PushTime { get; set; } + } + + private sealed class HarborTagDto + { + public string? Name { get; set; } + } +} diff --git a/src/Platform/StellaOps.Platform.WebService/Program.cs b/src/Platform/StellaOps.Platform.WebService/Program.cs index ab30ca9ad..e3b59c4c9 100644 --- a/src/Platform/StellaOps.Platform.WebService/Program.cs +++ b/src/Platform/StellaOps.Platform.WebService/Program.cs @@ -190,6 +190,17 @@ builder.Services.AddHttpClient("AuthorityInternal", client => client.Timeout = TimeSpan.FromSeconds(30); }); +builder.Services.AddHttpClient("HarborFixture", client => +{ + var harborUrl = builder.Configuration["STELLAOPS_HARBOR_URL"] + ?? builder.Configuration["Platform:HarborFixtureUrl"] + ?? "http://harbor-fixture.stella-ops.local"; + client.BaseAddress = new Uri(harborUrl.TrimEnd('/') + "/"); + client.DefaultRequestHeaders.Accept.Add( + new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); + client.Timeout = TimeSpan.FromSeconds(15); +}); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); @@ -345,6 +356,7 @@ app.MapAdministrationTrustSigningMutationEndpoints(); app.MapFederationTelemetryEndpoints(); app.MapSeedEndpoints(); app.MapMigrationAdminEndpoints(); +app.MapRegistrySearchEndpoints(); app.MapGet("/healthz", () => Results.Ok(new { status = "ok" })) .WithTags("Health") diff --git a/src/Router/StellaOps.Gateway.WebService/appsettings.json b/src/Router/StellaOps.Gateway.WebService/appsettings.json index 3d516ed59..129252b1c 100644 --- a/src/Router/StellaOps.Gateway.WebService/appsettings.json +++ b/src/Router/StellaOps.Gateway.WebService/appsettings.json @@ -122,6 +122,7 @@ { "Type": "Microservice", "Path": "^/api/v1/advisory(.*)", "IsRegex": true, "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory$1" }, { "Type": "Microservice", "Path": "^/api/v1/vex(.*)", "IsRegex": true, "TranslatesTo": "http://vexhub.stella-ops.local/api/v1/vex$1" }, { "Type": "Microservice", "Path": "^/api/v1/doctor/scheduler(.*)", "IsRegex": true, "TranslatesTo": "http://doctor-scheduler.stella-ops.local/api/v1/doctor/scheduler$1" }, + { "Type": "ReverseProxy", "Path": "^/api/v1/registries(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v1/registries$1", "PreserveAuthHeaders": true }, { "Type": "Microservice", "Path": "^/api/v2/context(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/context$1" }, { "Type": "Microservice", "Path": "^/api/v2/releases(.*)", "IsRegex": true, "TranslatesTo": "http://platform.stella-ops.local/api/v2/releases$1" }, diff --git a/src/Web/StellaOps.Web/src/app/core/api/release-management.client.ts b/src/Web/StellaOps.Web/src/app/core/api/release-management.client.ts index f45a3b3fd..f6ba40ee5 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/release-management.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/release-management.client.ts @@ -25,6 +25,24 @@ import type { export const RELEASE_MANAGEMENT_API = new InjectionToken('RELEASE_MANAGEMENT_API'); +interface RegistrySearchResponse { + items: Array<{ + name: string; + repository: string; + tags: string[]; + digests: Array<{ tag: string; digest: string; pushedAt: string }>; + }>; + totalCount: number; + registryId: string | null; +} + +interface RegistryDigestResponse { + name: string; + repository: string; + tags: string[]; + digests: Array<{ tag: string; digest: string; pushedAt: string }>; +} + interface PlatformListResponse { items: T[]; total: number; @@ -378,22 +396,50 @@ export class ReleaseManagementHttpClient implements ReleaseManagementApi { return of([]); } - return this.http.get('/api/registry/images/search', { params: { q: query } }).pipe( - catchError(() => of([])), + return this.http.get('/api/v1/registries/images/search', { params: { q: query } }).pipe( + map((response) => + (response.items ?? []).map((item) => ({ + name: item.name, + repository: item.repository, + tags: item.tags ?? [], + digests: (item.digests ?? []).map((d) => ({ + tag: d.tag, + digest: d.digest, + pushedAt: d.pushedAt, + })), + lastPushed: item.digests?.[0]?.pushedAt ?? '', + })), + ), + catchError((err) => { + console.warn('[ReleaseManagement] Registry image search failed:', err?.message ?? err); + return of([]); + }), ); } getImageDigests(repository: string): Observable { - return this.http.get('/api/registry/images/digests', { params: { repository } }).pipe( - catchError(() => - of({ + return this.http.get('/api/v1/registries/images/digests', { params: { repository } }).pipe( + map((response) => ({ + name: response.name, + repository: response.repository, + tags: response.tags ?? [], + digests: (response.digests ?? []).map((d) => ({ + tag: d.tag, + digest: d.digest, + pushedAt: d.pushedAt, + })), + lastPushed: response.digests?.[0]?.pushedAt ?? '', + })), + catchError((err) => { + console.warn('[ReleaseManagement] Registry digest lookup failed:', err?.message ?? err); + return of({ name: repository.split('/').at(-1) ?? repository, repository, tags: [], digests: [], lastPushed: '', - }), - ), + }); + }), ); } diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts index 1ed2332f3..cd5632a53 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts @@ -8,6 +8,7 @@ import { HealthStatus, Integration, IntegrationHealthResponse, + IntegrationType, TestConnectionResponse, IntegrationStatus, getHealthStatusColor, @@ -183,6 +184,11 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event

{{ lastTestResult.message || 'Connection successful.' }}

Tested at {{ lastTestResult.testedAt | date:'medium' }} (duration {{ lastTestResult.duration }}) + @if (lastTestResult.success && isRegistryType()) { + + Registry connected! Scan your first image -> + + } } @if (lastHealthResult) { @@ -423,6 +429,18 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event } .loading { text-align: center; padding: 3rem; color: var(--color-text-secondary); } + .workflow-cta { + display: inline-block; + margin-top: 0.75rem; + padding: 0.5rem 1rem; + background: var(--color-brand-primary); + color: var(--color-text-heading); + border-radius: var(--radius-md); + text-decoration: none; + font-weight: var(--font-weight-medium); + font-size: 0.875rem; + } + .delete-error { display: flex; justify-content: space-between; @@ -587,6 +605,10 @@ export class IntegrationDetailComponent implements OnInit { return getProviderLabel(provider); } + isRegistryType(): boolean { + return this.integration?.type === IntegrationType.Registry; + } + integrationHubRoute(): string[] { return this.integrationCommands(); } diff --git a/src/Web/StellaOps.Web/src/app/features/promotions/promotion-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/promotions/promotion-detail.component.ts index 0313c04f6..943f99915 100644 --- a/src/Web/StellaOps.Web/src/app/features/promotions/promotion-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/promotions/promotion-detail.component.ts @@ -109,6 +109,12 @@ type DetailTab =
Requested: {{ formatDate(promotion()!.requestedAt) }}
+ @if (!promotion()!.gatesPassed) { + + Review blocking finding -> + + } + @if (promotion()!.status === 'pending') {
@@ -540,6 +546,18 @@ type DetailTab = color: var(--color-brand-primary, #4f46e5); text-decoration: none; } + + .blocked-finding-link { + display: inline-block; + font-size: 0.82rem; + color: #991b1b; + text-decoration: none; + font-weight: 500; + } + + .blocked-finding-link:hover { + text-decoration: underline; + } `, ], }) diff --git a/src/Web/StellaOps.Web/src/app/features/promotions/promotions-list.component.ts b/src/Web/StellaOps.Web/src/app/features/promotions/promotions-list.component.ts index 7c3bec729..a971f791d 100644 --- a/src/Web/StellaOps.Web/src/app/features/promotions/promotions-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/promotions/promotions-list.component.ts @@ -197,6 +197,9 @@ interface PromotionRow { {{ promotion.riskSignal.text }} + @if (promotion.riskSignal.level === 'blocked') { + Review blocking finding -> + } @@ -411,6 +414,19 @@ interface PromotionRow { color: #4b5563; } + .blocked-finding-link { + display: block; + margin-top: 0.3rem; + font-size: 0.75rem; + color: #991b1b; + text-decoration: none; + font-weight: 500; + } + + .blocked-finding-link:hover { + text-decoration: underline; + } + .requested-cell { display: grid; gap: 0.2rem; diff --git a/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts b/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts index 504ab5e44..6d02316c0 100644 --- a/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts @@ -5,6 +5,8 @@ import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks- import { forkJoin, of } from 'rxjs'; import { catchError, map, take } from 'rxjs/operators'; import { AdvisorySourcesApi, AdvisorySourceListItemDto } from './advisory-sources.api'; +import { VULNERABILITY_API, type VulnerabilityApi } from '../../core/api/vulnerability.client'; +import type { VulnerabilityStats } from '../../core/api/vulnerability.models'; import { PlatformContextStore } from '../../core/context/platform-context.store'; @@ -95,11 +97,12 @@ interface PlatformListResponse {

Risk Posture

{{ riskPostureLabel() }}

{{ findingsCount() }} findings in scope + View in Vulnerabilities workspace

Blocking Items

{{ blockerCount() }}

- Policy action = block + {{ triageCriticalCount() }} critical, {{ triageHighCount() }} high severity

VEX Coverage

@@ -271,6 +274,8 @@ interface PlatformListResponse { } .kpis .value{margin:.2rem 0 0;font-size:1.15rem;font-weight:var(--font-weight-semibold)} .kpis small{font-size:.68rem;color:var(--color-text-secondary)} + .kpi-link{display:block;margin-top:.25rem;font-size:.66rem;color:var(--color-brand-primary);text-decoration:none} + .kpi-link:hover{text-decoration:underline} .grid{ display:grid; @@ -297,6 +302,7 @@ interface PlatformListResponse { export class SecurityRiskOverviewComponent { private readonly http = inject(HttpClient); private readonly advisorySourcesApi = inject(AdvisorySourcesApi); + private readonly vulnApi = inject(VULNERABILITY_API); readonly context = inject(PlatformContextStore); readonly loading = signal(false); @@ -306,10 +312,27 @@ export class SecurityRiskOverviewComponent { readonly sbomRows = signal([]); readonly feedHealth = signal([]); readonly vexSourceHealth = signal([]); + readonly triageStats = signal(null); - readonly findingsCount = computed(() => this.findings().length); + /** Use triage stats total when security findings API returns empty. */ + readonly findingsCount = computed(() => { + const securityFindings = this.findings().length; + if (securityFindings > 0) return securityFindings; + return this.triageStats()?.total ?? 0; + }); readonly reachableCount = computed(() => this.findings().filter((item) => item.reachable).length); - readonly blockerCount = computed(() => this.topBlockers().length); + + /** Triage severity breakdown for blocker KPI. */ + readonly triageCriticalCount = computed(() => this.triageStats()?.bySeverity?.critical ?? 0); + readonly triageHighCount = computed(() => this.triageStats()?.bySeverity?.high ?? 0); + + /** Blockers: disposition-based blockers + critical open findings from triage. */ + readonly blockerCount = computed(() => { + const dispositionBlockers = this.topBlockers().length; + if (dispositionBlockers > 0) return dispositionBlockers; + // Fall back to critical open from triage when no disposition data + return this.triageStats()?.criticalOpen ?? 0; + }); readonly topBlockers = computed(() => this.dispositions() .filter((item) => item.policyAction === 'block' || item.effectiveDisposition === 'action_required') @@ -342,10 +365,20 @@ export class SecurityRiskOverviewComponent { }); readonly riskPostureLabel = computed(() => { - const critical = this.findings().filter((item) => item.severity === 'critical').length; - const high = this.findings().filter((item) => item.severity === 'high').length; + const securityFindings = this.findings(); + const critical = securityFindings.filter((item) => item.severity === 'critical').length; + const high = securityFindings.filter((item) => item.severity === 'high').length; if (critical > 0) return 'HIGH'; if (high > 0) return 'ELEVATED'; + // Fall back to triage stats when security findings API returns empty + if (securityFindings.length === 0) { + const stats = this.triageStats(); + if (stats) { + if ((stats.bySeverity?.critical ?? 0) > 0) return 'HIGH'; + if ((stats.bySeverity?.high ?? 0) > 0) return 'ELEVATED'; + if (stats.total > 0) return 'GUARDED'; + } + } return 'GUARDED'; }); @@ -452,16 +485,20 @@ export class SecurityRiskOverviewComponent { catchError(() => of([] as IntegrationHealthRow[])) ); const vexHealth$ = of([] as IntegrationHealthRow[]); + const triageStats$ = this.vulnApi.getStats().pipe( + catchError(() => of(null as VulnerabilityStats | null)) + ); - forkJoin({ findings: findings$, disposition: disposition$, sbom: sbom$, feedHealth: feedHealth$, vexHealth: vexHealth$ }) + forkJoin({ findings: findings$, disposition: disposition$, sbom: sbom$, feedHealth: feedHealth$, vexHealth: vexHealth$, triageStats: triageStats$ }) .pipe(take(1)) .subscribe({ - next: ({ findings, disposition, sbom, feedHealth, vexHealth }) => { + next: ({ findings, disposition, sbom, feedHealth, vexHealth, triageStats }) => { this.findings.set(findings); this.dispositions.set(disposition); this.sbomRows.set(sbom); this.feedHealth.set(feedHealth); this.vexSourceHealth.set(vexHealth); + this.triageStats.set(triageStats); this.loading.set(false); }, error: (err: unknown) => { diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/findings-detail-page/findings-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/findings-detail-page/findings-detail-page.component.ts index 4046f6eed..5daec6993 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/findings-detail-page/findings-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/findings-detail-page/findings-detail-page.component.ts @@ -17,6 +17,7 @@ import { OnDestroy, } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; import { Subject, takeUntil, forkJoin } from 'rxjs'; // Components @@ -60,6 +61,7 @@ export interface FindingDetail { standalone: true, imports: [ CommonModule, + RouterModule, TriageLaneToggleComponent, GatedBucketsComponent, GatingReasonFilterComponent, @@ -194,6 +196,17 @@ export interface FindingDetail { (decisionSubmit)="onDecisionSubmit($event)" (decisionRevoked)="onDecisionRevoked($event)" /> + + + @if (decisionSuccess()) { +
+ Decision recorded. + + Check policy gates for this release -> + + +
+ }
`, styles: [` @@ -457,6 +470,37 @@ export interface FindingDetail { background: var(--color-brand-secondary); } + /* Workflow chain: decision success banner */ + .decision-success-banner { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 0.75rem; + border-radius: var(--radius-sm); + border: 1px solid color-mix(in srgb, var(--color-status-success-text) 30%, transparent); + background: color-mix(in srgb, var(--color-status-success-text) 8%, transparent); + color: var(--color-status-success-text); + font-size: 0.82rem; + font-weight: 500; + } + + .policy-gate-link { + margin-left: auto; + color: var(--color-brand-primary); + text-decoration: none; + font-weight: 600; + } + + .dismiss-btn { + background: none; + border: none; + cursor: pointer; + color: var(--color-text-secondary); + font-size: 1.1rem; + padding: 0 0.2rem; + } + /* High contrast mode */ @media (prefers-contrast: high) { .finding-card { @@ -493,6 +537,7 @@ export class FindingsDetailPageComponent implements OnInit, OnDestroy { readonly selectedFinding = signal(null); readonly selectedCallPath = signal(null); readonly isDrawerOpen = signal(false); + readonly decisionSuccess = signal(false); readonly evidenceHash = signal(''); // T004: Filter by lane @@ -644,6 +689,10 @@ export class FindingsDetailPageComponent implements OnInit, OnDestroy { // T029: Record decision this.ttfsService.recordDecision(finding.id, decision.status); + + // Workflow chain: show success banner with policy gate link + this.decisionSuccess.set(true); + this.isDrawerOpen.set(false); } }