save progress

This commit is contained in:
StellaOps Bot
2026-01-02 15:52:31 +02:00
parent 2dec7e6a04
commit f46bde5575
174 changed files with 20793 additions and 8307 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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