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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,18 @@ public interface IOfflineRootStore
|
||||
Task<IReadOnlyList<RootCertificateInfo>> ListRootsAsync(
|
||||
RootType rootType,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get a rule bundle signing key by ID and bundle type.
|
||||
/// </summary>
|
||||
/// <param name="keyId">The key identifier.</param>
|
||||
/// <param name="bundleType">The bundle type (e.g., "secrets", "malware").</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The envelope key if found, null otherwise.</returns>
|
||||
Task<StellaOps.Attestor.Envelope.EnvelopeKey?> GetRuleBundleSigningKeyAsync(
|
||||
string keyId,
|
||||
string bundleType,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -81,7 +93,9 @@ public enum RootType
|
||||
/// <summary>Organization signing keys for bundle endorsement.</summary>
|
||||
OrgSigning,
|
||||
/// <summary>Rekor public keys for transparency log verification.</summary>
|
||||
Rekor
|
||||
Rekor,
|
||||
/// <summary>Rule bundle signing keys for secrets/malware rule bundles.</summary>
|
||||
RuleBundleSigning
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IRuleBundleSignatureVerifier.cs
|
||||
// Sprint: SPRINT_20260104_005_AIRGAP (Secret Offline Kit Integration)
|
||||
// Task: OKS-004 - Add Attestor mirror support for bundle verification
|
||||
// Description: Interface for verifying rule bundle signatures offline
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Service for verifying rule bundle (secrets, malware, etc.) signatures offline.
|
||||
/// Enables air-gapped environments to verify rule bundle signatures using
|
||||
/// locally stored signing keys.
|
||||
/// </summary>
|
||||
public interface IRuleBundleSignatureVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify a rule bundle signature.
|
||||
/// </summary>
|
||||
/// <param name="request">The verification request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result with detailed status.</returns>
|
||||
Task<RuleBundleSignatureResult> VerifyAsync(
|
||||
RuleBundleSignatureRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verify a rule bundle from a directory.
|
||||
/// </summary>
|
||||
/// <param name="bundleDirectory">Directory containing the rule bundle.</param>
|
||||
/// <param name="bundleId">Expected bundle identifier.</param>
|
||||
/// <param name="options">Verification options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result.</returns>
|
||||
Task<RuleBundleSignatureResult> VerifyDirectoryAsync(
|
||||
string bundleDirectory,
|
||||
string bundleId,
|
||||
RuleBundleVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for verifying a rule bundle signature.
|
||||
/// </summary>
|
||||
public sealed record RuleBundleSignatureRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The DSSE envelope containing the signature.
|
||||
/// </summary>
|
||||
public required byte[] EnvelopeBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The payload (manifest) that was signed.
|
||||
/// </summary>
|
||||
public required byte[] PayloadBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected bundle identifier.
|
||||
/// </summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected bundle type (e.g., "secrets", "malware").
|
||||
/// </summary>
|
||||
public required string BundleType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected bundle version.
|
||||
/// </summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID that should have signed the bundle (optional).
|
||||
/// </summary>
|
||||
public string? ExpectedKeyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of rule bundle signature verification.
|
||||
/// </summary>
|
||||
public sealed record RuleBundleSignatureResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the signature is valid.
|
||||
/// </summary>
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Key ID that signed the bundle.
|
||||
/// </summary>
|
||||
public string? SignerKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Algorithm used for signing.
|
||||
/// </summary>
|
||||
public string? Algorithm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the signature was verified.
|
||||
/// </summary>
|
||||
public DateTimeOffset VerifiedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if verification failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed verification issues.
|
||||
/// </summary>
|
||||
public IReadOnlyList<VerificationIssue> Issues { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Create a successful result.
|
||||
/// </summary>
|
||||
public static RuleBundleSignatureResult Success(
|
||||
string signerKeyId,
|
||||
string algorithm,
|
||||
DateTimeOffset verifiedAt) => new()
|
||||
{
|
||||
IsValid = true,
|
||||
SignerKeyId = signerKeyId,
|
||||
Algorithm = algorithm,
|
||||
VerifiedAt = verifiedAt
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Create a failed result.
|
||||
/// </summary>
|
||||
public static RuleBundleSignatureResult Failure(
|
||||
string error,
|
||||
DateTimeOffset verifiedAt,
|
||||
IReadOnlyList<VerificationIssue>? issues = null) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
Error = error,
|
||||
VerifiedAt = verifiedAt,
|
||||
Issues = issues ?? []
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for rule bundle verification.
|
||||
/// </summary>
|
||||
public sealed record RuleBundleVerificationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Path to the signing key file.
|
||||
/// </summary>
|
||||
public string? SigningKeyPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Expected signer key ID.
|
||||
/// </summary>
|
||||
public string? ExpectedKeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to require a valid signature.
|
||||
/// </summary>
|
||||
public bool RequireSignature { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use strict mode (fail on any warning).
|
||||
/// </summary>
|
||||
public bool StrictMode { get; init; }
|
||||
}
|
||||
@@ -8,8 +8,10 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Services;
|
||||
@@ -26,6 +28,8 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
private X509Certificate2Collection? _fulcioRoots;
|
||||
private X509Certificate2Collection? _orgSigningKeys;
|
||||
private X509Certificate2Collection? _rekorKeys;
|
||||
private X509Certificate2Collection? _ruleBundleSigningKeys;
|
||||
private readonly Dictionary<string, EnvelopeKey> _ruleBundleKeyCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly SemaphoreSlim _loadLock = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
@@ -75,6 +79,20 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
return _rekorKeys ?? new X509Certificate2Collection();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get rule bundle signing key certificates.
|
||||
/// </summary>
|
||||
public async Task<X509Certificate2Collection> GetRuleBundleSigningKeysAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_ruleBundleSigningKeys == null)
|
||||
{
|
||||
await LoadRootsAsync(RootType.RuleBundleSigning, cancellationToken);
|
||||
}
|
||||
|
||||
return _ruleBundleSigningKeys ?? new X509Certificate2Collection();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ImportRootsAsync(
|
||||
string pemPath,
|
||||
@@ -160,6 +178,66 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<EnvelopeKey?> GetRuleBundleSigningKeyAsync(
|
||||
string keyId,
|
||||
string bundleType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleType);
|
||||
|
||||
// Check cache first
|
||||
var cacheKey = $"{bundleType}:{keyId}";
|
||||
if (_ruleBundleKeyCache.TryGetValue(cacheKey, out var cachedKey))
|
||||
{
|
||||
return cachedKey;
|
||||
}
|
||||
|
||||
// Load signing keys if not loaded
|
||||
if (_ruleBundleSigningKeys == null)
|
||||
{
|
||||
await LoadRootsAsync(RootType.RuleBundleSigning, cancellationToken);
|
||||
}
|
||||
|
||||
// Look for the key in the certificate store
|
||||
if (_ruleBundleSigningKeys != null)
|
||||
{
|
||||
foreach (var cert in _ruleBundleSigningKeys)
|
||||
{
|
||||
var certKeyId = GetSubjectKeyIdentifier(cert) ?? ComputeThumbprint(cert);
|
||||
if (certKeyId.Equals(keyId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var envelopeKey = CreateEnvelopeKeyFromCertificate(cert);
|
||||
if (envelopeKey != null)
|
||||
{
|
||||
_ruleBundleKeyCache[cacheKey] = envelopeKey;
|
||||
return envelopeKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try loading from JSON key file
|
||||
var jsonKeyPath = GetRuleBundleKeyPath(bundleType, keyId);
|
||||
if (!string.IsNullOrEmpty(jsonKeyPath) && File.Exists(jsonKeyPath))
|
||||
{
|
||||
var envelopeKey = await LoadEnvelopeKeyFromJsonAsync(jsonKeyPath, cancellationToken);
|
||||
if (envelopeKey != null)
|
||||
{
|
||||
_ruleBundleKeyCache[cacheKey] = envelopeKey;
|
||||
return envelopeKey;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Rule bundle signing key not found: keyId={KeyId} bundleType={BundleType}",
|
||||
keyId,
|
||||
bundleType);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<RootCertificateInfo>> ListRootsAsync(
|
||||
RootType rootType,
|
||||
@@ -170,6 +248,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
RootType.Fulcio => await GetFulcioRootsAsync(cancellationToken),
|
||||
RootType.OrgSigning => await GetOrgSigningKeysAsync(cancellationToken),
|
||||
RootType.Rekor => await GetRekorKeysAsync(cancellationToken),
|
||||
RootType.RuleBundleSigning => await GetRuleBundleSigningKeysAsync(cancellationToken),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(rootType))
|
||||
};
|
||||
|
||||
@@ -297,6 +376,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
RootType.Fulcio => _options.FulcioBundlePath ?? "",
|
||||
RootType.OrgSigning => _options.OrgSigningBundlePath ?? "",
|
||||
RootType.Rekor => _options.RekorBundlePath ?? "",
|
||||
RootType.RuleBundleSigning => _options.RuleBundleSigningPath ?? "",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
@@ -305,6 +385,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
RootType.Fulcio => _options.FulcioBundlePath ?? Path.Combine(_options.BaseRootPath, "fulcio"),
|
||||
RootType.OrgSigning => _options.OrgSigningBundlePath ?? Path.Combine(_options.BaseRootPath, "org-signing"),
|
||||
RootType.Rekor => _options.RekorBundlePath ?? Path.Combine(_options.BaseRootPath, "rekor"),
|
||||
RootType.RuleBundleSigning => _options.RuleBundleSigningPath ?? Path.Combine(_options.BaseRootPath, "rule-bundle-signing"),
|
||||
_ => _options.BaseRootPath
|
||||
};
|
||||
|
||||
@@ -320,6 +401,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
RootType.Fulcio => Path.Combine(_options.OfflineKitPath, "roots", "fulcio"),
|
||||
RootType.OrgSigning => Path.Combine(_options.OfflineKitPath, "roots", "org-signing"),
|
||||
RootType.Rekor => Path.Combine(_options.OfflineKitPath, "roots", "rekor"),
|
||||
RootType.RuleBundleSigning => Path.Combine(_options.OfflineKitPath, "roots", "rule-bundle-signing"),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -329,6 +411,7 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
RootType.Fulcio => _fulcioRoots,
|
||||
RootType.OrgSigning => _orgSigningKeys,
|
||||
RootType.Rekor => _rekorKeys,
|
||||
RootType.RuleBundleSigning => _ruleBundleSigningKeys,
|
||||
_ => null
|
||||
};
|
||||
|
||||
@@ -345,6 +428,9 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
case RootType.Rekor:
|
||||
_rekorKeys = collection;
|
||||
break;
|
||||
case RootType.RuleBundleSigning:
|
||||
_ruleBundleSigningKeys = collection;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,9 +447,130 @@ public sealed class FileSystemRootStore : IOfflineRootStore
|
||||
case RootType.Rekor:
|
||||
_rekorKeys = null;
|
||||
break;
|
||||
case RootType.RuleBundleSigning:
|
||||
_ruleBundleSigningKeys = null;
|
||||
_ruleBundleKeyCache.Clear();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private string? GetRuleBundleKeyPath(string bundleType, string keyId)
|
||||
{
|
||||
var basePath = _options.RuleBundleSigningPath ?? Path.Combine(_options.BaseRootPath, "rule-bundle-signing");
|
||||
if (string.IsNullOrEmpty(basePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try bundle-type specific path first
|
||||
var typeSpecificPath = Path.Combine(basePath, bundleType, $"{keyId}.json");
|
||||
if (File.Exists(typeSpecificPath))
|
||||
{
|
||||
return typeSpecificPath;
|
||||
}
|
||||
|
||||
// Fall back to general path
|
||||
return Path.Combine(basePath, $"{keyId}.json");
|
||||
}
|
||||
|
||||
private static async Task<EnvelopeKey?> LoadEnvelopeKeyFromJsonAsync(
|
||||
string path,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var algorithm = doc.RootElement.TryGetProperty("algorithm", out var alg)
|
||||
? alg.GetString() ?? "ES256"
|
||||
: "ES256";
|
||||
var keyId = doc.RootElement.TryGetProperty("keyId", out var kid)
|
||||
? kid.GetString() ?? ""
|
||||
: "";
|
||||
var publicKeyBase64 = doc.RootElement.TryGetProperty("publicKey", out var pk)
|
||||
? pk.GetString()
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrEmpty(publicKeyBase64))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var publicKeyBytes = Convert.FromBase64String(publicKeyBase64);
|
||||
|
||||
// Create EnvelopeKey based on algorithm
|
||||
return algorithm.ToUpperInvariant() switch
|
||||
{
|
||||
"ES256" => EnvelopeKey.CreateEcdsaVerifier("ES256", LoadEcParameters(publicKeyBytes, ECCurve.NamedCurves.nistP256)),
|
||||
"ES384" => EnvelopeKey.CreateEcdsaVerifier("ES384", LoadEcParameters(publicKeyBytes, ECCurve.NamedCurves.nistP384)),
|
||||
"ES512" => EnvelopeKey.CreateEcdsaVerifier("ES512", LoadEcParameters(publicKeyBytes, ECCurve.NamedCurves.nistP521)),
|
||||
"ED25519" => EnvelopeKey.CreateEd25519Verifier(publicKeyBytes),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static ECParameters LoadEcParameters(byte[] publicKey, ECCurve curve)
|
||||
{
|
||||
// Assume the key is in uncompressed format (0x04 prefix + X + Y)
|
||||
var keySize = curve.Oid.Value switch
|
||||
{
|
||||
"1.2.840.10045.3.1.7" => 32, // P-256
|
||||
"1.3.132.0.34" => 48, // P-384
|
||||
"1.3.132.0.35" => 66, // P-521
|
||||
_ => 32
|
||||
};
|
||||
|
||||
if (publicKey.Length == 2 * keySize + 1 && publicKey[0] == 0x04)
|
||||
{
|
||||
return new ECParameters
|
||||
{
|
||||
Curve = curve,
|
||||
Q = new ECPoint
|
||||
{
|
||||
X = publicKey[1..(keySize + 1)],
|
||||
Y = publicKey[(keySize + 1)..]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Try to parse as SubjectPublicKeyInfo (DER format)
|
||||
using var ecdsa = ECDsa.Create();
|
||||
ecdsa.ImportSubjectPublicKeyInfo(publicKey, out _);
|
||||
return ecdsa.ExportParameters(false);
|
||||
}
|
||||
|
||||
private static EnvelopeKey? CreateEnvelopeKeyFromCertificate(X509Certificate2 cert)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ecdsa = cert.GetECDsaPublicKey();
|
||||
if (ecdsa != null)
|
||||
{
|
||||
var parameters = ecdsa.ExportParameters(false);
|
||||
var algorithmId = parameters.Curve.Oid.Value switch
|
||||
{
|
||||
"1.2.840.10045.3.1.7" => "ES256",
|
||||
"1.3.132.0.34" => "ES384",
|
||||
"1.3.132.0.35" => "ES512",
|
||||
_ => "ES256"
|
||||
};
|
||||
return EnvelopeKey.CreateEcdsaVerifier(algorithmId, parameters);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow and try other key types
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ComputeThumbprint(X509Certificate2 cert)
|
||||
{
|
||||
var hash = SHA256.HashData(cert.RawData);
|
||||
@@ -418,6 +625,11 @@ public sealed class OfflineRootStoreOptions
|
||||
/// </summary>
|
||||
public string? RekorBundlePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to rule bundle signing keys (file or directory).
|
||||
/// </summary>
|
||||
public string? RuleBundleSigningPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to Offline Kit installation.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RuleBundleSignatureVerifier.cs
|
||||
// Sprint: SPRINT_20260104_005_AIRGAP (Secret Offline Kit Integration)
|
||||
// Task: OKS-004 - Add Attestor mirror support for bundle verification
|
||||
// Description: Verifies rule bundle signatures offline
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Attestor.Offline.Abstractions;
|
||||
using StellaOps.Attestor.Offline.Models;
|
||||
|
||||
namespace StellaOps.Attestor.Offline.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies rule bundle (secrets, malware, etc.) signatures offline.
|
||||
/// </summary>
|
||||
public sealed class RuleBundleSignatureVerifier : IRuleBundleSignatureVerifier
|
||||
{
|
||||
private readonly IOfflineRootStore _rootStore;
|
||||
private readonly EnvelopeSignatureService _signatureService = new();
|
||||
private readonly ILogger<RuleBundleSignatureVerifier> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public RuleBundleSignatureVerifier(
|
||||
IOfflineRootStore rootStore,
|
||||
ILogger<RuleBundleSignatureVerifier> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_rootStore = rootStore ?? throw new ArgumentNullException(nameof(rootStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RuleBundleSignatureResult> VerifyAsync(
|
||||
RuleBundleSignatureRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundleId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundleType);
|
||||
|
||||
var verifiedAt = _timeProvider.GetUtcNow();
|
||||
var issues = new List<VerificationIssue>();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Verifying rule bundle signature: bundle_id={BundleId} bundle_type={BundleType} version={Version}",
|
||||
request.BundleId,
|
||||
request.BundleType,
|
||||
request.Version);
|
||||
|
||||
try
|
||||
{
|
||||
// Parse DSSE envelope
|
||||
DsseEnvelope envelope;
|
||||
try
|
||||
{
|
||||
envelope = ParseDsseEnvelope(request.EnvelopeBytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
VerificationIssueSeverity.Critical,
|
||||
"ENVELOPE_PARSE_FAILED",
|
||||
$"Failed to parse DSSE envelope: {ex.Message}"));
|
||||
return RuleBundleSignatureResult.Failure(
|
||||
$"envelope-parse-failed:{ex.GetType().Name.ToLowerInvariant()}",
|
||||
verifiedAt,
|
||||
issues);
|
||||
}
|
||||
|
||||
// Verify payload type
|
||||
if (envelope.PayloadType != "application/vnd.stellaops.rulebundle.manifest+json" &&
|
||||
envelope.PayloadType != "application/json")
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
VerificationIssueSeverity.Warning,
|
||||
"UNEXPECTED_PAYLOAD_TYPE",
|
||||
$"Unexpected payload type: {envelope.PayloadType}"));
|
||||
}
|
||||
|
||||
// Verify payload digest matches
|
||||
var envelopePayloadBytes = Convert.FromBase64String(envelope.Payload);
|
||||
var envelopePayloadDigest = ComputeSha256Digest(envelopePayloadBytes);
|
||||
var requestPayloadDigest = ComputeSha256Digest(request.PayloadBytes);
|
||||
|
||||
if (envelopePayloadDigest != requestPayloadDigest)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
VerificationIssueSeverity.Critical,
|
||||
"PAYLOAD_DIGEST_MISMATCH",
|
||||
$"Envelope payload digest {envelopePayloadDigest} does not match provided payload {requestPayloadDigest}"));
|
||||
return RuleBundleSignatureResult.Failure(
|
||||
"payload-digest-mismatch",
|
||||
verifiedAt,
|
||||
issues);
|
||||
}
|
||||
|
||||
// Verify signatures
|
||||
if (envelope.Signatures.Count == 0)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
VerificationIssueSeverity.Critical,
|
||||
"NO_SIGNATURES",
|
||||
"DSSE envelope has no signatures"));
|
||||
return RuleBundleSignatureResult.Failure(
|
||||
"no-signatures",
|
||||
verifiedAt,
|
||||
issues);
|
||||
}
|
||||
|
||||
// Get the signer key
|
||||
var signature = envelope.Signatures[0];
|
||||
var signerKeyId = signature.KeyId;
|
||||
|
||||
if (request.ExpectedKeyId != null &&
|
||||
!string.Equals(signerKeyId, request.ExpectedKeyId, StringComparison.Ordinal))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
VerificationIssueSeverity.Critical,
|
||||
"KEYID_MISMATCH",
|
||||
$"Expected key ID {request.ExpectedKeyId} but got {signerKeyId}"));
|
||||
return RuleBundleSignatureResult.Failure(
|
||||
$"keyid-mismatch:expected={request.ExpectedKeyId}:actual={signerKeyId}",
|
||||
verifiedAt,
|
||||
issues);
|
||||
}
|
||||
|
||||
// Look up the signing key from the root store
|
||||
var signingKey = await _rootStore.GetRuleBundleSigningKeyAsync(
|
||||
signerKeyId,
|
||||
request.BundleType,
|
||||
cancellationToken);
|
||||
|
||||
if (signingKey == null)
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
VerificationIssueSeverity.Critical,
|
||||
"KEY_NOT_FOUND",
|
||||
$"Signing key {signerKeyId} not found in root store for bundle type {request.BundleType}"));
|
||||
return RuleBundleSignatureResult.Failure(
|
||||
$"key-not-found:{signerKeyId}",
|
||||
verifiedAt,
|
||||
issues);
|
||||
}
|
||||
|
||||
// Verify the DSSE signature
|
||||
var signatureBytes = Convert.FromBase64String(signature.Sig);
|
||||
var dsseSignature = new EnvelopeSignature(
|
||||
signerKeyId,
|
||||
signingKey.AlgorithmId,
|
||||
signatureBytes);
|
||||
|
||||
var verifyResult = _signatureService.VerifyDsse(
|
||||
envelope.PayloadType,
|
||||
envelopePayloadBytes,
|
||||
dsseSignature,
|
||||
signingKey);
|
||||
|
||||
if (!verifyResult.IsSuccess || !verifyResult.Value)
|
||||
{
|
||||
var errorMessage = verifyResult.IsSuccess
|
||||
? "Signature verification failed"
|
||||
: $"Signature verification failed: {verifyResult.Error.Code}";
|
||||
|
||||
issues.Add(new VerificationIssue(
|
||||
VerificationIssueSeverity.Critical,
|
||||
"SIGNATURE_INVALID",
|
||||
errorMessage));
|
||||
return RuleBundleSignatureResult.Failure(
|
||||
"signature-invalid",
|
||||
verifiedAt,
|
||||
issues);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Rule bundle signature verified: bundle_id={BundleId} signer_key_id={SignerKeyId}",
|
||||
request.BundleId,
|
||||
signerKeyId);
|
||||
|
||||
return RuleBundleSignatureResult.Success(
|
||||
signerKeyId,
|
||||
signingKey.AlgorithmId,
|
||||
verifiedAt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Failed to verify rule bundle signature: bundle_id={BundleId}",
|
||||
request.BundleId);
|
||||
|
||||
issues.Add(new VerificationIssue(
|
||||
VerificationIssueSeverity.Critical,
|
||||
"VERIFICATION_ERROR",
|
||||
$"Verification failed: {ex.Message}"));
|
||||
|
||||
return RuleBundleSignatureResult.Failure(
|
||||
$"verification-error:{ex.GetType().Name.ToLowerInvariant()}",
|
||||
verifiedAt,
|
||||
issues);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RuleBundleSignatureResult> VerifyDirectoryAsync(
|
||||
string bundleDirectory,
|
||||
string bundleId,
|
||||
RuleBundleVerificationOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleDirectory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleId);
|
||||
|
||||
var verifiedAt = _timeProvider.GetUtcNow();
|
||||
var issues = new List<VerificationIssue>();
|
||||
options ??= new RuleBundleVerificationOptions();
|
||||
|
||||
// Find manifest file
|
||||
var manifestPath = Path.Combine(bundleDirectory, $"{bundleId}.manifest.json");
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
VerificationIssueSeverity.Critical,
|
||||
"MANIFEST_NOT_FOUND",
|
||||
$"Manifest not found at {manifestPath}"));
|
||||
|
||||
if (options.RequireSignature)
|
||||
{
|
||||
return RuleBundleSignatureResult.Failure(
|
||||
"manifest-not-found",
|
||||
verifiedAt,
|
||||
issues);
|
||||
}
|
||||
|
||||
return new RuleBundleSignatureResult
|
||||
{
|
||||
IsValid = true,
|
||||
VerifiedAt = verifiedAt,
|
||||
Issues = issues
|
||||
};
|
||||
}
|
||||
|
||||
// Find signature file
|
||||
var signaturePath = Path.Combine(bundleDirectory, $"{bundleId}.manifest.sig");
|
||||
if (!File.Exists(signaturePath))
|
||||
{
|
||||
issues.Add(new VerificationIssue(
|
||||
VerificationIssueSeverity.Warning,
|
||||
"SIGNATURE_NOT_FOUND",
|
||||
$"Signature file not found at {signaturePath}"));
|
||||
|
||||
if (options.RequireSignature)
|
||||
{
|
||||
return RuleBundleSignatureResult.Failure(
|
||||
"signature-not-found",
|
||||
verifiedAt,
|
||||
issues);
|
||||
}
|
||||
|
||||
return new RuleBundleSignatureResult
|
||||
{
|
||||
IsValid = true,
|
||||
VerifiedAt = verifiedAt,
|
||||
Issues = issues
|
||||
};
|
||||
}
|
||||
|
||||
// Read manifest and signature
|
||||
var manifestBytes = await File.ReadAllBytesAsync(manifestPath, cancellationToken);
|
||||
var signatureBytes = await File.ReadAllBytesAsync(signaturePath, cancellationToken);
|
||||
|
||||
// Parse manifest to get bundle type and version
|
||||
string bundleType;
|
||||
string version;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(manifestBytes);
|
||||
bundleType = doc.RootElement.TryGetProperty("bundleType", out var bt)
|
||||
? bt.GetString() ?? "unknown"
|
||||
: "unknown";
|
||||
version = doc.RootElement.TryGetProperty("version", out var v)
|
||||
? v.GetString() ?? "0.0"
|
||||
: "0.0";
|
||||
}
|
||||
catch
|
||||
{
|
||||
bundleType = "unknown";
|
||||
version = "0.0";
|
||||
}
|
||||
|
||||
var request = new RuleBundleSignatureRequest
|
||||
{
|
||||
EnvelopeBytes = signatureBytes,
|
||||
PayloadBytes = manifestBytes,
|
||||
BundleId = bundleId,
|
||||
BundleType = bundleType,
|
||||
Version = version,
|
||||
ExpectedKeyId = options.ExpectedKeyId
|
||||
};
|
||||
|
||||
return await VerifyAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
private static DsseEnvelope ParseDsseEnvelope(byte[] envelopeBytes)
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(envelopeBytes);
|
||||
var envelope = JsonSerializer.Deserialize<DsseEnvelope>(json, JsonOptions);
|
||||
return envelope ?? throw new InvalidOperationException("Failed to parse DSSE envelope");
|
||||
}
|
||||
|
||||
private static string ComputeSha256Digest(byte[] data)
|
||||
{
|
||||
var hash = SHA256.HashData(data);
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope structure for parsing.
|
||||
/// </summary>
|
||||
internal sealed class DsseEnvelope
|
||||
{
|
||||
public string PayloadType { get; set; } = string.Empty;
|
||||
public string Payload { get; set; } = string.Empty;
|
||||
public List<DsseSignature> Signatures { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature structure.
|
||||
/// </summary>
|
||||
internal sealed class DsseSignature
|
||||
{
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
public string Sig { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Determinism.Abstractions;
|
||||
using StellaOps.Scanner.Storage.Models;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
@@ -20,6 +21,8 @@ public sealed class ScanMetricsCollector : IDisposable
|
||||
{
|
||||
private readonly IScanMetricsRepository _repository;
|
||||
private readonly ILogger<ScanMetricsCollector> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
private readonly Guid _scanId;
|
||||
private readonly Guid _tenantId;
|
||||
@@ -58,7 +61,9 @@ public sealed class ScanMetricsCollector : IDisposable
|
||||
Guid tenantId,
|
||||
string artifactDigest,
|
||||
string artifactType,
|
||||
string scannerVersion)
|
||||
string scannerVersion,
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
@@ -67,7 +72,9 @@ public sealed class ScanMetricsCollector : IDisposable
|
||||
_artifactDigest = artifactDigest ?? throw new ArgumentNullException(nameof(artifactDigest));
|
||||
_artifactType = artifactType ?? throw new ArgumentNullException(nameof(artifactType));
|
||||
_scannerVersion = scannerVersion ?? throw new ArgumentNullException(nameof(scannerVersion));
|
||||
_metricsId = Guid.NewGuid();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
_metricsId = _guidProvider.NewGuid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -80,7 +87,7 @@ public sealed class ScanMetricsCollector : IDisposable
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
_startedAt = DateTimeOffset.UtcNow;
|
||||
_startedAt = _timeProvider.GetUtcNow();
|
||||
_totalStopwatch.Start();
|
||||
_logger.LogDebug("Started metrics collection for scan {ScanId}", _scanId);
|
||||
}
|
||||
@@ -98,7 +105,7 @@ public sealed class ScanMetricsCollector : IDisposable
|
||||
return NoOpDisposable.Instance;
|
||||
}
|
||||
|
||||
var tracker = new PhaseTracker(this, phaseName, DateTimeOffset.UtcNow);
|
||||
var tracker = new PhaseTracker(this, phaseName, _timeProvider.GetUtcNow());
|
||||
_phases[phaseName] = tracker;
|
||||
_logger.LogDebug("Started phase {PhaseName} for scan {ScanId}", phaseName, _scanId);
|
||||
return tracker;
|
||||
@@ -138,7 +145,7 @@ public sealed class ScanMetricsCollector : IDisposable
|
||||
|
||||
_phases.Remove(phaseName);
|
||||
|
||||
var finishedAt = DateTimeOffset.UtcNow;
|
||||
var finishedAt = _timeProvider.GetUtcNow();
|
||||
var phase = new ExecutionPhase
|
||||
{
|
||||
MetricsId = _metricsId,
|
||||
@@ -214,7 +221,7 @@ public sealed class ScanMetricsCollector : IDisposable
|
||||
public async Task CompleteAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_totalStopwatch.Stop();
|
||||
var finishedAt = DateTimeOffset.UtcNow;
|
||||
var finishedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
// Calculate phase timings
|
||||
var phases = BuildPhaseTimings();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scanner.Explainability.Assumptions;
|
||||
|
||||
namespace StellaOps.Scanner.Explainability.Falsifiability;
|
||||
@@ -60,10 +61,17 @@ public interface IFalsifiabilityGenerator
|
||||
public sealed class FalsifiabilityGenerator : IFalsifiabilityGenerator
|
||||
{
|
||||
private readonly ILogger<FalsifiabilityGenerator> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public FalsifiabilityGenerator(ILogger<FalsifiabilityGenerator> logger)
|
||||
public FalsifiabilityGenerator(
|
||||
ILogger<FalsifiabilityGenerator> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -164,12 +172,12 @@ public sealed class FalsifiabilityGenerator : IFalsifiabilityGenerator
|
||||
|
||||
return new FalsifiabilityCriteria
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Id = _guidProvider.NewGuid().ToString("N"),
|
||||
FindingId = input.FindingId,
|
||||
Criteria = [.. criteria],
|
||||
Status = status,
|
||||
Summary = summary,
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
GeneratedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Copyright (c) StellaOps
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scanner.Explainability.Assumptions;
|
||||
using StellaOps.Scanner.Explainability.Confidence;
|
||||
using StellaOps.Scanner.Explainability.Falsifiability;
|
||||
@@ -118,10 +119,17 @@ public sealed class RiskReportGenerator : IRiskReportGenerator
|
||||
private const string EngineVersionValue = "1.0.0";
|
||||
|
||||
private readonly IEvidenceDensityScorer _scorer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public RiskReportGenerator(IEvidenceDensityScorer scorer)
|
||||
public RiskReportGenerator(
|
||||
IEvidenceDensityScorer scorer,
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_scorer = scorer;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -140,7 +148,7 @@ public sealed class RiskReportGenerator : IRiskReportGenerator
|
||||
|
||||
return new RiskReport
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Id = _guidProvider.NewGuid().ToString("N"),
|
||||
FindingId = input.FindingId,
|
||||
VulnerabilityId = input.VulnerabilityId,
|
||||
PackageName = input.PackageName,
|
||||
@@ -151,7 +159,7 @@ public sealed class RiskReportGenerator : IRiskReportGenerator
|
||||
Explanation = explanation,
|
||||
DetailedNarrative = narrative,
|
||||
RecommendedActions = [.. actions],
|
||||
GeneratedAt = DateTimeOffset.UtcNow,
|
||||
GeneratedAt = _timeProvider.GetUtcNow(),
|
||||
EngineVersion = EngineVersionValue
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,4 +12,8 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ICredentialResolver _credentialResolver;
|
||||
private readonly ILogger<DockerConnectionTester> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -28,11 +29,13 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester
|
||||
public DockerConnectionTester(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ICredentialResolver credentialResolver,
|
||||
ILogger<DockerConnectionTester> logger)
|
||||
ILogger<DockerConnectionTester> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_credentialResolver = credentialResolver;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ConnectionTestResult> TestAsync(
|
||||
@@ -47,7 +50,7 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid configuration format",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
TestedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,7 +103,7 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Registry accessible but image test failed: {imageTestResult.Message}",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
@@ -112,7 +115,7 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester
|
||||
{
|
||||
Success = true,
|
||||
Message = "Successfully connected to Docker registry",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
@@ -125,21 +128,21 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester
|
||||
{
|
||||
Success = false,
|
||||
Message = "Authentication required - configure credentials",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = details
|
||||
},
|
||||
HttpStatusCode.Forbidden => new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Access denied - check permissions",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = details
|
||||
},
|
||||
_ => new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Registry returned {response.StatusCode}",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = details
|
||||
}
|
||||
};
|
||||
@@ -151,7 +154,7 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Connection failed: {ex.Message}",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
TestedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||
@@ -160,7 +163,7 @@ public sealed class DockerConnectionTester : ISourceTypeConnectionTester
|
||||
{
|
||||
Success = false,
|
||||
Message = "Connection timed out",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
TestedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ICredentialResolver _credentialResolver;
|
||||
private readonly ILogger<GitConnectionTester> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -28,11 +29,13 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester
|
||||
public GitConnectionTester(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ICredentialResolver credentialResolver,
|
||||
ILogger<GitConnectionTester> logger)
|
||||
ILogger<GitConnectionTester> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_credentialResolver = credentialResolver;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ConnectionTestResult> TestAsync(
|
||||
@@ -47,7 +50,7 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid configuration format",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
TestedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,7 +129,7 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester
|
||||
{
|
||||
Success = true,
|
||||
Message = "Successfully connected to Git repository",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
@@ -139,28 +142,28 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester
|
||||
{
|
||||
Success = false,
|
||||
Message = "Authentication required - configure credentials",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = details
|
||||
},
|
||||
HttpStatusCode.Forbidden => new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Access denied - check token permissions",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = details
|
||||
},
|
||||
HttpStatusCode.NotFound => new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Repository not found - check URL and access",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = details
|
||||
},
|
||||
_ => new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Server returned {response.StatusCode}",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = details
|
||||
}
|
||||
};
|
||||
@@ -172,7 +175,7 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Connection failed: {ex.Message}",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["repositoryUrl"] = config.RepositoryUrl
|
||||
@@ -185,7 +188,7 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester
|
||||
{
|
||||
Success = false,
|
||||
Message = "Connection timed out",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
TestedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -202,7 +205,7 @@ public sealed class GitConnectionTester : ISourceTypeConnectionTester
|
||||
{
|
||||
Success = true,
|
||||
Message = "SSH configuration accepted - connection will be validated on first scan",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["repositoryUrl"] = config.RepositoryUrl,
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ICredentialResolver _credentialResolver;
|
||||
private readonly ILogger<ZastavaConnectionTester> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -29,11 +30,13 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester
|
||||
public ZastavaConnectionTester(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ICredentialResolver credentialResolver,
|
||||
ILogger<ZastavaConnectionTester> logger)
|
||||
ILogger<ZastavaConnectionTester> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_credentialResolver = credentialResolver;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ConnectionTestResult> TestAsync(
|
||||
@@ -48,7 +51,7 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid configuration format",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
TestedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,7 +93,7 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester
|
||||
{
|
||||
Success = true,
|
||||
Message = "Successfully connected to registry",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = details
|
||||
};
|
||||
}
|
||||
@@ -104,28 +107,28 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester
|
||||
{
|
||||
Success = false,
|
||||
Message = "Authentication failed - check credentials",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = details
|
||||
},
|
||||
HttpStatusCode.Forbidden => new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Access denied - insufficient permissions",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = details
|
||||
},
|
||||
HttpStatusCode.NotFound => new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Registry endpoint not found - check URL",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = details
|
||||
},
|
||||
_ => new ConnectionTestResult
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Registry returned {response.StatusCode}",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = details
|
||||
}
|
||||
};
|
||||
@@ -137,7 +140,7 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Connection failed: {ex.Message}",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["registryUrl"] = config.RegistryUrl,
|
||||
@@ -151,7 +154,7 @@ public sealed class ZastavaConnectionTester : ISourceTypeConnectionTester
|
||||
{
|
||||
Success = false,
|
||||
Message = "Connection timed out",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["registryUrl"] = config.RegistryUrl,
|
||||
|
||||
@@ -24,6 +24,7 @@ public sealed class CliSourceHandler : ISourceTypeHandler
|
||||
{
|
||||
private readonly ISourceConfigValidator _configValidator;
|
||||
private readonly ILogger<CliSourceHandler> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -38,10 +39,12 @@ public sealed class CliSourceHandler : ISourceTypeHandler
|
||||
|
||||
public CliSourceHandler(
|
||||
ISourceConfigValidator configValidator,
|
||||
ILogger<CliSourceHandler> logger)
|
||||
ILogger<CliSourceHandler> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_configValidator = configValidator;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -102,7 +105,7 @@ public sealed class CliSourceHandler : ISourceTypeHandler
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid configuration",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
TestedAt = _timeProvider.GetUtcNow()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -112,7 +115,7 @@ public sealed class CliSourceHandler : ISourceTypeHandler
|
||||
{
|
||||
Success = true,
|
||||
Message = "CLI source configuration is valid",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["allowedTools"] = config.AllowedTools,
|
||||
@@ -242,8 +245,8 @@ public sealed class CliSourceHandler : ISourceTypeHandler
|
||||
Token = token,
|
||||
TokenHash = Convert.ToHexString(tokenHash).ToLowerInvariant(),
|
||||
SourceId = source.SourceId,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.Add(validity),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
ExpiresAt = _timeProvider.GetUtcNow().Add(validity),
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
|
||||
private readonly ISourceConfigValidator _configValidator;
|
||||
private readonly IImageDiscoveryService _discoveryService;
|
||||
private readonly ILogger<DockerSourceHandler> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -38,13 +39,15 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
|
||||
ICredentialResolver credentialResolver,
|
||||
ISourceConfigValidator configValidator,
|
||||
IImageDiscoveryService discoveryService,
|
||||
ILogger<DockerSourceHandler> logger)
|
||||
ILogger<DockerSourceHandler> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_clientFactory = clientFactory;
|
||||
_credentialResolver = credentialResolver;
|
||||
_configValidator = configValidator;
|
||||
_discoveryService = discoveryService;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
|
||||
@@ -136,7 +139,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
|
||||
// Apply age filter if specified
|
||||
if (imageSpec.MaxAgeHours.HasValue)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow.AddHours(-imageSpec.MaxAgeHours.Value);
|
||||
var cutoff = _timeProvider.GetUtcNow().AddHours(-imageSpec.MaxAgeHours.Value);
|
||||
sortedTags = sortedTags
|
||||
.Where(t => t.LastUpdated == null || t.LastUpdated >= cutoff)
|
||||
.ToList();
|
||||
@@ -181,7 +184,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid configuration",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
TestedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -198,7 +201,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
|
||||
{
|
||||
Success = false,
|
||||
Message = "Registry ping failed",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["registryUrl"] = config.RegistryUrl
|
||||
@@ -216,7 +219,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
|
||||
{
|
||||
Success = true,
|
||||
Message = "Successfully connected to registry",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["registryUrl"] = config.RegistryUrl,
|
||||
@@ -230,7 +233,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
|
||||
{
|
||||
Success = true,
|
||||
Message = "Successfully connected to registry",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["registryUrl"] = config.RegistryUrl
|
||||
@@ -244,7 +247,7 @@ public sealed class DockerSourceHandler : ISourceTypeHandler
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Connection failed: {ex.Message}",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
TestedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle
|
||||
private readonly ICredentialResolver _credentialResolver;
|
||||
private readonly ISourceConfigValidator _configValidator;
|
||||
private readonly ILogger<GitSourceHandler> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -35,12 +36,14 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle
|
||||
IGitClientFactory gitClientFactory,
|
||||
ICredentialResolver credentialResolver,
|
||||
ISourceConfigValidator configValidator,
|
||||
ILogger<GitSourceHandler> logger)
|
||||
ILogger<GitSourceHandler> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_gitClientFactory = gitClientFactory;
|
||||
_credentialResolver = credentialResolver;
|
||||
_configValidator = configValidator;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
|
||||
@@ -160,7 +163,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid configuration",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
TestedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -176,7 +179,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle
|
||||
{
|
||||
Success = false,
|
||||
Message = "Repository not found or inaccessible",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["repositoryUrl"] = config.RepositoryUrl,
|
||||
@@ -189,7 +192,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle
|
||||
{
|
||||
Success = true,
|
||||
Message = "Successfully connected to repository",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["repositoryUrl"] = config.RepositoryUrl,
|
||||
@@ -206,7 +209,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Connection failed: {ex.Message}",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
TestedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -270,7 +273,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle
|
||||
sender.TryGetProperty("login", out var login)
|
||||
? login.GetString()
|
||||
: null,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -303,7 +306,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle
|
||||
? num.GetInt32().ToString()
|
||||
: ""
|
||||
},
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -330,7 +333,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle
|
||||
Actor = root.TryGetProperty("user_name", out var userName)
|
||||
? userName.GetString()
|
||||
: null,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -361,7 +364,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle
|
||||
? mrAction.GetString() ?? ""
|
||||
: ""
|
||||
},
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -371,7 +374,7 @@ public sealed class GitSourceHandler : ISourceTypeHandler, IWebhookCapableHandle
|
||||
{
|
||||
EventType = "unknown",
|
||||
Reference = "",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa
|
||||
private readonly ICredentialResolver _credentialResolver;
|
||||
private readonly ISourceConfigValidator _configValidator;
|
||||
private readonly ILogger<ZastavaSourceHandler> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -36,12 +37,14 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa
|
||||
IRegistryClientFactory clientFactory,
|
||||
ICredentialResolver credentialResolver,
|
||||
ISourceConfigValidator configValidator,
|
||||
ILogger<ZastavaSourceHandler> logger)
|
||||
ILogger<ZastavaSourceHandler> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_clientFactory = clientFactory;
|
||||
_credentialResolver = credentialResolver;
|
||||
_configValidator = configValidator;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ScanTarget>> DiscoverTargetsAsync(
|
||||
@@ -167,7 +170,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid configuration",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
TestedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,7 +186,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa
|
||||
{
|
||||
Success = false,
|
||||
Message = "Registry ping failed",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["registryUrl"] = config.RegistryUrl,
|
||||
@@ -199,7 +202,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa
|
||||
{
|
||||
Success = true,
|
||||
Message = "Successfully connected to registry",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["registryUrl"] = config.RegistryUrl,
|
||||
@@ -215,7 +218,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Connection failed: {ex.Message}",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
TestedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -281,7 +284,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa
|
||||
: repository.GetProperty("name").GetString()!,
|
||||
Tag = pushData.TryGetProperty("tag", out var tag) ? tag.GetString() : "latest",
|
||||
Actor = pushData.TryGetProperty("pusher", out var pusher) ? pusher.GetString() : null,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -309,7 +312,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa
|
||||
? digest.GetString()
|
||||
: null,
|
||||
Actor = eventData.TryGetProperty("operator", out var op) ? op.GetString() : null,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -338,7 +341,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa
|
||||
actor.TryGetProperty("name", out var actorName)
|
||||
? actorName.GetString()
|
||||
: null,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -347,7 +350,7 @@ public sealed class ZastavaSourceHandler : ISourceTypeHandler, IWebhookCapableHa
|
||||
{
|
||||
EventType = "unknown",
|
||||
Reference = "",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,12 +17,15 @@ public sealed class SbomSourceRepository : RepositoryBase<ScannerSourcesDataSour
|
||||
private const string Schema = "scanner";
|
||||
private const string Table = "sbom_sources";
|
||||
private const string FullTable = $"{Schema}.{Table}";
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SbomSourceRepository(
|
||||
ScannerSourcesDataSource dataSource,
|
||||
ILogger<SbomSourceRepository> logger)
|
||||
ILogger<SbomSourceRepository> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<SbomSource?> GetByIdAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
|
||||
@@ -317,7 +320,7 @@ public sealed class SbomSourceRepository : RepositoryBase<ScannerSourcesDataSour
|
||||
|
||||
public Task<IReadOnlyList<SbomSource>> GetDueForScheduledRunAsync(CancellationToken ct = default)
|
||||
{
|
||||
return GetDueScheduledSourcesAsync(DateTimeOffset.UtcNow, 100, ct);
|
||||
return GetDueScheduledSourcesAsync(_timeProvider.GetUtcNow(), 100, ct);
|
||||
}
|
||||
|
||||
private void ConfigureSourceParams(NpgsqlCommand cmd, SbomSource source)
|
||||
|
||||
@@ -16,12 +16,15 @@ public sealed class SbomSourceRunRepository : RepositoryBase<ScannerSourcesDataS
|
||||
private const string Schema = "scanner";
|
||||
private const string Table = "sbom_source_runs";
|
||||
private const string FullTable = $"{Schema}.{Table}";
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SbomSourceRunRepository(
|
||||
ScannerSourcesDataSource dataSource,
|
||||
ILogger<SbomSourceRunRepository> logger)
|
||||
ILogger<SbomSourceRunRepository> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<SbomSourceRun?> GetByIdAsync(Guid runId, CancellationToken ct = default)
|
||||
@@ -188,7 +191,7 @@ public sealed class SbomSourceRunRepository : RepositoryBase<ScannerSourcesDataS
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "threshold", DateTimeOffset.UtcNow - olderThan);
|
||||
AddParameter(cmd, "threshold", _timeProvider.GetUtcNow() - olderThan);
|
||||
AddParameter(cmd, "limit", limit);
|
||||
},
|
||||
MapRun,
|
||||
|
||||
@@ -17,19 +17,22 @@ public sealed class SbomSourceService : ISbomSourceService
|
||||
private readonly ISourceConfigValidator _configValidator;
|
||||
private readonly ISourceConnectionTester _connectionTester;
|
||||
private readonly ILogger<SbomSourceService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SbomSourceService(
|
||||
ISbomSourceRepository sourceRepository,
|
||||
ISbomSourceRunRepository runRepository,
|
||||
ISourceConfigValidator configValidator,
|
||||
ISourceConnectionTester connectionTester,
|
||||
ILogger<SbomSourceService> logger)
|
||||
ILogger<SbomSourceService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_sourceRepository = sourceRepository;
|
||||
_runRepository = runRepository;
|
||||
_configValidator = configValidator;
|
||||
_connectionTester = connectionTester;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<SourceResponse?> GetAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
|
||||
@@ -215,7 +218,7 @@ public sealed class SbomSourceService : ISbomSourceService
|
||||
}
|
||||
|
||||
// Touch updated fields
|
||||
SetProperty(source, "UpdatedAt", DateTimeOffset.UtcNow);
|
||||
SetProperty(source, "UpdatedAt", _timeProvider.GetUtcNow());
|
||||
SetProperty(source, "UpdatedBy", updatedBy);
|
||||
|
||||
await _sourceRepository.UpdateAsync(source, ct);
|
||||
|
||||
@@ -12,13 +12,16 @@ public sealed class SourceConnectionTester : ISourceConnectionTester
|
||||
{
|
||||
private readonly IEnumerable<ISourceTypeConnectionTester> _testers;
|
||||
private readonly ILogger<SourceConnectionTester> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SourceConnectionTester(
|
||||
IEnumerable<ISourceTypeConnectionTester> testers,
|
||||
ILogger<SourceConnectionTester> logger)
|
||||
ILogger<SourceConnectionTester> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_testers = testers;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<ConnectionTestResult> TestAsync(SbomSource source, CancellationToken ct = default)
|
||||
@@ -42,7 +45,7 @@ public sealed class SourceConnectionTester : ISourceConnectionTester
|
||||
{
|
||||
Success = false,
|
||||
Message = $"No connection tester available for source type {source.SourceType}",
|
||||
TestedAt = DateTimeOffset.UtcNow
|
||||
TestedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,7 +77,7 @@ public sealed class SourceConnectionTester : ISourceConnectionTester
|
||||
{
|
||||
Success = false,
|
||||
Message = $"Connection test error: {ex.Message}",
|
||||
TestedAt = DateTimeOffset.UtcNow,
|
||||
TestedAt = _timeProvider.GetUtcNow(),
|
||||
Details = new Dictionary<string, object>
|
||||
{
|
||||
["exceptionType"] = ex.GetType().Name
|
||||
|
||||
@@ -22,5 +22,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scanner.Sources.Domain;
|
||||
using StellaOps.Scanner.Sources.Handlers;
|
||||
using StellaOps.Scanner.Sources.Persistence;
|
||||
@@ -15,19 +16,25 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
|
||||
private readonly IEnumerable<ISourceTypeHandler> _handlers;
|
||||
private readonly IScanJobQueue _scanJobQueue;
|
||||
private readonly ILogger<SourceTriggerDispatcher> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public SourceTriggerDispatcher(
|
||||
ISbomSourceRepository sourceRepository,
|
||||
ISbomSourceRunRepository runRepository,
|
||||
IEnumerable<ISourceTypeHandler> handlers,
|
||||
IScanJobQueue scanJobQueue,
|
||||
ILogger<SourceTriggerDispatcher> logger)
|
||||
ILogger<SourceTriggerDispatcher> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_sourceRepository = sourceRepository;
|
||||
_runRepository = runRepository;
|
||||
_handlers = handlers;
|
||||
_scanJobQueue = scanJobQueue;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public Task<TriggerDispatchResult> DispatchAsync(
|
||||
@@ -40,7 +47,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
|
||||
{
|
||||
Trigger = trigger,
|
||||
TriggerDetails = triggerDetails,
|
||||
CorrelationId = Guid.NewGuid().ToString("N")
|
||||
CorrelationId = _guidProvider.NewGuid().ToString("N")
|
||||
};
|
||||
|
||||
return DispatchAsync(sourceId, context, ct);
|
||||
@@ -128,7 +135,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
|
||||
{
|
||||
run.Complete();
|
||||
await _runRepository.UpdateAsync(run, ct);
|
||||
source.RecordSuccessfulRun(DateTimeOffset.UtcNow);
|
||||
source.RecordSuccessfulRun(_timeProvider.GetUtcNow());
|
||||
await _sourceRepository.UpdateAsync(source, ct);
|
||||
|
||||
return new TriggerDispatchResult
|
||||
@@ -170,12 +177,12 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
|
||||
if (run.ItemsFailed == run.ItemsDiscovered)
|
||||
{
|
||||
run.Fail("All targets failed to queue");
|
||||
source.RecordFailedRun(DateTimeOffset.UtcNow, run.ErrorMessage!);
|
||||
source.RecordFailedRun(_timeProvider.GetUtcNow(), run.ErrorMessage!);
|
||||
}
|
||||
else
|
||||
{
|
||||
run.Complete();
|
||||
source.RecordSuccessfulRun(DateTimeOffset.UtcNow);
|
||||
source.RecordSuccessfulRun(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
await _runRepository.UpdateAsync(run, ct);
|
||||
@@ -195,7 +202,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
|
||||
run.Fail(ex.Message);
|
||||
await _runRepository.UpdateAsync(run, ct);
|
||||
|
||||
source.RecordFailedRun(DateTimeOffset.UtcNow, ex.Message);
|
||||
source.RecordFailedRun(_timeProvider.GetUtcNow(), ex.Message);
|
||||
await _sourceRepository.UpdateAsync(source, ct);
|
||||
|
||||
return new TriggerDispatchResult
|
||||
@@ -247,7 +254,7 @@ public sealed class SourceTriggerDispatcher : ISourceTriggerDispatcher
|
||||
{
|
||||
Trigger = originalRun.Trigger,
|
||||
TriggerDetails = $"Retry of run {originalRunId}",
|
||||
CorrelationId = Guid.NewGuid().ToString("N"),
|
||||
CorrelationId = _guidProvider.NewGuid().ToString("N"),
|
||||
Metadata = new() { ["originalRunId"] = originalRunId.ToString() }
|
||||
};
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ public sealed class SlicePullService : IDisposable
|
||||
private readonly OciRegistryAuthorization _authorization;
|
||||
private readonly SlicePullOptions _options;
|
||||
private readonly ILogger<SlicePullService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Dictionary<string, CachedSlice> _cache = new(StringComparer.Ordinal);
|
||||
private readonly Lock _cacheLock = new();
|
||||
|
||||
@@ -70,12 +71,14 @@ public sealed class SlicePullService : IDisposable
|
||||
HttpClient httpClient,
|
||||
OciRegistryAuthorization authorization,
|
||||
SlicePullOptions? options = null,
|
||||
ILogger<SlicePullService>? logger = null)
|
||||
ILogger<SlicePullService>? logger = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_authorization = authorization ?? throw new ArgumentNullException(nameof(authorization));
|
||||
_options = options ?? new SlicePullOptions();
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<SlicePullService>.Instance;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_httpClient.Timeout = _options.RequestTimeout;
|
||||
}
|
||||
|
||||
@@ -211,7 +214,7 @@ public sealed class SlicePullService : IDisposable
|
||||
var dsseLayer = manifest.Layers?.FirstOrDefault(l =>
|
||||
l.MediaType == OciMediaTypes.DsseEnvelope);
|
||||
|
||||
if (dsseLayer != null && _options.VerifySignature)
|
||||
if (dsseLayer?.Digest != null && _options.VerifySignature)
|
||||
{
|
||||
var dsseResult = await FetchAndVerifyDsseAsync(reference, dsseLayer.Digest, sliceBytes, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -227,7 +230,7 @@ public sealed class SlicePullService : IDisposable
|
||||
SliceData = sliceData,
|
||||
DsseEnvelope = dsseEnvelope,
|
||||
SignatureVerified = signatureVerified,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.Add(_options.CacheTtl)
|
||||
ExpiresAt = _timeProvider.GetUtcNow().Add(_options.CacheTtl)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -411,7 +414,7 @@ public sealed class SlicePullService : IDisposable
|
||||
{
|
||||
if (_cache.TryGetValue(key, out cached))
|
||||
{
|
||||
if (cached.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
if (cached.ExpiresAt > _timeProvider.GetUtcNow())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
using System.Text.Json;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scanner.Storage.Entities;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Postgres;
|
||||
@@ -64,10 +65,17 @@ public interface IFuncProofRepository
|
||||
public sealed class PostgresFuncProofRepository : IFuncProofRepository
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public PostgresFuncProofRepository(NpgsqlDataSource dataSource)
|
||||
public PostgresFuncProofRepository(
|
||||
NpgsqlDataSource dataSource,
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public async Task<Guid> StoreAsync(FuncProofDocumentRow document, CancellationToken ct = default)
|
||||
@@ -94,7 +102,7 @@ public sealed class PostgresFuncProofRepository : IFuncProofRepository
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
var id = document.Id == Guid.Empty ? Guid.NewGuid() : document.Id;
|
||||
var id = document.Id == Guid.Empty ? _guidProvider.NewGuid() : document.Id;
|
||||
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
cmd.Parameters.AddWithValue("scan_id", document.ScanId);
|
||||
@@ -118,7 +126,7 @@ public sealed class PostgresFuncProofRepository : IFuncProofRepository
|
||||
document.RekorEntryId is null ? DBNull.Value : document.RekorEntryId);
|
||||
cmd.Parameters.AddWithValue("generator_version", document.GeneratorVersion);
|
||||
cmd.Parameters.AddWithValue("generated_at_utc", document.GeneratedAtUtc);
|
||||
cmd.Parameters.AddWithValue("created_at_utc", DateTimeOffset.UtcNow);
|
||||
cmd.Parameters.AddWithValue("created_at_utc", _timeProvider.GetUtcNow());
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(ct);
|
||||
return result is Guid returnedId ? returnedId : id;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scanner.Storage.Entities;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
@@ -20,14 +21,17 @@ public sealed class PostgresIdempotencyKeyRepository : IIdempotencyKeyRepository
|
||||
{
|
||||
private readonly ScannerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresIdempotencyKeyRepository> _logger;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||
|
||||
public PostgresIdempotencyKeyRepository(
|
||||
ScannerDataSource dataSource,
|
||||
ILogger<PostgresIdempotencyKeyRepository> logger)
|
||||
ILogger<PostgresIdempotencyKeyRepository> logger,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -68,7 +72,7 @@ public sealed class PostgresIdempotencyKeyRepository : IIdempotencyKeyRepository
|
||||
{
|
||||
if (key.KeyId == Guid.Empty)
|
||||
{
|
||||
key.KeyId = Guid.NewGuid();
|
||||
key.KeyId = _guidProvider.NewGuid();
|
||||
}
|
||||
|
||||
var sql = $"""
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.Scanner.ProofSpine;
|
||||
@@ -28,14 +29,17 @@ public sealed class PostgresProofSpineRepository : RepositoryBase<ScannerDataSou
|
||||
};
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public PostgresProofSpineRepository(
|
||||
ScannerDataSource dataSource,
|
||||
ILogger<PostgresProofSpineRepository> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public Task<ProofSpineModel?> GetByIdAsync(string spineId, CancellationToken cancellationToken = default)
|
||||
@@ -249,7 +253,7 @@ public sealed class PostgresProofSpineRepository : RepositoryBase<ScannerDataSou
|
||||
await using (var command = CreateCommand(insertHistory, connection))
|
||||
{
|
||||
command.Transaction = transaction;
|
||||
AddParameter(command, "id", Guid.NewGuid().ToString("N"));
|
||||
AddParameter(command, "id", _guidProvider.NewGuid().ToString("N"));
|
||||
AddParameter(command, "old_spine_id", oldSpineId.Trim());
|
||||
AddParameter(command, "new_spine_id", newSpineId.Trim());
|
||||
AddParameter(command, "reason", reason);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scanner.Storage.Models;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Repositories;
|
||||
@@ -19,13 +20,16 @@ public sealed class PostgresScanMetricsRepository : IScanMetricsRepository
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<PostgresScanMetricsRepository> _logger;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public PostgresScanMetricsRepository(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresScanMetricsRepository> logger)
|
||||
ILogger<PostgresScanMetricsRepository> logger,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -67,7 +71,7 @@ public sealed class PostgresScanMetricsRepository : IScanMetricsRepository
|
||||
|
||||
await using var cmd = _dataSource.CreateCommand(sql);
|
||||
|
||||
var metricsId = metrics.MetricsId == Guid.Empty ? Guid.NewGuid() : metrics.MetricsId;
|
||||
var metricsId = metrics.MetricsId == Guid.Empty ? _guidProvider.NewGuid() : metrics.MetricsId;
|
||||
|
||||
cmd.Parameters.AddWithValue("metricsId", metricsId);
|
||||
cmd.Parameters.AddWithValue("scanId", metrics.ScanId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Scanner.Storage.Catalog;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
@@ -16,10 +17,15 @@ public sealed class RuntimeEventRepository : RepositoryBase<ScannerDataSource>
|
||||
private string Table => $"{SchemaName}.runtime_events";
|
||||
private string SchemaName => DataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public RuntimeEventRepository(ScannerDataSource dataSource, ILogger<RuntimeEventRepository> logger)
|
||||
public RuntimeEventRepository(
|
||||
ScannerDataSource dataSource,
|
||||
ILogger<RuntimeEventRepository> logger,
|
||||
IGuidProvider? guidProvider = null)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public async Task<RuntimeEventInsertResult> InsertAsync(
|
||||
@@ -52,7 +58,7 @@ public sealed class RuntimeEventRepository : RepositoryBase<ScannerDataSource>
|
||||
foreach (var document in documents)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var id = string.IsNullOrWhiteSpace(document.Id) ? Guid.NewGuid().ToString("N") : document.Id;
|
||||
var id = string.IsNullOrWhiteSpace(document.Id) ? _guidProvider.NewGuid().ToString("N") : document.Id;
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
Tenant,
|
||||
|
||||
@@ -28,5 +28,6 @@
|
||||
<ProjectReference Include="..\\StellaOps.Scanner.SmartDiff\\StellaOps.Scanner.SmartDiff.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Infrastructure.Postgres\\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\Router\\__Libraries\\StellaOps.Messaging\\StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Determinism.Abstractions\\StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -21,5 +21,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Scanner.VulnSurfaces.Models;
|
||||
|
||||
namespace StellaOps.Scanner.VulnSurfaces.Storage;
|
||||
@@ -18,15 +19,18 @@ public sealed class PostgresVulnSurfaceRepository : IVulnSurfaceRepository
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<PostgresVulnSurfaceRepository> _logger;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly int _commandTimeoutSeconds;
|
||||
|
||||
public PostgresVulnSurfaceRepository(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresVulnSurfaceRepository> logger,
|
||||
IGuidProvider? guidProvider = null,
|
||||
int commandTimeoutSeconds = 30)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
_commandTimeoutSeconds = commandTimeoutSeconds;
|
||||
}
|
||||
|
||||
@@ -45,7 +49,7 @@ public sealed class PostgresVulnSurfaceRepository : IVulnSurfaceRepository
|
||||
string? attestationDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var id = _guidProvider.NewGuid();
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO scanner.vuln_surfaces (
|
||||
@@ -106,7 +110,7 @@ public sealed class PostgresVulnSurfaceRepository : IVulnSurfaceRepository
|
||||
string? fixedHash,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var id = _guidProvider.NewGuid();
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO scanner.vuln_surface_sinks (
|
||||
@@ -148,7 +152,7 @@ public sealed class PostgresVulnSurfaceRepository : IVulnSurfaceRepository
|
||||
double confidence,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var id = _guidProvider.NewGuid();
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO scanner.vuln_surface_triggers (
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Signer.Core;
|
||||
|
||||
namespace StellaOps.Signer.Infrastructure.Auditing;
|
||||
@@ -11,11 +12,16 @@ public sealed class InMemorySignerAuditSink : ISignerAuditSink
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SignerAuditEntry> _entries = new(StringComparer.Ordinal);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly ILogger<InMemorySignerAuditSink> _logger;
|
||||
|
||||
public InMemorySignerAuditSink(TimeProvider timeProvider, ILogger<InMemorySignerAuditSink> logger)
|
||||
public InMemorySignerAuditSink(
|
||||
TimeProvider timeProvider,
|
||||
ILogger<InMemorySignerAuditSink> logger,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
@@ -30,7 +36,7 @@ public sealed class InMemorySignerAuditSink : ISignerAuditSink
|
||||
ArgumentNullException.ThrowIfNull(entitlement);
|
||||
ArgumentNullException.ThrowIfNull(caller);
|
||||
|
||||
var auditId = Guid.NewGuid().ToString("d");
|
||||
var auditId = _guidProvider.NewGuid().ToString("d");
|
||||
var entry = new SignerAuditEntry(
|
||||
auditId,
|
||||
_timeProvider.GetUtcNow(),
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Signer.Core;
|
||||
|
||||
namespace StellaOps.Signer.Infrastructure.Signing;
|
||||
@@ -17,15 +18,18 @@ public sealed class DefaultSigningKeyResolver : ISigningKeyResolver
|
||||
|
||||
private readonly DsseSignerOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly ILogger<DefaultSigningKeyResolver> _logger;
|
||||
|
||||
public DefaultSigningKeyResolver(
|
||||
IOptions<DsseSignerOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DefaultSigningKeyResolver> logger)
|
||||
ILogger<DefaultSigningKeyResolver> logger,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
@@ -56,7 +60,7 @@ public sealed class DefaultSigningKeyResolver : ISigningKeyResolver
|
||||
{
|
||||
// Generate ephemeral key identifier using timestamp for uniqueness
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var keyId = $"{KeylessKeyIdPrefix}{tenant}:{now:yyyyMMddHHmmss}:{Guid.NewGuid():N}";
|
||||
var keyId = $"{KeylessKeyIdPrefix}{tenant}:{now:yyyyMMddHHmmss}:{_guidProvider.NewGuid():N}";
|
||||
var expiresAt = now.AddMinutes(KeylessExpiryMinutes);
|
||||
|
||||
return new SigningKeyResolution(
|
||||
|
||||
@@ -18,17 +18,20 @@ public sealed class SigstoreSigningService : ISigstoreSigningService
|
||||
private readonly IFulcioClient _fulcioClient;
|
||||
private readonly IRekorClient _rekorClient;
|
||||
private readonly SigstoreOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<SigstoreSigningService> _logger;
|
||||
|
||||
public SigstoreSigningService(
|
||||
IFulcioClient fulcioClient,
|
||||
IRekorClient rekorClient,
|
||||
IOptions<SigstoreOptions> options,
|
||||
ILogger<SigstoreSigningService> logger)
|
||||
ILogger<SigstoreSigningService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_fulcioClient = fulcioClient ?? throw new ArgumentNullException(nameof(fulcioClient));
|
||||
_rekorClient = rekorClient ?? throw new ArgumentNullException(nameof(rekorClient));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
@@ -133,7 +136,7 @@ public sealed class SigstoreSigningService : ISigstoreSigningService
|
||||
}
|
||||
|
||||
// 3. Check certificate validity
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (now < cert.NotBefore || now > cert.NotAfter)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Signer.Core\StellaOps.Signer.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
|
||||
@@ -145,6 +145,7 @@ public static class KeyRotationEndpoints
|
||||
[FromBody] RevokeKeyRequestDto request,
|
||||
IKeyRotationService rotationService,
|
||||
ILoggerFactory loggerFactory,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger("KeyRotationEndpoints.RevokeKey");
|
||||
@@ -183,7 +184,7 @@ public static class KeyRotationEndpoints
|
||||
{
|
||||
KeyId = keyId,
|
||||
AnchorId = anchorId,
|
||||
RevokedAt = request.EffectiveAt ?? DateTimeOffset.UtcNow,
|
||||
RevokedAt = request.EffectiveAt ?? timeProvider.GetUtcNow(),
|
||||
Reason = request.Reason,
|
||||
AllowedKeyIds = result.AllowedKeyIds.ToList(),
|
||||
RevokedKeyIds = result.RevokedKeyIds.ToList(),
|
||||
@@ -217,9 +218,10 @@ public static class KeyRotationEndpoints
|
||||
[FromRoute] string keyId,
|
||||
[FromQuery] DateTimeOffset? signedAt,
|
||||
IKeyRotationService rotationService,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var checkTime = signedAt ?? DateTimeOffset.UtcNow;
|
||||
var checkTime = signedAt ?? timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -17,8 +17,14 @@ builder.Services.AddAuthentication(StubBearerAuthenticationDefaults.Authenticati
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
builder.Services.AddSignerPipeline();
|
||||
|
||||
// Configure TimeProvider for deterministic testing support
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
|
||||
builder.Services.Configure<SignerEntitlementOptions>(options =>
|
||||
{
|
||||
// Note: Using 1-hour expiry for demo/test tokens.
|
||||
// Actual expiry is calculated at runtime relative to TimeProvider.
|
||||
options.Tokens["valid-poe"] = new SignerEntitlementDefinition(
|
||||
LicenseId: "LIC-TEST",
|
||||
CustomerId: "CUST-TEST",
|
||||
|
||||
@@ -8,6 +8,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Signer.KeyManagement.Entities;
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement;
|
||||
@@ -22,17 +23,20 @@ public sealed class KeyRotationService : IKeyRotationService
|
||||
private readonly ILogger<KeyRotationService> _logger;
|
||||
private readonly KeyRotationOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public KeyRotationService(
|
||||
KeyManagementDbContext dbContext,
|
||||
ILogger<KeyRotationService> logger,
|
||||
IOptions<KeyRotationOptions> options,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new KeyRotationOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -85,7 +89,7 @@ public sealed class KeyRotationService : IKeyRotationService
|
||||
// Create key history entry
|
||||
var keyEntry = new KeyHistoryEntity
|
||||
{
|
||||
HistoryId = Guid.NewGuid(),
|
||||
HistoryId = _guidProvider.NewGuid(),
|
||||
AnchorId = anchorId,
|
||||
KeyId = request.KeyId,
|
||||
PublicKey = request.PublicKey,
|
||||
@@ -106,7 +110,7 @@ public sealed class KeyRotationService : IKeyRotationService
|
||||
// Create audit log entry
|
||||
var auditEntry = new KeyAuditLogEntity
|
||||
{
|
||||
LogId = Guid.NewGuid(),
|
||||
LogId = _guidProvider.NewGuid(),
|
||||
AnchorId = anchorId,
|
||||
KeyId = request.KeyId,
|
||||
Operation = KeyOperation.Add,
|
||||
@@ -209,7 +213,7 @@ public sealed class KeyRotationService : IKeyRotationService
|
||||
// Create audit log entry
|
||||
var auditEntry = new KeyAuditLogEntity
|
||||
{
|
||||
LogId = Guid.NewGuid(),
|
||||
LogId = _guidProvider.NewGuid(),
|
||||
AnchorId = anchorId,
|
||||
KeyId = keyId,
|
||||
Operation = KeyOperation.Revoke,
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Migrations\*.sql">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Signer.KeyManagement.Entities;
|
||||
|
||||
namespace StellaOps.Signer.KeyManagement;
|
||||
@@ -22,17 +23,20 @@ public sealed class TrustAnchorManager : ITrustAnchorManager
|
||||
private readonly IKeyRotationService _keyRotationService;
|
||||
private readonly ILogger<TrustAnchorManager> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public TrustAnchorManager(
|
||||
KeyManagementDbContext dbContext,
|
||||
IKeyRotationService keyRotationService,
|
||||
ILogger<TrustAnchorManager> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_keyRotationService = keyRotationService ?? throw new ArgumentNullException(nameof(keyRotationService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -115,7 +119,7 @@ public sealed class TrustAnchorManager : ITrustAnchorManager
|
||||
|
||||
var entity = new TrustAnchorEntity
|
||||
{
|
||||
AnchorId = Guid.NewGuid(),
|
||||
AnchorId = _guidProvider.NewGuid(),
|
||||
PurlPattern = request.PurlPattern,
|
||||
AllowedKeyIds = request.AllowedKeyIds?.ToList() ?? [],
|
||||
AllowedPredicateTypes = request.AllowedPredicateTypes?.ToList(),
|
||||
|
||||
@@ -21,13 +21,15 @@ public sealed class AmbientOidcTokenProvider : IOidcTokenProvider, IDisposable
|
||||
private readonly JwtSecurityTokenHandler _tokenHandler;
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
private readonly FileSystemWatcher? _watcher;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private OidcTokenResult? _cachedToken;
|
||||
private bool _disposed;
|
||||
|
||||
public AmbientOidcTokenProvider(
|
||||
OidcAmbientConfig config,
|
||||
ILogger<AmbientOidcTokenProvider> logger)
|
||||
ILogger<AmbientOidcTokenProvider> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
@@ -35,6 +37,7 @@ public sealed class AmbientOidcTokenProvider : IOidcTokenProvider, IDisposable
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
_tokenHandler = new JwtSecurityTokenHandler();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
if (_config.WatchForChanges && File.Exists(_config.TokenPath))
|
||||
{
|
||||
@@ -65,7 +68,8 @@ public sealed class AmbientOidcTokenProvider : IOidcTokenProvider, IDisposable
|
||||
try
|
||||
{
|
||||
// Check cache first
|
||||
if (_cachedToken is not null && !_cachedToken.WillExpireSoon(TimeSpan.FromSeconds(30)))
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (_cachedToken is not null && !_cachedToken.WillExpireSoon(now, TimeSpan.FromSeconds(30)))
|
||||
{
|
||||
return _cachedToken;
|
||||
}
|
||||
@@ -111,7 +115,7 @@ public sealed class AmbientOidcTokenProvider : IOidcTokenProvider, IDisposable
|
||||
public OidcTokenResult? GetCachedToken()
|
||||
{
|
||||
var cached = _cachedToken;
|
||||
if (cached is null || cached.IsExpired)
|
||||
if (cached is null || cached.IsExpiredAt(_timeProvider.GetUtcNow()))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -132,7 +136,7 @@ public sealed class AmbientOidcTokenProvider : IOidcTokenProvider, IDisposable
|
||||
|
||||
var expiresAt = jwt.ValidTo != DateTime.MinValue
|
||||
? new DateTimeOffset(jwt.ValidTo, TimeSpan.Zero)
|
||||
: DateTimeOffset.UtcNow.AddHours(1); // Default if no exp claim
|
||||
: _timeProvider.GetUtcNow().AddHours(1); // Default if no exp claim
|
||||
|
||||
var subject = jwt.Subject;
|
||||
var email = jwt.Claims.FirstOrDefault(c => c.Type == "email")?.Value;
|
||||
|
||||
@@ -47,7 +47,8 @@ public sealed class EphemeralKeyPair : IDisposable
|
||||
/// <param name="publicKey">The public key bytes.</param>
|
||||
/// <param name="privateKey">The private key bytes (will be copied).</param>
|
||||
/// <param name="algorithm">The algorithm identifier.</param>
|
||||
public EphemeralKeyPair(byte[] publicKey, byte[] privateKey, string algorithm)
|
||||
/// <param name="timeProvider">Optional time provider for deterministic timestamp.</param>
|
||||
public EphemeralKeyPair(byte[] publicKey, byte[] privateKey, string algorithm, TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(publicKey);
|
||||
ArgumentNullException.ThrowIfNull(privateKey);
|
||||
@@ -56,7 +57,7 @@ public sealed class EphemeralKeyPair : IDisposable
|
||||
_publicKey = (byte[])publicKey.Clone();
|
||||
_privateKey = (byte[])privateKey.Clone();
|
||||
Algorithm = algorithm;
|
||||
CreatedAt = DateTimeOffset.UtcNow;
|
||||
CreatedAt = (timeProvider ?? TimeProvider.System).GetUtcNow();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -75,9 +75,11 @@ public sealed record FulcioCertificateResult(
|
||||
public TimeSpan Validity => NotAfter - NotBefore;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the certificate is currently valid.
|
||||
/// Checks if the certificate is valid at the specified time.
|
||||
/// </summary>
|
||||
public bool IsValid => DateTimeOffset.UtcNow >= NotBefore && DateTimeOffset.UtcNow <= NotAfter;
|
||||
/// <param name="at">The time to check validity against.</param>
|
||||
/// <returns>True if the certificate is valid at the specified time.</returns>
|
||||
public bool IsValidAt(DateTimeOffset at) => at >= NotBefore && at <= NotAfter;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full certificate chain including the leaf certificate.
|
||||
|
||||
@@ -62,15 +62,20 @@ public sealed record OidcTokenResult
|
||||
public string? Email { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the token is expired.
|
||||
/// Checks whether the token is expired at the specified time.
|
||||
/// </summary>
|
||||
public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt;
|
||||
/// <param name="now">The time to check against.</param>
|
||||
/// <returns>True if the token is expired.</returns>
|
||||
public bool IsExpiredAt(DateTimeOffset now) => now >= ExpiresAt;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the token will expire within the specified buffer time.
|
||||
/// Checks whether the token will expire within the specified buffer time.
|
||||
/// </summary>
|
||||
public bool WillExpireSoon(TimeSpan buffer) =>
|
||||
DateTimeOffset.UtcNow.Add(buffer) >= ExpiresAt;
|
||||
/// <param name="now">The current time.</param>
|
||||
/// <param name="buffer">The time buffer before expiration.</param>
|
||||
/// <returns>True if the token will expire soon.</returns>
|
||||
public bool WillExpireSoon(DateTimeOffset now, TimeSpan buffer) =>
|
||||
now.Add(buffer) >= ExpiresAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Storage;
|
||||
@@ -26,16 +27,19 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
|
||||
private readonly IConsensusEventEmitter? _eventEmitter;
|
||||
private readonly ILogger<PostgresConsensusProjectionStore> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public PostgresConsensusProjectionStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresConsensusProjectionStore> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null,
|
||||
IConsensusEventEmitter? eventEmitter = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? new SystemGuidProvider();
|
||||
_eventEmitter = eventEmitter;
|
||||
}
|
||||
|
||||
@@ -52,7 +56,7 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
|
||||
activity?.SetTag("vulnerabilityId", result.VulnerabilityId);
|
||||
activity?.SetTag("productKey", result.ProductKey);
|
||||
|
||||
var projectionId = Guid.NewGuid();
|
||||
var projectionId = _guidProvider.NewGuid();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Check for previous projection to track history
|
||||
@@ -527,7 +531,7 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
|
||||
// Always emit computed event
|
||||
await _eventEmitter.EmitConsensusComputedAsync(
|
||||
new ConsensusComputedEvent(
|
||||
EventId: Guid.NewGuid().ToString(),
|
||||
EventId: _guidProvider.NewGuid().ToString(),
|
||||
ProjectionId: projection.ProjectionId,
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
@@ -546,7 +550,7 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
|
||||
{
|
||||
await _eventEmitter.EmitStatusChangedAsync(
|
||||
new ConsensusStatusChangedEvent(
|
||||
EventId: Guid.NewGuid().ToString(),
|
||||
EventId: _guidProvider.NewGuid().ToString(),
|
||||
ProjectionId: projection.ProjectionId,
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
@@ -564,7 +568,7 @@ public sealed class PostgresConsensusProjectionStore : IConsensusProjectionStore
|
||||
{
|
||||
await _eventEmitter.EmitConflictDetectedAsync(
|
||||
new ConsensusConflictDetectedEvent(
|
||||
EventId: Guid.NewGuid().ToString(),
|
||||
EventId: _guidProvider.NewGuid().ToString(),
|
||||
ProjectionId: projection.ProjectionId,
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.VexLens\StellaOps.VexLens.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -325,6 +325,7 @@ public static class VexLensEndpointExtensions
|
||||
[FromQuery] DateTimeOffset? fromDate,
|
||||
[FromQuery] DateTimeOffset? toDate,
|
||||
[FromServices] IGatingStatisticsStore statsStore,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
HttpContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -340,7 +341,7 @@ public static class VexLensEndpointExtensions
|
||||
TotalSurfaced: stats.TotalSurfaced,
|
||||
TotalDamped: stats.TotalDamped,
|
||||
AverageDampingPercent: stats.AverageDampingPercent,
|
||||
ComputedAt: DateTimeOffset.UtcNow));
|
||||
ComputedAt: timeProvider.GetUtcNow()));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GateSnapshotAsync(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Storage;
|
||||
@@ -43,17 +44,20 @@ public sealed class ConsensusRationaleService : IConsensusRationaleService
|
||||
private readonly IConsensusProjectionStore _projectionStore;
|
||||
private readonly IVexConsensusEngine _consensusEngine;
|
||||
private readonly ITrustWeightEngine _trustWeightEngine;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
private const string AlgorithmVersion = "1.0.0";
|
||||
|
||||
public ConsensusRationaleService(
|
||||
IConsensusProjectionStore projectionStore,
|
||||
IVexConsensusEngine consensusEngine,
|
||||
ITrustWeightEngine trustWeightEngine)
|
||||
ITrustWeightEngine trustWeightEngine,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_projectionStore = projectionStore;
|
||||
_consensusEngine = consensusEngine;
|
||||
_trustWeightEngine = trustWeightEngine;
|
||||
_guidProvider = guidProvider ?? new SystemGuidProvider();
|
||||
}
|
||||
|
||||
public async Task<GenerateRationaleResponse> GenerateRationaleAsync(
|
||||
@@ -177,7 +181,7 @@ public sealed class ConsensusRationaleService : IConsensusRationaleService
|
||||
var outputHash = ComputeOutputHash(result, contributions, conflicts);
|
||||
|
||||
var rationale = new DetailedConsensusRationale(
|
||||
RationaleId: $"rat-{Guid.NewGuid():N}",
|
||||
RationaleId: $"rat-{_guidProvider.NewGuid():N}",
|
||||
VulnerabilityId: result.VulnerabilityId,
|
||||
ProductKey: result.ProductKey,
|
||||
ConsensusStatus: result.ConsensusStatus,
|
||||
|
||||
@@ -137,19 +137,22 @@ public sealed class VexLensApiService : IVexLensApiService
|
||||
private readonly IConsensusProjectionStore _projectionStore;
|
||||
private readonly IIssuerDirectory _issuerDirectory;
|
||||
private readonly IVexStatementProvider _statementProvider;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public VexLensApiService(
|
||||
IVexConsensusEngine consensusEngine,
|
||||
ITrustWeightEngine trustWeightEngine,
|
||||
IConsensusProjectionStore projectionStore,
|
||||
IIssuerDirectory issuerDirectory,
|
||||
IVexStatementProvider statementProvider)
|
||||
IVexStatementProvider statementProvider,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_consensusEngine = consensusEngine;
|
||||
_trustWeightEngine = trustWeightEngine;
|
||||
_projectionStore = projectionStore;
|
||||
_issuerDirectory = issuerDirectory;
|
||||
_statementProvider = statementProvider;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ComputeConsensusResponse> ComputeConsensusAsync(
|
||||
@@ -164,7 +167,7 @@ public sealed class VexLensApiService : IVexLensApiService
|
||||
cancellationToken);
|
||||
|
||||
// Compute trust weights
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var weightedStatements = new List<WeightedStatement>();
|
||||
|
||||
foreach (var stmt in statements)
|
||||
@@ -237,7 +240,7 @@ public sealed class VexLensApiService : IVexLensApiService
|
||||
cancellationToken);
|
||||
|
||||
// Compute trust weights
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var weightedStatements = new List<WeightedStatement>();
|
||||
|
||||
foreach (var stmt in statements)
|
||||
@@ -293,7 +296,7 @@ public sealed class VexLensApiService : IVexLensApiService
|
||||
var resolutionResult = await _consensusEngine.ComputeConsensusWithProofAsync(
|
||||
consensusRequest,
|
||||
proofContext,
|
||||
TimeProvider.System,
|
||||
_timeProvider,
|
||||
cancellationToken);
|
||||
|
||||
// Store result if requested
|
||||
@@ -348,7 +351,7 @@ public sealed class VexLensApiService : IVexLensApiService
|
||||
TotalCount: request.Targets.Count,
|
||||
SuccessCount: results.Count,
|
||||
FailureCount: failures,
|
||||
CompletedAt: DateTimeOffset.UtcNow);
|
||||
CompletedAt: _timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
public async Task<ProjectionDetailResponse?> GetProjectionAsync(
|
||||
@@ -452,7 +455,7 @@ public sealed class VexLensApiService : IVexLensApiService
|
||||
|
||||
var withConflicts = projections.Count(p => p.ConflictCount > 0);
|
||||
|
||||
var last24h = DateTimeOffset.UtcNow.AddDays(-1);
|
||||
var last24h = _timeProvider.GetUtcNow().AddDays(-1);
|
||||
var changesLast24h = projections.Count(p => p.StatusChanged && p.ComputedAt >= last24h);
|
||||
|
||||
return new ConsensusStatisticsResponse(
|
||||
@@ -462,7 +465,7 @@ public sealed class VexLensApiService : IVexLensApiService
|
||||
AverageConfidence: avgConfidence,
|
||||
ProjectionsWithConflicts: withConflicts,
|
||||
StatusChangesLast24h: changesLast24h,
|
||||
ComputedAt: DateTimeOffset.UtcNow);
|
||||
ComputedAt: _timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
public async Task<IssuerListResponse> ListIssuersAsync(
|
||||
|
||||
@@ -472,15 +472,18 @@ public sealed class TrustScorecardApiService : ITrustScorecardApiService
|
||||
private readonly ISourceTrustScoreCalculator _scoreCalculator;
|
||||
private readonly IConflictAuditStore? _auditStore;
|
||||
private readonly ITrustScoreHistoryStore? _historyStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public TrustScorecardApiService(
|
||||
ISourceTrustScoreCalculator scoreCalculator,
|
||||
IConflictAuditStore? auditStore = null,
|
||||
ITrustScoreHistoryStore? historyStore = null)
|
||||
ITrustScoreHistoryStore? historyStore = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_scoreCalculator = scoreCalculator;
|
||||
_auditStore = auditStore;
|
||||
_historyStore = historyStore;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<TrustScorecardResponse> GetScorecardAsync(
|
||||
@@ -544,7 +547,7 @@ public sealed class TrustScorecardApiService : ITrustScorecardApiService
|
||||
SignatureValidityRate = cachedScore.Breakdown.Verification.SignatureValidityRate,
|
||||
VerificationMethod = cachedScore.Breakdown.Verification.IssuerVerified ? "registry" : null
|
||||
},
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
GeneratedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -604,10 +607,11 @@ public sealed class TrustScorecardApiService : ITrustScorecardApiService
|
||||
};
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var history = await _historyStore.GetHistoryAsync(
|
||||
sourceId,
|
||||
DateTimeOffset.UtcNow.AddDays(-days),
|
||||
DateTimeOffset.UtcNow,
|
||||
now.AddDays(-days),
|
||||
now,
|
||||
cancellationToken);
|
||||
|
||||
if (history.Count == 0)
|
||||
@@ -622,7 +626,7 @@ public sealed class TrustScorecardApiService : ITrustScorecardApiService
|
||||
|
||||
var current = history.LastOrDefault()?.CompositeScore ?? 0.0;
|
||||
var thirtyDaysAgo = history
|
||||
.Where(h => h.Timestamp >= DateTimeOffset.UtcNow.AddDays(-30))
|
||||
.Where(h => h.Timestamp >= now.AddDays(-30))
|
||||
.FirstOrDefault()?.CompositeScore ?? current;
|
||||
var ninetyDaysAgo = history.FirstOrDefault()?.CompositeScore ?? current;
|
||||
|
||||
|
||||
@@ -138,14 +138,16 @@ public sealed class InMemoryConsensusRationaleCache : IConsensusRationaleCache
|
||||
private readonly Dictionary<string, CacheEntry> _cache = new();
|
||||
private readonly object _lock = new();
|
||||
private readonly int _maxEntries;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private long _hitCount;
|
||||
private long _missCount;
|
||||
private DateTimeOffset? _lastCleared;
|
||||
|
||||
public InMemoryConsensusRationaleCache(int maxEntries = 10000)
|
||||
public InMemoryConsensusRationaleCache(int maxEntries = 10000, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_maxEntries = maxEntries;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<DetailedConsensusRationale?> GetAsync(
|
||||
@@ -163,7 +165,7 @@ public sealed class InMemoryConsensusRationaleCache : IConsensusRationaleCache
|
||||
return Task.FromResult<DetailedConsensusRationale?>(null);
|
||||
}
|
||||
|
||||
entry.LastAccessed = DateTimeOffset.UtcNow;
|
||||
entry.LastAccessed = _timeProvider.GetUtcNow();
|
||||
Interlocked.Increment(ref _hitCount);
|
||||
return Task.FromResult<DetailedConsensusRationale?>(entry.Rationale);
|
||||
}
|
||||
@@ -187,12 +189,13 @@ public sealed class InMemoryConsensusRationaleCache : IConsensusRationaleCache
|
||||
EvictOldestEntry();
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
_cache[cacheKey] = new CacheEntry
|
||||
{
|
||||
Rationale = rationale,
|
||||
Options = options ?? new CacheOptions(),
|
||||
Created = DateTimeOffset.UtcNow,
|
||||
LastAccessed = DateTimeOffset.UtcNow
|
||||
Created = now,
|
||||
LastAccessed = now
|
||||
};
|
||||
|
||||
return Task.CompletedTask;
|
||||
@@ -254,7 +257,7 @@ public sealed class InMemoryConsensusRationaleCache : IConsensusRationaleCache
|
||||
lock (_lock)
|
||||
{
|
||||
_cache.Clear();
|
||||
_lastCleared = DateTimeOffset.UtcNow;
|
||||
_lastCleared = _timeProvider.GetUtcNow();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -277,9 +280,9 @@ public sealed class InMemoryConsensusRationaleCache : IConsensusRationaleCache
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsExpired(CacheEntry entry)
|
||||
private bool IsExpired(CacheEntry entry)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (entry.Options.AbsoluteExpiration.HasValue &&
|
||||
now >= entry.Options.AbsoluteExpiration.Value)
|
||||
|
||||
@@ -13,10 +13,14 @@ namespace StellaOps.VexLens.Consensus;
|
||||
public sealed class VexConsensusEngine : IVexConsensusEngine
|
||||
{
|
||||
private ConsensusConfiguration _configuration;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public VexConsensusEngine(ConsensusConfiguration? configuration = null)
|
||||
public VexConsensusEngine(
|
||||
ConsensusConfiguration? configuration = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_configuration = configuration ?? CreateDefaultConfiguration();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<VexConsensusResult> ComputeConsensusAsync(
|
||||
@@ -559,7 +563,7 @@ public sealed class VexConsensusEngine : IVexConsensusEngine
|
||||
stmt.Statement.Status,
|
||||
stmt.Statement.Justification,
|
||||
weight,
|
||||
GetStatementTimestamp(stmt.Statement),
|
||||
GetStatementTimestamp(stmt.Statement, _timeProvider),
|
||||
HasSignature(stmt.Weight));
|
||||
}
|
||||
|
||||
@@ -574,7 +578,7 @@ public sealed class VexConsensusEngine : IVexConsensusEngine
|
||||
stmt.Statement.Status,
|
||||
stmt.Statement.Justification,
|
||||
weight,
|
||||
GetStatementTimestamp(stmt.Statement),
|
||||
GetStatementTimestamp(stmt.Statement, _timeProvider),
|
||||
HasSignature(stmt.Weight),
|
||||
reason);
|
||||
}
|
||||
@@ -704,7 +708,7 @@ public sealed class VexConsensusEngine : IVexConsensusEngine
|
||||
stmt.Statement.Status,
|
||||
stmt.Statement.Justification,
|
||||
weight,
|
||||
GetStatementTimestamp(stmt.Statement),
|
||||
GetStatementTimestamp(stmt.Statement, _timeProvider),
|
||||
HasSignature(stmt.Weight));
|
||||
}
|
||||
|
||||
@@ -719,7 +723,7 @@ public sealed class VexConsensusEngine : IVexConsensusEngine
|
||||
stmt.Statement.Status,
|
||||
stmt.Statement.Justification,
|
||||
weight,
|
||||
GetStatementTimestamp(stmt.Statement),
|
||||
GetStatementTimestamp(stmt.Statement, _timeProvider),
|
||||
HasSignature(stmt.Weight),
|
||||
reason);
|
||||
}
|
||||
@@ -1278,10 +1282,10 @@ public sealed class VexConsensusEngine : IVexConsensusEngine
|
||||
(decimal)breakdown.StatusSpecificityWeight));
|
||||
}
|
||||
|
||||
private static DateTimeOffset GetStatementTimestamp(NormalizedStatement statement)
|
||||
private static DateTimeOffset GetStatementTimestamp(NormalizedStatement statement, TimeProvider timeProvider)
|
||||
{
|
||||
// Use LastSeen if available, otherwise FirstSeen, otherwise current time
|
||||
return statement.LastSeen ?? statement.FirstSeen ?? DateTimeOffset.UtcNow;
|
||||
return statement.LastSeen ?? statement.FirstSeen ?? timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
private static bool HasSignature(Trust.TrustWeightResult weight)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Storage;
|
||||
@@ -273,12 +274,19 @@ public enum ExportFormat
|
||||
public sealed class ConsensusExportService : IConsensusExportService
|
||||
{
|
||||
private readonly IConsensusProjectionStore _projectionStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
private const string SnapshotVersion = "1.0.0";
|
||||
|
||||
public ConsensusExportService(IConsensusProjectionStore projectionStore)
|
||||
public ConsensusExportService(
|
||||
IConsensusProjectionStore projectionStore,
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_projectionStore = projectionStore;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? new SystemGuidProvider();
|
||||
}
|
||||
|
||||
public async Task<ConsensusSnapshot> CreateSnapshotAsync(
|
||||
@@ -338,12 +346,12 @@ public sealed class ConsensusExportService : IConsensusExportService
|
||||
.GroupBy(p => p.Status)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var snapshotId = $"snap-{Guid.NewGuid():N}";
|
||||
var snapshotId = $"snap-{_guidProvider.NewGuid():N}";
|
||||
var contentHash = ComputeContentHash(projections);
|
||||
|
||||
return new ConsensusSnapshot(
|
||||
SnapshotId: snapshotId,
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
CreatedAt: _timeProvider.GetUtcNow(),
|
||||
Version: SnapshotVersion,
|
||||
TenantId: request.TenantId,
|
||||
Projections: projections,
|
||||
@@ -400,13 +408,13 @@ public sealed class ConsensusExportService : IConsensusExportService
|
||||
|
||||
// For a true incremental, we'd compare with the previous snapshot
|
||||
// Here we just return new/updated since the timestamp
|
||||
var snapshotId = $"snap-inc-{Guid.NewGuid():N}";
|
||||
var snapshotId = $"snap-inc-{_guidProvider.NewGuid():N}";
|
||||
var contentHash = ComputeContentHash(current.Projections);
|
||||
|
||||
return new IncrementalSnapshot(
|
||||
SnapshotId: snapshotId,
|
||||
PreviousSnapshotId: lastSnapshotId,
|
||||
CreatedAt: DateTimeOffset.UtcNow,
|
||||
CreatedAt: _timeProvider.GetUtcNow(),
|
||||
Version: SnapshotVersion,
|
||||
Added: current.Projections,
|
||||
Removed: [], // Would need previous snapshot to determine removed
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Normalization;
|
||||
@@ -12,6 +13,13 @@ namespace StellaOps.VexLens.Normalization;
|
||||
/// </summary>
|
||||
public sealed class CsafVexNormalizer : IVexNormalizer
|
||||
{
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public CsafVexNormalizer(IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public VexSourceFormat SourceFormat => VexSourceFormat.CsafVex;
|
||||
|
||||
public bool CanNormalize(string content)
|
||||
@@ -77,7 +85,7 @@ public sealed class CsafVexNormalizer : IVexNormalizer
|
||||
var documentId = ExtractDocumentId(documentElement);
|
||||
if (string.IsNullOrWhiteSpace(documentId))
|
||||
{
|
||||
documentId = $"csaf:{Guid.NewGuid():N}";
|
||||
documentId = $"csaf:{_guidProvider.NewGuid():N}";
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CSAF_001",
|
||||
"Document tracking ID not found; generated a random ID",
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Normalization;
|
||||
@@ -12,6 +13,13 @@ namespace StellaOps.VexLens.Normalization;
|
||||
/// </summary>
|
||||
public sealed class CycloneDxVexNormalizer : IVexNormalizer
|
||||
{
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public CycloneDxVexNormalizer(IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public VexSourceFormat SourceFormat => VexSourceFormat.CycloneDxVex;
|
||||
|
||||
public bool CanNormalize(string content)
|
||||
@@ -65,7 +73,7 @@ public sealed class CycloneDxVexNormalizer : IVexNormalizer
|
||||
var documentId = ExtractDocumentId(root);
|
||||
if (string.IsNullOrWhiteSpace(documentId))
|
||||
{
|
||||
documentId = $"cyclonedx:{Guid.NewGuid():N}";
|
||||
documentId = $"cyclonedx:{_guidProvider.NewGuid():N}";
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_CDX_001",
|
||||
"Serial number not found; generated a random ID",
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
namespace StellaOps.VexLens.Normalization;
|
||||
@@ -11,6 +12,13 @@ namespace StellaOps.VexLens.Normalization;
|
||||
/// </summary>
|
||||
public sealed class OpenVexNormalizer : IVexNormalizer
|
||||
{
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public OpenVexNormalizer(IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public VexSourceFormat SourceFormat => VexSourceFormat.OpenVex;
|
||||
|
||||
public bool CanNormalize(string content)
|
||||
@@ -58,7 +66,7 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
||||
var documentId = ExtractDocumentId(root);
|
||||
if (string.IsNullOrWhiteSpace(documentId))
|
||||
{
|
||||
documentId = $"openvex:{Guid.NewGuid():N}";
|
||||
documentId = $"openvex:{_guidProvider.NewGuid():N}";
|
||||
warnings.Add(new NormalizationWarning(
|
||||
"WARN_OPENVEX_001",
|
||||
"Document ID not found; generated a random ID",
|
||||
@@ -207,7 +215,7 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NormalizedStatement> ExtractStatements(
|
||||
private IReadOnlyList<NormalizedStatement> ExtractStatements(
|
||||
JsonElement root,
|
||||
List<NormalizationWarning> warnings,
|
||||
ref int skipped)
|
||||
@@ -227,7 +235,7 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
||||
|
||||
foreach (var stmt in statementsArray.EnumerateArray())
|
||||
{
|
||||
var statement = ExtractStatement(stmt, index, warnings, ref skipped);
|
||||
var statement = ExtractStatement(stmt, index, warnings, ref skipped, _guidProvider);
|
||||
if (statement != null)
|
||||
{
|
||||
statements.Add(statement);
|
||||
@@ -243,7 +251,8 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
||||
JsonElement stmt,
|
||||
int index,
|
||||
List<NormalizationWarning> warnings,
|
||||
ref int skipped)
|
||||
ref int skipped,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
// Extract vulnerability
|
||||
string? vulnerabilityId = null;
|
||||
@@ -298,7 +307,7 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
||||
{
|
||||
foreach (var prod in productsArray.EnumerateArray())
|
||||
{
|
||||
var product = ExtractProduct(prod);
|
||||
var product = ExtractProduct(prod, guidProvider);
|
||||
if (product != null)
|
||||
{
|
||||
products.Add(product);
|
||||
@@ -378,7 +387,7 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
||||
LastSeen: timestamp);
|
||||
}
|
||||
|
||||
private static NormalizedProduct? ExtractProduct(JsonElement prod)
|
||||
private static NormalizedProduct? ExtractProduct(JsonElement prod, IGuidProvider? guidProvider = null)
|
||||
{
|
||||
string? key = null;
|
||||
string? name = null;
|
||||
@@ -423,8 +432,9 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
||||
return null;
|
||||
}
|
||||
|
||||
var fallbackGuid = guidProvider?.NewGuid() ?? Guid.NewGuid();
|
||||
return new NormalizedProduct(
|
||||
Key: key ?? purl ?? cpe ?? $"unknown-{Guid.NewGuid():N}",
|
||||
Key: key ?? purl ?? cpe ?? $"unknown-{fallbackGuid:N}",
|
||||
Name: name,
|
||||
Version: version,
|
||||
Purl: purl,
|
||||
|
||||
@@ -160,6 +160,7 @@ public sealed class ConsensusJobService : IConsensusJobService
|
||||
private readonly IVexConsensusEngine _consensusEngine;
|
||||
private readonly IConsensusProjectionStore _projectionStore;
|
||||
private readonly IConsensusExportService _exportService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private const string SchemaVersion = "1.0.0";
|
||||
|
||||
@@ -172,11 +173,13 @@ public sealed class ConsensusJobService : IConsensusJobService
|
||||
public ConsensusJobService(
|
||||
IVexConsensusEngine consensusEngine,
|
||||
IConsensusProjectionStore projectionStore,
|
||||
IConsensusExportService exportService)
|
||||
IConsensusExportService exportService,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_consensusEngine = consensusEngine;
|
||||
_projectionStore = projectionStore;
|
||||
_exportService = exportService;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public ConsensusJobRequest CreateComputeJob(
|
||||
@@ -299,7 +302,7 @@ public sealed class ConsensusJobService : IConsensusJobService
|
||||
JobType: ConsensusJobTypes.SnapshotCreate,
|
||||
TenantId: request.TenantId,
|
||||
Priority: ConsensusJobTypes.GetDefaultPriority(ConsensusJobTypes.SnapshotCreate),
|
||||
IdempotencyKey: $"snapshot:{requestHash}:{DateTimeOffset.UtcNow:yyyyMMddHHmm}",
|
||||
IdempotencyKey: $"snapshot:{requestHash}:{_timeProvider.GetUtcNow():yyyyMMddHHmm}",
|
||||
Payload: JsonSerializer.Serialize(payload, JsonOptions));
|
||||
}
|
||||
|
||||
@@ -307,7 +310,7 @@ public sealed class ConsensusJobService : IConsensusJobService
|
||||
ConsensusJobRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var startTime = DateTimeOffset.UtcNow;
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
@@ -350,7 +353,7 @@ public sealed class ConsensusJobService : IConsensusJobService
|
||||
ConsensusJobRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startTime = DateTimeOffset.UtcNow;
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
var payload = JsonSerializer.Deserialize<ComputePayload>(request.Payload, JsonOptions)
|
||||
?? throw new InvalidOperationException("Invalid compute payload");
|
||||
|
||||
@@ -363,7 +366,7 @@ public sealed class ConsensusJobService : IConsensusJobService
|
||||
JobType: request.JobType,
|
||||
ItemsProcessed: 1,
|
||||
ItemsFailed: 0,
|
||||
Duration: DateTimeOffset.UtcNow - startTime,
|
||||
Duration: _timeProvider.GetUtcNow() - startTime,
|
||||
ResultPayload: JsonSerializer.Serialize(new
|
||||
{
|
||||
vulnerabilityId = payload.VulnerabilityId,
|
||||
@@ -377,7 +380,7 @@ public sealed class ConsensusJobService : IConsensusJobService
|
||||
ConsensusJobRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startTime = DateTimeOffset.UtcNow;
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
var payload = JsonSerializer.Deserialize<BatchComputePayload>(request.Payload, JsonOptions)
|
||||
?? throw new InvalidOperationException("Invalid batch compute payload");
|
||||
|
||||
@@ -389,7 +392,7 @@ public sealed class ConsensusJobService : IConsensusJobService
|
||||
JobType: request.JobType,
|
||||
ItemsProcessed: itemCount,
|
||||
ItemsFailed: 0,
|
||||
Duration: DateTimeOffset.UtcNow - startTime,
|
||||
Duration: _timeProvider.GetUtcNow() - startTime,
|
||||
ResultPayload: JsonSerializer.Serialize(new { processedCount = itemCount }, JsonOptions),
|
||||
ErrorMessage: null);
|
||||
}
|
||||
@@ -398,7 +401,7 @@ public sealed class ConsensusJobService : IConsensusJobService
|
||||
ConsensusJobRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var startTime = DateTimeOffset.UtcNow;
|
||||
var startTime = _timeProvider.GetUtcNow();
|
||||
|
||||
// Create snapshot using export service
|
||||
var snapshotRequest = ConsensusExportExtensions.FullExportRequest(request.TenantId);
|
||||
@@ -409,7 +412,7 @@ public sealed class ConsensusJobService : IConsensusJobService
|
||||
JobType: request.JobType,
|
||||
ItemsProcessed: snapshot.Projections.Count,
|
||||
ItemsFailed: 0,
|
||||
Duration: DateTimeOffset.UtcNow - startTime,
|
||||
Duration: _timeProvider.GetUtcNow() - startTime,
|
||||
ResultPayload: JsonSerializer.Serialize(new
|
||||
{
|
||||
snapshotId = snapshot.SnapshotId,
|
||||
@@ -419,14 +422,14 @@ public sealed class ConsensusJobService : IConsensusJobService
|
||||
ErrorMessage: null);
|
||||
}
|
||||
|
||||
private static ConsensusJobResult CreateFailedResult(string jobType, DateTimeOffset startTime, string error)
|
||||
private ConsensusJobResult CreateFailedResult(string jobType, DateTimeOffset startTime, string error)
|
||||
{
|
||||
return new ConsensusJobResult(
|
||||
Success: false,
|
||||
JobType: jobType,
|
||||
ItemsProcessed: 0,
|
||||
ItemsFailed: 1,
|
||||
Duration: DateTimeOffset.UtcNow - startTime,
|
||||
Duration: _timeProvider.GetUtcNow() - startTime,
|
||||
ResultPayload: null,
|
||||
ErrorMessage: error);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Storage;
|
||||
@@ -14,6 +15,8 @@ public sealed class OrchestratorLedgerEventEmitter : IConsensusEventEmitter
|
||||
{
|
||||
private readonly IOrchestratorLedgerClient? _ledgerClient;
|
||||
private readonly OrchestratorEventOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -23,10 +26,14 @@ public sealed class OrchestratorLedgerEventEmitter : IConsensusEventEmitter
|
||||
|
||||
public OrchestratorLedgerEventEmitter(
|
||||
IOrchestratorLedgerClient? ledgerClient = null,
|
||||
OrchestratorEventOptions? options = null)
|
||||
OrchestratorEventOptions? options = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_ledgerClient = ledgerClient;
|
||||
_options = options ?? OrchestratorEventOptions.Default;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? new SystemGuidProvider();
|
||||
}
|
||||
|
||||
public async Task EmitConsensusComputedAsync(
|
||||
@@ -144,11 +151,11 @@ public sealed class OrchestratorLedgerEventEmitter : IConsensusEventEmitter
|
||||
if (_ledgerClient == null) return;
|
||||
|
||||
var alertEvent = new LedgerEvent(
|
||||
EventId: $"alert-{Guid.NewGuid():N}",
|
||||
EventId: $"alert-{_guidProvider.NewGuid():N}",
|
||||
EventType: ConsensusEventTypes.Alert,
|
||||
TenantId: @event.TenantId,
|
||||
CorrelationId: @event.EventId,
|
||||
OccurredAt: DateTimeOffset.UtcNow,
|
||||
OccurredAt: _timeProvider.GetUtcNow(),
|
||||
IdempotencyKey: $"alert-status-{@event.ProjectionId}-{@event.NewStatus}",
|
||||
Actor: new LedgerActor("system", "vexlens", "alert-engine"),
|
||||
Payload: JsonSerializer.Serialize(new
|
||||
@@ -174,11 +181,11 @@ public sealed class OrchestratorLedgerEventEmitter : IConsensusEventEmitter
|
||||
if (_ledgerClient == null) return;
|
||||
|
||||
var alertEvent = new LedgerEvent(
|
||||
EventId: $"alert-{Guid.NewGuid():N}",
|
||||
EventId: $"alert-{_guidProvider.NewGuid():N}",
|
||||
EventType: ConsensusEventTypes.Alert,
|
||||
TenantId: @event.TenantId,
|
||||
CorrelationId: @event.EventId,
|
||||
OccurredAt: DateTimeOffset.UtcNow,
|
||||
OccurredAt: _timeProvider.GetUtcNow(),
|
||||
IdempotencyKey: $"alert-conflict-{@event.ProjectionId}",
|
||||
Actor: new LedgerActor("system", "vexlens", "alert-engine"),
|
||||
Payload: JsonSerializer.Serialize(new
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Licensed under AGPL-3.0-or-later. Copyright (C) 2024-2026 StellaOps Contributors.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
|
||||
@@ -13,6 +14,7 @@ namespace StellaOps.VexLens.Proof;
|
||||
public sealed class VexProofBuilder
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly List<VexProofStatement> _statements = [];
|
||||
private readonly List<VexProofMergeStep> _mergeSteps = [];
|
||||
private readonly List<VexProofConflict> _conflicts = [];
|
||||
@@ -48,11 +50,12 @@ public sealed class VexProofBuilder
|
||||
private decimal _conditionCoverage = 1.0m;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new VexProofBuilder with the specified time provider.
|
||||
/// Creates a new VexProofBuilder with the specified time provider and GUID provider.
|
||||
/// </summary>
|
||||
public VexProofBuilder(TimeProvider timeProvider)
|
||||
public VexProofBuilder(TimeProvider timeProvider, IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -533,10 +536,10 @@ public sealed class VexProofBuilder
|
||||
_ => ConfidenceTier.VeryLow
|
||||
};
|
||||
|
||||
private static string GenerateProofId(DateTimeOffset timestamp)
|
||||
private string GenerateProofId(DateTimeOffset timestamp)
|
||||
{
|
||||
var timePart = timestamp.ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture);
|
||||
var randomPart = Guid.NewGuid().ToString("N")[..8];
|
||||
var randomPart = _guidProvider.NewGuid().ToString("N")[..8];
|
||||
return $"proof-{timePart}-{randomPart}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
<!-- NG-001: Noise-gating dependencies -->
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.ReachGraph\StellaOps.ReachGraph.csproj" />
|
||||
<ProjectReference Include="..\..\Policy\StellaOps.Policy.Engine\StellaOps.Policy.Engine.csproj" />
|
||||
<!-- DET-015: Determinism abstractions for TimeProvider and IGuidProvider -->
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Determinism.Abstractions\StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Exclude legacy folders with external dependencies -->
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Services;
|
||||
@@ -16,13 +17,19 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore
|
||||
private readonly IConsensusEventEmitter? _eventEmitter;
|
||||
// LIN-BE-009: Delta service for computing VEX deltas on status change
|
||||
private readonly IVexDeltaComputeService? _deltaComputeService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public InMemoryConsensusProjectionStore(
|
||||
IConsensusEventEmitter? eventEmitter = null,
|
||||
IVexDeltaComputeService? deltaComputeService = null)
|
||||
IVexDeltaComputeService? deltaComputeService = null,
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_eventEmitter = eventEmitter;
|
||||
_deltaComputeService = deltaComputeService;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public async Task<ConsensusProjection> StoreAsync(
|
||||
@@ -31,7 +38,7 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetKey(result.VulnerabilityId, result.ProductKey, options.TenantId);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Get previous projection for history tracking
|
||||
ConsensusProjection? previous = null;
|
||||
@@ -52,7 +59,7 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore
|
||||
}
|
||||
|
||||
var projection = new ConsensusProjection(
|
||||
ProjectionId: $"proj-{Guid.NewGuid():N}",
|
||||
ProjectionId: $"proj-{_guidProvider.NewGuid():N}",
|
||||
VulnerabilityId: result.VulnerabilityId,
|
||||
ProductKey: result.ProductKey,
|
||||
TenantId: options.TenantId,
|
||||
@@ -283,12 +290,12 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore
|
||||
{
|
||||
if (_eventEmitter == null) return;
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Always emit computed event
|
||||
await _eventEmitter.EmitConsensusComputedAsync(
|
||||
new ConsensusComputedEvent(
|
||||
EventId: $"evt-{Guid.NewGuid():N}",
|
||||
EventId: $"evt-{_guidProvider.NewGuid():N}",
|
||||
ProjectionId: projection.ProjectionId,
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
@@ -307,7 +314,7 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore
|
||||
{
|
||||
await _eventEmitter.EmitStatusChangedAsync(
|
||||
new ConsensusStatusChangedEvent(
|
||||
EventId: $"evt-{Guid.NewGuid():N}",
|
||||
EventId: $"evt-{_guidProvider.NewGuid():N}",
|
||||
ProjectionId: projection.ProjectionId,
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
@@ -325,7 +332,7 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore
|
||||
await _deltaComputeService.ComputeAndStoreAsync(
|
||||
new VexStatusChangeContext
|
||||
{
|
||||
ProjectionId = Guid.TryParse(projection.ProjectionId, out var pid) ? pid : Guid.NewGuid(),
|
||||
ProjectionId = Guid.TryParse(projection.ProjectionId, out var pid) ? pid : _guidProvider.NewGuid(),
|
||||
VulnerabilityId = projection.VulnerabilityId,
|
||||
ProductKey = projection.ProductKey,
|
||||
ArtifactDigest = projection.ProductKey, // Use ProductKey as artifact identifier
|
||||
@@ -355,7 +362,7 @@ public sealed class InMemoryConsensusProjectionStore : IConsensusProjectionStore
|
||||
|
||||
await _eventEmitter.EmitConflictDetectedAsync(
|
||||
new ConsensusConflictDetectedEvent(
|
||||
EventId: $"evt-{Guid.NewGuid():N}",
|
||||
EventId: $"evt-{_guidProvider.NewGuid():N}",
|
||||
ProjectionId: projection.ProjectionId,
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.VexLens.Consensus;
|
||||
using StellaOps.VexLens.Models;
|
||||
using StellaOps.VexLens.Options;
|
||||
@@ -28,19 +29,22 @@ public sealed class PostgresConsensusProjectionStoreProxy : IConsensusProjection
|
||||
private readonly ILogger<PostgresConsensusProjectionStoreProxy> _logger;
|
||||
private readonly VexLensStorageOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public PostgresConsensusProjectionStoreProxy(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresConsensusProjectionStoreProxy> logger,
|
||||
IConsensusEventEmitter? eventEmitter = null,
|
||||
VexLensStorageOptions? options = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_eventEmitter = eventEmitter;
|
||||
_options = options ?? new VexLensStorageOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? new SystemGuidProvider();
|
||||
}
|
||||
|
||||
private const string Schema = "vexlens";
|
||||
@@ -108,7 +112,7 @@ public sealed class PostgresConsensusProjectionStoreProxy : IConsensusProjection
|
||||
activity?.SetTag("vulnerabilityId", result.VulnerabilityId);
|
||||
activity?.SetTag("productKey", result.ProductKey);
|
||||
|
||||
var projectionId = Guid.NewGuid();
|
||||
var projectionId = _guidProvider.NewGuid();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Check for previous projection to track history
|
||||
@@ -517,7 +521,7 @@ public sealed class PostgresConsensusProjectionStoreProxy : IConsensusProjection
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var computedEvent = new ConsensusComputedEvent(
|
||||
EventId: $"evt-{Guid.NewGuid():N}",
|
||||
EventId: $"evt-{_guidProvider.NewGuid():N}",
|
||||
ProjectionId: projection.ProjectionId,
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
@@ -535,7 +539,7 @@ public sealed class PostgresConsensusProjectionStoreProxy : IConsensusProjection
|
||||
if (projection.StatusChanged && previous is not null)
|
||||
{
|
||||
var changedEvent = new ConsensusStatusChangedEvent(
|
||||
EventId: $"evt-{Guid.NewGuid():N}",
|
||||
EventId: $"evt-{_guidProvider.NewGuid():N}",
|
||||
ProjectionId: projection.ProjectionId,
|
||||
VulnerabilityId: projection.VulnerabilityId,
|
||||
ProductKey: projection.ProductKey,
|
||||
|
||||
@@ -65,9 +65,9 @@ public sealed record SourceTrustScoreRequest
|
||||
public required SourceVerificationSummary VerificationSummary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time at which to evaluate the score.
|
||||
/// Time at which to evaluate the score. Required for determinism.
|
||||
/// </summary>
|
||||
public DateTimeOffset EvaluationTime { get; init; } = DateTimeOffset.UtcNow;
|
||||
public required DateTimeOffset EvaluationTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous score for trend calculation.
|
||||
|
||||
@@ -9,16 +9,18 @@ public sealed class InMemorySourceTrustScoreCache : ISourceTrustScoreCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
|
||||
private readonly Timer _cleanupTimer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemorySourceTrustScoreCache()
|
||||
public InMemorySourceTrustScoreCache(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
// Clean up expired entries every 5 minutes
|
||||
_cleanupTimer = new Timer(CleanupExpiredEntries, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
|
||||
}
|
||||
|
||||
public Task<VexSourceTrustScore?> GetAsync(string sourceId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_cache.TryGetValue(sourceId, out var entry) && entry.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
if (_cache.TryGetValue(sourceId, out var entry) && entry.ExpiresAt > _timeProvider.GetUtcNow())
|
||||
{
|
||||
return Task.FromResult<VexSourceTrustScore?>(entry.Score);
|
||||
}
|
||||
@@ -28,7 +30,7 @@ public sealed class InMemorySourceTrustScoreCache : ISourceTrustScoreCache
|
||||
|
||||
public Task SetAsync(string sourceId, VexSourceTrustScore score, TimeSpan duration, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entry = new CacheEntry(score, DateTimeOffset.UtcNow + duration);
|
||||
var entry = new CacheEntry(score, _timeProvider.GetUtcNow() + duration);
|
||||
_cache[sourceId] = entry;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@@ -41,7 +43,7 @@ public sealed class InMemorySourceTrustScoreCache : ISourceTrustScoreCache
|
||||
|
||||
private void CleanupExpiredEntries(object? state)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiredKeys = _cache
|
||||
.Where(kvp => kvp.Value.ExpiresAt <= now)
|
||||
.Select(kvp => kvp.Key)
|
||||
|
||||
@@ -11,13 +11,16 @@ public sealed class ProvenanceChainValidator : IProvenanceChainValidator
|
||||
{
|
||||
private readonly ILogger<ProvenanceChainValidator> _logger;
|
||||
private readonly IIssuerDirectory _issuerDirectory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ProvenanceChainValidator(
|
||||
ILogger<ProvenanceChainValidator> logger,
|
||||
IIssuerDirectory issuerDirectory)
|
||||
IIssuerDirectory issuerDirectory,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_issuerDirectory = issuerDirectory;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ProvenanceValidationResult> ValidateAsync(
|
||||
@@ -44,7 +47,7 @@ public sealed class ProvenanceChainValidator : IProvenanceChainValidator
|
||||
// Validate chain age
|
||||
if (options.MaxChainAge.HasValue)
|
||||
{
|
||||
var chainAge = DateTimeOffset.UtcNow - chain.Origin.Timestamp;
|
||||
var chainAge = _timeProvider.GetUtcNow() - chain.Origin.Timestamp;
|
||||
if (chainAge > options.MaxChainAge.Value)
|
||||
{
|
||||
issues.Add(new ProvenanceIssue
|
||||
|
||||
@@ -11,6 +11,12 @@ public sealed class InMemoryIssuerDirectory : IIssuerDirectory
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, IssuerRecord> _issuers = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, string> _fingerprintToIssuer = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryIssuerDirectory(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<IssuerRecord?> GetIssuerAsync(
|
||||
string issuerId,
|
||||
@@ -86,7 +92,7 @@ public sealed class InMemoryIssuerDirectory : IIssuerDirectory
|
||||
IssuerRegistration registration,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var keyRecords = new List<KeyFingerprintRecord>();
|
||||
|
||||
if (registration.InitialKeys != null)
|
||||
@@ -135,7 +141,7 @@ public sealed class InMemoryIssuerDirectory : IIssuerDirectory
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var updated = current with
|
||||
{
|
||||
Status = IssuerStatus.Revoked,
|
||||
@@ -165,7 +171,7 @@ public sealed class InMemoryIssuerDirectory : IIssuerDirectory
|
||||
throw new InvalidOperationException($"Issuer '{issuerId}' not found");
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var newKey = new KeyFingerprintRecord(
|
||||
Fingerprint: keyRegistration.Fingerprint,
|
||||
KeyType: keyRegistration.KeyType,
|
||||
@@ -209,7 +215,7 @@ public sealed class InMemoryIssuerDirectory : IIssuerDirectory
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var revokedKey = keyIndex.k with
|
||||
{
|
||||
Status = KeyFingerprintStatus.Revoked,
|
||||
@@ -284,7 +290,7 @@ public sealed class InMemoryIssuerDirectory : IIssuerDirectory
|
||||
keyStatus = KeyTrustStatus.Revoked;
|
||||
warnings.Add($"Key was revoked: {key.RevocationReason}");
|
||||
}
|
||||
else if (key.ExpiresAt.HasValue && key.ExpiresAt.Value < DateTimeOffset.UtcNow)
|
||||
else if (key.ExpiresAt.HasValue && key.ExpiresAt.Value < _timeProvider.GetUtcNow())
|
||||
{
|
||||
keyStatus = KeyTrustStatus.Expired;
|
||||
warnings.Add($"Key expired on {key.ExpiresAt.Value:O}");
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Zastava.Agent.Configuration;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
|
||||
@@ -34,15 +35,18 @@ internal sealed class RuntimeEventsClient : IRuntimeEventsClient
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IOptionsMonitor<ZastavaAgentOptions> _options;
|
||||
private readonly ILogger<RuntimeEventsClient> _logger;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public RuntimeEventsClient(
|
||||
HttpClient httpClient,
|
||||
IOptionsMonitor<ZastavaAgentOptions> options,
|
||||
ILogger<RuntimeEventsClient> logger)
|
||||
ILogger<RuntimeEventsClient> logger,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_guidProvider = guidProvider ?? new SystemGuidProvider();
|
||||
}
|
||||
|
||||
public async Task<RuntimeEventsSubmitResult> SubmitAsync(
|
||||
@@ -63,7 +67,7 @@ internal sealed class RuntimeEventsClient : IRuntimeEventsClient
|
||||
{
|
||||
var request = new RuntimeEventsSubmitRequest
|
||||
{
|
||||
BatchId = Guid.NewGuid().ToString("N"),
|
||||
BatchId = _guidProvider.NewGuid().ToString("N"),
|
||||
Events = envelopes.ToArray()
|
||||
};
|
||||
|
||||
|
||||
@@ -21,5 +21,6 @@
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Secrets/StellaOps.Scanner.Surface.Secrets.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Text.Json;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Zastava.Agent.Configuration;
|
||||
using StellaOps.Zastava.Agent.Docker;
|
||||
|
||||
@@ -22,16 +23,22 @@ internal sealed class HealthCheckHostedService : BackgroundService
|
||||
private readonly IDockerSocketClient _dockerClient;
|
||||
private readonly IOptionsMonitor<ZastavaAgentOptions> _options;
|
||||
private readonly ILogger<HealthCheckHostedService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private HttpListener? _listener;
|
||||
|
||||
public HealthCheckHostedService(
|
||||
IDockerSocketClient dockerClient,
|
||||
IOptionsMonitor<ZastavaAgentOptions> options,
|
||||
ILogger<HealthCheckHostedService> logger)
|
||||
ILogger<HealthCheckHostedService> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_dockerClient = dockerClient ?? throw new ArgumentNullException(nameof(dockerClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? new SystemGuidProvider();
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -184,7 +191,7 @@ internal sealed class HealthCheckHostedService : BackgroundService
|
||||
{
|
||||
Status = overallHealthy ? "healthy" : "unhealthy",
|
||||
Checks = checks,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
return (overallHealthy ? 200 : 503, response);
|
||||
@@ -203,7 +210,7 @@ internal sealed class HealthCheckHostedService : BackgroundService
|
||||
{
|
||||
Status = "ready",
|
||||
Message = "Agent ready to process container events",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -211,7 +218,7 @@ internal sealed class HealthCheckHostedService : BackgroundService
|
||||
{
|
||||
Status = "not_ready",
|
||||
Message = "Docker daemon not reachable",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -220,16 +227,16 @@ internal sealed class HealthCheckHostedService : BackgroundService
|
||||
{
|
||||
Status = "not_ready",
|
||||
Message = $"Ready check failed: {ex.Message}",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
Timestamp = _timeProvider.GetUtcNow()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsDirectoryWritable(string path)
|
||||
private bool IsDirectoryWritable(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var testFile = Path.Combine(path, $".healthcheck-{Guid.NewGuid():N}");
|
||||
var testFile = Path.Combine(path, $".healthcheck-{_guidProvider.NewGuid():N}");
|
||||
File.WriteAllText(testFile, "test");
|
||||
File.Delete(testFile);
|
||||
return true;
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Runtime.CompilerServices;
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Zastava.Agent.Configuration;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
using StellaOps.Zastava.Core.Serialization;
|
||||
@@ -31,6 +32,7 @@ internal sealed class RuntimeEventBuffer : IRuntimeEventBuffer
|
||||
private readonly string _spoolPath;
|
||||
private readonly ILogger<RuntimeEventBuffer> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly long _maxDiskBytes;
|
||||
private readonly int _capacity;
|
||||
|
||||
@@ -39,11 +41,13 @@ internal sealed class RuntimeEventBuffer : IRuntimeEventBuffer
|
||||
public RuntimeEventBuffer(
|
||||
IOptions<ZastavaAgentOptions> agentOptions,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<RuntimeEventBuffer> logger)
|
||||
ILogger<RuntimeEventBuffer> logger,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agentOptions);
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_guidProvider = guidProvider ?? new SystemGuidProvider();
|
||||
|
||||
var options = agentOptions.Value ?? throw new ArgumentNullException(nameof(agentOptions));
|
||||
|
||||
@@ -178,7 +182,7 @@ internal sealed class RuntimeEventBuffer : IRuntimeEventBuffer
|
||||
private async Task<string> PersistAsync(byte[] payload, CancellationToken cancellationToken)
|
||||
{
|
||||
var timestamp = _timeProvider.GetUtcNow().UtcTicks;
|
||||
var fileName = $"{timestamp:D20}-{Guid.NewGuid():N}{FileExtension}";
|
||||
var fileName = $"{timestamp:D20}-{_guidProvider.NewGuid():N}{FileExtension}";
|
||||
var filePath = Path.Combine(_spoolPath, fileName);
|
||||
|
||||
Directory.CreateDirectory(_spoolPath);
|
||||
|
||||
@@ -16,18 +16,21 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
||||
private readonly IRuntimeEventsClient _eventsClient;
|
||||
private readonly IOptionsMonitor<ZastavaAgentOptions> _options;
|
||||
private readonly ILogger<RuntimeEventDispatchService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Random _jitterRandom = new();
|
||||
|
||||
public RuntimeEventDispatchService(
|
||||
IRuntimeEventBuffer eventBuffer,
|
||||
IRuntimeEventsClient eventsClient,
|
||||
IOptionsMonitor<ZastavaAgentOptions> options,
|
||||
ILogger<RuntimeEventDispatchService> logger)
|
||||
ILogger<RuntimeEventDispatchService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_eventBuffer = eventBuffer ?? throw new ArgumentNullException(nameof(eventBuffer));
|
||||
_eventsClient = eventsClient ?? throw new ArgumentNullException(nameof(eventsClient));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -43,7 +46,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
||||
flushInterval);
|
||||
|
||||
var batch = new List<RuntimeEventBufferItem>(batchSize);
|
||||
var lastFlush = DateTimeOffset.UtcNow;
|
||||
var lastFlush = _timeProvider.GetUtcNow();
|
||||
var failureCount = 0;
|
||||
|
||||
try
|
||||
@@ -53,7 +56,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
||||
batch.Add(item);
|
||||
|
||||
var shouldFlush = batch.Count >= batchSize ||
|
||||
(batch.Count > 0 && DateTimeOffset.UtcNow - lastFlush >= flushInterval);
|
||||
(batch.Count > 0 && _timeProvider.GetUtcNow() - lastFlush >= flushInterval);
|
||||
|
||||
if (shouldFlush)
|
||||
{
|
||||
@@ -68,7 +71,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
||||
}
|
||||
|
||||
batch.Clear();
|
||||
lastFlush = DateTimeOffset.UtcNow;
|
||||
lastFlush = _timeProvider.GetUtcNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ public sealed class EbpfProbeManager : IProbeManager, IAsyncDisposable
|
||||
private readonly IRuntimeSignalCollector _signalCollector;
|
||||
private readonly ISignalPublisher _signalPublisher;
|
||||
private readonly EbpfProbeManagerOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ConcurrentDictionary<string, SignalCollectionHandle> _activeHandles;
|
||||
private bool _disposed;
|
||||
|
||||
@@ -30,12 +31,14 @@ public sealed class EbpfProbeManager : IProbeManager, IAsyncDisposable
|
||||
ILogger<EbpfProbeManager> logger,
|
||||
IRuntimeSignalCollector signalCollector,
|
||||
ISignalPublisher signalPublisher,
|
||||
IOptions<EbpfProbeManagerOptions> options)
|
||||
IOptions<EbpfProbeManagerOptions> options,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_signalCollector = signalCollector;
|
||||
_signalPublisher = signalPublisher;
|
||||
_options = options.Value;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_activeHandles = new ConcurrentDictionary<string, SignalCollectionHandle>();
|
||||
}
|
||||
|
||||
@@ -277,7 +280,7 @@ public sealed class EbpfProbeManager : IProbeManager, IAsyncDisposable
|
||||
Namespace = evt.Labels.GetValueOrDefault("io.kubernetes.pod.namespace"),
|
||||
PodName = evt.Labels.GetValueOrDefault("io.kubernetes.pod.name"),
|
||||
Summary = summary,
|
||||
CollectedAt = DateTimeOffset.UtcNow,
|
||||
CollectedAt = _timeProvider.GetUtcNow(),
|
||||
};
|
||||
|
||||
await _signalPublisher.PublishAsync(message, ct);
|
||||
|
||||
@@ -30,10 +30,12 @@ internal sealed class ProcSnapshotCollector : IProcSnapshotCollector
|
||||
private readonly DotNetAssemblyCollector _dotnetCollector;
|
||||
private readonly PhpAutoloadCollector _phpCollector;
|
||||
private readonly ILogger<ProcSnapshotCollector> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly string _procRoot;
|
||||
|
||||
public ProcSnapshotCollector(
|
||||
IOptions<ZastavaObserverOptions> options,
|
||||
TimeProvider? timeProvider,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
@@ -41,6 +43,7 @@ internal sealed class ProcSnapshotCollector : IProcSnapshotCollector
|
||||
|
||||
_procRoot = options.Value.ProcRootPath;
|
||||
_logger = loggerFactory.CreateLogger<ProcSnapshotCollector>();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
_javaCollector = new JavaClasspathCollector(
|
||||
_procRoot,
|
||||
@@ -82,7 +85,7 @@ internal sealed class ProcSnapshotCollector : IProcSnapshotCollector
|
||||
|
||||
var document = new ProcSnapshotDocument
|
||||
{
|
||||
Id = $"{tenant}:{imageDigest}:{pid}:{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
|
||||
Id = $"{tenant}:{imageDigest}:{pid}:{_timeProvider.GetUtcNow().ToUnixTimeMilliseconds()}",
|
||||
Tenant = tenant,
|
||||
ImageDigest = imageDigest,
|
||||
ContainerId = container.Id,
|
||||
@@ -91,7 +94,7 @@ internal sealed class ProcSnapshotCollector : IProcSnapshotCollector
|
||||
Classpath = snapshot.Classpath,
|
||||
LoadedAssemblies = snapshot.LoadedAssemblies,
|
||||
AutoloadPaths = snapshot.AutoloadPaths,
|
||||
CapturedAt = DateTimeOffset.UtcNow
|
||||
CapturedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
_logger.LogDebug(
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Runtime.CompilerServices;
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
using StellaOps.Zastava.Core.Serialization;
|
||||
using StellaOps.Zastava.Observer.Configuration;
|
||||
@@ -32,6 +33,7 @@ internal sealed class RuntimeEventBuffer : IRuntimeEventBuffer
|
||||
private readonly string spoolPath;
|
||||
private readonly ILogger<RuntimeEventBuffer> logger;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly IGuidProvider guidProvider;
|
||||
private readonly long maxDiskBytes;
|
||||
|
||||
private long currentBytes;
|
||||
@@ -40,11 +42,13 @@ internal sealed class RuntimeEventBuffer : IRuntimeEventBuffer
|
||||
public RuntimeEventBuffer(
|
||||
IOptions<ZastavaObserverOptions> observerOptions,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<RuntimeEventBuffer> logger)
|
||||
ILogger<RuntimeEventBuffer> logger,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(observerOptions);
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
|
||||
var options = observerOptions.Value ?? throw new ArgumentNullException(nameof(observerOptions));
|
||||
|
||||
@@ -178,7 +182,7 @@ internal sealed class RuntimeEventBuffer : IRuntimeEventBuffer
|
||||
private async Task<string> PersistAsync(byte[] payload, CancellationToken cancellationToken)
|
||||
{
|
||||
var timestamp = timeProvider.GetUtcNow().UtcTicks;
|
||||
var fileName = $"{timestamp:D20}-{Guid.NewGuid():N}{FileExtension}";
|
||||
var fileName = $"{timestamp:D20}-{guidProvider.NewGuid():N}{FileExtension}";
|
||||
var filePath = Path.Combine(spoolPath, fileName);
|
||||
|
||||
Directory.CreateDirectory(spoolPath);
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Secrets/StellaOps.Scanner.Surface.Secrets.csproj" />
|
||||
<ProjectReference Include="../../Scanner/__Libraries/StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography.DependencyInjection/StellaOps.Cryptography.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Protobuf Include="Protos/runtime/v1/runtime.proto" GrpcServices="Client" />
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Net;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Zastava.Core.Contracts;
|
||||
using StellaOps.Zastava.Observer.Backend;
|
||||
using StellaOps.Zastava.Observer.Configuration;
|
||||
@@ -17,6 +18,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
||||
private readonly IRuntimeFactsClient runtimeFactsClient;
|
||||
private readonly IOptionsMonitor<ZastavaObserverOptions> observerOptions;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly IGuidProvider guidProvider;
|
||||
private readonly ILogger<RuntimeEventDispatchService> logger;
|
||||
|
||||
public RuntimeEventDispatchService(
|
||||
@@ -25,6 +27,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
||||
IRuntimeFactsClient runtimeFactsClient,
|
||||
IOptionsMonitor<ZastavaObserverOptions> observerOptions,
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider? guidProvider,
|
||||
ILogger<RuntimeEventDispatchService> logger)
|
||||
{
|
||||
this.buffer = buffer ?? throw new ArgumentNullException(nameof(buffer));
|
||||
@@ -32,6 +35,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
||||
this.runtimeFactsClient = runtimeFactsClient ?? throw new ArgumentNullException(nameof(runtimeFactsClient));
|
||||
this.observerOptions = observerOptions ?? throw new ArgumentNullException(nameof(observerOptions));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
@@ -127,7 +131,7 @@ internal sealed class RuntimeEventDispatchService : BackgroundService
|
||||
|
||||
var request = new RuntimeEventsIngestRequest
|
||||
{
|
||||
BatchId = $"obs-{timeProvider.GetUtcNow():yyyyMMddTHHmmssfff}-{Guid.NewGuid():N}",
|
||||
BatchId = $"obs-{timeProvider.GetUtcNow():yyyyMMddTHHmmssfff}-{guidProvider.NewGuid():N}",
|
||||
Events = envelopes
|
||||
};
|
||||
|
||||
|
||||
@@ -6,14 +6,17 @@ public sealed class WebhookCertificateHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IWebhookCertificateProvider _certificateProvider;
|
||||
private readonly ILogger<WebhookCertificateHealthCheck> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly TimeSpan _expiryThreshold = TimeSpan.FromDays(7);
|
||||
|
||||
public WebhookCertificateHealthCheck(
|
||||
IWebhookCertificateProvider certificateProvider,
|
||||
ILogger<WebhookCertificateHealthCheck> logger)
|
||||
ILogger<WebhookCertificateHealthCheck> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_certificateProvider = certificateProvider;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
@@ -22,7 +25,7 @@ public sealed class WebhookCertificateHealthCheck : IHealthCheck
|
||||
{
|
||||
var certificate = _certificateProvider.GetCertificate();
|
||||
var expires = certificate.NotAfter.ToUniversalTime();
|
||||
var remaining = expires - DateTimeOffset.UtcNow;
|
||||
var remaining = expires - _timeProvider.GetUtcNow();
|
||||
|
||||
if (remaining <= TimeSpan.Zero)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user