sprints work

This commit is contained in:
StellaOps Bot
2025-12-24 21:46:08 +02:00
parent 43e2af88f6
commit b9f71fc7e9
161 changed files with 29566 additions and 527 deletions

View File

@@ -0,0 +1,345 @@
// -----------------------------------------------------------------------------
// gated-buckets.component.ts
// Sprint: SPRINT_9200_0001_0004_FE_quiet_triage_ui
// Description: Component displaying gated bucket chips with expand functionality.
// Shows "+N unreachable", "+N policy-dismissed", etc. with click to expand.
// -----------------------------------------------------------------------------
import { Component, Input, Output, EventEmitter, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
GatedBucketsSummary,
GatingReason,
getGatingReasonLabel,
getGatingReasonIcon
} from '../../models/gating.model';
export interface BucketExpandEvent {
reason: GatingReason;
count: number;
}
@Component({
selector: 'app-gated-buckets',
standalone: true,
imports: [CommonModule],
template: `
<div class="gated-buckets" role="group" aria-label="Gated findings summary">
<!-- Actionable count summary -->
<div class="actionable-summary" [class.has-hidden]="totalHidden() > 0">
<span class="actionable-count">{{ actionableCount() }}</span>
<span class="actionable-label">actionable</span>
@if (totalHidden() > 0) {
<span class="hidden-hint">({{ totalHidden() }} hidden)</span>
}
</div>
<!-- Bucket chips -->
<div class="bucket-chips">
@if (unreachableCount() > 0) {
<button class="bucket-chip unreachable"
[class.expanded]="expandedBucket() === 'unreachable'"
(click)="toggleBucket('unreachable')"
[attr.aria-expanded]="expandedBucket() === 'unreachable'"
attr.aria-label="Show {{ unreachableCount() }} unreachable findings">
<span class="icon">{{ getIcon('unreachable') }}</span>
<span class="count">+{{ unreachableCount() }}</span>
<span class="label">unreachable</span>
</button>
}
@if (policyDismissedCount() > 0) {
<button class="bucket-chip policy-dismissed"
[class.expanded]="expandedBucket() === 'policy_dismissed'"
(click)="toggleBucket('policy_dismissed')"
[attr.aria-expanded]="expandedBucket() === 'policy_dismissed'"
attr.aria-label="Show {{ policyDismissedCount() }} policy-dismissed findings">
<span class="icon">{{ getIcon('policy_dismissed') }}</span>
<span class="count">+{{ policyDismissedCount() }}</span>
<span class="label">policy</span>
</button>
}
@if (backportedCount() > 0) {
<button class="bucket-chip backported"
[class.expanded]="expandedBucket() === 'backported'"
(click)="toggleBucket('backported')"
[attr.aria-expanded]="expandedBucket() === 'backported'"
attr.aria-label="Show {{ backportedCount() }} backported findings">
<span class="icon">{{ getIcon('backported') }}</span>
<span class="count">+{{ backportedCount() }}</span>
<span class="label">backported</span>
</button>
}
@if (vexNotAffectedCount() > 0) {
<button class="bucket-chip vex-not-affected"
[class.expanded]="expandedBucket() === 'vex_not_affected'"
(click)="toggleBucket('vex_not_affected')"
[attr.aria-expanded]="expandedBucket() === 'vex_not_affected'"
attr.aria-label="Show {{ vexNotAffectedCount() }} VEX not-affected findings">
<span class="icon">{{ getIcon('vex_not_affected') }}</span>
<span class="count">+{{ vexNotAffectedCount() }}</span>
<span class="label">VEX</span>
</button>
}
@if (supersededCount() > 0) {
<button class="bucket-chip superseded"
[class.expanded]="expandedBucket() === 'superseded'"
(click)="toggleBucket('superseded')"
[attr.aria-expanded]="expandedBucket() === 'superseded'"
attr.aria-label="Show {{ supersededCount() }} superseded findings">
<span class="icon">{{ getIcon('superseded') }}</span>
<span class="count">+{{ supersededCount() }}</span>
<span class="label">superseded</span>
</button>
}
@if (userMutedCount() > 0) {
<button class="bucket-chip user-muted"
[class.expanded]="expandedBucket() === 'user_muted'"
(click)="toggleBucket('user_muted')"
[attr.aria-expanded]="expandedBucket() === 'user_muted'"
attr.aria-label="Show {{ userMutedCount() }} user-muted findings">
<span class="icon">{{ getIcon('user_muted') }}</span>
<span class="count">+{{ userMutedCount() }}</span>
<span class="label">muted</span>
</button>
}
<!-- Show all toggle -->
@if (totalHidden() > 0) {
<button class="show-all-toggle"
[class.active]="showAll()"
(click)="toggleShowAll()"
[attr.aria-pressed]="showAll()">
{{ showAll() ? 'Hide gated' : 'Show all' }}
</button>
}
</div>
</div>
`,
styles: [`
.gated-buckets {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
background: var(--surface, #fff);
border-radius: 8px;
border: 1px solid var(--border-color, #e0e0e0);
}
.actionable-summary {
display: flex;
align-items: baseline;
gap: 6px;
}
.actionable-count {
font-size: 24px;
font-weight: 700;
color: var(--text-primary, #333);
}
.actionable-label {
font-size: 14px;
color: var(--text-secondary, #666);
}
.hidden-hint {
font-size: 12px;
color: var(--text-tertiary, #999);
}
.bucket-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.bucket-chip {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 14px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid transparent;
background: var(--surface-variant, #f5f5f5);
color: var(--text-secondary, #666);
}
.bucket-chip:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.bucket-chip:focus {
outline: 2px solid var(--primary-color, #1976d2);
outline-offset: 2px;
}
.bucket-chip.expanded {
background: var(--primary-light, #e3f2fd);
border-color: var(--primary-color, #1976d2);
color: var(--primary-color, #1976d2);
}
.bucket-chip .icon {
font-size: 12px;
}
.bucket-chip .count {
font-weight: 600;
}
.bucket-chip .label {
font-weight: 500;
}
/* Chip variants */
.bucket-chip.unreachable {
background: #e8f5e9;
color: #2e7d32;
}
.bucket-chip.unreachable.expanded {
background: #c8e6c9;
border-color: #2e7d32;
}
.bucket-chip.policy-dismissed {
background: #fff3e0;
color: #ef6c00;
}
.bucket-chip.policy-dismissed.expanded {
background: #ffe0b2;
border-color: #ef6c00;
}
.bucket-chip.backported {
background: #e3f2fd;
color: #1565c0;
}
.bucket-chip.backported.expanded {
background: #bbdefb;
border-color: #1565c0;
}
.bucket-chip.vex-not-affected {
background: #f3e5f5;
color: #7b1fa2;
}
.bucket-chip.vex-not-affected.expanded {
background: #e1bee7;
border-color: #7b1fa2;
}
.bucket-chip.superseded {
background: #fce4ec;
color: #c2185b;
}
.bucket-chip.superseded.expanded {
background: #f8bbd9;
border-color: #c2185b;
}
.bucket-chip.user-muted {
background: #eceff1;
color: #546e7a;
}
.bucket-chip.user-muted.expanded {
background: #cfd8dc;
border-color: #546e7a;
}
.show-all-toggle {
padding: 4px 12px;
border-radius: 14px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
background: transparent;
border: 1px dashed var(--border-color, #ccc);
color: var(--text-secondary, #666);
}
.show-all-toggle:hover {
border-style: solid;
background: var(--surface-variant, #f5f5f5);
}
.show-all-toggle.active {
background: var(--primary-light, #e3f2fd);
border: 1px solid var(--primary-color, #1976d2);
color: var(--primary-color, #1976d2);
}
`]
})
export class GatedBucketsComponent {
private _summary = signal<GatedBucketsSummary | undefined>(undefined);
private _expanded = signal<GatingReason | null>(null);
private _showAll = signal(false);
@Input()
set summary(value: GatedBucketsSummary | undefined) {
this._summary.set(value);
}
@Output() bucketExpand = new EventEmitter<BucketExpandEvent>();
@Output() showAllChange = new EventEmitter<boolean>();
// Computed signals
unreachableCount = computed(() => this._summary()?.unreachableCount ?? 0);
policyDismissedCount = computed(() => this._summary()?.policyDismissedCount ?? 0);
backportedCount = computed(() => this._summary()?.backportedCount ?? 0);
vexNotAffectedCount = computed(() => this._summary()?.vexNotAffectedCount ?? 0);
supersededCount = computed(() => this._summary()?.supersededCount ?? 0);
userMutedCount = computed(() => this._summary()?.userMutedCount ?? 0);
totalHidden = computed(() => this._summary()?.totalHiddenCount ?? 0);
actionableCount = computed(() => this._summary()?.actionableCount ?? 0);
expandedBucket = computed(() => this._expanded());
showAll = computed(() => this._showAll());
getIcon(reason: GatingReason): string {
return getGatingReasonIcon(reason);
}
getLabel(reason: GatingReason): string {
return getGatingReasonLabel(reason);
}
toggleBucket(reason: GatingReason): void {
const current = this._expanded();
if (current === reason) {
this._expanded.set(null);
} else {
this._expanded.set(reason);
const count = this.getCountForReason(reason);
this.bucketExpand.emit({ reason, count });
}
}
toggleShowAll(): void {
const newValue = !this._showAll();
this._showAll.set(newValue);
this.showAllChange.emit(newValue);
}
private getCountForReason(reason: GatingReason): number {
switch (reason) {
case 'unreachable': return this.unreachableCount();
case 'policy_dismissed': return this.policyDismissedCount();
case 'backported': return this.backportedCount();
case 'vex_not_affected': return this.vexNotAffectedCount();
case 'superseded': return this.supersededCount();
case 'user_muted': return this.userMutedCount();
default: return 0;
}
}
}

View File

@@ -0,0 +1,395 @@
// -----------------------------------------------------------------------------
// gating-explainer.component.ts
// Sprint: SPRINT_9200_0001_0004_FE_quiet_triage_ui
// Description: Modal/panel component explaining why a finding is hidden,
// with actionable links to evidence.
// -----------------------------------------------------------------------------
import { Component, Input, Output, EventEmitter, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
FindingGatingStatus,
GatingReason,
getGatingReasonLabel,
getGatingReasonIcon,
getGatingReasonClass
} from '../../models/gating.model';
@Component({
selector: 'app-gating-explainer',
standalone: true,
imports: [CommonModule],
template: `
<div class="gating-explainer" [class]="reasonClass()" [class.hidden]="!isVisible()">
<div class="explainer-header">
<span class="icon">{{ reasonIcon() }}</span>
<span class="title">{{ reasonLabel() }}</span>
<button class="close-btn" (click)="close()" aria-label="Close">×</button>
</div>
<div class="explainer-body">
<!-- Explanation text -->
<p class="explanation">{{ explanation() }}</p>
<!-- Evidence links -->
<div class="evidence-links">
@if (subgraphId()) {
<a class="evidence-link" (click)="viewReachability()">
🔗 View reachability graph
</a>
}
@if (deltasId()) {
<a class="evidence-link" (click)="viewDeltas()">
📊 View delta comparison
</a>
}
@if (hasVexTrust()) {
<a class="evidence-link" (click)="viewVexDetails()">
📝 View VEX details
</a>
}
</div>
<!-- VEX trust summary (if applicable) -->
@if (hasVexTrust()) {
<div class="vex-trust-summary">
<span class="trust-score">
Trust: {{ formatScore(vexTrustScore()) }}
</span>
@if (vexTrustThreshold()) {
<span class="trust-threshold">
/ {{ formatScore(vexTrustThreshold()) }} required
</span>
}
<span class="trust-status" [class.pass]="meetsThreshold()" [class.fail]="!meetsThreshold()">
{{ meetsThreshold() ? '✓ Meets threshold' : '✗ Below threshold' }}
</span>
</div>
}
<!-- Action hints -->
<div class="action-hints">
@switch (gatingReason()) {
@case ('unreachable') {
<p class="hint">
This finding is gated because static analysis shows the vulnerable code
path is not reachable from any entrypoint. Review the reachability graph
to verify.
</p>
}
@case ('policy_dismissed') {
<p class="hint">
This finding was dismissed by a policy rule. Check your policy configuration
to understand which rule applied.
</p>
}
@case ('backported') {
<p class="hint">
The vulnerability was patched via a distribution backport. The installed
version includes the security fix even though the version number is lower.
</p>
}
@case ('vex_not_affected') {
<p class="hint">
A trusted VEX statement declares this component is not affected.
Review the VEX document to understand the justification.
</p>
}
@case ('superseded') {
<p class="hint">
This CVE has been superseded by a newer advisory. Check for the
updated vulnerability information.
</p>
}
@case ('user_muted') {
<p class="hint">
You or another user explicitly muted this finding. You can unmute it
to restore visibility.
</p>
}
}
</div>
<!-- Ungating action -->
@if (canUngating()) {
<div class="ungating-actions">
<button class="ungating-btn" (click)="requestUngating()">
Show in actionable list
</button>
</div>
}
</div>
</div>
`,
styles: [`
.gating-explainer {
position: relative;
background: var(--surface, #fff);
border-radius: 8px;
border: 1px solid var(--border-color, #e0e0e0);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
max-width: 400px;
overflow: hidden;
}
.gating-explainer.hidden {
display: none;
}
.explainer-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.icon {
font-size: 18px;
}
.title {
flex: 1;
font-weight: 600;
font-size: 14px;
color: var(--text-primary, #333);
}
.close-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 18px;
color: var(--text-secondary, #666);
}
.close-btn:hover {
background: var(--surface-variant, #f5f5f5);
}
.explainer-body {
padding: 16px;
}
.explanation {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.5;
color: var(--text-primary, #333);
}
.evidence-links {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.evidence-link {
padding: 6px 10px;
background: var(--surface-variant, #f5f5f5);
border-radius: 4px;
font-size: 12px;
color: var(--primary-color, #1976d2);
cursor: pointer;
text-decoration: none;
transition: background 0.15s ease;
}
.evidence-link:hover {
background: var(--primary-light, #e3f2fd);
}
.vex-trust-summary {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--surface-variant, #f5f5f5);
border-radius: 4px;
margin-bottom: 12px;
font-size: 12px;
}
.trust-score {
font-weight: 600;
}
.trust-threshold {
color: var(--text-secondary, #666);
}
.trust-status {
margin-left: auto;
font-weight: 500;
}
.trust-status.pass {
color: #2e7d32;
}
.trust-status.fail {
color: #c62828;
}
.action-hints {
margin-bottom: 12px;
}
.hint {
margin: 0;
padding: 8px 12px;
background: #fff8e1;
border-left: 3px solid #ffc107;
font-size: 12px;
line-height: 1.5;
color: #5d4037;
}
.ungating-actions {
display: flex;
justify-content: flex-end;
}
.ungating-btn {
padding: 6px 12px;
background: transparent;
border: 1px solid var(--primary-color, #1976d2);
border-radius: 4px;
font-size: 12px;
font-weight: 500;
color: var(--primary-color, #1976d2);
cursor: pointer;
transition: all 0.15s ease;
}
.ungating-btn:hover {
background: var(--primary-color, #1976d2);
color: white;
}
/* Reason-specific colors */
.gating-unreachable .explainer-header {
background: #e8f5e9;
border-color: #a5d6a7;
}
.gating-policy .explainer-header {
background: #fff3e0;
border-color: #ffcc80;
}
.gating-backport .explainer-header {
background: #e3f2fd;
border-color: #90caf9;
}
.gating-vex .explainer-header {
background: #f3e5f5;
border-color: #ce93d8;
}
.gating-superseded .explainer-header {
background: #fce4ec;
border-color: #f48fb1;
}
.gating-muted .explainer-header {
background: #eceff1;
border-color: #b0bec5;
}
`]
})
export class GatingExplainerComponent {
private _status = signal<FindingGatingStatus | undefined>(undefined);
private _visible = signal(true);
@Input()
set status(value: FindingGatingStatus | undefined) {
this._status.set(value);
if (value) this._visible.set(true);
}
@Output() closeExplainer = new EventEmitter<void>();
@Output() viewReachabilityGraph = new EventEmitter<string>();
@Output() viewDeltaComparison = new EventEmitter<string>();
@Output() viewVexStatus = new EventEmitter<void>();
@Output() ungateRequest = new EventEmitter<string>();
// Computed signals
isVisible = computed(() => this._visible());
gatingReason = computed((): GatingReason => this._status()?.gatingReason ?? 'none');
reasonLabel = computed(() => getGatingReasonLabel(this.gatingReason()));
reasonIcon = computed(() => getGatingReasonIcon(this.gatingReason()));
reasonClass = computed(() => getGatingReasonClass(this.gatingReason()));
explanation = computed(() => this._status()?.gatingExplanation ?? this.getDefaultExplanation());
subgraphId = computed(() => this._status()?.subgraphId);
deltasId = computed(() => this._status()?.deltasId);
hasVexTrust = computed(() => this._status()?.vexTrustStatus !== undefined);
vexTrustScore = computed(() => this._status()?.vexTrustStatus?.trustScore);
vexTrustThreshold = computed(() => this._status()?.vexTrustStatus?.policyTrustThreshold);
meetsThreshold = computed(() => this._status()?.vexTrustStatus?.meetsPolicyThreshold ?? false);
canUngating = computed(() => {
const reason = this.gatingReason();
return reason === 'user_muted' || reason === 'policy_dismissed';
});
close(): void {
this._visible.set(false);
this.closeExplainer.emit();
}
viewReachability(): void {
const id = this.subgraphId();
if (id) this.viewReachabilityGraph.emit(id);
}
viewDeltas(): void {
const id = this.deltasId();
if (id) this.viewDeltaComparison.emit(id);
}
viewVexDetails(): void {
this.viewVexStatus.emit();
}
requestUngating(): void {
const findingId = this._status()?.findingId;
if (findingId) this.ungateRequest.emit(findingId);
}
formatScore(score?: number): string {
if (score === undefined) return '—';
return (score * 100).toFixed(0) + '%';
}
private getDefaultExplanation(): string {
switch (this.gatingReason()) {
case 'unreachable':
return 'This finding is hidden because the vulnerable code is not reachable from any application entrypoint.';
case 'policy_dismissed':
return 'This finding was dismissed by a policy rule.';
case 'backported':
return 'This vulnerability was fixed via a distribution backport.';
case 'vex_not_affected':
return 'A VEX statement from a trusted source declares this component is not affected.';
case 'superseded':
return 'This advisory has been superseded by a newer one.';
case 'user_muted':
return 'This finding was explicitly muted by a user.';
default:
return 'This finding is visible in the default view.';
}
}
}

View File

@@ -0,0 +1,385 @@
// -----------------------------------------------------------------------------
// replay-command.component.ts
// Sprint: SPRINT_9200_0001_0004_FE_quiet_triage_ui
// Description: Component for displaying and copying replay commands.
// Provides one-click copy for deterministic verdict replay.
// -----------------------------------------------------------------------------
import { Component, Input, Output, EventEmitter, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReplayCommand, ReplayCommandResponse } from '../../models/gating.model';
@Component({
selector: 'app-replay-command',
standalone: true,
imports: [CommonModule],
template: `
<div class="replay-command">
<div class="replay-header">
<span class="replay-title">Replay Command</span>
<span class="replay-subtitle">Reproduce this verdict deterministically</span>
</div>
<!-- Command tabs -->
<div class="command-tabs" role="tablist">
<button class="tab"
[class.active]="activeTab() === 'full'"
(click)="setActiveTab('full')"
role="tab"
[attr.aria-selected]="activeTab() === 'full'">
Full
</button>
@if (hasShortCommand()) {
<button class="tab"
[class.active]="activeTab() === 'short'"
(click)="setActiveTab('short')"
role="tab"
[attr.aria-selected]="activeTab() === 'short'">
Short
</button>
}
@if (hasOfflineCommand()) {
<button class="tab"
[class.active]="activeTab() === 'offline'"
(click)="setActiveTab('offline')"
role="tab"
[attr.aria-selected]="activeTab() === 'offline'">
Offline
</button>
}
</div>
<!-- Command display -->
<div class="command-container">
<pre class="command-text" [attr.data-shell]="activeCommand()?.shell">{{ activeCommand()?.command ?? 'No command available' }}</pre>
<div class="command-actions">
<button class="copy-btn"
[class.copied]="copied()"
(click)="copyCommand()"
[disabled]="!activeCommand()?.command">
{{ copied() ? '✓ Copied!' : '📋 Copy' }}
</button>
</div>
</div>
<!-- Prerequisites -->
@if (hasPrerequisites()) {
<div class="prerequisites">
<span class="prereq-label">Prerequisites:</span>
<ul class="prereq-list">
@for (prereq of activeCommand()?.prerequisites; track prereq) {
<li>{{ prereq }}</li>
}
</ul>
</div>
}
<!-- Network requirement warning -->
@if (activeCommand()?.requiresNetwork) {
<div class="network-warning">
⚠️ This command requires network access
</div>
}
<!-- Evidence bundle download -->
@if (hasBundleUrl()) {
<div class="bundle-download">
<a class="bundle-link" [href]="bundleUrl()" download>
📦 Download Evidence Bundle
</a>
@if (bundleInfo()) {
<span class="bundle-info">
{{ formatBundleSize(bundleInfo()?.sizeBytes) }} · {{ bundleInfo()?.format }}
</span>
}
</div>
}
<!-- Hash verification -->
@if (expectedHash()) {
<div class="hash-verification">
<span class="hash-label">Expected verdict hash:</span>
<code class="hash-value">{{ expectedHash() }}</code>
</div>
}
</div>
`,
styles: [`
.replay-command {
background: var(--surface, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
overflow: hidden;
}
.replay-header {
padding: 12px 16px;
background: var(--surface-variant, #f5f5f5);
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.replay-title {
display: block;
font-weight: 600;
font-size: 14px;
color: var(--text-primary, #333);
}
.replay-subtitle {
display: block;
font-size: 12px;
color: var(--text-secondary, #666);
margin-top: 2px;
}
.command-tabs {
display: flex;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.tab {
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
color: var(--text-secondary, #666);
transition: all 0.15s ease;
}
.tab:hover {
background: var(--surface-variant, #f5f5f5);
color: var(--text-primary, #333);
}
.tab.active {
color: var(--primary-color, #1976d2);
border-bottom-color: var(--primary-color, #1976d2);
}
.command-container {
padding: 12px 16px;
background: #1e1e1e;
}
.command-text {
margin: 0;
padding: 12px;
background: #2d2d2d;
border-radius: 4px;
font-family: 'Fira Code', 'Consolas', monospace;
font-size: 13px;
line-height: 1.5;
color: #d4d4d4;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
.command-text[data-shell="powershell"] {
color: #569cd6;
}
.command-text[data-shell="bash"] {
color: #b5cea8;
}
.command-actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.copy-btn {
padding: 6px 16px;
font-size: 13px;
font-weight: 500;
background: var(--primary-color, #1976d2);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.copy-btn:hover:not(:disabled) {
background: var(--primary-dark, #1565c0);
}
.copy-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.copy-btn.copied {
background: #43a047;
}
.prerequisites {
padding: 12px 16px;
background: #fff3e0;
border-top: 1px solid #ffcc80;
}
.prereq-label {
font-size: 12px;
font-weight: 600;
color: #ef6c00;
}
.prereq-list {
margin: 4px 0 0 16px;
padding: 0;
font-size: 12px;
color: #bf360c;
}
.prereq-list li {
margin: 2px 0;
}
.network-warning {
padding: 8px 16px;
background: #fff8e1;
color: #f57f17;
font-size: 12px;
border-top: 1px solid #ffecb3;
}
.bundle-download {
padding: 12px 16px;
background: var(--surface-variant, #f5f5f5);
border-top: 1px solid var(--border-color, #e0e0e0);
display: flex;
align-items: center;
gap: 12px;
}
.bundle-link {
padding: 6px 12px;
background: var(--primary-light, #e3f2fd);
color: var(--primary-color, #1976d2);
border-radius: 4px;
text-decoration: none;
font-size: 13px;
font-weight: 500;
transition: background 0.15s ease;
}
.bundle-link:hover {
background: var(--primary-color, #1976d2);
color: white;
}
.bundle-info {
font-size: 12px;
color: var(--text-secondary, #666);
}
.hash-verification {
padding: 8px 16px;
background: var(--surface, #fff);
border-top: 1px solid var(--border-color, #e0e0e0);
font-size: 12px;
}
.hash-label {
color: var(--text-secondary, #666);
}
.hash-value {
display: inline-block;
margin-left: 4px;
padding: 2px 6px;
background: var(--surface-variant, #f5f5f5);
border-radius: 2px;
font-family: 'Fira Code', monospace;
font-size: 11px;
color: var(--text-primary, #333);
}
`]
})
export class ReplayCommandComponent {
private _response = signal<ReplayCommandResponse | undefined>(undefined);
private _activeTab = signal<'full' | 'short' | 'offline'>('full');
private _copied = signal(false);
@Input()
set response(value: ReplayCommandResponse | undefined) {
this._response.set(value);
}
@Input()
set command(value: string | undefined) {
// Simple input for just a command string
if (value) {
this._response.set({
findingId: '',
scanId: '',
fullCommand: { type: 'full', command: value, shell: 'bash', requiresNetwork: false },
generatedAt: new Date().toISOString(),
expectedVerdictHash: ''
});
}
}
@Output() copySuccess = new EventEmitter<string>();
// Computed signals
activeTab = computed(() => this._activeTab());
copied = computed(() => this._copied());
hasShortCommand = computed(() => !!this._response()?.shortCommand);
hasOfflineCommand = computed(() => !!this._response()?.offlineCommand);
activeCommand = computed((): ReplayCommand | undefined => {
const response = this._response();
if (!response) return undefined;
switch (this._activeTab()) {
case 'short': return response.shortCommand ?? response.fullCommand;
case 'offline': return response.offlineCommand ?? response.fullCommand;
default: return response.fullCommand;
}
});
hasPrerequisites = computed(() => {
const prereqs = this.activeCommand()?.prerequisites;
return prereqs && prereqs.length > 0;
});
hasBundleUrl = computed(() => !!this._response()?.bundle?.downloadUri);
bundleUrl = computed(() => this._response()?.bundle?.downloadUri);
bundleInfo = computed(() => this._response()?.bundle);
expectedHash = computed(() => this._response()?.expectedVerdictHash);
setActiveTab(tab: 'full' | 'short' | 'offline'): void {
this._activeTab.set(tab);
}
async copyCommand(): Promise<void> {
const command = this.activeCommand()?.command;
if (!command) return;
try {
await navigator.clipboard.writeText(command);
this._copied.set(true);
this.copySuccess.emit(command);
setTimeout(() => this._copied.set(false), 2000);
} catch (err) {
console.error('Failed to copy command:', err);
}
}
formatBundleSize(bytes?: number): string {
if (bytes === undefined) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
}

View File

@@ -0,0 +1,397 @@
// -----------------------------------------------------------------------------
// vex-trust-display.component.ts
// Sprint: SPRINT_9200_0001_0004_FE_quiet_triage_ui
// Description: Component displaying VEX trust score vs. policy threshold.
// Shows "Score 0.62 vs required 0.8" with visual indicators.
// -----------------------------------------------------------------------------
import { Component, Input, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
VexTrustStatus,
TrustScoreBreakdown,
formatTrustScore,
getTrustScoreClass
} from '../../models/gating.model';
@Component({
selector: 'app-vex-trust-display',
standalone: true,
imports: [CommonModule],
template: `
<div class="vex-trust-display" [class]="trustClass()">
<!-- Score vs Threshold -->
<div class="trust-header">
<div class="trust-score-main">
<span class="score-value">{{ displayScore() }}</span>
<span class="score-label">trust score</span>
</div>
@if (hasThreshold()) {
<div class="threshold-comparison">
<span class="threshold-connector">vs</span>
<span class="threshold-value">{{ displayThreshold() }}</span>
<span class="threshold-label">required</span>
</div>
}
<!-- Status indicator -->
<div class="status-badge" [class]="statusBadgeClass()">
{{ statusText() }}
</div>
</div>
<!-- Progress bar visualization -->
@if (hasScore()) {
<div class="trust-bar-container">
<div class="trust-bar">
<div class="trust-fill" [style.width.%]="scorePercent()"></div>
@if (hasThreshold()) {
<div class="threshold-marker" [style.left.%]="thresholdPercent()">
<div class="marker-line"></div>
<span class="marker-label">{{ displayThreshold() }}</span>
</div>
}
</div>
</div>
}
<!-- Trust breakdown (expandable) -->
@if (hasBreakdown() && showBreakdown()) {
<div class="trust-breakdown">
<div class="breakdown-header">
<span>Trust factors</span>
<button class="collapse-btn" (click)="toggleBreakdown()">
{{ showBreakdown() ? 'Hide' : 'Show' }} details
</button>
</div>
<div class="breakdown-factors">
<div class="factor">
<span class="factor-label">Authority</span>
<div class="factor-bar">
<div class="factor-fill" [style.width.%]="authorityPercent()"></div>
</div>
<span class="factor-value">{{ formatFactor(breakdown()?.authority) }}</span>
</div>
<div class="factor">
<span class="factor-label">Accuracy</span>
<div class="factor-bar">
<div class="factor-fill" [style.width.%]="accuracyPercent()"></div>
</div>
<span class="factor-value">{{ formatFactor(breakdown()?.accuracy) }}</span>
</div>
<div class="factor">
<span class="factor-label">Timeliness</span>
<div class="factor-bar">
<div class="factor-fill" [style.width.%]="timelinessPercent()"></div>
</div>
<span class="factor-value">{{ formatFactor(breakdown()?.timeliness) }}</span>
</div>
<div class="factor">
<span class="factor-label">Verification</span>
<div class="factor-bar">
<div class="factor-fill" [style.width.%]="verificationPercent()"></div>
</div>
<span class="factor-value">{{ formatFactor(breakdown()?.verification) }}</span>
</div>
</div>
</div>
}
@if (hasBreakdown() && !showBreakdown()) {
<button class="show-breakdown-btn" (click)="toggleBreakdown()">
Show trust breakdown
</button>
}
</div>
`,
styles: [`
.vex-trust-display {
padding: 12px 16px;
border-radius: 8px;
background: var(--surface-variant, #f5f5f5);
border: 1px solid var(--border-color, #e0e0e0);
}
.trust-header {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.trust-score-main {
display: flex;
flex-direction: column;
}
.score-value {
font-size: 28px;
font-weight: 700;
line-height: 1;
}
.score-label {
font-size: 11px;
color: var(--text-secondary, #666);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.threshold-comparison {
display: flex;
align-items: baseline;
gap: 4px;
}
.threshold-connector {
font-size: 12px;
color: var(--text-tertiary, #999);
}
.threshold-value {
font-size: 20px;
font-weight: 600;
color: var(--text-secondary, #666);
}
.threshold-label {
font-size: 11px;
color: var(--text-tertiary, #999);
}
.status-badge {
margin-left: auto;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.status-badge.pass {
background: #e8f5e9;
color: #2e7d32;
}
.status-badge.fail {
background: #ffebee;
color: #c62828;
}
.status-badge.unknown {
background: #eceff1;
color: #546e7a;
}
/* Trust bar */
.trust-bar-container {
margin-top: 12px;
}
.trust-bar {
position: relative;
height: 8px;
background: var(--surface, #e0e0e0);
border-radius: 4px;
overflow: visible;
}
.trust-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.trust-pass .trust-fill {
background: linear-gradient(90deg, #66bb6a, #43a047);
}
.trust-fail .trust-fill {
background: linear-gradient(90deg, #ef5350, #e53935);
}
.trust-unknown .trust-fill {
background: linear-gradient(90deg, #90a4ae, #78909c);
}
.threshold-marker {
position: absolute;
top: -4px;
transform: translateX(-50%);
}
.marker-line {
width: 2px;
height: 16px;
background: var(--text-primary, #333);
}
.marker-label {
position: absolute;
top: 18px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
color: var(--text-secondary, #666);
white-space: nowrap;
}
/* Trust breakdown */
.trust-breakdown {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.breakdown-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary, #666);
}
.collapse-btn {
padding: 2px 8px;
font-size: 11px;
background: transparent;
border: 1px solid var(--border-color, #ccc);
border-radius: 4px;
cursor: pointer;
color: var(--text-secondary, #666);
}
.collapse-btn:hover {
background: var(--surface, #fff);
}
.breakdown-factors {
display: flex;
flex-direction: column;
gap: 8px;
}
.factor {
display: flex;
align-items: center;
gap: 8px;
}
.factor-label {
width: 80px;
font-size: 11px;
color: var(--text-secondary, #666);
}
.factor-bar {
flex: 1;
height: 6px;
background: var(--surface, #e0e0e0);
border-radius: 3px;
}
.factor-fill {
height: 100%;
background: var(--primary-color, #1976d2);
border-radius: 3px;
transition: width 0.3s ease;
}
.factor-value {
width: 40px;
text-align: right;
font-size: 11px;
font-weight: 600;
color: var(--text-primary, #333);
}
.show-breakdown-btn {
margin-top: 8px;
padding: 4px 8px;
font-size: 11px;
background: transparent;
border: 1px dashed var(--border-color, #ccc);
border-radius: 4px;
cursor: pointer;
color: var(--text-secondary, #666);
}
.show-breakdown-btn:hover {
border-style: solid;
background: var(--surface, #fff);
}
/* Trust level colors */
.trust-pass {
border-color: #a5d6a7;
}
.trust-fail {
border-color: #ef9a9a;
}
.trust-unknown {
border-color: #b0bec5;
}
`]
})
export class VexTrustDisplayComponent {
private _status = signal<VexTrustStatus | undefined>(undefined);
private _showBreakdown = signal(false);
@Input()
set status(value: VexTrustStatus | undefined) {
this._status.set(value);
}
// Computed signals
hasScore = computed(() => this._status()?.trustScore !== undefined);
hasThreshold = computed(() => this._status()?.policyTrustThreshold !== undefined);
hasBreakdown = computed(() => this._status()?.trustBreakdown !== undefined);
breakdown = computed(() => this._status()?.trustBreakdown);
showBreakdown = computed(() => this._showBreakdown());
displayScore = computed(() => formatTrustScore(this._status()?.trustScore));
displayThreshold = computed(() => formatTrustScore(this._status()?.policyTrustThreshold));
scorePercent = computed(() => (this._status()?.trustScore ?? 0) * 100);
thresholdPercent = computed(() => (this._status()?.policyTrustThreshold ?? 0) * 100);
meetsThreshold = computed(() => this._status()?.meetsPolicyThreshold ?? false);
trustClass = computed(() => {
if (!this.hasScore()) return 'trust-unknown';
return this.meetsThreshold() ? 'trust-pass' : 'trust-fail';
});
statusBadgeClass = computed(() => {
if (!this.hasScore()) return 'unknown';
return this.meetsThreshold() ? 'pass' : 'fail';
});
statusText = computed(() => {
if (!this.hasScore()) return 'Unknown';
return this.meetsThreshold() ? '✓ Meets threshold' : '✗ Below threshold';
});
// Breakdown percents
authorityPercent = computed(() => (this.breakdown()?.authority ?? 0) * 100);
accuracyPercent = computed(() => (this.breakdown()?.accuracy ?? 0) * 100);
timelinessPercent = computed(() => (this.breakdown()?.timeliness ?? 0) * 100);
verificationPercent = computed(() => (this.breakdown()?.verification ?? 0) * 100);
formatFactor(value?: number): string {
if (value === undefined) return '—';
return (value * 100).toFixed(0) + '%';
}
toggleBreakdown(): void {
this._showBreakdown.update(v => !v);
}
}

View File

@@ -0,0 +1,379 @@
// -----------------------------------------------------------------------------
// gating.model.ts
// Sprint: SPRINT_9200_0001_0004_FE_quiet_triage_ui
// Description: Models for gated triage - bucket chips, VEX trust display,
// and replay command support.
// -----------------------------------------------------------------------------
/**
* Gating reason enum values - matches backend GatingReason enum.
*/
export type GatingReason =
| 'none'
| 'unreachable'
| 'policy_dismissed'
| 'backported'
| 'vex_not_affected'
| 'superseded'
| 'user_muted';
/**
* Gated bucket summary for chip display.
*/
export interface GatedBucketsSummary {
readonly scanId: string;
readonly unreachableCount: number;
readonly policyDismissedCount: number;
readonly backportedCount: number;
readonly vexNotAffectedCount: number;
readonly supersededCount: number;
readonly userMutedCount: number;
readonly totalHiddenCount: number;
readonly actionableCount: number;
readonly totalCount: number;
readonly computedAt: string;
}
/**
* Gating status for a finding.
*/
export interface FindingGatingStatus {
readonly findingId: string;
readonly gatingReason: GatingReason;
readonly isHiddenByDefault: boolean;
readonly subgraphId?: string;
readonly deltasId?: string;
readonly gatingExplanation?: string;
readonly vexTrustStatus?: VexTrustStatus;
}
/**
* VEX trust status with threshold comparison.
*/
export interface VexTrustStatus {
readonly trustScore?: number;
readonly policyTrustThreshold?: number;
readonly meetsPolicyThreshold?: boolean;
readonly trustBreakdown?: TrustScoreBreakdown;
}
/**
* Breakdown of VEX trust score factors.
*/
export interface TrustScoreBreakdown {
readonly authority: number;
readonly accuracy: number;
readonly timeliness: number;
readonly verification: number;
}
/**
* Unified evidence response from API.
*/
export interface UnifiedEvidenceResponse {
readonly findingId: string;
readonly cveId: string;
readonly componentPurl: string;
readonly sbom?: SbomEvidence;
readonly reachability?: ReachabilityEvidence;
readonly vexClaims?: readonly VexClaimDetail[];
readonly attestations?: readonly AttestationSummary[];
readonly deltas?: DeltaEvidence;
readonly policy?: PolicyEvidence;
readonly manifests: ManifestHashes;
readonly verification: VerificationStatus;
readonly replayCommand?: string;
readonly shortReplayCommand?: string;
readonly evidenceBundleUrl?: string;
readonly generatedAt: string;
readonly cacheKey?: string;
}
/**
* SBOM evidence.
*/
export interface SbomEvidence {
readonly format: string;
readonly version: string;
readonly documentUri: string;
readonly digest: string;
readonly component?: SbomComponent;
readonly dependencies?: readonly string[];
readonly dependents?: readonly string[];
}
/**
* SBOM component information.
*/
export interface SbomComponent {
readonly purl: string;
readonly name: string;
readonly version: string;
readonly ecosystem?: string;
readonly licenses?: readonly string[];
readonly cpes?: readonly string[];
}
/**
* Reachability evidence.
*/
export interface ReachabilityEvidence {
readonly subgraphId: string;
readonly status: string;
readonly confidence: number;
readonly method: string;
readonly entryPoints?: readonly EntryPoint[];
readonly callChain?: CallChainSummary;
readonly graphUri?: string;
}
/**
* Entry point information.
*/
export interface EntryPoint {
readonly id: string;
readonly type: string;
readonly name: string;
readonly location?: string;
readonly distance?: number;
}
/**
* Call chain summary.
*/
export interface CallChainSummary {
readonly pathLength: number;
readonly pathCount: number;
readonly keySymbols?: readonly string[];
readonly callGraphUri?: string;
}
/**
* VEX claim with trust score.
*/
export interface VexClaimDetail {
readonly statementId: string;
readonly source: string;
readonly status: string;
readonly justification?: string;
readonly impactStatement?: string;
readonly issuedAt?: string;
readonly trustScore?: number;
readonly meetsPolicyThreshold?: boolean;
readonly documentUri?: string;
}
/**
* Attestation summary.
*/
export interface AttestationSummary {
readonly id: string;
readonly predicateType: string;
readonly subjectDigest: string;
readonly signer?: string;
readonly signedAt?: string;
readonly verificationStatus: string;
readonly transparencyLogEntry?: string;
readonly attestationUri?: string;
}
/**
* Delta evidence.
*/
export interface DeltaEvidence {
readonly deltaId: string;
readonly previousScanId: string;
readonly currentScanId: string;
readonly comparedAt?: string;
readonly summary?: DeltaSummary;
readonly deltaReportUri?: string;
}
/**
* Delta summary.
*/
export interface DeltaSummary {
readonly addedCount: number;
readonly removedCount: number;
readonly changedCount: number;
readonly isNew: boolean;
readonly statusChanged: boolean;
readonly previousStatus?: string;
}
/**
* Policy evidence.
*/
export interface PolicyEvidence {
readonly policyVersion: string;
readonly policyDigest: string;
readonly verdict: string;
readonly rulesFired?: readonly PolicyRuleFired[];
readonly counterfactuals?: readonly string[];
readonly policyDocumentUri?: string;
}
/**
* Policy rule that fired.
*/
export interface PolicyRuleFired {
readonly ruleId: string;
readonly name: string;
readonly effect: string;
readonly reason?: string;
}
/**
* Manifest hashes for verification.
*/
export interface ManifestHashes {
readonly artifactDigest: string;
readonly manifestHash: string;
readonly feedSnapshotHash: string;
readonly policyHash: string;
readonly knowledgeSnapshotId?: string;
readonly graphRevisionId?: string;
}
/**
* Verification status.
*/
export interface VerificationStatus {
readonly status: 'verified' | 'partial' | 'failed' | 'unknown';
readonly hashesVerified: boolean;
readonly attestationsVerified: boolean;
readonly evidenceComplete: boolean;
readonly issues?: readonly string[];
readonly verifiedAt?: string;
}
/**
* Replay command response.
*/
export interface ReplayCommandResponse {
readonly findingId: string;
readonly scanId: string;
readonly fullCommand: ReplayCommand;
readonly shortCommand?: ReplayCommand;
readonly offlineCommand?: ReplayCommand;
readonly snapshot?: SnapshotInfo;
readonly bundle?: EvidenceBundleInfo;
readonly generatedAt: string;
readonly expectedVerdictHash: string;
}
/**
* Replay command.
*/
export interface ReplayCommand {
readonly type: string;
readonly command: string;
readonly shell: string;
readonly parts?: ReplayCommandParts;
readonly requiresNetwork: boolean;
readonly prerequisites?: readonly string[];
}
/**
* Replay command parts.
*/
export interface ReplayCommandParts {
readonly binary: string;
readonly subcommand: string;
readonly target: string;
readonly arguments?: Record<string, string>;
readonly flags?: readonly string[];
}
/**
* Snapshot info.
*/
export interface SnapshotInfo {
readonly id: string;
readonly createdAt: string;
readonly feedVersions?: Record<string, string>;
readonly downloadUri?: string;
readonly contentHash?: string;
}
/**
* Evidence bundle download info.
*/
export interface EvidenceBundleInfo {
readonly id: string;
readonly downloadUri: string;
readonly sizeBytes?: number;
readonly contentHash: string;
readonly format: string;
readonly expiresAt?: string;
readonly contents?: readonly string[];
}
// === Helper Functions ===
/**
* Get display label for gating reason.
*/
export function getGatingReasonLabel(reason: GatingReason): string {
switch (reason) {
case 'none': return 'Not gated';
case 'unreachable': return 'Unreachable';
case 'policy_dismissed': return 'Policy dismissed';
case 'backported': return 'Backported';
case 'vex_not_affected': return 'VEX not affected';
case 'superseded': return 'Superseded';
case 'user_muted': return 'User muted';
default: return reason;
}
}
/**
* Get icon for gating reason.
*/
export function getGatingReasonIcon(reason: GatingReason): string {
switch (reason) {
case 'none': return '✓';
case 'unreachable': return '🔗';
case 'policy_dismissed': return '📋';
case 'backported': return '🔧';
case 'vex_not_affected': return '📝';
case 'superseded': return '🔄';
case 'user_muted': return '🔇';
default: return '?';
}
}
/**
* Get CSS class for gating reason.
*/
export function getGatingReasonClass(reason: GatingReason): string {
switch (reason) {
case 'none': return 'gating-none';
case 'unreachable': return 'gating-unreachable';
case 'policy_dismissed': return 'gating-policy';
case 'backported': return 'gating-backport';
case 'vex_not_affected': return 'gating-vex';
case 'superseded': return 'gating-superseded';
case 'user_muted': return 'gating-muted';
default: return 'gating-unknown';
}
}
/**
* Format trust score for display.
*/
export function formatTrustScore(score?: number): string {
if (score === undefined || score === null) return '—';
return (score * 100).toFixed(0) + '%';
}
/**
* Get trust score color class.
*/
export function getTrustScoreClass(score?: number, threshold?: number): string {
if (score === undefined || score === null) return 'trust-unknown';
if (threshold !== undefined && score >= threshold) return 'trust-pass';
if (score >= 0.8) return 'trust-high';
if (score >= 0.5) return 'trust-medium';
return 'trust-low';
}

View File

@@ -0,0 +1,186 @@
// -----------------------------------------------------------------------------
// gating.service.ts
// Sprint: SPRINT_9200_0001_0004_FE_quiet_triage_ui
// Description: Service for fetching gating information and unified evidence.
// -----------------------------------------------------------------------------
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, catchError, of } from 'rxjs';
import {
FindingGatingStatus,
GatedBucketsSummary,
UnifiedEvidenceResponse,
ReplayCommandResponse
} from '../models/gating.model';
@Injectable({
providedIn: 'root'
})
export class GatingService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/triage';
/**
* Get gating status for a single finding.
*/
getGatingStatus(findingId: string): Observable<FindingGatingStatus | null> {
return this.http.get<FindingGatingStatus>(`${this.baseUrl}/findings/${findingId}/gating`)
.pipe(
catchError(err => {
console.error(`Failed to get gating status for ${findingId}:`, err);
return of(null);
})
);
}
/**
* Get gating status for multiple findings.
*/
getBulkGatingStatus(findingIds: string[]): Observable<FindingGatingStatus[]> {
return this.http.post<FindingGatingStatus[]>(
`${this.baseUrl}/findings/gating/batch`,
{ findingIds }
).pipe(
catchError(err => {
console.error('Failed to get bulk gating status:', err);
return of([]);
})
);
}
/**
* Get gated buckets summary for a scan.
*/
getGatedBucketsSummary(scanId: string): Observable<GatedBucketsSummary | null> {
return this.http.get<GatedBucketsSummary>(`${this.baseUrl}/scans/${scanId}/gated-buckets`)
.pipe(
catchError(err => {
console.error(`Failed to get gated buckets for scan ${scanId}:`, err);
return of(null);
})
);
}
/**
* Get unified evidence for a finding.
*/
getUnifiedEvidence(
findingId: string,
options?: {
includeSbom?: boolean;
includeReachability?: boolean;
includeVex?: boolean;
includeAttestations?: boolean;
includeDeltas?: boolean;
includePolicy?: boolean;
includeReplayCommand?: boolean;
}
): Observable<UnifiedEvidenceResponse | null> {
let params = new HttpParams();
if (options) {
if (options.includeSbom !== undefined) {
params = params.set('includeSbom', options.includeSbom.toString());
}
if (options.includeReachability !== undefined) {
params = params.set('includeReachability', options.includeReachability.toString());
}
if (options.includeVex !== undefined) {
params = params.set('includeVex', options.includeVex.toString());
}
if (options.includeAttestations !== undefined) {
params = params.set('includeAttestations', options.includeAttestations.toString());
}
if (options.includeDeltas !== undefined) {
params = params.set('includeDeltas', options.includeDeltas.toString());
}
if (options.includePolicy !== undefined) {
params = params.set('includePolicy', options.includePolicy.toString());
}
if (options.includeReplayCommand !== undefined) {
params = params.set('includeReplayCommand', options.includeReplayCommand.toString());
}
}
return this.http.get<UnifiedEvidenceResponse>(`${this.baseUrl}/findings/${findingId}/evidence`, { params })
.pipe(
catchError(err => {
console.error(`Failed to get unified evidence for ${findingId}:`, err);
return of(null);
})
);
}
/**
* Get replay command for a finding.
*/
getReplayCommand(
findingId: string,
options?: {
shells?: string[];
includeOffline?: boolean;
generateBundle?: boolean;
}
): Observable<ReplayCommandResponse | null> {
let params = new HttpParams();
if (options) {
if (options.shells) {
options.shells.forEach(shell => {
params = params.append('shells', shell);
});
}
if (options.includeOffline !== undefined) {
params = params.set('includeOffline', options.includeOffline.toString());
}
if (options.generateBundle !== undefined) {
params = params.set('generateBundle', options.generateBundle.toString());
}
}
return this.http.get<ReplayCommandResponse>(`${this.baseUrl}/findings/${findingId}/replay-command`, { params })
.pipe(
catchError(err => {
console.error(`Failed to get replay command for ${findingId}:`, err);
return of(null);
})
);
}
/**
* Get replay command for an entire scan.
*/
getScanReplayCommand(
scanId: string,
options?: {
shells?: string[];
includeOffline?: boolean;
generateBundle?: boolean;
}
): Observable<ReplayCommandResponse | null> {
let params = new HttpParams();
if (options) {
if (options.shells) {
options.shells.forEach(shell => {
params = params.append('shells', shell);
});
}
if (options.includeOffline !== undefined) {
params = params.set('includeOffline', options.includeOffline.toString());
}
if (options.generateBundle !== undefined) {
params = params.set('generateBundle', options.generateBundle.toString());
}
}
return this.http.get<ReplayCommandResponse>(`${this.baseUrl}/scans/${scanId}/replay-command`, { params })
.pipe(
catchError(err => {
console.error(`Failed to get scan replay command for ${scanId}:`, err);
return of(null);
})
);
}
}