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

This commit is contained in:
StellaOps Bot
2025-11-27 08:51:10 +02:00
parent ea970ead2a
commit c34fb7256d
126 changed files with 18553 additions and 693 deletions

View File

@@ -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: () =>

View 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));
}
}

View 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;
}

View 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));
}
}

View 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',
},
};

View 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));
}
}

View 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;
}

View File

@@ -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;
}

View 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';

View File

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

View 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));
}

View File

@@ -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);
},
});
}
}

View File

@@ -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">&times;</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">&#8595;</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 ? '&#10003;' : '&#10007;' }}
</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

View File

@@ -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;
}
}

View File

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

View File

@@ -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);

View File

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

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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() ? '&#9650;' : '&#9660;' }}</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">&#9200;</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) {
&#10003;
} @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 &rarr;
</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());
}
}

View File

@@ -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') { &#10003; }
@case ('pending') { &#8987; }
@case ('failed') { &#10007; }
@default { ? }
}
</span>
<span class="determinism-badge__label">
Determinism: {{ statusLabel() }}
</span>
<span class="determinism-badge__toggle" aria-hidden="true">
{{ expanded() ? '&#9650;' : '&#9660;' }}
</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') { &#10003; }
@case ('pending') { &#8987; }
@case ('failed') { &#10007; }
}
</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) + '...';
}
}

View File

@@ -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) + '...';
}
}

View File

@@ -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') { &#9888; }
@case ('warn') { &#9888; }
@case ('pass') { &#10003; }
}
</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">&#8595;</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);
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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">
&times;
</button>
</header>
<div class="modal-body">
<span class="violation-severity-large" [ngClass]="getSeverityClass(selectedViolation()!.severity)">
{{ selectedViolation()!.severity | uppercase }}
</span>
<p class="violation-description">{{ selectedViolation()!.description }}</p>
<dl class="violation-meta">
<div>
<dt>Occurrences</dt>
<dd>{{ selectedViolation()!.count }}</dd>
</div>
<div>
<dt>Last Seen</dt>
<dd>{{ formatDate(selectedViolation()!.lastSeen) }}</dd>
</div>
</dl>
@if (selectedViolation()!.documentationUrl) {
<a
[href]="selectedViolation()!.documentationUrl"
class="docs-link"
target="_blank"
rel="noopener"
>
View Documentation &rarr;
</a>
}
</div>
<footer class="modal-footer">
<a
[routerLink]="['/sources/violations', selectedViolation()!.code]"
class="btn btn--primary"
>
View All Occurrences
</a>
<button type="button" class="btn btn--secondary" (click)="closeViolationDetail()">
Close
</button>
</footer>
</div>
</div>
}
}
</section>

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

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

View File

@@ -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">&larr; 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;
}
}
}

View File

@@ -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,
};