feat(cli): Implement crypto plugin CLI architecture with regional compliance
Sprint: SPRINT_4100_0006_0001 Status: COMPLETED Implemented plugin-based crypto command architecture for regional compliance with build-time distribution selection (GOST/eIDAS/SM) and runtime validation. ## New Commands - `stella crypto sign` - Sign artifacts with regional crypto providers - `stella crypto verify` - Verify signatures with trust policy support - `stella crypto profiles` - List available crypto providers & capabilities ## Build-Time Distribution Selection ```bash # International (default - BouncyCastle) dotnet build src/Cli/StellaOps.Cli/StellaOps.Cli.csproj # Russia distribution (GOST R 34.10-2012) dotnet build -p:StellaOpsEnableGOST=true # EU distribution (eIDAS Regulation 910/2014) dotnet build -p:StellaOpsEnableEIDAS=true # China distribution (SM2/SM3/SM4) dotnet build -p:StellaOpsEnableSM=true ``` ## Key Features - Build-time conditional compilation prevents export control violations - Runtime crypto profile validation on CLI startup - 8 predefined profiles (international, russia-prod/dev, eu-prod/dev, china-prod/dev) - Comprehensive configuration with environment variable substitution - Integration tests with distribution-specific assertions - Full migration path from deprecated `cryptoru` CLI ## Files Added - src/Cli/StellaOps.Cli/Commands/CryptoCommandGroup.cs - src/Cli/StellaOps.Cli/Commands/CommandHandlers.Crypto.cs - src/Cli/StellaOps.Cli/Services/CryptoProfileValidator.cs - src/Cli/StellaOps.Cli/appsettings.crypto.yaml.example - src/Cli/__Tests/StellaOps.Cli.Tests/CryptoCommandTests.cs - docs/cli/crypto-commands.md - docs/implplan/SPRINT_4100_0006_0001_COMPLETION_SUMMARY.md ## Files Modified - src/Cli/StellaOps.Cli/StellaOps.Cli.csproj (conditional plugin refs) - src/Cli/StellaOps.Cli/Program.cs (plugin registration + validation) - src/Cli/StellaOps.Cli/Commands/CommandFactory.cs (command wiring) - src/Scanner/__Libraries/StellaOps.Scanner.Core/Configuration/PoEConfiguration.cs (fix) ## Compliance - GOST (Russia): GOST R 34.10-2012, FSB certified - eIDAS (EU): Regulation (EU) No 910/2014, QES/AES/AdES - SM (China): GM/T 0003-2012 (SM2), OSCCA certified ## Migration `cryptoru` CLI deprecated → sunset date: 2025-07-01 - `cryptoru providers` → `stella crypto profiles` - `cryptoru sign` → `stella crypto sign` ## Testing ✅ All crypto code compiles successfully ✅ Integration tests pass ✅ Build verification for all distributions (international/GOST/eIDAS/SM) Next: SPRINT_4100_0006_0002 (eIDAS plugin implementation) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,682 @@
|
||||
/**
|
||||
* Proof of Exposure (PoE) Drawer Component.
|
||||
* Sprint: SPRINT_4400_0001_0001 (PoE UI & Policy Hooks)
|
||||
* Task: UI-002 - PoE Drawer with path visualization and metadata
|
||||
*
|
||||
* Slide-out drawer displaying PoE artifact details including:
|
||||
* - Call paths from entrypoint to vulnerable code
|
||||
* - DSSE signature verification status
|
||||
* - Rekor transparency log timestamp
|
||||
* - Policy digest and build ID
|
||||
* - Reproducibility instructions
|
||||
*/
|
||||
|
||||
import { Component, input, output, computed, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { PathViewerComponent } from './components/path-viewer/path-viewer.component';
|
||||
import { PoEBadgeComponent } from '../../shared/components/poe-badge.component';
|
||||
import { RekorLinkComponent } from '../../shared/components/rekor-link.component';
|
||||
|
||||
/**
|
||||
* PoE artifact data model.
|
||||
*/
|
||||
export interface PoEArtifact {
|
||||
vulnId: string;
|
||||
componentPurl: string;
|
||||
buildId: string;
|
||||
imageDigest: string;
|
||||
policyId: string;
|
||||
policyDigest: string;
|
||||
scannerVersion: string;
|
||||
generatedAt: string;
|
||||
poeHash: string;
|
||||
isSigned: boolean;
|
||||
hasRekorTimestamp: boolean;
|
||||
rekorLogIndex?: number;
|
||||
paths: PoEPath[];
|
||||
reproSteps: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* PoE call path model.
|
||||
*/
|
||||
export interface PoEPath {
|
||||
id: string;
|
||||
entrypoint: PoENode;
|
||||
intermediateNodes: PoENode[];
|
||||
sink: PoENode;
|
||||
edges: PoEEdge[];
|
||||
maxConfidence: number;
|
||||
minConfidence: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* PoE node model.
|
||||
*/
|
||||
export interface PoENode {
|
||||
id: string;
|
||||
symbol: string;
|
||||
moduleHash: string;
|
||||
addr: string;
|
||||
file?: string;
|
||||
line?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* PoE edge model.
|
||||
*/
|
||||
export interface PoEEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
confidence: number;
|
||||
guards?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Slide-out drawer component for displaying PoE artifact details.
|
||||
*
|
||||
* Features:
|
||||
* - Call path visualization with confidence scores
|
||||
* - DSSE signature status
|
||||
* - Rekor timestamp verification
|
||||
* - Build reproducibility instructions
|
||||
* - Export/download PoE artifact
|
||||
*
|
||||
* @example
|
||||
* <app-poe-drawer
|
||||
* [poeArtifact]="artifact"
|
||||
* [open]="isOpen"
|
||||
* (close)="handleClose()"
|
||||
* (exportPoE)="handleExport()"
|
||||
* />
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-poe-drawer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, PathViewerComponent, PoEBadgeComponent, RekorLinkComponent],
|
||||
template: `
|
||||
<div class="poe-drawer" [class.poe-drawer--open]="open()" role="complementary" [attr.aria-hidden]="!open()">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="poe-drawer__backdrop"
|
||||
(click)="handleClose()"
|
||||
[attr.aria-hidden]="true"
|
||||
></div>
|
||||
|
||||
<!-- Drawer panel -->
|
||||
<div
|
||||
class="poe-drawer__panel"
|
||||
role="dialog"
|
||||
aria-labelledby="poe-drawer-title"
|
||||
[attr.aria-modal]="true"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="poe-drawer__header">
|
||||
<div class="poe-drawer__title-row">
|
||||
<h2 id="poe-drawer-title" class="poe-drawer__title">
|
||||
Proof of Exposure
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="poe-drawer__close"
|
||||
(click)="handleClose()"
|
||||
aria-label="Close PoE drawer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (poeArtifact(); as poe) {
|
||||
<div class="poe-drawer__meta">
|
||||
<div class="poe-drawer__meta-item">
|
||||
<span class="poe-drawer__meta-label">Vulnerability:</span>
|
||||
<code class="poe-drawer__meta-value">{{ poe.vulnId }}</code>
|
||||
</div>
|
||||
<div class="poe-drawer__meta-item">
|
||||
<span class="poe-drawer__meta-label">Component:</span>
|
||||
<code class="poe-drawer__meta-value">{{ poe.componentPurl }}</code>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="poe-drawer__content">
|
||||
@if (poeArtifact(); as poe) {
|
||||
<!-- Verification Status -->
|
||||
<section class="poe-drawer__section">
|
||||
<h3 class="poe-drawer__section-title">Verification Status</h3>
|
||||
<div class="poe-drawer__status-grid">
|
||||
<div class="poe-drawer__status-item">
|
||||
<span class="poe-drawer__status-icon">
|
||||
{{ poe.isSigned ? '✓' : '✗' }}
|
||||
</span>
|
||||
<span [class.poe-drawer__status-valid]="poe.isSigned">
|
||||
{{ poe.isSigned ? 'DSSE Signed' : 'Not Signed' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="poe-drawer__status-item">
|
||||
<span class="poe-drawer__status-icon">
|
||||
{{ poe.hasRekorTimestamp ? '✓' : '○' }}
|
||||
</span>
|
||||
<span [class.poe-drawer__status-valid]="poe.hasRekorTimestamp">
|
||||
{{ poe.hasRekorTimestamp ? 'Rekor Timestamped' : 'No Rekor Timestamp' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (poe.hasRekorTimestamp && poe.rekorLogIndex !== undefined) {
|
||||
<div class="poe-drawer__rekor-link">
|
||||
<stella-rekor-link [logIndex]="poe.rekorLogIndex" />
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Call Paths -->
|
||||
<section class="poe-drawer__section">
|
||||
<h3 class="poe-drawer__section-title">
|
||||
Call Paths ({{ poe.paths.length }})
|
||||
</h3>
|
||||
<div class="poe-drawer__paths">
|
||||
@for (path of poe.paths; track path.id) {
|
||||
<div class="poe-drawer__path">
|
||||
<div class="poe-drawer__path-header">
|
||||
<span class="poe-drawer__path-label">Path {{ $index + 1 }}</span>
|
||||
<span class="poe-drawer__path-confidence">
|
||||
Confidence: {{ formatConfidence(path.minConfidence) }}–{{ formatConfidence(path.maxConfidence) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Path visualization -->
|
||||
<div class="poe-drawer__path-viz">
|
||||
<div class="poe-drawer__node poe-drawer__node--entry">
|
||||
<div class="poe-drawer__node-symbol">{{ path.entrypoint.symbol }}</div>
|
||||
@if (path.entrypoint.file) {
|
||||
<div class="poe-drawer__node-location">
|
||||
{{ path.entrypoint.file }}:{{ path.entrypoint.line }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@for (node of path.intermediateNodes; track node.id) {
|
||||
<div class="poe-drawer__arrow">↓</div>
|
||||
<div class="poe-drawer__node">
|
||||
<div class="poe-drawer__node-symbol">{{ node.symbol }}</div>
|
||||
@if (node.file) {
|
||||
<div class="poe-drawer__node-location">
|
||||
{{ node.file }}:{{ node.line }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="poe-drawer__arrow poe-drawer__arrow--final">↓</div>
|
||||
<div class="poe-drawer__node poe-drawer__node--sink">
|
||||
<div class="poe-drawer__node-symbol">{{ path.sink.symbol }}</div>
|
||||
@if (path.sink.file) {
|
||||
<div class="poe-drawer__node-location">
|
||||
{{ path.sink.file }}:{{ path.sink.line }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Guards (if any) -->
|
||||
@if (hasGuards(path)) {
|
||||
<div class="poe-drawer__guards">
|
||||
<strong>Guards:</strong>
|
||||
@for (edge of path.edges; track $index) {
|
||||
@if (edge.guards && edge.guards.length > 0) {
|
||||
<div class="poe-drawer__guard-list">
|
||||
@for (guard of edge.guards; track $index) {
|
||||
<code class="poe-drawer__guard">{{ guard }}</code>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Build Metadata -->
|
||||
<section class="poe-drawer__section">
|
||||
<h3 class="poe-drawer__section-title">Build Metadata</h3>
|
||||
<dl class="poe-drawer__metadata">
|
||||
<dt>Build ID:</dt>
|
||||
<dd><code>{{ poe.buildId }}</code></dd>
|
||||
|
||||
<dt>Image Digest:</dt>
|
||||
<dd><code>{{ poe.imageDigest }}</code></dd>
|
||||
|
||||
<dt>Policy ID:</dt>
|
||||
<dd><code>{{ poe.policyId }}</code></dd>
|
||||
|
||||
<dt>Policy Digest:</dt>
|
||||
<dd><code>{{ poe.policyDigest }}</code></dd>
|
||||
|
||||
<dt>Scanner Version:</dt>
|
||||
<dd><code>{{ poe.scannerVersion }}</code></dd>
|
||||
|
||||
<dt>Generated:</dt>
|
||||
<dd>{{ formatDate(poe.generatedAt) }}</dd>
|
||||
|
||||
<dt>PoE Hash:</dt>
|
||||
<dd><code class="poe-drawer__hash">{{ poe.poeHash }}</code></dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<!-- Reproducibility Steps -->
|
||||
<section class="poe-drawer__section">
|
||||
<h3 class="poe-drawer__section-title">Reproducibility</h3>
|
||||
<p class="poe-drawer__repro-intro">
|
||||
To independently verify this PoE artifact:
|
||||
</p>
|
||||
<ol class="poe-drawer__repro-steps">
|
||||
@for (step of poe.reproSteps; track $index) {
|
||||
<li>{{ step }}</li>
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="poe-drawer__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="poe-drawer__action poe-drawer__action--primary"
|
||||
(click)="handleExport()"
|
||||
>
|
||||
Export PoE Artifact
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="poe-drawer__action poe-drawer__action--secondary"
|
||||
(click)="handleVerify()"
|
||||
>
|
||||
Verify Offline
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="poe-drawer__empty">
|
||||
No PoE artifact loaded
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.poe-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s;
|
||||
opacity: 0;
|
||||
|
||||
&--open {
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.poe-drawer__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.poe-drawer__panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(600px, 90vw);
|
||||
background: var(--bg-primary, #fff);
|
||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
.poe-drawer--open & {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.poe-drawer__header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.poe-drawer__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.poe-drawer__title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.poe-drawer__close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.poe-drawer__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.poe-drawer__meta-item {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.poe-drawer__meta-label {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.poe-drawer__meta-value {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.8125rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.poe-drawer__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.poe-drawer__section {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.poe-drawer__section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.poe-drawer__status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.poe-drawer__status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.poe-drawer__status-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.poe-drawer__status-valid {
|
||||
color: var(--success-color, #28a745);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.poe-drawer__paths {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.poe-drawer__path {
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary, #f8f9fa);
|
||||
}
|
||||
|
||||
.poe-drawer__path-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.poe-drawer__path-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.poe-drawer__path-confidence {
|
||||
color: var(--text-secondary, #666);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.poe-drawer__path-viz {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.poe-drawer__node {
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.poe-drawer__node--entry {
|
||||
border-left: 3px solid var(--success-color, #28a745);
|
||||
}
|
||||
|
||||
.poe-drawer__node--sink {
|
||||
border-left: 3px solid var(--danger-color, #dc3545);
|
||||
}
|
||||
|
||||
.poe-drawer__node-symbol {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.poe-drawer__node-location {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.poe-drawer__arrow {
|
||||
text-align: center;
|
||||
color: var(--text-tertiary, #999);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.poe-drawer__guards {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px dashed var(--border-color, #e0e0e0);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.poe-drawer__guard-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.poe-drawer__guard {
|
||||
background: var(--bg-tertiary, #e9ecef);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.poe-drawer__metadata {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
|
||||
dt {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.poe-drawer__hash {
|
||||
background: var(--bg-tertiary, #e9ecef);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.poe-drawer__repro-intro {
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.poe-drawer__repro-steps {
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.poe-drawer__actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.poe-drawer__action {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&--primary {
|
||||
background: var(--primary-color, #007bff);
|
||||
color: #fff;
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-hover, #0056b3);
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: var(--bg-secondary, #f8f9fa);
|
||||
color: var(--text-primary, #212529);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary, #e9ecef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.poe-drawer__empty {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class PoEDrawerComponent {
|
||||
/**
|
||||
* PoE artifact to display.
|
||||
*/
|
||||
readonly poeArtifact = input<PoEArtifact | null>(null);
|
||||
|
||||
/**
|
||||
* Whether the drawer is open.
|
||||
*/
|
||||
readonly open = input<boolean>(false);
|
||||
|
||||
/**
|
||||
* Emitted when the drawer should close.
|
||||
*/
|
||||
readonly close = output<void>();
|
||||
|
||||
/**
|
||||
* Emitted when the user wants to export the PoE artifact.
|
||||
*/
|
||||
readonly exportPoE = output<void>();
|
||||
|
||||
/**
|
||||
* Emitted when the user wants to verify the PoE offline.
|
||||
*/
|
||||
readonly verifyPoE = output<void>();
|
||||
|
||||
handleClose(): void {
|
||||
this.close.emit();
|
||||
}
|
||||
|
||||
handleExport(): void {
|
||||
this.exportPoE.emit();
|
||||
}
|
||||
|
||||
handleVerify(): void {
|
||||
this.verifyPoE.emit();
|
||||
}
|
||||
|
||||
formatConfidence(confidence: number): string {
|
||||
return (confidence * 100).toFixed(0) + '%';
|
||||
}
|
||||
|
||||
formatDate(isoDate: string): string {
|
||||
return new Date(isoDate).toLocaleString();
|
||||
}
|
||||
|
||||
hasGuards(path: PoEPath): boolean {
|
||||
return path.edges.some(e => e.guards && e.guards.length > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Unit tests for PoEBadgeComponent.
|
||||
* Sprint: SPRINT_4400_0001_0001 (PoE UI & Policy Hooks)
|
||||
* Task: TEST-001 - PoE Badge Component Tests
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { PoEBadgeComponent, type PoEStatus } from './poe-badge.component';
|
||||
import { DebugElement } from '@angular/core';
|
||||
import { By } from '@angular/platform-browser';
|
||||
|
||||
describe('PoEBadgeComponent', () => {
|
||||
let component: PoEBadgeComponent;
|
||||
let fixture: ComponentFixture<PoEBadgeComponent>;
|
||||
let button: DebugElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PoEBadgeComponent]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PoEBadgeComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display valid status with green styling', () => {
|
||||
fixture.componentRef.setInput('status', 'valid');
|
||||
fixture.detectChanges();
|
||||
|
||||
button = fixture.debugElement.query(By.css('.poe-badge'));
|
||||
expect(button.nativeElement.classList.contains('poe-badge--valid')).toBe(true);
|
||||
expect(button.nativeElement.textContent).toContain('✓');
|
||||
});
|
||||
|
||||
it('should display missing status with gray styling', () => {
|
||||
fixture.componentRef.setInput('status', 'missing');
|
||||
fixture.detectChanges();
|
||||
|
||||
button = fixture.debugElement.query(By.css('.poe-badge'));
|
||||
expect(button.nativeElement.classList.contains('poe-badge--missing')).toBe(true);
|
||||
expect(button.nativeElement.textContent).toContain('○');
|
||||
});
|
||||
|
||||
it('should display error status with red styling', () => {
|
||||
fixture.componentRef.setInput('status', 'invalid_signature');
|
||||
fixture.detectChanges();
|
||||
|
||||
button = fixture.debugElement.query(By.css('.poe-badge'));
|
||||
expect(button.nativeElement.classList.contains('poe-badge--invalid_signature')).toBe(true);
|
||||
expect(button.nativeElement.textContent).toContain('✗');
|
||||
});
|
||||
|
||||
it('should show PoE label when showLabel is true', () => {
|
||||
fixture.componentRef.setInput('status', 'valid');
|
||||
fixture.componentRef.setInput('showLabel', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const label = fixture.debugElement.query(By.css('.poe-badge__label'));
|
||||
expect(label).toBeTruthy();
|
||||
expect(label.nativeElement.textContent).toBe('PoE');
|
||||
});
|
||||
|
||||
it('should hide PoE label when showLabel is false', () => {
|
||||
fixture.componentRef.setInput('status', 'valid');
|
||||
fixture.componentRef.setInput('showLabel', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const label = fixture.debugElement.query(By.css('.poe-badge__label'));
|
||||
expect(label).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should display path count for valid status', () => {
|
||||
fixture.componentRef.setInput('status', 'valid');
|
||||
fixture.componentRef.setInput('pathCount', 3);
|
||||
fixture.detectChanges();
|
||||
|
||||
const count = fixture.debugElement.query(By.css('.poe-badge__count'));
|
||||
expect(count).toBeTruthy();
|
||||
expect(count.nativeElement.textContent.trim()).toBe('3');
|
||||
});
|
||||
|
||||
it('should not display path count for non-valid status', () => {
|
||||
fixture.componentRef.setInput('status', 'missing');
|
||||
fixture.componentRef.setInput('pathCount', 3);
|
||||
fixture.detectChanges();
|
||||
|
||||
const count = fixture.debugElement.query(By.css('.poe-badge__count'));
|
||||
expect(count).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should display Rekor icon when hasRekorTimestamp is true and status is valid', () => {
|
||||
fixture.componentRef.setInput('status', 'valid');
|
||||
fixture.componentRef.setInput('hasRekorTimestamp', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const rekor = fixture.debugElement.query(By.css('.poe-badge__rekor'));
|
||||
expect(rekor).toBeTruthy();
|
||||
expect(rekor.nativeElement.textContent).toContain('🔒');
|
||||
});
|
||||
|
||||
it('should not display Rekor icon when status is not valid', () => {
|
||||
fixture.componentRef.setInput('status', 'missing');
|
||||
fixture.componentRef.setInput('hasRekorTimestamp', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const rekor = fixture.debugElement.query(By.css('.poe-badge__rekor'));
|
||||
expect(rekor).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tooltips', () => {
|
||||
it('should show correct tooltip for valid status', () => {
|
||||
fixture.componentRef.setInput('status', 'valid');
|
||||
fixture.detectChanges();
|
||||
|
||||
button = fixture.debugElement.query(By.css('.poe-badge'));
|
||||
expect(button.nativeElement.getAttribute('title')).toBe('Valid Proof of Exposure artifact');
|
||||
});
|
||||
|
||||
it('should show correct tooltip for missing status', () => {
|
||||
fixture.componentRef.setInput('status', 'missing');
|
||||
fixture.detectChanges();
|
||||
|
||||
button = fixture.debugElement.query(By.css('.poe-badge'));
|
||||
expect(button.nativeElement.getAttribute('title')).toBe('No Proof of Exposure artifact available');
|
||||
});
|
||||
|
||||
it('should show correct tooltip for unsigned status', () => {
|
||||
fixture.componentRef.setInput('status', 'unsigned');
|
||||
fixture.detectChanges();
|
||||
|
||||
button = fixture.debugElement.query(By.css('.poe-badge'));
|
||||
expect(button.nativeElement.getAttribute('title')).toBe('PoE artifact is not cryptographically signed (DSSE required)');
|
||||
});
|
||||
|
||||
it('should include path count in tooltip for valid status', () => {
|
||||
fixture.componentRef.setInput('status', 'valid');
|
||||
fixture.componentRef.setInput('pathCount', 2);
|
||||
fixture.detectChanges();
|
||||
|
||||
button = fixture.debugElement.query(By.css('.poe-badge'));
|
||||
expect(button.nativeElement.getAttribute('title')).toContain('with 2 paths');
|
||||
});
|
||||
|
||||
it('should include Rekor timestamp in tooltip', () => {
|
||||
fixture.componentRef.setInput('status', 'valid');
|
||||
fixture.componentRef.setInput('hasRekorTimestamp', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
button = fixture.debugElement.query(By.css('.poe-badge'));
|
||||
expect(button.nativeElement.getAttribute('title')).toContain('Rekor timestamped');
|
||||
});
|
||||
|
||||
it('should use custom tooltip when provided', () => {
|
||||
fixture.componentRef.setInput('status', 'valid');
|
||||
fixture.componentRef.setInput('customTooltip', 'Custom tooltip text');
|
||||
fixture.detectChanges();
|
||||
|
||||
button = fixture.debugElement.query(By.css('.poe-badge'));
|
||||
expect(button.nativeElement.getAttribute('title')).toBe('Custom tooltip text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have role="button"', () => {
|
||||
fixture.componentRef.setInput('status', 'valid');
|
||||
fixture.detectChanges();
|
||||
|
||||
button = fixture.debugElement.query(By.css('.poe-badge'));
|
||||
expect(button.nativeElement.getAttribute('role')).toBe('button');
|
||||
});
|
||||
|
||||
it('should have descriptive aria-label', () => {
|
||||
fixture.componentRef.setInput('status', 'valid');
|
||||
fixture.componentRef.setInput('pathCount', 3);
|
||||
fixture.detectChanges();
|
||||
|
||||
button = fixture.debugElement.query(By.css('.poe-badge'));
|
||||
const ariaLabel = button.nativeElement.getAttribute('aria-label');
|
||||
expect(ariaLabel).toContain('Proof of Exposure');
|
||||
expect(ariaLabel).toContain('Valid');
|
||||
expect(ariaLabel).toContain('3 paths');
|
||||
});
|
||||
|
||||
it('should indicate clickability in aria-label when clickable', () => {
|
||||
fixture.componentRef.setInput('status', 'valid');
|
||||
fixture.componentRef.setInput('clickable', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
button = fixture.debugElement.query(By.css('.poe-badge'));
|
||||
expect(button.nativeElement.getAttribute('aria-label')).toContain('Click to view details');
|
||||
});
|
||||
|
||||
it('should not indicate clickability when not clickable', () => {
|
||||
fixture.componentRef.setInput('status', 'valid');
|
||||
fixture.componentRef.setInput('clickable', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
button = fixture.debugElement.query(By.css('.poe-badge'));
|
||||
expect(button.nativeElement.getAttribute('aria-label')).not.toContain('Click to view details');
|
||||
});
|
||||
|
||||
it('should have aria-label for path count', () => {
|
||||
fixture.componentRef.setInput('status', 'valid');
|
||||
fixture.componentRef.setInput('pathCount', 1);
|
||||
fixture.detectChanges();
|
||||
|
||||
const count = fixture.debugElement.query(By.css('.poe-badge__count'));
|
||||
expect(count.nativeElement.getAttribute('aria-label')).toBe('1 path to vulnerable code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interaction', () => {
|
||||
it('should emit clicked event when clicked and clickable', () => {
|
||||
let clickEmitted = false;
|
||||
component.clicked.subscribe(() => {
|
||||
clickEmitted = true;
|
||||
});
|
||||
|
||||
fixture.componentRef.setInput('status', 'valid');
|
||||
fixture.componentRef.setInput('clickable', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
button = fixture.debugElement.query(By.css('.poe-badge'));
|
||||
button.nativeElement.click();
|
||||
|
||||
expect(clickEmitted).toBe(true);
|
||||
});
|
||||
|
||||
it('should not emit clicked event when status is missing', () => {
|
||||
let clickEmitted = false;
|
||||
component.clicked.subscribe(() => {
|
||||
clickEmitted = true;
|
||||
});
|
||||
|
||||
fixture.componentRef.setInput('status', 'missing');
|
||||
fixture.componentRef.setInput('clickable', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
button = fixture.debugElement.query(By.css('.poe-badge'));
|
||||
button.nativeElement.click();
|
||||
|
||||
expect(clickEmitted).toBe(false);
|
||||
});
|
||||
|
||||
it('should be disabled when not clickable', () => {
|
||||
fixture.componentRef.setInput('status', 'valid');
|
||||
fixture.componentRef.setInput('clickable', false);
|
||||
fixture.detectChanges();
|
||||
|
||||
button = fixture.debugElement.query(By.css('.poe-badge'));
|
||||
expect(button.nativeElement.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should be disabled when status is missing', () => {
|
||||
fixture.componentRef.setInput('status', 'missing');
|
||||
fixture.componentRef.setInput('clickable', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
button = fixture.debugElement.query(By.css('.poe-badge'));
|
||||
expect(button.nativeElement.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status Icons', () => {
|
||||
const statusIconTests: Array<{ status: PoEStatus; expectedIcon: string }> = [
|
||||
{ status: 'valid', expectedIcon: '✓' },
|
||||
{ status: 'missing', expectedIcon: '○' },
|
||||
{ status: 'unsigned', expectedIcon: '⚠' },
|
||||
{ status: 'stale', expectedIcon: '⚠' },
|
||||
{ status: 'invalid_signature', expectedIcon: '✗' },
|
||||
{ status: 'build_mismatch', expectedIcon: '✗' },
|
||||
{ status: 'error', expectedIcon: '✗' },
|
||||
];
|
||||
|
||||
statusIconTests.forEach(({ status, expectedIcon }) => {
|
||||
it(`should display ${expectedIcon} for ${status} status`, () => {
|
||||
fixture.componentRef.setInput('status', status);
|
||||
fixture.detectChanges();
|
||||
|
||||
const icon = fixture.debugElement.query(By.css('.poe-badge__icon'));
|
||||
expect(icon.nativeElement.textContent).toBe(expectedIcon);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Proof of Exposure (PoE) Badge Component.
|
||||
* Sprint: SPRINT_4400_0001_0001 (PoE UI & Policy Hooks)
|
||||
* Task: UI-001 - PoE Badge displaying validation status
|
||||
*
|
||||
* Displays a compact badge indicating whether a vulnerability has a valid PoE artifact.
|
||||
* PoE artifacts provide cryptographic proof of vulnerability reachability with signed attestations.
|
||||
*/
|
||||
|
||||
import { Component, input, computed, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
/**
|
||||
* PoE validation status values (aligned with backend enum).
|
||||
*/
|
||||
export type PoEStatus =
|
||||
| 'valid'
|
||||
| 'missing'
|
||||
| 'unsigned'
|
||||
| 'invalid_signature'
|
||||
| 'stale'
|
||||
| 'build_mismatch'
|
||||
| 'policy_mismatch'
|
||||
| 'insufficient_paths'
|
||||
| 'depth_exceeded'
|
||||
| 'low_confidence'
|
||||
| 'guarded_paths_disallowed'
|
||||
| 'hash_mismatch'
|
||||
| 'missing_rekor_timestamp'
|
||||
| 'error';
|
||||
|
||||
/**
|
||||
* Compact badge component displaying PoE validation status.
|
||||
*
|
||||
* Color scheme:
|
||||
* - valid (green): PoE is valid and meets all policy requirements
|
||||
* - missing (gray): PoE is not present
|
||||
* - stale/warning states (amber): PoE has validation warnings
|
||||
* - error states (red): PoE validation failed
|
||||
*
|
||||
* @example
|
||||
* <stella-poe-badge
|
||||
* [status]="'valid'"
|
||||
* [pathCount]="3"
|
||||
* [hasRekorTimestamp]="true"
|
||||
* (click)="openPoEViewer()"
|
||||
* />
|
||||
* <stella-poe-badge [status]="'missing'" />
|
||||
* <stella-poe-badge [status]="'stale'" [showLabel]="false" />
|
||||
*/
|
||||
@Component({
|
||||
selector: 'stella-poe-badge',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
class="poe-badge"
|
||||
[class]="badgeClass()"
|
||||
[attr.title]="tooltip()"
|
||||
[attr.aria-label]="ariaLabel()"
|
||||
[disabled]="!isClickable()"
|
||||
(click)="handleClick()"
|
||||
role="button"
|
||||
>
|
||||
<span class="poe-badge__icon" aria-hidden="true">{{ icon() }}</span>
|
||||
@if (showLabel()) {
|
||||
<span class="poe-badge__label">PoE</span>
|
||||
}
|
||||
@if (hasRekorTimestamp() && status() === 'valid') {
|
||||
<span class="poe-badge__rekor" title="Timestamped in Rekor transparency log">
|
||||
🔒
|
||||
</span>
|
||||
}
|
||||
@if (pathCount() !== undefined && status() === 'valid') {
|
||||
<span class="poe-badge__count" [attr.aria-label]="pathCountAriaLabel()">
|
||||
{{ pathCount() }}
|
||||
</span>
|
||||
}
|
||||
</button>
|
||||
`,
|
||||
styles: [`
|
||||
.poe-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
opacity: 0.85;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.poe-badge__icon {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.poe-badge__label {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.poe-badge__rekor {
|
||||
font-size: 0.625rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.poe-badge__count {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.6875rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// Valid state (green)
|
||||
.poe-badge--valid {
|
||||
background: rgba(40, 167, 69, 0.15);
|
||||
color: #28a745;
|
||||
border-color: rgba(40, 167, 69, 0.4);
|
||||
}
|
||||
|
||||
// Missing state (gray)
|
||||
.poe-badge--missing {
|
||||
background: rgba(108, 117, 125, 0.1);
|
||||
color: #6c757d;
|
||||
border-color: rgba(108, 117, 125, 0.3);
|
||||
}
|
||||
|
||||
// Warning states (amber)
|
||||
.poe-badge--unsigned,
|
||||
.poe-badge--stale,
|
||||
.poe-badge--low_confidence,
|
||||
.poe-badge--missing_rekor_timestamp {
|
||||
background: rgba(255, 193, 7, 0.15);
|
||||
color: #ffc107;
|
||||
border-color: rgba(255, 193, 7, 0.4);
|
||||
}
|
||||
|
||||
// Error states (red)
|
||||
.poe-badge--invalid_signature,
|
||||
.poe-badge--build_mismatch,
|
||||
.poe-badge--policy_mismatch,
|
||||
.poe-badge--insufficient_paths,
|
||||
.poe-badge--depth_exceeded,
|
||||
.poe-badge--guarded_paths_disallowed,
|
||||
.poe-badge--hash_mismatch,
|
||||
.poe-badge--error {
|
||||
background: rgba(220, 53, 69, 0.15);
|
||||
color: #dc3545;
|
||||
border-color: rgba(220, 53, 69, 0.4);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class PoEBadgeComponent {
|
||||
/**
|
||||
* PoE validation status.
|
||||
*/
|
||||
readonly status = input<PoEStatus>('missing');
|
||||
|
||||
/**
|
||||
* Number of paths in the PoE subgraph (if valid).
|
||||
*/
|
||||
readonly pathCount = input<number | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Whether the PoE has a Rekor transparency log timestamp.
|
||||
*/
|
||||
readonly hasRekorTimestamp = input<boolean>(false);
|
||||
|
||||
/**
|
||||
* Whether to show the "PoE" text label (default: true).
|
||||
* Set to false for a more compact icon-only display.
|
||||
*/
|
||||
readonly showLabel = input<boolean>(true);
|
||||
|
||||
/**
|
||||
* Whether the badge is clickable to open PoE details.
|
||||
*/
|
||||
readonly clickable = input<boolean>(true);
|
||||
|
||||
/**
|
||||
* Optional custom tooltip override.
|
||||
*/
|
||||
readonly customTooltip = input<string | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Emitted when the badge is clicked.
|
||||
*/
|
||||
readonly clicked = output<void>();
|
||||
|
||||
/**
|
||||
* Computed CSS class for status.
|
||||
*/
|
||||
readonly badgeClass = computed(() => `poe-badge poe-badge--${this.status()}`);
|
||||
|
||||
/**
|
||||
* Computed icon based on status.
|
||||
*/
|
||||
readonly icon = computed(() => {
|
||||
switch (this.status()) {
|
||||
case 'valid':
|
||||
return '✓'; // Check mark - PoE is valid
|
||||
case 'missing':
|
||||
return '○'; // Empty circle - no PoE
|
||||
case 'unsigned':
|
||||
case 'missing_rekor_timestamp':
|
||||
case 'stale':
|
||||
case 'low_confidence':
|
||||
return '⚠'; // Warning - PoE has issues
|
||||
case 'invalid_signature':
|
||||
case 'build_mismatch':
|
||||
case 'policy_mismatch':
|
||||
case 'insufficient_paths':
|
||||
case 'depth_exceeded':
|
||||
case 'guarded_paths_disallowed':
|
||||
case 'hash_mismatch':
|
||||
case 'error':
|
||||
return '✗'; // X mark - PoE validation failed
|
||||
default:
|
||||
return '?'; // Unknown status
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Computed tooltip text.
|
||||
*/
|
||||
readonly tooltip = computed(() => {
|
||||
if (this.customTooltip()) {
|
||||
return this.customTooltip();
|
||||
}
|
||||
|
||||
const pathCount = this.pathCount();
|
||||
const hasRekor = this.hasRekorTimestamp();
|
||||
|
||||
switch (this.status()) {
|
||||
case 'valid':
|
||||
let msg = 'Valid Proof of Exposure artifact';
|
||||
if (pathCount !== undefined) {
|
||||
msg += ` with ${pathCount} path${pathCount === 1 ? '' : 's'}`;
|
||||
}
|
||||
if (hasRekor) {
|
||||
msg += ' (Rekor timestamped)';
|
||||
}
|
||||
return msg;
|
||||
|
||||
case 'missing':
|
||||
return 'No Proof of Exposure artifact available';
|
||||
|
||||
case 'unsigned':
|
||||
return 'PoE artifact is not cryptographically signed (DSSE required)';
|
||||
|
||||
case 'invalid_signature':
|
||||
return 'PoE signature verification failed';
|
||||
|
||||
case 'stale':
|
||||
return 'PoE artifact is stale and should be refreshed';
|
||||
|
||||
case 'build_mismatch':
|
||||
return 'PoE build ID does not match scan build ID';
|
||||
|
||||
case 'policy_mismatch':
|
||||
return 'PoE policy digest does not match current policy';
|
||||
|
||||
case 'insufficient_paths':
|
||||
return 'PoE does not have enough paths to satisfy policy';
|
||||
|
||||
case 'depth_exceeded':
|
||||
return 'PoE path depth exceeds policy maximum';
|
||||
|
||||
case 'low_confidence':
|
||||
return 'PoE edges have confidence below policy threshold';
|
||||
|
||||
case 'guarded_paths_disallowed':
|
||||
return 'PoE contains guarded paths but policy disallows them';
|
||||
|
||||
case 'hash_mismatch':
|
||||
return 'PoE content hash does not match expected value';
|
||||
|
||||
case 'missing_rekor_timestamp':
|
||||
return 'PoE is missing required Rekor transparency log timestamp';
|
||||
|
||||
case 'error':
|
||||
return 'Error validating PoE artifact';
|
||||
|
||||
default:
|
||||
return 'Unknown PoE validation status';
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Aria label for screen readers.
|
||||
*/
|
||||
readonly ariaLabel = computed(() => {
|
||||
const status = this.status();
|
||||
const pathCount = this.pathCount();
|
||||
let label = `Proof of Exposure: ${this.formatStatusForSpeech(status)}`;
|
||||
|
||||
if (status === 'valid' && pathCount !== undefined) {
|
||||
label += `, ${pathCount} path${pathCount === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
if (this.isClickable()) {
|
||||
label += '. Click to view details';
|
||||
}
|
||||
|
||||
return label;
|
||||
});
|
||||
|
||||
/**
|
||||
* Aria label for path count.
|
||||
*/
|
||||
readonly pathCountAriaLabel = computed(() => {
|
||||
const count = this.pathCount();
|
||||
return count !== undefined ? `${count} path${count === 1 ? '' : 's'} to vulnerable code` : '';
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the badge should be clickable.
|
||||
*/
|
||||
readonly isClickable = computed(() => this.clickable() && this.status() !== 'missing');
|
||||
|
||||
/**
|
||||
* Handle badge click.
|
||||
*/
|
||||
handleClick(): void {
|
||||
if (this.isClickable()) {
|
||||
this.clicked.emit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format status enum for speech.
|
||||
*/
|
||||
private formatStatusForSpeech(status: PoEStatus): string {
|
||||
return status
|
||||
.replace(/_/g, ' ')
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user