new advisories work and features gaps work
This commit is contained in:
211
src/__Libraries/StellaOps.Reachability.Core/NodeHashRecipe.cs
Normal file
211
src/__Libraries/StellaOps.Reachability.Core/NodeHashRecipe.cs
Normal file
@@ -0,0 +1,211 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-001)
|
||||
// Description: Canonical node hash recipe for deterministic static/runtime evidence joining
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Reachability.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical node hash recipe for reachability graph nodes.
|
||||
/// Produces deterministic SHA-256 hashes that can join static and runtime evidence.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Hash recipe: SHA256(normalize(PURL) + ":" + normalize(SYMBOL_FQN))
|
||||
/// where:
|
||||
/// - PURL is normalized per PackageURL spec (lowercase scheme, sorted qualifiers)
|
||||
/// - SYMBOL_FQN is namespace.type.method(signature) with consistent normalization
|
||||
/// </remarks>
|
||||
public static class NodeHashRecipe
|
||||
{
|
||||
private const string HashPrefix = "sha256:";
|
||||
private const char Separator = ':';
|
||||
|
||||
/// <summary>
|
||||
/// Computes the canonical node hash for a symbol reference.
|
||||
/// </summary>
|
||||
/// <param name="purl">Package URL (will be normalized).</param>
|
||||
/// <param name="symbolFqn">Fully qualified symbol name (namespace.type.method(sig)).</param>
|
||||
/// <returns>Hash in format "sha256:<hex>".</returns>
|
||||
public static string ComputeHash(string purl, string symbolFqn)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(purl);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(symbolFqn);
|
||||
|
||||
var normalizedPurl = NormalizePurl(purl);
|
||||
var normalizedSymbol = NormalizeSymbolFqn(symbolFqn);
|
||||
|
||||
var input = $"{normalizedPurl}{Separator}{normalizedSymbol}";
|
||||
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
|
||||
return HashPrefix + Convert.ToHexStringLower(hashBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the canonical node hash for a SymbolRef.
|
||||
/// </summary>
|
||||
public static string ComputeHash(SymbolRef symbolRef)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(symbolRef);
|
||||
return ComputeHash(symbolRef.Purl, symbolRef.DisplayName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes node hashes for multiple symbols, returning in deterministic sorted order.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> ComputeHashes(IEnumerable<SymbolRef> symbols)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(symbols);
|
||||
|
||||
return symbols
|
||||
.Select(ComputeHash)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a PURL for consistent hashing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Normalization rules:
|
||||
/// - Lowercase scheme (pkg:)
|
||||
/// - Lowercase type (npm, pypi, etc.)
|
||||
/// - Preserve namespace/name case (some ecosystems are case-sensitive)
|
||||
/// - Sort qualifiers alphabetically by key
|
||||
/// - Remove trailing slashes
|
||||
/// - Normalize empty version to "unversioned"
|
||||
/// </remarks>
|
||||
public static string NormalizePurl(string purl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(purl))
|
||||
return string.Empty;
|
||||
|
||||
// Basic normalization: trim, ensure lowercase scheme
|
||||
var normalized = purl.Trim();
|
||||
|
||||
// Ensure pkg: scheme is lowercase
|
||||
if (normalized.StartsWith("PKG:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
normalized = "pkg:" + normalized[4..];
|
||||
}
|
||||
|
||||
// Split into components for further normalization
|
||||
var parts = normalized.Split('?', 2);
|
||||
var basePurl = parts[0].TrimEnd('/');
|
||||
|
||||
// Lowercase the type portion (e.g., NPM -> npm)
|
||||
var colonIndex = basePurl.IndexOf(':', StringComparison.Ordinal);
|
||||
if (colonIndex > 0)
|
||||
{
|
||||
var slashIndex = basePurl.IndexOf('/', colonIndex);
|
||||
if (slashIndex > colonIndex)
|
||||
{
|
||||
var scheme = basePurl[..colonIndex].ToLowerInvariant();
|
||||
var type = basePurl[(colonIndex + 1)..slashIndex].ToLowerInvariant();
|
||||
var rest = basePurl[slashIndex..];
|
||||
basePurl = $"{scheme}:{type}{rest}";
|
||||
}
|
||||
}
|
||||
|
||||
// Handle qualifiers if present
|
||||
if (parts.Length > 1 && !string.IsNullOrEmpty(parts[1]))
|
||||
{
|
||||
var qualifiers = parts[1]
|
||||
.Split('&')
|
||||
.Where(q => !string.IsNullOrEmpty(q))
|
||||
.Select(q => q.Trim())
|
||||
.OrderBy(q => q.Split('=')[0], StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (qualifiers.Length > 0)
|
||||
{
|
||||
return basePurl + "?" + string.Join("&", qualifiers);
|
||||
}
|
||||
}
|
||||
|
||||
return basePurl;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a fully qualified symbol name for consistent hashing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Normalization rules:
|
||||
/// - Trim whitespace
|
||||
/// - Normalize multiple consecutive dots to single dot
|
||||
/// - Normalize signature whitespace: remove spaces after commas in (type, type)
|
||||
/// - Empty signatures become ()
|
||||
/// - Replace "_" types with empty for module-level functions
|
||||
/// </remarks>
|
||||
public static string NormalizeSymbolFqn(string symbolFqn)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(symbolFqn))
|
||||
return string.Empty;
|
||||
|
||||
var normalized = symbolFqn.Trim();
|
||||
|
||||
// Normalize multiple dots
|
||||
while (normalized.Contains("..", StringComparison.Ordinal))
|
||||
{
|
||||
normalized = normalized.Replace("..", ".", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// Normalize signature whitespace
|
||||
if (normalized.Contains('('))
|
||||
{
|
||||
var parenStart = normalized.IndexOf('(');
|
||||
var parenEnd = normalized.LastIndexOf(')');
|
||||
|
||||
if (parenStart >= 0 && parenEnd > parenStart)
|
||||
{
|
||||
var beforeSig = normalized[..parenStart];
|
||||
var sig = normalized[parenStart..(parenEnd + 1)];
|
||||
var afterSig = normalized[(parenEnd + 1)..];
|
||||
|
||||
// Normalize signature: remove spaces, ensure consistent format
|
||||
sig = sig.Replace(" ", "", StringComparison.Ordinal);
|
||||
sig = sig.Replace(",", ", ", StringComparison.Ordinal); // Consistent single space after comma
|
||||
sig = sig.Replace(", )", ")", StringComparison.Ordinal); // Fix trailing space
|
||||
|
||||
normalized = beforeSig + sig + afterSig;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle "._." pattern (module-level function placeholder)
|
||||
normalized = normalized.Replace("._.", ".", StringComparison.Ordinal);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a hash was computed with this recipe.
|
||||
/// </summary>
|
||||
public static bool IsValidHash(string hash)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hash))
|
||||
return false;
|
||||
|
||||
if (!hash.StartsWith(HashPrefix, StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
var hexPart = hash[HashPrefix.Length..];
|
||||
return hexPart.Length == 64 && hexPart.All(c => char.IsAsciiHexDigit(c));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the hex portion of a hash (without sha256: prefix).
|
||||
/// </summary>
|
||||
public static string GetHexPart(string hash)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hash))
|
||||
return string.Empty;
|
||||
|
||||
return hash.StartsWith(HashPrefix, StringComparison.Ordinal)
|
||||
? hash[HashPrefix.Length..]
|
||||
: hash;
|
||||
}
|
||||
}
|
||||
179
src/__Libraries/StellaOps.Reachability.Core/PathHashRecipe.cs
Normal file
179
src/__Libraries/StellaOps.Reachability.Core/PathHashRecipe.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260112_004_SCANNER_path_witness_nodehash (PW-SCN-001)
|
||||
// Description: Canonical path hash recipe for deterministic path witness hashing
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Reachability.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical path hash recipe for reachability paths.
|
||||
/// Produces deterministic SHA-256 hashes for entire paths (sequence of nodes).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Hash recipe: SHA256(nodeHash1 + ">" + nodeHash2 + ">" + ... + nodeHashN)
|
||||
/// where each nodeHash is computed using <see cref="NodeHashRecipe"/>.
|
||||
/// The ">" separator represents directed edges in the path.
|
||||
/// </remarks>
|
||||
public static class PathHashRecipe
|
||||
{
|
||||
private const string HashPrefix = "sha256:";
|
||||
private const string EdgeSeparator = ">";
|
||||
|
||||
/// <summary>
|
||||
/// Computes the canonical path hash from a sequence of node hashes.
|
||||
/// </summary>
|
||||
/// <param name="nodeHashes">Ordered sequence of node hashes (from source to sink).</param>
|
||||
/// <returns>Hash in format "sha256:<hex>".</returns>
|
||||
public static string ComputeHash(IEnumerable<string> nodeHashes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nodeHashes);
|
||||
|
||||
var hashes = nodeHashes.ToList();
|
||||
if (hashes.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("Path must contain at least one node.", nameof(nodeHashes));
|
||||
}
|
||||
|
||||
// Normalize: strip sha256: prefix from each hash for consistent joining
|
||||
var normalizedHashes = hashes.Select(h => NodeHashRecipe.GetHexPart(h));
|
||||
var pathString = string.Join(EdgeSeparator, normalizedHashes);
|
||||
|
||||
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(pathString));
|
||||
return HashPrefix + Convert.ToHexStringLower(hashBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the canonical path hash from a sequence of symbol references.
|
||||
/// </summary>
|
||||
/// <param name="symbols">Ordered sequence of symbols (from source to sink).</param>
|
||||
/// <returns>Hash in format "sha256:<hex>".</returns>
|
||||
public static string ComputeHash(IEnumerable<SymbolRef> symbols)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(symbols);
|
||||
|
||||
var nodeHashes = symbols.Select(NodeHashRecipe.ComputeHash);
|
||||
return ComputeHash(nodeHashes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes path hash and returns the top-K node hashes in path order.
|
||||
/// </summary>
|
||||
/// <param name="nodeHashes">Ordered sequence of node hashes.</param>
|
||||
/// <param name="topK">Maximum number of node hashes to return (default: 10).</param>
|
||||
/// <returns>Tuple of (pathHash, topKNodeHashes).</returns>
|
||||
public static (string PathHash, IReadOnlyList<string> TopKNodes) ComputeWithTopK(
|
||||
IEnumerable<string> nodeHashes,
|
||||
int topK = 10)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(nodeHashes);
|
||||
if (topK < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(topK), "topK must be at least 1.");
|
||||
}
|
||||
|
||||
var hashes = nodeHashes.ToList();
|
||||
var pathHash = ComputeHash(hashes);
|
||||
|
||||
// Take first K and last (K/2) to capture entry and exit points
|
||||
var firstK = hashes.Take(topK / 2 + topK % 2);
|
||||
var lastK = hashes.TakeLast(topK / 2);
|
||||
|
||||
var topKNodes = firstK
|
||||
.Concat(lastK)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Take(topK)
|
||||
.ToList();
|
||||
|
||||
return (pathHash, topKNodes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes path hash for multiple paths and returns in deterministic order.
|
||||
/// </summary>
|
||||
/// <param name="paths">Collection of paths, each represented as a sequence of node hashes.</param>
|
||||
/// <returns>Distinct path hashes in sorted order.</returns>
|
||||
public static IReadOnlyList<string> ComputeHashes(IEnumerable<IEnumerable<string>> paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
return paths
|
||||
.Select(ComputeHash)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a hash was computed with this recipe.
|
||||
/// </summary>
|
||||
public static bool IsValidHash(string hash) => NodeHashRecipe.IsValidHash(hash);
|
||||
|
||||
/// <summary>
|
||||
/// Computes a combined hash for multiple paths (for graph-level identity).
|
||||
/// </summary>
|
||||
/// <param name="pathHashes">Collection of path hashes.</param>
|
||||
/// <returns>Combined hash in format "sha256:<hex>".</returns>
|
||||
public static string ComputeCombinedHash(IEnumerable<string> pathHashes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pathHashes);
|
||||
|
||||
var sortedHashes = pathHashes
|
||||
.Select(NodeHashRecipe.GetHexPart)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (sortedHashes.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("Must provide at least one path hash.", nameof(pathHashes));
|
||||
}
|
||||
|
||||
var combined = string.Join(",", sortedHashes);
|
||||
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(combined));
|
||||
|
||||
return HashPrefix + Convert.ToHexStringLower(hashBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a path fingerprint containing hash and metadata.
|
||||
/// </summary>
|
||||
public static PathFingerprint CreateFingerprint(
|
||||
IReadOnlyList<string> nodeHashes,
|
||||
int topK = 10)
|
||||
{
|
||||
var (pathHash, topKNodes) = ComputeWithTopK(nodeHashes, topK);
|
||||
|
||||
return new PathFingerprint
|
||||
{
|
||||
PathHash = pathHash,
|
||||
NodeCount = nodeHashes.Count,
|
||||
TopKNodeHashes = topKNodes,
|
||||
SourceNodeHash = nodeHashes.FirstOrDefault() ?? string.Empty,
|
||||
SinkNodeHash = nodeHashes.LastOrDefault() ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Path fingerprint containing hash and summary metadata.
|
||||
/// </summary>
|
||||
public sealed record PathFingerprint
|
||||
{
|
||||
/// <summary>Canonical path hash (sha256:hex).</summary>
|
||||
public required string PathHash { get; init; }
|
||||
|
||||
/// <summary>Total number of nodes in the path.</summary>
|
||||
public required int NodeCount { get; init; }
|
||||
|
||||
/// <summary>Top-K node hashes for efficient lookup.</summary>
|
||||
public required IReadOnlyList<string> TopKNodeHashes { get; init; }
|
||||
|
||||
/// <summary>Hash of the source (entry) node.</summary>
|
||||
public required string SourceNodeHash { get; init; }
|
||||
|
||||
/// <summary>Hash of the sink (exit/vulnerable) node.</summary>
|
||||
public required string SinkNodeHash { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user