audit, advisories and doctors/setup work
This commit is contained in:
317
src/Tools/GoldenPairs/GoldenPairsApp.cs
Normal file
317
src/Tools/GoldenPairs/GoldenPairsApp.cs
Normal 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);
|
||||
}
|
||||
62
src/Tools/GoldenPairs/Models/GoldenDiffReport.cs
Normal file
62
src/Tools/GoldenPairs/Models/GoldenDiffReport.cs
Normal 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; }
|
||||
}
|
||||
172
src/Tools/GoldenPairs/Models/GoldenPairMetadata.cs
Normal file
172
src/Tools/GoldenPairs/Models/GoldenPairMetadata.cs
Normal 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;
|
||||
}
|
||||
38
src/Tools/GoldenPairs/Models/GoldenPairsIndex.cs
Normal file
38
src/Tools/GoldenPairs/Models/GoldenPairsIndex.cs
Normal 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; }
|
||||
}
|
||||
20
src/Tools/GoldenPairs/Models/SectionHashModels.cs
Normal file
20
src/Tools/GoldenPairs/Models/SectionHashModels.cs
Normal 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; }
|
||||
}
|
||||
3
src/Tools/GoldenPairs/Program.cs
Normal file
3
src/Tools/GoldenPairs/Program.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using StellaOps.Tools.GoldenPairs;
|
||||
|
||||
return await GoldenPairsApp.RunAsync(args);
|
||||
36
src/Tools/GoldenPairs/Schema/GoldenPairsSchemaProvider.cs
Normal file
36
src/Tools/GoldenPairs/Schema/GoldenPairsSchemaProvider.cs
Normal 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()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
288
src/Tools/GoldenPairs/Services/DiffPipelineService.cs
Normal file
288
src/Tools/GoldenPairs/Services/DiffPipelineService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
54
src/Tools/GoldenPairs/Services/GoldenPairLayout.cs
Normal file
54
src/Tools/GoldenPairs/Services/GoldenPairLayout.cs
Normal 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");
|
||||
}
|
||||
210
src/Tools/GoldenPairs/Services/GoldenPairLoader.cs
Normal file
210
src/Tools/GoldenPairs/Services/GoldenPairLoader.cs
Normal 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);
|
||||
75
src/Tools/GoldenPairs/Services/GoldenPairNormalizer.cs
Normal file
75
src/Tools/GoldenPairs/Services/GoldenPairNormalizer.cs
Normal 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()
|
||||
};
|
||||
}
|
||||
46
src/Tools/GoldenPairs/Services/GoldenPairsPaths.cs
Normal file
46
src/Tools/GoldenPairs/Services/GoldenPairsPaths.cs
Normal 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));
|
||||
}
|
||||
51
src/Tools/GoldenPairs/Services/GoldenPairsServiceFactory.cs
Normal file
51
src/Tools/GoldenPairs/Services/GoldenPairsServiceFactory.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
285
src/Tools/GoldenPairs/Services/PackageMirrorService.cs
Normal file
285
src/Tools/GoldenPairs/Services/PackageMirrorService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
86
src/Tools/GoldenPairs/Services/SectionHashProvider.cs
Normal file
86
src/Tools/GoldenPairs/Services/SectionHashProvider.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
26
src/Tools/GoldenPairs/StellaOps.Tools.GoldenPairs.csproj
Normal file
26
src/Tools/GoldenPairs/StellaOps.Tools.GoldenPairs.csproj
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
111
src/Tools/__Tests/StellaOps.Tools.GoldenPairs.Tests/TestData.cs
Normal file
111
src/Tools/__Tests/StellaOps.Tools.GoldenPairs.Tests/TestData.cs
Normal 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"
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user