up
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Ordering;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical (deterministically ordered) graph representation.
|
||||
/// </summary>
|
||||
public sealed class CanonicalGraph
|
||||
{
|
||||
/// <summary>
|
||||
/// Ordering strategy used.
|
||||
/// </summary>
|
||||
public required GraphOrderingStrategy Strategy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deterministically ordered nodes.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<CanonicalNode> Nodes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Deterministically ordered edges.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<CanonicalEdge> Edges { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content hash of canonical representation.
|
||||
/// </summary>
|
||||
public required string ContentHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Anchor nodes (entry points), if present.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? AnchorNodes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional timestamp for diagnostics; excluded from <see cref="ContentHash"/>.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ComputedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CanonicalNode
|
||||
{
|
||||
/// <summary>
|
||||
/// Position in ordered list (0-indexed).
|
||||
/// </summary>
|
||||
public required int Index { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Node identifier.
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Node type (e.g. method, function).
|
||||
/// </summary>
|
||||
public required string NodeType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Node label for UI display (optional).
|
||||
/// </summary>
|
||||
public string? Label { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CanonicalEdge
|
||||
{
|
||||
public required int SourceIndex { get; init; }
|
||||
public required int TargetIndex { get; init; }
|
||||
public required string EdgeType { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Ordering;
|
||||
|
||||
public sealed class DeterministicGraphOrderer : IGraphOrderer
|
||||
{
|
||||
public IReadOnlyList<string> OrderNodes(
|
||||
RichGraph graph,
|
||||
GraphOrderingStrategy strategy = GraphOrderingStrategy.TopologicalLexicographic)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
return strategy switch
|
||||
{
|
||||
GraphOrderingStrategy.TopologicalLexicographic => TopologicalLexicographicOrder(graph),
|
||||
GraphOrderingStrategy.BreadthFirstLexicographic => BreadthFirstLexicographicOrder(graph),
|
||||
GraphOrderingStrategy.DepthFirstLexicographic => DepthFirstLexicographicOrder(graph),
|
||||
GraphOrderingStrategy.Lexicographic => LexicographicOrder(graph),
|
||||
_ => TopologicalLexicographicOrder(graph)
|
||||
};
|
||||
}
|
||||
|
||||
public IReadOnlyList<RichGraphEdge> OrderEdges(
|
||||
RichGraph graph,
|
||||
IReadOnlyList<string> nodeOrder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
ArgumentNullException.ThrowIfNull(nodeOrder);
|
||||
|
||||
var index = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < nodeOrder.Count; i++)
|
||||
{
|
||||
index[nodeOrder[i]] = i;
|
||||
}
|
||||
|
||||
return graph.Edges
|
||||
.Where(e => index.ContainsKey(e.From) && index.ContainsKey(e.To))
|
||||
.OrderBy(e => index[e.From])
|
||||
.ThenBy(e => index[e.To])
|
||||
.ThenBy(e => e.Kind, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Purl, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.SymbolDigest, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public CanonicalGraph Canonicalize(
|
||||
RichGraph graph,
|
||||
GraphOrderingStrategy strategy = GraphOrderingStrategy.TopologicalLexicographic)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
var nodeById = graph.Nodes
|
||||
.Where(n => !string.IsNullOrWhiteSpace(n.Id))
|
||||
.GroupBy(n => n.Id, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||
|
||||
var nodeOrder = OrderNodes(graph, strategy);
|
||||
var edges = OrderEdges(graph, nodeOrder);
|
||||
|
||||
var index = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < nodeOrder.Count; i++)
|
||||
{
|
||||
index[nodeOrder[i]] = i;
|
||||
}
|
||||
|
||||
var nodes = nodeOrder
|
||||
.Select(id =>
|
||||
{
|
||||
nodeById.TryGetValue(id, out var node);
|
||||
return new CanonicalNode
|
||||
{
|
||||
Index = index[id],
|
||||
Id = id,
|
||||
NodeType = node?.Kind ?? "unknown",
|
||||
Label = node?.Display
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var canonicalEdges = edges
|
||||
.Select(e => new CanonicalEdge
|
||||
{
|
||||
SourceIndex = index[e.From],
|
||||
TargetIndex = index[e.To],
|
||||
EdgeType = e.Kind
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var anchors = FindAnchorNodes(graph, nodeOrder);
|
||||
|
||||
return new CanonicalGraph
|
||||
{
|
||||
Strategy = strategy,
|
||||
Nodes = nodes,
|
||||
Edges = canonicalEdges,
|
||||
ContentHash = ComputeCanonicalHash(nodes, canonicalEdges),
|
||||
AnchorNodes = anchors
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> TopologicalLexicographicOrder(RichGraph graph)
|
||||
{
|
||||
var nodes = graph.Nodes.Select(n => n.Id).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
|
||||
nodes.Sort(StringComparer.Ordinal);
|
||||
|
||||
var adjacency = nodes.ToDictionary(n => n, _ => new List<string>(), StringComparer.Ordinal);
|
||||
var indegree = nodes.ToDictionary(n => n, _ => 0, StringComparer.Ordinal);
|
||||
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(edge.From) || string.IsNullOrWhiteSpace(edge.To))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!adjacency.TryGetValue(edge.From, out var neighbors) || !indegree.ContainsKey(edge.To))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
neighbors.Add(edge.To);
|
||||
indegree[edge.To] = indegree[edge.To] + 1;
|
||||
}
|
||||
|
||||
foreach (var list in adjacency.Values)
|
||||
{
|
||||
list.Sort(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
var ready = new SortedSet<string>(indegree.Where(kv => kv.Value == 0).Select(kv => kv.Key), StringComparer.Ordinal);
|
||||
var result = new List<string>(nodes.Count);
|
||||
|
||||
while (ready.Count > 0)
|
||||
{
|
||||
var next = ready.Min!;
|
||||
ready.Remove(next);
|
||||
result.Add(next);
|
||||
|
||||
foreach (var neighbor in adjacency[next])
|
||||
{
|
||||
indegree[neighbor] = indegree[neighbor] - 1;
|
||||
if (indegree[neighbor] == 0)
|
||||
{
|
||||
ready.Add(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.Count == nodes.Count)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(result, StringComparer.Ordinal);
|
||||
var remainder = nodes
|
||||
.Where(n => !seen.Contains(n))
|
||||
.OrderBy(n => n, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
result.AddRange(remainder);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BreadthFirstLexicographicOrder(RichGraph graph)
|
||||
{
|
||||
var ordered = new List<string>();
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
var adjacency = BuildAdjacency(graph);
|
||||
var entries = FindEntryPoints(graph).OrderBy(e => e, StringComparer.Ordinal).ToList();
|
||||
var queue = new Queue<string>(entries);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
if (!visited.Add(current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ordered.Add(current);
|
||||
|
||||
if (!adjacency.TryGetValue(current, out var neighbors))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var neighbor in neighbors)
|
||||
{
|
||||
if (!visited.Contains(neighbor))
|
||||
{
|
||||
queue.Enqueue(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append disconnected nodes deterministically
|
||||
foreach (var nodeId in graph.Nodes.Select(n => n.Id).Distinct(StringComparer.Ordinal).OrderBy(n => n, StringComparer.Ordinal))
|
||||
{
|
||||
if (visited.Add(nodeId))
|
||||
{
|
||||
ordered.Add(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> DepthFirstLexicographicOrder(RichGraph graph)
|
||||
{
|
||||
var ordered = new List<string>();
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
var adjacency = BuildAdjacency(graph);
|
||||
var entries = FindEntryPoints(graph).OrderBy(e => e, StringComparer.Ordinal).ToList();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
DfsVisit(entry, adjacency, visited, ordered);
|
||||
}
|
||||
|
||||
// Append disconnected nodes deterministically
|
||||
foreach (var nodeId in graph.Nodes.Select(n => n.Id).Distinct(StringComparer.Ordinal).OrderBy(n => n, StringComparer.Ordinal))
|
||||
{
|
||||
DfsVisit(nodeId, adjacency, visited, ordered);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private static void DfsVisit(
|
||||
string node,
|
||||
IReadOnlyDictionary<string, IReadOnlyList<string>> adjacency,
|
||||
HashSet<string> visited,
|
||||
List<string> result)
|
||||
{
|
||||
if (!visited.Add(node))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
result.Add(node);
|
||||
|
||||
if (!adjacency.TryGetValue(node, out var neighbors))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var neighbor in neighbors)
|
||||
{
|
||||
DfsVisit(neighbor, adjacency, visited, result);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> LexicographicOrder(RichGraph graph)
|
||||
{
|
||||
return graph.Nodes
|
||||
.Select(n => n.Id)
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, IReadOnlyList<string>> BuildAdjacency(RichGraph graph)
|
||||
{
|
||||
var adjacency = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(edge.From) || string.IsNullOrWhiteSpace(edge.To))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!adjacency.TryGetValue(edge.From, out var list))
|
||||
{
|
||||
list = new List<string>();
|
||||
adjacency[edge.From] = list;
|
||||
}
|
||||
|
||||
list.Add(edge.To);
|
||||
}
|
||||
|
||||
// Deterministic neighbor traversal
|
||||
foreach (var list in adjacency.Values)
|
||||
{
|
||||
list.Sort(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return adjacency.ToDictionary(kv => kv.Key, kv => (IReadOnlyList<string>)kv.Value, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> FindEntryPoints(RichGraph graph)
|
||||
{
|
||||
var nodeIds = graph.Nodes.Select(n => n.Id).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
|
||||
var inbound = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(edge.To))
|
||||
{
|
||||
inbound.Add(edge.To);
|
||||
}
|
||||
}
|
||||
|
||||
var roots = (graph.Roots ?? Array.Empty<RichGraphRoot>())
|
||||
.Select(r => r.Id)
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.ToList();
|
||||
|
||||
var semanticEntrypoints = graph.Nodes
|
||||
.Where(IsEntrypointNode)
|
||||
.Select(n => n.Id)
|
||||
.ToList();
|
||||
|
||||
var entryPoints = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var root in roots)
|
||||
{
|
||||
entryPoints.Add(root);
|
||||
}
|
||||
|
||||
foreach (var entry in semanticEntrypoints)
|
||||
{
|
||||
entryPoints.Add(entry);
|
||||
}
|
||||
|
||||
foreach (var nodeId in nodeIds)
|
||||
{
|
||||
if (!inbound.Contains(nodeId))
|
||||
{
|
||||
entryPoints.Add(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
return entryPoints.OrderBy(id => id, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? FindAnchorNodes(RichGraph graph, IReadOnlyList<string> nodeOrder)
|
||||
{
|
||||
var anchors = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var root in graph.Roots ?? Array.Empty<RichGraphRoot>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(root.Id))
|
||||
{
|
||||
anchors.Add(root.Id);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
if (IsEntrypointNode(node))
|
||||
{
|
||||
anchors.Add(node.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (anchors.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return nodeOrder.Where(anchors.Contains).ToList();
|
||||
}
|
||||
|
||||
private static string ComputeCanonicalHash(
|
||||
IReadOnlyList<CanonicalNode> nodes,
|
||||
IReadOnlyList<CanonicalEdge> edges)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
sb.Append("N:");
|
||||
sb.Append(node.Index);
|
||||
sb.Append(':');
|
||||
sb.Append(node.Id);
|
||||
sb.Append(':');
|
||||
sb.Append(node.NodeType);
|
||||
sb.Append(';');
|
||||
}
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
sb.Append("E:");
|
||||
sb.Append(edge.SourceIndex);
|
||||
sb.Append(':');
|
||||
sb.Append(edge.TargetIndex);
|
||||
sb.Append(':');
|
||||
sb.Append(edge.EdgeType);
|
||||
sb.Append(';');
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static bool IsEntrypointNode(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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace StellaOps.Scanner.Reachability.Ordering;
|
||||
|
||||
/// <summary>
|
||||
/// Strategy for deterministic graph node ordering.
|
||||
/// </summary>
|
||||
public enum GraphOrderingStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Topological sort with lexicographic tiebreaker.
|
||||
/// Best for DAGs (call graphs).
|
||||
/// </summary>
|
||||
TopologicalLexicographic,
|
||||
|
||||
/// <summary>
|
||||
/// Breadth-first from entry points with lexicographic tiebreaker.
|
||||
/// Best for displaying reachability paths.
|
||||
/// </summary>
|
||||
BreadthFirstLexicographic,
|
||||
|
||||
/// <summary>
|
||||
/// Depth-first from entry points with lexicographic tiebreaker.
|
||||
/// Best for call stack visualization.
|
||||
/// </summary>
|
||||
DepthFirstLexicographic,
|
||||
|
||||
/// <summary>
|
||||
/// Pure lexicographic ordering by node ID.
|
||||
/// Most predictable, may not respect graph structure.
|
||||
/// </summary>
|
||||
Lexicographic
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Ordering;
|
||||
|
||||
/// <summary>
|
||||
/// Orders graph nodes deterministically.
|
||||
/// </summary>
|
||||
public interface IGraphOrderer
|
||||
{
|
||||
/// <summary>
|
||||
/// Orders nodes in the graph deterministically.
|
||||
/// </summary>
|
||||
/// <param name="graph">The graph to order.</param>
|
||||
/// <param name="strategy">Ordering strategy to use.</param>
|
||||
/// <returns>Ordered list of node IDs.</returns>
|
||||
IReadOnlyList<string> OrderNodes(
|
||||
RichGraph graph,
|
||||
GraphOrderingStrategy strategy = GraphOrderingStrategy.TopologicalLexicographic);
|
||||
|
||||
/// <summary>
|
||||
/// Orders edges deterministically based on node ordering.
|
||||
/// </summary>
|
||||
IReadOnlyList<RichGraphEdge> OrderEdges(
|
||||
RichGraph graph,
|
||||
IReadOnlyList<string> nodeOrder);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a canonical representation of the graph.
|
||||
/// </summary>
|
||||
CanonicalGraph Canonicalize(
|
||||
RichGraph graph,
|
||||
GraphOrderingStrategy strategy = GraphOrderingStrategy.TopologicalLexicographic);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.ProofSpine;
|
||||
|
||||
/// <summary>
|
||||
/// Service for DSSE (Dead Simple Signing Envelope) signing operations.
|
||||
/// </summary>
|
||||
public interface IDsseSigningService
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs a payload and returns a DSSE envelope.
|
||||
/// </summary>
|
||||
Task<DsseEnvelope> SignAsync(
|
||||
object payload,
|
||||
ICryptoProfile cryptoProfile,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a DSSE envelope signature.
|
||||
/// </summary>
|
||||
Task<bool> VerifyAsync(
|
||||
DsseEnvelope envelope,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cryptographic profile for signing operations.
|
||||
/// </summary>
|
||||
public interface ICryptoProfile
|
||||
{
|
||||
/// <summary>
|
||||
/// Key identifier.
|
||||
/// </summary>
|
||||
string KeyId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Signing algorithm (e.g., "ed25519", "ecdsa-p256").
|
||||
/// </summary>
|
||||
string Algorithm { get; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.ProofSpine;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for ProofSpine persistence and queries.
|
||||
/// </summary>
|
||||
public interface IProofSpineRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a ProofSpine by its ID.
|
||||
/// </summary>
|
||||
Task<ProofSpine?> GetByIdAsync(string spineId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a ProofSpine by its decision criteria.
|
||||
/// </summary>
|
||||
Task<ProofSpine?> GetByDecisionAsync(
|
||||
string artifactId,
|
||||
string vulnId,
|
||||
string policyProfileId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all ProofSpines for a scan run.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProofSpine>> GetByScanRunAsync(
|
||||
string scanRunId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Saves a ProofSpine.
|
||||
/// </summary>
|
||||
Task<ProofSpine> SaveAsync(ProofSpine spine, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Supersedes an old spine with a new one.
|
||||
/// </summary>
|
||||
Task SupersedeAsync(
|
||||
string oldSpineId,
|
||||
string newSpineId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all segments for a spine.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ProofSegment>> GetSegmentsAsync(
|
||||
string spineId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.ProofSpine;
|
||||
|
||||
/// <summary>
|
||||
/// Builds ProofSpine chains from evidence segments.
|
||||
/// Ensures deterministic ordering and cryptographic chaining.
|
||||
/// </summary>
|
||||
public sealed class ProofSpineBuilder
|
||||
{
|
||||
private readonly List<ProofSegmentInput> _segments = new();
|
||||
private readonly IDsseSigningService _signer;
|
||||
private readonly ICryptoProfile _cryptoProfile;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private string? _artifactId;
|
||||
private string? _vulnerabilityId;
|
||||
private string? _policyProfileId;
|
||||
private string? _scanRunId;
|
||||
|
||||
public ProofSpineBuilder(
|
||||
IDsseSigningService signer,
|
||||
ICryptoProfile cryptoProfile,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_signer = signer;
|
||||
_cryptoProfile = cryptoProfile;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public ProofSpineBuilder ForArtifact(string artifactId)
|
||||
{
|
||||
_artifactId = artifactId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ProofSpineBuilder ForVulnerability(string vulnId)
|
||||
{
|
||||
_vulnerabilityId = vulnId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ProofSpineBuilder WithPolicyProfile(string profileId)
|
||||
{
|
||||
_policyProfileId = profileId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ProofSpineBuilder WithScanRun(string scanRunId)
|
||||
{
|
||||
_scanRunId = scanRunId;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an SBOM slice segment showing component relevance.
|
||||
/// </summary>
|
||||
public ProofSpineBuilder AddSbomSlice(
|
||||
string sbomDigest,
|
||||
IReadOnlyList<string> relevantPurls,
|
||||
string toolId,
|
||||
string toolVersion)
|
||||
{
|
||||
var input = new SbomSliceInput(sbomDigest, relevantPurls);
|
||||
var inputHash = ComputeCanonicalHash(input);
|
||||
var resultHash = ComputeCanonicalHash(relevantPurls);
|
||||
|
||||
_segments.Add(new ProofSegmentInput(
|
||||
ProofSegmentType.SbomSlice,
|
||||
inputHash,
|
||||
resultHash,
|
||||
input,
|
||||
toolId,
|
||||
toolVersion));
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a vulnerability match segment.
|
||||
/// </summary>
|
||||
public ProofSpineBuilder AddMatch(
|
||||
string vulnId,
|
||||
string purl,
|
||||
string matchedVersion,
|
||||
string matchReason,
|
||||
string toolId,
|
||||
string toolVersion)
|
||||
{
|
||||
var input = new MatchInput(vulnId, purl, matchedVersion);
|
||||
var result = new MatchResult(matchReason);
|
||||
|
||||
_segments.Add(new ProofSegmentInput(
|
||||
ProofSegmentType.Match,
|
||||
ComputeCanonicalHash(input),
|
||||
ComputeCanonicalHash(result),
|
||||
new { Input = input, Result = result },
|
||||
toolId,
|
||||
toolVersion));
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a reachability analysis segment.
|
||||
/// </summary>
|
||||
public ProofSpineBuilder AddReachability(
|
||||
string callgraphDigest,
|
||||
string latticeState,
|
||||
double confidence,
|
||||
IReadOnlyList<string>? pathWitness,
|
||||
string toolId,
|
||||
string toolVersion)
|
||||
{
|
||||
var input = new ReachabilityInput(callgraphDigest);
|
||||
var result = new ReachabilityResult(latticeState, confidence, pathWitness);
|
||||
|
||||
_segments.Add(new ProofSegmentInput(
|
||||
ProofSegmentType.Reachability,
|
||||
ComputeCanonicalHash(input),
|
||||
ComputeCanonicalHash(result),
|
||||
new { Input = input, Result = result },
|
||||
toolId,
|
||||
toolVersion));
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a guard analysis segment (feature flags, config gates).
|
||||
/// </summary>
|
||||
public ProofSpineBuilder AddGuardAnalysis(
|
||||
IReadOnlyList<GuardCondition> guards,
|
||||
bool allGuardsPassed,
|
||||
string toolId,
|
||||
string toolVersion)
|
||||
{
|
||||
var input = new GuardAnalysisInput(guards);
|
||||
var result = new GuardAnalysisResult(allGuardsPassed);
|
||||
|
||||
_segments.Add(new ProofSegmentInput(
|
||||
ProofSegmentType.GuardAnalysis,
|
||||
ComputeCanonicalHash(input),
|
||||
ComputeCanonicalHash(result),
|
||||
new { Input = input, Result = result },
|
||||
toolId,
|
||||
toolVersion));
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds runtime observation evidence.
|
||||
/// </summary>
|
||||
public ProofSpineBuilder AddRuntimeObservation(
|
||||
string runtimeFactsDigest,
|
||||
bool wasObserved,
|
||||
int hitCount,
|
||||
string toolId,
|
||||
string toolVersion)
|
||||
{
|
||||
var input = new RuntimeObservationInput(runtimeFactsDigest);
|
||||
var result = new RuntimeObservationResult(wasObserved, hitCount);
|
||||
|
||||
_segments.Add(new ProofSegmentInput(
|
||||
ProofSegmentType.RuntimeObservation,
|
||||
ComputeCanonicalHash(input),
|
||||
ComputeCanonicalHash(result),
|
||||
new { Input = input, Result = result },
|
||||
toolId,
|
||||
toolVersion));
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds policy evaluation segment with final verdict.
|
||||
/// </summary>
|
||||
public ProofSpineBuilder AddPolicyEval(
|
||||
string policyDigest,
|
||||
string verdict,
|
||||
string verdictReason,
|
||||
IReadOnlyDictionary<string, object> factors,
|
||||
string toolId,
|
||||
string toolVersion)
|
||||
{
|
||||
var input = new PolicyEvalInput(policyDigest, factors);
|
||||
var result = new PolicyEvalResult(verdict, verdictReason);
|
||||
|
||||
_segments.Add(new ProofSegmentInput(
|
||||
ProofSegmentType.PolicyEval,
|
||||
ComputeCanonicalHash(input),
|
||||
ComputeCanonicalHash(result),
|
||||
new { Input = input, Result = result },
|
||||
toolId,
|
||||
toolVersion));
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the final ProofSpine with chained, signed segments.
|
||||
/// </summary>
|
||||
public async Task<ProofSpine> BuildAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateBuilder();
|
||||
|
||||
// Sort segments by type (predetermined order)
|
||||
var orderedSegments = _segments
|
||||
.OrderBy(s => (int)s.Type)
|
||||
.ToList();
|
||||
|
||||
var builtSegments = new List<ProofSegment>();
|
||||
string? prevHash = null;
|
||||
|
||||
for (var i = 0; i < orderedSegments.Count; i++)
|
||||
{
|
||||
var input = orderedSegments[i];
|
||||
var createdAt = _timeProvider.GetUtcNow();
|
||||
|
||||
// Build payload for signing
|
||||
var payload = new ProofSegmentPayload(
|
||||
input.Type.ToString(),
|
||||
i,
|
||||
input.InputHash,
|
||||
input.ResultHash,
|
||||
prevHash,
|
||||
input.Payload,
|
||||
input.ToolId,
|
||||
input.ToolVersion,
|
||||
createdAt);
|
||||
|
||||
// Sign with DSSE
|
||||
var envelope = await _signer.SignAsync(
|
||||
payload,
|
||||
_cryptoProfile,
|
||||
cancellationToken);
|
||||
|
||||
var segmentId = ComputeSegmentId(input, i, prevHash);
|
||||
var segment = new ProofSegment(
|
||||
segmentId,
|
||||
input.Type,
|
||||
i,
|
||||
input.InputHash,
|
||||
input.ResultHash,
|
||||
prevHash,
|
||||
envelope,
|
||||
input.ToolId,
|
||||
input.ToolVersion,
|
||||
ProofSegmentStatus.Verified,
|
||||
createdAt);
|
||||
|
||||
builtSegments.Add(segment);
|
||||
prevHash = segment.ResultHash;
|
||||
}
|
||||
|
||||
// Compute root hash = hash(concat of all segment result hashes)
|
||||
var rootHash = ComputeRootHash(builtSegments);
|
||||
|
||||
// Compute deterministic spine ID
|
||||
var spineId = ComputeSpineId(_artifactId!, _vulnerabilityId!, _policyProfileId!, rootHash);
|
||||
|
||||
// Extract verdict from policy eval segment
|
||||
var (verdict, verdictReason) = ExtractVerdict(builtSegments);
|
||||
|
||||
return new ProofSpine(
|
||||
spineId,
|
||||
_artifactId!,
|
||||
_vulnerabilityId!,
|
||||
_policyProfileId!,
|
||||
builtSegments.ToImmutableArray(),
|
||||
verdict,
|
||||
verdictReason,
|
||||
rootHash,
|
||||
_scanRunId!,
|
||||
_timeProvider.GetUtcNow(),
|
||||
SupersededBySpineId: null);
|
||||
}
|
||||
|
||||
private void ValidateBuilder()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_artifactId))
|
||||
throw new InvalidOperationException("ArtifactId is required");
|
||||
if (string.IsNullOrWhiteSpace(_vulnerabilityId))
|
||||
throw new InvalidOperationException("VulnerabilityId is required");
|
||||
if (string.IsNullOrWhiteSpace(_policyProfileId))
|
||||
throw new InvalidOperationException("PolicyProfileId is required");
|
||||
if (string.IsNullOrWhiteSpace(_scanRunId))
|
||||
throw new InvalidOperationException("ScanRunId is required");
|
||||
if (_segments.Count == 0)
|
||||
throw new InvalidOperationException("At least one segment is required");
|
||||
}
|
||||
|
||||
private static string ComputeCanonicalHash(object input)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(input, CanonicalJsonOptions);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string ComputeSegmentId(ProofSegmentInput input, int index, string? prevHash)
|
||||
{
|
||||
var data = $"{input.Type}:{index}:{input.InputHash}:{input.ResultHash}:{prevHash ?? "null"}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(data));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant()[..32];
|
||||
}
|
||||
|
||||
private static string ComputeRootHash(IEnumerable<ProofSegment> segments)
|
||||
{
|
||||
var concat = string.Join(":", segments.Select(s => s.ResultHash));
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(concat));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string ComputeSpineId(string artifactId, string vulnId, string profileId, string rootHash)
|
||||
{
|
||||
var data = $"{artifactId}:{vulnId}:{profileId}:{rootHash}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(data));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant()[..32];
|
||||
}
|
||||
|
||||
private static (string Verdict, string VerdictReason) ExtractVerdict(List<ProofSegment> segments)
|
||||
{
|
||||
// Default verdict if no policy eval segment
|
||||
return ("under_investigation", "No policy evaluation completed");
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
}
|
||||
|
||||
// Supporting input types
|
||||
internal sealed record ProofSegmentInput(
|
||||
ProofSegmentType Type,
|
||||
string InputHash,
|
||||
string ResultHash,
|
||||
object Payload,
|
||||
string ToolId,
|
||||
string ToolVersion);
|
||||
|
||||
internal sealed record SbomSliceInput(string SbomDigest, IReadOnlyList<string> RelevantPurls);
|
||||
internal sealed record MatchInput(string VulnId, string Purl, string MatchedVersion);
|
||||
internal sealed record MatchResult(string MatchReason);
|
||||
internal sealed record ReachabilityInput(string CallgraphDigest);
|
||||
internal sealed record ReachabilityResult(string LatticeState, double Confidence, IReadOnlyList<string>? PathWitness);
|
||||
internal sealed record GuardAnalysisInput(IReadOnlyList<GuardCondition> Guards);
|
||||
internal sealed record GuardAnalysisResult(bool AllGuardsPassed);
|
||||
internal sealed record RuntimeObservationInput(string RuntimeFactsDigest);
|
||||
internal sealed record RuntimeObservationResult(bool WasObserved, int HitCount);
|
||||
internal sealed record PolicyEvalInput(string PolicyDigest, IReadOnlyDictionary<string, object> Factors);
|
||||
internal sealed record PolicyEvalResult(string Verdict, string VerdictReason);
|
||||
internal sealed record ProofSegmentPayload(
|
||||
string SegmentType, int Index, string InputHash, string ResultHash,
|
||||
string? PrevSegmentHash, object Payload, string ToolId, string ToolVersion,
|
||||
DateTimeOffset CreatedAt);
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.ProofSpine;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a complete verifiable decision chain from SBOM to VEX verdict.
|
||||
/// </summary>
|
||||
public sealed record ProofSpine(
|
||||
string SpineId,
|
||||
string ArtifactId,
|
||||
string VulnerabilityId,
|
||||
string PolicyProfileId,
|
||||
IReadOnlyList<ProofSegment> Segments,
|
||||
string Verdict,
|
||||
string VerdictReason,
|
||||
string RootHash,
|
||||
string ScanRunId,
|
||||
DateTimeOffset CreatedAt,
|
||||
string? SupersededBySpineId);
|
||||
|
||||
/// <summary>
|
||||
/// A single evidence segment in the proof chain.
|
||||
/// </summary>
|
||||
public sealed record ProofSegment(
|
||||
string SegmentId,
|
||||
ProofSegmentType SegmentType,
|
||||
int Index,
|
||||
string InputHash,
|
||||
string ResultHash,
|
||||
string? PrevSegmentHash,
|
||||
DsseEnvelope Envelope,
|
||||
string ToolId,
|
||||
string ToolVersion,
|
||||
ProofSegmentStatus Status,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Segment types in execution order.
|
||||
/// </summary>
|
||||
public enum ProofSegmentType
|
||||
{
|
||||
SbomSlice = 1, // Component relevance extraction
|
||||
Match = 2, // SBOM-to-vulnerability mapping
|
||||
Reachability = 3, // Symbol reachability analysis
|
||||
GuardAnalysis = 4, // Config/feature flag gates
|
||||
RuntimeObservation = 5, // Runtime evidence correlation
|
||||
PolicyEval = 6 // Lattice decision computation
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification status of a segment.
|
||||
/// </summary>
|
||||
public enum ProofSegmentStatus
|
||||
{
|
||||
Pending = 0,
|
||||
Verified = 1,
|
||||
Partial = 2, // Some evidence missing but chain valid
|
||||
Invalid = 3, // Signature verification failed
|
||||
Untrusted = 4 // Key not in trust store
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope wrapper for signed content.
|
||||
/// </summary>
|
||||
public sealed record DsseEnvelope(
|
||||
string PayloadType,
|
||||
byte[] Payload,
|
||||
IReadOnlyList<DsseSignature> Signatures);
|
||||
|
||||
/// <summary>
|
||||
/// A signature in a DSSE envelope.
|
||||
/// </summary>
|
||||
public sealed record DsseSignature(
|
||||
string KeyId,
|
||||
byte[] Sig);
|
||||
|
||||
/// <summary>
|
||||
/// Guard condition for feature flag or config gate analysis.
|
||||
/// </summary>
|
||||
public sealed record GuardCondition(
|
||||
string Name,
|
||||
string Type,
|
||||
string Value,
|
||||
bool Passed);
|
||||
@@ -151,8 +151,16 @@ public sealed class ReachabilityGraphBuilder
|
||||
var payload = new ReachabilityGraphPayload
|
||||
{
|
||||
SchemaVersion = GraphSchemaVersion,
|
||||
Nodes = nodes.Select(id => new ReachabilityNode(id)).ToList(),
|
||||
Edges = edges.Select(edge => new ReachabilityEdgePayload(edge.From, edge.To, edge.Kind)).ToList()
|
||||
Nodes = nodes
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.Select(id => new ReachabilityNode(id))
|
||||
.ToList(),
|
||||
Edges = edges
|
||||
.OrderBy(edge => edge.From, StringComparer.Ordinal)
|
||||
.ThenBy(edge => edge.To, StringComparer.Ordinal)
|
||||
.ThenBy(edge => edge.Kind, StringComparer.Ordinal)
|
||||
.Select(edge => new ReachabilityEdgePayload(edge.From, edge.To, edge.Kind))
|
||||
.ToList()
|
||||
};
|
||||
|
||||
var options = new JsonSerializerOptions
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Scanner.Reachability.Ordering;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
public static class RichGraphExtensions
|
||||
{
|
||||
public static CanonicalGraph ToCanonical(
|
||||
this RichGraph graph,
|
||||
IGraphOrderer orderer,
|
||||
GraphOrderingStrategy strategy = GraphOrderingStrategy.TopologicalLexicographic)
|
||||
{
|
||||
return orderer.Canonicalize(graph, strategy);
|
||||
}
|
||||
|
||||
public static string ToCanonicalJson(this CanonicalGraph graph)
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(graph, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Security-relevant sink categories for reachability analysis.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<SinkCategory>))]
|
||||
public enum SinkCategory
|
||||
{
|
||||
/// <summary>Command/process execution (e.g., Runtime.exec, Process.Start)</summary>
|
||||
[JsonStringEnumMemberName("CMD_EXEC")]
|
||||
CmdExec,
|
||||
|
||||
/// <summary>Unsafe deserialization (e.g., BinaryFormatter, pickle.loads)</summary>
|
||||
[JsonStringEnumMemberName("UNSAFE_DESER")]
|
||||
UnsafeDeser,
|
||||
|
||||
/// <summary>Raw SQL execution (e.g., SqlCommand with string concat)</summary>
|
||||
[JsonStringEnumMemberName("SQL_RAW")]
|
||||
SqlRaw,
|
||||
|
||||
/// <summary>Server-side request forgery (e.g., HttpClient with user input)</summary>
|
||||
[JsonStringEnumMemberName("SSRF")]
|
||||
Ssrf,
|
||||
|
||||
/// <summary>Arbitrary file write (e.g., File.WriteAllBytes with user path)</summary>
|
||||
[JsonStringEnumMemberName("FILE_WRITE")]
|
||||
FileWrite,
|
||||
|
||||
/// <summary>Path traversal (e.g., Path.Combine with ../)</summary>
|
||||
[JsonStringEnumMemberName("PATH_TRAVERSAL")]
|
||||
PathTraversal,
|
||||
|
||||
/// <summary>Template/expression injection (e.g., Razor, JEXL)</summary>
|
||||
[JsonStringEnumMemberName("TEMPLATE_INJECTION")]
|
||||
TemplateInjection,
|
||||
|
||||
/// <summary>Weak cryptography (e.g., MD5, DES, ECB mode)</summary>
|
||||
[JsonStringEnumMemberName("CRYPTO_WEAK")]
|
||||
CryptoWeak,
|
||||
|
||||
/// <summary>Authorization bypass (e.g., JWT none alg, missing authz check)</summary>
|
||||
[JsonStringEnumMemberName("AUTHZ_BYPASS")]
|
||||
AuthzBypass
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A known dangerous sink with its metadata.
|
||||
/// </summary>
|
||||
public sealed record SinkDefinition(
|
||||
SinkCategory Category,
|
||||
string SymbolPattern,
|
||||
string Language,
|
||||
string? Framework = null,
|
||||
string? Description = null,
|
||||
string? CweId = null,
|
||||
double SeverityWeight = 1.0);
|
||||
|
||||
/// <summary>
|
||||
/// Registry of known dangerous sinks per language.
|
||||
/// </summary>
|
||||
public static class SinkRegistry
|
||||
{
|
||||
private static readonly FrozenDictionary<string, ImmutableArray<SinkDefinition>> SinksByLanguage = BuildRegistry();
|
||||
|
||||
private static FrozenDictionary<string, ImmutableArray<SinkDefinition>> BuildRegistry()
|
||||
{
|
||||
var builder = new Dictionary<string, List<SinkDefinition>>(StringComparer.Ordinal);
|
||||
|
||||
// .NET sinks
|
||||
AddSink(builder, "dotnet", SinkCategory.CmdExec, "System.Diagnostics.Process.Start", cweId: "CWE-78");
|
||||
AddSink(builder, "dotnet", SinkCategory.CmdExec, "System.Diagnostics.ProcessStartInfo", cweId: "CWE-78");
|
||||
AddSink(builder, "dotnet", SinkCategory.UnsafeDeser, "System.Runtime.Serialization.Formatters.Binary.BinaryFormatter.Deserialize", cweId: "CWE-502");
|
||||
AddSink(builder, "dotnet", SinkCategory.UnsafeDeser, "Newtonsoft.Json.JsonConvert.DeserializeObject", cweId: "CWE-502", framework: "Newtonsoft.Json");
|
||||
AddSink(builder, "dotnet", SinkCategory.SqlRaw, "System.Data.SqlClient.SqlCommand.ExecuteReader", cweId: "CWE-89");
|
||||
AddSink(builder, "dotnet", SinkCategory.SqlRaw, "Microsoft.EntityFrameworkCore.RelationalQueryableExtensions.FromSqlRaw", cweId: "CWE-89", framework: "EFCore");
|
||||
AddSink(builder, "dotnet", SinkCategory.Ssrf, "System.Net.Http.HttpClient.GetAsync", cweId: "CWE-918");
|
||||
AddSink(builder, "dotnet", SinkCategory.FileWrite, "System.IO.File.WriteAllBytes", cweId: "CWE-73");
|
||||
AddSink(builder, "dotnet", SinkCategory.PathTraversal, "System.IO.Path.Combine", cweId: "CWE-22");
|
||||
AddSink(builder, "dotnet", SinkCategory.CryptoWeak, "System.Security.Cryptography.MD5.Create", cweId: "CWE-327");
|
||||
AddSink(builder, "dotnet", SinkCategory.CryptoWeak, "System.Security.Cryptography.DES.Create", cweId: "CWE-327");
|
||||
|
||||
// Java sinks
|
||||
AddSink(builder, "java", SinkCategory.CmdExec, "java.lang.Runtime.exec", cweId: "CWE-78");
|
||||
AddSink(builder, "java", SinkCategory.CmdExec, "java.lang.ProcessBuilder.start", cweId: "CWE-78");
|
||||
AddSink(builder, "java", SinkCategory.UnsafeDeser, "java.io.ObjectInputStream.readObject", cweId: "CWE-502");
|
||||
AddSink(builder, "java", SinkCategory.SqlRaw, "java.sql.Statement.executeQuery", cweId: "CWE-89");
|
||||
AddSink(builder, "java", SinkCategory.Ssrf, "java.net.URL.openConnection", cweId: "CWE-918");
|
||||
AddSink(builder, "java", SinkCategory.TemplateInjection, "org.springframework.expression.ExpressionParser.parseExpression", cweId: "CWE-917", framework: "Spring");
|
||||
|
||||
// Node.js sinks
|
||||
AddSink(builder, "node", SinkCategory.CmdExec, "child_process.exec", cweId: "CWE-78");
|
||||
AddSink(builder, "node", SinkCategory.CmdExec, "child_process.spawn", cweId: "CWE-78");
|
||||
AddSink(builder, "node", SinkCategory.UnsafeDeser, "node-serialize.unserialize", cweId: "CWE-502");
|
||||
AddSink(builder, "node", SinkCategory.SqlRaw, "mysql.query", cweId: "CWE-89");
|
||||
AddSink(builder, "node", SinkCategory.PathTraversal, "path.join", cweId: "CWE-22");
|
||||
AddSink(builder, "node", SinkCategory.TemplateInjection, "eval", cweId: "CWE-94");
|
||||
|
||||
// Python sinks
|
||||
AddSink(builder, "python", SinkCategory.CmdExec, "os.system", cweId: "CWE-78");
|
||||
AddSink(builder, "python", SinkCategory.CmdExec, "subprocess.call", cweId: "CWE-78");
|
||||
AddSink(builder, "python", SinkCategory.UnsafeDeser, "pickle.loads", cweId: "CWE-502");
|
||||
AddSink(builder, "python", SinkCategory.UnsafeDeser, "yaml.load", cweId: "CWE-502");
|
||||
AddSink(builder, "python", SinkCategory.SqlRaw, "sqlite3.Cursor.execute", cweId: "CWE-89");
|
||||
AddSink(builder, "python", SinkCategory.TemplateInjection, "jinja2.Template.render", cweId: "CWE-1336", framework: "Jinja2");
|
||||
|
||||
return builder.ToFrozenDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => kvp.Value.ToImmutableArray(),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static void AddSink(
|
||||
Dictionary<string, List<SinkDefinition>> builder,
|
||||
string language,
|
||||
SinkCategory category,
|
||||
string symbolPattern,
|
||||
string? cweId = null,
|
||||
string? framework = null)
|
||||
{
|
||||
if (!builder.TryGetValue(language, out var list))
|
||||
{
|
||||
list = new List<SinkDefinition>();
|
||||
builder[language] = list;
|
||||
}
|
||||
|
||||
list.Add(new SinkDefinition(
|
||||
Category: category,
|
||||
SymbolPattern: symbolPattern,
|
||||
Language: language,
|
||||
Framework: framework,
|
||||
CweId: cweId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all sink definitions for a language.
|
||||
/// </summary>
|
||||
public static ImmutableArray<SinkDefinition> GetSinksForLanguage(string language)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(language))
|
||||
{
|
||||
return ImmutableArray<SinkDefinition>.Empty;
|
||||
}
|
||||
|
||||
return SinksByLanguage.GetValueOrDefault(language.Trim().ToLowerInvariant(), ImmutableArray<SinkDefinition>.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered languages.
|
||||
/// </summary>
|
||||
public static IEnumerable<string> GetRegisteredLanguages() => SinksByLanguage.Keys;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a symbol matches any known sink.
|
||||
/// </summary>
|
||||
public static SinkDefinition? MatchSink(string language, string symbol)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(language) || string.IsNullOrWhiteSpace(symbol))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sinks = GetSinksForLanguage(language);
|
||||
return sinks.FirstOrDefault(sink => symbol.Contains(sink.SymbolPattern, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# StellaOps.Scanner.SmartDiff — Agent Charter
|
||||
|
||||
## Mission
|
||||
Deliver Smart-Diff primitives and detection logic that enable deterministic, attestable differential analysis between two scans, reducing noise to material risk changes.
|
||||
|
||||
## Responsibilities
|
||||
- Define Smart-Diff predicate models and deterministic serialization helpers.
|
||||
- Implement reachability gate computation and Smart-Diff delta structures.
|
||||
- Provide material change detection and scoring (see `SPRINT_3500*`).
|
||||
|
||||
## Interfaces & Dependencies
|
||||
- Consumed by Scanner WebService/Worker and downstream policy/VEX flows.
|
||||
- Predicate schemas are versioned/registered under `src/Attestor/StellaOps.Attestor.Types`.
|
||||
|
||||
## Testing Expectations
|
||||
- Unit tests for reachability gate computation and enum serialization.
|
||||
- Golden predicate fixtures to ensure deterministic output.
|
||||
|
||||
## Required Reading
|
||||
- `docs/product-advisories/14-Dec-2025 - Smart-Diff Technical Reference.md`
|
||||
- `docs/modules/scanner/architecture.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to `DOING`/`DONE` in the sprint file `/docs/implplan/SPRINT_*.md` when starting/finishing work.
|
||||
- 2. Keep outputs deterministic (stable ordering, UTC timestamps, invariant formatting).
|
||||
- 3. Avoid cross-module edits unless explicitly referenced in the sprint and recorded in Decisions & Risks.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.SmartDiff;
|
||||
|
||||
public static class SmartDiffJsonSerializer
|
||||
{
|
||||
public static string Serialize(SmartDiffPredicate predicate, bool indent = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
return JsonSerializer.Serialize(predicate, CreateDefault(indent));
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions CreateDefault(bool indent)
|
||||
{
|
||||
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = indent,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
namespace StellaOps.Scanner.SmartDiff;
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Smart-Diff predicate for DSSE attestation.
|
||||
/// </summary>
|
||||
public sealed record SmartDiffPredicate(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("baseImage")] ImageReference BaseImage,
|
||||
[property: JsonPropertyName("targetImage")] ImageReference TargetImage,
|
||||
[property: JsonPropertyName("diff")] DiffPayload Diff,
|
||||
[property: JsonPropertyName("reachabilityGate")] ReachabilityGate ReachabilityGate,
|
||||
[property: JsonPropertyName("scanner")] ScannerInfo Scanner,
|
||||
[property: JsonPropertyName("context")] RuntimeContext? Context = null,
|
||||
[property: JsonPropertyName("suppressedCount")] int SuppressedCount = 0,
|
||||
[property: JsonPropertyName("materialChanges")] ImmutableArray<MaterialChange>? MaterialChanges = null)
|
||||
{
|
||||
public const string PredicateType = "stellaops.dev/predicates/smart-diff@v1";
|
||||
public const string CurrentSchemaVersion = "1.0.0";
|
||||
}
|
||||
|
||||
public sealed record ImageReference(
|
||||
[property: JsonPropertyName("digest")] string Digest,
|
||||
[property: JsonPropertyName("name")] string? Name = null,
|
||||
[property: JsonPropertyName("tag")] string? Tag = null);
|
||||
|
||||
public sealed record DiffPayload(
|
||||
[property: JsonPropertyName("filesAdded")] ImmutableArray<string>? FilesAdded = null,
|
||||
[property: JsonPropertyName("filesRemoved")] ImmutableArray<string>? FilesRemoved = null,
|
||||
[property: JsonPropertyName("filesChanged")] ImmutableArray<FileChange>? FilesChanged = null,
|
||||
[property: JsonPropertyName("packagesChanged")] ImmutableArray<PackageChange>? PackagesChanged = null,
|
||||
[property: JsonPropertyName("packagesAdded")] ImmutableArray<PackageRef>? PackagesAdded = null,
|
||||
[property: JsonPropertyName("packagesRemoved")] ImmutableArray<PackageRef>? PackagesRemoved = null);
|
||||
|
||||
public sealed record FileChange(
|
||||
[property: JsonPropertyName("path")] string Path,
|
||||
[property: JsonPropertyName("hunks")] ImmutableArray<DiffHunk>? Hunks = null,
|
||||
[property: JsonPropertyName("fromHash")] string? FromHash = null,
|
||||
[property: JsonPropertyName("toHash")] string? ToHash = null);
|
||||
|
||||
public sealed record DiffHunk(
|
||||
[property: JsonPropertyName("startLine")] int StartLine,
|
||||
[property: JsonPropertyName("lineCount")] int LineCount,
|
||||
[property: JsonPropertyName("content")] string? Content = null);
|
||||
|
||||
public sealed record PackageChange(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("from")] string From,
|
||||
[property: JsonPropertyName("to")] string To,
|
||||
[property: JsonPropertyName("purl")] string? Purl = null,
|
||||
[property: JsonPropertyName("licenseDelta")] LicenseDelta? LicenseDelta = null);
|
||||
|
||||
public sealed record LicenseDelta(
|
||||
[property: JsonPropertyName("added")] ImmutableArray<string>? Added = null,
|
||||
[property: JsonPropertyName("removed")] ImmutableArray<string>? Removed = null);
|
||||
|
||||
public sealed record PackageRef(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("purl")] string? Purl = null);
|
||||
|
||||
public sealed record RuntimeContext(
|
||||
[property: JsonPropertyName("entrypoint")] ImmutableArray<string>? Entrypoint = null,
|
||||
[property: JsonPropertyName("env")] ImmutableDictionary<string, string>? Env = null,
|
||||
[property: JsonPropertyName("user")] UserContext? User = null);
|
||||
|
||||
public sealed record UserContext(
|
||||
[property: JsonPropertyName("uid")] int? Uid = null,
|
||||
[property: JsonPropertyName("gid")] int? Gid = null,
|
||||
[property: JsonPropertyName("caps")] ImmutableArray<string>? Caps = null);
|
||||
|
||||
/// <summary>
|
||||
/// 3-bit reachability gate derived from the 7-state lattice.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityGate(
|
||||
[property: JsonPropertyName("reachable")] bool? Reachable,
|
||||
[property: JsonPropertyName("configActivated")] bool? ConfigActivated,
|
||||
[property: JsonPropertyName("runningUser")] bool? RunningUser,
|
||||
[property: JsonPropertyName("class")] int Class,
|
||||
[property: JsonPropertyName("rationale")] string? Rationale = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Computes the 3-bit class from the gate values.
|
||||
/// Returns -1 if any gate value is unknown (null).
|
||||
/// </summary>
|
||||
public static int ComputeClass(bool? reachable, bool? configActivated, bool? runningUser)
|
||||
{
|
||||
if (reachable is null || configActivated is null || runningUser is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
return (reachable.Value ? 4 : 0)
|
||||
+ (configActivated.Value ? 2 : 0)
|
||||
+ (runningUser.Value ? 1 : 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ReachabilityGate with auto-computed class.
|
||||
/// </summary>
|
||||
public static ReachabilityGate Create(
|
||||
bool? reachable,
|
||||
bool? configActivated,
|
||||
bool? runningUser,
|
||||
string? rationale = null)
|
||||
=> new(
|
||||
reachable,
|
||||
configActivated,
|
||||
runningUser,
|
||||
ComputeClass(reachable, configActivated, runningUser),
|
||||
rationale);
|
||||
}
|
||||
|
||||
public sealed record ScannerInfo(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("version")] string Version,
|
||||
[property: JsonPropertyName("ruleset")] string? Ruleset = null);
|
||||
|
||||
public sealed record MaterialChange(
|
||||
[property: JsonPropertyName("findingKey")] FindingKey FindingKey,
|
||||
[property: JsonPropertyName("changeType")] MaterialChangeType ChangeType,
|
||||
[property: JsonPropertyName("reason")] string Reason,
|
||||
[property: JsonPropertyName("previousState")] RiskState? PreviousState = null,
|
||||
[property: JsonPropertyName("currentState")] RiskState? CurrentState = null,
|
||||
[property: JsonPropertyName("priorityScore")] int? PriorityScore = null);
|
||||
|
||||
public sealed record FindingKey(
|
||||
[property: JsonPropertyName("componentPurl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("componentVersion")] string ComponentVersion,
|
||||
[property: JsonPropertyName("cveId")] string CveId);
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<MaterialChangeType>))]
|
||||
public enum MaterialChangeType
|
||||
{
|
||||
[JsonStringEnumMemberName("reachability_flip")]
|
||||
ReachabilityFlip,
|
||||
|
||||
[JsonStringEnumMemberName("vex_flip")]
|
||||
VexFlip,
|
||||
|
||||
[JsonStringEnumMemberName("range_boundary")]
|
||||
RangeBoundary,
|
||||
|
||||
[JsonStringEnumMemberName("intelligence_flip")]
|
||||
IntelligenceFlip
|
||||
}
|
||||
|
||||
public sealed record RiskState(
|
||||
[property: JsonPropertyName("reachable")] bool? Reachable = null,
|
||||
[property: JsonPropertyName("vexStatus")] VexStatusType VexStatus = VexStatusType.Unknown,
|
||||
[property: JsonPropertyName("inAffectedRange")] bool? InAffectedRange = null,
|
||||
[property: JsonPropertyName("kev")] bool Kev = false,
|
||||
[property: JsonPropertyName("epssScore")] double? EpssScore = null,
|
||||
[property: JsonPropertyName("policyFlags")] ImmutableArray<string>? PolicyFlags = null);
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<VexStatusType>))]
|
||||
public enum VexStatusType
|
||||
{
|
||||
[JsonStringEnumMemberName("affected")]
|
||||
Affected,
|
||||
|
||||
[JsonStringEnumMemberName("not_affected")]
|
||||
NotAffected,
|
||||
|
||||
[JsonStringEnumMemberName("fixed")]
|
||||
Fixed,
|
||||
|
||||
[JsonStringEnumMemberName("under_investigation")]
|
||||
UnderInvestigation,
|
||||
|
||||
[JsonStringEnumMemberName("unknown")]
|
||||
Unknown
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user