release orchestrator v1 draft and build fixes

This commit is contained in:
master
2026-01-12 12:24:17 +02:00
parent f3de858c59
commit 9873f80830
1598 changed files with 240385 additions and 5944 deletions

View File

@@ -0,0 +1,170 @@
using System.Collections.Immutable;
using System.Reflection;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.ChangeTrace.Models;
using StellaOps.Scanner.ChangeTrace.Serialization;
namespace StellaOps.Scanner.ChangeTrace.Builder;
/// <summary>
/// Builder for constructing change traces from scan or binary comparisons.
/// </summary>
public sealed class ChangeTraceBuilder : IChangeTraceBuilder
{
private readonly ILogger<ChangeTraceBuilder> _logger;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Current engine version.
/// </summary>
public static string EngineVersion { get; } = Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? "1.0.0";
/// <summary>
/// Initializes a new instance of the ChangeTraceBuilder.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="timeProvider">Time provider for deterministic timestamps.</param>
public ChangeTraceBuilder(ILogger<ChangeTraceBuilder> logger, TimeProvider timeProvider)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <inheritdoc />
public Task<Models.ChangeTrace> FromScanComparisonAsync(
string fromScanId,
string toScanId,
ChangeTraceBuilderOptions? options = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(fromScanId);
ArgumentException.ThrowIfNullOrWhiteSpace(toScanId);
options ??= ChangeTraceBuilderOptions.Default;
_logger.LogInformation("Building change trace from scan comparison: {FromScanId} -> {ToScanId}",
fromScanId, toScanId);
// TODO: Integrate with actual scan repository to fetch scan data
// For now, create a placeholder trace structure
var trace = BuildPlaceholderTrace(fromScanId, toScanId, options);
var finalTrace = FinalizeTrace(trace);
return Task.FromResult(finalTrace);
}
/// <inheritdoc />
public Task<Models.ChangeTrace> FromBinaryComparisonAsync(
string fromBinaryPath,
string toBinaryPath,
ChangeTraceBuilderOptions? options = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(fromBinaryPath);
ArgumentException.ThrowIfNullOrWhiteSpace(toBinaryPath);
if (!File.Exists(fromBinaryPath))
throw new FileNotFoundException("From binary not found", fromBinaryPath);
if (!File.Exists(toBinaryPath))
throw new FileNotFoundException("To binary not found", toBinaryPath);
options ??= ChangeTraceBuilderOptions.Default;
_logger.LogInformation("Building change trace from binary comparison: {FromPath} -> {ToPath}",
fromBinaryPath, toBinaryPath);
// Generate scan IDs from file paths
var fromScanId = $"binary:{Path.GetFileName(fromBinaryPath)}";
var toScanId = $"binary:{Path.GetFileName(toBinaryPath)}";
// TODO: Integrate with BinaryIndex for symbol extraction
// For now, create a placeholder trace structure
var trace = BuildPlaceholderTrace(fromScanId, toScanId, options);
var finalTrace = FinalizeTrace(trace);
return Task.FromResult(finalTrace);
}
private Models.ChangeTrace BuildPlaceholderTrace(
string fromScanId,
string toScanId,
ChangeTraceBuilderOptions options)
{
var now = _timeProvider.GetUtcNow();
var combinedScanId = $"{fromScanId}..{toScanId}";
return new Models.ChangeTrace
{
Subject = new ChangeTraceSubject
{
Type = "scan.comparison",
Digest = $"sha256:{Guid.Empty:N}",
Name = combinedScanId
},
Basis = new ChangeTraceBasis
{
ScanId = combinedScanId,
FromScanId = fromScanId,
ToScanId = toScanId,
Policies = options.Policies,
DiffMethod = options.GetDiffMethods(),
EngineVersion = EngineVersion,
AnalyzedAt = now
},
Deltas = [],
Summary = new ChangeTraceSummary
{
ChangedPackages = 0,
ChangedSymbols = 0,
ChangedBytes = 0,
RiskDelta = 0.0,
Verdict = ChangeTraceVerdict.Neutral
}
};
}
private Models.ChangeTrace FinalizeTrace(Models.ChangeTrace trace)
{
// Compute commitment hash
var hash = ChangeTraceSerializer.ComputeCommitmentHash(trace);
return trace with
{
Commitment = new ChangeTraceCommitment
{
Sha256 = hash,
Algorithm = "RFC8785+SHA256"
}
};
}
/// <summary>
/// Computes the verdict based on risk delta score.
/// </summary>
/// <param name="riskDelta">Risk delta score.</param>
/// <returns>Verdict classification.</returns>
public static ChangeTraceVerdict ComputeVerdict(double riskDelta)
{
return riskDelta switch
{
< -0.3 => ChangeTraceVerdict.RiskDown,
> 0.3 => ChangeTraceVerdict.RiskUp,
_ => ChangeTraceVerdict.Neutral
};
}
/// <summary>
/// Computes trust delta score from before/after scores.
/// Formula: (AfterTrust - BeforeTrust) / max(BeforeTrust, 0.01)
/// </summary>
/// <param name="beforeTrust">Trust score before change.</param>
/// <param name="afterTrust">Trust score after change.</param>
/// <returns>Trust delta score.</returns>
public static double ComputeTrustDelta(double beforeTrust, double afterTrust)
{
var denominator = Math.Max(beforeTrust, 0.01);
return (afterTrust - beforeTrust) / denominator;
}
}

View File

@@ -0,0 +1,65 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.ChangeTrace.Builder;
/// <summary>
/// Options for change trace building.
/// </summary>
public sealed record ChangeTraceBuilderOptions
{
/// <summary>
/// Include package-level diffing. Default: true.
/// </summary>
public bool IncludePackageDiff { get; init; } = true;
/// <summary>
/// Include symbol-level diffing. Default: true.
/// </summary>
public bool IncludeSymbolDiff { get; init; } = true;
/// <summary>
/// Include byte-level diffing. Default: false.
/// </summary>
public bool IncludeByteDiff { get; init; } = false;
/// <summary>
/// Minimum confidence threshold for symbol matches.
/// Default: 0.75.
/// </summary>
public double MinSymbolConfidence { get; init; } = 0.75;
/// <summary>
/// Rolling hash window size for byte diffing.
/// Default: 2048 bytes.
/// </summary>
public int ByteDiffWindowSize { get; init; } = 2048;
/// <summary>
/// Maximum binary size for byte-level analysis (bytes).
/// Default: 10MB.
/// </summary>
public long MaxBinarySize { get; init; } = 10 * 1024 * 1024;
/// <summary>
/// Lattice policies to apply during trust delta computation.
/// Default: ["lattice:default@v3"].
/// </summary>
public ImmutableArray<string> Policies { get; init; } = ["lattice:default@v3"];
/// <summary>
/// Gets the diff methods enabled based on options.
/// </summary>
public ImmutableArray<string> GetDiffMethods()
{
var methods = ImmutableArray.CreateBuilder<string>();
if (IncludePackageDiff) methods.Add("pkg");
if (IncludeSymbolDiff) methods.Add("symbol");
if (IncludeByteDiff) methods.Add("byte");
return methods.ToImmutable();
}
/// <summary>
/// Default options instance.
/// </summary>
public static ChangeTraceBuilderOptions Default { get; } = new();
}

View File

@@ -0,0 +1,35 @@
namespace StellaOps.Scanner.ChangeTrace.Builder;
/// <summary>
/// Builder interface for constructing change traces.
/// </summary>
public interface IChangeTraceBuilder
{
/// <summary>
/// Build change trace from two scan comparisons.
/// </summary>
/// <param name="fromScanId">Scan ID of the "before" state.</param>
/// <param name="toScanId">Scan ID of the "after" state.</param>
/// <param name="options">Builder options for configuring the trace.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Constructed change trace.</returns>
Task<Models.ChangeTrace> FromScanComparisonAsync(
string fromScanId,
string toScanId,
ChangeTraceBuilderOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Build change trace from two binary files.
/// </summary>
/// <param name="fromBinaryPath">Path to the "before" binary.</param>
/// <param name="toBinaryPath">Path to the "after" binary.</param>
/// <param name="options">Builder options for configuring the trace.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Constructed change trace.</returns>
Task<Models.ChangeTrace> FromBinaryComparisonAsync(
string fromBinaryPath,
string toBinaryPath,
ChangeTraceBuilderOptions? options = null,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,57 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
using System.Collections.Immutable;
namespace StellaOps.Scanner.ChangeTrace.ByteDiff;
/// <summary>
/// Options for byte-level diffing.
/// </summary>
public sealed record ByteDiffOptions
{
/// <summary>
/// Rolling hash window size in bytes. Default: 2048.
/// </summary>
public int WindowSize { get; init; } = 2048;
/// <summary>
/// Step size for window advancement. Default: WindowSize (non-overlapping).
/// </summary>
public int StepSize { get; init; } = 2048;
/// <summary>
/// Maximum file size to analyze in bytes. Default: 10MB.
/// </summary>
public long MaxFileSize { get; init; } = 10 * 1024 * 1024;
/// <summary>
/// Whether to analyze by ELF/PE section. Default: true.
/// </summary>
public bool AnalyzeBySections { get; init; } = true;
/// <summary>
/// Sections to include (e.g., ".text", ".data"). Null = all sections.
/// </summary>
public ImmutableArray<string>? IncludeSections { get; init; }
/// <summary>
/// Whether to include context description in output. Default: false.
/// </summary>
public bool IncludeContext { get; init; } = false;
/// <summary>
/// Enable parallel processing for large files. Default: true.
/// </summary>
public bool EnableParallel { get; init; } = true;
/// <summary>
/// Minimum number of consecutive changed windows to report. Default: 1.
/// </summary>
public int MinConsecutiveChanges { get; init; } = 1;
/// <summary>
/// Create default options.
/// </summary>
public static ByteDiffOptions Default { get; } = new();
}

View File

@@ -0,0 +1,381 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
using System.Security.Cryptography;
using StellaOps.Scanner.ChangeTrace.Models;
namespace StellaOps.Scanner.ChangeTrace.ByteDiff;
/// <summary>
/// Byte-level binary comparison using rolling hash windows.
/// </summary>
public sealed class ByteLevelDiffer : IByteLevelDiffer
{
private readonly ISectionAnalyzer _sectionAnalyzer;
/// <summary>
/// Creates a new ByteLevelDiffer with the specified section analyzer.
/// </summary>
public ByteLevelDiffer(ISectionAnalyzer sectionAnalyzer)
{
_sectionAnalyzer = sectionAnalyzer ?? throw new ArgumentNullException(nameof(sectionAnalyzer));
}
/// <summary>
/// Creates a new ByteLevelDiffer with the default section analyzer.
/// </summary>
public ByteLevelDiffer() : this(new SectionAnalyzer())
{
}
/// <inheritdoc/>
public async Task<IReadOnlyList<ByteDelta>> CompareAsync(
Stream fromStream,
Stream toStream,
ByteDiffOptions? options = null,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(fromStream);
ArgumentNullException.ThrowIfNull(toStream);
options ??= ByteDiffOptions.Default;
// Check file sizes
if (fromStream.Length > options.MaxFileSize || toStream.Length > options.MaxFileSize)
{
// Fall back to sampling for large files
return await CompareLargeFilesAsync(fromStream, toStream, options, ct);
}
// Read both streams
var fromBytes = await ReadStreamAsync(fromStream, ct);
var toBytes = await ReadStreamAsync(toStream, ct);
// Compare by sections if enabled and formats support it
if (options.AnalyzeBySections)
{
var fromSections = await _sectionAnalyzer.AnalyzeAsync(fromBytes, ct);
var toSections = await _sectionAnalyzer.AnalyzeAsync(toBytes, ct);
if (fromSections.Count > 0 && toSections.Count > 0)
{
return CompareBySections(fromBytes, toBytes, fromSections, toSections, options);
}
}
// Fall back to full binary comparison
return CompareFullBinary(fromBytes, toBytes, options);
}
/// <inheritdoc/>
public async Task<IReadOnlyList<ByteDelta>> CompareFilesAsync(
string fromPath,
string toPath,
ByteDiffOptions? options = null,
CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrEmpty(fromPath);
ArgumentException.ThrowIfNullOrEmpty(toPath);
await using var fromStream = File.OpenRead(fromPath);
await using var toStream = File.OpenRead(toPath);
return await CompareAsync(fromStream, toStream, options, ct);
}
private IReadOnlyList<ByteDelta> CompareFullBinary(
byte[] fromBytes,
byte[] toBytes,
ByteDiffOptions options)
{
var deltas = new List<ByteDelta>();
var windowSize = options.WindowSize;
var stepSize = options.StepSize;
// Track consecutive changes for merging
var consecutiveChanges = new List<(long offset, int size, string fromHash, string toHash)>();
// Compare windows in "from" file
for (long offset = 0; offset + windowSize <= fromBytes.Length; offset += stepSize)
{
var fromWindow = fromBytes.AsSpan((int)offset, windowSize);
var fromHash = ComputeWindowHash(fromWindow);
// Check if same window exists in "to" file at same position
string toHash;
if (offset + windowSize <= toBytes.Length)
{
var toWindow = toBytes.AsSpan((int)offset, windowSize);
toHash = ComputeWindowHash(toWindow);
}
else
{
toHash = "truncated";
}
if (!string.Equals(fromHash, toHash, StringComparison.OrdinalIgnoreCase))
{
consecutiveChanges.Add((offset, windowSize, fromHash, toHash));
}
else if (consecutiveChanges.Count >= options.MinConsecutiveChanges)
{
// Flush consecutive changes as a single delta
deltas.Add(CreateMergedDelta(consecutiveChanges, null, options.IncludeContext));
consecutiveChanges.Clear();
}
else
{
consecutiveChanges.Clear();
}
}
// Flush remaining changes
if (consecutiveChanges.Count >= options.MinConsecutiveChanges)
{
deltas.Add(CreateMergedDelta(consecutiveChanges, null, options.IncludeContext));
}
// Handle size difference (bytes added or removed)
if (toBytes.Length > fromBytes.Length && fromBytes.Length > 0)
{
var addedOffset = (long)(fromBytes.Length / windowSize) * windowSize;
var addedSize = (int)(toBytes.Length - addedOffset);
if (addedSize > 0 && addedOffset + addedSize <= toBytes.Length)
{
var addedWindow = toBytes.AsSpan((int)addedOffset, Math.Min(addedSize, windowSize));
deltas.Add(new ByteDelta
{
Offset = addedOffset,
Size = addedSize,
FromHash = "absent",
ToHash = ComputeWindowHash(addedWindow),
Context = options.IncludeContext ? "Bytes added at end" : null
});
}
}
return deltas;
}
private IReadOnlyList<ByteDelta> CompareBySections(
byte[] fromBytes,
byte[] toBytes,
IReadOnlyList<SectionInfo> fromSections,
IReadOnlyList<SectionInfo> toSections,
ByteDiffOptions options)
{
var deltas = new List<ByteDelta>();
// Match sections by name
var toSectionDict = toSections.ToDictionary(s => s.Name, StringComparer.Ordinal);
foreach (var fromSection in fromSections)
{
// Filter by included sections
if (options.IncludeSections.HasValue &&
!options.IncludeSections.Value.Contains(fromSection.Name, StringComparer.Ordinal))
{
continue;
}
if (!toSectionDict.TryGetValue(fromSection.Name, out var toSection))
{
// Section removed
deltas.Add(new ByteDelta
{
Offset = fromSection.Offset,
Size = (int)Math.Min(fromSection.Size, int.MaxValue),
FromHash = ComputeSectionHash(fromBytes, fromSection),
ToHash = "removed",
Section = fromSection.Name,
Context = options.IncludeContext ? "Section removed" : null
});
continue;
}
// Compare section contents
var sectionDeltas = CompareSectionWindows(
fromBytes, toBytes, fromSection, toSection, options);
deltas.AddRange(sectionDeltas);
}
// Check for added sections
foreach (var toSection in toSections)
{
if (options.IncludeSections.HasValue &&
!options.IncludeSections.Value.Contains(toSection.Name, StringComparer.Ordinal))
{
continue;
}
if (!fromSections.Any(s => s.Name == toSection.Name))
{
// Section added
deltas.Add(new ByteDelta
{
Offset = toSection.Offset,
Size = (int)Math.Min(toSection.Size, int.MaxValue),
FromHash = "absent",
ToHash = ComputeSectionHash(toBytes, toSection),
Section = toSection.Name,
Context = options.IncludeContext ? "Section added" : null
});
}
}
return deltas;
}
private IReadOnlyList<ByteDelta> CompareSectionWindows(
byte[] fromBytes,
byte[] toBytes,
SectionInfo fromSection,
SectionInfo toSection,
ByteDiffOptions options)
{
var deltas = new List<ByteDelta>();
var windowSize = options.WindowSize;
var stepSize = options.StepSize;
var fromEnd = Math.Min(fromSection.Offset + fromSection.Size, fromBytes.Length);
var toEnd = Math.Min(toSection.Offset + toSection.Size, toBytes.Length);
var consecutiveChanges = new List<(long offset, int size, string fromHash, string toHash)>();
for (var fromOffset = fromSection.Offset;
fromOffset + windowSize <= fromEnd;
fromOffset += stepSize)
{
var toOffset = toSection.Offset + (fromOffset - fromSection.Offset);
var fromWindow = fromBytes.AsSpan((int)fromOffset, windowSize);
var fromHash = ComputeWindowHash(fromWindow);
string toHash;
if (toOffset + windowSize <= toEnd)
{
var toWindow = toBytes.AsSpan((int)toOffset, windowSize);
toHash = ComputeWindowHash(toWindow);
}
else
{
toHash = "truncated";
}
if (!string.Equals(fromHash, toHash, StringComparison.OrdinalIgnoreCase))
{
consecutiveChanges.Add((fromOffset, windowSize, fromHash, toHash));
}
else if (consecutiveChanges.Count >= options.MinConsecutiveChanges)
{
deltas.Add(CreateMergedDelta(consecutiveChanges, fromSection.Name, options.IncludeContext));
consecutiveChanges.Clear();
}
else
{
consecutiveChanges.Clear();
}
}
// Flush remaining changes
if (consecutiveChanges.Count >= options.MinConsecutiveChanges)
{
deltas.Add(CreateMergedDelta(consecutiveChanges, fromSection.Name, options.IncludeContext));
}
return deltas;
}
private async Task<IReadOnlyList<ByteDelta>> CompareLargeFilesAsync(
Stream fromStream,
Stream toStream,
ByteDiffOptions options,
CancellationToken ct)
{
// Sample-based comparison for large files
var deltas = new List<ByteDelta>();
var sampleInterval = Math.Max(1, (int)(fromStream.Length / 1000)); // ~1000 samples
var windowSize = options.WindowSize;
var buffer = new byte[windowSize];
for (long offset = 0; offset < fromStream.Length - windowSize; offset += sampleInterval)
{
ct.ThrowIfCancellationRequested();
fromStream.Position = offset;
var fromRead = await fromStream.ReadAsync(buffer.AsMemory(0, windowSize), ct);
if (fromRead < windowSize) break;
var fromHash = ComputeWindowHash(buffer.AsSpan(0, fromRead));
if (offset < toStream.Length - windowSize)
{
toStream.Position = offset;
var toRead = await toStream.ReadAsync(buffer.AsMemory(0, windowSize), ct);
var toHash = toRead >= windowSize
? ComputeWindowHash(buffer.AsSpan(0, toRead))
: "truncated";
if (!string.Equals(fromHash, toHash, StringComparison.OrdinalIgnoreCase))
{
deltas.Add(new ByteDelta
{
Offset = offset,
Size = windowSize,
FromHash = fromHash,
ToHash = toHash,
Context = options.IncludeContext ? "Sampled comparison (large file)" : null
});
}
}
}
return deltas;
}
private static string ComputeWindowHash(ReadOnlySpan<byte> window)
{
var hash = SHA256.HashData(window);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string ComputeSectionHash(byte[] bytes, SectionInfo section)
{
var start = (int)Math.Min(section.Offset, bytes.Length);
var length = (int)Math.Min(section.Size, bytes.Length - start);
if (length <= 0) return "empty";
return ComputeWindowHash(bytes.AsSpan(start, length));
}
private static ByteDelta CreateMergedDelta(
List<(long offset, int size, string fromHash, string toHash)> changes,
string? section,
bool includeContext)
{
var first = changes[0];
var last = changes[^1];
var totalSize = (int)(last.offset + last.size - first.offset);
return new ByteDelta
{
Offset = first.offset,
Size = totalSize,
FromHash = first.fromHash, // First window hash
ToHash = last.toHash, // Last window hash
Section = section,
Context = includeContext && changes.Count > 1
? $"{changes.Count} consecutive windows changed"
: null
};
}
private static async Task<byte[]> ReadStreamAsync(Stream stream, CancellationToken ct)
{
if (stream is MemoryStream ms && ms.TryGetBuffer(out var buffer))
{
return buffer.ToArray();
}
using var memoryStream = new MemoryStream();
await stream.CopyToAsync(memoryStream, ct);
return memoryStream.ToArray();
}
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
using StellaOps.Scanner.ChangeTrace.Models;
namespace StellaOps.Scanner.ChangeTrace.ByteDiff;
/// <summary>
/// Service for byte-level binary comparison using rolling hash windows.
/// </summary>
public interface IByteLevelDiffer
{
/// <summary>
/// Compare two binary files and return byte-level deltas.
/// </summary>
Task<IReadOnlyList<ByteDelta>> CompareAsync(
Stream fromStream,
Stream toStream,
ByteDiffOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Compare two binary files by path.
/// </summary>
Task<IReadOnlyList<ByteDelta>> CompareFilesAsync(
string fromPath,
string toPath,
ByteDiffOptions? options = null,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,59 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
namespace StellaOps.Scanner.ChangeTrace.ByteDiff;
/// <summary>
/// Analyzes binary format sections (ELF, PE, Mach-O).
/// </summary>
public interface ISectionAnalyzer
{
/// <summary>
/// Extract section information from binary.
/// </summary>
Task<IReadOnlyList<SectionInfo>> AnalyzeAsync(byte[] binary, CancellationToken ct = default);
}
/// <summary>
/// Information about a binary section.
/// </summary>
/// <param name="Name">Section name (e.g., ".text", ".data").</param>
/// <param name="Offset">Offset in bytes from start of file.</param>
/// <param name="Size">Size in bytes.</param>
/// <param name="Type">Type of section.</param>
public sealed record SectionInfo(
string Name,
long Offset,
long Size,
SectionType Type);
/// <summary>
/// Type of binary section.
/// </summary>
public enum SectionType
{
/// <summary>
/// Code section (.text).
/// </summary>
Code,
/// <summary>
/// Data section (.data, .rodata).
/// </summary>
Data,
/// <summary>
/// Uninitialized data section (.bss).
/// </summary>
Bss,
/// <summary>
/// Debug information (.debug_*).
/// </summary>
Debug,
/// <summary>
/// Other section type.
/// </summary>
Other
}

View File

@@ -0,0 +1,425 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
using System.Buffers.Binary;
namespace StellaOps.Scanner.ChangeTrace.ByteDiff;
/// <summary>
/// Analyzes binary format sections (ELF, PE, Mach-O).
/// </summary>
public sealed class SectionAnalyzer : ISectionAnalyzer
{
// ELF magic bytes
private static ReadOnlySpan<byte> ElfMagic => [0x7f, (byte)'E', (byte)'L', (byte)'F'];
// PE magic bytes (MZ header)
private static ReadOnlySpan<byte> PeMagic => [(byte)'M', (byte)'Z'];
/// <inheritdoc/>
public Task<IReadOnlyList<SectionInfo>> AnalyzeAsync(byte[] binary, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
if (binary.Length < 64)
{
// Too small to be a valid binary
return Task.FromResult<IReadOnlyList<SectionInfo>>([]);
}
// Detect format and parse sections
if (IsElf(binary))
{
return Task.FromResult(ParseElfSections(binary));
}
if (IsPe(binary))
{
return Task.FromResult(ParsePeSections(binary));
}
if (IsMachO(binary))
{
return Task.FromResult(ParseMachOSections(binary));
}
// Unknown format - return empty
return Task.FromResult<IReadOnlyList<SectionInfo>>([]);
}
private static bool IsElf(byte[] binary) =>
binary.Length >= 4 && binary.AsSpan(0, 4).SequenceEqual(ElfMagic);
private static bool IsPe(byte[] binary) =>
binary.Length >= 2 && binary.AsSpan(0, 2).SequenceEqual(PeMagic);
private static bool IsMachO(byte[] binary)
{
if (binary.Length < 4) return false;
var magic = BinaryPrimitives.ReadUInt32BigEndian(binary);
return magic is 0xfeedface or 0xfeedfacf or 0xcefaedfe or 0xcffaedfe;
}
private static IReadOnlyList<SectionInfo> ParseElfSections(byte[] binary)
{
var sections = new List<SectionInfo>();
try
{
// Check ELF class (32 or 64 bit)
var is64Bit = binary[4] == 2;
var isLittleEndian = binary[5] == 1;
// Get section header offset and count
long shoff;
int shentsize;
int shnum;
int shstrndx;
if (is64Bit)
{
shoff = isLittleEndian
? BinaryPrimitives.ReadInt64LittleEndian(binary.AsSpan(40))
: BinaryPrimitives.ReadInt64BigEndian(binary.AsSpan(40));
shentsize = isLittleEndian
? BinaryPrimitives.ReadUInt16LittleEndian(binary.AsSpan(58))
: BinaryPrimitives.ReadUInt16BigEndian(binary.AsSpan(58));
shnum = isLittleEndian
? BinaryPrimitives.ReadUInt16LittleEndian(binary.AsSpan(60))
: BinaryPrimitives.ReadUInt16BigEndian(binary.AsSpan(60));
shstrndx = isLittleEndian
? BinaryPrimitives.ReadUInt16LittleEndian(binary.AsSpan(62))
: BinaryPrimitives.ReadUInt16BigEndian(binary.AsSpan(62));
}
else
{
shoff = isLittleEndian
? BinaryPrimitives.ReadInt32LittleEndian(binary.AsSpan(32))
: BinaryPrimitives.ReadInt32BigEndian(binary.AsSpan(32));
shentsize = isLittleEndian
? BinaryPrimitives.ReadUInt16LittleEndian(binary.AsSpan(46))
: BinaryPrimitives.ReadUInt16BigEndian(binary.AsSpan(46));
shnum = isLittleEndian
? BinaryPrimitives.ReadUInt16LittleEndian(binary.AsSpan(48))
: BinaryPrimitives.ReadUInt16BigEndian(binary.AsSpan(48));
shstrndx = isLittleEndian
? BinaryPrimitives.ReadUInt16LittleEndian(binary.AsSpan(50))
: BinaryPrimitives.ReadUInt16BigEndian(binary.AsSpan(50));
}
// Validate header
if (shoff <= 0 || shoff >= binary.Length || shnum == 0 || shstrndx >= shnum)
{
return GetFallbackSections(binary, ".text", ".data");
}
// Get string table section
var strTableOffset = GetElfSectionOffset(binary, (int)shoff, shstrndx, shentsize, is64Bit, isLittleEndian);
if (strTableOffset < 0 || strTableOffset >= binary.Length)
{
return GetFallbackSections(binary, ".text", ".data");
}
// Parse each section
for (var i = 0; i < shnum; i++)
{
var sectionOffset = shoff + (i * shentsize);
if (sectionOffset + shentsize > binary.Length) break;
var (name, offset, size, type) = ParseElfSectionHeader(
binary, (int)sectionOffset, strTableOffset, is64Bit, isLittleEndian);
if (!string.IsNullOrEmpty(name) && size > 0)
{
sections.Add(new SectionInfo(name, offset, size, type));
}
}
}
catch
{
// Fallback on parse error
return GetFallbackSections(binary, ".text", ".data");
}
return sections.Count > 0 ? sections : GetFallbackSections(binary, ".text", ".data");
}
private static int GetElfSectionOffset(byte[] binary, int shoff, int index, int shentsize, bool is64Bit, bool isLittleEndian)
{
var offset = shoff + (index * shentsize);
if (offset + shentsize > binary.Length) return -1;
if (is64Bit)
{
return (int)(isLittleEndian
? BinaryPrimitives.ReadInt64LittleEndian(binary.AsSpan(offset + 24))
: BinaryPrimitives.ReadInt64BigEndian(binary.AsSpan(offset + 24)));
}
else
{
return isLittleEndian
? BinaryPrimitives.ReadInt32LittleEndian(binary.AsSpan(offset + 16))
: BinaryPrimitives.ReadInt32BigEndian(binary.AsSpan(offset + 16));
}
}
private static (string name, long offset, long size, SectionType type) ParseElfSectionHeader(
byte[] binary, int headerOffset, int strTableOffset, bool is64Bit, bool isLittleEndian)
{
var nameOffset = isLittleEndian
? BinaryPrimitives.ReadInt32LittleEndian(binary.AsSpan(headerOffset))
: BinaryPrimitives.ReadInt32BigEndian(binary.AsSpan(headerOffset));
var shType = isLittleEndian
? BinaryPrimitives.ReadUInt32LittleEndian(binary.AsSpan(headerOffset + 4))
: BinaryPrimitives.ReadUInt32BigEndian(binary.AsSpan(headerOffset + 4));
long offset, size;
if (is64Bit)
{
offset = isLittleEndian
? BinaryPrimitives.ReadInt64LittleEndian(binary.AsSpan(headerOffset + 24))
: BinaryPrimitives.ReadInt64BigEndian(binary.AsSpan(headerOffset + 24));
size = isLittleEndian
? BinaryPrimitives.ReadInt64LittleEndian(binary.AsSpan(headerOffset + 32))
: BinaryPrimitives.ReadInt64BigEndian(binary.AsSpan(headerOffset + 32));
}
else
{
offset = isLittleEndian
? BinaryPrimitives.ReadInt32LittleEndian(binary.AsSpan(headerOffset + 16))
: BinaryPrimitives.ReadInt32BigEndian(binary.AsSpan(headerOffset + 16));
size = isLittleEndian
? BinaryPrimitives.ReadInt32LittleEndian(binary.AsSpan(headerOffset + 20))
: BinaryPrimitives.ReadInt32BigEndian(binary.AsSpan(headerOffset + 20));
}
// Read section name from string table
var name = ReadNullTerminatedString(binary, strTableOffset + nameOffset);
// Determine section type
var sectionType = shType switch
{
1 => name.StartsWith(".text") ? SectionType.Code : SectionType.Data, // SHT_PROGBITS
8 => SectionType.Bss, // SHT_NOBITS
_ when name.StartsWith(".debug") => SectionType.Debug,
_ when name.StartsWith(".text") => SectionType.Code,
_ when name is ".data" or ".rodata" or ".bss" => SectionType.Data,
_ => SectionType.Other
};
return (name, offset, size, sectionType);
}
private static IReadOnlyList<SectionInfo> ParsePeSections(byte[] binary)
{
var sections = new List<SectionInfo>();
try
{
// Get PE header offset from DOS header
var peOffset = BinaryPrimitives.ReadInt32LittleEndian(binary.AsSpan(60));
if (peOffset < 0 || peOffset + 24 >= binary.Length) return GetFallbackSections(binary, ".text", ".rdata");
// Verify PE signature
if (binary[peOffset] != 'P' || binary[peOffset + 1] != 'E' ||
binary[peOffset + 2] != 0 || binary[peOffset + 3] != 0)
{
return GetFallbackSections(binary, ".text", ".rdata");
}
// Get number of sections and optional header size
var numberOfSections = BinaryPrimitives.ReadUInt16LittleEndian(binary.AsSpan(peOffset + 6));
var sizeOfOptionalHeader = BinaryPrimitives.ReadUInt16LittleEndian(binary.AsSpan(peOffset + 20));
// Section headers start after optional header
var sectionHeaderOffset = peOffset + 24 + sizeOfOptionalHeader;
for (var i = 0; i < numberOfSections; i++)
{
var offset = sectionHeaderOffset + (i * 40);
if (offset + 40 > binary.Length) break;
// Read section name (8 bytes, null-padded)
var nameBytes = binary.AsSpan(offset, 8);
var nameEnd = nameBytes.IndexOf((byte)0);
var name = System.Text.Encoding.ASCII.GetString(
nameEnd >= 0 ? nameBytes[..nameEnd] : nameBytes);
// Read virtual size and raw data offset/size
var virtualSize = BinaryPrimitives.ReadUInt32LittleEndian(binary.AsSpan(offset + 8));
var rawDataOffset = BinaryPrimitives.ReadUInt32LittleEndian(binary.AsSpan(offset + 20));
var rawDataSize = BinaryPrimitives.ReadUInt32LittleEndian(binary.AsSpan(offset + 16));
// Determine section type
var sectionType = name switch
{
".text" or ".code" => SectionType.Code,
".data" or ".rdata" => SectionType.Data,
".bss" => SectionType.Bss,
_ when name.StartsWith(".debug") => SectionType.Debug,
_ => SectionType.Other
};
if (!string.IsNullOrEmpty(name) && rawDataSize > 0)
{
sections.Add(new SectionInfo(name, rawDataOffset, rawDataSize, sectionType));
}
}
}
catch
{
return GetFallbackSections(binary, ".text", ".rdata");
}
return sections.Count > 0 ? sections : GetFallbackSections(binary, ".text", ".rdata");
}
private static IReadOnlyList<SectionInfo> ParseMachOSections(byte[] binary)
{
var sections = new List<SectionInfo>();
try
{
// Check endianness and 32/64 bit
var magic = BinaryPrimitives.ReadUInt32BigEndian(binary);
var is64Bit = magic is 0xfeedfacf or 0xcffaedfe;
var isLittleEndian = magic is 0xcefaedfe or 0xcffaedfe;
// Read number of load commands
var ncmds = isLittleEndian
? BinaryPrimitives.ReadUInt32LittleEndian(binary.AsSpan(16))
: BinaryPrimitives.ReadUInt32BigEndian(binary.AsSpan(16));
// Header size
var headerSize = is64Bit ? 32 : 28;
var cmdOffset = headerSize;
for (var i = 0; i < ncmds && cmdOffset < binary.Length - 8; i++)
{
var cmd = isLittleEndian
? BinaryPrimitives.ReadUInt32LittleEndian(binary.AsSpan(cmdOffset))
: BinaryPrimitives.ReadUInt32BigEndian(binary.AsSpan(cmdOffset));
var cmdSize = isLittleEndian
? BinaryPrimitives.ReadUInt32LittleEndian(binary.AsSpan(cmdOffset + 4))
: BinaryPrimitives.ReadUInt32BigEndian(binary.AsSpan(cmdOffset + 4));
// LC_SEGMENT (1) or LC_SEGMENT_64 (0x19)
if (cmd is 1 or 0x19)
{
var segmentSections = ParseMachOSegment(binary, cmdOffset, is64Bit, isLittleEndian);
sections.AddRange(segmentSections);
}
cmdOffset += (int)cmdSize;
}
}
catch
{
return GetFallbackSections(binary, "__TEXT", "__DATA");
}
return sections.Count > 0 ? sections : GetFallbackSections(binary, "__TEXT", "__DATA");
}
private static IReadOnlyList<SectionInfo> ParseMachOSegment(byte[] binary, int cmdOffset, bool is64Bit, bool isLittleEndian)
{
var sections = new List<SectionInfo>();
try
{
// Read segment name (16 bytes at offset 8)
var segNameBytes = binary.AsSpan(cmdOffset + 8, 16);
var segNameEnd = segNameBytes.IndexOf((byte)0);
var segName = System.Text.Encoding.ASCII.GetString(
segNameEnd >= 0 ? segNameBytes[..segNameEnd] : segNameBytes);
// Number of sections in this segment
var nsects = isLittleEndian
? BinaryPrimitives.ReadUInt32LittleEndian(binary.AsSpan(cmdOffset + (is64Bit ? 64 : 48)))
: BinaryPrimitives.ReadUInt32BigEndian(binary.AsSpan(cmdOffset + (is64Bit ? 64 : 48)));
// Section headers start after segment command header
var sectionOffset = cmdOffset + (is64Bit ? 72 : 56);
var sectionSize = is64Bit ? 80 : 68;
for (var i = 0; i < nsects && sectionOffset + sectionSize <= binary.Length; i++)
{
// Section name (16 bytes)
var sectNameBytes = binary.AsSpan(sectionOffset, 16);
var sectNameEnd = sectNameBytes.IndexOf((byte)0);
var sectName = System.Text.Encoding.ASCII.GetString(
sectNameEnd >= 0 ? sectNameBytes[..sectNameEnd] : sectNameBytes);
long offset, size;
if (is64Bit)
{
size = isLittleEndian
? BinaryPrimitives.ReadInt64LittleEndian(binary.AsSpan(sectionOffset + 40))
: BinaryPrimitives.ReadInt64BigEndian(binary.AsSpan(sectionOffset + 40));
offset = isLittleEndian
? BinaryPrimitives.ReadUInt32LittleEndian(binary.AsSpan(sectionOffset + 48))
: BinaryPrimitives.ReadUInt32BigEndian(binary.AsSpan(sectionOffset + 48));
}
else
{
size = isLittleEndian
? BinaryPrimitives.ReadInt32LittleEndian(binary.AsSpan(sectionOffset + 36))
: BinaryPrimitives.ReadInt32BigEndian(binary.AsSpan(sectionOffset + 36));
offset = isLittleEndian
? BinaryPrimitives.ReadUInt32LittleEndian(binary.AsSpan(sectionOffset + 40))
: BinaryPrimitives.ReadUInt32BigEndian(binary.AsSpan(sectionOffset + 40));
}
var fullName = $"{segName},{sectName}";
var sectionType = segName switch
{
"__TEXT" => SectionType.Code,
"__DATA" or "__DATA_CONST" => SectionType.Data,
"__DWARF" => SectionType.Debug,
_ => SectionType.Other
};
if (size > 0)
{
sections.Add(new SectionInfo(fullName, offset, size, sectionType));
}
sectionOffset += sectionSize;
}
}
catch
{
// Ignore parse errors for individual segments
}
return sections;
}
private static string ReadNullTerminatedString(byte[] binary, int offset)
{
if (offset < 0 || offset >= binary.Length) return string.Empty;
var end = offset;
while (end < binary.Length && binary[end] != 0)
{
end++;
}
return System.Text.Encoding.UTF8.GetString(binary.AsSpan(offset, end - offset));
}
private static IReadOnlyList<SectionInfo> GetFallbackSections(byte[] binary, string textName, string dataName)
{
// Provide reasonable fallback sections for unknown/unparseable binaries
var textSize = Math.Min(binary.Length / 2, binary.Length);
var dataSize = Math.Min(binary.Length / 4, binary.Length - textSize);
return
[
new SectionInfo(textName, 0, textSize, SectionType.Code),
new SectionInfo(dataName, textSize, dataSize, SectionType.Data)
];
}
}

View File

@@ -0,0 +1,362 @@
// -----------------------------------------------------------------------------
// ChangeTraceEvidenceExtension.cs
// Sprint: SPRINT_20260112_200_005_ATTEST_predicate
// Description: CycloneDX evidence extension for change traces.
// -----------------------------------------------------------------------------
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using StellaOps.Scanner.ChangeTrace.Models;
using ChangeTraceModel = StellaOps.Scanner.ChangeTrace.Models.ChangeTrace;
namespace StellaOps.Scanner.ChangeTrace.CycloneDx;
/// <summary>
/// CycloneDX evidence extension for change traces.
/// Provides both embedded and standalone export modes.
/// </summary>
public sealed class ChangeTraceEvidenceExtension : IChangeTraceEvidenceExtension
{
private const string ExtensionType = "stella.change-trace";
private const string ToolVendor = "StellaOps";
private const string ToolName = "ChangeTrace";
private readonly TimeProvider _timeProvider;
/// <summary>
/// Create a new change trace evidence extension.
/// </summary>
/// <param name="timeProvider">Time provider for timestamps.</param>
public ChangeTraceEvidenceExtension(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <summary>
/// Create a new change trace evidence extension with default time provider.
/// </summary>
public ChangeTraceEvidenceExtension()
: this(TimeProvider.System)
{
}
/// <inheritdoc />
public JsonDocument EmbedInCycloneDx(
JsonDocument bom,
ChangeTraceModel trace,
ChangeTraceEvidenceOptions? options = null)
{
ArgumentNullException.ThrowIfNull(bom);
ArgumentNullException.ThrowIfNull(trace);
options ??= ChangeTraceEvidenceOptions.Default;
// Parse existing BOM as a mutable JsonNode
var bomNode = JsonNode.Parse(bom.RootElement.GetRawText());
if (bomNode is not JsonObject bomObject)
{
throw new ArgumentException("BOM root must be a JSON object", nameof(bom));
}
// Build the extension object
var extensionNode = BuildExtensionObject(trace, options);
// Get or create extensions array
if (bomObject.TryGetPropertyValue("extensions", out var existingExtensions) &&
existingExtensions is JsonArray extensionsArray)
{
extensionsArray.Add(extensionNode);
}
else
{
var newExtensions = new JsonArray { extensionNode };
bomObject["extensions"] = newExtensions;
}
// Serialize back to JsonDocument
var outputJson = bomObject.ToJsonString(GetSerializerOptions());
return JsonDocument.Parse(outputJson);
}
/// <inheritdoc />
public JsonDocument ExportAsStandalone(
ChangeTraceModel trace,
ChangeTraceEvidenceOptions? options = null)
{
ArgumentNullException.ThrowIfNull(trace);
options ??= ChangeTraceEvidenceOptions.Default;
var extensionNode = BuildExtensionObject(trace, options);
var standaloneDoc = new JsonObject
{
["bomFormat"] = "CycloneDX",
["specVersion"] = options.SpecVersion,
["serialNumber"] = $"urn:uuid:{Guid.NewGuid()}",
["version"] = 1,
["metadata"] = new JsonObject
{
["timestamp"] = trace.Basis.AnalyzedAt.ToString("O", CultureInfo.InvariantCulture),
["tools"] = new JsonArray
{
new JsonObject
{
["vendor"] = ToolVendor,
["name"] = ToolName,
["version"] = trace.Basis.EngineVersion
}
}
},
["extensions"] = new JsonArray { extensionNode }
};
// Add subject information as component reference
if (!string.IsNullOrEmpty(trace.Subject.Purl))
{
var components = new JsonArray
{
new JsonObject
{
["type"] = "container",
["bom-ref"] = "change-trace-subject",
["purl"] = trace.Subject.Purl,
["hashes"] = new JsonArray
{
new JsonObject
{
["alg"] = "SHA-256",
["content"] = ExtractHashValue(trace.Subject.Digest)
}
}
}
};
standaloneDoc["components"] = components;
}
var json = standaloneDoc.ToJsonString(GetSerializerOptions());
return JsonDocument.Parse(json);
}
/// <summary>
/// Build the extension object for the change trace.
/// </summary>
private JsonObject BuildExtensionObject(
ChangeTraceModel trace,
ChangeTraceEvidenceOptions options)
{
var changeTraceNode = new JsonObject
{
["schema"] = trace.Schema,
["subject"] = BuildSubjectNode(trace.Subject),
["basis"] = BuildBasisNode(trace.Basis),
["summary"] = BuildSummaryNode(trace.Summary),
["commitment"] = trace.Commitment is not null ? BuildCommitmentNode(trace.Commitment) : null
};
// Add deltas with limit
var deltasArray = new JsonArray();
var deltaCount = 0;
foreach (var delta in trace.Deltas)
{
if (deltaCount >= options.MaxDeltas)
{
break;
}
deltasArray.Add(BuildDeltaNode(delta, options));
deltaCount++;
}
changeTraceNode["deltas"] = deltasArray;
// Add truncation notice if needed
if (trace.Deltas.Length > options.MaxDeltas)
{
changeTraceNode["truncated"] = true;
changeTraceNode["totalDeltas"] = trace.Deltas.Length;
}
return new JsonObject
{
["extensionType"] = ExtensionType,
["changeTrace"] = changeTraceNode
};
}
private static JsonObject BuildSubjectNode(ChangeTraceSubject subject)
{
var node = new JsonObject
{
["type"] = subject.Type,
["digest"] = subject.Digest
};
if (!string.IsNullOrEmpty(subject.Purl))
{
node["purl"] = subject.Purl;
}
if (!string.IsNullOrEmpty(subject.Name))
{
node["name"] = subject.Name;
}
return node;
}
private static JsonObject BuildBasisNode(ChangeTraceBasis basis)
{
var node = new JsonObject
{
["scanId"] = basis.ScanId,
["engineVersion"] = basis.EngineVersion,
["analyzedAt"] = basis.AnalyzedAt.ToString("O", CultureInfo.InvariantCulture)
};
if (!string.IsNullOrEmpty(basis.FromScanId))
{
node["fromScanId"] = basis.FromScanId;
}
if (!string.IsNullOrEmpty(basis.ToScanId))
{
node["toScanId"] = basis.ToScanId;
}
if (!basis.Policies.IsDefaultOrEmpty)
{
var policiesArray = new JsonArray();
foreach (var policy in basis.Policies)
{
policiesArray.Add(policy);
}
node["policies"] = policiesArray;
}
if (!basis.DiffMethod.IsDefaultOrEmpty)
{
var methodsArray = new JsonArray();
foreach (var method in basis.DiffMethod)
{
methodsArray.Add(method);
}
node["diffMethod"] = methodsArray;
}
return node;
}
private static JsonObject BuildSummaryNode(ChangeTraceSummary summary)
{
return new JsonObject
{
["changedPackages"] = summary.ChangedPackages,
["changedSymbols"] = summary.ChangedSymbols,
["changedBytes"] = summary.ChangedBytes,
["riskDelta"] = Math.Round(summary.RiskDelta, 4),
["verdict"] = summary.Verdict.ToString().ToLowerInvariant()
};
}
private static JsonObject BuildCommitmentNode(ChangeTraceCommitment commitment)
{
return new JsonObject
{
["sha256"] = commitment.Sha256,
["algorithm"] = commitment.Algorithm
};
}
private static JsonObject BuildDeltaNode(
PackageDelta delta,
ChangeTraceEvidenceOptions options)
{
var node = new JsonObject
{
["purl"] = delta.Purl,
["name"] = delta.Name,
["fromVersion"] = delta.FromVersion,
["toVersion"] = delta.ToVersion,
["changeType"] = delta.ChangeType.ToString().ToLowerInvariant(),
["explain"] = delta.Explain.ToString().ToLowerInvariant()
};
// Add evidence summary
node["evidence"] = new JsonObject
{
["symbolsChanged"] = delta.Evidence.SymbolsChanged,
["bytesChanged"] = delta.Evidence.BytesChanged,
["confidence"] = Math.Round(delta.Evidence.Confidence, 4)
};
// Add trust delta if available
if (delta.TrustDelta is not null)
{
var trustNode = new JsonObject
{
["score"] = Math.Round(delta.TrustDelta.Score, 4),
["reachabilityImpact"] = delta.TrustDelta.ReachabilityImpact.ToString().ToLowerInvariant(),
["exploitabilityImpact"] = delta.TrustDelta.ExploitabilityImpact.ToString().ToLowerInvariant()
};
if (options.IncludeProofSteps && !delta.TrustDelta.ProofSteps.IsDefaultOrEmpty)
{
var stepsArray = new JsonArray();
foreach (var step in delta.TrustDelta.ProofSteps)
{
stepsArray.Add(step);
}
trustNode["proofSteps"] = stepsArray;
}
node["trustDelta"] = trustNode;
}
// Add symbol deltas if requested
if (options.IncludeSymbolDeltas && !delta.SymbolDeltas.IsDefaultOrEmpty)
{
var symbolsArray = new JsonArray();
foreach (var symbol in delta.SymbolDeltas.Take(50))
{
symbolsArray.Add(new JsonObject
{
["name"] = symbol.Name,
["changeType"] = symbol.ChangeType.ToString().ToLowerInvariant()
});
}
node["symbolDeltas"] = symbolsArray;
}
// Add byte deltas if requested
if (options.IncludeByteDeltas && !delta.ByteDeltas.IsDefaultOrEmpty)
{
var bytesArray = new JsonArray();
foreach (var byteD in delta.ByteDeltas.Take(20))
{
bytesArray.Add(new JsonObject
{
["section"] = byteD.Section,
["offset"] = byteD.Offset,
["size"] = byteD.Size
});
}
node["byteDeltas"] = bytesArray;
}
return node;
}
private static string ExtractHashValue(string digest)
{
var colonIndex = digest.IndexOf(':', StringComparison.Ordinal);
return colonIndex > 0 ? digest[(colonIndex + 1)..] : digest;
}
private static JsonSerializerOptions GetSerializerOptions()
{
return new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
}
}

View File

@@ -0,0 +1,78 @@
// -----------------------------------------------------------------------------
// IChangeTraceEvidenceExtension.cs
// Sprint: SPRINT_20260112_200_005_ATTEST_predicate
// Description: Interface for CycloneDX evidence extension support.
// -----------------------------------------------------------------------------
using System.Text.Json;
namespace StellaOps.Scanner.ChangeTrace.CycloneDx;
using ChangeTraceModel = StellaOps.Scanner.ChangeTrace.Models.ChangeTrace;
/// <summary>
/// CycloneDX evidence extension for change traces.
/// Supports both embedded mode (within an existing BOM) and standalone mode.
/// </summary>
public interface IChangeTraceEvidenceExtension
{
/// <summary>
/// Embed change trace as component evidence in CycloneDX BOM.
/// Adds the change trace as an extension to the BOM's extensions array.
/// </summary>
/// <param name="bom">The existing CycloneDX BOM document.</param>
/// <param name="trace">The change trace to embed.</param>
/// <param name="options">Optional extension options.</param>
/// <returns>A new BOM document with the embedded change trace extension.</returns>
JsonDocument EmbedInCycloneDx(
JsonDocument bom,
ChangeTraceModel trace,
ChangeTraceEvidenceOptions? options = null);
/// <summary>
/// Export change trace as standalone CycloneDX evidence file.
/// Creates a minimal CycloneDX wrapper containing only the change trace extension.
/// </summary>
/// <param name="trace">The change trace to export.</param>
/// <param name="options">Optional extension options.</param>
/// <returns>A standalone CycloneDX document with the change trace extension.</returns>
JsonDocument ExportAsStandalone(
ChangeTraceModel trace,
ChangeTraceEvidenceOptions? options = null);
}
/// <summary>
/// Options for change trace CycloneDX evidence extension.
/// </summary>
public sealed record ChangeTraceEvidenceOptions
{
/// <summary>
/// Default extension options.
/// </summary>
public static readonly ChangeTraceEvidenceOptions Default = new();
/// <summary>
/// Include detailed proof steps in the evidence.
/// </summary>
public bool IncludeProofSteps { get; init; } = true;
/// <summary>
/// Include byte-level deltas in the evidence.
/// </summary>
public bool IncludeByteDeltas { get; init; }
/// <summary>
/// Include symbol-level deltas in the evidence.
/// </summary>
public bool IncludeSymbolDeltas { get; init; } = true;
/// <summary>
/// Maximum number of deltas to include.
/// </summary>
public int MaxDeltas { get; init; } = 100;
/// <summary>
/// CycloneDX spec version for standalone export.
/// </summary>
public string SpecVersion { get; init; } = "1.7";
}

View File

@@ -0,0 +1,117 @@
namespace StellaOps.Scanner.ChangeTrace.Integration;
/// <summary>
/// Simplified client interface for ReachGraph operations.
/// This is an adapter interface to decouple ChangeTrace from ReachGraph internals.
/// </summary>
public interface IReachGraphClient
{
/// <summary>
/// Get reachability information for a package in an image.
/// </summary>
/// <param name="imageDigest">Image digest (sha256:...).</param>
/// <param name="purl">Package URL.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Reachability result.</returns>
Task<ReachabilityResult> GetReachabilityAsync(
string imageDigest,
string purl,
CancellationToken ct = default);
/// <summary>
/// Get call paths to a vulnerable function.
/// </summary>
/// <param name="imageDigest">Image digest.</param>
/// <param name="functionName">Function name.</param>
/// <param name="maxPaths">Maximum number of paths to return.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Call path result.</returns>
Task<CallPathResult> GetCallPathsAsync(
string imageDigest,
string functionName,
int maxPaths = 5,
CancellationToken ct = default);
}
/// <summary>
/// Reachability result for a package.
/// </summary>
public sealed record ReachabilityResult
{
/// <summary>
/// Whether the package is reachable from entrypoints.
/// </summary>
public required bool IsReachable { get; init; }
/// <summary>
/// Number of reachable call paths.
/// </summary>
public required int ReachableCallPaths { get; init; }
/// <summary>
/// Total number of exported symbols.
/// </summary>
public int TotalSymbols { get; init; }
/// <summary>
/// Number of reachable symbols.
/// </summary>
public int ReachableSymbols { get; init; }
/// <summary>
/// Fraction of package that is unreachable (0.0 to 1.0).
/// </summary>
public double UnreachableFraction { get; init; }
/// <summary>
/// Entrypoints that reach this package.
/// </summary>
public IReadOnlyList<string>? ReachingEntrypoints { get; init; }
}
/// <summary>
/// Call path result for a function.
/// </summary>
public sealed record CallPathResult
{
/// <summary>
/// Number of call paths found.
/// </summary>
public required int PathCount { get; init; }
/// <summary>
/// Individual call paths.
/// </summary>
public IReadOnlyList<CallPath>? Paths { get; init; }
/// <summary>
/// Shortest path depth.
/// </summary>
public int? ShortestPathDepth { get; init; }
}
/// <summary>
/// A single call path from entrypoint to target.
/// </summary>
public sealed record CallPath
{
/// <summary>
/// Entrypoint function name.
/// </summary>
public required string Entrypoint { get; init; }
/// <summary>
/// Target function name.
/// </summary>
public required string Target { get; init; }
/// <summary>
/// Call chain (function names).
/// </summary>
public required IReadOnlyList<string> Chain { get; init; }
/// <summary>
/// Path depth (number of calls).
/// </summary>
public int Depth => Chain.Count;
}

View File

@@ -0,0 +1,93 @@
namespace StellaOps.Scanner.ChangeTrace.Integration;
/// <summary>
/// Simplified client interface for VexLens consensus operations.
/// This is an adapter interface to decouple ChangeTrace from VexLens internals.
/// </summary>
public interface IVexLensClient
{
/// <summary>
/// Get consensus trust score for a package version.
/// </summary>
/// <param name="purl">Package URL.</param>
/// <param name="version">Package version.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Consensus result with trust score.</returns>
Task<VexConsensusResult> GetConsensusAsync(
string purl,
string version,
CancellationToken ct = default);
/// <summary>
/// Get advisory information for a CVE.
/// </summary>
/// <param name="cveId">CVE identifier.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Advisory info or null if not found.</returns>
Task<VexAdvisoryInfo?> GetAdvisoryAsync(
string cveId,
CancellationToken ct = default);
}
/// <summary>
/// Simplified consensus result for change trace calculation.
/// </summary>
public sealed record VexConsensusResult
{
/// <summary>
/// Overall trust score (0.0 to 1.0).
/// Higher values indicate higher trust (less exploitable).
/// </summary>
public required double TrustScore { get; init; }
/// <summary>
/// Confidence in the consensus (0.0 to 1.0).
/// </summary>
public required double Confidence { get; init; }
/// <summary>
/// VEX status string (e.g., "not_affected", "affected", "fixed").
/// </summary>
public required string Status { get; init; }
/// <summary>
/// Number of VEX statements contributing to consensus.
/// </summary>
public int ContributingStatements { get; init; }
/// <summary>
/// Justification for the status, if available.
/// </summary>
public string? Justification { get; init; }
}
/// <summary>
/// Advisory information for a CVE.
/// </summary>
public sealed record VexAdvisoryInfo
{
/// <summary>
/// CVE identifier.
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Affected functions or components.
/// </summary>
public IReadOnlyList<string>? AffectedFunctions { get; init; }
/// <summary>
/// CVSS score if available.
/// </summary>
public double? CvssScore { get; init; }
/// <summary>
/// Summary description.
/// </summary>
public string? Summary { get; init; }
/// <summary>
/// Fixed in versions.
/// </summary>
public IReadOnlyList<string>? FixedInVersions { get; init; }
}

View File

@@ -0,0 +1,51 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.ChangeTrace.Models;
/// <summary>
/// Byte-level change delta (rolling hash window granularity).
/// </summary>
public sealed record ByteDelta
{
/// <summary>
/// Scope of this delta: always "byte" for byte deltas.
/// </summary>
[JsonPropertyName("scope")]
public string Scope { get; init; } = "byte";
/// <summary>
/// Byte offset where the change begins.
/// </summary>
[JsonPropertyName("offset")]
public required long Offset { get; init; }
/// <summary>
/// Size of the changed region in bytes.
/// </summary>
[JsonPropertyName("size")]
public required int Size { get; init; }
/// <summary>
/// Rolling hash of the "before" bytes.
/// </summary>
[JsonPropertyName("fromHash")]
public required string FromHash { get; init; }
/// <summary>
/// Rolling hash of the "after" bytes.
/// </summary>
[JsonPropertyName("toHash")]
public required string ToHash { get; init; }
/// <summary>
/// Binary section containing this change (e.g., ".text", ".data").
/// </summary>
[JsonPropertyName("section")]
public string? Section { get; init; }
/// <summary>
/// Optional context description for this change.
/// </summary>
[JsonPropertyName("context")]
public string? Context { get; init; }
}

View File

@@ -0,0 +1,178 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.ChangeTrace.Models;
/// <summary>
/// Root model for change trace artifacts.
/// Schema: stella.change-trace/1.0
/// </summary>
public sealed record ChangeTrace
{
/// <summary>
/// Current schema version for change trace documents.
/// </summary>
public const string SchemaVersion = "stella.change-trace/1.0";
/// <summary>
/// Schema identifier for this change trace.
/// </summary>
[JsonPropertyName("schema")]
public string Schema { get; init; } = SchemaVersion;
/// <summary>
/// Subject artifact being compared.
/// </summary>
[JsonPropertyName("subject")]
public required ChangeTraceSubject Subject { get; init; }
/// <summary>
/// Analysis basis and configuration.
/// </summary>
[JsonPropertyName("basis")]
public required ChangeTraceBasis Basis { get; init; }
/// <summary>
/// Package-level deltas with nested symbol and byte deltas.
/// </summary>
[JsonPropertyName("deltas")]
public ImmutableArray<PackageDelta> Deltas { get; init; } = [];
/// <summary>
/// Aggregated summary of all changes.
/// </summary>
[JsonPropertyName("summary")]
public required ChangeTraceSummary Summary { get; init; }
/// <summary>
/// Commitment hash for deterministic verification.
/// </summary>
[JsonPropertyName("commitment")]
public ChangeTraceCommitment? Commitment { get; init; }
/// <summary>
/// Reference to DSSE attestation, if attached.
/// </summary>
[JsonPropertyName("attestation")]
public ChangeTraceAttestationRef? Attestation { get; init; }
}
/// <summary>
/// Subject artifact being compared.
/// </summary>
public sealed record ChangeTraceSubject
{
/// <summary>
/// Type of artifact: "oci.image", "binary", "package".
/// </summary>
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>
/// Digest of the artifact (e.g., sha256:...).
/// </summary>
[JsonPropertyName("digest")]
public required string Digest { get; init; }
/// <summary>
/// Package URL if applicable.
/// </summary>
[JsonPropertyName("purl")]
public string? Purl { get; init; }
/// <summary>
/// Human-readable name of the artifact.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; init; }
}
/// <summary>
/// Analysis basis and configuration.
/// </summary>
public sealed record ChangeTraceBasis
{
/// <summary>
/// Primary scan identifier for this comparison.
/// </summary>
[JsonPropertyName("scanId")]
public required string ScanId { get; init; }
/// <summary>
/// Scan ID of the "before" state.
/// </summary>
[JsonPropertyName("fromScanId")]
public string? FromScanId { get; init; }
/// <summary>
/// Scan ID of the "after" state.
/// </summary>
[JsonPropertyName("toScanId")]
public string? ToScanId { get; init; }
/// <summary>
/// Lattice policies applied during analysis.
/// </summary>
[JsonPropertyName("policies")]
public ImmutableArray<string> Policies { get; init; } = [];
/// <summary>
/// Diff methods used: "pkg", "symbol", "byte".
/// </summary>
[JsonPropertyName("diffMethod")]
public ImmutableArray<string> DiffMethod { get; init; } = [];
/// <summary>
/// Version of the engine that produced this trace.
/// </summary>
[JsonPropertyName("engineVersion")]
public required string EngineVersion { get; init; }
/// <summary>
/// Digest of the engine binary/source for reproducibility verification.
/// </summary>
[JsonPropertyName("engineDigest")]
public string? EngineDigest { get; init; }
/// <summary>
/// Timestamp when analysis was performed.
/// </summary>
[JsonPropertyName("analyzedAt")]
public required DateTimeOffset AnalyzedAt { get; init; }
}
/// <summary>
/// Commitment hash for deterministic verification.
/// </summary>
public sealed record ChangeTraceCommitment
{
/// <summary>
/// SHA-256 hash of the canonical JSON representation.
/// </summary>
[JsonPropertyName("sha256")]
public required string Sha256 { get; init; }
/// <summary>
/// Algorithm used for canonicalization and hashing.
/// </summary>
[JsonPropertyName("algorithm")]
public string Algorithm { get; init; } = "RFC8785+SHA256";
}
/// <summary>
/// Reference to DSSE attestation.
/// </summary>
public sealed record ChangeTraceAttestationRef
{
/// <summary>
/// Predicate type for the attestation.
/// </summary>
[JsonPropertyName("predicateType")]
public required string PredicateType { get; init; }
/// <summary>
/// Digest of the DSSE envelope.
/// </summary>
[JsonPropertyName("envelopeDigest")]
public string? EnvelopeDigest { get; init; }
}

View File

@@ -0,0 +1,78 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.ChangeTrace.Models;
/// <summary>
/// Aggregated summary of all changes in a change trace.
/// </summary>
public sealed record ChangeTraceSummary
{
/// <summary>
/// Total number of packages with changes.
/// </summary>
[JsonPropertyName("changedPackages")]
public required int ChangedPackages { get; init; }
/// <summary>
/// Total number of symbols with changes.
/// </summary>
[JsonPropertyName("changedSymbols")]
public required int ChangedSymbols { get; init; }
/// <summary>
/// Total bytes changed across all packages.
/// </summary>
[JsonPropertyName("changedBytes")]
public required long ChangedBytes { get; init; }
/// <summary>
/// Aggregated risk delta score.
/// </summary>
[JsonPropertyName("riskDelta")]
public required double RiskDelta { get; init; }
/// <summary>
/// Overall verdict based on risk delta.
/// </summary>
[JsonPropertyName("verdict")]
public required ChangeTraceVerdict Verdict { get; init; }
/// <summary>
/// Risk score before changes.
/// </summary>
[JsonPropertyName("beforeRiskScore")]
public double? BeforeRiskScore { get; init; }
/// <summary>
/// Risk score after changes.
/// </summary>
[JsonPropertyName("afterRiskScore")]
public double? AfterRiskScore { get; init; }
}
/// <summary>
/// Overall verdict for a change trace.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ChangeTraceVerdict
{
/// <summary>
/// Risk has decreased significantly (score &lt; -0.3).
/// </summary>
RiskDown,
/// <summary>
/// Risk change is minimal (-0.3 &lt;= score &lt;= 0.3).
/// </summary>
Neutral,
/// <summary>
/// Risk has increased (score &gt; 0.3).
/// </summary>
RiskUp,
/// <summary>
/// Unable to determine risk impact.
/// </summary>
Inconclusive
}

View File

@@ -0,0 +1,208 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.ChangeTrace.Models;
/// <summary>
/// Package-level change delta.
/// </summary>
public sealed record PackageDelta
{
/// <summary>
/// Scope of this delta: always "pkg" for package deltas.
/// </summary>
[JsonPropertyName("scope")]
public string Scope { get; init; } = "pkg";
/// <summary>
/// Package URL (PURL) identifying the package.
/// </summary>
[JsonPropertyName("purl")]
public required string Purl { get; init; }
/// <summary>
/// Human-readable package name.
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Version before the change.
/// </summary>
[JsonPropertyName("fromVersion")]
public required string FromVersion { get; init; }
/// <summary>
/// Version after the change.
/// </summary>
[JsonPropertyName("toVersion")]
public required string ToVersion { get; init; }
/// <summary>
/// Type of change detected.
/// </summary>
[JsonPropertyName("changeType")]
public required PackageChangeType ChangeType { get; init; }
/// <summary>
/// Explanation of the change reason.
/// </summary>
[JsonPropertyName("explain")]
public required PackageChangeExplanation Explain { get; init; }
/// <summary>
/// Evidence supporting this change classification.
/// </summary>
[JsonPropertyName("evidence")]
public required PackageDeltaEvidence Evidence { get; init; }
/// <summary>
/// Trust delta computed for this package change.
/// </summary>
[JsonPropertyName("trustDelta")]
public TrustDelta? TrustDelta { get; init; }
/// <summary>
/// Symbol-level deltas within this package.
/// </summary>
[JsonPropertyName("symbolDeltas")]
public ImmutableArray<SymbolDelta> SymbolDeltas { get; init; } = [];
/// <summary>
/// Byte-level deltas within this package.
/// </summary>
[JsonPropertyName("byteDeltas")]
public ImmutableArray<ByteDelta> ByteDeltas { get; init; } = [];
}
/// <summary>
/// Type of package change detected.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum PackageChangeType
{
/// <summary>
/// Package was added to the artifact.
/// </summary>
Added,
/// <summary>
/// Package was removed from the artifact.
/// </summary>
Removed,
/// <summary>
/// Package was modified (general change).
/// </summary>
Modified,
/// <summary>
/// Package version was upgraded.
/// </summary>
Upgraded,
/// <summary>
/// Package version was downgraded.
/// </summary>
Downgraded,
/// <summary>
/// Package was rebuilt without version change.
/// </summary>
Rebuilt
}
/// <summary>
/// Explanation category for why the package changed.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum PackageChangeExplanation
{
/// <summary>
/// Vendor backport of upstream fixes.
/// </summary>
VendorBackport,
/// <summary>
/// Standard upstream version upgrade.
/// </summary>
UpstreamUpgrade,
/// <summary>
/// Security patch applied.
/// </summary>
SecurityPatch,
/// <summary>
/// Package rebuilt without source changes.
/// </summary>
Rebuild,
/// <summary>
/// Compilation flags or build options changed.
/// </summary>
FlagChange,
/// <summary>
/// New dependency added.
/// </summary>
NewDependency,
/// <summary>
/// Dependency removed.
/// </summary>
RemovedDependency,
/// <summary>
/// Change reason could not be determined.
/// </summary>
Unknown
}
/// <summary>
/// Evidence supporting the package change classification.
/// </summary>
public sealed record PackageDeltaEvidence
{
/// <summary>
/// Patch identifiers associated with this change.
/// </summary>
[JsonPropertyName("patchIds")]
public ImmutableArray<string> PatchIds { get; init; } = [];
/// <summary>
/// CVE identifiers addressed by this change.
/// </summary>
[JsonPropertyName("cveIds")]
public ImmutableArray<string> CveIds { get; init; } = [];
/// <summary>
/// Number of symbols changed in this package.
/// </summary>
[JsonPropertyName("symbolsChanged")]
public int SymbolsChanged { get; init; }
/// <summary>
/// Total bytes changed in this package.
/// </summary>
[JsonPropertyName("bytesChanged")]
public long BytesChanged { get; init; }
/// <summary>
/// Function names affected by this change.
/// </summary>
[JsonPropertyName("functions")]
public ImmutableArray<string> Functions { get; init; } = [];
/// <summary>
/// Method used to verify this change.
/// </summary>
[JsonPropertyName("verificationMethod")]
public string? VerificationMethod { get; init; }
/// <summary>
/// Confidence score for the change classification (0.0-1.0).
/// </summary>
[JsonPropertyName("confidence")]
public double Confidence { get; init; }
}

View File

@@ -0,0 +1,114 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.ChangeTrace.Models;
/// <summary>
/// Symbol-level change delta (function/method granularity).
/// </summary>
public sealed record SymbolDelta
{
/// <summary>
/// Scope of this delta: always "symbol" for symbol deltas.
/// </summary>
[JsonPropertyName("scope")]
public string Scope { get; init; } = "symbol";
/// <summary>
/// Symbol name (function/method name).
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Type of change detected for this symbol.
/// </summary>
[JsonPropertyName("changeType")]
public required SymbolChangeType ChangeType { get; init; }
/// <summary>
/// Hash of the symbol in the "before" state.
/// </summary>
[JsonPropertyName("fromHash")]
public string? FromHash { get; init; }
/// <summary>
/// Hash of the symbol in the "after" state.
/// </summary>
[JsonPropertyName("toHash")]
public string? ToHash { get; init; }
/// <summary>
/// Size difference in bytes.
/// </summary>
[JsonPropertyName("sizeDelta")]
public int SizeDelta { get; init; }
/// <summary>
/// Change in CFG (Control Flow Graph) basic block count.
/// </summary>
[JsonPropertyName("cfgBlockDelta")]
public int? CfgBlockDelta { get; init; }
/// <summary>
/// Similarity score between before and after versions (0.0-1.0).
/// </summary>
[JsonPropertyName("similarity")]
public double Similarity { get; init; }
/// <summary>
/// Confidence in the match determination (0.0-1.0).
/// </summary>
[JsonPropertyName("confidence")]
public double Confidence { get; init; }
/// <summary>
/// Method used for matching: "CFGHash", "InstructionHash", "SemanticHash".
/// </summary>
[JsonPropertyName("matchMethod")]
public string? MatchMethod { get; init; }
/// <summary>
/// Human-readable explanation of the change.
/// </summary>
[JsonPropertyName("explanation")]
public string? Explanation { get; init; }
/// <summary>
/// Indices of matched instruction chunks.
/// </summary>
[JsonPropertyName("matchedChunks")]
public ImmutableArray<int> MatchedChunks { get; init; } = [];
}
/// <summary>
/// Type of symbol change detected.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SymbolChangeType
{
/// <summary>
/// Symbol is unchanged between versions.
/// </summary>
Unchanged,
/// <summary>
/// Symbol was added.
/// </summary>
Added,
/// <summary>
/// Symbol was removed.
/// </summary>
Removed,
/// <summary>
/// Symbol was modified.
/// </summary>
Modified,
/// <summary>
/// Symbol was patched (security or bug fix detected).
/// </summary>
Patched
}

View File

@@ -0,0 +1,111 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.ChangeTrace.Models;
/// <summary>
/// Trust delta with lattice proof steps.
/// </summary>
public sealed record TrustDelta
{
/// <summary>
/// Impact on code reachability.
/// </summary>
[JsonPropertyName("reachabilityImpact")]
public required ReachabilityImpact ReachabilityImpact { get; init; }
/// <summary>
/// Impact on exploitability.
/// </summary>
[JsonPropertyName("exploitabilityImpact")]
public required ExploitabilityImpact ExploitabilityImpact { get; init; }
/// <summary>
/// Overall trust delta score (-1.0 to +1.0).
/// Negative values indicate risk reduction, positive values indicate risk increase.
/// </summary>
[JsonPropertyName("score")]
public required double Score { get; init; }
/// <summary>
/// Trust score before the change.
/// </summary>
[JsonPropertyName("beforeScore")]
public double? BeforeScore { get; init; }
/// <summary>
/// Trust score after the change.
/// </summary>
[JsonPropertyName("afterScore")]
public double? AfterScore { get; init; }
/// <summary>
/// Human-readable proof steps explaining the trust delta computation.
/// </summary>
[JsonPropertyName("proofSteps")]
public ImmutableArray<string> ProofSteps { get; init; } = [];
}
/// <summary>
/// Impact classification for code reachability.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ReachabilityImpact
{
/// <summary>
/// Reachability unchanged.
/// </summary>
Unchanged,
/// <summary>
/// Reachable code paths reduced.
/// </summary>
Reduced,
/// <summary>
/// Reachable code paths increased.
/// </summary>
Increased,
/// <summary>
/// All vulnerable paths eliminated.
/// </summary>
Eliminated,
/// <summary>
/// New reachable paths introduced.
/// </summary>
Introduced
}
/// <summary>
/// Impact classification for exploitability.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ExploitabilityImpact
{
/// <summary>
/// Exploitability unchanged.
/// </summary>
Unchanged,
/// <summary>
/// Exploitability decreased.
/// </summary>
Down,
/// <summary>
/// Exploitability increased.
/// </summary>
Up,
/// <summary>
/// Vulnerability eliminated.
/// </summary>
Eliminated,
/// <summary>
/// New vulnerability introduced.
/// </summary>
Introduced
}

View File

@@ -0,0 +1,22 @@
using StellaOps.Scanner.ChangeTrace.Scoring;
namespace StellaOps.Scanner.ChangeTrace.Proofs;
/// <summary>
/// Generates human-readable proof steps for trust delta calculations.
/// Proof steps explain how the trust delta was computed and why.
/// </summary>
public interface ILatticeProofGenerator
{
/// <summary>
/// Generate proof steps explaining how the trust delta was computed.
/// </summary>
/// <param name="context">Trust delta calculation context.</param>
/// <param name="delta">Computed delta value.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>List of human-readable proof steps.</returns>
Task<IReadOnlyList<string>> GenerateAsync(
TrustDeltaContext context,
double delta,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,196 @@
using System.Globalization;
using StellaOps.Scanner.ChangeTrace.Integration;
using StellaOps.Scanner.ChangeTrace.Scoring;
namespace StellaOps.Scanner.ChangeTrace.Proofs;
/// <summary>
/// Generates human-readable proof steps for trust delta calculations.
/// </summary>
public sealed class LatticeProofGenerator : ILatticeProofGenerator
{
private readonly IVexLensClient _vexLens;
/// <summary>
/// Create a new lattice proof generator.
/// </summary>
/// <param name="vexLens">VexLens client for advisory information.</param>
public LatticeProofGenerator(IVexLensClient vexLens)
{
_vexLens = vexLens ?? throw new ArgumentNullException(nameof(vexLens));
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> GenerateAsync(
TrustDeltaContext context,
double delta,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
var steps = new List<string>();
// Step 1: CVE context - what vulnerabilities affect this package
if (context.CveIds?.Count > 0)
{
foreach (var cve in context.CveIds.Take(3))
{
var advisory = await _vexLens.GetAdvisoryAsync(cve, ct).ConfigureAwait(false);
if (advisory is not null)
{
var affectedInfo = advisory.AffectedFunctions?.Count > 0
? string.Join(", ", advisory.AffectedFunctions.Take(2))
: "package code";
steps.Add(string.Format(
CultureInfo.InvariantCulture,
"{0} affects {1}",
cve,
affectedInfo));
}
else
{
steps.Add(string.Format(
CultureInfo.InvariantCulture,
"{0} referenced for {1}",
cve,
context.Purl));
}
}
if (context.CveIds.Count > 3)
{
steps.Add(string.Format(
CultureInfo.InvariantCulture,
"... and {0} more CVEs",
context.CveIds.Count - 3));
}
}
// Step 2: Version change context
if (!string.IsNullOrEmpty(context.FromVersion) && !string.IsNullOrEmpty(context.ToVersion))
{
if (context.FromVersion == context.ToVersion)
{
steps.Add(string.Format(
CultureInfo.InvariantCulture,
"Rebuilt at version {0}",
context.ToVersion));
}
else if (string.IsNullOrEmpty(context.FromVersion))
{
steps.Add(string.Format(
CultureInfo.InvariantCulture,
"Added at version {0}",
context.ToVersion));
}
else
{
steps.Add(string.Format(
CultureInfo.InvariantCulture,
"Version changed: {0} -> {1}",
context.FromVersion,
context.ToVersion));
}
}
// Step 3: Patch verification evidence
if (context.PatchVerificationConfidence.HasValue)
{
var confidence = context.PatchVerificationConfidence.Value;
var method = confidence >= 0.9 ? "CFG hash match"
: confidence >= 0.75 ? "instruction hash match"
: confidence >= 0.5 ? "section match"
: "heuristic match";
steps.Add(string.Format(
CultureInfo.InvariantCulture,
"Patch verified via {0}: {1:P0} confidence",
method,
confidence));
}
// Step 4: Symbol similarity evidence
if (context.SymbolMatchSimilarity.HasValue)
{
steps.Add(string.Format(
CultureInfo.InvariantCulture,
"Symbol similarity: {0:P0}",
context.SymbolMatchSimilarity.Value));
}
// Step 5: Reachability analysis
if (context.ReachableCallPathsBefore.HasValue && context.ReachableCallPathsAfter.HasValue)
{
var before = context.ReachableCallPathsBefore.Value;
var after = context.ReachableCallPathsAfter.Value;
if (before == 0 && after == 0)
{
steps.Add("Code path unreachable (before and after)");
}
else if (before > 0 && after == 0)
{
steps.Add(string.Format(
CultureInfo.InvariantCulture,
"Reachable call paths: {0} -> 0 (eliminated)",
before));
}
else if (before == 0 && after > 0)
{
steps.Add(string.Format(
CultureInfo.InvariantCulture,
"Reachable call paths: 0 -> {0} (introduced)",
after));
}
else
{
steps.Add(string.Format(
CultureInfo.InvariantCulture,
"Reachable call paths: {0} -> {1}",
before,
after));
}
}
// Step 6: Attestation presence
if (context.HasDsseAttestation)
{
if (context.IssuerAuthorityScore.HasValue)
{
steps.Add(string.Format(
CultureInfo.InvariantCulture,
"DSSE attestation present (issuer authority: {0:P0})",
context.IssuerAuthorityScore.Value));
}
else
{
steps.Add("DSSE attestation present");
}
}
// Step 7: Runtime confirmation
if (context.RuntimeConfirmationConfidence.HasValue)
{
steps.Add(string.Format(
CultureInfo.InvariantCulture,
"Runtime confirmation: {0:P0} confidence",
context.RuntimeConfirmationConfidence.Value));
}
// Step 8: Final verdict
var verdict = delta switch
{
<= -0.3 => "risk_down",
>= 0.3 => "risk_up",
_ => "neutral"
};
steps.Add(string.Format(
CultureInfo.InvariantCulture,
"Verdict: {0} ({1:+0.00;-0.00;0.00})",
verdict,
delta));
return steps;
}
}

View File

@@ -0,0 +1,34 @@
using StellaOps.Scanner.ChangeTrace.Models;
namespace StellaOps.Scanner.ChangeTrace.Scoring;
/// <summary>
/// Calculates trust delta between two artifact versions.
/// Uses VexLens for consensus scoring and ReachGraph for reachability analysis.
/// </summary>
public interface ITrustDeltaCalculator
{
/// <summary>
/// Calculate trust delta for a package change.
/// </summary>
/// <param name="context">Context containing package version information.</param>
/// <param name="options">Calculation options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Calculated trust delta with proof steps.</returns>
Task<TrustDelta> CalculateAsync(
TrustDeltaContext context,
TrustDeltaOptions? options = null,
CancellationToken ct = default);
/// <summary>
/// Calculate aggregate trust delta for all changes in a trace.
/// </summary>
/// <param name="contexts">Contexts for each package change.</param>
/// <param name="options">Calculation options.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Aggregate trust delta.</returns>
Task<TrustDelta> CalculateAggregateAsync(
IEnumerable<TrustDeltaContext> contexts,
TrustDeltaOptions? options = null,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,265 @@
using System.Collections.Immutable;
using StellaOps.Scanner.ChangeTrace.Integration;
using StellaOps.Scanner.ChangeTrace.Models;
using StellaOps.Scanner.ChangeTrace.Proofs;
namespace StellaOps.Scanner.ChangeTrace.Scoring;
/// <summary>
/// Calculates trust delta between two artifact versions.
/// Implements the trust-delta formula:
/// TrustDelta = (AfterTrust - BeforeTrust) / max(BeforeTrust, 0.01)
/// </summary>
public sealed class TrustDeltaCalculator : ITrustDeltaCalculator
{
private readonly IVexLensClient _vexLens;
private readonly IReachGraphClient? _reachGraph;
private readonly ILatticeProofGenerator _proofGenerator;
/// <summary>
/// Create a new trust delta calculator.
/// </summary>
/// <param name="vexLens">VexLens client for consensus scores.</param>
/// <param name="proofGenerator">Proof step generator.</param>
/// <param name="reachGraph">Optional ReachGraph client for reachability data.</param>
public TrustDeltaCalculator(
IVexLensClient vexLens,
ILatticeProofGenerator proofGenerator,
IReachGraphClient? reachGraph = null)
{
_vexLens = vexLens ?? throw new ArgumentNullException(nameof(vexLens));
_proofGenerator = proofGenerator ?? throw new ArgumentNullException(nameof(proofGenerator));
_reachGraph = reachGraph;
}
/// <inheritdoc />
public async Task<TrustDelta> CalculateAsync(
TrustDeltaContext context,
TrustDeltaOptions? options = null,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
options ??= TrustDeltaOptions.Default;
// Get VEX consensus for both versions
var beforeConsensus = await _vexLens.GetConsensusAsync(
context.Purl, context.FromVersion, ct).ConfigureAwait(false);
var afterConsensus = await _vexLens.GetConsensusAsync(
context.Purl, context.ToVersion, ct).ConfigureAwait(false);
// Get reachability data if available
var reachBefore = context.ReachableCallPathsBefore;
var reachAfter = context.ReachableCallPathsAfter;
// Enrich from ReachGraph if client available and image digests provided
if (_reachGraph is not null)
{
if (context.FromImageDigest is not null && !reachBefore.HasValue)
{
var reachResult = await _reachGraph.GetReachabilityAsync(
context.FromImageDigest, context.Purl, ct).ConfigureAwait(false);
reachBefore = reachResult.ReachableCallPaths;
}
if (context.ToImageDigest is not null && !reachAfter.HasValue)
{
var reachResult = await _reachGraph.GetReachabilityAsync(
context.ToImageDigest, context.Purl, ct).ConfigureAwait(false);
reachAfter = reachResult.ReachableCallPaths;
}
}
// Get reachability factors
var beforeReach = ComputeReachabilityFactor(reachBefore, options);
var afterReach = ComputeReachabilityFactor(reachAfter, options);
// Compute before/after trust
var beforeTrust = beforeConsensus.TrustScore * beforeReach;
var afterTrust = afterConsensus.TrustScore * afterReach;
// Add patch verification bonus
var patchBonus = ComputePatchVerificationBonus(context, options);
afterTrust += patchBonus;
// Clamp trust values to [0, 1]
beforeTrust = Math.Clamp(beforeTrust, 0.0, 1.0);
afterTrust = Math.Clamp(afterTrust, 0.0, 1.0);
// Compute delta using the formula
// Semantics: negative delta = risk down (improvement), positive delta = risk up (regression)
// Therefore: delta = (before - after) / max(before, min_denom)
var delta = (beforeTrust - afterTrust) / Math.Max(beforeTrust, options.MinTrustDenominator);
delta = Math.Clamp(delta, -1.0, 1.0);
// Determine impacts
var reachabilityImpact = DetermineReachabilityImpact(reachBefore, reachAfter);
var exploitabilityImpact = DetermineExploitabilityImpact(delta, options);
// Generate proof steps
var proofSteps = await _proofGenerator.GenerateAsync(context, delta, ct).ConfigureAwait(false);
return new TrustDelta
{
ReachabilityImpact = reachabilityImpact,
ExploitabilityImpact = exploitabilityImpact,
Score = Math.Round(delta, 2),
BeforeScore = Math.Round(beforeTrust, 2),
AfterScore = Math.Round(afterTrust, 2),
ProofSteps = proofSteps.ToImmutableArray()
};
}
/// <inheritdoc />
public async Task<TrustDelta> CalculateAggregateAsync(
IEnumerable<TrustDeltaContext> contexts,
TrustDeltaOptions? options = null,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(contexts);
options ??= TrustDeltaOptions.Default;
var contextList = contexts.ToList();
if (contextList.Count == 0)
{
return new TrustDelta
{
ReachabilityImpact = ReachabilityImpact.Unchanged,
ExploitabilityImpact = ExploitabilityImpact.Unchanged,
Score = 0.0,
ProofSteps = ["No changes to analyze"]
};
}
// Calculate individual deltas
var deltas = new List<TrustDelta>();
foreach (var context in contextList)
{
var delta = await CalculateAsync(context, options, ct).ConfigureAwait(false);
deltas.Add(delta);
}
// Aggregate scores (weighted average by absolute value for significance)
var totalWeight = 0.0;
var weightedSum = 0.0;
var allProofSteps = new List<string>();
foreach (var delta in deltas)
{
var weight = Math.Abs(delta.Score) + 0.1; // Minimum weight to include all
totalWeight += weight;
weightedSum += delta.Score * weight;
allProofSteps.AddRange(delta.ProofSteps);
}
var aggregateScore = totalWeight > 0 ? weightedSum / totalWeight : 0.0;
aggregateScore = Math.Clamp(aggregateScore, -1.0, 1.0);
// Determine aggregate impacts
var aggregateReachability = AggregateReachabilityImpact(deltas);
var aggregateExploitability = DetermineExploitabilityImpact(aggregateScore, options);
// Add summary proof step
allProofSteps.Add($"Aggregate of {deltas.Count} package changes");
return new TrustDelta
{
ReachabilityImpact = aggregateReachability,
ExploitabilityImpact = aggregateExploitability,
Score = Math.Round(aggregateScore, 2),
BeforeScore = Math.Round(deltas.Average(d => d.BeforeScore ?? 0.5), 2),
AfterScore = Math.Round(deltas.Average(d => d.AfterScore ?? 0.5), 2),
ProofSteps = allProofSteps.ToImmutableArray()
};
}
/// <summary>
/// Compute reachability factor for trust calculation.
/// Unreachable code gets reduced trust contribution.
/// </summary>
private static double ComputeReachabilityFactor(int? callPaths, TrustDeltaOptions options)
{
if (callPaths is null) return 1.0;
if (callPaths == 0) return options.UnreachableReductionFactor;
return 1.0;
}
/// <summary>
/// Compute patch verification bonus from context.
/// </summary>
private static double ComputePatchVerificationBonus(
TrustDeltaContext context,
TrustDeltaOptions options)
{
var bonus = 0.0;
if (context.PatchVerificationConfidence.HasValue)
{
bonus += options.FunctionMatchWeight * context.PatchVerificationConfidence.Value;
}
if (context.SymbolMatchSimilarity.HasValue)
{
bonus += options.SectionMatchWeight * context.SymbolMatchSimilarity.Value;
}
if (context.HasDsseAttestation && context.IssuerAuthorityScore.HasValue)
{
bonus += options.AttestationWeight * context.IssuerAuthorityScore.Value;
}
if (context.RuntimeConfirmationConfidence.HasValue)
{
bonus += options.RuntimeConfirmWeight * context.RuntimeConfirmationConfidence.Value;
}
return bonus;
}
/// <summary>
/// Determine reachability impact from before/after call path counts.
/// </summary>
private static ReachabilityImpact DetermineReachabilityImpact(int? before, int? after)
{
if (before is null || after is null) return ReachabilityImpact.Unchanged;
if (before == 0 && after > 0) return ReachabilityImpact.Introduced;
if (before > 0 && after == 0) return ReachabilityImpact.Eliminated;
if (after < before) return ReachabilityImpact.Reduced;
if (after > before) return ReachabilityImpact.Increased;
return ReachabilityImpact.Unchanged;
}
/// <summary>
/// Determine exploitability impact from delta value.
/// </summary>
private static ExploitabilityImpact DetermineExploitabilityImpact(
double delta,
TrustDeltaOptions options)
{
if (delta <= -options.ExploitabilityEliminatedThreshold)
return ExploitabilityImpact.Eliminated;
if (delta < -options.ExploitabilityChangeThreshold)
return ExploitabilityImpact.Down;
if (delta >= options.ExploitabilityIntroducedThreshold)
return ExploitabilityImpact.Introduced;
if (delta > options.ExploitabilityChangeThreshold)
return ExploitabilityImpact.Up;
return ExploitabilityImpact.Unchanged;
}
/// <summary>
/// Aggregate reachability impacts from multiple deltas.
/// </summary>
private static ReachabilityImpact AggregateReachabilityImpact(List<TrustDelta> deltas)
{
// Priority: Introduced > Increased > Reduced > Eliminated > Unchanged
if (deltas.Any(d => d.ReachabilityImpact == ReachabilityImpact.Introduced))
return ReachabilityImpact.Introduced;
if (deltas.Any(d => d.ReachabilityImpact == ReachabilityImpact.Increased))
return ReachabilityImpact.Increased;
if (deltas.Any(d => d.ReachabilityImpact == ReachabilityImpact.Reduced))
return ReachabilityImpact.Reduced;
if (deltas.Any(d => d.ReachabilityImpact == ReachabilityImpact.Eliminated))
return ReachabilityImpact.Eliminated;
return ReachabilityImpact.Unchanged;
}
}

View File

@@ -0,0 +1,75 @@
namespace StellaOps.Scanner.ChangeTrace.Scoring;
/// <summary>
/// Context for trust delta calculation.
/// Contains all input data needed to compute trust delta between two versions.
/// </summary>
public sealed record TrustDeltaContext
{
/// <summary>
/// Package URL (PURL) identifier.
/// </summary>
public required string Purl { get; init; }
/// <summary>
/// Version before the change.
/// </summary>
public required string FromVersion { get; init; }
/// <summary>
/// Version after the change.
/// </summary>
public required string ToVersion { get; init; }
/// <summary>
/// CVE IDs relevant to this package.
/// </summary>
public IReadOnlyList<string>? CveIds { get; init; }
/// <summary>
/// Patch verification confidence from binary analysis (0.0 to 1.0).
/// Based on function match similarity (CFG hash, instruction hash, etc.).
/// </summary>
public double? PatchVerificationConfidence { get; init; }
/// <summary>
/// Symbol match similarity from delta signature analysis (0.0 to 1.0).
/// </summary>
public double? SymbolMatchSimilarity { get; init; }
/// <summary>
/// Whether a DSSE attestation is available for this change.
/// </summary>
public bool HasDsseAttestation { get; init; }
/// <summary>
/// Authority score of the DSSE issuer (0.0 to 1.0).
/// </summary>
public double? IssuerAuthorityScore { get; init; }
/// <summary>
/// Number of reachable call paths before the change.
/// </summary>
public int? ReachableCallPathsBefore { get; init; }
/// <summary>
/// Number of reachable call paths after the change.
/// </summary>
public int? ReachableCallPathsAfter { get; init; }
/// <summary>
/// Image digest for the "before" state (for ReachGraph queries).
/// </summary>
public string? FromImageDigest { get; init; }
/// <summary>
/// Image digest for the "after" state (for ReachGraph queries).
/// </summary>
public string? ToImageDigest { get; init; }
/// <summary>
/// Runtime confirmation confidence (0.0 to 1.0).
/// From actual runtime observation of patch effectiveness.
/// </summary>
public double? RuntimeConfirmationConfidence { get; init; }
}

View File

@@ -0,0 +1,79 @@
namespace StellaOps.Scanner.ChangeTrace.Scoring;
/// <summary>
/// Options for trust delta calculation.
/// Configures weights and thresholds for the trust delta formula.
/// </summary>
public sealed record TrustDeltaOptions
{
/// <summary>
/// Default options instance.
/// </summary>
public static TrustDeltaOptions Default { get; } = new();
/// <summary>
/// Weight for function match confidence in patch verification bonus.
/// Default: 0.25 (25% contribution).
/// </summary>
public double FunctionMatchWeight { get; init; } = 0.25;
/// <summary>
/// Weight for section match confidence in patch verification bonus.
/// Default: 0.15 (15% contribution).
/// </summary>
public double SectionMatchWeight { get; init; } = 0.15;
/// <summary>
/// Weight for DSSE attestation presence in patch verification bonus.
/// Default: 0.10 (10% contribution).
/// </summary>
public double AttestationWeight { get; init; } = 0.10;
/// <summary>
/// Weight for runtime confirmation in patch verification bonus.
/// Default: 0.10 (10% contribution).
/// </summary>
public double RuntimeConfirmWeight { get; init; } = 0.10;
/// <summary>
/// Threshold for considering delta significant.
/// |delta| >= threshold => risk_up or risk_down verdict.
/// Default: 0.3 (30% change).
/// </summary>
public double SignificantDeltaThreshold { get; init; } = 0.3;
/// <summary>
/// Minimum trust value to use as denominator in delta calculation.
/// Prevents division by zero and very large delta values.
/// Default: 0.01.
/// </summary>
public double MinTrustDenominator { get; init; } = 0.01;
/// <summary>
/// Reduction factor for unreachable code paths.
/// Unreachable code contributes this fraction to trust (higher = more trust).
/// Default: 0.7 (30% reduction for unreachable).
/// </summary>
public double UnreachableReductionFactor { get; init; } = 0.7;
/// <summary>
/// Threshold for considering exploitability eliminated.
/// delta <= -threshold => exploitability eliminated.
/// Default: 0.5.
/// </summary>
public double ExploitabilityEliminatedThreshold { get; init; } = 0.5;
/// <summary>
/// Threshold for considering exploitability introduced.
/// delta >= threshold => exploitability introduced.
/// Default: 0.5.
/// </summary>
public double ExploitabilityIntroducedThreshold { get; init; } = 0.5;
/// <summary>
/// Threshold for considering exploitability changed.
/// |delta| >= threshold => up or down.
/// Default: 0.1.
/// </summary>
public double ExploitabilityChangeThreshold { get; init; } = 0.1;
}

View File

@@ -0,0 +1,158 @@
using System.Collections.Immutable;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Canonical.Json;
using StellaOps.Scanner.ChangeTrace.Models;
namespace StellaOps.Scanner.ChangeTrace.Serialization;
/// <summary>
/// Deterministic serialization for change traces (RFC 8785 compliant).
/// </summary>
public static class ChangeTraceSerializer
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
private static readonly JsonSerializerOptions PrettyOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
/// <summary>
/// Serialize change trace to canonical JSON (RFC 8785).
/// Deltas are sorted by PURL, symbols by name, bytes by offset.
/// </summary>
/// <param name="trace">Change trace to serialize.</param>
/// <returns>Canonical JSON string.</returns>
public static string SerializeCanonical(Models.ChangeTrace trace)
{
ArgumentNullException.ThrowIfNull(trace);
var sortedTrace = SortTrace(trace);
return CanonJson.Serialize(sortedTrace, SerializerOptions);
}
/// <summary>
/// Serialize with pretty printing for human reading.
/// </summary>
/// <param name="trace">Change trace to serialize.</param>
/// <returns>Pretty-printed JSON string.</returns>
public static string SerializePretty(Models.ChangeTrace trace)
{
ArgumentNullException.ThrowIfNull(trace);
var sortedTrace = SortTrace(trace);
return JsonSerializer.Serialize(sortedTrace, PrettyOptions);
}
/// <summary>
/// Serialize to UTF-8 bytes (canonical).
/// </summary>
/// <param name="trace">Change trace to serialize.</param>
/// <returns>UTF-8 encoded canonical JSON bytes.</returns>
public static byte[] SerializeCanonicalBytes(Models.ChangeTrace trace)
{
ArgumentNullException.ThrowIfNull(trace);
var sortedTrace = SortTrace(trace);
return CanonJson.Canonicalize(sortedTrace, SerializerOptions);
}
/// <summary>
/// Deserialize change trace from JSON.
/// </summary>
/// <param name="json">JSON string to deserialize.</param>
/// <returns>Deserialized change trace, or null if invalid.</returns>
public static Models.ChangeTrace? Deserialize(string json)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
return JsonSerializer.Deserialize<Models.ChangeTrace>(json, SerializerOptions);
}
/// <summary>
/// Deserialize change trace from UTF-8 bytes.
/// </summary>
/// <param name="utf8Json">UTF-8 encoded JSON bytes.</param>
/// <returns>Deserialized change trace, or null if invalid.</returns>
public static Models.ChangeTrace? Deserialize(ReadOnlySpan<byte> utf8Json)
{
return JsonSerializer.Deserialize<Models.ChangeTrace>(utf8Json, SerializerOptions);
}
/// <summary>
/// Compute commitment hash for a change trace.
/// The commitment field itself is excluded from the hash computation.
/// </summary>
/// <param name="trace">Change trace to hash.</param>
/// <returns>SHA-256 hash as lowercase hex string.</returns>
public static string ComputeCommitmentHash(Models.ChangeTrace trace)
{
ArgumentNullException.ThrowIfNull(trace);
// Remove commitment and attestation for hash computation
var traceForHash = trace with
{
Commitment = null,
Attestation = null
};
var sortedTrace = SortTrace(traceForHash);
var canonicalBytes = CanonJson.Canonicalize(sortedTrace, SerializerOptions);
return CanonJson.Sha256Hex(canonicalBytes);
}
/// <summary>
/// Verify that a change trace's commitment hash is correct.
/// </summary>
/// <param name="trace">Change trace to verify.</param>
/// <returns>True if commitment matches computed hash.</returns>
public static bool VerifyCommitment(Models.ChangeTrace trace)
{
ArgumentNullException.ThrowIfNull(trace);
if (trace.Commitment is null)
return false;
var computed = ComputeCommitmentHash(trace);
return string.Equals(computed, trace.Commitment.Sha256, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Sort trace contents for deterministic serialization.
/// </summary>
private static Models.ChangeTrace SortTrace(Models.ChangeTrace trace)
{
return trace with
{
Deltas = trace.Deltas
.OrderBy(d => d.Purl, StringComparer.Ordinal)
.Select(SortPackageDelta)
.ToImmutableArray()
};
}
private static PackageDelta SortPackageDelta(PackageDelta delta)
{
return delta with
{
SymbolDeltas = delta.SymbolDeltas
.OrderBy(s => s.Name, StringComparer.Ordinal)
.ToImmutableArray(),
ByteDeltas = delta.ByteDeltas
.OrderBy(b => b.Offset)
.ToImmutableArray()
};
}
}

View File

@@ -0,0 +1,19 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Scanner.ChangeTrace</RootNamespace>
<Description>Change-Trace library for deterministic trust-delta visualization between binary/package versions</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../__Libraries/StellaOps.Canonical.Json/StellaOps.Canonical.Json.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,260 @@
// -----------------------------------------------------------------------------
// ChangeTraceValidator.cs
// Sprint: SPRINT_20260112_200_006_CLI_commands
// Description: Validates change trace structure and content.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.ChangeTrace.Models;
using ChangeTraceModel = StellaOps.Scanner.ChangeTrace.Models.ChangeTrace;
namespace StellaOps.Scanner.ChangeTrace.Validation;
/// <summary>
/// Result of change trace validation.
/// </summary>
public sealed record ChangeTraceValidationResult
{
/// <summary>
/// Whether the trace is valid (no errors).
/// </summary>
public required bool IsValid { get; init; }
/// <summary>
/// Validation errors (structural issues that invalidate the trace).
/// </summary>
public required IReadOnlyList<string> Errors { get; init; }
/// <summary>
/// Validation warnings (issues that don't invalidate but should be noted).
/// </summary>
public required IReadOnlyList<string> Warnings { get; init; }
/// <summary>
/// Create a successful validation result.
/// </summary>
public static ChangeTraceValidationResult Success(IReadOnlyList<string>? warnings = null)
{
return new ChangeTraceValidationResult
{
IsValid = true,
Errors = Array.Empty<string>(),
Warnings = warnings ?? Array.Empty<string>()
};
}
/// <summary>
/// Create a failed validation result.
/// </summary>
public static ChangeTraceValidationResult Failure(
IReadOnlyList<string> errors,
IReadOnlyList<string>? warnings = null)
{
return new ChangeTraceValidationResult
{
IsValid = false,
Errors = errors,
Warnings = warnings ?? Array.Empty<string>()
};
}
}
/// <summary>
/// Validates change trace structure and content.
/// </summary>
public sealed class ChangeTraceValidator
{
/// <summary>
/// Validate a change trace.
/// </summary>
/// <param name="trace">The trace to validate.</param>
/// <returns>Validation result with any errors and warnings.</returns>
public ChangeTraceValidationResult Validate(ChangeTraceModel trace)
{
ArgumentNullException.ThrowIfNull(trace);
var errors = new List<string>();
var warnings = new List<string>();
// Validate schema
if (string.IsNullOrEmpty(trace.Schema))
{
errors.Add("Missing required field: schema");
}
else if (!trace.Schema.StartsWith("stella.change-trace/", StringComparison.OrdinalIgnoreCase))
{
warnings.Add($"Non-standard schema: {trace.Schema}");
}
// Validate subject
ValidateSubject(trace.Subject, errors, warnings);
// Validate basis
ValidateBasis(trace.Basis, errors, warnings);
// Validate summary
ValidateSummary(trace.Summary, errors, warnings);
// Validate deltas
ValidateDeltas(trace.Deltas, errors, warnings);
// Validate commitment if present
if (trace.Commitment is not null)
{
ValidateCommitment(trace.Commitment, errors, warnings);
}
return errors.Count > 0
? ChangeTraceValidationResult.Failure(errors, warnings)
: ChangeTraceValidationResult.Success(warnings);
}
private static void ValidateSubject(
ChangeTraceSubject? subject,
List<string> errors,
List<string> warnings)
{
if (subject is null)
{
errors.Add("Missing required field: subject");
return;
}
if (string.IsNullOrEmpty(subject.Type))
{
errors.Add("Missing required field: subject.type");
}
if (string.IsNullOrEmpty(subject.Digest))
{
errors.Add("Missing required field: subject.digest");
}
else if (!subject.Digest.Contains(':'))
{
warnings.Add("Subject digest should include algorithm prefix (e.g., sha256:...)");
}
}
private static void ValidateBasis(
ChangeTraceBasis? basis,
List<string> errors,
List<string> warnings)
{
if (basis is null)
{
errors.Add("Missing required field: basis");
return;
}
if (string.IsNullOrEmpty(basis.ScanId))
{
errors.Add("Missing required field: basis.scanId");
}
if (string.IsNullOrEmpty(basis.EngineVersion))
{
warnings.Add("Missing basis.engineVersion - reproducibility may be affected");
}
if (basis.AnalyzedAt == default)
{
warnings.Add("Missing basis.analyzedAt timestamp");
}
if (basis.DiffMethod.IsDefaultOrEmpty)
{
warnings.Add("Missing basis.diffMethod - diff methods should be specified");
}
}
private static void ValidateSummary(
ChangeTraceSummary? summary,
List<string> errors,
List<string> warnings)
{
if (summary is null)
{
errors.Add("Missing required field: summary");
return;
}
if (summary.ChangedPackages < 0)
{
errors.Add("Invalid summary.changedPackages: must be non-negative");
}
if (summary.ChangedSymbols < 0)
{
errors.Add("Invalid summary.changedSymbols: must be non-negative");
}
if (summary.ChangedBytes < 0)
{
errors.Add("Invalid summary.changedBytes: must be non-negative");
}
if (summary.RiskDelta < -1.0 || summary.RiskDelta > 1.0)
{
warnings.Add($"Unusual riskDelta value: {summary.RiskDelta} (expected -1.0 to 1.0)");
}
}
private static void ValidateDeltas(
IReadOnlyList<PackageDelta> deltas,
List<string> errors,
List<string> warnings)
{
if (deltas is null || deltas.Count == 0)
{
// Empty deltas is valid - just means no changes
return;
}
for (var i = 0; i < deltas.Count; i++)
{
var delta = deltas[i];
if (string.IsNullOrEmpty(delta.Purl))
{
errors.Add($"Missing purl for delta at index {i}");
}
if (string.IsNullOrEmpty(delta.FromVersion) && string.IsNullOrEmpty(delta.ToVersion))
{
warnings.Add($"Delta at index {i} has neither fromVersion nor toVersion");
}
if (delta.Evidence is not null)
{
if (delta.Evidence.Confidence < 0 || delta.Evidence.Confidence > 1)
{
warnings.Add($"Invalid confidence value for delta at index {i}: {delta.Evidence.Confidence}");
}
}
if (delta.TrustDelta is not null)
{
if (delta.TrustDelta.Score < -1.0 || delta.TrustDelta.Score > 1.0)
{
warnings.Add($"Unusual trust delta score for delta at index {i}: {delta.TrustDelta.Score}");
}
}
}
}
private static void ValidateCommitment(
ChangeTraceCommitment commitment,
List<string> errors,
List<string> warnings)
{
if (string.IsNullOrEmpty(commitment.Sha256))
{
errors.Add("Missing required field: commitment.sha256");
}
if (string.IsNullOrEmpty(commitment.Algorithm))
{
warnings.Add("Missing commitment.algorithm - defaults assumed");
}
}
}