save progress

This commit is contained in:
StellaOps Bot
2026-01-04 19:08:47 +02:00
parent f7d27c6fda
commit 75611a505f
97 changed files with 4531 additions and 293 deletions

View File

@@ -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();

View File

@@ -41,7 +41,7 @@ public static class JsonNormalizer
}
var normalized = NormalizeNode(node, options);
return normalized.ToJsonString(SerializerOptions);
return normalized?.ToJsonString(SerializerOptions) ?? "null";
}
/// <summary>

View File

@@ -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>

View File

@@ -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; }
}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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);

View File

@@ -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);

View File

@@ -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; }

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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; }
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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()
};
}

View File

@@ -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
};
}

View File

@@ -12,4 +12,8 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../__Libraries/StellaOps.Determinism.Abstractions/StellaOps.Determinism.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@@ -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()
};
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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()
};
}

View File

@@ -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()
};
}
}

View File

@@ -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()
};
}

View File

@@ -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()
};
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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);

View File

@@ -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

View File

@@ -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>

View File

@@ -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() }
};

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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 = $"""

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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(),

View File

@@ -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(

View File

@@ -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(

View File

@@ -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" />

View File

@@ -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
{

View File

@@ -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",

View File

@@ -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,

View File

@@ -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>

View File

@@ -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(),

View File

@@ -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;

View File

@@ -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>

View File

@@ -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.

View File

@@ -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>

View File

@@ -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,

View File

@@ -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>

View File

@@ -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(

View File

@@ -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,

View File

@@ -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(

View File

@@ -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;

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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}";
}
}

View File

@@ -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 -->

View File

@@ -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,

View File

@@ -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,

View File

@@ -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.

View File

@@ -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)

View File

@@ -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

View File

@@ -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}");

View File

@@ -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()
};

View File

@@ -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>

View File

@@ -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;

View File

@@ -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);

View File

@@ -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();
}
}
}

View File

@@ -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);

View File

@@ -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(

View File

@@ -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);

View File

@@ -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" />

View File

@@ -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
};

View File

@@ -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)
{