using System; using System.IO; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using StellaOps.Scanner.Cache.Abstractions; namespace StellaOps.Scanner.Reachability; /// /// Options for edge-bundle DSSE publishing. /// public sealed record EdgeBundlePublisherOptions { /// /// Whether to publish DSSE envelopes for edge bundles. /// Default: true. /// public bool Enabled { get; init; } = true; /// /// Maximum number of edge-bundle DSSEs to publish to Rekor per graph. /// Default: 5 (capped to prevent volume spikes). /// public int MaxRekorPublishesPerGraph { get; init; } = 5; /// /// Whether to publish runtime-hit bundles. /// public bool PublishRuntimeHits { get; init; } = true; /// /// Whether to publish init-array/static-init bundles. /// public bool PublishInitArray { get; init; } = true; /// /// Whether to publish third-party edge bundles. /// public bool PublishThirdParty { get; init; } = false; /// /// Whether to publish contested (low-confidence) edge bundles. /// public bool PublishContested { get; init; } = false; /// /// Whether to publish revoked edge bundles. /// public bool PublishRevoked { get; init; } = true; /// /// Confidence threshold below which edges are considered contested. /// public double ContestedConfidenceThreshold { get; init; } = 0.5; } /// /// Result of publishing an edge bundle. /// public sealed record EdgeBundlePublishResult( string BundleId, string GraphHash, EdgeBundleReason BundleReason, string ContentHash, string RelativePath, string CasUri, string DsseRelativePath, string DsseCasUri, string DsseDigest, int EdgeCount); /// /// Interface for edge-bundle DSSE publishing. /// public interface IEdgeBundlePublisher { Task PublishAsync( EdgeBundle bundle, IFileContentAddressableStore cas, CancellationToken cancellationToken = default); } /// /// Publishes edge bundles to CAS with deterministic DSSE envelopes. /// CAS paths follow: cas://reachability/edges/{graph_hash}/{bundle_id}[.dsse] /// public sealed class EdgeBundlePublisher : IEdgeBundlePublisher { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = false, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; public async Task PublishAsync( EdgeBundle bundle, IFileContentAddressableStore cas, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(bundle); ArgumentNullException.ThrowIfNull(cas); var canonical = bundle.Canonical(); var contentHash = canonical.ComputeContentHash(); var hashDigest = ExtractHashDigest(contentHash); var graphHashDigest = ExtractHashDigest(canonical.GraphHash); // Build the bundle JSON var bundleJson = SerializeBundle(canonical); // Store bundle JSON in CAS // Path: cas://reachability/edges/{graph_hash}/{bundle_id} var bundleKey = $"edges/{graphHashDigest}/{canonical.BundleId}"; await using var bundleStream = new MemoryStream(bundleJson, writable: false); var bundleEntry = await cas.PutAsync(new FileCasPutRequest(bundleKey, bundleStream, leaveOpen: false), cancellationToken).ConfigureAwait(false); var casUri = $"cas://reachability/edges/{graphHashDigest}/{canonical.BundleId}"; // Build and store DSSE envelope var dsse = BuildDeterministicEdgeBundleDsse(canonical, casUri, contentHash); await using var dsseStream = new MemoryStream(dsse.EnvelopeJson, writable: false); var dsseKey = $"edges/{graphHashDigest}/{canonical.BundleId}.dsse"; var dsseEntry = await cas.PutAsync(new FileCasPutRequest(dsseKey, dsseStream, leaveOpen: false), cancellationToken).ConfigureAwait(false); var dsseCasUri = $"cas://reachability/edges/{graphHashDigest}/{canonical.BundleId}.dsse"; return new EdgeBundlePublishResult( BundleId: canonical.BundleId, GraphHash: canonical.GraphHash, BundleReason: canonical.BundleReason, ContentHash: contentHash, RelativePath: bundleEntry.RelativePath, CasUri: casUri, DsseRelativePath: dsseEntry.RelativePath, DsseCasUri: dsseCasUri, DsseDigest: dsse.Digest, EdgeCount: canonical.Edges.Count); } private static byte[] SerializeBundle(EdgeBundle bundle) { var payload = new { schema = "edge-bundle-v1", bundleId = bundle.BundleId, graphHash = bundle.GraphHash, bundleReason = bundle.BundleReason.ToString(), customReason = bundle.CustomReason, generatedAt = bundle.GeneratedAt.ToString("O"), edges = bundle.Edges.Select(e => new { from = e.From, to = e.To, kind = e.Kind, reason = e.Reason.ToString(), revoked = e.Revoked, confidence = e.Confidence, purl = e.Purl, symbolDigest = e.SymbolDigest, evidence = e.Evidence }).ToArray() }; return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload, JsonOptions)); } private static EdgeBundleDsse BuildDeterministicEdgeBundleDsse(EdgeBundle bundle, string casUri, string contentHash) { var predicate = new { version = "1.0", schema = "edge-bundle-v1", bundleId = bundle.BundleId, graphHash = bundle.GraphHash, bundleReason = bundle.BundleReason.ToString(), hashes = new { contentHash }, cas = new { location = casUri }, edges = new { total = bundle.Edges.Count, revoked = bundle.Edges.Count(e => e.Revoked), reasons = bundle.Edges .GroupBy(e => e.Reason) .OrderBy(g => (int)g.Key) .ToDictionary(g => g.Key.ToString(), g => g.Count()) } }; var payloadType = "application/vnd.stellaops.edgebundle.predicate+json"; var payloadBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(predicate, JsonOptions)); var signatureHex = ComputeSha256Hex(payloadBytes); var envelope = new { payloadType, payload = Base64UrlEncode(payloadBytes), signatures = new[] { new { keyid = "scanner-deterministic", sig = Base64UrlEncode(Encoding.UTF8.GetBytes(signatureHex)) } } }; var envelopeJson = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(envelope, JsonOptions)); return new EdgeBundleDsse(envelopeJson, $"sha256:{signatureHex}"); } private static string ComputeSha256Hex(ReadOnlySpan data) { Span hash = stackalloc byte[32]; SHA256.HashData(data, hash); return Convert.ToHexString(hash).ToLowerInvariant(); } private static string Base64UrlEncode(ReadOnlySpan data) { var base64 = Convert.ToBase64String(data); return base64.Replace("+", "-").Replace("/", "_").TrimEnd('='); } private static string ExtractHashDigest(string prefixedHash) { var colonIndex = prefixedHash.IndexOf(':'); return colonIndex >= 0 ? prefixedHash[(colonIndex + 1)..] : prefixedHash; } } internal sealed record EdgeBundleDsse(byte[] EnvelopeJson, string Digest);