SPRINT_3600_0001_0001 - Reachability Drift Detection Master Plan
This commit is contained in:
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`
|
||||
Reference in New Issue
Block a user