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:
master
2025-12-23 13:13:00 +02:00
parent c8a871dd30
commit ef933db0d8
97 changed files with 17455 additions and 52 deletions

View File

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

View File

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

View File

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