up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-07 23:07:09 +02:00
parent 4b124fb056
commit 68bc53a07b
42 changed files with 3460 additions and 1132 deletions

View File

@@ -24,3 +24,4 @@
| UI-POLICY-23-005 | DONE (2025-12-05) | Simulator updated with SBOM/advisory pickers and explain trace view; uses PolicyApiService simulate. |
| UI-POLICY-23-006 | DONE (2025-12-06) | Explain view route `/policy-studio/packs/:packId/explain/:runId` with trace + JSON/PDF export (uses offline-safe jsPDF shim). |
| UI-POLICY-23-001 | DONE (2025-12-05) | Workspace route `/policy-studio/packs` with pack list + quick actions; cached pack store with offline fallback. |
| CVSS-UI-190-011 | DONE (2025-12-07) | Added CVSS receipt viewer route (/cvss/receipts/:receiptId) with score badge, tabbed sections, stub client, and unit spec in src/Web/StellaOps.Web. |

View File

@@ -1,5 +1,5 @@
import { Routes } from '@angular/router';
import { Routes } from '@angular/router';
import {
requireOrchViewerGuard,
requireOrchOperatorGuard,
@@ -9,61 +9,61 @@ import {
requirePolicyApproverGuard,
requirePolicyViewerGuard,
} from './core/auth';
export const routes: Routes = [
{
path: 'dashboard/sources',
loadComponent: () =>
import('./features/dashboard/sources-dashboard.component').then(
(m) => m.SourcesDashboardComponent
),
},
{
path: 'console/profile',
loadComponent: () =>
import('./features/console/console-profile.component').then(
(m) => m.ConsoleProfileComponent
),
},
{
path: 'console/status',
loadComponent: () =>
import('./features/console/console-status.component').then(
(m) => m.ConsoleStatusComponent
),
},
// Orchestrator routes - gated by orch:read scope (UI-ORCH-32-001)
{
path: 'orchestrator',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-dashboard.component').then(
(m) => m.OrchestratorDashboardComponent
),
},
{
path: 'orchestrator/jobs',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-jobs.component').then(
(m) => m.OrchestratorJobsComponent
),
},
{
path: 'orchestrator/jobs/:jobId',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-job-detail.component').then(
(m) => m.OrchestratorJobDetailComponent
),
},
{
path: 'orchestrator/quotas',
canMatch: [requireOrchOperatorGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-quotas.component').then(
(m) => m.OrchestratorQuotasComponent
),
export const routes: Routes = [
{
path: 'dashboard/sources',
loadComponent: () =>
import('./features/dashboard/sources-dashboard.component').then(
(m) => m.SourcesDashboardComponent
),
},
{
path: 'console/profile',
loadComponent: () =>
import('./features/console/console-profile.component').then(
(m) => m.ConsoleProfileComponent
),
},
{
path: 'console/status',
loadComponent: () =>
import('./features/console/console-status.component').then(
(m) => m.ConsoleStatusComponent
),
},
// Orchestrator routes - gated by orch:read scope (UI-ORCH-32-001)
{
path: 'orchestrator',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-dashboard.component').then(
(m) => m.OrchestratorDashboardComponent
),
},
{
path: 'orchestrator/jobs',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-jobs.component').then(
(m) => m.OrchestratorJobsComponent
),
},
{
path: 'orchestrator/jobs/:jobId',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-job-detail.component').then(
(m) => m.OrchestratorJobDetailComponent
),
},
{
path: 'orchestrator/quotas',
canMatch: [requireOrchOperatorGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-quotas.component').then(
(m) => m.OrchestratorQuotasComponent
),
},
{
path: 'policy-studio/packs',
@@ -132,61 +132,67 @@ export const routes: Routes = [
{
path: 'concelier/trivy-db-settings',
loadComponent: () =>
import('./features/trivy-db-settings/trivy-db-settings-page.component').then(
(m) => m.TrivyDbSettingsPageComponent
),
},
{
path: 'scans/:scanId',
loadComponent: () =>
import('./features/scans/scan-detail-page.component').then(
(m) => m.ScanDetailPageComponent
),
},
{
path: 'welcome',
loadComponent: () =>
import('./features/welcome/welcome-page.component').then(
(m) => m.WelcomePageComponent
),
},
{
path: 'risk',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/risk/risk-dashboard.component').then(
(m) => m.RiskDashboardComponent
),
},
{
path: 'vulnerabilities/:vulnId',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/vulnerabilities/vulnerability-detail.component').then(
(m) => m.VulnerabilityDetailComponent
),
},
{
path: 'notify',
loadComponent: () =>
import('./features/notify/notify-panel.component').then(
(m) => m.NotifyPanelComponent
),
},
{
path: 'auth/callback',
loadComponent: () =>
import('./features/auth/auth-callback.component').then(
(m) => m.AuthCallbackComponent
),
},
{
path: '',
pathMatch: 'full',
redirectTo: 'console/profile',
},
{
path: '**',
redirectTo: 'console/profile',
},
];
import('./features/trivy-db-settings/trivy-db-settings-page.component').then(
(m) => m.TrivyDbSettingsPageComponent
),
},
{
path: 'scans/:scanId',
loadComponent: () =>
import('./features/scans/scan-detail-page.component').then(
(m) => m.ScanDetailPageComponent
),
},
{
path: 'welcome',
loadComponent: () =>
import('./features/welcome/welcome-page.component').then(
(m) => m.WelcomePageComponent
),
},
{
path: 'risk',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/risk/risk-dashboard.component').then(
(m) => m.RiskDashboardComponent
),
},
{
path: 'vulnerabilities/:vulnId',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/vulnerabilities/vulnerability-detail.component').then(
(m) => m.VulnerabilityDetailComponent
),
},
{
path: 'cvss/receipts/:receiptId',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/cvss/cvss-receipt.component').then((m) => m.CvssReceiptComponent),
},
{
path: 'notify',
loadComponent: () =>
import('./features/notify/notify-panel.component').then(
(m) => m.NotifyPanelComponent
),
},
{
path: 'auth/callback',
loadComponent: () =>
import('./features/auth/auth-callback.component').then(
(m) => m.AuthCallbackComponent
),
},
{
path: '',
pathMatch: 'full',
redirectTo: 'console/profile',
},
{
path: '**',
redirectTo: 'console/profile',
},
];

View File

@@ -0,0 +1,58 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { CvssReceipt } from './cvss.models';
/**
* Placeholder CVSS client until Policy Gateway endpoint is wired.
* Emits deterministic sample data for UI development and tests.
*/
@Injectable({
providedIn: 'root',
})
export class CvssClient {
getReceipt(receiptId: string): Observable<CvssReceipt> {
const sample: CvssReceipt = {
receiptId,
vulnerabilityId: 'CVE-2025-1234',
createdAt: '2025-12-05T12:00:00Z',
createdBy: 'analyst@example.org',
score: {
base: 7.6,
threat: 7.6,
environmental: 8.1,
overall: 8.1,
vector:
'CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H',
severity: 'High',
},
policy: {
policyId: 'policy-bundle-main',
policyHash: 'sha256:deadbeefcafec0ffee1234',
version: '1.0.0',
},
evidence: [
{
id: 'ev-001',
description: 'Upstream advisory references vulnerable TLS parser',
source: 'NVD',
},
{
id: 'ev-002',
description: 'Vendor bulletin confirms threat active in region',
source: 'Vendor',
},
],
history: [
{
version: 1,
changedAt: '2025-12-05T12:00:00Z',
changedBy: 'analyst@example.org',
reason: 'Initial scoring',
},
],
};
return of(sample);
}
}

View File

@@ -0,0 +1,38 @@
export interface CvssScoreBreakdown {
readonly base: number;
readonly threat: number;
readonly environmental: number;
readonly overall: number;
readonly vector: string;
readonly severity: string;
}
export interface CvssPolicySummary {
readonly policyId: string;
readonly policyHash: string;
readonly version?: string;
}
export interface CvssEvidenceItem {
readonly id: string;
readonly description: string;
readonly source: string;
}
export interface CvssHistoryEntry {
readonly version: number;
readonly changedAt: string;
readonly changedBy: string;
readonly reason?: string;
}
export interface CvssReceipt {
readonly receiptId: string;
readonly vulnerabilityId: string;
readonly createdAt: string;
readonly createdBy: string;
readonly score: CvssScoreBreakdown;
readonly policy: CvssPolicySummary;
readonly evidence: readonly CvssEvidenceItem[];
readonly history: readonly CvssHistoryEntry[];
}

View File

@@ -0,0 +1,95 @@
<section class="cvss-receipt" *ngIf="receipt$ | async as receipt">
<header class="cvss-receipt__header">
<div>
<p class="cvss-receipt__label">CVSS Receipt</p>
<h1>
{{ receipt.vulnerabilityId }}
<span class="cvss-receipt__id">#{{ receipt.receiptId }}</span>
</h1>
<p class="cvss-receipt__meta">
Created {{ receipt.createdAt }} by {{ receipt.createdBy }} · Policy
{{ receipt.policy.policyId }} ({{ receipt.policy.version ?? 'v1' }})
</p>
</div>
<div class="cvss-receipt__score">
<div class="cvss-score-badge" [class.cvss-score-badge--critical]="receipt.score.overall >= 9">
{{ receipt.score.overall | number : '1.1-1' }}
<span class="cvss-score-badge__label">{{ receipt.score.severity }}</span>
</div>
<p class="cvss-receipt__vector">{{ receipt.score.vector }}</p>
</div>
</header>
<nav class="cvss-tabs" aria-label="CVSS receipt sections">
<button type="button" [class.active]="activeTab === 'base'" (click)="activeTab = 'base'">
Base
</button>
<button type="button" [class.active]="activeTab === 'threat'" (click)="activeTab = 'threat'">
Threat
</button>
<button
type="button"
[class.active]="activeTab === 'environmental'"
(click)="activeTab = 'environmental'"
>
Environmental
</button>
<button type="button" [class.active]="activeTab === 'evidence'" (click)="activeTab = 'evidence'">
Evidence
</button>
<button type="button" [class.active]="activeTab === 'policy'" (click)="activeTab = 'policy'">
Policy
</button>
<button type="button" [class.active]="activeTab === 'history'" (click)="activeTab = 'history'">
History
</button>
</nav>
<section class="cvss-panel" *ngIf="activeTab === 'base'">
<h2>Base Metrics</h2>
<p>Base score: {{ receipt.score.base | number : '1.1-1' }}</p>
<p>Vector: {{ receipt.score.vector }}</p>
</section>
<section class="cvss-panel" *ngIf="activeTab === 'threat'">
<h2>Threat Metrics</h2>
<p>Threat-adjusted score: {{ receipt.score.threat | number : '1.1-1' }}</p>
<p>Vector: {{ receipt.score.vector }}</p>
</section>
<section class="cvss-panel" *ngIf="activeTab === 'environmental'">
<h2>Environmental Metrics</h2>
<p>Environmental score: {{ receipt.score.environmental | number : '1.1-1' }}</p>
<p>Vector: {{ receipt.score.vector }}</p>
</section>
<section class="cvss-panel" *ngIf="activeTab === 'evidence'">
<h2>Evidence</h2>
<ul>
<li *ngFor="let item of receipt.evidence; trackBy: trackById">
<p class="evidence__id">{{ item.id }}</p>
<p>{{ item.description }}</p>
<p class="evidence__source">Source: {{ item.source }}</p>
</li>
</ul>
</section>
<section class="cvss-panel" *ngIf="activeTab === 'policy'">
<h2>Policy</h2>
<p>Policy ID: {{ receipt.policy.policyId }}</p>
<p>Version: {{ receipt.policy.version ?? 'v1' }}</p>
<p>Hash: {{ receipt.policy.policyHash }}</p>
</section>
<section class="cvss-panel" *ngIf="activeTab === 'history'">
<h2>History</h2>
<ul>
<li *ngFor="let entry of receipt.history">
<p>
v{{ entry.version }} · {{ entry.changedAt }} by {{ entry.changedBy }}
<span *ngIf="entry.reason">— {{ entry.reason }}</span>
</p>
</li>
</ul>
</section>
</section>

View File

@@ -0,0 +1,95 @@
.cvss-receipt {
display: grid;
gap: 1rem;
}
.cvss-receipt__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.cvss-receipt__label {
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0;
}
.cvss-receipt__id {
font-size: 0.9rem;
color: #666;
}
.cvss-receipt__meta {
color: #555;
margin: 0.25rem 0 0;
}
.cvss-receipt__score {
text-align: right;
}
.cvss-score-badge {
display: inline-flex;
align-items: baseline;
gap: 0.35rem;
padding: 0.35rem 0.6rem;
border-radius: 0.4rem;
background: #0a5ac2;
color: #fff;
font-weight: 700;
font-size: 1.5rem;
}
.cvss-score-badge__label {
font-size: 0.85rem;
font-weight: 600;
}
.cvss-score-badge--critical {
background: #b3261e;
}
.cvss-receipt__vector {
margin: 0.35rem 0 0;
font-family: monospace;
color: #333;
}
.cvss-tabs {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid #ddd;
}
.cvss-tabs button {
border: none;
background: transparent;
padding: 0.5rem 0.75rem;
font-weight: 600;
cursor: pointer;
}
.cvss-tabs button.active {
border-bottom: 2px solid #0a5ac2;
color: #0a5ac2;
}
.cvss-panel {
background: #f8f9fb;
border: 1px solid #e1e4ea;
border-radius: 0.5rem;
padding: 1rem;
}
.evidence__id {
font-weight: 700;
margin: 0;
}
.evidence__source {
color: #666;
font-size: 0.9rem;
}

View File

@@ -0,0 +1,69 @@
import { ActivatedRoute } from '@angular/router';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { CvssClient } from '../../core/api/cvss.client';
import { CvssReceiptComponent } from './cvss-receipt.component';
import { CvssReceipt } from '../../core/api/cvss.models';
describe(CvssReceiptComponent.name, () => {
let fixture: ComponentFixture<CvssReceiptComponent>;
const sample: CvssReceipt = {
receiptId: 'rcpt-123',
vulnerabilityId: 'CVE-2025-1234',
createdAt: '2025-12-05T12:00:00Z',
createdBy: 'analyst@example.org',
score: {
base: 7.6,
threat: 7.6,
environmental: 8.1,
overall: 8.1,
vector: 'CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H',
severity: 'High',
},
policy: {
policyId: 'policy-bundle-main',
policyHash: 'sha256:deadbeef',
version: '1.0.0',
},
evidence: [],
history: [],
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CvssReceiptComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
paramMap: of({
get: (key: string) => (key === 'receiptId' ? sample.receiptId : null),
}),
},
},
{
provide: CvssClient,
useValue: {
getReceipt: () => of(sample),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(CvssReceiptComponent);
fixture.detectChanges();
});
it('renders receipt id and vulnerability', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain(sample.vulnerabilityId);
expect(compiled.textContent).toContain(sample.receiptId);
});
it('renders overall score', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.cvss-score-badge')?.textContent).toContain('8.1');
});
});

View File

@@ -0,0 +1,35 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { Observable } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { CvssClient } from '../../core/api/cvss.client';
import { CvssReceipt } from '../../core/api/cvss.models';
@Component({
standalone: true,
selector: 'app-cvss-receipt',
imports: [CommonModule, RouterModule],
templateUrl: './cvss-receipt.component.html',
styleUrls: ['./cvss-receipt.component.scss'],
})
export class CvssReceiptComponent implements OnInit {
receipt$!: Observable<CvssReceipt>;
activeTab: 'base' | 'threat' | 'environmental' | 'evidence' | 'policy' | 'history' =
'base';
constructor(private readonly route: ActivatedRoute, private readonly client: CvssClient) {}
ngOnInit(): void {
this.receipt$ = this.route.paramMap.pipe(
map((params) => params.get('receiptId') ?? ''),
switchMap((id) => this.client.getReceipt(id))
);
}
trackById(_: number, item: { id?: string }): string | undefined {
return item.id;
}
}