feat: add security sink detection patterns for JavaScript/TypeScript
- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations). - Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns. - Added `package-lock.json` for dependency management.
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
@@ -25,15 +25,15 @@
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
{
|
||||
"glob": "config.json",
|
||||
"input": "src/config",
|
||||
"output": "."
|
||||
}
|
||||
],
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
{
|
||||
"glob": "config.json",
|
||||
"input": "src/config",
|
||||
"output": "."
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
@@ -49,8 +49,8 @@
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "2kb",
|
||||
"maximumError": "4kb"
|
||||
"maximumWarning": "6kb",
|
||||
"maximumError": "12kb"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
@@ -81,59 +81,59 @@
|
||||
"buildTarget": "stellaops-web:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"karmaConfig": "karma.conf.cjs",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/app/features/policy-studio/editor/monaco-loader.service.ts",
|
||||
"with": "src/app/features/policy-studio/editor/monaco-loader.service.stub.ts"
|
||||
}
|
||||
],
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
{
|
||||
"glob": "config.json",
|
||||
"input": "src/config",
|
||||
"output": "."
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
},
|
||||
"storybook": {
|
||||
"builder": "@storybook/angular:start-storybook",
|
||||
"options": {
|
||||
"configDir": ".storybook",
|
||||
"browserTarget": "stellaops-web:build",
|
||||
"compodoc": false,
|
||||
"port": 6006
|
||||
}
|
||||
},
|
||||
"build-storybook": {
|
||||
"builder": "@storybook/angular:build-storybook",
|
||||
"options": {
|
||||
"configDir": ".storybook",
|
||||
"browserTarget": "stellaops-web:build",
|
||||
"compodoc": false,
|
||||
"outputDir": "storybook-static"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"karmaConfig": "karma.conf.cjs",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/app/features/policy-studio/editor/monaco-loader.service.ts",
|
||||
"with": "src/app/features/policy-studio/editor/monaco-loader.service.stub.ts"
|
||||
}
|
||||
],
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets",
|
||||
{
|
||||
"glob": "config.json",
|
||||
"input": "src/config",
|
||||
"output": "."
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
},
|
||||
"storybook": {
|
||||
"builder": "@storybook/angular:start-storybook",
|
||||
"options": {
|
||||
"configDir": ".storybook",
|
||||
"browserTarget": "stellaops-web:build",
|
||||
"compodoc": false,
|
||||
"port": 6006
|
||||
}
|
||||
},
|
||||
"build-storybook": {
|
||||
"builder": "@storybook/angular:build-storybook",
|
||||
"options": {
|
||||
"configDir": ".storybook",
|
||||
"browserTarget": "stellaops-web:build",
|
||||
"compodoc": false,
|
||||
"outputDir": "storybook-static"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"analytics": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
982
src/Web/StellaOps.Web/package-lock.json
generated
982
src/Web/StellaOps.Web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,10 +24,12 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^17.3.0",
|
||||
"@angular/cdk": "^17.3.10",
|
||||
"@angular/common": "^17.3.0",
|
||||
"@angular/compiler": "^17.3.0",
|
||||
"@angular/core": "^17.3.0",
|
||||
"@angular/forms": "^17.3.0",
|
||||
"@angular/material": "^17.3.10",
|
||||
"@angular/platform-browser": "^17.3.0",
|
||||
"@angular/platform-browser-dynamic": "^17.3.0",
|
||||
"@angular/router": "^17.3.0",
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
import { AppConfigService } from '../config/app-config.service';
|
||||
import {
|
||||
AocMetrics,
|
||||
AocVerificationRequest,
|
||||
AocVerificationResult,
|
||||
AocDashboardSummary,
|
||||
ViolationDetail,
|
||||
TenantThroughput,
|
||||
} from './aoc.models';
|
||||
|
||||
/**
|
||||
* AOC API interface for dependency injection.
|
||||
*/
|
||||
export interface AocApi {
|
||||
getDashboardSummary(): Observable<AocDashboardSummary>;
|
||||
startVerification(): Observable<AocVerificationRequest>;
|
||||
getVerificationStatus(requestId: string): Observable<AocVerificationRequest>;
|
||||
getViolationsByCode(code: string): Observable<ViolationDetail[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection token for AOC API.
|
||||
*/
|
||||
export const AOC_API = new InjectionToken<AocApi>('AOC_API');
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AocClient {
|
||||
private readonly http = inject(HttpClient);
|
||||
@@ -114,3 +132,149 @@ export class AocClient {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock AOC API implementation for development.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockAocApi implements AocApi {
|
||||
getDashboardSummary(): Observable<AocDashboardSummary> {
|
||||
const now = new Date();
|
||||
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
// Generate history data points
|
||||
const history: { timestamp: string; value: number }[] = [];
|
||||
for (let i = 23; i >= 0; i--) {
|
||||
const ts = new Date(now.getTime() - i * 60 * 60 * 1000);
|
||||
history.push({
|
||||
timestamp: ts.toISOString(),
|
||||
value: 95 + Math.random() * 5,
|
||||
});
|
||||
}
|
||||
|
||||
const summary: AocDashboardSummary = {
|
||||
passFail: {
|
||||
passCount: 12847,
|
||||
failCount: 23,
|
||||
totalCount: 12870,
|
||||
passRate: 0.9982,
|
||||
trend: 'improving',
|
||||
history,
|
||||
},
|
||||
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(),
|
||||
},
|
||||
],
|
||||
throughput: {
|
||||
docsPerMinute: 8.9,
|
||||
avgLatencyMs: 145,
|
||||
p95LatencyMs: 312,
|
||||
queueDepth: 3,
|
||||
errorRate: 0.18,
|
||||
},
|
||||
throughputByTenant: [
|
||||
{ tenantId: 'tenant-1', tenantName: 'Production', documentsIngested: 8500, bytesIngested: 12500000 },
|
||||
{ tenantId: 'tenant-2', tenantName: 'Staging', documentsIngested: 3200, bytesIngested: 4800000 },
|
||||
{ tenantId: 'tenant-3', tenantName: 'Development', documentsIngested: 1170, bytesIngested: 1750000 },
|
||||
],
|
||||
sources: [
|
||||
{ id: 'src-1', sourceId: 'src-1', name: 'Docker Hub', type: 'registry', status: 'healthy', enabled: true, lastSync: now.toISOString() },
|
||||
{ id: 'src-2', sourceId: 'src-2', name: 'GitHub Packages', type: 'registry', status: 'healthy', enabled: true, lastSync: now.toISOString() },
|
||||
{ id: 'src-3', sourceId: 'src-3', name: 'Internal Git', type: 'git', status: 'degraded', enabled: true, lastSync: dayAgo.toISOString() },
|
||||
],
|
||||
timeWindow: {
|
||||
start: dayAgo.toISOString(),
|
||||
end: now.toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
return of(summary).pipe(delay(300));
|
||||
}
|
||||
|
||||
startVerification(): Observable<AocVerificationRequest> {
|
||||
return of({
|
||||
tenantId: 'tenant-1',
|
||||
requestId: 'req-' + Date.now(),
|
||||
status: 'pending',
|
||||
} as AocVerificationRequest & { requestId: string; status: string }).pipe(delay(200));
|
||||
}
|
||||
|
||||
getVerificationStatus(requestId: string): Observable<AocVerificationRequest> {
|
||||
return of({
|
||||
tenantId: 'tenant-1',
|
||||
requestId,
|
||||
status: 'completed',
|
||||
} as AocVerificationRequest & { requestId: string; status: string }).pipe(delay(200));
|
||||
}
|
||||
|
||||
getViolationsByCode(code: string): Observable<ViolationDetail[]> {
|
||||
const now = new Date();
|
||||
const violations: ViolationDetail[] = [
|
||||
{
|
||||
violationId: 'viol-1',
|
||||
documentType: 'sbom',
|
||||
documentId: 'doc-abc123',
|
||||
severity: 'high',
|
||||
detectedAt: new Date(now.getTime() - 15 * 60 * 1000).toISOString(),
|
||||
offendingFields: [
|
||||
{
|
||||
path: 'attestation.provenance',
|
||||
expectedValue: 'present',
|
||||
actualValue: undefined,
|
||||
reason: 'Required provenance attestation is missing from the SBOM document',
|
||||
},
|
||||
],
|
||||
provenance: {
|
||||
sourceType: 'registry',
|
||||
sourceUri: 'docker.io/library/nginx:latest',
|
||||
ingestedAt: new Date(now.getTime() - 20 * 60 * 1000).toISOString(),
|
||||
ingestedBy: 'scanner-agent-01',
|
||||
},
|
||||
suggestion: 'Add provenance attestation using in-toto/SLSA format',
|
||||
},
|
||||
{
|
||||
violationId: 'viol-2',
|
||||
documentType: 'attestation',
|
||||
documentId: 'doc-def456',
|
||||
severity: 'high',
|
||||
detectedAt: new Date(now.getTime() - 30 * 60 * 1000).toISOString(),
|
||||
offendingFields: [
|
||||
{
|
||||
path: 'predicate.builder.id',
|
||||
expectedValue: 'https://github.com/actions/runner',
|
||||
actualValue: 'unknown',
|
||||
reason: 'Builder ID does not match expected trusted builder',
|
||||
},
|
||||
],
|
||||
provenance: {
|
||||
sourceType: 'git',
|
||||
sourceUri: 'github.com/org/repo',
|
||||
ingestedAt: new Date(now.getTime() - 35 * 60 * 1000).toISOString(),
|
||||
ingestedBy: 'scanner-agent-02',
|
||||
commitSha: 'abc1234567890',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return of(violations.filter(() => true)).pipe(delay(300));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,3 +95,115 @@ export interface AocDocumentView {
|
||||
rawContent?: Record<string, unknown>;
|
||||
highlightedFields: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Violation severity levels.
|
||||
*/
|
||||
export type ViolationSeverity = 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
/**
|
||||
* AOC source configuration.
|
||||
*/
|
||||
export interface AocSource {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
name: string;
|
||||
type: 'registry' | 'git' | 'upload' | 'api';
|
||||
url?: string;
|
||||
enabled: boolean;
|
||||
lastSync?: string;
|
||||
status: 'healthy' | 'degraded' | 'offline';
|
||||
}
|
||||
|
||||
/**
|
||||
* Violation code definition.
|
||||
*/
|
||||
export interface AocViolationCode {
|
||||
code: string;
|
||||
description: string;
|
||||
severity: ViolationSeverity;
|
||||
category: string;
|
||||
remediation?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard summary data.
|
||||
*/
|
||||
export interface AocDashboardSummary {
|
||||
/** Pass/fail metrics */
|
||||
passFail: {
|
||||
passCount: number;
|
||||
failCount: number;
|
||||
totalCount: number;
|
||||
passRate: number;
|
||||
trend?: 'improving' | 'degrading' | 'stable';
|
||||
history?: { timestamp: string; value: number }[];
|
||||
};
|
||||
/** Recent violations */
|
||||
recentViolations: AocViolationSummary[];
|
||||
/** Ingest throughput */
|
||||
throughput: AocIngestThroughput;
|
||||
/** Throughput by tenant */
|
||||
throughputByTenant: TenantThroughput[];
|
||||
/** Configured sources */
|
||||
sources: AocSource[];
|
||||
/** Time window */
|
||||
timeWindow: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenant-level throughput metrics.
|
||||
*/
|
||||
export interface TenantThroughput {
|
||||
tenantId: string;
|
||||
tenantName?: string;
|
||||
documentsIngested: number;
|
||||
bytesIngested: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field that caused a violation.
|
||||
*/
|
||||
export interface OffendingField {
|
||||
path: string;
|
||||
expected?: string;
|
||||
actual?: string;
|
||||
expectedValue?: string;
|
||||
actualValue?: string;
|
||||
reason: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed violation record for display.
|
||||
*/
|
||||
export interface ViolationDetail {
|
||||
violationId: string;
|
||||
documentType: string;
|
||||
documentId: string;
|
||||
severity: ViolationSeverity;
|
||||
detectedAt: string;
|
||||
offendingFields: OffendingField[];
|
||||
provenance: ViolationProvenance;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provenance metadata for a violation.
|
||||
*/
|
||||
export interface ViolationProvenance {
|
||||
sourceType: string;
|
||||
sourceUri: string;
|
||||
ingestedAt: string;
|
||||
ingestedBy: string;
|
||||
buildId?: string;
|
||||
commitSha?: string;
|
||||
pipelineUrl?: string;
|
||||
}
|
||||
|
||||
// Type aliases for backwards compatibility
|
||||
export type IngestThroughput = AocIngestThroughput;
|
||||
export type VerificationRequest = AocVerificationRequest;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TenantActivationService } from '../auth/tenant-activation.service';
|
||||
import { SignalsApi, SIGNALS_API, ReachabilityFact, ReachabilityStatus, SignalsHttpClient, MockSignalsClient } from './signals.client';
|
||||
import { Vulnerability, VulnerabilitiesQueryOptions, VulnerabilitiesResponse } from './vulnerability.models';
|
||||
import { VulnerabilityApi, VULNERABILITY_API, MockVulnerabilityApiService } from './vulnerability.client';
|
||||
import { PolicySimulationRequest, PolicySimulationResult } from './policy-engine.models';
|
||||
import { QuickSimulationRequest, RiskSimulationResult } from './policy-engine.models';
|
||||
import { generateTraceId } from './trace.util';
|
||||
|
||||
/**
|
||||
@@ -98,7 +98,7 @@ export interface ReachabilityOverride {
|
||||
/**
|
||||
* Policy simulation with reachability request.
|
||||
*/
|
||||
export interface PolicySimulationWithReachabilityRequest extends PolicySimulationRequest {
|
||||
export interface PolicySimulationWithReachabilityRequest extends QuickSimulationRequest {
|
||||
/** Include reachability in evaluation. */
|
||||
includeReachability?: boolean;
|
||||
/** Reachability overrides for what-if analysis. */
|
||||
@@ -110,7 +110,7 @@ export interface PolicySimulationWithReachabilityRequest extends PolicySimulatio
|
||||
/**
|
||||
* Policy simulation result with reachability.
|
||||
*/
|
||||
export interface PolicySimulationWithReachabilityResult extends PolicySimulationResult {
|
||||
export interface PolicySimulationWithReachabilityResult extends RiskSimulationResult {
|
||||
/** Reachability impact on result. */
|
||||
reachabilityImpact: {
|
||||
/** Number of rules affected by reachability. */
|
||||
@@ -469,7 +469,7 @@ export class ReachabilityIntegrationService {
|
||||
private simulatePolicyDecision(
|
||||
request: PolicySimulationWithReachabilityRequest,
|
||||
reachabilityMap: Map<string, ComponentReachability>
|
||||
): PolicySimulationResult {
|
||||
): RiskSimulationResult {
|
||||
// Simplified simulation logic
|
||||
const hasReachable = Array.from(reachabilityMap.values()).some((r) => r.status === 'reachable');
|
||||
|
||||
@@ -478,7 +478,7 @@ export class ReachabilityIntegrationService {
|
||||
policyId: request.packId ?? 'default',
|
||||
timestamp: new Date().toISOString(),
|
||||
reason: hasReachable ? 'Reachable components found' : 'No reachable components',
|
||||
} as PolicySimulationResult;
|
||||
} as RiskSimulationResult;
|
||||
}
|
||||
|
||||
private countRulesAffectedByReachability(
|
||||
|
||||
@@ -161,3 +161,69 @@ export interface BulkUnknownsResult {
|
||||
readonly error: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Budget Models - Sprint 5100.0004.0001 T4
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Reason code for unknown classification.
|
||||
*/
|
||||
export type UnknownReasonCode =
|
||||
| 'Reachability'
|
||||
| 'Identity'
|
||||
| 'Provenance'
|
||||
| 'VexConflict'
|
||||
| 'FeedGap'
|
||||
| 'ConfigUnknown'
|
||||
| 'AnalyzerLimit';
|
||||
|
||||
/**
|
||||
* Budget action when exceeded.
|
||||
*/
|
||||
export type BudgetAction = 'Warn' | 'Block' | 'WarnUnlessException';
|
||||
|
||||
/**
|
||||
* Budget configuration for an environment.
|
||||
*/
|
||||
export interface UnknownBudget {
|
||||
readonly environment: string;
|
||||
readonly totalLimit: number | null;
|
||||
readonly reasonLimits: Record<UnknownReasonCode, number>;
|
||||
readonly action: BudgetAction;
|
||||
readonly exceededMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Budget violation details.
|
||||
*/
|
||||
export interface BudgetViolation {
|
||||
readonly reasonCode: UnknownReasonCode;
|
||||
readonly count: number;
|
||||
readonly limit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of checking unknowns against a budget.
|
||||
*/
|
||||
export interface BudgetCheckResult {
|
||||
readonly isWithinBudget: boolean;
|
||||
readonly recommendedAction: BudgetAction;
|
||||
readonly totalUnknowns: number;
|
||||
readonly totalLimit: number | null;
|
||||
readonly violations: readonly BudgetViolation[];
|
||||
readonly message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Budget status summary for dashboards.
|
||||
*/
|
||||
export interface BudgetStatusSummary {
|
||||
readonly environment: string;
|
||||
readonly totalUnknowns: number;
|
||||
readonly totalLimit: number | null;
|
||||
readonly percentageUsed: number;
|
||||
readonly isExceeded: boolean;
|
||||
readonly violationCount: number;
|
||||
readonly byReasonCode: Record<UnknownReasonCode, number>;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ describe('ExceptionApprovalQueueComponent', () => {
|
||||
let mockExceptionApi: jasmine.SpyObj<ExceptionApi>;
|
||||
|
||||
const mockPendingException: Exception = {
|
||||
schemaVersion: '1.0',
|
||||
tenantId: 'tenant-001',
|
||||
exceptionId: 'exc-pending-001',
|
||||
name: 'pending-exception',
|
||||
displayName: 'Pending Exception',
|
||||
@@ -42,7 +44,7 @@ describe('ExceptionApprovalQueueComponent', () => {
|
||||
]);
|
||||
|
||||
mockExceptionApi.listExceptions.and.returnValue(
|
||||
of({ items: [mockPendingException], total: 1 })
|
||||
of({ items: [mockPendingException], count: 1, continuationToken: null })
|
||||
);
|
||||
mockExceptionApi.transitionStatus.and.returnValue(of(mockPendingException));
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { of, throwError, Subject } from 'rxjs';
|
||||
import { of, throwError, Subject, EMPTY } from 'rxjs';
|
||||
|
||||
import { ExceptionDashboardComponent } from './exception-dashboard.component';
|
||||
import { EXCEPTION_API, ExceptionApi } from '../../core/api/exception.client';
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
EXCEPTION_EVENTS_API,
|
||||
ExceptionEventsApi,
|
||||
} from '../../core/api/exception-events.client';
|
||||
import { ExceptionEventDto } from '../../core/api/exception-events.models';
|
||||
import { Exception } from '../../core/api/exception.contract.models';
|
||||
import { AuthSessionStore } from '../../core/auth/auth-session.store';
|
||||
import { StellaOpsScopes } from '../../core/auth/scopes';
|
||||
@@ -19,16 +20,18 @@ describe('ExceptionDashboardComponent', () => {
|
||||
let mockEventsApi: jasmine.SpyObj<ExceptionEventsApi>;
|
||||
let mockAuthStore: jasmine.SpyObj<AuthSessionStore>;
|
||||
let mockRouter: jasmine.SpyObj<Router>;
|
||||
let eventsSubject: Subject<void>;
|
||||
let eventsSubject: Subject<ExceptionEventDto>;
|
||||
|
||||
const mockException: Exception = {
|
||||
schemaVersion: '1.0',
|
||||
tenantId: 'tenant-001',
|
||||
exceptionId: 'exc-001',
|
||||
name: 'test-exception',
|
||||
displayName: 'Test Exception',
|
||||
description: 'Test description',
|
||||
type: 'vulnerability',
|
||||
severity: 'high',
|
||||
status: 'active',
|
||||
status: 'approved',
|
||||
scope: {
|
||||
type: 'global',
|
||||
vulnIds: ['CVE-2024-1234'],
|
||||
@@ -46,7 +49,7 @@ describe('ExceptionDashboardComponent', () => {
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
eventsSubject = new Subject<void>();
|
||||
eventsSubject = new Subject<ExceptionEventDto>();
|
||||
|
||||
mockExceptionApi = jasmine.createSpyObj('ExceptionApi', [
|
||||
'listExceptions',
|
||||
@@ -57,13 +60,13 @@ describe('ExceptionDashboardComponent', () => {
|
||||
mockEventsApi = jasmine.createSpyObj('ExceptionEventsApi', ['streamEvents']);
|
||||
mockAuthStore = jasmine.createSpyObj('AuthSessionStore', [], {
|
||||
session: jasmine.createSpy().and.returnValue({
|
||||
scopes: [StellaOpsScopes.EXCEPTION_MANAGE],
|
||||
scopes: [StellaOpsScopes.EXCEPTION_WRITE],
|
||||
}),
|
||||
});
|
||||
mockRouter = jasmine.createSpyObj('Router', ['navigate']);
|
||||
|
||||
mockExceptionApi.listExceptions.and.returnValue(
|
||||
of({ items: [mockException], total: 1 })
|
||||
of({ items: [mockException], count: 1, continuationToken: null })
|
||||
);
|
||||
mockEventsApi.streamEvents.and.returnValue(eventsSubject.asObservable());
|
||||
|
||||
@@ -118,6 +121,8 @@ describe('ExceptionDashboardComponent', () => {
|
||||
cves: ['CVE-2024-5678'],
|
||||
},
|
||||
tags: ['security'],
|
||||
recheckPolicy: null,
|
||||
evidenceSubmissions: [],
|
||||
};
|
||||
|
||||
mockExceptionApi.createException.and.returnValue(of(mockException));
|
||||
@@ -141,7 +146,12 @@ describe('ExceptionDashboardComponent', () => {
|
||||
await fixture.whenStable();
|
||||
|
||||
mockExceptionApi.listExceptions.calls.reset();
|
||||
eventsSubject.next();
|
||||
eventsSubject.next({
|
||||
type: 'exception.created',
|
||||
tenantId: 'tenant-001',
|
||||
exceptionId: 'exc-002',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(mockExceptionApi.listExceptions).toHaveBeenCalledWith({ limit: 200 });
|
||||
|
||||
@@ -282,13 +282,13 @@ export class ExceptionDashboardComponent implements OnInit, OnDestroy {
|
||||
|
||||
private mapScope(scope: ContractException['scope']): ExceptionScope {
|
||||
return {
|
||||
images: scope.images ?? undefined,
|
||||
cves: scope.cves ?? scope.vulnIds ?? undefined,
|
||||
packages: scope.packages ?? undefined,
|
||||
licenses: scope.licenses ?? undefined,
|
||||
policyRules: scope.policyRules ?? undefined,
|
||||
images: scope.images ? [...scope.images] : undefined,
|
||||
cves: scope.cves ? [...scope.cves] : scope.vulnIds ? [...scope.vulnIds] : undefined,
|
||||
packages: scope.packages ? [...scope.packages] : undefined,
|
||||
licenses: scope.licenses ? [...scope.licenses] : undefined,
|
||||
policyRules: scope.policyRules ? [...scope.policyRules] : undefined,
|
||||
tenantId: scope.tenantId,
|
||||
environments: scope.environments ?? undefined,
|
||||
environments: scope.environments ? [...scope.environments] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ describe('ExceptionDetailComponent', () => {
|
||||
let component: ExceptionDetailComponent;
|
||||
|
||||
const mockException: Exception = {
|
||||
schemaVersion: '1.0',
|
||||
tenantId: 'tenant-001',
|
||||
exceptionId: 'exc-001',
|
||||
name: 'test-exception',
|
||||
displayName: 'Test Exception',
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
Exception,
|
||||
ExceptionType,
|
||||
ExceptionScope,
|
||||
} from '../../core/api/exception.models';
|
||||
|
||||
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'
|
||||
@@ -84,21 +84,21 @@ interface EvidenceSubmission {
|
||||
fileName?: string;
|
||||
validationStatus: EvidenceValidationStatus;
|
||||
}
|
||||
|
||||
export interface JustificationTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
template: string;
|
||||
type: ExceptionType[];
|
||||
}
|
||||
|
||||
export interface TimeboxPreset {
|
||||
label: string;
|
||||
days: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
|
||||
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';
|
||||
@@ -110,34 +110,34 @@ export interface ExceptionDraft {
|
||||
recheckPolicy: RecheckPolicyDraft | null;
|
||||
evidenceSubmissions: EvidenceSubmission[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-exception-wizard',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './exception-wizard.component.html',
|
||||
styleUrls: ['./exception-wizard.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
|
||||
@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>();
|
||||
|
||||
/** 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[]>([]);
|
||||
|
||||
/** 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',
|
||||
@@ -160,57 +160,57 @@ export class ExceptionWizardComponent {
|
||||
recheckPolicy: null,
|
||||
evidenceSubmissions: [],
|
||||
});
|
||||
|
||||
readonly scopePreview = signal<string[]>([]);
|
||||
|
||||
readonly scopePreview = signal<string[]>([]);
|
||||
readonly selectedTemplate = signal<string | null>(null);
|
||||
readonly newTag = signal('');
|
||||
private conditionCounter = 0;
|
||||
|
||||
|
||||
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' },
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
{ 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'],
|
||||
},
|
||||
{
|
||||
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 defaultEvidenceHooks: EvidenceHookRequirement[] = [
|
||||
@@ -246,7 +246,7 @@ export class ExceptionWizardComponent {
|
||||
},
|
||||
];
|
||||
|
||||
readonly evidenceHooks = input<EvidenceHookRequirement[]>(this.defaultEvidenceHooks);
|
||||
readonly evidenceHooks = input<EvidenceHookRequirement[]>([]);
|
||||
|
||||
readonly environmentOptions = ['development', 'staging', 'production'];
|
||||
|
||||
@@ -285,11 +285,11 @@ export class ExceptionWizardComponent {
|
||||
];
|
||||
|
||||
readonly currentStepIndex = computed(() => this.steps.indexOf(this.currentStep()));
|
||||
|
||||
readonly canGoNext = computed(() => {
|
||||
const step = this.currentStep();
|
||||
const d = this.draft();
|
||||
|
||||
|
||||
readonly canGoNext = computed(() => {
|
||||
const step = this.currentStep();
|
||||
const d = this.draft();
|
||||
|
||||
switch (step) {
|
||||
case 'type':
|
||||
return d.type !== null;
|
||||
@@ -309,13 +309,23 @@ export class ExceptionWizardComponent {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
readonly canGoBack = computed(() => this.currentStepIndex() > 0);
|
||||
|
||||
|
||||
readonly canGoBack = computed(() => this.currentStepIndex() > 0);
|
||||
|
||||
readonly effectiveTemplates = computed(() => {
|
||||
const input = this.templates();
|
||||
return input.length > 0 ? input : this.defaultTemplates;
|
||||
});
|
||||
|
||||
readonly effectiveEvidenceHooks = computed(() => {
|
||||
const input = this.evidenceHooks();
|
||||
return input.length > 0 ? input : this.defaultEvidenceHooks;
|
||||
});
|
||||
|
||||
readonly applicableTemplates = computed(() => {
|
||||
const type = this.draft().type;
|
||||
if (!type) return [];
|
||||
return (this.templates() || this.defaultTemplates).filter((t) => t.type.includes(type));
|
||||
return this.effectiveTemplates().filter((t) => t.type.includes(type));
|
||||
});
|
||||
|
||||
readonly recheckPolicy = computed(() => this.draft().recheckPolicy);
|
||||
@@ -334,7 +344,7 @@ export class ExceptionWizardComponent {
|
||||
|
||||
readonly evidenceEntries = computed(() => {
|
||||
const submissions = this.draft().evidenceSubmissions;
|
||||
return this.evidenceHooks().map((hook) => {
|
||||
return this.effectiveEvidenceHooks().map((hook) => {
|
||||
const submission = submissions.find((s) => s.hookId === hook.hookId) ?? null;
|
||||
const status = this.resolveEvidenceStatus(hook, submission);
|
||||
return {
|
||||
@@ -354,66 +364,66 @@ export class ExceptionWizardComponent {
|
||||
readonly isEvidenceSatisfied = computed(() => {
|
||||
return this.missingEvidence().length === 0;
|
||||
});
|
||||
|
||||
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 }));
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
enableRecheckPolicy(): void {
|
||||
@@ -495,7 +505,7 @@ export class ExceptionWizardComponent {
|
||||
}
|
||||
|
||||
updateEvidenceSubmission(hookId: string, updates: Partial<EvidenceSubmission>): void {
|
||||
const hooks = this.evidenceHooks();
|
||||
const hooks = this.effectiveEvidenceHooks();
|
||||
const hook = hooks.find((h) => h.hookId === hookId);
|
||||
if (!hook) return;
|
||||
|
||||
@@ -536,72 +546,72 @@ export class ExceptionWizardComponent {
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Unknowns Budget Widget Component
|
||||
* Sprint: SPRINT_5100_0004_0001
|
||||
* Task: T4 - Unknowns Dashboard Integration
|
||||
*
|
||||
* Displays budget status with meter visualization, violations,
|
||||
* and environment-based thresholds.
|
||||
*/
|
||||
import { Component, Input, OnInit, inject, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {
|
||||
BudgetStatusSummary,
|
||||
BudgetCheckResult,
|
||||
BudgetViolation,
|
||||
UnknownReasonCode,
|
||||
} from '../../core/api/unknowns.models';
|
||||
|
||||
/** Short codes for reason codes */
|
||||
const REASON_SHORT_CODES: Record<UnknownReasonCode, string> = {
|
||||
Reachability: 'U-RCH',
|
||||
Identity: 'U-ID',
|
||||
Provenance: 'U-PROV',
|
||||
VexConflict: 'U-VEX',
|
||||
FeedGap: 'U-FEED',
|
||||
ConfigUnknown: 'U-CONFIG',
|
||||
AnalyzerLimit: 'U-ANALYZER',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'stella-unknowns-budget-widget',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="budget-widget" [class.exceeded]="isExceeded()">
|
||||
<header class="budget-header">
|
||||
<h3>Unknowns Budget</h3>
|
||||
<span class="environment-badge">{{ environment() }}</span>
|
||||
</header>
|
||||
|
||||
<!-- Budget Meter -->
|
||||
<div class="budget-meter" [attr.aria-valuenow]="usagePercent()" aria-valuemin="0" aria-valuemax="100">
|
||||
<div
|
||||
class="meter-fill"
|
||||
[style.width.%]="usagePercent()"
|
||||
[class.warning]="usagePercent() > 75 && usagePercent() <= 100"
|
||||
[class.exceeded]="usagePercent() > 100"
|
||||
></div>
|
||||
<span class="meter-label">
|
||||
{{ status()?.totalUnknowns ?? 0 }} / {{ limitDisplay() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<div class="budget-status">
|
||||
<span [class]="statusClass()">{{ statusText() }}</span>
|
||||
@if (status()?.percentageUsed) {
|
||||
<span class="usage-percent">{{ status()?.percentageUsed | number: '1.1-1' }}% used</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Violations List -->
|
||||
@if (hasViolations()) {
|
||||
<div class="violations">
|
||||
<h4>Violations by Reason</h4>
|
||||
<ul class="violation-list">
|
||||
@for (violation of result()?.violations ?? []; track violation.reasonCode) {
|
||||
<li class="violation-item">
|
||||
<span class="reason-code">{{ getShortCode(violation.reasonCode) }}</span>
|
||||
<span class="violation-counts">
|
||||
{{ violation.count }} / {{ violation.limit }}
|
||||
</span>
|
||||
<span class="violation-bar">
|
||||
<span
|
||||
class="violation-fill"
|
||||
[style.width.%]="violationPercent(violation)"
|
||||
></span>
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- By Reason Code Breakdown -->
|
||||
@if (showDetails() && status()?.byReasonCode) {
|
||||
<div class="reason-breakdown">
|
||||
<h4>By Reason Code</h4>
|
||||
<ul class="reason-list">
|
||||
@for (entry of reasonCodeEntries(); track entry.code) {
|
||||
<li class="reason-item">
|
||||
<span class="reason-code">{{ getShortCode(entry.code) }}</span>
|
||||
<span class="reason-count">{{ entry.count }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Message -->
|
||||
@if (result()?.message) {
|
||||
<div class="budget-message" [class.error]="isExceeded()">
|
||||
{{ result()?.message }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="budget-actions">
|
||||
<button type="button" class="btn-refresh" (click)="refresh()">
|
||||
Refresh
|
||||
</button>
|
||||
@if (showDetails()) {
|
||||
<button type="button" class="btn-toggle" (click)="showDetails.set(false)">
|
||||
Hide Details
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="btn-toggle" (click)="showDetails.set(true)">
|
||||
Show Details
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.budget-widget {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 8px;
|
||||
background: var(--card-bg, #ffffff);
|
||||
font-family: var(--font-family, system-ui, sans-serif);
|
||||
}
|
||||
|
||||
.budget-widget.exceeded {
|
||||
border-color: var(--error-color, #dc3545);
|
||||
background: var(--error-bg, #fff5f5);
|
||||
}
|
||||
|
||||
.budget-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.budget-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.environment-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
background: var(--primary-light, #e3f2fd);
|
||||
color: var(--primary-color, #1976d2);
|
||||
}
|
||||
|
||||
.budget-meter {
|
||||
position: relative;
|
||||
height: 24px;
|
||||
background: var(--meter-bg, #f5f5f5);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.meter-fill {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
background: var(--success-color, #4caf50);
|
||||
border-radius: 12px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.meter-fill.warning {
|
||||
background: var(--warning-color, #ff9800);
|
||||
}
|
||||
|
||||
.meter-fill.exceeded {
|
||||
background: var(--error-color, #dc3545);
|
||||
}
|
||||
|
||||
.meter-label {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color, #333);
|
||||
}
|
||||
|
||||
.budget-status {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.status-pass {
|
||||
color: var(--success-color, #4caf50);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-fail {
|
||||
color: var(--error-color, #dc3545);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.usage-percent {
|
||||
color: var(--text-muted, #666);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.violations, .reason-breakdown {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.violations h4, .reason-breakdown h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, #666);
|
||||
}
|
||||
|
||||
.violation-list, .reason-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.violation-item, .reason-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.reason-code {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--code-bg, #f0f0f0);
|
||||
border-radius: 3px;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.violation-counts, .reason-count {
|
||||
font-size: 0.875rem;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.violation-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--meter-bg, #f5f5f5);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.violation-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--error-color, #dc3545);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.budget-message {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
background: var(--info-bg, #e3f2fd);
|
||||
color: var(--info-color, #1976d2);
|
||||
}
|
||||
|
||||
.budget-message.error {
|
||||
background: var(--error-bg, #fff5f5);
|
||||
color: var(--error-color, #dc3545);
|
||||
}
|
||||
|
||||
.budget-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-refresh, .btn-toggle {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
background: var(--card-bg, #ffffff);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-refresh:hover, .btn-toggle:hover {
|
||||
background: var(--hover-bg, #f5f5f5);
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class UnknownsBudgetWidgetComponent implements OnInit {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
@Input() environment = signal<string>('prod');
|
||||
@Input() apiBaseUrl = '/api/v1/policy';
|
||||
|
||||
readonly status = signal<BudgetStatusSummary | null>(null);
|
||||
readonly result = signal<BudgetCheckResult | null>(null);
|
||||
readonly showDetails = signal(false);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
readonly usagePercent = computed(() => {
|
||||
const s = this.status();
|
||||
if (!s?.totalLimit) return 0;
|
||||
return Math.min((s.totalUnknowns / s.totalLimit) * 100, 150);
|
||||
});
|
||||
|
||||
readonly isExceeded = computed(() => {
|
||||
return this.status()?.isExceeded ?? this.result()?.isWithinBudget === false;
|
||||
});
|
||||
|
||||
readonly statusClass = computed(() => {
|
||||
return this.isExceeded() ? 'status-fail' : 'status-pass';
|
||||
});
|
||||
|
||||
readonly statusText = computed(() => {
|
||||
return this.isExceeded() ? 'Budget Exceeded' : 'Within Budget';
|
||||
});
|
||||
|
||||
readonly limitDisplay = computed(() => {
|
||||
const limit = this.status()?.totalLimit;
|
||||
return limit !== null && limit !== undefined ? String(limit) : '\u221E';
|
||||
});
|
||||
|
||||
readonly hasViolations = computed(() => {
|
||||
return (this.result()?.violations?.length ?? 0) > 0;
|
||||
});
|
||||
|
||||
readonly reasonCodeEntries = computed(() => {
|
||||
const byReason = this.status()?.byReasonCode;
|
||||
if (!byReason) return [];
|
||||
return Object.entries(byReason)
|
||||
.filter(([_, count]) => count > 0)
|
||||
.map(([code, count]) => ({
|
||||
code: code as UnknownReasonCode,
|
||||
count,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
const env = typeof this.environment === 'function'
|
||||
? this.environment()
|
||||
: this.environment;
|
||||
|
||||
this.http
|
||||
.get<BudgetStatusSummary>(
|
||||
`${this.apiBaseUrl}/unknowns/budget/status`,
|
||||
{ params: { environment: env } }
|
||||
)
|
||||
.subscribe({
|
||||
next: (status) => {
|
||||
this.status.set(status);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message || 'Failed to load budget status');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getShortCode(reasonCode: UnknownReasonCode): string {
|
||||
return REASON_SHORT_CODES[reasonCode] ?? reasonCode;
|
||||
}
|
||||
|
||||
violationPercent(violation: BudgetViolation): number {
|
||||
if (violation.limit <= 0) return 100;
|
||||
return Math.min((violation.count / violation.limit) * 100, 100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { Component, input, computed, signal, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { VerdictExplanation, VexStatus, getStatusLabel, getStatusColor } from './trust-algebra.models';
|
||||
|
||||
type SortColumn = 'sourceId' | 'status' | 'claimScore' | 'provenanceScore' | 'coverageScore' | 'replayabilityScore';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
/**
|
||||
* Claim Table Component
|
||||
*
|
||||
* Displays a sortable table of all VEX claims with scores and conflict highlighting.
|
||||
*
|
||||
* @see Sprint 7100.0003.0001 T4
|
||||
*/
|
||||
@Component({
|
||||
selector: 'st-claim-table',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="claim-table">
|
||||
<div class="claim-table__header">
|
||||
<span class="claim-table__title">VEX Claims ({{ claims().length }})</span>
|
||||
<label class="claim-table__toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="showConflictsOnly()"
|
||||
(change)="toggleConflicts()"
|
||||
aria-label="Show only conflicting claims"
|
||||
/>
|
||||
<span>Show Conflicts Only</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="claim-table__container">
|
||||
<table class="claim-table__table" role="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col"
|
||||
tabindex="0"
|
||||
role="columnheader"
|
||||
[attr.aria-sort]="getSortAriaLabel('sourceId')"
|
||||
(click)="sort('sourceId')"
|
||||
(keydown.enter)="sort('sourceId')"
|
||||
(keydown.space)="sort('sourceId'); $event.preventDefault()"
|
||||
class="claim-table__th--sortable">
|
||||
Source
|
||||
@if (sortColumn() === 'sourceId') {
|
||||
<span class="claim-table__sort-icon" aria-hidden="true">{{ sortDirection() === 'asc' ? '↑' : '↓' }}</span>
|
||||
}
|
||||
</th>
|
||||
<th scope="col"
|
||||
tabindex="0"
|
||||
role="columnheader"
|
||||
[attr.aria-sort]="getSortAriaLabel('status')"
|
||||
(click)="sort('status')"
|
||||
(keydown.enter)="sort('status')"
|
||||
(keydown.space)="sort('status'); $event.preventDefault()"
|
||||
class="claim-table__th--sortable">
|
||||
Status
|
||||
@if (sortColumn() === 'status') {
|
||||
<span class="claim-table__sort-icon" aria-hidden="true">{{ sortDirection() === 'asc' ? '↑' : '↓' }}</span>
|
||||
}
|
||||
</th>
|
||||
<th scope="col" role="columnheader">Reason</th>
|
||||
<th scope="col"
|
||||
tabindex="0"
|
||||
role="columnheader"
|
||||
[attr.aria-sort]="getSortAriaLabel('provenanceScore')"
|
||||
[attr.title]="'Provenance Score'"
|
||||
(click)="sort('provenanceScore')"
|
||||
(keydown.enter)="sort('provenanceScore')"
|
||||
(keydown.space)="sort('provenanceScore'); $event.preventDefault()"
|
||||
class="claim-table__th--sortable claim-table__th--numeric">
|
||||
<abbr title="Provenance">P</abbr>
|
||||
@if (sortColumn() === 'provenanceScore') {
|
||||
<span class="claim-table__sort-icon" aria-hidden="true">{{ sortDirection() === 'asc' ? '↑' : '↓' }}</span>
|
||||
}
|
||||
</th>
|
||||
<th scope="col"
|
||||
tabindex="0"
|
||||
role="columnheader"
|
||||
[attr.aria-sort]="getSortAriaLabel('coverageScore')"
|
||||
[attr.title]="'Coverage Score'"
|
||||
(click)="sort('coverageScore')"
|
||||
(keydown.enter)="sort('coverageScore')"
|
||||
(keydown.space)="sort('coverageScore'); $event.preventDefault()"
|
||||
class="claim-table__th--sortable claim-table__th--numeric">
|
||||
<abbr title="Coverage">C</abbr>
|
||||
@if (sortColumn() === 'coverageScore') {
|
||||
<span class="claim-table__sort-icon" aria-hidden="true">{{ sortDirection() === 'asc' ? '↑' : '↓' }}</span>
|
||||
}
|
||||
</th>
|
||||
<th scope="col"
|
||||
tabindex="0"
|
||||
role="columnheader"
|
||||
[attr.aria-sort]="getSortAriaLabel('replayabilityScore')"
|
||||
[attr.title]="'Replayability Score'"
|
||||
(click)="sort('replayabilityScore')"
|
||||
(keydown.enter)="sort('replayabilityScore')"
|
||||
(keydown.space)="sort('replayabilityScore'); $event.preventDefault()"
|
||||
class="claim-table__th--sortable claim-table__th--numeric">
|
||||
<abbr title="Replayability">R</abbr>
|
||||
@if (sortColumn() === 'replayabilityScore') {
|
||||
<span class="claim-table__sort-icon" aria-hidden="true">{{ sortDirection() === 'asc' ? '↑' : '↓' }}</span>
|
||||
}
|
||||
</th>
|
||||
<th scope="col"
|
||||
tabindex="0"
|
||||
role="columnheader"
|
||||
[attr.aria-sort]="getSortAriaLabel('claimScore')"
|
||||
(click)="sort('claimScore')"
|
||||
(keydown.enter)="sort('claimScore')"
|
||||
(keydown.space)="sort('claimScore'); $event.preventDefault()"
|
||||
class="claim-table__th--sortable claim-table__th--numeric">
|
||||
Score
|
||||
@if (sortColumn() === 'claimScore') {
|
||||
<span class="claim-table__sort-icon" aria-hidden="true">{{ sortDirection() === 'asc' ? '↑' : '↓' }}</span>
|
||||
}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (claim of sortedClaims(); track claim.sourceId) {
|
||||
<tr
|
||||
[class.claim-table__row--winner]="claim.accepted"
|
||||
[class.claim-table__row--conflict]="hasConflict() && !claim.accepted"
|
||||
>
|
||||
<td class="claim-table__cell--source">
|
||||
@if (claim.accepted) {
|
||||
<span class="claim-table__winner-icon" title="Winner">★</span>
|
||||
}
|
||||
@if (hasConflict() && !claim.accepted) {
|
||||
<span class="claim-table__conflict-icon" title="Conflict">⚠</span>
|
||||
}
|
||||
{{ claim.sourceId }}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="claim-table__status"
|
||||
[style.color]="getStatusColor(claim.assertedStatus)"
|
||||
>
|
||||
{{ getStatusLabel(claim.assertedStatus) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="claim-table__cell--reason">{{ claim.reason }}</td>
|
||||
<td class="claim-table__cell--numeric">{{ claim.provenanceScore.toFixed(2) }}</td>
|
||||
<td class="claim-table__cell--numeric">{{ claim.coverageScore.toFixed(2) }}</td>
|
||||
<td class="claim-table__cell--numeric">{{ claim.replayabilityScore.toFixed(2) }}</td>
|
||||
<td class="claim-table__cell--numeric claim-table__cell--score">
|
||||
{{ claim.claimScore.toFixed(2) }}
|
||||
@if (claim.accepted) {
|
||||
<span class="claim-table__score-indicator">▲</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (hasConflict()) {
|
||||
<div class="claim-table__legend">
|
||||
<span class="claim-table__legend-item">
|
||||
<span class="claim-table__winner-icon">★</span> = Winner
|
||||
</span>
|
||||
<span class="claim-table__legend-item">
|
||||
<span class="claim-table__conflict-icon">⚠</span> = Conflict (penalty applied)
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.claim-table {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.claim-table__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.claim-table__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.claim-table__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.claim-table__toggle input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.claim-table__container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.claim-table__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.claim-table__table th {
|
||||
padding: 0.625rem 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.claim-table__th--sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: -2px;
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
|
||||
.claim-table__th--numeric {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.claim-table__sort-icon {
|
||||
margin-left: 0.25rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.claim-table__table td {
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.claim-table__row--winner {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.claim-table__row--conflict {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.claim-table__cell--source {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.claim-table__cell--reason {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.claim-table__cell--numeric {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.claim-table__cell--score {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.claim-table__winner-icon {
|
||||
color: #16a34a;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.claim-table__conflict-icon {
|
||||
color: #dc2626;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.claim-table__score-indicator {
|
||||
color: #16a34a;
|
||||
margin-left: 0.25rem;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.claim-table__status {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.claim-table__legend {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.625rem 1rem;
|
||||
background: #f9fafb;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.claim-table__legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class ClaimTableComponent {
|
||||
/**
|
||||
* List of claim explanations to display.
|
||||
*/
|
||||
readonly claims = input.required<VerdictExplanation[]>();
|
||||
|
||||
/**
|
||||
* Emits when a claim row is clicked for details.
|
||||
*/
|
||||
readonly claimSelected = output<VerdictExplanation>();
|
||||
|
||||
protected readonly showConflictsOnly = signal(false);
|
||||
protected readonly sortColumn = signal<SortColumn>('claimScore');
|
||||
protected readonly sortDirection = signal<SortDirection>('desc');
|
||||
|
||||
protected readonly hasConflict = computed((): boolean => {
|
||||
const statuses = new Set(this.claims().map(c => c.assertedStatus));
|
||||
return statuses.size > 1;
|
||||
});
|
||||
|
||||
protected readonly sortedClaims = computed((): VerdictExplanation[] => {
|
||||
let filtered = this.claims();
|
||||
|
||||
if (this.showConflictsOnly() && this.hasConflict()) {
|
||||
const winnerStatus = filtered.find(c => c.accepted)?.assertedStatus;
|
||||
if (winnerStatus) {
|
||||
filtered = filtered.filter(c => c.assertedStatus !== winnerStatus || c.accepted);
|
||||
}
|
||||
}
|
||||
|
||||
const col = this.sortColumn();
|
||||
const dir = this.sortDirection();
|
||||
const mult = dir === 'asc' ? 1 : -1;
|
||||
|
||||
return [...filtered].sort((a, b) => {
|
||||
const aVal = a[col];
|
||||
const bVal = b[col];
|
||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||
return mult * aVal.localeCompare(bVal);
|
||||
}
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
return mult * (aVal - bVal);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
protected toggleConflicts(): void {
|
||||
this.showConflictsOnly.update(v => !v);
|
||||
}
|
||||
|
||||
protected sort(column: SortColumn): void {
|
||||
if (this.sortColumn() === column) {
|
||||
this.sortDirection.update(d => d === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
this.sortColumn.set(column);
|
||||
this.sortDirection.set(column === 'claimScore' ? 'desc' : 'asc');
|
||||
}
|
||||
}
|
||||
|
||||
protected getStatusLabel = getStatusLabel;
|
||||
protected getStatusColor = getStatusColor;
|
||||
|
||||
protected getSortAriaLabel(column: SortColumn): 'ascending' | 'descending' | 'none' {
|
||||
if (this.sortColumn() !== column) {
|
||||
return 'none';
|
||||
}
|
||||
return this.sortDirection() === 'asc' ? 'ascending' : 'descending';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { Component, input, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { getConfidenceBand, formatConfidence, ConfidenceBand } from './trust-algebra.models';
|
||||
|
||||
/**
|
||||
* Confidence Meter Component
|
||||
*
|
||||
* Displays a visual meter showing confidence level (0-1) with color coding.
|
||||
* Includes threshold markers for policy gates.
|
||||
*
|
||||
* @see Sprint 7100.0003.0001 T2
|
||||
*/
|
||||
@Component({
|
||||
selector: 'st-confidence-meter',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="confidence-meter" [attr.aria-label]="ariaLabel()">
|
||||
<div class="confidence-meter__header">
|
||||
<span class="confidence-meter__label">Confidence</span>
|
||||
<span class="confidence-meter__value" [class]="valueClass()">
|
||||
{{ formattedConfidence() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="confidence-meter__bar-container">
|
||||
<div class="confidence-meter__bar-track">
|
||||
<div
|
||||
class="confidence-meter__bar-fill"
|
||||
[style.width.%]="fillWidth()"
|
||||
[class]="fillClass()"
|
||||
></div>
|
||||
|
||||
<!-- Threshold markers -->
|
||||
@for (threshold of thresholds(); track threshold.value) {
|
||||
<div
|
||||
class="confidence-meter__threshold"
|
||||
[style.left.%]="threshold.value * 100"
|
||||
[attr.title]="threshold.label"
|
||||
>
|
||||
<span class="confidence-meter__threshold-label">{{ threshold.label }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="confidence-meter__band">
|
||||
<span [class]="bandClass()">{{ bandLabel() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.confidence-meter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.confidence-meter__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.confidence-meter__label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.confidence-meter__value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.confidence-meter__value--high {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.confidence-meter__value--medium {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.confidence-meter__value--low {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.confidence-meter__bar-container {
|
||||
position: relative;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.confidence-meter__bar-track {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 4px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.confidence-meter__bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease-out;
|
||||
}
|
||||
|
||||
.confidence-meter__bar-fill--high {
|
||||
background: linear-gradient(90deg, #22c55e, #16a34a);
|
||||
}
|
||||
|
||||
.confidence-meter__bar-fill--medium {
|
||||
background: linear-gradient(90deg, #fbbf24, #d97706);
|
||||
}
|
||||
|
||||
.confidence-meter__bar-fill--low {
|
||||
background: linear-gradient(90deg, #f87171, #dc2626);
|
||||
}
|
||||
|
||||
.confidence-meter__threshold {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
transform: translateX(-50%);
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
background: #6b7280;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.confidence-meter__threshold-label {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.625rem;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.confidence-meter__band {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.confidence-meter__band-label {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.confidence-meter__band-label--high {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.confidence-meter__band-label--medium {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.confidence-meter__band-label--low {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class ConfidenceMeterComponent {
|
||||
/**
|
||||
* Confidence value between 0 and 1.
|
||||
*/
|
||||
readonly confidence = input.required<number>();
|
||||
|
||||
/**
|
||||
* Policy thresholds to display as markers.
|
||||
*/
|
||||
readonly policyThresholds = input<{ value: number; label: string }[]>([
|
||||
{ value: 0.75, label: 'prod' },
|
||||
{ value: 0.60, label: 'staging' },
|
||||
{ value: 0.40, label: 'dev' },
|
||||
]);
|
||||
|
||||
protected readonly band = computed((): ConfidenceBand => {
|
||||
return getConfidenceBand(this.confidence());
|
||||
});
|
||||
|
||||
protected readonly formattedConfidence = computed((): string => {
|
||||
return formatConfidence(this.confidence());
|
||||
});
|
||||
|
||||
protected readonly fillWidth = computed((): number => {
|
||||
return Math.min(100, Math.max(0, this.confidence() * 100));
|
||||
});
|
||||
|
||||
protected readonly valueClass = computed((): string => {
|
||||
return `confidence-meter__value--${this.band()}`;
|
||||
});
|
||||
|
||||
protected readonly fillClass = computed((): string => {
|
||||
return `confidence-meter__bar-fill--${this.band()}`;
|
||||
});
|
||||
|
||||
protected readonly bandLabel = computed((): string => {
|
||||
const b = this.band();
|
||||
return b.charAt(0).toUpperCase() + b.slice(1) + ' Confidence';
|
||||
});
|
||||
|
||||
protected readonly bandClass = computed((): string => {
|
||||
return `confidence-meter__band-label confidence-meter__band-label--${this.band()}`;
|
||||
});
|
||||
|
||||
protected readonly thresholds = computed(() => {
|
||||
return this.policyThresholds();
|
||||
});
|
||||
|
||||
protected readonly ariaLabel = computed((): string => {
|
||||
return `Confidence: ${this.formattedConfidence()}, ${this.bandLabel()}`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Trust Algebra Module
|
||||
*
|
||||
* Angular components for VEX Trust Lattice visualization.
|
||||
* @see Sprint 7100.0003.0001
|
||||
*/
|
||||
|
||||
// Models
|
||||
export * from './trust-algebra.models';
|
||||
|
||||
// Service
|
||||
export * from './trust-algebra.service';
|
||||
|
||||
// Components
|
||||
export { TrustAlgebraComponent } from './trust-algebra.component';
|
||||
export { ConfidenceMeterComponent } from './confidence-meter.component';
|
||||
export { TrustVectorBarsComponent } from './trust-vector-bars.component';
|
||||
export { ClaimTableComponent } from './claim-table.component';
|
||||
export { PolicyChipsComponent } from './policy-chips.component';
|
||||
export { ReplayButtonComponent } from './replay-button.component';
|
||||
@@ -0,0 +1,285 @@
|
||||
import { Component, input, computed, signal, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { PolicyGateResult } from './trust-algebra.models';
|
||||
|
||||
/**
|
||||
* Policy Chips Component
|
||||
*
|
||||
* Displays policy gate results as colored chips.
|
||||
*
|
||||
* @see Sprint 7100.0003.0001 T5
|
||||
*/
|
||||
@Component({
|
||||
selector: 'st-policy-chips',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="policy-chips" role="group" aria-label="Policy gate results">
|
||||
<div class="policy-chips__header">
|
||||
<span class="policy-chips__title">Policy Gates</span>
|
||||
<span [class]="overallClass()">
|
||||
{{ overallPassed() ? '✓ PASS' : '✗ FAIL' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="policy-chips__list">
|
||||
@for (gate of gates(); track gate.name) {
|
||||
<button
|
||||
type="button"
|
||||
[class]="getChipClass(gate)"
|
||||
[attr.aria-label]="getAriaLabel(gate)"
|
||||
[attr.title]="gate.reason || gate.name"
|
||||
(click)="selectGate(gate)"
|
||||
>
|
||||
<span class="policy-chips__icon">{{ gate.passed ? '✓' : '✗' }}</span>
|
||||
<span class="policy-chips__name">{{ formatGateName(gate.name) }}</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (showNotApplicable()) {
|
||||
@for (gate of notApplicableGates(); track gate) {
|
||||
<span class="policy-chips__chip policy-chips__chip--na" [attr.title]="gate + ' (not applicable)'">
|
||||
<span class="policy-chips__icon">—</span>
|
||||
<span class="policy-chips__name">{{ formatGateName(gate) }}</span>
|
||||
</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="policy-chips__context">
|
||||
<span class="policy-chips__meta">
|
||||
Policy: <code>{{ shortenHash(policyHash()) }}</code>
|
||||
</span>
|
||||
<span class="policy-chips__meta">
|
||||
Lattice: <code>{{ latticeVersion() }}</code>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="policy-chips__view-btn"
|
||||
(click)="viewPolicyClick()"
|
||||
aria-label="View policy YAML"
|
||||
>
|
||||
View Policy YAML
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.policy-chips {
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.policy-chips__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.policy-chips__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.policy-chips__overall {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.policy-chips__overall--pass {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.policy-chips__overall--fail {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.policy-chips__list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.policy-chips__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, box-shadow 0.1s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.policy-chips__chip--pass {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.policy-chips__chip--fail {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.policy-chips__chip--na {
|
||||
background: #f3f4f6;
|
||||
color: #9ca3af;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.policy-chips__icon {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.policy-chips__name {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.policy-chips__context {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.policy-chips__meta code {
|
||||
font-family: ui-monospace, monospace;
|
||||
background: #f3f4f6;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.policy-chips__view-btn {
|
||||
margin-left: auto;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
|
||||
&:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class PolicyChipsComponent {
|
||||
/**
|
||||
* Policy gate results to display.
|
||||
*/
|
||||
readonly gates = input.required<PolicyGateResult[]>();
|
||||
|
||||
/**
|
||||
* Policy hash (sha256:...).
|
||||
*/
|
||||
readonly policyHash = input.required<string>();
|
||||
|
||||
/**
|
||||
* Lattice version (e.g., "1.0.0").
|
||||
*/
|
||||
readonly latticeVersion = input.required<string>();
|
||||
|
||||
/**
|
||||
* Gates that are not applicable (shown as gray).
|
||||
*/
|
||||
readonly notApplicableGates = input<string[]>([]);
|
||||
|
||||
/**
|
||||
* Whether to show not-applicable gates.
|
||||
*/
|
||||
readonly showNotApplicable = input(true);
|
||||
|
||||
/**
|
||||
* Whether the user can edit the policy (false in replay mode).
|
||||
*/
|
||||
readonly readOnly = input(false);
|
||||
|
||||
/**
|
||||
* Emits when user clicks "View Policy YAML".
|
||||
*/
|
||||
readonly viewPolicy = output<void>();
|
||||
|
||||
/**
|
||||
* Emits when user clicks a specific gate chip.
|
||||
*/
|
||||
readonly gateSelected = output<PolicyGateResult>();
|
||||
|
||||
protected readonly overallPassed = computed((): boolean => {
|
||||
return this.gates().every(g => g.passed);
|
||||
});
|
||||
|
||||
protected readonly overallClass = computed((): string => {
|
||||
const passed = this.overallPassed();
|
||||
return `policy-chips__overall policy-chips__overall--${passed ? 'pass' : 'fail'}`;
|
||||
});
|
||||
|
||||
protected getChipClass(gate: PolicyGateResult): string {
|
||||
return `policy-chips__chip policy-chips__chip--${gate.passed ? 'pass' : 'fail'}`;
|
||||
}
|
||||
|
||||
protected getAriaLabel(gate: PolicyGateResult): string {
|
||||
return `${this.formatGateName(gate.name)}: ${gate.passed ? 'Passed' : 'Failed'}${gate.reason ? '. ' + gate.reason : ''}`;
|
||||
}
|
||||
|
||||
protected formatGateName(name: string): string {
|
||||
return name
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, s => s.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
protected shortenHash(hash: string): string {
|
||||
if (hash.startsWith('sha256:')) {
|
||||
return hash.substring(0, 14) + '...';
|
||||
}
|
||||
return hash.substring(0, 10) + '...';
|
||||
}
|
||||
|
||||
protected selectGate(gate: PolicyGateResult): void {
|
||||
this.gateSelected.emit(gate);
|
||||
}
|
||||
|
||||
protected viewPolicyClick(): void {
|
||||
this.viewPolicy.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { Component, input, signal, computed, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { ReplayVerificationResult } from './trust-algebra.models';
|
||||
import { TrustAlgebraService } from './trust-algebra.service';
|
||||
|
||||
type ReplayState = 'idle' | 'loading' | 'success' | 'failure';
|
||||
|
||||
/**
|
||||
* Replay Button Component
|
||||
*
|
||||
* Triggers replay verification and displays results.
|
||||
*
|
||||
* @see Sprint 7100.0003.0001 T6
|
||||
*/
|
||||
@Component({
|
||||
selector: 'st-replay-button',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="replay-button">
|
||||
<div class="replay-button__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="replay-button__btn replay-button__btn--primary"
|
||||
[disabled]="isLoading()"
|
||||
(click)="reproduce()"
|
||||
[attr.aria-busy]="isLoading()"
|
||||
>
|
||||
@if (isLoading()) {
|
||||
<span class="replay-button__spinner"></span>
|
||||
<span>Verifying...</span>
|
||||
} @else if (isSuccess()) {
|
||||
<span class="replay-button__icon replay-button__icon--success">✓</span>
|
||||
<span>Verdict Reproduced</span>
|
||||
} @else if (isFailure()) {
|
||||
<span class="replay-button__icon replay-button__icon--failure">✗</span>
|
||||
<span>Mismatch Detected</span>
|
||||
} @else {
|
||||
<span class="replay-button__icon">🔄</span>
|
||||
<span>Reproduce Verdict</span>
|
||||
}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="replay-button__btn replay-button__btn--secondary"
|
||||
(click)="copyId()"
|
||||
[attr.aria-label]="'Copy manifest ID: ' + manifestId()"
|
||||
>
|
||||
<span>📋</span>
|
||||
<span>Copy ID</span>
|
||||
</button>
|
||||
|
||||
@if (isSuccess()) {
|
||||
<button
|
||||
type="button"
|
||||
class="replay-button__btn replay-button__btn--secondary"
|
||||
(click)="download()"
|
||||
aria-label="Download signed manifest"
|
||||
>
|
||||
<span>⬇</span>
|
||||
<span>Download</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Result panel -->
|
||||
@if (result()) {
|
||||
<div [class]="resultPanelClass()">
|
||||
@if (isSuccess()) {
|
||||
<div class="replay-button__result-header replay-button__result-header--success">
|
||||
<span class="replay-button__result-icon">✓</span>
|
||||
<span>Verdict Successfully Reproduced</span>
|
||||
</div>
|
||||
<div class="replay-button__result-detail">
|
||||
<span>Signature valid: {{ result()?.signatureValid ? 'Yes' : 'No' }}</span>
|
||||
@if (result()?.verifiedAt) {
|
||||
<span>Verified at: {{ formatDate(result()?.verifiedAt) }}</span>
|
||||
}
|
||||
</div>
|
||||
} @else if (isFailure()) {
|
||||
<div class="replay-button__result-header replay-button__result-header--failure">
|
||||
<span class="replay-button__result-icon">✗</span>
|
||||
<span>{{ result()?.error || 'Mismatch Detected' }}</span>
|
||||
</div>
|
||||
@if (result()?.differences?.length) {
|
||||
<div class="replay-button__differences">
|
||||
<span class="replay-button__diff-title">Differences:</span>
|
||||
<ul class="replay-button__diff-list">
|
||||
@for (diff of result()?.differences; track diff) {
|
||||
<li>{{ diff }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Copy feedback -->
|
||||
@if (copyFeedback()) {
|
||||
<div class="replay-button__feedback" role="status" aria-live="polite">
|
||||
{{ copyFeedback() }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.replay-button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.replay-button__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.replay-button__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, transform 0.1s;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.replay-button__btn--primary {
|
||||
background: #3b82f6;
|
||||
border: 1px solid #2563eb;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
|
||||
.replay-button__btn--secondary {
|
||||
background: white;
|
||||
border: 1px solid #d1d5db;
|
||||
color: #374151;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #f9fafb;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
|
||||
.replay-button__icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.replay-button__icon--success {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.replay-button__icon--failure {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.replay-button__spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.replay-button__result-panel {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.replay-button__result-panel--success {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #86efac;
|
||||
}
|
||||
|
||||
.replay-button__result-panel--failure {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fca5a5;
|
||||
}
|
||||
|
||||
.replay-button__result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.replay-button__result-header--success {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.replay-button__result-header--failure {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.replay-button__result-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.replay-button__result-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
color: #374151;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.replay-button__differences {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.replay-button__diff-title {
|
||||
font-weight: 600;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.replay-button__diff-list {
|
||||
margin: 0.25rem 0 0 1rem;
|
||||
padding: 0;
|
||||
list-style: disc;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
|
||||
.replay-button__diff-list li {
|
||||
margin: 0.125rem 0;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.replay-button__feedback {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: #374151;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class ReplayButtonComponent {
|
||||
private readonly service = inject(TrustAlgebraService);
|
||||
|
||||
/**
|
||||
* The manifest ID to verify.
|
||||
*/
|
||||
readonly manifestId = input.required<string>();
|
||||
|
||||
protected readonly state = signal<ReplayState>('idle');
|
||||
protected readonly result = signal<ReplayVerificationResult | null>(null);
|
||||
protected readonly copyFeedback = signal<string | null>(null);
|
||||
|
||||
protected readonly isLoading = computed(() => this.state() === 'loading');
|
||||
protected readonly isSuccess = computed(() => this.state() === 'success');
|
||||
protected readonly isFailure = computed(() => this.state() === 'failure');
|
||||
|
||||
protected readonly resultPanelClass = computed((): string => {
|
||||
const s = this.state();
|
||||
if (s === 'success') return 'replay-button__result-panel replay-button__result-panel--success';
|
||||
if (s === 'failure') return 'replay-button__result-panel replay-button__result-panel--failure';
|
||||
return 'replay-button__result-panel';
|
||||
});
|
||||
|
||||
protected reproduce(): void {
|
||||
if (this.isLoading()) return;
|
||||
|
||||
this.state.set('loading');
|
||||
this.result.set(null);
|
||||
|
||||
this.service.replayVerdict(this.manifestId()).subscribe({
|
||||
next: (res) => {
|
||||
this.result.set(res);
|
||||
this.state.set(res.success ? 'success' : 'failure');
|
||||
},
|
||||
error: (err) => {
|
||||
this.result.set({
|
||||
success: false,
|
||||
originalManifest: {} as any,
|
||||
signatureValid: false,
|
||||
error: err?.message || 'Replay verification failed',
|
||||
});
|
||||
this.state.set('failure');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected async copyId(): Promise<void> {
|
||||
const copied = await this.service.copyManifestId(this.manifestId());
|
||||
this.copyFeedback.set(copied ? 'Manifest ID copied to clipboard' : 'Failed to copy');
|
||||
setTimeout(() => this.copyFeedback.set(null), 2000);
|
||||
}
|
||||
|
||||
protected download(): void {
|
||||
this.service.downloadManifest(this.manifestId()).subscribe({
|
||||
next: (blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `verdict-${this.manifestId()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
error: () => {
|
||||
this.copyFeedback.set('Failed to download manifest');
|
||||
setTimeout(() => this.copyFeedback.set(null), 2000);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected formatDate(dateStr: string | undefined): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { Component, input, computed, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { VerdictManifest, TrustVector, PolicyGateResult, getStatusLabel, getStatusColor } from './trust-algebra.models';
|
||||
import { ConfidenceMeterComponent } from './confidence-meter.component';
|
||||
import { TrustVectorBarsComponent } from './trust-vector-bars.component';
|
||||
import { ClaimTableComponent } from './claim-table.component';
|
||||
import { PolicyChipsComponent } from './policy-chips.component';
|
||||
import { ReplayButtonComponent } from './replay-button.component';
|
||||
|
||||
type ExpandedSection = 'summary' | 'trust-vector' | 'claims' | 'policy';
|
||||
|
||||
/**
|
||||
* Trust Algebra Component
|
||||
*
|
||||
* Main component for VEX verdict explanation and visualization.
|
||||
* Shows confidence meter, trust vector breakdown, claim table, and policy gates.
|
||||
*
|
||||
* @see Sprint 7100.0003.0001 T1
|
||||
*/
|
||||
@Component({
|
||||
selector: 'st-trust-algebra',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ConfidenceMeterComponent,
|
||||
TrustVectorBarsComponent,
|
||||
ClaimTableComponent,
|
||||
PolicyChipsComponent,
|
||||
ReplayButtonComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="trust-algebra" role="region" aria-label="Trust Algebra Panel">
|
||||
<!-- Header -->
|
||||
<header class="trust-algebra__header">
|
||||
<div class="trust-algebra__title-row">
|
||||
<h2 class="trust-algebra__title">Trust Algebra</h2>
|
||||
@if (isReplayMode()) {
|
||||
<span class="trust-algebra__replay-badge">Replay Mode</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="trust-algebra__summary">
|
||||
<div class="trust-algebra__scope">
|
||||
<span class="trust-algebra__vuln-id">{{ manifest().vulnerabilityId }}</span>
|
||||
<span class="trust-algebra__separator">×</span>
|
||||
<span class="trust-algebra__asset" [title]="manifest().assetDigest">
|
||||
{{ shortenDigest(manifest().assetDigest) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="trust-algebra__verdict">
|
||||
<span
|
||||
class="trust-algebra__status"
|
||||
[style.background]="getStatusBackground(manifest().result.status)"
|
||||
[style.color]="getStatusColor(manifest().result.status)"
|
||||
>
|
||||
{{ getStatusLabel(manifest().result.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Confidence Section -->
|
||||
<section class="trust-algebra__section" [class.trust-algebra__section--expanded]="isExpanded('summary')">
|
||||
<button
|
||||
type="button"
|
||||
class="trust-algebra__section-header"
|
||||
(click)="toggleSection('summary')"
|
||||
[attr.aria-expanded]="isExpanded('summary')"
|
||||
>
|
||||
<span class="trust-algebra__section-title">Confidence</span>
|
||||
<span class="trust-algebra__section-toggle">{{ isExpanded('summary') ? '−' : '+' }}</span>
|
||||
</button>
|
||||
@if (isExpanded('summary')) {
|
||||
<div class="trust-algebra__section-content">
|
||||
<st-confidence-meter [confidence]="manifest().result.confidence" />
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Trust Vector Section -->
|
||||
<section class="trust-algebra__section" [class.trust-algebra__section--expanded]="isExpanded('trust-vector')">
|
||||
<button
|
||||
type="button"
|
||||
class="trust-algebra__section-header"
|
||||
(click)="toggleSection('trust-vector')"
|
||||
[attr.aria-expanded]="isExpanded('trust-vector')"
|
||||
>
|
||||
<span class="trust-algebra__section-title">Trust Vector (P/C/R)</span>
|
||||
<span class="trust-algebra__section-toggle">{{ isExpanded('trust-vector') ? '−' : '+' }}</span>
|
||||
</button>
|
||||
@if (isExpanded('trust-vector')) {
|
||||
<div class="trust-algebra__section-content">
|
||||
<st-trust-vector-bars [vector]="winningTrustVector()" />
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Claims Section -->
|
||||
<section class="trust-algebra__section" [class.trust-algebra__section--expanded]="isExpanded('claims')">
|
||||
<button
|
||||
type="button"
|
||||
class="trust-algebra__section-header"
|
||||
(click)="toggleSection('claims')"
|
||||
[attr.aria-expanded]="isExpanded('claims')"
|
||||
>
|
||||
<span class="trust-algebra__section-title">VEX Claims</span>
|
||||
<span class="trust-algebra__section-toggle">{{ isExpanded('claims') ? '−' : '+' }}</span>
|
||||
</button>
|
||||
@if (isExpanded('claims')) {
|
||||
<div class="trust-algebra__section-content">
|
||||
<st-claim-table [claims]="manifest().result.explanations" />
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Policy Section -->
|
||||
<section class="trust-algebra__section" [class.trust-algebra__section--expanded]="isExpanded('policy')">
|
||||
<button
|
||||
type="button"
|
||||
class="trust-algebra__section-header"
|
||||
(click)="toggleSection('policy')"
|
||||
[attr.aria-expanded]="isExpanded('policy')"
|
||||
>
|
||||
<span class="trust-algebra__section-title">Policy Gates</span>
|
||||
<span class="trust-algebra__section-toggle">{{ isExpanded('policy') ? '−' : '+' }}</span>
|
||||
</button>
|
||||
@if (isExpanded('policy')) {
|
||||
<div class="trust-algebra__section-content">
|
||||
<st-policy-chips
|
||||
[gates]="policyGates()"
|
||||
[policyHash]="manifest().policyHash"
|
||||
[latticeVersion]="manifest().latticeVersion"
|
||||
[readOnly]="isReplayMode()"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Replay Actions -->
|
||||
<footer class="trust-algebra__footer">
|
||||
<st-replay-button [manifestId]="manifest().manifestId" />
|
||||
</footer>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.trust-algebra {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.trust-algebra__header {
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.trust-algebra__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.trust-algebra__title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.trust-algebra__replay-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.trust-algebra__summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.trust-algebra__scope {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.trust-algebra__vuln-id {
|
||||
font-weight: 600;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.trust-algebra__separator {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.trust-algebra__asset {
|
||||
font-family: ui-monospace, monospace;
|
||||
color: #6b7280;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.trust-algebra__status {
|
||||
display: inline-block;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.trust-algebra__section {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trust-algebra__section--expanded {
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.trust-algebra__section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #f9fafb;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.trust-algebra__section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.trust-algebra__section-toggle {
|
||||
font-size: 1.25rem;
|
||||
color: #6b7280;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.trust-algebra__section-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.trust-algebra__footer {
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class TrustAlgebraComponent {
|
||||
/**
|
||||
* The verdict manifest to display.
|
||||
*/
|
||||
readonly manifest = input.required<VerdictManifest>();
|
||||
|
||||
/**
|
||||
* Whether the component is in replay mode (read-only policy view).
|
||||
*/
|
||||
readonly isReplayMode = input(false);
|
||||
|
||||
protected readonly expandedSections = signal<Set<ExpandedSection>>(new Set(['summary']));
|
||||
|
||||
protected readonly winningTrustVector = computed((): TrustVector => {
|
||||
const winner = this.manifest().result.explanations.find(e => e.accepted);
|
||||
if (winner) {
|
||||
return {
|
||||
provenance: winner.provenanceScore,
|
||||
coverage: winner.coverageScore,
|
||||
replayability: winner.replayabilityScore,
|
||||
};
|
||||
}
|
||||
// Default if no winner found
|
||||
return { provenance: 0.5, coverage: 0.5, replayability: 0.5 };
|
||||
});
|
||||
|
||||
protected readonly policyGates = computed((): PolicyGateResult[] => {
|
||||
// In a real implementation, these would come from the manifest or API
|
||||
// For now, return placeholder gates based on confidence level
|
||||
const confidence = this.manifest().result.confidence;
|
||||
return [
|
||||
{
|
||||
name: 'MinimumConfidence',
|
||||
passed: confidence >= 0.4,
|
||||
reason: confidence >= 0.4 ? undefined : `Confidence ${(confidence * 100).toFixed(0)}% below threshold`,
|
||||
},
|
||||
{
|
||||
name: 'SourceQuota',
|
||||
passed: true,
|
||||
},
|
||||
{
|
||||
name: 'UnknownsBudget',
|
||||
passed: true,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
protected isExpanded(section: ExpandedSection): boolean {
|
||||
return this.expandedSections().has(section);
|
||||
}
|
||||
|
||||
protected toggleSection(section: ExpandedSection): void {
|
||||
this.expandedSections.update(sections => {
|
||||
const newSet = new Set(sections);
|
||||
if (newSet.has(section)) {
|
||||
newSet.delete(section);
|
||||
} else {
|
||||
newSet.add(section);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
|
||||
protected shortenDigest(digest: string): string {
|
||||
if (digest.startsWith('sha256:')) {
|
||||
return digest.substring(0, 14) + '...' + digest.substring(digest.length - 6);
|
||||
}
|
||||
return digest.substring(0, 10) + '...';
|
||||
}
|
||||
|
||||
protected getStatusLabel = getStatusLabel;
|
||||
protected getStatusColor = getStatusColor;
|
||||
|
||||
protected getStatusBackground(status: string): string {
|
||||
switch (status) {
|
||||
case 'affected':
|
||||
return '#fee2e2';
|
||||
case 'not_affected':
|
||||
return '#dcfce7';
|
||||
case 'fixed':
|
||||
return '#dbeafe';
|
||||
case 'under_investigation':
|
||||
return '#fef3c7';
|
||||
default:
|
||||
return '#f3f4f6';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Trust Algebra Models
|
||||
*
|
||||
* TypeScript interfaces for VEX Trust Lattice visualization.
|
||||
* @see docs/modules/excititor/trust-lattice.md
|
||||
* @see docs/modules/authority/verdict-manifest.md
|
||||
*/
|
||||
|
||||
export type VexStatus = 'affected' | 'not_affected' | 'fixed' | 'under_investigation';
|
||||
|
||||
export interface TrustVector {
|
||||
provenance: number;
|
||||
coverage: number;
|
||||
replayability: number;
|
||||
}
|
||||
|
||||
export interface VerdictInputs {
|
||||
sbomDigests: string[];
|
||||
vulnFeedSnapshotIds: string[];
|
||||
vexDocumentDigests: string[];
|
||||
reachabilityGraphIds: string[];
|
||||
clockCutoff: string;
|
||||
}
|
||||
|
||||
export interface VerdictExplanation {
|
||||
sourceId: string;
|
||||
reason: string;
|
||||
provenanceScore: number;
|
||||
coverageScore: number;
|
||||
replayabilityScore: number;
|
||||
strengthMultiplier: number;
|
||||
freshnessMultiplier: number;
|
||||
claimScore: number;
|
||||
assertedStatus: VexStatus;
|
||||
accepted: boolean;
|
||||
}
|
||||
|
||||
export interface VerdictResult {
|
||||
status: VexStatus;
|
||||
confidence: number;
|
||||
explanations: VerdictExplanation[];
|
||||
evidenceRefs: string[];
|
||||
}
|
||||
|
||||
export interface VerdictManifest {
|
||||
manifestId: string;
|
||||
tenant: string;
|
||||
assetDigest: string;
|
||||
vulnerabilityId: string;
|
||||
inputs: VerdictInputs;
|
||||
result: VerdictResult;
|
||||
policyHash: string;
|
||||
latticeVersion: string;
|
||||
evaluatedAt: string;
|
||||
manifestDigest: string;
|
||||
signatureBase64?: string;
|
||||
rekorLogId?: string;
|
||||
}
|
||||
|
||||
export interface ReplayVerificationResult {
|
||||
success: boolean;
|
||||
originalManifest: VerdictManifest;
|
||||
replayedManifest?: VerdictManifest;
|
||||
differences?: string[];
|
||||
signatureValid: boolean;
|
||||
error?: string;
|
||||
verifiedAt?: string;
|
||||
}
|
||||
|
||||
export interface PolicyGateResult {
|
||||
name: string;
|
||||
passed: boolean;
|
||||
reason?: string;
|
||||
configuration?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type ConfidenceBand = 'high' | 'medium' | 'low';
|
||||
|
||||
export function getConfidenceBand(confidence: number): ConfidenceBand {
|
||||
if (confidence >= 0.75) return 'high';
|
||||
if (confidence >= 0.5) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
export function formatConfidence(confidence: number): string {
|
||||
return `${(confidence * 100).toFixed(0)}%`;
|
||||
}
|
||||
|
||||
export function getStatusLabel(status: VexStatus): string {
|
||||
switch (status) {
|
||||
case 'affected':
|
||||
return 'Affected';
|
||||
case 'not_affected':
|
||||
return 'Not Affected';
|
||||
case 'fixed':
|
||||
return 'Fixed';
|
||||
case 'under_investigation':
|
||||
return 'Under Investigation';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
export function getStatusColor(status: VexStatus): string {
|
||||
switch (status) {
|
||||
case 'affected':
|
||||
return '#dc2626'; // red
|
||||
case 'not_affected':
|
||||
return '#16a34a'; // green
|
||||
case 'fixed':
|
||||
return '#2563eb'; // blue
|
||||
case 'under_investigation':
|
||||
return '#d97706'; // amber
|
||||
default:
|
||||
return '#6b7280'; // gray
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Injectable, inject, InjectionToken } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, catchError, of } from 'rxjs';
|
||||
|
||||
import { VerdictManifest, ReplayVerificationResult } from './trust-algebra.models';
|
||||
|
||||
/**
|
||||
* Trust Algebra API Service
|
||||
*
|
||||
* Handles API calls for verdict manifests and replay verification.
|
||||
* @see Sprint 7100.0003.0001 T7
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TrustAlgebraService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = '/api/v1/authority/verdicts';
|
||||
|
||||
/**
|
||||
* Get a verdict manifest by its ID.
|
||||
*/
|
||||
getVerdictManifest(manifestId: string): Observable<VerdictManifest | null> {
|
||||
return this.http.get<VerdictManifest>(`${this.baseUrl}/${encodeURIComponent(manifestId)}`).pipe(
|
||||
catchError(() => of(null))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest verdict for an asset/vulnerability pair.
|
||||
*/
|
||||
getVerdictByScope(
|
||||
assetDigest: string,
|
||||
vulnerabilityId: string,
|
||||
policyHash?: string,
|
||||
latticeVersion?: string
|
||||
): Observable<VerdictManifest | null> {
|
||||
const params: Record<string, string> = {
|
||||
assetDigest,
|
||||
vulnerabilityId,
|
||||
};
|
||||
if (policyHash) params['policyHash'] = policyHash;
|
||||
if (latticeVersion) params['latticeVersion'] = latticeVersion;
|
||||
|
||||
return this.http.get<VerdictManifest>(this.baseUrl, { params }).pipe(
|
||||
catchError(() => of(null))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger replay verification for a verdict manifest.
|
||||
*/
|
||||
replayVerdict(manifestId: string): Observable<ReplayVerificationResult> {
|
||||
return this.http.post<ReplayVerificationResult>(
|
||||
`${this.baseUrl}/${encodeURIComponent(manifestId)}/replay`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the signed verdict manifest as a blob.
|
||||
*/
|
||||
downloadManifest(manifestId: string): Observable<Blob> {
|
||||
return this.http.get(
|
||||
`${this.baseUrl}/${encodeURIComponent(manifestId)}/download`,
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy manifest ID to clipboard.
|
||||
*/
|
||||
async copyManifestId(manifestId: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(manifestId);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection token for the Trust Algebra API service.
|
||||
*/
|
||||
export const TRUST_ALGEBRA_API = new InjectionToken<TrustAlgebraService>('TrustAlgebraApi', {
|
||||
providedIn: 'root',
|
||||
factory: () => inject(TrustAlgebraService),
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
import { Component, input, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { TrustVector } from './trust-algebra.models';
|
||||
|
||||
/**
|
||||
* Trust Vector Bars Component
|
||||
*
|
||||
* Displays a stacked horizontal bar chart showing P/C/R contributions
|
||||
* to the base trust score.
|
||||
*
|
||||
* @see Sprint 7100.0003.0001 T3
|
||||
*/
|
||||
@Component({
|
||||
selector: 'st-trust-vector-bars',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="trust-vector-bars" [attr.aria-label]="ariaLabel()">
|
||||
<div class="trust-vector-bars__header">
|
||||
<span class="trust-vector-bars__title">Trust Vector Breakdown</span>
|
||||
<span class="trust-vector-bars__score">= {{ formattedBaseTrust() }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Stacked bar -->
|
||||
<div class="trust-vector-bars__bar-container">
|
||||
<div class="trust-vector-bars__bar">
|
||||
<div
|
||||
class="trust-vector-bars__segment trust-vector-bars__segment--provenance"
|
||||
[style.width.%]="provenanceWidth()"
|
||||
[attr.title]="'Provenance: ' + formattedWeighted().provenance"
|
||||
></div>
|
||||
<div
|
||||
class="trust-vector-bars__segment trust-vector-bars__segment--coverage"
|
||||
[style.width.%]="coverageWidth()"
|
||||
[attr.title]="'Coverage: ' + formattedWeighted().coverage"
|
||||
></div>
|
||||
<div
|
||||
class="trust-vector-bars__segment trust-vector-bars__segment--replayability"
|
||||
[style.width.%]="replayabilityWidth()"
|
||||
[attr.title]="'Replayability: ' + formattedWeighted().replayability"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="trust-vector-bars__legend">
|
||||
<div class="trust-vector-bars__legend-item">
|
||||
<span class="trust-vector-bars__legend-dot trust-vector-bars__legend-dot--provenance"></span>
|
||||
<span class="trust-vector-bars__legend-label">
|
||||
Provenance (wP={{ weights().provenance }})
|
||||
</span>
|
||||
<span class="trust-vector-bars__legend-value">{{ vector().provenance.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="trust-vector-bars__legend-item">
|
||||
<span class="trust-vector-bars__legend-dot trust-vector-bars__legend-dot--coverage"></span>
|
||||
<span class="trust-vector-bars__legend-label">
|
||||
Coverage (wC={{ weights().coverage }})
|
||||
</span>
|
||||
<span class="trust-vector-bars__legend-value">{{ vector().coverage.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="trust-vector-bars__legend-item">
|
||||
<span class="trust-vector-bars__legend-dot trust-vector-bars__legend-dot--replayability"></span>
|
||||
<span class="trust-vector-bars__legend-label">
|
||||
Replayability (wR={{ weights().replayability }})
|
||||
</span>
|
||||
<span class="trust-vector-bars__legend-value">{{ vector().replayability.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.trust-vector-bars {
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.trust-vector-bars__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.trust-vector-bars__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.trust-vector-bars__score {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.trust-vector-bars__bar-container {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.trust-vector-bars__bar {
|
||||
display: flex;
|
||||
height: 24px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trust-vector-bars__segment {
|
||||
height: 100%;
|
||||
transition: width 0.3s ease-out;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.trust-vector-bars__segment--provenance {
|
||||
background: linear-gradient(90deg, #3b82f6, #2563eb);
|
||||
}
|
||||
|
||||
.trust-vector-bars__segment--coverage {
|
||||
background: linear-gradient(90deg, #22c55e, #16a34a);
|
||||
}
|
||||
|
||||
.trust-vector-bars__segment--replayability {
|
||||
background: linear-gradient(90deg, #a855f7, #9333ea);
|
||||
}
|
||||
|
||||
.trust-vector-bars__legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.trust-vector-bars__legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.trust-vector-bars__legend-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.trust-vector-bars__legend-dot--provenance {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.trust-vector-bars__legend-dot--coverage {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.trust-vector-bars__legend-dot--replayability {
|
||||
background: #a855f7;
|
||||
}
|
||||
|
||||
.trust-vector-bars__legend-label {
|
||||
flex: 1;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.trust-vector-bars__legend-value {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #111827;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class TrustVectorBarsComponent {
|
||||
/**
|
||||
* The trust vector to display.
|
||||
*/
|
||||
readonly vector = input.required<TrustVector>();
|
||||
|
||||
/**
|
||||
* Component weights for base trust calculation.
|
||||
* Default: wP=0.45, wC=0.35, wR=0.20
|
||||
*/
|
||||
readonly weights = input<TrustVector>({
|
||||
provenance: 0.45,
|
||||
coverage: 0.35,
|
||||
replayability: 0.20,
|
||||
});
|
||||
|
||||
protected readonly baseTrust = computed((): number => {
|
||||
const v = this.vector();
|
||||
const w = this.weights();
|
||||
return w.provenance * v.provenance + w.coverage * v.coverage + w.replayability * v.replayability;
|
||||
});
|
||||
|
||||
protected readonly formattedBaseTrust = computed((): string => {
|
||||
return this.baseTrust().toFixed(2);
|
||||
});
|
||||
|
||||
protected readonly weightedValues = computed(() => {
|
||||
const v = this.vector();
|
||||
const w = this.weights();
|
||||
return {
|
||||
provenance: w.provenance * v.provenance,
|
||||
coverage: w.coverage * v.coverage,
|
||||
replayability: w.replayability * v.replayability,
|
||||
};
|
||||
});
|
||||
|
||||
protected readonly formattedWeighted = computed(() => {
|
||||
const wv = this.weightedValues();
|
||||
return {
|
||||
provenance: wv.provenance.toFixed(2),
|
||||
coverage: wv.coverage.toFixed(2),
|
||||
replayability: wv.replayability.toFixed(2),
|
||||
};
|
||||
});
|
||||
|
||||
protected readonly totalWeighted = computed((): number => {
|
||||
const wv = this.weightedValues();
|
||||
return wv.provenance + wv.coverage + wv.replayability;
|
||||
});
|
||||
|
||||
protected readonly provenanceWidth = computed((): number => {
|
||||
const total = this.totalWeighted();
|
||||
if (total === 0) return 0;
|
||||
return (this.weightedValues().provenance / total) * 100;
|
||||
});
|
||||
|
||||
protected readonly coverageWidth = computed((): number => {
|
||||
const total = this.totalWeighted();
|
||||
if (total === 0) return 0;
|
||||
return (this.weightedValues().coverage / total) * 100;
|
||||
});
|
||||
|
||||
protected readonly replayabilityWidth = computed((): number => {
|
||||
const total = this.totalWeighted();
|
||||
if (total === 0) return 0;
|
||||
return (this.weightedValues().replayability / total) * 100;
|
||||
});
|
||||
|
||||
protected readonly ariaLabel = computed((): string => {
|
||||
const v = this.vector();
|
||||
return `Trust vector: Provenance ${v.provenance.toFixed(2)}, Coverage ${v.coverage.toFixed(2)}, Replayability ${v.replayability.toFixed(2)}. Base trust: ${this.formattedBaseTrust()}`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Comparator Badge Component Tests.
|
||||
* Sprint: SPRINT_4000_0002_0001 (Backport Explainability UX)
|
||||
* Task: T5 - Integration and E2E Tests
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ComparatorBadgeComponent } from './comparator-badge.component';
|
||||
|
||||
describe('ComparatorBadgeComponent', () => {
|
||||
let component: ComparatorBadgeComponent;
|
||||
let fixture: ComponentFixture<ComparatorBadgeComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ComparatorBadgeComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ComparatorBadgeComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('comparator type normalization', () => {
|
||||
it('should normalize rpm-evr to rpm', () => {
|
||||
fixture.componentRef.setInput('comparator', 'rpm-evr');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['normalizedComparator']()).toBe('rpm');
|
||||
expect(component['comparatorLabel']()).toBe('RPM EVR');
|
||||
});
|
||||
|
||||
it('should normalize dpkg to dpkg', () => {
|
||||
fixture.componentRef.setInput('comparator', 'dpkg');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['normalizedComparator']()).toBe('dpkg');
|
||||
expect(component['comparatorLabel']()).toBe('dpkg');
|
||||
});
|
||||
|
||||
it('should normalize debian to dpkg', () => {
|
||||
fixture.componentRef.setInput('comparator', 'debian');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['normalizedComparator']()).toBe('dpkg');
|
||||
});
|
||||
|
||||
it('should normalize apk to apk', () => {
|
||||
fixture.componentRef.setInput('comparator', 'apk');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['normalizedComparator']()).toBe('apk');
|
||||
expect(component['comparatorLabel']()).toBe('APK');
|
||||
});
|
||||
|
||||
it('should normalize alpine to apk', () => {
|
||||
fixture.componentRef.setInput('comparator', 'alpine');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['normalizedComparator']()).toBe('apk');
|
||||
});
|
||||
|
||||
it('should normalize semver to semver', () => {
|
||||
fixture.componentRef.setInput('comparator', 'semver');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['normalizedComparator']()).toBe('semver');
|
||||
expect(component['comparatorLabel']()).toBe('SemVer');
|
||||
});
|
||||
|
||||
it('should handle unknown comparator', () => {
|
||||
fixture.componentRef.setInput('comparator', 'custom');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['normalizedComparator']()).toBe('unknown');
|
||||
expect(component['comparatorLabel']()).toBe('custom');
|
||||
});
|
||||
|
||||
it('should handle null comparator', () => {
|
||||
fixture.componentRef.setInput('comparator', null);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['normalizedComparator']()).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('badge styling', () => {
|
||||
it('should apply rpm class for rpm comparator', () => {
|
||||
fixture.componentRef.setInput('comparator', 'rpm-evr');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['badgeClass']()).toContain('comparator-badge--rpm');
|
||||
});
|
||||
|
||||
it('should apply dpkg class for dpkg comparator', () => {
|
||||
fixture.componentRef.setInput('comparator', 'dpkg');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['badgeClass']()).toContain('comparator-badge--dpkg');
|
||||
});
|
||||
|
||||
it('should apply apk class for apk comparator', () => {
|
||||
fixture.componentRef.setInput('comparator', 'apk');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['badgeClass']()).toContain('comparator-badge--apk');
|
||||
});
|
||||
|
||||
it('should apply semver class for semver comparator', () => {
|
||||
fixture.componentRef.setInput('comparator', 'semver');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['badgeClass']()).toContain('comparator-badge--semver');
|
||||
});
|
||||
|
||||
it('should apply compact class when compact input is true', () => {
|
||||
fixture.componentRef.setInput('comparator', 'rpm-evr');
|
||||
fixture.componentRef.setInput('compact', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['badgeClass']()).toContain('comparator-badge--compact');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tooltip', () => {
|
||||
it('should have appropriate tooltip for rpm', () => {
|
||||
fixture.componentRef.setInput('comparator', 'rpm-evr');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['tooltipText']()).toContain('RPM EVR semantics');
|
||||
});
|
||||
|
||||
it('should have appropriate tooltip for dpkg', () => {
|
||||
fixture.componentRef.setInput('comparator', 'dpkg');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['tooltipText']()).toContain('dpkg semantics');
|
||||
});
|
||||
|
||||
it('should have appropriate tooltip for apk', () => {
|
||||
fixture.componentRef.setInput('comparator', 'apk');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['tooltipText']()).toContain('Alpine APK');
|
||||
});
|
||||
|
||||
it('should have appropriate tooltip for semver', () => {
|
||||
fixture.componentRef.setInput('comparator', 'semver');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['tooltipText']()).toContain('SemVer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have aria-label', () => {
|
||||
fixture.componentRef.setInput('comparator', 'dpkg');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['ariaLabel']()).toContain('Compared with');
|
||||
expect(component['ariaLabel']()).toContain('dpkg');
|
||||
});
|
||||
|
||||
it('should have role="status" on the badge', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const badge = compiled.querySelector('.comparator-badge');
|
||||
expect(badge.getAttribute('role')).toBe('status');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render the badge with icon and label', () => {
|
||||
fixture.componentRef.setInput('comparator', 'apk');
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.comparator-badge__icon')).toBeTruthy();
|
||||
expect(compiled.querySelector('.comparator-badge__label')).toBeTruthy();
|
||||
expect(compiled.querySelector('.comparator-badge__label').textContent).toContain('APK');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Comparator Badge Component.
|
||||
* Sprint: SPRINT_4000_0002_0001 (Backport Explainability UX)
|
||||
* Task: T3 - Create "Compared With" Badge Component
|
||||
*
|
||||
* Shows which version comparator was used for vulnerability comparison.
|
||||
* Color-coded by distro/ecosystem for quick visual identification.
|
||||
*/
|
||||
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
/**
|
||||
* Supported comparator types.
|
||||
*/
|
||||
export type ComparatorType = 'rpm-evr' | 'dpkg' | 'apk' | 'semver' | string;
|
||||
|
||||
/**
|
||||
* Comparator badge component displaying version comparison algorithm used.
|
||||
*
|
||||
* Features:
|
||||
* - Distro-specific color coding (RPM red, Debian yellow, APK green, SemVer blue)
|
||||
* - Compact display with icon and label
|
||||
* - Accessible with ARIA labels
|
||||
*
|
||||
* @example
|
||||
* <stella-comparator-badge [comparator]="'dpkg'" />
|
||||
*/
|
||||
@Component({
|
||||
selector: 'stella-comparator-badge',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<span
|
||||
class="comparator-badge"
|
||||
[class]="badgeClass()"
|
||||
[attr.title]="tooltipText()"
|
||||
[attr.aria-label]="ariaLabel()"
|
||||
role="status"
|
||||
>
|
||||
<span class="comparator-badge__icon" aria-hidden="true">⇄</span>
|
||||
<span class="comparator-badge__label">{{ comparatorLabel() }}</span>
|
||||
</span>
|
||||
`,
|
||||
styles: [`
|
||||
.comparator-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: help;
|
||||
transition: opacity 0.15s;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.comparator-badge__icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.comparator-badge__label {
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
// RPM (Red Hat, Fedora, CentOS, SUSE)
|
||||
.comparator-badge--rpm {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fca5a5;
|
||||
}
|
||||
|
||||
// Debian/Ubuntu (dpkg)
|
||||
.comparator-badge--dpkg {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border: 1px solid #fcd34d;
|
||||
}
|
||||
|
||||
// Alpine (APK)
|
||||
.comparator-badge--apk {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border: 1px solid #6ee7b7;
|
||||
}
|
||||
|
||||
// SemVer (NPM, Cargo, Go, etc.)
|
||||
.comparator-badge--semver {
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
border: 1px solid #a5b4fc;
|
||||
}
|
||||
|
||||
// Unknown/fallback
|
||||
.comparator-badge--unknown {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
// Compact variant
|
||||
.comparator-badge--compact {
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
|
||||
.comparator-badge__icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class ComparatorBadgeComponent {
|
||||
/**
|
||||
* The comparator type identifier.
|
||||
*/
|
||||
readonly comparator = input<ComparatorType | null>(null);
|
||||
|
||||
/**
|
||||
* Whether to use compact styling.
|
||||
*/
|
||||
readonly compact = input(false);
|
||||
|
||||
protected readonly normalizedComparator = computed((): string => {
|
||||
const c = this.comparator()?.toLowerCase() ?? '';
|
||||
// Normalize variations
|
||||
if (c.includes('rpm') || c === 'nevra' || c === 'rpm-evr') {
|
||||
return 'rpm';
|
||||
}
|
||||
if (c.includes('dpkg') || c.includes('deb') || c === 'debian') {
|
||||
return 'dpkg';
|
||||
}
|
||||
if (c.includes('apk') || c === 'alpine') {
|
||||
return 'apk';
|
||||
}
|
||||
if (c.includes('semver') || c === 'semantic') {
|
||||
return 'semver';
|
||||
}
|
||||
return 'unknown';
|
||||
});
|
||||
|
||||
protected readonly badgeClass = computed(() => {
|
||||
const c = this.normalizedComparator();
|
||||
const classes = [`comparator-badge--${c}`];
|
||||
if (this.compact()) {
|
||||
classes.push('comparator-badge--compact');
|
||||
}
|
||||
return classes.join(' ');
|
||||
});
|
||||
|
||||
protected readonly comparatorLabel = computed(() => {
|
||||
const c = this.normalizedComparator();
|
||||
switch (c) {
|
||||
case 'rpm':
|
||||
return 'RPM EVR';
|
||||
case 'dpkg':
|
||||
return 'dpkg';
|
||||
case 'apk':
|
||||
return 'APK';
|
||||
case 'semver':
|
||||
return 'SemVer';
|
||||
default:
|
||||
return this.comparator() ?? 'Unknown';
|
||||
}
|
||||
});
|
||||
|
||||
protected readonly tooltipText = computed(() => {
|
||||
const c = this.normalizedComparator();
|
||||
switch (c) {
|
||||
case 'rpm':
|
||||
return 'Compared using RPM EVR semantics (Epoch:Version-Release)';
|
||||
case 'dpkg':
|
||||
return 'Compared using dpkg semantics (Epoch:Upstream-Revision)';
|
||||
case 'apk':
|
||||
return 'Compared using Alpine APK version ordering';
|
||||
case 'semver':
|
||||
return 'Compared using Semantic Versioning (SemVer 2.0)';
|
||||
default:
|
||||
return 'Version comparison method';
|
||||
}
|
||||
});
|
||||
|
||||
protected readonly ariaLabel = computed(() => {
|
||||
return `Compared with: ${this.comparatorLabel()}`;
|
||||
});
|
||||
}
|
||||
@@ -51,7 +51,7 @@ describe('EvidenceDrawerComponent', () => {
|
||||
entrypoint: { nodeId: 'entry', symbol: 'BillingController.Pay' },
|
||||
sink: { nodeId: 'sink', symbol: 'HttpClient.Post' },
|
||||
},
|
||||
confidenceTier: 'high',
|
||||
confidenceTier: 'confirmed',
|
||||
gates: [
|
||||
{ gateType: 'auth', symbol: 'JwtMiddleware.Authenticate', confidence: 0.95, description: 'JWT required' },
|
||||
{ gateType: 'rate-limit', symbol: 'RateLimiter.Check', confidence: 0.90, description: '100 req/min' },
|
||||
|
||||
@@ -16,7 +16,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { PathVisualizationComponent, PathVisualizationData } from './path-visualization.component';
|
||||
import { ConfidenceTierBadgeComponent } from './confidence-tier-badge.component';
|
||||
import { GateBadgeComponent } from './gate-badge.component';
|
||||
import { GateInfo } from '../../core/api/witness.models';
|
||||
import { GateInfo, ConfidenceTier } from '../../core/api/witness.models';
|
||||
|
||||
/**
|
||||
* Evidence tab types.
|
||||
@@ -82,7 +82,7 @@ export interface EvidenceDrawerData {
|
||||
|
||||
// Reachability
|
||||
reachabilityPath?: PathVisualizationData;
|
||||
confidenceTier?: string;
|
||||
confidenceTier?: ConfidenceTier;
|
||||
gates?: GateInfo[];
|
||||
|
||||
// VEX
|
||||
|
||||
@@ -520,10 +520,10 @@ export class ExceptionBadgeComponent implements OnInit, OnDestroy, OnChanges {
|
||||
|
||||
const vulnMatch =
|
||||
!!context.vulnId &&
|
||||
(scope.vulnIds?.includes(context.vulnId) || scope.cves?.includes(context.vulnId));
|
||||
!!(scope.vulnIds?.includes(context.vulnId) || scope.cves?.includes(context.vulnId));
|
||||
const purlMatch =
|
||||
!!context.componentPurl && scope.componentPurls?.includes(context.componentPurl);
|
||||
const assetMatch = !!context.assetId && scope.assetIds?.includes(context.assetId);
|
||||
!!context.componentPurl && !!scope.componentPurls?.includes(context.componentPurl);
|
||||
const assetMatch = !!context.assetId && !!scope.assetIds?.includes(context.assetId);
|
||||
|
||||
return vulnMatch || purlMatch || assetMatch;
|
||||
}
|
||||
@@ -544,4 +544,4 @@ export class ExceptionBadgeComponent implements OnInit, OnDestroy, OnChanges {
|
||||
if (text.length <= 90) return text;
|
||||
return `${text.slice(0, 90)}...`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,3 +51,7 @@ export {
|
||||
GapEntry,
|
||||
MetricsFindingData,
|
||||
} from './metrics-dashboard.component';
|
||||
|
||||
// Backport Explainability UX (SPRINT_4000_0002_0001)
|
||||
export { ComparatorBadgeComponent, ComparatorType } from './comparator-badge.component';
|
||||
export { VersionProofPopoverComponent, VersionComparisonData } from './version-proof-popover.component';
|
||||
|
||||
@@ -118,7 +118,7 @@ export interface DriftResult {
|
||||
<div class="risk-drift-card__sink-cause">
|
||||
<strong>Cause:</strong> {{ sink.cause.description }}
|
||||
<span class="risk-drift-card__sink-location" *ngIf="sink.cause.changedFile">
|
||||
@ {{ sink.cause.changedFile }}
|
||||
@ {{ sink.cause.changedFile }}
|
||||
<span *ngIf="sink.cause.changedLine">:{{ sink.cause.changedLine }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Version Proof Popover Component Tests.
|
||||
* Sprint: SPRINT_4000_0002_0001 (Backport Explainability UX)
|
||||
* Task: T5 - Integration and E2E Tests
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { VersionProofPopoverComponent, VersionComparisonData } from './version-proof-popover.component';
|
||||
|
||||
describe('VersionProofPopoverComponent', () => {
|
||||
let component: VersionProofPopoverComponent;
|
||||
let fixture: ComponentFixture<VersionProofPopoverComponent>;
|
||||
|
||||
const mockFixedComparison: VersionComparisonData = {
|
||||
comparator: 'dpkg',
|
||||
installedVersion: '1:1.1.1k-1+deb11u2',
|
||||
fixedVersion: '1:1.1.1k-1+deb11u1',
|
||||
isFixed: true,
|
||||
proofLines: [
|
||||
'Epoch: 1 == 1 (equal)',
|
||||
'Upstream version: 1.1.1k == 1.1.1k (equal)',
|
||||
'Debian revision: 1+deb11u2 > 1+deb11u1 (left is newer)'
|
||||
],
|
||||
advisorySource: 'DSA-5678-1'
|
||||
};
|
||||
|
||||
const mockVulnerableComparison: VersionComparisonData = {
|
||||
comparator: 'rpm-evr',
|
||||
installedVersion: '1.2.3-1.el8',
|
||||
fixedVersion: '1.2.4-1.el8',
|
||||
isFixed: false,
|
||||
proofLines: [
|
||||
'Epoch: 0 == 0 (equal)',
|
||||
'Version: 1.2.3 < 1.2.4 (left is older)'
|
||||
],
|
||||
advisorySource: 'RHSA-2025:1234'
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VersionProofPopoverComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(VersionProofPopoverComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should be closed by default', () => {
|
||||
expect(component.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('should show trigger button', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.version-proof__trigger')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not show popover when closed', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.version-proof__popover')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle functionality', () => {
|
||||
it('should open popover on click', () => {
|
||||
fixture.componentRef.setInput('comparison', mockFixedComparison);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.toggle();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isOpen()).toBe(true);
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.version-proof__popover')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should close popover on second click', () => {
|
||||
fixture.componentRef.setInput('comparison', mockFixedComparison);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.toggle();
|
||||
fixture.detectChanges();
|
||||
expect(component.isOpen()).toBe(true);
|
||||
|
||||
component.toggle();
|
||||
fixture.detectChanges();
|
||||
expect(component.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('should close popover via close method', () => {
|
||||
fixture.componentRef.setInput('comparison', mockFixedComparison);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.toggle();
|
||||
fixture.detectChanges();
|
||||
expect(component.isOpen()).toBe(true);
|
||||
|
||||
component.close();
|
||||
fixture.detectChanges();
|
||||
expect(component.isOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fixed status display', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('comparison', mockFixedComparison);
|
||||
fixture.detectChanges();
|
||||
component.toggle();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should show "Fixed" status text', () => {
|
||||
expect(component.statusText()).toBe('Fixed');
|
||||
});
|
||||
|
||||
it('should apply fixed header class', () => {
|
||||
expect(component['headerClass']()).toContain('version-proof__header--fixed');
|
||||
});
|
||||
|
||||
it('should display correct versions', () => {
|
||||
expect(component.installedVersion()).toBe('1:1.1.1k-1+deb11u2');
|
||||
expect(component.fixedVersion()).toBe('1:1.1.1k-1+deb11u1');
|
||||
});
|
||||
|
||||
it('should display advisory source', () => {
|
||||
expect(component.advisorySource()).toBe('DSA-5678-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('vulnerable status display', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('comparison', mockVulnerableComparison);
|
||||
fixture.detectChanges();
|
||||
component.toggle();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should show "Vulnerable" status text', () => {
|
||||
expect(component.statusText()).toBe('Vulnerable');
|
||||
});
|
||||
|
||||
it('should apply vulnerable header class', () => {
|
||||
expect(component['headerClass']()).toContain('version-proof__header--vulnerable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('proof lines rendering', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('comparison', mockFixedComparison);
|
||||
fixture.detectChanges();
|
||||
component.toggle();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should display proof lines', () => {
|
||||
expect(component.proofLines().length).toBe(3);
|
||||
});
|
||||
|
||||
it('should render proof lines in the DOM', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const lines = compiled.querySelectorAll('.version-proof__proof-line');
|
||||
expect(lines.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proof line styling', () => {
|
||||
it('should apply equal class for equal comparison', () => {
|
||||
const line = 'Epoch: 1 == 1 (equal)';
|
||||
expect(component.proofLineClass(line)).toContain('version-proof__proof-line--equal');
|
||||
});
|
||||
|
||||
it('should apply older class for older comparison', () => {
|
||||
const line = 'Version: 1.2.3 < 1.2.4 (left is older)';
|
||||
expect(component.proofLineClass(line)).toContain('version-proof__proof-line--older');
|
||||
});
|
||||
|
||||
it('should apply newer class for newer comparison', () => {
|
||||
const line = 'Revision: 1+deb11u2 > 1+deb11u1 (left is newer)';
|
||||
expect(component.proofLineClass(line)).toContain('version-proof__proof-line--newer');
|
||||
});
|
||||
|
||||
it('should apply older class for vulnerable status', () => {
|
||||
const line = 'Status: VULNERABLE';
|
||||
expect(component.proofLineClass(line)).toContain('version-proof__proof-line--older');
|
||||
});
|
||||
});
|
||||
|
||||
describe('comparator badge integration', () => {
|
||||
it('should show comparator badge when comparator is set', () => {
|
||||
fixture.componentRef.setInput('comparison', mockFixedComparison);
|
||||
fixture.detectChanges();
|
||||
component.toggle();
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('stella-comparator-badge')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty proof lines', () => {
|
||||
it('should show "No proof steps available" when no proof lines', () => {
|
||||
const comparisonWithoutProof: VersionComparisonData = {
|
||||
...mockFixedComparison,
|
||||
proofLines: []
|
||||
};
|
||||
fixture.componentRef.setInput('comparison', comparisonWithoutProof);
|
||||
fixture.detectChanges();
|
||||
component.toggle();
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.version-proof__no-proof')).toBeTruthy();
|
||||
expect(compiled.querySelector('.version-proof__no-proof').textContent).toContain('No proof steps available');
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('comparison', mockFixedComparison);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should have aria-expanded on trigger', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const trigger = compiled.querySelector('.version-proof__trigger');
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
||||
|
||||
component.toggle();
|
||||
fixture.detectChanges();
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('should have aria-label on trigger', () => {
|
||||
const compiled = fixture.nativeElement;
|
||||
const trigger = compiled.querySelector('.version-proof__trigger');
|
||||
expect(trigger.getAttribute('aria-label')).toContain('version comparison details');
|
||||
});
|
||||
|
||||
it('should have role="dialog" on popover', () => {
|
||||
component.toggle();
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
const popover = compiled.querySelector('.version-proof__popover');
|
||||
expect(popover.getAttribute('role')).toBe('dialog');
|
||||
});
|
||||
});
|
||||
|
||||
describe('trigger label', () => {
|
||||
it('should include status in trigger label', () => {
|
||||
fixture.componentRef.setInput('comparison', mockFixedComparison);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['triggerLabel']()).toContain('Fixed');
|
||||
});
|
||||
|
||||
it('should include vulnerable in trigger label when vulnerable', () => {
|
||||
fixture.componentRef.setInput('comparison', mockVulnerableComparison);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component['triggerLabel']()).toContain('Vulnerable');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Version Proof Popover Component.
|
||||
* Sprint: SPRINT_4000_0002_0001 (Backport Explainability UX)
|
||||
* Task: T4 - Create "Why Fixed/Vulnerable" Popover
|
||||
*
|
||||
* Displays version comparison steps for explainability.
|
||||
* Shows why a package is considered fixed or vulnerable.
|
||||
*/
|
||||
|
||||
import { Component, computed, input, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ComparatorBadgeComponent } from './comparator-badge.component';
|
||||
|
||||
/**
|
||||
* Version comparison evidence data.
|
||||
*/
|
||||
export interface VersionComparisonData {
|
||||
comparator: string;
|
||||
installedVersion: string;
|
||||
fixedVersion: string;
|
||||
isFixed: boolean;
|
||||
proofLines: string[];
|
||||
advisorySource?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Version proof popover component showing comparison details.
|
||||
*
|
||||
* Features:
|
||||
* - Shows fixed/vulnerable status with icon
|
||||
* - Displays installed vs fixed versions
|
||||
* - Step-by-step comparison proof
|
||||
* - Advisory source reference
|
||||
* - Accessible keyboard navigation
|
||||
*
|
||||
* @example
|
||||
* <stella-version-proof-popover
|
||||
* [comparison]="versionComparisonData"
|
||||
* />
|
||||
*/
|
||||
@Component({
|
||||
selector: 'stella-version-proof-popover',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ComparatorBadgeComponent],
|
||||
template: `
|
||||
<div class="version-proof" [class.version-proof--open]="isOpen()">
|
||||
<!-- Trigger button -->
|
||||
<button
|
||||
class="version-proof__trigger"
|
||||
(click)="toggle()"
|
||||
[attr.aria-expanded]="isOpen()"
|
||||
[attr.aria-label]="triggerLabel()"
|
||||
type="button"
|
||||
>
|
||||
<span class="version-proof__icon" aria-hidden="true">❓</span>
|
||||
</button>
|
||||
|
||||
<!-- Popover content -->
|
||||
@if (isOpen()) {
|
||||
<div
|
||||
class="version-proof__popover"
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
[attr.aria-label]="popoverLabel()"
|
||||
>
|
||||
<!-- Header with status -->
|
||||
<div class="version-proof__header" [class]="headerClass()">
|
||||
<span class="version-proof__status-icon" aria-hidden="true">
|
||||
{{ isFixed() ? '✔' : '⚠' }}
|
||||
</span>
|
||||
<span class="version-proof__status-text">{{ statusText() }}</span>
|
||||
<button
|
||||
class="version-proof__close"
|
||||
(click)="close()"
|
||||
aria-label="Close"
|
||||
type="button"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Version comparison -->
|
||||
<div class="version-proof__versions">
|
||||
<div class="version-proof__version-row">
|
||||
<span class="version-proof__label">Installed:</span>
|
||||
<code class="version-proof__value">{{ installedVersion() }}</code>
|
||||
</div>
|
||||
<div class="version-proof__version-row">
|
||||
<span class="version-proof__label">Fixed in:</span>
|
||||
<code class="version-proof__value">{{ fixedVersion() }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comparator badge -->
|
||||
@if (comparator()) {
|
||||
<div class="version-proof__comparator">
|
||||
<stella-comparator-badge [comparator]="comparator()" [compact]="true" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Divider -->
|
||||
<hr class="version-proof__divider" aria-hidden="true" />
|
||||
|
||||
<!-- Proof lines -->
|
||||
@if (proofLines().length > 0) {
|
||||
<div class="version-proof__proof">
|
||||
<div class="version-proof__proof-title">Comparison steps:</div>
|
||||
<ol class="version-proof__proof-list">
|
||||
@for (line of proofLines(); track $index) {
|
||||
<li class="version-proof__proof-line" [class]="proofLineClass(line)">
|
||||
{{ line }}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="version-proof__no-proof">
|
||||
No proof steps available
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Advisory source -->
|
||||
@if (advisorySource()) {
|
||||
<div class="version-proof__source">
|
||||
<span class="version-proof__source-icon" aria-hidden="true">📄</span>
|
||||
<span class="version-proof__source-label">Source:</span>
|
||||
<span class="version-proof__source-value">{{ advisorySource() }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.version-proof {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.version-proof__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(108, 117, 125, 0.1);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(108, 117, 125, 0.2);
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #007bff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.version-proof--open .version-proof__trigger {
|
||||
background: #007bff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.version-proof__popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
min-width: 320px;
|
||||
max-width: 400px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(108, 117, 125, 0.3);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
animation: popover-fade-in 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes popover-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.version-proof__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.version-proof__header--fixed {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.version-proof__header--vulnerable {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.version-proof__status-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.version-proof__status-text {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.version-proof__close {
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.version-proof__versions {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.version-proof__version-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.version-proof__label {
|
||||
min-width: 70px;
|
||||
font-size: 13px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.version-proof__value {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
color: #495057;
|
||||
background: rgba(108, 117, 125, 0.08);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.version-proof__comparator {
|
||||
padding: 0 16px 8px;
|
||||
}
|
||||
|
||||
.version-proof__divider {
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-top: 1px dashed rgba(108, 117, 125, 0.3);
|
||||
}
|
||||
|
||||
.version-proof__proof {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.version-proof__proof-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #6c757d;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.version-proof__proof-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.version-proof__proof-line {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #495057;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.5;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.version-proof__proof-line--equal {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.version-proof__proof-line--older {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.version-proof__proof-line--newer {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.version-proof__no-proof {
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.version-proof__source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 16px 12px;
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.version-proof__source-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.version-proof__source-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.version-proof__source-value {
|
||||
color: #007bff;
|
||||
}
|
||||
`],
|
||||
host: {
|
||||
'(document:click)': 'onDocumentClick($event)',
|
||||
'(document:keydown.escape)': 'close()',
|
||||
}
|
||||
})
|
||||
export class VersionProofPopoverComponent {
|
||||
/**
|
||||
* Version comparison evidence data.
|
||||
*/
|
||||
readonly comparison = input<VersionComparisonData | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Internal open state.
|
||||
*/
|
||||
private readonly _open = signal(false);
|
||||
|
||||
readonly isOpen = computed(() => this._open());
|
||||
|
||||
readonly isFixed = computed(() => this.comparison()?.isFixed ?? false);
|
||||
|
||||
readonly installedVersion = computed(() => this.comparison()?.installedVersion ?? 'Unknown');
|
||||
|
||||
readonly fixedVersion = computed(() => this.comparison()?.fixedVersion ?? 'Unknown');
|
||||
|
||||
readonly comparator = computed(() => this.comparison()?.comparator ?? null);
|
||||
|
||||
readonly proofLines = computed(() => this.comparison()?.proofLines ?? []);
|
||||
|
||||
readonly advisorySource = computed(() => this.comparison()?.advisorySource);
|
||||
|
||||
readonly statusText = computed(() => this.isFixed() ? 'Fixed' : 'Vulnerable');
|
||||
|
||||
readonly headerClass = computed(() =>
|
||||
this.isFixed()
|
||||
? 'version-proof__header version-proof__header--fixed'
|
||||
: 'version-proof__header version-proof__header--vulnerable'
|
||||
);
|
||||
|
||||
readonly triggerLabel = computed(() =>
|
||||
`Show version comparison details: ${this.statusText()}`
|
||||
);
|
||||
|
||||
readonly popoverLabel = computed(() =>
|
||||
`Version comparison: ${this.installedVersion()} vs ${this.fixedVersion()}`
|
||||
);
|
||||
|
||||
toggle(): void {
|
||||
this._open.update(v => !v);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this._open.set(false);
|
||||
}
|
||||
|
||||
onDocumentClick(event: Event): void {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.version-proof')) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
proofLineClass(line: string): string {
|
||||
const lower = line.toLowerCase();
|
||||
if (lower.includes('equal') || lower.includes('==')) {
|
||||
return 'version-proof__proof-line version-proof__proof-line--equal';
|
||||
}
|
||||
if (lower.includes('older') || lower.includes('vulnerable') || lower.includes('<')) {
|
||||
return 'version-proof__proof-line version-proof__proof-line--older';
|
||||
}
|
||||
if (lower.includes('newer') || lower.includes('fixed') || lower.includes('>')) {
|
||||
return 'version-proof__proof-line version-proof__proof-line--newer';
|
||||
}
|
||||
return 'version-proof__proof-line';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Production environment configuration.
|
||||
*/
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiBaseUrl: '/api',
|
||||
authEnabled: true,
|
||||
debugMode: false,
|
||||
};
|
||||
9
src/Web/StellaOps.Web/src/environments/environment.ts
Normal file
9
src/Web/StellaOps.Web/src/environments/environment.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Development environment configuration.
|
||||
*/
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiBaseUrl: '/api',
|
||||
authEnabled: true,
|
||||
debugMode: true,
|
||||
};
|
||||
Reference in New Issue
Block a user