save progress
This commit is contained in:
@@ -43,6 +43,7 @@ public sealed record OfflineVerificationPolicy
|
||||
return values
|
||||
.Select(static value => value?.Trim())
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
@@ -203,6 +204,7 @@ public sealed record OfflineCertConstraints
|
||||
return values
|
||||
.Select(static value => value?.Trim())
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
@@ -41,7 +41,7 @@ public static class JsonNormalizer
|
||||
}
|
||||
|
||||
var normalized = NormalizeNode(node, options);
|
||||
return normalized.ToJsonString(SerializerOptions);
|
||||
return normalized?.ToJsonString(SerializerOptions) ?? "null";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -128,7 +128,8 @@ public sealed class SbomNormalizer
|
||||
/// </summary>
|
||||
private JsonNode NormalizeGeneric(JsonNode node)
|
||||
{
|
||||
return NormalizeNode(node);
|
||||
// NormalizeNode only returns null if input is null; node is non-null here
|
||||
return NormalizeNode(node)!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RuleBundleValidator.cs
|
||||
// Sprint: SPRINT_20260104_005_AIRGAP (Secret Offline Kit Integration)
|
||||
// Task: OKS-003 - Create bundle verification in Importer
|
||||
// Description: Validates rule bundles (secrets, malware, etc.) for offline import.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
using StellaOps.AirGap.Importer.Telemetry;
|
||||
using StellaOps.AirGap.Importer.Versioning;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Validates rule bundles (secrets, malware, etc.) for offline import.
|
||||
/// Verifies signature, version monotonicity, and file digests.
|
||||
/// </summary>
|
||||
public sealed class RuleBundleValidator
|
||||
{
|
||||
private readonly DsseVerifier _dsseVerifier;
|
||||
private readonly IVersionMonotonicityChecker _monotonicityChecker;
|
||||
private readonly ILogger<RuleBundleValidator> _logger;
|
||||
|
||||
public RuleBundleValidator(
|
||||
DsseVerifier dsseVerifier,
|
||||
IVersionMonotonicityChecker monotonicityChecker,
|
||||
ILogger<RuleBundleValidator> logger)
|
||||
{
|
||||
_dsseVerifier = dsseVerifier ?? throw new ArgumentNullException(nameof(dsseVerifier));
|
||||
_monotonicityChecker = monotonicityChecker ?? throw new ArgumentNullException(nameof(monotonicityChecker));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a rule bundle for import.
|
||||
/// </summary>
|
||||
public async Task<RuleBundleValidationResult> ValidateAsync(
|
||||
RuleBundleValidationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.TenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundleId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundleType);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.Version);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundleDirectory);
|
||||
|
||||
using var tenantScope = _logger.BeginTenantScope(request.TenantId);
|
||||
var verificationLog = new List<string>(capacity: 8);
|
||||
|
||||
// Verify manifest file exists
|
||||
var manifestPath = Path.Combine(request.BundleDirectory, $"{request.BundleId}.manifest.json");
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
var reason = $"manifest-not-found:{manifestPath}";
|
||||
verificationLog.Add(reason);
|
||||
_logger.LogWarning(
|
||||
"offlinekit.rulebundle.validation failed tenant_id={tenant_id} bundle_id={bundle_id} reason={reason}",
|
||||
request.TenantId,
|
||||
request.BundleId,
|
||||
reason);
|
||||
return RuleBundleValidationResult.Failure(reason, verificationLog);
|
||||
}
|
||||
|
||||
// Read and parse manifest
|
||||
string manifestJson;
|
||||
RuleBundleManifest? manifest;
|
||||
try
|
||||
{
|
||||
manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken);
|
||||
manifest = JsonSerializer.Deserialize<RuleBundleManifest>(manifestJson, JsonOptions);
|
||||
if (manifest is null)
|
||||
{
|
||||
var reason = "manifest-parse-failed:null";
|
||||
verificationLog.Add(reason);
|
||||
return RuleBundleValidationResult.Failure(reason, verificationLog);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var reason = $"manifest-parse-failed:{ex.GetType().Name.ToLowerInvariant()}";
|
||||
verificationLog.Add(reason);
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"offlinekit.rulebundle.validation failed tenant_id={tenant_id} bundle_id={bundle_id} reason={reason}",
|
||||
request.TenantId,
|
||||
request.BundleId,
|
||||
reason);
|
||||
return RuleBundleValidationResult.Failure(reason, verificationLog);
|
||||
}
|
||||
|
||||
// Verify signature if envelope provided
|
||||
if (request.SignatureEnvelope is not null)
|
||||
{
|
||||
var signatureResult = _dsseVerifier.Verify(request.SignatureEnvelope, request.TrustRoots, _logger);
|
||||
if (!signatureResult.IsValid)
|
||||
{
|
||||
var reason = $"signature-invalid:{signatureResult.Reason}";
|
||||
verificationLog.Add(reason);
|
||||
_logger.LogWarning(
|
||||
"offlinekit.rulebundle.validation failed tenant_id={tenant_id} bundle_id={bundle_id} reason={reason}",
|
||||
request.TenantId,
|
||||
request.BundleId,
|
||||
reason);
|
||||
return RuleBundleValidationResult.Failure(reason, verificationLog);
|
||||
}
|
||||
verificationLog.Add($"signature:verified");
|
||||
}
|
||||
else if (request.RequireSignature)
|
||||
{
|
||||
var reason = "signature-required-but-missing";
|
||||
verificationLog.Add(reason);
|
||||
_logger.LogWarning(
|
||||
"offlinekit.rulebundle.validation failed tenant_id={tenant_id} bundle_id={bundle_id} reason={reason}",
|
||||
request.TenantId,
|
||||
request.BundleId,
|
||||
reason);
|
||||
return RuleBundleValidationResult.Failure(reason, verificationLog);
|
||||
}
|
||||
|
||||
// Verify file digests
|
||||
var digestErrors = new List<string>();
|
||||
foreach (var file in manifest.Files)
|
||||
{
|
||||
var filePath = Path.Combine(request.BundleDirectory, file.Name);
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
digestErrors.Add($"file-missing:{file.Name}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var actualDigest = await ComputeFileDigestAsync(filePath, cancellationToken);
|
||||
if (!string.Equals(actualDigest, file.Digest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
digestErrors.Add($"digest-mismatch:{file.Name}:expected={file.Digest}:actual={actualDigest}");
|
||||
}
|
||||
}
|
||||
|
||||
if (digestErrors.Count > 0)
|
||||
{
|
||||
var reason = string.Join(";", digestErrors);
|
||||
verificationLog.Add(reason);
|
||||
_logger.LogWarning(
|
||||
"offlinekit.rulebundle.validation failed tenant_id={tenant_id} bundle_id={bundle_id} reason={reason}",
|
||||
request.TenantId,
|
||||
request.BundleId,
|
||||
reason);
|
||||
return RuleBundleValidationResult.Failure(reason, verificationLog);
|
||||
}
|
||||
verificationLog.Add($"digests:verified:{manifest.Files.Count}");
|
||||
|
||||
// Verify version monotonicity (CalVer format YYYY.MM)
|
||||
var bundleVersionKey = $"rulebundle:{request.BundleType}:{request.BundleId}";
|
||||
BundleVersion incomingVersion;
|
||||
try
|
||||
{
|
||||
incomingVersion = BundleVersion.Parse(request.Version, request.CreatedAt ?? DateTimeOffset.UtcNow);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var reason = $"version-parse-failed:{ex.GetType().Name.ToLowerInvariant()}";
|
||||
verificationLog.Add(reason);
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"offlinekit.rulebundle.validation failed tenant_id={tenant_id} bundle_id={bundle_id} reason={reason}",
|
||||
request.TenantId,
|
||||
request.BundleId,
|
||||
reason);
|
||||
return RuleBundleValidationResult.Failure(reason, verificationLog);
|
||||
}
|
||||
|
||||
var monotonicity = await _monotonicityChecker.CheckAsync(
|
||||
request.TenantId,
|
||||
bundleVersionKey,
|
||||
incomingVersion,
|
||||
cancellationToken);
|
||||
|
||||
if (!monotonicity.IsMonotonic && !request.ForceActivate)
|
||||
{
|
||||
var reason = $"version-non-monotonic:incoming={incomingVersion.SemVer}:current={monotonicity.CurrentVersion?.SemVer ?? "(none)"}";
|
||||
verificationLog.Add(reason);
|
||||
_logger.LogWarning(
|
||||
"offlinekit.rulebundle.validation failed tenant_id={tenant_id} bundle_id={bundle_id} reason={reason}",
|
||||
request.TenantId,
|
||||
request.BundleId,
|
||||
reason);
|
||||
return RuleBundleValidationResult.Failure(reason, verificationLog);
|
||||
}
|
||||
|
||||
if (!monotonicity.IsMonotonic && request.ForceActivate)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"offlinekit.rulebundle.force_activation tenant_id={tenant_id} bundle_id={bundle_id} incoming_version={incoming_version} current_version={current_version} reason={reason}",
|
||||
request.TenantId,
|
||||
request.BundleId,
|
||||
incomingVersion.SemVer,
|
||||
monotonicity.CurrentVersion?.SemVer,
|
||||
request.ForceActivateReason);
|
||||
}
|
||||
|
||||
verificationLog.Add($"version:monotonic:{incomingVersion.SemVer}");
|
||||
|
||||
// Record activation
|
||||
try
|
||||
{
|
||||
var combinedDigest = ComputeCombinedDigest(manifest.Files);
|
||||
await _monotonicityChecker.RecordActivationAsync(
|
||||
request.TenantId,
|
||||
bundleVersionKey,
|
||||
incomingVersion,
|
||||
combinedDigest,
|
||||
request.ForceActivate,
|
||||
request.ForceActivateReason,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var reason = $"version-store-write-failed:{ex.GetType().Name.ToLowerInvariant()}";
|
||||
verificationLog.Add(reason);
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"offlinekit.rulebundle.activation failed tenant_id={tenant_id} bundle_id={bundle_id}",
|
||||
request.TenantId,
|
||||
request.BundleId);
|
||||
return RuleBundleValidationResult.Failure(reason, verificationLog);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"offlinekit.rulebundle.validation succeeded tenant_id={tenant_id} bundle_id={bundle_id} bundle_type={bundle_type} version={version} rule_count={rule_count}",
|
||||
request.TenantId,
|
||||
request.BundleId,
|
||||
request.BundleType,
|
||||
request.Version,
|
||||
manifest.RuleCount);
|
||||
|
||||
return RuleBundleValidationResult.Success(
|
||||
"rulebundle-validated",
|
||||
verificationLog,
|
||||
manifest.RuleCount,
|
||||
manifest.SignerKeyId);
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeFileDigestAsync(string filePath, CancellationToken ct)
|
||||
{
|
||||
await using var stream = File.OpenRead(filePath);
|
||||
var hash = await SHA256.HashDataAsync(stream, ct);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string ComputeCombinedDigest(IReadOnlyList<RuleBundleFileEntry> files)
|
||||
{
|
||||
var sortedDigests = files
|
||||
.OrderBy(f => f.Name, StringComparer.Ordinal)
|
||||
.Select(f => f.Digest)
|
||||
.ToArray();
|
||||
|
||||
var combined = string.Join(":", sortedDigests);
|
||||
var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(combined));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for validating a rule bundle.
|
||||
/// </summary>
|
||||
public sealed record RuleBundleValidationRequest(
|
||||
string TenantId,
|
||||
string BundleId,
|
||||
string BundleType,
|
||||
string Version,
|
||||
string BundleDirectory,
|
||||
DateTimeOffset? CreatedAt,
|
||||
DsseEnvelope? SignatureEnvelope,
|
||||
TrustRootConfig TrustRoots,
|
||||
bool RequireSignature,
|
||||
bool ForceActivate,
|
||||
string? ForceActivateReason);
|
||||
|
||||
/// <summary>
|
||||
/// Result of rule bundle validation.
|
||||
/// </summary>
|
||||
public sealed record RuleBundleValidationResult
|
||||
{
|
||||
public bool IsValid { get; init; }
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
public IReadOnlyList<string> VerificationLog { get; init; } = [];
|
||||
public int RuleCount { get; init; }
|
||||
public string? SignerKeyId { get; init; }
|
||||
|
||||
public static RuleBundleValidationResult Success(
|
||||
string reason,
|
||||
IReadOnlyList<string> verificationLog,
|
||||
int ruleCount,
|
||||
string? signerKeyId) => new()
|
||||
{
|
||||
IsValid = true,
|
||||
Reason = reason,
|
||||
VerificationLog = verificationLog,
|
||||
RuleCount = ruleCount,
|
||||
SignerKeyId = signerKeyId
|
||||
};
|
||||
|
||||
public static RuleBundleValidationResult Failure(
|
||||
string reason,
|
||||
IReadOnlyList<string> verificationLog) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
Reason = reason,
|
||||
VerificationLog = verificationLog
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manifest for a rule bundle.
|
||||
/// </summary>
|
||||
internal sealed class RuleBundleManifest
|
||||
{
|
||||
public string BundleId { get; set; } = string.Empty;
|
||||
public string BundleType { get; set; } = string.Empty;
|
||||
public string Version { get; set; } = string.Empty;
|
||||
public int RuleCount { get; set; }
|
||||
public string? SignerKeyId { get; set; }
|
||||
public DateTimeOffset? SignedAt { get; set; }
|
||||
public List<RuleBundleFileEntry> Files { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// File entry in a rule bundle manifest.
|
||||
/// </summary>
|
||||
internal sealed class RuleBundleFileEntry
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Digest { get; set; } = string.Empty;
|
||||
public long SizeBytes { get; set; }
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public sealed record BundleManifest
|
||||
public ImmutableArray<CatalogComponent> Catalogs { get; init; } = [];
|
||||
public RekorSnapshot? RekorSnapshot { get; init; }
|
||||
public ImmutableArray<CryptoProviderComponent> CryptoProviders { get; init; } = [];
|
||||
public ImmutableArray<RuleBundleComponent> RuleBundles { get; init; } = [];
|
||||
public long TotalSizeBytes { get; init; }
|
||||
public string? BundleDigest { get; init; }
|
||||
}
|
||||
@@ -102,3 +103,39 @@ public sealed record CryptoProviderComponent(
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
ImmutableArray<string> SupportedAlgorithms);
|
||||
|
||||
/// <summary>
|
||||
/// Component for a rule bundle (e.g., secrets detection rules).
|
||||
/// </summary>
|
||||
/// <param name="BundleId">Bundle identifier (e.g., "secrets.ruleset").</param>
|
||||
/// <param name="BundleType">Bundle type (e.g., "secrets", "malware").</param>
|
||||
/// <param name="Version">Bundle version in YYYY.MM format.</param>
|
||||
/// <param name="RelativePath">Relative path to the bundle directory.</param>
|
||||
/// <param name="Digest">Combined digest of all files in the bundle.</param>
|
||||
/// <param name="SizeBytes">Total size of the bundle in bytes.</param>
|
||||
/// <param name="RuleCount">Number of rules in the bundle.</param>
|
||||
/// <param name="SignerKeyId">Key ID used to sign the bundle.</param>
|
||||
/// <param name="SignedAt">When the bundle was signed.</param>
|
||||
/// <param name="Files">List of files in the bundle.</param>
|
||||
public sealed record RuleBundleComponent(
|
||||
string BundleId,
|
||||
string BundleType,
|
||||
string Version,
|
||||
string RelativePath,
|
||||
string Digest,
|
||||
long SizeBytes,
|
||||
int RuleCount,
|
||||
string? SignerKeyId,
|
||||
DateTimeOffset? SignedAt,
|
||||
ImmutableArray<RuleBundleFileComponent> Files);
|
||||
|
||||
/// <summary>
|
||||
/// A file within a rule bundle component.
|
||||
/// </summary>
|
||||
/// <param name="Name">Filename (e.g., "secrets.ruleset.manifest.json").</param>
|
||||
/// <param name="Digest">SHA256 digest of the file.</param>
|
||||
/// <param name="SizeBytes">File size in bytes.</param>
|
||||
public sealed record RuleBundleFileComponent(
|
||||
string Name,
|
||||
string Digest,
|
||||
long SizeBytes);
|
||||
|
||||
@@ -25,6 +25,7 @@ public sealed class KnowledgeSnapshotManifest
|
||||
public List<VexSnapshotEntry> VexStatements { get; init; } = [];
|
||||
public List<PolicySnapshotEntry> Policies { get; init; } = [];
|
||||
public List<TrustRootSnapshotEntry> TrustRoots { get; init; } = [];
|
||||
public List<RuleBundleSnapshotEntry> RuleBundles { get; init; } = [];
|
||||
public TimeAnchorEntry? TimeAnchor { get; set; }
|
||||
}
|
||||
|
||||
@@ -81,6 +82,79 @@ public sealed class TrustRootSnapshotEntry
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry for a rule bundle in the snapshot.
|
||||
/// Used for detection rule bundles (secrets, malware, etc.).
|
||||
/// </summary>
|
||||
public sealed class RuleBundleSnapshotEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle identifier (e.g., "secrets.ruleset").
|
||||
/// </summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle type (e.g., "secrets", "malware").
|
||||
/// </summary>
|
||||
public required string BundleType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle version in YYYY.MM format.
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Relative path to the bundle directory in the snapshot.
|
||||
/// </summary>
|
||||
public required string RelativePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of files in the bundle with their digests.
|
||||
/// </summary>
|
||||
public required List<RuleBundleFile> Files { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of rules in the bundle.
|
||||
/// </summary>
|
||||
public int RuleCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used to sign the bundle.
|
||||
/// </summary>
|
||||
public string? SignerKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bundle was signed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bundle signature was verified during export.
|
||||
/// </summary>
|
||||
public DateTimeOffset? VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A file within a rule bundle.
|
||||
/// </summary>
|
||||
public sealed class RuleBundleFile
|
||||
{
|
||||
/// <summary>
|
||||
/// Filename (e.g., "secrets.ruleset.manifest.json").
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA256 digest of the file.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File size in bytes.
|
||||
/// </summary>
|
||||
public required long SizeBytes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time anchor entry in the manifest.
|
||||
/// </summary>
|
||||
|
||||
@@ -81,9 +81,64 @@ public sealed class BundleBuilder : IBundleBuilder
|
||||
cryptoConfig.ExpiresAt));
|
||||
}
|
||||
|
||||
var ruleBundles = new List<RuleBundleComponent>();
|
||||
foreach (var ruleBundleConfig in request.RuleBundles)
|
||||
{
|
||||
// Validate relative path before combining
|
||||
var targetDir = PathValidation.SafeCombine(outputPath, ruleBundleConfig.RelativePath);
|
||||
Directory.CreateDirectory(targetDir);
|
||||
|
||||
var files = new List<RuleBundleFileComponent>();
|
||||
long bundleTotalSize = 0;
|
||||
var digestBuilder = new System.Text.StringBuilder();
|
||||
|
||||
// Copy all files from source directory
|
||||
if (Directory.Exists(ruleBundleConfig.SourceDirectory))
|
||||
{
|
||||
foreach (var sourceFile in Directory.GetFiles(ruleBundleConfig.SourceDirectory)
|
||||
.OrderBy(f => Path.GetFileName(f), StringComparer.Ordinal))
|
||||
{
|
||||
var fileName = Path.GetFileName(sourceFile);
|
||||
var targetFile = Path.Combine(targetDir, fileName);
|
||||
|
||||
await using (var input = File.OpenRead(sourceFile))
|
||||
await using (var output = File.Create(targetFile))
|
||||
{
|
||||
await input.CopyToAsync(output, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await using var digestStream = File.OpenRead(targetFile);
|
||||
var hash = await SHA256.HashDataAsync(digestStream, ct).ConfigureAwait(false);
|
||||
var fileDigest = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
|
||||
var fileInfo = new FileInfo(targetFile);
|
||||
files.Add(new RuleBundleFileComponent(fileName, fileDigest, fileInfo.Length));
|
||||
bundleTotalSize += fileInfo.Length;
|
||||
digestBuilder.Append(fileDigest);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute combined digest from all file digests
|
||||
var combinedDigest = Convert.ToHexString(
|
||||
SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(digestBuilder.ToString()))).ToLowerInvariant();
|
||||
|
||||
ruleBundles.Add(new RuleBundleComponent(
|
||||
ruleBundleConfig.BundleId,
|
||||
ruleBundleConfig.BundleType,
|
||||
ruleBundleConfig.Version,
|
||||
ruleBundleConfig.RelativePath,
|
||||
combinedDigest,
|
||||
bundleTotalSize,
|
||||
ruleBundleConfig.RuleCount,
|
||||
ruleBundleConfig.SignerKeyId,
|
||||
ruleBundleConfig.SignedAt,
|
||||
files.ToImmutableArray()));
|
||||
}
|
||||
|
||||
var totalSize = feeds.Sum(f => f.SizeBytes) +
|
||||
policies.Sum(p => p.SizeBytes) +
|
||||
cryptoMaterials.Sum(c => c.SizeBytes);
|
||||
cryptoMaterials.Sum(c => c.SizeBytes) +
|
||||
ruleBundles.Sum(r => r.SizeBytes);
|
||||
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
@@ -96,6 +151,7 @@ public sealed class BundleBuilder : IBundleBuilder
|
||||
Feeds = feeds.ToImmutableArray(),
|
||||
Policies = policies.ToImmutableArray(),
|
||||
CryptoMaterials = cryptoMaterials.ToImmutableArray(),
|
||||
RuleBundles = ruleBundles.ToImmutableArray(),
|
||||
TotalSizeBytes = totalSize
|
||||
};
|
||||
|
||||
@@ -138,7 +194,8 @@ public sealed record BundleBuildRequest(
|
||||
DateTimeOffset? ExpiresAt,
|
||||
IReadOnlyList<FeedBuildConfig> Feeds,
|
||||
IReadOnlyList<PolicyBuildConfig> Policies,
|
||||
IReadOnlyList<CryptoBuildConfig> CryptoMaterials);
|
||||
IReadOnlyList<CryptoBuildConfig> CryptoMaterials,
|
||||
IReadOnlyList<RuleBundleBuildConfig> RuleBundles);
|
||||
|
||||
public abstract record BundleComponentSource(string SourcePath, string RelativePath);
|
||||
|
||||
@@ -169,3 +226,24 @@ public sealed record CryptoBuildConfig(
|
||||
CryptoComponentType Type,
|
||||
DateTimeOffset? ExpiresAt)
|
||||
: BundleComponentSource(SourcePath, RelativePath);
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for building a rule bundle component.
|
||||
/// </summary>
|
||||
/// <param name="BundleId">Bundle identifier (e.g., "secrets.ruleset").</param>
|
||||
/// <param name="BundleType">Bundle type (e.g., "secrets", "malware").</param>
|
||||
/// <param name="Version">Bundle version in YYYY.MM format.</param>
|
||||
/// <param name="SourceDirectory">Source directory containing the rule bundle files.</param>
|
||||
/// <param name="RelativePath">Relative path in the output bundle.</param>
|
||||
/// <param name="RuleCount">Number of rules in the bundle.</param>
|
||||
/// <param name="SignerKeyId">Key ID used to sign the bundle.</param>
|
||||
/// <param name="SignedAt">When the bundle was signed.</param>
|
||||
public sealed record RuleBundleBuildConfig(
|
||||
string BundleId,
|
||||
string BundleType,
|
||||
string Version,
|
||||
string SourceDirectory,
|
||||
string RelativePath,
|
||||
int RuleCount,
|
||||
string? SignerKeyId,
|
||||
DateTimeOffset? SignedAt);
|
||||
|
||||
@@ -408,6 +408,38 @@ public sealed class SnapshotBundleReader : ISnapshotBundleReader
|
||||
entries.Add(new BundleEntry(trust.RelativePath, digest, content.Length));
|
||||
}
|
||||
|
||||
foreach (var ruleBundle in manifest.RuleBundles)
|
||||
{
|
||||
// Verify each file in the rule bundle
|
||||
foreach (var file in ruleBundle.Files)
|
||||
{
|
||||
var relativePath = $"{ruleBundle.RelativePath}/{file.Name}";
|
||||
var filePath = Path.Combine(bundleDir, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return new MerkleVerificationResult
|
||||
{
|
||||
Verified = false,
|
||||
Error = $"Missing rule bundle file: {relativePath}"
|
||||
};
|
||||
}
|
||||
|
||||
var content = await File.ReadAllBytesAsync(filePath, cancellationToken);
|
||||
var digest = ComputeSha256(content);
|
||||
|
||||
if (digest != file.Digest)
|
||||
{
|
||||
return new MerkleVerificationResult
|
||||
{
|
||||
Verified = false,
|
||||
Error = $"Digest mismatch for rule bundle file {relativePath}"
|
||||
};
|
||||
}
|
||||
|
||||
entries.Add(new BundleEntry(relativePath, digest, content.Length));
|
||||
}
|
||||
}
|
||||
|
||||
// Compute merkle root
|
||||
var computedRoot = ComputeMerkleRoot(entries);
|
||||
|
||||
|
||||
@@ -186,6 +186,52 @@ public sealed class SnapshotBundleWriter : ISnapshotBundleWriter
|
||||
}
|
||||
}
|
||||
|
||||
// Write rule bundles
|
||||
if (request.RuleBundles is { Count: > 0 })
|
||||
{
|
||||
var rulesDir = Path.Combine(tempDir, "rules");
|
||||
Directory.CreateDirectory(rulesDir);
|
||||
|
||||
foreach (var ruleBundle in request.RuleBundles)
|
||||
{
|
||||
var bundleDir = Path.Combine(rulesDir, ruleBundle.BundleId);
|
||||
Directory.CreateDirectory(bundleDir);
|
||||
|
||||
var bundleFiles = new List<RuleBundleFile>();
|
||||
var bundleRelativePath = $"rules/{ruleBundle.BundleId}";
|
||||
|
||||
foreach (var file in ruleBundle.Files)
|
||||
{
|
||||
var filePath = Path.Combine(bundleDir, file.Name);
|
||||
await File.WriteAllBytesAsync(filePath, file.Content, cancellationToken);
|
||||
|
||||
var relativePath = $"{bundleRelativePath}/{file.Name}";
|
||||
var digest = ComputeSha256(file.Content);
|
||||
|
||||
entries.Add(new BundleEntry(relativePath, digest, file.Content.Length));
|
||||
bundleFiles.Add(new RuleBundleFile
|
||||
{
|
||||
Name = file.Name,
|
||||
Digest = digest,
|
||||
SizeBytes = file.Content.Length
|
||||
});
|
||||
}
|
||||
|
||||
manifest.RuleBundles.Add(new RuleBundleSnapshotEntry
|
||||
{
|
||||
BundleId = ruleBundle.BundleId,
|
||||
BundleType = ruleBundle.BundleType,
|
||||
Version = ruleBundle.Version,
|
||||
RelativePath = bundleRelativePath,
|
||||
Files = bundleFiles,
|
||||
RuleCount = ruleBundle.RuleCount,
|
||||
SignerKeyId = ruleBundle.SignerKeyId,
|
||||
SignedAt = ruleBundle.SignedAt,
|
||||
VerifiedAt = ruleBundle.VerifiedAt
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Write time anchor
|
||||
if (request.TimeAnchor is not null)
|
||||
{
|
||||
@@ -389,6 +435,7 @@ public sealed record SnapshotBundleRequest
|
||||
public List<VexContent> VexStatements { get; init; } = [];
|
||||
public List<PolicyContent> Policies { get; init; } = [];
|
||||
public List<TrustRootContent> TrustRoots { get; init; } = [];
|
||||
public List<RuleBundleContent> RuleBundles { get; init; } = [];
|
||||
public TimeAnchorContent? TimeAnchor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
@@ -445,6 +492,68 @@ public sealed record TrustRootContent
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for a rule bundle (e.g., secrets detection rules).
|
||||
/// </summary>
|
||||
public sealed record RuleBundleContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle identifier (e.g., "secrets.ruleset").
|
||||
/// </summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle type (e.g., "secrets", "malware").
|
||||
/// </summary>
|
||||
public required string BundleType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle version in YYYY.MM format.
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Files in the bundle.
|
||||
/// </summary>
|
||||
public required List<RuleBundleFileContent> Files { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of rules in the bundle.
|
||||
/// </summary>
|
||||
public int RuleCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID used to sign the bundle.
|
||||
/// </summary>
|
||||
public string? SignerKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bundle was signed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the bundle signature was verified during export.
|
||||
/// </summary>
|
||||
public DateTimeOffset? VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A file within a rule bundle.
|
||||
/// </summary>
|
||||
public sealed record RuleBundleFileContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Filename (e.g., "secrets.ruleset.manifest.json").
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File content.
|
||||
/// </summary>
|
||||
public required byte[] Content { get; init; }
|
||||
}
|
||||
|
||||
public sealed record TimeAnchorContent
|
||||
{
|
||||
public required DateTimeOffset AnchorTime { get; init; }
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RuleBundleValidatorTests.cs
|
||||
// Sprint: SPRINT_20260104_005_AIRGAP (Secret Offline Kit Integration)
|
||||
// Task: OKS-008 - Add integration tests for offline flow
|
||||
// Description: Tests for rule bundle validation in offline import
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
using StellaOps.AirGap.Importer.Versioning;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Tests.Validation;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class RuleBundleValidatorTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly CapturingMonotonicityChecker _monotonicityChecker;
|
||||
|
||||
public RuleBundleValidatorTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "stellaops-rulebundle-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_monotonicityChecker = new CapturingMonotonicityChecker();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WhenManifestNotFound_ShouldFail()
|
||||
{
|
||||
// Arrange
|
||||
var validator = CreateValidator();
|
||||
var bundleDir = Path.Combine(_tempDir, "missing-manifest");
|
||||
Directory.CreateDirectory(bundleDir);
|
||||
|
||||
var request = CreateRequest(bundleDir, "test-bundle", "secrets");
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Reason.Should().StartWith("manifest-not-found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WhenManifestParseError_ShouldFail()
|
||||
{
|
||||
// Arrange
|
||||
var validator = CreateValidator();
|
||||
var bundleDir = Path.Combine(_tempDir, "invalid-manifest");
|
||||
Directory.CreateDirectory(bundleDir);
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(bundleDir, "test-bundle.manifest.json"),
|
||||
"not-valid-json{{{");
|
||||
|
||||
var request = CreateRequest(bundleDir, "test-bundle", "secrets");
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Reason.Should().StartWith("manifest-parse-failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WhenFileDigestMismatch_ShouldFail()
|
||||
{
|
||||
// Arrange
|
||||
var validator = CreateValidator();
|
||||
var bundleDir = Path.Combine(_tempDir, "digest-mismatch");
|
||||
Directory.CreateDirectory(bundleDir);
|
||||
|
||||
var rulesContent = "{\"id\":\"test-rule\"}";
|
||||
var rulesPath = Path.Combine(bundleDir, "test-bundle.rules.jsonl");
|
||||
await File.WriteAllTextAsync(rulesPath, rulesContent);
|
||||
|
||||
// Create manifest with wrong digest
|
||||
var manifest = new
|
||||
{
|
||||
bundleId = "test-bundle",
|
||||
bundleType = "secrets",
|
||||
version = "2026.1.0",
|
||||
ruleCount = 1,
|
||||
files = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
name = "test-bundle.rules.jsonl",
|
||||
digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
sizeBytes = rulesContent.Length
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(bundleDir, "test-bundle.manifest.json"),
|
||||
JsonSerializer.Serialize(manifest));
|
||||
|
||||
var request = CreateRequest(bundleDir, "test-bundle", "secrets");
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Reason.Should().Contain("digest-mismatch");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WhenFileMissing_ShouldFail()
|
||||
{
|
||||
// Arrange
|
||||
var validator = CreateValidator();
|
||||
var bundleDir = Path.Combine(_tempDir, "file-missing");
|
||||
Directory.CreateDirectory(bundleDir);
|
||||
|
||||
var manifest = new
|
||||
{
|
||||
bundleId = "test-bundle",
|
||||
bundleType = "secrets",
|
||||
version = "2026.1.0",
|
||||
ruleCount = 1,
|
||||
files = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
name = "test-bundle.rules.jsonl",
|
||||
digest = "sha256:abcd1234",
|
||||
sizeBytes = 100
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(bundleDir, "test-bundle.manifest.json"),
|
||||
JsonSerializer.Serialize(manifest));
|
||||
|
||||
var request = CreateRequest(bundleDir, "test-bundle", "secrets");
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Reason.Should().Contain("file-missing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WhenSignatureRequiredButMissing_ShouldFail()
|
||||
{
|
||||
// Arrange
|
||||
var validator = CreateValidator();
|
||||
var bundleDir = await CreateValidBundleAsync("sig-required");
|
||||
|
||||
var request = CreateRequest(
|
||||
bundleDir,
|
||||
"test-bundle",
|
||||
"secrets",
|
||||
signatureEnvelope: null,
|
||||
requireSignature: true);
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Reason.Should().Be("signature-required-but-missing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WhenVersionNonMonotonic_ShouldFail()
|
||||
{
|
||||
// Arrange
|
||||
var monotonicityChecker = new NonMonotonicChecker();
|
||||
var validator = CreateValidator(monotonicityChecker);
|
||||
var bundleDir = await CreateValidBundleAsync("non-monotonic");
|
||||
|
||||
var request = CreateRequest(
|
||||
bundleDir,
|
||||
"test-bundle",
|
||||
"secrets",
|
||||
requireSignature: false);
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Reason.Should().StartWith("version-non-monotonic");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WhenAllChecksPass_ShouldSucceed()
|
||||
{
|
||||
// Arrange
|
||||
var validator = CreateValidator();
|
||||
var bundleDir = await CreateValidBundleAsync("all-pass");
|
||||
|
||||
var request = CreateRequest(
|
||||
bundleDir,
|
||||
"test-bundle",
|
||||
"secrets",
|
||||
requireSignature: false);
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Reason.Should().Be("rulebundle-validated");
|
||||
result.RuleCount.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WhenForceActivateWithOlderVersion_ShouldSucceed()
|
||||
{
|
||||
// Arrange
|
||||
var monotonicityChecker = new NonMonotonicChecker();
|
||||
var validator = CreateValidator(monotonicityChecker);
|
||||
var bundleDir = await CreateValidBundleAsync("force-activate");
|
||||
|
||||
var request = CreateRequest(
|
||||
bundleDir,
|
||||
"test-bundle",
|
||||
"secrets",
|
||||
requireSignature: false,
|
||||
forceActivate: true,
|
||||
forceActivateReason: "Rollback due to compatibility issue");
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Reason.Should().Be("rulebundle-validated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_ShouldRecordActivation()
|
||||
{
|
||||
// Arrange
|
||||
var validator = CreateValidator();
|
||||
var bundleDir = await CreateValidBundleAsync("record-activation");
|
||||
|
||||
var request = CreateRequest(
|
||||
bundleDir,
|
||||
"test-bundle",
|
||||
"secrets",
|
||||
requireSignature: false);
|
||||
|
||||
// Act
|
||||
var result = await validator.ValidateAsync(request);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
_monotonicityChecker.RecordedActivations.Should().HaveCount(1);
|
||||
_monotonicityChecker.RecordedActivations[0].BundleType.Should().Contain("secrets");
|
||||
}
|
||||
|
||||
private RuleBundleValidator CreateValidator(IVersionMonotonicityChecker? checker = null)
|
||||
{
|
||||
return new RuleBundleValidator(
|
||||
new DsseVerifier(),
|
||||
checker ?? _monotonicityChecker,
|
||||
NullLogger<RuleBundleValidator>.Instance);
|
||||
}
|
||||
|
||||
private async Task<string> CreateValidBundleAsync(string name)
|
||||
{
|
||||
var bundleDir = Path.Combine(_tempDir, name);
|
||||
Directory.CreateDirectory(bundleDir);
|
||||
|
||||
// Create rules file
|
||||
var rulesContent = "{\"id\":\"test-rule-1\",\"name\":\"Test Rule\",\"pattern\":\"SECRET_\"}\n" +
|
||||
"{\"id\":\"test-rule-2\",\"name\":\"Another Rule\",\"pattern\":\"API_KEY_\"}";
|
||||
var rulesPath = Path.Combine(bundleDir, "test-bundle.rules.jsonl");
|
||||
await File.WriteAllTextAsync(rulesPath, rulesContent);
|
||||
|
||||
// Compute digest
|
||||
var rulesBytes = Encoding.UTF8.GetBytes(rulesContent);
|
||||
var rulesDigest = $"sha256:{Convert.ToHexString(SHA256.HashData(rulesBytes)).ToLowerInvariant()}";
|
||||
|
||||
// Create manifest
|
||||
var manifest = new
|
||||
{
|
||||
bundleId = "test-bundle",
|
||||
bundleType = "secrets",
|
||||
version = "2026.1.0",
|
||||
ruleCount = 2,
|
||||
files = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
name = "test-bundle.rules.jsonl",
|
||||
digest = rulesDigest,
|
||||
sizeBytes = rulesBytes.Length
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(bundleDir, "test-bundle.manifest.json"),
|
||||
JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
return bundleDir;
|
||||
}
|
||||
|
||||
private static RuleBundleValidationRequest CreateRequest(
|
||||
string bundleDir,
|
||||
string bundleId,
|
||||
string bundleType,
|
||||
DsseEnvelope? signatureEnvelope = null,
|
||||
TrustRootConfig? trustRoots = null,
|
||||
bool requireSignature = false,
|
||||
bool forceActivate = false,
|
||||
string? forceActivateReason = null)
|
||||
{
|
||||
return new RuleBundleValidationRequest(
|
||||
TenantId: "tenant-test",
|
||||
BundleId: bundleId,
|
||||
BundleType: bundleType,
|
||||
Version: "2026.1.0",
|
||||
BundleDirectory: bundleDir,
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
SignatureEnvelope: signatureEnvelope,
|
||||
TrustRoots: trustRoots ?? TrustRootConfig.Empty("/tmp"),
|
||||
RequireSignature: requireSignature,
|
||||
ForceActivate: forceActivate,
|
||||
ForceActivateReason: forceActivateReason);
|
||||
}
|
||||
|
||||
private sealed class CapturingMonotonicityChecker : IVersionMonotonicityChecker
|
||||
{
|
||||
public List<(string TenantId, string BundleType, BundleVersion Version)> RecordedActivations { get; } = [];
|
||||
|
||||
public Task<MonotonicityCheckResult> CheckAsync(
|
||||
string tenantId,
|
||||
string bundleType,
|
||||
BundleVersion incomingVersion,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new MonotonicityCheckResult(
|
||||
IsMonotonic: true,
|
||||
CurrentVersion: null,
|
||||
CurrentBundleDigest: null,
|
||||
CurrentActivatedAt: null,
|
||||
ReasonCode: "FIRST_ACTIVATION"));
|
||||
}
|
||||
|
||||
public Task RecordActivationAsync(
|
||||
string tenantId,
|
||||
string bundleType,
|
||||
BundleVersion version,
|
||||
string bundleDigest,
|
||||
bool wasForceActivated = false,
|
||||
string? forceActivateReason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
RecordedActivations.Add((tenantId, bundleType, version));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NonMonotonicChecker : IVersionMonotonicityChecker
|
||||
{
|
||||
public Task<MonotonicityCheckResult> CheckAsync(
|
||||
string tenantId,
|
||||
string bundleType,
|
||||
BundleVersion incomingVersion,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new MonotonicityCheckResult(
|
||||
IsMonotonic: false,
|
||||
CurrentVersion: BundleVersion.Parse("2026.12.0", DateTimeOffset.UtcNow),
|
||||
CurrentBundleDigest: "sha256:current",
|
||||
CurrentActivatedAt: DateTimeOffset.UtcNow.AddDays(-1),
|
||||
ReasonCode: "OLDER_VERSION"));
|
||||
}
|
||||
|
||||
public Task RecordActivationAsync(
|
||||
string tenantId,
|
||||
string bundleType,
|
||||
BundleVersion version,
|
||||
string bundleDigest,
|
||||
bool wasForceActivated = false,
|
||||
string? forceActivateReason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user