// ----------------------------------------------------------------------------- // 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; /// /// Validates rule bundles (secrets, malware, etc.) for offline import. /// Verifies signature, version monotonicity, and file digests. /// public sealed class RuleBundleValidator { private readonly DsseVerifier _dsseVerifier; private readonly IVersionMonotonicityChecker _monotonicityChecker; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; public RuleBundleValidator( DsseVerifier dsseVerifier, IVersionMonotonicityChecker monotonicityChecker, ILogger logger, TimeProvider? timeProvider = null) { _dsseVerifier = dsseVerifier ?? throw new ArgumentNullException(nameof(dsseVerifier)); _monotonicityChecker = monotonicityChecker ?? throw new ArgumentNullException(nameof(monotonicityChecker)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? TimeProvider.System; } /// /// Validates a rule bundle for import. /// public async Task 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(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(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(); foreach (var file in manifest.Files) { // Validate path to prevent traversal attacks if (!PathValidation.IsSafeRelativePath(file.Name)) { digestErrors.Add($"unsafe-path:{file.Name}"); continue; } var filePath = PathValidation.SafeCombine(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 ?? _timeProvider.GetUtcNow()); } 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 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 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 }; } /// /// Request for validating a rule bundle. /// 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); /// /// Result of rule bundle validation. /// public sealed record RuleBundleValidationResult { public bool IsValid { get; init; } public string Reason { get; init; } = string.Empty; public IReadOnlyList VerificationLog { get; init; } = []; public int RuleCount { get; init; } public string? SignerKeyId { get; init; } public static RuleBundleValidationResult Success( string reason, IReadOnlyList verificationLog, int ruleCount, string? signerKeyId) => new() { IsValid = true, Reason = reason, VerificationLog = verificationLog, RuleCount = ruleCount, SignerKeyId = signerKeyId }; public static RuleBundleValidationResult Failure( string reason, IReadOnlyList verificationLog) => new() { IsValid = false, Reason = reason, VerificationLog = verificationLog }; } /// /// Manifest for a rule bundle. /// 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 Files { get; set; } = []; } /// /// File entry in a rule bundle manifest. /// internal sealed class RuleBundleFileEntry { public string Name { get; set; } = string.Empty; public string Digest { get; set; } = string.Empty; public long SizeBytes { get; set; } } /// /// Utility methods for path validation and security. /// internal static class PathValidation { /// /// Validates that a relative path does not escape the bundle root. /// public static bool IsSafeRelativePath(string? relativePath) { if (string.IsNullOrWhiteSpace(relativePath)) { return false; } // Check for absolute paths if (Path.IsPathRooted(relativePath)) { return false; } // Check for path traversal sequences var normalized = relativePath.Replace('\\', '/'); var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries); var depth = 0; foreach (var segment in segments) { if (segment == "..") { depth--; if (depth < 0) { return false; } } else if (segment != ".") { depth++; } } // Also check for null bytes if (relativePath.Contains('\0')) { return false; } return true; } /// /// Combines a root path with a relative path, validating that the result does not escape the root. /// public static string SafeCombine(string rootPath, string relativePath) { if (!IsSafeRelativePath(relativePath)) { throw new ArgumentException( $"Invalid relative path: path traversal or absolute path detected in '{relativePath}'", nameof(relativePath)); } var combined = Path.GetFullPath(Path.Combine(rootPath, relativePath)); var normalizedRoot = Path.GetFullPath(rootPath); // Ensure the combined path starts with the root path if (!combined.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException( $"Path '{relativePath}' escapes root directory", nameof(relativePath)); } return combined; } }