release orchestrator v1 draft and build fixes
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 < -0.3).
|
||||
/// </summary>
|
||||
RiskDown,
|
||||
|
||||
/// <summary>
|
||||
/// Risk change is minimal (-0.3 <= score <= 0.3).
|
||||
/// </summary>
|
||||
Neutral,
|
||||
|
||||
/// <summary>
|
||||
/// Risk has increased (score > 0.3).
|
||||
/// </summary>
|
||||
RiskUp,
|
||||
|
||||
/// <summary>
|
||||
/// Unable to determine risk impact.
|
||||
/// </summary>
|
||||
Inconclusive
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user