audit, advisories and doctors/setup work

This commit is contained in:
master
2026-01-13 18:53:39 +02:00
parent 9ca7cb183e
commit d7be6ba34b
811 changed files with 54242 additions and 4056 deletions

View File

@@ -0,0 +1,317 @@
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Collections.Immutable;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Tools.GoldenPairs.Models;
using StellaOps.Tools.GoldenPairs.Serialization;
using StellaOps.Tools.GoldenPairs.Services;
namespace StellaOps.Tools.GoldenPairs;
public static class GoldenPairsApp
{
public static Task<int> RunAsync(string[] args)
{
var repoRootOption = new Option<DirectoryInfo?>("--repo-root")
{
Description = "Repository root (defaults to nearest folder containing src/StellaOps.sln)."
};
var datasetRootOption = new Option<DirectoryInfo?>("--dataset-root")
{
Description = "Dataset root (defaults to datasets/golden-pairs under repo root)."
};
var root = new RootCommand("Golden pairs corpus tooling.");
root.AddGlobalOption(repoRootOption);
root.AddGlobalOption(datasetRootOption);
root.AddCommand(BuildMirrorCommand(repoRootOption, datasetRootOption));
root.AddCommand(BuildDiffCommand(repoRootOption, datasetRootOption));
root.AddCommand(BuildValidateCommand(repoRootOption, datasetRootOption));
return root.InvokeAsync(args);
}
private static Command BuildMirrorCommand(Option<DirectoryInfo?> repoRootOption, Option<DirectoryInfo?> datasetRootOption)
{
var cveArgument = new Argument<string>("cve", "CVE identifier to mirror.");
var command = new Command("mirror", "Fetch artifacts for a golden pair.");
command.AddArgument(cveArgument);
command.SetAction(async (parseResult, cancellationToken) =>
{
var context = ResolveContext(parseResult, repoRootOption, datasetRootOption);
if (context is null)
{
return 2;
}
using var provider = context.Provider;
var loader = provider.GetRequiredService<GoldenPairLoader>();
var mirror = provider.GetRequiredService<IPackageMirrorService>();
var layout = provider.GetRequiredService<GoldenPairLayout>();
var cve = parseResult.GetValue(cveArgument) ?? string.Empty;
var loadResult = await loader.LoadAsync(cve, cancellationToken).ConfigureAwait(false);
if (!loadResult.IsValid || loadResult.Metadata is null)
{
return ReportLoadErrors(loadResult.Errors);
}
var metadata = loadResult.Metadata;
Directory.CreateDirectory(layout.GetOriginalDirectory(metadata.Cve));
Directory.CreateDirectory(layout.GetPatchedDirectory(metadata.Cve));
var originalResult = await mirror.FetchAsync(metadata.Original, layout.GetOriginalDirectory(metadata.Cve), cancellationToken)
.ConfigureAwait(false);
if (!originalResult.Success)
{
Console.Error.WriteLine($"[FAIL] Original mirror failed: {originalResult.ErrorMessage}");
return 1;
}
var originalPath = EnsureArtifactNamed(metadata, layout.GetOriginalDirectory(metadata.Cve), originalResult.LocalPath);
WriteShaFile(originalPath, originalResult.ActualSha256);
var patchedResult = await mirror.FetchAsync(metadata.Patched, layout.GetPatchedDirectory(metadata.Cve), cancellationToken)
.ConfigureAwait(false);
if (!patchedResult.Success)
{
Console.Error.WriteLine($"[FAIL] Patched mirror failed: {patchedResult.ErrorMessage}");
return 1;
}
var patchedPath = EnsureArtifactNamed(metadata, layout.GetPatchedDirectory(metadata.Cve), patchedResult.LocalPath);
WriteShaFile(patchedPath, patchedResult.ActualSha256);
Console.WriteLine($"[OK] Mirrored {metadata.Cve} into {layout.GetPairDirectory(metadata.Cve)}");
return 0;
});
return command;
}
private static Command BuildDiffCommand(Option<DirectoryInfo?> repoRootOption, Option<DirectoryInfo?> datasetRootOption)
{
var cveArgument = new Argument<string>("cve", "CVE identifier to diff.");
var command = new Command("diff", "Run diff analysis on a golden pair.");
command.AddArgument(cveArgument);
var outputOption = new Option<string>("--output")
{
Description = "Output format: json or table.",
DefaultValueFactory = _ => "json"
};
command.AddOption(outputOption);
command.SetAction(async (parseResult, cancellationToken) =>
{
var context = ResolveContext(parseResult, repoRootOption, datasetRootOption);
if (context is null)
{
return 2;
}
using var provider = context.Provider;
var loader = provider.GetRequiredService<GoldenPairLoader>();
var diff = provider.GetRequiredService<IDiffPipelineService>();
var layout = provider.GetRequiredService<GoldenPairLayout>();
var cve = parseResult.GetValue(cveArgument) ?? string.Empty;
var loadResult = await loader.LoadAsync(cve, cancellationToken).ConfigureAwait(false);
if (!loadResult.IsValid || loadResult.Metadata is null)
{
return ReportLoadErrors(loadResult.Errors);
}
GoldenDiffReport report;
try
{
report = await diff.DiffAsync(loadResult.Metadata, cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[FAIL] Diff failed: {ex.Message}");
return 1;
}
var reportJson = GoldenPairsJsonSerializer.SerializeIndented(report);
var reportPath = layout.GetDiffReportPath(loadResult.Metadata.Cve);
Directory.CreateDirectory(Path.GetDirectoryName(reportPath)!);
await File.WriteAllTextAsync(reportPath, reportJson, cancellationToken).ConfigureAwait(false);
var output = parseResult.GetValue(outputOption) ?? "json";
if (string.Equals(output, "table", StringComparison.OrdinalIgnoreCase))
{
WriteTableReport(report);
}
else
{
Console.WriteLine(reportJson);
}
Console.WriteLine($"[OK] Diff report written to {reportPath}");
return report.MatchesExpected ? 0 : 1;
});
return command;
}
private static Command BuildValidateCommand(Option<DirectoryInfo?> repoRootOption, Option<DirectoryInfo?> datasetRootOption)
{
var command = new Command("validate", "Validate all golden pairs in the corpus.");
var failFastOption = new Option<bool>("--fail-fast")
{
Description = "Stop at first failure."
};
command.AddOption(failFastOption);
command.SetAction(async (parseResult, cancellationToken) =>
{
var context = ResolveContext(parseResult, repoRootOption, datasetRootOption);
if (context is null)
{
return 2;
}
using var provider = context.Provider;
var loader = provider.GetRequiredService<GoldenPairLoader>();
var diff = provider.GetRequiredService<IDiffPipelineService>();
var layout = provider.GetRequiredService<GoldenPairLayout>();
var failFast = parseResult.GetValue(failFastOption);
var pairDirectories = Directory.EnumerateDirectories(layout.DatasetRoot, "CVE-*", SearchOption.TopDirectoryOnly)
.OrderBy(path => path, StringComparer.Ordinal)
.ToArray();
var failures = 0;
foreach (var pairDir in pairDirectories)
{
var metadataPath = Path.Combine(pairDir, GoldenPairLayout.DefaultMetadataFileName);
var loadResult = await loader.LoadFromPathAsync(metadataPath, cancellationToken).ConfigureAwait(false);
if (!loadResult.IsValid || loadResult.Metadata is null)
{
failures++;
Console.Error.WriteLine($"[FAIL] {Path.GetFileName(pairDir)}: metadata invalid.");
if (failFast)
{
return 1;
}
continue;
}
try
{
var report = await diff.DiffAsync(loadResult.Metadata, cancellationToken: cancellationToken).ConfigureAwait(false);
var reportJson = GoldenPairsJsonSerializer.SerializeIndented(report);
await File.WriteAllTextAsync(layout.GetDiffReportPath(loadResult.Metadata.Cve), reportJson, cancellationToken)
.ConfigureAwait(false);
if (report.MatchesExpected)
{
Console.WriteLine($"[OK] {loadResult.Metadata.Cve} validated.");
}
else
{
failures++;
Console.Error.WriteLine($"[FAIL] {loadResult.Metadata.Cve} mismatch.");
if (failFast)
{
return 1;
}
}
}
catch (Exception ex)
{
failures++;
Console.Error.WriteLine($"[FAIL] {loadResult.Metadata.Cve}: {ex.Message}");
if (failFast)
{
return 1;
}
}
}
Console.WriteLine($"Summary: {pairDirectories.Length - failures}/{pairDirectories.Length} passed.");
return failures == 0 ? 0 : 1;
});
return command;
}
private static GoldenPairsContext? ResolveContext(
ParseResult parseResult,
Option<DirectoryInfo?> repoRootOption,
Option<DirectoryInfo?> datasetRootOption)
{
var repoRoot = parseResult.GetValue(repoRootOption)?.FullName;
var datasetRoot = parseResult.GetValue(datasetRootOption)?.FullName;
var resolvedRepoRoot = GoldenPairsPaths.TryResolveRepoRoot(repoRoot);
if (resolvedRepoRoot is null)
{
Console.Error.WriteLine("[FAIL] Unable to resolve repo root. Provide --repo-root explicitly.");
return null;
}
var resolvedDatasetRoot = GoldenPairsPaths.ResolveDatasetRoot(resolvedRepoRoot, datasetRoot);
var metadataSchemaPath = GoldenPairsPaths.ResolveMetadataSchemaPath(resolvedRepoRoot);
var indexSchemaPath = GoldenPairsPaths.ResolveIndexSchemaPath(resolvedRepoRoot);
var provider = GoldenPairsServiceFactory.Build(resolvedDatasetRoot, metadataSchemaPath, indexSchemaPath);
return new GoldenPairsContext(resolvedRepoRoot, resolvedDatasetRoot, provider);
}
private static int ReportLoadErrors(ImmutableArray<GoldenPairLoadError> errors)
{
foreach (var error in errors)
{
var location = string.IsNullOrWhiteSpace(error.InstanceLocation) ? string.Empty : $" ({error.InstanceLocation})";
Console.Error.WriteLine($"[FAIL] {error.Message}{location}");
}
return 1;
}
private static string EnsureArtifactNamed(GoldenPairMetadata metadata, string destinationDirectory, string currentPath)
{
var expectedPath = Path.Combine(destinationDirectory, metadata.Artifact.Name);
if (string.Equals(currentPath, expectedPath, StringComparison.Ordinal))
{
return currentPath;
}
if (File.Exists(expectedPath))
{
File.Delete(expectedPath);
}
File.Move(currentPath, expectedPath);
return expectedPath;
}
private static void WriteShaFile(string filePath, string sha256)
{
var shaPath = $"{filePath}.sha256";
File.WriteAllText(shaPath, $"{sha256}\n", Encoding.ASCII);
}
private static void WriteTableReport(GoldenDiffReport report)
{
Console.WriteLine($"CVE: {report.Cve}");
Console.WriteLine($"Verdict: {report.Verdict.ToString().ToLowerInvariant()} ({report.Confidence:F2})");
Console.WriteLine($"Matches expected: {report.MatchesExpected}");
Console.WriteLine("Sections:");
foreach (var section in report.Sections)
{
Console.WriteLine($" {section.Name} - {section.Status.ToString().ToLowerInvariant()}");
}
}
private sealed record GoldenPairsContext(
string RepoRoot,
string DatasetRoot,
ServiceProvider Provider);
}

View File

@@ -0,0 +1,62 @@
using System.Collections.Immutable;
namespace StellaOps.Tools.GoldenPairs.Models;
public enum SectionComparisonStatus
{
Identical,
Modified,
Added,
Removed
}
/// <summary>
/// Report from comparing a golden pair.
/// </summary>
public sealed record GoldenDiffReport
{
/// <summary>CVE being analyzed.</summary>
public required string Cve { get; init; }
/// <summary>Original binary info.</summary>
public required ArtifactHashInfo Original { get; init; }
/// <summary>Patched binary info.</summary>
public required ArtifactHashInfo Patched { get; init; }
/// <summary>Section-by-section comparison.</summary>
public required ImmutableArray<SectionComparison> Sections { get; init; }
/// <summary>Overall verdict.</summary>
public required GoldenDiffVerdict Verdict { get; init; }
/// <summary>Confidence score (0.0-1.0).</summary>
public required double Confidence { get; init; }
/// <summary>Whether result matches expected.</summary>
public required bool MatchesExpected { get; init; }
/// <summary>Discrepancies from expected (if any).</summary>
public ImmutableArray<string> Discrepancies { get; init; } = [];
/// <summary>Analysis timestamp.</summary>
public required DateTimeOffset AnalyzedAt { get; init; }
/// <summary>Tool version.</summary>
public required string ToolVersion { get; init; }
}
public sealed record ArtifactHashInfo
{
public required string Sha256 { get; init; }
public string? BuildId { get; init; }
}
public sealed record SectionComparison
{
public required string Name { get; init; }
public required SectionComparisonStatus Status { get; init; }
public string? OriginalHash { get; init; }
public string? PatchedHash { get; init; }
public long? SizeDelta { get; init; }
}

View File

@@ -0,0 +1,172 @@
using System.Collections.Immutable;
namespace StellaOps.Tools.GoldenPairs.Models;
public enum SeverityLevel
{
Critical,
High,
Medium,
Low
}
public enum BinaryFormat
{
Elf,
Pe,
Macho
}
public enum GoldenDiffVerdict
{
Patched,
Vanilla,
Unknown
}
/// <summary>
/// Metadata for a golden pair (stock vs patched binary).
/// </summary>
public sealed record GoldenPairMetadata
{
/// <summary>CVE identifier (e.g., "CVE-2022-0847").</summary>
public required string Cve { get; init; }
/// <summary>Human-readable vulnerability name.</summary>
public required string Name { get; init; }
/// <summary>Brief description of the vulnerability.</summary>
public string? Description { get; init; }
/// <summary>Severity level.</summary>
public required SeverityLevel Severity { get; init; }
/// <summary>Target artifact information.</summary>
public required ArtifactInfo Artifact { get; init; }
/// <summary>Original (unpatched) binary.</summary>
public required BinaryArtifact Original { get; init; }
/// <summary>Patched binary.</summary>
public required BinaryArtifact Patched { get; init; }
/// <summary>Patch commit/change information.</summary>
public required PatchInfo Patch { get; init; }
/// <summary>Security advisories for this CVE.</summary>
public ImmutableArray<AdvisoryRef> Advisories { get; init; } = [];
/// <summary>Expected diff results for validation.</summary>
public required ExpectedDiff ExpectedDiff { get; init; }
/// <summary>When this pair was created.</summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>Tool version that created this pair.</summary>
public required string CreatedBy { get; init; }
}
/// <summary>
/// Information about the target artifact.
/// </summary>
public sealed record ArtifactInfo
{
/// <summary>Artifact name (e.g., "vmlinux", "sudo", "spoolsv.dll").</summary>
public required string Name { get; init; }
/// <summary>Binary format (elf, pe, macho).</summary>
public required BinaryFormat Format { get; init; }
/// <summary>CPU architecture (x86_64, aarch64, etc.).</summary>
public required string Architecture { get; init; }
/// <summary>Operating system (linux, windows, darwin).</summary>
public string Os { get; init; } = "linux";
}
/// <summary>
/// A binary artifact in the golden pair.
/// </summary>
public sealed record BinaryArtifact
{
/// <summary>Package name (e.g., "linux-image-5.16.11-generic").</summary>
public required string Package { get; init; }
/// <summary>Package version.</summary>
public required string Version { get; init; }
/// <summary>Distribution (e.g., "Ubuntu 22.04", "Debian 11").</summary>
public required string Distro { get; init; }
/// <summary>Package source (apt://, https://, file://).</summary>
public required string Source { get; init; }
/// <summary>SHA-256 hash of the binary.</summary>
public required string Sha256 { get; init; }
/// <summary>ELF Build-ID or PE GUID (if available).</summary>
public string? BuildId { get; init; }
/// <summary>Debug symbols available.</summary>
public bool HasDebugSymbols { get; init; }
/// <summary>Path to debug symbols package.</summary>
public string? DebugSymbolsSource { get; init; }
/// <summary>Relative path within the package.</summary>
public string? PathInPackage { get; init; }
}
/// <summary>
/// Information about the security patch.
/// </summary>
public sealed record PatchInfo
{
/// <summary>Commit hash of the fix.</summary>
public required string Commit { get; init; }
/// <summary>URL to upstream commit.</summary>
public string? Upstream { get; init; }
/// <summary>Functions changed by the patch.</summary>
public ImmutableArray<string> FunctionsChanged { get; init; } = [];
/// <summary>Files changed by the patch.</summary>
public ImmutableArray<string> FilesChanged { get; init; } = [];
/// <summary>Patch summary.</summary>
public string? Summary { get; init; }
}
/// <summary>
/// Reference to a security advisory.
/// </summary>
public sealed record AdvisoryRef
{
/// <summary>Advisory source (ubuntu, debian, nvd, msrc, etc.).</summary>
public required string Source { get; init; }
/// <summary>Advisory identifier (e.g., "USN-5317-1").</summary>
public required string Id { get; init; }
/// <summary>URL to the advisory.</summary>
public required string Url { get; init; }
}
/// <summary>
/// Expected diff results for validation.
/// </summary>
public sealed record ExpectedDiff
{
/// <summary>Sections expected to be modified.</summary>
public ImmutableArray<string> SectionsChanged { get; init; } = [];
/// <summary>Sections expected to be identical.</summary>
public ImmutableArray<string> SectionsIdentical { get; init; } = [];
/// <summary>Expected verdict.</summary>
public required GoldenDiffVerdict Verdict { get; init; }
/// <summary>Minimum confidence score expected.</summary>
public double ConfidenceMin { get; init; } = 0.9;
}

View File

@@ -0,0 +1,38 @@
using System.Collections.Immutable;
namespace StellaOps.Tools.GoldenPairs.Models;
public enum GoldenPairStatus
{
Pending,
Validated,
Failed,
Draft
}
public sealed record GoldenPairsIndex
{
public required string Version { get; init; }
public required DateTimeOffset GeneratedAt { get; init; }
public required ImmutableArray<GoldenPairSummary> Pairs { get; init; }
public required GoldenPairsIndexSummary Summary { get; init; }
}
public sealed record GoldenPairSummary
{
public required string Cve { get; init; }
public required string Name { get; init; }
public required SeverityLevel Severity { get; init; }
public required BinaryFormat Format { get; init; }
public required GoldenPairStatus Status { get; init; }
public DateTimeOffset? LastValidated { get; init; }
public string? Path { get; init; }
}
public sealed record GoldenPairsIndexSummary
{
public required int Total { get; init; }
public required int Validated { get; init; }
public required int Failed { get; init; }
public required int Pending { get; init; }
}

View File

@@ -0,0 +1,20 @@
using System.Collections.Immutable;
namespace StellaOps.Tools.GoldenPairs.Models;
public sealed record SectionHashSet
{
public required string FilePath { get; init; }
public required string FileHash { get; init; }
public string? BuildId { get; init; }
public required ImmutableArray<SectionHashEntry> Sections { get; init; }
public DateTimeOffset ExtractedAt { get; init; }
public string? ExtractorVersion { get; init; }
}
public sealed record SectionHashEntry
{
public required string Name { get; init; }
public long Size { get; init; }
public required string Sha256 { get; init; }
}

View File

@@ -0,0 +1,3 @@
using StellaOps.Tools.GoldenPairs;
return await GoldenPairsApp.RunAsync(args);

View File

@@ -0,0 +1,36 @@
using Json.Schema;
namespace StellaOps.Tools.GoldenPairs.Schema;
public sealed class GoldenPairsSchemaProvider
{
private readonly string _metadataSchemaPath;
private readonly string _indexSchemaPath;
private readonly Lazy<JsonSchema> _metadataSchema;
private readonly Lazy<JsonSchema> _indexSchema;
public GoldenPairsSchemaProvider(string metadataSchemaPath, string indexSchemaPath)
{
_metadataSchemaPath = metadataSchemaPath ?? throw new ArgumentNullException(nameof(metadataSchemaPath));
_indexSchemaPath = indexSchemaPath ?? throw new ArgumentNullException(nameof(indexSchemaPath));
_metadataSchema = new Lazy<JsonSchema>(() => LoadSchema(_metadataSchemaPath));
_indexSchema = new Lazy<JsonSchema>(() => LoadSchema(_indexSchemaPath));
}
public JsonSchema MetadataSchema => _metadataSchema.Value;
public JsonSchema IndexSchema => _indexSchema.Value;
private static JsonSchema LoadSchema(string path)
{
if (!File.Exists(path))
{
throw new FileNotFoundException("Schema file not found.", path);
}
var schemaJson = File.ReadAllText(path);
return JsonSchema.FromText(schemaJson, new BuildOptions
{
SchemaRegistry = new SchemaRegistry()
});
}
}

View File

@@ -0,0 +1,78 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace StellaOps.Tools.GoldenPairs.Serialization;
public static class GoldenPairsJsonSerializer
{
private static readonly JsonSerializerOptions CompactOptions = CreateOptions(writeIndented: false);
private static readonly JsonSerializerOptions PrettyOptions = CreateOptions(writeIndented: true);
public static string Serialize<T>(T value)
=> JsonSerializer.Serialize(value, CompactOptions);
public static string SerializeIndented<T>(T value)
=> JsonSerializer.Serialize(value, PrettyOptions);
public static T Deserialize<T>(string json)
=> JsonSerializer.Deserialize<T>(json, CompactOptions)
?? throw new InvalidOperationException($"Unable to deserialize type {typeof(T).Name}.");
public static JsonSerializerOptions CreateReaderOptions()
=> CreateOptions(writeIndented: false);
private static JsonSerializerOptions CreateOptions(bool writeIndented)
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNameCaseInsensitive = true,
WriteIndented = writeIndented,
Encoder = JavaScriptEncoder.Default
};
var baselineResolver = options.TypeInfoResolver ?? new DefaultJsonTypeInfoResolver();
options.TypeInfoResolver = new DeterministicTypeInfoResolver(baselineResolver);
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: false));
return options;
}
private sealed class DeterministicTypeInfoResolver : IJsonTypeInfoResolver
{
private readonly IJsonTypeInfoResolver _inner;
public DeterministicTypeInfoResolver(IJsonTypeInfoResolver inner)
{
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
}
public JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
{
var info = _inner.GetTypeInfo(type, options);
if (info is null)
{
throw new InvalidOperationException($"Unable to resolve JsonTypeInfo for '{type}'.");
}
if (info.Kind is JsonTypeInfoKind.Object && info.Properties is { Count: > 1 })
{
var ordered = info.Properties
.OrderBy(property => property.Name, StringComparer.Ordinal)
.ToArray();
info.Properties.Clear();
foreach (var property in ordered)
{
info.Properties.Add(property);
}
}
return info;
}
}
}

View File

@@ -0,0 +1,288 @@
using System.Collections.Immutable;
using StellaOps.Tools.GoldenPairs.Models;
namespace StellaOps.Tools.GoldenPairs.Services;
public interface IDiffPipelineService
{
Task<GoldenDiffReport> DiffAsync(
GoldenPairMetadata pair,
DiffOptions? options = null,
CancellationToken cancellationToken = default);
ValidationResult Validate(GoldenDiffReport report, ExpectedDiff expected);
}
public sealed record DiffOptions
{
public ImmutableArray<string>? SectionFilter { get; init; }
public bool UsePrecomputedHashes { get; init; } = true;
public bool IncludeFunctionAnalysis { get; init; } = false;
}
public sealed record ValidationResult
{
public required bool IsValid { get; init; }
public required ImmutableArray<string> Errors { get; init; }
public required ImmutableArray<string> Warnings { get; init; }
}
public sealed class DiffPipelineService : IDiffPipelineService
{
private readonly GoldenPairLayout _layout;
private readonly ISectionHashProvider _hashProvider;
private readonly TimeProvider _timeProvider;
public DiffPipelineService(
GoldenPairLayout layout,
ISectionHashProvider hashProvider,
TimeProvider timeProvider)
{
_layout = layout ?? throw new ArgumentNullException(nameof(layout));
_hashProvider = hashProvider ?? throw new ArgumentNullException(nameof(hashProvider));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public async Task<GoldenDiffReport> DiffAsync(
GoldenPairMetadata pair,
DiffOptions? options = null,
CancellationToken cancellationToken = default)
{
if (pair is null)
{
throw new ArgumentNullException(nameof(pair));
}
options ??= new DiffOptions();
var originalHashes = await GetSectionHashesAsync(pair, isOriginal: true, options, cancellationToken)
.ConfigureAwait(false);
var patchedHashes = await GetSectionHashesAsync(pair, isOriginal: false, options, cancellationToken)
.ConfigureAwait(false);
var sections = CompareSections(originalHashes, patchedHashes, options.SectionFilter);
var (verdict, confidence) = DetermineVerdict(sections);
var report = new GoldenDiffReport
{
Cve = pair.Cve,
Original = new ArtifactHashInfo { Sha256 = pair.Original.Sha256, BuildId = pair.Original.BuildId },
Patched = new ArtifactHashInfo { Sha256 = pair.Patched.Sha256, BuildId = pair.Patched.BuildId },
Sections = sections,
Verdict = verdict,
Confidence = confidence,
MatchesExpected = false,
Discrepancies = ImmutableArray<string>.Empty,
AnalyzedAt = _timeProvider.GetUtcNow(),
ToolVersion = GoldenPairsToolVersion.Resolve()
};
var validation = Validate(report, pair.ExpectedDiff);
return report with
{
MatchesExpected = validation.IsValid,
Discrepancies = validation.Errors
};
}
public ValidationResult Validate(GoldenDiffReport report, ExpectedDiff expected)
{
var errors = new List<string>();
var warnings = new List<string>();
foreach (var section in expected.SectionsChanged)
{
var comparison = report.Sections.FirstOrDefault(s => string.Equals(s.Name, section, StringComparison.Ordinal));
if (comparison is null)
{
errors.Add($"Missing expected changed section '{section}'.");
continue;
}
if (comparison.Status == SectionComparisonStatus.Identical)
{
errors.Add($"Section '{section}' expected to change but is identical.");
}
}
foreach (var section in expected.SectionsIdentical)
{
var comparison = report.Sections.FirstOrDefault(s => string.Equals(s.Name, section, StringComparison.Ordinal));
if (comparison is null)
{
errors.Add($"Missing expected identical section '{section}'.");
continue;
}
if (comparison.Status != SectionComparisonStatus.Identical)
{
errors.Add($"Section '{section}' expected identical but is {comparison.Status.ToString().ToLowerInvariant()}.");
}
}
if (report.Verdict != expected.Verdict)
{
errors.Add($"Verdict mismatch: expected {expected.Verdict.ToString().ToLowerInvariant()} but got {report.Verdict.ToString().ToLowerInvariant()}.");
}
if (report.Confidence < expected.ConfidenceMin)
{
errors.Add($"Confidence {report.Confidence:F2} is below expected minimum {expected.ConfidenceMin:F2}.");
}
return new ValidationResult
{
IsValid = errors.Count == 0,
Errors = errors.ToImmutableArray(),
Warnings = warnings.ToImmutableArray()
};
}
private async Task<SectionHashSet> GetSectionHashesAsync(
GoldenPairMetadata pair,
bool isOriginal,
DiffOptions options,
CancellationToken cancellationToken)
{
var hashPath = isOriginal ? _layout.GetOriginalSectionHashPath(pair) : _layout.GetPatchedSectionHashPath(pair);
if (options.UsePrecomputedHashes)
{
var existing = await _hashProvider.LoadAsync(hashPath, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
return existing;
}
}
var binaryPath = isOriginal ? _layout.GetOriginalBinaryPath(pair) : _layout.GetPatchedBinaryPath(pair);
var extracted = await _hashProvider.ExtractAsync(binaryPath, cancellationToken).ConfigureAwait(false);
if (extracted is null)
{
throw new InvalidOperationException($"Section hashes not found for '{binaryPath}'.");
}
return extracted;
}
private static ImmutableArray<SectionComparison> CompareSections(
SectionHashSet original,
SectionHashSet patched,
ImmutableArray<string>? filter)
{
var filterSet = filter.HasValue
? new HashSet<string>(filter.Value, StringComparer.Ordinal)
: null;
var originalByName = original.Sections.ToDictionary(s => s.Name, StringComparer.Ordinal);
var patchedByName = patched.Sections.ToDictionary(s => s.Name, StringComparer.Ordinal);
var names = originalByName.Keys
.Concat(patchedByName.Keys)
.Distinct(StringComparer.Ordinal)
.OrderBy(name => name, StringComparer.Ordinal);
var builder = ImmutableArray.CreateBuilder<SectionComparison>();
foreach (var name in names)
{
if (filterSet is not null && !filterSet.Contains(name))
{
continue;
}
originalByName.TryGetValue(name, out var originalEntry);
patchedByName.TryGetValue(name, out var patchedEntry);
SectionComparisonStatus status;
if (originalEntry is null)
{
status = SectionComparisonStatus.Added;
}
else if (patchedEntry is null)
{
status = SectionComparisonStatus.Removed;
}
else if (string.Equals(originalEntry.Sha256, patchedEntry.Sha256, StringComparison.Ordinal))
{
status = SectionComparisonStatus.Identical;
}
else
{
status = SectionComparisonStatus.Modified;
}
builder.Add(new SectionComparison
{
Name = name,
Status = status,
OriginalHash = originalEntry?.Sha256,
PatchedHash = patchedEntry?.Sha256,
SizeDelta = ComputeSizeDelta(originalEntry, patchedEntry)
});
}
return builder.ToImmutable();
}
private static long? ComputeSizeDelta(SectionHashEntry? original, SectionHashEntry? patched)
{
if (original is null && patched is null)
{
return null;
}
if (original is null)
{
return patched!.Size;
}
if (patched is null)
{
return -original.Size;
}
return patched.Size - original.Size;
}
private static (GoldenDiffVerdict verdict, double confidence) DetermineVerdict(ImmutableArray<SectionComparison> sections)
{
var textSection = sections.FirstOrDefault(
section => string.Equals(section.Name, ".text", StringComparison.Ordinal));
if (textSection is null)
{
return (GoldenDiffVerdict.Unknown, 0.5);
}
if (textSection.Status == SectionComparisonStatus.Modified)
{
var otherChanges = sections.Count(section => section.Status == SectionComparisonStatus.Modified && section.Name != ".text");
var confidence = otherChanges > 2 ? 0.7 : 0.95;
return (GoldenDiffVerdict.Patched, confidence);
}
if (textSection.Status == SectionComparisonStatus.Identical)
{
return (GoldenDiffVerdict.Vanilla, 0.9);
}
return (GoldenDiffVerdict.Unknown, 0.5);
}
}
internal static class GoldenPairsToolVersion
{
public static string Resolve()
{
var assembly = typeof(GoldenPairsToolVersion).Assembly;
var info = assembly.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false)
.Cast<System.Reflection.AssemblyInformationalVersionAttribute>()
.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(info?.InformationalVersion))
{
return info!.InformationalVersion;
}
var version = assembly.GetName().Version;
return version is null ? "0.0.0" : version.ToString();
}
}

View File

@@ -0,0 +1,54 @@
using StellaOps.Tools.GoldenPairs.Models;
namespace StellaOps.Tools.GoldenPairs.Services;
public sealed class GoldenPairLayout
{
public const string DefaultMetadataFileName = "metadata.json";
public const string DefaultIndexFileName = "index.json";
public const string DefaultOriginalDirectoryName = "original";
public const string DefaultPatchedDirectoryName = "patched";
public const string DefaultDiffReportFileName = "diff-report.json";
public GoldenPairLayout(string datasetRoot)
{
DatasetRoot = datasetRoot ?? throw new ArgumentNullException(nameof(datasetRoot));
}
public string DatasetRoot { get; }
public string MetadataFileName { get; init; } = DefaultMetadataFileName;
public string IndexFileName { get; init; } = DefaultIndexFileName;
public string OriginalDirectoryName { get; init; } = DefaultOriginalDirectoryName;
public string PatchedDirectoryName { get; init; } = DefaultPatchedDirectoryName;
public string DiffReportFileName { get; init; } = DefaultDiffReportFileName;
public string GetPairDirectory(string cve)
=> Path.Combine(DatasetRoot, cve);
public string GetMetadataPath(string cve)
=> Path.Combine(GetPairDirectory(cve), MetadataFileName);
public string GetIndexPath()
=> Path.Combine(DatasetRoot, IndexFileName);
public string GetDiffReportPath(string cve)
=> Path.Combine(GetPairDirectory(cve), DiffReportFileName);
public string GetOriginalDirectory(string cve)
=> Path.Combine(GetPairDirectory(cve), OriginalDirectoryName);
public string GetPatchedDirectory(string cve)
=> Path.Combine(GetPairDirectory(cve), PatchedDirectoryName);
public string GetOriginalBinaryPath(GoldenPairMetadata pair)
=> Path.Combine(GetOriginalDirectory(pair.Cve), pair.Artifact.Name);
public string GetPatchedBinaryPath(GoldenPairMetadata pair)
=> Path.Combine(GetPatchedDirectory(pair.Cve), pair.Artifact.Name);
public string GetOriginalSectionHashPath(GoldenPairMetadata pair)
=> Path.Combine(GetOriginalDirectory(pair.Cve), $"{pair.Artifact.Name}.sections.json");
public string GetPatchedSectionHashPath(GoldenPairMetadata pair)
=> Path.Combine(GetPatchedDirectory(pair.Cve), $"{pair.Artifact.Name}.sections.json");
}

View File

@@ -0,0 +1,210 @@
using System.Collections.Immutable;
using System.Text.Json;
using Json.Schema;
using StellaOps.Tools.GoldenPairs.Models;
using StellaOps.Tools.GoldenPairs.Schema;
using StellaOps.Tools.GoldenPairs.Serialization;
namespace StellaOps.Tools.GoldenPairs.Services;
public sealed class GoldenPairLoader
{
private readonly GoldenPairsSchemaProvider _schemaProvider;
private readonly GoldenPairLayout _layout;
private readonly JsonSerializerOptions _serializerOptions;
public GoldenPairLoader(GoldenPairsSchemaProvider schemaProvider, GoldenPairLayout layout)
{
_schemaProvider = schemaProvider ?? throw new ArgumentNullException(nameof(schemaProvider));
_layout = layout ?? throw new ArgumentNullException(nameof(layout));
_serializerOptions = GoldenPairsJsonSerializer.CreateReaderOptions();
}
public async Task<GoldenPairLoadResult> LoadAsync(string cve, CancellationToken cancellationToken = default)
{
var path = _layout.GetMetadataPath(cve);
return await LoadFromPathAsync(path, cancellationToken).ConfigureAwait(false);
}
public async Task<GoldenPairLoadResult> LoadFromPathAsync(string path, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(path))
{
return GoldenPairLoadResult.FromError(path, "path", "Metadata path is required.");
}
if (!File.Exists(path))
{
return GoldenPairLoadResult.FromError(path, "missing", "Metadata file not found.");
}
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
JsonDocument document;
try
{
document = JsonDocument.Parse(json);
}
catch (JsonException ex)
{
return GoldenPairLoadResult.FromError(path, "invalidJson", ex.Message);
}
using (document)
{
var schemaErrors = ValidateDocument(document, _schemaProvider.MetadataSchema);
if (schemaErrors.Length > 0)
{
return new GoldenPairLoadResult(path, null, schemaErrors);
}
GoldenPairMetadata? metadata;
try
{
metadata = JsonSerializer.Deserialize<GoldenPairMetadata>(document.RootElement, _serializerOptions);
}
catch (JsonException ex)
{
return GoldenPairLoadResult.FromError(path, "deserialize", ex.Message);
}
if (metadata is null)
{
return GoldenPairLoadResult.FromError(path, "deserialize", "Unable to deserialize metadata.");
}
metadata = GoldenPairNormalizer.Normalize(metadata);
return new GoldenPairLoadResult(path, metadata, ImmutableArray<GoldenPairLoadError>.Empty);
}
}
public async Task<GoldenPairsIndexLoadResult> LoadIndexAsync(CancellationToken cancellationToken = default)
=> await LoadIndexFromPathAsync(_layout.GetIndexPath(), cancellationToken).ConfigureAwait(false);
public async Task<GoldenPairsIndexLoadResult> LoadIndexFromPathAsync(string path, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(path))
{
return GoldenPairsIndexLoadResult.FromError(path, "path", "Index path is required.");
}
if (!File.Exists(path))
{
return GoldenPairsIndexLoadResult.FromError(path, "missing", "Index file not found.");
}
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
JsonDocument document;
try
{
document = JsonDocument.Parse(json);
}
catch (JsonException ex)
{
return GoldenPairsIndexLoadResult.FromError(path, "invalidJson", ex.Message);
}
using (document)
{
var schemaErrors = ValidateDocument(document, _schemaProvider.IndexSchema);
if (schemaErrors.Length > 0)
{
return new GoldenPairsIndexLoadResult(path, null, schemaErrors);
}
GoldenPairsIndex? index;
try
{
index = JsonSerializer.Deserialize<GoldenPairsIndex>(document.RootElement, _serializerOptions);
}
catch (JsonException ex)
{
return GoldenPairsIndexLoadResult.FromError(path, "deserialize", ex.Message);
}
if (index is null)
{
return GoldenPairsIndexLoadResult.FromError(path, "deserialize", "Unable to deserialize index.");
}
return new GoldenPairsIndexLoadResult(path, index, ImmutableArray<GoldenPairLoadError>.Empty);
}
}
private static ImmutableArray<GoldenPairLoadError> ValidateDocument(JsonDocument document, JsonSchema schema)
{
var result = schema.Evaluate(document.RootElement, new EvaluationOptions
{
OutputFormat = OutputFormat.List,
RequireFormatValidation = true
});
if (result.IsValid)
{
return ImmutableArray<GoldenPairLoadError>.Empty;
}
var errors = new List<GoldenPairLoadError>();
CollectErrors(result, errors);
if (errors.Count == 0)
{
errors.Add(new GoldenPairLoadError("schema", "Schema validation failed."));
}
return errors.ToImmutableArray();
}
private static void CollectErrors(EvaluationResults node, List<GoldenPairLoadError> errors)
{
if (node.Errors is { Count: > 0 })
{
foreach (var kvp in node.Errors)
{
errors.Add(new GoldenPairLoadError(
"schema",
kvp.Value ?? "Schema validation error",
node.InstanceLocation.ToString(),
node.SchemaLocation.ToString(),
kvp.Key));
}
}
if (node.Details is null)
{
return;
}
foreach (var child in node.Details)
{
CollectErrors(child, errors);
}
}
}
public sealed record GoldenPairLoadResult(
string Path,
GoldenPairMetadata? Metadata,
ImmutableArray<GoldenPairLoadError> Errors)
{
public bool IsValid => Errors.IsEmpty;
public static GoldenPairLoadResult FromError(string? path, string code, string message)
=> new(path ?? string.Empty, null, ImmutableArray.Create(new GoldenPairLoadError(code, message)));
}
public sealed record GoldenPairsIndexLoadResult(
string Path,
GoldenPairsIndex? Index,
ImmutableArray<GoldenPairLoadError> Errors)
{
public bool IsValid => Errors.IsEmpty;
public static GoldenPairsIndexLoadResult FromError(string? path, string code, string message)
=> new(path ?? string.Empty, null, ImmutableArray.Create(new GoldenPairLoadError(code, message)));
}
public sealed record GoldenPairLoadError(
string Code,
string Message,
string? InstanceLocation = null,
string? SchemaLocation = null,
string? Keyword = null);

View File

@@ -0,0 +1,75 @@
using System.Collections.Immutable;
using StellaOps.Tools.GoldenPairs.Models;
namespace StellaOps.Tools.GoldenPairs.Services;
public static class GoldenPairNormalizer
{
public static GoldenPairMetadata Normalize(GoldenPairMetadata metadata)
{
if (metadata is null)
{
throw new ArgumentNullException(nameof(metadata));
}
var advisories = metadata.Advisories
.OrderBy(a => a.Source, StringComparer.Ordinal)
.ThenBy(a => a.Id, StringComparer.Ordinal)
.ToImmutableArray();
var patch = metadata.Patch with
{
FunctionsChanged = metadata.Patch.FunctionsChanged
.OrderBy(value => value, StringComparer.Ordinal)
.ToImmutableArray(),
FilesChanged = metadata.Patch.FilesChanged
.OrderBy(value => value, StringComparer.Ordinal)
.ToImmutableArray()
};
var expectedDiff = metadata.ExpectedDiff with
{
SectionsChanged = metadata.ExpectedDiff.SectionsChanged
.OrderBy(value => value, StringComparer.Ordinal)
.ToImmutableArray(),
SectionsIdentical = metadata.ExpectedDiff.SectionsIdentical
.OrderBy(value => value, StringComparer.Ordinal)
.ToImmutableArray()
};
var artifact = metadata.Artifact with
{
Name = metadata.Artifact.Name.Trim(),
Architecture = metadata.Artifact.Architecture.Trim(),
Os = metadata.Artifact.Os.Trim().ToLowerInvariant()
};
return metadata with
{
Cve = metadata.Cve.Trim().ToUpperInvariant(),
Name = metadata.Name.Trim(),
Description = metadata.Description?.Trim(),
Advisories = advisories,
Patch = patch,
ExpectedDiff = expectedDiff,
Artifact = artifact,
Original = NormalizeArtifact(metadata.Original),
Patched = NormalizeArtifact(metadata.Patched),
CreatedAt = metadata.CreatedAt.ToUniversalTime(),
CreatedBy = metadata.CreatedBy.Trim()
};
}
private static BinaryArtifact NormalizeArtifact(BinaryArtifact artifact)
=> artifact with
{
Package = artifact.Package.Trim(),
Version = artifact.Version.Trim(),
Distro = artifact.Distro.Trim(),
Source = artifact.Source.Trim(),
Sha256 = artifact.Sha256.Trim().ToLowerInvariant(),
BuildId = artifact.BuildId?.Trim().ToLowerInvariant(),
DebugSymbolsSource = artifact.DebugSymbolsSource?.Trim(),
PathInPackage = artifact.PathInPackage?.Trim()
};
}

View File

@@ -0,0 +1,46 @@
namespace StellaOps.Tools.GoldenPairs.Services;
public static class GoldenPairsPaths
{
public const string DatasetRelativePath = "datasets/golden-pairs";
public const string MetadataSchemaRelativePath = "docs/schemas/golden-pair-v1.schema.json";
public const string IndexSchemaRelativePath = "docs/schemas/golden-pairs-index.schema.json";
public static string? TryResolveRepoRoot(string? repoRoot)
{
if (!string.IsNullOrWhiteSpace(repoRoot))
{
return Path.GetFullPath(repoRoot);
}
var current = new DirectoryInfo(Directory.GetCurrentDirectory());
while (current is not null)
{
var solutionPath = Path.Combine(current.FullName, "src", "StellaOps.sln");
if (File.Exists(solutionPath))
{
return current.FullName;
}
current = current.Parent;
}
return null;
}
public static string ResolveDatasetRoot(string repoRoot, string? overridePath = null)
{
if (!string.IsNullOrWhiteSpace(overridePath))
{
return Path.GetFullPath(overridePath);
}
return Path.GetFullPath(Path.Combine(repoRoot, DatasetRelativePath));
}
public static string ResolveMetadataSchemaPath(string repoRoot)
=> Path.GetFullPath(Path.Combine(repoRoot, MetadataSchemaRelativePath));
public static string ResolveIndexSchemaPath(string repoRoot)
=> Path.GetFullPath(Path.Combine(repoRoot, IndexSchemaRelativePath));
}

View File

@@ -0,0 +1,51 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Analyzers.Native;
using StellaOps.Tools.GoldenPairs.Schema;
namespace StellaOps.Tools.GoldenPairs.Services;
public static class GoldenPairsServiceFactory
{
public static ServiceProvider Build(
string datasetRoot,
string metadataSchemaPath,
string indexSchemaPath)
{
var services = new ServiceCollection();
services.AddLogging(builder =>
{
builder.AddSimpleConsole(options =>
{
options.SingleLine = true;
options.TimestampFormat = "HH:mm:ss ";
});
builder.SetMinimumLevel(LogLevel.Information);
});
services.AddHttpClient("golden-pairs-mirror");
var layout = new GoldenPairLayout(datasetRoot);
services.AddSingleton(layout);
var schemaProvider = new GoldenPairsSchemaProvider(metadataSchemaPath, indexSchemaPath);
services.AddSingleton(schemaProvider);
services.AddSingleton<IElfSectionHashExtractor>(sp =>
{
var timeProvider = sp.GetRequiredService<TimeProvider>();
var options = Options.Create(new ElfSectionHashOptions());
return new ElfSectionHashExtractor(timeProvider, options);
});
services.AddSingleton<GoldenPairLoader>();
services.AddSingleton<ISectionHashProvider, FileSectionHashProvider>();
services.AddSingleton<TimeProvider>(TimeProvider.System);
services.AddSingleton<IDiffPipelineService, DiffPipelineService>();
services.AddSingleton<IPackageMirrorService, AptPackageMirrorService>();
return services.BuildServiceProvider();
}
}

View File

@@ -0,0 +1,285 @@
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using SharpCompress.Archives;
using SharpCompress.Common;
using StellaOps.Tools.GoldenPairs.Models;
namespace StellaOps.Tools.GoldenPairs.Services;
public interface IPackageMirrorService
{
Task<MirrorResult> FetchAsync(
BinaryArtifact artifact,
string destination,
CancellationToken cancellationToken = default);
Task<bool> VerifyAsync(
string path,
string expectedSha256,
CancellationToken cancellationToken = default);
}
public sealed record MirrorResult
{
public required bool Success { get; init; }
public required string LocalPath { get; init; }
public required string ActualSha256 { get; init; }
public bool HashMatches { get; init; }
public string? ErrorMessage { get; init; }
public long BytesDownloaded { get; init; }
}
public sealed class AptPackageMirrorService : IPackageMirrorService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<AptPackageMirrorService> _logger;
public AptPackageMirrorService(
IHttpClientFactory httpClientFactory,
ILogger<AptPackageMirrorService> logger)
{
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<MirrorResult> FetchAsync(
BinaryArtifact artifact,
string destination,
CancellationToken cancellationToken = default)
{
if (artifact is null)
{
throw new ArgumentNullException(nameof(artifact));
}
if (string.IsNullOrWhiteSpace(destination))
{
throw new ArgumentException("Destination is required.", nameof(destination));
}
Directory.CreateDirectory(destination);
try
{
var sourceUri = ResolveSourceUri(artifact.Source);
var fileName = GetArtifactFileName(artifact);
var stagingPath = Path.Combine(destination, fileName);
var bytesDownloaded = await DownloadAsync(sourceUri, stagingPath, cancellationToken).ConfigureAwait(false);
string localPath;
if (!string.IsNullOrWhiteSpace(artifact.PathInPackage))
{
localPath = await ExtractFromDebAsync(stagingPath, artifact.PathInPackage, destination, cancellationToken)
.ConfigureAwait(false);
}
else
{
localPath = stagingPath;
}
var actualHash = await ComputeSha256Async(localPath, cancellationToken).ConfigureAwait(false);
var hashMatches = string.Equals(actualHash, artifact.Sha256, StringComparison.OrdinalIgnoreCase);
if (!hashMatches)
{
return new MirrorResult
{
Success = false,
LocalPath = localPath,
ActualSha256 = actualHash,
HashMatches = false,
ErrorMessage = $"Hash mismatch: expected {artifact.Sha256}, got {actualHash}",
BytesDownloaded = bytesDownloaded
};
}
return new MirrorResult
{
Success = true,
LocalPath = localPath,
ActualSha256 = actualHash,
HashMatches = true,
ErrorMessage = null,
BytesDownloaded = bytesDownloaded
};
}
catch (Exception ex)
{
_logger.LogWarning("Mirror failed: {Message}", ex.Message);
return new MirrorResult
{
Success = false,
LocalPath = string.Empty,
ActualSha256 = string.Empty,
HashMatches = false,
ErrorMessage = ex.Message,
BytesDownloaded = 0
};
}
}
public async Task<bool> VerifyAsync(
string path,
string expectedSha256,
CancellationToken cancellationToken = default)
{
if (!File.Exists(path))
{
return false;
}
var actual = await ComputeSha256Async(path, cancellationToken).ConfigureAwait(false);
return string.Equals(actual, expectedSha256, StringComparison.OrdinalIgnoreCase);
}
private async Task<long> DownloadAsync(Uri source, string destination, CancellationToken cancellationToken)
{
if (source.Scheme.Equals(Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase))
{
var sourcePath = source.LocalPath;
if (!File.Exists(sourcePath))
{
throw new FileNotFoundException("Source file not found.", sourcePath);
}
File.Copy(sourcePath, destination, overwrite: true);
return new FileInfo(destination).Length;
}
var client = _httpClientFactory.CreateClient("golden-pairs-mirror");
using var response = await client.GetAsync(source, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using var target = new FileStream(
destination,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 81920,
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
await sourceStream.CopyToAsync(target, cancellationToken).ConfigureAwait(false);
return target.Length;
}
private static Uri ResolveSourceUri(string source)
{
if (string.IsNullOrWhiteSpace(source))
{
throw new ArgumentException("Source URI is required.", nameof(source));
}
if (source.StartsWith("apt://", StringComparison.OrdinalIgnoreCase))
{
var withoutScheme = source.Substring("apt://".Length);
return new Uri($"https://{withoutScheme}");
}
if (Uri.TryCreate(source, UriKind.Absolute, out var uri))
{
return uri;
}
throw new InvalidOperationException($"Invalid source URI: {source}");
}
private static string GetArtifactFileName(BinaryArtifact artifact)
{
if (!string.IsNullOrWhiteSpace(artifact.PathInPackage))
{
return Path.GetFileName(artifact.PathInPackage);
}
var name = Path.GetFileName(artifact.Source);
if (!string.IsNullOrWhiteSpace(name))
{
return name;
}
return $"{artifact.Package}.bin";
}
private static async Task<string> ExtractFromDebAsync(
string debPath,
string pathInPackage,
string destination,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(pathInPackage))
{
throw new InvalidOperationException("pathInPackage is required when extracting from deb.");
}
var normalizedPath = NormalizeArchivePath(pathInPackage);
using var debArchive = ArchiveFactory.Open(debPath);
var dataEntry = debArchive.Entries
.FirstOrDefault(entry => entry.Key.StartsWith("data.tar", StringComparison.OrdinalIgnoreCase));
if (dataEntry is null)
{
throw new InvalidOperationException("Deb archive missing data.tar payload.");
}
using var dataStream = dataEntry.OpenEntryStream();
using var dataArchive = ArchiveFactory.Open(dataStream);
var fileEntry = dataArchive.Entries
.FirstOrDefault(entry => string.Equals(NormalizeArchivePath(entry.Key), normalizedPath, StringComparison.Ordinal));
if (fileEntry is null)
{
throw new FileNotFoundException($"Path '{pathInPackage}' not found inside deb archive.");
}
var outputPath = Path.Combine(destination, Path.GetFileName(normalizedPath));
fileEntry.WriteToFile(outputPath, new ExtractionOptions
{
ExtractFullPath = false,
Overwrite = true
});
await Task.CompletedTask.ConfigureAwait(false);
return outputPath;
}
private static string NormalizeArchivePath(string path)
{
var normalized = path.Replace('\\', '/').TrimStart('/');
if (normalized.StartsWith("./", StringComparison.Ordinal))
{
normalized = normalized[2..];
}
return normalized;
}
private static async Task<string> ComputeSha256Async(string path, CancellationToken cancellationToken)
{
await using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 81920,
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
using var sha = SHA256.Create();
var hash = await sha.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false);
return ConvertToLowerHex(hash);
}
private static string ConvertToLowerHex(byte[] bytes)
{
var builder = new StringBuilder(bytes.Length * 2);
foreach (var value in bytes)
{
_ = builder.Append(value.ToString("x2", System.Globalization.CultureInfo.InvariantCulture));
}
return builder.ToString();
}
}

View File

@@ -0,0 +1,86 @@
using System.Collections.Immutable;
using System.Text.Json;
using StellaOps.Scanner.Analyzers.Native;
using StellaOps.Scanner.Contracts;
using StellaOps.Tools.GoldenPairs.Models;
using StellaOps.Tools.GoldenPairs.Serialization;
namespace StellaOps.Tools.GoldenPairs.Services;
public interface ISectionHashProvider
{
Task<SectionHashSet?> LoadAsync(string path, CancellationToken cancellationToken = default);
Task<SectionHashSet?> ExtractAsync(string binaryPath, CancellationToken cancellationToken = default);
}
public sealed class FileSectionHashProvider : ISectionHashProvider
{
private readonly IElfSectionHashExtractor? _extractor;
private readonly JsonSerializerOptions _options = GoldenPairsJsonSerializer.CreateReaderOptions();
public FileSectionHashProvider()
{
}
public FileSectionHashProvider(IElfSectionHashExtractor extractor)
{
_extractor = extractor;
}
public async Task<SectionHashSet?> LoadAsync(string path, CancellationToken cancellationToken = default)
{
if (!File.Exists(path))
{
return null;
}
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
try
{
return JsonSerializer.Deserialize<SectionHashSet>(json, _options);
}
catch (JsonException)
{
return null;
}
}
public async Task<SectionHashSet?> ExtractAsync(string binaryPath, CancellationToken cancellationToken = default)
{
if (_extractor is null)
{
return null;
}
var extracted = await _extractor.ExtractAsync(binaryPath, cancellationToken).ConfigureAwait(false);
if (extracted is null)
{
return null;
}
return MapSectionHashes(extracted);
}
private static SectionHashSet MapSectionHashes(ElfSectionHashSet source)
{
var sections = source.Sections
.OrderBy(section => section.Name, StringComparer.Ordinal)
.Select(section => new SectionHashEntry
{
Name = section.Name,
Size = section.Size,
Sha256 = section.Sha256
})
.ToImmutableArray();
return new SectionHashSet
{
FilePath = source.FilePath,
FileHash = source.FileHash,
BuildId = source.BuildId,
Sections = sections,
ExtractedAt = source.ExtractedAt,
ExtractorVersion = source.ExtractorVersion
};
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="JsonSchema.Net" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="SharpCompress" />
<PackageReference Include="System.CommandLine" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Scanner\StellaOps.Scanner.Analyzers.Native\StellaOps.Scanner.Analyzers.Native.csproj" />
</ItemGroup>
</Project>

View File

@@ -419,6 +419,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PolicySchemaExporter.Tests"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PolicySimulationSmoke.Tests", "__Tests\PolicySimulationSmoke.Tests\PolicySimulationSmoke.Tests.csproj", "{321594DF-0087-4FD9-8421-C17C749FF742}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GoldenPairs", "GoldenPairs", "{ADB3198F-1727-8E79-4D5B-708BB9821357}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Tools.GoldenPairs", "GoldenPairs\StellaOps.Tools.GoldenPairs.csproj", "{61A281C6-3DD8-4672-B943-BC64BE05C7DD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Tools.GoldenPairs.Tests", "__Tests\StellaOps.Tools.GoldenPairs.Tests\StellaOps.Tools.GoldenPairs.Tests.csproj", "{E432DAE8-FDDA-44B1-AFC6-FA304B1EE63E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -1713,6 +1719,30 @@ Global
{321594DF-0087-4FD9-8421-C17C749FF742}.Release|x64.Build.0 = Release|Any CPU
{321594DF-0087-4FD9-8421-C17C749FF742}.Release|x86.ActiveCfg = Release|Any CPU
{321594DF-0087-4FD9-8421-C17C749FF742}.Release|x86.Build.0 = Release|Any CPU
{61A281C6-3DD8-4672-B943-BC64BE05C7DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{61A281C6-3DD8-4672-B943-BC64BE05C7DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{61A281C6-3DD8-4672-B943-BC64BE05C7DD}.Debug|x64.ActiveCfg = Debug|Any CPU
{61A281C6-3DD8-4672-B943-BC64BE05C7DD}.Debug|x64.Build.0 = Debug|Any CPU
{61A281C6-3DD8-4672-B943-BC64BE05C7DD}.Debug|x86.ActiveCfg = Debug|Any CPU
{61A281C6-3DD8-4672-B943-BC64BE05C7DD}.Debug|x86.Build.0 = Debug|Any CPU
{61A281C6-3DD8-4672-B943-BC64BE05C7DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{61A281C6-3DD8-4672-B943-BC64BE05C7DD}.Release|Any CPU.Build.0 = Release|Any CPU
{61A281C6-3DD8-4672-B943-BC64BE05C7DD}.Release|x64.ActiveCfg = Release|Any CPU
{61A281C6-3DD8-4672-B943-BC64BE05C7DD}.Release|x64.Build.0 = Release|Any CPU
{61A281C6-3DD8-4672-B943-BC64BE05C7DD}.Release|x86.ActiveCfg = Release|Any CPU
{61A281C6-3DD8-4672-B943-BC64BE05C7DD}.Release|x86.Build.0 = Release|Any CPU
{E432DAE8-FDDA-44B1-AFC6-FA304B1EE63E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E432DAE8-FDDA-44B1-AFC6-FA304B1EE63E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E432DAE8-FDDA-44B1-AFC6-FA304B1EE63E}.Debug|x64.ActiveCfg = Debug|Any CPU
{E432DAE8-FDDA-44B1-AFC6-FA304B1EE63E}.Debug|x64.Build.0 = Debug|Any CPU
{E432DAE8-FDDA-44B1-AFC6-FA304B1EE63E}.Debug|x86.ActiveCfg = Debug|Any CPU
{E432DAE8-FDDA-44B1-AFC6-FA304B1EE63E}.Debug|x86.Build.0 = Debug|Any CPU
{E432DAE8-FDDA-44B1-AFC6-FA304B1EE63E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E432DAE8-FDDA-44B1-AFC6-FA304B1EE63E}.Release|Any CPU.Build.0 = Release|Any CPU
{E432DAE8-FDDA-44B1-AFC6-FA304B1EE63E}.Release|x64.ActiveCfg = Release|Any CPU
{E432DAE8-FDDA-44B1-AFC6-FA304B1EE63E}.Release|x64.Build.0 = Release|Any CPU
{E432DAE8-FDDA-44B1-AFC6-FA304B1EE63E}.Release|x86.ActiveCfg = Release|Any CPU
{E432DAE8-FDDA-44B1-AFC6-FA304B1EE63E}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -1886,6 +1916,8 @@ Global
{50FA7781-4439-465A-8061-BEC5C3469814} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{F2181DC4-43EB-4F3A-BD3E-03AD1F9CE3C5} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{321594DF-0087-4FD9-8421-C17C749FF742} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{61A281C6-3DD8-4672-B943-BC64BE05C7DD} = {ADB3198F-1727-8E79-4D5B-708BB9821357}
{E432DAE8-FDDA-44B1-AFC6-FA304B1EE63E} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {180AF072-9F83-5251-AFF7-C5FF574F0925}

View File

@@ -0,0 +1,104 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Tools.GoldenPairs.Models;
using StellaOps.Tools.GoldenPairs.Serialization;
using StellaOps.Tools.GoldenPairs.Services;
namespace StellaOps.Tools.GoldenPairs.Tests;
public sealed class DiffPipelineServiceTests
{
[Fact]
public async Task DiffAsync_ModifiedText_ReturnsPatched()
{
var metadata = TestData.CreateMetadata();
using var temp = new TempDirectory();
var layout = new GoldenPairLayout(temp.Path);
Directory.CreateDirectory(layout.GetOriginalDirectory(metadata.Cve));
Directory.CreateDirectory(layout.GetPatchedDirectory(metadata.Cve));
var originalSections = TestData.CreateSectionHashSet(
filePath: "original",
fileHash: metadata.Original.Sha256,
new SectionHashEntry { Name = ".text", Sha256 = "1111", Size = 100 },
new SectionHashEntry { Name = ".rodata", Sha256 = "2222", Size = 50 });
var patchedSections = TestData.CreateSectionHashSet(
filePath: "patched",
fileHash: metadata.Patched.Sha256,
new SectionHashEntry { Name = ".text", Sha256 = "3333", Size = 110 },
new SectionHashEntry { Name = ".rodata", Sha256 = "2222", Size = 50 });
await File.WriteAllTextAsync(layout.GetOriginalSectionHashPath(metadata), GoldenPairsJsonSerializer.Serialize(originalSections));
await File.WriteAllTextAsync(layout.GetPatchedSectionHashPath(metadata), GoldenPairsJsonSerializer.Serialize(patchedSections));
var diff = new DiffPipelineService(layout, new FileSectionHashProvider(), new FixedTimeProvider());
var report = await diff.DiffAsync(metadata);
report.Verdict.Should().Be(GoldenDiffVerdict.Patched);
report.MatchesExpected.Should().BeTrue();
report.Sections.Should().HaveCount(2);
report.Sections.First(section => section.Name == ".text").Status.Should().Be(SectionComparisonStatus.Modified);
report.Sections.First(section => section.Name == ".rodata").Status.Should().Be(SectionComparisonStatus.Identical);
}
[Fact]
public void Validate_WrongVerdict_Fails()
{
var report = new GoldenDiffReport
{
Cve = "CVE-2022-0847",
Original = new ArtifactHashInfo { Sha256 = "aa" },
Patched = new ArtifactHashInfo { Sha256 = "bb" },
Sections = ImmutableArray<SectionComparison>.Empty,
Verdict = GoldenDiffVerdict.Patched,
Confidence = 0.9,
MatchesExpected = true,
Discrepancies = ImmutableArray<string>.Empty,
AnalyzedAt = new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero),
ToolVersion = "1.0.0"
};
var expected = new ExpectedDiff
{
Verdict = GoldenDiffVerdict.Vanilla,
ConfidenceMin = 0.1
};
var validation = new DiffPipelineService(
new GoldenPairLayout(Path.GetTempPath()),
new FileSectionHashProvider(),
new FixedTimeProvider())
.Validate(report, expected);
validation.IsValid.Should().BeFalse();
validation.Errors.Should().NotBeEmpty();
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixed = new(2026, 1, 13, 12, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => _fixed;
}
private sealed class TempDirectory : IDisposable
{
public TempDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"golden-diff-{Guid.NewGuid():N}");
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
}
}

View File

@@ -0,0 +1,91 @@
using FluentAssertions;
using StellaOps.Tools.GoldenPairs.Schema;
using StellaOps.Tools.GoldenPairs.Serialization;
using StellaOps.Tools.GoldenPairs.Services;
namespace StellaOps.Tools.GoldenPairs.Tests;
public sealed class GoldenPairLoaderTests
{
[Fact]
public async Task LoadAsync_ReturnsMetadataAndNormalizes()
{
var metadata = TestData.CreateMetadata();
using var temp = new TempDirectory();
var datasetRoot = Path.Combine(temp.Path, "datasets");
var pairDir = Path.Combine(datasetRoot, metadata.Cve);
Directory.CreateDirectory(pairDir);
var metadataPath = Path.Combine(pairDir, "metadata.json");
await File.WriteAllTextAsync(metadataPath, GoldenPairsJsonSerializer.Serialize(metadata));
var loader = CreateLoader(datasetRoot);
var result = await loader.LoadAsync(metadata.Cve);
result.IsValid.Should().BeTrue();
result.Metadata.Should().NotBeNull();
result.Metadata!.Cve.Should().Be("CVE-2022-0847");
result.Metadata!.Advisories[0].Source.Should().Be("nvd");
result.Metadata!.Advisories[1].Source.Should().Be("ubuntu");
}
[Fact]
public async Task LoadAsync_MissingMetadata_ReturnsError()
{
using var temp = new TempDirectory();
var loader = CreateLoader(temp.Path);
var result = await loader.LoadAsync("CVE-2099-9999");
result.IsValid.Should().BeFalse();
result.Errors.Should().NotBeEmpty();
}
private static GoldenPairLoader CreateLoader(string datasetRoot)
{
var repoRoot = FindRepoRoot();
var schemaProvider = new GoldenPairsSchemaProvider(
Path.Combine(repoRoot, "docs", "schemas", "golden-pair-v1.schema.json"),
Path.Combine(repoRoot, "docs", "schemas", "golden-pairs-index.schema.json"));
var layout = new GoldenPairLayout(datasetRoot);
return new GoldenPairLoader(schemaProvider, layout);
}
private static string FindRepoRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
var solutionPath = Path.Combine(current.FullName, "src", "StellaOps.sln");
if (File.Exists(solutionPath))
{
return current.FullName;
}
current = current.Parent;
}
throw new InvalidOperationException("Repository root not found.");
}
private sealed class TempDirectory : IDisposable
{
public TempDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"golden-pairs-{Guid.NewGuid():N}");
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
}
}

View File

@@ -0,0 +1,84 @@
using System.Text.Json;
using FluentAssertions;
using Json.Schema;
using StellaOps.Tools.GoldenPairs.Schema;
using StellaOps.Tools.GoldenPairs.Serialization;
namespace StellaOps.Tools.GoldenPairs.Tests;
public sealed class GoldenPairSchemaTests
{
[Fact]
public void MetadataSchema_ValidatesSample()
{
var schemaProvider = CreateSchemaProvider();
var metadata = TestData.CreateMetadata();
var json = GoldenPairsJsonSerializer.Serialize(metadata);
using var document = JsonDocument.Parse(json);
var result = schemaProvider.MetadataSchema.Evaluate(document.RootElement, new EvaluationOptions
{
OutputFormat = OutputFormat.List,
RequireFormatValidation = true
});
result.IsValid.Should().BeTrue();
}
[Fact]
public void MetadataSchema_RejectsMissingCve()
{
using var document = JsonDocument.Parse("{\"name\":\"Dirty Pipe\"}");
var schemaProvider = CreateSchemaProvider();
var result = schemaProvider.MetadataSchema.Evaluate(document.RootElement, new EvaluationOptions
{
OutputFormat = OutputFormat.List,
RequireFormatValidation = true
});
result.IsValid.Should().BeFalse();
}
[Fact]
public void IndexSchema_ValidatesSample()
{
var schemaProvider = CreateSchemaProvider();
var index = TestData.CreateIndex();
var json = GoldenPairsJsonSerializer.Serialize(index);
using var document = JsonDocument.Parse(json);
var result = schemaProvider.IndexSchema.Evaluate(document.RootElement, new EvaluationOptions
{
OutputFormat = OutputFormat.List,
RequireFormatValidation = true
});
result.IsValid.Should().BeTrue();
}
private static GoldenPairsSchemaProvider CreateSchemaProvider()
{
var repoRoot = FindRepoRoot();
var metadataSchemaPath = Path.Combine(repoRoot, "docs", "schemas", "golden-pair-v1.schema.json");
var indexSchemaPath = Path.Combine(repoRoot, "docs", "schemas", "golden-pairs-index.schema.json");
return new GoldenPairsSchemaProvider(metadataSchemaPath, indexSchemaPath);
}
private static string FindRepoRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
var solutionPath = Path.Combine(current.FullName, "src", "StellaOps.sln");
if (File.Exists(solutionPath))
{
return current.FullName;
}
current = current.Parent;
}
throw new InvalidOperationException("Repository root not found.");
}
}

View File

@@ -0,0 +1,100 @@
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Tools.GoldenPairs.Models;
using StellaOps.Tools.GoldenPairs.Services;
namespace StellaOps.Tools.GoldenPairs.Tests;
public sealed class PackageMirrorServiceTests
{
[Fact]
public async Task FetchAsync_FileSource_CopiesAndVerifies()
{
using var temp = new TempDirectory();
var sourcePath = Path.Combine(temp.Path, "source.bin");
await File.WriteAllTextAsync(sourcePath, "payload");
var sha256 = ComputeSha256(sourcePath);
var artifact = new BinaryArtifact
{
Package = "test",
Version = "1.0",
Distro = "local",
Source = new Uri(sourcePath).AbsoluteUri,
Sha256 = sha256,
HasDebugSymbols = false
};
var mirror = new AptPackageMirrorService(new NoHttpClientFactory(), NullLogger<AptPackageMirrorService>.Instance);
var result = await mirror.FetchAsync(artifact, temp.Path);
result.Success.Should().BeTrue();
result.HashMatches.Should().BeTrue();
File.Exists(result.LocalPath).Should().BeTrue();
}
[Fact]
public async Task FetchAsync_HashMismatch_Fails()
{
using var temp = new TempDirectory();
var sourcePath = Path.Combine(temp.Path, "source.bin");
await File.WriteAllTextAsync(sourcePath, "payload");
var artifact = new BinaryArtifact
{
Package = "test",
Version = "1.0",
Distro = "local",
Source = new Uri(sourcePath).AbsoluteUri,
Sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
HasDebugSymbols = false
};
var mirror = new AptPackageMirrorService(new NoHttpClientFactory(), NullLogger<AptPackageMirrorService>.Instance);
var result = await mirror.FetchAsync(artifact, temp.Path);
result.Success.Should().BeFalse();
result.HashMatches.Should().BeFalse();
}
private static string ComputeSha256(string path)
{
using var sha = SHA256.Create();
var bytes = sha.ComputeHash(File.ReadAllBytes(path));
var builder = new StringBuilder(bytes.Length * 2);
foreach (var value in bytes)
{
_ = builder.Append(value.ToString("x2", System.Globalization.CultureInfo.InvariantCulture));
}
return builder.ToString();
}
private sealed class NoHttpClientFactory : IHttpClientFactory
{
public HttpClient CreateClient(string name)
=> throw new InvalidOperationException("HttpClient should not be used for file sources.");
}
private sealed class TempDirectory : IDisposable
{
public TempDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"golden-mirror-{Guid.NewGuid():N}");
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
}
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsTestProject>true</IsTestProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\GoldenPairs\StellaOps.Tools.GoldenPairs.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,111 @@
using System.Collections.Immutable;
using StellaOps.Tools.GoldenPairs.Models;
namespace StellaOps.Tools.GoldenPairs.Tests;
internal static class TestData
{
public static GoldenPairMetadata CreateMetadata()
=> new()
{
Cve = "CVE-2022-0847",
Name = "Dirty Pipe",
Description = "Test description.",
Severity = SeverityLevel.High,
Artifact = new ArtifactInfo
{
Name = "vmlinux",
Format = BinaryFormat.Elf,
Architecture = "x86_64",
Os = "linux"
},
Original = new BinaryArtifact
{
Package = "linux-image-5.16.11-generic",
Version = "5.16.11",
Distro = "Ubuntu 22.04",
Source = "file:///tmp/original.bin",
Sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
BuildId = "deadbeef",
HasDebugSymbols = false,
PathInPackage = "/boot/vmlinux"
},
Patched = new BinaryArtifact
{
Package = "linux-image-5.16.12-generic",
Version = "5.16.12",
Distro = "Ubuntu 22.04",
Source = "file:///tmp/patched.bin",
Sha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
BuildId = "beefdead",
HasDebugSymbols = false,
PathInPackage = "/boot/vmlinux"
},
Patch = new PatchInfo
{
Commit = "9d2231c5d74e13b2a0546fee6737ee4446017903",
Upstream = "https://example.invalid/commit",
FunctionsChanged = ImmutableArray.Create("push_pipe", "copy_page_to_iter_pipe"),
FilesChanged = ImmutableArray.Create("lib/iov_iter.c", "fs/pipe.c"),
Summary = "Fix PIPE_BUF_FLAG_CAN_MERGE handling"
},
Advisories = ImmutableArray.Create(
new AdvisoryRef
{
Source = "ubuntu",
Id = "USN-5317-1",
Url = "https://ubuntu.com/security/notices/USN-5317-1"
},
new AdvisoryRef
{
Source = "nvd",
Id = "CVE-2022-0847",
Url = "https://nvd.nist.gov/vuln/detail/CVE-2022-0847"
}),
ExpectedDiff = new ExpectedDiff
{
SectionsChanged = ImmutableArray.Create(".text"),
SectionsIdentical = ImmutableArray.Create(".rodata", ".data"),
Verdict = GoldenDiffVerdict.Patched,
ConfidenceMin = 0.9
},
CreatedAt = new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero),
CreatedBy = "StellaOps Golden Pairs Tool v1.0.0"
};
public static GoldenPairsIndex CreateIndex()
=> new()
{
Version = "1.0.0",
GeneratedAt = new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero),
Pairs = ImmutableArray.Create(
new GoldenPairSummary
{
Cve = "CVE-2022-0847",
Name = "Dirty Pipe",
Severity = SeverityLevel.High,
Format = BinaryFormat.Elf,
Status = GoldenPairStatus.Validated,
LastValidated = new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero),
Path = "CVE-2022-0847"
}),
Summary = new GoldenPairsIndexSummary
{
Total = 1,
Validated = 1,
Failed = 0,
Pending = 0
}
};
public static SectionHashSet CreateSectionHashSet(string filePath, string fileHash, params SectionHashEntry[] sections)
=> new()
{
FilePath = filePath,
FileHash = fileHash,
BuildId = "deadbeef",
Sections = sections.ToImmutableArray(),
ExtractedAt = new DateTimeOffset(2026, 1, 13, 12, 0, 0, TimeSpan.Zero),
ExtractorVersion = "1.0.0"
};
}