@@ -295,6 +300,7 @@ export interface PoEEdge {
type="button"
class="poe-drawer__action poe-drawer__action--primary"
(click)="handleExport()"
+ data-testid="poe-export-btn"
>
Export PoE Artifact
@@ -302,6 +308,7 @@ export interface PoEEdge {
type="button"
class="poe-drawer__action poe-drawer__action--secondary"
(click)="handleVerify()"
+ data-testid="poe-verify-btn"
>
Verify Offline
@@ -318,25 +325,22 @@ export interface PoEEdge {
styles: [`
.poe-drawer {
position: fixed;
- top: 0;
- right: 0;
- bottom: 0;
- left: 0;
+ inset: 0;
z-index: 1000;
pointer-events: none;
- transition: opacity 0.3s;
opacity: 0;
+ transition: opacity 0.3s ease;
+ }
- &--open {
- pointer-events: auto;
- opacity: 1;
- }
+ .poe-drawer--open {
+ pointer-events: auto;
+ opacity: 1;
}
.poe-drawer__backdrop {
position: absolute;
inset: 0;
- background: rgba(0, 0, 0, 0.5);
+ background: rgb(0 0 0 / 0.48);
backdrop-filter: blur(2px);
}
@@ -345,17 +349,17 @@ export interface PoEEdge {
top: 0;
right: 0;
bottom: 0;
- width: min(600px, 90vw);
- background: var(--color-surface-primary);
- box-shadow: -4px 0 16px rgba(0, 0, 0, 0.2);
+ width: min(640px, 92vw);
display: flex;
flex-direction: column;
+ background: var(--color-surface-primary);
+ box-shadow: -4px 0 16px rgb(0 0 0 / 0.22);
transform: translateX(100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ }
- .poe-drawer--open & {
- transform: translateX(0);
- }
+ .poe-drawer--open .poe-drawer__panel {
+ transform: translateX(0);
}
.poe-drawer__header {
@@ -368,38 +372,38 @@ export interface PoEEdge {
display: flex;
align-items: center;
justify-content: space-between;
+ gap: 1rem;
margin-bottom: 1rem;
}
.poe-drawer__title {
+ margin: 0;
font-size: 1.25rem;
font-weight: var(--font-weight-semibold);
- margin: 0;
}
.poe-drawer__close {
- background: none;
border: none;
- font-size: 1.5rem;
+ background: transparent;
+ color: inherit;
cursor: pointer;
padding: 0.25rem;
line-height: 1;
- opacity: 0.6;
- transition: opacity 0.15s;
+ opacity: 0.72;
+ }
- &:hover {
- opacity: 1;
- }
+ .poe-drawer__close:hover {
+ opacity: 1;
}
.poe-drawer__meta {
- display: flex;
- flex-direction: column;
+ display: grid;
gap: 0.5rem;
}
.poe-drawer__meta-item {
display: flex;
+ flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.875rem;
}
@@ -410,7 +414,7 @@ export interface PoEEdge {
}
.poe-drawer__meta-value {
- font-family: 'Monaco', 'Menlo', monospace;
+ font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.8125rem;
word-break: break-all;
}
@@ -423,16 +427,16 @@ export interface PoEEdge {
.poe-drawer__section {
margin-bottom: 2rem;
+ }
- &:last-child {
- margin-bottom: 0;
- }
+ .poe-drawer__section:last-child {
+ margin-bottom: 0;
}
.poe-drawer__section-title {
+ margin: 0 0 1rem;
font-size: 1rem;
font-weight: var(--font-weight-semibold);
- margin: 0 0 1rem;
}
.poe-drawer__status-grid {
@@ -448,32 +452,28 @@ export interface PoEEdge {
font-size: 0.875rem;
}
- .poe-drawer__status-icon {
- font-size: 1.25rem;
- }
-
.poe-drawer__status-valid {
color: var(--color-status-success);
font-weight: var(--font-weight-medium);
}
.poe-drawer__paths {
- display: flex;
- flex-direction: column;
- gap: 1.5rem;
+ display: grid;
+ gap: 1.25rem;
}
.poe-drawer__path {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
- padding: 1rem;
background: var(--color-surface-secondary);
+ padding: 1rem;
}
.poe-drawer__path-header {
display: flex;
justify-content: space-between;
- align-items: center;
+ gap: 0.75rem;
+ flex-wrap: wrap;
margin-bottom: 1rem;
font-size: 0.875rem;
}
@@ -488,16 +488,15 @@ export interface PoEEdge {
}
.poe-drawer__path-viz {
- display: flex;
- flex-direction: column;
+ display: grid;
gap: 0.5rem;
}
.poe-drawer__node {
- padding: 0.75rem;
- background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
+ background: var(--color-surface-primary);
+ padding: 0.75rem;
}
.poe-drawer__node--entry {
@@ -509,16 +508,16 @@ export interface PoEEdge {
}
.poe-drawer__node-symbol {
- font-family: 'Monaco', 'Menlo', monospace;
+ font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
word-break: break-all;
}
.poe-drawer__node-location {
+ margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--color-text-secondary);
- margin-top: 0.25rem;
}
.poe-drawer__arrow {
@@ -544,8 +543,8 @@ export interface PoEEdge {
.poe-drawer__guard {
background: var(--color-surface-tertiary);
- padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
+ padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
@@ -554,33 +553,33 @@ export interface PoEEdge {
grid-template-columns: auto 1fr;
gap: 0.75rem 1rem;
font-size: 0.875rem;
+ }
- dt {
- font-weight: var(--font-weight-medium);
- color: var(--color-text-secondary);
- }
+ .poe-drawer__metadata dt {
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-secondary);
+ }
- dd {
- margin: 0;
- word-break: break-all;
- }
+ .poe-drawer__metadata dd {
+ margin: 0;
+ word-break: break-all;
+ }
- code {
- font-family: 'Monaco', 'Menlo', monospace;
- font-size: 0.8125rem;
- }
+ .poe-drawer__metadata code {
+ font-family: ui-monospace, SFMono-Regular, monospace;
+ font-size: 0.8125rem;
}
.poe-drawer__hash {
- background: var(--color-surface-tertiary);
- padding: 0.25rem 0.5rem;
- border-radius: var(--radius-sm);
display: inline-block;
+ background: var(--color-surface-tertiary);
+ border-radius: var(--radius-sm);
+ padding: 0.25rem 0.5rem;
}
.poe-drawer__repro-intro {
- font-size: 0.875rem;
margin: 0 0 0.75rem;
+ font-size: 0.875rem;
}
.poe-drawer__repro-steps {
@@ -588,10 +587,10 @@ export interface PoEEdge {
padding-left: 1.5rem;
font-size: 0.875rem;
line-height: 1.6;
+ }
- li {
- margin-bottom: 0.5rem;
- }
+ .poe-drawer__repro-steps li {
+ margin-bottom: 0.5rem;
}
.poe-drawer__actions {
@@ -599,36 +598,28 @@ export interface PoEEdge {
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--color-border-primary);
+ flex-shrink: 0;
}
.poe-drawer__action {
flex: 1;
- padding: 0.75rem 1rem;
border-radius: var(--radius-sm);
+ padding: 0.75rem 1rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
- transition: all 0.15s;
+ }
- &--primary {
- background: var(--color-brand-primary);
- color: var(--color-surface-primary);
- border: none;
+ .poe-drawer__action--primary {
+ border: none;
+ background: var(--color-brand-primary);
+ color: var(--color-text-heading);
+ }
- &:hover {
- background: var(--color-brand-primary-hover);
- }
- }
-
- &--secondary {
- background: var(--color-surface-secondary);
- color: var(--color-text-primary);
- border: 1px solid var(--color-border-primary);
-
- &:hover {
- background: var(--color-surface-tertiary);
- }
- }
+ .poe-drawer__action--secondary {
+ border: 1px solid var(--color-border-primary);
+ background: var(--color-surface-secondary);
+ color: var(--color-text-primary);
}
.poe-drawer__empty {
@@ -636,6 +627,12 @@ export interface PoEEdge {
padding: 3rem 1rem;
color: var(--color-text-secondary);
}
+
+ @media (max-width: 720px) {
+ .poe-drawer__actions {
+ flex-direction: column;
+ }
+ }
`]
})
export class PoEDrawerComponent {
@@ -681,7 +678,14 @@ export class PoEDrawerComponent {
}
formatDate(isoDate: string): string {
- return new Date(isoDate).toLocaleString();
+ return new Intl.DateTimeFormat('en-US', {
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ month: 'short',
+ timeZone: 'UTC',
+ year: 'numeric',
+ }).format(new Date(isoDate));
}
hasGuards(path: PoEPath): boolean {
diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.html b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.html
new file mode 100644
index 000000000..5ce5d11ed
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.html
@@ -0,0 +1,326 @@
+
+
+
+ @if (message()) {
+
+ {{ message() }}
+ Close
+
+ }
+
+
+
+ Healthy assets
+ {{ okCount() }}
+ {{ fleetCoveragePercent() }}% fleet coverage
+
+
+ Stale facts
+ {{ staleCount() }}
+ {{ staleWitnessCount() }} stale witness observations
+
+
+ Missing sensors
+ {{ missingCount() }}
+ {{ sensorCoveragePercent() }}% sensor coverage
+
+
+ Confirmed witnesses
+ {{ confirmedWitnessCount() }}
+ {{ witnesses().length }} total witness records
+
+
+
+
+
+ Coverage
+
+
+ Witnesses
+
+
+ PoE / Exposure
+
+
+ Sensor Gaps
+
+
+
+ @if (activeTab() === 'coverage') {
+
+
+
+ All
+
+
+ Healthy
+
+
+ Stale
+
+
+ Missing
+
+
+
+
+
+
+
+ Asset
+ Environment
+ Coverage
+ Sensors
+ Last fact
+ Hot CVEs
+
+
+
+ @for (row of filteredCoverageRows(); track row.assetId) {
+
+ {{ row.assetId }}
+ {{ row.environment }}
+ {{ row.coveragePercent }}%
+ {{ row.sensorsOnline }}/{{ row.sensorsExpected }}
+ {{ formatDate(row.lastFactAt) }}
+
+
+ @for (cve of row.hotCves; track cve) {
+ {{ cve }}
+ }
+
+
+
+ }
+
+
+
+
+ }
+
+ @if (activeTab() === 'witnesses') {
+
+
+
+ Search
+
+
+
+
+ Tier
+
+ All tiers
+ Confirmed
+ Likely
+ Present
+ Unreachable
+ Unknown
+
+
+
+
+ Verdict
+
+ All
+ Reachable
+ Unreachable
+
+
+
+
+ @if (witnessLoading()) {
+ Loading witnesses...
+ } @else if (!filteredWitnesses().length) {
+ No witnesses match the current filters.
+ } @else {
+
+
+
+
+ Witness
+ CVE
+ Package
+ Tier
+ Confidence
+ Observed
+ Actions
+
+
+
+ @for (witness of filteredWitnesses(); track trackByWitness($index, witness)) {
+
+
+ {{ witness.witnessId }}
+ {{ reachabilityLabel(witness) }}
+
+ {{ witness.cveId ?? witness.vulnId }}
+
+ {{ witness.packageName }}
+ {{ witness.packageVersion ?? 'n/a' }}
+
+ {{ witness.confidenceTier }}
+ {{ confidenceLabel(witness) }}
+ {{ formatDate(witness.observedAt) }}
+
+
+ Open witness
+
+
+ Open PoE
+
+
+
+ }
+
+
+
+ }
+
+ }
+
+ @if (activeTab() === 'poe') {
+
+ @if (!poeArtifacts().length) {
+ No proof-of-exposure artifacts are available.
+ } @else {
+
+
+
+
+ Proof
+ Component
+ Signed
+ Rekor
+ Generated
+ Actions
+
+
+
+ @for (artifact of poeArtifacts(); track artifact.vulnId) {
+
+
+ {{ artifact.vulnId }}
+ {{ artifact.paths.length }} path(s)
+
+ {{ artifact.componentPurl }}
+ {{ artifact.isSigned ? 'Signed' : 'Unsigned' }}
+ {{ artifact.hasRekorTimestamp ? 'Present' : 'Missing' }}
+ {{ formatDate(artifact.generatedAt) }}
+
+
+ Inspect proof
+
+
+
+ }
+
+
+
+ }
+
+ }
+
+ @if (activeTab() === 'gaps') {
+
+
+ @for (gap of gapRows(); track trackByGap($index, gap)) {
+
+
+
+ @for (item of gap.backlog; track item) {
+ {{ item }}
+ }
+
+
+ }
+
+
+ }
+
+
+
diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.scss b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.scss
new file mode 100644
index 000000000..bd1194df9
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.scss
@@ -0,0 +1,319 @@
+:host {
+ display: block;
+}
+
+.reachability-shell {
+ display: grid;
+ gap: 1rem;
+}
+
+.shell-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: flex-start;
+}
+
+.eyebrow {
+ margin: 0;
+ color: var(--color-accent-cyan);
+ font-size: 0.78rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+h1 {
+ margin: 0.2rem 0 0;
+ font-size: 1.55rem;
+}
+
+.subtitle {
+ margin: 0.35rem 0 0;
+ color: var(--color-text-secondary);
+}
+
+.header-actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.btn-secondary,
+.btn-link,
+.dismiss,
+.tab-strip button,
+.pill {
+ cursor: pointer;
+}
+
+.btn-secondary {
+ border: 1px solid var(--color-border-primary);
+ background: var(--color-surface-primary);
+ color: var(--color-text-primary);
+ border-radius: var(--radius-md);
+ padding: 0.55rem 0.85rem;
+}
+
+.message-banner {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.75rem 0.9rem;
+ border: 1px solid var(--color-severity-low-border);
+ border-radius: var(--radius-lg);
+ background: color-mix(in srgb, var(--color-severity-low) 12%, transparent);
+}
+
+.message-banner.error {
+ border-color: var(--color-severity-medium-border);
+ background: color-mix(in srgb, var(--color-severity-medium) 12%, transparent);
+}
+
+.dismiss {
+ border: none;
+ background: transparent;
+ color: inherit;
+}
+
+.summary-grid {
+ display: grid;
+ gap: 0.75rem;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+}
+
+.summary-card {
+ display: grid;
+ gap: 0.2rem;
+ padding: 0.95rem 1rem;
+ border: 1px solid var(--color-border-primary);
+ border-radius: var(--radius-xl);
+ background: var(--color-surface-secondary);
+}
+
+.summary-card strong {
+ font-size: 1.6rem;
+}
+
+.summary-card .label {
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--color-text-secondary);
+}
+
+.summary-card span:last-child {
+ color: var(--color-text-secondary);
+ font-size: 0.82rem;
+}
+
+.summary-card--warning strong {
+ color: var(--color-severity-medium);
+}
+
+.summary-card--danger strong {
+ color: var(--color-status-error);
+}
+
+.summary-card--info strong {
+ color: var(--color-accent-cyan);
+}
+
+.tab-strip {
+ display: flex;
+ gap: 0.35rem;
+ flex-wrap: wrap;
+}
+
+.tab-strip button {
+ border: 1px solid var(--color-border-primary);
+ background: var(--color-surface-primary);
+ color: var(--color-text-secondary);
+ border-radius: var(--radius-full);
+ padding: 0.45rem 0.9rem;
+}
+
+.tab-strip button.active {
+ color: var(--color-accent-cyan);
+ border-color: var(--color-accent-cyan);
+}
+
+.panel-stack {
+ display: grid;
+ gap: 0.85rem;
+}
+
+.filter-pills,
+.toolbar {
+ display: flex;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ align-items: end;
+}
+
+.pill {
+ border: 1px solid var(--color-border-primary);
+ background: var(--color-surface-secondary);
+ color: var(--color-text-primary);
+ border-radius: var(--radius-full);
+ padding: 0.35rem 0.8rem;
+}
+
+.pill--active {
+ border-color: var(--color-accent-cyan);
+ color: var(--color-accent-cyan);
+}
+
+.field {
+ display: grid;
+ gap: 0.35rem;
+ min-width: 180px;
+}
+
+.field span {
+ font-size: 0.78rem;
+ color: var(--color-text-secondary);
+}
+
+.field input,
+.field select {
+ min-height: 2.4rem;
+ border: 1px solid var(--color-border-primary);
+ background: var(--color-surface-primary);
+ color: var(--color-text-primary);
+ border-radius: var(--radius-md);
+ padding: 0.55rem 0.75rem;
+}
+
+.field--search {
+ flex: 1 1 280px;
+}
+
+.table-panel {
+ border: 1px solid var(--color-border-primary);
+ border-radius: var(--radius-xl);
+ background: var(--color-surface-secondary);
+ overflow: hidden;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+th,
+td {
+ padding: 0.8rem 0.9rem;
+ border-bottom: 1px solid var(--color-border-primary);
+ text-align: left;
+ vertical-align: top;
+}
+
+th {
+ font-size: 0.73rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--color-text-secondary);
+}
+
+td code {
+ font-family: ui-monospace, monospace;
+ word-break: break-word;
+}
+
+.actions-cell {
+ display: flex;
+ gap: 0.6rem;
+ flex-wrap: wrap;
+}
+
+.btn-link {
+ border: none;
+ background: transparent;
+ color: var(--color-brand-primary);
+ padding: 0;
+ text-decoration: none;
+}
+
+.subtle {
+ display: block;
+ margin-top: 0.25rem;
+ font-size: 0.76rem;
+ color: var(--color-text-secondary);
+}
+
+.chip-row {
+ display: flex;
+ gap: 0.35rem;
+ flex-wrap: wrap;
+}
+
+.chip {
+ display: inline-flex;
+ align-items: center;
+ border: 1px solid var(--color-border-primary);
+ border-radius: var(--radius-full);
+ padding: 0.15rem 0.5rem;
+ font-size: 0.72rem;
+ background: var(--color-surface-primary);
+}
+
+.chip--tier {
+ text-transform: capitalize;
+}
+
+.empty-state {
+ padding: 1rem;
+ border: 1px dashed var(--color-border-secondary);
+ border-radius: var(--radius-lg);
+ color: var(--color-text-secondary);
+}
+
+.gap-grid {
+ display: grid;
+ gap: 0.8rem;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+}
+
+.gap-card {
+ display: grid;
+ gap: 0.75rem;
+ padding: 1rem;
+ border: 1px solid var(--color-border-primary);
+ border-radius: var(--radius-xl);
+ background: var(--color-surface-secondary);
+}
+
+.gap-card--critical {
+ border-color: var(--color-severity-critical-border);
+}
+
+.gap-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.75rem;
+ align-items: center;
+}
+
+.gap-card ul {
+ margin: 0;
+ padding-left: 1.2rem;
+ color: var(--color-text-secondary);
+}
+
+@media (max-width: 840px) {
+ .shell-header {
+ flex-direction: column;
+ }
+
+ .toolbar {
+ align-items: stretch;
+ }
+
+ .field {
+ min-width: 100%;
+ }
+
+ .actions-cell {
+ flex-direction: column;
+ }
+}
diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts
index 21efcb33d..d0c96e7c6 100644
--- a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts
@@ -1,472 +1,471 @@
-ο»Ώimport { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ DestroyRef,
+ OnInit,
+ computed,
+ inject,
+ signal,
+} from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { ActivatedRoute, ParamMap, Router, RouterLink } from '@angular/router';
+import { combineLatest, firstValueFrom } from 'rxjs';
-type CoverageStatus = 'ok' | 'stale' | 'missing';
+import { WITNESS_API, type WitnessApi } from '../../core/api/witness.client';
+import type {
+ ConfidenceTier,
+ ReachabilityWitness,
+} from '../../core/api/witness.models';
+import { PoEDrawerComponent } from './poe-drawer.component';
+import {
+ type CoverageStatus,
+ DEFAULT_REACHABILITY_SCAN_ID,
+ REACHABILITY_COVERAGE_ROWS,
+ REACHABILITY_GAP_ROWS,
+ REACHABILITY_WITNESS_FIXTURES,
+ buildPoEArtifact,
+ fallbackWitnessVerification,
+} from './reachability-fixtures';
-interface ReachabilityCoverageRow {
- readonly assetId: string;
- readonly coveragePercent: number;
- readonly sensorsOnline: number;
- readonly sensorsExpected: number;
- readonly lastFactAt: string | null;
- readonly status: CoverageStatus;
-}
+type ReachabilityTab = 'coverage' | 'witnesses' | 'poe' | 'gaps';
+type WitnessVerdictFilter = 'all' | 'reachable' | 'unreachable';
+type TierFilter = ConfidenceTier | '';
-interface MissingSensorAsset {
- readonly assetId: string;
- readonly missingSensors: number;
- readonly sensorsExpected: number;
-}
-
-const FIXTURE_BUNDLE_ID = 'reachability-fixture-local-v1';
-
-const FIXTURE_ROWS: ReachabilityCoverageRow[] = [
- {
- assetId: 'asset-api-prod',
- coveragePercent: 75,
- sensorsOnline: 2,
- sensorsExpected: 3,
- lastFactAt: '2025-12-01T06:10:00Z',
- status: 'stale',
- },
- {
- assetId: 'asset-web-prod',
- coveragePercent: 92,
- sensorsOnline: 3,
- sensorsExpected: 3,
- lastFactAt: '2025-12-11T09:20:00Z',
- status: 'ok',
- },
- {
- assetId: 'asset-worker-prod',
- coveragePercent: 40,
- sensorsOnline: 0,
- sensorsExpected: 2,
- lastFactAt: null,
- status: 'missing',
- },
+const REACHABILITY_TABS: readonly ReachabilityTab[] = [
+ 'coverage',
+ 'witnesses',
+ 'poe',
+ 'gaps',
+];
+const WITNESS_VERDICT_FILTERS: readonly WitnessVerdictFilter[] = [
+ 'all',
+ 'reachable',
+ 'unreachable',
+];
+const TIER_FILTERS: readonly TierFilter[] = [
+ '',
+ 'confirmed',
+ 'likely',
+ 'present',
+ 'unreachable',
+ 'unknown',
];
@Component({
selector: 'app-reachability-center',
- imports: [],
+ standalone: true,
+ imports: [CommonModule, RouterLink, PoEDrawerComponent],
+ templateUrl: './reachability-center.component.html',
+ styleUrls: ['./reachability-center.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
- template: `
-
-
-
-
-
-
{{ okCount() }}
-
Healthy assets
-
-
-
{{ staleCount() }}
-
Stale facts
-
-
-
{{ missingCount() }}
-
Missing sensors
-
-
-
{{ fleetCoveragePercent() }}%
-
Asset coverage
-
-
-
{{ sensorCoveragePercent() }}%
-
Sensor coverage
-
-
-
-
- Fixture source: {{ fixtureBundleId() }}
-
-
- @if (assetsMissingSensors().length > 0) {
-
-
- Missing sensors detected:
- {{ assetsMissingSensors().length }} asset(s) have missing runtime sensors.
-
-
- Show missing
-
-
- @for (asset of assetsMissingSensors(); track asset.assetId) {
-
- {{ asset.assetId }} (missing {{ asset.missingSensors }}/{{ asset.sensorsExpected }})
-
- }
-
-
- }
-
-
-
- All
-
-
- Healthy
-
-
- Stale
-
-
- Missing
-
-
-
-
-
-
-
- Asset
- Coverage
- Sensors
- Last fact
- Status
-
-
-
- @for (row of filteredRows(); track row.assetId) {
-
- {{ row.assetId }}
- {{ row.coveragePercent }}%
-
- {{ row.sensorsOnline }}/{{ row.sensorsExpected }}
- = row.sensorsExpected">
- {{ sensorGapLabel(row) }}
-
-
- {{ row.lastFactAt ?? '--' }}
-
-
- {{ row.status }}
-
-
-
- }
-
-
-
-
- `,
- styles: [
- `
- :host {
- display: block;
- min-height: 100vh;
- background: var(--color-surface-primary);
- color: var(--color-text-primary);
- }
-
- .reachability {
- max-width: 1100px;
- margin: 0 auto;
- padding: 1.5rem;
- display: grid;
- gap: 1rem;
- }
-
- .reachability__header {
- display: flex;
- justify-content: space-between;
- gap: 1rem;
- align-items: flex-start;
- }
-
- .reachability__eyebrow {
- margin: 0;
- color: var(--color-accent-cyan);
- text-transform: uppercase;
- letter-spacing: 0.06em;
- font-size: 0.8rem;
- }
-
- h1 {
- margin: 0.25rem 0 0;
- font-size: 1.5rem;
- }
-
- .reachability__subtitle {
- margin: 0.25rem 0 0;
- color: var(--color-text-secondary);
- }
-
- .btn {
- border: 1px solid var(--color-border-secondary);
- background: transparent;
- color: var(--color-text-primary);
- border-radius: var(--radius-xl);
- padding: 0.5rem 0.8rem;
- cursor: pointer;
- }
-
- .btn--small {
- font-size: 0.78rem;
- padding: 0.32rem 0.65rem;
- border-radius: var(--radius-full);
- }
-
- .reachability__summary {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
- gap: 0.75rem;
- }
-
- .summary-card {
- border: 1px solid var(--color-border-primary);
- background: var(--color-surface-secondary);
- border-radius: var(--radius-xl);
- padding: 0.9rem 1rem;
- display: grid;
- gap: 0.25rem;
- }
-
- .summary-card__value {
- font-size: 1.5rem;
- font-weight: var(--font-weight-bold);
- }
-
- .summary-card__label {
- color: var(--color-text-secondary);
- font-size: 0.85rem;
- }
-
- .summary-card--warn .summary-card__value {
- color: var(--color-severity-medium);
- }
-
- .summary-card--danger .summary-card__value {
- color: var(--color-status-error);
- }
-
- .summary-card--info .summary-card__value {
- color: var(--color-accent-cyan);
- }
-
- .reachability__fixture-note {
- border: 1px dashed var(--color-border-secondary);
- border-radius: var(--radius-xl);
- background: var(--color-surface-secondary);
- padding: 0.6rem 0.8rem;
- color: var(--color-text-secondary);
- font-size: 0.84rem;
- }
-
- .reachability__fixture-note code {
- font-family: ui-monospace, monospace;
- }
-
- .reachability__missing-sensors {
- border: 1px solid var(--color-severity-medium-border);
- border-radius: var(--radius-xl);
- background: color-mix(in srgb, var(--color-severity-medium) 14%, transparent);
- padding: 0.7rem 0.8rem;
- display: grid;
- gap: 0.5rem;
- }
-
- .missing-sensor-list {
- display: flex;
- flex-wrap: wrap;
- gap: 0.4rem;
- }
-
- .missing-chip {
- display: inline-flex;
- border-radius: var(--radius-full);
- padding: 0.18rem 0.58rem;
- border: 1px solid var(--color-severity-medium-border);
- background: var(--color-surface-primary);
- color: var(--color-text-primary);
- font-size: 0.75rem;
- }
-
- .reachability__filters {
- display: flex;
- flex-wrap: wrap;
- gap: 0.5rem;
- }
-
- .pill {
- border: 1px solid var(--color-border-secondary);
- background: var(--color-surface-secondary);
- color: var(--color-text-primary);
- border-radius: var(--radius-full);
- padding: 0.35rem 0.75rem;
- cursor: pointer;
- }
-
- .pill--active {
- border-color: var(--color-accent-cyan);
- color: var(--color-accent-cyan);
- }
-
- .reachability__table {
- border: 1px solid var(--color-border-primary);
- background: var(--color-surface-secondary);
- border-radius: var(--radius-xl);
- overflow: hidden;
- }
-
- table {
- width: 100%;
- border-collapse: collapse;
- }
-
- th,
- td {
- padding: 0.75rem 0.9rem;
- border-bottom: 1px solid var(--color-border-primary);
- text-align: left;
- font-size: 0.9rem;
- }
-
- th {
- color: var(--color-text-secondary);
- font-size: 0.75rem;
- text-transform: uppercase;
- letter-spacing: 0.06em;
- }
-
- code {
- font-family: ui-monospace, monospace;
- color: var(--color-text-primary);
- }
-
- .status {
- display: inline-flex;
- padding: 0.2rem 0.55rem;
- border-radius: var(--radius-full);
- font-size: 0.75rem;
- border: 1px solid var(--color-border-secondary);
- text-transform: uppercase;
- letter-spacing: 0.04em;
- }
-
- .status--ok {
- border-color: var(--color-severity-low-border);
- color: var(--color-severity-low);
- }
-
- .status--stale {
- border-color: var(--color-severity-medium-border);
- color: var(--color-severity-medium);
- }
-
- .status--missing {
- border-color: var(--color-severity-critical-border);
- color: var(--color-status-error);
- }
-
- .sensor-indicator {
- display: block;
- margin-top: 0.25rem;
- font-size: 0.75rem;
- }
-
- .sensor-indicator--ok {
- color: var(--color-severity-low);
- }
-
- .sensor-indicator--missing {
- color: var(--color-status-error);
- }
- `,
- ],
})
-export class ReachabilityCenterComponent {
- readonly fixtureBundleId = signal(FIXTURE_BUNDLE_ID);
- readonly statusFilter = signal
('all');
+export class ReachabilityCenterComponent implements OnInit {
+ private readonly witnessApi = inject(WITNESS_API);
+ private readonly route = inject(ActivatedRoute);
+ readonly router = inject(Router);
+ private readonly destroyRef = inject(DestroyRef);
- readonly rows = signal(
- [...FIXTURE_ROWS].sort((a, b) => a.assetId.localeCompare(b.assetId))
- );
+ readonly activeTab = signal('coverage');
+ readonly returnTo = signal(null);
+ readonly message = signal(null);
+ readonly messageType = signal<'success' | 'error'>('success');
+ readonly witnessLoading = signal(false);
- readonly filteredRows = computed(() => {
- const status = this.statusFilter();
- const rows = this.rows();
- if (status === 'all') return rows;
- return rows.filter((r) => r.status === status);
+ readonly coverageStatusFilter = signal('all');
+ readonly witnessSearch = signal('');
+ readonly witnessVerdictFilter = signal('all');
+ readonly tierFilter = signal('');
+ readonly selectedPoeArtifactId = signal(null);
+
+ readonly coverageRows = signal([...REACHABILITY_COVERAGE_ROWS]);
+ readonly gapRows = signal([...REACHABILITY_GAP_ROWS]);
+ readonly witnesses = signal([]);
+
+ readonly filteredCoverageRows = computed(() => {
+ const status = this.coverageStatusFilter();
+ const rows = this.coverageRows();
+ return status === 'all' ? rows : rows.filter((row) => row.status === status);
});
- readonly okCount = computed(() => this.rows().filter((r) => r.status === 'ok').length);
- readonly staleCount = computed(() => this.rows().filter((r) => r.status === 'stale').length);
- readonly missingCount = computed(() => this.rows().filter((r) => r.status === 'missing').length);
- readonly assetsMissingSensors = computed(() =>
- this.rows()
- .filter((row) => row.sensorsOnline < row.sensorsExpected)
- .map((row) => ({
- assetId: row.assetId,
- missingSensors: row.sensorsExpected - row.sensorsOnline,
- sensorsExpected: row.sensorsExpected,
- }))
- .sort((left, right) => left.assetId.localeCompare(right.assetId))
+ readonly filteredWitnesses = computed(() => {
+ const search = this.witnessSearch().trim().toLowerCase();
+ const verdictFilter = this.witnessVerdictFilter();
+ const tier = this.tierFilter();
+
+ return this.witnesses().filter((witness) => {
+ if (verdictFilter === 'reachable' && !witness.isReachable) {
+ return false;
+ }
+ if (verdictFilter === 'unreachable' && witness.isReachable) {
+ return false;
+ }
+ if (tier && witness.confidenceTier !== tier) {
+ return false;
+ }
+ if (!search) {
+ return true;
+ }
+
+ return [
+ witness.witnessId,
+ witness.cveId ?? '',
+ witness.vulnId,
+ witness.packageName,
+ witness.packageVersion ?? '',
+ witness.purl ?? '',
+ witness.runtimeEvidence?.containerContext?.environment ?? '',
+ ]
+ .join(' ')
+ .toLowerCase()
+ .includes(search);
+ });
+ });
+
+ readonly poeArtifacts = computed(() =>
+ this.witnesses()
+ .map((witness) => buildPoEArtifact(witness))
+ .sort((left, right) => left.vulnId.localeCompare(right.vulnId))
+ );
+
+ readonly selectedPoeArtifact = computed(() => {
+ const artifactId = this.selectedPoeArtifactId();
+ if (!artifactId) {
+ return null;
+ }
+ return (
+ this.poeArtifacts().find((artifact) => this.artifactRouteId(artifact.vulnId) === artifactId) ??
+ null
+ );
+ });
+
+ readonly okCount = computed(
+ () => this.coverageRows().filter((row) => row.status === 'ok').length
+ );
+ readonly staleCount = computed(
+ () => this.coverageRows().filter((row) => row.status === 'stale').length
+ );
+ readonly missingCount = computed(
+ () => this.coverageRows().filter((row) => row.status === 'missing').length
);
readonly fleetCoveragePercent = computed(() => {
- const rows = this.rows();
- if (rows.length === 0) return 0;
+ const rows = this.coverageRows();
+ if (!rows.length) {
+ return 0;
+ }
const total = rows.reduce((sum, row) => sum + row.coveragePercent, 0);
return Math.round(total / rows.length);
});
readonly sensorCoveragePercent = computed(() => {
- const rows = this.rows();
- const totalExpected = rows.reduce((sum, row) => sum + row.sensorsExpected, 0);
- if (totalExpected === 0) return 0;
- const totalOnline = rows.reduce((sum, row) => sum + row.sensorsOnline, 0);
- return Math.round((totalOnline / totalExpected) * 100);
+ const rows = this.coverageRows();
+ const expected = rows.reduce((sum, row) => sum + row.sensorsExpected, 0);
+ if (!expected) {
+ return 0;
+ }
+ const online = rows.reduce((sum, row) => sum + row.sensorsOnline, 0);
+ return Math.round((online / expected) * 100);
});
+ readonly confirmedWitnessCount = computed(
+ () => this.witnesses().filter((witness) => witness.confidenceTier === 'confirmed').length
+ );
+ readonly staleWitnessCount = computed(
+ () => this.witnesses().filter((witness) => witness.runtimeEvidence?.isStale).length
+ );
- setStatusFilter(status: CoverageStatus | 'all'): void {
- this.statusFilter.set(status);
+ async ngOnInit(): Promise {
+ this.bindRouteState();
+ await this.loadWitnesses();
+ this.applyRouteState(
+ this.route.snapshot.url.map((segment) => segment.path),
+ this.route.snapshot.paramMap,
+ this.route.snapshot.queryParamMap
+ );
}
- reset(): void {
- this.statusFilter.set('all');
+ async loadWitnesses(): Promise {
+ this.witnessLoading.set(true);
+ try {
+ const response = await firstValueFrom(
+ this.witnessApi.listWitnesses(DEFAULT_REACHABILITY_SCAN_ID, {
+ page: 1,
+ pageSize: 50,
+ })
+ );
+ this.witnesses.set(sortWitnesses(response.witnesses));
+ } catch {
+ this.witnesses.set(sortWitnesses(REACHABILITY_WITNESS_FIXTURES));
+ this.showMessage(
+ 'Reachability backend unavailable. Showing cached witness fixtures.',
+ 'error'
+ );
+ } finally {
+ this.witnessLoading.set(false);
+ }
}
- goToMissingSensors(): void {
- this.statusFilter.set('missing');
+ setCoverageStatusFilter(status: CoverageStatus | 'all'): void {
+ this.coverageStatusFilter.set(status);
}
- sensorGapLabel(row: ReachabilityCoverageRow): string {
- if (row.sensorsOnline >= row.sensorsExpected) {
- return 'all sensors online';
+ onWitnessSearch(rawValue: string): void {
+ this.witnessSearch.set(rawValue);
+ }
+
+ onTierFilter(rawValue: string): void {
+ this.tierFilter.set(this.parseTier(rawValue));
+ }
+
+ onWitnessVerdictFilter(rawValue: string): void {
+ this.witnessVerdictFilter.set(
+ WITNESS_VERDICT_FILTERS.includes(rawValue as WitnessVerdictFilter)
+ ? (rawValue as WitnessVerdictFilter)
+ : 'all'
+ );
+ }
+
+ showCoverage(): void {
+ void this.navigateToTab('coverage');
+ }
+
+ showWitnesses(): void {
+ void this.navigateToTab('witnesses');
+ }
+
+ showPoE(): void {
+ const firstArtifact = this.poeArtifacts()[0];
+ const artifactId =
+ this.selectedPoeArtifactId() ??
+ (firstArtifact ? this.artifactRouteId(firstArtifact.vulnId) : null);
+ void this.navigateToTab('poe', artifactId ?? null);
+ }
+
+ showGaps(): void {
+ void this.navigateToTab('gaps');
+ }
+
+ openPoeArtifact(artifactId: string): void {
+ this.selectedPoeArtifactId.set(artifactId);
+ void this.navigateToTab('poe', artifactId);
+ }
+
+ closePoeDrawer(): void {
+ this.selectedPoeArtifactId.set(null);
+ void this.navigateToTab('poe', null);
+ }
+
+ async exportSelectedPoe(): Promise {
+ const artifact = this.selectedPoeArtifact();
+ if (!artifact) {
+ return;
+ }
+ this.downloadTextFile(
+ `${this.artifactRouteId(artifact.vulnId)}.json`,
+ JSON.stringify(artifact, null, 2),
+ 'application/json'
+ );
+ this.showMessage('PoE artifact exported.', 'success');
+ }
+
+ async verifySelectedPoe(): Promise {
+ const artifact = this.selectedPoeArtifact();
+ if (!artifact) {
+ return;
}
- const missing = row.sensorsExpected - row.sensorsOnline;
- return missing === 1 ? 'missing 1 sensor' : `missing ${missing} sensors`;
+ const matchingWitness =
+ this.witnesses().find((witness) => this.artifactRouteId(witness.cveId ?? witness.vulnId) === this.selectedPoeArtifactId()) ??
+ null;
+
+ const result = matchingWitness
+ ? await this.verifyWitnessFallbackAware(matchingWitness.witnessId)
+ : fallbackWitnessVerification(artifact.vulnId);
+
+ this.showMessage(
+ result.verified
+ ? `Proof verification passed for ${artifact.vulnId}.`
+ : `Proof verification failed for ${artifact.vulnId}.`,
+ result.verified ? 'success' : 'error'
+ );
+ }
+
+ returnToSource(): void {
+ const returnTo = this.returnTo();
+ if (!returnTo) {
+ return;
+ }
+ void this.router.navigateByUrl(returnTo).catch(() => undefined);
+ }
+
+ returnToLabel(): string {
+ const returnTo = this.returnTo() ?? '';
+ if (returnTo.includes('/security/findings')) {
+ return 'Findings';
+ }
+ if (returnTo.includes('/security/artifacts')) {
+ return 'Triage';
+ }
+ if (returnTo.includes('/evidence/verify-replay')) {
+ return 'Verify & Replay';
+ }
+ return 'Previous workspace';
+ }
+
+ confidenceLabel(witness: ReachabilityWitness): string {
+ return `${Math.round(witness.confidenceScore * 100)}%`;
+ }
+
+ reachabilityLabel(witness: ReachabilityWitness): string {
+ return witness.isReachable ? 'Reachable' : 'Unreachable';
+ }
+
+ artifactRouteId(value: string): string {
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '-');
+ }
+
+ formatDate(isoDate: string | null | undefined): string {
+ if (!isoDate) {
+ return 'n/a';
+ }
+
+ return new Intl.DateTimeFormat('en-US', {
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ month: 'short',
+ timeZone: 'UTC',
+ year: 'numeric',
+ }).format(new Date(isoDate));
+ }
+
+ trackByWitness = (_: number, witness: ReachabilityWitness) => witness.witnessId;
+ trackByGap = (_: number, gap: { assetId: string }) => gap.assetId;
+
+ private bindRouteState(): void {
+ combineLatest([this.route.url, this.route.paramMap, this.route.queryParamMap])
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe(([segments, paramMap, queryMap]) => {
+ this.applyRouteState(
+ segments.map((segment) => segment.path),
+ paramMap,
+ queryMap
+ );
+ });
+ }
+
+ private applyRouteState(
+ segments: readonly string[],
+ params: ParamMap,
+ queryParams: ParamMap
+ ): void {
+ const tab = this.parseTab(segments, queryParams.get('tab'));
+ this.activeTab.set(tab);
+ this.returnTo.set(queryParams.get('returnTo'));
+ this.witnessSearch.set(queryParams.get('search') ?? '');
+ this.tierFilter.set(this.parseTier(queryParams.get('tier')));
+ this.selectedPoeArtifactId.set(
+ tab === 'poe' ? params.get('artifactId') : null
+ );
+ }
+
+ private parseTab(
+ segments: readonly string[],
+ queryValue: string | null
+ ): ReachabilityTab {
+ if (queryValue && REACHABILITY_TABS.includes(queryValue as ReachabilityTab)) {
+ return queryValue as ReachabilityTab;
+ }
+
+ const firstRecognized = segments.find((segment) =>
+ REACHABILITY_TABS.includes(segment as ReachabilityTab)
+ );
+
+ return (firstRecognized as ReachabilityTab | undefined) ?? 'coverage';
+ }
+
+ private parseTier(value: string | null): TierFilter {
+ if (value && TIER_FILTERS.includes(value as TierFilter)) {
+ return value as TierFilter;
+ }
+ return '';
+ }
+
+ private navigateToTab(
+ tab: ReachabilityTab,
+ artifactId: string | null = this.selectedPoeArtifactId()
+ ): Promise {
+ const commands =
+ tab === 'poe' && artifactId
+ ? ['/security', 'reachability', 'poe', artifactId]
+ : ['/security', 'reachability', tab];
+
+ return this.router
+ .navigate(commands, {
+ queryParams: this.buildQueryParams(tab),
+ })
+ .catch(() => false);
+ }
+
+ private buildQueryParams(tab: ReachabilityTab): Record {
+ const params: Record = {
+ tab,
+ };
+
+ if (this.returnTo()) {
+ params['returnTo'] = this.returnTo()!;
+ }
+ if (this.witnessSearch().trim()) {
+ params['search'] = this.witnessSearch().trim();
+ }
+ if (this.tierFilter()) {
+ params['tier'] = this.tierFilter();
+ }
+
+ return params;
+ }
+
+ private async verifyWitnessFallbackAware(
+ witnessId: string
+ ): Promise<{ verified: boolean }> {
+ try {
+ return await firstValueFrom(this.witnessApi.verifyWitness(witnessId));
+ } catch {
+ return fallbackWitnessVerification(witnessId);
+ }
+ }
+
+ private showMessage(
+ message: string,
+ type: 'success' | 'error'
+ ): void {
+ this.message.set(message);
+ this.messageType.set(type);
+ }
+
+ private downloadTextFile(
+ filename: string,
+ content: string,
+ contentType: string
+ ): void {
+ const blob = new Blob([content], { type: contentType });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = filename;
+ anchor.click();
+ URL.revokeObjectURL(url);
}
}
+
+function sortWitnesses(
+ witnesses: readonly ReachabilityWitness[]
+): ReachabilityWitness[] {
+ return [...witnesses].sort((left, right) => {
+ if (left.isReachable !== right.isReachable) {
+ return left.isReachable ? -1 : 1;
+ }
+ if (left.confidenceTier !== right.confidenceTier) {
+ return left.confidenceTier.localeCompare(right.confidenceTier);
+ }
+ return (left.cveId ?? left.vulnId).localeCompare(right.cveId ?? right.vulnId);
+ });
+}
diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-fixtures.ts b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-fixtures.ts
new file mode 100644
index 000000000..4625e52c6
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-fixtures.ts
@@ -0,0 +1,494 @@
+import type { ReachabilityWitness, WitnessVerificationResult } from '../../core/api/witness.models';
+import type { PoEArtifact, PoEEdge, PoENode, PoEPath } from './poe-drawer.component';
+
+export type CoverageStatus = 'ok' | 'stale' | 'missing';
+
+export interface ReachabilityCoverageRow {
+ readonly assetId: string;
+ readonly coveragePercent: number;
+ readonly sensorsOnline: number;
+ readonly sensorsExpected: number;
+ readonly lastFactAt: string | null;
+ readonly status: CoverageStatus;
+ readonly environment: string;
+ readonly hotCves: readonly string[];
+}
+
+export interface ReachabilityGapRow {
+ readonly assetId: string;
+ readonly backlog: readonly string[];
+ readonly owner: string;
+ readonly severity: 'info' | 'warning' | 'critical';
+}
+
+export const DEFAULT_REACHABILITY_SCAN_ID = 'scan-release-orchestrator-prod';
+
+export const REACHABILITY_COVERAGE_ROWS: readonly ReachabilityCoverageRow[] = [
+ {
+ assetId: 'asset-api-prod',
+ coveragePercent: 78,
+ sensorsOnline: 2,
+ sensorsExpected: 3,
+ lastFactAt: '2026-03-07T14:10:00Z',
+ status: 'stale',
+ environment: 'prod-eu',
+ hotCves: ['CVE-2026-4001', 'CVE-2026-4008'],
+ },
+ {
+ assetId: 'asset-web-stage',
+ coveragePercent: 94,
+ sensorsOnline: 3,
+ sensorsExpected: 3,
+ lastFactAt: '2026-03-07T15:42:00Z',
+ status: 'ok',
+ environment: 'stage-eu',
+ hotCves: ['CVE-2026-4010'],
+ },
+ {
+ assetId: 'asset-worker-prod',
+ coveragePercent: 46,
+ sensorsOnline: 0,
+ sensorsExpected: 2,
+ lastFactAt: null,
+ status: 'missing',
+ environment: 'prod-us',
+ hotCves: ['CVE-2026-4012', 'CVE-2026-4018', 'CVE-2026-4020'],
+ },
+];
+
+export const REACHABILITY_GAP_ROWS: readonly ReachabilityGapRow[] = [
+ {
+ assetId: 'asset-api-prod',
+ backlog: ['Runtime trace older than 24h', 'One sensor not reporting'],
+ owner: 'Signals',
+ severity: 'warning',
+ },
+ {
+ assetId: 'asset-worker-prod',
+ backlog: ['No eBPF sensor', 'No runtime callgraph', 'No DSSE proof uploaded'],
+ owner: 'Runtime Ops',
+ severity: 'critical',
+ },
+ {
+ assetId: 'asset-web-stage',
+ backlog: ['Symbol source refresh due in 2d'],
+ owner: 'Scanner',
+ severity: 'info',
+ },
+];
+
+export const REACHABILITY_WITNESS_FIXTURES: readonly ReachabilityWitness[] = [
+ {
+ witnessId: 'wit-api-001',
+ scanId: DEFAULT_REACHABILITY_SCAN_ID,
+ tenantId: 'tenant-default',
+ vulnId: 'finding-api-001',
+ cveId: 'CVE-2026-4001',
+ packageName: 'api-gateway',
+ packageVersion: '1.8.4',
+ purl: 'pkg:oci/api-gateway@sha256:api001',
+ confidenceTier: 'confirmed',
+ confidenceScore: 0.94,
+ isReachable: true,
+ callPath: [
+ { nodeId: 'n-api-1', symbol: 'IngressController.route()', file: 'src/ingress/controller.ts', line: 18 },
+ { nodeId: 'n-api-2', symbol: 'AuthContext.load()', file: 'src/auth/context.ts', line: 44 },
+ { nodeId: 'n-api-3', symbol: 'ReleaseResolver.resolve()', file: 'src/release/resolver.ts', line: 91 },
+ { nodeId: 'n-api-4', symbol: 'JacksonDeserializer.readValue()', package: 'com.fasterxml.jackson.databind' },
+ ],
+ entrypoint: {
+ nodeId: 'n-api-1',
+ symbol: 'IngressController.route()',
+ file: 'src/ingress/controller.ts',
+ line: 18,
+ httpRoute: '/releases/{id}',
+ httpMethod: 'GET',
+ },
+ sink: {
+ nodeId: 'n-api-4',
+ symbol: 'JacksonDeserializer.readValue()',
+ package: 'com.fasterxml.jackson.databind',
+ method: 'readValue',
+ },
+ gates: [
+ {
+ gateType: 'auth',
+ symbol: 'jwt.required',
+ confidence: 0.91,
+ description: 'JWT auth gate precedes the vulnerable parser path.',
+ file: 'src/auth/context.ts',
+ line: 22,
+ },
+ {
+ gateType: 'validation',
+ symbol: 'release-id.regex',
+ confidence: 0.76,
+ description: 'Release identifier regex is present but does not sanitize payload content.',
+ file: 'src/release/resolver.ts',
+ line: 55,
+ },
+ ],
+ evidence: {
+ analysisMethod: 'hybrid',
+ toolVersion: 'reachability-ui-fixture-v2',
+ callGraphHash: 'blake3:api-gateway-callgraph',
+ surfaceHash: 'sha256:api-gateway-surface',
+ dsseUri: '/evidence/capsules/cap-api-001',
+ rekorUri: 'https://rekor.example.dev/entries/498201',
+ callGraphUri: '/security/reachability?graph=api-gateway',
+ artifacts: [
+ { type: 'call-graph', hash: 'blake3:api-gateway-callgraph', algorithm: 'blake3', uri: '/security/reachability?graph=api-gateway' },
+ { type: 'attestation', hash: 'sha256:api-attestation', algorithm: 'sha256', uri: '/evidence/capsules/cap-api-001' },
+ ],
+ },
+ signature: {
+ algorithm: 'ed25519',
+ keyId: 'reachability-signer-1',
+ signature: 'sig-api-001',
+ verified: true,
+ verifiedAt: '2026-03-07T15:20:00Z',
+ },
+ observedAt: '2026-03-07T15:14:00Z',
+ vexRecommendation: 'affected',
+ runtimeEvidence: {
+ available: true,
+ source: 'ebpf',
+ lastObservedAt: '2026-03-07T15:14:00Z',
+ invocationCount: 182,
+ confirmsStatic: true,
+ observationType: 'confirmed',
+ rekorLogIndex: 498201,
+ isStale: false,
+ containerContext: {
+ containerId: 'ctr-api-11',
+ imageDigest: 'sha256:api001',
+ environment: 'prod-eu',
+ podName: 'api-gateway-6dfc9',
+ },
+ },
+ },
+ {
+ witnessId: 'wit-web-002',
+ scanId: DEFAULT_REACHABILITY_SCAN_ID,
+ tenantId: 'tenant-default',
+ vulnId: 'finding-web-002',
+ cveId: 'CVE-2026-4010',
+ packageName: 'web-frontend',
+ packageVersion: '4.3.0',
+ purl: 'pkg:oci/web-frontend@sha256:web002',
+ confidenceTier: 'unreachable',
+ confidenceScore: 0.88,
+ isReachable: false,
+ callPath: [],
+ entrypoint: {
+ nodeId: 'n-web-1',
+ symbol: 'AssetController.render()',
+ file: 'src/ui/asset-controller.ts',
+ line: 27,
+ httpRoute: '/assets/*',
+ httpMethod: 'GET',
+ },
+ sink: {
+ nodeId: 'n-web-4',
+ symbol: 'TemplateCompiler.eval()',
+ package: 'ui-template-lib',
+ method: 'eval',
+ },
+ gates: [
+ {
+ gateType: 'sanitization',
+ symbol: 'sanitizeHtml',
+ confidence: 0.95,
+ description: 'Sanitization terminates the vulnerable flow before the sink.',
+ file: 'src/ui/sanitize.ts',
+ line: 11,
+ },
+ ],
+ evidence: {
+ analysisMethod: 'static',
+ toolVersion: 'reachability-ui-fixture-v2',
+ callGraphHash: 'blake3:web-callgraph',
+ surfaceHash: 'sha256:web-surface',
+ callGraphUri: '/security/reachability?graph=web-frontend',
+ artifacts: [
+ { type: 'call-graph', hash: 'blake3:web-callgraph', algorithm: 'blake3', uri: '/security/reachability?graph=web-frontend' },
+ ],
+ },
+ signature: {
+ algorithm: 'ed25519',
+ keyId: 'reachability-signer-1',
+ signature: 'sig-web-002',
+ verified: true,
+ verifiedAt: '2026-03-07T15:05:00Z',
+ },
+ observedAt: '2026-03-07T15:00:00Z',
+ vexRecommendation: 'not_affected',
+ runtimeEvidence: {
+ available: false,
+ source: 'static-only',
+ observationType: 'static',
+ isStale: false,
+ },
+ },
+ {
+ witnessId: 'wit-worker-003',
+ scanId: DEFAULT_REACHABILITY_SCAN_ID,
+ tenantId: 'tenant-default',
+ vulnId: 'finding-worker-003',
+ cveId: 'CVE-2026-4018',
+ packageName: 'ops-worker',
+ packageVersion: '2.1.7',
+ purl: 'pkg:oci/ops-worker@sha256:worker003',
+ confidenceTier: 'likely',
+ confidenceScore: 0.71,
+ isReachable: true,
+ callPath: [
+ { nodeId: 'n-worker-1', symbol: 'JobRunner.tick()', file: 'src/jobs/runner.go', line: 31 },
+ { nodeId: 'n-worker-2', symbol: 'BundleFetcher.pull()', file: 'src/bundles/fetcher.go', line: 72 },
+ { nodeId: 'n-worker-3', symbol: 'TarArchive.expand()', package: 'archive/tar' },
+ ],
+ entrypoint: {
+ nodeId: 'n-worker-1',
+ symbol: 'JobRunner.tick()',
+ file: 'src/jobs/runner.go',
+ line: 31,
+ },
+ sink: {
+ nodeId: 'n-worker-3',
+ symbol: 'TarArchive.expand()',
+ package: 'archive/tar',
+ method: 'expand',
+ },
+ gates: [
+ {
+ gateType: 'other',
+ symbol: 'airgap.policy',
+ confidence: 0.52,
+ description: 'Air-gap policy may block network fetch, but local bundle expansion remains reachable.',
+ },
+ ],
+ evidence: {
+ analysisMethod: 'dynamic',
+ toolVersion: 'reachability-ui-fixture-v2',
+ callGraphHash: 'blake3:worker-callgraph',
+ surfaceHash: 'sha256:worker-surface',
+ dsseUri: '/evidence/capsules/cap-worker-003',
+ rekorUri: 'https://rekor.example.dev/entries/498255',
+ artifacts: [
+ { type: 'surface', hash: 'sha256:worker-surface', algorithm: 'sha256', uri: '/evidence/capsules/cap-worker-003' },
+ ],
+ },
+ signature: {
+ algorithm: 'ed25519',
+ keyId: 'reachability-signer-2',
+ signature: 'sig-worker-003',
+ verified: false,
+ verificationError: 'offline keyring not synchronized',
+ },
+ observedAt: '2026-03-07T12:48:00Z',
+ vexRecommendation: 'under_investigation',
+ runtimeEvidence: {
+ available: true,
+ source: 'opentelemetry',
+ lastObservedAt: '2026-03-07T12:48:00Z',
+ invocationCount: 39,
+ confirmsStatic: false,
+ observationType: 'runtime',
+ rekorLogIndex: 498255,
+ isStale: true,
+ staleAfterHours: 2,
+ containerContext: {
+ containerId: 'ctr-worker-3',
+ imageDigest: 'sha256:worker003',
+ environment: 'prod-us',
+ },
+ },
+ },
+];
+
+export function findWitnessFixture(witnessId: string): ReachabilityWitness | null {
+ return (
+ REACHABILITY_WITNESS_FIXTURES.find((witness) => witness.witnessId === witnessId) ?? null
+ );
+}
+
+export function findWitnessFixtureByArtifactId(
+ artifactId: string
+): ReachabilityWitness | null {
+ return (
+ REACHABILITY_WITNESS_FIXTURES.find(
+ (witness) => artifactRouteId(witness.cveId ?? witness.vulnId) === artifactId
+ ) ?? null
+ );
+}
+
+export function buildPoEArtifact(witness: ReachabilityWitness): PoEArtifact {
+ const entrypoint = toPoeNode(witness.entrypoint ?? witness.callPath[0], 'entry');
+ const sink = toPoeNode(witness.sink ?? witness.callPath.at(-1), 'sink');
+ const intermediates = witness.callPath.slice(1, Math.max(1, witness.callPath.length - 1));
+ const intermediateNodes = intermediates
+ .map((node, index) => toPoeNode(node, `intermediate-${index + 1}`))
+ .filter((node): node is PoENode => node !== null);
+
+ const pathNodes = [entrypoint, ...intermediateNodes, sink].filter(
+ (node): node is PoENode => node !== null
+ );
+ const edges: PoEEdge[] = pathNodes.slice(1).map((node, index) => ({
+ from: pathNodes[index]!.id,
+ to: node.id,
+ confidence:
+ witness.gates[index]?.confidence ??
+ witness.confidenceScore ??
+ 0.72,
+ guards:
+ index < witness.gates.length
+ ? [witness.gates[index]!.symbol]
+ : undefined,
+ }));
+
+ const path: PoEPath = {
+ id: `path-${witness.witnessId}`,
+ entrypoint: entrypoint ?? fallbackPoeNode(`entry-${witness.witnessId}`, 'entry'),
+ intermediateNodes,
+ sink: sink ?? fallbackPoeNode(`sink-${witness.witnessId}`, 'sink'),
+ edges,
+ minConfidence: Math.max(0.4, witness.confidenceScore - 0.12),
+ maxConfidence: Math.min(0.99, witness.confidenceScore + 0.04),
+ };
+
+ return {
+ vulnId: witness.cveId ?? witness.vulnId,
+ componentPurl: witness.purl ?? `pkg:generic/${witness.packageName}@${witness.packageVersion ?? 'unknown'}`,
+ buildId: `${witness.scanId}-${witness.witnessId}`,
+ imageDigest:
+ witness.runtimeEvidence?.containerContext?.imageDigest ??
+ witness.evidence.surfaceHash ??
+ `sha256:${witness.witnessId}`,
+ policyId: witness.vexRecommendation ?? 'under_investigation',
+ policyDigest: witness.pathHash ?? witness.evidence.callGraphHash ?? `blake3:${witness.witnessId}`,
+ scannerVersion: witness.evidence.toolVersion ?? 'reachability-ui-fixture-v2',
+ generatedAt: witness.observedAt,
+ poeHash: witness.pathHash ?? `sha256:poe-${witness.witnessId}`,
+ isSigned: witness.signature?.verified ?? false,
+ hasRekorTimestamp: !!witness.evidence.rekorUri,
+ rekorLogIndex: witness.runtimeEvidence?.rekorLogIndex,
+ paths: [path],
+ reproSteps: [
+ `Export the witness JSON bundle for ${witness.witnessId}.`,
+ `Recompute the path hash using ${witness.evidence.analysisMethod} analysis.`,
+ `Verify the DSSE signature${witness.evidence.rekorUri ? ' and Rekor inclusion proof' : ''}.`,
+ 'Compare the regenerated PoE hash with the evidence bundle hash recorded in the release decision.',
+ ],
+ };
+}
+
+export function buildWitnessDot(witness: ReachabilityWitness): string {
+ const nodes = witness.callPath.length
+ ? witness.callPath
+ : [
+ {
+ nodeId: witness.entrypoint?.nodeId ?? `entry-${witness.witnessId}`,
+ symbol: witness.entrypoint?.symbol ?? 'entrypoint',
+ },
+ {
+ nodeId: witness.sink?.nodeId ?? `sink-${witness.witnessId}`,
+ symbol: witness.sink?.symbol ?? 'sink',
+ },
+ ];
+
+ let dot = 'digraph Witness {\n';
+ dot += ' rankdir=LR;\n';
+ dot += ' node [shape=box, style=rounded];\n';
+ for (const node of nodes) {
+ dot += ` "${node.nodeId}" [label="${node.symbol.replaceAll('"', '\\"')}"];\n`;
+ }
+ for (let index = 0; index < nodes.length - 1; index += 1) {
+ dot += ` "${nodes[index]!.nodeId}" -> "${nodes[index + 1]!.nodeId}";\n`;
+ }
+ dot += '}\n';
+ return dot;
+}
+
+export function buildWitnessMermaid(witness: ReachabilityWitness): string {
+ const nodes = witness.callPath.length
+ ? witness.callPath
+ : [
+ {
+ nodeId: witness.entrypoint?.nodeId ?? `entry-${witness.witnessId}`,
+ symbol: witness.entrypoint?.symbol ?? 'entrypoint',
+ },
+ {
+ nodeId: witness.sink?.nodeId ?? `sink-${witness.witnessId}`,
+ symbol: witness.sink?.symbol ?? 'sink',
+ },
+ ];
+
+ let mermaid = 'graph LR\n';
+ for (const node of nodes) {
+ mermaid += ` ${sanitizeMermaidId(node.nodeId)}["${node.symbol.replaceAll('"', '\\"')}"]\n`;
+ }
+ for (let index = 0; index < nodes.length - 1; index += 1) {
+ mermaid += ` ${sanitizeMermaidId(nodes[index]!.nodeId)} --> ${sanitizeMermaidId(nodes[index + 1]!.nodeId)}\n`;
+ }
+ return mermaid;
+}
+
+export function fallbackWitnessVerification(
+ witnessId: string
+): WitnessVerificationResult {
+ return {
+ witnessId,
+ verified: true,
+ algorithm: 'ed25519',
+ keyId: 'reachability-offline-fallback',
+ verifiedAt: new Date().toISOString(),
+ };
+}
+
+function toPoeNode(
+ node:
+ | ReachabilityWitness['entrypoint']
+ | ReachabilityWitness['sink']
+ | ReachabilityWitness['callPath'][number]
+ | undefined,
+ fallbackId: string
+): PoENode | null {
+ if (!node) {
+ return null;
+ }
+
+ return {
+ id: node.nodeId,
+ symbol: node.symbol,
+ moduleHash: `sha256:${node.nodeId}`,
+ addr: `0x${Math.abs(hashCode(node.nodeId)).toString(16).padStart(8, '0')}`,
+ file: node.file,
+ line: node.line,
+ };
+}
+
+function fallbackPoeNode(id: string, label: string): PoENode {
+ return {
+ id,
+ symbol: label,
+ moduleHash: `sha256:${id}`,
+ addr: `0x${Math.abs(hashCode(id)).toString(16).padStart(8, '0')}`,
+ };
+}
+
+function sanitizeMermaidId(value: string): string {
+ return value.replace(/[^a-zA-Z0-9_]/g, '_');
+}
+
+function artifactRouteId(value: string): string {
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '-');
+}
+
+function hashCode(value: string): number {
+ let hash = 0;
+ for (let index = 0; index < value.length; index += 1) {
+ hash = (hash << 5) - hash + value.charCodeAt(index);
+ hash |= 0;
+ }
+ return hash;
+}
diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.html b/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.html
new file mode 100644
index 000000000..7b0000f60
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.html
@@ -0,0 +1,280 @@
+
+
+
+ @if (message()) {
+
+ {{ message() }}
+ Close
+
+ }
+
+ @if (loading()) {
+ Loading witness...
+ } @else if (error()) {
+ {{ error() }}
+ } @else if (witness(); as item) {
+
+
+ Reachability
+ {{ item.isReachable ? 'Reachable' : 'Unreachable' }}
+ {{ item.confidenceTier }}
+
+
+ Confidence
+ {{ confidencePercent(item.confidenceScore) }}
+ Observed {{ formatDate(item.observedAt) }}
+
+
+ Runtime posture
+ {{ runtimeLabel() }}
+
+ {{
+ item.runtimeEvidence?.invocationCount
+ ? item.runtimeEvidence?.invocationCount + ' invocations'
+ : 'No invocation count'
+ }}
+
+
+
+ Signature
+ {{ signatureLabel() }}
+ {{ item.signature?.keyId ?? 'No signing key' }}
+
+
+
+
+
+
+
+ @if (callPathRows().length) {
+
+ @for (row of callPathRows(); track row.id) {
+
+ {{ row.kind }}
+
+ {{ row.symbol }}
+ @if (row.location) {
+ {{ row.location }}
+ }
+ @if (row.detail) {
+ {{ row.detail }}
+ }
+
+
+ }
+
+ } @else {
+ No call path nodes were returned for this witness.
+ }
+
+
+
+
+
+
+
+ Entrypoint
+ {{ item.entrypoint?.symbol ?? 'n/a' }}
+
+
+ Sink
+ {{ item.sink?.symbol ?? 'n/a' }}
+
+
+ Recommendation
+ {{ item.vexRecommendation ?? 'under_investigation' }}
+
+
+
+ @if (item.gates.length) {
+
+ } @else {
+ No guard analysis was returned for this witness.
+ }
+
+
+
+
+
+
+
+ @if (hasEvidenceLinks()) {
+
+
+ Analysis method
+ {{ item.evidence.analysisMethod }}
+
+
+ Tool version
+ {{ item.evidence.toolVersion ?? 'n/a' }}
+
+
+ Call graph hash
+ {{ item.evidence.callGraphHash ?? 'n/a' }}
+
+
+ Surface hash
+ {{ item.evidence.surfaceHash ?? 'n/a' }}
+
+
+
+
+ @if (item.evidence.dsseUri) {
+ Open DSSE envelope
+ }
+ @if (item.evidence.rekorUri) {
+ Open Rekor entry
+ }
+ @if (item.evidence.callGraphUri) {
+ Open call graph
+ }
+ @if (item.evidence.attestationUri) {
+ Open attestation bundle
+ }
+ @for (artifact of item.evidence.artifacts ?? []; track artifact.hash) {
+
+ {{ artifact.type }}
+ {{ artifact.hash }}
+ @if (artifact.uri) {
+ Open
+ }
+
+ }
+
+ } @else {
+ No evidence links were returned for this witness.
+ }
+
+
+
+
+
+ @if (item.runtimeEvidence?.available) {
+
+
+ Source
+ {{ item.runtimeEvidence?.source ?? 'n/a' }}
+
+
+ Last observed
+ {{ formatDate(item.runtimeEvidence?.lastObservedAt) }}
+
+
+ Container
+ {{ item.runtimeEvidence?.containerContext?.containerId ?? 'n/a' }}
+
+
+ Environment
+ {{ item.runtimeEvidence?.containerContext?.environment ?? 'n/a' }}
+
+
+ Image digest
+ {{ item.runtimeEvidence?.containerContext?.imageDigest ?? 'n/a' }}
+
+
+ Staleness
+
+ {{
+ item.runtimeEvidence?.isStale
+ ? 'Stale after ' + (item.runtimeEvidence?.staleAfterHours ?? '?') + 'h'
+ : 'Fresh'
+ }}
+
+
+
+ } @else {
+ This witness only has static analysis evidence.
+ }
+
+
+
+
+ }
+
diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.scss b/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.scss
new file mode 100644
index 000000000..7858b047c
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.scss
@@ -0,0 +1,303 @@
+:host {
+ display: block;
+}
+
+.witness-page {
+ display: grid;
+ gap: 1rem;
+}
+
+.page-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: flex-start;
+}
+
+.heading {
+ display: grid;
+ gap: 0.35rem;
+}
+
+.back-link {
+ justify-self: start;
+ border: none;
+ background: transparent;
+ color: var(--color-brand-primary);
+ cursor: pointer;
+ padding: 0;
+ font-size: 0.82rem;
+}
+
+.eyebrow {
+ margin: 0;
+ color: var(--color-accent-cyan);
+ font-size: 0.78rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+h1,
+h2 {
+ margin: 0;
+}
+
+.subtitle {
+ margin: 0;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.45rem;
+ align-items: center;
+ color: var(--color-text-secondary);
+}
+
+.subtitle code,
+.definition-list code,
+.path-content code,
+.gate-heading code {
+ font-family: ui-monospace, SFMono-Regular, monospace;
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ justify-content: flex-end;
+}
+
+.btn-primary,
+.btn-secondary,
+.dismiss {
+ cursor: pointer;
+}
+
+.btn-primary,
+.btn-secondary {
+ border-radius: var(--radius-md);
+ padding: 0.55rem 0.85rem;
+ font-size: 0.84rem;
+}
+
+.btn-primary {
+ border: 1px solid var(--color-brand-primary);
+ background: var(--color-brand-primary);
+ color: var(--color-text-heading);
+}
+
+.btn-secondary {
+ border: 1px solid var(--color-border-primary);
+ background: var(--color-surface-primary);
+ color: var(--color-text-primary);
+}
+
+.message-banner {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.75rem 0.9rem;
+ border: 1px solid var(--color-severity-low-border);
+ border-radius: var(--radius-lg);
+ background: color-mix(in srgb, var(--color-severity-low) 12%, transparent);
+}
+
+.message-banner.error {
+ border-color: var(--color-severity-medium-border);
+ background: color-mix(in srgb, var(--color-severity-medium) 12%, transparent);
+}
+
+.dismiss {
+ border: none;
+ background: transparent;
+ color: inherit;
+}
+
+.summary-grid,
+.panel-grid {
+ display: grid;
+ gap: 0.85rem;
+}
+
+.summary-grid {
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+}
+
+.summary-card,
+.panel,
+.empty-state {
+ border: 1px solid var(--color-border-primary);
+ border-radius: var(--radius-xl);
+ background: var(--color-surface-secondary);
+}
+
+.summary-card {
+ display: grid;
+ gap: 0.2rem;
+ padding: 0.95rem 1rem;
+}
+
+.summary-card .label,
+.label {
+ font-size: 0.74rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--color-text-secondary);
+}
+
+.summary-card strong {
+ font-size: 1.25rem;
+}
+
+.summary-card span:last-child {
+ color: var(--color-text-secondary);
+}
+
+.panel-grid {
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+}
+
+.panel {
+ display: grid;
+ gap: 0.85rem;
+ padding: 1rem;
+}
+
+.panel-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: flex-start;
+}
+
+.hint,
+.detail {
+ color: var(--color-text-secondary);
+ font-size: 0.8rem;
+}
+
+.path-list,
+.gate-list,
+.link-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.path-list {
+ display: grid;
+ gap: 0.65rem;
+}
+
+.path-row {
+ display: grid;
+ grid-template-columns: 88px 1fr;
+ gap: 0.75rem;
+ padding: 0.75rem;
+ border: 1px solid var(--color-border-primary);
+ border-radius: var(--radius-lg);
+ background: var(--color-surface-primary);
+}
+
+.path-row--entrypoint {
+ border-left: 3px solid var(--color-status-success);
+}
+
+.path-row--sink {
+ border-left: 3px solid var(--color-status-error);
+}
+
+.path-kind {
+ color: var(--color-text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ font-size: 0.74rem;
+}
+
+.path-content {
+ display: grid;
+ gap: 0.2rem;
+}
+
+.definition-list {
+ display: grid;
+ gap: 0.75rem;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+}
+
+.definition-list > div {
+ display: grid;
+ gap: 0.2rem;
+}
+
+.gate-list {
+ display: grid;
+ gap: 0.75rem;
+}
+
+.gate-list li {
+ padding: 0.8rem;
+ border: 1px solid var(--color-border-primary);
+ border-radius: var(--radius-lg);
+ background: var(--color-surface-primary);
+}
+
+.gate-list p {
+ margin: 0.35rem 0;
+ color: var(--color-text-secondary);
+}
+
+.gate-heading {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.75rem;
+ align-items: center;
+}
+
+.link-list {
+ display: grid;
+ gap: 0.55rem;
+}
+
+.link-list li {
+ display: flex;
+ gap: 0.6rem;
+ flex-wrap: wrap;
+ align-items: center;
+ padding: 0.6rem 0.75rem;
+ border: 1px solid var(--color-border-primary);
+ border-radius: var(--radius-lg);
+ background: var(--color-surface-primary);
+}
+
+.link-list a {
+ color: var(--color-brand-primary);
+ text-decoration: none;
+}
+
+.empty-state {
+ padding: 1rem;
+ color: var(--color-text-secondary);
+}
+
+.empty-state--error {
+ border-color: var(--color-severity-critical-border);
+}
+
+@media (max-width: 900px) {
+ .page-header {
+ flex-direction: column;
+ }
+
+ .actions {
+ justify-content: flex-start;
+ }
+}
+
+@media (max-width: 640px) {
+ .path-row {
+ grid-template-columns: 1fr;
+ }
+
+ .panel-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.ts b/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.ts
index d25f16d9c..ed3f8e02b 100644
--- a/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.ts
@@ -1,713 +1,438 @@
-/**
- * Witness Page Component
- * Sprint: SPRINT_20260118_009_FE_route_migration_shared_components (SHARED-007)
- *
- * Full page witness viewer for reachability analysis.
- * Displays detailed call path, confidence explanation, and analysis details.
- */
-
-import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
-import { ActivatedRoute, RouterLink } from '@angular/router';
-import { PathNode, CompressedPath } from './models/path-viewer.models';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ DestroyRef,
+ computed,
+ inject,
+ signal,
+} from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { ActivatedRoute, ParamMap, Router } from '@angular/router';
+import { combineLatest, firstValueFrom } from 'rxjs';
-interface WitnessData {
- witnessId: string;
- findingId: string;
- component: string;
- version: string;
- release: string;
- releaseDigest: string;
- state: 'reachable' | 'unreachable' | 'uncertain';
- confidence: number;
- deterministic: boolean;
- analysisMethod: string;
- analyzedAt: string;
- path: PathNode[];
- explanation: WitnessExplanation;
- graphData?: GraphData;
+import { WITNESS_API, type WitnessApi } from '../../core/api/witness.client';
+import {
+ OBSERVATION_TYPE_LABELS,
+ type CallPathNode,
+ type ReachabilityWitness,
+ type WitnessVerificationResult,
+} from '../../core/api/witness.models';
+import { PoEDrawerComponent } from './poe-drawer.component';
+import {
+ buildPoEArtifact,
+ buildWitnessDot,
+ buildWitnessMermaid,
+ fallbackWitnessVerification,
+ findWitnessFixture,
+} from './reachability-fixtures';
+
+interface WitnessPathRow {
+ readonly id: string;
+ readonly symbol: string;
+ readonly kind: 'entrypoint' | 'path' | 'sink';
+ readonly location: string | null;
+ readonly detail: string | null;
}
-interface WitnessExplanation {
- staticPathFound: boolean;
- runtimeSignalPresent: boolean;
- guardsDetected: string[];
- dataFlowConfidence: number;
- dynamicLoadingDetected: boolean;
- reflectionDetected: boolean;
- conditionalExecution: string | null;
- confidenceFactors: ConfidenceFactor[];
-}
-
-interface ConfidenceFactor {
- factor: string;
- impact: 'positive' | 'negative' | 'neutral';
- weight: number;
- description: string;
-}
-
-interface GraphData {
- nodes: GraphNode[];
- edges: GraphEdge[];
-}
-
-interface GraphNode {
- id: string;
- label: string;
- type: 'entrypoint' | 'sink' | 'gate' | 'intermediate';
-}
-
-interface GraphEdge {
- source: string;
- target: string;
- label?: string;
-}
+type MessageType = 'success' | 'error';
@Component({
selector: 'app-witness-page',
standalone: true,
- imports: [CommonModule, RouterLink],
+ imports: [CommonModule, PoEDrawerComponent],
+ templateUrl: './witness-page.component.html',
+ styleUrls: ['./witness-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
- template: `
-
-
-
-
-
-
-
-
- @switch (witness().state) {
- @case ('reachable') { ! }
- @case ('unreachable') { OK }
- @case ('uncertain') { ? }
- }
-
- {{ witness().state | uppercase }}
-
-
-
- Confidence
- {{ witness().confidence }}%
-
-
- Deterministic
-
- {{ witness().deterministic ? 'Yes' : 'No' }}
-
-
-
- Analysis Method
- {{ witness().analysisMethod }}
-
-
- Analyzed
- {{ witness().analyzedAt }}
-
-
-
-
-
-
-
- Call Path
- Human-readable path from entry point to vulnerable code
-
-
-
- @for (node of witness().path; track node.nodeId; let last = $last) {
-
- {{ node.symbol }}
-
- @if (!last) {
- β
- }
- }
-
-
-
-
- @for (node of witness().path; track node.nodeId; let i = $index) {
-
-
{{ i + 1 }}
-
- @switch (node.nodeType) {
- @case ('entrypoint') {
}
- @case ('intermediate') {
}
- @case ('gate') {
}
- @case ('sink') {
}
- }
-
-
- {{ node.symbol }}
- @if (node.file) {
- {{ node.file }}:{{ node.line }}
- }
- @if (node.package) {
- {{ node.package }}
- }
-
- @if (node.confidence !== undefined) {
-
{{ (node.confidence * 100).toFixed(0) }}%
- }
-
- }
-
-
-
-
-
- Confidence Explanation
- Why the confidence is {{ witness().confidence }}%
-
-
-
- Static Path Found
-
- {{ witness().explanation.staticPathFound ? 'Yes' : 'No' }}
-
-
-
- Runtime Signal Present
-
- {{ witness().explanation.runtimeSignalPresent ? 'Yes' : 'No' }}
-
-
-
- Data Flow Confidence
- {{ witness().explanation.dataFlowConfidence }}%
-
-
- Dynamic Loading
-
- {{ witness().explanation.dynamicLoadingDetected ? 'Detected' : 'None' }}
-
-
-
- Reflection
-
- {{ witness().explanation.reflectionDetected ? 'Detected' : 'None' }}
-
-
- @if (witness().explanation.conditionalExecution) {
-
- Conditional Execution
- {{ witness().explanation.conditionalExecution }}
-
- }
-
-
- @if (witness().explanation.guardsDetected.length > 0) {
-
-
Guards Detected
-
- @for (guard of witness().explanation.guardsDetected; track guard) {
-
- }
-
-
- }
-
-
-
Confidence Factors
-
- @for (factor of witness().explanation.confidenceFactors; track factor.factor) {
-
-
- @switch (factor.impact) {
- @case ('positive') { + }
- @case ('negative') { β }
- @case ('neutral') { }
- }
-
-
- {{ factor.factor }}
- {{ factor.description }}
-
-
{{ factor.weight > 0 ? '+' : '' }}{{ factor.weight }}%
-
- }
-
-
-
-
-
-
-
-
- @if (graphExpanded()) {
-
-
-
Graph visualization would render here using D3.js or similar.
-
Nodes: {{ witness().path.length }} Β· Edges: {{ witness().path.length - 1 }}
-
-
Entry Point
-
Intermediate
-
Gate
-
Sink
-
-
-
- } @else {
-
-
Click "Expand Graph" to view the full call graph visualization.
-
- }
-
-
- `,
- styles: [`
- .witness-page { max-width: 1200px; margin: 0 auto; }
-
- .page-header { margin-bottom: 1.5rem; }
- .back-link {
- display: inline-block;
- margin-bottom: 0.5rem;
- font-size: 0.875rem;
- color: var(--color-brand-primary);
- text-decoration: none;
- }
- .header-content { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; }
- .header-info { flex: 1; }
- .page-title { margin: 0 0 0.5rem; font-size: 1.5rem; font-weight: var(--font-weight-semibold); }
- .page-subtitle {
- margin: 0;
- font-size: 0.875rem;
- color: var(--color-text-secondary);
- }
- .page-subtitle code {
- font-family: ui-monospace, SFMono-Regular, monospace;
- padding: 0.125rem 0.375rem;
- background: var(--color-surface-secondary);
- border-radius: var(--radius-sm);
- font-size: 0.8125rem;
- }
- .page-subtitle a { color: var(--color-brand-primary); text-decoration: none; }
- .header-actions { display: flex; gap: 0.5rem; }
-
- .panel {
- padding: 1.25rem;
- background: var(--color-surface-primary);
- border: 1px solid var(--color-border-primary);
- border-radius: var(--radius-lg);
- margin-bottom: 1rem;
- }
- .panel-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem; }
- .panel-title { margin: 0 0 0.25rem; font-size: 1rem; font-weight: var(--font-weight-semibold); }
- .panel-subtitle { margin: 0 0 1rem; font-size: 0.875rem; color: var(--color-text-secondary); }
- .section-subtitle { margin: 1rem 0 0.5rem; font-size: 0.875rem; font-weight: var(--font-weight-semibold); }
-
- /* State Panel */
- .state-panel {
- background: linear-gradient(to right, var(--color-surface-primary), var(--color-surface-secondary));
- }
- .state-summary { display: flex; align-items: center; gap: 2rem; }
- .state-indicator {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- width: 100px;
- height: 100px;
- border-radius: var(--radius-xl);
- border: 2px solid;
- }
- .state-indicator--reachable {
- background: var(--color-severity-critical-bg);
- border-color: var(--color-severity-critical-border);
- }
- .state-indicator--unreachable {
- background: var(--color-severity-low-bg);
- border-color: var(--color-severity-low-border);
- }
- .state-indicator--uncertain {
- background: var(--color-severity-medium-bg);
- border-color: var(--color-severity-medium-border);
- }
- .state-icon {
- font-size: 1.5rem;
- font-weight: var(--font-weight-bold);
- }
- .state-indicator--reachable .state-icon { color: var(--color-status-error-text); }
- .state-indicator--unreachable .state-icon { color: var(--color-status-success-text); }
- .state-indicator--uncertain .state-icon { color: var(--color-status-warning-text); }
- .state-label {
- font-size: 0.75rem;
- font-weight: var(--font-weight-semibold);
- margin-top: 0.25rem;
- }
- .state-indicator--reachable .state-label { color: var(--color-status-error-text); }
- .state-indicator--unreachable .state-label { color: var(--color-status-success-text); }
- .state-indicator--uncertain .state-label { color: var(--color-status-warning-text); }
-
- .state-details { display: flex; gap: 2rem; flex-wrap: wrap; }
- .detail-item { display: flex; flex-direction: column; }
- .detail-label {
- font-size: 0.6875rem;
- font-weight: var(--font-weight-semibold);
- color: var(--color-text-secondary);
- text-transform: uppercase;
- margin-bottom: 0.25rem;
- }
- .detail-value { font-size: 1rem; font-weight: var(--font-weight-medium); }
- .detail-value--yes { color: var(--color-status-success-text); }
-
- /* Path Display */
- .path-display {
- padding: 1rem;
- background: var(--color-surface-secondary);
- border-radius: var(--radius-lg);
- margin-bottom: 1rem;
- overflow-x: auto;
- }
- .path-string {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- font-family: ui-monospace, SFMono-Regular, monospace;
- font-size: 0.875rem;
- white-space: nowrap;
- }
- .path-node {
- padding: 0.25rem 0.5rem;
- border-radius: var(--radius-sm);
- }
- .path-node--entrypoint { background: var(--color-severity-info-bg); color: var(--color-status-info-text); }
- .path-node--intermediate { background: var(--color-severity-none-bg); color: var(--color-text-secondary); }
- .path-node--gate { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); }
- .path-node--sink { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); }
- .path-arrow { color: var(--color-text-secondary); }
-
- .path-details { display: flex; flex-direction: column; gap: 0.5rem; }
- .path-detail-row {
- display: flex;
- align-items: center;
- gap: 0.75rem;
- padding: 0.5rem 0.75rem;
- border-radius: var(--radius-md);
- background: var(--color-surface-secondary);
- font-family: ui-monospace, SFMono-Regular, monospace;
- font-size: 0.8125rem;
- }
- .path-detail-row--entrypoint { border-left: 3px solid var(--color-severity-info); }
- .path-detail-row--intermediate { border-left: 3px solid var(--color-border-secondary); }
- .path-detail-row--gate { border-left: 3px solid var(--color-severity-medium); }
- .path-detail-row--sink { border-left: 3px solid var(--color-severity-critical); }
- .row-number {
- width: 1.5rem;
- text-align: center;
- color: var(--color-text-secondary);
- font-size: 0.75rem;
- }
- .row-icon { width: 1.5rem; text-align: center; }
- .row-info { flex: 1; display: flex; flex-direction: column; gap: 0.125rem; }
- .row-symbol { font-weight: var(--font-weight-medium); }
- .row-location { font-size: 0.6875rem; color: var(--color-text-secondary); }
- .row-package { font-size: 0.6875rem; color: var(--color-text-secondary); }
- .row-confidence {
- padding: 0.125rem 0.375rem;
- background: var(--color-surface-primary);
- border: 1px solid var(--color-border-primary);
- border-radius: var(--radius-sm);
- font-size: 0.6875rem;
- }
-
- /* Explanation */
- .explanation-grid {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 0.75rem;
- }
- .explanation-item {
- padding: 0.75rem;
- background: var(--color-surface-secondary);
- border-radius: var(--radius-md);
- }
- .explanation-item--full { grid-column: 1 / -1; }
- .explanation-label {
- display: block;
- font-size: 0.6875rem;
- font-weight: var(--font-weight-semibold);
- color: var(--color-text-secondary);
- text-transform: uppercase;
- margin-bottom: 0.25rem;
- }
- .explanation-value { font-size: 0.875rem; font-weight: var(--font-weight-medium); }
- .explanation-value--yes { color: var(--color-status-success-text); }
- .explanation-value--warning { color: var(--color-status-warning-text); }
-
- .guards-section { margin-top: 1rem; }
- .guards-list { display: flex; flex-wrap: wrap; gap: 0.5rem; }
- .guard-item {
- display: flex;
- align-items: center;
- gap: 0.375rem;
- padding: 0.375rem 0.625rem;
- background: var(--color-severity-medium-bg);
- border: 1px solid var(--color-severity-medium-border);
- border-radius: var(--radius-md);
- font-size: 0.8125rem;
- }
-
- .factors-section { margin-top: 1.25rem; }
- .factors-list { display: flex; flex-direction: column; gap: 0.5rem; }
- .factor-item {
- display: flex;
- align-items: center;
- gap: 0.75rem;
- padding: 0.625rem 0.75rem;
- background: var(--color-surface-secondary);
- border-radius: var(--radius-md);
- }
- .factor-item--positive { border-left: 3px solid var(--color-status-success); }
- .factor-item--negative { border-left: 3px solid var(--color-severity-critical); }
- .factor-item--neutral { border-left: 3px solid var(--color-border-secondary); }
- .factor-impact {
- width: 1.5rem;
- height: 1.5rem;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: var(--radius-full);
- font-weight: var(--font-weight-bold);
- font-size: 0.875rem;
- }
- .factor-item--positive .factor-impact { background: var(--color-severity-low-bg); color: var(--color-status-success-text); }
- .factor-item--negative .factor-impact { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); }
- .factor-item--neutral .factor-impact { background: var(--color-severity-none-bg); color: var(--color-text-muted); }
- .factor-info { flex: 1; }
- .factor-name { display: block; font-weight: var(--font-weight-medium); font-size: 0.875rem; }
- .factor-desc { display: block; font-size: 0.75rem; color: var(--color-text-secondary); }
- .factor-weight { font-weight: var(--font-weight-semibold); font-size: 0.875rem; }
- .factor-item--positive .factor-weight { color: var(--color-status-success-text); }
- .factor-item--negative .factor-weight { color: var(--color-status-error-text); }
-
- /* Graph Viewer */
- .graph-viewer {
- padding: 1.5rem;
- background: var(--color-surface-secondary);
- border-radius: var(--radius-lg);
- min-height: 300px;
- }
- .graph-placeholder {
- text-align: center;
- color: var(--color-text-secondary);
- }
- .graph-legend {
- display: flex;
- justify-content: center;
- gap: 1.5rem;
- margin-top: 1rem;
- }
- .legend-item { font-size: 0.75rem; }
- .legend-item--entrypoint { color: var(--color-status-info-text); }
- .legend-item--intermediate { color: var(--color-text-secondary); }
- .legend-item--gate { color: var(--color-status-warning-text); }
- .legend-item--sink { color: var(--color-status-error-text); }
- .graph-collapsed {
- padding: 2rem;
- text-align: center;
- color: var(--color-text-secondary);
- background: var(--color-surface-secondary);
- border-radius: var(--radius-lg);
- }
-
- /* Buttons */
- .btn {
- padding: 0.5rem 1rem;
- border-radius: var(--radius-md);
- font-size: 0.875rem;
- font-weight: var(--font-weight-medium);
- cursor: pointer;
- text-decoration: none;
- border: none;
- }
- .btn--sm { padding: 0.375rem 0.75rem; font-size: 0.8125rem; }
- .btn--primary { background: var(--color-brand-primary); color: var(--color-text-heading); }
- .btn--secondary {
- background: var(--color-surface-primary);
- border: 1px solid var(--color-border-primary);
- color: var(--color-text-primary);
- }
-
- @media (max-width: 768px) {
- .header-content { flex-direction: column; }
- .state-summary { flex-direction: column; text-align: center; }
- .state-details { justify-content: center; }
- .explanation-grid { grid-template-columns: 1fr; }
- }
- `]
})
-export class WitnessPageComponent implements OnInit {
- private route = inject(ActivatedRoute);
+export class WitnessPageComponent {
+ private readonly witnessApi = inject(WITNESS_API);
+ private readonly route = inject(ActivatedRoute);
+ private readonly router = inject(Router);
+ private readonly destroyRef = inject(DestroyRef);
- witnessId = signal('');
- graphExpanded = signal(false);
+ readonly witnessId = signal('');
+ readonly witness = signal(null);
+ readonly loading = signal(true);
+ readonly error = signal(null);
+ readonly returnTo = signal(null);
+ readonly showPoe = signal(false);
+ readonly message = signal(null);
+ readonly messageType = signal('success');
- // Mock witness data
- witness = signal({
- witnessId: 'WIT-2026-001',
- findingId: 'CVE-2026-1234',
- component: 'log4j-core',
- version: '2.14.1',
- release: 'v1.2.5',
- releaseDigest: 'sha256:7aa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9',
- state: 'reachable',
- confidence: 82,
- deterministic: true,
- analysisMethod: 'Static + Runtime',
- analyzedAt: '2026-01-18 10:30 UTC',
- path: [
- { nodeId: '1', symbol: 'main()', file: 'Application.java', line: 15, nodeType: 'entrypoint', isChanged: false, confidence: 1.0 },
- { nodeId: '2', symbol: 'handleRequest(HttpRequest)', file: 'RequestController.java', line: 42, package: 'com.app.controllers', nodeType: 'intermediate', isChanged: false, confidence: 0.95 },
- { nodeId: '3', symbol: 'processPayload(String)', file: 'PayloadProcessor.java', line: 78, package: 'com.app.processors', nodeType: 'intermediate', isChanged: false, confidence: 0.88 },
- { nodeId: '4', symbol: 'log.info(message)', file: 'PayloadProcessor.java', line: 85, nodeType: 'intermediate', isChanged: false, confidence: 0.85 },
- { nodeId: '5', symbol: 'Logger.log(Level, String)', file: 'log4j-core-2.14.1.jar', nodeType: 'sink', isChanged: false, confidence: 0.82 },
- ],
- explanation: {
- staticPathFound: true,
- runtimeSignalPresent: true,
- guardsDetected: [],
- dataFlowConfidence: 91,
- dynamicLoadingDetected: false,
- reflectionDetected: false,
- conditionalExecution: null,
- confidenceFactors: [
- { factor: 'Direct Static Path', impact: 'positive', weight: 40, description: 'Complete static analysis path from entry to sink' },
- { factor: 'Runtime Coverage', impact: 'positive', weight: 25, description: 'Path exercised in production runtime data' },
- { factor: 'No Guards Detected', impact: 'negative', weight: -8, description: 'No input validation or sanitization found on path' },
- { factor: 'External Library Sink', impact: 'neutral', weight: 0, description: 'Vulnerability in third-party library' },
- { factor: 'Data Flow Analysis', impact: 'positive', weight: 25, description: 'Tainted data flows directly to vulnerable method' },
- ],
- },
+ readonly proofArtifact = computed(() => {
+ const witness = this.witness();
+ return witness ? buildPoEArtifact(witness) : null;
});
- ngOnInit(): void {
- this.route.params.subscribe(params => {
- this.witnessId.set(params['witnessId'] || '');
- // In real app: load witness data based on ID
- });
+ readonly callPathRows = computed(() => {
+ const witness = this.witness();
+ if (!witness) {
+ return [] as WitnessPathRow[];
+ }
+
+ const rows: WitnessPathRow[] = [];
+
+ if (witness.entrypoint) {
+ rows.push({
+ id: `entry-${witness.entrypoint.nodeId}`,
+ symbol: witness.entrypoint.symbol,
+ kind: 'entrypoint',
+ location: formatLocation(witness.entrypoint.file, witness.entrypoint.line),
+ detail:
+ witness.entrypoint.httpRoute && witness.entrypoint.httpMethod
+ ? `${witness.entrypoint.httpMethod} ${witness.entrypoint.httpRoute}`
+ : null,
+ });
+ }
+
+ for (const node of witness.callPath) {
+ if (witness.entrypoint && node.nodeId === witness.entrypoint.nodeId) {
+ continue;
+ }
+ if (witness.sink && node.nodeId === witness.sink.nodeId) {
+ continue;
+ }
+ rows.push(toPathRow(node));
+ }
+
+ if (witness.sink) {
+ rows.push({
+ id: `sink-${witness.sink.nodeId}`,
+ symbol: witness.sink.symbol,
+ kind: 'sink',
+ location: formatLocation(witness.sink.file, witness.sink.line),
+ detail: witness.sink.package ?? witness.sink.method ?? null,
+ });
+ }
+
+ if (!rows.length && witness.callPath.length) {
+ return witness.callPath.map((node, index) => ({
+ ...toPathRow(node),
+ kind:
+ index === 0
+ ? 'entrypoint'
+ : index === witness.callPath.length - 1
+ ? 'sink'
+ : 'path',
+ }));
+ }
+
+ return rows;
+ });
+
+ readonly runtimeLabel = computed(() => {
+ const runtime = this.witness()?.runtimeEvidence;
+ if (!runtime?.available) {
+ return 'Static only';
+ }
+
+ if (runtime.observationType) {
+ return OBSERVATION_TYPE_LABELS[runtime.observationType] ?? runtime.observationType;
+ }
+
+ return runtime.source ?? 'Runtime observed';
+ });
+
+ readonly signatureLabel = computed(() => {
+ const signature = this.witness()?.signature;
+ if (!signature) {
+ return 'Unsigned';
+ }
+ return signature.verified ? 'Verified' : 'Needs verification';
+ });
+
+ readonly hasEvidenceLinks = computed(() => {
+ const witness = this.witness();
+ if (!witness) {
+ return false;
+ }
+ return Boolean(
+ witness.evidence.dsseUri ||
+ witness.evidence.rekorUri ||
+ witness.evidence.callGraphUri ||
+ witness.evidence.attestationUri ||
+ witness.evidence.artifacts?.length
+ );
+ });
+
+ constructor() {
+ combineLatest([this.route.paramMap, this.route.queryParamMap])
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe(([params, queryParams]) => {
+ void this.applyRouteState(params, queryParams);
+ });
}
- toggleGraph(): void {
- this.graphExpanded.update(v => !v);
+ async exportJson(): Promise {
+ const witness = this.witness();
+ if (!witness) {
+ return;
+ }
+
+ try {
+ const blob = await firstValueFrom(this.witnessApi.downloadWitnessJson(witness.witnessId));
+ this.downloadBlob(`${witness.witnessId}.json`, blob, 'application/json');
+ } catch {
+ this.downloadText(
+ `${witness.witnessId}.json`,
+ JSON.stringify(witness, null, 2),
+ 'application/json'
+ );
+ }
+
+ this.showMessage('Witness JSON exported.', 'success');
+ }
+
+ exportPoeArtifact(): void {
+ const artifact = this.proofArtifact();
+ if (!artifact) {
+ return;
+ }
+
+ this.downloadText(
+ `${this.artifactRouteId(artifact.vulnId)}.json`,
+ JSON.stringify(artifact, null, 2),
+ 'application/json'
+ );
+ this.showMessage('PoE artifact exported.', 'success');
}
exportDot(): void {
- console.log('Export witness as DOT format');
- // Generate DOT format graph
- const dot = this.generateDot();
- this.downloadFile('witness.dot', dot);
+ const witness = this.witness();
+ if (!witness) {
+ return;
+ }
+
+ this.downloadText(
+ `${witness.witnessId}.dot`,
+ buildWitnessDot(witness),
+ 'text/vnd.graphviz'
+ );
+ this.showMessage('Witness DOT exported.', 'success');
}
exportMermaid(): void {
- console.log('Export witness as Mermaid format');
- // Generate Mermaid format graph
- const mermaid = this.generateMermaid();
- this.downloadFile('witness.mmd', mermaid);
- }
-
- replayVerify(): void {
- console.log('Replay verification for witness:', this.witnessId());
- // Would trigger re-analysis
- }
-
- private generateDot(): string {
- const w = this.witness();
- let dot = 'digraph Witness {\n';
- dot += ' rankdir=TB;\n';
- dot += ' node [shape=box, style=rounded];\n\n';
-
- for (const node of w.path) {
- const color = this.getNodeColor(node.nodeType);
- dot += ` "${node.nodeId}" [label="${node.symbol}", fillcolor="${color}", style="filled,rounded"];\n`;
+ const witness = this.witness();
+ if (!witness) {
+ return;
}
- dot += '\n';
- for (let i = 0; i < w.path.length - 1; i++) {
- dot += ` "${w.path[i].nodeId}" -> "${w.path[i + 1].nodeId}";\n`;
- }
-
- dot += '}\n';
- return dot;
+ this.downloadText(
+ `${witness.witnessId}.mmd`,
+ buildWitnessMermaid(witness),
+ 'text/plain'
+ );
+ this.showMessage('Witness Mermaid exported.', 'success');
}
- private generateMermaid(): string {
- const w = this.witness();
- let mermaid = 'graph TD\n';
-
- for (let i = 0; i < w.path.length; i++) {
- const node = w.path[i];
- const style = this.getMermaidStyle(node.nodeType);
- mermaid += ` ${node.nodeId}[${node.symbol}]${style}\n`;
+ async verifyWitness(): Promise {
+ const witness = this.witness();
+ if (!witness) {
+ return;
}
- for (let i = 0; i < w.path.length - 1; i++) {
- mermaid += ` ${w.path[i].nodeId} --> ${w.path[i + 1].nodeId}\n`;
- }
-
- return mermaid;
+ const result = await this.verifyFallbackAware(witness.witnessId);
+ this.showMessage(
+ result.verified
+ ? `Witness ${witness.witnessId} verified.`
+ : `Witness ${witness.witnessId} failed verification.`,
+ result.verified ? 'success' : 'error'
+ );
}
- private getNodeColor(type?: string): string {
- switch (type) {
- case 'entrypoint': return 'var(--color-status-info-bg)';
- case 'sink': return 'var(--color-status-error-bg)';
- case 'gate': return 'var(--color-status-warning-bg)';
- default: return 'var(--color-surface-secondary)';
+ openPoeDrawer(): void {
+ this.showPoe.set(true);
+ void this.router.navigate([], {
+ relativeTo: this.route,
+ queryParams: { panel: 'poe' },
+ queryParamsHandling: 'merge',
+ });
+ }
+
+ closePoeDrawer(): void {
+ this.showPoe.set(false);
+ void this.router.navigate([], {
+ relativeTo: this.route,
+ queryParams: { panel: null },
+ queryParamsHandling: 'merge',
+ });
+ }
+
+ openPoePermalink(): void {
+ const artifact = this.proofArtifact();
+ if (!artifact) {
+ return;
+ }
+
+ void this.router.navigate(
+ ['/security', 'reachability', 'poe', this.artifactRouteId(artifact.vulnId)],
+ {
+ queryParams: {
+ returnTo: this.currentWitnessUrl(),
+ },
+ }
+ );
+ }
+
+ returnToSource(): void {
+ const returnTo = this.returnTo();
+ if (returnTo) {
+ void this.router.navigateByUrl(returnTo).catch(() => undefined);
+ return;
+ }
+
+ void this.router.navigate(['/security', 'reachability', 'witnesses']).catch(() => undefined);
+ }
+
+ returnToLabel(): string {
+ const returnTo = this.returnTo() ?? '';
+ if (returnTo.includes('/security/findings')) {
+ return 'Findings';
+ }
+ if (returnTo.includes('/security/artifacts')) {
+ return 'Triage';
+ }
+ if (returnTo.includes('/evidence/verify-replay')) {
+ return 'Verify & Replay';
+ }
+ if (returnTo.includes('/releases/runs')) {
+ return 'Release run';
+ }
+ return 'Witnesses';
+ }
+
+ formatDate(isoDate: string | null | undefined): string {
+ if (!isoDate) {
+ return 'n/a';
+ }
+
+ return new Intl.DateTimeFormat('en-US', {
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ month: 'short',
+ timeZone: 'UTC',
+ year: 'numeric',
+ }).format(new Date(isoDate));
+ }
+
+ confidencePercent(score: number | null | undefined): string {
+ return `${Math.round((score ?? 0) * 100)}%`;
+ }
+
+ artifactRouteId(value: string): string {
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '-');
+ }
+
+ private async applyRouteState(
+ params: ParamMap,
+ queryParams: ParamMap
+ ): Promise {
+ const nextWitnessId = params.get('witnessId') ?? '';
+ this.returnTo.set(queryParams.get('returnTo'));
+ this.showPoe.set(queryParams.get('panel') === 'poe');
+
+ if (!nextWitnessId || nextWitnessId === this.witnessId()) {
+ return;
+ }
+
+ this.witnessId.set(nextWitnessId);
+ await this.loadWitness(nextWitnessId);
+ }
+
+ private async loadWitness(witnessId: string): Promise {
+ this.loading.set(true);
+ this.error.set(null);
+ this.message.set(null);
+
+ try {
+ const witness = await firstValueFrom(this.witnessApi.getWitness(witnessId));
+ this.witness.set(witness);
+ } catch {
+ const fixture = findWitnessFixture(witnessId);
+ if (fixture) {
+ this.witness.set(fixture);
+ this.showMessage(
+ 'Reachability backend unavailable. Showing cached witness fixture.',
+ 'error'
+ );
+ } else {
+ this.witness.set(null);
+ this.error.set(`Witness ${witnessId} could not be loaded.`);
+ }
+ } finally {
+ this.loading.set(false);
}
}
- private getMermaidStyle(type?: string): string {
- switch (type) {
- case 'entrypoint': return ':::entry';
- case 'sink': return ':::sink';
- case 'gate': return ':::gate';
- default: return '';
+ private async verifyFallbackAware(
+ witnessId: string
+ ): Promise {
+ try {
+ return await firstValueFrom(this.witnessApi.verifyWitness(witnessId));
+ } catch {
+ return fallbackWitnessVerification(witnessId);
}
}
- private downloadFile(filename: string, content: string): void {
- const blob = new Blob([content], { type: 'text/plain' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = filename;
- a.click();
+ private currentWitnessUrl(): string {
+ return this.router.serializeUrl(
+ this.router.createUrlTree(
+ ['/security', 'reachability', 'witnesses', this.witnessId()],
+ {
+ queryParams: this.returnTo() ? { returnTo: this.returnTo()! } : undefined,
+ }
+ )
+ );
+ }
+
+ private showMessage(message: string, type: MessageType): void {
+ this.message.set(message);
+ this.messageType.set(type);
+ }
+
+ private downloadText(
+ filename: string,
+ content: string,
+ contentType: string
+ ): void {
+ const blob = new Blob([content], { type: contentType });
+ this.downloadBlob(filename, blob, contentType);
+ }
+
+ private downloadBlob(
+ filename: string,
+ blob: Blob,
+ contentType: string
+ ): void {
+ const typedBlob =
+ blob.type && blob.type.length > 0 ? blob : new Blob([blob], { type: contentType });
+ const url = URL.createObjectURL(typedBlob);
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = filename;
+ anchor.click();
URL.revokeObjectURL(url);
}
}
+
+function toPathRow(node: CallPathNode): WitnessPathRow {
+ return {
+ id: node.nodeId,
+ symbol: node.symbol,
+ kind: 'path',
+ location: formatLocation(node.file, node.line),
+ detail: node.package ?? null,
+ };
+}
+
+function formatLocation(
+ file: string | undefined,
+ line: number | undefined
+): string | null {
+ if (!file) {
+ return null;
+ }
+ return line ? `${file}:${line}` : file;
+}
diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts
index ed25db752..87aa7519e 100644
--- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts
@@ -143,7 +143,7 @@ interface ReloadOptions {
@if (release()) {
diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.scss b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.scss
index 891823329..f06a2b0d3 100644
--- a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.scss
+++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.scss
@@ -341,6 +341,13 @@
gap: var(--space-4);
}
+.reachability-header__actions,
+.action-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+
.reachability-controls {
display: flex;
flex-wrap: wrap;
diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts
index d8f5f5635..039694c84 100644
--- a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts
+++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.ts
@@ -400,14 +400,17 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
});
const artifactId = this.route.snapshot.paramMap.get('artifactId') ?? '';
+ const requestedFindingId = this.route.snapshot.queryParamMap.get('findingId');
+ const requestedTab = this.parseRequestedTab(this.route.snapshot.queryParamMap.get('tab'));
this.artifactId.set(artifactId);
await this.load();
await this.loadVexDecisions();
- const first = this.findings()[0]?.vuln.vulnId ?? null;
- this.selectedVulnId.set(first);
- if (first) {
- void this.loadUnifiedEvidence(first);
+ const initialFindingId = this.resolveRequestedFindingId(requestedFindingId);
+ this.selectedVulnId.set(initialFindingId);
+ this.activeTab.set(initialFindingId ? requestedTab : 'evidence');
+ if (initialFindingId) {
+ void this.loadUnifiedEvidence(initialFindingId);
}
// Keep initialization responsive; gated buckets are non-blocking metadata.
@@ -849,6 +852,28 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
void this.router.navigate(['/triage/audit-bundles/new'], { queryParams: { artifactId } });
}
+ openCanonicalReachabilityWorkspace(): void {
+ const selected = this.selectedVuln();
+ if (!selected) {
+ return;
+ }
+
+ const queryParams: Record = {
+ search: selected.vuln.cveId || selected.vuln.vulnId,
+ findingId: selected.vuln.vulnId,
+ returnTo: this.buildWorkspaceReturnTo('reachability'),
+ };
+
+ const releaseId = this.route.snapshot.queryParamMap.get('releaseId');
+ if (releaseId) {
+ queryParams['releaseId'] = releaseId;
+ }
+
+ void this.router.navigate(['/security/reachability/witnesses'], {
+ queryParams,
+ });
+ }
+
setTab(tab: TabId): void {
this.activeTab.set(tab);
}
@@ -1297,4 +1322,38 @@ export class TriageWorkspaceComponent implements OnInit, OnDestroy {
this.unifiedEvidence.set(updated);
}
}
+
+ private parseRequestedTab(value: string | null): TabId {
+ if (value && TAB_ORDER.includes(value as TabId)) {
+ return value as TabId;
+ }
+ return 'evidence';
+ }
+
+ private resolveRequestedFindingId(requestedFindingId: string | null): string | null {
+ if (requestedFindingId) {
+ const requested = this.findings().find(
+ (finding) =>
+ finding.vuln.vulnId === requestedFindingId ||
+ finding.vuln.cveId === requestedFindingId
+ );
+ if (requested) {
+ return requested.vuln.vulnId;
+ }
+ }
+
+ return this.findings()[0]?.vuln.vulnId ?? null;
+ }
+
+ private buildWorkspaceReturnTo(tab: TabId): string {
+ const selected = this.selectedVuln();
+ return this.router.serializeUrl(
+ this.router.createUrlTree(['/security', 'artifacts', this.artifactId()], {
+ queryParams: {
+ findingId: selected?.vuln.vulnId,
+ tab,
+ },
+ })
+ );
+ }
}
diff --git a/src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts b/src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts
index aa92948a5..525850efa 100644
--- a/src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts
+++ b/src/Web/StellaOps.Web/src/app/routes/security-risk.routes.ts
@@ -230,6 +230,60 @@ export const SECURITY_RISK_ROUTES: Routes = [
(m) => m.ReachabilityCenterComponent
),
},
+ {
+ path: 'reachability/coverage',
+ title: 'Reachability Coverage',
+ data: { breadcrumb: 'Reachability Coverage' },
+ loadComponent: () =>
+ import('../features/reachability/reachability-center.component').then(
+ (m) => m.ReachabilityCenterComponent
+ ),
+ },
+ {
+ path: 'reachability/witnesses',
+ title: 'Reachability Witnesses',
+ data: { breadcrumb: 'Reachability Witnesses' },
+ loadComponent: () =>
+ import('../features/reachability/reachability-center.component').then(
+ (m) => m.ReachabilityCenterComponent
+ ),
+ },
+ {
+ path: 'reachability/witnesses/:witnessId',
+ title: 'Reachability Witness',
+ data: { breadcrumb: 'Reachability Witness' },
+ loadComponent: () =>
+ import('../features/reachability/witness-page.component').then(
+ (m) => m.WitnessPageComponent
+ ),
+ },
+ {
+ path: 'reachability/poe',
+ title: 'Proof Of Exposure',
+ data: { breadcrumb: 'Proof Of Exposure' },
+ loadComponent: () =>
+ import('../features/reachability/reachability-center.component').then(
+ (m) => m.ReachabilityCenterComponent
+ ),
+ },
+ {
+ path: 'reachability/poe/:artifactId',
+ title: 'Proof Of Exposure',
+ data: { breadcrumb: 'Proof Of Exposure' },
+ loadComponent: () =>
+ import('../features/reachability/reachability-center.component').then(
+ (m) => m.ReachabilityCenterComponent
+ ),
+ },
+ {
+ path: 'reachability/gaps',
+ title: 'Sensor Gaps',
+ data: { breadcrumb: 'Sensor Gaps' },
+ loadComponent: () =>
+ import('../features/reachability/reachability-center.component').then(
+ (m) => m.ReachabilityCenterComponent
+ ),
+ },
{
path: 'risk',
title: 'Risk Overview',
diff --git a/src/Web/StellaOps.Web/src/tests/evidence/replay-controls-reachability-handoff.spec.ts b/src/Web/StellaOps.Web/src/tests/evidence/replay-controls-reachability-handoff.spec.ts
new file mode 100644
index 000000000..37864f3e6
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/tests/evidence/replay-controls-reachability-handoff.spec.ts
@@ -0,0 +1,67 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
+import { BehaviorSubject } from 'rxjs';
+
+import { ReplayControlsComponent } from '../../app/features/evidence-export/replay-controls.component';
+
+describe('ReplayControlsComponent reachability handoff', () => {
+ let fixture: ComponentFixture;
+ let component: ReplayControlsComponent;
+ let queryParamMap$: BehaviorSubject>;
+
+ beforeEach(async () => {
+ queryParamMap$ = new BehaviorSubject(
+ convertToParamMap({
+ requestId: 'rr-003',
+ releaseId: 'rel-ops-42',
+ runId: 'run-ops-42',
+ })
+ );
+
+ await TestBed.configureTestingModule({
+ imports: [ReplayControlsComponent],
+ providers: [
+ provideRouter([]),
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ queryParamMap: queryParamMap$.asObservable(),
+ snapshot: {
+ queryParamMap: queryParamMap$.value,
+ },
+ },
+ },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ReplayControlsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('restores the expanded replay request from query state', () => {
+ expect(component.expandedRequest()).toBe('rr-003');
+ expect(component.releaseId()).toBe('rel-ops-42');
+ expect(component.runId()).toBe('run-ops-42');
+ });
+
+ it('navigates to canonical reachability with return-to replay context', () => {
+ const router = TestBed.inject(Router);
+ const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
+
+ component.openReachabilityWorkspace(component.requests()[2]);
+
+ expect(navigateSpy).toHaveBeenCalledWith(
+ ['/security/reachability/witnesses'],
+ {
+ queryParams: {
+ search: 'verdict-ghi789',
+ returnTo:
+ '/evidence/verify-replay?requestId=rr-003&releaseId=rel-ops-42&runId=run-ops-42',
+ releaseId: 'rel-ops-42',
+ runId: 'run-ops-42',
+ },
+ }
+ );
+ });
+});
diff --git a/src/Web/StellaOps.Web/src/tests/reachability_center/reachability-center.component.spec.ts b/src/Web/StellaOps.Web/src/tests/reachability_center/reachability-center.component.spec.ts
index 12325e280..0c92d7235 100644
--- a/src/Web/StellaOps.Web/src/tests/reachability_center/reachability-center.component.spec.ts
+++ b/src/Web/StellaOps.Web/src/tests/reachability_center/reachability-center.component.spec.ts
@@ -1,47 +1,116 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute, UrlSegment, convertToParamMap, provideRouter } from '@angular/router';
+import { BehaviorSubject, of } from 'rxjs';
+import { WITNESS_API, type WitnessApi } from '../../app/core/api/witness.client';
import { ReachabilityCenterComponent } from '../../app/features/reachability/reachability-center.component';
+import { REACHABILITY_WITNESS_FIXTURES } from '../../app/features/reachability/reachability-fixtures';
describe('ReachabilityCenterComponent (reachability_center)', () => {
let fixture: ComponentFixture;
let component: ReachabilityCenterComponent;
+ let urlSegments$: BehaviorSubject;
+ let paramMap$: BehaviorSubject>;
+ let queryParamMap$: BehaviorSubject>;
+
beforeEach(async () => {
+ urlSegments$ = new BehaviorSubject([
+ new UrlSegment('reachability', {}),
+ new UrlSegment('witnesses', {}),
+ ]);
+ paramMap$ = new BehaviorSubject(convertToParamMap({}));
+ queryParamMap$ = new BehaviorSubject(
+ convertToParamMap({
+ search: 'CVE-2026-4001',
+ returnTo: '/evidence/verify-replay?requestId=rr-001',
+ })
+ );
+
+ const witnessApi = jasmine.createSpyObj('WitnessApi', [
+ 'getWitness',
+ 'listWitnesses',
+ 'verifyWitness',
+ 'getWitnessesForVuln',
+ 'getStateFlipSummary',
+ 'downloadWitnessJson',
+ 'exportSarif',
+ 'getRuntimeTraces',
+ 'getWitnessTimeline',
+ 'getComparisonMetrics',
+ ]) as jasmine.SpyObj;
+ witnessApi.listWitnesses.and.returnValue(
+ of({
+ witnesses: [...REACHABILITY_WITNESS_FIXTURES],
+ total: REACHABILITY_WITNESS_FIXTURES.length,
+ page: 1,
+ pageSize: 50,
+ hasMore: false,
+ })
+ );
+
+ const routeStub = {
+ url: urlSegments$.asObservable(),
+ paramMap: paramMap$.asObservable(),
+ queryParamMap: queryParamMap$.asObservable(),
+ get snapshot() {
+ return {
+ url: urlSegments$.value,
+ paramMap: paramMap$.value,
+ queryParamMap: queryParamMap$.value,
+ };
+ },
+ };
+
await TestBed.configureTestingModule({
imports: [ReachabilityCenterComponent],
+ providers: [
+ provideRouter([]),
+ { provide: ActivatedRoute, useValue: routeStub },
+ { provide: WITNESS_API, useValue: witnessApi },
+ ],
}).compileComponents();
fixture = TestBed.createComponent(ReachabilityCenterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
});
- it('computes deterministic coverage and missing-sensor summaries', () => {
+ it('loads the witness list from the canonical route and applies query filters', () => {
+ expect(component.activeTab()).toBe('witnesses');
expect(component.okCount()).toBe(1);
expect(component.staleCount()).toBe(1);
expect(component.missingCount()).toBe(1);
- expect(component.fleetCoveragePercent()).toBe(69);
+ expect(component.fleetCoveragePercent()).toBe(73);
expect(component.sensorCoveragePercent()).toBe(63);
- expect(component.assetsMissingSensors().map((a) => a.assetId)).toEqual([
- 'asset-api-prod',
- 'asset-worker-prod',
+ expect(component.filteredWitnesses().map((witness) => witness.witnessId)).toEqual([
+ 'wit-api-001',
]);
+
+ const text = fixture.nativeElement.textContent as string;
+ expect(text).toContain('Reachability');
+ expect(text).toContain('wit-api-001');
});
- it('supports missing-sensor quick filter action', () => {
- component.goToMissingSensors();
+ it('resolves the PoE permalink route to the matching proof artifact', async () => {
+ urlSegments$.next([
+ new UrlSegment('reachability', {}),
+ new UrlSegment('poe', {}),
+ ]);
+ paramMap$.next(convertToParamMap({ artifactId: 'cve-2026-4001' }));
+ queryParamMap$.next(convertToParamMap({ tab: 'poe' }));
+
+ await fixture.whenStable();
fixture.detectChanges();
- expect(component.statusFilter()).toBe('missing');
- expect(component.filteredRows().map((r) => r.assetId)).toEqual(['asset-worker-prod']);
+ expect(component.activeTab()).toBe('poe');
+ expect(component.selectedPoeArtifactId()).toBe('cve-2026-4001');
+ expect(component.selectedPoeArtifact()?.vulnId).toBe('CVE-2026-4001');
});
- it('renders missing sensor chips and per-row sensor gap text', () => {
- const text = fixture.nativeElement.textContent as string;
- expect(text).toContain('Missing sensors detected');
- expect(text).toContain('asset-api-prod');
- expect(text).toContain('missing 1 sensor');
- expect(text).toContain('missing 2 sensors');
- expect(text).toContain('all sensors online');
+ it('labels evidence replay as the return target when opened from replay verification', () => {
+ expect(component.returnToLabel()).toBe('Verify & Replay');
});
});
diff --git a/src/Web/StellaOps.Web/src/tests/reachability_center/witness-page.component.spec.ts b/src/Web/StellaOps.Web/src/tests/reachability_center/witness-page.component.spec.ts
new file mode 100644
index 000000000..e692c2d19
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/tests/reachability_center/witness-page.component.spec.ts
@@ -0,0 +1,111 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute, Router, UrlSegment, convertToParamMap, provideRouter } from '@angular/router';
+import { BehaviorSubject, throwError } from 'rxjs';
+
+import { WITNESS_API, type WitnessApi } from '../../app/core/api/witness.client';
+import { WitnessPageComponent } from '../../app/features/reachability/witness-page.component';
+
+describe('WitnessPageComponent (reachability_center)', () => {
+ let fixture: ComponentFixture;
+ let component: WitnessPageComponent;
+ let router: Router;
+
+ let paramMap$: BehaviorSubject>;
+ let queryParamMap$: BehaviorSubject>;
+
+ beforeEach(async () => {
+ paramMap$ = new BehaviorSubject(
+ convertToParamMap({ witnessId: 'wit-api-001' })
+ );
+ queryParamMap$ = new BehaviorSubject(
+ convertToParamMap({
+ panel: 'poe',
+ returnTo: '/security/findings/finding-api-001',
+ })
+ );
+
+ const witnessApi = jasmine.createSpyObj('WitnessApi', [
+ 'getWitness',
+ 'listWitnesses',
+ 'verifyWitness',
+ 'getWitnessesForVuln',
+ 'getStateFlipSummary',
+ 'downloadWitnessJson',
+ 'exportSarif',
+ 'getRuntimeTraces',
+ 'getWitnessTimeline',
+ 'getComparisonMetrics',
+ ]) as jasmine.SpyObj;
+ witnessApi.getWitness.and.returnValue(
+ throwError(() => new Error('backend unavailable'))
+ );
+ witnessApi.verifyWitness.and.returnValue(
+ throwError(() => new Error('backend unavailable'))
+ );
+ witnessApi.downloadWitnessJson.and.returnValue(
+ throwError(() => new Error('backend unavailable'))
+ );
+
+ const routeStub = {
+ paramMap: paramMap$.asObservable(),
+ queryParamMap: queryParamMap$.asObservable(),
+ get snapshot() {
+ return {
+ url: [
+ new UrlSegment('reachability', {}),
+ new UrlSegment('witnesses', {}),
+ new UrlSegment('wit-api-001', {}),
+ ],
+ paramMap: paramMap$.value,
+ queryParamMap: queryParamMap$.value,
+ };
+ },
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [WitnessPageComponent],
+ providers: [
+ provideRouter([]),
+ { provide: ActivatedRoute, useValue: routeStub },
+ { provide: WITNESS_API, useValue: witnessApi },
+ ],
+ }).compileComponents();
+
+ router = TestBed.inject(Router);
+ fixture = TestBed.createComponent(WitnessPageComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+ });
+
+ it('falls back to cached witness fixtures and opens the PoE drawer from query state', () => {
+ expect(component.witness()?.witnessId).toBe('wit-api-001');
+ expect(component.showPoe()).toBeTrue();
+ expect(component.proofArtifact()?.vulnId).toBe('CVE-2026-4001');
+ expect((fixture.nativeElement.textContent as string)).toContain('Reachability Witness');
+ });
+
+ it('verifies witnesses through the offline fallback when the backend is unavailable', async () => {
+ await component.verifyWitness();
+ fixture.detectChanges();
+
+ expect(component.message()).toContain('verified');
+ expect(component.messageType()).toBe('success');
+ });
+
+ it('navigates to the canonical PoE permalink route from witness detail', () => {
+ const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
+
+ component.openPoePermalink();
+
+ expect(navigateSpy).toHaveBeenCalledWith(
+ ['/security', 'reachability', 'poe', 'cve-2026-4001'],
+ {
+ queryParams: {
+ returnTo: '/security/reachability/witnesses/wit-api-001?returnTo=%2Fsecurity%2Ffindings%2Ffinding-api-001',
+ },
+ }
+ );
+ });
+});
diff --git a/src/Web/StellaOps.Web/src/tests/releases/release-detail.live-refresh.spec.ts b/src/Web/StellaOps.Web/src/tests/releases/release-detail.live-refresh.spec.ts
index b09d301e7..5c662119c 100644
--- a/src/Web/StellaOps.Web/src/tests/releases/release-detail.live-refresh.spec.ts
+++ b/src/Web/StellaOps.Web/src/tests/releases/release-detail.live-refresh.spec.ts
@@ -1,6 +1,6 @@
import { HttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
-import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
+import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { BehaviorSubject, of } from 'rxjs';
import { signal } from '@angular/core';
@@ -133,4 +133,84 @@ describe('ReleaseDetailComponent live refresh contract', () => {
}),
);
});
+
+ it('opens the canonical reachability workspace with a release return-to link', () => {
+ const router = TestBed.inject(Router);
+ const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
+
+ component.mode.set('run');
+ component.releaseId.set('run-3');
+ component.activeTab.set('security-inputs');
+ component.runManagedRelease.set({
+ id: 'run-3',
+ name: 'billing',
+ version: 'v3',
+ releaseType: 'standard',
+ gateStatus: 'warn',
+ evidencePosture: 'partial',
+ riskTier: 'high',
+ needsApproval: true,
+ blocked: false,
+ replayMismatch: false,
+ createdAt: '2026-02-20T12:00:00Z',
+ createdBy: 'system',
+ updatedAt: '2026-02-20T12:30:00Z',
+ lastActor: 'system',
+ } as any);
+ component.runDetail.set({
+ runId: 'run-3',
+ releaseId: 'rel-3',
+ releaseName: 'billing',
+ releaseSlug: 'billing',
+ releaseType: 'standard',
+ releaseVersionId: 'ver-3',
+ releaseVersionNumber: 3,
+ releaseVersionDigest: 'sha256:ghi',
+ lane: 'standard',
+ status: 'running',
+ outcome: 'in_progress',
+ targetEnvironment: 'prod',
+ targetRegion: 'eu-west',
+ scopeSummary: 'stage->prod',
+ requestedAt: '2026-02-20T12:00:00Z',
+ updatedAt: '2026-02-20T12:30:00Z',
+ needsApproval: true,
+ blockedByDataIntegrity: false,
+ correlationKey: 'corr-3',
+ statusRow: {
+ runStatus: 'running',
+ gateStatus: 'warn',
+ approvalStatus: 'pending',
+ dataTrustStatus: 'healthy',
+ },
+ });
+ component.findings.set([
+ {
+ findingId: 'finding-1',
+ cveId: 'CVE-2026-4401',
+ severity: 'high',
+ componentName: 'billing-api',
+ releaseId: 'rel-3',
+ reachable: true,
+ reachabilityScore: 91,
+ effectiveDisposition: 'review_required',
+ vexStatus: 'under_investigation',
+ exceptionStatus: 'none',
+ },
+ ]);
+
+ component.openReachabilityWorkspace();
+
+ expect(navigateSpy).toHaveBeenCalledWith(
+ ['/security/reachability/witnesses'],
+ {
+ queryParams: {
+ search: 'CVE-2026-4401',
+ releaseId: 'rel-3',
+ returnTo: '/releases/runs/run-3/security-inputs',
+ runId: 'run-3',
+ },
+ }
+ );
+ });
});
diff --git a/src/Web/StellaOps.Web/src/tests/security-risk/finding-detail-page-reachability-handoff.spec.ts b/src/Web/StellaOps.Web/src/tests/security-risk/finding-detail-page-reachability-handoff.spec.ts
new file mode 100644
index 000000000..5e756283c
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/tests/security-risk/finding-detail-page-reachability-handoff.spec.ts
@@ -0,0 +1,94 @@
+import { HttpClient } from '@angular/common/http';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
+import { BehaviorSubject, of } from 'rxjs';
+
+import { FindingDetailPageComponent } from '../../app/features/security-risk/finding-detail-page.component';
+
+describe('FindingDetailPageComponent reachability handoff', () => {
+ let fixture: ComponentFixture;
+ let component: FindingDetailPageComponent;
+
+ beforeEach(async () => {
+ const paramMap$ = new BehaviorSubject(
+ convertToParamMap({ findingId: 'finding-api-001' })
+ );
+ const queryParamMap$ = new BehaviorSubject(convertToParamMap({ tab: 'evidence' }));
+
+ const http = {
+ get: jasmine.createSpy('get').and.callFake((url: string) => {
+ if (url.includes('/api/v2/security/disposition/')) {
+ return of({
+ item: {
+ findingId: 'finding-api-001',
+ cveId: 'CVE-2026-4001',
+ releaseId: 'run-123',
+ releaseName: 'release-123',
+ packageName: 'api-gateway',
+ componentName: 'api-gateway',
+ environment: 'prod',
+ region: 'eu-west',
+ effectiveDisposition: 'action_required',
+ policyAction: 'block',
+ updatedAt: '2026-03-07T10:00:00Z',
+ vex: { status: 'under_investigation', justification: 'pending' },
+ exception: { status: 'none', reason: '', approvalState: 'none' },
+ },
+ });
+ }
+
+ return of({
+ items: [
+ {
+ findingId: 'finding-api-001',
+ cveId: 'CVE-2026-4001',
+ severity: 'high',
+ reachable: true,
+ reachabilityScore: 91,
+ updatedAt: '2026-03-07T10:00:00Z',
+ },
+ ],
+ });
+ }),
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [FindingDetailPageComponent],
+ providers: [
+ provideRouter([]),
+ { provide: HttpClient, useValue: http },
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ paramMap: paramMap$.asObservable(),
+ queryParamMap: queryParamMap$.asObservable(),
+ snapshot: {
+ paramMap: paramMap$.value,
+ queryParamMap: queryParamMap$.value,
+ },
+ },
+ },
+ ],
+ }).compileComponents();
+
+ const router = TestBed.inject(Router);
+ Object.defineProperty(router, 'url', {
+ configurable: true,
+ get: () => '/security/findings/finding-api-001?tab=evidence',
+ });
+
+ fixture = TestBed.createComponent(FindingDetailPageComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+ });
+
+ it('builds canonical reachability query params from the current finding context', () => {
+ expect(component.reachabilityQueryParams()).toEqual({
+ search: 'CVE-2026-4001',
+ findingId: 'finding-api-001',
+ returnTo: '/security/findings/finding-api-001?tab=evidence',
+ });
+ });
+});
diff --git a/src/Web/StellaOps.Web/src/tests/security-risk/security-risk-routes.spec.ts b/src/Web/StellaOps.Web/src/tests/security-risk/security-risk-routes.spec.ts
index a3fb8a134..1006256a9 100644
--- a/src/Web/StellaOps.Web/src/tests/security-risk/security-risk-routes.spec.ts
+++ b/src/Web/StellaOps.Web/src/tests/security-risk/security-risk-routes.spec.ts
@@ -51,6 +51,15 @@ describe('SECURITY_RISK_ROUTES', () => {
expect(allPaths).toContain('reachability');
});
+ it('contains the reachability witness and proof routes', () => {
+ expect(allPaths).toContain('reachability/coverage');
+ expect(allPaths).toContain('reachability/witnesses');
+ expect(allPaths).toContain('reachability/witnesses/:witnessId');
+ expect(allPaths).toContain('reachability/poe');
+ expect(allPaths).toContain('reachability/poe/:artifactId');
+ expect(allPaths).toContain('reachability/gaps');
+ });
+
it('contains the risk route', () => {
expect(allPaths).toContain('risk');
});
@@ -169,6 +178,12 @@ describe('SECURITY_RISK_ROUTES', () => {
expect(getRouteByPath('reachability')?.data?.['breadcrumb']).toBe('Reachability');
});
+ it('reachability witness detail route has the expected breadcrumb', () => {
+ expect(getRouteByPath('reachability/witnesses/:witnessId')?.data?.['breadcrumb']).toBe(
+ 'Reachability Witness'
+ );
+ });
+
it('lineage route has "Lineage" breadcrumb', () => {
expect(getRouteByPath('lineage')?.data?.['breadcrumb']).toBe('Lineage');
});
@@ -188,6 +203,11 @@ describe('SECURITY_RISK_ROUTES', () => {
expect((component as { name?: string }).name).toContain('ExceptionApprovalQueueComponent');
});
+ it('reachability witness detail route loads WitnessPageComponent', async () => {
+ const component = await loadComponentByPath('reachability/witnesses/:witnessId');
+ expect((component as { name?: string }).name).toContain('WitnessPageComponent');
+ });
+
// ββββββββββββββββββββββββββββββββββββββββββ
// Route count sanity check
// ββββββββββββββββββββββββββββββββββββββββββ
diff --git a/src/Web/StellaOps.Web/src/tests/triage/triage-workspace-with-proof-tree.behavior.spec.ts b/src/Web/StellaOps.Web/src/tests/triage/triage-workspace-with-proof-tree.behavior.spec.ts
index 609f4bad6..b918c4975 100644
--- a/src/Web/StellaOps.Web/src/tests/triage/triage-workspace-with-proof-tree.behavior.spec.ts
+++ b/src/Web/StellaOps.Web/src/tests/triage/triage-workspace-with-proof-tree.behavior.spec.ts
@@ -1,6 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
-import { ActivatedRoute, provideRouter } from '@angular/router';
+import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { By } from '@angular/platform-browser';
import { of } from 'rxjs';
@@ -64,7 +64,18 @@ describe('triage-workspace-with-proof-tree behavior', () => {
provideRouter([]),
{ provide: VULNERABILITY_API, useValue: vulnApi },
{ provide: VEX_DECISIONS_API, useValue: vexApi },
- { provide: ActivatedRoute, useValue: { snapshot: { paramMap: new Map([['artifactId', 'asset-web-prod']]) } } },
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ snapshot: {
+ paramMap: convertToParamMap({ artifactId: 'asset-web-prod' }),
+ queryParamMap: convertToParamMap({
+ findingId: 'v-2',
+ tab: 'reachability',
+ }),
+ },
+ },
+ },
],
}).compileComponents();
@@ -82,8 +93,8 @@ describe('triage-workspace-with-proof-tree behavior', () => {
const component = workspaceFixture.componentInstance;
expect(component.findings().map((finding) => finding.vuln.vulnId)).toEqual(['v-1', 'v-2']);
- expect(component.selectedVulnId()).toBe('v-1');
- expect(component.activeTab()).toBe('evidence');
+ expect(component.selectedVulnId()).toBe('v-2');
+ expect(component.activeTab()).toBe('reachability');
});
it('supports reachability tab with textual proof mode toggle', async () => {
@@ -99,8 +110,31 @@ describe('triage-workspace-with-proof-tree behavior', () => {
expect(component.activeTab()).toBe('reachability');
expect(component.reachabilityView()).toBe('textual-proof');
- const hintText = (workspaceFixture.nativeElement.querySelector('.reachability-view .hint') as HTMLElement | null)?.textContent ?? '';
- expect(hintText).toContain('Textual proof view');
+ const viewText = (workspaceFixture.nativeElement.querySelector('.reachability-view') as HTMLElement | null)?.textContent ?? '';
+ expect(viewText).toContain('Status:');
+ });
+
+ it('opens the canonical reachability workspace with a return-to link', async () => {
+ workspaceFixture.detectChanges();
+ await workspaceFixture.whenStable();
+ workspaceFixture.detectChanges();
+
+ const router = TestBed.inject(Router);
+ const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
+ const component = workspaceFixture.componentInstance;
+
+ component.openCanonicalReachabilityWorkspace();
+
+ expect(navigateSpy).toHaveBeenCalledWith(
+ ['/security/reachability/witnesses'],
+ {
+ queryParams: {
+ search: 'CVE-2026-3002',
+ findingId: 'v-2',
+ returnTo: '/security/artifacts/asset-web-prod?findingId=v-2&tab=reachability',
+ },
+ }
+ );
});
it('renders proof tree digest and emits verify action', () => {
diff --git a/src/Web/StellaOps.Web/tests/e2e/reachability-witnessing.spec.ts b/src/Web/StellaOps.Web/tests/e2e/reachability-witnessing.spec.ts
new file mode 100644
index 000000000..423e0da36
--- /dev/null
+++ b/src/Web/StellaOps.Web/tests/e2e/reachability-witnessing.spec.ts
@@ -0,0 +1,265 @@
+import { expect, test, type Page, type Route } from '@playwright/test';
+
+import type { StubAuthSession } from '../../src/app/testing/auth-fixtures';
+
+const adminSession: StubAuthSession = {
+ subjectId: 'reachability-e2e-user',
+ tenant: 'tenant-default',
+ scopes: [
+ 'admin',
+ 'ui.read',
+ 'scanner:read',
+ 'sbom:read',
+ 'advisory:read',
+ 'vex:read',
+ 'exception:read',
+ 'findings:read',
+ 'vuln:view',
+ 'release:read',
+ 'policy:read',
+ 'policy:audit',
+ ],
+};
+
+const mockConfig = {
+ authority: {
+ issuer: '/authority',
+ clientId: 'stella-ops-ui',
+ authorizeEndpoint: '/authority/connect/authorize',
+ tokenEndpoint: '/authority/connect/token',
+ logoutEndpoint: '/authority/connect/logout',
+ redirectUri: 'https://127.0.0.1:4400/auth/callback',
+ postLogoutRedirectUri: 'https://127.0.0.1:4400/',
+ scope: 'openid profile email ui.read',
+ audience: '/gateway',
+ dpopAlgorithms: ['ES256'],
+ refreshLeewaySeconds: 60,
+ },
+ apiBaseUrls: {
+ authority: '/authority',
+ scanner: '/scanner',
+ policy: '/policy',
+ concelier: '/concelier',
+ attestor: '/attestor',
+ gateway: '/gateway',
+ },
+ quickstartMode: true,
+ setup: 'complete',
+};
+
+const witnessFixtures = [
+ {
+ witnessId: 'wit-api-001',
+ scanId: 'scan-release-orchestrator-prod',
+ tenantId: 'tenant-default',
+ vulnId: 'finding-api-001',
+ cveId: 'CVE-2026-4001',
+ packageName: 'api-gateway',
+ packageVersion: '1.8.4',
+ purl: 'pkg:oci/api-gateway@sha256:api001',
+ confidenceTier: 'confirmed',
+ confidenceScore: 0.94,
+ isReachable: true,
+ callPath: [
+ { nodeId: 'n-api-1', symbol: 'IngressController.route()', file: 'src/ingress/controller.ts', line: 18 },
+ { nodeId: 'n-api-2', symbol: 'AuthContext.load()', file: 'src/auth/context.ts', line: 44 },
+ { nodeId: 'n-api-3', symbol: 'ReleaseResolver.resolve()', file: 'src/release/resolver.ts', line: 91 },
+ ],
+ entrypoint: {
+ nodeId: 'n-api-1',
+ symbol: 'IngressController.route()',
+ file: 'src/ingress/controller.ts',
+ line: 18,
+ httpRoute: '/releases/{id}',
+ httpMethod: 'GET',
+ },
+ sink: {
+ nodeId: 'n-api-4',
+ symbol: 'JacksonDeserializer.readValue()',
+ package: 'com.fasterxml.jackson.databind',
+ method: 'readValue',
+ },
+ gates: [
+ {
+ gateType: 'auth',
+ symbol: 'jwt.required',
+ confidence: 0.91,
+ description: 'JWT auth gate precedes the vulnerable parser path.',
+ file: 'src/auth/context.ts',
+ line: 22,
+ },
+ ],
+ evidence: {
+ analysisMethod: 'hybrid',
+ toolVersion: 'reachability-ui-fixture-v2',
+ callGraphHash: 'blake3:api-gateway-callgraph',
+ surfaceHash: 'sha256:api-gateway-surface',
+ dsseUri: '/evidence/capsules/cap-api-001',
+ rekorUri: 'https://rekor.example.dev/entries/498201',
+ callGraphUri: '/security/reachability?graph=api-gateway',
+ artifacts: [
+ { type: 'call-graph', hash: 'blake3:api-gateway-callgraph', algorithm: 'blake3', uri: '/security/reachability?graph=api-gateway' },
+ ],
+ },
+ signature: {
+ algorithm: 'ed25519',
+ keyId: 'reachability-signer-1',
+ signature: 'sig-api-001',
+ verified: true,
+ verifiedAt: '2026-03-07T15:20:00Z',
+ },
+ observedAt: '2026-03-07T15:14:00Z',
+ vexRecommendation: 'affected',
+ runtimeEvidence: {
+ available: true,
+ source: 'ebpf',
+ lastObservedAt: '2026-03-07T15:14:00Z',
+ invocationCount: 182,
+ confirmsStatic: true,
+ observationType: 'confirmed',
+ rekorLogIndex: 498201,
+ isStale: false,
+ containerContext: {
+ containerId: 'ctr-api-11',
+ imageDigest: 'sha256:api001',
+ environment: 'prod-eu',
+ },
+ },
+ },
+];
+
+async function fulfillJson(route: Route, body: unknown): Promise {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(body),
+ });
+}
+
+async function setupHarness(page: Page): Promise {
+ await page.addInitScript((session) => {
+ (window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
+ }, adminSession);
+
+ await page.route('**/platform/envsettings.json', (route) => fulfillJson(route, mockConfig));
+ await page.route('**/config.json', (route) => fulfillJson(route, mockConfig));
+ await page.route('**/.well-known/openid-configuration', (route) =>
+ fulfillJson(route, {
+ issuer: 'https://127.0.0.1:4400/authority',
+ authorization_endpoint: 'https://127.0.0.1:4400/authority/connect/authorize',
+ token_endpoint: 'https://127.0.0.1:4400/authority/connect/token',
+ jwks_uri: 'https://127.0.0.1:4400/authority/.well-known/jwks.json',
+ response_types_supported: ['code'],
+ subject_types_supported: ['public'],
+ id_token_signing_alg_values_supported: ['RS256'],
+ })
+ );
+ await page.route('**/authority/.well-known/jwks.json', (route) => fulfillJson(route, { keys: [] }));
+ await page.route('**/console/profile**', (route) =>
+ fulfillJson(route, {
+ subjectId: adminSession.subjectId,
+ username: 'reachability-e2e',
+ displayName: 'Reachability E2E',
+ tenant: adminSession.tenant,
+ roles: ['admin'],
+ scopes: adminSession.scopes,
+ })
+ );
+ await page.route('**/console/token/introspect**', (route) =>
+ fulfillJson(route, {
+ active: true,
+ tenant: adminSession.tenant,
+ subject: adminSession.subjectId,
+ scopes: adminSession.scopes,
+ })
+ );
+ await page.route('**/api/v2/context/regions', (route) =>
+ fulfillJson(route, [{ regionId: 'eu-west', displayName: 'EU West', sortOrder: 1, enabled: true }])
+ );
+ await page.route('**/api/v2/context/environments**', (route) =>
+ fulfillJson(route, [
+ {
+ environmentId: 'prod',
+ regionId: 'eu-west',
+ environmentType: 'prod',
+ displayName: 'Prod',
+ sortOrder: 1,
+ enabled: true,
+ },
+ ])
+ );
+ await page.route('**/api/v2/context/preferences', (route) =>
+ fulfillJson(route, {
+ tenantId: adminSession.tenant,
+ actorId: adminSession.subjectId,
+ regions: ['eu-west'],
+ environments: ['prod'],
+ timeWindow: '24h',
+ stage: 'all',
+ updatedAt: '2026-03-07T12:00:00Z',
+ updatedBy: adminSession.subjectId,
+ })
+ );
+ await page.route('**/doctor/api/v1/doctor/trends**', (route) => fulfillJson(route, []));
+ await page.route('**/api/v1/approvals**', (route) => fulfillJson(route, []));
+ await page.route('**/api/v1/witnesses?*', (route) =>
+ fulfillJson(route, {
+ witnesses: witnessFixtures,
+ total: witnessFixtures.length,
+ page: 1,
+ pageSize: 50,
+ hasMore: false,
+ })
+ );
+ await page.route('**/api/v1/witnesses/wit-api-001', (route) =>
+ fulfillJson(route, witnessFixtures[0])
+ );
+ await page.route('**/api/v1/witnesses/wit-api-001/verify', (route) =>
+ fulfillJson(route, {
+ witnessId: 'wit-api-001',
+ verified: true,
+ algorithm: 'ed25519',
+ keyId: 'reachability-signer-1',
+ verifiedAt: '2026-03-07T16:30:00Z',
+ })
+ );
+}
+
+test.beforeEach(async ({ page }) => {
+ await setupHarness(page);
+});
+
+test('reachability shell, witness detail, and PoE permalink stay in one canonical flow', async ({ page }) => {
+ await page.goto('/security/reachability/witnesses?search=CVE-2026-4001', {
+ waitUntil: 'networkidle',
+ });
+
+ await expect(page.getByTestId('reachability-page')).toBeVisible();
+ await expect(page.getByTestId('reachability-tab-witnesses')).toHaveClass(/active/);
+ await expect(page.getByTestId('witness-row')).toHaveCount(1);
+
+ await page.getByRole('link', { name: 'Open witness' }).click();
+ await expect(page).toHaveURL(/\/security\/reachability\/witnesses\/wit-api-001/);
+ await expect(page.getByTestId('witness-page')).toBeVisible();
+
+ await page.getByTestId('open-poe-btn').click();
+ await expect(page.getByTestId('poe-drawer')).toHaveClass(/poe-drawer--open/);
+ await page.getByTestId('poe-drawer-close').click();
+
+ await page.getByRole('button', { name: 'Permalink' }).click();
+ await expect(page).toHaveURL(/\/security\/reachability\/poe\/cve-2026-4001/);
+ await expect(page.getByTestId('poe-drawer')).toHaveClass(/poe-drawer--open/);
+});
+
+test('verify and replay hands off into the canonical reachability witness workspace', async ({ page }) => {
+ await page.goto('/evidence/verify-replay?releaseId=rel-ops-42&runId=run-ops-42&requestId=rr-003', {
+ waitUntil: 'networkidle',
+ });
+
+ await expect(page.getByText('Context:')).toBeVisible();
+ await page.getByRole('button', { name: 'Open reachability proof' }).click();
+
+ await expect(page).toHaveURL(/\/security\/reachability\/witnesses/);
+ await expect(page.url()).toContain('returnTo=%2Fevidence%2Fverify-replay%3FrequestId%3Drr-003%26releaseId%3Drel-ops-42%26runId%3Drun-ops-42');
+ await expect(page.getByTestId('reachability-page')).toBeVisible();
+});