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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ public sealed class AocForbiddenFieldAnalyzer : DiagnosticAnalyzer
|
||||
public const string DiagnosticIdForbiddenField = "AOC0001";
|
||||
public const string DiagnosticIdDerivedField = "AOC0002";
|
||||
public const string DiagnosticIdUnguardedWrite = "AOC0003";
|
||||
private const string IngestionAllOption = "stellaops_aoc_ingestion";
|
||||
private const string IngestionAssemblyOption = "stellaops_aoc_ingestion_assemblies";
|
||||
private const string IngestionNamespaceOption = "stellaops_aoc_ingestion_namespace_prefixes";
|
||||
|
||||
private static readonly ImmutableHashSet<string> ForbiddenTopLevel = ImmutableHashSet.Create(
|
||||
StringComparer.OrdinalIgnoreCase,
|
||||
@@ -72,21 +75,25 @@ public sealed class AocForbiddenFieldAnalyzer : DiagnosticAnalyzer
|
||||
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
|
||||
context.EnableConcurrentExecution();
|
||||
|
||||
context.RegisterOperationAction(AnalyzeAssignment, OperationKind.SimpleAssignment);
|
||||
context.RegisterOperationAction(AnalyzePropertyReference, OperationKind.PropertyReference);
|
||||
context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation);
|
||||
context.RegisterSyntaxNodeAction(AnalyzeObjectInitializer, SyntaxKind.ObjectInitializerExpression);
|
||||
context.RegisterSyntaxNodeAction(AnalyzeAnonymousObjectMember, SyntaxKind.AnonymousObjectMemberDeclarator);
|
||||
context.RegisterCompilationStartAction(startContext =>
|
||||
{
|
||||
var symbols = new AnalyzerTypeSymbols(startContext.Compilation);
|
||||
startContext.RegisterOperationAction(ctx => AnalyzeAssignment(ctx, symbols), OperationKind.SimpleAssignment);
|
||||
startContext.RegisterOperationAction(ctx => AnalyzePropertyReference(ctx, symbols), OperationKind.PropertyReference);
|
||||
startContext.RegisterOperationAction(ctx => AnalyzeInvocation(ctx, symbols), OperationKind.Invocation);
|
||||
startContext.RegisterSyntaxNodeAction(ctx => AnalyzeObjectInitializer(ctx, symbols), SyntaxKind.ObjectInitializerExpression);
|
||||
startContext.RegisterSyntaxNodeAction(ctx => AnalyzeAnonymousObjectMember(ctx, symbols), SyntaxKind.AnonymousObjectMemberDeclarator);
|
||||
});
|
||||
}
|
||||
|
||||
private static void AnalyzeAssignment(OperationAnalysisContext context)
|
||||
private static void AnalyzeAssignment(OperationAnalysisContext context, AnalyzerTypeSymbols symbols)
|
||||
{
|
||||
if (context.Operation is not ISimpleAssignmentOperation assignment)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsIngestionContext(context.ContainingSymbol))
|
||||
if (!IsIngestionContext(context.ContainingSymbol, context.Options, symbols))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -100,14 +107,14 @@ public sealed class AocForbiddenFieldAnalyzer : DiagnosticAnalyzer
|
||||
CheckForbiddenField(context, targetName!, assignment.Syntax.GetLocation());
|
||||
}
|
||||
|
||||
private static void AnalyzePropertyReference(OperationAnalysisContext context)
|
||||
private static void AnalyzePropertyReference(OperationAnalysisContext context, AnalyzerTypeSymbols symbols)
|
||||
{
|
||||
if (context.Operation is not IPropertyReferenceOperation propertyRef)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsIngestionContext(context.ContainingSymbol))
|
||||
if (!IsIngestionContext(context.ContainingSymbol, context.Options, symbols))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -121,14 +128,14 @@ public sealed class AocForbiddenFieldAnalyzer : DiagnosticAnalyzer
|
||||
CheckForbiddenField(context, propertyName, propertyRef.Syntax.GetLocation());
|
||||
}
|
||||
|
||||
private static void AnalyzeInvocation(OperationAnalysisContext context)
|
||||
private static void AnalyzeInvocation(OperationAnalysisContext context, AnalyzerTypeSymbols symbols)
|
||||
{
|
||||
if (context.Operation is not IInvocationOperation invocation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsIngestionContext(context.ContainingSymbol))
|
||||
if (!IsIngestionContext(context.ContainingSymbol, context.Options, symbols))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -144,9 +151,9 @@ public sealed class AocForbiddenFieldAnalyzer : DiagnosticAnalyzer
|
||||
}
|
||||
|
||||
// Check for unguarded database write operations
|
||||
if (IsDatabaseWriteOperation(method))
|
||||
if (IsDatabaseWriteOperation(method, symbols))
|
||||
{
|
||||
if (!IsWithinAocGuardScope(invocation))
|
||||
if (!IsWithinAocGuardScope(invocation, symbols))
|
||||
{
|
||||
var diagnostic = Diagnostic.Create(
|
||||
UnguardedWriteRule,
|
||||
@@ -157,11 +164,11 @@ public sealed class AocForbiddenFieldAnalyzer : DiagnosticAnalyzer
|
||||
}
|
||||
}
|
||||
|
||||
private static void AnalyzeObjectInitializer(SyntaxNodeAnalysisContext context)
|
||||
private static void AnalyzeObjectInitializer(SyntaxNodeAnalysisContext context, AnalyzerTypeSymbols symbols)
|
||||
{
|
||||
var initializer = (InitializerExpressionSyntax)context.Node;
|
||||
|
||||
if (!IsIngestionContext(context.ContainingSymbol))
|
||||
if (!IsIngestionContext(context.ContainingSymbol, context.Options, symbols))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -185,11 +192,11 @@ public sealed class AocForbiddenFieldAnalyzer : DiagnosticAnalyzer
|
||||
}
|
||||
}
|
||||
|
||||
private static void AnalyzeAnonymousObjectMember(SyntaxNodeAnalysisContext context)
|
||||
private static void AnalyzeAnonymousObjectMember(SyntaxNodeAnalysisContext context, AnalyzerTypeSymbols symbols)
|
||||
{
|
||||
var member = (AnonymousObjectMemberDeclaratorSyntax)context.Node;
|
||||
|
||||
if (!IsIngestionContext(context.ContainingSymbol))
|
||||
if (!IsIngestionContext(context.ContainingSymbol, context.Options, symbols))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -265,7 +272,7 @@ public sealed class AocForbiddenFieldAnalyzer : DiagnosticAnalyzer
|
||||
return parent is ISimpleAssignmentOperation assignment && assignment.Target == propertyRef;
|
||||
}
|
||||
|
||||
private static bool IsIngestionContext(ISymbol? containingSymbol)
|
||||
private static bool IsIngestionContext(ISymbol? containingSymbol, AnalyzerOptions options, AnalyzerTypeSymbols symbols)
|
||||
{
|
||||
if (containingSymbol is null)
|
||||
{
|
||||
@@ -280,11 +287,30 @@ public sealed class AocForbiddenFieldAnalyzer : DiagnosticAnalyzer
|
||||
|
||||
// Allow analyzer assemblies and tests
|
||||
if (assemblyName!.EndsWith(".Analyzers", StringComparison.Ordinal) ||
|
||||
assemblyName.EndsWith(".Tests", StringComparison.Ordinal))
|
||||
assemblyName.EndsWith(".Tests", StringComparison.Ordinal) ||
|
||||
assemblyName.EndsWith(".Test", StringComparison.Ordinal) ||
|
||||
assemblyName.EndsWith(".Testing", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (HasIngestionMarker(containingSymbol) ||
|
||||
HasIngestionMarker(containingSymbol.ContainingType) ||
|
||||
HasIngestionMarker(containingSymbol.ContainingAssembly))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IsConfigIngestionAssembly(assemblyName, options))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IsConfigIngestionNamespace(containingSymbol.ContainingNamespace?.ToDisplayString(), options))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for ingestion-related assemblies/namespaces
|
||||
if (assemblyName.Contains(".Connector.", StringComparison.Ordinal) ||
|
||||
assemblyName.Contains(".Ingestion", StringComparison.Ordinal) ||
|
||||
@@ -307,6 +333,112 @@ public sealed class AocForbiddenFieldAnalyzer : DiagnosticAnalyzer
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasIngestionMarker(ISymbol? symbol)
|
||||
{
|
||||
if (symbol is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var attribute in symbol.GetAttributes())
|
||||
{
|
||||
var attributeName = attribute.AttributeClass?.Name;
|
||||
if (string.Equals(attributeName, "AocIngestionAttribute", StringComparison.Ordinal) ||
|
||||
string.Equals(attributeName, "AocIngestionContextAttribute", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsConfigIngestionAssembly(string assemblyName, AnalyzerOptions options)
|
||||
{
|
||||
if (IsConfigIngestionEnabledForAll(options))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryGetOption(options, IngestionAssemblyOption, out var assemblies) ||
|
||||
TryGetOption(options, "build_property.StellaOpsAocIngestionAssemblies", out assemblies))
|
||||
{
|
||||
foreach (var name in SplitOptionValue(assemblies))
|
||||
{
|
||||
if (string.Equals(name, "*", StringComparison.Ordinal) ||
|
||||
string.Equals(name, "all", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(name, assemblyName, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsConfigIngestionNamespace(string? ns, AnalyzerOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ns))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var namespaceValue = ns!;
|
||||
if (TryGetOption(options, IngestionNamespaceOption, out var namespaces) ||
|
||||
TryGetOption(options, "build_property.StellaOpsAocIngestionNamespacePrefixes", out namespaces))
|
||||
{
|
||||
foreach (var prefix in SplitOptionValue(namespaces))
|
||||
{
|
||||
if (namespaceValue.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsConfigIngestionEnabledForAll(AnalyzerOptions options)
|
||||
{
|
||||
if (TryGetOption(options, IngestionAllOption, out var value) ||
|
||||
TryGetOption(options, "build_property.StellaOpsAocIngestion", out value))
|
||||
{
|
||||
if (string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(value, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryGetOption(AnalyzerOptions options, string key, out string value)
|
||||
{
|
||||
value = string.Empty;
|
||||
if (!options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue(key, out var raw) || string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = raw.Trim();
|
||||
return value.Length > 0;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitOptionValue(string value)
|
||||
{
|
||||
foreach (var entry in value.Split(new[] { ';', ',', ' ' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var trimmed = entry.Trim();
|
||||
if (!string.IsNullOrEmpty(trimmed))
|
||||
{
|
||||
yield return trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsDictionarySetOperation(IMethodSymbol method)
|
||||
{
|
||||
var name = method.Name;
|
||||
@@ -331,35 +463,54 @@ public sealed class AocForbiddenFieldAnalyzer : DiagnosticAnalyzer
|
||||
typeName.Contains("JsonElement", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool IsDatabaseWriteOperation(IMethodSymbol method)
|
||||
private static bool IsDatabaseWriteOperation(IMethodSymbol method, AnalyzerTypeSymbols symbols)
|
||||
{
|
||||
var name = method.Name;
|
||||
var writeOps = new[]
|
||||
if ((string.Equals(name, "SaveChanges", StringComparison.Ordinal) ||
|
||||
string.Equals(name, "SaveChangesAsync", StringComparison.Ordinal)) &&
|
||||
IsOnTypeOrDerived(method.ContainingType, symbols.DbContext))
|
||||
{
|
||||
"InsertOne", "InsertOneAsync",
|
||||
"InsertMany", "InsertManyAsync",
|
||||
"UpdateOne", "UpdateOneAsync",
|
||||
"UpdateMany", "UpdateManyAsync",
|
||||
"ReplaceOne", "ReplaceOneAsync",
|
||||
"BulkWrite", "BulkWriteAsync",
|
||||
"ExecuteNonQuery", "ExecuteNonQueryAsync",
|
||||
"SaveChanges", "SaveChangesAsync",
|
||||
"Add", "AddAsync",
|
||||
"Update", "UpdateAsync"
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var op in writeOps)
|
||||
if ((string.Equals(name, "Add", StringComparison.Ordinal) ||
|
||||
string.Equals(name, "AddAsync", StringComparison.Ordinal) ||
|
||||
string.Equals(name, "Update", StringComparison.Ordinal) ||
|
||||
string.Equals(name, "UpdateAsync", StringComparison.Ordinal)) &&
|
||||
IsOnTypeOrDerived(method.ContainingType, symbols.DbSet))
|
||||
{
|
||||
if (string.Equals(name, op, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((string.Equals(name, "ExecuteNonQuery", StringComparison.Ordinal) ||
|
||||
string.Equals(name, "ExecuteNonQueryAsync", StringComparison.Ordinal)) &&
|
||||
(IsOnTypeOrDerived(method.ContainingType, symbols.DbCommand) ||
|
||||
IsOnTypeOrDerived(method.ContainingType, symbols.NpgsqlCommand)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((string.Equals(name, "InsertOne", StringComparison.Ordinal) ||
|
||||
string.Equals(name, "InsertOneAsync", StringComparison.Ordinal) ||
|
||||
string.Equals(name, "InsertMany", StringComparison.Ordinal) ||
|
||||
string.Equals(name, "InsertManyAsync", StringComparison.Ordinal) ||
|
||||
string.Equals(name, "UpdateOne", StringComparison.Ordinal) ||
|
||||
string.Equals(name, "UpdateOneAsync", StringComparison.Ordinal) ||
|
||||
string.Equals(name, "UpdateMany", StringComparison.Ordinal) ||
|
||||
string.Equals(name, "UpdateManyAsync", StringComparison.Ordinal) ||
|
||||
string.Equals(name, "ReplaceOne", StringComparison.Ordinal) ||
|
||||
string.Equals(name, "ReplaceOneAsync", StringComparison.Ordinal) ||
|
||||
string.Equals(name, "BulkWrite", StringComparison.Ordinal) ||
|
||||
string.Equals(name, "BulkWriteAsync", StringComparison.Ordinal)) &&
|
||||
IsOnTypeOrDerived(method.ContainingType, symbols.MongoCollection))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsWithinAocGuardScope(IInvocationOperation invocation)
|
||||
private static bool IsWithinAocGuardScope(IInvocationOperation invocation, AnalyzerTypeSymbols symbols)
|
||||
{
|
||||
// Walk up the operation tree to find if we're within an AOC guard validation scope
|
||||
var current = invocation.Parent;
|
||||
@@ -371,8 +522,8 @@ public sealed class AocForbiddenFieldAnalyzer : DiagnosticAnalyzer
|
||||
if (current is IInvocationOperation parentInvocation)
|
||||
{
|
||||
var method = parentInvocation.TargetMethod;
|
||||
if (method.Name == "Validate" &&
|
||||
method.ContainingType?.Name.Contains("AocGuard", StringComparison.Ordinal) == true)
|
||||
if ((method.Name == "Validate" || method.Name == "ValidateOrThrow") &&
|
||||
IsAocGuardInvocation(method, symbols))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -387,7 +538,7 @@ public sealed class AocForbiddenFieldAnalyzer : DiagnosticAnalyzer
|
||||
{
|
||||
foreach (var param in containingMethod.Parameters)
|
||||
{
|
||||
if (param.Type.Name.Contains("AocGuard", StringComparison.Ordinal))
|
||||
if (IsAocGuardType(param.Type, symbols))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -401,4 +552,80 @@ public sealed class AocForbiddenFieldAnalyzer : DiagnosticAnalyzer
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsOnTypeOrDerived(INamedTypeSymbol? type, INamedTypeSymbol? expected)
|
||||
{
|
||||
if (type is null || expected is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var current = type; current is not null; current = current.BaseType)
|
||||
{
|
||||
if (SymbolEqualityComparer.Default.Equals(current, expected))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var iface in type.AllInterfaces)
|
||||
{
|
||||
if (SymbolEqualityComparer.Default.Equals(iface, expected))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsAocGuardType(ITypeSymbol? type, AnalyzerTypeSymbols symbols)
|
||||
{
|
||||
if (type is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (symbols.AocGuard is not null && IsOnTypeOrDerived(type as INamedTypeSymbol, symbols.AocGuard))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return type.Name.Contains("AocGuard", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool IsAocGuardInvocation(IMethodSymbol method, AnalyzerTypeSymbols symbols)
|
||||
{
|
||||
if (IsAocGuardType(method.ContainingType, symbols))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (method.IsExtensionMethod && method.Parameters.Length > 0)
|
||||
{
|
||||
return IsAocGuardType(method.Parameters[0].Type, symbols);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private sealed class AnalyzerTypeSymbols
|
||||
{
|
||||
public AnalyzerTypeSymbols(Compilation compilation)
|
||||
{
|
||||
AocGuard = compilation.GetTypeByMetadataName("StellaOps.Aoc.IAocGuard");
|
||||
DbContext = compilation.GetTypeByMetadataName("Microsoft.EntityFrameworkCore.DbContext");
|
||||
DbSet = compilation.GetTypeByMetadataName("Microsoft.EntityFrameworkCore.DbSet`1");
|
||||
DbCommand = compilation.GetTypeByMetadataName("System.Data.Common.DbCommand");
|
||||
NpgsqlCommand = compilation.GetTypeByMetadataName("Npgsql.NpgsqlCommand");
|
||||
MongoCollection = compilation.GetTypeByMetadataName("MongoDB.Driver.IMongoCollection`1");
|
||||
}
|
||||
|
||||
public INamedTypeSymbol? AocGuard { get; }
|
||||
public INamedTypeSymbol? DbContext { get; }
|
||||
public INamedTypeSymbol? DbSet { get; }
|
||||
public INamedTypeSymbol? DbCommand { get; }
|
||||
public INamedTypeSymbol? NpgsqlCommand { get; }
|
||||
public INamedTypeSymbol? MongoCollection { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0037-M | DONE | Maintainability audit for StellaOps.Aoc.Analyzers. |
|
||||
| AUDIT-0037-T | DONE | Test coverage audit for StellaOps.Aoc.Analyzers. |
|
||||
| AUDIT-0037-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0037-A | DONE | Applied ingestion markers, tighter DB detection, and guard-scope coverage. |
|
||||
|
||||
@@ -5,9 +5,11 @@ using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Aoc;
|
||||
using StellaOps.Aoc.AspNetCore.Results;
|
||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
|
||||
namespace StellaOps.Aoc.AspNetCore.Routing;
|
||||
|
||||
@@ -34,37 +36,57 @@ public sealed class AocGuardEndpointFilter<TRequest> : IEndpointFilter
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
if (TryGetArgument(context, out var request))
|
||||
if (!TryGetArgument(context, out var request))
|
||||
{
|
||||
var payloads = _payloadSelector(request);
|
||||
if (payloads is not null)
|
||||
var logger = context.HttpContext.RequestServices.GetService<ILogger<AocGuardEndpointFilter<TRequest>>>();
|
||||
logger?.LogWarning("AOC guard filter did not find request argument of type {RequestType}.", typeof(TRequest).FullName);
|
||||
return HttpResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "AOC guard payload missing",
|
||||
detail: $"Request payload of type {typeof(TRequest).Name} was not found.");
|
||||
}
|
||||
|
||||
IEnumerable<object?> payloads;
|
||||
try
|
||||
{
|
||||
payloads = _payloadSelector(request) ?? Array.Empty<object?>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var logger = context.HttpContext.RequestServices.GetService<ILogger<AocGuardEndpointFilter<TRequest>>>();
|
||||
logger?.LogError(ex, "AOC guard payload selector failed for {RequestType}.", typeof(TRequest).FullName);
|
||||
return HttpResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "AOC guard payload selector failed",
|
||||
detail: "Request payload could not be extracted for validation.");
|
||||
}
|
||||
|
||||
var guard = context.HttpContext.RequestServices.GetRequiredService<IAocGuard>();
|
||||
var options = ResolveOptions(context.HttpContext.RequestServices);
|
||||
|
||||
foreach (var payload in payloads)
|
||||
{
|
||||
if (payload is null)
|
||||
{
|
||||
var guard = context.HttpContext.RequestServices.GetRequiredService<IAocGuard>();
|
||||
var options = ResolveOptions(context.HttpContext.RequestServices);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var payload in payloads)
|
||||
{
|
||||
if (payload is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonElement element = payload switch
|
||||
{
|
||||
JsonElement jsonElement => jsonElement,
|
||||
JsonDocument jsonDocument => jsonDocument.RootElement,
|
||||
_ => JsonSerializer.SerializeToElement(payload, _serializerOptions)
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
guard.ValidateOrThrow(element, options);
|
||||
}
|
||||
catch (AocGuardException exception)
|
||||
{
|
||||
return AocHttpResults.Problem(context.HttpContext, exception);
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
ValidatePayload(payload, guard, options);
|
||||
}
|
||||
catch (AocGuardException exception)
|
||||
{
|
||||
return AocHttpResults.Problem(context.HttpContext, exception);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var logger = context.HttpContext.RequestServices.GetService<ILogger<AocGuardEndpointFilter<TRequest>>>();
|
||||
logger?.LogError(ex, "AOC guard payload validation failed for {RequestType}.", typeof(TRequest).FullName);
|
||||
return HttpResults.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "AOC guard payload invalid",
|
||||
detail: "Request payload could not be serialized for validation.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,4 +118,25 @@ public sealed class AocGuardEndpointFilter<TRequest> : IEndpointFilter
|
||||
argument = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ValidatePayload(object payload, IAocGuard guard, AocGuardOptions options)
|
||||
{
|
||||
if (payload is JsonElement jsonElement)
|
||||
{
|
||||
guard.ValidateOrThrow(jsonElement, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload is JsonDocument jsonDocument)
|
||||
{
|
||||
using (jsonDocument)
|
||||
{
|
||||
guard.ValidateOrThrow(jsonDocument.RootElement, options);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var element = JsonSerializer.SerializeToElement(payload, _serializerOptions);
|
||||
guard.ValidateOrThrow(element, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0039-M | DONE | Maintainability audit for StellaOps.Aoc.AspNetCore. |
|
||||
| AUDIT-0039-T | DONE | Test coverage audit for StellaOps.Aoc.AspNetCore. |
|
||||
| AUDIT-0039-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0039-A | DONE | Hardened guard filter error handling and added tests. |
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Aoc;
|
||||
@@ -20,9 +21,16 @@ public sealed record AocError(
|
||||
}
|
||||
|
||||
var violations = result.Violations;
|
||||
var code = violations.IsDefaultOrEmpty ? "ERR_AOC_000" : violations[0].ErrorCode;
|
||||
var orderedViolations = violations.IsDefaultOrEmpty
|
||||
? violations
|
||||
: violations
|
||||
.OrderBy(v => v.ErrorCode, StringComparer.Ordinal)
|
||||
.ThenBy(v => v.Path, StringComparer.Ordinal)
|
||||
.ThenBy(v => v.Message, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
var code = orderedViolations.IsDefaultOrEmpty ? "ERR_AOC_000" : orderedViolations[0].ErrorCode;
|
||||
var resolvedMessage = message ?? $"AOC guard rejected the payload with {code}.";
|
||||
return new(code, resolvedMessage, violations);
|
||||
return new(code, resolvedMessage, orderedViolations);
|
||||
}
|
||||
|
||||
public static AocError FromException(AocGuardException exception, string? message = null)
|
||||
|
||||
@@ -45,6 +45,12 @@ public sealed record AocGuardOptions
|
||||
/// </summary>
|
||||
public bool RequireSignatureMetadata { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional allowlist of signature formats. When empty, any format is accepted.
|
||||
/// </summary>
|
||||
public ImmutableHashSet<string> AllowedSignatureFormats { get; init; } =
|
||||
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// When true, tenant must be a non-empty string.
|
||||
/// </summary>
|
||||
|
||||
@@ -26,9 +26,9 @@ public static class AocViolationCodeExtensions
|
||||
AocViolationCode.SignatureInvalid => "ERR_AOC_005",
|
||||
AocViolationCode.DerivedFindingDetected => "ERR_AOC_006",
|
||||
AocViolationCode.UnknownField => "ERR_AOC_007",
|
||||
AocViolationCode.MissingRequiredField => "ERR_AOC_004",
|
||||
AocViolationCode.InvalidTenant => "ERR_AOC_004",
|
||||
AocViolationCode.InvalidSignatureMetadata => "ERR_AOC_005",
|
||||
AocViolationCode.MissingRequiredField => "ERR_AOC_008",
|
||||
AocViolationCode.InvalidTenant => "ERR_AOC_009",
|
||||
AocViolationCode.InvalidSignatureMetadata => "ERR_AOC_010",
|
||||
_ => "ERR_AOC_000",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Aoc;
|
||||
@@ -15,13 +16,12 @@ public sealed class AocWriteGuard : IAocGuard
|
||||
{
|
||||
options ??= AocGuardOptions.Default;
|
||||
var violations = ImmutableArray.CreateBuilder<AocViolation>();
|
||||
var presentTopLevel = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var allowedTopLevelFields = options.AllowedTopLevelFields ?? AocGuardOptions.Default.AllowedTopLevelFields;
|
||||
var requiredTopLevelFields = options.RequiredTopLevelFields ?? AocGuardOptions.Default.RequiredTopLevelFields;
|
||||
var allowedTopLevelFields = (options.AllowedTopLevelFields ?? AocGuardOptions.Default.AllowedTopLevelFields)
|
||||
.Union(requiredTopLevelFields);
|
||||
|
||||
foreach (var property in document.EnumerateObject())
|
||||
{
|
||||
presentTopLevel.Add(property.Name);
|
||||
|
||||
if (AocForbiddenKeys.IsForbiddenTopLevel(property.Name))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.ForbiddenField, $"/{property.Name}", $"Field '{property.Name}' is forbidden in AOC documents."));
|
||||
@@ -40,20 +40,27 @@ public sealed class AocWriteGuard : IAocGuard
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var required in options.RequiredTopLevelFields)
|
||||
foreach (var required in requiredTopLevelFields.OrderBy(name => name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (options.RequireTenant && string.Equals(required, "tenant", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!document.TryGetProperty(required, out var element) || element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, $"/{required}", $"Required field '{required}' is missing."));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.RequireTenant && string.Equals(required, "tenant", StringComparison.OrdinalIgnoreCase))
|
||||
if (options.RequireTenant)
|
||||
{
|
||||
if (!document.TryGetProperty("tenant", out var tenantElement) ||
|
||||
tenantElement.ValueKind != JsonValueKind.String ||
|
||||
string.IsNullOrWhiteSpace(tenantElement.GetString()))
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(element.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.InvalidTenant, "/tenant", "Tenant must be a non-empty string."));
|
||||
}
|
||||
violations.Add(AocViolation.Create(AocViolationCode.InvalidTenant, "/tenant", "Tenant must be a non-empty string."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +80,7 @@ public sealed class AocWriteGuard : IAocGuard
|
||||
}
|
||||
else if (options.RequireSignatureMetadata)
|
||||
{
|
||||
ValidateSignature(signature, violations);
|
||||
ValidateSignature(signature, violations, options);
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -101,7 +108,7 @@ public sealed class AocWriteGuard : IAocGuard
|
||||
return AocGuardResult.FromViolations(violations);
|
||||
}
|
||||
|
||||
private static void ValidateSignature(JsonElement signature, ImmutableArray<AocViolation>.Builder violations)
|
||||
private static void ValidateSignature(JsonElement signature, ImmutableArray<AocViolation>.Builder violations, AocGuardOptions options)
|
||||
{
|
||||
if (!signature.TryGetProperty("present", out var presentElement) || presentElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
|
||||
{
|
||||
@@ -113,22 +120,74 @@ public sealed class AocWriteGuard : IAocGuard
|
||||
|
||||
if (!signaturePresent)
|
||||
{
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!signature.TryGetProperty("format", out var formatElement) || formatElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(formatElement.GetString()))
|
||||
if (!signature.TryGetProperty("format", out var formatElement) || formatElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(formatElement.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/format", "Signature format is required when signature is present."));
|
||||
}
|
||||
else
|
||||
{
|
||||
var format = formatElement.GetString()!.Trim();
|
||||
if (options.AllowedSignatureFormats.Count > 0 &&
|
||||
!options.AllowedSignatureFormats.Contains(format))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/format", "Signature format is required when signature is present."));
|
||||
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/format", $"Signature format '{format}' is not permitted."));
|
||||
}
|
||||
}
|
||||
|
||||
if (!signature.TryGetProperty("sig", out var sigElement) || sigElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(sigElement.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.SignatureInvalid, "/upstream/signature/sig", "Signature payload is required when signature is present."));
|
||||
}
|
||||
else if (!IsBase64Payload(sigElement.GetString()!))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/sig", "Signature payload must be base64 or base64url encoded."));
|
||||
}
|
||||
|
||||
if (!signature.TryGetProperty("key_id", out var keyIdElement) || keyIdElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(keyIdElement.GetString()))
|
||||
{
|
||||
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/key_id", "Signature key identifier is required when signature is present."));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsBase64Payload(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TryDecodeBase64(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalized = value.Replace('-', '+').Replace('_', '/');
|
||||
switch (normalized.Length % 4)
|
||||
{
|
||||
case 2:
|
||||
normalized += "==";
|
||||
break;
|
||||
case 3:
|
||||
normalized += "=";
|
||||
break;
|
||||
}
|
||||
|
||||
return TryDecodeBase64(normalized);
|
||||
}
|
||||
|
||||
private static bool TryDecodeBase64(string value)
|
||||
{
|
||||
try
|
||||
{
|
||||
Convert.FromBase64String(value);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0036-M | DONE | Maintainability audit for StellaOps.Aoc. |
|
||||
| AUDIT-0036-T | DONE | Test coverage audit for StellaOps.Aoc. |
|
||||
| AUDIT-0036-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0036-A | DONE | Applied error code fixes, deterministic ordering, and guard validation hardening. |
|
||||
|
||||
@@ -256,7 +256,7 @@ public sealed class AocForbiddenFieldAnalyzerTests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DoesNotReportDiagnostic_ForIngestionNamespaceButNotConnector()
|
||||
public async Task ReportsDiagnostic_ForIngestionNamespace()
|
||||
{
|
||||
const string source = """
|
||||
namespace StellaOps.Concelier.Ingestion;
|
||||
@@ -279,6 +279,166 @@ public sealed class AocForbiddenFieldAnalyzerTests
|
||||
Assert.Contains(diagnostics, d => d.Id == AocForbiddenFieldAnalyzer.DiagnosticIdForbiddenField);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Theory]
|
||||
[InlineData("StellaOps.Concelier.Connector.Sample.Test")]
|
||||
[InlineData("StellaOps.Concelier.Connector.Sample.Testing")]
|
||||
public async Task DoesNotReportDiagnostic_ForTestAssemblySuffixes(string assemblyName)
|
||||
{
|
||||
const string source = """
|
||||
namespace StellaOps.Concelier.Connector.Sample;
|
||||
|
||||
public sealed class AdvisoryModel
|
||||
{
|
||||
public string? severity { get; set; }
|
||||
}
|
||||
|
||||
public sealed class IngesterTests
|
||||
{
|
||||
public void TestProcess()
|
||||
{
|
||||
var advisory = new AdvisoryModel { severity = "high" };
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, assemblyName);
|
||||
Assert.DoesNotContain(diagnostics, d => d.Id == AocForbiddenFieldAnalyzer.DiagnosticIdForbiddenField);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReportsDiagnostic_ForIngestionAttribute()
|
||||
{
|
||||
const string source = """
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Aoc
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Assembly)]
|
||||
public sealed class AocIngestionAttribute : Attribute { }
|
||||
}
|
||||
|
||||
namespace StellaOps.Internal.Processing;
|
||||
|
||||
public sealed class AdvisoryModel
|
||||
{
|
||||
public string? severity { get; set; }
|
||||
}
|
||||
|
||||
[StellaOps.Aoc.AocIngestion]
|
||||
public sealed class Processor
|
||||
{
|
||||
public void Process(AdvisoryModel advisory)
|
||||
{
|
||||
advisory.severity = "high";
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, "StellaOps.Internal.Processing");
|
||||
Assert.Contains(diagnostics, d => d.Id == AocForbiddenFieldAnalyzer.DiagnosticIdForbiddenField);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReportsDiagnostic_ForDbContextSaveChangesWithoutGuard()
|
||||
{
|
||||
const string source = """
|
||||
namespace Microsoft.EntityFrameworkCore
|
||||
{
|
||||
public class DbContext
|
||||
{
|
||||
public int SaveChanges() => 0;
|
||||
}
|
||||
}
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Sample;
|
||||
|
||||
public sealed class TestDbContext : Microsoft.EntityFrameworkCore.DbContext
|
||||
{
|
||||
}
|
||||
|
||||
public sealed class Ingester
|
||||
{
|
||||
public void Process()
|
||||
{
|
||||
var db = new TestDbContext();
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, "StellaOps.Concelier.Connector.Sample");
|
||||
Assert.Contains(diagnostics, d => d.Id == AocForbiddenFieldAnalyzer.DiagnosticIdUnguardedWrite);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DoesNotReportDiagnostic_ForNonDbAddMethod()
|
||||
{
|
||||
const string source = """
|
||||
namespace StellaOps.Concelier.Connector.Sample;
|
||||
|
||||
public sealed class CustomRepo
|
||||
{
|
||||
public void Add(object value) { }
|
||||
}
|
||||
|
||||
public sealed class Ingester
|
||||
{
|
||||
public void Process(CustomRepo repo)
|
||||
{
|
||||
repo.Add(new object());
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, "StellaOps.Concelier.Connector.Sample");
|
||||
Assert.DoesNotContain(diagnostics, d => d.Id == AocForbiddenFieldAnalyzer.DiagnosticIdUnguardedWrite);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DoesNotReportDiagnostic_WhenGuardParameterPresent()
|
||||
{
|
||||
const string source = """
|
||||
namespace StellaOps.Aoc
|
||||
{
|
||||
public interface IAocGuard
|
||||
{
|
||||
void Validate(object doc);
|
||||
}
|
||||
}
|
||||
|
||||
namespace Microsoft.EntityFrameworkCore
|
||||
{
|
||||
public class DbContext
|
||||
{
|
||||
public int SaveChanges() => 0;
|
||||
}
|
||||
}
|
||||
|
||||
namespace StellaOps.Concelier.Connector.Sample;
|
||||
|
||||
public sealed class TestDbContext : Microsoft.EntityFrameworkCore.DbContext
|
||||
{
|
||||
}
|
||||
|
||||
public sealed class Ingester
|
||||
{
|
||||
public void Process(StellaOps.Aoc.IAocGuard guard)
|
||||
{
|
||||
var db = new TestDbContext();
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var diagnostics = await AnalyzeAsync(source, "StellaOps.Concelier.Connector.Sample");
|
||||
Assert.DoesNotContain(diagnostics, d => d.Id == AocForbiddenFieldAnalyzer.DiagnosticIdUnguardedWrite);
|
||||
}
|
||||
|
||||
private static async Task<ImmutableArray<Diagnostic>> AnalyzeAsync(string source, string assemblyName)
|
||||
{
|
||||
var compilation = CSharpCompilation.Create(
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Aoc;
|
||||
using StellaOps.Aoc.AspNetCore.Routing;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Aoc.AspNetCore.Tests;
|
||||
|
||||
public sealed class AocGuardEndpointFilterTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReturnsProblem_WhenRequestMissing()
|
||||
{
|
||||
var httpContext = BuildHttpContext(new TestAocGuard());
|
||||
var filter = new AocGuardEndpointFilter<GuardPayload>(_ => Array.Empty<object?>(), null, null);
|
||||
var context = new TestEndpointFilterInvocationContext(httpContext, Array.Empty<object?>());
|
||||
|
||||
var result = await filter.InvokeAsync(context, _ => new ValueTask<object?>(TypedResults.Ok()));
|
||||
|
||||
var status = await ExecuteAsync(result, httpContext);
|
||||
Assert.Equal(StatusCodes.Status400BadRequest, status);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReturnsProblem_WhenPayloadSelectorThrows()
|
||||
{
|
||||
var httpContext = BuildHttpContext(new TestAocGuard());
|
||||
var filter = new AocGuardEndpointFilter<GuardPayload>(_ => throw new InvalidOperationException("boom"), null, null);
|
||||
var context = new TestEndpointFilterInvocationContext(httpContext, new object?[] { new GuardPayload(new JsonElement()) });
|
||||
|
||||
var result = await filter.InvokeAsync(context, _ => new ValueTask<object?>(TypedResults.Ok()));
|
||||
|
||||
var status = await ExecuteAsync(result, httpContext);
|
||||
Assert.Equal(StatusCodes.Status400BadRequest, status);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReturnsProblem_WhenSerializationFails()
|
||||
{
|
||||
var httpContext = BuildHttpContext(new TestAocGuard());
|
||||
var filter = new AocGuardEndpointFilter<GuardPayload>(_ => new object?[] { new SelfReferencingPayload() }, null, null);
|
||||
var context = new TestEndpointFilterInvocationContext(httpContext, new object?[] { new GuardPayload(new JsonElement()) });
|
||||
|
||||
var result = await filter.InvokeAsync(context, _ => new ValueTask<object?>(TypedResults.Ok()));
|
||||
|
||||
var status = await ExecuteAsync(result, httpContext);
|
||||
Assert.Equal(StatusCodes.Status400BadRequest, status);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ValidatesJsonDocumentPayloads()
|
||||
{
|
||||
var guard = new TestAocGuard();
|
||||
var httpContext = BuildHttpContext(guard);
|
||||
var filter = new AocGuardEndpointFilter<GuardPayload>(_ =>
|
||||
{
|
||||
using var doc = JsonDocument.Parse("""{"tenant":"default","source":{},"upstream":{"content_hash":"sha256:abc","signature":{"present":false}},"content":{"raw":{}},"linkset":{}}""");
|
||||
return new object?[] { JsonDocument.Parse(doc.RootElement.GetRawText()) };
|
||||
}, null, null);
|
||||
var context = new TestEndpointFilterInvocationContext(httpContext, new object?[] { new GuardPayload(new JsonElement()) });
|
||||
|
||||
var result = await filter.InvokeAsync(context, _ => new ValueTask<object?>(TypedResults.Ok()));
|
||||
|
||||
var status = await ExecuteAsync(result, httpContext);
|
||||
Assert.Equal(StatusCodes.Status200OK, status);
|
||||
Assert.True(guard.WasValidated);
|
||||
}
|
||||
|
||||
private static DefaultHttpContext BuildHttpContext(IAocGuard guard)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton(guard);
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
return new DefaultHttpContext { RequestServices = provider, Response = { Body = new MemoryStream() } };
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteAsync(object? result, HttpContext context)
|
||||
{
|
||||
if (result is IResult httpResult)
|
||||
{
|
||||
await httpResult.ExecuteAsync(context);
|
||||
}
|
||||
|
||||
return context.Response.StatusCode;
|
||||
}
|
||||
|
||||
private sealed record GuardPayload(JsonElement Payload);
|
||||
|
||||
private sealed class SelfReferencingPayload
|
||||
{
|
||||
public SelfReferencingPayload? Self { get; set; }
|
||||
|
||||
public SelfReferencingPayload()
|
||||
{
|
||||
Self = this;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestAocGuard : IAocGuard
|
||||
{
|
||||
public bool WasValidated { get; private set; }
|
||||
|
||||
public AocGuardResult Validate(JsonElement document, AocGuardOptions? options = null)
|
||||
{
|
||||
WasValidated = true;
|
||||
return AocGuardResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestEndpointFilterInvocationContext : EndpointFilterInvocationContext
|
||||
{
|
||||
private readonly HttpContext _httpContext;
|
||||
private readonly IList<object?> _arguments;
|
||||
|
||||
public TestEndpointFilterInvocationContext(HttpContext httpContext, IList<object?> arguments)
|
||||
{
|
||||
_httpContext = httpContext;
|
||||
_arguments = arguments;
|
||||
}
|
||||
|
||||
public override HttpContext HttpContext => _httpContext;
|
||||
|
||||
public override IList<object?> Arguments => _arguments;
|
||||
|
||||
public override T GetArgument<T>(int index) => (T)_arguments[index]!;
|
||||
}
|
||||
}
|
||||
@@ -40,17 +40,17 @@ public sealed class AocHttpResultsTests
|
||||
var root = document.RootElement;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(StatusCodes.Status422UnprocessableEntity, context.Response.StatusCode);
|
||||
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
|
||||
Assert.Equal("Aggregation-Only Contract violation", root.GetProperty("title").GetString());
|
||||
Assert.Equal("ERR_AOC_004", root.GetProperty("code").GetString());
|
||||
Assert.Equal("ERR_AOC_001", root.GetProperty("code").GetString());
|
||||
|
||||
var violationsJson = root.GetProperty("violations");
|
||||
Assert.Equal(2, violationsJson.GetArrayLength());
|
||||
Assert.Equal("ERR_AOC_004", violationsJson[0].GetProperty("code").GetString());
|
||||
Assert.Equal("/upstream", violationsJson[0].GetProperty("path").GetString());
|
||||
Assert.Equal("ERR_AOC_001", violationsJson[0].GetProperty("code").GetString());
|
||||
Assert.Equal("/severity", violationsJson[0].GetProperty("path").GetString());
|
||||
|
||||
var errorJson = root.GetProperty("error");
|
||||
Assert.Equal("ERR_AOC_004", errorJson.GetProperty("code").GetString());
|
||||
Assert.Equal("ERR_AOC_001", errorJson.GetProperty("code").GetString());
|
||||
Assert.Equal(2, errorJson.GetProperty("violations").GetArrayLength());
|
||||
Assert.False(string.IsNullOrWhiteSpace(errorJson.GetProperty("message").GetString()));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ public sealed class AocErrorTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void FromResult_UsesFirstViolationCode()
|
||||
public void FromResult_UsesDeterministicViolationCode()
|
||||
{
|
||||
var violations = ImmutableArray.Create(
|
||||
AocViolation.Create(AocViolationCode.MissingProvenance, "/upstream", "Missing"),
|
||||
@@ -18,8 +18,8 @@ public sealed class AocErrorTests
|
||||
|
||||
var error = AocError.FromResult(result);
|
||||
|
||||
Assert.Equal("ERR_AOC_004", error.Code);
|
||||
Assert.Equal(violations, error.Violations);
|
||||
Assert.Equal("ERR_AOC_001", error.Code);
|
||||
Assert.Equal("ERR_AOC_001", error.Violations[0].ErrorCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
|
||||
@@ -90,7 +90,7 @@ public sealed class AocWriteGuardTests
|
||||
var result = Guard.Validate(document.RootElement);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Violations, v => v.ErrorCode == "ERR_AOC_004" && v.Path == "/tenant");
|
||||
Assert.Contains(result.Violations, v => v.ErrorCode == "ERR_AOC_009" && v.Path == "/tenant");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using StellaOps.Attestation;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Attestation.Tests;
|
||||
|
||||
public sealed class DsseEnvelopeExtensionsTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void FromBase64_RoundTripsPayloadAndSignature()
|
||||
{
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("{}");
|
||||
var payloadBase64 = Convert.ToBase64String(payloadBytes);
|
||||
var signatureBase64 = Convert.ToBase64String(Convert.FromHexString("deadbeef"));
|
||||
|
||||
var envelope = DsseEnvelopeExtensions.FromBase64(
|
||||
"example/type",
|
||||
payloadBase64,
|
||||
new (string? KeyId, string SignatureBase64)[] { (KeyId: "key-1", SignatureBase64: signatureBase64) });
|
||||
|
||||
Assert.Equal(payloadBase64, envelope.GetPayloadBase64());
|
||||
Assert.Single(envelope.Signatures);
|
||||
Assert.Equal("key-1", envelope.Signatures[0].KeyId);
|
||||
Assert.Equal(signatureBase64, envelope.Signatures[0].Signature);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void FromBase64_ThrowsOnInvalidSignatureBase64()
|
||||
{
|
||||
var payloadBytes = Encoding.UTF8.GetBytes("{}");
|
||||
var payloadBase64 = Convert.ToBase64String(payloadBytes);
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
DsseEnvelopeExtensions.FromBase64(
|
||||
"example/type",
|
||||
payloadBase64,
|
||||
new (string? KeyId, string SignatureBase64)[] { (KeyId: "key-1", SignatureBase64: "not-base64") }));
|
||||
|
||||
Assert.Contains("base64", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -15,24 +15,30 @@ public class DsseHelperTests
|
||||
{
|
||||
private sealed class FakeSigner : IAuthoritySigner
|
||||
{
|
||||
public byte[]? LastPayload { get; private set; }
|
||||
|
||||
public Task<string> GetKeyIdAsync(System.Threading.CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult("fake-key");
|
||||
|
||||
public Task<byte[]> SignAsync(ReadOnlyMemory<byte> paePayload, System.Threading.CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Convert.FromHexString("deadbeef"));
|
||||
{
|
||||
LastPayload = paePayload.ToArray();
|
||||
return Task.FromResult(Convert.FromHexString("deadbeef"));
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WrapAsync_ProducesDsseEnvelope()
|
||||
{
|
||||
var signer = new FakeSigner();
|
||||
var stmt = new InTotoStatement(
|
||||
Type: "https://in-toto.io/Statement/v1",
|
||||
Subject: new[] { new Subject("demo", new System.Collections.Generic.Dictionary<string, string> { { "sha256", "abcd" } }) },
|
||||
PredicateType: "demo/predicate",
|
||||
Predicate: new { hello = "world" });
|
||||
|
||||
var envelope = await DsseHelper.WrapAsync(stmt, new FakeSigner());
|
||||
var envelope = await DsseHelper.WrapAsync(stmt, signer, TestContext.Current.CancellationToken);
|
||||
|
||||
envelope.PayloadType.Should().Be("https://in-toto.io/Statement/v1");
|
||||
var roundtrip = JsonSerializer.Deserialize<InTotoStatement>(envelope.Payload.Span);
|
||||
@@ -51,9 +57,27 @@ public class DsseHelperTests
|
||||
|
||||
var pae = DsseHelper.PreAuthenticationEncoding(payloadType, payload);
|
||||
|
||||
// Verify PAE contains expected components (payload type and payload)
|
||||
var paeString = Encoding.UTF8.GetString(pae);
|
||||
paeString.Should().Contain(payloadType);
|
||||
paeString.Should().Contain("{}");
|
||||
var expected = Encoding.UTF8.GetBytes($"DSSEv1 {payloadType.Length} {payloadType} {payload.Length} {{}}");
|
||||
pae.Should().Equal(expected);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task WrapAsync_UsesDefaultPayloadTypeWhenMissing()
|
||||
{
|
||||
var signer = new FakeSigner();
|
||||
var stmt = new InTotoStatement(
|
||||
Type: "",
|
||||
Subject: new[] { new Subject("demo", new System.Collections.Generic.Dictionary<string, string> { { "sha256", "abcd" } }) },
|
||||
PredicateType: "demo/predicate",
|
||||
Predicate: new { hello = "world" });
|
||||
|
||||
var envelope = await DsseHelper.WrapAsync(stmt, signer, TestContext.Current.CancellationToken);
|
||||
|
||||
envelope.PayloadType.Should().Be("https://in-toto.io/Statement/v1");
|
||||
signer.LastPayload.Should().NotBeNull();
|
||||
var expectedPayload = JsonSerializer.SerializeToUtf8Bytes(stmt, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = false });
|
||||
var expectedPae = DsseHelper.PreAuthenticationEncoding(envelope.PayloadType, expectedPayload);
|
||||
signer.LastPayload!.Should().Equal(expectedPae);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,27 @@ public static class DsseEnvelopeExtensions
|
||||
ArgumentNullException.ThrowIfNull(signatures);
|
||||
|
||||
var payloadBytes = Convert.FromBase64String(payloadBase64);
|
||||
var dsseSignatures = signatures.Select(s => new DsseSignature(s.SignatureBase64, s.KeyId));
|
||||
var dsseSignatures = new List<DsseSignature>();
|
||||
var index = 0;
|
||||
foreach (var signature in signatures)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(signature.SignatureBase64))
|
||||
{
|
||||
throw new ArgumentException("Signature must be provided.", nameof(signatures));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Convert.FromBase64String(signature.SignatureBase64);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new ArgumentException($"Signature at index {index} must be base64-encoded.", nameof(signatures), ex);
|
||||
}
|
||||
|
||||
dsseSignatures.Add(new DsseSignature(signature.SignatureBase64, signature.KeyId));
|
||||
index++;
|
||||
}
|
||||
|
||||
return new DsseEnvelope(payloadType, payloadBytes, dsseSignatures);
|
||||
}
|
||||
|
||||
@@ -10,33 +10,42 @@ namespace StellaOps.Attestation;
|
||||
|
||||
public static class DsseHelper
|
||||
{
|
||||
private const string DefaultPayloadType = "https://in-toto.io/Statement/v1";
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public static byte[] PreAuthenticationEncoding(string payloadType, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
static byte[] Cat(params byte[][] parts)
|
||||
{
|
||||
var len = 0;
|
||||
foreach (var part in parts)
|
||||
{
|
||||
len += part.Length;
|
||||
}
|
||||
|
||||
var buf = new byte[len];
|
||||
var offset = 0;
|
||||
foreach (var part in parts)
|
||||
{
|
||||
Buffer.BlockCopy(part, 0, buf, offset, part.Length);
|
||||
offset += part.Length;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
var header = Encoding.UTF8.GetBytes("DSSEv1");
|
||||
var pt = Encoding.UTF8.GetBytes(payloadType);
|
||||
var lenPt = Encoding.UTF8.GetBytes(pt.Length.ToString(CultureInfo.InvariantCulture));
|
||||
var lenPayload = Encoding.UTF8.GetBytes(payload.Length.ToString(CultureInfo.InvariantCulture));
|
||||
var space = Encoding.UTF8.GetBytes(" ");
|
||||
|
||||
return Cat(header, space, lenPt, space, pt, space, lenPayload, space, payload.ToArray());
|
||||
var totalLength = header.Length + space.Length + lenPt.Length + space.Length + pt.Length +
|
||||
space.Length + lenPayload.Length + space.Length + payload.Length;
|
||||
var buffer = new byte[totalLength];
|
||||
var offset = 0;
|
||||
|
||||
static void CopyBytes(byte[] source, byte[] destination, ref int index)
|
||||
{
|
||||
Buffer.BlockCopy(source, 0, destination, index, source.Length);
|
||||
index += source.Length;
|
||||
}
|
||||
|
||||
CopyBytes(header, buffer, ref offset);
|
||||
CopyBytes(space, buffer, ref offset);
|
||||
CopyBytes(lenPt, buffer, ref offset);
|
||||
CopyBytes(space, buffer, ref offset);
|
||||
CopyBytes(pt, buffer, ref offset);
|
||||
CopyBytes(space, buffer, ref offset);
|
||||
CopyBytes(lenPayload, buffer, ref offset);
|
||||
CopyBytes(space, buffer, ref offset);
|
||||
payload.CopyTo(buffer.AsSpan(offset));
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static async Task<DsseEnvelope> WrapAsync(InTotoStatement statement, IAuthoritySigner signer, CancellationToken cancellationToken = default)
|
||||
@@ -44,13 +53,13 @@ public static class DsseHelper
|
||||
ArgumentNullException.ThrowIfNull(statement);
|
||||
ArgumentNullException.ThrowIfNull(signer);
|
||||
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(statement, statement.GetType());
|
||||
var pae = PreAuthenticationEncoding(statement.Type ?? string.Empty, payloadBytes);
|
||||
var payloadType = string.IsNullOrWhiteSpace(statement.Type) ? DefaultPayloadType : statement.Type;
|
||||
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(statement, SerializerOptions);
|
||||
var pae = PreAuthenticationEncoding(payloadType, payloadBytes);
|
||||
var signatureBytes = await signer.SignAsync(pae, cancellationToken).ConfigureAwait(false);
|
||||
var keyId = await signer.GetKeyIdAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var dsseSignature = DsseSignature.FromBytes(signatureBytes, keyId);
|
||||
var payloadType = statement.Type ?? "https://in-toto.io/Statement/v1";
|
||||
return new DsseEnvelope(payloadType, payloadBytes, new[] { dsseSignature });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0043-M | DONE | Maintainability audit for StellaOps.Attestation. |
|
||||
| AUDIT-0043-T | DONE | Test coverage audit for StellaOps.Attestation. |
|
||||
| AUDIT-0043-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0043-A | DONE | Applied DSSE payloadType alignment and base64 validation with tests. |
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Description: Fluent builder for constructing Sigstore bundles
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Globalization;
|
||||
using StellaOps.Attestor.Bundle.Models;
|
||||
using StellaOps.Attestor.Bundle.Serialization;
|
||||
|
||||
@@ -38,11 +39,24 @@ public sealed class SigstoreBundleBuilder
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(payload);
|
||||
ArgumentNullException.ThrowIfNull(signatures);
|
||||
|
||||
EnsureBase64(payload, nameof(payload));
|
||||
var signatureList = signatures.ToList();
|
||||
if (signatureList.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one signature is required.", nameof(signatures));
|
||||
}
|
||||
|
||||
foreach (var signature in signatureList)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(signature);
|
||||
EnsureBase64(signature.Sig, nameof(signatures));
|
||||
}
|
||||
|
||||
_dsseEnvelope = new BundleDsseEnvelope
|
||||
{
|
||||
PayloadType = payloadType,
|
||||
Payload = payload,
|
||||
Signatures = signatures.ToList()
|
||||
Signatures = signatureList
|
||||
};
|
||||
|
||||
return this;
|
||||
@@ -83,6 +97,7 @@ public sealed class SigstoreBundleBuilder
|
||||
public SigstoreBundleBuilder WithCertificateBase64(string base64DerCertificate)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(base64DerCertificate);
|
||||
EnsureBase64(base64DerCertificate, nameof(base64DerCertificate));
|
||||
_certificate = new CertificateInfo
|
||||
{
|
||||
RawBytes = base64DerCertificate
|
||||
@@ -140,6 +155,13 @@ public sealed class SigstoreBundleBuilder
|
||||
string version = "0.0.1",
|
||||
InclusionProof? inclusionProof = null)
|
||||
{
|
||||
EnsureNumber(logIndex, nameof(logIndex));
|
||||
EnsureNumber(integratedTime, nameof(integratedTime));
|
||||
EnsureBase64(logIdKeyId, nameof(logIdKeyId));
|
||||
EnsureBase64(canonicalizedBody, nameof(canonicalizedBody));
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(kind);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(version);
|
||||
|
||||
var entry = new TransparencyLogEntry
|
||||
{
|
||||
LogIndex = logIndex,
|
||||
@@ -260,4 +282,29 @@ public sealed class SigstoreBundleBuilder
|
||||
var bundle = Build();
|
||||
return SigstoreBundleSerializer.SerializeToUtf8Bytes(bundle);
|
||||
}
|
||||
|
||||
private static void EnsureBase64(string value, string paramName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ArgumentException("Value must be provided.", paramName);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Convert.FromBase64String(value);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new ArgumentException("Value must be base64-encoded.", paramName, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureNumber(string value, string paramName)
|
||||
{
|
||||
if (!long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
|
||||
{
|
||||
throw new ArgumentException("Value must be an integer string.", paramName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,24 @@ public static class SigstoreBundleSerializer
|
||||
throw new SigstoreBundleException("Bundle verificationMaterial is required");
|
||||
}
|
||||
|
||||
if (bundle.VerificationMaterial.Certificate is null &&
|
||||
bundle.VerificationMaterial.PublicKey is null)
|
||||
{
|
||||
throw new SigstoreBundleException("Bundle verificationMaterial must include certificate or publicKey");
|
||||
}
|
||||
|
||||
if (bundle.VerificationMaterial.Certificate is not null &&
|
||||
string.IsNullOrWhiteSpace(bundle.VerificationMaterial.Certificate.RawBytes))
|
||||
{
|
||||
throw new SigstoreBundleException("Bundle certificate rawBytes is required");
|
||||
}
|
||||
|
||||
if (bundle.VerificationMaterial.PublicKey is not null &&
|
||||
string.IsNullOrWhiteSpace(bundle.VerificationMaterial.PublicKey.RawBytes))
|
||||
{
|
||||
throw new SigstoreBundleException("Bundle publicKey rawBytes is required");
|
||||
}
|
||||
|
||||
if (bundle.DsseEnvelope is null)
|
||||
{
|
||||
throw new SigstoreBundleException("Bundle dsseEnvelope is required");
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0045-M | DONE | Maintainability audit for StellaOps.Attestor.Bundle. |
|
||||
| AUDIT-0045-T | DONE | Test coverage audit for StellaOps.Attestor.Bundle. |
|
||||
| AUDIT-0045-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0045-A | DONE | Applied bundle validation hardening, verifier fixes, and test coverage. |
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
@@ -287,7 +288,22 @@ public sealed class SigstoreBundleVerifier
|
||||
}
|
||||
|
||||
// Construct PAE (Pre-Authentication Encoding) for DSSE
|
||||
var payloadBytes = Convert.FromBase64String(envelope.Payload);
|
||||
byte[] payloadBytes;
|
||||
try
|
||||
{
|
||||
payloadBytes = Convert.FromBase64String(envelope.Payload);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
errors.Add(new BundleVerificationError
|
||||
{
|
||||
Code = BundleVerificationErrorCode.DsseSignatureInvalid,
|
||||
Message = "DSSE envelope payload is not valid base64",
|
||||
Exception = ex
|
||||
});
|
||||
return new VerificationCheckResult(false, CheckResult.Failed, errors);
|
||||
}
|
||||
|
||||
var paeMessage = ConstructPae(envelope.PayloadType, payloadBytes);
|
||||
|
||||
// Verify at least one signature
|
||||
@@ -304,6 +320,15 @@ public sealed class SigstoreBundleVerifier
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
errors.Add(new BundleVerificationError
|
||||
{
|
||||
Code = BundleVerificationErrorCode.DsseSignatureInvalid,
|
||||
Message = "DSSE signature is not valid base64",
|
||||
Exception = ex
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogDebug(ex, "Signature verification attempt failed");
|
||||
@@ -320,7 +345,9 @@ public sealed class SigstoreBundleVerifier
|
||||
return new VerificationCheckResult(false, CheckResult.Failed, errors);
|
||||
}
|
||||
|
||||
return new VerificationCheckResult(true, CheckResult.Passed, errors);
|
||||
return errors.Count == 0
|
||||
? new VerificationCheckResult(true, CheckResult.Passed, errors)
|
||||
: new VerificationCheckResult(false, CheckResult.Failed, errors);
|
||||
}
|
||||
|
||||
private static byte[] ConstructPae(string payloadType, byte[] payload)
|
||||
@@ -331,8 +358,8 @@ public sealed class SigstoreBundleVerifier
|
||||
const byte Space = 0x20;
|
||||
|
||||
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
|
||||
var typeLenBytes = Encoding.UTF8.GetBytes(typeBytes.Length.ToString());
|
||||
var payloadLenBytes = Encoding.UTF8.GetBytes(payload.Length.ToString());
|
||||
var typeLenBytes = Encoding.UTF8.GetBytes(typeBytes.Length.ToString(CultureInfo.InvariantCulture));
|
||||
var payloadLenBytes = Encoding.UTF8.GetBytes(payload.Length.ToString(CultureInfo.InvariantCulture));
|
||||
var prefixBytes = Encoding.UTF8.GetBytes(DssePrefix);
|
||||
|
||||
var totalLength = prefixBytes.Length + 1 + typeLenBytes.Length + 1 +
|
||||
@@ -426,23 +453,29 @@ public sealed class SigstoreBundleVerifier
|
||||
await Task.CompletedTask; // Async for future extensibility
|
||||
|
||||
var errors = new List<BundleVerificationError>();
|
||||
var sawProof = false;
|
||||
|
||||
foreach (var entry in tlogEntries)
|
||||
{
|
||||
if (entry.InclusionProof is null)
|
||||
{
|
||||
// Skip entries without inclusion proofs
|
||||
errors.Add(new BundleVerificationError
|
||||
{
|
||||
Code = BundleVerificationErrorCode.InclusionProofInvalid,
|
||||
Message = $"Missing inclusion proof for log index {entry.LogIndex}"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var valid = VerifyMerkleInclusionProof(entry);
|
||||
sawProof = true;
|
||||
var valid = VerifyMerkleInclusionProof(entry, out var errorCode);
|
||||
if (!valid)
|
||||
{
|
||||
errors.Add(new BundleVerificationError
|
||||
{
|
||||
Code = BundleVerificationErrorCode.InclusionProofInvalid,
|
||||
Code = errorCode,
|
||||
Message = $"Merkle inclusion proof verification failed for log index {entry.LogIndex}"
|
||||
});
|
||||
}
|
||||
@@ -458,6 +491,15 @@ public sealed class SigstoreBundleVerifier
|
||||
}
|
||||
}
|
||||
|
||||
if (!sawProof && tlogEntries.Count > 0)
|
||||
{
|
||||
errors.Add(new BundleVerificationError
|
||||
{
|
||||
Code = BundleVerificationErrorCode.InclusionProofInvalid,
|
||||
Message = "No inclusion proofs present in transparency log entries"
|
||||
});
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return new VerificationCheckResult(false, CheckResult.Failed, errors);
|
||||
@@ -466,8 +508,10 @@ public sealed class SigstoreBundleVerifier
|
||||
return new VerificationCheckResult(true, CheckResult.Passed, errors);
|
||||
}
|
||||
|
||||
private bool VerifyMerkleInclusionProof(TransparencyLogEntry entry)
|
||||
private bool VerifyMerkleInclusionProof(TransparencyLogEntry entry, out BundleVerificationErrorCode errorCode)
|
||||
{
|
||||
errorCode = BundleVerificationErrorCode.InclusionProofInvalid;
|
||||
|
||||
if (entry.InclusionProof is null)
|
||||
{
|
||||
return false;
|
||||
@@ -475,9 +519,14 @@ public sealed class SigstoreBundleVerifier
|
||||
|
||||
var proof = entry.InclusionProof;
|
||||
|
||||
if (!string.Equals(proof.LogIndex, entry.LogIndex, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse values
|
||||
if (!long.TryParse(proof.LogIndex, out var leafIndex) ||
|
||||
!long.TryParse(proof.TreeSize, out var treeSize))
|
||||
if (!long.TryParse(proof.LogIndex, NumberStyles.Integer, CultureInfo.InvariantCulture, out var leafIndex) ||
|
||||
!long.TryParse(proof.TreeSize, NumberStyles.Integer, CultureInfo.InvariantCulture, out var treeSize))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -500,7 +549,13 @@ public sealed class SigstoreBundleVerifier
|
||||
// Verify Merkle path
|
||||
var computedRoot = ComputeMerkleRoot(leafHash, leafIndex, treeSize, hashes);
|
||||
|
||||
return computedRoot.SequenceEqual(expectedRoot);
|
||||
if (!computedRoot.SequenceEqual(expectedRoot))
|
||||
{
|
||||
errorCode = BundleVerificationErrorCode.RootHashMismatch;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static byte[] ComputeLeafHash(byte[] data)
|
||||
|
||||
@@ -333,4 +333,62 @@ public class SigstoreBundleBuilderTests
|
||||
var decoded = Convert.FromBase64String(bundle.VerificationMaterial.Certificate!.RawBytes);
|
||||
decoded.Should().BeEquivalentTo(certBytes);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WithDsseEnvelope_InvalidPayloadBase64_Throws()
|
||||
{
|
||||
var builder = new SigstoreBundleBuilder();
|
||||
|
||||
var act = () => builder.WithDsseEnvelope(
|
||||
"application/vnd.in-toto+json",
|
||||
"not-base64",
|
||||
new[] { new BundleSignature { Sig = Convert.ToBase64String(new byte[64]) } });
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*base64*");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WithDsseEnvelope_InvalidSignatureBase64_Throws()
|
||||
{
|
||||
var builder = new SigstoreBundleBuilder();
|
||||
|
||||
var act = () => builder.WithDsseEnvelope(
|
||||
"application/vnd.in-toto+json",
|
||||
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
|
||||
new[] { new BundleSignature { Sig = "not-base64" } });
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*base64*");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WithRekorEntry_InvalidLogIndex_Throws()
|
||||
{
|
||||
var builder = new SigstoreBundleBuilder();
|
||||
|
||||
var act = () => builder.WithRekorEntry(
|
||||
logIndex: "not-a-number",
|
||||
logIdKeyId: Convert.ToBase64String(new byte[32]),
|
||||
integratedTime: "1703500000",
|
||||
canonicalizedBody: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")));
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*integer*");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void WithCertificateBase64_InvalidBase64_Throws()
|
||||
{
|
||||
var builder = new SigstoreBundleBuilder();
|
||||
|
||||
var act = () => builder.WithCertificateBase64("not-base64");
|
||||
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.WithMessage("*base64*");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,18 @@ public class SigstoreBundleSerializerTests
|
||||
.WithMessage("*dsseEnvelope*");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Deserialize_MissingVerificationKeyMaterial_ThrowsSigstoreBundleException()
|
||||
{
|
||||
var json = """{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json","verificationMaterial":{},"dsseEnvelope":{"payloadType":"test","payload":"e30=","signatures":[{"sig":"AAAA"}]}}""";
|
||||
|
||||
var act = () => SigstoreBundleSerializer.Deserialize(json);
|
||||
|
||||
act.Should().Throw<SigstoreBundleException>()
|
||||
.WithMessage("*certificate*publicKey*");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Serialize_NullBundle_ThrowsArgumentNullException()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------------
|
||||
// SigstoreBundleVerifierTests.cs
|
||||
// Sprint: SPRINT_8200_0001_0005 - Sigstore Bundle Implementation
|
||||
// Tasks: BUNDLE-8200-020, BUNDLE-8200-021 - Bundle verification tests
|
||||
@@ -36,7 +36,7 @@ public class SigstoreBundleVerifierTests
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(bundle);
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
@@ -61,7 +61,7 @@ public class SigstoreBundleVerifierTests
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(bundle);
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
@@ -89,7 +89,7 @@ public class SigstoreBundleVerifierTests
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(bundle);
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
@@ -104,16 +104,23 @@ public class SigstoreBundleVerifierTests
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var certBytes = CreateSelfSignedCertificateBytes(ecdsa);
|
||||
|
||||
var bundle = new SigstoreBundleBuilder()
|
||||
.WithDsseEnvelope(
|
||||
"application/vnd.in-toto+json",
|
||||
Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
|
||||
Array.Empty<BundleSignature>())
|
||||
.WithCertificateBase64(Convert.ToBase64String(certBytes))
|
||||
.Build();
|
||||
var bundle = new SigstoreBundle
|
||||
{
|
||||
MediaType = SigstoreBundleConstants.MediaTypeV03,
|
||||
VerificationMaterial = new VerificationMaterial
|
||||
{
|
||||
Certificate = new CertificateInfo { RawBytes = Convert.ToBase64String(certBytes) }
|
||||
},
|
||||
DsseEnvelope = new BundleDsseEnvelope
|
||||
{
|
||||
PayloadType = "application/vnd.in-toto+json",
|
||||
Payload = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
|
||||
Signatures = Array.Empty<BundleSignature>()
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(bundle);
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
@@ -137,7 +144,7 @@ public class SigstoreBundleVerifierTests
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(bundle);
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
@@ -167,7 +174,7 @@ public class SigstoreBundleVerifierTests
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(bundle);
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
@@ -199,7 +206,7 @@ public class SigstoreBundleVerifierTests
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(bundle);
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
@@ -233,7 +240,7 @@ public class SigstoreBundleVerifierTests
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(bundle, options);
|
||||
var result = await _verifier.VerifyAsync(bundle, options, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Checks.CertificateChain.Should().Be(CheckResult.Failed);
|
||||
@@ -262,19 +269,109 @@ public class SigstoreBundleVerifierTests
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var result = await _verifier.VerifyAsync(bundle);
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Checks.InclusionProof.Should().Be(CheckResult.Skipped);
|
||||
result.Checks.TransparencyLog.Should().Be(CheckResult.Skipped);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Verify_InclusionProofMissing_ReturnsFailed()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var certBytes = CreateSelfSignedCertificateBytes(ecdsa);
|
||||
var payload = System.Text.Encoding.UTF8.GetBytes("{}");
|
||||
var payloadType = "application/vnd.in-toto+json";
|
||||
var paeMessage = ConstructPae(payloadType, payload);
|
||||
var signature = ecdsa.SignData(paeMessage, HashAlgorithmName.SHA256);
|
||||
|
||||
var bundle = new SigstoreBundleBuilder()
|
||||
.WithDsseEnvelope(
|
||||
payloadType,
|
||||
Convert.ToBase64String(payload),
|
||||
new[] { new BundleSignature { Sig = Convert.ToBase64String(signature) } })
|
||||
.WithCertificateBase64(Convert.ToBase64String(certBytes))
|
||||
.WithRekorEntry(
|
||||
logIndex: "12",
|
||||
logIdKeyId: Convert.ToBase64String(new byte[32]),
|
||||
integratedTime: "1710000000",
|
||||
canonicalizedBody: Convert.ToBase64String(new byte[16]))
|
||||
.Build();
|
||||
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Code == BundleVerificationErrorCode.InclusionProofInvalid);
|
||||
result.Checks.InclusionProof.Should().Be(CheckResult.Failed);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Verify_InvalidPayloadBase64_ReturnsFailed()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var certBytes = CreateSelfSignedCertificateBytes(ecdsa);
|
||||
var payloadType = "application/vnd.in-toto+json";
|
||||
|
||||
var bundle = new SigstoreBundle
|
||||
{
|
||||
MediaType = SigstoreBundleConstants.MediaTypeV03,
|
||||
VerificationMaterial = new VerificationMaterial
|
||||
{
|
||||
Certificate = new CertificateInfo { RawBytes = Convert.ToBase64String(certBytes) }
|
||||
},
|
||||
DsseEnvelope = new BundleDsseEnvelope
|
||||
{
|
||||
PayloadType = payloadType,
|
||||
Payload = "not-base64",
|
||||
Signatures = new[] { new BundleSignature { Sig = Convert.ToBase64String(new byte[64]) } }
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Code == BundleVerificationErrorCode.DsseSignatureInvalid);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Verify_InvalidSignatureBase64_ReturnsFailed()
|
||||
{
|
||||
using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var certBytes = CreateSelfSignedCertificateBytes(ecdsa);
|
||||
var payload = System.Text.Encoding.UTF8.GetBytes("{}");
|
||||
var payloadType = "application/vnd.in-toto+json";
|
||||
|
||||
var bundle = new SigstoreBundle
|
||||
{
|
||||
MediaType = SigstoreBundleConstants.MediaTypeV03,
|
||||
VerificationMaterial = new VerificationMaterial
|
||||
{
|
||||
Certificate = new CertificateInfo { RawBytes = Convert.ToBase64String(certBytes) }
|
||||
},
|
||||
DsseEnvelope = new BundleDsseEnvelope
|
||||
{
|
||||
PayloadType = payloadType,
|
||||
Payload = Convert.ToBase64String(payload),
|
||||
Signatures = new[] { new BundleSignature { Sig = "not-base64" } }
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _verifier.VerifyAsync(bundle, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Code == BundleVerificationErrorCode.DsseSignatureInvalid);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Verify_NullBundle_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act
|
||||
var act = async () => await _verifier.VerifyAsync(null!);
|
||||
var act = async () => await _verifier.VerifyAsync(null!, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
@@ -286,8 +383,8 @@ public class SigstoreBundleVerifierTests
|
||||
const byte Space = 0x20;
|
||||
|
||||
var typeBytes = System.Text.Encoding.UTF8.GetBytes(payloadType);
|
||||
var typeLenBytes = System.Text.Encoding.UTF8.GetBytes(typeBytes.Length.ToString());
|
||||
var payloadLenBytes = System.Text.Encoding.UTF8.GetBytes(payload.Length.ToString());
|
||||
var typeLenBytes = System.Text.Encoding.UTF8.GetBytes(typeBytes.Length.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
var payloadLenBytes = System.Text.Encoding.UTF8.GetBytes(payload.Length.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
var prefixBytes = System.Text.Encoding.UTF8.GetBytes(DssePrefix);
|
||||
|
||||
var totalLength = prefixBytes.Length + 1 + typeLenBytes.Length + 1 +
|
||||
@@ -331,3 +428,4 @@ public class SigstoreBundleVerifierTests
|
||||
return cert.Export(System.Security.Cryptography.X509Certificates.X509ContentType.Cert);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,13 @@ public sealed class GhsaOptions
|
||||
{
|
||||
public static string HttpClientName => "source.ghsa";
|
||||
|
||||
public Uri BaseEndpoint { get; set; } = new("https://api.github.com/", UriKind.Absolute);
|
||||
private Uri _baseEndpoint = new("https://api.github.com/", UriKind.Absolute);
|
||||
|
||||
public Uri BaseEndpoint
|
||||
{
|
||||
get => _baseEndpoint;
|
||||
set => _baseEndpoint = EnsureHttps(value);
|
||||
}
|
||||
|
||||
public string ApiToken { get; set; } = string.Empty;
|
||||
|
||||
@@ -72,4 +78,29 @@ public sealed class GhsaOptions
|
||||
throw new InvalidOperationException("SecondaryRateLimitBackoff must be greater than zero.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Uri EnsureHttps(Uri? endpoint)
|
||||
{
|
||||
if (endpoint is null)
|
||||
{
|
||||
return new Uri("https://api.github.com/", UriKind.Absolute);
|
||||
}
|
||||
|
||||
if (!endpoint.IsAbsoluteUri)
|
||||
{
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
if (string.Equals(endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var builder = new UriBuilder(endpoint)
|
||||
{
|
||||
Scheme = Uri.UriSchemeHttps,
|
||||
Port = -1
|
||||
};
|
||||
return builder.Uri;
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory job store for development and tests when no persistent store is configured.
|
||||
/// </summary>
|
||||
public sealed class InMemoryJobStore : IJobStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, JobRunSnapshot> _runs = new();
|
||||
|
||||
public Task<JobRunSnapshot> CreateAsync(JobRunCreateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var run = new JobRunSnapshot(
|
||||
Guid.NewGuid(),
|
||||
request.Kind,
|
||||
JobRunStatus.Pending,
|
||||
request.CreatedAt,
|
||||
null,
|
||||
null,
|
||||
request.Trigger,
|
||||
request.ParametersHash,
|
||||
null,
|
||||
request.Timeout,
|
||||
request.LeaseDuration,
|
||||
request.Parameters);
|
||||
|
||||
_runs[run.RunId] = run;
|
||||
return Task.FromResult(run);
|
||||
}
|
||||
|
||||
public Task<JobRunSnapshot?> TryStartAsync(Guid runId, DateTimeOffset startedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_runs.TryGetValue(runId, out var run))
|
||||
{
|
||||
var updated = run with { Status = JobRunStatus.Running, StartedAt = startedAt };
|
||||
_runs[runId] = updated;
|
||||
return Task.FromResult<JobRunSnapshot?>(updated);
|
||||
}
|
||||
|
||||
return Task.FromResult<JobRunSnapshot?>(null);
|
||||
}
|
||||
|
||||
public Task<JobRunSnapshot?> TryCompleteAsync(Guid runId, JobRunCompletion completion, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_runs.TryGetValue(runId, out var run))
|
||||
{
|
||||
var updated = run with
|
||||
{
|
||||
Status = completion.Status,
|
||||
CompletedAt = completion.CompletedAt,
|
||||
Error = completion.Error
|
||||
};
|
||||
_runs[runId] = updated;
|
||||
return Task.FromResult<JobRunSnapshot?>(updated);
|
||||
}
|
||||
|
||||
return Task.FromResult<JobRunSnapshot?>(null);
|
||||
}
|
||||
|
||||
public Task<JobRunSnapshot?> FindAsync(Guid runId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_runs.TryGetValue(runId, out var run) ? run : null);
|
||||
|
||||
public Task<IReadOnlyList<JobRunSnapshot>> GetRecentRunsAsync(string? kind, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _runs.Values.AsEnumerable();
|
||||
if (!string.IsNullOrWhiteSpace(kind))
|
||||
{
|
||||
query = query.Where(r => string.Equals(r.Kind, kind, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
var list = query
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<JobRunSnapshot>>(list);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<JobRunSnapshot>> GetActiveRunsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var list = _runs.Values
|
||||
.Where(r => r.Status is JobRunStatus.Pending or JobRunStatus.Running)
|
||||
.ToArray();
|
||||
return Task.FromResult<IReadOnlyList<JobRunSnapshot>>(list);
|
||||
}
|
||||
|
||||
public Task<JobRunSnapshot?> GetLastRunAsync(string kind, CancellationToken cancellationToken)
|
||||
{
|
||||
var run = _runs.Values
|
||||
.Where(r => string.Equals(r.Kind, kind, StringComparison.Ordinal))
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
return Task.FromResult<JobRunSnapshot?>(run);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyDictionary<string, JobRunSnapshot>> GetLastRunsAsync(IEnumerable<string> kinds, CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new Dictionary<string, JobRunSnapshot>(StringComparer.Ordinal);
|
||||
foreach (var kind in kinds.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
var run = _runs.Values
|
||||
.Where(r => string.Equals(r.Kind, kind, StringComparison.Ordinal))
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (run is not null)
|
||||
{
|
||||
results[kind] = run;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyDictionary<string, JobRunSnapshot>>(results);
|
||||
}
|
||||
}
|
||||
@@ -233,5 +233,6 @@ public sealed record Advisory
|
||||
/// Semantic merge hash for provenance-scoped deduplication.
|
||||
/// Nullable during migration; computed from (CVE + PURL + version-range + CWE + patch-lineage).
|
||||
/// </summary>
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? MergeHash { get; }
|
||||
}
|
||||
|
||||
@@ -269,16 +269,18 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
|
||||
}
|
||||
}
|
||||
|
||||
var normalizedVersions = BuildNormalizedVersions(versionRanges);
|
||||
var (platform, normalizedVersions) = ReadDatabaseSpecific(a.DatabaseSpecific);
|
||||
var effectivePlatform = platform ?? ResolvePlatformFromRanges(versionRanges);
|
||||
var resolvedNormalizedVersions = normalizedVersions ?? BuildNormalizedVersions(versionRanges);
|
||||
|
||||
return new AffectedPackage(
|
||||
MapEcosystemToType(a.Ecosystem),
|
||||
a.PackageName,
|
||||
null,
|
||||
effectivePlatform,
|
||||
versionRanges,
|
||||
Array.Empty<AffectedPackageStatus>(),
|
||||
Array.Empty<AdvisoryProvenance>(),
|
||||
normalizedVersions);
|
||||
resolvedNormalizedVersions);
|
||||
}).ToArray();
|
||||
|
||||
// Parse provenance if available
|
||||
@@ -391,7 +393,7 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
|
||||
"pub" => "semver",
|
||||
"rpm" => "rpm",
|
||||
"deb" => "deb",
|
||||
"apk" => "semver",
|
||||
"apk" => "apk",
|
||||
"cpe" => "cpe",
|
||||
"vendor" => "vendor",
|
||||
"ics" => "ics-vendor",
|
||||
@@ -399,4 +401,75 @@ public sealed class PostgresAdvisoryStore : IPostgresAdvisoryStore, AdvisoryCont
|
||||
_ => "semver"
|
||||
};
|
||||
}
|
||||
|
||||
private static (string? Platform, IReadOnlyList<NormalizedVersionRule>? NormalizedVersions) ReadDatabaseSpecific(string? databaseSpecific)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(databaseSpecific) || databaseSpecific == "{}")
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(databaseSpecific);
|
||||
var root = document.RootElement;
|
||||
|
||||
string? platform = null;
|
||||
if (root.TryGetProperty("platform", out var platformValue) && platformValue.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
platform = platformValue.GetString();
|
||||
}
|
||||
|
||||
IReadOnlyList<NormalizedVersionRule>? normalizedVersions = null;
|
||||
if (root.TryGetProperty("normalizedVersions", out var normalizedValue) && normalizedValue.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
normalizedVersions = JsonSerializer.Deserialize<NormalizedVersionRule[]>(normalizedValue.GetRawText(), JsonOptions);
|
||||
}
|
||||
|
||||
return (platform, normalizedVersions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolvePlatformFromRanges(IEnumerable<AffectedVersionRange> ranges)
|
||||
{
|
||||
foreach (var range in ranges)
|
||||
{
|
||||
var extensions = range.Primitives?.VendorExtensions;
|
||||
if (extensions is null || extensions.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (extensions.TryGetValue("debian.release", out var debRelease) && !string.IsNullOrWhiteSpace(debRelease))
|
||||
{
|
||||
return debRelease;
|
||||
}
|
||||
|
||||
if (extensions.TryGetValue("ubuntu.release", out var ubuntuRelease) && !string.IsNullOrWhiteSpace(ubuntuRelease))
|
||||
{
|
||||
return ubuntuRelease;
|
||||
}
|
||||
|
||||
if (extensions.TryGetValue("alpine.distroversion", out var alpineRelease) && !string.IsNullOrWhiteSpace(alpineRelease))
|
||||
{
|
||||
if (extensions.TryGetValue("alpine.repo", out var alpineRepo) && !string.IsNullOrWhiteSpace(alpineRepo))
|
||||
{
|
||||
return $"{alpineRelease}/{alpineRepo}";
|
||||
}
|
||||
|
||||
return alpineRelease;
|
||||
}
|
||||
|
||||
if (extensions.TryGetValue("suse.platform", out var susePlatform) && !string.IsNullOrWhiteSpace(susePlatform))
|
||||
{
|
||||
return susePlatform;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ public sealed class AdvisoryConverter
|
||||
{
|
||||
var ecosystem = MapTypeToEcosystem(pkg.Type);
|
||||
var versionRangeJson = JsonSerializer.Serialize(pkg.VersionRanges, JsonOptions);
|
||||
var databaseSpecificJson = BuildDatabaseSpecific(pkg);
|
||||
|
||||
affectedEntities.Add(new AdvisoryAffectedEntity
|
||||
{
|
||||
@@ -110,7 +111,7 @@ public sealed class AdvisoryConverter
|
||||
VersionRange = versionRangeJson,
|
||||
VersionsAffected = null,
|
||||
VersionsFixed = ExtractFixedVersions(pkg.VersionRanges),
|
||||
DatabaseSpecific = null,
|
||||
DatabaseSpecific = databaseSpecificJson,
|
||||
CreatedAt = now
|
||||
});
|
||||
}
|
||||
@@ -245,6 +246,29 @@ public sealed class AdvisoryConverter
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static string? BuildDatabaseSpecific(AffectedPackage package)
|
||||
{
|
||||
if (package is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var payload = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
if (!string.IsNullOrWhiteSpace(package.Platform))
|
||||
{
|
||||
payload["platform"] = package.Platform;
|
||||
}
|
||||
|
||||
if (!package.NormalizedVersions.IsEmpty)
|
||||
{
|
||||
payload["normalizedVersions"] = package.NormalizedVersions;
|
||||
}
|
||||
|
||||
return payload.Count == 0
|
||||
? null
|
||||
: JsonSerializer.Serialize(payload, JsonOptions);
|
||||
}
|
||||
|
||||
private static string[]? ExtractFixedVersions(IEnumerable<AffectedVersionRange> ranges)
|
||||
{
|
||||
var fixedVersions = ranges
|
||||
|
||||
@@ -271,10 +271,10 @@ public static partial class ChangelogParser
|
||||
[GeneratedRegex(@"^\* (.+) - (.+)")]
|
||||
private static partial Regex RpmHeaderRegex();
|
||||
|
||||
[GeneratedRegex(@" ([\d\.\-]+):")]
|
||||
[GeneratedRegex(@"^\s{2}([0-9A-Za-z\.\-_+]+):")]
|
||||
private static partial Regex AlpineVersionRegex();
|
||||
|
||||
[GeneratedRegex(@"CVE-\d{4}-\d{4,}")]
|
||||
[GeneratedRegex(@"CVE-\d{4}-[0-9A-Za-z]{4,}")]
|
||||
private static partial Regex CvePatternRegex();
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,11 @@ public static partial class PatchHeaderParser
|
||||
|
||||
private static double CalculateConfidence(int cveCount, string description, string origin)
|
||||
{
|
||||
if (cveCount == 0)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Base confidence for patch header CVE mention
|
||||
var confidence = 0.80;
|
||||
|
||||
@@ -137,7 +142,7 @@ public static partial class PatchHeaderParser
|
||||
return Math.Min(confidence, 0.95);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"CVE-\d{4}-\d{4,}")]
|
||||
[GeneratedRegex(@"CVE-\d{4}-[0-9A-Za-z]{4,}")]
|
||||
private static partial Regex CvePatternRegex();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -530,12 +531,14 @@ public sealed class GhsaResilienceTests : IAsyncLifetime
|
||||
var since = initialTime - TimeSpan.FromDays(30);
|
||||
var listUri = new Uri($"https://ghsa.test/security/advisories?updated_since={Uri.EscapeDataString(since.ToString("O"))}&updated_until={Uri.EscapeDataString(initialTime.ToString("O"))}&page=1&per_page=5");
|
||||
harness.Handler.AddJsonResponse(listUri, json);
|
||||
RegisterDetailResponses(harness, json, initialTime);
|
||||
}
|
||||
|
||||
private async Task EnsureHarnessAsync(DateTimeOffset initialTime)
|
||||
{
|
||||
if (_harness is not null)
|
||||
{
|
||||
await _harness.ResetAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -558,6 +561,43 @@ public sealed class GhsaResilienceTests : IAsyncLifetime
|
||||
_harness = harness;
|
||||
}
|
||||
|
||||
private static void RegisterDetailResponses(ConnectorTestHarness harness, string listJson, DateTimeOffset publishedAt)
|
||||
{
|
||||
using var document = JsonDocument.Parse(listJson);
|
||||
if (!document.RootElement.TryGetProperty("advisories", out var advisories) || advisories.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var advisory in advisories.EnumerateArray())
|
||||
{
|
||||
if (!advisory.TryGetProperty("ghsa_id", out var ghsaIdValue) || ghsaIdValue.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var ghsaId = ghsaIdValue.GetString();
|
||||
if (string.IsNullOrWhiteSpace(ghsaId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var detailUri = new Uri($"https://ghsa.test/security/advisories/{Uri.EscapeDataString(ghsaId)}");
|
||||
var detailPayload = $$"""
|
||||
{
|
||||
"ghsa_id": "{{ghsaId}}",
|
||||
"summary": "resilience fixture",
|
||||
"description": "fixture detail payload",
|
||||
"severity": "low",
|
||||
"published_at": "{{publishedAt:O}}",
|
||||
"updated_at": "{{publishedAt:O}}"
|
||||
}
|
||||
""";
|
||||
|
||||
harness.Handler.AddJsonResponse(detailUri, detailPayload);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await ValueTask.CompletedTask;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -458,12 +459,14 @@ public sealed class GhsaSecurityTests : IAsyncLifetime
|
||||
var since = initialTime - TimeSpan.FromDays(30);
|
||||
var listUri = new Uri($"https://ghsa.test/security/advisories?updated_since={Uri.EscapeDataString(since.ToString("O"))}&updated_until={Uri.EscapeDataString(initialTime.ToString("O"))}&page=1&per_page=5");
|
||||
harness.Handler.AddJsonResponse(listUri, json);
|
||||
RegisterDetailResponses(harness, json, initialTime);
|
||||
}
|
||||
|
||||
private async Task EnsureHarnessAsync(DateTimeOffset initialTime)
|
||||
{
|
||||
if (_harness is not null)
|
||||
{
|
||||
await _harness.ResetAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -486,6 +489,43 @@ public sealed class GhsaSecurityTests : IAsyncLifetime
|
||||
_harness = harness;
|
||||
}
|
||||
|
||||
private static void RegisterDetailResponses(ConnectorTestHarness harness, string listJson, DateTimeOffset publishedAt)
|
||||
{
|
||||
using var document = JsonDocument.Parse(listJson);
|
||||
if (!document.RootElement.TryGetProperty("advisories", out var advisories) || advisories.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var advisory in advisories.EnumerateArray())
|
||||
{
|
||||
if (!advisory.TryGetProperty("ghsa_id", out var ghsaIdValue) || ghsaIdValue.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var ghsaId = ghsaIdValue.GetString();
|
||||
if (string.IsNullOrWhiteSpace(ghsaId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var detailUri = new Uri($"https://ghsa.test/security/advisories/{Uri.EscapeDataString(ghsaId)}");
|
||||
var detailPayload = $$"""
|
||||
{
|
||||
"ghsa_id": "{{ghsaId}}",
|
||||
"summary": "security advisory",
|
||||
"description": "fixture detail payload",
|
||||
"severity": "low",
|
||||
"published_at": "{{publishedAt:O}}",
|
||||
"updated_at": "{{publishedAt:O}}"
|
||||
}
|
||||
""";
|
||||
|
||||
harness.Handler.AddJsonResponse(detailUri, detailPayload);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await ValueTask.CompletedTask;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Evidence Locker Core Agent Charter
|
||||
|
||||
## Mission
|
||||
Maintain Evidence Locker core domain contracts and deterministic hashing helpers.
|
||||
|
||||
## Responsibilities
|
||||
- Define evidence bundle and snapshot models with typed identifiers.
|
||||
- Provide Merkle tree calculation and crypto selection hooks used by builders.
|
||||
- Maintain repository and service interfaces for infrastructure and web layers.
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/evidence-locker/architecture.md
|
||||
- docs/modules/evidence-locker/bundle-packaging.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Definition of Done
|
||||
- Core models remain deterministic and validated.
|
||||
- Hashing logic has tests for ordering and empty inputs.
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to DOING/DONE in the sprint file and local TASKS.md.
|
||||
- 2. Review this charter and required docs before coding.
|
||||
- 3. Keep outputs deterministic (ordering, timestamps, hashes) and offline-friendly.
|
||||
- 4. Add tests for core invariants and negative paths.
|
||||
- 5. Revert to TODO if paused; capture context in PR notes.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Evidence Locker Core Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0288-M | DONE | Maintainability audit for EvidenceLocker.Core. |
|
||||
| AUDIT-0288-T | DONE | Test coverage audit for EvidenceLocker.Core. |
|
||||
| AUDIT-0288-A | TODO | Pending approval for changes. |
|
||||
@@ -0,0 +1,28 @@
|
||||
# Evidence Locker Infrastructure Agent Charter
|
||||
|
||||
## Mission
|
||||
Maintain Evidence Locker infrastructure services: storage backends, repositories, packaging, signing, and timeline publishing.
|
||||
|
||||
## Responsibilities
|
||||
- Own database migrations, data source configuration, and repository implementations.
|
||||
- Implement object-store adapters (filesystem and S3) with write-once semantics.
|
||||
- Provide bundle packaging, portable bundle generation, and signature/timestamp workflows.
|
||||
- Integrate timeline publishing and incident mode notifications.
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/evidence-locker/architecture.md
|
||||
- docs/modules/evidence-locker/bundle-packaging.md
|
||||
- docs/modules/evidence-locker/attestation-contract.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Definition of Done
|
||||
- Deterministic bundle packaging and portable output verified by tests.
|
||||
- Migration runner applies scripts with checksum validation.
|
||||
- Storage backends enforce write-once when configured.
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to DOING/DONE in the sprint file and local TASKS.md.
|
||||
- 2. Review this charter and required docs before coding.
|
||||
- 3. Keep outputs deterministic (ordering, timestamps, hashes) and offline-friendly.
|
||||
- 4. Add tests for repository and storage invariants and negative paths.
|
||||
- 5. Revert to TODO if paused; capture context in PR notes.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Evidence Locker Infrastructure Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0289-M | DONE | Maintainability audit for EvidenceLocker.Infrastructure. |
|
||||
| AUDIT-0289-T | DONE | Test coverage audit for EvidenceLocker.Infrastructure. |
|
||||
| AUDIT-0289-A | TODO | Pending approval for changes. |
|
||||
@@ -0,0 +1,25 @@
|
||||
# Evidence Locker Tests Agent Charter
|
||||
|
||||
## Mission
|
||||
Keep Evidence Locker tests deterministic, readable, and aligned with module contracts.
|
||||
|
||||
## Responsibilities
|
||||
- Maintain unit, integration, and contract tests for Evidence Locker services.
|
||||
- Keep fixtures deterministic and offline-friendly.
|
||||
- Ensure integration tests are clearly labeled and skip gracefully when dependencies are unavailable.
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/evidence-locker/architecture.md
|
||||
- docs/modules/evidence-locker/bundle-packaging.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Definition of Done
|
||||
- Tests are deterministic and categorized correctly.
|
||||
- Fixtures and golden bundles remain stable across runs.
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to DOING/DONE in the sprint file and local TASKS.md.
|
||||
- 2. Review this charter and required docs before coding.
|
||||
- 3. Keep outputs deterministic (ordering, timestamps, hashes) and offline-friendly.
|
||||
- 4. Add tests for negative paths and determinism regressions.
|
||||
- 5. Revert to TODO if paused; capture context in PR notes.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Evidence Locker Tests Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0290-M | DONE | Maintainability audit for EvidenceLocker.Tests. |
|
||||
| AUDIT-0290-T | DONE | Test coverage audit for EvidenceLocker.Tests. |
|
||||
| AUDIT-0290-A | DONE | Waived (test project). |
|
||||
@@ -0,0 +1,28 @@
|
||||
# Evidence Locker WebService Agent Charter
|
||||
|
||||
## Mission
|
||||
Deliver the Evidence Locker HTTP API with correct auth, validation, and audit logging.
|
||||
|
||||
## Responsibilities
|
||||
- Maintain Minimal API endpoints and request/response contracts.
|
||||
- Enforce tenant resolution and scope-based authorization.
|
||||
- Keep audit logging consistent and deterministic.
|
||||
- Keep router integration and OpenAPI wiring in sync with configs.
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/evidence-locker/architecture.md
|
||||
- docs/modules/evidence-locker/attestation-contract.md
|
||||
- docs/modules/evidence-locker/bundle-packaging.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Definition of Done
|
||||
- Endpoints validate inputs and return stable error responses.
|
||||
- Auth scopes map to intended operations.
|
||||
- Audit logs capture tenant, subject, and outcome.
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to DOING/DONE in the sprint file and local TASKS.md.
|
||||
- 2. Review this charter and required docs before coding.
|
||||
- 3. Keep outputs deterministic (ordering, timestamps, hashes) and offline-friendly.
|
||||
- 4. Add tests for validation, auth policies, and error handling.
|
||||
- 5. Revert to TODO if paused; capture context in PR notes.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Evidence Locker WebService Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0291-M | DONE | Maintainability audit for EvidenceLocker.WebService. |
|
||||
| AUDIT-0291-T | DONE | Test coverage audit for EvidenceLocker.WebService. |
|
||||
| AUDIT-0291-A | TODO | Pending approval for changes. |
|
||||
@@ -0,0 +1,24 @@
|
||||
# Evidence Locker Worker Agent Charter
|
||||
|
||||
## Mission
|
||||
Run background workflows for Evidence Locker with reliable connectivity and telemetry.
|
||||
|
||||
## Responsibilities
|
||||
- Host long-running background tasks for Evidence Locker services.
|
||||
- Validate configuration and dependencies on startup.
|
||||
- Keep worker behavior deterministic and easy to observe.
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/evidence-locker/architecture.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Definition of Done
|
||||
- Worker starts cleanly with validated configuration.
|
||||
- Failures are logged and surfaced deterministically.
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to DOING/DONE in the sprint file and local TASKS.md.
|
||||
- 2. Review this charter and required docs before coding.
|
||||
- 3. Keep outputs deterministic (ordering, timestamps, hashes) and offline-friendly.
|
||||
- 4. Add tests for startup validation and failure paths.
|
||||
- 5. Revert to TODO if paused; capture context in PR notes.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Evidence Locker Worker Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0292-M | DONE | Maintainability audit for EvidenceLocker.Worker. |
|
||||
| AUDIT-0292-T | DONE | Test coverage audit for EvidenceLocker.Worker. |
|
||||
| AUDIT-0292-A | TODO | Pending approval for changes. |
|
||||
10
src/EvidenceLocker/StellaOps.EvidenceLocker/TASKS.md
Normal file
10
src/EvidenceLocker/StellaOps.EvidenceLocker/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Evidence Locker Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0287-M | DONE | Maintainability audit for StellaOps.EvidenceLocker. |
|
||||
| AUDIT-0287-T | DONE | Test coverage audit for StellaOps.EvidenceLocker. |
|
||||
| AUDIT-0287-A | TODO | Pending approval for changes. |
|
||||
@@ -0,0 +1,24 @@
|
||||
# Excititor S3 Artifact Store Agent Charter
|
||||
|
||||
## Mission
|
||||
Maintain the S3-backed artifact store client and DI wiring for Excititor exports.
|
||||
|
||||
## Responsibilities
|
||||
- Implement S3 client interactions for artifact storage.
|
||||
- Provide dependency injection wiring and options handling.
|
||||
- Keep storage operations deterministic and failure-aware.
|
||||
|
||||
## Required Reading
|
||||
- docs/modules/excititor/architecture.md
|
||||
- docs/modules/platform/architecture-overview.md
|
||||
|
||||
## Definition of Done
|
||||
- S3 client operations are validated and tested.
|
||||
- Error handling is predictable and logged.
|
||||
|
||||
## Working Agreement
|
||||
- 1. Update task status to DOING/DONE in the sprint file and local TASKS.md.
|
||||
- 2. Review this charter and required docs before coding.
|
||||
- 3. Keep outputs deterministic (ordering, timestamps, hashes) and offline-friendly.
|
||||
- 4. Add tests for error paths and option validation.
|
||||
- 5. Revert to TODO if paused; capture context in PR notes.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Excititor S3 Artifact Store Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0293-M | DONE | Maintainability audit for Excititor.ArtifactStores.S3. |
|
||||
| AUDIT-0293-T | DONE | Test coverage audit for Excititor.ArtifactStores.S3. |
|
||||
| AUDIT-0293-A | TODO | Pending approval for changes. |
|
||||
@@ -0,0 +1,10 @@
|
||||
# Excititor Attestation Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0295-M | DONE | Maintainability audit for Excititor.Attestation. |
|
||||
| AUDIT-0295-T | DONE | Test coverage audit for Excititor.Attestation. |
|
||||
| AUDIT-0295-A | TODO | Pending approval for changes. |
|
||||
@@ -0,0 +1,10 @@
|
||||
# Excititor Connectors Abstractions Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0297-M | DONE | Maintainability audit for Excititor.Connectors.Abstractions. |
|
||||
| AUDIT-0297-T | DONE | Test coverage audit for Excititor.Connectors.Abstractions. |
|
||||
| AUDIT-0297-A | TODO | Pending approval for changes. |
|
||||
@@ -0,0 +1,10 @@
|
||||
# Excititor Connectors Cisco CSAF Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0298-M | DONE | Maintainability audit for Excititor.Connectors.Cisco.CSAF. |
|
||||
| AUDIT-0298-T | DONE | Test coverage audit for Excititor.Connectors.Cisco.CSAF. |
|
||||
| AUDIT-0298-A | TODO | Pending approval for changes. |
|
||||
@@ -0,0 +1,10 @@
|
||||
# Excititor Connectors MSRC CSAF Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0300-M | DONE | Maintainability audit for Excititor.Connectors.MSRC.CSAF. |
|
||||
| AUDIT-0300-T | DONE | Test coverage audit for Excititor.Connectors.MSRC.CSAF. |
|
||||
| AUDIT-0300-A | TODO | Pending approval for changes. |
|
||||
@@ -0,0 +1,10 @@
|
||||
# Excititor Connectors OCI OpenVEX Attest Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0302-M | DONE | Maintainability audit for Excititor.Connectors.OCI.OpenVEX.Attest. |
|
||||
| AUDIT-0302-T | DONE | Test coverage audit for Excititor.Connectors.OCI.OpenVEX.Attest. |
|
||||
| AUDIT-0302-A | TODO | Pending approval for changes. |
|
||||
@@ -0,0 +1,10 @@
|
||||
# Excititor Connectors Oracle CSAF Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0304-M | DONE | Maintainability audit for Excititor.Connectors.Oracle.CSAF. |
|
||||
| AUDIT-0304-T | DONE | Test coverage audit for Excititor.Connectors.Oracle.CSAF. |
|
||||
| AUDIT-0304-A | TODO | Pending approval for changes. |
|
||||
@@ -0,0 +1,10 @@
|
||||
# Excititor Connectors RedHat CSAF Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0306-M | DONE | Maintainability audit for Excititor.Connectors.RedHat.CSAF. |
|
||||
| AUDIT-0306-T | DONE | Test coverage audit for Excititor.Connectors.RedHat.CSAF. |
|
||||
| AUDIT-0306-A | TODO | Pending approval for changes. |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user