Sprint 2+3+5: Registry search, workflow chain, unified security data

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) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-16 16:08:22 +02:00
parent 189171c594
commit efa33efdbc
11 changed files with 617 additions and 14 deletions

View File

@@ -25,6 +25,24 @@ import type {
export const RELEASE_MANAGEMENT_API = new InjectionToken<ReleaseManagementApi>('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<T> {
items: T[];
total: number;
@@ -378,22 +396,50 @@ export class ReleaseManagementHttpClient implements ReleaseManagementApi {
return of([]);
}
return this.http.get<RegistryImage[]>('/api/registry/images/search', { params: { q: query } }).pipe(
catchError(() => of([])),
return this.http.get<RegistrySearchResponse>('/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<RegistryImage> {
return this.http.get<RegistryImage>('/api/registry/images/digests', { params: { repository } }).pipe(
catchError(() =>
of({
return this.http.get<RegistryDigestResponse>('/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: '',
}),
),
});
}),
);
}

View File

@@ -8,6 +8,7 @@ import {
HealthStatus,
Integration,
IntegrationHealthResponse,
IntegrationType,
TestConnectionResponse,
IntegrationStatus,
getHealthStatusColor,
@@ -183,6 +184,11 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
</div>
<p>{{ lastTestResult.message || 'Connection successful.' }}</p>
<small>Tested at {{ lastTestResult.testedAt | date:'medium' }} (duration {{ lastTestResult.duration }})</small>
@if (lastTestResult.success && isRegistryType()) {
<a class="workflow-cta" routerLink="/security/scan">
Registry connected! Scan your first image ->
</a>
}
</div>
}
@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();
}

View File

@@ -109,6 +109,12 @@ type DetailTab =
<div><strong>Requested:</strong> {{ formatDate(promotion()!.requestedAt) }}</div>
</div>
@if (!promotion()!.gatesPassed) {
<a routerLink="/triage/artifacts" class="blocked-finding-link">
Review blocking finding ->
</a>
}
@if (promotion()!.status === 'pending') {
<div class="decision-box" *stellaOperatorOnly>
<label for="decisionComment">Decision comment</label>
@@ -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;
}
`,
],
})

View File

@@ -197,6 +197,9 @@ interface PromotionRow {
<span class="signal signal--{{ promotion.riskSignal.level }}">
{{ promotion.riskSignal.text }}
</span>
@if (promotion.riskSignal.level === 'blocked') {
<a class="blocked-finding-link" routerLink="/triage/artifacts">Review blocking finding -></a>
}
</td>
<td>
<span class="signal signal--{{ promotion.dataHealth.level }}">
@@ -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;

View File

@@ -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<T> {
<h2>Risk Posture</h2>
<p class="value">{{ riskPostureLabel() }}</p>
<small>{{ findingsCount() }} findings in scope</small>
<a routerLink="/triage/artifacts" queryParamsHandling="merge" class="kpi-link">View in Vulnerabilities workspace</a>
</article>
<article>
<h2>Blocking Items</h2>
<p class="value">{{ blockerCount() }}</p>
<small>Policy action = block</small>
<small>{{ triageCriticalCount() }} critical, {{ triageHighCount() }} high severity</small>
</article>
<article>
<h2>VEX Coverage</h2>
@@ -271,6 +274,8 @@ interface PlatformListResponse<T> {
}
.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<T> {
export class SecurityRiskOverviewComponent {
private readonly http = inject(HttpClient);
private readonly advisorySourcesApi = inject(AdvisorySourcesApi);
private readonly vulnApi = inject<VulnerabilityApi>(VULNERABILITY_API);
readonly context = inject(PlatformContextStore);
readonly loading = signal(false);
@@ -306,10 +312,27 @@ export class SecurityRiskOverviewComponent {
readonly sbomRows = signal<SecuritySbomExplorerResponse['table']>([]);
readonly feedHealth = signal<IntegrationHealthRow[]>([]);
readonly vexSourceHealth = signal<IntegrationHealthRow[]>([]);
readonly triageStats = signal<VulnerabilityStats | null>(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) => {

View File

@@ -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)"
/>
<!-- Workflow chain: decision recorded success banner -->
@if (decisionSuccess()) {
<div class="decision-success-banner" role="status">
<span>Decision recorded.</span>
<a routerLink="/ops/policy/simulation" class="policy-gate-link">
Check policy gates for this release ->
</a>
<button type="button" class="dismiss-btn" (click)="decisionSuccess.set(false)" aria-label="Dismiss">&times;</button>
</div>
}
</div>
`,
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<FindingDetail | null>(null);
readonly selectedCallPath = signal<CallPath | null>(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);
}
}