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,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();
}
}

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);

View File

@@ -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) };
}
}