Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -0,0 +1,462 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Collections.Immutable;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.ReachGraph.Schema;
namespace StellaOps.ReachGraph.Serialization;
/// <summary>
/// Serializer for canonical (deterministic) ReachGraph JSON.
/// Guarantees:
/// - Lexicographically sorted object keys
/// - Arrays sorted by deterministic field (Nodes by Id, Edges by From/To)
/// - UTC ISO-8601 timestamps with millisecond precision
/// - Null fields omitted
/// - Minified output for storage, prettified for debugging
/// </summary>
public sealed class CanonicalReachGraphSerializer
{
private readonly JsonSerializerOptions _minifiedOptions;
private readonly JsonSerializerOptions _prettyOptions;
public CanonicalReachGraphSerializer()
{
_minifiedOptions = CreateSerializerOptions(writeIndented: false);
_prettyOptions = CreateSerializerOptions(writeIndented: true);
}
/// <summary>
/// Serialize to canonical minified JSON bytes.
/// </summary>
public byte[] SerializeMinimal(ReachGraphMinimal graph)
{
ArgumentNullException.ThrowIfNull(graph);
var canonical = Canonicalize(graph);
return JsonSerializer.SerializeToUtf8Bytes(canonical, _minifiedOptions);
}
/// <summary>
/// Serialize to canonical prettified JSON for debugging.
/// </summary>
public string SerializePretty(ReachGraphMinimal graph)
{
ArgumentNullException.ThrowIfNull(graph);
var canonical = Canonicalize(graph);
return JsonSerializer.Serialize(canonical, _prettyOptions);
}
/// <summary>
/// Deserialize from JSON bytes.
/// </summary>
public ReachGraphMinimal Deserialize(ReadOnlySpan<byte> json)
{
var dto = JsonSerializer.Deserialize<ReachGraphMinimalDto>(json, _minifiedOptions)
?? throw new JsonException("Failed to deserialize ReachGraphMinimal");
return FromDto(dto);
}
/// <summary>
/// Deserialize from JSON string.
/// </summary>
public ReachGraphMinimal Deserialize(string json)
{
ArgumentException.ThrowIfNullOrEmpty(json);
var dto = JsonSerializer.Deserialize<ReachGraphMinimalDto>(json, _minifiedOptions)
?? throw new JsonException("Failed to deserialize ReachGraphMinimal");
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));
}
}