up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
This commit is contained in:
@@ -57,6 +57,41 @@ export const routes: Routes = [
|
||||
(m) => m.GraphExplorerComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'evidence/:advisoryId',
|
||||
loadComponent: () =>
|
||||
import('./features/evidence/evidence-page.component').then(
|
||||
(m) => m.EvidencePageComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'sources',
|
||||
loadComponent: () =>
|
||||
import('./features/sources/aoc-dashboard.component').then(
|
||||
(m) => m.AocDashboardComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'sources/violations/:code',
|
||||
loadComponent: () =>
|
||||
import('./features/sources/violation-detail.component').then(
|
||||
(m) => m.ViolationDetailComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'releases',
|
||||
loadComponent: () =>
|
||||
import('./features/releases/release-flow.component').then(
|
||||
(m) => m.ReleaseFlowComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'releases/:releaseId',
|
||||
loadComponent: () =>
|
||||
import('./features/releases/release-flow.component').then(
|
||||
(m) => m.ReleaseFlowComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'auth/callback',
|
||||
loadComponent: () =>
|
||||
|
||||
364
src/Web/StellaOps.Web/src/app/core/api/aoc.client.ts
Normal file
364
src/Web/StellaOps.Web/src/app/core/api/aoc.client.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
import {
|
||||
AocDashboardSummary,
|
||||
AocPassFailSummary,
|
||||
AocViolationCode,
|
||||
IngestThroughput,
|
||||
AocSource,
|
||||
AocCheckResult,
|
||||
VerificationRequest,
|
||||
ViolationDetail,
|
||||
TimeSeriesPoint,
|
||||
} from './aoc.models';
|
||||
|
||||
/**
|
||||
* Injection token for AOC API client.
|
||||
*/
|
||||
export const AOC_API = new InjectionToken<AocApi>('AOC_API');
|
||||
|
||||
/**
|
||||
* AOC API interface.
|
||||
*/
|
||||
export interface AocApi {
|
||||
getDashboardSummary(): Observable<AocDashboardSummary>;
|
||||
getViolationDetail(violationId: string): Observable<ViolationDetail>;
|
||||
getViolationsByCode(code: string): Observable<readonly ViolationDetail[]>;
|
||||
startVerification(): Observable<VerificationRequest>;
|
||||
getVerificationStatus(requestId: string): Observable<VerificationRequest>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Data Fixtures
|
||||
// ============================================================================
|
||||
|
||||
function generateHistory(days: number, baseValue: number, variance: number): TimeSeriesPoint[] {
|
||||
const points: TimeSeriesPoint[] = [];
|
||||
const now = new Date();
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const date = new Date(now);
|
||||
date.setDate(date.getDate() - i);
|
||||
points.push({
|
||||
timestamp: date.toISOString(),
|
||||
value: baseValue + Math.floor(Math.random() * variance * 2) - variance,
|
||||
});
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
const mockPassFailSummary: AocPassFailSummary = {
|
||||
period: 'last_24h',
|
||||
totalChecks: 1247,
|
||||
passed: 1198,
|
||||
failed: 32,
|
||||
pending: 12,
|
||||
skipped: 5,
|
||||
passRate: 0.961,
|
||||
trend: 'improving',
|
||||
history: generateHistory(7, 96, 3),
|
||||
};
|
||||
|
||||
const mockViolationCodes: AocViolationCode[] = [
|
||||
{
|
||||
code: 'AOC-001',
|
||||
name: 'Missing Provenance',
|
||||
severity: 'critical',
|
||||
description: 'Document lacks required provenance attestation',
|
||||
count: 12,
|
||||
lastSeen: '2025-11-27T09:45:00Z',
|
||||
documentationUrl: 'https://docs.stellaops.io/aoc/violations/AOC-001',
|
||||
},
|
||||
{
|
||||
code: 'AOC-002',
|
||||
name: 'Invalid Signature',
|
||||
severity: 'critical',
|
||||
description: 'Document signature verification failed',
|
||||
count: 8,
|
||||
lastSeen: '2025-11-27T08:30:00Z',
|
||||
documentationUrl: 'https://docs.stellaops.io/aoc/violations/AOC-002',
|
||||
},
|
||||
{
|
||||
code: 'AOC-010',
|
||||
name: 'Schema Mismatch',
|
||||
severity: 'high',
|
||||
description: 'Document does not conform to expected schema version',
|
||||
count: 5,
|
||||
lastSeen: '2025-11-27T07:15:00Z',
|
||||
documentationUrl: 'https://docs.stellaops.io/aoc/violations/AOC-010',
|
||||
},
|
||||
{
|
||||
code: 'AOC-015',
|
||||
name: 'Timestamp Drift',
|
||||
severity: 'medium',
|
||||
description: 'Document timestamp exceeds allowed drift threshold',
|
||||
count: 4,
|
||||
lastSeen: '2025-11-27T06:00:00Z',
|
||||
},
|
||||
{
|
||||
code: 'AOC-020',
|
||||
name: 'Metadata Incomplete',
|
||||
severity: 'low',
|
||||
description: 'Optional metadata fields are missing',
|
||||
count: 3,
|
||||
lastSeen: '2025-11-26T22:30:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const mockThroughput: IngestThroughput[] = [
|
||||
{
|
||||
tenantId: 'tenant-001',
|
||||
tenantName: 'Acme Corp',
|
||||
documentsIngested: 15420,
|
||||
bytesIngested: 2_450_000_000,
|
||||
documentsPerMinute: 10.7,
|
||||
bytesPerMinute: 1_701_388,
|
||||
period: 'last_24h',
|
||||
},
|
||||
{
|
||||
tenantId: 'tenant-002',
|
||||
tenantName: 'TechStart Inc',
|
||||
documentsIngested: 8932,
|
||||
bytesIngested: 1_120_000_000,
|
||||
documentsPerMinute: 6.2,
|
||||
bytesPerMinute: 777_777,
|
||||
period: 'last_24h',
|
||||
},
|
||||
{
|
||||
tenantId: 'tenant-003',
|
||||
tenantName: 'DataFlow Ltd',
|
||||
documentsIngested: 5678,
|
||||
bytesIngested: 890_000_000,
|
||||
documentsPerMinute: 3.9,
|
||||
bytesPerMinute: 618_055,
|
||||
period: 'last_24h',
|
||||
},
|
||||
{
|
||||
tenantId: 'tenant-004',
|
||||
tenantName: 'SecureOps',
|
||||
documentsIngested: 3421,
|
||||
bytesIngested: 456_000_000,
|
||||
documentsPerMinute: 2.4,
|
||||
bytesPerMinute: 316_666,
|
||||
period: 'last_24h',
|
||||
},
|
||||
];
|
||||
|
||||
const mockSources: AocSource[] = [
|
||||
{
|
||||
sourceId: 'src-001',
|
||||
name: 'Production Registry',
|
||||
type: 'registry',
|
||||
status: 'passed',
|
||||
lastCheck: '2025-11-27T10:00:00Z',
|
||||
checkCount: 523,
|
||||
passRate: 0.98,
|
||||
recentViolations: [],
|
||||
},
|
||||
{
|
||||
sourceId: 'src-002',
|
||||
name: 'GitHub Actions Pipeline',
|
||||
type: 'pipeline',
|
||||
status: 'failed',
|
||||
lastCheck: '2025-11-27T09:45:00Z',
|
||||
checkCount: 412,
|
||||
passRate: 0.92,
|
||||
recentViolations: [mockViolationCodes[0], mockViolationCodes[1]],
|
||||
},
|
||||
{
|
||||
sourceId: 'src-003',
|
||||
name: 'Staging Registry',
|
||||
type: 'registry',
|
||||
status: 'passed',
|
||||
lastCheck: '2025-11-27T09:30:00Z',
|
||||
checkCount: 201,
|
||||
passRate: 0.995,
|
||||
recentViolations: [],
|
||||
},
|
||||
{
|
||||
sourceId: 'src-004',
|
||||
name: 'Manual Upload',
|
||||
type: 'manual',
|
||||
status: 'pending',
|
||||
lastCheck: '2025-11-27T08:00:00Z',
|
||||
checkCount: 111,
|
||||
passRate: 0.85,
|
||||
recentViolations: [mockViolationCodes[2]],
|
||||
},
|
||||
];
|
||||
|
||||
const mockRecentChecks: AocCheckResult[] = [
|
||||
{
|
||||
checkId: 'chk-001',
|
||||
documentId: 'doc-abc123',
|
||||
documentType: 'sbom',
|
||||
status: 'passed',
|
||||
checkedAt: '2025-11-27T10:00:00Z',
|
||||
violations: [],
|
||||
sourceId: 'src-001',
|
||||
tenantId: 'tenant-001',
|
||||
},
|
||||
{
|
||||
checkId: 'chk-002',
|
||||
documentId: 'doc-def456',
|
||||
documentType: 'attestation',
|
||||
status: 'failed',
|
||||
checkedAt: '2025-11-27T09:55:00Z',
|
||||
violations: [mockViolationCodes[0]],
|
||||
sourceId: 'src-002',
|
||||
tenantId: 'tenant-001',
|
||||
},
|
||||
{
|
||||
checkId: 'chk-003',
|
||||
documentId: 'doc-ghi789',
|
||||
documentType: 'sbom',
|
||||
status: 'passed',
|
||||
checkedAt: '2025-11-27T09:50:00Z',
|
||||
violations: [],
|
||||
sourceId: 'src-001',
|
||||
tenantId: 'tenant-002',
|
||||
},
|
||||
{
|
||||
checkId: 'chk-004',
|
||||
documentId: 'doc-jkl012',
|
||||
documentType: 'provenance',
|
||||
status: 'failed',
|
||||
checkedAt: '2025-11-27T09:45:00Z',
|
||||
violations: [mockViolationCodes[1]],
|
||||
sourceId: 'src-002',
|
||||
tenantId: 'tenant-001',
|
||||
},
|
||||
{
|
||||
checkId: 'chk-005',
|
||||
documentId: 'doc-mno345',
|
||||
documentType: 'sbom',
|
||||
status: 'pending',
|
||||
checkedAt: '2025-11-27T09:40:00Z',
|
||||
violations: [],
|
||||
sourceId: 'src-004',
|
||||
tenantId: 'tenant-003',
|
||||
},
|
||||
];
|
||||
|
||||
const mockDashboard: AocDashboardSummary = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
passFail: mockPassFailSummary,
|
||||
recentViolations: mockViolationCodes,
|
||||
throughputByTenant: mockThroughput,
|
||||
sources: mockSources,
|
||||
recentChecks: mockRecentChecks,
|
||||
};
|
||||
|
||||
const mockViolationDetails: ViolationDetail[] = [
|
||||
{
|
||||
violationId: 'viol-001',
|
||||
code: 'AOC-001',
|
||||
severity: 'critical',
|
||||
documentId: 'doc-def456',
|
||||
documentType: 'attestation',
|
||||
offendingFields: [
|
||||
{
|
||||
path: '$.predicate.buildType',
|
||||
expectedValue: 'https://slsa.dev/provenance/v1',
|
||||
actualValue: undefined,
|
||||
reason: 'Required field is missing',
|
||||
},
|
||||
{
|
||||
path: '$.predicate.builder.id',
|
||||
expectedValue: 'https://github.com/actions/runner',
|
||||
actualValue: undefined,
|
||||
reason: 'Builder ID not specified',
|
||||
},
|
||||
],
|
||||
provenance: {
|
||||
sourceType: 'pipeline',
|
||||
sourceUri: 'github.com/acme/api-service',
|
||||
ingestedAt: '2025-11-27T09:55:00Z',
|
||||
ingestedBy: 'github-actions',
|
||||
buildId: 'build-12345',
|
||||
commitSha: 'a1b2c3d4e5f6',
|
||||
pipelineUrl: 'https://github.com/acme/api-service/actions/runs/12345',
|
||||
},
|
||||
detectedAt: '2025-11-27T09:55:00Z',
|
||||
suggestion: 'Add SLSA provenance attestation to your build pipeline. See https://slsa.dev/spec/v1.0/provenance',
|
||||
},
|
||||
{
|
||||
violationId: 'viol-002',
|
||||
code: 'AOC-002',
|
||||
severity: 'critical',
|
||||
documentId: 'doc-jkl012',
|
||||
documentType: 'provenance',
|
||||
offendingFields: [
|
||||
{
|
||||
path: '$.signatures[0]',
|
||||
expectedValue: 'Valid DSSE signature',
|
||||
actualValue: 'Invalid or expired signature',
|
||||
reason: 'Signature verification failed: key not found in keyring',
|
||||
},
|
||||
],
|
||||
provenance: {
|
||||
sourceType: 'pipeline',
|
||||
sourceUri: 'github.com/acme/worker-service',
|
||||
ingestedAt: '2025-11-27T09:45:00Z',
|
||||
ingestedBy: 'github-actions',
|
||||
buildId: 'build-12346',
|
||||
commitSha: 'b2c3d4e5f6a7',
|
||||
pipelineUrl: 'https://github.com/acme/worker-service/actions/runs/12346',
|
||||
},
|
||||
detectedAt: '2025-11-27T09:45:00Z',
|
||||
suggestion: 'Ensure the signing key is registered in your tenant keyring. Run: stella keys add --public-key <key-file>',
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Mock API Implementation
|
||||
// ============================================================================
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockAocApi implements AocApi {
|
||||
getDashboardSummary(): Observable<AocDashboardSummary> {
|
||||
return of({
|
||||
...mockDashboard,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}).pipe(delay(300));
|
||||
}
|
||||
|
||||
getViolationDetail(violationId: string): Observable<ViolationDetail> {
|
||||
const detail = mockViolationDetails.find((v) => v.violationId === violationId);
|
||||
if (!detail) {
|
||||
throw new Error(`Violation not found: ${violationId}`);
|
||||
}
|
||||
return of(detail).pipe(delay(200));
|
||||
}
|
||||
|
||||
getViolationsByCode(code: string): Observable<readonly ViolationDetail[]> {
|
||||
const details = mockViolationDetails.filter((v) => v.code === code);
|
||||
return of(details).pipe(delay(250));
|
||||
}
|
||||
|
||||
startVerification(): Observable<VerificationRequest> {
|
||||
return of({
|
||||
requestId: `verify-${Date.now()}`,
|
||||
status: 'queued',
|
||||
documentsToVerify: 1247,
|
||||
documentsVerified: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
cliCommand: 'stella aoc verify --since 24h --output json',
|
||||
}).pipe(delay(400));
|
||||
}
|
||||
|
||||
getVerificationStatus(requestId: string): Observable<VerificationRequest> {
|
||||
// Simulate a completed verification
|
||||
return of({
|
||||
requestId,
|
||||
status: 'completed',
|
||||
startedAt: new Date(Date.now() - 120000).toISOString(),
|
||||
completedAt: new Date().toISOString(),
|
||||
documentsToVerify: 1247,
|
||||
documentsVerified: 1247,
|
||||
passed: 1198,
|
||||
failed: 49,
|
||||
cliCommand: 'stella aoc verify --since 24h --output json',
|
||||
}).pipe(delay(300));
|
||||
}
|
||||
}
|
||||
152
src/Web/StellaOps.Web/src/app/core/api/aoc.models.ts
Normal file
152
src/Web/StellaOps.Web/src/app/core/api/aoc.models.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Attestation of Conformance (AOC) models for UI-AOC-19-001.
|
||||
* Supports Sources dashboard tiles showing pass/fail, violation codes, and ingest throughput.
|
||||
*/
|
||||
|
||||
// AOC verification status
|
||||
export type AocVerificationStatus = 'passed' | 'failed' | 'pending' | 'skipped';
|
||||
|
||||
// Violation severity levels
|
||||
export type ViolationSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info';
|
||||
|
||||
/**
|
||||
* AOC violation code with metadata.
|
||||
*/
|
||||
export interface AocViolationCode {
|
||||
readonly code: string;
|
||||
readonly name: string;
|
||||
readonly severity: ViolationSeverity;
|
||||
readonly description: string;
|
||||
readonly count: number;
|
||||
readonly lastSeen: string;
|
||||
readonly documentationUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-tenant ingest throughput metrics.
|
||||
*/
|
||||
export interface IngestThroughput {
|
||||
readonly tenantId: string;
|
||||
readonly tenantName: string;
|
||||
readonly documentsIngested: number;
|
||||
readonly bytesIngested: number;
|
||||
readonly documentsPerMinute: number;
|
||||
readonly bytesPerMinute: number;
|
||||
readonly period: string; // e.g., "last_24h", "last_7d"
|
||||
}
|
||||
|
||||
/**
|
||||
* Time-series data point for charts.
|
||||
*/
|
||||
export interface TimeSeriesPoint {
|
||||
readonly timestamp: string;
|
||||
readonly value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* AOC pass/fail summary for a time period.
|
||||
*/
|
||||
export interface AocPassFailSummary {
|
||||
readonly period: string;
|
||||
readonly totalChecks: number;
|
||||
readonly passed: number;
|
||||
readonly failed: number;
|
||||
readonly pending: number;
|
||||
readonly skipped: number;
|
||||
readonly passRate: number; // 0-1
|
||||
readonly trend: 'improving' | 'stable' | 'degrading';
|
||||
readonly history: readonly TimeSeriesPoint[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual AOC check result.
|
||||
*/
|
||||
export interface AocCheckResult {
|
||||
readonly checkId: string;
|
||||
readonly documentId: string;
|
||||
readonly documentType: string;
|
||||
readonly status: AocVerificationStatus;
|
||||
readonly checkedAt: string;
|
||||
readonly violations: readonly AocViolationCode[];
|
||||
readonly sourceId?: string;
|
||||
readonly tenantId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source with AOC metrics.
|
||||
*/
|
||||
export interface AocSource {
|
||||
readonly sourceId: string;
|
||||
readonly name: string;
|
||||
readonly type: 'registry' | 'repository' | 'pipeline' | 'manual';
|
||||
readonly status: AocVerificationStatus;
|
||||
readonly lastCheck: string;
|
||||
readonly checkCount: number;
|
||||
readonly passRate: number;
|
||||
readonly recentViolations: readonly AocViolationCode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* AOC dashboard summary combining all metrics.
|
||||
*/
|
||||
export interface AocDashboardSummary {
|
||||
readonly generatedAt: string;
|
||||
readonly passFail: AocPassFailSummary;
|
||||
readonly recentViolations: readonly AocViolationCode[];
|
||||
readonly throughputByTenant: readonly IngestThroughput[];
|
||||
readonly sources: readonly AocSource[];
|
||||
readonly recentChecks: readonly AocCheckResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verification request for "Verify last 24h" action.
|
||||
*/
|
||||
export interface VerificationRequest {
|
||||
readonly requestId: string;
|
||||
readonly status: 'queued' | 'running' | 'completed' | 'failed';
|
||||
readonly startedAt?: string;
|
||||
readonly completedAt?: string;
|
||||
readonly documentsToVerify: number;
|
||||
readonly documentsVerified: number;
|
||||
readonly passed: number;
|
||||
readonly failed: number;
|
||||
readonly cliCommand?: string; // CLI parity command
|
||||
}
|
||||
|
||||
/**
|
||||
* Violation detail for drill-down view.
|
||||
*/
|
||||
export interface ViolationDetail {
|
||||
readonly violationId: string;
|
||||
readonly code: string;
|
||||
readonly severity: ViolationSeverity;
|
||||
readonly documentId: string;
|
||||
readonly documentType: string;
|
||||
readonly offendingFields: readonly OffendingField[];
|
||||
readonly provenance: ProvenanceMetadata;
|
||||
readonly detectedAt: string;
|
||||
readonly suggestion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offending field in a document.
|
||||
*/
|
||||
export interface OffendingField {
|
||||
readonly path: string; // JSON path, e.g., "$.metadata.labels"
|
||||
readonly expectedValue?: string;
|
||||
readonly actualValue?: string;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provenance metadata for a document.
|
||||
*/
|
||||
export interface ProvenanceMetadata {
|
||||
readonly sourceType: string;
|
||||
readonly sourceUri: string;
|
||||
readonly ingestedAt: string;
|
||||
readonly ingestedBy: string;
|
||||
readonly buildId?: string;
|
||||
readonly commitSha?: string;
|
||||
readonly pipelineUrl?: string;
|
||||
}
|
||||
323
src/Web/StellaOps.Web/src/app/core/api/evidence.client.ts
Normal file
323
src/Web/StellaOps.Web/src/app/core/api/evidence.client.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
|
||||
import {
|
||||
EvidenceData,
|
||||
Linkset,
|
||||
Observation,
|
||||
PolicyEvidence,
|
||||
} from './evidence.models';
|
||||
|
||||
export interface EvidenceApi {
|
||||
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData>;
|
||||
getObservation(observationId: string): Observable<Observation>;
|
||||
getLinkset(linksetId: string): Observable<Linkset>;
|
||||
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null>;
|
||||
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob>;
|
||||
}
|
||||
|
||||
export const EVIDENCE_API = new InjectionToken<EvidenceApi>('EVIDENCE_API');
|
||||
|
||||
// Mock data for development
|
||||
const MOCK_OBSERVATIONS: Observation[] = [
|
||||
{
|
||||
observationId: 'obs-ghsa-001',
|
||||
tenantId: 'tenant-1',
|
||||
source: 'ghsa',
|
||||
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
|
||||
title: 'Log4j Remote Code Execution (Log4Shell)',
|
||||
summary: 'Apache Log4j2 2.0-beta9 through 2.15.0 (excluding security releases 2.12.2, 2.12.3, and 2.3.1) JNDI features do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
],
|
||||
affected: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||
package: 'log4j-core',
|
||||
ecosystem: 'maven',
|
||||
ranges: [
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.0-beta9' },
|
||||
{ fixed: '2.15.0' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://github.com/advisories/GHSA-jfh8-c2jp-5v3q',
|
||||
'https://logging.apache.org/log4j/2.x/security.html',
|
||||
],
|
||||
weaknesses: ['CWE-502', 'CWE-400', 'CWE-20'],
|
||||
published: '2021-12-10T00:00:00Z',
|
||||
modified: '2024-01-15T10:30:00Z',
|
||||
provenance: {
|
||||
sourceArtifactSha: 'sha256:abc123def456...',
|
||||
fetchedAt: '2024-11-20T08:00:00Z',
|
||||
ingestJobId: 'job-ghsa-2024-1120',
|
||||
},
|
||||
ingestedAt: '2024-11-20T08:05:00Z',
|
||||
},
|
||||
{
|
||||
observationId: 'obs-nvd-001',
|
||||
tenantId: 'tenant-1',
|
||||
source: 'nvd',
|
||||
advisoryId: 'CVE-2021-44228',
|
||||
title: 'Apache Log4j2 Remote Code Execution Vulnerability',
|
||||
summary: 'Apache Log4j2 <=2.14.1 JNDI features used in configuration, log messages, and parameters do not protect against attacker controlled LDAP and other JNDI related endpoints.',
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
{ system: 'cvss_v2', score: 9.3, vector: 'AV:N/AC:M/Au:N/C:C/I:C/A:C' },
|
||||
],
|
||||
affected: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||
package: 'log4j-core',
|
||||
ecosystem: 'maven',
|
||||
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
|
||||
cpe: ['cpe:2.3:a:apache:log4j:*:*:*:*:*:*:*:*'],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://nvd.nist.gov/vuln/detail/CVE-2021-44228',
|
||||
'https://www.cisa.gov/news-events/alerts/2021/12/11/apache-log4j-vulnerability-guidance',
|
||||
],
|
||||
relationships: [
|
||||
{ type: 'alias', source: 'CVE-2021-44228', target: 'GHSA-jfh8-c2jp-5v3q', provenance: 'nvd' },
|
||||
],
|
||||
weaknesses: ['CWE-917', 'CWE-20', 'CWE-400', 'CWE-502'],
|
||||
published: '2021-12-10T10:15:00Z',
|
||||
modified: '2024-02-20T15:45:00Z',
|
||||
provenance: {
|
||||
sourceArtifactSha: 'sha256:def789ghi012...',
|
||||
fetchedAt: '2024-11-20T08:10:00Z',
|
||||
ingestJobId: 'job-nvd-2024-1120',
|
||||
},
|
||||
ingestedAt: '2024-11-20T08:15:00Z',
|
||||
},
|
||||
{
|
||||
observationId: 'obs-osv-001',
|
||||
tenantId: 'tenant-1',
|
||||
source: 'osv',
|
||||
advisoryId: 'GHSA-jfh8-c2jp-5v3q',
|
||||
title: 'Remote code injection in Log4j',
|
||||
summary: 'Logging untrusted data with log4j versions 2.0-beta9 through 2.14.1 can result in remote code execution.',
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
],
|
||||
affected: [
|
||||
{
|
||||
purl: 'pkg:maven/org.apache.logging.log4j/log4j-core',
|
||||
package: 'log4j-core',
|
||||
ecosystem: 'Maven',
|
||||
ranges: [
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.0-beta9' },
|
||||
{ fixed: '2.3.1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.4' },
|
||||
{ fixed: '2.12.2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'ECOSYSTEM',
|
||||
events: [
|
||||
{ introduced: '2.13.0' },
|
||||
{ fixed: '2.15.0' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
references: [
|
||||
'https://osv.dev/vulnerability/GHSA-jfh8-c2jp-5v3q',
|
||||
],
|
||||
published: '2021-12-10T00:00:00Z',
|
||||
modified: '2023-06-15T09:00:00Z',
|
||||
provenance: {
|
||||
sourceArtifactSha: 'sha256:ghi345jkl678...',
|
||||
fetchedAt: '2024-11-20T08:20:00Z',
|
||||
ingestJobId: 'job-osv-2024-1120',
|
||||
},
|
||||
ingestedAt: '2024-11-20T08:25:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_LINKSET: Linkset = {
|
||||
linksetId: 'ls-log4shell-001',
|
||||
tenantId: 'tenant-1',
|
||||
advisoryId: 'CVE-2021-44228',
|
||||
source: 'aggregated',
|
||||
observations: ['obs-ghsa-001', 'obs-nvd-001', 'obs-osv-001'],
|
||||
normalized: {
|
||||
purls: ['pkg:maven/org.apache.logging.log4j/log4j-core'],
|
||||
versions: ['2.0-beta9', '2.0', '2.1', '2.2', '2.3', '2.4', '2.4.1', '2.5', '2.6', '2.6.1', '2.6.2', '2.7', '2.8', '2.8.1', '2.8.2', '2.9.0', '2.9.1', '2.10.0', '2.11.0', '2.11.1', '2.11.2', '2.12.0', '2.12.1', '2.13.0', '2.13.1', '2.13.2', '2.13.3', '2.14.0', '2.14.1'],
|
||||
severities: [
|
||||
{ system: 'cvss_v3', score: 10.0, vector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H' },
|
||||
],
|
||||
},
|
||||
confidence: 0.95,
|
||||
conflicts: [
|
||||
{
|
||||
field: 'affected.ranges',
|
||||
reason: 'Different fixed version ranges reported by sources',
|
||||
values: ['2.15.0 (GHSA)', '2.3.1/2.12.2/2.15.0 (OSV)'],
|
||||
sourceIds: ['ghsa', 'osv'],
|
||||
},
|
||||
{
|
||||
field: 'weaknesses',
|
||||
reason: 'Different CWE identifiers reported',
|
||||
values: ['CWE-502, CWE-400, CWE-20 (GHSA)', 'CWE-917, CWE-20, CWE-400, CWE-502 (NVD)'],
|
||||
sourceIds: ['ghsa', 'nvd'],
|
||||
},
|
||||
],
|
||||
createdAt: '2024-11-20T08:30:00Z',
|
||||
builtByJobId: 'linkset-build-2024-1120',
|
||||
provenance: {
|
||||
observationHashes: [
|
||||
'sha256:abc123...',
|
||||
'sha256:def789...',
|
||||
'sha256:ghi345...',
|
||||
],
|
||||
toolVersion: 'concelier-lnm-1.2.0',
|
||||
policyHash: 'sha256:policy-hash-001',
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_POLICY_EVIDENCE: PolicyEvidence = {
|
||||
policyId: 'pol-critical-vuln-001',
|
||||
policyName: 'Critical Vulnerability Policy',
|
||||
decision: 'block',
|
||||
decidedAt: '2024-11-20T08:35:00Z',
|
||||
reason: 'Critical severity vulnerability (CVSS 10.0) with known exploits',
|
||||
rules: [
|
||||
{
|
||||
ruleId: 'rule-cvss-critical',
|
||||
ruleName: 'Block Critical CVSS',
|
||||
passed: false,
|
||||
reason: 'CVSS score 10.0 exceeds threshold of 9.0',
|
||||
matchedItems: ['CVE-2021-44228'],
|
||||
},
|
||||
{
|
||||
ruleId: 'rule-known-exploit',
|
||||
ruleName: 'Known Exploit Check',
|
||||
passed: false,
|
||||
reason: 'Active exploitation reported by CISA',
|
||||
matchedItems: ['KEV-2021-44228'],
|
||||
},
|
||||
{
|
||||
ruleId: 'rule-fix-available',
|
||||
ruleName: 'Fix Available',
|
||||
passed: true,
|
||||
reason: 'Fixed version 2.15.0+ available',
|
||||
},
|
||||
],
|
||||
linksetIds: ['ls-log4shell-001'],
|
||||
aocChain: [
|
||||
{
|
||||
attestationId: 'aoc-obs-ghsa-001',
|
||||
type: 'observation',
|
||||
hash: 'sha256:abc123def456...',
|
||||
timestamp: '2024-11-20T08:05:00Z',
|
||||
parentHash: undefined,
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-obs-nvd-001',
|
||||
type: 'observation',
|
||||
hash: 'sha256:def789ghi012...',
|
||||
timestamp: '2024-11-20T08:15:00Z',
|
||||
parentHash: 'sha256:abc123def456...',
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-obs-osv-001',
|
||||
type: 'observation',
|
||||
hash: 'sha256:ghi345jkl678...',
|
||||
timestamp: '2024-11-20T08:25:00Z',
|
||||
parentHash: 'sha256:def789ghi012...',
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-ls-001',
|
||||
type: 'linkset',
|
||||
hash: 'sha256:linkset-hash-001...',
|
||||
timestamp: '2024-11-20T08:30:00Z',
|
||||
parentHash: 'sha256:ghi345jkl678...',
|
||||
},
|
||||
{
|
||||
attestationId: 'aoc-policy-001',
|
||||
type: 'policy',
|
||||
hash: 'sha256:policy-decision-hash...',
|
||||
timestamp: '2024-11-20T08:35:00Z',
|
||||
signer: 'policy-engine-v1',
|
||||
parentHash: 'sha256:linkset-hash-001...',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockEvidenceApiService implements EvidenceApi {
|
||||
getEvidenceForAdvisory(advisoryId: string): Observable<EvidenceData> {
|
||||
// Filter observations related to the advisory
|
||||
const observations = MOCK_OBSERVATIONS.filter(
|
||||
(o) =>
|
||||
o.advisoryId === advisoryId ||
|
||||
o.advisoryId === 'GHSA-jfh8-c2jp-5v3q' // Related to CVE-2021-44228
|
||||
);
|
||||
|
||||
const linkset = MOCK_LINKSET;
|
||||
const policyEvidence = MOCK_POLICY_EVIDENCE;
|
||||
|
||||
const data: EvidenceData = {
|
||||
advisoryId,
|
||||
title: observations[0]?.title ?? `Evidence for ${advisoryId}`,
|
||||
observations,
|
||||
linkset,
|
||||
policyEvidence,
|
||||
hasConflicts: linkset.conflicts.length > 0,
|
||||
conflictCount: linkset.conflicts.length,
|
||||
};
|
||||
|
||||
return of(data).pipe(delay(300));
|
||||
}
|
||||
|
||||
getObservation(observationId: string): Observable<Observation> {
|
||||
const observation = MOCK_OBSERVATIONS.find((o) => o.observationId === observationId);
|
||||
if (!observation) {
|
||||
throw new Error(`Observation not found: ${observationId}`);
|
||||
}
|
||||
return of(observation).pipe(delay(100));
|
||||
}
|
||||
|
||||
getLinkset(linksetId: string): Observable<Linkset> {
|
||||
if (linksetId === MOCK_LINKSET.linksetId) {
|
||||
return of(MOCK_LINKSET).pipe(delay(100));
|
||||
}
|
||||
throw new Error(`Linkset not found: ${linksetId}`);
|
||||
}
|
||||
|
||||
getPolicyEvidence(advisoryId: string): Observable<PolicyEvidence | null> {
|
||||
if (advisoryId === 'CVE-2021-44228' || advisoryId === 'GHSA-jfh8-c2jp-5v3q') {
|
||||
return of(MOCK_POLICY_EVIDENCE).pipe(delay(100));
|
||||
}
|
||||
return of(null).pipe(delay(100));
|
||||
}
|
||||
|
||||
downloadRawDocument(type: 'observation' | 'linkset', id: string): Observable<Blob> {
|
||||
let data: object;
|
||||
if (type === 'observation') {
|
||||
data = MOCK_OBSERVATIONS.find((o) => o.observationId === id) ?? {};
|
||||
} else {
|
||||
data = MOCK_LINKSET;
|
||||
}
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
return of(blob).pipe(delay(100));
|
||||
}
|
||||
}
|
||||
189
src/Web/StellaOps.Web/src/app/core/api/evidence.models.ts
Normal file
189
src/Web/StellaOps.Web/src/app/core/api/evidence.models.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Link-Not-Merge Evidence Models
|
||||
* Based on docs/modules/concelier/link-not-merge-schema.md
|
||||
*/
|
||||
|
||||
// Severity from advisory sources
|
||||
export interface AdvisorySeverity {
|
||||
readonly system: string; // e.g., 'cvss_v3', 'cvss_v2', 'ghsa'
|
||||
readonly score: number;
|
||||
readonly vector?: string;
|
||||
}
|
||||
|
||||
// Affected package information
|
||||
export interface AffectedPackage {
|
||||
readonly purl: string;
|
||||
readonly package?: string;
|
||||
readonly versions?: readonly string[];
|
||||
readonly ranges?: readonly VersionRange[];
|
||||
readonly ecosystem?: string;
|
||||
readonly cpe?: readonly string[];
|
||||
}
|
||||
|
||||
export interface VersionRange {
|
||||
readonly type: string;
|
||||
readonly events: readonly VersionEvent[];
|
||||
}
|
||||
|
||||
export interface VersionEvent {
|
||||
readonly introduced?: string;
|
||||
readonly fixed?: string;
|
||||
readonly last_affected?: string;
|
||||
}
|
||||
|
||||
// Relationship between advisories
|
||||
export interface AdvisoryRelationship {
|
||||
readonly type: string;
|
||||
readonly source: string;
|
||||
readonly target: string;
|
||||
readonly provenance?: string;
|
||||
}
|
||||
|
||||
// Provenance tracking
|
||||
export interface ObservationProvenance {
|
||||
readonly sourceArtifactSha: string;
|
||||
readonly fetchedAt: string;
|
||||
readonly ingestJobId?: string;
|
||||
readonly signature?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Raw observation from a single source
|
||||
export interface Observation {
|
||||
readonly observationId: string;
|
||||
readonly tenantId: string;
|
||||
readonly source: string; // e.g., 'ghsa', 'nvd', 'cert-bund'
|
||||
readonly advisoryId: string;
|
||||
readonly title?: string;
|
||||
readonly summary?: string;
|
||||
readonly severities: readonly AdvisorySeverity[];
|
||||
readonly affected: readonly AffectedPackage[];
|
||||
readonly references?: readonly string[];
|
||||
readonly scopes?: readonly string[];
|
||||
readonly relationships?: readonly AdvisoryRelationship[];
|
||||
readonly weaknesses?: readonly string[];
|
||||
readonly published?: string;
|
||||
readonly modified?: string;
|
||||
readonly provenance: ObservationProvenance;
|
||||
readonly ingestedAt: string;
|
||||
}
|
||||
|
||||
// Conflict when sources disagree
|
||||
export interface LinksetConflict {
|
||||
readonly field: string;
|
||||
readonly reason: string;
|
||||
readonly values?: readonly string[];
|
||||
readonly sourceIds?: readonly string[];
|
||||
}
|
||||
|
||||
// Linkset provenance
|
||||
export interface LinksetProvenance {
|
||||
readonly observationHashes: readonly string[];
|
||||
readonly toolVersion?: string;
|
||||
readonly policyHash?: string;
|
||||
}
|
||||
|
||||
// Normalized linkset aggregating multiple observations
|
||||
export interface Linkset {
|
||||
readonly linksetId: string;
|
||||
readonly tenantId: string;
|
||||
readonly advisoryId: string;
|
||||
readonly source: string;
|
||||
readonly observations: readonly string[]; // observation IDs
|
||||
readonly normalized?: {
|
||||
readonly purls?: readonly string[];
|
||||
readonly versions?: readonly string[];
|
||||
readonly ranges?: readonly VersionRange[];
|
||||
readonly severities?: readonly AdvisorySeverity[];
|
||||
};
|
||||
readonly confidence?: number; // 0-1
|
||||
readonly conflicts: readonly LinksetConflict[];
|
||||
readonly createdAt: string;
|
||||
readonly builtByJobId?: string;
|
||||
readonly provenance?: LinksetProvenance;
|
||||
}
|
||||
|
||||
// Policy decision result
|
||||
export type PolicyDecision = 'pass' | 'warn' | 'block' | 'pending';
|
||||
|
||||
// Policy decision with evidence
|
||||
export interface PolicyEvidence {
|
||||
readonly policyId: string;
|
||||
readonly policyName: string;
|
||||
readonly decision: PolicyDecision;
|
||||
readonly decidedAt: string;
|
||||
readonly reason?: string;
|
||||
readonly rules: readonly PolicyRuleResult[];
|
||||
readonly linksetIds: readonly string[];
|
||||
readonly aocChain?: AocChainEntry[];
|
||||
}
|
||||
|
||||
export interface PolicyRuleResult {
|
||||
readonly ruleId: string;
|
||||
readonly ruleName: string;
|
||||
readonly passed: boolean;
|
||||
readonly reason?: string;
|
||||
readonly matchedItems?: readonly string[];
|
||||
}
|
||||
|
||||
// AOC (Attestation of Compliance) chain entry
|
||||
export interface AocChainEntry {
|
||||
readonly attestationId: string;
|
||||
readonly type: 'observation' | 'linkset' | 'policy' | 'signature';
|
||||
readonly hash: string;
|
||||
readonly timestamp: string;
|
||||
readonly signer?: string;
|
||||
readonly parentHash?: string;
|
||||
}
|
||||
|
||||
// Evidence panel data combining all elements
|
||||
export interface EvidenceData {
|
||||
readonly advisoryId: string;
|
||||
readonly title?: string;
|
||||
readonly observations: readonly Observation[];
|
||||
readonly linkset?: Linkset;
|
||||
readonly policyEvidence?: PolicyEvidence;
|
||||
readonly hasConflicts: boolean;
|
||||
readonly conflictCount: number;
|
||||
}
|
||||
|
||||
// Source metadata for display
|
||||
export interface SourceInfo {
|
||||
readonly sourceId: string;
|
||||
readonly name: string;
|
||||
readonly icon?: string;
|
||||
readonly url?: string;
|
||||
readonly lastUpdated?: string;
|
||||
}
|
||||
|
||||
export const SOURCE_INFO: Record<string, SourceInfo> = {
|
||||
ghsa: {
|
||||
sourceId: 'ghsa',
|
||||
name: 'GitHub Security Advisories',
|
||||
icon: 'github',
|
||||
url: 'https://github.com/advisories',
|
||||
},
|
||||
nvd: {
|
||||
sourceId: 'nvd',
|
||||
name: 'National Vulnerability Database',
|
||||
icon: 'database',
|
||||
url: 'https://nvd.nist.gov',
|
||||
},
|
||||
'cert-bund': {
|
||||
sourceId: 'cert-bund',
|
||||
name: 'CERT-Bund',
|
||||
icon: 'shield',
|
||||
url: 'https://www.cert-bund.de',
|
||||
},
|
||||
osv: {
|
||||
sourceId: 'osv',
|
||||
name: 'Open Source Vulnerabilities',
|
||||
icon: 'box',
|
||||
url: 'https://osv.dev',
|
||||
},
|
||||
cve: {
|
||||
sourceId: 'cve',
|
||||
name: 'CVE Program',
|
||||
icon: 'alert-triangle',
|
||||
url: 'https://cve.mitre.org',
|
||||
},
|
||||
};
|
||||
373
src/Web/StellaOps.Web/src/app/core/api/release.client.ts
Normal file
373
src/Web/StellaOps.Web/src/app/core/api/release.client.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
import {
|
||||
Release,
|
||||
ReleaseArtifact,
|
||||
PolicyEvaluation,
|
||||
PolicyGateResult,
|
||||
DeterminismGateDetails,
|
||||
RemediationHint,
|
||||
DeterminismFeatureFlags,
|
||||
PolicyGateStatus,
|
||||
} from './release.models';
|
||||
|
||||
/**
|
||||
* Injection token for Release API client.
|
||||
*/
|
||||
export const RELEASE_API = new InjectionToken<ReleaseApi>('RELEASE_API');
|
||||
|
||||
/**
|
||||
* Release API interface.
|
||||
*/
|
||||
export interface ReleaseApi {
|
||||
getRelease(releaseId: string): Observable<Release>;
|
||||
listReleases(): Observable<readonly Release[]>;
|
||||
publishRelease(releaseId: string): Observable<Release>;
|
||||
cancelRelease(releaseId: string): Observable<Release>;
|
||||
getFeatureFlags(): Observable<DeterminismFeatureFlags>;
|
||||
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Data Fixtures
|
||||
// ============================================================================
|
||||
|
||||
const determinismPassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-det-001',
|
||||
gateType: 'determinism',
|
||||
name: 'SBOM Determinism',
|
||||
status: 'passed',
|
||||
message: 'Merkle root consistent. All fragment attestations verified.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: true,
|
||||
evidence: {
|
||||
type: 'determinism',
|
||||
url: '/scans/scan-abc123?tab=determinism',
|
||||
details: {
|
||||
merkleRoot: 'sha256:a1b2c3d4e5f6...',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const determinismFailingGate: PolicyGateResult = {
|
||||
gateId: 'gate-det-002',
|
||||
gateType: 'determinism',
|
||||
name: 'SBOM Determinism',
|
||||
status: 'failed',
|
||||
message: 'Merkle root mismatch. 2 fragment attestations failed verification.',
|
||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||
blockingPublish: true,
|
||||
evidence: {
|
||||
type: 'determinism',
|
||||
url: '/scans/scan-def456?tab=determinism',
|
||||
details: {
|
||||
merkleRoot: 'sha256:f1e2d3c4b5a6...',
|
||||
expectedMerkleRoot: 'sha256:9a8b7c6d5e4f...',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 6,
|
||||
failedFragments: [
|
||||
'sha256:layer3digest...',
|
||||
'sha256:layer5digest...',
|
||||
],
|
||||
},
|
||||
},
|
||||
remediation: {
|
||||
gateType: 'determinism',
|
||||
severity: 'critical',
|
||||
summary: 'The SBOM composition cannot be independently verified. Fragment attestations for layers 3 and 5 failed DSSE signature verification.',
|
||||
steps: [
|
||||
{
|
||||
action: 'rebuild',
|
||||
title: 'Rebuild with deterministic toolchain',
|
||||
description: 'Rebuild the image using Stella Scanner with --deterministic flag to ensure consistent fragment hashes.',
|
||||
command: 'stella scan --deterministic --sign --push',
|
||||
documentationUrl: 'https://docs.stellaops.io/scanner/determinism',
|
||||
automated: false,
|
||||
},
|
||||
{
|
||||
action: 'provide-provenance',
|
||||
title: 'Provide provenance attestation',
|
||||
description: 'Ensure build provenance (SLSA Level 2+) is attached to the image manifest.',
|
||||
documentationUrl: 'https://docs.stellaops.io/provenance',
|
||||
automated: false,
|
||||
},
|
||||
{
|
||||
action: 'sign-artifact',
|
||||
title: 'Re-sign with valid key',
|
||||
description: 'Sign the SBOM fragments with a valid DSSE key registered in your tenant.',
|
||||
command: 'stella sign --artifact sha256:...',
|
||||
automated: true,
|
||||
},
|
||||
{
|
||||
action: 'request-exception',
|
||||
title: 'Request policy exception',
|
||||
description: 'If this is a known issue with a compensating control, request a time-boxed exception.',
|
||||
automated: true,
|
||||
},
|
||||
],
|
||||
estimatedEffort: '15-30 minutes',
|
||||
exceptionAllowed: true,
|
||||
},
|
||||
};
|
||||
|
||||
const vulnerabilityPassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-vuln-001',
|
||||
gateType: 'vulnerability',
|
||||
name: 'Vulnerability Scan',
|
||||
status: 'passed',
|
||||
message: 'No critical or high vulnerabilities. 3 medium, 12 low.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: false,
|
||||
};
|
||||
|
||||
const entropyWarningGate: PolicyGateResult = {
|
||||
gateId: 'gate-ent-001',
|
||||
gateType: 'entropy',
|
||||
name: 'Entropy Analysis',
|
||||
status: 'warning',
|
||||
message: 'Image opaque ratio 12% (warn threshold: 10%). Consider providing provenance.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: false,
|
||||
remediation: {
|
||||
gateType: 'entropy',
|
||||
severity: 'medium',
|
||||
summary: 'High entropy detected in some layers. This may indicate packed/encrypted content.',
|
||||
steps: [
|
||||
{
|
||||
action: 'provide-provenance',
|
||||
title: 'Provide source provenance',
|
||||
description: 'Attach build provenance or source mappings for high-entropy binaries.',
|
||||
automated: false,
|
||||
},
|
||||
],
|
||||
estimatedEffort: '10 minutes',
|
||||
exceptionAllowed: true,
|
||||
},
|
||||
};
|
||||
|
||||
const licensePassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-lic-001',
|
||||
gateType: 'license',
|
||||
name: 'License Compliance',
|
||||
status: 'passed',
|
||||
message: 'All licenses approved. 45 MIT, 12 Apache-2.0, 3 BSD-3-Clause.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: false,
|
||||
};
|
||||
|
||||
const signaturePassingGate: PolicyGateResult = {
|
||||
gateId: 'gate-sig-001',
|
||||
gateType: 'signature',
|
||||
name: 'Signature Verification',
|
||||
status: 'passed',
|
||||
message: 'Image signature verified against tenant keyring.',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
blockingPublish: true,
|
||||
};
|
||||
|
||||
const signatureFailingGate: PolicyGateResult = {
|
||||
gateId: 'gate-sig-002',
|
||||
gateType: 'signature',
|
||||
name: 'Signature Verification',
|
||||
status: 'failed',
|
||||
message: 'No valid signature found. Image must be signed before release.',
|
||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||
blockingPublish: true,
|
||||
remediation: {
|
||||
gateType: 'signature',
|
||||
severity: 'critical',
|
||||
summary: 'The image is not signed or the signature cannot be verified.',
|
||||
steps: [
|
||||
{
|
||||
action: 'sign-artifact',
|
||||
title: 'Sign the image',
|
||||
description: 'Sign the image using your tenant signing key.',
|
||||
command: 'cosign sign --key cosign.key myregistry/myimage:v1.2.3',
|
||||
automated: true,
|
||||
},
|
||||
],
|
||||
estimatedEffort: '2 minutes',
|
||||
exceptionAllowed: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Artifacts with policy evaluations
|
||||
const passingArtifact: ReleaseArtifact = {
|
||||
artifactId: 'art-001',
|
||||
name: 'api-service',
|
||||
tag: 'v1.2.3',
|
||||
digest: 'sha256:abc123def456789012345678901234567890abcdef',
|
||||
size: 245_000_000,
|
||||
createdAt: '2025-11-27T08:00:00Z',
|
||||
registry: 'registry.stellaops.io/prod',
|
||||
policyEvaluation: {
|
||||
evaluationId: 'eval-001',
|
||||
artifactDigest: 'sha256:abc123def456789012345678901234567890abcdef',
|
||||
evaluatedAt: '2025-11-27T10:15:00Z',
|
||||
overallStatus: 'passed',
|
||||
gates: [
|
||||
determinismPassingGate,
|
||||
vulnerabilityPassingGate,
|
||||
entropyWarningGate,
|
||||
licensePassingGate,
|
||||
signaturePassingGate,
|
||||
],
|
||||
blockingGates: [],
|
||||
canPublish: true,
|
||||
determinismDetails: {
|
||||
merkleRoot: 'sha256:a1b2c3d4e5f67890abcdef1234567890fedcba0987654321',
|
||||
merkleRootConsistent: true,
|
||||
contentHash: 'sha256:content1234567890abcdef',
|
||||
compositionManifestUri: 'oci://registry.stellaops.io/prod/api-service@sha256:abc123/_composition.json',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const failingArtifact: ReleaseArtifact = {
|
||||
artifactId: 'art-002',
|
||||
name: 'worker-service',
|
||||
tag: 'v1.2.3',
|
||||
digest: 'sha256:def456abc789012345678901234567890fedcba98',
|
||||
size: 312_000_000,
|
||||
createdAt: '2025-11-27T07:45:00Z',
|
||||
registry: 'registry.stellaops.io/prod',
|
||||
policyEvaluation: {
|
||||
evaluationId: 'eval-002',
|
||||
artifactDigest: 'sha256:def456abc789012345678901234567890fedcba98',
|
||||
evaluatedAt: '2025-11-27T09:30:00Z',
|
||||
overallStatus: 'failed',
|
||||
gates: [
|
||||
determinismFailingGate,
|
||||
vulnerabilityPassingGate,
|
||||
licensePassingGate,
|
||||
signatureFailingGate,
|
||||
],
|
||||
blockingGates: ['gate-det-002', 'gate-sig-002'],
|
||||
canPublish: false,
|
||||
determinismDetails: {
|
||||
merkleRoot: 'sha256:f1e2d3c4b5a67890',
|
||||
merkleRootConsistent: false,
|
||||
contentHash: 'sha256:content9876543210',
|
||||
compositionManifestUri: 'oci://registry.stellaops.io/prod/worker-service@sha256:def456/_composition.json',
|
||||
fragmentCount: 8,
|
||||
verifiedFragments: 6,
|
||||
failedFragments: ['sha256:layer3digest...', 'sha256:layer5digest...'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Release fixtures
|
||||
const passingRelease: Release = {
|
||||
releaseId: 'rel-001',
|
||||
name: 'Platform v1.2.3',
|
||||
version: '1.2.3',
|
||||
status: 'pending_approval',
|
||||
createdAt: '2025-11-27T08:30:00Z',
|
||||
createdBy: 'deploy-bot',
|
||||
artifacts: [passingArtifact],
|
||||
targetEnvironment: 'production',
|
||||
notes: 'Feature release with API improvements and bug fixes.',
|
||||
approvals: [
|
||||
{
|
||||
approvalId: 'apr-001',
|
||||
approver: 'security-team',
|
||||
decision: 'approved',
|
||||
comment: 'Security review passed.',
|
||||
decidedAt: '2025-11-27T09:00:00Z',
|
||||
},
|
||||
{
|
||||
approvalId: 'apr-002',
|
||||
approver: 'release-manager',
|
||||
decision: 'pending',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const blockedRelease: Release = {
|
||||
releaseId: 'rel-002',
|
||||
name: 'Platform v1.2.4-rc1',
|
||||
version: '1.2.4-rc1',
|
||||
status: 'blocked',
|
||||
createdAt: '2025-11-27T07:00:00Z',
|
||||
createdBy: 'deploy-bot',
|
||||
artifacts: [failingArtifact],
|
||||
targetEnvironment: 'staging',
|
||||
notes: 'Release candidate blocked due to policy gate failures.',
|
||||
};
|
||||
|
||||
const mixedRelease: Release = {
|
||||
releaseId: 'rel-003',
|
||||
name: 'Platform v1.2.5',
|
||||
version: '1.2.5',
|
||||
status: 'blocked',
|
||||
createdAt: '2025-11-27T06:00:00Z',
|
||||
createdBy: 'ci-pipeline',
|
||||
artifacts: [passingArtifact, failingArtifact],
|
||||
targetEnvironment: 'production',
|
||||
notes: 'Multi-artifact release with mixed policy results.',
|
||||
};
|
||||
|
||||
const mockReleases: readonly Release[] = [passingRelease, blockedRelease, mixedRelease];
|
||||
|
||||
const mockFeatureFlags: DeterminismFeatureFlags = {
|
||||
enabled: true,
|
||||
blockOnFailure: true,
|
||||
warnOnly: false,
|
||||
bypassRoles: ['security-admin', 'release-manager'],
|
||||
requireApprovalForBypass: true,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Mock API Implementation
|
||||
// ============================================================================
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockReleaseApi implements ReleaseApi {
|
||||
getRelease(releaseId: string): Observable<Release> {
|
||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||
if (!release) {
|
||||
throw new Error(`Release not found: ${releaseId}`);
|
||||
}
|
||||
return of(release).pipe(delay(200));
|
||||
}
|
||||
|
||||
listReleases(): Observable<readonly Release[]> {
|
||||
return of(mockReleases).pipe(delay(300));
|
||||
}
|
||||
|
||||
publishRelease(releaseId: string): Observable<Release> {
|
||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||
if (!release) {
|
||||
throw new Error(`Release not found: ${releaseId}`);
|
||||
}
|
||||
// Simulate publish (would update status in real implementation)
|
||||
return of({
|
||||
...release,
|
||||
status: 'published',
|
||||
publishedAt: new Date().toISOString(),
|
||||
} as Release).pipe(delay(500));
|
||||
}
|
||||
|
||||
cancelRelease(releaseId: string): Observable<Release> {
|
||||
const release = mockReleases.find((r) => r.releaseId === releaseId);
|
||||
if (!release) {
|
||||
throw new Error(`Release not found: ${releaseId}`);
|
||||
}
|
||||
return of({
|
||||
...release,
|
||||
status: 'cancelled',
|
||||
} as Release).pipe(delay(300));
|
||||
}
|
||||
|
||||
getFeatureFlags(): Observable<DeterminismFeatureFlags> {
|
||||
return of(mockFeatureFlags).pipe(delay(100));
|
||||
}
|
||||
|
||||
requestBypass(releaseId: string, reason: string): Observable<{ requestId: string }> {
|
||||
return of({ requestId: `bypass-${Date.now()}` }).pipe(delay(400));
|
||||
}
|
||||
}
|
||||
161
src/Web/StellaOps.Web/src/app/core/api/release.models.ts
Normal file
161
src/Web/StellaOps.Web/src/app/core/api/release.models.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Release and Policy Gate models for UI-POLICY-DET-01.
|
||||
* Supports determinism-gated release flows with remediation hints.
|
||||
*/
|
||||
|
||||
// Policy gate evaluation status
|
||||
export type PolicyGateStatus = 'passed' | 'failed' | 'pending' | 'skipped' | 'warning';
|
||||
|
||||
// Types of policy gates
|
||||
export type PolicyGateType =
|
||||
| 'determinism'
|
||||
| 'vulnerability'
|
||||
| 'license'
|
||||
| 'entropy'
|
||||
| 'signature'
|
||||
| 'sbom-completeness'
|
||||
| 'custom';
|
||||
|
||||
// Remediation action types
|
||||
export type RemediationActionType =
|
||||
| 'rebuild'
|
||||
| 'provide-provenance'
|
||||
| 'sign-artifact'
|
||||
| 'update-dependency'
|
||||
| 'request-exception'
|
||||
| 'manual-review';
|
||||
|
||||
/**
|
||||
* A single remediation step with optional automation support.
|
||||
*/
|
||||
export interface RemediationStep {
|
||||
readonly action: RemediationActionType;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly command?: string; // Optional CLI command to run
|
||||
readonly documentationUrl?: string;
|
||||
readonly automated: boolean; // Can be triggered from UI
|
||||
}
|
||||
|
||||
/**
|
||||
* Remediation hints for a failed policy gate.
|
||||
*/
|
||||
export interface RemediationHint {
|
||||
readonly gateType: PolicyGateType;
|
||||
readonly severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
readonly summary: string;
|
||||
readonly steps: readonly RemediationStep[];
|
||||
readonly estimatedEffort?: string; // e.g., "5 minutes", "1 hour"
|
||||
readonly exceptionAllowed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual policy gate evaluation result.
|
||||
*/
|
||||
export interface PolicyGateResult {
|
||||
readonly gateId: string;
|
||||
readonly gateType: PolicyGateType;
|
||||
readonly name: string;
|
||||
readonly status: PolicyGateStatus;
|
||||
readonly message: string;
|
||||
readonly evaluatedAt: string;
|
||||
readonly blockingPublish: boolean;
|
||||
readonly evidence?: {
|
||||
readonly type: string;
|
||||
readonly url?: string;
|
||||
readonly details?: Record<string, unknown>;
|
||||
};
|
||||
readonly remediation?: RemediationHint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determinism-specific gate details.
|
||||
*/
|
||||
export interface DeterminismGateDetails {
|
||||
readonly merkleRoot?: string;
|
||||
readonly merkleRootConsistent: boolean;
|
||||
readonly contentHash?: string;
|
||||
readonly compositionManifestUri?: string;
|
||||
readonly fragmentCount?: number;
|
||||
readonly verifiedFragments?: number;
|
||||
readonly failedFragments?: readonly string[]; // Layer digests that failed
|
||||
}
|
||||
|
||||
/**
|
||||
* Overall policy evaluation for a release artifact.
|
||||
*/
|
||||
export interface PolicyEvaluation {
|
||||
readonly evaluationId: string;
|
||||
readonly artifactDigest: string;
|
||||
readonly evaluatedAt: string;
|
||||
readonly overallStatus: PolicyGateStatus;
|
||||
readonly gates: readonly PolicyGateResult[];
|
||||
readonly blockingGates: readonly string[]; // Gate IDs that block publish
|
||||
readonly canPublish: boolean;
|
||||
readonly determinismDetails?: DeterminismGateDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release artifact with policy evaluation.
|
||||
*/
|
||||
export interface ReleaseArtifact {
|
||||
readonly artifactId: string;
|
||||
readonly name: string;
|
||||
readonly tag: string;
|
||||
readonly digest: string;
|
||||
readonly size: number;
|
||||
readonly createdAt: string;
|
||||
readonly registry: string;
|
||||
readonly policyEvaluation?: PolicyEvaluation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release workflow status.
|
||||
*/
|
||||
export type ReleaseStatus =
|
||||
| 'draft'
|
||||
| 'pending_approval'
|
||||
| 'approved'
|
||||
| 'publishing'
|
||||
| 'published'
|
||||
| 'blocked'
|
||||
| 'cancelled';
|
||||
|
||||
/**
|
||||
* Release with multiple artifacts and policy gates.
|
||||
*/
|
||||
export interface Release {
|
||||
readonly releaseId: string;
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly status: ReleaseStatus;
|
||||
readonly createdAt: string;
|
||||
readonly createdBy: string;
|
||||
readonly artifacts: readonly ReleaseArtifact[];
|
||||
readonly targetEnvironment: string;
|
||||
readonly notes?: string;
|
||||
readonly approvals?: readonly ReleaseApproval[];
|
||||
readonly publishedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release approval record.
|
||||
*/
|
||||
export interface ReleaseApproval {
|
||||
readonly approvalId: string;
|
||||
readonly approver: string;
|
||||
readonly decision: 'approved' | 'rejected' | 'pending';
|
||||
readonly comment?: string;
|
||||
readonly decidedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature flag configuration for determinism blocking.
|
||||
*/
|
||||
export interface DeterminismFeatureFlags {
|
||||
readonly enabled: boolean;
|
||||
readonly blockOnFailure: boolean;
|
||||
readonly warnOnly: boolean;
|
||||
readonly bypassRoles?: readonly string[];
|
||||
readonly requireApprovalForBypass: boolean;
|
||||
}
|
||||
@@ -9,9 +9,94 @@ export interface ScanAttestationStatus {
|
||||
readonly statusMessage?: string;
|
||||
}
|
||||
|
||||
// Determinism models based on docs/modules/scanner/deterministic-sbom-compose.md
|
||||
|
||||
export type DeterminismStatus = 'verified' | 'pending' | 'failed' | 'unknown';
|
||||
|
||||
export interface FragmentAttestation {
|
||||
readonly layerDigest: string;
|
||||
readonly fragmentSha256: string;
|
||||
readonly dsseEnvelopeSha256: string;
|
||||
readonly dsseStatus: 'verified' | 'pending' | 'failed';
|
||||
readonly verifiedAt?: string;
|
||||
}
|
||||
|
||||
export interface CompositionManifest {
|
||||
readonly compositionUri: string;
|
||||
readonly merkleRoot: string;
|
||||
readonly fragmentCount: number;
|
||||
readonly fragments: readonly FragmentAttestation[];
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
export interface DeterminismEvidence {
|
||||
readonly status: DeterminismStatus;
|
||||
readonly merkleRoot?: string;
|
||||
readonly merkleRootConsistent: boolean;
|
||||
readonly compositionManifest?: CompositionManifest;
|
||||
readonly contentHash?: string;
|
||||
readonly verifiedAt?: string;
|
||||
readonly failureReason?: string;
|
||||
readonly stellaProperties?: {
|
||||
readonly 'stellaops:stella.contentHash'?: string;
|
||||
readonly 'stellaops:composition.manifest'?: string;
|
||||
readonly 'stellaops:merkle.root'?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Entropy analysis models based on docs/modules/scanner/entropy.md
|
||||
|
||||
export interface EntropyWindow {
|
||||
readonly offset: number;
|
||||
readonly length: number;
|
||||
readonly entropy: number; // 0-8 bits/byte
|
||||
}
|
||||
|
||||
export interface EntropyFile {
|
||||
readonly path: string;
|
||||
readonly size: number;
|
||||
readonly opaqueBytes: number;
|
||||
readonly opaqueRatio: number; // 0-1
|
||||
readonly flags: readonly string[]; // e.g., 'stripped', 'section:.UPX0', 'no-symbols', 'packed'
|
||||
readonly windows: readonly EntropyWindow[];
|
||||
}
|
||||
|
||||
export interface EntropyLayerSummary {
|
||||
readonly digest: string;
|
||||
readonly opaqueBytes: number;
|
||||
readonly totalBytes: number;
|
||||
readonly opaqueRatio: number; // 0-1
|
||||
readonly indicators: readonly string[]; // e.g., 'packed', 'no-symbols'
|
||||
}
|
||||
|
||||
export interface EntropyReport {
|
||||
readonly schema: string;
|
||||
readonly generatedAt: string;
|
||||
readonly imageDigest: string;
|
||||
readonly layerDigest?: string;
|
||||
readonly files: readonly EntropyFile[];
|
||||
}
|
||||
|
||||
export interface EntropyLayerSummaryReport {
|
||||
readonly schema: string;
|
||||
readonly generatedAt: string;
|
||||
readonly imageDigest: string;
|
||||
readonly layers: readonly EntropyLayerSummary[];
|
||||
readonly imageOpaqueRatio: number; // 0-1
|
||||
readonly entropyPenalty: number; // 0-0.3
|
||||
}
|
||||
|
||||
export interface EntropyEvidence {
|
||||
readonly report?: EntropyReport;
|
||||
readonly layerSummary?: EntropyLayerSummaryReport;
|
||||
readonly downloadUrl?: string; // URL to entropy.report.json
|
||||
}
|
||||
|
||||
export interface ScanDetail {
|
||||
readonly scanId: string;
|
||||
readonly imageDigest: string;
|
||||
readonly completedAt: string;
|
||||
readonly attestation?: ScanAttestationStatus;
|
||||
readonly determinism?: DeterminismEvidence;
|
||||
readonly entropy?: EntropyEvidence;
|
||||
}
|
||||
|
||||
125
src/Web/StellaOps.Web/src/app/core/auth/auth.service.ts
Normal file
125
src/Web/StellaOps.Web/src/app/core/auth/auth.service.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Injectable, InjectionToken, signal, computed } from '@angular/core';
|
||||
import {
|
||||
StellaOpsScopes,
|
||||
StellaOpsScope,
|
||||
ScopeGroups,
|
||||
hasScope,
|
||||
hasAllScopes,
|
||||
hasAnyScope,
|
||||
} from './scopes';
|
||||
|
||||
/**
|
||||
* User info from authentication.
|
||||
*/
|
||||
export interface AuthUser {
|
||||
readonly id: string;
|
||||
readonly email: string;
|
||||
readonly name: string;
|
||||
readonly tenantId: string;
|
||||
readonly tenantName: string;
|
||||
readonly roles: readonly string[];
|
||||
readonly scopes: readonly StellaOpsScope[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection token for Auth service.
|
||||
*/
|
||||
export const AUTH_SERVICE = new InjectionToken<AuthService>('AUTH_SERVICE');
|
||||
|
||||
/**
|
||||
* Auth service interface.
|
||||
*/
|
||||
export interface AuthService {
|
||||
readonly isAuthenticated: ReturnType<typeof signal<boolean>>;
|
||||
readonly user: ReturnType<typeof signal<AuthUser | null>>;
|
||||
readonly scopes: ReturnType<typeof computed<readonly StellaOpsScope[]>>;
|
||||
|
||||
hasScope(scope: StellaOpsScope): boolean;
|
||||
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean;
|
||||
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean;
|
||||
canViewGraph(): boolean;
|
||||
canEditGraph(): boolean;
|
||||
canExportGraph(): boolean;
|
||||
canSimulate(): boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Auth Service
|
||||
// ============================================================================
|
||||
|
||||
const MOCK_USER: AuthUser = {
|
||||
id: 'user-001',
|
||||
email: 'developer@example.com',
|
||||
name: 'Developer User',
|
||||
tenantId: 'tenant-001',
|
||||
tenantName: 'Acme Corp',
|
||||
roles: ['developer', 'security-analyst'],
|
||||
scopes: [
|
||||
// Graph permissions
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.GRAPH_WRITE,
|
||||
StellaOpsScopes.GRAPH_SIMULATE,
|
||||
StellaOpsScopes.GRAPH_EXPORT,
|
||||
// SBOM permissions
|
||||
StellaOpsScopes.SBOM_READ,
|
||||
// Policy permissions
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_EVALUATE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
// Scanner permissions
|
||||
StellaOpsScopes.SCANNER_READ,
|
||||
// Exception permissions
|
||||
StellaOpsScopes.EXCEPTION_READ,
|
||||
StellaOpsScopes.EXCEPTION_WRITE,
|
||||
// Release permissions
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
// AOC permissions
|
||||
StellaOpsScopes.AOC_READ,
|
||||
],
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockAuthService implements AuthService {
|
||||
readonly isAuthenticated = signal(true);
|
||||
readonly user = signal<AuthUser | null>(MOCK_USER);
|
||||
|
||||
readonly scopes = computed(() => {
|
||||
const u = this.user();
|
||||
return u?.scopes ?? [];
|
||||
});
|
||||
|
||||
hasScope(scope: StellaOpsScope): boolean {
|
||||
return hasScope(this.scopes(), scope);
|
||||
}
|
||||
|
||||
hasAllScopes(scopes: readonly StellaOpsScope[]): boolean {
|
||||
return hasAllScopes(this.scopes(), scopes);
|
||||
}
|
||||
|
||||
hasAnyScope(scopes: readonly StellaOpsScope[]): boolean {
|
||||
return hasAnyScope(this.scopes(), scopes);
|
||||
}
|
||||
|
||||
canViewGraph(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.GRAPH_READ);
|
||||
}
|
||||
|
||||
canEditGraph(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.GRAPH_WRITE);
|
||||
}
|
||||
|
||||
canExportGraph(): boolean {
|
||||
return this.hasScope(StellaOpsScopes.GRAPH_EXPORT);
|
||||
}
|
||||
|
||||
canSimulate(): boolean {
|
||||
return this.hasAnyScope([
|
||||
StellaOpsScopes.GRAPH_SIMULATE,
|
||||
StellaOpsScopes.POLICY_SIMULATE,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export scopes for convenience
|
||||
export { StellaOpsScopes, ScopeGroups } from './scopes';
|
||||
export type { StellaOpsScope } from './scopes';
|
||||
16
src/Web/StellaOps.Web/src/app/core/auth/index.ts
Normal file
16
src/Web/StellaOps.Web/src/app/core/auth/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
StellaOpsScopes,
|
||||
StellaOpsScope,
|
||||
ScopeGroups,
|
||||
ScopeLabels,
|
||||
hasScope,
|
||||
hasAllScopes,
|
||||
hasAnyScope,
|
||||
} from './scopes';
|
||||
|
||||
export {
|
||||
AuthUser,
|
||||
AuthService,
|
||||
AUTH_SERVICE,
|
||||
MockAuthService,
|
||||
} from './auth.service';
|
||||
166
src/Web/StellaOps.Web/src/app/core/auth/scopes.ts
Normal file
166
src/Web/StellaOps.Web/src/app/core/auth/scopes.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* StellaOps OAuth2 Scopes - Stub implementation for UI-GRAPH-21-001.
|
||||
*
|
||||
* This is a stub implementation to unblock Graph Explorer development.
|
||||
* Will be replaced by generated SDK exports once SPRINT_0208_0001_0001_sdk delivers.
|
||||
*
|
||||
* @see docs/modules/platform/architecture-overview.md
|
||||
*/
|
||||
|
||||
/**
|
||||
* All available StellaOps OAuth2 scopes.
|
||||
*/
|
||||
export const StellaOpsScopes = {
|
||||
// Graph scopes
|
||||
GRAPH_READ: 'graph:read',
|
||||
GRAPH_WRITE: 'graph:write',
|
||||
GRAPH_ADMIN: 'graph:admin',
|
||||
GRAPH_EXPORT: 'graph:export',
|
||||
GRAPH_SIMULATE: 'graph:simulate',
|
||||
|
||||
// SBOM scopes
|
||||
SBOM_READ: 'sbom:read',
|
||||
SBOM_WRITE: 'sbom:write',
|
||||
SBOM_ATTEST: 'sbom:attest',
|
||||
|
||||
// Scanner scopes
|
||||
SCANNER_READ: 'scanner:read',
|
||||
SCANNER_WRITE: 'scanner:write',
|
||||
SCANNER_SCAN: 'scanner:scan',
|
||||
|
||||
// Policy scopes
|
||||
POLICY_READ: 'policy:read',
|
||||
POLICY_WRITE: 'policy:write',
|
||||
POLICY_EVALUATE: 'policy:evaluate',
|
||||
POLICY_SIMULATE: 'policy:simulate',
|
||||
|
||||
// Exception scopes
|
||||
EXCEPTION_READ: 'exception:read',
|
||||
EXCEPTION_WRITE: 'exception:write',
|
||||
EXCEPTION_APPROVE: 'exception:approve',
|
||||
|
||||
// Release scopes
|
||||
RELEASE_READ: 'release:read',
|
||||
RELEASE_WRITE: 'release:write',
|
||||
RELEASE_PUBLISH: 'release:publish',
|
||||
RELEASE_BYPASS: 'release:bypass',
|
||||
|
||||
// AOC scopes
|
||||
AOC_READ: 'aoc:read',
|
||||
AOC_VERIFY: 'aoc:verify',
|
||||
|
||||
// Admin scopes
|
||||
ADMIN: 'admin',
|
||||
TENANT_ADMIN: 'tenant:admin',
|
||||
} as const;
|
||||
|
||||
export type StellaOpsScope = (typeof StellaOpsScopes)[keyof typeof StellaOpsScopes];
|
||||
|
||||
/**
|
||||
* Scope groupings for common use cases.
|
||||
*/
|
||||
export const ScopeGroups = {
|
||||
GRAPH_VIEWER: [
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.SBOM_READ,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
] as const,
|
||||
|
||||
GRAPH_EDITOR: [
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.GRAPH_WRITE,
|
||||
StellaOpsScopes.SBOM_READ,
|
||||
StellaOpsScopes.SBOM_WRITE,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_EVALUATE,
|
||||
] as const,
|
||||
|
||||
GRAPH_ADMIN: [
|
||||
StellaOpsScopes.GRAPH_READ,
|
||||
StellaOpsScopes.GRAPH_WRITE,
|
||||
StellaOpsScopes.GRAPH_ADMIN,
|
||||
StellaOpsScopes.GRAPH_EXPORT,
|
||||
StellaOpsScopes.GRAPH_SIMULATE,
|
||||
] as const,
|
||||
|
||||
RELEASE_MANAGER: [
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.RELEASE_WRITE,
|
||||
StellaOpsScopes.RELEASE_PUBLISH,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_EVALUATE,
|
||||
] as const,
|
||||
|
||||
SECURITY_ADMIN: [
|
||||
StellaOpsScopes.EXCEPTION_READ,
|
||||
StellaOpsScopes.EXCEPTION_WRITE,
|
||||
StellaOpsScopes.EXCEPTION_APPROVE,
|
||||
StellaOpsScopes.RELEASE_BYPASS,
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
StellaOpsScopes.POLICY_WRITE,
|
||||
] as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Human-readable labels for scopes.
|
||||
*/
|
||||
export const ScopeLabels: Record<StellaOpsScope, string> = {
|
||||
'graph:read': 'View Graph',
|
||||
'graph:write': 'Edit Graph',
|
||||
'graph:admin': 'Administer Graph',
|
||||
'graph:export': 'Export Graph Data',
|
||||
'graph:simulate': 'Run Graph Simulations',
|
||||
'sbom:read': 'View SBOMs',
|
||||
'sbom:write': 'Create/Edit SBOMs',
|
||||
'sbom:attest': 'Attest SBOMs',
|
||||
'scanner:read': 'View Scan Results',
|
||||
'scanner:write': 'Configure Scanner',
|
||||
'scanner:scan': 'Trigger Scans',
|
||||
'policy:read': 'View Policies',
|
||||
'policy:write': 'Edit Policies',
|
||||
'policy:evaluate': 'Evaluate Policies',
|
||||
'policy:simulate': 'Simulate Policy Changes',
|
||||
'exception:read': 'View Exceptions',
|
||||
'exception:write': 'Create Exceptions',
|
||||
'exception:approve': 'Approve Exceptions',
|
||||
'release:read': 'View Releases',
|
||||
'release:write': 'Create Releases',
|
||||
'release:publish': 'Publish Releases',
|
||||
'release:bypass': 'Bypass Release Gates',
|
||||
'aoc:read': 'View AOC Status',
|
||||
'aoc:verify': 'Trigger AOC Verification',
|
||||
'admin': 'System Administrator',
|
||||
'tenant:admin': 'Tenant Administrator',
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a set of scopes includes a required scope.
|
||||
*/
|
||||
export function hasScope(
|
||||
userScopes: readonly string[],
|
||||
requiredScope: StellaOpsScope
|
||||
): boolean {
|
||||
return userScopes.includes(requiredScope) || userScopes.includes(StellaOpsScopes.ADMIN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a set of scopes includes all required scopes.
|
||||
*/
|
||||
export function hasAllScopes(
|
||||
userScopes: readonly string[],
|
||||
requiredScopes: readonly StellaOpsScope[]
|
||||
): boolean {
|
||||
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
|
||||
return requiredScopes.every((scope) => userScopes.includes(scope));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a set of scopes includes any of the required scopes.
|
||||
*/
|
||||
export function hasAnyScope(
|
||||
userScopes: readonly string[],
|
||||
requiredScopes: readonly StellaOpsScope[]
|
||||
): boolean {
|
||||
if (userScopes.includes(StellaOpsScopes.ADMIN)) return true;
|
||||
return requiredScopes.some((scope) => userScopes.includes(scope));
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
import { EvidenceData } from '../../core/api/evidence.models';
|
||||
import { EVIDENCE_API, MockEvidenceApiService } from '../../core/api/evidence.client';
|
||||
import { EvidencePanelComponent } from './evidence-panel.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-evidence-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, EvidencePanelComponent],
|
||||
providers: [
|
||||
{ provide: EVIDENCE_API, useClass: MockEvidenceApiService },
|
||||
],
|
||||
template: `
|
||||
<div class="evidence-page">
|
||||
@if (loading()) {
|
||||
<div class="evidence-page__loading">
|
||||
<div class="spinner" aria-label="Loading evidence"></div>
|
||||
<p>Loading evidence for {{ advisoryId() }}...</p>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="evidence-page__error" role="alert">
|
||||
<h2>Error Loading Evidence</h2>
|
||||
<p>{{ error() }}</p>
|
||||
<button type="button" (click)="reload()">Retry</button>
|
||||
</div>
|
||||
} @else if (evidenceData()) {
|
||||
<app-evidence-panel
|
||||
[advisoryId]="advisoryId()"
|
||||
[evidenceData]="evidenceData()"
|
||||
(close)="onClose()"
|
||||
(downloadDocument)="onDownload($event)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="evidence-page__empty">
|
||||
<h2>No Advisory ID</h2>
|
||||
<p>Please provide an advisory ID to view evidence.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.evidence-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.evidence-page__loading,
|
||||
.evidence-page__error,
|
||||
.evidence-page__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.evidence-page__loading .spinner {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.evidence-page__loading p {
|
||||
margin-top: 1rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.evidence-page__error {
|
||||
border: 1px solid #fca5a5;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.evidence-page__error h2 {
|
||||
color: #dc2626;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.evidence-page__error p {
|
||||
color: #991b1b;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.evidence-page__error button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #dc2626;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #dc2626;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.evidence-page__error button:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.evidence-page__empty h2 {
|
||||
color: #374151;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.evidence-page__empty p {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EvidencePageComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly evidenceApi = inject(EVIDENCE_API);
|
||||
|
||||
readonly advisoryId = signal<string>('');
|
||||
readonly evidenceData = signal<EvidenceData | null>(null);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
// React to route param changes
|
||||
effect(() => {
|
||||
const params = this.route.snapshot.paramMap;
|
||||
const id = params.get('advisoryId');
|
||||
if (id) {
|
||||
this.advisoryId.set(id);
|
||||
this.loadEvidence(id);
|
||||
}
|
||||
}, { allowSignalWrites: true });
|
||||
}
|
||||
|
||||
private loadEvidence(advisoryId: string): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.evidenceApi.getEvidenceForAdvisory(advisoryId).subscribe({
|
||||
next: (data) => {
|
||||
this.evidenceData.set(data);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message ?? 'Failed to load evidence');
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
const id = this.advisoryId();
|
||||
if (id) {
|
||||
this.loadEvidence(id);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.router.navigate(['/vulnerabilities']);
|
||||
}
|
||||
|
||||
onDownload(event: { type: 'observation' | 'linkset'; id: string }): void {
|
||||
this.evidenceApi.downloadRawDocument(event.type, event.id).subscribe({
|
||||
next: (blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${event.type}-${event.id}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Download failed:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
<div class="evidence-panel" role="dialog" aria-labelledby="evidence-panel-title">
|
||||
<!-- Header -->
|
||||
<header class="evidence-panel__header">
|
||||
<div class="evidence-panel__title-row">
|
||||
<h2 id="evidence-panel-title" class="evidence-panel__title">
|
||||
Evidence: {{ advisoryId() }}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="evidence-panel__close"
|
||||
(click)="onClose()"
|
||||
aria-label="Close evidence panel"
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Policy Decision Summary -->
|
||||
@if (policyEvidence(); as policy) {
|
||||
<div class="evidence-panel__decision-summary" [class]="policyDecisionClass()">
|
||||
<span class="decision-badge">{{ policyDecisionLabel() }}</span>
|
||||
<span class="decision-policy">{{ policy.policyName }}</span>
|
||||
@if (policy.reason) {
|
||||
<span class="decision-reason">{{ policy.reason }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Conflict Banner -->
|
||||
@if (hasConflicts()) {
|
||||
<div
|
||||
class="evidence-panel__conflict-banner"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="conflict-icon" aria-hidden="true">!</span>
|
||||
<span class="conflict-text">
|
||||
{{ conflictCount() }} conflict(s) detected between sources
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="conflict-toggle"
|
||||
(click)="toggleConflictDetails()"
|
||||
[attr.aria-expanded]="showConflictDetails()"
|
||||
>
|
||||
{{ showConflictDetails() ? 'Hide details' : 'Show details' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (showConflictDetails() && linkset(); as ls) {
|
||||
<div class="evidence-panel__conflict-details">
|
||||
@for (conflict of ls.conflicts; track trackByConflictField($index, conflict)) {
|
||||
<div class="conflict-item">
|
||||
<span class="conflict-field">{{ conflict.field }}</span>
|
||||
<span class="conflict-reason">{{ conflict.reason }}</span>
|
||||
@if (conflict.values && conflict.values.length > 0) {
|
||||
<ul class="conflict-values">
|
||||
@for (value of conflict.values; track value) {
|
||||
<li>{{ value }}</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</header>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<nav class="evidence-panel__tabs" role="tablist" aria-label="Evidence sections">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="evidence-panel__tab"
|
||||
[class.active]="isActiveTab('observations')"
|
||||
[attr.aria-selected]="isActiveTab('observations')"
|
||||
(click)="setActiveTab('observations')"
|
||||
>
|
||||
Observations ({{ observations().length }})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="evidence-panel__tab"
|
||||
[class.active]="isActiveTab('linkset')"
|
||||
[attr.aria-selected]="isActiveTab('linkset')"
|
||||
(click)="setActiveTab('linkset')"
|
||||
[disabled]="!linkset()"
|
||||
>
|
||||
Linkset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="evidence-panel__tab"
|
||||
[class.active]="isActiveTab('policy')"
|
||||
[attr.aria-selected]="isActiveTab('policy')"
|
||||
(click)="setActiveTab('policy')"
|
||||
[disabled]="!policyEvidence()"
|
||||
>
|
||||
Policy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="evidence-panel__tab"
|
||||
[class.active]="isActiveTab('aoc')"
|
||||
[attr.aria-selected]="isActiveTab('aoc')"
|
||||
(click)="setActiveTab('aoc')"
|
||||
[disabled]="aocChain().length === 0"
|
||||
>
|
||||
AOC Chain
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="evidence-panel__content">
|
||||
<!-- Observations Tab -->
|
||||
@if (isActiveTab('observations')) {
|
||||
<section
|
||||
class="evidence-panel__section"
|
||||
role="tabpanel"
|
||||
aria-label="Observations"
|
||||
>
|
||||
<!-- View Toggle -->
|
||||
<div class="evidence-panel__view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="view-btn"
|
||||
[class.active]="observationView() === 'side-by-side'"
|
||||
(click)="setObservationView('side-by-side')"
|
||||
aria-label="Side by side view"
|
||||
>
|
||||
Side by Side
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="view-btn"
|
||||
[class.active]="observationView() === 'stacked'"
|
||||
(click)="setObservationView('stacked')"
|
||||
aria-label="Stacked view"
|
||||
>
|
||||
Stacked
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Observations Grid -->
|
||||
<div
|
||||
class="observations-grid"
|
||||
[class.side-by-side]="observationView() === 'side-by-side'"
|
||||
[class.stacked]="observationView() === 'stacked'"
|
||||
>
|
||||
@for (obs of observations(); track trackByObservationId($index, obs)) {
|
||||
<article
|
||||
class="observation-card"
|
||||
[class.expanded]="isObservationExpanded(obs.observationId)"
|
||||
>
|
||||
<!-- Observation Header -->
|
||||
<header class="observation-card__header">
|
||||
<div class="observation-card__source">
|
||||
<span
|
||||
class="source-icon"
|
||||
[attr.aria-label]="getSourceInfo(obs.source).name"
|
||||
>
|
||||
{{ getSourceInfo(obs.source).name.charAt(0) }}
|
||||
</span>
|
||||
<span class="source-name">{{ getSourceInfo(obs.source).name }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="observation-card__download"
|
||||
(click)="onDownloadObservation(obs.observationId)"
|
||||
aria-label="Download raw observation document"
|
||||
title="Download raw JSON"
|
||||
>
|
||||
<span aria-hidden="true">↓</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Observation Body -->
|
||||
<div class="observation-card__body">
|
||||
<h4 class="observation-card__title">
|
||||
{{ obs.title ?? obs.advisoryId }}
|
||||
</h4>
|
||||
|
||||
@if (obs.summary) {
|
||||
<p class="observation-card__summary">{{ obs.summary }}</p>
|
||||
}
|
||||
|
||||
<!-- Severities -->
|
||||
@if (obs.severities.length > 0) {
|
||||
<div class="observation-card__severities">
|
||||
@for (sev of obs.severities; track sev.system) {
|
||||
<span
|
||||
class="severity-badge"
|
||||
[class]="getSeverityClass(sev.score)"
|
||||
>
|
||||
{{ sev.system.toUpperCase() }}: {{ sev.score }}
|
||||
({{ getSeverityLabel(sev.score) }})
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Affected Packages -->
|
||||
@if (obs.affected.length > 0) {
|
||||
<div class="observation-card__affected">
|
||||
<strong>Affected:</strong>
|
||||
<ul>
|
||||
@for (pkg of obs.affected; track pkg.purl) {
|
||||
<li>
|
||||
<code class="purl">{{ pkg.purl }}</code>
|
||||
@if (pkg.ecosystem) {
|
||||
<span class="ecosystem">({{ pkg.ecosystem }})</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Expandable Details -->
|
||||
<button
|
||||
type="button"
|
||||
class="observation-card__expand"
|
||||
(click)="toggleObservationExpanded(obs.observationId)"
|
||||
[attr.aria-expanded]="isObservationExpanded(obs.observationId)"
|
||||
>
|
||||
{{ isObservationExpanded(obs.observationId) ? 'Show less' : 'Show more' }}
|
||||
</button>
|
||||
|
||||
@if (isObservationExpanded(obs.observationId)) {
|
||||
<div class="observation-card__details">
|
||||
<!-- Weaknesses -->
|
||||
@if (obs.weaknesses && obs.weaknesses.length > 0) {
|
||||
<div class="detail-section">
|
||||
<strong>Weaknesses:</strong>
|
||||
<span class="weakness-list">
|
||||
{{ obs.weaknesses.join(', ') }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- References -->
|
||||
@if (obs.references && obs.references.length > 0) {
|
||||
<div class="detail-section">
|
||||
<strong>References:</strong>
|
||||
<ul class="reference-list">
|
||||
@for (ref of obs.references; track ref) {
|
||||
<li>
|
||||
<a
|
||||
[href]="ref"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ ref }}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Provenance -->
|
||||
<div class="detail-section">
|
||||
<strong>Provenance:</strong>
|
||||
<dl class="provenance-list">
|
||||
<dt>Source Artifact SHA:</dt>
|
||||
<dd>
|
||||
<code>{{ truncateHash(obs.provenance.sourceArtifactSha, 20) }}</code>
|
||||
</dd>
|
||||
<dt>Fetched At:</dt>
|
||||
<dd>{{ formatDate(obs.provenance.fetchedAt) }}</dd>
|
||||
@if (obs.provenance.ingestJobId) {
|
||||
<dt>Ingest Job:</dt>
|
||||
<dd>
|
||||
<code>{{ obs.provenance.ingestJobId }}</code>
|
||||
</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Timestamps -->
|
||||
<div class="detail-section">
|
||||
<strong>Timestamps:</strong>
|
||||
<dl class="timestamp-list">
|
||||
<dt>Published:</dt>
|
||||
<dd>{{ formatDate(obs.published) }}</dd>
|
||||
<dt>Modified:</dt>
|
||||
<dd>{{ formatDate(obs.modified) }}</dd>
|
||||
<dt>Ingested:</dt>
|
||||
<dd>{{ formatDate(obs.ingestedAt) }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Linkset Tab -->
|
||||
@if (isActiveTab('linkset') && linkset(); as ls) {
|
||||
<section
|
||||
class="evidence-panel__section"
|
||||
role="tabpanel"
|
||||
aria-label="Linkset"
|
||||
>
|
||||
<div class="linkset-panel">
|
||||
<header class="linkset-panel__header">
|
||||
<h3>Linkset: {{ ls.linksetId }}</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="linkset-panel__download"
|
||||
(click)="onDownloadLinkset(ls.linksetId)"
|
||||
aria-label="Download raw linkset document"
|
||||
>
|
||||
Download Raw JSON
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="linkset-panel__meta">
|
||||
<dl>
|
||||
<dt>Advisory ID:</dt>
|
||||
<dd>{{ ls.advisoryId }}</dd>
|
||||
<dt>Source:</dt>
|
||||
<dd>{{ ls.source }}</dd>
|
||||
<dt>Confidence:</dt>
|
||||
<dd>
|
||||
@if (ls.confidence !== undefined) {
|
||||
<span
|
||||
class="confidence-badge"
|
||||
[class.high]="ls.confidence >= 0.9"
|
||||
[class.medium]="ls.confidence >= 0.7 && ls.confidence < 0.9"
|
||||
[class.low]="ls.confidence < 0.7"
|
||||
>
|
||||
{{ (ls.confidence * 100).toFixed(0) }}%
|
||||
</span>
|
||||
} @else {
|
||||
N/A
|
||||
}
|
||||
</dd>
|
||||
<dt>Created:</dt>
|
||||
<dd>{{ formatDate(ls.createdAt) }}</dd>
|
||||
@if (ls.builtByJobId) {
|
||||
<dt>Built By Job:</dt>
|
||||
<dd>
|
||||
<code>{{ ls.builtByJobId }}</code>
|
||||
</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Linked Observations -->
|
||||
<div class="linkset-panel__observations">
|
||||
<h4>Linked Observations ({{ ls.observations.length }})</h4>
|
||||
<ul class="observation-id-list">
|
||||
@for (obsId of ls.observations; track obsId) {
|
||||
<li>
|
||||
<code>{{ obsId }}</code>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Normalized Data -->
|
||||
@if (ls.normalized) {
|
||||
<div class="linkset-panel__normalized">
|
||||
<h4>Normalized Data</h4>
|
||||
<dl>
|
||||
@if (ls.normalized.purls && ls.normalized.purls.length > 0) {
|
||||
<dt>PURLs:</dt>
|
||||
<dd>
|
||||
<ul>
|
||||
@for (purl of ls.normalized.purls; track purl) {
|
||||
<li>
|
||||
<code>{{ purl }}</code>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</dd>
|
||||
}
|
||||
@if (ls.normalized.severities && ls.normalized.severities.length > 0) {
|
||||
<dt>Severities:</dt>
|
||||
<dd>
|
||||
@for (sev of ls.normalized.severities; track sev.system) {
|
||||
<span
|
||||
class="severity-badge"
|
||||
[class]="getSeverityClass(sev.score)"
|
||||
>
|
||||
{{ sev.system.toUpperCase() }}: {{ sev.score }}
|
||||
</span>
|
||||
}
|
||||
</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Provenance -->
|
||||
@if (ls.provenance) {
|
||||
<div class="linkset-panel__provenance">
|
||||
<h4>Provenance</h4>
|
||||
<dl>
|
||||
@if (ls.provenance.toolVersion) {
|
||||
<dt>Tool Version:</dt>
|
||||
<dd>
|
||||
<code>{{ ls.provenance.toolVersion }}</code>
|
||||
</dd>
|
||||
}
|
||||
@if (ls.provenance.policyHash) {
|
||||
<dt>Policy Hash:</dt>
|
||||
<dd>
|
||||
<code>{{ truncateHash(ls.provenance.policyHash) }}</code>
|
||||
</dd>
|
||||
}
|
||||
@if (ls.provenance.observationHashes.length > 0) {
|
||||
<dt>Observation Hashes:</dt>
|
||||
<dd>
|
||||
<ul>
|
||||
@for (hash of ls.provenance.observationHashes; track hash) {
|
||||
<li>
|
||||
<code>{{ truncateHash(hash, 16) }}</code>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Policy Tab -->
|
||||
@if (isActiveTab('policy') && policyEvidence(); as policy) {
|
||||
<section
|
||||
class="evidence-panel__section"
|
||||
role="tabpanel"
|
||||
aria-label="Policy"
|
||||
>
|
||||
<div class="policy-panel">
|
||||
<header class="policy-panel__header">
|
||||
<h3>{{ policy.policyName }}</h3>
|
||||
<span
|
||||
class="policy-decision-badge"
|
||||
[class]="policyDecisionClass()"
|
||||
>
|
||||
{{ policyDecisionLabel() }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="policy-panel__meta">
|
||||
<dl>
|
||||
<dt>Policy ID:</dt>
|
||||
<dd>
|
||||
<code>{{ policy.policyId }}</code>
|
||||
</dd>
|
||||
<dt>Decided At:</dt>
|
||||
<dd>{{ formatDate(policy.decidedAt) }}</dd>
|
||||
@if (policy.reason) {
|
||||
<dt>Reason:</dt>
|
||||
<dd>{{ policy.reason }}</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Policy Rules -->
|
||||
<div class="policy-panel__rules">
|
||||
<h4>Rule Results ({{ policy.rules.length }})</h4>
|
||||
<ul class="rule-list">
|
||||
@for (rule of policy.rules; track trackByRuleId($index, rule)) {
|
||||
<li class="rule-item" [class]="getRuleClass(rule.passed)">
|
||||
<span class="rule-icon" aria-hidden="true">
|
||||
{{ rule.passed ? '✓' : '✗' }}
|
||||
</span>
|
||||
<div class="rule-content">
|
||||
<span class="rule-name">{{ rule.ruleName }}</span>
|
||||
<code class="rule-id">{{ rule.ruleId }}</code>
|
||||
@if (rule.reason) {
|
||||
<p class="rule-reason">{{ rule.reason }}</p>
|
||||
}
|
||||
@if (rule.matchedItems && rule.matchedItems.length > 0) {
|
||||
<div class="rule-matched">
|
||||
<strong>Matched:</strong>
|
||||
<span>{{ rule.matchedItems.join(', ') }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Linked Linksets -->
|
||||
@if (policy.linksetIds.length > 0) {
|
||||
<div class="policy-panel__linksets">
|
||||
<h4>Linked Linksets</h4>
|
||||
<ul>
|
||||
@for (lsId of policy.linksetIds; track lsId) {
|
||||
<li>
|
||||
<code>{{ lsId }}</code>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- AOC Chain Tab -->
|
||||
@if (isActiveTab('aoc')) {
|
||||
<section
|
||||
class="evidence-panel__section"
|
||||
role="tabpanel"
|
||||
aria-label="AOC Chain"
|
||||
>
|
||||
<div class="aoc-panel">
|
||||
<header class="aoc-panel__header">
|
||||
<h3>Attestation of Compliance Chain</h3>
|
||||
<p class="aoc-panel__description">
|
||||
Cryptographic chain of evidence from raw observations through policy decision.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="aoc-chain">
|
||||
@for (entry of aocChain(); track trackByAocId($index, entry); let i = $index; let last = $last) {
|
||||
<div class="aoc-entry" [class]="getAocTypeClass(entry.type)">
|
||||
<div class="aoc-entry__connector">
|
||||
<span class="aoc-entry__number">{{ i + 1 }}</span>
|
||||
@if (!last) {
|
||||
<span class="aoc-entry__line" aria-hidden="true"></span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="aoc-entry__content">
|
||||
<header class="aoc-entry__header">
|
||||
<span class="aoc-type-badge">{{ getAocTypeLabel(entry.type) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="aoc-entry__toggle"
|
||||
(click)="toggleAocEntry(entry.attestationId)"
|
||||
[attr.aria-expanded]="isAocEntryExpanded(entry.attestationId)"
|
||||
>
|
||||
{{ isAocEntryExpanded(entry.attestationId) ? 'Collapse' : 'Expand' }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="aoc-entry__summary">
|
||||
<code class="aoc-hash">{{ truncateHash(entry.hash, 16) }}</code>
|
||||
<span class="aoc-timestamp">{{ formatDate(entry.timestamp) }}</span>
|
||||
</div>
|
||||
|
||||
@if (isAocEntryExpanded(entry.attestationId)) {
|
||||
<div class="aoc-entry__details">
|
||||
<dl>
|
||||
<dt>Attestation ID:</dt>
|
||||
<dd>
|
||||
<code>{{ entry.attestationId }}</code>
|
||||
</dd>
|
||||
<dt>Full Hash:</dt>
|
||||
<dd>
|
||||
<code class="full-hash">{{ entry.hash }}</code>
|
||||
</dd>
|
||||
@if (entry.parentHash) {
|
||||
<dt>Parent Hash:</dt>
|
||||
<dd>
|
||||
<code class="full-hash">{{ entry.parentHash }}</code>
|
||||
</dd>
|
||||
}
|
||||
@if (entry.signer) {
|
||||
<dt>Signer:</dt>
|
||||
<dd>{{ entry.signer }}</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,255 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
import {
|
||||
AocChainEntry,
|
||||
EvidenceData,
|
||||
Linkset,
|
||||
LinksetConflict,
|
||||
Observation,
|
||||
PolicyDecision,
|
||||
PolicyEvidence,
|
||||
PolicyRuleResult,
|
||||
SOURCE_INFO,
|
||||
SourceInfo,
|
||||
} from '../../core/api/evidence.models';
|
||||
import { EvidenceApi, EVIDENCE_API } from '../../core/api/evidence.client';
|
||||
|
||||
type TabId = 'observations' | 'linkset' | 'policy' | 'aoc';
|
||||
type ObservationView = 'side-by-side' | 'stacked';
|
||||
|
||||
@Component({
|
||||
selector: 'app-evidence-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './evidence-panel.component.html',
|
||||
styleUrls: ['./evidence-panel.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EvidencePanelComponent {
|
||||
private readonly evidenceApi = inject(EVIDENCE_API);
|
||||
|
||||
// Inputs
|
||||
readonly advisoryId = input.required<string>();
|
||||
readonly evidenceData = input<EvidenceData | null>(null);
|
||||
|
||||
// Outputs
|
||||
readonly close = output<void>();
|
||||
readonly downloadDocument = output<{ type: 'observation' | 'linkset'; id: string }>();
|
||||
|
||||
// UI State
|
||||
readonly activeTab = signal<TabId>('observations');
|
||||
readonly observationView = signal<ObservationView>('side-by-side');
|
||||
readonly expandedObservation = signal<string | null>(null);
|
||||
readonly expandedAocEntry = signal<string | null>(null);
|
||||
readonly showConflictDetails = signal(false);
|
||||
|
||||
// Loading/error state
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
// Computed values
|
||||
readonly observations = computed(() => this.evidenceData()?.observations ?? []);
|
||||
readonly linkset = computed(() => this.evidenceData()?.linkset ?? null);
|
||||
readonly policyEvidence = computed(() => this.evidenceData()?.policyEvidence ?? null);
|
||||
readonly hasConflicts = computed(() => this.evidenceData()?.hasConflicts ?? false);
|
||||
readonly conflictCount = computed(() => this.evidenceData()?.conflictCount ?? 0);
|
||||
|
||||
readonly aocChain = computed(() => {
|
||||
const policy = this.policyEvidence();
|
||||
return policy?.aocChain ?? [];
|
||||
});
|
||||
|
||||
readonly policyDecisionClass = computed(() => {
|
||||
const decision = this.policyEvidence()?.decision;
|
||||
return this.getDecisionClass(decision);
|
||||
});
|
||||
|
||||
readonly policyDecisionLabel = computed(() => {
|
||||
const decision = this.policyEvidence()?.decision;
|
||||
return this.getDecisionLabel(decision);
|
||||
});
|
||||
|
||||
readonly observationSources = computed(() => {
|
||||
const obs = this.observations();
|
||||
return obs.map((o) => this.getSourceInfo(o.source));
|
||||
});
|
||||
|
||||
// Tab methods
|
||||
setActiveTab(tab: TabId): void {
|
||||
this.activeTab.set(tab);
|
||||
}
|
||||
|
||||
isActiveTab(tab: TabId): boolean {
|
||||
return this.activeTab() === tab;
|
||||
}
|
||||
|
||||
// Observation view methods
|
||||
setObservationView(view: ObservationView): void {
|
||||
this.observationView.set(view);
|
||||
}
|
||||
|
||||
toggleObservationExpanded(observationId: string): void {
|
||||
const current = this.expandedObservation();
|
||||
this.expandedObservation.set(current === observationId ? null : observationId);
|
||||
}
|
||||
|
||||
isObservationExpanded(observationId: string): boolean {
|
||||
return this.expandedObservation() === observationId;
|
||||
}
|
||||
|
||||
// AOC chain methods
|
||||
toggleAocEntry(attestationId: string): void {
|
||||
const current = this.expandedAocEntry();
|
||||
this.expandedAocEntry.set(current === attestationId ? null : attestationId);
|
||||
}
|
||||
|
||||
isAocEntryExpanded(attestationId: string): boolean {
|
||||
return this.expandedAocEntry() === attestationId;
|
||||
}
|
||||
|
||||
// Conflict methods
|
||||
toggleConflictDetails(): void {
|
||||
this.showConflictDetails.update((v) => !v);
|
||||
}
|
||||
|
||||
// Source info helper
|
||||
getSourceInfo(sourceId: string): SourceInfo {
|
||||
return (
|
||||
SOURCE_INFO[sourceId] ?? {
|
||||
sourceId,
|
||||
name: sourceId.toUpperCase(),
|
||||
icon: 'file',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Decision helpers
|
||||
getDecisionClass(decision: PolicyDecision | undefined): string {
|
||||
switch (decision) {
|
||||
case 'pass':
|
||||
return 'decision-pass';
|
||||
case 'warn':
|
||||
return 'decision-warn';
|
||||
case 'block':
|
||||
return 'decision-block';
|
||||
case 'pending':
|
||||
default:
|
||||
return 'decision-pending';
|
||||
}
|
||||
}
|
||||
|
||||
getDecisionLabel(decision: PolicyDecision | undefined): string {
|
||||
switch (decision) {
|
||||
case 'pass':
|
||||
return 'Passed';
|
||||
case 'warn':
|
||||
return 'Warning';
|
||||
case 'block':
|
||||
return 'Blocked';
|
||||
case 'pending':
|
||||
default:
|
||||
return 'Pending';
|
||||
}
|
||||
}
|
||||
|
||||
// Rule result helpers
|
||||
getRuleClass(passed: boolean): string {
|
||||
return passed ? 'rule-passed' : 'rule-failed';
|
||||
}
|
||||
|
||||
getRuleIcon(passed: boolean): string {
|
||||
return passed ? 'check-circle' : 'x-circle';
|
||||
}
|
||||
|
||||
// AOC chain helpers
|
||||
getAocTypeLabel(type: AocChainEntry['type']): string {
|
||||
switch (type) {
|
||||
case 'observation':
|
||||
return 'Observation';
|
||||
case 'linkset':
|
||||
return 'Linkset';
|
||||
case 'policy':
|
||||
return 'Policy Decision';
|
||||
case 'signature':
|
||||
return 'Signature';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
getAocTypeClass(type: AocChainEntry['type']): string {
|
||||
return `aoc-type-${type}`;
|
||||
}
|
||||
|
||||
// Severity helpers
|
||||
getSeverityClass(score: number): string {
|
||||
if (score >= 9.0) return 'severity-critical';
|
||||
if (score >= 7.0) return 'severity-high';
|
||||
if (score >= 4.0) return 'severity-medium';
|
||||
return 'severity-low';
|
||||
}
|
||||
|
||||
getSeverityLabel(score: number): string {
|
||||
if (score >= 9.0) return 'Critical';
|
||||
if (score >= 7.0) return 'High';
|
||||
if (score >= 4.0) return 'Medium';
|
||||
return 'Low';
|
||||
}
|
||||
|
||||
// Download handlers
|
||||
onDownloadObservation(observationId: string): void {
|
||||
this.downloadDocument.emit({ type: 'observation', id: observationId });
|
||||
}
|
||||
|
||||
onDownloadLinkset(linksetId: string): void {
|
||||
this.downloadDocument.emit({ type: 'linkset', id: linksetId });
|
||||
}
|
||||
|
||||
// Close handler
|
||||
onClose(): void {
|
||||
this.close.emit();
|
||||
}
|
||||
|
||||
// Date formatting
|
||||
formatDate(dateStr: string | undefined): string {
|
||||
if (!dateStr) return 'N/A';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
// Hash truncation for display
|
||||
truncateHash(hash: string | undefined, length = 12): string {
|
||||
if (!hash) return 'N/A';
|
||||
if (hash.length <= length) return hash;
|
||||
return hash.slice(0, length) + '...';
|
||||
}
|
||||
|
||||
// Track by functions for ngFor
|
||||
trackByObservationId(_: number, obs: Observation): string {
|
||||
return obs.observationId;
|
||||
}
|
||||
|
||||
trackByAocId(_: number, entry: AocChainEntry): string {
|
||||
return entry.attestationId;
|
||||
}
|
||||
|
||||
trackByConflictField(_: number, conflict: LinksetConflict): string {
|
||||
return conflict.field;
|
||||
}
|
||||
|
||||
trackByRuleId(_: number, rule: PolicyRuleResult): string {
|
||||
return rule.ruleId;
|
||||
}
|
||||
}
|
||||
2
src/Web/StellaOps.Web/src/app/features/evidence/index.ts
Normal file
2
src/Web/StellaOps.Web/src/app/features/evidence/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { EvidencePanelComponent } from './evidence-panel.component';
|
||||
export { EvidencePageComponent } from './evidence-page.component';
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Component,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
@@ -17,6 +18,12 @@ import {
|
||||
ExceptionExplainComponent,
|
||||
ExceptionExplainData,
|
||||
} from '../../shared/components';
|
||||
import {
|
||||
AUTH_SERVICE,
|
||||
AuthService,
|
||||
MockAuthService,
|
||||
StellaOpsScopes,
|
||||
} from '../../core/auth';
|
||||
|
||||
export interface GraphNode {
|
||||
readonly id: string;
|
||||
@@ -74,11 +81,27 @@ type ViewMode = 'hierarchy' | 'flat';
|
||||
selector: 'app-graph-explorer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ExceptionDraftInlineComponent, ExceptionBadgeComponent, ExceptionExplainComponent],
|
||||
providers: [{ provide: AUTH_SERVICE, useClass: MockAuthService }],
|
||||
templateUrl: './graph-explorer.component.html',
|
||||
styleUrls: ['./graph-explorer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class GraphExplorerComponent implements OnInit {
|
||||
private readonly authService = inject(AUTH_SERVICE);
|
||||
|
||||
// Scope-based permissions (using stub StellaOpsScopes from UI-GRAPH-21-001)
|
||||
readonly canViewGraph = computed(() => this.authService.canViewGraph());
|
||||
readonly canEditGraph = computed(() => this.authService.canEditGraph());
|
||||
readonly canExportGraph = computed(() => this.authService.canExportGraph());
|
||||
readonly canSimulate = computed(() => this.authService.canSimulate());
|
||||
readonly canCreateException = computed(() =>
|
||||
this.authService.hasScope(StellaOpsScopes.EXCEPTION_WRITE)
|
||||
);
|
||||
|
||||
// Current user info
|
||||
readonly currentUser = computed(() => this.authService.user());
|
||||
readonly userScopes = computed(() => this.authService.scopes());
|
||||
|
||||
// View state
|
||||
readonly loading = signal(false);
|
||||
readonly message = signal<string | null>(null);
|
||||
|
||||
3
src/Web/StellaOps.Web/src/app/features/releases/index.ts
Normal file
3
src/Web/StellaOps.Web/src/app/features/releases/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { ReleaseFlowComponent } from './release-flow.component';
|
||||
export { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
|
||||
export { RemediationHintsComponent } from './remediation-hints.component';
|
||||
@@ -0,0 +1,328 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PolicyGateResult,
|
||||
PolicyGateStatus,
|
||||
DeterminismFeatureFlags,
|
||||
} from '../../core/api/release.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-gate-indicator',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div
|
||||
class="gate-indicator"
|
||||
[class.gate-indicator--expanded]="expanded()"
|
||||
[class]="'gate-indicator--' + gate().status"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="gate-header"
|
||||
(click)="toggleExpanded()"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
[attr.aria-controls]="'gate-details-' + gate().gateId"
|
||||
>
|
||||
<div class="gate-status">
|
||||
<span class="status-icon" [class]="getStatusIconClass()" aria-hidden="true">
|
||||
@switch (gate().status) {
|
||||
@case ('passed') { <span>✓</span> }
|
||||
@case ('failed') { <span>✗</span> }
|
||||
@case ('warning') { <span>!</span> }
|
||||
@case ('pending') { <span>⌛</span> }
|
||||
@case ('skipped') { <span>-</span> }
|
||||
}
|
||||
</span>
|
||||
<span class="status-text">{{ getStatusLabel() }}</span>
|
||||
</div>
|
||||
<div class="gate-info">
|
||||
<span class="gate-name">{{ gate().name }}</span>
|
||||
@if (gate().gateType === 'determinism' && isDeterminismGate()) {
|
||||
<span class="gate-type-badge gate-type-badge--determinism">Determinism</span>
|
||||
}
|
||||
@if (gate().blockingPublish) {
|
||||
<span class="blocking-badge" title="This gate blocks publishing">Blocking</span>
|
||||
}
|
||||
</div>
|
||||
<span class="expand-icon" aria-hidden="true">{{ expanded() ? '▲' : '▼' }}</span>
|
||||
</button>
|
||||
|
||||
@if (expanded()) {
|
||||
<div class="gate-details" [id]="'gate-details-' + gate().gateId">
|
||||
<p class="gate-message">{{ gate().message }}</p>
|
||||
<div class="gate-meta">
|
||||
<span class="meta-item">
|
||||
<strong>Evaluated:</strong> {{ formatDate(gate().evaluatedAt) }}
|
||||
</span>
|
||||
@if (gate().evidence?.url) {
|
||||
<a
|
||||
[href]="gate().evidence?.url"
|
||||
class="evidence-link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
View Evidence
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Determinism-specific info when feature flag shows it -->
|
||||
@if (gate().gateType === 'determinism' && featureFlags()?.enabled) {
|
||||
<div class="feature-flag-info">
|
||||
@if (featureFlags()?.blockOnFailure) {
|
||||
<span class="flag-badge flag-badge--active">Determinism Blocking Enabled</span>
|
||||
} @else if (featureFlags()?.warnOnly) {
|
||||
<span class="flag-badge flag-badge--warn">Determinism Warn-Only Mode</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.gate-indicator {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&--passed {
|
||||
border-left: 3px solid #22c55e;
|
||||
}
|
||||
|
||||
&--failed {
|
||||
border-left: 3px solid #ef4444;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
border-left: 3px solid #f97316;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
border-left: 3px solid #eab308;
|
||||
}
|
||||
|
||||
&--skipped {
|
||||
border-left: 3px solid #64748b;
|
||||
}
|
||||
|
||||
&--expanded {
|
||||
border-color: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
.gate-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #e2e8f0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
.gate-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.gate-indicator--passed .status-icon {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.gate-indicator--failed .status-icon {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.gate-indicator--warning .status-icon {
|
||||
background: rgba(249, 115, 22, 0.2);
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.gate-indicator--pending .status-icon {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.gate-indicator--skipped .status-icon {
|
||||
background: rgba(100, 116, 139, 0.2);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.gate-indicator--passed .status-text { color: #22c55e; }
|
||||
.gate-indicator--failed .status-text { color: #ef4444; }
|
||||
.gate-indicator--warning .status-text { color: #f97316; }
|
||||
.gate-indicator--pending .status-text { color: #eab308; }
|
||||
.gate-indicator--skipped .status-text { color: #64748b; }
|
||||
|
||||
.gate-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.gate-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gate-type-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
&--determinism {
|
||||
background: rgba(147, 51, 234, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
}
|
||||
|
||||
.blocking-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: #64748b;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.gate-details {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
border-top: 1px solid #334155;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.gate-message {
|
||||
margin: 0.75rem 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.gate-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #64748b;
|
||||
|
||||
strong {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-link {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-flag-info {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.flag-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
|
||||
&--active {
|
||||
background: rgba(147, 51, 234, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PolicyGateIndicatorComponent {
|
||||
readonly gate = input.required<PolicyGateResult>();
|
||||
readonly featureFlags = input<DeterminismFeatureFlags | null>(null);
|
||||
|
||||
readonly expanded = signal(false);
|
||||
|
||||
readonly isDeterminismGate = computed(() => this.gate().gateType === 'determinism');
|
||||
|
||||
toggleExpanded(): void {
|
||||
this.expanded.update((v) => !v);
|
||||
}
|
||||
|
||||
getStatusLabel(): string {
|
||||
const labels: Record<PolicyGateStatus, string> = {
|
||||
passed: 'Passed',
|
||||
failed: 'Failed',
|
||||
pending: 'Pending',
|
||||
warning: 'Warning',
|
||||
skipped: 'Skipped',
|
||||
};
|
||||
return labels[this.gate().status] ?? 'Unknown';
|
||||
}
|
||||
|
||||
getStatusIconClass(): string {
|
||||
return `status-icon--${this.gate().status}`;
|
||||
}
|
||||
|
||||
formatDate(isoString: string): string {
|
||||
try {
|
||||
return new Date(isoString).toLocaleString();
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
<section class="release-flow">
|
||||
<!-- Header -->
|
||||
<header class="release-flow__header">
|
||||
<div class="header-left">
|
||||
@if (viewMode() === 'detail') {
|
||||
<button type="button" class="back-button" (click)="backToList()" aria-label="Back to releases">
|
||||
<span aria-hidden="true">←</span> Releases
|
||||
</button>
|
||||
}
|
||||
<h1>{{ viewMode() === 'list' ? 'Release Management' : selectedRelease()?.name }}</h1>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
@if (isDeterminismEnabled()) {
|
||||
<span class="feature-badge feature-badge--enabled" title="Determinism policy gates are active">
|
||||
Determinism Gates Active
|
||||
</span>
|
||||
} @else {
|
||||
<span class="feature-badge feature-badge--disabled" title="Determinism policy gates are disabled">
|
||||
Determinism Gates Disabled
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Loading State -->
|
||||
@if (loading()) {
|
||||
<div class="loading-container" aria-live="polite">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading releases...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- List View -->
|
||||
@if (!loading() && viewMode() === 'list') {
|
||||
<div class="releases-list">
|
||||
@for (release of releases(); track trackByReleaseId($index, release)) {
|
||||
<article
|
||||
class="release-card"
|
||||
[class.release-card--blocked]="release.status === 'blocked'"
|
||||
(click)="selectRelease(release)"
|
||||
(keydown.enter)="selectRelease(release)"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
[attr.aria-label]="'View release ' + release.name"
|
||||
>
|
||||
<div class="release-card__header">
|
||||
<h2>{{ release.name }}</h2>
|
||||
<span class="release-status" [ngClass]="getReleaseStatusClass(release)">
|
||||
{{ release.status | titlecase }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="release-card__meta">
|
||||
<span class="meta-item">
|
||||
<strong>Version:</strong> {{ release.version }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<strong>Target:</strong> {{ release.targetEnvironment }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<strong>Artifacts:</strong> {{ release.artifacts.length }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="release-card__gates">
|
||||
@for (artifact of release.artifacts; track trackByArtifactId($index, artifact)) {
|
||||
@if (artifact.policyEvaluation) {
|
||||
<div class="artifact-gates">
|
||||
<span class="artifact-name">{{ artifact.name }}:</span>
|
||||
@for (gate of artifact.policyEvaluation.gates; track trackByGateId($index, gate)) {
|
||||
<span
|
||||
class="gate-pip"
|
||||
[ngClass]="getStatusClass(gate.status)"
|
||||
[title]="gate.name + ': ' + gate.status"
|
||||
></span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (release.status === 'blocked') {
|
||||
<div class="release-card__warning">
|
||||
<span class="warning-icon" aria-hidden="true">!</span>
|
||||
Policy gates blocking publish
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
} @empty {
|
||||
<p class="empty-state">No releases found.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Detail View -->
|
||||
@if (!loading() && viewMode() === 'detail' && selectedRelease()) {
|
||||
<div class="release-detail">
|
||||
<!-- Release Info -->
|
||||
<section class="detail-section">
|
||||
<h2>Release Information</h2>
|
||||
<dl class="info-grid">
|
||||
<div>
|
||||
<dt>Version</dt>
|
||||
<dd>{{ selectedRelease()?.version }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>
|
||||
<span class="release-status" [ngClass]="getReleaseStatusClass(selectedRelease()!)">
|
||||
{{ selectedRelease()?.status | titlecase }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Target Environment</dt>
|
||||
<dd>{{ selectedRelease()?.targetEnvironment }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dd>{{ selectedRelease()?.createdAt }} by {{ selectedRelease()?.createdBy }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@if (selectedRelease()?.notes) {
|
||||
<p class="release-notes">{{ selectedRelease()?.notes }}</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Determinism Gate Summary -->
|
||||
@if (isDeterminismEnabled() && determinismBlockingCount() > 0) {
|
||||
<section class="detail-section determinism-blocking-banner">
|
||||
<div class="banner-icon" aria-hidden="true">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="banner-content">
|
||||
<h3>Determinism Check Failed</h3>
|
||||
<p>
|
||||
{{ determinismBlockingCount() }} artifact(s) failed SBOM determinism verification.
|
||||
Publishing is blocked until issues are resolved or a bypass is approved.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Artifacts Section -->
|
||||
<section class="detail-section">
|
||||
<h2>Artifacts ({{ selectedRelease()?.artifacts?.length }})</h2>
|
||||
<div class="artifacts-tabs" role="tablist">
|
||||
@for (artifact of selectedRelease()?.artifacts; track trackByArtifactId($index, artifact)) {
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="artifact-tab"
|
||||
[class.artifact-tab--active]="selectedArtifact()?.artifactId === artifact.artifactId"
|
||||
[class.artifact-tab--blocked]="!artifact.policyEvaluation?.canPublish"
|
||||
[attr.aria-selected]="selectedArtifact()?.artifactId === artifact.artifactId"
|
||||
(click)="selectArtifact(artifact)"
|
||||
>
|
||||
<span class="artifact-tab__name">{{ artifact.name }}</span>
|
||||
<span class="artifact-tab__tag">{{ artifact.tag }}</span>
|
||||
@if (!artifact.policyEvaluation?.canPublish) {
|
||||
<span class="artifact-tab__blocked" aria-label="Blocked">!</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Selected Artifact Details -->
|
||||
@if (selectedArtifact()) {
|
||||
<div class="artifact-detail" role="tabpanel">
|
||||
<dl class="artifact-meta">
|
||||
<div>
|
||||
<dt>Digest</dt>
|
||||
<dd><code>{{ selectedArtifact()?.digest }}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Size</dt>
|
||||
<dd>{{ formatBytes(selectedArtifact()!.size) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Registry</dt>
|
||||
<dd>{{ selectedArtifact()?.registry }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<!-- Policy Gates -->
|
||||
@if (selectedArtifact()?.policyEvaluation) {
|
||||
<div class="policy-gates">
|
||||
<h3>Policy Gates</h3>
|
||||
<div class="gates-list">
|
||||
@for (gate of selectedArtifact()!.policyEvaluation!.gates; track trackByGateId($index, gate)) {
|
||||
<app-policy-gate-indicator
|
||||
[gate]="gate"
|
||||
[featureFlags]="featureFlags()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Determinism Details -->
|
||||
@if (selectedArtifact()!.policyEvaluation!.determinismDetails) {
|
||||
<div class="determinism-details">
|
||||
<h4>Determinism Evidence</h4>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Merkle Root</dt>
|
||||
<dd>
|
||||
<code>{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.merkleRoot }}</code>
|
||||
@if (selectedArtifact()!.policyEvaluation!.determinismDetails!.merkleRootConsistent) {
|
||||
<span class="consistency-badge consistency-badge--consistent">Consistent</span>
|
||||
} @else {
|
||||
<span class="consistency-badge consistency-badge--inconsistent">Mismatch</span>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Fragment Verification</dt>
|
||||
<dd>
|
||||
{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.verifiedFragments }} /
|
||||
{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.fragmentCount }} verified
|
||||
</dd>
|
||||
</div>
|
||||
@if (selectedArtifact()!.policyEvaluation!.determinismDetails!.compositionManifestUri) {
|
||||
<div>
|
||||
<dt>Composition Manifest</dt>
|
||||
<dd><code>{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.compositionManifestUri }}</code></dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
|
||||
<!-- Failed Fragments -->
|
||||
@if (selectedArtifact()!.policyEvaluation!.determinismDetails!.failedFragments?.length) {
|
||||
<div class="failed-fragments">
|
||||
<h5>Failed Fragment Layers</h5>
|
||||
<ul>
|
||||
@for (fragment of selectedArtifact()!.policyEvaluation!.determinismDetails!.failedFragments; track fragment) {
|
||||
<li><code>{{ fragment }}</code></li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Remediation Hints for Failed Gates -->
|
||||
@for (gate of selectedArtifact()!.policyEvaluation!.gates; track trackByGateId($index, gate)) {
|
||||
@if (gate.status === 'failed' && gate.remediation) {
|
||||
<app-remediation-hints [gate]="gate" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
<section class="detail-section actions-section">
|
||||
<h2>Actions</h2>
|
||||
<div class="action-buttons">
|
||||
@if (canPublishSelected()) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
(click)="publishRelease()"
|
||||
[disabled]="publishing()"
|
||||
>
|
||||
@if (publishing()) {
|
||||
Publishing...
|
||||
} @else {
|
||||
Publish Release
|
||||
}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary btn--disabled"
|
||||
disabled
|
||||
title="Cannot publish: {{ blockingGatesCount() }} policy gate(s) blocking"
|
||||
>
|
||||
Publish Blocked
|
||||
</button>
|
||||
@if (canBypass() && determinismBlockingCount() > 0) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--warning"
|
||||
(click)="openBypassModal()"
|
||||
>
|
||||
Request Bypass
|
||||
</button>
|
||||
}
|
||||
}
|
||||
<button type="button" class="btn btn--secondary" (click)="backToList()">
|
||||
Back to List
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Bypass Request Modal -->
|
||||
@if (showBypassModal()) {
|
||||
<div class="modal-overlay" (click)="closeBypassModal()" role="dialog" aria-modal="true" aria-labelledby="bypass-modal-title">
|
||||
<div class="modal-content" (click)="$event.stopPropagation()">
|
||||
<h2 id="bypass-modal-title">Request Policy Bypass</h2>
|
||||
<p class="modal-description">
|
||||
You are requesting to bypass {{ determinismBlockingCount() }} failing determinism gate(s).
|
||||
This request requires approval from a security administrator.
|
||||
</p>
|
||||
<label for="bypass-reason">Justification</label>
|
||||
<textarea
|
||||
id="bypass-reason"
|
||||
rows="4"
|
||||
placeholder="Explain why this bypass is necessary and what compensating controls are in place..."
|
||||
[value]="bypassReason()"
|
||||
(input)="updateBypassReason($event)"
|
||||
></textarea>
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--primary"
|
||||
(click)="submitBypassRequest()"
|
||||
[disabled]="!bypassReason().trim()"
|
||||
>
|
||||
Submit Request
|
||||
</button>
|
||||
<button type="button" class="btn btn--secondary" (click)="closeBypassModal()">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,661 @@
|
||||
.release-flow {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
color: #e2e8f0;
|
||||
background: #0f172a;
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
// Header
|
||||
.release-flow__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
background: transparent;
|
||||
border: 1px solid #334155;
|
||||
color: #94a3b8;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
|
||||
&:hover {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
&--enabled {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
background: rgba(100, 116, 139, 0.2);
|
||||
color: #94a3b8;
|
||||
border: 1px solid rgba(100, 116, 139, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
// Loading
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #334155;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Releases List
|
||||
.releases-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.release-card {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2933;
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&--blocked {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
.release-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.release-status {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.release-status--draft {
|
||||
background: rgba(100, 116, 139, 0.2);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.release-status--pending {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.release-status--approved {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.release-status--publishing {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.release-status--published {
|
||||
background: rgba(34, 197, 94, 0.3);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.release-status--blocked {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.release-status--cancelled {
|
||||
background: rgba(100, 116, 139, 0.2);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.release-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
|
||||
strong {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
}
|
||||
|
||||
.release-card__gates {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.artifact-gates {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.gate-pip {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status--passed {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.status--failed {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.status--pending {
|
||||
background: #eab308;
|
||||
}
|
||||
|
||||
.status--warning {
|
||||
background: #f97316;
|
||||
}
|
||||
|
||||
.status--skipped {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
.release-card__warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #ef4444;
|
||||
color: #111827;
|
||||
border-radius: 50%;
|
||||
font-weight: bold;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
// Detail View
|
||||
.release-detail {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2933;
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.125rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 1rem 0 0.75rem 0;
|
||||
font-size: 1rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
h5 {
|
||||
margin: 0.75rem 0 0.5rem 0;
|
||||
font-size: 0.8125rem;
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
|
||||
dt {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
.release-notes {
|
||||
margin: 1rem 0 0 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #0f172a;
|
||||
border-radius: 4px;
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// Determinism Blocking Banner
|
||||
.determinism-blocking-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
flex-shrink: 0;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #fca5a5;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Artifacts Tabs
|
||||
.artifacts-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.artifact-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 4px;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: #1d4ed8;
|
||||
border-color: #1d4ed8;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
&--blocked:not(.artifact-tab--active) {
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.artifact-tab__name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.artifact-tab__tag {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.artifact-tab__blocked {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #ef4444;
|
||||
color: #111827;
|
||||
border-radius: 50%;
|
||||
font-weight: bold;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
// Artifact Detail
|
||||
.artifact-detail {
|
||||
padding: 1rem;
|
||||
background: #0f172a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.artifact-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 0 0 1rem 0;
|
||||
|
||||
dt {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
// Policy Gates
|
||||
.policy-gates {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.gates-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
// Determinism Details
|
||||
.determinism-details {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #111827;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #1f2933;
|
||||
|
||||
dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.consistency-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
|
||||
&--consistent {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
&--inconsistent {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
.failed-fragments {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-radius: 4px;
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
list-style: disc;
|
||||
|
||||
li {
|
||||
margin: 0.25rem 0;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Actions Section
|
||||
.actions-section {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: none;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: #1d4ed8;
|
||||
color: #f8fafc;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #1e40af;
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: #334155;
|
||||
color: #e2e8f0;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #475569;
|
||||
}
|
||||
}
|
||||
|
||||
&--warning {
|
||||
background: #d97706;
|
||||
color: #f8fafc;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #b45309;
|
||||
}
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
background: #475569;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
// Modal
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2933;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.25rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 1rem 0 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 4px;
|
||||
color: #e2e8f0;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-description {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import {
|
||||
Release,
|
||||
ReleaseArtifact,
|
||||
PolicyGateResult,
|
||||
PolicyGateStatus,
|
||||
DeterminismFeatureFlags,
|
||||
} from '../../core/api/release.models';
|
||||
import { RELEASE_API, MockReleaseApi } from '../../core/api/release.client';
|
||||
import { PolicyGateIndicatorComponent } from './policy-gate-indicator.component';
|
||||
import { RemediationHintsComponent } from './remediation-hints.component';
|
||||
|
||||
type ViewMode = 'list' | 'detail';
|
||||
|
||||
@Component({
|
||||
selector: 'app-release-flow',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule, PolicyGateIndicatorComponent, RemediationHintsComponent],
|
||||
providers: [{ provide: RELEASE_API, useClass: MockReleaseApi }],
|
||||
templateUrl: './release-flow.component.html',
|
||||
styleUrls: ['./release-flow.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ReleaseFlowComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly releaseApi = inject(RELEASE_API);
|
||||
|
||||
// State
|
||||
readonly releases = signal<readonly Release[]>([]);
|
||||
readonly selectedRelease = signal<Release | null>(null);
|
||||
readonly selectedArtifact = signal<ReleaseArtifact | null>(null);
|
||||
readonly featureFlags = signal<DeterminismFeatureFlags | null>(null);
|
||||
readonly loading = signal(true);
|
||||
readonly publishing = signal(false);
|
||||
readonly viewMode = signal<ViewMode>('list');
|
||||
readonly bypassReason = signal('');
|
||||
readonly showBypassModal = signal(false);
|
||||
|
||||
// Computed values
|
||||
readonly canPublishSelected = computed(() => {
|
||||
const release = this.selectedRelease();
|
||||
if (!release) return false;
|
||||
return release.artifacts.every((a) => a.policyEvaluation?.canPublish ?? false);
|
||||
});
|
||||
|
||||
readonly blockingGatesCount = computed(() => {
|
||||
const release = this.selectedRelease();
|
||||
if (!release) return 0;
|
||||
return release.artifacts.reduce((count, artifact) => {
|
||||
return count + (artifact.policyEvaluation?.blockingGates.length ?? 0);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
readonly determinismBlockingCount = computed(() => {
|
||||
const release = this.selectedRelease();
|
||||
if (!release) return 0;
|
||||
return release.artifacts.reduce((count, artifact) => {
|
||||
const gates = artifact.policyEvaluation?.gates ?? [];
|
||||
const deterministicBlocking = gates.filter(
|
||||
(g) => g.gateType === 'determinism' && g.status === 'failed' && g.blockingPublish
|
||||
);
|
||||
return count + deterministicBlocking.length;
|
||||
}, 0);
|
||||
});
|
||||
|
||||
readonly isDeterminismEnabled = computed(() => {
|
||||
const flags = this.featureFlags();
|
||||
return flags?.enabled ?? false;
|
||||
});
|
||||
|
||||
readonly canBypass = computed(() => {
|
||||
const flags = this.featureFlags();
|
||||
return flags?.bypassRoles && flags.bypassRoles.length > 0;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
private loadData(): void {
|
||||
this.loading.set(true);
|
||||
|
||||
// Load feature flags
|
||||
this.releaseApi.getFeatureFlags().subscribe({
|
||||
next: (flags) => this.featureFlags.set(flags),
|
||||
error: (err) => console.error('Failed to load feature flags:', err),
|
||||
});
|
||||
|
||||
// Load releases
|
||||
this.releaseApi.listReleases().subscribe({
|
||||
next: (releases) => {
|
||||
this.releases.set(releases);
|
||||
this.loading.set(false);
|
||||
|
||||
// Check if we should auto-select from route
|
||||
const releaseId = this.route.snapshot.paramMap.get('releaseId');
|
||||
if (releaseId) {
|
||||
const release = releases.find((r) => r.releaseId === releaseId);
|
||||
if (release) {
|
||||
this.selectRelease(release);
|
||||
}
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load releases:', err);
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
selectRelease(release: Release): void {
|
||||
this.selectedRelease.set(release);
|
||||
this.selectedArtifact.set(release.artifacts[0] ?? null);
|
||||
this.viewMode.set('detail');
|
||||
}
|
||||
|
||||
selectArtifact(artifact: ReleaseArtifact): void {
|
||||
this.selectedArtifact.set(artifact);
|
||||
}
|
||||
|
||||
backToList(): void {
|
||||
this.selectedRelease.set(null);
|
||||
this.selectedArtifact.set(null);
|
||||
this.viewMode.set('list');
|
||||
}
|
||||
|
||||
publishRelease(): void {
|
||||
const release = this.selectedRelease();
|
||||
if (!release || !this.canPublishSelected()) return;
|
||||
|
||||
this.publishing.set(true);
|
||||
this.releaseApi.publishRelease(release.releaseId).subscribe({
|
||||
next: (updated) => {
|
||||
// Update the release in the list
|
||||
this.releases.update((list) =>
|
||||
list.map((r) => (r.releaseId === updated.releaseId ? updated : r))
|
||||
);
|
||||
this.selectedRelease.set(updated);
|
||||
this.publishing.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Publish failed:', err);
|
||||
this.publishing.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openBypassModal(): void {
|
||||
this.bypassReason.set('');
|
||||
this.showBypassModal.set(true);
|
||||
}
|
||||
|
||||
closeBypassModal(): void {
|
||||
this.showBypassModal.set(false);
|
||||
}
|
||||
|
||||
submitBypassRequest(): void {
|
||||
const release = this.selectedRelease();
|
||||
const reason = this.bypassReason();
|
||||
if (!release || !reason.trim()) return;
|
||||
|
||||
this.releaseApi.requestBypass(release.releaseId, reason).subscribe({
|
||||
next: (result) => {
|
||||
console.log('Bypass requested:', result.requestId);
|
||||
this.closeBypassModal();
|
||||
// In real implementation, would show notification and refresh
|
||||
},
|
||||
error: (err) => console.error('Bypass request failed:', err),
|
||||
});
|
||||
}
|
||||
|
||||
updateBypassReason(event: Event): void {
|
||||
const target = event.target as HTMLTextAreaElement;
|
||||
this.bypassReason.set(target.value);
|
||||
}
|
||||
|
||||
getStatusClass(status: PolicyGateStatus): string {
|
||||
const statusClasses: Record<PolicyGateStatus, string> = {
|
||||
passed: 'status--passed',
|
||||
failed: 'status--failed',
|
||||
pending: 'status--pending',
|
||||
warning: 'status--warning',
|
||||
skipped: 'status--skipped',
|
||||
};
|
||||
return statusClasses[status] ?? 'status--pending';
|
||||
}
|
||||
|
||||
getReleaseStatusClass(release: Release): string {
|
||||
const statusClasses: Record<string, string> = {
|
||||
draft: 'release-status--draft',
|
||||
pending_approval: 'release-status--pending',
|
||||
approved: 'release-status--approved',
|
||||
publishing: 'release-status--publishing',
|
||||
published: 'release-status--published',
|
||||
blocked: 'release-status--blocked',
|
||||
cancelled: 'release-status--cancelled',
|
||||
};
|
||||
return statusClasses[release.status] ?? 'release-status--draft';
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
trackByReleaseId(_index: number, release: Release): string {
|
||||
return release.releaseId;
|
||||
}
|
||||
|
||||
trackByArtifactId(_index: number, artifact: ReleaseArtifact): string {
|
||||
return artifact.artifactId;
|
||||
}
|
||||
|
||||
trackByGateId(_index: number, gate: PolicyGateResult): string {
|
||||
return gate.gateId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PolicyGateResult,
|
||||
RemediationHint,
|
||||
RemediationStep,
|
||||
RemediationActionType,
|
||||
} from '../../core/api/release.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-remediation-hints',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="remediation-hints" [class.remediation-hints--collapsed]="!expanded()">
|
||||
<button
|
||||
type="button"
|
||||
class="remediation-header"
|
||||
(click)="toggleExpanded()"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
>
|
||||
<div class="header-content">
|
||||
<span class="header-icon" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="header-title">Remediation Steps</span>
|
||||
<span class="severity-badge" [class]="'severity-badge--' + hint().severity">
|
||||
{{ hint().severity | titlecase }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="expand-icon" aria-hidden="true">{{ expanded() ? '▲' : '▼' }}</span>
|
||||
</button>
|
||||
|
||||
@if (expanded()) {
|
||||
<div class="remediation-content">
|
||||
<!-- Summary -->
|
||||
<p class="remediation-summary">{{ hint().summary }}</p>
|
||||
|
||||
<!-- Estimated Effort -->
|
||||
@if (hint().estimatedEffort) {
|
||||
<div class="effort-indicator">
|
||||
<span class="effort-icon" aria-hidden="true">⏰</span>
|
||||
<span>Estimated effort: <strong>{{ hint().estimatedEffort }}</strong></span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Steps -->
|
||||
<ol class="remediation-steps">
|
||||
@for (step of hint().steps; track step.action; let i = $index) {
|
||||
<li class="step" [class.step--automated]="step.automated">
|
||||
<div class="step-header">
|
||||
<span class="step-number">{{ i + 1 }}</span>
|
||||
<span class="step-title">{{ step.title }}</span>
|
||||
@if (step.automated) {
|
||||
<span class="automated-badge" title="Can be triggered from UI">Automated</span>
|
||||
}
|
||||
<span class="action-type-icon" [title]="getActionTypeLabel(step.action)">
|
||||
{{ getActionTypeIcon(step.action) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="step-description">{{ step.description }}</p>
|
||||
|
||||
@if (step.command) {
|
||||
<div class="step-command">
|
||||
<code>{{ step.command }}</code>
|
||||
<button
|
||||
type="button"
|
||||
class="copy-button"
|
||||
(click)="copyCommand(step.command)"
|
||||
title="Copy command"
|
||||
aria-label="Copy command to clipboard"
|
||||
>
|
||||
@if (copiedCommand() === step.command) {
|
||||
✓
|
||||
} @else {
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (step.documentationUrl) {
|
||||
<a
|
||||
[href]="step.documentationUrl"
|
||||
class="docs-link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
View documentation →
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (step.automated) {
|
||||
<button
|
||||
type="button"
|
||||
class="action-button"
|
||||
(click)="triggerAction(step)"
|
||||
>
|
||||
{{ getActionButtonLabel(step.action) }}
|
||||
</button>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
|
||||
<!-- Exception Option -->
|
||||
@if (hint().exceptionAllowed) {
|
||||
<div class="exception-option">
|
||||
<div class="exception-info">
|
||||
<span class="exception-icon" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>A policy exception can be requested if compensating controls are in place.</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="exception-button"
|
||||
(click)="requestException()"
|
||||
>
|
||||
Request Exception
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.remediation-hints {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
margin-top: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.remediation-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
border: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
color: #e2e8f0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.remediation-hints:not(.remediation-hints--collapsed) .remediation-header {
|
||||
border-bottom-color: #334155;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.severity-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
|
||||
&--critical {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
&--high {
|
||||
background: rgba(249, 115, 22, 0.2);
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
&--low {
|
||||
background: rgba(100, 116, 139, 0.2);
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: #64748b;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.remediation-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.remediation-summary {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.effort-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
color: #94a3b8;
|
||||
|
||||
strong {
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.effort-icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.remediation-steps {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.step {
|
||||
position: relative;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&--automated {
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: #334155;
|
||||
color: #e2e8f0;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.automated-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.action-type-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.step-description {
|
||||
margin: 0 0 0.75rem 0;
|
||||
padding-left: calc(22px + 0.75rem);
|
||||
color: #94a3b8;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.step-command {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 0;
|
||||
margin-left: calc(22px + 0.75rem);
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #111827;
|
||||
border: 1px solid #1f2933;
|
||||
border-radius: 4px;
|
||||
|
||||
code {
|
||||
flex: 1;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: #22c55e;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
.docs-link {
|
||||
display: inline-block;
|
||||
margin-left: calc(22px + 0.75rem);
|
||||
margin-bottom: 0.5rem;
|
||||
color: #3b82f6;
|
||||
font-size: 0.8125rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.action-button {
|
||||
margin-left: calc(22px + 0.75rem);
|
||||
padding: 0.5rem 1rem;
|
||||
background: #22c55e;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #0f172a;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #16a34a;
|
||||
}
|
||||
}
|
||||
|
||||
.exception-option {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: rgba(147, 51, 234, 0.1);
|
||||
border: 1px solid rgba(147, 51, 234, 0.2);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.exception-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #a855f7;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.exception-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.exception-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #7c3aed;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #f8fafc;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #6d28d9;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class RemediationHintsComponent {
|
||||
readonly gate = input.required<PolicyGateResult>();
|
||||
readonly actionTriggered = output<{ gate: PolicyGateResult; step: RemediationStep }>();
|
||||
readonly exceptionRequested = output<PolicyGateResult>();
|
||||
|
||||
readonly expanded = signal(true); // Default expanded for failed gates
|
||||
readonly copiedCommand = signal<string | null>(null);
|
||||
|
||||
readonly hint = computed<RemediationHint>(() => {
|
||||
return (
|
||||
this.gate().remediation ?? {
|
||||
gateType: this.gate().gateType,
|
||||
severity: 'medium',
|
||||
summary: 'No specific remediation steps available.',
|
||||
steps: [],
|
||||
exceptionAllowed: false,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
toggleExpanded(): void {
|
||||
this.expanded.update((v) => !v);
|
||||
}
|
||||
|
||||
getActionTypeIcon(action: RemediationActionType): string {
|
||||
const icons: Record<RemediationActionType, string> = {
|
||||
rebuild: '🔨',
|
||||
'provide-provenance': '📜',
|
||||
'sign-artifact': '🔐',
|
||||
'update-dependency': '📦',
|
||||
'request-exception': '🛡️',
|
||||
'manual-review': '👁️',
|
||||
};
|
||||
return icons[action] ?? '📋';
|
||||
}
|
||||
|
||||
getActionTypeLabel(action: RemediationActionType): string {
|
||||
const labels: Record<RemediationActionType, string> = {
|
||||
rebuild: 'Rebuild required',
|
||||
'provide-provenance': 'Provide provenance',
|
||||
'sign-artifact': 'Sign artifact',
|
||||
'update-dependency': 'Update dependency',
|
||||
'request-exception': 'Request exception',
|
||||
'manual-review': 'Manual review',
|
||||
};
|
||||
return labels[action] ?? action;
|
||||
}
|
||||
|
||||
getActionButtonLabel(action: RemediationActionType): string {
|
||||
const labels: Record<RemediationActionType, string> = {
|
||||
rebuild: 'Trigger Rebuild',
|
||||
'provide-provenance': 'Upload Provenance',
|
||||
'sign-artifact': 'Sign Now',
|
||||
'update-dependency': 'Update',
|
||||
'request-exception': 'Request',
|
||||
'manual-review': 'Start Review',
|
||||
};
|
||||
return labels[action] ?? 'Execute';
|
||||
}
|
||||
|
||||
async copyCommand(command: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
this.copiedCommand.set(command);
|
||||
setTimeout(() => this.copiedCommand.set(null), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy command:', err);
|
||||
}
|
||||
}
|
||||
|
||||
triggerAction(step: RemediationStep): void {
|
||||
this.actionTriggered.emit({ gate: this.gate(), step });
|
||||
}
|
||||
|
||||
requestException(): void {
|
||||
this.exceptionRequested.emit(this.gate());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
import {
|
||||
DeterminismEvidence,
|
||||
DeterminismStatus,
|
||||
FragmentAttestation,
|
||||
} from '../../core/api/scanner.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-determinism-badge',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="determinism-badge" [class]="statusClass()">
|
||||
<!-- Badge Header (always visible) -->
|
||||
<button
|
||||
type="button"
|
||||
class="determinism-badge__header"
|
||||
(click)="toggleExpanded()"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
aria-controls="determinism-details"
|
||||
>
|
||||
<span class="determinism-badge__icon" aria-hidden="true">
|
||||
@switch (status()) {
|
||||
@case ('verified') { ✓ }
|
||||
@case ('pending') { ⌛ }
|
||||
@case ('failed') { ✗ }
|
||||
@default { ? }
|
||||
}
|
||||
</span>
|
||||
<span class="determinism-badge__label">
|
||||
Determinism: {{ statusLabel() }}
|
||||
</span>
|
||||
<span class="determinism-badge__toggle" aria-hidden="true">
|
||||
{{ expanded() ? '▲' : '▼' }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Expanded Details -->
|
||||
@if (expanded() && evidence()) {
|
||||
<div
|
||||
id="determinism-details"
|
||||
class="determinism-badge__details"
|
||||
role="region"
|
||||
aria-label="Determinism evidence details"
|
||||
>
|
||||
<!-- Merkle Root Section -->
|
||||
<section class="details-section">
|
||||
<h4 class="details-section__title">Merkle Root</h4>
|
||||
<div class="merkle-root">
|
||||
@if (evidence()?.merkleRoot) {
|
||||
<code class="hash-value">{{ evidence()?.merkleRoot }}</code>
|
||||
<span
|
||||
class="consistency-badge"
|
||||
[class.consistent]="evidence()?.merkleRootConsistent"
|
||||
[class.inconsistent]="!evidence()?.merkleRootConsistent"
|
||||
>
|
||||
{{ evidence()?.merkleRootConsistent ? 'Consistent' : 'Inconsistent' }}
|
||||
</span>
|
||||
} @else {
|
||||
<span class="no-data">No Merkle root available</span>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Content Hash Section -->
|
||||
@if (evidence()?.contentHash) {
|
||||
<section class="details-section">
|
||||
<h4 class="details-section__title">Content Hash</h4>
|
||||
<code class="hash-value">{{ evidence()?.contentHash }}</code>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Composition Manifest Section -->
|
||||
@if (evidence()?.compositionManifest; as manifest) {
|
||||
<section class="details-section">
|
||||
<h4 class="details-section__title">Composition Manifest</h4>
|
||||
<dl class="manifest-info">
|
||||
<dt>URI:</dt>
|
||||
<dd>
|
||||
<code class="uri-value">{{ manifest.compositionUri }}</code>
|
||||
</dd>
|
||||
<dt>Fragment Count:</dt>
|
||||
<dd>{{ manifest.fragmentCount }}</dd>
|
||||
<dt>Created:</dt>
|
||||
<dd>{{ formatDate(manifest.createdAt) }}</dd>
|
||||
</dl>
|
||||
|
||||
<!-- Fragment Attestations -->
|
||||
@if (manifest.fragments.length > 0) {
|
||||
<div class="fragments-section">
|
||||
<h5 class="fragments-title">
|
||||
Fragment Attestations ({{ manifest.fragments.length }})
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="fragments-toggle"
|
||||
(click)="toggleFragments()"
|
||||
[attr.aria-expanded]="showFragments()"
|
||||
>
|
||||
{{ showFragments() ? 'Hide fragments' : 'Show fragments' }}
|
||||
</button>
|
||||
|
||||
@if (showFragments()) {
|
||||
<ul class="fragments-list">
|
||||
@for (fragment of manifest.fragments; track fragment.layerDigest) {
|
||||
<li class="fragment-item" [class]="getFragmentClass(fragment)">
|
||||
<div class="fragment-header">
|
||||
<span
|
||||
class="fragment-status"
|
||||
[attr.aria-label]="'DSSE status: ' + fragment.dsseStatus"
|
||||
>
|
||||
@switch (fragment.dsseStatus) {
|
||||
@case ('verified') { ✓ }
|
||||
@case ('pending') { ⌛ }
|
||||
@case ('failed') { ✗ }
|
||||
}
|
||||
</span>
|
||||
<span class="fragment-layer">
|
||||
Layer: {{ truncateHash(fragment.layerDigest, 16) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="fragment-details">
|
||||
<div class="fragment-row">
|
||||
<span class="fragment-label">Fragment SHA256:</span>
|
||||
<code class="fragment-hash">{{ truncateHash(fragment.fragmentSha256, 20) }}</code>
|
||||
</div>
|
||||
<div class="fragment-row">
|
||||
<span class="fragment-label">DSSE Envelope:</span>
|
||||
<code class="fragment-hash">{{ truncateHash(fragment.dsseEnvelopeSha256, 20) }}</code>
|
||||
</div>
|
||||
@if (fragment.verifiedAt) {
|
||||
<div class="fragment-row">
|
||||
<span class="fragment-label">Verified:</span>
|
||||
<span class="fragment-date">{{ formatDate(fragment.verifiedAt) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Stella Properties Section -->
|
||||
@if (evidence()?.stellaProperties) {
|
||||
<section class="details-section">
|
||||
<h4 class="details-section__title">Stella Properties</h4>
|
||||
<dl class="stella-props">
|
||||
@if (evidence()?.stellaProperties?.['stellaops:stella.contentHash']) {
|
||||
<dt>stellaops:stella.contentHash</dt>
|
||||
<dd>
|
||||
<code>{{ truncateHash(evidence()?.stellaProperties?.['stellaops:stella.contentHash'] ?? '', 24) }}</code>
|
||||
</dd>
|
||||
}
|
||||
@if (evidence()?.stellaProperties?.['stellaops:composition.manifest']) {
|
||||
<dt>stellaops:composition.manifest</dt>
|
||||
<dd>
|
||||
<code>{{ evidence()?.stellaProperties?.['stellaops:composition.manifest'] }}</code>
|
||||
</dd>
|
||||
}
|
||||
@if (evidence()?.stellaProperties?.['stellaops:merkle.root']) {
|
||||
<dt>stellaops:merkle.root</dt>
|
||||
<dd>
|
||||
<code>{{ truncateHash(evidence()?.stellaProperties?.['stellaops:merkle.root'] ?? '', 24) }}</code>
|
||||
</dd>
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Verification Info -->
|
||||
@if (evidence()?.verifiedAt) {
|
||||
<section class="details-section">
|
||||
<h4 class="details-section__title">Verification</h4>
|
||||
<p class="verified-at">
|
||||
Last verified: {{ formatDate(evidence()?.verifiedAt) }}
|
||||
</p>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Failure Reason -->
|
||||
@if (evidence()?.failureReason) {
|
||||
<section class="details-section details-section--error">
|
||||
<h4 class="details-section__title">Failure Reason</h4>
|
||||
<p class="failure-reason">{{ evidence()?.failureReason }}</p>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.determinism-badge {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
|
||||
&.status-verified {
|
||||
border-color: #86efac;
|
||||
}
|
||||
|
||||
&.status-pending {
|
||||
border-color: #fcd34d;
|
||||
}
|
||||
|
||||
&.status-failed {
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
|
||||
&.status-unknown {
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
}
|
||||
|
||||
.determinism-badge__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
transition: background-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.status-verified & {
|
||||
background: #f0fdf4;
|
||||
&:hover { background: #dcfce7; }
|
||||
}
|
||||
|
||||
.status-pending & {
|
||||
background: #fffbeb;
|
||||
&:hover { background: #fef3c7; }
|
||||
}
|
||||
|
||||
.status-failed & {
|
||||
background: #fef2f2;
|
||||
&:hover { background: #fee2e2; }
|
||||
}
|
||||
}
|
||||
|
||||
.determinism-badge__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
|
||||
.status-verified & {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status-pending & {
|
||||
background: #f59e0b;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status-failed & {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status-unknown & {
|
||||
background: #6b7280;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.determinism-badge__label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.determinism-badge__toggle {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.determinism-badge__details {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.details-section {
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&--error {
|
||||
background: #fef2f2;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #fca5a5;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
}
|
||||
|
||||
.merkle-root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hash-value,
|
||||
.uri-value {
|
||||
display: inline-block;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.consistency-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
|
||||
&.consistent {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
&.inconsistent {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.no-data {
|
||||
font-size: 0.8125rem;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.manifest-info,
|
||||
.stella-props {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
|
||||
dt {
|
||||
color: #6b7280;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0.25rem 0 0;
|
||||
color: #111827;
|
||||
|
||||
code {
|
||||
font-size: 0.75rem;
|
||||
background: #fff;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fragments-section {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.fragments-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.fragments-toggle {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 0.75rem;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.fragments-list {
|
||||
list-style: none;
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.fragment-item {
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.5rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&.fragment-verified {
|
||||
border-color: #86efac;
|
||||
}
|
||||
|
||||
&.fragment-pending {
|
||||
border-color: #fcd34d;
|
||||
}
|
||||
|
||||
&.fragment-failed {
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
}
|
||||
|
||||
.fragment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.fragment-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
|
||||
.fragment-verified & {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fragment-pending & {
|
||||
background: #f59e0b;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fragment-failed & {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.fragment-layer {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.fragment-details {
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
.fragment-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fragment-label {
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fragment-hash {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
background: #f3f4f6;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.fragment-date {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.verified-at {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.failure-reason {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: #dc2626;
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DeterminismBadgeComponent {
|
||||
readonly evidence = input<DeterminismEvidence | null>(null);
|
||||
|
||||
readonly expanded = signal(false);
|
||||
readonly showFragments = signal(false);
|
||||
|
||||
readonly status = computed<DeterminismStatus>(() => {
|
||||
return this.evidence()?.status ?? 'unknown';
|
||||
});
|
||||
|
||||
readonly statusClass = computed(() => {
|
||||
return `status-${this.status()}`;
|
||||
});
|
||||
|
||||
readonly statusLabel = computed(() => {
|
||||
switch (this.status()) {
|
||||
case 'verified':
|
||||
return 'Verified';
|
||||
case 'pending':
|
||||
return 'Pending';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
});
|
||||
|
||||
toggleExpanded(): void {
|
||||
this.expanded.update((v) => !v);
|
||||
}
|
||||
|
||||
toggleFragments(): void {
|
||||
this.showFragments.update((v) => !v);
|
||||
}
|
||||
|
||||
getFragmentClass(fragment: FragmentAttestation): string {
|
||||
return `fragment-${fragment.dsseStatus}`;
|
||||
}
|
||||
|
||||
formatDate(dateStr: string | undefined): string {
|
||||
if (!dateStr) return 'N/A';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
truncateHash(hash: string, length: number): string {
|
||||
if (hash.length <= length) return hash;
|
||||
return hash.slice(0, length) + '...';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,950 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
import {
|
||||
EntropyEvidence,
|
||||
EntropyFile,
|
||||
EntropyLayerSummary,
|
||||
EntropyWindow,
|
||||
} from '../../core/api/scanner.models';
|
||||
|
||||
type ViewMode = 'summary' | 'layers' | 'files';
|
||||
|
||||
@Component({
|
||||
selector: 'app-entropy-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="entropy-panel">
|
||||
<!-- Header with Summary -->
|
||||
<header class="entropy-panel__header">
|
||||
<div class="entropy-panel__title-row">
|
||||
<h3 class="entropy-panel__title">Entropy Analysis</h3>
|
||||
@if (downloadUrl()) {
|
||||
<a
|
||||
[href]="downloadUrl()"
|
||||
class="entropy-panel__download"
|
||||
download="entropy.report.json"
|
||||
aria-label="Download entropy report"
|
||||
>
|
||||
Download Report
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Overall Stats -->
|
||||
@if (layerSummary(); as summary) {
|
||||
<div class="entropy-panel__stats">
|
||||
<div class="stat-card" [class]="getPenaltyClass(summary.entropyPenalty)">
|
||||
<span class="stat-label">Entropy Penalty</span>
|
||||
<span class="stat-value">{{ (summary.entropyPenalty * 100).toFixed(1) }}%</span>
|
||||
<span class="stat-hint">max 30%</span>
|
||||
</div>
|
||||
<div class="stat-card" [class]="getRatioClass(summary.imageOpaqueRatio)">
|
||||
<span class="stat-label">Image Opaque Ratio</span>
|
||||
<span class="stat-value">{{ (summary.imageOpaqueRatio * 100).toFixed(1) }}%</span>
|
||||
<span class="stat-hint">of total bytes</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Layers Analyzed</span>
|
||||
<span class="stat-value">{{ summary.layers.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
|
||||
<!-- View Toggle -->
|
||||
<nav class="entropy-panel__nav" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="nav-tab"
|
||||
[class.active]="viewMode() === 'summary'"
|
||||
[attr.aria-selected]="viewMode() === 'summary'"
|
||||
(click)="setViewMode('summary')"
|
||||
>
|
||||
Summary
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="nav-tab"
|
||||
[class.active]="viewMode() === 'layers'"
|
||||
[attr.aria-selected]="viewMode() === 'layers'"
|
||||
(click)="setViewMode('layers')"
|
||||
>
|
||||
Layers
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="nav-tab"
|
||||
[class.active]="viewMode() === 'files'"
|
||||
[attr.aria-selected]="viewMode() === 'files'"
|
||||
(click)="setViewMode('files')"
|
||||
>
|
||||
Files
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="entropy-panel__content">
|
||||
<!-- Summary View -->
|
||||
@if (viewMode() === 'summary') {
|
||||
<section class="summary-view" role="tabpanel">
|
||||
<!-- Layer Donut Chart -->
|
||||
@if (layerSummary()?.layers?.length) {
|
||||
<div class="donut-section">
|
||||
<h4>Layer Distribution</h4>
|
||||
<div class="donut-chart" role="img" aria-label="Layer opaque ratio distribution">
|
||||
<svg viewBox="0 0 100 100" class="donut-svg">
|
||||
@for (segment of donutSegments(); track segment.digest; let i = $index) {
|
||||
<circle
|
||||
class="donut-segment"
|
||||
[attr.cx]="50"
|
||||
[attr.cy]="50"
|
||||
[attr.r]="40"
|
||||
fill="none"
|
||||
[attr.stroke]="segment.color"
|
||||
stroke-width="15"
|
||||
[attr.stroke-dasharray]="segment.dasharray"
|
||||
[attr.stroke-dashoffset]="segment.dashoffset"
|
||||
[attr.transform]="'rotate(-90 50 50)'"
|
||||
>
|
||||
<title>{{ segment.label }}: {{ (segment.ratio * 100).toFixed(1) }}% opaque</title>
|
||||
</circle>
|
||||
}
|
||||
<text x="50" y="50" class="donut-center-text" text-anchor="middle" dominant-baseline="middle">
|
||||
{{ (layerSummary()?.imageOpaqueRatio ?? 0) * 100 | number:'1.0-0' }}%
|
||||
</text>
|
||||
<text x="50" y="62" class="donut-center-label" text-anchor="middle">
|
||||
opaque
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
<ul class="donut-legend">
|
||||
@for (segment of donutSegments(); track segment.digest) {
|
||||
<li class="legend-item">
|
||||
<span class="legend-color" [style.background]="segment.color"></span>
|
||||
<span class="legend-label">{{ truncateHash(segment.digest, 12) }}</span>
|
||||
<span class="legend-value">{{ (segment.ratio * 100).toFixed(1) }}%</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Risk Indicators -->
|
||||
@if (allIndicators().length > 0) {
|
||||
<div class="indicators-section">
|
||||
<h4>Why Risky?</h4>
|
||||
<div class="risk-chips">
|
||||
@for (indicator of allIndicators(); track indicator.name) {
|
||||
<span
|
||||
class="risk-chip"
|
||||
[class]="'risk-chip--' + indicator.severity"
|
||||
[attr.title]="indicator.description"
|
||||
>
|
||||
<span class="chip-icon" aria-hidden="true">{{ indicator.icon }}</span>
|
||||
{{ indicator.name }}
|
||||
@if (indicator.count > 1) {
|
||||
<span class="chip-count">({{ indicator.count }})</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Layers View -->
|
||||
@if (viewMode() === 'layers') {
|
||||
<section class="layers-view" role="tabpanel">
|
||||
@if (layerSummary()?.layers?.length) {
|
||||
<ul class="layer-list">
|
||||
@for (layer of layerSummary()?.layers ?? []; track layer.digest) {
|
||||
<li class="layer-item">
|
||||
<div class="layer-header">
|
||||
<code class="layer-digest">{{ truncateHash(layer.digest, 20) }}</code>
|
||||
<span class="layer-ratio" [class]="getRatioClass(layer.opaqueRatio)">
|
||||
{{ (layer.opaqueRatio * 100).toFixed(1) }}% opaque
|
||||
</span>
|
||||
</div>
|
||||
<div class="layer-bar-container">
|
||||
<div
|
||||
class="layer-bar"
|
||||
[style.width.%]="layer.opaqueRatio * 100"
|
||||
[class]="getRatioClass(layer.opaqueRatio)"
|
||||
></div>
|
||||
</div>
|
||||
<div class="layer-details">
|
||||
<span class="layer-bytes">
|
||||
{{ formatBytes(layer.opaqueBytes) }} / {{ formatBytes(layer.totalBytes) }}
|
||||
</span>
|
||||
@if (layer.indicators.length > 0) {
|
||||
<div class="layer-indicators">
|
||||
@for (ind of layer.indicators; track ind) {
|
||||
<span class="indicator-tag">{{ ind }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="empty-message">No layer entropy data available.</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Files View -->
|
||||
@if (viewMode() === 'files') {
|
||||
<section class="files-view" role="tabpanel">
|
||||
@if (report()?.files?.length) {
|
||||
<ul class="file-list">
|
||||
@for (file of report()?.files ?? []; track file.path) {
|
||||
<li class="file-item" [class.expanded]="expandedFile() === file.path">
|
||||
<button
|
||||
type="button"
|
||||
class="file-header"
|
||||
(click)="toggleFileExpanded(file.path)"
|
||||
[attr.aria-expanded]="expandedFile() === file.path"
|
||||
>
|
||||
<span class="file-path">{{ file.path }}</span>
|
||||
<span class="file-ratio" [class]="getRatioClass(file.opaqueRatio)">
|
||||
{{ (file.opaqueRatio * 100).toFixed(1) }}%
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Entropy Heatmap -->
|
||||
<div class="file-heatmap" aria-label="Entropy heatmap for {{ file.path }}">
|
||||
@for (window of file.windows; track window.offset) {
|
||||
<div
|
||||
class="heatmap-cell"
|
||||
[style.background]="getEntropyColor(window.entropy)"
|
||||
[attr.title]="'Offset: ' + window.offset + ', Entropy: ' + window.entropy.toFixed(2)"
|
||||
></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (expandedFile() === file.path) {
|
||||
<div class="file-details">
|
||||
<dl class="file-meta">
|
||||
<dt>Size:</dt>
|
||||
<dd>{{ formatBytes(file.size) }}</dd>
|
||||
<dt>Opaque bytes:</dt>
|
||||
<dd>{{ formatBytes(file.opaqueBytes) }}</dd>
|
||||
<dt>Opaque ratio:</dt>
|
||||
<dd>{{ (file.opaqueRatio * 100).toFixed(2) }}%</dd>
|
||||
</dl>
|
||||
@if (file.flags.length > 0) {
|
||||
<div class="file-flags">
|
||||
<strong>Flags:</strong>
|
||||
@for (flag of file.flags; track flag) {
|
||||
<span class="flag-tag">{{ flag }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (file.windows.length > 0) {
|
||||
<div class="file-windows">
|
||||
<strong>High-entropy windows ({{ file.windows.length }}):</strong>
|
||||
<table class="windows-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Offset</th>
|
||||
<th>Length</th>
|
||||
<th>Entropy</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (w of file.windows.slice(0, 10); track w.offset) {
|
||||
<tr>
|
||||
<td>{{ w.offset }}</td>
|
||||
<td>{{ w.length }}</td>
|
||||
<td [class]="getEntropyClass(w.entropy)">{{ w.entropy.toFixed(3) }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@if (file.windows.length > 10) {
|
||||
<p class="more-windows">+ {{ file.windows.length - 10 }} more windows</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="empty-message">No file entropy data available.</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.entropy-panel {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.entropy-panel__header {
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.entropy-panel__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.entropy-panel__title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.entropy-panel__download {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #3b82f6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #3b82f6;
|
||||
font-size: 0.8125rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
|
||||
.entropy-panel__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
text-align: center;
|
||||
|
||||
&.severity-high {
|
||||
border-color: #fca5a5;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
&.severity-medium {
|
||||
border-color: #fcd34d;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
&.severity-low {
|
||||
border-color: #86efac;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.stat-hint {
|
||||
display: block;
|
||||
font-size: 0.6875rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.entropy-panel__nav {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #3b82f6;
|
||||
border-bottom-color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.entropy-panel__content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
// Donut Chart
|
||||
.donut-section {
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
.donut-chart {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.donut-svg {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.donut-center-text {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
fill: #111827;
|
||||
}
|
||||
|
||||
.donut-center-label {
|
||||
font-size: 8px;
|
||||
fill: #6b7280;
|
||||
}
|
||||
|
||||
.donut-legend {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.legend-value {
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
// Risk Chips
|
||||
.indicators-section {
|
||||
h4 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
.risk-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.risk-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
|
||||
&--high {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&--medium {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
&--low {
|
||||
background: #e5e7eb;
|
||||
color: #4b5563;
|
||||
}
|
||||
}
|
||||
|
||||
.chip-icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.chip-count {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
// Layers View
|
||||
.layer-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.layer-item {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.layer-digest {
|
||||
font-size: 0.75rem;
|
||||
background: #f3f4f6;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.layer-ratio {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
|
||||
&.severity-high {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&.severity-medium {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
&.severity-low {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-bar-container {
|
||||
height: 8px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.layer-bar {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s;
|
||||
|
||||
&.severity-high {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
&.severity-medium {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
&.severity-low {
|
||||
background: #22c55e;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.layer-bytes {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.layer-indicators {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.indicator-tag {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: #f3f4f6;
|
||||
border-radius: 2px;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
// Files View
|
||||
.file-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.5rem;
|
||||
overflow: hidden;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.file-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
background: #f9fafb;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
}
|
||||
|
||||
.file-path {
|
||||
font-family: monospace;
|
||||
font-size: 0.8125rem;
|
||||
color: #374151;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.file-ratio {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
margin-left: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.severity-high {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&.severity-medium {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
&.severity-low {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
}
|
||||
|
||||
.file-heatmap {
|
||||
display: flex;
|
||||
height: 8px;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.heatmap-cell {
|
||||
flex: 1;
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
.file-details {
|
||||
padding: 0.75rem;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.25rem 0.75rem;
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
|
||||
dt {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
.file-flags {
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
|
||||
strong {
|
||||
color: #374151;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.flag-tag {
|
||||
display: inline-block;
|
||||
margin-right: 0.25rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: #fef3c7;
|
||||
border-radius: 2px;
|
||||
font-size: 0.75rem;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.file-windows {
|
||||
font-size: 0.8125rem;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.windows-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.75rem;
|
||||
|
||||
th, td {
|
||||
padding: 0.375rem 0.5rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f9fafb;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
td {
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.more-windows {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.severity-high {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.severity-medium {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.severity-low {
|
||||
color: #15803d;
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EntropyPanelComponent {
|
||||
readonly evidence = input<EntropyEvidence | null>(null);
|
||||
readonly download = output<void>();
|
||||
|
||||
readonly viewMode = signal<ViewMode>('summary');
|
||||
readonly expandedFile = signal<string | null>(null);
|
||||
|
||||
readonly report = computed(() => this.evidence()?.report ?? null);
|
||||
readonly layerSummary = computed(() => this.evidence()?.layerSummary ?? null);
|
||||
readonly downloadUrl = computed(() => this.evidence()?.downloadUrl ?? null);
|
||||
|
||||
// Compute donut segments for layer visualization
|
||||
readonly donutSegments = computed(() => {
|
||||
const summary = this.layerSummary();
|
||||
if (!summary?.layers?.length) return [];
|
||||
|
||||
const colors = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#06b6d4'];
|
||||
const circumference = 2 * Math.PI * 40;
|
||||
let offset = 0;
|
||||
|
||||
return summary.layers.map((layer, i) => {
|
||||
const ratio = layer.totalBytes / summary.layers.reduce((sum, l) => sum + l.totalBytes, 0);
|
||||
const length = circumference * ratio;
|
||||
const segment = {
|
||||
digest: layer.digest,
|
||||
ratio: layer.opaqueRatio,
|
||||
color: colors[i % colors.length],
|
||||
dasharray: `${length} ${circumference - length}`,
|
||||
dashoffset: -offset,
|
||||
label: `Layer ${i + 1}`,
|
||||
};
|
||||
offset += length;
|
||||
return segment;
|
||||
});
|
||||
});
|
||||
|
||||
// Aggregate all indicators across layers
|
||||
readonly allIndicators = computed(() => {
|
||||
const summary = this.layerSummary();
|
||||
if (!summary?.layers?.length) return [];
|
||||
|
||||
const indicatorMap = new Map<string, { name: string; count: number; severity: string; description: string; icon: string }>();
|
||||
|
||||
const indicatorMeta: Record<string, { severity: string; description: string; icon: string }> = {
|
||||
'packed': { severity: 'high', description: 'File appears to be packed/compressed', icon: '!' },
|
||||
'no-symbols': { severity: 'medium', description: 'No debug symbols present', icon: '?' },
|
||||
'stripped': { severity: 'medium', description: 'Binary has been stripped', icon: '-' },
|
||||
'section:.UPX0': { severity: 'high', description: 'UPX packer detected', icon: '!' },
|
||||
'section:.UPX1': { severity: 'high', description: 'UPX packer detected', icon: '!' },
|
||||
'section:.aspack': { severity: 'high', description: 'ASPack packer detected', icon: '!' },
|
||||
};
|
||||
|
||||
for (const layer of summary.layers) {
|
||||
for (const ind of layer.indicators) {
|
||||
const existing = indicatorMap.get(ind);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
const meta = indicatorMeta[ind] ?? { severity: 'low', description: ind, icon: '*' };
|
||||
indicatorMap.set(ind, { name: ind, count: 1, ...meta });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(indicatorMap.values()).sort((a, b) => {
|
||||
const severityOrder = { high: 0, medium: 1, low: 2 };
|
||||
return (severityOrder[a.severity as keyof typeof severityOrder] ?? 3) -
|
||||
(severityOrder[b.severity as keyof typeof severityOrder] ?? 3);
|
||||
});
|
||||
});
|
||||
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
}
|
||||
|
||||
toggleFileExpanded(path: string): void {
|
||||
const current = this.expandedFile();
|
||||
this.expandedFile.set(current === path ? null : path);
|
||||
}
|
||||
|
||||
getPenaltyClass(penalty: number): string {
|
||||
if (penalty >= 0.2) return 'severity-high';
|
||||
if (penalty >= 0.1) return 'severity-medium';
|
||||
return 'severity-low';
|
||||
}
|
||||
|
||||
getRatioClass(ratio: number): string {
|
||||
if (ratio >= 0.3) return 'severity-high';
|
||||
if (ratio >= 0.15) return 'severity-medium';
|
||||
return 'severity-low';
|
||||
}
|
||||
|
||||
getEntropyClass(entropy: number): string {
|
||||
if (entropy >= 7.5) return 'severity-high';
|
||||
if (entropy >= 7.0) return 'severity-medium';
|
||||
return 'severity-low';
|
||||
}
|
||||
|
||||
getEntropyColor(entropy: number): string {
|
||||
// Map entropy (0-8) to color (green -> yellow -> red)
|
||||
const normalized = Math.min(entropy / 8, 1);
|
||||
if (normalized < 0.5) {
|
||||
// Green to Yellow
|
||||
const g = Math.round(255);
|
||||
const r = Math.round(normalized * 2 * 255);
|
||||
return `rgb(${r}, ${g}, 0)`;
|
||||
} else {
|
||||
// Yellow to Red
|
||||
const r = 255;
|
||||
const g = Math.round((1 - (normalized - 0.5) * 2) * 255);
|
||||
return `rgb(${r}, ${g}, 0)`;
|
||||
}
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
truncateHash(hash: string, length: number): string {
|
||||
if (hash.length <= length) return hash;
|
||||
return hash.slice(0, length) + '...';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
import { EntropyEvidence } from '../../core/api/scanner.models';
|
||||
|
||||
export type PolicyDecisionKind = 'pass' | 'warn' | 'block';
|
||||
|
||||
export interface EntropyPolicyThresholds {
|
||||
readonly blockImageOpaqueRatio: number; // Default 0.15
|
||||
readonly warnFileOpaqueRatio: number; // Default 0.30
|
||||
readonly maxEntropyPenalty: number; // Default 0.30
|
||||
}
|
||||
|
||||
export interface EntropyPolicyResult {
|
||||
readonly decision: PolicyDecisionKind;
|
||||
readonly reasons: readonly string[];
|
||||
readonly mitigations: readonly string[];
|
||||
readonly thresholds: EntropyPolicyThresholds;
|
||||
}
|
||||
|
||||
const DEFAULT_THRESHOLDS: EntropyPolicyThresholds = {
|
||||
blockImageOpaqueRatio: 0.15,
|
||||
warnFileOpaqueRatio: 0.30,
|
||||
maxEntropyPenalty: 0.30,
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-entropy-policy-banner',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div
|
||||
class="entropy-policy-banner"
|
||||
[class]="bannerClass()"
|
||||
role="alert"
|
||||
[attr.aria-live]="policyResult().decision === 'block' ? 'assertive' : 'polite'"
|
||||
>
|
||||
<!-- Banner Header -->
|
||||
<header class="banner-header">
|
||||
<span class="banner-icon" aria-hidden="true">
|
||||
@switch (policyResult().decision) {
|
||||
@case ('block') { ⚠ }
|
||||
@case ('warn') { ⚠ }
|
||||
@case ('pass') { ✓ }
|
||||
}
|
||||
</span>
|
||||
<h4 class="banner-title">{{ bannerTitle() }}</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="banner-toggle"
|
||||
(click)="toggleExpanded()"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
aria-controls="banner-details"
|
||||
>
|
||||
{{ expanded() ? 'Hide details' : 'Show details' }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Banner Summary -->
|
||||
<p class="banner-summary">{{ bannerSummary() }}</p>
|
||||
|
||||
<!-- Expanded Details -->
|
||||
@if (expanded()) {
|
||||
<div id="banner-details" class="banner-details">
|
||||
<!-- Reasons -->
|
||||
@if (policyResult().reasons.length > 0) {
|
||||
<section class="details-section">
|
||||
<h5>Why {{ policyResult().decision === 'block' ? 'Blocked' : policyResult().decision === 'warn' ? 'Warning' : 'Passed' }}</h5>
|
||||
<ul class="reason-list">
|
||||
@for (reason of policyResult().reasons; track reason) {
|
||||
<li>{{ reason }}</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Thresholds -->
|
||||
<section class="details-section">
|
||||
<h5>Policy Thresholds</h5>
|
||||
<dl class="threshold-list">
|
||||
<dt>Block when image opaque ratio exceeds:</dt>
|
||||
<dd>
|
||||
<span
|
||||
class="threshold-value"
|
||||
[class.exceeded]="isBlockThresholdExceeded()"
|
||||
>
|
||||
{{ (policyResult().thresholds.blockImageOpaqueRatio * 100).toFixed(0) }}%
|
||||
</span>
|
||||
@if (evidence()?.layerSummary?.imageOpaqueRatio !== undefined) {
|
||||
<span class="current-value">
|
||||
(current: {{ (evidence()!.layerSummary!.imageOpaqueRatio * 100).toFixed(1) }}%)
|
||||
</span>
|
||||
}
|
||||
</dd>
|
||||
<dt>Warn when any file opaque ratio exceeds:</dt>
|
||||
<dd>
|
||||
<span
|
||||
class="threshold-value"
|
||||
[class.exceeded]="isWarnThresholdExceeded()"
|
||||
>
|
||||
{{ (policyResult().thresholds.warnFileOpaqueRatio * 100).toFixed(0) }}%
|
||||
</span>
|
||||
@if (maxFileOpaqueRatio() !== null) {
|
||||
<span class="current-value">
|
||||
(max file: {{ (maxFileOpaqueRatio()! * 100).toFixed(1) }}%)
|
||||
</span>
|
||||
}
|
||||
</dd>
|
||||
<dt>Maximum entropy penalty:</dt>
|
||||
<dd>
|
||||
<span class="threshold-value">
|
||||
{{ (policyResult().thresholds.maxEntropyPenalty * 100).toFixed(0) }}%
|
||||
</span>
|
||||
@if (evidence()?.layerSummary?.entropyPenalty !== undefined) {
|
||||
<span class="current-value">
|
||||
(current: {{ (evidence()!.layerSummary!.entropyPenalty * 100).toFixed(1) }}%)
|
||||
</span>
|
||||
}
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<!-- Mitigations -->
|
||||
@if (policyResult().mitigations.length > 0) {
|
||||
<section class="details-section">
|
||||
<h5>Recommended Actions</h5>
|
||||
<ol class="mitigation-list">
|
||||
@for (mitigation of policyResult().mitigations; track mitigation) {
|
||||
<li>{{ mitigation }}</li>
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Download Link -->
|
||||
@if (downloadUrl()) {
|
||||
<section class="details-section">
|
||||
<h5>Evidence</h5>
|
||||
<a
|
||||
[href]="downloadUrl()"
|
||||
class="evidence-download"
|
||||
download="entropy.report.json"
|
||||
>
|
||||
<span class="download-icon" aria-hidden="true">↓</span>
|
||||
Download entropy.report.json
|
||||
</a>
|
||||
<p class="evidence-hint">
|
||||
Use this file for offline audits and to verify entropy calculations.
|
||||
</p>
|
||||
</section>
|
||||
}
|
||||
|
||||
<!-- Suppression Info -->
|
||||
<section class="details-section details-section--info">
|
||||
<h5>Suppression Options</h5>
|
||||
<p class="suppression-info">
|
||||
Entropy penalties can be suppressed when:
|
||||
</p>
|
||||
<ul class="suppression-list">
|
||||
<li>Debug symbols are present <strong>and</strong> provenance is attested</li>
|
||||
<li>An explicit policy waiver is configured for the tenant</li>
|
||||
<li>The package has verified provenance from a trusted source</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Tooltip Trigger -->
|
||||
<div class="banner-tooltip-container">
|
||||
<button
|
||||
type="button"
|
||||
class="tooltip-trigger"
|
||||
(mouseenter)="showTooltip.set(true)"
|
||||
(mouseleave)="showTooltip.set(false)"
|
||||
(focus)="showTooltip.set(true)"
|
||||
(blur)="showTooltip.set(false)"
|
||||
aria-describedby="entropy-tooltip"
|
||||
>
|
||||
<span aria-hidden="true">?</span>
|
||||
<span class="sr-only">More information about entropy policy</span>
|
||||
</button>
|
||||
@if (showTooltip()) {
|
||||
<div
|
||||
id="entropy-tooltip"
|
||||
class="tooltip"
|
||||
role="tooltip"
|
||||
>
|
||||
<p><strong>What is entropy analysis?</strong></p>
|
||||
<p>
|
||||
Entropy measures randomness in binary data. High entropy (>7.2 bits/byte)
|
||||
often indicates compressed, encrypted, or packed code that is difficult to audit.
|
||||
</p>
|
||||
<p>
|
||||
Opaque regions without provenance cannot be whitelisted without an explicit
|
||||
policy waiver.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.entropy-policy-banner {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&.decision-pass {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #86efac;
|
||||
}
|
||||
|
||||
&.decision-warn {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fcd34d;
|
||||
}
|
||||
|
||||
&.decision-block {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fca5a5;
|
||||
}
|
||||
}
|
||||
|
||||
.banner-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
font-size: 1.25rem;
|
||||
|
||||
.decision-pass & {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.decision-warn & {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.decision-block & {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
.banner-title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
|
||||
.decision-pass & {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.decision-warn & {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.decision-block & {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.banner-toggle {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.decision-pass & {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.decision-warn & {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.decision-block & {
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.banner-summary {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
|
||||
.decision-pass & {
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.decision-warn & {
|
||||
color: #78350f;
|
||||
}
|
||||
|
||||
.decision-block & {
|
||||
color: #991b1b;
|
||||
}
|
||||
}
|
||||
|
||||
.banner-details {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid currentColor;
|
||||
opacity: 0.3;
|
||||
|
||||
.decision-pass & {
|
||||
border-color: #86efac;
|
||||
}
|
||||
|
||||
.decision-warn & {
|
||||
border-color: #fcd34d;
|
||||
}
|
||||
|
||||
.decision-block & {
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
}
|
||||
|
||||
.details-section {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&--info {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
h5 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
}
|
||||
|
||||
.reason-list,
|
||||
.mitigation-list,
|
||||
.suppression-list {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #374151;
|
||||
|
||||
li {
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mitigation-list {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.threshold-list {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
|
||||
dt {
|
||||
color: #6b7280;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0.25rem 0 0;
|
||||
color: #111827;
|
||||
}
|
||||
}
|
||||
|
||||
.threshold-value {
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: #e5e7eb;
|
||||
border-radius: 4px;
|
||||
|
||||
&.exceeded {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.current-value {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.suppression-info {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.evidence-download {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #3b82f6;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #3b82f6;
|
||||
font-size: 0.8125rem;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.download-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-hint {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
// Tooltip
|
||||
.banner-tooltip-container {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.tooltip-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
cursor: help;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
width: 280px;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: #1f2937;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
z-index: 10;
|
||||
|
||||
p {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #e5e7eb;
|
||||
line-height: 1.5;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EntropyPolicyBannerComponent {
|
||||
readonly evidence = input<EntropyEvidence | null>(null);
|
||||
readonly customThresholds = input<Partial<EntropyPolicyThresholds>>({});
|
||||
|
||||
readonly expanded = signal(false);
|
||||
readonly showTooltip = signal(false);
|
||||
|
||||
readonly thresholds = computed<EntropyPolicyThresholds>(() => ({
|
||||
...DEFAULT_THRESHOLDS,
|
||||
...this.customThresholds(),
|
||||
}));
|
||||
|
||||
readonly downloadUrl = computed(() => this.evidence()?.downloadUrl ?? null);
|
||||
|
||||
readonly maxFileOpaqueRatio = computed(() => {
|
||||
const report = this.evidence()?.report;
|
||||
if (!report?.files?.length) return null;
|
||||
return Math.max(...report.files.map(f => f.opaqueRatio));
|
||||
});
|
||||
|
||||
readonly policyResult = computed<EntropyPolicyResult>(() => {
|
||||
const ev = this.evidence();
|
||||
const thresholds = this.thresholds();
|
||||
const reasons: string[] = [];
|
||||
const mitigations: string[] = [];
|
||||
let decision: PolicyDecisionKind = 'pass';
|
||||
|
||||
if (!ev?.layerSummary) {
|
||||
return { decision: 'pass', reasons: ['No entropy data available'], mitigations: [], thresholds };
|
||||
}
|
||||
|
||||
const summary = ev.layerSummary;
|
||||
const report = ev.report;
|
||||
|
||||
// Check block condition: imageOpaqueRatio > threshold AND provenance unknown
|
||||
if (summary.imageOpaqueRatio > thresholds.blockImageOpaqueRatio) {
|
||||
decision = 'block';
|
||||
reasons.push(
|
||||
`Image opaque ratio (${(summary.imageOpaqueRatio * 100).toFixed(1)}%) exceeds ` +
|
||||
`block threshold (${(thresholds.blockImageOpaqueRatio * 100).toFixed(0)}%)`
|
||||
);
|
||||
mitigations.push('Provide attestation of provenance for opaque binaries');
|
||||
mitigations.push('Unpack or decompress packed executables before scanning');
|
||||
}
|
||||
|
||||
// Check warn condition: any file with opaqueRatio > threshold
|
||||
if (report?.files) {
|
||||
const highOpaqueFiles = report.files.filter(f => f.opaqueRatio > thresholds.warnFileOpaqueRatio);
|
||||
if (highOpaqueFiles.length > 0) {
|
||||
if (decision !== 'block') {
|
||||
decision = 'warn';
|
||||
}
|
||||
reasons.push(
|
||||
`${highOpaqueFiles.length} file(s) exceed warn threshold ` +
|
||||
`(${(thresholds.warnFileOpaqueRatio * 100).toFixed(0)}% opaque)`
|
||||
);
|
||||
mitigations.push('Review high-entropy files for packed or obfuscated code');
|
||||
mitigations.push('Include debug symbols in builds where possible');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for packed indicators
|
||||
const packedLayers = summary.layers.filter(l =>
|
||||
l.indicators.some(i => i === 'packed' || i.startsWith('section:.UPX'))
|
||||
);
|
||||
if (packedLayers.length > 0) {
|
||||
if (decision !== 'block') {
|
||||
decision = 'warn';
|
||||
}
|
||||
reasons.push(`${packedLayers.length} layer(s) contain packed or compressed binaries`);
|
||||
mitigations.push('Use uncompressed binaries or provide packer provenance');
|
||||
}
|
||||
|
||||
// Check for stripped binaries without symbols
|
||||
const strippedLayers = summary.layers.filter(l =>
|
||||
l.indicators.some(i => i === 'stripped' || i === 'no-symbols')
|
||||
);
|
||||
if (strippedLayers.length > 0 && decision === 'pass') {
|
||||
reasons.push(`${strippedLayers.length} layer(s) contain stripped binaries without symbols`);
|
||||
// Only add mitigation if not already present
|
||||
if (!mitigations.includes('Include debug symbols in builds where possible')) {
|
||||
mitigations.push('Include debug symbols in builds where possible');
|
||||
}
|
||||
}
|
||||
|
||||
// Default pass reasons
|
||||
if (decision === 'pass' && reasons.length === 0) {
|
||||
reasons.push('All entropy metrics within acceptable thresholds');
|
||||
reasons.push(`Entropy penalty (${(summary.entropyPenalty * 100).toFixed(1)}%) is low`);
|
||||
}
|
||||
|
||||
return { decision, reasons, mitigations, thresholds };
|
||||
});
|
||||
|
||||
readonly bannerClass = computed(() => `decision-${this.policyResult().decision}`);
|
||||
|
||||
readonly bannerTitle = computed(() => {
|
||||
switch (this.policyResult().decision) {
|
||||
case 'block':
|
||||
return 'Entropy Policy: Blocked';
|
||||
case 'warn':
|
||||
return 'Entropy Policy: Warning';
|
||||
case 'pass':
|
||||
return 'Entropy Policy: Passed';
|
||||
}
|
||||
});
|
||||
|
||||
readonly bannerSummary = computed(() => {
|
||||
const result = this.policyResult();
|
||||
const ev = this.evidence();
|
||||
|
||||
switch (result.decision) {
|
||||
case 'block':
|
||||
return `This image is blocked due to high entropy/opaque content. ` +
|
||||
`Entropy penalty: ${((ev?.layerSummary?.entropyPenalty ?? 0) * 100).toFixed(1)}%`;
|
||||
case 'warn':
|
||||
return `This image has elevated entropy metrics that may indicate packed or obfuscated code. ` +
|
||||
`Entropy penalty: ${((ev?.layerSummary?.entropyPenalty ?? 0) * 100).toFixed(1)}%`;
|
||||
case 'pass':
|
||||
return `This image has acceptable entropy metrics. ` +
|
||||
`Entropy penalty: ${((ev?.layerSummary?.entropyPenalty ?? 0) * 100).toFixed(1)}%`;
|
||||
}
|
||||
});
|
||||
|
||||
isBlockThresholdExceeded(): boolean {
|
||||
const ratio = this.evidence()?.layerSummary?.imageOpaqueRatio;
|
||||
if (ratio === undefined) return false;
|
||||
return ratio > this.thresholds().blockImageOpaqueRatio;
|
||||
}
|
||||
|
||||
isWarnThresholdExceeded(): boolean {
|
||||
const maxRatio = this.maxFileOpaqueRatio();
|
||||
if (maxRatio === null) return false;
|
||||
return maxRatio > this.thresholds().warnFileOpaqueRatio;
|
||||
}
|
||||
|
||||
toggleExpanded(): void {
|
||||
this.expanded.update(v => !v);
|
||||
}
|
||||
}
|
||||
@@ -49,4 +49,31 @@
|
||||
<p *ngIf="!scan().attestation" class="attestation-empty">
|
||||
No attestation has been recorded for this scan.
|
||||
</p>
|
||||
|
||||
<!-- Determinism Evidence Section -->
|
||||
<section class="determinism-section">
|
||||
<h2>SBOM Determinism</h2>
|
||||
@if (scan().determinism) {
|
||||
<app-determinism-badge [evidence]="scan().determinism" />
|
||||
} @else {
|
||||
<p class="determinism-empty">
|
||||
No determinism evidence available for this scan.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Entropy Analysis Section -->
|
||||
<section class="entropy-section">
|
||||
<h2>Entropy Analysis</h2>
|
||||
@if (scan().entropy) {
|
||||
<!-- Policy Banner with thresholds and mitigations -->
|
||||
<app-entropy-policy-banner [evidence]="scan().entropy" />
|
||||
<!-- Detailed entropy visualization -->
|
||||
<app-entropy-panel [evidence]="scan().entropy" />
|
||||
} @else {
|
||||
<p class="entropy-empty">
|
||||
No entropy analysis available for this scan.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -77,3 +77,43 @@
|
||||
font-style: italic;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
// Determinism Section
|
||||
.determinism-section {
|
||||
border: 1px solid #1f2933;
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
background: #111827;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.125rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
.determinism-empty {
|
||||
font-style: italic;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Entropy Section
|
||||
.entropy-section {
|
||||
border: 1px solid #1f2933;
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
background: #111827;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.125rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
.entropy-empty {
|
||||
font-style: italic;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ScanAttestationPanelComponent } from './scan-attestation-panel.component';
|
||||
import { DeterminismBadgeComponent } from './determinism-badge.component';
|
||||
import { EntropyPanelComponent } from './entropy-panel.component';
|
||||
import { EntropyPolicyBannerComponent } from './entropy-policy-banner.component';
|
||||
import { ScanDetail } from '../../core/api/scanner.models';
|
||||
import {
|
||||
scanDetailWithFailedAttestation,
|
||||
@@ -24,7 +27,7 @@ const SCENARIO_MAP: Record<Scenario, ScanDetail> = {
|
||||
@Component({
|
||||
selector: 'app-scan-detail-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ScanAttestationPanelComponent],
|
||||
imports: [CommonModule, ScanAttestationPanelComponent, DeterminismBadgeComponent, EntropyPanelComponent, EntropyPolicyBannerComponent],
|
||||
templateUrl: './scan-detail-page.component.html',
|
||||
styleUrls: ['./scan-detail-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
<section class="aoc-dashboard">
|
||||
<header class="dashboard-header">
|
||||
<h1>Sources Dashboard</h1>
|
||||
<p class="subtitle">Attestation of Conformance (AOC) Metrics</p>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-container" aria-live="polite">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading dashboard...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading() && dashboard()) {
|
||||
<!-- Top Tiles Row -->
|
||||
<div class="tiles-row">
|
||||
<!-- Pass/Fail Tile -->
|
||||
<article class="tile tile--pass-fail">
|
||||
<header class="tile__header">
|
||||
<h2>AOC Pass Rate</h2>
|
||||
<span class="tile__period">Last 24h</span>
|
||||
</header>
|
||||
<div class="tile__content">
|
||||
<div class="pass-rate-display">
|
||||
<span class="pass-rate-value" [ngClass]="passRateClass()">{{ passRate() }}%</span>
|
||||
<span class="pass-rate-trend" [ngClass]="trendClass()">
|
||||
{{ trendIcon() }}
|
||||
<span class="sr-only">{{ dashboard()?.passFail.trend }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pass-fail-stats">
|
||||
<div class="stat stat--passed">
|
||||
<span class="stat-label">Passed</span>
|
||||
<span class="stat-value">{{ formatNumber(dashboard()!.passFail.passed) }}</span>
|
||||
</div>
|
||||
<div class="stat stat--failed">
|
||||
<span class="stat-label">Failed</span>
|
||||
<span class="stat-value">{{ formatNumber(dashboard()!.passFail.failed) }}</span>
|
||||
</div>
|
||||
<div class="stat stat--pending">
|
||||
<span class="stat-label">Pending</span>
|
||||
<span class="stat-value">{{ formatNumber(dashboard()!.passFail.pending) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mini Chart -->
|
||||
<div class="mini-chart" aria-label="Pass rate trend over 7 days">
|
||||
@for (point of chartData(); track point.timestamp) {
|
||||
<div
|
||||
class="chart-bar"
|
||||
[style.height.%]="point.height"
|
||||
[title]="formatShortDate(point.timestamp) + ': ' + point.value + '%'"
|
||||
></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Critical Violations Tile -->
|
||||
<article class="tile tile--violations">
|
||||
<header class="tile__header">
|
||||
<h2>Recent Violations</h2>
|
||||
@if (criticalViolations() > 0) {
|
||||
<span class="critical-badge">{{ criticalViolations() }} critical</span>
|
||||
}
|
||||
</header>
|
||||
<div class="tile__content">
|
||||
<ul class="violations-list">
|
||||
@for (violation of dashboard()!.recentViolations; track trackByCode($index, violation)) {
|
||||
<li
|
||||
class="violation-item"
|
||||
(click)="selectViolation(violation)"
|
||||
(keydown.enter)="selectViolation(violation)"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
>
|
||||
<span class="violation-severity" [ngClass]="getSeverityClass(violation.severity)">
|
||||
{{ violation.severity | uppercase }}
|
||||
</span>
|
||||
<span class="violation-code">{{ violation.code }}</span>
|
||||
<span class="violation-name">{{ violation.name }}</span>
|
||||
<span class="violation-count">{{ violation.count }}</span>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="no-violations">No recent violations</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Ingest Throughput Tile -->
|
||||
<article class="tile tile--throughput">
|
||||
<header class="tile__header">
|
||||
<h2>Ingest Throughput</h2>
|
||||
<span class="tile__period">Last 24h</span>
|
||||
</header>
|
||||
<div class="tile__content">
|
||||
<div class="throughput-summary">
|
||||
<div class="throughput-stat">
|
||||
<span class="throughput-value">{{ formatNumber(totalThroughput().docs) }}</span>
|
||||
<span class="throughput-label">Documents</span>
|
||||
</div>
|
||||
<div class="throughput-stat">
|
||||
<span class="throughput-value">{{ formatBytes(totalThroughput().bytes) }}</span>
|
||||
<span class="throughput-label">Total Size</span>
|
||||
</div>
|
||||
</div>
|
||||
<table class="throughput-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tenant</th>
|
||||
<th>Docs</th>
|
||||
<th>Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (tenant of dashboard()!.throughputByTenant; track trackByTenantId($index, tenant)) {
|
||||
<tr>
|
||||
<td>{{ tenant.tenantName }}</td>
|
||||
<td>{{ formatNumber(tenant.documentsIngested) }}</td>
|
||||
<td>{{ tenant.documentsPerMinute.toFixed(1) }}/min</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<!-- Sources Section -->
|
||||
<section class="sources-section">
|
||||
<header class="section-header">
|
||||
<h2>Sources</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="verify-button"
|
||||
(click)="startVerification()"
|
||||
[disabled]="verifying()"
|
||||
>
|
||||
@if (verifying()) {
|
||||
<span class="spinner-small"></span>
|
||||
Verifying...
|
||||
} @else {
|
||||
Verify Last 24h
|
||||
}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Verification Result -->
|
||||
@if (verificationRequest()) {
|
||||
<div class="verification-result" [class.verification-result--completed]="verificationRequest()!.status === 'completed'">
|
||||
<div class="verification-header">
|
||||
<span class="verification-status">
|
||||
@if (verificationRequest()!.status === 'completed') {
|
||||
Verification Complete
|
||||
} @else if (verificationRequest()!.status === 'running') {
|
||||
Verification Running...
|
||||
} @else {
|
||||
Verification {{ verificationRequest()!.status | titlecase }}
|
||||
}
|
||||
</span>
|
||||
@if (verificationRequest()!.completedAt) {
|
||||
<span class="verification-time">{{ formatDate(verificationRequest()!.completedAt!) }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (verificationRequest()!.status === 'completed') {
|
||||
<div class="verification-stats">
|
||||
<div class="verification-stat verification-stat--passed">
|
||||
<span class="stat-value">{{ verificationRequest()!.passed }}</span>
|
||||
<span class="stat-label">Passed</span>
|
||||
</div>
|
||||
<div class="verification-stat verification-stat--failed">
|
||||
<span class="stat-value">{{ verificationRequest()!.failed }}</span>
|
||||
<span class="stat-label">Failed</span>
|
||||
</div>
|
||||
<div class="verification-stat">
|
||||
<span class="stat-value">{{ verificationRequest()!.documentsVerified }}</span>
|
||||
<span class="stat-label">Total</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (verificationRequest()!.cliCommand) {
|
||||
<div class="cli-parity">
|
||||
<span class="cli-label">CLI Equivalent:</span>
|
||||
<code>{{ verificationRequest()!.cliCommand }}</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="sources-grid">
|
||||
@for (source of dashboard()!.sources; track trackBySourceId($index, source)) {
|
||||
<article class="source-card" [ngClass]="getSourceStatusClass(source)">
|
||||
<div class="source-header">
|
||||
<span class="source-icon" [attr.aria-label]="source.type">
|
||||
@switch (source.type) {
|
||||
@case ('registry') { <span>📦</span> }
|
||||
@case ('pipeline') { <span>🔄</span> }
|
||||
@case ('repository') { <span>📁</span> }
|
||||
@case ('manual') { <span>📤</span> }
|
||||
}
|
||||
</span>
|
||||
<div class="source-info">
|
||||
<h3>{{ source.name }}</h3>
|
||||
<span class="source-type">{{ source.type | titlecase }}</span>
|
||||
</div>
|
||||
<span class="source-status-badge">{{ source.status | titlecase }}</span>
|
||||
</div>
|
||||
<div class="source-stats">
|
||||
<div class="source-stat">
|
||||
<span class="source-stat-value">{{ source.checkCount }}</span>
|
||||
<span class="source-stat-label">Checks</span>
|
||||
</div>
|
||||
<div class="source-stat">
|
||||
<span class="source-stat-value">{{ (source.passRate * 100).toFixed(1) }}%</span>
|
||||
<span class="source-stat-label">Pass Rate</span>
|
||||
</div>
|
||||
</div>
|
||||
@if (source.recentViolations.length > 0) {
|
||||
<div class="source-violations">
|
||||
<span class="source-violations-label">Recent:</span>
|
||||
@for (v of source.recentViolations; track v.code) {
|
||||
<span class="source-violation-chip" [ngClass]="getSeverityClass(v.severity)">
|
||||
{{ v.code }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="source-last-check">
|
||||
Last check: {{ formatDate(source.lastCheck) }}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Violation Detail Modal -->
|
||||
@if (selectedViolation()) {
|
||||
<div
|
||||
class="modal-overlay"
|
||||
(click)="closeViolationDetail()"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[attr.aria-labelledby]="'violation-title'"
|
||||
>
|
||||
<div class="modal-content" (click)="$event.stopPropagation()">
|
||||
<header class="modal-header">
|
||||
<h2 id="violation-title">
|
||||
<span class="modal-code">{{ selectedViolation()!.code }}</span>
|
||||
{{ selectedViolation()!.name }}
|
||||
</h2>
|
||||
<button type="button" class="modal-close" (click)="closeViolationDetail()" aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<span class="violation-severity-large" [ngClass]="getSeverityClass(selectedViolation()!.severity)">
|
||||
{{ selectedViolation()!.severity | uppercase }}
|
||||
</span>
|
||||
<p class="violation-description">{{ selectedViolation()!.description }}</p>
|
||||
<dl class="violation-meta">
|
||||
<div>
|
||||
<dt>Occurrences</dt>
|
||||
<dd>{{ selectedViolation()!.count }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last Seen</dt>
|
||||
<dd>{{ formatDate(selectedViolation()!.lastSeen) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@if (selectedViolation()!.documentationUrl) {
|
||||
<a
|
||||
[href]="selectedViolation()!.documentationUrl"
|
||||
class="docs-link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
View Documentation →
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<footer class="modal-footer">
|
||||
<a
|
||||
[routerLink]="['/sources/violations', selectedViolation()!.code]"
|
||||
class="btn btn--primary"
|
||||
>
|
||||
View All Occurrences
|
||||
</a>
|
||||
<button type="button" class="btn btn--secondary" (click)="closeViolationDetail()">
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,752 @@
|
||||
.aoc-dashboard {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
color: #e2e8f0;
|
||||
background: #0f172a;
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
// Header
|
||||
.dashboard-header {
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Loading
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #334155;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// Tiles
|
||||
.tiles-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tile {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2933;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tile__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #1f2933;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.tile__period {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.tile__content {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
// Pass/Fail Tile
|
||||
.pass-rate-display {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pass-rate-value {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rate--excellent {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.rate--good {
|
||||
color: #84cc16;
|
||||
}
|
||||
|
||||
.rate--warning {
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.rate--critical {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.pass-rate-trend {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.trend--improving {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.trend--stable {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.trend--degrading {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.pass-fail-stats {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat--passed .stat-value {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.stat--failed .stat-value {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.stat--pending .stat-value {
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.mini-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
height: 40px;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.chart-bar {
|
||||
flex: 1;
|
||||
background: linear-gradient(to top, #3b82f6, #60a5fa);
|
||||
border-radius: 2px 2px 0 0;
|
||||
min-height: 4px;
|
||||
transition: height 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(to top, #2563eb, #3b82f6);
|
||||
}
|
||||
}
|
||||
|
||||
// Violations Tile
|
||||
.critical-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.violations-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.violation-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.625rem 0;
|
||||
border-bottom: 1px solid #1f2933;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
.violation-severity {
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.severity--critical {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.severity--high {
|
||||
background: rgba(249, 115, 22, 0.2);
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.severity--medium {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.severity--low {
|
||||
background: rgba(100, 116, 139, 0.2);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.severity--info {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.violation-code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.violation-name {
|
||||
font-size: 0.8125rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.violation-count {
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.no-violations {
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
padding: 1rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Throughput Tile
|
||||
.throughput-summary {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.throughput-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.throughput-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.throughput-label {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.throughput-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.25rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.5rem 0.25rem;
|
||||
border-bottom: 1px solid #1f2933;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Sources Section
|
||||
.sources-section {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.verify-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: #1d4ed8;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #f8fafc;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #1e40af;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner-small {
|
||||
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;
|
||||
}
|
||||
|
||||
// Verification Result
|
||||
.verification-result {
|
||||
background: #111827;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&--completed {
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.verification-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.verification-status {
|
||||
font-weight: 600;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.verification-time {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.verification-stats {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.verification-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
&--passed .stat-value {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
&--failed .stat-value {
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
.cli-parity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #0f172a;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.cli-label {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.cli-parity code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
// Sources Grid
|
||||
.sources-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.source-card {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2933;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: #334155;
|
||||
}
|
||||
}
|
||||
|
||||
.source-status--passed {
|
||||
border-left: 3px solid #22c55e;
|
||||
}
|
||||
|
||||
.source-status--failed {
|
||||
border-left: 3px solid #ef4444;
|
||||
}
|
||||
|
||||
.source-status--pending {
|
||||
border-left: 3px solid #eab308;
|
||||
}
|
||||
|
||||
.source-status--skipped {
|
||||
border-left: 3px solid #64748b;
|
||||
}
|
||||
|
||||
.source-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.source-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.source-info {
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.source-type {
|
||||
font-size: 0.6875rem;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.source-status-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.source-status--passed .source-status-badge {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.source-status--failed .source-status-badge {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.source-status--pending .source-status-badge {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.source-stats {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.source-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.source-stat-value {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.source-stat-label {
|
||||
font-size: 0.625rem;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.source-violations {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.source-violations-label {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.source-violation-chip {
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.625rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.source-last-check {
|
||||
font-size: 0.6875rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
// Modal
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2933;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem;
|
||||
border-bottom: 1px solid #1f2933;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: #94a3b8;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.violation-severity-large {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.violation-description {
|
||||
margin: 0 0 1rem;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.violation-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
margin: 0 0 1rem;
|
||||
|
||||
dt {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
}
|
||||
|
||||
.docs-link {
|
||||
display: inline-block;
|
||||
color: #3b82f6;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid #1f2933;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
border: none;
|
||||
|
||||
&--primary {
|
||||
background: #1d4ed8;
|
||||
color: #f8fafc;
|
||||
|
||||
&:hover {
|
||||
background: #1e40af;
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: #334155;
|
||||
color: #e2e8f0;
|
||||
|
||||
&:hover {
|
||||
background: #475569;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Screen reader only
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import {
|
||||
AocDashboardSummary,
|
||||
AocViolationCode,
|
||||
IngestThroughput,
|
||||
AocSource,
|
||||
ViolationSeverity,
|
||||
VerificationRequest,
|
||||
} from '../../core/api/aoc.models';
|
||||
import { AOC_API, MockAocApi } from '../../core/api/aoc.client';
|
||||
|
||||
@Component({
|
||||
selector: 'app-aoc-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule],
|
||||
providers: [{ provide: AOC_API, useClass: MockAocApi }],
|
||||
templateUrl: './aoc-dashboard.component.html',
|
||||
styleUrls: ['./aoc-dashboard.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AocDashboardComponent implements OnInit {
|
||||
private readonly aocApi = inject(AOC_API);
|
||||
|
||||
// State
|
||||
readonly dashboard = signal<AocDashboardSummary | null>(null);
|
||||
readonly loading = signal(true);
|
||||
readonly verificationRequest = signal<VerificationRequest | null>(null);
|
||||
readonly verifying = signal(false);
|
||||
readonly selectedViolation = signal<AocViolationCode | null>(null);
|
||||
|
||||
// Computed values
|
||||
readonly passRate = computed(() => {
|
||||
const dash = this.dashboard();
|
||||
return dash ? Math.round(dash.passFail.passRate * 100) : 0;
|
||||
});
|
||||
|
||||
readonly passRateClass = computed(() => {
|
||||
const rate = this.passRate();
|
||||
if (rate >= 95) return 'rate--excellent';
|
||||
if (rate >= 85) return 'rate--good';
|
||||
if (rate >= 70) return 'rate--warning';
|
||||
return 'rate--critical';
|
||||
});
|
||||
|
||||
readonly trendIcon = computed(() => {
|
||||
const trend = this.dashboard()?.passFail.trend;
|
||||
if (trend === 'improving') return '↑';
|
||||
if (trend === 'degrading') return '↓';
|
||||
return '→';
|
||||
});
|
||||
|
||||
readonly trendClass = computed(() => {
|
||||
const trend = this.dashboard()?.passFail.trend;
|
||||
if (trend === 'improving') return 'trend--improving';
|
||||
if (trend === 'degrading') return 'trend--degrading';
|
||||
return 'trend--stable';
|
||||
});
|
||||
|
||||
readonly totalThroughput = computed(() => {
|
||||
const dash = this.dashboard();
|
||||
if (!dash) return { docs: 0, bytes: 0 };
|
||||
return dash.throughputByTenant.reduce(
|
||||
(acc, t) => ({
|
||||
docs: acc.docs + t.documentsIngested,
|
||||
bytes: acc.bytes + t.bytesIngested,
|
||||
}),
|
||||
{ docs: 0, bytes: 0 }
|
||||
);
|
||||
});
|
||||
|
||||
readonly criticalViolations = computed(() => {
|
||||
const dash = this.dashboard();
|
||||
if (!dash) return 0;
|
||||
return dash.recentViolations
|
||||
.filter((v) => v.severity === 'critical')
|
||||
.reduce((sum, v) => sum + v.count, 0);
|
||||
});
|
||||
|
||||
readonly chartData = computed(() => {
|
||||
const dash = this.dashboard();
|
||||
if (!dash) return [];
|
||||
const history = dash.passFail.history;
|
||||
const max = Math.max(...history.map((p) => p.value));
|
||||
return history.map((p) => ({
|
||||
timestamp: p.timestamp,
|
||||
value: p.value,
|
||||
height: (p.value / max) * 100,
|
||||
}));
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadDashboard();
|
||||
}
|
||||
|
||||
private loadDashboard(): void {
|
||||
this.loading.set(true);
|
||||
this.aocApi.getDashboardSummary().subscribe({
|
||||
next: (summary) => {
|
||||
this.dashboard.set(summary);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load AOC dashboard:', err);
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
startVerification(): void {
|
||||
this.verifying.set(true);
|
||||
this.verificationRequest.set(null);
|
||||
|
||||
this.aocApi.startVerification().subscribe({
|
||||
next: (request) => {
|
||||
this.verificationRequest.set(request);
|
||||
// Poll for status updates (simplified - in real app would use interval)
|
||||
setTimeout(() => this.pollVerificationStatus(request.requestId), 2000);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to start verification:', err);
|
||||
this.verifying.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private pollVerificationStatus(requestId: string): void {
|
||||
this.aocApi.getVerificationStatus(requestId).subscribe({
|
||||
next: (request) => {
|
||||
this.verificationRequest.set(request);
|
||||
if (request.status === 'completed' || request.status === 'failed') {
|
||||
this.verifying.set(false);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to get verification status:', err);
|
||||
this.verifying.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
selectViolation(violation: AocViolationCode): void {
|
||||
this.selectedViolation.set(violation);
|
||||
}
|
||||
|
||||
closeViolationDetail(): void {
|
||||
this.selectedViolation.set(null);
|
||||
}
|
||||
|
||||
getSeverityClass(severity: ViolationSeverity): string {
|
||||
return `severity--${severity}`;
|
||||
}
|
||||
|
||||
getSourceStatusClass(source: AocSource): string {
|
||||
return `source-status--${source.status}`;
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
formatNumber(num: number): string {
|
||||
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
||||
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
formatDate(isoString: string): string {
|
||||
try {
|
||||
return new Date(isoString).toLocaleString();
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
formatShortDate(isoString: string): string {
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
trackByCode(_index: number, violation: AocViolationCode): string {
|
||||
return violation.code;
|
||||
}
|
||||
|
||||
trackByTenantId(_index: number, throughput: IngestThroughput): string {
|
||||
return throughput.tenantId;
|
||||
}
|
||||
|
||||
trackBySourceId(_index: number, source: AocSource): string {
|
||||
return source.sourceId;
|
||||
}
|
||||
}
|
||||
2
src/Web/StellaOps.Web/src/app/features/sources/index.ts
Normal file
2
src/Web/StellaOps.Web/src/app/features/sources/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AocDashboardComponent } from './aoc-dashboard.component';
|
||||
export { ViolationDetailComponent } from './violation-detail.component';
|
||||
@@ -0,0 +1,527 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute, RouterModule } from '@angular/router';
|
||||
import {
|
||||
ViolationDetail,
|
||||
ViolationSeverity,
|
||||
OffendingField,
|
||||
} from '../../core/api/aoc.models';
|
||||
import { AOC_API, MockAocApi } from '../../core/api/aoc.client';
|
||||
|
||||
@Component({
|
||||
selector: 'app-violation-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule],
|
||||
providers: [{ provide: AOC_API, useClass: MockAocApi }],
|
||||
template: `
|
||||
<section class="violation-detail">
|
||||
<header class="detail-header">
|
||||
<a routerLink="/sources" class="back-link">← Sources Dashboard</a>
|
||||
<h1>
|
||||
<span class="violation-code">{{ code() }}</span>
|
||||
Violation Details
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-container">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading violations...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading() && violations().length === 0) {
|
||||
<div class="empty-state">
|
||||
<p>No violations found for code {{ code() }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading() && violations().length > 0) {
|
||||
<div class="violation-summary">
|
||||
<span class="violation-count">{{ violations().length }} occurrence(s)</span>
|
||||
<span class="severity-badge" [ngClass]="getSeverityClass(violations()[0].severity)">
|
||||
{{ violations()[0].severity | uppercase }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="violations-list">
|
||||
@for (violation of violations(); track violation.violationId) {
|
||||
<article class="violation-card">
|
||||
<header class="violation-card__header">
|
||||
<div class="document-info">
|
||||
<span class="document-type">{{ violation.documentType | titlecase }}</span>
|
||||
<code class="document-id">{{ violation.documentId }}</code>
|
||||
</div>
|
||||
<span class="detected-at">{{ formatDate(violation.detectedAt) }}</span>
|
||||
</header>
|
||||
|
||||
<!-- Offending Fields Section -->
|
||||
<section class="offending-fields">
|
||||
<h3>Offending Fields</h3>
|
||||
<div class="fields-list">
|
||||
@for (field of violation.offendingFields; track field.path) {
|
||||
<div class="field-item">
|
||||
<div class="field-path">
|
||||
<span class="path-label">Path:</span>
|
||||
<code>{{ field.path }}</code>
|
||||
</div>
|
||||
<div class="field-values">
|
||||
@if (field.expectedValue) {
|
||||
<div class="expected">
|
||||
<span class="value-label">Expected:</span>
|
||||
<code class="value-code">{{ field.expectedValue }}</code>
|
||||
</div>
|
||||
}
|
||||
<div class="actual">
|
||||
<span class="value-label">Actual:</span>
|
||||
<code class="value-code value-code--error">
|
||||
{{ field.actualValue ?? '(missing)' }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-reason">
|
||||
<span class="reason-icon" aria-hidden="true">!</span>
|
||||
{{ field.reason }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Provenance Metadata Section -->
|
||||
<section class="provenance-section">
|
||||
<h3>Provenance Metadata</h3>
|
||||
<dl class="provenance-grid">
|
||||
<div>
|
||||
<dt>Source Type</dt>
|
||||
<dd>{{ violation.provenance.sourceType | titlecase }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Source URI</dt>
|
||||
<dd><code>{{ violation.provenance.sourceUri }}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Ingested At</dt>
|
||||
<dd>{{ formatDate(violation.provenance.ingestedAt) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Ingested By</dt>
|
||||
<dd>{{ violation.provenance.ingestedBy }}</dd>
|
||||
</div>
|
||||
@if (violation.provenance.buildId) {
|
||||
<div>
|
||||
<dt>Build ID</dt>
|
||||
<dd><code>{{ violation.provenance.buildId }}</code></dd>
|
||||
</div>
|
||||
}
|
||||
@if (violation.provenance.commitSha) {
|
||||
<div>
|
||||
<dt>Commit SHA</dt>
|
||||
<dd><code>{{ violation.provenance.commitSha }}</code></dd>
|
||||
</div>
|
||||
}
|
||||
@if (violation.provenance.pipelineUrl) {
|
||||
<div class="full-width">
|
||||
<dt>Pipeline URL</dt>
|
||||
<dd>
|
||||
<a [href]="violation.provenance.pipelineUrl" target="_blank" rel="noopener">
|
||||
{{ violation.provenance.pipelineUrl }}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<!-- Suggestion Section -->
|
||||
@if (violation.suggestion) {
|
||||
<section class="suggestion-section">
|
||||
<h3>Suggested Fix</h3>
|
||||
<div class="suggestion-content">
|
||||
<span class="suggestion-icon" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 16v-4M12 8h.01"/>
|
||||
</svg>
|
||||
</span>
|
||||
<p>{{ violation.suggestion }}</p>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.violation-detail {
|
||||
padding: 1.5rem;
|
||||
color: #e2e8f0;
|
||||
background: #0f172a;
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #94a3b8;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
|
||||
&:hover {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.violation-code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #334155;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.violation-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.violation-count {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.severity-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.severity--critical {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.severity--high {
|
||||
background: rgba(249, 115, 22, 0.2);
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.severity--medium {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.severity--low {
|
||||
background: rgba(100, 116, 139, 0.2);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.violations-list {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.violation-card {
|
||||
background: #111827;
|
||||
border: 1px solid #1f2933;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.violation-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
background: #0f172a;
|
||||
border-bottom: 1px solid #1f2933;
|
||||
}
|
||||
|
||||
.document-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.document-type {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #3b82f6;
|
||||
border-radius: 4px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.document-id {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.8125rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.detected-at {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.offending-fields,
|
||||
.provenance-section,
|
||||
.suggestion-section {
|
||||
padding: 1.25rem;
|
||||
border-bottom: 1px solid #1f2933;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.fields-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.field-item {
|
||||
padding: 1rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2933;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.field-path {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.path-label {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.field-path code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.875rem;
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
.field-values {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.expected,
|
||||
.actual {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.value-label {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.value-code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.8125rem;
|
||||
color: #22c55e;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.value-code--error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.field-reason {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-radius: 4px;
|
||||
color: #fca5a5;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.reason-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #ef4444;
|
||||
color: #111827;
|
||||
border-radius: 50%;
|
||||
font-size: 0.625rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.provenance-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
|
||||
dt {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
|
||||
code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: #94a3b8;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestion-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
border-radius: 6px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #93c5fd;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestion-icon {
|
||||
flex-shrink: 0;
|
||||
color: #3b82f6;
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ViolationDetailComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly aocApi = inject(AOC_API);
|
||||
|
||||
readonly code = signal('');
|
||||
readonly violations = signal<readonly ViolationDetail[]>([]);
|
||||
readonly loading = signal(true);
|
||||
|
||||
ngOnInit(): void {
|
||||
const codeParam = this.route.snapshot.paramMap.get('code');
|
||||
if (codeParam) {
|
||||
this.code.set(codeParam);
|
||||
this.loadViolations(codeParam);
|
||||
}
|
||||
}
|
||||
|
||||
private loadViolations(code: string): void {
|
||||
this.loading.set(true);
|
||||
this.aocApi.getViolationsByCode(code).subscribe({
|
||||
next: (violations) => {
|
||||
this.violations.set(violations);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load violations:', err);
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getSeverityClass(severity: ViolationSeverity): string {
|
||||
return `severity--${severity}`;
|
||||
}
|
||||
|
||||
formatDate(isoString: string): string {
|
||||
try {
|
||||
return new Date(isoString).toLocaleString();
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,229 @@
|
||||
import { ScanDetail } from '../core/api/scanner.models';
|
||||
import { DeterminismEvidence, EntropyEvidence, ScanDetail } from '../core/api/scanner.models';
|
||||
|
||||
// Mock determinism evidence for verified scan
|
||||
const verifiedDeterminism: DeterminismEvidence = {
|
||||
status: 'verified',
|
||||
merkleRoot: 'sha256:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
|
||||
merkleRootConsistent: true,
|
||||
contentHash: 'sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210',
|
||||
verifiedAt: '2025-10-23T12:05:00Z',
|
||||
compositionManifest: {
|
||||
compositionUri: 'cas://stellaops/scans/scan-verified-001/_composition.json',
|
||||
merkleRoot: 'sha256:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
|
||||
fragmentCount: 3,
|
||||
createdAt: '2025-10-20T18:22:00Z',
|
||||
fragments: [
|
||||
{
|
||||
layerDigest: 'sha256:layer1abc123def456789012345678901234567890abcdef12345678901234',
|
||||
fragmentSha256: 'sha256:frag1111111111111111111111111111111111111111111111111111111111',
|
||||
dsseEnvelopeSha256: 'sha256:dsse1111111111111111111111111111111111111111111111111111111111',
|
||||
dsseStatus: 'verified',
|
||||
verifiedAt: '2025-10-23T12:04:55Z',
|
||||
},
|
||||
{
|
||||
layerDigest: 'sha256:layer2def456abc789012345678901234567890abcdef12345678901234',
|
||||
fragmentSha256: 'sha256:frag2222222222222222222222222222222222222222222222222222222222',
|
||||
dsseEnvelopeSha256: 'sha256:dsse2222222222222222222222222222222222222222222222222222222222',
|
||||
dsseStatus: 'verified',
|
||||
verifiedAt: '2025-10-23T12:04:56Z',
|
||||
},
|
||||
{
|
||||
layerDigest: 'sha256:layer3ghi789jkl012345678901234567890abcdef12345678901234',
|
||||
fragmentSha256: 'sha256:frag3333333333333333333333333333333333333333333333333333333333',
|
||||
dsseEnvelopeSha256: 'sha256:dsse3333333333333333333333333333333333333333333333333333333333',
|
||||
dsseStatus: 'verified',
|
||||
verifiedAt: '2025-10-23T12:04:57Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
stellaProperties: {
|
||||
'stellaops:stella.contentHash': 'sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210',
|
||||
'stellaops:composition.manifest': 'cas://stellaops/scans/scan-verified-001/_composition.json',
|
||||
'stellaops:merkle.root': 'sha256:a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock determinism evidence for failed scan
|
||||
const failedDeterminism: DeterminismEvidence = {
|
||||
status: 'failed',
|
||||
merkleRoot: 'sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
merkleRootConsistent: false,
|
||||
verifiedAt: '2025-10-23T09:18:15Z',
|
||||
failureReason: 'Merkle root mismatch: computed root does not match stored root. Fragment at layer sha256:layer2def... has inconsistent hash.',
|
||||
compositionManifest: {
|
||||
compositionUri: 'cas://stellaops/scans/scan-failed-002/_composition.json',
|
||||
merkleRoot: 'sha256:expected000000000000000000000000000000000000000000000000000000',
|
||||
fragmentCount: 2,
|
||||
createdAt: '2025-10-19T07:14:30Z',
|
||||
fragments: [
|
||||
{
|
||||
layerDigest: 'sha256:layer1abc123fail456789012345678901234567890abcdef12345678901234',
|
||||
fragmentSha256: 'sha256:fragfail11111111111111111111111111111111111111111111111111111',
|
||||
dsseEnvelopeSha256: 'sha256:dssefail11111111111111111111111111111111111111111111111111111',
|
||||
dsseStatus: 'verified',
|
||||
verifiedAt: '2025-10-23T09:18:10Z',
|
||||
},
|
||||
{
|
||||
layerDigest: 'sha256:layer2def456fail789012345678901234567890abcdef12345678901234',
|
||||
fragmentSha256: 'sha256:fragfail22222222222222222222222222222222222222222222222222222',
|
||||
dsseEnvelopeSha256: 'sha256:dssefail22222222222222222222222222222222222222222222222222222',
|
||||
dsseStatus: 'failed',
|
||||
verifiedAt: '2025-10-23T09:18:12Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
stellaProperties: {
|
||||
'stellaops:stella.contentHash': 'sha256:mismatch0000000000000000000000000000000000000000000000000000',
|
||||
'stellaops:composition.manifest': 'cas://stellaops/scans/scan-failed-002/_composition.json',
|
||||
'stellaops:merkle.root': 'sha256:expected000000000000000000000000000000000000000000000000000000',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock entropy evidence for verified scan (low risk)
|
||||
const verifiedEntropy: EntropyEvidence = {
|
||||
layerSummary: {
|
||||
schema: 'stellaops.entropy/layer-summary@1',
|
||||
generatedAt: '2025-10-20T18:22:00Z',
|
||||
imageDigest: 'sha256:9f92a8c39f8d4f7bb1a60f2be650b3019b9a1bb50d2da839efa9bf2a278a0071',
|
||||
layers: [
|
||||
{
|
||||
digest: 'sha256:base-layer-001',
|
||||
opaqueBytes: 102400,
|
||||
totalBytes: 10485760,
|
||||
opaqueRatio: 0.01,
|
||||
indicators: [],
|
||||
},
|
||||
{
|
||||
digest: 'sha256:app-layer-002',
|
||||
opaqueBytes: 524288,
|
||||
totalBytes: 5242880,
|
||||
opaqueRatio: 0.10,
|
||||
indicators: ['no-symbols'],
|
||||
},
|
||||
{
|
||||
digest: 'sha256:deps-layer-003',
|
||||
opaqueBytes: 204800,
|
||||
totalBytes: 2097152,
|
||||
opaqueRatio: 0.10,
|
||||
indicators: [],
|
||||
},
|
||||
],
|
||||
imageOpaqueRatio: 0.05,
|
||||
entropyPenalty: 0.03,
|
||||
},
|
||||
report: {
|
||||
schema: 'stellaops.entropy/report@1',
|
||||
generatedAt: '2025-10-20T18:22:00Z',
|
||||
imageDigest: 'sha256:9f92a8c39f8d4f7bb1a60f2be650b3019b9a1bb50d2da839efa9bf2a278a0071',
|
||||
files: [
|
||||
{
|
||||
path: '/usr/bin/app',
|
||||
size: 2097152,
|
||||
opaqueBytes: 209715,
|
||||
opaqueRatio: 0.10,
|
||||
flags: ['no-symbols'],
|
||||
windows: [
|
||||
{ offset: 0, length: 4096, entropy: 5.2 },
|
||||
{ offset: 4096, length: 4096, entropy: 6.1 },
|
||||
{ offset: 8192, length: 4096, entropy: 7.3 },
|
||||
{ offset: 12288, length: 4096, entropy: 6.8 },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/usr/lib/libcrypto.so',
|
||||
size: 1048576,
|
||||
opaqueBytes: 52428,
|
||||
opaqueRatio: 0.05,
|
||||
flags: [],
|
||||
windows: [
|
||||
{ offset: 0, length: 4096, entropy: 4.5 },
|
||||
{ offset: 4096, length: 4096, entropy: 5.8 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
downloadUrl: '/api/v1/scans/scan-verified-001/entropy',
|
||||
};
|
||||
|
||||
// Mock entropy evidence for failed scan (high risk)
|
||||
const failedEntropy: EntropyEvidence = {
|
||||
layerSummary: {
|
||||
schema: 'stellaops.entropy/layer-summary@1',
|
||||
generatedAt: '2025-10-19T07:14:30Z',
|
||||
imageDigest: 'sha256:b0d6865de537e45bdd9dd72cdac02bc6f459f0e546ed9134e2afc2fccd6298e0',
|
||||
layers: [
|
||||
{
|
||||
digest: 'sha256:base-layer-fail-001',
|
||||
opaqueBytes: 1048576,
|
||||
totalBytes: 5242880,
|
||||
opaqueRatio: 0.20,
|
||||
indicators: ['stripped'],
|
||||
},
|
||||
{
|
||||
digest: 'sha256:packed-layer-fail-002',
|
||||
opaqueBytes: 3145728,
|
||||
totalBytes: 4194304,
|
||||
opaqueRatio: 0.75,
|
||||
indicators: ['packed', 'section:.UPX0', 'no-symbols'],
|
||||
},
|
||||
],
|
||||
imageOpaqueRatio: 0.45,
|
||||
entropyPenalty: 0.25,
|
||||
},
|
||||
report: {
|
||||
schema: 'stellaops.entropy/report@1',
|
||||
generatedAt: '2025-10-19T07:14:30Z',
|
||||
imageDigest: 'sha256:b0d6865de537e45bdd9dd72cdac02bc6f459f0e546ed9134e2afc2fccd6298e0',
|
||||
files: [
|
||||
{
|
||||
path: '/opt/app/suspicious_binary',
|
||||
size: 3145728,
|
||||
opaqueBytes: 2831155,
|
||||
opaqueRatio: 0.90,
|
||||
flags: ['packed', 'section:.UPX0', 'stripped', 'no-symbols'],
|
||||
windows: [
|
||||
{ offset: 0, length: 4096, entropy: 7.92 },
|
||||
{ offset: 1024, length: 4096, entropy: 7.88 },
|
||||
{ offset: 2048, length: 4096, entropy: 7.95 },
|
||||
{ offset: 3072, length: 4096, entropy: 7.91 },
|
||||
{ offset: 4096, length: 4096, entropy: 7.89 },
|
||||
{ offset: 5120, length: 4096, entropy: 7.94 },
|
||||
{ offset: 6144, length: 4096, entropy: 7.87 },
|
||||
{ offset: 7168, length: 4096, entropy: 7.93 },
|
||||
{ offset: 8192, length: 4096, entropy: 7.90 },
|
||||
{ offset: 9216, length: 4096, entropy: 7.86 },
|
||||
{ offset: 10240, length: 4096, entropy: 7.91 },
|
||||
{ offset: 11264, length: 4096, entropy: 7.88 },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/opt/app/libblob.so',
|
||||
size: 524288,
|
||||
opaqueBytes: 314573,
|
||||
opaqueRatio: 0.60,
|
||||
flags: ['stripped', 'no-symbols'],
|
||||
windows: [
|
||||
{ offset: 0, length: 4096, entropy: 7.45 },
|
||||
{ offset: 1024, length: 4096, entropy: 7.38 },
|
||||
{ offset: 2048, length: 4096, entropy: 7.52 },
|
||||
{ offset: 3072, length: 4096, entropy: 7.41 },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/usr/local/bin/helper',
|
||||
size: 102400,
|
||||
opaqueBytes: 30720,
|
||||
opaqueRatio: 0.30,
|
||||
flags: ['no-symbols'],
|
||||
windows: [
|
||||
{ offset: 0, length: 4096, entropy: 7.22 },
|
||||
{ offset: 4096, length: 4096, entropy: 6.95 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
downloadUrl: '/api/v1/scans/scan-failed-002/entropy',
|
||||
};
|
||||
|
||||
export const scanDetailWithVerifiedAttestation: ScanDetail = {
|
||||
scanId: 'scan-verified-001',
|
||||
@@ -13,6 +238,8 @@ export const scanDetailWithVerifiedAttestation: ScanDetail = {
|
||||
checkedAt: '2025-10-23T12:04:52Z',
|
||||
statusMessage: 'Rekor transparency log inclusion proof verified.',
|
||||
},
|
||||
determinism: verifiedDeterminism,
|
||||
entropy: verifiedEntropy,
|
||||
};
|
||||
|
||||
export const scanDetailWithFailedAttestation: ScanDetail = {
|
||||
@@ -27,4 +254,6 @@ export const scanDetailWithFailedAttestation: ScanDetail = {
|
||||
statusMessage:
|
||||
'Verification failed: inclusion proof leaf hash mismatch at depth 4.',
|
||||
},
|
||||
determinism: failedDeterminism,
|
||||
entropy: failedEntropy,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user