SPRINT_3600_0001_0001 - Reachability Drift Detection Master Plan
This commit is contained in:
334
docs/api/triage.contract.v1.md
Normal file
334
docs/api/triage.contract.v1.md
Normal file
@@ -0,0 +1,334 @@
|
||||
# Stella Ops Triage API Contract v1
|
||||
|
||||
Base path: `/api/triage/v1`
|
||||
|
||||
This contract is served by `scanner.webservice` (or a dedicated triage facade that reads scanner-owned tables).
|
||||
All risk/lattice outputs originate from `scanner.webservice`.
|
||||
|
||||
Key requirements:
|
||||
- Deterministic outputs (policyId + policyVersion + inputsHash).
|
||||
- Proof-linking (chips reference evidenceIds).
|
||||
- `concelier` and `excititor` preserve prune source: API surfaces source chains via `sourceRefs`.
|
||||
|
||||
## 0. Conventions
|
||||
|
||||
### 0.1 Identifiers
|
||||
- `caseId` == `findingId` (UUID). A case is a finding scoped to an asset/environment.
|
||||
- Hashes are hex strings.
|
||||
|
||||
### 0.2 Caching
|
||||
- GET endpoints SHOULD return `ETag`.
|
||||
- Clients SHOULD send `If-None-Match`.
|
||||
|
||||
### 0.3 Errors
|
||||
Standard error envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "string",
|
||||
"message": "string",
|
||||
"details": { "any": "json" },
|
||||
"traceId": "string"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Common codes:
|
||||
|
||||
* `not_found`
|
||||
* `validation_error`
|
||||
* `conflict`
|
||||
* `unauthorized`
|
||||
* `forbidden`
|
||||
* `rate_limited`
|
||||
|
||||
## 1. Findings Table
|
||||
|
||||
### 1.1 List findings
|
||||
|
||||
`GET /findings`
|
||||
|
||||
Query params:
|
||||
|
||||
* `showMuted` (bool, default false)
|
||||
* `lane` (optional, enum)
|
||||
* `search` (optional string; searches asset, purl, cveId)
|
||||
* `page` (int, default 1)
|
||||
* `pageSize` (int, default 50; max 200)
|
||||
* `sort` (optional: `updatedAt`, `score`, `lane`)
|
||||
* `order` (optional: `asc|desc`)
|
||||
|
||||
Response 200:
|
||||
|
||||
```json
|
||||
{
|
||||
"page": 1,
|
||||
"pageSize": 50,
|
||||
"total": 12345,
|
||||
"mutedCounts": { "reach": 1904, "vex": 513, "compensated": 18 },
|
||||
"rows": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"lane": "BLOCKED",
|
||||
"verdict": "BLOCK",
|
||||
"score": 87,
|
||||
"reachable": "YES",
|
||||
"vex": "affected",
|
||||
"exploit": "YES",
|
||||
"asset": "prod/api-gateway:1.2.3",
|
||||
"updatedAt": "2025-12-16T01:02:03Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Case Narrative
|
||||
|
||||
### 2.1 Get case header
|
||||
|
||||
`GET /cases/{caseId}`
|
||||
|
||||
Response 200:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"verdict": "BLOCK",
|
||||
"lane": "BLOCKED",
|
||||
"score": 87,
|
||||
"policyId": "prod-strict",
|
||||
"policyVersion": "2025.12.14",
|
||||
"inputsHash": "hex",
|
||||
"why": "Reachable path observed; exploit signal present; prod-strict blocks.",
|
||||
"chips": [
|
||||
{ "key": "reachability", "label": "Reachability", "value": "Reachable (92%)", "evidenceIds": ["uuid"] },
|
||||
{ "key": "vex", "label": "VEX", "value": "affected", "evidenceIds": ["uuid"] },
|
||||
{ "key": "gate", "label": "Gate", "value": "BLOCKED by prod-strict", "evidenceIds": ["uuid"] }
|
||||
],
|
||||
"sourceRefs": [
|
||||
{
|
||||
"domain": "concelier",
|
||||
"kind": "cve_record",
|
||||
"ref": "concelier:osv:...",
|
||||
"pruned": false
|
||||
},
|
||||
{
|
||||
"domain": "excititor",
|
||||
"kind": "effective_vex",
|
||||
"ref": "excititor:openvex:...",
|
||||
"pruned": false
|
||||
}
|
||||
],
|
||||
"updatedAt": "2025-12-16T01:02:03Z"
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
* `sourceRefs` provides preserved provenance chains (including pruned markers when applicable).
|
||||
|
||||
## 3. Evidence
|
||||
|
||||
### 3.1 List evidence for case
|
||||
|
||||
`GET /cases/{caseId}/evidence`
|
||||
|
||||
Response 200:
|
||||
|
||||
```json
|
||||
{
|
||||
"caseId": "uuid",
|
||||
"items": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"type": "VEX_DOC",
|
||||
"title": "Vendor OpenVEX assertion",
|
||||
"issuer": "vendor.example",
|
||||
"signed": true,
|
||||
"signedBy": "CN=Vendor VEX Signer",
|
||||
"contentHash": "hex",
|
||||
"createdAt": "2025-12-15T22:10:00Z",
|
||||
"previewUrl": "/api/triage/v1/evidence/uuid/preview",
|
||||
"rawUrl": "/api/triage/v1/evidence/uuid/raw"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Get raw evidence object
|
||||
|
||||
`GET /evidence/{evidenceId}/raw`
|
||||
|
||||
Returns:
|
||||
|
||||
* `application/json` for JSON evidence
|
||||
* `application/octet-stream` for binary
|
||||
* MUST include `Content-SHA256` header (hex) when possible.
|
||||
|
||||
### 3.3 Preview evidence object
|
||||
|
||||
`GET /evidence/{evidenceId}/preview`
|
||||
|
||||
Returns a compact representation safe for UI preview.
|
||||
|
||||
## 4. Decisions
|
||||
|
||||
### 4.1 Create decision
|
||||
|
||||
`POST /decisions`
|
||||
|
||||
Request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"caseId": "uuid",
|
||||
"kind": "MUTE_REACH",
|
||||
"reasonCode": "NON_REACHABLE",
|
||||
"note": "No entry path in this env; reviewed runtime traces.",
|
||||
"ttl": "2026-01-16T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Response 201:
|
||||
|
||||
```json
|
||||
{
|
||||
"decision": {
|
||||
"id": "uuid",
|
||||
"kind": "MUTE_REACH",
|
||||
"reasonCode": "NON_REACHABLE",
|
||||
"note": "No entry path in this env; reviewed runtime traces.",
|
||||
"ttl": "2026-01-16T00:00:00Z",
|
||||
"actor": { "subject": "user:abc", "display": "Vlad" },
|
||||
"createdAt": "2025-12-16T01:10:00Z",
|
||||
"signatureRef": "dsse:rekor:uuid"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* Server signs decisions (DSSE) and persists signature reference.
|
||||
* Creating a decision MUST create a `Snapshot` with trigger `DECISION`.
|
||||
|
||||
### 4.2 Revoke decision
|
||||
|
||||
`POST /decisions/{decisionId}/revoke`
|
||||
|
||||
Body (optional):
|
||||
|
||||
```json
|
||||
{ "reason": "Mistake; reachability now observed." }
|
||||
```
|
||||
|
||||
Response 200:
|
||||
|
||||
```json
|
||||
{ "revokedAt": "2025-12-16T02:00:00Z", "signatureRef": "dsse:rekor:uuid" }
|
||||
```
|
||||
|
||||
## 5. Snapshots & Smart-Diff
|
||||
|
||||
### 5.1 List snapshots
|
||||
|
||||
`GET /cases/{caseId}/snapshots`
|
||||
|
||||
Response 200:
|
||||
|
||||
```json
|
||||
{
|
||||
"caseId": "uuid",
|
||||
"items": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"trigger": "POLICY_UPDATE",
|
||||
"changedAt": "2025-12-16T00:00:00Z",
|
||||
"fromInputsHash": "hex",
|
||||
"toInputsHash": "hex",
|
||||
"summary": "Policy version changed; gate threshold crossed."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Smart-Diff between two snapshots
|
||||
|
||||
`GET /cases/{caseId}/smart-diff?from={inputsHashA}&to={inputsHashB}`
|
||||
|
||||
Response 200:
|
||||
|
||||
```json
|
||||
{
|
||||
"fromInputsHash": "hex",
|
||||
"toInputsHash": "hex",
|
||||
"inputsChanged": [
|
||||
{ "key": "policyVersion", "before": "2025.12.14", "after": "2025.12.16", "evidenceIds": ["uuid"] }
|
||||
],
|
||||
"outputsChanged": [
|
||||
{ "key": "verdict", "before": "SHIP", "after": "BLOCK", "evidenceIds": ["uuid"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Export Evidence Bundle
|
||||
|
||||
### 6.1 Start export
|
||||
|
||||
`POST /cases/{caseId}/export`
|
||||
|
||||
Response 202:
|
||||
|
||||
```json
|
||||
{
|
||||
"exportId": "uuid",
|
||||
"status": "QUEUED"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Poll export
|
||||
|
||||
`GET /exports/{exportId}`
|
||||
|
||||
Response 200:
|
||||
|
||||
```json
|
||||
{
|
||||
"exportId": "uuid",
|
||||
"status": "READY",
|
||||
"downloadUrl": "/api/triage/v1/exports/uuid/download"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Download bundle
|
||||
|
||||
`GET /exports/{exportId}/download`
|
||||
|
||||
Returns:
|
||||
|
||||
* `application/zip`
|
||||
* DSSE envelope embedded (or alongside in zip)
|
||||
* bundle contains replay manifest, artifacts, risk result, snapshots
|
||||
|
||||
## 7. Events (Notify.WebService integration)
|
||||
|
||||
These are emitted by `notify.webservice` when scanner outputs change.
|
||||
|
||||
* `first_signal`
|
||||
* fired on first actionable detection for an asset/environment
|
||||
* `risk_changed`
|
||||
* fired when verdict/lane changes or thresholds crossed
|
||||
* `gate_blocked`
|
||||
* fired when CI gate blocks
|
||||
|
||||
Event payload includes:
|
||||
|
||||
* caseId
|
||||
* old/new verdict/lane/score (for changed events)
|
||||
* inputsHash
|
||||
* links to `/cases/{caseId}`
|
||||
|
||||
---
|
||||
|
||||
**Document Version**: 1.0
|
||||
**Target Platform**: .NET 10, PostgreSQL >= 16
|
||||
249
docs/db/triage_schema.sql
Normal file
249
docs/db/triage_schema.sql
Normal file
@@ -0,0 +1,249 @@
|
||||
-- Stella Ops Triage Schema (PostgreSQL)
|
||||
-- System of record: PostgreSQL
|
||||
-- Ephemeral acceleration: Valkey (not represented here)
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Extensions
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
-- Enums
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_lane') THEN
|
||||
CREATE TYPE triage_lane AS ENUM (
|
||||
'ACTIVE',
|
||||
'BLOCKED',
|
||||
'NEEDS_EXCEPTION',
|
||||
'MUTED_REACH',
|
||||
'MUTED_VEX',
|
||||
'COMPENSATED'
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_verdict') THEN
|
||||
CREATE TYPE triage_verdict AS ENUM ('SHIP', 'BLOCK', 'EXCEPTION');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_reachability') THEN
|
||||
CREATE TYPE triage_reachability AS ENUM ('YES', 'NO', 'UNKNOWN');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_vex_status') THEN
|
||||
CREATE TYPE triage_vex_status AS ENUM ('affected', 'not_affected', 'under_investigation', 'unknown');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_decision_kind') THEN
|
||||
CREATE TYPE triage_decision_kind AS ENUM ('MUTE_REACH', 'MUTE_VEX', 'ACK', 'EXCEPTION');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_snapshot_trigger') THEN
|
||||
CREATE TYPE triage_snapshot_trigger AS ENUM (
|
||||
'FEED_UPDATE',
|
||||
'VEX_UPDATE',
|
||||
'SBOM_UPDATE',
|
||||
'RUNTIME_TRACE',
|
||||
'POLICY_UPDATE',
|
||||
'DECISION',
|
||||
'RESCAN'
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'triage_evidence_type') THEN
|
||||
CREATE TYPE triage_evidence_type AS ENUM (
|
||||
'SBOM_SLICE',
|
||||
'VEX_DOC',
|
||||
'PROVENANCE',
|
||||
'CALLSTACK_SLICE',
|
||||
'REACHABILITY_PROOF',
|
||||
'REPLAY_MANIFEST',
|
||||
'POLICY',
|
||||
'SCAN_LOG',
|
||||
'OTHER'
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Core: finding (caseId == findingId)
|
||||
CREATE TABLE IF NOT EXISTS triage_finding (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
asset_id uuid NOT NULL,
|
||||
environment_id uuid NULL,
|
||||
asset_label text NOT NULL, -- e.g. "prod/api-gateway:1.2.3"
|
||||
purl text NOT NULL, -- package-url
|
||||
cve_id text NULL,
|
||||
rule_id text NULL,
|
||||
first_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (asset_id, environment_id, purl, cve_id, rule_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_finding_last_seen ON triage_finding (last_seen_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_finding_asset_label ON triage_finding (asset_label);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_finding_purl ON triage_finding (purl);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_finding_cve ON triage_finding (cve_id);
|
||||
|
||||
-- Effective VEX (post-merge), with preserved provenance pointers
|
||||
CREATE TABLE IF NOT EXISTS triage_effective_vex (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
status triage_vex_status NOT NULL,
|
||||
source_domain text NOT NULL, -- "excititor"
|
||||
source_ref text NOT NULL, -- stable ref string (preserve prune source)
|
||||
pruned_sources jsonb NULL, -- array of pruned items with reasons (optional)
|
||||
dsse_envelope_hash text NULL,
|
||||
signature_ref text NULL, -- rekor/ledger ref
|
||||
issuer text NULL,
|
||||
valid_from timestamptz NOT NULL DEFAULT now(),
|
||||
valid_to timestamptz NULL,
|
||||
collected_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_effective_vex_finding ON triage_effective_vex (finding_id, collected_at DESC);
|
||||
|
||||
-- Reachability results
|
||||
CREATE TABLE IF NOT EXISTS triage_reachability_result (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
reachable triage_reachability NOT NULL,
|
||||
confidence smallint NOT NULL CHECK (confidence >= 0 AND confidence <= 100),
|
||||
static_proof_ref text NULL, -- evidence ref (callgraph slice / CFG slice)
|
||||
runtime_proof_ref text NULL, -- evidence ref (runtime hits)
|
||||
inputs_hash text NOT NULL, -- hash of inputs used to compute reachability
|
||||
computed_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_reachability_finding ON triage_reachability_result (finding_id, computed_at DESC);
|
||||
|
||||
-- Risk/lattice result (scanner.webservice output)
|
||||
CREATE TABLE IF NOT EXISTS triage_risk_result (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
policy_id text NOT NULL,
|
||||
policy_version text NOT NULL,
|
||||
inputs_hash text NOT NULL,
|
||||
score int NOT NULL CHECK (score >= 0 AND score <= 100),
|
||||
verdict triage_verdict NOT NULL,
|
||||
lane triage_lane NOT NULL,
|
||||
why text NOT NULL, -- short narrative
|
||||
explanation jsonb NULL, -- structured lattice explanation for UI diffing
|
||||
computed_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (finding_id, policy_id, policy_version, inputs_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_risk_finding ON triage_risk_result (finding_id, computed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_risk_lane ON triage_risk_result (lane, computed_at DESC);
|
||||
|
||||
-- Signed Decisions (mute/ack/exception), reversible by revoke
|
||||
CREATE TABLE IF NOT EXISTS triage_decision (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
kind triage_decision_kind NOT NULL,
|
||||
reason_code text NOT NULL,
|
||||
note text NULL,
|
||||
policy_ref text NULL, -- optional: policy that allowed decision
|
||||
ttl timestamptz NULL,
|
||||
actor_subject text NOT NULL, -- Authority subject (sub)
|
||||
actor_display text NULL,
|
||||
signature_ref text NULL, -- DSSE signature reference
|
||||
dsse_hash text NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
revoked_at timestamptz NULL,
|
||||
revoke_reason text NULL,
|
||||
revoke_signature_ref text NULL,
|
||||
revoke_dsse_hash text NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_decision_finding ON triage_decision (finding_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_decision_kind ON triage_decision (kind, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_decision_active ON triage_decision (finding_id) WHERE revoked_at IS NULL;
|
||||
|
||||
-- Evidence artifacts (hash-addressed, signed)
|
||||
CREATE TABLE IF NOT EXISTS triage_evidence_artifact (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
type triage_evidence_type NOT NULL,
|
||||
title text NOT NULL,
|
||||
issuer text NULL,
|
||||
signed boolean NOT NULL DEFAULT false,
|
||||
signed_by text NULL,
|
||||
content_hash text NOT NULL,
|
||||
signature_ref text NULL,
|
||||
media_type text NULL,
|
||||
uri text NOT NULL, -- object store / file path / inline ref
|
||||
size_bytes bigint NULL,
|
||||
metadata jsonb NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (finding_id, type, content_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_evidence_finding ON triage_evidence_artifact (finding_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_evidence_type ON triage_evidence_artifact (type, created_at DESC);
|
||||
|
||||
-- Snapshots for Smart-Diff (immutable records of input/output changes)
|
||||
CREATE TABLE IF NOT EXISTS triage_snapshot (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
finding_id uuid NOT NULL REFERENCES triage_finding(id) ON DELETE CASCADE,
|
||||
trigger triage_snapshot_trigger NOT NULL,
|
||||
from_inputs_hash text NULL,
|
||||
to_inputs_hash text NOT NULL,
|
||||
summary text NOT NULL,
|
||||
diff_json jsonb NULL, -- optional: precomputed diff
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (finding_id, to_inputs_hash, created_at)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_snapshot_finding ON triage_snapshot (finding_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS ix_triage_snapshot_trigger ON triage_snapshot (trigger, created_at DESC);
|
||||
|
||||
-- Current-case view: latest risk + latest reachability + latest effective VEX
|
||||
CREATE OR REPLACE VIEW v_triage_case_current AS
|
||||
WITH latest_risk AS (
|
||||
SELECT DISTINCT ON (finding_id)
|
||||
finding_id, policy_id, policy_version, inputs_hash, score, verdict, lane, why, computed_at
|
||||
FROM triage_risk_result
|
||||
ORDER BY finding_id, computed_at DESC
|
||||
),
|
||||
latest_reach AS (
|
||||
SELECT DISTINCT ON (finding_id)
|
||||
finding_id, reachable, confidence, static_proof_ref, runtime_proof_ref, computed_at
|
||||
FROM triage_reachability_result
|
||||
ORDER BY finding_id, computed_at DESC
|
||||
),
|
||||
latest_vex AS (
|
||||
SELECT DISTINCT ON (finding_id)
|
||||
finding_id, status, issuer, signature_ref, source_domain, source_ref, collected_at
|
||||
FROM triage_effective_vex
|
||||
ORDER BY finding_id, collected_at DESC
|
||||
)
|
||||
SELECT
|
||||
f.id AS case_id,
|
||||
f.asset_id,
|
||||
f.environment_id,
|
||||
f.asset_label,
|
||||
f.purl,
|
||||
f.cve_id,
|
||||
f.rule_id,
|
||||
f.first_seen_at,
|
||||
f.last_seen_at,
|
||||
r.policy_id,
|
||||
r.policy_version,
|
||||
r.inputs_hash,
|
||||
r.score,
|
||||
r.verdict,
|
||||
r.lane,
|
||||
r.why,
|
||||
r.computed_at AS risk_computed_at,
|
||||
coalesce(re.reachable, 'UNKNOWN'::triage_reachability) AS reachable,
|
||||
re.confidence AS reach_confidence,
|
||||
v.status AS vex_status,
|
||||
v.issuer AS vex_issuer,
|
||||
v.signature_ref AS vex_signature_ref,
|
||||
v.source_domain AS vex_source_domain,
|
||||
v.source_ref AS vex_source_ref
|
||||
FROM triage_finding f
|
||||
LEFT JOIN latest_risk r ON r.finding_id = f.id
|
||||
LEFT JOIN latest_reach re ON re.finding_id = f.id
|
||||
LEFT JOIN latest_vex v ON v.finding_id = f.id;
|
||||
|
||||
COMMIT;
|
||||
663
docs/dev/performance-testing-playbook.md
Normal file
663
docs/dev/performance-testing-playbook.md
Normal file
@@ -0,0 +1,663 @@
|
||||
# Performance Testing Pipeline for Queue-Based Workflows
|
||||
|
||||
> **Note**: This document was originally created as part of advisory analysis. It provides a comprehensive playbook for HTTP → Valkey → Worker performance testing.
|
||||
|
||||
---
|
||||
|
||||
## What we're measuring (plain English)
|
||||
|
||||
* **TTFB/TTFS (HTTP):** time the gateway spends accepting the request + queuing the job.
|
||||
* **Valkey latency:** enqueue (`LPUSH`/`XADD`), pop/claim (`BRPOP`/`XREADGROUP`), and round-trip.
|
||||
* **Worker service time:** time to pick up, process, and ack.
|
||||
* **Queueing delay:** time spent waiting in the queue (arrival → start of worker).
|
||||
|
||||
These four add up to the "hop latency" users feel when the system is under load.
|
||||
|
||||
---
|
||||
|
||||
## Minimal tracing you can add today
|
||||
|
||||
Emit these IDs/headers end-to-end:
|
||||
|
||||
* `x-stella-corr-id` (uuid)
|
||||
* `x-stella-enq-ts` (gateway enqueue ts, ns)
|
||||
* `x-stella-claim-ts` (worker claim ts, ns)
|
||||
* `x-stella-done-ts` (worker done ts, ns)
|
||||
|
||||
From these, compute:
|
||||
|
||||
* `queue_delay = claim_ts - enq_ts`
|
||||
* `service_time = done_ts - claim_ts`
|
||||
* `http_ttfs = gateway_first_byte_ts - http_request_start_ts`
|
||||
* `hop_latency = done_ts - enq_ts` (or return-path if synchronous)
|
||||
|
||||
Clock-sync tip: use monotonic clocks in code and convert to ns; don't mix wall-clock.
|
||||
|
||||
---
|
||||
|
||||
## Valkey commands (safe, BSD Valkey)
|
||||
|
||||
Use **Valkey Streams + Consumer Groups** for fairness and metrics:
|
||||
|
||||
* Enqueue: `XADD jobs * corr-id <uuid> enq-ts <ns> payload <...>`
|
||||
* Claim: `XREADGROUP GROUP workers w1 COUNT 1 BLOCK 1000 STREAMS jobs >`
|
||||
* Ack: `XACK jobs workers <id>`
|
||||
|
||||
Add a small Lua for timestamping at enqueue (atomic):
|
||||
|
||||
```lua
|
||||
-- KEYS[1]=stream
|
||||
-- ARGV[1]=enq_ts_ns, ARGV[2]=corr_id, ARGV[3]=payload
|
||||
return redis.call('XADD', KEYS[1], '*',
|
||||
'corr', ARGV[2], 'enq', ARGV[1], 'p', ARGV[3])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Load shapes to test (find the envelope)
|
||||
|
||||
1. **Open-loop (arrival-rate controlled):** 50 → 10k req/min in steps; constant rate per step. Reveals queueing onset.
|
||||
2. **Burst:** 0 → N in short spikes (e.g., 5k in 10s) to see saturation and drain time.
|
||||
3. **Step-up/down:** double every 2 min until SLO breach; then halve down.
|
||||
4. **Long tail soak:** run at 70–80% of max for 1h; watch p95-p99.9 drift.
|
||||
|
||||
Target outputs per step: **p50/p90/p95/p99** for `queue_delay`, `service_time`, `hop_latency`, plus **throughput** and **error rate**.
|
||||
|
||||
---
|
||||
|
||||
## k6 script (HTTP client pressure)
|
||||
|
||||
```javascript
|
||||
// save as hop-test.js
|
||||
import http from 'k6/http';
|
||||
import { check, sleep } from 'k6';
|
||||
|
||||
export let options = {
|
||||
scenarios: {
|
||||
step_load: {
|
||||
executor: 'ramping-arrival-rate',
|
||||
startRate: 20, timeUnit: '1s',
|
||||
preAllocatedVUs: 200, maxVUs: 5000,
|
||||
stages: [
|
||||
{ target: 50, duration: '1m' },
|
||||
{ target: 100, duration: '1m' },
|
||||
{ target: 200, duration: '1m' },
|
||||
{ target: 400, duration: '1m' },
|
||||
{ target: 800, duration: '1m' },
|
||||
],
|
||||
},
|
||||
},
|
||||
thresholds: {
|
||||
'http_req_failed': ['rate<0.01'],
|
||||
'http_req_duration{phase:hop}': ['p(95)<500'],
|
||||
},
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const corr = crypto.randomUUID();
|
||||
const res = http.post(
|
||||
__ENV.GW_URL,
|
||||
JSON.stringify({ data: 'ping', corr }),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json', 'x-stella-corr-id': corr },
|
||||
tags: { phase: 'hop' },
|
||||
}
|
||||
);
|
||||
check(res, { 'status 2xx/202': r => r.status === 200 || r.status === 202 });
|
||||
sleep(0.01);
|
||||
}
|
||||
```
|
||||
|
||||
Run: `GW_URL=https://gateway.example/hop k6 run hop-test.js`
|
||||
|
||||
---
|
||||
|
||||
## Worker hooks (.NET 10 sketch)
|
||||
|
||||
```csharp
|
||||
// At claim
|
||||
var now = Stopwatch.GetTimestamp(); // monotonic
|
||||
var claimNs = now.ToNanoseconds();
|
||||
log.AddTag("x-stella-claim-ts", claimNs);
|
||||
|
||||
// After processing
|
||||
var doneNs = Stopwatch.GetTimestamp().ToNanoseconds();
|
||||
log.AddTag("x-stella-done-ts", doneNs);
|
||||
// Include corr-id and stream entry id in logs/metrics
|
||||
```
|
||||
|
||||
Helper:
|
||||
|
||||
```csharp
|
||||
public static class MonoTime {
|
||||
static readonly double _nsPerTick = 1_000_000_000d / Stopwatch.Frequency;
|
||||
public static long ToNanoseconds(this long ticks) => (long)(ticks * _nsPerTick);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prometheus metrics to expose
|
||||
|
||||
* `valkey_enqueue_ns` (histogram)
|
||||
* `valkey_claim_block_ms` (gauge)
|
||||
* `worker_service_ns` (histogram, labels: worker_type, route)
|
||||
* `queue_depth` (gauge via `XLEN` or `XINFO STREAM`)
|
||||
* `enqueue_rate`, `dequeue_rate` (counters)
|
||||
|
||||
Example recording rules:
|
||||
|
||||
```yaml
|
||||
- record: hop:queue_delay_p95
|
||||
expr: histogram_quantile(0.95, sum(rate(valkey_enqueue_ns_bucket[1m])) by (le))
|
||||
- record: hop:service_time_p95
|
||||
expr: histogram_quantile(0.95, sum(rate(worker_service_ns_bucket[1m])) by (le))
|
||||
- record: hop:latency_budget_p95
|
||||
expr: hop:queue_delay_p95 + hop:service_time_p95
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Autoscaling signals (HPA/KEDA friendly)
|
||||
|
||||
* **Primary:** queue depth & its derivative (d/dt).
|
||||
* **Secondary:** p95 `queue_delay` and worker CPU.
|
||||
* **Safety:** max in-flight per worker; backpressure HTTP 429 when `queue_depth > D` or `p95_queue_delay > SLO*0.8`.
|
||||
|
||||
---
|
||||
|
||||
## Plot the "envelope" (what you'll look at)
|
||||
|
||||
* X-axis: **offered load** (req/s).
|
||||
* Y-axis: **p95 hop latency** (ms).
|
||||
* Overlay: p99 (dashed), **SLO line** (e.g., 500 ms), and **capacity knee** (where p95 sharply rises).
|
||||
* Add secondary panel: **queue depth** vs load.
|
||||
|
||||
---
|
||||
|
||||
# Performance Test Guidelines
|
||||
|
||||
## HTTP → Valkey → Worker pipeline
|
||||
|
||||
## 1) Objectives and scope
|
||||
|
||||
### Primary objectives
|
||||
|
||||
Your performance tests MUST answer these questions with evidence:
|
||||
|
||||
1. **Capacity knee**: At what offered load does **queue delay** start growing sharply?
|
||||
2. **User-impact envelope**: What are p50/p95/p99 **hop latency** curves vs offered load?
|
||||
3. **Decomposition**: How much of hop latency is:
|
||||
* gateway enqueue time
|
||||
* Valkey enqueue/claim RTT
|
||||
* queue wait time
|
||||
* worker service time
|
||||
4. **Scaling behavior**: How do these change with worker replica counts (N workers)?
|
||||
5. **Stability**: Under sustained load, do latencies drift (GC, memory, fragmentation, background jobs)?
|
||||
|
||||
### Non-goals (explicitly out of scope unless you add them later)
|
||||
|
||||
* Micro-optimizing single function runtime
|
||||
* Synthetic "max QPS" records without a representative payload
|
||||
* Tests that don't collect segment metrics (end-to-end only) for anything beyond basic smoke
|
||||
|
||||
---
|
||||
|
||||
## 2) Definitions and required metrics
|
||||
|
||||
### Required latency definitions (standardize these names)
|
||||
|
||||
Agents MUST compute and report these per request/job:
|
||||
|
||||
* **`t_http_accept`**: time from client send → gateway accepts request
|
||||
* **`t_enqueue`**: time spent in gateway to enqueue into Valkey (server-side)
|
||||
* **`t_valkey_rtt_enq`**: client-observed RTT for enqueue command(s)
|
||||
* **`t_queue_delay`**: `claim_ts - enq_ts`
|
||||
* **`t_service`**: `done_ts - claim_ts`
|
||||
* **`t_hop`**: `done_ts - enq_ts` (this is the "true pipeline hop" latency)
|
||||
* Optional but recommended:
|
||||
* **`t_ack`**: time to ack completion (Valkey ack RTT)
|
||||
* **`t_http_response`**: request start → gateway response sent (TTFB/TTFS)
|
||||
|
||||
### Required percentiles and aggregations
|
||||
|
||||
Per scenario step (e.g., each offered load plateau), agents MUST output:
|
||||
|
||||
* p50 / p90 / p95 / p99 / p99.9 for: `t_hop`, `t_queue_delay`, `t_service`, `t_enqueue`
|
||||
* Throughput: offered rps and achieved rps
|
||||
* Error rate: HTTP failures, enqueue failures, worker failures
|
||||
* Queue depth and backlog drain time
|
||||
|
||||
### Required system-level telemetry (minimum)
|
||||
|
||||
Agents MUST collect these time series during tests:
|
||||
|
||||
* **Worker**: CPU, memory, GC pauses (if .NET), threadpool saturation indicators
|
||||
* **Valkey**: ops/sec, connected clients, blocked clients, memory used, evictions, slowlog count
|
||||
* **Gateway**: CPU/mem, request rate, response codes, request duration histogram
|
||||
|
||||
---
|
||||
|
||||
## 3) Environment and test hygiene requirements
|
||||
|
||||
### Environment requirements
|
||||
|
||||
Agents SHOULD run tests in an environment that matches production in:
|
||||
|
||||
* container CPU/memory limits
|
||||
* number of nodes, network topology
|
||||
* Valkey topology (single, cluster, sentinel, etc.)
|
||||
* worker replica autoscaling rules (or deliberately disabled)
|
||||
|
||||
If exact parity isn't possible, agents MUST record all known differences in the report.
|
||||
|
||||
### Test hygiene (non-negotiable)
|
||||
|
||||
Agents MUST:
|
||||
|
||||
1. **Start from empty queues** (no backlog).
|
||||
2. **Disable client retries** (or explicitly run two variants: retries off / retries on).
|
||||
3. **Warm up** before measuring (e.g., 60s warm-up minimum).
|
||||
4. **Hold steady plateaus** long enough to stabilize (usually 2–5 minutes per step).
|
||||
5. **Cool down** and verify backlog drains (queue depth returns to baseline).
|
||||
6. Record exact versions/SHAs of gateway/worker and Valkey config.
|
||||
|
||||
### Load generator hygiene
|
||||
|
||||
Agents MUST ensure the load generator is not the bottleneck:
|
||||
|
||||
* CPU < ~70% during test
|
||||
* no local socket exhaustion
|
||||
* enough VUs/connections
|
||||
* if needed, distributed load generation
|
||||
|
||||
---
|
||||
|
||||
## 4) Instrumentation spec (agents implement this first)
|
||||
|
||||
### Correlation and timestamps
|
||||
|
||||
Agents MUST propagate an end-to-end correlation ID and timestamps.
|
||||
|
||||
**Required fields**
|
||||
|
||||
* `corr_id` (UUID)
|
||||
* `enq_ts_ns` (set at enqueue, monotonic or consistent clock)
|
||||
* `claim_ts_ns` (set by worker when job is claimed)
|
||||
* `done_ts_ns` (set by worker when job processing ends)
|
||||
|
||||
**Where these live**
|
||||
|
||||
* HTTP request header: `x-corr-id: <uuid>`
|
||||
* Valkey job payload fields: `corr`, `enq`, and optionally payload size/type
|
||||
* Worker logs/metrics: include `corr_id`, job id, `claim_ts_ns`, `done_ts_ns`
|
||||
|
||||
### Clock requirements
|
||||
|
||||
Agents MUST use a consistent timing source:
|
||||
|
||||
* Prefer monotonic timers for durations (Stopwatch / monotonic clock)
|
||||
* If timestamps cross machines, ensure they're comparable:
|
||||
* either rely on synchronized clocks (NTP) **and** monitor drift
|
||||
* or compute durations using monotonic tick deltas within the same host and transmit durations (less ideal for queue delay)
|
||||
|
||||
**Practical recommendation**: use wall-clock ns for cross-host timestamps with NTP + drift checks, and also record per-host monotonic durations for sanity.
|
||||
|
||||
### Valkey queue semantics (recommended)
|
||||
|
||||
Agents SHOULD use **Streams + Consumer Groups** for stable claim semantics and good observability:
|
||||
|
||||
* Enqueue: `XADD jobs * corr <uuid> enq <ns> payload <...>`
|
||||
* Claim: `XREADGROUP GROUP workers <consumer> COUNT 1 BLOCK 1000 STREAMS jobs >`
|
||||
* Ack: `XACK jobs workers <id>`
|
||||
|
||||
Agents MUST record stream length (`XLEN`) or consumer group lag (`XINFO GROUPS`) as queue depth/lag.
|
||||
|
||||
### Metrics exposure
|
||||
|
||||
Agents MUST publish Prometheus (or equivalent) histograms:
|
||||
|
||||
* `gateway_enqueue_seconds` (or ns) histogram
|
||||
* `valkey_enqueue_rtt_seconds` histogram
|
||||
* `worker_service_seconds` histogram
|
||||
* `queue_delay_seconds` histogram (derived from timestamps; can be computed in worker or offline)
|
||||
* `hop_latency_seconds` histogram
|
||||
|
||||
---
|
||||
|
||||
## 5) Workload modeling and test data
|
||||
|
||||
Agents MUST define a workload model before running capacity tests:
|
||||
|
||||
1. **Endpoint(s)**: list exact gateway routes under test
|
||||
2. **Payload types**: small/typical/large
|
||||
3. **Mix**: e.g., 70/25/5 by payload size
|
||||
4. **Idempotency rules**: ensure repeated jobs don't corrupt state
|
||||
5. **Data reset strategy**: how test data is cleaned or isolated per run
|
||||
|
||||
Agents SHOULD test at least:
|
||||
|
||||
* Typical payload (p50)
|
||||
* Large payload (p95)
|
||||
* Worst-case allowed payload (bounded by your API limits)
|
||||
|
||||
---
|
||||
|
||||
## 6) Scenario suite your agents MUST implement
|
||||
|
||||
Each scenario MUST be defined as code/config (not manual).
|
||||
|
||||
### Scenario A — Smoke (fast sanity)
|
||||
|
||||
**Goal**: verify instrumentation + basic correctness
|
||||
**Load**: low (e.g., 1–5 rps), 2 minutes
|
||||
**Pass**:
|
||||
|
||||
* 0 backlog after run
|
||||
* error rate < 0.1%
|
||||
* metrics present for all segments
|
||||
|
||||
### Scenario B — Baseline (repeatable reference point)
|
||||
|
||||
**Goal**: establish a stable baseline for regression tracking
|
||||
**Load**: fixed moderate load (e.g., 30–50% of expected capacity), 10 minutes
|
||||
**Pass**:
|
||||
|
||||
* p95 `t_hop` within baseline ± tolerance (set after first runs)
|
||||
* no upward drift in p95 across time (trend line ~flat)
|
||||
|
||||
### Scenario C — Capacity ramp (open-loop)
|
||||
|
||||
**Goal**: find the knee where queueing begins
|
||||
**Method**: open-loop arrival-rate ramp with plateaus
|
||||
Example stages (edit to fit your system):
|
||||
|
||||
* 50 rps for 2m
|
||||
* 100 rps for 2m
|
||||
* 200 rps for 2m
|
||||
* 400 rps for 2m
|
||||
* … until SLO breach or errors spike
|
||||
|
||||
**MUST**:
|
||||
|
||||
* warm-up stage before first plateau
|
||||
* record per-plateau summary
|
||||
|
||||
**Stop conditions** (any triggers stop):
|
||||
|
||||
* error rate > 1%
|
||||
* queue depth grows without bound over an entire plateau
|
||||
* p95 `t_hop` exceeds SLO for 2 consecutive plateaus
|
||||
|
||||
### Scenario D — Stress (push past capacity)
|
||||
|
||||
**Goal**: characterize failure mode and recovery
|
||||
**Load**: 120–200% of knee load, 5–10 minutes
|
||||
**Pass** (for resilience):
|
||||
|
||||
* system does not crash permanently
|
||||
* once load stops, backlog drains within target time (define it)
|
||||
|
||||
### Scenario E — Burst / spike
|
||||
|
||||
**Goal**: see how quickly queue grows and drains
|
||||
**Load shape**:
|
||||
|
||||
* baseline low load
|
||||
* sudden burst (e.g., 10× for 10–30s)
|
||||
* return to baseline
|
||||
|
||||
**Report**:
|
||||
|
||||
* peak queue depth
|
||||
* time to drain to baseline
|
||||
* p99 `t_hop` during burst
|
||||
|
||||
### Scenario F — Soak (long-running)
|
||||
|
||||
**Goal**: detect drift (leaks, fragmentation, GC patterns)
|
||||
**Load**: 70–85% of knee, 60–180 minutes
|
||||
**Pass**:
|
||||
|
||||
* p95 does not trend upward beyond threshold
|
||||
* memory remains bounded
|
||||
* no rising error rate
|
||||
|
||||
### Scenario G — Scaling curve (worker replica sweep)
|
||||
|
||||
**Goal**: turn results into scaling rules
|
||||
**Method**:
|
||||
|
||||
* Repeat Scenario C with worker replicas = 1, 2, 4, 8…
|
||||
|
||||
**Deliverable**:
|
||||
|
||||
* plot of knee load vs worker count
|
||||
* p95 `t_service` vs worker count (should remain similar; queue delay should drop)
|
||||
|
||||
---
|
||||
|
||||
## 7) Execution protocol (runbook)
|
||||
|
||||
Agents MUST run every scenario using the same disciplined flow:
|
||||
|
||||
### Pre-run checklist
|
||||
|
||||
* confirm system versions/SHAs
|
||||
* confirm autoscaling mode:
|
||||
* **Off** for baseline capacity characterization
|
||||
* **On** for validating autoscaling policies
|
||||
* clear queues and consumer group pending entries
|
||||
* restart or at least record "time since deploy" for services (cold vs warm)
|
||||
|
||||
### During run
|
||||
|
||||
* ensure load is truly open-loop when required (arrival-rate based)
|
||||
* continuously record:
|
||||
* offered vs achieved rate
|
||||
* queue depth
|
||||
* CPU/mem for gateway/worker/Valkey
|
||||
|
||||
### Post-run
|
||||
|
||||
* stop load
|
||||
* wait until backlog drains (or record that it doesn't)
|
||||
* export:
|
||||
* k6/runner raw output
|
||||
* Prometheus time series snapshot
|
||||
* sampled logs with corr_id fields
|
||||
* generate a summary report automatically (no hand calculations)
|
||||
|
||||
---
|
||||
|
||||
## 8) Analysis rules (how agents compute "the envelope")
|
||||
|
||||
Agents MUST generate at minimum two plots per run:
|
||||
|
||||
1. **Latency envelope**: offered load (x-axis) vs p95 `t_hop` (y-axis)
|
||||
* overlay p99 (and SLO line)
|
||||
2. **Queue behavior**: offered load vs queue depth (or lag), plus drain time
|
||||
|
||||
### How to identify the "knee"
|
||||
|
||||
Agents SHOULD mark the knee as the first plateau where:
|
||||
|
||||
* queue depth grows monotonically within the plateau, **or**
|
||||
* p95 `t_queue_delay` increases by > X% step-to-step (e.g., 50–100%)
|
||||
|
||||
### Convert results into scaling guidance
|
||||
|
||||
Agents SHOULD compute:
|
||||
|
||||
* `capacity_per_worker ≈ 1 / mean(t_service)` (jobs/sec per worker)
|
||||
* recommended replicas for offered load λ at target utilization U:
|
||||
* `workers_needed = ceil(λ * mean(t_service) / U)`
|
||||
* choose U ~ 0.6–0.75 for headroom
|
||||
|
||||
This should be reported alongside the measured envelope.
|
||||
|
||||
---
|
||||
|
||||
## 9) Pass/fail criteria and regression gates
|
||||
|
||||
Agents MUST define gates in configuration, not in someone's head.
|
||||
|
||||
Suggested gating structure:
|
||||
|
||||
* **Smoke gate**: error rate < 0.1%, backlog drains
|
||||
* **Baseline gate**: p95 `t_hop` regression < 10% (tune after you have history)
|
||||
* **Capacity gate**: knee load regression < 10% (optional but very valuable)
|
||||
* **Soak gate**: p95 drift over time < 15% and no memory runaway
|
||||
|
||||
---
|
||||
|
||||
## 10) Common pitfalls (agents must avoid)
|
||||
|
||||
1. **Closed-loop tests used for capacity**
|
||||
Closed-loop ("N concurrent users") self-throttles and can hide queueing onset. Use open-loop arrival rate for capacity.
|
||||
|
||||
2. **Ignoring queue depth**
|
||||
A system can look "healthy" in request latency while silently building backlog.
|
||||
|
||||
3. **Measuring only gateway latency**
|
||||
You must measure enqueue → claim → done to see the real hop.
|
||||
|
||||
4. **Load generator bottleneck**
|
||||
If the generator saturates, you'll under-estimate capacity.
|
||||
|
||||
5. **Retries enabled by default**
|
||||
Retries can inflate load and hide root causes; run with retries off first.
|
||||
|
||||
6. **Not controlling warm vs cold**
|
||||
Cold caches vs warmed services produce different envelopes; record the condition.
|
||||
|
||||
---
|
||||
|
||||
# Agent implementation checklist (deliverables)
|
||||
|
||||
Assign these as concrete tasks to your agents.
|
||||
|
||||
## Agent 1 — Observability & tracing
|
||||
|
||||
MUST deliver:
|
||||
|
||||
* correlation id propagation gateway → Valkey → worker
|
||||
* timestamps `enq/claim/done`
|
||||
* Prometheus histograms for enqueue, service, hop
|
||||
* queue depth metric (`XLEN` / `XINFO` lag)
|
||||
|
||||
## Agent 2 — Load test harness
|
||||
|
||||
MUST deliver:
|
||||
|
||||
* test runner scripts (k6 or equivalent) for scenarios A–G
|
||||
* test config file (YAML/JSON) controlling:
|
||||
* stages (rates/durations)
|
||||
* payload mix
|
||||
* headers (corr-id)
|
||||
* reproducible seeds and version stamping
|
||||
|
||||
## Agent 3 — Result collector and analyzer
|
||||
|
||||
MUST deliver:
|
||||
|
||||
* a pipeline that merges:
|
||||
* load generator output
|
||||
* hop timing data (from logs or a completion stream)
|
||||
* Prometheus snapshots
|
||||
* automatic summary + plots:
|
||||
* latency envelope
|
||||
* queue depth/drain
|
||||
* CSV/JSON exports for long-term tracking
|
||||
|
||||
## Agent 4 — Reporting and dashboards
|
||||
|
||||
MUST deliver:
|
||||
|
||||
* a standard report template that includes:
|
||||
* environment details
|
||||
* scenario details
|
||||
* key charts
|
||||
* knee estimate
|
||||
* scaling recommendation
|
||||
* Grafana dashboard with the required panels
|
||||
|
||||
## Agent 5 — CI / release integration
|
||||
|
||||
SHOULD deliver:
|
||||
|
||||
* PR-level smoke test (Scenario A)
|
||||
* nightly baseline (Scenario B)
|
||||
* weekly capacity sweep (Scenario C + scaling curve)
|
||||
|
||||
---
|
||||
|
||||
## Template: scenario spec (agents can copy/paste)
|
||||
|
||||
```yaml
|
||||
test_run:
|
||||
system_under_test:
|
||||
gateway_sha: "<git sha>"
|
||||
worker_sha: "<git sha>"
|
||||
valkey_version: "<version>"
|
||||
environment:
|
||||
cluster: "<name>"
|
||||
workers: 4
|
||||
autoscaling: "off" # off|on
|
||||
workload:
|
||||
endpoint: "/hop"
|
||||
payload_profile: "p50"
|
||||
mix:
|
||||
p50: 0.7
|
||||
p95: 0.25
|
||||
max: 0.05
|
||||
scenario:
|
||||
name: "capacity_ramp"
|
||||
mode: "open_loop"
|
||||
warmup_seconds: 60
|
||||
stages:
|
||||
- rps: 50
|
||||
duration_seconds: 120
|
||||
- rps: 100
|
||||
duration_seconds: 120
|
||||
- rps: 200
|
||||
duration_seconds: 120
|
||||
- rps: 400
|
||||
duration_seconds: 120
|
||||
gates:
|
||||
max_error_rate: 0.01
|
||||
slo_ms_p95_hop: 500
|
||||
backlog_must_drain_seconds: 300
|
||||
outputs:
|
||||
artifacts_dir: "./artifacts/<timestamp>/"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sample folder layout
|
||||
|
||||
```
|
||||
perf/
|
||||
docker-compose.yml
|
||||
prometheus/
|
||||
prometheus.yml
|
||||
k6/
|
||||
lib.js
|
||||
smoke.js
|
||||
capacity_ramp.js
|
||||
burst.js
|
||||
soak.js
|
||||
stress.js
|
||||
scaling_curve.sh
|
||||
tools/
|
||||
analyze.py
|
||||
src/
|
||||
Perf.Gateway/
|
||||
Perf.Worker/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Document Version**: 1.0
|
||||
**Archived From**: docs/product-advisories/unprocessed/16-Dec-2025 - Reimagining Proof-Linked UX in Security Workflows.md
|
||||
**Archive Reason**: Wrong content was pasted; this performance testing content preserved for future use.
|
||||
365
docs/implplan/SPRINT_3600_0001_0001_reachability_drift_master.md
Normal file
365
docs/implplan/SPRINT_3600_0001_0001_reachability_drift_master.md
Normal file
@@ -0,0 +1,365 @@
|
||||
# SPRINT_3600_0001_0001 - Reachability Drift Detection Master Plan
|
||||
|
||||
**Status:** TODO
|
||||
**Priority:** P0 - CRITICAL
|
||||
**Module:** Scanner, Signals, Web
|
||||
**Working Directory:** `src/Scanner/__Libraries/StellaOps.Scanner.ReachabilityDrift/`
|
||||
**Estimated Effort:** X-Large (3 sub-sprints)
|
||||
**Dependencies:** SPRINT_3500 (Smart-Diff) - COMPLETE
|
||||
|
||||
---
|
||||
|
||||
## Topic & Scope
|
||||
|
||||
Implementation of Reachability Drift Detection as specified in `docs/product-advisories/17-Dec-2025 - Reachability Drift Detection.md`. This extends Smart-Diff to detect when vulnerable code paths become reachable/unreachable between container image versions, with causal attribution and UI visualization.
|
||||
|
||||
**Business Value:**
|
||||
- Transform from "all vulnerabilities" to "material reachability changes"
|
||||
- Reduce alert fatigue by 90%+ through meaningful drift detection
|
||||
- Enable causal attribution ("guard removed in AuthFilter.cs:42")
|
||||
- Provide actionable UI for security review
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Concurrency
|
||||
|
||||
**Internal Dependencies:**
|
||||
- `SPRINT_3500` (Smart-Diff) - COMPLETE - Provides MaterialRiskChangeDetector, VexCandidateEmitter
|
||||
- `StellaOps.Signals.Contracts` - Provides CallPath, ReachabilitySignal models
|
||||
- `StellaOps.Scanner.SmartDiff` - Provides detection infrastructure
|
||||
- `vex.graph_nodes/edges` - Existing graph storage schema
|
||||
|
||||
**Concurrency:**
|
||||
- Sprint 3600.2 (Call Graph) must complete before 3600.3 (Drift Detection)
|
||||
- Sprint 3600.4 (UI) can start in parallel once 3600.3 API contracts are defined
|
||||
|
||||
---
|
||||
|
||||
## Documentation Prerequisites
|
||||
|
||||
Before starting implementation, read:
|
||||
- `docs/product-advisories/17-Dec-2025 - Reachability Drift Detection.md`
|
||||
- `docs/product-advisories/14-Dec-2025 - Smart-Diff Technical Reference.md`
|
||||
- `docs/product-advisories/14-Dec-2025 - Reachability Analysis Technical Reference.md`
|
||||
- `docs/modules/scanner/architecture.md`
|
||||
- `docs/reachability/lattice.md`
|
||||
- `bench/reachability-benchmark/README.md`
|
||||
|
||||
---
|
||||
|
||||
## Wave Coordination
|
||||
|
||||
```
|
||||
SPRINT_3600_0002 (Call Graph Infrastructure)
|
||||
│
|
||||
▼
|
||||
SPRINT_3600_0003 (Drift Detection Engine)
|
||||
│
|
||||
├──────────────────────┐
|
||||
▼ ▼
|
||||
SPRINT_3600_0004 (UI) API Integration
|
||||
│ │
|
||||
└──────────────┬───────┘
|
||||
▼
|
||||
Integration Tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wave Detail Snapshots
|
||||
|
||||
### Wave 1: Call Graph Infrastructure (SPRINT_3600_0002_0001)
|
||||
- .NET call graph extraction via Roslyn
|
||||
- Node.js call graph extraction via AST parsing
|
||||
- Entrypoint discovery for ASP.NET Core, Express, Fastify
|
||||
- Sink taxonomy implementation
|
||||
- Call graph storage and caching
|
||||
|
||||
### Wave 2: Drift Detection Engine (SPRINT_3600_0003_0001)
|
||||
- Code change facts extraction (AST-level)
|
||||
- Cross-scan graph comparison
|
||||
- Drift cause attribution
|
||||
- Path compression for storage
|
||||
- API endpoints
|
||||
|
||||
### Wave 3: UI and Evidence Chain (SPRINT_3600_0004_0001)
|
||||
- Angular Path Viewer component
|
||||
- Risk Drift Card component
|
||||
- Evidence chain linking
|
||||
- DSSE attestation for drift results
|
||||
- CLI output enhancements
|
||||
|
||||
---
|
||||
|
||||
## Interlocks
|
||||
|
||||
1. **Schema Versioning**: New tables must be versioned migrations (006_reachability_drift_tables.sql)
|
||||
2. **Determinism**: Call graph extraction must be deterministic (stable node IDs)
|
||||
3. **Benchmark Alignment**: Must pass `bench/reachability-benchmark` cases
|
||||
4. **Smart-Diff Compat**: Must integrate with existing MaterialRiskChangeDetector
|
||||
|
||||
---
|
||||
|
||||
## Upcoming Checkpoints
|
||||
|
||||
- TBD
|
||||
|
||||
---
|
||||
|
||||
## Action Tracker
|
||||
|
||||
| Date (UTC) | Action | Owner | Notes |
|
||||
|---|---|---|---|
|
||||
| 2025-12-17 | Created master sprint from advisory analysis | Agent | Initial planning |
|
||||
|
||||
---
|
||||
|
||||
## 1. EXECUTIVE SUMMARY
|
||||
|
||||
Reachability Drift Detection extends Smart-Diff to track **function-level reachability changes** between scans. Instead of reporting all vulnerabilities, it identifies:
|
||||
|
||||
1. **New reachable paths** - Vulnerable sinks that became reachable
|
||||
2. **Mitigated paths** - Vulnerable sinks that became unreachable
|
||||
3. **Causal attribution** - Why the change occurred (guard removed, new route, etc.)
|
||||
|
||||
### Technical Approach
|
||||
|
||||
| Phase | Component | Description |
|
||||
|-------|-----------|-------------|
|
||||
| Extract | Call Graph Extractor | Per-language AST analysis |
|
||||
| Model | Entrypoint Discovery | HTTP handlers, CLI commands, jobs |
|
||||
| Diff | Code Change Facts | AST-level symbol changes |
|
||||
| Analyze | Reachability BFS | Multi-source traversal from entrypoints |
|
||||
| Compare | Drift Detector | Graph N vs N-1 comparison |
|
||||
| Attribute | Cause Explainer | Link drift to code changes |
|
||||
| Present | Path Viewer | Angular UI component |
|
||||
|
||||
---
|
||||
|
||||
## 2. ARCHITECTURE OVERVIEW
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ REACHABILITY DRIFT ARCHITECTURE │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Scan T-1 │ │ Scan T │ │ Call Graph │ │
|
||||
│ │ (Baseline) │────►│ (Current) │────►│ Extractor │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ GRAPH EXTRACTION │ │
|
||||
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │
|
||||
│ │ │ .NET/Roslyn│ │ Node/AST │ │ Go/SSA │ │ │
|
||||
│ │ └────────────┘ └────────────┘ └────────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ REACHABILITY ANALYSIS │ │
|
||||
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
|
||||
│ │ │Entrypoint│ │BFS/DFS │ │ Sink │ │Reachable│ │ │
|
||||
│ │ │Discovery │ │Traversal│ │Detection│ │ Set │ │ │
|
||||
│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ DRIFT DETECTION │ │
|
||||
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │
|
||||
│ │ │Code Change │ │Graph Diff │ │ Cause │ │ │
|
||||
│ │ │ Facts │ │ Comparison │ │ Attribution│ │ │
|
||||
│ │ └────────────┘ └────────────┘ └────────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ OUTPUT GENERATION │ │
|
||||
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │
|
||||
│ │ │ Path Viewer│ │ SARIF │ │ DSSE │ │ │
|
||||
│ │ │ UI │ │ Output │ │ Attestation│ │ │
|
||||
│ │ └────────────┘ └────────────┘ └────────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. SUB-SPRINT STRUCTURE
|
||||
|
||||
| Sprint | ID | Topic | Status | Priority | Dependencies |
|
||||
|--------|-----|-------|--------|----------|--------------|
|
||||
| 1 | SPRINT_3600_0002_0001 | Call Graph Infrastructure | TODO | P0 | Master |
|
||||
| 2 | SPRINT_3600_0003_0001 | Drift Detection Engine | TODO | P0 | Sprint 1 |
|
||||
| 3 | SPRINT_3600_0004_0001 | UI and Evidence Chain | TODO | P1 | Sprint 2 |
|
||||
|
||||
### Sprint Dependency Graph
|
||||
|
||||
```
|
||||
SPRINT_3600_0002 (Call Graph)
|
||||
│
|
||||
├──────────────────────┐
|
||||
▼ │
|
||||
SPRINT_3600_0003 (Detection) │
|
||||
│ │
|
||||
├──────────────────────┤
|
||||
▼ ▼
|
||||
SPRINT_3600_0004 (UI) Integration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. GAP ANALYSIS SUMMARY
|
||||
|
||||
### 4.1 Existing Infrastructure (Leverage Points)
|
||||
|
||||
| Component | Location | Status |
|
||||
|-----------|----------|--------|
|
||||
| MaterialRiskChangeDetector | `Scanner.SmartDiff.Detection` | COMPLETE |
|
||||
| VexCandidateEmitter | `Scanner.SmartDiff.Detection` | COMPLETE |
|
||||
| ReachabilityGateBridge | `Scanner.SmartDiff.Detection` | COMPLETE |
|
||||
| CallPath model | `Signals.Contracts.Evidence` | COMPLETE |
|
||||
| ReachabilityLatticeState | `Signals.Contracts.Evidence` | COMPLETE |
|
||||
| vex.graph_nodes/edges | Database | COMPLETE |
|
||||
| scanner.material_risk_changes | Database | COMPLETE |
|
||||
| FN-Drift tracking | `Scanner.Core.Drift` | COMPLETE |
|
||||
| Reachability benchmark | `bench/reachability-benchmark` | COMPLETE |
|
||||
| Language analyzers | `Scanner.Analyzers.Lang.*` | PARTIAL |
|
||||
|
||||
### 4.2 Missing Components (Implementation Required)
|
||||
|
||||
| Component | Sprint | Priority |
|
||||
|-----------|--------|----------|
|
||||
| CallGraphExtractor.DotNet (Roslyn) | 3600.2 | P0 |
|
||||
| CallGraphExtractor.Node (AST) | 3600.2 | P0 |
|
||||
| EntrypointDiscovery.AspNetCore | 3600.2 | P0 |
|
||||
| EntrypointDiscovery.Express | 3600.2 | P0 |
|
||||
| SinkDetector (taxonomy) | 3600.2 | P0 |
|
||||
| scanner.code_changes table | 3600.3 | P0 |
|
||||
| scanner.call_graph_snapshots table | 3600.2 | P0 |
|
||||
| CodeChangeFact extractor | 3600.3 | P0 |
|
||||
| DriftCauseExplainer | 3600.3 | P0 |
|
||||
| PathCompressor | 3600.3 | P1 |
|
||||
| PathViewerComponent (Angular) | 3600.4 | P1 |
|
||||
| RiskDriftCardComponent (Angular) | 3600.4 | P1 |
|
||||
| DSSE attestation for drift | 3600.4 | P1 |
|
||||
|
||||
---
|
||||
|
||||
## 5. MODULE OWNERSHIP
|
||||
|
||||
| Module | Owner Role | Sprints |
|
||||
|--------|------------|---------|
|
||||
| Scanner | Scanner Guild | 3600.2, 3600.3 |
|
||||
| Signals | Signals Guild | 3600.2 (contracts) |
|
||||
| Web | Frontend Guild | 3600.4 |
|
||||
| Attestor | Attestor Guild | 3600.4 (DSSE) |
|
||||
|
||||
---
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
| # | Task ID | Sprint | Status | Description |
|
||||
|---|---------|--------|--------|-------------|
|
||||
| 1 | RDRIFT-MASTER-0001 | 3600 | DOING | Coordinate all sub-sprints |
|
||||
| 2 | RDRIFT-MASTER-0002 | 3600 | TODO | Create integration test suite |
|
||||
| 3 | RDRIFT-MASTER-0003 | 3600 | TODO | Update Scanner AGENTS.md |
|
||||
| 4 | RDRIFT-MASTER-0004 | 3600 | TODO | Update Web AGENTS.md |
|
||||
| 5 | RDRIFT-MASTER-0005 | 3600 | TODO | Validate benchmark cases pass |
|
||||
| 6 | RDRIFT-MASTER-0006 | 3600 | TODO | Document air-gap workflows |
|
||||
|
||||
---
|
||||
|
||||
## 6. SUCCESS CRITERIA
|
||||
|
||||
### 6.1 Functional Requirements
|
||||
|
||||
- [ ] .NET call graph extraction via Roslyn
|
||||
- [ ] Node.js call graph extraction via AST
|
||||
- [ ] ASP.NET Core entrypoint discovery
|
||||
- [ ] Express/Fastify entrypoint discovery
|
||||
- [ ] Sink taxonomy (9 categories)
|
||||
- [ ] Code change facts extraction
|
||||
- [ ] Cross-scan drift detection
|
||||
- [ ] Causal attribution
|
||||
- [ ] Path Viewer UI
|
||||
- [ ] DSSE attestation
|
||||
|
||||
### 6.2 Determinism Requirements
|
||||
|
||||
- [ ] Same inputs produce identical call graph hash
|
||||
- [ ] Node IDs stable across extractions
|
||||
- [ ] Drift detection order-independent
|
||||
- [ ] Path compression reversible
|
||||
|
||||
### 6.3 Test Requirements
|
||||
|
||||
- [ ] Unit tests for each extractor
|
||||
- [ ] Integration tests with benchmark cases
|
||||
- [ ] Golden fixtures for drift detection
|
||||
- [ ] UI component tests
|
||||
|
||||
### 6.4 Performance Requirements
|
||||
|
||||
- [ ] Call graph extraction < 60s for 100K LOC
|
||||
- [ ] Drift comparison < 5s per image pair
|
||||
- [ ] Path storage < 10KB per compressed path
|
||||
|
||||
---
|
||||
|
||||
## Decisions & Risks
|
||||
|
||||
### 7.1 Architectural Decisions
|
||||
|
||||
| ID | Decision | Rationale |
|
||||
|----|----------|-----------|
|
||||
| RDRIFT-DEC-001 | Use scan_id not commit_sha | StellaOps is image-centric |
|
||||
| RDRIFT-DEC-002 | Store graphs in CAS, metadata in Postgres | Separate large blobs from metadata |
|
||||
| RDRIFT-DEC-003 | Start with .NET + Node only | Highest ROI languages |
|
||||
| RDRIFT-DEC-004 | Extend existing schema, don't duplicate | Leverage vex.graph_* tables |
|
||||
|
||||
### 7.2 Risks & Mitigations
|
||||
|
||||
| ID | Risk | Likelihood | Impact | Mitigation |
|
||||
|----|------|------------|--------|------------|
|
||||
| RDRIFT-RISK-001 | Roslyn memory pressure on large solutions | Medium | High | Incremental analysis, streaming |
|
||||
| RDRIFT-RISK-002 | Call graph over-approximation | Medium | Medium | Conservative static analysis |
|
||||
| RDRIFT-RISK-003 | UI performance with large paths | Low | Medium | Path compression, lazy loading |
|
||||
| RDRIFT-RISK-004 | False positive drift detection | Medium | Medium | Confidence scoring, review workflow |
|
||||
|
||||
---
|
||||
|
||||
## 8. DEPENDENCIES
|
||||
|
||||
### 8.1 Internal Dependencies
|
||||
|
||||
- `StellaOps.Scanner.SmartDiff` - Detection infrastructure
|
||||
- `StellaOps.Signals.Contracts` - CallPath models
|
||||
- `StellaOps.Attestor.ProofChain` - DSSE attestations
|
||||
- `StellaOps.Scanner.Analyzers.Lang.*` - Language parsers
|
||||
|
||||
### 8.2 External Dependencies
|
||||
|
||||
- Microsoft.CodeAnalysis (Roslyn) - .NET analysis
|
||||
- @babel/parser, @babel/traverse - Node.js analysis
|
||||
- golang.org/x/tools/go/ssa - Go analysis (future)
|
||||
|
||||
---
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date (UTC) | Update | Owner |
|
||||
|---|---|---|
|
||||
| 2025-12-17 | Created master sprint from advisory analysis | Agent |
|
||||
|
||||
---
|
||||
|
||||
## 9. REFERENCES
|
||||
|
||||
- **Source Advisory**: `docs/product-advisories/17-Dec-2025 - Reachability Drift Detection.md`
|
||||
- **Smart-Diff Reference**: `docs/product-advisories/14-Dec-2025 - Smart-Diff Technical Reference.md`
|
||||
- **Reachability Reference**: `docs/product-advisories/14-Dec-2025 - Reachability Analysis Technical Reference.md`
|
||||
- **Benchmark**: `bench/reachability-benchmark/README.md`
|
||||
1273
docs/implplan/SPRINT_3600_0002_0001_call_graph_infrastructure.md
Normal file
1273
docs/implplan/SPRINT_3600_0002_0001_call_graph_infrastructure.md
Normal file
File diff suppressed because it is too large
Load Diff
949
docs/implplan/SPRINT_3600_0003_0001_drift_detection_engine.md
Normal file
949
docs/implplan/SPRINT_3600_0003_0001_drift_detection_engine.md
Normal file
@@ -0,0 +1,949 @@
|
||||
# SPRINT_3600_0003_0001 - Drift Detection Engine
|
||||
|
||||
**Status:** TODO
|
||||
**Priority:** P0 - CRITICAL
|
||||
**Module:** Scanner
|
||||
**Working Directory:** `src/Scanner/__Libraries/StellaOps.Scanner.ReachabilityDrift/`
|
||||
**Estimated Effort:** Medium
|
||||
**Dependencies:** SPRINT_3600_0002_0001 (Call Graph Infrastructure)
|
||||
|
||||
---
|
||||
|
||||
## Topic & Scope
|
||||
|
||||
Implement the drift detection engine that compares call graphs between scans to identify reachability changes. This sprint covers:
|
||||
- Code change facts extraction (AST-level)
|
||||
- Cross-scan graph comparison
|
||||
- Drift cause attribution
|
||||
- Path compression for storage
|
||||
- API endpoints for drift results
|
||||
|
||||
---
|
||||
|
||||
## Documentation Prerequisites
|
||||
|
||||
- `docs/product-advisories/17-Dec-2025 - Reachability Drift Detection.md`
|
||||
- `docs/implplan/SPRINT_3600_0002_0001_call_graph_infrastructure.md`
|
||||
- `src/Scanner/__Libraries/StellaOps.Scanner.SmartDiff/AGENTS.md`
|
||||
|
||||
---
|
||||
|
||||
## Wave Coordination
|
||||
|
||||
Single wave with sequential tasks:
|
||||
1. Code change models and extraction
|
||||
2. Cross-scan comparison engine
|
||||
3. Cause attribution
|
||||
4. Path compression
|
||||
5. API integration
|
||||
|
||||
---
|
||||
|
||||
## Interlocks
|
||||
|
||||
- Depends on CallGraphSnapshot model from Sprint 3600.2
|
||||
- Must integrate with existing MaterialRiskChangeDetector
|
||||
- Must extend scanner.material_risk_changes table
|
||||
|
||||
---
|
||||
|
||||
## Action Tracker
|
||||
|
||||
| Date (UTC) | Action | Owner | Notes |
|
||||
|---|---|---|---|
|
||||
| 2025-12-17 | Created sprint from master plan | Agent | Initial |
|
||||
|
||||
---
|
||||
|
||||
## 1. OBJECTIVE
|
||||
|
||||
Build the drift detection engine:
|
||||
1. **Code Change Facts** - Extract AST-level changes between scans
|
||||
2. **Graph Comparison** - Detect reachability flips
|
||||
3. **Cause Attribution** - Explain why drift occurred
|
||||
4. **Path Compression** - Efficient storage for UI display
|
||||
|
||||
---
|
||||
|
||||
## 2. TECHNICAL DESIGN
|
||||
|
||||
### 2.1 Code Change Facts Model
|
||||
|
||||
```csharp
|
||||
// File: src/Scanner/__Libraries/StellaOps.Scanner.ReachabilityDrift/Models/CodeChangeFact.cs
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift;
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an AST-level code change fact.
|
||||
/// </summary>
|
||||
public sealed record CodeChangeFact
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public required string ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("baseScanId")]
|
||||
public required string BaseScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("file")]
|
||||
public required string File { get; init; }
|
||||
|
||||
[JsonPropertyName("symbol")]
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
public required CodeChangeKind Kind { get; init; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public JsonDocument? Details { get; init; }
|
||||
|
||||
[JsonPropertyName("detectedAt")]
|
||||
public required DateTimeOffset DetectedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of code changes relevant to reachability.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<CodeChangeKind>))]
|
||||
public enum CodeChangeKind
|
||||
{
|
||||
/// <summary>Symbol added (new function/method).</summary>
|
||||
[JsonStringEnumMemberName("added")]
|
||||
Added,
|
||||
|
||||
/// <summary>Symbol removed.</summary>
|
||||
[JsonStringEnumMemberName("removed")]
|
||||
Removed,
|
||||
|
||||
/// <summary>Function signature changed (parameters, return type).</summary>
|
||||
[JsonStringEnumMemberName("signature_changed")]
|
||||
SignatureChanged,
|
||||
|
||||
/// <summary>Guard condition around call modified.</summary>
|
||||
[JsonStringEnumMemberName("guard_changed")]
|
||||
GuardChanged,
|
||||
|
||||
/// <summary>Callee package/version changed.</summary>
|
||||
[JsonStringEnumMemberName("dependency_changed")]
|
||||
DependencyChanged,
|
||||
|
||||
/// <summary>Visibility changed (public<->internal<->private).</summary>
|
||||
[JsonStringEnumMemberName("visibility_changed")]
|
||||
VisibilityChanged
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Drift Result Model
|
||||
|
||||
```csharp
|
||||
// File: src/Scanner/__Libraries/StellaOps.Scanner.ReachabilityDrift/Models/ReachabilityDriftResult.cs
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift;
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Result of reachability drift detection between two scans.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityDriftResult
|
||||
{
|
||||
[JsonPropertyName("baseScanId")]
|
||||
public required string BaseScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("headScanId")]
|
||||
public required string HeadScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("detectedAt")]
|
||||
public required DateTimeOffset DetectedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("newlyReachable")]
|
||||
public required ImmutableArray<DriftedSink> NewlyReachable { get; init; }
|
||||
|
||||
[JsonPropertyName("newlyUnreachable")]
|
||||
public required ImmutableArray<DriftedSink> NewlyUnreachable { get; init; }
|
||||
|
||||
[JsonPropertyName("totalDriftCount")]
|
||||
public int TotalDriftCount => NewlyReachable.Length + NewlyUnreachable.Length;
|
||||
|
||||
[JsonPropertyName("hasMaterialDrift")]
|
||||
public bool HasMaterialDrift => TotalDriftCount > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sink that changed reachability status.
|
||||
/// </summary>
|
||||
public sealed record DriftedSink
|
||||
{
|
||||
[JsonPropertyName("sinkNodeId")]
|
||||
public required string SinkNodeId { get; init; }
|
||||
|
||||
[JsonPropertyName("symbol")]
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
[JsonPropertyName("sinkCategory")]
|
||||
public required SinkCategory SinkCategory { get; init; }
|
||||
|
||||
[JsonPropertyName("direction")]
|
||||
public required DriftDirection Direction { get; init; }
|
||||
|
||||
[JsonPropertyName("cause")]
|
||||
public required DriftCause Cause { get; init; }
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public required CompressedPath Path { get; init; }
|
||||
|
||||
[JsonPropertyName("associatedVulns")]
|
||||
public ImmutableArray<AssociatedVuln> AssociatedVulns { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Direction of reachability drift.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<DriftDirection>))]
|
||||
public enum DriftDirection
|
||||
{
|
||||
[JsonStringEnumMemberName("became_reachable")]
|
||||
BecameReachable,
|
||||
|
||||
[JsonStringEnumMemberName("became_unreachable")]
|
||||
BecameUnreachable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cause of the drift, linked to code changes.
|
||||
/// </summary>
|
||||
public sealed record DriftCause
|
||||
{
|
||||
[JsonPropertyName("kind")]
|
||||
public required DriftCauseKind Kind { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
|
||||
[JsonPropertyName("changedSymbol")]
|
||||
public string? ChangedSymbol { get; init; }
|
||||
|
||||
[JsonPropertyName("changedFile")]
|
||||
public string? ChangedFile { get; init; }
|
||||
|
||||
[JsonPropertyName("changedLine")]
|
||||
public int? ChangedLine { get; init; }
|
||||
|
||||
[JsonPropertyName("codeChangeId")]
|
||||
public Guid? CodeChangeId { get; init; }
|
||||
|
||||
public static DriftCause GuardRemoved(string symbol, string file, int line) =>
|
||||
new()
|
||||
{
|
||||
Kind = DriftCauseKind.GuardRemoved,
|
||||
Description = $"Guard condition removed in {symbol}",
|
||||
ChangedSymbol = symbol,
|
||||
ChangedFile = file,
|
||||
ChangedLine = line
|
||||
};
|
||||
|
||||
public static DriftCause NewPublicRoute(string symbol) =>
|
||||
new()
|
||||
{
|
||||
Kind = DriftCauseKind.NewPublicRoute,
|
||||
Description = $"New public entrypoint: {symbol}",
|
||||
ChangedSymbol = symbol
|
||||
};
|
||||
|
||||
public static DriftCause VisibilityEscalated(string symbol) =>
|
||||
new()
|
||||
{
|
||||
Kind = DriftCauseKind.VisibilityEscalated,
|
||||
Description = $"Visibility escalated to public: {symbol}",
|
||||
ChangedSymbol = symbol
|
||||
};
|
||||
|
||||
public static DriftCause DependencyUpgraded(string package, string fromVersion, string toVersion) =>
|
||||
new()
|
||||
{
|
||||
Kind = DriftCauseKind.DependencyUpgraded,
|
||||
Description = $"Dependency upgraded: {package} {fromVersion} -> {toVersion}"
|
||||
};
|
||||
|
||||
public static DriftCause GuardAdded(string symbol) =>
|
||||
new()
|
||||
{
|
||||
Kind = DriftCauseKind.GuardAdded,
|
||||
Description = $"Guard condition added in {symbol}",
|
||||
ChangedSymbol = symbol
|
||||
};
|
||||
|
||||
public static DriftCause SymbolRemoved(string symbol) =>
|
||||
new()
|
||||
{
|
||||
Kind = DriftCauseKind.SymbolRemoved,
|
||||
Description = $"Symbol removed: {symbol}",
|
||||
ChangedSymbol = symbol
|
||||
};
|
||||
|
||||
public static DriftCause Unknown() =>
|
||||
new()
|
||||
{
|
||||
Kind = DriftCauseKind.Unknown,
|
||||
Description = "Cause could not be determined"
|
||||
};
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<DriftCauseKind>))]
|
||||
public enum DriftCauseKind
|
||||
{
|
||||
[JsonStringEnumMemberName("guard_removed")]
|
||||
GuardRemoved,
|
||||
|
||||
[JsonStringEnumMemberName("guard_added")]
|
||||
GuardAdded,
|
||||
|
||||
[JsonStringEnumMemberName("new_public_route")]
|
||||
NewPublicRoute,
|
||||
|
||||
[JsonStringEnumMemberName("visibility_escalated")]
|
||||
VisibilityEscalated,
|
||||
|
||||
[JsonStringEnumMemberName("dependency_upgraded")]
|
||||
DependencyUpgraded,
|
||||
|
||||
[JsonStringEnumMemberName("symbol_removed")]
|
||||
SymbolRemoved,
|
||||
|
||||
[JsonStringEnumMemberName("unknown")]
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability associated with a sink.
|
||||
/// </summary>
|
||||
public sealed record AssociatedVuln
|
||||
{
|
||||
[JsonPropertyName("cveId")]
|
||||
public required string CveId { get; init; }
|
||||
|
||||
[JsonPropertyName("epss")]
|
||||
public double? Epss { get; init; }
|
||||
|
||||
[JsonPropertyName("cvss")]
|
||||
public double? Cvss { get; init; }
|
||||
|
||||
[JsonPropertyName("vexStatus")]
|
||||
public string? VexStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("packagePurl")]
|
||||
public string? PackagePurl { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Compressed Path Model
|
||||
|
||||
```csharp
|
||||
// File: src/Scanner/__Libraries/StellaOps.Scanner.ReachabilityDrift/Models/CompressedPath.cs
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift;
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Compressed representation of a call path for storage and UI.
|
||||
/// </summary>
|
||||
public sealed record CompressedPath
|
||||
{
|
||||
[JsonPropertyName("entrypoint")]
|
||||
public required PathNode Entrypoint { get; init; }
|
||||
|
||||
[JsonPropertyName("sink")]
|
||||
public required PathNode Sink { get; init; }
|
||||
|
||||
[JsonPropertyName("intermediateCount")]
|
||||
public required int IntermediateCount { get; init; }
|
||||
|
||||
[JsonPropertyName("keyNodes")]
|
||||
public required ImmutableArray<PathNode> KeyNodes { get; init; }
|
||||
|
||||
[JsonPropertyName("fullPath")]
|
||||
public ImmutableArray<string>? FullPath { get; init; } // Node IDs for expansion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Node in a compressed path.
|
||||
/// </summary>
|
||||
public sealed record PathNode
|
||||
{
|
||||
[JsonPropertyName("nodeId")]
|
||||
public required string NodeId { get; init; }
|
||||
|
||||
[JsonPropertyName("symbol")]
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
[JsonPropertyName("file")]
|
||||
public string? File { get; init; }
|
||||
|
||||
[JsonPropertyName("line")]
|
||||
public int? Line { get; init; }
|
||||
|
||||
[JsonPropertyName("package")]
|
||||
public string? Package { get; init; }
|
||||
|
||||
[JsonPropertyName("isChanged")]
|
||||
public bool IsChanged { get; init; }
|
||||
|
||||
[JsonPropertyName("changeKind")]
|
||||
public CodeChangeKind? ChangeKind { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Drift Detector Service
|
||||
|
||||
```csharp
|
||||
// File: src/Scanner/__Libraries/StellaOps.Scanner.ReachabilityDrift/Services/ReachabilityDriftDetector.cs
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift.Services;
|
||||
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.CallGraph.Analysis;
|
||||
|
||||
/// <summary>
|
||||
/// Detects reachability drift between two scan snapshots.
|
||||
/// </summary>
|
||||
public sealed class ReachabilityDriftDetector
|
||||
{
|
||||
private readonly ReachabilityAnalyzer _reachabilityAnalyzer = new();
|
||||
private readonly DriftCauseExplainer _causeExplainer = new();
|
||||
private readonly PathCompressor _pathCompressor = new();
|
||||
|
||||
/// <summary>
|
||||
/// Compares two call graph snapshots and returns drift results.
|
||||
/// </summary>
|
||||
public ReachabilityDriftResult Detect(
|
||||
CallGraphSnapshot baseGraph,
|
||||
CallGraphSnapshot headGraph,
|
||||
IReadOnlyList<CodeChangeFact> codeChanges)
|
||||
{
|
||||
// Compute reachability for both graphs
|
||||
var baseReachability = _reachabilityAnalyzer.Analyze(baseGraph);
|
||||
var headReachability = _reachabilityAnalyzer.Analyze(headGraph);
|
||||
|
||||
var newlyReachable = new List<DriftedSink>();
|
||||
var newlyUnreachable = new List<DriftedSink>();
|
||||
|
||||
// Find sinks that became reachable
|
||||
foreach (var sinkId in headGraph.SinkIds)
|
||||
{
|
||||
var wasReachable = baseReachability.ReachableSinks.Contains(sinkId);
|
||||
var isReachable = headReachability.ReachableSinks.Contains(sinkId);
|
||||
|
||||
if (!wasReachable && isReachable)
|
||||
{
|
||||
var sink = headGraph.Nodes.First(n => n.NodeId == sinkId);
|
||||
var path = headReachability.ShortestPaths.TryGetValue(sinkId, out var p) ? p : [];
|
||||
var cause = _causeExplainer.Explain(baseGraph, headGraph, sinkId, path, codeChanges);
|
||||
|
||||
newlyReachable.Add(new DriftedSink
|
||||
{
|
||||
SinkNodeId = sinkId,
|
||||
Symbol = sink.Symbol,
|
||||
SinkCategory = sink.SinkCategory ?? SinkCategory.CmdExec,
|
||||
Direction = DriftDirection.BecameReachable,
|
||||
Cause = cause,
|
||||
Path = _pathCompressor.Compress(path, headGraph, codeChanges)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Find sinks that became unreachable
|
||||
foreach (var sinkId in baseGraph.SinkIds)
|
||||
{
|
||||
var wasReachable = baseReachability.ReachableSinks.Contains(sinkId);
|
||||
var isReachable = headReachability.ReachableSinks.Contains(sinkId);
|
||||
|
||||
if (wasReachable && !isReachable)
|
||||
{
|
||||
var sink = baseGraph.Nodes.First(n => n.NodeId == sinkId);
|
||||
var path = baseReachability.ShortestPaths.TryGetValue(sinkId, out var p) ? p : [];
|
||||
var cause = _causeExplainer.ExplainUnreachable(baseGraph, headGraph, sinkId, path, codeChanges);
|
||||
|
||||
newlyUnreachable.Add(new DriftedSink
|
||||
{
|
||||
SinkNodeId = sinkId,
|
||||
Symbol = sink.Symbol,
|
||||
SinkCategory = sink.SinkCategory ?? SinkCategory.CmdExec,
|
||||
Direction = DriftDirection.BecameUnreachable,
|
||||
Cause = cause,
|
||||
Path = _pathCompressor.Compress(path, baseGraph, codeChanges)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new ReachabilityDriftResult
|
||||
{
|
||||
BaseScanId = baseGraph.ScanId,
|
||||
HeadScanId = headGraph.ScanId,
|
||||
DetectedAt = DateTimeOffset.UtcNow,
|
||||
NewlyReachable = newlyReachable.ToImmutableArray(),
|
||||
NewlyUnreachable = newlyUnreachable.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 Drift Cause Explainer
|
||||
|
||||
```csharp
|
||||
// File: src/Scanner/__Libraries/StellaOps.Scanner.ReachabilityDrift/Services/DriftCauseExplainer.cs
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift.Services;
|
||||
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
|
||||
/// <summary>
|
||||
/// Explains why a reachability drift occurred.
|
||||
/// </summary>
|
||||
public sealed class DriftCauseExplainer
|
||||
{
|
||||
/// <summary>
|
||||
/// Explains why a sink became reachable.
|
||||
/// </summary>
|
||||
public DriftCause Explain(
|
||||
CallGraphSnapshot baseGraph,
|
||||
CallGraphSnapshot headGraph,
|
||||
string sinkNodeId,
|
||||
ImmutableArray<string> path,
|
||||
IReadOnlyList<CodeChangeFact> codeChanges)
|
||||
{
|
||||
if (path.IsDefaultOrEmpty)
|
||||
return DriftCause.Unknown();
|
||||
|
||||
// Check each node on path for code changes
|
||||
foreach (var nodeId in path)
|
||||
{
|
||||
var headNode = headGraph.Nodes.FirstOrDefault(n => n.NodeId == nodeId);
|
||||
if (headNode is null) continue;
|
||||
|
||||
var change = codeChanges.FirstOrDefault(c =>
|
||||
c.Symbol == headNode.Symbol ||
|
||||
c.Symbol == ExtractTypeName(headNode.Symbol));
|
||||
|
||||
if (change is not null)
|
||||
{
|
||||
return change.Kind switch
|
||||
{
|
||||
CodeChangeKind.GuardChanged => DriftCause.GuardRemoved(
|
||||
headNode.Symbol, headNode.File, headNode.Line),
|
||||
CodeChangeKind.Added => DriftCause.NewPublicRoute(headNode.Symbol),
|
||||
CodeChangeKind.VisibilityChanged => DriftCause.VisibilityEscalated(headNode.Symbol),
|
||||
CodeChangeKind.DependencyChanged => ExplainDependencyChange(change),
|
||||
_ => DriftCause.Unknown()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if entrypoint is new
|
||||
var entrypoint = path.FirstOrDefault();
|
||||
if (entrypoint is not null)
|
||||
{
|
||||
var baseHasEntrypoint = baseGraph.EntrypointIds.Contains(entrypoint);
|
||||
var headHasEntrypoint = headGraph.EntrypointIds.Contains(entrypoint);
|
||||
|
||||
if (!baseHasEntrypoint && headHasEntrypoint)
|
||||
{
|
||||
var epNode = headGraph.Nodes.First(n => n.NodeId == entrypoint);
|
||||
return DriftCause.NewPublicRoute(epNode.Symbol);
|
||||
}
|
||||
}
|
||||
|
||||
return DriftCause.Unknown();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Explains why a sink became unreachable.
|
||||
/// </summary>
|
||||
public DriftCause ExplainUnreachable(
|
||||
CallGraphSnapshot baseGraph,
|
||||
CallGraphSnapshot headGraph,
|
||||
string sinkNodeId,
|
||||
ImmutableArray<string> basePath,
|
||||
IReadOnlyList<CodeChangeFact> codeChanges)
|
||||
{
|
||||
// Check if any node on path was removed
|
||||
foreach (var nodeId in basePath)
|
||||
{
|
||||
var existsInHead = headGraph.Nodes.Any(n => n.NodeId == nodeId);
|
||||
if (!existsInHead)
|
||||
{
|
||||
var baseNode = baseGraph.Nodes.First(n => n.NodeId == nodeId);
|
||||
return DriftCause.SymbolRemoved(baseNode.Symbol);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for guard additions
|
||||
foreach (var nodeId in basePath)
|
||||
{
|
||||
var change = codeChanges.FirstOrDefault(c =>
|
||||
c.Kind == CodeChangeKind.GuardChanged);
|
||||
|
||||
if (change is not null)
|
||||
{
|
||||
return DriftCause.GuardAdded(change.Symbol);
|
||||
}
|
||||
}
|
||||
|
||||
return DriftCause.Unknown();
|
||||
}
|
||||
|
||||
private static string ExtractTypeName(string symbol)
|
||||
{
|
||||
var lastDot = symbol.LastIndexOf('.');
|
||||
if (lastDot > 0)
|
||||
{
|
||||
var beforeMethod = symbol[..lastDot];
|
||||
var typeEnd = beforeMethod.LastIndexOf('.');
|
||||
return typeEnd > 0 ? beforeMethod[(typeEnd + 1)..] : beforeMethod;
|
||||
}
|
||||
return symbol;
|
||||
}
|
||||
|
||||
private static DriftCause ExplainDependencyChange(CodeChangeFact change)
|
||||
{
|
||||
if (change.Details is not null)
|
||||
{
|
||||
var details = change.Details.RootElement;
|
||||
var package = details.TryGetProperty("package", out var p) ? p.GetString() : "unknown";
|
||||
var from = details.TryGetProperty("fromVersion", out var f) ? f.GetString() : "?";
|
||||
var to = details.TryGetProperty("toVersion", out var t) ? t.GetString() : "?";
|
||||
return DriftCause.DependencyUpgraded(package ?? "unknown", from ?? "?", to ?? "?");
|
||||
}
|
||||
return DriftCause.Unknown();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.6 Path Compressor
|
||||
|
||||
```csharp
|
||||
// File: src/Scanner/__Libraries/StellaOps.Scanner.ReachabilityDrift/Services/PathCompressor.cs
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift.Services;
|
||||
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
|
||||
/// <summary>
|
||||
/// Compresses call paths for efficient storage and UI display.
|
||||
/// </summary>
|
||||
public sealed class PathCompressor
|
||||
{
|
||||
private const int MaxKeyNodes = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Compresses a full path to key nodes only.
|
||||
/// </summary>
|
||||
public CompressedPath Compress(
|
||||
ImmutableArray<string> fullPath,
|
||||
CallGraphSnapshot graph,
|
||||
IReadOnlyList<CodeChangeFact> codeChanges)
|
||||
{
|
||||
if (fullPath.IsDefaultOrEmpty)
|
||||
{
|
||||
return new CompressedPath
|
||||
{
|
||||
Entrypoint = new PathNode { NodeId = "unknown", Symbol = "unknown" },
|
||||
Sink = new PathNode { NodeId = "unknown", Symbol = "unknown" },
|
||||
IntermediateCount = 0,
|
||||
KeyNodes = []
|
||||
};
|
||||
}
|
||||
|
||||
var entrypointNode = graph.Nodes.FirstOrDefault(n => n.NodeId == fullPath[0]);
|
||||
var sinkNode = graph.Nodes.FirstOrDefault(n => n.NodeId == fullPath[^1]);
|
||||
|
||||
// Identify key nodes (changed, entry, sink, or interesting)
|
||||
var keyNodes = new List<PathNode>();
|
||||
var changedSymbols = codeChanges.Select(c => c.Symbol).ToHashSet();
|
||||
|
||||
for (var i = 1; i < fullPath.Length - 1 && keyNodes.Count < MaxKeyNodes; i++)
|
||||
{
|
||||
var nodeId = fullPath[i];
|
||||
var node = graph.Nodes.FirstOrDefault(n => n.NodeId == nodeId);
|
||||
if (node is null) continue;
|
||||
|
||||
var isChanged = changedSymbols.Contains(node.Symbol);
|
||||
var change = codeChanges.FirstOrDefault(c => c.Symbol == node.Symbol);
|
||||
|
||||
if (isChanged || node.IsEntrypoint || node.IsSink)
|
||||
{
|
||||
keyNodes.Add(new PathNode
|
||||
{
|
||||
NodeId = node.NodeId,
|
||||
Symbol = node.Symbol,
|
||||
File = node.File,
|
||||
Line = node.Line,
|
||||
Package = node.Package,
|
||||
IsChanged = isChanged,
|
||||
ChangeKind = change?.Kind
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new CompressedPath
|
||||
{
|
||||
Entrypoint = CreatePathNode(entrypointNode, changedSymbols, codeChanges),
|
||||
Sink = CreatePathNode(sinkNode, changedSymbols, codeChanges),
|
||||
IntermediateCount = fullPath.Length - 2,
|
||||
KeyNodes = keyNodes.ToImmutableArray(),
|
||||
FullPath = fullPath // Optionally include for expansion
|
||||
};
|
||||
}
|
||||
|
||||
private static PathNode CreatePathNode(
|
||||
CallGraphNode? node,
|
||||
HashSet<string> changedSymbols,
|
||||
IReadOnlyList<CodeChangeFact> codeChanges)
|
||||
{
|
||||
if (node is null)
|
||||
{
|
||||
return new PathNode { NodeId = "unknown", Symbol = "unknown" };
|
||||
}
|
||||
|
||||
var isChanged = changedSymbols.Contains(node.Symbol);
|
||||
var change = codeChanges.FirstOrDefault(c => c.Symbol == node.Symbol);
|
||||
|
||||
return new PathNode
|
||||
{
|
||||
NodeId = node.NodeId,
|
||||
Symbol = node.Symbol,
|
||||
File = node.File,
|
||||
Line = node.Line,
|
||||
Package = node.Package,
|
||||
IsChanged = isChanged,
|
||||
ChangeKind = change?.Kind
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.7 Database Schema Extensions
|
||||
|
||||
```sql
|
||||
-- File: src/Scanner/__Libraries/StellaOps.Scanner.Storage/Postgres/Migrations/007_drift_detection_tables.sql
|
||||
-- Sprint: SPRINT_3600_0003_0001
|
||||
-- Description: Drift detection engine tables
|
||||
|
||||
-- Code change facts from AST-level analysis
|
||||
CREATE TABLE IF NOT EXISTS scanner.code_changes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
scan_id TEXT NOT NULL,
|
||||
base_scan_id TEXT NOT NULL,
|
||||
file TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
change_kind TEXT NOT NULL,
|
||||
details JSONB,
|
||||
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT code_changes_unique UNIQUE (tenant_id, scan_id, base_scan_id, file, symbol)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_code_changes_scan ON scanner.code_changes(scan_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_code_changes_symbol ON scanner.code_changes(symbol);
|
||||
CREATE INDEX IF NOT EXISTS idx_code_changes_kind ON scanner.code_changes(change_kind);
|
||||
|
||||
-- Extend material_risk_changes with drift-specific columns
|
||||
ALTER TABLE scanner.material_risk_changes
|
||||
ADD COLUMN IF NOT EXISTS cause TEXT,
|
||||
ADD COLUMN IF NOT EXISTS cause_kind TEXT,
|
||||
ADD COLUMN IF NOT EXISTS path_nodes JSONB,
|
||||
ADD COLUMN IF NOT EXISTS base_scan_id TEXT,
|
||||
ADD COLUMN IF NOT EXISTS associated_vulns JSONB;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_material_risk_changes_cause
|
||||
ON scanner.material_risk_changes(cause_kind)
|
||||
WHERE cause_kind IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_material_risk_changes_base_scan
|
||||
ON scanner.material_risk_changes(base_scan_id)
|
||||
WHERE base_scan_id IS NOT NULL;
|
||||
|
||||
-- Reachability drift results (aggregate per scan pair)
|
||||
CREATE TABLE IF NOT EXISTS scanner.reachability_drift_results (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
base_scan_id TEXT NOT NULL,
|
||||
head_scan_id TEXT NOT NULL,
|
||||
newly_reachable_count INT NOT NULL DEFAULT 0,
|
||||
newly_unreachable_count INT NOT NULL DEFAULT 0,
|
||||
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
result_digest TEXT NOT NULL, -- Hash for dedup
|
||||
|
||||
CONSTRAINT reachability_drift_unique UNIQUE (tenant_id, base_scan_id, head_scan_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_drift_results_head_scan
|
||||
ON scanner.reachability_drift_results(head_scan_id);
|
||||
|
||||
-- Drifted sinks (individual sink drift records)
|
||||
CREATE TABLE IF NOT EXISTS scanner.drifted_sinks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
drift_result_id UUID NOT NULL REFERENCES scanner.reachability_drift_results(id),
|
||||
sink_node_id TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
sink_category TEXT NOT NULL,
|
||||
direction TEXT NOT NULL, -- became_reachable|became_unreachable
|
||||
cause_kind TEXT NOT NULL,
|
||||
cause_description TEXT NOT NULL,
|
||||
cause_symbol TEXT,
|
||||
cause_file TEXT,
|
||||
cause_line INT,
|
||||
code_change_id UUID REFERENCES scanner.code_changes(id),
|
||||
compressed_path JSONB NOT NULL,
|
||||
associated_vulns JSONB,
|
||||
|
||||
CONSTRAINT drifted_sinks_unique UNIQUE (drift_result_id, sink_node_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_drifted_sinks_drift_result
|
||||
ON scanner.drifted_sinks(drift_result_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_drifted_sinks_direction
|
||||
ON scanner.drifted_sinks(direction);
|
||||
CREATE INDEX IF NOT EXISTS idx_drifted_sinks_category
|
||||
ON scanner.drifted_sinks(sink_category);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE scanner.code_changes ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE scanner.reachability_drift_results ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE scanner.drifted_sinks ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS code_changes_tenant_isolation ON scanner.code_changes;
|
||||
CREATE POLICY code_changes_tenant_isolation ON scanner.code_changes
|
||||
USING (tenant_id = scanner.current_tenant_id());
|
||||
|
||||
DROP POLICY IF EXISTS drift_results_tenant_isolation ON scanner.reachability_drift_results;
|
||||
CREATE POLICY drift_results_tenant_isolation ON scanner.reachability_drift_results
|
||||
USING (tenant_id = scanner.current_tenant_id());
|
||||
|
||||
DROP POLICY IF EXISTS drifted_sinks_tenant_isolation ON scanner.drifted_sinks;
|
||||
CREATE POLICY drifted_sinks_tenant_isolation ON scanner.drifted_sinks
|
||||
USING (tenant_id = (
|
||||
SELECT tenant_id FROM scanner.reachability_drift_results
|
||||
WHERE id = drift_result_id
|
||||
));
|
||||
|
||||
COMMENT ON TABLE scanner.code_changes IS 'AST-level code change facts for drift analysis';
|
||||
COMMENT ON TABLE scanner.reachability_drift_results IS 'Aggregate drift results per scan pair';
|
||||
COMMENT ON TABLE scanner.drifted_sinks IS 'Individual drifted sink records with causes and paths';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
| # | Task ID | Status | Description | Notes |
|
||||
|---|---------|--------|-------------|-------|
|
||||
| 1 | DRIFT-001 | TODO | Create CodeChangeFact model | With all change kinds |
|
||||
| 2 | DRIFT-002 | TODO | Create CodeChangeKind enum | 6 types |
|
||||
| 3 | DRIFT-003 | TODO | Create ReachabilityDriftResult model | Aggregate result |
|
||||
| 4 | DRIFT-004 | TODO | Create DriftedSink model | With cause and path |
|
||||
| 5 | DRIFT-005 | TODO | Create DriftDirection enum | 2 directions |
|
||||
| 6 | DRIFT-006 | TODO | Create DriftCause model | With factory methods |
|
||||
| 7 | DRIFT-007 | TODO | Create DriftCauseKind enum | 7 kinds |
|
||||
| 8 | DRIFT-008 | TODO | Create CompressedPath model | For UI display |
|
||||
| 9 | DRIFT-009 | TODO | Create PathNode model | With change flags |
|
||||
| 10 | DRIFT-010 | TODO | Implement ReachabilityDriftDetector | Core detection |
|
||||
| 11 | DRIFT-011 | TODO | Implement DriftCauseExplainer | Cause attribution |
|
||||
| 12 | DRIFT-012 | TODO | Implement ExplainUnreachable method | Reverse direction |
|
||||
| 13 | DRIFT-013 | TODO | Implement PathCompressor | Key node selection |
|
||||
| 14 | DRIFT-014 | TODO | Create Postgres migration 007 | code_changes, drift tables |
|
||||
| 15 | DRIFT-015 | TODO | Implement ICodeChangeRepository | Storage contract |
|
||||
| 16 | DRIFT-016 | TODO | Implement PostgresCodeChangeRepository | With Dapper |
|
||||
| 17 | DRIFT-017 | TODO | Implement IDriftResultRepository | Storage contract |
|
||||
| 18 | DRIFT-018 | TODO | Implement PostgresDriftResultRepository | With Dapper |
|
||||
| 19 | DRIFT-019 | TODO | Unit tests for ReachabilityDriftDetector | Various scenarios |
|
||||
| 20 | DRIFT-020 | TODO | Unit tests for DriftCauseExplainer | All cause kinds |
|
||||
| 21 | DRIFT-021 | TODO | Unit tests for PathCompressor | Compression logic |
|
||||
| 22 | DRIFT-022 | TODO | Integration tests with benchmark cases | End-to-end |
|
||||
| 23 | DRIFT-023 | TODO | Golden fixtures for drift detection | Determinism |
|
||||
| 24 | DRIFT-024 | TODO | API endpoint GET /scans/{id}/drift | Drift results |
|
||||
| 25 | DRIFT-025 | TODO | API endpoint GET /drift/{id}/sinks | Individual sinks |
|
||||
| 26 | DRIFT-026 | TODO | Integrate with MaterialRiskChangeDetector | Extend R1 rule |
|
||||
|
||||
---
|
||||
|
||||
## 3. ACCEPTANCE CRITERIA
|
||||
|
||||
### 3.1 Code Change Detection
|
||||
|
||||
- [ ] Detects added symbols
|
||||
- [ ] Detects removed symbols
|
||||
- [ ] Detects signature changes
|
||||
- [ ] Detects guard changes
|
||||
- [ ] Detects dependency changes
|
||||
- [ ] Detects visibility changes
|
||||
|
||||
### 3.2 Drift Detection
|
||||
|
||||
- [ ] Correctly identifies newly reachable sinks
|
||||
- [ ] Correctly identifies newly unreachable sinks
|
||||
- [ ] Handles graphs with different node sets
|
||||
- [ ] Handles cyclic graphs
|
||||
|
||||
### 3.3 Cause Attribution
|
||||
|
||||
- [ ] Attributes guard removal causes
|
||||
- [ ] Attributes new route causes
|
||||
- [ ] Attributes visibility escalation causes
|
||||
- [ ] Attributes dependency upgrade causes
|
||||
- [ ] Provides unknown cause for undetectable cases
|
||||
|
||||
### 3.4 Path Compression
|
||||
|
||||
- [ ] Selects appropriate key nodes
|
||||
- [ ] Marks changed nodes correctly
|
||||
- [ ] Preserves entrypoint and sink
|
||||
- [ ] Limits key nodes to max count
|
||||
|
||||
### 3.5 Integration
|
||||
|
||||
- [ ] Integrates with MaterialRiskChangeDetector
|
||||
- [ ] Extends material_risk_changes table correctly
|
||||
- [ ] API endpoints return correct data
|
||||
|
||||
---
|
||||
|
||||
## Decisions & Risks
|
||||
|
||||
| ID | Decision | Rationale |
|
||||
|----|----------|-----------|
|
||||
| DRIFT-DEC-001 | Extend existing tables, don't duplicate | Leverage scanner.material_risk_changes |
|
||||
| DRIFT-DEC-002 | Store full path optionally | Enable UI expansion without re-computation |
|
||||
| DRIFT-DEC-003 | Limit key nodes to 5 | Balance detail vs. storage |
|
||||
|
||||
| ID | Risk | Mitigation |
|
||||
|----|------|------------|
|
||||
| DRIFT-RISK-001 | Cause attribution false positives | Conservative matching, show "unknown" |
|
||||
| DRIFT-RISK-002 | Large path storage | Compression, CAS for full paths |
|
||||
| DRIFT-RISK-003 | Performance on large graphs | Caching, pre-computed reachability |
|
||||
|
||||
---
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date (UTC) | Update | Owner |
|
||||
|---|---|---|
|
||||
| 2025-12-17 | Created sprint from master plan | Agent |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- **Master Sprint**: `SPRINT_3600_0001_0001_reachability_drift_master.md`
|
||||
- **Call Graph Sprint**: `SPRINT_3600_0002_0001_call_graph_infrastructure.md`
|
||||
- **Advisory**: `17-Dec-2025 - Reachability Drift Detection.md`
|
||||
886
docs/implplan/SPRINT_3600_0004_0001_ui_evidence_chain.md
Normal file
886
docs/implplan/SPRINT_3600_0004_0001_ui_evidence_chain.md
Normal file
@@ -0,0 +1,886 @@
|
||||
# SPRINT_3600_0004_0001 - UI and Evidence Chain
|
||||
|
||||
**Status:** TODO
|
||||
**Priority:** P1 - HIGH
|
||||
**Module:** Web, Attestor
|
||||
**Working Directory:** `src/Web/StellaOps.Web/`, `src/Attestor/`
|
||||
**Estimated Effort:** Medium
|
||||
**Dependencies:** SPRINT_3600_0003_0001 (Drift Detection Engine)
|
||||
|
||||
---
|
||||
|
||||
## Topic & Scope
|
||||
|
||||
Implement the UI components and evidence chain integration for reachability drift. This sprint covers:
|
||||
- Angular Path Viewer component
|
||||
- Risk Drift Card component
|
||||
- DSSE attestation for drift results
|
||||
- CLI output enhancements
|
||||
- SARIF integration
|
||||
|
||||
---
|
||||
|
||||
## Documentation Prerequisites
|
||||
|
||||
- `docs/product-advisories/17-Dec-2025 - Reachability Drift Detection.md`
|
||||
- `docs/implplan/SPRINT_3600_0003_0001_drift_detection_engine.md`
|
||||
- `docs/modules/attestor/architecture.md`
|
||||
- `src/Web/StellaOps.Web/README.md`
|
||||
|
||||
---
|
||||
|
||||
## Wave Coordination
|
||||
|
||||
Parallel tracks:
|
||||
- Track A: Angular UI components
|
||||
- Track B: DSSE attestation
|
||||
- Track C: CLI enhancements
|
||||
|
||||
---
|
||||
|
||||
## Interlocks
|
||||
|
||||
- Depends on drift detection API from Sprint 3600.3
|
||||
- Must align with existing Console design patterns
|
||||
- Must use existing Attestor infrastructure
|
||||
|
||||
---
|
||||
|
||||
## Action Tracker
|
||||
|
||||
| Date (UTC) | Action | Owner | Notes |
|
||||
|---|---|---|---|
|
||||
| 2025-12-17 | Created sprint from master plan | Agent | Initial |
|
||||
|
||||
---
|
||||
|
||||
## 1. OBJECTIVE
|
||||
|
||||
Build the user-facing components:
|
||||
1. **Path Viewer** - Interactive call path visualization
|
||||
2. **Risk Drift Card** - Summary view for PRs/scans
|
||||
3. **Evidence Chain** - DSSE attestation linking
|
||||
4. **CLI Output** - Enhanced drift reporting
|
||||
|
||||
---
|
||||
|
||||
## 2. TECHNICAL DESIGN
|
||||
|
||||
### 2.1 Angular Path Viewer Component
|
||||
|
||||
```typescript
|
||||
// File: src/Web/StellaOps.Web/src/app/components/path-viewer/path-viewer.component.ts
|
||||
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
export interface PathNode {
|
||||
nodeId: string;
|
||||
symbol: string;
|
||||
file?: string;
|
||||
line?: number;
|
||||
package?: string;
|
||||
isChanged: boolean;
|
||||
changeKind?: string;
|
||||
}
|
||||
|
||||
export interface CompressedPath {
|
||||
entrypoint: PathNode;
|
||||
sink: PathNode;
|
||||
intermediateCount: number;
|
||||
keyNodes: PathNode[];
|
||||
fullPath?: string[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-path-viewer',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="path-viewer">
|
||||
<div class="path-header">
|
||||
<span class="path-title">{{ title }}</span>
|
||||
<button
|
||||
*ngIf="collapsible"
|
||||
class="btn-collapse"
|
||||
(click)="toggleCollapse()">
|
||||
{{ collapsed ? 'Expand' : 'Collapse' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="path-content" *ngIf="!collapsed">
|
||||
<!-- Entrypoint -->
|
||||
<div class="path-node entrypoint">
|
||||
<span class="node-icon">○</span>
|
||||
<div class="node-details">
|
||||
<span class="node-symbol">{{ path.entrypoint.symbol }}</span>
|
||||
<span class="node-location" *ngIf="path.entrypoint.file">
|
||||
{{ path.entrypoint.file }}:{{ path.entrypoint.line }}
|
||||
</span>
|
||||
<span class="node-badge entrypoint-badge">ENTRYPOINT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connector -->
|
||||
<div class="path-connector"></div>
|
||||
|
||||
<!-- Key intermediate nodes -->
|
||||
<ng-container *ngFor="let node of path.keyNodes; let i = index">
|
||||
<div
|
||||
class="path-node"
|
||||
[class.changed]="node.isChanged">
|
||||
<span class="node-icon" [class.changed-icon]="node.isChanged">
|
||||
{{ node.isChanged ? '●' : '○' }}
|
||||
</span>
|
||||
<div class="node-details">
|
||||
<span class="node-symbol">{{ node.symbol }}</span>
|
||||
<span class="node-location" *ngIf="node.file">
|
||||
{{ node.file }}:{{ node.line }}
|
||||
</span>
|
||||
<span
|
||||
class="node-badge change-badge"
|
||||
*ngIf="node.isChanged">
|
||||
{{ formatChangeKind(node.changeKind) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="path-connector"></div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Collapsed indicator -->
|
||||
<div
|
||||
class="path-collapsed-indicator"
|
||||
*ngIf="path.intermediateCount > path.keyNodes.length">
|
||||
<span>... {{ path.intermediateCount - path.keyNodes.length }} more nodes ...</span>
|
||||
<button class="btn-expand" (click)="requestFullPath()">
|
||||
Show full path
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sink -->
|
||||
<div class="path-node sink">
|
||||
<span class="node-icon sink-icon">◆</span>
|
||||
<div class="node-details">
|
||||
<span class="node-symbol">{{ path.sink.symbol }}</span>
|
||||
<span class="node-location" *ngIf="path.sink.file">
|
||||
{{ path.sink.file }}:{{ path.sink.line }}
|
||||
</span>
|
||||
<span class="node-badge sink-badge">VULNERABLE SINK</span>
|
||||
<span class="node-badge package-badge" *ngIf="path.sink.package">
|
||||
{{ path.sink.package }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="path-legend" *ngIf="showLegend && !collapsed">
|
||||
<span><span class="legend-icon">○</span> Node</span>
|
||||
<span><span class="legend-icon changed-icon">●</span> Changed</span>
|
||||
<span><span class="legend-icon sink-icon">◆</span> Sink</span>
|
||||
<span><span class="legend-line">─</span> Call</span>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styleUrls: ['./path-viewer.component.scss']
|
||||
})
|
||||
export class PathViewerComponent {
|
||||
@Input() path!: CompressedPath;
|
||||
@Input() title = 'Call Path';
|
||||
@Input() collapsible = true;
|
||||
@Input() showLegend = true;
|
||||
@Input() collapsed = false;
|
||||
|
||||
@Output() expandPath = new EventEmitter<string[]>();
|
||||
|
||||
toggleCollapse(): void {
|
||||
this.collapsed = !this.collapsed;
|
||||
}
|
||||
|
||||
requestFullPath(): void {
|
||||
if (this.path.fullPath) {
|
||||
this.expandPath.emit(this.path.fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
formatChangeKind(kind?: string): string {
|
||||
if (!kind) return 'Changed';
|
||||
return kind
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Risk Drift Card Component
|
||||
|
||||
```typescript
|
||||
// File: src/Web/StellaOps.Web/src/app/components/risk-drift-card/risk-drift-card.component.ts
|
||||
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { PathViewerComponent, CompressedPath } from '../path-viewer/path-viewer.component';
|
||||
|
||||
export interface DriftedSink {
|
||||
sinkNodeId: string;
|
||||
symbol: string;
|
||||
sinkCategory: string;
|
||||
direction: 'became_reachable' | 'became_unreachable';
|
||||
cause: DriftCause;
|
||||
path: CompressedPath;
|
||||
associatedVulns: AssociatedVuln[];
|
||||
}
|
||||
|
||||
export interface DriftCause {
|
||||
kind: string;
|
||||
description: string;
|
||||
changedSymbol?: string;
|
||||
changedFile?: string;
|
||||
changedLine?: number;
|
||||
}
|
||||
|
||||
export interface AssociatedVuln {
|
||||
cveId: string;
|
||||
epss?: number;
|
||||
cvss?: number;
|
||||
vexStatus?: string;
|
||||
packagePurl?: string;
|
||||
}
|
||||
|
||||
export interface DriftResult {
|
||||
baseScanId: string;
|
||||
headScanId: string;
|
||||
newlyReachable: DriftedSink[];
|
||||
newlyUnreachable: DriftedSink[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-risk-drift-card',
|
||||
standalone: true,
|
||||
imports: [CommonModule, PathViewerComponent],
|
||||
template: `
|
||||
<div class="risk-drift-card">
|
||||
<div class="card-header">
|
||||
<h3>Risk Drift</h3>
|
||||
<button class="btn-collapse" (click)="toggleExpand()">
|
||||
{{ expanded ? '▲' : '▼' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card-summary">
|
||||
<span class="badge new-reachable" *ngIf="result.newlyReachable.length > 0">
|
||||
+{{ result.newlyReachable.length }} new reachable paths
|
||||
</span>
|
||||
<span class="badge mitigated" *ngIf="result.newlyUnreachable.length > 0">
|
||||
-{{ result.newlyUnreachable.length }} mitigated paths
|
||||
</span>
|
||||
<span class="badge no-drift" *ngIf="!hasDrift">
|
||||
No material drift detected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-content" *ngIf="expanded">
|
||||
<!-- Newly Reachable Section -->
|
||||
<div class="drift-section new-reachable" *ngIf="result.newlyReachable.length > 0">
|
||||
<h4>New Reachable Paths</h4>
|
||||
<div
|
||||
class="drifted-sink"
|
||||
*ngFor="let sink of result.newlyReachable">
|
||||
<div class="sink-header">
|
||||
<span class="sink-route">
|
||||
{{ formatRoute(sink) }}
|
||||
</span>
|
||||
<div class="sink-badges">
|
||||
<span
|
||||
class="vuln-badge"
|
||||
*ngFor="let vuln of sink.associatedVulns">
|
||||
{{ vuln.cveId }}
|
||||
<span class="epss" *ngIf="vuln.epss">(EPSS {{ vuln.epss | number:'1.2-2' }})</span>
|
||||
</span>
|
||||
<span class="vex-badge" *ngIf="sink.associatedVulns[0]?.vexStatus">
|
||||
VEX: {{ sink.associatedVulns[0].vexStatus }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sink-cause">
|
||||
<strong>Cause:</strong> {{ sink.cause.description }}
|
||||
</div>
|
||||
|
||||
<app-path-viewer
|
||||
[path]="sink.path"
|
||||
[title]="''"
|
||||
[showLegend]="false"
|
||||
[collapsed]="true">
|
||||
</app-path-viewer>
|
||||
|
||||
<div class="sink-actions">
|
||||
<button class="btn-action" (click)="viewPath.emit(sink)">
|
||||
View Path
|
||||
</button>
|
||||
<button class="btn-action" (click)="quarantine.emit(sink)">
|
||||
Quarantine Route
|
||||
</button>
|
||||
<button class="btn-action" (click)="pinVersion.emit(sink)">
|
||||
Pin Version
|
||||
</button>
|
||||
<button class="btn-action secondary" (click)="addException.emit(sink)">
|
||||
Add Exception
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mitigated Section -->
|
||||
<div class="drift-section mitigated" *ngIf="result.newlyUnreachable.length > 0">
|
||||
<h4>Mitigated Paths</h4>
|
||||
<div
|
||||
class="drifted-sink mitigated"
|
||||
*ngFor="let sink of result.newlyUnreachable">
|
||||
<div class="sink-header">
|
||||
<span class="sink-route">
|
||||
{{ formatRoute(sink) }}
|
||||
</span>
|
||||
<div class="sink-badges">
|
||||
<span
|
||||
class="vuln-badge resolved"
|
||||
*ngFor="let vuln of sink.associatedVulns">
|
||||
{{ vuln.cveId }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sink-cause">
|
||||
<strong>Reason:</strong> {{ sink.cause.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styleUrls: ['./risk-drift-card.component.scss']
|
||||
})
|
||||
export class RiskDriftCardComponent {
|
||||
@Input() result!: DriftResult;
|
||||
@Input() expanded = true;
|
||||
|
||||
@Output() viewPath = new EventEmitter<DriftedSink>();
|
||||
@Output() quarantine = new EventEmitter<DriftedSink>();
|
||||
@Output() pinVersion = new EventEmitter<DriftedSink>();
|
||||
@Output() addException = new EventEmitter<DriftedSink>();
|
||||
|
||||
get hasDrift(): boolean {
|
||||
return this.result.newlyReachable.length > 0 ||
|
||||
this.result.newlyUnreachable.length > 0;
|
||||
}
|
||||
|
||||
toggleExpand(): void {
|
||||
this.expanded = !this.expanded;
|
||||
}
|
||||
|
||||
formatRoute(sink: DriftedSink): string {
|
||||
const entrypoint = sink.path.entrypoint.symbol;
|
||||
const sinkSymbol = sink.path.sink.symbol;
|
||||
const intermediateCount = sink.path.intermediateCount;
|
||||
|
||||
if (intermediateCount <= 2) {
|
||||
return `${entrypoint} → ${sinkSymbol}`;
|
||||
}
|
||||
return `${entrypoint} → ... → ${sinkSymbol}`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 DSSE Predicate for Drift
|
||||
|
||||
```csharp
|
||||
// File: src/Attestor/StellaOps.Attestor.Types/Predicates/ReachabilityDriftPredicate.cs
|
||||
|
||||
namespace StellaOps.Attestor.Types.Predicates;
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE predicate for reachability drift attestation.
|
||||
/// predicateType: stellaops.dev/predicates/reachability-drift@v1
|
||||
/// </summary>
|
||||
public sealed record ReachabilityDriftPredicate
|
||||
{
|
||||
public const string PredicateType = "stellaops.dev/predicates/reachability-drift@v1";
|
||||
|
||||
[JsonPropertyName("baseImage")]
|
||||
public required ImageReference BaseImage { get; init; }
|
||||
|
||||
[JsonPropertyName("targetImage")]
|
||||
public required ImageReference TargetImage { get; init; }
|
||||
|
||||
[JsonPropertyName("baseScanId")]
|
||||
public required string BaseScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("headScanId")]
|
||||
public required string HeadScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("drift")]
|
||||
public required DriftSummary Drift { get; init; }
|
||||
|
||||
[JsonPropertyName("analysis")]
|
||||
public required AnalysisMetadata Analysis { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ImageReference
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DriftSummary
|
||||
{
|
||||
[JsonPropertyName("newlyReachableCount")]
|
||||
public required int NewlyReachableCount { get; init; }
|
||||
|
||||
[JsonPropertyName("newlyUnreachableCount")]
|
||||
public required int NewlyUnreachableCount { get; init; }
|
||||
|
||||
[JsonPropertyName("newlyReachable")]
|
||||
public required ImmutableArray<DriftedSinkSummary> NewlyReachable { get; init; }
|
||||
|
||||
[JsonPropertyName("newlyUnreachable")]
|
||||
public required ImmutableArray<DriftedSinkSummary> NewlyUnreachable { get; init; }
|
||||
}
|
||||
|
||||
public sealed record DriftedSinkSummary
|
||||
{
|
||||
[JsonPropertyName("sinkNodeId")]
|
||||
public required string SinkNodeId { get; init; }
|
||||
|
||||
[JsonPropertyName("symbol")]
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
[JsonPropertyName("sinkCategory")]
|
||||
public required string SinkCategory { get; init; }
|
||||
|
||||
[JsonPropertyName("causeKind")]
|
||||
public required string CauseKind { get; init; }
|
||||
|
||||
[JsonPropertyName("causeDescription")]
|
||||
public required string CauseDescription { get; init; }
|
||||
|
||||
[JsonPropertyName("associatedCves")]
|
||||
public ImmutableArray<string> AssociatedCves { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record AnalysisMetadata
|
||||
{
|
||||
[JsonPropertyName("analyzedAt")]
|
||||
public required DateTimeOffset AnalyzedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("scanner")]
|
||||
public required ScannerInfo Scanner { get; init; }
|
||||
|
||||
[JsonPropertyName("baseGraphDigest")]
|
||||
public required string BaseGraphDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("headGraphDigest")]
|
||||
public required string HeadGraphDigest { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ScannerInfo
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[JsonPropertyName("ruleset")]
|
||||
public string? Ruleset { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 CLI Output Enhancement
|
||||
|
||||
```csharp
|
||||
// File: src/Cli/StellaOps.Cli/Commands/DriftCommand.cs
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Spectre.Console;
|
||||
|
||||
public class DriftCommand : Command
|
||||
{
|
||||
public DriftCommand() : base("drift", "Detect reachability drift between image versions")
|
||||
{
|
||||
var baseOption = new Option<string>("--base", "Base image reference") { IsRequired = true };
|
||||
var targetOption = new Option<string>("--target", "Target image reference") { IsRequired = true };
|
||||
var formatOption = new Option<string>("--format", () => "table", "Output format (table|json|sarif)");
|
||||
var verboseOption = new Option<bool>("--verbose", () => false, "Show detailed path information");
|
||||
|
||||
AddOption(baseOption);
|
||||
AddOption(targetOption);
|
||||
AddOption(formatOption);
|
||||
AddOption(verboseOption);
|
||||
|
||||
this.SetHandler(ExecuteAsync, baseOption, targetOption, formatOption, verboseOption);
|
||||
}
|
||||
|
||||
private async Task ExecuteAsync(string baseImage, string targetImage, string format, bool verbose)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[bold]Analyzing drift:[/] {baseImage} → {targetImage}");
|
||||
|
||||
// TODO: Call drift detection service
|
||||
var result = await DetectDriftAsync(baseImage, targetImage);
|
||||
|
||||
switch (format.ToLowerInvariant())
|
||||
{
|
||||
case "json":
|
||||
OutputJson(result);
|
||||
break;
|
||||
case "sarif":
|
||||
OutputSarif(result);
|
||||
break;
|
||||
default:
|
||||
OutputTable(result, verbose);
|
||||
break;
|
||||
}
|
||||
|
||||
// Exit code based on drift
|
||||
Environment.ExitCode = result.TotalDriftCount switch
|
||||
{
|
||||
0 => 0, // No drift
|
||||
> 0 when result.NewlyReachable.Length > 0 => 1, // New reachable (info)
|
||||
_ => 0 // Only mitigated
|
||||
};
|
||||
}
|
||||
|
||||
private void OutputTable(ReachabilityDriftResult result, bool verbose)
|
||||
{
|
||||
if (result.NewlyReachable.Length > 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("\n[red bold]NEW REACHABLE PATHS[/]");
|
||||
|
||||
var table = new Table();
|
||||
table.AddColumn("Sink");
|
||||
table.AddColumn("Category");
|
||||
table.AddColumn("Cause");
|
||||
if (verbose)
|
||||
{
|
||||
table.AddColumn("Path");
|
||||
}
|
||||
table.AddColumn("CVEs");
|
||||
|
||||
foreach (var sink in result.NewlyReachable)
|
||||
{
|
||||
var row = new List<string>
|
||||
{
|
||||
sink.Symbol,
|
||||
sink.SinkCategory.ToString(),
|
||||
sink.Cause.Description
|
||||
};
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
row.Add($"{sink.Path.Entrypoint.Symbol} → ... → {sink.Path.Sink.Symbol}");
|
||||
}
|
||||
|
||||
row.Add(string.Join(", ", sink.AssociatedVulns.Select(v => v.CveId)));
|
||||
|
||||
table.AddRow(row.ToArray());
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
}
|
||||
|
||||
if (result.NewlyUnreachable.Length > 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("\n[green bold]MITIGATED PATHS[/]");
|
||||
|
||||
var table = new Table();
|
||||
table.AddColumn("Sink");
|
||||
table.AddColumn("Category");
|
||||
table.AddColumn("Reason");
|
||||
|
||||
foreach (var sink in result.NewlyUnreachable)
|
||||
{
|
||||
table.AddRow(
|
||||
sink.Symbol,
|
||||
sink.SinkCategory.ToString(),
|
||||
sink.Cause.Description);
|
||||
}
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
}
|
||||
|
||||
if (result.TotalDriftCount == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("\n[green]No material reachability drift detected.[/]");
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"\n[bold]Summary:[/] +{result.NewlyReachable.Length} reachable, -{result.NewlyUnreachable.Length} mitigated");
|
||||
}
|
||||
|
||||
private void OutputJson(ReachabilityDriftResult result)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
Console.WriteLine(json);
|
||||
}
|
||||
|
||||
private void OutputSarif(ReachabilityDriftResult result)
|
||||
{
|
||||
// Generate SARIF 2.1.0 output
|
||||
// TODO: Implement SARIF generation
|
||||
throw new NotImplementedException("SARIF output to be implemented");
|
||||
}
|
||||
|
||||
private Task<ReachabilityDriftResult> DetectDriftAsync(string baseImage, string targetImage)
|
||||
{
|
||||
// TODO: Implement actual drift detection
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 SARIF Integration
|
||||
|
||||
```csharp
|
||||
// File: src/Scanner/__Libraries/StellaOps.Scanner.ReachabilityDrift/Output/DriftSarifGenerator.cs
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift.Output;
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
/// <summary>
|
||||
/// Generates SARIF 2.1.0 output for drift results.
|
||||
/// </summary>
|
||||
public sealed class DriftSarifGenerator
|
||||
{
|
||||
private const string ToolName = "StellaOps.ReachabilityDrift";
|
||||
private const string ToolVersion = "1.0.0";
|
||||
|
||||
public JsonDocument Generate(ReachabilityDriftResult result)
|
||||
{
|
||||
var rules = new List<object>();
|
||||
var results = new List<object>();
|
||||
|
||||
// Add rules for each drift type
|
||||
rules.Add(new
|
||||
{
|
||||
id = "RDRIFT001",
|
||||
name = "NewlyReachableSink",
|
||||
shortDescription = new { text = "Vulnerable sink became reachable" },
|
||||
fullDescription = new { text = "A vulnerable code sink became reachable from application entrypoints due to code changes." },
|
||||
defaultConfiguration = new { level = "error" }
|
||||
});
|
||||
|
||||
rules.Add(new
|
||||
{
|
||||
id = "RDRIFT002",
|
||||
name = "MitigatedSink",
|
||||
shortDescription = new { text = "Vulnerable sink became unreachable" },
|
||||
fullDescription = new { text = "A vulnerable code sink is no longer reachable from application entrypoints." },
|
||||
defaultConfiguration = new { level = "note" }
|
||||
});
|
||||
|
||||
// Add results for newly reachable sinks
|
||||
foreach (var sink in result.NewlyReachable)
|
||||
{
|
||||
results.Add(new
|
||||
{
|
||||
ruleId = "RDRIFT001",
|
||||
level = "error",
|
||||
message = new
|
||||
{
|
||||
text = $"Sink {sink.Symbol} became reachable. Cause: {sink.Cause.Description}"
|
||||
},
|
||||
locations = sink.Cause.ChangedFile is not null ? new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
physicalLocation = new
|
||||
{
|
||||
artifactLocation = new { uri = sink.Cause.ChangedFile },
|
||||
region = new { startLine = sink.Cause.ChangedLine ?? 1 }
|
||||
}
|
||||
}
|
||||
} : Array.Empty<object>(),
|
||||
properties = new
|
||||
{
|
||||
sinkCategory = sink.SinkCategory.ToString(),
|
||||
causeKind = sink.Cause.Kind.ToString(),
|
||||
associatedVulns = sink.AssociatedVulns.Select(v => v.CveId).ToArray()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add results for mitigated sinks
|
||||
foreach (var sink in result.NewlyUnreachable)
|
||||
{
|
||||
results.Add(new
|
||||
{
|
||||
ruleId = "RDRIFT002",
|
||||
level = "note",
|
||||
message = new
|
||||
{
|
||||
text = $"Sink {sink.Symbol} is no longer reachable. Reason: {sink.Cause.Description}"
|
||||
},
|
||||
properties = new
|
||||
{
|
||||
sinkCategory = sink.SinkCategory.ToString(),
|
||||
causeKind = sink.Cause.Kind.ToString()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var sarif = new
|
||||
{
|
||||
version = "2.1.0",
|
||||
schema = "https://json.schemastore.org/sarif-2.1.0.json",
|
||||
runs = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tool = new
|
||||
{
|
||||
driver = new
|
||||
{
|
||||
name = ToolName,
|
||||
version = ToolVersion,
|
||||
informationUri = "https://stellaops.dev/docs/reachability-drift",
|
||||
rules = rules.ToArray()
|
||||
}
|
||||
},
|
||||
results = results.ToArray(),
|
||||
invocations = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
executionSuccessful = true,
|
||||
endTimeUtc = result.DetectedAt.UtcDateTime.ToString("o")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(sarif, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
return JsonDocument.Parse(json);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
| # | Task ID | Status | Description | Notes |
|
||||
|---|---------|--------|-------------|-------|
|
||||
| 1 | UI-001 | TODO | Create PathNode TypeScript interface | Angular model |
|
||||
| 2 | UI-002 | TODO | Create CompressedPath TypeScript interface | Angular model |
|
||||
| 3 | UI-003 | TODO | Create PathViewerComponent | Core visualization |
|
||||
| 4 | UI-004 | TODO | Style PathViewerComponent | SCSS styling |
|
||||
| 5 | UI-005 | TODO | Create DriftedSink TypeScript interface | Angular model |
|
||||
| 6 | UI-006 | TODO | Create DriftResult TypeScript interface | Angular model |
|
||||
| 7 | UI-007 | TODO | Create RiskDriftCardComponent | Summary card |
|
||||
| 8 | UI-008 | TODO | Style RiskDriftCardComponent | SCSS styling |
|
||||
| 9 | UI-009 | TODO | Create drift API service | Angular HTTP service |
|
||||
| 10 | UI-010 | TODO | Integrate PathViewer into scan details | Page integration |
|
||||
| 11 | UI-011 | TODO | Integrate RiskDriftCard into PR view | Page integration |
|
||||
| 12 | UI-012 | TODO | Unit tests for PathViewerComponent | Jest tests |
|
||||
| 13 | UI-013 | TODO | Unit tests for RiskDriftCardComponent | Jest tests |
|
||||
| 14 | UI-014 | TODO | Create ReachabilityDriftPredicate model | DSSE predicate |
|
||||
| 15 | UI-015 | TODO | Register predicate in Attestor | Type registration |
|
||||
| 16 | UI-016 | TODO | Implement drift attestation service | DSSE signing |
|
||||
| 17 | UI-017 | TODO | Add attestation to drift API | API integration |
|
||||
| 18 | UI-018 | TODO | Unit tests for attestation | Predicate validation |
|
||||
| 19 | UI-019 | TODO | Create DriftCommand for CLI | CLI command |
|
||||
| 20 | UI-020 | TODO | Implement table output | Spectre.Console |
|
||||
| 21 | UI-021 | TODO | Implement JSON output | JSON serialization |
|
||||
| 22 | UI-022 | TODO | Create DriftSarifGenerator | SARIF 2.1.0 |
|
||||
| 23 | UI-023 | TODO | Implement SARIF output for CLI | CLI integration |
|
||||
| 24 | UI-024 | TODO | Update CLI documentation | docs/cli/ |
|
||||
| 25 | UI-025 | TODO | Integration tests for CLI | End-to-end |
|
||||
|
||||
---
|
||||
|
||||
## 3. ACCEPTANCE CRITERIA
|
||||
|
||||
### 3.1 Path Viewer Component
|
||||
|
||||
- [ ] Displays entrypoint and sink nodes
|
||||
- [ ] Shows key intermediate nodes
|
||||
- [ ] Highlights changed nodes
|
||||
- [ ] Supports collapse/expand
|
||||
- [ ] Shows legend
|
||||
- [ ] Handles paths of various lengths
|
||||
|
||||
### 3.2 Risk Drift Card Component
|
||||
|
||||
- [ ] Shows summary badges
|
||||
- [ ] Lists newly reachable paths
|
||||
- [ ] Lists mitigated paths
|
||||
- [ ] Shows associated vulnerabilities
|
||||
- [ ] Provides action buttons
|
||||
- [ ] Supports expand/collapse
|
||||
|
||||
### 3.3 DSSE Attestation
|
||||
|
||||
- [ ] Generates valid predicate
|
||||
- [ ] Signs with DSSE envelope
|
||||
- [ ] Includes graph digests
|
||||
- [ ] Includes all drift details
|
||||
- [ ] Passes schema validation
|
||||
|
||||
### 3.4 CLI Output
|
||||
|
||||
- [ ] Table output is readable
|
||||
- [ ] JSON output is valid
|
||||
- [ ] SARIF output passes schema validation
|
||||
- [ ] Exit codes are correct
|
||||
- [ ] Verbose mode shows paths
|
||||
|
||||
---
|
||||
|
||||
## Decisions & Risks
|
||||
|
||||
| ID | Decision | Rationale |
|
||||
|----|----------|-----------|
|
||||
| UI-DEC-001 | Standalone Angular components | Reusability across pages |
|
||||
| UI-DEC-002 | SARIF rule IDs prefixed with RDRIFT | Distinguish from other SARIF sources |
|
||||
| UI-DEC-003 | CLI uses Spectre.Console | Consistent with existing CLI style |
|
||||
|
||||
| ID | Risk | Mitigation |
|
||||
|----|------|------------|
|
||||
| UI-RISK-001 | Large paths slow UI | Lazy loading, pagination |
|
||||
| UI-RISK-002 | SARIF compatibility issues | Test against multiple consumers |
|
||||
| UI-RISK-003 | Attestation size limits | Summary only, link to full data |
|
||||
|
||||
---
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Date (UTC) | Update | Owner |
|
||||
|---|---|---|
|
||||
| 2025-12-17 | Created sprint from master plan | Agent |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- **Master Sprint**: `SPRINT_3600_0001_0001_reachability_drift_master.md`
|
||||
- **Drift Detection Sprint**: `SPRINT_3600_0003_0001_drift_detection_engine.md`
|
||||
- **Advisory**: `17-Dec-2025 - Reachability Drift Detection.md`
|
||||
- **Angular Style Guide**: https://angular.io/guide/styleguide
|
||||
- **SARIF 2.1.0 Spec**: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
|
||||
241
docs/implplan/SPRINT_3700_0001_0001_triage_db_schema.md
Normal file
241
docs/implplan/SPRINT_3700_0001_0001_triage_db_schema.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# SPRINT_3700_0001_0001_triage_db_schema
|
||||
|
||||
**Epic:** Triage Infrastructure
|
||||
**Module:** Scanner
|
||||
**Working Directory:** `src/Scanner/__Libraries/StellaOps.Scanner.Triage/`
|
||||
**Status:** TODO
|
||||
**Created:** 2025-12-17
|
||||
**Target Completion:** TBD
|
||||
**Depends On:** None
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Implement the PostgreSQL database schema for the Narrative-First Triage UX system, including all tables, enums, indexes, and views required to support the triage workflow.
|
||||
|
||||
### 1.1 Deliverables
|
||||
|
||||
1. PostgreSQL migration script (`triage_schema.sql`)
|
||||
2. EF Core entities for all triage tables
|
||||
3. `TriageDbContext` with proper configuration
|
||||
4. Integration tests using Testcontainers
|
||||
5. Performance validation for indexed queries
|
||||
|
||||
### 1.2 Dependencies
|
||||
|
||||
- PostgreSQL >= 16
|
||||
- EF Core 9.0
|
||||
- `StellaOps.Infrastructure.Postgres` for base patterns
|
||||
|
||||
---
|
||||
|
||||
## 2. Delivery Tracker
|
||||
|
||||
| ID | Task | Owner | Status | Notes |
|
||||
|----|------|-------|--------|-------|
|
||||
| T1 | Create migration script from `docs/db/triage_schema.sql` | — | TODO | |
|
||||
| T2 | Create PostgreSQL enums (7 types) | — | TODO | See schema |
|
||||
| T3 | Create `TriageFinding` entity | — | TODO | |
|
||||
| T4 | Create `TriageEffectiveVex` entity | — | TODO | |
|
||||
| T5 | Create `TriageReachabilityResult` entity | — | TODO | |
|
||||
| T6 | Create `TriageRiskResult` entity | — | TODO | |
|
||||
| T7 | Create `TriageDecision` entity | — | TODO | |
|
||||
| T8 | Create `TriageEvidenceArtifact` entity | — | TODO | |
|
||||
| T9 | Create `TriageSnapshot` entity | — | TODO | |
|
||||
| T10 | Create `TriageDbContext` with Fluent API | — | TODO | |
|
||||
| T11 | Implement `v_triage_case_current` view mapping | — | TODO | |
|
||||
| T12 | Add performance indexes | — | TODO | |
|
||||
| T13 | Write integration tests with Testcontainers | — | TODO | |
|
||||
| T14 | Validate query performance (explain analyze) | — | TODO | |
|
||||
|
||||
---
|
||||
|
||||
## 3. Task Details
|
||||
|
||||
### T1: Create migration script
|
||||
|
||||
**Location:** `src/Scanner/__Libraries/StellaOps.Scanner.Triage/Migrations/`
|
||||
|
||||
Use the schema from `docs/db/triage_schema.sql` as the authoritative source. Create an EF Core migration that matches.
|
||||
|
||||
### T2-T9: Entity Classes
|
||||
|
||||
Create entities in `src/Scanner/__Libraries/StellaOps.Scanner.Triage/Entities/`
|
||||
|
||||
```csharp
|
||||
// Example structure
|
||||
namespace StellaOps.Scanner.Triage.Entities;
|
||||
|
||||
public enum TriageLane
|
||||
{
|
||||
Active,
|
||||
Blocked,
|
||||
NeedsException,
|
||||
MutedReach,
|
||||
MutedVex,
|
||||
Compensated
|
||||
}
|
||||
|
||||
public enum TriageVerdict
|
||||
{
|
||||
Ship,
|
||||
Block,
|
||||
Exception
|
||||
}
|
||||
|
||||
public sealed record TriageFinding
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid AssetId { get; init; }
|
||||
public Guid? EnvironmentId { get; init; }
|
||||
public required string AssetLabel { get; init; }
|
||||
public required string Purl { get; init; }
|
||||
public string? CveId { get; init; }
|
||||
public string? RuleId { get; init; }
|
||||
public DateTimeOffset FirstSeenAt { get; init; }
|
||||
public DateTimeOffset LastSeenAt { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
### T10: DbContext Configuration
|
||||
|
||||
```csharp
|
||||
public sealed class TriageDbContext : DbContext
|
||||
{
|
||||
public DbSet<TriageFinding> Findings => Set<TriageFinding>();
|
||||
public DbSet<TriageEffectiveVex> EffectiveVex => Set<TriageEffectiveVex>();
|
||||
public DbSet<TriageReachabilityResult> ReachabilityResults => Set<TriageReachabilityResult>();
|
||||
public DbSet<TriageRiskResult> RiskResults => Set<TriageRiskResult>();
|
||||
public DbSet<TriageDecision> Decisions => Set<TriageDecision>();
|
||||
public DbSet<TriageEvidenceArtifact> EvidenceArtifacts => Set<TriageEvidenceArtifact>();
|
||||
public DbSet<TriageSnapshot> Snapshots => Set<TriageSnapshot>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// Configure PostgreSQL enums
|
||||
modelBuilder.HasPostgresEnum<TriageLane>("triage_lane");
|
||||
modelBuilder.HasPostgresEnum<TriageVerdict>("triage_verdict");
|
||||
// ... more enums
|
||||
|
||||
// Configure entities
|
||||
modelBuilder.Entity<TriageFinding>(entity =>
|
||||
{
|
||||
entity.ToTable("triage_finding");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.LastSeenAt).IsDescending();
|
||||
// ... more configuration
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### T11: View Mapping
|
||||
|
||||
Map the `v_triage_case_current` view as a keyless entity:
|
||||
|
||||
```csharp
|
||||
[Keyless]
|
||||
public sealed record TriageCaseCurrent
|
||||
{
|
||||
public Guid CaseId { get; init; }
|
||||
public Guid AssetId { get; init; }
|
||||
// ... all view columns
|
||||
}
|
||||
|
||||
// In DbContext
|
||||
modelBuilder.Entity<TriageCaseCurrent>()
|
||||
.ToView("v_triage_case_current")
|
||||
.HasNoKey();
|
||||
```
|
||||
|
||||
### T13: Integration Tests
|
||||
|
||||
```csharp
|
||||
public class TriageSchemaTests : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:16-alpine")
|
||||
.Build();
|
||||
|
||||
[Fact]
|
||||
public async Task Schema_Creates_Successfully()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
|
||||
// Verify tables exist
|
||||
var tables = await context.Database.SqlQuery<string>(
|
||||
$"SELECT tablename FROM pg_tables WHERE schemaname = 'public'")
|
||||
.ToListAsync();
|
||||
|
||||
Assert.Contains("triage_finding", tables);
|
||||
Assert.Contains("triage_decision", tables);
|
||||
// ... more assertions
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task View_Returns_Correct_Columns()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
|
||||
// Insert test data
|
||||
var finding = new TriageFinding { /* ... */ };
|
||||
context.Findings.Add(finding);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// Query view
|
||||
var cases = await context.Set<TriageCaseCurrent>().ToListAsync();
|
||||
Assert.Single(cases);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Decisions & Risks
|
||||
|
||||
### 4.1 Decisions
|
||||
|
||||
| Decision | Rationale | Date |
|
||||
|----------|-----------|------|
|
||||
| Use PostgreSQL enums | Type safety, smaller storage | 2025-12-17 |
|
||||
| Use `DISTINCT ON` in view | Efficient "latest" queries | 2025-12-17 |
|
||||
| Store explanation as JSONB | Flexible schema for lattice output | 2025-12-17 |
|
||||
|
||||
### 4.2 Risks
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Enum changes require migration | Medium | Use versioned enums, add-only pattern |
|
||||
| View performance on large datasets | High | Monitor, add materialized view if needed |
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance Criteria (Sprint)
|
||||
|
||||
- [ ] All 8 tables created with correct constraints
|
||||
- [ ] All 7 enums registered in PostgreSQL
|
||||
- [ ] View `v_triage_case_current` returns correct data
|
||||
- [ ] Indexes created and verified with EXPLAIN ANALYZE
|
||||
- [ ] Integration tests pass with Testcontainers
|
||||
- [ ] No circular dependencies in foreign keys
|
||||
- [ ] Migration is idempotent (can run multiple times)
|
||||
|
||||
---
|
||||
|
||||
## 6. Execution Log
|
||||
|
||||
| Date | Update | Owner |
|
||||
|------|--------|-------|
|
||||
| 2025-12-17 | Sprint file created | Claude |
|
||||
|
||||
---
|
||||
|
||||
## 7. Reference Files
|
||||
|
||||
- Schema definition: `docs/db/triage_schema.sql`
|
||||
- UX Guide: `docs/ux/TRIAGE_UX_GUIDE.md`
|
||||
- API Contract: `docs/api/triage.contract.v1.md`
|
||||
- Advisory: `docs/product-advisories/unprocessed/16-Dec-2025 - Reimagining Proof-Linked UX in Security Workflows.md`
|
||||
@@ -0,0 +1,395 @@
|
||||
# Reachability Drift Detection
|
||||
|
||||
**Date**: 2025-12-17
|
||||
**Status**: ANALYZED - Ready for Implementation Planning
|
||||
**Related Advisories**:
|
||||
- 14-Dec-2025 - Smart-Diff Technical Reference
|
||||
- 14-Dec-2025 - Reachability Analysis Technical Reference
|
||||
|
||||
---
|
||||
|
||||
## 1. EXECUTIVE SUMMARY
|
||||
|
||||
This advisory proposes extending StellaOps' Smart-Diff capabilities to detect **reachability drift** - changes in whether vulnerable code paths are reachable from application entry points between container image versions.
|
||||
|
||||
**Core Insight**: Raw diffs don't equal risk. Most changed lines don't matter for exploitability. Reachability drift detection fuses **call-stack reachability graphs** with **Smart-Diff metadata** to flag only paths that went from **unreachable to reachable** (or vice-versa), tied to **SBOM components** and **VEX statements**.
|
||||
|
||||
---
|
||||
|
||||
## 2. GAP ANALYSIS vs EXISTING INFRASTRUCTURE
|
||||
|
||||
### 2.1 What Already Exists (Leverage Points)
|
||||
|
||||
| Component | Location | Status |
|
||||
|-----------|----------|--------|
|
||||
| `MaterialRiskChangeDetector` | `Scanner.SmartDiff.Detection` | DONE - R1-R4 rules |
|
||||
| `VexCandidateEmitter` | `Scanner.SmartDiff.Detection` | DONE - Absent API detection |
|
||||
| `ReachabilityGateBridge` | `Scanner.SmartDiff.Detection` | DONE - Lattice to 3-bit |
|
||||
| `ReachabilitySignal` | `Signals.Contracts` | DONE - Call path model |
|
||||
| `ReachabilityLatticeState` | `Signals.Contracts.Evidence` | DONE - 5-state enum |
|
||||
| `CallPath`, `CallPathNode` | `Signals.Contracts.Evidence` | DONE - Path representation |
|
||||
| `ReachabilityEvidenceChain` | `Signals.Contracts.Evidence` | DONE - Proof chain |
|
||||
| `vex.graph_nodes/edges` | DB Schema | DONE - Graph storage |
|
||||
| `scanner.risk_state_snapshots` | DB Schema | DONE - State storage |
|
||||
| `scanner.material_risk_changes` | DB Schema | DONE - Change storage |
|
||||
| `FnDriftCalculator` | `Scanner.Core.Drift` | DONE - Classification drift |
|
||||
| `SarifOutputGenerator` | `Scanner.SmartDiff.Output` | DONE - CI output |
|
||||
| Reachability Benchmark | `bench/reachability-benchmark/` | DONE - Ground truth cases |
|
||||
| Language Analyzers | `Scanner.Analyzers.Lang.*` | PARTIAL - Package detection, limited call graph |
|
||||
|
||||
### 2.2 What's Missing (New Implementation Required)
|
||||
|
||||
| Component | Advisory Ref | Gap Description |
|
||||
|-----------|-------------|-----------------|
|
||||
| **Call Graph Extractor (.NET)** | §7 C# Roslyn | No MSBuildWorkspace/Roslyn analysis exists |
|
||||
| **Call Graph Extractor (Go)** | §7 Go SSA | No golang.org/x/tools/go/ssa integration |
|
||||
| **Call Graph Extractor (Java)** | §7 | No Soot/WALA integration |
|
||||
| **Call Graph Extractor (Node)** | §7 | No @babel/traverse integration |
|
||||
| **`scanner.code_changes` table** | §4 Smart-Diff | AST-level diff facts not persisted |
|
||||
| **Drift Cause Explainer** | §6 Timeline | No causal attribution on path nodes |
|
||||
| **Path Viewer UI** | §UX | No Angular component for call path visualization |
|
||||
| **Cross-scan Function-level Drift** | §6 | State drift exists, function-level doesn't |
|
||||
| **Entrypoint Discovery (per-framework)** | §3 | Limited beyond package.json/manifest parsing |
|
||||
|
||||
### 2.3 Terminology Mapping
|
||||
|
||||
| Advisory Term | StellaOps Equivalent | Notes |
|
||||
|--------------|---------------------|-------|
|
||||
| `commit_sha` | `scan_id` | StellaOps is image-centric, not commit-centric |
|
||||
| `call_node` | `vex.graph_nodes` | Existing schema, extend don't duplicate |
|
||||
| `call_edge` | `vex.graph_edges` | Existing schema |
|
||||
| `reachability_drift` | `scanner.material_risk_changes` | Add `cause`, `path_nodes` columns |
|
||||
| Risk Drift | Material Risk Change | Existing term is more precise |
|
||||
| Router, Signals | Signals module only | Router module is not implemented |
|
||||
|
||||
---
|
||||
|
||||
## 3. RECOMMENDED IMPLEMENTATION PATH
|
||||
|
||||
### 3.1 What to Ship (Delta from Current State)
|
||||
|
||||
```
|
||||
NEW TABLES:
|
||||
├── scanner.code_changes # AST-level diff facts
|
||||
└── scanner.call_graph_snapshots # Per-scan call graph cache
|
||||
|
||||
NEW COLUMNS:
|
||||
├── scanner.material_risk_changes.cause # TEXT - "guard_removed", "new_route", etc.
|
||||
├── scanner.material_risk_changes.path_nodes # JSONB - Compressed path representation
|
||||
└── scanner.material_risk_changes.base_scan_id # UUID - For cross-scan comparison
|
||||
|
||||
NEW SERVICES:
|
||||
├── CallGraphExtractor.DotNet # Roslyn-based for .NET projects
|
||||
├── CallGraphExtractor.Node # AST-based for Node.js
|
||||
├── DriftCauseExplainer # Attribute causes to code changes
|
||||
└── PathCompressor # Compress paths for storage/UI
|
||||
|
||||
NEW UI:
|
||||
└── PathViewerComponent # Angular component for call path visualization
|
||||
```
|
||||
|
||||
### 3.2 What NOT to Ship (Avoid Duplication)
|
||||
|
||||
- **Don't create `call_node`/`call_edge` tables** - Use existing `vex.graph_nodes`/`vex.graph_edges`
|
||||
- **Don't add `commit_sha` columns** - Use `scan_id` consistently
|
||||
- **Don't build React components** - Angular v17 is the stack
|
||||
|
||||
### 3.3 Use Valkey for Graph Caching
|
||||
|
||||
Valkey is already integrated in `Router.Gateway.RateLimit`. Use it for:
|
||||
- **Call graph snapshot caching** - Fast cross-instance lookups
|
||||
- **Reachability result caching** - Avoid recomputation
|
||||
- **Key pattern**: `stella:callgraph:{scan_id}:{lang}:{digest}`
|
||||
|
||||
```yaml
|
||||
# Configuration pattern (align with existing Router rate limiting)
|
||||
reachability:
|
||||
valkey_connection: "localhost:6379"
|
||||
valkey_bucket: "stella-reachability"
|
||||
cache_ttl_hours: 24
|
||||
circuit_breaker:
|
||||
failure_threshold: 5
|
||||
timeout_seconds: 30
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. TECHNICAL DESIGN
|
||||
|
||||
### 4.1 Call Graph Extraction Model
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Per-scan call graph snapshot for drift comparison.
|
||||
/// </summary>
|
||||
public sealed record CallGraphSnapshot
|
||||
{
|
||||
public required string ScanId { get; init; }
|
||||
public required string GraphDigest { get; init; } // Content hash
|
||||
public required string Language { get; init; }
|
||||
public required DateTimeOffset ExtractedAt { get; init; }
|
||||
public required ImmutableArray<CallGraphNode> Nodes { get; init; }
|
||||
public required ImmutableArray<CallGraphEdge> Edges { get; init; }
|
||||
public required ImmutableArray<string> EntrypointIds { get; init; }
|
||||
}
|
||||
|
||||
public sealed record CallGraphNode
|
||||
{
|
||||
public required string NodeId { get; init; } // Stable identifier
|
||||
public required string Symbol { get; init; } // Fully qualified name
|
||||
public required string File { get; init; }
|
||||
public required int Line { get; init; }
|
||||
public required string Package { get; init; }
|
||||
public required string Visibility { get; init; } // public/internal/private
|
||||
public required bool IsEntrypoint { get; init; }
|
||||
public required bool IsSink { get; init; }
|
||||
public string? SinkCategory { get; init; } // CMD_EXEC, SQL_RAW, etc.
|
||||
}
|
||||
|
||||
public sealed record CallGraphEdge
|
||||
{
|
||||
public required string SourceId { get; init; }
|
||||
public required string TargetId { get; init; }
|
||||
public required string CallKind { get; init; } // direct/virtual/delegate
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Code Change Facts Model
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// AST-level code change facts from Smart-Diff.
|
||||
/// </summary>
|
||||
public sealed record CodeChangeFact
|
||||
{
|
||||
public required string ScanId { get; init; }
|
||||
public required string File { get; init; }
|
||||
public required string Symbol { get; init; }
|
||||
public required CodeChangeKind Kind { get; init; }
|
||||
public required JsonDocument Details { get; init; }
|
||||
}
|
||||
|
||||
public enum CodeChangeKind
|
||||
{
|
||||
Added,
|
||||
Removed,
|
||||
SignatureChanged,
|
||||
GuardChanged, // Boolean condition around call modified
|
||||
DependencyChanged, // Callee package/version changed
|
||||
VisibilityChanged // public<->internal<->private
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Drift Cause Attribution
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Explains why a reachability flip occurred.
|
||||
/// </summary>
|
||||
public sealed class DriftCauseExplainer
|
||||
{
|
||||
public DriftCause Explain(
|
||||
CallGraphSnapshot baseGraph,
|
||||
CallGraphSnapshot headGraph,
|
||||
string sinkSymbol,
|
||||
IReadOnlyList<CodeChangeFact> codeChanges)
|
||||
{
|
||||
// Find shortest path to sink in head graph
|
||||
var path = ShortestPath(headGraph.EntrypointIds, sinkSymbol, headGraph);
|
||||
if (path is null)
|
||||
return DriftCause.Unknown;
|
||||
|
||||
// Check each node on path for code changes
|
||||
foreach (var nodeId in path.NodeIds)
|
||||
{
|
||||
var node = headGraph.Nodes.First(n => n.NodeId == nodeId);
|
||||
var change = codeChanges.FirstOrDefault(c => c.Symbol == node.Symbol);
|
||||
|
||||
if (change is not null)
|
||||
{
|
||||
return change.Kind switch
|
||||
{
|
||||
CodeChangeKind.GuardChanged => DriftCause.GuardRemoved(node.Symbol, node.File, node.Line),
|
||||
CodeChangeKind.Added => DriftCause.NewPublicRoute(node.Symbol),
|
||||
CodeChangeKind.VisibilityChanged => DriftCause.VisibilityEscalated(node.Symbol),
|
||||
CodeChangeKind.DependencyChanged => DriftCause.DepUpgraded(change.Details),
|
||||
_ => DriftCause.CodeModified(node.Symbol)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return DriftCause.Unknown;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 Database Schema Extensions
|
||||
|
||||
```sql
|
||||
-- New table: Code change facts from AST-level Smart-Diff
|
||||
CREATE TABLE scanner.code_changes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
scan_id TEXT NOT NULL,
|
||||
file TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
change_kind TEXT NOT NULL, -- added|removed|signature|guard|dep|visibility
|
||||
details JSONB,
|
||||
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT code_changes_unique UNIQUE (tenant_id, scan_id, file, symbol)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_code_changes_scan ON scanner.code_changes(scan_id);
|
||||
CREATE INDEX idx_code_changes_symbol ON scanner.code_changes(symbol);
|
||||
|
||||
-- New table: Per-scan call graph snapshots (compressed)
|
||||
CREATE TABLE scanner.call_graph_snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
scan_id TEXT NOT NULL,
|
||||
language TEXT NOT NULL,
|
||||
graph_digest TEXT NOT NULL, -- Content hash for dedup
|
||||
node_count INT NOT NULL,
|
||||
edge_count INT NOT NULL,
|
||||
entrypoint_count INT NOT NULL,
|
||||
extracted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
cas_uri TEXT NOT NULL, -- Reference to CAS for full graph
|
||||
|
||||
CONSTRAINT call_graph_snapshots_unique UNIQUE (tenant_id, scan_id, language)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_call_graph_snapshots_digest ON scanner.call_graph_snapshots(graph_digest);
|
||||
|
||||
-- Extend existing material_risk_changes table
|
||||
ALTER TABLE scanner.material_risk_changes
|
||||
ADD COLUMN IF NOT EXISTS cause TEXT,
|
||||
ADD COLUMN IF NOT EXISTS path_nodes JSONB,
|
||||
ADD COLUMN IF NOT EXISTS base_scan_id TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_material_risk_changes_cause
|
||||
ON scanner.material_risk_changes(cause) WHERE cause IS NOT NULL;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. UI DESIGN
|
||||
|
||||
### 5.1 Risk Drift Card (PR/Commit View)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ RISK DRIFT ▼ │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ +3 new reachable paths -2 mitigated paths │
|
||||
│ │
|
||||
│ ┌─ NEW REACHABLE ──────────────────────────────────────────────┐ │
|
||||
│ │ POST /payments → PaymentsController.Capture → ... → │ │
|
||||
│ │ crypto.Verify(legacy) │ │
|
||||
│ │ │ │
|
||||
│ │ [pkg:payments@1.8.2] [CVE-2024-1234] [EPSS 0.72] [VEX:affected]│ │
|
||||
│ │ │ │
|
||||
│ │ Cause: guard removed in AuthFilter.cs:42 │ │
|
||||
│ │ │ │
|
||||
│ │ [View Path] [Quarantine Route] [Pin Version] [Add Exception] │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─ MITIGATED ──────────────────────────────────────────────────┐ │
|
||||
│ │ GET /admin → AdminController.Execute → ... → cmd.Run │ │
|
||||
│ │ │ │
|
||||
│ │ [pkg:admin@2.0.0] [CVE-2024-5678] [VEX:not_affected] │ │
|
||||
│ │ │ │
|
||||
│ │ Reason: Vulnerable API removed in upgrade │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.2 Path Viewer Component
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ CALL PATH: POST /payments → crypto.Verify(legacy) [Collapse] │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ○ POST /payments [ENTRYPOINT] │
|
||||
│ │ PaymentsController.cs:45 │
|
||||
│ │ │
|
||||
│ ├──○ PaymentsController.Capture() │
|
||||
│ │ │ PaymentsController.cs:89 │
|
||||
│ │ │ │
|
||||
│ │ ├──○ PaymentService.ProcessPayment() │
|
||||
│ │ │ │ PaymentService.cs:156 │
|
||||
│ │ │ │ │
|
||||
│ │ │ ├──● CryptoHelper.Verify() ← GUARD REMOVED │
|
||||
│ │ │ │ │ CryptoHelper.cs:42 [Changed: AuthFilter removed] │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ └──◆ crypto.Verify(legacy) [VULNERABLE SINK] │
|
||||
│ │ │ │ pkg:crypto@1.2.3 │
|
||||
│ │ │ │ CVE-2024-1234 (CVSS 9.8) │
|
||||
│ │
|
||||
│ Legend: ○ Node ● Changed ◆ Sink ─ Call │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. POLICY INTEGRATION
|
||||
|
||||
### 6.1 CI Gate Behavior
|
||||
|
||||
```yaml
|
||||
# Policy wiring for drift detection
|
||||
smart_diff:
|
||||
gates:
|
||||
# Fail PR when new reachable paths to affected sinks
|
||||
- condition: "delta_reachable > 0 AND vex_status IN ['affected', 'under_investigation']"
|
||||
action: block
|
||||
message: "New reachable paths to vulnerable sinks detected"
|
||||
|
||||
# Warn when new paths to any sink
|
||||
- condition: "delta_reachable > 0"
|
||||
action: warn
|
||||
message: "New reachable paths detected - review recommended"
|
||||
|
||||
# Auto-mitigate when VEX confirms not_affected
|
||||
- condition: "vex_status == 'not_affected' AND vex_justification IN ['component_not_present', 'fix_applied']"
|
||||
action: allow
|
||||
auto_mitigate: true
|
||||
```
|
||||
|
||||
### 6.2 Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Success, no material drift |
|
||||
| 1 | Success, material drift found (info) |
|
||||
| 2 | Success, hardening regression detected |
|
||||
| 3 | Success, new KEV reachable |
|
||||
| 10+ | Errors |
|
||||
|
||||
---
|
||||
|
||||
## 7. SPRINT STRUCTURE
|
||||
|
||||
### 7.1 Master Sprint: SPRINT_3600_0001_0001
|
||||
|
||||
**Topic**: Reachability Drift Detection
|
||||
**Dependencies**: SPRINT_3500 (Smart-Diff) - COMPLETE
|
||||
|
||||
### 7.2 Sub-Sprints
|
||||
|
||||
| ID | Topic | Priority | Effort | Dependencies |
|
||||
|----|-------|----------|--------|--------------|
|
||||
| SPRINT_3600_0002_0001 | Call Graph Infrastructure | P0 | Large | Master |
|
||||
| SPRINT_3600_0003_0001 | Drift Detection Engine | P0 | Medium | 3600.2 |
|
||||
| SPRINT_3600_0004_0001 | UI and Evidence Chain | P1 | Medium | 3600.3 |
|
||||
|
||||
---
|
||||
|
||||
## 8. REFERENCES
|
||||
|
||||
- `docs/product-advisories/14-Dec-2025 - Smart-Diff Technical Reference.md`
|
||||
- `docs/product-advisories/14-Dec-2025 - Reachability Analysis Technical Reference.md`
|
||||
- `docs/implplan/SPRINT_3500_0001_0001_smart_diff_master.md`
|
||||
- `docs/reachability/lattice.md`
|
||||
- `bench/reachability-benchmark/README.md`
|
||||
File diff suppressed because it is too large
Load Diff
400
docs/ux/TRIAGE_UI_REDUCER_SPEC.md
Normal file
400
docs/ux/TRIAGE_UI_REDUCER_SPEC.md
Normal 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
236
docs/ux/TRIAGE_UX_GUIDE.md
Normal 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
|
||||
Reference in New Issue
Block a user