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:
StellaOps Bot
2025-12-26 15:17:15 +02:00
parent 7792749bb4
commit 907783f625
354 changed files with 79727 additions and 1346 deletions

View File

@@ -1,13 +1,31 @@
// -----------------------------------------------------------------------------
// CallGraphServiceCollectionExtensions.cs
// Sprint: SPRINT_20251226_005_SCANNER_reachability_extractors (REACH-REG-01)
// Description: DI registration for all call graph extractors.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Scanner.CallGraph.Caching;
using StellaOps.Scanner.CallGraph.DotNet;
using StellaOps.Scanner.CallGraph.Go;
using StellaOps.Scanner.CallGraph.Java;
using StellaOps.Scanner.CallGraph.Node;
using StellaOps.Scanner.CallGraph.Python;
namespace StellaOps.Scanner.CallGraph.DependencyInjection;
/// <summary>
/// Extension methods for registering call graph services in dependency injection.
/// </summary>
public static class CallGraphServiceCollectionExtensions
{
/// <summary>
/// Adds all call graph extraction and analysis services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration instance.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddCallGraphServices(this IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
@@ -15,9 +33,18 @@ public static class CallGraphServiceCollectionExtensions
services.Configure<CallGraphCacheConfig>(configuration.GetSection("CallGraph:Cache"));
services.AddSingleton<ICallGraphExtractor, DotNetCallGraphExtractor>();
services.AddSingleton<ICallGraphExtractor, NodeCallGraphExtractor>();
// Register all language-specific call graph extractors
// Each extractor implements ICallGraphExtractor and is keyed by Language property
services.AddSingleton<ICallGraphExtractor, DotNetCallGraphExtractor>(); // .NET/C# via Roslyn
services.AddSingleton<ICallGraphExtractor, JavaCallGraphExtractor>(); // Java via ASM bytecode parsing
services.AddSingleton<ICallGraphExtractor, NodeCallGraphExtractor>(); // Node.js/JavaScript via Babel
services.AddSingleton<ICallGraphExtractor, PythonCallGraphExtractor>(); // Python via AST analysis
services.AddSingleton<ICallGraphExtractor, GoCallGraphExtractor>(); // Go via SSA analysis
// Register the extractor registry for language-based lookup
services.AddSingleton<ICallGraphExtractorRegistry, CallGraphExtractorRegistry>();
// Core analysis services
services.AddSingleton<ReachabilityAnalyzer>();
services.AddSingleton<ICallGraphCacheService, ValkeyCallGraphCacheService>();

View File

@@ -0,0 +1,520 @@
// -----------------------------------------------------------------------------
// FunctionBoundaryDetector.cs
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
// Tasks: FUNC-03, FUNC-04 — Function boundary detection using DWARF/symbol table and heuristics
// Description: Detects function boundaries from binary analysis.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Evidence;
namespace StellaOps.Scanner.CallGraph.Binary;
/// <summary>
/// Detects function boundaries in native binaries using multiple strategies:
/// 1. DWARF debug info (highest confidence)
/// 2. Symbol table entries (high confidence)
/// 3. Prolog/epilog heuristics for stripped binaries (lower confidence)
/// </summary>
public sealed class FunctionBoundaryDetector
{
private readonly ILogger<FunctionBoundaryDetector> _logger;
private readonly DwarfDebugReader _dwarfReader;
private readonly FuncProofGenerationOptions _options;
// Common function prologs by architecture
private static readonly byte[][] X86_64Prologs =
[
[0x55, 0x48, 0x89, 0xe5], // push rbp; mov rbp, rsp
[0x55, 0x48, 0x8b, 0xec], // push rbp; mov rbp, rsp (alternate)
[0x41, 0x57], // push r15
[0x41, 0x56], // push r14
[0x41, 0x55], // push r13
[0x41, 0x54], // push r12
[0x53], // push rbx
[0x55], // push rbp
];
private static readonly byte[][] Arm64Prologs =
[
[0xfd, 0x7b, 0xbf, 0xa9], // stp x29, x30, [sp, #-16]!
[0xfd, 0x7b, 0xbe, 0xa9], // stp x29, x30, [sp, #-32]!
[0xfd, 0x03, 0x00, 0x91], // mov x29, sp
];
// Common function epilogs
private static readonly byte[][] X86_64Epilogs =
[
[0xc3], // ret
[0xc2], // ret imm16
[0x5d, 0xc3], // pop rbp; ret
[0xc9, 0xc3], // leave; ret
];
private static readonly byte[][] Arm64Epilogs =
[
[0xc0, 0x03, 0x5f, 0xd6], // ret
[0xfd, 0x7b, 0xc1, 0xa8], // ldp x29, x30, [sp], #16
];
public FunctionBoundaryDetector(
ILogger<FunctionBoundaryDetector> logger,
DwarfDebugReader dwarfReader,
IOptions<FuncProofGenerationOptions>? options = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_dwarfReader = dwarfReader ?? throw new ArgumentNullException(nameof(dwarfReader));
_options = options?.Value ?? new FuncProofGenerationOptions();
}
/// <summary>
/// Detects function boundaries using all available strategies.
/// </summary>
public async Task<IReadOnlyList<DetectedFunction>> DetectAsync(
string binaryPath,
BinaryFormat format,
BinaryArchitecture architecture,
CancellationToken ct = default)
{
var functions = new List<DetectedFunction>();
// Strategy 1: Try DWARF debug info first (highest confidence)
if (format == BinaryFormat.Elf)
{
try
{
var dwarfInfo = await _dwarfReader.ReadAsync(binaryPath, ct);
if (dwarfInfo.Functions.Count > 0)
{
_logger.LogDebug("Found {Count} functions via DWARF", dwarfInfo.Functions.Count);
foreach (var func in dwarfInfo.Functions)
{
functions.Add(new DetectedFunction
{
Symbol = func.Name,
MangledName = func.LinkageName,
StartAddress = func.LowPc,
EndAddress = func.HighPc,
Confidence = _options.DwarfConfidence,
DetectionMethod = FunctionDetectionMethod.Dwarf,
SourceFile = func.DeclFile,
SourceLine = func.DeclLine
});
}
return functions;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "DWARF parsing failed, falling back to symbol table");
}
}
// Strategy 2: Symbol table (high confidence)
var symbols = await ExtractSymbolTableAsync(binaryPath, format, ct);
if (symbols.Count > 0)
{
_logger.LogDebug("Found {Count} functions via symbol table", symbols.Count);
functions.AddRange(symbols.Select(s => new DetectedFunction
{
Symbol = s.Name,
MangledName = s.MangledName,
StartAddress = s.Address,
EndAddress = s.Address + s.Size,
Confidence = _options.SymbolConfidence,
DetectionMethod = FunctionDetectionMethod.SymbolTable
}));
// If we have symbols but no sizes, try to infer from gaps
InferFunctionSizes(functions);
return functions;
}
// Strategy 3: Heuristic prolog/epilog detection (lower confidence)
_logger.LogDebug("Using heuristic function detection for stripped binary");
var textSection = await BinaryTextSectionReader.TryReadAsync(binaryPath, format, ct);
if (textSection is not null)
{
var heuristicFunctions = DetectByPrologEpilog(textSection, architecture);
functions.AddRange(heuristicFunctions);
}
return functions;
}
/// <summary>
/// Extracts function symbols from the binary's symbol table.
/// </summary>
private async Task<List<SymbolEntry>> ExtractSymbolTableAsync(
string binaryPath,
BinaryFormat format,
CancellationToken ct)
{
var symbols = new List<SymbolEntry>();
await using var stream = File.OpenRead(binaryPath);
using var reader = new BinaryReader(stream);
switch (format)
{
case BinaryFormat.Elf:
symbols = await ExtractElfSymbolsAsync(reader, ct);
break;
case BinaryFormat.Pe:
symbols = ExtractPeSymbols(reader);
break;
case BinaryFormat.MachO:
symbols = ExtractMachOSymbols(reader);
break;
}
// Filter to only function symbols
return symbols
.Where(s => s.Type == SymbolType.Function && s.Address != 0)
.OrderBy(s => s.Address)
.ToList();
}
private async Task<List<SymbolEntry>> ExtractElfSymbolsAsync(BinaryReader reader, CancellationToken ct)
{
var symbols = new List<SymbolEntry>();
reader.BaseStream.Seek(0, SeekOrigin.Begin);
var ident = reader.ReadBytes(16);
if (ident[0] != 0x7F || ident[1] != 'E' || ident[2] != 'L' || ident[3] != 'F')
return symbols;
var is64Bit = ident[4] == 2;
// Read section headers to find symbol tables
reader.BaseStream.Seek(is64Bit ? 40 : 32, SeekOrigin.Begin);
var sectionHeaderOffset = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
reader.BaseStream.Seek(is64Bit ? 58 : 46, SeekOrigin.Begin);
var sectionHeaderSize = reader.ReadUInt16();
var sectionCount = reader.ReadUInt16();
var strTabIndex = reader.ReadUInt16();
// Find .symtab and .dynsym sections
for (int i = 0; i < sectionCount; i++)
{
reader.BaseStream.Seek(sectionHeaderOffset + i * sectionHeaderSize, SeekOrigin.Begin);
var nameIdx = reader.ReadUInt32();
var type = reader.ReadUInt32();
// SHT_SYMTAB = 2, SHT_DYNSYM = 11
if (type == 2 || type == 11)
{
reader.BaseStream.Seek(sectionHeaderOffset + i * sectionHeaderSize + (is64Bit ? 24 : 16), SeekOrigin.Begin);
var offset = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
var size = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
reader.BaseStream.Seek(sectionHeaderOffset + i * sectionHeaderSize + (is64Bit ? 40 : 24), SeekOrigin.Begin);
var link = reader.ReadUInt32(); // String table section index
var entSize = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
// Read string table
reader.BaseStream.Seek(sectionHeaderOffset + (int)link * sectionHeaderSize + (is64Bit ? 24 : 16), SeekOrigin.Begin);
var strOffset = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
var strSize = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
reader.BaseStream.Seek(strOffset, SeekOrigin.Begin);
var strTab = reader.ReadBytes((int)strSize);
// Read symbols
var entrySize = is64Bit ? 24 : 16;
var count = size / entrySize;
for (long j = 0; j < count; j++)
{
reader.BaseStream.Seek(offset + j * entrySize, SeekOrigin.Begin);
var stName = reader.ReadUInt32();
var stInfo = is64Bit ? reader.ReadByte() : reader.ReadByte();
var stOther = is64Bit ? reader.ReadByte() : reader.ReadByte();
var stShndx = is64Bit ? reader.ReadUInt16() : reader.ReadUInt16();
long stValue, stSize;
if (is64Bit)
{
reader.BaseStream.Seek(offset + j * entrySize + 8, SeekOrigin.Begin);
stValue = reader.ReadInt64();
stSize = reader.ReadInt64();
}
else
{
reader.BaseStream.Seek(offset + j * entrySize + 4, SeekOrigin.Begin);
stValue = reader.ReadInt32();
stSize = reader.ReadInt32();
}
// STT_FUNC = 2
var stType = stInfo & 0x0f;
if (stType == 2 && stValue != 0)
{
var name = ReadNullTerminatedString(strTab, (int)stName);
if (!string.IsNullOrEmpty(name))
{
symbols.Add(new SymbolEntry
{
Name = DemangleSymbol(name),
MangledName = name,
Address = stValue,
Size = stSize,
Type = SymbolType.Function
});
}
}
}
}
}
return symbols;
}
private List<SymbolEntry> ExtractPeSymbols(BinaryReader reader)
{
// PE symbol extraction - simplified implementation
// Full implementation would parse COFF symbol table or PDB
return [];
}
private List<SymbolEntry> ExtractMachOSymbols(BinaryReader reader)
{
// Mach-O symbol extraction - simplified implementation
// Full implementation would parse LC_SYMTAB load command
return [];
}
/// <summary>
/// Detects functions by scanning for prolog/epilog patterns.
/// </summary>
private List<DetectedFunction> DetectByPrologEpilog(
BinaryTextSection textSection,
BinaryArchitecture architecture)
{
var functions = new List<DetectedFunction>();
var prologs = architecture switch
{
BinaryArchitecture.X86_64 or BinaryArchitecture.X86 => X86_64Prologs,
BinaryArchitecture.Arm64 or BinaryArchitecture.Arm => Arm64Prologs,
_ => X86_64Prologs
};
var epilogs = architecture switch
{
BinaryArchitecture.X86_64 or BinaryArchitecture.X86 => X86_64Epilogs,
BinaryArchitecture.Arm64 or BinaryArchitecture.Arm => Arm64Epilogs,
_ => X86_64Epilogs
};
var data = textSection.Data;
var baseAddr = textSection.VirtualAddress;
// Scan for prologs
var prologOffsets = new List<long>();
for (int i = 0; i < data.Length - 4; i++)
{
foreach (var prolog in prologs)
{
if (i + prolog.Length <= data.Length && MatchesPattern(data, i, prolog))
{
prologOffsets.Add(i);
break;
}
}
}
// For each prolog, find the next epilog to determine function end
for (int p = 0; p < prologOffsets.Count; p++)
{
var start = prologOffsets[p];
var maxEnd = p + 1 < prologOffsets.Count
? prologOffsets[p + 1]
: data.Length;
// Find epilog within range
long end = maxEnd;
for (long i = start + 4; i < maxEnd - 1; i++)
{
foreach (var epilog in epilogs)
{
if (i + epilog.Length <= data.Length && MatchesPattern(data, (int)i, epilog))
{
end = i + epilog.Length;
goto foundEpilog;
}
}
}
foundEpilog:
functions.Add(new DetectedFunction
{
Symbol = $"sub_{baseAddr + start:x}",
StartAddress = baseAddr + start,
EndAddress = baseAddr + end,
Confidence = _options.HeuristicConfidence,
DetectionMethod = FunctionDetectionMethod.Heuristic
});
}
return functions;
}
private static bool MatchesPattern(byte[] data, int offset, byte[] pattern)
{
for (int i = 0; i < pattern.Length; i++)
{
if (data[offset + i] != pattern[i])
return false;
}
return true;
}
/// <summary>
/// Infers function sizes from gaps between symbols.
/// </summary>
private void InferFunctionSizes(List<DetectedFunction> functions)
{
if (functions.Count < 2) return;
var sorted = functions.OrderBy(f => f.StartAddress).ToList();
for (int i = 0; i < sorted.Count - 1; i++)
{
if (sorted[i].EndAddress == sorted[i].StartAddress)
{
// Function has no size, infer from next function
sorted[i] = sorted[i] with
{
EndAddress = sorted[i + 1].StartAddress,
Confidence = sorted[i].Confidence * _options.InferredSizePenalty // Reduce confidence for inferred size
};
}
}
}
private static string ReadNullTerminatedString(byte[] data, int offset)
{
if (offset < 0 || offset >= data.Length)
return string.Empty;
var end = offset;
while (end < data.Length && data[end] != 0)
end++;
return System.Text.Encoding.UTF8.GetString(data, offset, end - offset);
}
private static string DemangleSymbol(string name)
{
// Basic C++ demangling - production would use a proper demangler
if (name.StartsWith("_Z"))
{
// This is a mangled C++ name
// Full implementation would use c++filt or similar
return name;
}
return name;
}
}
/// <summary>
/// Detected function boundary.
/// </summary>
public sealed record DetectedFunction
{
public required string Symbol { get; init; }
public string? MangledName { get; init; }
public required long StartAddress { get; init; }
public required long EndAddress { get; init; }
public required double Confidence { get; init; }
public required FunctionDetectionMethod DetectionMethod { get; init; }
public string? SourceFile { get; init; }
public int? SourceLine { get; init; }
}
/// <summary>
/// Method used to detect function boundaries.
/// </summary>
public enum FunctionDetectionMethod
{
Dwarf,
SymbolTable,
Heuristic
}
/// <summary>
/// Symbol table entry.
/// </summary>
internal record SymbolEntry
{
public required string Name { get; init; }
public string? MangledName { get; init; }
public required long Address { get; init; }
public required long Size { get; init; }
public required SymbolType Type { get; init; }
}
/// <summary>
/// Symbol type.
/// </summary>
internal enum SymbolType
{
Function,
Object,
Other
}
/// <summary>
/// Binary architecture.
/// </summary>
public enum BinaryArchitecture
{
Unknown,
X86,
X86_64,
Arm,
Arm64,
Riscv64
}
/// <summary>
/// Binary format.
/// </summary>
public enum BinaryFormat
{
Elf,
Pe,
MachO
}
/// <summary>
/// Binary .text section data.
/// </summary>
public sealed record BinaryTextSection
{
public required byte[] Data { get; init; }
public required long VirtualAddress { get; init; }
public required BinaryArchitecture Architecture { get; init; }
}
/// <summary>
/// Reader for binary .text sections.
/// </summary>
public static class BinaryTextSectionReader
{
public static async Task<BinaryTextSection?> TryReadAsync(
string path,
BinaryFormat format,
CancellationToken ct)
{
// Simplified implementation - would parse ELF/PE/Mach-O headers
// to locate .text section
await Task.CompletedTask;
return null;
}
}

View File

@@ -0,0 +1,104 @@
// -----------------------------------------------------------------------------
// CallGraphExtractorRegistry.cs
// Sprint: SPRINT_20251226_005_SCANNER_reachability_extractors (REACH-REG-01)
// Description: Registry implementation for language-specific call graph extractors.
// -----------------------------------------------------------------------------
using System.Collections.Frozen;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.CallGraph;
/// <summary>
/// Registry implementation for language-specific call graph extractors.
/// Provides deterministic ordering and language-based lookup.
/// </summary>
/// <remarks>
/// Supported languages (alphabetical order for determinism):
/// - dotnet: .NET/C# via Roslyn semantic analysis
/// - go: Go via SSA-based analysis (external tool or static fallback)
/// - java: Java via ASM bytecode parsing
/// - node: Node.js/JavaScript via Babel AST
/// - python: Python via AST analysis
/// </remarks>
public sealed class CallGraphExtractorRegistry : ICallGraphExtractorRegistry
{
private readonly FrozenDictionary<string, ICallGraphExtractor> _extractorsByLanguage;
private readonly IReadOnlyList<ICallGraphExtractor> _extractors;
private readonly IReadOnlyList<string> _supportedLanguages;
private readonly ILogger<CallGraphExtractorRegistry>? _logger;
/// <summary>
/// Creates a new registry from the provided extractors.
/// </summary>
/// <param name="extractors">The extractors to register.</param>
/// <param name="logger">Optional logger for diagnostics.</param>
public CallGraphExtractorRegistry(
IEnumerable<ICallGraphExtractor> extractors,
ILogger<CallGraphExtractorRegistry>? logger = null)
{
ArgumentNullException.ThrowIfNull(extractors);
_logger = logger;
var extractorList = extractors.ToList();
// Build lookup dictionary (case-insensitive language matching)
var dict = new Dictionary<string, ICallGraphExtractor>(StringComparer.OrdinalIgnoreCase);
foreach (var extractor in extractorList)
{
if (!dict.TryAdd(extractor.Language, extractor))
{
_logger?.LogWarning(
"Duplicate extractor registration for language '{Language}'; keeping first registration",
extractor.Language);
}
}
_extractorsByLanguage = dict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
// Order extractors deterministically by language
_extractors = extractorList
.OrderBy(e => e.Language, StringComparer.OrdinalIgnoreCase)
.ToList()
.AsReadOnly();
_supportedLanguages = _extractorsByLanguage.Keys
.OrderBy(k => k, StringComparer.OrdinalIgnoreCase)
.ToList()
.AsReadOnly();
_logger?.LogInformation(
"CallGraphExtractorRegistry initialized with {Count} extractors: [{Languages}]",
_supportedLanguages.Count,
string.Join(", ", _supportedLanguages));
}
/// <inheritdoc />
public IReadOnlyList<ICallGraphExtractor> Extractors => _extractors;
/// <inheritdoc />
public IReadOnlyList<string> SupportedLanguages => _supportedLanguages;
/// <inheritdoc />
public ICallGraphExtractor? GetExtractor(string language)
{
if (string.IsNullOrWhiteSpace(language))
{
return null;
}
_extractorsByLanguage.TryGetValue(language, out var extractor);
return extractor;
}
/// <inheritdoc />
public bool IsLanguageSupported(string language)
{
if (string.IsNullOrWhiteSpace(language))
{
return false;
}
return _extractorsByLanguage.ContainsKey(language);
}
}

View File

@@ -0,0 +1,38 @@
// -----------------------------------------------------------------------------
// ICallGraphExtractorRegistry.cs
// Sprint: SPRINT_20251226_005_SCANNER_reachability_extractors (REACH-REG-01)
// Description: Registry interface for language-specific call graph extractors.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.CallGraph;
/// <summary>
/// Registry for language-specific call graph extractors.
/// Provides lookup by language identifier and enumeration of supported languages.
/// </summary>
public interface ICallGraphExtractorRegistry
{
/// <summary>
/// Gets all registered extractors.
/// </summary>
IReadOnlyList<ICallGraphExtractor> Extractors { get; }
/// <summary>
/// Gets the supported language identifiers.
/// </summary>
IReadOnlyList<string> SupportedLanguages { get; }
/// <summary>
/// Gets an extractor for the specified language.
/// </summary>
/// <param name="language">The language identifier (e.g., "java", "node", "python", "go", "dotnet").</param>
/// <returns>The extractor for the language, or null if not supported.</returns>
ICallGraphExtractor? GetExtractor(string language);
/// <summary>
/// Checks if the specified language is supported.
/// </summary>
/// <param name="language">The language identifier.</param>
/// <returns>True if the language has a registered extractor.</returns>
bool IsLanguageSupported(string language);
}

View File

@@ -26,6 +26,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\StellaOps.Scanner.Evidence\\StellaOps.Scanner.Evidence.csproj" />
<ProjectReference Include="..\\StellaOps.Scanner.Reachability\\StellaOps.Scanner.Reachability.csproj" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,339 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Replay.Core;
using StellaOps.Scanner.Evidence.Models;
namespace StellaOps.Scanner.Storage.Oci;
/// <summary>
/// Service for publishing FuncProof documents to OCI registries as referrer artifacts.
/// Follows the OCI referrer pattern to link FuncProof evidence to the original image.
/// </summary>
public interface IFuncProofOciPublisher
{
/// <summary>
/// Publishes a FuncProof document to an OCI registry as a referrer artifact.
/// </summary>
/// <param name="request">The publish request containing FuncProof and target details.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Result containing the pushed manifest digest and reference.</returns>
Task<FuncProofOciPublishResult> PublishAsync(FuncProofOciPublishRequest request, CancellationToken ct = default);
}
/// <summary>
/// Request to publish a FuncProof document to OCI registry.
/// </summary>
public sealed record FuncProofOciPublishRequest
{
/// <summary>
/// The FuncProof document to publish.
/// </summary>
public required FuncProof FuncProof { get; init; }
/// <summary>
/// Optional DSSE envelope containing the signed FuncProof.
/// If provided, this is published instead of the raw FuncProof.
/// </summary>
public DsseEnvelope? DsseEnvelope { get; init; }
/// <summary>
/// Target OCI registry reference (e.g., "registry.example.com/repo:tag").
/// </summary>
public required string RegistryReference { get; init; }
/// <summary>
/// Digest of the subject image this FuncProof refers to.
/// Used to create a referrer relationship (OCI referrer pattern).
/// </summary>
public required string SubjectDigest { get; init; }
/// <summary>
/// Optional tag for the FuncProof artifact. If null, uses the proof ID.
/// </summary>
public string? Tag { get; init; }
/// <summary>
/// Additional annotations to include in the OCI manifest.
/// </summary>
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
}
/// <summary>
/// Result of publishing a FuncProof document to OCI registry.
/// </summary>
public sealed record FuncProofOciPublishResult
{
public required bool Success { get; init; }
public string? ManifestDigest { get; init; }
public string? ManifestReference { get; init; }
public string? ProofLayerDigest { get; init; }
public string? Error { get; init; }
public static FuncProofOciPublishResult Failed(string error) => new()
{
Success = false,
Error = error
};
}
/// <summary>
/// Configuration options for FuncProof OCI publishing.
/// </summary>
public sealed class FuncProofOciOptions
{
public const string SectionName = "Scanner:FuncProof:Oci";
/// <summary>
/// Whether to publish FuncProof as a referrer artifact.
/// </summary>
public bool EnableReferrerPublish { get; set; } = true;
/// <summary>
/// Whether to include the DSSE envelope as a separate layer.
/// </summary>
public bool IncludeDsseLayer { get; set; } = true;
/// <summary>
/// Whether to compress the FuncProof content before publishing.
/// </summary>
public bool CompressContent { get; set; } = false;
}
/// <summary>
/// Default implementation of FuncProof OCI publisher.
/// </summary>
public sealed class FuncProofOciPublisher : IFuncProofOciPublisher
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
private readonly IOciPushService _ociPushService;
private readonly IOptions<FuncProofOciOptions> _options;
private readonly ILogger<FuncProofOciPublisher> _logger;
public FuncProofOciPublisher(
IOciPushService ociPushService,
IOptions<FuncProofOciOptions> options,
ILogger<FuncProofOciPublisher> logger)
{
_ociPushService = ociPushService ?? throw new ArgumentNullException(nameof(ociPushService));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<FuncProofOciPublishResult> PublishAsync(
FuncProofOciPublishRequest request,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(request.FuncProof);
ArgumentException.ThrowIfNullOrWhiteSpace(request.RegistryReference);
ArgumentException.ThrowIfNullOrWhiteSpace(request.SubjectDigest);
ct.ThrowIfCancellationRequested();
if (string.IsNullOrEmpty(request.FuncProof.ProofId))
{
return FuncProofOciPublishResult.Failed("FuncProof must have a valid ProofId before publishing.");
}
_logger.LogDebug(
"Publishing FuncProof {ProofId} to OCI registry {Reference}",
request.FuncProof.ProofId,
request.RegistryReference);
try
{
var layers = BuildLayers(request);
var annotations = BuildAnnotations(request);
var pushRequest = new OciArtifactPushRequest
{
Reference = request.RegistryReference,
ArtifactType = FuncProofOciMediaTypes.ArtifactType,
Layers = layers,
SubjectDigest = request.SubjectDigest,
Annotations = annotations
};
var result = await _ociPushService.PushAsync(pushRequest, ct).ConfigureAwait(false);
if (!result.Success)
{
_logger.LogWarning(
"Failed to publish FuncProof {ProofId}: {Error}",
request.FuncProof.ProofId,
result.Error);
return FuncProofOciPublishResult.Failed(result.Error ?? "Unknown OCI push failure");
}
_logger.LogInformation(
"Published FuncProof {ProofId} to {Reference} with digest {Digest}",
request.FuncProof.ProofId,
result.ManifestReference,
result.ManifestDigest);
return new FuncProofOciPublishResult
{
Success = true,
ManifestDigest = result.ManifestDigest,
ManifestReference = result.ManifestReference,
ProofLayerDigest = result.LayerDigests?.FirstOrDefault()
};
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Error publishing FuncProof {ProofId}", request.FuncProof.ProofId);
return FuncProofOciPublishResult.Failed($"Publish error: {ex.Message}");
}
}
private List<OciLayerContent> BuildLayers(FuncProofOciPublishRequest request)
{
var layers = new List<OciLayerContent>();
var opts = _options.Value;
// Primary FuncProof layer
byte[] proofContent;
string proofMediaType;
if (request.DsseEnvelope is not null && opts.IncludeDsseLayer)
{
// Use DSSE envelope as primary layer
proofContent = JsonSerializer.SerializeToUtf8Bytes(request.DsseEnvelope, JsonOptions);
proofMediaType = FuncProofOciMediaTypes.DsseLayer;
}
else
{
// Use raw FuncProof
proofContent = JsonSerializer.SerializeToUtf8Bytes(request.FuncProof, JsonOptions);
proofMediaType = FuncProofOciMediaTypes.ProofLayer;
}
if (opts.CompressContent)
{
proofContent = CompressGzip(proofContent);
proofMediaType += "+gzip";
}
layers.Add(new OciLayerContent
{
Content = proofContent,
MediaType = proofMediaType,
Annotations = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
[OciAnnotations.Title] = $"funcproof-{request.FuncProof.ProofId}",
[FuncProofOciAnnotations.ProofId] = request.FuncProof.ProofId,
[FuncProofOciAnnotations.BuildId] = request.FuncProof.BuildId ?? string.Empty,
[FuncProofOciAnnotations.FunctionCount] = request.FuncProof.Functions?.Count.ToString() ?? "0"
}
});
// Add raw FuncProof as secondary layer if DSSE was primary
if (request.DsseEnvelope is not null && opts.IncludeDsseLayer)
{
var rawContent = JsonSerializer.SerializeToUtf8Bytes(request.FuncProof, JsonOptions);
if (opts.CompressContent)
{
rawContent = CompressGzip(rawContent);
}
layers.Add(new OciLayerContent
{
Content = rawContent,
MediaType = opts.CompressContent
? FuncProofOciMediaTypes.ProofLayer + "+gzip"
: FuncProofOciMediaTypes.ProofLayer,
Annotations = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
[OciAnnotations.Title] = $"funcproof-raw-{request.FuncProof.ProofId}"
}
});
}
return layers;
}
private SortedDictionary<string, string> BuildAnnotations(FuncProofOciPublishRequest request)
{
var annotations = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
[OciAnnotations.Title] = $"FuncProof for {request.FuncProof.BuildId ?? request.FuncProof.ProofId}",
[FuncProofOciAnnotations.ProofId] = request.FuncProof.ProofId,
[FuncProofOciAnnotations.SchemaVersion] = FuncProofConstants.SchemaVersion
};
if (!string.IsNullOrEmpty(request.FuncProof.BuildId))
{
annotations[FuncProofOciAnnotations.BuildId] = request.FuncProof.BuildId;
}
if (!string.IsNullOrEmpty(request.FuncProof.FileSha256))
{
annotations[FuncProofOciAnnotations.FileSha256] = request.FuncProof.FileSha256;
}
if (request.FuncProof.Metadata?.CreatedAt is not null)
{
annotations[OciAnnotations.Created] = request.FuncProof.Metadata.CreatedAt;
}
// Merge user-provided annotations
if (request.Annotations is not null)
{
foreach (var (key, value) in request.Annotations)
{
annotations[key] = value;
}
}
return annotations;
}
private static byte[] CompressGzip(byte[] data)
{
using var output = new System.IO.MemoryStream();
using (var gzip = new System.IO.Compression.GZipStream(output, System.IO.Compression.CompressionLevel.Optimal))
{
gzip.Write(data, 0, data.Length);
}
return output.ToArray();
}
}
/// <summary>
/// OCI media types for FuncProof artifacts.
/// </summary>
public static class FuncProofOciMediaTypes
{
/// <summary>
/// Artifact type for FuncProof OCI artifacts.
/// </summary>
public const string ArtifactType = "application/vnd.stellaops.funcproof";
/// <summary>
/// Media type for the FuncProof JSON layer.
/// </summary>
public const string ProofLayer = "application/vnd.stellaops.funcproof+json";
/// <summary>
/// Media type for the DSSE envelope layer containing signed FuncProof.
/// </summary>
public const string DsseLayer = "application/vnd.stellaops.funcproof.dsse+json";
}
/// <summary>
/// Custom OCI annotations for FuncProof artifacts.
/// </summary>
public static class FuncProofOciAnnotations
{
public const string ProofId = "io.stellaops.funcproof.id";
public const string BuildId = "io.stellaops.funcproof.build-id";
public const string FileSha256 = "io.stellaops.funcproof.file-sha256";
public const string FunctionCount = "io.stellaops.funcproof.function-count";
public const string SchemaVersion = "io.stellaops.funcproof.schema-version";
}

View File

@@ -0,0 +1,116 @@
// -----------------------------------------------------------------------------
// FuncProofDocumentRow.cs
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
// Task: FUNC-02 — Create FuncProofDocument PostgreSQL entity
// Description: Entity mapping for scanner.func_proof table.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Storage.Entities;
/// <summary>
/// Recorded FuncProof evidence per scan.
/// Maps to scanner.func_proof table with indexes on build_id.
/// </summary>
public sealed class FuncProofDocumentRow
{
/// <summary>
/// Primary key (UUID).
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Reference to the parent scan.
/// </summary>
public Guid ScanId { get; set; }
/// <summary>
/// Content-addressable proof ID: blake3:{hash}.
/// </summary>
public string ProofId { get; set; } = string.Empty;
/// <summary>
/// GNU Build-ID, PE CodeView GUID, or Mach-O UUID.
/// Indexed for fast lookup.
/// </summary>
public string BuildId { get; set; } = string.Empty;
/// <summary>
/// Type of build ID: "gnu-build-id", "pe-codeview", "macho-uuid", "file-sha256".
/// </summary>
public string BuildIdType { get; set; } = string.Empty;
/// <summary>
/// SHA-256 of the entire binary file.
/// </summary>
public string FileSha256 { get; set; } = string.Empty;
/// <summary>
/// Binary format: "elf", "pe", "macho".
/// </summary>
public string BinaryFormat { get; set; } = string.Empty;
/// <summary>
/// Target architecture: "x86_64", "aarch64", etc.
/// </summary>
public string Architecture { get; set; } = string.Empty;
/// <summary>
/// Whether the binary is stripped.
/// </summary>
public bool IsStripped { get; set; }
/// <summary>
/// Number of functions in the proof.
/// </summary>
public int FunctionCount { get; set; }
/// <summary>
/// Number of traces in the proof.
/// </summary>
public int TraceCount { get; set; }
/// <summary>
/// Full FuncProof JSON document (JSONB).
/// </summary>
public string ProofContent { get; set; } = string.Empty;
/// <summary>
/// Compressed proof content (gzip) for large documents.
/// </summary>
public byte[]? CompressedContent { get; set; }
/// <summary>
/// DSSE envelope ID if signed.
/// </summary>
public string? DsseEnvelopeId { get; set; }
/// <summary>
/// OCI artifact digest if published.
/// </summary>
public string? OciArtifactDigest { get; set; }
/// <summary>
/// Rekor transparency log entry ID.
/// </summary>
public string? RekorEntryId { get; set; }
/// <summary>
/// Generator version that created this proof.
/// </summary>
public string GeneratorVersion { get; set; } = string.Empty;
/// <summary>
/// When the proof was generated (UTC).
/// </summary>
public DateTimeOffset GeneratedAtUtc { get; set; }
/// <summary>
/// When this row was created (UTC).
/// </summary>
public DateTimeOffset CreatedAtUtc { get; set; }
/// <summary>
/// When this row was last updated (UTC).
/// </summary>
public DateTimeOffset? UpdatedAtUtc { get; set; }
}

View File

@@ -0,0 +1,136 @@
-- -----------------------------------------------------------------------------
-- 019_func_proof_tables.sql
-- Sprint: SPRINT_20251226_009_SCANNER_funcproof
-- Task: FUNC-02 — Create func_proof PostgreSQL table with indexes
-- Description: Schema for function-level proof documents.
-- -----------------------------------------------------------------------------
-- Create func_proof table
CREATE TABLE IF NOT EXISTS scanner.func_proof (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scan_id UUID NOT NULL,
proof_id TEXT NOT NULL,
build_id TEXT NOT NULL,
build_id_type TEXT NOT NULL,
file_sha256 TEXT NOT NULL,
binary_format TEXT NOT NULL,
architecture TEXT NOT NULL,
is_stripped BOOLEAN NOT NULL DEFAULT FALSE,
function_count INTEGER NOT NULL DEFAULT 0,
trace_count INTEGER NOT NULL DEFAULT 0,
proof_content JSONB NOT NULL,
compressed_content BYTEA,
dsse_envelope_id TEXT,
oci_artifact_digest TEXT,
rekor_entry_id TEXT,
generator_version TEXT NOT NULL,
generated_at_utc TIMESTAMPTZ NOT NULL,
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at_utc TIMESTAMPTZ
);
-- Index on build_id for fast lookup by binary identity
CREATE INDEX IF NOT EXISTS idx_func_proof_build_id
ON scanner.func_proof(build_id);
-- Index on file_sha256 for lookup by file hash
CREATE INDEX IF NOT EXISTS idx_func_proof_file_sha256
ON scanner.func_proof(file_sha256);
-- Index on scan_id for retrieving all proofs for a scan
CREATE INDEX IF NOT EXISTS idx_func_proof_scan_id
ON scanner.func_proof(scan_id);
-- Index on proof_id for content-addressable lookup
CREATE UNIQUE INDEX IF NOT EXISTS idx_func_proof_proof_id
ON scanner.func_proof(proof_id);
-- Composite index for build_id + architecture
CREATE INDEX IF NOT EXISTS idx_func_proof_build_arch
ON scanner.func_proof(build_id, architecture);
-- GIN index on proof_content for JSONB queries (e.g., finding functions by symbol)
CREATE INDEX IF NOT EXISTS idx_func_proof_content_gin
ON scanner.func_proof USING GIN (proof_content jsonb_path_ops);
-- Create func_node table for denormalized function lookup
CREATE TABLE IF NOT EXISTS scanner.func_node (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
func_proof_id UUID NOT NULL REFERENCES scanner.func_proof(id) ON DELETE CASCADE,
symbol TEXT NOT NULL,
symbol_digest TEXT NOT NULL,
start_address BIGINT NOT NULL,
end_address BIGINT NOT NULL,
function_hash TEXT NOT NULL,
confidence DOUBLE PRECISION NOT NULL DEFAULT 1.0,
is_entrypoint BOOLEAN NOT NULL DEFAULT FALSE,
entrypoint_type TEXT,
is_sink BOOLEAN NOT NULL DEFAULT FALSE,
sink_vuln_id TEXT,
source_file TEXT,
source_line INTEGER,
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index on symbol_digest for fast cross-binary correlation
CREATE INDEX IF NOT EXISTS idx_func_node_symbol_digest
ON scanner.func_node(symbol_digest);
-- Index on func_proof_id for retrieving all nodes for a proof
CREATE INDEX IF NOT EXISTS idx_func_node_proof_id
ON scanner.func_node(func_proof_id);
-- Index on symbol for text search
CREATE INDEX IF NOT EXISTS idx_func_node_symbol
ON scanner.func_node(symbol);
-- Composite index for vulnerable sinks
CREATE INDEX IF NOT EXISTS idx_func_node_sinks
ON scanner.func_node(is_sink, sink_vuln_id) WHERE is_sink = TRUE;
-- Composite index for entrypoints
CREATE INDEX IF NOT EXISTS idx_func_node_entrypoints
ON scanner.func_node(is_entrypoint, entrypoint_type) WHERE is_entrypoint = TRUE;
-- Create func_trace table for denormalized trace lookup
CREATE TABLE IF NOT EXISTS scanner.func_trace (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
func_proof_id UUID NOT NULL REFERENCES scanner.func_proof(id) ON DELETE CASCADE,
trace_id TEXT NOT NULL,
edge_list_hash TEXT NOT NULL,
hop_count INTEGER NOT NULL,
entry_symbol_digest TEXT NOT NULL,
sink_symbol_digest TEXT NOT NULL,
path TEXT[] NOT NULL,
truncated BOOLEAN NOT NULL DEFAULT FALSE,
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index on func_proof_id for retrieving all traces for a proof
CREATE INDEX IF NOT EXISTS idx_func_trace_proof_id
ON scanner.func_trace(func_proof_id);
-- Index on entry_symbol_digest for finding traces from a specific entrypoint
CREATE INDEX IF NOT EXISTS idx_func_trace_entry_digest
ON scanner.func_trace(entry_symbol_digest);
-- Index on sink_symbol_digest for finding traces to a specific sink
CREATE INDEX IF NOT EXISTS idx_func_trace_sink_digest
ON scanner.func_trace(sink_symbol_digest);
-- Index on edge_list_hash for deduplication
CREATE INDEX IF NOT EXISTS idx_func_trace_edge_hash
ON scanner.func_trace(edge_list_hash);
-- Add comments for documentation
COMMENT ON TABLE scanner.func_proof IS 'Function-level proof documents for binary reachability evidence';
COMMENT ON COLUMN scanner.func_proof.proof_id IS 'Content-addressable ID: blake3:{hash} of canonical JSON';
COMMENT ON COLUMN scanner.func_proof.build_id IS 'GNU Build-ID (ELF), PE CodeView GUID, or Mach-O UUID';
COMMENT ON COLUMN scanner.func_proof.proof_content IS 'Full FuncProof JSON document';
COMMENT ON COLUMN scanner.func_proof.compressed_content IS 'Optional gzip-compressed content for large documents';
COMMENT ON TABLE scanner.func_node IS 'Denormalized function entries for fast symbol lookup';
COMMENT ON COLUMN scanner.func_node.symbol_digest IS 'BLAKE3(symbol_name + offset_range) for cross-binary correlation';
COMMENT ON TABLE scanner.func_trace IS 'Denormalized entry→sink traces for reachability queries';
COMMENT ON COLUMN scanner.func_trace.edge_list_hash IS 'BLAKE3 hash of sorted edge pairs for deduplication';

View File

@@ -0,0 +1,286 @@
// -----------------------------------------------------------------------------
// PostgresFuncProofRepository.cs
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
// Task: FUNC-02 — PostgreSQL repository for FuncProof documents
// Description: Repository for storing and retrieving FuncProof evidence.
// -----------------------------------------------------------------------------
using System.Text.Json;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Scanner.Storage.Entities;
namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// Repository for FuncProof documents.
/// </summary>
public interface IFuncProofRepository
{
/// <summary>
/// Stores a FuncProof document.
/// </summary>
Task<Guid> StoreAsync(FuncProofDocumentRow document, CancellationToken ct = default);
/// <summary>
/// Retrieves a FuncProof document by ID.
/// </summary>
Task<FuncProofDocumentRow?> GetByIdAsync(Guid id, CancellationToken ct = default);
/// <summary>
/// Retrieves a FuncProof document by proof ID (content-addressable).
/// </summary>
Task<FuncProofDocumentRow?> GetByProofIdAsync(string proofId, CancellationToken ct = default);
/// <summary>
/// Retrieves all FuncProof documents for a build ID.
/// </summary>
Task<IReadOnlyList<FuncProofDocumentRow>> GetByBuildIdAsync(string buildId, CancellationToken ct = default);
/// <summary>
/// Retrieves all FuncProof documents for a scan.
/// </summary>
Task<IReadOnlyList<FuncProofDocumentRow>> GetByScanIdAsync(Guid scanId, CancellationToken ct = default);
/// <summary>
/// Checks if a FuncProof document exists by proof ID.
/// </summary>
Task<bool> ExistsAsync(string proofId, CancellationToken ct = default);
/// <summary>
/// Updates DSSE envelope and OCI artifact information.
/// </summary>
Task UpdateSignatureInfoAsync(
Guid id,
string dsseEnvelopeId,
string? ociArtifactDigest,
string? rekorEntryId,
CancellationToken ct = default);
}
/// <summary>
/// PostgreSQL implementation of FuncProof repository.
/// </summary>
public sealed class PostgresFuncProofRepository : IFuncProofRepository
{
private readonly NpgsqlDataSource _dataSource;
public PostgresFuncProofRepository(NpgsqlDataSource dataSource)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
}
public async Task<Guid> StoreAsync(FuncProofDocumentRow document, CancellationToken ct = default)
{
const string sql = """
INSERT INTO scanner.func_proof (
id, scan_id, proof_id, build_id, build_id_type,
file_sha256, binary_format, architecture, is_stripped,
function_count, trace_count, proof_content, compressed_content,
dsse_envelope_id, oci_artifact_digest, rekor_entry_id,
generator_version, generated_at_utc, created_at_utc
) VALUES (
@id, @scan_id, @proof_id, @build_id, @build_id_type,
@file_sha256, @binary_format, @architecture, @is_stripped,
@function_count, @trace_count, @proof_content::jsonb, @compressed_content,
@dsse_envelope_id, @oci_artifact_digest, @rekor_entry_id,
@generator_version, @generated_at_utc, @created_at_utc
)
ON CONFLICT (proof_id) DO UPDATE SET
updated_at_utc = NOW()
RETURNING id
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
var id = document.Id == Guid.Empty ? Guid.NewGuid() : document.Id;
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("scan_id", document.ScanId);
cmd.Parameters.AddWithValue("proof_id", document.ProofId);
cmd.Parameters.AddWithValue("build_id", document.BuildId);
cmd.Parameters.AddWithValue("build_id_type", document.BuildIdType);
cmd.Parameters.AddWithValue("file_sha256", document.FileSha256);
cmd.Parameters.AddWithValue("binary_format", document.BinaryFormat);
cmd.Parameters.AddWithValue("architecture", document.Architecture);
cmd.Parameters.AddWithValue("is_stripped", document.IsStripped);
cmd.Parameters.AddWithValue("function_count", document.FunctionCount);
cmd.Parameters.AddWithValue("trace_count", document.TraceCount);
cmd.Parameters.AddWithValue("proof_content", document.ProofContent);
cmd.Parameters.AddWithValue("compressed_content",
document.CompressedContent is null ? DBNull.Value : document.CompressedContent);
cmd.Parameters.AddWithValue("dsse_envelope_id",
document.DsseEnvelopeId is null ? DBNull.Value : document.DsseEnvelopeId);
cmd.Parameters.AddWithValue("oci_artifact_digest",
document.OciArtifactDigest is null ? DBNull.Value : document.OciArtifactDigest);
cmd.Parameters.AddWithValue("rekor_entry_id",
document.RekorEntryId is null ? DBNull.Value : document.RekorEntryId);
cmd.Parameters.AddWithValue("generator_version", document.GeneratorVersion);
cmd.Parameters.AddWithValue("generated_at_utc", document.GeneratedAtUtc);
cmd.Parameters.AddWithValue("created_at_utc", DateTimeOffset.UtcNow);
var result = await cmd.ExecuteScalarAsync(ct);
return result is Guid returnedId ? returnedId : id;
}
public async Task<FuncProofDocumentRow?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
const string sql = """
SELECT id, scan_id, proof_id, build_id, build_id_type,
file_sha256, binary_format, architecture, is_stripped,
function_count, trace_count, proof_content, compressed_content,
dsse_envelope_id, oci_artifact_digest, rekor_entry_id,
generator_version, generated_at_utc, created_at_utc, updated_at_utc
FROM scanner.func_proof
WHERE id = @id
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("id", id);
await using var reader = await cmd.ExecuteReaderAsync(ct);
return await reader.ReadAsync(ct) ? MapRow(reader) : null;
}
public async Task<FuncProofDocumentRow?> GetByProofIdAsync(string proofId, CancellationToken ct = default)
{
const string sql = """
SELECT id, scan_id, proof_id, build_id, build_id_type,
file_sha256, binary_format, architecture, is_stripped,
function_count, trace_count, proof_content, compressed_content,
dsse_envelope_id, oci_artifact_digest, rekor_entry_id,
generator_version, generated_at_utc, created_at_utc, updated_at_utc
FROM scanner.func_proof
WHERE proof_id = @proof_id
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("proof_id", proofId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
return await reader.ReadAsync(ct) ? MapRow(reader) : null;
}
public async Task<IReadOnlyList<FuncProofDocumentRow>> GetByBuildIdAsync(string buildId, CancellationToken ct = default)
{
const string sql = """
SELECT id, scan_id, proof_id, build_id, build_id_type,
file_sha256, binary_format, architecture, is_stripped,
function_count, trace_count, proof_content, compressed_content,
dsse_envelope_id, oci_artifact_digest, rekor_entry_id,
generator_version, generated_at_utc, created_at_utc, updated_at_utc
FROM scanner.func_proof
WHERE build_id = @build_id
ORDER BY generated_at_utc DESC
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("build_id", buildId);
var results = new List<FuncProofDocumentRow>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
results.Add(MapRow(reader));
}
return results;
}
public async Task<IReadOnlyList<FuncProofDocumentRow>> GetByScanIdAsync(Guid scanId, CancellationToken ct = default)
{
const string sql = """
SELECT id, scan_id, proof_id, build_id, build_id_type,
file_sha256, binary_format, architecture, is_stripped,
function_count, trace_count, proof_content, compressed_content,
dsse_envelope_id, oci_artifact_digest, rekor_entry_id,
generator_version, generated_at_utc, created_at_utc, updated_at_utc
FROM scanner.func_proof
WHERE scan_id = @scan_id
ORDER BY build_id
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("scan_id", scanId);
var results = new List<FuncProofDocumentRow>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
results.Add(MapRow(reader));
}
return results;
}
public async Task<bool> ExistsAsync(string proofId, CancellationToken ct = default)
{
const string sql = "SELECT EXISTS(SELECT 1 FROM scanner.func_proof WHERE proof_id = @proof_id)";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("proof_id", proofId);
var result = await cmd.ExecuteScalarAsync(ct);
return result is true;
}
public async Task UpdateSignatureInfoAsync(
Guid id,
string dsseEnvelopeId,
string? ociArtifactDigest,
string? rekorEntryId,
CancellationToken ct = default)
{
const string sql = """
UPDATE scanner.func_proof
SET dsse_envelope_id = @dsse_envelope_id,
oci_artifact_digest = @oci_artifact_digest,
rekor_entry_id = @rekor_entry_id,
updated_at_utc = NOW()
WHERE id = @id
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("id", id);
cmd.Parameters.AddWithValue("dsse_envelope_id", dsseEnvelopeId);
cmd.Parameters.AddWithValue("oci_artifact_digest",
ociArtifactDigest is null ? DBNull.Value : ociArtifactDigest);
cmd.Parameters.AddWithValue("rekor_entry_id",
rekorEntryId is null ? DBNull.Value : rekorEntryId);
await cmd.ExecuteNonQueryAsync(ct);
}
private static FuncProofDocumentRow MapRow(NpgsqlDataReader reader)
{
return new FuncProofDocumentRow
{
Id = reader.GetGuid(0),
ScanId = reader.GetGuid(1),
ProofId = reader.GetString(2),
BuildId = reader.GetString(3),
BuildIdType = reader.GetString(4),
FileSha256 = reader.GetString(5),
BinaryFormat = reader.GetString(6),
Architecture = reader.GetString(7),
IsStripped = reader.GetBoolean(8),
FunctionCount = reader.GetInt32(9),
TraceCount = reader.GetInt32(10),
ProofContent = reader.GetString(11),
CompressedContent = reader.IsDBNull(12) ? null : (byte[])reader.GetValue(12),
DsseEnvelopeId = reader.IsDBNull(13) ? null : reader.GetString(13),
OciArtifactDigest = reader.IsDBNull(14) ? null : reader.GetString(14),
RekorEntryId = reader.IsDBNull(15) ? null : reader.GetString(15),
GeneratorVersion = reader.GetString(16),
GeneratedAtUtc = reader.GetDateTime(17),
CreatedAtUtc = reader.GetDateTime(18),
UpdatedAtUtc = reader.IsDBNull(19) ? null : reader.GetDateTime(19)
};
}
}