advisories update

This commit is contained in:
StellaOps Bot
2025-11-23 17:18:17 +02:00
parent 7768555f2d
commit c3ce1ebc25
25 changed files with 16553 additions and 10646 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,972 @@
Heres a compact, readytorun plan to benchmark how consistently different vulnerability scanners score the *same* SBOM/VEX—so we can quantify StellaOps determinism advantage.
# Why this matters (quickly)
Scanners often disagree on CVSS and “severity after VEX.” Measuring variance under identical inputs lets us prove scoring stability (a StellaOps moat: deterministic, replayable scans).
# What well measure
* **Determinism rate**: % of runs yielding identical (hashequal) results per scanner.
* **CVSS delta σ**: standard deviation of (scanner_score reference_score) across vulns.
* **Orderinvariance**: re-feed inputs in randomized orders; expect identical outputs.
* **VEX application stability**: variance before vs. after applying VEX justifications.
* **Drift vs. feeds**: pin feeds to content hashes; any change must be attributable.
# Inputs (frozen & hashed)
* 35 **SBOMs** (CycloneDX 1.6 + SPDX 3.0.1) from wellknown images (e.g., nginx, keycloak, alpineglibc, a Java app, a Node app).
* Matching **VEX** docs (CycloneDX VEX) covering “not affected,” “affected,” and “fixed.”
* **Feeds bundle**: vendor DBs (NVD, GHSA, distro OVAL), all vendored and hashed.
* **Policy**: identical normalization rules (CVSS v3.1 only, prefer vendor over NVD, etc.).
# Scanners (example set)
* Anchore/Grype, Trivy, Snyk CLI, osvscanner, DependencyTrack API (server mode), plus **StellaOps Scanner**.
# Protocol (10 runs × 2 orders)
1. **Pin environment** (Docker images + airgapped tarballs). Record:
* tool version, container digest, feed bundle SHA256, SBOM/VEX SHA256.
2. **Run matrix**: for each SBOM/VEX, per scanner:
* 10 runs with canonical file order.
* 10 runs with randomized SBOM component order + shuffled VEX statements.
3. **Capture** normalized JSON: `{purl, vuln_id, base_cvss, effective_severity, vex_applied, notes}`.
4. **Hash** each runs full result (SHA256 over canonical JSON).
5. **Compute** metrics per scanner:
* Determinism rate = identical_hash_runs / total_runs.
* σ(CVSS delta) vs. reference (choose NVD base as reference, or Stella policy).
* Orderinvariance failures (# of distinct hashes between canonical vs. shuffled).
* VEX stability: σ before vs. after VEX; Δσ should shrink, not grow.
# Minimal harness (Python outline)
```python
# run_bench.py
# prerequisites: docker CLI, Python 3.10+, numpy, pandas
from pathlib import Path
import json, hashlib, random, subprocess
import numpy as np
SBOMS = ["sboms/nginx.cdx.json", "sboms/keycloak.spdx.json", ...]
VEXES = ["vex/nginx.vex.json", "vex/keycloak.vex.json", ...]
SCANNERS = {
"grype": ["docker","run","--rm","-v","$PWD:/w","grype:TAG","--input","/w/{sbom}","--vex","/w/{vex}","--output","json"],
"trivy": ["docker","run","--rm","-v","$PWD:/w","aquasec/trivy:TAG","sbom","/w/{sbom}","--vex","/w/{vex}","--format","json"],
# add more…
"stella": ["docker","run","--rm","-v","$PWD:/w","stellaops/scanner:TAG","scan","--sbom","/w/{sbom}","--vex","/w/{vex}","--normalize","json"]
}
def canon(obj): return json.dumps(obj, sort_keys=True, separators=(",",":")).encode()
def shas(b): return hashlib.sha256(b).hexdigest()
def shuffle_file(src, dst): # implement component/VEX statement shuffle preserving semantics
data = json.load(open(src))
for k in ("components","vulnerabilities","vex","statements"):
if isinstance(data, dict) and k in data and isinstance(data[k], list):
random.shuffle(data[k])
json.dump(data, open(dst,"w"), indent=0, separators=(",",":"))
def run(cmd): return subprocess.check_output(cmd, text=True)
results=[]
for sbom, vex in zip(SBOMS, VEXES):
for scanner, tmpl in SCANNERS.items():
for mode in ("canonical","shuffled"):
for i in range(10):
sb, vx = sbom, vex
if mode=="shuffled":
sb, vx = f"tmp/{Path(sbom).stem}.json", f"tmp/{Path(vex).stem}.json"
shuffle_file(sbom, sb); shuffle_file(vex, vx)
out = run([c.format(sbom=sb, vex=vx) for c in tmpl])
j = json.loads(out)
# normalize to minimal tuple per finding (purl,id,base_cvss,effective)
norm = [{"purl":x["purl"],"id":x["id"],"base":x.get("cvss","NA"),
"eff":x.get("effectiveSeverity","NA")} for x in j.get("findings",[])]
blob = canon({"scanner":scanner,"sbom":sbom,"vex":vex,"findings":norm})
results.append({
"scanner":scanner,"sbom":sbom,"mode":mode,"run":i,
"hash":shas(blob),"norm":norm
})
# compute stats (pandas groupby): determinism %, std dev of (eff - ref) per (scanner,sbom)
```
# Pass/Fail gates (suggested)
* **Determinism ≥ 99%** across 20 runs per (scanner, SBOM).
* **Orderinvariance = 100%** identical hashes.
* **VEX stability**: σ_after ≤ σ_before (VEX reduces variance).
* **Provenance**: any change must correlate to a different feed bundle hash.
# Deliverables
* `bench/` with SBOMs, VEX, feeds bundle manifest (hashes).
* `run_bench.py` + `analyze.ipynb` (charts: determinism%, σ by scanner).
* Onepage **StellaOps Differentiator**: “Provable Scoring Stability” with the above metrics and reproducibility recipe.
# Next step
If you want, Ill generate the folder skeleton, example SBOM/VEX, and the analysis notebook stub so you can drop in your scanners and hit run.
Heres a concrete, .NETfriendly implementation plan you can actually build, not just admire in a doc.
Ill assume:
* .NET 8 (or 6) SDK
* Windows or Linux dev machine with Docker installed
* Youre comfortable with basic C#, CLI, and JSON
---
## 1. Project structure
Create a simple solution with two projects:
```bash
dotnet new sln -n ScannerBench
cd ScannerBench
dotnet new console -n ScannerBench.Runner
dotnet new xunit -n ScannerBench.Tests
dotnet sln add ScannerBench.Runner/ScannerBench.Runner.csproj
dotnet sln add ScannerBench.Tests/ScannerBench.Tests.csproj
dotnet add ScannerBench.Tests reference ScannerBench.Runner
```
Inside `ScannerBench.Runner` create folders:
* `Inputs/` SBOM & VEX files
* `Inputs/Sboms/nginx.cdx.json`
* `Inputs/Vex/nginx.vex.json`
* (and a few more pairs)
* `Config/` scanner config JSON or YAML later if you want
* `Results/` captured run outputs (for debugging / manual inspection)
---
## 2. Define core domain models (C#)
In `ScannerBench.Runner` add a file `Models.cs`:
```csharp
using System.Collections.Generic;
namespace ScannerBench.Runner;
public sealed record ScannerConfig(
string Name,
string DockerImage,
string[] CommandTemplate // tokens; use {sbom} and {vex} placeholders
);
public sealed record BenchInput(
string Id, // e.g. "nginx-cdx"
string SbomPath,
string VexPath
);
public sealed record NormalizedFinding(
string Purl,
string VulnerabilityId, // CVE-20211234, GHSAxxx, etc.
string BaseCvss, // normalized to string for simplicity
string EffectiveSeverity // e.g. "LOW", "MEDIUM", "HIGH"
);
public sealed record ScanRun(
string ScannerName,
string InputId,
int RunIndex,
string Mode, // "canonical" | "shuffled"
string ResultHash,
IReadOnlyList<NormalizedFinding> Findings
);
public sealed record DeterminismStats(
string ScannerName,
string InputId,
string Mode,
int TotalRuns,
int DistinctHashes
);
public sealed record CvssDeltaStats(
string ScannerName,
string InputId,
double MeanDelta,
double StdDevDelta
);
```
You can grow this later, but this is enough to get the first version working.
---
## 3. Hardcode scanner configs (first pass)
In `ScannerConfigs.cs`:
```csharp
namespace ScannerBench.Runner;
public static class ScannerConfigs
{
public static readonly ScannerConfig[] All =
{
new(
Name: "grype",
DockerImage: "anchore/grype:v0.79.0",
CommandTemplate: new[]
{
"grype",
"--input", "/work/{sbom}",
"--output", "json"
// add flags like --vex when supported
}
),
new(
Name: "trivy",
DockerImage: "aquasec/trivy:0.55.0",
CommandTemplate: new[]
{
"trivy", "sbom", "/work/{sbom}",
"--format", "json"
}
),
new(
Name: "stella",
DockerImage: "stellaops/scanner:latest",
CommandTemplate: new[]
{
"scanner", "scan",
"--sbom", "/work/{sbom}",
"--vex", "/work/{vex}",
"--output-format", "json"
}
)
};
}
```
You can tweak command templates once you wire up actual tools.
---
## 4. Input set (SBOM + VEX pairs)
In `BenchInputs.cs`:
```csharp
namespace ScannerBench.Runner;
public static class BenchInputs
{
public static readonly BenchInput[] All =
{
new("nginx-cdx", "Inputs/Sboms/nginx.cdx.json", "Inputs/Vex/nginx.vex.json"),
new("keycloak-spdx", "Inputs/Sboms/keycloak.spdx.json", "Inputs/Vex/keycloak.vex.json")
// add more as needed
};
}
```
Populate `Inputs/Sboms` and `Inputs/Vex` manually or with a script (doesnt need to be .NET).
---
## 5. Utility: JSON shuffle to test orderinvariance
You want to randomize component/vulnerability/VEX statement order to confirm that scanners dont change results based on input ordering.
Create `JsonShuffler.cs`:
```csharp
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ScannerBench.Runner;
public static class JsonShuffler
{
private static readonly string[] ListKeysToShuffle =
{
"components",
"vulnerabilities",
"statements",
"vex"
};
public static string CreateShuffledCopy(string sourcePath, string tmpDir)
{
Directory.CreateDirectory(tmpDir);
var jsonText = File.ReadAllText(sourcePath);
var node = JsonNode.Parse(jsonText);
if (node is null)
throw new InvalidOperationException($"Could not parse JSON: {sourcePath}");
ShuffleLists(node);
var fileName = Path.GetFileName(sourcePath);
var destPath = Path.Combine(tmpDir, fileName);
File.WriteAllText(destPath, node.ToJsonString(new JsonSerializerOptions
{
WriteIndented = false
}));
return destPath;
}
private static void ShuffleLists(JsonNode node)
{
if (node is JsonObject obj)
{
foreach (var kvp in obj.ToList())
{
if (kvp.Value is JsonArray arr && ListKeysToShuffle.Contains(kvp.Key))
{
ShuffleInPlace(arr);
}
else if (kvp.Value is not null)
{
ShuffleLists(kvp.Value);
}
}
}
else if (node is JsonArray arr)
{
foreach (var child in arr)
{
if (child is not null)
ShuffleLists(child);
}
}
}
private static void ShuffleInPlace(JsonArray arr)
{
var rnd = new Random();
var list = arr.ToList();
arr.Clear();
foreach (var item in list.OrderBy(_ => rnd.Next()))
{
arr.Add(item);
}
}
}
```
---
## 6. Utility: run Dockerized scanner from C#
Create `DockerRunner.cs`:
```csharp
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
namespace ScannerBench.Runner;
public static class DockerRunner
{
public static string RunScanner(
ScannerConfig scanner,
string sbomPath,
string? vexPath,
string workDir)
{
// Build the container command (inside container)
var innerCmdTokens = scanner.CommandTemplate
.Select(t => t.Replace("{sbom}", sbomPath.Replace("\\", "/"))
.Replace("{vex}", vexPath?.Replace("\\", "/") ?? ""))
.ToArray();
// We run: docker run --rm -v <fullpath>:/work <image> <innerCmdTokens...>
var dockerArgs = new StringBuilder();
dockerArgs.Append("run --rm ");
dockerArgs.Append($"-v \"{workDir}:/work\" ");
dockerArgs.Append(scanner.DockerImage);
dockerArgs.Append(' ');
dockerArgs.Append(string.Join(' ', innerCmdTokens.Select(Escape)));
return RunProcess("docker", dockerArgs.ToString());
}
private static string RunProcess(string fileName, string arguments)
{
var psi = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(psi)
?? throw new InvalidOperationException("Failed to start process");
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
$"Process failed ({fileName} {arguments}): {stderr}");
}
return stdout;
}
private static string Escape(string arg)
{
if (string.IsNullOrEmpty(arg)) return "\"\"";
if (arg.Contains(' ') || arg.Contains('"'))
{
return "\"" + arg.Replace("\"", "\\\"") + "\"";
}
return arg;
}
}
```
Notes:
* `workDir` will be your project directory (so `/work/Inputs/...` inside the container).
* For simplicity, Im not handling Windows vs Linux nuances heavily; adjust path escaping if needed on Windows.
---
## 7. Utility: Normalize scanner JSON output
Different scanners have different JSON; you just need a **mapping** from each scanner to the `NormalizedFinding` shape.
Create `Normalizer.cs`:
```csharp
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ScannerBench.Runner;
public static class Normalizer
{
public static IReadOnlyList<NormalizedFinding> Normalize(
string scannerName,
string rawJson)
{
var node = JsonNode.Parse(rawJson)
?? throw new InvalidOperationException("Cannot parse scanner JSON");
return scannerName switch
{
"grype" => NormalizeGrype(node),
"trivy" => NormalizeTrivy(node),
"stella" => NormalizeStella(node),
_ => throw new NotSupportedException($"Unknown scanner: {scannerName}")
};
}
private static IReadOnlyList<NormalizedFinding> NormalizeGrype(JsonNode root)
{
// Adjust based on actual Grype JSON
var findings = new List<NormalizedFinding>();
var matches = root["matches"] as JsonArray;
if (matches is null) return findings;
foreach (var m in matches)
{
if (m is null) continue;
var artifact = m["artifact"];
var vuln = m["vulnerability"];
var purl = artifact?["purl"]?.ToString() ?? "";
var id = vuln?["id"]?.ToString() ?? "";
var cvss = vuln?["cvss"]?[0]?["metrics"]?["baseScore"]?.ToString() ?? "NA";
var severity = vuln?["severity"]?.ToString() ?? "UNKNOWN";
findings.Add(new NormalizedFinding(
Purl: purl,
VulnerabilityId: id,
BaseCvss: cvss,
EffectiveSeverity: severity.ToUpperInvariant()
));
}
return findings;
}
private static IReadOnlyList<NormalizedFinding> NormalizeTrivy(JsonNode root)
{
var list = new List<NormalizedFinding>();
var results = root["Results"] as JsonArray;
if (results is null) return list;
foreach (var r in results)
{
var vulnerabilities = r?["Vulnerabilities"] as JsonArray;
if (vulnerabilities is null) continue;
foreach (var v in vulnerabilities)
{
if (v is null) continue;
var pkgName = v["PkgName"]?.ToString() ?? "";
var purl = v["Purl"]?.ToString() ?? pkgName;
var id = v["VulnerabilityID"]?.ToString() ?? "";
var cvss = v["CVSS"]?["nvd"]?["V3Score"]?.ToString()
?? v["CVSS"]?["nvd"]?["V2Score"]?.ToString()
?? "NA";
var severity = v["Severity"]?.ToString() ?? "UNKNOWN";
list.Add(new NormalizedFinding(
Purl: purl,
VulnerabilityId: id,
BaseCvss: cvss,
EffectiveSeverity: severity.ToUpperInvariant()
));
}
}
return list;
}
private static IReadOnlyList<NormalizedFinding> NormalizeStella(JsonNode root)
{
// Adjust to match Stella Ops output schema
var list = new List<NormalizedFinding>();
var findings = root["findings"] as JsonArray;
if (findings is null) return list;
foreach (var f in findings)
{
if (f is null) continue;
var purl = f["purl"]?.ToString() ?? "";
var id = f["id"]?.ToString() ?? "";
var cvss = f["baseCvss"]?.ToString()
?? f["cvss"]?.ToString()
?? "NA";
var severity = f["effectiveSeverity"]?.ToString()
?? f["severity"]?.ToString()
?? "UNKNOWN";
list.Add(new NormalizedFinding(
Purl: purl,
VulnerabilityId: id,
BaseCvss: cvss,
EffectiveSeverity: severity.ToUpperInvariant()
));
}
return list;
}
}
```
Youll need to tweak the JSON paths once you inspect real outputs, but the pattern is clear.
---
## 8. Utility: Hashing & canonicalization
Create `Hashing.cs`:
```csharp
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace ScannerBench.Runner;
public static class Hashing
{
public static string ComputeResultHash(
string scannerName,
string inputId,
IReadOnlyList<NormalizedFinding> findings)
{
// Ensure deterministic ordering before hashing
var ordered = findings
.OrderBy(f => f.Purl)
.ThenBy(f => f.VulnerabilityId)
.ToList();
var payload = new
{
scanner = scannerName,
input = inputId,
findings = ordered
};
var json = JsonSerializer.Serialize(payload,
new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
using var sha = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(json);
var hashBytes = sha.ComputeHash(bytes);
return ConvertToHex(hashBytes);
}
private static string ConvertToHex(byte[] bytes)
{
var sb = new StringBuilder(bytes.Length * 2);
foreach (var b in bytes)
sb.Append(b.ToString("x2"));
return sb.ToString();
}
}
```
---
## 9. Metric computation (determinism & CVSS deltas)
Create `StatsCalculator.cs`:
```csharp
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace ScannerBench.Runner;
public static class StatsCalculator
{
public static DeterminismStats ComputeDeterminism(
string scannerName,
string inputId,
string mode,
IReadOnlyList<ScanRun> runs)
{
var hashes = runs.Select(r => r.ResultHash).Distinct().Count();
return new DeterminismStats(
ScannerName: scannerName,
InputId: inputId,
Mode: mode,
TotalRuns: runs.Count,
DistinctHashes: hashes
);
}
public static CvssDeltaStats ComputeCvssDeltas(
string scannerName,
string inputId,
IReadOnlyList<ScanRun> scannerRuns,
IReadOnlyList<ScanRun> referenceRuns)
{
// Use the *first* run of each as baseline (assuming deterministic)
var scannerFindings = scannerRuns.First().Findings;
var refFindings = referenceRuns.First().Findings;
// Map by (purl,id)
var refMap = refFindings.ToDictionary(
f => (f.Purl, f.VulnerabilityId),
f => ParseCvss(f.BaseCvss)
);
var deltas = new List<double>();
foreach (var f in scannerFindings)
{
if (!refMap.TryGetValue((f.Purl, f.VulnerabilityId), out var refScore))
continue;
var score = ParseCvss(f.BaseCvss);
if (double.IsNaN(score) || double.IsNaN(refScore))
continue;
deltas.Add(score - refScore);
}
if (deltas.Count == 0)
{
return new CvssDeltaStats(scannerName, inputId, double.NaN, double.NaN);
}
var mean = deltas.Average();
var variance = deltas.Sum(d => Math.Pow(d - mean, 2)) / deltas.Count;
var stdDev = Math.Sqrt(variance);
return new CvssDeltaStats(scannerName, inputId, mean, stdDev);
}
private static double ParseCvss(string value)
{
if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var v))
return v;
return double.NaN;
}
}
```
Pick your “reference” scanner (e.g., NVDaligned policy or Stella) when you call this method.
---
## 10. Main runner: orchestrate everything
In `Program.cs`:
```csharp
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using ScannerBench.Runner;
class Program
{
static void Main(string[] args)
{
var projectRoot = GetProjectRoot();
var tmpDir = Path.Combine(projectRoot, "Tmp");
Directory.CreateDirectory(tmpDir);
const int runsPerMode = 10;
var allRuns = new List<ScanRun>();
foreach (var input in BenchInputs.All)
{
Console.WriteLine($"=== Input: {input.Id} ===");
foreach (var scanner in ScannerConfigs.All)
{
Console.WriteLine($" Scanner: {scanner.Name}");
// Canonical runs
var canonicalRuns = RunMultiple(
scanner, input, projectRoot, tmpDir,
mode: "canonical", runsPerMode);
// Shuffled runs
var shuffledRuns = RunMultiple(
scanner, input, projectRoot, tmpDir,
mode: "shuffled", runsPerMode);
allRuns.AddRange(canonicalRuns);
allRuns.AddRange(shuffledRuns);
// Determinism stats
var canonStats = StatsCalculator.ComputeDeterminism(
scanner.Name, input.Id, "canonical", canonicalRuns);
var shuffleStats = StatsCalculator.ComputeDeterminism(
scanner.Name, input.Id, "shuffled", shuffledRuns);
Console.WriteLine($" Canonical: {canonStats.DistinctHashes}/{canonStats.TotalRuns} distinct hashes");
Console.WriteLine($" Shuffled: {shuffleStats.DistinctHashes}/{shuffleStats.TotalRuns} distinct hashes");
}
}
// Example: compute CVSS deltas vs Stella
var stellaByInput = allRuns.Where(r => r.ScannerName == "stella")
.GroupBy(r => r.InputId)
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var scanner in ScannerConfigs.All.Where(s => s.Name != "stella"))
{
foreach (var input in BenchInputs.All)
{
var scannerRuns = allRuns
.Where(r => r.ScannerName == scanner.Name &&
r.InputId == input.Id &&
r.Mode == "canonical")
.ToList();
if (scannerRuns.Count == 0 || !stellaByInput.TryGetValue(input.Id, out var stellaRuns))
continue;
var stats = StatsCalculator.ComputeCvssDeltas(
scanner.Name,
input.Id,
scannerRuns,
stellaRuns.Where(r => r.Mode == "canonical").ToList());
Console.WriteLine(
$"CVSS delta vs Stella [{scanner.Name}, {input.Id}]: mean={stats.MeanDelta:F2}, stddev={stats.StdDevDelta:F2}");
}
}
Console.WriteLine("Done.");
}
private static List<ScanRun> RunMultiple(
ScannerConfig scanner,
BenchInput input,
string projectRoot,
string tmpDir,
string mode,
int runsPerMode)
{
var list = new List<ScanRun>();
var inputSbomFull = Path.Combine(projectRoot, input.SbomPath);
var inputVexFull = Path.Combine(projectRoot, input.VexPath);
for (int i = 0; i < runsPerMode; i++)
{
string sbomPath;
string vexPath;
if (mode == "canonical")
{
sbomPath = input.SbomPath; // path relative to /work
vexPath = input.VexPath;
}
else
{
sbomPath = Path.GetRelativePath(
projectRoot,
JsonShuffler.CreateShuffledCopy(inputSbomFull, tmpDir));
vexPath = Path.GetRelativePath(
projectRoot,
JsonShuffler.CreateShuffledCopy(inputVexFull, tmpDir));
}
var rawJson = DockerRunner.RunScanner(
scanner,
sbomPath,
vexPath,
projectRoot);
var findings = Normalizer.Normalize(scanner.Name, rawJson);
var hash = Hashing.ComputeResultHash(scanner.Name, input.Id, findings);
list.Add(new ScanRun(
ScannerName: scanner.Name,
InputId: input.Id,
RunIndex: i,
Mode: mode,
ResultHash: hash,
Findings: findings));
Console.WriteLine($" {mode} run {i + 1}/{runsPerMode}: hash={hash[..8]}...");
}
return list;
}
private static string GetProjectRoot()
{
var dir = Directory.GetCurrentDirectory();
// If you run from bin/Debug, go up until we find .sln or .git, or just go two levels up
return dir;
}
}
```
This is intentionally straightforward: run all scanners × inputs × modes, gather runs, print determinism stats and CVSS deltas vs Stella.
---
## 11. Add a couple of automated tests (xUnit)
In `ScannerBench.Tests`, create `StatsTests.cs`:
```csharp
using System.Collections.Generic;
using ScannerBench.Runner;
using Xunit;
public class StatsTests
{
[Fact]
public void Determinism_Is_One_When_All_Hashes_Equal()
{
var runs = new List<ScanRun>
{
new("s", "i", 0, "canonical", "aaa", new List<NormalizedFinding>()),
new("s", "i", 1, "canonical", "aaa", new List<NormalizedFinding>()),
};
var stats = StatsCalculator.ComputeDeterminism("s", "i", "canonical", runs);
Assert.Equal(1, stats.DistinctHashes);
Assert.Equal(2, stats.TotalRuns);
}
[Fact]
public void CvssDelta_Computes_Mean_And_StdDev()
{
var refRuns = new List<ScanRun>
{
new("ref", "i", 0, "canonical", "h1", new List<NormalizedFinding>
{
new("pkg1","CVE-1","5.0","HIGH"),
new("pkg2","CVE-2","7.0","HIGH")
})
};
var scannerRuns = new List<ScanRun>
{
new("scan", "i", 0, "canonical", "h2", new List<NormalizedFinding>
{
new("pkg1","CVE-1","6.0","HIGH"), // +1
new("pkg2","CVE-2","8.0","HIGH") // +1
})
};
var stats = StatsCalculator.ComputeCvssDeltas("scan", "i", scannerRuns, refRuns);
Assert.Equal(1.0, stats.MeanDelta, 3);
Assert.Equal(0.0, stats.StdDevDelta, 3);
}
}
```
Run tests:
```bash
dotnet test
```
---
## 12. How youll use this in practice
1. **Drop SBOM & VEX files** into `Inputs/Sboms` and `Inputs/Vex`.
2. **Install Docker** and make sure CLI works.
3. Pull scanner images (optional but nice):
```bash
docker pull anchore/grype:v0.79.0
docker pull aquasec/trivy:0.55.0
docker pull stellaops/scanner:latest
```
4. `cd ScannerBench.Runner` and run:
```bash
dotnet run
```
5. Inspect console output:
* For each scanner & SBOM:
* Determinism: `distinct hashes / total runs` (expect 1 / N).
* Orderinvariance: compare canonical vs shuffled determinism.
* CVSS deltas vs Stella: look at standard deviation (lower = more aligned).
6. Optional: serialize `allRuns` and metrics to `Results/*.json` and plot them in whatever you like.
---
If youd like, next step I can help you:
* tighten the JSON normalization against real scanner outputs, or
* add a small HTML/Blazor or minimal API endpoint that renders the stats as a web dashboard instead of console output.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,830 @@
Heres a crisp idea I think youll like: **attested, offlineverifiable call graphs** for binaries.
![abstract graph with signed edges concept](https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80\&w=1200\&auto=format\&fit=crop)
### The gist
* **Goal:** Make binary reachability (who calls whom) something an auditor can replay **deterministically**, even airgapped.
* **How:**
1. Build the call graph for ELF/PE/MachO.
2. **Seal each edge (caller → callee) as its own artifact** and sign it in a **DSSE** (intoto envelope).
3. Bundle a **reachability graph manifest** listing all edgeartifacts + hashes of the inputs (binary, debug info, decompiler version, lattice/policy config).
4. Upload edgeattestations to a **transparency log** (e.g., Rekor v2).
5. Anyone can later fetch/verifiy the envelopes and **replay the analysis identically** (same inputs ⇒ same graph).
### Why this matters
* **Deterministic audits:** “Prove this edge existed at analysis time.” No handwavy “our tool said so last week.”
* **Granular trust:** You can quarantine or dispute **just one edge** without invalidating the whole graph.
* **Supplychain fit:** Edgeartifacts compose nicely with SBOM/VEX; you can say “CVE123 is reachable via these signed edges.”
### Minimal vocabulary
* **DSSE:** A standard envelope that signs the *statement* (here: an edge) and its *subject* (binary, buildID, PURLs).
* **Rekor (v2):** An appendonly public log for attestations. Inclusion proofs = tamperevidence.
* **Reachability graph:** Nodes are functions/symbols; edges are possible calls; roots are entrypoints (exports, handlers, ctors, etc.).
### What “bestinclass” looks like in StellaOps
* **Edge schema (per envelope):**
* `subject`: binary digest + **buildid**, container image digest (if relevant)
* `caller`: {binaryoffset | symbol | demangled | PURL, version}
* `callee`: same structure
* `reason`: static pattern (PLT/JMP, thunk), **init_array/ctors**, EH frames, import table, or **dynamic witness** (trace sample ID)
* `provenance`: tool name + version, pipeline run ID, OS, container digest
* `policy-hash`: hash of lattice/policy/rules used
* `evidence`: (optional) byte slice, CFG snippet hash, or trace excerpt hash
* **Graph manifest (DSSE too):**
* list of edge envelope digests, **roots set**, toolchain hashes, input feeds, **PURL map** (component/function ↔ PURL).
* **Verification flow:**
* Verify envelopes → verify Rekor inclusion → recompute edges from inputs (or check cached proofs) → compare manifest hash.
* **Roots you must include:** exports, syscalls, signal handlers, **.init_array / .ctors**, TLS callbacks, exception trampolines, plugin entrypoints, registered callbacks.
### Quick implementation plan (C#/.NET 10, fits your stack)
1. **Parsers**: ELF/PE/MachO loaders (SymbolTable, DynSym, Reloc/Relr, Import/Export, Sections, BuildID), plus DWARF/PDB stubs when present.
2. **Normalizer**: stable symbol IDs (image base + RVA) and **PURL resolver** (package → function namespace).
3. **Edge extractors** (pluggable):
* Static: import thunks, PLT/JMP, reloctargets, vtable patterns, .init_array, EH tables, jump tables.
* Dynamic (optional): eBPF/ETW/Perf trace ingester → produce **witness edges**.
4. **Edge attestation**: one DSSE per edge + signer (FIPS/SM/GOST/EIDAS as needed).
5. **Manifest builder**: emit graph manifest + policy/lattice hash; store in your **Ledger**.
6. **Transparency client**: Rekor v2 submit/query; cache inclusion proofs for offline bundles.
7. **Verifier**: deterministic replay runner; diff engine (edgeset, roots, policy changes).
8. **UI**: “Edge provenance” panel; click an edge → see DSSE, Rekor proof, extraction reason.
### Practical guardrails
* **Idempotence:** Edge IDs = `hash(callerID, calleeID, reason, tool-version)`. Reruns dont duplicate.
* **Explainability:** Every edge must say *why it exists* (pattern or witness).
* **Stripped binaries:** fall back to pattern heuristics + patch oracles; mark edges **probabilistic** with separate attestation type.
* **Hybrid truth:** Keep static and dynamic edges distinct; policies can require both for “reachable”.
### How this helps your daytoday
* **Compliance**: Ship an SBOM/VEX plus a **proof pack**; auditors can verify offline.
* **Triage**: For a CVE, show **the exact signed path** from entrypoint → vulnerable function; suppresses noisy “maybereachable” claims.
* **Vendor claims**: Accept thirdparty edges only if they come with DSSE + Rekor inclusion.
If you want, I can draft the **DSSE edge schema (JSON)**, the **manifest format**, and the **.NET 10 interfaces** (`IEdgeExtractor`, `IAttestor`, `IReplayer`, `ITransparencyClient`) so your midlevel dev can start coding today.
Heres a concrete, “give this to a midlevel .NET dev” implementation plan for the attested, offlineverifiable call graph.
Ill assume:
* Recent .NET (your “.NET 10”)
* C#
* You can add NuGet packages
* You already have (or will have) an “Authority Signer” for DSSE signatures (file key, KMS, etc.)
---
## 0. Solution layout (what projects to create)
Create a new solution, e.g. `StellaOps.CallGraph.sln` with:
1. **`StellaOps.CallGraph.Core`** (Class Library)
* Domain models (functions, edges, manifests)
* Interfaces (`IBinaryParser`, `IEdgeExtractor`, `IAttestor`, `IRekorClient`, etc.)
* DSSE envelope and helpers
2. **`StellaOps.CallGraph.BinaryParsers`** (Class Library)
* Implementations of `IBinaryParser` for:
* **PE/.NET assemblies** using `System.Reflection.Metadata` / `PEReader`([NuGet][1])
* Optionally native PE / ELF using `Microsoft.Binary.Parsers`([NuGet][2]) or `ELFSharp`([NuGet][3])
3. **`StellaOps.CallGraph.EdgeExtraction`** (Class Library)
* Callgraph builder / edge extractors (import table, IL call instructions, .ctors, etc.)
4. **`StellaOps.CallGraph.Attestation`** (Class Library)
* DSSE helpers
* Attestation logic for edges + graph manifest
* Transparency log (Rekor) client
5. **`StellaOps.CallGraph.Cli`** (Console app)
* Developer entrypoint: `callgraph analyze <binary>`
* Outputs:
* Edge DSSE envelopes (one per edge, or batched)
* Graph manifest DSSE
* Humanreadable summary
6. **`StellaOps.CallGraph.Tests`** (xUnit / NUnit)
* Unit tests per layer
---
## 1. Define the core domain (Core project)
### 1.1 Records and enums
Create these in `StellaOps.CallGraph.Core`:
```csharp
public sealed record BinaryIdentity(
string LogicalId, // e.g. build-id or image digest
string Path, // local path used during analysis
string? BuildId,
string? ImageDigest, // e.g. OCI digest
IReadOnlyDictionary<string, string> Digests // sha256, sha512, etc.
);
public sealed record FunctionRef(
string BinaryLogicalId, // link to BinaryIdentity.LogicalId
ulong Rva, // Relative virtual address (for native) or metadata token for managed
string? SymbolName, // raw symbol if available
string? DisplayName, // demangled, user-facing
string? Purl // optional: pkg/function mapping
);
public enum EdgeReasonKind
{
ImportTable,
StaticCall, // direct call instruction
VirtualDispatch, // via vtable / callvirt
InitArrayOrCtor,
ExceptionHandler,
DynamicWitness // from traces
}
public sealed record EdgeReason(
EdgeReasonKind Kind,
string Detail // e.g. ".text: call 0x401234", "import: kernel32!CreateFileW"
);
public sealed record ReachabilityEdge(
FunctionRef Caller,
FunctionRef Callee,
EdgeReason Reason,
string ToolVersion,
string PolicyHash, // hash of lattice/policy
string EvidenceHash // hash of raw evidence blob (CFG snippet, trace, etc.)
);
```
Graph manifest:
```csharp
public sealed record CallGraphManifest(
string SchemaVersion,
BinaryIdentity Binary,
IReadOnlyList<FunctionRef> Roots,
IReadOnlyList<string> EdgeEnvelopeDigests, // sha256 of DSSE envelopes
string PolicyHash,
IReadOnlyDictionary<string, string> ToolMetadata
);
```
### 1.2 Core interfaces
```csharp
public interface IBinaryParser
{
BinaryIdentity Identify(string path);
IReadOnlyList<FunctionRef> GetFunctions(BinaryIdentity binary);
IReadOnlyList<FunctionRef> GetRoots(BinaryIdentity binary); // exports, entrypoint, handlers, etc.
BinaryCodeRegion GetCodeRegion(BinaryIdentity binary); // raw bytes + mappings, see below
}
public sealed record BinaryCodeRegion(
byte[] Bytes,
ulong ImageBase,
IReadOnlyList<SectionInfo> Sections
);
public sealed record SectionInfo(
string Name,
ulong Rva,
uint Size
);
public interface IEdgeExtractor
{
IReadOnlyList<ReachabilityEdge> Extract(
BinaryIdentity binary,
IReadOnlyList<FunctionRef> functions,
BinaryCodeRegion code);
}
public interface IAttestor
{
Task<DsseEnvelope> SignEdgeAsync(
ReachabilityEdge edge,
BinaryIdentity binary,
CancellationToken ct = default);
Task<DsseEnvelope> SignManifestAsync(
CallGraphManifest manifest,
CancellationToken ct = default);
}
public interface IRekorClient
{
Task<RekorEntryRef> UploadAsync(DsseEnvelope envelope, CancellationToken ct = default);
}
public sealed record RekorEntryRef(string LogId, long Index, string Uuid);
```
(Well define `DsseEnvelope` in section 3.)
---
## 2. Implement minimal PE parser (BinaryParsers project)
Start with **PE/.NET** only; expand later.
### 2.1 Add NuGet packages
* `System.Reflection.Metadata` (if youre not already on a shared framework that has it)([NuGet][1])
* Optionally `Microsoft.Binary.Parsers` for native PE & ELF; it already knows how to parse PE headers and ELF.([NuGet][2])
### 2.2 Implement `PeBinaryParser` (managed assemblies)
In `StellaOps.CallGraph.BinaryParsers`:
* `BinaryIdentity Identify(string path)`
* Open file, compute SHA256 (streaming).
* Use `PEReader` and `MetadataReader` to pull:
* MVID (`ModuleDefinition`).
* Assembly name, version.
* Derive `LogicalId`, e.g. `"dotnet:<AssemblyName>/<Mvid>"`.
* `IReadOnlyList<FunctionRef> GetFunctions(...)`
* Use `PEReader``GetMetadataReader()` to enumerate methods:
* `reader.TypeDefinitions` → methods in each type.
* For each `MethodDefinition`, compute:
* `BinaryLogicalId = binary.LogicalId`
* `Rva = methodDef.RelativeVirtualAddress`
* `SymbolName = reader.GetString(methodDef.Name)`
* `DisplayName = typeFullName + "::" + methodName + signature`
* `Purl` optional mapping (you can fill later from SBOM).
* `IReadOnlyList<FunctionRef> GetRoots(...)`
* Roots for .NET:
* `Main` methods in entry assembly.
* Public exported API if you want (public methods in public types).
* Static constructors (.cctor) for public types (init roots).
* Keep it simple for v1: treat `Main` as only root.
* `BinaryCodeRegion GetCodeRegion(...)`
* For managed assemblies, you only need IL for now:
* Use `PEReader.GetMethodBody(rva)` to get `MethodBodyBlock`.([Microsoft Learn][4])
* For v1, you can assemble permethod IL as you go in the extractor instead of prebuilding a whole region.
Implementation trick: have `PeBinaryParser` expose a helper:
```csharp
public MethodBodyBlock? TryGetMethodBody(BinaryIdentity binary, uint rva);
```
Youll pass this down to the edge extractor.
### 2.3 (Optional) native PE/ELF
Once managed assemblies work:
* Add `Microsoft.Binary.Parsers` for PE + ELF.([NuGet][2])
* Or `ELFSharp` if you prefer.([NuGet][3])
You can then:
* Parse import table → edges from “import stub” → imported function.
* Parse export table → roots (exports).
* Parse `.pdata`, `.xdata` → exception handlers.
* Parse `.init_array` (ELF) / TLS callbacks, C runtime init functions.
For an “average dev” first iteration, you can **skip native** and get a lot of value from .NET assemblies only.
---
## 3. DSSE attestation primitives (Attestation project)
You already use DSSE elsewhere, but heres a selfcontained minimal version.
### 3.1 Envelope models
```csharp
public sealed record DsseSignature(
string KeyId,
string Sig // base64 signature
);
public sealed record DsseEnvelope(
string PayloadType, // e.g. "application/vnd.stella.call-edge+json"
string Payload, // base64-encoded JSON statement
IReadOnlyList<DsseSignature> Signatures
);
```
Statement for a **single edge**:
```csharp
public sealed record EdgeStatement(
string _type, // e.g. "https://stella.ops/Statement/CallEdge/v1"
object subject, // Binary info + maybe PURLs
ReachabilityEdge edge
);
```
You can loosely follow the DSSE / intoto style: Googles Grafeas `Envelope` type also matches DSSEs `envelope.proto`.([Google Cloud][5])
### 3.2 Preauthentication encoding (PAE)
Implement DSSE PAE once:
```csharp
public static class Dsse
{
public static byte[] PreAuthEncode(string payloadType, byte[] payload)
{
static byte[] Cat(params byte[][] parts)
{
var total = parts.Sum(p => p.Length);
var buf = new byte[total];
var offset = 0;
foreach (var part in parts)
{
Buffer.BlockCopy(part, 0, buf, offset, part.Length);
offset += part.Length;
}
return buf;
}
static byte[] Utf8(string s) => Encoding.UTF8.GetBytes(s);
var header = Utf8("DSSEv1");
var pt = Utf8(payloadType);
var lenPt = Utf8(pt.Length.ToString(CultureInfo.InvariantCulture));
var lenPayload = Utf8(payload.Length.ToString(CultureInfo.InvariantCulture));
var space = Utf8(" ");
return Cat(header, space, lenPt, space, pt, space, lenPayload, space, payload);
}
}
```
### 3.3 Implement `IAttestor`
Assume you already have some `IAuthoritySigner` that can sign arbitrary byte arrays (Ed25519, RSA, etc.).
```csharp
public sealed class DsseAttestor : IAttestor
{
private readonly IAuthoritySigner _signer;
private readonly JsonSerializerOptions _jsonOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public DsseAttestor(IAuthoritySigner signer) => _signer = signer;
public async Task<DsseEnvelope> SignEdgeAsync(
ReachabilityEdge edge,
BinaryIdentity binary,
CancellationToken ct = default)
{
var stmt = new EdgeStatement(
_type: "https://stella.ops/Statement/CallEdge/v1",
subject: new
{
type = "file",
name = binary.Path,
digest = binary.Digests
},
edge: edge
);
return await SignStatementAsync(
stmt,
payloadType: "application/vnd.stella.call-edge+json",
ct);
}
public async Task<DsseEnvelope> SignManifestAsync(
CallGraphManifest manifest,
CancellationToken ct = default)
{
var stmt = new
{
_type = "https://stella.ops/Statement/CallGraphManifest/v1",
subject = new
{
type = "file",
name = manifest.Binary.Path,
digest = manifest.Binary.Digests
},
manifest
};
return await SignStatementAsync(
stmt,
payloadType: "application/vnd.stella.call-manifest+json",
ct);
}
private async Task<DsseEnvelope> SignStatementAsync(
object statement,
string payloadType,
CancellationToken ct)
{
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(statement, _jsonOptions);
var pae = Dsse.PreAuthEncode(payloadType, payloadBytes);
var signatureBytes = await _signer.SignAsync(pae, ct).ConfigureAwait(false);
var keyId = await _signer.GetKeyIdAsync(ct).ConfigureAwait(false);
return new DsseEnvelope(
PayloadType: payloadType,
Payload: Convert.ToBase64String(payloadBytes),
Signatures: new[]
{
new DsseSignature(keyId, Convert.ToBase64String(signatureBytes))
});
}
}
```
You can plug in:
* `IAuthoritySigner` using `System.Security.Cryptography.Ed25519` on .NET (or BouncyCastle) for signatures.([Stack Overflow][6])
---
## 4. Edge extraction (EdgeExtraction project)
### 4.1 Choose strategy per binary type
For **managed .NET assemblies** the easiest route is to use `Mono.Cecil` to read IL opcodes.([NuGet][7])
Add package: `Mono.Cecil`.
```csharp
public sealed class ManagedIlEdgeExtractor : IEdgeExtractor
{
public IReadOnlyList<ReachabilityEdge> Extract(
BinaryIdentity binary,
IReadOnlyList<FunctionRef> functions,
BinaryCodeRegion code)
{
// For managed we won't use BinaryCodeRegion; well re-open file with Cecil.
var result = new List<ReachabilityEdge>();
var filePath = binary.Path;
var module = ModuleDefinition.ReadModule(filePath, new ReaderParameters
{
ReadSymbols = false
});
foreach (var type in module.Types)
foreach (var method in type.Methods.Where(m => m.HasBody))
{
var callerRef = ToFunctionRef(binary, method);
foreach (var instr in method.Body.Instructions)
{
if (instr.OpCode.FlowControl != FlowControl.Call)
continue;
if (instr.Operand is not MethodReference calleeMethod)
continue;
var calleeRef = ToFunctionRef(binary, calleeMethod);
var edge = new ReachabilityEdge(
Caller: callerRef,
Callee: calleeRef,
Reason: new EdgeReason(
EdgeReasonKind.StaticCall,
Detail: $"IL {instr.OpCode} {calleeMethod.FullName}"
),
ToolVersion: "stella-callgraph/0.1.0",
PolicyHash: "TODO",
EvidenceHash: "TODO" // later: hash of snippet
);
result.Add(edge);
}
}
return result;
}
private static FunctionRef ToFunctionRef(BinaryIdentity binary, MethodReference method)
{
var displayName = $"{method.DeclaringType.FullName}::{method.Name}";
return new FunctionRef(
BinaryLogicalId: binary.LogicalId,
Rva: (ulong)method.MetadataToken.ToInt32(),
SymbolName: method.FullName,
DisplayName: displayName,
Purl: null
);
}
}
```
Later, you can add:
* Import table edges (`EdgeReasonKind.ImportTable`).
* Virtual dispatch edges, heuristics, etc.
* Dynamic edges from trace logs (`EdgeReasonKind.DynamicWitness`).
### 4.2 Callgraph builder
Add a thin orchestration service:
```csharp
public sealed class CallGraphBuilder
{
private readonly IBinaryParser _parser;
private readonly IReadOnlyList<IEdgeExtractor> _extractors;
public CallGraphBuilder(
IBinaryParser parser,
IEnumerable<IEdgeExtractor> extractors)
{
_parser = parser;
_extractors = extractors.ToList();
}
public (BinaryIdentity binary,
IReadOnlyList<FunctionRef> functions,
IReadOnlyList<FunctionRef> roots,
IReadOnlyList<ReachabilityEdge> edges) Build(string path)
{
var binary = _parser.Identify(path);
var functions = _parser.GetFunctions(binary);
var roots = _parser.GetRoots(binary);
// Optionally, pack code region if needed
var code = new BinaryCodeRegion(Array.Empty<byte>(), 0, Array.Empty<SectionInfo>());
var edges = _extractors
.SelectMany(e => e.Extract(binary, functions, code))
.ToList();
return (binary, functions, roots, edges);
}
}
```
---
## 5. Edge→DSSE and manifest→DSSE wiring
In `StellaOps.CallGraph.Attestation`, create a coordinator:
```csharp
public sealed class CallGraphAttestationService
{
private readonly CallGraphBuilder _builder;
private readonly IAttestor _attestor;
private readonly IRekorClient _rekor;
public CallGraphAttestationService(
CallGraphBuilder builder,
IAttestor attestor,
IRekorClient rekor)
{
_builder = builder;
_attestor = attestor;
_rekor = rekor;
}
public async Task<CallGraphAttestationResult> AnalyzeAndAttestAsync(
string path,
CancellationToken ct = default)
{
var (binary, functions, roots, edges) = _builder.Build(path);
// 1) Sign each edge
var edgeEnvelopes = new List<DsseEnvelope>();
foreach (var edge in edges)
{
var env = await _attestor.SignEdgeAsync(edge, binary, ct);
edgeEnvelopes.Add(env);
}
// 2) Compute digests for manifest
var edgeEnvelopeDigests = edgeEnvelopes
.Select(e => Crypto.HashSha256(JsonSerializer.SerializeToUtf8Bytes(e)))
.ToList();
var manifest = new CallGraphManifest(
SchemaVersion: "1.0",
Binary: binary,
Roots: roots,
EdgeEnvelopeDigests: edgeEnvelopeDigests,
PolicyHash: edges.FirstOrDefault()?.PolicyHash ?? "",
ToolMetadata: new Dictionary<string, string>
{
["builder"] = "stella-callgraph/0.1.0",
["created-at"] = DateTimeOffset.UtcNow.ToString("O")
});
var manifestEnvelope = await _attestor.SignManifestAsync(manifest, ct);
// 3) Publish DSSE envelopes to Rekor (if configured)
var rekorRefs = new List<RekorEntryRef>();
foreach (var env in edgeEnvelopes.Append(manifestEnvelope))
{
var entry = await _rekor.UploadAsync(env, ct);
rekorRefs.Add(entry);
}
return new CallGraphAttestationResult(
Manifest: manifest,
ManifestEnvelope: manifestEnvelope,
EdgeEnvelopes: edgeEnvelopes,
RekorEntries: rekorRefs);
}
}
public sealed record CallGraphAttestationResult(
CallGraphManifest Manifest,
DsseEnvelope ManifestEnvelope,
IReadOnlyList<DsseEnvelope> EdgeEnvelopes,
IReadOnlyList<RekorEntryRef> RekorEntries);
```
---
## 6. Rekor v2 client (transparency log)
Rekor is a RESTbased transparency log (part of Sigstore).([Sigstore][8])
For an average dev, keep it **simple**:
1. Add `HttpClient`based `RekorClient`:
* `UploadAsync(DsseEnvelope)`:
* POST to your Rekor servers `/api/v1/log/entries` (v1 today; v2 is under active development, but the pattern is similar).
* Store returned `logID`, `logIndex`, `uuid` in `RekorEntryRef`.
2. For offline replay youll want to store:
* The DSSE envelopes.
* Rekor entry references (and ideally inclusion proofs, but that can come later).
You dont need to fully implement Merkle tree verification in v1; you can add that when you harden the verifier.
---
## 7. CLI for developers (Cli project)
A simple console app gives you fast feedback:
```bash
stella-callgraph analyze myapp.dll \
--output-dir artifacts/callgraph
```
Implementation sketch:
```csharp
static async Task<int> Main(string[] args)
{
var input = args[1]; // TODO: proper parser
var services = Bootstrap(); // DI container
var svc = services.GetRequiredService<CallGraphAttestationService>();
var result = await svc.AnalyzeAndAttestAsync(input);
// Write DSSE envelopes & manifest as JSON files
var outDir = Path.Combine("artifacts", "callgraph");
Directory.CreateDirectory(outDir);
await File.WriteAllTextAsync(
Path.Combine(outDir, "manifest.dsse.json"),
JsonSerializer.Serialize(result.ManifestEnvelope, new JsonSerializerOptions { WriteIndented = true }));
for (var i = 0; i < result.EdgeEnvelopes.Count; i++)
{
var path = Path.Combine(outDir, $"edge-{i:D6}.dsse.json");
await File.WriteAllTextAsync(path,
JsonSerializer.Serialize(result.EdgeEnvelopes[i], new JsonSerializerOptions { WriteIndented = true }));
}
return 0;
}
```
---
## 8. Verifier (same libraries, different flow)
Later (or in parallel), add a **verification** mode:
1. Inputs:
* Binary file.
* Manifest DSSE file.
* Edge DSSE files.
* (Optionally) Rekor log inclusion proof bundle.
2. Steps (same dev can implement):
* Verify DSSE signatures for manifest and edges (using `IAuthoritySigner.VerifyAsync`).
* Check:
* Manifests binary digest matches the current file.
* Manifests edgeenvelope digests match hashes of the provided DSSE edge files.
* Rebuild call graph using the same tool & policy version and diff against attested edges:
* For deterministic replay, their differences should be zero.
* Optionally:
* Ask Rekor for current log info and verify inclusion proof (advanced).
---
## 9. Order of work for a midlevel .NET dev
If you hand this as a sequence of tasks:
1. **Core models & interfaces**
* Add domain records (`BinaryIdentity`, `FunctionRef`, `ReachabilityEdge`, `CallGraphManifest`).
* Add `IBinaryParser`, `IEdgeExtractor`, `IAttestor`, `IRekorClient`.
2. **Managed PE parser**
* Implement `PeBinaryParser` using `System.Reflection.Metadata` (`PEReader`, `MetadataReader`).([NuGet][1])
* Return `BinaryIdentity`, a list of methods as `FunctionRef`, and roots (`Main`).
3. **IL edge extractor**
* Add `Mono.Cecil`.
* Implement `ManagedIlEdgeExtractor` that:
* Iterates methods and IL instructions.
* Emits edges for `call` and `callvirt`.
4. **CallGraphBuilder**
* Wire `.Build(path)` to use `PeBinaryParser` + `ManagedIlEdgeExtractor`.
5. **DSSE library**
* Add `DsseEnvelope`, `DsseSignature`, `Dsse.PreAuthEncode`.
* Implement `DsseAttestor` that wraps `ReachabilityEdge` and `CallGraphManifest` into DSSE envelopes using an `IAuthoritySigner`.
6. **Rekor client (stub, then real)**
* First: `DummyRekorClient` that just returns fake IDs.
* Then: `HttpRekorClient` that POSTs to your Rekor server.
7. **CallGraphAttestationService + CLI**
* Implement `CallGraphAttestationService`.
* CLI command to:
* Run analysis.
* Write DSSE files + a human readable summary.
8. **Verifier**
* Implement basic “offline verify” command:
* Verify DSSE signatures on manifest + edges.
* Verify manifest ↔ edge digest linkage.
* (Later) compare reanalyzed graph with attested one.
---
If you want, I can next:
* Propose the **exact JSON schema** for `EdgeStatement` and `CallGraphManifest` (with sample instances).
* Or help turn this into a **Jira/Linear ticket breakdown** ready for your team.
[1]: https://www.nuget.org/packages/system.reflection.metadata/?utm_source=chatgpt.com "System.Reflection.Metadata 10.0.0"
[2]: https://www.nuget.org/packages/Microsoft.Binary.Parsers?utm_source=chatgpt.com "Microsoft.Binary.Parsers 4.4.8"
[3]: https://www.nuget.org/packages/ELFSharp?utm_source=chatgpt.com "ELFSharp 2.17.3"
[4]: https://learn.microsoft.com/en-us/dotnet/api/system.reflection.portableexecutable.pereader?view=net-10.0&utm_source=chatgpt.com "PEReader Class (System.Reflection.PortableExecutable)"
[5]: https://cloud.google.com/dotnet/docs/reference/Grafeas.V1/latest/Grafeas.V1.Envelope?utm_source=chatgpt.com "Grafeas v1 API - Class Envelope (3.10.0) | .NET client library"
[6]: https://stackoverflow.com/questions/72152837/get-public-and-private-key-from-pem-ed25519-in-c-sharp?utm_source=chatgpt.com "Get public and private key from PEM ed25519 in C#"
[7]: https://www.nuget.org/packages/mono.cecil/?utm_source=chatgpt.com "Mono.Cecil 0.11.6"
[8]: https://docs.sigstore.dev/logging/overview/?utm_source=chatgpt.com "Rekor"