Files
git.stella-ops.org/src/AirGap/StellaOps.AirGap.Importer/Validation/RuleBundleValidator.cs
2026-01-07 09:43:12 +02:00

433 lines
16 KiB
C#

// -----------------------------------------------------------------------------
// 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;
private readonly TimeProvider _timeProvider;
public RuleBundleValidator(
DsseVerifier dsseVerifier,
IVersionMonotonicityChecker monotonicityChecker,
ILogger<RuleBundleValidator> 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;
}
/// <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)
{
// 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<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; }
}
/// <summary>
/// Utility methods for path validation and security.
/// </summary>
internal static class PathValidation
{
/// <summary>
/// Validates that a relative path does not escape the bundle root.
/// </summary>
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;
}
/// <summary>
/// Combines a root path with a relative path, validating that the result does not escape the root.
/// </summary>
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;
}
}