Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
369
src/__Libraries/StellaOps.ReachGraph/AGENTS.md
Normal file
369
src/__Libraries/StellaOps.ReachGraph/AGENTS.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# StellaOps.ReachGraph Module
|
||||
|
||||
## Module Charter
|
||||
|
||||
The **ReachGraph** module provides a unified store for reachability subgraphs, enabling fast, deterministic, audit-ready answers to "*exactly why* a dependency is reachable."
|
||||
|
||||
### Mission
|
||||
|
||||
Consolidate reachability data from Scanner, Signals, and Attestor into a single, content-addressed store with:
|
||||
- **Edge explainability**: Every edge carries "why" metadata (import, dynamic load, guards)
|
||||
- **Deterministic replay**: Same inputs produce identical digests
|
||||
- **Slice queries**: Fast queries by package, CVE, entrypoint, or file
|
||||
- **Audit-ready proofs**: DSSE-signed artifacts verifiable offline
|
||||
|
||||
### Scope
|
||||
|
||||
| In Scope | Out of Scope |
|
||||
|----------|--------------|
|
||||
| ReachGraph schema and data model | Call graph extraction (handled by Scanner) |
|
||||
| Content-addressed storage | Runtime signal collection (handled by Signals) |
|
||||
| Slice query APIs | DSSE signing internals (handled by Attestor) |
|
||||
| Deterministic serialization | VEX document ingestion (handled by Excititor) |
|
||||
| Valkey caching | Policy evaluation (handled by Policy module) |
|
||||
| Replay verification | UI components (handled by Web module) |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component Diagram
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ ReachGraph Module │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ Schema Layer │ │ Serialization │ │ Signing Layer │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ ReachGraphMin │ │ Canonical JSON │ │ DSSE Wrapper │ │
|
||||
│ │ EdgeExplanation │ │ BLAKE3 Digest │ │ Verification │ │
|
||||
│ │ Provenance │ │ Compression │ │ │ │
|
||||
│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌────────▼────────────────────▼────────────────────▼────────┐ │
|
||||
│ │ Store Layer │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
|
||||
│ │ │ Repository │ │ Slice Engine │ │ Replay Driver│ │ │
|
||||
│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────────────▼───────────────────────────────┐ │
|
||||
│ │ Persistence Layer │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────┐ ┌──────────────┐ │ │
|
||||
│ │ │ PostgreSQL │ │ Valkey │ │ │
|
||||
│ │ │ (primary) │ │ (cache) │ │ │
|
||||
│ │ └──────────────┘ └──────────────┘ │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
src/__Libraries/StellaOps.ReachGraph/
|
||||
├── Schema/
|
||||
│ ├── ReachGraphMinimal.cs # Top-level graph structure
|
||||
│ ├── ReachGraphNode.cs # Node with metadata
|
||||
│ ├── ReachGraphEdge.cs # Edge with explanation
|
||||
│ ├── EdgeExplanation.cs # Why the edge exists
|
||||
│ └── ReachGraphProvenance.cs # Input tracking
|
||||
├── Serialization/
|
||||
│ ├── CanonicalReachGraphSerializer.cs
|
||||
│ ├── SortedKeysJsonConverter.cs
|
||||
│ └── DeterministicArraySortConverter.cs
|
||||
├── Hashing/
|
||||
│ ├── ReachGraphDigestComputer.cs
|
||||
│ └── Blake3HashProvider.cs
|
||||
├── Signing/
|
||||
│ ├── IReachGraphSignerService.cs
|
||||
│ └── ReachGraphSignerService.cs
|
||||
├── Store/
|
||||
│ ├── IReachGraphRepository.cs
|
||||
│ ├── PostgresReachGraphRepository.cs
|
||||
│ └── SliceQueryEngine.cs
|
||||
├── Cache/
|
||||
│ ├── IReachGraphCache.cs
|
||||
│ └── ValkeyReachGraphCache.cs
|
||||
├── Replay/
|
||||
│ ├── IReplayDriver.cs
|
||||
│ └── DeterministicReplayDriver.cs
|
||||
└── StellaOps.ReachGraph.csproj
|
||||
|
||||
src/__Libraries/StellaOps.ReachGraph.Persistence/
|
||||
├── Migrations/
|
||||
│ └── 001_reachgraph_store.sql
|
||||
├── Models/
|
||||
│ └── SubgraphEntity.cs
|
||||
└── StellaOps.ReachGraph.Persistence.csproj
|
||||
|
||||
src/ReachGraph/
|
||||
├── StellaOps.ReachGraph.WebService/
|
||||
│ ├── Endpoints/
|
||||
│ │ ├── ReachGraphEndpoints.cs
|
||||
│ │ └── SliceQueryEndpoints.cs
|
||||
│ ├── Contracts/
|
||||
│ │ ├── UpsertRequest.cs
|
||||
│ │ ├── SliceQueryRequest.cs
|
||||
│ │ └── ReplayRequest.cs
|
||||
│ ├── Program.cs
|
||||
│ └── openapi.yaml
|
||||
└── __Tests/
|
||||
└── StellaOps.ReachGraph.WebService.Tests/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### ReachGraphMinimal Schema (v1)
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "reachgraph.min@v1",
|
||||
"artifact": {
|
||||
"name": "svc.payments",
|
||||
"digest": "sha256:abc123...",
|
||||
"env": ["linux/amd64"]
|
||||
},
|
||||
"scope": {
|
||||
"entrypoints": ["/app/bin/svc"],
|
||||
"selectors": ["prod"],
|
||||
"cves": ["CVE-2024-1234"]
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "sha256:nodeHash1",
|
||||
"kind": "function",
|
||||
"ref": "main()",
|
||||
"file": "src/index.ts",
|
||||
"line": 1,
|
||||
"isEntrypoint": true
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": "sha256:nodeHash1",
|
||||
"to": "sha256:nodeHash2",
|
||||
"why": {
|
||||
"type": "Import",
|
||||
"loc": "src/index.ts:3",
|
||||
"confidence": 1.0
|
||||
}
|
||||
}
|
||||
],
|
||||
"provenance": {
|
||||
"intoto": ["attestation-1.link"],
|
||||
"inputs": {
|
||||
"sbom": "sha256:sbomDigest",
|
||||
"vex": "sha256:vexDigest",
|
||||
"callgraph": "sha256:cgDigest"
|
||||
},
|
||||
"computedAt": "2025-12-27T10:00:00Z",
|
||||
"analyzer": {
|
||||
"name": "stellaops-scanner",
|
||||
"version": "1.0.0",
|
||||
"toolchainDigest": "sha256:..."
|
||||
}
|
||||
},
|
||||
"signatures": [
|
||||
{"keyId": "scanner-signing-2025", "sig": "base64..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Edge Explanation Types
|
||||
|
||||
| Type | Description | Example Guard |
|
||||
|------|-------------|---------------|
|
||||
| `Import` | Static import statement | - |
|
||||
| `DynamicLoad` | Runtime require/import | - |
|
||||
| `Reflection` | Reflective invocation | - |
|
||||
| `Ffi` | Foreign function call | - |
|
||||
| `EnvGuard` | Environment variable check | `DEBUG=true` |
|
||||
| `FeatureFlag` | Feature flag condition | `FEATURE_X=enabled` |
|
||||
| `PlatformArch` | Platform/arch guard | `os=linux` |
|
||||
| `TaintGate` | Sanitization/validation | - |
|
||||
| `LoaderRule` | PLT/IAT/GOT entry | `RTLD_LAZY` |
|
||||
| `DirectCall` | Direct function call | - |
|
||||
| `Unknown` | Cannot determine | - |
|
||||
|
||||
---
|
||||
|
||||
## API Contracts
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/v1/reachgraphs` | Upsert subgraph |
|
||||
| GET | `/v1/reachgraphs/{digest}` | Get full subgraph |
|
||||
| GET | `/v1/reachgraphs/{digest}/slice` | Query slice |
|
||||
| POST | `/v1/reachgraphs/replay` | Verify determinism |
|
||||
| GET | `/v1/reachgraphs/by-artifact/{digest}` | List by artifact |
|
||||
|
||||
### Slice Query Parameters
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `q` | PURL pattern for package slice |
|
||||
| `cve` | CVE ID for vulnerability slice |
|
||||
| `entrypoint` | Entrypoint path/symbol |
|
||||
| `file` | File path pattern (glob) |
|
||||
| `depth` | Max traversal depth |
|
||||
| `direction` | `upstream`, `downstream`, `both` |
|
||||
|
||||
---
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
### Determinism Rules
|
||||
|
||||
1. **All JSON serialization must use canonical format**
|
||||
- Sorted object keys (lexicographic)
|
||||
- Sorted arrays by deterministic field
|
||||
- UTC ISO-8601 timestamps
|
||||
- No null fields (omit when null)
|
||||
|
||||
2. **Hash computation excludes signatures**
|
||||
- Remove `signatures` field before hashing
|
||||
- Use BLAKE3-256 for all digests
|
||||
|
||||
3. **Tests must verify determinism**
|
||||
- Same input must produce same digest
|
||||
- Golden samples for regression testing
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Return structured errors with codes
|
||||
- Log correlation IDs for tracing
|
||||
- Never expose internal details in errors
|
||||
|
||||
### Performance
|
||||
|
||||
- Cache hot slices in Valkey (30min TTL)
|
||||
- Compress stored blobs with gzip
|
||||
- Paginate large results (50 nodes per page)
|
||||
- Timeout long queries (30s max)
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Upstream (Data Producers)
|
||||
|
||||
| Module | Data | Integration |
|
||||
|--------|------|-------------|
|
||||
| Scanner.CallGraph | Call graph nodes/edges | `ICallGraphExtractor` produces input |
|
||||
| Signals | Runtime facts | Correlates static + dynamic paths |
|
||||
| Attestor | DSSE signing | `IReachGraphSignerService` delegates |
|
||||
|
||||
### Downstream (Data Consumers)
|
||||
|
||||
| Module | Usage | Integration |
|
||||
|--------|-------|-------------|
|
||||
| Policy | VEX decisions | `ReachabilityRequirementGate` queries slices |
|
||||
| Web | UI panel | REST API for "Why Reachable?" |
|
||||
| CLI | Proof export | `stella reachgraph` commands |
|
||||
| ExportCenter | Batch reports | Includes subgraphs in evidence bundles |
|
||||
|
||||
---
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- `CanonicalSerializerTests.cs` - Deterministic serialization
|
||||
- `DigestComputerTests.cs` - BLAKE3 hashing
|
||||
- `EdgeExplanationTests.cs` - Type coverage
|
||||
- `SliceEngineTests.cs` - Query correctness
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- PostgreSQL with Testcontainers
|
||||
- Valkey cache behavior
|
||||
- Tenant isolation (RLS)
|
||||
- Rate limiting enforcement
|
||||
|
||||
### Golden Samples
|
||||
|
||||
Located in `tests/ReachGraph/Fixtures/`:
|
||||
- `simple-single-path.reachgraph.min.json`
|
||||
- `multi-edge-java.reachgraph.min.json`
|
||||
- `feature-flag-guards.reachgraph.min.json`
|
||||
- `large-graph-50-nodes.reachgraph.min.json`
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `REACHGRAPH_POSTGRES_CONNECTION` | PostgreSQL connection string | - |
|
||||
| `REACHGRAPH_VALKEY_CONNECTION` | Valkey connection string | - |
|
||||
| `REACHGRAPH_CACHE_TTL_MINUTES` | Cache TTL for full graphs | 1440 |
|
||||
| `REACHGRAPH_SLICE_CACHE_TTL_MINUTES` | Cache TTL for slices | 30 |
|
||||
| `REACHGRAPH_MAX_GRAPH_SIZE_MB` | Max graph size in cache | 10 |
|
||||
|
||||
### YAML Configuration
|
||||
|
||||
```yaml
|
||||
# etc/reachgraph.yaml
|
||||
reachgraph:
|
||||
store:
|
||||
maxDepth: 10
|
||||
maxPaths: 5
|
||||
compressionLevel: 6
|
||||
cache:
|
||||
enabled: true
|
||||
ttlMinutes: 30
|
||||
replay:
|
||||
enabled: true
|
||||
logResults: true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Observability
|
||||
|
||||
### Metrics
|
||||
|
||||
- `reachgraph_upsert_total` - Upsert count by result
|
||||
- `reachgraph_query_duration_seconds` - Query latency histogram
|
||||
- `reachgraph_cache_hit_ratio` - Cache hit rate
|
||||
- `reachgraph_replay_match_total` - Replay verification results
|
||||
- `reachgraph_slice_size_bytes` - Slice response sizes
|
||||
|
||||
### Logging
|
||||
|
||||
- Structured JSON logs
|
||||
- Correlation ID in all entries
|
||||
- Tenant context
|
||||
- Query parameters (sanitized)
|
||||
|
||||
### Tracing
|
||||
|
||||
- OpenTelemetry spans for:
|
||||
- Upsert operations
|
||||
- Slice queries
|
||||
- Cache lookups
|
||||
- Replay verification
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `docs/implplan/SPRINT_1227_0012_0001_LB_reachgraph_core.md`
|
||||
- `docs/implplan/SPRINT_1227_0012_0002_BE_reachgraph_store.md`
|
||||
- `docs/implplan/SPRINT_1227_0012_0003_FE_reachgraph_integration.md`
|
||||
- `src/Attestor/POE_PREDICATE_SPEC.md` (predecessor schema)
|
||||
- `docs/modules/scanner/architecture.md`
|
||||
- `docs/modules/signals/architecture.md`
|
||||
|
||||
---
|
||||
|
||||
_Module created: 2025-12-27. Owner: ReachGraph Guild._
|
||||
@@ -0,0 +1,113 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using Blake3;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using StellaOps.ReachGraph.Serialization;
|
||||
|
||||
namespace StellaOps.ReachGraph.Hashing;
|
||||
|
||||
/// <summary>
|
||||
/// Computes BLAKE3-256 digests for reachability graphs using canonical serialization.
|
||||
/// </summary>
|
||||
public sealed class ReachGraphDigestComputer
|
||||
{
|
||||
private readonly CanonicalReachGraphSerializer _serializer;
|
||||
|
||||
public ReachGraphDigestComputer()
|
||||
: this(new CanonicalReachGraphSerializer())
|
||||
{
|
||||
}
|
||||
|
||||
public ReachGraphDigestComputer(CanonicalReachGraphSerializer serializer)
|
||||
{
|
||||
_serializer = serializer ?? throw new ArgumentNullException(nameof(serializer));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute BLAKE3-256 digest of canonical JSON (excluding signatures).
|
||||
/// </summary>
|
||||
/// <param name="graph">The reachability graph to hash.</param>
|
||||
/// <returns>Digest in format "blake3:{hex}".</returns>
|
||||
public string ComputeDigest(ReachGraphMinimal graph)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
// Remove signatures before hashing (avoid circular dependency)
|
||||
var unsigned = graph with { Signatures = null };
|
||||
var canonical = _serializer.SerializeMinimal(unsigned);
|
||||
|
||||
using var hasher = Hasher.New();
|
||||
hasher.Update(canonical);
|
||||
var hash = hasher.Finalize();
|
||||
|
||||
return $"blake3:{Convert.ToHexString(hash.AsSpan()).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute BLAKE3-256 digest from raw canonical JSON bytes.
|
||||
/// </summary>
|
||||
/// <param name="canonicalJson">The canonical JSON bytes to hash.</param>
|
||||
/// <returns>Digest in format "blake3:{hex}".</returns>
|
||||
public static string ComputeDigest(ReadOnlySpan<byte> canonicalJson)
|
||||
{
|
||||
using var hasher = Hasher.New();
|
||||
hasher.Update(canonicalJson);
|
||||
var hash = hasher.Finalize();
|
||||
|
||||
return $"blake3:{Convert.ToHexString(hash.AsSpan()).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify digest matches graph content.
|
||||
/// </summary>
|
||||
/// <param name="graph">The reachability graph to verify.</param>
|
||||
/// <param name="expectedDigest">The expected digest.</param>
|
||||
/// <returns>True if digest matches, false otherwise.</returns>
|
||||
public bool VerifyDigest(ReachGraphMinimal graph, string expectedDigest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
ArgumentException.ThrowIfNullOrEmpty(expectedDigest);
|
||||
|
||||
var computed = ComputeDigest(graph);
|
||||
return string.Equals(computed, expectedDigest, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a digest string into its algorithm and hash components.
|
||||
/// </summary>
|
||||
/// <param name="digest">The digest string (e.g., "blake3:abc123...").</param>
|
||||
/// <returns>Tuple of (algorithm, hash) or null if invalid format.</returns>
|
||||
public static (string Algorithm, string Hash)? ParseDigest(string digest)
|
||||
{
|
||||
if (string.IsNullOrEmpty(digest))
|
||||
return null;
|
||||
|
||||
var colonIndex = digest.IndexOf(':');
|
||||
if (colonIndex <= 0 || colonIndex >= digest.Length - 1)
|
||||
return null;
|
||||
|
||||
var algorithm = digest[..colonIndex];
|
||||
var hash = digest[(colonIndex + 1)..];
|
||||
|
||||
return (algorithm, hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate that a digest string has the correct format for BLAKE3.
|
||||
/// </summary>
|
||||
/// <param name="digest">The digest string to validate.</param>
|
||||
/// <returns>True if valid BLAKE3 digest format, false otherwise.</returns>
|
||||
public static bool IsValidBlake3Digest(string digest)
|
||||
{
|
||||
var parsed = ParseDigest(digest);
|
||||
if (parsed is null)
|
||||
return false;
|
||||
|
||||
var (algorithm, hash) = parsed.Value;
|
||||
|
||||
// BLAKE3-256 produces 64 hex characters (32 bytes)
|
||||
return string.Equals(algorithm, "blake3", StringComparison.OrdinalIgnoreCase) &&
|
||||
hash.Length == 64 &&
|
||||
hash.All(c => char.IsAsciiHexDigit(c));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ReachGraph.Schema;
|
||||
|
||||
/// <summary>
|
||||
/// Why an edge exists in the reachability graph.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<EdgeExplanationType>))]
|
||||
public enum EdgeExplanationType
|
||||
{
|
||||
/// <summary>Static import (ES6 import, Python import, using directive).</summary>
|
||||
Import,
|
||||
|
||||
/// <summary>Dynamic load (require(), dlopen, LoadLibrary).</summary>
|
||||
DynamicLoad,
|
||||
|
||||
/// <summary>Reflection invocation (Class.forName, Type.GetType).</summary>
|
||||
Reflection,
|
||||
|
||||
/// <summary>Foreign function interface (JNI, P/Invoke, ctypes).</summary>
|
||||
Ffi,
|
||||
|
||||
/// <summary>Environment variable guard (process.env.X, os.environ.get).</summary>
|
||||
EnvGuard,
|
||||
|
||||
/// <summary>Feature flag check (LaunchDarkly, unleash, custom flags).</summary>
|
||||
FeatureFlag,
|
||||
|
||||
/// <summary>Platform/architecture guard (process.platform, runtime.GOOS).</summary>
|
||||
PlatformArch,
|
||||
|
||||
/// <summary>Taint gate (sanitization, validation).</summary>
|
||||
TaintGate,
|
||||
|
||||
/// <summary>Loader rule (PLT/IAT/GOT entry).</summary>
|
||||
LoaderRule,
|
||||
|
||||
/// <summary>Direct call (static, virtual, delegate).</summary>
|
||||
DirectCall,
|
||||
|
||||
/// <summary>Cannot determine explanation type.</summary>
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full edge explanation with metadata.
|
||||
/// </summary>
|
||||
public sealed record EdgeExplanation
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the type of edge explanation.
|
||||
/// </summary>
|
||||
public required EdgeExplanationType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the source location (file:line).
|
||||
/// </summary>
|
||||
public string? Loc { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the guard predicate expression (e.g., "FEATURE_X=true").
|
||||
/// </summary>
|
||||
public string? Guard { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the confidence score [0.0, 1.0].
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets additional metadata (language-specific).
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
namespace StellaOps.ReachGraph.Schema;
|
||||
|
||||
/// <summary>
|
||||
/// An edge in the reachability subgraph connecting two nodes.
|
||||
/// </summary>
|
||||
public sealed record ReachGraphEdge
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the source node ID.
|
||||
/// </summary>
|
||||
public required string From { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the target node ID.
|
||||
/// </summary>
|
||||
public required string To { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the explanation of why this edge exists.
|
||||
/// </summary>
|
||||
public required EdgeExplanation Why { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.ReachGraph.Schema;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal reachability subgraph format optimized for:
|
||||
/// - Compact serialization (delta-friendly, gzip-hot)
|
||||
/// - Deterministic digest computation
|
||||
/// - Offline verification with DSSE signatures
|
||||
/// - VEX-first policy integration
|
||||
/// </summary>
|
||||
public sealed record ReachGraphMinimal
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the schema version identifier.
|
||||
/// </summary>
|
||||
public string SchemaVersion { get; init; } = "reachgraph.min@v1";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifact this graph describes.
|
||||
/// </summary>
|
||||
public required ReachGraphArtifact Artifact { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the scope/context of this graph.
|
||||
/// </summary>
|
||||
public required ReachGraphScope Scope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the nodes in this subgraph.
|
||||
/// </summary>
|
||||
public required ImmutableArray<ReachGraphNode> Nodes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the edges in this subgraph.
|
||||
/// </summary>
|
||||
public required ImmutableArray<ReachGraphEdge> Edges { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the provenance information for this graph.
|
||||
/// </summary>
|
||||
public required ReachGraphProvenance Provenance { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the DSSE signatures (optional, set after signing).
|
||||
/// </summary>
|
||||
public ImmutableArray<ReachGraphSignature>? Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes the artifact this reachability graph applies to.
|
||||
/// </summary>
|
||||
public sealed record ReachGraphArtifact(
|
||||
string Name,
|
||||
string Digest,
|
||||
ImmutableArray<string> Env
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Defines the scope/context of the reachability analysis.
|
||||
/// </summary>
|
||||
public sealed record ReachGraphScope(
|
||||
ImmutableArray<string> Entrypoints,
|
||||
ImmutableArray<string> Selectors,
|
||||
ImmutableArray<string>? Cves = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature on the reachability graph.
|
||||
/// </summary>
|
||||
public sealed record ReachGraphSignature(
|
||||
string KeyId,
|
||||
string Sig
|
||||
);
|
||||
@@ -0,0 +1,81 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ReachGraph.Schema;
|
||||
|
||||
/// <summary>
|
||||
/// Kind of node in the reachability graph.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ReachGraphNodeKind>))]
|
||||
public enum ReachGraphNodeKind
|
||||
{
|
||||
/// <summary>Package/dependency node.</summary>
|
||||
Package,
|
||||
|
||||
/// <summary>Source file node.</summary>
|
||||
File,
|
||||
|
||||
/// <summary>Function/method node.</summary>
|
||||
Function,
|
||||
|
||||
/// <summary>Binary symbol node.</summary>
|
||||
Symbol,
|
||||
|
||||
/// <summary>Class/type node.</summary>
|
||||
Class,
|
||||
|
||||
/// <summary>Module/namespace node.</summary>
|
||||
Module
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A node in the reachability subgraph.
|
||||
/// </summary>
|
||||
public sealed record ReachGraphNode
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the content-addressed node ID: sha256(canonical(kind:ref)).
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the kind of node.
|
||||
/// </summary>
|
||||
public required ReachGraphNodeKind Kind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reference (PURL for package, path for file, symbol for function).
|
||||
/// </summary>
|
||||
public required string Ref { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the source file path (if available).
|
||||
/// </summary>
|
||||
public string? File { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the line number (if available).
|
||||
/// </summary>
|
||||
public int? Line { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the module/library hash.
|
||||
/// </summary>
|
||||
public string? ModuleHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the binary address (for native code).
|
||||
/// </summary>
|
||||
public string? Addr { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this is an entry point.
|
||||
/// </summary>
|
||||
public bool? IsEntrypoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this is a sink (vulnerable function).
|
||||
/// </summary>
|
||||
public bool? IsSink { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.ReachGraph.Schema;
|
||||
|
||||
/// <summary>
|
||||
/// Provenance information for a reachability graph.
|
||||
/// </summary>
|
||||
public sealed record ReachGraphProvenance
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the in-toto attestation links.
|
||||
/// </summary>
|
||||
public ImmutableArray<string>? Intoto { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the input artifact digests.
|
||||
/// </summary>
|
||||
public required ReachGraphInputs Inputs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when this graph was computed (UTC).
|
||||
/// </summary>
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the analyzer that produced this graph.
|
||||
/// </summary>
|
||||
public required ReachGraphAnalyzer Analyzer { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Input artifact digests for provenance tracking.
|
||||
/// </summary>
|
||||
public sealed record ReachGraphInputs
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the SBOM digest (sha256:...).
|
||||
/// </summary>
|
||||
public required string Sbom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the VEX digest if available.
|
||||
/// </summary>
|
||||
public string? Vex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the call graph digest.
|
||||
/// </summary>
|
||||
public string? Callgraph { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the runtime facts batch digest.
|
||||
/// </summary>
|
||||
public string? RuntimeFacts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the policy digest used for filtering.
|
||||
/// </summary>
|
||||
public string? Policy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyzer metadata for reproducibility.
|
||||
/// </summary>
|
||||
public sealed record ReachGraphAnalyzer(
|
||||
string Name,
|
||||
string Version,
|
||||
string ToolchainDigest
|
||||
);
|
||||
@@ -0,0 +1,462 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Serializer for canonical (deterministic) ReachGraph JSON.
|
||||
/// Guarantees:
|
||||
/// - Lexicographically sorted object keys
|
||||
/// - Arrays sorted by deterministic field (Nodes by Id, Edges by From/To)
|
||||
/// - UTC ISO-8601 timestamps with millisecond precision
|
||||
/// - Null fields omitted
|
||||
/// - Minified output for storage, prettified for debugging
|
||||
/// </summary>
|
||||
public sealed class CanonicalReachGraphSerializer
|
||||
{
|
||||
private readonly JsonSerializerOptions _minifiedOptions;
|
||||
private readonly JsonSerializerOptions _prettyOptions;
|
||||
|
||||
public CanonicalReachGraphSerializer()
|
||||
{
|
||||
_minifiedOptions = CreateSerializerOptions(writeIndented: false);
|
||||
_prettyOptions = CreateSerializerOptions(writeIndented: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialize to canonical minified JSON bytes.
|
||||
/// </summary>
|
||||
public byte[] SerializeMinimal(ReachGraphMinimal graph)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
var canonical = Canonicalize(graph);
|
||||
return JsonSerializer.SerializeToUtf8Bytes(canonical, _minifiedOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serialize to canonical prettified JSON for debugging.
|
||||
/// </summary>
|
||||
public string SerializePretty(ReachGraphMinimal graph)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
var canonical = Canonicalize(graph);
|
||||
return JsonSerializer.Serialize(canonical, _prettyOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize from JSON bytes.
|
||||
/// </summary>
|
||||
public ReachGraphMinimal Deserialize(ReadOnlySpan<byte> json)
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<ReachGraphMinimalDto>(json, _minifiedOptions)
|
||||
?? throw new JsonException("Failed to deserialize ReachGraphMinimal");
|
||||
|
||||
return FromDto(dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize from JSON string.
|
||||
/// </summary>
|
||||
public ReachGraphMinimal Deserialize(string json)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(json);
|
||||
|
||||
var dto = JsonSerializer.Deserialize<ReachGraphMinimalDto>(json, _minifiedOptions)
|
||||
?? throw new JsonException("Failed to deserialize ReachGraphMinimal");
|
||||
|
||||
return FromDto(dto);
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions CreateSerializerOptions(bool writeIndented)
|
||||
{
|
||||
return new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = writeIndented,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters =
|
||||
{
|
||||
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase),
|
||||
new Iso8601MillisecondConverter()
|
||||
},
|
||||
// Ensure consistent ordering
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a canonical DTO with sorted arrays.
|
||||
/// </summary>
|
||||
private static ReachGraphMinimalDto Canonicalize(ReachGraphMinimal graph)
|
||||
{
|
||||
// Sort nodes by Id lexicographically
|
||||
var sortedNodes = graph.Nodes
|
||||
.OrderBy(n => n.Id, StringComparer.Ordinal)
|
||||
.Select(n => new ReachGraphNodeDto
|
||||
{
|
||||
Id = n.Id,
|
||||
Kind = n.Kind,
|
||||
Ref = n.Ref,
|
||||
File = n.File,
|
||||
Line = n.Line,
|
||||
ModuleHash = n.ModuleHash,
|
||||
Addr = n.Addr,
|
||||
IsEntrypoint = n.IsEntrypoint,
|
||||
IsSink = n.IsSink
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Sort edges by From, then To
|
||||
var sortedEdges = graph.Edges
|
||||
.OrderBy(e => e.From, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.To, StringComparer.Ordinal)
|
||||
.Select(e => new ReachGraphEdgeDto
|
||||
{
|
||||
From = e.From,
|
||||
To = e.To,
|
||||
Why = new EdgeExplanationDto
|
||||
{
|
||||
Type = e.Why.Type,
|
||||
Loc = e.Why.Loc,
|
||||
Guard = e.Why.Guard,
|
||||
Confidence = e.Why.Confidence,
|
||||
Metadata = e.Why.Metadata?.Count > 0
|
||||
? new SortedDictionary<string, string>(e.Why.Metadata, StringComparer.Ordinal)
|
||||
: null
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Sort signatures by KeyId if present
|
||||
List<ReachGraphSignatureDto>? sortedSignatures = null;
|
||||
if (graph.Signatures is { Length: > 0 } sigs)
|
||||
{
|
||||
sortedSignatures = sigs
|
||||
.OrderBy(s => s.KeyId, StringComparer.Ordinal)
|
||||
.Select(s => new ReachGraphSignatureDto { KeyId = s.KeyId, Sig = s.Sig })
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Sort entrypoints and selectors
|
||||
var sortedEntrypoints = graph.Scope.Entrypoints
|
||||
.OrderBy(e => e, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var sortedSelectors = graph.Scope.Selectors
|
||||
.OrderBy(s => s, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
List<string>? sortedCves = graph.Scope.Cves is { Length: > 0 } cves
|
||||
? cves.OrderBy(c => c, StringComparer.Ordinal).ToList()
|
||||
: null;
|
||||
|
||||
// Sort env array
|
||||
var sortedEnv = graph.Artifact.Env
|
||||
.OrderBy(e => e, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// Sort intoto array if present
|
||||
List<string>? sortedIntoto = graph.Provenance.Intoto is { Length: > 0 } intoto
|
||||
? intoto.OrderBy(i => i, StringComparer.Ordinal).ToList()
|
||||
: null;
|
||||
|
||||
return new ReachGraphMinimalDto
|
||||
{
|
||||
SchemaVersion = graph.SchemaVersion,
|
||||
Artifact = new ReachGraphArtifactDto
|
||||
{
|
||||
Name = graph.Artifact.Name,
|
||||
Digest = graph.Artifact.Digest,
|
||||
Env = sortedEnv
|
||||
},
|
||||
Scope = new ReachGraphScopeDto
|
||||
{
|
||||
Entrypoints = sortedEntrypoints,
|
||||
Selectors = sortedSelectors,
|
||||
Cves = sortedCves
|
||||
},
|
||||
Nodes = sortedNodes,
|
||||
Edges = sortedEdges,
|
||||
Provenance = new ReachGraphProvenanceDto
|
||||
{
|
||||
Intoto = sortedIntoto,
|
||||
Inputs = new ReachGraphInputsDto
|
||||
{
|
||||
Sbom = graph.Provenance.Inputs.Sbom,
|
||||
Vex = graph.Provenance.Inputs.Vex,
|
||||
Callgraph = graph.Provenance.Inputs.Callgraph,
|
||||
RuntimeFacts = graph.Provenance.Inputs.RuntimeFacts,
|
||||
Policy = graph.Provenance.Inputs.Policy
|
||||
},
|
||||
ComputedAt = graph.Provenance.ComputedAt,
|
||||
Analyzer = new ReachGraphAnalyzerDto
|
||||
{
|
||||
Name = graph.Provenance.Analyzer.Name,
|
||||
Version = graph.Provenance.Analyzer.Version,
|
||||
ToolchainDigest = graph.Provenance.Analyzer.ToolchainDigest
|
||||
}
|
||||
},
|
||||
Signatures = sortedSignatures
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert DTO back to domain record.
|
||||
/// </summary>
|
||||
private static ReachGraphMinimal FromDto(ReachGraphMinimalDto dto)
|
||||
{
|
||||
return new ReachGraphMinimal
|
||||
{
|
||||
SchemaVersion = dto.SchemaVersion,
|
||||
Artifact = new ReachGraphArtifact(
|
||||
dto.Artifact.Name,
|
||||
dto.Artifact.Digest,
|
||||
[.. dto.Artifact.Env]
|
||||
),
|
||||
Scope = new ReachGraphScope(
|
||||
[.. dto.Scope.Entrypoints],
|
||||
[.. dto.Scope.Selectors],
|
||||
dto.Scope.Cves is { Count: > 0 } ? [.. dto.Scope.Cves] : null
|
||||
),
|
||||
Nodes = [.. dto.Nodes.Select(n => new ReachGraphNode
|
||||
{
|
||||
Id = n.Id,
|
||||
Kind = n.Kind,
|
||||
Ref = n.Ref,
|
||||
File = n.File,
|
||||
Line = n.Line,
|
||||
ModuleHash = n.ModuleHash,
|
||||
Addr = n.Addr,
|
||||
IsEntrypoint = n.IsEntrypoint,
|
||||
IsSink = n.IsSink
|
||||
})],
|
||||
Edges = [.. dto.Edges.Select(e => new ReachGraphEdge
|
||||
{
|
||||
From = e.From,
|
||||
To = e.To,
|
||||
Why = new EdgeExplanation
|
||||
{
|
||||
Type = e.Why.Type,
|
||||
Loc = e.Why.Loc,
|
||||
Guard = e.Why.Guard,
|
||||
Confidence = e.Why.Confidence,
|
||||
Metadata = e.Why.Metadata?.Count > 0
|
||||
? e.Why.Metadata.ToImmutableDictionary()
|
||||
: null
|
||||
}
|
||||
})],
|
||||
Provenance = new ReachGraphProvenance
|
||||
{
|
||||
Intoto = dto.Provenance.Intoto is { Count: > 0 } ? [.. dto.Provenance.Intoto] : null,
|
||||
Inputs = new ReachGraphInputs
|
||||
{
|
||||
Sbom = dto.Provenance.Inputs.Sbom,
|
||||
Vex = dto.Provenance.Inputs.Vex,
|
||||
Callgraph = dto.Provenance.Inputs.Callgraph,
|
||||
RuntimeFacts = dto.Provenance.Inputs.RuntimeFacts,
|
||||
Policy = dto.Provenance.Inputs.Policy
|
||||
},
|
||||
ComputedAt = dto.Provenance.ComputedAt,
|
||||
Analyzer = new ReachGraphAnalyzer(
|
||||
dto.Provenance.Analyzer.Name,
|
||||
dto.Provenance.Analyzer.Version,
|
||||
dto.Provenance.Analyzer.ToolchainDigest
|
||||
)
|
||||
},
|
||||
Signatures = dto.Signatures is { Count: > 0 }
|
||||
? [.. dto.Signatures.Select(s => new ReachGraphSignature(s.KeyId, s.Sig))]
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
#region DTOs for canonical serialization (alphabetically ordered properties)
|
||||
|
||||
private sealed class ReachGraphMinimalDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string SchemaVersion { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required ReachGraphArtifactDto Artifact { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required ReachGraphScopeDto Scope { get; init; }
|
||||
|
||||
[JsonPropertyOrder(4)]
|
||||
public required List<ReachGraphNodeDto> Nodes { get; init; }
|
||||
|
||||
[JsonPropertyOrder(5)]
|
||||
public required List<ReachGraphEdgeDto> Edges { get; init; }
|
||||
|
||||
[JsonPropertyOrder(6)]
|
||||
public required ReachGraphProvenanceDto Provenance { get; init; }
|
||||
|
||||
[JsonPropertyOrder(7)]
|
||||
public List<ReachGraphSignatureDto>? Signatures { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphArtifactDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required List<string> Env { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphScopeDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required List<string> Entrypoints { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required List<string> Selectors { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public List<string>? Cves { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphNodeDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required ReachGraphNodeKind Kind { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required string Ref { get; init; }
|
||||
|
||||
[JsonPropertyOrder(4)]
|
||||
public string? File { get; init; }
|
||||
|
||||
[JsonPropertyOrder(5)]
|
||||
public int? Line { get; init; }
|
||||
|
||||
[JsonPropertyOrder(6)]
|
||||
public string? ModuleHash { get; init; }
|
||||
|
||||
[JsonPropertyOrder(7)]
|
||||
public string? Addr { get; init; }
|
||||
|
||||
[JsonPropertyOrder(8)]
|
||||
public bool? IsEntrypoint { get; init; }
|
||||
|
||||
[JsonPropertyOrder(9)]
|
||||
public bool? IsSink { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphEdgeDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string From { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required string To { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required EdgeExplanationDto Why { get; init; }
|
||||
}
|
||||
|
||||
private sealed class EdgeExplanationDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required EdgeExplanationType Type { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public string? Loc { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public string? Guard { get; init; }
|
||||
|
||||
[JsonPropertyOrder(4)]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
[JsonPropertyOrder(5)]
|
||||
public SortedDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphProvenanceDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public List<string>? Intoto { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required ReachGraphInputsDto Inputs { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
[JsonPropertyOrder(4)]
|
||||
public required ReachGraphAnalyzerDto Analyzer { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphInputsDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string Sbom { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public string? Vex { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public string? Callgraph { get; init; }
|
||||
|
||||
[JsonPropertyOrder(4)]
|
||||
public string? RuntimeFacts { get; init; }
|
||||
|
||||
[JsonPropertyOrder(5)]
|
||||
public string? Policy { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphAnalyzerDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required string ToolchainDigest { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphSignatureDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON converter for ISO-8601 timestamps with millisecond precision.
|
||||
/// </summary>
|
||||
internal sealed class Iso8601MillisecondConverter : JsonConverter<DateTimeOffset>
|
||||
{
|
||||
private const string Format = "yyyy-MM-ddTHH:mm:ss.fffZ";
|
||||
|
||||
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var str = reader.GetString() ?? throw new JsonException("Expected string value for DateTimeOffset");
|
||||
return DateTimeOffset.Parse(str, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
|
||||
{
|
||||
// Always output in UTC with millisecond precision
|
||||
writer.WriteStringValue(value.UtcDateTime.ToString(Format, System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
namespace StellaOps.ReachGraph.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Key store abstraction for ReachGraph signing operations.
|
||||
/// Wraps the underlying cryptographic key management (Attestor, Signer module, etc.).
|
||||
/// </summary>
|
||||
public interface IReachGraphKeyStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Sign data with the specified key.
|
||||
/// </summary>
|
||||
/// <param name="keyId">The key identifier.</param>
|
||||
/// <param name="data">The data to sign (typically PAE-encoded).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The signature bytes.</returns>
|
||||
Task<byte[]> SignAsync(string keyId, byte[] data, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify a signature with the specified key.
|
||||
/// </summary>
|
||||
/// <param name="keyId">The key identifier.</param>
|
||||
/// <param name="data">The data that was signed.</param>
|
||||
/// <param name="signature">The signature to verify.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if signature is valid, false otherwise.</returns>
|
||||
Task<bool> VerifyAsync(string keyId, byte[] data, byte[] signature, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a key exists and is available for signing.
|
||||
/// </summary>
|
||||
/// <param name="keyId">The key identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if key exists and can sign, false otherwise.</returns>
|
||||
Task<bool> CanSignAsync(string keyId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Check if a key exists and is available for verification.
|
||||
/// </summary>
|
||||
/// <param name="keyId">The key identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if key exists and can verify, false otherwise.</returns>
|
||||
Task<bool> CanVerifyAsync(string keyId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// Service for signing and verifying reachability graphs using DSSE envelopes.
|
||||
/// </summary>
|
||||
public interface IReachGraphSignerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Sign a reachability graph using DSSE envelope format.
|
||||
/// </summary>
|
||||
/// <param name="graph">The graph to sign.</param>
|
||||
/// <param name="keyId">The key identifier to use for signing.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The graph with signature attached.</returns>
|
||||
Task<ReachGraphMinimal> SignAsync(
|
||||
ReachGraphMinimal graph,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify signatures on a reachability graph.
|
||||
/// </summary>
|
||||
/// <param name="graph">The graph to verify.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result with valid/invalid key IDs.</returns>
|
||||
Task<ReachGraphVerificationResult> VerifyAsync(
|
||||
ReachGraphMinimal graph,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Create a DSSE envelope for a reachability graph.
|
||||
/// </summary>
|
||||
/// <param name="graph">The graph to envelope.</param>
|
||||
/// <param name="keyId">The key identifier to use for signing.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Serialized DSSE envelope bytes.</returns>
|
||||
Task<byte[]> CreateDsseEnvelopeAsync(
|
||||
ReachGraphMinimal graph,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of reachability graph signature verification.
|
||||
/// </summary>
|
||||
public sealed record ReachGraphVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether all signatures are valid.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the key IDs with valid signatures.
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> ValidKeyIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the key IDs with invalid signatures.
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> InvalidKeyIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message if verification failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful verification result.
|
||||
/// </summary>
|
||||
public static ReachGraphVerificationResult Success(ImmutableArray<string> validKeyIds) =>
|
||||
new()
|
||||
{
|
||||
IsValid = true,
|
||||
ValidKeyIds = validKeyIds,
|
||||
InvalidKeyIds = []
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed verification result.
|
||||
/// </summary>
|
||||
public static ReachGraphVerificationResult Failure(
|
||||
ImmutableArray<string> validKeyIds,
|
||||
ImmutableArray<string> invalidKeyIds,
|
||||
string? error = null) =>
|
||||
new()
|
||||
{
|
||||
IsValid = false,
|
||||
ValidKeyIds = validKeyIds,
|
||||
InvalidKeyIds = invalidKeyIds,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// Licensed to StellaOps under the AGPL-3.0-or-later license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ReachGraph.Hashing;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using StellaOps.ReachGraph.Serialization;
|
||||
|
||||
namespace StellaOps.ReachGraph.Signing;
|
||||
|
||||
/// <summary>
|
||||
/// DSSE-based signing service for reachability graphs.
|
||||
/// Wraps the Attestor envelope signing infrastructure.
|
||||
/// </summary>
|
||||
public sealed class ReachGraphSignerService : IReachGraphSignerService
|
||||
{
|
||||
private const string PayloadType = "application/vnd.stellaops.reachgraph.min+json";
|
||||
|
||||
private readonly IReachGraphKeyStore _keyStore;
|
||||
private readonly CanonicalReachGraphSerializer _serializer;
|
||||
private readonly ReachGraphDigestComputer _digestComputer;
|
||||
private readonly ILogger<ReachGraphSignerService> _logger;
|
||||
|
||||
public ReachGraphSignerService(
|
||||
IReachGraphKeyStore keyStore,
|
||||
CanonicalReachGraphSerializer serializer,
|
||||
ReachGraphDigestComputer digestComputer,
|
||||
ILogger<ReachGraphSignerService> logger)
|
||||
{
|
||||
_keyStore = keyStore ?? throw new ArgumentNullException(nameof(keyStore));
|
||||
_serializer = serializer ?? throw new ArgumentNullException(nameof(serializer));
|
||||
_digestComputer = digestComputer ?? throw new ArgumentNullException(nameof(digestComputer));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ReachGraphMinimal> SignAsync(
|
||||
ReachGraphMinimal graph,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
ArgumentException.ThrowIfNullOrEmpty(keyId);
|
||||
|
||||
_logger.LogDebug("Signing reachability graph with key {KeyId}", keyId);
|
||||
|
||||
// Get canonical JSON (without existing signatures)
|
||||
var unsigned = graph with { Signatures = null };
|
||||
var canonicalBytes = _serializer.SerializeMinimal(unsigned);
|
||||
|
||||
// Compute PAE (Pre-Authentication Encoding) for DSSE
|
||||
var pae = ComputePae(PayloadType, canonicalBytes);
|
||||
|
||||
// Sign with the key
|
||||
var signatureBytes = await _keyStore.SignAsync(keyId, pae, cancellationToken);
|
||||
var signatureBase64 = Convert.ToBase64String(signatureBytes);
|
||||
|
||||
// Create new signature entry
|
||||
var newSignature = new ReachGraphSignature(keyId, signatureBase64);
|
||||
|
||||
// Append to existing signatures (if any) and return
|
||||
var existingSignatures = graph.Signatures ?? [];
|
||||
var allSignatures = existingSignatures.Add(newSignature);
|
||||
|
||||
// Sort signatures by KeyId for determinism
|
||||
allSignatures = [.. allSignatures.OrderBy(s => s.KeyId, StringComparer.Ordinal)];
|
||||
|
||||
_logger.LogInformation(
|
||||
"Signed reachability graph with key {KeyId}, digest {Digest}",
|
||||
keyId, _digestComputer.ComputeDigest(unsigned));
|
||||
|
||||
return graph with { Signatures = allSignatures };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ReachGraphVerificationResult> VerifyAsync(
|
||||
ReachGraphMinimal graph,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
if (graph.Signatures is null or { Length: 0 })
|
||||
{
|
||||
return ReachGraphVerificationResult.Failure([], [], "No signatures to verify");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Verifying {Count} signature(s) on reachability graph", graph.Signatures.Value.Length);
|
||||
|
||||
// Get canonical JSON (without signatures)
|
||||
var unsigned = graph with { Signatures = null };
|
||||
var canonicalBytes = _serializer.SerializeMinimal(unsigned);
|
||||
|
||||
// Compute PAE
|
||||
var pae = ComputePae(PayloadType, canonicalBytes);
|
||||
|
||||
var validKeyIds = new List<string>();
|
||||
var invalidKeyIds = new List<string>();
|
||||
|
||||
foreach (var signature in graph.Signatures.Value)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var signatureBytes = Convert.FromBase64String(signature.Sig);
|
||||
var isValid = await _keyStore.VerifyAsync(signature.KeyId, pae, signatureBytes, cancellationToken);
|
||||
|
||||
if (isValid)
|
||||
{
|
||||
validKeyIds.Add(signature.KeyId);
|
||||
}
|
||||
else
|
||||
{
|
||||
invalidKeyIds.Add(signature.KeyId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or ArgumentException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid signature format for key {KeyId}", signature.KeyId);
|
||||
invalidKeyIds.Add(signature.KeyId);
|
||||
}
|
||||
}
|
||||
|
||||
var isAllValid = invalidKeyIds.Count == 0 && validKeyIds.Count > 0;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Verification result: {Valid} valid, {Invalid} invalid signatures",
|
||||
validKeyIds.Count, invalidKeyIds.Count);
|
||||
|
||||
return isAllValid
|
||||
? ReachGraphVerificationResult.Success([.. validKeyIds])
|
||||
: ReachGraphVerificationResult.Failure(
|
||||
[.. validKeyIds],
|
||||
[.. invalidKeyIds],
|
||||
invalidKeyIds.Count > 0 ? $"{invalidKeyIds.Count} signature(s) failed verification" : null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> CreateDsseEnvelopeAsync(
|
||||
ReachGraphMinimal graph,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
ArgumentException.ThrowIfNullOrEmpty(keyId);
|
||||
|
||||
// Sign the graph first
|
||||
var signedGraph = await SignAsync(graph, keyId, cancellationToken);
|
||||
|
||||
// Get canonical JSON for the signed graph
|
||||
var canonicalBytes = _serializer.SerializeMinimal(signedGraph);
|
||||
|
||||
// Build DSSE envelope JSON
|
||||
return BuildDsseEnvelopeJson(canonicalBytes, signedGraph.Signatures ?? []);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute DSSE Pre-Authentication Encoding (PAE).
|
||||
/// PAE(type, payload) = "DSSEv1" || len(type) || type || len(payload) || payload
|
||||
/// </summary>
|
||||
private static byte[] ComputePae(string payloadType, byte[] payload)
|
||||
{
|
||||
// PAE format: "DSSEv1" + 8-byte LE length of type + type bytes + 8-byte LE length of payload + payload
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
var prefix = Encoding.UTF8.GetBytes("DSSEv1 ");
|
||||
|
||||
var result = new byte[prefix.Length + 8 + typeBytes.Length + 8 + payload.Length];
|
||||
var offset = 0;
|
||||
|
||||
// Copy "DSSEv1 "
|
||||
Buffer.BlockCopy(prefix, 0, result, offset, prefix.Length);
|
||||
offset += prefix.Length;
|
||||
|
||||
// Write type length as 8-byte LE
|
||||
BitConverter.TryWriteBytes(result.AsSpan(offset, 8), (long)typeBytes.Length);
|
||||
offset += 8;
|
||||
|
||||
// Copy type bytes
|
||||
Buffer.BlockCopy(typeBytes, 0, result, offset, typeBytes.Length);
|
||||
offset += typeBytes.Length;
|
||||
|
||||
// Write payload length as 8-byte LE
|
||||
BitConverter.TryWriteBytes(result.AsSpan(offset, 8), (long)payload.Length);
|
||||
offset += 8;
|
||||
|
||||
// Copy payload
|
||||
Buffer.BlockCopy(payload, 0, result, offset, payload.Length);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a DSSE envelope JSON document.
|
||||
/// </summary>
|
||||
private static byte[] BuildDsseEnvelopeJson(byte[] payload, ImmutableArray<ReachGraphSignature> signatures)
|
||||
{
|
||||
var payloadBase64 = Convert.ToBase64String(payload);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new System.Text.Json.Utf8JsonWriter(ms);
|
||||
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("payloadType", PayloadType);
|
||||
writer.WriteString("payload", payloadBase64);
|
||||
writer.WritePropertyName("signatures");
|
||||
writer.WriteStartArray();
|
||||
|
||||
foreach (var sig in signatures.OrderBy(s => s.KeyId, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("keyid", sig.KeyId);
|
||||
writer.WriteString("sig", sig.Sig);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
writer.Flush();
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RootNamespace>StellaOps.ReachGraph</RootNamespace>
|
||||
<Description>Unified reachability subgraph store for StellaOps</Description>
|
||||
<Authors>StellaOps</Authors>
|
||||
<PackageId>StellaOps.ReachGraph</PackageId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Blake3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.ReachGraph.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user