Files
git.stella-ops.org/docs/product-advisories/27-Nov-2025 - Verifying Binary Reachability via DSSE Envelopes.md
master e950474a77
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
up
2025-11-27 15:16:31 +02:00

39 KiB
Raw Blame History

Nice, lets lock this in so your dev can basically copypaste and go.

Ill give you:

  1. JSON schemas (Draft 202012)
  2. Example JSON documents (edge + manifest + DSSE envelope)
  3. C# interfaces: IEdgeExtractor, IAttestor, IReplayer, ITransparencyClient
  4. 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:

  1. Rebuilds BinarySubject from binaryPath.
  2. Re-runs your configured IEdgeExtractor chain.
  3. Regenerates EdgeIds.
  4. 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.Core project.
  • Add records: BinarySubject, FunctionId, CallEdge, ToolProvenance, EdgeStatement, GraphManifest, DsseSignature, DsseEnvelope.
  • Add BinaryContext struct/record.
  • Add EdgeReasonKind enum.
  • Add Hashing helper (SHA256 hex).

CG-2 JSON schema + serialization

  • Add JSON schema files:

    • dsse-envelope-v1.json
    • call-edge-statement-v1.json
    • call-graph-manifest-v1.json
  • Configure tests that serialize sample EdgeStatement / GraphManifest and validate against schemas (optional but nice).

CG-3 Edge extractor abstraction

  • Add IEdgeExtractor interface.

  • Add BinaryContext builder 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 : IEdgeExtractor that:

    • Iterates IL, emits StaticDirectCall edges.
    • Computes deterministic EdgeId using canonical string + SHA256.
  • Unit tests: simple test DLL with one method calling another → expect a single edge.

CG-5 DSSE attestation

  • Add ISigningKey abstraction 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 + models TransparencyEntryRef, TransparencyInclusionProof.
  • Implement FileTransparencyClient that 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 edgeEnvelopeDigests sorted deterministically.
  • Add a CallGraphAttestationService that:

    • Accepts binary path.
    • Builds BinaryContext.
    • Runs all registered IEdgeExtractors.
    • Signs each EdgeStatement via IAttestor.
    • Builds and signs GraphManifest.
    • Optionally publishes envelopes via ITransparencyClient.

CG-8 Replayer

  • Implement CallGraphReplayer : IReplayer:

    • Recompute BinarySubject from binaryPath.
    • Run extractors.
    • Compare recomputed EdgeIds against those inferred from envelopes/manifest.
    • Populate ReplayResult with ReplayMismatch entries.
  • Tests:

    • Self-consistency test (same config → Success = true).
    • Edge removed/added → Success = false with appropriate mismatches.

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 analyze on a sample binary, then verify → exit code 0.


If youd like, next step I can help you decide concrete PE / ELF libraries for each platform and sketch one full extractor implementation endtoend (e.g., import-table edges for Windows PE). Alright, lets turn this into something your devs can literally work through story by story.

Ill 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 LangVersion to latest and target your .NET 10 TFM.

Acceptance criteria

  • dotnet build succeeds.
  • 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.Cecil or separate in EdgeExtraction
  • EdgeExtraction:

    • Mono.Cecil
  • CLI:

    • System.CommandLine
  • Tests:

    • xunit / NUnit
    • FluentAssertions (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:

  • BinarySubject
  • FunctionId
  • EdgeReasonKind (enum)
  • CallEdge
  • ToolProvenance
  • EdgeStatement
  • GraphManifest
  • DsseSignature
  • DsseEnvelope

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:

    • BinaryId pointing at BinarySubject.Sha256 or BuildId
    • Kind string: "RVA" | "Symbol" | "Pdb" | "Other"
    • Value string: 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 CallEdge and 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 were 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

  • BinaryContext encapsulates 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.Sha256Hex to compute SHA-256.

  • Use PEReader + MetadataReader to:

    • 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.

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 *.Program or Program.
  • Mark them as roots: same FunctionId representation.

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:

    • GetAllFunctions returns all methods (at least user-defined).
    • GetRoots includes Program.Main.
  • If assembly is not managed (no metadata), PeManagedBinaryParser fails gracefully with a clear exception or sentinel result (you can add a CanParse(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.Cecil to re-open context.Path.

  • Iterate module.Types / type.Methods where HasBody.

  • For each instruction in method.Body.Instructions:

    • If instr.OpCode.FlowControl is FlowControl.Call and instr.Operand is MethodReference → create edge.

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.StaticDirectCall
    • EvidenceHash = 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 ManagedIlEdgeExtractor on that assembly.

  • Assert:

    • There is an edge where:

      • Caller.Value contains "A::Caller"
      • Callee.Value contains "A::Callee"
      • Reason == StaticDirectCall
    • EdgeId is 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 EdgeStatement to JSON.
    • Compute pae = DssePaEncoder.PreAuthEncode(payloadType, payloadBytes).
    • sigBytes = ISigningKey.SignAsync(pae).
    • Return DsseEnvelope(payloadType, base64(payloadBytes), [sig]).
  • 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 payload and 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

  1. Load:
var context = _parser.Load(binaryPath);
  1. Extract edges:
var edges = new List<CallEdge>();
foreach (var extractor in _extractors)
{
    var result = await extractor.ExtractAsync(context, ct);
    edges.AddRange(result);
}
  1. Build EdgeStatement per 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);
  1. Compute edgeEnvelopeDigests:
var envelopeDigests = edgeEnvelopes
    .Select(e =>
        Hashing.Sha256Hex(
            Encoding.UTF8.GetBytes(JsonSerializer.Serialize(e, jsonOptions))))
    .OrderBy(x => x) // deterministic order
    .ToList();
  1. Build roots:
var roots = _parser.GetRoots(context);
  1. Build GraphManifest & sign.

T5.1.2 Unit test: vertical slice

  • Use the simple test assembly from earlier.

  • Wire up:

    • PeManagedBinaryParser
    • ManagedIlEdgeExtractor
    • DsseAttestor with test signing key.
    • Simple ToolProvenance & policyHash constant.
  • Assert:

    • At least one edge envelope produced.
    • Manifest includes same count of edgeEnvelopeDigests.
    • VerifyManifestAsync and VerifyEdgeAsync return 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.
  • 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.
  • 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:

  1. Verify manifest subject vs current file:

    • Recompute SHA-256 of binaryPath.
    • Compare to manifest.Subject.digest["sha256"] (or Sha256 property).
    • If mismatch → add ReplayMismatch("BinaryDigestMismatch", "...").
  2. Re-run analysis:

    • Use same IBinaryParser + IEdgeExtractor set as production.
    • Build set of recomputed EdgeIds.
  3. Parse envelopes:

    • For each edge envelope:

      • Verify DSSE signature (using same IAttestor.VerifyEdgeAsync).
      • Decode payload and deserialize EdgeStatement.
      • Collect attested EdgeIds into a set.
  4. Compare:

    • MissingEdge: in attested set but not recomputed.
    • ExtraEdge: recomputed but not in attested.
    • RootMismatch: any difference between recomputed roots and manifest.Roots.
  5. 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 BinaryDigestMismatch or 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:

    1. Resolve services via DI:

      • IBinaryParserPeManagedBinaryParser
      • IEdgeExtractorManagedIlEdgeExtractor
      • IAttestorDsseAttestor with test key (configurable later)
      • ITransparencyClientFileTransparencyClient or no-op
    2. Call CallGraphAttestationService.AnalyzeAndAttestAsync.

    3. Write:

      • manifest.dsse.json
      • edge-000001.dsse.json, etc.

T8.1.2 Implement verify command

  • Command: verify <binary> --manifest <manifest.dsse.json> --edges-dir <dir>

  • Behavior:

    1. Load manifest DSSE, verify signature, deserialize GraphManifest.

    2. Load all edge DSSE JSON files from directory.

    3. Call IReplayer.ReplayAsync.

    4. Exit code:

      • 0 if Success = true.
      • Non-zero if mismatches.

Acceptance criteria

  • Running analyze on sample binary produces DSSE files.
  • Running verify on 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 IConfiguration inside 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:

  1. Phase 0 Projects & deps (T0.1.x, T2.1.1).
  2. Phase 1 Core models (T1.1.x).
  3. Phase 2 IBinaryParser + PeManagedBinaryParser + hashing (T2.2.x).
  4. Phase 3 IEdgeExtractor + ManagedIlEdgeExtractor + EdgeId (T3.1.x, T3.2.x).
  5. Phase 4 DSSE PAE + ISigningKey + DsseAttestor (T4.1.x).
  6. Phase 5 CallGraphAttestationService vertical slice (T5.1.x).
  7. Phase 6 Simple FileTransparencyClient (T6.1.x).
  8. Phase 7 IReplayer + CallGraphReplayer (T7.1.x).
  9. Phase 8 CLI analyze + verify commands (T8.1.x).
  10. Phase 9 Logging, config, hardening (T9.1.x, T9.2.x).

If youd 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.