Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 21:45:32 +02:00
510 changed files with 138401 additions and 51276 deletions

View File

@@ -1,34 +1,34 @@
# StellaOps Web Frontend
## Mission
Design and build the StellaOps web user experience that surfaces backend capabilities (Authority, Concelier, Exporters) through an offline-friendly Angular application.
## Team Composition
- **UX Specialist** defines user journeys, interaction patterns, accessibility guidelines, and visual design language.
- **Angular Engineers** implement the SPA, integrate with backend APIs, and ensure deterministic builds suitable for air-gapped deployments.
## Operating Principles
- Favor modular Angular architecture (feature modules, shared UI kit) with strong typing via latest TypeScript/Angular releases.
- Align UI flows with backend contracts; coordinate with Authority and Concelier teams for API changes.
- Keep assets and build outputs deterministic and cacheable for Offline Kit packaging.
- Track work using the local `TASKS.md` board; keep statuses (TODO/DOING/REVIEW/BLOCKED/DONE) up to date.
## Key Paths
- `src/Web/StellaOps.Web` — Angular workspace (to be scaffolded).
- `docs/` — UX specs and mockups (to be added).
- `ops/` — Web deployment manifests for air-gapped environments (future).
## Coordination
- Sync with DevEx for project scaffolding and build pipelines.
- Partner with Docs Guild to translate UX decisions into operator guides.
- Collaborate with Security Guild to validate authentication flows and session handling.
## Required Reading
- `docs/modules/platform/architecture-overview.md`
## Working Agreement
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.
# StellaOps Web Frontend
## Mission
Design and build the StellaOps web user experience that surfaces backend capabilities (Authority, Concelier, Exporters) through an offline-friendly Angular application.
## Team Composition
- **UX Specialist** defines user journeys, interaction patterns, accessibility guidelines, and visual design language.
- **Angular Engineers** implement the SPA, integrate with backend APIs, and ensure deterministic builds suitable for air-gapped deployments.
## Operating Principles
- Favor modular Angular architecture (feature modules, shared UI kit) with strong typing via latest TypeScript/Angular releases.
- Align UI flows with backend contracts; coordinate with Authority and Concelier teams for API changes.
- Keep assets and build outputs deterministic and cacheable for Offline Kit packaging.
- Track work using the local `TASKS.md` board; keep statuses (TODO/DOING/REVIEW/BLOCKED/DONE) up to date.
## Key Paths
- `src/Web/StellaOps.Web` — Angular workspace (to be scaffolded).
- `docs/` — UX specs and mockups (to be added).
- `ops/` — Web deployment manifests for air-gapped environments (future).
## Coordination
- Sync with DevEx for project scaffolding and build pipelines.
- Partner with Docs Guild to translate UX decisions into operator guides.
- Collaborate with Security Guild to validate authentication flows and session handling.
## Required Reading
- `docs/modules/platform/architecture-overview.md`
## Working Agreement
- 1. Update task status to `DOING`/`DONE` in both correspoding sprint file `/docs/implplan/SPRINT_*.md` and the local `TASKS.md` when you start or finish work.
- 2. Review this charter and the Required Reading documents before coding; confirm prerequisites are met.
- 3. Keep changes deterministic (stable ordering, timestamps, hashes) and align with offline/air-gap expectations.
- 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change.
- 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context.

View File

@@ -1,42 +1,42 @@
# Deterministic Install & Headless Chromium
Offline runners must avoid ad-hoc network calls while staying reproducible. The Angular workspace now ships a locked dependency graph and helpers for provisioning a Chromium binary without embedding it directly in `npm install`.
## Prerequisites
- Node.js **20.11.0** or newer (matches the `engines` constraint).
- npm **10.2.0** or newer.
- Local npm cache location available to both the connected “seed” machine and the offline runner (for example, `/opt/stellaops/npm-cache`).
## One-Time Cache Priming (Connected Host)
```bash
export NPM_CONFIG_CACHE=/opt/stellaops/npm-cache
npm run ci:install
```
`ci:install` executes `npm ci --prefer-offline --no-audit --no-fund` so every package and integrity hash lands in the cache without touching arbitrary registries afterwards.
If you plan to bundle a Chromium binary, download it while still connected:
```bash
npx @puppeteer/browsers install chrome@stable --path .cache/chromium
```
Archive both the npm cache and `.cache/chromium/` directory; include them in your Offline Kit transfer.
## Offline Runner Execution
1. Extract the pre-warmed npm cache to the offline host and export `NPM_CONFIG_CACHE` to that directory.
2. Optionally copy the `.cache/chromium/` folder next to `package.json` (the Karma launcher auto-detects platform-specific paths inside this directory).
3. Run `npm run ci:install` to restore dependencies without network access.
4. Validate Chromium availability with `npm run verify:chromium`. This command exits non-zero and prints the search paths if no binary is discovered.
5. Execute tests via `npm run test:ci` (internally calls `verify:chromium` before running `ng test --watch=false`).
## Chromium Options
- **System package** Install `chromium`, `chromium-browser`, or `google-chrome-stable` via your distribution repository or the Offline Kit. The launcher checks `/usr/bin/chromium-browser`, `/usr/bin/chromium`, and `/usr/bin/google-chrome(-stable)` automatically.
- **Environment override** Set `CHROME_BIN` or `STELLAOPS_CHROMIUM_BIN` to the executable path if you host Chromium in a custom location.
- **Offline cache drop** Place the extracted archive under `.cache/chromium/` (`chrome-linux64/chrome`, `chrome-win64/chrome.exe`, or `chrome-mac/Chromium.app/...`). The Karma harness resolves these automatically.
Consult `src/Web/StellaOps.Web/README.md` for a shortened operator flow overview.
# Deterministic Install & Headless Chromium
Offline runners must avoid ad-hoc network calls while staying reproducible. The Angular workspace now ships a locked dependency graph and helpers for provisioning a Chromium binary without embedding it directly in `npm install`.
## Prerequisites
- Node.js **20.11.0** or newer (matches the `engines` constraint).
- npm **10.2.0** or newer.
- Local npm cache location available to both the connected “seed” machine and the offline runner (for example, `/opt/stellaops/npm-cache`).
## One-Time Cache Priming (Connected Host)
```bash
export NPM_CONFIG_CACHE=/opt/stellaops/npm-cache
npm run ci:install
```
`ci:install` executes `npm ci --prefer-offline --no-audit --no-fund` so every package and integrity hash lands in the cache without touching arbitrary registries afterwards.
If you plan to bundle a Chromium binary, download it while still connected:
```bash
npx @puppeteer/browsers install chrome@stable --path .cache/chromium
```
Archive both the npm cache and `.cache/chromium/` directory; include them in your Offline Kit transfer.
## Offline Runner Execution
1. Extract the pre-warmed npm cache to the offline host and export `NPM_CONFIG_CACHE` to that directory.
2. Optionally copy the `.cache/chromium/` folder next to `package.json` (the Karma launcher auto-detects platform-specific paths inside this directory).
3. Run `npm run ci:install` to restore dependencies without network access.
4. Validate Chromium availability with `npm run verify:chromium`. This command exits non-zero and prints the search paths if no binary is discovered.
5. Execute tests via `npm run test:ci` (internally calls `verify:chromium` before running `ng test --watch=false`).
## Chromium Options
- **System package** Install `chromium`, `chromium-browser`, or `google-chrome-stable` via your distribution repository or the Offline Kit. The launcher checks `/usr/bin/chromium-browser`, `/usr/bin/chromium`, and `/usr/bin/google-chrome(-stable)` automatically.
- **Environment override** Set `CHROME_BIN` or `STELLAOPS_CHROMIUM_BIN` to the executable path if you host Chromium in a custom location.
- **Offline cache drop** Place the extracted archive under `.cache/chromium/` (`chrome-linux64/chrome`, `chrome-win64/chrome.exe`, or `chrome-mac/Chromium.app/...`). The Karma harness resolves these automatically.
Consult `src/Web/StellaOps.Web/README.md` for a shortened operator flow overview.

View File

@@ -1,27 +1,27 @@
# Helm Readiness & Probes
This app serves static health endpoints for platform probes:
- `/assets/health/liveness.json`
- `/assets/health/readiness.json`
- `/assets/health/version.json`
These are packaged with the Angular build. Configure Helm/Nginx to route the probes directly to the web pod.
## Suggested Helm values
```yaml
livenessProbe:
httpGet:
path: /assets/health/liveness.json
port: http
readinessProbe:
httpGet:
path: /assets/health/readiness.json
port: http
```
## Updating
- Edit the JSON under `src/assets/health/*.json` for environment-specific readiness details.
- Run `npm run build` (or CI pipeline) to bake the files into the image.
# Helm Readiness & Probes
This app serves static health endpoints for platform probes:
- `/assets/health/liveness.json`
- `/assets/health/readiness.json`
- `/assets/health/version.json`
These are packaged with the Angular build. Configure Helm/Nginx to route the probes directly to the web pod.
## Suggested Helm values
```yaml
livenessProbe:
httpGet:
path: /assets/health/liveness.json
port: http
readinessProbe:
httpGet:
path: /assets/health/readiness.json
port: http
```
## Updating
- Edit the JSON under `src/assets/health/*.json` for environment-specific readiness details.
- Run `npm run build` (or CI pipeline) to bake the files into the image.

View File

@@ -1,37 +1,37 @@
# WEB1.TRIVY-SETTINGS Backend Contract & UI Wiring Notes
## 1. Known backend surfaces
- `POST /jobs/export:trivy-db`
Payload is wrapped as `{ "trigger": "<source>", "parameters": { ... } }` and accepts the overrides shown in `TrivyDbExportJob` (`publishFull`, `publishDelta`, `includeFull`, `includeDelta`).
Evidence: `src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs:263`, `src/Cli/StellaOps.Cli/Services/Models/Transport/JobTriggerRequest.cs:5`, `src/Concelier/StellaOps.Concelier.PluginBinaries/StellaOps.Concelier.Exporter.TrivyDb/TrivyDbExportJob.cs:27`.
- Export configuration defaults sit under `TrivyDbExportOptions.Oras` and `.OfflineBundle`. Both booleans default to `true`, so overriding to `false` must be explicit.
Evidence: `src/Concelier/StellaOps.Concelier.PluginBinaries/StellaOps.Concelier.Exporter.TrivyDb/TrivyDbExportOptions.cs:8`.
## 2. Clarifications needed from Concelier backend
| Topic | Questions to resolve | Suggested owner |
| --- | --- | --- |
| Settings endpoint surface | `Program.cs` only exposes `/jobs/*` and health endpoints—there is currently **no** `/exporters/trivy-db/settings` route. Confirm the intended path (`/api/v1/concelier/exporters/trivy-db/settings`?), verbs (`GET`/`PUT` or `PATCH`), and DTO schema (flat booleans vs nested `oras`/`offlineBundle`). | Concelier WebService |
| Auth scopes | Verify required roles (likely `concelier.export` or `concelier.admin`) and whether UI needs to request additional scopes beyond existing dashboard access. | Authority & Concelier teams |
| Concurrency control | Determine if settings payload includes an ETag or timestamp we must echo (`If-Match`) to avoid stomping concurrent edits. | Concelier WebService |
| Validation & defaults | Clarify server-side validation rules (e.g., must `publishDelta` be `false` when `publishFull` is `false`?) and shape of Problem+JSON responses. | Concelier WebService |
| Manual run trigger | Confirm whether settings update should immediately kick an export or if UI should call `POST /jobs/export:trivy-db` separately (current CLI behaviour suggests a separate call). | Concelier WebService |
## 3. Proposed Angular implementation (pending contract lock)
- **Feature module**: `app/concelier/trivy-db-settings/` with a standalone routed page (`TrivyDbSettingsPage`) and a reusable form component (`TrivyDbSettingsForm`).
- **State & transport**:
- Client wrapper under `core/api/concelier-exporter.client.ts` exposing `getTrivyDbSettings`, `updateTrivyDbSettings`, and `runTrivyDbExport`.
- Store built with `@ngrx/signals` keeping `settings`, `isDirty`, `lastFetchedAt`, and error state; optimistic updates gated on ETag confirmation once the backend specifies the shape.
- Shared DTOs generated from the confirmed schema to keep Concelier/CLI alignment.
- **UX flow**:
- Load settings on navigation; show inline info about current publish/bundle defaults.
- “Run export now” button opens confirmation modal summarising overrides, then calls `runTrivyDbExport` (separate API call) while reusing local state.
- Surface Problem+JSON errors via existing toast/notification pattern and capture correlation IDs for ops visibility.
- **Offline posture**: cache latest successful settings payload in IndexedDB (read-only when offline) and disable the run button when token/scopes are missing.
## 4. Next steps
1. Share section 2 with Concelier WebService owners to confirm the REST contract (blocking before scaffolding DTOs).
2. Once confirmed, scaffold the Angular workspace and feature shell, keeping deterministic build outputs per `src/Web/StellaOps.Web/AGENTS.md`.
# WEB1.TRIVY-SETTINGS Backend Contract & UI Wiring Notes
## 1. Known backend surfaces
- `POST /jobs/export:trivy-db`
Payload is wrapped as `{ "trigger": "<source>", "parameters": { ... } }` and accepts the overrides shown in `TrivyDbExportJob` (`publishFull`, `publishDelta`, `includeFull`, `includeDelta`).
Evidence: `src/Cli/StellaOps.Cli/Commands/CommandHandlers.cs:263`, `src/Cli/StellaOps.Cli/Services/Models/Transport/JobTriggerRequest.cs:5`, `src/Concelier/StellaOps.Concelier.PluginBinaries/StellaOps.Concelier.Exporter.TrivyDb/TrivyDbExportJob.cs:27`.
- Export configuration defaults sit under `TrivyDbExportOptions.Oras` and `.OfflineBundle`. Both booleans default to `true`, so overriding to `false` must be explicit.
Evidence: `src/Concelier/StellaOps.Concelier.PluginBinaries/StellaOps.Concelier.Exporter.TrivyDb/TrivyDbExportOptions.cs:8`.
## 2. Clarifications needed from Concelier backend
| Topic | Questions to resolve | Suggested owner |
| --- | --- | --- |
| Settings endpoint surface | `Program.cs` only exposes `/jobs/*` and health endpoints—there is currently **no** `/exporters/trivy-db/settings` route. Confirm the intended path (`/api/v1/concelier/exporters/trivy-db/settings`?), verbs (`GET`/`PUT` or `PATCH`), and DTO schema (flat booleans vs nested `oras`/`offlineBundle`). | Concelier WebService |
| Auth scopes | Verify required roles (likely `concelier.export` or `concelier.admin`) and whether UI needs to request additional scopes beyond existing dashboard access. | Authority & Concelier teams |
| Concurrency control | Determine if settings payload includes an ETag or timestamp we must echo (`If-Match`) to avoid stomping concurrent edits. | Concelier WebService |
| Validation & defaults | Clarify server-side validation rules (e.g., must `publishDelta` be `false` when `publishFull` is `false`?) and shape of Problem+JSON responses. | Concelier WebService |
| Manual run trigger | Confirm whether settings update should immediately kick an export or if UI should call `POST /jobs/export:trivy-db` separately (current CLI behaviour suggests a separate call). | Concelier WebService |
## 3. Proposed Angular implementation (pending contract lock)
- **Feature module**: `app/concelier/trivy-db-settings/` with a standalone routed page (`TrivyDbSettingsPage`) and a reusable form component (`TrivyDbSettingsForm`).
- **State & transport**:
- Client wrapper under `core/api/concelier-exporter.client.ts` exposing `getTrivyDbSettings`, `updateTrivyDbSettings`, and `runTrivyDbExport`.
- Store built with `@ngrx/signals` keeping `settings`, `isDirty`, `lastFetchedAt`, and error state; optimistic updates gated on ETag confirmation once the backend specifies the shape.
- Shared DTOs generated from the confirmed schema to keep Concelier/CLI alignment.
- **UX flow**:
- Load settings on navigation; show inline info about current publish/bundle defaults.
- “Run export now” button opens confirmation modal summarising overrides, then calls `runTrivyDbExport` (separate API call) while reusing local state.
- Surface Problem+JSON errors via existing toast/notification pattern and capture correlation IDs for ops visibility.
- **Offline posture**: cache latest successful settings payload in IndexedDB (read-only when offline) and disable the run button when token/scopes are missing.
## 4. Next steps
1. Share section 2 with Concelier WebService owners to confirm the REST contract (blocking before scaffolding DTOs).
2. Once confirmed, scaffold the Angular workspace and feature shell, keeping deterministic build outputs per `src/Web/StellaOps.Web/AGENTS.md`.

View File

@@ -1,6 +1,13 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'dashboard/sources',
loadComponent: () =>
import('./features/dashboard/sources-dashboard.component').then(
(m) => m.SourcesDashboardComponent
),
},
{
path: 'console/profile',
loadComponent: () =>
@@ -36,62 +43,6 @@ export const routes: Routes = [
(m) => m.NotifyPanelComponent
),
},
{
path: 'exceptions',
loadComponent: () =>
import('./features/exceptions/exception-center.component').then(
(m) => m.ExceptionCenterComponent
),
},
{
path: 'vulnerabilities',
loadComponent: () =>
import('./features/vulnerabilities/vulnerability-explorer.component').then(
(m) => m.VulnerabilityExplorerComponent
),
},
{
path: 'graph',
loadComponent: () =>
import('./features/graph/graph-explorer.component').then(
(m) => m.GraphExplorerComponent
),
},
{
path: 'evidence/:advisoryId',
loadComponent: () =>
import('./features/evidence/evidence-page.component').then(
(m) => m.EvidencePageComponent
),
},
{
path: 'sources',
loadComponent: () =>
import('./features/sources/aoc-dashboard.component').then(
(m) => m.AocDashboardComponent
),
},
{
path: 'sources/violations/:code',
loadComponent: () =>
import('./features/sources/violation-detail.component').then(
(m) => m.ViolationDetailComponent
),
},
{
path: 'releases',
loadComponent: () =>
import('./features/releases/release-flow.component').then(
(m) => m.ReleaseFlowComponent
),
},
{
path: 'releases/:releaseId',
loadComponent: () =>
import('./features/releases/release-flow.component').then(
(m) => m.ReleaseFlowComponent
),
},
{
path: 'auth/callback',
loadComponent: () =>

View File

@@ -1,364 +1,116 @@
import { Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay } from 'rxjs';
import {
AocDashboardSummary,
AocPassFailSummary,
AocViolationCode,
IngestThroughput,
AocSource,
AocCheckResult,
VerificationRequest,
ViolationDetail,
TimeSeriesPoint,
} from './aoc.models';
/**
* Injection token for AOC API client.
*/
export const AOC_API = new InjectionToken<AocApi>('AOC_API');
/**
* AOC API interface.
*/
export interface AocApi {
getDashboardSummary(): Observable<AocDashboardSummary>;
getViolationDetail(violationId: string): Observable<ViolationDetail>;
getViolationsByCode(code: string): Observable<readonly ViolationDetail[]>;
startVerification(): Observable<VerificationRequest>;
getVerificationStatus(requestId: string): Observable<VerificationRequest>;
}
// ============================================================================
// Mock Data Fixtures
// ============================================================================
function generateHistory(days: number, baseValue: number, variance: number): TimeSeriesPoint[] {
const points: TimeSeriesPoint[] = [];
const now = new Date();
for (let i = days - 1; i >= 0; i--) {
const date = new Date(now);
date.setDate(date.getDate() - i);
points.push({
timestamp: date.toISOString(),
value: baseValue + Math.floor(Math.random() * variance * 2) - variance,
});
}
return points;
}
const mockPassFailSummary: AocPassFailSummary = {
period: 'last_24h',
totalChecks: 1247,
passed: 1198,
failed: 32,
pending: 12,
skipped: 5,
passRate: 0.961,
trend: 'improving',
history: generateHistory(7, 96, 3),
};
const mockViolationCodes: AocViolationCode[] = [
{
code: 'AOC-001',
name: 'Missing Provenance',
severity: 'critical',
description: 'Document lacks required provenance attestation',
count: 12,
lastSeen: '2025-11-27T09:45:00Z',
documentationUrl: 'https://docs.stellaops.io/aoc/violations/AOC-001',
},
{
code: 'AOC-002',
name: 'Invalid Signature',
severity: 'critical',
description: 'Document signature verification failed',
count: 8,
lastSeen: '2025-11-27T08:30:00Z',
documentationUrl: 'https://docs.stellaops.io/aoc/violations/AOC-002',
},
{
code: 'AOC-010',
name: 'Schema Mismatch',
severity: 'high',
description: 'Document does not conform to expected schema version',
count: 5,
lastSeen: '2025-11-27T07:15:00Z',
documentationUrl: 'https://docs.stellaops.io/aoc/violations/AOC-010',
},
{
code: 'AOC-015',
name: 'Timestamp Drift',
severity: 'medium',
description: 'Document timestamp exceeds allowed drift threshold',
count: 4,
lastSeen: '2025-11-27T06:00:00Z',
},
{
code: 'AOC-020',
name: 'Metadata Incomplete',
severity: 'low',
description: 'Optional metadata fields are missing',
count: 3,
lastSeen: '2025-11-26T22:30:00Z',
},
];
const mockThroughput: IngestThroughput[] = [
{
tenantId: 'tenant-001',
tenantName: 'Acme Corp',
documentsIngested: 15420,
bytesIngested: 2_450_000_000,
documentsPerMinute: 10.7,
bytesPerMinute: 1_701_388,
period: 'last_24h',
},
{
tenantId: 'tenant-002',
tenantName: 'TechStart Inc',
documentsIngested: 8932,
bytesIngested: 1_120_000_000,
documentsPerMinute: 6.2,
bytesPerMinute: 777_777,
period: 'last_24h',
},
{
tenantId: 'tenant-003',
tenantName: 'DataFlow Ltd',
documentsIngested: 5678,
bytesIngested: 890_000_000,
documentsPerMinute: 3.9,
bytesPerMinute: 618_055,
period: 'last_24h',
},
{
tenantId: 'tenant-004',
tenantName: 'SecureOps',
documentsIngested: 3421,
bytesIngested: 456_000_000,
documentsPerMinute: 2.4,
bytesPerMinute: 316_666,
period: 'last_24h',
},
];
const mockSources: AocSource[] = [
{
sourceId: 'src-001',
name: 'Production Registry',
type: 'registry',
status: 'passed',
lastCheck: '2025-11-27T10:00:00Z',
checkCount: 523,
passRate: 0.98,
recentViolations: [],
},
{
sourceId: 'src-002',
name: 'GitHub Actions Pipeline',
type: 'pipeline',
status: 'failed',
lastCheck: '2025-11-27T09:45:00Z',
checkCount: 412,
passRate: 0.92,
recentViolations: [mockViolationCodes[0], mockViolationCodes[1]],
},
{
sourceId: 'src-003',
name: 'Staging Registry',
type: 'registry',
status: 'passed',
lastCheck: '2025-11-27T09:30:00Z',
checkCount: 201,
passRate: 0.995,
recentViolations: [],
},
{
sourceId: 'src-004',
name: 'Manual Upload',
type: 'manual',
status: 'pending',
lastCheck: '2025-11-27T08:00:00Z',
checkCount: 111,
passRate: 0.85,
recentViolations: [mockViolationCodes[2]],
},
];
const mockRecentChecks: AocCheckResult[] = [
{
checkId: 'chk-001',
documentId: 'doc-abc123',
documentType: 'sbom',
status: 'passed',
checkedAt: '2025-11-27T10:00:00Z',
violations: [],
sourceId: 'src-001',
tenantId: 'tenant-001',
},
{
checkId: 'chk-002',
documentId: 'doc-def456',
documentType: 'attestation',
status: 'failed',
checkedAt: '2025-11-27T09:55:00Z',
violations: [mockViolationCodes[0]],
sourceId: 'src-002',
tenantId: 'tenant-001',
},
{
checkId: 'chk-003',
documentId: 'doc-ghi789',
documentType: 'sbom',
status: 'passed',
checkedAt: '2025-11-27T09:50:00Z',
violations: [],
sourceId: 'src-001',
tenantId: 'tenant-002',
},
{
checkId: 'chk-004',
documentId: 'doc-jkl012',
documentType: 'provenance',
status: 'failed',
checkedAt: '2025-11-27T09:45:00Z',
violations: [mockViolationCodes[1]],
sourceId: 'src-002',
tenantId: 'tenant-001',
},
{
checkId: 'chk-005',
documentId: 'doc-mno345',
documentType: 'sbom',
status: 'pending',
checkedAt: '2025-11-27T09:40:00Z',
violations: [],
sourceId: 'src-004',
tenantId: 'tenant-003',
},
];
const mockDashboard: AocDashboardSummary = {
generatedAt: new Date().toISOString(),
passFail: mockPassFailSummary,
recentViolations: mockViolationCodes,
throughputByTenant: mockThroughput,
sources: mockSources,
recentChecks: mockRecentChecks,
};
const mockViolationDetails: ViolationDetail[] = [
{
violationId: 'viol-001',
code: 'AOC-001',
severity: 'critical',
documentId: 'doc-def456',
documentType: 'attestation',
offendingFields: [
{
path: '$.predicate.buildType',
expectedValue: 'https://slsa.dev/provenance/v1',
actualValue: undefined,
reason: 'Required field is missing',
},
{
path: '$.predicate.builder.id',
expectedValue: 'https://github.com/actions/runner',
actualValue: undefined,
reason: 'Builder ID not specified',
},
],
provenance: {
sourceType: 'pipeline',
sourceUri: 'github.com/acme/api-service',
ingestedAt: '2025-11-27T09:55:00Z',
ingestedBy: 'github-actions',
buildId: 'build-12345',
commitSha: 'a1b2c3d4e5f6',
pipelineUrl: 'https://github.com/acme/api-service/actions/runs/12345',
},
detectedAt: '2025-11-27T09:55:00Z',
suggestion: 'Add SLSA provenance attestation to your build pipeline. See https://slsa.dev/spec/v1.0/provenance',
},
{
violationId: 'viol-002',
code: 'AOC-002',
severity: 'critical',
documentId: 'doc-jkl012',
documentType: 'provenance',
offendingFields: [
{
path: '$.signatures[0]',
expectedValue: 'Valid DSSE signature',
actualValue: 'Invalid or expired signature',
reason: 'Signature verification failed: key not found in keyring',
},
],
provenance: {
sourceType: 'pipeline',
sourceUri: 'github.com/acme/worker-service',
ingestedAt: '2025-11-27T09:45:00Z',
ingestedBy: 'github-actions',
buildId: 'build-12346',
commitSha: 'b2c3d4e5f6a7',
pipelineUrl: 'https://github.com/acme/worker-service/actions/runs/12346',
},
detectedAt: '2025-11-27T09:45:00Z',
suggestion: 'Ensure the signing key is registered in your tenant keyring. Run: stella keys add --public-key <key-file>',
},
];
// ============================================================================
// Mock API Implementation
// ============================================================================
@Injectable({ providedIn: 'root' })
export class MockAocApi implements AocApi {
getDashboardSummary(): Observable<AocDashboardSummary> {
return of({
...mockDashboard,
generatedAt: new Date().toISOString(),
}).pipe(delay(300));
}
getViolationDetail(violationId: string): Observable<ViolationDetail> {
const detail = mockViolationDetails.find((v) => v.violationId === violationId);
if (!detail) {
throw new Error(`Violation not found: ${violationId}`);
}
return of(detail).pipe(delay(200));
}
getViolationsByCode(code: string): Observable<readonly ViolationDetail[]> {
const details = mockViolationDetails.filter((v) => v.code === code);
return of(details).pipe(delay(250));
}
startVerification(): Observable<VerificationRequest> {
return of({
requestId: `verify-${Date.now()}`,
status: 'queued',
documentsToVerify: 1247,
documentsVerified: 0,
passed: 0,
failed: 0,
cliCommand: 'stella aoc verify --since 24h --output json',
}).pipe(delay(400));
}
getVerificationStatus(requestId: string): Observable<VerificationRequest> {
// Simulate a completed verification
return of({
requestId,
status: 'completed',
startedAt: new Date(Date.now() - 120000).toISOString(),
completedAt: new Date().toISOString(),
documentsToVerify: 1247,
documentsVerified: 1247,
passed: 1198,
failed: 49,
cliCommand: 'stella aoc verify --since 24h --output json',
}).pipe(delay(300));
}
}
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Observable, of, delay } from 'rxjs';
import { AppConfigService } from '../config/app-config.service';
import {
AocMetrics,
AocVerificationRequest,
AocVerificationResult,
} from './aoc.models';
@Injectable({ providedIn: 'root' })
export class AocClient {
private readonly http = inject(HttpClient);
private readonly config = inject(AppConfigService);
/**
* Gets AOC metrics for the dashboard.
*/
getMetrics(tenantId: string, windowMinutes = 1440): Observable<AocMetrics> {
// TODO: Replace with real API call when available
// return this.http.get<AocMetrics>(
// this.config.apiBaseUrl + '/aoc/metrics',
// { params: { tenantId, windowMinutes: windowMinutes.toString() } }
// );
// Mock data for development
return of(this.getMockMetrics()).pipe(delay(300));
}
/**
* Triggers verification of documents within a time window.
*/
verify(request: AocVerificationRequest): Observable<AocVerificationResult> {
// TODO: Replace with real API call when available
// return this.http.post<AocVerificationResult>(
// this.config.apiBaseUrl + '/aoc/verify',
// request
// );
// Mock verification result
return of(this.getMockVerificationResult()).pipe(delay(500));
}
private getMockMetrics(): AocMetrics {
const now = new Date();
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
return {
passCount: 12847,
failCount: 23,
totalCount: 12870,
passRate: 99.82,
recentViolations: [
{
code: 'AOC-PROV-001',
description: 'Missing provenance attestation',
count: 12,
severity: 'high',
lastSeen: new Date(now.getTime() - 15 * 60 * 1000).toISOString(),
},
{
code: 'AOC-DIGEST-002',
description: 'Digest mismatch in manifest',
count: 7,
severity: 'critical',
lastSeen: new Date(now.getTime() - 45 * 60 * 1000).toISOString(),
},
{
code: 'AOC-SCHEMA-003',
description: 'Schema validation failed',
count: 4,
severity: 'medium',
lastSeen: new Date(now.getTime() - 2 * 60 * 60 * 1000).toISOString(),
},
],
ingestThroughput: {
docsPerMinute: 8.9,
avgLatencyMs: 145,
p95LatencyMs: 312,
queueDepth: 3,
errorRate: 0.18,
},
timeWindow: {
start: dayAgo.toISOString(),
end: now.toISOString(),
durationMinutes: 1440,
},
};
}
private getMockVerificationResult(): AocVerificationResult {
const verifyId = 'verify-' + Date.now().toString();
return {
verificationId: verifyId,
status: 'passed',
checkedCount: 1523,
passedCount: 1520,
failedCount: 3,
violations: [
{
documentId: 'doc-abc123',
violationCode: 'AOC-PROV-001',
field: 'attestation.provenance',
expected: 'present',
actual: 'missing',
provenance: {
sourceId: 'source-registry-1',
ingestedAt: new Date().toISOString(),
digest: 'sha256:abc123...',
},
},
],
completedAt: new Date().toISOString(),
};
}
}

View File

@@ -1,152 +1,97 @@
/**
* Attestation of Conformance (AOC) models for UI-AOC-19-001.
* Supports Sources dashboard tiles showing pass/fail, violation codes, and ingest throughput.
*/
// AOC verification status
export type AocVerificationStatus = 'passed' | 'failed' | 'pending' | 'skipped';
// Violation severity levels
export type ViolationSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info';
/**
* AOC violation code with metadata.
*/
export interface AocViolationCode {
readonly code: string;
readonly name: string;
readonly severity: ViolationSeverity;
readonly description: string;
readonly count: number;
readonly lastSeen: string;
readonly documentationUrl?: string;
}
/**
* Per-tenant ingest throughput metrics.
*/
export interface IngestThroughput {
readonly tenantId: string;
readonly tenantName: string;
readonly documentsIngested: number;
readonly bytesIngested: number;
readonly documentsPerMinute: number;
readonly bytesPerMinute: number;
readonly period: string; // e.g., "last_24h", "last_7d"
}
/**
* Time-series data point for charts.
*/
export interface TimeSeriesPoint {
readonly timestamp: string;
readonly value: number;
}
/**
* AOC pass/fail summary for a time period.
*/
export interface AocPassFailSummary {
readonly period: string;
readonly totalChecks: number;
readonly passed: number;
readonly failed: number;
readonly pending: number;
readonly skipped: number;
readonly passRate: number; // 0-1
readonly trend: 'improving' | 'stable' | 'degrading';
readonly history: readonly TimeSeriesPoint[];
}
/**
* Individual AOC check result.
*/
export interface AocCheckResult {
readonly checkId: string;
readonly documentId: string;
readonly documentType: string;
readonly status: AocVerificationStatus;
readonly checkedAt: string;
readonly violations: readonly AocViolationCode[];
readonly sourceId?: string;
readonly tenantId: string;
}
/**
* Source with AOC metrics.
*/
export interface AocSource {
readonly sourceId: string;
readonly name: string;
readonly type: 'registry' | 'repository' | 'pipeline' | 'manual';
readonly status: AocVerificationStatus;
readonly lastCheck: string;
readonly checkCount: number;
readonly passRate: number;
readonly recentViolations: readonly AocViolationCode[];
}
/**
* AOC dashboard summary combining all metrics.
*/
export interface AocDashboardSummary {
readonly generatedAt: string;
readonly passFail: AocPassFailSummary;
readonly recentViolations: readonly AocViolationCode[];
readonly throughputByTenant: readonly IngestThroughput[];
readonly sources: readonly AocSource[];
readonly recentChecks: readonly AocCheckResult[];
}
/**
* Verification request for "Verify last 24h" action.
*/
export interface VerificationRequest {
readonly requestId: string;
readonly status: 'queued' | 'running' | 'completed' | 'failed';
readonly startedAt?: string;
readonly completedAt?: string;
readonly documentsToVerify: number;
readonly documentsVerified: number;
readonly passed: number;
readonly failed: number;
readonly cliCommand?: string; // CLI parity command
}
/**
* Violation detail for drill-down view.
*/
export interface ViolationDetail {
readonly violationId: string;
readonly code: string;
readonly severity: ViolationSeverity;
readonly documentId: string;
readonly documentType: string;
readonly offendingFields: readonly OffendingField[];
readonly provenance: ProvenanceMetadata;
readonly detectedAt: string;
readonly suggestion?: string;
}
/**
* Offending field in a document.
*/
export interface OffendingField {
readonly path: string; // JSON path, e.g., "$.metadata.labels"
readonly expectedValue?: string;
readonly actualValue?: string;
readonly reason: string;
}
/**
* Provenance metadata for a document.
*/
export interface ProvenanceMetadata {
readonly sourceType: string;
readonly sourceUri: string;
readonly ingestedAt: string;
readonly ingestedBy: string;
readonly buildId?: string;
readonly commitSha?: string;
readonly pipelineUrl?: string;
}
/**
* AOC (Authorization of Containers) models for dashboard metrics.
*/
export interface AocMetrics {
/** Pass/fail counts for the time window */
passCount: number;
failCount: number;
totalCount: number;
passRate: number;
/** Recent violations grouped by code */
recentViolations: AocViolationSummary[];
/** Ingest throughput metrics */
ingestThroughput: AocIngestThroughput;
/** Time window for these metrics */
timeWindow: {
start: string;
end: string;
durationMinutes: number;
};
}
export interface AocViolationSummary {
code: string;
description: string;
count: number;
severity: 'critical' | 'high' | 'medium' | 'low';
lastSeen: string;
}
export interface AocIngestThroughput {
/** Documents processed per minute */
docsPerMinute: number;
/** Average processing latency in milliseconds */
avgLatencyMs: number;
/** P95 latency in milliseconds */
p95LatencyMs: number;
/** Current queue depth */
queueDepth: number;
/** Error rate percentage */
errorRate: number;
}
export interface AocVerificationRequest {
tenantId: string;
since?: string;
limit?: number;
}
export interface AocVerificationResult {
verificationId: string;
status: 'passed' | 'failed' | 'partial';
checkedCount: number;
passedCount: number;
failedCount: number;
violations: AocViolationDetail[];
completedAt: string;
}
export interface AocViolationDetail {
documentId: string;
violationCode: string;
field?: string;
expected?: string;
actual?: string;
provenance?: AocProvenance;
}
export interface AocProvenance {
sourceId: string;
ingestedAt: string;
digest: string;
sourceType?: 'registry' | 'git' | 'upload' | 'api';
sourceUrl?: string;
submitter?: string;
}
export interface AocViolationGroup {
code: string;
description: string;
severity: 'critical' | 'high' | 'medium' | 'low';
violations: AocViolationDetail[];
affectedDocuments: number;
remediation?: string;
}
export interface AocDocumentView {
documentId: string;
documentType: string;
violations: AocViolationDetail[];
provenance: AocProvenance;
rawContent?: Record<string, unknown>;
highlightedFields: string[];
}

View File

@@ -0,0 +1,77 @@
/**
* Determinism verification models for SBOM scan details.
*/
export interface DeterminismStatus {
/** Overall determinism status */
status: 'verified' | 'warning' | 'failed' | 'unknown';
/** Merkle root from _composition.json */
merkleRoot: string | null;
/** Whether Merkle root matches computed hash */
merkleConsistent: boolean;
/** Fragment hashes with verification status */
fragments: DeterminismFragment[];
/** Composition metadata */
composition: CompositionMeta | null;
/** Timestamp of verification */
verifiedAt: string;
/** Any issues found */
issues: DeterminismIssue[];
}
export interface DeterminismFragment {
/** Fragment identifier (e.g., layer digest) */
id: string;
/** Fragment type */
type: 'layer' | 'metadata' | 'attestation' | 'sbom';
/** Expected hash from composition */
expectedHash: string;
/** Computed hash */
computedHash: string;
/** Whether hashes match */
matches: boolean;
/** Size in bytes */
size: number;
}
export interface CompositionMeta {
/** Composition schema version */
schemaVersion: string;
/** Scanner version that produced this */
scannerVersion: string;
/** Build timestamp */
buildTimestamp: string;
/** Total fragments */
fragmentCount: number;
/** Composition file hash */
compositionHash: string;
}
export interface DeterminismIssue {
/** Issue severity */
severity: 'error' | 'warning' | 'info';
/** Issue code */
code: string;
/** Human-readable message */
message: string;
/** Affected fragment ID if applicable */
fragmentId?: string;
}

View File

@@ -0,0 +1,95 @@
/**
* Entropy analysis models for image security visualization.
*/
export interface EntropyAnalysis {
/** Image digest */
imageDigest: string;
/** Overall entropy score (0-10, higher = more suspicious) */
overallScore: number;
/** Risk level classification */
riskLevel: 'low' | 'medium' | 'high' | 'critical';
/** Per-layer entropy breakdown */
layers: LayerEntropy[];
/** Files with high entropy (potential secrets/malware) */
highEntropyFiles: HighEntropyFile[];
/** Detector hints for suspicious patterns */
detectorHints: DetectorHint[];
/** Analysis timestamp */
analyzedAt: string;
/** Link to raw entropy report */
reportUrl: string;
}
export interface LayerEntropy {
/** Layer digest */
digest: string;
/** Layer command (e.g., COPY, RUN) */
command: string;
/** Layer size in bytes */
size: number;
/** Average entropy for this layer (0-8 bits) */
avgEntropy: number;
/** Percentage of opaque bytes (high entropy) */
opaqueByteRatio: number;
/** Number of high-entropy files */
highEntropyFileCount: number;
/** Risk contribution to overall score */
riskContribution: number;
}
export interface HighEntropyFile {
/** File path in container */
path: string;
/** Layer where file was added */
layerDigest: string;
/** File size in bytes */
size: number;
/** File entropy (0-8 bits) */
entropy: number;
/** Classification */
classification: 'encrypted' | 'compressed' | 'binary' | 'suspicious' | 'unknown';
/** Why this file is flagged */
reason: string;
}
export interface DetectorHint {
/** Hint ID */
id: string;
/** Severity */
severity: 'critical' | 'high' | 'medium' | 'low';
/** Pattern type */
type: 'credential' | 'key' | 'token' | 'obfuscated' | 'packed' | 'crypto';
/** Human-readable description */
description: string;
/** Affected file paths */
affectedPaths: string[];
/** Confidence (0-100) */
confidence: number;
/** Remediation suggestion */
remediation: string;
}

View File

@@ -1,323 +1,323 @@
import { Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay } from 'rxjs';
import {
EvidenceData,
Linkset,
Observation,
PolicyEvidence,
} from './evidence.models';
export interface EvidenceApi {
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData>;
getObservation(observationId: string): Observable<Observation>;
getLinkset(linksetId: string): Observable<Linkset>;
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null>;
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob>;
}
export const EVIDENCE_API = new InjectionToken<EvidenceApi>('EVIDENCE_API');
// Mock data for development
const MOCK_OBSERVATIONS: Observation[] = [
{
observationId: 'obs-ghsa-001',
tenantId: 'tenant-1',
source: 'ghsa',
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
title: 'Log4j Remote Code Execution (Log4Shell)',
summary: 'Apache Log4j2 2.0-beta9 through 2.15.0 (excluding security releases 2.12.2, 2.12.3, and 2.3.1) JNDI features do not protect against attacker controlled LDAP and other JNDI related endpoints.',
severities: [
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
],
affected: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
package: 'log4j-core',
ecosystem: 'maven',
ranges: [
{
type: 'ECOSYSTEM',
events: [
{ introduced: '2.0-beta9' },
{ fixed: '2.15.0' },
],
},
],
},
],
references: [
'https://github.com/advisories/GHSA-jfh8-c2jp-5v3q',
'https://logging.apache.org/log4j/2.x/security.html',
],
weaknesses: ['CWE-502', 'CWE-400', 'CWE-20'],
published: '2021-12-10T00:00:00Z',
modified: '2024-01-15T10:30:00Z',
provenance: {
sourceArtifactSha: 'sha256:abc123def456...',
fetchedAt: '2024-11-20T08:00:00Z',
ingestJobId: 'job-ghsa-2024-1120',
},
ingestedAt: '2024-11-20T08:05:00Z',
},
{
observationId: 'obs-nvd-001',
tenantId: 'tenant-1',
source: 'nvd',
advisoryId: 'CVE-2021-44228',
title: 'Apache Log4j2 Remote Code Execution Vulnerability',
summary: 'Apache Log4j2 <=2.14.1 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
severities: [
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
{ system: 'cvss_v2', score: 9.3, vector: 'AV:N/AC:M/Au:N/C:C/I:C/A:C' },
],
affected: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
package: 'log4j-core',
ecosystem: 'maven',
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
cpe: ['cpe:2.3:a:apache:log4j:*:*:*:*:*:*:*:*'],
},
],
references: [
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
'https://www.cisa.gov/news-events/alerts/2021/12/11/apache-log4j-vulnerability-guidance',
],
relationships: [
{ type: 'alias', source: 'CVE-2021-44228', target: 'GHSA-jfh8-c2jp-5v3q', provenance: 'nvd' },
],
weaknesses: ['CWE-917', 'CWE-20', 'CWE-400', 'CWE-502'],
published: '2021-12-10T10:15:00Z',
modified: '2024-02-20T15:45:00Z',
provenance: {
sourceArtifactSha: 'sha256:def789ghi012...',
fetchedAt: '2024-11-20T08:10:00Z',
ingestJobId: 'job-nvd-2024-1120',
},
ingestedAt: '2024-11-20T08:15:00Z',
},
{
observationId: 'obs-osv-001',
tenantId: 'tenant-1',
source: 'osv',
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
title: 'Remote code injection in Log4j',
summary: 'Logging untrusted data with log4j versions 2.0-beta9 through 2.14.1 can result in remote code execution.',
severities: [
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
],
affected: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
package: 'log4j-core',
ecosystem: 'Maven',
ranges: [
{
type: 'ECOSYSTEM',
events: [
{ introduced: '2.0-beta9' },
{ fixed: '2.3.1' },
],
},
{
type: 'ECOSYSTEM',
events: [
{ introduced: '2.4' },
{ fixed: '2.12.2' },
],
},
{
type: 'ECOSYSTEM',
events: [
{ introduced: '2.13.0' },
{ fixed: '2.15.0' },
],
},
],
},
],
references: [
'https://osv.dev/vulnerability/GHSA-jfh8-c2jp-5v3q',
],
published: '2021-12-10T00:00:00Z',
modified: '2023-06-15T09:00:00Z',
provenance: {
sourceArtifactSha: 'sha256:ghi345jkl678...',
fetchedAt: '2024-11-20T08:20:00Z',
ingestJobId: 'job-osv-2024-1120',
},
ingestedAt: '2024-11-20T08:25:00Z',
},
];
const MOCK_LINKSET: Linkset = {
linksetId: 'ls-log4shell-001',
tenantId: 'tenant-1',
advisoryId: 'CVE-2021-44228',
source: 'aggregated',
observations: ['obs-ghsa-001', 'obs-nvd-001', 'obs-osv-001'],
normalized: {
purls: ['pkg:maven/org.apache.logging.log4j/log4j-core'],
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
severities: [
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
],
},
confidence: 0.95,
conflicts: [
{
field: 'affected.ranges',
reason: 'Different fixed version ranges reported by sources',
values: ['2.15.0 (GHSA)', '2.3.1/2.12.2/2.15.0 (OSV)'],
sourceIds: ['ghsa', 'osv'],
},
{
field: 'weaknesses',
reason: 'Different CWE identifiers reported',
values: ['CWE-502, CWE-400, CWE-20 (GHSA)', 'CWE-917, CWE-20, CWE-400, CWE-502 (NVD)'],
sourceIds: ['ghsa', 'nvd'],
},
],
createdAt: '2024-11-20T08:30:00Z',
builtByJobId: 'linkset-build-2024-1120',
provenance: {
observationHashes: [
'sha256:abc123...',
'sha256:def789...',
'sha256:ghi345...',
],
toolVersion: 'concelier-lnm-1.2.0',
policyHash: 'sha256:policy-hash-001',
},
};
const MOCK_POLICY_EVIDENCE: PolicyEvidence = {
policyId: 'pol-critical-vuln-001',
policyName: 'Critical Vulnerability Policy',
decision: 'block',
decidedAt: '2024-11-20T08:35:00Z',
reason: 'Critical severity vulnerability (CVSS 10.0) with known exploits',
rules: [
{
ruleId: 'rule-cvss-critical',
ruleName: 'Block Critical CVSS',
passed: false,
reason: 'CVSS score 10.0 exceeds threshold of 9.0',
matchedItems: ['CVE-2021-44228'],
},
{
ruleId: 'rule-known-exploit',
ruleName: 'Known Exploit Check',
passed: false,
reason: 'Active exploitation reported by CISA',
matchedItems: ['KEV-2021-44228'],
},
{
ruleId: 'rule-fix-available',
ruleName: 'Fix Available',
passed: true,
reason: 'Fixed version 2.15.0+ available',
},
],
linksetIds: ['ls-log4shell-001'],
aocChain: [
{
attestationId: 'aoc-obs-ghsa-001',
type: 'observation',
hash: 'sha256:abc123def456...',
timestamp: '2024-11-20T08:05:00Z',
parentHash: undefined,
},
{
attestationId: 'aoc-obs-nvd-001',
type: 'observation',
hash: 'sha256:def789ghi012...',
timestamp: '2024-11-20T08:15:00Z',
parentHash: 'sha256:abc123def456...',
},
{
attestationId: 'aoc-obs-osv-001',
type: 'observation',
hash: 'sha256:ghi345jkl678...',
timestamp: '2024-11-20T08:25:00Z',
parentHash: 'sha256:def789ghi012...',
},
{
attestationId: 'aoc-ls-001',
type: 'linkset',
hash: 'sha256:linkset-hash-001...',
timestamp: '2024-11-20T08:30:00Z',
parentHash: 'sha256:ghi345jkl678...',
},
{
attestationId: 'aoc-policy-001',
type: 'policy',
hash: 'sha256:policy-decision-hash...',
timestamp: '2024-11-20T08:35:00Z',
signer: 'policy-engine-v1',
parentHash: 'sha256:linkset-hash-001...',
},
],
};
@Injectable({ providedIn: 'root' })
export class MockEvidenceApiService implements EvidenceApi {
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData> {
// Filter observations related to the advisory
const observations = MOCK_OBSERVATIONS.filter(
(o) =>
o.advisoryId === advisoryId ||
o.advisoryId === 'GHSA-jfh8-c2jp-5v3q' // Related to CVE-2021-44228
);
const linkset = MOCK_LINKSET;
const policyEvidence = MOCK_POLICY_EVIDENCE;
const data: EvidenceData = {
advisoryId,
title: observations[0]?.title ?? `Evidence for ${advisoryId}`,
observations,
linkset,
policyEvidence,
hasConflicts: linkset.conflicts.length > 0,
conflictCount: linkset.conflicts.length,
};
return of(data).pipe(delay(300));
}
getObservation(observationId: string): Observable<Observation> {
const observation = MOCK_OBSERVATIONS.find((o) => o.observationId === observationId);
if (!observation) {
throw new Error(`Observation not found: ${observationId}`);
}
return of(observation).pipe(delay(100));
}
getLinkset(linksetId: string): Observable<Linkset> {
if (linksetId === MOCK_LINKSET.linksetId) {
return of(MOCK_LINKSET).pipe(delay(100));
}
throw new Error(`Linkset not found: ${linksetId}`);
}
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null> {
if (advisoryId === 'CVE-2021-44228' || advisoryId === 'GHSA-jfh8-c2jp-5v3q') {
return of(MOCK_POLICY_EVIDENCE).pipe(delay(100));
}
return of(null).pipe(delay(100));
}
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob> {
let data: object;
if (type === 'observation') {
data = MOCK_OBSERVATIONS.find((o) => o.observationId === id) ?? {};
} else {
data = MOCK_LINKSET;
}
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
return of(blob).pipe(delay(100));
}
}
import { Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay } from 'rxjs';
import {
EvidenceData,
Linkset,
Observation,
PolicyEvidence,
} from './evidence.models';
export interface EvidenceApi {
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData>;
getObservation(observationId: string): Observable<Observation>;
getLinkset(linksetId: string): Observable<Linkset>;
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null>;
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob>;
}
export const EVIDENCE_API = new InjectionToken<EvidenceApi>('EVIDENCE_API');
// Mock data for development
const MOCK_OBSERVATIONS: Observation[] = [
{
observationId: 'obs-ghsa-001',
tenantId: 'tenant-1',
source: 'ghsa',
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
title: 'Log4j Remote Code Execution (Log4Shell)',
summary: 'Apache Log4j2 2.0-beta9 through 2.15.0 (excluding security releases 2.12.2, 2.12.3, and 2.3.1) JNDI features do not protect against attacker controlled LDAP and other JNDI related endpoints.',
severities: [
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
],
affected: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
package: 'log4j-core',
ecosystem: 'maven',
ranges: [
{
type: 'ECOSYSTEM',
events: [
{ introduced: '2.0-beta9' },
{ fixed: '2.15.0' },
],
},
],
},
],
references: [
'https://github.com/advisories/GHSA-jfh8-c2jp-5v3q',
'https://logging.apache.org/log4j/2.x/security.html',
],
weaknesses: ['CWE-502', 'CWE-400', 'CWE-20'],
published: '2021-12-10T00:00:00Z',
modified: '2024-01-15T10:30:00Z',
provenance: {
sourceArtifactSha: 'sha256:abc123def456...',
fetchedAt: '2024-11-20T08:00:00Z',
ingestJobId: 'job-ghsa-2024-1120',
},
ingestedAt: '2024-11-20T08:05:00Z',
},
{
observationId: 'obs-nvd-001',
tenantId: 'tenant-1',
source: 'nvd',
advisoryId: 'CVE-2021-44228',
title: 'Apache Log4j2 Remote Code Execution Vulnerability',
summary: 'Apache Log4j2 <=2.14.1 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
severities: [
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
{ system: 'cvss_v2', score: 9.3, vector: 'AV:N/AC:M/Au:N/C:C/I:C/A:C' },
],
affected: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
package: 'log4j-core',
ecosystem: 'maven',
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
cpe: ['cpe:2.3:a:apache:log4j:*:*:*:*:*:*:*:*'],
},
],
references: [
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
'https://www.cisa.gov/news-events/alerts/2021/12/11/apache-log4j-vulnerability-guidance',
],
relationships: [
{ type: 'alias', source: 'CVE-2021-44228', target: 'GHSA-jfh8-c2jp-5v3q', provenance: 'nvd' },
],
weaknesses: ['CWE-917', 'CWE-20', 'CWE-400', 'CWE-502'],
published: '2021-12-10T10:15:00Z',
modified: '2024-02-20T15:45:00Z',
provenance: {
sourceArtifactSha: 'sha256:def789ghi012...',
fetchedAt: '2024-11-20T08:10:00Z',
ingestJobId: 'job-nvd-2024-1120',
},
ingestedAt: '2024-11-20T08:15:00Z',
},
{
observationId: 'obs-osv-001',
tenantId: 'tenant-1',
source: 'osv',
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
title: 'Remote code injection in Log4j',
summary: 'Logging untrusted data with log4j versions 2.0-beta9 through 2.14.1 can result in remote code execution.',
severities: [
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
],
affected: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
package: 'log4j-core',
ecosystem: 'Maven',
ranges: [
{
type: 'ECOSYSTEM',
events: [
{ introduced: '2.0-beta9' },
{ fixed: '2.3.1' },
],
},
{
type: 'ECOSYSTEM',
events: [
{ introduced: '2.4' },
{ fixed: '2.12.2' },
],
},
{
type: 'ECOSYSTEM',
events: [
{ introduced: '2.13.0' },
{ fixed: '2.15.0' },
],
},
],
},
],
references: [
'https://osv.dev/vulnerability/GHSA-jfh8-c2jp-5v3q',
],
published: '2021-12-10T00:00:00Z',
modified: '2023-06-15T09:00:00Z',
provenance: {
sourceArtifactSha: 'sha256:ghi345jkl678...',
fetchedAt: '2024-11-20T08:20:00Z',
ingestJobId: 'job-osv-2024-1120',
},
ingestedAt: '2024-11-20T08:25:00Z',
},
];
const MOCK_LINKSET: Linkset = {
linksetId: 'ls-log4shell-001',
tenantId: 'tenant-1',
advisoryId: 'CVE-2021-44228',
source: 'aggregated',
observations: ['obs-ghsa-001', 'obs-nvd-001', 'obs-osv-001'],
normalized: {
purls: ['pkg:maven/org.apache.logging.log4j/log4j-core'],
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
severities: [
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
],
},
confidence: 0.95,
conflicts: [
{
field: 'affected.ranges',
reason: 'Different fixed version ranges reported by sources',
values: ['2.15.0 (GHSA)', '2.3.1/2.12.2/2.15.0 (OSV)'],
sourceIds: ['ghsa', 'osv'],
},
{
field: 'weaknesses',
reason: 'Different CWE identifiers reported',
values: ['CWE-502, CWE-400, CWE-20 (GHSA)', 'CWE-917, CWE-20, CWE-400, CWE-502 (NVD)'],
sourceIds: ['ghsa', 'nvd'],
},
],
createdAt: '2024-11-20T08:30:00Z',
builtByJobId: 'linkset-build-2024-1120',
provenance: {
observationHashes: [
'sha256:abc123...',
'sha256:def789...',
'sha256:ghi345...',
],
toolVersion: 'concelier-lnm-1.2.0',
policyHash: 'sha256:policy-hash-001',
},
};
const MOCK_POLICY_EVIDENCE: PolicyEvidence = {
policyId: 'pol-critical-vuln-001',
policyName: 'Critical Vulnerability Policy',
decision: 'block',
decidedAt: '2024-11-20T08:35:00Z',
reason: 'Critical severity vulnerability (CVSS 10.0) with known exploits',
rules: [
{
ruleId: 'rule-cvss-critical',
ruleName: 'Block Critical CVSS',
passed: false,
reason: 'CVSS score 10.0 exceeds threshold of 9.0',
matchedItems: ['CVE-2021-44228'],
},
{
ruleId: 'rule-known-exploit',
ruleName: 'Known Exploit Check',
passed: false,
reason: 'Active exploitation reported by CISA',
matchedItems: ['KEV-2021-44228'],
},
{
ruleId: 'rule-fix-available',
ruleName: 'Fix Available',
passed: true,
reason: 'Fixed version 2.15.0+ available',
},
],
linksetIds: ['ls-log4shell-001'],
aocChain: [
{
attestationId: 'aoc-obs-ghsa-001',
type: 'observation',
hash: 'sha256:abc123def456...',
timestamp: '2024-11-20T08:05:00Z',
parentHash: undefined,
},
{
attestationId: 'aoc-obs-nvd-001',
type: 'observation',
hash: 'sha256:def789ghi012...',
timestamp: '2024-11-20T08:15:00Z',
parentHash: 'sha256:abc123def456...',
},
{
attestationId: 'aoc-obs-osv-001',
type: 'observation',
hash: 'sha256:ghi345jkl678...',
timestamp: '2024-11-20T08:25:00Z',
parentHash: 'sha256:def789ghi012...',
},
{
attestationId: 'aoc-ls-001',
type: 'linkset',
hash: 'sha256:linkset-hash-001...',
timestamp: '2024-11-20T08:30:00Z',
parentHash: 'sha256:ghi345jkl678...',
},
{
attestationId: 'aoc-policy-001',
type: 'policy',
hash: 'sha256:policy-decision-hash...',
timestamp: '2024-11-20T08:35:00Z',
signer: 'policy-engine-v1',
parentHash: 'sha256:linkset-hash-001...',
},
],
};
@Injectable({ providedIn: 'root' })
export class MockEvidenceApiService implements EvidenceApi {
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData> {
// Filter observations related to the advisory
const observations = MOCK_OBSERVATIONS.filter(
(o) =>
o.advisoryId === advisoryId ||
o.advisoryId === 'GHSA-jfh8-c2jp-5v3q' // Related to CVE-2021-44228
);
const linkset = MOCK_LINKSET;
const policyEvidence = MOCK_POLICY_EVIDENCE;
const data: EvidenceData = {
advisoryId,
title: observations[0]?.title ?? `Evidence for ${advisoryId}`,
observations,
linkset,
policyEvidence,
hasConflicts: linkset.conflicts.length > 0,
conflictCount: linkset.conflicts.length,
};
return of(data).pipe(delay(300));
}
getObservation(observationId: string): Observable<Observation> {
const observation = MOCK_OBSERVATIONS.find((o) => o.observationId === observationId);
if (!observation) {
throw new Error(`Observation not found: ${observationId}`);
}
return of(observation).pipe(delay(100));
}
getLinkset(linksetId: string): Observable<Linkset> {
if (linksetId === MOCK_LINKSET.linksetId) {
return of(MOCK_LINKSET).pipe(delay(100));
}
throw new Error(`Linkset not found: ${linksetId}`);
}
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null> {
if (advisoryId === 'CVE-2021-44228' || advisoryId === 'GHSA-jfh8-c2jp-5v3q') {
return of(MOCK_POLICY_EVIDENCE).pipe(delay(100));
}
return of(null).pipe(delay(100));
}
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob> {
let data: object;
if (type === 'observation') {
data = MOCK_OBSERVATIONS.find((o) => o.observationId === id) ?? {};
} else {
data = MOCK_LINKSET;
}
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
return of(blob).pipe(delay(100));
}
}

View File

@@ -1,189 +1,189 @@
/**
* Link-Not-Merge Evidence Models
* Based on docs/modules/concelier/link-not-merge-schema.md
*/
// Severity from advisory sources
export interface AdvisorySeverity {
readonly system: string; // e.g., 'cvss_v3', 'cvss_v2', 'ghsa'
readonly score: number;
readonly vector?: string;
}
// Affected package information
export interface AffectedPackage {
readonly purl: string;
readonly package?: string;
readonly versions?: readonly string[];
readonly ranges?: readonly VersionRange[];
readonly ecosystem?: string;
readonly cpe?: readonly string[];
}
export interface VersionRange {
readonly type: string;
readonly events: readonly VersionEvent[];
}
export interface VersionEvent {
readonly introduced?: string;
readonly fixed?: string;
readonly last_affected?: string;
}
// Relationship between advisories
export interface AdvisoryRelationship {
readonly type: string;
readonly source: string;
readonly target: string;
readonly provenance?: string;
}
// Provenance tracking
export interface ObservationProvenance {
readonly sourceArtifactSha: string;
readonly fetchedAt: string;
readonly ingestJobId?: string;
readonly signature?: Record<string, unknown>;
}
// Raw observation from a single source
export interface Observation {
readonly observationId: string;
readonly tenantId: string;
readonly source: string; // e.g., 'ghsa', 'nvd', 'cert-bund'
readonly advisoryId: string;
readonly title?: string;
readonly summary?: string;
readonly severities: readonly AdvisorySeverity[];
readonly affected: readonly AffectedPackage[];
readonly references?: readonly string[];
readonly scopes?: readonly string[];
readonly relationships?: readonly AdvisoryRelationship[];
readonly weaknesses?: readonly string[];
readonly published?: string;
readonly modified?: string;
readonly provenance: ObservationProvenance;
readonly ingestedAt: string;
}
// Conflict when sources disagree
export interface LinksetConflict {
readonly field: string;
readonly reason: string;
readonly values?: readonly string[];
readonly sourceIds?: readonly string[];
}
// Linkset provenance
export interface LinksetProvenance {
readonly observationHashes: readonly string[];
readonly toolVersion?: string;
readonly policyHash?: string;
}
// Normalized linkset aggregating multiple observations
export interface Linkset {
readonly linksetId: string;
readonly tenantId: string;
readonly advisoryId: string;
readonly source: string;
readonly observations: readonly string[]; // observation IDs
readonly normalized?: {
readonly purls?: readonly string[];
readonly versions?: readonly string[];
readonly ranges?: readonly VersionRange[];
readonly severities?: readonly AdvisorySeverity[];
};
readonly confidence?: number; // 0-1
readonly conflicts: readonly LinksetConflict[];
readonly createdAt: string;
readonly builtByJobId?: string;
readonly provenance?: LinksetProvenance;
}
// Policy decision result
export type PolicyDecision = 'pass' | 'warn' | 'block' | 'pending';
// Policy decision with evidence
export interface PolicyEvidence {
readonly policyId: string;
readonly policyName: string;
readonly decision: PolicyDecision;
readonly decidedAt: string;
readonly reason?: string;
readonly rules: readonly PolicyRuleResult[];
readonly linksetIds: readonly string[];
readonly aocChain?: AocChainEntry[];
}
export interface PolicyRuleResult {
readonly ruleId: string;
readonly ruleName: string;
readonly passed: boolean;
readonly reason?: string;
readonly matchedItems?: readonly string[];
}
// AOC (Attestation of Compliance) chain entry
export interface AocChainEntry {
readonly attestationId: string;
readonly type: 'observation' | 'linkset' | 'policy' | 'signature';
readonly hash: string;
readonly timestamp: string;
readonly signer?: string;
readonly parentHash?: string;
}
// Evidence panel data combining all elements
export interface EvidenceData {
readonly advisoryId: string;
readonly title?: string;
readonly observations: readonly Observation[];
readonly linkset?: Linkset;
readonly policyEvidence?: PolicyEvidence;
readonly hasConflicts: boolean;
readonly conflictCount: number;
}
// Source metadata for display
export interface SourceInfo {
readonly sourceId: string;
readonly name: string;
readonly icon?: string;
readonly url?: string;
readonly lastUpdated?: string;
}
export const SOURCE_INFO: Record<string, SourceInfo> = {
ghsa: {
sourceId: 'ghsa',
name: 'GitHub Security Advisories',
icon: 'github',
url: 'https://github.com/advisories',
},
nvd: {
sourceId: 'nvd',
name: 'National Vulnerability Database',
icon: 'database',
url: 'https://nvd.nist.gov',
},
'cert-bund': {
sourceId: 'cert-bund',
name: 'CERT-Bund',
icon: 'shield',
url: 'https://www.cert-bund.de',
},
osv: {
sourceId: 'osv',
name: 'Open Source Vulnerabilities',
icon: 'box',
url: 'https://osv.dev',
},
cve: {
sourceId: 'cve',
name: 'CVE Program',
icon: 'alert-triangle',
url: 'https://cve.mitre.org',
},
};
/**
* Link-Not-Merge Evidence Models
* Based on docs/modules/concelier/link-not-merge-schema.md
*/
// Severity from advisory sources
export interface AdvisorySeverity {
readonly system: string; // e.g., 'cvss_v3', 'cvss_v2', 'ghsa'
readonly score: number;
readonly vector?: string;
}
// Affected package information
export interface AffectedPackage {
readonly purl: string;
readonly package?: string;
readonly versions?: readonly string[];
readonly ranges?: readonly VersionRange[];
readonly ecosystem?: string;
readonly cpe?: readonly string[];
}
export interface VersionRange {
readonly type: string;
readonly events: readonly VersionEvent[];
}
export interface VersionEvent {
readonly introduced?: string;
readonly fixed?: string;
readonly last_affected?: string;
}
// Relationship between advisories
export interface AdvisoryRelationship {
readonly type: string;
readonly source: string;
readonly target: string;
readonly provenance?: string;
}
// Provenance tracking
export interface ObservationProvenance {
readonly sourceArtifactSha: string;
readonly fetchedAt: string;
readonly ingestJobId?: string;
readonly signature?: Record<string, unknown>;
}
// Raw observation from a single source
export interface Observation {
readonly observationId: string;
readonly tenantId: string;
readonly source: string; // e.g., 'ghsa', 'nvd', 'cert-bund'
readonly advisoryId: string;
readonly title?: string;
readonly summary?: string;
readonly severities: readonly AdvisorySeverity[];
readonly affected: readonly AffectedPackage[];
readonly references?: readonly string[];
readonly scopes?: readonly string[];
readonly relationships?: readonly AdvisoryRelationship[];
readonly weaknesses?: readonly string[];
readonly published?: string;
readonly modified?: string;
readonly provenance: ObservationProvenance;
readonly ingestedAt: string;
}
// Conflict when sources disagree
export interface LinksetConflict {
readonly field: string;
readonly reason: string;
readonly values?: readonly string[];
readonly sourceIds?: readonly string[];
}
// Linkset provenance
export interface LinksetProvenance {
readonly observationHashes: readonly string[];
readonly toolVersion?: string;
readonly policyHash?: string;
}
// Normalized linkset aggregating multiple observations
export interface Linkset {
readonly linksetId: string;
readonly tenantId: string;
readonly advisoryId: string;
readonly source: string;
readonly observations: readonly string[]; // observation IDs
readonly normalized?: {
readonly purls?: readonly string[];
readonly versions?: readonly string[];
readonly ranges?: readonly VersionRange[];
readonly severities?: readonly AdvisorySeverity[];
};
readonly confidence?: number; // 0-1
readonly conflicts: readonly LinksetConflict[];
readonly createdAt: string;
readonly builtByJobId?: string;
readonly provenance?: LinksetProvenance;
}
// Policy decision result
export type PolicyDecision = 'pass' | 'warn' | 'block' | 'pending';
// Policy decision with evidence
export interface PolicyEvidence {
readonly policyId: string;
readonly policyName: string;
readonly decision: PolicyDecision;
readonly decidedAt: string;
readonly reason?: string;
readonly rules: readonly PolicyRuleResult[];
readonly linksetIds: readonly string[];
readonly aocChain?: AocChainEntry[];
}
export interface PolicyRuleResult {
readonly ruleId: string;
readonly ruleName: string;
readonly passed: boolean;
readonly reason?: string;
readonly matchedItems?: readonly string[];
}
// AOC (Attestation of Compliance) chain entry
export interface AocChainEntry {
readonly attestationId: string;
readonly type: 'observation' | 'linkset' | 'policy' | 'signature';
readonly hash: string;
readonly timestamp: string;
readonly signer?: string;
readonly parentHash?: string;
}
// Evidence panel data combining all elements
export interface EvidenceData {
readonly advisoryId: string;
readonly title?: string;
readonly observations: readonly Observation[];
readonly linkset?: Linkset;
readonly policyEvidence?: PolicyEvidence;
readonly hasConflicts: boolean;
readonly conflictCount: number;
}
// Source metadata for display
export interface SourceInfo {
readonly sourceId: string;
readonly name: string;
readonly icon?: string;
readonly url?: string;
readonly lastUpdated?: string;
}
export const SOURCE_INFO: Record<string, SourceInfo> = {
ghsa: {
sourceId: 'ghsa',
name: 'GitHub Security Advisories',
icon: 'github',
url: 'https://github.com/advisories',
},
nvd: {
sourceId: 'nvd',
name: 'National Vulnerability Database',
icon: 'database',
url: 'https://nvd.nist.gov',
},
'cert-bund': {
sourceId: 'cert-bund',
name: 'CERT-Bund',
icon: 'shield',
url: 'https://www.cert-bund.de',
},
osv: {
sourceId: 'osv',
name: 'Open Source Vulnerabilities',
icon: 'box',
url: 'https://osv.dev',
},
cve: {
sourceId: 'cve',
name: 'CVE Program',
icon: 'alert-triangle',
url: 'https://cve.mitre.org',
},
};

View File

@@ -1,424 +1,424 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable } from 'rxjs';
import {
Exception,
ExceptionsQueryOptions,
ExceptionsResponse,
ExceptionStats,
ExceptionStatusTransition,
} from './exception.models';
export interface ExceptionApi {
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse>;
getException(exceptionId: string): Observable<Exception>;
createException(exception: Partial<Exception>): Observable<Exception>;
updateException(exceptionId: string, updates: Partial<Exception>): Observable<Exception>;
deleteException(exceptionId: string): Observable<void>;
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception>;
getStats(): Observable<ExceptionStats>;
}
export const EXCEPTION_API = new InjectionToken<ExceptionApi>('EXCEPTION_API');
export const EXCEPTION_API_BASE_URL = new InjectionToken<string>('EXCEPTION_API_BASE_URL');
@Injectable({ providedIn: 'root' })
export class ExceptionApiHttpClient implements ExceptionApi {
constructor(
private readonly http: HttpClient,
@Inject(EXCEPTION_API_BASE_URL) private readonly baseUrl: string
) {}
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse> {
let params = new HttpParams();
if (options?.status) {
params = params.set('status', options.status);
}
if (options?.severity) {
params = params.set('severity', options.severity);
}
if (options?.search) {
params = params.set('search', options.search);
}
if (options?.sortBy) {
params = params.set('sortBy', options.sortBy);
}
if (options?.sortOrder) {
params = params.set('sortOrder', options.sortOrder);
}
if (options?.limit) {
params = params.set('limit', options.limit.toString());
}
if (options?.continuationToken) {
params = params.set('continuationToken', options.continuationToken);
}
return this.http.get<ExceptionsResponse>(`${this.baseUrl}/exceptions`, {
params,
headers: this.buildHeaders(),
});
}
getException(exceptionId: string): Observable<Exception> {
return this.http.get<Exception>(`${this.baseUrl}/exceptions/${exceptionId}`, {
headers: this.buildHeaders(),
});
}
createException(exception: Partial<Exception>): Observable<Exception> {
return this.http.post<Exception>(`${this.baseUrl}/exceptions`, exception, {
headers: this.buildHeaders(),
});
}
updateException(exceptionId: string, updates: Partial<Exception>): Observable<Exception> {
return this.http.patch<Exception>(`${this.baseUrl}/exceptions/${exceptionId}`, updates, {
headers: this.buildHeaders(),
});
}
deleteException(exceptionId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/exceptions/${exceptionId}`, {
headers: this.buildHeaders(),
});
}
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception> {
return this.http.post<Exception>(
`${this.baseUrl}/exceptions/${transition.exceptionId}/transition`,
{
newStatus: transition.newStatus,
comment: transition.comment,
},
{
headers: this.buildHeaders(),
}
);
}
getStats(): Observable<ExceptionStats> {
return this.http.get<ExceptionStats>(`${this.baseUrl}/exceptions/stats`, {
headers: this.buildHeaders(),
});
}
private buildHeaders(): HttpHeaders {
return new HttpHeaders({
'Content-Type': 'application/json',
});
}
}
/**
* Mock implementation for development and testing.
*/
@Injectable({ providedIn: 'root' })
export class MockExceptionApiService implements ExceptionApi {
private readonly mockExceptions: Exception[] = [
{
schemaVersion: '1.0',
exceptionId: 'exc-001',
tenantId: 'tenant-dev',
name: 'log4j-temporary-exception',
displayName: 'Log4j Temporary Exception',
description: 'Temporary exception for legacy Log4j usage in internal tooling',
status: 'approved',
severity: 'high',
scope: {
type: 'component',
componentPurls: ['pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1'],
vulnIds: ['CVE-2021-44228'],
},
justification: {
template: 'internal-only',
text: 'Internal-only service with no external exposure. Migration planned for Q1.',
},
timebox: {
startDate: '2025-01-01T00:00:00Z',
endDate: '2025-03-31T23:59:59Z',
autoRenew: false,
},
approvals: [
{
approvalId: 'apr-001',
approvedBy: 'security-lead@example.com',
approvedAt: '2024-12-15T10:30:00Z',
comment: 'Approved with condition: migrate before Q2',
},
],
labels: { team: 'platform', priority: 'P2' },
createdBy: 'dev@example.com',
createdAt: '2024-12-10T09:00:00Z',
updatedBy: 'security-lead@example.com',
updatedAt: '2024-12-15T10:30:00Z',
},
{
schemaVersion: '1.0',
exceptionId: 'exc-002',
tenantId: 'tenant-dev',
name: 'openssl-vuln-exception',
displayName: 'OpenSSL Vulnerability Exception',
status: 'pending_review',
severity: 'critical',
scope: {
type: 'asset',
assetIds: ['asset-nginx-prod'],
vulnIds: ['CVE-2024-0001'],
},
justification: {
template: 'compensating-control',
text: 'Compensating controls in place: WAF rules, network segmentation, monitoring.',
},
timebox: {
startDate: '2025-01-15T00:00:00Z',
endDate: '2025-02-15T23:59:59Z',
},
labels: { team: 'infrastructure' },
createdBy: 'ops@example.com',
createdAt: '2025-01-10T14:00:00Z',
},
{
schemaVersion: '1.0',
exceptionId: 'exc-003',
tenantId: 'tenant-dev',
name: 'legacy-crypto-exception',
displayName: 'Legacy Crypto Library',
status: 'draft',
severity: 'medium',
scope: {
type: 'tenant',
tenantId: 'tenant-dev',
},
justification: {
text: 'Migration in progress. ETA: 2 weeks.',
},
timebox: {
startDate: '2025-01-20T00:00:00Z',
endDate: '2025-02-20T23:59:59Z',
},
createdBy: 'dev@example.com',
createdAt: '2025-01-18T11:00:00Z',
},
{
schemaVersion: '1.0',
exceptionId: 'exc-004',
tenantId: 'tenant-dev',
name: 'expired-cert-exception',
displayName: 'Expired Certificate Exception',
status: 'expired',
severity: 'low',
scope: {
type: 'asset',
assetIds: ['asset-test-env'],
},
justification: {
text: 'Test environment only, not production facing.',
},
timebox: {
startDate: '2024-10-01T00:00:00Z',
endDate: '2024-12-31T23:59:59Z',
},
createdBy: 'qa@example.com',
createdAt: '2024-09-25T08:00:00Z',
},
{
schemaVersion: '1.0',
exceptionId: 'exc-005',
tenantId: 'tenant-dev',
name: 'rejected-exception',
displayName: 'Rejected Risk Exception',
status: 'rejected',
severity: 'critical',
scope: {
type: 'global',
},
justification: {
text: 'Blanket exception for all critical vulns.',
},
timebox: {
startDate: '2025-01-01T00:00:00Z',
endDate: '2025-12-31T23:59:59Z',
},
createdBy: 'dev@example.com',
createdAt: '2024-12-20T16:00:00Z',
},
];
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse> {
let filtered = [...this.mockExceptions];
if (options?.status) {
filtered = filtered.filter((e) => e.status === options.status);
}
if (options?.severity) {
filtered = filtered.filter((e) => e.severity === options.severity);
}
if (options?.search) {
const searchLower = options.search.toLowerCase();
filtered = filtered.filter(
(e) =>
e.name.toLowerCase().includes(searchLower) ||
e.displayName?.toLowerCase().includes(searchLower) ||
e.description?.toLowerCase().includes(searchLower)
);
}
const sortBy = options?.sortBy ?? 'createdAt';
const sortOrder = options?.sortOrder ?? 'desc';
filtered.sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'severity':
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
comparison = severityOrder[a.severity] - severityOrder[b.severity];
break;
case 'status':
comparison = a.status.localeCompare(b.status);
break;
case 'updatedAt':
comparison = (a.updatedAt ?? a.createdAt).localeCompare(b.updatedAt ?? b.createdAt);
break;
default:
comparison = a.createdAt.localeCompare(b.createdAt);
}
return sortOrder === 'asc' ? comparison : -comparison;
});
const limit = options?.limit ?? 20;
const items = filtered.slice(0, limit);
return new Observable((subscriber) => {
setTimeout(() => {
subscriber.next({
items,
count: filtered.length,
continuationToken: filtered.length > limit ? 'next-page-token' : null,
});
subscriber.complete();
}, 300);
});
}
getException(exceptionId: string): Observable<Exception> {
return new Observable((subscriber) => {
const exception = this.mockExceptions.find((e) => e.exceptionId === exceptionId);
setTimeout(() => {
if (exception) {
subscriber.next(exception);
} else {
subscriber.error(new Error(`Exception ${exceptionId} not found`));
}
subscriber.complete();
}, 100);
});
}
createException(exception: Partial<Exception>): Observable<Exception> {
return new Observable((subscriber) => {
const newException: Exception = {
schemaVersion: '1.0',
exceptionId: `exc-${Math.random().toString(36).slice(2, 10)}`,
tenantId: 'tenant-dev',
name: exception.name ?? 'new-exception',
status: 'draft',
severity: exception.severity ?? 'medium',
scope: exception.scope ?? { type: 'tenant' },
justification: exception.justification ?? { text: '' },
timebox: exception.timebox ?? {
startDate: new Date().toISOString(),
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
},
createdBy: 'ui@stella-ops.local',
createdAt: new Date().toISOString(),
...exception,
} as Exception;
this.mockExceptions.push(newException);
setTimeout(() => {
subscriber.next(newException);
subscriber.complete();
}, 200);
});
}
updateException(exceptionId: string, updates: Partial<Exception>): Observable<Exception> {
return new Observable((subscriber) => {
const index = this.mockExceptions.findIndex((e) => e.exceptionId === exceptionId);
if (index === -1) {
subscriber.error(new Error(`Exception ${exceptionId} not found`));
return;
}
const updated = {
...this.mockExceptions[index],
...updates,
updatedAt: new Date().toISOString(),
updatedBy: 'ui@stella-ops.local',
};
this.mockExceptions[index] = updated;
setTimeout(() => {
subscriber.next(updated);
subscriber.complete();
}, 200);
});
}
deleteException(exceptionId: string): Observable<void> {
return new Observable((subscriber) => {
const index = this.mockExceptions.findIndex((e) => e.exceptionId === exceptionId);
if (index !== -1) {
this.mockExceptions.splice(index, 1);
}
setTimeout(() => {
subscriber.next();
subscriber.complete();
}, 200);
});
}
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception> {
return this.updateException(transition.exceptionId, {
status: transition.newStatus,
});
}
getStats(): Observable<ExceptionStats> {
return new Observable((subscriber) => {
const byStatus: Record<string, number> = {
draft: 0,
pending_review: 0,
approved: 0,
rejected: 0,
expired: 0,
revoked: 0,
};
const bySeverity: Record<string, number> = {
critical: 0,
high: 0,
medium: 0,
low: 0,
};
this.mockExceptions.forEach((e) => {
byStatus[e.status] = (byStatus[e.status] ?? 0) + 1;
bySeverity[e.severity] = (bySeverity[e.severity] ?? 0) + 1;
});
setTimeout(() => {
subscriber.next({
total: this.mockExceptions.length,
byStatus: byStatus as Record<any, number>,
bySeverity: bySeverity as Record<any, number>,
expiringWithin7Days: 1,
pendingApproval: byStatus['pending_review'],
});
subscriber.complete();
}, 100);
});
}
}
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable } from 'rxjs';
import {
Exception,
ExceptionsQueryOptions,
ExceptionsResponse,
ExceptionStats,
ExceptionStatusTransition,
} from './exception.models';
export interface ExceptionApi {
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse>;
getException(exceptionId: string): Observable<Exception>;
createException(exception: Partial<Exception>): Observable<Exception>;
updateException(exceptionId: string, updates: Partial<Exception>): Observable<Exception>;
deleteException(exceptionId: string): Observable<void>;
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception>;
getStats(): Observable<ExceptionStats>;
}
export const EXCEPTION_API = new InjectionToken<ExceptionApi>('EXCEPTION_API');
export const EXCEPTION_API_BASE_URL = new InjectionToken<string>('EXCEPTION_API_BASE_URL');
@Injectable({ providedIn: 'root' })
export class ExceptionApiHttpClient implements ExceptionApi {
constructor(
private readonly http: HttpClient,
@Inject(EXCEPTION_API_BASE_URL) private readonly baseUrl: string
) {}
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse> {
let params = new HttpParams();
if (options?.status) {
params = params.set('status', options.status);
}
if (options?.severity) {
params = params.set('severity', options.severity);
}
if (options?.search) {
params = params.set('search', options.search);
}
if (options?.sortBy) {
params = params.set('sortBy', options.sortBy);
}
if (options?.sortOrder) {
params = params.set('sortOrder', options.sortOrder);
}
if (options?.limit) {
params = params.set('limit', options.limit.toString());
}
if (options?.continuationToken) {
params = params.set('continuationToken', options.continuationToken);
}
return this.http.get<ExceptionsResponse>(`${this.baseUrl}/exceptions`, {
params,
headers: this.buildHeaders(),
});
}
getException(exceptionId: string): Observable<Exception> {
return this.http.get<Exception>(`${this.baseUrl}/exceptions/${exceptionId}`, {
headers: this.buildHeaders(),
});
}
createException(exception: Partial<Exception>): Observable<Exception> {
return this.http.post<Exception>(`${this.baseUrl}/exceptions`, exception, {
headers: this.buildHeaders(),
});
}
updateException(exceptionId: string, updates: Partial<Exception>): Observable<Exception> {
return this.http.patch<Exception>(`${this.baseUrl}/exceptions/${exceptionId}`, updates, {
headers: this.buildHeaders(),
});
}
deleteException(exceptionId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/exceptions/${exceptionId}`, {
headers: this.buildHeaders(),
});
}
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception> {
return this.http.post<Exception>(
`${this.baseUrl}/exceptions/${transition.exceptionId}/transition`,
{
newStatus: transition.newStatus,
comment: transition.comment,
},
{
headers: this.buildHeaders(),
}
);
}
getStats(): Observable<ExceptionStats> {
return this.http.get<ExceptionStats>(`${this.baseUrl}/exceptions/stats`, {
headers: this.buildHeaders(),
});
}
private buildHeaders(): HttpHeaders {
return new HttpHeaders({
'Content-Type': 'application/json',
});
}
}
/**
* Mock implementation for development and testing.
*/
@Injectable({ providedIn: 'root' })
export class MockExceptionApiService implements ExceptionApi {
private readonly mockExceptions: Exception[] = [
{
schemaVersion: '1.0',
exceptionId: 'exc-001',
tenantId: 'tenant-dev',
name: 'log4j-temporary-exception',
displayName: 'Log4j Temporary Exception',
description: 'Temporary exception for legacy Log4j usage in internal tooling',
status: 'approved',
severity: 'high',
scope: {
type: 'component',
componentPurls: ['pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1'],
vulnIds: ['CVE-2021-44228'],
},
justification: {
template: 'internal-only',
text: 'Internal-only service with no external exposure. Migration planned for Q1.',
},
timebox: {
startDate: '2025-01-01T00:00:00Z',
endDate: '2025-03-31T23:59:59Z',
autoRenew: false,
},
approvals: [
{
approvalId: 'apr-001',
approvedBy: 'security-lead@example.com',
approvedAt: '2024-12-15T10:30:00Z',
comment: 'Approved with condition: migrate before Q2',
},
],
labels: { team: 'platform', priority: 'P2' },
createdBy: 'dev@example.com',
createdAt: '2024-12-10T09:00:00Z',
updatedBy: 'security-lead@example.com',
updatedAt: '2024-12-15T10:30:00Z',
},
{
schemaVersion: '1.0',
exceptionId: 'exc-002',
tenantId: 'tenant-dev',
name: 'openssl-vuln-exception',
displayName: 'OpenSSL Vulnerability Exception',
status: 'pending_review',
severity: 'critical',
scope: {
type: 'asset',
assetIds: ['asset-nginx-prod'],
vulnIds: ['CVE-2024-0001'],
},
justification: {
template: 'compensating-control',
text: 'Compensating controls in place: WAF rules, network segmentation, monitoring.',
},
timebox: {
startDate: '2025-01-15T00:00:00Z',
endDate: '2025-02-15T23:59:59Z',
},
labels: { team: 'infrastructure' },
createdBy: 'ops@example.com',
createdAt: '2025-01-10T14:00:00Z',
},
{
schemaVersion: '1.0',
exceptionId: 'exc-003',
tenantId: 'tenant-dev',
name: 'legacy-crypto-exception',
displayName: 'Legacy Crypto Library',
status: 'draft',
severity: 'medium',
scope: {
type: 'tenant',
tenantId: 'tenant-dev',
},
justification: {
text: 'Migration in progress. ETA: 2 weeks.',
},
timebox: {
startDate: '2025-01-20T00:00:00Z',
endDate: '2025-02-20T23:59:59Z',
},
createdBy: 'dev@example.com',
createdAt: '2025-01-18T11:00:00Z',
},
{
schemaVersion: '1.0',
exceptionId: 'exc-004',
tenantId: 'tenant-dev',
name: 'expired-cert-exception',
displayName: 'Expired Certificate Exception',
status: 'expired',
severity: 'low',
scope: {
type: 'asset',
assetIds: ['asset-test-env'],
},
justification: {
text: 'Test environment only, not production facing.',
},
timebox: {
startDate: '2024-10-01T00:00:00Z',
endDate: '2024-12-31T23:59:59Z',
},
createdBy: 'qa@example.com',
createdAt: '2024-09-25T08:00:00Z',
},
{
schemaVersion: '1.0',
exceptionId: 'exc-005',
tenantId: 'tenant-dev',
name: 'rejected-exception',
displayName: 'Rejected Risk Exception',
status: 'rejected',
severity: 'critical',
scope: {
type: 'global',
},
justification: {
text: 'Blanket exception for all critical vulns.',
},
timebox: {
startDate: '2025-01-01T00:00:00Z',
endDate: '2025-12-31T23:59:59Z',
},
createdBy: 'dev@example.com',
createdAt: '2024-12-20T16:00:00Z',
},
];
listExceptions(options?: ExceptionsQueryOptions): Observable<ExceptionsResponse> {
let filtered = [...this.mockExceptions];
if (options?.status) {
filtered = filtered.filter((e) => e.status === options.status);
}
if (options?.severity) {
filtered = filtered.filter((e) => e.severity === options.severity);
}
if (options?.search) {
const searchLower = options.search.toLowerCase();
filtered = filtered.filter(
(e) =>
e.name.toLowerCase().includes(searchLower) ||
e.displayName?.toLowerCase().includes(searchLower) ||
e.description?.toLowerCase().includes(searchLower)
);
}
const sortBy = options?.sortBy ?? 'createdAt';
const sortOrder = options?.sortOrder ?? 'desc';
filtered.sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'severity':
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
comparison = severityOrder[a.severity] - severityOrder[b.severity];
break;
case 'status':
comparison = a.status.localeCompare(b.status);
break;
case 'updatedAt':
comparison = (a.updatedAt ?? a.createdAt).localeCompare(b.updatedAt ?? b.createdAt);
break;
default:
comparison = a.createdAt.localeCompare(b.createdAt);
}
return sortOrder === 'asc' ? comparison : -comparison;
});
const limit = options?.limit ?? 20;
const items = filtered.slice(0, limit);
return new Observable((subscriber) => {
setTimeout(() => {
subscriber.next({
items,
count: filtered.length,
continuationToken: filtered.length > limit ? 'next-page-token' : null,
});
subscriber.complete();
}, 300);
});
}
getException(exceptionId: string): Observable<Exception> {
return new Observable((subscriber) => {
const exception = this.mockExceptions.find((e) => e.exceptionId === exceptionId);
setTimeout(() => {
if (exception) {
subscriber.next(exception);
} else {
subscriber.error(new Error(`Exception ${exceptionId} not found`));
}
subscriber.complete();
}, 100);
});
}
createException(exception: Partial<Exception>): Observable<Exception> {
return new Observable((subscriber) => {
const newException: Exception = {
schemaVersion: '1.0',
exceptionId: `exc-${Math.random().toString(36).slice(2, 10)}`,
tenantId: 'tenant-dev',
name: exception.name ?? 'new-exception',
status: 'draft',
severity: exception.severity ?? 'medium',
scope: exception.scope ?? { type: 'tenant' },
justification: exception.justification ?? { text: '' },
timebox: exception.timebox ?? {
startDate: new Date().toISOString(),
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
},
createdBy: 'ui@stella-ops.local',
createdAt: new Date().toISOString(),
...exception,
} as Exception;
this.mockExceptions.push(newException);
setTimeout(() => {
subscriber.next(newException);
subscriber.complete();
}, 200);
});
}
updateException(exceptionId: string, updates: Partial<Exception>): Observable<Exception> {
return new Observable((subscriber) => {
const index = this.mockExceptions.findIndex((e) => e.exceptionId === exceptionId);
if (index === -1) {
subscriber.error(new Error(`Exception ${exceptionId} not found`));
return;
}
const updated = {
...this.mockExceptions[index],
...updates,
updatedAt: new Date().toISOString(),
updatedBy: 'ui@stella-ops.local',
};
this.mockExceptions[index] = updated;
setTimeout(() => {
subscriber.next(updated);
subscriber.complete();
}, 200);
});
}
deleteException(exceptionId: string): Observable<void> {
return new Observable((subscriber) => {
const index = this.mockExceptions.findIndex((e) => e.exceptionId === exceptionId);
if (index !== -1) {
this.mockExceptions.splice(index, 1);
}
setTimeout(() => {
subscriber.next();
subscriber.complete();
}, 200);
});
}
transitionStatus(transition: ExceptionStatusTransition): Observable<Exception> {
return this.updateException(transition.exceptionId, {
status: transition.newStatus,
});
}
getStats(): Observable<ExceptionStats> {
return new Observable((subscriber) => {
const byStatus: Record<string, number> = {
draft: 0,
pending_review: 0,
approved: 0,
rejected: 0,
expired: 0,
revoked: 0,
};
const bySeverity: Record<string, number> = {
critical: 0,
high: 0,
medium: 0,
low: 0,
};
this.mockExceptions.forEach((e) => {
byStatus[e.status] = (byStatus[e.status] ?? 0) + 1;
bySeverity[e.severity] = (bySeverity[e.severity] ?? 0) + 1;
});
setTimeout(() => {
subscriber.next({
total: this.mockExceptions.length,
byStatus: byStatus as Record<any, number>,
bySeverity: bySeverity as Record<any, number>,
expiringWithin7Days: 1,
pendingApproval: byStatus['pending_review'],
});
subscriber.complete();
}, 100);
});
}
}

View File

@@ -1,116 +1,205 @@
/**
* Exception Center domain models.
* Represents policy exceptions with workflow lifecycle management.
*/
export type ExceptionStatus =
| 'draft'
| 'pending_review'
| 'approved'
| 'rejected'
| 'expired'
| 'revoked';
export type ExceptionSeverity = 'critical' | 'high' | 'medium' | 'low';
export type ExceptionScope = 'global' | 'tenant' | 'asset' | 'component';
export interface ExceptionJustification {
readonly template?: string;
readonly text: string;
readonly attachments?: readonly string[];
}
export interface ExceptionTimebox {
readonly startDate: string;
readonly endDate: string;
readonly autoRenew?: boolean;
readonly maxRenewals?: number;
readonly renewalCount?: number;
}
export interface ExceptionApproval {
readonly approvalId: string;
readonly approvedBy: string;
readonly approvedAt: string;
readonly comment?: string;
}
export interface ExceptionAuditEntry {
readonly auditId: string;
readonly action: string;
readonly actor: string;
readonly timestamp: string;
readonly details?: Record<string, string>;
readonly previousStatus?: ExceptionStatus;
readonly newStatus?: ExceptionStatus;
}
export interface ExceptionScope {
readonly type: ExceptionScope;
readonly tenantId?: string;
readonly assetIds?: readonly string[];
readonly componentPurls?: readonly string[];
readonly vulnIds?: readonly string[];
}
export interface Exception {
readonly schemaVersion?: string;
readonly exceptionId: string;
readonly tenantId: string;
readonly name: string;
readonly displayName?: string;
readonly description?: string;
readonly status: ExceptionStatus;
readonly severity: ExceptionSeverity;
readonly scope: ExceptionScope;
readonly justification: ExceptionJustification;
readonly timebox: ExceptionTimebox;
readonly policyRuleIds?: readonly string[];
readonly approvals?: readonly ExceptionApproval[];
readonly auditTrail?: readonly ExceptionAuditEntry[];
readonly labels?: Record<string, string>;
readonly metadata?: Record<string, string>;
readonly createdBy: string;
readonly createdAt: string;
readonly updatedBy?: string;
readonly updatedAt?: string;
}
export interface ExceptionsQueryOptions {
readonly status?: ExceptionStatus;
readonly severity?: ExceptionSeverity;
readonly scope?: ExceptionScope;
readonly search?: string;
readonly sortBy?: 'createdAt' | 'updatedAt' | 'name' | 'severity' | 'status';
readonly sortOrder?: 'asc' | 'desc';
readonly limit?: number;
readonly continuationToken?: string;
}
export interface ExceptionsResponse {
readonly items: readonly Exception[];
readonly count: number;
readonly continuationToken?: string | null;
}
export interface ExceptionStatusTransition {
readonly exceptionId: string;
readonly newStatus: ExceptionStatus;
readonly comment?: string;
}
export interface ExceptionKanbanColumn {
readonly status: ExceptionStatus;
readonly label: string;
readonly items: readonly Exception[];
readonly count: number;
}
export interface ExceptionStats {
readonly total: number;
readonly byStatus: Record<ExceptionStatus, number>;
readonly bySeverity: Record<ExceptionSeverity, number>;
readonly expiringWithin7Days: number;
readonly pendingApproval: number;
}
/**
* Exception management models for the Exception Center.
*/
export type ExceptionStatus = 'draft' | 'pending' | 'approved' | 'active' | 'expired' | 'revoked';
export type ExceptionType = 'vulnerability' | 'license' | 'policy' | 'entropy' | 'determinism';
export interface Exception {
/** Unique exception ID */
id: string;
/** Short title */
title: string;
/** Detailed justification */
justification: string;
/** Exception type */
type: ExceptionType;
/** Current status */
status: ExceptionStatus;
/** Severity being excepted */
severity: 'critical' | 'high' | 'medium' | 'low';
/** Scope definition */
scope: ExceptionScope;
/** Time constraints */
timebox: ExceptionTimebox;
/** Workflow history */
workflow: ExceptionWorkflow;
/** Audit trail */
auditLog: ExceptionAuditEntry[];
/** Associated findings/violations */
findings: string[];
/** Tags for filtering */
tags: string[];
/** Created timestamp */
createdAt: string;
/** Last updated timestamp */
updatedAt: string;
}
export interface ExceptionScope {
/** Affected images (glob patterns allowed) */
images?: string[];
/** Affected CVEs */
cves?: string[];
/** Affected packages */
packages?: string[];
/** Affected licenses */
licenses?: string[];
/** Affected policy rules */
policyRules?: string[];
/** Tenant scope */
tenantId?: string;
/** Environment scope */
environments?: string[];
}
export interface ExceptionTimebox {
/** Start date */
startsAt: string;
/** Expiration date */
expiresAt: string;
/** Remaining days */
remainingDays: number;
/** Is expired */
isExpired: boolean;
/** Warning threshold (days before expiry) */
warnDays: number;
/** Is in warning period */
isWarning: boolean;
}
export interface ExceptionWorkflow {
/** Current workflow state */
state: ExceptionStatus;
/** Requested by */
requestedBy: string;
/** Requested at */
requestedAt: string;
/** Approved by */
approvedBy?: string;
/** Approved at */
approvedAt?: string;
/** Revoked by */
revokedBy?: string;
/** Revoked at */
revokedAt?: string;
/** Revocation reason */
revocationReason?: string;
/** Required approvers */
requiredApprovers: string[];
/** Current approvals */
approvals: ExceptionApproval[];
}
export interface ExceptionApproval {
/** Approver identity */
approver: string;
/** Decision */
decision: 'approved' | 'rejected';
/** Timestamp */
at: string;
/** Optional comment */
comment?: string;
}
export interface ExceptionAuditEntry {
/** Entry ID */
id: string;
/** Action performed */
action: 'created' | 'submitted' | 'approved' | 'rejected' | 'activated' | 'expired' | 'revoked' | 'edited';
/** Actor */
actor: string;
/** Timestamp */
at: string;
/** Details */
details?: string;
/** Previous values (for edits) */
previousValues?: Record<string, unknown>;
/** New values (for edits) */
newValues?: Record<string, unknown>;
}
export interface ExceptionFilter {
status?: ExceptionStatus[];
type?: ExceptionType[];
severity?: string[];
search?: string;
tags?: string[];
expiringSoon?: boolean;
createdAfter?: string;
createdBefore?: string;
}
export interface ExceptionSortOption {
field: 'createdAt' | 'updatedAt' | 'expiresAt' | 'severity' | 'title';
direction: 'asc' | 'desc';
}
export interface ExceptionTransition {
from: ExceptionStatus;
to: ExceptionStatus;
action: string;
requiresApproval: boolean;
allowedRoles: string[];
}
export const EXCEPTION_TRANSITIONS: ExceptionTransition[] = [
{ from: 'draft', to: 'pending', action: 'Submit for Approval', requiresApproval: false, allowedRoles: ['user', 'admin'] },
{ from: 'pending', to: 'approved', action: 'Approve', requiresApproval: true, allowedRoles: ['approver', 'admin'] },
{ from: 'pending', to: 'draft', action: 'Request Changes', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
{ from: 'approved', to: 'active', action: 'Activate', requiresApproval: false, allowedRoles: ['admin'] },
{ from: 'active', to: 'revoked', action: 'Revoke', requiresApproval: false, allowedRoles: ['admin'] },
{ from: 'pending', to: 'revoked', action: 'Reject', requiresApproval: false, allowedRoles: ['approver', 'admin'] },
];
export const KANBAN_COLUMNS: { status: ExceptionStatus; label: string; color: string }[] = [
{ status: 'draft', label: 'Draft', color: '#9ca3af' },
{ status: 'pending', label: 'Pending Approval', color: '#f59e0b' },
{ status: 'approved', label: 'Approved', color: '#3b82f6' },
{ status: 'active', label: 'Active', color: '#10b981' },
{ status: 'expired', label: 'Expired', color: '#6b7280' },
{ status: 'revoked', label: 'Revoked', color: '#ef4444' },
];

View File

@@ -1,128 +1,128 @@
export interface PolicyPreviewRequestDto {
imageDigest: string;
findings: ReadonlyArray<PolicyPreviewFindingDto>;
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
policy?: PolicyPreviewPolicyDto;
}
export interface PolicyPreviewPolicyDto {
content?: string;
format?: string;
actor?: string;
description?: string;
}
export interface PolicyPreviewFindingDto {
id: string;
severity: string;
environment?: string;
source?: string;
vendor?: string;
license?: string;
image?: string;
repository?: string;
package?: string;
purl?: string;
cve?: string;
path?: string;
layerDigest?: string;
tags?: ReadonlyArray<string>;
}
export interface PolicyPreviewVerdictDto {
findingId: string;
status: string;
ruleName?: string | null;
ruleAction?: string | null;
notes?: string | null;
score?: number | null;
configVersion?: string | null;
inputs?: Readonly<Record<string, number>>;
quietedBy?: string | null;
quiet?: boolean | null;
unknownConfidence?: number | null;
confidenceBand?: string | null;
unknownAgeDays?: number | null;
sourceTrust?: string | null;
reachability?: string | null;
}
export interface PolicyPreviewDiffDto {
findingId: string;
baseline: PolicyPreviewVerdictDto;
projected: PolicyPreviewVerdictDto;
changed: boolean;
}
export interface PolicyPreviewIssueDto {
code: string;
message: string;
severity: string;
path: string;
}
export interface PolicyPreviewResponseDto {
success: boolean;
policyDigest: string;
revisionId?: string | null;
changed: number;
diffs: ReadonlyArray<PolicyPreviewDiffDto>;
issues: ReadonlyArray<PolicyPreviewIssueDto>;
}
export interface PolicyPreviewSample {
previewRequest: PolicyPreviewRequestDto;
previewResponse: PolicyPreviewResponseDto;
}
export interface PolicyReportRequestDto {
imageDigest: string;
findings: ReadonlyArray<PolicyPreviewFindingDto>;
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
}
export interface PolicyReportResponseDto {
report: PolicyReportDocumentDto;
dsse?: DsseEnvelopeDto | null;
}
export interface PolicyReportDocumentDto {
reportId: string;
imageDigest: string;
generatedAt: string;
verdict: string;
policy: PolicyReportPolicyDto;
summary: PolicyReportSummaryDto;
verdicts: ReadonlyArray<PolicyPreviewVerdictDto>;
issues: ReadonlyArray<PolicyPreviewIssueDto>;
}
export interface PolicyReportPolicyDto {
revisionId?: string | null;
digest?: string | null;
}
export interface PolicyReportSummaryDto {
total: number;
blocked: number;
warned: number;
ignored: number;
quieted: number;
}
export interface DsseEnvelopeDto {
payloadType: string;
payload: string;
signatures: ReadonlyArray<DsseSignatureDto>;
}
export interface DsseSignatureDto {
keyId: string;
algorithm: string;
signature: string;
}
export interface PolicyReportSample {
reportRequest: PolicyReportRequestDto;
reportResponse: PolicyReportResponseDto;
}
export interface PolicyPreviewRequestDto {
imageDigest: string;
findings: ReadonlyArray<PolicyPreviewFindingDto>;
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
policy?: PolicyPreviewPolicyDto;
}
export interface PolicyPreviewPolicyDto {
content?: string;
format?: string;
actor?: string;
description?: string;
}
export interface PolicyPreviewFindingDto {
id: string;
severity: string;
environment?: string;
source?: string;
vendor?: string;
license?: string;
image?: string;
repository?: string;
package?: string;
purl?: string;
cve?: string;
path?: string;
layerDigest?: string;
tags?: ReadonlyArray<string>;
}
export interface PolicyPreviewVerdictDto {
findingId: string;
status: string;
ruleName?: string | null;
ruleAction?: string | null;
notes?: string | null;
score?: number | null;
configVersion?: string | null;
inputs?: Readonly<Record<string, number>>;
quietedBy?: string | null;
quiet?: boolean | null;
unknownConfidence?: number | null;
confidenceBand?: string | null;
unknownAgeDays?: number | null;
sourceTrust?: string | null;
reachability?: string | null;
}
export interface PolicyPreviewDiffDto {
findingId: string;
baseline: PolicyPreviewVerdictDto;
projected: PolicyPreviewVerdictDto;
changed: boolean;
}
export interface PolicyPreviewIssueDto {
code: string;
message: string;
severity: string;
path: string;
}
export interface PolicyPreviewResponseDto {
success: boolean;
policyDigest: string;
revisionId?: string | null;
changed: number;
diffs: ReadonlyArray<PolicyPreviewDiffDto>;
issues: ReadonlyArray<PolicyPreviewIssueDto>;
}
export interface PolicyPreviewSample {
previewRequest: PolicyPreviewRequestDto;
previewResponse: PolicyPreviewResponseDto;
}
export interface PolicyReportRequestDto {
imageDigest: string;
findings: ReadonlyArray<PolicyPreviewFindingDto>;
baseline?: ReadonlyArray<PolicyPreviewVerdictDto>;
}
export interface PolicyReportResponseDto {
report: PolicyReportDocumentDto;
dsse?: DsseEnvelopeDto | null;
}
export interface PolicyReportDocumentDto {
reportId: string;
imageDigest: string;
generatedAt: string;
verdict: string;
policy: PolicyReportPolicyDto;
summary: PolicyReportSummaryDto;
verdicts: ReadonlyArray<PolicyPreviewVerdictDto>;
issues: ReadonlyArray<PolicyPreviewIssueDto>;
}
export interface PolicyReportPolicyDto {
revisionId?: string | null;
digest?: string | null;
}
export interface PolicyReportSummaryDto {
total: number;
blocked: number;
warned: number;
ignored: number;
quieted: number;
}
export interface DsseEnvelopeDto {
payloadType: string;
payload: string;
signatures: ReadonlyArray<DsseSignatureDto>;
}
export interface DsseSignatureDto {
keyId: string;
algorithm: string;
signature: string;
}
export interface PolicyReportSample {
reportRequest: PolicyReportRequestDto;
reportResponse: PolicyReportResponseDto;
}

View File

@@ -0,0 +1,163 @@
/**
* Policy gate models for release flow indicators.
*/
export interface PolicyGateStatus {
/** Overall gate status */
status: 'passed' | 'failed' | 'warning' | 'pending' | 'skipped';
/** Policy evaluation ID */
evaluationId: string;
/** Target artifact (image, SBOM, etc.) */
targetRef: string;
/** Policy set that was evaluated */
policySetId: string;
/** Individual gate results */
gates: PolicyGate[];
/** Blocking issues preventing publish */
blockingIssues: PolicyBlockingIssue[];
/** Warning-level issues */
warnings: PolicyWarning[];
/** Remediation hints for failures */
remediationHints: PolicyRemediationHint[];
/** Evaluation timestamp */
evaluatedAt: string;
/** Can the artifact be published? */
canPublish: boolean;
/** Reason if publish is blocked */
blockReason?: string;
}
export interface PolicyGate {
/** Gate identifier */
gateId: string;
/** Human-readable name */
name: string;
/** Gate type */
type: 'determinism' | 'vulnerability' | 'license' | 'signature' | 'entropy' | 'custom';
/** Gate result */
result: 'passed' | 'failed' | 'warning' | 'skipped';
/** Is this gate required for publish? */
required: boolean;
/** Gate-specific details */
details?: Record<string, unknown>;
/** Evidence references */
evidenceRefs?: string[];
}
export interface PolicyBlockingIssue {
/** Issue code */
code: string;
/** Gate that produced this issue */
gateId: string;
/** Issue severity */
severity: 'critical' | 'high';
/** Issue description */
message: string;
/** Affected resource */
resource?: string;
}
export interface PolicyWarning {
/** Warning code */
code: string;
/** Gate that produced this warning */
gateId: string;
/** Warning message */
message: string;
/** Affected resource */
resource?: string;
}
export interface PolicyRemediationHint {
/** Which gate/issue this remediates */
forGate: string;
/** Which issue code */
forCode?: string;
/** Hint title */
title: string;
/** Step-by-step instructions */
steps: string[];
/** Documentation link */
docsUrl?: string;
/** CLI command to run */
cliCommand?: string;
/** Estimated effort */
effort?: 'trivial' | 'easy' | 'moderate' | 'complex';
}
export interface DeterminismGateDetails {
/** Merkle root consistency */
merkleRootConsistent: boolean;
/** Expected Merkle root */
expectedMerkleRoot?: string;
/** Computed Merkle root */
computedMerkleRoot?: string;
/** Fragment verification results */
fragmentResults: {
fragmentId: string;
expected: string;
computed: string;
match: boolean;
}[];
/** Composition file present */
compositionPresent: boolean;
/** Total fragments */
totalFragments: number;
/** Matching fragments */
matchingFragments: number;
}
export interface EntropyGateDetails {
/** Overall entropy score */
entropyScore: number;
/** Score threshold for warning */
warnThreshold: number;
/** Score threshold for block */
blockThreshold: number;
/** Action taken based on score */
action: 'allow' | 'warn' | 'block';
/** High entropy files count */
highEntropyFileCount: number;
/** Suspicious patterns detected */
suspiciousPatterns: string[];
}

View File

@@ -1,373 +1,373 @@
import { Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay } from 'rxjs';
import {
Release,
ReleaseArtifact,
PolicyEvaluation,
PolicyGateResult,
DeterminismGateDetails,
RemediationHint,
DeterminismFeatureFlags,
PolicyGateStatus,
} from './release.models';
/**
* Injection token for Release API client.
*/
export const RELEASE_API = new InjectionToken<ReleaseApi>('RELEASE_API');
/**
* Release API interface.
*/
export interface ReleaseApi {
getRelease(releaseId: string): Observable<Release>;
listReleases(): Observable<readonly Release[]>;
publishRelease(releaseId: string): Observable<Release>;
cancelRelease(releaseId: string): Observable<Release>;
getFeatureFlags(): Observable<DeterminismFeatureFlags>;
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }>;
}
// ============================================================================
// Mock Data Fixtures
// ============================================================================
const determinismPassingGate: PolicyGateResult = {
gateId: 'gate-det-001',
gateType: 'determinism',
name: 'SBOM Determinism',
status: 'passed',
message: 'Merkle root consistent. All fragment attestations verified.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: true,
evidence: {
type: 'determinism',
url: '/scans/scan-abc123?tab=determinism',
details: {
merkleRoot: 'sha256:a1b2c3d4e5f6...',
fragmentCount: 8,
verifiedFragments: 8,
},
},
};
const determinismFailingGate: PolicyGateResult = {
gateId: 'gate-det-002',
gateType: 'determinism',
name: 'SBOM Determinism',
status: 'failed',
message: 'Merkle root mismatch. 2 fragment attestations failed verification.',
evaluatedAt: '2025-11-27T09:30:00Z',
blockingPublish: true,
evidence: {
type: 'determinism',
url: '/scans/scan-def456?tab=determinism',
details: {
merkleRoot: 'sha256:f1e2d3c4b5a6...',
expectedMerkleRoot: 'sha256:9a8b7c6d5e4f...',
fragmentCount: 8,
verifiedFragments: 6,
failedFragments: [
'sha256:layer3digest...',
'sha256:layer5digest...',
],
},
},
remediation: {
gateType: 'determinism',
severity: 'critical',
summary: 'The SBOM composition cannot be independently verified. Fragment attestations for layers 3 and 5 failed DSSE signature verification.',
steps: [
{
action: 'rebuild',
title: 'Rebuild with deterministic toolchain',
description: 'Rebuild the image using Stella Scanner with --deterministic flag to ensure consistent fragment hashes.',
command: 'stella scan --deterministic --sign --push',
documentationUrl: 'https://docs.stellaops.io/scanner/determinism',
automated: false,
},
{
action: 'provide-provenance',
title: 'Provide provenance attestation',
description: 'Ensure build provenance (SLSA Level 2+) is attached to the image manifest.',
documentationUrl: 'https://docs.stellaops.io/provenance',
automated: false,
},
{
action: 'sign-artifact',
title: 'Re-sign with valid key',
description: 'Sign the SBOM fragments with a valid DSSE key registered in your tenant.',
command: 'stella sign --artifact sha256:...',
automated: true,
},
{
action: 'request-exception',
title: 'Request policy exception',
description: 'If this is a known issue with a compensating control, request a time-boxed exception.',
automated: true,
},
],
estimatedEffort: '15-30 minutes',
exceptionAllowed: true,
},
};
const vulnerabilityPassingGate: PolicyGateResult = {
gateId: 'gate-vuln-001',
gateType: 'vulnerability',
name: 'Vulnerability Scan',
status: 'passed',
message: 'No critical or high vulnerabilities. 3 medium, 12 low.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: false,
};
const entropyWarningGate: PolicyGateResult = {
gateId: 'gate-ent-001',
gateType: 'entropy',
name: 'Entropy Analysis',
status: 'warning',
message: 'Image opaque ratio 12% (warn threshold: 10%). Consider providing provenance.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: false,
remediation: {
gateType: 'entropy',
severity: 'medium',
summary: 'High entropy detected in some layers. This may indicate packed/encrypted content.',
steps: [
{
action: 'provide-provenance',
title: 'Provide source provenance',
description: 'Attach build provenance or source mappings for high-entropy binaries.',
automated: false,
},
],
estimatedEffort: '10 minutes',
exceptionAllowed: true,
},
};
const licensePassingGate: PolicyGateResult = {
gateId: 'gate-lic-001',
gateType: 'license',
name: 'License Compliance',
status: 'passed',
message: 'All licenses approved. 45 MIT, 12 Apache-2.0, 3 BSD-3-Clause.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: false,
};
const signaturePassingGate: PolicyGateResult = {
gateId: 'gate-sig-001',
gateType: 'signature',
name: 'Signature Verification',
status: 'passed',
message: 'Image signature verified against tenant keyring.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: true,
};
const signatureFailingGate: PolicyGateResult = {
gateId: 'gate-sig-002',
gateType: 'signature',
name: 'Signature Verification',
status: 'failed',
message: 'No valid signature found. Image must be signed before release.',
evaluatedAt: '2025-11-27T09:30:00Z',
blockingPublish: true,
remediation: {
gateType: 'signature',
severity: 'critical',
summary: 'The image is not signed or the signature cannot be verified.',
steps: [
{
action: 'sign-artifact',
title: 'Sign the image',
description: 'Sign the image using your tenant signing key.',
command: 'cosign sign --key cosign.key myregistry/myimage:v1.2.3',
automated: true,
},
],
estimatedEffort: '2 minutes',
exceptionAllowed: false,
},
};
// Artifacts with policy evaluations
const passingArtifact: ReleaseArtifact = {
artifactId: 'art-001',
name: 'api-service',
tag: 'v1.2.3',
digest: 'sha256:abc123def456789012345678901234567890abcdef',
size: 245_000_000,
createdAt: '2025-11-27T08:00:00Z',
registry: 'registry.stellaops.io/prod',
policyEvaluation: {
evaluationId: 'eval-001',
artifactDigest: 'sha256:abc123def456789012345678901234567890abcdef',
evaluatedAt: '2025-11-27T10:15:00Z',
overallStatus: 'passed',
gates: [
determinismPassingGate,
vulnerabilityPassingGate,
entropyWarningGate,
licensePassingGate,
signaturePassingGate,
],
blockingGates: [],
canPublish: true,
determinismDetails: {
merkleRoot: 'sha256:a1b2c3d4e5f67890abcdef1234567890fedcba0987654321',
merkleRootConsistent: true,
contentHash: 'sha256:content1234567890abcdef',
compositionManifestUri: 'oci://registry.stellaops.io/prod/api-service@sha256:abc123/_composition.json',
fragmentCount: 8,
verifiedFragments: 8,
},
},
};
const failingArtifact: ReleaseArtifact = {
artifactId: 'art-002',
name: 'worker-service',
tag: 'v1.2.3',
digest: 'sha256:def456abc789012345678901234567890fedcba98',
size: 312_000_000,
createdAt: '2025-11-27T07:45:00Z',
registry: 'registry.stellaops.io/prod',
policyEvaluation: {
evaluationId: 'eval-002',
artifactDigest: 'sha256:def456abc789012345678901234567890fedcba98',
evaluatedAt: '2025-11-27T09:30:00Z',
overallStatus: 'failed',
gates: [
determinismFailingGate,
vulnerabilityPassingGate,
licensePassingGate,
signatureFailingGate,
],
blockingGates: ['gate-det-002', 'gate-sig-002'],
canPublish: false,
determinismDetails: {
merkleRoot: 'sha256:f1e2d3c4b5a67890',
merkleRootConsistent: false,
contentHash: 'sha256:content9876543210',
compositionManifestUri: 'oci://registry.stellaops.io/prod/worker-service@sha256:def456/_composition.json',
fragmentCount: 8,
verifiedFragments: 6,
failedFragments: ['sha256:layer3digest...', 'sha256:layer5digest...'],
},
},
};
// Release fixtures
const passingRelease: Release = {
releaseId: 'rel-001',
name: 'Platform v1.2.3',
version: '1.2.3',
status: 'pending_approval',
createdAt: '2025-11-27T08:30:00Z',
createdBy: 'deploy-bot',
artifacts: [passingArtifact],
targetEnvironment: 'production',
notes: 'Feature release with API improvements and bug fixes.',
approvals: [
{
approvalId: 'apr-001',
approver: 'security-team',
decision: 'approved',
comment: 'Security review passed.',
decidedAt: '2025-11-27T09:00:00Z',
},
{
approvalId: 'apr-002',
approver: 'release-manager',
decision: 'pending',
},
],
};
const blockedRelease: Release = {
releaseId: 'rel-002',
name: 'Platform v1.2.4-rc1',
version: '1.2.4-rc1',
status: 'blocked',
createdAt: '2025-11-27T07:00:00Z',
createdBy: 'deploy-bot',
artifacts: [failingArtifact],
targetEnvironment: 'staging',
notes: 'Release candidate blocked due to policy gate failures.',
};
const mixedRelease: Release = {
releaseId: 'rel-003',
name: 'Platform v1.2.5',
version: '1.2.5',
status: 'blocked',
createdAt: '2025-11-27T06:00:00Z',
createdBy: 'ci-pipeline',
artifacts: [passingArtifact, failingArtifact],
targetEnvironment: 'production',
notes: 'Multi-artifact release with mixed policy results.',
};
const mockReleases: readonly Release[] = [passingRelease, blockedRelease, mixedRelease];
const mockFeatureFlags: DeterminismFeatureFlags = {
enabled: true,
blockOnFailure: true,
warnOnly: false,
bypassRoles: ['security-admin', 'release-manager'],
requireApprovalForBypass: true,
};
// ============================================================================
// Mock API Implementation
// ============================================================================
@Injectable({ providedIn: 'root' })
export class MockReleaseApi implements ReleaseApi {
getRelease(releaseId: string): Observable<Release> {
const release = mockReleases.find((r) => r.releaseId === releaseId);
if (!release) {
throw new Error(`Release not found: ${releaseId}`);
}
return of(release).pipe(delay(200));
}
listReleases(): Observable<readonly Release[]> {
return of(mockReleases).pipe(delay(300));
}
publishRelease(releaseId: string): Observable<Release> {
const release = mockReleases.find((r) => r.releaseId === releaseId);
if (!release) {
throw new Error(`Release not found: ${releaseId}`);
}
// Simulate publish (would update status in real implementation)
return of({
...release,
status: 'published',
publishedAt: new Date().toISOString(),
} as Release).pipe(delay(500));
}
cancelRelease(releaseId: string): Observable<Release> {
const release = mockReleases.find((r) => r.releaseId === releaseId);
if (!release) {
throw new Error(`Release not found: ${releaseId}`);
}
return of({
...release,
status: 'cancelled',
} as Release).pipe(delay(300));
}
getFeatureFlags(): Observable<DeterminismFeatureFlags> {
return of(mockFeatureFlags).pipe(delay(100));
}
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }> {
return of({ requestId: `bypass-${Date.now()}` }).pipe(delay(400));
}
}
import { Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay } from 'rxjs';
import {
Release,
ReleaseArtifact,
PolicyEvaluation,
PolicyGateResult,
DeterminismGateDetails,
RemediationHint,
DeterminismFeatureFlags,
PolicyGateStatus,
} from './release.models';
/**
* Injection token for Release API client.
*/
export const RELEASE_API = new InjectionToken<ReleaseApi>('RELEASE_API');
/**
* Release API interface.
*/
export interface ReleaseApi {
getRelease(releaseId: string): Observable<Release>;
listReleases(): Observable<readonly Release[]>;
publishRelease(releaseId: string): Observable<Release>;
cancelRelease(releaseId: string): Observable<Release>;
getFeatureFlags(): Observable<DeterminismFeatureFlags>;
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }>;
}
// ============================================================================
// Mock Data Fixtures
// ============================================================================
const determinismPassingGate: PolicyGateResult = {
gateId: 'gate-det-001',
gateType: 'determinism',
name: 'SBOM Determinism',
status: 'passed',
message: 'Merkle root consistent. All fragment attestations verified.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: true,
evidence: {
type: 'determinism',
url: '/scans/scan-abc123?tab=determinism',
details: {
merkleRoot: 'sha256:a1b2c3d4e5f6...',
fragmentCount: 8,
verifiedFragments: 8,
},
},
};
const determinismFailingGate: PolicyGateResult = {
gateId: 'gate-det-002',
gateType: 'determinism',
name: 'SBOM Determinism',
status: 'failed',
message: 'Merkle root mismatch. 2 fragment attestations failed verification.',
evaluatedAt: '2025-11-27T09:30:00Z',
blockingPublish: true,
evidence: {
type: 'determinism',
url: '/scans/scan-def456?tab=determinism',
details: {
merkleRoot: 'sha256:f1e2d3c4b5a6...',
expectedMerkleRoot: 'sha256:9a8b7c6d5e4f...',
fragmentCount: 8,
verifiedFragments: 6,
failedFragments: [
'sha256:layer3digest...',
'sha256:layer5digest...',
],
},
},
remediation: {
gateType: 'determinism',
severity: 'critical',
summary: 'The SBOM composition cannot be independently verified. Fragment attestations for layers 3 and 5 failed DSSE signature verification.',
steps: [
{
action: 'rebuild',
title: 'Rebuild with deterministic toolchain',
description: 'Rebuild the image using Stella Scanner with --deterministic flag to ensure consistent fragment hashes.',
command: 'stella scan --deterministic --sign --push',
documentationUrl: 'https://docs.stellaops.io/scanner/determinism',
automated: false,
},
{
action: 'provide-provenance',
title: 'Provide provenance attestation',
description: 'Ensure build provenance (SLSA Level 2+) is attached to the image manifest.',
documentationUrl: 'https://docs.stellaops.io/provenance',
automated: false,
},
{
action: 'sign-artifact',
title: 'Re-sign with valid key',
description: 'Sign the SBOM fragments with a valid DSSE key registered in your tenant.',
command: 'stella sign --artifact sha256:...',
automated: true,
},
{
action: 'request-exception',
title: 'Request policy exception',
description: 'If this is a known issue with a compensating control, request a time-boxed exception.',
automated: true,
},
],
estimatedEffort: '15-30 minutes',
exceptionAllowed: true,
},
};
const vulnerabilityPassingGate: PolicyGateResult = {
gateId: 'gate-vuln-001',
gateType: 'vulnerability',
name: 'Vulnerability Scan',
status: 'passed',
message: 'No critical or high vulnerabilities. 3 medium, 12 low.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: false,
};
const entropyWarningGate: PolicyGateResult = {
gateId: 'gate-ent-001',
gateType: 'entropy',
name: 'Entropy Analysis',
status: 'warning',
message: 'Image opaque ratio 12% (warn threshold: 10%). Consider providing provenance.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: false,
remediation: {
gateType: 'entropy',
severity: 'medium',
summary: 'High entropy detected in some layers. This may indicate packed/encrypted content.',
steps: [
{
action: 'provide-provenance',
title: 'Provide source provenance',
description: 'Attach build provenance or source mappings for high-entropy binaries.',
automated: false,
},
],
estimatedEffort: '10 minutes',
exceptionAllowed: true,
},
};
const licensePassingGate: PolicyGateResult = {
gateId: 'gate-lic-001',
gateType: 'license',
name: 'License Compliance',
status: 'passed',
message: 'All licenses approved. 45 MIT, 12 Apache-2.0, 3 BSD-3-Clause.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: false,
};
const signaturePassingGate: PolicyGateResult = {
gateId: 'gate-sig-001',
gateType: 'signature',
name: 'Signature Verification',
status: 'passed',
message: 'Image signature verified against tenant keyring.',
evaluatedAt: '2025-11-27T10:15:00Z',
blockingPublish: true,
};
const signatureFailingGate: PolicyGateResult = {
gateId: 'gate-sig-002',
gateType: 'signature',
name: 'Signature Verification',
status: 'failed',
message: 'No valid signature found. Image must be signed before release.',
evaluatedAt: '2025-11-27T09:30:00Z',
blockingPublish: true,
remediation: {
gateType: 'signature',
severity: 'critical',
summary: 'The image is not signed or the signature cannot be verified.',
steps: [
{
action: 'sign-artifact',
title: 'Sign the image',
description: 'Sign the image using your tenant signing key.',
command: 'cosign sign --key cosign.key myregistry/myimage:v1.2.3',
automated: true,
},
],
estimatedEffort: '2 minutes',
exceptionAllowed: false,
},
};
// Artifacts with policy evaluations
const passingArtifact: ReleaseArtifact = {
artifactId: 'art-001',
name: 'api-service',
tag: 'v1.2.3',
digest: 'sha256:abc123def456789012345678901234567890abcdef',
size: 245_000_000,
createdAt: '2025-11-27T08:00:00Z',
registry: 'registry.stellaops.io/prod',
policyEvaluation: {
evaluationId: 'eval-001',
artifactDigest: 'sha256:abc123def456789012345678901234567890abcdef',
evaluatedAt: '2025-11-27T10:15:00Z',
overallStatus: 'passed',
gates: [
determinismPassingGate,
vulnerabilityPassingGate,
entropyWarningGate,
licensePassingGate,
signaturePassingGate,
],
blockingGates: [],
canPublish: true,
determinismDetails: {
merkleRoot: 'sha256:a1b2c3d4e5f67890abcdef1234567890fedcba0987654321',
merkleRootConsistent: true,
contentHash: 'sha256:content1234567890abcdef',
compositionManifestUri: 'oci://registry.stellaops.io/prod/api-service@sha256:abc123/_composition.json',
fragmentCount: 8,
verifiedFragments: 8,
},
},
};
const failingArtifact: ReleaseArtifact = {
artifactId: 'art-002',
name: 'worker-service',
tag: 'v1.2.3',
digest: 'sha256:def456abc789012345678901234567890fedcba98',
size: 312_000_000,
createdAt: '2025-11-27T07:45:00Z',
registry: 'registry.stellaops.io/prod',
policyEvaluation: {
evaluationId: 'eval-002',
artifactDigest: 'sha256:def456abc789012345678901234567890fedcba98',
evaluatedAt: '2025-11-27T09:30:00Z',
overallStatus: 'failed',
gates: [
determinismFailingGate,
vulnerabilityPassingGate,
licensePassingGate,
signatureFailingGate,
],
blockingGates: ['gate-det-002', 'gate-sig-002'],
canPublish: false,
determinismDetails: {
merkleRoot: 'sha256:f1e2d3c4b5a67890',
merkleRootConsistent: false,
contentHash: 'sha256:content9876543210',
compositionManifestUri: 'oci://registry.stellaops.io/prod/worker-service@sha256:def456/_composition.json',
fragmentCount: 8,
verifiedFragments: 6,
failedFragments: ['sha256:layer3digest...', 'sha256:layer5digest...'],
},
},
};
// Release fixtures
const passingRelease: Release = {
releaseId: 'rel-001',
name: 'Platform v1.2.3',
version: '1.2.3',
status: 'pending_approval',
createdAt: '2025-11-27T08:30:00Z',
createdBy: 'deploy-bot',
artifacts: [passingArtifact],
targetEnvironment: 'production',
notes: 'Feature release with API improvements and bug fixes.',
approvals: [
{
approvalId: 'apr-001',
approver: 'security-team',
decision: 'approved',
comment: 'Security review passed.',
decidedAt: '2025-11-27T09:00:00Z',
},
{
approvalId: 'apr-002',
approver: 'release-manager',
decision: 'pending',
},
],
};
const blockedRelease: Release = {
releaseId: 'rel-002',
name: 'Platform v1.2.4-rc1',
version: '1.2.4-rc1',
status: 'blocked',
createdAt: '2025-11-27T07:00:00Z',
createdBy: 'deploy-bot',
artifacts: [failingArtifact],
targetEnvironment: 'staging',
notes: 'Release candidate blocked due to policy gate failures.',
};
const mixedRelease: Release = {
releaseId: 'rel-003',
name: 'Platform v1.2.5',
version: '1.2.5',
status: 'blocked',
createdAt: '2025-11-27T06:00:00Z',
createdBy: 'ci-pipeline',
artifacts: [passingArtifact, failingArtifact],
targetEnvironment: 'production',
notes: 'Multi-artifact release with mixed policy results.',
};
const mockReleases: readonly Release[] = [passingRelease, blockedRelease, mixedRelease];
const mockFeatureFlags: DeterminismFeatureFlags = {
enabled: true,
blockOnFailure: true,
warnOnly: false,
bypassRoles: ['security-admin', 'release-manager'],
requireApprovalForBypass: true,
};
// ============================================================================
// Mock API Implementation
// ============================================================================
@Injectable({ providedIn: 'root' })
export class MockReleaseApi implements ReleaseApi {
getRelease(releaseId: string): Observable<Release> {
const release = mockReleases.find((r) => r.releaseId === releaseId);
if (!release) {
throw new Error(`Release not found: ${releaseId}`);
}
return of(release).pipe(delay(200));
}
listReleases(): Observable<readonly Release[]> {
return of(mockReleases).pipe(delay(300));
}
publishRelease(releaseId: string): Observable<Release> {
const release = mockReleases.find((r) => r.releaseId === releaseId);
if (!release) {
throw new Error(`Release not found: ${releaseId}`);
}
// Simulate publish (would update status in real implementation)
return of({
...release,
status: 'published',
publishedAt: new Date().toISOString(),
} as Release).pipe(delay(500));
}
cancelRelease(releaseId: string): Observable<Release> {
const release = mockReleases.find((r) => r.releaseId === releaseId);
if (!release) {
throw new Error(`Release not found: ${releaseId}`);
}
return of({
...release,
status: 'cancelled',
} as Release).pipe(delay(300));
}
getFeatureFlags(): Observable<DeterminismFeatureFlags> {
return of(mockFeatureFlags).pipe(delay(100));
}
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }> {
return of({ requestId: `bypass-${Date.now()}` }).pipe(delay(400));
}
}

View File

@@ -1,161 +1,161 @@
/**
* Release and Policy Gate models for UI-POLICY-DET-01.
* Supports determinism-gated release flows with remediation hints.
*/
// Policy gate evaluation status
export type PolicyGateStatus = 'passed' | 'failed' | 'pending' | 'skipped' | 'warning';
// Types of policy gates
export type PolicyGateType =
| 'determinism'
| 'vulnerability'
| 'license'
| 'entropy'
| 'signature'
| 'sbom-completeness'
| 'custom';
// Remediation action types
export type RemediationActionType =
| 'rebuild'
| 'provide-provenance'
| 'sign-artifact'
| 'update-dependency'
| 'request-exception'
| 'manual-review';
/**
* A single remediation step with optional automation support.
*/
export interface RemediationStep {
readonly action: RemediationActionType;
readonly title: string;
readonly description: string;
readonly command?: string; // Optional CLI command to run
readonly documentationUrl?: string;
readonly automated: boolean; // Can be triggered from UI
}
/**
* Remediation hints for a failed policy gate.
*/
export interface RemediationHint {
readonly gateType: PolicyGateType;
readonly severity: 'critical' | 'high' | 'medium' | 'low';
readonly summary: string;
readonly steps: readonly RemediationStep[];
readonly estimatedEffort?: string; // e.g., "5 minutes", "1 hour"
readonly exceptionAllowed: boolean;
}
/**
* Individual policy gate evaluation result.
*/
export interface PolicyGateResult {
readonly gateId: string;
readonly gateType: PolicyGateType;
readonly name: string;
readonly status: PolicyGateStatus;
readonly message: string;
readonly evaluatedAt: string;
readonly blockingPublish: boolean;
readonly evidence?: {
readonly type: string;
readonly url?: string;
readonly details?: Record<string, unknown>;
};
readonly remediation?: RemediationHint;
}
/**
* Determinism-specific gate details.
*/
export interface DeterminismGateDetails {
readonly merkleRoot?: string;
readonly merkleRootConsistent: boolean;
readonly contentHash?: string;
readonly compositionManifestUri?: string;
readonly fragmentCount?: number;
readonly verifiedFragments?: number;
readonly failedFragments?: readonly string[]; // Layer digests that failed
}
/**
* Overall policy evaluation for a release artifact.
*/
export interface PolicyEvaluation {
readonly evaluationId: string;
readonly artifactDigest: string;
readonly evaluatedAt: string;
readonly overallStatus: PolicyGateStatus;
readonly gates: readonly PolicyGateResult[];
readonly blockingGates: readonly string[]; // Gate IDs that block publish
readonly canPublish: boolean;
readonly determinismDetails?: DeterminismGateDetails;
}
/**
* Release artifact with policy evaluation.
*/
export interface ReleaseArtifact {
readonly artifactId: string;
readonly name: string;
readonly tag: string;
readonly digest: string;
readonly size: number;
readonly createdAt: string;
readonly registry: string;
readonly policyEvaluation?: PolicyEvaluation;
}
/**
* Release workflow status.
*/
export type ReleaseStatus =
| 'draft'
| 'pending_approval'
| 'approved'
| 'publishing'
| 'published'
| 'blocked'
| 'cancelled';
/**
* Release with multiple artifacts and policy gates.
*/
export interface Release {
readonly releaseId: string;
readonly name: string;
readonly version: string;
readonly status: ReleaseStatus;
readonly createdAt: string;
readonly createdBy: string;
readonly artifacts: readonly ReleaseArtifact[];
readonly targetEnvironment: string;
readonly notes?: string;
readonly approvals?: readonly ReleaseApproval[];
readonly publishedAt?: string;
}
/**
* Release approval record.
*/
export interface ReleaseApproval {
readonly approvalId: string;
readonly approver: string;
readonly decision: 'approved' | 'rejected' | 'pending';
readonly comment?: string;
readonly decidedAt?: string;
}
/**
* Feature flag configuration for determinism blocking.
*/
export interface DeterminismFeatureFlags {
readonly enabled: boolean;
readonly blockOnFailure: boolean;
readonly warnOnly: boolean;
readonly bypassRoles?: readonly string[];
readonly requireApprovalForBypass: boolean;
}
/**
* Release and Policy Gate models for UI-POLICY-DET-01.
* Supports determinism-gated release flows with remediation hints.
*/
// Policy gate evaluation status
export type PolicyGateStatus = 'passed' | 'failed' | 'pending' | 'skipped' | 'warning';
// Types of policy gates
export type PolicyGateType =
| 'determinism'
| 'vulnerability'
| 'license'
| 'entropy'
| 'signature'
| 'sbom-completeness'
| 'custom';
// Remediation action types
export type RemediationActionType =
| 'rebuild'
| 'provide-provenance'
| 'sign-artifact'
| 'update-dependency'
| 'request-exception'
| 'manual-review';
/**
* A single remediation step with optional automation support.
*/
export interface RemediationStep {
readonly action: RemediationActionType;
readonly title: string;
readonly description: string;
readonly command?: string; // Optional CLI command to run
readonly documentationUrl?: string;
readonly automated: boolean; // Can be triggered from UI
}
/**
* Remediation hints for a failed policy gate.
*/
export interface RemediationHint {
readonly gateType: PolicyGateType;
readonly severity: 'critical' | 'high' | 'medium' | 'low';
readonly summary: string;
readonly steps: readonly RemediationStep[];
readonly estimatedEffort?: string; // e.g., "5 minutes", "1 hour"
readonly exceptionAllowed: boolean;
}
/**
* Individual policy gate evaluation result.
*/
export interface PolicyGateResult {
readonly gateId: string;
readonly gateType: PolicyGateType;
readonly name: string;
readonly status: PolicyGateStatus;
readonly message: string;
readonly evaluatedAt: string;
readonly blockingPublish: boolean;
readonly evidence?: {
readonly type: string;
readonly url?: string;
readonly details?: Record<string, unknown>;
};
readonly remediation?: RemediationHint;
}
/**
* Determinism-specific gate details.
*/
export interface DeterminismGateDetails {
readonly merkleRoot?: string;
readonly merkleRootConsistent: boolean;
readonly contentHash?: string;
readonly compositionManifestUri?: string;
readonly fragmentCount?: number;
readonly verifiedFragments?: number;
readonly failedFragments?: readonly string[]; // Layer digests that failed
}
/**
* Overall policy evaluation for a release artifact.
*/
export interface PolicyEvaluation {
readonly evaluationId: string;
readonly artifactDigest: string;
readonly evaluatedAt: string;
readonly overallStatus: PolicyGateStatus;
readonly gates: readonly PolicyGateResult[];
readonly blockingGates: readonly string[]; // Gate IDs that block publish
readonly canPublish: boolean;
readonly determinismDetails?: DeterminismGateDetails;
}
/**
* Release artifact with policy evaluation.
*/
export interface ReleaseArtifact {
readonly artifactId: string;
readonly name: string;
readonly tag: string;
readonly digest: string;
readonly size: number;
readonly createdAt: string;
readonly registry: string;
readonly policyEvaluation?: PolicyEvaluation;
}
/**
* Release workflow status.
*/
export type ReleaseStatus =
| 'draft'
| 'pending_approval'
| 'approved'
| 'publishing'
| 'published'
| 'blocked'
| 'cancelled';
/**
* Release with multiple artifacts and policy gates.
*/
export interface Release {
readonly releaseId: string;
readonly name: string;
readonly version: string;
readonly status: ReleaseStatus;
readonly createdAt: string;
readonly createdBy: string;
readonly artifacts: readonly ReleaseArtifact[];
readonly targetEnvironment: string;
readonly notes?: string;
readonly approvals?: readonly ReleaseApproval[];
readonly publishedAt?: string;
}
/**
* Release approval record.
*/
export interface ReleaseApproval {
readonly approvalId: string;
readonly approver: string;
readonly decision: 'approved' | 'rejected' | 'pending';
readonly comment?: string;
readonly decidedAt?: string;
}
/**
* Feature flag configuration for determinism blocking.
*/
export interface DeterminismFeatureFlags {
readonly enabled: boolean;
readonly blockOnFailure: boolean;
readonly warnOnly: boolean;
readonly bypassRoles?: readonly string[];
readonly requireApprovalForBypass: boolean;
}

View File

@@ -1,316 +1,316 @@
import { Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay } from 'rxjs';
import {
Vulnerability,
VulnerabilitiesQueryOptions,
VulnerabilitiesResponse,
VulnerabilityStats,
} from './vulnerability.models';
export interface VulnerabilityApi {
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse>;
getVulnerability(vulnId: string): Observable<Vulnerability>;
getStats(): Observable<VulnerabilityStats>;
}
export const VULNERABILITY_API = new InjectionToken<VulnerabilityApi>('VULNERABILITY_API');
const MOCK_VULNERABILITIES: Vulnerability[] = [
{
vulnId: 'vuln-001',
cveId: 'CVE-2021-44228',
title: 'Log4Shell - Remote Code Execution in Apache Log4j',
description: 'Apache Log4j2 2.0-beta9 through 2.15.0 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
severity: 'critical',
cvssScore: 10.0,
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H',
status: 'open',
publishedAt: '2021-12-10T00:00:00Z',
modifiedAt: '2024-06-27T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1',
name: 'log4j-core',
version: '2.14.1',
fixedVersion: '2.17.1',
assetIds: ['asset-web-prod', 'asset-api-prod'],
},
],
references: [
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
'https://logging.apache.org/log4j/2.x/security.html',
],
hasException: false,
},
{
vulnId: 'vuln-002',
cveId: 'CVE-2021-45046',
title: 'Log4j2 Thread Context Message Pattern DoS',
description: 'It was found that the fix to address CVE-2021-44228 in Apache Log4j 2.15.0 was incomplete in certain non-default configurations.',
severity: 'critical',
cvssScore: 9.0,
cvssVector: 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H',
status: 'excepted',
publishedAt: '2021-12-14T00:00:00Z',
modifiedAt: '2023-11-06T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.15.0',
name: 'log4j-core',
version: '2.15.0',
fixedVersion: '2.17.1',
assetIds: ['asset-internal-001'],
},
],
hasException: true,
exceptionId: 'exc-test-001',
},
{
vulnId: 'vuln-003',
cveId: 'CVE-2023-44487',
title: 'HTTP/2 Rapid Reset Attack',
description: 'The HTTP/2 protocol allows a denial of service (server resource consumption) because request cancellation can reset many streams quickly.',
severity: 'high',
cvssScore: 7.5,
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H',
status: 'in_progress',
publishedAt: '2023-10-10T00:00:00Z',
modifiedAt: '2024-05-01T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:golang/golang.org/x/net@0.15.0',
name: 'golang.org/x/net',
version: '0.15.0',
fixedVersion: '0.17.0',
assetIds: ['asset-api-prod', 'asset-worker-prod'],
},
{
purl: 'pkg:npm/nghttp2@1.55.0',
name: 'nghttp2',
version: '1.55.0',
fixedVersion: '1.57.0',
assetIds: ['asset-web-prod'],
},
],
hasException: false,
},
{
vulnId: 'vuln-004',
cveId: 'CVE-2024-21626',
title: 'runc container escape vulnerability',
description: 'runc is a CLI tool for spawning and running containers on Linux. In runc 1.1.11 and earlier, due to an internal file descriptor leak, an attacker could cause a newly-spawned container process to have a working directory in the host filesystem namespace.',
severity: 'high',
cvssScore: 8.6,
cvssVector: 'CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H',
status: 'fixed',
publishedAt: '2024-01-31T00:00:00Z',
modifiedAt: '2024-09-13T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:golang/github.com/opencontainers/runc@1.1.10',
name: 'runc',
version: '1.1.10',
fixedVersion: '1.1.12',
assetIds: ['asset-builder-001'],
},
],
hasException: false,
},
{
vulnId: 'vuln-005',
cveId: 'CVE-2023-38545',
title: 'curl SOCKS5 heap buffer overflow',
description: 'This flaw makes curl overflow a heap based buffer in the SOCKS5 proxy handshake.',
severity: 'high',
cvssScore: 9.8,
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H',
status: 'open',
publishedAt: '2023-10-11T00:00:00Z',
modifiedAt: '2024-06-10T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:deb/debian/curl@7.88.1-10',
name: 'curl',
version: '7.88.1-10',
fixedVersion: '8.4.0',
assetIds: ['asset-web-prod', 'asset-api-prod', 'asset-worker-prod'],
},
],
hasException: false,
},
{
vulnId: 'vuln-006',
cveId: 'CVE-2022-22965',
title: 'Spring4Shell - Spring Framework RCE',
description: 'A Spring MVC or Spring WebFlux application running on JDK 9+ may be vulnerable to remote code execution (RCE) via data binding.',
severity: 'critical',
cvssScore: 9.8,
status: 'wont_fix',
publishedAt: '2022-03-31T00:00:00Z',
modifiedAt: '2024-08-20T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:maven/org.springframework/spring-beans@5.3.17',
name: 'spring-beans',
version: '5.3.17',
fixedVersion: '5.3.18',
assetIds: ['asset-legacy-001'],
},
],
hasException: true,
exceptionId: 'exc-legacy-spring',
},
{
vulnId: 'vuln-007',
cveId: 'CVE-2023-45853',
title: 'MiniZip integer overflow in zipOpenNewFileInZip4_64',
description: 'MiniZip in zlib through 1.3 has an integer overflow and resultant heap-based buffer overflow in zipOpenNewFileInZip4_64.',
severity: 'medium',
cvssScore: 5.3,
status: 'open',
publishedAt: '2023-10-14T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:deb/debian/zlib@1.2.13',
name: 'zlib',
version: '1.2.13',
fixedVersion: '1.3.1',
assetIds: ['asset-web-prod'],
},
],
hasException: false,
},
{
vulnId: 'vuln-008',
cveId: 'CVE-2024-0567',
title: 'GnuTLS certificate verification bypass',
description: 'A vulnerability was found in GnuTLS. The response times to malformed ciphertexts in RSA-PSK ClientKeyExchange differ from response times of ciphertexts with correct PKCS#1 v1.5 padding.',
severity: 'medium',
cvssScore: 5.9,
status: 'open',
publishedAt: '2024-01-16T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:rpm/fedora/gnutls@3.8.2',
name: 'gnutls',
version: '3.8.2',
fixedVersion: '3.8.3',
assetIds: ['asset-internal-001'],
},
],
hasException: false,
},
{
vulnId: 'vuln-009',
cveId: 'CVE-2023-5363',
title: 'OpenSSL POLY1305 MAC implementation corrupts vector registers',
description: 'Issue summary: A bug has been identified in the POLY1305 MAC implementation which corrupts XMM registers on Windows.',
severity: 'low',
cvssScore: 3.7,
status: 'fixed',
publishedAt: '2023-10-24T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:nuget/System.Security.Cryptography.Pkcs@7.0.2',
name: 'System.Security.Cryptography.Pkcs',
version: '7.0.2',
fixedVersion: '8.0.0',
assetIds: ['asset-api-prod'],
},
],
hasException: false,
},
{
vulnId: 'vuln-010',
cveId: 'CVE-2024-24790',
title: 'Go net/netip ParseAddr stack exhaustion',
description: 'The various Is methods (IsLoopback, IsUnspecified, and similar) did not correctly report the status of an empty IP address.',
severity: 'low',
cvssScore: 4.0,
status: 'open',
publishedAt: '2024-06-05T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:golang/stdlib@1.21.10',
name: 'go stdlib',
version: '1.21.10',
fixedVersion: '1.21.11',
assetIds: ['asset-api-prod'],
},
],
hasException: false,
},
];
@Injectable({ providedIn: 'root' })
export class MockVulnerabilityApiService implements VulnerabilityApi {
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse> {
let items = [...MOCK_VULNERABILITIES];
if (options?.severity && options.severity !== 'all') {
items = items.filter((v) => v.severity === options.severity);
}
if (options?.status && options.status !== 'all') {
items = items.filter((v) => v.status === options.status);
}
if (options?.hasException !== undefined) {
items = items.filter((v) => v.hasException === options.hasException);
}
if (options?.search) {
const search = options.search.toLowerCase();
items = items.filter(
(v) =>
v.cveId.toLowerCase().includes(search) ||
v.title.toLowerCase().includes(search) ||
v.description?.toLowerCase().includes(search)
);
}
const total = items.length;
const offset = options?.offset ?? 0;
const limit = options?.limit ?? 50;
items = items.slice(offset, offset + limit);
return of({
items,
total,
hasMore: offset + items.length < total,
}).pipe(delay(200));
}
getVulnerability(vulnId: string): Observable<Vulnerability> {
const vuln = MOCK_VULNERABILITIES.find((v) => v.vulnId === vulnId);
if (!vuln) {
throw new Error(`Vulnerability ${vulnId} not found`);
}
return of(vuln).pipe(delay(100));
}
getStats(): Observable<VulnerabilityStats> {
const vulns = MOCK_VULNERABILITIES;
const stats: VulnerabilityStats = {
total: vulns.length,
bySeverity: {
critical: vulns.filter((v) => v.severity === 'critical').length,
high: vulns.filter((v) => v.severity === 'high').length,
medium: vulns.filter((v) => v.severity === 'medium').length,
low: vulns.filter((v) => v.severity === 'low').length,
unknown: vulns.filter((v) => v.severity === 'unknown').length,
},
byStatus: {
open: vulns.filter((v) => v.status === 'open').length,
fixed: vulns.filter((v) => v.status === 'fixed').length,
wont_fix: vulns.filter((v) => v.status === 'wont_fix').length,
in_progress: vulns.filter((v) => v.status === 'in_progress').length,
excepted: vulns.filter((v) => v.status === 'excepted').length,
},
withExceptions: vulns.filter((v) => v.hasException).length,
criticalOpen: vulns.filter((v) => v.severity === 'critical' && v.status === 'open').length,
};
return of(stats).pipe(delay(150));
}
}
import { Injectable, InjectionToken } from '@angular/core';
import { Observable, of, delay } from 'rxjs';
import {
Vulnerability,
VulnerabilitiesQueryOptions,
VulnerabilitiesResponse,
VulnerabilityStats,
} from './vulnerability.models';
export interface VulnerabilityApi {
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse>;
getVulnerability(vulnId: string): Observable<Vulnerability>;
getStats(): Observable<VulnerabilityStats>;
}
export const VULNERABILITY_API = new InjectionToken<VulnerabilityApi>('VULNERABILITY_API');
const MOCK_VULNERABILITIES: Vulnerability[] = [
{
vulnId: 'vuln-001',
cveId: 'CVE-2021-44228',
title: 'Log4Shell - Remote Code Execution in Apache Log4j',
description: 'Apache Log4j2 2.0-beta9 through 2.15.0 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
severity: 'critical',
cvssScore: 10.0,
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H',
status: 'open',
publishedAt: '2021-12-10T00:00:00Z',
modifiedAt: '2024-06-27T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1',
name: 'log4j-core',
version: '2.14.1',
fixedVersion: '2.17.1',
assetIds: ['asset-web-prod', 'asset-api-prod'],
},
],
references: [
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
'https://logging.apache.org/log4j/2.x/security.html',
],
hasException: false,
},
{
vulnId: 'vuln-002',
cveId: 'CVE-2021-45046',
title: 'Log4j2 Thread Context Message Pattern DoS',
description: 'It was found that the fix to address CVE-2021-44228 in Apache Log4j 2.15.0 was incomplete in certain non-default configurations.',
severity: 'critical',
cvssScore: 9.0,
cvssVector: 'CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H',
status: 'excepted',
publishedAt: '2021-12-14T00:00:00Z',
modifiedAt: '2023-11-06T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.15.0',
name: 'log4j-core',
version: '2.15.0',
fixedVersion: '2.17.1',
assetIds: ['asset-internal-001'],
},
],
hasException: true,
exceptionId: 'exc-test-001',
},
{
vulnId: 'vuln-003',
cveId: 'CVE-2023-44487',
title: 'HTTP/2 Rapid Reset Attack',
description: 'The HTTP/2 protocol allows a denial of service (server resource consumption) because request cancellation can reset many streams quickly.',
severity: 'high',
cvssScore: 7.5,
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H',
status: 'in_progress',
publishedAt: '2023-10-10T00:00:00Z',
modifiedAt: '2024-05-01T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:golang/golang.org/x/net@0.15.0',
name: 'golang.org/x/net',
version: '0.15.0',
fixedVersion: '0.17.0',
assetIds: ['asset-api-prod', 'asset-worker-prod'],
},
{
purl: 'pkg:npm/nghttp2@1.55.0',
name: 'nghttp2',
version: '1.55.0',
fixedVersion: '1.57.0',
assetIds: ['asset-web-prod'],
},
],
hasException: false,
},
{
vulnId: 'vuln-004',
cveId: 'CVE-2024-21626',
title: 'runc container escape vulnerability',
description: 'runc is a CLI tool for spawning and running containers on Linux. In runc 1.1.11 and earlier, due to an internal file descriptor leak, an attacker could cause a newly-spawned container process to have a working directory in the host filesystem namespace.',
severity: 'high',
cvssScore: 8.6,
cvssVector: 'CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H',
status: 'fixed',
publishedAt: '2024-01-31T00:00:00Z',
modifiedAt: '2024-09-13T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:golang/github.com/opencontainers/runc@1.1.10',
name: 'runc',
version: '1.1.10',
fixedVersion: '1.1.12',
assetIds: ['asset-builder-001'],
},
],
hasException: false,
},
{
vulnId: 'vuln-005',
cveId: 'CVE-2023-38545',
title: 'curl SOCKS5 heap buffer overflow',
description: 'This flaw makes curl overflow a heap based buffer in the SOCKS5 proxy handshake.',
severity: 'high',
cvssScore: 9.8,
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H',
status: 'open',
publishedAt: '2023-10-11T00:00:00Z',
modifiedAt: '2024-06-10T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:deb/debian/curl@7.88.1-10',
name: 'curl',
version: '7.88.1-10',
fixedVersion: '8.4.0',
assetIds: ['asset-web-prod', 'asset-api-prod', 'asset-worker-prod'],
},
],
hasException: false,
},
{
vulnId: 'vuln-006',
cveId: 'CVE-2022-22965',
title: 'Spring4Shell - Spring Framework RCE',
description: 'A Spring MVC or Spring WebFlux application running on JDK 9+ may be vulnerable to remote code execution (RCE) via data binding.',
severity: 'critical',
cvssScore: 9.8,
status: 'wont_fix',
publishedAt: '2022-03-31T00:00:00Z',
modifiedAt: '2024-08-20T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:maven/org.springframework/spring-beans@5.3.17',
name: 'spring-beans',
version: '5.3.17',
fixedVersion: '5.3.18',
assetIds: ['asset-legacy-001'],
},
],
hasException: true,
exceptionId: 'exc-legacy-spring',
},
{
vulnId: 'vuln-007',
cveId: 'CVE-2023-45853',
title: 'MiniZip integer overflow in zipOpenNewFileInZip4_64',
description: 'MiniZip in zlib through 1.3 has an integer overflow and resultant heap-based buffer overflow in zipOpenNewFileInZip4_64.',
severity: 'medium',
cvssScore: 5.3,
status: 'open',
publishedAt: '2023-10-14T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:deb/debian/zlib@1.2.13',
name: 'zlib',
version: '1.2.13',
fixedVersion: '1.3.1',
assetIds: ['asset-web-prod'],
},
],
hasException: false,
},
{
vulnId: 'vuln-008',
cveId: 'CVE-2024-0567',
title: 'GnuTLS certificate verification bypass',
description: 'A vulnerability was found in GnuTLS. The response times to malformed ciphertexts in RSA-PSK ClientKeyExchange differ from response times of ciphertexts with correct PKCS#1 v1.5 padding.',
severity: 'medium',
cvssScore: 5.9,
status: 'open',
publishedAt: '2024-01-16T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:rpm/fedora/gnutls@3.8.2',
name: 'gnutls',
version: '3.8.2',
fixedVersion: '3.8.3',
assetIds: ['asset-internal-001'],
},
],
hasException: false,
},
{
vulnId: 'vuln-009',
cveId: 'CVE-2023-5363',
title: 'OpenSSL POLY1305 MAC implementation corrupts vector registers',
description: 'Issue summary: A bug has been identified in the POLY1305 MAC implementation which corrupts XMM registers on Windows.',
severity: 'low',
cvssScore: 3.7,
status: 'fixed',
publishedAt: '2023-10-24T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:nuget/System.Security.Cryptography.Pkcs@7.0.2',
name: 'System.Security.Cryptography.Pkcs',
version: '7.0.2',
fixedVersion: '8.0.0',
assetIds: ['asset-api-prod'],
},
],
hasException: false,
},
{
vulnId: 'vuln-010',
cveId: 'CVE-2024-24790',
title: 'Go net/netip ParseAddr stack exhaustion',
description: 'The various Is methods (IsLoopback, IsUnspecified, and similar) did not correctly report the status of an empty IP address.',
severity: 'low',
cvssScore: 4.0,
status: 'open',
publishedAt: '2024-06-05T00:00:00Z',
affectedComponents: [
{
purl: 'pkg:golang/stdlib@1.21.10',
name: 'go stdlib',
version: '1.21.10',
fixedVersion: '1.21.11',
assetIds: ['asset-api-prod'],
},
],
hasException: false,
},
];
@Injectable({ providedIn: 'root' })
export class MockVulnerabilityApiService implements VulnerabilityApi {
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse> {
let items = [...MOCK_VULNERABILITIES];
if (options?.severity && options.severity !== 'all') {
items = items.filter((v) => v.severity === options.severity);
}
if (options?.status && options.status !== 'all') {
items = items.filter((v) => v.status === options.status);
}
if (options?.hasException !== undefined) {
items = items.filter((v) => v.hasException === options.hasException);
}
if (options?.search) {
const search = options.search.toLowerCase();
items = items.filter(
(v) =>
v.cveId.toLowerCase().includes(search) ||
v.title.toLowerCase().includes(search) ||
v.description?.toLowerCase().includes(search)
);
}
const total = items.length;
const offset = options?.offset ?? 0;
const limit = options?.limit ?? 50;
items = items.slice(offset, offset + limit);
return of({
items,
total,
hasMore: offset + items.length < total,
}).pipe(delay(200));
}
getVulnerability(vulnId: string): Observable<Vulnerability> {
const vuln = MOCK_VULNERABILITIES.find((v) => v.vulnId === vulnId);
if (!vuln) {
throw new Error(`Vulnerability ${vulnId} not found`);
}
return of(vuln).pipe(delay(100));
}
getStats(): Observable<VulnerabilityStats> {
const vulns = MOCK_VULNERABILITIES;
const stats: VulnerabilityStats = {
total: vulns.length,
bySeverity: {
critical: vulns.filter((v) => v.severity === 'critical').length,
high: vulns.filter((v) => v.severity === 'high').length,
medium: vulns.filter((v) => v.severity === 'medium').length,
low: vulns.filter((v) => v.severity === 'low').length,
unknown: vulns.filter((v) => v.severity === 'unknown').length,
},
byStatus: {
open: vulns.filter((v) => v.status === 'open').length,
fixed: vulns.filter((v) => v.status === 'fixed').length,
wont_fix: vulns.filter((v) => v.status === 'wont_fix').length,
in_progress: vulns.filter((v) => v.status === 'in_progress').length,
excepted: vulns.filter((v) => v.status === 'excepted').length,
},
withExceptions: vulns.filter((v) => v.hasException).length,
criticalOpen: vulns.filter((v) => v.severity === 'critical' && v.status === 'open').length,
};
return of(stats).pipe(delay(150));
}
}

View File

@@ -1,50 +1,50 @@
export type VulnerabilitySeverity = 'critical' | 'high' | 'medium' | 'low' | 'unknown';
export type VulnerabilityStatus = 'open' | 'fixed' | 'wont_fix' | 'in_progress' | 'excepted';
export interface Vulnerability {
readonly vulnId: string;
readonly cveId: string;
readonly title: string;
readonly description?: string;
readonly severity: VulnerabilitySeverity;
readonly cvssScore?: number;
readonly cvssVector?: string;
readonly status: VulnerabilityStatus;
readonly publishedAt?: string;
readonly modifiedAt?: string;
readonly affectedComponents: readonly AffectedComponent[];
readonly references?: readonly string[];
readonly hasException?: boolean;
readonly exceptionId?: string;
}
export interface AffectedComponent {
readonly purl: string;
readonly name: string;
readonly version: string;
readonly fixedVersion?: string;
readonly assetIds: readonly string[];
}
export interface VulnerabilityStats {
readonly total: number;
readonly bySeverity: Record<VulnerabilitySeverity, number>;
readonly byStatus: Record<VulnerabilityStatus, number>;
readonly withExceptions: number;
readonly criticalOpen: number;
}
export interface VulnerabilitiesQueryOptions {
readonly severity?: VulnerabilitySeverity | 'all';
readonly status?: VulnerabilityStatus | 'all';
readonly search?: string;
readonly hasException?: boolean;
readonly limit?: number;
readonly offset?: number;
}
export interface VulnerabilitiesResponse {
readonly items: readonly Vulnerability[];
readonly total: number;
readonly hasMore: boolean;
}
export type VulnerabilitySeverity = 'critical' | 'high' | 'medium' | 'low' | 'unknown';
export type VulnerabilityStatus = 'open' | 'fixed' | 'wont_fix' | 'in_progress' | 'excepted';
export interface Vulnerability {
readonly vulnId: string;
readonly cveId: string;
readonly title: string;
readonly description?: string;
readonly severity: VulnerabilitySeverity;
readonly cvssScore?: number;
readonly cvssVector?: string;
readonly status: VulnerabilityStatus;
readonly publishedAt?: string;
readonly modifiedAt?: string;
readonly affectedComponents: readonly AffectedComponent[];
readonly references?: readonly string[];
readonly hasException?: boolean;
readonly exceptionId?: string;
}
export interface AffectedComponent {
readonly purl: string;
readonly name: string;
readonly version: string;
readonly fixedVersion?: string;
readonly assetIds: readonly string[];
}
export interface VulnerabilityStats {
readonly total: number;
readonly bySeverity: Record<VulnerabilitySeverity, number>;
readonly byStatus: Record<VulnerabilityStatus, number>;
readonly withExceptions: number;
readonly criticalOpen: number;
}
export interface VulnerabilitiesQueryOptions {
readonly severity?: VulnerabilitySeverity | 'all';
readonly status?: VulnerabilityStatus | 'all';
readonly search?: string;
readonly hasException?: boolean;
readonly limit?: number;
readonly offset?: number;
}
export interface VulnerabilitiesResponse {
readonly items: readonly Vulnerability[];
readonly total: number;
readonly hasMore: boolean;
}

View File

@@ -1,125 +1,125 @@
import { Injectable, InjectionToken, signal, computed } from '@angular/core';
import {
StellaOpsScopes,
StellaOpsScope,
ScopeGroups,
hasScope,
hasAllScopes,
hasAnyScope,
} from './scopes';
/**
* User info from authentication.
*/
export interface AuthUser {
readonly id: string;
readonly email: string;
readonly name: string;
readonly tenantId: string;
readonly tenantName: string;
readonly roles: readonly string[];
readonly scopes: readonly StellaOpsScope[];
}
/**
* Injection token for Auth service.
*/
export const AUTH_SERVICE = new InjectionToken<AuthService>('AUTH_SERVICE');
/**
* Auth service interface.
*/
export interface AuthService {
readonly isAuthenticated: ReturnType<typeof signal<boolean>>;
readonly user: ReturnType<typeof signal<AuthUser | null>>;
readonly scopes: ReturnType<typeof computed<readonly StellaOpsScope[]>>;
hasScope(scope: StellaOpsScope): boolean;
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean;
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean;
canViewGraph(): boolean;
canEditGraph(): boolean;
canExportGraph(): boolean;
canSimulate(): boolean;
}
// ============================================================================
// Mock Auth Service
// ============================================================================
const MOCK_USER: AuthUser = {
id: 'user-001',
email: 'developer@example.com',
name: 'Developer User',
tenantId: 'tenant-001',
tenantName: 'Acme Corp',
roles: ['developer', 'security-analyst'],
scopes: [
// Graph permissions
StellaOpsScopes.GRAPH_READ,
StellaOpsScopes.GRAPH_WRITE,
StellaOpsScopes.GRAPH_SIMULATE,
StellaOpsScopes.GRAPH_EXPORT,
// SBOM permissions
StellaOpsScopes.SBOM_READ,
// Policy permissions
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_EVALUATE,
StellaOpsScopes.POLICY_SIMULATE,
// Scanner permissions
StellaOpsScopes.SCANNER_READ,
// Exception permissions
StellaOpsScopes.EXCEPTION_READ,
StellaOpsScopes.EXCEPTION_WRITE,
// Release permissions
StellaOpsScopes.RELEASE_READ,
// AOC permissions
StellaOpsScopes.AOC_READ,
],
};
@Injectable({ providedIn: 'root' })
export class MockAuthService implements AuthService {
readonly isAuthenticated = signal(true);
readonly user = signal<AuthUser | null>(MOCK_USER);
readonly scopes = computed(() => {
const u = this.user();
return u?.scopes ?? [];
});
hasScope(scope: StellaOpsScope): boolean {
return hasScope(this.scopes(), scope);
}
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean {
return hasAllScopes(this.scopes(), scopes);
}
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean {
return hasAnyScope(this.scopes(), scopes);
}
canViewGraph(): boolean {
return this.hasScope(StellaOpsScopes.GRAPH_READ);
}
canEditGraph(): boolean {
return this.hasScope(StellaOpsScopes.GRAPH_WRITE);
}
canExportGraph(): boolean {
return this.hasScope(StellaOpsScopes.GRAPH_EXPORT);
}
canSimulate(): boolean {
return this.hasAnyScope([
StellaOpsScopes.GRAPH_SIMULATE,
StellaOpsScopes.POLICY_SIMULATE,
]);
}
}
// Re-export scopes for convenience
export { StellaOpsScopes, ScopeGroups } from './scopes';
export type { StellaOpsScope } from './scopes';
import { Injectable, InjectionToken, signal, computed } from '@angular/core';
import {
StellaOpsScopes,
StellaOpsScope,
ScopeGroups,
hasScope,
hasAllScopes,
hasAnyScope,
} from './scopes';
/**
* User info from authentication.
*/
export interface AuthUser {
readonly id: string;
readonly email: string;
readonly name: string;
readonly tenantId: string;
readonly tenantName: string;
readonly roles: readonly string[];
readonly scopes: readonly StellaOpsScope[];
}
/**
* Injection token for Auth service.
*/
export const AUTH_SERVICE = new InjectionToken<AuthService>('AUTH_SERVICE');
/**
* Auth service interface.
*/
export interface AuthService {
readonly isAuthenticated: ReturnType<typeof signal<boolean>>;
readonly user: ReturnType<typeof signal<AuthUser | null>>;
readonly scopes: ReturnType<typeof computed<readonly StellaOpsScope[]>>;
hasScope(scope: StellaOpsScope): boolean;
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean;
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean;
canViewGraph(): boolean;
canEditGraph(): boolean;
canExportGraph(): boolean;
canSimulate(): boolean;
}
// ============================================================================
// Mock Auth Service
// ============================================================================
const MOCK_USER: AuthUser = {
id: 'user-001',
email: 'developer@example.com',
name: 'Developer User',
tenantId: 'tenant-001',
tenantName: 'Acme Corp',
roles: ['developer', 'security-analyst'],
scopes: [
// Graph permissions
StellaOpsScopes.GRAPH_READ,
StellaOpsScopes.GRAPH_WRITE,
StellaOpsScopes.GRAPH_SIMULATE,
StellaOpsScopes.GRAPH_EXPORT,
// SBOM permissions
StellaOpsScopes.SBOM_READ,
// Policy permissions
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_EVALUATE,
StellaOpsScopes.POLICY_SIMULATE,
// Scanner permissions
StellaOpsScopes.SCANNER_READ,
// Exception permissions
StellaOpsScopes.EXCEPTION_READ,
StellaOpsScopes.EXCEPTION_WRITE,
// Release permissions
StellaOpsScopes.RELEASE_READ,
// AOC permissions
StellaOpsScopes.AOC_READ,
],
};
@Injectable({ providedIn: 'root' })
export class MockAuthService implements AuthService {
readonly isAuthenticated = signal(true);
readonly user = signal<AuthUser | null>(MOCK_USER);
readonly scopes = computed(() => {
const u = this.user();
return u?.scopes ?? [];
});
hasScope(scope: StellaOpsScope): boolean {
return hasScope(this.scopes(), scope);
}
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean {
return hasAllScopes(this.scopes(), scopes);
}
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean {
return hasAnyScope(this.scopes(), scopes);
}
canViewGraph(): boolean {
return this.hasScope(StellaOpsScopes.GRAPH_READ);
}
canEditGraph(): boolean {
return this.hasScope(StellaOpsScopes.GRAPH_WRITE);
}
canExportGraph(): boolean {
return this.hasScope(StellaOpsScopes.GRAPH_EXPORT);
}
canSimulate(): boolean {
return this.hasAnyScope([
StellaOpsScopes.GRAPH_SIMULATE,
StellaOpsScopes.POLICY_SIMULATE,
]);
}
}
// Re-export scopes for convenience
export { StellaOpsScopes, ScopeGroups } from './scopes';
export type { StellaOpsScope } from './scopes';

View File

@@ -1,16 +1,16 @@
export {
StellaOpsScopes,
StellaOpsScope,
ScopeGroups,
ScopeLabels,
hasScope,
hasAllScopes,
hasAnyScope,
} from './scopes';
export {
AuthUser,
AuthService,
AUTH_SERVICE,
MockAuthService,
} from './auth.service';
export {
StellaOpsScopes,
StellaOpsScope,
ScopeGroups,
ScopeLabels,
hasScope,
hasAllScopes,
hasAnyScope,
} from './scopes';
export {
AuthUser,
AuthService,
AUTH_SERVICE,
MockAuthService,
} from './auth.service';

View File

@@ -1,166 +1,166 @@
/**
* StellaOps OAuth2 Scopes - Stub implementation for UI-GRAPH-21-001.
*
* This is a stub implementation to unblock Graph Explorer development.
* Will be replaced by generated SDK exports once SPRINT_0208_0001_0001_sdk delivers.
*
* @see docs/modules/platform/architecture-overview.md
*/
/**
* All available StellaOps OAuth2 scopes.
*/
export const StellaOpsScopes = {
// Graph scopes
GRAPH_READ: 'graph:read',
GRAPH_WRITE: 'graph:write',
GRAPH_ADMIN: 'graph:admin',
GRAPH_EXPORT: 'graph:export',
GRAPH_SIMULATE: 'graph:simulate',
// SBOM scopes
SBOM_READ: 'sbom:read',
SBOM_WRITE: 'sbom:write',
SBOM_ATTEST: 'sbom:attest',
// Scanner scopes
SCANNER_READ: 'scanner:read',
SCANNER_WRITE: 'scanner:write',
SCANNER_SCAN: 'scanner:scan',
// Policy scopes
POLICY_READ: 'policy:read',
POLICY_WRITE: 'policy:write',
POLICY_EVALUATE: 'policy:evaluate',
POLICY_SIMULATE: 'policy:simulate',
// Exception scopes
EXCEPTION_READ: 'exception:read',
EXCEPTION_WRITE: 'exception:write',
EXCEPTION_APPROVE: 'exception:approve',
// Release scopes
RELEASE_READ: 'release:read',
RELEASE_WRITE: 'release:write',
RELEASE_PUBLISH: 'release:publish',
RELEASE_BYPASS: 'release:bypass',
// AOC scopes
AOC_READ: 'aoc:read',
AOC_VERIFY: 'aoc:verify',
// Admin scopes
ADMIN: 'admin',
TENANT_ADMIN: 'tenant:admin',
} as const;
export type StellaOpsScope = (typeof StellaOpsScopes)[keyof typeof StellaOpsScopes];
/**
* Scope groupings for common use cases.
*/
export const ScopeGroups = {
GRAPH_VIEWER: [
StellaOpsScopes.GRAPH_READ,
StellaOpsScopes.SBOM_READ,
StellaOpsScopes.POLICY_READ,
] as const,
GRAPH_EDITOR: [
StellaOpsScopes.GRAPH_READ,
StellaOpsScopes.GRAPH_WRITE,
StellaOpsScopes.SBOM_READ,
StellaOpsScopes.SBOM_WRITE,
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_EVALUATE,
] as const,
GRAPH_ADMIN: [
StellaOpsScopes.GRAPH_READ,
StellaOpsScopes.GRAPH_WRITE,
StellaOpsScopes.GRAPH_ADMIN,
StellaOpsScopes.GRAPH_EXPORT,
StellaOpsScopes.GRAPH_SIMULATE,
] as const,
RELEASE_MANAGER: [
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.RELEASE_WRITE,
StellaOpsScopes.RELEASE_PUBLISH,
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_EVALUATE,
] as const,
SECURITY_ADMIN: [
StellaOpsScopes.EXCEPTION_READ,
StellaOpsScopes.EXCEPTION_WRITE,
StellaOpsScopes.EXCEPTION_APPROVE,
StellaOpsScopes.RELEASE_BYPASS,
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_WRITE,
] as const,
} as const;
/**
* Human-readable labels for scopes.
*/
export const ScopeLabels: Record<StellaOpsScope, string> = {
'graph:read': 'View Graph',
'graph:write': 'Edit Graph',
'graph:admin': 'Administer Graph',
'graph:export': 'Export Graph Data',
'graph:simulate': 'Run Graph Simulations',
'sbom:read': 'View SBOMs',
'sbom:write': 'Create/Edit SBOMs',
'sbom:attest': 'Attest SBOMs',
'scanner:read': 'View Scan Results',
'scanner:write': 'Configure Scanner',
'scanner:scan': 'Trigger Scans',
'policy:read': 'View Policies',
'policy:write': 'Edit Policies',
'policy:evaluate': 'Evaluate Policies',
'policy:simulate': 'Simulate Policy Changes',
'exception:read': 'View Exceptions',
'exception:write': 'Create Exceptions',
'exception:approve': 'Approve Exceptions',
'release:read': 'View Releases',
'release:write': 'Create Releases',
'release:publish': 'Publish Releases',
'release:bypass': 'Bypass Release Gates',
'aoc:read': 'View AOC Status',
'aoc:verify': 'Trigger AOC Verification',
'admin': 'System Administrator',
'tenant:admin': 'Tenant Administrator',
};
/**
* Check if a set of scopes includes a required scope.
*/
export function hasScope(
userScopes: readonly string[],
requiredScope: StellaOpsScope
): boolean {
return userScopes.includes(requiredScope) || userScopes.includes(StellaOpsScopes.ADMIN);
}
/**
* Check if a set of scopes includes all required scopes.
*/
export function hasAllScopes(
userScopes: readonly string[],
requiredScopes: readonly StellaOpsScope[]
): boolean {
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
return requiredScopes.every((scope) => userScopes.includes(scope));
}
/**
* Check if a set of scopes includes any of the required scopes.
*/
export function hasAnyScope(
userScopes: readonly string[],
requiredScopes: readonly StellaOpsScope[]
): boolean {
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
return requiredScopes.some((scope) => userScopes.includes(scope));
}
/**
* StellaOps OAuth2 Scopes - Stub implementation for UI-GRAPH-21-001.
*
* This is a stub implementation to unblock Graph Explorer development.
* Will be replaced by generated SDK exports once SPRINT_0208_0001_0001_sdk delivers.
*
* @see docs/modules/platform/architecture-overview.md
*/
/**
* All available StellaOps OAuth2 scopes.
*/
export const StellaOpsScopes = {
// Graph scopes
GRAPH_READ: 'graph:read',
GRAPH_WRITE: 'graph:write',
GRAPH_ADMIN: 'graph:admin',
GRAPH_EXPORT: 'graph:export',
GRAPH_SIMULATE: 'graph:simulate',
// SBOM scopes
SBOM_READ: 'sbom:read',
SBOM_WRITE: 'sbom:write',
SBOM_ATTEST: 'sbom:attest',
// Scanner scopes
SCANNER_READ: 'scanner:read',
SCANNER_WRITE: 'scanner:write',
SCANNER_SCAN: 'scanner:scan',
// Policy scopes
POLICY_READ: 'policy:read',
POLICY_WRITE: 'policy:write',
POLICY_EVALUATE: 'policy:evaluate',
POLICY_SIMULATE: 'policy:simulate',
// Exception scopes
EXCEPTION_READ: 'exception:read',
EXCEPTION_WRITE: 'exception:write',
EXCEPTION_APPROVE: 'exception:approve',
// Release scopes
RELEASE_READ: 'release:read',
RELEASE_WRITE: 'release:write',
RELEASE_PUBLISH: 'release:publish',
RELEASE_BYPASS: 'release:bypass',
// AOC scopes
AOC_READ: 'aoc:read',
AOC_VERIFY: 'aoc:verify',
// Admin scopes
ADMIN: 'admin',
TENANT_ADMIN: 'tenant:admin',
} as const;
export type StellaOpsScope = (typeof StellaOpsScopes)[keyof typeof StellaOpsScopes];
/**
* Scope groupings for common use cases.
*/
export const ScopeGroups = {
GRAPH_VIEWER: [
StellaOpsScopes.GRAPH_READ,
StellaOpsScopes.SBOM_READ,
StellaOpsScopes.POLICY_READ,
] as const,
GRAPH_EDITOR: [
StellaOpsScopes.GRAPH_READ,
StellaOpsScopes.GRAPH_WRITE,
StellaOpsScopes.SBOM_READ,
StellaOpsScopes.SBOM_WRITE,
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_EVALUATE,
] as const,
GRAPH_ADMIN: [
StellaOpsScopes.GRAPH_READ,
StellaOpsScopes.GRAPH_WRITE,
StellaOpsScopes.GRAPH_ADMIN,
StellaOpsScopes.GRAPH_EXPORT,
StellaOpsScopes.GRAPH_SIMULATE,
] as const,
RELEASE_MANAGER: [
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.RELEASE_WRITE,
StellaOpsScopes.RELEASE_PUBLISH,
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_EVALUATE,
] as const,
SECURITY_ADMIN: [
StellaOpsScopes.EXCEPTION_READ,
StellaOpsScopes.EXCEPTION_WRITE,
StellaOpsScopes.EXCEPTION_APPROVE,
StellaOpsScopes.RELEASE_BYPASS,
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_WRITE,
] as const,
} as const;
/**
* Human-readable labels for scopes.
*/
export const ScopeLabels: Record<StellaOpsScope, string> = {
'graph:read': 'View Graph',
'graph:write': 'Edit Graph',
'graph:admin': 'Administer Graph',
'graph:export': 'Export Graph Data',
'graph:simulate': 'Run Graph Simulations',
'sbom:read': 'View SBOMs',
'sbom:write': 'Create/Edit SBOMs',
'sbom:attest': 'Attest SBOMs',
'scanner:read': 'View Scan Results',
'scanner:write': 'Configure Scanner',
'scanner:scan': 'Trigger Scans',
'policy:read': 'View Policies',
'policy:write': 'Edit Policies',
'policy:evaluate': 'Evaluate Policies',
'policy:simulate': 'Simulate Policy Changes',
'exception:read': 'View Exceptions',
'exception:write': 'Create Exceptions',
'exception:approve': 'Approve Exceptions',
'release:read': 'View Releases',
'release:write': 'Create Releases',
'release:publish': 'Publish Releases',
'release:bypass': 'Bypass Release Gates',
'aoc:read': 'View AOC Status',
'aoc:verify': 'Trigger AOC Verification',
'admin': 'System Administrator',
'tenant:admin': 'Tenant Administrator',
};
/**
* Check if a set of scopes includes a required scope.
*/
export function hasScope(
userScopes: readonly string[],
requiredScope: StellaOpsScope
): boolean {
return userScopes.includes(requiredScope) || userScopes.includes(StellaOpsScopes.ADMIN);
}
/**
* Check if a set of scopes includes all required scopes.
*/
export function hasAllScopes(
userScopes: readonly string[],
requiredScopes: readonly StellaOpsScope[]
): boolean {
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
return requiredScopes.every((scope) => userScopes.includes(scope));
}
/**
* Check if a set of scopes includes any of the required scopes.
*/
export function hasAnyScope(
userScopes: readonly string[],
requiredScopes: readonly StellaOpsScope[]
): boolean {
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
return requiredScopes.some((scope) => userScopes.includes(scope));
}

View File

@@ -0,0 +1,179 @@
<div class="verify-action" [class]="'state-' + state()">
<!-- Action Header -->
<div class="action-header">
<div class="action-info">
<span class="status-icon">{{ statusIcon() }}</span>
<div class="action-text">
<h4 class="action-title">Verify Last {{ windowHours() }} Hours</h4>
<p class="action-desc">{{ statusLabel() }}</p>
</div>
</div>
<div class="action-buttons">
@if (state() === 'idle' || state() === 'completed' || state() === 'error') {
<button class="btn-verify" (click)="runVerification()">
@if (state() === 'idle') {
Run Verification
} @else {
Re-run
}
</button>
}
<button class="btn-cli" (click)="toggleCliGuidance()" [class.active]="showCliGuidance()">
CLI
</button>
</div>
</div>
<!-- Progress Bar -->
@if (state() === 'running') {
<div class="progress-section">
<div class="progress-bar">
<div class="progress-fill" [style.width]="progress() + '%'"></div>
</div>
<span class="progress-text">{{ progress() | number:'1.0-0' }}%</span>
</div>
}
<!-- Error State -->
@if (state() === 'error' && error()) {
<div class="error-banner">
<span class="error-icon">[X]</span>
<span class="error-message">{{ error() }}</span>
<button class="btn-retry" (click)="runVerification()">Retry</button>
</div>
}
<!-- Results -->
@if (state() === 'completed' && result()) {
<div class="results-section">
<!-- Summary Stats -->
<div class="results-summary">
<div class="stat-card" [class.success]="result()!.status === 'passed'">
<span class="stat-value">{{ result()!.checkedCount | number }}</span>
<span class="stat-label">Documents Checked</span>
</div>
<div class="stat-card success">
<span class="stat-value">{{ result()!.passedCount | number }}</span>
<span class="stat-label">Passed</span>
</div>
<div class="stat-card" [class.error]="result()!.failedCount > 0">
<span class="stat-value">{{ result()!.failedCount | number }}</span>
<span class="stat-label">Failed</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ resultSummary()?.passRate }}%</span>
<span class="stat-label">Pass Rate</span>
</div>
</div>
<!-- Violations Preview -->
@if (result()!.violations.length > 0) {
<div class="violations-preview">
<h5 class="preview-title">
Violations Found
<span class="violation-count">{{ result()!.violations.length }}</span>
</h5>
<!-- Violation codes breakdown -->
<div class="code-breakdown">
@for (code of resultSummary()?.uniqueCodes || []; track code) {
<span class="code-chip">
{{ code }}
<span class="code-count">
{{ result()!.violations.filter(v => v.violationCode === code).length }}
</span>
</span>
}
</div>
<!-- Sample violations -->
<ul class="violations-list">
@for (v of result()!.violations.slice(0, 3); track v.documentId + v.violationCode) {
<li class="violation-item">
<button class="violation-btn" (click)="onSelectViolation(v)">
<span class="v-code">{{ v.violationCode }}</span>
<span class="v-doc">{{ v.documentId | slice:0:20 }}...</span>
@if (v.field) {
<span class="v-field">{{ v.field }}</span>
}
</button>
</li>
}
@if (result()!.violations.length > 3) {
<li class="more-violations">
+ {{ result()!.violations.length - 3 }} more violations
</li>
}
</ul>
</div>
} @else {
<div class="no-violations">
<span class="success-icon">[+]</span>
<span>No violations found in the last {{ windowHours() }} hours</span>
</div>
}
<!-- Completion Info -->
<div class="completion-info">
<span class="verify-id">ID: {{ result()!.verificationId | slice:0:12 }}</span>
<span class="verify-time">Completed: {{ result()!.completedAt | date:'medium' }}</span>
</div>
</div>
}
<!-- CLI Guidance Panel -->
@if (showCliGuidance()) {
<div class="cli-guidance">
<h5 class="cli-title">CLI Parity</h5>
<p class="cli-desc">{{ cliGuidance.description }}</p>
<!-- Current Command -->
<div class="cli-command-section">
<label class="cli-label">Equivalent Command</label>
<div class="cli-command">
<code>{{ getCliCommand() }}</code>
<button class="btn-copy" (click)="copyCommand(getCliCommand())" title="Copy">
[C]
</button>
</div>
</div>
<!-- Available Flags -->
<div class="cli-flags-section">
<label class="cli-label">Available Flags</label>
<table class="flags-table">
<tbody>
@for (flag of cliGuidance.flags; track flag.flag) {
<tr>
<td class="flag-name"><code>{{ flag.flag }}</code></td>
<td class="flag-desc">{{ flag.description }}</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Examples -->
<div class="cli-examples-section">
<label class="cli-label">Examples</label>
<div class="examples-list">
@for (example of cliGuidance.examples; track example) {
<div class="example-item">
<code>{{ example }}</code>
<button class="btn-copy" (click)="copyCommand(example)" title="Copy">
[C]
</button>
</div>
}
</div>
</div>
<!-- Install hint -->
<div class="install-hint">
<span class="hint-icon">[i]</span>
<span>Install CLI: <code>npm install -g @stellaops/cli</code></span>
</div>
</div>
}
</div>

View File

@@ -0,0 +1,517 @@
.verify-action {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
background: var(--color-bg-card, white);
overflow: hidden;
&.state-running {
.action-header {
background: var(--color-info-bg, #f0f9ff);
border-left: 4px solid var(--color-info, #2563eb);
}
.status-icon { color: var(--color-info, #2563eb); }
}
&.state-completed {
.action-header {
background: var(--color-success-bg, #ecfdf5);
border-left: 4px solid var(--color-success, #059669);
}
.status-icon { color: var(--color-success, #059669); }
}
&.state-error {
.action-header {
background: var(--color-error-bg, #fef2f2);
border-left: 4px solid var(--color-error, #dc2626);
}
.status-icon { color: var(--color-error, #dc2626); }
}
}
.action-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--color-bg-subtle, #f9fafb);
border-left: 4px solid var(--color-border, #e5e7eb);
flex-wrap: wrap;
}
.action-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.status-icon {
font-family: monospace;
font-weight: 700;
font-size: 1rem;
color: var(--color-text-muted, #6b7280);
}
.action-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.action-title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text, #111827);
}
.action-desc {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
.btn-verify {
padding: 0.5rem 1rem;
background: var(--color-primary, #2563eb);
border: none;
border-radius: 4px;
font-size: 0.8125rem;
font-weight: 600;
color: white;
cursor: pointer;
&:hover {
background: var(--color-primary-dark, #1d4ed8);
}
}
.btn-cli {
padding: 0.5rem 0.75rem;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-size: 0.8125rem;
font-family: monospace;
cursor: pointer;
color: var(--color-text-muted, #6b7280);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
&.active {
background: var(--color-primary, #2563eb);
color: white;
border-color: var(--color-primary, #2563eb);
}
}
// Progress
.progress-section {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.progress-bar {
flex: 1;
height: 8px;
background: var(--color-bg-subtle, #e5e7eb);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--color-primary, #2563eb);
border-radius: 4px;
transition: width 0.2s ease;
}
.progress-text {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted, #6b7280);
min-width: 40px;
text-align: right;
}
// Error
.error-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--color-error-bg, #fef2f2);
border-top: 1px solid var(--color-error-border, #fecaca);
}
.error-icon {
font-family: monospace;
font-weight: 700;
color: var(--color-error, #dc2626);
}
.error-message {
flex: 1;
font-size: 0.8125rem;
color: var(--color-error, #dc2626);
}
.btn-retry {
padding: 0.25rem 0.75rem;
background: var(--color-error, #dc2626);
border: none;
border-radius: 4px;
font-size: 0.75rem;
color: white;
cursor: pointer;
&:hover {
background: var(--color-error-dark, #b91c1c);
}
}
// Results
.results-section {
padding: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.results-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.stat-card {
padding: 0.75rem;
background: var(--color-bg-subtle, #f9fafb);
border-radius: 6px;
text-align: center;
&.success {
background: var(--color-success-bg, #ecfdf5);
.stat-value { color: var(--color-success, #059669); }
}
&.error {
background: var(--color-error-bg, #fef2f2);
.stat-value { color: var(--color-error, #dc2626); }
}
}
.stat-value {
display: block;
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text, #111827);
}
.stat-label {
font-size: 0.6875rem;
color: var(--color-text-muted, #6b7280);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.violations-preview {
margin-bottom: 1rem;
}
.preview-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text, #374151);
margin: 0 0 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.violation-count {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
background: var(--color-error-bg, #fef2f2);
color: var(--color-error, #dc2626);
border-radius: 10px;
font-weight: normal;
}
.code-breakdown {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-bottom: 0.75rem;
}
.code-chip {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--color-bg-subtle, #f3f4f6);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-family: monospace;
font-size: 0.75rem;
}
.code-count {
font-size: 0.625rem;
padding: 0 0.25rem;
background: var(--color-error, #dc2626);
color: white;
border-radius: 8px;
}
.violations-list {
list-style: none;
padding: 0;
margin: 0;
}
.violation-item {
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
&:last-child {
border-bottom: none;
}
}
.violation-btn {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
&:hover {
background: var(--color-bg-hover, #f9fafb);
}
}
.v-code {
font-family: monospace;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-error, #dc2626);
}
.v-doc {
font-family: monospace;
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
}
.v-field {
font-size: 0.6875rem;
padding: 0.125rem 0.25rem;
background: var(--color-warning-bg, #fef3c7);
border-radius: 2px;
color: var(--color-warning-dark, #92400e);
}
.more-violations {
padding: 0.5rem;
font-size: 0.75rem;
color: var(--color-text-muted, #9ca3af);
font-style: italic;
}
.no-violations {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: var(--color-success-bg, #ecfdf5);
border-radius: 4px;
font-size: 0.875rem;
color: var(--color-success, #059669);
margin-bottom: 1rem;
}
.success-icon {
font-family: monospace;
font-weight: 700;
}
.completion-info {
display: flex;
justify-content: space-between;
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
padding-top: 0.5rem;
border-top: 1px solid var(--color-border-light, #f3f4f6);
}
.verify-id {
font-family: monospace;
}
// CLI Guidance
.cli-guidance {
padding: 1rem;
background: var(--color-bg-subtle, #f9fafb);
border-top: 1px solid var(--color-border, #e5e7eb);
}
.cli-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text, #374151);
margin: 0 0 0.5rem;
}
.cli-desc {
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
margin: 0 0 1rem;
}
.cli-command-section,
.cli-flags-section,
.cli-examples-section {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
.cli-label {
display: block;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #6b7280);
margin-bottom: 0.375rem;
}
.cli-command {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--color-bg-code, #1f2937);
border-radius: 4px;
code {
flex: 1;
font-size: 0.8125rem;
color: #e5e7eb;
white-space: nowrap;
overflow-x: auto;
}
}
.btn-copy {
padding: 0.25rem 0.375rem;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 3px;
font-family: monospace;
font-size: 0.625rem;
color: #9ca3af;
cursor: pointer;
flex-shrink: 0;
&:hover {
background: rgba(255, 255, 255, 0.2);
color: white;
}
}
.flags-table {
width: 100%;
font-size: 0.8125rem;
border-collapse: collapse;
tr {
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
&:last-child {
border-bottom: none;
}
}
td {
padding: 0.375rem 0;
}
.flag-name {
width: 140px;
code {
font-size: 0.75rem;
background: var(--color-bg-code, #f3f4f6);
padding: 0.125rem 0.25rem;
border-radius: 2px;
}
}
.flag-desc {
color: var(--color-text-muted, #6b7280);
}
}
.examples-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.example-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
background: var(--color-bg-code, #1f2937);
border-radius: 4px;
code {
flex: 1;
font-size: 0.75rem;
color: #d1d5db;
white-space: nowrap;
overflow-x: auto;
}
}
.install-hint {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: var(--color-info-bg, #f0f9ff);
border-radius: 4px;
font-size: 0.75rem;
color: var(--color-info, #0284c7);
margin-top: 1rem;
code {
background: var(--color-bg-code, #e0f2fe);
padding: 0.125rem 0.25rem;
border-radius: 2px;
}
}
.hint-icon {
font-family: monospace;
font-weight: 600;
}

View File

@@ -0,0 +1,184 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
} from '@angular/core';
import { AocClient } from '../../core/api/aoc.client';
import {
AocVerificationRequest,
AocVerificationResult,
AocViolationDetail,
} from '../../core/api/aoc.models';
type VerifyState = 'idle' | 'running' | 'completed' | 'error';
export interface CliParityGuidance {
command: string;
description: string;
flags: { flag: string; description: string }[];
examples: string[];
}
@Component({
selector: 'app-verify-action',
standalone: true,
imports: [CommonModule],
templateUrl: './verify-action.component.html',
styleUrls: ['./verify-action.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VerifyActionComponent {
private readonly aocClient = inject(AocClient);
/** Tenant ID to verify */
readonly tenantId = input.required<string>();
/** Time window in hours (default 24h) */
readonly windowHours = input(24);
/** Maximum documents to check */
readonly limit = input(10000);
/** Emits when verification completes */
readonly verified = output<AocVerificationResult>();
/** Emits when user clicks on a violation */
readonly selectViolation = output<AocViolationDetail>();
readonly state = signal<VerifyState>('idle');
readonly result = signal<AocVerificationResult | null>(null);
readonly error = signal<string | null>(null);
readonly progress = signal(0);
readonly showCliGuidance = signal(false);
readonly statusIcon = computed(() => {
switch (this.state()) {
case 'idle':
return '[ ]';
case 'running':
return '[~]';
case 'completed':
return this.result()?.status === 'passed' ? '[+]' : '[!]';
case 'error':
return '[X]';
default:
return '[?]';
}
});
readonly statusLabel = computed(() => {
switch (this.state()) {
case 'idle':
return 'Ready to verify';
case 'running':
return 'Verification in progress...';
case 'completed':
const r = this.result();
if (!r) return 'Completed';
return r.status === 'passed'
? 'Verification passed'
: r.status === 'failed'
? 'Verification failed'
: 'Verification completed with warnings';
case 'error':
return 'Verification error';
default:
return '';
}
});
readonly resultSummary = computed(() => {
const r = this.result();
if (!r) return null;
return {
passRate: ((r.passedCount / r.checkedCount) * 100).toFixed(2),
violationCount: r.violations.length,
uniqueCodes: [...new Set(r.violations.map((v) => v.violationCode))],
};
});
readonly cliGuidance: CliParityGuidance = {
command: 'stella aoc verify',
description:
'Run the same verification from CLI for automation, CI/CD pipelines, or detailed output.',
flags: [
{ flag: '--tenant', description: 'Tenant ID to verify' },
{ flag: '--since', description: 'Start time (ISO8601 or duration like "24h")' },
{ flag: '--limit', description: 'Maximum documents to check' },
{ flag: '--output', description: 'Output format: json, table, summary' },
{ flag: '--fail-on-violation', description: 'Exit with code 1 if any violations found' },
{ flag: '--verbose', description: 'Show detailed violation information' },
],
examples: [
'stella aoc verify --tenant $TENANT_ID --since 24h',
'stella aoc verify --tenant $TENANT_ID --since 24h --output json > report.json',
'stella aoc verify --tenant $TENANT_ID --since 24h --fail-on-violation',
],
};
async runVerification(): Promise<void> {
if (this.state() === 'running') return;
this.state.set('running');
this.error.set(null);
this.result.set(null);
this.progress.set(0);
// Simulate progress updates
const progressInterval = setInterval(() => {
this.progress.update((p) => Math.min(p + Math.random() * 15, 90));
}, 200);
const since = new Date();
since.setHours(since.getHours() - this.windowHours());
const request: AocVerificationRequest = {
tenantId: this.tenantId(),
since: since.toISOString(),
limit: this.limit(),
};
this.aocClient.verify(request).subscribe({
next: (result) => {
clearInterval(progressInterval);
this.progress.set(100);
this.result.set(result);
this.state.set('completed');
this.verified.emit(result);
},
error: (err) => {
clearInterval(progressInterval);
this.state.set('error');
this.error.set(err.message || 'Verification failed');
},
});
}
reset(): void {
this.state.set('idle');
this.result.set(null);
this.error.set(null);
this.progress.set(0);
}
toggleCliGuidance(): void {
this.showCliGuidance.update((v) => !v);
}
onSelectViolation(violation: AocViolationDetail): void {
this.selectViolation.emit(violation);
}
copyCommand(command: string): void {
navigator.clipboard.writeText(command);
}
getCliCommand(): string {
return `stella aoc verify --tenant ${this.tenantId()} --since ${this.windowHours()}h`;
}
}

View File

@@ -0,0 +1,279 @@
<div class="violation-drilldown">
<!-- Header with Summary -->
<header class="drilldown-header">
<div class="summary-stats">
<div class="stat">
<span class="stat-value">{{ totalViolations() }}</span>
<span class="stat-label">Violations</span>
</div>
<div class="stat">
<span class="stat-value">{{ totalDocuments() }}</span>
<span class="stat-label">Documents</span>
</div>
<div class="severity-breakdown">
@if (severityCounts().critical > 0) {
<span class="severity-chip critical">{{ severityCounts().critical }} critical</span>
}
@if (severityCounts().high > 0) {
<span class="severity-chip high">{{ severityCounts().high }} high</span>
}
@if (severityCounts().medium > 0) {
<span class="severity-chip medium">{{ severityCounts().medium }} medium</span>
}
@if (severityCounts().low > 0) {
<span class="severity-chip low">{{ severityCounts().low }} low</span>
}
</div>
</div>
<div class="controls">
<div class="view-toggle">
<button
class="toggle-btn"
[class.active]="viewMode() === 'by-violation'"
(click)="setViewMode('by-violation')"
>
By Violation
</button>
<button
class="toggle-btn"
[class.active]="viewMode() === 'by-document'"
(click)="setViewMode('by-document')"
>
By Document
</button>
</div>
<input
type="search"
class="search-input"
placeholder="Filter violations..."
[value]="searchFilter()"
(input)="onSearch($event)"
/>
</div>
</header>
<!-- By Violation View -->
@if (viewMode() === 'by-violation') {
<div class="violation-list">
@for (group of filteredGroups(); track group.code) {
<div class="violation-group" [class]="'severity-' + group.severity">
<button
class="group-header"
(click)="toggleGroup(group.code)"
[attr.aria-expanded]="expandedCode() === group.code"
>
<span class="severity-icon">{{ getSeverityIcon(group.severity) }}</span>
<div class="group-info">
<span class="violation-code">{{ group.code }}</span>
<span class="violation-desc">{{ group.description }}</span>
</div>
<span class="affected-count">{{ group.affectedDocuments }} doc(s)</span>
<span class="expand-icon" [class.expanded]="expandedCode() === group.code">v</span>
</button>
@if (expandedCode() === group.code) {
<div class="group-details">
@if (group.remediation) {
<div class="remediation-hint">
<strong>Remediation:</strong> {{ group.remediation }}
</div>
}
<table class="violations-table">
<thead>
<tr>
<th>Document</th>
<th>Field</th>
<th>Expected</th>
<th>Actual</th>
<th>Provenance</th>
<th></th>
</tr>
</thead>
<tbody>
@for (v of group.violations; track v.documentId + v.field) {
<tr class="violation-row">
<td class="doc-cell">
<button class="doc-link" (click)="onSelectDocument(v.documentId)">
{{ v.documentId | slice:0:20 }}...
</button>
</td>
<td class="field-cell">
@if (v.field) {
<code class="field-path highlighted">{{ v.field }}</code>
} @else {
<span class="no-field">-</span>
}
</td>
<td class="expected-cell">
@if (v.expected) {
<code class="value expected">{{ v.expected }}</code>
} @else {
<span class="no-value">-</span>
}
</td>
<td class="actual-cell">
@if (v.actual) {
<code class="value actual error">{{ v.actual }}</code>
} @else {
<span class="no-value">-</span>
}
</td>
<td class="provenance-cell">
@if (v.provenance) {
<div class="provenance-info">
<span class="source-type">{{ getSourceTypeIcon(v.provenance.sourceType) }}</span>
<span class="source-id" [title]="v.provenance.sourceId">
{{ v.provenance.sourceId | slice:0:15 }}
</span>
<span class="digest" [title]="v.provenance.digest">
{{ formatDigest(v.provenance.digest) }}
</span>
</div>
} @else {
<span class="no-provenance">No provenance</span>
}
</td>
<td class="actions-cell">
<button class="btn-icon" (click)="onViewRaw(v.documentId)" title="View raw">
{ }
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
@if (filteredGroups().length === 0) {
<div class="empty-state">
@if (searchFilter()) {
<p>No violations match "{{ searchFilter() }}"</p>
} @else {
<p>No violations to display</p>
}
</div>
}
</div>
}
<!-- By Document View -->
@if (viewMode() === 'by-document') {
<div class="document-list">
@for (doc of filteredDocuments(); track doc.documentId) {
<div class="document-card">
<button
class="doc-header"
(click)="toggleDocument(doc.documentId)"
[attr.aria-expanded]="expandedDocId() === doc.documentId"
>
<span class="doc-type-badge">{{ doc.documentType }}</span>
<span class="doc-id">{{ doc.documentId }}</span>
<span class="violation-count">{{ doc.violations.length }} violation(s)</span>
<span class="expand-icon" [class.expanded]="expandedDocId() === doc.documentId">v</span>
</button>
@if (expandedDocId() === doc.documentId) {
<div class="doc-details">
<!-- Provenance Section -->
<div class="provenance-section">
<h4 class="section-title">Provenance</h4>
<dl class="provenance-grid">
<div class="prov-item">
<dt>Source</dt>
<dd>
<span class="source-type">{{ getSourceTypeIcon(doc.provenance.sourceType) }}</span>
{{ doc.provenance.sourceId }}
</dd>
</div>
<div class="prov-item">
<dt>Digest</dt>
<dd><code>{{ doc.provenance.digest }}</code></dd>
</div>
<div class="prov-item">
<dt>Ingested</dt>
<dd>{{ formatDate(doc.provenance.ingestedAt) }}</dd>
</div>
@if (doc.provenance.submitter) {
<div class="prov-item">
<dt>Submitter</dt>
<dd>{{ doc.provenance.submitter }}</dd>
</div>
}
@if (doc.provenance.sourceUrl) {
<div class="prov-item">
<dt>Source URL</dt>
<dd class="url">{{ doc.provenance.sourceUrl }}</dd>
</div>
}
</dl>
</div>
<!-- Violations Section -->
<div class="violations-section">
<h4 class="section-title">Violations</h4>
<ul class="doc-violations-list">
@for (v of doc.violations; track v.violationCode + v.field) {
<li class="doc-violation-item">
<div class="violation-header">
<code class="violation-code">{{ v.violationCode }}</code>
@if (v.field) {
<span class="at-field">at</span>
<code class="field-path highlighted">{{ v.field }}</code>
}
</div>
@if (v.expected || v.actual) {
<div class="value-diff">
<div class="expected-row">
<span class="label">Expected:</span>
<code class="value">{{ v.expected || 'N/A' }}</code>
</div>
<div class="actual-row">
<span class="label">Actual:</span>
<code class="value error">{{ v.actual || 'N/A' }}</code>
</div>
</div>
}
</li>
}
</ul>
</div>
<!-- Raw Content Preview -->
@if (doc.rawContent) {
<div class="raw-content-section">
<h4 class="section-title">
Document Fields
<button class="btn-link" (click)="onViewRaw(doc.documentId)">View Full</button>
</h4>
<div class="field-preview">
@for (field of doc.highlightedFields; track field) {
<div class="field-row" [class.error]="isFieldHighlighted(doc, field)">
<span class="field-name">{{ field }}</span>
<code class="field-value">{{ getFieldValue(doc.rawContent, field) }}</code>
</div>
}
</div>
</div>
}
</div>
}
</div>
}
@if (filteredDocuments().length === 0) {
<div class="empty-state">
@if (searchFilter()) {
<p>No documents match "{{ searchFilter() }}"</p>
} @else {
<p>No documents to display</p>
}
</div>
}
</div>
}
</div>

View File

@@ -0,0 +1,585 @@
.violation-drilldown {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
background: var(--color-bg-card, white);
overflow: hidden;
}
.drilldown-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 1rem;
background: var(--color-bg-subtle, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
flex-wrap: wrap;
}
.summary-stats {
display: flex;
align-items: center;
gap: 1.5rem;
flex-wrap: wrap;
}
.stat {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text, #111827);
}
.stat-label {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.severity-breakdown {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.severity-chip {
font-size: 0.6875rem;
padding: 0.125rem 0.5rem;
border-radius: 12px;
font-weight: 500;
&.critical {
background: var(--color-critical-bg, #fef2f2);
color: var(--color-critical, #dc2626);
}
&.high {
background: var(--color-error-bg, #fff7ed);
color: var(--color-error, #ea580c);
}
&.medium {
background: var(--color-warning-bg, #fffbeb);
color: var(--color-warning, #d97706);
}
&.low {
background: var(--color-info-bg, #f0f9ff);
color: var(--color-info, #0284c7);
}
}
.controls {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.view-toggle {
display: flex;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
overflow: hidden;
}
.toggle-btn {
padding: 0.375rem 0.75rem;
background: var(--color-bg-card, white);
border: none;
font-size: 0.8125rem;
cursor: pointer;
color: var(--color-text-muted, #6b7280);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
&.active {
background: var(--color-primary, #2563eb);
color: white;
}
&:not(:last-child) {
border-right: 1px solid var(--color-border, #e5e7eb);
}
}
.search-input {
padding: 0.375rem 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
font-size: 0.8125rem;
min-width: 200px;
&:focus {
outline: 2px solid var(--color-primary, #2563eb);
outline-offset: -1px;
}
}
// Violation List (By Violation View)
.violation-list {
max-height: 600px;
overflow-y: auto;
}
.violation-group {
border-bottom: 1px solid var(--color-border, #e5e7eb);
&:last-child {
border-bottom: none;
}
&.severity-critical {
.group-header { border-left: 3px solid var(--color-critical, #dc2626); }
.severity-icon { color: var(--color-critical, #dc2626); }
}
&.severity-high {
.group-header { border-left: 3px solid var(--color-error, #ea580c); }
.severity-icon { color: var(--color-error, #ea580c); }
}
&.severity-medium {
.group-header { border-left: 3px solid var(--color-warning, #d97706); }
.severity-icon { color: var(--color-warning, #d97706); }
}
&.severity-low {
.group-header { border-left: 3px solid var(--color-info, #0284c7); }
.severity-icon { color: var(--color-info, #0284c7); }
}
}
.group-header {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
&:hover {
background: var(--color-bg-hover, #f9fafb);
}
}
.severity-icon {
font-weight: 700;
font-size: 0.875rem;
width: 1.5rem;
text-align: center;
}
.group-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.violation-code {
font-family: monospace;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text, #111827);
}
.violation-desc {
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.affected-count {
font-size: 0.75rem;
color: var(--color-text-muted, #9ca3af);
white-space: nowrap;
}
.expand-icon {
font-size: 0.625rem;
color: var(--color-text-muted, #9ca3af);
transition: transform 0.2s;
&.expanded {
transform: rotate(180deg);
}
}
.group-details {
padding: 0 1rem 1rem;
background: var(--color-bg-subtle, #f9fafb);
}
.remediation-hint {
font-size: 0.8125rem;
padding: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
background: var(--color-info-bg, #f0f9ff);
border-radius: 4px;
color: var(--color-text, #374151);
}
.violations-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
th {
text-align: left;
padding: 0.5rem;
font-weight: 600;
color: var(--color-text-muted, #6b7280);
border-bottom: 1px solid var(--color-border, #e5e7eb);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
td {
padding: 0.5rem;
vertical-align: top;
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
}
tr:last-child td {
border-bottom: none;
}
}
.doc-link {
background: none;
border: none;
color: var(--color-primary, #2563eb);
font-family: monospace;
font-size: 0.75rem;
cursor: pointer;
padding: 0;
&:hover {
text-decoration: underline;
}
}
.field-path {
font-size: 0.75rem;
padding: 0.125rem 0.25rem;
border-radius: 2px;
&.highlighted {
background: var(--color-warning-bg, #fef3c7);
color: var(--color-warning-dark, #92400e);
}
}
.value {
font-size: 0.75rem;
padding: 0.125rem 0.25rem;
border-radius: 2px;
background: var(--color-bg-code, #f3f4f6);
max-width: 150px;
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.expected {
background: var(--color-success-bg, #ecfdf5);
color: var(--color-success, #059669);
}
&.actual.error {
background: var(--color-error-bg, #fef2f2);
color: var(--color-error, #dc2626);
}
}
.no-field,
.no-value,
.no-provenance {
color: var(--color-text-muted, #9ca3af);
font-style: italic;
}
.provenance-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
}
.source-type {
font-family: monospace;
font-weight: 600;
}
.source-id,
.digest {
color: var(--color-text-muted, #6b7280);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
}
.btn-icon {
background: none;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.25rem 0.5rem;
font-family: monospace;
font-size: 0.75rem;
cursor: pointer;
color: var(--color-text-muted, #6b7280);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
color: var(--color-text, #374151);
}
}
// Document List (By Document View)
.document-list {
max-height: 600px;
overflow-y: auto;
}
.document-card {
border-bottom: 1px solid var(--color-border, #e5e7eb);
&:last-child {
border-bottom: none;
}
}
.doc-header {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
&:hover {
background: var(--color-bg-hover, #f9fafb);
}
}
.doc-type-badge {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
border-radius: 3px;
background: var(--color-bg-subtle, #f3f4f6);
color: var(--color-text-muted, #6b7280);
text-transform: uppercase;
font-weight: 500;
}
.doc-id {
flex: 1;
font-family: monospace;
font-size: 0.8125rem;
color: var(--color-text, #111827);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.violation-count {
font-size: 0.75rem;
color: var(--color-error, #dc2626);
font-weight: 500;
}
.doc-details {
padding: 0 1rem 1rem;
background: var(--color-bg-subtle, #f9fafb);
}
.section-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #6b7280);
margin: 0 0 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-link {
background: none;
border: none;
color: var(--color-primary, #2563eb);
font-size: 0.75rem;
cursor: pointer;
padding: 0;
text-transform: none;
letter-spacing: normal;
font-weight: normal;
&:hover {
text-decoration: underline;
}
}
.provenance-section,
.violations-section,
.raw-content-section {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
.provenance-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.5rem;
margin: 0;
}
.prov-item {
dt {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
margin-bottom: 0.125rem;
}
dd {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text, #374151);
code {
font-size: 0.75rem;
background: var(--color-bg-code, #f3f4f6);
padding: 0.125rem 0.25rem;
border-radius: 2px;
}
&.url {
font-size: 0.75rem;
word-break: break-all;
}
}
}
.doc-violations-list {
list-style: none;
padding: 0;
margin: 0;
}
.doc-violation-item {
padding: 0.5rem;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
}
.violation-header {
display: flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
}
.at-field {
font-size: 0.75rem;
color: var(--color-text-muted, #9ca3af);
}
.value-diff {
margin-top: 0.5rem;
padding: 0.5rem;
background: var(--color-bg-subtle, #f9fafb);
border-radius: 4px;
}
.expected-row,
.actual-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: 0.8125rem;
.label {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
min-width: 60px;
}
}
.actual-row {
margin-top: 0.25rem;
}
.field-preview {
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
overflow: hidden;
}
.field-row {
display: flex;
padding: 0.375rem 0.5rem;
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
font-size: 0.8125rem;
&:last-child {
border-bottom: none;
}
&.error {
background: var(--color-error-bg, #fef2f2);
.field-name {
color: var(--color-error, #dc2626);
}
}
}
.field-name {
font-family: monospace;
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
min-width: 120px;
}
.field-value {
font-size: 0.75rem;
color: var(--color-text, #374151);
word-break: break-all;
}
.empty-state {
padding: 2rem;
text-align: center;
color: var(--color-text-muted, #9ca3af);
font-style: italic;
}

View File

@@ -0,0 +1,182 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
AocViolationDetail,
AocViolationGroup,
AocDocumentView,
AocProvenance,
} from '../../core/api/aoc.models';
type ViewMode = 'by-violation' | 'by-document';
@Component({
selector: 'app-violation-drilldown',
standalone: true,
imports: [CommonModule],
templateUrl: './violation-drilldown.component.html',
styleUrls: ['./violation-drilldown.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ViolationDrilldownComponent {
/** Violation groups to display */
readonly violationGroups = input.required<AocViolationGroup[]>();
/** Document views for by-document mode */
readonly documentViews = input<AocDocumentView[]>([]);
/** Emits when user clicks on a document */
readonly selectDocument = output<string>();
/** Emits when user wants to view raw document */
readonly viewRawDocument = output<string>();
/** Current view mode */
readonly viewMode = signal<ViewMode>('by-violation');
/** Currently expanded violation code */
readonly expandedCode = signal<string | null>(null);
/** Currently expanded document ID */
readonly expandedDocId = signal<string | null>(null);
/** Search filter */
readonly searchFilter = signal('');
readonly filteredGroups = computed(() => {
const filter = this.searchFilter().toLowerCase();
if (!filter) return this.violationGroups();
return this.violationGroups().filter(
(g) =>
g.code.toLowerCase().includes(filter) ||
g.description.toLowerCase().includes(filter) ||
g.violations.some(
(v) =>
v.documentId.toLowerCase().includes(filter) ||
v.field?.toLowerCase().includes(filter)
)
);
});
readonly filteredDocuments = computed(() => {
const filter = this.searchFilter().toLowerCase();
if (!filter) return this.documentViews();
return this.documentViews().filter(
(d) =>
d.documentId.toLowerCase().includes(filter) ||
d.documentType.toLowerCase().includes(filter) ||
d.violations.some(
(v) =>
v.violationCode.toLowerCase().includes(filter) ||
v.field?.toLowerCase().includes(filter)
)
);
});
readonly totalViolations = computed(() =>
this.violationGroups().reduce((sum, g) => sum + g.violations.length, 0)
);
readonly totalDocuments = computed(() => {
const docIds = new Set<string>();
for (const group of this.violationGroups()) {
for (const v of group.violations) {
docIds.add(v.documentId);
}
}
return docIds.size;
});
readonly severityCounts = computed(() => {
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
for (const group of this.violationGroups()) {
counts[group.severity] += group.violations.length;
}
return counts;
});
setViewMode(mode: ViewMode): void {
this.viewMode.set(mode);
}
toggleGroup(code: string): void {
this.expandedCode.update((current) => (current === code ? null : code));
}
toggleDocument(docId: string): void {
this.expandedDocId.update((current) => (current === docId ? null : docId));
}
onSearch(event: Event): void {
const input = event.target as HTMLInputElement;
this.searchFilter.set(input.value);
}
onSelectDocument(docId: string): void {
this.selectDocument.emit(docId);
}
onViewRaw(docId: string): void {
this.viewRawDocument.emit(docId);
}
getSeverityIcon(severity: string): string {
switch (severity) {
case 'critical':
return '!!';
case 'high':
return '!';
case 'medium':
return '~';
default:
return '-';
}
}
getSourceTypeIcon(sourceType?: string): string {
switch (sourceType) {
case 'registry':
return '[R]';
case 'git':
return '[G]';
case 'upload':
return '[U]';
case 'api':
return '[A]';
default:
return '[?]';
}
}
formatDigest(digest: string, length = 12): string {
if (digest.length <= length) return digest;
return digest.slice(0, length) + '...';
}
formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString();
}
isFieldHighlighted(doc: AocDocumentView, field: string): boolean {
return doc.highlightedFields.includes(field);
}
getFieldValue(content: Record<string, unknown> | undefined, path: string): string {
if (!content) return 'N/A';
const parts = path.split('.');
let current: unknown = content;
for (const part of parts) {
if (current == null || typeof current !== 'object') return 'N/A';
current = (current as Record<string, unknown>)[part];
}
if (current == null) return 'null';
if (typeof current === 'object') return JSON.stringify(current);
return String(current);
}
}

View File

@@ -0,0 +1,148 @@
<div class="sources-dashboard">
<header class="dashboard-header">
<h1>Sources Dashboard</h1>
<div class="actions">
<button
class="btn btn-primary"
[disabled]="verifying()"
(click)="onVerifyLast24h()"
>
{{ verifying() ? 'Verifying...' : 'Verify last 24h' }}
</button>
<button class="btn btn-secondary" (click)="loadMetrics()">
Refresh
</button>
</div>
</header>
@if (loading()) {
<div class="loading-state">
<div class="spinner"></div>
<p>Loading AOC metrics...</p>
</div>
}
@if (error()) {
<div class="error-state">
<p class="error-message">{{ error() }}</p>
<button class="btn btn-secondary" (click)="loadMetrics()">Retry</button>
</div>
}
@if (metrics(); as m) {
<div class="metrics-grid">
<!-- Pass/Fail Tile -->
<div class="tile tile-pass-fail" [class]="passRateClass()">
<h2 class="tile-title">AOC Pass/Fail</h2>
<div class="tile-content">
<div class="metric-large">
<span class="value">{{ passRate() }}%</span>
<span class="label">Pass Rate</span>
</div>
<div class="metric-details">
<div class="detail">
<span class="count pass">{{ m.passCount | number }}</span>
<span class="label">Passed</span>
</div>
<div class="detail">
<span class="count fail">{{ m.failCount | number }}</span>
<span class="label">Failed</span>
</div>
<div class="detail">
<span class="count total">{{ m.totalCount | number }}</span>
<span class="label">Total</span>
</div>
</div>
</div>
</div>
<!-- Recent Violations Tile -->
<div class="tile tile-violations">
<h2 class="tile-title">Recent Violations</h2>
<div class="tile-content">
@if (m.recentViolations.length === 0) {
<p class="empty-state">No violations in time window</p>
} @else {
<ul class="violations-list">
@for (v of m.recentViolations; track v.code) {
<li class="violation-item" [class]="getSeverityClass(v.severity)">
<div class="violation-header">
<code class="violation-code">{{ v.code }}</code>
<span class="violation-count">{{ v.count }}x</span>
</div>
<p class="violation-desc">{{ v.description }}</p>
<span class="violation-time">{{ formatRelativeTime(v.lastSeen) }}</span>
</li>
}
</ul>
}
</div>
</div>
<!-- Ingest Throughput Tile -->
<div class="tile tile-throughput" [class]="throughputStatus()">
<h2 class="tile-title">Ingest Throughput</h2>
<div class="tile-content">
<div class="throughput-grid">
<div class="throughput-item">
<span class="value">{{ m.ingestThroughput.docsPerMinute | number:'1.1-1' }}</span>
<span class="label">docs/min</span>
</div>
<div class="throughput-item">
<span class="value">{{ m.ingestThroughput.avgLatencyMs }}</span>
<span class="label">avg ms</span>
</div>
<div class="throughput-item">
<span class="value">{{ m.ingestThroughput.p95LatencyMs }}</span>
<span class="label">p95 ms</span>
</div>
<div class="throughput-item">
<span class="value">{{ m.ingestThroughput.queueDepth }}</span>
<span class="label">queue</span>
</div>
<div class="throughput-item">
<span class="value">{{ m.ingestThroughput.errorRate | number:'1.2-2' }}%</span>
<span class="label">errors</span>
</div>
</div>
</div>
</div>
</div>
<!-- Verification Result -->
@if (verificationResult(); as result) {
<div class="verification-result" [class]="'status-' + result.status">
<h3>Verification Complete</h3>
<div class="result-summary">
<span class="status-badge">{{ result.status | titlecase }}</span>
<span>Checked: {{ result.checkedCount | number }}</span>
<span>Passed: {{ result.passedCount | number }}</span>
<span>Failed: {{ result.failedCount | number }}</span>
</div>
@if (result.violations.length > 0) {
<details class="violations-details">
<summary>View {{ result.violations.length }} violation(s)</summary>
<ul class="violation-list">
@for (v of result.violations; track v.documentId) {
<li>
<strong>{{ v.violationCode }}</strong> in {{ v.documentId }}
@if (v.field) {
<br>Field: {{ v.field }} (expected: {{ v.expected }}, actual: {{ v.actual }})
}
</li>
}
</ul>
</details>
}
<p class="cli-hint">
CLI equivalent: <code>stella aoc verify --since=24h --tenant=default</code>
</p>
</div>
}
<p class="time-window">
Data from {{ m.timeWindow.start | date:'short' }} to {{ m.timeWindow.end | date:'short' }}
({{ m.timeWindow.durationMinutes / 60 | number:'1.0-0' }}h window)
</p>
}
</div>

View File

@@ -0,0 +1,325 @@
.sources-dashboard {
padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.actions {
display: flex;
gap: 0.5rem;
}
}
.btn {
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
cursor: pointer;
border: 1px solid transparent;
transition: background-color 0.2s, border-color 0.2s;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&-primary {
background-color: var(--color-primary, #2563eb);
color: white;
&:hover:not(:disabled) {
background-color: var(--color-primary-hover, #1d4ed8);
}
}
&-secondary {
background-color: transparent;
border-color: var(--color-border, #d1d5db);
color: var(--color-text, #374151);
&:hover:not(:disabled) {
background-color: var(--color-bg-hover, #f3f4f6);
}
}
}
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem;
text-align: center;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--color-border, #e5e7eb);
border-top-color: var(--color-primary, #2563eb);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error-message {
color: var(--color-error, #dc2626);
margin-bottom: 1rem;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1.5rem;
}
.tile {
background: var(--color-bg-card, white);
border-radius: 8px;
border: 1px solid var(--color-border, #e5e7eb);
overflow: hidden;
&-title {
font-size: 0.875rem;
font-weight: 600;
padding: 0.75rem 1rem;
margin: 0;
background: var(--color-bg-subtle, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
&-content {
padding: 1rem;
}
}
.tile-pass-fail {
&.excellent .metric-large .value { color: var(--color-success, #059669); }
&.good .metric-large .value { color: var(--color-success-muted, #10b981); }
&.warning .metric-large .value { color: var(--color-warning, #d97706); }
&.critical .metric-large .value { color: var(--color-error, #dc2626); }
}
.metric-large {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 1rem;
.value {
font-size: 2.5rem;
font-weight: 700;
line-height: 1;
}
.label {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
}
}
.metric-details {
display: flex;
justify-content: space-around;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
.detail {
display: flex;
flex-direction: column;
align-items: center;
}
.count {
font-size: 1.25rem;
font-weight: 600;
&.pass { color: var(--color-success, #059669); }
&.fail { color: var(--color-error, #dc2626); }
&.total { color: var(--color-text, #374151); }
}
.label {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
}
}
.violations-list {
list-style: none;
padding: 0;
margin: 0;
}
.violation-item {
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 0.5rem;
background: var(--color-bg-subtle, #f9fafb);
&.severity-critical { border-left: 3px solid var(--color-error, #dc2626); }
&.severity-high { border-left: 3px solid var(--color-warning, #d97706); }
&.severity-medium { border-left: 3px solid var(--color-info, #2563eb); }
&.severity-low { border-left: 3px solid var(--color-text-muted, #9ca3af); }
}
.violation-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.violation-code {
font-family: monospace;
font-size: 0.8125rem;
font-weight: 600;
}
.violation-count {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
}
.violation-desc {
font-size: 0.8125rem;
margin: 0 0 0.25rem;
color: var(--color-text, #374151);
}
.violation-time {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
}
.empty-state {
color: var(--color-text-muted, #6b7280);
font-style: italic;
text-align: center;
padding: 1rem;
}
.throughput-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: 1rem;
text-align: center;
}
.throughput-item {
display: flex;
flex-direction: column;
.value {
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text, #374151);
}
.label {
font-size: 0.6875rem;
color: var(--color-text-muted, #6b7280);
text-transform: uppercase;
}
}
.tile-throughput {
&.critical .throughput-item .value { color: var(--color-error, #dc2626); }
&.warning .throughput-item .value { color: var(--color-warning, #d97706); }
}
.verification-result {
margin-top: 1.5rem;
padding: 1rem;
border-radius: 8px;
border: 1px solid var(--color-border, #e5e7eb);
&.status-passed { background: var(--color-success-bg, #ecfdf5); border-color: var(--color-success, #059669); }
&.status-failed { background: var(--color-error-bg, #fef2f2); border-color: var(--color-error, #dc2626); }
&.status-partial { background: var(--color-warning-bg, #fffbeb); border-color: var(--color-warning, #d97706); }
h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.result-summary {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.status-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
&.status-passed .status-badge { background: var(--color-success, #059669); color: white; }
&.status-failed .status-badge { background: var(--color-error, #dc2626); color: white; }
&.status-partial .status-badge { background: var(--color-warning, #d97706); color: white; }
}
.violations-details {
margin: 0.75rem 0;
summary {
cursor: pointer;
color: var(--color-primary, #2563eb);
font-size: 0.875rem;
}
.violation-list {
margin-top: 0.5rem;
padding-left: 1.25rem;
font-size: 0.8125rem;
li {
margin-bottom: 0.5rem;
}
}
}
.cli-hint {
margin: 0.75rem 0 0;
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
code {
background: var(--color-bg-code, #f3f4f6);
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-family: monospace;
font-size: 0.6875rem;
}
}
.time-window {
margin-top: 1rem;
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
text-align: center;
}

View File

@@ -0,0 +1,111 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
OnInit,
signal,
} from '@angular/core';
import { AocClient } from '../../core/api/aoc.client';
import {
AocMetrics,
AocViolationSummary,
AocVerificationResult,
} from '../../core/api/aoc.models';
@Component({
selector: 'app-sources-dashboard',
standalone: true,
imports: [CommonModule],
templateUrl: './sources-dashboard.component.html',
styleUrls: ['./sources-dashboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SourcesDashboardComponent implements OnInit {
private readonly aocClient = inject(AocClient);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly metrics = signal<AocMetrics | null>(null);
readonly verifying = signal(false);
readonly verificationResult = signal<AocVerificationResult | null>(null);
readonly passRate = computed(() => {
const m = this.metrics();
return m ? m.passRate.toFixed(2) : '0.00';
});
readonly passRateClass = computed(() => {
const m = this.metrics();
if (!m) return 'neutral';
if (m.passRate >= 99.5) return 'excellent';
if (m.passRate >= 95) return 'good';
if (m.passRate >= 90) return 'warning';
return 'critical';
});
readonly throughputStatus = computed(() => {
const m = this.metrics();
if (!m) return 'neutral';
if (m.ingestThroughput.queueDepth > 100) return 'critical';
if (m.ingestThroughput.queueDepth > 50) return 'warning';
return 'good';
});
ngOnInit(): void {
this.loadMetrics();
}
loadMetrics(): void {
this.loading.set(true);
this.error.set(null);
this.aocClient.getMetrics('default').subscribe({
next: (metrics) => {
this.metrics.set(metrics);
this.loading.set(false);
},
error: (err) => {
this.error.set('Failed to load AOC metrics');
this.loading.set(false);
console.error('AOC metrics error:', err);
},
});
}
onVerifyLast24h(): void {
this.verifying.set(true);
this.verificationResult.set(null);
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
this.aocClient.verify({ tenantId: 'default', since }).subscribe({
next: (result) => {
this.verificationResult.set(result);
this.verifying.set(false);
},
error: (err) => {
this.verifying.set(false);
console.error('AOC verification error:', err);
},
});
}
getSeverityClass(severity: AocViolationSummary['severity']): string {
return 'severity-' + severity;
}
formatRelativeTime(isoDate: string): string {
const date = new Date(isoDate);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return diffMins + 'm ago';
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return diffHours + 'h ago';
const diffDays = Math.floor(diffHours / 24);
return diffDays + 'd ago';
}
}

View File

@@ -1,200 +1,200 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { EvidenceData } from '../../core/api/evidence.models';
import { EVIDENCE_API, MockEvidenceApiService } from '../../core/api/evidence.client';
import { EvidencePanelComponent } from './evidence-panel.component';
@Component({
selector: 'app-evidence-page',
standalone: true,
imports: [CommonModule, EvidencePanelComponent],
providers: [
{ provide: EVIDENCE_API, useClass: MockEvidenceApiService },
],
template: `
<div class="evidence-page">
@if (loading()) {
<div class="evidence-page__loading">
<div class="spinner" aria-label="Loading evidence"></div>
<p>Loading evidence for {{ advisoryId() }}...</p>
</div>
} @else if (error()) {
<div class="evidence-page__error" role="alert">
<h2>Error Loading Evidence</h2>
<p>{{ error() }}</p>
<button type="button" (click)="reload()">Retry</button>
</div>
} @else if (evidenceData()) {
<app-evidence-panel
[advisoryId]="advisoryId()"
[evidenceData]="evidenceData()"
(close)="onClose()"
(downloadDocument)="onDownload($event)"
/>
} @else {
<div class="evidence-page__empty">
<h2>No Advisory ID</h2>
<p>Please provide an advisory ID to view evidence.</p>
</div>
}
</div>
`,
styles: [`
.evidence-page {
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 2rem;
background: #f3f4f6;
}
.evidence-page__loading,
.evidence-page__error,
.evidence-page__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
text-align: center;
}
.evidence-page__loading .spinner {
width: 2.5rem;
height: 2.5rem;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.evidence-page__loading p {
margin-top: 1rem;
color: #6b7280;
}
.evidence-page__error {
border: 1px solid #fca5a5;
background: #fef2f2;
}
.evidence-page__error h2 {
color: #dc2626;
margin: 0 0 0.5rem;
}
.evidence-page__error p {
color: #991b1b;
margin: 0 0 1rem;
}
.evidence-page__error button {
padding: 0.5rem 1rem;
border: 1px solid #dc2626;
border-radius: 4px;
background: #fff;
color: #dc2626;
cursor: pointer;
}
.evidence-page__error button:hover {
background: #fee2e2;
}
.evidence-page__empty h2 {
color: #374151;
margin: 0 0 0.5rem;
}
.evidence-page__empty p {
color: #6b7280;
margin: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EvidencePageComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly evidenceApi = inject(EVIDENCE_API);
readonly advisoryId = signal<string>('');
readonly evidenceData = signal<EvidenceData | null>(null);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
constructor() {
// React to route param changes
effect(() => {
const params = this.route.snapshot.paramMap;
const id = params.get('advisoryId');
if (id) {
this.advisoryId.set(id);
this.loadEvidence(id);
}
}, { allowSignalWrites: true });
}
private loadEvidence(advisoryId: string): void {
this.loading.set(true);
this.error.set(null);
this.evidenceApi.getEvidenceForAdvisory(advisoryId).subscribe({
next: (data) => {
this.evidenceData.set(data);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message ?? 'Failed to load evidence');
this.loading.set(false);
},
});
}
reload(): void {
const id = this.advisoryId();
if (id) {
this.loadEvidence(id);
}
}
onClose(): void {
this.router.navigate(['/vulnerabilities']);
}
onDownload(event: { type: 'observation' | 'linkset'; id: string }): void {
this.evidenceApi.downloadRawDocument(event.type, event.id).subscribe({
next: (blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${event.type}-${event.id}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
},
error: (err) => {
console.error('Download failed:', err);
},
});
}
}
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { EvidenceData } from '../../core/api/evidence.models';
import { EVIDENCE_API, MockEvidenceApiService } from '../../core/api/evidence.client';
import { EvidencePanelComponent } from './evidence-panel.component';
@Component({
selector: 'app-evidence-page',
standalone: true,
imports: [CommonModule, EvidencePanelComponent],
providers: [
{ provide: EVIDENCE_API, useClass: MockEvidenceApiService },
],
template: `
<div class="evidence-page">
@if (loading()) {
<div class="evidence-page__loading">
<div class="spinner" aria-label="Loading evidence"></div>
<p>Loading evidence for {{ advisoryId() }}...</p>
</div>
} @else if (error()) {
<div class="evidence-page__error" role="alert">
<h2>Error Loading Evidence</h2>
<p>{{ error() }}</p>
<button type="button" (click)="reload()">Retry</button>
</div>
} @else if (evidenceData()) {
<app-evidence-panel
[advisoryId]="advisoryId()"
[evidenceData]="evidenceData()"
(close)="onClose()"
(downloadDocument)="onDownload($event)"
/>
} @else {
<div class="evidence-page__empty">
<h2>No Advisory ID</h2>
<p>Please provide an advisory ID to view evidence.</p>
</div>
}
</div>
`,
styles: [`
.evidence-page {
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 2rem;
background: #f3f4f6;
}
.evidence-page__loading,
.evidence-page__error,
.evidence-page__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
text-align: center;
}
.evidence-page__loading .spinner {
width: 2.5rem;
height: 2.5rem;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.evidence-page__loading p {
margin-top: 1rem;
color: #6b7280;
}
.evidence-page__error {
border: 1px solid #fca5a5;
background: #fef2f2;
}
.evidence-page__error h2 {
color: #dc2626;
margin: 0 0 0.5rem;
}
.evidence-page__error p {
color: #991b1b;
margin: 0 0 1rem;
}
.evidence-page__error button {
padding: 0.5rem 1rem;
border: 1px solid #dc2626;
border-radius: 4px;
background: #fff;
color: #dc2626;
cursor: pointer;
}
.evidence-page__error button:hover {
background: #fee2e2;
}
.evidence-page__empty h2 {
color: #374151;
margin: 0 0 0.5rem;
}
.evidence-page__empty p {
color: #6b7280;
margin: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EvidencePageComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly evidenceApi = inject(EVIDENCE_API);
readonly advisoryId = signal<string>('');
readonly evidenceData = signal<EvidenceData | null>(null);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
constructor() {
// React to route param changes
effect(() => {
const params = this.route.snapshot.paramMap;
const id = params.get('advisoryId');
if (id) {
this.advisoryId.set(id);
this.loadEvidence(id);
}
}, { allowSignalWrites: true });
}
private loadEvidence(advisoryId: string): void {
this.loading.set(true);
this.error.set(null);
this.evidenceApi.getEvidenceForAdvisory(advisoryId).subscribe({
next: (data) => {
this.evidenceData.set(data);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message ?? 'Failed to load evidence');
this.loading.set(false);
},
});
}
reload(): void {
const id = this.advisoryId();
if (id) {
this.loadEvidence(id);
}
}
onClose(): void {
this.router.navigate(['/vulnerabilities']);
}
onDownload(event: { type: 'observation' | 'linkset'; id: string }): void {
this.evidenceApi.downloadRawDocument(event.type, event.id).subscribe({
next: (blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${event.type}-${event.id}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
},
error: (err) => {
console.error('Download failed:', err);
},
});
}
}

View File

@@ -1,255 +1,255 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
} from '@angular/core';
import {
AocChainEntry,
EvidenceData,
Linkset,
LinksetConflict,
Observation,
PolicyDecision,
PolicyEvidence,
PolicyRuleResult,
SOURCE_INFO,
SourceInfo,
} from '../../core/api/evidence.models';
import { EvidenceApi, EVIDENCE_API } from '../../core/api/evidence.client';
type TabId = 'observations' | 'linkset' | 'policy' | 'aoc';
type ObservationView = 'side-by-side' | 'stacked';
@Component({
selector: 'app-evidence-panel',
standalone: true,
imports: [CommonModule],
templateUrl: './evidence-panel.component.html',
styleUrls: ['./evidence-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EvidencePanelComponent {
private readonly evidenceApi = inject(EVIDENCE_API);
// Inputs
readonly advisoryId = input.required<string>();
readonly evidenceData = input<EvidenceData | null>(null);
// Outputs
readonly close = output<void>();
readonly downloadDocument = output<{ type: 'observation' | 'linkset'; id: string }>();
// UI State
readonly activeTab = signal<TabId>('observations');
readonly observationView = signal<ObservationView>('side-by-side');
readonly expandedObservation = signal<string | null>(null);
readonly expandedAocEntry = signal<string | null>(null);
readonly showConflictDetails = signal(false);
// Loading/error state
readonly loading = signal(false);
readonly error = signal<string | null>(null);
// Computed values
readonly observations = computed(() => this.evidenceData()?.observations ?? []);
readonly linkset = computed(() => this.evidenceData()?.linkset ?? null);
readonly policyEvidence = computed(() => this.evidenceData()?.policyEvidence ?? null);
readonly hasConflicts = computed(() => this.evidenceData()?.hasConflicts ?? false);
readonly conflictCount = computed(() => this.evidenceData()?.conflictCount ?? 0);
readonly aocChain = computed(() => {
const policy = this.policyEvidence();
return policy?.aocChain ?? [];
});
readonly policyDecisionClass = computed(() => {
const decision = this.policyEvidence()?.decision;
return this.getDecisionClass(decision);
});
readonly policyDecisionLabel = computed(() => {
const decision = this.policyEvidence()?.decision;
return this.getDecisionLabel(decision);
});
readonly observationSources = computed(() => {
const obs = this.observations();
return obs.map((o) => this.getSourceInfo(o.source));
});
// Tab methods
setActiveTab(tab: TabId): void {
this.activeTab.set(tab);
}
isActiveTab(tab: TabId): boolean {
return this.activeTab() === tab;
}
// Observation view methods
setObservationView(view: ObservationView): void {
this.observationView.set(view);
}
toggleObservationExpanded(observationId: string): void {
const current = this.expandedObservation();
this.expandedObservation.set(current === observationId ? null : observationId);
}
isObservationExpanded(observationId: string): boolean {
return this.expandedObservation() === observationId;
}
// AOC chain methods
toggleAocEntry(attestationId: string): void {
const current = this.expandedAocEntry();
this.expandedAocEntry.set(current === attestationId ? null : attestationId);
}
isAocEntryExpanded(attestationId: string): boolean {
return this.expandedAocEntry() === attestationId;
}
// Conflict methods
toggleConflictDetails(): void {
this.showConflictDetails.update((v) => !v);
}
// Source info helper
getSourceInfo(sourceId: string): SourceInfo {
return (
SOURCE_INFO[sourceId] ?? {
sourceId,
name: sourceId.toUpperCase(),
icon: 'file',
}
);
}
// Decision helpers
getDecisionClass(decision: PolicyDecision | undefined): string {
switch (decision) {
case 'pass':
return 'decision-pass';
case 'warn':
return 'decision-warn';
case 'block':
return 'decision-block';
case 'pending':
default:
return 'decision-pending';
}
}
getDecisionLabel(decision: PolicyDecision | undefined): string {
switch (decision) {
case 'pass':
return 'Passed';
case 'warn':
return 'Warning';
case 'block':
return 'Blocked';
case 'pending':
default:
return 'Pending';
}
}
// Rule result helpers
getRuleClass(passed: boolean): string {
return passed ? 'rule-passed' : 'rule-failed';
}
getRuleIcon(passed: boolean): string {
return passed ? 'check-circle' : 'x-circle';
}
// AOC chain helpers
getAocTypeLabel(type: AocChainEntry['type']): string {
switch (type) {
case 'observation':
return 'Observation';
case 'linkset':
return 'Linkset';
case 'policy':
return 'Policy Decision';
case 'signature':
return 'Signature';
default:
return type;
}
}
getAocTypeClass(type: AocChainEntry['type']): string {
return `aoc-type-${type}`;
}
// Severity helpers
getSeverityClass(score: number): string {
if (score >= 9.0) return 'severity-critical';
if (score >= 7.0) return 'severity-high';
if (score >= 4.0) return 'severity-medium';
return 'severity-low';
}
getSeverityLabel(score: number): string {
if (score >= 9.0) return 'Critical';
if (score >= 7.0) return 'High';
if (score >= 4.0) return 'Medium';
return 'Low';
}
// Download handlers
onDownloadObservation(observationId: string): void {
this.downloadDocument.emit({ type: 'observation', id: observationId });
}
onDownloadLinkset(linksetId: string): void {
this.downloadDocument.emit({ type: 'linkset', id: linksetId });
}
// Close handler
onClose(): void {
this.close.emit();
}
// Date formatting
formatDate(dateStr: string | undefined): string {
if (!dateStr) return 'N/A';
try {
return new Date(dateStr).toLocaleString();
} catch {
return dateStr;
}
}
// Hash truncation for display
truncateHash(hash: string | undefined, length = 12): string {
if (!hash) return 'N/A';
if (hash.length <= length) return hash;
return hash.slice(0, length) + '...';
}
// Track by functions for ngFor
trackByObservationId(_: number, obs: Observation): string {
return obs.observationId;
}
trackByAocId(_: number, entry: AocChainEntry): string {
return entry.attestationId;
}
trackByConflictField(_: number, conflict: LinksetConflict): string {
return conflict.field;
}
trackByRuleId(_: number, rule: PolicyRuleResult): string {
return rule.ruleId;
}
}
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
} from '@angular/core';
import {
AocChainEntry,
EvidenceData,
Linkset,
LinksetConflict,
Observation,
PolicyDecision,
PolicyEvidence,
PolicyRuleResult,
SOURCE_INFO,
SourceInfo,
} from '../../core/api/evidence.models';
import { EvidenceApi, EVIDENCE_API } from '../../core/api/evidence.client';
type TabId = 'observations' | 'linkset' | 'policy' | 'aoc';
type ObservationView = 'side-by-side' | 'stacked';
@Component({
selector: 'app-evidence-panel',
standalone: true,
imports: [CommonModule],
templateUrl: './evidence-panel.component.html',
styleUrls: ['./evidence-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EvidencePanelComponent {
private readonly evidenceApi = inject(EVIDENCE_API);
// Inputs
readonly advisoryId = input.required<string>();
readonly evidenceData = input<EvidenceData | null>(null);
// Outputs
readonly close = output<void>();
readonly downloadDocument = output<{ type: 'observation' | 'linkset'; id: string }>();
// UI State
readonly activeTab = signal<TabId>('observations');
readonly observationView = signal<ObservationView>('side-by-side');
readonly expandedObservation = signal<string | null>(null);
readonly expandedAocEntry = signal<string | null>(null);
readonly showConflictDetails = signal(false);
// Loading/error state
readonly loading = signal(false);
readonly error = signal<string | null>(null);
// Computed values
readonly observations = computed(() => this.evidenceData()?.observations ?? []);
readonly linkset = computed(() => this.evidenceData()?.linkset ?? null);
readonly policyEvidence = computed(() => this.evidenceData()?.policyEvidence ?? null);
readonly hasConflicts = computed(() => this.evidenceData()?.hasConflicts ?? false);
readonly conflictCount = computed(() => this.evidenceData()?.conflictCount ?? 0);
readonly aocChain = computed(() => {
const policy = this.policyEvidence();
return policy?.aocChain ?? [];
});
readonly policyDecisionClass = computed(() => {
const decision = this.policyEvidence()?.decision;
return this.getDecisionClass(decision);
});
readonly policyDecisionLabel = computed(() => {
const decision = this.policyEvidence()?.decision;
return this.getDecisionLabel(decision);
});
readonly observationSources = computed(() => {
const obs = this.observations();
return obs.map((o) => this.getSourceInfo(o.source));
});
// Tab methods
setActiveTab(tab: TabId): void {
this.activeTab.set(tab);
}
isActiveTab(tab: TabId): boolean {
return this.activeTab() === tab;
}
// Observation view methods
setObservationView(view: ObservationView): void {
this.observationView.set(view);
}
toggleObservationExpanded(observationId: string): void {
const current = this.expandedObservation();
this.expandedObservation.set(current === observationId ? null : observationId);
}
isObservationExpanded(observationId: string): boolean {
return this.expandedObservation() === observationId;
}
// AOC chain methods
toggleAocEntry(attestationId: string): void {
const current = this.expandedAocEntry();
this.expandedAocEntry.set(current === attestationId ? null : attestationId);
}
isAocEntryExpanded(attestationId: string): boolean {
return this.expandedAocEntry() === attestationId;
}
// Conflict methods
toggleConflictDetails(): void {
this.showConflictDetails.update((v) => !v);
}
// Source info helper
getSourceInfo(sourceId: string): SourceInfo {
return (
SOURCE_INFO[sourceId] ?? {
sourceId,
name: sourceId.toUpperCase(),
icon: 'file',
}
);
}
// Decision helpers
getDecisionClass(decision: PolicyDecision | undefined): string {
switch (decision) {
case 'pass':
return 'decision-pass';
case 'warn':
return 'decision-warn';
case 'block':
return 'decision-block';
case 'pending':
default:
return 'decision-pending';
}
}
getDecisionLabel(decision: PolicyDecision | undefined): string {
switch (decision) {
case 'pass':
return 'Passed';
case 'warn':
return 'Warning';
case 'block':
return 'Blocked';
case 'pending':
default:
return 'Pending';
}
}
// Rule result helpers
getRuleClass(passed: boolean): string {
return passed ? 'rule-passed' : 'rule-failed';
}
getRuleIcon(passed: boolean): string {
return passed ? 'check-circle' : 'x-circle';
}
// AOC chain helpers
getAocTypeLabel(type: AocChainEntry['type']): string {
switch (type) {
case 'observation':
return 'Observation';
case 'linkset':
return 'Linkset';
case 'policy':
return 'Policy Decision';
case 'signature':
return 'Signature';
default:
return type;
}
}
getAocTypeClass(type: AocChainEntry['type']): string {
return `aoc-type-${type}`;
}
// Severity helpers
getSeverityClass(score: number): string {
if (score >= 9.0) return 'severity-critical';
if (score >= 7.0) return 'severity-high';
if (score >= 4.0) return 'severity-medium';
return 'severity-low';
}
getSeverityLabel(score: number): string {
if (score >= 9.0) return 'Critical';
if (score >= 7.0) return 'High';
if (score >= 4.0) return 'Medium';
return 'Low';
}
// Download handlers
onDownloadObservation(observationId: string): void {
this.downloadDocument.emit({ type: 'observation', id: observationId });
}
onDownloadLinkset(linksetId: string): void {
this.downloadDocument.emit({ type: 'linkset', id: linksetId });
}
// Close handler
onClose(): void {
this.close.emit();
}
// Date formatting
formatDate(dateStr: string | undefined): string {
if (!dateStr) return 'N/A';
try {
return new Date(dateStr).toLocaleString();
} catch {
return dateStr;
}
}
// Hash truncation for display
truncateHash(hash: string | undefined, length = 12): string {
if (!hash) return 'N/A';
if (hash.length <= length) return hash;
return hash.slice(0, length) + '...';
}
// Track by functions for ngFor
trackByObservationId(_: number, obs: Observation): string {
return obs.observationId;
}
trackByAocId(_: number, entry: AocChainEntry): string {
return entry.attestationId;
}
trackByConflictField(_: number, conflict: LinksetConflict): string {
return conflict.field;
}
trackByRuleId(_: number, rule: PolicyRuleResult): string {
return rule.ruleId;
}
}

View File

@@ -1,2 +1,2 @@
export { EvidencePanelComponent } from './evidence-panel.component';
export { EvidencePageComponent } from './evidence-page.component';
export { EvidencePanelComponent } from './evidence-panel.component';
export { EvidencePageComponent } from './evidence-page.component';

View File

@@ -1,489 +1,302 @@
<div class="exception-center" role="main" aria-label="Exception Center">
<!-- Screen reader announcements (ARIA live region) -->
<div
class="sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
>
{{ screenReaderAnnouncement() }}
</div>
<!-- Keyboard shortcuts hint -->
<div class="keyboard-hints" aria-hidden="true">
<span class="keyboard-hint">
<kbd>X</kbd> Create
</span>
<span class="keyboard-hint">
<kbd>A</kbd> Approve
</span>
<span class="keyboard-hint">
<kbd>R</kbd> Reject
</span>
<span class="keyboard-hint">
<kbd>Esc</kbd> Close
</span>
</div>
<!-- Header -->
<header class="exception-center__header">
<div class="exception-center__title-section">
<h1>Exception Center</h1>
<p class="exception-center__subtitle">Manage policy exceptions with workflow approvals</p>
</div>
<div class="exception-center__actions">
<button
type="button"
class="btn btn--secondary"
(click)="refreshData()"
[disabled]="loading()"
aria-label="Refresh exception list"
>
Refresh
</button>
<button
type="button"
class="btn btn--primary"
(click)="openWizard()"
aria-label="Create new exception (keyboard shortcut: X)"
title="Create Exception (X)"
>
Create Exception
</button>
</div>
</header>
<!-- Stats Bar -->
<div class="exception-center__stats" *ngIf="stats() as s">
<div class="stat-card">
<span class="stat-card__value">{{ s.total }}</span>
<span class="stat-card__label">Total</span>
</div>
<div class="stat-card stat-card--pending">
<span class="stat-card__value">{{ s.pendingApproval }}</span>
<span class="stat-card__label">Pending Review</span>
</div>
<div class="stat-card stat-card--warning">
<span class="stat-card__value">{{ s.expiringWithin7Days }}</span>
<span class="stat-card__label">Expiring Soon</span>
</div>
<div class="stat-card stat-card--approved">
<span class="stat-card__value">{{ s.byStatus['approved'] ?? 0 }}</span>
<span class="stat-card__label">Approved</span>
</div>
</div>
<!-- Message Toast -->
<div
class="exception-center__message"
*ngIf="message() as msg"
[class.exception-center__message--success]="messageType() === 'success'"
[class.exception-center__message--error]="messageType() === 'error'"
>
{{ msg }}
</div>
<!-- Toolbar -->
<div class="exception-center__toolbar">
<!-- View Toggle -->
<div class="view-toggle">
<button
type="button"
class="view-toggle__btn"
[class.view-toggle__btn--active]="viewMode() === 'list'"
(click)="setViewMode('list')"
title="List view"
>
List
</button>
<button
type="button"
class="view-toggle__btn"
[class.view-toggle__btn--active]="viewMode() === 'kanban'"
(click)="setViewMode('kanban')"
title="Kanban view"
>
Kanban
</button>
</div>
<!-- Search -->
<div class="search-box">
<input
type="text"
class="search-box__input"
placeholder="Search exceptions..."
[value]="searchQuery()"
(input)="onSearchInput($event)"
/>
<button
type="button"
class="search-box__clear"
*ngIf="searchQuery()"
(click)="clearSearch()"
>
Clear
</button>
</div>
<!-- Filters -->
<div class="filters">
<div class="filter-group">
<label class="filter-group__label">Status</label>
<select
class="filter-group__select"
[value]="statusFilter()"
(change)="setStatusFilter($any($event.target).value)"
>
<option value="all">All Statuses</option>
<option *ngFor="let status of allStatuses" [value]="status">
{{ statusLabels[status] }}
</option>
</select>
</div>
<div class="filter-group">
<label class="filter-group__label">Severity</label>
<select
class="filter-group__select"
[value]="severityFilter()"
(change)="setSeverityFilter($any($event.target).value)"
>
<option value="all">All Severities</option>
<option *ngFor="let sev of allSeverities" [value]="sev">
{{ severityLabels[sev] }}
</option>
</select>
</div>
</div>
</div>
<!-- Loading State -->
<div class="exception-center__loading" *ngIf="loading()">
<span class="spinner"></span>
<span>Loading exceptions...</span>
</div>
<!-- Main Content -->
<div class="exception-center__content" *ngIf="!loading()">
<!-- List View -->
<div class="list-view" *ngIf="viewMode() === 'list'">
<table class="exception-table" *ngIf="filteredExceptions().length > 0">
<thead>
<tr>
<th class="exception-table__th exception-table__th--sortable" (click)="toggleSort('name')">
Name {{ getSortIcon('name') }}
</th>
<th class="exception-table__th exception-table__th--sortable" (click)="toggleSort('status')">
Status {{ getSortIcon('status') }}
</th>
<th class="exception-table__th exception-table__th--sortable" (click)="toggleSort('severity')">
Severity {{ getSortIcon('severity') }}
</th>
<th class="exception-table__th">Scope</th>
<th class="exception-table__th">Timebox</th>
<th class="exception-table__th exception-table__th--sortable" (click)="toggleSort('createdAt')">
Created {{ getSortIcon('createdAt') }}
</th>
<th class="exception-table__th">Actions</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let exc of filteredExceptions(); trackBy: trackByException"
class="exception-table__row"
[class.exception-table__row--selected]="selectedExceptionId() === exc.exceptionId"
(click)="selectException(exc.exceptionId)"
>
<td class="exception-table__td">
<div class="exception-name">
<span class="exception-name__title">{{ exc.displayName || exc.name }}</span>
<span class="exception-name__id">{{ exc.exceptionId }}</span>
</div>
</td>
<td class="exception-table__td">
<span class="chip" [ngClass]="getStatusClass(exc.status)">
{{ statusLabels[exc.status] }}
</span>
</td>
<td class="exception-table__td">
<span class="chip" [ngClass]="getSeverityClass(exc.severity)">
{{ severityLabels[exc.severity] }}
</span>
</td>
<td class="exception-table__td">
<span class="scope-badge">{{ exc.scope.type }}</span>
</td>
<td class="exception-table__td">
<div class="timebox">
<span>{{ formatDate(exc.timebox.startDate) }}</span>
<span class="timebox__separator">-</span>
<span [class.timebox__expiring]="isExpiringSoon(exc)">
{{ formatDate(exc.timebox.endDate) }}
</span>
</div>
</td>
<td class="exception-table__td">
{{ formatDate(exc.createdAt) }}
</td>
<td class="exception-table__td exception-table__td--actions">
<div class="action-buttons">
<button
*ngFor="let targetStatus of getAvailableTransitions(exc.status)"
type="button"
class="btn btn--small btn--action"
(click)="transitionStatus(exc.exceptionId, targetStatus); $event.stopPropagation()"
>
{{ statusLabels[targetStatus] }}
</button>
<button
type="button"
class="btn btn--small btn--danger"
(click)="deleteException(exc.exceptionId); $event.stopPropagation()"
*ngIf="exc.status === 'draft' || exc.status === 'rejected'"
>
Delete
</button>
</div>
</td>
</tr>
</tbody>
</table>
<div class="empty-state" *ngIf="filteredExceptions().length === 0">
<p>No exceptions found matching your filters.</p>
</div>
</div>
<!-- Kanban View -->
<div class="kanban-view" *ngIf="viewMode() === 'kanban'">
<div
class="kanban-column"
*ngFor="let column of kanbanColumns(); trackBy: trackByColumn"
>
<div class="kanban-column__header">
<h3 class="kanban-column__title">{{ column.label }}</h3>
<span class="kanban-column__count">{{ column.count }}</span>
</div>
<div class="kanban-column__cards">
<div
class="kanban-card"
*ngFor="let exc of column.items; trackBy: trackByException"
[class.kanban-card--selected]="selectedExceptionId() === exc.exceptionId"
(click)="selectException(exc.exceptionId)"
>
<div class="kanban-card__header">
<span class="chip" [ngClass]="getSeverityClass(exc.severity)">
{{ severityLabels[exc.severity] }}
</span>
<span class="kanban-card__expiry" *ngIf="isExpiringSoon(exc)">
Expiring soon
</span>
</div>
<h4 class="kanban-card__title">{{ exc.displayName || exc.name }}</h4>
<p class="kanban-card__description" *ngIf="exc.description">
{{ exc.description | slice:0:80 }}{{ exc.description.length > 80 ? '...' : '' }}
</p>
<div class="kanban-card__meta">
<span class="scope-badge scope-badge--small">{{ exc.scope.type }}</span>
<span class="kanban-card__date">{{ formatDate(exc.createdAt) }}</span>
</div>
<div class="kanban-card__actions">
<button
*ngFor="let targetStatus of getAvailableTransitions(exc.status)"
type="button"
class="btn btn--small btn--action"
(click)="transitionStatus(exc.exceptionId, targetStatus); $event.stopPropagation()"
>
{{ statusLabels[targetStatus] }}
</button>
</div>
</div>
<div class="kanban-column__empty" *ngIf="column.items.length === 0">
No exceptions
</div>
</div>
</div>
</div>
</div>
<!-- Detail Panel (Side Panel) -->
<div class="detail-panel" *ngIf="selectedException() as exc">
<div class="detail-panel__header">
<h2>{{ exc.displayName || exc.name }}</h2>
<button type="button" class="detail-panel__close" (click)="clearSelection()">Close</button>
</div>
<div class="detail-panel__content">
<div class="detail-section">
<h3>Status</h3>
<span class="chip chip--large" [ngClass]="getStatusClass(exc.status)">
{{ statusLabels[exc.status] }}
</span>
</div>
<div class="detail-section">
<h3>Severity</h3>
<span class="chip chip--large" [ngClass]="getSeverityClass(exc.severity)">
{{ severityLabels[exc.severity] }}
</span>
</div>
<div class="detail-section">
<h3>Description</h3>
<p>{{ exc.description || 'No description provided.' }}</p>
</div>
<div class="detail-section">
<h3>Scope</h3>
<div class="scope-details">
<div class="scope-detail">
<span class="scope-detail__label">Type:</span>
<span class="scope-detail__value">{{ exc.scope.type }}</span>
</div>
<div class="scope-detail" *ngIf="exc.scope.vulnIds?.length">
<span class="scope-detail__label">Vulnerabilities:</span>
<span class="scope-detail__value">{{ exc.scope.vulnIds.join(', ') }}</span>
</div>
<div class="scope-detail" *ngIf="exc.scope.componentPurls?.length">
<span class="scope-detail__label">Components:</span>
<span class="scope-detail__value">{{ exc.scope.componentPurls.join(', ') }}</span>
</div>
<div class="scope-detail" *ngIf="exc.scope.assetIds?.length">
<span class="scope-detail__label">Assets:</span>
<span class="scope-detail__value">{{ exc.scope.assetIds.join(', ') }}</span>
</div>
</div>
</div>
<div class="detail-section">
<h3>Justification</h3>
<p>{{ exc.justification.text }}</p>
<span class="chip chip--small" *ngIf="exc.justification.template">
Template: {{ exc.justification.template }}
</span>
</div>
<div class="detail-section">
<h3>Timebox</h3>
<div class="timebox-details">
<div class="timebox-detail">
<span class="timebox-detail__label">Start:</span>
<span class="timebox-detail__value">{{ formatDateTime(exc.timebox.startDate) }}</span>
</div>
<div class="timebox-detail">
<span class="timebox-detail__label">End:</span>
<span class="timebox-detail__value" [class.timebox__expiring]="isExpiringSoon(exc)">
{{ formatDateTime(exc.timebox.endDate) }}
</span>
</div>
<div class="timebox-detail" *ngIf="exc.timebox.autoRenew">
<span class="timebox-detail__label">Auto-renew:</span>
<span class="timebox-detail__value">Yes ({{ exc.timebox.renewalCount || 0 }}/{{ exc.timebox.maxRenewals || 'unlimited' }})</span>
</div>
</div>
</div>
<div class="detail-section" *ngIf="exc.approvals?.length">
<h3>Approvals</h3>
<div class="approval-list">
<div class="approval-item" *ngFor="let approval of exc.approvals">
<div class="approval-item__header">
<span class="approval-item__by">{{ approval.approvedBy }}</span>
<span class="approval-item__date">{{ formatDateTime(approval.approvedAt) }}</span>
</div>
<p class="approval-item__comment" *ngIf="approval.comment">{{ approval.comment }}</p>
</div>
</div>
</div>
<div class="detail-section">
<h3>Metadata</h3>
<div class="metadata-grid">
<div class="metadata-item">
<span class="metadata-item__label">Created by:</span>
<span class="metadata-item__value">{{ exc.createdBy }}</span>
</div>
<div class="metadata-item">
<span class="metadata-item__label">Created at:</span>
<span class="metadata-item__value">{{ formatDateTime(exc.createdAt) }}</span>
</div>
<div class="metadata-item" *ngIf="exc.updatedBy">
<span class="metadata-item__label">Updated by:</span>
<span class="metadata-item__value">{{ exc.updatedBy }}</span>
</div>
<div class="metadata-item" *ngIf="exc.updatedAt">
<span class="metadata-item__label">Updated at:</span>
<span class="metadata-item__value">{{ formatDateTime(exc.updatedAt) }}</span>
</div>
</div>
</div>
<!-- Audit Trail Toggle -->
<div class="detail-section">
<button type="button" class="btn btn--secondary" (click)="toggleAuditPanel()">
{{ showAuditPanel() ? 'Hide' : 'Show' }} Audit Trail
</button>
</div>
<!-- Audit Trail -->
<div class="detail-section" *ngIf="showAuditPanel() && exc.auditTrail?.length">
<h3>Audit Trail</h3>
<div class="audit-list">
<div class="audit-item" *ngFor="let entry of exc.auditTrail; trackBy: trackByAudit">
<div class="audit-item__header">
<span class="audit-item__action">{{ entry.action }}</span>
<span class="audit-item__date">{{ formatDateTime(entry.timestamp) }}</span>
</div>
<div class="audit-item__actor">by {{ entry.actor }}</div>
<div class="audit-item__transition" *ngIf="entry.previousStatus && entry.newStatus">
{{ statusLabels[entry.previousStatus] }} &rarr; {{ statusLabels[entry.newStatus] }}
</div>
</div>
</div>
</div>
<div class="detail-section" *ngIf="showAuditPanel() && !exc.auditTrail?.length">
<p class="empty-audit">No audit entries recorded.</p>
</div>
</div>
<!-- Actions -->
<div class="detail-panel__actions">
<button
*ngFor="let targetStatus of getAvailableTransitions(exc.status)"
type="button"
class="btn btn--primary"
(click)="transitionStatus(exc.exceptionId, targetStatus)"
>
{{ statusLabels[targetStatus] }}
</button>
<button
type="button"
class="btn btn--danger"
(click)="deleteException(exc.exceptionId)"
*ngIf="exc.status === 'draft' || exc.status === 'rejected'"
>
Delete
</button>
</div>
</div>
<!-- Wizard Modal -->
<div class="wizard-modal" *ngIf="showWizard()">
<div class="wizard-modal__backdrop" (click)="closeWizard()"></div>
<div class="wizard-modal__container">
<app-exception-wizard
(created)="onExceptionCreated($event)"
(cancelled)="closeWizard()"
></app-exception-wizard>
</div>
</div>
</div>
<div class="exception-center">
<!-- Header -->
<header class="center-header">
<div class="header-left">
<h2 class="center-title">Exception Center</h2>
<div class="status-chips">
@for (col of kanbanColumns; track col.status) {
<span
class="status-chip"
[style.borderColor]="col.color"
[class.active]="filter().status?.includes(col.status)"
(click)="updateFilter('status', filter().status?.includes(col.status)
? filter().status?.filter(s => s !== col.status)
: [...(filter().status || []), col.status])"
>
{{ col.label }}
<span class="chip-count">{{ statusCounts()[col.status] || 0 }}</span>
</span>
}
</div>
</div>
<div class="header-right">
<div class="view-toggle">
<button
class="toggle-btn"
[class.active]="viewMode() === 'list'"
(click)="setViewMode('list')"
title="List view"
>
=
</button>
<button
class="toggle-btn"
[class.active]="viewMode() === 'kanban'"
(click)="setViewMode('kanban')"
title="Kanban view"
>
#
</button>
</div>
<button class="btn-filter" (click)="toggleFilters()" [class.active]="showFilters()">
Filters
</button>
<button class="btn-create" (click)="onCreate()">
+ New Exception
</button>
</div>
</header>
<!-- Filters Panel -->
@if (showFilters()) {
<div class="filters-panel">
<div class="filter-row">
<div class="filter-group">
<label class="filter-label">Search</label>
<input
type="search"
class="filter-input"
placeholder="Search exceptions..."
[value]="filter().search || ''"
(input)="updateFilter('search', $any($event.target).value)"
/>
</div>
<div class="filter-group">
<label class="filter-label">Type</label>
<div class="filter-chips">
@for (type of ['vulnerability', 'license', 'policy', 'entropy', 'determinism']; track type) {
<button
class="filter-chip"
[class.active]="filter().type?.includes($any(type))"
(click)="updateFilter('type', filter().type?.includes($any(type))
? filter().type?.filter(t => t !== type)
: [...(filter().type || []), type])"
>
{{ type | titlecase }}
</button>
}
</div>
</div>
<div class="filter-group">
<label class="filter-label">Severity</label>
<div class="filter-chips">
@for (sev of ['critical', 'high', 'medium', 'low']; track sev) {
<button
class="filter-chip"
[class]="'sev-' + sev"
[class.active]="filter().severity?.includes(sev)"
(click)="updateFilter('severity', filter().severity?.includes(sev)
? filter().severity?.filter(s => s !== sev)
: [...(filter().severity || []), sev])"
>
{{ sev | titlecase }}
</button>
}
</div>
</div>
<div class="filter-group">
<label class="filter-label">Tags</label>
<div class="filter-chips tags">
@for (tag of allTags().slice(0, 8); track tag) {
<button
class="filter-chip tag"
[class.active]="filter().tags?.includes(tag)"
(click)="updateFilter('tags', filter().tags?.includes(tag)
? filter().tags?.filter(t => t !== tag)
: [...(filter().tags || []), tag])"
>
{{ tag }}
</button>
}
</div>
</div>
<div class="filter-group">
<label class="filter-checkbox">
<input
type="checkbox"
[checked]="filter().expiringSoon"
(change)="updateFilter('expiringSoon', $any($event.target).checked)"
/>
Expiring soon
</label>
</div>
</div>
<button class="btn-clear-filters" (click)="clearFilters()">Clear filters</button>
</div>
}
<!-- List View -->
@if (viewMode() === 'list') {
<div class="list-view">
<!-- Sort Header -->
<div class="list-header">
<button class="sort-btn" (click)="setSort('title')">
Title
@if (sort().field === 'title') {
<span class="sort-icon">{{ sort().direction === 'asc' ? '^' : 'v' }}</span>
}
</button>
<button class="sort-btn" (click)="setSort('severity')">
Severity
@if (sort().field === 'severity') {
<span class="sort-icon">{{ sort().direction === 'asc' ? '^' : 'v' }}</span>
}
</button>
<span class="col-header">Status</span>
<button class="sort-btn" (click)="setSort('expiresAt')">
Expires
@if (sort().field === 'expiresAt') {
<span class="sort-icon">{{ sort().direction === 'asc' ? '^' : 'v' }}</span>
}
</button>
<button class="sort-btn" (click)="setSort('updatedAt')">
Updated
@if (sort().field === 'updatedAt') {
<span class="sort-icon">{{ sort().direction === 'asc' ? '^' : 'v' }}</span>
}
</button>
<span class="col-header">Actions</span>
</div>
<!-- Exception Rows -->
<div class="list-body">
@for (exc of filteredExceptions(); track exc.id) {
<div class="exception-row" [class]="'status-' + exc.status">
<button class="row-main" (click)="onSelect(exc)">
<div class="exc-title-cell">
<span class="type-badge">{{ getTypeIcon(exc.type) }}</span>
<div class="exc-title-info">
<span class="exc-title">{{ exc.title }}</span>
<span class="exc-id">{{ exc.id }}</span>
</div>
</div>
<div class="exc-severity-cell">
<span class="severity-badge" [class]="getSeverityClass(exc.severity)">
{{ exc.severity | titlecase }}
</span>
</div>
<div class="exc-status-cell">
<span class="status-badge" [class]="'status-' + exc.status">
{{ getStatusIcon(exc.status) }} {{ exc.status | titlecase }}
</span>
</div>
<div class="exc-expires-cell">
<span
class="expires-text"
[class.warning]="exc.timebox.isWarning"
[class.expired]="exc.timebox.isExpired"
>
{{ formatRemainingDays(exc.timebox.remainingDays) }}
</span>
</div>
<div class="exc-updated-cell">
{{ formatDate(exc.updatedAt) }}
</div>
</button>
<div class="row-actions">
@for (trans of getAvailableTransitions(exc); track trans.to) {
<button
class="action-btn"
[title]="trans.action"
(click)="onTransition(exc, trans.to)"
>
{{ trans.action }}
</button>
}
<button class="action-btn audit" (click)="onViewAudit(exc)" title="View audit log">
[A]
</button>
</div>
</div>
}
@if (filteredExceptions().length === 0) {
<div class="empty-state">
<p>No exceptions match the current filters</p>
<button class="btn-link" (click)="clearFilters()">Clear filters</button>
</div>
}
</div>
</div>
}
<!-- Kanban View -->
@if (viewMode() === 'kanban') {
<div class="kanban-view">
@for (col of kanbanColumns; track col.status) {
<div class="kanban-column">
<div class="column-header" [style.borderColor]="col.color">
<span class="column-title">{{ col.label }}</span>
<span class="column-count">{{ exceptionsByStatus().get(col.status)?.length || 0 }}</span>
</div>
<div class="column-body">
@for (exc of exceptionsByStatus().get(col.status) || []; track exc.id) {
<div class="kanban-card" [class]="getSeverityClass(exc.severity)">
<button class="card-main" (click)="onSelect(exc)">
<div class="card-header">
<span class="type-badge">{{ getTypeIcon(exc.type) }}</span>
<span class="severity-dot" [class]="getSeverityClass(exc.severity)"></span>
</div>
<h4 class="card-title">{{ exc.title }}</h4>
<p class="card-id">{{ exc.id }}</p>
<div class="card-meta">
<span
class="expires-badge"
[class.warning]="exc.timebox.isWarning"
[class.expired]="exc.timebox.isExpired"
>
{{ formatRemainingDays(exc.timebox.remainingDays) }}
</span>
</div>
@if (exc.tags.length > 0) {
<div class="card-tags">
@for (tag of exc.tags.slice(0, 3); track tag) {
<span class="tag">{{ tag }}</span>
}
</div>
}
</button>
<div class="card-actions">
@for (trans of getAvailableTransitions(exc); track trans.to) {
<button
class="card-action-btn"
(click)="onTransition(exc, trans.to)"
>
{{ trans.action }}
</button>
}
</div>
</div>
}
@if ((exceptionsByStatus().get(col.status)?.length || 0) === 0) {
<div class="column-empty">No exceptions</div>
}
</div>
</div>
}
</div>
}
<!-- Footer Stats -->
<footer class="center-footer">
<span class="total-count">
{{ filteredExceptions().length }} of {{ exceptions().length }} exceptions
</span>
</footer>
</div>

View File

@@ -1,564 +1,246 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
HostListener,
OnDestroy,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import {
EXCEPTION_API,
ExceptionApi,
MockExceptionApiService,
} from '../../core/api/exception.client';
import {
Exception,
ExceptionKanbanColumn,
ExceptionSeverity,
ExceptionStats,
ExceptionStatus,
ExceptionsQueryOptions,
} from '../../core/api/exception.models';
import { ExceptionWizardComponent } from './exception-wizard.component';
type ViewMode = 'list' | 'kanban';
type StatusFilter = ExceptionStatus | 'all';
type SeverityFilter = ExceptionSeverity | 'all';
type SortField = 'createdAt' | 'updatedAt' | 'name' | 'severity' | 'status';
type SortOrder = 'asc' | 'desc';
const STATUS_LABELS: Record<ExceptionStatus, string> = {
draft: 'Draft',
pending_review: 'Pending Review',
approved: 'Approved',
rejected: 'Rejected',
expired: 'Expired',
revoked: 'Revoked',
};
const SEVERITY_LABELS: Record<ExceptionSeverity, string> = {
critical: 'Critical',
high: 'High',
medium: 'Medium',
low: 'Low',
};
const KANBAN_COLUMN_ORDER: ExceptionStatus[] = [
'draft',
'pending_review',
'approved',
'rejected',
'expired',
'revoked',
];
@Component({
selector: 'app-exception-center',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, ExceptionWizardComponent],
templateUrl: './exception-center.component.html',
styleUrls: ['./exception-center.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{ provide: EXCEPTION_API, useClass: MockExceptionApiService },
],
})
export class ExceptionCenterComponent implements OnInit, OnDestroy {
private readonly api = inject<ExceptionApi>(EXCEPTION_API);
private readonly formBuilder = inject(NonNullableFormBuilder);
// Screen reader announcements
readonly screenReaderAnnouncement = signal('');
private announcementTimeout?: ReturnType<typeof setTimeout>;
// View state
readonly viewMode = signal<ViewMode>('list');
readonly loading = signal(false);
readonly message = signal<string | null>(null);
readonly messageType = signal<'success' | 'error' | 'info'>('info');
readonly showWizard = signal(false);
// Data
readonly exceptions = signal<Exception[]>([]);
readonly stats = signal<ExceptionStats | null>(null);
readonly selectedExceptionId = signal<string | null>(null);
// Filters & sorting
readonly statusFilter = signal<StatusFilter>('all');
readonly severityFilter = signal<SeverityFilter>('all');
readonly searchQuery = signal('');
readonly sortField = signal<SortField>('createdAt');
readonly sortOrder = signal<SortOrder>('desc');
// Constants for template
readonly statusLabels = STATUS_LABELS;
readonly severityLabels = SEVERITY_LABELS;
readonly allStatuses: ExceptionStatus[] = KANBAN_COLUMN_ORDER;
readonly allSeverities: ExceptionSeverity[] = ['critical', 'high', 'medium', 'low'];
// Computed: filtered and sorted list
readonly filteredExceptions = computed(() => {
let items = [...this.exceptions()];
const status = this.statusFilter();
const severity = this.severityFilter();
const search = this.searchQuery().toLowerCase();
if (status !== 'all') {
items = items.filter((e) => e.status === status);
}
if (severity !== 'all') {
items = items.filter((e) => e.severity === severity);
}
if (search) {
items = items.filter(
(e) =>
e.name.toLowerCase().includes(search) ||
e.displayName?.toLowerCase().includes(search) ||
e.description?.toLowerCase().includes(search)
);
}
return this.sortExceptions(items);
});
// Computed: kanban columns
readonly kanbanColumns = computed<ExceptionKanbanColumn[]>(() => {
const items = this.exceptions();
const severity = this.severityFilter();
const search = this.searchQuery().toLowerCase();
let filtered = items;
if (severity !== 'all') {
filtered = filtered.filter((e) => e.severity === severity);
}
if (search) {
filtered = filtered.filter(
(e) =>
e.name.toLowerCase().includes(search) ||
e.displayName?.toLowerCase().includes(search) ||
e.description?.toLowerCase().includes(search)
);
}
return KANBAN_COLUMN_ORDER.map((status) => {
const columnItems = filtered.filter((e) => e.status === status);
return {
status,
label: STATUS_LABELS[status],
items: columnItems,
count: columnItems.length,
};
});
});
// Computed: selected exception
readonly selectedException = computed(() => {
const id = this.selectedExceptionId();
if (!id) return null;
return this.exceptions().find((e) => e.exceptionId === id) ?? null;
});
// Search form
readonly searchForm = this.formBuilder.group({
query: this.formBuilder.control(''),
});
// Audit view state
readonly showAuditPanel = signal(false);
async ngOnInit(): Promise<void> {
await this.loadData();
this.announceToScreenReader('Exception Center loaded. Press X to create exception, A to approve, R to reject.');
}
ngOnDestroy(): void {
if (this.announcementTimeout) {
clearTimeout(this.announcementTimeout);
}
}
// Keyboard shortcuts
@HostListener('document:keydown', ['$event'])
handleKeyboardShortcut(event: KeyboardEvent): void {
// Ignore shortcuts when typing in form fields
const target = event.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable
) {
return;
}
// Ignore if modifier keys are pressed
if (event.ctrlKey || event.altKey || event.metaKey) {
return;
}
switch (event.key.toLowerCase()) {
case 'x':
event.preventDefault();
this.handleCreateShortcut();
break;
case 'a':
event.preventDefault();
this.handleApproveShortcut();
break;
case 'r':
event.preventDefault();
this.handleRejectShortcut();
break;
case 'escape':
if (this.showWizard()) {
event.preventDefault();
this.closeWizard();
} else if (this.selectedExceptionId()) {
event.preventDefault();
this.clearSelection();
}
break;
}
}
private handleCreateShortcut(): void {
if (this.showWizard()) {
return; // Wizard already open
}
this.openWizard();
this.announceToScreenReader('Exception creation wizard opened');
}
private handleApproveShortcut(): void {
const selected = this.selectedException();
if (!selected) {
this.announceToScreenReader('No exception selected. Select an exception first.');
return;
}
if (selected.status !== 'pending_review') {
this.announceToScreenReader(`Cannot approve exception. Current status is ${STATUS_LABELS[selected.status]}.`);
return;
}
this.approveException(selected.exceptionId);
}
private handleRejectShortcut(): void {
const selected = this.selectedException();
if (!selected) {
this.announceToScreenReader('No exception selected. Select an exception first.');
return;
}
if (selected.status !== 'pending_review') {
this.announceToScreenReader(`Cannot reject exception. Current status is ${STATUS_LABELS[selected.status]}.`);
return;
}
this.rejectException(selected.exceptionId);
}
// Screen reader announcements
private announceToScreenReader(message: string): void {
// Clear any pending announcement
if (this.announcementTimeout) {
clearTimeout(this.announcementTimeout);
}
// Set the announcement (this triggers the ARIA live region)
this.screenReaderAnnouncement.set(message);
// Clear after a short delay to allow re-announcement of same message
this.announcementTimeout = setTimeout(() => {
this.screenReaderAnnouncement.set('');
}, 1000);
}
async loadData(): Promise<void> {
this.loading.set(true);
this.message.set(null);
try {
const [exceptionsResponse, statsResponse] = await Promise.all([
firstValueFrom(this.api.listExceptions()),
firstValueFrom(this.api.getStats()),
]);
this.exceptions.set([...exceptionsResponse.items]);
this.stats.set(statsResponse);
} catch (error) {
this.showMessage(this.toErrorMessage(error), 'error');
} finally {
this.loading.set(false);
}
}
async refreshData(): Promise<void> {
await this.loadData();
}
// View mode
setViewMode(mode: ViewMode): void {
this.viewMode.set(mode);
}
// Filters
setStatusFilter(status: StatusFilter): void {
this.statusFilter.set(status);
}
setSeverityFilter(severity: SeverityFilter): void {
this.severityFilter.set(severity);
}
onSearchInput(event: Event): void {
const input = event.target as HTMLInputElement;
this.searchQuery.set(input.value);
}
clearSearch(): void {
this.searchQuery.set('');
this.searchForm.patchValue({ query: '' });
}
// Sorting
toggleSort(field: SortField): void {
if (this.sortField() === field) {
this.sortOrder.set(this.sortOrder() === 'asc' ? 'desc' : 'asc');
} else {
this.sortField.set(field);
this.sortOrder.set('desc');
}
}
getSortIcon(field: SortField): string {
if (this.sortField() !== field) return '';
return this.sortOrder() === 'asc' ? '↑' : '↓';
}
// Selection
selectException(exceptionId: string): void {
this.selectedExceptionId.set(exceptionId);
this.showAuditPanel.set(false);
}
clearSelection(): void {
this.selectedExceptionId.set(null);
this.showAuditPanel.set(false);
}
// Workflow transitions
async transitionStatus(exceptionId: string, newStatus: ExceptionStatus): Promise<void> {
const exception = this.exceptions().find((e) => e.exceptionId === exceptionId);
const exceptionName = exception?.name ?? 'Exception';
this.loading.set(true);
this.announceToScreenReader(`Processing ${exceptionName}...`);
try {
await firstValueFrom(
this.api.transitionStatus({ exceptionId, newStatus })
);
await this.loadData();
const successMessage = `${exceptionName} transitioned to ${STATUS_LABELS[newStatus]}`;
this.showMessage(successMessage, 'success');
this.announceToScreenReader(successMessage);
} catch (error) {
const errorMessage = this.toErrorMessage(error);
this.showMessage(errorMessage, 'error');
this.announceToScreenReader(`Error: ${errorMessage}`);
} finally {
this.loading.set(false);
}
}
async submitForReview(exceptionId: string): Promise<void> {
this.announceToScreenReader('Submitting exception for review...');
await this.transitionStatus(exceptionId, 'pending_review');
}
async approveException(exceptionId: string): Promise<void> {
this.announceToScreenReader('Approving exception...');
await this.transitionStatus(exceptionId, 'approved');
}
async rejectException(exceptionId: string): Promise<void> {
this.announceToScreenReader('Rejecting exception...');
await this.transitionStatus(exceptionId, 'rejected');
}
async revokeException(exceptionId: string): Promise<void> {
this.announceToScreenReader('Revoking exception...');
await this.transitionStatus(exceptionId, 'revoked');
}
// Delete
async deleteException(exceptionId: string): Promise<void> {
if (!confirm('Are you sure you want to delete this exception?')) {
return;
}
this.loading.set(true);
try {
await firstValueFrom(this.api.deleteException(exceptionId));
if (this.selectedExceptionId() === exceptionId) {
this.clearSelection();
}
await this.loadData();
this.showMessage('Exception deleted', 'success');
} catch (error) {
this.showMessage(this.toErrorMessage(error), 'error');
} finally {
this.loading.set(false);
}
}
// Audit panel
toggleAuditPanel(): void {
this.showAuditPanel.set(!this.showAuditPanel());
}
// Wizard
openWizard(): void {
this.showWizard.set(true);
}
closeWizard(): void {
this.showWizard.set(false);
}
async onExceptionCreated(exception: Exception): Promise<void> {
this.closeWizard();
await this.loadData();
this.showMessage(`Exception "${exception.name}" created`, 'success');
this.selectException(exception.exceptionId);
}
// Helpers
getStatusClass(status: ExceptionStatus): string {
switch (status) {
case 'approved':
return 'status--approved';
case 'pending_review':
return 'status--pending';
case 'rejected':
return 'status--rejected';
case 'expired':
return 'status--expired';
case 'revoked':
return 'status--revoked';
default:
return 'status--draft';
}
}
getSeverityClass(severity: ExceptionSeverity): string {
return `severity--${severity}`;
}
formatDate(dateString: string | undefined): string {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
formatDateTime(dateString: string | undefined): string {
if (!dateString) return '-';
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
isExpiringSoon(exception: Exception): boolean {
const endDate = new Date(exception.timebox.endDate);
const now = new Date();
const sevenDaysFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
return endDate <= sevenDaysFromNow && endDate > now;
}
getAvailableTransitions(status: ExceptionStatus): ExceptionStatus[] {
switch (status) {
case 'draft':
return ['pending_review'];
case 'pending_review':
return ['approved', 'rejected'];
case 'approved':
return ['revoked'];
case 'rejected':
return ['draft'];
case 'expired':
return ['draft'];
case 'revoked':
return ['draft'];
default:
return [];
}
}
trackByException = (_: number, item: Exception) => item.exceptionId;
trackByColumn = (_: number, item: ExceptionKanbanColumn) => item.status;
trackByAudit = (_: number, item: { auditId: string }) => item.auditId;
private sortExceptions(items: Exception[]): Exception[] {
const field = this.sortField();
const order = this.sortOrder();
return items.sort((a, b) => {
let comparison = 0;
switch (field) {
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'severity':
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
comparison = severityOrder[a.severity] - severityOrder[b.severity];
break;
case 'status':
comparison = a.status.localeCompare(b.status);
break;
case 'updatedAt':
comparison = (a.updatedAt ?? a.createdAt).localeCompare(
b.updatedAt ?? b.createdAt
);
break;
default:
comparison = a.createdAt.localeCompare(b.createdAt);
}
return order === 'asc' ? comparison : -comparison;
});
}
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
this.message.set(text);
this.messageType.set(type);
setTimeout(() => this.message.set(null), 5000);
}
private toErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
return 'Operation failed. Please retry.';
}
}
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
Exception,
ExceptionStatus,
ExceptionType,
ExceptionFilter,
ExceptionSortOption,
ExceptionTransition,
EXCEPTION_TRANSITIONS,
KANBAN_COLUMNS,
} from '../../core/api/exception.models';
type ViewMode = 'list' | 'kanban';
@Component({
selector: 'app-exception-center',
standalone: true,
imports: [CommonModule],
templateUrl: './exception-center.component.html',
styleUrls: ['./exception-center.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExceptionCenterComponent {
/** All exceptions */
readonly exceptions = input.required<Exception[]>();
/** Current user role for transition permissions */
readonly userRole = input<string>('user');
/** Emits when creating new exception */
readonly create = output<void>();
/** Emits when selecting an exception */
readonly select = output<Exception>();
/** Emits when performing a workflow transition */
readonly transition = output<{ exception: Exception; to: ExceptionStatus }>();
/** Emits when viewing audit log */
readonly viewAudit = output<Exception>();
readonly viewMode = signal<ViewMode>('list');
readonly filter = signal<ExceptionFilter>({});
readonly sort = signal<ExceptionSortOption>({ field: 'updatedAt', direction: 'desc' });
readonly expandedId = signal<string | null>(null);
readonly showFilters = signal(false);
readonly kanbanColumns = KANBAN_COLUMNS;
readonly filteredExceptions = computed(() => {
let result = [...this.exceptions()];
const f = this.filter();
// Apply filters
if (f.status && f.status.length > 0) {
result = result.filter((e) => f.status!.includes(e.status));
}
if (f.type && f.type.length > 0) {
result = result.filter((e) => f.type!.includes(e.type));
}
if (f.severity && f.severity.length > 0) {
result = result.filter((e) => f.severity!.includes(e.severity));
}
if (f.search) {
const search = f.search.toLowerCase();
result = result.filter(
(e) =>
e.title.toLowerCase().includes(search) ||
e.justification.toLowerCase().includes(search) ||
e.id.toLowerCase().includes(search)
);
}
if (f.tags && f.tags.length > 0) {
result = result.filter((e) => f.tags!.some((t) => e.tags.includes(t)));
}
if (f.expiringSoon) {
result = result.filter((e) => e.timebox.isWarning && !e.timebox.isExpired);
}
// Apply sort
const s = this.sort();
result.sort((a, b) => {
let cmp = 0;
switch (s.field) {
case 'createdAt':
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
break;
case 'updatedAt':
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
break;
case 'expiresAt':
cmp = new Date(a.timebox.expiresAt).getTime() - new Date(b.timebox.expiresAt).getTime();
break;
case 'severity':
const sevOrder = { critical: 0, high: 1, medium: 2, low: 3 };
cmp = sevOrder[a.severity] - sevOrder[b.severity];
break;
case 'title':
cmp = a.title.localeCompare(b.title);
break;
}
return s.direction === 'asc' ? cmp : -cmp;
});
return result;
});
readonly exceptionsByStatus = computed(() => {
const byStatus = new Map<ExceptionStatus, Exception[]>();
for (const col of KANBAN_COLUMNS) {
byStatus.set(col.status, []);
}
for (const exc of this.filteredExceptions()) {
const list = byStatus.get(exc.status) || [];
list.push(exc);
byStatus.set(exc.status, list);
}
return byStatus;
});
readonly statusCounts = computed(() => {
const counts: Record<string, number> = {};
for (const exc of this.exceptions()) {
counts[exc.status] = (counts[exc.status] || 0) + 1;
}
return counts;
});
readonly allTags = computed(() => {
const tags = new Set<string>();
for (const exc of this.exceptions()) {
for (const tag of exc.tags) {
tags.add(tag);
}
}
return Array.from(tags).sort();
});
setViewMode(mode: ViewMode): void {
this.viewMode.set(mode);
}
toggleFilters(): void {
this.showFilters.update((v) => !v);
}
updateFilter(key: keyof ExceptionFilter, value: unknown): void {
this.filter.update((f) => ({ ...f, [key]: value }));
}
clearFilters(): void {
this.filter.set({});
}
setSort(field: ExceptionSortOption['field']): void {
this.sort.update((s) => ({
field,
direction: s.field === field && s.direction === 'desc' ? 'asc' : 'desc',
}));
}
toggleExpand(id: string): void {
this.expandedId.update((current) => (current === id ? null : id));
}
onCreate(): void {
this.create.emit();
}
onSelect(exc: Exception): void {
this.select.emit(exc);
}
onTransition(exc: Exception, to: ExceptionStatus): void {
this.transition.emit({ exception: exc, to });
}
onViewAudit(exc: Exception): void {
this.viewAudit.emit(exc);
}
getAvailableTransitions(exc: Exception): ExceptionTransition[] {
return EXCEPTION_TRANSITIONS.filter(
(t) => t.from === exc.status && t.allowedRoles.includes(this.userRole())
);
}
getStatusIcon(status: ExceptionStatus): string {
switch (status) {
case 'draft':
return '[D]';
case 'pending':
return '[?]';
case 'approved':
return '[+]';
case 'active':
return '[*]';
case 'expired':
return '[X]';
case 'revoked':
return '[!]';
default:
return '[-]';
}
}
getTypeIcon(type: ExceptionType): string {
switch (type) {
case 'vulnerability':
return 'V';
case 'license':
return 'L';
case 'policy':
return 'P';
case 'entropy':
return 'E';
case 'determinism':
return 'D';
default:
return '?';
}
}
getSeverityClass(severity: string): string {
return 'severity-' + severity;
}
formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString();
}
formatRemainingDays(days: number): string {
if (days < 0) return 'Expired';
if (days === 0) return 'Expires today';
if (days === 1) return '1 day left';
return days + ' days left';
}
}

View File

@@ -1,168 +1,168 @@
<div class="draft-inline">
<header class="draft-inline__header">
<h3>Draft Exception</h3>
<span class="draft-inline__source">from {{ context.sourceLabel }}</span>
</header>
<!-- Error Message -->
<div class="draft-inline__error" *ngIf="error()">
{{ error() }}
</div>
<!-- Scope Summary -->
<div class="draft-inline__scope">
<span class="draft-inline__scope-label">Scope:</span>
<span class="draft-inline__scope-value">{{ scopeSummary() }}</span>
<span class="draft-inline__scope-type">{{ scopeType() }}</span>
</div>
<!-- Vulnerabilities Preview -->
<div class="draft-inline__vulns" *ngIf="context.vulnIds?.length">
<span class="draft-inline__vulns-label">Vulnerabilities:</span>
<div class="draft-inline__vulns-list">
<span class="vuln-chip" *ngFor="let vulnId of context.vulnIds | slice:0:5">
{{ vulnId }}
</span>
<span class="vuln-chip vuln-chip--more" *ngIf="context.vulnIds.length > 5">
+{{ context.vulnIds.length - 5 }} more
</span>
</div>
</div>
<!-- Components Preview -->
<div class="draft-inline__components" *ngIf="context.componentPurls?.length">
<span class="draft-inline__components-label">Components:</span>
<div class="draft-inline__components-list">
<span class="component-chip" *ngFor="let purl of context.componentPurls | slice:0:3">
{{ purl | slice:-40 }}
</span>
<span class="component-chip component-chip--more" *ngIf="context.componentPurls.length > 3">
+{{ context.componentPurls.length - 3 }} more
</span>
</div>
</div>
<form [formGroup]="draftForm" class="draft-inline__form">
<!-- Name -->
<div class="form-row">
<label class="form-label" for="draftName">
Name <span class="required">*</span>
</label>
<input
type="text"
id="draftName"
class="form-input"
formControlName="name"
placeholder="e.g., cve-2021-44228-exception"
/>
</div>
<!-- Severity -->
<div class="form-row">
<label class="form-label">Severity</label>
<div class="severity-chips">
<label
*ngFor="let opt of severityOptions"
class="severity-option"
[class.severity-option--selected]="draftForm.controls.severity.value === opt.value"
>
<input type="radio" [value]="opt.value" formControlName="severity" />
<span class="severity-chip severity-chip--{{ opt.value }}">{{ opt.label }}</span>
</label>
</div>
</div>
<!-- Quick Justification -->
<div class="form-row">
<label class="form-label">Quick Justification</label>
<div class="template-chips">
<button
type="button"
*ngFor="let template of quickTemplates"
class="template-chip"
[class.template-chip--selected]="draftForm.controls.justificationTemplate.value === template.id"
(click)="selectTemplate(template.id)"
>
{{ template.label }}
</button>
</div>
</div>
<!-- Justification Text -->
<div class="form-row">
<label class="form-label" for="justificationText">
Justification <span class="required">*</span>
</label>
<textarea
id="justificationText"
class="form-textarea"
formControlName="justificationText"
rows="3"
placeholder="Explain why this exception is needed..."
></textarea>
<span class="form-hint">Minimum 20 characters</span>
</div>
<!-- Timebox -->
<div class="form-row form-row--inline">
<label class="form-label" for="timeboxDays">Duration</label>
<div class="timebox-quick">
<button type="button" class="timebox-btn" (click)="draftForm.patchValue({ timeboxDays: 7 })">7d</button>
<button type="button" class="timebox-btn" (click)="draftForm.patchValue({ timeboxDays: 14 })">14d</button>
<button type="button" class="timebox-btn" (click)="draftForm.patchValue({ timeboxDays: 30 })">30d</button>
<button type="button" class="timebox-btn" (click)="draftForm.patchValue({ timeboxDays: 90 })">90d</button>
<input
type="number"
id="timeboxDays"
class="form-input form-input--small"
formControlName="timeboxDays"
min="1"
max="365"
/>
<span class="timebox-label">days</span>
</div>
</div>
</form>
<!-- Simulation Toggle -->
<div class="draft-inline__simulation">
<button type="button" class="simulation-toggle" (click)="toggleSimulation()">
{{ showSimulation() ? 'Hide' : 'Show' }} Impact Simulation
</button>
<div class="simulation-result" *ngIf="showSimulation() && simulationResult() as sim">
<div class="simulation-stat">
<span class="simulation-stat__label">Affected Findings:</span>
<span class="simulation-stat__value">~{{ sim.affectedFindings }}</span>
</div>
<div class="simulation-stat">
<span class="simulation-stat__label">Policy Impact:</span>
<span class="simulation-stat__value simulation-stat__value--{{ sim.policyImpact }}">
{{ sim.policyImpact }}
</span>
</div>
<div class="simulation-stat">
<span class="simulation-stat__label">Coverage Estimate:</span>
<span class="simulation-stat__value">{{ sim.coverageEstimate }}</span>
</div>
</div>
</div>
<!-- Actions -->
<footer class="draft-inline__footer">
<button type="button" class="btn btn--text" (click)="cancel()">
Cancel
</button>
<button type="button" class="btn btn--secondary" (click)="expandToFullWizard()">
Full Wizard
</button>
<button
type="button"
class="btn btn--primary"
[disabled]="!canSubmit()"
(click)="submitDraft()"
>
{{ loading() ? 'Creating...' : 'Create Draft' }}
</button>
</footer>
</div>
<div class="draft-inline">
<header class="draft-inline__header">
<h3>Draft Exception</h3>
<span class="draft-inline__source">from {{ context.sourceLabel }}</span>
</header>
<!-- Error Message -->
<div class="draft-inline__error" *ngIf="error()">
{{ error() }}
</div>
<!-- Scope Summary -->
<div class="draft-inline__scope">
<span class="draft-inline__scope-label">Scope:</span>
<span class="draft-inline__scope-value">{{ scopeSummary() }}</span>
<span class="draft-inline__scope-type">{{ scopeType() }}</span>
</div>
<!-- Vulnerabilities Preview -->
<div class="draft-inline__vulns" *ngIf="context.vulnIds?.length">
<span class="draft-inline__vulns-label">Vulnerabilities:</span>
<div class="draft-inline__vulns-list">
<span class="vuln-chip" *ngFor="let vulnId of context.vulnIds | slice:0:5">
{{ vulnId }}
</span>
<span class="vuln-chip vuln-chip--more" *ngIf="context.vulnIds.length > 5">
+{{ context.vulnIds.length - 5 }} more
</span>
</div>
</div>
<!-- Components Preview -->
<div class="draft-inline__components" *ngIf="context.componentPurls?.length">
<span class="draft-inline__components-label">Components:</span>
<div class="draft-inline__components-list">
<span class="component-chip" *ngFor="let purl of context.componentPurls | slice:0:3">
{{ purl | slice:-40 }}
</span>
<span class="component-chip component-chip--more" *ngIf="context.componentPurls.length > 3">
+{{ context.componentPurls.length - 3 }} more
</span>
</div>
</div>
<form [formGroup]="draftForm" class="draft-inline__form">
<!-- Name -->
<div class="form-row">
<label class="form-label" for="draftName">
Name <span class="required">*</span>
</label>
<input
type="text"
id="draftName"
class="form-input"
formControlName="name"
placeholder="e.g., cve-2021-44228-exception"
/>
</div>
<!-- Severity -->
<div class="form-row">
<label class="form-label">Severity</label>
<div class="severity-chips">
<label
*ngFor="let opt of severityOptions"
class="severity-option"
[class.severity-option--selected]="draftForm.controls.severity.value === opt.value"
>
<input type="radio" [value]="opt.value" formControlName="severity" />
<span class="severity-chip severity-chip--{{ opt.value }}">{{ opt.label }}</span>
</label>
</div>
</div>
<!-- Quick Justification -->
<div class="form-row">
<label class="form-label">Quick Justification</label>
<div class="template-chips">
<button
type="button"
*ngFor="let template of quickTemplates"
class="template-chip"
[class.template-chip--selected]="draftForm.controls.justificationTemplate.value === template.id"
(click)="selectTemplate(template.id)"
>
{{ template.label }}
</button>
</div>
</div>
<!-- Justification Text -->
<div class="form-row">
<label class="form-label" for="justificationText">
Justification <span class="required">*</span>
</label>
<textarea
id="justificationText"
class="form-textarea"
formControlName="justificationText"
rows="3"
placeholder="Explain why this exception is needed..."
></textarea>
<span class="form-hint">Minimum 20 characters</span>
</div>
<!-- Timebox -->
<div class="form-row form-row--inline">
<label class="form-label" for="timeboxDays">Duration</label>
<div class="timebox-quick">
<button type="button" class="timebox-btn" (click)="draftForm.patchValue({ timeboxDays: 7 })">7d</button>
<button type="button" class="timebox-btn" (click)="draftForm.patchValue({ timeboxDays: 14 })">14d</button>
<button type="button" class="timebox-btn" (click)="draftForm.patchValue({ timeboxDays: 30 })">30d</button>
<button type="button" class="timebox-btn" (click)="draftForm.patchValue({ timeboxDays: 90 })">90d</button>
<input
type="number"
id="timeboxDays"
class="form-input form-input--small"
formControlName="timeboxDays"
min="1"
max="365"
/>
<span class="timebox-label">days</span>
</div>
</div>
</form>
<!-- Simulation Toggle -->
<div class="draft-inline__simulation">
<button type="button" class="simulation-toggle" (click)="toggleSimulation()">
{{ showSimulation() ? 'Hide' : 'Show' }} Impact Simulation
</button>
<div class="simulation-result" *ngIf="showSimulation() && simulationResult() as sim">
<div class="simulation-stat">
<span class="simulation-stat__label">Affected Findings:</span>
<span class="simulation-stat__value">~{{ sim.affectedFindings }}</span>
</div>
<div class="simulation-stat">
<span class="simulation-stat__label">Policy Impact:</span>
<span class="simulation-stat__value simulation-stat__value--{{ sim.policyImpact }}">
{{ sim.policyImpact }}
</span>
</div>
<div class="simulation-stat">
<span class="simulation-stat__label">Coverage Estimate:</span>
<span class="simulation-stat__value">{{ sim.coverageEstimate }}</span>
</div>
</div>
</div>
<!-- Actions -->
<footer class="draft-inline__footer">
<button type="button" class="btn btn--text" (click)="cancel()">
Cancel
</button>
<button type="button" class="btn btn--secondary" (click)="expandToFullWizard()">
Full Wizard
</button>
<button
type="button"
class="btn btn--primary"
[disabled]="!canSubmit()"
(click)="submitDraft()"
>
{{ loading() ? 'Creating...' : 'Create Draft' }}
</button>
</footer>
</div>

View File

@@ -1,435 +1,435 @@
.draft-inline {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background: white;
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
// Header
.draft-inline__header {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #f1f5f9;
h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #1e293b;
}
}
.draft-inline__source {
font-size: 0.75rem;
color: #64748b;
}
// Error
.draft-inline__error {
padding: 0.5rem 0.75rem;
background: #fef2f2;
color: #991b1b;
border: 1px solid #fca5a5;
border-radius: 0.375rem;
font-size: 0.8125rem;
}
// Scope
.draft-inline__scope {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #f8fafc;
border-radius: 0.375rem;
font-size: 0.8125rem;
}
.draft-inline__scope-label {
color: #64748b;
font-weight: 500;
}
.draft-inline__scope-value {
color: #1e293b;
flex: 1;
}
.draft-inline__scope-type {
padding: 0.125rem 0.5rem;
background: #e0e7ff;
color: #4338ca;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
}
// Vulnerabilities preview
.draft-inline__vulns,
.draft-inline__components {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.draft-inline__vulns-label,
.draft-inline__components-label {
font-size: 0.75rem;
color: #64748b;
font-weight: 500;
}
.draft-inline__vulns-list,
.draft-inline__components-list {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.vuln-chip {
display: inline-flex;
padding: 0.125rem 0.5rem;
background: #fef2f2;
color: #dc2626;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-family: ui-monospace, monospace;
&--more {
background: #f1f5f9;
color: #64748b;
}
}
.component-chip {
display: inline-flex;
padding: 0.125rem 0.5rem;
background: #f0fdf4;
color: #166534;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-family: ui-monospace, monospace;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&--more {
background: #f1f5f9;
color: #64748b;
}
}
// Form
.draft-inline__form {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.form-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
&--inline {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
}
.form-label {
font-size: 0.75rem;
font-weight: 500;
color: #475569;
}
.required {
color: #ef4444;
}
.form-input {
padding: 0.5rem 0.625rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
font-size: 0.8125rem;
transition: border-color 0.2s ease;
&:focus {
outline: none;
border-color: #4f46e5;
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
}
&--small {
width: 60px;
text-align: center;
}
}
.form-textarea {
padding: 0.5rem 0.625rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
font-size: 0.8125rem;
resize: vertical;
min-height: 60px;
&:focus {
outline: none;
border-color: #4f46e5;
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
}
}
.form-hint {
font-size: 0.6875rem;
color: #94a3b8;
}
// Severity chips
.severity-chips {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.severity-option {
cursor: pointer;
input {
display: none;
}
&--selected .severity-chip {
box-shadow: 0 0 0 2px currentColor;
}
}
.severity-chip {
display: inline-flex;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 500;
transition: box-shadow 0.2s ease;
&--critical {
background: #fef2f2;
color: #dc2626;
}
&--high {
background: #fff7ed;
color: #ea580c;
}
&--medium {
background: #fefce8;
color: #ca8a04;
}
&--low {
background: #f0fdf4;
color: #16a34a;
}
}
// Template chips
.template-chips {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.template-chip {
padding: 0.25rem 0.625rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
background: white;
color: #475569;
font-size: 0.6875rem;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f8fafc;
border-color: #4f46e5;
}
&--selected {
background: #eef2ff;
border-color: #4f46e5;
color: #4f46e5;
}
}
// Timebox
.timebox-quick {
display: flex;
align-items: center;
gap: 0.375rem;
}
.timebox-btn {
padding: 0.25rem 0.5rem;
border: 1px solid #e2e8f0;
border-radius: 0.25rem;
background: white;
color: #64748b;
font-size: 0.6875rem;
cursor: pointer;
&:hover {
background: #f1f5f9;
border-color: #4f46e5;
color: #4f46e5;
}
}
.timebox-label {
font-size: 0.75rem;
color: #64748b;
}
// Simulation
.draft-inline__simulation {
padding-top: 0.75rem;
border-top: 1px solid #f1f5f9;
}
.simulation-toggle {
padding: 0.375rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
background: white;
color: #4f46e5;
font-size: 0.75rem;
cursor: pointer;
&:hover {
background: #eef2ff;
}
}
.simulation-result {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
margin-top: 0.75rem;
padding: 0.75rem;
background: #f8fafc;
border-radius: 0.375rem;
}
.simulation-stat {
display: flex;
flex-direction: column;
gap: 0.125rem;
text-align: center;
}
.simulation-stat__label {
font-size: 0.625rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.simulation-stat__value {
font-size: 0.875rem;
font-weight: 600;
color: #1e293b;
&--high {
color: #dc2626;
}
&--moderate {
color: #f59e0b;
}
&--low {
color: #10b981;
}
}
// Footer
.draft-inline__footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid #f1f5f9;
}
// Buttons
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&--primary {
background: #4f46e5;
color: white;
&:hover:not(:disabled) {
background: #4338ca;
}
}
&--secondary {
background: white;
color: #475569;
border: 1px solid #e2e8f0;
&:hover:not(:disabled) {
background: #f8fafc;
}
}
&--text {
background: transparent;
color: #64748b;
&:hover:not(:disabled) {
color: #1e293b;
}
}
}
// Responsive
@media (max-width: 480px) {
.simulation-result {
grid-template-columns: 1fr;
}
.draft-inline__footer {
flex-direction: column;
}
.severity-chips,
.template-chips {
flex-direction: column;
}
}
.draft-inline {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background: white;
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
// Header
.draft-inline__header {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #f1f5f9;
h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #1e293b;
}
}
.draft-inline__source {
font-size: 0.75rem;
color: #64748b;
}
// Error
.draft-inline__error {
padding: 0.5rem 0.75rem;
background: #fef2f2;
color: #991b1b;
border: 1px solid #fca5a5;
border-radius: 0.375rem;
font-size: 0.8125rem;
}
// Scope
.draft-inline__scope {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #f8fafc;
border-radius: 0.375rem;
font-size: 0.8125rem;
}
.draft-inline__scope-label {
color: #64748b;
font-weight: 500;
}
.draft-inline__scope-value {
color: #1e293b;
flex: 1;
}
.draft-inline__scope-type {
padding: 0.125rem 0.5rem;
background: #e0e7ff;
color: #4338ca;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
}
// Vulnerabilities preview
.draft-inline__vulns,
.draft-inline__components {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.draft-inline__vulns-label,
.draft-inline__components-label {
font-size: 0.75rem;
color: #64748b;
font-weight: 500;
}
.draft-inline__vulns-list,
.draft-inline__components-list {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.vuln-chip {
display: inline-flex;
padding: 0.125rem 0.5rem;
background: #fef2f2;
color: #dc2626;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-family: ui-monospace, monospace;
&--more {
background: #f1f5f9;
color: #64748b;
}
}
.component-chip {
display: inline-flex;
padding: 0.125rem 0.5rem;
background: #f0fdf4;
color: #166534;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-family: ui-monospace, monospace;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&--more {
background: #f1f5f9;
color: #64748b;
}
}
// Form
.draft-inline__form {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.form-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
&--inline {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
}
.form-label {
font-size: 0.75rem;
font-weight: 500;
color: #475569;
}
.required {
color: #ef4444;
}
.form-input {
padding: 0.5rem 0.625rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
font-size: 0.8125rem;
transition: border-color 0.2s ease;
&:focus {
outline: none;
border-color: #4f46e5;
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
}
&--small {
width: 60px;
text-align: center;
}
}
.form-textarea {
padding: 0.5rem 0.625rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
font-size: 0.8125rem;
resize: vertical;
min-height: 60px;
&:focus {
outline: none;
border-color: #4f46e5;
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
}
}
.form-hint {
font-size: 0.6875rem;
color: #94a3b8;
}
// Severity chips
.severity-chips {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.severity-option {
cursor: pointer;
input {
display: none;
}
&--selected .severity-chip {
box-shadow: 0 0 0 2px currentColor;
}
}
.severity-chip {
display: inline-flex;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 500;
transition: box-shadow 0.2s ease;
&--critical {
background: #fef2f2;
color: #dc2626;
}
&--high {
background: #fff7ed;
color: #ea580c;
}
&--medium {
background: #fefce8;
color: #ca8a04;
}
&--low {
background: #f0fdf4;
color: #16a34a;
}
}
// Template chips
.template-chips {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.template-chip {
padding: 0.25rem 0.625rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
background: white;
color: #475569;
font-size: 0.6875rem;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f8fafc;
border-color: #4f46e5;
}
&--selected {
background: #eef2ff;
border-color: #4f46e5;
color: #4f46e5;
}
}
// Timebox
.timebox-quick {
display: flex;
align-items: center;
gap: 0.375rem;
}
.timebox-btn {
padding: 0.25rem 0.5rem;
border: 1px solid #e2e8f0;
border-radius: 0.25rem;
background: white;
color: #64748b;
font-size: 0.6875rem;
cursor: pointer;
&:hover {
background: #f1f5f9;
border-color: #4f46e5;
color: #4f46e5;
}
}
.timebox-label {
font-size: 0.75rem;
color: #64748b;
}
// Simulation
.draft-inline__simulation {
padding-top: 0.75rem;
border-top: 1px solid #f1f5f9;
}
.simulation-toggle {
padding: 0.375rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
background: white;
color: #4f46e5;
font-size: 0.75rem;
cursor: pointer;
&:hover {
background: #eef2ff;
}
}
.simulation-result {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
margin-top: 0.75rem;
padding: 0.75rem;
background: #f8fafc;
border-radius: 0.375rem;
}
.simulation-stat {
display: flex;
flex-direction: column;
gap: 0.125rem;
text-align: center;
}
.simulation-stat__label {
font-size: 0.625rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.simulation-stat__value {
font-size: 0.875rem;
font-weight: 600;
color: #1e293b;
&--high {
color: #dc2626;
}
&--moderate {
color: #f59e0b;
}
&--low {
color: #10b981;
}
}
// Footer
.draft-inline__footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid #f1f5f9;
}
// Buttons
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&--primary {
background: #4f46e5;
color: white;
&:hover:not(:disabled) {
background: #4338ca;
}
}
&--secondary {
background: white;
color: #475569;
border: 1px solid #e2e8f0;
&:hover:not(:disabled) {
background: #f8fafc;
}
}
&--text {
background: transparent;
color: #64748b;
&:hover:not(:disabled) {
color: #1e293b;
}
}
}
// Responsive
@media (max-width: 480px) {
.simulation-result {
grid-template-columns: 1fr;
}
.draft-inline__footer {
flex-direction: column;
}
.severity-chips,
.template-chips {
flex-direction: column;
}
}

View File

@@ -1,213 +1,213 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnInit,
Output,
computed,
inject,
signal,
} from '@angular/core';
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import {
EXCEPTION_API,
ExceptionApi,
MockExceptionApiService,
} from '../../core/api/exception.client';
import {
Exception,
ExceptionScope,
ExceptionSeverity,
} from '../../core/api/exception.models';
export interface ExceptionDraftContext {
readonly vulnIds?: readonly string[];
readonly componentPurls?: readonly string[];
readonly assetIds?: readonly string[];
readonly tenantId?: string;
readonly suggestedName?: string;
readonly suggestedSeverity?: ExceptionSeverity;
readonly sourceType: 'vulnerability' | 'component' | 'asset' | 'graph';
readonly sourceLabel: string;
}
const QUICK_TEMPLATES = [
{ id: 'risk-accepted', label: 'Risk Accepted', text: 'Risk has been reviewed and formally accepted.' },
{ id: 'compensating-control', label: 'Compensating Control', text: 'Compensating controls in place: ' },
{ id: 'false-positive', label: 'False Positive', text: 'This finding is a false positive because: ' },
{ id: 'scheduled-fix', label: 'Scheduled Fix', text: 'Fix scheduled for deployment. Timeline: ' },
] as const;
const SEVERITY_OPTIONS: readonly { value: ExceptionSeverity; label: string }[] = [
{ value: 'critical', label: 'Critical' },
{ value: 'high', label: 'High' },
{ value: 'medium', label: 'Medium' },
{ value: 'low', label: 'Low' },
];
@Component({
selector: 'app-exception-draft-inline',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './exception-draft-inline.component.html',
styleUrls: ['./exception-draft-inline.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{ provide: EXCEPTION_API, useClass: MockExceptionApiService },
],
})
export class ExceptionDraftInlineComponent implements OnInit {
private readonly api = inject<ExceptionApi>(EXCEPTION_API);
private readonly formBuilder = inject(NonNullableFormBuilder);
@Input() context!: ExceptionDraftContext;
@Output() readonly created = new EventEmitter<Exception>();
@Output() readonly cancelled = new EventEmitter<void>();
@Output() readonly openFullWizard = new EventEmitter<ExceptionDraftContext>();
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly showSimulation = signal(false);
readonly quickTemplates = QUICK_TEMPLATES;
readonly severityOptions = SEVERITY_OPTIONS;
readonly draftForm = this.formBuilder.group({
name: this.formBuilder.control('', {
validators: [Validators.required, Validators.minLength(3)],
}),
severity: this.formBuilder.control<ExceptionSeverity>('medium'),
justificationTemplate: this.formBuilder.control('risk-accepted'),
justificationText: this.formBuilder.control('', {
validators: [Validators.required, Validators.minLength(20)],
}),
timeboxDays: this.formBuilder.control(30),
});
readonly scopeType = computed<ExceptionScope>(() => {
if (this.context?.componentPurls?.length) return 'component';
if (this.context?.assetIds?.length) return 'asset';
if (this.context?.tenantId) return 'tenant';
return 'global';
});
readonly scopeSummary = computed(() => {
const ctx = this.context;
const items: string[] = [];
if (ctx?.vulnIds?.length) {
items.push(`${ctx.vulnIds.length} vulnerabilit${ctx.vulnIds.length === 1 ? 'y' : 'ies'}`);
}
if (ctx?.componentPurls?.length) {
items.push(`${ctx.componentPurls.length} component${ctx.componentPurls.length === 1 ? '' : 's'}`);
}
if (ctx?.assetIds?.length) {
items.push(`${ctx.assetIds.length} asset${ctx.assetIds.length === 1 ? '' : 's'}`);
}
if (ctx?.tenantId) {
items.push(`Tenant: ${ctx.tenantId}`);
}
return items.length > 0 ? items.join(', ') : 'Global scope';
});
readonly simulationResult = computed(() => {
if (!this.showSimulation()) return null;
const vulnCount = this.context?.vulnIds?.length ?? 0;
const componentCount = this.context?.componentPurls?.length ?? 0;
return {
affectedFindings: vulnCount * Math.max(1, componentCount),
policyImpact: this.draftForm.controls.severity.value === 'critical' ? 'high' : 'moderate',
coverageEstimate: `~${Math.min(100, vulnCount * 15 + componentCount * 10)}%`,
};
});
readonly canSubmit = computed(() => {
return this.draftForm.valid && !this.loading();
});
ngOnInit(): void {
if (this.context?.suggestedName) {
this.draftForm.patchValue({ name: this.context.suggestedName });
}
if (this.context?.suggestedSeverity) {
this.draftForm.patchValue({ severity: this.context.suggestedSeverity });
}
const defaultTemplate = this.quickTemplates[0];
this.draftForm.patchValue({ justificationText: defaultTemplate.text });
}
selectTemplate(templateId: string): void {
const template = this.quickTemplates.find((t) => t.id === templateId);
if (template) {
this.draftForm.patchValue({
justificationTemplate: templateId,
justificationText: template.text,
});
}
}
toggleSimulation(): void {
this.showSimulation.set(!this.showSimulation());
}
async submitDraft(): Promise<void> {
if (!this.canSubmit()) return;
this.loading.set(true);
this.error.set(null);
try {
const formValue = this.draftForm.getRawValue();
const endDate = new Date();
endDate.setDate(endDate.getDate() + formValue.timeboxDays);
const exception: Partial<Exception> = {
name: formValue.name,
severity: formValue.severity,
status: 'draft',
scope: {
type: this.scopeType(),
tenantId: this.context?.tenantId,
assetIds: this.context?.assetIds ? [...this.context.assetIds] : undefined,
componentPurls: this.context?.componentPurls ? [...this.context.componentPurls] : undefined,
vulnIds: this.context?.vulnIds ? [...this.context.vulnIds] : undefined,
},
justification: {
template: formValue.justificationTemplate,
text: formValue.justificationText,
},
timebox: {
startDate: new Date().toISOString(),
endDate: endDate.toISOString(),
},
};
const created = await firstValueFrom(this.api.createException(exception));
this.created.emit(created);
} catch (err) {
this.error.set(err instanceof Error ? err.message : 'Failed to create exception draft.');
} finally {
this.loading.set(false);
}
}
cancel(): void {
this.cancelled.emit();
}
expandToFullWizard(): void {
this.openFullWizard.emit(this.context);
}
}
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnInit,
Output,
computed,
inject,
signal,
} from '@angular/core';
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import {
EXCEPTION_API,
ExceptionApi,
MockExceptionApiService,
} from '../../core/api/exception.client';
import {
Exception,
ExceptionScope,
ExceptionSeverity,
} from '../../core/api/exception.models';
export interface ExceptionDraftContext {
readonly vulnIds?: readonly string[];
readonly componentPurls?: readonly string[];
readonly assetIds?: readonly string[];
readonly tenantId?: string;
readonly suggestedName?: string;
readonly suggestedSeverity?: ExceptionSeverity;
readonly sourceType: 'vulnerability' | 'component' | 'asset' | 'graph';
readonly sourceLabel: string;
}
const QUICK_TEMPLATES = [
{ id: 'risk-accepted', label: 'Risk Accepted', text: 'Risk has been reviewed and formally accepted.' },
{ id: 'compensating-control', label: 'Compensating Control', text: 'Compensating controls in place: ' },
{ id: 'false-positive', label: 'False Positive', text: 'This finding is a false positive because: ' },
{ id: 'scheduled-fix', label: 'Scheduled Fix', text: 'Fix scheduled for deployment. Timeline: ' },
] as const;
const SEVERITY_OPTIONS: readonly { value: ExceptionSeverity; label: string }[] = [
{ value: 'critical', label: 'Critical' },
{ value: 'high', label: 'High' },
{ value: 'medium', label: 'Medium' },
{ value: 'low', label: 'Low' },
];
@Component({
selector: 'app-exception-draft-inline',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './exception-draft-inline.component.html',
styleUrls: ['./exception-draft-inline.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{ provide: EXCEPTION_API, useClass: MockExceptionApiService },
],
})
export class ExceptionDraftInlineComponent implements OnInit {
private readonly api = inject<ExceptionApi>(EXCEPTION_API);
private readonly formBuilder = inject(NonNullableFormBuilder);
@Input() context!: ExceptionDraftContext;
@Output() readonly created = new EventEmitter<Exception>();
@Output() readonly cancelled = new EventEmitter<void>();
@Output() readonly openFullWizard = new EventEmitter<ExceptionDraftContext>();
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly showSimulation = signal(false);
readonly quickTemplates = QUICK_TEMPLATES;
readonly severityOptions = SEVERITY_OPTIONS;
readonly draftForm = this.formBuilder.group({
name: this.formBuilder.control('', {
validators: [Validators.required, Validators.minLength(3)],
}),
severity: this.formBuilder.control<ExceptionSeverity>('medium'),
justificationTemplate: this.formBuilder.control('risk-accepted'),
justificationText: this.formBuilder.control('', {
validators: [Validators.required, Validators.minLength(20)],
}),
timeboxDays: this.formBuilder.control(30),
});
readonly scopeType = computed<ExceptionScope>(() => {
if (this.context?.componentPurls?.length) return 'component';
if (this.context?.assetIds?.length) return 'asset';
if (this.context?.tenantId) return 'tenant';
return 'global';
});
readonly scopeSummary = computed(() => {
const ctx = this.context;
const items: string[] = [];
if (ctx?.vulnIds?.length) {
items.push(`${ctx.vulnIds.length} vulnerabilit${ctx.vulnIds.length === 1 ? 'y' : 'ies'}`);
}
if (ctx?.componentPurls?.length) {
items.push(`${ctx.componentPurls.length} component${ctx.componentPurls.length === 1 ? '' : 's'}`);
}
if (ctx?.assetIds?.length) {
items.push(`${ctx.assetIds.length} asset${ctx.assetIds.length === 1 ? '' : 's'}`);
}
if (ctx?.tenantId) {
items.push(`Tenant: ${ctx.tenantId}`);
}
return items.length > 0 ? items.join(', ') : 'Global scope';
});
readonly simulationResult = computed(() => {
if (!this.showSimulation()) return null;
const vulnCount = this.context?.vulnIds?.length ?? 0;
const componentCount = this.context?.componentPurls?.length ?? 0;
return {
affectedFindings: vulnCount * Math.max(1, componentCount),
policyImpact: this.draftForm.controls.severity.value === 'critical' ? 'high' : 'moderate',
coverageEstimate: `~${Math.min(100, vulnCount * 15 + componentCount * 10)}%`,
};
});
readonly canSubmit = computed(() => {
return this.draftForm.valid && !this.loading();
});
ngOnInit(): void {
if (this.context?.suggestedName) {
this.draftForm.patchValue({ name: this.context.suggestedName });
}
if (this.context?.suggestedSeverity) {
this.draftForm.patchValue({ severity: this.context.suggestedSeverity });
}
const defaultTemplate = this.quickTemplates[0];
this.draftForm.patchValue({ justificationText: defaultTemplate.text });
}
selectTemplate(templateId: string): void {
const template = this.quickTemplates.find((t) => t.id === templateId);
if (template) {
this.draftForm.patchValue({
justificationTemplate: templateId,
justificationText: template.text,
});
}
}
toggleSimulation(): void {
this.showSimulation.set(!this.showSimulation());
}
async submitDraft(): Promise<void> {
if (!this.canSubmit()) return;
this.loading.set(true);
this.error.set(null);
try {
const formValue = this.draftForm.getRawValue();
const endDate = new Date();
endDate.setDate(endDate.getDate() + formValue.timeboxDays);
const exception: Partial<Exception> = {
name: formValue.name,
severity: formValue.severity,
status: 'draft',
scope: {
type: this.scopeType(),
tenantId: this.context?.tenantId,
assetIds: this.context?.assetIds ? [...this.context.assetIds] : undefined,
componentPurls: this.context?.componentPurls ? [...this.context.componentPurls] : undefined,
vulnIds: this.context?.vulnIds ? [...this.context.vulnIds] : undefined,
},
justification: {
template: formValue.justificationTemplate,
text: formValue.justificationText,
},
timebox: {
startDate: new Date().toISOString(),
endDate: endDate.toISOString(),
},
};
const created = await firstValueFrom(this.api.createException(exception));
this.created.emit(created);
} catch (err) {
this.error.set(err instanceof Error ? err.message : 'Failed to create exception draft.');
} finally {
this.loading.set(false);
}
}
cancel(): void {
this.cancelled.emit();
}
expandToFullWizard(): void {
this.openFullWizard.emit(this.context);
}
}

View File

@@ -1,438 +1,406 @@
<div class="wizard">
<!-- Progress Steps -->
<nav class="wizard__steps">
<button
*ngFor="let step of steps; let i = index"
type="button"
class="wizard__step"
[class.wizard__step--active]="isStepActive(step)"
[class.wizard__step--completed]="isStepCompleted(step)"
[class.wizard__step--disabled]="!canNavigateToStep(step)"
(click)="goToStep(step)"
[disabled]="!canNavigateToStep(step)"
>
<span class="wizard__step-number">{{ i + 1 }}</span>
<span class="wizard__step-label">{{ getStepLabel(step) }}</span>
</button>
</nav>
<!-- Error Message -->
<div class="wizard__error" *ngIf="error()">
{{ error() }}
</div>
<!-- Step Content -->
<div class="wizard__content">
<!-- Step 1: Basics -->
<div class="wizard__panel" *ngIf="currentStep() === 'basics'">
<h2>Basic Information</h2>
<p class="wizard__description">Provide a name and description for this exception.</p>
<form [formGroup]="basicsForm" class="form">
<div class="form__group">
<label class="form__label" for="name">
Exception Name <span class="form__required">*</span>
</label>
<input
type="text"
id="name"
class="form__input"
formControlName="name"
placeholder="e.g., log4j-legacy-exception"
/>
<span class="form__hint">A unique identifier (3-100 characters, no spaces preferred)</span>
<span class="form__error" *ngIf="basicsForm.controls.name.touched && basicsForm.controls.name.errors?.['required']">
Name is required
</span>
<span class="form__error" *ngIf="basicsForm.controls.name.errors?.['minlength']">
Name must be at least 3 characters
</span>
</div>
<div class="form__group">
<label class="form__label" for="displayName">Display Name</label>
<input
type="text"
id="displayName"
class="form__input"
formControlName="displayName"
placeholder="e.g., Log4j Legacy Exception"
/>
<span class="form__hint">Human-friendly name for display in UI</span>
</div>
<div class="form__group">
<label class="form__label" for="description">Description</label>
<textarea
id="description"
class="form__textarea"
formControlName="description"
rows="3"
placeholder="Briefly describe why this exception is needed..."
></textarea>
<span class="form__hint">Max 500 characters</span>
</div>
<div class="form__group">
<label class="form__label">Severity</label>
<div class="form__radio-group">
<label
*ngFor="let opt of severityOptions"
class="form__radio"
[class.form__radio--selected]="basicsForm.controls.severity.value === opt.value"
>
<input
type="radio"
[value]="opt.value"
formControlName="severity"
/>
<span class="severity-chip severity-chip--{{ opt.value }}">{{ opt.label }}</span>
</label>
</div>
</div>
</form>
</div>
<!-- Step 2: Scope -->
<div class="wizard__panel" *ngIf="currentStep() === 'scope'">
<h2>Exception Scope</h2>
<p class="wizard__description">Define what this exception applies to.</p>
<form [formGroup]="scopeForm" class="form">
<div class="form__group">
<label class="form__label">Scope Type <span class="form__required">*</span></label>
<div class="scope-type-cards">
<button
*ngFor="let type of scopeTypes"
type="button"
class="scope-type-card"
[class.scope-type-card--selected]="scopeForm.controls.type.value === type.value"
(click)="scopeForm.patchValue({ type: type.value })"
>
<span class="scope-type-card__label">{{ type.label }}</span>
<span class="scope-type-card__desc">{{ type.description }}</span>
</button>
</div>
</div>
<!-- Tenant scope -->
<div class="form__group" *ngIf="scopeForm.controls.type.value === 'tenant'">
<label class="form__label" for="tenantId">
Tenant ID <span class="form__required">*</span>
</label>
<input
type="text"
id="tenantId"
class="form__input"
formControlName="tenantId"
placeholder="e.g., tenant-prod-001"
/>
</div>
<!-- Asset scope -->
<div class="form__group" *ngIf="scopeForm.controls.type.value === 'asset'">
<label class="form__label" for="assetIds">
Asset IDs <span class="form__required">*</span>
</label>
<textarea
id="assetIds"
class="form__textarea"
formControlName="assetIds"
rows="3"
placeholder="Enter asset IDs, one per line or comma-separated"
></textarea>
<span class="form__hint">e.g., asset-web-prod, asset-api-prod</span>
</div>
<!-- Component scope -->
<div class="form__group" *ngIf="scopeForm.controls.type.value === 'component'">
<label class="form__label" for="componentPurls">
Component PURLs <span class="form__required">*</span>
</label>
<textarea
id="componentPurls"
class="form__textarea"
formControlName="componentPurls"
rows="3"
placeholder="Enter Package URLs, one per line"
></textarea>
<span class="form__hint">e.g., pkg:maven/org.apache.logging.log4j/log4j-core&#64;2.14.1</span>
</div>
<!-- Vulnerability IDs (for all non-global scopes) -->
<div class="form__group" *ngIf="scopeForm.controls.type.value !== 'global'">
<label class="form__label" for="vulnIds">Vulnerability IDs (Optional)</label>
<textarea
id="vulnIds"
class="form__textarea"
formControlName="vulnIds"
rows="2"
placeholder="CVE-2021-44228, CVE-2021-45046"
></textarea>
<span class="form__hint">Leave empty to apply to all vulnerabilities in scope</span>
</div>
<!-- Scope Preview -->
<div class="scope-preview">
<h3 class="scope-preview__title">Scope Preview</h3>
<ul class="scope-preview__list">
<li *ngFor="let item of scopePreview()">{{ item }}</li>
</ul>
</div>
</form>
</div>
<!-- Step 3: Justification -->
<div class="wizard__panel" *ngIf="currentStep() === 'justification'">
<h2>Justification</h2>
<p class="wizard__description">Explain why this exception is needed.</p>
<form [formGroup]="justificationForm" class="form">
<div class="form__group">
<label class="form__label">Justification Template</label>
<div class="template-cards">
<button
*ngFor="let template of justificationTemplates"
type="button"
class="template-card"
[class.template-card--selected]="justificationForm.controls.template.value === template.id"
(click)="selectTemplate(template.id)"
>
<span class="template-card__name">{{ template.name }}</span>
<span class="template-card__desc">{{ template.description }}</span>
</button>
</div>
</div>
<div class="form__group">
<label class="form__label" for="justificationText">
Justification Text <span class="form__required">*</span>
</label>
<textarea
id="justificationText"
class="form__textarea form__textarea--large"
formControlName="text"
rows="6"
placeholder="Provide a detailed justification..."
></textarea>
<span class="form__hint">Minimum 20 characters. Be specific about the risk mitigation.</span>
<span class="form__error" *ngIf="justificationForm.controls.text.touched && justificationForm.controls.text.errors?.['required']">
Justification text is required
</span>
<span class="form__error" *ngIf="justificationForm.controls.text.errors?.['minlength']">
Justification must be at least 20 characters
</span>
</div>
<div class="form__group">
<label class="form__label" for="attachments">Attachments (Optional)</label>
<textarea
id="attachments"
class="form__textarea"
formControlName="attachments"
rows="2"
placeholder="Links to supporting documents, one per line"
></textarea>
<span class="form__hint">e.g., JIRA ticket URLs, risk assessment documents</span>
</div>
</form>
</div>
<!-- Step 4: Timebox -->
<div class="wizard__panel" *ngIf="currentStep() === 'timebox'">
<h2>Timebox</h2>
<p class="wizard__description">Set the validity period for this exception.</p>
<form [formGroup]="timeboxForm" class="form">
<div class="timebox-presets">
<span class="timebox-presets__label">Quick presets:</span>
<button type="button" class="timebox-preset" (click)="setTimeboxPreset(7)">7 days</button>
<button type="button" class="timebox-preset" (click)="setTimeboxPreset(14)">14 days</button>
<button type="button" class="timebox-preset" (click)="setTimeboxPreset(30)">30 days</button>
<button type="button" class="timebox-preset" (click)="setTimeboxPreset(90)">90 days</button>
</div>
<div class="form__row">
<div class="form__group form__group--half">
<label class="form__label" for="startDate">
Start Date <span class="form__required">*</span>
</label>
<input
type="date"
id="startDate"
class="form__input"
formControlName="startDate"
/>
</div>
<div class="form__group form__group--half">
<label class="form__label" for="endDate">
End Date <span class="form__required">*</span>
</label>
<input
type="date"
id="endDate"
class="form__input"
formControlName="endDate"
/>
</div>
</div>
<div class="timebox-summary" [class.timebox-summary--warning]="timeboxWarning()">
<span class="timebox-summary__duration">Duration: {{ timeboxDays() }} days</span>
<span class="timebox-summary__warning" *ngIf="timeboxWarning()">
{{ timeboxWarning() }}
</span>
</div>
<div class="form__group">
<label class="form__checkbox">
<input type="checkbox" formControlName="autoRenew" />
<span>Enable auto-renewal</span>
</label>
<span class="form__hint">Automatically renew when the exception expires</span>
</div>
<div class="form__group" *ngIf="timeboxForm.controls.autoRenew.value">
<label class="form__label" for="maxRenewals">Maximum Renewals</label>
<input
type="number"
id="maxRenewals"
class="form__input form__input--small"
formControlName="maxRenewals"
min="0"
max="12"
/>
<span class="form__hint">0 = unlimited (not recommended)</span>
</div>
<div class="timebox-guardrails">
<h4>Guardrails</h4>
<ul>
<li [class.guardrail--pass]="timeboxDays() <= 365">
Maximum duration: 365 days
</li>
<li [class.guardrail--pass]="timeboxDays() <= 90" [class.guardrail--warning]="timeboxDays() > 90 && timeboxDays() <= 365">
Recommended: &le;90 days with renewal
</li>
<li [class.guardrail--pass]="!timeboxForm.controls.autoRenew.value || timeboxForm.controls.maxRenewals.value > 0">
Auto-renewal should have a limit
</li>
</ul>
</div>
</form>
</div>
<!-- Step 5: Review -->
<div class="wizard__panel" *ngIf="currentStep() === 'review'">
<h2>Review Exception</h2>
<p class="wizard__description">Review all details before creating the exception.</p>
<div class="review-section">
<h3>Basic Information</h3>
<dl class="review-list">
<dt>Name</dt>
<dd>{{ exceptionPreview().name }}</dd>
<dt>Display Name</dt>
<dd>{{ exceptionPreview().displayName || '-' }}</dd>
<dt>Description</dt>
<dd>{{ exceptionPreview().description || '-' }}</dd>
<dt>Severity</dt>
<dd>
<span class="severity-chip severity-chip--{{ exceptionPreview().severity }}">
{{ exceptionPreview().severity }}
</span>
</dd>
</dl>
<button type="button" class="review-edit" (click)="goToStep('basics')">Edit</button>
</div>
<div class="review-section">
<h3>Scope</h3>
<dl class="review-list">
<dt>Type</dt>
<dd>{{ exceptionPreview().scope?.type }}</dd>
<dt>Details</dt>
<dd>
<ul class="review-scope-list">
<li *ngFor="let item of scopePreview()">{{ item }}</li>
</ul>
</dd>
</dl>
<button type="button" class="review-edit" (click)="goToStep('scope')">Edit</button>
</div>
<div class="review-section">
<h3>Justification</h3>
<dl class="review-list">
<dt>Template</dt>
<dd>{{ selectedTemplate()?.name || 'Custom' }}</dd>
<dt>Text</dt>
<dd class="review-text">{{ exceptionPreview().justification?.text }}</dd>
</dl>
<button type="button" class="review-edit" (click)="goToStep('justification')">Edit</button>
</div>
<div class="review-section">
<h3>Timebox</h3>
<dl class="review-list">
<dt>Start Date</dt>
<dd>{{ exceptionPreview().timebox?.startDate | date:'mediumDate' }}</dd>
<dt>End Date</dt>
<dd>{{ exceptionPreview().timebox?.endDate | date:'mediumDate' }}</dd>
<dt>Duration</dt>
<dd>{{ timeboxDays() }} days</dd>
<dt>Auto-Renew</dt>
<dd>{{ exceptionPreview().timebox?.autoRenew ? 'Yes' : 'No' }}</dd>
</dl>
<button type="button" class="review-edit" (click)="goToStep('timebox')">Edit</button>
</div>
<div class="review-notice">
<p>The exception will be created in <strong>Draft</strong> status. Submit it for review to start the approval workflow.</p>
</div>
</div>
</div>
<!-- Footer Actions -->
<footer class="wizard__footer">
<button
type="button"
class="btn btn--secondary"
(click)="cancel()"
>
Cancel
</button>
<div class="wizard__footer-right">
<button
type="button"
class="btn btn--secondary"
(click)="prevStep()"
*ngIf="currentStep() !== 'basics'"
>
Back
</button>
<button
type="button"
class="btn btn--primary"
(click)="nextStep()"
[disabled]="!canProceed()"
*ngIf="currentStep() !== 'review'"
>
Continue
</button>
<button
type="button"
class="btn btn--primary"
(click)="submitException()"
[disabled]="loading()"
*ngIf="currentStep() === 'review'"
>
{{ loading() ? 'Creating...' : 'Create Exception' }}
</button>
</div>
</footer>
</div>
<div class="exception-wizard">
<!-- Progress Steps -->
<div class="wizard-progress">
@for (step of steps; track step; let i = $index) {
<button
class="progress-step"
[class.active]="currentStep() === step"
[class.completed]="i < currentStepIndex()"
[class.disabled]="i > currentStepIndex()"
(click)="goToStep(step)"
[disabled]="i > currentStepIndex()"
>
<span class="step-number">{{ i + 1 }}</span>
<span class="step-label">{{ step | titlecase }}</span>
</button>
@if (i < steps.length - 1) {
<div class="step-connector" [class.completed]="i < currentStepIndex()"></div>
}
}
</div>
<!-- Step Content -->
<div class="wizard-content">
<!-- Step 1: Type Selection -->
@if (currentStep() === 'type') {
<div class="step-panel">
<h3 class="step-title">What type of exception do you need?</h3>
<p class="step-desc">Select the category that best matches your exception request.</p>
<div class="type-grid">
@for (type of exceptionTypes; track type.type) {
<button
class="type-card"
[class.selected]="draft().type === type.type"
(click)="selectType(type.type)"
>
<span class="type-icon">{{ type.icon }}</span>
<div class="type-info">
<span class="type-label">{{ type.label }}</span>
<span class="type-desc">{{ type.description }}</span>
</div>
@if (draft().type === type.type) {
<span class="selected-check">[+]</span>
}
</button>
}
</div>
</div>
}
<!-- Step 2: Scope Definition -->
@if (currentStep() === 'scope') {
<div class="step-panel">
<h3 class="step-title">Define the exception scope</h3>
<p class="step-desc">Specify what this exception applies to. Be as specific as possible.</p>
<div class="scope-form">
@if (draft().type === 'vulnerability') {
<div class="scope-field">
<label class="field-label">CVEs</label>
<textarea
class="field-textarea"
placeholder="Enter CVE IDs, one per line (e.g., CVE-2024-1234)"
[value]="draft().scope.cves?.join('\n') || ''"
(input)="updateScope('cves', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
></textarea>
</div>
<div class="scope-field">
<label class="field-label">Packages (optional)</label>
<textarea
class="field-textarea"
placeholder="Package names to scope (e.g., lodash, express)"
[value]="draft().scope.packages?.join('\n') || ''"
(input)="updateScope('packages', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
></textarea>
</div>
}
@if (draft().type === 'license') {
<div class="scope-field">
<label class="field-label">Licenses</label>
<textarea
class="field-textarea"
placeholder="License identifiers (e.g., GPL-3.0, AGPL-3.0)"
[value]="draft().scope.licenses?.join('\n') || ''"
(input)="updateScope('licenses', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
></textarea>
</div>
}
@if (draft().type === 'policy') {
<div class="scope-field">
<label class="field-label">Policy Rules</label>
<textarea
class="field-textarea"
placeholder="Policy rule IDs (e.g., SEC-001, COMP-002)"
[value]="draft().scope.policyRules?.join('\n') || ''"
(input)="updateScope('policyRules', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
></textarea>
</div>
}
<div class="scope-field">
<label class="field-label">Images (optional - limits scope to specific images)</label>
<textarea
class="field-textarea"
placeholder="Image references (e.g., myregistry/myimage:*, myregistry/app:v1.0)"
[value]="draft().scope.images?.join('\n') || ''"
(input)="updateScope('images', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
></textarea>
<span class="field-hint">Use * for wildcards. Leave empty to apply to all images.</span>
</div>
<div class="scope-field">
<label class="field-label">Environments (optional)</label>
<div class="env-chips">
@for (env of ['development', 'staging', 'production']; track env) {
<button
class="env-chip"
[class.selected]="draft().scope.environments?.includes(env)"
(click)="updateScope('environments',
draft().scope.environments?.includes(env)
? draft().scope.environments?.filter(e => e !== env)
: [...(draft().scope.environments || []), env])"
>
{{ env | titlecase }}
</button>
}
</div>
</div>
@if (scopePreview().length > 0) {
<div class="scope-preview">
<span class="preview-label">Scope preview:</span>
<span class="preview-text">{{ scopePreview().join(', ') }}</span>
</div>
}
</div>
</div>
}
<!-- Step 3: Justification -->
@if (currentStep() === 'justification') {
<div class="step-panel">
<h3 class="step-title">Provide justification</h3>
<p class="step-desc">Explain why this exception is needed. Use a template or write your own.</p>
<div class="justification-form">
<div class="form-field">
<label class="field-label">Title</label>
<input
type="text"
class="field-input"
placeholder="Brief descriptive title for this exception"
[value]="draft().title"
(input)="updateDraft('title', $any($event.target).value)"
/>
</div>
<div class="form-field">
<label class="field-label">Severity</label>
<div class="severity-options">
@for (sev of ['critical', 'high', 'medium', 'low']; track sev) {
<button
class="severity-btn"
[class]="'sev-' + sev"
[class.selected]="draft().severity === sev"
(click)="updateDraft('severity', $any(sev))"
>
{{ sev | titlecase }}
</button>
}
</div>
</div>
@if (applicableTemplates().length > 0) {
<div class="form-field">
<label class="field-label">Templates</label>
<div class="template-list">
@for (tpl of applicableTemplates(); track tpl.id) {
<button
class="template-btn"
[class.selected]="selectedTemplate() === tpl.id"
(click)="selectTemplate(tpl.id)"
>
<span class="tpl-name">{{ tpl.name }}</span>
<span class="tpl-desc">{{ tpl.description }}</span>
</button>
}
</div>
</div>
}
<div class="form-field">
<label class="field-label">
Justification
<span class="char-count">{{ draft().justification.length }} chars (min 20)</span>
</label>
<textarea
class="field-textarea large"
placeholder="Provide detailed justification for this exception..."
[value]="draft().justification"
(input)="updateDraft('justification', $any($event.target).value)"
></textarea>
</div>
<div class="form-field">
<label class="field-label">Tags (optional)</label>
<div class="tags-input">
<div class="current-tags">
@for (tag of draft().tags; track tag) {
<span class="tag">
{{ tag }}
<button class="tag-remove" (click)="removeTag(tag)">x</button>
</span>
}
</div>
<input
type="text"
class="tag-input"
placeholder="Add tag..."
[value]="newTag()"
(input)="onTagInput($event)"
(keydown.enter)="addTag(); $event.preventDefault()"
/>
</div>
</div>
</div>
</div>
}
<!-- Step 4: Timebox -->
@if (currentStep() === 'timebox') {
<div class="step-panel">
<h3 class="step-title">Set exception duration</h3>
<p class="step-desc">
Exceptions must have an expiration date. Maximum duration: {{ maxDurationDays() }} days.
</p>
<div class="timebox-form">
<div class="timebox-presets">
@for (preset of timeboxPresets; track preset.days) {
<button
class="preset-btn"
[class.selected]="draft().expiresInDays === preset.days"
[disabled]="preset.days > maxDurationDays()"
(click)="selectTimebox(preset.days)"
>
<span class="preset-label">{{ preset.label }}</span>
<span class="preset-desc">{{ preset.description }}</span>
</button>
}
</div>
<div class="custom-duration">
<label class="field-label">Or set custom duration (days)</label>
<input
type="number"
class="field-input duration-input"
min="1"
[max]="maxDurationDays()"
[value]="draft().expiresInDays"
(input)="updateDraft('expiresInDays', +$any($event.target).value)"
/>
</div>
<div class="timebox-preview">
<div class="preview-row">
<span class="preview-label">Expires on:</span>
<span class="preview-value">{{ formatDate(expirationDate()) }}</span>
</div>
<div class="preview-row">
<span class="preview-label">Duration:</span>
<span class="preview-value">{{ draft().expiresInDays }} days</span>
</div>
</div>
@if (timeboxWarning()) {
<div class="timebox-warning">
<span class="warning-icon">[!]</span>
<span>{{ timeboxWarning() }}</span>
</div>
}
</div>
</div>
}
<!-- Step 5: Review -->
@if (currentStep() === 'review') {
<div class="step-panel">
<h3 class="step-title">Review and submit</h3>
<p class="step-desc">Please review your exception request before submitting.</p>
<div class="review-summary">
<div class="review-section">
<h4 class="section-title">Type & Severity</h4>
<div class="review-row">
<span class="review-label">Type:</span>
<span class="review-value">{{ draft().type | titlecase }}</span>
</div>
<div class="review-row">
<span class="review-label">Severity:</span>
<span class="review-value severity-badge" [class]="'sev-' + draft().severity">
{{ draft().severity | titlecase }}
</span>
</div>
</div>
<div class="review-section">
<h4 class="section-title">Scope</h4>
@if (draft().scope.cves?.length) {
<div class="review-row">
<span class="review-label">CVEs:</span>
<span class="review-value">{{ draft().scope.cves?.join(', ') }}</span>
</div>
}
@if (draft().scope.packages?.length) {
<div class="review-row">
<span class="review-label">Packages:</span>
<span class="review-value">{{ draft().scope.packages?.join(', ') }}</span>
</div>
}
@if (draft().scope.licenses?.length) {
<div class="review-row">
<span class="review-label">Licenses:</span>
<span class="review-value">{{ draft().scope.licenses?.join(', ') }}</span>
</div>
}
@if (draft().scope.policyRules?.length) {
<div class="review-row">
<span class="review-label">Policy Rules:</span>
<span class="review-value">{{ draft().scope.policyRules?.join(', ') }}</span>
</div>
}
@if (draft().scope.images?.length) {
<div class="review-row">
<span class="review-label">Images:</span>
<span class="review-value">{{ draft().scope.images?.join(', ') }}</span>
</div>
}
@if (draft().scope.environments?.length) {
<div class="review-row">
<span class="review-label">Environments:</span>
<span class="review-value">{{ draft().scope.environments?.join(', ') }}</span>
</div>
}
</div>
<div class="review-section">
<h4 class="section-title">Details</h4>
<div class="review-row">
<span class="review-label">Title:</span>
<span class="review-value">{{ draft().title }}</span>
</div>
<div class="review-row full">
<span class="review-label">Justification:</span>
<p class="review-justification">{{ draft().justification }}</p>
</div>
@if (draft().tags.length > 0) {
<div class="review-row">
<span class="review-label">Tags:</span>
<div class="review-tags">
@for (tag of draft().tags; track tag) {
<span class="tag">{{ tag }}</span>
}
</div>
</div>
}
</div>
<div class="review-section">
<h4 class="section-title">Timebox</h4>
<div class="review-row">
<span class="review-label">Duration:</span>
<span class="review-value">{{ draft().expiresInDays }} days</span>
</div>
<div class="review-row">
<span class="review-label">Expires:</span>
<span class="review-value">{{ formatDate(expirationDate()) }}</span>
</div>
</div>
</div>
</div>
}
</div>
<!-- Footer Actions -->
<div class="wizard-footer">
<button class="btn-cancel" (click)="onCancel()">Cancel</button>
<div class="footer-right">
@if (canGoBack()) {
<button class="btn-back" (click)="goBack()">Back</button>
}
@if (currentStep() !== 'review') {
<button class="btn-next" [disabled]="!canGoNext()" (click)="goNext()">
Next
</button>
} @else {
<button class="btn-submit" [disabled]="!canGoNext()" (click)="onSubmit()">
Submit Exception
</button>
}
</div>
</div>
</div>

View File

@@ -1,453 +1,296 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
OnInit,
Output,
computed,
inject,
signal,
} from '@angular/core';
import {
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import {
EXCEPTION_API,
ExceptionApi,
MockExceptionApiService,
} from '../../core/api/exception.client';
import {
Exception,
ExceptionJustification,
ExceptionScope,
ExceptionSeverity,
ExceptionTimebox,
} from '../../core/api/exception.models';
type WizardStep = 'basics' | 'scope' | 'justification' | 'timebox' | 'review';
interface JustificationTemplate {
readonly id: string;
readonly name: string;
readonly description: string;
readonly defaultText: string;
readonly requiredFields: readonly string[];
}
const JUSTIFICATION_TEMPLATES: readonly JustificationTemplate[] = [
{
id: 'risk-accepted',
name: 'Risk Accepted',
description: 'The risk has been formally accepted by stakeholders',
defaultText: 'Risk has been reviewed and formally accepted. Rationale: ',
requiredFields: ['approver', 'rationale'],
},
{
id: 'compensating-control',
name: 'Compensating Control',
description: 'Alternative security controls are in place to mitigate the risk',
defaultText: 'Compensating controls in place: ',
requiredFields: ['controls', 'effectiveness'],
},
{
id: 'false-positive',
name: 'False Positive',
description: 'The finding has been determined to be a false positive',
defaultText: 'This finding is a false positive because: ',
requiredFields: ['evidence'],
},
{
id: 'scheduled-fix',
name: 'Scheduled Fix',
description: 'A fix is planned within the timebox period',
defaultText: 'Fix scheduled for deployment. Timeline: ',
requiredFields: ['timeline', 'ticket'],
},
{
id: 'internal-only',
name: 'Internal Only',
description: 'Component is used only in internal/non-production environments',
defaultText: 'This component is used only in internal environments with no external exposure. Environment: ',
requiredFields: ['environment'],
},
{
id: 'custom',
name: 'Custom',
description: 'Provide a custom justification',
defaultText: '',
requiredFields: [],
},
];
const SCOPE_TYPES: readonly { value: ExceptionScope; label: string; description: string }[] = [
{ value: 'global', label: 'Global', description: 'Applies to all assets and components' },
{ value: 'tenant', label: 'Tenant', description: 'Applies to a specific tenant' },
{ value: 'asset', label: 'Asset', description: 'Applies to specific assets' },
{ value: 'component', label: 'Component', description: 'Applies to specific components/PURLs' },
];
const SEVERITY_OPTIONS: readonly { value: ExceptionSeverity; label: string }[] = [
{ value: 'critical', label: 'Critical' },
{ value: 'high', label: 'High' },
{ value: 'medium', label: 'Medium' },
{ value: 'low', label: 'Low' },
];
const MAX_TIMEBOX_DAYS = 365;
const DEFAULT_TIMEBOX_DAYS = 30;
const MIN_TIMEBOX_DAYS = 1;
@Component({
selector: 'app-exception-wizard',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './exception-wizard.component.html',
styleUrls: ['./exception-wizard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{ provide: EXCEPTION_API, useClass: MockExceptionApiService },
],
})
export class ExceptionWizardComponent implements OnInit {
private readonly api = inject<ExceptionApi>(EXCEPTION_API);
private readonly formBuilder = inject(NonNullableFormBuilder);
@Output() readonly created = new EventEmitter<Exception>();
@Output() readonly cancelled = new EventEmitter<void>();
// Wizard state
readonly currentStep = signal<WizardStep>('basics');
readonly loading = signal(false);
readonly error = signal<string | null>(null);
// Constants for template
readonly steps: readonly WizardStep[] = ['basics', 'scope', 'justification', 'timebox', 'review'];
readonly justificationTemplates = JUSTIFICATION_TEMPLATES;
readonly scopeTypes = SCOPE_TYPES;
readonly severityOptions = SEVERITY_OPTIONS;
// Forms for each step
readonly basicsForm = this.formBuilder.group({
name: this.formBuilder.control('', {
validators: [Validators.required, Validators.minLength(3), Validators.maxLength(100)],
}),
displayName: this.formBuilder.control(''),
description: this.formBuilder.control('', {
validators: [Validators.maxLength(500)],
}),
severity: this.formBuilder.control<ExceptionSeverity>('medium'),
});
readonly scopeForm = this.formBuilder.group({
type: this.formBuilder.control<ExceptionScope>('component'),
tenantId: this.formBuilder.control(''),
assetIds: this.formBuilder.control(''),
componentPurls: this.formBuilder.control(''),
vulnIds: this.formBuilder.control(''),
});
readonly justificationForm = this.formBuilder.group({
template: this.formBuilder.control('risk-accepted'),
text: this.formBuilder.control('', {
validators: [Validators.required, Validators.minLength(20)],
}),
attachments: this.formBuilder.control(''),
});
readonly timeboxForm = this.formBuilder.group({
startDate: this.formBuilder.control(this.formatDateForInput(new Date())),
endDate: this.formBuilder.control(this.formatDateForInput(this.addDays(new Date(), DEFAULT_TIMEBOX_DAYS))),
autoRenew: this.formBuilder.control(false),
maxRenewals: this.formBuilder.control(0),
});
// Computed: selected template
readonly selectedTemplate = computed(() => {
const templateId = this.justificationForm.controls.template.value;
return this.justificationTemplates.find((t) => t.id === templateId);
});
// Computed: scope preview
readonly scopePreview = computed(() => {
const scope = this.scopeForm.getRawValue();
const items: string[] = [];
if (scope.type === 'global') {
items.push('All assets and components');
}
if (scope.tenantId) {
items.push(`Tenant: ${scope.tenantId}`);
}
if (scope.assetIds) {
const assets = this.parseList(scope.assetIds);
items.push(`Assets (${assets.length}): ${assets.slice(0, 3).join(', ')}${assets.length > 3 ? '...' : ''}`);
}
if (scope.componentPurls) {
const purls = this.parseList(scope.componentPurls);
items.push(`Components (${purls.length}): ${purls.slice(0, 2).join(', ')}${purls.length > 2 ? '...' : ''}`);
}
if (scope.vulnIds) {
const vulns = this.parseList(scope.vulnIds);
items.push(`Vulnerabilities (${vulns.length}): ${vulns.join(', ')}`);
}
return items.length > 0 ? items : ['No scope defined'];
});
// Computed: timebox validation
readonly timeboxDays = computed(() => {
const timebox = this.timeboxForm.getRawValue();
const start = new Date(timebox.startDate);
const end = new Date(timebox.endDate);
return Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
});
readonly timeboxWarning = computed(() => {
const days = this.timeboxDays();
if (days > 90) {
return 'Long exception period (>90 days). Consider shorter timebox with renewal.';
}
if (days > MAX_TIMEBOX_DAYS) {
return `Maximum timebox is ${MAX_TIMEBOX_DAYS} days.`;
}
if (days < MIN_TIMEBOX_DAYS) {
return 'End date must be after start date.';
}
return null;
});
readonly timeboxValid = computed(() => {
const days = this.timeboxDays();
return days >= MIN_TIMEBOX_DAYS && days <= MAX_TIMEBOX_DAYS;
});
// Computed: step completion status
readonly stepStatus = computed(() => ({
basics: this.basicsForm.valid,
scope: this.isScopeValid(),
justification: this.justificationForm.valid,
timebox: this.timeboxValid(),
review: true,
}));
// Computed: can proceed to next step
readonly canProceed = computed(() => {
const step = this.currentStep();
return this.stepStatus()[step];
});
// Computed: exception preview
readonly exceptionPreview = computed<Partial<Exception>>(() => {
const basics = this.basicsForm.getRawValue();
const scope = this.scopeForm.getRawValue();
const justification = this.justificationForm.getRawValue();
const timebox = this.timeboxForm.getRawValue();
return {
name: basics.name,
displayName: basics.displayName || undefined,
description: basics.description || undefined,
severity: basics.severity,
status: 'draft',
scope: this.buildScope(scope),
justification: this.buildJustification(justification),
timebox: this.buildTimebox(timebox),
};
});
ngOnInit(): void {
// Set default template text when template changes
this.justificationForm.controls.template.valueChanges.subscribe((templateId) => {
const template = this.justificationTemplates.find((t) => t.id === templateId);
if (template && !this.justificationForm.controls.text.dirty) {
this.justificationForm.patchValue({ text: template.defaultText });
}
});
// Initialize with default template text
const defaultTemplate = this.justificationTemplates[0];
this.justificationForm.patchValue({ text: defaultTemplate.defaultText });
}
// Navigation
goToStep(step: WizardStep): void {
const currentIndex = this.steps.indexOf(this.currentStep());
const targetIndex = this.steps.indexOf(step);
// Can only go back or to completed steps
if (targetIndex < currentIndex || this.canNavigateToStep(step)) {
this.currentStep.set(step);
}
}
nextStep(): void {
const currentIndex = this.steps.indexOf(this.currentStep());
if (currentIndex < this.steps.length - 1 && this.canProceed()) {
this.currentStep.set(this.steps[currentIndex + 1]);
}
}
prevStep(): void {
const currentIndex = this.steps.indexOf(this.currentStep());
if (currentIndex > 0) {
this.currentStep.set(this.steps[currentIndex - 1]);
}
}
isStepActive(step: WizardStep): boolean {
return this.currentStep() === step;
}
isStepCompleted(step: WizardStep): boolean {
const stepIndex = this.steps.indexOf(step);
const currentIndex = this.steps.indexOf(this.currentStep());
return stepIndex < currentIndex && this.stepStatus()[step];
}
canNavigateToStep(step: WizardStep): boolean {
const stepIndex = this.steps.indexOf(step);
// Can navigate to any previous step or current step
// Can navigate forward only if all previous steps are complete
for (let i = 0; i < stepIndex; i++) {
if (!this.stepStatus()[this.steps[i]]) {
return false;
}
}
return true;
}
getStepLabel(step: WizardStep): string {
const labels: Record<WizardStep, string> = {
basics: 'Basic Info',
scope: 'Scope',
justification: 'Justification',
timebox: 'Timebox',
review: 'Review',
};
return labels[step];
}
// Template selection
selectTemplate(templateId: string): void {
this.justificationForm.patchValue({ template: templateId });
const template = this.justificationTemplates.find((t) => t.id === templateId);
if (template) {
this.justificationForm.patchValue({ text: template.defaultText });
this.justificationForm.controls.text.markAsPristine();
}
}
// Timebox helpers
setTimeboxPreset(days: number): void {
const start = new Date();
const end = this.addDays(start, days);
this.timeboxForm.patchValue({
startDate: this.formatDateForInput(start),
endDate: this.formatDateForInput(end),
});
}
// Form submission
async submitException(): Promise<void> {
if (!this.isFormComplete()) {
this.error.set('Please complete all required fields.');
return;
}
this.loading.set(true);
this.error.set(null);
try {
const exception = this.exceptionPreview();
const created = await firstValueFrom(this.api.createException(exception));
this.created.emit(created);
} catch (err) {
this.error.set(err instanceof Error ? err.message : 'Failed to create exception.');
} finally {
this.loading.set(false);
}
}
cancel(): void {
this.cancelled.emit();
}
// Validation helpers
private isScopeValid(): boolean {
const scope = this.scopeForm.getRawValue();
if (scope.type === 'global') {
return true;
}
if (scope.type === 'tenant' && scope.tenantId) {
return true;
}
if (scope.type === 'asset' && scope.assetIds) {
return this.parseList(scope.assetIds).length > 0;
}
if (scope.type === 'component' && scope.componentPurls) {
return this.parseList(scope.componentPurls).length > 0;
}
return false;
}
private isFormComplete(): boolean {
return (
this.basicsForm.valid &&
this.isScopeValid() &&
this.justificationForm.valid &&
this.timeboxValid()
);
}
// Builder helpers
private buildScope(raw: ReturnType<typeof this.scopeForm.getRawValue>): Exception['scope'] {
return {
type: raw.type,
tenantId: raw.tenantId || undefined,
assetIds: raw.assetIds ? this.parseList(raw.assetIds) : undefined,
componentPurls: raw.componentPurls ? this.parseList(raw.componentPurls) : undefined,
vulnIds: raw.vulnIds ? this.parseList(raw.vulnIds) : undefined,
};
}
private buildJustification(raw: ReturnType<typeof this.justificationForm.getRawValue>): ExceptionJustification {
return {
template: raw.template !== 'custom' ? raw.template : undefined,
text: raw.text,
attachments: raw.attachments ? this.parseList(raw.attachments) : undefined,
};
}
private buildTimebox(raw: ReturnType<typeof this.timeboxForm.getRawValue>): ExceptionTimebox {
return {
startDate: new Date(raw.startDate).toISOString(),
endDate: new Date(raw.endDate).toISOString(),
autoRenew: raw.autoRenew || undefined,
maxRenewals: raw.autoRenew && raw.maxRenewals > 0 ? raw.maxRenewals : undefined,
};
}
// Utility helpers
private parseList(value: string): string[] {
if (!value) return [];
return value
.split(/[\n,;]/)
.map((s) => s.trim())
.filter(Boolean);
}
private formatDateForInput(date: Date): string {
return date.toISOString().split('T')[0];
}
private addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
}
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
Exception,
ExceptionType,
ExceptionScope,
} from '../../core/api/exception.models';
type WizardStep = 'type' | 'scope' | 'justification' | 'timebox' | 'review';
export interface JustificationTemplate {
id: string;
name: string;
description: string;
template: string;
type: ExceptionType[];
}
export interface TimeboxPreset {
label: string;
days: number;
description: string;
}
export interface ExceptionDraft {
type: ExceptionType | null;
severity: 'critical' | 'high' | 'medium' | 'low';
title: string;
justification: string;
scope: Partial<ExceptionScope>;
expiresInDays: number;
tags: string[];
}
@Component({
selector: 'app-exception-wizard',
standalone: true,
imports: [CommonModule],
templateUrl: './exception-wizard.component.html',
styleUrls: ['./exception-wizard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExceptionWizardComponent {
/** Pre-selected type (e.g., from vulnerability view) */
readonly preselectedType = input<ExceptionType>();
/** Pre-filled scope (e.g., specific CVE) */
readonly prefilledScope = input<Partial<ExceptionScope>>();
/** Available justification templates */
readonly templates = input<JustificationTemplate[]>(this.defaultTemplates);
/** Maximum allowed exception duration in days */
readonly maxDurationDays = input(90);
/** Emits when wizard is cancelled */
readonly cancel = output<void>();
/** Emits when exception is created */
readonly create = output<ExceptionDraft>();
readonly steps: WizardStep[] = ['type', 'scope', 'justification', 'timebox', 'review'];
readonly currentStep = signal<WizardStep>('type');
readonly draft = signal<ExceptionDraft>({
type: null,
severity: 'medium',
title: '',
justification: '',
scope: {},
expiresInDays: 30,
tags: [],
});
readonly scopePreview = signal<string[]>([]);
readonly selectedTemplate = signal<string | null>(null);
readonly newTag = signal('');
readonly timeboxPresets: TimeboxPreset[] = [
{ label: '7 days', days: 7, description: 'Short-term exception for urgent fixes' },
{ label: '14 days', days: 14, description: 'Sprint-length exception' },
{ label: '30 days', days: 30, description: 'Standard exception duration' },
{ label: '60 days', days: 60, description: 'Extended exception for complex remediation' },
{ label: '90 days', days: 90, description: 'Maximum allowed duration' },
];
readonly exceptionTypes: { type: ExceptionType; label: string; icon: string; description: string }[] = [
{ type: 'vulnerability', label: 'Vulnerability', icon: 'V', description: 'Exception for specific CVEs or vulnerability findings' },
{ type: 'license', label: 'License', icon: 'L', description: 'Exception for license compliance violations' },
{ type: 'policy', label: 'Policy', icon: 'P', description: 'Exception for policy rule violations' },
{ type: 'entropy', label: 'Entropy', icon: 'E', description: 'Exception for high entropy findings' },
{ type: 'determinism', label: 'Determinism', icon: 'D', description: 'Exception for determinism check failures' },
];
readonly defaultTemplates: JustificationTemplate[] = [
{
id: 'false-positive',
name: 'False Positive',
description: 'The finding is a false positive and does not represent a real risk',
template: 'This finding has been determined to be a false positive because:\n\n[Explain why this is a false positive]\n\nEvidence:\n- [Evidence 1]\n- [Evidence 2]',
type: ['vulnerability', 'entropy', 'license'],
},
{
id: 'mitigated',
name: 'Mitigating Controls',
description: 'Risk is mitigated by other security controls',
template: 'The risk associated with this finding is mitigated by the following controls:\n\n1. [Control 1]\n2. [Control 2]\n\nResidual risk assessment: [Low/Medium]',
type: ['vulnerability', 'policy'],
},
{
id: 'planned-fix',
name: 'Planned Remediation',
description: 'Fix is planned but requires time to implement',
template: 'Remediation is planned with the following timeline:\n\nPlanned fix date: [Date]\nAssigned to: [Team/Person]\nTracking ticket: [Ticket ID]\n\nReason for delay:\n[Explain why immediate fix is not possible]',
type: ['vulnerability', 'license', 'policy', 'entropy', 'determinism'],
},
{
id: 'business-need',
name: 'Business Requirement',
description: 'Required for critical business functionality',
template: 'This exception is required for the following business reason:\n\n[Explain business requirement]\n\nImpact if not granted:\n- [Impact 1]\n- [Impact 2]\n\nApproved by: [Business Owner]',
type: ['license', 'policy'],
},
];
readonly currentStepIndex = computed(() => this.steps.indexOf(this.currentStep()));
readonly canGoNext = computed(() => {
const step = this.currentStep();
const d = this.draft();
switch (step) {
case 'type':
return d.type !== null;
case 'scope':
return this.hasValidScope();
case 'justification':
return d.title.trim().length > 0 && d.justification.trim().length > 20;
case 'timebox':
return d.expiresInDays > 0 && d.expiresInDays <= this.maxDurationDays();
case 'review':
return true;
default:
return false;
}
});
readonly canGoBack = computed(() => this.currentStepIndex() > 0);
readonly applicableTemplates = computed(() => {
const type = this.draft().type;
if (!type) return [];
return (this.templates() || this.defaultTemplates).filter((t) => t.type.includes(type));
});
readonly expirationDate = computed(() => {
const days = this.draft().expiresInDays;
const date = new Date();
date.setDate(date.getDate() + days);
return date;
});
readonly timeboxWarning = computed(() => {
const days = this.draft().expiresInDays;
if (days > 60) return 'Extended exceptions require additional justification';
if (days > 30) return 'Consider if a shorter duration is sufficient';
return null;
});
ngOnInit(): void {
// Apply preselected values
if (this.preselectedType()) {
this.updateDraft('type', this.preselectedType()!);
this.currentStep.set('scope');
}
if (this.prefilledScope()) {
this.updateDraft('scope', this.prefilledScope()!);
}
}
private hasValidScope(): boolean {
const scope = this.draft().scope;
return !!(
(scope.cves && scope.cves.length > 0) ||
(scope.packages && scope.packages.length > 0) ||
(scope.images && scope.images.length > 0) ||
(scope.licenses && scope.licenses.length > 0) ||
(scope.policyRules && scope.policyRules.length > 0)
);
}
updateDraft<K extends keyof ExceptionDraft>(key: K, value: ExceptionDraft[K]): void {
this.draft.update((d) => ({ ...d, [key]: value }));
}
updateScope<K extends keyof ExceptionScope>(key: K, value: ExceptionScope[K]): void {
this.draft.update((d) => ({
...d,
scope: { ...d.scope, [key]: value },
}));
this.updateScopePreview();
}
private updateScopePreview(): void {
const scope = this.draft().scope;
const preview: string[] = [];
if (scope.cves?.length) preview.push(`${scope.cves.length} CVE(s)`);
if (scope.packages?.length) preview.push(`${scope.packages.length} package(s)`);
if (scope.images?.length) preview.push(`${scope.images.length} image(s)`);
if (scope.licenses?.length) preview.push(`${scope.licenses.length} license(s)`);
if (scope.policyRules?.length) preview.push(`${scope.policyRules.length} rule(s)`);
this.scopePreview.set(preview);
}
selectType(type: ExceptionType): void {
this.updateDraft('type', type);
}
selectTemplate(templateId: string): void {
const template = this.applicableTemplates().find((t) => t.id === templateId);
if (template) {
this.selectedTemplate.set(templateId);
this.updateDraft('justification', template.template);
}
}
selectTimebox(days: number): void {
this.updateDraft('expiresInDays', days);
}
addTag(): void {
const tag = this.newTag().trim();
if (tag && !this.draft().tags.includes(tag)) {
this.updateDraft('tags', [...this.draft().tags, tag]);
this.newTag.set('');
}
}
removeTag(tag: string): void {
this.updateDraft('tags', this.draft().tags.filter((t) => t !== tag));
}
goNext(): void {
if (!this.canGoNext()) return;
const idx = this.currentStepIndex();
if (idx < this.steps.length - 1) {
this.currentStep.set(this.steps[idx + 1]);
}
}
goBack(): void {
if (!this.canGoBack()) return;
const idx = this.currentStepIndex();
if (idx > 0) {
this.currentStep.set(this.steps[idx - 1]);
}
}
goToStep(step: WizardStep): void {
const targetIdx = this.steps.indexOf(step);
if (targetIdx <= this.currentStepIndex()) {
this.currentStep.set(step);
}
}
onCancel(): void {
this.cancel.emit();
}
onSubmit(): void {
if (this.canGoNext()) {
this.create.emit(this.draft());
}
}
formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
onTagInput(event: Event): void {
this.newTag.set((event.target as HTMLInputElement).value);
}
}

View File

@@ -1,310 +1,310 @@
<div class="graph-explorer">
<!-- Header -->
<header class="graph-explorer__header">
<div class="graph-explorer__title-section">
<h1>Graph Explorer</h1>
<p class="graph-explorer__subtitle">Visualize asset dependencies and vulnerabilities</p>
</div>
<div class="graph-explorer__actions">
<button
type="button"
class="btn btn--secondary"
(click)="loadData()"
[disabled]="loading()"
>
Refresh
</button>
</div>
</header>
<!-- Message Toast -->
<div
class="graph-explorer__message"
*ngIf="message() as msg"
[class.graph-explorer__message--success]="messageType() === 'success'"
[class.graph-explorer__message--error]="messageType() === 'error'"
>
{{ msg }}
</div>
<!-- Toolbar -->
<div class="graph-explorer__toolbar">
<!-- View Toggle -->
<div class="view-toggle">
<button
type="button"
class="view-toggle__btn"
[class.view-toggle__btn--active]="viewMode() === 'hierarchy'"
(click)="setViewMode('hierarchy')"
>
Hierarchy
</button>
<button
type="button"
class="view-toggle__btn"
[class.view-toggle__btn--active]="viewMode() === 'flat'"
(click)="setViewMode('flat')"
>
Flat List
</button>
</div>
<!-- Layer Toggles -->
<div class="layer-toggles">
<label class="layer-toggle">
<input type="checkbox" [checked]="showAssets()" (change)="toggleAssets()" />
<span class="layer-toggle__icon">📦</span>
<span>Assets</span>
</label>
<label class="layer-toggle">
<input type="checkbox" [checked]="showComponents()" (change)="toggleComponents()" />
<span class="layer-toggle__icon">🧩</span>
<span>Components</span>
</label>
<label class="layer-toggle">
<input type="checkbox" [checked]="showVulnerabilities()" (change)="toggleVulnerabilities()" />
<span class="layer-toggle__icon">⚠️</span>
<span>Vulnerabilities</span>
</label>
</div>
<!-- Severity Filter -->
<div class="filter-group">
<label class="filter-group__label">Severity</label>
<select
class="filter-group__select"
[value]="filterSeverity()"
(change)="setSeverityFilter($any($event.target).value)"
>
<option value="all">All</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
</div>
<!-- Loading State -->
<div class="graph-explorer__loading" *ngIf="loading()">
<span class="spinner"></span>
<span>Loading graph data...</span>
</div>
<!-- Main Content -->
<div class="graph-explorer__content" *ngIf="!loading()">
<!-- Hierarchy View -->
<div class="hierarchy-view" *ngIf="viewMode() === 'hierarchy'">
<!-- Assets Layer -->
<div class="graph-layer">
<h3 class="graph-layer__title">
<span class="graph-layer__icon">📦</span>
Assets ({{ assets().length }})
</h3>
<div class="graph-nodes">
<div
*ngFor="let node of assets(); trackBy: trackByNode"
class="graph-node"
[class]="getNodeClass(node)"
(click)="selectNode(node.id)"
>
<span class="graph-node__name">{{ node.name }}</span>
<span class="graph-node__badge" *ngIf="node.vulnCount">
{{ node.vulnCount }} vulns
</span>
</div>
</div>
</div>
<!-- Components Layer -->
<div class="graph-layer">
<h3 class="graph-layer__title">
<span class="graph-layer__icon">🧩</span>
Components ({{ components().length }})
</h3>
<div class="graph-nodes">
<div
*ngFor="let node of components(); trackBy: trackByNode"
class="graph-node"
[class]="getNodeClass(node)"
(click)="selectNode(node.id)"
>
<span class="graph-node__name">{{ node.name }}</span>
<span class="graph-node__version">{{ node.version }}</span>
<span class="graph-node__severity chip" *ngIf="node.severity" [class]="getSeverityClass(node.severity)">
{{ node.severity }}
</span>
<span class="graph-node__exception" *ngIf="node.hasException"></span>
</div>
</div>
</div>
<!-- Vulnerabilities Layer -->
<div class="graph-layer">
<h3 class="graph-layer__title">
<span class="graph-layer__icon">⚠️</span>
Vulnerabilities ({{ vulnerabilities().length }})
</h3>
<div class="graph-nodes">
<div
*ngFor="let node of vulnerabilities(); trackBy: trackByNode"
class="graph-node"
[class]="getNodeClass(node)"
(click)="selectNode(node.id)"
>
<span class="graph-node__name">{{ node.name }}</span>
<span class="graph-node__severity chip" *ngIf="node.severity" [class]="getSeverityClass(node.severity)">
{{ node.severity }}
</span>
<span class="graph-node__exception" *ngIf="node.hasException"></span>
</div>
</div>
</div>
</div>
<!-- Flat View -->
<div class="flat-view" *ngIf="viewMode() === 'flat'">
<table class="node-table">
<thead>
<tr>
<th>Type</th>
<th>Name</th>
<th>Version/ID</th>
<th>Severity</th>
<th>Exception</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let node of filteredNodes(); trackBy: trackByNode"
class="node-table__row"
[class.node-table__row--selected]="selectedNodeId() === node.id"
(click)="selectNode(node.id)"
>
<td>
<span class="node-type-badge node-type-badge--{{ node.type }}">
{{ getNodeTypeIcon(node.type) }} {{ node.type }}
</span>
</td>
<td>{{ node.name }}</td>
<td>{{ node.version || node.purl || '-' }}</td>
<td>
<span class="chip chip--small" *ngIf="node.severity" [class]="getSeverityClass(node.severity)">
{{ node.severity }}
</span>
<span *ngIf="!node.severity">-</span>
</td>
<td>
<span class="exception-indicator" *ngIf="node.hasException">✓ Excepted</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Detail Panel -->
<div class="detail-panel" *ngIf="selectedNode() as node">
<div class="detail-panel__header">
<div class="detail-panel__title">
<span class="detail-panel__icon">{{ getNodeTypeIcon(node.type) }}</span>
<h2>{{ node.name }}</h2>
</div>
<button type="button" class="detail-panel__close" (click)="clearSelection()">Close</button>
</div>
<div class="detail-panel__content">
<!-- Node Info -->
<div class="detail-section">
<div class="detail-grid">
<div class="detail-item">
<span class="detail-item__label">Type</span>
<span class="node-type-badge node-type-badge--{{ node.type }}">
{{ node.type }}
</span>
</div>
<div class="detail-item" *ngIf="node.version">
<span class="detail-item__label">Version</span>
<span class="detail-item__value">{{ node.version }}</span>
</div>
<div class="detail-item" *ngIf="node.severity">
<span class="detail-item__label">Severity</span>
<span class="chip" [class]="getSeverityClass(node.severity)">{{ node.severity }}</span>
</div>
</div>
</div>
<!-- PURL -->
<div class="detail-section" *ngIf="node.purl">
<h4>Package URL</h4>
<code class="purl-display">{{ node.purl }}</code>
</div>
<!-- Exception Status -->
<div class="detail-section" *ngIf="getExceptionBadgeData(node) as badgeData">
<h4>Exception Status</h4>
<app-exception-badge
[data]="badgeData"
[compact]="false"
(viewDetails)="onViewExceptionDetails($event)"
(explain)="onExplainException($event)"
></app-exception-badge>
</div>
<!-- Related Nodes -->
<div class="detail-section">
<h4>Related Nodes ({{ relatedNodes().length }})</h4>
<div class="related-nodes">
<div
*ngFor="let related of relatedNodes(); trackBy: trackByNode"
class="related-node"
(click)="selectNode(related.id)"
>
<span class="related-node__icon">{{ getNodeTypeIcon(related.type) }}</span>
<span class="related-node__name">{{ related.name }}</span>
<span class="chip chip--small" *ngIf="related.severity" [class]="getSeverityClass(related.severity)">
{{ related.severity }}
</span>
</div>
<div class="related-nodes__empty" *ngIf="relatedNodes().length === 0">
No related nodes found
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="detail-panel__actions" *ngIf="!node.hasException && !showExceptionDraft()">
<button
type="button"
class="btn btn--primary"
(click)="startExceptionDraft()"
>
Create Exception
</button>
</div>
<!-- Inline Exception Draft -->
<div class="detail-panel__exception-draft" *ngIf="showExceptionDraft() && exceptionDraftContext()">
<app-exception-draft-inline
[context]="exceptionDraftContext()!"
(created)="onExceptionCreated()"
(cancelled)="cancelExceptionDraft()"
(openFullWizard)="openFullWizard()"
></app-exception-draft-inline>
</div>
</div>
<!-- Exception Explain Modal -->
<div class="explain-modal" *ngIf="showExceptionExplain() && exceptionExplainData()">
<div class="explain-modal__backdrop" (click)="closeExplain()"></div>
<div class="explain-modal__container">
<app-exception-explain
[data]="exceptionExplainData()!"
(close)="closeExplain()"
(viewException)="viewExceptionFromExplain($event)"
></app-exception-explain>
</div>
</div>
</div>
<div class="graph-explorer">
<!-- Header -->
<header class="graph-explorer__header">
<div class="graph-explorer__title-section">
<h1>Graph Explorer</h1>
<p class="graph-explorer__subtitle">Visualize asset dependencies and vulnerabilities</p>
</div>
<div class="graph-explorer__actions">
<button
type="button"
class="btn btn--secondary"
(click)="loadData()"
[disabled]="loading()"
>
Refresh
</button>
</div>
</header>
<!-- Message Toast -->
<div
class="graph-explorer__message"
*ngIf="message() as msg"
[class.graph-explorer__message--success]="messageType() === 'success'"
[class.graph-explorer__message--error]="messageType() === 'error'"
>
{{ msg }}
</div>
<!-- Toolbar -->
<div class="graph-explorer__toolbar">
<!-- View Toggle -->
<div class="view-toggle">
<button
type="button"
class="view-toggle__btn"
[class.view-toggle__btn--active]="viewMode() === 'hierarchy'"
(click)="setViewMode('hierarchy')"
>
Hierarchy
</button>
<button
type="button"
class="view-toggle__btn"
[class.view-toggle__btn--active]="viewMode() === 'flat'"
(click)="setViewMode('flat')"
>
Flat List
</button>
</div>
<!-- Layer Toggles -->
<div class="layer-toggles">
<label class="layer-toggle">
<input type="checkbox" [checked]="showAssets()" (change)="toggleAssets()" />
<span class="layer-toggle__icon">📦</span>
<span>Assets</span>
</label>
<label class="layer-toggle">
<input type="checkbox" [checked]="showComponents()" (change)="toggleComponents()" />
<span class="layer-toggle__icon">🧩</span>
<span>Components</span>
</label>
<label class="layer-toggle">
<input type="checkbox" [checked]="showVulnerabilities()" (change)="toggleVulnerabilities()" />
<span class="layer-toggle__icon">⚠️</span>
<span>Vulnerabilities</span>
</label>
</div>
<!-- Severity Filter -->
<div class="filter-group">
<label class="filter-group__label">Severity</label>
<select
class="filter-group__select"
[value]="filterSeverity()"
(change)="setSeverityFilter($any($event.target).value)"
>
<option value="all">All</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
</div>
<!-- Loading State -->
<div class="graph-explorer__loading" *ngIf="loading()">
<span class="spinner"></span>
<span>Loading graph data...</span>
</div>
<!-- Main Content -->
<div class="graph-explorer__content" *ngIf="!loading()">
<!-- Hierarchy View -->
<div class="hierarchy-view" *ngIf="viewMode() === 'hierarchy'">
<!-- Assets Layer -->
<div class="graph-layer">
<h3 class="graph-layer__title">
<span class="graph-layer__icon">📦</span>
Assets ({{ assets().length }})
</h3>
<div class="graph-nodes">
<div
*ngFor="let node of assets(); trackBy: trackByNode"
class="graph-node"
[class]="getNodeClass(node)"
(click)="selectNode(node.id)"
>
<span class="graph-node__name">{{ node.name }}</span>
<span class="graph-node__badge" *ngIf="node.vulnCount">
{{ node.vulnCount }} vulns
</span>
</div>
</div>
</div>
<!-- Components Layer -->
<div class="graph-layer">
<h3 class="graph-layer__title">
<span class="graph-layer__icon">🧩</span>
Components ({{ components().length }})
</h3>
<div class="graph-nodes">
<div
*ngFor="let node of components(); trackBy: trackByNode"
class="graph-node"
[class]="getNodeClass(node)"
(click)="selectNode(node.id)"
>
<span class="graph-node__name">{{ node.name }}</span>
<span class="graph-node__version">{{ node.version }}</span>
<span class="graph-node__severity chip" *ngIf="node.severity" [class]="getSeverityClass(node.severity)">
{{ node.severity }}
</span>
<span class="graph-node__exception" *ngIf="node.hasException"></span>
</div>
</div>
</div>
<!-- Vulnerabilities Layer -->
<div class="graph-layer">
<h3 class="graph-layer__title">
<span class="graph-layer__icon">⚠️</span>
Vulnerabilities ({{ vulnerabilities().length }})
</h3>
<div class="graph-nodes">
<div
*ngFor="let node of vulnerabilities(); trackBy: trackByNode"
class="graph-node"
[class]="getNodeClass(node)"
(click)="selectNode(node.id)"
>
<span class="graph-node__name">{{ node.name }}</span>
<span class="graph-node__severity chip" *ngIf="node.severity" [class]="getSeverityClass(node.severity)">
{{ node.severity }}
</span>
<span class="graph-node__exception" *ngIf="node.hasException"></span>
</div>
</div>
</div>
</div>
<!-- Flat View -->
<div class="flat-view" *ngIf="viewMode() === 'flat'">
<table class="node-table">
<thead>
<tr>
<th>Type</th>
<th>Name</th>
<th>Version/ID</th>
<th>Severity</th>
<th>Exception</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let node of filteredNodes(); trackBy: trackByNode"
class="node-table__row"
[class.node-table__row--selected]="selectedNodeId() === node.id"
(click)="selectNode(node.id)"
>
<td>
<span class="node-type-badge node-type-badge--{{ node.type }}">
{{ getNodeTypeIcon(node.type) }} {{ node.type }}
</span>
</td>
<td>{{ node.name }}</td>
<td>{{ node.version || node.purl || '-' }}</td>
<td>
<span class="chip chip--small" *ngIf="node.severity" [class]="getSeverityClass(node.severity)">
{{ node.severity }}
</span>
<span *ngIf="!node.severity">-</span>
</td>
<td>
<span class="exception-indicator" *ngIf="node.hasException">✓ Excepted</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Detail Panel -->
<div class="detail-panel" *ngIf="selectedNode() as node">
<div class="detail-panel__header">
<div class="detail-panel__title">
<span class="detail-panel__icon">{{ getNodeTypeIcon(node.type) }}</span>
<h2>{{ node.name }}</h2>
</div>
<button type="button" class="detail-panel__close" (click)="clearSelection()">Close</button>
</div>
<div class="detail-panel__content">
<!-- Node Info -->
<div class="detail-section">
<div class="detail-grid">
<div class="detail-item">
<span class="detail-item__label">Type</span>
<span class="node-type-badge node-type-badge--{{ node.type }}">
{{ node.type }}
</span>
</div>
<div class="detail-item" *ngIf="node.version">
<span class="detail-item__label">Version</span>
<span class="detail-item__value">{{ node.version }}</span>
</div>
<div class="detail-item" *ngIf="node.severity">
<span class="detail-item__label">Severity</span>
<span class="chip" [class]="getSeverityClass(node.severity)">{{ node.severity }}</span>
</div>
</div>
</div>
<!-- PURL -->
<div class="detail-section" *ngIf="node.purl">
<h4>Package URL</h4>
<code class="purl-display">{{ node.purl }}</code>
</div>
<!-- Exception Status -->
<div class="detail-section" *ngIf="getExceptionBadgeData(node) as badgeData">
<h4>Exception Status</h4>
<app-exception-badge
[data]="badgeData"
[compact]="false"
(viewDetails)="onViewExceptionDetails($event)"
(explain)="onExplainException($event)"
></app-exception-badge>
</div>
<!-- Related Nodes -->
<div class="detail-section">
<h4>Related Nodes ({{ relatedNodes().length }})</h4>
<div class="related-nodes">
<div
*ngFor="let related of relatedNodes(); trackBy: trackByNode"
class="related-node"
(click)="selectNode(related.id)"
>
<span class="related-node__icon">{{ getNodeTypeIcon(related.type) }}</span>
<span class="related-node__name">{{ related.name }}</span>
<span class="chip chip--small" *ngIf="related.severity" [class]="getSeverityClass(related.severity)">
{{ related.severity }}
</span>
</div>
<div class="related-nodes__empty" *ngIf="relatedNodes().length === 0">
No related nodes found
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="detail-panel__actions" *ngIf="!node.hasException && !showExceptionDraft()">
<button
type="button"
class="btn btn--primary"
(click)="startExceptionDraft()"
>
Create Exception
</button>
</div>
<!-- Inline Exception Draft -->
<div class="detail-panel__exception-draft" *ngIf="showExceptionDraft() && exceptionDraftContext()">
<app-exception-draft-inline
[context]="exceptionDraftContext()!"
(created)="onExceptionCreated()"
(cancelled)="cancelExceptionDraft()"
(openFullWizard)="openFullWizard()"
></app-exception-draft-inline>
</div>
</div>
<!-- Exception Explain Modal -->
<div class="explain-modal" *ngIf="showExceptionExplain() && exceptionExplainData()">
<div class="explain-modal__backdrop" (click)="closeExplain()"></div>
<div class="explain-modal__container">
<app-exception-explain
[data]="exceptionExplainData()!"
(close)="closeExplain()"
(viewException)="viewExceptionFromExplain($event)"
></app-exception-explain>
</div>
</div>
</div>

View File

@@ -1,410 +1,410 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import {
ExceptionDraftContext,
ExceptionDraftInlineComponent,
} from '../exceptions/exception-draft-inline.component';
import {
ExceptionBadgeComponent,
ExceptionBadgeData,
ExceptionExplainComponent,
ExceptionExplainData,
} from '../../shared/components';
import {
AUTH_SERVICE,
AuthService,
MockAuthService,
StellaOpsScopes,
} from '../../core/auth';
export interface GraphNode {
readonly id: string;
readonly type: 'asset' | 'component' | 'vulnerability';
readonly name: string;
readonly purl?: string;
readonly version?: string;
readonly severity?: 'critical' | 'high' | 'medium' | 'low';
readonly vulnCount?: number;
readonly hasException?: boolean;
}
export interface GraphEdge {
readonly source: string;
readonly target: string;
readonly type: 'depends_on' | 'has_vulnerability' | 'child_of';
}
const MOCK_NODES: GraphNode[] = [
{ id: 'asset-web-prod', type: 'asset', name: 'web-prod', vulnCount: 5 },
{ id: 'asset-api-prod', type: 'asset', name: 'api-prod', vulnCount: 3 },
{ id: 'comp-log4j', type: 'component', name: 'log4j-core', purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', version: '2.14.1', severity: 'critical', vulnCount: 2 },
{ id: 'comp-spring', type: 'component', name: 'spring-beans', purl: 'pkg:maven/org.springframework/spring-beans@5.3.17', version: '5.3.17', severity: 'critical', vulnCount: 1, hasException: true },
{ id: 'comp-curl', type: 'component', name: 'curl', purl: 'pkg:deb/debian/curl@7.88.1-10', version: '7.88.1-10', severity: 'high', vulnCount: 1 },
{ id: 'comp-nghttp2', type: 'component', name: 'nghttp2', purl: 'pkg:npm/nghttp2@1.55.0', version: '1.55.0', severity: 'high', vulnCount: 1 },
{ id: 'comp-golang-net', type: 'component', name: 'golang.org/x/net', purl: 'pkg:golang/golang.org/x/net@0.15.0', version: '0.15.0', severity: 'high', vulnCount: 1 },
{ id: 'comp-zlib', type: 'component', name: 'zlib', purl: 'pkg:deb/debian/zlib@1.2.13', version: '1.2.13', severity: 'medium', vulnCount: 1 },
{ id: 'vuln-log4shell', type: 'vulnerability', name: 'CVE-2021-44228', severity: 'critical' },
{ id: 'vuln-log4j-dos', type: 'vulnerability', name: 'CVE-2021-45046', severity: 'critical', hasException: true },
{ id: 'vuln-spring4shell', type: 'vulnerability', name: 'CVE-2022-22965', severity: 'critical', hasException: true },
{ id: 'vuln-http2-reset', type: 'vulnerability', name: 'CVE-2023-44487', severity: 'high' },
{ id: 'vuln-curl-heap', type: 'vulnerability', name: 'CVE-2023-38545', severity: 'high' },
];
const MOCK_EDGES: GraphEdge[] = [
{ source: 'asset-web-prod', target: 'comp-log4j', type: 'depends_on' },
{ source: 'asset-web-prod', target: 'comp-curl', type: 'depends_on' },
{ source: 'asset-web-prod', target: 'comp-nghttp2', type: 'depends_on' },
{ source: 'asset-web-prod', target: 'comp-zlib', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-log4j', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-curl', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-golang-net', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-spring', type: 'depends_on' },
{ source: 'comp-log4j', target: 'vuln-log4shell', type: 'has_vulnerability' },
{ source: 'comp-log4j', target: 'vuln-log4j-dos', type: 'has_vulnerability' },
{ source: 'comp-spring', target: 'vuln-spring4shell', type: 'has_vulnerability' },
{ source: 'comp-nghttp2', target: 'vuln-http2-reset', type: 'has_vulnerability' },
{ source: 'comp-golang-net', target: 'vuln-http2-reset', type: 'has_vulnerability' },
{ source: 'comp-curl', target: 'vuln-curl-heap', type: 'has_vulnerability' },
];
type ViewMode = 'hierarchy' | 'flat';
@Component({
selector: 'app-graph-explorer',
standalone: true,
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent],
providers: [{ provide: AUTH_SERVICE, useClass: MockAuthService }],
templateUrl: './graph-explorer.component.html',
styleUrls: ['./graph-explorer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GraphExplorerComponent implements OnInit {
private readonly authService = inject(AUTH_SERVICE);
// Scope-based permissions (using stub StellaOpsScopes from UI-GRAPH-21-001)
readonly canViewGraph = computed(() => this.authService.canViewGraph());
readonly canEditGraph = computed(() => this.authService.canEditGraph());
readonly canExportGraph = computed(() => this.authService.canExportGraph());
readonly canSimulate = computed(() => this.authService.canSimulate());
readonly canCreateException = computed(() =>
this.authService.hasScope(StellaOpsScopes.EXCEPTION_WRITE)
);
// Current user info
readonly currentUser = computed(() => this.authService.user());
readonly userScopes = computed(() => this.authService.scopes());
// View state
readonly loading = signal(false);
readonly message = signal<string | null>(null);
readonly messageType = signal<'success' | 'error' | 'info'>('info');
readonly viewMode = signal<ViewMode>('hierarchy');
// Data
readonly nodes = signal<GraphNode[]>([]);
readonly edges = signal<GraphEdge[]>([]);
readonly selectedNodeId = signal<string | null>(null);
// Exception draft state
readonly showExceptionDraft = signal(false);
// Exception explain state
readonly showExceptionExplain = signal(false);
readonly explainNodeId = signal<string | null>(null);
// Filters
readonly showVulnerabilities = signal(true);
readonly showComponents = signal(true);
readonly showAssets = signal(true);
readonly filterSeverity = signal<'all' | 'critical' | 'high' | 'medium' | 'low'>('all');
// Computed: filtered nodes
readonly filteredNodes = computed(() => {
let items = [...this.nodes()];
const showVulns = this.showVulnerabilities();
const showComps = this.showComponents();
const showAssetNodes = this.showAssets();
const severity = this.filterSeverity();
items = items.filter((n) => {
if (n.type === 'vulnerability' && !showVulns) return false;
if (n.type === 'component' && !showComps) return false;
if (n.type === 'asset' && !showAssetNodes) return false;
if (severity !== 'all' && n.severity && n.severity !== severity) return false;
return true;
});
return items;
});
// Computed: assets
readonly assets = computed(() => {
return this.filteredNodes().filter((n) => n.type === 'asset');
});
// Computed: components
readonly components = computed(() => {
return this.filteredNodes().filter((n) => n.type === 'component');
});
// Computed: vulnerabilities
readonly vulnerabilities = computed(() => {
return this.filteredNodes().filter((n) => n.type === 'vulnerability');
});
// Computed: selected node
readonly selectedNode = computed(() => {
const id = this.selectedNodeId();
if (!id) return null;
return this.nodes().find((n) => n.id === id) ?? null;
});
// Computed: related nodes for selected
readonly relatedNodes = computed(() => {
const selectedId = this.selectedNodeId();
if (!selectedId) return [];
const edgeList = this.edges();
const relatedIds = new Set<string>();
edgeList.forEach((e) => {
if (e.source === selectedId) relatedIds.add(e.target);
if (e.target === selectedId) relatedIds.add(e.source);
});
return this.nodes().filter((n) => relatedIds.has(n.id));
});
// Get exception badge data for a node
getExceptionBadgeData(node: GraphNode): ExceptionBadgeData | null {
if (!node.hasException) return null;
return {
exceptionId: `exc-${node.id}`,
status: 'approved',
severity: node.severity ?? 'medium',
name: `${node.name} Exception`,
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
justificationSummary: 'Risk accepted with compensating controls.',
approvedBy: 'Security Team',
};
}
// Computed: explain data for selected node
readonly exceptionExplainData = computed<ExceptionExplainData | null>(() => {
const nodeId = this.explainNodeId();
if (!nodeId) return null;
const node = this.nodes().find((n) => n.id === nodeId);
if (!node || !node.hasException) return null;
const relatedComps = this.edges()
.filter((e) => e.source === nodeId || e.target === nodeId)
.map((e) => (e.source === nodeId ? e.target : e.source))
.map((id) => this.nodes().find((n) => n.id === id))
.filter((n): n is GraphNode => n !== undefined && n.type === 'component');
return {
exceptionId: `exc-${node.id}`,
name: `${node.name} Exception`,
status: 'approved',
severity: node.severity ?? 'medium',
scope: {
type: node.type === 'vulnerability' ? 'vulnerability' : 'component',
vulnIds: node.type === 'vulnerability' ? [node.name] : undefined,
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
},
justification: {
template: 'risk-accepted',
text: 'Risk accepted with compensating controls in place. The affected item is in a controlled environment.',
},
timebox: {
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
autoRenew: false,
},
approvedBy: 'Security Team',
approvedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
impact: {
affectedFindings: 1,
affectedAssets: 1,
policyOverrides: 1,
},
};
});
// Computed: exception draft context
readonly exceptionDraftContext = computed<ExceptionDraftContext | null>(() => {
const node = this.selectedNode();
if (!node) return null;
if (node.type === 'component') {
const relatedVulns = this.relatedNodes().filter((n) => n.type === 'vulnerability');
return {
componentPurls: node.purl ? [node.purl] : undefined,
vulnIds: relatedVulns.map((v) => v.name),
suggestedName: `${node.name}-exception`,
suggestedSeverity: node.severity ?? 'medium',
sourceType: 'component',
sourceLabel: `${node.name}@${node.version}`,
};
}
if (node.type === 'vulnerability') {
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
return {
vulnIds: [node.name],
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
suggestedName: `${node.name.toLowerCase()}-exception`,
suggestedSeverity: node.severity ?? 'medium',
sourceType: 'vulnerability',
sourceLabel: node.name,
};
}
if (node.type === 'asset') {
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
return {
assetIds: [node.name],
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
suggestedName: `${node.name}-exception`,
sourceType: 'asset',
sourceLabel: node.name,
};
}
return null;
});
ngOnInit(): void {
this.loadData();
}
loadData(): void {
this.loading.set(true);
// Simulate API call
setTimeout(() => {
this.nodes.set([...MOCK_NODES]);
this.edges.set([...MOCK_EDGES]);
this.loading.set(false);
}, 300);
}
// View mode
setViewMode(mode: ViewMode): void {
this.viewMode.set(mode);
}
// Filters
toggleVulnerabilities(): void {
this.showVulnerabilities.set(!this.showVulnerabilities());
}
toggleComponents(): void {
this.showComponents.set(!this.showComponents());
}
toggleAssets(): void {
this.showAssets.set(!this.showAssets());
}
setSeverityFilter(severity: 'all' | 'critical' | 'high' | 'medium' | 'low'): void {
this.filterSeverity.set(severity);
}
// Selection
selectNode(nodeId: string): void {
this.selectedNodeId.set(nodeId);
this.showExceptionDraft.set(false);
}
clearSelection(): void {
this.selectedNodeId.set(null);
this.showExceptionDraft.set(false);
}
// Exception drafting
startExceptionDraft(): void {
this.showExceptionDraft.set(true);
}
cancelExceptionDraft(): void {
this.showExceptionDraft.set(false);
}
onExceptionCreated(): void {
this.showExceptionDraft.set(false);
this.showMessage('Exception draft created successfully', 'success');
this.loadData();
}
openFullWizard(): void {
this.showMessage('Opening full wizard... (would navigate to Exception Center)', 'info');
}
// Exception explain
onViewExceptionDetails(exceptionId: string): void {
this.showMessage(`Navigating to exception ${exceptionId}...`, 'info');
}
onExplainException(exceptionId: string): void {
// Find the node with this exception ID
const node = this.nodes().find((n) => `exc-${n.id}` === exceptionId);
if (node) {
this.explainNodeId.set(node.id);
this.showExceptionExplain.set(true);
}
}
closeExplain(): void {
this.showExceptionExplain.set(false);
this.explainNodeId.set(null);
}
viewExceptionFromExplain(exceptionId: string): void {
this.closeExplain();
this.onViewExceptionDetails(exceptionId);
}
// Helpers
getNodeTypeIcon(type: GraphNode['type']): string {
switch (type) {
case 'asset':
return '📦';
case 'component':
return '🧩';
case 'vulnerability':
return '⚠️';
default:
return '•';
}
}
getSeverityClass(severity: string | undefined): string {
if (!severity) return '';
return `severity--${severity}`;
}
getNodeClass(node: GraphNode): string {
const classes = [`node--${node.type}`];
if (node.severity) classes.push(`node--${node.severity}`);
if (node.hasException) classes.push('node--excepted');
if (this.selectedNodeId() === node.id) classes.push('node--selected');
return classes.join(' ');
}
trackByNode = (_: number, item: GraphNode) => item.id;
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
this.message.set(text);
this.messageType.set(type);
setTimeout(() => this.message.set(null), 5000);
}
}
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import {
ExceptionDraftContext,
ExceptionDraftInlineComponent,
} from '../exceptions/exception-draft-inline.component';
import {
ExceptionBadgeComponent,
ExceptionBadgeData,
ExceptionExplainComponent,
ExceptionExplainData,
} from '../../shared/components';
import {
AUTH_SERVICE,
AuthService,
MockAuthService,
StellaOpsScopes,
} from '../../core/auth';
export interface GraphNode {
readonly id: string;
readonly type: 'asset' | 'component' | 'vulnerability';
readonly name: string;
readonly purl?: string;
readonly version?: string;
readonly severity?: 'critical' | 'high' | 'medium' | 'low';
readonly vulnCount?: number;
readonly hasException?: boolean;
}
export interface GraphEdge {
readonly source: string;
readonly target: string;
readonly type: 'depends_on' | 'has_vulnerability' | 'child_of';
}
const MOCK_NODES: GraphNode[] = [
{ id: 'asset-web-prod', type: 'asset', name: 'web-prod', vulnCount: 5 },
{ id: 'asset-api-prod', type: 'asset', name: 'api-prod', vulnCount: 3 },
{ id: 'comp-log4j', type: 'component', name: 'log4j-core', purl: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1', version: '2.14.1', severity: 'critical', vulnCount: 2 },
{ id: 'comp-spring', type: 'component', name: 'spring-beans', purl: 'pkg:maven/org.springframework/spring-beans@5.3.17', version: '5.3.17', severity: 'critical', vulnCount: 1, hasException: true },
{ id: 'comp-curl', type: 'component', name: 'curl', purl: 'pkg:deb/debian/curl@7.88.1-10', version: '7.88.1-10', severity: 'high', vulnCount: 1 },
{ id: 'comp-nghttp2', type: 'component', name: 'nghttp2', purl: 'pkg:npm/nghttp2@1.55.0', version: '1.55.0', severity: 'high', vulnCount: 1 },
{ id: 'comp-golang-net', type: 'component', name: 'golang.org/x/net', purl: 'pkg:golang/golang.org/x/net@0.15.0', version: '0.15.0', severity: 'high', vulnCount: 1 },
{ id: 'comp-zlib', type: 'component', name: 'zlib', purl: 'pkg:deb/debian/zlib@1.2.13', version: '1.2.13', severity: 'medium', vulnCount: 1 },
{ id: 'vuln-log4shell', type: 'vulnerability', name: 'CVE-2021-44228', severity: 'critical' },
{ id: 'vuln-log4j-dos', type: 'vulnerability', name: 'CVE-2021-45046', severity: 'critical', hasException: true },
{ id: 'vuln-spring4shell', type: 'vulnerability', name: 'CVE-2022-22965', severity: 'critical', hasException: true },
{ id: 'vuln-http2-reset', type: 'vulnerability', name: 'CVE-2023-44487', severity: 'high' },
{ id: 'vuln-curl-heap', type: 'vulnerability', name: 'CVE-2023-38545', severity: 'high' },
];
const MOCK_EDGES: GraphEdge[] = [
{ source: 'asset-web-prod', target: 'comp-log4j', type: 'depends_on' },
{ source: 'asset-web-prod', target: 'comp-curl', type: 'depends_on' },
{ source: 'asset-web-prod', target: 'comp-nghttp2', type: 'depends_on' },
{ source: 'asset-web-prod', target: 'comp-zlib', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-log4j', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-curl', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-golang-net', type: 'depends_on' },
{ source: 'asset-api-prod', target: 'comp-spring', type: 'depends_on' },
{ source: 'comp-log4j', target: 'vuln-log4shell', type: 'has_vulnerability' },
{ source: 'comp-log4j', target: 'vuln-log4j-dos', type: 'has_vulnerability' },
{ source: 'comp-spring', target: 'vuln-spring4shell', type: 'has_vulnerability' },
{ source: 'comp-nghttp2', target: 'vuln-http2-reset', type: 'has_vulnerability' },
{ source: 'comp-golang-net', target: 'vuln-http2-reset', type: 'has_vulnerability' },
{ source: 'comp-curl', target: 'vuln-curl-heap', type: 'has_vulnerability' },
];
type ViewMode = 'hierarchy' | 'flat';
@Component({
selector: 'app-graph-explorer',
standalone: true,
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent],
providers: [{ provide: AUTH_SERVICE, useClass: MockAuthService }],
templateUrl: './graph-explorer.component.html',
styleUrls: ['./graph-explorer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GraphExplorerComponent implements OnInit {
private readonly authService = inject(AUTH_SERVICE);
// Scope-based permissions (using stub StellaOpsScopes from UI-GRAPH-21-001)
readonly canViewGraph = computed(() => this.authService.canViewGraph());
readonly canEditGraph = computed(() => this.authService.canEditGraph());
readonly canExportGraph = computed(() => this.authService.canExportGraph());
readonly canSimulate = computed(() => this.authService.canSimulate());
readonly canCreateException = computed(() =>
this.authService.hasScope(StellaOpsScopes.EXCEPTION_WRITE)
);
// Current user info
readonly currentUser = computed(() => this.authService.user());
readonly userScopes = computed(() => this.authService.scopes());
// View state
readonly loading = signal(false);
readonly message = signal<string | null>(null);
readonly messageType = signal<'success' | 'error' | 'info'>('info');
readonly viewMode = signal<ViewMode>('hierarchy');
// Data
readonly nodes = signal<GraphNode[]>([]);
readonly edges = signal<GraphEdge[]>([]);
readonly selectedNodeId = signal<string | null>(null);
// Exception draft state
readonly showExceptionDraft = signal(false);
// Exception explain state
readonly showExceptionExplain = signal(false);
readonly explainNodeId = signal<string | null>(null);
// Filters
readonly showVulnerabilities = signal(true);
readonly showComponents = signal(true);
readonly showAssets = signal(true);
readonly filterSeverity = signal<'all' | 'critical' | 'high' | 'medium' | 'low'>('all');
// Computed: filtered nodes
readonly filteredNodes = computed(() => {
let items = [...this.nodes()];
const showVulns = this.showVulnerabilities();
const showComps = this.showComponents();
const showAssetNodes = this.showAssets();
const severity = this.filterSeverity();
items = items.filter((n) => {
if (n.type === 'vulnerability' && !showVulns) return false;
if (n.type === 'component' && !showComps) return false;
if (n.type === 'asset' && !showAssetNodes) return false;
if (severity !== 'all' && n.severity && n.severity !== severity) return false;
return true;
});
return items;
});
// Computed: assets
readonly assets = computed(() => {
return this.filteredNodes().filter((n) => n.type === 'asset');
});
// Computed: components
readonly components = computed(() => {
return this.filteredNodes().filter((n) => n.type === 'component');
});
// Computed: vulnerabilities
readonly vulnerabilities = computed(() => {
return this.filteredNodes().filter((n) => n.type === 'vulnerability');
});
// Computed: selected node
readonly selectedNode = computed(() => {
const id = this.selectedNodeId();
if (!id) return null;
return this.nodes().find((n) => n.id === id) ?? null;
});
// Computed: related nodes for selected
readonly relatedNodes = computed(() => {
const selectedId = this.selectedNodeId();
if (!selectedId) return [];
const edgeList = this.edges();
const relatedIds = new Set<string>();
edgeList.forEach((e) => {
if (e.source === selectedId) relatedIds.add(e.target);
if (e.target === selectedId) relatedIds.add(e.source);
});
return this.nodes().filter((n) => relatedIds.has(n.id));
});
// Get exception badge data for a node
getExceptionBadgeData(node: GraphNode): ExceptionBadgeData | null {
if (!node.hasException) return null;
return {
exceptionId: `exc-${node.id}`,
status: 'approved',
severity: node.severity ?? 'medium',
name: `${node.name} Exception`,
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
justificationSummary: 'Risk accepted with compensating controls.',
approvedBy: 'Security Team',
};
}
// Computed: explain data for selected node
readonly exceptionExplainData = computed<ExceptionExplainData | null>(() => {
const nodeId = this.explainNodeId();
if (!nodeId) return null;
const node = this.nodes().find((n) => n.id === nodeId);
if (!node || !node.hasException) return null;
const relatedComps = this.edges()
.filter((e) => e.source === nodeId || e.target === nodeId)
.map((e) => (e.source === nodeId ? e.target : e.source))
.map((id) => this.nodes().find((n) => n.id === id))
.filter((n): n is GraphNode => n !== undefined && n.type === 'component');
return {
exceptionId: `exc-${node.id}`,
name: `${node.name} Exception`,
status: 'approved',
severity: node.severity ?? 'medium',
scope: {
type: node.type === 'vulnerability' ? 'vulnerability' : 'component',
vulnIds: node.type === 'vulnerability' ? [node.name] : undefined,
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
},
justification: {
template: 'risk-accepted',
text: 'Risk accepted with compensating controls in place. The affected item is in a controlled environment.',
},
timebox: {
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
autoRenew: false,
},
approvedBy: 'Security Team',
approvedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
impact: {
affectedFindings: 1,
affectedAssets: 1,
policyOverrides: 1,
},
};
});
// Computed: exception draft context
readonly exceptionDraftContext = computed<ExceptionDraftContext | null>(() => {
const node = this.selectedNode();
if (!node) return null;
if (node.type === 'component') {
const relatedVulns = this.relatedNodes().filter((n) => n.type === 'vulnerability');
return {
componentPurls: node.purl ? [node.purl] : undefined,
vulnIds: relatedVulns.map((v) => v.name),
suggestedName: `${node.name}-exception`,
suggestedSeverity: node.severity ?? 'medium',
sourceType: 'component',
sourceLabel: `${node.name}@${node.version}`,
};
}
if (node.type === 'vulnerability') {
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
return {
vulnIds: [node.name],
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
suggestedName: `${node.name.toLowerCase()}-exception`,
suggestedSeverity: node.severity ?? 'medium',
sourceType: 'vulnerability',
sourceLabel: node.name,
};
}
if (node.type === 'asset') {
const relatedComps = this.relatedNodes().filter((n) => n.type === 'component');
return {
assetIds: [node.name],
componentPurls: relatedComps.filter((c) => c.purl).map((c) => c.purl!),
suggestedName: `${node.name}-exception`,
sourceType: 'asset',
sourceLabel: node.name,
};
}
return null;
});
ngOnInit(): void {
this.loadData();
}
loadData(): void {
this.loading.set(true);
// Simulate API call
setTimeout(() => {
this.nodes.set([...MOCK_NODES]);
this.edges.set([...MOCK_EDGES]);
this.loading.set(false);
}, 300);
}
// View mode
setViewMode(mode: ViewMode): void {
this.viewMode.set(mode);
}
// Filters
toggleVulnerabilities(): void {
this.showVulnerabilities.set(!this.showVulnerabilities());
}
toggleComponents(): void {
this.showComponents.set(!this.showComponents());
}
toggleAssets(): void {
this.showAssets.set(!this.showAssets());
}
setSeverityFilter(severity: 'all' | 'critical' | 'high' | 'medium' | 'low'): void {
this.filterSeverity.set(severity);
}
// Selection
selectNode(nodeId: string): void {
this.selectedNodeId.set(nodeId);
this.showExceptionDraft.set(false);
}
clearSelection(): void {
this.selectedNodeId.set(null);
this.showExceptionDraft.set(false);
}
// Exception drafting
startExceptionDraft(): void {
this.showExceptionDraft.set(true);
}
cancelExceptionDraft(): void {
this.showExceptionDraft.set(false);
}
onExceptionCreated(): void {
this.showExceptionDraft.set(false);
this.showMessage('Exception draft created successfully', 'success');
this.loadData();
}
openFullWizard(): void {
this.showMessage('Opening full wizard... (would navigate to Exception Center)', 'info');
}
// Exception explain
onViewExceptionDetails(exceptionId: string): void {
this.showMessage(`Navigating to exception ${exceptionId}...`, 'info');
}
onExplainException(exceptionId: string): void {
// Find the node with this exception ID
const node = this.nodes().find((n) => `exc-${n.id}` === exceptionId);
if (node) {
this.explainNodeId.set(node.id);
this.showExceptionExplain.set(true);
}
}
closeExplain(): void {
this.showExceptionExplain.set(false);
this.explainNodeId.set(null);
}
viewExceptionFromExplain(exceptionId: string): void {
this.closeExplain();
this.onViewExceptionDetails(exceptionId);
}
// Helpers
getNodeTypeIcon(type: GraphNode['type']): string {
switch (type) {
case 'asset':
return '📦';
case 'component':
return '🧩';
case 'vulnerability':
return '⚠️';
default:
return '•';
}
}
getSeverityClass(severity: string | undefined): string {
if (!severity) return '';
return `severity--${severity}`;
}
getNodeClass(node: GraphNode): string {
const classes = [`node--${node.type}`];
if (node.severity) classes.push(`node--${node.severity}`);
if (node.hasException) classes.push('node--excepted');
if (this.selectedNodeId() === node.id) classes.push('node--selected');
return classes.join(' ');
}
trackByNode = (_: number, item: GraphNode) => item.id;
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
this.message.set(text);
this.messageType.set(type);
setTimeout(() => this.message.set(null), 5000);
}
}

View File

@@ -1,3 +1,3 @@
export { ReleaseFlowComponent } from './release-flow.component';
export { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
export { RemediationHintsComponent } from './remediation-hints.component';
export { ReleaseFlowComponent } from './release-flow.component';
export { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
export { RemediationHintsComponent } from './remediation-hints.component';

View File

@@ -1,328 +1,328 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
signal,
} from '@angular/core';
import {
PolicyGateResult,
PolicyGateStatus,
DeterminismFeatureFlags,
} from '../../core/api/release.models';
@Component({
selector: 'app-policy-gate-indicator',
standalone: true,
imports: [CommonModule],
template: `
<div
class="gate-indicator"
[class.gate-indicator--expanded]="expanded()"
[class]="'gate-indicator--' + gate().status"
>
<button
type="button"
class="gate-header"
(click)="toggleExpanded()"
[attr.aria-expanded]="expanded()"
[attr.aria-controls]="'gate-details-' + gate().gateId"
>
<div class="gate-status">
<span class="status-icon" [class]="getStatusIconClass()" aria-hidden="true">
@switch (gate().status) {
@case ('passed') { <span>&#10003;</span> }
@case ('failed') { <span>&#10007;</span> }
@case ('warning') { <span>!</span> }
@case ('pending') { <span>&#8987;</span> }
@case ('skipped') { <span>-</span> }
}
</span>
<span class="status-text">{{ getStatusLabel() }}</span>
</div>
<div class="gate-info">
<span class="gate-name">{{ gate().name }}</span>
@if (gate().gateType === 'determinism' && isDeterminismGate()) {
<span class="gate-type-badge gate-type-badge--determinism">Determinism</span>
}
@if (gate().blockingPublish) {
<span class="blocking-badge" title="This gate blocks publishing">Blocking</span>
}
</div>
<span class="expand-icon" aria-hidden="true">{{ expanded() ? '&#9650;' : '&#9660;' }}</span>
</button>
@if (expanded()) {
<div class="gate-details" [id]="'gate-details-' + gate().gateId">
<p class="gate-message">{{ gate().message }}</p>
<div class="gate-meta">
<span class="meta-item">
<strong>Evaluated:</strong> {{ formatDate(gate().evaluatedAt) }}
</span>
@if (gate().evidence?.url) {
<a
[href]="gate().evidence?.url"
class="evidence-link"
target="_blank"
rel="noopener"
>
View Evidence
</a>
}
</div>
<!-- Determinism-specific info when feature flag shows it -->
@if (gate().gateType === 'determinism' && featureFlags()?.enabled) {
<div class="feature-flag-info">
@if (featureFlags()?.blockOnFailure) {
<span class="flag-badge flag-badge--active">Determinism Blocking Enabled</span>
} @else if (featureFlags()?.warnOnly) {
<span class="flag-badge flag-badge--warn">Determinism Warn-Only Mode</span>
}
</div>
}
</div>
}
</div>
`,
styles: [`
.gate-indicator {
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
overflow: hidden;
transition: border-color 0.15s;
&--passed {
border-left: 3px solid #22c55e;
}
&--failed {
border-left: 3px solid #ef4444;
}
&--warning {
border-left: 3px solid #f97316;
}
&--pending {
border-left: 3px solid #eab308;
}
&--skipped {
border-left: 3px solid #64748b;
}
&--expanded {
border-color: #475569;
}
}
.gate-header {
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
padding: 0.75rem 1rem;
background: transparent;
border: none;
color: #e2e8f0;
cursor: pointer;
text-align: left;
&:hover {
background: rgba(255, 255, 255, 0.03);
}
}
.gate-status {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 80px;
}
.status-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
font-size: 0.75rem;
font-weight: bold;
}
.gate-indicator--passed .status-icon {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.gate-indicator--failed .status-icon {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.gate-indicator--warning .status-icon {
background: rgba(249, 115, 22, 0.2);
color: #f97316;
}
.gate-indicator--pending .status-icon {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.gate-indicator--skipped .status-icon {
background: rgba(100, 116, 139, 0.2);
color: #64748b;
}
.status-text {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.gate-indicator--passed .status-text { color: #22c55e; }
.gate-indicator--failed .status-text { color: #ef4444; }
.gate-indicator--warning .status-text { color: #f97316; }
.gate-indicator--pending .status-text { color: #eab308; }
.gate-indicator--skipped .status-text { color: #64748b; }
.gate-info {
flex: 1;
display: flex;
align-items: center;
gap: 0.75rem;
}
.gate-name {
font-weight: 500;
}
.gate-type-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
&--determinism {
background: rgba(147, 51, 234, 0.2);
color: #a855f7;
}
}
.blocking-badge {
padding: 0.125rem 0.5rem;
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
}
.expand-icon {
color: #64748b;
font-size: 0.625rem;
}
.gate-details {
padding: 0 1rem 1rem 1rem;
border-top: 1px solid #334155;
margin-top: 0;
}
.gate-message {
margin: 0.75rem 0;
color: #94a3b8;
font-size: 0.875rem;
line-height: 1.5;
}
.gate-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
font-size: 0.8125rem;
color: #64748b;
strong {
color: #94a3b8;
}
}
.evidence-link {
color: #3b82f6;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.feature-flag-info {
margin-top: 0.75rem;
}
.flag-badge {
display: inline-block;
padding: 0.25rem 0.625rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 500;
&--active {
background: rgba(147, 51, 234, 0.2);
color: #a855f7;
}
&--warn {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PolicyGateIndicatorComponent {
readonly gate = input.required<PolicyGateResult>();
readonly featureFlags = input<DeterminismFeatureFlags | null>(null);
readonly expanded = signal(false);
readonly isDeterminismGate = computed(() => this.gate().gateType === 'determinism');
toggleExpanded(): void {
this.expanded.update((v) => !v);
}
getStatusLabel(): string {
const labels: Record<PolicyGateStatus, string> = {
passed: 'Passed',
failed: 'Failed',
pending: 'Pending',
warning: 'Warning',
skipped: 'Skipped',
};
return labels[this.gate().status] ?? 'Unknown';
}
getStatusIconClass(): string {
return `status-icon--${this.gate().status}`;
}
formatDate(isoString: string): string {
try {
return new Date(isoString).toLocaleString();
} catch {
return isoString;
}
}
}
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
signal,
} from '@angular/core';
import {
PolicyGateResult,
PolicyGateStatus,
DeterminismFeatureFlags,
} from '../../core/api/release.models';
@Component({
selector: 'app-policy-gate-indicator',
standalone: true,
imports: [CommonModule],
template: `
<div
class="gate-indicator"
[class.gate-indicator--expanded]="expanded()"
[class]="'gate-indicator--' + gate().status"
>
<button
type="button"
class="gate-header"
(click)="toggleExpanded()"
[attr.aria-expanded]="expanded()"
[attr.aria-controls]="'gate-details-' + gate().gateId"
>
<div class="gate-status">
<span class="status-icon" [class]="getStatusIconClass()" aria-hidden="true">
@switch (gate().status) {
@case ('passed') { <span>&#10003;</span> }
@case ('failed') { <span>&#10007;</span> }
@case ('warning') { <span>!</span> }
@case ('pending') { <span>&#8987;</span> }
@case ('skipped') { <span>-</span> }
}
</span>
<span class="status-text">{{ getStatusLabel() }}</span>
</div>
<div class="gate-info">
<span class="gate-name">{{ gate().name }}</span>
@if (gate().gateType === 'determinism' && isDeterminismGate()) {
<span class="gate-type-badge gate-type-badge--determinism">Determinism</span>
}
@if (gate().blockingPublish) {
<span class="blocking-badge" title="This gate blocks publishing">Blocking</span>
}
</div>
<span class="expand-icon" aria-hidden="true">{{ expanded() ? '&#9650;' : '&#9660;' }}</span>
</button>
@if (expanded()) {
<div class="gate-details" [id]="'gate-details-' + gate().gateId">
<p class="gate-message">{{ gate().message }}</p>
<div class="gate-meta">
<span class="meta-item">
<strong>Evaluated:</strong> {{ formatDate(gate().evaluatedAt) }}
</span>
@if (gate().evidence?.url) {
<a
[href]="gate().evidence?.url"
class="evidence-link"
target="_blank"
rel="noopener"
>
View Evidence
</a>
}
</div>
<!-- Determinism-specific info when feature flag shows it -->
@if (gate().gateType === 'determinism' && featureFlags()?.enabled) {
<div class="feature-flag-info">
@if (featureFlags()?.blockOnFailure) {
<span class="flag-badge flag-badge--active">Determinism Blocking Enabled</span>
} @else if (featureFlags()?.warnOnly) {
<span class="flag-badge flag-badge--warn">Determinism Warn-Only Mode</span>
}
</div>
}
</div>
}
</div>
`,
styles: [`
.gate-indicator {
background: #1e293b;
border: 1px solid #334155;
border-radius: 6px;
overflow: hidden;
transition: border-color 0.15s;
&--passed {
border-left: 3px solid #22c55e;
}
&--failed {
border-left: 3px solid #ef4444;
}
&--warning {
border-left: 3px solid #f97316;
}
&--pending {
border-left: 3px solid #eab308;
}
&--skipped {
border-left: 3px solid #64748b;
}
&--expanded {
border-color: #475569;
}
}
.gate-header {
display: flex;
align-items: center;
gap: 1rem;
width: 100%;
padding: 0.75rem 1rem;
background: transparent;
border: none;
color: #e2e8f0;
cursor: pointer;
text-align: left;
&:hover {
background: rgba(255, 255, 255, 0.03);
}
}
.gate-status {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 80px;
}
.status-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
font-size: 0.75rem;
font-weight: bold;
}
.gate-indicator--passed .status-icon {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.gate-indicator--failed .status-icon {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.gate-indicator--warning .status-icon {
background: rgba(249, 115, 22, 0.2);
color: #f97316;
}
.gate-indicator--pending .status-icon {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.gate-indicator--skipped .status-icon {
background: rgba(100, 116, 139, 0.2);
color: #64748b;
}
.status-text {
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.gate-indicator--passed .status-text { color: #22c55e; }
.gate-indicator--failed .status-text { color: #ef4444; }
.gate-indicator--warning .status-text { color: #f97316; }
.gate-indicator--pending .status-text { color: #eab308; }
.gate-indicator--skipped .status-text { color: #64748b; }
.gate-info {
flex: 1;
display: flex;
align-items: center;
gap: 0.75rem;
}
.gate-name {
font-weight: 500;
}
.gate-type-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
&--determinism {
background: rgba(147, 51, 234, 0.2);
color: #a855f7;
}
}
.blocking-badge {
padding: 0.125rem 0.5rem;
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
}
.expand-icon {
color: #64748b;
font-size: 0.625rem;
}
.gate-details {
padding: 0 1rem 1rem 1rem;
border-top: 1px solid #334155;
margin-top: 0;
}
.gate-message {
margin: 0.75rem 0;
color: #94a3b8;
font-size: 0.875rem;
line-height: 1.5;
}
.gate-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
font-size: 0.8125rem;
color: #64748b;
strong {
color: #94a3b8;
}
}
.evidence-link {
color: #3b82f6;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.feature-flag-info {
margin-top: 0.75rem;
}
.flag-badge {
display: inline-block;
padding: 0.25rem 0.625rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 500;
&--active {
background: rgba(147, 51, 234, 0.2);
color: #a855f7;
}
&--warn {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PolicyGateIndicatorComponent {
readonly gate = input.required<PolicyGateResult>();
readonly featureFlags = input<DeterminismFeatureFlags | null>(null);
readonly expanded = signal(false);
readonly isDeterminismGate = computed(() => this.gate().gateType === 'determinism');
toggleExpanded(): void {
this.expanded.update((v) => !v);
}
getStatusLabel(): string {
const labels: Record<PolicyGateStatus, string> = {
passed: 'Passed',
failed: 'Failed',
pending: 'Pending',
warning: 'Warning',
skipped: 'Skipped',
};
return labels[this.gate().status] ?? 'Unknown';
}
getStatusIconClass(): string {
return `status-icon--${this.gate().status}`;
}
formatDate(isoString: string): string {
try {
return new Date(isoString).toLocaleString();
} catch {
return isoString;
}
}
}

View File

@@ -1,331 +1,331 @@
<section class="release-flow">
<!-- Header -->
<header class="release-flow__header">
<div class="header-left">
@if (viewMode() === 'detail') {
<button type="button" class="back-button" (click)="backToList()" aria-label="Back to releases">
<span aria-hidden="true">&larr;</span> Releases
</button>
}
<h1>{{ viewMode() === 'list' ? 'Release Management' : selectedRelease()?.name }}</h1>
</div>
<div class="header-right">
@if (isDeterminismEnabled()) {
<span class="feature-badge feature-badge--enabled" title="Determinism policy gates are active">
Determinism Gates Active
</span>
} @else {
<span class="feature-badge feature-badge--disabled" title="Determinism policy gates are disabled">
Determinism Gates Disabled
</span>
}
</div>
</header>
<!-- Loading State -->
@if (loading()) {
<div class="loading-container" aria-live="polite">
<div class="loading-spinner"></div>
<p>Loading releases...</p>
</div>
}
<!-- List View -->
@if (!loading() && viewMode() === 'list') {
<div class="releases-list">
@for (release of releases(); track trackByReleaseId($index, release)) {
<article
class="release-card"
[class.release-card--blocked]="release.status === 'blocked'"
(click)="selectRelease(release)"
(keydown.enter)="selectRelease(release)"
tabindex="0"
role="button"
[attr.aria-label]="'View release ' + release.name"
>
<div class="release-card__header">
<h2>{{ release.name }}</h2>
<span class="release-status" [ngClass]="getReleaseStatusClass(release)">
{{ release.status | titlecase }}
</span>
</div>
<div class="release-card__meta">
<span class="meta-item">
<strong>Version:</strong> {{ release.version }}
</span>
<span class="meta-item">
<strong>Target:</strong> {{ release.targetEnvironment }}
</span>
<span class="meta-item">
<strong>Artifacts:</strong> {{ release.artifacts.length }}
</span>
</div>
<div class="release-card__gates">
@for (artifact of release.artifacts; track trackByArtifactId($index, artifact)) {
@if (artifact.policyEvaluation) {
<div class="artifact-gates">
<span class="artifact-name">{{ artifact.name }}:</span>
@for (gate of artifact.policyEvaluation.gates; track trackByGateId($index, gate)) {
<span
class="gate-pip"
[ngClass]="getStatusClass(gate.status)"
[title]="gate.name + ': ' + gate.status"
></span>
}
</div>
}
}
</div>
@if (release.status === 'blocked') {
<div class="release-card__warning">
<span class="warning-icon" aria-hidden="true">!</span>
Policy gates blocking publish
</div>
}
</article>
} @empty {
<p class="empty-state">No releases found.</p>
}
</div>
}
<!-- Detail View -->
@if (!loading() && viewMode() === 'detail' && selectedRelease()) {
<div class="release-detail">
<!-- Release Info -->
<section class="detail-section">
<h2>Release Information</h2>
<dl class="info-grid">
<div>
<dt>Version</dt>
<dd>{{ selectedRelease()?.version }}</dd>
</div>
<div>
<dt>Status</dt>
<dd>
<span class="release-status" [ngClass]="getReleaseStatusClass(selectedRelease()!)">
{{ selectedRelease()?.status | titlecase }}
</span>
</dd>
</div>
<div>
<dt>Target Environment</dt>
<dd>{{ selectedRelease()?.targetEnvironment }}</dd>
</div>
<div>
<dt>Created</dt>
<dd>{{ selectedRelease()?.createdAt }} by {{ selectedRelease()?.createdBy }}</dd>
</div>
</dl>
@if (selectedRelease()?.notes) {
<p class="release-notes">{{ selectedRelease()?.notes }}</p>
}
</section>
<!-- Determinism Gate Summary -->
@if (isDeterminismEnabled() && determinismBlockingCount() > 0) {
<section class="detail-section determinism-blocking-banner">
<div class="banner-icon" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div class="banner-content">
<h3>Determinism Check Failed</h3>
<p>
{{ determinismBlockingCount() }} artifact(s) failed SBOM determinism verification.
Publishing is blocked until issues are resolved or a bypass is approved.
</p>
</div>
</section>
}
<!-- Artifacts Section -->
<section class="detail-section">
<h2>Artifacts ({{ selectedRelease()?.artifacts?.length }})</h2>
<div class="artifacts-tabs" role="tablist">
@for (artifact of selectedRelease()?.artifacts; track trackByArtifactId($index, artifact)) {
<button
type="button"
role="tab"
class="artifact-tab"
[class.artifact-tab--active]="selectedArtifact()?.artifactId === artifact.artifactId"
[class.artifact-tab--blocked]="!artifact.policyEvaluation?.canPublish"
[attr.aria-selected]="selectedArtifact()?.artifactId === artifact.artifactId"
(click)="selectArtifact(artifact)"
>
<span class="artifact-tab__name">{{ artifact.name }}</span>
<span class="artifact-tab__tag">{{ artifact.tag }}</span>
@if (!artifact.policyEvaluation?.canPublish) {
<span class="artifact-tab__blocked" aria-label="Blocked">!</span>
}
</button>
}
</div>
<!-- Selected Artifact Details -->
@if (selectedArtifact()) {
<div class="artifact-detail" role="tabpanel">
<dl class="artifact-meta">
<div>
<dt>Digest</dt>
<dd><code>{{ selectedArtifact()?.digest }}</code></dd>
</div>
<div>
<dt>Size</dt>
<dd>{{ formatBytes(selectedArtifact()!.size) }}</dd>
</div>
<div>
<dt>Registry</dt>
<dd>{{ selectedArtifact()?.registry }}</dd>
</div>
</dl>
<!-- Policy Gates -->
@if (selectedArtifact()?.policyEvaluation) {
<div class="policy-gates">
<h3>Policy Gates</h3>
<div class="gates-list">
@for (gate of selectedArtifact()!.policyEvaluation!.gates; track trackByGateId($index, gate)) {
<app-policy-gate-indicator
[gate]="gate"
[featureFlags]="featureFlags()"
/>
}
</div>
<!-- Determinism Details -->
@if (selectedArtifact()!.policyEvaluation!.determinismDetails) {
<div class="determinism-details">
<h4>Determinism Evidence</h4>
<dl>
<div>
<dt>Merkle Root</dt>
<dd>
<code>{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.merkleRoot }}</code>
@if (selectedArtifact()!.policyEvaluation!.determinismDetails!.merkleRootConsistent) {
<span class="consistency-badge consistency-badge--consistent">Consistent</span>
} @else {
<span class="consistency-badge consistency-badge--inconsistent">Mismatch</span>
}
</dd>
</div>
<div>
<dt>Fragment Verification</dt>
<dd>
{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.verifiedFragments }} /
{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.fragmentCount }} verified
</dd>
</div>
@if (selectedArtifact()!.policyEvaluation!.determinismDetails!.compositionManifestUri) {
<div>
<dt>Composition Manifest</dt>
<dd><code>{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.compositionManifestUri }}</code></dd>
</div>
}
</dl>
<!-- Failed Fragments -->
@if (selectedArtifact()!.policyEvaluation!.determinismDetails!.failedFragments?.length) {
<div class="failed-fragments">
<h5>Failed Fragment Layers</h5>
<ul>
@for (fragment of selectedArtifact()!.policyEvaluation!.determinismDetails!.failedFragments; track fragment) {
<li><code>{{ fragment }}</code></li>
}
</ul>
</div>
}
</div>
}
<!-- Remediation Hints for Failed Gates -->
@for (gate of selectedArtifact()!.policyEvaluation!.gates; track trackByGateId($index, gate)) {
@if (gate.status === 'failed' && gate.remediation) {
<app-remediation-hints [gate]="gate" />
}
}
</div>
}
</div>
}
</section>
<!-- Actions -->
<section class="detail-section actions-section">
<h2>Actions</h2>
<div class="action-buttons">
@if (canPublishSelected()) {
<button
type="button"
class="btn btn--primary"
(click)="publishRelease()"
[disabled]="publishing()"
>
@if (publishing()) {
Publishing...
} @else {
Publish Release
}
</button>
} @else {
<button
type="button"
class="btn btn--primary btn--disabled"
disabled
title="Cannot publish: {{ blockingGatesCount() }} policy gate(s) blocking"
>
Publish Blocked
</button>
@if (canBypass() && determinismBlockingCount() > 0) {
<button
type="button"
class="btn btn--warning"
(click)="openBypassModal()"
>
Request Bypass
</button>
}
}
<button type="button" class="btn btn--secondary" (click)="backToList()">
Back to List
</button>
</div>
</section>
</div>
}
<!-- Bypass Request Modal -->
@if (showBypassModal()) {
<div class="modal-overlay" (click)="closeBypassModal()" role="dialog" aria-modal="true" aria-labelledby="bypass-modal-title">
<div class="modal-content" (click)="$event.stopPropagation()">
<h2 id="bypass-modal-title">Request Policy Bypass</h2>
<p class="modal-description">
You are requesting to bypass {{ determinismBlockingCount() }} failing determinism gate(s).
This request requires approval from a security administrator.
</p>
<label for="bypass-reason">Justification</label>
<textarea
id="bypass-reason"
rows="4"
placeholder="Explain why this bypass is necessary and what compensating controls are in place..."
[value]="bypassReason()"
(input)="updateBypassReason($event)"
></textarea>
<div class="modal-actions">
<button
type="button"
class="btn btn--primary"
(click)="submitBypassRequest()"
[disabled]="!bypassReason().trim()"
>
Submit Request
</button>
<button type="button" class="btn btn--secondary" (click)="closeBypassModal()">
Cancel
</button>
</div>
</div>
</div>
}
</section>
<section class="release-flow">
<!-- Header -->
<header class="release-flow__header">
<div class="header-left">
@if (viewMode() === 'detail') {
<button type="button" class="back-button" (click)="backToList()" aria-label="Back to releases">
<span aria-hidden="true">&larr;</span> Releases
</button>
}
<h1>{{ viewMode() === 'list' ? 'Release Management' : selectedRelease()?.name }}</h1>
</div>
<div class="header-right">
@if (isDeterminismEnabled()) {
<span class="feature-badge feature-badge--enabled" title="Determinism policy gates are active">
Determinism Gates Active
</span>
} @else {
<span class="feature-badge feature-badge--disabled" title="Determinism policy gates are disabled">
Determinism Gates Disabled
</span>
}
</div>
</header>
<!-- Loading State -->
@if (loading()) {
<div class="loading-container" aria-live="polite">
<div class="loading-spinner"></div>
<p>Loading releases...</p>
</div>
}
<!-- List View -->
@if (!loading() && viewMode() === 'list') {
<div class="releases-list">
@for (release of releases(); track trackByReleaseId($index, release)) {
<article
class="release-card"
[class.release-card--blocked]="release.status === 'blocked'"
(click)="selectRelease(release)"
(keydown.enter)="selectRelease(release)"
tabindex="0"
role="button"
[attr.aria-label]="'View release ' + release.name"
>
<div class="release-card__header">
<h2>{{ release.name }}</h2>
<span class="release-status" [ngClass]="getReleaseStatusClass(release)">
{{ release.status | titlecase }}
</span>
</div>
<div class="release-card__meta">
<span class="meta-item">
<strong>Version:</strong> {{ release.version }}
</span>
<span class="meta-item">
<strong>Target:</strong> {{ release.targetEnvironment }}
</span>
<span class="meta-item">
<strong>Artifacts:</strong> {{ release.artifacts.length }}
</span>
</div>
<div class="release-card__gates">
@for (artifact of release.artifacts; track trackByArtifactId($index, artifact)) {
@if (artifact.policyEvaluation) {
<div class="artifact-gates">
<span class="artifact-name">{{ artifact.name }}:</span>
@for (gate of artifact.policyEvaluation.gates; track trackByGateId($index, gate)) {
<span
class="gate-pip"
[ngClass]="getStatusClass(gate.status)"
[title]="gate.name + ': ' + gate.status"
></span>
}
</div>
}
}
</div>
@if (release.status === 'blocked') {
<div class="release-card__warning">
<span class="warning-icon" aria-hidden="true">!</span>
Policy gates blocking publish
</div>
}
</article>
} @empty {
<p class="empty-state">No releases found.</p>
}
</div>
}
<!-- Detail View -->
@if (!loading() && viewMode() === 'detail' && selectedRelease()) {
<div class="release-detail">
<!-- Release Info -->
<section class="detail-section">
<h2>Release Information</h2>
<dl class="info-grid">
<div>
<dt>Version</dt>
<dd>{{ selectedRelease()?.version }}</dd>
</div>
<div>
<dt>Status</dt>
<dd>
<span class="release-status" [ngClass]="getReleaseStatusClass(selectedRelease()!)">
{{ selectedRelease()?.status | titlecase }}
</span>
</dd>
</div>
<div>
<dt>Target Environment</dt>
<dd>{{ selectedRelease()?.targetEnvironment }}</dd>
</div>
<div>
<dt>Created</dt>
<dd>{{ selectedRelease()?.createdAt }} by {{ selectedRelease()?.createdBy }}</dd>
</div>
</dl>
@if (selectedRelease()?.notes) {
<p class="release-notes">{{ selectedRelease()?.notes }}</p>
}
</section>
<!-- Determinism Gate Summary -->
@if (isDeterminismEnabled() && determinismBlockingCount() > 0) {
<section class="detail-section determinism-blocking-banner">
<div class="banner-icon" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div class="banner-content">
<h3>Determinism Check Failed</h3>
<p>
{{ determinismBlockingCount() }} artifact(s) failed SBOM determinism verification.
Publishing is blocked until issues are resolved or a bypass is approved.
</p>
</div>
</section>
}
<!-- Artifacts Section -->
<section class="detail-section">
<h2>Artifacts ({{ selectedRelease()?.artifacts?.length }})</h2>
<div class="artifacts-tabs" role="tablist">
@for (artifact of selectedRelease()?.artifacts; track trackByArtifactId($index, artifact)) {
<button
type="button"
role="tab"
class="artifact-tab"
[class.artifact-tab--active]="selectedArtifact()?.artifactId === artifact.artifactId"
[class.artifact-tab--blocked]="!artifact.policyEvaluation?.canPublish"
[attr.aria-selected]="selectedArtifact()?.artifactId === artifact.artifactId"
(click)="selectArtifact(artifact)"
>
<span class="artifact-tab__name">{{ artifact.name }}</span>
<span class="artifact-tab__tag">{{ artifact.tag }}</span>
@if (!artifact.policyEvaluation?.canPublish) {
<span class="artifact-tab__blocked" aria-label="Blocked">!</span>
}
</button>
}
</div>
<!-- Selected Artifact Details -->
@if (selectedArtifact()) {
<div class="artifact-detail" role="tabpanel">
<dl class="artifact-meta">
<div>
<dt>Digest</dt>
<dd><code>{{ selectedArtifact()?.digest }}</code></dd>
</div>
<div>
<dt>Size</dt>
<dd>{{ formatBytes(selectedArtifact()!.size) }}</dd>
</div>
<div>
<dt>Registry</dt>
<dd>{{ selectedArtifact()?.registry }}</dd>
</div>
</dl>
<!-- Policy Gates -->
@if (selectedArtifact()?.policyEvaluation) {
<div class="policy-gates">
<h3>Policy Gates</h3>
<div class="gates-list">
@for (gate of selectedArtifact()!.policyEvaluation!.gates; track trackByGateId($index, gate)) {
<app-policy-gate-indicator
[gate]="gate"
[featureFlags]="featureFlags()"
/>
}
</div>
<!-- Determinism Details -->
@if (selectedArtifact()!.policyEvaluation!.determinismDetails) {
<div class="determinism-details">
<h4>Determinism Evidence</h4>
<dl>
<div>
<dt>Merkle Root</dt>
<dd>
<code>{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.merkleRoot }}</code>
@if (selectedArtifact()!.policyEvaluation!.determinismDetails!.merkleRootConsistent) {
<span class="consistency-badge consistency-badge--consistent">Consistent</span>
} @else {
<span class="consistency-badge consistency-badge--inconsistent">Mismatch</span>
}
</dd>
</div>
<div>
<dt>Fragment Verification</dt>
<dd>
{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.verifiedFragments }} /
{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.fragmentCount }} verified
</dd>
</div>
@if (selectedArtifact()!.policyEvaluation!.determinismDetails!.compositionManifestUri) {
<div>
<dt>Composition Manifest</dt>
<dd><code>{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.compositionManifestUri }}</code></dd>
</div>
}
</dl>
<!-- Failed Fragments -->
@if (selectedArtifact()!.policyEvaluation!.determinismDetails!.failedFragments?.length) {
<div class="failed-fragments">
<h5>Failed Fragment Layers</h5>
<ul>
@for (fragment of selectedArtifact()!.policyEvaluation!.determinismDetails!.failedFragments; track fragment) {
<li><code>{{ fragment }}</code></li>
}
</ul>
</div>
}
</div>
}
<!-- Remediation Hints for Failed Gates -->
@for (gate of selectedArtifact()!.policyEvaluation!.gates; track trackByGateId($index, gate)) {
@if (gate.status === 'failed' && gate.remediation) {
<app-remediation-hints [gate]="gate" />
}
}
</div>
}
</div>
}
</section>
<!-- Actions -->
<section class="detail-section actions-section">
<h2>Actions</h2>
<div class="action-buttons">
@if (canPublishSelected()) {
<button
type="button"
class="btn btn--primary"
(click)="publishRelease()"
[disabled]="publishing()"
>
@if (publishing()) {
Publishing...
} @else {
Publish Release
}
</button>
} @else {
<button
type="button"
class="btn btn--primary btn--disabled"
disabled
title="Cannot publish: {{ blockingGatesCount() }} policy gate(s) blocking"
>
Publish Blocked
</button>
@if (canBypass() && determinismBlockingCount() > 0) {
<button
type="button"
class="btn btn--warning"
(click)="openBypassModal()"
>
Request Bypass
</button>
}
}
<button type="button" class="btn btn--secondary" (click)="backToList()">
Back to List
</button>
</div>
</section>
</div>
}
<!-- Bypass Request Modal -->
@if (showBypassModal()) {
<div class="modal-overlay" (click)="closeBypassModal()" role="dialog" aria-modal="true" aria-labelledby="bypass-modal-title">
<div class="modal-content" (click)="$event.stopPropagation()">
<h2 id="bypass-modal-title">Request Policy Bypass</h2>
<p class="modal-description">
You are requesting to bypass {{ determinismBlockingCount() }} failing determinism gate(s).
This request requires approval from a security administrator.
</p>
<label for="bypass-reason">Justification</label>
<textarea
id="bypass-reason"
rows="4"
placeholder="Explain why this bypass is necessary and what compensating controls are in place..."
[value]="bypassReason()"
(input)="updateBypassReason($event)"
></textarea>
<div class="modal-actions">
<button
type="button"
class="btn btn--primary"
(click)="submitBypassRequest()"
[disabled]="!bypassReason().trim()"
>
Submit Request
</button>
<button type="button" class="btn btn--secondary" (click)="closeBypassModal()">
Cancel
</button>
</div>
</div>
</div>
}
</section>

View File

@@ -1,229 +1,229 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
OnInit,
signal,
} from '@angular/core';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import {
Release,
ReleaseArtifact,
PolicyGateResult,
PolicyGateStatus,
DeterminismFeatureFlags,
} from '../../core/api/release.models';
import { RELEASE_API, MockReleaseApi } from '../../core/api/release.client';
import { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
import { RemediationHintsComponent } from './remediation-hints.component';
type ViewMode = 'list' | 'detail';
@Component({
selector: 'app-release-flow',
standalone: true,
imports: [CommonModule, RouterModule, PolicyGateIndicatorComponent, RemediationHintsComponent],
providers: [{ provide: RELEASE_API, useClass: MockReleaseApi }],
templateUrl: './release-flow.component.html',
styleUrls: ['./release-flow.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReleaseFlowComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly releaseApi = inject(RELEASE_API);
// State
readonly releases = signal<readonly Release[]>([]);
readonly selectedRelease = signal<Release | null>(null);
readonly selectedArtifact = signal<ReleaseArtifact | null>(null);
readonly featureFlags = signal<DeterminismFeatureFlags | null>(null);
readonly loading = signal(true);
readonly publishing = signal(false);
readonly viewMode = signal<ViewMode>('list');
readonly bypassReason = signal('');
readonly showBypassModal = signal(false);
// Computed values
readonly canPublishSelected = computed(() => {
const release = this.selectedRelease();
if (!release) return false;
return release.artifacts.every((a) => a.policyEvaluation?.canPublish ?? false);
});
readonly blockingGatesCount = computed(() => {
const release = this.selectedRelease();
if (!release) return 0;
return release.artifacts.reduce((count, artifact) => {
return count + (artifact.policyEvaluation?.blockingGates.length ?? 0);
}, 0);
});
readonly determinismBlockingCount = computed(() => {
const release = this.selectedRelease();
if (!release) return 0;
return release.artifacts.reduce((count, artifact) => {
const gates = artifact.policyEvaluation?.gates ?? [];
const deterministicBlocking = gates.filter(
(g) => g.gateType === 'determinism' && g.status === 'failed' && g.blockingPublish
);
return count + deterministicBlocking.length;
}, 0);
});
readonly isDeterminismEnabled = computed(() => {
const flags = this.featureFlags();
return flags?.enabled ?? false;
});
readonly canBypass = computed(() => {
const flags = this.featureFlags();
return flags?.bypassRoles && flags.bypassRoles.length > 0;
});
ngOnInit(): void {
this.loadData();
}
private loadData(): void {
this.loading.set(true);
// Load feature flags
this.releaseApi.getFeatureFlags().subscribe({
next: (flags) => this.featureFlags.set(flags),
error: (err) => console.error('Failed to load feature flags:', err),
});
// Load releases
this.releaseApi.listReleases().subscribe({
next: (releases) => {
this.releases.set(releases);
this.loading.set(false);
// Check if we should auto-select from route
const releaseId = this.route.snapshot.paramMap.get('releaseId');
if (releaseId) {
const release = releases.find((r) => r.releaseId === releaseId);
if (release) {
this.selectRelease(release);
}
}
},
error: (err) => {
console.error('Failed to load releases:', err);
this.loading.set(false);
},
});
}
selectRelease(release: Release): void {
this.selectedRelease.set(release);
this.selectedArtifact.set(release.artifacts[0] ?? null);
this.viewMode.set('detail');
}
selectArtifact(artifact: ReleaseArtifact): void {
this.selectedArtifact.set(artifact);
}
backToList(): void {
this.selectedRelease.set(null);
this.selectedArtifact.set(null);
this.viewMode.set('list');
}
publishRelease(): void {
const release = this.selectedRelease();
if (!release || !this.canPublishSelected()) return;
this.publishing.set(true);
this.releaseApi.publishRelease(release.releaseId).subscribe({
next: (updated) => {
// Update the release in the list
this.releases.update((list) =>
list.map((r) => (r.releaseId === updated.releaseId ? updated : r))
);
this.selectedRelease.set(updated);
this.publishing.set(false);
},
error: (err) => {
console.error('Publish failed:', err);
this.publishing.set(false);
},
});
}
openBypassModal(): void {
this.bypassReason.set('');
this.showBypassModal.set(true);
}
closeBypassModal(): void {
this.showBypassModal.set(false);
}
submitBypassRequest(): void {
const release = this.selectedRelease();
const reason = this.bypassReason();
if (!release || !reason.trim()) return;
this.releaseApi.requestBypass(release.releaseId, reason).subscribe({
next: (result) => {
console.log('Bypass requested:', result.requestId);
this.closeBypassModal();
// In real implementation, would show notification and refresh
},
error: (err) => console.error('Bypass request failed:', err),
});
}
updateBypassReason(event: Event): void {
const target = event.target as HTMLTextAreaElement;
this.bypassReason.set(target.value);
}
getStatusClass(status: PolicyGateStatus): string {
const statusClasses: Record<PolicyGateStatus, string> = {
passed: 'status--passed',
failed: 'status--failed',
pending: 'status--pending',
warning: 'status--warning',
skipped: 'status--skipped',
};
return statusClasses[status] ?? 'status--pending';
}
getReleaseStatusClass(release: Release): string {
const statusClasses: Record<string, string> = {
draft: 'release-status--draft',
pending_approval: 'release-status--pending',
approved: 'release-status--approved',
publishing: 'release-status--publishing',
published: 'release-status--published',
blocked: 'release-status--blocked',
cancelled: 'release-status--cancelled',
};
return statusClasses[release.status] ?? 'release-status--draft';
}
formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
trackByReleaseId(_index: number, release: Release): string {
return release.releaseId;
}
trackByArtifactId(_index: number, artifact: ReleaseArtifact): string {
return artifact.artifactId;
}
trackByGateId(_index: number, gate: PolicyGateResult): string {
return gate.gateId;
}
}
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
OnInit,
signal,
} from '@angular/core';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import {
Release,
ReleaseArtifact,
PolicyGateResult,
PolicyGateStatus,
DeterminismFeatureFlags,
} from '../../core/api/release.models';
import { RELEASE_API, MockReleaseApi } from '../../core/api/release.client';
import { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
import { RemediationHintsComponent } from './remediation-hints.component';
type ViewMode = 'list' | 'detail';
@Component({
selector: 'app-release-flow',
standalone: true,
imports: [CommonModule, RouterModule, PolicyGateIndicatorComponent, RemediationHintsComponent],
providers: [{ provide: RELEASE_API, useClass: MockReleaseApi }],
templateUrl: './release-flow.component.html',
styleUrls: ['./release-flow.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReleaseFlowComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly releaseApi = inject(RELEASE_API);
// State
readonly releases = signal<readonly Release[]>([]);
readonly selectedRelease = signal<Release | null>(null);
readonly selectedArtifact = signal<ReleaseArtifact | null>(null);
readonly featureFlags = signal<DeterminismFeatureFlags | null>(null);
readonly loading = signal(true);
readonly publishing = signal(false);
readonly viewMode = signal<ViewMode>('list');
readonly bypassReason = signal('');
readonly showBypassModal = signal(false);
// Computed values
readonly canPublishSelected = computed(() => {
const release = this.selectedRelease();
if (!release) return false;
return release.artifacts.every((a) => a.policyEvaluation?.canPublish ?? false);
});
readonly blockingGatesCount = computed(() => {
const release = this.selectedRelease();
if (!release) return 0;
return release.artifacts.reduce((count, artifact) => {
return count + (artifact.policyEvaluation?.blockingGates.length ?? 0);
}, 0);
});
readonly determinismBlockingCount = computed(() => {
const release = this.selectedRelease();
if (!release) return 0;
return release.artifacts.reduce((count, artifact) => {
const gates = artifact.policyEvaluation?.gates ?? [];
const deterministicBlocking = gates.filter(
(g) => g.gateType === 'determinism' && g.status === 'failed' && g.blockingPublish
);
return count + deterministicBlocking.length;
}, 0);
});
readonly isDeterminismEnabled = computed(() => {
const flags = this.featureFlags();
return flags?.enabled ?? false;
});
readonly canBypass = computed(() => {
const flags = this.featureFlags();
return flags?.bypassRoles && flags.bypassRoles.length > 0;
});
ngOnInit(): void {
this.loadData();
}
private loadData(): void {
this.loading.set(true);
// Load feature flags
this.releaseApi.getFeatureFlags().subscribe({
next: (flags) => this.featureFlags.set(flags),
error: (err) => console.error('Failed to load feature flags:', err),
});
// Load releases
this.releaseApi.listReleases().subscribe({
next: (releases) => {
this.releases.set(releases);
this.loading.set(false);
// Check if we should auto-select from route
const releaseId = this.route.snapshot.paramMap.get('releaseId');
if (releaseId) {
const release = releases.find((r) => r.releaseId === releaseId);
if (release) {
this.selectRelease(release);
}
}
},
error: (err) => {
console.error('Failed to load releases:', err);
this.loading.set(false);
},
});
}
selectRelease(release: Release): void {
this.selectedRelease.set(release);
this.selectedArtifact.set(release.artifacts[0] ?? null);
this.viewMode.set('detail');
}
selectArtifact(artifact: ReleaseArtifact): void {
this.selectedArtifact.set(artifact);
}
backToList(): void {
this.selectedRelease.set(null);
this.selectedArtifact.set(null);
this.viewMode.set('list');
}
publishRelease(): void {
const release = this.selectedRelease();
if (!release || !this.canPublishSelected()) return;
this.publishing.set(true);
this.releaseApi.publishRelease(release.releaseId).subscribe({
next: (updated) => {
// Update the release in the list
this.releases.update((list) =>
list.map((r) => (r.releaseId === updated.releaseId ? updated : r))
);
this.selectedRelease.set(updated);
this.publishing.set(false);
},
error: (err) => {
console.error('Publish failed:', err);
this.publishing.set(false);
},
});
}
openBypassModal(): void {
this.bypassReason.set('');
this.showBypassModal.set(true);
}
closeBypassModal(): void {
this.showBypassModal.set(false);
}
submitBypassRequest(): void {
const release = this.selectedRelease();
const reason = this.bypassReason();
if (!release || !reason.trim()) return;
this.releaseApi.requestBypass(release.releaseId, reason).subscribe({
next: (result) => {
console.log('Bypass requested:', result.requestId);
this.closeBypassModal();
// In real implementation, would show notification and refresh
},
error: (err) => console.error('Bypass request failed:', err),
});
}
updateBypassReason(event: Event): void {
const target = event.target as HTMLTextAreaElement;
this.bypassReason.set(target.value);
}
getStatusClass(status: PolicyGateStatus): string {
const statusClasses: Record<PolicyGateStatus, string> = {
passed: 'status--passed',
failed: 'status--failed',
pending: 'status--pending',
warning: 'status--warning',
skipped: 'status--skipped',
};
return statusClasses[status] ?? 'status--pending';
}
getReleaseStatusClass(release: Release): string {
const statusClasses: Record<string, string> = {
draft: 'release-status--draft',
pending_approval: 'release-status--pending',
approved: 'release-status--approved',
publishing: 'release-status--publishing',
published: 'release-status--published',
blocked: 'release-status--blocked',
cancelled: 'release-status--cancelled',
};
return statusClasses[release.status] ?? 'release-status--draft';
}
formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
trackByReleaseId(_index: number, release: Release): string {
return release.releaseId;
}
trackByArtifactId(_index: number, artifact: ReleaseArtifact): string {
return artifact.artifactId;
}
trackByGateId(_index: number, gate: PolicyGateResult): string {
return gate.gateId;
}
}

View File

@@ -1,296 +1,296 @@
<section class="aoc-dashboard">
<header class="dashboard-header">
<h1>Sources Dashboard</h1>
<p class="subtitle">Attestation of Conformance (AOC) Metrics</p>
</header>
@if (loading()) {
<div class="loading-container" aria-live="polite">
<div class="loading-spinner"></div>
<p>Loading dashboard...</p>
</div>
}
@if (!loading() && dashboard()) {
<!-- Top Tiles Row -->
<div class="tiles-row">
<!-- Pass/Fail Tile -->
<article class="tile tile--pass-fail">
<header class="tile__header">
<h2>AOC Pass Rate</h2>
<span class="tile__period">Last 24h</span>
</header>
<div class="tile__content">
<div class="pass-rate-display">
<span class="pass-rate-value" [ngClass]="passRateClass()">{{ passRate() }}%</span>
<span class="pass-rate-trend" [ngClass]="trendClass()">
{{ trendIcon() }}
<span class="sr-only">{{ dashboard()?.passFail.trend }}</span>
</span>
</div>
<div class="pass-fail-stats">
<div class="stat stat--passed">
<span class="stat-label">Passed</span>
<span class="stat-value">{{ formatNumber(dashboard()!.passFail.passed) }}</span>
</div>
<div class="stat stat--failed">
<span class="stat-label">Failed</span>
<span class="stat-value">{{ formatNumber(dashboard()!.passFail.failed) }}</span>
</div>
<div class="stat stat--pending">
<span class="stat-label">Pending</span>
<span class="stat-value">{{ formatNumber(dashboard()!.passFail.pending) }}</span>
</div>
</div>
<!-- Mini Chart -->
<div class="mini-chart" aria-label="Pass rate trend over 7 days">
@for (point of chartData(); track point.timestamp) {
<div
class="chart-bar"
[style.height.%]="point.height"
[title]="formatShortDate(point.timestamp) + ': ' + point.value + '%'"
></div>
}
</div>
</div>
</article>
<!-- Critical Violations Tile -->
<article class="tile tile--violations">
<header class="tile__header">
<h2>Recent Violations</h2>
@if (criticalViolations() > 0) {
<span class="critical-badge">{{ criticalViolations() }} critical</span>
}
</header>
<div class="tile__content">
<ul class="violations-list">
@for (violation of dashboard()!.recentViolations; track trackByCode($index, violation)) {
<li
class="violation-item"
(click)="selectViolation(violation)"
(keydown.enter)="selectViolation(violation)"
tabindex="0"
role="button"
>
<span class="violation-severity" [ngClass]="getSeverityClass(violation.severity)">
{{ violation.severity | uppercase }}
</span>
<span class="violation-code">{{ violation.code }}</span>
<span class="violation-name">{{ violation.name }}</span>
<span class="violation-count">{{ violation.count }}</span>
</li>
} @empty {
<li class="no-violations">No recent violations</li>
}
</ul>
</div>
</article>
<!-- Ingest Throughput Tile -->
<article class="tile tile--throughput">
<header class="tile__header">
<h2>Ingest Throughput</h2>
<span class="tile__period">Last 24h</span>
</header>
<div class="tile__content">
<div class="throughput-summary">
<div class="throughput-stat">
<span class="throughput-value">{{ formatNumber(totalThroughput().docs) }}</span>
<span class="throughput-label">Documents</span>
</div>
<div class="throughput-stat">
<span class="throughput-value">{{ formatBytes(totalThroughput().bytes) }}</span>
<span class="throughput-label">Total Size</span>
</div>
</div>
<table class="throughput-table">
<thead>
<tr>
<th>Tenant</th>
<th>Docs</th>
<th>Rate</th>
</tr>
</thead>
<tbody>
@for (tenant of dashboard()!.throughputByTenant; track trackByTenantId($index, tenant)) {
<tr>
<td>{{ tenant.tenantName }}</td>
<td>{{ formatNumber(tenant.documentsIngested) }}</td>
<td>{{ tenant.documentsPerMinute.toFixed(1) }}/min</td>
</tr>
}
</tbody>
</table>
</div>
</article>
</div>
<!-- Sources Section -->
<section class="sources-section">
<header class="section-header">
<h2>Sources</h2>
<button
type="button"
class="verify-button"
(click)="startVerification()"
[disabled]="verifying()"
>
@if (verifying()) {
<span class="spinner-small"></span>
Verifying...
} @else {
Verify Last 24h
}
</button>
</header>
<!-- Verification Result -->
@if (verificationRequest()) {
<div class="verification-result" [class.verification-result--completed]="verificationRequest()!.status === 'completed'">
<div class="verification-header">
<span class="verification-status">
@if (verificationRequest()!.status === 'completed') {
Verification Complete
} @else if (verificationRequest()!.status === 'running') {
Verification Running...
} @else {
Verification {{ verificationRequest()!.status | titlecase }}
}
</span>
@if (verificationRequest()!.completedAt) {
<span class="verification-time">{{ formatDate(verificationRequest()!.completedAt!) }}</span>
}
</div>
@if (verificationRequest()!.status === 'completed') {
<div class="verification-stats">
<div class="verification-stat verification-stat--passed">
<span class="stat-value">{{ verificationRequest()!.passed }}</span>
<span class="stat-label">Passed</span>
</div>
<div class="verification-stat verification-stat--failed">
<span class="stat-value">{{ verificationRequest()!.failed }}</span>
<span class="stat-label">Failed</span>
</div>
<div class="verification-stat">
<span class="stat-value">{{ verificationRequest()!.documentsVerified }}</span>
<span class="stat-label">Total</span>
</div>
</div>
}
@if (verificationRequest()!.cliCommand) {
<div class="cli-parity">
<span class="cli-label">CLI Equivalent:</span>
<code>{{ verificationRequest()!.cliCommand }}</code>
</div>
}
</div>
}
<div class="sources-grid">
@for (source of dashboard()!.sources; track trackBySourceId($index, source)) {
<article class="source-card" [ngClass]="getSourceStatusClass(source)">
<div class="source-header">
<span class="source-icon" [attr.aria-label]="source.type">
@switch (source.type) {
@case ('registry') { <span>📦</span> }
@case ('pipeline') { <span>🔄</span> }
@case ('repository') { <span>📁</span> }
@case ('manual') { <span>📤</span> }
}
</span>
<div class="source-info">
<h3>{{ source.name }}</h3>
<span class="source-type">{{ source.type | titlecase }}</span>
</div>
<span class="source-status-badge">{{ source.status | titlecase }}</span>
</div>
<div class="source-stats">
<div class="source-stat">
<span class="source-stat-value">{{ source.checkCount }}</span>
<span class="source-stat-label">Checks</span>
</div>
<div class="source-stat">
<span class="source-stat-value">{{ (source.passRate * 100).toFixed(1) }}%</span>
<span class="source-stat-label">Pass Rate</span>
</div>
</div>
@if (source.recentViolations.length > 0) {
<div class="source-violations">
<span class="source-violations-label">Recent:</span>
@for (v of source.recentViolations; track v.code) {
<span class="source-violation-chip" [ngClass]="getSeverityClass(v.severity)">
{{ v.code }}
</span>
}
</div>
}
<div class="source-last-check">
Last check: {{ formatDate(source.lastCheck) }}
</div>
</article>
}
</div>
</section>
<!-- Violation Detail Modal -->
@if (selectedViolation()) {
<div
class="modal-overlay"
(click)="closeViolationDetail()"
role="dialog"
aria-modal="true"
[attr.aria-labelledby]="'violation-title'"
>
<div class="modal-content" (click)="$event.stopPropagation()">
<header class="modal-header">
<h2 id="violation-title">
<span class="modal-code">{{ selectedViolation()!.code }}</span>
{{ selectedViolation()!.name }}
</h2>
<button type="button" class="modal-close" (click)="closeViolationDetail()" aria-label="Close">
&times;
</button>
</header>
<div class="modal-body">
<span class="violation-severity-large" [ngClass]="getSeverityClass(selectedViolation()!.severity)">
{{ selectedViolation()!.severity | uppercase }}
</span>
<p class="violation-description">{{ selectedViolation()!.description }}</p>
<dl class="violation-meta">
<div>
<dt>Occurrences</dt>
<dd>{{ selectedViolation()!.count }}</dd>
</div>
<div>
<dt>Last Seen</dt>
<dd>{{ formatDate(selectedViolation()!.lastSeen) }}</dd>
</div>
</dl>
@if (selectedViolation()!.documentationUrl) {
<a
[href]="selectedViolation()!.documentationUrl"
class="docs-link"
target="_blank"
rel="noopener"
>
View Documentation &rarr;
</a>
}
</div>
<footer class="modal-footer">
<a
[routerLink]="['/sources/violations', selectedViolation()!.code]"
class="btn btn--primary"
>
View All Occurrences
</a>
<button type="button" class="btn btn--secondary" (click)="closeViolationDetail()">
Close
</button>
</footer>
</div>
</div>
}
}
</section>
<section class="aoc-dashboard">
<header class="dashboard-header">
<h1>Sources Dashboard</h1>
<p class="subtitle">Attestation of Conformance (AOC) Metrics</p>
</header>
@if (loading()) {
<div class="loading-container" aria-live="polite">
<div class="loading-spinner"></div>
<p>Loading dashboard...</p>
</div>
}
@if (!loading() && dashboard()) {
<!-- Top Tiles Row -->
<div class="tiles-row">
<!-- Pass/Fail Tile -->
<article class="tile tile--pass-fail">
<header class="tile__header">
<h2>AOC Pass Rate</h2>
<span class="tile__period">Last 24h</span>
</header>
<div class="tile__content">
<div class="pass-rate-display">
<span class="pass-rate-value" [ngClass]="passRateClass()">{{ passRate() }}%</span>
<span class="pass-rate-trend" [ngClass]="trendClass()">
{{ trendIcon() }}
<span class="sr-only">{{ dashboard()?.passFail.trend }}</span>
</span>
</div>
<div class="pass-fail-stats">
<div class="stat stat--passed">
<span class="stat-label">Passed</span>
<span class="stat-value">{{ formatNumber(dashboard()!.passFail.passed) }}</span>
</div>
<div class="stat stat--failed">
<span class="stat-label">Failed</span>
<span class="stat-value">{{ formatNumber(dashboard()!.passFail.failed) }}</span>
</div>
<div class="stat stat--pending">
<span class="stat-label">Pending</span>
<span class="stat-value">{{ formatNumber(dashboard()!.passFail.pending) }}</span>
</div>
</div>
<!-- Mini Chart -->
<div class="mini-chart" aria-label="Pass rate trend over 7 days">
@for (point of chartData(); track point.timestamp) {
<div
class="chart-bar"
[style.height.%]="point.height"
[title]="formatShortDate(point.timestamp) + ': ' + point.value + '%'"
></div>
}
</div>
</div>
</article>
<!-- Critical Violations Tile -->
<article class="tile tile--violations">
<header class="tile__header">
<h2>Recent Violations</h2>
@if (criticalViolations() > 0) {
<span class="critical-badge">{{ criticalViolations() }} critical</span>
}
</header>
<div class="tile__content">
<ul class="violations-list">
@for (violation of dashboard()!.recentViolations; track trackByCode($index, violation)) {
<li
class="violation-item"
(click)="selectViolation(violation)"
(keydown.enter)="selectViolation(violation)"
tabindex="0"
role="button"
>
<span class="violation-severity" [ngClass]="getSeverityClass(violation.severity)">
{{ violation.severity | uppercase }}
</span>
<span class="violation-code">{{ violation.code }}</span>
<span class="violation-name">{{ violation.name }}</span>
<span class="violation-count">{{ violation.count }}</span>
</li>
} @empty {
<li class="no-violations">No recent violations</li>
}
</ul>
</div>
</article>
<!-- Ingest Throughput Tile -->
<article class="tile tile--throughput">
<header class="tile__header">
<h2>Ingest Throughput</h2>
<span class="tile__period">Last 24h</span>
</header>
<div class="tile__content">
<div class="throughput-summary">
<div class="throughput-stat">
<span class="throughput-value">{{ formatNumber(totalThroughput().docs) }}</span>
<span class="throughput-label">Documents</span>
</div>
<div class="throughput-stat">
<span class="throughput-value">{{ formatBytes(totalThroughput().bytes) }}</span>
<span class="throughput-label">Total Size</span>
</div>
</div>
<table class="throughput-table">
<thead>
<tr>
<th>Tenant</th>
<th>Docs</th>
<th>Rate</th>
</tr>
</thead>
<tbody>
@for (tenant of dashboard()!.throughputByTenant; track trackByTenantId($index, tenant)) {
<tr>
<td>{{ tenant.tenantName }}</td>
<td>{{ formatNumber(tenant.documentsIngested) }}</td>
<td>{{ tenant.documentsPerMinute.toFixed(1) }}/min</td>
</tr>
}
</tbody>
</table>
</div>
</article>
</div>
<!-- Sources Section -->
<section class="sources-section">
<header class="section-header">
<h2>Sources</h2>
<button
type="button"
class="verify-button"
(click)="startVerification()"
[disabled]="verifying()"
>
@if (verifying()) {
<span class="spinner-small"></span>
Verifying...
} @else {
Verify Last 24h
}
</button>
</header>
<!-- Verification Result -->
@if (verificationRequest()) {
<div class="verification-result" [class.verification-result--completed]="verificationRequest()!.status === 'completed'">
<div class="verification-header">
<span class="verification-status">
@if (verificationRequest()!.status === 'completed') {
Verification Complete
} @else if (verificationRequest()!.status === 'running') {
Verification Running...
} @else {
Verification {{ verificationRequest()!.status | titlecase }}
}
</span>
@if (verificationRequest()!.completedAt) {
<span class="verification-time">{{ formatDate(verificationRequest()!.completedAt!) }}</span>
}
</div>
@if (verificationRequest()!.status === 'completed') {
<div class="verification-stats">
<div class="verification-stat verification-stat--passed">
<span class="stat-value">{{ verificationRequest()!.passed }}</span>
<span class="stat-label">Passed</span>
</div>
<div class="verification-stat verification-stat--failed">
<span class="stat-value">{{ verificationRequest()!.failed }}</span>
<span class="stat-label">Failed</span>
</div>
<div class="verification-stat">
<span class="stat-value">{{ verificationRequest()!.documentsVerified }}</span>
<span class="stat-label">Total</span>
</div>
</div>
}
@if (verificationRequest()!.cliCommand) {
<div class="cli-parity">
<span class="cli-label">CLI Equivalent:</span>
<code>{{ verificationRequest()!.cliCommand }}</code>
</div>
}
</div>
}
<div class="sources-grid">
@for (source of dashboard()!.sources; track trackBySourceId($index, source)) {
<article class="source-card" [ngClass]="getSourceStatusClass(source)">
<div class="source-header">
<span class="source-icon" [attr.aria-label]="source.type">
@switch (source.type) {
@case ('registry') { <span>📦</span> }
@case ('pipeline') { <span>🔄</span> }
@case ('repository') { <span>📁</span> }
@case ('manual') { <span>📤</span> }
}
</span>
<div class="source-info">
<h3>{{ source.name }}</h3>
<span class="source-type">{{ source.type | titlecase }}</span>
</div>
<span class="source-status-badge">{{ source.status | titlecase }}</span>
</div>
<div class="source-stats">
<div class="source-stat">
<span class="source-stat-value">{{ source.checkCount }}</span>
<span class="source-stat-label">Checks</span>
</div>
<div class="source-stat">
<span class="source-stat-value">{{ (source.passRate * 100).toFixed(1) }}%</span>
<span class="source-stat-label">Pass Rate</span>
</div>
</div>
@if (source.recentViolations.length > 0) {
<div class="source-violations">
<span class="source-violations-label">Recent:</span>
@for (v of source.recentViolations; track v.code) {
<span class="source-violation-chip" [ngClass]="getSeverityClass(v.severity)">
{{ v.code }}
</span>
}
</div>
}
<div class="source-last-check">
Last check: {{ formatDate(source.lastCheck) }}
</div>
</article>
}
</div>
</section>
<!-- Violation Detail Modal -->
@if (selectedViolation()) {
<div
class="modal-overlay"
(click)="closeViolationDetail()"
role="dialog"
aria-modal="true"
[attr.aria-labelledby]="'violation-title'"
>
<div class="modal-content" (click)="$event.stopPropagation()">
<header class="modal-header">
<h2 id="violation-title">
<span class="modal-code">{{ selectedViolation()!.code }}</span>
{{ selectedViolation()!.name }}
</h2>
<button type="button" class="modal-close" (click)="closeViolationDetail()" aria-label="Close">
&times;
</button>
</header>
<div class="modal-body">
<span class="violation-severity-large" [ngClass]="getSeverityClass(selectedViolation()!.severity)">
{{ selectedViolation()!.severity | uppercase }}
</span>
<p class="violation-description">{{ selectedViolation()!.description }}</p>
<dl class="violation-meta">
<div>
<dt>Occurrences</dt>
<dd>{{ selectedViolation()!.count }}</dd>
</div>
<div>
<dt>Last Seen</dt>
<dd>{{ formatDate(selectedViolation()!.lastSeen) }}</dd>
</div>
</dl>
@if (selectedViolation()!.documentationUrl) {
<a
[href]="selectedViolation()!.documentationUrl"
class="docs-link"
target="_blank"
rel="noopener"
>
View Documentation &rarr;
</a>
}
</div>
<footer class="modal-footer">
<a
[routerLink]="['/sources/violations', selectedViolation()!.code]"
class="btn btn--primary"
>
View All Occurrences
</a>
<button type="button" class="btn btn--secondary" (click)="closeViolationDetail()">
Close
</button>
</footer>
</div>
</div>
}
}
</section>

View File

@@ -1,207 +1,207 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
OnInit,
signal,
} from '@angular/core';
import { RouterModule } from '@angular/router';
import {
AocDashboardSummary,
AocViolationCode,
IngestThroughput,
AocSource,
ViolationSeverity,
VerificationRequest,
} from '../../core/api/aoc.models';
import { AOC_API, MockAocApi } from '../../core/api/aoc.client';
@Component({
selector: 'app-aoc-dashboard',
standalone: true,
imports: [CommonModule, RouterModule],
providers: [{ provide: AOC_API, useClass: MockAocApi }],
templateUrl: './aoc-dashboard.component.html',
styleUrls: ['./aoc-dashboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AocDashboardComponent implements OnInit {
private readonly aocApi = inject(AOC_API);
// State
readonly dashboard = signal<AocDashboardSummary | null>(null);
readonly loading = signal(true);
readonly verificationRequest = signal<VerificationRequest | null>(null);
readonly verifying = signal(false);
readonly selectedViolation = signal<AocViolationCode | null>(null);
// Computed values
readonly passRate = computed(() => {
const dash = this.dashboard();
return dash ? Math.round(dash.passFail.passRate * 100) : 0;
});
readonly passRateClass = computed(() => {
const rate = this.passRate();
if (rate >= 95) return 'rate--excellent';
if (rate >= 85) return 'rate--good';
if (rate >= 70) return 'rate--warning';
return 'rate--critical';
});
readonly trendIcon = computed(() => {
const trend = this.dashboard()?.passFail.trend;
if (trend === 'improving') return '↑';
if (trend === 'degrading') return '↓';
return '→';
});
readonly trendClass = computed(() => {
const trend = this.dashboard()?.passFail.trend;
if (trend === 'improving') return 'trend--improving';
if (trend === 'degrading') return 'trend--degrading';
return 'trend--stable';
});
readonly totalThroughput = computed(() => {
const dash = this.dashboard();
if (!dash) return { docs: 0, bytes: 0 };
return dash.throughputByTenant.reduce(
(acc, t) => ({
docs: acc.docs + t.documentsIngested,
bytes: acc.bytes + t.bytesIngested,
}),
{ docs: 0, bytes: 0 }
);
});
readonly criticalViolations = computed(() => {
const dash = this.dashboard();
if (!dash) return 0;
return dash.recentViolations
.filter((v) => v.severity === 'critical')
.reduce((sum, v) => sum + v.count, 0);
});
readonly chartData = computed(() => {
const dash = this.dashboard();
if (!dash) return [];
const history = dash.passFail.history;
const max = Math.max(...history.map((p) => p.value));
return history.map((p) => ({
timestamp: p.timestamp,
value: p.value,
height: (p.value / max) * 100,
}));
});
ngOnInit(): void {
this.loadDashboard();
}
private loadDashboard(): void {
this.loading.set(true);
this.aocApi.getDashboardSummary().subscribe({
next: (summary) => {
this.dashboard.set(summary);
this.loading.set(false);
},
error: (err) => {
console.error('Failed to load AOC dashboard:', err);
this.loading.set(false);
},
});
}
startVerification(): void {
this.verifying.set(true);
this.verificationRequest.set(null);
this.aocApi.startVerification().subscribe({
next: (request) => {
this.verificationRequest.set(request);
// Poll for status updates (simplified - in real app would use interval)
setTimeout(() => this.pollVerificationStatus(request.requestId), 2000);
},
error: (err) => {
console.error('Failed to start verification:', err);
this.verifying.set(false);
},
});
}
private pollVerificationStatus(requestId: string): void {
this.aocApi.getVerificationStatus(requestId).subscribe({
next: (request) => {
this.verificationRequest.set(request);
if (request.status === 'completed' || request.status === 'failed') {
this.verifying.set(false);
}
},
error: (err) => {
console.error('Failed to get verification status:', err);
this.verifying.set(false);
},
});
}
selectViolation(violation: AocViolationCode): void {
this.selectedViolation.set(violation);
}
closeViolationDetail(): void {
this.selectedViolation.set(null);
}
getSeverityClass(severity: ViolationSeverity): string {
return `severity--${severity}`;
}
getSourceStatusClass(source: AocSource): string {
return `source-status--${source.status}`;
}
formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
formatNumber(num: number): string {
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
return num.toString();
}
formatDate(isoString: string): string {
try {
return new Date(isoString).toLocaleString();
} catch {
return isoString;
}
}
formatShortDate(isoString: string): string {
try {
const date = new Date(isoString);
return `${date.getMonth() + 1}/${date.getDate()}`;
} catch {
return '';
}
}
trackByCode(_index: number, violation: AocViolationCode): string {
return violation.code;
}
trackByTenantId(_index: number, throughput: IngestThroughput): string {
return throughput.tenantId;
}
trackBySourceId(_index: number, source: AocSource): string {
return source.sourceId;
}
}
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
OnInit,
signal,
} from '@angular/core';
import { RouterModule } from '@angular/router';
import {
AocDashboardSummary,
AocViolationCode,
IngestThroughput,
AocSource,
ViolationSeverity,
VerificationRequest,
} from '../../core/api/aoc.models';
import { AOC_API, MockAocApi } from '../../core/api/aoc.client';
@Component({
selector: 'app-aoc-dashboard',
standalone: true,
imports: [CommonModule, RouterModule],
providers: [{ provide: AOC_API, useClass: MockAocApi }],
templateUrl: './aoc-dashboard.component.html',
styleUrls: ['./aoc-dashboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AocDashboardComponent implements OnInit {
private readonly aocApi = inject(AOC_API);
// State
readonly dashboard = signal<AocDashboardSummary | null>(null);
readonly loading = signal(true);
readonly verificationRequest = signal<VerificationRequest | null>(null);
readonly verifying = signal(false);
readonly selectedViolation = signal<AocViolationCode | null>(null);
// Computed values
readonly passRate = computed(() => {
const dash = this.dashboard();
return dash ? Math.round(dash.passFail.passRate * 100) : 0;
});
readonly passRateClass = computed(() => {
const rate = this.passRate();
if (rate >= 95) return 'rate--excellent';
if (rate >= 85) return 'rate--good';
if (rate >= 70) return 'rate--warning';
return 'rate--critical';
});
readonly trendIcon = computed(() => {
const trend = this.dashboard()?.passFail.trend;
if (trend === 'improving') return '↑';
if (trend === 'degrading') return '↓';
return '→';
});
readonly trendClass = computed(() => {
const trend = this.dashboard()?.passFail.trend;
if (trend === 'improving') return 'trend--improving';
if (trend === 'degrading') return 'trend--degrading';
return 'trend--stable';
});
readonly totalThroughput = computed(() => {
const dash = this.dashboard();
if (!dash) return { docs: 0, bytes: 0 };
return dash.throughputByTenant.reduce(
(acc, t) => ({
docs: acc.docs + t.documentsIngested,
bytes: acc.bytes + t.bytesIngested,
}),
{ docs: 0, bytes: 0 }
);
});
readonly criticalViolations = computed(() => {
const dash = this.dashboard();
if (!dash) return 0;
return dash.recentViolations
.filter((v) => v.severity === 'critical')
.reduce((sum, v) => sum + v.count, 0);
});
readonly chartData = computed(() => {
const dash = this.dashboard();
if (!dash) return [];
const history = dash.passFail.history;
const max = Math.max(...history.map((p) => p.value));
return history.map((p) => ({
timestamp: p.timestamp,
value: p.value,
height: (p.value / max) * 100,
}));
});
ngOnInit(): void {
this.loadDashboard();
}
private loadDashboard(): void {
this.loading.set(true);
this.aocApi.getDashboardSummary().subscribe({
next: (summary) => {
this.dashboard.set(summary);
this.loading.set(false);
},
error: (err) => {
console.error('Failed to load AOC dashboard:', err);
this.loading.set(false);
},
});
}
startVerification(): void {
this.verifying.set(true);
this.verificationRequest.set(null);
this.aocApi.startVerification().subscribe({
next: (request) => {
this.verificationRequest.set(request);
// Poll for status updates (simplified - in real app would use interval)
setTimeout(() => this.pollVerificationStatus(request.requestId), 2000);
},
error: (err) => {
console.error('Failed to start verification:', err);
this.verifying.set(false);
},
});
}
private pollVerificationStatus(requestId: string): void {
this.aocApi.getVerificationStatus(requestId).subscribe({
next: (request) => {
this.verificationRequest.set(request);
if (request.status === 'completed' || request.status === 'failed') {
this.verifying.set(false);
}
},
error: (err) => {
console.error('Failed to get verification status:', err);
this.verifying.set(false);
},
});
}
selectViolation(violation: AocViolationCode): void {
this.selectedViolation.set(violation);
}
closeViolationDetail(): void {
this.selectedViolation.set(null);
}
getSeverityClass(severity: ViolationSeverity): string {
return `severity--${severity}`;
}
getSourceStatusClass(source: AocSource): string {
return `source-status--${source.status}`;
}
formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
formatNumber(num: number): string {
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
return num.toString();
}
formatDate(isoString: string): string {
try {
return new Date(isoString).toLocaleString();
} catch {
return isoString;
}
}
formatShortDate(isoString: string): string {
try {
const date = new Date(isoString);
return `${date.getMonth() + 1}/${date.getDate()}`;
} catch {
return '';
}
}
trackByCode(_index: number, violation: AocViolationCode): string {
return violation.code;
}
trackByTenantId(_index: number, throughput: IngestThroughput): string {
return throughput.tenantId;
}
trackBySourceId(_index: number, source: AocSource): string {
return source.sourceId;
}
}

View File

@@ -1,2 +1,2 @@
export { AocDashboardComponent } from './aoc-dashboard.component';
export { ViolationDetailComponent } from './violation-detail.component';
export { AocDashboardComponent } from './aoc-dashboard.component';
export { ViolationDetailComponent } from './violation-detail.component';

View File

@@ -1,334 +1,334 @@
<div class="vuln-explorer">
<!-- Header -->
<header class="vuln-explorer__header">
<div class="vuln-explorer__title-section">
<h1>Vulnerability Explorer</h1>
<p class="vuln-explorer__subtitle">Browse and manage vulnerabilities across your assets</p>
</div>
<div class="vuln-explorer__actions">
<button
type="button"
class="btn btn--secondary"
(click)="loadData()"
[disabled]="loading()"
>
Refresh
</button>
</div>
</header>
<!-- Stats Bar -->
<div class="vuln-explorer__stats" *ngIf="stats() as s">
<div class="stat-card stat-card--critical">
<span class="stat-card__value">{{ s.criticalOpen }}</span>
<span class="stat-card__label">Critical Open</span>
</div>
<div class="stat-card stat-card--high">
<span class="stat-card__value">{{ s.bySeverity['high'] }}</span>
<span class="stat-card__label">High</span>
</div>
<div class="stat-card">
<span class="stat-card__value">{{ s.total }}</span>
<span class="stat-card__label">Total</span>
</div>
<div class="stat-card stat-card--excepted">
<span class="stat-card__value">{{ s.withExceptions }}</span>
<span class="stat-card__label">With Exceptions</span>
</div>
</div>
<!-- Message Toast -->
<div
class="vuln-explorer__message"
*ngIf="message() as msg"
[class.vuln-explorer__message--success]="messageType() === 'success'"
[class.vuln-explorer__message--error]="messageType() === 'error'"
>
{{ msg }}
</div>
<!-- Toolbar -->
<div class="vuln-explorer__toolbar">
<!-- Search -->
<div class="search-box">
<input
type="text"
class="search-box__input"
placeholder="Search CVE ID, title, description..."
[value]="searchQuery()"
(input)="onSearchInput($event)"
/>
<button
type="button"
class="search-box__clear"
*ngIf="searchQuery()"
(click)="clearSearch()"
>
Clear
</button>
</div>
<!-- Filters -->
<div class="filters">
<div class="filter-group">
<label class="filter-group__label">Severity</label>
<select
class="filter-group__select"
[value]="severityFilter()"
(change)="setSeverityFilter($any($event.target).value)"
>
<option value="all">All Severities</option>
<option *ngFor="let sev of allSeverities" [value]="sev">
{{ severityLabels[sev] }}
</option>
</select>
</div>
<div class="filter-group">
<label class="filter-group__label">Status</label>
<select
class="filter-group__select"
[value]="statusFilter()"
(change)="setStatusFilter($any($event.target).value)"
>
<option value="all">All Statuses</option>
<option *ngFor="let st of allStatuses" [value]="st">
{{ statusLabels[st] }}
</option>
</select>
</div>
<label class="filter-checkbox">
<input
type="checkbox"
[checked]="showExceptedOnly()"
(change)="toggleExceptedOnly()"
/>
<span>Show with exceptions only</span>
</label>
</div>
</div>
<!-- Loading State -->
<div class="vuln-explorer__loading" *ngIf="loading()">
<span class="spinner"></span>
<span>Loading vulnerabilities...</span>
</div>
<!-- Main Content -->
<div class="vuln-explorer__content" *ngIf="!loading()">
<!-- Vulnerability List -->
<div class="vuln-list">
<table class="vuln-table" *ngIf="filteredVulnerabilities().length > 0">
<thead>
<tr>
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('cveId')">
CVE ID {{ getSortIcon('cveId') }}
</th>
<th class="vuln-table__th">Title</th>
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('severity')">
Severity {{ getSortIcon('severity') }}
</th>
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('cvssScore')">
CVSS {{ getSortIcon('cvssScore') }}
</th>
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('status')">
Status {{ getSortIcon('status') }}
</th>
<th class="vuln-table__th">Components</th>
<th class="vuln-table__th">Actions</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let vuln of filteredVulnerabilities(); trackBy: trackByVuln"
class="vuln-table__row"
[class.vuln-table__row--selected]="selectedVulnId() === vuln.vulnId"
[class.vuln-table__row--excepted]="vuln.hasException"
(click)="selectVulnerability(vuln.vulnId)"
>
<td class="vuln-table__td">
<div class="vuln-cve">
<span class="vuln-cve__id">{{ vuln.cveId }}</span>
<app-exception-badge
*ngIf="getExceptionBadgeData(vuln) as badgeData"
[data]="badgeData"
[compact]="true"
(viewDetails)="onViewExceptionDetails($event)"
(explain)="onExplainException($event)"
></app-exception-badge>
</div>
</td>
<td class="vuln-table__td">
<span class="vuln-title">{{ vuln.title | slice:0:60 }}{{ vuln.title.length > 60 ? '...' : '' }}</span>
</td>
<td class="vuln-table__td">
<span class="chip" [ngClass]="getSeverityClass(vuln.severity)">
{{ severityLabels[vuln.severity] }}
</span>
</td>
<td class="vuln-table__td">
<span class="cvss-score" [class.cvss-score--critical]="(vuln.cvssScore ?? 0) >= 9">
{{ formatCvss(vuln.cvssScore) }}
</span>
</td>
<td class="vuln-table__td">
<span class="chip chip--small" [ngClass]="getStatusClass(vuln.status)">
{{ statusLabels[vuln.status] }}
</span>
</td>
<td class="vuln-table__td">
<span class="component-count">{{ vuln.affectedComponents.length }}</span>
</td>
<td class="vuln-table__td vuln-table__td--actions">
<button
type="button"
class="btn btn--small btn--action"
(click)="startExceptionDraft(vuln); $event.stopPropagation()"
*ngIf="!vuln.hasException"
title="Create exception for this vulnerability"
>
+ Exception
</button>
</td>
</tr>
</tbody>
</table>
<div class="empty-state" *ngIf="filteredVulnerabilities().length === 0">
<p>No vulnerabilities found matching your filters.</p>
</div>
</div>
</div>
<!-- Detail Panel -->
<div class="detail-panel" *ngIf="selectedVulnerability() as vuln">
<div class="detail-panel__header">
<h2>{{ vuln.cveId }}</h2>
<button type="button" class="detail-panel__close" (click)="clearSelection()">Close</button>
</div>
<div class="detail-panel__content">
<!-- Title & Description -->
<div class="detail-section">
<h3>{{ vuln.title }}</h3>
<p class="detail-description">{{ vuln.description }}</p>
</div>
<!-- Severity & CVSS -->
<div class="detail-section detail-section--row">
<div class="detail-item">
<span class="detail-item__label">Severity</span>
<span class="chip chip--large" [ngClass]="getSeverityClass(vuln.severity)">
{{ severityLabels[vuln.severity] }}
</span>
</div>
<div class="detail-item">
<span class="detail-item__label">CVSS Score</span>
<span class="cvss-score cvss-score--large" [class.cvss-score--critical]="(vuln.cvssScore ?? 0) >= 9">
{{ formatCvss(vuln.cvssScore) }}
</span>
</div>
<div class="detail-item">
<span class="detail-item__label">Status</span>
<span class="chip" [ngClass]="getStatusClass(vuln.status)">
{{ statusLabels[vuln.status] }}
</span>
</div>
</div>
<!-- Exception Badge -->
<div class="detail-section" *ngIf="getExceptionBadgeData(vuln) as badgeData">
<h4>Exception Status</h4>
<app-exception-badge
[data]="badgeData"
[compact]="false"
(viewDetails)="onViewExceptionDetails($event)"
(explain)="onExplainException($event)"
></app-exception-badge>
</div>
<!-- Affected Components -->
<div class="detail-section">
<h4>Affected Components ({{ vuln.affectedComponents.length }})</h4>
<div class="affected-components">
<div
class="affected-component"
*ngFor="let comp of vuln.affectedComponents; trackBy: trackByComponent"
>
<div class="affected-component__header">
<span class="affected-component__name">{{ comp.name }}</span>
<span class="affected-component__version">{{ comp.version }}</span>
</div>
<div class="affected-component__purl">{{ comp.purl }}</div>
<div class="affected-component__fix" *ngIf="comp.fixedVersion">
Fixed in: <strong>{{ comp.fixedVersion }}</strong>
</div>
<div class="affected-component__assets">
Assets: {{ comp.assetIds.join(', ') }}
</div>
</div>
</div>
</div>
<!-- References -->
<div class="detail-section" *ngIf="vuln.references?.length">
<h4>References</h4>
<ul class="references-list">
<li *ngFor="let ref of vuln.references">
<a [href]="ref" target="_blank" rel="noopener noreferrer">{{ ref }}</a>
</li>
</ul>
</div>
<!-- Dates -->
<div class="detail-section">
<h4>Timeline</h4>
<div class="timeline">
<div class="timeline-item" *ngIf="vuln.publishedAt">
<span class="timeline-item__label">Published:</span>
<span class="timeline-item__value">{{ formatDate(vuln.publishedAt) }}</span>
</div>
<div class="timeline-item" *ngIf="vuln.modifiedAt">
<span class="timeline-item__label">Last Modified:</span>
<span class="timeline-item__value">{{ formatDate(vuln.modifiedAt) }}</span>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="detail-panel__actions" *ngIf="!vuln.hasException && !showExceptionDraft()">
<button
type="button"
class="btn btn--primary"
(click)="startExceptionDraft()"
>
Create Exception
</button>
</div>
<!-- Inline Exception Draft -->
<div class="detail-panel__exception-draft" *ngIf="showExceptionDraft() && exceptionDraftContext()">
<app-exception-draft-inline
[context]="exceptionDraftContext()!"
(created)="onExceptionCreated()"
(cancelled)="cancelExceptionDraft()"
(openFullWizard)="openFullWizard()"
></app-exception-draft-inline>
</div>
</div>
<!-- Exception Explain Modal -->
<div class="explain-modal" *ngIf="showExceptionExplain() && exceptionExplainData()">
<div class="explain-modal__backdrop" (click)="closeExplain()"></div>
<div class="explain-modal__container">
<app-exception-explain
[data]="exceptionExplainData()!"
(close)="closeExplain()"
(viewException)="viewExceptionFromExplain($event)"
></app-exception-explain>
</div>
</div>
</div>
<div class="vuln-explorer">
<!-- Header -->
<header class="vuln-explorer__header">
<div class="vuln-explorer__title-section">
<h1>Vulnerability Explorer</h1>
<p class="vuln-explorer__subtitle">Browse and manage vulnerabilities across your assets</p>
</div>
<div class="vuln-explorer__actions">
<button
type="button"
class="btn btn--secondary"
(click)="loadData()"
[disabled]="loading()"
>
Refresh
</button>
</div>
</header>
<!-- Stats Bar -->
<div class="vuln-explorer__stats" *ngIf="stats() as s">
<div class="stat-card stat-card--critical">
<span class="stat-card__value">{{ s.criticalOpen }}</span>
<span class="stat-card__label">Critical Open</span>
</div>
<div class="stat-card stat-card--high">
<span class="stat-card__value">{{ s.bySeverity['high'] }}</span>
<span class="stat-card__label">High</span>
</div>
<div class="stat-card">
<span class="stat-card__value">{{ s.total }}</span>
<span class="stat-card__label">Total</span>
</div>
<div class="stat-card stat-card--excepted">
<span class="stat-card__value">{{ s.withExceptions }}</span>
<span class="stat-card__label">With Exceptions</span>
</div>
</div>
<!-- Message Toast -->
<div
class="vuln-explorer__message"
*ngIf="message() as msg"
[class.vuln-explorer__message--success]="messageType() === 'success'"
[class.vuln-explorer__message--error]="messageType() === 'error'"
>
{{ msg }}
</div>
<!-- Toolbar -->
<div class="vuln-explorer__toolbar">
<!-- Search -->
<div class="search-box">
<input
type="text"
class="search-box__input"
placeholder="Search CVE ID, title, description..."
[value]="searchQuery()"
(input)="onSearchInput($event)"
/>
<button
type="button"
class="search-box__clear"
*ngIf="searchQuery()"
(click)="clearSearch()"
>
Clear
</button>
</div>
<!-- Filters -->
<div class="filters">
<div class="filter-group">
<label class="filter-group__label">Severity</label>
<select
class="filter-group__select"
[value]="severityFilter()"
(change)="setSeverityFilter($any($event.target).value)"
>
<option value="all">All Severities</option>
<option *ngFor="let sev of allSeverities" [value]="sev">
{{ severityLabels[sev] }}
</option>
</select>
</div>
<div class="filter-group">
<label class="filter-group__label">Status</label>
<select
class="filter-group__select"
[value]="statusFilter()"
(change)="setStatusFilter($any($event.target).value)"
>
<option value="all">All Statuses</option>
<option *ngFor="let st of allStatuses" [value]="st">
{{ statusLabels[st] }}
</option>
</select>
</div>
<label class="filter-checkbox">
<input
type="checkbox"
[checked]="showExceptedOnly()"
(change)="toggleExceptedOnly()"
/>
<span>Show with exceptions only</span>
</label>
</div>
</div>
<!-- Loading State -->
<div class="vuln-explorer__loading" *ngIf="loading()">
<span class="spinner"></span>
<span>Loading vulnerabilities...</span>
</div>
<!-- Main Content -->
<div class="vuln-explorer__content" *ngIf="!loading()">
<!-- Vulnerability List -->
<div class="vuln-list">
<table class="vuln-table" *ngIf="filteredVulnerabilities().length > 0">
<thead>
<tr>
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('cveId')">
CVE ID {{ getSortIcon('cveId') }}
</th>
<th class="vuln-table__th">Title</th>
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('severity')">
Severity {{ getSortIcon('severity') }}
</th>
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('cvssScore')">
CVSS {{ getSortIcon('cvssScore') }}
</th>
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('status')">
Status {{ getSortIcon('status') }}
</th>
<th class="vuln-table__th">Components</th>
<th class="vuln-table__th">Actions</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let vuln of filteredVulnerabilities(); trackBy: trackByVuln"
class="vuln-table__row"
[class.vuln-table__row--selected]="selectedVulnId() === vuln.vulnId"
[class.vuln-table__row--excepted]="vuln.hasException"
(click)="selectVulnerability(vuln.vulnId)"
>
<td class="vuln-table__td">
<div class="vuln-cve">
<span class="vuln-cve__id">{{ vuln.cveId }}</span>
<app-exception-badge
*ngIf="getExceptionBadgeData(vuln) as badgeData"
[data]="badgeData"
[compact]="true"
(viewDetails)="onViewExceptionDetails($event)"
(explain)="onExplainException($event)"
></app-exception-badge>
</div>
</td>
<td class="vuln-table__td">
<span class="vuln-title">{{ vuln.title | slice:0:60 }}{{ vuln.title.length > 60 ? '...' : '' }}</span>
</td>
<td class="vuln-table__td">
<span class="chip" [ngClass]="getSeverityClass(vuln.severity)">
{{ severityLabels[vuln.severity] }}
</span>
</td>
<td class="vuln-table__td">
<span class="cvss-score" [class.cvss-score--critical]="(vuln.cvssScore ?? 0) >= 9">
{{ formatCvss(vuln.cvssScore) }}
</span>
</td>
<td class="vuln-table__td">
<span class="chip chip--small" [ngClass]="getStatusClass(vuln.status)">
{{ statusLabels[vuln.status] }}
</span>
</td>
<td class="vuln-table__td">
<span class="component-count">{{ vuln.affectedComponents.length }}</span>
</td>
<td class="vuln-table__td vuln-table__td--actions">
<button
type="button"
class="btn btn--small btn--action"
(click)="startExceptionDraft(vuln); $event.stopPropagation()"
*ngIf="!vuln.hasException"
title="Create exception for this vulnerability"
>
+ Exception
</button>
</td>
</tr>
</tbody>
</table>
<div class="empty-state" *ngIf="filteredVulnerabilities().length === 0">
<p>No vulnerabilities found matching your filters.</p>
</div>
</div>
</div>
<!-- Detail Panel -->
<div class="detail-panel" *ngIf="selectedVulnerability() as vuln">
<div class="detail-panel__header">
<h2>{{ vuln.cveId }}</h2>
<button type="button" class="detail-panel__close" (click)="clearSelection()">Close</button>
</div>
<div class="detail-panel__content">
<!-- Title & Description -->
<div class="detail-section">
<h3>{{ vuln.title }}</h3>
<p class="detail-description">{{ vuln.description }}</p>
</div>
<!-- Severity & CVSS -->
<div class="detail-section detail-section--row">
<div class="detail-item">
<span class="detail-item__label">Severity</span>
<span class="chip chip--large" [ngClass]="getSeverityClass(vuln.severity)">
{{ severityLabels[vuln.severity] }}
</span>
</div>
<div class="detail-item">
<span class="detail-item__label">CVSS Score</span>
<span class="cvss-score cvss-score--large" [class.cvss-score--critical]="(vuln.cvssScore ?? 0) >= 9">
{{ formatCvss(vuln.cvssScore) }}
</span>
</div>
<div class="detail-item">
<span class="detail-item__label">Status</span>
<span class="chip" [ngClass]="getStatusClass(vuln.status)">
{{ statusLabels[vuln.status] }}
</span>
</div>
</div>
<!-- Exception Badge -->
<div class="detail-section" *ngIf="getExceptionBadgeData(vuln) as badgeData">
<h4>Exception Status</h4>
<app-exception-badge
[data]="badgeData"
[compact]="false"
(viewDetails)="onViewExceptionDetails($event)"
(explain)="onExplainException($event)"
></app-exception-badge>
</div>
<!-- Affected Components -->
<div class="detail-section">
<h4>Affected Components ({{ vuln.affectedComponents.length }})</h4>
<div class="affected-components">
<div
class="affected-component"
*ngFor="let comp of vuln.affectedComponents; trackBy: trackByComponent"
>
<div class="affected-component__header">
<span class="affected-component__name">{{ comp.name }}</span>
<span class="affected-component__version">{{ comp.version }}</span>
</div>
<div class="affected-component__purl">{{ comp.purl }}</div>
<div class="affected-component__fix" *ngIf="comp.fixedVersion">
Fixed in: <strong>{{ comp.fixedVersion }}</strong>
</div>
<div class="affected-component__assets">
Assets: {{ comp.assetIds.join(', ') }}
</div>
</div>
</div>
</div>
<!-- References -->
<div class="detail-section" *ngIf="vuln.references?.length">
<h4>References</h4>
<ul class="references-list">
<li *ngFor="let ref of vuln.references">
<a [href]="ref" target="_blank" rel="noopener noreferrer">{{ ref }}</a>
</li>
</ul>
</div>
<!-- Dates -->
<div class="detail-section">
<h4>Timeline</h4>
<div class="timeline">
<div class="timeline-item" *ngIf="vuln.publishedAt">
<span class="timeline-item__label">Published:</span>
<span class="timeline-item__value">{{ formatDate(vuln.publishedAt) }}</span>
</div>
<div class="timeline-item" *ngIf="vuln.modifiedAt">
<span class="timeline-item__label">Last Modified:</span>
<span class="timeline-item__value">{{ formatDate(vuln.modifiedAt) }}</span>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="detail-panel__actions" *ngIf="!vuln.hasException && !showExceptionDraft()">
<button
type="button"
class="btn btn--primary"
(click)="startExceptionDraft()"
>
Create Exception
</button>
</div>
<!-- Inline Exception Draft -->
<div class="detail-panel__exception-draft" *ngIf="showExceptionDraft() && exceptionDraftContext()">
<app-exception-draft-inline
[context]="exceptionDraftContext()!"
(created)="onExceptionCreated()"
(cancelled)="cancelExceptionDraft()"
(openFullWizard)="openFullWizard()"
></app-exception-draft-inline>
</div>
</div>
<!-- Exception Explain Modal -->
<div class="explain-modal" *ngIf="showExceptionExplain() && exceptionExplainData()">
<div class="explain-modal__backdrop" (click)="closeExplain()"></div>
<div class="explain-modal__container">
<app-exception-explain
[data]="exceptionExplainData()!"
(close)="closeExplain()"
(viewException)="viewExceptionFromExplain($event)"
></app-exception-explain>
</div>
</div>
</div>

View File

@@ -1,406 +1,406 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { firstValueFrom } from 'rxjs';
import {
VULNERABILITY_API,
VulnerabilityApi,
MockVulnerabilityApiService,
} from '../../core/api/vulnerability.client';
import {
Vulnerability,
VulnerabilitySeverity,
VulnerabilityStats,
VulnerabilityStatus,
} from '../../core/api/vulnerability.models';
import {
ExceptionDraftContext,
ExceptionDraftInlineComponent,
} from '../exceptions/exception-draft-inline.component';
import {
ExceptionBadgeComponent,
ExceptionBadgeData,
ExceptionExplainComponent,
ExceptionExplainData,
} from '../../shared/components';
type SeverityFilter = VulnerabilitySeverity | 'all';
type StatusFilter = VulnerabilityStatus | 'all';
type SortField = 'cveId' | 'severity' | 'cvssScore' | 'publishedAt' | 'status';
type SortOrder = 'asc' | 'desc';
const SEVERITY_LABELS: Record<VulnerabilitySeverity, string> = {
critical: 'Critical',
high: 'High',
medium: 'Medium',
low: 'Low',
unknown: 'Unknown',
};
const STATUS_LABELS: Record<VulnerabilityStatus, string> = {
open: 'Open',
fixed: 'Fixed',
wont_fix: "Won't Fix",
in_progress: 'In Progress',
excepted: 'Excepted',
};
const SEVERITY_ORDER: Record<VulnerabilitySeverity, number> = {
critical: 0,
high: 1,
medium: 2,
low: 3,
unknown: 4,
};
@Component({
selector: 'app-vulnerability-explorer',
standalone: true,
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent],
templateUrl: './vulnerability-explorer.component.html',
styleUrls: ['./vulnerability-explorer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{ provide: VULNERABILITY_API, useClass: MockVulnerabilityApiService },
],
})
export class VulnerabilityExplorerComponent implements OnInit {
private readonly api = inject<VulnerabilityApi>(VULNERABILITY_API);
// View state
readonly loading = signal(false);
readonly message = signal<string | null>(null);
readonly messageType = signal<'success' | 'error' | 'info'>('info');
// Data
readonly vulnerabilities = signal<Vulnerability[]>([]);
readonly stats = signal<VulnerabilityStats | null>(null);
readonly selectedVulnId = signal<string | null>(null);
// Filters & sorting
readonly severityFilter = signal<SeverityFilter>('all');
readonly statusFilter = signal<StatusFilter>('all');
readonly searchQuery = signal('');
readonly sortField = signal<SortField>('severity');
readonly sortOrder = signal<SortOrder>('asc');
readonly showExceptedOnly = signal(false);
// Exception draft state
readonly showExceptionDraft = signal(false);
readonly selectedForException = signal<Vulnerability[]>([]);
// Exception explain state
readonly showExceptionExplain = signal(false);
readonly explainExceptionId = signal<string | null>(null);
// Constants for template
readonly severityLabels = SEVERITY_LABELS;
readonly statusLabels = STATUS_LABELS;
readonly allSeverities: VulnerabilitySeverity[] = ['critical', 'high', 'medium', 'low', 'unknown'];
readonly allStatuses: VulnerabilityStatus[] = ['open', 'fixed', 'wont_fix', 'in_progress', 'excepted'];
// Computed: filtered and sorted list
readonly filteredVulnerabilities = computed(() => {
let items = [...this.vulnerabilities()];
const severity = this.severityFilter();
const status = this.statusFilter();
const search = this.searchQuery().toLowerCase();
const exceptedOnly = this.showExceptedOnly();
if (severity !== 'all') {
items = items.filter((v) => v.severity === severity);
}
if (status !== 'all') {
items = items.filter((v) => v.status === status);
}
if (exceptedOnly) {
items = items.filter((v) => v.hasException);
}
if (search) {
items = items.filter(
(v) =>
v.cveId.toLowerCase().includes(search) ||
v.title.toLowerCase().includes(search) ||
v.description?.toLowerCase().includes(search)
);
}
return this.sortVulnerabilities(items);
});
// Computed: selected vulnerability
readonly selectedVulnerability = computed(() => {
const id = this.selectedVulnId();
if (!id) return null;
return this.vulnerabilities().find((v) => v.vulnId === id) ?? null;
});
// Computed: get exception badge data for a vulnerability
getExceptionBadgeData(vuln: Vulnerability): ExceptionBadgeData | null {
if (!vuln.hasException || !vuln.exceptionId) return null;
return {
exceptionId: vuln.exceptionId,
status: 'approved',
severity: vuln.severity === 'unknown' ? 'medium' : vuln.severity,
name: `${vuln.cveId} Exception`,
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
justificationSummary: 'Risk accepted with compensating controls in place.',
approvedBy: 'Security Team',
};
}
// Computed: explain data for selected exception
readonly exceptionExplainData = computed<ExceptionExplainData | null>(() => {
const exceptionId = this.explainExceptionId();
if (!exceptionId) return null;
const vuln = this.vulnerabilities().find((v) => v.exceptionId === exceptionId);
if (!vuln) return null;
return {
exceptionId,
name: `${vuln.cveId} Exception`,
status: 'approved',
severity: vuln.severity === 'unknown' ? 'medium' : vuln.severity,
scope: {
type: 'vulnerability',
vulnIds: [vuln.cveId],
componentPurls: vuln.affectedComponents.map((c) => c.purl),
assetIds: vuln.affectedComponents.flatMap((c) => c.assetIds),
},
justification: {
template: 'risk-accepted',
text: 'Risk accepted with compensating controls in place. The vulnerability affects internal services with restricted network access.',
},
timebox: {
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
autoRenew: false,
},
approvedBy: 'Security Team',
approvedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
impact: {
affectedFindings: vuln.affectedComponents.length,
affectedAssets: [...new Set(vuln.affectedComponents.flatMap((c) => c.assetIds))].length,
policyOverrides: 1,
},
};
});
// Computed: exception draft context
readonly exceptionDraftContext = computed<ExceptionDraftContext | null>(() => {
const selected = this.selectedForException();
if (selected.length === 0) return null;
const vulnIds = selected.map((v) => v.cveId);
const componentPurls = [...new Set(selected.flatMap((v) => v.affectedComponents.map((c) => c.purl)))];
const assetIds = [...new Set(selected.flatMap((v) => v.affectedComponents.flatMap((c) => c.assetIds)))];
const maxSeverity = selected.reduce((max, v) => {
return SEVERITY_ORDER[v.severity] < SEVERITY_ORDER[max] ? v.severity : max;
}, 'low' as VulnerabilitySeverity);
return {
vulnIds,
componentPurls,
assetIds,
suggestedName: selected.length === 1 ? `${selected[0].cveId.toLowerCase()}-exception` : `multi-vuln-exception-${Date.now()}`,
suggestedSeverity: maxSeverity === 'unknown' ? 'medium' : maxSeverity,
sourceType: 'vulnerability',
sourceLabel: selected.length === 1 ? selected[0].cveId : `${selected.length} vulnerabilities`,
};
});
async ngOnInit(): Promise<void> {
await this.loadData();
}
async loadData(): Promise<void> {
this.loading.set(true);
this.message.set(null);
try {
const [vulnsResponse, statsResponse] = await Promise.all([
firstValueFrom(this.api.listVulnerabilities()),
firstValueFrom(this.api.getStats()),
]);
this.vulnerabilities.set([...vulnsResponse.items]);
this.stats.set(statsResponse);
} catch (error) {
this.showMessage(this.toErrorMessage(error), 'error');
} finally {
this.loading.set(false);
}
}
// Filters
setSeverityFilter(severity: SeverityFilter): void {
this.severityFilter.set(severity);
}
setStatusFilter(status: StatusFilter): void {
this.statusFilter.set(status);
}
onSearchInput(event: Event): void {
const input = event.target as HTMLInputElement;
this.searchQuery.set(input.value);
}
clearSearch(): void {
this.searchQuery.set('');
}
toggleExceptedOnly(): void {
this.showExceptedOnly.set(!this.showExceptedOnly());
}
// Sorting
toggleSort(field: SortField): void {
if (this.sortField() === field) {
this.sortOrder.set(this.sortOrder() === 'asc' ? 'desc' : 'asc');
} else {
this.sortField.set(field);
this.sortOrder.set('asc');
}
}
getSortIcon(field: SortField): string {
if (this.sortField() !== field) return '';
return this.sortOrder() === 'asc' ? '↑' : '↓';
}
// Selection
selectVulnerability(vulnId: string): void {
this.selectedVulnId.set(vulnId);
this.showExceptionDraft.set(false);
}
clearSelection(): void {
this.selectedVulnId.set(null);
this.showExceptionDraft.set(false);
}
// Exception drafting
startExceptionDraft(vuln?: Vulnerability): void {
if (vuln) {
this.selectedForException.set([vuln]);
} else if (this.selectedVulnerability()) {
this.selectedForException.set([this.selectedVulnerability()!]);
}
this.showExceptionDraft.set(true);
}
cancelExceptionDraft(): void {
this.showExceptionDraft.set(false);
this.selectedForException.set([]);
}
onExceptionCreated(): void {
this.showExceptionDraft.set(false);
this.selectedForException.set([]);
this.showMessage('Exception draft created successfully', 'success');
this.loadData();
}
// Exception explain
onViewExceptionDetails(exceptionId: string): void {
this.showMessage(`Navigating to exception ${exceptionId}...`, 'info');
}
onExplainException(exceptionId: string): void {
this.explainExceptionId.set(exceptionId);
this.showExceptionExplain.set(true);
}
closeExplain(): void {
this.showExceptionExplain.set(false);
this.explainExceptionId.set(null);
}
viewExceptionFromExplain(exceptionId: string): void {
this.closeExplain();
this.onViewExceptionDetails(exceptionId);
}
openFullWizard(): void {
// In a real app, this would navigate to the Exception Center wizard
// For now, just show a message
this.showMessage('Opening full wizard... (would navigate to Exception Center)', 'info');
}
// Helpers
getSeverityClass(severity: VulnerabilitySeverity): string {
return `severity--${severity}`;
}
getStatusClass(status: VulnerabilityStatus): string {
return `status--${status.replace('_', '-')}`;
}
formatDate(dateString: string | undefined): string {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
formatCvss(score: number | undefined): string {
if (score === undefined) return '-';
return score.toFixed(1);
}
trackByVuln = (_: number, item: Vulnerability) => item.vulnId;
trackByComponent = (_: number, item: { purl: string }) => item.purl;
private sortVulnerabilities(items: Vulnerability[]): Vulnerability[] {
const field = this.sortField();
const order = this.sortOrder();
return items.sort((a, b) => {
let comparison = 0;
switch (field) {
case 'cveId':
comparison = a.cveId.localeCompare(b.cveId);
break;
case 'severity':
comparison = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
break;
case 'cvssScore':
comparison = (b.cvssScore ?? 0) - (a.cvssScore ?? 0);
break;
case 'publishedAt':
comparison = (b.publishedAt ?? '').localeCompare(a.publishedAt ?? '');
break;
case 'status':
comparison = a.status.localeCompare(b.status);
break;
default:
comparison = 0;
}
return order === 'asc' ? comparison : -comparison;
});
}
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
this.message.set(text);
this.messageType.set(type);
setTimeout(() => this.message.set(null), 5000);
}
private toErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
return 'Operation failed. Please retry.';
}
}
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { firstValueFrom } from 'rxjs';
import {
VULNERABILITY_API,
VulnerabilityApi,
MockVulnerabilityApiService,
} from '../../core/api/vulnerability.client';
import {
Vulnerability,
VulnerabilitySeverity,
VulnerabilityStats,
VulnerabilityStatus,
} from '../../core/api/vulnerability.models';
import {
ExceptionDraftContext,
ExceptionDraftInlineComponent,
} from '../exceptions/exception-draft-inline.component';
import {
ExceptionBadgeComponent,
ExceptionBadgeData,
ExceptionExplainComponent,
ExceptionExplainData,
} from '../../shared/components';
type SeverityFilter = VulnerabilitySeverity | 'all';
type StatusFilter = VulnerabilityStatus | 'all';
type SortField = 'cveId' | 'severity' | 'cvssScore' | 'publishedAt' | 'status';
type SortOrder = 'asc' | 'desc';
const SEVERITY_LABELS: Record<VulnerabilitySeverity, string> = {
critical: 'Critical',
high: 'High',
medium: 'Medium',
low: 'Low',
unknown: 'Unknown',
};
const STATUS_LABELS: Record<VulnerabilityStatus, string> = {
open: 'Open',
fixed: 'Fixed',
wont_fix: "Won't Fix",
in_progress: 'In Progress',
excepted: 'Excepted',
};
const SEVERITY_ORDER: Record<VulnerabilitySeverity, number> = {
critical: 0,
high: 1,
medium: 2,
low: 3,
unknown: 4,
};
@Component({
selector: 'app-vulnerability-explorer',
standalone: true,
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent],
templateUrl: './vulnerability-explorer.component.html',
styleUrls: ['./vulnerability-explorer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{ provide: VULNERABILITY_API, useClass: MockVulnerabilityApiService },
],
})
export class VulnerabilityExplorerComponent implements OnInit {
private readonly api = inject<VulnerabilityApi>(VULNERABILITY_API);
// View state
readonly loading = signal(false);
readonly message = signal<string | null>(null);
readonly messageType = signal<'success' | 'error' | 'info'>('info');
// Data
readonly vulnerabilities = signal<Vulnerability[]>([]);
readonly stats = signal<VulnerabilityStats | null>(null);
readonly selectedVulnId = signal<string | null>(null);
// Filters & sorting
readonly severityFilter = signal<SeverityFilter>('all');
readonly statusFilter = signal<StatusFilter>('all');
readonly searchQuery = signal('');
readonly sortField = signal<SortField>('severity');
readonly sortOrder = signal<SortOrder>('asc');
readonly showExceptedOnly = signal(false);
// Exception draft state
readonly showExceptionDraft = signal(false);
readonly selectedForException = signal<Vulnerability[]>([]);
// Exception explain state
readonly showExceptionExplain = signal(false);
readonly explainExceptionId = signal<string | null>(null);
// Constants for template
readonly severityLabels = SEVERITY_LABELS;
readonly statusLabels = STATUS_LABELS;
readonly allSeverities: VulnerabilitySeverity[] = ['critical', 'high', 'medium', 'low', 'unknown'];
readonly allStatuses: VulnerabilityStatus[] = ['open', 'fixed', 'wont_fix', 'in_progress', 'excepted'];
// Computed: filtered and sorted list
readonly filteredVulnerabilities = computed(() => {
let items = [...this.vulnerabilities()];
const severity = this.severityFilter();
const status = this.statusFilter();
const search = this.searchQuery().toLowerCase();
const exceptedOnly = this.showExceptedOnly();
if (severity !== 'all') {
items = items.filter((v) => v.severity === severity);
}
if (status !== 'all') {
items = items.filter((v) => v.status === status);
}
if (exceptedOnly) {
items = items.filter((v) => v.hasException);
}
if (search) {
items = items.filter(
(v) =>
v.cveId.toLowerCase().includes(search) ||
v.title.toLowerCase().includes(search) ||
v.description?.toLowerCase().includes(search)
);
}
return this.sortVulnerabilities(items);
});
// Computed: selected vulnerability
readonly selectedVulnerability = computed(() => {
const id = this.selectedVulnId();
if (!id) return null;
return this.vulnerabilities().find((v) => v.vulnId === id) ?? null;
});
// Computed: get exception badge data for a vulnerability
getExceptionBadgeData(vuln: Vulnerability): ExceptionBadgeData | null {
if (!vuln.hasException || !vuln.exceptionId) return null;
return {
exceptionId: vuln.exceptionId,
status: 'approved',
severity: vuln.severity === 'unknown' ? 'medium' : vuln.severity,
name: `${vuln.cveId} Exception`,
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
justificationSummary: 'Risk accepted with compensating controls in place.',
approvedBy: 'Security Team',
};
}
// Computed: explain data for selected exception
readonly exceptionExplainData = computed<ExceptionExplainData | null>(() => {
const exceptionId = this.explainExceptionId();
if (!exceptionId) return null;
const vuln = this.vulnerabilities().find((v) => v.exceptionId === exceptionId);
if (!vuln) return null;
return {
exceptionId,
name: `${vuln.cveId} Exception`,
status: 'approved',
severity: vuln.severity === 'unknown' ? 'medium' : vuln.severity,
scope: {
type: 'vulnerability',
vulnIds: [vuln.cveId],
componentPurls: vuln.affectedComponents.map((c) => c.purl),
assetIds: vuln.affectedComponents.flatMap((c) => c.assetIds),
},
justification: {
template: 'risk-accepted',
text: 'Risk accepted with compensating controls in place. The vulnerability affects internal services with restricted network access.',
},
timebox: {
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
autoRenew: false,
},
approvedBy: 'Security Team',
approvedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
impact: {
affectedFindings: vuln.affectedComponents.length,
affectedAssets: [...new Set(vuln.affectedComponents.flatMap((c) => c.assetIds))].length,
policyOverrides: 1,
},
};
});
// Computed: exception draft context
readonly exceptionDraftContext = computed<ExceptionDraftContext | null>(() => {
const selected = this.selectedForException();
if (selected.length === 0) return null;
const vulnIds = selected.map((v) => v.cveId);
const componentPurls = [...new Set(selected.flatMap((v) => v.affectedComponents.map((c) => c.purl)))];
const assetIds = [...new Set(selected.flatMap((v) => v.affectedComponents.flatMap((c) => c.assetIds)))];
const maxSeverity = selected.reduce((max, v) => {
return SEVERITY_ORDER[v.severity] < SEVERITY_ORDER[max] ? v.severity : max;
}, 'low' as VulnerabilitySeverity);
return {
vulnIds,
componentPurls,
assetIds,
suggestedName: selected.length === 1 ? `${selected[0].cveId.toLowerCase()}-exception` : `multi-vuln-exception-${Date.now()}`,
suggestedSeverity: maxSeverity === 'unknown' ? 'medium' : maxSeverity,
sourceType: 'vulnerability',
sourceLabel: selected.length === 1 ? selected[0].cveId : `${selected.length} vulnerabilities`,
};
});
async ngOnInit(): Promise<void> {
await this.loadData();
}
async loadData(): Promise<void> {
this.loading.set(true);
this.message.set(null);
try {
const [vulnsResponse, statsResponse] = await Promise.all([
firstValueFrom(this.api.listVulnerabilities()),
firstValueFrom(this.api.getStats()),
]);
this.vulnerabilities.set([...vulnsResponse.items]);
this.stats.set(statsResponse);
} catch (error) {
this.showMessage(this.toErrorMessage(error), 'error');
} finally {
this.loading.set(false);
}
}
// Filters
setSeverityFilter(severity: SeverityFilter): void {
this.severityFilter.set(severity);
}
setStatusFilter(status: StatusFilter): void {
this.statusFilter.set(status);
}
onSearchInput(event: Event): void {
const input = event.target as HTMLInputElement;
this.searchQuery.set(input.value);
}
clearSearch(): void {
this.searchQuery.set('');
}
toggleExceptedOnly(): void {
this.showExceptedOnly.set(!this.showExceptedOnly());
}
// Sorting
toggleSort(field: SortField): void {
if (this.sortField() === field) {
this.sortOrder.set(this.sortOrder() === 'asc' ? 'desc' : 'asc');
} else {
this.sortField.set(field);
this.sortOrder.set('asc');
}
}
getSortIcon(field: SortField): string {
if (this.sortField() !== field) return '';
return this.sortOrder() === 'asc' ? '↑' : '↓';
}
// Selection
selectVulnerability(vulnId: string): void {
this.selectedVulnId.set(vulnId);
this.showExceptionDraft.set(false);
}
clearSelection(): void {
this.selectedVulnId.set(null);
this.showExceptionDraft.set(false);
}
// Exception drafting
startExceptionDraft(vuln?: Vulnerability): void {
if (vuln) {
this.selectedForException.set([vuln]);
} else if (this.selectedVulnerability()) {
this.selectedForException.set([this.selectedVulnerability()!]);
}
this.showExceptionDraft.set(true);
}
cancelExceptionDraft(): void {
this.showExceptionDraft.set(false);
this.selectedForException.set([]);
}
onExceptionCreated(): void {
this.showExceptionDraft.set(false);
this.selectedForException.set([]);
this.showMessage('Exception draft created successfully', 'success');
this.loadData();
}
// Exception explain
onViewExceptionDetails(exceptionId: string): void {
this.showMessage(`Navigating to exception ${exceptionId}...`, 'info');
}
onExplainException(exceptionId: string): void {
this.explainExceptionId.set(exceptionId);
this.showExceptionExplain.set(true);
}
closeExplain(): void {
this.showExceptionExplain.set(false);
this.explainExceptionId.set(null);
}
viewExceptionFromExplain(exceptionId: string): void {
this.closeExplain();
this.onViewExceptionDetails(exceptionId);
}
openFullWizard(): void {
// In a real app, this would navigate to the Exception Center wizard
// For now, just show a message
this.showMessage('Opening full wizard... (would navigate to Exception Center)', 'info');
}
// Helpers
getSeverityClass(severity: VulnerabilitySeverity): string {
return `severity--${severity}`;
}
getStatusClass(status: VulnerabilityStatus): string {
return `status--${status.replace('_', '-')}`;
}
formatDate(dateString: string | undefined): string {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
formatCvss(score: number | undefined): string {
if (score === undefined) return '-';
return score.toFixed(1);
}
trackByVuln = (_: number, item: Vulnerability) => item.vulnId;
trackByComponent = (_: number, item: { purl: string }) => item.purl;
private sortVulnerabilities(items: Vulnerability[]): Vulnerability[] {
const field = this.sortField();
const order = this.sortOrder();
return items.sort((a, b) => {
let comparison = 0;
switch (field) {
case 'cveId':
comparison = a.cveId.localeCompare(b.cveId);
break;
case 'severity':
comparison = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
break;
case 'cvssScore':
comparison = (b.cvssScore ?? 0) - (a.cvssScore ?? 0);
break;
case 'publishedAt':
comparison = (b.publishedAt ?? '').localeCompare(a.publishedAt ?? '');
break;
case 'status':
comparison = a.status.localeCompare(b.status);
break;
default:
comparison = 0;
}
return order === 'asc' ? comparison : -comparison;
});
}
private showMessage(text: string, type: 'success' | 'error' | 'info'): void {
this.message.set(text);
this.messageType.set(type);
setTimeout(() => this.message.set(null), 5000);
}
private toErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
return 'Operation failed. Please retry.';
}
}

View File

@@ -1,123 +1,123 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { AppConfigService } from '../../core/config/app-config.service';
@Component({
standalone: true,
selector: 'app-welcome-page',
imports: [CommonModule],
template: `
<section class="welcome-card">
<h1>{{ title() }}</h1>
<p class="message">{{ message() }}</p>
<dl class="config-grid">
<div>
<dt>Quickstart mode</dt>
<dd>{{ quickstartEnabled() ? 'Enabled' : 'Disabled' }}</dd>
</div>
<div>
<dt>Authority</dt>
<dd>{{ config().authority.issuer }}</dd>
</div>
<div>
<dt>Policy API</dt>
<dd>{{ config().apiBaseUrls.policy }}</dd>
</div>
<div>
<dt>Scanner API</dt>
<dd>{{ config().apiBaseUrls.scanner }}</dd>
</div>
</dl>
<a
*ngIf="docsUrl() as docs"
class="docs-link"
[href]="docs"
rel="noreferrer"
target="_blank"
>
View deployment guide
</a>
</section>
`,
styles: [
`
:host {
display: block;
max-width: 720px;
margin: 0 auto;
}
.welcome-card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
}
h1 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
}
.message {
margin: 0 0 1rem;
color: #475569;
}
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.75rem;
margin: 1rem 0;
}
dt {
font-size: 0.85rem;
color: #334155;
margin-bottom: 0.1rem;
}
dd {
margin: 0;
font-weight: 600;
color: #0f172a;
word-break: break-all;
}
.docs-link {
display: inline-block;
margin-top: 0.5rem;
color: #4338ca;
font-weight: 600;
text-decoration: none;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WelcomePageComponent {
private readonly configService = inject(AppConfigService);
readonly config = computed(() => this.configService.config);
readonly quickstartEnabled = computed(
() => this.config().quickstartMode ?? false
);
readonly title = computed(
() => this.config().welcome?.title ?? 'Welcome to StellaOps'
);
readonly message = computed(
() =>
this.config().welcome?.message ??
'This page surfaces safe deployment configuration for operators.'
);
readonly docsUrl = computed(() => this.config().welcome?.docsUrl);
}
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { AppConfigService } from '../../core/config/app-config.service';
@Component({
standalone: true,
selector: 'app-welcome-page',
imports: [CommonModule],
template: `
<section class="welcome-card">
<h1>{{ title() }}</h1>
<p class="message">{{ message() }}</p>
<dl class="config-grid">
<div>
<dt>Quickstart mode</dt>
<dd>{{ quickstartEnabled() ? 'Enabled' : 'Disabled' }}</dd>
</div>
<div>
<dt>Authority</dt>
<dd>{{ config().authority.issuer }}</dd>
</div>
<div>
<dt>Policy API</dt>
<dd>{{ config().apiBaseUrls.policy }}</dd>
</div>
<div>
<dt>Scanner API</dt>
<dd>{{ config().apiBaseUrls.scanner }}</dd>
</div>
</dl>
<a
*ngIf="docsUrl() as docs"
class="docs-link"
[href]="docs"
rel="noreferrer"
target="_blank"
>
View deployment guide
</a>
</section>
`,
styles: [
`
:host {
display: block;
max-width: 720px;
margin: 0 auto;
}
.welcome-card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
}
h1 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
}
.message {
margin: 0 0 1rem;
color: #475569;
}
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.75rem;
margin: 1rem 0;
}
dt {
font-size: 0.85rem;
color: #334155;
margin-bottom: 0.1rem;
}
dd {
margin: 0;
font-weight: 600;
color: #0f172a;
word-break: break-all;
}
.docs-link {
display: inline-block;
margin-top: 0.5rem;
color: #4338ca;
font-weight: 600;
text-decoration: none;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WelcomePageComponent {
private readonly configService = inject(AppConfigService);
readonly config = computed(() => this.configService.config);
readonly quickstartEnabled = computed(
() => this.config().quickstartMode ?? false
);
readonly title = computed(
() => this.config().welcome?.title ?? 'Welcome to StellaOps'
);
readonly message = computed(
() =>
this.config().welcome?.message ??
'This page surfaces safe deployment configuration for operators.'
);
readonly docsUrl = computed(() => this.config().welcome?.docsUrl);
}

View File

@@ -0,0 +1,130 @@
<div class="determinism-badge" [class]="'status-' + status().status">
<!-- Compact Badge -->
<button
class="badge-trigger"
(click)="toggleExpanded()"
[attr.aria-expanded]="isExpanded()"
aria-controls="determinism-details"
>
<span class="badge-icon">{{ statusIcon() }}</span>
<span class="badge-label">{{ statusLabel() }}</span>
<span class="badge-stats">
{{ fragmentStats().matched }}/{{ fragmentStats().total }} fragments
</span>
<span class="badge-expand-icon" [class.expanded]="isExpanded()"></span>
</button>
<!-- Expanded Details -->
@if (isExpanded()) {
<div id="determinism-details" class="badge-details" role="region" aria-label="Determinism details">
<!-- Merkle Root Section -->
<div class="detail-section">
<h4 class="section-title">Merkle Root</h4>
<div class="merkle-info">
@if (status().merkleRoot) {
<code class="hash" [title]="status().merkleRoot">
{{ formatHash(status().merkleRoot!, 24) }}
</code>
<span class="consistency-badge" [class.consistent]="status().merkleConsistent">
{{ status().merkleConsistent ? 'Consistent' : 'Mismatch' }}
</span>
} @else {
<span class="no-data">No Merkle root available</span>
}
</div>
</div>
<!-- Composition Metadata -->
@if (status().composition; as comp) {
<div class="detail-section">
<h4 class="section-title">Composition</h4>
<dl class="composition-meta">
<div class="meta-item">
<dt>Schema</dt>
<dd>{{ comp.schemaVersion }}</dd>
</div>
<div class="meta-item">
<dt>Scanner</dt>
<dd>{{ comp.scannerVersion }}</dd>
</div>
<div class="meta-item">
<dt>Built</dt>
<dd>{{ comp.buildTimestamp | date:'short' }}</dd>
</div>
<div class="meta-item">
<dt>Hash</dt>
<dd><code [title]="comp.compositionHash">{{ formatHash(comp.compositionHash) }}</code></dd>
</div>
</dl>
<button class="btn-link" (click)="onViewComposition()">
View _composition.json →
</button>
</div>
}
<!-- Fragment Hashes -->
<div class="detail-section">
<h4 class="section-title">
Fragment Hashes
<span class="fragment-count">
({{ fragmentStats().percentage | number:'1.0-0' }}% match)
</span>
</h4>
<div class="fragments-list">
@for (fragment of status().fragments; track fragment.id) {
<div class="fragment-item" [class.mismatch]="!fragment.matches">
<span class="fragment-icon">{{ getFragmentIcon(fragment) }}</span>
<div class="fragment-info">
<span class="fragment-id" [title]="fragment.id">
{{ fragment.type }}: {{ formatHash(fragment.id, 16) }}
</span>
<span class="fragment-size">{{ formatBytes(fragment.size) }}</span>
</div>
<div class="fragment-hashes">
<code class="hash expected" [title]="fragment.expectedHash">
E: {{ formatHash(fragment.expectedHash) }}
</code>
<code class="hash computed" [title]="fragment.computedHash">
C: {{ formatHash(fragment.computedHash) }}
</code>
</div>
</div>
}
</div>
</div>
<!-- Issues -->
@if (status().issues.length > 0) {
<div class="detail-section issues-section">
<h4 class="section-title">
Issues
@if (issuesByLevel().errors.length > 0) {
<span class="issue-count error">{{ issuesByLevel().errors.length }} errors</span>
}
@if (issuesByLevel().warnings.length > 0) {
<span class="issue-count warning">{{ issuesByLevel().warnings.length }} warnings</span>
}
</h4>
<ul class="issues-list">
@for (issue of status().issues; track issue.code) {
<li class="issue-item" [class]="'severity-' + issue.severity">
<span class="issue-icon">{{ getIssueIcon(issue) }}</span>
<div class="issue-content">
<code class="issue-code">{{ issue.code }}</code>
<span class="issue-message">{{ issue.message }}</span>
@if (issue.fragmentId) {
<span class="issue-fragment">Fragment: {{ formatHash(issue.fragmentId) }}</span>
}
</div>
</li>
}
</ul>
</div>
}
<p class="verified-at">
Verified {{ status().verifiedAt | date:'medium' }}
</p>
</div>
}
</div>

View File

@@ -0,0 +1,322 @@
.determinism-badge {
font-size: 0.875rem;
border-radius: 6px;
border: 1px solid var(--color-border, #e5e7eb);
background: var(--color-bg-card, white);
overflow: hidden;
&.status-verified {
.badge-trigger { border-left: 3px solid var(--color-success, #059669); }
.badge-icon { color: var(--color-success, #059669); }
}
&.status-warning {
.badge-trigger { border-left: 3px solid var(--color-warning, #d97706); }
.badge-icon { color: var(--color-warning, #d97706); }
}
&.status-failed {
.badge-trigger { border-left: 3px solid var(--color-error, #dc2626); }
.badge-icon { color: var(--color-error, #dc2626); }
}
&.status-unknown {
.badge-trigger { border-left: 3px solid var(--color-text-muted, #9ca3af); }
.badge-icon { color: var(--color-text-muted, #9ca3af); }
}
}
.badge-trigger {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
&:hover {
background: var(--color-bg-hover, #f9fafb);
}
&:focus-visible {
outline: 2px solid var(--color-primary, #2563eb);
outline-offset: -2px;
}
}
.badge-icon {
font-size: 1rem;
font-weight: bold;
}
.badge-label {
font-weight: 600;
color: var(--color-text, #374151);
}
.badge-stats {
color: var(--color-text-muted, #6b7280);
font-size: 0.75rem;
margin-left: auto;
}
.badge-expand-icon {
color: var(--color-text-muted, #9ca3af);
font-size: 0.625rem;
transition: transform 0.2s;
&.expanded {
transform: rotate(180deg);
}
}
.badge-details {
border-top: 1px solid var(--color-border, #e5e7eb);
padding: 0.75rem;
background: var(--color-bg-subtle, #f9fafb);
}
.detail-section {
margin-bottom: 1rem;
&:last-of-type {
margin-bottom: 0.5rem;
}
}
.section-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #6b7280);
margin: 0 0 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.fragment-count {
font-weight: normal;
text-transform: none;
}
.merkle-info {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.hash {
font-family: monospace;
font-size: 0.75rem;
background: var(--color-bg-code, #f3f4f6);
padding: 0.125rem 0.375rem;
border-radius: 3px;
color: var(--color-text, #374151);
}
.consistency-badge {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-weight: 600;
background: var(--color-error-bg, #fef2f2);
color: var(--color-error, #dc2626);
&.consistent {
background: var(--color-success-bg, #ecfdf5);
color: var(--color-success, #059669);
}
}
.no-data {
font-style: italic;
color: var(--color-text-muted, #9ca3af);
}
.composition-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.5rem;
margin: 0 0 0.5rem;
}
.meta-item {
dt {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
}
dd {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text, #374151);
}
}
.btn-link {
background: none;
border: none;
color: var(--color-primary, #2563eb);
font-size: 0.75rem;
cursor: pointer;
padding: 0;
&:hover {
text-decoration: underline;
}
}
.fragments-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
max-height: 200px;
overflow-y: auto;
}
.fragment-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.375rem;
border-radius: 4px;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
&.mismatch {
border-color: var(--color-error, #dc2626);
background: var(--color-error-bg, #fef2f2);
}
}
.fragment-icon {
font-size: 0.75rem;
font-weight: bold;
color: var(--color-success, #059669);
.mismatch & {
color: var(--color-error, #dc2626);
}
}
.fragment-info {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.fragment-id {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text, #374151);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fragment-size {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
}
.fragment-hashes {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.hash.expected {
opacity: 0.7;
}
.hash.computed {
.mismatch & {
color: var(--color-error, #dc2626);
}
}
.issues-section {
.section-title {
flex-wrap: wrap;
}
}
.issue-count {
font-size: 0.625rem;
padding: 0.125rem 0.25rem;
border-radius: 2px;
font-weight: normal;
text-transform: none;
&.error {
background: var(--color-error-bg, #fef2f2);
color: var(--color-error, #dc2626);
}
&.warning {
background: var(--color-warning-bg, #fffbeb);
color: var(--color-warning, #d97706);
}
}
.issues-list {
list-style: none;
padding: 0;
margin: 0;
}
.issue-item {
display: flex;
gap: 0.375rem;
padding: 0.25rem 0;
border-bottom: 1px solid var(--color-border, #e5e7eb);
&:last-child {
border-bottom: none;
}
&.severity-error .issue-icon { color: var(--color-error, #dc2626); }
&.severity-warning .issue-icon { color: var(--color-warning, #d97706); }
&.severity-info .issue-icon { color: var(--color-info, #2563eb); }
}
.issue-icon {
font-size: 0.75rem;
}
.issue-content {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.issue-code {
font-family: monospace;
font-size: 0.6875rem;
color: var(--color-text-muted, #6b7280);
}
.issue-message {
font-size: 0.8125rem;
color: var(--color-text, #374151);
}
.issue-fragment {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
}
.verified-at {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
margin: 0;
text-align: right;
}

View File

@@ -0,0 +1,118 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
DeterminismStatus,
DeterminismFragment,
DeterminismIssue,
} from '../../core/api/determinism.models';
@Component({
selector: 'app-determinism-badge',
standalone: true,
imports: [CommonModule],
templateUrl: './determinism-badge.component.html',
styleUrls: ['./determinism-badge.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DeterminismBadgeComponent {
/** Determinism status data */
readonly status = input.required<DeterminismStatus>();
/** Whether to show expanded details by default */
readonly expanded = input(false);
/** Emits when user clicks to view full composition */
readonly viewComposition = output<void>();
/** Local expanded state */
readonly isExpanded = signal(false);
readonly statusIcon = computed(() => {
switch (this.status().status) {
case 'verified':
return '✓';
case 'warning':
return '⚠';
case 'failed':
return '✗';
default:
return '?';
}
});
readonly statusLabel = computed(() => {
switch (this.status().status) {
case 'verified':
return 'Deterministic';
case 'warning':
return 'Partial';
case 'failed':
return 'Non-deterministic';
default:
return 'Unknown';
}
});
readonly fragmentStats = computed(() => {
const fragments = this.status().fragments;
const matched = fragments.filter((f) => f.matches).length;
const total = fragments.length;
return { matched, total, percentage: total > 0 ? (matched / total) * 100 : 0 };
});
readonly issuesByLevel = computed(() => {
const issues = this.status().issues;
return {
errors: issues.filter((i) => i.severity === 'error'),
warnings: issues.filter((i) => i.severity === 'warning'),
info: issues.filter((i) => i.severity === 'info'),
};
});
constructor() {
// Initialize expanded state from input
this.isExpanded.set(this.expanded());
}
toggleExpanded(): void {
this.isExpanded.update((v) => !v);
}
onViewComposition(): void {
this.viewComposition.emit();
}
formatHash(hash: string, length = 12): string {
if (!hash) return 'N/A';
if (hash.length <= length) return hash;
return hash.substring(0, length) + '...';
}
formatBytes(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
getFragmentIcon(fragment: DeterminismFragment): string {
return fragment.matches ? '✓' : '✗';
}
getIssueIcon(issue: DeterminismIssue): string {
switch (issue.severity) {
case 'error':
return '✗';
case 'warning':
return '⚠';
default:
return '';
}
}
}

View File

@@ -0,0 +1,160 @@
<div class="entropy-panel" [class]="riskClass()">
<!-- Header with Overall Score -->
<header class="panel-header">
<div class="score-section">
<div class="score-ring" [attr.data-score]="analysis().overallScore">
<svg viewBox="0 0 100 100" class="score-svg">
<circle class="score-bg" cx="50" cy="50" r="45" />
<circle
class="score-fill"
cx="50"
cy="50"
r="45"
[style.strokeDasharray]="(analysis().overallScore / 10 * 283) + ' 283'"
/>
</svg>
<span class="score-value">{{ analysis().overallScore | number:'1.1-1' }}</span>
</div>
<div class="score-info">
<h3 class="risk-label">{{ analysis().riskLevel | titlecase }} Risk</h3>
<p class="score-desc">{{ scoreDescription() }}</p>
</div>
</div>
<button class="btn-report" (click)="onViewReport()">
View entropy.report.json →
</button>
</header>
<div class="panel-content">
<!-- Layer Donut Chart -->
<section class="section layer-section">
<h4 class="section-title">Layer Entropy Distribution</h4>
<div class="layer-visualization">
<div class="donut-chart">
<svg viewBox="-50 -50 100 100" class="donut-svg">
@for (layer of layerDonutData(); track layer.digest) {
<path
class="donut-segment"
[attr.d]="'M 0 -40 A 40 40 0 ' + (layer.percentage > 50 ? 1 : 0) + ' 1 ' + (Math.sin(layer.percentage * 3.6 * Math.PI / 180) * 40) + ' ' + (-Math.cos(layer.percentage * 3.6 * Math.PI / 180) * 40)"
[style.stroke]="layer.color"
[style.transform]="'rotate(' + layer.startAngle + 'deg)'"
(click)="onSelectLayer(layer.digest)"
/>
}
</svg>
<span class="donut-center">
{{ analysis().layers.length }} layers
</span>
</div>
<div class="layer-legend">
@for (layer of analysis().layers; track layer.digest) {
<button
class="legend-item"
(click)="onSelectLayer(layer.digest)"
>
<span class="legend-color" [style.background]="layerDonutData()[analysis().layers.indexOf(layer)]?.color"></span>
<span class="legend-label" [title]="layer.command">
{{ formatPath(layer.command, 20) }}
</span>
<span class="legend-value">{{ layer.opaqueByteRatio | number:'1.0-0' }}% opaque</span>
</button>
}
</div>
</div>
</section>
<!-- High Entropy Files Heatmap -->
<section class="section files-section">
<h4 class="section-title">
High Entropy Files
<span class="count-badge">{{ analysis().highEntropyFiles.length }}</span>
</h4>
@if (topHighEntropyFiles().length === 0) {
<p class="empty-state">No high entropy files detected</p>
} @else {
<div class="files-heatmap">
@for (file of topHighEntropyFiles(); track file.path) {
<button
class="file-item"
[class]="getEntropyClass(file.entropy)"
(click)="onSelectFile(file.path)"
>
<span class="file-icon">{{ getClassificationIcon(file.classification) }}</span>
<div class="file-info">
<span class="file-path" [title]="file.path">{{ formatPath(file.path) }}</span>
<div class="file-meta">
<span class="file-size">{{ formatBytes(file.size) }}</span>
<span class="file-class">{{ file.classification }}</span>
</div>
</div>
<div class="entropy-bar-container">
<div class="entropy-bar" [style.width]="getEntropyBarWidth(file.entropy)"></div>
<span class="entropy-value">{{ file.entropy | number:'1.2-2' }} bits</span>
</div>
</button>
}
</div>
@if (analysis().highEntropyFiles.length > 10) {
<p class="more-files">
+ {{ analysis().highEntropyFiles.length - 10 }} more files
</p>
}
}
</section>
<!-- Why Risky? Detector Hints -->
<section class="section hints-section">
<h4 class="section-title">Why Risky?</h4>
@if (analysis().detectorHints.length === 0) {
<p class="empty-state">No specific risks detected</p>
} @else {
<div class="hint-chips">
@for (group of detectorHintsByType(); track group.type) {
<button class="hint-chip" [class]="'severity-' + group.maxSeverity">
<span class="chip-icon">{{ getHintTypeIcon(group.type) }}</span>
<span class="chip-label">{{ group.type | titlecase }}</span>
<span class="chip-count">{{ group.count }}</span>
</button>
}
</div>
<ul class="hints-list">
@for (hint of analysis().detectorHints.slice(0, 5); track hint.id) {
<li class="hint-item" [class]="'severity-' + hint.severity">
<div class="hint-header">
<span class="hint-icon">{{ getHintTypeIcon(hint.type) }}</span>
<span class="hint-type">{{ hint.type | titlecase }}</span>
<span class="hint-confidence">{{ hint.confidence }}% confidence</span>
</div>
<p class="hint-desc">{{ hint.description }}</p>
<p class="hint-remediation">
<strong>Fix:</strong> {{ hint.remediation }}
</p>
@if (hint.affectedPaths.length > 0) {
<details class="affected-paths">
<summary>{{ hint.affectedPaths.length }} affected file(s)</summary>
<ul>
@for (path of hint.affectedPaths.slice(0, 3); track path) {
<li><code>{{ formatPath(path, 50) }}</code></li>
}
@if (hint.affectedPaths.length > 3) {
<li class="more">+ {{ hint.affectedPaths.length - 3 }} more</li>
}
</ul>
</details>
}
</li>
}
</ul>
@if (analysis().detectorHints.length > 5) {
<p class="more-hints">
+ {{ analysis().detectorHints.length - 5 }} more hints in report
</p>
}
}
</section>
</div>
<footer class="panel-footer">
<span class="analyzed-at">Analyzed {{ analysis().analyzedAt | date:'medium' }}</span>
</footer>
</div>

View File

@@ -0,0 +1,431 @@
.entropy-panel {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
background: var(--color-bg-card, white);
overflow: hidden;
&.risk-low { --risk-color: var(--color-success, #059669); }
&.risk-medium { --risk-color: var(--color-warning, #d97706); }
&.risk-high { --risk-color: var(--color-error, #ea580c); }
&.risk-critical { --risk-color: var(--color-critical, #dc2626); }
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 1rem;
background: var(--color-bg-subtle, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.score-section {
display: flex;
align-items: center;
gap: 1rem;
}
.score-ring {
position: relative;
width: 64px;
height: 64px;
}
.score-svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.score-bg {
fill: none;
stroke: var(--color-border, #e5e7eb);
stroke-width: 8;
}
.score-fill {
fill: none;
stroke: var(--risk-color);
stroke-width: 8;
stroke-linecap: round;
transition: stroke-dasharray 0.5s ease;
}
.score-value {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1.25rem;
font-weight: 700;
color: var(--risk-color);
}
.score-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.risk-label {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--risk-color);
}
.score-desc {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
}
.btn-report {
background: none;
border: none;
color: var(--color-primary, #2563eb);
font-size: 0.8125rem;
cursor: pointer;
padding: 0.25rem 0.5rem;
&:hover {
text-decoration: underline;
}
}
.panel-content {
padding: 1rem;
}
.section {
margin-bottom: 1.5rem;
&:last-child {
margin-bottom: 0;
}
}
.section-title {
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #6b7280);
margin: 0 0 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.count-badge {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
border-radius: 10px;
background: var(--color-bg-subtle, #f3f4f6);
color: var(--color-text, #374151);
font-weight: normal;
}
.layer-visualization {
display: grid;
grid-template-columns: 120px 1fr;
gap: 1rem;
align-items: start;
}
.donut-chart {
position: relative;
width: 100px;
height: 100px;
}
.donut-svg {
width: 100%;
height: 100%;
}
.donut-segment {
fill: none;
stroke-width: 16;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.8;
}
}
.donut-center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
text-align: center;
}
.layer-legend {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
text-align: left;
&:hover {
background: var(--color-bg-hover, #f9fafb);
}
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
flex-shrink: 0;
}
.legend-label {
font-size: 0.75rem;
color: var(--color-text, #374151);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.legend-value {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
}
.empty-state {
font-style: italic;
color: var(--color-text-muted, #9ca3af);
text-align: center;
padding: 1rem;
}
.files-heatmap {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.file-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 4px;
background: var(--color-bg-subtle, #f9fafb);
border: 1px solid transparent;
cursor: pointer;
text-align: left;
&:hover {
border-color: var(--color-border, #e5e7eb);
}
&.entropy-low { border-left: 3px solid var(--color-success, #059669); }
&.entropy-medium { border-left: 3px solid var(--color-warning, #d97706); }
&.entropy-high { border-left: 3px solid var(--color-error, #ea580c); }
&.entropy-critical { border-left: 3px solid var(--color-critical, #dc2626); }
}
.file-icon {
font-size: 1.25rem;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-path {
display: block;
font-size: 0.8125rem;
font-family: monospace;
color: var(--color-text, #374151);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-meta {
display: flex;
gap: 0.5rem;
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
}
.entropy-bar-container {
width: 100px;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.entropy-bar {
height: 6px;
border-radius: 3px;
background: linear-gradient(to right, var(--color-success, #059669), var(--color-warning, #d97706), var(--color-critical, #dc2626));
}
.entropy-value {
font-size: 0.625rem;
color: var(--color-text-muted, #9ca3af);
text-align: right;
}
.more-files,
.more-hints {
font-size: 0.75rem;
color: var(--color-text-muted, #9ca3af);
text-align: center;
margin: 0.5rem 0 0;
}
.hint-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.hint-chip {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 16px;
background: var(--color-bg-subtle, #f3f4f6);
border: 1px solid var(--color-border, #e5e7eb);
font-size: 0.75rem;
cursor: pointer;
&:hover {
background: var(--color-bg-hover, #e5e7eb);
}
&.severity-critical { border-color: var(--color-critical, #dc2626); background: #fef2f2; }
&.severity-high { border-color: var(--color-error, #ea580c); background: #fff7ed; }
&.severity-medium { border-color: var(--color-warning, #d97706); background: #fffbeb; }
}
.chip-icon {
font-size: 0.875rem;
}
.chip-label {
font-weight: 500;
}
.chip-count {
background: rgba(0, 0, 0, 0.1);
padding: 0 0.25rem;
border-radius: 8px;
font-size: 0.625rem;
}
.hints-list {
list-style: none;
padding: 0;
margin: 0;
}
.hint-item {
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 0.5rem;
background: var(--color-bg-subtle, #f9fafb);
&.severity-critical { border-left: 3px solid var(--color-critical, #dc2626); }
&.severity-high { border-left: 3px solid var(--color-error, #ea580c); }
&.severity-medium { border-left: 3px solid var(--color-warning, #d97706); }
&.severity-low { border-left: 3px solid var(--color-text-muted, #9ca3af); }
}
.hint-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.hint-icon {
font-size: 1rem;
}
.hint-type {
font-weight: 600;
font-size: 0.8125rem;
}
.hint-confidence {
margin-left: auto;
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
}
.hint-desc {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
color: var(--color-text, #374151);
}
.hint-remediation {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
}
.affected-paths {
margin-top: 0.5rem;
summary {
font-size: 0.75rem;
color: var(--color-primary, #2563eb);
cursor: pointer;
}
ul {
margin: 0.25rem 0 0;
padding-left: 1rem;
font-size: 0.75rem;
}
code {
font-family: monospace;
background: var(--color-bg-code, #f3f4f6);
padding: 0 0.25rem;
border-radius: 2px;
}
.more {
color: var(--color-text-muted, #9ca3af);
font-style: italic;
list-style: none;
}
}
.panel-footer {
padding: 0.5rem 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
background: var(--color-bg-subtle, #f9fafb);
}
.analyzed-at {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
}

View File

@@ -0,0 +1,179 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import {
EntropyAnalysis,
LayerEntropy,
HighEntropyFile,
DetectorHint,
} from '../../core/api/entropy.models';
@Component({
selector: 'app-entropy-panel',
standalone: true,
imports: [CommonModule],
templateUrl: './entropy-panel.component.html',
styleUrls: ['./entropy-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EntropyPanelComponent {
/** Entropy analysis data */
readonly analysis = input.required<EntropyAnalysis>();
/** Emits when user wants to view raw report */
readonly viewReport = output<void>();
/** Emits when user clicks on a layer */
readonly selectLayer = output<string>();
/** Emits when user clicks on a file */
readonly selectFile = output<string>();
readonly riskClass = computed(() => 'risk-' + this.analysis().riskLevel);
readonly scoreDescription = computed(() => {
const score = this.analysis().overallScore;
if (score <= 2) return 'Minimal entropy detected';
if (score <= 4) return 'Normal entropy levels';
if (score <= 6) return 'Elevated entropy - review recommended';
if (score <= 8) return 'High entropy - potential secrets detected';
return 'Critical entropy - likely obfuscated content';
});
readonly layerDonutData = computed(() => {
const layers = this.analysis().layers;
const total = layers.reduce((sum, l) => sum + l.riskContribution, 0);
return layers.map((layer, index) => ({
...layer,
percentage: total > 0 ? (layer.riskContribution / total) * 100 : 0,
startAngle: this.calculateStartAngle(layers, index, total),
color: this.getLayerColor(layer.riskContribution, total / layers.length),
}));
});
readonly topHighEntropyFiles = computed(() => {
return [...this.analysis().highEntropyFiles]
.sort((a, b) => b.entropy - a.entropy)
.slice(0, 10);
});
readonly detectorHintsByType = computed(() => {
const hints = this.analysis().detectorHints;
const byType = new Map<string, DetectorHint[]>();
for (const hint of hints) {
const existing = byType.get(hint.type) || [];
existing.push(hint);
byType.set(hint.type, existing);
}
return Array.from(byType.entries()).map(([type, items]) => ({
type,
items,
count: items.length,
maxSeverity: this.getMaxSeverity(items),
}));
});
private calculateStartAngle(
layers: LayerEntropy[],
index: number,
total: number
): number {
let angle = 0;
for (let i = 0; i < index; i++) {
angle += total > 0 ? (layers[i].riskContribution / total) * 360 : 0;
}
return angle;
}
private getLayerColor(contribution: number, avg: number): string {
const ratio = contribution / (avg || 1);
if (ratio > 2) return 'var(--color-entropy-critical, #dc2626)';
if (ratio > 1.5) return 'var(--color-entropy-high, #ea580c)';
if (ratio > 1) return 'var(--color-entropy-medium, #d97706)';
return 'var(--color-entropy-low, #65a30d)';
}
private getMaxSeverity(hints: DetectorHint[]): string {
const severityOrder = ['critical', 'high', 'medium', 'low'];
for (const severity of severityOrder) {
if (hints.some((h) => h.severity === severity)) {
return severity;
}
}
return 'low';
}
getEntropyBarWidth(entropy: number): string {
// Entropy is 0-8 bits, normalize to percentage
return Math.min(entropy / 8 * 100, 100) + '%';
}
getEntropyClass(entropy: number): string {
if (entropy >= 7) return 'entropy-critical';
if (entropy >= 6) return 'entropy-high';
if (entropy >= 4.5) return 'entropy-medium';
return 'entropy-low';
}
getClassificationIcon(classification: HighEntropyFile['classification']): string {
switch (classification) {
case 'encrypted':
return '🔐';
case 'compressed':
return '📦';
case 'binary':
return '⚙️';
case 'suspicious':
return '⚠️';
default:
return '❓';
}
}
getHintTypeIcon(type: DetectorHint['type']): string {
switch (type) {
case 'credential':
return '🔑';
case 'key':
return '🔏';
case 'token':
return '🎫';
case 'obfuscated':
return '🎭';
case 'packed':
return '📦';
case 'crypto':
return '🔐';
default:
return '❓';
}
}
formatBytes(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
formatPath(path: string, maxLength = 40): string {
if (path.length <= maxLength) return path;
return '...' + path.slice(-maxLength + 3);
}
onViewReport(): void {
this.viewReport.emit();
}
onSelectLayer(digest: string): void {
this.selectLayer.emit(digest);
}
onSelectFile(path: string): void {
this.selectFile.emit(path);
}
}

View File

@@ -0,0 +1,200 @@
<div class="entropy-policy-banner" [class]="bannerClass()">
<!-- Main Banner -->
<div class="banner-main">
<span class="banner-icon">{{ bannerIcon() }}</span>
<div class="banner-content">
<h4 class="banner-title">{{ bannerTitle() }}</h4>
<p class="banner-message">{{ bannerMessage() }}</p>
</div>
<div class="banner-actions">
@if (config().reportUrl) {
<button class="btn-secondary" (click)="onDownloadReport()">
Download Report
</button>
}
<button class="btn-secondary" (click)="onViewAnalysis()">
View Analysis
</button>
@if (config().action !== 'allow') {
<button
class="btn-expand"
(click)="toggleExpanded()"
[attr.aria-expanded]="expanded()"
>
{{ expanded() ? 'Hide' : 'Show' }} Details
</button>
}
</div>
</div>
<!-- Score Visualization -->
<div class="score-visualization">
<div class="score-bar">
<div class="score-track">
<!-- Zone backgrounds -->
<div class="zone allow" [style.width]="warnPercentage() + '%'"></div>
<div
class="zone warn"
[style.left]="warnPercentage() + '%'"
[style.width]="(blockPercentage() - warnPercentage()) + '%'"
></div>
<div
class="zone block"
[style.left]="blockPercentage() + '%'"
[style.width]="(100 - blockPercentage()) + '%'"
></div>
<!-- Threshold markers -->
<div class="threshold-line warn" [style.left]="warnPercentage() + '%'">
<span class="threshold-label">Warn</span>
</div>
<div class="threshold-line block" [style.left]="blockPercentage() + '%'">
<span class="threshold-label">Block</span>
</div>
<!-- Current score marker -->
<div
class="score-marker"
[style.left]="scorePercentage() + '%'"
[class]="'action-' + config().action"
>
<span class="score-value">{{ config().currentScore | number:'1.1-1' }}</span>
</div>
</div>
<div class="scale-labels">
<span>0</span>
<span>2</span>
<span>4</span>
<span>6</span>
<span>8</span>
<span>10</span>
</div>
</div>
<div class="score-legend">
<span class="legend-item allow">
<span class="legend-dot"></span>
Allow (&lt; {{ config().warnThreshold }})
</span>
<span class="legend-item warn">
<span class="legend-dot"></span>
Warn ({{ config().warnThreshold }} - {{ config().blockThreshold }})
</span>
<span class="legend-item block">
<span class="legend-dot"></span>
Block (&gt; {{ config().blockThreshold }})
</span>
</div>
</div>
<!-- Expanded Details -->
@if (expanded()) {
<div class="banner-details">
<!-- Policy Info -->
<div class="policy-info">
<h5 class="section-title">Policy Information</h5>
<dl class="info-grid">
<div class="info-item">
<dt>Policy</dt>
<dd>{{ config().policyName }}</dd>
</div>
<div class="info-item">
<dt>Policy ID</dt>
<dd><code>{{ config().policyId }}</code></dd>
</div>
<div class="info-item">
<dt>Warn Threshold</dt>
<dd>{{ config().warnThreshold }} / 10</dd>
</div>
<div class="info-item">
<dt>Block Threshold</dt>
<dd>{{ config().blockThreshold }} / 10</dd>
</div>
<div class="info-item">
<dt>High Entropy Files</dt>
<dd>{{ config().highEntropyFileCount }} files</dd>
</div>
</dl>
</div>
<!-- Threshold Explanation -->
<div class="threshold-explanation">
<h5 class="section-title">Understanding Entropy Thresholds</h5>
<div class="explanation-content">
<p>
<strong>Entropy</strong> measures the randomness of data in files. High entropy often indicates:
</p>
<ul class="entropy-indicators">
<li><span class="indicator-icon">[E]</span> Encrypted content</li>
<li><span class="indicator-icon">[C]</span> Compressed files (zip, gz, etc.)</li>
<li><span class="indicator-icon">[B]</span> Binary executables</li>
<li><span class="indicator-icon">[S]</span> Potential secrets or credentials</li>
<li><span class="indicator-icon">[O]</span> Obfuscated or packed code</li>
</ul>
<p class="explanation-note">
While some high-entropy content is legitimate (fonts, images, compressed assets),
unexpected high entropy may indicate security concerns requiring review.
</p>
</div>
</div>
<!-- Mitigation Steps -->
@if (config().action !== 'allow') {
<div class="mitigation-section">
<h5 class="section-title">Mitigation Steps</h5>
<div class="mitigation-list">
@for (step of effectiveMitigationSteps(); track step.id) {
<div class="mitigation-card">
<div class="mitigation-header">
<span class="mitigation-title">{{ step.title }}</span>
<div class="mitigation-badges">
<span class="badge impact" [class]="'impact-' + step.impact">
{{ getImpactLabel(step.impact) }}
</span>
<span class="badge effort">{{ getEffortLabel(step.effort) }}</span>
</div>
</div>
<p class="mitigation-desc">{{ step.description }}</p>
@if (step.command) {
<div class="mitigation-command">
<code>{{ step.command }}</code>
<button class="btn-run" (click)="onRunMitigation(step)">
Run
</button>
</div>
}
@if (step.docsUrl) {
<a class="docs-link" [href]="step.docsUrl" target="_blank">
Learn more ->
</a>
}
</div>
}
</div>
</div>
}
<!-- Report Download -->
@if (config().reportUrl) {
<div class="report-section">
<h5 class="section-title">Raw Evidence</h5>
<div class="report-info">
<span class="report-label">entropy.report.json</span>
<p class="report-desc">
Download the full entropy analysis report containing per-file entropy scores,
detector findings, and detailed metrics.
</p>
<button class="btn-download" (click)="onDownloadReport()">
Download entropy.report.json
</button>
</div>
</div>
}
</div>
}
</div>

View File

@@ -0,0 +1,489 @@
.entropy-policy-banner {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
background: var(--color-bg-card, white);
overflow: hidden;
&.action-allow {
border-color: var(--color-success-border, #a7f3d0);
.banner-main {
background: var(--color-success-bg, #ecfdf5);
border-left: 4px solid var(--color-success, #059669);
}
.banner-icon {
color: var(--color-success, #059669);
}
}
&.action-warn {
border-color: var(--color-warning-border, #fde68a);
.banner-main {
background: var(--color-warning-bg, #fffbeb);
border-left: 4px solid var(--color-warning, #d97706);
}
.banner-icon {
color: var(--color-warning, #d97706);
}
}
&.action-block {
border-color: var(--color-error-border, #fecaca);
.banner-main {
background: var(--color-error-bg, #fef2f2);
border-left: 4px solid var(--color-error, #dc2626);
}
.banner-icon {
color: var(--color-error, #dc2626);
}
}
}
.banner-main {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
flex-wrap: wrap;
}
.banner-icon {
font-family: monospace;
font-weight: 700;
font-size: 1.125rem;
}
.banner-content {
flex: 1;
min-width: 200px;
}
.banner-title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text, #111827);
}
.banner-message {
margin: 0.25rem 0 0;
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
}
.banner-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn-secondary {
padding: 0.375rem 0.75rem;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-size: 0.8125rem;
cursor: pointer;
color: var(--color-text, #374151);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
}
.btn-expand {
padding: 0.375rem 0.75rem;
background: transparent;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-size: 0.8125rem;
cursor: pointer;
color: var(--color-primary, #2563eb);
&:hover {
background: var(--color-primary-bg, #eff6ff);
}
}
// Score Visualization
.score-visualization {
padding: 0.75rem 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.score-bar {
margin-bottom: 0.5rem;
}
.score-track {
position: relative;
height: 24px;
background: var(--color-bg-subtle, #f3f4f6);
border-radius: 4px;
overflow: visible;
}
.zone {
position: absolute;
top: 0;
height: 100%;
&.allow {
background: var(--color-success-bg, #dcfce7);
border-radius: 4px 0 0 4px;
}
&.warn {
background: var(--color-warning-bg, #fef3c7);
}
&.block {
background: var(--color-error-bg, #fee2e2);
border-radius: 0 4px 4px 0;
}
}
.threshold-line {
position: absolute;
top: 0;
width: 2px;
height: 100%;
background: currentColor;
transform: translateX(-1px);
&.warn {
color: var(--color-warning, #d97706);
}
&.block {
color: var(--color-error, #dc2626);
}
.threshold-label {
position: absolute;
top: -18px;
left: 50%;
transform: translateX(-50%);
font-size: 0.625rem;
font-weight: 600;
white-space: nowrap;
}
}
.score-marker {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
z-index: 1;
&.action-allow {
.score-value {
background: var(--color-success, #059669);
}
}
&.action-warn {
.score-value {
background: var(--color-warning, #d97706);
}
}
&.action-block {
.score-value {
background: var(--color-error, #dc2626);
}
}
}
.score-value {
display: block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 700;
color: white;
white-space: nowrap;
}
.scale-labels {
display: flex;
justify-content: space-between;
font-size: 0.625rem;
color: var(--color-text-muted, #9ca3af);
padding: 0 2px;
}
.score-legend {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--color-text-muted, #6b7280);
&.allow .legend-dot { background: var(--color-success, #059669); }
&.warn .legend-dot { background: var(--color-warning, #d97706); }
&.block .legend-dot { background: var(--color-error, #dc2626); }
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 2px;
}
// Expanded Details
.banner-details {
border-top: 1px solid var(--color-border, #e5e7eb);
padding: 1rem;
background: var(--color-bg-subtle, #f9fafb);
}
.section-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #6b7280);
margin: 0 0 0.75rem;
}
.policy-info,
.threshold-explanation,
.mitigation-section,
.report-section {
margin-bottom: 1.25rem;
&:last-child {
margin-bottom: 0;
}
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.75rem;
margin: 0;
}
.info-item {
dt {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
margin-bottom: 0.125rem;
}
dd {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text, #374151);
code {
font-size: 0.75rem;
background: var(--color-bg-code, #f3f4f6);
padding: 0.125rem 0.25rem;
border-radius: 2px;
}
}
}
.explanation-content {
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.75rem;
p {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
color: var(--color-text, #374151);
}
}
.entropy-indicators {
list-style: none;
padding: 0;
margin: 0 0 0.5rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.25rem;
li {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
}
}
.indicator-icon {
font-family: monospace;
font-weight: 600;
font-size: 0.6875rem;
color: var(--color-text-muted, #6b7280);
}
.explanation-note {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
font-style: italic;
margin-bottom: 0;
}
.mitigation-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.mitigation-card {
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.75rem;
}
.mitigation-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.mitigation-title {
font-weight: 600;
font-size: 0.875rem;
color: var(--color-text, #374151);
}
.mitigation-badges {
display: flex;
gap: 0.375rem;
}
.badge {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
border-radius: 10px;
font-weight: 500;
&.impact {
&.impact-high {
background: var(--color-success-bg, #ecfdf5);
color: var(--color-success, #059669);
}
&.impact-medium {
background: var(--color-info-bg, #f0f9ff);
color: var(--color-info, #0284c7);
}
&.impact-low {
background: var(--color-bg-subtle, #f3f4f6);
color: var(--color-text-muted, #6b7280);
}
}
&.effort {
background: var(--color-bg-subtle, #f3f4f6);
color: var(--color-text-muted, #6b7280);
}
}
.mitigation-desc {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
}
.mitigation-command {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
padding: 0.5rem;
background: var(--color-bg-code, #1f2937);
border-radius: 4px;
code {
flex: 1;
font-size: 0.75rem;
color: #e5e7eb;
white-space: nowrap;
overflow-x: auto;
}
.btn-run {
padding: 0.25rem 0.5rem;
background: var(--color-primary, #2563eb);
border: none;
border-radius: 3px;
font-size: 0.6875rem;
color: white;
cursor: pointer;
flex-shrink: 0;
&:hover {
background: var(--color-primary-dark, #1d4ed8);
}
}
}
.docs-link {
font-size: 0.75rem;
color: var(--color-primary, #2563eb);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.report-info {
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.75rem;
}
.report-label {
font-family: monospace;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text, #374151);
}
.report-desc {
margin: 0.5rem 0;
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
}
.btn-download {
padding: 0.5rem 1rem;
background: var(--color-primary, #2563eb);
border: none;
border-radius: 4px;
font-size: 0.8125rem;
font-weight: 500;
color: white;
cursor: pointer;
&:hover {
background: var(--color-primary-dark, #1d4ed8);
}
}

View File

@@ -0,0 +1,215 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
export interface EntropyPolicyConfig {
/** Warn threshold (0-10) */
warnThreshold: number;
/** Block threshold (0-10) */
blockThreshold: number;
/** Current entropy score */
currentScore: number;
/** Action taken */
action: 'allow' | 'warn' | 'block';
/** Policy ID */
policyId: string;
/** Policy name */
policyName: string;
/** High entropy file count */
highEntropyFileCount: number;
/** Link to entropy report */
reportUrl?: string;
}
export interface EntropyMitigationStep {
id: string;
title: string;
description: string;
impact: 'high' | 'medium' | 'low';
effort: 'trivial' | 'easy' | 'moderate' | 'complex';
command?: string;
docsUrl?: string;
}
@Component({
selector: 'app-entropy-policy-banner',
standalone: true,
imports: [CommonModule],
templateUrl: './entropy-policy-banner.component.html',
styleUrls: ['./entropy-policy-banner.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EntropyPolicyBannerComponent {
/** Policy configuration and current state */
readonly config = input.required<EntropyPolicyConfig>();
/** Custom mitigation steps */
readonly mitigationSteps = input<EntropyMitigationStep[]>([]);
/** Emits when user wants to download entropy report */
readonly downloadReport = output<string>();
/** Emits when user runs a mitigation command */
readonly runMitigation = output<EntropyMitigationStep>();
/** Emits when user wants to view detailed analysis */
readonly viewAnalysis = output<void>();
/** Show expanded details */
readonly expanded = signal(false);
readonly bannerClass = computed(() => 'action-' + this.config().action);
readonly bannerIcon = computed(() => {
switch (this.config().action) {
case 'allow':
return '[OK]';
case 'warn':
return '[!]';
case 'block':
return '[X]';
default:
return '[?]';
}
});
readonly bannerTitle = computed(() => {
switch (this.config().action) {
case 'allow':
return 'Entropy Check Passed';
case 'warn':
return 'Entropy Warning';
case 'block':
return 'Entropy Block';
default:
return 'Entropy Status Unknown';
}
});
readonly bannerMessage = computed(() => {
const cfg = this.config();
switch (cfg.action) {
case 'allow':
return 'Entropy score ' + cfg.currentScore.toFixed(1) + ' is within acceptable limits.';
case 'warn':
return 'Entropy score ' + cfg.currentScore.toFixed(1) + ' exceeds warning threshold (' + cfg.warnThreshold + '). Review recommended.';
case 'block':
return 'Entropy score ' + cfg.currentScore.toFixed(1) + ' exceeds block threshold (' + cfg.blockThreshold + '). Publication blocked.';
default:
return '';
}
});
readonly scorePercentage = computed(() =>
(this.config().currentScore / 10) * 100
);
readonly warnPercentage = computed(() =>
(this.config().warnThreshold / 10) * 100
);
readonly blockPercentage = computed(() =>
(this.config().blockThreshold / 10) * 100
);
readonly defaultMitigationSteps: EntropyMitigationStep[] = [
{
id: 'review-files',
title: 'Review High-Entropy Files',
description: 'Examine files flagged as high entropy to identify false positives or legitimate concerns.',
impact: 'high',
effort: 'easy',
docsUrl: '/docs/security/entropy-analysis',
},
{
id: 'exclude-known',
title: 'Exclude Known Binary Artifacts',
description: 'Add exclusion patterns for legitimate compressed files, fonts, or compiled assets.',
impact: 'medium',
effort: 'trivial',
command: 'stella policy entropy exclude --pattern "*.woff2" --pattern "*.gz"',
},
{
id: 'investigate-secrets',
title: 'Investigate Potential Secrets',
description: 'Check if high-entropy content contains accidentally committed secrets or credentials.',
impact: 'high',
effort: 'moderate',
command: 'stella scan secrets --image $IMAGE_REF',
docsUrl: '/docs/security/secret-detection',
},
{
id: 'adjust-threshold',
title: 'Adjust Policy Thresholds',
description: 'If false positives are common, consider adjusting warn/block thresholds for this policy.',
impact: 'medium',
effort: 'easy',
command: 'stella policy entropy set-threshold --policy $POLICY_ID --warn 7.0 --block 8.5',
},
];
readonly effectiveMitigationSteps = computed(() => {
const custom = this.mitigationSteps();
return custom.length > 0 ? custom : this.defaultMitigationSteps;
});
toggleExpanded(): void {
this.expanded.update((v) => !v);
}
onDownloadReport(): void {
const url = this.config().reportUrl;
if (url) {
this.downloadReport.emit(url);
}
}
onViewAnalysis(): void {
this.viewAnalysis.emit();
}
onRunMitigation(step: EntropyMitigationStep): void {
this.runMitigation.emit(step);
}
getImpactLabel(impact: string): string {
switch (impact) {
case 'high':
return 'High Impact';
case 'medium':
return 'Medium Impact';
case 'low':
return 'Low Impact';
default:
return '';
}
}
getEffortLabel(effort: string): string {
switch (effort) {
case 'trivial':
return '< 5 min';
case 'easy':
return '5-15 min';
case 'moderate':
return '15-60 min';
case 'complex':
return '> 1 hour';
default:
return '';
}
}
}

View File

@@ -1,404 +1,404 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
computed,
signal,
} from '@angular/core';
export interface ExceptionBadgeData {
readonly exceptionId: string;
readonly status: 'draft' | 'pending_review' | 'approved' | 'rejected' | 'expired' | 'revoked';
readonly severity: 'critical' | 'high' | 'medium' | 'low';
readonly name: string;
readonly endDate: string;
readonly justificationSummary?: string;
readonly approvedBy?: string;
}
@Component({
selector: 'app-exception-badge',
standalone: true,
imports: [CommonModule],
template: `
<div
class="exception-badge"
[class]="badgeClass()"
[class.exception-badge--expanded]="expanded()"
(click)="toggleExpanded()"
(keydown.enter)="toggleExpanded()"
(keydown.space)="toggleExpanded(); $event.preventDefault()"
tabindex="0"
role="button"
[attr.aria-expanded]="expanded()"
[attr.aria-label]="ariaLabel()"
>
<!-- Collapsed View -->
<div class="exception-badge__summary">
<span class="exception-badge__icon">✓</span>
<span class="exception-badge__label">Excepted</span>
<span class="exception-badge__countdown" *ngIf="isExpiringSoon() && countdownText()">
{{ countdownText() }}
</span>
</div>
<!-- Expanded View -->
<div class="exception-badge__details" *ngIf="expanded()">
<div class="exception-badge__header">
<span class="exception-badge__name">{{ data.name }}</span>
<span class="exception-badge__status exception-badge__status--{{ data.status }}">
{{ statusLabel() }}
</span>
</div>
<div class="exception-badge__info">
<div class="exception-badge__row">
<span class="exception-badge__row-label">Severity:</span>
<span class="exception-badge__severity exception-badge__severity--{{ data.severity }}">
{{ data.severity }}
</span>
</div>
<div class="exception-badge__row">
<span class="exception-badge__row-label">Expires:</span>
<span class="exception-badge__expiry" [class.exception-badge__expiry--soon]="isExpiringSoon()">
{{ formatDate(data.endDate) }}
</span>
</div>
<div class="exception-badge__row" *ngIf="data.approvedBy">
<span class="exception-badge__row-label">Approved by:</span>
<span>{{ data.approvedBy }}</span>
</div>
</div>
<div class="exception-badge__justification" *ngIf="data.justificationSummary">
<span class="exception-badge__justification-label">Justification:</span>
<p>{{ data.justificationSummary }}</p>
</div>
<div class="exception-badge__actions">
<button
type="button"
class="exception-badge__action"
(click)="viewDetails.emit(data.exceptionId); $event.stopPropagation()"
>
View Details
</button>
<button
type="button"
class="exception-badge__action exception-badge__action--secondary"
(click)="explain.emit(data.exceptionId); $event.stopPropagation()"
>
Explain
</button>
</div>
</div>
</div>
`,
styles: [`
.exception-badge {
display: inline-flex;
flex-direction: column;
background: #f3e8ff;
border: 1px solid #c4b5fd;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.8125rem;
&:hover {
background: #ede9fe;
border-color: #a78bfa;
}
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.3);
}
&--expanded {
min-width: 280px;
}
&--expired {
background: #f1f5f9;
border-color: #cbd5e1;
}
}
.exception-badge__summary {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
}
.exception-badge__icon {
color: #7c3aed;
font-weight: bold;
}
.exception-badge__label {
color: #6d28d9;
font-weight: 500;
}
.exception-badge__countdown {
padding: 0.125rem 0.375rem;
background: #fef3c7;
color: #92400e;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
}
.exception-badge__details {
padding: 0.75rem;
border-top: 1px solid #c4b5fd;
}
.exception-badge__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.exception-badge__name {
font-weight: 600;
color: #1e293b;
}
.exception-badge__status {
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 500;
&--approved {
background: #dcfce7;
color: #166534;
}
&--pending_review {
background: #fef3c7;
color: #92400e;
}
&--draft {
background: #f1f5f9;
color: #475569;
}
&--expired {
background: #f1f5f9;
color: #6b7280;
}
&--revoked {
background: #fce7f3;
color: #9d174d;
}
}
.exception-badge__info {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-bottom: 0.5rem;
}
.exception-badge__row {
display: flex;
gap: 0.5rem;
font-size: 0.75rem;
}
.exception-badge__row-label {
color: #64748b;
min-width: 80px;
}
.exception-badge__severity {
padding: 0.0625rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
text-transform: capitalize;
&--critical {
background: #fef2f2;
color: #dc2626;
}
&--high {
background: #fff7ed;
color: #ea580c;
}
&--medium {
background: #fefce8;
color: #ca8a04;
}
&--low {
background: #f0fdf4;
color: #16a34a;
}
}
.exception-badge__expiry {
color: #1e293b;
&--soon {
color: #dc2626;
font-weight: 500;
}
}
.exception-badge__justification {
margin-bottom: 0.5rem;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.5);
border-radius: 0.25rem;
}
.exception-badge__justification-label {
display: block;
font-size: 0.6875rem;
color: #64748b;
margin-bottom: 0.25rem;
}
.exception-badge__justification p {
margin: 0;
font-size: 0.75rem;
color: #475569;
line-height: 1.4;
}
.exception-badge__actions {
display: flex;
gap: 0.5rem;
}
.exception-badge__action {
flex: 1;
padding: 0.375rem 0.5rem;
border: none;
border-radius: 0.25rem;
background: #7c3aed;
color: white;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: #6d28d9;
}
&--secondary {
background: white;
color: #7c3aed;
border: 1px solid #c4b5fd;
&:hover {
background: #f3e8ff;
}
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExceptionBadgeComponent implements OnInit, OnDestroy {
@Input({ required: true }) data!: ExceptionBadgeData;
@Input() compact = false;
@Output() readonly viewDetails = new EventEmitter<string>();
@Output() readonly explain = new EventEmitter<string>();
readonly expanded = signal(false);
private countdownInterval?: ReturnType<typeof setInterval>;
private readonly now = signal(new Date());
readonly countdownText = computed(() => {
const endDate = new Date(this.data.endDate);
const current = this.now();
const diffMs = endDate.getTime() - current.getTime();
if (diffMs <= 0) return 'Expired';
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h`;
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
return `${minutes}m`;
});
readonly isExpiringSoon = computed(() => {
const endDate = new Date(this.data.endDate);
const current = this.now();
const sevenDays = 7 * 24 * 60 * 60 * 1000;
return endDate.getTime() - current.getTime() < sevenDays && endDate > current;
});
readonly badgeClass = computed(() => {
const classes = ['exception-badge'];
if (this.data.status === 'expired') classes.push('exception-badge--expired');
return classes.join(' ');
});
readonly statusLabel = computed(() => {
const labels: Record<string, string> = {
draft: 'Draft',
pending_review: 'Pending',
approved: 'Approved',
rejected: 'Rejected',
expired: 'Expired',
revoked: 'Revoked',
};
return labels[this.data.status] || this.data.status;
});
readonly ariaLabel = computed(() => {
return `Exception: ${this.data.name}, status: ${this.statusLabel()}, ${this.expanded() ? 'expanded' : 'collapsed'}`;
});
ngOnInit(): void {
if (this.isExpiringSoon()) {
this.countdownInterval = setInterval(() => {
this.now.set(new Date());
}, 60000); // Update every minute
}
}
ngOnDestroy(): void {
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
}
}
toggleExpanded(): void {
if (!this.compact) {
this.expanded.set(!this.expanded());
}
}
formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
}
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
computed,
signal,
} from '@angular/core';
export interface ExceptionBadgeData {
readonly exceptionId: string;
readonly status: 'draft' | 'pending_review' | 'approved' | 'rejected' | 'expired' | 'revoked';
readonly severity: 'critical' | 'high' | 'medium' | 'low';
readonly name: string;
readonly endDate: string;
readonly justificationSummary?: string;
readonly approvedBy?: string;
}
@Component({
selector: 'app-exception-badge',
standalone: true,
imports: [CommonModule],
template: `
<div
class="exception-badge"
[class]="badgeClass()"
[class.exception-badge--expanded]="expanded()"
(click)="toggleExpanded()"
(keydown.enter)="toggleExpanded()"
(keydown.space)="toggleExpanded(); $event.preventDefault()"
tabindex="0"
role="button"
[attr.aria-expanded]="expanded()"
[attr.aria-label]="ariaLabel()"
>
<!-- Collapsed View -->
<div class="exception-badge__summary">
<span class="exception-badge__icon">✓</span>
<span class="exception-badge__label">Excepted</span>
<span class="exception-badge__countdown" *ngIf="isExpiringSoon() && countdownText()">
{{ countdownText() }}
</span>
</div>
<!-- Expanded View -->
<div class="exception-badge__details" *ngIf="expanded()">
<div class="exception-badge__header">
<span class="exception-badge__name">{{ data.name }}</span>
<span class="exception-badge__status exception-badge__status--{{ data.status }}">
{{ statusLabel() }}
</span>
</div>
<div class="exception-badge__info">
<div class="exception-badge__row">
<span class="exception-badge__row-label">Severity:</span>
<span class="exception-badge__severity exception-badge__severity--{{ data.severity }}">
{{ data.severity }}
</span>
</div>
<div class="exception-badge__row">
<span class="exception-badge__row-label">Expires:</span>
<span class="exception-badge__expiry" [class.exception-badge__expiry--soon]="isExpiringSoon()">
{{ formatDate(data.endDate) }}
</span>
</div>
<div class="exception-badge__row" *ngIf="data.approvedBy">
<span class="exception-badge__row-label">Approved by:</span>
<span>{{ data.approvedBy }}</span>
</div>
</div>
<div class="exception-badge__justification" *ngIf="data.justificationSummary">
<span class="exception-badge__justification-label">Justification:</span>
<p>{{ data.justificationSummary }}</p>
</div>
<div class="exception-badge__actions">
<button
type="button"
class="exception-badge__action"
(click)="viewDetails.emit(data.exceptionId); $event.stopPropagation()"
>
View Details
</button>
<button
type="button"
class="exception-badge__action exception-badge__action--secondary"
(click)="explain.emit(data.exceptionId); $event.stopPropagation()"
>
Explain
</button>
</div>
</div>
</div>
`,
styles: [`
.exception-badge {
display: inline-flex;
flex-direction: column;
background: #f3e8ff;
border: 1px solid #c4b5fd;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.8125rem;
&:hover {
background: #ede9fe;
border-color: #a78bfa;
}
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.3);
}
&--expanded {
min-width: 280px;
}
&--expired {
background: #f1f5f9;
border-color: #cbd5e1;
}
}
.exception-badge__summary {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
}
.exception-badge__icon {
color: #7c3aed;
font-weight: bold;
}
.exception-badge__label {
color: #6d28d9;
font-weight: 500;
}
.exception-badge__countdown {
padding: 0.125rem 0.375rem;
background: #fef3c7;
color: #92400e;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
}
.exception-badge__details {
padding: 0.75rem;
border-top: 1px solid #c4b5fd;
}
.exception-badge__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.exception-badge__name {
font-weight: 600;
color: #1e293b;
}
.exception-badge__status {
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 500;
&--approved {
background: #dcfce7;
color: #166534;
}
&--pending_review {
background: #fef3c7;
color: #92400e;
}
&--draft {
background: #f1f5f9;
color: #475569;
}
&--expired {
background: #f1f5f9;
color: #6b7280;
}
&--revoked {
background: #fce7f3;
color: #9d174d;
}
}
.exception-badge__info {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-bottom: 0.5rem;
}
.exception-badge__row {
display: flex;
gap: 0.5rem;
font-size: 0.75rem;
}
.exception-badge__row-label {
color: #64748b;
min-width: 80px;
}
.exception-badge__severity {
padding: 0.0625rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
text-transform: capitalize;
&--critical {
background: #fef2f2;
color: #dc2626;
}
&--high {
background: #fff7ed;
color: #ea580c;
}
&--medium {
background: #fefce8;
color: #ca8a04;
}
&--low {
background: #f0fdf4;
color: #16a34a;
}
}
.exception-badge__expiry {
color: #1e293b;
&--soon {
color: #dc2626;
font-weight: 500;
}
}
.exception-badge__justification {
margin-bottom: 0.5rem;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.5);
border-radius: 0.25rem;
}
.exception-badge__justification-label {
display: block;
font-size: 0.6875rem;
color: #64748b;
margin-bottom: 0.25rem;
}
.exception-badge__justification p {
margin: 0;
font-size: 0.75rem;
color: #475569;
line-height: 1.4;
}
.exception-badge__actions {
display: flex;
gap: 0.5rem;
}
.exception-badge__action {
flex: 1;
padding: 0.375rem 0.5rem;
border: none;
border-radius: 0.25rem;
background: #7c3aed;
color: white;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: #6d28d9;
}
&--secondary {
background: white;
color: #7c3aed;
border: 1px solid #c4b5fd;
&:hover {
background: #f3e8ff;
}
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExceptionBadgeComponent implements OnInit, OnDestroy {
@Input({ required: true }) data!: ExceptionBadgeData;
@Input() compact = false;
@Output() readonly viewDetails = new EventEmitter<string>();
@Output() readonly explain = new EventEmitter<string>();
readonly expanded = signal(false);
private countdownInterval?: ReturnType<typeof setInterval>;
private readonly now = signal(new Date());
readonly countdownText = computed(() => {
const endDate = new Date(this.data.endDate);
const current = this.now();
const diffMs = endDate.getTime() - current.getTime();
if (diffMs <= 0) return 'Expired';
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h`;
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
return `${minutes}m`;
});
readonly isExpiringSoon = computed(() => {
const endDate = new Date(this.data.endDate);
const current = this.now();
const sevenDays = 7 * 24 * 60 * 60 * 1000;
return endDate.getTime() - current.getTime() < sevenDays && endDate > current;
});
readonly badgeClass = computed(() => {
const classes = ['exception-badge'];
if (this.data.status === 'expired') classes.push('exception-badge--expired');
return classes.join(' ');
});
readonly statusLabel = computed(() => {
const labels: Record<string, string> = {
draft: 'Draft',
pending_review: 'Pending',
approved: 'Approved',
rejected: 'Rejected',
expired: 'Expired',
revoked: 'Revoked',
};
return labels[this.data.status] || this.data.status;
});
readonly ariaLabel = computed(() => {
return `Exception: ${this.data.name}, status: ${this.statusLabel()}, ${this.expanded() ? 'expanded' : 'collapsed'}`;
});
ngOnInit(): void {
if (this.isExpiringSoon()) {
this.countdownInterval = setInterval(() => {
this.now.set(new Date());
}, 60000); // Update every minute
}
}
ngOnDestroy(): void {
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
}
}
toggleExpanded(): void {
if (!this.compact) {
this.expanded.set(!this.expanded());
}
}
formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
}

View File

@@ -1,2 +1,2 @@
export { ExceptionBadgeComponent, ExceptionBadgeData } from './exception-badge.component';
export { ExceptionExplainComponent, ExceptionExplainData } from './exception-explain.component';
export { ExceptionBadgeComponent, ExceptionBadgeData } from './exception-badge.component';
export { ExceptionExplainComponent, ExceptionExplainData } from './exception-explain.component';

View File

@@ -0,0 +1,277 @@
<div class="policy-gate-indicator" [class]="statusClass()" [class.compact]="compact()">
<!-- Status Banner -->
<div class="status-banner">
<span class="status-icon">{{ statusIcon() }}</span>
<div class="status-info">
<span class="status-label">{{ statusLabel() }}</span>
<span class="gate-summary">
{{ passedGates().length }}/{{ gateStatus().gates.length }} gates passed
@if (warningGates().length > 0) {
<span class="warning-count">({{ warningGates().length }} warnings)</span>
}
</span>
</div>
<div class="status-actions">
@if (!gateStatus().canPublish && gateStatus().remediationHints.length > 0) {
<button class="btn-remediation" (click)="toggleRemediation()">
{{ showRemediation() ? 'Hide' : 'Show' }} Fixes
</button>
}
<button
class="btn-publish"
[disabled]="!gateStatus().canPublish"
(click)="onPublish()"
[title]="gateStatus().blockReason || 'Publish artifact'"
>
@if (gateStatus().canPublish) {
Publish
} @else {
Blocked
}
</button>
</div>
</div>
<!-- Block Reason Banner -->
@if (!gateStatus().canPublish && gateStatus().blockReason) {
<div class="block-banner">
<span class="block-icon">[!]</span>
<span class="block-message">{{ gateStatus().blockReason }}</span>
</div>
}
<!-- Gate List -->
@if (!compact()) {
<div class="gates-list">
@for (gate of gateStatus().gates; track gate.gateId) {
<div class="gate-item" [class]="'result-' + gate.result">
<button
class="gate-header"
(click)="toggleGate(gate.gateId)"
[attr.aria-expanded]="expandedGate() === gate.gateId"
>
<span class="gate-type-icon">{{ getGateIcon(gate.type) }}</span>
<span class="result-icon">{{ getResultIcon(gate.result) }}</span>
<div class="gate-info">
<span class="gate-name">{{ gate.name }}</span>
@if (gate.required) {
<span class="required-badge">Required</span>
}
</div>
<span class="expand-icon" [class.expanded]="expandedGate() === gate.gateId">v</span>
</button>
@if (expandedGate() === gate.gateId) {
<div class="gate-details">
<!-- Determinism Gate Details -->
@if (gate.type === 'determinism' && getDeterminismDetails(gate)) {
@let det = getDeterminismDetails(gate)!;
<div class="determinism-details">
<div class="detail-row">
<span class="detail-label">Merkle Root</span>
<span class="detail-value" [class.mismatch]="!det.merkleRootConsistent">
@if (det.merkleRootConsistent) {
<span class="match-icon">[+]</span> Consistent
} @else {
<span class="mismatch-icon">[x]</span> Mismatch
}
</span>
</div>
@if (!det.merkleRootConsistent) {
<div class="hash-comparison">
<div class="hash-row">
<span class="hash-label">Expected:</span>
<code class="hash">{{ formatHash(det.expectedMerkleRoot, 24) }}</code>
</div>
<div class="hash-row">
<span class="hash-label">Computed:</span>
<code class="hash mismatch">{{ formatHash(det.computedMerkleRoot, 24) }}</code>
</div>
</div>
}
<div class="detail-row">
<span class="detail-label">Fragments</span>
<span class="detail-value">
{{ det.matchingFragments }}/{{ det.totalFragments }} verified
</span>
</div>
<div class="detail-row">
<span class="detail-label">Composition File</span>
<span class="detail-value" [class.missing]="!det.compositionPresent">
{{ det.compositionPresent ? 'Present' : 'Missing' }}
</span>
</div>
@if (det.fragmentResults.length > 0) {
<details class="fragment-details">
<summary>Fragment Verification ({{ det.fragmentResults.length }})</summary>
<ul class="fragment-list">
@for (frag of det.fragmentResults.slice(0, 5); track frag.fragmentId) {
<li [class.mismatch]="!frag.match">
<span class="frag-id">{{ frag.fragmentId }}</span>
<span class="frag-status">{{ frag.match ? '+' : 'x' }}</span>
</li>
}
@if (det.fragmentResults.length > 5) {
<li class="more">+ {{ det.fragmentResults.length - 5 }} more</li>
}
</ul>
</details>
}
</div>
}
<!-- Entropy Gate Details -->
@if (gate.type === 'entropy' && getEntropyDetails(gate)) {
@let ent = getEntropyDetails(gate)!;
<div class="entropy-details">
<div class="detail-row">
<span class="detail-label">Entropy Score</span>
<span class="detail-value score" [class]="'action-' + ent.action">
{{ ent.entropyScore | number:'1.1-1' }} / 10
</span>
</div>
<div class="threshold-bar">
<div class="threshold-track">
<div class="threshold-marker warn" [style.left]="(ent.warnThreshold / 10 * 100) + '%'"></div>
<div class="threshold-marker block" [style.left]="(ent.blockThreshold / 10 * 100) + '%'"></div>
<div class="score-marker" [style.left]="(ent.entropyScore / 10 * 100) + '%'"></div>
</div>
<div class="threshold-labels">
<span>0</span>
<span class="warn-label">Warn ({{ ent.warnThreshold }})</span>
<span class="block-label">Block ({{ ent.blockThreshold }})</span>
<span>10</span>
</div>
</div>
<div class="detail-row">
<span class="detail-label">High Entropy Files</span>
<span class="detail-value">{{ ent.highEntropyFileCount }}</span>
</div>
@if (ent.suspiciousPatterns.length > 0) {
<div class="suspicious-patterns">
<span class="detail-label">Suspicious Patterns</span>
<ul class="pattern-list">
@for (pattern of ent.suspiciousPatterns; track pattern) {
<li>{{ pattern }}</li>
}
</ul>
</div>
}
</div>
}
<!-- Evidence Links -->
@if (gate.evidenceRefs && gate.evidenceRefs.length > 0) {
<div class="evidence-links">
<span class="evidence-label">Evidence:</span>
@for (ref of gate.evidenceRefs; track ref) {
<button class="evidence-link" (click)="onViewEvidence(ref)">
{{ ref | slice:0:20 }}...
</button>
}
</div>
}
<!-- Gate-specific Remediation -->
@if (gate.result === 'failed') {
@let hints = getHintsForGate(gate.gateId);
@if (hints.length > 0) {
<div class="gate-remediation">
<h5 class="remediation-title">How to Fix</h5>
@for (hint of hints; track hint.title) {
<div class="hint-card">
<div class="hint-header">
<span class="hint-title">{{ hint.title }}</span>
@if (hint.effort) {
<span class="effort-badge">{{ getEffortLabel(hint.effort) }}</span>
}
</div>
<ol class="hint-steps">
@for (step of hint.steps; track step) {
<li>{{ step }}</li>
}
</ol>
@if (hint.cliCommand) {
<div class="cli-command">
<code>{{ hint.cliCommand }}</code>
<button class="btn-run" (click)="onRunRemediation(hint)">Run</button>
</div>
}
@if (hint.docsUrl) {
<a class="docs-link" [href]="hint.docsUrl" target="_blank">
Documentation ->
</a>
}
</div>
}
</div>
}
}
</div>
}
</div>
}
</div>
}
<!-- Blocking Issues Summary -->
@if (gateStatus().blockingIssues.length > 0) {
<div class="blocking-issues">
<h4 class="issues-title">Blocking Issues ({{ gateStatus().blockingIssues.length }})</h4>
<ul class="issues-list">
@for (issue of gateStatus().blockingIssues; track issue.code) {
<li class="issue-item" [class]="'severity-' + issue.severity">
<span class="issue-code">{{ issue.code }}</span>
<span class="issue-message">{{ issue.message }}</span>
@if (issue.resource) {
<span class="issue-resource">{{ issue.resource }}</span>
}
</li>
}
</ul>
</div>
}
<!-- Remediation Panel -->
@if (showRemediation() && gateStatus().remediationHints.length > 0) {
<div class="remediation-panel">
<h4 class="remediation-panel-title">Remediation Steps</h4>
@for (hint of gateStatus().remediationHints; track hint.title) {
<div class="remediation-card">
<div class="remediation-header">
<span class="remediation-for">{{ hint.forGate }}</span>
<span class="remediation-title">{{ hint.title }}</span>
@if (hint.effort) {
<span class="effort-badge">{{ getEffortLabel(hint.effort) }}</span>
}
</div>
<ol class="remediation-steps">
@for (step of hint.steps; track step) {
<li>{{ step }}</li>
}
</ol>
@if (hint.cliCommand) {
<div class="cli-command">
<code>{{ hint.cliCommand }}</code>
<button class="btn-run" (click)="onRunRemediation(hint)">Run</button>
</div>
}
</div>
}
</div>
}
<!-- Footer -->
<footer class="indicator-footer">
<span class="eval-id">Evaluation: {{ gateStatus().evaluationId | slice:0:12 }}</span>
<span class="eval-time">{{ gateStatus().evaluatedAt | date:'medium' }}</span>
</footer>
</div>

View File

@@ -0,0 +1,673 @@
.policy-gate-indicator {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
background: var(--color-bg-card, white);
overflow: hidden;
&.status-passed {
.status-banner { border-left: 4px solid var(--color-success, #059669); }
.status-icon { color: var(--color-success, #059669); }
}
&.status-failed {
.status-banner { border-left: 4px solid var(--color-error, #dc2626); }
.status-icon { color: var(--color-error, #dc2626); }
}
&.status-warning {
.status-banner { border-left: 4px solid var(--color-warning, #d97706); }
.status-icon { color: var(--color-warning, #d97706); }
}
&.status-pending {
.status-banner { border-left: 4px solid var(--color-info, #2563eb); }
.status-icon { color: var(--color-info, #2563eb); }
}
&.status-skipped {
.status-banner { border-left: 4px solid var(--color-text-muted, #9ca3af); }
.status-icon { color: var(--color-text-muted, #9ca3af); }
}
&.compact {
.gates-list,
.blocking-issues,
.remediation-panel {
display: none;
}
}
}
.status-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--color-bg-subtle, #f9fafb);
}
.status-icon {
font-family: monospace;
font-weight: 700;
font-size: 1rem;
}
.status-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.status-label {
font-weight: 600;
color: var(--color-text, #111827);
}
.gate-summary {
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
}
.warning-count {
color: var(--color-warning, #d97706);
}
.status-actions {
display: flex;
gap: 0.5rem;
}
.btn-remediation {
padding: 0.375rem 0.75rem;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-size: 0.8125rem;
cursor: pointer;
color: var(--color-text, #374151);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
}
}
.btn-publish {
padding: 0.375rem 1rem;
background: var(--color-success, #059669);
border: none;
border-radius: 4px;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
color: white;
&:hover:not(:disabled) {
background: var(--color-success-dark, #047857);
}
&:disabled {
background: var(--color-text-muted, #9ca3af);
cursor: not-allowed;
}
}
.block-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--color-error-bg, #fef2f2);
border-bottom: 1px solid var(--color-error-border, #fecaca);
}
.block-icon {
font-family: monospace;
font-weight: 700;
color: var(--color-error, #dc2626);
}
.block-message {
font-size: 0.8125rem;
color: var(--color-error, #dc2626);
}
.gates-list {
border-top: 1px solid var(--color-border, #e5e7eb);
}
.gate-item {
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
&:last-child {
border-bottom: none;
}
&.result-passed {
.result-icon { color: var(--color-success, #059669); }
}
&.result-failed {
.result-icon { color: var(--color-error, #dc2626); }
.gate-header { background: var(--color-error-bg, #fef2f2); }
}
&.result-warning {
.result-icon { color: var(--color-warning, #d97706); }
.gate-header { background: var(--color-warning-bg, #fffbeb); }
}
&.result-skipped {
.result-icon { color: var(--color-text-muted, #9ca3af); }
.gate-name { color: var(--color-text-muted, #9ca3af); }
}
}
.gate-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 1rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
&:hover {
background: var(--color-bg-hover, #f9fafb);
}
}
.gate-type-icon {
font-family: monospace;
font-weight: 600;
font-size: 0.75rem;
width: 1.25rem;
height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-subtle, #f3f4f6);
border-radius: 3px;
color: var(--color-text-muted, #6b7280);
}
.result-icon {
font-family: monospace;
font-weight: 700;
font-size: 0.875rem;
}
.gate-info {
flex: 1;
display: flex;
align-items: center;
gap: 0.5rem;
}
.gate-name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text, #374151);
}
.required-badge {
font-size: 0.625rem;
padding: 0.0625rem 0.25rem;
border-radius: 2px;
background: var(--color-warning-bg, #fef3c7);
color: var(--color-warning-dark, #92400e);
text-transform: uppercase;
font-weight: 600;
}
.expand-icon {
font-size: 0.625rem;
color: var(--color-text-muted, #9ca3af);
transition: transform 0.2s;
&.expanded {
transform: rotate(180deg);
}
}
.gate-details {
padding: 0.75rem 1rem;
background: var(--color-bg-subtle, #f9fafb);
border-top: 1px solid var(--color-border-light, #f3f4f6);
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
font-size: 0.8125rem;
}
.detail-label {
color: var(--color-text-muted, #6b7280);
}
.detail-value {
font-weight: 500;
color: var(--color-text, #374151);
&.mismatch,
&.missing {
color: var(--color-error, #dc2626);
}
&.score {
font-family: monospace;
&.action-allow { color: var(--color-success, #059669); }
&.action-warn { color: var(--color-warning, #d97706); }
&.action-block { color: var(--color-error, #dc2626); }
}
}
.match-icon {
color: var(--color-success, #059669);
}
.mismatch-icon {
color: var(--color-error, #dc2626);
}
.hash-comparison {
margin: 0.5rem 0;
padding: 0.5rem;
background: var(--color-bg-card, white);
border-radius: 4px;
border: 1px solid var(--color-border, #e5e7eb);
}
.hash-row {
display: flex;
gap: 0.5rem;
font-size: 0.75rem;
margin-bottom: 0.25rem;
&:last-child {
margin-bottom: 0;
}
}
.hash-label {
color: var(--color-text-muted, #9ca3af);
min-width: 70px;
}
.hash {
font-family: monospace;
background: var(--color-bg-code, #f3f4f6);
padding: 0.125rem 0.25rem;
border-radius: 2px;
&.mismatch {
background: var(--color-error-bg, #fef2f2);
color: var(--color-error, #dc2626);
}
}
.fragment-details {
margin-top: 0.5rem;
summary {
font-size: 0.75rem;
color: var(--color-primary, #2563eb);
cursor: pointer;
}
}
.fragment-list {
list-style: none;
padding: 0;
margin: 0.25rem 0 0;
li {
display: flex;
justify-content: space-between;
padding: 0.125rem 0;
font-size: 0.75rem;
&.mismatch {
color: var(--color-error, #dc2626);
}
&.more {
color: var(--color-text-muted, #9ca3af);
font-style: italic;
}
}
}
.frag-id {
font-family: monospace;
}
.frag-status {
font-weight: 700;
}
// Entropy Details
.threshold-bar {
margin: 0.75rem 0;
}
.threshold-track {
position: relative;
height: 8px;
background: linear-gradient(to right,
var(--color-success, #059669) 0%,
var(--color-warning, #d97706) 50%,
var(--color-error, #dc2626) 100%
);
border-radius: 4px;
}
.threshold-marker {
position: absolute;
top: -2px;
width: 2px;
height: 12px;
background: var(--color-text, #374151);
&.warn::after,
&.block::after {
content: '';
position: absolute;
top: -4px;
left: -3px;
width: 8px;
height: 8px;
border-radius: 50%;
}
&.warn::after {
background: var(--color-warning, #d97706);
}
&.block::after {
background: var(--color-error, #dc2626);
}
}
.score-marker {
position: absolute;
top: -4px;
width: 16px;
height: 16px;
background: white;
border: 2px solid var(--color-text, #374151);
border-radius: 50%;
transform: translateX(-50%);
}
.threshold-labels {
display: flex;
justify-content: space-between;
font-size: 0.625rem;
color: var(--color-text-muted, #9ca3af);
margin-top: 0.25rem;
}
.warn-label {
color: var(--color-warning, #d97706);
}
.block-label {
color: var(--color-error, #dc2626);
}
.suspicious-patterns {
margin-top: 0.5rem;
}
.pattern-list {
list-style: disc;
margin: 0.25rem 0 0 1rem;
padding: 0;
li {
font-size: 0.75rem;
color: var(--color-warning, #d97706);
}
}
.evidence-links {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border-light, #f3f4f6);
flex-wrap: wrap;
}
.evidence-label {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
}
.evidence-link {
background: none;
border: none;
color: var(--color-primary, #2563eb);
font-size: 0.75rem;
font-family: monospace;
cursor: pointer;
padding: 0;
&:hover {
text-decoration: underline;
}
}
.gate-remediation {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border-light, #f3f4f6);
}
.remediation-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted, #6b7280);
margin: 0 0 0.5rem;
}
.hint-card,
.remediation-card {
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.75rem;
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
}
.hint-header,
.remediation-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.hint-title,
.remediation-title {
font-weight: 600;
font-size: 0.8125rem;
color: var(--color-text, #374151);
}
.remediation-for {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
background: var(--color-bg-subtle, #f3f4f6);
border-radius: 3px;
font-family: monospace;
}
.effort-badge {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
background: var(--color-info-bg, #f0f9ff);
color: var(--color-info, #0284c7);
border-radius: 10px;
margin-left: auto;
}
.hint-steps,
.remediation-steps {
margin: 0;
padding-left: 1.25rem;
li {
font-size: 0.8125rem;
color: var(--color-text, #374151);
margin-bottom: 0.25rem;
&:last-child {
margin-bottom: 0;
}
}
}
.cli-command {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
padding: 0.5rem;
background: var(--color-bg-code, #1f2937);
border-radius: 4px;
code {
flex: 1;
font-size: 0.75rem;
color: #e5e7eb;
white-space: nowrap;
overflow-x: auto;
}
.btn-run {
padding: 0.25rem 0.5rem;
background: var(--color-primary, #2563eb);
border: none;
border-radius: 3px;
font-size: 0.6875rem;
color: white;
cursor: pointer;
&:hover {
background: var(--color-primary-dark, #1d4ed8);
}
}
}
.docs-link {
display: inline-block;
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--color-primary, #2563eb);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.blocking-issues {
padding: 0.75rem 1rem;
background: var(--color-error-bg, #fef2f2);
border-top: 1px solid var(--color-error-border, #fecaca);
}
.issues-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-error, #dc2626);
margin: 0 0 0.5rem;
}
.issues-list {
list-style: none;
padding: 0;
margin: 0;
}
.issue-item {
display: flex;
gap: 0.5rem;
padding: 0.375rem 0;
font-size: 0.8125rem;
border-bottom: 1px solid var(--color-error-border, #fecaca);
flex-wrap: wrap;
&:last-child {
border-bottom: none;
}
&.severity-critical .issue-code {
background: var(--color-critical, #dc2626);
color: white;
}
&.severity-high .issue-code {
background: var(--color-error, #ea580c);
color: white;
}
}
.issue-code {
font-family: monospace;
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
border-radius: 2px;
font-weight: 600;
}
.issue-message {
flex: 1;
color: var(--color-text, #374151);
}
.issue-resource {
font-family: monospace;
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
}
.remediation-panel {
padding: 0.75rem 1rem;
background: var(--color-info-bg, #f0f9ff);
border-top: 1px solid var(--color-info-border, #bae6fd);
}
.remediation-panel-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-info-dark, #0369a1);
margin: 0 0 0.75rem;
}
.indicator-footer {
display: flex;
justify-content: space-between;
padding: 0.5rem 1rem;
background: var(--color-bg-subtle, #f9fafb);
border-top: 1px solid var(--color-border, #e5e7eb);
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
}
.eval-id {
font-family: monospace;
}

View File

@@ -0,0 +1,190 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import {
PolicyGateStatus,
PolicyGate,
PolicyRemediationHint,
DeterminismGateDetails,
EntropyGateDetails,
} from '../../core/api/policy.models';
@Component({
selector: 'app-policy-gate-indicator',
standalone: true,
imports: [CommonModule],
templateUrl: './policy-gate-indicator.component.html',
styleUrls: ['./policy-gate-indicator.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PolicyGateIndicatorComponent {
/** Policy gate status data */
readonly gateStatus = input.required<PolicyGateStatus>();
/** Show compact view */
readonly compact = input(false);
/** Emits when user clicks publish (if allowed) */
readonly publish = output<string>();
/** Emits when user wants to view evidence */
readonly viewEvidence = output<string>();
/** Emits when user wants to run remediation */
readonly runRemediation = output<PolicyRemediationHint>();
/** Currently expanded gate */
readonly expandedGate = signal<string | null>(null);
/** Show remediation panel */
readonly showRemediation = signal(false);
readonly statusClass = computed(() => 'status-' + this.gateStatus().status);
readonly statusIcon = computed(() => {
switch (this.gateStatus().status) {
case 'passed':
return '[OK]';
case 'failed':
return '[X]';
case 'warning':
return '[!]';
case 'pending':
return '[...]';
case 'skipped':
return '[-]';
default:
return '[?]';
}
});
readonly statusLabel = computed(() => {
switch (this.gateStatus().status) {
case 'passed':
return 'All Gates Passed';
case 'failed':
return 'Gates Failed';
case 'warning':
return 'Gates Passed with Warnings';
case 'pending':
return 'Evaluation Pending';
case 'skipped':
return 'Gates Skipped';
default:
return 'Unknown Status';
}
});
readonly passedGates = computed(() =>
this.gateStatus().gates.filter((g) => g.result === 'passed')
);
readonly failedGates = computed(() =>
this.gateStatus().gates.filter((g) => g.result === 'failed')
);
readonly warningGates = computed(() =>
this.gateStatus().gates.filter((g) => g.result === 'warning')
);
readonly determinismGate = computed(() =>
this.gateStatus().gates.find((g) => g.type === 'determinism')
);
readonly entropyGate = computed(() =>
this.gateStatus().gates.find((g) => g.type === 'entropy')
);
toggleGate(gateId: string): void {
this.expandedGate.update((current) => (current === gateId ? null : gateId));
}
toggleRemediation(): void {
this.showRemediation.update((v) => !v);
}
onPublish(): void {
if (this.gateStatus().canPublish) {
this.publish.emit(this.gateStatus().evaluationId);
}
}
onViewEvidence(ref: string): void {
this.viewEvidence.emit(ref);
}
onRunRemediation(hint: PolicyRemediationHint): void {
this.runRemediation.emit(hint);
}
getGateIcon(type: string): string {
switch (type) {
case 'determinism':
return '#';
case 'vulnerability':
return '!';
case 'license':
return 'L';
case 'signature':
return 'S';
case 'entropy':
return 'E';
default:
return '?';
}
}
getResultIcon(result: string): string {
switch (result) {
case 'passed':
return '+';
case 'failed':
return 'x';
case 'warning':
return '!';
case 'skipped':
return '-';
default:
return '?';
}
}
getEffortLabel(effort?: string): string {
switch (effort) {
case 'trivial':
return '< 5 min';
case 'easy':
return '5-15 min';
case 'moderate':
return '15-60 min';
case 'complex':
return '> 1 hour';
default:
return '';
}
}
getDeterminismDetails(gate: PolicyGate): DeterminismGateDetails | null {
return gate.details as DeterminismGateDetails | null;
}
getEntropyDetails(gate: PolicyGate): EntropyGateDetails | null {
return gate.details as EntropyGateDetails | null;
}
formatHash(hash: string | undefined, length = 12): string {
if (!hash) return 'N/A';
if (hash.length <= length) return hash;
return hash.slice(0, length) + '...';
}
getHintsForGate(gateId: string): PolicyRemediationHint[] {
return this.gateStatus().remediationHints.filter((h) => h.forGate === gateId);
}
}

View File

@@ -1,193 +1,193 @@
import {
Exception,
ExceptionStats,
} from '../core/api/exception.models';
/**
* Test fixtures for Exception Center components and services.
*/
export const exceptionDraft: Exception = {
schemaVersion: '1.0',
exceptionId: 'exc-test-001',
tenantId: 'tenant-test',
name: 'test-draft-exception',
displayName: 'Test Draft Exception',
description: 'A draft exception for testing purposes',
status: 'draft',
severity: 'medium',
scope: {
type: 'component',
componentPurls: ['pkg:npm/lodash@4.17.20'],
vulnIds: ['CVE-2021-23337'],
},
justification: {
template: 'risk-accepted',
text: 'Risk accepted for testing environment only.',
},
timebox: {
startDate: '2025-01-01T00:00:00Z',
endDate: '2025-03-31T23:59:59Z',
},
labels: { env: 'test' },
createdBy: 'test@example.com',
createdAt: '2025-01-01T10:00:00Z',
};
export const exceptionPendingReview: Exception = {
schemaVersion: '1.0',
exceptionId: 'exc-test-002',
tenantId: 'tenant-test',
name: 'test-pending-exception',
displayName: 'Test Pending Review Exception',
description: 'An exception awaiting review',
status: 'pending_review',
severity: 'high',
scope: {
type: 'asset',
assetIds: ['asset-web-prod'],
vulnIds: ['CVE-2024-1234'],
},
justification: {
template: 'compensating-control',
text: 'WAF rules in place to mitigate risk.',
},
timebox: {
startDate: '2025-01-15T00:00:00Z',
endDate: '2025-02-15T23:59:59Z',
},
createdBy: 'ops@example.com',
createdAt: '2025-01-10T14:00:00Z',
};
export const exceptionApproved: Exception = {
schemaVersion: '1.0',
exceptionId: 'exc-test-003',
tenantId: 'tenant-test',
name: 'test-approved-exception',
displayName: 'Test Approved Exception',
description: 'An approved exception with audit trail',
status: 'approved',
severity: 'critical',
scope: {
type: 'global',
},
justification: {
text: 'Emergency exception for production hotfix.',
},
timebox: {
startDate: '2025-01-20T00:00:00Z',
endDate: '2025-01-27T23:59:59Z',
autoRenew: false,
},
approvals: [
{
approvalId: 'apr-test-001',
approvedBy: 'security@example.com',
approvedAt: '2025-01-20T09:30:00Z',
comment: 'Approved for emergency hotfix window',
},
],
auditTrail: [
{
auditId: 'aud-001',
action: 'created',
actor: 'dev@example.com',
timestamp: '2025-01-19T16:00:00Z',
},
{
auditId: 'aud-002',
action: 'submitted_for_review',
actor: 'dev@example.com',
timestamp: '2025-01-19T16:05:00Z',
previousStatus: 'draft',
newStatus: 'pending_review',
},
{
auditId: 'aud-003',
action: 'approved',
actor: 'security@example.com',
timestamp: '2025-01-20T09:30:00Z',
previousStatus: 'pending_review',
newStatus: 'approved',
},
],
createdBy: 'dev@example.com',
createdAt: '2025-01-19T16:00:00Z',
updatedBy: 'security@example.com',
updatedAt: '2025-01-20T09:30:00Z',
};
export const exceptionExpired: Exception = {
schemaVersion: '1.0',
exceptionId: 'exc-test-004',
tenantId: 'tenant-test',
name: 'test-expired-exception',
displayName: 'Test Expired Exception',
status: 'expired',
severity: 'low',
scope: {
type: 'tenant',
tenantId: 'tenant-test',
},
justification: {
text: 'Legacy system exception.',
},
timebox: {
startDate: '2024-06-01T00:00:00Z',
endDate: '2024-12-31T23:59:59Z',
},
createdBy: 'admin@example.com',
createdAt: '2024-05-15T08:00:00Z',
};
export const exceptionRejected: Exception = {
schemaVersion: '1.0',
exceptionId: 'exc-test-005',
tenantId: 'tenant-test',
name: 'test-rejected-exception',
displayName: 'Test Rejected Exception',
description: 'Rejected due to insufficient justification',
status: 'rejected',
severity: 'critical',
scope: {
type: 'global',
},
justification: {
text: 'We need this exception.',
},
timebox: {
startDate: '2025-01-01T00:00:00Z',
endDate: '2025-12-31T23:59:59Z',
},
createdBy: 'dev@example.com',
createdAt: '2024-12-20T10:00:00Z',
};
export const allTestExceptions: Exception[] = [
exceptionDraft,
exceptionPendingReview,
exceptionApproved,
exceptionExpired,
exceptionRejected,
];
export const testExceptionStats: ExceptionStats = {
total: 5,
byStatus: {
draft: 1,
pending_review: 1,
approved: 1,
rejected: 1,
expired: 1,
revoked: 0,
},
bySeverity: {
critical: 2,
high: 1,
medium: 1,
low: 1,
},
expiringWithin7Days: 1,
pendingApproval: 1,
};
import {
Exception,
ExceptionStats,
} from '../core/api/exception.models';
/**
* Test fixtures for Exception Center components and services.
*/
export const exceptionDraft: Exception = {
schemaVersion: '1.0',
exceptionId: 'exc-test-001',
tenantId: 'tenant-test',
name: 'test-draft-exception',
displayName: 'Test Draft Exception',
description: 'A draft exception for testing purposes',
status: 'draft',
severity: 'medium',
scope: {
type: 'component',
componentPurls: ['pkg:npm/lodash@4.17.20'],
vulnIds: ['CVE-2021-23337'],
},
justification: {
template: 'risk-accepted',
text: 'Risk accepted for testing environment only.',
},
timebox: {
startDate: '2025-01-01T00:00:00Z',
endDate: '2025-03-31T23:59:59Z',
},
labels: { env: 'test' },
createdBy: 'test@example.com',
createdAt: '2025-01-01T10:00:00Z',
};
export const exceptionPendingReview: Exception = {
schemaVersion: '1.0',
exceptionId: 'exc-test-002',
tenantId: 'tenant-test',
name: 'test-pending-exception',
displayName: 'Test Pending Review Exception',
description: 'An exception awaiting review',
status: 'pending_review',
severity: 'high',
scope: {
type: 'asset',
assetIds: ['asset-web-prod'],
vulnIds: ['CVE-2024-1234'],
},
justification: {
template: 'compensating-control',
text: 'WAF rules in place to mitigate risk.',
},
timebox: {
startDate: '2025-01-15T00:00:00Z',
endDate: '2025-02-15T23:59:59Z',
},
createdBy: 'ops@example.com',
createdAt: '2025-01-10T14:00:00Z',
};
export const exceptionApproved: Exception = {
schemaVersion: '1.0',
exceptionId: 'exc-test-003',
tenantId: 'tenant-test',
name: 'test-approved-exception',
displayName: 'Test Approved Exception',
description: 'An approved exception with audit trail',
status: 'approved',
severity: 'critical',
scope: {
type: 'global',
},
justification: {
text: 'Emergency exception for production hotfix.',
},
timebox: {
startDate: '2025-01-20T00:00:00Z',
endDate: '2025-01-27T23:59:59Z',
autoRenew: false,
},
approvals: [
{
approvalId: 'apr-test-001',
approvedBy: 'security@example.com',
approvedAt: '2025-01-20T09:30:00Z',
comment: 'Approved for emergency hotfix window',
},
],
auditTrail: [
{
auditId: 'aud-001',
action: 'created',
actor: 'dev@example.com',
timestamp: '2025-01-19T16:00:00Z',
},
{
auditId: 'aud-002',
action: 'submitted_for_review',
actor: 'dev@example.com',
timestamp: '2025-01-19T16:05:00Z',
previousStatus: 'draft',
newStatus: 'pending_review',
},
{
auditId: 'aud-003',
action: 'approved',
actor: 'security@example.com',
timestamp: '2025-01-20T09:30:00Z',
previousStatus: 'pending_review',
newStatus: 'approved',
},
],
createdBy: 'dev@example.com',
createdAt: '2025-01-19T16:00:00Z',
updatedBy: 'security@example.com',
updatedAt: '2025-01-20T09:30:00Z',
};
export const exceptionExpired: Exception = {
schemaVersion: '1.0',
exceptionId: 'exc-test-004',
tenantId: 'tenant-test',
name: 'test-expired-exception',
displayName: 'Test Expired Exception',
status: 'expired',
severity: 'low',
scope: {
type: 'tenant',
tenantId: 'tenant-test',
},
justification: {
text: 'Legacy system exception.',
},
timebox: {
startDate: '2024-06-01T00:00:00Z',
endDate: '2024-12-31T23:59:59Z',
},
createdBy: 'admin@example.com',
createdAt: '2024-05-15T08:00:00Z',
};
export const exceptionRejected: Exception = {
schemaVersion: '1.0',
exceptionId: 'exc-test-005',
tenantId: 'tenant-test',
name: 'test-rejected-exception',
displayName: 'Test Rejected Exception',
description: 'Rejected due to insufficient justification',
status: 'rejected',
severity: 'critical',
scope: {
type: 'global',
},
justification: {
text: 'We need this exception.',
},
timebox: {
startDate: '2025-01-01T00:00:00Z',
endDate: '2025-12-31T23:59:59Z',
},
createdBy: 'dev@example.com',
createdAt: '2024-12-20T10:00:00Z',
};
export const allTestExceptions: Exception[] = [
exceptionDraft,
exceptionPendingReview,
exceptionApproved,
exceptionExpired,
exceptionRejected,
];
export const testExceptionStats: ExceptionStats = {
total: 5,
byStatus: {
draft: 1,
pending_review: 1,
approved: 1,
rejected: 1,
expired: 1,
revoked: 0,
},
bySeverity: {
critical: 2,
high: 1,
medium: 1,
low: 1,
},
expiringWithin7Days: 1,
pendingApproval: 1,
};

View File

@@ -1,4 +1,4 @@
{
"status": "OK",
"timestamp": "1970-01-01T00:00:00Z"
}
{
"status": "OK",
"timestamp": "1970-01-01T00:00:00Z"
}

View File

@@ -1,8 +1,8 @@
{
"status": "OK",
"dependencies": {
"api": "UNKNOWN",
"telemetry": "DISABLED"
},
"timestamp": "1970-01-01T00:00:00Z"
}
{
"status": "OK",
"dependencies": {
"api": "UNKNOWN",
"telemetry": "DISABLED"
},
"timestamp": "1970-01-01T00:00:00Z"
}

View File

@@ -1,5 +1,5 @@
{
"version": "0.0.0",
"commit": "local",
"builtAt": "1970-01-01T00:00:00Z"
}
{
"version": "0.0.0",
"commit": "local",
"builtAt": "1970-01-01T00:00:00Z"
}