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