save progress
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
@@ -116,12 +117,12 @@ public static class OfflineVerificationPolicyLoader
|
||||
return JsonValue.Create(boolean);
|
||||
}
|
||||
|
||||
if (long.TryParse(scalar.Value, out var integer))
|
||||
if (long.TryParse(scalar.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var integer))
|
||||
{
|
||||
return JsonValue.Create(integer);
|
||||
}
|
||||
|
||||
if (decimal.TryParse(scalar.Value, out var decimalValue))
|
||||
if (decimal.TryParse(scalar.Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var decimalValue))
|
||||
{
|
||||
return JsonValue.Create(decimalValue);
|
||||
}
|
||||
@@ -129,4 +130,3 @@ public static class OfflineVerificationPolicyLoader
|
||||
return JsonValue.Create(scalar.Value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class EvidenceGraph
|
||||
/// Generation timestamp in ISO 8601 UTC format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public string GeneratedAt { get; init; } = DateTimeOffset.UtcNow.ToString("O");
|
||||
public string GeneratedAt { get; init; } = DateTimeOffset.UnixEpoch.ToString("O");
|
||||
|
||||
/// <summary>
|
||||
/// Generator tool identifier.
|
||||
@@ -128,6 +128,9 @@ public sealed record AttestationNodeRef
|
||||
[JsonPropertyName("path")]
|
||||
public required string Path { get; init; }
|
||||
|
||||
[JsonPropertyName("contentHash")]
|
||||
public required string ContentHash { get; init; }
|
||||
|
||||
[JsonPropertyName("signatureValid")]
|
||||
public bool SignatureValid { get; init; }
|
||||
|
||||
@@ -237,6 +240,7 @@ public sealed class EvidenceGraphSerializer
|
||||
Generator = graph.Generator,
|
||||
Nodes = graph.Nodes
|
||||
.OrderBy(n => n.Id, StringComparer.Ordinal)
|
||||
.Select(ApplyDeterministicOrdering)
|
||||
.ToList(),
|
||||
Edges = graph.Edges
|
||||
.OrderBy(e => e.Source, StringComparer.Ordinal)
|
||||
@@ -303,4 +307,27 @@ public sealed class EvidenceGraphSerializer
|
||||
|
||||
return (graph, hashValid);
|
||||
}
|
||||
|
||||
private static EvidenceNode ApplyDeterministicOrdering(EvidenceNode node)
|
||||
{
|
||||
return node with
|
||||
{
|
||||
Sboms = node.Sboms?
|
||||
.OrderBy(s => s.ContentHash, StringComparer.Ordinal)
|
||||
.ThenBy(s => s.Path, StringComparer.Ordinal)
|
||||
.ThenBy(s => s.Format, StringComparer.Ordinal)
|
||||
.ToList(),
|
||||
Attestations = node.Attestations?
|
||||
.OrderBy(a => a.ContentHash, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.Path, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.PredicateType, StringComparer.Ordinal)
|
||||
.ToList(),
|
||||
VexStatements = node.VexStatements?
|
||||
.OrderBy(v => v.VulnerabilityId, StringComparer.Ordinal)
|
||||
.ThenBy(v => v.Source, StringComparer.Ordinal)
|
||||
.ThenBy(v => v.Status, StringComparer.Ordinal)
|
||||
.ThenBy(v => v.Justification ?? string.Empty, StringComparer.Ordinal)
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
|
||||
{
|
||||
PredicateType = a.PredicateType,
|
||||
Path = a.FilePath,
|
||||
ContentHash = a.ContentHash,
|
||||
SignatureValid = a.SignatureVerified,
|
||||
RekorVerified = a.TlogVerified
|
||||
}).ToList(),
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// Part of Step 2: Evidence Collection (Task T5)
|
||||
// =============================================================================
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
@@ -121,7 +122,7 @@ public sealed class CycloneDxParser : ISbomParser
|
||||
{
|
||||
if (metadataProp.TryGetProperty("timestamp", out var timestampProp))
|
||||
{
|
||||
if (DateTimeOffset.TryParse(timestampProp.GetString(), out var parsed))
|
||||
if (TryParseTimestamp(timestampProp.GetString(), out var parsed))
|
||||
{
|
||||
createdAt = parsed;
|
||||
}
|
||||
@@ -214,21 +215,7 @@ public sealed class CycloneDxParser : ISbomParser
|
||||
}
|
||||
|
||||
// Determine primary digest (prefer SHA-256)
|
||||
string? digest = null;
|
||||
if (hashes.TryGetValue("SHA-256", out var sha256))
|
||||
{
|
||||
digest = NormalizeDigest("sha256:" + sha256);
|
||||
}
|
||||
else if (hashes.TryGetValue("SHA256", out sha256))
|
||||
{
|
||||
digest = NormalizeDigest("sha256:" + sha256);
|
||||
}
|
||||
else if (hashes.Count > 0)
|
||||
{
|
||||
// Use first available hash
|
||||
var first = hashes.First();
|
||||
digest = NormalizeDigest($"{first.Key.ToLowerInvariant().Replace("-", "")}:{first.Value}");
|
||||
}
|
||||
var digest = TrySelectSha256Digest(hashes);
|
||||
|
||||
// If no digest, this component can't be indexed by digest
|
||||
if (string.IsNullOrEmpty(digest))
|
||||
@@ -333,4 +320,24 @@ public sealed class CycloneDxParser : ISbomParser
|
||||
{
|
||||
return ArtifactIndex.NormalizeDigest(digest);
|
||||
}
|
||||
|
||||
private static bool TryParseTimestamp(string? value, out DateTimeOffset timestamp)
|
||||
{
|
||||
timestamp = default;
|
||||
return !string.IsNullOrWhiteSpace(value) &&
|
||||
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out timestamp);
|
||||
}
|
||||
|
||||
private static string? TrySelectSha256Digest(IReadOnlyDictionary<string, string> hashes)
|
||||
{
|
||||
foreach (var key in new[] { "SHA-256", "SHA256", "sha256" })
|
||||
{
|
||||
if (hashes.TryGetValue(key, out var sha256))
|
||||
{
|
||||
return NormalizeDigest("sha256:" + sha256);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,10 +174,14 @@ public sealed record InTotoSubject
|
||||
/// </summary>
|
||||
public string? GetSha256Digest()
|
||||
{
|
||||
if (Digest.TryGetValue("sha256", out var hash))
|
||||
foreach (var (key, value) in Digest)
|
||||
{
|
||||
return "sha256:" + hash.ToLowerInvariant();
|
||||
if (string.Equals(key, "sha256", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "sha256:" + value.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// Part of Step 2: Evidence Collection (Task T5)
|
||||
// =============================================================================
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Reconciliation.Parsers;
|
||||
@@ -114,7 +115,7 @@ public sealed class SpdxParser : ISbomParser
|
||||
if (root.TryGetProperty("creationInfo", out var creationInfoProp) &&
|
||||
creationInfoProp.TryGetProperty("created", out var createdProp))
|
||||
{
|
||||
if (DateTimeOffset.TryParse(createdProp.GetString(), out var parsed))
|
||||
if (TryParseTimestamp(createdProp.GetString(), out var parsed))
|
||||
{
|
||||
createdAt = parsed;
|
||||
}
|
||||
@@ -224,17 +225,7 @@ public sealed class SpdxParser : ISbomParser
|
||||
}
|
||||
|
||||
// Determine primary digest (prefer SHA256)
|
||||
string? digest = null;
|
||||
if (hashes.TryGetValue("SHA256", out var sha256))
|
||||
{
|
||||
digest = NormalizeDigest("sha256:" + sha256);
|
||||
}
|
||||
else if (hashes.Count > 0)
|
||||
{
|
||||
// Use first available hash
|
||||
var first = hashes.First();
|
||||
digest = NormalizeDigest($"{first.Key.ToLowerInvariant()}:{first.Value}");
|
||||
}
|
||||
var digest = TrySelectSha256Digest(hashes);
|
||||
|
||||
// If no digest, this package can't be indexed by digest
|
||||
if (string.IsNullOrEmpty(digest))
|
||||
@@ -302,4 +293,24 @@ public sealed class SpdxParser : ISbomParser
|
||||
{
|
||||
return ArtifactIndex.NormalizeDigest(digest);
|
||||
}
|
||||
|
||||
private static bool TryParseTimestamp(string? value, out DateTimeOffset timestamp)
|
||||
{
|
||||
timestamp = default;
|
||||
return !string.IsNullOrWhiteSpace(value) &&
|
||||
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out timestamp);
|
||||
}
|
||||
|
||||
private static string? TrySelectSha256Digest(IReadOnlyDictionary<string, string> hashes)
|
||||
{
|
||||
foreach (var key in new[] { "SHA256", "SHA-256", "sha256" })
|
||||
{
|
||||
if (hashes.TryGetValue(key, out var sha256))
|
||||
{
|
||||
return NormalizeDigest("sha256:" + sha256);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
using Org.BouncyCastle.OpenSsl;
|
||||
using Org.BouncyCastle.Asn1.X9;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.AirGap.Importer.Validation;
|
||||
using AttestorDsseEnvelope = StellaOps.Attestor.Envelope.DsseEnvelope;
|
||||
using AttestorDsseSignature = StellaOps.Attestor.Envelope.DsseSignature;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Reconciliation.Signing;
|
||||
|
||||
@@ -45,10 +48,10 @@ internal sealed class EvidenceGraphDsseSigner
|
||||
var envelopeKey = LoadEcdsaEnvelopeKey(signingPrivateKeyPemPath, signingKeyId);
|
||||
var signature = SignDeterministicEcdsa(pae, signingPrivateKeyPemPath, envelopeKey.AlgorithmId);
|
||||
|
||||
var envelope = new DsseEnvelope(
|
||||
var envelope = new AttestorDsseEnvelope(
|
||||
EvidenceGraphPayloadType,
|
||||
payloadBytes,
|
||||
signatures: [DsseSignature.FromBytes(signature, envelopeKey.KeyId)],
|
||||
signatures: [AttestorDsseSignature.FromBytes(signature, envelopeKey.KeyId)],
|
||||
payloadContentType: "application/json");
|
||||
|
||||
var serialized = DsseEnvelopeSerializer.Serialize(
|
||||
@@ -177,25 +180,3 @@ internal sealed class EvidenceGraphDsseSigner
|
||||
/// </summary>
|
||||
private sealed record EnvelopeKey(string AlgorithmId, string KeyId);
|
||||
}
|
||||
|
||||
internal static class DssePreAuthenticationEncoding
|
||||
{
|
||||
private const string Prefix = "DSSEv1";
|
||||
|
||||
public static byte[] Encode(string payloadType, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payloadType))
|
||||
{
|
||||
throw new ArgumentException("payloadType must be provided.", nameof(payloadType));
|
||||
}
|
||||
|
||||
var payloadTypeByteCount = Encoding.UTF8.GetByteCount(payloadType);
|
||||
var header = $"{Prefix} {payloadTypeByteCount} {payloadType} {payload.Length} ";
|
||||
var headerBytes = Encoding.UTF8.GetBytes(header);
|
||||
|
||||
var buffer = new byte[headerBytes.Length + payload.Length];
|
||||
headerBytes.CopyTo(buffer.AsSpan());
|
||||
payload.CopyTo(buffer.AsSpan(headerBytes.Length));
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,10 +107,12 @@ public sealed class SourcePrecedenceLattice
|
||||
nameof(statements));
|
||||
}
|
||||
|
||||
// Sort by precedence (descending), then by timestamp (descending)
|
||||
// Sort by precedence (descending), timestamp (descending), then status priority (configurable).
|
||||
var winner = statementList
|
||||
.OrderByDescending(s => (int)s.Source)
|
||||
.ThenByDescending(s => s.Timestamp ?? DateTimeOffset.MinValue)
|
||||
.ThenByDescending(s => GetStatusPriority(s.Status))
|
||||
.ThenBy(s => s.DocumentRef ?? string.Empty, StringComparer.Ordinal)
|
||||
.First();
|
||||
|
||||
return winner;
|
||||
@@ -184,18 +186,8 @@ public sealed class SourcePrecedenceLattice
|
||||
}
|
||||
|
||||
// Same precedence and timestamp - true conflict
|
||||
// Use status priority: NotAffected > Fixed > UnderInvestigation > Affected > Unknown
|
||||
var statusPriority = new Dictionary<VexStatus, int>
|
||||
{
|
||||
[VexStatus.NotAffected] = 5,
|
||||
[VexStatus.Fixed] = 4,
|
||||
[VexStatus.UnderInvestigation] = 3,
|
||||
[VexStatus.Affected] = 2,
|
||||
[VexStatus.Unknown] = 1
|
||||
};
|
||||
|
||||
var aPriority = statusPriority.GetValueOrDefault(a.Status, 0);
|
||||
var bPriority = statusPriority.GetValueOrDefault(b.Status, 0);
|
||||
var aPriority = GetStatusPriority(a.Status);
|
||||
var bPriority = GetStatusPriority(b.Status);
|
||||
|
||||
if (aPriority != bPriority)
|
||||
{
|
||||
@@ -218,6 +210,29 @@ public sealed class SourcePrecedenceLattice
|
||||
Winner: fallbackWinner,
|
||||
Reason: "Deterministic fallback (document ref ordering)");
|
||||
}
|
||||
|
||||
private int GetStatusPriority(VexStatus status)
|
||||
{
|
||||
return _config.PreferRestrictive
|
||||
? status switch
|
||||
{
|
||||
VexStatus.Affected => 5,
|
||||
VexStatus.UnderInvestigation => 4,
|
||||
VexStatus.Fixed => 3,
|
||||
VexStatus.NotAffected => 2,
|
||||
VexStatus.Unknown => 1,
|
||||
_ => 0
|
||||
}
|
||||
: status switch
|
||||
{
|
||||
VexStatus.NotAffected => 5,
|
||||
VexStatus.Fixed => 4,
|
||||
VexStatus.UnderInvestigation => 3,
|
||||
VexStatus.Affected => 2,
|
||||
VexStatus.Unknown => 1,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0026-M | DONE | Maintainability audit for StellaOps.AirGap.Importer. |
|
||||
| AUDIT-0026-T | DONE | Test coverage audit for StellaOps.AirGap.Importer. |
|
||||
| AUDIT-0026-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0026-A | DOING | Pending approval for changes. |
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AirGap.Importer.Validation;
|
||||
|
||||
internal static class DssePreAuthenticationEncoding
|
||||
{
|
||||
private const string Prefix = "DSSEv1";
|
||||
|
||||
public static byte[] Encode(string payloadType, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payloadType))
|
||||
{
|
||||
throw new ArgumentException("payloadType must be provided.", nameof(payloadType));
|
||||
}
|
||||
|
||||
var payloadTypeByteCount = Encoding.UTF8.GetByteCount(payloadType);
|
||||
var header = $"{Prefix} {payloadTypeByteCount} {payloadType} {payload.Length} ";
|
||||
var headerBytes = Encoding.UTF8.GetBytes(header);
|
||||
|
||||
var buffer = new byte[headerBytes.Length + payload.Length];
|
||||
headerBytes.CopyTo(buffer.AsSpan());
|
||||
payload.CopyTo(buffer.AsSpan(headerBytes.Length));
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
using StellaOps.Cryptography;
|
||||
@@ -12,10 +11,10 @@ namespace StellaOps.AirGap.Importer.Validation;
|
||||
/// </summary>
|
||||
public sealed class DsseVerifier
|
||||
{
|
||||
private const string PaePrefix = "DSSEv1";
|
||||
private readonly ICryptoProviderRegistry _cryptoRegistry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DsseVerifier(ICryptoProviderRegistry? cryptoRegistry = null)
|
||||
public DsseVerifier(ICryptoProviderRegistry? cryptoRegistry = null, TimeProvider? timeProvider = null)
|
||||
{
|
||||
if (cryptoRegistry is null)
|
||||
{
|
||||
@@ -27,6 +26,8 @@ public sealed class DsseVerifier
|
||||
{
|
||||
_cryptoRegistry = cryptoRegistry;
|
||||
}
|
||||
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public BundleValidationResult Verify(DsseEnvelope envelope, TrustRootConfig trustRoots, ILogger? logger = null)
|
||||
@@ -41,35 +42,86 @@ public sealed class DsseVerifier
|
||||
return BundleValidationResult.Failure("trust-roots-required");
|
||||
}
|
||||
|
||||
if (!IsAlgorithmAllowed(trustRoots.AllowedSignatureAlgorithms))
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"offlinekit.dsse.verify failed reason_code={reason_code} allowed_algorithms={allowed_algorithms}",
|
||||
"ALGORITHM_NOT_ALLOWED",
|
||||
trustRoots.AllowedSignatureAlgorithms.Count);
|
||||
return BundleValidationResult.Failure("signature-algorithm-disallowed");
|
||||
}
|
||||
|
||||
if (!IsWithinTrustWindow(trustRoots, _timeProvider.GetUtcNow(), out var windowReason))
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"offlinekit.dsse.verify failed reason_code={reason_code} trust_window={trust_window}",
|
||||
"TRUST_WINDOW_INVALID",
|
||||
windowReason);
|
||||
return BundleValidationResult.Failure("trust-window-invalid");
|
||||
}
|
||||
|
||||
logger?.LogDebug(
|
||||
"offlinekit.dsse.verify start payload_type={payload_type} signatures={signatures} public_keys={public_keys}",
|
||||
envelope.PayloadType,
|
||||
envelope.Signatures.Count,
|
||||
trustRoots.PublicKeys.Count);
|
||||
|
||||
if (!TryDecodeBase64(envelope.Payload, out var payloadBytes))
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"offlinekit.dsse.verify failed reason_code={reason_code}",
|
||||
"PAYLOAD_BASE64_INVALID");
|
||||
return BundleValidationResult.Failure("payload-base64-invalid");
|
||||
}
|
||||
|
||||
var fingerprints = new HashSet<string>(trustRoots.TrustedKeyFingerprints, StringComparer.OrdinalIgnoreCase);
|
||||
var signatureKeyIds = envelope.Signatures
|
||||
.Where(sig => !string.IsNullOrWhiteSpace(sig.KeyId))
|
||||
.Select(sig => sig.KeyId!)
|
||||
.ToList();
|
||||
|
||||
if (signatureKeyIds.Count == 0)
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"offlinekit.dsse.verify failed reason_code={reason_code}",
|
||||
"SIGNATURE_KEYID_MISSING");
|
||||
return BundleValidationResult.Failure("signature-keyid-missing");
|
||||
}
|
||||
|
||||
var pae = DssePreAuthenticationEncoding.Encode(envelope.PayloadType, payloadBytes);
|
||||
|
||||
foreach (var signature in envelope.Signatures)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(signature.KeyId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!trustRoots.PublicKeys.TryGetValue(signature.KeyId, out var keyBytes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fingerprint = ComputeFingerprint(keyBytes);
|
||||
if (!trustRoots.TrustedKeyFingerprints.Contains(fingerprint))
|
||||
if (!fingerprints.Contains(fingerprint))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var pae = BuildPreAuthEncoding(envelope.PayloadType, envelope.Payload);
|
||||
if (TryVerifyRsaPss(keyBytes, pae, signature.Signature))
|
||||
if (!TryDecodeBase64(signature.Signature, out var sigBytes))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryVerifyRsaPss(keyBytes, pae, sigBytes))
|
||||
{
|
||||
logger?.LogInformation(
|
||||
"offlinekit.dsse.verify succeeded key_id={key_id} fingerprint={fingerprint} payload_type={payload_type}",
|
||||
signature.KeyId,
|
||||
fingerprint,
|
||||
envelope.PayloadType);
|
||||
return BundleValidationResult.Success("dsse-signature-verified");
|
||||
}
|
||||
return BundleValidationResult.Success("dsse-signature-verified");
|
||||
}
|
||||
}
|
||||
|
||||
logger?.LogWarning(
|
||||
@@ -80,31 +132,7 @@ public sealed class DsseVerifier
|
||||
return BundleValidationResult.Failure("dsse-signature-untrusted-or-invalid");
|
||||
}
|
||||
|
||||
private static byte[] BuildPreAuthEncoding(string payloadType, string payloadBase64)
|
||||
{
|
||||
var payloadBytes = Convert.FromBase64String(payloadBase64);
|
||||
var parts = new[]
|
||||
{
|
||||
PaePrefix,
|
||||
payloadType,
|
||||
Encoding.UTF8.GetString(payloadBytes)
|
||||
};
|
||||
|
||||
var paeBuilder = new StringBuilder();
|
||||
paeBuilder.Append("PAE:");
|
||||
paeBuilder.Append(parts.Length);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
paeBuilder.Append(' ');
|
||||
paeBuilder.Append(part.Length);
|
||||
paeBuilder.Append(' ');
|
||||
paeBuilder.Append(part);
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetBytes(paeBuilder.ToString());
|
||||
}
|
||||
|
||||
private bool TryVerifyRsaPss(byte[] publicKey, byte[] pae, string signatureBase64)
|
||||
private bool TryVerifyRsaPss(byte[] publicKey, byte[] pae, byte[] signatureBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -112,8 +140,7 @@ public sealed class DsseVerifier
|
||||
var verifier = _cryptoRegistry.ResolveOrThrow(CryptoCapability.Verification, "PS256")
|
||||
.CreateEphemeralVerifier("PS256", publicKey);
|
||||
|
||||
var sig = Convert.FromBase64String(signatureBase64);
|
||||
var result = verifier.VerifyAsync(pae, sig).GetAwaiter().GetResult();
|
||||
var result = verifier.VerifyAsync(pae, signatureBytes).GetAwaiter().GetResult();
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
@@ -128,5 +155,57 @@ public sealed class DsseVerifier
|
||||
var hash = hasherResolution.Hasher.ComputeHash(publicKey);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecodeBase64(string value, out byte[] bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(value);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAlgorithmAllowed(IReadOnlyCollection<string> allowedAlgorithms)
|
||||
{
|
||||
if (allowedAlgorithms.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var algorithm in allowedAlgorithms)
|
||||
{
|
||||
if (string.Equals(algorithm, "PS256", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(algorithm, "RSASSA-PSS-SHA256", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(algorithm, "RSA-PSS-SHA256", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsWithinTrustWindow(TrustRootConfig trustRoots, DateTimeOffset nowUtc, out string reason)
|
||||
{
|
||||
reason = string.Empty;
|
||||
|
||||
if (trustRoots.NotBeforeUtc is { } notBefore && nowUtc < notBefore)
|
||||
{
|
||||
reason = "not-before";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (trustRoots.NotAfterUtc is { } notAfter && nowUtc > notAfter)
|
||||
{
|
||||
reason = "not-after";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AirGap.Importer.Contracts;
|
||||
using StellaOps.AirGap.Importer.Quarantine;
|
||||
@@ -99,6 +100,38 @@ public sealed class ImportValidator
|
||||
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
|
||||
return failed;
|
||||
}
|
||||
|
||||
var expectedMerkleRoot = TryExtractManifestMerkleRoot(request.ManifestJson, out var manifestError);
|
||||
if (string.IsNullOrWhiteSpace(expectedMerkleRoot))
|
||||
{
|
||||
var failed = BundleValidationResult.Failure(manifestError ?? "merkle-root-missing");
|
||||
verificationLog.Add(failed.Reason);
|
||||
_logger.LogWarning(
|
||||
"offlinekit.import.validation failed tenant_id={tenant_id} bundle_type={bundle_type} bundle_digest={bundle_digest} reason_code={reason_code} reason_message={reason_message}",
|
||||
request.TenantId,
|
||||
request.BundleType,
|
||||
request.BundleDigest,
|
||||
"MERKLE_ROOT_MISSING",
|
||||
failed.Reason);
|
||||
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
|
||||
return failed;
|
||||
}
|
||||
|
||||
if (!string.Equals(expectedMerkleRoot, merkleRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var failed = BundleValidationResult.Failure($"merkle-root-mismatch:expected={expectedMerkleRoot}:actual={merkleRoot}");
|
||||
verificationLog.Add(failed.Reason);
|
||||
_logger.LogWarning(
|
||||
"offlinekit.import.validation failed tenant_id={tenant_id} bundle_type={bundle_type} bundle_digest={bundle_digest} reason_code={reason_code} reason_message={reason_message}",
|
||||
request.TenantId,
|
||||
request.BundleType,
|
||||
request.BundleDigest,
|
||||
"MERKLE_ROOT_MISMATCH",
|
||||
failed.Reason);
|
||||
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
|
||||
return failed;
|
||||
}
|
||||
|
||||
verificationLog.Add($"merkle:{merkleRoot}");
|
||||
|
||||
var rotationResult = _rotation.Validate(request.TrustStore.ActiveKeys, request.TrustStore.PendingKeys, request.ApproverIds);
|
||||
@@ -279,6 +312,48 @@ public sealed class ImportValidator
|
||||
request.BundlePath);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryExtractManifestMerkleRoot(string? manifestJson, out string? errorReason)
|
||||
{
|
||||
errorReason = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifestJson))
|
||||
{
|
||||
errorReason = "manifest-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(manifestJson);
|
||||
if (TryGetString(doc.RootElement, "merkleRoot", out var merkleRoot) ||
|
||||
TryGetString(doc.RootElement, "merkle_root", out merkleRoot))
|
||||
{
|
||||
return merkleRoot;
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
errorReason = $"manifest-json-invalid:{ex.GetType().Name.ToLowerInvariant()}";
|
||||
return null;
|
||||
}
|
||||
|
||||
errorReason = "merkle-root-missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryGetString(JsonElement element, string propertyName, out string? value)
|
||||
{
|
||||
if (element.TryGetProperty(propertyName, out var property) &&
|
||||
property.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
value = property.GetString();
|
||||
return !string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ImportValidationRequest(
|
||||
|
||||
@@ -32,15 +32,31 @@ public sealed class MerkleRootCalculator
|
||||
private static byte[] HashLeaf(NamedStream entry)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
using var buffer = new MemoryStream();
|
||||
entry.Stream.Seek(0, SeekOrigin.Begin);
|
||||
entry.Stream.CopyTo(buffer);
|
||||
var contentHash = sha256.ComputeHash(buffer.ToArray());
|
||||
|
||||
var contentHash = ComputeContentHash(sha256, entry.Stream);
|
||||
var leafBytes = Encoding.UTF8.GetBytes(entry.Path.ToLowerInvariant() + ":" + Convert.ToHexString(contentHash).ToLowerInvariant());
|
||||
return SHA256.HashData(leafBytes);
|
||||
}
|
||||
|
||||
private static byte[] ComputeContentHash(HashAlgorithm hasher, Stream stream)
|
||||
{
|
||||
var canSeek = stream.CanSeek;
|
||||
var originalPosition = canSeek ? stream.Position : 0;
|
||||
|
||||
if (canSeek)
|
||||
{
|
||||
stream.Seek(0, SeekOrigin.Begin);
|
||||
}
|
||||
|
||||
var hash = hasher.ComputeHash(stream);
|
||||
|
||||
if (canSeek)
|
||||
{
|
||||
stream.Seek(originalPosition, SeekOrigin.Begin);
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static IEnumerable<byte[]> Pairwise(IReadOnlyList<byte[]> nodes)
|
||||
{
|
||||
for (var i = 0; i < nodes.Count; i += 2)
|
||||
|
||||
@@ -399,7 +399,7 @@ public static class RekorOfflineReceiptVerifier
|
||||
.Select(static line => line.TrimEnd())
|
||||
.ToList();
|
||||
|
||||
// Extract signatures first (note format: "— origin base64sig", or "sig <base64>").
|
||||
// Extract signatures first (note format: em-dash prefix or "sig <base64>").
|
||||
var signatures = new List<byte[]>();
|
||||
foreach (var line in lines)
|
||||
{
|
||||
@@ -409,7 +409,7 @@ public static class RekorOfflineReceiptVerifier
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith("—", StringComparison.Ordinal) || trimmed.StartsWith("--", StringComparison.OrdinalIgnoreCase))
|
||||
if (LooksLikeDashSignature(trimmed) || trimmed.StartsWith("--", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var token = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries).LastOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(token) && TryDecodeBase64(token, out var sigBytes))
|
||||
@@ -486,7 +486,7 @@ public static class RekorOfflineReceiptVerifier
|
||||
|
||||
private static bool LooksLikeSignatureLine(string trimmedLine)
|
||||
{
|
||||
if (trimmedLine.StartsWith("—", StringComparison.Ordinal))
|
||||
if (LooksLikeDashSignature(trimmedLine))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -505,6 +505,11 @@ public static class RekorOfflineReceiptVerifier
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
private static bool LooksLikeDashSignature(string trimmedLine)
|
||||
{
|
||||
return trimmedLine.Length > 0 && trimmedLine[0] == '\u2014';
|
||||
}
|
||||
private static bool TryDecodeBase64(string token, out byte[] bytes)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -61,6 +61,28 @@ public sealed class HttpClientUsageAnalyzerTests
|
||||
Assert.DoesNotContain(diagnostics, d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DoesNotReportDiagnostic_ForTestingAssemblyNames()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.App;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var client = new HttpClient();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App.Testing");
|
||||
Assert.DoesNotContain(diagnostics, d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CodeFix_RewritesToFactoryCall()
|
||||
@@ -88,7 +110,45 @@ public sealed class HttpClientUsageAnalyzerTests
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var client = global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create(egressPolicy: /* TODO: provide IEgressPolicy instance */, request: new global::StellaOps.AirGap.Policy.EgressRequest(component: "REPLACE_COMPONENT", destination: new global::System.Uri("https://replace-with-endpoint"), intent: "REPLACE_INTENT"));
|
||||
var client = global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create(egressPolicy: default(global::StellaOps.AirGap.Policy.IEgressPolicy) /* TODO: provide IEgressPolicy instance */, request: new global::StellaOps.AirGap.Policy.EgressRequest(component: "REPLACE_COMPONENT", destination: new global::System.Uri("https://replace-with-endpoint"), intent: "REPLACE_INTENT"));
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var updated = await ApplyCodeFixAsync(source, assemblyName: "Sample.Service");
|
||||
Assert.Equal(expected.ReplaceLineEndings(), updated.ReplaceLineEndings());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CodeFix_PreservesHttpClientArguments()
|
||||
{
|
||||
const string source = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.Service;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var handler = new HttpClientHandler();
|
||||
var client = new HttpClient(handler, disposeHandler: false);
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
const string expected = """
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Sample.Service;
|
||||
|
||||
public sealed class Demo
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var handler = new HttpClientHandler();
|
||||
var client = global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create(egressPolicy: default(global::StellaOps.AirGap.Policy.IEgressPolicy) /* TODO: provide IEgressPolicy instance */, request: new global::StellaOps.AirGap.Policy.EgressRequest(component: "REPLACE_COMPONENT", destination: new global::System.Uri("https://replace-with-endpoint"), intent: "REPLACE_INTENT"), clientFactory: () => new global::System.Net.Http.HttpClient(handler, disposeHandler: false));
|
||||
}
|
||||
}
|
||||
""";
|
||||
@@ -183,6 +243,9 @@ public sealed class HttpClientUsageAnalyzerTests
|
||||
{
|
||||
public static System.Net.Http.HttpClient Create(IEgressPolicy egressPolicy, EgressRequest request)
|
||||
=> throw new System.NotImplementedException();
|
||||
|
||||
public static System.Net.Http.HttpClient Create(IEgressPolicy egressPolicy, EgressRequest request, System.Func<System.Net.Http.HttpClient> clientFactory)
|
||||
=> throw new System.NotImplementedException();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
@@ -303,7 +303,7 @@ public sealed class PolicyAnalyzerRoslynTests
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var client = global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create(egressPolicy: /* TODO: provide IEgressPolicy instance */, request: new global::StellaOps.AirGap.Policy.EgressRequest(component: "REPLACE_COMPONENT", destination: new global::System.Uri("https://replace-with-endpoint"), intent: "REPLACE_INTENT"));
|
||||
var client = global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create(egressPolicy: default(global::StellaOps.AirGap.Policy.IEgressPolicy) /* TODO: provide IEgressPolicy instance */, request: new global::StellaOps.AirGap.Policy.EgressRequest(component: "REPLACE_COMPONENT", destination: new global::System.Uri("https://replace-with-endpoint"), intent: "REPLACE_INTENT"));
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
@@ -54,8 +54,14 @@ public sealed class HttpClientUsageAnalyzer : DiagnosticAnalyzer
|
||||
return;
|
||||
}
|
||||
|
||||
var httpClientSymbol = context.Compilation.GetTypeByMetadataName(HttpClientMetadataName);
|
||||
if (httpClientSymbol is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var createdType = creation.Type;
|
||||
if (createdType is null || !string.Equals(createdType.ToDisplayString(), HttpClientMetadataName, StringComparison.Ordinal))
|
||||
if (createdType is null || !SymbolEqualityComparer.Default.Equals(createdType, httpClientSymbol))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -88,7 +94,9 @@ public sealed class HttpClientUsageAnalyzer : DiagnosticAnalyzer
|
||||
return true;
|
||||
}
|
||||
|
||||
if (assemblyName.EndsWith(".Tests", StringComparison.OrdinalIgnoreCase))
|
||||
if (assemblyName.EndsWith(".Tests", StringComparison.OrdinalIgnoreCase) ||
|
||||
assemblyName.EndsWith(".Test", StringComparison.OrdinalIgnoreCase) ||
|
||||
assemblyName.EndsWith(".Testing", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Composition;
|
||||
using System.Threading;
|
||||
@@ -59,13 +60,7 @@ public sealed class HttpClientUsageCodeFixProvider : CodeFixProvider
|
||||
|
||||
private static async Task<Document> ReplaceWithFactoryCallAsync(Document document, ObjectCreationExpressionSyntax creation, CancellationToken cancellationToken)
|
||||
{
|
||||
var replacementExpression = SyntaxFactory.ParseExpression(
|
||||
"global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create(" +
|
||||
"egressPolicy: /* TODO: provide IEgressPolicy instance */, " +
|
||||
"request: new global::StellaOps.AirGap.Policy.EgressRequest(" +
|
||||
"component: \"REPLACE_COMPONENT\", " +
|
||||
"destination: new global::System.Uri(\"https://replace-with-endpoint\"), " +
|
||||
"intent: \"REPLACE_INTENT\"))");
|
||||
var replacementExpression = BuildReplacementExpression(creation);
|
||||
|
||||
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (root is null)
|
||||
@@ -76,4 +71,55 @@ public sealed class HttpClientUsageCodeFixProvider : CodeFixProvider
|
||||
var updatedRoot = root.ReplaceNode(creation, replacementExpression.WithTriviaFrom(creation));
|
||||
return document.WithSyntaxRoot(updatedRoot);
|
||||
}
|
||||
|
||||
private static ExpressionSyntax BuildReplacementExpression(ObjectCreationExpressionSyntax creation)
|
||||
{
|
||||
var requestExpression = SyntaxFactory.ParseExpression(
|
||||
"new global::StellaOps.AirGap.Policy.EgressRequest(" +
|
||||
"component: \"REPLACE_COMPONENT\", " +
|
||||
"destination: new global::System.Uri(\"https://replace-with-endpoint\"), " +
|
||||
"intent: \"REPLACE_INTENT\")");
|
||||
|
||||
var egressPolicyExpression = SyntaxFactory.ParseExpression(
|
||||
"default(global::StellaOps.AirGap.Policy.IEgressPolicy)");
|
||||
|
||||
var arguments = new List<ArgumentSyntax>
|
||||
{
|
||||
SyntaxFactory.Argument(egressPolicyExpression)
|
||||
.WithNameColon(SyntaxFactory.NameColon("egressPolicy"))
|
||||
.WithTrailingTrivia(
|
||||
SyntaxFactory.Space,
|
||||
SyntaxFactory.Comment("/* TODO: provide IEgressPolicy instance */")),
|
||||
SyntaxFactory.Argument(requestExpression)
|
||||
.WithNameColon(SyntaxFactory.NameColon("request"))
|
||||
};
|
||||
|
||||
if (ShouldUseClientFactory(creation))
|
||||
{
|
||||
var clientFactoryLambda = SyntaxFactory.ParenthesizedLambdaExpression(
|
||||
SyntaxFactory.ParameterList(),
|
||||
CreateHttpClientExpression(creation));
|
||||
|
||||
arguments.Add(
|
||||
SyntaxFactory.Argument(clientFactoryLambda)
|
||||
.WithNameColon(SyntaxFactory.NameColon("clientFactory")));
|
||||
}
|
||||
|
||||
return SyntaxFactory.InvocationExpression(
|
||||
SyntaxFactory.ParseExpression("global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create"))
|
||||
.WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(arguments)));
|
||||
}
|
||||
|
||||
private static bool ShouldUseClientFactory(ObjectCreationExpressionSyntax creation)
|
||||
=> (creation.ArgumentList?.Arguments.Count ?? 0) > 0 || creation.Initializer is not null;
|
||||
|
||||
private static ObjectCreationExpressionSyntax CreateHttpClientExpression(ObjectCreationExpressionSyntax creation)
|
||||
{
|
||||
var httpClientType = SyntaxFactory.ParseTypeName("global::System.Net.Http.HttpClient");
|
||||
var arguments = creation.ArgumentList ?? SyntaxFactory.ArgumentList();
|
||||
|
||||
return SyntaxFactory.ObjectCreationExpression(httpClientType)
|
||||
.WithArgumentList(arguments)
|
||||
.WithInitializer(creation.Initializer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0031-M | DONE | Maintainability audit for StellaOps.AirGap.Policy.Analyzers. |
|
||||
| AUDIT-0031-T | DONE | Test coverage audit for StellaOps.AirGap.Policy.Analyzers. |
|
||||
| AUDIT-0031-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0031-A | DONE | Applied analyzer symbol match, test assembly exemptions, and code-fix preservation. |
|
||||
|
||||
@@ -26,6 +26,35 @@ public static class EgressHttpClientFactory
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="HttpClient"/> from a caller-provided factory after validating the supplied egress request.
|
||||
/// </summary>
|
||||
/// <param name="egressPolicy">The policy used to validate outbound requests.</param>
|
||||
/// <param name="request">Describes the destination and intent for the outbound call.</param>
|
||||
/// <param name="clientFactory">Factory used to supply a configured client (for example, from IHttpClientFactory).</param>
|
||||
/// <param name="configure">Optional configuration hook applied to the newly created client.</param>
|
||||
/// <returns>An <see cref="HttpClient"/> that has been pre-authorised by the policy.</returns>
|
||||
public static HttpClient Create(
|
||||
IEgressPolicy egressPolicy,
|
||||
EgressRequest request,
|
||||
Func<HttpClient> clientFactory,
|
||||
Action<HttpClient>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(egressPolicy);
|
||||
ArgumentNullException.ThrowIfNull(clientFactory);
|
||||
|
||||
egressPolicy.EnsureAllowed(request);
|
||||
|
||||
var client = clientFactory();
|
||||
if (client is null)
|
||||
{
|
||||
throw new InvalidOperationException("EgressHttpClientFactory received a null HttpClient from the factory.");
|
||||
}
|
||||
|
||||
configure?.Invoke(client);
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates and configures an <see cref="HttpClient"/> after validating the supplied egress request against the policy.
|
||||
/// </summary>
|
||||
@@ -42,4 +71,23 @@ public static class EgressHttpClientFactory
|
||||
string intent,
|
||||
Action<HttpClient>? configure = null)
|
||||
=> Create(egressPolicy, new EgressRequest(component, destination, intent), configure);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a configured <see cref="HttpClient"/> using a caller-provided factory after policy validation.
|
||||
/// </summary>
|
||||
/// <param name="egressPolicy">The policy used to validate outbound requests.</param>
|
||||
/// <param name="component">Component initiating the request.</param>
|
||||
/// <param name="destination">Destination that will be contacted.</param>
|
||||
/// <param name="intent">Intent label describing why the request is needed.</param>
|
||||
/// <param name="clientFactory">Factory used to supply a configured client.</param>
|
||||
/// <param name="configure">Optional configuration hook applied to the newly created client.</param>
|
||||
/// <returns>An <see cref="HttpClient"/> that has been pre-authorised by the policy.</returns>
|
||||
public static HttpClient Create(
|
||||
IEgressPolicy egressPolicy,
|
||||
string component,
|
||||
Uri destination,
|
||||
string intent,
|
||||
Func<HttpClient> clientFactory,
|
||||
Action<HttpClient>? configure = null)
|
||||
=> Create(egressPolicy, new EgressRequest(component, destination, intent), clientFactory, configure);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AirGap.Policy;
|
||||
|
||||
@@ -11,8 +12,9 @@ namespace StellaOps.AirGap.Policy;
|
||||
/// </summary>
|
||||
public sealed class EgressPolicy : IEgressPolicy
|
||||
{
|
||||
private readonly EgressRule[] _rules;
|
||||
private readonly EgressPolicyOptions _options;
|
||||
private readonly IDisposable? _optionsSubscription;
|
||||
private EgressRule[] _rules = Array.Empty<EgressRule>();
|
||||
private EgressPolicyOptions _options = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EgressPolicy"/> class.
|
||||
@@ -20,35 +22,56 @@ public sealed class EgressPolicy : IEgressPolicy
|
||||
/// <param name="options">Options describing how egress should be enforced.</param>
|
||||
public EgressPolicy(EgressPolicyOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_rules = options.BuildRuleSet();
|
||||
ApplyOptions(options ?? throw new ArgumentNullException(nameof(options)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EgressPolicy"/> class with reload support.
|
||||
/// </summary>
|
||||
/// <param name="optionsMonitor">Options monitor that supplies updated policy settings.</param>
|
||||
public EgressPolicy(IOptionsMonitor<EgressPolicyOptions> optionsMonitor)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(optionsMonitor);
|
||||
|
||||
ApplyOptions(optionsMonitor.CurrentValue);
|
||||
_optionsSubscription = optionsMonitor.OnChange((updated, _) => ApplyOptions(updated));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsSealed => Mode == EgressPolicyMode.Sealed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public EgressPolicyMode Mode => _options.Mode;
|
||||
public EgressPolicyMode Mode => Volatile.Read(ref _options).Mode;
|
||||
|
||||
/// <inheritdoc />
|
||||
public EgressDecision Evaluate(EgressRequest request)
|
||||
{
|
||||
if (!HasValidDestination(request))
|
||||
{
|
||||
return EgressDecision.Blocked(
|
||||
"Egress request is missing a valid destination URI.",
|
||||
BuildInvalidRequestRemediation(request));
|
||||
}
|
||||
|
||||
var options = Volatile.Read(ref _options);
|
||||
var rules = Volatile.Read(ref _rules);
|
||||
|
||||
if (!IsSealed)
|
||||
{
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
|
||||
if (_options.AllowLoopback && IsLoopback(request.Destination))
|
||||
if (options.AllowLoopback && IsLoopback(request.Destination))
|
||||
{
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
|
||||
if (_options.AllowPrivateNetworks && IsPrivateNetwork(request.Destination))
|
||||
if (options.AllowPrivateNetworks && IsPrivateNetwork(request.Destination))
|
||||
{
|
||||
return EgressDecision.Allowed;
|
||||
}
|
||||
|
||||
foreach (var rule in _rules)
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
if (rule.Allows(request))
|
||||
{
|
||||
@@ -56,8 +79,9 @@ public sealed class EgressPolicy : IEgressPolicy
|
||||
}
|
||||
}
|
||||
|
||||
var reason = $"Destination '{request.Destination.Host}' is not present in the sealed-mode allow list.";
|
||||
var remediation = BuildRemediation(request);
|
||||
var destinationLabel = request.Destination?.Host ?? "unknown-host";
|
||||
var reason = $"Destination '{destinationLabel}' is not present in the sealed-mode allow list.";
|
||||
var remediation = BuildRemediation(request, rules);
|
||||
return EgressDecision.Blocked(reason, remediation);
|
||||
}
|
||||
|
||||
@@ -95,14 +119,21 @@ public sealed class EgressPolicy : IEgressPolicy
|
||||
=> new(
|
||||
request,
|
||||
decision.Reason ?? "Egress blocked.",
|
||||
decision.Remediation ?? BuildRemediation(request),
|
||||
_options.RemediationDocumentationUrl,
|
||||
_options.SupportContact);
|
||||
decision.Remediation ?? BuildRemediation(request, Volatile.Read(ref _rules)),
|
||||
Volatile.Read(ref _options).RemediationDocumentationUrl,
|
||||
Volatile.Read(ref _options).SupportContact);
|
||||
|
||||
private string BuildRemediation(EgressRequest request)
|
||||
private string BuildRemediation(EgressRequest request, EgressRule[] rules)
|
||||
{
|
||||
var host = request.Destination.Host;
|
||||
var portSegment = request.Destination.IsDefaultPort ? string.Empty : $":{request.Destination.Port.ToString(CultureInfo.InvariantCulture)}";
|
||||
var host = request.Destination?.Host;
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
host = "unknown-host";
|
||||
}
|
||||
|
||||
var portSegment = request.Destination is { IsDefaultPort: false }
|
||||
? $":{request.Destination.Port.ToString(CultureInfo.InvariantCulture)}"
|
||||
: string.Empty;
|
||||
var transport = request.Transport.ToString().ToUpperInvariant();
|
||||
|
||||
var builder = new System.Text.StringBuilder();
|
||||
@@ -113,14 +144,14 @@ public sealed class EgressPolicy : IEgressPolicy
|
||||
.Append(transport)
|
||||
.Append(") to the airgap.egressAllowlist configuration.");
|
||||
|
||||
if (_rules.Length == 0)
|
||||
if (rules.Length == 0)
|
||||
{
|
||||
builder.Append(" No allow entries are currently configured; sealed mode blocks every external host.");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(" Current allow list sample: ");
|
||||
var limit = Math.Min(_rules.Length, 3);
|
||||
var limit = Math.Min(rules.Length, 3);
|
||||
for (var i = 0; i < limit; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
@@ -128,15 +159,15 @@ public sealed class EgressPolicy : IEgressPolicy
|
||||
builder.Append(", ");
|
||||
}
|
||||
|
||||
builder.Append(_rules[i].HostPattern);
|
||||
if (_rules[i].Port is int port)
|
||||
builder.Append(rules[i].HostPattern);
|
||||
if (rules[i].Port is int port)
|
||||
{
|
||||
builder.Append(':')
|
||||
.Append(port.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
|
||||
if (_rules.Length > limit)
|
||||
if (rules.Length > limit)
|
||||
{
|
||||
builder.Append(", ...");
|
||||
}
|
||||
@@ -147,6 +178,16 @@ public sealed class EgressPolicy : IEgressPolicy
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string BuildInvalidRequestRemediation(EgressRequest request)
|
||||
{
|
||||
var component = string.IsNullOrWhiteSpace(request.Component) ? "unknown-component" : request.Component;
|
||||
var intent = string.IsNullOrWhiteSpace(request.Intent) ? "unknown-intent" : request.Intent;
|
||||
return $"Provide an absolute destination URI for component '{component}' (intent: {intent}) before evaluating sealed-mode egress.";
|
||||
}
|
||||
|
||||
private static bool HasValidDestination(EgressRequest request)
|
||||
=> request.Destination is { IsAbsoluteUri: true };
|
||||
|
||||
private static bool IsLoopback(Uri destination)
|
||||
{
|
||||
if (string.Equals(destination.Host, "localhost", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -183,9 +224,20 @@ public sealed class EgressPolicy : IEgressPolicy
|
||||
|
||||
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
|
||||
{
|
||||
return address.IsIPv6LinkLocal || address.IsIPv6SiteLocal;
|
||||
var bytes = address.GetAddressBytes();
|
||||
var isUniqueLocal = bytes.Length > 0 && (bytes[0] & 0xFE) == 0xFC; // fc00::/7
|
||||
return address.IsIPv6LinkLocal || address.IsIPv6SiteLocal || isUniqueLocal;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ApplyOptions(EgressPolicyOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var rules = options.BuildRuleSet();
|
||||
Volatile.Write(ref _rules, rules);
|
||||
Volatile.Write(ref _options, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ public static class EgressPolicyServiceCollectionExtensions
|
||||
|
||||
services.TryAddSingleton<IEgressPolicy>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<EgressPolicyOptions>>().Value;
|
||||
return new EgressPolicy(options);
|
||||
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<EgressPolicyOptions>>();
|
||||
return new EgressPolicy(optionsMonitor);
|
||||
});
|
||||
|
||||
return services;
|
||||
@@ -122,6 +122,7 @@ public static class EgressPolicyServiceCollectionExtensions
|
||||
}
|
||||
|
||||
var rules = new List<EgressRule>();
|
||||
var seenRules = new HashSet<RuleKey>();
|
||||
foreach (var ruleSection in EnumerateAllowRuleSections(effectiveSection, primarySection, root))
|
||||
{
|
||||
var hostPattern = ruleSection["HostPattern"]
|
||||
@@ -141,7 +142,11 @@ public static class EgressPolicyServiceCollectionExtensions
|
||||
var description = ruleSection["Description"] ?? ruleSection["Notes"];
|
||||
description = string.IsNullOrWhiteSpace(description) ? null : description.Trim();
|
||||
|
||||
rules.Add(new EgressRule(hostPattern, port, transport, description));
|
||||
var ruleKey = RuleKey.Create(hostPattern, port, transport);
|
||||
if (seenRules.Add(ruleKey))
|
||||
{
|
||||
rules.Add(new EgressRule(hostPattern, port, transport, description));
|
||||
}
|
||||
}
|
||||
|
||||
options.SetAllowRules(rules);
|
||||
@@ -279,4 +284,10 @@ public static class EgressPolicyServiceCollectionExtensions
|
||||
? parsed
|
||||
: EgressTransport.Any;
|
||||
}
|
||||
|
||||
private readonly record struct RuleKey(string HostPattern, int? Port, EgressTransport Transport)
|
||||
{
|
||||
public static RuleKey Create(string hostPattern, int? port, EgressTransport transport)
|
||||
=> new(hostPattern.Trim().ToLowerInvariant(), port, transport);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0030-M | DONE | Maintainability audit for StellaOps.AirGap.Policy. |
|
||||
| AUDIT-0030-T | DONE | Test coverage audit for StellaOps.AirGap.Policy. |
|
||||
| AUDIT-0030-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0030-A | DONE | Applied reloadable policy, allowlist de-dup, request guards, and client factory overload. |
|
||||
|
||||
@@ -11,13 +11,20 @@ public class TimeStatusController : ControllerBase
|
||||
private readonly TimeStatusService _statusService;
|
||||
private readonly TimeAnchorLoader _loader;
|
||||
private readonly TrustRootProvider _trustRoots;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<TimeStatusController> _logger;
|
||||
|
||||
public TimeStatusController(TimeStatusService statusService, TimeAnchorLoader loader, TrustRootProvider trustRoots, ILogger<TimeStatusController> logger)
|
||||
public TimeStatusController(
|
||||
TimeStatusService statusService,
|
||||
TimeAnchorLoader loader,
|
||||
TrustRootProvider trustRoots,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<TimeStatusController> logger)
|
||||
{
|
||||
_statusService = statusService;
|
||||
_loader = loader;
|
||||
_trustRoots = trustRoots;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -29,7 +36,7 @@ public class TimeStatusController : ControllerBase
|
||||
return BadRequest("tenantId-required");
|
||||
}
|
||||
|
||||
var status = await _statusService.GetStatusAsync(tenantId, DateTimeOffset.UtcNow, HttpContext.RequestAborted);
|
||||
var status = await _statusService.GetStatusAsync(tenantId, _timeProvider.GetUtcNow(), HttpContext.RequestAborted);
|
||||
return Ok(TimeStatusDto.FromStatus(status));
|
||||
}
|
||||
|
||||
@@ -73,7 +80,7 @@ public class TimeStatusController : ControllerBase
|
||||
|
||||
await _statusService.SetAnchorAsync(request.TenantId, anchor, budget, HttpContext.RequestAborted);
|
||||
_logger.LogInformation("Time anchor set for tenant {Tenant} format={Format} digest={Digest} warning={Warning}s breach={Breach}s", request.TenantId, anchor.Format, anchor.TokenDigest, budget.WarningSeconds, budget.BreachSeconds);
|
||||
var status = await _statusService.GetStatusAsync(request.TenantId, DateTimeOffset.UtcNow, HttpContext.RequestAborted);
|
||||
var status = await _statusService.GetStatusAsync(request.TenantId, _timeProvider.GetUtcNow(), HttpContext.RequestAborted);
|
||||
return Ok(TimeStatusDto.FromStatus(status));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,19 @@ public sealed class TimeAnchorHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly TimeStatusService _statusService;
|
||||
private readonly IOptions<AirGapOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public TimeAnchorHealthCheck(TimeStatusService statusService, IOptions<AirGapOptions> options)
|
||||
public TimeAnchorHealthCheck(TimeStatusService statusService, IOptions<AirGapOptions> options, TimeProvider timeProvider)
|
||||
{
|
||||
_statusService = statusService;
|
||||
_options = options;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var opts = _options.Value;
|
||||
var status = await _statusService.GetStatusAsync(opts.TenantId, DateTimeOffset.UtcNow, cancellationToken);
|
||||
var status = await _statusService.GetStatusAsync(opts.TenantId, _timeProvider.GetUtcNow(), cancellationToken);
|
||||
|
||||
if (status.Anchor == TimeAnchor.Unknown)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using StellaOps.AirGap.Time.Hooks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
using StellaOps.AirGap.Time.Stores;
|
||||
@@ -13,7 +14,8 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddSingleton<StalenessCalculator>();
|
||||
builder.Services.AddSingleton<TimeTelemetry>();
|
||||
builder.Services.AddSingleton<TimeStatusService>();
|
||||
builder.Services.AddSingleton<ITimeAnchorStore, InMemoryTimeAnchorStore>();
|
||||
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
|
||||
builder.Services.TryAddSingleton<ITimeAnchorStore, InMemoryTimeAnchorStore>();
|
||||
builder.Services.AddSingleton<TimeVerificationService>();
|
||||
builder.Services.AddSingleton<TimeAnchorLoader>();
|
||||
builder.Services.AddSingleton<TimeTokenParser>();
|
||||
|
||||
@@ -23,6 +23,8 @@ public sealed class RoughtimeVerifier : ITimeTokenVerifier
|
||||
// Ed25519 constants
|
||||
private const int Ed25519SignatureLength = 64;
|
||||
private const int Ed25519PublicKeyLength = 32;
|
||||
private const int MerkleNodeLength = 32;
|
||||
private const int MerkleIndexLength = 4;
|
||||
|
||||
public TimeTokenFormat Format => TimeTokenFormat.Roughtime;
|
||||
|
||||
@@ -187,7 +189,7 @@ public sealed class RoughtimeVerifier : ITimeTokenVerifier
|
||||
}
|
||||
|
||||
// Parse SREP (signed response) for MIDP and RADI
|
||||
var srepResult = ParseSignedResponse(srepBytes, out midpointMicros, out radiusMicros);
|
||||
var srepResult = ParseSignedResponse(srepBytes, out midpointMicros, out radiusMicros, out _, out _, out _);
|
||||
if (!srepResult.IsValid)
|
||||
{
|
||||
return srepResult;
|
||||
@@ -202,10 +204,16 @@ public sealed class RoughtimeVerifier : ITimeTokenVerifier
|
||||
private static TimeAnchorValidationResult ParseSignedResponse(
|
||||
ReadOnlySpan<byte> data,
|
||||
out long midpointMicros,
|
||||
out uint radiusMicros)
|
||||
out uint radiusMicros,
|
||||
out ReadOnlySpan<byte> rootBytes,
|
||||
out ReadOnlySpan<byte> pathBytes,
|
||||
out uint index)
|
||||
{
|
||||
midpointMicros = 0;
|
||||
radiusMicros = 0;
|
||||
rootBytes = ReadOnlySpan<byte>.Empty;
|
||||
pathBytes = ReadOnlySpan<byte>.Empty;
|
||||
index = 0;
|
||||
|
||||
if (data.Length < 8)
|
||||
{
|
||||
@@ -232,6 +240,9 @@ public sealed class RoughtimeVerifier : ITimeTokenVerifier
|
||||
|
||||
var hasMidp = false;
|
||||
var hasRadi = false;
|
||||
var hasRoot = false;
|
||||
var hasPath = false;
|
||||
var hasIndex = false;
|
||||
|
||||
for (var i = 0; i < (int)numTags; i++)
|
||||
{
|
||||
@@ -273,6 +284,27 @@ public sealed class RoughtimeVerifier : ITimeTokenVerifier
|
||||
hasRadi = true;
|
||||
}
|
||||
break;
|
||||
case TagRoot:
|
||||
if (value.Length == MerkleNodeLength)
|
||||
{
|
||||
rootBytes = value;
|
||||
hasRoot = true;
|
||||
}
|
||||
break;
|
||||
case TagPath:
|
||||
if (!value.IsEmpty && value.Length % MerkleNodeLength == 0)
|
||||
{
|
||||
pathBytes = value;
|
||||
hasPath = true;
|
||||
}
|
||||
break;
|
||||
case TagIndx:
|
||||
if (value.Length == MerkleIndexLength)
|
||||
{
|
||||
index = BinaryPrimitives.ReadUInt32LittleEndian(value);
|
||||
hasIndex = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,6 +313,27 @@ public sealed class RoughtimeVerifier : ITimeTokenVerifier
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-midpoint");
|
||||
}
|
||||
|
||||
if (!hasRoot)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-root");
|
||||
}
|
||||
|
||||
if (!hasPath)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-path");
|
||||
}
|
||||
|
||||
if (!hasIndex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-missing-index");
|
||||
}
|
||||
|
||||
var pathValidation = ValidateMerklePathStructure(rootBytes, pathBytes, index);
|
||||
if (!pathValidation.IsValid)
|
||||
{
|
||||
return pathValidation;
|
||||
}
|
||||
|
||||
if (!hasRadi)
|
||||
{
|
||||
// RADI is optional, default to 1 second uncertainty
|
||||
@@ -290,6 +343,31 @@ public sealed class RoughtimeVerifier : ITimeTokenVerifier
|
||||
return TimeAnchorValidationResult.Success("roughtime-srep-parsed");
|
||||
}
|
||||
|
||||
private static TimeAnchorValidationResult ValidateMerklePathStructure(ReadOnlySpan<byte> rootBytes, ReadOnlySpan<byte> pathBytes, uint index)
|
||||
{
|
||||
if (rootBytes.Length != MerkleNodeLength)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-root-length");
|
||||
}
|
||||
|
||||
if (pathBytes.IsEmpty || pathBytes.Length % MerkleNodeLength != 0)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-path-length");
|
||||
}
|
||||
|
||||
var depth = pathBytes.Length / MerkleNodeLength;
|
||||
if (depth <= 31)
|
||||
{
|
||||
var maxIndex = 1u << depth;
|
||||
if (index >= maxIndex)
|
||||
{
|
||||
return TimeAnchorValidationResult.Failure("roughtime-invalid-index");
|
||||
}
|
||||
}
|
||||
|
||||
return TimeAnchorValidationResult.Success("roughtime-merkle-structure-valid");
|
||||
}
|
||||
|
||||
private static bool VerifyEd25519Signature(ReadOnlySpan<byte> message, ReadOnlySpan<byte> signature, byte[] publicKey)
|
||||
{
|
||||
try
|
||||
@@ -301,8 +379,6 @@ public sealed class RoughtimeVerifier : ITimeTokenVerifier
|
||||
prefixBytes.CopyTo(signedData, 0);
|
||||
message.CopyTo(signedData.AsSpan(prefixBytes.Length));
|
||||
|
||||
using var ed25519 = ECDiffieHellman.Create(ECCurve.CreateFromFriendlyName("curve25519"));
|
||||
|
||||
// Use .NET's Ed25519 verification
|
||||
// Note: .NET 10 supports Ed25519 natively via ECDsa with curve Ed25519
|
||||
return Ed25519.Verify(publicKey, signedData, signature.ToArray());
|
||||
|
||||
@@ -15,15 +15,17 @@ public sealed record StartupValidationResult(bool IsValid, string Reason, TimeSt
|
||||
public sealed class SealedStartupValidator
|
||||
{
|
||||
private readonly TimeStatusService _statusService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SealedStartupValidator(TimeStatusService statusService)
|
||||
public SealedStartupValidator(TimeStatusService statusService, TimeProvider timeProvider)
|
||||
{
|
||||
_statusService = statusService;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public async Task<StartupValidationResult> ValidateAsync(string tenantId, StalenessBudget budget, CancellationToken cancellationToken)
|
||||
{
|
||||
var status = await _statusService.GetStatusAsync(tenantId, DateTimeOffset.UtcNow, cancellationToken);
|
||||
var status = await _statusService.GetStatusAsync(tenantId, _timeProvider.GetUtcNow(), cancellationToken);
|
||||
|
||||
if (status.Anchor == TimeAnchor.Unknown)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Threading;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Stores;
|
||||
|
||||
@@ -12,14 +13,15 @@ public sealed class TimeStatusService
|
||||
private readonly ITimeAnchorStore _store;
|
||||
private readonly StalenessCalculator _calculator;
|
||||
private readonly TimeTelemetry _telemetry;
|
||||
private readonly IReadOnlyDictionary<string, StalenessBudget> _contentBudgets;
|
||||
private IReadOnlyDictionary<string, StalenessBudget> _contentBudgets;
|
||||
|
||||
public TimeStatusService(ITimeAnchorStore store, StalenessCalculator calculator, TimeTelemetry telemetry, IOptions<AirGapOptions> options)
|
||||
public TimeStatusService(ITimeAnchorStore store, StalenessCalculator calculator, TimeTelemetry telemetry, IOptionsMonitor<AirGapOptions> options)
|
||||
{
|
||||
_store = store;
|
||||
_calculator = calculator;
|
||||
_telemetry = telemetry;
|
||||
_contentBudgets = BuildContentBudgets(options.Value);
|
||||
_contentBudgets = BuildContentBudgets(options.CurrentValue);
|
||||
options.OnChange(opts => Interlocked.Exchange(ref _contentBudgets, BuildContentBudgets(opts)));
|
||||
}
|
||||
|
||||
public async Task SetAnchorAsync(string tenantId, TimeAnchor anchor, StalenessBudget budget, CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -7,25 +7,40 @@ namespace StellaOps.AirGap.Time.Services;
|
||||
public sealed class TimeTelemetry
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.AirGap.Time", "1.0.0");
|
||||
private const int MaxEntries = 1024;
|
||||
|
||||
private static readonly ConcurrentDictionary<string, Snapshot> _latest = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, Snapshot> _latest = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentQueue<string> _evictionQueue = new();
|
||||
|
||||
private static readonly ObservableGauge<long> AnchorAgeGauge = Meter.CreateObservableGauge(
|
||||
"airgap_time_anchor_age_seconds",
|
||||
() => _latest.Select(kvp => new Measurement<long>(kvp.Value.AgeSeconds, new KeyValuePair<string, object?>("tenant", kvp.Key))));
|
||||
private readonly ObservableGauge<long> _anchorAgeGauge;
|
||||
|
||||
private static readonly Counter<long> StatusCounter = Meter.CreateCounter<long>("airgap_time_anchor_status_total");
|
||||
private static readonly Counter<long> WarningCounter = Meter.CreateCounter<long>("airgap_time_anchor_warning_total");
|
||||
private static readonly Counter<long> BreachCounter = Meter.CreateCounter<long>("airgap_time_anchor_breach_total");
|
||||
|
||||
public TimeTelemetry()
|
||||
{
|
||||
_anchorAgeGauge = Meter.CreateObservableGauge(
|
||||
"airgap_time_anchor_age_seconds",
|
||||
() => _latest.Select(kvp => new Measurement<long>(kvp.Value.AgeSeconds, new KeyValuePair<string, object?>("tenant", kvp.Key))));
|
||||
}
|
||||
|
||||
public void Record(string tenantId, Models.TimeStatus status)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedTenant = tenantId.Trim();
|
||||
var snapshot = new Snapshot(status.Staleness.AgeSeconds, status.Staleness.IsWarning, status.Staleness.IsBreach);
|
||||
_latest[tenantId] = snapshot;
|
||||
_latest[normalizedTenant] = snapshot;
|
||||
_evictionQueue.Enqueue(normalizedTenant);
|
||||
TrimCache();
|
||||
|
||||
var tags = new TagList
|
||||
{
|
||||
{ "tenant", tenantId },
|
||||
{ "tenant", normalizedTenant },
|
||||
{ "is_warning", status.Staleness.IsWarning },
|
||||
{ "is_breach", status.Staleness.IsBreach }
|
||||
};
|
||||
@@ -45,7 +60,21 @@ public sealed class TimeTelemetry
|
||||
|
||||
public Snapshot? GetLatest(string tenantId)
|
||||
{
|
||||
return _latest.TryGetValue(tenantId, out var snap) ? snap : null;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedTenant = tenantId.Trim();
|
||||
return _latest.TryGetValue(normalizedTenant, out var snap) ? snap : null;
|
||||
}
|
||||
|
||||
private void TrimCache()
|
||||
{
|
||||
while (_latest.Count > MaxEntries && _evictionQueue.TryDequeue(out var candidate))
|
||||
{
|
||||
_latest.TryRemove(candidate, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record Snapshot(long AgeSeconds, bool IsWarning, bool IsBreach);
|
||||
|
||||
@@ -26,12 +26,21 @@ public sealed class TrustRootProvider
|
||||
var doc = JsonDocument.Parse(stream);
|
||||
var roots = new List<TimeTrustRoot>();
|
||||
|
||||
if (doc.RootElement.TryGetProperty("roughtime", out var roughtimeArr))
|
||||
if (doc.RootElement.TryGetProperty("roughtime", out var roughtimeArr) && roughtimeArr.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in roughtimeArr.EnumerateArray())
|
||||
{
|
||||
var name = item.GetProperty("name").GetString() ?? "unknown-roughtime";
|
||||
var pkB64 = item.GetProperty("publicKeyBase64").GetString() ?? string.Empty;
|
||||
var name = item.TryGetProperty("name", out var nameProp) && nameProp.ValueKind == JsonValueKind.String
|
||||
? nameProp.GetString() ?? "unknown-roughtime"
|
||||
: "unknown-roughtime";
|
||||
|
||||
if (!item.TryGetProperty("publicKeyBase64", out var pkProp) || pkProp.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
_logger.LogWarning("Roughtime trust root {Name} missing publicKeyBase64 field.", name);
|
||||
continue;
|
||||
}
|
||||
|
||||
var pkB64 = pkProp.GetString() ?? string.Empty;
|
||||
try
|
||||
{
|
||||
var pk = Convert.FromBase64String(pkB64);
|
||||
@@ -44,12 +53,21 @@ public sealed class TrustRootProvider
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.RootElement.TryGetProperty("rfc3161", out var rfcArr))
|
||||
if (doc.RootElement.TryGetProperty("rfc3161", out var rfcArr) && rfcArr.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in rfcArr.EnumerateArray())
|
||||
{
|
||||
var name = item.GetProperty("name").GetString() ?? "unknown-rfc3161";
|
||||
var certPem = item.GetProperty("certificatePem").GetString() ?? string.Empty;
|
||||
var name = item.TryGetProperty("name", out var nameProp) && nameProp.ValueKind == JsonValueKind.String
|
||||
? nameProp.GetString() ?? "unknown-rfc3161"
|
||||
: "unknown-rfc3161";
|
||||
|
||||
if (!item.TryGetProperty("certificatePem", out var certProp) || certProp.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
_logger.LogWarning("RFC3161 trust root {Name} missing certificatePem field.", name);
|
||||
continue;
|
||||
}
|
||||
|
||||
var certPem = certProp.GetString() ?? string.Empty;
|
||||
var normalized = certPem.Replace("-----BEGIN CERTIFICATE-----", string.Empty)
|
||||
.Replace("-----END CERTIFICATE-----", string.Empty)
|
||||
.Replace("\n", string.Empty)
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Stores;
|
||||
|
||||
public sealed class InMemoryTimeAnchorStore : ITimeAnchorStore
|
||||
{
|
||||
private readonly Dictionary<string, (TimeAnchor Anchor, StalenessBudget Budget)> _anchors = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, (TimeAnchor Anchor, StalenessBudget Budget)> _anchors = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task SetAsync(string tenantId, TimeAnchor anchor, StalenessBudget budget, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
_anchors[tenantId] = (anchor, budget);
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
_anchors[tenantId.Trim()] = (anchor, budget);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<(TimeAnchor Anchor, StalenessBudget Budget)> GetAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (_anchors.TryGetValue(tenantId, out var value))
|
||||
if (!string.IsNullOrWhiteSpace(tenantId) &&
|
||||
_anchors.TryGetValue(tenantId.Trim(), out var value))
|
||||
{
|
||||
return Task.FromResult(value);
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0034-M | DONE | Maintainability audit for StellaOps.AirGap.Time. |
|
||||
| AUDIT-0034-T | DONE | Test coverage audit for StellaOps.AirGap.Time. |
|
||||
| AUDIT-0034-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0034-A | DONE | Applied time provider, options reload, and trust-root/roughtime hardening. |
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Persistence.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.AirGap.Persistence.EfCore.Context;
|
||||
|
||||
@@ -8,14 +11,25 @@ namespace StellaOps.AirGap.Persistence.EfCore.Context;
|
||||
/// </summary>
|
||||
public class AirGapDbContext : DbContext
|
||||
{
|
||||
private readonly string _schemaName;
|
||||
|
||||
public AirGapDbContext(DbContextOptions<AirGapDbContext> options)
|
||||
: this(options, null)
|
||||
{
|
||||
}
|
||||
|
||||
public AirGapDbContext(DbContextOptions<AirGapDbContext> options, IOptions<PostgresOptions>? postgresOptions)
|
||||
: base(options)
|
||||
{
|
||||
var schema = postgresOptions?.Value.SchemaName;
|
||||
_schemaName = string.IsNullOrWhiteSpace(schema)
|
||||
? AirGapDataSource.DefaultSchemaName
|
||||
: schema;
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema("airgap");
|
||||
modelBuilder.HasDefaultSchema(_schemaName);
|
||||
base.OnModelCreating(modelBuilder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AirGap.Controller.Stores;
|
||||
using StellaOps.AirGap.Importer.Versioning;
|
||||
using StellaOps.AirGap.Persistence.Postgres;
|
||||
using StellaOps.AirGap.Persistence.Postgres.Repositories;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.AirGap.Persistence.Extensions;
|
||||
|
||||
@@ -23,6 +26,7 @@ public static class AirGapPersistenceExtensions
|
||||
{
|
||||
services.Configure<PostgresOptions>(sectionName, configuration.GetSection(sectionName));
|
||||
services.AddSingleton<AirGapDataSource>();
|
||||
services.AddHostedService(sp => CreateMigrationHost(sp));
|
||||
services.AddScoped<IAirGapStateStore, PostgresAirGapStateStore>();
|
||||
services.AddScoped<IBundleVersionStore, PostgresBundleVersionStore>();
|
||||
|
||||
@@ -38,9 +42,46 @@ public static class AirGapPersistenceExtensions
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
services.AddSingleton<AirGapDataSource>();
|
||||
services.AddHostedService(sp => CreateMigrationHost(sp));
|
||||
services.AddScoped<IAirGapStateStore, PostgresAirGapStateStore>();
|
||||
services.AddScoped<IBundleVersionStore, PostgresBundleVersionStore>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static IHostedService CreateMigrationHost(IServiceProvider serviceProvider)
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<PostgresOptions>>().Value;
|
||||
var schemaName = string.IsNullOrWhiteSpace(options.SchemaName)
|
||||
? AirGapDataSource.DefaultSchemaName
|
||||
: options.SchemaName!;
|
||||
|
||||
var connectionString = BuildMigrationConnectionString(options, schemaName);
|
||||
var logger = serviceProvider.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("Migration.AirGap.Persistence");
|
||||
var lifetime = serviceProvider.GetRequiredService<IHostApplicationLifetime>();
|
||||
|
||||
return new AirGapStartupMigrationHost(
|
||||
connectionString,
|
||||
schemaName,
|
||||
"AirGap.Persistence",
|
||||
typeof(AirGapDataSource).Assembly,
|
||||
logger,
|
||||
lifetime);
|
||||
}
|
||||
|
||||
private static string BuildMigrationConnectionString(PostgresOptions options, string schemaName)
|
||||
{
|
||||
var builder = new NpgsqlConnectionStringBuilder(options.ConnectionString)
|
||||
{
|
||||
CommandTimeout = options.CommandTimeoutSeconds
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(schemaName))
|
||||
{
|
||||
builder.SearchPath = $"{schemaName}, public";
|
||||
}
|
||||
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
|
||||
namespace StellaOps.AirGap.Persistence.Postgres;
|
||||
|
||||
internal sealed class AirGapStartupMigrationHost : StartupMigrationHost
|
||||
{
|
||||
public AirGapStartupMigrationHost(
|
||||
string connectionString,
|
||||
string schemaName,
|
||||
string moduleName,
|
||||
Assembly migrationsAssembly,
|
||||
ILogger logger,
|
||||
IHostApplicationLifetime lifetime,
|
||||
StartupMigrationOptions? options = null)
|
||||
: base(connectionString, schemaName, moduleName, migrationsAssembly, logger, lifetime, options)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -26,25 +26,47 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
var tenantKey = NormalizeTenantId(tenantId);
|
||||
var stateTable = GetQualifiedTableName("state");
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "reader", cancellationToken).ConfigureAwait(false);
|
||||
var sql = $$"""
|
||||
SELECT id, tenant_id, sealed, policy_hash, time_anchor, last_transition_at,
|
||||
staleness_budget, drift_baseline_seconds, content_budgets
|
||||
FROM state
|
||||
WHERE LOWER(tenant_id) = LOWER(@tenant_id);
|
||||
FROM {{stateTable}}
|
||||
WHERE tenant_id = @tenant_id;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "tenant_id", tenantKey);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
await using (var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Return default state for tenant if not found
|
||||
return new AirGapState { TenantId = tenantId };
|
||||
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return Map(reader);
|
||||
}
|
||||
}
|
||||
|
||||
return Map(reader);
|
||||
// Fallback for legacy rows stored without normalization.
|
||||
await using var fallbackCommand = CreateCommand($$"""
|
||||
SELECT id, tenant_id, sealed, policy_hash, time_anchor, last_transition_at,
|
||||
staleness_budget, drift_baseline_seconds, content_budgets
|
||||
FROM {{stateTable}}
|
||||
WHERE LOWER(tenant_id) = LOWER(@tenant_id)
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT 1;
|
||||
""", connection);
|
||||
AddParameter(fallbackCommand, "tenant_id", tenantId);
|
||||
|
||||
await using var fallbackReader = await fallbackCommand.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (await fallbackReader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return Map(fallbackReader);
|
||||
}
|
||||
|
||||
// Return default state for tenant if not found
|
||||
return new AirGapState { TenantId = tenantId };
|
||||
}
|
||||
|
||||
public async Task SetAsync(AirGapState state, CancellationToken cancellationToken = default)
|
||||
@@ -52,9 +74,12 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "writer", cancellationToken).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
INSERT INTO state (
|
||||
var tenantKey = NormalizeTenantId(state.TenantId);
|
||||
var stateTable = GetQualifiedTableName("state");
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "writer", cancellationToken).ConfigureAwait(false);
|
||||
var sql = $$"""
|
||||
INSERT INTO {{stateTable}} (
|
||||
id, tenant_id, sealed, policy_hash, time_anchor, last_transition_at,
|
||||
staleness_budget, drift_baseline_seconds, content_budgets
|
||||
)
|
||||
@@ -76,7 +101,7 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "id", state.Id);
|
||||
AddParameter(command, "tenant_id", state.TenantId);
|
||||
AddParameter(command, "tenant_id", tenantKey);
|
||||
AddParameter(command, "sealed", state.Sealed);
|
||||
AddParameter(command, "policy_hash", (object?)state.PolicyHash ?? DBNull.Value);
|
||||
AddJsonbParameter(command, "time_anchor", SerializeTimeAnchor(state.TimeAnchor));
|
||||
@@ -88,7 +113,7 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static AirGapState Map(NpgsqlDataReader reader)
|
||||
private AirGapState Map(NpgsqlDataReader reader)
|
||||
{
|
||||
var id = reader.GetString(0);
|
||||
var tenantId = reader.GetString(1);
|
||||
@@ -133,7 +158,7 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
|
||||
return JsonSerializer.Serialize(obj);
|
||||
}
|
||||
|
||||
private static TimeAnchor DeserializeTimeAnchor(string json)
|
||||
private TimeAnchor DeserializeTimeAnchor(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -152,8 +177,9 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
|
||||
|
||||
return new TimeAnchor(anchorTime, source, format, signatureFingerprint, tokenDigest);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "AirGap state: Failed to parse time anchor JSON; using default.");
|
||||
return TimeAnchor.Unknown;
|
||||
}
|
||||
}
|
||||
@@ -168,7 +194,7 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
|
||||
return JsonSerializer.Serialize(obj);
|
||||
}
|
||||
|
||||
private static StalenessBudget DeserializeStalenessBudget(string json)
|
||||
private StalenessBudget DeserializeStalenessBudget(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -180,8 +206,9 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
|
||||
|
||||
return new StalenessBudget(warningSeconds, breachSeconds);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "AirGap state: Failed to parse staleness budget JSON; using default.");
|
||||
return StalenessBudget.Default;
|
||||
}
|
||||
}
|
||||
@@ -193,14 +220,20 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
|
||||
return "{}";
|
||||
}
|
||||
|
||||
var dict = budgets.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => new { warningSeconds = kv.Value.WarningSeconds, breachSeconds = kv.Value.BreachSeconds });
|
||||
var dict = new SortedDictionary<string, object>(StringComparer.Ordinal);
|
||||
foreach (var kv in budgets.OrderBy(kv => kv.Key, StringComparer.Ordinal))
|
||||
{
|
||||
dict[kv.Key] = new
|
||||
{
|
||||
warningSeconds = kv.Value.WarningSeconds,
|
||||
breachSeconds = kv.Value.BreachSeconds
|
||||
};
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(dict);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, StalenessBudget> DeserializeContentBudgets(string? json)
|
||||
private IReadOnlyDictionary<string, StalenessBudget> DeserializeContentBudgets(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
@@ -221,8 +254,9 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
|
||||
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "AirGap state: Failed to parse content budgets JSON; using defaults.");
|
||||
return new Dictionary<string, StalenessBudget>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -245,29 +279,12 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
|
||||
}
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
var schemaName = DataSource.SchemaName ?? "public";
|
||||
var quotedSchema = QuoteIdentifier(schemaName);
|
||||
var sql = $$"""
|
||||
CREATE SCHEMA IF NOT EXISTS {{quotedSchema}};
|
||||
CREATE TABLE IF NOT EXISTS {{quotedSchema}}.state (
|
||||
id TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL PRIMARY KEY,
|
||||
sealed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
policy_hash TEXT,
|
||||
time_anchor JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
last_transition_at TIMESTAMPTZ NOT NULL DEFAULT '0001-01-01T00:00:00Z',
|
||||
staleness_budget JSONB NOT NULL DEFAULT '{"warningSeconds":3600,"breachSeconds":7200}'::jsonb,
|
||||
drift_baseline_seconds BIGINT NOT NULL DEFAULT 0,
|
||||
content_budgets JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_airgap_state_tenant ON {{quotedSchema}}.state(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_airgap_state_sealed ON {{quotedSchema}}.state(sealed) WHERE sealed = TRUE;
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
var schemaName = GetSchemaName();
|
||||
if (!await TableExistsAsync(connection, schemaName, "state", cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"AirGap state table missing in schema '{schemaName}'. Run AirGap migrations before using the store.");
|
||||
}
|
||||
_initialized = true;
|
||||
}
|
||||
finally
|
||||
@@ -276,6 +293,46 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> TableExistsAsync(
|
||||
NpgsqlConnection connection,
|
||||
string schemaName,
|
||||
string tableName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = @schema AND table_name = @table
|
||||
);
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "schema", schemaName);
|
||||
AddParameter(command, "table", tableName);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result is true;
|
||||
}
|
||||
|
||||
private string GetQualifiedTableName(string tableName)
|
||||
{
|
||||
var schema = GetSchemaName();
|
||||
return $"{QuoteIdentifier(schema)}.{QuoteIdentifier(tableName)}";
|
||||
}
|
||||
|
||||
private string GetSchemaName()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(DataSource.SchemaName))
|
||||
{
|
||||
return DataSource.SchemaName!;
|
||||
}
|
||||
|
||||
return AirGapDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
private static string NormalizeTenantId(string tenantId) => tenantId.Trim().ToLowerInvariant();
|
||||
|
||||
private static string QuoteIdentifier(string identifier)
|
||||
{
|
||||
var escaped = identifier.Replace("\"", "\"\"", StringComparison.Ordinal);
|
||||
|
||||
@@ -31,11 +31,12 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
|
||||
var tenantKey = NormalizeKey(tenantId);
|
||||
var bundleTypeKey = NormalizeKey(bundleType);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", ct).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
var versionTable = GetQualifiedTableName("bundle_versions");
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "reader", ct).ConfigureAwait(false);
|
||||
var sql = $$"""
|
||||
SELECT tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
|
||||
bundle_created_at, bundle_digest, activated_at, was_force_activated, force_activate_reason
|
||||
FROM bundle_versions
|
||||
FROM {{versionTable}}
|
||||
WHERE tenant_id = @tenant_id AND bundle_type = @bundle_type;
|
||||
""";
|
||||
|
||||
@@ -55,11 +56,13 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
|
||||
var tenantKey = NormalizeKey(record.TenantId);
|
||||
var bundleTypeKey = NormalizeKey(record.BundleType);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "writer", ct).ConfigureAwait(false);
|
||||
var versionTable = GetQualifiedTableName("bundle_versions");
|
||||
var historyTable = GetQualifiedTableName("bundle_version_history");
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "writer", ct).ConfigureAwait(false);
|
||||
await using var tx = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
const string closeHistorySql = """
|
||||
UPDATE bundle_version_history
|
||||
var closeHistorySql = $$"""
|
||||
UPDATE {{historyTable}}
|
||||
SET deactivated_at = @activated_at
|
||||
WHERE tenant_id = @tenant_id AND bundle_type = @bundle_type AND deactivated_at IS NULL;
|
||||
""";
|
||||
@@ -73,8 +76,8 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
|
||||
await closeCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
const string historySql = """
|
||||
INSERT INTO bundle_version_history (
|
||||
var historySql = $$"""
|
||||
INSERT INTO {{historyTable}} (
|
||||
tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
|
||||
bundle_created_at, bundle_digest, activated_at, deactivated_at, was_force_activated, force_activate_reason
|
||||
)
|
||||
@@ -102,8 +105,8 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
|
||||
await historyCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
const string upsertSql = """
|
||||
INSERT INTO bundle_versions (
|
||||
var upsertSql = $$"""
|
||||
INSERT INTO {{versionTable}} (
|
||||
tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
|
||||
bundle_created_at, bundle_digest, activated_at, was_force_activated, force_activate_reason
|
||||
)
|
||||
@@ -165,13 +168,14 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
|
||||
var tenantKey = NormalizeKey(tenantId);
|
||||
var bundleTypeKey = NormalizeKey(bundleType);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", ct).ConfigureAwait(false);
|
||||
const string sql = """
|
||||
var historyTable = GetQualifiedTableName("bundle_version_history");
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "reader", ct).ConfigureAwait(false);
|
||||
var sql = $$"""
|
||||
SELECT tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
|
||||
bundle_created_at, bundle_digest, activated_at, was_force_activated, force_activate_reason
|
||||
FROM bundle_version_history
|
||||
FROM {{historyTable}}
|
||||
WHERE tenant_id = @tenant_id AND bundle_type = @bundle_type
|
||||
ORDER BY activated_at DESC
|
||||
ORDER BY activated_at DESC, id DESC
|
||||
LIMIT @limit;
|
||||
""";
|
||||
|
||||
@@ -236,56 +240,15 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
|
||||
}
|
||||
|
||||
await using var connection = await DataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
|
||||
var schemaName = DataSource.SchemaName ?? "public";
|
||||
var quotedSchema = QuoteIdentifier(schemaName);
|
||||
var sql = $$"""
|
||||
CREATE SCHEMA IF NOT EXISTS {{quotedSchema}};
|
||||
var schemaName = GetSchemaName();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS {{quotedSchema}}.bundle_versions (
|
||||
tenant_id TEXT NOT NULL,
|
||||
bundle_type TEXT NOT NULL,
|
||||
version_string TEXT NOT NULL,
|
||||
major INTEGER NOT NULL,
|
||||
minor INTEGER NOT NULL,
|
||||
patch INTEGER NOT NULL,
|
||||
prerelease TEXT,
|
||||
bundle_created_at TIMESTAMPTZ NOT NULL,
|
||||
bundle_digest TEXT NOT NULL,
|
||||
activated_at TIMESTAMPTZ NOT NULL,
|
||||
was_force_activated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
force_activate_reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (tenant_id, bundle_type)
|
||||
);
|
||||
if (!await TableExistsAsync(connection, schemaName, "bundle_versions", ct).ConfigureAwait(false) ||
|
||||
!await TableExistsAsync(connection, schemaName, "bundle_version_history", ct).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"AirGap bundle version tables missing in schema '{schemaName}'. Run AirGap migrations before using the store.");
|
||||
}
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_airgap_bundle_versions_tenant
|
||||
ON {{quotedSchema}}.bundle_versions(tenant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS {{quotedSchema}}.bundle_version_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
bundle_type TEXT NOT NULL,
|
||||
version_string TEXT NOT NULL,
|
||||
major INTEGER NOT NULL,
|
||||
minor INTEGER NOT NULL,
|
||||
patch INTEGER NOT NULL,
|
||||
prerelease TEXT,
|
||||
bundle_created_at TIMESTAMPTZ NOT NULL,
|
||||
bundle_digest TEXT NOT NULL,
|
||||
activated_at TIMESTAMPTZ NOT NULL,
|
||||
deactivated_at TIMESTAMPTZ,
|
||||
was_force_activated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
force_activate_reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_airgap_bundle_version_history_tenant
|
||||
ON {{quotedSchema}}.bundle_version_history(tenant_id, bundle_type, activated_at DESC);
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
_initialized = true;
|
||||
}
|
||||
finally
|
||||
@@ -294,6 +257,44 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> TableExistsAsync(
|
||||
NpgsqlConnection connection,
|
||||
string schemaName,
|
||||
string tableName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = @schema AND table_name = @table
|
||||
);
|
||||
""";
|
||||
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
AddParameter(command, "schema", schemaName);
|
||||
AddParameter(command, "table", tableName);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result is true;
|
||||
}
|
||||
|
||||
private string GetQualifiedTableName(string tableName)
|
||||
{
|
||||
var schema = GetSchemaName();
|
||||
return $"{QuoteIdentifier(schema)}.{QuoteIdentifier(tableName)}";
|
||||
}
|
||||
|
||||
private string GetSchemaName()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(DataSource.SchemaName))
|
||||
{
|
||||
return DataSource.SchemaName!;
|
||||
}
|
||||
|
||||
return AirGapDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
private static string NormalizeKey(string value) => value.Trim().ToLowerInvariant();
|
||||
|
||||
private static string QuoteIdentifier(string identifier)
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0028-M | DONE | Maintainability audit for StellaOps.AirGap.Persistence. |
|
||||
| AUDIT-0028-T | DONE | Test coverage audit for StellaOps.AirGap.Persistence. |
|
||||
| AUDIT-0028-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0028-A | DONE | Applied schema + determinism fixes and migration host wiring. |
|
||||
|
||||
@@ -11,7 +11,7 @@ public class SealedStartupValidatorTests
|
||||
[Fact]
|
||||
public async Task FailsWhenAnchorMissing()
|
||||
{
|
||||
var validator = Build(out var statusService);
|
||||
var validator = Build(out var statusService, DateTimeOffset.UnixEpoch);
|
||||
var result = await validator.ValidateAsync("t1", StalenessBudget.Default, default);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal("time-anchor-missing", result.Reason);
|
||||
@@ -21,10 +21,10 @@ public class SealedStartupValidatorTests
|
||||
[Fact]
|
||||
public async Task FailsWhenBreach()
|
||||
{
|
||||
var validator = Build(out var statusService);
|
||||
var now = DateTimeOffset.UnixEpoch.AddSeconds(25);
|
||||
var validator = Build(out var statusService, now);
|
||||
var anchor = new TimeAnchor(DateTimeOffset.UnixEpoch, "src", "fmt", "fp", "digest");
|
||||
await statusService.SetAnchorAsync("t1", anchor, new StalenessBudget(10, 20));
|
||||
var now = DateTimeOffset.UnixEpoch.AddSeconds(25);
|
||||
var status = await statusService.GetStatusAsync("t1", now);
|
||||
var result = status.Staleness.IsBreach;
|
||||
Assert.True(result);
|
||||
@@ -37,8 +37,8 @@ public class SealedStartupValidatorTests
|
||||
[Fact]
|
||||
public async Task SucceedsWhenFresh()
|
||||
{
|
||||
var validator = Build(out var statusService);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = DateTimeOffset.UnixEpoch.AddSeconds(5);
|
||||
var validator = Build(out var statusService, now);
|
||||
var anchor = new TimeAnchor(now, "src", "fmt", "fp", "digest");
|
||||
await statusService.SetAnchorAsync("t1", anchor, new StalenessBudget(10, 20));
|
||||
var validation = await validator.ValidateAsync("t1", new StalenessBudget(10, 20), default);
|
||||
@@ -49,8 +49,9 @@ public class SealedStartupValidatorTests
|
||||
[Fact]
|
||||
public async Task FailsOnBudgetMismatch()
|
||||
{
|
||||
var validator = Build(out var statusService);
|
||||
var anchor = new TimeAnchor(DateTimeOffset.UtcNow, "src", "fmt", "fp", "digest");
|
||||
var now = DateTimeOffset.UnixEpoch.AddSeconds(5);
|
||||
var validator = Build(out var statusService, now);
|
||||
var anchor = new TimeAnchor(now, "src", "fmt", "fp", "digest");
|
||||
await statusService.SetAnchorAsync("t1", anchor, new StalenessBudget(10, 20));
|
||||
|
||||
var validation = await validator.ValidateAsync("t1", new StalenessBudget(5, 15), default);
|
||||
@@ -59,10 +60,19 @@ public class SealedStartupValidatorTests
|
||||
Assert.Equal("time-anchor-budget-mismatch", validation.Reason);
|
||||
}
|
||||
|
||||
private static SealedStartupValidator Build(out TimeStatusService statusService)
|
||||
private static SealedStartupValidator Build(out TimeStatusService statusService, DateTimeOffset now)
|
||||
{
|
||||
var store = new InMemoryTimeAnchorStore();
|
||||
statusService = new TimeStatusService(store, new StalenessCalculator(), new TimeTelemetry(), Microsoft.Extensions.Options.Options.Create(new AirGapOptions()));
|
||||
return new SealedStartupValidator(statusService);
|
||||
statusService = new TimeStatusService(store, new StalenessCalculator(), new TimeTelemetry(), new TestOptionsMonitor<AirGapOptions>(new AirGapOptions()));
|
||||
return new SealedStartupValidator(statusService, new FixedTimeProvider(now));
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
internal sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
private T _value;
|
||||
|
||||
public TestOptionsMonitor(T value)
|
||||
{
|
||||
_value = value;
|
||||
}
|
||||
|
||||
public T CurrentValue => _value;
|
||||
|
||||
public T Get(string? name) => _value;
|
||||
|
||||
public IDisposable OnChange(Action<T, string> listener) => new NoopDisposable();
|
||||
|
||||
public void SetValue(T value) => _value = value;
|
||||
|
||||
private sealed class NoopDisposable : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ public class TimeAnchorPolicyServiceTests
|
||||
Staleness = new StalenessOptions { WarningSeconds = 3600, BreachSeconds = 7200 },
|
||||
ContentBudgets = new Dictionary<string, StalenessOptions>()
|
||||
};
|
||||
_statusService = new TimeStatusService(_store, _calculator, _telemetry, Options.Create(_airGapOptions));
|
||||
_statusService = new TimeStatusService(_store, _calculator, _telemetry, new TestOptionsMonitor<AirGapOptions>(_airGapOptions));
|
||||
}
|
||||
|
||||
private TimeAnchorPolicyService CreateService(TimeAnchorPolicyOptions? options = null)
|
||||
|
||||
@@ -42,7 +42,7 @@ public class TimeStatusServiceTests
|
||||
private static TimeStatusService Build(out TimeTelemetry telemetry)
|
||||
{
|
||||
telemetry = new TimeTelemetry();
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new AirGapOptions());
|
||||
var options = new TestOptionsMonitor<AirGapOptions>(new AirGapOptions());
|
||||
return new TimeStatusService(new InMemoryTimeAnchorStore(), new StalenessCalculator(), telemetry, options);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user