new advisories work and features gaps work

This commit is contained in:
master
2026-01-14 18:39:19 +02:00
parent 95d5898650
commit 15aeac8e8b
148 changed files with 16731 additions and 554 deletions

View 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:&lt;hex&gt;".</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;
}
}

View 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:&lt;hex&gt;".</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:&lt;hex&gt;".</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:&lt;hex&gt;".</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; }
}