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
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:
@@ -0,0 +1,417 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum edges per bundle per CONTRACT-EDGE-BUNDLE-401.
|
||||
/// </summary>
|
||||
public static class EdgeBundleConstants
|
||||
{
|
||||
public const int MaxEdgesPerBundle = 512;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reason for bundling a specific set of edges.
|
||||
/// </summary>
|
||||
public enum EdgeBundleReason
|
||||
{
|
||||
/// <summary>Edges with runtime hit evidence.</summary>
|
||||
RuntimeHits,
|
||||
|
||||
/// <summary>Edges from init-array/TLS initializers.</summary>
|
||||
InitArray,
|
||||
|
||||
/// <summary>Edges from static constructors.</summary>
|
||||
StaticInit,
|
||||
|
||||
/// <summary>Edges to third-party dependencies.</summary>
|
||||
ThirdParty,
|
||||
|
||||
/// <summary>Edges with contested reachability (low confidence).</summary>
|
||||
Contested,
|
||||
|
||||
/// <summary>Edges marked as revoked/patched.</summary>
|
||||
Revoked,
|
||||
|
||||
/// <summary>Custom bundle reason.</summary>
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-edge reason for inclusion in an edge bundle.
|
||||
/// </summary>
|
||||
public enum EdgeReason
|
||||
{
|
||||
/// <summary>Edge was executed at runtime (observed).</summary>
|
||||
RuntimeHit,
|
||||
|
||||
/// <summary>Edge is from init-array/DT_INIT.</summary>
|
||||
InitArray,
|
||||
|
||||
/// <summary>Edge is from TLS init.</summary>
|
||||
TlsInit,
|
||||
|
||||
/// <summary>Edge is from static constructor.</summary>
|
||||
StaticConstructor,
|
||||
|
||||
/// <summary>Edge is from module initializer.</summary>
|
||||
ModuleInit,
|
||||
|
||||
/// <summary>Edge targets a third-party dependency.</summary>
|
||||
ThirdPartyCall,
|
||||
|
||||
/// <summary>Edge has low/uncertain confidence.</summary>
|
||||
LowConfidence,
|
||||
|
||||
/// <summary>Edge was patched/revoked and is no longer reachable.</summary>
|
||||
Revoked,
|
||||
|
||||
/// <summary>Edge exists but target was removed.</summary>
|
||||
TargetRemoved,
|
||||
|
||||
/// <summary>Unknown reason.</summary>
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An edge within an edge bundle with per-edge metadata.
|
||||
/// </summary>
|
||||
public sealed record BundledEdge(
|
||||
string From,
|
||||
string To,
|
||||
string Kind,
|
||||
EdgeReason Reason,
|
||||
bool Revoked,
|
||||
double Confidence,
|
||||
string? Purl,
|
||||
string? SymbolDigest,
|
||||
string? Evidence)
|
||||
{
|
||||
public BundledEdge Trimmed()
|
||||
{
|
||||
return this with
|
||||
{
|
||||
From = From.Trim(),
|
||||
To = To.Trim(),
|
||||
Kind = string.IsNullOrWhiteSpace(Kind) ? "call" : Kind.Trim(),
|
||||
Purl = string.IsNullOrWhiteSpace(Purl) ? null : Purl.Trim(),
|
||||
SymbolDigest = string.IsNullOrWhiteSpace(SymbolDigest) ? null : SymbolDigest.Trim(),
|
||||
Evidence = string.IsNullOrWhiteSpace(Evidence) ? null : Evidence.Trim(),
|
||||
Confidence = Math.Min(1.0, Math.Max(0.0, Confidence))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bundle of edges for targeted DSSE attestation.
|
||||
/// </summary>
|
||||
public sealed record EdgeBundle(
|
||||
string BundleId,
|
||||
string GraphHash,
|
||||
EdgeBundleReason BundleReason,
|
||||
IReadOnlyList<BundledEdge> Edges,
|
||||
DateTimeOffset GeneratedAt,
|
||||
string? CustomReason = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes a canonical, sorted edge bundle for hashing.
|
||||
/// </summary>
|
||||
public EdgeBundle Canonical()
|
||||
{
|
||||
var sortedEdges = (Edges ?? Array.Empty<BundledEdge>())
|
||||
.Select(e => e.Trimmed())
|
||||
.OrderBy(e => e.From, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.To, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Kind, StringComparer.Ordinal)
|
||||
.ThenBy(e => (int)e.Reason)
|
||||
.ToImmutableList();
|
||||
|
||||
return this with { Edges = sortedEdges };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the bundle content hash (SHA-256) from canonical form.
|
||||
/// </summary>
|
||||
public string ComputeContentHash()
|
||||
{
|
||||
var canonical = Canonical();
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(canonical.GraphHash);
|
||||
sb.Append(':');
|
||||
sb.Append(canonical.BundleReason);
|
||||
sb.Append(':');
|
||||
|
||||
foreach (var edge in canonical.Edges)
|
||||
{
|
||||
sb.Append(edge.From);
|
||||
sb.Append('>');
|
||||
sb.Append(edge.To);
|
||||
sb.Append(':');
|
||||
sb.Append(edge.Kind);
|
||||
sb.Append(':');
|
||||
sb.Append((int)edge.Reason);
|
||||
sb.Append(':');
|
||||
sb.Append(edge.Revoked ? '1' : '0');
|
||||
sb.Append(';');
|
||||
}
|
||||
|
||||
var data = Encoding.UTF8.GetBytes(sb.ToString());
|
||||
var hash = SHA256.HashData(data);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for creating edge bundles from a rich graph.
|
||||
/// </summary>
|
||||
public sealed class EdgeBundleBuilder
|
||||
{
|
||||
private readonly string _graphHash;
|
||||
private readonly List<BundledEdge> _edges = new();
|
||||
private EdgeBundleReason _bundleReason = EdgeBundleReason.Custom;
|
||||
private string? _customReason;
|
||||
|
||||
public EdgeBundleBuilder(string graphHash)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(graphHash);
|
||||
_graphHash = graphHash;
|
||||
}
|
||||
|
||||
public EdgeBundleBuilder WithReason(EdgeBundleReason reason, string? customReason = null)
|
||||
{
|
||||
_bundleReason = reason;
|
||||
_customReason = reason == EdgeBundleReason.Custom ? customReason : null;
|
||||
return this;
|
||||
}
|
||||
|
||||
public EdgeBundleBuilder AddEdge(RichGraphEdge edge, EdgeReason reason, bool revoked = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(edge);
|
||||
|
||||
if (_edges.Count >= EdgeBundleConstants.MaxEdgesPerBundle)
|
||||
{
|
||||
throw new InvalidOperationException($"Edge bundle cannot exceed {EdgeBundleConstants.MaxEdgesPerBundle} edges");
|
||||
}
|
||||
|
||||
_edges.Add(new BundledEdge(
|
||||
From: edge.From,
|
||||
To: edge.To,
|
||||
Kind: edge.Kind,
|
||||
Reason: reason,
|
||||
Revoked: revoked,
|
||||
Confidence: edge.Confidence,
|
||||
Purl: edge.Purl,
|
||||
SymbolDigest: edge.SymbolDigest,
|
||||
Evidence: edge.Evidence?.FirstOrDefault()));
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public EdgeBundleBuilder AddEdge(BundledEdge edge)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(edge);
|
||||
|
||||
if (_edges.Count >= EdgeBundleConstants.MaxEdgesPerBundle)
|
||||
{
|
||||
throw new InvalidOperationException($"Edge bundle cannot exceed {EdgeBundleConstants.MaxEdgesPerBundle} edges");
|
||||
}
|
||||
|
||||
_edges.Add(edge);
|
||||
return this;
|
||||
}
|
||||
|
||||
public EdgeBundle Build()
|
||||
{
|
||||
var canonical = _edges
|
||||
.Select(e => e.Trimmed())
|
||||
.OrderBy(e => e.From, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.To, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Kind, StringComparer.Ordinal)
|
||||
.ToImmutableList();
|
||||
|
||||
var bundleId = ComputeBundleId(canonical);
|
||||
|
||||
return new EdgeBundle(
|
||||
BundleId: bundleId,
|
||||
GraphHash: _graphHash,
|
||||
BundleReason: _bundleReason,
|
||||
Edges: canonical,
|
||||
GeneratedAt: DateTimeOffset.UtcNow,
|
||||
CustomReason: _customReason);
|
||||
}
|
||||
|
||||
private string ComputeBundleId(IReadOnlyList<BundledEdge> edges)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(_graphHash);
|
||||
sb.Append(':');
|
||||
sb.Append(_bundleReason);
|
||||
sb.Append(':');
|
||||
|
||||
foreach (var edge in edges.Take(10)) // Use first 10 edges for ID derivation
|
||||
{
|
||||
sb.Append(edge.From);
|
||||
sb.Append(edge.To);
|
||||
}
|
||||
|
||||
var data = Encoding.UTF8.GetBytes(sb.ToString());
|
||||
var hash = SHA256.HashData(data);
|
||||
return $"bundle:{Convert.ToHexString(hash[..8]).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts edge bundles from a rich graph by reason category.
|
||||
/// </summary>
|
||||
public static class EdgeBundleExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts edges that match init-array/static init patterns.
|
||||
/// </summary>
|
||||
public static EdgeBundle? ExtractInitArrayBundle(RichGraph graph, string graphHash, IReadOnlySet<string>? initRootTargets = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
var builder = new EdgeBundleBuilder(graphHash).WithReason(EdgeBundleReason.InitArray);
|
||||
var initTargets = initRootTargets ?? graph.Roots
|
||||
.Where(r => r.Phase is "load" or "init" or "preinit")
|
||||
.Select(r => r.Id)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var count = 0;
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
if (count >= EdgeBundleConstants.MaxEdgesPerBundle)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (initTargets.Contains(edge.From))
|
||||
{
|
||||
var reason = edge.Kind.Contains("init", StringComparison.OrdinalIgnoreCase)
|
||||
? EdgeReason.InitArray
|
||||
: EdgeReason.StaticConstructor;
|
||||
builder.AddEdge(edge, reason);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count > 0 ? builder.Build() : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts edges targeting third-party dependencies (by purl).
|
||||
/// </summary>
|
||||
public static EdgeBundle? ExtractThirdPartyBundle(RichGraph graph, string graphHash, IReadOnlySet<string>? firstPartyPurls = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
var builder = new EdgeBundleBuilder(graphHash).WithReason(EdgeBundleReason.ThirdParty);
|
||||
var firstParty = firstPartyPurls ?? new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
var count = 0;
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
if (count >= EdgeBundleConstants.MaxEdgesPerBundle)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(edge.Purl) &&
|
||||
!firstParty.Contains(edge.Purl) &&
|
||||
!edge.Purl.StartsWith("pkg:unknown", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
builder.AddEdge(edge, EdgeReason.ThirdPartyCall);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count > 0 ? builder.Build() : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts edges with low confidence (contested reachability).
|
||||
/// </summary>
|
||||
public static EdgeBundle? ExtractContestedBundle(RichGraph graph, string graphHash, double confidenceThreshold = 0.5)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
var builder = new EdgeBundleBuilder(graphHash).WithReason(EdgeBundleReason.Contested);
|
||||
|
||||
var count = 0;
|
||||
foreach (var edge in graph.Edges.Where(e => e.Confidence < confidenceThreshold))
|
||||
{
|
||||
if (count >= EdgeBundleConstants.MaxEdgesPerBundle)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
builder.AddEdge(edge, EdgeReason.LowConfidence);
|
||||
count++;
|
||||
}
|
||||
|
||||
return count > 0 ? builder.Build() : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts revoked edges (patched/removed targets).
|
||||
/// </summary>
|
||||
public static EdgeBundle? ExtractRevokedBundle(RichGraph graph, string graphHash, IReadOnlySet<string> revokedTargets)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
ArgumentNullException.ThrowIfNull(revokedTargets);
|
||||
|
||||
var builder = new EdgeBundleBuilder(graphHash).WithReason(EdgeBundleReason.Revoked);
|
||||
|
||||
var count = 0;
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
if (count >= EdgeBundleConstants.MaxEdgesPerBundle)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (revokedTargets.Contains(edge.To))
|
||||
{
|
||||
builder.AddEdge(edge, EdgeReason.Revoked, revoked: true);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count > 0 ? builder.Build() : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts edges with runtime hit evidence.
|
||||
/// </summary>
|
||||
public static EdgeBundle? ExtractRuntimeHitsBundle(IReadOnlyList<BundledEdge> runtimeHitEdges, string graphHash)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(runtimeHitEdges);
|
||||
|
||||
if (runtimeHitEdges.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = new EdgeBundleBuilder(graphHash).WithReason(EdgeBundleReason.RuntimeHits);
|
||||
|
||||
var count = 0;
|
||||
foreach (var edge in runtimeHitEdges)
|
||||
{
|
||||
if (count >= EdgeBundleConstants.MaxEdgesPerBundle)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
builder.AddEdge(edge with { Reason = EdgeReason.RuntimeHit });
|
||||
count++;
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,264 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
/// <summary>
|
||||
/// Semantic attribute keys for richgraph-v1 nodes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part of Sprint 0411 - Semantic Entrypoint Engine (Task 19).
|
||||
/// These attributes extend RichGraphNode to include semantic analysis data.
|
||||
/// </remarks>
|
||||
public static class RichGraphSemanticAttributes
|
||||
{
|
||||
/// <summary>Application intent (WebServer, Worker, CliTool, etc.).</summary>
|
||||
public const string Intent = "semantic_intent";
|
||||
|
||||
/// <summary>Comma-separated capability flags.</summary>
|
||||
public const string Capabilities = "semantic_capabilities";
|
||||
|
||||
/// <summary>Threat vector types (comma-separated).</summary>
|
||||
public const string ThreatVectors = "semantic_threats";
|
||||
|
||||
/// <summary>Risk score (0.0-1.0).</summary>
|
||||
public const string RiskScore = "semantic_risk_score";
|
||||
|
||||
/// <summary>Confidence score (0.0-1.0).</summary>
|
||||
public const string Confidence = "semantic_confidence";
|
||||
|
||||
/// <summary>Confidence tier (Unknown, Low, Medium, High, Definitive).</summary>
|
||||
public const string ConfidenceTier = "semantic_confidence_tier";
|
||||
|
||||
/// <summary>Framework name.</summary>
|
||||
public const string Framework = "semantic_framework";
|
||||
|
||||
/// <summary>Framework version.</summary>
|
||||
public const string FrameworkVersion = "semantic_framework_version";
|
||||
|
||||
/// <summary>Whether this is an entrypoint node.</summary>
|
||||
public const string IsEntrypoint = "is_entrypoint";
|
||||
|
||||
/// <summary>Data flow boundaries (JSON array).</summary>
|
||||
public const string DataBoundaries = "semantic_boundaries";
|
||||
|
||||
/// <summary>OWASP category if applicable.</summary>
|
||||
public const string OwaspCategory = "owasp_category";
|
||||
|
||||
/// <summary>CWE ID if applicable.</summary>
|
||||
public const string CweId = "cwe_id";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for accessing semantic data on RichGraph nodes.
|
||||
/// </summary>
|
||||
public static class RichGraphSemanticExtensions
|
||||
{
|
||||
/// <summary>Gets the application intent from node attributes.</summary>
|
||||
public static string? GetIntent(this RichGraphNode node)
|
||||
{
|
||||
return node.Attributes?.TryGetValue(RichGraphSemanticAttributes.Intent, out var value) == true ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>Gets the capabilities as a list.</summary>
|
||||
public static IReadOnlyList<string> GetCapabilities(this RichGraphNode node)
|
||||
{
|
||||
if (node.Attributes?.TryGetValue(RichGraphSemanticAttributes.Capabilities, out var value) != true ||
|
||||
string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
/// <summary>Gets the threat vectors as a list.</summary>
|
||||
public static IReadOnlyList<string> GetThreatVectors(this RichGraphNode node)
|
||||
{
|
||||
if (node.Attributes?.TryGetValue(RichGraphSemanticAttributes.ThreatVectors, out var value) != true ||
|
||||
string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
/// <summary>Gets the risk score.</summary>
|
||||
public static double? GetRiskScore(this RichGraphNode node)
|
||||
{
|
||||
if (node.Attributes?.TryGetValue(RichGraphSemanticAttributes.RiskScore, out var value) != true ||
|
||||
string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return double.TryParse(value, out var score) ? score : null;
|
||||
}
|
||||
|
||||
/// <summary>Gets the confidence score.</summary>
|
||||
public static double? GetConfidence(this RichGraphNode node)
|
||||
{
|
||||
if (node.Attributes?.TryGetValue(RichGraphSemanticAttributes.Confidence, out var value) != true ||
|
||||
string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return double.TryParse(value, out var score) ? score : null;
|
||||
}
|
||||
|
||||
/// <summary>Checks if this node is an entrypoint.</summary>
|
||||
public static bool IsEntrypoint(this RichGraphNode node)
|
||||
{
|
||||
if (node.Attributes?.TryGetValue(RichGraphSemanticAttributes.IsEntrypoint, out var value) != true ||
|
||||
string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return bool.TryParse(value, out var result) && result;
|
||||
}
|
||||
|
||||
/// <summary>Checks if node has semantic data.</summary>
|
||||
public static bool HasSemanticData(this RichGraphNode node)
|
||||
{
|
||||
return node.Attributes?.ContainsKey(RichGraphSemanticAttributes.Intent) == true ||
|
||||
node.Attributes?.ContainsKey(RichGraphSemanticAttributes.Capabilities) == true;
|
||||
}
|
||||
|
||||
/// <summary>Gets the framework name.</summary>
|
||||
public static string? GetFramework(this RichGraphNode node)
|
||||
{
|
||||
return node.Attributes?.TryGetValue(RichGraphSemanticAttributes.Framework, out var value) == true ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>Gets all entrypoint nodes from the graph.</summary>
|
||||
public static IReadOnlyList<RichGraphNode> GetEntrypointNodes(this RichGraph graph)
|
||||
{
|
||||
return graph.Nodes.Where(n => n.IsEntrypoint()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Gets all nodes with semantic data.</summary>
|
||||
public static IReadOnlyList<RichGraphNode> GetNodesWithSemantics(this RichGraph graph)
|
||||
{
|
||||
return graph.Nodes.Where(n => n.HasSemanticData()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Calculates overall risk score for the graph.</summary>
|
||||
public static double CalculateOverallRiskScore(this RichGraph graph)
|
||||
{
|
||||
var riskScores = graph.Nodes
|
||||
.Select(n => n.GetRiskScore())
|
||||
.Where(s => s.HasValue)
|
||||
.Select(s => s!.Value)
|
||||
.ToList();
|
||||
|
||||
if (riskScores.Count == 0)
|
||||
return 0.0;
|
||||
|
||||
// Use max risk score as overall
|
||||
return riskScores.Max();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for creating RichGraphNode with semantic attributes.
|
||||
/// </summary>
|
||||
public sealed class RichGraphNodeSemanticBuilder
|
||||
{
|
||||
private readonly Dictionary<string, string> _attributes = new(StringComparer.Ordinal);
|
||||
|
||||
public RichGraphNodeSemanticBuilder WithIntent(string intent)
|
||||
{
|
||||
_attributes[RichGraphSemanticAttributes.Intent] = intent;
|
||||
return this;
|
||||
}
|
||||
|
||||
public RichGraphNodeSemanticBuilder WithCapabilities(IEnumerable<string> capabilities)
|
||||
{
|
||||
_attributes[RichGraphSemanticAttributes.Capabilities] = string.Join(",", capabilities);
|
||||
return this;
|
||||
}
|
||||
|
||||
public RichGraphNodeSemanticBuilder WithThreatVectors(IEnumerable<string> threats)
|
||||
{
|
||||
_attributes[RichGraphSemanticAttributes.ThreatVectors] = string.Join(",", threats);
|
||||
return this;
|
||||
}
|
||||
|
||||
public RichGraphNodeSemanticBuilder WithRiskScore(double score)
|
||||
{
|
||||
_attributes[RichGraphSemanticAttributes.RiskScore] = score.ToString("F3");
|
||||
return this;
|
||||
}
|
||||
|
||||
public RichGraphNodeSemanticBuilder WithConfidence(double score, string tier)
|
||||
{
|
||||
_attributes[RichGraphSemanticAttributes.Confidence] = score.ToString("F3");
|
||||
_attributes[RichGraphSemanticAttributes.ConfidenceTier] = tier;
|
||||
return this;
|
||||
}
|
||||
|
||||
public RichGraphNodeSemanticBuilder WithFramework(string framework, string? version = null)
|
||||
{
|
||||
_attributes[RichGraphSemanticAttributes.Framework] = framework;
|
||||
if (version is not null)
|
||||
{
|
||||
_attributes[RichGraphSemanticAttributes.FrameworkVersion] = version;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public RichGraphNodeSemanticBuilder AsEntrypoint()
|
||||
{
|
||||
_attributes[RichGraphSemanticAttributes.IsEntrypoint] = "true";
|
||||
return this;
|
||||
}
|
||||
|
||||
public RichGraphNodeSemanticBuilder WithOwaspCategory(string category)
|
||||
{
|
||||
_attributes[RichGraphSemanticAttributes.OwaspCategory] = category;
|
||||
return this;
|
||||
}
|
||||
|
||||
public RichGraphNodeSemanticBuilder WithCweId(int cweId)
|
||||
{
|
||||
_attributes[RichGraphSemanticAttributes.CweId] = cweId.ToString();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Builds the attributes dictionary.</summary>
|
||||
public IReadOnlyDictionary<string, string> Build()
|
||||
{
|
||||
return _attributes.ToImmutableDictionary();
|
||||
}
|
||||
|
||||
/// <summary>Merges semantic attributes with existing node attributes.</summary>
|
||||
public IReadOnlyDictionary<string, string> MergeWith(IReadOnlyDictionary<string, string>? existing)
|
||||
{
|
||||
var merged = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
foreach (var pair in existing)
|
||||
{
|
||||
merged[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var pair in _attributes)
|
||||
{
|
||||
merged[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
return merged.ToImmutableDictionary();
|
||||
}
|
||||
|
||||
/// <summary>Creates a new RichGraphNode with semantic attributes.</summary>
|
||||
public RichGraphNode ApplyTo(RichGraphNode node)
|
||||
{
|
||||
return node with { Attributes = MergeWith(node.Attributes) };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user