SPRINT_3600_0001_0001 - Reachability Drift Detection Master Plan

This commit is contained in:
2025-12-18 00:02:31 +02:00
parent 8bbfe4d2d2
commit dee252940b
13 changed files with 6099 additions and 1651 deletions

View File

@@ -0,0 +1,400 @@
# Stella Ops Triage UI Reducer Spec (Pure State + Explicit Commands)
## 0. Purpose
Define a deterministic, testable UI state machine for the triage UI.
- State transitions are pure functions.
- Side effects are emitted as explicit Commands.
- Enables UI "replay" for debugging (aligns with Stella's deterministic ethos).
Target stack: Angular 17 + TypeScript.
## 1. Core Concepts
- Action: user/system event (route change, button click, HTTP success).
- State: all data required to render triage surfaces.
- Command: side-effect request (HTTP, download, navigation).
Reducer signature:
```ts
type ReduceResult = { state: TriageState; cmd: Command };
function reduce(state: TriageState, action: Action): ReduceResult;
```
## 2. State Model
```ts
export type Lane =
| "ACTIVE"
| "BLOCKED"
| "NEEDS_EXCEPTION"
| "MUTED_REACH"
| "MUTED_VEX"
| "COMPENSATED";
export type Verdict = "SHIP" | "BLOCK" | "EXCEPTION";
export interface MutedCounts {
reach: number;
vex: number;
compensated: number;
}
export interface FindingRow {
id: string; // caseId == findingId
lane: Lane;
verdict: Verdict;
score: number;
reachable: "YES" | "NO" | "UNKNOWN";
vex: "affected" | "not_affected" | "under_investigation" | "unknown";
exploit: "YES" | "NO" | "UNKNOWN";
asset: string;
updatedAt: string; // ISO
}
export interface CaseHeader {
id: string;
verdict: Verdict;
lane: Lane;
score: number;
policyId: string;
policyVersion: string;
inputsHash: string;
why: string; // short narrative
chips: Array<{ key: string; label: string; value: string; evidenceIds?: string[] }>;
}
export type EvidenceType =
| "SBOM_SLICE"
| "VEX_DOC"
| "PROVENANCE"
| "CALLSTACK_SLICE"
| "REACHABILITY_PROOF"
| "REPLAY_MANIFEST"
| "POLICY"
| "SCAN_LOG"
| "OTHER";
export interface EvidenceItem {
id: string;
type: EvidenceType;
title: string;
issuer?: string;
signed: boolean;
signedBy?: string;
contentHash: string;
createdAt: string;
previewUrl?: string;
rawUrl: string;
}
export type DecisionKind = "MUTE_REACH" | "MUTE_VEX" | "ACK" | "EXCEPTION";
export interface DecisionItem {
id: string;
kind: DecisionKind;
reasonCode: string;
note?: string;
ttl?: string;
actor: { subject: string; display?: string };
createdAt: string;
revokedAt?: string;
signatureRef?: string;
}
export type SnapshotTrigger =
| "FEED_UPDATE"
| "VEX_UPDATE"
| "SBOM_UPDATE"
| "RUNTIME_TRACE"
| "POLICY_UPDATE"
| "DECISION"
| "RESCAN";
export interface SnapshotItem {
id: string;
trigger: SnapshotTrigger;
changedAt: string;
fromInputsHash: string;
toInputsHash: string;
summary: string;
}
export interface SmartDiff {
fromInputsHash: string;
toInputsHash: string;
inputsChanged: Array<{ key: string; before?: string; after?: string; evidenceIds?: string[] }>;
outputsChanged: Array<{ key: string; before?: string; after?: string; evidenceIds?: string[] }>;
}
export interface TriageState {
route: { page: "TABLE" | "CASE"; caseId?: string };
filters: {
showMuted: boolean;
lane?: Lane;
search?: string;
page: number;
pageSize: number;
};
table: {
loading: boolean;
rows: FindingRow[];
mutedCounts?: MutedCounts;
error?: string;
etag?: string;
};
caseView: {
loading: boolean;
header?: CaseHeader;
evidenceLoading: boolean;
evidence?: EvidenceItem[];
decisionsLoading: boolean;
decisions?: DecisionItem[];
snapshotsLoading: boolean;
snapshots?: SnapshotItem[];
diffLoading: boolean;
activeDiff?: SmartDiff;
error?: string;
etag?: string;
};
ui: {
decisionDrawerOpen: boolean;
diffPanelOpen: boolean;
toast?: { kind: "success" | "error" | "info"; message: string };
};
}
```
## 3. Commands
```ts
export type Command =
| { type: "NONE" }
| { type: "HTTP_GET"; url: string; headers?: Record<string, string>; onSuccess: Action; onError: Action }
| { type: "HTTP_POST"; url: string; body: unknown; headers?: Record<string, string>; onSuccess: Action; onError: Action }
| { type: "HTTP_DELETE"; url: string; headers?: Record<string, string>; onSuccess: Action; onError: Action }
| { type: "DOWNLOAD"; url: string }
| { type: "NAVIGATE"; route: TriageState["route"] };
```
## 4. Actions
```ts
export type Action =
// routing
| { type: "ROUTE_TABLE" }
| { type: "ROUTE_CASE"; caseId: string }
// table
| { type: "TABLE_LOAD" }
| { type: "TABLE_LOAD_OK"; rows: FindingRow[]; mutedCounts: MutedCounts; etag?: string }
| { type: "TABLE_LOAD_ERR"; error: string }
| { type: "FILTER_SET_SEARCH"; search?: string }
| { type: "FILTER_SET_LANE"; lane?: Lane }
| { type: "FILTER_TOGGLE_SHOW_MUTED" }
| { type: "FILTER_SET_PAGE"; page: number }
| { type: "FILTER_SET_PAGE_SIZE"; pageSize: number }
// case header
| { type: "CASE_LOAD"; caseId: string }
| { type: "CASE_LOAD_OK"; header: CaseHeader; etag?: string }
| { type: "CASE_LOAD_ERR"; error: string }
// evidence
| { type: "EVIDENCE_LOAD"; caseId: string }
| { type: "EVIDENCE_LOAD_OK"; evidence: EvidenceItem[] }
| { type: "EVIDENCE_LOAD_ERR"; error: string }
// decisions
| { type: "DECISIONS_LOAD"; caseId: string }
| { type: "DECISIONS_LOAD_OK"; decisions: DecisionItem[] }
| { type: "DECISIONS_LOAD_ERR"; error: string }
| { type: "DECISION_DRAWER_OPEN"; open: boolean }
| { type: "DECISION_CREATE"; caseId: string; kind: DecisionKind; reasonCode: string; note?: string; ttl?: string }
| { type: "DECISION_CREATE_OK"; decision: DecisionItem }
| { type: "DECISION_CREATE_ERR"; error: string }
| { type: "DECISION_REVOKE"; caseId: string; decisionId: string }
| { type: "DECISION_REVOKE_OK"; decisionId: string }
| { type: "DECISION_REVOKE_ERR"; error: string }
// snapshots + smart diff
| { type: "SNAPSHOTS_LOAD"; caseId: string }
| { type: "SNAPSHOTS_LOAD_OK"; snapshots: SnapshotItem[] }
| { type: "SNAPSHOTS_LOAD_ERR"; error: string }
| { type: "DIFF_OPEN"; open: boolean }
| { type: "DIFF_LOAD"; caseId: string; fromInputsHash: string; toInputsHash: string }
| { type: "DIFF_LOAD_OK"; diff: SmartDiff }
| { type: "DIFF_LOAD_ERR"; error: string }
// export bundle
| { type: "BUNDLE_EXPORT"; caseId: string }
| { type: "BUNDLE_EXPORT_OK"; downloadUrl: string }
| { type: "BUNDLE_EXPORT_ERR"; error: string };
```
## 5. Reducer Invariants
* Pure: no I/O in reducer.
* Any mutation of gating/visibility must originate from:
* `CASE_LOAD_OK` (new computed risk)
* `DECISION_CREATE_OK` / `DECISION_REVOKE_OK`
* Evidence is loaded lazily; header is loaded first.
* "Show muted" affects only table filtering, never deletes data.
## 6. Reducer Implementation (Reference)
```ts
export function reduce(state: TriageState, action: Action): { state: TriageState; cmd: Command } {
switch (action.type) {
case "ROUTE_TABLE":
return {
state: { ...state, route: { page: "TABLE" } },
cmd: { type: "NAVIGATE", route: { page: "TABLE" } }
};
case "ROUTE_CASE":
return {
state: {
...state,
route: { page: "CASE", caseId: action.caseId },
caseView: { ...state.caseView, loading: true, error: undefined }
},
cmd: {
type: "HTTP_GET",
url: `/api/triage/v1/cases/${encodeURIComponent(action.caseId)}`,
headers: state.caseView.etag ? { "If-None-Match": state.caseView.etag } : undefined,
onSuccess: { type: "CASE_LOAD_OK", header: undefined as any },
onError: { type: "CASE_LOAD_ERR", error: "" }
}
};
case "TABLE_LOAD":
return {
state: { ...state, table: { ...state.table, loading: true, error: undefined } },
cmd: {
type: "HTTP_GET",
url: `/api/triage/v1/findings?showMuted=${state.filters.showMuted}&page=${state.filters.page}&pageSize=${state.filters.pageSize}`
+ (state.filters.lane ? `&lane=${state.filters.lane}` : "")
+ (state.filters.search ? `&search=${encodeURIComponent(state.filters.search)}` : ""),
headers: state.table.etag ? { "If-None-Match": state.table.etag } : undefined,
onSuccess: { type: "TABLE_LOAD_OK", rows: [], mutedCounts: { reach: 0, vex: 0, compensated: 0 } },
onError: { type: "TABLE_LOAD_ERR", error: "" }
}
};
case "TABLE_LOAD_OK":
return {
state: { ...state, table: { ...state.table, loading: false, rows: action.rows, mutedCounts: action.mutedCounts, etag: action.etag } },
cmd: { type: "NONE" }
};
case "TABLE_LOAD_ERR":
return {
state: { ...state, table: { ...state.table, loading: false, error: action.error } },
cmd: { type: "NONE" }
};
case "CASE_LOAD_OK": {
const header = action.header;
return {
state: {
...state,
caseView: {
...state.caseView,
loading: false,
header,
etag: action.etag,
evidenceLoading: true,
decisionsLoading: true,
snapshotsLoading: true
}
},
cmd: {
type: "HTTP_GET",
url: `/api/triage/v1/cases/${encodeURIComponent(header.id)}/evidence`,
onSuccess: { type: "EVIDENCE_LOAD_OK", evidence: [] },
onError: { type: "EVIDENCE_LOAD_ERR", error: "" }
}
};
}
case "EVIDENCE_LOAD_OK":
return {
state: { ...state, caseView: { ...state.caseView, evidenceLoading: false, evidence: action.evidence } },
cmd: { type: "NONE" }
};
case "DECISION_DRAWER_OPEN":
return { state: { ...state, ui: { ...state.ui, decisionDrawerOpen: action.open } }, cmd: { type: "NONE" } };
case "DECISION_CREATE":
return {
state: state,
cmd: {
type: "HTTP_POST",
url: `/api/triage/v1/decisions`,
body: { caseId: action.caseId, kind: action.kind, reasonCode: action.reasonCode, note: action.note, ttl: action.ttl },
onSuccess: { type: "DECISION_CREATE_OK", decision: undefined as any },
onError: { type: "DECISION_CREATE_ERR", error: "" }
}
};
case "DECISION_CREATE_OK":
return {
state: {
...state,
ui: { ...state.ui, decisionDrawerOpen: false, toast: { kind: "success", message: "Decision applied. Undo available in History." } }
},
// after decision, refresh header + snapshots (re-compute may occur server-side)
cmd: { type: "HTTP_GET", url: `/api/triage/v1/cases/${encodeURIComponent(state.route.caseId!)}`, onSuccess: { type: "CASE_LOAD_OK", header: undefined as any }, onError: { type: "CASE_LOAD_ERR", error: "" } }
};
case "BUNDLE_EXPORT":
return {
state,
cmd: {
type: "HTTP_POST",
url: `/api/triage/v1/cases/${encodeURIComponent(action.caseId)}/export`,
body: {},
onSuccess: { type: "BUNDLE_EXPORT_OK", downloadUrl: "" },
onError: { type: "BUNDLE_EXPORT_ERR", error: "" }
}
};
case "BUNDLE_EXPORT_OK":
return {
state: { ...state, ui: { ...state.ui, toast: { kind: "success", message: "Evidence bundle ready." } } },
cmd: { type: "DOWNLOAD", url: action.downloadUrl }
};
default:
return { state, cmd: { type: "NONE" } };
}
}
```
## 7. Unit Testing Requirements
Minimum tests:
* Reducer purity: no global mutation.
* TABLE_LOAD produces correct URL for filters.
* ROUTE_CASE triggers case header load.
* CASE_LOAD_OK triggers EVIDENCE load (and separately decisions/snapshots in your integration layer).
* DECISION_CREATE_OK closes drawer and refreshes case header.
* BUNDLE_EXPORT_OK emits DOWNLOAD.
Recommended: golden-state snapshots to ensure backwards compatibility when the state model evolves.
---
**Document Version**: 1.0
**Target Platform**: Angular v17 + TypeScript

236
docs/ux/TRIAGE_UX_GUIDE.md Normal file
View File

@@ -0,0 +1,236 @@
# Stella Ops Triage UX Guide (Narrative-First + Proof-Linked)
## 0. Scope
This guide specifies the user experience for Stella Ops triage and evidence workflows:
- Narrative-first case view that answers DevOps' three questions quickly.
- Proof-linked evidence surfaces (SBOM/VEX/provenance/reachability/replay).
- Quiet-by-default noise controls with reversible, signed decisions.
- Smart-Diff history that explains meaningful risk changes.
Architecture constraints:
- Lattice/risk evaluation executes in `scanner.webservice`.
- `concelier` and `excititor` must **preserve prune source** (every merged/pruned datum remains traceable to origin).
## 1. UX Contract
Every triage surface must answer, in order:
1) Can I ship this?
2) If not, what exactly blocks me?
3) What's the minimum safe change to unblock?
Everything else is secondary and should be progressively disclosed.
## 2. Primary Objects in the UX
- Finding/Case: a specific vuln/rule tied to an asset (image/artifact/environment).
- Risk Result: deterministic lattice output (score/verdict/lane), computed by `scanner.webservice`.
- Evidence Artifact: signed, hash-addressed proof objects (SBOM slice, VEX doc, provenance, reachability slice, replay manifest).
- Decision: reversible user/system action that changes visibility/gating (mute/ack/exception) and is always signed/auditable.
- Snapshot: immutable record of inputs/outputs hashes enabling Smart-Diff.
## 3. Global UX Principles
### 3.1 Narrative-first, list-second
Default view is a "Case" narrative header + evidence rail. Lists exist for scanning and sorting, but not as the primary cognitive surface.
### 3.2 Time-to-evidence (TTFS) target
From pipeline alert click → human-readable verdict + first evidence link:
- p95 ≤ 30 seconds (including auth and initial fetch).
- "Evidence" is always one click away (no deep tab chains).
### 3.3 Proof-linking is mandatory
Any chip/badge that asserts a fact must link to the exact evidence object(s) that justify it.
Examples:
- "Reachable: Yes" → call-stack slice (and/or runtime hit record)
- "VEX: not_affected" → effective VEX assertion + signature details
- "Blocked by Policy Gate X" → policy artifact + lattice explanation
### 3.4 Quiet by default, never silent
Muted lanes are hidden by default but surfaced with counts and a toggle.
Muting never deletes; it creates a signed Decision with TTL/reason and is reversible.
### 3.5 Deterministic and replayable
Users must be able to export an evidence bundle containing:
- scan replay manifest (feeds/rules/policies/hashes)
- signed artifacts
- outputs (risk result, snapshots)
so auditors can replay identically.
## 4. Information Architecture
### 4.1 Screens
1) Findings Table (global)
- Purpose: scan, sort, filter, jump into cases
- Default: muted lanes hidden
- Banner: shows count of auto-muted by policy with "Show" toggle
2) Case View (single-page narrative)
- Purpose: decision making + proof review
- Above fold: verdict + chips + deterministic score
- Right rail: evidence list
- Tabs (max 3):
- Evidence (default)
- Reachability & Impact
- History (Smart-Diff)
3) Export / Verify Bundle
- Purpose: offline/audit verification
- Async export job, then download DSSE-signed zip
- Verification UI: signature status, hash tree, issuer chain
### 4.2 Lanes (visibility buckets)
Lanes are a UX categorization derived from deterministic risk + decisions:
- ACTIVE
- BLOCKED
- NEEDS_EXCEPTION
- MUTED_REACH (non-reachable)
- MUTED_VEX (effective VEX says not_affected)
- COMPENSATED (controls satisfy policy)
Default: show ACTIVE/BLOCKED/NEEDS_EXCEPTION.
Muted lanes appear behind a toggle and via the banner counts.
## 5. Case View Layout (Required)
### 5.1 Top Bar
- Asset name / Image tag / Environment
- Last evaluated time
- Policy profile name (e.g., "Strict CI Gate")
### 5.2 Verdict Banner (Above fold)
Large, unambiguous verdict:
- SHIP
- BLOCKED
- NEEDS EXCEPTION
Below verdict:
- One-line "why" summary (max 140 chars), e.g.:
- "Reachable path observed; exploit signal present; Policy 'prod-strict' blocks."
### 5.3 Chips (Each chip is clickable)
Minimum set:
- Reachability: Reachable / Not reachable / Unknown (with confidence)
- Effective VEX: affected / not_affected / under_investigation
- Exploit signal: yes/no + source indicator
- Exposure: internet-exposed yes/no (if available)
- Asset tier: tier label
- Gate: allow/block/exception-needed (policy gate name)
Chip click behavior:
- Opens evidence panel anchored to the proof objects
- Shows source chain (concelier/excititor preserved sources)
### 5.4 Evidence Rail (Always visible right side)
List of evidence artifacts with:
- Type icon
- Title
- Issuer
- Signed/verified indicator
- Content hash (short)
- Created timestamp
Actions per item:
- Preview
- Copy hash
- Open raw
- "Show in bundle" marker
### 5.5 Actions Footer (Only primary actions)
- Create work item
- Acknowledge / Mute (opens Decision drawer)
- Propose exception (Decision with TTL + approver chain)
- Export evidence bundle
No more than 4 primary buttons. Secondary actions go into kebab menu.
## 6. Decision Flows (Mute/Ack/Exception)
### 6.1 Decision Drawer (common UI)
Fields:
- Decision kind: Mute reach / Mute VEX / Acknowledge / Exception
- Reason code (dropdown) + free-text note
- TTL (required for exceptions; optional for mutes)
- Policy ref (auto-filled; editable only by admins)
- "Sign and apply" (server-side DSSE signing; user identity included)
On submit:
- Create Decision (signed)
- Re-evaluate lane/verdict if applicable
- Create Snapshot ("DECISION" trigger)
- Show toast with undo link
### 6.2 Undo
Undo is implemented as "revoke decision" (signed revoke record or revocation fields).
Never delete.
## 7. Smart-Diff UX
### 7.1 Timeline
Chronological snapshots:
- when (timestamp)
- trigger (feed/vex/sbom/policy/runtime/decision/rescan)
- summary (short)
### 7.2 Diff panel
Two-column diff:
- Inputs changed (with proof links): VEX assertion changed, policy version changed, runtime trace arrived, etc.
- Outputs changed: lane, verdict, score, gates
### 7.3 Meaningful change definition
The UI only highlights "meaningful" changes:
- verdict change
- lane change
- score crosses a policy threshold
- reachability state changes
- effective VEX status changes
Other changes remain in "details" expandable.
## 8. Performance & UI Engineering Requirements
- Findings table uses virtual scroll and server-side pagination.
- Case view loads in 2 steps:
1) Header narrative (small payload)
2) Evidence list + snapshots (lazy)
- Evidence previews are lazy-loaded and cancellable.
- Use ETag/If-None-Match for case and evidence list endpoints.
- UI must remain usable under high latency (air-gapped / offline kits):
- show cached last-known verdict with clear "stale" marker
- allow exporting bundles from cached artifacts when permissible
## 9. Accessibility & Operator Usability
- Keyboard navigation: table rows, chips, evidence list
- High contrast mode supported
- All status is conveyed by text + shape (not color only)
- Copy-to-clipboard for hashes, purls, CVE IDs
## 10. Telemetry (Must instrument)
- TTFS: notification click → verdict banner rendered
- Time-to-proof: click chip → proof preview shown
- Mute reversal rate (auto-muted later becomes actionable)
- Bundle export success/latency
## 11. Responsibilities by Service
- `scanner.webservice`:
- produces reachability results, risk results, snapshots
- stores/serves case narrative header, evidence indexes, Smart-Diff
- `concelier`:
- aggregates vuln feeds and preserves per-source provenance ("preserve prune source")
- `excititor`:
- merges VEX and preserves original assertion sources ("preserve prune source")
- `notify.webservice`:
- emits first_signal / risk_changed / gate_blocked
- `scheduler.webservice`:
- re-evaluates existing images on feed/policy updates, triggers snapshots
---
**Document Version**: 1.0
**Target Platform**: .NET 10, PostgreSQL >= 16, Angular v17