save progress
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user