sprints work
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user