This commit is contained in:
StellaOps Bot
2025-12-14 23:20:14 +02:00
parent 3411e825cd
commit b058dbe031
356 changed files with 68310 additions and 1108 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

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

View File

@@ -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.

View File

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

View File

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

View File

@@ -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>