433 lines
16 KiB
C#
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;
|
|
}
|
|
}
|