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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,13 +1,31 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CallGraphServiceCollectionExtensions.cs
|
||||
// Sprint: SPRINT_20251226_005_SCANNER_reachability_extractors (REACH-REG-01)
|
||||
// Description: DI registration for all call graph extractors.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scanner.CallGraph.Caching;
|
||||
using StellaOps.Scanner.CallGraph.DotNet;
|
||||
using StellaOps.Scanner.CallGraph.Go;
|
||||
using StellaOps.Scanner.CallGraph.Java;
|
||||
using StellaOps.Scanner.CallGraph.Node;
|
||||
using StellaOps.Scanner.CallGraph.Python;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering call graph services in dependency injection.
|
||||
/// </summary>
|
||||
public static class CallGraphServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds all call graph extraction and analysis services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The configuration instance.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddCallGraphServices(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
@@ -15,9 +33,18 @@ public static class CallGraphServiceCollectionExtensions
|
||||
|
||||
services.Configure<CallGraphCacheConfig>(configuration.GetSection("CallGraph:Cache"));
|
||||
|
||||
services.AddSingleton<ICallGraphExtractor, DotNetCallGraphExtractor>();
|
||||
services.AddSingleton<ICallGraphExtractor, NodeCallGraphExtractor>();
|
||||
// Register all language-specific call graph extractors
|
||||
// Each extractor implements ICallGraphExtractor and is keyed by Language property
|
||||
services.AddSingleton<ICallGraphExtractor, DotNetCallGraphExtractor>(); // .NET/C# via Roslyn
|
||||
services.AddSingleton<ICallGraphExtractor, JavaCallGraphExtractor>(); // Java via ASM bytecode parsing
|
||||
services.AddSingleton<ICallGraphExtractor, NodeCallGraphExtractor>(); // Node.js/JavaScript via Babel
|
||||
services.AddSingleton<ICallGraphExtractor, PythonCallGraphExtractor>(); // Python via AST analysis
|
||||
services.AddSingleton<ICallGraphExtractor, GoCallGraphExtractor>(); // Go via SSA analysis
|
||||
|
||||
// Register the extractor registry for language-based lookup
|
||||
services.AddSingleton<ICallGraphExtractorRegistry, CallGraphExtractorRegistry>();
|
||||
|
||||
// Core analysis services
|
||||
services.AddSingleton<ReachabilityAnalyzer>();
|
||||
services.AddSingleton<ICallGraphCacheService, ValkeyCallGraphCacheService>();
|
||||
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// FunctionBoundaryDetector.cs
|
||||
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
|
||||
// Tasks: FUNC-03, FUNC-04 — Function boundary detection using DWARF/symbol table and heuristics
|
||||
// Description: Detects function boundaries from binary analysis.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Evidence;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Binary;
|
||||
|
||||
/// <summary>
|
||||
/// Detects function boundaries in native binaries using multiple strategies:
|
||||
/// 1. DWARF debug info (highest confidence)
|
||||
/// 2. Symbol table entries (high confidence)
|
||||
/// 3. Prolog/epilog heuristics for stripped binaries (lower confidence)
|
||||
/// </summary>
|
||||
public sealed class FunctionBoundaryDetector
|
||||
{
|
||||
private readonly ILogger<FunctionBoundaryDetector> _logger;
|
||||
private readonly DwarfDebugReader _dwarfReader;
|
||||
private readonly FuncProofGenerationOptions _options;
|
||||
|
||||
// Common function prologs by architecture
|
||||
private static readonly byte[][] X86_64Prologs =
|
||||
[
|
||||
[0x55, 0x48, 0x89, 0xe5], // push rbp; mov rbp, rsp
|
||||
[0x55, 0x48, 0x8b, 0xec], // push rbp; mov rbp, rsp (alternate)
|
||||
[0x41, 0x57], // push r15
|
||||
[0x41, 0x56], // push r14
|
||||
[0x41, 0x55], // push r13
|
||||
[0x41, 0x54], // push r12
|
||||
[0x53], // push rbx
|
||||
[0x55], // push rbp
|
||||
];
|
||||
|
||||
private static readonly byte[][] Arm64Prologs =
|
||||
[
|
||||
[0xfd, 0x7b, 0xbf, 0xa9], // stp x29, x30, [sp, #-16]!
|
||||
[0xfd, 0x7b, 0xbe, 0xa9], // stp x29, x30, [sp, #-32]!
|
||||
[0xfd, 0x03, 0x00, 0x91], // mov x29, sp
|
||||
];
|
||||
|
||||
// Common function epilogs
|
||||
private static readonly byte[][] X86_64Epilogs =
|
||||
[
|
||||
[0xc3], // ret
|
||||
[0xc2], // ret imm16
|
||||
[0x5d, 0xc3], // pop rbp; ret
|
||||
[0xc9, 0xc3], // leave; ret
|
||||
];
|
||||
|
||||
private static readonly byte[][] Arm64Epilogs =
|
||||
[
|
||||
[0xc0, 0x03, 0x5f, 0xd6], // ret
|
||||
[0xfd, 0x7b, 0xc1, 0xa8], // ldp x29, x30, [sp], #16
|
||||
];
|
||||
|
||||
public FunctionBoundaryDetector(
|
||||
ILogger<FunctionBoundaryDetector> logger,
|
||||
DwarfDebugReader dwarfReader,
|
||||
IOptions<FuncProofGenerationOptions>? options = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_dwarfReader = dwarfReader ?? throw new ArgumentNullException(nameof(dwarfReader));
|
||||
_options = options?.Value ?? new FuncProofGenerationOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects function boundaries using all available strategies.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DetectedFunction>> DetectAsync(
|
||||
string binaryPath,
|
||||
BinaryFormat format,
|
||||
BinaryArchitecture architecture,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var functions = new List<DetectedFunction>();
|
||||
|
||||
// Strategy 1: Try DWARF debug info first (highest confidence)
|
||||
if (format == BinaryFormat.Elf)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dwarfInfo = await _dwarfReader.ReadAsync(binaryPath, ct);
|
||||
if (dwarfInfo.Functions.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Found {Count} functions via DWARF", dwarfInfo.Functions.Count);
|
||||
foreach (var func in dwarfInfo.Functions)
|
||||
{
|
||||
functions.Add(new DetectedFunction
|
||||
{
|
||||
Symbol = func.Name,
|
||||
MangledName = func.LinkageName,
|
||||
StartAddress = func.LowPc,
|
||||
EndAddress = func.HighPc,
|
||||
Confidence = _options.DwarfConfidence,
|
||||
DetectionMethod = FunctionDetectionMethod.Dwarf,
|
||||
SourceFile = func.DeclFile,
|
||||
SourceLine = func.DeclLine
|
||||
});
|
||||
}
|
||||
return functions;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "DWARF parsing failed, falling back to symbol table");
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Symbol table (high confidence)
|
||||
var symbols = await ExtractSymbolTableAsync(binaryPath, format, ct);
|
||||
if (symbols.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Found {Count} functions via symbol table", symbols.Count);
|
||||
functions.AddRange(symbols.Select(s => new DetectedFunction
|
||||
{
|
||||
Symbol = s.Name,
|
||||
MangledName = s.MangledName,
|
||||
StartAddress = s.Address,
|
||||
EndAddress = s.Address + s.Size,
|
||||
Confidence = _options.SymbolConfidence,
|
||||
DetectionMethod = FunctionDetectionMethod.SymbolTable
|
||||
}));
|
||||
|
||||
// If we have symbols but no sizes, try to infer from gaps
|
||||
InferFunctionSizes(functions);
|
||||
|
||||
return functions;
|
||||
}
|
||||
|
||||
// Strategy 3: Heuristic prolog/epilog detection (lower confidence)
|
||||
_logger.LogDebug("Using heuristic function detection for stripped binary");
|
||||
var textSection = await BinaryTextSectionReader.TryReadAsync(binaryPath, format, ct);
|
||||
if (textSection is not null)
|
||||
{
|
||||
var heuristicFunctions = DetectByPrologEpilog(textSection, architecture);
|
||||
functions.AddRange(heuristicFunctions);
|
||||
}
|
||||
|
||||
return functions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts function symbols from the binary's symbol table.
|
||||
/// </summary>
|
||||
private async Task<List<SymbolEntry>> ExtractSymbolTableAsync(
|
||||
string binaryPath,
|
||||
BinaryFormat format,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var symbols = new List<SymbolEntry>();
|
||||
|
||||
await using var stream = File.OpenRead(binaryPath);
|
||||
using var reader = new BinaryReader(stream);
|
||||
|
||||
switch (format)
|
||||
{
|
||||
case BinaryFormat.Elf:
|
||||
symbols = await ExtractElfSymbolsAsync(reader, ct);
|
||||
break;
|
||||
case BinaryFormat.Pe:
|
||||
symbols = ExtractPeSymbols(reader);
|
||||
break;
|
||||
case BinaryFormat.MachO:
|
||||
symbols = ExtractMachOSymbols(reader);
|
||||
break;
|
||||
}
|
||||
|
||||
// Filter to only function symbols
|
||||
return symbols
|
||||
.Where(s => s.Type == SymbolType.Function && s.Address != 0)
|
||||
.OrderBy(s => s.Address)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<List<SymbolEntry>> ExtractElfSymbolsAsync(BinaryReader reader, CancellationToken ct)
|
||||
{
|
||||
var symbols = new List<SymbolEntry>();
|
||||
|
||||
reader.BaseStream.Seek(0, SeekOrigin.Begin);
|
||||
var ident = reader.ReadBytes(16);
|
||||
|
||||
if (ident[0] != 0x7F || ident[1] != 'E' || ident[2] != 'L' || ident[3] != 'F')
|
||||
return symbols;
|
||||
|
||||
var is64Bit = ident[4] == 2;
|
||||
|
||||
// Read section headers to find symbol tables
|
||||
reader.BaseStream.Seek(is64Bit ? 40 : 32, SeekOrigin.Begin);
|
||||
var sectionHeaderOffset = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
|
||||
|
||||
reader.BaseStream.Seek(is64Bit ? 58 : 46, SeekOrigin.Begin);
|
||||
var sectionHeaderSize = reader.ReadUInt16();
|
||||
var sectionCount = reader.ReadUInt16();
|
||||
var strTabIndex = reader.ReadUInt16();
|
||||
|
||||
// Find .symtab and .dynsym sections
|
||||
for (int i = 0; i < sectionCount; i++)
|
||||
{
|
||||
reader.BaseStream.Seek(sectionHeaderOffset + i * sectionHeaderSize, SeekOrigin.Begin);
|
||||
|
||||
var nameIdx = reader.ReadUInt32();
|
||||
var type = reader.ReadUInt32();
|
||||
|
||||
// SHT_SYMTAB = 2, SHT_DYNSYM = 11
|
||||
if (type == 2 || type == 11)
|
||||
{
|
||||
reader.BaseStream.Seek(sectionHeaderOffset + i * sectionHeaderSize + (is64Bit ? 24 : 16), SeekOrigin.Begin);
|
||||
var offset = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
|
||||
var size = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
|
||||
|
||||
reader.BaseStream.Seek(sectionHeaderOffset + i * sectionHeaderSize + (is64Bit ? 40 : 24), SeekOrigin.Begin);
|
||||
var link = reader.ReadUInt32(); // String table section index
|
||||
var entSize = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
|
||||
|
||||
// Read string table
|
||||
reader.BaseStream.Seek(sectionHeaderOffset + (int)link * sectionHeaderSize + (is64Bit ? 24 : 16), SeekOrigin.Begin);
|
||||
var strOffset = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
|
||||
var strSize = is64Bit ? reader.ReadInt64() : reader.ReadInt32();
|
||||
|
||||
reader.BaseStream.Seek(strOffset, SeekOrigin.Begin);
|
||||
var strTab = reader.ReadBytes((int)strSize);
|
||||
|
||||
// Read symbols
|
||||
var entrySize = is64Bit ? 24 : 16;
|
||||
var count = size / entrySize;
|
||||
|
||||
for (long j = 0; j < count; j++)
|
||||
{
|
||||
reader.BaseStream.Seek(offset + j * entrySize, SeekOrigin.Begin);
|
||||
|
||||
var stName = reader.ReadUInt32();
|
||||
var stInfo = is64Bit ? reader.ReadByte() : reader.ReadByte();
|
||||
var stOther = is64Bit ? reader.ReadByte() : reader.ReadByte();
|
||||
var stShndx = is64Bit ? reader.ReadUInt16() : reader.ReadUInt16();
|
||||
|
||||
long stValue, stSize;
|
||||
if (is64Bit)
|
||||
{
|
||||
reader.BaseStream.Seek(offset + j * entrySize + 8, SeekOrigin.Begin);
|
||||
stValue = reader.ReadInt64();
|
||||
stSize = reader.ReadInt64();
|
||||
}
|
||||
else
|
||||
{
|
||||
reader.BaseStream.Seek(offset + j * entrySize + 4, SeekOrigin.Begin);
|
||||
stValue = reader.ReadInt32();
|
||||
stSize = reader.ReadInt32();
|
||||
}
|
||||
|
||||
// STT_FUNC = 2
|
||||
var stType = stInfo & 0x0f;
|
||||
if (stType == 2 && stValue != 0)
|
||||
{
|
||||
var name = ReadNullTerminatedString(strTab, (int)stName);
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
symbols.Add(new SymbolEntry
|
||||
{
|
||||
Name = DemangleSymbol(name),
|
||||
MangledName = name,
|
||||
Address = stValue,
|
||||
Size = stSize,
|
||||
Type = SymbolType.Function
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return symbols;
|
||||
}
|
||||
|
||||
private List<SymbolEntry> ExtractPeSymbols(BinaryReader reader)
|
||||
{
|
||||
// PE symbol extraction - simplified implementation
|
||||
// Full implementation would parse COFF symbol table or PDB
|
||||
return [];
|
||||
}
|
||||
|
||||
private List<SymbolEntry> ExtractMachOSymbols(BinaryReader reader)
|
||||
{
|
||||
// Mach-O symbol extraction - simplified implementation
|
||||
// Full implementation would parse LC_SYMTAB load command
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects functions by scanning for prolog/epilog patterns.
|
||||
/// </summary>
|
||||
private List<DetectedFunction> DetectByPrologEpilog(
|
||||
BinaryTextSection textSection,
|
||||
BinaryArchitecture architecture)
|
||||
{
|
||||
var functions = new List<DetectedFunction>();
|
||||
var prologs = architecture switch
|
||||
{
|
||||
BinaryArchitecture.X86_64 or BinaryArchitecture.X86 => X86_64Prologs,
|
||||
BinaryArchitecture.Arm64 or BinaryArchitecture.Arm => Arm64Prologs,
|
||||
_ => X86_64Prologs
|
||||
};
|
||||
|
||||
var epilogs = architecture switch
|
||||
{
|
||||
BinaryArchitecture.X86_64 or BinaryArchitecture.X86 => X86_64Epilogs,
|
||||
BinaryArchitecture.Arm64 or BinaryArchitecture.Arm => Arm64Epilogs,
|
||||
_ => X86_64Epilogs
|
||||
};
|
||||
|
||||
var data = textSection.Data;
|
||||
var baseAddr = textSection.VirtualAddress;
|
||||
|
||||
// Scan for prologs
|
||||
var prologOffsets = new List<long>();
|
||||
for (int i = 0; i < data.Length - 4; i++)
|
||||
{
|
||||
foreach (var prolog in prologs)
|
||||
{
|
||||
if (i + prolog.Length <= data.Length && MatchesPattern(data, i, prolog))
|
||||
{
|
||||
prologOffsets.Add(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For each prolog, find the next epilog to determine function end
|
||||
for (int p = 0; p < prologOffsets.Count; p++)
|
||||
{
|
||||
var start = prologOffsets[p];
|
||||
var maxEnd = p + 1 < prologOffsets.Count
|
||||
? prologOffsets[p + 1]
|
||||
: data.Length;
|
||||
|
||||
// Find epilog within range
|
||||
long end = maxEnd;
|
||||
for (long i = start + 4; i < maxEnd - 1; i++)
|
||||
{
|
||||
foreach (var epilog in epilogs)
|
||||
{
|
||||
if (i + epilog.Length <= data.Length && MatchesPattern(data, (int)i, epilog))
|
||||
{
|
||||
end = i + epilog.Length;
|
||||
goto foundEpilog;
|
||||
}
|
||||
}
|
||||
}
|
||||
foundEpilog:
|
||||
|
||||
functions.Add(new DetectedFunction
|
||||
{
|
||||
Symbol = $"sub_{baseAddr + start:x}",
|
||||
StartAddress = baseAddr + start,
|
||||
EndAddress = baseAddr + end,
|
||||
Confidence = _options.HeuristicConfidence,
|
||||
DetectionMethod = FunctionDetectionMethod.Heuristic
|
||||
});
|
||||
}
|
||||
|
||||
return functions;
|
||||
}
|
||||
|
||||
private static bool MatchesPattern(byte[] data, int offset, byte[] pattern)
|
||||
{
|
||||
for (int i = 0; i < pattern.Length; i++)
|
||||
{
|
||||
if (data[offset + i] != pattern[i])
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Infers function sizes from gaps between symbols.
|
||||
/// </summary>
|
||||
private void InferFunctionSizes(List<DetectedFunction> functions)
|
||||
{
|
||||
if (functions.Count < 2) return;
|
||||
|
||||
var sorted = functions.OrderBy(f => f.StartAddress).ToList();
|
||||
for (int i = 0; i < sorted.Count - 1; i++)
|
||||
{
|
||||
if (sorted[i].EndAddress == sorted[i].StartAddress)
|
||||
{
|
||||
// Function has no size, infer from next function
|
||||
sorted[i] = sorted[i] with
|
||||
{
|
||||
EndAddress = sorted[i + 1].StartAddress,
|
||||
Confidence = sorted[i].Confidence * _options.InferredSizePenalty // Reduce confidence for inferred size
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadNullTerminatedString(byte[] data, int offset)
|
||||
{
|
||||
if (offset < 0 || offset >= data.Length)
|
||||
return string.Empty;
|
||||
|
||||
var end = offset;
|
||||
while (end < data.Length && data[end] != 0)
|
||||
end++;
|
||||
|
||||
return System.Text.Encoding.UTF8.GetString(data, offset, end - offset);
|
||||
}
|
||||
|
||||
private static string DemangleSymbol(string name)
|
||||
{
|
||||
// Basic C++ demangling - production would use a proper demangler
|
||||
if (name.StartsWith("_Z"))
|
||||
{
|
||||
// This is a mangled C++ name
|
||||
// Full implementation would use c++filt or similar
|
||||
return name;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detected function boundary.
|
||||
/// </summary>
|
||||
public sealed record DetectedFunction
|
||||
{
|
||||
public required string Symbol { get; init; }
|
||||
public string? MangledName { get; init; }
|
||||
public required long StartAddress { get; init; }
|
||||
public required long EndAddress { get; init; }
|
||||
public required double Confidence { get; init; }
|
||||
public required FunctionDetectionMethod DetectionMethod { get; init; }
|
||||
public string? SourceFile { get; init; }
|
||||
public int? SourceLine { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Method used to detect function boundaries.
|
||||
/// </summary>
|
||||
public enum FunctionDetectionMethod
|
||||
{
|
||||
Dwarf,
|
||||
SymbolTable,
|
||||
Heuristic
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Symbol table entry.
|
||||
/// </summary>
|
||||
internal record SymbolEntry
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public string? MangledName { get; init; }
|
||||
public required long Address { get; init; }
|
||||
public required long Size { get; init; }
|
||||
public required SymbolType Type { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Symbol type.
|
||||
/// </summary>
|
||||
internal enum SymbolType
|
||||
{
|
||||
Function,
|
||||
Object,
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binary architecture.
|
||||
/// </summary>
|
||||
public enum BinaryArchitecture
|
||||
{
|
||||
Unknown,
|
||||
X86,
|
||||
X86_64,
|
||||
Arm,
|
||||
Arm64,
|
||||
Riscv64
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binary format.
|
||||
/// </summary>
|
||||
public enum BinaryFormat
|
||||
{
|
||||
Elf,
|
||||
Pe,
|
||||
MachO
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binary .text section data.
|
||||
/// </summary>
|
||||
public sealed record BinaryTextSection
|
||||
{
|
||||
public required byte[] Data { get; init; }
|
||||
public required long VirtualAddress { get; init; }
|
||||
public required BinaryArchitecture Architecture { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reader for binary .text sections.
|
||||
/// </summary>
|
||||
public static class BinaryTextSectionReader
|
||||
{
|
||||
public static async Task<BinaryTextSection?> TryReadAsync(
|
||||
string path,
|
||||
BinaryFormat format,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Simplified implementation - would parse ELF/PE/Mach-O headers
|
||||
// to locate .text section
|
||||
await Task.CompletedTask;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CallGraphExtractorRegistry.cs
|
||||
// Sprint: SPRINT_20251226_005_SCANNER_reachability_extractors (REACH-REG-01)
|
||||
// Description: Registry implementation for language-specific call graph extractors.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph;
|
||||
|
||||
/// <summary>
|
||||
/// Registry implementation for language-specific call graph extractors.
|
||||
/// Provides deterministic ordering and language-based lookup.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Supported languages (alphabetical order for determinism):
|
||||
/// - dotnet: .NET/C# via Roslyn semantic analysis
|
||||
/// - go: Go via SSA-based analysis (external tool or static fallback)
|
||||
/// - java: Java via ASM bytecode parsing
|
||||
/// - node: Node.js/JavaScript via Babel AST
|
||||
/// - python: Python via AST analysis
|
||||
/// </remarks>
|
||||
public sealed class CallGraphExtractorRegistry : ICallGraphExtractorRegistry
|
||||
{
|
||||
private readonly FrozenDictionary<string, ICallGraphExtractor> _extractorsByLanguage;
|
||||
private readonly IReadOnlyList<ICallGraphExtractor> _extractors;
|
||||
private readonly IReadOnlyList<string> _supportedLanguages;
|
||||
private readonly ILogger<CallGraphExtractorRegistry>? _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new registry from the provided extractors.
|
||||
/// </summary>
|
||||
/// <param name="extractors">The extractors to register.</param>
|
||||
/// <param name="logger">Optional logger for diagnostics.</param>
|
||||
public CallGraphExtractorRegistry(
|
||||
IEnumerable<ICallGraphExtractor> extractors,
|
||||
ILogger<CallGraphExtractorRegistry>? logger = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(extractors);
|
||||
_logger = logger;
|
||||
|
||||
var extractorList = extractors.ToList();
|
||||
|
||||
// Build lookup dictionary (case-insensitive language matching)
|
||||
var dict = new Dictionary<string, ICallGraphExtractor>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var extractor in extractorList)
|
||||
{
|
||||
if (!dict.TryAdd(extractor.Language, extractor))
|
||||
{
|
||||
_logger?.LogWarning(
|
||||
"Duplicate extractor registration for language '{Language}'; keeping first registration",
|
||||
extractor.Language);
|
||||
}
|
||||
}
|
||||
|
||||
_extractorsByLanguage = dict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Order extractors deterministically by language
|
||||
_extractors = extractorList
|
||||
.OrderBy(e => e.Language, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
|
||||
_supportedLanguages = _extractorsByLanguage.Keys
|
||||
.OrderBy(k => k, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
|
||||
_logger?.LogInformation(
|
||||
"CallGraphExtractorRegistry initialized with {Count} extractors: [{Languages}]",
|
||||
_supportedLanguages.Count,
|
||||
string.Join(", ", _supportedLanguages));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ICallGraphExtractor> Extractors => _extractors;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> SupportedLanguages => _supportedLanguages;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ICallGraphExtractor? GetExtractor(string language)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(language))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_extractorsByLanguage.TryGetValue(language, out var extractor);
|
||||
return extractor;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsLanguageSupported(string language)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(language))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _extractorsByLanguage.ContainsKey(language);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ICallGraphExtractorRegistry.cs
|
||||
// Sprint: SPRINT_20251226_005_SCANNER_reachability_extractors (REACH-REG-01)
|
||||
// Description: Registry interface for language-specific call graph extractors.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph;
|
||||
|
||||
/// <summary>
|
||||
/// Registry for language-specific call graph extractors.
|
||||
/// Provides lookup by language identifier and enumeration of supported languages.
|
||||
/// </summary>
|
||||
public interface ICallGraphExtractorRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets all registered extractors.
|
||||
/// </summary>
|
||||
IReadOnlyList<ICallGraphExtractor> Extractors { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the supported language identifiers.
|
||||
/// </summary>
|
||||
IReadOnlyList<string> SupportedLanguages { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets an extractor for the specified language.
|
||||
/// </summary>
|
||||
/// <param name="language">The language identifier (e.g., "java", "node", "python", "go", "dotnet").</param>
|
||||
/// <returns>The extractor for the language, or null if not supported.</returns>
|
||||
ICallGraphExtractor? GetExtractor(string language);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the specified language is supported.
|
||||
/// </summary>
|
||||
/// <param name="language">The language identifier.</param>
|
||||
/// <returns>True if the language has a registered extractor.</returns>
|
||||
bool IsLanguageSupported(string language);
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.Scanner.Evidence\\StellaOps.Scanner.Evidence.csproj" />
|
||||
<ProjectReference Include="..\\StellaOps.Scanner.Reachability\\StellaOps.Scanner.Reachability.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// FuncProofBuilder.cs
|
||||
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
|
||||
// Tasks: FUNC-05, FUNC-07, FUNC-10, FUNC-11 — Symbol/function hashing and trace serialization
|
||||
// Description: Builds FuncProof documents from binary analysis results.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Scanner.Evidence.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Builds FuncProof documents from binary analysis results.
|
||||
/// </summary>
|
||||
public sealed class FuncProofBuilder
|
||||
{
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private ICryptoHash? _cryptoHash;
|
||||
private FuncProofGenerationOptions _options = new();
|
||||
private string? _buildId;
|
||||
private string? _buildIdType;
|
||||
private string? _fileSha256;
|
||||
private string? _binaryFormat;
|
||||
private string? _architecture;
|
||||
private bool _isStripped;
|
||||
private readonly Dictionary<string, FuncProofSection> _sections = new();
|
||||
private readonly List<FuncProofFunctionBuilder> _functions = [];
|
||||
private readonly List<FuncProofTrace> _traces = [];
|
||||
private FuncProofMetadata? _metadata;
|
||||
private string _generatorVersion = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Sets the cryptographic hash provider for regional compliance.
|
||||
/// If not set, defaults to SHA-256 for backward compatibility.
|
||||
/// </summary>
|
||||
public FuncProofBuilder WithCryptoHash(ICryptoHash cryptoHash)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the generation options for configurable parameters.
|
||||
/// </summary>
|
||||
public FuncProofBuilder WithOptions(FuncProofGenerationOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the binary identity information.
|
||||
/// </summary>
|
||||
public FuncProofBuilder WithBinaryIdentity(
|
||||
string buildId,
|
||||
string buildIdType,
|
||||
string fileSha256,
|
||||
string binaryFormat,
|
||||
string architecture,
|
||||
bool isStripped)
|
||||
{
|
||||
_buildId = buildId;
|
||||
_buildIdType = buildIdType;
|
||||
_fileSha256 = fileSha256;
|
||||
_binaryFormat = binaryFormat;
|
||||
_architecture = architecture;
|
||||
_isStripped = isStripped;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a section with hash.
|
||||
/// </summary>
|
||||
public FuncProofBuilder AddSection(string name, byte[] content, long offset, long? virtualAddress = null)
|
||||
{
|
||||
var hash = ComputeBlake3Hash(content);
|
||||
_sections[name] = new FuncProofSection
|
||||
{
|
||||
Hash = $"blake3:{hash}",
|
||||
Offset = offset,
|
||||
Size = content.Length,
|
||||
VirtualAddress = virtualAddress
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a section with pre-computed hash.
|
||||
/// </summary>
|
||||
public FuncProofBuilder AddSection(string name, string hash, long offset, long size, long? virtualAddress = null)
|
||||
{
|
||||
_sections[name] = new FuncProofSection
|
||||
{
|
||||
Hash = hash.StartsWith("blake3:") ? hash : $"blake3:{hash}",
|
||||
Offset = offset,
|
||||
Size = size,
|
||||
VirtualAddress = virtualAddress
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a function definition.
|
||||
/// </summary>
|
||||
public FuncProofFunctionBuilder AddFunction(string symbol, long startAddress, long endAddress)
|
||||
{
|
||||
var builder = new FuncProofFunctionBuilder(this, symbol, startAddress, endAddress);
|
||||
_functions.Add(builder);
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an entry→sink trace.
|
||||
/// </summary>
|
||||
public FuncProofBuilder AddTrace(
|
||||
string entrySymbolDigest,
|
||||
string sinkSymbolDigest,
|
||||
IReadOnlyList<(string callerDigest, string calleeDigest)> edges,
|
||||
IReadOnlyList<string>? path = null)
|
||||
{
|
||||
var edgeListHash = ComputeEdgeListHash(edges);
|
||||
var hopCount = edges.Count;
|
||||
var maxHops = _options.MaxTraceHops;
|
||||
var truncated = hopCount > maxHops;
|
||||
|
||||
var effectivePath = path ?? edges.Select(e => e.calleeDigest).Prepend(entrySymbolDigest).ToList();
|
||||
if (effectivePath.Count > maxHops + 1)
|
||||
{
|
||||
effectivePath = effectivePath.Take(maxHops + 1).ToList();
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
var trace = new FuncProofTrace
|
||||
{
|
||||
TraceId = $"trace-{_traces.Count + 1}",
|
||||
EdgeListHash = $"blake3:{edgeListHash}",
|
||||
HopCount = Math.Min(hopCount, maxHops),
|
||||
EntrySymbolDigest = entrySymbolDigest,
|
||||
SinkSymbolDigest = sinkSymbolDigest,
|
||||
Path = effectivePath.ToImmutableArray(),
|
||||
Truncated = truncated
|
||||
};
|
||||
|
||||
_traces.Add(trace);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets build metadata.
|
||||
/// </summary>
|
||||
public FuncProofBuilder WithMetadata(FuncProofMetadata metadata)
|
||||
{
|
||||
_metadata = metadata;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the generator version.
|
||||
/// </summary>
|
||||
public FuncProofBuilder WithGeneratorVersion(string version)
|
||||
{
|
||||
_generatorVersion = version;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the FuncProof document.
|
||||
/// </summary>
|
||||
public FuncProof Build()
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(_buildId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(_buildIdType);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(_fileSha256);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(_binaryFormat);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(_architecture);
|
||||
|
||||
var functions = _functions
|
||||
.Select(f => f.Build())
|
||||
.OrderBy(f => f.Start, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var sections = _sections
|
||||
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
|
||||
.ToImmutableDictionary();
|
||||
|
||||
var traces = _traces
|
||||
.OrderBy(t => t.TraceId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
// Build initial proof without proofId
|
||||
var proof = new FuncProof
|
||||
{
|
||||
ProofId = string.Empty, // Placeholder
|
||||
BuildId = _buildId,
|
||||
BuildIdType = _buildIdType,
|
||||
FileSha256 = _fileSha256,
|
||||
BinaryFormat = _binaryFormat,
|
||||
Architecture = _architecture,
|
||||
IsStripped = _isStripped,
|
||||
Sections = sections,
|
||||
Functions = functions,
|
||||
Traces = traces,
|
||||
Meta = _metadata,
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
GeneratorVersion = _generatorVersion
|
||||
};
|
||||
|
||||
// Compute content-addressable ID
|
||||
var proofId = ComputeProofId(proof, _cryptoHash);
|
||||
|
||||
return proof with { ProofId = proofId };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the content-addressable proof ID.
|
||||
/// Uses ICryptoHash for regional compliance (defaults to BLAKE3 in "world" profile).
|
||||
/// </summary>
|
||||
public static string ComputeProofId(FuncProof proof, ICryptoHash? cryptoHash = null)
|
||||
{
|
||||
// Create a version without proofId for hashing
|
||||
var forHashing = proof with { ProofId = string.Empty };
|
||||
var json = JsonSerializer.Serialize(forHashing, CanonicalJsonOptions);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
var hash = ComputeHashForGraph(bytes, cryptoHash);
|
||||
|
||||
// Prefix indicates algorithm used (determined by compliance profile)
|
||||
var algorithmPrefix = cryptoHash is not null ? "graph" : "sha256";
|
||||
return $"{algorithmPrefix}:{hash}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes symbol digest: BLAKE3(symbol_name + "|" + start + "|" + end).
|
||||
/// Uses ICryptoHash for regional compliance (defaults to BLAKE3 in "world" profile).
|
||||
/// </summary>
|
||||
public static string ComputeSymbolDigest(string symbol, long start, long end, ICryptoHash? cryptoHash = null)
|
||||
{
|
||||
var input = $"{symbol}|{start:x}|{end:x}";
|
||||
var bytes = Encoding.UTF8.GetBytes(input);
|
||||
return ComputeHashForGraph(bytes, cryptoHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes function range hash over the function bytes.
|
||||
/// Uses ICryptoHash for regional compliance (defaults to BLAKE3 in "world" profile).
|
||||
/// </summary>
|
||||
public static string ComputeFunctionHash(byte[] functionBytes, ICryptoHash? cryptoHash = null)
|
||||
{
|
||||
return ComputeHashForGraph(functionBytes, cryptoHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes edge list hash: hash of sorted edge pairs.
|
||||
/// Uses ICryptoHash for regional compliance (defaults to BLAKE3 in "world" profile).
|
||||
/// </summary>
|
||||
private static string ComputeEdgeListHash(IReadOnlyList<(string callerDigest, string calleeDigest)> edges, ICryptoHash? cryptoHash = null)
|
||||
{
|
||||
var sortedEdges = edges
|
||||
.Select(e => $"{e.callerDigest}→{e.calleeDigest}")
|
||||
.OrderBy(e => e, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var edgeList = string.Join("\n", sortedEdges);
|
||||
var bytes = Encoding.UTF8.GetBytes(edgeList);
|
||||
return ComputeHashForGraph(bytes, cryptoHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes hash using the Graph purpose from ICryptoHash.
|
||||
/// Falls back to SHA-256 if no crypto hash provider is available.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Default algorithm by compliance profile:
|
||||
/// - world: BLAKE3-256
|
||||
/// - fips/kcmvp/eidas: SHA-256
|
||||
/// - gost: GOST3411-2012-256
|
||||
/// - sm: SM3
|
||||
/// </remarks>
|
||||
private static string ComputeHashForGraph(byte[] data, ICryptoHash? cryptoHash)
|
||||
{
|
||||
if (cryptoHash is not null)
|
||||
{
|
||||
// Use purpose-based hashing for compliance-aware algorithm selection
|
||||
return cryptoHash.ComputeHashHexForPurpose(data, HashPurpose.Graph);
|
||||
}
|
||||
|
||||
// Fallback: use SHA-256 when no ICryptoHash provider is available
|
||||
// This maintains backward compatibility for tests and standalone usage
|
||||
var hash = SHA256.HashData(data);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for individual function entries.
|
||||
/// </summary>
|
||||
public sealed class FuncProofFunctionBuilder
|
||||
{
|
||||
private readonly FuncProofBuilder _parent;
|
||||
private readonly string _symbol;
|
||||
private readonly long _startAddress;
|
||||
private readonly long _endAddress;
|
||||
private string? _mangledName;
|
||||
private byte[]? _functionBytes;
|
||||
private string? _precomputedHash;
|
||||
private double _confidence = 1.0;
|
||||
private string? _sourceFile;
|
||||
private int? _sourceLine;
|
||||
private bool _isEntrypoint;
|
||||
private string? _entrypointType;
|
||||
private bool _isSink;
|
||||
private string? _sinkVulnId;
|
||||
|
||||
internal FuncProofFunctionBuilder(FuncProofBuilder parent, string symbol, long startAddress, long endAddress)
|
||||
{
|
||||
_parent = parent;
|
||||
_symbol = symbol;
|
||||
_startAddress = startAddress;
|
||||
_endAddress = endAddress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the mangled name if different from symbol.
|
||||
/// </summary>
|
||||
public FuncProofFunctionBuilder WithMangledName(string mangledName)
|
||||
{
|
||||
_mangledName = mangledName;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the function bytes for hash computation.
|
||||
/// </summary>
|
||||
public FuncProofFunctionBuilder WithBytes(byte[] bytes)
|
||||
{
|
||||
_functionBytes = bytes;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a pre-computed hash.
|
||||
/// </summary>
|
||||
public FuncProofFunctionBuilder WithHash(string hash)
|
||||
{
|
||||
_precomputedHash = hash;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the confidence level for boundary detection.
|
||||
/// </summary>
|
||||
public FuncProofFunctionBuilder WithConfidence(double confidence)
|
||||
{
|
||||
_confidence = confidence;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets source location from DWARF info.
|
||||
/// </summary>
|
||||
public FuncProofFunctionBuilder WithSourceLocation(string file, int line)
|
||||
{
|
||||
_sourceFile = file;
|
||||
_sourceLine = line;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks this function as an entrypoint.
|
||||
/// </summary>
|
||||
public FuncProofFunctionBuilder AsEntrypoint(string? type = null)
|
||||
{
|
||||
_isEntrypoint = true;
|
||||
_entrypointType = type;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks this function as a vulnerable sink.
|
||||
/// </summary>
|
||||
public FuncProofFunctionBuilder AsSink(string? vulnId = null)
|
||||
{
|
||||
_isSink = true;
|
||||
_sinkVulnId = vulnId;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns to the parent builder.
|
||||
/// </summary>
|
||||
public FuncProofBuilder Done() => _parent;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the function entry.
|
||||
/// </summary>
|
||||
internal FuncProofFunction Build()
|
||||
{
|
||||
var symbolDigest = FuncProofBuilder.ComputeSymbolDigest(_symbol, _startAddress, _endAddress);
|
||||
|
||||
string hash;
|
||||
if (_precomputedHash != null)
|
||||
{
|
||||
hash = _precomputedHash.StartsWith("blake3:") ? _precomputedHash : $"blake3:{_precomputedHash}";
|
||||
}
|
||||
else if (_functionBytes != null)
|
||||
{
|
||||
hash = $"blake3:{FuncProofBuilder.ComputeFunctionHash(_functionBytes)}";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use symbol digest as fallback hash
|
||||
hash = $"blake3:{symbolDigest}";
|
||||
}
|
||||
|
||||
return new FuncProofFunction
|
||||
{
|
||||
Symbol = _symbol,
|
||||
MangledName = _mangledName,
|
||||
SymbolDigest = symbolDigest,
|
||||
Start = $"0x{_startAddress:x}",
|
||||
End = $"0x{_endAddress:x}",
|
||||
Size = _endAddress - _startAddress,
|
||||
Hash = hash,
|
||||
Confidence = _confidence,
|
||||
SourceFile = _sourceFile,
|
||||
SourceLine = _sourceLine,
|
||||
IsEntrypoint = _isEntrypoint,
|
||||
EntrypointType = _entrypointType,
|
||||
IsSink = _isSink,
|
||||
SinkVulnId = _sinkVulnId
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.Scanner.Evidence.Models;
|
||||
using StellaOps.Scanner.ProofSpine;
|
||||
|
||||
namespace StellaOps.Scanner.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Service for wrapping FuncProof documents in DSSE (Dead Simple Signing Envelope) for
|
||||
/// cryptographic attestation and transparency log integration.
|
||||
/// </summary>
|
||||
public interface IFuncProofDsseService
|
||||
{
|
||||
/// <summary>
|
||||
/// Wraps a FuncProof document in a signed DSSE envelope.
|
||||
/// </summary>
|
||||
/// <param name="funcProof">The FuncProof document to sign.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A signed DSSE envelope containing the FuncProof payload.</returns>
|
||||
Task<FuncProofDsseResult> SignAsync(FuncProof funcProof, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a FuncProof DSSE envelope signature.
|
||||
/// </summary>
|
||||
/// <param name="envelope">The DSSE envelope to verify.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Verification outcome with validity and trust status.</returns>
|
||||
Task<FuncProofVerificationResult> VerifyAsync(DsseEnvelope envelope, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the FuncProof payload from a DSSE envelope without verification.
|
||||
/// </summary>
|
||||
/// <param name="envelope">The DSSE envelope containing the FuncProof.</param>
|
||||
/// <returns>The extracted FuncProof document, or null if extraction fails.</returns>
|
||||
FuncProof? ExtractPayload(DsseEnvelope envelope);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of signing a FuncProof document.
|
||||
/// </summary>
|
||||
/// <param name="Envelope">The signed DSSE envelope.</param>
|
||||
/// <param name="EnvelopeId">Content-addressable ID of the envelope (SHA-256 of canonical JSON).</param>
|
||||
/// <param name="EnvelopeJson">Serialized envelope JSON for storage/transmission.</param>
|
||||
public sealed record FuncProofDsseResult(
|
||||
DsseEnvelope Envelope,
|
||||
string EnvelopeId,
|
||||
string EnvelopeJson);
|
||||
|
||||
/// <summary>
|
||||
/// Result of verifying a FuncProof DSSE envelope.
|
||||
/// </summary>
|
||||
/// <param name="IsValid">True if signature verification passed.</param>
|
||||
/// <param name="IsTrusted">True if signed with a trusted key (not deterministic fallback).</param>
|
||||
/// <param name="FailureReason">Description of failure if verification failed.</param>
|
||||
/// <param name="FuncProof">The extracted FuncProof if verification succeeded.</param>
|
||||
public sealed record FuncProofVerificationResult(
|
||||
bool IsValid,
|
||||
bool IsTrusted,
|
||||
string? FailureReason,
|
||||
FuncProof? FuncProof);
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for FuncProof DSSE signing.
|
||||
/// </summary>
|
||||
public sealed class FuncProofDsseOptions
|
||||
{
|
||||
public const string SectionName = "Scanner:FuncProof:Dsse";
|
||||
|
||||
/// <summary>
|
||||
/// Key identifier for signing operations.
|
||||
/// </summary>
|
||||
public string KeyId { get; set; } = "funcproof-default";
|
||||
|
||||
/// <summary>
|
||||
/// Signing algorithm (e.g., "hs256", "ed25519").
|
||||
/// </summary>
|
||||
public string Algorithm { get; set; } = "hs256";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include the proof ID in the envelope metadata.
|
||||
/// </summary>
|
||||
public bool IncludeProofIdInMetadata { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Crypto profile for FuncProof DSSE signing.
|
||||
/// </summary>
|
||||
internal sealed class FuncProofCryptoProfile : ICryptoProfile
|
||||
{
|
||||
public FuncProofCryptoProfile(string keyId, string algorithm)
|
||||
{
|
||||
KeyId = keyId ?? throw new ArgumentNullException(nameof(keyId));
|
||||
Algorithm = algorithm ?? throw new ArgumentNullException(nameof(algorithm));
|
||||
}
|
||||
|
||||
public string KeyId { get; }
|
||||
public string Algorithm { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of FuncProof DSSE signing service.
|
||||
/// </summary>
|
||||
public sealed class FuncProofDsseService : IFuncProofDsseService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly IDsseSigningService _signingService;
|
||||
private readonly IOptions<FuncProofDsseOptions> _options;
|
||||
private readonly ILogger<FuncProofDsseService> _logger;
|
||||
|
||||
public FuncProofDsseService(
|
||||
IDsseSigningService signingService,
|
||||
IOptions<FuncProofDsseOptions> options,
|
||||
ILogger<FuncProofDsseService> logger)
|
||||
{
|
||||
_signingService = signingService ?? throw new ArgumentNullException(nameof(signingService));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<FuncProofDsseResult> SignAsync(FuncProof funcProof, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(funcProof);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (string.IsNullOrEmpty(funcProof.ProofId))
|
||||
{
|
||||
throw new ArgumentException("FuncProof must have a valid ProofId before signing.", nameof(funcProof));
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Signing FuncProof {ProofId} for build {BuildId}",
|
||||
funcProof.ProofId,
|
||||
funcProof.BuildId);
|
||||
|
||||
var opts = _options.Value;
|
||||
var profile = new FuncProofCryptoProfile(opts.KeyId, opts.Algorithm);
|
||||
|
||||
// Sign the FuncProof document
|
||||
var envelope = await _signingService.SignAsync(
|
||||
funcProof,
|
||||
FuncProofConstants.MediaType,
|
||||
profile,
|
||||
ct);
|
||||
|
||||
// Compute envelope ID (content-addressable)
|
||||
var envelopeJson = JsonSerializer.Serialize(envelope, JsonOptions);
|
||||
var envelopeId = ComputeEnvelopeId(envelopeJson);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Signed FuncProof {ProofId} with envelope ID {EnvelopeId}",
|
||||
funcProof.ProofId,
|
||||
envelopeId);
|
||||
|
||||
return new FuncProofDsseResult(envelope, envelopeId, envelopeJson);
|
||||
}
|
||||
|
||||
public async Task<FuncProofVerificationResult> VerifyAsync(DsseEnvelope envelope, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Validate payload type
|
||||
if (!string.Equals(envelope.PayloadType, FuncProofConstants.MediaType, StringComparison.Ordinal))
|
||||
{
|
||||
return new FuncProofVerificationResult(
|
||||
false,
|
||||
false,
|
||||
$"Invalid payload type: expected '{FuncProofConstants.MediaType}', got '{envelope.PayloadType}'",
|
||||
null);
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
var outcome = await _signingService.VerifyAsync(envelope, ct);
|
||||
if (!outcome.IsValid)
|
||||
{
|
||||
_logger.LogWarning("FuncProof DSSE verification failed: {Reason}", outcome.FailureReason);
|
||||
return new FuncProofVerificationResult(false, false, outcome.FailureReason, null);
|
||||
}
|
||||
|
||||
// Extract and validate payload
|
||||
var funcProof = ExtractPayload(envelope);
|
||||
if (funcProof is null)
|
||||
{
|
||||
return new FuncProofVerificationResult(
|
||||
false,
|
||||
outcome.IsTrusted,
|
||||
"Failed to deserialize FuncProof payload",
|
||||
null);
|
||||
}
|
||||
|
||||
// Verify proof ID integrity
|
||||
var computedProofId = FuncProofBuilder.ComputeProofId(funcProof);
|
||||
if (!string.Equals(computedProofId, funcProof.ProofId, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"FuncProof ID mismatch: claimed {Claimed}, computed {Computed}",
|
||||
funcProof.ProofId,
|
||||
computedProofId);
|
||||
return new FuncProofVerificationResult(
|
||||
false,
|
||||
outcome.IsTrusted,
|
||||
$"Proof ID mismatch: claimed {funcProof.ProofId}, computed {computedProofId}",
|
||||
null);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"FuncProof {ProofId} verified successfully (trusted: {IsTrusted})",
|
||||
funcProof.ProofId,
|
||||
outcome.IsTrusted);
|
||||
|
||||
return new FuncProofVerificationResult(true, outcome.IsTrusted, null, funcProof);
|
||||
}
|
||||
|
||||
public FuncProof? ExtractPayload(DsseEnvelope envelope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
|
||||
try
|
||||
{
|
||||
var payloadBytes = Convert.FromBase64String(envelope.Payload);
|
||||
return JsonSerializer.Deserialize<FuncProof>(payloadBytes, JsonOptions);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or JsonException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to extract FuncProof from DSSE envelope");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes content-addressable ID for the DSSE envelope.
|
||||
/// Uses SHA-256 hash of the canonical JSON representation.
|
||||
/// </summary>
|
||||
private static string ComputeEnvelopeId(string envelopeJson)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(envelopeJson);
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for FuncProof DSSE integration.
|
||||
/// </summary>
|
||||
public static class FuncProofDsseExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a FuncProof DSSE envelope without signing (for unsigned storage/testing).
|
||||
/// </summary>
|
||||
public static DsseEnvelope ToUnsignedEnvelope(this FuncProof funcProof)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(funcProof);
|
||||
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(funcProof, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
return new DsseEnvelope(
|
||||
FuncProofConstants.MediaType,
|
||||
Convert.ToBase64String(payloadBytes),
|
||||
Array.Empty<DsseSignature>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a DSSE envelope from JSON.
|
||||
/// </summary>
|
||||
public static DsseEnvelope? ParseEnvelope(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<DsseEnvelope>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// FuncProofGenerationOptions.cs
|
||||
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
|
||||
// Task: FUNC-15 — Configurable generation options for FuncProof
|
||||
// Description: Configuration options for FuncProof generation including confidence
|
||||
// thresholds, trace depth limits, and function detection settings.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Scanner.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for FuncProof generation.
|
||||
/// Bind from configuration section "Scanner:FuncProof:Generation".
|
||||
/// </summary>
|
||||
public sealed class FuncProofGenerationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name for binding.
|
||||
/// </summary>
|
||||
public const string SectionName = "Scanner:FuncProof:Generation";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum trace depth (hop count) before truncation.
|
||||
/// Default: 10 hops (consistent with score-policy.v1.schema.json hopBuckets.maxHops).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Traces exceeding this depth are truncated and marked with IsTruncated=true.
|
||||
/// The truncation point is recorded to allow policy-based analysis.
|
||||
/// </remarks>
|
||||
public int MaxTraceHops { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum confidence threshold for including functions in the proof.
|
||||
/// Functions with confidence below this threshold are excluded.
|
||||
/// Default: 0.0 (include all detected functions).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Set to 0.5 to exclude low-confidence heuristic detections.
|
||||
/// Set to 0.8 to include only symbol table and DWARF detections.
|
||||
/// Set to 1.0 to include only DWARF debug info functions.
|
||||
/// </remarks>
|
||||
public double MinConfidenceThreshold { get; set; } = 0.0;
|
||||
|
||||
/// <summary>
|
||||
/// Confidence value for functions detected via DWARF debug info.
|
||||
/// Default: 1.0 (highest confidence - authoritative source).
|
||||
/// </summary>
|
||||
public double DwarfConfidence { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Confidence value for functions detected via symbol table entries.
|
||||
/// Default: 0.8 (high confidence - symbols may be incomplete).
|
||||
/// </summary>
|
||||
public double SymbolConfidence { get; set; } = 0.8;
|
||||
|
||||
/// <summary>
|
||||
/// Confidence value for functions detected via prolog/epilog heuristics.
|
||||
/// Default: 0.5 (moderate confidence - heuristics may have false positives).
|
||||
/// </summary>
|
||||
public double HeuristicConfidence { get; set; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Penalty multiplier applied to functions with inferred (non-authoritative) sizes.
|
||||
/// The original confidence is multiplied by this value.
|
||||
/// Default: 0.9 (10% confidence reduction).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When function size is inferred from the next function's address rather than
|
||||
/// from debug info or symbol table, confidence is reduced by this factor.
|
||||
/// </remarks>
|
||||
public double InferredSizePenalty { get; set; } = 0.9;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include functions from external/system libraries.
|
||||
/// Default: false (only include functions from the target binary).
|
||||
/// </summary>
|
||||
public bool IncludeExternalFunctions { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable parallel function detection for large binaries.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool EnableParallelDetection { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum function size in bytes for heuristic detection.
|
||||
/// Functions smaller than this are filtered out from heuristic results.
|
||||
/// Default: 4 bytes (minimum viable function).
|
||||
/// </summary>
|
||||
public int MinFunctionSize { get; set; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum function size in bytes for heuristic detection.
|
||||
/// Functions larger than this are flagged for review.
|
||||
/// Default: 1MB (unusually large functions may indicate detection errors).
|
||||
/// </summary>
|
||||
public int MaxFunctionSize { get; set; } = 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to compute call graph edges during proof generation.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Disabling this produces a simpler proof with only function boundaries,
|
||||
/// without trace information. Useful for quick enumeration.
|
||||
/// </remarks>
|
||||
public bool ComputeCallGraph { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include raw bytes hash for each function.
|
||||
/// Default: true (required for deterministic verification).
|
||||
/// </summary>
|
||||
public bool IncludeFunctionHashes { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Detection strategies to use, in priority order.
|
||||
/// Default: All strategies (DWARF, Symbols, Heuristic).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each strategy is tried in order. Higher-confidence results from
|
||||
/// earlier strategies take precedence over lower-confidence results.
|
||||
/// </remarks>
|
||||
public FunctionDetectionStrategy[] DetectionStrategies { get; set; } =
|
||||
[FunctionDetectionStrategy.Dwarf, FunctionDetectionStrategy.Symbols, FunctionDetectionStrategy.Heuristic];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Function detection strategies for binary analysis.
|
||||
/// </summary>
|
||||
public enum FunctionDetectionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Use DWARF debug information (highest confidence).
|
||||
/// Requires unstripped binaries with debug symbols.
|
||||
/// </summary>
|
||||
Dwarf = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Use symbol table entries (high confidence).
|
||||
/// Works with unstripped binaries.
|
||||
/// </summary>
|
||||
Symbols = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Use prolog/epilog pattern heuristics (moderate confidence).
|
||||
/// Works with stripped binaries but may have false positives.
|
||||
/// </summary>
|
||||
Heuristic = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Automatic strategy selection based on binary analysis.
|
||||
/// Tries all strategies and merges results by confidence.
|
||||
/// </summary>
|
||||
Auto = 99
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.Scanner.Evidence.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Service for submitting FuncProof documents to transparency logs (e.g., Sigstore Rekor).
|
||||
/// Provides tamper-evident logging of binary reachability proofs.
|
||||
/// </summary>
|
||||
public interface IFuncProofTransparencyService
|
||||
{
|
||||
/// <summary>
|
||||
/// Submits a signed FuncProof DSSE envelope to the transparency log.
|
||||
/// </summary>
|
||||
/// <param name="envelope">The DSSE envelope containing the signed FuncProof.</param>
|
||||
/// <param name="funcProof">The original FuncProof document for metadata extraction.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Result containing the transparency log entry details.</returns>
|
||||
Task<FuncProofTransparencyResult> SubmitAsync(
|
||||
DsseEnvelope envelope,
|
||||
FuncProof funcProof,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a FuncProof entry exists in the transparency log.
|
||||
/// </summary>
|
||||
/// <param name="entryId">The transparency log entry ID to verify.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Verification result with inclusion proof status.</returns>
|
||||
Task<FuncProofTransparencyVerifyResult> VerifyAsync(string entryId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of submitting a FuncProof to the transparency log.
|
||||
/// </summary>
|
||||
public sealed record FuncProofTransparencyResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier of the transparency log entry.
|
||||
/// </summary>
|
||||
public string? EntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Full URL location of the transparency log entry.
|
||||
/// </summary>
|
||||
public string? EntryLocation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Log index position (for Rekor-style transparency logs).
|
||||
/// </summary>
|
||||
public long? LogIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL to retrieve the inclusion proof.
|
||||
/// </summary>
|
||||
public string? InclusionProofUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the entry was recorded (UTC ISO-8601).
|
||||
/// </summary>
|
||||
public string? RecordedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if submission failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static FuncProofTransparencyResult Failed(string error) => new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error
|
||||
};
|
||||
|
||||
public static FuncProofTransparencyResult Skipped(string reason) => new()
|
||||
{
|
||||
Success = true,
|
||||
Error = reason
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of verifying a FuncProof transparency log entry.
|
||||
/// </summary>
|
||||
public sealed record FuncProofTransparencyVerifyResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True if the entry was found and verified in the log.
|
||||
/// </summary>
|
||||
public bool IsIncluded { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True if the inclusion proof was cryptographically verified.
|
||||
/// </summary>
|
||||
public bool ProofVerified { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if verification failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static FuncProofTransparencyVerifyResult Failed(string error) => new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for FuncProof transparency logging.
|
||||
/// </summary>
|
||||
public sealed class FuncProofTransparencyOptions
|
||||
{
|
||||
public const string SectionName = "Scanner:FuncProof:Transparency";
|
||||
|
||||
/// <summary>
|
||||
/// Whether transparency logging is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Base URL of the transparency log (e.g., https://rekor.sigstore.dev).
|
||||
/// </summary>
|
||||
public string? RekorUrl { get; set; } = "https://rekor.sigstore.dev";
|
||||
|
||||
/// <summary>
|
||||
/// API key for authenticated access to the transparency log (optional).
|
||||
/// </summary>
|
||||
public string? ApiKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for transparency log operations.
|
||||
/// </summary>
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Number of retry attempts for failed submissions.
|
||||
/// </summary>
|
||||
public int RetryCount { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Delay between retry attempts.
|
||||
/// </summary>
|
||||
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow offline mode (skip transparency log if unavailable).
|
||||
/// </summary>
|
||||
public bool AllowOffline { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of FuncProof transparency service using Rekor.
|
||||
/// </summary>
|
||||
public sealed class FuncProofTransparencyService : IFuncProofTransparencyService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOptions<FuncProofTransparencyOptions> _options;
|
||||
private readonly ILogger<FuncProofTransparencyService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public FuncProofTransparencyService(
|
||||
HttpClient httpClient,
|
||||
IOptions<FuncProofTransparencyOptions> options,
|
||||
ILogger<FuncProofTransparencyService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<FuncProofTransparencyResult> SubmitAsync(
|
||||
DsseEnvelope envelope,
|
||||
FuncProof funcProof,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
ArgumentNullException.ThrowIfNull(funcProof);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var opts = _options.Value;
|
||||
if (!opts.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Transparency logging disabled, skipping submission for FuncProof {ProofId}", funcProof.ProofId);
|
||||
return FuncProofTransparencyResult.Skipped("Transparency logging is disabled");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(opts.RekorUrl))
|
||||
{
|
||||
return FuncProofTransparencyResult.Failed("Rekor URL is not configured");
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Submitting FuncProof {ProofId} to transparency log at {RekorUrl}",
|
||||
funcProof.ProofId,
|
||||
opts.RekorUrl);
|
||||
|
||||
try
|
||||
{
|
||||
var entry = await SubmitToRekorAsync(envelope, opts, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"FuncProof {ProofId} recorded in transparency log: entry {EntryId} at index {LogIndex}",
|
||||
funcProof.ProofId,
|
||||
entry.EntryId,
|
||||
entry.LogIndex);
|
||||
|
||||
return new FuncProofTransparencyResult
|
||||
{
|
||||
Success = true,
|
||||
EntryId = entry.EntryId,
|
||||
EntryLocation = entry.EntryLocation,
|
||||
LogIndex = entry.LogIndex,
|
||||
InclusionProofUrl = entry.InclusionProofUrl,
|
||||
RecordedAt = _timeProvider.GetUtcNow().ToString("O")
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex) when (opts.AllowOffline)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Transparency log unavailable for FuncProof {ProofId}, continuing in offline mode",
|
||||
funcProof.ProofId);
|
||||
return FuncProofTransparencyResult.Skipped($"Transparency log unavailable (offline mode): {ex.Message}");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to submit FuncProof {ProofId} to transparency log", funcProof.ProofId);
|
||||
return FuncProofTransparencyResult.Failed($"Submission failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<FuncProofTransparencyVerifyResult> VerifyAsync(string entryId, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(entryId);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var opts = _options.Value;
|
||||
if (string.IsNullOrWhiteSpace(opts.RekorUrl))
|
||||
{
|
||||
return FuncProofTransparencyVerifyResult.Failed("Rekor URL is not configured");
|
||||
}
|
||||
|
||||
_logger.LogDebug("Verifying transparency log entry {EntryId}", entryId);
|
||||
|
||||
try
|
||||
{
|
||||
var entryUrl = BuildEntryUrl(opts.RekorUrl, entryId);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(opts.Timeout);
|
||||
|
||||
var response = await _httpClient.GetAsync(entryUrl, cts.Token).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug("Transparency log entry {EntryId} verified successfully", entryId);
|
||||
return new FuncProofTransparencyVerifyResult
|
||||
{
|
||||
Success = true,
|
||||
IsIncluded = true,
|
||||
ProofVerified = true // Rekor guarantees inclusion if entry exists
|
||||
};
|
||||
}
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return new FuncProofTransparencyVerifyResult
|
||||
{
|
||||
Success = true,
|
||||
IsIncluded = false,
|
||||
ProofVerified = false,
|
||||
Error = "Entry not found in transparency log"
|
||||
};
|
||||
}
|
||||
|
||||
return FuncProofTransparencyVerifyResult.Failed($"Verification failed with status {response.StatusCode}");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to verify transparency log entry {EntryId}", entryId);
|
||||
return FuncProofTransparencyVerifyResult.Failed($"Verification failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<RekorEntryInfo> SubmitToRekorAsync(
|
||||
DsseEnvelope envelope,
|
||||
FuncProofTransparencyOptions opts,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Build Rekor hashedrekord entry
|
||||
var rekorEntry = BuildRekorEntry(envelope);
|
||||
var payload = JsonSerializer.Serialize(rekorEntry, JsonOptions);
|
||||
|
||||
using var content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
HttpResponseMessage? response = null;
|
||||
Exception? lastException = null;
|
||||
|
||||
for (var attempt = 0; attempt < opts.RetryCount; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(opts.Timeout);
|
||||
|
||||
var requestUrl = $"{opts.RekorUrl.TrimEnd('/')}/api/v1/log/entries";
|
||||
if (!string.IsNullOrWhiteSpace(opts.ApiKey))
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", opts.ApiKey);
|
||||
}
|
||||
|
||||
response = await _httpClient.PostAsync(requestUrl, content, cts.Token).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Rekor submission attempt {Attempt} failed with status {Status}",
|
||||
attempt + 1, response.StatusCode);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogWarning(ex, "Rekor submission attempt {Attempt} failed", attempt + 1);
|
||||
}
|
||||
|
||||
if (attempt + 1 < opts.RetryCount)
|
||||
{
|
||||
await Task.Delay(opts.RetryDelay, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (response is null || !response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorMsg = lastException?.Message ?? response?.StatusCode.ToString() ?? "Unknown error";
|
||||
throw new HttpRequestException($"Failed to submit to Rekor after {opts.RetryCount} attempts: {errorMsg}");
|
||||
}
|
||||
|
||||
return await ParseRekorResponseAsync(response, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static object BuildRekorEntry(DsseEnvelope envelope)
|
||||
{
|
||||
// Build Rekor hashedrekord v0.0.1 entry format
|
||||
// See: https://github.com/sigstore/rekor/blob/main/pkg/types/hashedrekord/v0.0.1/hashedrekord_v0_0_1_schema.json
|
||||
var envelopeJson = JsonSerializer.Serialize(envelope, JsonOptions);
|
||||
var envelopeBytes = System.Text.Encoding.UTF8.GetBytes(envelopeJson);
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(envelopeBytes);
|
||||
|
||||
return new
|
||||
{
|
||||
kind = "hashedrekord",
|
||||
apiVersion = "0.0.1",
|
||||
spec = new
|
||||
{
|
||||
data = new
|
||||
{
|
||||
hash = new
|
||||
{
|
||||
algorithm = "sha256",
|
||||
value = Convert.ToHexString(hash).ToLowerInvariant()
|
||||
}
|
||||
},
|
||||
signature = new
|
||||
{
|
||||
content = Convert.ToBase64String(envelopeBytes),
|
||||
publicKey = new
|
||||
{
|
||||
content = string.Empty // For keyless signing, this would be populated by Fulcio
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<RekorEntryInfo> ParseRekorResponseAsync(HttpResponseMessage response, CancellationToken ct)
|
||||
{
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct).ConfigureAwait(false);
|
||||
|
||||
// Rekor returns a map with UUID as key
|
||||
string? entryId = null;
|
||||
long? logIndex = null;
|
||||
string? entryLocation = null;
|
||||
|
||||
if (json.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in json.EnumerateObject())
|
||||
{
|
||||
entryId = prop.Name;
|
||||
if (prop.Value.TryGetProperty("logIndex", out var logIndexProp))
|
||||
{
|
||||
logIndex = logIndexProp.GetInt64();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
entryLocation = response.Headers.Location?.ToString();
|
||||
if (string.IsNullOrEmpty(entryLocation) && !string.IsNullOrEmpty(entryId))
|
||||
{
|
||||
entryLocation = $"/api/v1/log/entries/{entryId}";
|
||||
}
|
||||
|
||||
return new RekorEntryInfo(
|
||||
entryId ?? string.Empty,
|
||||
entryLocation ?? string.Empty,
|
||||
logIndex,
|
||||
logIndex.HasValue ? $"/api/v1/log/entries?logIndex={logIndex}" : null);
|
||||
}
|
||||
|
||||
private static string BuildEntryUrl(string rekorUrl, string entryId)
|
||||
{
|
||||
// Support both UUID and log index formats
|
||||
if (long.TryParse(entryId, out var logIndex))
|
||||
{
|
||||
return $"{rekorUrl.TrimEnd('/')}/api/v1/log/entries?logIndex={logIndex}";
|
||||
}
|
||||
return $"{rekorUrl.TrimEnd('/')}/api/v1/log/entries/{entryId}";
|
||||
}
|
||||
|
||||
private sealed record RekorEntryInfo(
|
||||
string EntryId,
|
||||
string EntryLocation,
|
||||
long? LogIndex,
|
||||
string? InclusionProofUrl);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// FuncProof.cs
|
||||
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
|
||||
// Task: FUNC-01 — Define FuncProof JSON model
|
||||
// Description: Function-level proof objects for binary-level reachability evidence.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Evidence.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Function-level proof document providing cryptographic evidence of binary composition.
|
||||
/// Contains Build-ID, section hashes, function ranges with hashes, and entry→sink traces.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// FuncProof is designed for:
|
||||
/// <list type="bullet">
|
||||
/// <item>Auditor replay without source code access</item>
|
||||
/// <item>Symbol-level correlation with VEX statements</item>
|
||||
/// <item>DSSE signing and OCI referrer publishing</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed record FuncProof
|
||||
{
|
||||
/// <summary>
|
||||
/// Schema version for forward compatibility.
|
||||
/// </summary>
|
||||
[JsonPropertyName("schemaVersion")]
|
||||
public string SchemaVersion { get; init; } = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressable ID: BLAKE3 hash of canonical JSON representation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("proofId")]
|
||||
public required string ProofId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// GNU Build-ID (ELF), PE CodeView GUID, or Mach-O UUID.
|
||||
/// Primary correlation key for binary identity.
|
||||
/// </summary>
|
||||
[JsonPropertyName("buildId")]
|
||||
public required string BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of build ID: "gnu-build-id", "pe-codeview", "macho-uuid", "file-sha256".
|
||||
/// </summary>
|
||||
[JsonPropertyName("buildIdType")]
|
||||
public required string BuildIdType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 of the entire binary file for integrity verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("fileSha256")]
|
||||
public required string FileSha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Binary format: "elf", "pe", "macho".
|
||||
/// </summary>
|
||||
[JsonPropertyName("binaryFormat")]
|
||||
public required string BinaryFormat { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target architecture: "x86_64", "aarch64", "arm", "i386", etc.
|
||||
/// </summary>
|
||||
[JsonPropertyName("architecture")]
|
||||
public required string Architecture { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the binary is stripped of debug symbols.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isStripped")]
|
||||
public bool IsStripped { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Section hashes for integrity verification.
|
||||
/// Key: section name (e.g., ".text", ".rodata"), Value: BLAKE3 hash.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sections")]
|
||||
public ImmutableDictionary<string, FuncProofSection> Sections { get; init; }
|
||||
= ImmutableDictionary<string, FuncProofSection>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Function definitions with address ranges and hashes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("functions")]
|
||||
public ImmutableArray<FuncProofFunction> Functions { get; init; }
|
||||
= ImmutableArray<FuncProofFunction>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Entry→sink trace hashes for reachability evidence.
|
||||
/// Each hash represents a unique call path from entrypoint to vulnerable sink.
|
||||
/// </summary>
|
||||
[JsonPropertyName("traces")]
|
||||
public ImmutableArray<FuncProofTrace> Traces { get; init; }
|
||||
= ImmutableArray<FuncProofTrace>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Build metadata extracted from the binary or external sources.
|
||||
/// </summary>
|
||||
[JsonPropertyName("meta")]
|
||||
public FuncProofMetadata? Meta { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when this proof was generated (UTC ISO-8601).
|
||||
/// </summary>
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the tool that generated this proof.
|
||||
/// </summary>
|
||||
[JsonPropertyName("generatorVersion")]
|
||||
public required string GeneratorVersion { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Section information with hash and range.
|
||||
/// </summary>
|
||||
public sealed record FuncProofSection
|
||||
{
|
||||
/// <summary>
|
||||
/// BLAKE3 hash of the section contents.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hash")]
|
||||
public required string Hash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Section start offset in file.
|
||||
/// </summary>
|
||||
[JsonPropertyName("offset")]
|
||||
public required long Offset { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Section size in bytes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("size")]
|
||||
public required long Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Virtual address if applicable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("virtualAddress")]
|
||||
public long? VirtualAddress { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Function definition with address range and hash.
|
||||
/// </summary>
|
||||
public sealed record FuncProofFunction
|
||||
{
|
||||
/// <summary>
|
||||
/// Symbol name (demangled if available).
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbol")]
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Mangled/raw symbol name if different from demangled.
|
||||
/// </summary>
|
||||
[JsonPropertyName("mangledName")]
|
||||
public string? MangledName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol digest: BLAKE3(symbol_name + offset_range).
|
||||
/// Used for stable cross-binary correlation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbolDigest")]
|
||||
public required string SymbolDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start address (hex string, e.g., "0x401120").
|
||||
/// </summary>
|
||||
[JsonPropertyName("start")]
|
||||
public required string Start { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End address (hex string, e.g., "0x4013af").
|
||||
/// </summary>
|
||||
[JsonPropertyName("end")]
|
||||
public required string End { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size in bytes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("size")]
|
||||
public required long Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// BLAKE3 hash of the function's bytes within .text section.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hash")]
|
||||
public required string Hash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level for function boundary detection.
|
||||
/// 1.0 = DWARF/debug info, 0.8 = symbol table, 0.5 = heuristic prolog/epilog.
|
||||
/// </summary>
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Source file path (if DWARF info available).
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceFile")]
|
||||
public string? SourceFile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source line number (if DWARF info available).
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceLine")]
|
||||
public int? SourceLine { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this function is marked as an entrypoint.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isEntrypoint")]
|
||||
public bool IsEntrypoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of entrypoint if applicable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("entrypointType")]
|
||||
public string? EntrypointType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this function is a known vulnerable sink.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isSink")]
|
||||
public bool IsSink { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE or vulnerability ID if this is a sink.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sinkVulnId")]
|
||||
public string? SinkVulnId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry→sink trace with edge list hash.
|
||||
/// </summary>
|
||||
public sealed record FuncProofTrace
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique trace identifier (index or content-derived).
|
||||
/// </summary>
|
||||
[JsonPropertyName("traceId")]
|
||||
public required string TraceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// BLAKE3 hash of the edge list: sorted (caller_digest, callee_digest) pairs.
|
||||
/// </summary>
|
||||
[JsonPropertyName("edgeListHash")]
|
||||
public required string EdgeListHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of hops in this trace.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hopCount")]
|
||||
public required int HopCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol digest of the entry point.
|
||||
/// </summary>
|
||||
[JsonPropertyName("entrySymbolDigest")]
|
||||
public required string EntrySymbolDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol digest of the sink (vulnerable function).
|
||||
/// </summary>
|
||||
[JsonPropertyName("sinkSymbolDigest")]
|
||||
public required string SinkSymbolDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Compact path representation: ordered list of symbol digests.
|
||||
/// Limited to 10 hops max for compressed paths.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public ImmutableArray<string> Path { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this trace was truncated due to depth limit.
|
||||
/// </summary>
|
||||
[JsonPropertyName("truncated")]
|
||||
public bool Truncated { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build metadata extracted from binary or external sources.
|
||||
/// </summary>
|
||||
public sealed record FuncProofMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Compiler identification (e.g., "clang-18", "gcc-14").
|
||||
/// </summary>
|
||||
[JsonPropertyName("compiler")]
|
||||
public string? Compiler { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Compiler flags if extractable.
|
||||
/// </summary>
|
||||
[JsonPropertyName("flags")]
|
||||
public string? Flags { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Linker identification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("linker")]
|
||||
public string? Linker { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build timestamp if available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("buildTime")]
|
||||
public DateTimeOffset? BuildTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source commit hash if embedded in binary.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sourceCommit")]
|
||||
public string? SourceCommit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package name/version if this binary is part of a package.
|
||||
/// </summary>
|
||||
[JsonPropertyName("packageInfo")]
|
||||
public string? PackageInfo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OS ABI (e.g., "linux", "freebsd", "none").
|
||||
/// </summary>
|
||||
[JsonPropertyName("osAbi")]
|
||||
public string? OsAbi { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional properties as key-value pairs.
|
||||
/// </summary>
|
||||
[JsonPropertyName("properties")]
|
||||
public ImmutableDictionary<string, string>? Properties { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content type for FuncProof artifacts.
|
||||
/// </summary>
|
||||
public static class FuncProofConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// OCI media type for FuncProof artifacts.
|
||||
/// </summary>
|
||||
public const string MediaType = "application/vnd.stellaops.funcproof+json";
|
||||
|
||||
/// <summary>
|
||||
/// DSSE payload type for FuncProof.
|
||||
/// </summary>
|
||||
public const string DssePayloadType = "application/vnd.stellaops.funcproof+json";
|
||||
|
||||
/// <summary>
|
||||
/// Current schema version.
|
||||
/// </summary>
|
||||
public const string SchemaVersion = "1.0.0";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum trace depth before truncation.
|
||||
/// </summary>
|
||||
public const int MaxTraceHops = 10;
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SbomFuncProofLinker.cs
|
||||
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
|
||||
// Task: FUNC-15 — SBOM evidence link with CycloneDX integration
|
||||
// Description: Links FuncProof documents to SBOM components via CycloneDX 1.6
|
||||
// evidence model. Enables auditors to trace from SBOM → binary proof.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Evidence.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Evidence;
|
||||
|
||||
/// <summary>
|
||||
/// Links FuncProof evidence to SBOM components using CycloneDX 1.6 evidence model.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// CycloneDX 1.6 supports components.evidence.callFlow for linking binary
|
||||
/// analysis results to component entries. This enables:
|
||||
/// - Tracing from SBOM component → FuncProof document
|
||||
/// - Embedding function reachability as component evidence
|
||||
/// - Providing auditors with binary-level verification data
|
||||
/// </remarks>
|
||||
public interface ISbomFuncProofLinker
|
||||
{
|
||||
/// <summary>
|
||||
/// Links FuncProof evidence to a CycloneDX SBOM component.
|
||||
/// </summary>
|
||||
/// <param name="sbomJson">The CycloneDX SBOM JSON.</param>
|
||||
/// <param name="componentBomRef">The bom-ref of the target component.</param>
|
||||
/// <param name="funcProof">The FuncProof document to link.</param>
|
||||
/// <param name="proofDigest">SHA-256 digest of the signed FuncProof DSSE envelope.</param>
|
||||
/// <param name="proofLocation">URI or OCI reference to the FuncProof artifact.</param>
|
||||
/// <returns>Updated SBOM JSON with evidence linked.</returns>
|
||||
string LinkFuncProofEvidence(
|
||||
string sbomJson,
|
||||
string componentBomRef,
|
||||
FuncProof funcProof,
|
||||
string proofDigest,
|
||||
string proofLocation);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts FuncProof references from a CycloneDX SBOM component.
|
||||
/// </summary>
|
||||
/// <param name="sbomJson">The CycloneDX SBOM JSON.</param>
|
||||
/// <param name="componentBomRef">The bom-ref of the target component.</param>
|
||||
/// <returns>List of FuncProof evidence references found.</returns>
|
||||
IReadOnlyList<FuncProofEvidenceRef> ExtractFuncProofReferences(
|
||||
string sbomJson,
|
||||
string componentBomRef);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a CycloneDX evidence structure for a FuncProof document.
|
||||
/// </summary>
|
||||
FuncProofEvidenceRef CreateEvidenceRef(
|
||||
FuncProof funcProof,
|
||||
string proofDigest,
|
||||
string proofLocation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to FuncProof evidence in an SBOM.
|
||||
/// </summary>
|
||||
public sealed record FuncProofEvidenceRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Proof ID from the FuncProof document.
|
||||
/// </summary>
|
||||
public required string ProofId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build ID that links to the binary.
|
||||
/// </summary>
|
||||
public required string BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 of the binary file.
|
||||
/// </summary>
|
||||
public required string FileSha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the signed FuncProof DSSE envelope.
|
||||
/// </summary>
|
||||
public required string ProofDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URI or OCI reference to the FuncProof artifact.
|
||||
/// </summary>
|
||||
public required string Location { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of functions in the proof.
|
||||
/// </summary>
|
||||
public required int FunctionCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of traces in the proof.
|
||||
/// </summary>
|
||||
public required int TraceCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the proof was generated.
|
||||
/// </summary>
|
||||
public DateTimeOffset? GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Transparency log entry ID (if logged to Rekor).
|
||||
/// </summary>
|
||||
public string? TransparencyLogEntry { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of SBOM-FuncProof linker.
|
||||
/// </summary>
|
||||
public sealed class SbomFuncProofLinker : ISbomFuncProofLinker
|
||||
{
|
||||
private readonly ILogger<SbomFuncProofLinker> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
// CycloneDX evidence type for binary analysis
|
||||
private const string EvidenceType = "binary-analysis";
|
||||
private const string EvidenceMethod = "funcproof";
|
||||
private const string StellaOpsNamespace = "https://stellaops.io/evidence/funcproof";
|
||||
|
||||
public SbomFuncProofLinker(ILogger<SbomFuncProofLinker> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string LinkFuncProofEvidence(
|
||||
string sbomJson,
|
||||
string componentBomRef,
|
||||
FuncProof funcProof,
|
||||
string proofDigest,
|
||||
string proofLocation)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sbomJson);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentBomRef);
|
||||
ArgumentNullException.ThrowIfNull(funcProof);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(proofDigest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(proofLocation);
|
||||
|
||||
var doc = JsonNode.Parse(sbomJson) as JsonObject
|
||||
?? throw new ArgumentException("Invalid SBOM JSON", nameof(sbomJson));
|
||||
|
||||
// Validate this is a CycloneDX document
|
||||
if (doc["bomFormat"]?.GetValue<string>() != "CycloneDX")
|
||||
{
|
||||
throw new ArgumentException("SBOM is not in CycloneDX format", nameof(sbomJson));
|
||||
}
|
||||
|
||||
// Find the target component
|
||||
var components = doc["components"] as JsonArray;
|
||||
if (components == null || components.Count == 0)
|
||||
{
|
||||
throw new ArgumentException($"No components found in SBOM", nameof(sbomJson));
|
||||
}
|
||||
|
||||
var targetComponent = FindComponent(components, componentBomRef);
|
||||
if (targetComponent == null)
|
||||
{
|
||||
throw new ArgumentException($"Component with bom-ref '{componentBomRef}' not found", nameof(componentBomRef));
|
||||
}
|
||||
|
||||
// Create evidence structure
|
||||
var evidenceRef = CreateEvidenceRef(funcProof, proofDigest, proofLocation);
|
||||
var evidence = CreateCycloneDxEvidence(evidenceRef);
|
||||
|
||||
// Add or update evidence on the component
|
||||
AddEvidenceToComponent(targetComponent, evidence);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Linked FuncProof {ProofId} to component {BomRef} with {FunctionCount} functions",
|
||||
funcProof.ProofId, componentBomRef, funcProof.Functions.Length);
|
||||
|
||||
return doc.ToJsonString(JsonOptions);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<FuncProofEvidenceRef> ExtractFuncProofReferences(
|
||||
string sbomJson,
|
||||
string componentBomRef)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sbomJson);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentBomRef);
|
||||
|
||||
var doc = JsonNode.Parse(sbomJson) as JsonObject;
|
||||
if (doc == null) return [];
|
||||
|
||||
var components = doc["components"] as JsonArray;
|
||||
if (components == null) return [];
|
||||
|
||||
var targetComponent = FindComponent(components, componentBomRef);
|
||||
if (targetComponent == null) return [];
|
||||
|
||||
var evidence = targetComponent["evidence"] as JsonObject;
|
||||
if (evidence == null) return [];
|
||||
|
||||
var references = new List<FuncProofEvidenceRef>();
|
||||
|
||||
// Check callflow evidence (CycloneDX 1.6+)
|
||||
var callflow = evidence["callflow"] as JsonObject;
|
||||
if (callflow != null)
|
||||
{
|
||||
var frames = callflow["frames"] as JsonArray;
|
||||
if (frames != null)
|
||||
{
|
||||
foreach (var frame in frames)
|
||||
{
|
||||
if (frame is not JsonObject frameObj) continue;
|
||||
|
||||
// Check if this is a FuncProof reference
|
||||
var properties = frameObj["properties"] as JsonArray;
|
||||
if (properties == null) continue;
|
||||
|
||||
var isFuncProof = properties.Any(p =>
|
||||
p is JsonObject po &&
|
||||
po["name"]?.GetValue<string>() == "stellaops:evidence:type" &&
|
||||
po["value"]?.GetValue<string>() == "funcproof");
|
||||
|
||||
if (!isFuncProof) continue;
|
||||
|
||||
var evidenceRef = ParseEvidenceFromProperties(properties);
|
||||
if (evidenceRef != null)
|
||||
{
|
||||
references.Add(evidenceRef);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check externalReferences for FuncProof links
|
||||
var externalRefs = targetComponent["externalReferences"] as JsonArray;
|
||||
if (externalRefs != null)
|
||||
{
|
||||
foreach (var extRef in externalRefs)
|
||||
{
|
||||
if (extRef is not JsonObject extRefObj) continue;
|
||||
|
||||
var type = extRefObj["type"]?.GetValue<string>();
|
||||
var comment = extRefObj["comment"]?.GetValue<string>();
|
||||
|
||||
if (type == "evidence" && comment?.Contains("funcproof") == true)
|
||||
{
|
||||
var url = extRefObj["url"]?.GetValue<string>();
|
||||
var hashes = extRefObj["hashes"] as JsonArray;
|
||||
|
||||
var sha256Hash = hashes?
|
||||
.OfType<JsonObject>()
|
||||
.FirstOrDefault(h => h["alg"]?.GetValue<string>() == "SHA-256")?
|
||||
["content"]?.GetValue<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(url))
|
||||
{
|
||||
// Parse additional metadata from comment
|
||||
var metadata = ParseCommentMetadata(comment);
|
||||
|
||||
references.Add(new FuncProofEvidenceRef
|
||||
{
|
||||
ProofId = metadata.TryGetValue("proofId", out var pid) ? pid : "unknown",
|
||||
BuildId = metadata.TryGetValue("buildId", out var bid) ? bid : "unknown",
|
||||
FileSha256 = metadata.TryGetValue("fileSha256", out var fsha) ? fsha : "unknown",
|
||||
ProofDigest = sha256Hash ?? "unknown",
|
||||
Location = url,
|
||||
FunctionCount = int.TryParse(
|
||||
metadata.TryGetValue("functionCount", out var fc) ? fc : "0",
|
||||
out var fcInt) ? fcInt : 0,
|
||||
TraceCount = int.TryParse(
|
||||
metadata.TryGetValue("traceCount", out var tc) ? tc : "0",
|
||||
out var tcInt) ? tcInt : 0
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public FuncProofEvidenceRef CreateEvidenceRef(
|
||||
FuncProof funcProof,
|
||||
string proofDigest,
|
||||
string proofLocation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(funcProof);
|
||||
|
||||
return new FuncProofEvidenceRef
|
||||
{
|
||||
ProofId = funcProof.ProofId,
|
||||
BuildId = funcProof.BuildId,
|
||||
FileSha256 = funcProof.FileSha256,
|
||||
ProofDigest = proofDigest,
|
||||
Location = proofLocation,
|
||||
FunctionCount = funcProof.Functions.Length,
|
||||
TraceCount = funcProof.Traces?.Length ?? 0,
|
||||
GeneratedAt = funcProof.Metadata?.Timestamp != null
|
||||
? DateTimeOffset.Parse(funcProof.Metadata.Timestamp)
|
||||
: null,
|
||||
TransparencyLogEntry = funcProof.Metadata?.Properties?.TryGetValue("rekorEntryId", out var rekorId) == true
|
||||
? rekorId
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject? FindComponent(JsonArray components, string bomRef)
|
||||
{
|
||||
foreach (var component in components)
|
||||
{
|
||||
if (component is not JsonObject componentObj) continue;
|
||||
|
||||
var currentBomRef = componentObj["bom-ref"]?.GetValue<string>();
|
||||
if (currentBomRef == bomRef)
|
||||
{
|
||||
return componentObj;
|
||||
}
|
||||
|
||||
// Check nested components
|
||||
var nestedComponents = componentObj["components"] as JsonArray;
|
||||
if (nestedComponents != null)
|
||||
{
|
||||
var found = FindComponent(nestedComponents, bomRef);
|
||||
if (found != null) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private JsonObject CreateCycloneDxEvidence(FuncProofEvidenceRef evidenceRef)
|
||||
{
|
||||
// Create CycloneDX 1.6 evidence structure with callflow
|
||||
var evidence = new JsonObject
|
||||
{
|
||||
["callflow"] = new JsonObject
|
||||
{
|
||||
["frames"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["package"] = "binary",
|
||||
["module"] = evidenceRef.BuildId,
|
||||
["function"] = $"[{evidenceRef.FunctionCount} functions analyzed]",
|
||||
["line"] = 0,
|
||||
["column"] = 0,
|
||||
["fullFilename"] = evidenceRef.Location,
|
||||
["properties"] = new JsonArray
|
||||
{
|
||||
CreateProperty("stellaops:evidence:type", "funcproof"),
|
||||
CreateProperty("stellaops:funcproof:proofId", evidenceRef.ProofId),
|
||||
CreateProperty("stellaops:funcproof:buildId", evidenceRef.BuildId),
|
||||
CreateProperty("stellaops:funcproof:fileSha256", evidenceRef.FileSha256),
|
||||
CreateProperty("stellaops:funcproof:proofDigest", evidenceRef.ProofDigest),
|
||||
CreateProperty("stellaops:funcproof:functionCount", evidenceRef.FunctionCount.ToString()),
|
||||
CreateProperty("stellaops:funcproof:traceCount", evidenceRef.TraceCount.ToString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add transparency log entry if available
|
||||
if (!string.IsNullOrEmpty(evidenceRef.TransparencyLogEntry))
|
||||
{
|
||||
var frames = evidence["callflow"]!["frames"] as JsonArray;
|
||||
var firstFrame = frames![0] as JsonObject;
|
||||
var properties = firstFrame!["properties"] as JsonArray;
|
||||
properties!.Add(CreateProperty("stellaops:funcproof:rekorEntryId", evidenceRef.TransparencyLogEntry));
|
||||
}
|
||||
|
||||
return evidence;
|
||||
}
|
||||
|
||||
private static JsonObject CreateProperty(string name, string value) =>
|
||||
new JsonObject
|
||||
{
|
||||
["name"] = name,
|
||||
["value"] = value
|
||||
};
|
||||
|
||||
private static void AddEvidenceToComponent(JsonObject component, JsonObject evidence)
|
||||
{
|
||||
var existingEvidence = component["evidence"] as JsonObject;
|
||||
if (existingEvidence == null)
|
||||
{
|
||||
component["evidence"] = evidence;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Merge callflow frames
|
||||
var existingCallflow = existingEvidence["callflow"] as JsonObject;
|
||||
var newCallflow = evidence["callflow"] as JsonObject;
|
||||
|
||||
if (existingCallflow == null && newCallflow != null)
|
||||
{
|
||||
existingEvidence["callflow"] = newCallflow;
|
||||
}
|
||||
else if (existingCallflow != null && newCallflow != null)
|
||||
{
|
||||
var existingFrames = existingCallflow["frames"] as JsonArray ?? new JsonArray();
|
||||
var newFrames = newCallflow["frames"] as JsonArray ?? new JsonArray();
|
||||
|
||||
foreach (var frame in newFrames)
|
||||
{
|
||||
if (frame != null)
|
||||
{
|
||||
existingFrames.Add(frame.DeepClone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also add external reference for tooling compatibility
|
||||
var externalRefs = component["externalReferences"] as JsonArray;
|
||||
if (externalRefs == null)
|
||||
{
|
||||
externalRefs = new JsonArray();
|
||||
component["externalReferences"] = externalRefs;
|
||||
}
|
||||
|
||||
// Get values from evidence
|
||||
var proofId = GetPropertyValue(evidence, "stellaops:funcproof:proofId") ?? "unknown";
|
||||
var buildId = GetPropertyValue(evidence, "stellaops:funcproof:buildId") ?? "unknown";
|
||||
var fileSha256 = GetPropertyValue(evidence, "stellaops:funcproof:fileSha256") ?? "unknown";
|
||||
var proofDigest = GetPropertyValue(evidence, "stellaops:funcproof:proofDigest") ?? "unknown";
|
||||
var functionCount = GetPropertyValue(evidence, "stellaops:funcproof:functionCount") ?? "0";
|
||||
var traceCount = GetPropertyValue(evidence, "stellaops:funcproof:traceCount") ?? "0";
|
||||
var location = ((evidence["callflow"] as JsonObject)?["frames"] as JsonArray)?
|
||||
[0]?["fullFilename"]?.GetValue<string>() ?? "";
|
||||
|
||||
externalRefs.Add(new JsonObject
|
||||
{
|
||||
["type"] = "evidence",
|
||||
["url"] = location,
|
||||
["comment"] = $"funcproof:proofId={proofId};buildId={buildId};fileSha256={fileSha256};functionCount={functionCount};traceCount={traceCount}",
|
||||
["hashes"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["alg"] = "SHA-256",
|
||||
["content"] = proofDigest
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static string? GetPropertyValue(JsonObject evidence, string propertyName)
|
||||
{
|
||||
var frames = (evidence["callflow"] as JsonObject)?["frames"] as JsonArray;
|
||||
if (frames == null || frames.Count == 0) return null;
|
||||
|
||||
var properties = (frames[0] as JsonObject)?["properties"] as JsonArray;
|
||||
if (properties == null) return null;
|
||||
|
||||
foreach (var prop in properties)
|
||||
{
|
||||
if (prop is JsonObject propObj &&
|
||||
propObj["name"]?.GetValue<string>() == propertyName)
|
||||
{
|
||||
return propObj["value"]?.GetValue<string>();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private FuncProofEvidenceRef? ParseEvidenceFromProperties(JsonArray properties)
|
||||
{
|
||||
var props = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var prop in properties)
|
||||
{
|
||||
if (prop is not JsonObject propObj) continue;
|
||||
|
||||
var name = propObj["name"]?.GetValue<string>();
|
||||
var value = propObj["value"]?.GetValue<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(name) && value != null)
|
||||
{
|
||||
// Strip the stellaops:funcproof: prefix
|
||||
if (name.StartsWith("stellaops:funcproof:"))
|
||||
{
|
||||
props[name["stellaops:funcproof:".Length..]] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!props.TryGetValue("proofId", out var proofId)) return null;
|
||||
|
||||
return new FuncProofEvidenceRef
|
||||
{
|
||||
ProofId = proofId,
|
||||
BuildId = props.TryGetValue("buildId", out var bid) ? bid : "unknown",
|
||||
FileSha256 = props.TryGetValue("fileSha256", out var fsha) ? fsha : "unknown",
|
||||
ProofDigest = props.TryGetValue("proofDigest", out var pd) ? pd : "unknown",
|
||||
Location = "", // Will be filled from frame.fullFilename
|
||||
FunctionCount = int.TryParse(
|
||||
props.TryGetValue("functionCount", out var fc) ? fc : "0",
|
||||
out var fcInt) ? fcInt : 0,
|
||||
TraceCount = int.TryParse(
|
||||
props.TryGetValue("traceCount", out var tc) ? tc : "0",
|
||||
out var tcInt) ? tcInt : 0,
|
||||
TransparencyLogEntry = props.TryGetValue("rekorEntryId", out var rekor) ? rekor : null
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseCommentMetadata(string? comment)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrEmpty(comment)) return result;
|
||||
|
||||
// Parse "funcproof:proofId=xxx;buildId=yyy;..." format
|
||||
var parts = comment.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var trimmed = part.Trim();
|
||||
if (trimmed.StartsWith("funcproof:"))
|
||||
{
|
||||
trimmed = trimmed["funcproof:".Length..];
|
||||
}
|
||||
|
||||
var eqIdx = trimmed.IndexOf('=');
|
||||
if (eqIdx > 0)
|
||||
{
|
||||
var key = trimmed[..eqIdx].Trim();
|
||||
var value = trimmed[(eqIdx + 1)..].Trim();
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -14,5 +14,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.Scanner.Evidence.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Oci;
|
||||
|
||||
/// <summary>
|
||||
/// Service for publishing FuncProof documents to OCI registries as referrer artifacts.
|
||||
/// Follows the OCI referrer pattern to link FuncProof evidence to the original image.
|
||||
/// </summary>
|
||||
public interface IFuncProofOciPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// Publishes a FuncProof document to an OCI registry as a referrer artifact.
|
||||
/// </summary>
|
||||
/// <param name="request">The publish request containing FuncProof and target details.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Result containing the pushed manifest digest and reference.</returns>
|
||||
Task<FuncProofOciPublishResult> PublishAsync(FuncProofOciPublishRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to publish a FuncProof document to OCI registry.
|
||||
/// </summary>
|
||||
public sealed record FuncProofOciPublishRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The FuncProof document to publish.
|
||||
/// </summary>
|
||||
public required FuncProof FuncProof { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional DSSE envelope containing the signed FuncProof.
|
||||
/// If provided, this is published instead of the raw FuncProof.
|
||||
/// </summary>
|
||||
public DsseEnvelope? DsseEnvelope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target OCI registry reference (e.g., "registry.example.com/repo:tag").
|
||||
/// </summary>
|
||||
public required string RegistryReference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digest of the subject image this FuncProof refers to.
|
||||
/// Used to create a referrer relationship (OCI referrer pattern).
|
||||
/// </summary>
|
||||
public required string SubjectDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tag for the FuncProof artifact. If null, uses the proof ID.
|
||||
/// </summary>
|
||||
public string? Tag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional annotations to include in the OCI manifest.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of publishing a FuncProof document to OCI registry.
|
||||
/// </summary>
|
||||
public sealed record FuncProofOciPublishResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public string? ManifestDigest { get; init; }
|
||||
public string? ManifestReference { get; init; }
|
||||
public string? ProofLayerDigest { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
public static FuncProofOciPublishResult Failed(string error) => new()
|
||||
{
|
||||
Success = false,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for FuncProof OCI publishing.
|
||||
/// </summary>
|
||||
public sealed class FuncProofOciOptions
|
||||
{
|
||||
public const string SectionName = "Scanner:FuncProof:Oci";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to publish FuncProof as a referrer artifact.
|
||||
/// </summary>
|
||||
public bool EnableReferrerPublish { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include the DSSE envelope as a separate layer.
|
||||
/// </summary>
|
||||
public bool IncludeDsseLayer { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to compress the FuncProof content before publishing.
|
||||
/// </summary>
|
||||
public bool CompressContent { get; set; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of FuncProof OCI publisher.
|
||||
/// </summary>
|
||||
public sealed class FuncProofOciPublisher : IFuncProofOciPublisher
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly IOciPushService _ociPushService;
|
||||
private readonly IOptions<FuncProofOciOptions> _options;
|
||||
private readonly ILogger<FuncProofOciPublisher> _logger;
|
||||
|
||||
public FuncProofOciPublisher(
|
||||
IOciPushService ociPushService,
|
||||
IOptions<FuncProofOciOptions> options,
|
||||
ILogger<FuncProofOciPublisher> logger)
|
||||
{
|
||||
_ociPushService = ociPushService ?? throw new ArgumentNullException(nameof(ociPushService));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<FuncProofOciPublishResult> PublishAsync(
|
||||
FuncProofOciPublishRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(request.FuncProof);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.RegistryReference);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.SubjectDigest);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (string.IsNullOrEmpty(request.FuncProof.ProofId))
|
||||
{
|
||||
return FuncProofOciPublishResult.Failed("FuncProof must have a valid ProofId before publishing.");
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Publishing FuncProof {ProofId} to OCI registry {Reference}",
|
||||
request.FuncProof.ProofId,
|
||||
request.RegistryReference);
|
||||
|
||||
try
|
||||
{
|
||||
var layers = BuildLayers(request);
|
||||
var annotations = BuildAnnotations(request);
|
||||
|
||||
var pushRequest = new OciArtifactPushRequest
|
||||
{
|
||||
Reference = request.RegistryReference,
|
||||
ArtifactType = FuncProofOciMediaTypes.ArtifactType,
|
||||
Layers = layers,
|
||||
SubjectDigest = request.SubjectDigest,
|
||||
Annotations = annotations
|
||||
};
|
||||
|
||||
var result = await _ociPushService.PushAsync(pushRequest, ct).ConfigureAwait(false);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to publish FuncProof {ProofId}: {Error}",
|
||||
request.FuncProof.ProofId,
|
||||
result.Error);
|
||||
return FuncProofOciPublishResult.Failed(result.Error ?? "Unknown OCI push failure");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Published FuncProof {ProofId} to {Reference} with digest {Digest}",
|
||||
request.FuncProof.ProofId,
|
||||
result.ManifestReference,
|
||||
result.ManifestDigest);
|
||||
|
||||
return new FuncProofOciPublishResult
|
||||
{
|
||||
Success = true,
|
||||
ManifestDigest = result.ManifestDigest,
|
||||
ManifestReference = result.ManifestReference,
|
||||
ProofLayerDigest = result.LayerDigests?.FirstOrDefault()
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Error publishing FuncProof {ProofId}", request.FuncProof.ProofId);
|
||||
return FuncProofOciPublishResult.Failed($"Publish error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private List<OciLayerContent> BuildLayers(FuncProofOciPublishRequest request)
|
||||
{
|
||||
var layers = new List<OciLayerContent>();
|
||||
var opts = _options.Value;
|
||||
|
||||
// Primary FuncProof layer
|
||||
byte[] proofContent;
|
||||
string proofMediaType;
|
||||
|
||||
if (request.DsseEnvelope is not null && opts.IncludeDsseLayer)
|
||||
{
|
||||
// Use DSSE envelope as primary layer
|
||||
proofContent = JsonSerializer.SerializeToUtf8Bytes(request.DsseEnvelope, JsonOptions);
|
||||
proofMediaType = FuncProofOciMediaTypes.DsseLayer;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use raw FuncProof
|
||||
proofContent = JsonSerializer.SerializeToUtf8Bytes(request.FuncProof, JsonOptions);
|
||||
proofMediaType = FuncProofOciMediaTypes.ProofLayer;
|
||||
}
|
||||
|
||||
if (opts.CompressContent)
|
||||
{
|
||||
proofContent = CompressGzip(proofContent);
|
||||
proofMediaType += "+gzip";
|
||||
}
|
||||
|
||||
layers.Add(new OciLayerContent
|
||||
{
|
||||
Content = proofContent,
|
||||
MediaType = proofMediaType,
|
||||
Annotations = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[OciAnnotations.Title] = $"funcproof-{request.FuncProof.ProofId}",
|
||||
[FuncProofOciAnnotations.ProofId] = request.FuncProof.ProofId,
|
||||
[FuncProofOciAnnotations.BuildId] = request.FuncProof.BuildId ?? string.Empty,
|
||||
[FuncProofOciAnnotations.FunctionCount] = request.FuncProof.Functions?.Count.ToString() ?? "0"
|
||||
}
|
||||
});
|
||||
|
||||
// Add raw FuncProof as secondary layer if DSSE was primary
|
||||
if (request.DsseEnvelope is not null && opts.IncludeDsseLayer)
|
||||
{
|
||||
var rawContent = JsonSerializer.SerializeToUtf8Bytes(request.FuncProof, JsonOptions);
|
||||
if (opts.CompressContent)
|
||||
{
|
||||
rawContent = CompressGzip(rawContent);
|
||||
}
|
||||
|
||||
layers.Add(new OciLayerContent
|
||||
{
|
||||
Content = rawContent,
|
||||
MediaType = opts.CompressContent
|
||||
? FuncProofOciMediaTypes.ProofLayer + "+gzip"
|
||||
: FuncProofOciMediaTypes.ProofLayer,
|
||||
Annotations = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[OciAnnotations.Title] = $"funcproof-raw-{request.FuncProof.ProofId}"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
private SortedDictionary<string, string> BuildAnnotations(FuncProofOciPublishRequest request)
|
||||
{
|
||||
var annotations = new SortedDictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[OciAnnotations.Title] = $"FuncProof for {request.FuncProof.BuildId ?? request.FuncProof.ProofId}",
|
||||
[FuncProofOciAnnotations.ProofId] = request.FuncProof.ProofId,
|
||||
[FuncProofOciAnnotations.SchemaVersion] = FuncProofConstants.SchemaVersion
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(request.FuncProof.BuildId))
|
||||
{
|
||||
annotations[FuncProofOciAnnotations.BuildId] = request.FuncProof.BuildId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(request.FuncProof.FileSha256))
|
||||
{
|
||||
annotations[FuncProofOciAnnotations.FileSha256] = request.FuncProof.FileSha256;
|
||||
}
|
||||
|
||||
if (request.FuncProof.Metadata?.CreatedAt is not null)
|
||||
{
|
||||
annotations[OciAnnotations.Created] = request.FuncProof.Metadata.CreatedAt;
|
||||
}
|
||||
|
||||
// Merge user-provided annotations
|
||||
if (request.Annotations is not null)
|
||||
{
|
||||
foreach (var (key, value) in request.Annotations)
|
||||
{
|
||||
annotations[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return annotations;
|
||||
}
|
||||
|
||||
private static byte[] CompressGzip(byte[] data)
|
||||
{
|
||||
using var output = new System.IO.MemoryStream();
|
||||
using (var gzip = new System.IO.Compression.GZipStream(output, System.IO.Compression.CompressionLevel.Optimal))
|
||||
{
|
||||
gzip.Write(data, 0, data.Length);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCI media types for FuncProof artifacts.
|
||||
/// </summary>
|
||||
public static class FuncProofOciMediaTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// Artifact type for FuncProof OCI artifacts.
|
||||
/// </summary>
|
||||
public const string ArtifactType = "application/vnd.stellaops.funcproof";
|
||||
|
||||
/// <summary>
|
||||
/// Media type for the FuncProof JSON layer.
|
||||
/// </summary>
|
||||
public const string ProofLayer = "application/vnd.stellaops.funcproof+json";
|
||||
|
||||
/// <summary>
|
||||
/// Media type for the DSSE envelope layer containing signed FuncProof.
|
||||
/// </summary>
|
||||
public const string DsseLayer = "application/vnd.stellaops.funcproof.dsse+json";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom OCI annotations for FuncProof artifacts.
|
||||
/// </summary>
|
||||
public static class FuncProofOciAnnotations
|
||||
{
|
||||
public const string ProofId = "io.stellaops.funcproof.id";
|
||||
public const string BuildId = "io.stellaops.funcproof.build-id";
|
||||
public const string FileSha256 = "io.stellaops.funcproof.file-sha256";
|
||||
public const string FunctionCount = "io.stellaops.funcproof.function-count";
|
||||
public const string SchemaVersion = "io.stellaops.funcproof.schema-version";
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// FuncProofDocumentRow.cs
|
||||
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
|
||||
// Task: FUNC-02 — Create FuncProofDocument PostgreSQL entity
|
||||
// Description: Entity mapping for scanner.func_proof table.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Recorded FuncProof evidence per scan.
|
||||
/// Maps to scanner.func_proof table with indexes on build_id.
|
||||
/// </summary>
|
||||
public sealed class FuncProofDocumentRow
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key (UUID).
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the parent scan.
|
||||
/// </summary>
|
||||
public Guid ScanId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Content-addressable proof ID: blake3:{hash}.
|
||||
/// </summary>
|
||||
public string ProofId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// GNU Build-ID, PE CodeView GUID, or Mach-O UUID.
|
||||
/// Indexed for fast lookup.
|
||||
/// </summary>
|
||||
public string BuildId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Type of build ID: "gnu-build-id", "pe-codeview", "macho-uuid", "file-sha256".
|
||||
/// </summary>
|
||||
public string BuildIdType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 of the entire binary file.
|
||||
/// </summary>
|
||||
public string FileSha256 { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Binary format: "elf", "pe", "macho".
|
||||
/// </summary>
|
||||
public string BinaryFormat { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Target architecture: "x86_64", "aarch64", etc.
|
||||
/// </summary>
|
||||
public string Architecture { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the binary is stripped.
|
||||
/// </summary>
|
||||
public bool IsStripped { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of functions in the proof.
|
||||
/// </summary>
|
||||
public int FunctionCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of traces in the proof.
|
||||
/// </summary>
|
||||
public int TraceCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Full FuncProof JSON document (JSONB).
|
||||
/// </summary>
|
||||
public string ProofContent { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Compressed proof content (gzip) for large documents.
|
||||
/// </summary>
|
||||
public byte[]? CompressedContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope ID if signed.
|
||||
/// </summary>
|
||||
public string? DsseEnvelopeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OCI artifact digest if published.
|
||||
/// </summary>
|
||||
public string? OciArtifactDigest { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor transparency log entry ID.
|
||||
/// </summary>
|
||||
public string? RekorEntryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Generator version that created this proof.
|
||||
/// </summary>
|
||||
public string GeneratorVersion { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// When the proof was generated (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset GeneratedAtUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this row was created (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAtUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this row was last updated (UTC).
|
||||
/// </summary>
|
||||
public DateTimeOffset? UpdatedAtUtc { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 019_func_proof_tables.sql
|
||||
-- Sprint: SPRINT_20251226_009_SCANNER_funcproof
|
||||
-- Task: FUNC-02 — Create func_proof PostgreSQL table with indexes
|
||||
-- Description: Schema for function-level proof documents.
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
-- Create func_proof table
|
||||
CREATE TABLE IF NOT EXISTS scanner.func_proof (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scan_id UUID NOT NULL,
|
||||
proof_id TEXT NOT NULL,
|
||||
build_id TEXT NOT NULL,
|
||||
build_id_type TEXT NOT NULL,
|
||||
file_sha256 TEXT NOT NULL,
|
||||
binary_format TEXT NOT NULL,
|
||||
architecture TEXT NOT NULL,
|
||||
is_stripped BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
function_count INTEGER NOT NULL DEFAULT 0,
|
||||
trace_count INTEGER NOT NULL DEFAULT 0,
|
||||
proof_content JSONB NOT NULL,
|
||||
compressed_content BYTEA,
|
||||
dsse_envelope_id TEXT,
|
||||
oci_artifact_digest TEXT,
|
||||
rekor_entry_id TEXT,
|
||||
generator_version TEXT NOT NULL,
|
||||
generated_at_utc TIMESTAMPTZ NOT NULL,
|
||||
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at_utc TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Index on build_id for fast lookup by binary identity
|
||||
CREATE INDEX IF NOT EXISTS idx_func_proof_build_id
|
||||
ON scanner.func_proof(build_id);
|
||||
|
||||
-- Index on file_sha256 for lookup by file hash
|
||||
CREATE INDEX IF NOT EXISTS idx_func_proof_file_sha256
|
||||
ON scanner.func_proof(file_sha256);
|
||||
|
||||
-- Index on scan_id for retrieving all proofs for a scan
|
||||
CREATE INDEX IF NOT EXISTS idx_func_proof_scan_id
|
||||
ON scanner.func_proof(scan_id);
|
||||
|
||||
-- Index on proof_id for content-addressable lookup
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_func_proof_proof_id
|
||||
ON scanner.func_proof(proof_id);
|
||||
|
||||
-- Composite index for build_id + architecture
|
||||
CREATE INDEX IF NOT EXISTS idx_func_proof_build_arch
|
||||
ON scanner.func_proof(build_id, architecture);
|
||||
|
||||
-- GIN index on proof_content for JSONB queries (e.g., finding functions by symbol)
|
||||
CREATE INDEX IF NOT EXISTS idx_func_proof_content_gin
|
||||
ON scanner.func_proof USING GIN (proof_content jsonb_path_ops);
|
||||
|
||||
-- Create func_node table for denormalized function lookup
|
||||
CREATE TABLE IF NOT EXISTS scanner.func_node (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
func_proof_id UUID NOT NULL REFERENCES scanner.func_proof(id) ON DELETE CASCADE,
|
||||
symbol TEXT NOT NULL,
|
||||
symbol_digest TEXT NOT NULL,
|
||||
start_address BIGINT NOT NULL,
|
||||
end_address BIGINT NOT NULL,
|
||||
function_hash TEXT NOT NULL,
|
||||
confidence DOUBLE PRECISION NOT NULL DEFAULT 1.0,
|
||||
is_entrypoint BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
entrypoint_type TEXT,
|
||||
is_sink BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sink_vuln_id TEXT,
|
||||
source_file TEXT,
|
||||
source_line INTEGER,
|
||||
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index on symbol_digest for fast cross-binary correlation
|
||||
CREATE INDEX IF NOT EXISTS idx_func_node_symbol_digest
|
||||
ON scanner.func_node(symbol_digest);
|
||||
|
||||
-- Index on func_proof_id for retrieving all nodes for a proof
|
||||
CREATE INDEX IF NOT EXISTS idx_func_node_proof_id
|
||||
ON scanner.func_node(func_proof_id);
|
||||
|
||||
-- Index on symbol for text search
|
||||
CREATE INDEX IF NOT EXISTS idx_func_node_symbol
|
||||
ON scanner.func_node(symbol);
|
||||
|
||||
-- Composite index for vulnerable sinks
|
||||
CREATE INDEX IF NOT EXISTS idx_func_node_sinks
|
||||
ON scanner.func_node(is_sink, sink_vuln_id) WHERE is_sink = TRUE;
|
||||
|
||||
-- Composite index for entrypoints
|
||||
CREATE INDEX IF NOT EXISTS idx_func_node_entrypoints
|
||||
ON scanner.func_node(is_entrypoint, entrypoint_type) WHERE is_entrypoint = TRUE;
|
||||
|
||||
-- Create func_trace table for denormalized trace lookup
|
||||
CREATE TABLE IF NOT EXISTS scanner.func_trace (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
func_proof_id UUID NOT NULL REFERENCES scanner.func_proof(id) ON DELETE CASCADE,
|
||||
trace_id TEXT NOT NULL,
|
||||
edge_list_hash TEXT NOT NULL,
|
||||
hop_count INTEGER NOT NULL,
|
||||
entry_symbol_digest TEXT NOT NULL,
|
||||
sink_symbol_digest TEXT NOT NULL,
|
||||
path TEXT[] NOT NULL,
|
||||
truncated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index on func_proof_id for retrieving all traces for a proof
|
||||
CREATE INDEX IF NOT EXISTS idx_func_trace_proof_id
|
||||
ON scanner.func_trace(func_proof_id);
|
||||
|
||||
-- Index on entry_symbol_digest for finding traces from a specific entrypoint
|
||||
CREATE INDEX IF NOT EXISTS idx_func_trace_entry_digest
|
||||
ON scanner.func_trace(entry_symbol_digest);
|
||||
|
||||
-- Index on sink_symbol_digest for finding traces to a specific sink
|
||||
CREATE INDEX IF NOT EXISTS idx_func_trace_sink_digest
|
||||
ON scanner.func_trace(sink_symbol_digest);
|
||||
|
||||
-- Index on edge_list_hash for deduplication
|
||||
CREATE INDEX IF NOT EXISTS idx_func_trace_edge_hash
|
||||
ON scanner.func_trace(edge_list_hash);
|
||||
|
||||
-- Add comments for documentation
|
||||
COMMENT ON TABLE scanner.func_proof IS 'Function-level proof documents for binary reachability evidence';
|
||||
COMMENT ON COLUMN scanner.func_proof.proof_id IS 'Content-addressable ID: blake3:{hash} of canonical JSON';
|
||||
COMMENT ON COLUMN scanner.func_proof.build_id IS 'GNU Build-ID (ELF), PE CodeView GUID, or Mach-O UUID';
|
||||
COMMENT ON COLUMN scanner.func_proof.proof_content IS 'Full FuncProof JSON document';
|
||||
COMMENT ON COLUMN scanner.func_proof.compressed_content IS 'Optional gzip-compressed content for large documents';
|
||||
|
||||
COMMENT ON TABLE scanner.func_node IS 'Denormalized function entries for fast symbol lookup';
|
||||
COMMENT ON COLUMN scanner.func_node.symbol_digest IS 'BLAKE3(symbol_name + offset_range) for cross-binary correlation';
|
||||
|
||||
COMMENT ON TABLE scanner.func_trace IS 'Denormalized entry→sink traces for reachability queries';
|
||||
COMMENT ON COLUMN scanner.func_trace.edge_list_hash IS 'BLAKE3 hash of sorted edge pairs for deduplication';
|
||||
@@ -0,0 +1,286 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgresFuncProofRepository.cs
|
||||
// Sprint: SPRINT_20251226_009_SCANNER_funcproof
|
||||
// Task: FUNC-02 — PostgreSQL repository for FuncProof documents
|
||||
// Description: Repository for storing and retrieving FuncProof evidence.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Scanner.Storage.Entities;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for FuncProof documents.
|
||||
/// </summary>
|
||||
public interface IFuncProofRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Stores a FuncProof document.
|
||||
/// </summary>
|
||||
Task<Guid> StoreAsync(FuncProofDocumentRow document, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a FuncProof document by ID.
|
||||
/// </summary>
|
||||
Task<FuncProofDocumentRow?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a FuncProof document by proof ID (content-addressable).
|
||||
/// </summary>
|
||||
Task<FuncProofDocumentRow?> GetByProofIdAsync(string proofId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all FuncProof documents for a build ID.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FuncProofDocumentRow>> GetByBuildIdAsync(string buildId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all FuncProof documents for a scan.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<FuncProofDocumentRow>> GetByScanIdAsync(Guid scanId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a FuncProof document exists by proof ID.
|
||||
/// </summary>
|
||||
Task<bool> ExistsAsync(string proofId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates DSSE envelope and OCI artifact information.
|
||||
/// </summary>
|
||||
Task UpdateSignatureInfoAsync(
|
||||
Guid id,
|
||||
string dsseEnvelopeId,
|
||||
string? ociArtifactDigest,
|
||||
string? rekorEntryId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of FuncProof repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresFuncProofRepository : IFuncProofRepository
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
|
||||
public PostgresFuncProofRepository(NpgsqlDataSource dataSource)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
}
|
||||
|
||||
public async Task<Guid> StoreAsync(FuncProofDocumentRow document, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO scanner.func_proof (
|
||||
id, scan_id, proof_id, build_id, build_id_type,
|
||||
file_sha256, binary_format, architecture, is_stripped,
|
||||
function_count, trace_count, proof_content, compressed_content,
|
||||
dsse_envelope_id, oci_artifact_digest, rekor_entry_id,
|
||||
generator_version, generated_at_utc, created_at_utc
|
||||
) VALUES (
|
||||
@id, @scan_id, @proof_id, @build_id, @build_id_type,
|
||||
@file_sha256, @binary_format, @architecture, @is_stripped,
|
||||
@function_count, @trace_count, @proof_content::jsonb, @compressed_content,
|
||||
@dsse_envelope_id, @oci_artifact_digest, @rekor_entry_id,
|
||||
@generator_version, @generated_at_utc, @created_at_utc
|
||||
)
|
||||
ON CONFLICT (proof_id) DO UPDATE SET
|
||||
updated_at_utc = NOW()
|
||||
RETURNING id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
var id = document.Id == Guid.Empty ? Guid.NewGuid() : document.Id;
|
||||
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
cmd.Parameters.AddWithValue("scan_id", document.ScanId);
|
||||
cmd.Parameters.AddWithValue("proof_id", document.ProofId);
|
||||
cmd.Parameters.AddWithValue("build_id", document.BuildId);
|
||||
cmd.Parameters.AddWithValue("build_id_type", document.BuildIdType);
|
||||
cmd.Parameters.AddWithValue("file_sha256", document.FileSha256);
|
||||
cmd.Parameters.AddWithValue("binary_format", document.BinaryFormat);
|
||||
cmd.Parameters.AddWithValue("architecture", document.Architecture);
|
||||
cmd.Parameters.AddWithValue("is_stripped", document.IsStripped);
|
||||
cmd.Parameters.AddWithValue("function_count", document.FunctionCount);
|
||||
cmd.Parameters.AddWithValue("trace_count", document.TraceCount);
|
||||
cmd.Parameters.AddWithValue("proof_content", document.ProofContent);
|
||||
cmd.Parameters.AddWithValue("compressed_content",
|
||||
document.CompressedContent is null ? DBNull.Value : document.CompressedContent);
|
||||
cmd.Parameters.AddWithValue("dsse_envelope_id",
|
||||
document.DsseEnvelopeId is null ? DBNull.Value : document.DsseEnvelopeId);
|
||||
cmd.Parameters.AddWithValue("oci_artifact_digest",
|
||||
document.OciArtifactDigest is null ? DBNull.Value : document.OciArtifactDigest);
|
||||
cmd.Parameters.AddWithValue("rekor_entry_id",
|
||||
document.RekorEntryId is null ? DBNull.Value : document.RekorEntryId);
|
||||
cmd.Parameters.AddWithValue("generator_version", document.GeneratorVersion);
|
||||
cmd.Parameters.AddWithValue("generated_at_utc", document.GeneratedAtUtc);
|
||||
cmd.Parameters.AddWithValue("created_at_utc", DateTimeOffset.UtcNow);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(ct);
|
||||
return result is Guid returnedId ? returnedId : id;
|
||||
}
|
||||
|
||||
public async Task<FuncProofDocumentRow?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, scan_id, proof_id, build_id, build_id_type,
|
||||
file_sha256, binary_format, architecture, is_stripped,
|
||||
function_count, trace_count, proof_content, compressed_content,
|
||||
dsse_envelope_id, oci_artifact_digest, rekor_entry_id,
|
||||
generator_version, generated_at_utc, created_at_utc, updated_at_utc
|
||||
FROM scanner.func_proof
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
return await reader.ReadAsync(ct) ? MapRow(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<FuncProofDocumentRow?> GetByProofIdAsync(string proofId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, scan_id, proof_id, build_id, build_id_type,
|
||||
file_sha256, binary_format, architecture, is_stripped,
|
||||
function_count, trace_count, proof_content, compressed_content,
|
||||
dsse_envelope_id, oci_artifact_digest, rekor_entry_id,
|
||||
generator_version, generated_at_utc, created_at_utc, updated_at_utc
|
||||
FROM scanner.func_proof
|
||||
WHERE proof_id = @proof_id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("proof_id", proofId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
return await reader.ReadAsync(ct) ? MapRow(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<FuncProofDocumentRow>> GetByBuildIdAsync(string buildId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, scan_id, proof_id, build_id, build_id_type,
|
||||
file_sha256, binary_format, architecture, is_stripped,
|
||||
function_count, trace_count, proof_content, compressed_content,
|
||||
dsse_envelope_id, oci_artifact_digest, rekor_entry_id,
|
||||
generator_version, generated_at_utc, created_at_utc, updated_at_utc
|
||||
FROM scanner.func_proof
|
||||
WHERE build_id = @build_id
|
||||
ORDER BY generated_at_utc DESC
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("build_id", buildId);
|
||||
|
||||
var results = new List<FuncProofDocumentRow>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapRow(reader));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<FuncProofDocumentRow>> GetByScanIdAsync(Guid scanId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT id, scan_id, proof_id, build_id, build_id_type,
|
||||
file_sha256, binary_format, architecture, is_stripped,
|
||||
function_count, trace_count, proof_content, compressed_content,
|
||||
dsse_envelope_id, oci_artifact_digest, rekor_entry_id,
|
||||
generator_version, generated_at_utc, created_at_utc, updated_at_utc
|
||||
FROM scanner.func_proof
|
||||
WHERE scan_id = @scan_id
|
||||
ORDER BY build_id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("scan_id", scanId);
|
||||
|
||||
var results = new List<FuncProofDocumentRow>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapRow(reader));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(string proofId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = "SELECT EXISTS(SELECT 1 FROM scanner.func_proof WHERE proof_id = @proof_id)";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("proof_id", proofId);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(ct);
|
||||
return result is true;
|
||||
}
|
||||
|
||||
public async Task UpdateSignatureInfoAsync(
|
||||
Guid id,
|
||||
string dsseEnvelopeId,
|
||||
string? ociArtifactDigest,
|
||||
string? rekorEntryId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE scanner.func_proof
|
||||
SET dsse_envelope_id = @dsse_envelope_id,
|
||||
oci_artifact_digest = @oci_artifact_digest,
|
||||
rekor_entry_id = @rekor_entry_id,
|
||||
updated_at_utc = NOW()
|
||||
WHERE id = @id
|
||||
""";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
cmd.Parameters.AddWithValue("dsse_envelope_id", dsseEnvelopeId);
|
||||
cmd.Parameters.AddWithValue("oci_artifact_digest",
|
||||
ociArtifactDigest is null ? DBNull.Value : ociArtifactDigest);
|
||||
cmd.Parameters.AddWithValue("rekor_entry_id",
|
||||
rekorEntryId is null ? DBNull.Value : rekorEntryId);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
private static FuncProofDocumentRow MapRow(NpgsqlDataReader reader)
|
||||
{
|
||||
return new FuncProofDocumentRow
|
||||
{
|
||||
Id = reader.GetGuid(0),
|
||||
ScanId = reader.GetGuid(1),
|
||||
ProofId = reader.GetString(2),
|
||||
BuildId = reader.GetString(3),
|
||||
BuildIdType = reader.GetString(4),
|
||||
FileSha256 = reader.GetString(5),
|
||||
BinaryFormat = reader.GetString(6),
|
||||
Architecture = reader.GetString(7),
|
||||
IsStripped = reader.GetBoolean(8),
|
||||
FunctionCount = reader.GetInt32(9),
|
||||
TraceCount = reader.GetInt32(10),
|
||||
ProofContent = reader.GetString(11),
|
||||
CompressedContent = reader.IsDBNull(12) ? null : (byte[])reader.GetValue(12),
|
||||
DsseEnvelopeId = reader.IsDBNull(13) ? null : reader.GetString(13),
|
||||
OciArtifactDigest = reader.IsDBNull(14) ? null : reader.GetString(14),
|
||||
RekorEntryId = reader.IsDBNull(15) ? null : reader.GetString(15),
|
||||
GeneratorVersion = reader.GetString(16),
|
||||
GeneratedAtUtc = reader.GetDateTime(17),
|
||||
CreatedAtUtc = reader.GetDateTime(18),
|
||||
UpdatedAtUtc = reader.IsDBNull(19) ? null : reader.GetDateTime(19)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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") });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user