39 KiB
Nice, let’s lock this in so your dev can basically copy‑paste and go.
I’ll give you:
- JSON schemas (Draft 2020‑12)
- Example JSON documents (edge + manifest + DSSE envelope)
- C# interfaces:
IEdgeExtractor,IAttestor,IReplayer,ITransparencyClient - A quick ticket-style breakdown you can drop into Jira/Linear
1. JSON Schemas
1.1 DSSE Envelope schema
File: schemas/dsse-envelope-v1.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://stellaops.dev/schemas/dsse-envelope-v1.json",
"title": "DSSE Envelope",
"type": "object",
"required": ["payloadType", "payload", "signatures"],
"properties": {
"payloadType": {
"type": "string",
"description": "Type URL of the statement, e.g. stellaops.dev/call-edge/v1"
},
"payload": {
"type": "string",
"description": "Base64-encoded JSON for the statement"
},
"signatures": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["keyid", "sig"],
"properties": {
"keyid": {
"type": "string",
"description": "Key identifier for the signing key"
},
"sig": {
"type": "string",
"description": "Base64-encoded signature over DSSE PAE(payloadType, payload)"
}
},
"additionalProperties": false
}
}
},
"additionalProperties": false
}
1.2 EdgeStatement schema
File: schemas/call-edge-statement-v1.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://stellaops.dev/schemas/call-edge-statement-v1.json",
"title": "Call Edge Statement",
"type": "object",
"required": ["statementType", "subject", "edge", "provenance", "policyHash"],
"properties": {
"statementType": {
"type": "string",
"const": "stellaops.dev/call-edge/v1"
},
"subject": {
"$ref": "#/$defs/BinarySubject"
},
"edge": {
"$ref": "#/$defs/CallEdge"
},
"provenance": {
"$ref": "#/$defs/ToolProvenance"
},
"policyHash": {
"type": "string",
"minLength": 1,
"description": "Hash (e.g. sha256) of the policy/config/lattice used"
}
},
"$defs": {
"BinarySubject": {
"type": "object",
"required": ["type", "name", "digest"],
"properties": {
"type": {
"type": "string",
"enum": ["file"]
},
"name": {
"type": "string",
"description": "Human-friendly name, usually the filename"
},
"digest": {
"type": "object",
"description": "Map of algorithm name -> hex digest (e.g. sha256, sha512)",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
},
"minProperties": 1
},
"buildId": {
"type": "string",
"description": "Optional platform-specific build ID (e.g. PE timestamp+size, ELF build-id)"
}
},
"additionalProperties": false
},
"FunctionId": {
"type": "object",
"required": ["binaryId", "kind", "value"],
"properties": {
"binaryId": {
"type": "string",
"description": "Identifier linking back to subject.digest or buildId"
},
"kind": {
"type": "string",
"enum": ["RVA", "Symbol", "Pdb", "Other"]
},
"value": {
"type": "string",
"description": "RVA (e.g. 0x401000) or fully qualified symbol name"
}
},
"additionalProperties": false
},
"CallEdge": {
"type": "object",
"required": ["edgeId", "caller", "callee", "reason"],
"properties": {
"edgeId": {
"type": "string",
"description": "Deterministic ID (e.g. sha256 of canonical edge tuple)"
},
"caller": {
"$ref": "#/$defs/FunctionId"
},
"callee": {
"$ref": "#/$defs/FunctionId"
},
"reason": {
"type": "string",
"enum": [
"StaticImportThunk",
"StaticDirectCall",
"StaticCtorOrInitArray",
"ExceptionHandler",
"JumpTable",
"DynamicTraceWitness"
]
},
"evidenceHash": {
"type": ["string", "null"],
"description": "Optional hash of attached evidence (CFG snippet, trace chunk, etc.)"
}
},
"additionalProperties": false
},
"ToolProvenance": {
"type": "object",
"required": ["toolName", "toolVersion"],
"properties": {
"toolName": {
"type": "string"
},
"toolVersion": {
"type": "string"
},
"hostOs": {
"type": "string"
},
"runtime": {
"type": "string",
"description": ".NET runtime or other execution environment"
},
"pipelineRunId": {
"type": "string",
"description": "CI/CD or local run identifier"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
1.3 GraphManifest schema
File: schemas/call-graph-manifest-v1.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://stellaops.dev/schemas/call-graph-manifest-v1.json",
"title": "Call Graph Manifest",
"type": "object",
"required": ["manifestType", "subject", "edgeEnvelopeDigests", "roots", "provenance", "policyHash"],
"properties": {
"manifestType": {
"type": "string",
"const": "stellaops.dev/call-graph-manifest/v1"
},
"subject": {
"$ref": "#/$defs/BinarySubject"
},
"edgeEnvelopeDigests": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"description": "Hex sha256 digest of a DSSE envelope JSON blob"
}
},
"roots": {
"type": "array",
"items": {
"$ref": "#/$defs/FunctionId"
},
"description": "Entrypoints / roots used for reachability"
},
"provenance": {
"$ref": "#/$defs/ToolProvenance"
},
"policyHash": {
"type": "string",
"description": "Hash (e.g. sha256) of the policy/config/lattice used"
}
},
"$defs": {
"BinarySubject": {
"type": "object",
"required": ["type", "name", "digest"],
"properties": {
"type": {
"type": "string",
"enum": ["file"]
},
"name": {
"type": "string"
},
"digest": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
},
"minProperties": 1
},
"buildId": {
"type": "string"
}
},
"additionalProperties": false
},
"FunctionId": {
"type": "object",
"required": ["binaryId", "kind", "value"],
"properties": {
"binaryId": {
"type": "string"
},
"kind": {
"type": "string",
"enum": ["RVA", "Symbol", "Pdb", "Other"]
},
"value": {
"type": "string"
}
},
"additionalProperties": false
},
"ToolProvenance": {
"type": "object",
"required": ["toolName", "toolVersion"],
"properties": {
"toolName": {
"type": "string"
},
"toolVersion": {
"type": "string"
},
"hostOs": {
"type": "string"
},
"runtime": {
"type": "string"
},
"pipelineRunId": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
2. Example JSON documents
2.1 Example EdgeStatement
{
"statementType": "stellaops.dev/call-edge/v1",
"subject": {
"type": "file",
"name": "myservice.dll",
"digest": {
"sha256": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684aa...",
"sha512": "0b6bf8fdfa..."
},
"buildId": "dotnet:mvid:2e81a930-6d5d-4862-b6fd-b6d2a5a8af93"
},
"edge": {
"edgeId": "b3b40b0e5cfac0dfe5e04dfe0e53e3fb90b504e0be6e0cf1b79dd6e0db9ab012",
"caller": {
"binaryId": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684aa...",
"kind": "Pdb",
"value": "MyCompany.Service.Controllers.UserController::GetUser"
},
"callee": {
"binaryId": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684aa...",
"kind": "Pdb",
"value": "MyCompany.Service.Core.UserRepository::GetById"
},
"reason": "StaticDirectCall",
"evidenceHash": "8af02b0ea518f50b4c4735a3df0e93c7c8d3e0f1d2..."
},
"provenance": {
"toolName": "StellaOps.CallGraph",
"toolVersion": "1.0.0",
"hostOs": "Windows 11.0.22631",
"runtime": ".NET 9.0.0",
"pipelineRunId": "build-12345"
},
"policyHash": "e6a9a7b1b909b2ba03b6f71a0d13a5b9b2f3e97832..."
}
2.2 Example GraphManifest
{
"manifestType": "stellaops.dev/call-graph-manifest/v1",
"subject": {
"type": "file",
"name": "myservice.dll",
"digest": {
"sha256": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684aa..."
},
"buildId": "dotnet:mvid:2e81a930-6d5d-4862-b6fd-b6d2a5a8af93"
},
"edgeEnvelopeDigests": [
"f7b0b1e47f599f8990dd52978a0aee22722e7bcb9e30...",
"180a2a6e065d667ea2290da8b7ad7010c0d7af9aec33..."
],
"roots": [
{
"binaryId": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684aa...",
"kind": "Pdb",
"value": "MyCompany.Service.Program::Main"
}
],
"provenance": {
"toolName": "StellaOps.CallGraph",
"toolVersion": "1.0.0",
"hostOs": "Windows 11.0.22631",
"runtime": ".NET 9.0.0",
"pipelineRunId": "build-12345"
},
"policyHash": "e6a9a7b1b909b2ba03b6f71a0d13a5b9b2f3e97832..."
}
2.3 Example DSSE envelope wrapping the EdgeStatement
{
"payloadType": "stellaops.dev/call-edge/v1",
"payload": "eyJzdGF0ZW1lbnRUeXBlIjoi...base64-of-edge-statement-json...IiwicG9saWN5SGFzaCI6Ii4uLiJ9",
"signatures": [
{
"keyid": "edge-signing-key-1",
"sig": "k5p5+1Hz+PZd...base64-signature..."
}
]
}
3. C# interfaces
Drop these into StellaOps.CallGraph.Core (or split into Core / Attestation / Transparency as you prefer).
3.1 Shared models (minimal, just to compile the interfaces)
public sealed record BinarySubject(
string Name,
string Sha256,
string? BuildId
);
public sealed record FunctionId(
string BinaryId,
string Kind,
string Value
);
public enum EdgeReasonKind
{
StaticImportThunk,
StaticDirectCall,
StaticCtorOrInitArray,
ExceptionHandler,
JumpTable,
DynamicTraceWitness
}
public sealed record CallEdge(
string EdgeId,
FunctionId Caller,
FunctionId Callee,
EdgeReasonKind Reason,
string? EvidenceHash
);
public sealed record EdgeStatement(
string StatementType,
BinarySubject Subject,
CallEdge Edge,
ToolProvenance Provenance,
string PolicyHash
);
public sealed record ToolProvenance(
string ToolName,
string ToolVersion,
string HostOs,
string Runtime,
string PipelineRunId
);
public sealed record GraphManifest(
string ManifestType,
BinarySubject Subject,
IReadOnlyList<string> EdgeEnvelopeDigests,
IReadOnlyList<FunctionId> Roots,
ToolProvenance Provenance,
string PolicyHash
);
public sealed record DsseSignature(string KeyId, string Sig);
public sealed record DsseEnvelope(
string PayloadType,
string Payload,
IReadOnlyList<DsseSignature> Signatures
);
3.2 IEdgeExtractor
One extractor per “strategy” (IL calls, import table, .ctors, traces, etc.)
public sealed record BinaryContext(
string Path,
byte[] Bytes,
BinarySubject Subject
// optional: add parsed PE/ELF metadata, debug info handles, etc.
);
public interface IEdgeExtractor
{
/// Unique ID for this extractor (used in logs / debugging).
string Id { get; }
/// Human readable description, e.g. "Managed IL direct calls".
string Description { get; }
/// Extract edges from the binary context.
Task<IReadOnlyList<CallEdge>> ExtractAsync(
BinaryContext context,
CancellationToken cancellationToken = default);
}
You can have ManagedIlEdgeExtractor, PeImportTableEdgeExtractor, ElfInitArrayEdgeExtractor, TraceLogEdgeExtractor, etc.
3.3 IAttestor
Wraps DSSE signing/verification for both edges and manifests.
public interface IAttestor
{
/// Sign a single call edge statement as DSSE.
Task<DsseEnvelope> SignEdgeAsync(
EdgeStatement statement,
CancellationToken cancellationToken = default);
/// Verify DSSE signature and PAE for an edge envelope.
Task<bool> VerifyEdgeAsync(
DsseEnvelope envelope,
CancellationToken cancellationToken = default);
/// Sign the call graph manifest as DSSE.
Task<DsseEnvelope> SignManifestAsync(
GraphManifest manifest,
CancellationToken cancellationToken = default);
/// Verify DSSE signature and PAE for a manifest envelope.
Task<bool> VerifyManifestAsync(
DsseEnvelope envelope,
CancellationToken cancellationToken = default);
}
Implementation DsseAttestor plugs in your actual crypto (ISigningKey or KMS client).
3.4 ITransparencyClient (Rekor / log abstraction)
public sealed record TransparencyEntryRef(
string EntryId,
long? Index,
string? LogId
);
public sealed record TransparencyInclusionProof(
string EntryId,
string RootHash,
int TreeSize,
IReadOnlyList<string> AuditPath
);
public interface ITransparencyClient
{
/// Publish a DSSE envelope to the transparency log.
Task<TransparencyEntryRef> PublishAsync(
DsseEnvelope envelope,
CancellationToken cancellationToken = default);
/// Fetch an inclusion proof (if supported).
Task<TransparencyInclusionProof?> GetInclusionProofAsync(
string entryId,
CancellationToken cancellationToken = default);
}
For v1 you can stub this out with a filesystem log and later swap in a Rekor client.
3.5 IReplayer (deterministic offline verification)
This is the “does reality match the attestation?” API.
public sealed record ReplayMismatch(
string Kind, // "MissingEdge", "ExtraEdge", "RootMismatch", etc.
string Details
);
public sealed record ReplayResult(
bool Success,
IReadOnlyList<ReplayMismatch> Mismatches
);
public interface IReplayer
{
/// Deterministically recompute call graph and compare with an attested manifest + envelopes.
Task<ReplayResult> ReplayAsync(
string binaryPath,
GraphManifest manifest,
IReadOnlyList<DsseEnvelope> edgeEnvelopes,
CancellationToken cancellationToken = default);
}
Implementation CallGraphReplayer just:
- Rebuilds
BinarySubjectfrombinaryPath. - Re-runs your configured
IEdgeExtractorchain. - Regenerates EdgeIds.
- Compares EdgeId set + roots with the manifest and envelopes.
4. Ticket-style breakdown (you can paste this directly)
You can rename IDs to match your project convention.
CG-1 – Core data contracts
- Create
StellaOps.CallGraph.Coreproject. - Add records:
BinarySubject,FunctionId,CallEdge,ToolProvenance,EdgeStatement,GraphManifest,DsseSignature,DsseEnvelope. - Add
BinaryContextstruct/record. - Add
EdgeReasonKindenum. - Add
Hashinghelper (SHA‑256 hex).
CG-2 – JSON schema + serialization
-
Add JSON schema files:
dsse-envelope-v1.jsoncall-edge-statement-v1.jsoncall-graph-manifest-v1.json
-
Configure tests that serialize sample
EdgeStatement/GraphManifestand validate against schemas (optional but nice).
CG-3 – Edge extractor abstraction
-
Add
IEdgeExtractorinterface. -
Add
BinaryContextbuilder that:- Reads bytes from file path
- Computes
BinarySubject(sha256 + buildId placeholder).
CG-4 – MVP PE managed IL extractor
-
Reference
Mono.Cecil(or chosen IL reader). -
Implement
ManagedIlEdgeExtractor : IEdgeExtractorthat:- Iterates IL, emits
StaticDirectCalledges. - Computes deterministic
EdgeIdusing canonical string + SHA‑256.
- Iterates IL, emits
-
Unit tests: simple test DLL with one method calling another → expect a single edge.
CG-5 – DSSE attestation
-
Add
ISigningKeyabstraction and one file-based implementation (RSA or Ed25519). -
Implement
DsseAttestor : IAttestor:- PAE implementation.
- Edge + manifest signing & verification.
-
Tests:
- Round-trip sign/verify for edge and manifest.
- Tamper payload → verify fails.
CG-6 – Transparency client stub
- Add
ITransparencyClient+ modelsTransparencyEntryRef,TransparencyInclusionProof. - Implement
FileTransparencyClientthat appends entries to a JSONL file, returns synthetic IDs. - Add integration test: publish envelope, read it back.
CG-7 – Manifest builder & orchestration
-
Implement
ManifestBuilder(or a small service class) that:- Takes
BinarySubject, list of edge envelopes, list of roots, tool provenance, policy hash. - Computes
edgeEnvelopeDigestssorted deterministically.
- Takes
-
Add a
CallGraphAttestationServicethat:- Accepts binary path.
- Builds
BinaryContext. - Runs all registered
IEdgeExtractors. - Signs each
EdgeStatementviaIAttestor. - Builds and signs
GraphManifest. - Optionally publishes envelopes via
ITransparencyClient.
CG-8 – Replayer
-
Implement
CallGraphReplayer : IReplayer:- Recompute
BinarySubjectfrombinaryPath. - Run extractors.
- Compare recomputed EdgeIds against those inferred from envelopes/manifest.
- Populate
ReplayResultwithReplayMismatchentries.
- Recompute
-
Tests:
- Self-consistency test (same config →
Success = true). - Edge removed/added →
Success = falsewith appropriate mismatches.
- Self-consistency test (same config →
CG-9 – CLI
-
New project
StellaOps.CallGraph.Cli. -
Use
System.CommandLine. -
Commands:
analyze <binary> --out <dir>verify <binary> --manifest <file> --edges <dir>
-
Wire up DI for
IEdgeExtractor,IAttestor,ITransparencyClient,IReplayer. -
Integration test: run
analyzeon a sample binary, thenverify→ exit code 0.
If you’d like, next step I can help you decide concrete PE / ELF libraries for each platform and sketch one full extractor implementation end‑to‑end (e.g., import-table edges for Windows PE). Alright, let’s turn this into something your devs can literally work through story by story.
I’ll structure it as phases → epics → concrete tasks, with what to build, implementation hints, and acceptance criteria. You can paste these straight into Jira/Linear.
Phase 0 – Solution scaffolding & baseline
Epic 0.1 – Solution & projects
Goal: Have a clean .NET solution with the right projects and references.
Tasks
T0.1.1 – Create solution & projects
-
Create solution:
StellaOps.CallGraph.sln -
Projects:
StellaOps.CallGraph.Core(Class Library)StellaOps.CallGraph.BinaryParsers(Class Library)StellaOps.CallGraph.EdgeExtraction(Class Library)StellaOps.CallGraph.Attestation(Class Library)StellaOps.CallGraph.Cli(Console App)StellaOps.CallGraph.Tests(xUnit/NUnit)
-
Set
LangVersionto latest and target your.NET 10TFM.
Acceptance criteria
dotnet buildsucceeds.- Projects reference each other as expected (Core ← BinaryParsers/EdgeExtraction/Attestation, Cli references all).
T0.1.2 – Add core dependencies
In relevant projects, add NuGet packages:
-
Core:
System.Text.Json
-
BinaryParsers:
System.Reflection.Metadata- (later)
Mono.Cecilor separate in EdgeExtraction
-
EdgeExtraction:
Mono.Cecil
-
CLI:
System.CommandLine
-
Tests:
xunit/NUnitFluentAssertions(optional, but nice)
Acceptance criteria
- All packages restored successfully.
- No unused or redundant packages.
Phase 1 – Core domain models & contracts
Epic 1.1 – Data contracts
Goal: Have a shared, stable model for binaries, functions, edges, manifests, DSSE envelopes.
Tasks
T1.1.1 – Implement core records
In StellaOps.CallGraph.Core add:
BinarySubjectFunctionIdEdgeReasonKind(enum)CallEdgeToolProvenanceEdgeStatementGraphManifestDsseSignatureDsseEnvelope
Use auto-properties or records; keep them immutable where possible.
Key details
-
BinarySubject:Name(file name or logical name)Sha256(hex string)BuildId(nullable)
-
FunctionId:BinaryIdpointing atBinarySubject.Sha256orBuildIdKindstring:"RVA" | "Symbol" | "Pdb" | "Other"Valuestring: RVA (hex), symbol name, or metadata token
-
CallEdge:EdgeId(computed deterministic ID)Caller,Callee(FunctionId)Reason(EdgeReasonKind)EvidenceHash(nullable)
Acceptance criteria
- Models compile.
- Public properties clearly named.
- No business logic baked into these types yet.
T1.1.2 – Implement EdgeReasonKind enum
Values (at least):
public enum EdgeReasonKind
{
StaticImportThunk,
StaticDirectCall,
StaticCtorOrInitArray,
ExceptionHandler,
JumpTable,
DynamicTraceWitness
}
Acceptance criteria
- Enum used by
CallEdgeand anywhere reasons are exposed. - No magic strings for reasons used anywhere else.
Phase 2 – Binary abstraction & managed PE parser
Epic 2.1 – Binary context & hashing
Goal: Standard way to represent “this binary we’re analyzing”.
Tasks
T2.1.1 – Implement hashing helper
In Core:
public static class Hashing
{
public static string Sha256Hex(Stream stream)
{
using var sha = SHA256.Create();
var hash = sha.ComputeHash(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
public static string Sha256Hex(byte[] data) { ... }
}
Acceptance criteria
- Unit test: Known byte input → expected hex string.
- Works on large streams without loading entire file into memory (stream-based).
T2.1.2 – Define BinaryContext
public sealed record BinaryContext(
string Path,
BinarySubject Subject,
byte[] Bytes
// Later: add parsed metadata if needed
);
Acceptance criteria
BinaryContextencapsulates exactly what extractors need now (path + subject + raw bytes).- No parser-specific details leak into it yet.
Epic 2.2 – IBinaryParser and managed implementation
Goal: For a .NET assembly, identify it and list functions + roots.
Tasks
T2.2.1 – Define IBinaryParser
In Core:
public interface IBinaryParser
{
BinaryContext Load(string path); // compute subject + read bytes
IReadOnlyList<FunctionId> GetAllFunctions(BinaryContext context);
IReadOnlyList<FunctionId> GetRoots(BinaryContext context);
}
Acceptance criteria
- Interface compiles.
- No implementation yet.
T2.2.2 – Implement PeManagedBinaryParser
In BinaryParsers:
-
Use
FileStream+Hashing.Sha256Hexto compute SHA-256. -
Use
PEReader+MetadataReaderto:- Confirm this is a managed assembly (has metadata).
- Extract MVID & assembly name if needed.
-
Build
BinarySubject:Name= filename without path.Sha256= hash.BuildId="dotnet:mvid:<guid>".
-
Load(path):- Compute hash, read bytes to memory, return
BinaryContext.
- Compute hash, read bytes to memory, return
Implementation hints
using var stream = File.OpenRead(path);
var sha256 = Hashing.Sha256Hex(stream);
stream.Position = 0;
var peReader = new PEReader(stream);
var mdReader = peReader.GetMetadataReader();
var mvid = mdReader.GetGuid(mdReader.GetModuleDefinition().Mvid);
var subject = new BinarySubject(
Name: Path.GetFileName(path),
Sha256: sha256,
BuildId: $"dotnet:mvid:{mvid:D}"
);
T2.2.3 – Implement GetAllFunctions
- Re-open assembly with
Mono.Cecil(simpler for IL later):
var module = ModuleDefinition.ReadModule(context.Path);
foreach (var type in module.Types)
foreach (var method in type.Methods)
{
var funcId = new FunctionId(
BinaryId: context.Subject.Sha256,
Kind: "Pdb",
Value: $"{type.FullName}::{method.Name}"
);
}
T2.2.4 – Implement GetRoots
First pass (simple but deterministic):
-
Root =
Program.Main-like method in entry assembly:- Find method(s) with name
"Main"in public types under namespaces like*.ProgramorProgram.
- Find method(s) with name
-
Mark them as roots: same
FunctionIdrepresentation.
Later you can extend to:
- Public API surface (public methods in public types).
- Static constructors
.cctor.
Acceptance criteria (for Epic 2.2)
-
Unit test assembly with simple code:
GetAllFunctionsreturns all methods (at least user-defined).GetRootsincludesProgram.Main.
-
If assembly is not managed (no metadata),
PeManagedBinaryParserfails gracefully with a clear exception or sentinel result (you can add aCanParse(path)later if needed).
Phase 3 – Edge extraction (managed IL)
Epic 3.1 – Edge extractor abstraction
Goal: Have a pluggable way to produce edges from a BinaryContext.
Tasks
T3.1.1 – Define IEdgeExtractor
public interface IEdgeExtractor
{
string Id { get; }
string Description { get; }
Task<IReadOnlyList<CallEdge>> ExtractAsync(
BinaryContext context,
CancellationToken cancellationToken = default);
}
Acceptance criteria
- Interface compiles.
- No dependencies on dsse/transparency yet.
Epic 3.2 – Managed IL direct-call extractor
Goal: For .NET assemblies, emit StaticDirectCall edges using IL.
Tasks
T3.2.1 – Implement ManagedIlEdgeExtractor basics
In EdgeExtraction:
-
Use
Mono.Cecilto re-opencontext.Path. -
Iterate
module.Types/type.MethodswhereHasBody. -
For each instruction in
method.Body.Instructions:- If
instr.OpCode.FlowControlisFlowControl.Callandinstr.OperandisMethodReference→ create edge.
- If
Caller FunctionId:
var callerId = new FunctionId(
BinaryId: context.Subject.Sha256,
Kind: "Pdb",
Value: $"{method.DeclaringType.FullName}::{method.Name}"
);
Callee FunctionId:
var calleeId = new FunctionId(
BinaryId: context.Subject.Sha256, // same binary for now
Kind: "Pdb",
Value: $"{calleeMethod.DeclaringType.FullName}::{calleeMethod.Name}"
);
T3.2.2 – Implement deterministic EdgeId generation
Add helper in Core:
public static class EdgeIdGenerator
{
public static string Compute(CallEdge edgeWithoutId)
{
var canonical = string.Join("|", new[]
{
edgeWithoutId.Caller.BinaryId,
edgeWithoutId.Caller.Kind,
edgeWithoutId.Caller.Value,
edgeWithoutId.Callee.BinaryId,
edgeWithoutId.Callee.Kind,
edgeWithoutId.Callee.Value,
edgeWithoutId.Reason.ToString()
});
var bytes = Encoding.UTF8.GetBytes(canonical);
return Hashing.Sha256Hex(bytes);
}
}
Then in ManagedIlEdgeExtractor, build edge without ID, compute ID, then create the full CallEdge.
T3.2.3 – Set reason & evidence
-
For now:
Reason = EdgeReasonKind.StaticDirectCallEvidenceHash = null(or hash IL snippet later).
T3.2.4 – Unit tests
Create a tiny test assembly (project) like:
public class A
{
public void Caller() => Callee();
public void Callee() { }
}
Then in test:
-
Run
ManagedIlEdgeExtractoron that assembly. -
Assert:
-
There is an edge where:
Caller.Valuecontains"A::Caller"Callee.Valuecontains"A::Callee"Reason == StaticDirectCall
-
EdgeIdis non-empty and stable across runs (run twice, compare).
-
Acceptance criteria (Epic 3.2)
- For simple assemblies, edges match expected calls.
- No unhandled exceptions on normal assemblies.
Phase 4 – DSSE attestation engine
Epic 4.1 – DSSE PAE & signing abstraction
Goal: Ability to sign statements (edges, manifest) with DSSE envelopes.
Tasks
T4.1.1 – Implement PAE helper
In Attestation:
public static class DssePaEncoder
{
public static byte[] PreAuthEncode(string payloadType, byte[] payload)
{
static byte[] Utf8(string s) => Encoding.UTF8.GetBytes(s);
static byte[] Concat(params byte[][] parts) { ... }
var header = Utf8("DSSEv1");
var pt = Utf8(payloadType);
var ptLen = Utf8(pt.Length.ToString(CultureInfo.InvariantCulture));
var payloadLen = Utf8(payload.Length.ToString(CultureInfo.InvariantCulture));
var space = Utf8(" ");
return Concat(header, space, ptLen, space, pt, space, payloadLen, space, payload);
}
}
T4.1.2 – Define ISigningKey abstraction
public interface ISigningKey
{
string KeyId { get; }
Task<byte[]> SignAsync(byte[] data, CancellationToken ct = default);
Task<bool> VerifyAsync(byte[] data, byte[] signature, CancellationToken ct = default);
}
Implement a simple file-based Ed25519 or RSA keypair later.
T4.1.3 – Implement DsseAttestor
Implements IAttestor:
-
SignEdgeAsync(EdgeStatement):- Serialize
EdgeStatementto JSON. - Compute
pae = DssePaEncoder.PreAuthEncode(payloadType, payloadBytes). sigBytes = ISigningKey.SignAsync(pae).- Return
DsseEnvelope(payloadType, base64(payloadBytes), [sig]).
- Serialize
-
VerifyEdgeAsync(DsseEnvelope):- Decode payload.
- Recompute PAE.
- Verify signature with
ISigningKey.VerifyAsync.
Do the same for GraphManifest.
Serialization settings
Use System.Text.Json with:
new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
T4.1.4 – Unit tests
-
Use a test signing key with deterministic signatures.
-
Sign + verify:
- EdgeStatement → DSSE → verify returns true.
- Tamper
payloadand ensure verify returns false.
Acceptance criteria (Epic 4.1)
- DSSE envelopes created and verified locally.
- Code is isolated from edge extraction (no cyclical dependencies).
Phase 5 – Call graph pipeline & manifest
Epic 5.1 – Attestation orchestrator
Goal: End-to-end: parse binary → extract edges → sign edges → build & sign manifest.
Tasks
T5.1.1 – Implement CallGraphAttestationService
In Attestation (or a separate Orchestration project if you prefer):
public sealed class CallGraphAttestationService
{
private readonly IBinaryParser _parser;
private readonly IEnumerable<IEdgeExtractor> _extractors;
private readonly IAttestor _attestor;
private readonly ToolProvenance _provenance;
private readonly string _policyHash;
public async Task<(DsseEnvelope ManifestEnvelope,
IReadOnlyList<DsseEnvelope> EdgeEnvelopes,
GraphManifest Manifest)>
AnalyzeAndAttestAsync(string binaryPath, CancellationToken ct = default)
{
// 1) Load binary
// 2) Run extractors
// 3) Build EdgeStatements & DSSE envelopes
// 4) Compute manifest & DSSE envelope
}
}
Implementation detail
- Load:
var context = _parser.Load(binaryPath);
- Extract edges:
var edges = new List<CallEdge>();
foreach (var extractor in _extractors)
{
var result = await extractor.ExtractAsync(context, ct);
edges.AddRange(result);
}
- Build
EdgeStatementper edge:
var stmt = new EdgeStatement(
StatementType: "stellaops.dev/call-edge/v1",
Subject: context.Subject,
Edge: edge,
Provenance: _provenance,
PolicyHash: _policyHash
);
var env = await _attestor.SignEdgeAsync(stmt, ct);
- Compute
edgeEnvelopeDigests:
var envelopeDigests = edgeEnvelopes
.Select(e =>
Hashing.Sha256Hex(
Encoding.UTF8.GetBytes(JsonSerializer.Serialize(e, jsonOptions))))
.OrderBy(x => x) // deterministic order
.ToList();
- Build roots:
var roots = _parser.GetRoots(context);
- Build
GraphManifest& sign.
T5.1.2 – Unit test: vertical slice
-
Use the simple test assembly from earlier.
-
Wire up:
PeManagedBinaryParserManagedIlEdgeExtractorDsseAttestorwith test signing key.- Simple
ToolProvenance&policyHashconstant.
-
Assert:
- At least one edge envelope produced.
- Manifest includes same count of
edgeEnvelopeDigests. VerifyManifestAsyncandVerifyEdgeAsyncreturn true.
Acceptance criteria (Epic 5.1)
- You can call one method in code and get a manifest DSSE + edge DSSEs for a binary.
- Tests confirm everything compiles and verifies.
Phase 6 – Transparency log client (Rekor or equivalent)
Epic 6.1 – Abstract transparency client
Goal: Publish envelopes to a transparency log (real or stub).
Tasks
T6.1.1 – Implement ITransparencyClient
As previously defined:
public interface ITransparencyClient
{
Task<TransparencyEntryRef> PublishAsync(
DsseEnvelope envelope,
CancellationToken cancellationToken = default);
Task<TransparencyInclusionProof?> GetInclusionProofAsync(
string entryId,
CancellationToken cancellationToken = default);
}
T6.1.2 – Implement FileTransparencyClient (MVP)
-
Writes each envelope as a JSON line into a file:
- e.g.,
transparency-log.jsonl.
- e.g.,
-
EntryId= SHA-256 of envelope JSON. -
Index= line number. -
LogId= fixed string"local-file-log".
Acceptance criteria
- Publishing several envelopes appends lines.
- No concurrency issues for single-process usage.
T6.1.3 – Integrate into CallGraphAttestationService (optional in v1)
-
After signing edges & manifest:
- Optionally call
_transparencyClient.PublishAsync(...)for each envelope.
- Optionally call
-
Return transparency references if you want, or store them alongside.
Phase 7 – Replayer / verifier
Epic 7.1 – Offline replay
Goal: Given a binary + manifest + edge envelopes, recompute graph and compare.
Tasks
T7.1.1 – Implement IReplayer
public sealed record ReplayMismatch(string Kind, string Details);
public sealed record ReplayResult(bool Success, IReadOnlyList<ReplayMismatch> Mismatches);
public interface IReplayer
{
Task<ReplayResult> ReplayAsync(
string binaryPath,
GraphManifest manifest,
IReadOnlyList<DsseEnvelope> edgeEnvelopes,
CancellationToken cancellationToken = default);
}
T7.1.2 – Implement CallGraphReplayer
Steps:
-
Verify manifest subject vs current file:
- Recompute SHA-256 of
binaryPath. - Compare to
manifest.Subject.digest["sha256"](orSha256property). - If mismatch → add
ReplayMismatch("BinaryDigestMismatch", "...").
- Recompute SHA-256 of
-
Re-run analysis:
- Use same
IBinaryParser+IEdgeExtractorset as production. - Build set of recomputed
EdgeIds.
- Use same
-
Parse envelopes:
-
For each edge envelope:
- Verify DSSE signature (using same
IAttestor.VerifyEdgeAsync). - Decode payload and deserialize
EdgeStatement. - Collect attested
EdgeIds into a set.
- Verify DSSE signature (using same
-
-
Compare:
MissingEdge: in attested set but not recomputed.ExtraEdge: recomputed but not in attested.RootMismatch: any difference between recomputed roots andmanifest.Roots.
-
Set
Success = (no mismatches).
T7.1.3 – Tests
-
Use the vertical-slice test:
- Generate attestation.
- Call replayer → expect
Success = true.
-
Modify binary (or edges) slightly:
- Rebuild binary without re-attesting.
- Replay → expect
BinaryDigestMismatchor edge differences.
Acceptance criteria
- Replayer can detect changed binaries or changed call graph.
- No unhandled exceptions on normal flows.
Phase 8 – CLI & developer UX
Epic 8.1 – CLI commands
Goal: Simple commands devs can run locally.
Tasks
T8.1.1 – Implement analyze command
In StellaOps.CallGraph.Cli:
-
Command:
analyze <binary> --out <dir> -
Behavior:
-
Resolve services via DI:
IBinaryParser→PeManagedBinaryParserIEdgeExtractor→ManagedIlEdgeExtractorIAttestor→DsseAttestorwith test key (configurable later)ITransparencyClient→FileTransparencyClientor no-op
-
Call
CallGraphAttestationService.AnalyzeAndAttestAsync. -
Write:
manifest.dsse.jsonedge-000001.dsse.json, etc.
-
T8.1.2 – Implement verify command
-
Command:
verify <binary> --manifest <manifest.dsse.json> --edges-dir <dir> -
Behavior:
-
Load manifest DSSE, verify signature, deserialize
GraphManifest. -
Load all edge DSSE JSON files from directory.
-
Call
IReplayer.ReplayAsync. -
Exit code:
0ifSuccess = true.- Non-zero if mismatches.
-
Acceptance criteria
- Running
analyzeon sample binary produces DSSE files. - Running
verifyon same binary + outputs returns success (exit code 0). - Help text is clear:
stella-callgraph --help.
Phase 9 – Hardening & non-functional aspects
Epic 9.1 – Logging & error handling
Tasks
-
Add minimal logging via
Microsoft.Extensions.Logging. -
Log:
- Binary path, subject hash.
- Number of edges extracted per extractor.
- Errors with context (but no sensitive key material).
Acceptance criteria
- Mis-parsed binaries or invalid IL lead to clear error logs, not silent failures.
Epic 9.2 – Configuration & keys
Tasks
-
Add configuration model for:
- DSSE signing key location / KMS.
- Policy hash value (string).
- Transparency log settings (file path or HTTP endpoint).
-
Use
IConfigurationinside CLI.
Acceptance criteria
- You can switch signing keys or policy hash without code change.
- No private keys are ever written to logs.
Suggested order for the team
If you want a straight, “do this in order” list for devs:
- Phase 0 – Projects & deps (T0.1.x, T2.1.1).
- Phase 1 – Core models (T1.1.x).
- Phase 2 –
IBinaryParser+PeManagedBinaryParser+ hashing (T2.2.x). - Phase 3 –
IEdgeExtractor+ManagedIlEdgeExtractor+ EdgeId (T3.1.x, T3.2.x). - Phase 4 – DSSE PAE +
ISigningKey+DsseAttestor(T4.1.x). - Phase 5 –
CallGraphAttestationServicevertical slice (T5.1.x). - Phase 6 – Simple
FileTransparencyClient(T6.1.x). - Phase 7 –
IReplayer+CallGraphReplayer(T7.1.x). - Phase 8 – CLI
analyze+verifycommands (T8.1.x). - Phase 9 – Logging, config, hardening (T9.1.x, T9.2.x).
If you’d like, I can next expand any one epic (e.g. “Edge extraction” or “DSSE attestation”) into even more concrete pseudo-code so a dev can almost just fill in the blanks.