SPRINT_3600_0001_0001 - Reachability Drift Detection Master Plan

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

View File

@@ -0,0 +1,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`

File diff suppressed because it is too large Load Diff

View 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`

View 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

View 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`