up
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

This commit is contained in:
StellaOps Bot
2025-12-13 18:08:55 +02:00
parent 6e45066e37
commit f1a39c4ce3
234 changed files with 24038 additions and 6910 deletions

View File

@@ -0,0 +1,235 @@
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);