feat: Add in-memory implementations for issuer audit, key, repository, and trust management
Some checks failed
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled

- Introduced InMemoryIssuerAuditSink to retain audit entries for testing.
- Implemented InMemoryIssuerKeyRepository for deterministic key storage.
- Created InMemoryIssuerRepository to manage issuer records in memory.
- Added InMemoryIssuerTrustRepository for managing issuer trust overrides.
- Each repository utilizes concurrent collections for thread-safe operations.
- Enhanced deprecation tracking with a comprehensive YAML schema for API governance.
This commit is contained in:
master
2025-12-11 19:47:43 +02:00
parent ab22181e8b
commit ce5ec9c158
48 changed files with 1898 additions and 1580 deletions

View File

@@ -114,7 +114,7 @@ internal static class NativeReachabilityGraphBuilder
.ToImmutableArray();
var distinctEdges = edges
.GroupBy(e => (e.From, e.To, e.Reason), ValueTuple.Create)
.GroupBy(e => (e.From, e.To, e.Reason))
.Select(g => g.First())
.OrderBy(e => e.From, StringComparer.Ordinal)
.ThenBy(e => e.To, StringComparer.Ordinal)

View File

@@ -44,7 +44,9 @@ public sealed class ReachabilityGraphBuilder
string? display = null,
string? sourceFile = null,
int? sourceLine = null,
IReadOnlyDictionary<string, string>? attributes = null)
IReadOnlyDictionary<string, string>? attributes = null,
string? purl = null,
string? symbolDigest = null)
{
if (string.IsNullOrWhiteSpace(symbolId))
{
@@ -59,7 +61,9 @@ public sealed class ReachabilityGraphBuilder
display?.Trim(),
sourceFile?.Trim(),
sourceLine,
attributes?.ToImmutableSortedDictionary(StringComparer.Ordinal) ?? ImmutableSortedDictionary<string, string>.Empty);
attributes?.ToImmutableSortedDictionary(StringComparer.Ordinal) ?? ImmutableSortedDictionary<string, string>.Empty,
purl?.Trim(),
symbolDigest?.Trim());
_richNodes[id] = node;
nodes.Add(id);
@@ -93,6 +97,9 @@ public sealed class ReachabilityGraphBuilder
/// <param name="origin">Origin: static or runtime.</param>
/// <param name="provenance">Provenance hint: jvm-bytecode, il, ts-ast, ssa, ebpf, etw, jfr, hook.</param>
/// <param name="evidence">Evidence locator (e.g., "file:path:line").</param>
/// <param name="purl">PURL of the component that defines the callee.</param>
/// <param name="symbolDigest">Stable hash of the normalized callee signature.</param>
/// <param name="candidates">Ranked candidate purls when resolution is ambiguous.</param>
public ReachabilityGraphBuilder AddEdge(
string from,
string to,
@@ -100,7 +107,10 @@ public sealed class ReachabilityGraphBuilder
EdgeConfidence confidence,
string origin = "static",
string? provenance = null,
string? evidence = null)
string? evidence = null,
string? purl = null,
string? symbolDigest = null,
IReadOnlyList<(string Purl, string? SymbolDigest, double? Score)>? candidates = null)
{
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to))
{
@@ -118,7 +128,10 @@ public sealed class ReachabilityGraphBuilder
confidence,
origin?.Trim() ?? "static",
provenance?.Trim(),
evidence?.Trim());
evidence?.Trim(),
purl?.Trim(),
symbolDigest?.Trim(),
candidates);
_richEdges.Add(richEdge);
nodes.Add(fromId);
@@ -172,7 +185,9 @@ public sealed class ReachabilityGraphBuilder
rich.Kind,
rich.Display,
source,
rich.Attributes.Count > 0 ? rich.Attributes : null));
rich.Attributes.Count > 0 ? rich.Attributes : null,
rich.Purl,
rich.SymbolDigest));
}
else
{
@@ -199,12 +214,17 @@ public sealed class ReachabilityGraphBuilder
rich.Provenance,
rich.Evidence);
var candidates = rich.Candidates?.Select(c => new ReachabilityEdgeCandidate(c.Purl, c.SymbolDigest, c.Score)).ToList();
edgeList.Add(new ReachabilityUnionEdge(
rich.From,
rich.To,
rich.EdgeType,
ConfidenceToString(rich.Confidence),
source));
source,
rich.Purl,
rich.SymbolDigest,
candidates));
}
// Add any legacy edges not already covered
@@ -315,7 +335,9 @@ public sealed class ReachabilityGraphBuilder
string? Display,
string? SourceFile,
int? SourceLine,
ImmutableSortedDictionary<string, string> Attributes);
ImmutableSortedDictionary<string, string> Attributes,
string? Purl = null,
string? SymbolDigest = null);
private sealed record RichEdge(
string From,
@@ -324,7 +346,10 @@ public sealed class ReachabilityGraphBuilder
EdgeConfidence Confidence,
string Origin,
string? Provenance,
string? Evidence);
string? Evidence,
string? Purl = null,
string? SymbolDigest = null,
IReadOnlyList<(string Purl, string? SymbolDigest, double? Score)>? Candidates = null);
}
/// <summary>

View File

@@ -15,6 +15,7 @@ public interface IRichGraphPublisher
/// <summary>
/// Packages richgraph-v1 JSON + meta into a deterministic zip and stores it in CAS.
/// CAS paths follow the richgraph-v1 contract: cas://reachability/graphs/{blake3}
/// </summary>
public sealed class ReachabilityRichGraphPublisher : IRichGraphPublisher
{
@@ -45,11 +46,20 @@ public sealed class ReachabilityRichGraphPublisher : IRichGraphPublisher
var zipPath = Path.Combine(folder, "richgraph.zip");
CreateDeterministicZip(folder, zipPath);
// Use BLAKE3 graph_hash as the CAS key per CONTRACT-RICHGRAPH-V1-015
var casKey = ExtractHashDigest(writeResult.GraphHash);
await using var stream = File.OpenRead(zipPath);
var sha = ComputeSha256(zipPath);
var casEntry = await cas.PutAsync(new FileCasPutRequest(sha, stream, leaveOpen: false), cancellationToken).ConfigureAwait(false);
var casEntry = await cas.PutAsync(new FileCasPutRequest(casKey, stream, leaveOpen: false), cancellationToken).ConfigureAwait(false);
return new RichGraphPublishResult(writeResult.GraphHash, casEntry.RelativePath, writeResult.NodeCount, writeResult.EdgeCount);
// Build CAS URI per contract: cas://reachability/graphs/{blake3}
var casUri = $"cas://reachability/graphs/{casKey}";
return new RichGraphPublishResult(
writeResult.GraphHash,
casEntry.RelativePath,
casUri,
writeResult.NodeCount,
writeResult.EdgeCount);
}
private static void CreateDeterministicZip(string sourceDir, string destinationZip)
@@ -71,16 +81,19 @@ public sealed class ReachabilityRichGraphPublisher : IRichGraphPublisher
}
}
private static string ComputeSha256(string path)
/// <summary>
/// Extracts the hex digest from a prefixed hash (e.g., "blake3:abc123" → "abc123").
/// </summary>
private static string ExtractHashDigest(string prefixedHash)
{
using var sha = System.Security.Cryptography.SHA256.Create();
using var stream = File.OpenRead(path);
return Convert.ToHexString(sha.ComputeHash(stream)).ToLowerInvariant();
var colonIndex = prefixedHash.IndexOf(':');
return colonIndex >= 0 ? prefixedHash[(colonIndex + 1)..] : prefixedHash;
}
}
public sealed record RichGraphPublishResult(
string GraphHash,
string RelativePath,
string CasUri,
int NodeCount,
int EdgeCount);

View File

@@ -75,7 +75,9 @@ public sealed class ReachabilityUnionWriter
Source = n.Source?.Trimmed(),
Attributes = (n.Attributes ?? ImmutableDictionary<string, string>.Empty)
.Where(kv => !string.IsNullOrWhiteSpace(kv.Key) && kv.Value is not null)
.ToImmutableSortedDictionary(kv => kv.Key.Trim(), kv => kv.Value!.Trim())
.ToImmutableSortedDictionary(kv => kv.Key.Trim(), kv => kv.Value!.Trim()),
Purl = Trim(n.Purl),
SymbolDigest = Trim(n.SymbolDigest)
})
.OrderBy(n => n.SymbolId, StringComparer.Ordinal)
.ThenBy(n => n.Kind, StringComparer.Ordinal)
@@ -89,7 +91,10 @@ public sealed class ReachabilityUnionWriter
To = Trim(e.To)!,
EdgeType = Trim(e.EdgeType) ?? "call",
Confidence = Trim(e.Confidence) ?? "certain",
Source = e.Source?.Trimmed()
Source = e.Source?.Trimmed(),
Purl = Trim(e.Purl),
SymbolDigest = Trim(e.SymbolDigest),
Candidates = NormalizeCandidates(e.Candidates)
})
.OrderBy(e => e.From, StringComparer.Ordinal)
.ThenBy(e => e.To, StringComparer.Ordinal)
@@ -110,6 +115,24 @@ public sealed class ReachabilityUnionWriter
return new NormalizedGraph(nodes, edges, facts);
}
private static IReadOnlyList<ReachabilityEdgeCandidate>? NormalizeCandidates(IReadOnlyList<ReachabilityEdgeCandidate>? candidates)
{
if (candidates is null || candidates.Count == 0)
{
return null;
}
return candidates
.Where(c => !string.IsNullOrWhiteSpace(c.Purl))
.Select(c => new ReachabilityEdgeCandidate(
c.Purl.Trim(),
Trim(c.SymbolDigest),
c.Score))
.OrderByDescending(c => c.Score ?? 0)
.ThenBy(c => c.Purl, StringComparer.Ordinal)
.ToList();
}
private static async Task<FileHashInfo> WriteNdjsonAsync<T>(
string path,
IReadOnlyCollection<T> items,
@@ -145,6 +168,16 @@ public sealed class ReachabilityUnionWriter
jw.WriteString("display", node.Display);
}
if (!string.IsNullOrWhiteSpace(node.Purl))
{
jw.WriteString("purl", node.Purl);
}
if (!string.IsNullOrWhiteSpace(node.SymbolDigest))
{
jw.WriteString("symbol_digest", node.SymbolDigest);
}
if (node.Source is not null)
{
jw.WritePropertyName("source");
@@ -180,6 +213,37 @@ public sealed class ReachabilityUnionWriter
jw.WriteString("edge_type", edge.EdgeType);
jw.WriteString("confidence", edge.Confidence);
if (!string.IsNullOrWhiteSpace(edge.Purl))
{
jw.WriteString("purl", edge.Purl);
}
if (!string.IsNullOrWhiteSpace(edge.SymbolDigest))
{
jw.WriteString("symbol_digest", edge.SymbolDigest);
}
if (edge.Candidates is { Count: > 0 })
{
jw.WritePropertyName("candidates");
jw.WriteStartArray();
foreach (var candidate in edge.Candidates)
{
jw.WriteStartObject();
jw.WriteString("purl", candidate.Purl);
if (!string.IsNullOrWhiteSpace(candidate.SymbolDigest))
{
jw.WriteString("symbol_digest", candidate.SymbolDigest);
}
if (candidate.Score.HasValue)
{
jw.WriteNumber("score", candidate.Score.Value);
}
jw.WriteEndObject();
}
jw.WriteEndArray();
}
if (edge.Source is not null)
{
jw.WritePropertyName("source");
@@ -327,14 +391,27 @@ public sealed record ReachabilityUnionNode(
string Kind,
string? Display = null,
ReachabilitySource? Source = null,
IReadOnlyDictionary<string, string>? Attributes = null);
IReadOnlyDictionary<string, string>? Attributes = null,
string? Purl = null,
string? SymbolDigest = null);
public sealed record ReachabilityUnionEdge(
string From,
string To,
string EdgeType,
string? Confidence = "certain",
ReachabilitySource? Source = null);
ReachabilitySource? Source = null,
string? Purl = null,
string? SymbolDigest = null,
IReadOnlyList<ReachabilityEdgeCandidate>? Candidates = null);
/// <summary>
/// Represents a candidate purl+digest when callee resolution is ambiguous.
/// </summary>
public sealed record ReachabilityEdgeCandidate(
string Purl,
string? SymbolDigest = null,
double? Score = null);
public sealed record ReachabilityRuntimeFact(
string SymbolId,

View File

@@ -38,4 +38,132 @@ public class ReachabilityUnionWriterTests
var nodeLines = await File.ReadAllLinesAsync(Path.Combine(temp.Path, "reachability_graphs/analysis-x/nodes.ndjson"));
Assert.Contains(nodeLines, l => l.Contains("sym:dotnet:A"));
}
[Fact]
public async Task WritesNodePurlAndSymbolDigest()
{
var writer = new ReachabilityUnionWriter();
using var temp = new TempDir();
var graph = new ReachabilityUnionGraph(
Nodes: new[]
{
new ReachabilityUnionNode(
"sym:dotnet:A",
"dotnet",
"method",
"TestMethod",
null,
null,
Purl: "pkg:nuget/TestPackage@1.0.0",
SymbolDigest: "sha256:abc123")
},
Edges: Array.Empty<ReachabilityUnionEdge>());
var result = await writer.WriteAsync(graph, temp.Path, "analysis-purl");
var nodeLines = await File.ReadAllLinesAsync(result.Nodes.Path);
Assert.Single(nodeLines);
Assert.Contains("\"purl\":\"pkg:nuget/TestPackage@1.0.0\"", nodeLines[0]);
Assert.Contains("\"symbol_digest\":\"sha256:abc123\"", nodeLines[0]);
}
[Fact]
public async Task WritesEdgePurlAndSymbolDigest()
{
var writer = new ReachabilityUnionWriter();
using var temp = new TempDir();
var graph = new ReachabilityUnionGraph(
Nodes: new[]
{
new ReachabilityUnionNode("sym:dotnet:A", "dotnet", "method"),
new ReachabilityUnionNode("sym:dotnet:B", "dotnet", "method")
},
Edges: new[]
{
new ReachabilityUnionEdge(
"sym:dotnet:A",
"sym:dotnet:B",
"call",
"high",
null,
Purl: "pkg:nuget/TargetPackage@2.0.0",
SymbolDigest: "sha256:def456")
});
var result = await writer.WriteAsync(graph, temp.Path, "analysis-edge-purl");
var edgeLines = await File.ReadAllLinesAsync(result.Edges.Path);
Assert.Single(edgeLines);
Assert.Contains("\"purl\":\"pkg:nuget/TargetPackage@2.0.0\"", edgeLines[0]);
Assert.Contains("\"symbol_digest\":\"sha256:def456\"", edgeLines[0]);
}
[Fact]
public async Task WritesEdgeCandidates()
{
var writer = new ReachabilityUnionWriter();
using var temp = new TempDir();
var graph = new ReachabilityUnionGraph(
Nodes: new[]
{
new ReachabilityUnionNode("sym:binary:main", "binary", "function"),
new ReachabilityUnionNode("sym:binary:openssl_connect", "binary", "function")
},
Edges: new[]
{
new ReachabilityUnionEdge(
"sym:binary:main",
"sym:binary:openssl_connect",
"call",
"medium",
null,
Purl: null,
SymbolDigest: null,
Candidates: new List<ReachabilityEdgeCandidate>
{
new("pkg:deb/ubuntu/openssl@3.0.2", "sha256:abc", 0.8),
new("pkg:deb/debian/openssl@3.0.2", "sha256:def", 0.6)
})
});
var result = await writer.WriteAsync(graph, temp.Path, "analysis-candidates");
var edgeLines = await File.ReadAllLinesAsync(result.Edges.Path);
Assert.Single(edgeLines);
Assert.Contains("\"candidates\":", edgeLines[0]);
Assert.Contains("pkg:deb/ubuntu/openssl@3.0.2", edgeLines[0]);
Assert.Contains("pkg:deb/debian/openssl@3.0.2", edgeLines[0]);
Assert.Contains("\"score\":0.8", edgeLines[0]);
}
[Fact]
public async Task OmitsPurlAndSymbolDigestWhenNull()
{
var writer = new ReachabilityUnionWriter();
using var temp = new TempDir();
var graph = new ReachabilityUnionGraph(
Nodes: new[]
{
new ReachabilityUnionNode("sym:dotnet:A", "dotnet", "method")
},
Edges: new[]
{
new ReachabilityUnionEdge("sym:dotnet:A", "sym:dotnet:A", "call")
});
var result = await writer.WriteAsync(graph, temp.Path, "analysis-null-purl");
var nodeLines = await File.ReadAllLinesAsync(result.Nodes.Path);
Assert.DoesNotContain("purl", nodeLines[0]);
Assert.DoesNotContain("symbol_digest", nodeLines[0]);
var edgeLines = await File.ReadAllLinesAsync(result.Edges.Path);
Assert.DoesNotContain("purl", edgeLines[0]);
Assert.DoesNotContain("symbol_digest", edgeLines[0]);
Assert.DoesNotContain("candidates", edgeLines[0]);
}
}

View File

@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using StellaOps.Cryptography;
using StellaOps.Scanner.Reachability;
using Xunit;
@@ -9,7 +10,7 @@ public class RichGraphPublisherTests
[Fact]
public async Task PublishesGraphToCas()
{
var writer = new RichGraphWriter();
var writer = new RichGraphWriter(CryptoHashFactory.CreateDefault());
var publisher = new ReachabilityRichGraphPublisher(writer);
var cas = new FakeFileContentAddressableStore();
@@ -21,7 +22,8 @@ public class RichGraphPublisherTests
var rich = RichGraphBuilder.FromUnion(union, "test", "1.0.0");
var result = await publisher.PublishAsync(rich, "scan-1", cas, temp.Path);
Assert.StartsWith("sha256:", result.GraphHash);
Assert.Contains(":", result.GraphHash); // hash format: algorithm:digest
Assert.StartsWith("cas://reachability/graphs/", result.CasUri);
Assert.Equal(1, result.NodeCount);
}
}

View File

@@ -1,5 +1,6 @@
using System.IO;
using System.Threading.Tasks;
using StellaOps.Cryptography;
using StellaOps.Scanner.Reachability;
using Xunit;
@@ -10,7 +11,7 @@ public class RichGraphWriterTests
[Fact]
public async Task WritesCanonicalGraphAndMeta()
{
var writer = new RichGraphWriter();
var writer = new RichGraphWriter(CryptoHashFactory.CreateDefault());
using var temp = new TempDir();
var union = new ReachabilityUnionGraph(
@@ -31,7 +32,7 @@ public class RichGraphWriterTests
Assert.True(File.Exists(result.MetaPath));
var json = await File.ReadAllTextAsync(result.GraphPath);
Assert.Contains("richgraph-v1", json);
Assert.StartsWith("sha256:", result.GraphHash);
Assert.Contains(":", result.GraphHash); // hash format: algorithm:digest
Assert.Equal(2, result.NodeCount);
Assert.Equal(1, result.EdgeCount);
}