Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
236 lines
8.0 KiB
C#
236 lines
8.0 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Options for edge-bundle DSSE publishing.
|
|
/// </summary>
|
|
public sealed record EdgeBundlePublisherOptions
|
|
{
|
|
/// <summary>
|
|
/// Whether to publish DSSE envelopes for edge bundles.
|
|
/// Default: true.
|
|
/// </summary>
|
|
public bool Enabled { get; init; } = true;
|
|
|
|
/// <summary>
|
|
/// Maximum number of edge-bundle DSSEs to publish to Rekor per graph.
|
|
/// Default: 5 (capped to prevent volume spikes).
|
|
/// </summary>
|
|
public int MaxRekorPublishesPerGraph { get; init; } = 5;
|
|
|
|
/// <summary>
|
|
/// Whether to publish runtime-hit bundles.
|
|
/// </summary>
|
|
public bool PublishRuntimeHits { get; init; } = true;
|
|
|
|
/// <summary>
|
|
/// Whether to publish init-array/static-init bundles.
|
|
/// </summary>
|
|
public bool PublishInitArray { get; init; } = true;
|
|
|
|
/// <summary>
|
|
/// Whether to publish third-party edge bundles.
|
|
/// </summary>
|
|
public bool PublishThirdParty { get; init; } = false;
|
|
|
|
/// <summary>
|
|
/// Whether to publish contested (low-confidence) edge bundles.
|
|
/// </summary>
|
|
public bool PublishContested { get; init; } = false;
|
|
|
|
/// <summary>
|
|
/// Whether to publish revoked edge bundles.
|
|
/// </summary>
|
|
public bool PublishRevoked { get; init; } = true;
|
|
|
|
/// <summary>
|
|
/// Confidence threshold below which edges are considered contested.
|
|
/// </summary>
|
|
public double ContestedConfidenceThreshold { get; init; } = 0.5;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of publishing an edge bundle.
|
|
/// </summary>
|
|
public sealed record EdgeBundlePublishResult(
|
|
string BundleId,
|
|
string GraphHash,
|
|
EdgeBundleReason BundleReason,
|
|
string ContentHash,
|
|
string RelativePath,
|
|
string CasUri,
|
|
string DsseRelativePath,
|
|
string DsseCasUri,
|
|
string DsseDigest,
|
|
int EdgeCount);
|
|
|
|
/// <summary>
|
|
/// Interface for edge-bundle DSSE publishing.
|
|
/// </summary>
|
|
public interface IEdgeBundlePublisher
|
|
{
|
|
Task<EdgeBundlePublishResult> PublishAsync(
|
|
EdgeBundle bundle,
|
|
IFileContentAddressableStore cas,
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Publishes edge bundles to CAS with deterministic DSSE envelopes.
|
|
/// CAS paths follow: cas://reachability/edges/{graph_hash}/{bundle_id}[.dsse]
|
|
/// </summary>
|
|
public sealed class EdgeBundlePublisher : IEdgeBundlePublisher
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
WriteIndented = false,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
};
|
|
|
|
public async Task<EdgeBundlePublishResult> 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<byte> data)
|
|
{
|
|
Span<byte> hash = stackalloc byte[32];
|
|
SHA256.HashData(data, hash);
|
|
return Convert.ToHexString(hash).ToLowerInvariant();
|
|
}
|
|
|
|
private static string Base64UrlEncode(ReadOnlySpan<byte> 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);
|