test fixes and new product advisories work

This commit is contained in:
master
2026-01-28 02:30:48 +02:00
parent 82caceba56
commit 644887997c
288 changed files with 69101 additions and 375 deletions

View File

@@ -5,6 +5,11 @@ namespace StellaOps.AirGap.Importer.Validation;
/// </summary>
public sealed record BundleValidationResult(bool IsValid, string Reason)
{
/// <summary>
/// Summary of referrer validation results (if referrer validation was performed).
/// </summary>
public ReferrerValidationSummary? ReferrerSummary { get; init; }
public static BundleValidationResult Success(string reason = "ok") => new(true, reason);
public static BundleValidationResult Failure(string reason) => new(false, reason);
}

View File

@@ -9,7 +9,7 @@ using StellaOps.AirGap.Importer.Versioning;
namespace StellaOps.AirGap.Importer.Validation;
/// <summary>
/// Coordinates DSSE, TUF, Merkle, monotonicity, and quarantine behaviors for an offline import.
/// Coordinates DSSE, TUF, Merkle, monotonicity, referrer validation, and quarantine behaviors for an offline import.
/// </summary>
public sealed class ImportValidator
{
@@ -19,6 +19,7 @@ public sealed class ImportValidator
private readonly RootRotationPolicy _rotation;
private readonly IVersionMonotonicityChecker _monotonicityChecker;
private readonly IQuarantineService _quarantineService;
private readonly ReferrerValidator? _referrerValidator;
private readonly ILogger<ImportValidator> _logger;
public ImportValidator(
@@ -28,7 +29,8 @@ public sealed class ImportValidator
RootRotationPolicy rotation,
IVersionMonotonicityChecker monotonicityChecker,
IQuarantineService quarantineService,
ILogger<ImportValidator> logger)
ILogger<ImportValidator> logger,
ReferrerValidator? referrerValidator = null)
{
_dsse = dsse ?? throw new ArgumentNullException(nameof(dsse));
_tuf = tuf ?? throw new ArgumentNullException(nameof(tuf));
@@ -36,6 +38,7 @@ public sealed class ImportValidator
_rotation = rotation ?? throw new ArgumentNullException(nameof(rotation));
_monotonicityChecker = monotonicityChecker ?? throw new ArgumentNullException(nameof(monotonicityChecker));
_quarantineService = quarantineService ?? throw new ArgumentNullException(nameof(quarantineService));
_referrerValidator = referrerValidator;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -152,6 +155,45 @@ public sealed class ImportValidator
}
verificationLog.Add($"rotation:{rotationResult.Reason}");
// Referrer validation (if validator is provided and bundle type supports it)
ReferrerValidationSummary? referrerSummary = null;
if (_referrerValidator is not null && IsBundleTypeWithReferrers(request.BundleType))
{
referrerSummary = _referrerValidator.Validate(
request.ManifestJson,
request.PayloadEntries,
cancellationToken);
if (!referrerSummary.IsValid)
{
var errorDetails = FormatReferrerErrors(referrerSummary);
var failed = BundleValidationResult.Failure($"referrer-validation-failed:{errorDetails}");
verificationLog.Add(failed.Reason);
_logger.LogWarning(
"offlinekit.import.validation failed tenant_id={tenant_id} bundle_type={bundle_type} bundle_digest={bundle_digest} reason_code={reason_code} referrer_missing={missing} checksum_mismatch={checksum} size_mismatch={size}",
request.TenantId,
request.BundleType,
request.BundleDigest,
"REFERRER_VALIDATION_FAILED",
referrerSummary.MissingReferrers,
referrerSummary.ChecksumMismatches,
referrerSummary.SizeMismatches);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed with { ReferrerSummary = referrerSummary };
}
if (referrerSummary.OrphanedReferrers > 0)
{
_logger.LogWarning(
"offlinekit.import.referrer_orphans tenant_id={tenant_id} bundle_type={bundle_type} orphaned_count={orphaned_count}",
request.TenantId,
request.BundleType,
referrerSummary.OrphanedReferrers);
}
verificationLog.Add($"referrers:valid={referrerSummary.ValidReferrers}:total={referrerSummary.TotalReferrers}");
}
BundleVersion incomingVersion;
try
{
@@ -254,7 +296,7 @@ public sealed class ImportValidator
request.BundleDigest,
request.ManifestVersion,
request.ForceActivate);
return BundleValidationResult.Success("import-validated");
return BundleValidationResult.Success("import-validated") with { ReferrerSummary = referrerSummary };
}
private async Task TryQuarantineAsync(
@@ -355,6 +397,35 @@ public sealed class ImportValidator
value = null;
return false;
}
private static bool IsBundleTypeWithReferrers(string bundleType)
{
// Only mirror bundles and offline kits containing mirror bundles support referrers
return bundleType.Equals("mirror-bundle", StringComparison.OrdinalIgnoreCase) ||
bundleType.Equals("offline-kit", StringComparison.OrdinalIgnoreCase);
}
private static string FormatReferrerErrors(ReferrerValidationSummary summary)
{
var parts = new List<string>(3);
if (summary.MissingReferrers > 0)
{
parts.Add($"missing={summary.MissingReferrers}");
}
if (summary.ChecksumMismatches > 0)
{
parts.Add($"checksum_mismatch={summary.ChecksumMismatches}");
}
if (summary.SizeMismatches > 0)
{
parts.Add($"size_mismatch={summary.SizeMismatches}");
}
return parts.Count > 0 ? string.Join(",", parts) : "unknown";
}
}
public sealed record ImportValidationRequest(

View File

@@ -0,0 +1,480 @@
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace StellaOps.AirGap.Importer.Validation;
/// <summary>
/// Validates OCI referrer artifacts declared in a mirror bundle manifest.
/// </summary>
public sealed class ReferrerValidator
{
private readonly ILogger<ReferrerValidator> _logger;
public ReferrerValidator(ILogger<ReferrerValidator> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Validates referrer artifacts in a bundle against manifest declarations.
/// </summary>
/// <param name="manifestJson">The bundle manifest JSON containing referrers section.</param>
/// <param name="bundleEntries">Named streams of bundle entries for content validation.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Referrer validation summary with any issues found.</returns>
public ReferrerValidationSummary Validate(
string? manifestJson,
IReadOnlyList<NamedStream> bundleEntries,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(manifestJson))
{
return ReferrerValidationSummary.Empty();
}
var referrers = TryParseReferrersSection(manifestJson);
if (referrers is null || referrers.Count == 0)
{
// No referrers declared; check for orphans
var orphans = FindOrphanedReferrers(bundleEntries, new HashSet<string>(StringComparer.OrdinalIgnoreCase));
return new ReferrerValidationSummary
{
TotalSubjects = 0,
TotalReferrers = 0,
ValidReferrers = 0,
MissingReferrers = 0,
ChecksumMismatches = 0,
SizeMismatches = 0,
OrphanedReferrers = orphans.Count,
Issues = orphans
};
}
var issues = new List<ReferrerValidationIssue>();
var validCount = 0;
var missingCount = 0;
var checksumMismatchCount = 0;
var sizeMismatchCount = 0;
// Build lookup of bundle entries by path
var entryLookup = bundleEntries
.ToDictionary(e => NormalizePath(e.Path), e => e, StringComparer.OrdinalIgnoreCase);
// Track which paths we've validated (for orphan detection)
var validatedPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var referrer in referrers)
{
cancellationToken.ThrowIfCancellationRequested();
var normalizedPath = NormalizePath(referrer.Path);
validatedPaths.Add(normalizedPath);
if (!entryLookup.TryGetValue(normalizedPath, out var entry))
{
missingCount++;
issues.Add(new ReferrerValidationIssue
{
IssueType = ReferrerValidationIssueType.ReferrerMissing,
Severity = ReferrerValidationSeverity.Error,
SubjectDigest = referrer.SubjectDigest,
ReferrerDigest = referrer.Digest,
ExpectedPath = referrer.Path,
Message = $"Declared referrer artifact not found in bundle: {referrer.Path}"
});
continue;
}
// Validate checksum
var actualChecksum = ComputeStreamChecksum(entry.Stream);
if (!string.Equals(actualChecksum, referrer.Sha256, StringComparison.OrdinalIgnoreCase))
{
checksumMismatchCount++;
issues.Add(new ReferrerValidationIssue
{
IssueType = ReferrerValidationIssueType.ReferrerChecksumMismatch,
Severity = ReferrerValidationSeverity.Error,
SubjectDigest = referrer.SubjectDigest,
ReferrerDigest = referrer.Digest,
ExpectedPath = referrer.Path,
ExpectedValue = referrer.Sha256,
ActualValue = actualChecksum,
Message = $"Referrer artifact checksum mismatch: expected {referrer.Sha256}, got {actualChecksum}"
});
continue;
}
// Validate size
var actualSize = GetStreamLength(entry.Stream);
if (referrer.Size > 0 && actualSize != referrer.Size)
{
sizeMismatchCount++;
issues.Add(new ReferrerValidationIssue
{
IssueType = ReferrerValidationIssueType.ReferrerSizeMismatch,
Severity = ReferrerValidationSeverity.Error,
SubjectDigest = referrer.SubjectDigest,
ReferrerDigest = referrer.Digest,
ExpectedPath = referrer.Path,
ExpectedValue = referrer.Size.ToString(),
ActualValue = actualSize.ToString(),
Message = $"Referrer artifact size mismatch: expected {referrer.Size} bytes, got {actualSize} bytes"
});
continue;
}
validCount++;
}
// Find orphaned referrer artifacts (files in referrers/ not declared in manifest)
var orphanedIssues = FindOrphanedReferrers(bundleEntries, validatedPaths);
issues.AddRange(orphanedIssues);
// Count unique subjects
var subjectCount = referrers.Select(r => r.SubjectDigest).Distinct(StringComparer.OrdinalIgnoreCase).Count();
_logger.LogInformation(
"Referrer validation completed: subjects={subjects} total={total} valid={valid} missing={missing} checksum_mismatch={checksum_mismatch} size_mismatch={size_mismatch} orphaned={orphaned}",
subjectCount,
referrers.Count,
validCount,
missingCount,
checksumMismatchCount,
sizeMismatchCount,
orphanedIssues.Count);
return new ReferrerValidationSummary
{
TotalSubjects = subjectCount,
TotalReferrers = referrers.Count,
ValidReferrers = validCount,
MissingReferrers = missingCount,
ChecksumMismatches = checksumMismatchCount,
SizeMismatches = sizeMismatchCount,
OrphanedReferrers = orphanedIssues.Count,
Issues = issues
};
}
/// <summary>
/// Checks if the validation summary represents a passing state.
/// Missing referrers and checksum/size mismatches are failures.
/// Orphaned referrers are warnings only.
/// </summary>
public static bool IsValid(ReferrerValidationSummary summary)
{
return summary.MissingReferrers == 0 &&
summary.ChecksumMismatches == 0 &&
summary.SizeMismatches == 0;
}
private static IReadOnlyList<ParsedReferrer>? TryParseReferrersSection(string manifestJson)
{
try
{
using var doc = JsonDocument.Parse(manifestJson);
// Look for referrers section (can be top-level or nested)
if (!doc.RootElement.TryGetProperty("referrers", out var referrersElement))
{
return null;
}
// Parse subjects array
if (!referrersElement.TryGetProperty("subjects", out var subjectsElement) ||
subjectsElement.ValueKind != JsonValueKind.Array)
{
return null;
}
var referrers = new List<ParsedReferrer>();
foreach (var subject in subjectsElement.EnumerateArray())
{
var subjectDigest = GetStringProperty(subject, "subject");
if (string.IsNullOrEmpty(subjectDigest))
{
continue;
}
if (!subject.TryGetProperty("artifacts", out var artifactsElement) ||
artifactsElement.ValueKind != JsonValueKind.Array)
{
continue;
}
foreach (var artifact in artifactsElement.EnumerateArray())
{
var digest = GetStringProperty(artifact, "digest");
var path = GetStringProperty(artifact, "path");
var sha256 = GetStringProperty(artifact, "sha256");
var size = GetLongProperty(artifact, "size");
var category = GetStringProperty(artifact, "category");
var artifactType = GetStringProperty(artifact, "artifactType");
if (string.IsNullOrEmpty(path))
{
continue;
}
referrers.Add(new ParsedReferrer(
SubjectDigest: subjectDigest,
Digest: digest ?? string.Empty,
Path: path,
Sha256: sha256 ?? string.Empty,
Size: size,
Category: category ?? string.Empty,
ArtifactType: artifactType));
}
}
return referrers;
}
catch (JsonException)
{
return null;
}
}
private static List<ReferrerValidationIssue> FindOrphanedReferrers(
IReadOnlyList<NamedStream> bundleEntries,
HashSet<string> validatedPaths)
{
var orphans = new List<ReferrerValidationIssue>();
foreach (var entry in bundleEntries)
{
var normalizedPath = NormalizePath(entry.Path);
// Check if this is a referrer artifact (under referrers/ directory)
if (!normalizedPath.StartsWith("referrers/", StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Skip if already validated
if (validatedPaths.Contains(normalizedPath))
{
continue;
}
orphans.Add(new ReferrerValidationIssue
{
IssueType = ReferrerValidationIssueType.OrphanedReferrer,
Severity = ReferrerValidationSeverity.Warning,
ExpectedPath = entry.Path,
Message = $"Referrer artifact exists but is not declared in manifest: {entry.Path}"
});
}
return orphans;
}
private static string NormalizePath(string path)
{
return path.Replace('\\', '/').TrimStart('/');
}
private static string ComputeStreamChecksum(Stream stream)
{
var canSeek = stream.CanSeek;
var originalPosition = canSeek ? stream.Position : 0;
if (canSeek)
{
stream.Seek(0, SeekOrigin.Begin);
}
var hash = SHA256.HashData(stream);
if (canSeek)
{
stream.Seek(originalPosition, SeekOrigin.Begin);
}
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static long GetStreamLength(Stream stream)
{
if (stream.CanSeek)
{
return stream.Length;
}
// For non-seekable streams, we already computed the hash so position is at end
return stream.Position;
}
private static string? GetStringProperty(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String)
{
return prop.GetString();
}
return null;
}
private static long GetLongProperty(JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.Number)
{
return prop.GetInt64();
}
return 0;
}
private sealed record ParsedReferrer(
string SubjectDigest,
string Digest,
string Path,
string Sha256,
long Size,
string Category,
string? ArtifactType);
}
/// <summary>
/// Summary of referrer validation results.
/// </summary>
public sealed record ReferrerValidationSummary
{
/// <summary>
/// Number of unique subject images with declared referrers.
/// </summary>
public int TotalSubjects { get; init; }
/// <summary>
/// Total number of declared referrer artifacts.
/// </summary>
public int TotalReferrers { get; init; }
/// <summary>
/// Number of referrers that passed validation.
/// </summary>
public int ValidReferrers { get; init; }
/// <summary>
/// Number of declared referrers not found in bundle.
/// </summary>
public int MissingReferrers { get; init; }
/// <summary>
/// Number of referrers with checksum mismatches.
/// </summary>
public int ChecksumMismatches { get; init; }
/// <summary>
/// Number of referrers with size mismatches.
/// </summary>
public int SizeMismatches { get; init; }
/// <summary>
/// Number of undeclared referrer artifacts found in bundle.
/// </summary>
public int OrphanedReferrers { get; init; }
/// <summary>
/// Detailed list of validation issues.
/// </summary>
public IReadOnlyList<ReferrerValidationIssue> Issues { get; init; } = [];
/// <summary>
/// Creates an empty summary when no referrers are present.
/// </summary>
public static ReferrerValidationSummary Empty() => new();
/// <summary>
/// Whether the validation passed (no errors, warnings are allowed).
/// </summary>
public bool IsValid => MissingReferrers == 0 && ChecksumMismatches == 0 && SizeMismatches == 0;
}
/// <summary>
/// A specific validation issue found during referrer validation.
/// </summary>
public sealed record ReferrerValidationIssue
{
/// <summary>
/// Type of validation issue.
/// </summary>
public required ReferrerValidationIssueType IssueType { get; init; }
/// <summary>
/// Severity of the issue.
/// </summary>
public required ReferrerValidationSeverity Severity { get; init; }
/// <summary>
/// Subject image digest (if applicable).
/// </summary>
public string? SubjectDigest { get; init; }
/// <summary>
/// Referrer artifact digest (if applicable).
/// </summary>
public string? ReferrerDigest { get; init; }
/// <summary>
/// Expected path in the bundle.
/// </summary>
public string? ExpectedPath { get; init; }
/// <summary>
/// Expected value (for mismatch issues).
/// </summary>
public string? ExpectedValue { get; init; }
/// <summary>
/// Actual value found (for mismatch issues).
/// </summary>
public string? ActualValue { get; init; }
/// <summary>
/// Human-readable description of the issue.
/// </summary>
public required string Message { get; init; }
}
/// <summary>
/// Types of referrer validation issues.
/// </summary>
public enum ReferrerValidationIssueType
{
/// <summary>
/// Declared referrer artifact not found in bundle.
/// </summary>
ReferrerMissing = 1,
/// <summary>
/// Referrer artifact checksum doesn't match declared value.
/// </summary>
ReferrerChecksumMismatch = 2,
/// <summary>
/// Referrer artifact size doesn't match declared value.
/// </summary>
ReferrerSizeMismatch = 3,
/// <summary>
/// Artifact found in referrers/ directory but not declared in manifest.
/// </summary>
OrphanedReferrer = 4
}
/// <summary>
/// Severity levels for referrer validation issues.
/// </summary>
public enum ReferrerValidationSeverity
{
/// <summary>
/// Warning - does not fail validation.
/// </summary>
Warning = 1,
/// <summary>
/// Error - fails validation.
/// </summary>
Error = 2
}