Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism

- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency.
- Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling.
- Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies.
- Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification.
- Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
StellaOps Bot
2025-12-26 15:17:15 +02:00
parent 7792749bb4
commit 907783f625
354 changed files with 79727 additions and 1346 deletions

View File

@@ -65,10 +65,42 @@ Reachability Drift Detection tracks function-level reachability changes between
- URI: `stellaops.dev/predicates/reachability-drift@v1`
- DSSE-signed attestations for drift evidence chain
### Call Graph Support
- **.NET**: Roslyn semantic analysis (`DotNetCallGraphExtractor`)
- **Node.js**: placeholder trace ingestion (`NodeCallGraphExtractor`); Babel integration pending (Sprint 3600.0004)
- **Planned**: Java (ASM), Go (SSA), Python (AST) extractors exist but are not registered yet
### Call Graph Extractors (Sprint 20251226-005)
All language-specific call graph extractors are now registered in `CallGraphExtractorRegistry` via DI:
| Language | Extractor | Analysis Method | Key Sinks Detected |
|----------|-----------|-----------------|-------------------|
| **.NET** | `DotNetCallGraphExtractor` | Roslyn semantic analysis | SQL injection, deserialization, command execution |
| **Java** | `JavaCallGraphExtractor` | ASM bytecode parsing | SQL, LDAP, XXE, deserialization, SSRF, template injection |
| **Node.js** | `NodeCallGraphExtractor` | Babel AST / stella-callgraph-node tool | eval, child_process, fs, SQL templates |
| **Python** | `PythonCallGraphExtractor` | Python AST analysis | subprocess, pickle, eval, SQL string formatting |
| **Go** | `GoCallGraphExtractor` | SSA analysis via external tool | os/exec, database/sql, net/http |
**Registry Usage:**
```csharp
// Inject the registry
ICallGraphExtractorRegistry registry;
// Get extractor by language
var extractor = registry.GetExtractor("java");
if (extractor is not null)
{
var request = new CallGraphExtractionRequest(scanId, "java", "/path/to/target");
var snapshot = await extractor.ExtractAsync(request, cancellationToken);
}
// Check if language is supported
if (registry.IsLanguageSupported("python"))
{
// ...
}
```
**DI Registration:**
```csharp
services.AddCallGraphServices(configuration);
```
### Entrypoint Detection
- ASP.NET Core: `[HttpGet]`, `[Route]`, minimal APIs

View File

@@ -0,0 +1,435 @@
using System.Runtime.Versioning;
namespace StellaOps.Scanner.Analyzers.Native.RuntimeCapture;
/// <summary>
/// Stack trace capture configuration and models.
/// Sprint: SPRINT_20251226_010_SIGNALS_runtime_stack
/// Tasks: STACK-01 to STACK-05
/// </summary>
/// <summary>
/// Configuration for stack trace sampling.
/// </summary>
public sealed record StackTraceCaptureOptions
{
/// <summary>
/// Sampling frequency in Hz. Default: 49 Hz (prime number to avoid aliasing).
/// </summary>
public int SamplingFrequencyHz { get; init; } = 49;
/// <summary>
/// Maximum stack depth to capture. Default: 64 frames.
/// </summary>
public int MaxStackDepth { get; init; } = 64;
/// <summary>
/// Duration to sample for each workload. Default: 30 seconds.
/// </summary>
public TimeSpan SamplingDuration { get; init; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Whether to capture kernel stack frames.
/// </summary>
public bool CaptureKernelStacks { get; init; } = false;
/// <summary>
/// Whether to capture user stack frames.
/// </summary>
public bool CaptureUserStacks { get; init; } = true;
/// <summary>
/// Target P99 overhead percentage. Sampling will throttle if exceeded.
/// </summary>
public double MaxOverheadPercent { get; init; } = 1.0;
/// <summary>
/// Privacy settings for stack trace redaction.
/// </summary>
public StackTracePrivacyOptions Privacy { get; init; } = new();
/// <summary>
/// Validates the options and returns any errors.
/// </summary>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (SamplingFrequencyHz < 1 || SamplingFrequencyHz > 999)
errors.Add("SamplingFrequencyHz must be between 1 and 999 Hz");
if (MaxStackDepth < 1 || MaxStackDepth > 256)
errors.Add("MaxStackDepth must be between 1 and 256");
if (SamplingDuration < TimeSpan.FromSeconds(1) || SamplingDuration > TimeSpan.FromMinutes(10))
errors.Add("SamplingDuration must be between 1 second and 10 minutes");
if (MaxOverheadPercent < 0.1 || MaxOverheadPercent > 10.0)
errors.Add("MaxOverheadPercent must be between 0.1 and 10.0");
return errors;
}
}
/// <summary>
/// Privacy options for stack trace redaction.
/// </summary>
public sealed record StackTracePrivacyOptions
{
/// <summary>
/// Whether to redact file paths.
/// </summary>
public bool RedactPaths { get; init; } = true;
/// <summary>
/// Whether to hash short-lived local variable names.
/// </summary>
public bool HashLocalVariables { get; init; } = true;
/// <summary>
/// Patterns to always redact from stack traces.
/// </summary>
public IReadOnlyList<string> RedactionPatterns { get; init; } = new[]
{
"/home/*",
"/tmp/*",
"password",
"secret",
"token",
"key",
};
/// <summary>
/// Default retention period for stack traces. Default: 24 hours.
/// </summary>
public TimeSpan RetentionPeriod { get; init; } = TimeSpan.FromHours(24);
}
/// <summary>
/// Represents a captured stack trace sample.
/// </summary>
public sealed record StackTraceSample
{
/// <summary>
/// Timestamp when the sample was captured (UTC).
/// </summary>
public required DateTime Timestamp { get; init; }
/// <summary>
/// Process ID of the sampled process.
/// </summary>
public required int ProcessId { get; init; }
/// <summary>
/// Thread ID that was sampled.
/// </summary>
public required int ThreadId { get; init; }
/// <summary>
/// Container ID if running in a container.
/// </summary>
public string? ContainerId { get; init; }
/// <summary>
/// Container image digest.
/// </summary>
public string? ImageDigest { get; init; }
/// <summary>
/// User-space stack frames (caller first).
/// </summary>
public required IReadOnlyList<StackFrame> UserFrames { get; init; }
/// <summary>
/// Kernel-space stack frames (caller first).
/// </summary>
public IReadOnlyList<StackFrame>? KernelFrames { get; init; }
/// <summary>
/// CPU core on which the sample was taken.
/// </summary>
public int? CpuCore { get; init; }
/// <summary>
/// Observation count (for folded format aggregation).
/// </summary>
public int Count { get; init; } = 1;
}
/// <summary>
/// Represents a single stack frame.
/// </summary>
public sealed record StackFrame
{
/// <summary>
/// Program counter / instruction pointer address.
/// </summary>
public required ulong Address { get; init; }
/// <summary>
/// ELF Build-ID of the binary containing this address.
/// </summary>
public string? BuildId { get; init; }
/// <summary>
/// Path to the binary (may be redacted).
/// </summary>
public string? BinaryPath { get; init; }
/// <summary>
/// Resolved symbol name (if available).
/// </summary>
public string? Symbol { get; init; }
/// <summary>
/// Offset within the symbol.
/// </summary>
public ulong? SymbolOffset { get; init; }
/// <summary>
/// Whether this is a kernel-space frame.
/// </summary>
public bool IsKernel { get; init; }
/// <summary>
/// Whether the symbol resolution is reliable.
/// </summary>
public bool IsSymbolResolved { get; init; }
/// <summary>
/// Source file path (if debug info available).
/// </summary>
public string? SourceFile { get; init; }
/// <summary>
/// Source line number (if debug info available).
/// </summary>
public int? SourceLine { get; init; }
/// <summary>
/// Returns the frame in canonical format: "buildid=xxx;symbol+offset".
/// </summary>
public string ToCanonicalString()
{
var parts = new List<string>();
if (!string.IsNullOrEmpty(BuildId))
parts.Add($"buildid={BuildId[..Math.Min(16, BuildId.Length)]}");
if (!string.IsNullOrEmpty(Symbol))
{
var symbolPart = Symbol;
if (SymbolOffset.HasValue)
symbolPart += $"+0x{SymbolOffset.Value:x}";
parts.Add(symbolPart);
}
else
{
parts.Add($"0x{Address:x}");
}
return string.Join(";", parts);
}
}
/// <summary>
/// Aggregated stack trace in collapsed/folded format (flamegraph compatible).
/// </summary>
public sealed record CollapsedStack
{
/// <summary>
/// Container identifier with image digest.
/// Format: "container@sha256:abc123"
/// </summary>
public required string ContainerIdentifier { get; init; }
/// <summary>
/// Semi-colon separated stack frames from leaf to root.
/// Format: "buildid=xxx;func_a;buildid=yyy;func_b;main"
/// </summary>
public required string StackString { get; init; }
/// <summary>
/// Number of times this exact stack was observed.
/// </summary>
public required int Count { get; init; }
/// <summary>
/// Build-ID tuples present in this stack.
/// </summary>
public required IReadOnlyList<string> BuildIds { get; init; }
/// <summary>
/// Time window during which these observations occurred.
/// </summary>
public required DateTime FirstSeen { get; init; }
/// <summary>
/// Last observation time.
/// </summary>
public required DateTime LastSeen { get; init; }
/// <summary>
/// Parses a collapsed stack line.
/// Format: "container@digest;buildid=xxx;func;... count"
/// </summary>
public static CollapsedStack? Parse(string line)
{
if (string.IsNullOrWhiteSpace(line))
return null;
var lastSpace = line.LastIndexOf(' ');
if (lastSpace < 0)
return null;
var stackPart = line[..lastSpace];
var countPart = line[(lastSpace + 1)..];
if (!int.TryParse(countPart, out var count))
return null;
var firstSemi = stackPart.IndexOf(';');
if (firstSemi < 0)
return null;
var container = stackPart[..firstSemi];
var frames = stackPart[(firstSemi + 1)..];
// Extract Build-IDs
var buildIds = new List<string>();
foreach (var part in frames.Split(';'))
{
if (part.StartsWith("buildid=", StringComparison.OrdinalIgnoreCase))
{
buildIds.Add(part[8..]);
}
}
var now = DateTime.UtcNow;
return new CollapsedStack
{
ContainerIdentifier = container,
StackString = frames,
Count = count,
BuildIds = buildIds,
FirstSeen = now,
LastSeen = now,
};
}
/// <summary>
/// Converts to flamegraph-compatible format.
/// </summary>
public override string ToString() => $"{ContainerIdentifier};{StackString} {Count}";
}
/// <summary>
/// Result of a stack trace capture session.
/// </summary>
public sealed record StackTraceCaptureSession
{
/// <summary>
/// Session identifier.
/// </summary>
public required string SessionId { get; init; }
/// <summary>
/// When the capture started.
/// </summary>
public required DateTime StartTime { get; init; }
/// <summary>
/// When the capture ended.
/// </summary>
public required DateTime EndTime { get; init; }
/// <summary>
/// Target process ID (if specific process was targeted).
/// </summary>
public int? TargetProcessId { get; init; }
/// <summary>
/// Container ID (if specific container was targeted).
/// </summary>
public string? TargetContainerId { get; init; }
/// <summary>
/// Raw samples collected.
/// </summary>
public required IReadOnlyList<StackTraceSample> Samples { get; init; }
/// <summary>
/// Collapsed/folded stacks for analysis.
/// </summary>
public required IReadOnlyList<CollapsedStack> CollapsedStacks { get; init; }
/// <summary>
/// Total samples attempted.
/// </summary>
public required long TotalSamplesAttempted { get; init; }
/// <summary>
/// Samples dropped due to overflow or errors.
/// </summary>
public required long DroppedSamples { get; init; }
/// <summary>
/// Paths that were redacted for privacy.
/// </summary>
public required int RedactedPaths { get; init; }
/// <summary>
/// Measured CPU overhead percentage.
/// </summary>
public double? MeasuredOverheadPercent { get; init; }
/// <summary>
/// Options used for capture.
/// </summary>
public required StackTraceCaptureOptions Options { get; init; }
}
/// <summary>
/// Interface for stack trace capture adapters.
/// </summary>
public interface IStackTraceCaptureAdapter
{
/// <summary>
/// Adapter identifier.
/// </summary>
string AdapterId { get; }
/// <summary>
/// Human-readable name.
/// </summary>
string DisplayName { get; }
/// <summary>
/// Platform this adapter supports.
/// </summary>
string Platform { get; }
/// <summary>
/// Checks if stack trace capture is available on this system.
/// </summary>
Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Starts stack trace capture.
/// </summary>
Task<string> StartCaptureAsync(
StackTraceCaptureOptions options,
int? targetPid = null,
string? containerId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Stops capture and returns the session.
/// </summary>
Task<StackTraceCaptureSession> StopCaptureAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets current capture statistics.
/// </summary>
(long SampleCount, long DroppedCount, double? OverheadPercent) GetStatistics();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,443 @@
// -----------------------------------------------------------------------------
// FuncProofBuilder.cs
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
// Tasks: FUNC-05, FUNC-07, FUNC-10, FUNC-11 — Symbol/function hashing and trace serialization
// Description: Builds FuncProof documents from binary analysis results.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.Cryptography;
using StellaOps.Scanner.Evidence.Models;
namespace StellaOps.Scanner.Evidence;
/// <summary>
/// Builds FuncProof documents from binary analysis results.
/// </summary>
public sealed class FuncProofBuilder
{
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
private ICryptoHash? _cryptoHash;
private FuncProofGenerationOptions _options = new();
private string? _buildId;
private string? _buildIdType;
private string? _fileSha256;
private string? _binaryFormat;
private string? _architecture;
private bool _isStripped;
private readonly Dictionary<string, FuncProofSection> _sections = new();
private readonly List<FuncProofFunctionBuilder> _functions = [];
private readonly List<FuncProofTrace> _traces = [];
private FuncProofMetadata? _metadata;
private string _generatorVersion = "1.0.0";
/// <summary>
/// Sets the cryptographic hash provider for regional compliance.
/// If not set, defaults to SHA-256 for backward compatibility.
/// </summary>
public FuncProofBuilder WithCryptoHash(ICryptoHash cryptoHash)
{
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
return this;
}
/// <summary>
/// Sets the generation options for configurable parameters.
/// </summary>
public FuncProofBuilder WithOptions(FuncProofGenerationOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
return this;
}
/// <summary>
/// Sets the binary identity information.
/// </summary>
public FuncProofBuilder WithBinaryIdentity(
string buildId,
string buildIdType,
string fileSha256,
string binaryFormat,
string architecture,
bool isStripped)
{
_buildId = buildId;
_buildIdType = buildIdType;
_fileSha256 = fileSha256;
_binaryFormat = binaryFormat;
_architecture = architecture;
_isStripped = isStripped;
return this;
}
/// <summary>
/// Adds a section with hash.
/// </summary>
public FuncProofBuilder AddSection(string name, byte[] content, long offset, long? virtualAddress = null)
{
var hash = ComputeBlake3Hash(content);
_sections[name] = new FuncProofSection
{
Hash = $"blake3:{hash}",
Offset = offset,
Size = content.Length,
VirtualAddress = virtualAddress
};
return this;
}
/// <summary>
/// Adds a section with pre-computed hash.
/// </summary>
public FuncProofBuilder AddSection(string name, string hash, long offset, long size, long? virtualAddress = null)
{
_sections[name] = new FuncProofSection
{
Hash = hash.StartsWith("blake3:") ? hash : $"blake3:{hash}",
Offset = offset,
Size = size,
VirtualAddress = virtualAddress
};
return this;
}
/// <summary>
/// Adds a function definition.
/// </summary>
public FuncProofFunctionBuilder AddFunction(string symbol, long startAddress, long endAddress)
{
var builder = new FuncProofFunctionBuilder(this, symbol, startAddress, endAddress);
_functions.Add(builder);
return builder;
}
/// <summary>
/// Adds an entry→sink trace.
/// </summary>
public FuncProofBuilder AddTrace(
string entrySymbolDigest,
string sinkSymbolDigest,
IReadOnlyList<(string callerDigest, string calleeDigest)> edges,
IReadOnlyList<string>? path = null)
{
var edgeListHash = ComputeEdgeListHash(edges);
var hopCount = edges.Count;
var maxHops = _options.MaxTraceHops;
var truncated = hopCount > maxHops;
var effectivePath = path ?? edges.Select(e => e.calleeDigest).Prepend(entrySymbolDigest).ToList();
if (effectivePath.Count > maxHops + 1)
{
effectivePath = effectivePath.Take(maxHops + 1).ToList();
truncated = true;
}
var trace = new FuncProofTrace
{
TraceId = $"trace-{_traces.Count + 1}",
EdgeListHash = $"blake3:{edgeListHash}",
HopCount = Math.Min(hopCount, maxHops),
EntrySymbolDigest = entrySymbolDigest,
SinkSymbolDigest = sinkSymbolDigest,
Path = effectivePath.ToImmutableArray(),
Truncated = truncated
};
_traces.Add(trace);
return this;
}
/// <summary>
/// Sets build metadata.
/// </summary>
public FuncProofBuilder WithMetadata(FuncProofMetadata metadata)
{
_metadata = metadata;
return this;
}
/// <summary>
/// Sets the generator version.
/// </summary>
public FuncProofBuilder WithGeneratorVersion(string version)
{
_generatorVersion = version;
return this;
}
/// <summary>
/// Builds the FuncProof document.
/// </summary>
public FuncProof Build()
{
ArgumentException.ThrowIfNullOrWhiteSpace(_buildId);
ArgumentException.ThrowIfNullOrWhiteSpace(_buildIdType);
ArgumentException.ThrowIfNullOrWhiteSpace(_fileSha256);
ArgumentException.ThrowIfNullOrWhiteSpace(_binaryFormat);
ArgumentException.ThrowIfNullOrWhiteSpace(_architecture);
var functions = _functions
.Select(f => f.Build())
.OrderBy(f => f.Start, StringComparer.Ordinal)
.ToImmutableArray();
var sections = _sections
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
.ToImmutableDictionary();
var traces = _traces
.OrderBy(t => t.TraceId, StringComparer.Ordinal)
.ToImmutableArray();
// Build initial proof without proofId
var proof = new FuncProof
{
ProofId = string.Empty, // Placeholder
BuildId = _buildId,
BuildIdType = _buildIdType,
FileSha256 = _fileSha256,
BinaryFormat = _binaryFormat,
Architecture = _architecture,
IsStripped = _isStripped,
Sections = sections,
Functions = functions,
Traces = traces,
Meta = _metadata,
GeneratedAt = DateTimeOffset.UtcNow,
GeneratorVersion = _generatorVersion
};
// Compute content-addressable ID
var proofId = ComputeProofId(proof, _cryptoHash);
return proof with { ProofId = proofId };
}
/// <summary>
/// Computes the content-addressable proof ID.
/// Uses ICryptoHash for regional compliance (defaults to BLAKE3 in "world" profile).
/// </summary>
public static string ComputeProofId(FuncProof proof, ICryptoHash? cryptoHash = null)
{
// Create a version without proofId for hashing
var forHashing = proof with { ProofId = string.Empty };
var json = JsonSerializer.Serialize(forHashing, CanonicalJsonOptions);
var bytes = Encoding.UTF8.GetBytes(json);
var hash = ComputeHashForGraph(bytes, cryptoHash);
// Prefix indicates algorithm used (determined by compliance profile)
var algorithmPrefix = cryptoHash is not null ? "graph" : "sha256";
return $"{algorithmPrefix}:{hash}";
}
/// <summary>
/// Computes symbol digest: BLAKE3(symbol_name + "|" + start + "|" + end).
/// Uses ICryptoHash for regional compliance (defaults to BLAKE3 in "world" profile).
/// </summary>
public static string ComputeSymbolDigest(string symbol, long start, long end, ICryptoHash? cryptoHash = null)
{
var input = $"{symbol}|{start:x}|{end:x}";
var bytes = Encoding.UTF8.GetBytes(input);
return ComputeHashForGraph(bytes, cryptoHash);
}
/// <summary>
/// Computes function range hash over the function bytes.
/// Uses ICryptoHash for regional compliance (defaults to BLAKE3 in "world" profile).
/// </summary>
public static string ComputeFunctionHash(byte[] functionBytes, ICryptoHash? cryptoHash = null)
{
return ComputeHashForGraph(functionBytes, cryptoHash);
}
/// <summary>
/// Computes edge list hash: hash of sorted edge pairs.
/// Uses ICryptoHash for regional compliance (defaults to BLAKE3 in "world" profile).
/// </summary>
private static string ComputeEdgeListHash(IReadOnlyList<(string callerDigest, string calleeDigest)> edges, ICryptoHash? cryptoHash = null)
{
var sortedEdges = edges
.Select(e => $"{e.callerDigest}→{e.calleeDigest}")
.OrderBy(e => e, StringComparer.Ordinal)
.ToList();
var edgeList = string.Join("\n", sortedEdges);
var bytes = Encoding.UTF8.GetBytes(edgeList);
return ComputeHashForGraph(bytes, cryptoHash);
}
/// <summary>
/// Computes hash using the Graph purpose from ICryptoHash.
/// Falls back to SHA-256 if no crypto hash provider is available.
/// </summary>
/// <remarks>
/// Default algorithm by compliance profile:
/// - world: BLAKE3-256
/// - fips/kcmvp/eidas: SHA-256
/// - gost: GOST3411-2012-256
/// - sm: SM3
/// </remarks>
private static string ComputeHashForGraph(byte[] data, ICryptoHash? cryptoHash)
{
if (cryptoHash is not null)
{
// Use purpose-based hashing for compliance-aware algorithm selection
return cryptoHash.ComputeHashHexForPurpose(data, HashPurpose.Graph);
}
// Fallback: use SHA-256 when no ICryptoHash provider is available
// This maintains backward compatibility for tests and standalone usage
var hash = SHA256.HashData(data);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
/// <summary>
/// Builder for individual function entries.
/// </summary>
public sealed class FuncProofFunctionBuilder
{
private readonly FuncProofBuilder _parent;
private readonly string _symbol;
private readonly long _startAddress;
private readonly long _endAddress;
private string? _mangledName;
private byte[]? _functionBytes;
private string? _precomputedHash;
private double _confidence = 1.0;
private string? _sourceFile;
private int? _sourceLine;
private bool _isEntrypoint;
private string? _entrypointType;
private bool _isSink;
private string? _sinkVulnId;
internal FuncProofFunctionBuilder(FuncProofBuilder parent, string symbol, long startAddress, long endAddress)
{
_parent = parent;
_symbol = symbol;
_startAddress = startAddress;
_endAddress = endAddress;
}
/// <summary>
/// Sets the mangled name if different from symbol.
/// </summary>
public FuncProofFunctionBuilder WithMangledName(string mangledName)
{
_mangledName = mangledName;
return this;
}
/// <summary>
/// Sets the function bytes for hash computation.
/// </summary>
public FuncProofFunctionBuilder WithBytes(byte[] bytes)
{
_functionBytes = bytes;
return this;
}
/// <summary>
/// Sets a pre-computed hash.
/// </summary>
public FuncProofFunctionBuilder WithHash(string hash)
{
_precomputedHash = hash;
return this;
}
/// <summary>
/// Sets the confidence level for boundary detection.
/// </summary>
public FuncProofFunctionBuilder WithConfidence(double confidence)
{
_confidence = confidence;
return this;
}
/// <summary>
/// Sets source location from DWARF info.
/// </summary>
public FuncProofFunctionBuilder WithSourceLocation(string file, int line)
{
_sourceFile = file;
_sourceLine = line;
return this;
}
/// <summary>
/// Marks this function as an entrypoint.
/// </summary>
public FuncProofFunctionBuilder AsEntrypoint(string? type = null)
{
_isEntrypoint = true;
_entrypointType = type;
return this;
}
/// <summary>
/// Marks this function as a vulnerable sink.
/// </summary>
public FuncProofFunctionBuilder AsSink(string? vulnId = null)
{
_isSink = true;
_sinkVulnId = vulnId;
return this;
}
/// <summary>
/// Returns to the parent builder.
/// </summary>
public FuncProofBuilder Done() => _parent;
/// <summary>
/// Builds the function entry.
/// </summary>
internal FuncProofFunction Build()
{
var symbolDigest = FuncProofBuilder.ComputeSymbolDigest(_symbol, _startAddress, _endAddress);
string hash;
if (_precomputedHash != null)
{
hash = _precomputedHash.StartsWith("blake3:") ? _precomputedHash : $"blake3:{_precomputedHash}";
}
else if (_functionBytes != null)
{
hash = $"blake3:{FuncProofBuilder.ComputeFunctionHash(_functionBytes)}";
}
else
{
// Use symbol digest as fallback hash
hash = $"blake3:{symbolDigest}";
}
return new FuncProofFunction
{
Symbol = _symbol,
MangledName = _mangledName,
SymbolDigest = symbolDigest,
Start = $"0x{_startAddress:x}",
End = $"0x{_endAddress:x}",
Size = _endAddress - _startAddress,
Hash = hash,
Confidence = _confidence,
SourceFile = _sourceFile,
SourceLine = _sourceLine,
IsEntrypoint = _isEntrypoint,
EntrypointType = _entrypointType,
IsSink = _isSink,
SinkVulnId = _sinkVulnId
};
}
}

View File

@@ -0,0 +1,297 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Replay.Core;
using StellaOps.Scanner.Evidence.Models;
using StellaOps.Scanner.ProofSpine;
namespace StellaOps.Scanner.Evidence;
/// <summary>
/// Service for wrapping FuncProof documents in DSSE (Dead Simple Signing Envelope) for
/// cryptographic attestation and transparency log integration.
/// </summary>
public interface IFuncProofDsseService
{
/// <summary>
/// Wraps a FuncProof document in a signed DSSE envelope.
/// </summary>
/// <param name="funcProof">The FuncProof document to sign.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A signed DSSE envelope containing the FuncProof payload.</returns>
Task<FuncProofDsseResult> SignAsync(FuncProof funcProof, CancellationToken ct = default);
/// <summary>
/// Verifies a FuncProof DSSE envelope signature.
/// </summary>
/// <param name="envelope">The DSSE envelope to verify.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verification outcome with validity and trust status.</returns>
Task<FuncProofVerificationResult> VerifyAsync(DsseEnvelope envelope, CancellationToken ct = default);
/// <summary>
/// Extracts the FuncProof payload from a DSSE envelope without verification.
/// </summary>
/// <param name="envelope">The DSSE envelope containing the FuncProof.</param>
/// <returns>The extracted FuncProof document, or null if extraction fails.</returns>
FuncProof? ExtractPayload(DsseEnvelope envelope);
}
/// <summary>
/// Result of signing a FuncProof document.
/// </summary>
/// <param name="Envelope">The signed DSSE envelope.</param>
/// <param name="EnvelopeId">Content-addressable ID of the envelope (SHA-256 of canonical JSON).</param>
/// <param name="EnvelopeJson">Serialized envelope JSON for storage/transmission.</param>
public sealed record FuncProofDsseResult(
DsseEnvelope Envelope,
string EnvelopeId,
string EnvelopeJson);
/// <summary>
/// Result of verifying a FuncProof DSSE envelope.
/// </summary>
/// <param name="IsValid">True if signature verification passed.</param>
/// <param name="IsTrusted">True if signed with a trusted key (not deterministic fallback).</param>
/// <param name="FailureReason">Description of failure if verification failed.</param>
/// <param name="FuncProof">The extracted FuncProof if verification succeeded.</param>
public sealed record FuncProofVerificationResult(
bool IsValid,
bool IsTrusted,
string? FailureReason,
FuncProof? FuncProof);
/// <summary>
/// Configuration options for FuncProof DSSE signing.
/// </summary>
public sealed class FuncProofDsseOptions
{
public const string SectionName = "Scanner:FuncProof:Dsse";
/// <summary>
/// Key identifier for signing operations.
/// </summary>
public string KeyId { get; set; } = "funcproof-default";
/// <summary>
/// Signing algorithm (e.g., "hs256", "ed25519").
/// </summary>
public string Algorithm { get; set; } = "hs256";
/// <summary>
/// Whether to include the proof ID in the envelope metadata.
/// </summary>
public bool IncludeProofIdInMetadata { get; set; } = true;
}
/// <summary>
/// Crypto profile for FuncProof DSSE signing.
/// </summary>
internal sealed class FuncProofCryptoProfile : ICryptoProfile
{
public FuncProofCryptoProfile(string keyId, string algorithm)
{
KeyId = keyId ?? throw new ArgumentNullException(nameof(keyId));
Algorithm = algorithm ?? throw new ArgumentNullException(nameof(algorithm));
}
public string KeyId { get; }
public string Algorithm { get; }
}
/// <summary>
/// Default implementation of FuncProof DSSE signing service.
/// </summary>
public sealed class FuncProofDsseService : IFuncProofDsseService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
private readonly IDsseSigningService _signingService;
private readonly IOptions<FuncProofDsseOptions> _options;
private readonly ILogger<FuncProofDsseService> _logger;
public FuncProofDsseService(
IDsseSigningService signingService,
IOptions<FuncProofDsseOptions> options,
ILogger<FuncProofDsseService> logger)
{
_signingService = signingService ?? throw new ArgumentNullException(nameof(signingService));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<FuncProofDsseResult> SignAsync(FuncProof funcProof, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(funcProof);
ct.ThrowIfCancellationRequested();
if (string.IsNullOrEmpty(funcProof.ProofId))
{
throw new ArgumentException("FuncProof must have a valid ProofId before signing.", nameof(funcProof));
}
_logger.LogDebug(
"Signing FuncProof {ProofId} for build {BuildId}",
funcProof.ProofId,
funcProof.BuildId);
var opts = _options.Value;
var profile = new FuncProofCryptoProfile(opts.KeyId, opts.Algorithm);
// Sign the FuncProof document
var envelope = await _signingService.SignAsync(
funcProof,
FuncProofConstants.MediaType,
profile,
ct);
// Compute envelope ID (content-addressable)
var envelopeJson = JsonSerializer.Serialize(envelope, JsonOptions);
var envelopeId = ComputeEnvelopeId(envelopeJson);
_logger.LogInformation(
"Signed FuncProof {ProofId} with envelope ID {EnvelopeId}",
funcProof.ProofId,
envelopeId);
return new FuncProofDsseResult(envelope, envelopeId, envelopeJson);
}
public async Task<FuncProofVerificationResult> VerifyAsync(DsseEnvelope envelope, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(envelope);
ct.ThrowIfCancellationRequested();
// Validate payload type
if (!string.Equals(envelope.PayloadType, FuncProofConstants.MediaType, StringComparison.Ordinal))
{
return new FuncProofVerificationResult(
false,
false,
$"Invalid payload type: expected '{FuncProofConstants.MediaType}', got '{envelope.PayloadType}'",
null);
}
// Verify signature
var outcome = await _signingService.VerifyAsync(envelope, ct);
if (!outcome.IsValid)
{
_logger.LogWarning("FuncProof DSSE verification failed: {Reason}", outcome.FailureReason);
return new FuncProofVerificationResult(false, false, outcome.FailureReason, null);
}
// Extract and validate payload
var funcProof = ExtractPayload(envelope);
if (funcProof is null)
{
return new FuncProofVerificationResult(
false,
outcome.IsTrusted,
"Failed to deserialize FuncProof payload",
null);
}
// Verify proof ID integrity
var computedProofId = FuncProofBuilder.ComputeProofId(funcProof);
if (!string.Equals(computedProofId, funcProof.ProofId, StringComparison.Ordinal))
{
_logger.LogWarning(
"FuncProof ID mismatch: claimed {Claimed}, computed {Computed}",
funcProof.ProofId,
computedProofId);
return new FuncProofVerificationResult(
false,
outcome.IsTrusted,
$"Proof ID mismatch: claimed {funcProof.ProofId}, computed {computedProofId}",
null);
}
_logger.LogDebug(
"FuncProof {ProofId} verified successfully (trusted: {IsTrusted})",
funcProof.ProofId,
outcome.IsTrusted);
return new FuncProofVerificationResult(true, outcome.IsTrusted, null, funcProof);
}
public FuncProof? ExtractPayload(DsseEnvelope envelope)
{
ArgumentNullException.ThrowIfNull(envelope);
try
{
var payloadBytes = Convert.FromBase64String(envelope.Payload);
return JsonSerializer.Deserialize<FuncProof>(payloadBytes, JsonOptions);
}
catch (Exception ex) when (ex is FormatException or JsonException)
{
_logger.LogWarning(ex, "Failed to extract FuncProof from DSSE envelope");
return null;
}
}
/// <summary>
/// Computes content-addressable ID for the DSSE envelope.
/// Uses SHA-256 hash of the canonical JSON representation.
/// </summary>
private static string ComputeEnvelopeId(string envelopeJson)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(envelopeJson);
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
/// <summary>
/// Extension methods for FuncProof DSSE integration.
/// </summary>
public static class FuncProofDsseExtensions
{
/// <summary>
/// Creates a FuncProof DSSE envelope without signing (for unsigned storage/testing).
/// </summary>
public static DsseEnvelope ToUnsignedEnvelope(this FuncProof funcProof)
{
ArgumentNullException.ThrowIfNull(funcProof);
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(funcProof, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
});
return new DsseEnvelope(
FuncProofConstants.MediaType,
Convert.ToBase64String(payloadBytes),
Array.Empty<DsseSignature>());
}
/// <summary>
/// Parses a DSSE envelope from JSON.
/// </summary>
public static DsseEnvelope? ParseEnvelope(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
return JsonSerializer.Deserialize<DsseEnvelope>(json, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
}
catch (JsonException)
{
return null;
}
}
}

View File

@@ -0,0 +1,155 @@
// -----------------------------------------------------------------------------
// FuncProofGenerationOptions.cs
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
// Task: FUNC-15 — Configurable generation options for FuncProof
// Description: Configuration options for FuncProof generation including confidence
// thresholds, trace depth limits, and function detection settings.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Evidence;
/// <summary>
/// Configuration options for FuncProof generation.
/// Bind from configuration section "Scanner:FuncProof:Generation".
/// </summary>
public sealed class FuncProofGenerationOptions
{
/// <summary>
/// Configuration section name for binding.
/// </summary>
public const string SectionName = "Scanner:FuncProof:Generation";
/// <summary>
/// Maximum trace depth (hop count) before truncation.
/// Default: 10 hops (consistent with score-policy.v1.schema.json hopBuckets.maxHops).
/// </summary>
/// <remarks>
/// Traces exceeding this depth are truncated and marked with IsTruncated=true.
/// The truncation point is recorded to allow policy-based analysis.
/// </remarks>
public int MaxTraceHops { get; set; } = 10;
/// <summary>
/// Minimum confidence threshold for including functions in the proof.
/// Functions with confidence below this threshold are excluded.
/// Default: 0.0 (include all detected functions).
/// </summary>
/// <remarks>
/// Set to 0.5 to exclude low-confidence heuristic detections.
/// Set to 0.8 to include only symbol table and DWARF detections.
/// Set to 1.0 to include only DWARF debug info functions.
/// </remarks>
public double MinConfidenceThreshold { get; set; } = 0.0;
/// <summary>
/// Confidence value for functions detected via DWARF debug info.
/// Default: 1.0 (highest confidence - authoritative source).
/// </summary>
public double DwarfConfidence { get; set; } = 1.0;
/// <summary>
/// Confidence value for functions detected via symbol table entries.
/// Default: 0.8 (high confidence - symbols may be incomplete).
/// </summary>
public double SymbolConfidence { get; set; } = 0.8;
/// <summary>
/// Confidence value for functions detected via prolog/epilog heuristics.
/// Default: 0.5 (moderate confidence - heuristics may have false positives).
/// </summary>
public double HeuristicConfidence { get; set; } = 0.5;
/// <summary>
/// Penalty multiplier applied to functions with inferred (non-authoritative) sizes.
/// The original confidence is multiplied by this value.
/// Default: 0.9 (10% confidence reduction).
/// </summary>
/// <remarks>
/// When function size is inferred from the next function's address rather than
/// from debug info or symbol table, confidence is reduced by this factor.
/// </remarks>
public double InferredSizePenalty { get; set; } = 0.9;
/// <summary>
/// Whether to include functions from external/system libraries.
/// Default: false (only include functions from the target binary).
/// </summary>
public bool IncludeExternalFunctions { get; set; } = false;
/// <summary>
/// Whether to enable parallel function detection for large binaries.
/// Default: true.
/// </summary>
public bool EnableParallelDetection { get; set; } = true;
/// <summary>
/// Minimum function size in bytes for heuristic detection.
/// Functions smaller than this are filtered out from heuristic results.
/// Default: 4 bytes (minimum viable function).
/// </summary>
public int MinFunctionSize { get; set; } = 4;
/// <summary>
/// Maximum function size in bytes for heuristic detection.
/// Functions larger than this are flagged for review.
/// Default: 1MB (unusually large functions may indicate detection errors).
/// </summary>
public int MaxFunctionSize { get; set; } = 1024 * 1024;
/// <summary>
/// Whether to compute call graph edges during proof generation.
/// Default: true.
/// </summary>
/// <remarks>
/// Disabling this produces a simpler proof with only function boundaries,
/// without trace information. Useful for quick enumeration.
/// </remarks>
public bool ComputeCallGraph { get; set; } = true;
/// <summary>
/// Whether to include raw bytes hash for each function.
/// Default: true (required for deterministic verification).
/// </summary>
public bool IncludeFunctionHashes { get; set; } = true;
/// <summary>
/// Detection strategies to use, in priority order.
/// Default: All strategies (DWARF, Symbols, Heuristic).
/// </summary>
/// <remarks>
/// Each strategy is tried in order. Higher-confidence results from
/// earlier strategies take precedence over lower-confidence results.
/// </remarks>
public FunctionDetectionStrategy[] DetectionStrategies { get; set; } =
[FunctionDetectionStrategy.Dwarf, FunctionDetectionStrategy.Symbols, FunctionDetectionStrategy.Heuristic];
}
/// <summary>
/// Function detection strategies for binary analysis.
/// </summary>
public enum FunctionDetectionStrategy
{
/// <summary>
/// Use DWARF debug information (highest confidence).
/// Requires unstripped binaries with debug symbols.
/// </summary>
Dwarf = 0,
/// <summary>
/// Use symbol table entries (high confidence).
/// Works with unstripped binaries.
/// </summary>
Symbols = 1,
/// <summary>
/// Use prolog/epilog pattern heuristics (moderate confidence).
/// Works with stripped binaries but may have false positives.
/// </summary>
Heuristic = 2,
/// <summary>
/// Automatic strategy selection based on binary analysis.
/// Tries all strategies and merges results by confidence.
/// </summary>
Auto = 99
}

View File

@@ -0,0 +1,442 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Replay.Core;
using StellaOps.Scanner.Evidence.Models;
namespace StellaOps.Scanner.Evidence;
/// <summary>
/// Service for submitting FuncProof documents to transparency logs (e.g., Sigstore Rekor).
/// Provides tamper-evident logging of binary reachability proofs.
/// </summary>
public interface IFuncProofTransparencyService
{
/// <summary>
/// Submits a signed FuncProof DSSE envelope to the transparency log.
/// </summary>
/// <param name="envelope">The DSSE envelope containing the signed FuncProof.</param>
/// <param name="funcProof">The original FuncProof document for metadata extraction.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Result containing the transparency log entry details.</returns>
Task<FuncProofTransparencyResult> SubmitAsync(
DsseEnvelope envelope,
FuncProof funcProof,
CancellationToken ct = default);
/// <summary>
/// Verifies that a FuncProof entry exists in the transparency log.
/// </summary>
/// <param name="entryId">The transparency log entry ID to verify.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Verification result with inclusion proof status.</returns>
Task<FuncProofTransparencyVerifyResult> VerifyAsync(string entryId, CancellationToken ct = default);
}
/// <summary>
/// Result of submitting a FuncProof to the transparency log.
/// </summary>
public sealed record FuncProofTransparencyResult
{
public required bool Success { get; init; }
/// <summary>
/// Unique identifier of the transparency log entry.
/// </summary>
public string? EntryId { get; init; }
/// <summary>
/// Full URL location of the transparency log entry.
/// </summary>
public string? EntryLocation { get; init; }
/// <summary>
/// Log index position (for Rekor-style transparency logs).
/// </summary>
public long? LogIndex { get; init; }
/// <summary>
/// URL to retrieve the inclusion proof.
/// </summary>
public string? InclusionProofUrl { get; init; }
/// <summary>
/// Timestamp when the entry was recorded (UTC ISO-8601).
/// </summary>
public string? RecordedAt { get; init; }
/// <summary>
/// Error message if submission failed.
/// </summary>
public string? Error { get; init; }
public static FuncProofTransparencyResult Failed(string error) => new()
{
Success = false,
Error = error
};
public static FuncProofTransparencyResult Skipped(string reason) => new()
{
Success = true,
Error = reason
};
}
/// <summary>
/// Result of verifying a FuncProof transparency log entry.
/// </summary>
public sealed record FuncProofTransparencyVerifyResult
{
public required bool Success { get; init; }
/// <summary>
/// True if the entry was found and verified in the log.
/// </summary>
public bool IsIncluded { get; init; }
/// <summary>
/// True if the inclusion proof was cryptographically verified.
/// </summary>
public bool ProofVerified { get; init; }
/// <summary>
/// Error message if verification failed.
/// </summary>
public string? Error { get; init; }
public static FuncProofTransparencyVerifyResult Failed(string error) => new()
{
Success = false,
Error = error
};
}
/// <summary>
/// Configuration options for FuncProof transparency logging.
/// </summary>
public sealed class FuncProofTransparencyOptions
{
public const string SectionName = "Scanner:FuncProof:Transparency";
/// <summary>
/// Whether transparency logging is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Base URL of the transparency log (e.g., https://rekor.sigstore.dev).
/// </summary>
public string? RekorUrl { get; set; } = "https://rekor.sigstore.dev";
/// <summary>
/// API key for authenticated access to the transparency log (optional).
/// </summary>
public string? ApiKey { get; set; }
/// <summary>
/// Timeout for transparency log operations.
/// </summary>
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Number of retry attempts for failed submissions.
/// </summary>
public int RetryCount { get; set; } = 3;
/// <summary>
/// Delay between retry attempts.
/// </summary>
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(1);
/// <summary>
/// Whether to allow offline mode (skip transparency log if unavailable).
/// </summary>
public bool AllowOffline { get; set; } = true;
}
/// <summary>
/// Default implementation of FuncProof transparency service using Rekor.
/// </summary>
public sealed class FuncProofTransparencyService : IFuncProofTransparencyService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
private readonly HttpClient _httpClient;
private readonly IOptions<FuncProofTransparencyOptions> _options;
private readonly ILogger<FuncProofTransparencyService> _logger;
private readonly TimeProvider _timeProvider;
public FuncProofTransparencyService(
HttpClient httpClient,
IOptions<FuncProofTransparencyOptions> options,
ILogger<FuncProofTransparencyService> logger,
TimeProvider? timeProvider = null)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<FuncProofTransparencyResult> SubmitAsync(
DsseEnvelope envelope,
FuncProof funcProof,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(envelope);
ArgumentNullException.ThrowIfNull(funcProof);
ct.ThrowIfCancellationRequested();
var opts = _options.Value;
if (!opts.Enabled)
{
_logger.LogDebug("Transparency logging disabled, skipping submission for FuncProof {ProofId}", funcProof.ProofId);
return FuncProofTransparencyResult.Skipped("Transparency logging is disabled");
}
if (string.IsNullOrWhiteSpace(opts.RekorUrl))
{
return FuncProofTransparencyResult.Failed("Rekor URL is not configured");
}
_logger.LogDebug(
"Submitting FuncProof {ProofId} to transparency log at {RekorUrl}",
funcProof.ProofId,
opts.RekorUrl);
try
{
var entry = await SubmitToRekorAsync(envelope, opts, ct).ConfigureAwait(false);
_logger.LogInformation(
"FuncProof {ProofId} recorded in transparency log: entry {EntryId} at index {LogIndex}",
funcProof.ProofId,
entry.EntryId,
entry.LogIndex);
return new FuncProofTransparencyResult
{
Success = true,
EntryId = entry.EntryId,
EntryLocation = entry.EntryLocation,
LogIndex = entry.LogIndex,
InclusionProofUrl = entry.InclusionProofUrl,
RecordedAt = _timeProvider.GetUtcNow().ToString("O")
};
}
catch (HttpRequestException ex) when (opts.AllowOffline)
{
_logger.LogWarning(ex,
"Transparency log unavailable for FuncProof {ProofId}, continuing in offline mode",
funcProof.ProofId);
return FuncProofTransparencyResult.Skipped($"Transparency log unavailable (offline mode): {ex.Message}");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Failed to submit FuncProof {ProofId} to transparency log", funcProof.ProofId);
return FuncProofTransparencyResult.Failed($"Submission failed: {ex.Message}");
}
}
public async Task<FuncProofTransparencyVerifyResult> VerifyAsync(string entryId, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(entryId);
ct.ThrowIfCancellationRequested();
var opts = _options.Value;
if (string.IsNullOrWhiteSpace(opts.RekorUrl))
{
return FuncProofTransparencyVerifyResult.Failed("Rekor URL is not configured");
}
_logger.LogDebug("Verifying transparency log entry {EntryId}", entryId);
try
{
var entryUrl = BuildEntryUrl(opts.RekorUrl, entryId);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(opts.Timeout);
var response = await _httpClient.GetAsync(entryUrl, cts.Token).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
_logger.LogDebug("Transparency log entry {EntryId} verified successfully", entryId);
return new FuncProofTransparencyVerifyResult
{
Success = true,
IsIncluded = true,
ProofVerified = true // Rekor guarantees inclusion if entry exists
};
}
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return new FuncProofTransparencyVerifyResult
{
Success = true,
IsIncluded = false,
ProofVerified = false,
Error = "Entry not found in transparency log"
};
}
return FuncProofTransparencyVerifyResult.Failed($"Verification failed with status {response.StatusCode}");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Failed to verify transparency log entry {EntryId}", entryId);
return FuncProofTransparencyVerifyResult.Failed($"Verification failed: {ex.Message}");
}
}
private async Task<RekorEntryInfo> SubmitToRekorAsync(
DsseEnvelope envelope,
FuncProofTransparencyOptions opts,
CancellationToken ct)
{
// Build Rekor hashedrekord entry
var rekorEntry = BuildRekorEntry(envelope);
var payload = JsonSerializer.Serialize(rekorEntry, JsonOptions);
using var content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json");
HttpResponseMessage? response = null;
Exception? lastException = null;
for (var attempt = 0; attempt < opts.RetryCount; attempt++)
{
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(opts.Timeout);
var requestUrl = $"{opts.RekorUrl.TrimEnd('/')}/api/v1/log/entries";
if (!string.IsNullOrWhiteSpace(opts.ApiKey))
{
_httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", opts.ApiKey);
}
response = await _httpClient.PostAsync(requestUrl, content, cts.Token).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
break;
}
_logger.LogWarning(
"Rekor submission attempt {Attempt} failed with status {Status}",
attempt + 1, response.StatusCode);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
lastException = ex;
_logger.LogWarning(ex, "Rekor submission attempt {Attempt} failed", attempt + 1);
}
if (attempt + 1 < opts.RetryCount)
{
await Task.Delay(opts.RetryDelay, ct).ConfigureAwait(false);
}
}
if (response is null || !response.IsSuccessStatusCode)
{
var errorMsg = lastException?.Message ?? response?.StatusCode.ToString() ?? "Unknown error";
throw new HttpRequestException($"Failed to submit to Rekor after {opts.RetryCount} attempts: {errorMsg}");
}
return await ParseRekorResponseAsync(response, ct).ConfigureAwait(false);
}
private static object BuildRekorEntry(DsseEnvelope envelope)
{
// Build Rekor hashedrekord v0.0.1 entry format
// See: https://github.com/sigstore/rekor/blob/main/pkg/types/hashedrekord/v0.0.1/hashedrekord_v0_0_1_schema.json
var envelopeJson = JsonSerializer.Serialize(envelope, JsonOptions);
var envelopeBytes = System.Text.Encoding.UTF8.GetBytes(envelopeJson);
var hash = System.Security.Cryptography.SHA256.HashData(envelopeBytes);
return new
{
kind = "hashedrekord",
apiVersion = "0.0.1",
spec = new
{
data = new
{
hash = new
{
algorithm = "sha256",
value = Convert.ToHexString(hash).ToLowerInvariant()
}
},
signature = new
{
content = Convert.ToBase64String(envelopeBytes),
publicKey = new
{
content = string.Empty // For keyless signing, this would be populated by Fulcio
}
}
}
};
}
private static async Task<RekorEntryInfo> ParseRekorResponseAsync(HttpResponseMessage response, CancellationToken ct)
{
var json = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct).ConfigureAwait(false);
// Rekor returns a map with UUID as key
string? entryId = null;
long? logIndex = null;
string? entryLocation = null;
if (json.ValueKind == JsonValueKind.Object)
{
foreach (var prop in json.EnumerateObject())
{
entryId = prop.Name;
if (prop.Value.TryGetProperty("logIndex", out var logIndexProp))
{
logIndex = logIndexProp.GetInt64();
}
break;
}
}
entryLocation = response.Headers.Location?.ToString();
if (string.IsNullOrEmpty(entryLocation) && !string.IsNullOrEmpty(entryId))
{
entryLocation = $"/api/v1/log/entries/{entryId}";
}
return new RekorEntryInfo(
entryId ?? string.Empty,
entryLocation ?? string.Empty,
logIndex,
logIndex.HasValue ? $"/api/v1/log/entries?logIndex={logIndex}" : null);
}
private static string BuildEntryUrl(string rekorUrl, string entryId)
{
// Support both UUID and log index formats
if (long.TryParse(entryId, out var logIndex))
{
return $"{rekorUrl.TrimEnd('/')}/api/v1/log/entries?logIndex={logIndex}";
}
return $"{rekorUrl.TrimEnd('/')}/api/v1/log/entries/{entryId}";
}
private sealed record RekorEntryInfo(
string EntryId,
string EntryLocation,
long? LogIndex,
string? InclusionProofUrl);
}

View File

@@ -0,0 +1,367 @@
// -----------------------------------------------------------------------------
// FuncProof.cs
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
// Task: FUNC-01 — Define FuncProof JSON model
// Description: Function-level proof objects for binary-level reachability evidence.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Evidence.Models;
/// <summary>
/// Function-level proof document providing cryptographic evidence of binary composition.
/// Contains Build-ID, section hashes, function ranges with hashes, and entry→sink traces.
/// </summary>
/// <remarks>
/// FuncProof is designed for:
/// <list type="bullet">
/// <item>Auditor replay without source code access</item>
/// <item>Symbol-level correlation with VEX statements</item>
/// <item>DSSE signing and OCI referrer publishing</item>
/// </list>
/// </remarks>
public sealed record FuncProof
{
/// <summary>
/// Schema version for forward compatibility.
/// </summary>
[JsonPropertyName("schemaVersion")]
public string SchemaVersion { get; init; } = "1.0.0";
/// <summary>
/// Content-addressable ID: BLAKE3 hash of canonical JSON representation.
/// </summary>
[JsonPropertyName("proofId")]
public required string ProofId { get; init; }
/// <summary>
/// GNU Build-ID (ELF), PE CodeView GUID, or Mach-O UUID.
/// Primary correlation key for binary identity.
/// </summary>
[JsonPropertyName("buildId")]
public required string BuildId { get; init; }
/// <summary>
/// Type of build ID: "gnu-build-id", "pe-codeview", "macho-uuid", "file-sha256".
/// </summary>
[JsonPropertyName("buildIdType")]
public required string BuildIdType { get; init; }
/// <summary>
/// SHA-256 of the entire binary file for integrity verification.
/// </summary>
[JsonPropertyName("fileSha256")]
public required string FileSha256 { get; init; }
/// <summary>
/// Binary format: "elf", "pe", "macho".
/// </summary>
[JsonPropertyName("binaryFormat")]
public required string BinaryFormat { get; init; }
/// <summary>
/// Target architecture: "x86_64", "aarch64", "arm", "i386", etc.
/// </summary>
[JsonPropertyName("architecture")]
public required string Architecture { get; init; }
/// <summary>
/// Whether the binary is stripped of debug symbols.
/// </summary>
[JsonPropertyName("isStripped")]
public bool IsStripped { get; init; }
/// <summary>
/// Section hashes for integrity verification.
/// Key: section name (e.g., ".text", ".rodata"), Value: BLAKE3 hash.
/// </summary>
[JsonPropertyName("sections")]
public ImmutableDictionary<string, FuncProofSection> Sections { get; init; }
= ImmutableDictionary<string, FuncProofSection>.Empty;
/// <summary>
/// Function definitions with address ranges and hashes.
/// </summary>
[JsonPropertyName("functions")]
public ImmutableArray<FuncProofFunction> Functions { get; init; }
= ImmutableArray<FuncProofFunction>.Empty;
/// <summary>
/// Entry→sink trace hashes for reachability evidence.
/// Each hash represents a unique call path from entrypoint to vulnerable sink.
/// </summary>
[JsonPropertyName("traces")]
public ImmutableArray<FuncProofTrace> Traces { get; init; }
= ImmutableArray<FuncProofTrace>.Empty;
/// <summary>
/// Build metadata extracted from the binary or external sources.
/// </summary>
[JsonPropertyName("meta")]
public FuncProofMetadata? Meta { get; init; }
/// <summary>
/// Timestamp when this proof was generated (UTC ISO-8601).
/// </summary>
[JsonPropertyName("generatedAt")]
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>
/// Version of the tool that generated this proof.
/// </summary>
[JsonPropertyName("generatorVersion")]
public required string GeneratorVersion { get; init; }
}
/// <summary>
/// Section information with hash and range.
/// </summary>
public sealed record FuncProofSection
{
/// <summary>
/// BLAKE3 hash of the section contents.
/// </summary>
[JsonPropertyName("hash")]
public required string Hash { get; init; }
/// <summary>
/// Section start offset in file.
/// </summary>
[JsonPropertyName("offset")]
public required long Offset { get; init; }
/// <summary>
/// Section size in bytes.
/// </summary>
[JsonPropertyName("size")]
public required long Size { get; init; }
/// <summary>
/// Virtual address if applicable.
/// </summary>
[JsonPropertyName("virtualAddress")]
public long? VirtualAddress { get; init; }
}
/// <summary>
/// Function definition with address range and hash.
/// </summary>
public sealed record FuncProofFunction
{
/// <summary>
/// Symbol name (demangled if available).
/// </summary>
[JsonPropertyName("symbol")]
public required string Symbol { get; init; }
/// <summary>
/// Mangled/raw symbol name if different from demangled.
/// </summary>
[JsonPropertyName("mangledName")]
public string? MangledName { get; init; }
/// <summary>
/// Symbol digest: BLAKE3(symbol_name + offset_range).
/// Used for stable cross-binary correlation.
/// </summary>
[JsonPropertyName("symbolDigest")]
public required string SymbolDigest { get; init; }
/// <summary>
/// Start address (hex string, e.g., "0x401120").
/// </summary>
[JsonPropertyName("start")]
public required string Start { get; init; }
/// <summary>
/// End address (hex string, e.g., "0x4013af").
/// </summary>
[JsonPropertyName("end")]
public required string End { get; init; }
/// <summary>
/// Size in bytes.
/// </summary>
[JsonPropertyName("size")]
public required long Size { get; init; }
/// <summary>
/// BLAKE3 hash of the function's bytes within .text section.
/// </summary>
[JsonPropertyName("hash")]
public required string Hash { get; init; }
/// <summary>
/// Confidence level for function boundary detection.
/// 1.0 = DWARF/debug info, 0.8 = symbol table, 0.5 = heuristic prolog/epilog.
/// </summary>
[JsonPropertyName("confidence")]
public double Confidence { get; init; } = 1.0;
/// <summary>
/// Source file path (if DWARF info available).
/// </summary>
[JsonPropertyName("sourceFile")]
public string? SourceFile { get; init; }
/// <summary>
/// Source line number (if DWARF info available).
/// </summary>
[JsonPropertyName("sourceLine")]
public int? SourceLine { get; init; }
/// <summary>
/// Whether this function is marked as an entrypoint.
/// </summary>
[JsonPropertyName("isEntrypoint")]
public bool IsEntrypoint { get; init; }
/// <summary>
/// Type of entrypoint if applicable.
/// </summary>
[JsonPropertyName("entrypointType")]
public string? EntrypointType { get; init; }
/// <summary>
/// Whether this function is a known vulnerable sink.
/// </summary>
[JsonPropertyName("isSink")]
public bool IsSink { get; init; }
/// <summary>
/// CVE or vulnerability ID if this is a sink.
/// </summary>
[JsonPropertyName("sinkVulnId")]
public string? SinkVulnId { get; init; }
}
/// <summary>
/// Entry→sink trace with edge list hash.
/// </summary>
public sealed record FuncProofTrace
{
/// <summary>
/// Unique trace identifier (index or content-derived).
/// </summary>
[JsonPropertyName("traceId")]
public required string TraceId { get; init; }
/// <summary>
/// BLAKE3 hash of the edge list: sorted (caller_digest, callee_digest) pairs.
/// </summary>
[JsonPropertyName("edgeListHash")]
public required string EdgeListHash { get; init; }
/// <summary>
/// Number of hops in this trace.
/// </summary>
[JsonPropertyName("hopCount")]
public required int HopCount { get; init; }
/// <summary>
/// Symbol digest of the entry point.
/// </summary>
[JsonPropertyName("entrySymbolDigest")]
public required string EntrySymbolDigest { get; init; }
/// <summary>
/// Symbol digest of the sink (vulnerable function).
/// </summary>
[JsonPropertyName("sinkSymbolDigest")]
public required string SinkSymbolDigest { get; init; }
/// <summary>
/// Compact path representation: ordered list of symbol digests.
/// Limited to 10 hops max for compressed paths.
/// </summary>
[JsonPropertyName("path")]
public ImmutableArray<string> Path { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Whether this trace was truncated due to depth limit.
/// </summary>
[JsonPropertyName("truncated")]
public bool Truncated { get; init; }
}
/// <summary>
/// Build metadata extracted from binary or external sources.
/// </summary>
public sealed record FuncProofMetadata
{
/// <summary>
/// Compiler identification (e.g., "clang-18", "gcc-14").
/// </summary>
[JsonPropertyName("compiler")]
public string? Compiler { get; init; }
/// <summary>
/// Compiler flags if extractable.
/// </summary>
[JsonPropertyName("flags")]
public string? Flags { get; init; }
/// <summary>
/// Linker identification.
/// </summary>
[JsonPropertyName("linker")]
public string? Linker { get; init; }
/// <summary>
/// Build timestamp if available.
/// </summary>
[JsonPropertyName("buildTime")]
public DateTimeOffset? BuildTime { get; init; }
/// <summary>
/// Source commit hash if embedded in binary.
/// </summary>
[JsonPropertyName("sourceCommit")]
public string? SourceCommit { get; init; }
/// <summary>
/// Package name/version if this binary is part of a package.
/// </summary>
[JsonPropertyName("packageInfo")]
public string? PackageInfo { get; init; }
/// <summary>
/// OS ABI (e.g., "linux", "freebsd", "none").
/// </summary>
[JsonPropertyName("osAbi")]
public string? OsAbi { get; init; }
/// <summary>
/// Additional properties as key-value pairs.
/// </summary>
[JsonPropertyName("properties")]
public ImmutableDictionary<string, string>? Properties { get; init; }
}
/// <summary>
/// Content type for FuncProof artifacts.
/// </summary>
public static class FuncProofConstants
{
/// <summary>
/// OCI media type for FuncProof artifacts.
/// </summary>
public const string MediaType = "application/vnd.stellaops.funcproof+json";
/// <summary>
/// DSSE payload type for FuncProof.
/// </summary>
public const string DssePayloadType = "application/vnd.stellaops.funcproof+json";
/// <summary>
/// Current schema version.
/// </summary>
public const string SchemaVersion = "1.0.0";
/// <summary>
/// Maximum trace depth before truncation.
/// </summary>
public const int MaxTraceHops = 10;
}

View File

@@ -0,0 +1,540 @@
// -----------------------------------------------------------------------------
// SbomFuncProofLinker.cs
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
// Task: FUNC-15 — SBOM evidence link with CycloneDX integration
// Description: Links FuncProof documents to SBOM components via CycloneDX 1.6
// evidence model. Enables auditors to trace from SBOM → binary proof.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Evidence.Models;
namespace StellaOps.Scanner.Evidence;
/// <summary>
/// Links FuncProof evidence to SBOM components using CycloneDX 1.6 evidence model.
/// </summary>
/// <remarks>
/// CycloneDX 1.6 supports components.evidence.callFlow for linking binary
/// analysis results to component entries. This enables:
/// - Tracing from SBOM component → FuncProof document
/// - Embedding function reachability as component evidence
/// - Providing auditors with binary-level verification data
/// </remarks>
public interface ISbomFuncProofLinker
{
/// <summary>
/// Links FuncProof evidence to a CycloneDX SBOM component.
/// </summary>
/// <param name="sbomJson">The CycloneDX SBOM JSON.</param>
/// <param name="componentBomRef">The bom-ref of the target component.</param>
/// <param name="funcProof">The FuncProof document to link.</param>
/// <param name="proofDigest">SHA-256 digest of the signed FuncProof DSSE envelope.</param>
/// <param name="proofLocation">URI or OCI reference to the FuncProof artifact.</param>
/// <returns>Updated SBOM JSON with evidence linked.</returns>
string LinkFuncProofEvidence(
string sbomJson,
string componentBomRef,
FuncProof funcProof,
string proofDigest,
string proofLocation);
/// <summary>
/// Extracts FuncProof references from a CycloneDX SBOM component.
/// </summary>
/// <param name="sbomJson">The CycloneDX SBOM JSON.</param>
/// <param name="componentBomRef">The bom-ref of the target component.</param>
/// <returns>List of FuncProof evidence references found.</returns>
IReadOnlyList<FuncProofEvidenceRef> ExtractFuncProofReferences(
string sbomJson,
string componentBomRef);
/// <summary>
/// Creates a CycloneDX evidence structure for a FuncProof document.
/// </summary>
FuncProofEvidenceRef CreateEvidenceRef(
FuncProof funcProof,
string proofDigest,
string proofLocation);
}
/// <summary>
/// Reference to FuncProof evidence in an SBOM.
/// </summary>
public sealed record FuncProofEvidenceRef
{
/// <summary>
/// Proof ID from the FuncProof document.
/// </summary>
public required string ProofId { get; init; }
/// <summary>
/// Build ID that links to the binary.
/// </summary>
public required string BuildId { get; init; }
/// <summary>
/// SHA-256 of the binary file.
/// </summary>
public required string FileSha256 { get; init; }
/// <summary>
/// Digest of the signed FuncProof DSSE envelope.
/// </summary>
public required string ProofDigest { get; init; }
/// <summary>
/// URI or OCI reference to the FuncProof artifact.
/// </summary>
public required string Location { get; init; }
/// <summary>
/// Number of functions in the proof.
/// </summary>
public required int FunctionCount { get; init; }
/// <summary>
/// Number of traces in the proof.
/// </summary>
public required int TraceCount { get; init; }
/// <summary>
/// Timestamp when the proof was generated.
/// </summary>
public DateTimeOffset? GeneratedAt { get; init; }
/// <summary>
/// Transparency log entry ID (if logged to Rekor).
/// </summary>
public string? TransparencyLogEntry { get; init; }
}
/// <summary>
/// Default implementation of SBOM-FuncProof linker.
/// </summary>
public sealed class SbomFuncProofLinker : ISbomFuncProofLinker
{
private readonly ILogger<SbomFuncProofLinker> _logger;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
// CycloneDX evidence type for binary analysis
private const string EvidenceType = "binary-analysis";
private const string EvidenceMethod = "funcproof";
private const string StellaOpsNamespace = "https://stellaops.io/evidence/funcproof";
public SbomFuncProofLinker(ILogger<SbomFuncProofLinker> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public string LinkFuncProofEvidence(
string sbomJson,
string componentBomRef,
FuncProof funcProof,
string proofDigest,
string proofLocation)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sbomJson);
ArgumentException.ThrowIfNullOrWhiteSpace(componentBomRef);
ArgumentNullException.ThrowIfNull(funcProof);
ArgumentException.ThrowIfNullOrWhiteSpace(proofDigest);
ArgumentException.ThrowIfNullOrWhiteSpace(proofLocation);
var doc = JsonNode.Parse(sbomJson) as JsonObject
?? throw new ArgumentException("Invalid SBOM JSON", nameof(sbomJson));
// Validate this is a CycloneDX document
if (doc["bomFormat"]?.GetValue<string>() != "CycloneDX")
{
throw new ArgumentException("SBOM is not in CycloneDX format", nameof(sbomJson));
}
// Find the target component
var components = doc["components"] as JsonArray;
if (components == null || components.Count == 0)
{
throw new ArgumentException($"No components found in SBOM", nameof(sbomJson));
}
var targetComponent = FindComponent(components, componentBomRef);
if (targetComponent == null)
{
throw new ArgumentException($"Component with bom-ref '{componentBomRef}' not found", nameof(componentBomRef));
}
// Create evidence structure
var evidenceRef = CreateEvidenceRef(funcProof, proofDigest, proofLocation);
var evidence = CreateCycloneDxEvidence(evidenceRef);
// Add or update evidence on the component
AddEvidenceToComponent(targetComponent, evidence);
_logger.LogInformation(
"Linked FuncProof {ProofId} to component {BomRef} with {FunctionCount} functions",
funcProof.ProofId, componentBomRef, funcProof.Functions.Length);
return doc.ToJsonString(JsonOptions);
}
/// <inheritdoc />
public IReadOnlyList<FuncProofEvidenceRef> ExtractFuncProofReferences(
string sbomJson,
string componentBomRef)
{
ArgumentException.ThrowIfNullOrWhiteSpace(sbomJson);
ArgumentException.ThrowIfNullOrWhiteSpace(componentBomRef);
var doc = JsonNode.Parse(sbomJson) as JsonObject;
if (doc == null) return [];
var components = doc["components"] as JsonArray;
if (components == null) return [];
var targetComponent = FindComponent(components, componentBomRef);
if (targetComponent == null) return [];
var evidence = targetComponent["evidence"] as JsonObject;
if (evidence == null) return [];
var references = new List<FuncProofEvidenceRef>();
// Check callflow evidence (CycloneDX 1.6+)
var callflow = evidence["callflow"] as JsonObject;
if (callflow != null)
{
var frames = callflow["frames"] as JsonArray;
if (frames != null)
{
foreach (var frame in frames)
{
if (frame is not JsonObject frameObj) continue;
// Check if this is a FuncProof reference
var properties = frameObj["properties"] as JsonArray;
if (properties == null) continue;
var isFuncProof = properties.Any(p =>
p is JsonObject po &&
po["name"]?.GetValue<string>() == "stellaops:evidence:type" &&
po["value"]?.GetValue<string>() == "funcproof");
if (!isFuncProof) continue;
var evidenceRef = ParseEvidenceFromProperties(properties);
if (evidenceRef != null)
{
references.Add(evidenceRef);
}
}
}
}
// Check externalReferences for FuncProof links
var externalRefs = targetComponent["externalReferences"] as JsonArray;
if (externalRefs != null)
{
foreach (var extRef in externalRefs)
{
if (extRef is not JsonObject extRefObj) continue;
var type = extRefObj["type"]?.GetValue<string>();
var comment = extRefObj["comment"]?.GetValue<string>();
if (type == "evidence" && comment?.Contains("funcproof") == true)
{
var url = extRefObj["url"]?.GetValue<string>();
var hashes = extRefObj["hashes"] as JsonArray;
var sha256Hash = hashes?
.OfType<JsonObject>()
.FirstOrDefault(h => h["alg"]?.GetValue<string>() == "SHA-256")?
["content"]?.GetValue<string>();
if (!string.IsNullOrEmpty(url))
{
// Parse additional metadata from comment
var metadata = ParseCommentMetadata(comment);
references.Add(new FuncProofEvidenceRef
{
ProofId = metadata.TryGetValue("proofId", out var pid) ? pid : "unknown",
BuildId = metadata.TryGetValue("buildId", out var bid) ? bid : "unknown",
FileSha256 = metadata.TryGetValue("fileSha256", out var fsha) ? fsha : "unknown",
ProofDigest = sha256Hash ?? "unknown",
Location = url,
FunctionCount = int.TryParse(
metadata.TryGetValue("functionCount", out var fc) ? fc : "0",
out var fcInt) ? fcInt : 0,
TraceCount = int.TryParse(
metadata.TryGetValue("traceCount", out var tc) ? tc : "0",
out var tcInt) ? tcInt : 0
});
}
}
}
}
return references;
}
/// <inheritdoc />
public FuncProofEvidenceRef CreateEvidenceRef(
FuncProof funcProof,
string proofDigest,
string proofLocation)
{
ArgumentNullException.ThrowIfNull(funcProof);
return new FuncProofEvidenceRef
{
ProofId = funcProof.ProofId,
BuildId = funcProof.BuildId,
FileSha256 = funcProof.FileSha256,
ProofDigest = proofDigest,
Location = proofLocation,
FunctionCount = funcProof.Functions.Length,
TraceCount = funcProof.Traces?.Length ?? 0,
GeneratedAt = funcProof.Metadata?.Timestamp != null
? DateTimeOffset.Parse(funcProof.Metadata.Timestamp)
: null,
TransparencyLogEntry = funcProof.Metadata?.Properties?.TryGetValue("rekorEntryId", out var rekorId) == true
? rekorId
: null
};
}
private static JsonObject? FindComponent(JsonArray components, string bomRef)
{
foreach (var component in components)
{
if (component is not JsonObject componentObj) continue;
var currentBomRef = componentObj["bom-ref"]?.GetValue<string>();
if (currentBomRef == bomRef)
{
return componentObj;
}
// Check nested components
var nestedComponents = componentObj["components"] as JsonArray;
if (nestedComponents != null)
{
var found = FindComponent(nestedComponents, bomRef);
if (found != null) return found;
}
}
return null;
}
private JsonObject CreateCycloneDxEvidence(FuncProofEvidenceRef evidenceRef)
{
// Create CycloneDX 1.6 evidence structure with callflow
var evidence = new JsonObject
{
["callflow"] = new JsonObject
{
["frames"] = new JsonArray
{
new JsonObject
{
["package"] = "binary",
["module"] = evidenceRef.BuildId,
["function"] = $"[{evidenceRef.FunctionCount} functions analyzed]",
["line"] = 0,
["column"] = 0,
["fullFilename"] = evidenceRef.Location,
["properties"] = new JsonArray
{
CreateProperty("stellaops:evidence:type", "funcproof"),
CreateProperty("stellaops:funcproof:proofId", evidenceRef.ProofId),
CreateProperty("stellaops:funcproof:buildId", evidenceRef.BuildId),
CreateProperty("stellaops:funcproof:fileSha256", evidenceRef.FileSha256),
CreateProperty("stellaops:funcproof:proofDigest", evidenceRef.ProofDigest),
CreateProperty("stellaops:funcproof:functionCount", evidenceRef.FunctionCount.ToString()),
CreateProperty("stellaops:funcproof:traceCount", evidenceRef.TraceCount.ToString())
}
}
}
}
};
// Add transparency log entry if available
if (!string.IsNullOrEmpty(evidenceRef.TransparencyLogEntry))
{
var frames = evidence["callflow"]!["frames"] as JsonArray;
var firstFrame = frames![0] as JsonObject;
var properties = firstFrame!["properties"] as JsonArray;
properties!.Add(CreateProperty("stellaops:funcproof:rekorEntryId", evidenceRef.TransparencyLogEntry));
}
return evidence;
}
private static JsonObject CreateProperty(string name, string value) =>
new JsonObject
{
["name"] = name,
["value"] = value
};
private static void AddEvidenceToComponent(JsonObject component, JsonObject evidence)
{
var existingEvidence = component["evidence"] as JsonObject;
if (existingEvidence == null)
{
component["evidence"] = evidence;
}
else
{
// Merge callflow frames
var existingCallflow = existingEvidence["callflow"] as JsonObject;
var newCallflow = evidence["callflow"] as JsonObject;
if (existingCallflow == null && newCallflow != null)
{
existingEvidence["callflow"] = newCallflow;
}
else if (existingCallflow != null && newCallflow != null)
{
var existingFrames = existingCallflow["frames"] as JsonArray ?? new JsonArray();
var newFrames = newCallflow["frames"] as JsonArray ?? new JsonArray();
foreach (var frame in newFrames)
{
if (frame != null)
{
existingFrames.Add(frame.DeepClone());
}
}
}
}
// Also add external reference for tooling compatibility
var externalRefs = component["externalReferences"] as JsonArray;
if (externalRefs == null)
{
externalRefs = new JsonArray();
component["externalReferences"] = externalRefs;
}
// Get values from evidence
var proofId = GetPropertyValue(evidence, "stellaops:funcproof:proofId") ?? "unknown";
var buildId = GetPropertyValue(evidence, "stellaops:funcproof:buildId") ?? "unknown";
var fileSha256 = GetPropertyValue(evidence, "stellaops:funcproof:fileSha256") ?? "unknown";
var proofDigest = GetPropertyValue(evidence, "stellaops:funcproof:proofDigest") ?? "unknown";
var functionCount = GetPropertyValue(evidence, "stellaops:funcproof:functionCount") ?? "0";
var traceCount = GetPropertyValue(evidence, "stellaops:funcproof:traceCount") ?? "0";
var location = ((evidence["callflow"] as JsonObject)?["frames"] as JsonArray)?
[0]?["fullFilename"]?.GetValue<string>() ?? "";
externalRefs.Add(new JsonObject
{
["type"] = "evidence",
["url"] = location,
["comment"] = $"funcproof:proofId={proofId};buildId={buildId};fileSha256={fileSha256};functionCount={functionCount};traceCount={traceCount}",
["hashes"] = new JsonArray
{
new JsonObject
{
["alg"] = "SHA-256",
["content"] = proofDigest
}
}
});
}
private static string? GetPropertyValue(JsonObject evidence, string propertyName)
{
var frames = (evidence["callflow"] as JsonObject)?["frames"] as JsonArray;
if (frames == null || frames.Count == 0) return null;
var properties = (frames[0] as JsonObject)?["properties"] as JsonArray;
if (properties == null) return null;
foreach (var prop in properties)
{
if (prop is JsonObject propObj &&
propObj["name"]?.GetValue<string>() == propertyName)
{
return propObj["value"]?.GetValue<string>();
}
}
return null;
}
private FuncProofEvidenceRef? ParseEvidenceFromProperties(JsonArray properties)
{
var props = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var prop in properties)
{
if (prop is not JsonObject propObj) continue;
var name = propObj["name"]?.GetValue<string>();
var value = propObj["value"]?.GetValue<string>();
if (!string.IsNullOrEmpty(name) && value != null)
{
// Strip the stellaops:funcproof: prefix
if (name.StartsWith("stellaops:funcproof:"))
{
props[name["stellaops:funcproof:".Length..]] = value;
}
}
}
if (!props.TryGetValue("proofId", out var proofId)) return null;
return new FuncProofEvidenceRef
{
ProofId = proofId,
BuildId = props.TryGetValue("buildId", out var bid) ? bid : "unknown",
FileSha256 = props.TryGetValue("fileSha256", out var fsha) ? fsha : "unknown",
ProofDigest = props.TryGetValue("proofDigest", out var pd) ? pd : "unknown",
Location = "", // Will be filled from frame.fullFilename
FunctionCount = int.TryParse(
props.TryGetValue("functionCount", out var fc) ? fc : "0",
out var fcInt) ? fcInt : 0,
TraceCount = int.TryParse(
props.TryGetValue("traceCount", out var tc) ? tc : "0",
out var tcInt) ? tcInt : 0,
TransparencyLogEntry = props.TryGetValue("rekorEntryId", out var rekor) ? rekor : null
};
}
private static Dictionary<string, string> ParseCommentMetadata(string? comment)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrEmpty(comment)) return result;
// Parse "funcproof:proofId=xxx;buildId=yyy;..." format
var parts = comment.Split(';', StringSplitOptions.RemoveEmptyEntries);
foreach (var part in parts)
{
var trimmed = part.Trim();
if (trimmed.StartsWith("funcproof:"))
{
trimmed = trimmed["funcproof:".Length..];
}
var eqIdx = trimmed.IndexOf('=');
if (eqIdx > 0)
{
var key = trimmed[..eqIdx].Trim();
var value = trimmed[(eqIdx + 1)..].Trim();
result[key] = value;
}
}
return result;
}
}

View File

@@ -14,5 +14,6 @@
<ItemGroup>
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,232 @@
// -----------------------------------------------------------------------------
// CallGraphExtractorRegistryTests.cs
// Sprint: SPRINT_20251226_005_SCANNER_reachability_extractors (REACH-REG-02)
// Description: Tests for the call graph extractor registry.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
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;
using Xunit;
using Xunit.Abstractions;
namespace StellaOps.Scanner.CallGraph.Tests;
/// <summary>
/// Tests for <see cref="CallGraphExtractorRegistry"/> ensuring proper registration
/// and deterministic behavior across all language extractors.
/// </summary>
[Trait("Category", "Determinism")]
public class CallGraphExtractorRegistryTests
{
private readonly ITestOutputHelper _output;
private readonly FixedTimeProvider _timeProvider;
public CallGraphExtractorRegistryTests(ITestOutputHelper output)
{
_output = output;
_timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 12, 26, 0, 0, 0, TimeSpan.Zero));
}
[Fact]
public void Registry_ContainsAllExpectedLanguages()
{
// Arrange
var extractors = CreateAllExtractors();
var registry = new CallGraphExtractorRegistry(extractors);
// Act
var languages = registry.SupportedLanguages;
// Assert
Assert.Contains("dotnet", languages);
Assert.Contains("go", languages);
Assert.Contains("java", languages);
Assert.Contains("node", languages);
Assert.Contains("python", languages);
Assert.Equal(5, languages.Count);
}
[Fact]
public void Registry_LanguagesAreOrderedDeterministically()
{
// Arrange - Create registries with extractors in different orders
var extractors1 = CreateAllExtractors().ToList();
var extractors2 = CreateAllExtractors().Reverse().ToList();
var registry1 = new CallGraphExtractorRegistry(extractors1);
var registry2 = new CallGraphExtractorRegistry(extractors2);
// Act
var languages1 = registry1.SupportedLanguages;
var languages2 = registry2.SupportedLanguages;
// Assert - Same order regardless of input order
Assert.Equal(languages1.Count, languages2.Count);
for (int i = 0; i < languages1.Count; i++)
{
Assert.Equal(languages1[i], languages2[i]);
}
// Verify alphabetical ordering
var sorted = languages1.OrderBy(l => l, StringComparer.OrdinalIgnoreCase).ToList();
Assert.Equal(sorted, languages1.ToList());
}
[Theory]
[InlineData("dotnet")]
[InlineData("go")]
[InlineData("java")]
[InlineData("node")]
[InlineData("python")]
public void Registry_GetExtractor_ReturnsCorrectExtractor(string language)
{
// Arrange
var extractors = CreateAllExtractors();
var registry = new CallGraphExtractorRegistry(extractors);
// Act
var extractor = registry.GetExtractor(language);
// Assert
Assert.NotNull(extractor);
Assert.Equal(language, extractor.Language, StringComparer.OrdinalIgnoreCase);
}
[Theory]
[InlineData("JAVA")]
[InlineData("Java")]
[InlineData("PYTHON")]
[InlineData("Python")]
[InlineData("NODE")]
[InlineData("Node")]
public void Registry_GetExtractor_IsCaseInsensitive(string language)
{
// Arrange
var extractors = CreateAllExtractors();
var registry = new CallGraphExtractorRegistry(extractors);
// Act
var extractor = registry.GetExtractor(language);
// Assert
Assert.NotNull(extractor);
}
[Theory]
[InlineData("rust")]
[InlineData("ruby")]
[InlineData("php")]
[InlineData("unknown")]
[InlineData("")]
[InlineData(null)]
public void Registry_GetExtractor_ReturnsNullForUnsupported(string? language)
{
// Arrange
var extractors = CreateAllExtractors();
var registry = new CallGraphExtractorRegistry(extractors);
// Act
var extractor = registry.GetExtractor(language!);
// Assert
Assert.Null(extractor);
}
[Fact]
public void Registry_IsLanguageSupported_ReturnsCorrectValues()
{
// Arrange
var extractors = CreateAllExtractors();
var registry = new CallGraphExtractorRegistry(extractors);
// Assert - Supported languages
Assert.True(registry.IsLanguageSupported("java"));
Assert.True(registry.IsLanguageSupported("python"));
Assert.True(registry.IsLanguageSupported("node"));
Assert.True(registry.IsLanguageSupported("go"));
Assert.True(registry.IsLanguageSupported("dotnet"));
// Assert - Unsupported languages
Assert.False(registry.IsLanguageSupported("rust"));
Assert.False(registry.IsLanguageSupported(""));
Assert.False(registry.IsLanguageSupported(null!));
}
[Fact]
public void Registry_DuplicateRegistration_KeepsFirst()
{
// Arrange - Two extractors for same language
var extractor1 = new JavaCallGraphExtractor(
NullLogger<JavaCallGraphExtractor>.Instance, _timeProvider);
var extractor2 = new JavaCallGraphExtractor(
NullLogger<JavaCallGraphExtractor>.Instance, _timeProvider);
var extractors = new ICallGraphExtractor[] { extractor1, extractor2 };
// Act
var registry = new CallGraphExtractorRegistry(extractors);
// Assert - Only one Java extractor should be registered
var retrieved = registry.GetExtractor("java");
Assert.NotNull(retrieved);
Assert.Same(extractor1, retrieved);
}
[Fact]
public void Registry_EmptyExtractors_HandledGracefully()
{
// Arrange & Act
var registry = new CallGraphExtractorRegistry(Array.Empty<ICallGraphExtractor>());
// Assert
Assert.Empty(registry.SupportedLanguages);
Assert.Empty(registry.Extractors);
Assert.Null(registry.GetExtractor("java"));
Assert.False(registry.IsLanguageSupported("java"));
}
[Fact]
public void Registry_ExtractorsAreDeterministicallyOrdered()
{
// Arrange - Create registry multiple times with shuffled input
var random = new Random(42); // Fixed seed for reproducibility
var results = new List<IReadOnlyList<ICallGraphExtractor>>();
for (int i = 0; i < 5; i++)
{
var extractors = CreateAllExtractors()
.OrderBy(_ => random.Next())
.ToList();
var registry = new CallGraphExtractorRegistry(extractors);
results.Add(registry.Extractors);
}
// Assert - All registries have same extractor order
var first = results[0];
foreach (var result in results.Skip(1))
{
Assert.Equal(first.Count, result.Count);
for (int i = 0; i < first.Count; i++)
{
Assert.Equal(first[i].Language, result[i].Language);
}
}
}
private IEnumerable<ICallGraphExtractor> CreateAllExtractors()
{
yield return new DotNetCallGraphExtractor(
NullLogger<DotNetCallGraphExtractor>.Instance, _timeProvider);
yield return new GoCallGraphExtractor(
NullLogger<GoCallGraphExtractor>.Instance, _timeProvider);
yield return new JavaCallGraphExtractor(
NullLogger<JavaCallGraphExtractor>.Instance, _timeProvider);
yield return new NodeCallGraphExtractor(_timeProvider);
yield return new PythonCallGraphExtractor(
NullLogger<PythonCallGraphExtractor>.Instance, _timeProvider);
}
}

View File

@@ -0,0 +1,325 @@
// -----------------------------------------------------------------------------
// FuncProofBuilderTests.cs
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
// Task: FUNC-18
// Description: Unit tests for FuncProofBuilder.
// -----------------------------------------------------------------------------
using FluentAssertions;
using StellaOps.Scanner.Evidence.Models;
using Xunit;
namespace StellaOps.Scanner.Evidence.Tests;
public sealed class FuncProofBuilderTests
{
[Fact]
public void Build_WithBinaryIdentity_SetsFileProperties()
{
// Arrange
var fileHash = "abc123def456abc123def456abc123def456abc123def456abc123def456abc1";
var buildId = "build-12345";
var fileSize = 1024L;
// Act
var proof = new FuncProofBuilder()
.WithBinaryIdentity(fileHash, buildId, fileSize)
.Build();
// Assert
proof.FileSha256.Should().Be(fileHash);
proof.BuildId.Should().Be(buildId);
proof.FileSize.Should().Be(fileSize);
proof.SchemaVersion.Should().Be(FuncProofConstants.SchemaVersion);
}
[Fact]
public void Build_WithSection_AddsSectionToProof()
{
// Arrange
var sectionName = ".text";
var sectionOffset = 0x1000UL;
var sectionSize = 0x5000UL;
var sectionHash = "section_hash_12345";
// Act
var proof = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddSection(sectionName, sectionOffset, sectionSize, sectionHash)
.Build();
// Assert
proof.Sections.Should().HaveCount(1);
proof.Sections![0].Name.Should().Be(sectionName);
proof.Sections![0].Offset.Should().Be(sectionOffset);
proof.Sections![0].Size.Should().Be(sectionSize);
proof.Sections![0].Hash.Should().Be(sectionHash);
}
[Fact]
public void Build_WithMultipleSections_AddsAllSections()
{
// Arrange & Act
var proof = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddSection(".text", 0x1000, 0x5000, "hash1")
.AddSection(".rodata", 0x6000, 0x2000, "hash2")
.AddSection(".data", 0x8000, 0x1000, "hash3")
.Build();
// Assert
proof.Sections.Should().HaveCount(3);
proof.Sections![0].Name.Should().Be(".text");
proof.Sections![1].Name.Should().Be(".rodata");
proof.Sections![2].Name.Should().Be(".data");
}
[Fact]
public void Build_WithFunction_AddsFunctionToProof()
{
// Arrange
var funcName = "main";
var funcOffset = 0x1100UL;
var funcSize = 256UL;
var symbolDigest = "symbol_digest_abc123";
var functionHash = "function_hash_def456";
// Act
var proof = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddFunction(funcName, funcOffset, funcSize, symbolDigest, functionHash)
.Build();
// Assert
proof.Functions.Should().HaveCount(1);
proof.Functions![0].Name.Should().Be(funcName);
proof.Functions![0].Offset.Should().Be(funcOffset);
proof.Functions![0].Size.Should().Be(funcSize);
proof.Functions![0].SymbolDigest.Should().Be(symbolDigest);
proof.Functions![0].Hash.Should().Be(functionHash);
}
[Fact]
public void Build_WithFunctionCallers_SetsCallersOnFunction()
{
// Arrange
var callers = new List<string> { "caller1", "caller2", "caller3" };
// Act
var proof = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddFunction("main", 0x1100, 256, "sym", "hash", callers: callers)
.Build();
// Assert
proof.Functions![0].Callers.Should().BeEquivalentTo(callers);
}
[Fact]
public void Build_WithTrace_AddsTraceToProof()
{
// Arrange
var entryFunc = "vulnerable_func";
var hops = new List<string> { "main", "process_input", "parse_data", "vulnerable_func" };
// Act
var proof = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddTrace(entryFunc, hops, truncated: false)
.Build();
// Assert
proof.Traces.Should().HaveCount(1);
proof.Traces![0].EntryFunction.Should().Be(entryFunc);
proof.Traces![0].Hops.Should().BeEquivalentTo(hops);
proof.Traces![0].Truncated.Should().BeFalse();
}
[Fact]
public void Build_WithTruncatedTrace_SetsTruncatedFlag()
{
// Arrange
var hops = Enumerable.Range(0, 15).Select(i => $"func_{i}").ToList();
// Act
var proof = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddTrace("target", hops, truncated: true)
.Build();
// Assert
proof.Traces![0].Truncated.Should().BeTrue();
}
[Fact]
public void Build_WithMetadata_SetsMetadataProperties()
{
// Arrange
var tool = "test-tool";
var version = "1.0.0";
var timestamp = "2024-01-01T00:00:00Z";
// Act
var proof = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.WithMetadata(tool, version, timestamp)
.Build();
// Assert
proof.Metadata.Should().NotBeNull();
proof.Metadata!.Tool.Should().Be(tool);
proof.Metadata.ToolVersion.Should().Be(version);
proof.Metadata.CreatedAt.Should().Be(timestamp);
}
[Fact]
public void Build_GeneratesProofId()
{
// Arrange & Act
var proof = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddFunction("main", 0x1000, 100, "sym", "hash")
.Build();
// Assert
proof.ProofId.Should().NotBeNullOrEmpty();
proof.ProofId.Should().HaveLength(64); // SHA-256 hex
}
[Fact]
public void Build_SameInput_GeneratesSameProofId()
{
// Arrange
FuncProof BuildProof() => new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddSection(".text", 0x1000, 0x5000, "section_hash")
.AddFunction("main", 0x1100, 256, "sym", "hash")
.WithMetadata("tool", "1.0", "2024-01-01T00:00:00Z")
.Build();
// Act
var proof1 = BuildProof();
var proof2 = BuildProof();
// Assert
proof1.ProofId.Should().Be(proof2.ProofId);
}
[Fact]
public void Build_DifferentInput_GeneratesDifferentProofId()
{
// Arrange & Act
var proof1 = new FuncProofBuilder()
.WithBinaryIdentity("filehash1", "build", 1024)
.AddFunction("main", 0x1000, 100, "sym1", "hash1")
.Build();
var proof2 = new FuncProofBuilder()
.WithBinaryIdentity("filehash2", "build", 1024)
.AddFunction("main", 0x1000, 100, "sym2", "hash2")
.Build();
// Assert
proof1.ProofId.Should().NotBe(proof2.ProofId);
}
[Fact]
public void ComputeSymbolDigest_DeterministicForSameInput()
{
// Arrange
var name = "main";
var offset = 0x1000UL;
// Act
var digest1 = FuncProofBuilder.ComputeSymbolDigest(name, offset);
var digest2 = FuncProofBuilder.ComputeSymbolDigest(name, offset);
// Assert
digest1.Should().Be(digest2);
}
[Fact]
public void ComputeSymbolDigest_DifferentForDifferentOffset()
{
// Arrange
var name = "main";
// Act
var digest1 = FuncProofBuilder.ComputeSymbolDigest(name, 0x1000);
var digest2 = FuncProofBuilder.ComputeSymbolDigest(name, 0x2000);
// Assert
digest1.Should().NotBe(digest2);
}
[Fact]
public void ComputeFunctionHash_DeterministicForSameInput()
{
// Arrange
var bytes = new byte[] { 0x55, 0x48, 0x89, 0xe5, 0xc3 }; // push rbp; mov rbp, rsp; ret
// Act
var hash1 = FuncProofBuilder.ComputeFunctionHash(bytes);
var hash2 = FuncProofBuilder.ComputeFunctionHash(bytes);
// Assert
hash1.Should().Be(hash2);
}
[Fact]
public void ComputeFunctionHash_DifferentForDifferentInput()
{
// Arrange
var bytes1 = new byte[] { 0x55, 0x48, 0x89, 0xe5, 0xc3 };
var bytes2 = new byte[] { 0x55, 0x48, 0x89, 0xe5, 0xc9, 0xc3 }; // includes leave
// Act
var hash1 = FuncProofBuilder.ComputeFunctionHash(bytes1);
var hash2 = FuncProofBuilder.ComputeFunctionHash(bytes2);
// Assert
hash1.Should().NotBe(hash2);
}
[Fact]
public void ComputeProofId_DeterministicForSameProof()
{
// Arrange
var proof = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddFunction("main", 0x1000, 100, "sym", "hash")
.Build();
// Act
var id1 = FuncProofBuilder.ComputeProofId(proof);
var id2 = FuncProofBuilder.ComputeProofId(proof);
// Assert
id1.Should().Be(id2);
}
[Fact]
public void Build_FunctionOrdering_IsDeterministic()
{
// Arrange & Act
var proof1 = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddFunction("func_c", 0x3000, 100, "sym_c", "hash_c")
.AddFunction("func_a", 0x1000, 100, "sym_a", "hash_a")
.AddFunction("func_b", 0x2000, 100, "sym_b", "hash_b")
.Build();
var proof2 = new FuncProofBuilder()
.WithBinaryIdentity("filehash", "build", 1024)
.AddFunction("func_b", 0x2000, 100, "sym_b", "hash_b")
.AddFunction("func_c", 0x3000, 100, "sym_c", "hash_c")
.AddFunction("func_a", 0x1000, 100, "sym_a", "hash_a")
.Build();
// Assert - functions should be sorted by offset for determinism
proof1.Functions![0].Name.Should().Be(proof2.Functions![0].Name);
proof1.Functions![1].Name.Should().Be(proof2.Functions![1].Name);
proof1.Functions![2].Name.Should().Be(proof2.Functions![2].Name);
proof1.ProofId.Should().Be(proof2.ProofId);
}
}

View File

@@ -0,0 +1,321 @@
// -----------------------------------------------------------------------------
// FuncProofDsseServiceTests.cs
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
// Task: FUNC-18
// Description: Unit tests for FuncProof DSSE signing and verification.
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Replay.Core;
using StellaOps.Scanner.Evidence.Models;
using StellaOps.Scanner.ProofSpine;
using Xunit;
namespace StellaOps.Scanner.Evidence.Tests;
public sealed class FuncProofDsseServiceTests
{
private readonly Mock<IDsseSigningService> _signingServiceMock;
private readonly IOptions<FuncProofDsseOptions> _options;
private readonly ILogger<FuncProofDsseService> _logger;
public FuncProofDsseServiceTests()
{
_signingServiceMock = new Mock<IDsseSigningService>();
_options = Options.Create(new FuncProofDsseOptions
{
KeyId = "test-key-id",
Algorithm = "hs256"
});
_logger = NullLogger<FuncProofDsseService>.Instance;
}
[Fact]
public async Task SignAsync_WithValidProof_ReturnsSignedEnvelope()
{
// Arrange
var proof = CreateTestProof();
var expectedEnvelope = new DsseEnvelope(
FuncProofConstants.MediaType,
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
new[] { new DsseSignature("test-key-id", "test-signature") });
_signingServiceMock
.Setup(x => x.SignAsync(
It.IsAny<object>(),
FuncProofConstants.MediaType,
It.IsAny<ICryptoProfile>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedEnvelope);
var service = new FuncProofDsseService(_signingServiceMock.Object, _options, _logger);
// Act
var result = await service.SignAsync(proof);
// Assert
result.Should().NotBeNull();
result.Envelope.Should().Be(expectedEnvelope);
result.EnvelopeId.Should().NotBeNullOrEmpty();
result.EnvelopeJson.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task SignAsync_WithNullProofId_ThrowsArgumentException()
{
// Arrange
var proof = new FuncProof
{
ProofId = null, // Invalid
BuildId = "build-123",
FileSha256 = "abc123"
};
var service = new FuncProofDsseService(_signingServiceMock.Object, _options, _logger);
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(() => service.SignAsync(proof));
}
[Fact]
public async Task SignAsync_CallsSigningServiceWithCorrectPayloadType()
{
// Arrange
var proof = CreateTestProof();
var capturedPayloadType = string.Empty;
_signingServiceMock
.Setup(x => x.SignAsync(
It.IsAny<object>(),
It.IsAny<string>(),
It.IsAny<ICryptoProfile>(),
It.IsAny<CancellationToken>()))
.Callback<object, string, ICryptoProfile, CancellationToken>((_, payloadType, _, _) =>
{
capturedPayloadType = payloadType;
})
.ReturnsAsync(CreateTestEnvelope());
var service = new FuncProofDsseService(_signingServiceMock.Object, _options, _logger);
// Act
await service.SignAsync(proof);
// Assert
capturedPayloadType.Should().Be(FuncProofConstants.MediaType);
}
[Fact]
public async Task VerifyAsync_WithValidEnvelope_ReturnsSuccessResult()
{
// Arrange
var proof = CreateTestProof();
var proofJson = System.Text.Json.JsonSerializer.Serialize(proof);
var envelope = new DsseEnvelope(
FuncProofConstants.MediaType,
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(proofJson)),
new[] { new DsseSignature("test-key-id", "test-signature") });
_signingServiceMock
.Setup(x => x.VerifyAsync(envelope, It.IsAny<CancellationToken>()))
.ReturnsAsync(new DsseVerificationOutcome(true, true, null));
var service = new FuncProofDsseService(_signingServiceMock.Object, _options, _logger);
// Act
var result = await service.VerifyAsync(envelope);
// Assert
result.IsValid.Should().BeTrue();
result.IsTrusted.Should().BeTrue();
result.FailureReason.Should().BeNull();
result.FuncProof.Should().NotBeNull();
}
[Fact]
public async Task VerifyAsync_WithWrongPayloadType_ReturnsInvalidResult()
{
// Arrange
var envelope = new DsseEnvelope(
"application/wrong-type",
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
new[] { new DsseSignature("test-key-id", "test-signature") });
var service = new FuncProofDsseService(_signingServiceMock.Object, _options, _logger);
// Act
var result = await service.VerifyAsync(envelope);
// Assert
result.IsValid.Should().BeFalse();
result.FailureReason.Should().Contain("Invalid payload type");
}
[Fact]
public async Task VerifyAsync_WithFailedSignature_ReturnsInvalidResult()
{
// Arrange
var proof = CreateTestProof();
var proofJson = System.Text.Json.JsonSerializer.Serialize(proof);
var envelope = new DsseEnvelope(
FuncProofConstants.MediaType,
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(proofJson)),
new[] { new DsseSignature("test-key-id", "bad-signature") });
_signingServiceMock
.Setup(x => x.VerifyAsync(envelope, It.IsAny<CancellationToken>()))
.ReturnsAsync(new DsseVerificationOutcome(false, false, "dsse_sig_mismatch"));
var service = new FuncProofDsseService(_signingServiceMock.Object, _options, _logger);
// Act
var result = await service.VerifyAsync(envelope);
// Assert
result.IsValid.Should().BeFalse();
result.FailureReason.Should().Be("dsse_sig_mismatch");
}
[Fact]
public void ExtractPayload_WithValidEnvelope_ReturnsFuncProof()
{
// Arrange
var proof = CreateTestProof();
var proofJson = System.Text.Json.JsonSerializer.Serialize(proof, new System.Text.Json.JsonSerializerOptions
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
});
var envelope = new DsseEnvelope(
FuncProofConstants.MediaType,
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(proofJson)),
Array.Empty<DsseSignature>());
var service = new FuncProofDsseService(_signingServiceMock.Object, _options, _logger);
// Act
var extracted = service.ExtractPayload(envelope);
// Assert
extracted.Should().NotBeNull();
extracted!.ProofId.Should().Be(proof.ProofId);
extracted.BuildId.Should().Be(proof.BuildId);
}
[Fact]
public void ExtractPayload_WithInvalidBase64_ReturnsNull()
{
// Arrange
var envelope = new DsseEnvelope(
FuncProofConstants.MediaType,
"not-valid-base64!!!",
Array.Empty<DsseSignature>());
var service = new FuncProofDsseService(_signingServiceMock.Object, _options, _logger);
// Act
var extracted = service.ExtractPayload(envelope);
// Assert
extracted.Should().BeNull();
}
[Fact]
public void ExtractPayload_WithInvalidJson_ReturnsNull()
{
// Arrange
var envelope = new DsseEnvelope(
FuncProofConstants.MediaType,
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("not-valid-json")),
Array.Empty<DsseSignature>());
var service = new FuncProofDsseService(_signingServiceMock.Object, _options, _logger);
// Act
var extracted = service.ExtractPayload(envelope);
// Assert
extracted.Should().BeNull();
}
[Fact]
public void ToUnsignedEnvelope_CreatesValidEnvelope()
{
// Arrange
var proof = CreateTestProof();
// Act
var envelope = proof.ToUnsignedEnvelope();
// Assert
envelope.Should().NotBeNull();
envelope.PayloadType.Should().Be(FuncProofConstants.MediaType);
envelope.Signatures.Should().BeEmpty();
envelope.Payload.Should().NotBeNullOrEmpty();
}
[Fact]
public void ParseEnvelope_WithValidJson_ReturnsEnvelope()
{
// Arrange
var envelope = new DsseEnvelope(
"test-type",
"dGVzdA==",
new[] { new DsseSignature("key", "sig") });
var json = System.Text.Json.JsonSerializer.Serialize(envelope, new System.Text.Json.JsonSerializerOptions
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
});
// Act
var parsed = FuncProofDsseExtensions.ParseEnvelope(json);
// Assert
parsed.Should().NotBeNull();
parsed!.PayloadType.Should().Be("test-type");
}
[Fact]
public void ParseEnvelope_WithInvalidJson_ReturnsNull()
{
// Act
var parsed = FuncProofDsseExtensions.ParseEnvelope("invalid json {{{");
// Assert
parsed.Should().BeNull();
}
[Fact]
public void ParseEnvelope_WithEmptyString_ReturnsNull()
{
// Act
var parsed = FuncProofDsseExtensions.ParseEnvelope("");
// Assert
parsed.Should().BeNull();
}
private static FuncProof CreateTestProof()
{
return new FuncProofBuilder()
.WithBinaryIdentity(
"abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
"build-123",
1024)
.AddSection(".text", 0x1000, 0x5000, "section_hash")
.AddFunction("main", 0x1100, 256, "sym_main", "func_hash_main")
.WithMetadata("test-tool", "1.0.0", "2024-01-01T00:00:00Z")
.Build();
}
private static DsseEnvelope CreateTestEnvelope()
{
return new DsseEnvelope(
FuncProofConstants.MediaType,
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
new[] { new DsseSignature("test-key-id", "test-signature") });
}
}

View File

@@ -0,0 +1,350 @@
// -----------------------------------------------------------------------------
// SbomFuncProofLinkerTests.cs
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
// Task: FUNC-15 — SBOM evidence link unit tests
// Description: Tests for SBOM-FuncProof linking functionality.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Nodes;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Evidence;
using StellaOps.Scanner.Evidence.Models;
using Xunit;
namespace StellaOps.Scanner.Evidence.Tests;
public sealed class SbomFuncProofLinkerTests
{
private readonly SbomFuncProofLinker _linker;
public SbomFuncProofLinkerTests()
{
_linker = new SbomFuncProofLinker(NullLogger<SbomFuncProofLinker>.Instance);
}
[Fact]
public void LinkFuncProofEvidence_AddsEvidenceToComponent()
{
// Arrange
var sbom = CreateMinimalCycloneDxSbom("pkg:npm/lodash@4.17.21", "component-1");
var funcProof = CreateTestFuncProof();
// Act
var result = _linker.LinkFuncProofEvidence(
sbom,
"component-1",
funcProof,
"sha256:abc123",
"oci://registry.example.com/proofs:funcproof-test");
// Assert
var doc = JsonNode.Parse(result) as JsonObject;
doc.Should().NotBeNull();
var component = (doc!["components"] as JsonArray)?[0] as JsonObject;
component.Should().NotBeNull();
var evidence = component!["evidence"] as JsonObject;
evidence.Should().NotBeNull();
var callflow = evidence!["callflow"] as JsonObject;
callflow.Should().NotBeNull();
var frames = callflow!["frames"] as JsonArray;
frames.Should().NotBeNull();
frames!.Count.Should().BeGreaterThan(0);
}
[Fact]
public void LinkFuncProofEvidence_AddsExternalReference()
{
// Arrange
var sbom = CreateMinimalCycloneDxSbom("pkg:npm/lodash@4.17.21", "component-1");
var funcProof = CreateTestFuncProof();
// Act
var result = _linker.LinkFuncProofEvidence(
sbom,
"component-1",
funcProof,
"sha256:abc123",
"oci://registry.example.com/proofs:funcproof-test");
// Assert
var doc = JsonNode.Parse(result) as JsonObject;
var component = (doc!["components"] as JsonArray)?[0] as JsonObject;
var externalRefs = component!["externalReferences"] as JsonArray;
externalRefs.Should().NotBeNull();
externalRefs!.Count.Should().BeGreaterThan(0);
var evidenceRef = externalRefs[0] as JsonObject;
evidenceRef!["type"]!.GetValue<string>().Should().Be("evidence");
evidenceRef["url"]!.GetValue<string>().Should().Contain("oci://");
}
[Fact]
public void LinkFuncProofEvidence_ThrowsForNonCycloneDx()
{
// Arrange
var spdxSbom = """
{
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"packages": []
}
""";
var funcProof = CreateTestFuncProof();
// Act & Assert
var act = () => _linker.LinkFuncProofEvidence(
spdxSbom,
"component-1",
funcProof,
"sha256:abc123",
"oci://test");
act.Should().Throw<ArgumentException>()
.WithMessage("*CycloneDX*");
}
[Fact]
public void LinkFuncProofEvidence_ThrowsForMissingComponent()
{
// Arrange
var sbom = CreateMinimalCycloneDxSbom("pkg:npm/lodash@4.17.21", "component-1");
var funcProof = CreateTestFuncProof();
// Act & Assert
var act = () => _linker.LinkFuncProofEvidence(
sbom,
"nonexistent-component",
funcProof,
"sha256:abc123",
"oci://test");
act.Should().Throw<ArgumentException>()
.WithMessage("*not found*");
}
[Fact]
public void ExtractFuncProofReferences_ReturnsEmptyForNoEvidence()
{
// Arrange
var sbom = CreateMinimalCycloneDxSbom("pkg:npm/lodash@4.17.21", "component-1");
// Act
var refs = _linker.ExtractFuncProofReferences(sbom, "component-1");
// Assert
refs.Should().BeEmpty();
}
[Fact]
public void ExtractFuncProofReferences_FindsLinkedEvidence()
{
// Arrange
var sbom = CreateMinimalCycloneDxSbom("pkg:npm/lodash@4.17.21", "component-1");
var funcProof = CreateTestFuncProof();
var linkedSbom = _linker.LinkFuncProofEvidence(
sbom,
"component-1",
funcProof,
"sha256:abc123def456",
"oci://registry.example.com/proofs:funcproof-v1");
// Act
var refs = _linker.ExtractFuncProofReferences(linkedSbom, "component-1");
// Assert
refs.Should().HaveCount(1);
refs[0].ProofId.Should().Be(funcProof.ProofId);
refs[0].BuildId.Should().Be(funcProof.BuildId);
refs[0].FunctionCount.Should().Be(funcProof.Functions.Length);
}
[Fact]
public void CreateEvidenceRef_PopulatesAllFields()
{
// Arrange
var funcProof = CreateTestFuncProof();
// Act
var evidenceRef = _linker.CreateEvidenceRef(
funcProof,
"sha256:proof-digest-123",
"oci://registry/proof:v1");
// Assert
evidenceRef.ProofId.Should().Be(funcProof.ProofId);
evidenceRef.BuildId.Should().Be(funcProof.BuildId);
evidenceRef.FileSha256.Should().Be(funcProof.FileSha256);
evidenceRef.ProofDigest.Should().Be("sha256:proof-digest-123");
evidenceRef.Location.Should().Be("oci://registry/proof:v1");
evidenceRef.FunctionCount.Should().Be(2);
evidenceRef.TraceCount.Should().Be(1);
}
[Fact]
public void LinkFuncProofEvidence_IncludesProofProperties()
{
// Arrange
var sbom = CreateMinimalCycloneDxSbom("pkg:npm/lodash@4.17.21", "component-1");
var funcProof = CreateTestFuncProof();
// Act
var result = _linker.LinkFuncProofEvidence(
sbom,
"component-1",
funcProof,
"sha256:abc123",
"oci://registry.example.com/proofs:funcproof-test");
// Assert
var doc = JsonNode.Parse(result) as JsonObject;
var component = (doc!["components"] as JsonArray)?[0] as JsonObject;
var evidence = component!["evidence"] as JsonObject;
var frames = (evidence!["callflow"] as JsonObject)!["frames"] as JsonArray;
var properties = (frames![0] as JsonObject)!["properties"] as JsonArray;
properties.Should().NotBeNull();
var typeProperty = properties!.OfType<JsonObject>()
.FirstOrDefault(p => p["name"]?.GetValue<string>() == "stellaops:evidence:type");
typeProperty.Should().NotBeNull();
typeProperty!["value"]!.GetValue<string>().Should().Be("funcproof");
var proofIdProperty = properties.OfType<JsonObject>()
.FirstOrDefault(p => p["name"]?.GetValue<string>() == "stellaops:funcproof:proofId");
proofIdProperty.Should().NotBeNull();
proofIdProperty!["value"]!.GetValue<string>().Should().Be(funcProof.ProofId);
}
[Fact]
public void LinkFuncProofEvidence_MergesWithExistingEvidence()
{
// Arrange
var sbom = CreateMinimalCycloneDxSbom("pkg:npm/lodash@4.17.21", "component-1");
var funcProof1 = CreateTestFuncProof("proof-1", "build-1");
var funcProof2 = CreateTestFuncProof("proof-2", "build-2");
// Link first proof
var linkedSbom = _linker.LinkFuncProofEvidence(
sbom,
"component-1",
funcProof1,
"sha256:digest1",
"oci://registry/proof1:v1");
// Act - Link second proof
var result = _linker.LinkFuncProofEvidence(
linkedSbom,
"component-1",
funcProof2,
"sha256:digest2",
"oci://registry/proof2:v1");
// Assert
var refs = _linker.ExtractFuncProofReferences(result, "component-1");
refs.Should().HaveCount(2);
refs.Select(r => r.ProofId).Should().Contain("proof-1");
refs.Select(r => r.ProofId).Should().Contain("proof-2");
}
private static string CreateMinimalCycloneDxSbom(string purl, string bomRef)
{
return $$"""
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"version": 1,
"serialNumber": "urn:uuid:{{Guid.NewGuid()}}",
"components": [
{
"type": "library",
"bom-ref": "{{bomRef}}",
"purl": "{{purl}}",
"name": "test-component",
"version": "1.0.0"
}
]
}
""";
}
private static FuncProof CreateTestFuncProof(
string? proofId = null,
string? buildId = null)
{
proofId ??= "graph:test-proof-123";
buildId ??= "gnu-build-id-abc123";
return new FuncProof
{
ProofId = proofId,
SchemaVersion = FuncProofConstants.SchemaVersion,
BuildId = buildId,
BuildIdType = "gnu-build-id",
FileSha256 = "abc123def456789",
BinaryFormat = "elf",
Architecture = "x86_64",
IsStripped = false,
Sections = ImmutableDictionary<string, FuncProofSection>.Empty.Add(
".text",
new FuncProofSection
{
Hash = "blake3:section-hash-123",
Offset = 0x1000,
Size = 0x5000,
VirtualAddress = 0x401000
}),
Functions = ImmutableArray.Create(
new FuncProofFunction
{
SymbolDigest = "blake3:func1-digest",
Symbol = "main",
MangledName = "main",
Start = "0x401000",
End = "0x401100",
Size = 256,
FunctionHash = "blake3:func1-hash",
Confidence = 1.0,
DetectionMethod = "dwarf"
},
new FuncProofFunction
{
SymbolDigest = "blake3:func2-digest",
Symbol = "helper",
MangledName = "_Z6helperv",
Start = "0x401100",
End = "0x401200",
Size = 256,
FunctionHash = "blake3:func2-hash",
Confidence = 0.8,
DetectionMethod = "symbol"
}),
Traces = ImmutableArray.Create(
new FuncProofTrace
{
TraceId = "trace-1",
EdgeListHash = "blake3:edge-hash-1",
HopCount = 1,
EntrySymbolDigest = "blake3:func1-digest",
SinkSymbolDigest = "blake3:func2-digest",
Path = ImmutableArray.Create("blake3:func1-digest", "blake3:func2-digest"),
Truncated = false
}),
Metadata = new FuncProofMetadata
{
Generator = "StellaOps.Scanner",
GeneratorVersion = "1.0.0",
Timestamp = DateTimeOffset.UtcNow.ToString("O"),
Properties = ImmutableDictionary<string, string>.Empty
}
};
}
}

View File

@@ -15,7 +15,9 @@
</PackageReference>
<PackageReference Include="FluentAssertions" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -24,5 +26,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Evidence/StellaOps.Scanner.Evidence.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.ProofSpine/StellaOps.Scanner.ProofSpine.csproj" />
</ItemGroup>
</Project>