Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism
- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency. - Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling. - Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies. - Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification. - Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// FuncProofBuilder.cs
|
||||
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
|
||||
// Tasks: FUNC-05, FUNC-07, FUNC-10, FUNC-11 — Symbol/function hashing and trace serialization
|
||||
// Description: Builds FuncProof documents from binary analysis results.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Scanner.Evidence.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Builds FuncProof documents from binary analysis results.
|
||||
/// </summary>
|
||||
public sealed class FuncProofBuilder
|
||||
{
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private ICryptoHash? _cryptoHash;
|
||||
private FuncProofGenerationOptions _options = new();
|
||||
private string? _buildId;
|
||||
private string? _buildIdType;
|
||||
private string? _fileSha256;
|
||||
private string? _binaryFormat;
|
||||
private string? _architecture;
|
||||
private bool _isStripped;
|
||||
private readonly Dictionary<string, FuncProofSection> _sections = new();
|
||||
private readonly List<FuncProofFunctionBuilder> _functions = [];
|
||||
private readonly List<FuncProofTrace> _traces = [];
|
||||
private FuncProofMetadata? _metadata;
|
||||
private string _generatorVersion = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Sets the cryptographic hash provider for regional compliance.
|
||||
/// If not set, defaults to SHA-256 for backward compatibility.
|
||||
/// </summary>
|
||||
public FuncProofBuilder WithCryptoHash(ICryptoHash cryptoHash)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the generation options for configurable parameters.
|
||||
/// </summary>
|
||||
public FuncProofBuilder WithOptions(FuncProofGenerationOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the binary identity information.
|
||||
/// </summary>
|
||||
public FuncProofBuilder WithBinaryIdentity(
|
||||
string buildId,
|
||||
string buildIdType,
|
||||
string fileSha256,
|
||||
string binaryFormat,
|
||||
string architecture,
|
||||
bool isStripped)
|
||||
{
|
||||
_buildId = buildId;
|
||||
_buildIdType = buildIdType;
|
||||
_fileSha256 = fileSha256;
|
||||
_binaryFormat = binaryFormat;
|
||||
_architecture = architecture;
|
||||
_isStripped = isStripped;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a section with hash.
|
||||
/// </summary>
|
||||
public FuncProofBuilder AddSection(string name, byte[] content, long offset, long? virtualAddress = null)
|
||||
{
|
||||
var hash = ComputeBlake3Hash(content);
|
||||
_sections[name] = new FuncProofSection
|
||||
{
|
||||
Hash = $"blake3:{hash}",
|
||||
Offset = offset,
|
||||
Size = content.Length,
|
||||
VirtualAddress = virtualAddress
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a section with pre-computed hash.
|
||||
/// </summary>
|
||||
public FuncProofBuilder AddSection(string name, string hash, long offset, long size, long? virtualAddress = null)
|
||||
{
|
||||
_sections[name] = new FuncProofSection
|
||||
{
|
||||
Hash = hash.StartsWith("blake3:") ? hash : $"blake3:{hash}",
|
||||
Offset = offset,
|
||||
Size = size,
|
||||
VirtualAddress = virtualAddress
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a function definition.
|
||||
/// </summary>
|
||||
public FuncProofFunctionBuilder AddFunction(string symbol, long startAddress, long endAddress)
|
||||
{
|
||||
var builder = new FuncProofFunctionBuilder(this, symbol, startAddress, endAddress);
|
||||
_functions.Add(builder);
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an entry→sink trace.
|
||||
/// </summary>
|
||||
public FuncProofBuilder AddTrace(
|
||||
string entrySymbolDigest,
|
||||
string sinkSymbolDigest,
|
||||
IReadOnlyList<(string callerDigest, string calleeDigest)> edges,
|
||||
IReadOnlyList<string>? path = null)
|
||||
{
|
||||
var edgeListHash = ComputeEdgeListHash(edges);
|
||||
var hopCount = edges.Count;
|
||||
var maxHops = _options.MaxTraceHops;
|
||||
var truncated = hopCount > maxHops;
|
||||
|
||||
var effectivePath = path ?? edges.Select(e => e.calleeDigest).Prepend(entrySymbolDigest).ToList();
|
||||
if (effectivePath.Count > maxHops + 1)
|
||||
{
|
||||
effectivePath = effectivePath.Take(maxHops + 1).ToList();
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
var trace = new FuncProofTrace
|
||||
{
|
||||
TraceId = $"trace-{_traces.Count + 1}",
|
||||
EdgeListHash = $"blake3:{edgeListHash}",
|
||||
HopCount = Math.Min(hopCount, maxHops),
|
||||
EntrySymbolDigest = entrySymbolDigest,
|
||||
SinkSymbolDigest = sinkSymbolDigest,
|
||||
Path = effectivePath.ToImmutableArray(),
|
||||
Truncated = truncated
|
||||
};
|
||||
|
||||
_traces.Add(trace);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets build metadata.
|
||||
/// </summary>
|
||||
public FuncProofBuilder WithMetadata(FuncProofMetadata metadata)
|
||||
{
|
||||
_metadata = metadata;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the generator version.
|
||||
/// </summary>
|
||||
public FuncProofBuilder WithGeneratorVersion(string version)
|
||||
{
|
||||
_generatorVersion = version;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the FuncProof document.
|
||||
/// </summary>
|
||||
public FuncProof Build()
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(_buildId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(_buildIdType);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(_fileSha256);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(_binaryFormat);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(_architecture);
|
||||
|
||||
var functions = _functions
|
||||
.Select(f => f.Build())
|
||||
.OrderBy(f => f.Start, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var sections = _sections
|
||||
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
|
||||
.ToImmutableDictionary();
|
||||
|
||||
var traces = _traces
|
||||
.OrderBy(t => t.TraceId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
// Build initial proof without proofId
|
||||
var proof = new FuncProof
|
||||
{
|
||||
ProofId = string.Empty, // Placeholder
|
||||
BuildId = _buildId,
|
||||
BuildIdType = _buildIdType,
|
||||
FileSha256 = _fileSha256,
|
||||
BinaryFormat = _binaryFormat,
|
||||
Architecture = _architecture,
|
||||
IsStripped = _isStripped,
|
||||
Sections = sections,
|
||||
Functions = functions,
|
||||
Traces = traces,
|
||||
Meta = _metadata,
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
GeneratorVersion = _generatorVersion
|
||||
};
|
||||
|
||||
// Compute content-addressable ID
|
||||
var proofId = ComputeProofId(proof, _cryptoHash);
|
||||
|
||||
return proof with { ProofId = proofId };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the content-addressable proof ID.
|
||||
/// Uses ICryptoHash for regional compliance (defaults to BLAKE3 in "world" profile).
|
||||
/// </summary>
|
||||
public static string ComputeProofId(FuncProof proof, ICryptoHash? cryptoHash = null)
|
||||
{
|
||||
// Create a version without proofId for hashing
|
||||
var forHashing = proof with { ProofId = string.Empty };
|
||||
var json = JsonSerializer.Serialize(forHashing, CanonicalJsonOptions);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
var hash = ComputeHashForGraph(bytes, cryptoHash);
|
||||
|
||||
// Prefix indicates algorithm used (determined by compliance profile)
|
||||
var algorithmPrefix = cryptoHash is not null ? "graph" : "sha256";
|
||||
return $"{algorithmPrefix}:{hash}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes symbol digest: BLAKE3(symbol_name + "|" + start + "|" + end).
|
||||
/// Uses ICryptoHash for regional compliance (defaults to BLAKE3 in "world" profile).
|
||||
/// </summary>
|
||||
public static string ComputeSymbolDigest(string symbol, long start, long end, ICryptoHash? cryptoHash = null)
|
||||
{
|
||||
var input = $"{symbol}|{start:x}|{end:x}";
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
return ComputeHashForGraph(bytes, cryptoHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes function range hash over the function bytes.
|
||||
/// Uses ICryptoHash for regional compliance (defaults to BLAKE3 in "world" profile).
|
||||
/// </summary>
|
||||
public static string ComputeFunctionHash(byte[] functionBytes, ICryptoHash? cryptoHash = null)
|
||||
{
|
||||
return ComputeHashForGraph(functionBytes, cryptoHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes edge list hash: hash of sorted edge pairs.
|
||||
/// Uses ICryptoHash for regional compliance (defaults to BLAKE3 in "world" profile).
|
||||
/// </summary>
|
||||
private static string ComputeEdgeListHash(IReadOnlyList<(string callerDigest, string calleeDigest)> edges, ICryptoHash? cryptoHash = null)
|
||||
{
|
||||
var sortedEdges = edges
|
||||
.Select(e => $"{e.callerDigest}→{e.calleeDigest}")
|
||||
.OrderBy(e => e, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var edgeList = string.Join("\n", sortedEdges);
|
||||
var bytes = Encoding.UTF8.GetBytes(edgeList);
|
||||
return ComputeHashForGraph(bytes, cryptoHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes hash using the Graph purpose from ICryptoHash.
|
||||
/// Falls back to SHA-256 if no crypto hash provider is available.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Default algorithm by compliance profile:
|
||||
/// - world: BLAKE3-256
|
||||
/// - fips/kcmvp/eidas: SHA-256
|
||||
/// - gost: GOST3411-2012-256
|
||||
/// - sm: SM3
|
||||
/// </remarks>
|
||||
private static string ComputeHashForGraph(byte[] data, ICryptoHash? cryptoHash)
|
||||
{
|
||||
if (cryptoHash is not null)
|
||||
{
|
||||
// Use purpose-based hashing for compliance-aware algorithm selection
|
||||
return cryptoHash.ComputeHashHexForPurpose(data, HashPurpose.Graph);
|
||||
}
|
||||
|
||||
// Fallback: use SHA-256 when no ICryptoHash provider is available
|
||||
// This maintains backward compatibility for tests and standalone usage
|
||||
var hash = SHA256.HashData(data);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for individual function entries.
|
||||
/// </summary>
|
||||
public sealed class FuncProofFunctionBuilder
|
||||
{
|
||||
private readonly FuncProofBuilder _parent;
|
||||
private readonly string _symbol;
|
||||
private readonly long _startAddress;
|
||||
private readonly long _endAddress;
|
||||
private string? _mangledName;
|
||||
private byte[]? _functionBytes;
|
||||
private string? _precomputedHash;
|
||||
private double _confidence = 1.0;
|
||||
private string? _sourceFile;
|
||||
private int? _sourceLine;
|
||||
private bool _isEntrypoint;
|
||||
private string? _entrypointType;
|
||||
private bool _isSink;
|
||||
private string? _sinkVulnId;
|
||||
|
||||
internal FuncProofFunctionBuilder(FuncProofBuilder parent, string symbol, long startAddress, long endAddress)
|
||||
{
|
||||
_parent = parent;
|
||||
_symbol = symbol;
|
||||
_startAddress = startAddress;
|
||||
_endAddress = endAddress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the mangled name if different from symbol.
|
||||
/// </summary>
|
||||
public FuncProofFunctionBuilder WithMangledName(string mangledName)
|
||||
{
|
||||
_mangledName = mangledName;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the function bytes for hash computation.
|
||||
/// </summary>
|
||||
public FuncProofFunctionBuilder WithBytes(byte[] bytes)
|
||||
{
|
||||
_functionBytes = bytes;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a pre-computed hash.
|
||||
/// </summary>
|
||||
public FuncProofFunctionBuilder WithHash(string hash)
|
||||
{
|
||||
_precomputedHash = hash;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the confidence level for boundary detection.
|
||||
/// </summary>
|
||||
public FuncProofFunctionBuilder WithConfidence(double confidence)
|
||||
{
|
||||
_confidence = confidence;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets source location from DWARF info.
|
||||
/// </summary>
|
||||
public FuncProofFunctionBuilder WithSourceLocation(string file, int line)
|
||||
{
|
||||
_sourceFile = file;
|
||||
_sourceLine = line;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks this function as an entrypoint.
|
||||
/// </summary>
|
||||
public FuncProofFunctionBuilder AsEntrypoint(string? type = null)
|
||||
{
|
||||
_isEntrypoint = true;
|
||||
_entrypointType = type;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks this function as a vulnerable sink.
|
||||
/// </summary>
|
||||
public FuncProofFunctionBuilder AsSink(string? vulnId = null)
|
||||
{
|
||||
_isSink = true;
|
||||
_sinkVulnId = vulnId;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns to the parent builder.
|
||||
/// </summary>
|
||||
public FuncProofBuilder Done() => _parent;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the function entry.
|
||||
/// </summary>
|
||||
internal FuncProofFunction Build()
|
||||
{
|
||||
var symbolDigest = FuncProofBuilder.ComputeSymbolDigest(_symbol, _startAddress, _endAddress);
|
||||
|
||||
string hash;
|
||||
if (_precomputedHash != null)
|
||||
{
|
||||
hash = _precomputedHash.StartsWith("blake3:") ? _precomputedHash : $"blake3:{_precomputedHash}";
|
||||
}
|
||||
else if (_functionBytes != null)
|
||||
{
|
||||
hash = $"blake3:{FuncProofBuilder.ComputeFunctionHash(_functionBytes)}";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use symbol digest as fallback hash
|
||||
hash = $"blake3:{symbolDigest}";
|
||||
}
|
||||
|
||||
return new FuncProofFunction
|
||||
{
|
||||
Symbol = _symbol,
|
||||
MangledName = _mangledName,
|
||||
SymbolDigest = symbolDigest,
|
||||
Start = $"0x{_startAddress:x}",
|
||||
End = $"0x{_endAddress:x}",
|
||||
Size = _endAddress - _startAddress,
|
||||
Hash = hash,
|
||||
Confidence = _confidence,
|
||||
SourceFile = _sourceFile,
|
||||
SourceLine = _sourceLine,
|
||||
IsEntrypoint = _isEntrypoint,
|
||||
EntrypointType = _entrypointType,
|
||||
IsSink = _isSink,
|
||||
SinkVulnId = _sinkVulnId
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.Scanner.Evidence.Models;
|
||||
using StellaOps.Scanner.ProofSpine;
|
||||
|
||||
namespace StellaOps.Scanner.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Service for wrapping FuncProof documents in DSSE (Dead Simple Signing Envelope) for
|
||||
/// cryptographic attestation and transparency log integration.
|
||||
/// </summary>
|
||||
public interface IFuncProofDsseService
|
||||
{
|
||||
/// <summary>
|
||||
/// Wraps a FuncProof document in a signed DSSE envelope.
|
||||
/// </summary>
|
||||
/// <param name="funcProof">The FuncProof document to sign.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A signed DSSE envelope containing the FuncProof payload.</returns>
|
||||
Task<FuncProofDsseResult> SignAsync(FuncProof funcProof, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a FuncProof DSSE envelope signature.
|
||||
/// </summary>
|
||||
/// <param name="envelope">The DSSE envelope to verify.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Verification outcome with validity and trust status.</returns>
|
||||
Task<FuncProofVerificationResult> VerifyAsync(DsseEnvelope envelope, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the FuncProof payload from a DSSE envelope without verification.
|
||||
/// </summary>
|
||||
/// <param name="envelope">The DSSE envelope containing the FuncProof.</param>
|
||||
/// <returns>The extracted FuncProof document, or null if extraction fails.</returns>
|
||||
FuncProof? ExtractPayload(DsseEnvelope envelope);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of signing a FuncProof document.
|
||||
/// </summary>
|
||||
/// <param name="Envelope">The signed DSSE envelope.</param>
|
||||
/// <param name="EnvelopeId">Content-addressable ID of the envelope (SHA-256 of canonical JSON).</param>
|
||||
/// <param name="EnvelopeJson">Serialized envelope JSON for storage/transmission.</param>
|
||||
public sealed record FuncProofDsseResult(
|
||||
DsseEnvelope Envelope,
|
||||
string EnvelopeId,
|
||||
string EnvelopeJson);
|
||||
|
||||
/// <summary>
|
||||
/// Result of verifying a FuncProof DSSE envelope.
|
||||
/// </summary>
|
||||
/// <param name="IsValid">True if signature verification passed.</param>
|
||||
/// <param name="IsTrusted">True if signed with a trusted key (not deterministic fallback).</param>
|
||||
/// <param name="FailureReason">Description of failure if verification failed.</param>
|
||||
/// <param name="FuncProof">The extracted FuncProof if verification succeeded.</param>
|
||||
public sealed record FuncProofVerificationResult(
|
||||
bool IsValid,
|
||||
bool IsTrusted,
|
||||
string? FailureReason,
|
||||
FuncProof? FuncProof);
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for FuncProof DSSE signing.
|
||||
/// </summary>
|
||||
public sealed class FuncProofDsseOptions
|
||||
{
|
||||
public const string SectionName = "Scanner:FuncProof:Dsse";
|
||||
|
||||
/// <summary>
|
||||
/// Key identifier for signing operations.
|
||||
/// </summary>
|
||||
public string KeyId { get; set; } = "funcproof-default";
|
||||
|
||||
/// <summary>
|
||||
/// Signing algorithm (e.g., "hs256", "ed25519").
|
||||
/// </summary>
|
||||
public string Algorithm { get; set; } = "hs256";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include the proof ID in the envelope metadata.
|
||||
/// </summary>
|
||||
public bool IncludeProofIdInMetadata { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crypto profile for FuncProof DSSE signing.
|
||||
/// </summary>
|
||||
internal sealed class FuncProofCryptoProfile : ICryptoProfile
|
||||
{
|
||||
public FuncProofCryptoProfile(string keyId, string algorithm)
|
||||
{
|
||||
KeyId = keyId ?? throw new ArgumentNullException(nameof(keyId));
|
||||
Algorithm = algorithm ?? throw new ArgumentNullException(nameof(algorithm));
|
||||
}
|
||||
|
||||
public string KeyId { get; }
|
||||
public string Algorithm { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of FuncProof DSSE signing service.
|
||||
/// </summary>
|
||||
public sealed class FuncProofDsseService : IFuncProofDsseService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly IDsseSigningService _signingService;
|
||||
private readonly IOptions<FuncProofDsseOptions> _options;
|
||||
private readonly ILogger<FuncProofDsseService> _logger;
|
||||
|
||||
public FuncProofDsseService(
|
||||
IDsseSigningService signingService,
|
||||
IOptions<FuncProofDsseOptions> options,
|
||||
ILogger<FuncProofDsseService> logger)
|
||||
{
|
||||
_signingService = signingService ?? throw new ArgumentNullException(nameof(signingService));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<FuncProofDsseResult> SignAsync(FuncProof funcProof, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(funcProof);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (string.IsNullOrEmpty(funcProof.ProofId))
|
||||
{
|
||||
throw new ArgumentException("FuncProof must have a valid ProofId before signing.", nameof(funcProof));
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Signing FuncProof {ProofId} for build {BuildId}",
|
||||
funcProof.ProofId,
|
||||
funcProof.BuildId);
|
||||
|
||||
var opts = _options.Value;
|
||||
var profile = new FuncProofCryptoProfile(opts.KeyId, opts.Algorithm);
|
||||
|
||||
// Sign the FuncProof document
|
||||
var envelope = await _signingService.SignAsync(
|
||||
funcProof,
|
||||
FuncProofConstants.MediaType,
|
||||
profile,
|
||||
ct);
|
||||
|
||||
// Compute envelope ID (content-addressable)
|
||||
var envelopeJson = JsonSerializer.Serialize(envelope, JsonOptions);
|
||||
var envelopeId = ComputeEnvelopeId(envelopeJson);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Signed FuncProof {ProofId} with envelope ID {EnvelopeId}",
|
||||
funcProof.ProofId,
|
||||
envelopeId);
|
||||
|
||||
return new FuncProofDsseResult(envelope, envelopeId, envelopeJson);
|
||||
}
|
||||
|
||||
public async Task<FuncProofVerificationResult> VerifyAsync(DsseEnvelope envelope, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Validate payload type
|
||||
if (!string.Equals(envelope.PayloadType, FuncProofConstants.MediaType, StringComparison.Ordinal))
|
||||
{
|
||||
return new FuncProofVerificationResult(
|
||||
false,
|
||||
false,
|
||||
$"Invalid payload type: expected '{FuncProofConstants.MediaType}', got '{envelope.PayloadType}'",
|
||||
null);
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
var outcome = await _signingService.VerifyAsync(envelope, ct);
|
||||
if (!outcome.IsValid)
|
||||
{
|
||||
_logger.LogWarning("FuncProof DSSE verification failed: {Reason}", outcome.FailureReason);
|
||||
return new FuncProofVerificationResult(false, false, outcome.FailureReason, null);
|
||||
}
|
||||
|
||||
// Extract and validate payload
|
||||
var funcProof = ExtractPayload(envelope);
|
||||
if (funcProof is null)
|
||||
{
|
||||
return new FuncProofVerificationResult(
|
||||
false,
|
||||
outcome.IsTrusted,
|
||||
"Failed to deserialize FuncProof payload",
|
||||
null);
|
||||
}
|
||||
|
||||
// Verify proof ID integrity
|
||||
var computedProofId = FuncProofBuilder.ComputeProofId(funcProof);
|
||||
if (!string.Equals(computedProofId, funcProof.ProofId, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"FuncProof ID mismatch: claimed {Claimed}, computed {Computed}",
|
||||
funcProof.ProofId,
|
||||
computedProofId);
|
||||
return new FuncProofVerificationResult(
|
||||
false,
|
||||
outcome.IsTrusted,
|
||||
$"Proof ID mismatch: claimed {funcProof.ProofId}, computed {computedProofId}",
|
||||
null);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"FuncProof {ProofId} verified successfully (trusted: {IsTrusted})",
|
||||
funcProof.ProofId,
|
||||
outcome.IsTrusted);
|
||||
|
||||
return new FuncProofVerificationResult(true, outcome.IsTrusted, null, funcProof);
|
||||
}
|
||||
|
||||
public FuncProof? ExtractPayload(DsseEnvelope envelope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
|
||||
try
|
||||
{
|
||||
var payloadBytes = Convert.FromBase64String(envelope.Payload);
|
||||
return JsonSerializer.Deserialize<FuncProof>(payloadBytes, JsonOptions);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or JsonException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to extract FuncProof from DSSE envelope");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes content-addressable ID for the DSSE envelope.
|
||||
/// Uses SHA-256 hash of the canonical JSON representation.
|
||||
/// </summary>
|
||||
private static string ComputeEnvelopeId(string envelopeJson)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(envelopeJson);
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for FuncProof DSSE integration.
|
||||
/// </summary>
|
||||
public static class FuncProofDsseExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a FuncProof DSSE envelope without signing (for unsigned storage/testing).
|
||||
/// </summary>
|
||||
public static DsseEnvelope ToUnsignedEnvelope(this FuncProof funcProof)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(funcProof);
|
||||
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(funcProof, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
return new DsseEnvelope(
|
||||
FuncProofConstants.MediaType,
|
||||
Convert.ToBase64String(payloadBytes),
|
||||
Array.Empty<DsseSignature>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a DSSE envelope from JSON.
|
||||
/// </summary>
|
||||
public static DsseEnvelope? ParseEnvelope(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<DsseEnvelope>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// FuncProofGenerationOptions.cs
|
||||
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
|
||||
// Task: FUNC-15 — Configurable generation options for FuncProof
|
||||
// Description: Configuration options for FuncProof generation including confidence
|
||||
// thresholds, trace depth limits, and function detection settings.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Scanner.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for FuncProof generation.
|
||||
/// Bind from configuration section "Scanner:FuncProof:Generation".
|
||||
/// </summary>
|
||||
public sealed class FuncProofGenerationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name for binding.
|
||||
/// </summary>
|
||||
public const string SectionName = "Scanner:FuncProof:Generation";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum trace depth (hop count) before truncation.
|
||||
/// Default: 10 hops (consistent with score-policy.v1.schema.json hopBuckets.maxHops).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Traces exceeding this depth are truncated and marked with IsTruncated=true.
|
||||
/// The truncation point is recorded to allow policy-based analysis.
|
||||
/// </remarks>
|
||||
public int MaxTraceHops { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum confidence threshold for including functions in the proof.
|
||||
/// Functions with confidence below this threshold are excluded.
|
||||
/// Default: 0.0 (include all detected functions).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Set to 0.5 to exclude low-confidence heuristic detections.
|
||||
/// Set to 0.8 to include only symbol table and DWARF detections.
|
||||
/// Set to 1.0 to include only DWARF debug info functions.
|
||||
/// </remarks>
|
||||
public double MinConfidenceThreshold { get; set; } = 0.0;
|
||||
|
||||
/// <summary>
|
||||
/// Confidence value for functions detected via DWARF debug info.
|
||||
/// Default: 1.0 (highest confidence - authoritative source).
|
||||
/// </summary>
|
||||
public double DwarfConfidence { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Confidence value for functions detected via symbol table entries.
|
||||
/// Default: 0.8 (high confidence - symbols may be incomplete).
|
||||
/// </summary>
|
||||
public double SymbolConfidence { get; set; } = 0.8;
|
||||
|
||||
/// <summary>
|
||||
/// Confidence value for functions detected via prolog/epilog heuristics.
|
||||
/// Default: 0.5 (moderate confidence - heuristics may have false positives).
|
||||
/// </summary>
|
||||
public double HeuristicConfidence { get; set; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Penalty multiplier applied to functions with inferred (non-authoritative) sizes.
|
||||
/// The original confidence is multiplied by this value.
|
||||
/// Default: 0.9 (10% confidence reduction).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When function size is inferred from the next function's address rather than
|
||||
/// from debug info or symbol table, confidence is reduced by this factor.
|
||||
/// </remarks>
|
||||
public double InferredSizePenalty { get; set; } = 0.9;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include functions from external/system libraries.
|
||||
/// Default: false (only include functions from the target binary).
|
||||
/// </summary>
|
||||
public bool IncludeExternalFunctions { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable parallel function detection for large binaries.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool EnableParallelDetection { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum function size in bytes for heuristic detection.
|
||||
/// Functions smaller than this are filtered out from heuristic results.
|
||||
/// Default: 4 bytes (minimum viable function).
|
||||
/// </summary>
|
||||
public int MinFunctionSize { get; set; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum function size in bytes for heuristic detection.
|
||||
/// Functions larger than this are flagged for review.
|
||||
/// Default: 1MB (unusually large functions may indicate detection errors).
|
||||
/// </summary>
|
||||
public int MaxFunctionSize { get; set; } = 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to compute call graph edges during proof generation.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Disabling this produces a simpler proof with only function boundaries,
|
||||
/// without trace information. Useful for quick enumeration.
|
||||
/// </remarks>
|
||||
public bool ComputeCallGraph { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include raw bytes hash for each function.
|
||||
/// Default: true (required for deterministic verification).
|
||||
/// </summary>
|
||||
public bool IncludeFunctionHashes { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Detection strategies to use, in priority order.
|
||||
/// Default: All strategies (DWARF, Symbols, Heuristic).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each strategy is tried in order. Higher-confidence results from
|
||||
/// earlier strategies take precedence over lower-confidence results.
|
||||
/// </remarks>
|
||||
public FunctionDetectionStrategy[] DetectionStrategies { get; set; } =
|
||||
[FunctionDetectionStrategy.Dwarf, FunctionDetectionStrategy.Symbols, FunctionDetectionStrategy.Heuristic];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Function detection strategies for binary analysis.
|
||||
/// </summary>
|
||||
public enum FunctionDetectionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Use DWARF debug information (highest confidence).
|
||||
/// Requires unstripped binaries with debug symbols.
|
||||
/// </summary>
|
||||
Dwarf = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Use symbol table entries (high confidence).
|
||||
/// Works with unstripped binaries.
|
||||
/// </summary>
|
||||
Symbols = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Use prolog/epilog pattern heuristics (moderate confidence).
|
||||
/// Works with stripped binaries but may have false positives.
|
||||
/// </summary>
|
||||
Heuristic = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Automatic strategy selection based on binary analysis.
|
||||
/// Tries all strategies and merges results by confidence.
|
||||
/// </summary>
|
||||
Auto = 99
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.Scanner.Evidence.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Service for submitting FuncProof documents to transparency logs (e.g., Sigstore Rekor).
|
||||
/// Provides tamper-evident logging of binary reachability proofs.
|
||||
/// </summary>
|
||||
public interface IFuncProofTransparencyService
|
||||
{
|
||||
/// <summary>
|
||||
/// Submits a signed FuncProof DSSE envelope to the transparency log.
|
||||
/// </summary>
|
||||
/// <param name="envelope">The DSSE envelope containing the signed FuncProof.</param>
|
||||
/// <param name="funcProof">The original FuncProof document for metadata extraction.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Result containing the transparency log entry details.</returns>
|
||||
Task<FuncProofTransparencyResult> SubmitAsync(
|
||||
DsseEnvelope envelope,
|
||||
FuncProof funcProof,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a FuncProof entry exists in the transparency log.
|
||||
/// </summary>
|
||||
/// <param name="entryId">The transparency log entry ID to verify.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Verification result with inclusion proof status.</returns>
|
||||
Task<FuncProofTransparencyVerifyResult> VerifyAsync(string entryId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of submitting a FuncProof to the transparency log.
|
||||
/// </summary>
|
||||
public sealed record FuncProofTransparencyResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier of the transparency log entry.
|
||||
/// </summary>
|
||||
public string? EntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Full URL location of the transparency log entry.
|
||||
/// </summary>
|
||||
public string? EntryLocation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Log index position (for Rekor-style transparency logs).
|
||||
/// </summary>
|
||||
public long? LogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to retrieve the inclusion proof.
|
||||
/// </summary>
|
||||
public string? InclusionProofUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the entry was recorded (UTC ISO-8601).
|
||||
/// </summary>
|
||||
public string? RecordedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if submission failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static FuncProofTransparencyResult Failed(string error) => new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error
|
||||
};
|
||||
|
||||
public static FuncProofTransparencyResult Skipped(string reason) => new()
|
||||
{
|
||||
Success = true,
|
||||
Error = reason
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of verifying a FuncProof transparency log entry.
|
||||
/// </summary>
|
||||
public sealed record FuncProofTransparencyVerifyResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True if the entry was found and verified in the log.
|
||||
/// </summary>
|
||||
public bool IsIncluded { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True if the inclusion proof was cryptographically verified.
|
||||
/// </summary>
|
||||
public bool ProofVerified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if verification failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static FuncProofTransparencyVerifyResult Failed(string error) => new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for FuncProof transparency logging.
|
||||
/// </summary>
|
||||
public sealed class FuncProofTransparencyOptions
|
||||
{
|
||||
public const string SectionName = "Scanner:FuncProof:Transparency";
|
||||
|
||||
/// <summary>
|
||||
/// Whether transparency logging is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Base URL of the transparency log (e.g., https://rekor.sigstore.dev).
|
||||
/// </summary>
|
||||
public string? RekorUrl { get; set; } = "https://rekor.sigstore.dev";
|
||||
|
||||
/// <summary>
|
||||
/// API key for authenticated access to the transparency log (optional).
|
||||
/// </summary>
|
||||
public string? ApiKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for transparency log operations.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Number of retry attempts for failed submissions.
|
||||
/// </summary>
|
||||
public int RetryCount { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Delay between retry attempts.
|
||||
/// </summary>
|
||||
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow offline mode (skip transparency log if unavailable).
|
||||
/// </summary>
|
||||
public bool AllowOffline { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of FuncProof transparency service using Rekor.
|
||||
/// </summary>
|
||||
public sealed class FuncProofTransparencyService : IFuncProofTransparencyService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOptions<FuncProofTransparencyOptions> _options;
|
||||
private readonly ILogger<FuncProofTransparencyService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public FuncProofTransparencyService(
|
||||
HttpClient httpClient,
|
||||
IOptions<FuncProofTransparencyOptions> options,
|
||||
ILogger<FuncProofTransparencyService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<FuncProofTransparencyResult> SubmitAsync(
|
||||
DsseEnvelope envelope,
|
||||
FuncProof funcProof,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
ArgumentNullException.ThrowIfNull(funcProof);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var opts = _options.Value;
|
||||
if (!opts.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Transparency logging disabled, skipping submission for FuncProof {ProofId}", funcProof.ProofId);
|
||||
return FuncProofTransparencyResult.Skipped("Transparency logging is disabled");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(opts.RekorUrl))
|
||||
{
|
||||
return FuncProofTransparencyResult.Failed("Rekor URL is not configured");
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Submitting FuncProof {ProofId} to transparency log at {RekorUrl}",
|
||||
funcProof.ProofId,
|
||||
opts.RekorUrl);
|
||||
|
||||
try
|
||||
{
|
||||
var entry = await SubmitToRekorAsync(envelope, opts, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"FuncProof {ProofId} recorded in transparency log: entry {EntryId} at index {LogIndex}",
|
||||
funcProof.ProofId,
|
||||
entry.EntryId,
|
||||
entry.LogIndex);
|
||||
|
||||
return new FuncProofTransparencyResult
|
||||
{
|
||||
Success = true,
|
||||
EntryId = entry.EntryId,
|
||||
EntryLocation = entry.EntryLocation,
|
||||
LogIndex = entry.LogIndex,
|
||||
InclusionProofUrl = entry.InclusionProofUrl,
|
||||
RecordedAt = _timeProvider.GetUtcNow().ToString("O")
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex) when (opts.AllowOffline)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Transparency log unavailable for FuncProof {ProofId}, continuing in offline mode",
|
||||
funcProof.ProofId);
|
||||
return FuncProofTransparencyResult.Skipped($"Transparency log unavailable (offline mode): {ex.Message}");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to submit FuncProof {ProofId} to transparency log", funcProof.ProofId);
|
||||
return FuncProofTransparencyResult.Failed($"Submission failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<FuncProofTransparencyVerifyResult> VerifyAsync(string entryId, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(entryId);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var opts = _options.Value;
|
||||
if (string.IsNullOrWhiteSpace(opts.RekorUrl))
|
||||
{
|
||||
return FuncProofTransparencyVerifyResult.Failed("Rekor URL is not configured");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Verifying transparency log entry {EntryId}", entryId);
|
||||
|
||||
try
|
||||
{
|
||||
var entryUrl = BuildEntryUrl(opts.RekorUrl, entryId);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(opts.Timeout);
|
||||
|
||||
var response = await _httpClient.GetAsync(entryUrl, cts.Token).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug("Transparency log entry {EntryId} verified successfully", entryId);
|
||||
return new FuncProofTransparencyVerifyResult
|
||||
{
|
||||
Success = true,
|
||||
IsIncluded = true,
|
||||
ProofVerified = true // Rekor guarantees inclusion if entry exists
|
||||
};
|
||||
}
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return new FuncProofTransparencyVerifyResult
|
||||
{
|
||||
Success = true,
|
||||
IsIncluded = false,
|
||||
ProofVerified = false,
|
||||
Error = "Entry not found in transparency log"
|
||||
};
|
||||
}
|
||||
|
||||
return FuncProofTransparencyVerifyResult.Failed($"Verification failed with status {response.StatusCode}");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to verify transparency log entry {EntryId}", entryId);
|
||||
return FuncProofTransparencyVerifyResult.Failed($"Verification failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<RekorEntryInfo> SubmitToRekorAsync(
|
||||
DsseEnvelope envelope,
|
||||
FuncProofTransparencyOptions opts,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Build Rekor hashedrekord entry
|
||||
var rekorEntry = BuildRekorEntry(envelope);
|
||||
var payload = JsonSerializer.Serialize(rekorEntry, JsonOptions);
|
||||
|
||||
using var content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
HttpResponseMessage? response = null;
|
||||
Exception? lastException = null;
|
||||
|
||||
for (var attempt = 0; attempt < opts.RetryCount; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(opts.Timeout);
|
||||
|
||||
var requestUrl = $"{opts.RekorUrl.TrimEnd('/')}/api/v1/log/entries";
|
||||
if (!string.IsNullOrWhiteSpace(opts.ApiKey))
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", opts.ApiKey);
|
||||
}
|
||||
|
||||
response = await _httpClient.PostAsync(requestUrl, content, cts.Token).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Rekor submission attempt {Attempt} failed with status {Status}",
|
||||
attempt + 1, response.StatusCode);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogWarning(ex, "Rekor submission attempt {Attempt} failed", attempt + 1);
|
||||
}
|
||||
|
||||
if (attempt + 1 < opts.RetryCount)
|
||||
{
|
||||
await Task.Delay(opts.RetryDelay, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (response is null || !response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorMsg = lastException?.Message ?? response?.StatusCode.ToString() ?? "Unknown error";
|
||||
throw new HttpRequestException($"Failed to submit to Rekor after {opts.RetryCount} attempts: {errorMsg}");
|
||||
}
|
||||
|
||||
return await ParseRekorResponseAsync(response, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static object BuildRekorEntry(DsseEnvelope envelope)
|
||||
{
|
||||
// Build Rekor hashedrekord v0.0.1 entry format
|
||||
// See: https://github.com/sigstore/rekor/blob/main/pkg/types/hashedrekord/v0.0.1/hashedrekord_v0_0_1_schema.json
|
||||
var envelopeJson = JsonSerializer.Serialize(envelope, JsonOptions);
|
||||
var envelopeBytes = System.Text.Encoding.UTF8.GetBytes(envelopeJson);
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(envelopeBytes);
|
||||
|
||||
return new
|
||||
{
|
||||
kind = "hashedrekord",
|
||||
apiVersion = "0.0.1",
|
||||
spec = new
|
||||
{
|
||||
data = new
|
||||
{
|
||||
hash = new
|
||||
{
|
||||
algorithm = "sha256",
|
||||
value = Convert.ToHexString(hash).ToLowerInvariant()
|
||||
}
|
||||
},
|
||||
signature = new
|
||||
{
|
||||
content = Convert.ToBase64String(envelopeBytes),
|
||||
publicKey = new
|
||||
{
|
||||
content = string.Empty // For keyless signing, this would be populated by Fulcio
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<RekorEntryInfo> ParseRekorResponseAsync(HttpResponseMessage response, CancellationToken ct)
|
||||
{
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct).ConfigureAwait(false);
|
||||
|
||||
// Rekor returns a map with UUID as key
|
||||
string? entryId = null;
|
||||
long? logIndex = null;
|
||||
string? entryLocation = null;
|
||||
|
||||
if (json.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in json.EnumerateObject())
|
||||
{
|
||||
entryId = prop.Name;
|
||||
if (prop.Value.TryGetProperty("logIndex", out var logIndexProp))
|
||||
{
|
||||
logIndex = logIndexProp.GetInt64();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
entryLocation = response.Headers.Location?.ToString();
|
||||
if (string.IsNullOrEmpty(entryLocation) && !string.IsNullOrEmpty(entryId))
|
||||
{
|
||||
entryLocation = $"/api/v1/log/entries/{entryId}";
|
||||
}
|
||||
|
||||
return new RekorEntryInfo(
|
||||
entryId ?? string.Empty,
|
||||
entryLocation ?? string.Empty,
|
||||
logIndex,
|
||||
logIndex.HasValue ? $"/api/v1/log/entries?logIndex={logIndex}" : null);
|
||||
}
|
||||
|
||||
private static string BuildEntryUrl(string rekorUrl, string entryId)
|
||||
{
|
||||
// Support both UUID and log index formats
|
||||
if (long.TryParse(entryId, out var logIndex))
|
||||
{
|
||||
return $"{rekorUrl.TrimEnd('/')}/api/v1/log/entries?logIndex={logIndex}";
|
||||
}
|
||||
return $"{rekorUrl.TrimEnd('/')}/api/v1/log/entries/{entryId}";
|
||||
}
|
||||
|
||||
private sealed record RekorEntryInfo(
|
||||
string EntryId,
|
||||
string EntryLocation,
|
||||
long? LogIndex,
|
||||
string? InclusionProofUrl);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// FuncProof.cs
|
||||
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
|
||||
// Task: FUNC-01 — Define FuncProof JSON model
|
||||
// Description: Function-level proof objects for binary-level reachability evidence.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Evidence.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Function-level proof document providing cryptographic evidence of binary composition.
|
||||
/// Contains Build-ID, section hashes, function ranges with hashes, and entry→sink traces.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// FuncProof is designed for:
|
||||
/// <list type="bullet">
|
||||
/// <item>Auditor replay without source code access</item>
|
||||
/// <item>Symbol-level correlation with VEX statements</item>
|
||||
/// <item>DSSE signing and OCI referrer publishing</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed record FuncProof
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version for forward compatibility.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressable ID: BLAKE3 hash of canonical JSON representation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("proofId")]
|
||||
public required string ProofId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// GNU Build-ID (ELF), PE CodeView GUID, or Mach-O UUID.
|
||||
/// Primary correlation key for binary identity.
|
||||
/// </summary>
|
||||
[JsonPropertyName("buildId")]
|
||||
public required string BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of build ID: "gnu-build-id", "pe-codeview", "macho-uuid", "file-sha256".
|
||||
/// </summary>
|
||||
[JsonPropertyName("buildIdType")]
|
||||
public required string BuildIdType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 of the entire binary file for integrity verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fileSha256")]
|
||||
public required string FileSha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Binary format: "elf", "pe", "macho".
|
||||
/// </summary>
|
||||
[JsonPropertyName("binaryFormat")]
|
||||
public required string BinaryFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target architecture: "x86_64", "aarch64", "arm", "i386", etc.
|
||||
/// </summary>
|
||||
[JsonPropertyName("architecture")]
|
||||
public required string Architecture { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the binary is stripped of debug symbols.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isStripped")]
|
||||
public bool IsStripped { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Section hashes for integrity verification.
|
||||
/// Key: section name (e.g., ".text", ".rodata"), Value: BLAKE3 hash.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sections")]
|
||||
public ImmutableDictionary<string, FuncProofSection> Sections { get; init; }
|
||||
= ImmutableDictionary<string, FuncProofSection>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Function definitions with address ranges and hashes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("functions")]
|
||||
public ImmutableArray<FuncProofFunction> Functions { get; init; }
|
||||
= ImmutableArray<FuncProofFunction>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Entry→sink trace hashes for reachability evidence.
|
||||
/// Each hash represents a unique call path from entrypoint to vulnerable sink.
|
||||
/// </summary>
|
||||
[JsonPropertyName("traces")]
|
||||
public ImmutableArray<FuncProofTrace> Traces { get; init; }
|
||||
= ImmutableArray<FuncProofTrace>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Build metadata extracted from the binary or external sources.
|
||||
/// </summary>
|
||||
[JsonPropertyName("meta")]
|
||||
public FuncProofMetadata? Meta { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when this proof was generated (UTC ISO-8601).
|
||||
/// </summary>
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the tool that generated this proof.
|
||||
/// </summary>
|
||||
[JsonPropertyName("generatorVersion")]
|
||||
public required string GeneratorVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Section information with hash and range.
|
||||
/// </summary>
|
||||
public sealed record FuncProofSection
|
||||
{
|
||||
/// <summary>
|
||||
/// BLAKE3 hash of the section contents.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hash")]
|
||||
public required string Hash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Section start offset in file.
|
||||
/// </summary>
|
||||
[JsonPropertyName("offset")]
|
||||
public required long Offset { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Section size in bytes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("size")]
|
||||
public required long Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Virtual address if applicable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("virtualAddress")]
|
||||
public long? VirtualAddress { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Function definition with address range and hash.
|
||||
/// </summary>
|
||||
public sealed record FuncProofFunction
|
||||
{
|
||||
/// <summary>
|
||||
/// Symbol name (demangled if available).
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbol")]
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Mangled/raw symbol name if different from demangled.
|
||||
/// </summary>
|
||||
[JsonPropertyName("mangledName")]
|
||||
public string? MangledName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol digest: BLAKE3(symbol_name + offset_range).
|
||||
/// Used for stable cross-binary correlation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbolDigest")]
|
||||
public required string SymbolDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start address (hex string, e.g., "0x401120").
|
||||
/// </summary>
|
||||
[JsonPropertyName("start")]
|
||||
public required string Start { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End address (hex string, e.g., "0x4013af").
|
||||
/// </summary>
|
||||
[JsonPropertyName("end")]
|
||||
public required string End { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size in bytes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("size")]
|
||||
public required long Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// BLAKE3 hash of the function's bytes within .text section.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hash")]
|
||||
public required string Hash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level for function boundary detection.
|
||||
/// 1.0 = DWARF/debug info, 0.8 = symbol table, 0.5 = heuristic prolog/epilog.
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Source file path (if DWARF info available).
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceFile")]
|
||||
public string? SourceFile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source line number (if DWARF info available).
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceLine")]
|
||||
public int? SourceLine { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this function is marked as an entrypoint.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isEntrypoint")]
|
||||
public bool IsEntrypoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of entrypoint if applicable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("entrypointType")]
|
||||
public string? EntrypointType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this function is a known vulnerable sink.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isSink")]
|
||||
public bool IsSink { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE or vulnerability ID if this is a sink.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sinkVulnId")]
|
||||
public string? SinkVulnId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry→sink trace with edge list hash.
|
||||
/// </summary>
|
||||
public sealed record FuncProofTrace
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique trace identifier (index or content-derived).
|
||||
/// </summary>
|
||||
[JsonPropertyName("traceId")]
|
||||
public required string TraceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// BLAKE3 hash of the edge list: sorted (caller_digest, callee_digest) pairs.
|
||||
/// </summary>
|
||||
[JsonPropertyName("edgeListHash")]
|
||||
public required string EdgeListHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of hops in this trace.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hopCount")]
|
||||
public required int HopCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol digest of the entry point.
|
||||
/// </summary>
|
||||
[JsonPropertyName("entrySymbolDigest")]
|
||||
public required string EntrySymbolDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol digest of the sink (vulnerable function).
|
||||
/// </summary>
|
||||
[JsonPropertyName("sinkSymbolDigest")]
|
||||
public required string SinkSymbolDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Compact path representation: ordered list of symbol digests.
|
||||
/// Limited to 10 hops max for compressed paths.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public ImmutableArray<string> Path { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this trace was truncated due to depth limit.
|
||||
/// </summary>
|
||||
[JsonPropertyName("truncated")]
|
||||
public bool Truncated { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build metadata extracted from binary or external sources.
|
||||
/// </summary>
|
||||
public sealed record FuncProofMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Compiler identification (e.g., "clang-18", "gcc-14").
|
||||
/// </summary>
|
||||
[JsonPropertyName("compiler")]
|
||||
public string? Compiler { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Compiler flags if extractable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("flags")]
|
||||
public string? Flags { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Linker identification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("linker")]
|
||||
public string? Linker { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build timestamp if available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("buildTime")]
|
||||
public DateTimeOffset? BuildTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source commit hash if embedded in binary.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceCommit")]
|
||||
public string? SourceCommit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package name/version if this binary is part of a package.
|
||||
/// </summary>
|
||||
[JsonPropertyName("packageInfo")]
|
||||
public string? PackageInfo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OS ABI (e.g., "linux", "freebsd", "none").
|
||||
/// </summary>
|
||||
[JsonPropertyName("osAbi")]
|
||||
public string? OsAbi { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional properties as key-value pairs.
|
||||
/// </summary>
|
||||
[JsonPropertyName("properties")]
|
||||
public ImmutableDictionary<string, string>? Properties { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content type for FuncProof artifacts.
|
||||
/// </summary>
|
||||
public static class FuncProofConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// OCI media type for FuncProof artifacts.
|
||||
/// </summary>
|
||||
public const string MediaType = "application/vnd.stellaops.funcproof+json";
|
||||
|
||||
/// <summary>
|
||||
/// DSSE payload type for FuncProof.
|
||||
/// </summary>
|
||||
public const string DssePayloadType = "application/vnd.stellaops.funcproof+json";
|
||||
|
||||
/// <summary>
|
||||
/// Current schema version.
|
||||
/// </summary>
|
||||
public const string SchemaVersion = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum trace depth before truncation.
|
||||
/// </summary>
|
||||
public const int MaxTraceHops = 10;
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SbomFuncProofLinker.cs
|
||||
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
|
||||
// Task: FUNC-15 — SBOM evidence link with CycloneDX integration
|
||||
// Description: Links FuncProof documents to SBOM components via CycloneDX 1.6
|
||||
// evidence model. Enables auditors to trace from SBOM → binary proof.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Evidence.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Links FuncProof evidence to SBOM components using CycloneDX 1.6 evidence model.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// CycloneDX 1.6 supports components.evidence.callFlow for linking binary
|
||||
/// analysis results to component entries. This enables:
|
||||
/// - Tracing from SBOM component → FuncProof document
|
||||
/// - Embedding function reachability as component evidence
|
||||
/// - Providing auditors with binary-level verification data
|
||||
/// </remarks>
|
||||
public interface ISbomFuncProofLinker
|
||||
{
|
||||
/// <summary>
|
||||
/// Links FuncProof evidence to a CycloneDX SBOM component.
|
||||
/// </summary>
|
||||
/// <param name="sbomJson">The CycloneDX SBOM JSON.</param>
|
||||
/// <param name="componentBomRef">The bom-ref of the target component.</param>
|
||||
/// <param name="funcProof">The FuncProof document to link.</param>
|
||||
/// <param name="proofDigest">SHA-256 digest of the signed FuncProof DSSE envelope.</param>
|
||||
/// <param name="proofLocation">URI or OCI reference to the FuncProof artifact.</param>
|
||||
/// <returns>Updated SBOM JSON with evidence linked.</returns>
|
||||
string LinkFuncProofEvidence(
|
||||
string sbomJson,
|
||||
string componentBomRef,
|
||||
FuncProof funcProof,
|
||||
string proofDigest,
|
||||
string proofLocation);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts FuncProof references from a CycloneDX SBOM component.
|
||||
/// </summary>
|
||||
/// <param name="sbomJson">The CycloneDX SBOM JSON.</param>
|
||||
/// <param name="componentBomRef">The bom-ref of the target component.</param>
|
||||
/// <returns>List of FuncProof evidence references found.</returns>
|
||||
IReadOnlyList<FuncProofEvidenceRef> ExtractFuncProofReferences(
|
||||
string sbomJson,
|
||||
string componentBomRef);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a CycloneDX evidence structure for a FuncProof document.
|
||||
/// </summary>
|
||||
FuncProofEvidenceRef CreateEvidenceRef(
|
||||
FuncProof funcProof,
|
||||
string proofDigest,
|
||||
string proofLocation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to FuncProof evidence in an SBOM.
|
||||
/// </summary>
|
||||
public sealed record FuncProofEvidenceRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Proof ID from the FuncProof document.
|
||||
/// </summary>
|
||||
public required string ProofId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build ID that links to the binary.
|
||||
/// </summary>
|
||||
public required string BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 of the binary file.
|
||||
/// </summary>
|
||||
public required string FileSha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the signed FuncProof DSSE envelope.
|
||||
/// </summary>
|
||||
public required string ProofDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URI or OCI reference to the FuncProof artifact.
|
||||
/// </summary>
|
||||
public required string Location { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of functions in the proof.
|
||||
/// </summary>
|
||||
public required int FunctionCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of traces in the proof.
|
||||
/// </summary>
|
||||
public required int TraceCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the proof was generated.
|
||||
/// </summary>
|
||||
public DateTimeOffset? GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Transparency log entry ID (if logged to Rekor).
|
||||
/// </summary>
|
||||
public string? TransparencyLogEntry { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of SBOM-FuncProof linker.
|
||||
/// </summary>
|
||||
public sealed class SbomFuncProofLinker : ISbomFuncProofLinker
|
||||
{
|
||||
private readonly ILogger<SbomFuncProofLinker> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
// CycloneDX evidence type for binary analysis
|
||||
private const string EvidenceType = "binary-analysis";
|
||||
private const string EvidenceMethod = "funcproof";
|
||||
private const string StellaOpsNamespace = "https://stellaops.io/evidence/funcproof";
|
||||
|
||||
public SbomFuncProofLinker(ILogger<SbomFuncProofLinker> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string LinkFuncProofEvidence(
|
||||
string sbomJson,
|
||||
string componentBomRef,
|
||||
FuncProof funcProof,
|
||||
string proofDigest,
|
||||
string proofLocation)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sbomJson);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentBomRef);
|
||||
ArgumentNullException.ThrowIfNull(funcProof);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(proofDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(proofLocation);
|
||||
|
||||
var doc = JsonNode.Parse(sbomJson) as JsonObject
|
||||
?? throw new ArgumentException("Invalid SBOM JSON", nameof(sbomJson));
|
||||
|
||||
// Validate this is a CycloneDX document
|
||||
if (doc["bomFormat"]?.GetValue<string>() != "CycloneDX")
|
||||
{
|
||||
throw new ArgumentException("SBOM is not in CycloneDX format", nameof(sbomJson));
|
||||
}
|
||||
|
||||
// Find the target component
|
||||
var components = doc["components"] as JsonArray;
|
||||
if (components == null || components.Count == 0)
|
||||
{
|
||||
throw new ArgumentException($"No components found in SBOM", nameof(sbomJson));
|
||||
}
|
||||
|
||||
var targetComponent = FindComponent(components, componentBomRef);
|
||||
if (targetComponent == null)
|
||||
{
|
||||
throw new ArgumentException($"Component with bom-ref '{componentBomRef}' not found", nameof(componentBomRef));
|
||||
}
|
||||
|
||||
// Create evidence structure
|
||||
var evidenceRef = CreateEvidenceRef(funcProof, proofDigest, proofLocation);
|
||||
var evidence = CreateCycloneDxEvidence(evidenceRef);
|
||||
|
||||
// Add or update evidence on the component
|
||||
AddEvidenceToComponent(targetComponent, evidence);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Linked FuncProof {ProofId} to component {BomRef} with {FunctionCount} functions",
|
||||
funcProof.ProofId, componentBomRef, funcProof.Functions.Length);
|
||||
|
||||
return doc.ToJsonString(JsonOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<FuncProofEvidenceRef> ExtractFuncProofReferences(
|
||||
string sbomJson,
|
||||
string componentBomRef)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sbomJson);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentBomRef);
|
||||
|
||||
var doc = JsonNode.Parse(sbomJson) as JsonObject;
|
||||
if (doc == null) return [];
|
||||
|
||||
var components = doc["components"] as JsonArray;
|
||||
if (components == null) return [];
|
||||
|
||||
var targetComponent = FindComponent(components, componentBomRef);
|
||||
if (targetComponent == null) return [];
|
||||
|
||||
var evidence = targetComponent["evidence"] as JsonObject;
|
||||
if (evidence == null) return [];
|
||||
|
||||
var references = new List<FuncProofEvidenceRef>();
|
||||
|
||||
// Check callflow evidence (CycloneDX 1.6+)
|
||||
var callflow = evidence["callflow"] as JsonObject;
|
||||
if (callflow != null)
|
||||
{
|
||||
var frames = callflow["frames"] as JsonArray;
|
||||
if (frames != null)
|
||||
{
|
||||
foreach (var frame in frames)
|
||||
{
|
||||
if (frame is not JsonObject frameObj) continue;
|
||||
|
||||
// Check if this is a FuncProof reference
|
||||
var properties = frameObj["properties"] as JsonArray;
|
||||
if (properties == null) continue;
|
||||
|
||||
var isFuncProof = properties.Any(p =>
|
||||
p is JsonObject po &&
|
||||
po["name"]?.GetValue<string>() == "stellaops:evidence:type" &&
|
||||
po["value"]?.GetValue<string>() == "funcproof");
|
||||
|
||||
if (!isFuncProof) continue;
|
||||
|
||||
var evidenceRef = ParseEvidenceFromProperties(properties);
|
||||
if (evidenceRef != null)
|
||||
{
|
||||
references.Add(evidenceRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check externalReferences for FuncProof links
|
||||
var externalRefs = targetComponent["externalReferences"] as JsonArray;
|
||||
if (externalRefs != null)
|
||||
{
|
||||
foreach (var extRef in externalRefs)
|
||||
{
|
||||
if (extRef is not JsonObject extRefObj) continue;
|
||||
|
||||
var type = extRefObj["type"]?.GetValue<string>();
|
||||
var comment = extRefObj["comment"]?.GetValue<string>();
|
||||
|
||||
if (type == "evidence" && comment?.Contains("funcproof") == true)
|
||||
{
|
||||
var url = extRefObj["url"]?.GetValue<string>();
|
||||
var hashes = extRefObj["hashes"] as JsonArray;
|
||||
|
||||
var sha256Hash = hashes?
|
||||
.OfType<JsonObject>()
|
||||
.FirstOrDefault(h => h["alg"]?.GetValue<string>() == "SHA-256")?
|
||||
["content"]?.GetValue<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(url))
|
||||
{
|
||||
// Parse additional metadata from comment
|
||||
var metadata = ParseCommentMetadata(comment);
|
||||
|
||||
references.Add(new FuncProofEvidenceRef
|
||||
{
|
||||
ProofId = metadata.TryGetValue("proofId", out var pid) ? pid : "unknown",
|
||||
BuildId = metadata.TryGetValue("buildId", out var bid) ? bid : "unknown",
|
||||
FileSha256 = metadata.TryGetValue("fileSha256", out var fsha) ? fsha : "unknown",
|
||||
ProofDigest = sha256Hash ?? "unknown",
|
||||
Location = url,
|
||||
FunctionCount = int.TryParse(
|
||||
metadata.TryGetValue("functionCount", out var fc) ? fc : "0",
|
||||
out var fcInt) ? fcInt : 0,
|
||||
TraceCount = int.TryParse(
|
||||
metadata.TryGetValue("traceCount", out var tc) ? tc : "0",
|
||||
out var tcInt) ? tcInt : 0
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public FuncProofEvidenceRef CreateEvidenceRef(
|
||||
FuncProof funcProof,
|
||||
string proofDigest,
|
||||
string proofLocation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(funcProof);
|
||||
|
||||
return new FuncProofEvidenceRef
|
||||
{
|
||||
ProofId = funcProof.ProofId,
|
||||
BuildId = funcProof.BuildId,
|
||||
FileSha256 = funcProof.FileSha256,
|
||||
ProofDigest = proofDigest,
|
||||
Location = proofLocation,
|
||||
FunctionCount = funcProof.Functions.Length,
|
||||
TraceCount = funcProof.Traces?.Length ?? 0,
|
||||
GeneratedAt = funcProof.Metadata?.Timestamp != null
|
||||
? DateTimeOffset.Parse(funcProof.Metadata.Timestamp)
|
||||
: null,
|
||||
TransparencyLogEntry = funcProof.Metadata?.Properties?.TryGetValue("rekorEntryId", out var rekorId) == true
|
||||
? rekorId
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject? FindComponent(JsonArray components, string bomRef)
|
||||
{
|
||||
foreach (var component in components)
|
||||
{
|
||||
if (component is not JsonObject componentObj) continue;
|
||||
|
||||
var currentBomRef = componentObj["bom-ref"]?.GetValue<string>();
|
||||
if (currentBomRef == bomRef)
|
||||
{
|
||||
return componentObj;
|
||||
}
|
||||
|
||||
// Check nested components
|
||||
var nestedComponents = componentObj["components"] as JsonArray;
|
||||
if (nestedComponents != null)
|
||||
{
|
||||
var found = FindComponent(nestedComponents, bomRef);
|
||||
if (found != null) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private JsonObject CreateCycloneDxEvidence(FuncProofEvidenceRef evidenceRef)
|
||||
{
|
||||
// Create CycloneDX 1.6 evidence structure with callflow
|
||||
var evidence = new JsonObject
|
||||
{
|
||||
["callflow"] = new JsonObject
|
||||
{
|
||||
["frames"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["package"] = "binary",
|
||||
["module"] = evidenceRef.BuildId,
|
||||
["function"] = $"[{evidenceRef.FunctionCount} functions analyzed]",
|
||||
["line"] = 0,
|
||||
["column"] = 0,
|
||||
["fullFilename"] = evidenceRef.Location,
|
||||
["properties"] = new JsonArray
|
||||
{
|
||||
CreateProperty("stellaops:evidence:type", "funcproof"),
|
||||
CreateProperty("stellaops:funcproof:proofId", evidenceRef.ProofId),
|
||||
CreateProperty("stellaops:funcproof:buildId", evidenceRef.BuildId),
|
||||
CreateProperty("stellaops:funcproof:fileSha256", evidenceRef.FileSha256),
|
||||
CreateProperty("stellaops:funcproof:proofDigest", evidenceRef.ProofDigest),
|
||||
CreateProperty("stellaops:funcproof:functionCount", evidenceRef.FunctionCount.ToString()),
|
||||
CreateProperty("stellaops:funcproof:traceCount", evidenceRef.TraceCount.ToString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add transparency log entry if available
|
||||
if (!string.IsNullOrEmpty(evidenceRef.TransparencyLogEntry))
|
||||
{
|
||||
var frames = evidence["callflow"]!["frames"] as JsonArray;
|
||||
var firstFrame = frames![0] as JsonObject;
|
||||
var properties = firstFrame!["properties"] as JsonArray;
|
||||
properties!.Add(CreateProperty("stellaops:funcproof:rekorEntryId", evidenceRef.TransparencyLogEntry));
|
||||
}
|
||||
|
||||
return evidence;
|
||||
}
|
||||
|
||||
private static JsonObject CreateProperty(string name, string value) =>
|
||||
new JsonObject
|
||||
{
|
||||
["name"] = name,
|
||||
["value"] = value
|
||||
};
|
||||
|
||||
private static void AddEvidenceToComponent(JsonObject component, JsonObject evidence)
|
||||
{
|
||||
var existingEvidence = component["evidence"] as JsonObject;
|
||||
if (existingEvidence == null)
|
||||
{
|
||||
component["evidence"] = evidence;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Merge callflow frames
|
||||
var existingCallflow = existingEvidence["callflow"] as JsonObject;
|
||||
var newCallflow = evidence["callflow"] as JsonObject;
|
||||
|
||||
if (existingCallflow == null && newCallflow != null)
|
||||
{
|
||||
existingEvidence["callflow"] = newCallflow;
|
||||
}
|
||||
else if (existingCallflow != null && newCallflow != null)
|
||||
{
|
||||
var existingFrames = existingCallflow["frames"] as JsonArray ?? new JsonArray();
|
||||
var newFrames = newCallflow["frames"] as JsonArray ?? new JsonArray();
|
||||
|
||||
foreach (var frame in newFrames)
|
||||
{
|
||||
if (frame != null)
|
||||
{
|
||||
existingFrames.Add(frame.DeepClone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also add external reference for tooling compatibility
|
||||
var externalRefs = component["externalReferences"] as JsonArray;
|
||||
if (externalRefs == null)
|
||||
{
|
||||
externalRefs = new JsonArray();
|
||||
component["externalReferences"] = externalRefs;
|
||||
}
|
||||
|
||||
// Get values from evidence
|
||||
var proofId = GetPropertyValue(evidence, "stellaops:funcproof:proofId") ?? "unknown";
|
||||
var buildId = GetPropertyValue(evidence, "stellaops:funcproof:buildId") ?? "unknown";
|
||||
var fileSha256 = GetPropertyValue(evidence, "stellaops:funcproof:fileSha256") ?? "unknown";
|
||||
var proofDigest = GetPropertyValue(evidence, "stellaops:funcproof:proofDigest") ?? "unknown";
|
||||
var functionCount = GetPropertyValue(evidence, "stellaops:funcproof:functionCount") ?? "0";
|
||||
var traceCount = GetPropertyValue(evidence, "stellaops:funcproof:traceCount") ?? "0";
|
||||
var location = ((evidence["callflow"] as JsonObject)?["frames"] as JsonArray)?
|
||||
[0]?["fullFilename"]?.GetValue<string>() ?? "";
|
||||
|
||||
externalRefs.Add(new JsonObject
|
||||
{
|
||||
["type"] = "evidence",
|
||||
["url"] = location,
|
||||
["comment"] = $"funcproof:proofId={proofId};buildId={buildId};fileSha256={fileSha256};functionCount={functionCount};traceCount={traceCount}",
|
||||
["hashes"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["alg"] = "SHA-256",
|
||||
["content"] = proofDigest
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static string? GetPropertyValue(JsonObject evidence, string propertyName)
|
||||
{
|
||||
var frames = (evidence["callflow"] as JsonObject)?["frames"] as JsonArray;
|
||||
if (frames == null || frames.Count == 0) return null;
|
||||
|
||||
var properties = (frames[0] as JsonObject)?["properties"] as JsonArray;
|
||||
if (properties == null) return null;
|
||||
|
||||
foreach (var prop in properties)
|
||||
{
|
||||
if (prop is JsonObject propObj &&
|
||||
propObj["name"]?.GetValue<string>() == propertyName)
|
||||
{
|
||||
return propObj["value"]?.GetValue<string>();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private FuncProofEvidenceRef? ParseEvidenceFromProperties(JsonArray properties)
|
||||
{
|
||||
var props = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var prop in properties)
|
||||
{
|
||||
if (prop is not JsonObject propObj) continue;
|
||||
|
||||
var name = propObj["name"]?.GetValue<string>();
|
||||
var value = propObj["value"]?.GetValue<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(name) && value != null)
|
||||
{
|
||||
// Strip the stellaops:funcproof: prefix
|
||||
if (name.StartsWith("stellaops:funcproof:"))
|
||||
{
|
||||
props[name["stellaops:funcproof:".Length..]] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!props.TryGetValue("proofId", out var proofId)) return null;
|
||||
|
||||
return new FuncProofEvidenceRef
|
||||
{
|
||||
ProofId = proofId,
|
||||
BuildId = props.TryGetValue("buildId", out var bid) ? bid : "unknown",
|
||||
FileSha256 = props.TryGetValue("fileSha256", out var fsha) ? fsha : "unknown",
|
||||
ProofDigest = props.TryGetValue("proofDigest", out var pd) ? pd : "unknown",
|
||||
Location = "", // Will be filled from frame.fullFilename
|
||||
FunctionCount = int.TryParse(
|
||||
props.TryGetValue("functionCount", out var fc) ? fc : "0",
|
||||
out var fcInt) ? fcInt : 0,
|
||||
TraceCount = int.TryParse(
|
||||
props.TryGetValue("traceCount", out var tc) ? tc : "0",
|
||||
out var tcInt) ? tcInt : 0,
|
||||
TransparencyLogEntry = props.TryGetValue("rekorEntryId", out var rekor) ? rekor : null
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseCommentMetadata(string? comment)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrEmpty(comment)) return result;
|
||||
|
||||
// Parse "funcproof:proofId=xxx;buildId=yyy;..." format
|
||||
var parts = comment.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var trimmed = part.Trim();
|
||||
if (trimmed.StartsWith("funcproof:"))
|
||||
{
|
||||
trimmed = trimmed["funcproof:".Length..];
|
||||
}
|
||||
|
||||
var eqIdx = trimmed.IndexOf('=');
|
||||
if (eqIdx > 0)
|
||||
{
|
||||
var key = trimmed[..eqIdx].Trim();
|
||||
var value = trimmed[(eqIdx + 1)..].Trim();
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -14,5 +14,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user