stabilizaiton work - projects rework for maintenanceability and ui livening
This commit is contained in:
@@ -57,83 +57,3 @@ public sealed record DeduplicatedEdge
|
||||
/// </summary>
|
||||
public bool IsCorroborated => Sources.Count > 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for creating <see cref="DeduplicatedEdge"/> instances by merging multiple source edges.
|
||||
/// </summary>
|
||||
public sealed class DeduplicatedEdgeBuilder
|
||||
{
|
||||
private readonly EdgeSemanticKey _key;
|
||||
private readonly string _from;
|
||||
private readonly string _to;
|
||||
private readonly HashSet<string> _sources = new(StringComparer.Ordinal);
|
||||
private EdgeExplanation? _explanation;
|
||||
private double _maxStrength;
|
||||
private DateTimeOffset _lastSeen = DateTimeOffset.MinValue;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new builder for the given semantic key.
|
||||
/// </summary>
|
||||
public DeduplicatedEdgeBuilder(EdgeSemanticKey key, string from, string to)
|
||||
{
|
||||
_key = key;
|
||||
_from = from;
|
||||
_to = to;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a source edge to this builder.
|
||||
/// </summary>
|
||||
/// <param name="sourceId">The source identifier (e.g., feed name, analyzer ID).</param>
|
||||
/// <param name="explanation">The edge explanation from this source.</param>
|
||||
/// <param name="strength">The strength/weight from this source.</param>
|
||||
/// <param name="observedAt">When this source observed the edge.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public DeduplicatedEdgeBuilder AddSource(
|
||||
string sourceId,
|
||||
EdgeExplanation explanation,
|
||||
double strength,
|
||||
DateTimeOffset observedAt)
|
||||
{
|
||||
_sources.Add(sourceId);
|
||||
|
||||
// Keep the strongest explanation
|
||||
if (strength > _maxStrength || _explanation is null)
|
||||
{
|
||||
_maxStrength = strength;
|
||||
_explanation = explanation;
|
||||
}
|
||||
|
||||
// Track most recent observation
|
||||
if (observedAt > _lastSeen)
|
||||
{
|
||||
_lastSeen = observedAt;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the deduplicated edge.
|
||||
/// </summary>
|
||||
/// <returns>The deduplicated edge with merged provenance.</returns>
|
||||
/// <exception cref="InvalidOperationException">If no sources were added.</exception>
|
||||
public DeduplicatedEdge Build()
|
||||
{
|
||||
if (_sources.Count == 0 || _explanation is null)
|
||||
{
|
||||
throw new InvalidOperationException("At least one source must be added before building.");
|
||||
}
|
||||
|
||||
return new DeduplicatedEdge
|
||||
{
|
||||
Key = _key,
|
||||
From = _from,
|
||||
To = _to,
|
||||
Why = _explanation,
|
||||
Sources = _sources.ToImmutableHashSet(StringComparer.Ordinal),
|
||||
Strength = _maxStrength,
|
||||
LastSeen = _lastSeen
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.ReachGraph.Deduplication;
|
||||
|
||||
/// <summary>
|
||||
/// Builder for creating <see cref="DeduplicatedEdge"/> instances by merging multiple source edges.
|
||||
/// </summary>
|
||||
public sealed class DeduplicatedEdgeBuilder
|
||||
{
|
||||
private readonly EdgeSemanticKey _key;
|
||||
private readonly string _from;
|
||||
private readonly string _to;
|
||||
private readonly HashSet<string> _sources = new(StringComparer.Ordinal);
|
||||
private EdgeExplanation? _explanation;
|
||||
private double _maxStrength;
|
||||
private DateTimeOffset _lastSeen = DateTimeOffset.MinValue;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new builder for the given semantic key.
|
||||
/// </summary>
|
||||
public DeduplicatedEdgeBuilder(EdgeSemanticKey key, string from, string to)
|
||||
{
|
||||
_key = key;
|
||||
_from = from;
|
||||
_to = to;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a source edge to this builder.
|
||||
/// </summary>
|
||||
/// <param name="sourceId">The source identifier (e.g., feed name, analyzer ID).</param>
|
||||
/// <param name="explanation">The edge explanation from this source.</param>
|
||||
/// <param name="strength">The strength/weight from this source.</param>
|
||||
/// <param name="observedAt">When this source observed the edge.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public DeduplicatedEdgeBuilder AddSource(
|
||||
string sourceId,
|
||||
EdgeExplanation explanation,
|
||||
double strength,
|
||||
DateTimeOffset observedAt)
|
||||
{
|
||||
_sources.Add(sourceId);
|
||||
|
||||
if (strength > _maxStrength || _explanation is null)
|
||||
{
|
||||
_maxStrength = strength;
|
||||
_explanation = explanation;
|
||||
}
|
||||
|
||||
if (observedAt > _lastSeen)
|
||||
{
|
||||
_lastSeen = observedAt;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the deduplicated edge.
|
||||
/// </summary>
|
||||
/// <returns>The deduplicated edge with merged provenance.</returns>
|
||||
/// <exception cref="InvalidOperationException">If no sources were added.</exception>
|
||||
public DeduplicatedEdge Build()
|
||||
{
|
||||
if (_sources.Count == 0 || _explanation is null)
|
||||
{
|
||||
throw new InvalidOperationException("At least one source must be added before building.");
|
||||
}
|
||||
|
||||
return new DeduplicatedEdge
|
||||
{
|
||||
Key = _key,
|
||||
From = _from,
|
||||
To = _to,
|
||||
Why = _explanation,
|
||||
Sources = _sources.ToImmutableHashSet(StringComparer.Ordinal),
|
||||
Strength = _maxStrength,
|
||||
LastSeen = _lastSeen
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,32 +2,9 @@
|
||||
|
||||
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.ReachGraph.Deduplication;
|
||||
|
||||
/// <summary>
|
||||
/// Service for deduplicating edges from multiple sources into semantically unique edges.
|
||||
/// </summary>
|
||||
public interface IEdgeDeduplicator
|
||||
{
|
||||
/// <summary>
|
||||
/// Deduplicates a collection of edges by their semantic keys.
|
||||
/// </summary>
|
||||
/// <param name="edges">The edges to deduplicate.</param>
|
||||
/// <param name="keyExtractor">Function to extract semantic key from an edge.</param>
|
||||
/// <param name="sourceExtractor">Function to extract source ID from an edge.</param>
|
||||
/// <param name="strengthExtractor">Function to extract strength/weight from an edge.</param>
|
||||
/// <param name="timestampExtractor">Function to extract observation timestamp.</param>
|
||||
/// <returns>Deduplicated edges with merged provenance.</returns>
|
||||
IReadOnlyList<DeduplicatedEdge> Deduplicate(
|
||||
IEnumerable<ReachGraphEdge> edges,
|
||||
Func<ReachGraphEdge, EdgeSemanticKey> keyExtractor,
|
||||
Func<ReachGraphEdge, string> sourceExtractor,
|
||||
Func<ReachGraphEdge, double> strengthExtractor,
|
||||
Func<ReachGraphEdge, DateTimeOffset> timestampExtractor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IEdgeDeduplicator"/>.
|
||||
/// </summary>
|
||||
@@ -80,59 +57,3 @@ public sealed class EdgeDeduplicator : IEdgeDeduplicator
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extensions for edge deduplication.
|
||||
/// </summary>
|
||||
public static class EdgeDeduplicatorExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Deduplicates edges using default extractors based on edge properties.
|
||||
/// </summary>
|
||||
/// <param name="deduplicator">The deduplicator instance.</param>
|
||||
/// <param name="edges">The edges to deduplicate.</param>
|
||||
/// <param name="vulnerabilityId">The vulnerability ID to associate with edges.</param>
|
||||
/// <param name="defaultSource">Default source ID if not specified.</param>
|
||||
/// <param name="timeProvider">Time provider for timestamps.</param>
|
||||
/// <returns>Deduplicated edges.</returns>
|
||||
public static IReadOnlyList<DeduplicatedEdge> DeduplicateWithDefaults(
|
||||
this IEdgeDeduplicator deduplicator,
|
||||
IEnumerable<ReachGraphEdge> edges,
|
||||
string vulnerabilityId,
|
||||
string defaultSource = "unknown",
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
var time = timeProvider ?? TimeProvider.System;
|
||||
var now = time.GetUtcNow();
|
||||
|
||||
return deduplicator.Deduplicate(
|
||||
edges,
|
||||
keyExtractor: e => new EdgeSemanticKey(e.From, e.To, vulnerabilityId),
|
||||
sourceExtractor: _ => defaultSource,
|
||||
strengthExtractor: e => GetEdgeStrength(e.Why),
|
||||
timestampExtractor: _ => now);
|
||||
}
|
||||
|
||||
private static double GetEdgeStrength(EdgeExplanation explanation)
|
||||
{
|
||||
// Use the explanation's confidence as the base strength
|
||||
// Map edge explanation type to a multiplier
|
||||
var typeMultiplier = explanation.Type switch
|
||||
{
|
||||
EdgeExplanationType.DirectCall => 1.0,
|
||||
EdgeExplanationType.Import => 0.95,
|
||||
EdgeExplanationType.DynamicLoad => 0.9,
|
||||
EdgeExplanationType.Ffi => 0.85,
|
||||
EdgeExplanationType.Reflection => 0.8,
|
||||
EdgeExplanationType.LoaderRule => 0.75,
|
||||
EdgeExplanationType.TaintGate => 0.7,
|
||||
EdgeExplanationType.EnvGuard => 0.65,
|
||||
EdgeExplanationType.FeatureFlag => 0.6,
|
||||
EdgeExplanationType.PlatformArch => 0.6,
|
||||
EdgeExplanationType.Unknown => 0.5,
|
||||
_ => 0.5
|
||||
};
|
||||
|
||||
return explanation.Confidence * typeMultiplier;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.Deduplication;
|
||||
|
||||
/// <summary>
|
||||
/// Extensions for edge deduplication.
|
||||
/// </summary>
|
||||
public static class EdgeDeduplicatorExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Deduplicates edges using default extractors based on edge properties.
|
||||
/// </summary>
|
||||
/// <param name="deduplicator">The deduplicator instance.</param>
|
||||
/// <param name="edges">The edges to deduplicate.</param>
|
||||
/// <param name="vulnerabilityId">The vulnerability ID to associate with edges.</param>
|
||||
/// <param name="defaultSource">Default source ID if not specified.</param>
|
||||
/// <param name="timeProvider">Time provider for timestamps.</param>
|
||||
/// <returns>Deduplicated edges.</returns>
|
||||
public static IReadOnlyList<DeduplicatedEdge> DeduplicateWithDefaults(
|
||||
this IEdgeDeduplicator deduplicator,
|
||||
IEnumerable<ReachGraphEdge> edges,
|
||||
string vulnerabilityId,
|
||||
string defaultSource = "unknown",
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
var time = timeProvider ?? TimeProvider.System;
|
||||
var now = time.GetUtcNow();
|
||||
|
||||
return deduplicator.Deduplicate(
|
||||
edges,
|
||||
keyExtractor: e => new EdgeSemanticKey(e.From, e.To, vulnerabilityId),
|
||||
sourceExtractor: _ => defaultSource,
|
||||
strengthExtractor: e => GetEdgeStrength(e.Why),
|
||||
timestampExtractor: _ => now);
|
||||
}
|
||||
|
||||
private static double GetEdgeStrength(EdgeExplanation explanation)
|
||||
{
|
||||
var typeMultiplier = explanation.Type switch
|
||||
{
|
||||
EdgeExplanationType.DirectCall => 1.0,
|
||||
EdgeExplanationType.Import => 0.95,
|
||||
EdgeExplanationType.DynamicLoad => 0.9,
|
||||
EdgeExplanationType.Ffi => 0.85,
|
||||
EdgeExplanationType.Reflection => 0.8,
|
||||
EdgeExplanationType.LoaderRule => 0.75,
|
||||
EdgeExplanationType.TaintGate => 0.7,
|
||||
EdgeExplanationType.EnvGuard => 0.65,
|
||||
EdgeExplanationType.FeatureFlag => 0.6,
|
||||
EdgeExplanationType.PlatformArch => 0.6,
|
||||
EdgeExplanationType.Unknown => 0.5,
|
||||
_ => 0.5
|
||||
};
|
||||
|
||||
return explanation.Confidence * typeMultiplier;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.ReachGraph.Deduplication;
|
||||
|
||||
public readonly partial record struct EdgeSemanticKey
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes a canonical string key for this semantic key.
|
||||
/// </summary>
|
||||
/// <returns>A canonical string representation suitable for dictionary keys.</returns>
|
||||
public string ComputeKey()
|
||||
{
|
||||
var builder = new StringBuilder(256);
|
||||
builder.Append(EntryPointId);
|
||||
builder.Append('|');
|
||||
builder.Append(SinkId);
|
||||
builder.Append('|');
|
||||
builder.Append(VulnerabilityId ?? string.Empty);
|
||||
builder.Append('|');
|
||||
builder.Append(GateApplied ?? string.Empty);
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a SHA-256 hash of the canonical key for compact storage.
|
||||
/// </summary>
|
||||
/// <returns>A lowercase hex-encoded SHA-256 hash.</returns>
|
||||
public string ComputeHash()
|
||||
{
|
||||
var key = ComputeKey();
|
||||
var bytes = Encoding.UTF8.GetBytes(key);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return HashCode.Combine(
|
||||
EntryPointId,
|
||||
SinkId,
|
||||
VulnerabilityId ?? string.Empty,
|
||||
GateApplied ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(EdgeSemanticKey other)
|
||||
{
|
||||
return string.Equals(EntryPointId, other.EntryPointId, StringComparison.Ordinal) &&
|
||||
string.Equals(SinkId, other.SinkId, StringComparison.Ordinal) &&
|
||||
string.Equals(VulnerabilityId, other.VulnerabilityId, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(GateApplied, other.GateApplied, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString() => ComputeKey();
|
||||
|
||||
private static string? NormalizeId(string? id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (id.StartsWith("cve-", StringComparison.OrdinalIgnoreCase) ||
|
||||
id.StartsWith("CVE-", StringComparison.Ordinal))
|
||||
{
|
||||
return id.ToUpperInvariant();
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.ReachGraph.Deduplication;
|
||||
|
||||
/// <summary>
|
||||
@@ -20,7 +16,7 @@ namespace StellaOps.ReachGraph.Deduplication;
|
||||
/// - Vulnerability ID (if applicable)
|
||||
/// - Applied gate (if any)
|
||||
/// </remarks>
|
||||
public readonly record struct EdgeSemanticKey : IEquatable<EdgeSemanticKey>
|
||||
public readonly partial record struct EdgeSemanticKey : IEquatable<EdgeSemanticKey>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the entry point node identifier.
|
||||
@@ -64,71 +60,4 @@ public readonly record struct EdgeSemanticKey : IEquatable<EdgeSemanticKey>
|
||||
GateApplied = gateApplied;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a canonical string key for this semantic key.
|
||||
/// </summary>
|
||||
/// <returns>A canonical string representation suitable for dictionary keys.</returns>
|
||||
public string ComputeKey()
|
||||
{
|
||||
var builder = new StringBuilder(256);
|
||||
builder.Append(EntryPointId);
|
||||
builder.Append('|');
|
||||
builder.Append(SinkId);
|
||||
builder.Append('|');
|
||||
builder.Append(VulnerabilityId ?? string.Empty);
|
||||
builder.Append('|');
|
||||
builder.Append(GateApplied ?? string.Empty);
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a SHA-256 hash of the canonical key for compact storage.
|
||||
/// </summary>
|
||||
/// <returns>A lowercase hex-encoded SHA-256 hash.</returns>
|
||||
public string ComputeHash()
|
||||
{
|
||||
var key = ComputeKey();
|
||||
var bytes = Encoding.UTF8.GetBytes(key);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return HashCode.Combine(
|
||||
EntryPointId,
|
||||
SinkId,
|
||||
VulnerabilityId ?? string.Empty,
|
||||
GateApplied ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(EdgeSemanticKey other)
|
||||
{
|
||||
return string.Equals(EntryPointId, other.EntryPointId, StringComparison.Ordinal) &&
|
||||
string.Equals(SinkId, other.SinkId, StringComparison.Ordinal) &&
|
||||
string.Equals(VulnerabilityId, other.VulnerabilityId, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(GateApplied, other.GateApplied, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString() => ComputeKey();
|
||||
|
||||
private static string? NormalizeId(string? id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize CVE IDs to uppercase for consistent comparison
|
||||
if (id.StartsWith("cve-", StringComparison.OrdinalIgnoreCase) ||
|
||||
id.StartsWith("CVE-", StringComparison.Ordinal))
|
||||
{
|
||||
return id.ToUpperInvariant();
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.Deduplication;
|
||||
|
||||
/// <summary>
|
||||
/// Service for deduplicating edges from multiple sources into semantically unique edges.
|
||||
/// </summary>
|
||||
public interface IEdgeDeduplicator
|
||||
{
|
||||
/// <summary>
|
||||
/// Deduplicates a collection of edges by their semantic keys.
|
||||
/// </summary>
|
||||
/// <param name="edges">The edges to deduplicate.</param>
|
||||
/// <param name="keyExtractor">Function to extract semantic key from an edge.</param>
|
||||
/// <param name="sourceExtractor">Function to extract source ID from an edge.</param>
|
||||
/// <param name="strengthExtractor">Function to extract strength/weight from an edge.</param>
|
||||
/// <param name="timestampExtractor">Function to extract observation timestamp.</param>
|
||||
/// <returns>Deduplicated edges with merged provenance.</returns>
|
||||
IReadOnlyList<DeduplicatedEdge> Deduplicate(
|
||||
IEnumerable<ReachGraphEdge> edges,
|
||||
Func<ReachGraphEdge, EdgeSemanticKey> keyExtractor,
|
||||
Func<ReachGraphEdge, string> sourceExtractor,
|
||||
Func<ReachGraphEdge, double> strengthExtractor,
|
||||
Func<ReachGraphEdge, DateTimeOffset> timestampExtractor);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
namespace StellaOps.ReachGraph.Hashing;
|
||||
|
||||
public sealed partial class ReachGraphDigestComputer
|
||||
{
|
||||
/// <summary>
|
||||
/// Parse a digest string into its algorithm and hash components.
|
||||
/// </summary>
|
||||
/// <param name="digest">The digest string (e.g., "blake3:abc123...").</param>
|
||||
/// <returns>Tuple of (algorithm, hash) or null if invalid format.</returns>
|
||||
public static (string Algorithm, string Hash)? ParseDigest(string digest)
|
||||
{
|
||||
if (string.IsNullOrEmpty(digest))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var colonIndex = digest.IndexOf(':');
|
||||
if (colonIndex <= 0 || colonIndex >= digest.Length - 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var algorithm = digest[..colonIndex];
|
||||
var hash = digest[(colonIndex + 1)..];
|
||||
|
||||
return (algorithm, hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate that a digest string has the correct format for BLAKE3.
|
||||
/// </summary>
|
||||
/// <param name="digest">The digest string to validate.</param>
|
||||
/// <returns>True if valid BLAKE3 digest format, false otherwise.</returns>
|
||||
public static bool IsValidBlake3Digest(string digest)
|
||||
{
|
||||
var parsed = ParseDigest(digest);
|
||||
if (parsed is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var (algorithm, hash) = parsed.Value;
|
||||
|
||||
return string.Equals(algorithm, "blake3", StringComparison.OrdinalIgnoreCase) &&
|
||||
hash.Length == 64 &&
|
||||
hash.All(static c => char.IsAsciiHexDigit(c));
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ namespace StellaOps.ReachGraph.Hashing;
|
||||
/// <summary>
|
||||
/// Computes BLAKE3-256 digests for reachability graphs using canonical serialization.
|
||||
/// </summary>
|
||||
public sealed class ReachGraphDigestComputer
|
||||
public sealed partial class ReachGraphDigestComputer
|
||||
{
|
||||
private readonly CanonicalReachGraphSerializer _serializer;
|
||||
|
||||
@@ -73,42 +73,4 @@ public sealed class ReachGraphDigestComputer
|
||||
return string.Equals(computed, expectedDigest, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a digest string into its algorithm and hash components.
|
||||
/// </summary>
|
||||
/// <param name="digest">The digest string (e.g., "blake3:abc123...").</param>
|
||||
/// <returns>Tuple of (algorithm, hash) or null if invalid format.</returns>
|
||||
public static (string Algorithm, string Hash)? ParseDigest(string digest)
|
||||
{
|
||||
if (string.IsNullOrEmpty(digest))
|
||||
return null;
|
||||
|
||||
var colonIndex = digest.IndexOf(':');
|
||||
if (colonIndex <= 0 || colonIndex >= digest.Length - 1)
|
||||
return null;
|
||||
|
||||
var algorithm = digest[..colonIndex];
|
||||
var hash = digest[(colonIndex + 1)..];
|
||||
|
||||
return (algorithm, hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate that a digest string has the correct format for BLAKE3.
|
||||
/// </summary>
|
||||
/// <param name="digest">The digest string to validate.</param>
|
||||
/// <returns>True if valid BLAKE3 digest format, false otherwise.</returns>
|
||||
public static bool IsValidBlake3Digest(string digest)
|
||||
{
|
||||
var parsed = ParseDigest(digest);
|
||||
if (parsed is null)
|
||||
return false;
|
||||
|
||||
var (algorithm, hash) = parsed.Value;
|
||||
|
||||
// BLAKE3-256 produces 64 hex characters (32 bytes)
|
||||
return string.Equals(algorithm, "blake3", StringComparison.OrdinalIgnoreCase) &&
|
||||
hash.Length == 64 &&
|
||||
hash.All(c => char.IsAsciiHexDigit(c));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.ReachGraph.Serialization;
|
||||
|
||||
public sealed partial class CanonicalReachGraphSerializer
|
||||
{
|
||||
private static ReachGraphArtifactDto CreateArtifactDto(ReachGraphArtifact artifact)
|
||||
{
|
||||
var sortedEnv = artifact.Env.OrderBy(e => e, StringComparer.Ordinal).ToList();
|
||||
|
||||
return new ReachGraphArtifactDto
|
||||
{
|
||||
Name = artifact.Name,
|
||||
Digest = artifact.Digest,
|
||||
Env = sortedEnv
|
||||
};
|
||||
}
|
||||
|
||||
private static ReachGraphScopeDto CreateScopeDto(ReachGraphScope scope)
|
||||
{
|
||||
var sortedEntrypoints = scope.Entrypoints.OrderBy(e => e, StringComparer.Ordinal).ToList();
|
||||
var sortedSelectors = scope.Selectors.OrderBy(s => s, StringComparer.Ordinal).ToList();
|
||||
List<string>? sortedCves = scope.Cves is { Length: > 0 } cves
|
||||
? cves.OrderBy(c => c, StringComparer.Ordinal).ToList()
|
||||
: null;
|
||||
|
||||
return new ReachGraphScopeDto
|
||||
{
|
||||
Entrypoints = sortedEntrypoints,
|
||||
Selectors = sortedSelectors,
|
||||
Cves = sortedCves
|
||||
};
|
||||
}
|
||||
|
||||
private static ReachGraphProvenanceDto CreateProvenanceDto(ReachGraphProvenance provenance)
|
||||
{
|
||||
List<string>? sortedIntoto = provenance.Intoto is { Length: > 0 } intoto
|
||||
? intoto.OrderBy(i => i, StringComparer.Ordinal).ToList()
|
||||
: null;
|
||||
|
||||
return new ReachGraphProvenanceDto
|
||||
{
|
||||
Intoto = sortedIntoto,
|
||||
Inputs = new ReachGraphInputsDto
|
||||
{
|
||||
Sbom = provenance.Inputs.Sbom,
|
||||
Vex = provenance.Inputs.Vex,
|
||||
Callgraph = provenance.Inputs.Callgraph,
|
||||
RuntimeFacts = provenance.Inputs.RuntimeFacts,
|
||||
Policy = provenance.Inputs.Policy
|
||||
},
|
||||
ComputedAt = provenance.ComputedAt,
|
||||
Analyzer = new ReachGraphAnalyzerDto
|
||||
{
|
||||
Name = provenance.Analyzer.Name,
|
||||
Version = provenance.Analyzer.Version,
|
||||
ToolchainDigest = provenance.Analyzer.ToolchainDigest
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static List<ReachGraphSignatureDto>? CreateSignatureDtos(
|
||||
ImmutableArray<ReachGraphSignature>? signatures)
|
||||
{
|
||||
if (signatures is not { Length: > 0 } sigs)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return sigs
|
||||
.OrderBy(s => s.KeyId, StringComparer.Ordinal)
|
||||
.Select(s => new ReachGraphSignatureDto { KeyId = s.KeyId, Sig = s.Sig })
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.ReachGraph.Serialization;
|
||||
|
||||
public sealed partial class CanonicalReachGraphSerializer
|
||||
{
|
||||
private static List<ReachGraphNodeDto> CreateNodeDtos(ImmutableArray<ReachGraphNode> nodes)
|
||||
{
|
||||
return nodes
|
||||
.OrderBy(n => n.Id, StringComparer.Ordinal)
|
||||
.Select(n => new ReachGraphNodeDto
|
||||
{
|
||||
Id = n.Id,
|
||||
Kind = n.Kind,
|
||||
Ref = n.Ref,
|
||||
File = n.File,
|
||||
Line = n.Line,
|
||||
ModuleHash = n.ModuleHash,
|
||||
Addr = n.Addr,
|
||||
IsEntrypoint = n.IsEntrypoint,
|
||||
IsSink = n.IsSink
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<ReachGraphEdgeDto> CreateEdgeDtos(ImmutableArray<ReachGraphEdge> edges)
|
||||
{
|
||||
return edges
|
||||
.OrderBy(e => e.From, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.To, StringComparer.Ordinal)
|
||||
.Select(e => new ReachGraphEdgeDto
|
||||
{
|
||||
From = e.From,
|
||||
To = e.To,
|
||||
Why = new EdgeExplanationDto
|
||||
{
|
||||
Type = e.Why.Type,
|
||||
Loc = e.Why.Loc,
|
||||
Guard = e.Why.Guard,
|
||||
Confidence = e.Why.Confidence,
|
||||
Metadata = e.Why.Metadata?.Count > 0
|
||||
? new SortedDictionary<string, string>(e.Why.Metadata, StringComparer.Ordinal)
|
||||
: null
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.Serialization;
|
||||
|
||||
public sealed partial class CanonicalReachGraphSerializer
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a canonical DTO with sorted arrays.
|
||||
/// </summary>
|
||||
private static ReachGraphMinimalDto Canonicalize(ReachGraphMinimal graph)
|
||||
{
|
||||
return new ReachGraphMinimalDto
|
||||
{
|
||||
SchemaVersion = graph.SchemaVersion,
|
||||
Artifact = CreateArtifactDto(graph.Artifact),
|
||||
Scope = CreateScopeDto(graph.Scope),
|
||||
Nodes = CreateNodeDtos(graph.Nodes),
|
||||
Edges = CreateEdgeDtos(graph.Edges),
|
||||
Provenance = CreateProvenanceDto(graph.Provenance),
|
||||
Signatures = CreateSignatureDtos(graph.Signatures)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ReachGraph.Serialization;
|
||||
|
||||
public sealed partial class CanonicalReachGraphSerializer
|
||||
{
|
||||
private sealed class ReachGraphMinimalDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string SchemaVersion { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required ReachGraphArtifactDto Artifact { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required ReachGraphScopeDto Scope { get; init; }
|
||||
|
||||
[JsonPropertyOrder(4)]
|
||||
public required List<ReachGraphNodeDto> Nodes { get; init; }
|
||||
|
||||
[JsonPropertyOrder(5)]
|
||||
public required List<ReachGraphEdgeDto> Edges { get; init; }
|
||||
|
||||
[JsonPropertyOrder(6)]
|
||||
public required ReachGraphProvenanceDto Provenance { get; init; }
|
||||
|
||||
[JsonPropertyOrder(7)]
|
||||
public List<ReachGraphSignatureDto>? Signatures { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphArtifactDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required List<string> Env { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphScopeDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required List<string> Entrypoints { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required List<string> Selectors { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public List<string>? Cves { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ReachGraph.Serialization;
|
||||
|
||||
public sealed partial class CanonicalReachGraphSerializer
|
||||
{
|
||||
private sealed class ReachGraphNodeDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required ReachGraphNodeKind Kind { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required string Ref { get; init; }
|
||||
|
||||
[JsonPropertyOrder(4)]
|
||||
public string? File { get; init; }
|
||||
|
||||
[JsonPropertyOrder(5)]
|
||||
public int? Line { get; init; }
|
||||
|
||||
[JsonPropertyOrder(6)]
|
||||
public string? ModuleHash { get; init; }
|
||||
|
||||
[JsonPropertyOrder(7)]
|
||||
public string? Addr { get; init; }
|
||||
|
||||
[JsonPropertyOrder(8)]
|
||||
public bool? IsEntrypoint { get; init; }
|
||||
|
||||
[JsonPropertyOrder(9)]
|
||||
public bool? IsSink { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphEdgeDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string From { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required string To { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required EdgeExplanationDto Why { get; init; }
|
||||
}
|
||||
|
||||
private sealed class EdgeExplanationDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required EdgeExplanationType Type { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public string? Loc { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public string? Guard { get; init; }
|
||||
|
||||
[JsonPropertyOrder(4)]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
[JsonPropertyOrder(5)]
|
||||
public SortedDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ReachGraph.Serialization;
|
||||
|
||||
public sealed partial class CanonicalReachGraphSerializer
|
||||
{
|
||||
private sealed class ReachGraphProvenanceDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public List<string>? Intoto { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required ReachGraphInputsDto Inputs { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
[JsonPropertyOrder(4)]
|
||||
public required ReachGraphAnalyzerDto Analyzer { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphInputsDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string Sbom { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public string? Vex { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public string? Callgraph { get; init; }
|
||||
|
||||
[JsonPropertyOrder(4)]
|
||||
public string? RuntimeFacts { get; init; }
|
||||
|
||||
[JsonPropertyOrder(5)]
|
||||
public string? Policy { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphAnalyzerDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required string ToolchainDigest { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphSignatureDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.ReachGraph.Serialization;
|
||||
|
||||
public sealed partial class CanonicalReachGraphSerializer
|
||||
{
|
||||
private static ReachGraphArtifact CreateArtifact(ReachGraphArtifactDto dto)
|
||||
{
|
||||
return new ReachGraphArtifact(dto.Name, dto.Digest, [.. dto.Env]);
|
||||
}
|
||||
|
||||
private static ReachGraphScope CreateScope(ReachGraphScopeDto dto)
|
||||
{
|
||||
return new ReachGraphScope(
|
||||
[.. dto.Entrypoints],
|
||||
[.. dto.Selectors],
|
||||
dto.Cves is { Count: > 0 } ? [.. dto.Cves] : null);
|
||||
}
|
||||
|
||||
private static ReachGraphProvenance CreateProvenance(ReachGraphProvenanceDto dto)
|
||||
{
|
||||
return new ReachGraphProvenance
|
||||
{
|
||||
Intoto = dto.Intoto is { Count: > 0 } ? [.. dto.Intoto] : null,
|
||||
Inputs = new ReachGraphInputs
|
||||
{
|
||||
Sbom = dto.Inputs.Sbom,
|
||||
Vex = dto.Inputs.Vex,
|
||||
Callgraph = dto.Inputs.Callgraph,
|
||||
RuntimeFacts = dto.Inputs.RuntimeFacts,
|
||||
Policy = dto.Inputs.Policy
|
||||
},
|
||||
ComputedAt = dto.ComputedAt,
|
||||
Analyzer = new ReachGraphAnalyzer(
|
||||
dto.Analyzer.Name,
|
||||
dto.Analyzer.Version,
|
||||
dto.Analyzer.ToolchainDigest)
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableArray<ReachGraphSignature>? CreateSignatures(
|
||||
List<ReachGraphSignatureDto>? signatures)
|
||||
{
|
||||
if (signatures is not { Count: > 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return [.. signatures.Select(s => new ReachGraphSignature(s.KeyId, s.Sig))];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.ReachGraph.Serialization;
|
||||
|
||||
public sealed partial class CanonicalReachGraphSerializer
|
||||
{
|
||||
private static ImmutableArray<ReachGraphNode> CreateNodes(List<ReachGraphNodeDto> nodes)
|
||||
{
|
||||
return [.. nodes.Select(n => new ReachGraphNode
|
||||
{
|
||||
Id = n.Id,
|
||||
Kind = n.Kind,
|
||||
Ref = n.Ref,
|
||||
File = n.File,
|
||||
Line = n.Line,
|
||||
ModuleHash = n.ModuleHash,
|
||||
Addr = n.Addr,
|
||||
IsEntrypoint = n.IsEntrypoint,
|
||||
IsSink = n.IsSink
|
||||
})];
|
||||
}
|
||||
|
||||
private static ImmutableArray<ReachGraphEdge> CreateEdges(List<ReachGraphEdgeDto> edges)
|
||||
{
|
||||
return [.. edges.Select(e => new ReachGraphEdge
|
||||
{
|
||||
From = e.From,
|
||||
To = e.To,
|
||||
Why = new EdgeExplanation
|
||||
{
|
||||
Type = e.Why.Type,
|
||||
Loc = e.Why.Loc,
|
||||
Guard = e.Why.Guard,
|
||||
Confidence = e.Why.Confidence,
|
||||
Metadata = e.Why.Metadata?.Count > 0
|
||||
? e.Why.Metadata.ToImmutableDictionary()
|
||||
: null
|
||||
}
|
||||
})];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.Serialization;
|
||||
|
||||
public sealed partial class CanonicalReachGraphSerializer
|
||||
{
|
||||
/// <summary>
|
||||
/// Convert DTO back to domain record.
|
||||
/// </summary>
|
||||
private static ReachGraphMinimal FromDto(ReachGraphMinimalDto dto)
|
||||
{
|
||||
return new ReachGraphMinimal
|
||||
{
|
||||
SchemaVersion = dto.SchemaVersion,
|
||||
Artifact = CreateArtifact(dto.Artifact),
|
||||
Scope = CreateScope(dto.Scope),
|
||||
Nodes = CreateNodes(dto.Nodes),
|
||||
Edges = CreateEdges(dto.Edges),
|
||||
Provenance = CreateProvenance(dto.Provenance),
|
||||
Signatures = CreateSignatures(dto.Signatures)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ReachGraph.Serialization;
|
||||
|
||||
public sealed partial class CanonicalReachGraphSerializer
|
||||
{
|
||||
private static JsonSerializerOptions CreateSerializerOptions(bool writeIndented)
|
||||
{
|
||||
return new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = writeIndented,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters =
|
||||
{
|
||||
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase),
|
||||
new Iso8601MillisecondConverter()
|
||||
},
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ReachGraph.Serialization;
|
||||
|
||||
@@ -18,7 +13,7 @@ namespace StellaOps.ReachGraph.Serialization;
|
||||
/// - Null fields omitted
|
||||
/// - Minified output for storage, prettified for debugging
|
||||
/// </summary>
|
||||
public sealed class CanonicalReachGraphSerializer
|
||||
public sealed partial class CanonicalReachGraphSerializer
|
||||
{
|
||||
private readonly JsonSerializerOptions _minifiedOptions;
|
||||
private readonly JsonSerializerOptions _prettyOptions;
|
||||
@@ -74,390 +69,4 @@ public sealed class CanonicalReachGraphSerializer
|
||||
|
||||
return FromDto(dto);
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions CreateSerializerOptions(bool writeIndented)
|
||||
{
|
||||
return new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = writeIndented,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Converters =
|
||||
{
|
||||
new JsonStringEnumConverter(JsonNamingPolicy.CamelCase),
|
||||
new Iso8601MillisecondConverter()
|
||||
},
|
||||
// Ensure consistent ordering
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a canonical DTO with sorted arrays.
|
||||
/// </summary>
|
||||
private static ReachGraphMinimalDto Canonicalize(ReachGraphMinimal graph)
|
||||
{
|
||||
// Sort nodes by Id lexicographically
|
||||
var sortedNodes = graph.Nodes
|
||||
.OrderBy(n => n.Id, StringComparer.Ordinal)
|
||||
.Select(n => new ReachGraphNodeDto
|
||||
{
|
||||
Id = n.Id,
|
||||
Kind = n.Kind,
|
||||
Ref = n.Ref,
|
||||
File = n.File,
|
||||
Line = n.Line,
|
||||
ModuleHash = n.ModuleHash,
|
||||
Addr = n.Addr,
|
||||
IsEntrypoint = n.IsEntrypoint,
|
||||
IsSink = n.IsSink
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Sort edges by From, then To
|
||||
var sortedEdges = graph.Edges
|
||||
.OrderBy(e => e.From, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.To, StringComparer.Ordinal)
|
||||
.Select(e => new ReachGraphEdgeDto
|
||||
{
|
||||
From = e.From,
|
||||
To = e.To,
|
||||
Why = new EdgeExplanationDto
|
||||
{
|
||||
Type = e.Why.Type,
|
||||
Loc = e.Why.Loc,
|
||||
Guard = e.Why.Guard,
|
||||
Confidence = e.Why.Confidence,
|
||||
Metadata = e.Why.Metadata?.Count > 0
|
||||
? new SortedDictionary<string, string>(e.Why.Metadata, StringComparer.Ordinal)
|
||||
: null
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Sort signatures by KeyId if present
|
||||
List<ReachGraphSignatureDto>? sortedSignatures = null;
|
||||
if (graph.Signatures is { Length: > 0 } sigs)
|
||||
{
|
||||
sortedSignatures = sigs
|
||||
.OrderBy(s => s.KeyId, StringComparer.Ordinal)
|
||||
.Select(s => new ReachGraphSignatureDto { KeyId = s.KeyId, Sig = s.Sig })
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Sort entrypoints and selectors
|
||||
var sortedEntrypoints = graph.Scope.Entrypoints
|
||||
.OrderBy(e => e, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var sortedSelectors = graph.Scope.Selectors
|
||||
.OrderBy(s => s, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
List<string>? sortedCves = graph.Scope.Cves is { Length: > 0 } cves
|
||||
? cves.OrderBy(c => c, StringComparer.Ordinal).ToList()
|
||||
: null;
|
||||
|
||||
// Sort env array
|
||||
var sortedEnv = graph.Artifact.Env
|
||||
.OrderBy(e => e, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
// Sort intoto array if present
|
||||
List<string>? sortedIntoto = graph.Provenance.Intoto is { Length: > 0 } intoto
|
||||
? intoto.OrderBy(i => i, StringComparer.Ordinal).ToList()
|
||||
: null;
|
||||
|
||||
return new ReachGraphMinimalDto
|
||||
{
|
||||
SchemaVersion = graph.SchemaVersion,
|
||||
Artifact = new ReachGraphArtifactDto
|
||||
{
|
||||
Name = graph.Artifact.Name,
|
||||
Digest = graph.Artifact.Digest,
|
||||
Env = sortedEnv
|
||||
},
|
||||
Scope = new ReachGraphScopeDto
|
||||
{
|
||||
Entrypoints = sortedEntrypoints,
|
||||
Selectors = sortedSelectors,
|
||||
Cves = sortedCves
|
||||
},
|
||||
Nodes = sortedNodes,
|
||||
Edges = sortedEdges,
|
||||
Provenance = new ReachGraphProvenanceDto
|
||||
{
|
||||
Intoto = sortedIntoto,
|
||||
Inputs = new ReachGraphInputsDto
|
||||
{
|
||||
Sbom = graph.Provenance.Inputs.Sbom,
|
||||
Vex = graph.Provenance.Inputs.Vex,
|
||||
Callgraph = graph.Provenance.Inputs.Callgraph,
|
||||
RuntimeFacts = graph.Provenance.Inputs.RuntimeFacts,
|
||||
Policy = graph.Provenance.Inputs.Policy
|
||||
},
|
||||
ComputedAt = graph.Provenance.ComputedAt,
|
||||
Analyzer = new ReachGraphAnalyzerDto
|
||||
{
|
||||
Name = graph.Provenance.Analyzer.Name,
|
||||
Version = graph.Provenance.Analyzer.Version,
|
||||
ToolchainDigest = graph.Provenance.Analyzer.ToolchainDigest
|
||||
}
|
||||
},
|
||||
Signatures = sortedSignatures
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert DTO back to domain record.
|
||||
/// </summary>
|
||||
private static ReachGraphMinimal FromDto(ReachGraphMinimalDto dto)
|
||||
{
|
||||
return new ReachGraphMinimal
|
||||
{
|
||||
SchemaVersion = dto.SchemaVersion,
|
||||
Artifact = new ReachGraphArtifact(
|
||||
dto.Artifact.Name,
|
||||
dto.Artifact.Digest,
|
||||
[.. dto.Artifact.Env]
|
||||
),
|
||||
Scope = new ReachGraphScope(
|
||||
[.. dto.Scope.Entrypoints],
|
||||
[.. dto.Scope.Selectors],
|
||||
dto.Scope.Cves is { Count: > 0 } ? [.. dto.Scope.Cves] : null
|
||||
),
|
||||
Nodes = [.. dto.Nodes.Select(n => new ReachGraphNode
|
||||
{
|
||||
Id = n.Id,
|
||||
Kind = n.Kind,
|
||||
Ref = n.Ref,
|
||||
File = n.File,
|
||||
Line = n.Line,
|
||||
ModuleHash = n.ModuleHash,
|
||||
Addr = n.Addr,
|
||||
IsEntrypoint = n.IsEntrypoint,
|
||||
IsSink = n.IsSink
|
||||
})],
|
||||
Edges = [.. dto.Edges.Select(e => new ReachGraphEdge
|
||||
{
|
||||
From = e.From,
|
||||
To = e.To,
|
||||
Why = new EdgeExplanation
|
||||
{
|
||||
Type = e.Why.Type,
|
||||
Loc = e.Why.Loc,
|
||||
Guard = e.Why.Guard,
|
||||
Confidence = e.Why.Confidence,
|
||||
Metadata = e.Why.Metadata?.Count > 0
|
||||
? e.Why.Metadata.ToImmutableDictionary()
|
||||
: null
|
||||
}
|
||||
})],
|
||||
Provenance = new ReachGraphProvenance
|
||||
{
|
||||
Intoto = dto.Provenance.Intoto is { Count: > 0 } ? [.. dto.Provenance.Intoto] : null,
|
||||
Inputs = new ReachGraphInputs
|
||||
{
|
||||
Sbom = dto.Provenance.Inputs.Sbom,
|
||||
Vex = dto.Provenance.Inputs.Vex,
|
||||
Callgraph = dto.Provenance.Inputs.Callgraph,
|
||||
RuntimeFacts = dto.Provenance.Inputs.RuntimeFacts,
|
||||
Policy = dto.Provenance.Inputs.Policy
|
||||
},
|
||||
ComputedAt = dto.Provenance.ComputedAt,
|
||||
Analyzer = new ReachGraphAnalyzer(
|
||||
dto.Provenance.Analyzer.Name,
|
||||
dto.Provenance.Analyzer.Version,
|
||||
dto.Provenance.Analyzer.ToolchainDigest
|
||||
)
|
||||
},
|
||||
Signatures = dto.Signatures is { Count: > 0 }
|
||||
? [.. dto.Signatures.Select(s => new ReachGraphSignature(s.KeyId, s.Sig))]
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
#region DTOs for canonical serialization (alphabetically ordered properties)
|
||||
|
||||
private sealed class ReachGraphMinimalDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string SchemaVersion { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required ReachGraphArtifactDto Artifact { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required ReachGraphScopeDto Scope { get; init; }
|
||||
|
||||
[JsonPropertyOrder(4)]
|
||||
public required List<ReachGraphNodeDto> Nodes { get; init; }
|
||||
|
||||
[JsonPropertyOrder(5)]
|
||||
public required List<ReachGraphEdgeDto> Edges { get; init; }
|
||||
|
||||
[JsonPropertyOrder(6)]
|
||||
public required ReachGraphProvenanceDto Provenance { get; init; }
|
||||
|
||||
[JsonPropertyOrder(7)]
|
||||
public List<ReachGraphSignatureDto>? Signatures { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphArtifactDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required List<string> Env { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphScopeDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required List<string> Entrypoints { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required List<string> Selectors { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public List<string>? Cves { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphNodeDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required ReachGraphNodeKind Kind { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required string Ref { get; init; }
|
||||
|
||||
[JsonPropertyOrder(4)]
|
||||
public string? File { get; init; }
|
||||
|
||||
[JsonPropertyOrder(5)]
|
||||
public int? Line { get; init; }
|
||||
|
||||
[JsonPropertyOrder(6)]
|
||||
public string? ModuleHash { get; init; }
|
||||
|
||||
[JsonPropertyOrder(7)]
|
||||
public string? Addr { get; init; }
|
||||
|
||||
[JsonPropertyOrder(8)]
|
||||
public bool? IsEntrypoint { get; init; }
|
||||
|
||||
[JsonPropertyOrder(9)]
|
||||
public bool? IsSink { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphEdgeDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string From { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required string To { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required EdgeExplanationDto Why { get; init; }
|
||||
}
|
||||
|
||||
private sealed class EdgeExplanationDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required EdgeExplanationType Type { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public string? Loc { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public string? Guard { get; init; }
|
||||
|
||||
[JsonPropertyOrder(4)]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
[JsonPropertyOrder(5)]
|
||||
public SortedDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphProvenanceDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public List<string>? Intoto { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required ReachGraphInputsDto Inputs { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required DateTimeOffset ComputedAt { get; init; }
|
||||
|
||||
[JsonPropertyOrder(4)]
|
||||
public required ReachGraphAnalyzerDto Analyzer { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphInputsDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string Sbom { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public string? Vex { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public string? Callgraph { get; init; }
|
||||
|
||||
[JsonPropertyOrder(4)]
|
||||
public string? RuntimeFacts { get; init; }
|
||||
|
||||
[JsonPropertyOrder(5)]
|
||||
public string? Policy { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphAnalyzerDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string Name { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required string Version { get; init; }
|
||||
|
||||
[JsonPropertyOrder(3)]
|
||||
public required string ToolchainDigest { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ReachGraphSignatureDto
|
||||
{
|
||||
[JsonPropertyOrder(1)]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
[JsonPropertyOrder(2)]
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON converter for ISO-8601 timestamps with millisecond precision.
|
||||
/// </summary>
|
||||
internal sealed class Iso8601MillisecondConverter : JsonConverter<DateTimeOffset>
|
||||
{
|
||||
private const string Format = "yyyy-MM-ddTHH:mm:ss.fffZ";
|
||||
|
||||
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var str = reader.GetString() ?? throw new JsonException("Expected string value for DateTimeOffset");
|
||||
return DateTimeOffset.Parse(str, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
|
||||
{
|
||||
// Always output in UTC with millisecond precision
|
||||
writer.WriteStringValue(value.UtcDateTime.ToString(Format, System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.ReachGraph.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// JSON converter for ISO-8601 timestamps with millisecond precision.
|
||||
/// </summary>
|
||||
internal sealed class Iso8601MillisecondConverter : JsonConverter<DateTimeOffset>
|
||||
{
|
||||
private const string Format = "yyyy-MM-ddTHH:mm:ss.fffZ";
|
||||
|
||||
public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var str = reader.GetString() ?? throw new JsonException("Expected string value for DateTimeOffset");
|
||||
return DateTimeOffset.Parse(str, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStringValue(value.UtcDateTime.ToString(Format, CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.Signing;
|
||||
|
||||
public sealed partial class ReachGraphSignerService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> CreateDsseEnvelopeAsync(
|
||||
ReachGraphMinimal graph,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
ArgumentException.ThrowIfNullOrEmpty(keyId);
|
||||
|
||||
var signedGraph = await SignAsync(graph, keyId, cancellationToken).ConfigureAwait(false);
|
||||
var canonicalBytes = _serializer.SerializeMinimal(signedGraph);
|
||||
|
||||
return BuildDsseEnvelopeJson(canonicalBytes, signedGraph.Signatures ?? []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.ReachGraph.Signing;
|
||||
|
||||
public sealed partial class ReachGraphSignerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute DSSE Pre-Authentication Encoding (PAE).
|
||||
/// PAE(type, payload) = "DSSEv1" || len(type) || type || len(payload) || payload
|
||||
/// </summary>
|
||||
private static byte[] ComputePae(string payloadType, byte[] payload)
|
||||
{
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
var prefix = Encoding.UTF8.GetBytes("DSSEv1 ");
|
||||
|
||||
var result = new byte[prefix.Length + 8 + typeBytes.Length + 8 + payload.Length];
|
||||
var offset = 0;
|
||||
|
||||
Buffer.BlockCopy(prefix, 0, result, offset, prefix.Length);
|
||||
offset += prefix.Length;
|
||||
|
||||
BitConverter.TryWriteBytes(result.AsSpan(offset, 8), (long)typeBytes.Length);
|
||||
offset += 8;
|
||||
|
||||
Buffer.BlockCopy(typeBytes, 0, result, offset, typeBytes.Length);
|
||||
offset += typeBytes.Length;
|
||||
|
||||
BitConverter.TryWriteBytes(result.AsSpan(offset, 8), (long)payload.Length);
|
||||
offset += 8;
|
||||
|
||||
Buffer.BlockCopy(payload, 0, result, offset, payload.Length);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a DSSE envelope JSON document.
|
||||
/// </summary>
|
||||
private static byte[] BuildDsseEnvelopeJson(
|
||||
byte[] payload,
|
||||
ImmutableArray<ReachGraphSignature> signatures)
|
||||
{
|
||||
var payloadBase64 = Convert.ToBase64String(payload);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new Utf8JsonWriter(ms);
|
||||
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("payloadType", PayloadType);
|
||||
writer.WriteString("payload", payloadBase64);
|
||||
writer.WritePropertyName("signatures");
|
||||
writer.WriteStartArray();
|
||||
|
||||
foreach (var sig in signatures.OrderBy(s => s.KeyId, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("keyid", sig.KeyId);
|
||||
writer.WriteString("sig", sig.Sig);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
writer.Flush();
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.Signing;
|
||||
|
||||
public sealed partial class ReachGraphSignerService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<ReachGraphMinimal> SignAsync(
|
||||
ReachGraphMinimal graph,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
ArgumentException.ThrowIfNullOrEmpty(keyId);
|
||||
|
||||
_logger.LogDebug("Signing reachability graph with key {KeyId}", keyId);
|
||||
|
||||
var unsigned = graph with { Signatures = null };
|
||||
var canonicalBytes = _serializer.SerializeMinimal(unsigned);
|
||||
|
||||
var pae = ComputePae(PayloadType, canonicalBytes);
|
||||
|
||||
var signatureBytes = await _keyStore
|
||||
.SignAsync(keyId, pae, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var signatureBase64 = Convert.ToBase64String(signatureBytes);
|
||||
|
||||
var newSignature = new ReachGraphSignature(keyId, signatureBase64);
|
||||
|
||||
var existingSignatures = graph.Signatures ?? [];
|
||||
var allSignatures = existingSignatures.Add(newSignature);
|
||||
allSignatures = [.. allSignatures.OrderBy(s => s.KeyId, StringComparer.Ordinal)];
|
||||
|
||||
_logger.LogInformation(
|
||||
"Signed reachability graph with key {KeyId}, digest {Digest}",
|
||||
keyId, _digestComputer.ComputeDigest(unsigned));
|
||||
|
||||
return graph with { Signatures = allSignatures };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.Signing;
|
||||
|
||||
public sealed partial class ReachGraphSignerService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<ReachGraphVerificationResult> VerifyAsync(
|
||||
ReachGraphMinimal graph,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
if (graph.Signatures is null or { Length: 0 })
|
||||
{
|
||||
return ReachGraphVerificationResult.Failure([], [], "No signatures to verify");
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Verifying {Count} signature(s) on reachability graph",
|
||||
graph.Signatures.Value.Length);
|
||||
|
||||
var unsigned = graph with { Signatures = null };
|
||||
var canonicalBytes = _serializer.SerializeMinimal(unsigned);
|
||||
var pae = ComputePae(PayloadType, canonicalBytes);
|
||||
|
||||
var validKeyIds = new List<string>();
|
||||
var invalidKeyIds = new List<string>();
|
||||
|
||||
foreach (var signature in graph.Signatures.Value)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var signatureBytes = Convert.FromBase64String(signature.Sig);
|
||||
var isValid = await _keyStore
|
||||
.VerifyAsync(signature.KeyId, pae, signatureBytes, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (isValid)
|
||||
{
|
||||
validKeyIds.Add(signature.KeyId);
|
||||
}
|
||||
else
|
||||
{
|
||||
invalidKeyIds.Add(signature.KeyId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or ArgumentException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid signature format for key {KeyId}", signature.KeyId);
|
||||
invalidKeyIds.Add(signature.KeyId);
|
||||
}
|
||||
}
|
||||
|
||||
var isAllValid = invalidKeyIds.Count == 0 && validKeyIds.Count > 0;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Verification result: {Valid} valid, {Invalid} invalid signatures",
|
||||
validKeyIds.Count, invalidKeyIds.Count);
|
||||
|
||||
return isAllValid
|
||||
? ReachGraphVerificationResult.Success([.. validKeyIds])
|
||||
: ReachGraphVerificationResult.Failure(
|
||||
[.. validKeyIds],
|
||||
[.. invalidKeyIds],
|
||||
invalidKeyIds.Count > 0
|
||||
? $"{invalidKeyIds.Count} signature(s) failed verification"
|
||||
: null);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ReachGraph.Hashing;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using StellaOps.ReachGraph.Serialization;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.ReachGraph.Signing;
|
||||
|
||||
@@ -14,10 +9,9 @@ namespace StellaOps.ReachGraph.Signing;
|
||||
/// DSSE-based signing service for reachability graphs.
|
||||
/// Wraps the Attestor envelope signing infrastructure.
|
||||
/// </summary>
|
||||
public sealed class ReachGraphSignerService : IReachGraphSignerService
|
||||
public sealed partial class ReachGraphSignerService : IReachGraphSignerService
|
||||
{
|
||||
private const string PayloadType = "application/vnd.stellaops.reachgraph.min+json";
|
||||
|
||||
private readonly IReachGraphKeyStore _keyStore;
|
||||
private readonly CanonicalReachGraphSerializer _serializer;
|
||||
private readonly ReachGraphDigestComputer _digestComputer;
|
||||
@@ -34,191 +28,4 @@ public sealed class ReachGraphSignerService : IReachGraphSignerService
|
||||
_digestComputer = digestComputer ?? throw new ArgumentNullException(nameof(digestComputer));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ReachGraphMinimal> SignAsync(
|
||||
ReachGraphMinimal graph,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
ArgumentException.ThrowIfNullOrEmpty(keyId);
|
||||
|
||||
_logger.LogDebug("Signing reachability graph with key {KeyId}", keyId);
|
||||
|
||||
// Get canonical JSON (without existing signatures)
|
||||
var unsigned = graph with { Signatures = null };
|
||||
var canonicalBytes = _serializer.SerializeMinimal(unsigned);
|
||||
|
||||
// Compute PAE (Pre-Authentication Encoding) for DSSE
|
||||
var pae = ComputePae(PayloadType, canonicalBytes);
|
||||
|
||||
// Sign with the key
|
||||
var signatureBytes = await _keyStore.SignAsync(keyId, pae, cancellationToken);
|
||||
var signatureBase64 = Convert.ToBase64String(signatureBytes);
|
||||
|
||||
// Create new signature entry
|
||||
var newSignature = new ReachGraphSignature(keyId, signatureBase64);
|
||||
|
||||
// Append to existing signatures (if any) and return
|
||||
var existingSignatures = graph.Signatures ?? [];
|
||||
var allSignatures = existingSignatures.Add(newSignature);
|
||||
|
||||
// Sort signatures by KeyId for determinism
|
||||
allSignatures = [.. allSignatures.OrderBy(s => s.KeyId, StringComparer.Ordinal)];
|
||||
|
||||
_logger.LogInformation(
|
||||
"Signed reachability graph with key {KeyId}, digest {Digest}",
|
||||
keyId, _digestComputer.ComputeDigest(unsigned));
|
||||
|
||||
return graph with { Signatures = allSignatures };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ReachGraphVerificationResult> VerifyAsync(
|
||||
ReachGraphMinimal graph,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
if (graph.Signatures is null or { Length: 0 })
|
||||
{
|
||||
return ReachGraphVerificationResult.Failure([], [], "No signatures to verify");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Verifying {Count} signature(s) on reachability graph", graph.Signatures.Value.Length);
|
||||
|
||||
// Get canonical JSON (without signatures)
|
||||
var unsigned = graph with { Signatures = null };
|
||||
var canonicalBytes = _serializer.SerializeMinimal(unsigned);
|
||||
|
||||
// Compute PAE
|
||||
var pae = ComputePae(PayloadType, canonicalBytes);
|
||||
|
||||
var validKeyIds = new List<string>();
|
||||
var invalidKeyIds = new List<string>();
|
||||
|
||||
foreach (var signature in graph.Signatures.Value)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var signatureBytes = Convert.FromBase64String(signature.Sig);
|
||||
var isValid = await _keyStore.VerifyAsync(signature.KeyId, pae, signatureBytes, cancellationToken);
|
||||
|
||||
if (isValid)
|
||||
{
|
||||
validKeyIds.Add(signature.KeyId);
|
||||
}
|
||||
else
|
||||
{
|
||||
invalidKeyIds.Add(signature.KeyId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or ArgumentException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid signature format for key {KeyId}", signature.KeyId);
|
||||
invalidKeyIds.Add(signature.KeyId);
|
||||
}
|
||||
}
|
||||
|
||||
var isAllValid = invalidKeyIds.Count == 0 && validKeyIds.Count > 0;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Verification result: {Valid} valid, {Invalid} invalid signatures",
|
||||
validKeyIds.Count, invalidKeyIds.Count);
|
||||
|
||||
return isAllValid
|
||||
? ReachGraphVerificationResult.Success([.. validKeyIds])
|
||||
: ReachGraphVerificationResult.Failure(
|
||||
[.. validKeyIds],
|
||||
[.. invalidKeyIds],
|
||||
invalidKeyIds.Count > 0 ? $"{invalidKeyIds.Count} signature(s) failed verification" : null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]> CreateDsseEnvelopeAsync(
|
||||
ReachGraphMinimal graph,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
ArgumentException.ThrowIfNullOrEmpty(keyId);
|
||||
|
||||
// Sign the graph first
|
||||
var signedGraph = await SignAsync(graph, keyId, cancellationToken);
|
||||
|
||||
// Get canonical JSON for the signed graph
|
||||
var canonicalBytes = _serializer.SerializeMinimal(signedGraph);
|
||||
|
||||
// Build DSSE envelope JSON
|
||||
return BuildDsseEnvelopeJson(canonicalBytes, signedGraph.Signatures ?? []);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute DSSE Pre-Authentication Encoding (PAE).
|
||||
/// PAE(type, payload) = "DSSEv1" || len(type) || type || len(payload) || payload
|
||||
/// </summary>
|
||||
private static byte[] ComputePae(string payloadType, byte[] payload)
|
||||
{
|
||||
// PAE format: "DSSEv1" + 8-byte LE length of type + type bytes + 8-byte LE length of payload + payload
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
var prefix = Encoding.UTF8.GetBytes("DSSEv1 ");
|
||||
|
||||
var result = new byte[prefix.Length + 8 + typeBytes.Length + 8 + payload.Length];
|
||||
var offset = 0;
|
||||
|
||||
// Copy "DSSEv1 "
|
||||
Buffer.BlockCopy(prefix, 0, result, offset, prefix.Length);
|
||||
offset += prefix.Length;
|
||||
|
||||
// Write type length as 8-byte LE
|
||||
BitConverter.TryWriteBytes(result.AsSpan(offset, 8), (long)typeBytes.Length);
|
||||
offset += 8;
|
||||
|
||||
// Copy type bytes
|
||||
Buffer.BlockCopy(typeBytes, 0, result, offset, typeBytes.Length);
|
||||
offset += typeBytes.Length;
|
||||
|
||||
// Write payload length as 8-byte LE
|
||||
BitConverter.TryWriteBytes(result.AsSpan(offset, 8), (long)payload.Length);
|
||||
offset += 8;
|
||||
|
||||
// Copy payload
|
||||
Buffer.BlockCopy(payload, 0, result, offset, payload.Length);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a DSSE envelope JSON document.
|
||||
/// </summary>
|
||||
private static byte[] BuildDsseEnvelopeJson(byte[] payload, ImmutableArray<ReachGraphSignature> signatures)
|
||||
{
|
||||
var payloadBase64 = Convert.ToBase64String(payload);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new System.Text.Json.Utf8JsonWriter(ms);
|
||||
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("payloadType", PayloadType);
|
||||
writer.WriteString("payload", payloadBase64);
|
||||
writer.WritePropertyName("signatures");
|
||||
writer.WriteStartArray();
|
||||
|
||||
foreach (var sig in signatures.OrderBy(s => s.KeyId, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("keyid", sig.KeyId);
|
||||
writer.WriteString("sig", sig.Sig);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
writer.Flush();
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0105-T | DONE | Revalidated 2026-01-08; test coverage audit for ReachGraph core. |
|
||||
| AUDIT-0105-A | TODO | Pending approval (revalidated 2026-01-08). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-08 | DONE | Split dedup, hashing, serialization, and signing files to <=100 lines, added ConfigureAwait(false) in signing, added dedup/semantic key unit tests; `dotnet test src/__Libraries/__Tests/StellaOps.ReachGraph.Tests/StellaOps.ReachGraph.Tests.csproj` passed 2026-02-03 (MTP0001 warning). |
|
||||
|
||||
Reference in New Issue
Block a user