Add signal contracts for reachability, exploitability, trust, and unknown symbols
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals DSSE Sign & Evidence Locker / sign-signals-artifacts (push) Has been cancelled
Signals DSSE Sign & Evidence Locker / verify-signatures (push) Has been cancelled

- Introduced `ReachabilityState`, `RuntimeHit`, `ExploitabilitySignal`, `ReachabilitySignal`, `SignalEnvelope`, `SignalType`, `TrustSignal`, and `UnknownSymbolSignal` records to define various signal types and their properties.
- Implemented JSON serialization attributes for proper data interchange.
- Created project files for the new signal contracts library and corresponding test projects.
- Added deterministic test fixtures for micro-interaction testing.
- Included cryptographic keys for secure operations with cosign.
This commit is contained in:
StellaOps Bot
2025-12-05 00:27:00 +02:00
parent b018949a8d
commit 8768c27f30
192 changed files with 27569 additions and 2552 deletions

View File

@@ -13,7 +13,7 @@
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
@@ -100,13 +100,32 @@
"output": "."
}
],
"styles": [
"src/styles.scss"
],
"scripts": []
}
}
}
}
}
}
"styles": [
"src/styles.scss"
],
"scripts": []
}
},
"storybook": {
"builder": "@storybook/angular:start-storybook",
"options": {
"configDir": ".storybook",
"browserTarget": "stellaops-web:build",
"port": 4600,
"quiet": true,
"ci": true
}
},
"build-storybook": {
"builder": "@storybook/angular:build-storybook",
"options": {
"configDir": ".storybook",
"browserTarget": "stellaops-web:build",
"outputDir": "storybook-static",
"quiet": true
}
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -13,8 +13,8 @@
"serve:test": "ng serve --configuration development --port 4400 --host 127.0.0.1",
"verify:chromium": "node ./scripts/verify-chromium.js",
"ci:install": "npm ci --prefer-offline --no-audit --no-fund",
"storybook": "storybook dev -p 4600",
"storybook:build": "storybook build",
"storybook": "ng run stellaops-web:storybook",
"storybook:build": "ng run stellaops-web:build-storybook",
"test:a11y": "FAIL_ON_A11Y=0 playwright test tests/e2e/a11y-smoke.spec.ts"
},
"engines": {
@@ -45,7 +45,6 @@
"@storybook/addon-essentials": "8.1.0",
"@storybook/addon-interactions": "8.1.0",
"@storybook/angular": "8.1.0",
"@storybook/angular-renderer": "8.1.0",
"@storybook/test": "8.1.0",
"@storybook/testing-library": "0.2.2",
"storybook": "8.1.0",

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env node
/**
* Minimal Storybook wrapper to avoid missing legacy CLI bins.
* Supports:
* node scripts/storybook.js dev --ci --quiet --port 4600
* node scripts/storybook.js build --quiet
*/
const { dev, build } = require('@storybook/core-server');
const args = process.argv.slice(2);
const mode = args.shift() ?? 'dev';
const hasFlag = (flag) => args.includes(flag);
const getFlagValue = (flag) => {
const idx = args.indexOf(flag);
return idx >= 0 ? args[idx + 1] : undefined;
};
const ci = hasFlag('--ci') || process.env.CI === 'true';
const quiet = hasFlag('--quiet') || hasFlag('-q');
const port = Number(getFlagValue('--port') ?? process.env.STORYBOOK_PORT ?? 4600);
const host = process.env.STORYBOOK_HOST ?? '127.0.0.1';
const configDir = process.env.STORYBOOK_CONFIG_DIR ?? '.storybook';
const outputDir = process.env.STORYBOOK_OUTPUT_DIR ?? 'storybook-static';
async function run() {
if (mode === 'build') {
await build({
configDir,
outputDir,
quiet,
loglevel: quiet ? 'warn' : 'info',
});
return;
}
await dev({
configDir,
port,
host,
ci,
quiet,
loglevel: quiet ? 'warn' : 'info',
});
}
run().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -1,5 +1,10 @@
import { Routes } from '@angular/router';
import {
requireOrchViewerGuard,
requireOrchOperatorGuard,
} from './core/auth';
export const routes: Routes = [
{
path: 'dashboard/sources',
@@ -15,6 +20,46 @@ export const routes: Routes = [
(m) => m.ConsoleProfileComponent
),
},
{
path: 'console/status',
loadComponent: () =>
import('./features/console/console-status.component').then(
(m) => m.ConsoleStatusComponent
),
},
// Orchestrator routes - gated by orch:read scope (UI-ORCH-32-001)
{
path: 'orchestrator',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-dashboard.component').then(
(m) => m.OrchestratorDashboardComponent
),
},
{
path: 'orchestrator/jobs',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-jobs.component').then(
(m) => m.OrchestratorJobsComponent
),
},
{
path: 'orchestrator/jobs/:jobId',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-job-detail.component').then(
(m) => m.OrchestratorJobDetailComponent
),
},
{
path: 'orchestrator/quotas',
canMatch: [requireOrchOperatorGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-quotas.component').then(
(m) => m.OrchestratorQuotasComponent
),
},
{
path: 'concelier/trivy-db-settings',
loadComponent: () =>
@@ -29,29 +74,29 @@ export const routes: Routes = [
(m) => m.ScanDetailPageComponent
),
},
{
path: 'welcome',
loadComponent: () =>
import('./features/welcome/welcome-page.component').then(
(m) => m.WelcomePageComponent
),
},
{
path: 'risk',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/risk/risk-dashboard.component').then(
(m) => m.RiskDashboardComponent
),
},
{
path: 'vulnerabilities/:vulnId',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/vulnerabilities/vulnerability-detail.component').then(
(m) => m.VulnerabilityDetailComponent
),
},
{
path: 'welcome',
loadComponent: () =>
import('./features/welcome/welcome-page.component').then(
(m) => m.WelcomePageComponent
),
},
{
path: 'risk',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/risk/risk-dashboard.component').then(
(m) => m.RiskDashboardComponent
),
},
{
path: 'vulnerabilities/:vulnId',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/vulnerabilities/vulnerability-detail.component').then(
(m) => m.VulnerabilityDetailComponent
),
},
{
path: 'notify',
loadComponent: () =>

View File

@@ -135,6 +135,81 @@ export interface AocChainEntry {
readonly parentHash?: string;
}
// VEX Decision types (based on docs/schemas/vex-decision.schema.json)
export type VexStatus = 'NOT_AFFECTED' | 'AFFECTED_MITIGATED' | 'AFFECTED_UNMITIGATED' | 'FIXED';
export type VexJustificationType =
| 'CODE_NOT_PRESENT'
| 'CODE_NOT_REACHABLE'
| 'VULNERABLE_CODE_NOT_IN_EXECUTE_PATH'
| 'CONFIGURATION_NOT_AFFECTED'
| 'OS_NOT_AFFECTED'
| 'RUNTIME_MITIGATION_PRESENT'
| 'COMPENSATING_CONTROLS'
| 'ACCEPTED_BUSINESS_RISK'
| 'OTHER';
export interface VexSubjectRef {
readonly type: 'IMAGE' | 'REPO' | 'SBOM_COMPONENT' | 'OTHER';
readonly name: string;
readonly digest: Record<string, string>;
readonly sbomNodeId?: string;
}
export interface VexEvidenceRef {
readonly type: 'PR' | 'TICKET' | 'DOC' | 'COMMIT' | 'OTHER';
readonly title?: string;
readonly url: string;
}
export interface VexScope {
readonly environments?: readonly string[];
readonly projects?: readonly string[];
}
export interface VexValidFor {
readonly notBefore?: string;
readonly notAfter?: string;
}
export interface VexActorRef {
readonly id: string;
readonly displayName: string;
}
export interface VexDecision {
readonly id: string;
readonly vulnerabilityId: string;
readonly subject: VexSubjectRef;
readonly status: VexStatus;
readonly justificationType: VexJustificationType;
readonly justificationText?: string;
readonly evidenceRefs?: readonly VexEvidenceRef[];
readonly scope?: VexScope;
readonly validFor?: VexValidFor;
readonly supersedesDecisionId?: string;
readonly createdBy: VexActorRef;
readonly createdAt: string;
readonly updatedAt?: string;
}
// VEX status summary for UI display
export interface VexStatusSummary {
readonly notAffected: number;
readonly affectedMitigated: number;
readonly affectedUnmitigated: number;
readonly fixed: number;
readonly total: number;
}
// VEX conflict indicator
export interface VexConflict {
readonly vulnerabilityId: string;
readonly conflictingStatuses: readonly VexStatus[];
readonly decisionIds: readonly string[];
readonly reason: string;
}
// Evidence panel data combining all elements
export interface EvidenceData {
readonly advisoryId: string;
@@ -142,6 +217,8 @@ export interface EvidenceData {
readonly observations: readonly Observation[];
readonly linkset?: Linkset;
readonly policyEvidence?: PolicyEvidence;
readonly vexDecisions?: readonly VexDecision[];
readonly vexConflicts?: readonly VexConflict[];
readonly hasConflicts: boolean;
readonly conflictCount: number;
}
@@ -155,6 +232,32 @@ export interface SourceInfo {
readonly lastUpdated?: string;
}
// Filter configuration for observations/linksets
export type SeverityBucket = 'critical' | 'high' | 'medium' | 'low' | 'all';
export interface ObservationFilters {
readonly sources: readonly string[]; // Filter by source IDs
readonly severityBucket: SeverityBucket; // Filter by severity level
readonly conflictOnly: boolean; // Show only observations with conflicts
readonly hasCvssVector: boolean | null; // null = all, true = has vector, false = no vector
}
export const DEFAULT_OBSERVATION_FILTERS: ObservationFilters = {
sources: [],
severityBucket: 'all',
conflictOnly: false,
hasCvssVector: null,
};
// Pagination configuration
export interface PaginationState {
readonly pageSize: number;
readonly currentPage: number;
readonly totalItems: number;
}
export const DEFAULT_PAGE_SIZE = 10;
export const SOURCE_INFO: Record<string, SourceInfo> = {
ghsa: {
sourceId: 'ghsa',

View File

@@ -2,6 +2,7 @@ import { inject } from '@angular/core';
import { CanMatchFn, Router } from '@angular/router';
import { AuthSessionStore } from './auth-session.store';
import { StellaOpsScopes, type StellaOpsScope } from './scopes';
/**
* Simple guard to prevent unauthenticated navigation to protected routes.
@@ -13,3 +14,116 @@ export const requireAuthGuard: CanMatchFn = () => {
const isAuthenticated = auth.isAuthenticated();
return isAuthenticated ? true : router.createUrlTree(['/welcome']);
};
/**
* Creates a guard that requires specific scopes.
* Redirects to /welcome if not authenticated, or returns false if missing scopes.
*
* @param requiredScopes - Scopes that must all be present
* @param redirectPath - Optional path to redirect to if scope check fails (default: none, just denies)
*/
export function requireScopesGuard(
requiredScopes: readonly StellaOpsScope[],
redirectPath?: string
): CanMatchFn {
return () => {
const auth = inject(AuthSessionStore);
const router = inject(Router);
if (!auth.isAuthenticated()) {
return router.createUrlTree(['/welcome']);
}
const session = auth.session();
const userScopes = session?.scopes ?? [];
// Admin scope grants access to everything
if (userScopes.includes(StellaOpsScopes.ADMIN)) {
return true;
}
const hasAllRequired = requiredScopes.every((scope) =>
userScopes.includes(scope)
);
if (hasAllRequired) {
return true;
}
if (redirectPath) {
return router.createUrlTree([redirectPath]);
}
return false;
};
}
/**
* Creates a guard that requires any of the specified scopes.
* Redirects to /welcome if not authenticated, or returns false if no matching scopes.
*
* @param requiredScopes - At least one of these scopes must be present
* @param redirectPath - Optional path to redirect to if scope check fails
*/
export function requireAnyScopeGuard(
requiredScopes: readonly StellaOpsScope[],
redirectPath?: string
): CanMatchFn {
return () => {
const auth = inject(AuthSessionStore);
const router = inject(Router);
if (!auth.isAuthenticated()) {
return router.createUrlTree(['/welcome']);
}
const session = auth.session();
const userScopes = session?.scopes ?? [];
// Admin scope grants access to everything
if (userScopes.includes(StellaOpsScopes.ADMIN)) {
return true;
}
const hasAnyRequired = requiredScopes.some((scope) =>
userScopes.includes(scope)
);
if (hasAnyRequired) {
return true;
}
if (redirectPath) {
return router.createUrlTree([redirectPath]);
}
return false;
};
}
// Pre-built guards for common scope requirements (UI-ORCH-32-001)
/**
* Guard requiring orch:read scope for Orchestrator dashboard access.
* Redirects to /console/profile if user lacks Orchestrator viewer access.
*/
export const requireOrchViewerGuard: CanMatchFn = requireScopesGuard(
[StellaOpsScopes.ORCH_READ],
'/console/profile'
);
/**
* Guard requiring orch:operate scope for Orchestrator control actions.
*/
export const requireOrchOperatorGuard: CanMatchFn = requireScopesGuard(
[StellaOpsScopes.ORCH_READ, StellaOpsScopes.ORCH_OPERATE],
'/console/profile'
);
/**
* Guard requiring orch:quota scope for quota management.
*/
export const requireOrchQuotaGuard: CanMatchFn = requireScopesGuard(
[StellaOpsScopes.ORCH_READ, StellaOpsScopes.ORCH_QUOTA],
'/console/profile'
);

View File

@@ -41,6 +41,11 @@ export interface AuthService {
canEditGraph(): boolean;
canExportGraph(): boolean;
canSimulate(): boolean;
// Orchestrator access (UI-ORCH-32-001)
canViewOrchestrator(): boolean;
canOperateOrchestrator(): boolean;
canManageOrchestratorQuotas(): boolean;
canInitiateBackfill(): boolean;
}
// ============================================================================
@@ -75,6 +80,10 @@ const MOCK_USER: AuthUser = {
StellaOpsScopes.RELEASE_READ,
// AOC permissions
StellaOpsScopes.AOC_READ,
// Orchestrator permissions (UI-ORCH-32-001)
StellaOpsScopes.ORCH_READ,
// UI permissions
StellaOpsScopes.UI_READ,
],
};
@@ -118,6 +127,23 @@ export class MockAuthService implements AuthService {
StellaOpsScopes.POLICY_SIMULATE,
]);
}
// Orchestrator access methods (UI-ORCH-32-001)
canViewOrchestrator(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_READ);
}
canOperateOrchestrator(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_OPERATE);
}
canManageOrchestratorQuotas(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_QUOTA);
}
canInitiateBackfill(): boolean {
return this.hasScope(StellaOpsScopes.ORCH_BACKFILL);
}
}
// Re-export scopes for convenience

View File

@@ -14,3 +14,12 @@ export {
AUTH_SERVICE,
MockAuthService,
} from './auth.service';
export {
requireAuthGuard,
requireScopesGuard,
requireAnyScopeGuard,
requireOrchViewerGuard,
requireOrchOperatorGuard,
requireOrchQuotaGuard,
} from './auth.guard';

View File

@@ -49,6 +49,15 @@ export const StellaOpsScopes = {
AOC_READ: 'aoc:read',
AOC_VERIFY: 'aoc:verify',
// Orchestrator scopes (UI-ORCH-32-001)
ORCH_READ: 'orch:read',
ORCH_OPERATE: 'orch:operate',
ORCH_QUOTA: 'orch:quota',
ORCH_BACKFILL: 'orch:backfill',
// UI scopes
UI_READ: 'ui.read',
// Admin scopes
ADMIN: 'admin',
TENANT_ADMIN: 'tenant:admin',
@@ -99,6 +108,26 @@ export const ScopeGroups = {
StellaOpsScopes.POLICY_READ,
StellaOpsScopes.POLICY_WRITE,
] as const,
// Orchestrator scope groups (UI-ORCH-32-001)
ORCH_VIEWER: [
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.UI_READ,
] as const,
ORCH_OPERATOR: [
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.ORCH_OPERATE,
StellaOpsScopes.UI_READ,
] as const,
ORCH_ADMIN: [
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.ORCH_OPERATE,
StellaOpsScopes.ORCH_QUOTA,
StellaOpsScopes.ORCH_BACKFILL,
StellaOpsScopes.UI_READ,
] as const,
} as const;
/**
@@ -129,6 +158,14 @@ export const ScopeLabels: Record<StellaOpsScope, string> = {
'release:bypass': 'Bypass Release Gates',
'aoc:read': 'View AOC Status',
'aoc:verify': 'Trigger AOC Verification',
// Orchestrator scope labels (UI-ORCH-32-001)
'orch:read': 'View Orchestrator Jobs',
'orch:operate': 'Operate Orchestrator',
'orch:quota': 'Manage Orchestrator Quotas',
'orch:backfill': 'Initiate Backfill Runs',
// UI scope labels
'ui.read': 'Console Access',
// Admin scope labels
'admin': 'System Administrator',
'tenant:admin': 'Tenant Administrator',
};

View File

@@ -5,16 +5,56 @@
<h2 id="evidence-panel-title" class="evidence-panel__title">
Evidence: {{ advisoryId() }}
</h2>
<button
type="button"
class="evidence-panel__close"
(click)="onClose()"
aria-label="Close evidence panel"
>
<span aria-hidden="true">&times;</span>
</button>
<div class="evidence-panel__actions">
<button
type="button"
class="evidence-panel__permalink-btn"
(click)="togglePermalink()"
[attr.aria-expanded]="showPermalink()"
aria-controls="permalink-section"
title="Share permalink"
>
<span aria-hidden="true">&#128279;</span>
<span class="visually-hidden">Share permalink</span>
</button>
<button
type="button"
class="evidence-panel__close"
(click)="onClose()"
aria-label="Close evidence panel"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
</div>
<!-- Permalink Section -->
@if (showPermalink()) {
<div id="permalink-section" class="evidence-panel__permalink">
<label for="permalink-input" class="visually-hidden">Permalink URL</label>
<input
id="permalink-input"
type="text"
readonly
[value]="permalink()"
class="evidence-panel__permalink-input"
aria-describedby="permalink-hint"
/>
<button
type="button"
class="evidence-panel__copy-btn"
[class.copied]="permalinkCopied()"
(click)="copyPermalink()"
[attr.aria-label]="permalinkCopied() ? 'Copied!' : 'Copy to clipboard'"
>
{{ permalinkCopied() ? 'Copied!' : 'Copy' }}
</button>
<span id="permalink-hint" class="evidence-panel__permalink-hint">
Share this link to navigate directly to this evidence view
</span>
</div>
}
<!-- Policy Decision Summary -->
@if (policyEvidence(); as policy) {
<div class="evidence-panel__decision-summary" [class]="policyDecisionClass()">
@@ -77,7 +117,7 @@
[attr.aria-selected]="isActiveTab('observations')"
(click)="setActiveTab('observations')"
>
Observations ({{ observations().length }})
Observations ({{ filteredObservations().length }}@if (activeFilterCount() > 0) {/{{ observations().length }}})
</button>
<button
type="button"
@@ -90,6 +130,21 @@
>
Linkset
</button>
<button
type="button"
role="tab"
class="evidence-panel__tab"
[class.active]="isActiveTab('vex')"
[class.has-conflicts]="hasVexConflicts()"
[attr.aria-selected]="isActiveTab('vex')"
(click)="setActiveTab('vex')"
[disabled]="!hasVexData()"
>
VEX ({{ vexDecisions().length }})
@if (hasVexConflicts()) {
<span class="conflict-indicator" aria-label="Has conflicts">!</span>
}
</button>
<button
type="button"
role="tab"
@@ -123,26 +178,191 @@
role="tabpanel"
aria-label="Observations"
>
<!-- View Toggle -->
<div class="evidence-panel__view-toggle">
<button
type="button"
class="view-btn"
[class.active]="observationView() === 'side-by-side'"
(click)="setObservationView('side-by-side')"
aria-label="Side by side view"
>
Side by Side
</button>
<button
type="button"
class="view-btn"
[class.active]="observationView() === 'stacked'"
(click)="setObservationView('stacked')"
aria-label="Stacked view"
>
Stacked
</button>
<!-- Toolbar: View Toggle + Filters -->
<div class="evidence-panel__toolbar">
<div class="evidence-panel__view-toggle">
<button
type="button"
class="view-btn"
[class.active]="observationView() === 'side-by-side'"
(click)="setObservationView('side-by-side')"
aria-label="Side by side view"
>
Side by Side
</button>
<button
type="button"
class="view-btn"
[class.active]="observationView() === 'stacked'"
(click)="setObservationView('stacked')"
aria-label="Stacked view"
>
Stacked
</button>
</div>
<div class="evidence-panel__filter-controls">
<button
type="button"
class="filter-toggle-btn"
[class.active]="showFilters()"
(click)="toggleFilters()"
[attr.aria-expanded]="showFilters()"
aria-controls="observation-filters"
>
Filters
@if (activeFilterCount() > 0) {
<span class="filter-badge" aria-label="{{ activeFilterCount() }} active filters">
{{ activeFilterCount() }}
</span>
}
</button>
@if (activeFilterCount() > 0) {
<button
type="button"
class="filter-clear-btn"
(click)="clearFilters()"
aria-label="Clear all filters"
>
Clear
</button>
}
</div>
</div>
<!-- Filter Panel -->
@if (showFilters()) {
<div id="observation-filters" class="evidence-panel__filters" role="group" aria-label="Observation filters">
<!-- Source Filter -->
<fieldset class="filter-group">
<legend>Source</legend>
<div class="filter-options">
@for (source of availableSources(); track source.sourceId) {
<label class="filter-checkbox">
<input
type="checkbox"
[checked]="isSourceSelected(source.sourceId)"
(change)="toggleSourceFilter(source.sourceId)"
/>
<span>{{ source.name }}</span>
</label>
}
</div>
</fieldset>
<!-- Severity Bucket Filter -->
<fieldset class="filter-group">
<legend>Severity</legend>
<div class="filter-options filter-options--inline">
<label class="filter-radio">
<input
type="radio"
name="severity"
[checked]="isSeverityBucketSelected('all')"
(change)="updateSeverityBucket('all')"
/>
<span>All</span>
</label>
<label class="filter-radio severity-critical">
<input
type="radio"
name="severity"
[checked]="isSeverityBucketSelected('critical')"
(change)="updateSeverityBucket('critical')"
/>
<span>Critical</span>
</label>
<label class="filter-radio severity-high">
<input
type="radio"
name="severity"
[checked]="isSeverityBucketSelected('high')"
(change)="updateSeverityBucket('high')"
/>
<span>High</span>
</label>
<label class="filter-radio severity-medium">
<input
type="radio"
name="severity"
[checked]="isSeverityBucketSelected('medium')"
(change)="updateSeverityBucket('medium')"
/>
<span>Medium</span>
</label>
<label class="filter-radio severity-low">
<input
type="radio"
name="severity"
[checked]="isSeverityBucketSelected('low')"
(change)="updateSeverityBucket('low')"
/>
<span>Low</span>
</label>
</div>
</fieldset>
<!-- Conflict Only Filter -->
@if (hasConflicts()) {
<fieldset class="filter-group">
<legend>Conflicts</legend>
<div class="filter-options">
<label class="filter-checkbox">
<input
type="checkbox"
[checked]="filters().conflictOnly"
(change)="toggleConflictOnly()"
/>
<span>Show only conflicting sources</span>
</label>
</div>
</fieldset>
}
<!-- CVSS Vector Presence Filter -->
<fieldset class="filter-group">
<legend>CVSS Vector</legend>
<div class="filter-options filter-options--inline">
<label class="filter-radio">
<input
type="radio"
name="cvss-vector"
[checked]="filters().hasCvssVector === null"
(change)="updateCvssVectorFilter(null)"
/>
<span>All</span>
</label>
<label class="filter-radio">
<input
type="radio"
name="cvss-vector"
[checked]="filters().hasCvssVector === true"
(change)="updateCvssVectorFilter(true)"
/>
<span>Has Vector</span>
</label>
<label class="filter-radio">
<input
type="radio"
name="cvss-vector"
[checked]="filters().hasCvssVector === false"
(change)="updateCvssVectorFilter(false)"
/>
<span>No Vector</span>
</label>
</div>
</fieldset>
</div>
}
<!-- Results Summary -->
<div class="evidence-panel__results-summary">
<span>
Showing {{ paginatedObservations().length }} of {{ filteredObservations().length }}
@if (filteredObservations().length !== observations().length) {
({{ observations().length }} total)
}
</span>
</div>
<!-- Observations Grid -->
@@ -151,7 +371,7 @@
[class.side-by-side]="observationView() === 'side-by-side'"
[class.stacked]="observationView() === 'stacked'"
>
@for (obs of observations(); track trackByObservationId($index, obs)) {
@for (obs of paginatedObservations(); track trackByObservationId($index, obs)) {
<article
class="observation-card"
[class.expanded]="isObservationExpanded(obs.observationId)"
@@ -299,6 +519,65 @@
</article>
}
</div>
<!-- Pagination Controls -->
@if (totalPages() > 1) {
<nav class="evidence-panel__pagination" aria-label="Observation pagination">
<div class="pagination-info">
Page {{ currentPage() + 1 }} of {{ totalPages() }}
</div>
<div class="pagination-controls">
<button
type="button"
class="pagination-btn"
[disabled]="!hasPreviousPage()"
(click)="previousPage()"
aria-label="Previous page"
>
&larr; Previous
</button>
<!-- Page number buttons (show max 5) -->
@for (page of [].constructor(Math.min(5, totalPages())); track $index; let i = $index) {
@let pageNum = currentPage() < 2 ? i : Math.min(currentPage() - 2 + i, totalPages() - 1);
<button
type="button"
class="pagination-btn pagination-btn--number"
[class.active]="currentPage() === pageNum"
(click)="goToPage(pageNum)"
[attr.aria-current]="currentPage() === pageNum ? 'page' : null"
aria-label="Page {{ pageNum + 1 }}"
>
{{ pageNum + 1 }}
</button>
}
<button
type="button"
class="pagination-btn"
[disabled]="!hasNextPage()"
(click)="nextPage()"
aria-label="Next page"
>
Next &rarr;
</button>
</div>
<div class="pagination-size">
<label for="page-size">Per page:</label>
<select
id="page-size"
[value]="pageSize()"
(change)="updatePageSize(+$any($event.target).value)"
>
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
</select>
</div>
</nav>
}
</section>
}
@@ -436,6 +715,216 @@
</section>
}
<!-- VEX Tab -->
@if (isActiveTab('vex')) {
<section
class="evidence-panel__section"
role="tabpanel"
aria-label="VEX Decisions"
>
<div class="vex-panel">
<!-- Header with export actions -->
<header class="vex-panel__header">
<div class="vex-panel__title">
<h3>VEX Decisions</h3>
<p class="vex-panel__description">
Vulnerability exploitability decisions for this advisory
</p>
</div>
<div class="vex-panel__actions">
<button
type="button"
class="vex-export-btn"
(click)="onExportVex('json')"
title="Export as JSON"
>
Export JSON
</button>
<button
type="button"
class="vex-export-btn"
(click)="onExportVex('openvex')"
title="Export as OpenVEX"
>
Export OpenVEX
</button>
<button
type="button"
class="vex-export-btn"
(click)="onExportVex('csaf')"
title="Export as CSAF VEX"
>
Export CSAF
</button>
</div>
</header>
<!-- Status Summary Cards -->
<div class="vex-panel__summary">
<div class="vex-summary-card vex-summary-card--not-affected">
<span class="vex-summary-card__count">{{ vexStatusSummary().notAffected }}</span>
<span class="vex-summary-card__label">Not Affected</span>
</div>
<div class="vex-summary-card vex-summary-card--mitigated">
<span class="vex-summary-card__count">{{ vexStatusSummary().affectedMitigated }}</span>
<span class="vex-summary-card__label">Mitigated</span>
</div>
<div class="vex-summary-card vex-summary-card--unmitigated">
<span class="vex-summary-card__count">{{ vexStatusSummary().affectedUnmitigated }}</span>
<span class="vex-summary-card__label">Unmitigated</span>
</div>
<div class="vex-summary-card vex-summary-card--fixed">
<span class="vex-summary-card__count">{{ vexStatusSummary().fixed }}</span>
<span class="vex-summary-card__label">Fixed</span>
</div>
</div>
<!-- VEX Conflicts Warning -->
@if (hasVexConflicts()) {
<div class="vex-panel__conflicts" role="alert">
<header class="vex-conflicts__header">
<span class="vex-conflicts__icon" aria-hidden="true">!</span>
<span class="vex-conflicts__title">
{{ vexConflicts().length }} Conflicting Decision(s) Detected
</span>
</header>
<ul class="vex-conflicts__list">
@for (conflict of vexConflicts(); track trackByVexConflictId($index, conflict)) {
<li class="vex-conflicts__item">
<strong>{{ conflict.vulnerabilityId }}:</strong>
{{ conflict.reason }}
<span class="vex-conflicts__statuses">
({{ conflict.conflictingStatuses.map(getVexStatusLabel).join(' vs ') }})
</span>
</li>
}
</ul>
</div>
}
<!-- VEX Decisions List -->
<div class="vex-panel__decisions">
<h4>Decisions ({{ vexDecisions().length }})</h4>
@for (decision of vexDecisions(); track trackByVexDecisionId($index, decision)) {
<article
class="vex-decision-card"
[class.expired]="isVexDecisionExpired(decision)"
[class.pending]="isVexDecisionPending(decision)"
>
<header class="vex-decision-card__header">
<div class="vex-decision-card__status">
<span
class="vex-status-badge"
[class]="getVexStatusClass(decision.status)"
>
{{ getVexStatusLabel(decision.status) }}
</span>
@if (isVexDecisionExpired(decision)) {
<span class="vex-expired-badge">Expired</span>
}
@if (isVexDecisionPending(decision)) {
<span class="vex-pending-badge">Pending</span>
}
</div>
<code class="vex-decision-card__vuln-id">{{ decision.vulnerabilityId }}</code>
</header>
<div class="vex-decision-card__body">
<!-- Subject -->
<div class="vex-decision-card__section">
<dt>Subject:</dt>
<dd>
<span class="vex-subject-type">{{ decision.subject.type }}</span>
<code class="vex-subject-name">{{ decision.subject.name }}</code>
</dd>
</div>
<!-- Justification -->
<div class="vex-decision-card__section">
<dt>Justification:</dt>
<dd>
<span class="vex-justification-type">
{{ getVexJustificationLabel(decision.justificationType) }}
</span>
@if (decision.justificationText) {
<p class="vex-justification-text">{{ decision.justificationText }}</p>
}
</dd>
</div>
<!-- Scope -->
@if (decision.scope) {
<div class="vex-decision-card__section">
<dt>Scope:</dt>
<dd>
@if (decision.scope.environments && decision.scope.environments.length > 0) {
<span class="vex-scope-label">Environments:</span>
<span class="vex-scope-values">{{ decision.scope.environments.join(', ') }}</span>
}
@if (decision.scope.projects && decision.scope.projects.length > 0) {
<span class="vex-scope-label">Projects:</span>
<span class="vex-scope-values">{{ decision.scope.projects.join(', ') }}</span>
}
</dd>
</div>
}
<!-- Validity -->
@if (decision.validFor) {
<div class="vex-decision-card__section">
<dt>Valid:</dt>
<dd>
@if (decision.validFor.notBefore) {
<span>From {{ formatDate(decision.validFor.notBefore) }}</span>
}
@if (decision.validFor.notAfter) {
<span>Until {{ formatDate(decision.validFor.notAfter) }}</span>
}
</dd>
</div>
}
<!-- Evidence References -->
@if (decision.evidenceRefs && decision.evidenceRefs.length > 0) {
<div class="vex-decision-card__section">
<dt>Evidence:</dt>
<dd>
<ul class="vex-evidence-list">
@for (ref of decision.evidenceRefs; track ref.url) {
<li>
<span class="vex-evidence-type">{{ ref.type }}</span>
<a
[href]="ref.url"
target="_blank"
rel="noopener noreferrer"
class="vex-evidence-link"
>
{{ ref.title || ref.url }}
</a>
</li>
}
</ul>
</dd>
</div>
}
<!-- Created By -->
<div class="vex-decision-card__footer">
<span class="vex-decision-card__author">
By {{ decision.createdBy.displayName }}
</span>
<span class="vex-decision-card__date">
{{ formatDate(decision.createdAt) }}
</span>
</div>
</div>
</article>
}
</div>
</div>
</section>
}
<!-- Policy Tab -->
@if (isActiveTab('policy') && policyEvidence(); as policy) {
<section

View File

@@ -44,6 +44,98 @@ $color-text-muted: #6b7280;
color: #111827;
}
&__actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
&__permalink-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid $color-border;
border-radius: 4px;
background: #fff;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.15s, border-color 0.15s;
&:hover {
background: #f3f4f6;
border-color: #9ca3af;
}
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
}
&__permalink {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding: 0.75rem;
background: #f3f4f6;
border-radius: 6px;
border: 1px solid $color-border;
}
&__permalink-input {
flex: 1;
min-width: 200px;
padding: 0.5rem 0.75rem;
border: 1px solid $color-border;
border-radius: 4px;
background: #fff;
font-size: 0.8125rem;
font-family: 'Monaco', 'Consolas', monospace;
color: #374151;
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
}
&__copy-btn {
padding: 0.5rem 1rem;
border: 1px solid #3b82f6;
border-radius: 4px;
background: #3b82f6;
font-size: 0.8125rem;
font-weight: 500;
color: #fff;
cursor: pointer;
transition: background-color 0.15s;
&:hover {
background: #2563eb;
}
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
&.copied {
background: #22c55e;
border-color: #22c55e;
}
}
&__permalink-hint {
flex-basis: 100%;
font-size: 0.75rem;
color: $color-text-muted;
}
&__close {
display: flex;
align-items: center;
@@ -257,10 +349,18 @@ $color-text-muted: #6b7280;
animation: fadeIn 0.2s ease-out;
}
&__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
&__view-toggle {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
.view-btn {
padding: 0.5rem 0.75rem;
@@ -288,6 +388,245 @@ $color-text-muted: #6b7280;
}
}
}
&__filter-controls {
display: flex;
gap: 0.5rem;
align-items: center;
.filter-toggle-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid $color-border;
border-radius: 4px;
background: #fff;
font-size: 0.8125rem;
color: $color-text-muted;
cursor: pointer;
transition: background-color 0.15s, border-color 0.15s;
&:hover {
border-color: #9ca3af;
}
&.active {
background: #eff6ff;
border-color: #3b82f6;
color: #3b82f6;
}
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.filter-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 0.375rem;
border-radius: 10px;
background: #3b82f6;
color: #fff;
font-size: 0.6875rem;
font-weight: 600;
}
}
.filter-clear-btn {
padding: 0.5rem 0.75rem;
border: none;
border-radius: 4px;
background: transparent;
font-size: 0.8125rem;
color: #3b82f6;
cursor: pointer;
&:hover {
text-decoration: underline;
}
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
}
}
&__filters {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
padding: 1rem;
margin-bottom: 1rem;
background: $color-bg-muted;
border-radius: 8px;
border: 1px solid $color-border;
.filter-group {
border: none;
padding: 0;
margin: 0;
min-width: 150px;
legend {
font-size: 0.75rem;
font-weight: 600;
color: #374151;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
}
.filter-options {
display: flex;
flex-direction: column;
gap: 0.375rem;
&--inline {
flex-direction: row;
flex-wrap: wrap;
gap: 0.75rem;
}
}
.filter-checkbox,
.filter-radio {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: #374151;
cursor: pointer;
input {
width: 1rem;
height: 1rem;
margin: 0;
cursor: pointer;
accent-color: #3b82f6;
}
&.severity-critical span {
color: $color-critical;
font-weight: 500;
}
&.severity-high span {
color: $color-high;
font-weight: 500;
}
&.severity-medium span {
color: #a16207;
font-weight: 500;
}
&.severity-low span {
color: #15803d;
font-weight: 500;
}
}
}
&__results-summary {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
font-size: 0.8125rem;
color: $color-text-muted;
}
&__pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid $color-border;
.pagination-info {
font-size: 0.8125rem;
color: $color-text-muted;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 0.25rem;
}
.pagination-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 2rem;
padding: 0 0.5rem;
border: 1px solid $color-border;
border-radius: 4px;
background: #fff;
font-size: 0.8125rem;
color: $color-text-muted;
cursor: pointer;
transition: background-color 0.15s, border-color 0.15s;
&:hover:not(:disabled) {
border-color: #9ca3af;
background: #f3f4f6;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.active {
background: #3b82f6;
border-color: #3b82f6;
color: #fff;
}
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
&--number {
font-weight: 500;
}
}
.pagination-size {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: $color-text-muted;
select {
padding: 0.375rem 0.5rem;
border: 1px solid $color-border;
border-radius: 4px;
background: #fff;
font-size: 0.8125rem;
cursor: pointer;
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
}
}
}
}
// Observations Grid
@@ -994,6 +1333,412 @@ $color-text-muted: #6b7280;
color: #374151;
}
// VEX Panel
.vex-panel {
&__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #111827;
}
}
&__title {
flex: 1;
}
&__description {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: $color-text-muted;
}
&__actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
&__summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
&__conflicts {
margin-bottom: 1.5rem;
padding: 1rem;
border-radius: 8px;
background: #fef3c7;
border: 1px solid #fcd34d;
}
&__decisions {
h4 {
margin: 0 0 1rem;
font-size: 0.9375rem;
font-weight: 600;
color: #374151;
}
}
}
.vex-export-btn {
padding: 0.5rem 0.75rem;
border: 1px solid #3b82f6;
border-radius: 4px;
background: #fff;
font-size: 0.8125rem;
color: #3b82f6;
cursor: pointer;
transition: background-color 0.15s;
&:hover {
background: #eff6ff;
}
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
}
.vex-summary-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
border-radius: 8px;
background: #f9fafb;
border: 1px solid $color-border;
&__count {
font-size: 1.5rem;
font-weight: 700;
line-height: 1;
}
&__label {
margin-top: 0.5rem;
font-size: 0.75rem;
color: $color-text-muted;
text-align: center;
}
&--not-affected {
background: #f0fdf4;
border-color: #86efac;
.vex-summary-card__count {
color: #15803d;
}
}
&--mitigated {
background: #fef9c3;
border-color: #fde047;
.vex-summary-card__count {
color: #a16207;
}
}
&--unmitigated {
background: #fee2e2;
border-color: #fca5a5;
.vex-summary-card__count {
color: #dc2626;
}
}
&--fixed {
background: #eff6ff;
border-color: #93c5fd;
.vex-summary-card__count {
color: #2563eb;
}
}
}
.vex-conflicts {
&__header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: #f59e0b;
color: #fff;
font-weight: 700;
font-size: 0.875rem;
}
&__title {
font-weight: 600;
color: #92400e;
}
&__list {
margin: 0;
padding-left: 2rem;
font-size: 0.875rem;
color: #78350f;
}
&__item {
margin: 0.5rem 0;
}
&__statuses {
color: $color-text-muted;
font-style: italic;
}
}
.vex-decision-card {
border: 1px solid $color-border;
border-radius: 8px;
margin-bottom: 1rem;
background: #fff;
overflow: hidden;
&.expired {
opacity: 0.7;
border-color: #fca5a5;
}
&.pending {
border-color: #fde047;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
background: $color-bg-muted;
border-bottom: 1px solid $color-border;
flex-wrap: wrap;
}
&__status {
display: flex;
align-items: center;
gap: 0.5rem;
}
&__vuln-id {
font-size: 0.875rem;
background: #f3f4f6;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
&__body {
padding: 1rem;
}
&__section {
margin-bottom: 0.75rem;
&:last-child {
margin-bottom: 0;
}
dt {
font-size: 0.75rem;
font-weight: 600;
color: $color-text-muted;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.25rem;
}
dd {
margin: 0;
font-size: 0.875rem;
color: #374151;
}
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid $color-border;
font-size: 0.8125rem;
color: $color-text-muted;
}
}
.vex-status-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
&.vex-status--not-affected {
background: #dcfce7;
color: #15803d;
}
&.vex-status--mitigated {
background: #fef3c7;
color: #92400e;
}
&.vex-status--unmitigated {
background: #fee2e2;
color: #dc2626;
}
&.vex-status--fixed {
background: #dbeafe;
color: #2563eb;
}
}
.vex-expired-badge,
.vex-pending-badge {
display: inline-block;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
}
.vex-expired-badge {
background: #fee2e2;
color: #dc2626;
}
.vex-pending-badge {
background: #fef9c3;
color: #a16207;
}
.vex-subject-type {
display: inline-block;
padding: 0.125rem 0.375rem;
border-radius: 4px;
background: #e0e7ff;
color: #4338ca;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
margin-right: 0.5rem;
}
.vex-subject-name {
font-size: 0.8125rem;
word-break: break-all;
}
.vex-justification-type {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background: #f3f4f6;
color: #374151;
font-size: 0.8125rem;
font-weight: 500;
}
.vex-justification-text {
margin: 0.5rem 0 0;
font-size: 0.875rem;
color: $color-text-muted;
line-height: 1.5;
}
.vex-scope-label {
display: inline-block;
font-size: 0.75rem;
color: $color-text-muted;
margin-right: 0.25rem;
}
.vex-scope-values {
font-weight: 500;
margin-right: 1rem;
}
.vex-evidence-list {
margin: 0;
padding-left: 1.25rem;
font-size: 0.8125rem;
li {
margin: 0.375rem 0;
}
}
.vex-evidence-type {
display: inline-block;
padding: 0.125rem 0.25rem;
border-radius: 2px;
background: #e5e7eb;
color: #374151;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
margin-right: 0.5rem;
}
.vex-evidence-link {
color: #3b82f6;
word-break: break-all;
&:hover {
text-decoration: underline;
}
}
// Tab conflict indicator
.evidence-panel__tab {
.conflict-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
margin-left: 0.375rem;
border-radius: 50%;
background: #f59e0b;
color: #fff;
font-size: 0.625rem;
font-weight: 700;
}
&.has-conflicts {
color: #92400e;
}
}
// Animation
@keyframes fadeIn {
from {
@@ -1010,3 +1755,16 @@ $color-text-muted: #6b7280;
code {
font-family: 'Monaco', 'Consolas', 'Liberation Mono', monospace;
}
// Accessibility utility - visually hidden but accessible to screen readers
.visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}

View File

@@ -11,19 +11,28 @@ import {
import {
AocChainEntry,
DEFAULT_OBSERVATION_FILTERS,
DEFAULT_PAGE_SIZE,
EvidenceData,
Linkset,
LinksetConflict,
Observation,
ObservationFilters,
PolicyDecision,
PolicyEvidence,
PolicyRuleResult,
SeverityBucket,
SOURCE_INFO,
SourceInfo,
VexConflict,
VexDecision,
VexJustificationType,
VexStatus,
VexStatusSummary,
} from '../../core/api/evidence.models';
import { EvidenceApi, EVIDENCE_API } from '../../core/api/evidence.client';
type TabId = 'observations' | 'linkset' | 'policy' | 'aoc';
type TabId = 'observations' | 'linkset' | 'vex' | 'policy' | 'aoc';
type ObservationView = 'side-by-side' | 'stacked';
@Component({
@@ -37,6 +46,9 @@ type ObservationView = 'side-by-side' | 'stacked';
export class EvidencePanelComponent {
private readonly evidenceApi = inject(EVIDENCE_API);
// Expose Math for template usage
readonly Math = Math;
// Inputs
readonly advisoryId = input.required<string>();
readonly evidenceData = input<EvidenceData | null>(null);
@@ -52,6 +64,14 @@ export class EvidencePanelComponent {
readonly expandedAocEntry = signal<string | null>(null);
readonly showConflictDetails = signal(false);
// Filter state
readonly filters = signal<ObservationFilters>(DEFAULT_OBSERVATION_FILTERS);
readonly showFilters = signal(false);
// Pagination state
readonly pageSize = signal(DEFAULT_PAGE_SIZE);
readonly currentPage = signal(0);
// Loading/error state
readonly loading = signal(false);
readonly error = signal<string | null>(null);
@@ -83,6 +103,130 @@ export class EvidencePanelComponent {
return obs.map((o) => this.getSourceInfo(o.source));
});
// Available sources for filter dropdown
readonly availableSources = computed(() => {
const obs = this.observations();
const sourceIds = [...new Set(obs.map((o) => o.source))];
return sourceIds.map((id) => this.getSourceInfo(id));
});
// Filtered observations based on current filters
readonly filteredObservations = computed(() => {
const obs = this.observations();
const f = this.filters();
const linkset = this.linkset();
return obs.filter((o) => {
// Source filter
if (f.sources.length > 0 && !f.sources.includes(o.source)) {
return false;
}
// Severity bucket filter
if (f.severityBucket !== 'all') {
const maxScore = Math.max(...o.severities.map((s) => s.score), 0);
if (!this.matchesSeverityBucket(maxScore, f.severityBucket)) {
return false;
}
}
// Conflict-only filter
if (f.conflictOnly && linkset) {
const isInConflict = linkset.conflicts.some((c) =>
c.sourceIds?.includes(o.source)
);
if (!isInConflict) {
return false;
}
}
// CVSS vector presence filter
if (f.hasCvssVector !== null) {
const hasVector = o.severities.some((s) => !!s.vector);
if (f.hasCvssVector !== hasVector) {
return false;
}
}
return true;
});
});
// Paginated observations
readonly paginatedObservations = computed(() => {
const filtered = this.filteredObservations();
const page = this.currentPage();
const size = this.pageSize();
const start = page * size;
return filtered.slice(start, start + size);
});
// Total pages for pagination
readonly totalPages = computed(() => {
const total = this.filteredObservations().length;
const size = this.pageSize();
return Math.ceil(total / size);
});
// Whether there are more pages
readonly hasNextPage = computed(() => this.currentPage() < this.totalPages() - 1);
readonly hasPreviousPage = computed(() => this.currentPage() > 0);
// Active filter count for badge
readonly activeFilterCount = computed(() => {
const f = this.filters();
let count = 0;
if (f.sources.length > 0) count++;
if (f.severityBucket !== 'all') count++;
if (f.conflictOnly) count++;
if (f.hasCvssVector !== null) count++;
return count;
});
// VEX computed values
readonly vexDecisions = computed(() => this.evidenceData()?.vexDecisions ?? []);
readonly vexConflicts = computed(() => this.evidenceData()?.vexConflicts ?? []);
readonly hasVexData = computed(() => this.vexDecisions().length > 0);
readonly hasVexConflicts = computed(() => this.vexConflicts().length > 0);
// Permalink state
readonly showPermalink = signal(false);
readonly permalinkCopied = signal(false);
readonly vexStatusSummary = computed((): VexStatusSummary => {
const decisions = this.vexDecisions();
return {
notAffected: decisions.filter((d) => d.status === 'NOT_AFFECTED').length,
affectedMitigated: decisions.filter((d) => d.status === 'AFFECTED_MITIGATED').length,
affectedUnmitigated: decisions.filter((d) => d.status === 'AFFECTED_UNMITIGATED').length,
fixed: decisions.filter((d) => d.status === 'FIXED').length,
total: decisions.length,
};
});
// Permalink computed value
readonly permalink = computed(() => {
const advisoryId = this.advisoryId();
const tab = this.activeTab();
const linkset = this.linkset();
const policy = this.policyEvidence();
// Build query params for current state
const params = new URLSearchParams();
params.set('tab', tab);
if (linkset) {
params.set('linkset', linkset.linksetId);
}
if (policy) {
params.set('policy', policy.policyId);
}
// Base URL with advisory path and query string
const baseUrl = typeof window !== 'undefined' ? window.location.origin : '';
return `${baseUrl}/evidence/${encodeURIComponent(advisoryId)}?${params.toString()}`;
});
// Tab methods
setActiveTab(tab: TabId): void {
this.activeTab.set(tab);
@@ -106,6 +250,94 @@ export class EvidencePanelComponent {
return this.expandedObservation() === observationId;
}
// Filter methods
toggleFilters(): void {
this.showFilters.update((v) => !v);
}
updateSourceFilter(sources: readonly string[]): void {
this.filters.update((f) => ({ ...f, sources }));
this.currentPage.set(0); // Reset to first page on filter change
}
toggleSourceFilter(sourceId: string): void {
this.filters.update((f) => {
const sources = f.sources.includes(sourceId)
? f.sources.filter((s) => s !== sourceId)
: [...f.sources, sourceId];
return { ...f, sources };
});
this.currentPage.set(0);
}
updateSeverityBucket(bucket: SeverityBucket): void {
this.filters.update((f) => ({ ...f, severityBucket: bucket }));
this.currentPage.set(0);
}
toggleConflictOnly(): void {
this.filters.update((f) => ({ ...f, conflictOnly: !f.conflictOnly }));
this.currentPage.set(0);
}
updateCvssVectorFilter(value: boolean | null): void {
this.filters.update((f) => ({ ...f, hasCvssVector: value }));
this.currentPage.set(0);
}
clearFilters(): void {
this.filters.set(DEFAULT_OBSERVATION_FILTERS);
this.currentPage.set(0);
}
isSourceSelected(sourceId: string): boolean {
return this.filters().sources.includes(sourceId);
}
isSeverityBucketSelected(bucket: SeverityBucket): boolean {
return this.filters().severityBucket === bucket;
}
// Severity bucket matching helper
matchesSeverityBucket(score: number, bucket: SeverityBucket): boolean {
switch (bucket) {
case 'critical':
return score >= 9.0;
case 'high':
return score >= 7.0 && score < 9.0;
case 'medium':
return score >= 4.0 && score < 7.0;
case 'low':
return score < 4.0;
case 'all':
default:
return true;
}
}
// Pagination methods
goToPage(page: number): void {
const maxPage = Math.max(0, this.totalPages() - 1);
this.currentPage.set(Math.max(0, Math.min(page, maxPage)));
}
nextPage(): void {
if (this.hasNextPage()) {
this.currentPage.update((p) => p + 1);
}
}
previousPage(): void {
if (this.hasPreviousPage()) {
this.currentPage.update((p) => p - 1);
}
}
updatePageSize(size: number): void {
this.pageSize.set(size);
this.currentPage.set(0);
}
// AOC chain methods
toggleAocEntry(attestationId: string): void {
const current = this.expandedAocEntry();
@@ -205,6 +437,105 @@ export class EvidencePanelComponent {
return 'Low';
}
// VEX helpers
getVexStatusLabel(status: VexStatus): string {
switch (status) {
case 'NOT_AFFECTED':
return 'Not Affected';
case 'AFFECTED_MITIGATED':
return 'Affected (Mitigated)';
case 'AFFECTED_UNMITIGATED':
return 'Affected (Unmitigated)';
case 'FIXED':
return 'Fixed';
default:
return status;
}
}
getVexStatusClass(status: VexStatus): string {
switch (status) {
case 'NOT_AFFECTED':
return 'vex-status--not-affected';
case 'AFFECTED_MITIGATED':
return 'vex-status--mitigated';
case 'AFFECTED_UNMITIGATED':
return 'vex-status--unmitigated';
case 'FIXED':
return 'vex-status--fixed';
default:
return '';
}
}
getVexJustificationLabel(type: VexJustificationType): string {
const labels: Record<VexJustificationType, string> = {
CODE_NOT_PRESENT: 'Code Not Present',
CODE_NOT_REACHABLE: 'Code Not Reachable',
VULNERABLE_CODE_NOT_IN_EXECUTE_PATH: 'Vulnerable Code Not In Execute Path',
CONFIGURATION_NOT_AFFECTED: 'Configuration Not Affected',
OS_NOT_AFFECTED: 'OS Not Affected',
RUNTIME_MITIGATION_PRESENT: 'Runtime Mitigation Present',
COMPENSATING_CONTROLS: 'Compensating Controls',
ACCEPTED_BUSINESS_RISK: 'Accepted Business Risk',
OTHER: 'Other',
};
return labels[type] ?? type;
}
isVexDecisionExpired(decision: VexDecision): boolean {
if (!decision.validFor?.notAfter) return false;
return new Date(decision.validFor.notAfter) < new Date();
}
isVexDecisionPending(decision: VexDecision): boolean {
if (!decision.validFor?.notBefore) return false;
return new Date(decision.validFor.notBefore) > new Date();
}
// VEX export handlers
readonly exportVex = output<{ format: 'json' | 'csaf' | 'openvex' }>();
onExportVex(format: 'json' | 'csaf' | 'openvex'): void {
this.exportVex.emit({ format });
}
// Permalink methods
togglePermalink(): void {
this.showPermalink.update((v) => !v);
this.permalinkCopied.set(false);
}
async copyPermalink(): Promise<void> {
const link = this.permalink();
try {
await navigator.clipboard.writeText(link);
this.permalinkCopied.set(true);
// Reset after 2 seconds
setTimeout(() => this.permalinkCopied.set(false), 2000);
} catch (err) {
// Fallback for browsers without clipboard API
this.fallbackCopyToClipboard(link);
}
}
private fallbackCopyToClipboard(text: string): void {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
this.permalinkCopied.set(true);
setTimeout(() => this.permalinkCopied.set(false), 2000);
} catch {
console.error('Fallback: Unable to copy');
}
document.body.removeChild(textArea);
}
// Download handlers
onDownloadObservation(observationId: string): void {
this.downloadDocument.emit({ type: 'observation', id: observationId });
@@ -252,4 +583,12 @@ export class EvidencePanelComponent {
trackByRuleId(_: number, rule: PolicyRuleResult): string {
return rule.ruleId;
}
trackByVexDecisionId(_: number, decision: VexDecision): string {
return decision.id;
}
trackByVexConflictId(_: number, conflict: VexConflict): string {
return conflict.vulnerabilityId;
}
}

View File

@@ -0,0 +1,173 @@
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { AUTH_SERVICE, AuthService } from '../../core/auth';
/**
* Orchestrator Dashboard - Main landing page for Orchestrator features.
* Requires orch:read scope for access (gated by requireOrchViewerGuard).
*
* @see UI-ORCH-32-001
*/
@Component({
selector: 'app-orchestrator-dashboard',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<div class="orch-dashboard">
<header class="orch-dashboard__header">
<h1 class="orch-dashboard__title">Orchestrator Dashboard</h1>
<p class="orch-dashboard__description">
Monitor and manage orchestrated jobs, quotas, and backfill operations.
</p>
</header>
<nav class="orch-dashboard__nav">
<a routerLink="/orchestrator/jobs" class="orch-dashboard__card">
<span class="orch-dashboard__card-icon">&#x1F4CB;</span>
<span class="orch-dashboard__card-title">Jobs</span>
<span class="orch-dashboard__card-desc">View job status and history</span>
</a>
@if (authService.canOperateOrchestrator()) {
<a routerLink="/orchestrator/quotas" class="orch-dashboard__card">
<span class="orch-dashboard__card-icon">&#x2699;</span>
<span class="orch-dashboard__card-title">Quotas</span>
<span class="orch-dashboard__card-desc">Manage resource quotas</span>
</a>
}
</nav>
<section class="orch-dashboard__scope-info">
<h2>Your Orchestrator Access</h2>
<ul>
<li>
<strong>View Jobs:</strong>
{{ authService.canViewOrchestrator() ? 'Granted' : 'Denied' }}
</li>
<li>
<strong>Operate:</strong>
{{ authService.canOperateOrchestrator() ? 'Granted' : 'Denied' }}
</li>
<li>
<strong>Manage Quotas:</strong>
{{ authService.canManageOrchestratorQuotas() ? 'Granted' : 'Denied' }}
</li>
<li>
<strong>Initiate Backfill:</strong>
{{ authService.canInitiateBackfill() ? 'Granted' : 'Denied' }}
</li>
</ul>
</section>
</div>
`,
styles: [`
.orch-dashboard {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.orch-dashboard__header {
margin-bottom: 2rem;
}
.orch-dashboard__title {
margin: 0 0 0.5rem;
font-size: 1.75rem;
font-weight: 600;
color: #111827;
}
.orch-dashboard__description {
margin: 0;
color: #6b7280;
}
.orch-dashboard__nav {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.orch-dashboard__card {
display: flex;
flex-direction: column;
padding: 1.5rem;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
text-decoration: none;
transition: box-shadow 0.15s, border-color 0.15s;
&:hover {
border-color: #3b82f6;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
&:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
}
.orch-dashboard__card-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.orch-dashboard__card-title {
font-size: 1.125rem;
font-weight: 600;
color: #111827;
margin-bottom: 0.25rem;
}
.orch-dashboard__card-desc {
font-size: 0.875rem;
color: #6b7280;
}
.orch-dashboard__scope-info {
padding: 1.5rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
h2 {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: 600;
color: #374151;
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
li {
padding: 0.5rem 0;
font-size: 0.875rem;
color: #374151;
border-bottom: 1px solid #e5e7eb;
&:last-child {
border-bottom: none;
}
strong {
display: inline-block;
min-width: 140px;
color: #6b7280;
}
}
}
`],
})
export class OrchestratorDashboardComponent {
protected readonly authService = inject(AUTH_SERVICE) as AuthService;
}

View File

@@ -0,0 +1,94 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
/**
* Orchestrator Job Detail - Shows details for a specific job.
* Requires orch:read scope for access.
*
* @see UI-ORCH-32-001
*/
@Component({
selector: 'app-orchestrator-job-detail',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<div class="orch-job-detail">
<header class="orch-job-detail__header">
<a routerLink="/orchestrator/jobs" class="orch-job-detail__back">&larr; Back to Jobs</a>
<h1 class="orch-job-detail__title">Job Detail</h1>
<p class="orch-job-detail__id">ID: {{ jobId }}</p>
</header>
<div class="orch-job-detail__placeholder">
<p>Job details will be implemented when Orchestrator API contract is finalized.</p>
<p class="orch-job-detail__hint">This page requires the <code>orch:read</code> scope.</p>
</div>
</div>
`,
styles: [`
.orch-job-detail {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.orch-job-detail__header {
margin-bottom: 2rem;
}
.orch-job-detail__back {
display: inline-block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: #3b82f6;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.orch-job-detail__title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: #111827;
}
.orch-job-detail__id {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: #6b7280;
font-family: monospace;
}
.orch-job-detail__placeholder {
padding: 3rem;
background: #f9fafb;
border: 1px dashed #d1d5db;
border-radius: 8px;
text-align: center;
color: #6b7280;
p {
margin: 0.5rem 0;
}
code {
background: #e5e7eb;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.875rem;
}
}
.orch-job-detail__hint {
font-size: 0.875rem;
color: #9ca3af;
}
`],
})
export class OrchestratorJobDetailComponent {
@Input() jobId: string = '';
}

View File

@@ -0,0 +1,84 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
/**
* Orchestrator Jobs List - Shows all orchestrator jobs.
* Requires orch:read scope for access.
*
* @see UI-ORCH-32-001
*/
@Component({
selector: 'app-orchestrator-jobs',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<div class="orch-jobs">
<header class="orch-jobs__header">
<a routerLink="/orchestrator" class="orch-jobs__back">&larr; Back to Dashboard</a>
<h1 class="orch-jobs__title">Orchestrator Jobs</h1>
</header>
<div class="orch-jobs__placeholder">
<p>Job list will be implemented when Orchestrator API contract is finalized.</p>
<p class="orch-jobs__hint">This page requires the <code>orch:read</code> scope.</p>
</div>
</div>
`,
styles: [`
.orch-jobs {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.orch-jobs__header {
margin-bottom: 2rem;
}
.orch-jobs__back {
display: inline-block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: #3b82f6;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.orch-jobs__title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: #111827;
}
.orch-jobs__placeholder {
padding: 3rem;
background: #f9fafb;
border: 1px dashed #d1d5db;
border-radius: 8px;
text-align: center;
color: #6b7280;
p {
margin: 0.5rem 0;
}
code {
background: #e5e7eb;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.875rem;
}
}
.orch-jobs__hint {
font-size: 0.875rem;
color: #9ca3af;
}
`],
})
export class OrchestratorJobsComponent {}

View File

@@ -0,0 +1,86 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
/**
* Orchestrator Quotas Management - Manage resource quotas.
* Requires orch:read + orch:operate scopes for access.
*
* @see UI-ORCH-32-001
*/
@Component({
selector: 'app-orchestrator-quotas',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<div class="orch-quotas">
<header class="orch-quotas__header">
<a routerLink="/orchestrator" class="orch-quotas__back">&larr; Back to Dashboard</a>
<h1 class="orch-quotas__title">Orchestrator Quotas</h1>
</header>
<div class="orch-quotas__placeholder">
<p>Quota management will be implemented when Orchestrator API contract is finalized.</p>
<p class="orch-quotas__hint">
This page requires the <code>orch:read</code> and <code>orch:operate</code> scopes.
</p>
</div>
</div>
`,
styles: [`
.orch-quotas {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.orch-quotas__header {
margin-bottom: 2rem;
}
.orch-quotas__back {
display: inline-block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: #3b82f6;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.orch-quotas__title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: #111827;
}
.orch-quotas__placeholder {
padding: 3rem;
background: #f9fafb;
border: 1px dashed #d1d5db;
border-radius: 8px;
text-align: center;
color: #6b7280;
p {
margin: 0.5rem 0;
}
code {
background: #e5e7eb;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.875rem;
}
}
.orch-quotas__hint {
font-size: 0.875rem;
color: #9ca3af;
}
`],
})
export class OrchestratorQuotasComponent {}

View File

@@ -0,0 +1,86 @@
{
"$schema": "https://stella-ops.org/i18n/micro-interactions.schema.json",
"_meta": {
"version": "1.0",
"locale": "en-US",
"description": "Micro-interaction copy for StellaOps Console (MI9)"
},
"loading": {
"skeleton": "Loading...",
"spinner": "Please wait...",
"progress": "Loading {percent}%",
"slow": "This is taking longer than expected...",
"retry": "Retry"
},
"error": {
"generic": "Something went wrong",
"network": "Network error. Check your connection.",
"timeout": "Request timed out. Please try again.",
"notFound": "The requested resource was not found.",
"unauthorized": "You don't have permission to view this.",
"serverError": "Server error. Please try again later.",
"tryAgain": "Try again",
"goBack": "Go back",
"contactSupport": "Contact support"
},
"offline": {
"banner": "You're offline",
"description": "Some features may be unavailable.",
"lastSync": "Last synced {time}",
"reconnecting": "Reconnecting...",
"reconnected": "Back online"
},
"toast": {
"success": "Success",
"info": "Info",
"warning": "Warning",
"error": "Error",
"dismiss": "Dismiss",
"undo": "Undo",
"undoCountdown": "Undo ({seconds}s)"
},
"actions": {
"save": "Save",
"saving": "Saving...",
"saved": "Saved",
"cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete",
"deleting": "Deleting...",
"deleted": "Deleted",
"submit": "Submit",
"submitting": "Submitting...",
"submitted": "Submitted",
"close": "Close",
"expand": "Expand",
"collapse": "Collapse",
"showMore": "Show more",
"showLess": "Show less"
},
"validation": {
"required": "This field is required",
"invalid": "Invalid value",
"tooLong": "Maximum {max} characters allowed",
"tooShort": "Minimum {min} characters required",
"invalidEmail": "Please enter a valid email address",
"invalidUrl": "Please enter a valid URL"
},
"accessibility": {
"loading": "Content is loading",
"loaded": "Content loaded",
"error": "An error occurred",
"expanded": "Expanded",
"collapsed": "Collapsed",
"selected": "Selected",
"deselected": "Deselected",
"required": "Required field",
"optional": "Optional",
"menu": "Menu",
"dialog": "Dialog",
"alert": "Alert"
},
"motion": {
"reducedMotion": "Animations reduced",
"motionEnabled": "Animations enabled"
}
}