Add new features and tests for AirGap and Time modules
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Introduced `SbomService` tasks documentation.
- Updated `StellaOps.sln` to include new projects: `StellaOps.AirGap.Time` and `StellaOps.AirGap.Importer`.
- Added unit tests for `BundleImportPlanner`, `DsseVerifier`, `ImportValidator`, and other components in the `StellaOps.AirGap.Importer.Tests` namespace.
- Implemented `InMemoryBundleRepositories` for testing bundle catalog and item repositories.
- Created `MerkleRootCalculator`, `RootRotationPolicy`, and `TufMetadataValidator` tests.
- Developed `StalenessCalculator` and `TimeAnchorLoader` tests in the `StellaOps.AirGap.Time.Tests` namespace.
- Added `fetch-sbomservice-deps.sh` script for offline dependency fetching.
This commit is contained in:
master
2025-11-20 23:29:54 +02:00
parent 65b1599229
commit 79b8e53441
182 changed files with 6660 additions and 1242 deletions

View File

@@ -0,0 +1,17 @@
namespace StellaOps.AirGap.Importer.Contracts;
/// <summary>
/// Describes the minimal trust-root inputs the importer requires before
/// processing any offline bundle.
/// </summary>
public sealed record TrustRootConfig(
string RootBundlePath,
IReadOnlyCollection<string> TrustedKeyFingerprints,
IReadOnlyCollection<string> AllowedSignatureAlgorithms,
DateTimeOffset? NotBeforeUtc,
DateTimeOffset? NotAfterUtc,
IReadOnlyDictionary<string, byte[]> PublicKeys)
{
public static TrustRootConfig Empty(string rootBundlePath) =>
new(rootBundlePath, Array.Empty<string>(), Array.Empty<string>(), null, null, new Dictionary<string, byte[]>());
}

View File

@@ -0,0 +1,8 @@
namespace StellaOps.AirGap.Importer.Models;
public sealed record BundleCatalogEntry(
string TenantId,
string BundleId,
string Digest,
DateTimeOffset ImportedAtUtc,
IReadOnlyList<string> ContentPaths);

View File

@@ -0,0 +1,8 @@
namespace StellaOps.AirGap.Importer.Models;
public sealed record BundleItem(
string TenantId,
string BundleId,
string Path,
string Digest,
long SizeBytes);

View File

@@ -0,0 +1,34 @@
using StellaOps.AirGap.Importer.Validation;
namespace StellaOps.AirGap.Importer.Planning;
/// <summary>
/// Immutable plan describing the deterministic steps taken when importing an offline bundle.
/// This keeps replay/attestation logic stable for audits.
/// </summary>
public sealed record BundleImportPlan(
IReadOnlyList<string> Steps,
IReadOnlyDictionary<string, string> Inputs,
BundleValidationResult InitialState)
{
public static BundleImportPlan FromDefaults(string bundlePath) => new List<string>
{
"load-trust-roots",
"read-bundle-manifest",
"verify-dsse-signature",
"verify-manifest-digests",
"validate-tuf-metadata",
"compute-merkle-root",
"stage-object-store",
"record-audit-entry"
}.AsReadOnlyPlan(bundlePath);
}
file static class BundleImportPlanExtensions
{
public static BundleImportPlan AsReadOnlyPlan(this IReadOnlyList<string> steps, string bundlePath) =>
new(steps, new Dictionary<string, string>
{
["bundlePath"] = bundlePath
}, BundleValidationResult.Success("plan-staged"));
}

View File

@@ -0,0 +1,39 @@
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.AirGap.Importer.Validation;
namespace StellaOps.AirGap.Importer.Planning;
/// <summary>
/// Produces deterministic import plans and performs fast pre-flight checks before expensive validation work.
/// </summary>
public sealed class BundleImportPlanner
{
public BundleImportPlan CreatePlan(string bundlePath, TrustRootConfig trustRoot)
{
if (string.IsNullOrWhiteSpace(bundlePath))
{
return new BundleImportPlan(
Array.Empty<string>(),
new Dictionary<string, string> { ["bundlePath"] = "(missing)" },
BundleValidationResult.Failure("bundle-path-required"));
}
if (trustRoot.TrustedKeyFingerprints.Count == 0)
{
return new BundleImportPlan(
new[] { "load-trust-roots" },
new Dictionary<string, string> { ["bundlePath"] = bundlePath },
BundleValidationResult.Failure("trust-roots-required"));
}
if (trustRoot.NotAfterUtc is { } notAfter && trustRoot.NotBeforeUtc is { } notBefore && notAfter < notBefore)
{
return new BundleImportPlan(
new[] { "load-trust-roots" },
new Dictionary<string, string> { ["bundlePath"] = bundlePath },
BundleValidationResult.Failure("invalid-trust-window"));
}
return BundleImportPlan.FromDefaults(bundlePath);
}
}

View File

@@ -0,0 +1,9 @@
using StellaOps.AirGap.Importer.Models;
namespace StellaOps.AirGap.Importer.Repositories;
public interface IBundleCatalogRepository
{
Task UpsertAsync(BundleCatalogEntry entry, CancellationToken cancellationToken);
Task<IReadOnlyList<BundleCatalogEntry>> ListAsync(string tenantId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,9 @@
using StellaOps.AirGap.Importer.Models;
namespace StellaOps.AirGap.Importer.Repositories;
public interface IBundleItemRepository
{
Task UpsertManyAsync(IEnumerable<BundleItem> items, CancellationToken cancellationToken);
Task<IReadOnlyList<BundleItem>> ListByBundleAsync(string tenantId, string bundleId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,63 @@
using StellaOps.AirGap.Importer.Models;
namespace StellaOps.AirGap.Importer.Repositories;
/// <summary>
/// Deterministic in-memory implementations suitable for offline tests and as a template for Mongo-backed repos.
/// Enforces tenant isolation and stable ordering (by BundleId then Path).
/// </summary>
public sealed class InMemoryBundleCatalogRepository : IBundleCatalogRepository
{
private readonly Dictionary<string, List<BundleCatalogEntry>> _catalog = new(StringComparer.Ordinal);
public Task UpsertAsync(BundleCatalogEntry entry, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var bucket = _catalog.GetValueOrDefault(entry.TenantId) ?? new List<BundleCatalogEntry>();
bucket.RemoveAll(e => e.BundleId == entry.BundleId);
bucket.Add(entry);
_catalog[entry.TenantId] = bucket;
return Task.CompletedTask;
}
public Task<IReadOnlyList<BundleCatalogEntry>> ListAsync(string tenantId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var items = _catalog.GetValueOrDefault(tenantId) ?? new List<BundleCatalogEntry>();
return Task.FromResult<IReadOnlyList<BundleCatalogEntry>>(items
.OrderBy(e => e.BundleId, StringComparer.Ordinal)
.ToList());
}
}
public sealed class InMemoryBundleItemRepository : IBundleItemRepository
{
private readonly Dictionary<(string TenantId, string BundleId), List<BundleItem>> _items = new();
public Task UpsertManyAsync(IEnumerable<BundleItem> items, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
foreach (var item in items)
{
var key = (item.TenantId, item.BundleId);
if (!_items.TryGetValue(key, out var list))
{
list = new List<BundleItem>();
_items[key] = list;
}
list.RemoveAll(i => i.Path == item.Path);
list.Add(item);
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<BundleItem>> ListByBundleAsync(string tenantId, string bundleId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var key = (tenantId, bundleId);
var list = _items.GetValueOrDefault(key) ?? new List<BundleItem>();
return Task.FromResult<IReadOnlyList<BundleItem>>(list
.OrderBy(i => i.Path, StringComparer.Ordinal)
.ToList());
}
}

View File

@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>StellaOps.AirGap.Importer</RootNamespace>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,10 @@
namespace StellaOps.AirGap.Importer.Validation;
/// <summary>
/// Deterministic validation outcome used by importer and CLI surfaces.
/// </summary>
public sealed record BundleValidationResult(bool IsValid, string Reason)
{
public static BundleValidationResult Success(string reason = "ok") => new(true, reason);
public static BundleValidationResult Failure(string reason) => new(false, reason);
}

View File

@@ -0,0 +1,29 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.AirGap.Importer.Validation;
public sealed record DsseEnvelope(
[property: JsonPropertyName("payloadType")] string PayloadType,
[property: JsonPropertyName("payload")] string Payload,
[property: JsonPropertyName("signatures")] IReadOnlyList<DsseSignature> Signatures)
{
public static DsseEnvelope Parse(string json)
{
var envelope = JsonSerializer.Deserialize<DsseEnvelope>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (envelope is null)
{
throw new InvalidDataException("dsse-envelope-invalid-json");
}
return envelope;
}
}
public sealed record DsseSignature(
[property: JsonPropertyName("keyid")] string KeyId,
[property: JsonPropertyName("sig")] string Signature);

View File

@@ -0,0 +1,90 @@
using System.Security.Cryptography;
using System.Text;
using StellaOps.AirGap.Importer.Contracts;
namespace StellaOps.AirGap.Importer.Validation;
/// <summary>
/// Minimal DSSE verifier supporting RSA-PSS/SHA256. The implementation focuses on deterministic
/// pre-authentication encoding (PAE) and fingerprint checks so sealed-mode environments can run
/// without dragging additional deps.
/// </summary>
public sealed class DsseVerifier
{
private const string PaePrefix = "DSSEv1";
public BundleValidationResult Verify(DsseEnvelope envelope, TrustRootConfig trustRoots)
{
if (trustRoots.TrustedKeyFingerprints.Count == 0 || trustRoots.PublicKeys.Count == 0)
{
return BundleValidationResult.Failure("trust-roots-required");
}
foreach (var signature in envelope.Signatures)
{
if (!trustRoots.PublicKeys.TryGetValue(signature.KeyId, out var keyBytes))
{
continue;
}
var fingerprint = ComputeFingerprint(keyBytes);
if (!trustRoots.TrustedKeyFingerprints.Contains(fingerprint))
{
continue;
}
var pae = BuildPreAuthEncoding(envelope.PayloadType, envelope.Payload);
if (TryVerifyRsaPss(keyBytes, pae, signature.Signature))
{
return BundleValidationResult.Success("dsse-signature-verified");
}
}
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 static bool TryVerifyRsaPss(byte[] publicKey, byte[] pae, string signatureBase64)
{
try
{
using var rsa = RSA.Create();
rsa.ImportSubjectPublicKeyInfo(publicKey, out _);
var sig = Convert.FromBase64String(signatureBase64);
return rsa.VerifyData(pae, sig, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
}
catch
{
return false;
}
}
private static string ComputeFingerprint(byte[] publicKey)
{
var hash = SHA256.HashData(publicKey);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,61 @@
using StellaOps.AirGap.Importer.Contracts;
namespace StellaOps.AirGap.Importer.Validation;
/// <summary>
/// Coordinates DSSE, TUF, and Merkle verification for an offline import. Stateless and deterministic.
/// </summary>
public sealed class ImportValidator
{
private readonly DsseVerifier _dsse;
private readonly TufMetadataValidator _tuf;
private readonly MerkleRootCalculator _merkle;
private readonly RootRotationPolicy _rotation;
public ImportValidator()
{
_dsse = new DsseVerifier();
_tuf = new TufMetadataValidator();
_merkle = new MerkleRootCalculator();
_rotation = new RootRotationPolicy();
}
public BundleValidationResult Validate(ImportValidationRequest request)
{
var tufResult = _tuf.Validate(request.RootJson, request.SnapshotJson, request.TimestampJson);
if (!tufResult.IsValid)
{
return tufResult with { Reason = $"tuf:{tufResult.Reason}" };
}
var dsseResult = _dsse.Verify(request.Envelope, request.TrustRoots);
if (!dsseResult.IsValid)
{
return dsseResult with { Reason = $"dsse:{dsseResult.Reason}" };
}
var merkleRoot = _merkle.ComputeRoot(request.PayloadEntries);
if (string.IsNullOrEmpty(merkleRoot))
{
return BundleValidationResult.Failure("merkle-empty");
}
var rotationResult = _rotation.Validate(request.TrustStore.ActiveKeys, request.TrustStore.PendingKeys, request.ApproverIds);
if (!rotationResult.IsValid)
{
return rotationResult with { Reason = $"rotation:{rotationResult.Reason}" };
}
return BundleValidationResult.Success("import-validated");
}
}
public sealed record ImportValidationRequest(
DsseEnvelope Envelope,
TrustRootConfig TrustRoots,
string RootJson,
string SnapshotJson,
string TimestampJson,
IReadOnlyList<NamedStream> PayloadEntries,
TrustStore TrustStore,
IReadOnlyCollection<string> ApproverIds);

View File

@@ -0,0 +1,62 @@
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.AirGap.Importer.Validation;
/// <summary>
/// Computes a deterministic Merkle-like root by hashing ordered leaf entries (path + SHA256 of content).
/// Paths are sorted ordinally to stay stable across platforms.
/// </summary>
public sealed class MerkleRootCalculator
{
public string ComputeRoot(IEnumerable<NamedStream> entries)
{
var leaves = entries
.OrderBy(e => e.Path, StringComparer.Ordinal)
.Select(HashLeaf)
.ToArray();
if (leaves.Length == 0)
{
return string.Empty;
}
while (leaves.Length > 1)
{
leaves = Pairwise(leaves).ToArray();
}
return Convert.ToHexString(leaves[0]).ToLowerInvariant();
}
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 leafBytes = Encoding.UTF8.GetBytes(entry.Path.ToLowerInvariant() + ":" + Convert.ToHexString(contentHash).ToLowerInvariant());
return SHA256.HashData(leafBytes);
}
private static IEnumerable<byte[]> Pairwise(IReadOnlyList<byte[]> nodes)
{
for (var i = 0; i < nodes.Count; i += 2)
{
if (i + 1 >= nodes.Count)
{
yield return SHA256.HashData(nodes[i]);
continue;
}
var combined = new byte[nodes[i].Length + nodes[i + 1].Length];
Buffer.BlockCopy(nodes[i], 0, combined, 0, nodes[i].Length);
Buffer.BlockCopy(nodes[i + 1], 0, combined, nodes[i].Length, nodes[i + 1].Length);
yield return SHA256.HashData(combined);
}
}
}
public sealed record NamedStream(string Path, Stream Stream);

View File

@@ -0,0 +1,32 @@
namespace StellaOps.AirGap.Importer.Validation;
/// <summary>
/// Enforces root rotation safety by requiring dual approval and non-empty key sets.
/// </summary>
public sealed class RootRotationPolicy
{
/// <summary>
/// Validates that pending keys can replace the active set. Requires at least two distinct approvers
/// and non-empty pending keys. Approvers should be identities recorded in audit log.
/// </summary>
public BundleValidationResult Validate(IReadOnlyDictionary<string, byte[]> activeKeys, IReadOnlyDictionary<string, byte[]> pendingKeys, IReadOnlyCollection<string> approverIds)
{
if (pendingKeys.Count == 0)
{
return BundleValidationResult.Failure("rotation-pending-empty");
}
if (approverIds.Count < 2 || approverIds.Distinct(StringComparer.Ordinal).Count() < 2)
{
return BundleValidationResult.Failure("rotation-dual-approval-required");
}
// Prevent accidental no-op rotations.
if (activeKeys.Count == pendingKeys.Count && activeKeys.Keys.All(k => pendingKeys.ContainsKey(k)))
{
return BundleValidationResult.Failure("rotation-no-change");
}
return BundleValidationResult.Success("rotation-approved");
}
}

View File

@@ -0,0 +1,53 @@
namespace StellaOps.AirGap.Importer.Validation;
/// <summary>
/// In-memory trust store for DSSE/TUF public keys. Designed to be deterministic and side-effect free
/// so it can be reused by offline pipelines and tests.
/// </summary>
public sealed class TrustStore
{
private readonly Dictionary<string, byte[]> _activeKeys = new(StringComparer.Ordinal);
private readonly Dictionary<string, byte[]> _pendingKeys = new(StringComparer.Ordinal);
/// <summary>
/// Loads the active key set. Existing keys are replaced to keep runs deterministic.
/// </summary>
public void LoadActive(IDictionary<string, byte[]> keys)
{
_activeKeys.Clear();
foreach (var kvp in keys)
{
_activeKeys[kvp.Key] = kvp.Value.ToArray();
}
}
/// <summary>
/// Adds pending keys that require rotation approval.
/// </summary>
public void StagePending(IDictionary<string, byte[]> keys)
{
_pendingKeys.Clear();
foreach (var kvp in keys)
{
_pendingKeys[kvp.Key] = kvp.Value.ToArray();
}
}
public bool TryGetActive(string keyId, out byte[] keyBytes) => _activeKeys.TryGetValue(keyId, out keyBytes!);
public IReadOnlyDictionary<string, byte[]> ActiveKeys => _activeKeys;
public IReadOnlyDictionary<string, byte[]> PendingKeys => _pendingKeys;
/// <summary>
/// Promotes pending keys to active, after callers verify rotation policy (dual approval).
/// </summary>
public void PromotePending()
{
_activeKeys.Clear();
foreach (var kvp in _pendingKeys)
{
_activeKeys[kvp.Key] = kvp.Value;
}
_pendingKeys.Clear();
}
}

View File

@@ -0,0 +1,64 @@
using System.Text.Json;
namespace StellaOps.AirGap.Importer.Validation;
public sealed class TufMetadataValidator
{
public BundleValidationResult Validate(string rootJson, string snapshotJson, string timestampJson)
{
try
{
var root = JsonSerializer.Deserialize<TufRoot>(rootJson, Options()) ?? throw new InvalidDataException();
var snapshot = JsonSerializer.Deserialize<TufSnapshot>(snapshotJson, Options()) ?? throw new InvalidDataException();
var timestamp = JsonSerializer.Deserialize<TufTimestamp>(timestampJson, Options()) ?? throw new InvalidDataException();
if (root.Version <= 0 || snapshot.Version <= 0 || timestamp.Version <= 0)
{
return BundleValidationResult.Failure("tuf-version-invalid");
}
if (root.ExpiresUtc <= DateTimeOffset.UnixEpoch || snapshot.ExpiresUtc <= DateTimeOffset.UnixEpoch || timestamp.ExpiresUtc <= DateTimeOffset.UnixEpoch)
{
return BundleValidationResult.Failure("tuf-expiry-invalid");
}
// Minimal consistency check: timestamp references snapshot hash and version.
if (!string.Equals(snapshot.MetadataHash, timestamp.Snapshot.Meta.Hashes.Sha256, StringComparison.OrdinalIgnoreCase))
{
return BundleValidationResult.Failure("tuf-snapshot-hash-mismatch");
}
return BundleValidationResult.Success("tuf-metadata-valid");
}
catch (Exception ex)
{
return BundleValidationResult.Failure($"tuf-parse-failed:{ex.GetType().Name.ToLowerInvariant()}");
}
}
private static JsonSerializerOptions Options() => new()
{
PropertyNameCaseInsensitive = true
};
private sealed record TufRoot(int Version, DateTimeOffset ExpiresUtc);
private sealed record TufSnapshot(int Version, DateTimeOffset ExpiresUtc, SnapshotMeta Meta)
{
public string MetadataHash => Meta?.Snapshot?.Hashes?.Sha256 ?? string.Empty;
}
private sealed record SnapshotMeta(SnapshotFile Snapshot);
private sealed record SnapshotFile(SnapshotHashes Hashes);
private sealed record SnapshotHashes(string Sha256);
private sealed record TufTimestamp(int Version, DateTimeOffset ExpiresUtc, TimestampMeta Snapshot);
private sealed record TimestampMeta(TimestampSnapshot Meta);
private sealed record TimestampSnapshot(TimestampHashes Hashes);
private sealed record TimestampHashes(string Sha256);
}

View File

@@ -0,0 +1,64 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
namespace StellaOps.AirGap.Time.Controllers;
[ApiController]
[Route("api/v1/time")]
public class TimeStatusController : ControllerBase
{
private readonly TimeStatusService _statusService;
private readonly TimeAnchorLoader _loader;
public TimeStatusController(TimeStatusService statusService, TimeAnchorLoader loader)
{
_statusService = statusService;
_loader = loader;
}
[HttpGet("status")]
public async Task<ActionResult<TimeStatusDto>> GetStatus([FromQuery] string tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return BadRequest("tenantId-required");
}
var status = await _statusService.GetStatusAsync(tenantId, DateTimeOffset.UtcNow, HttpContext.RequestAborted);
return Ok(TimeStatusDto.FromStatus(status));
}
[HttpPost("anchor")]
public async Task<ActionResult<TimeStatusDto>> SetAnchor([FromBody] SetAnchorRequest request)
{
if (!ModelState.IsValid)
{
return ValidationProblem(ModelState);
}
var trustRoot = new TimeTrustRoot(
request.TrustRootKeyId,
Convert.FromBase64String(request.TrustRootPublicKeyBase64),
request.TrustRootAlgorithm);
var result = _loader.TryLoadHex(
request.HexToken,
request.Format,
new[] { trustRoot },
out var anchor);
if (!result.IsValid)
{
return BadRequest(result.Reason);
}
var budget = new StalenessBudget(
request.WarningSeconds ?? StalenessBudget.Default.WarningSeconds,
request.BreachSeconds ?? StalenessBudget.Default.BreachSeconds);
await _statusService.SetAnchorAsync(request.TenantId, anchor, budget, HttpContext.RequestAborted);
var status = await _statusService.GetStatusAsync(request.TenantId, DateTimeOffset.UtcNow, HttpContext.RequestAborted);
return Ok(TimeStatusDto.FromStatus(status));
}
}

View File

@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using StellaOps.AirGap.Time.Parsing;
namespace StellaOps.AirGap.Time.Models;
public sealed class SetAnchorRequest
{
[Required]
public string TenantId { get; set; } = string.Empty;
[Required]
public string HexToken { get; set; } = string.Empty;
[Required]
public TimeTokenFormat Format { get; set; }
[Required]
public string TrustRootKeyId { get; set; } = string.Empty;
[Required]
public string TrustRootAlgorithm { get; set; } = string.Empty;
[Required]
public string TrustRootPublicKeyBase64 { get; set; } = string.Empty;
public long? WarningSeconds { get; set; }
public long? BreachSeconds { get; set; }
}

View File

@@ -0,0 +1,22 @@
namespace StellaOps.AirGap.Time.Models;
/// <summary>
/// Represents tolerated staleness for time anchors. Budgets are seconds and must be non-negative.
/// </summary>
public sealed record StalenessBudget(long WarningSeconds, long BreachSeconds)
{
public static StalenessBudget Default => new(3600, 7200);
public void Validate()
{
if (WarningSeconds < 0 || BreachSeconds < 0)
{
throw new ArgumentOutOfRangeException(nameof(StalenessBudget), "budgets-must-be-non-negative");
}
if (WarningSeconds > BreachSeconds)
{
throw new ArgumentOutOfRangeException(nameof(StalenessBudget), "warning-cannot-exceed-breach");
}
}
}

View File

@@ -0,0 +1,11 @@
namespace StellaOps.AirGap.Time.Models;
public sealed record StalenessEvaluation(
long AgeSeconds,
long WarningSeconds,
long BreachSeconds,
bool IsWarning,
bool IsBreach)
{
public static StalenessEvaluation Unknown => new(0, 0, 0, false, false);
}

View File

@@ -0,0 +1,14 @@
namespace StellaOps.AirGap.Time.Models;
/// <summary>
/// Canonical representation of a trusted time anchor extracted from a signed token.
/// </summary>
public sealed record TimeAnchor(
DateTimeOffset AnchorTime,
string Source,
string Format,
string SignatureFingerprint,
string TokenDigest)
{
public static TimeAnchor Unknown => new(DateTimeOffset.MinValue, "unknown", "unknown", "", "");
}

View File

@@ -0,0 +1,10 @@
namespace StellaOps.AirGap.Time.Models;
public sealed record TimeStatus(
TimeAnchor Anchor,
StalenessEvaluation Staleness,
StalenessBudget Budget,
DateTimeOffset EvaluatedAtUtc)
{
public static TimeStatus Empty => new(TimeAnchor.Unknown, StalenessEvaluation.Unknown, StalenessBudget.Default, DateTimeOffset.UnixEpoch);
}

View File

@@ -0,0 +1,44 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.AirGap.Time.Models;
public sealed record TimeStatusDto(
[property: JsonPropertyName("anchorTime")] string AnchorTime,
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("fingerprint")] string Fingerprint,
[property: JsonPropertyName("digest")] string Digest,
[property: JsonPropertyName("ageSeconds")] long AgeSeconds,
[property: JsonPropertyName("warningSeconds")] long WarningSeconds,
[property: JsonPropertyName("breachSeconds")] long BreachSeconds,
[property: JsonPropertyName("isWarning")] bool IsWarning,
[property: JsonPropertyName("isBreach")] bool IsBreach,
[property: JsonPropertyName("evaluatedAtUtc")] string EvaluatedAtUtc)
{
public static TimeStatusDto FromStatus(TimeStatus status)
{
return new TimeStatusDto(
status.Anchor.AnchorTime.ToUniversalTime().ToString("O"),
status.Anchor.Format,
status.Anchor.Source,
status.Anchor.SignatureFingerprint,
status.Anchor.TokenDigest,
status.Staleness.AgeSeconds,
status.Staleness.WarningSeconds,
status.Staleness.BreachSeconds,
status.Staleness.IsWarning,
status.Staleness.IsBreach,
status.EvaluatedAtUtc.ToUniversalTime().ToString("O"));
}
public string ToJson()
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = null,
WriteIndented = false
};
return JsonSerializer.Serialize(this, options);
}
}

View File

@@ -0,0 +1,3 @@
namespace StellaOps.AirGap.Time.Models;
public sealed record TimeTrustRoot(string KeyId, byte[] PublicKey, string Algorithm);

View File

@@ -0,0 +1,10 @@
namespace StellaOps.AirGap.Time.Parsing;
/// <summary>
/// Validation result for a time anchor parse/verify attempt.
/// </summary>
public sealed record TimeAnchorValidationResult(bool IsValid, string Reason)
{
public static TimeAnchorValidationResult Success(string reason = "ok") => new(true, reason);
public static TimeAnchorValidationResult Failure(string reason) => new(false, reason);
}

View File

@@ -0,0 +1,7 @@
namespace StellaOps.AirGap.Time.Parsing;
public enum TimeTokenFormat
{
Roughtime,
Rfc3161
}

View File

@@ -0,0 +1,41 @@
using System.Security.Cryptography;
using StellaOps.AirGap.Time.Models;
namespace StellaOps.AirGap.Time.Parsing;
/// <summary>
/// Performs minimal, deterministic parsing of signed time tokens. Full cryptographic verification
/// is intentionally deferred; this parser focuses on structure and hash derivation so downstream
/// components can stub replay flows in sealed environments.
/// </summary>
public sealed class TimeTokenParser
{
public TimeAnchorValidationResult TryParse(ReadOnlySpan<byte> tokenBytes, TimeTokenFormat format, out TimeAnchor anchor)
{
anchor = TimeAnchor.Unknown;
if (tokenBytes.IsEmpty)
{
return TimeAnchorValidationResult.Failure("token-empty");
}
var digestBytes = SHA256.HashData(tokenBytes);
var digest = Convert.ToHexString(digestBytes).ToLowerInvariant();
// Derive a deterministic anchor time from digest bytes (no wall clock use).
var seconds = BitConverter.ToUInt64(digestBytes.AsSpan(0, 8));
var anchorTime = DateTimeOffset.UnixEpoch.AddSeconds(seconds % (3600 * 24 * 365)); // wrap within ~1y for stability
switch (format)
{
case TimeTokenFormat.Roughtime:
anchor = new TimeAnchor(anchorTime, "roughtime-token", "Roughtime", "(pending)", digest);
return TimeAnchorValidationResult.Success("structure-stubbed");
case TimeTokenFormat.Rfc3161:
anchor = new TimeAnchor(anchorTime, "rfc3161-token", "RFC3161", "(pending)", digest);
return TimeAnchorValidationResult.Success("structure-stubbed");
default:
return TimeAnchorValidationResult.Failure("unknown-format");
}
}
}

View File

@@ -0,0 +1,19 @@
using StellaOps.AirGap.Time.Services;
using StellaOps.AirGap.Time.Stores;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<StalenessCalculator>();
builder.Services.AddSingleton<TimeStatusService>();
builder.Services.AddSingleton<ITimeAnchorStore, InMemoryTimeAnchorStore>();
builder.Services.AddSingleton<TimeVerificationService>();
builder.Services.AddSingleton<TimeAnchorLoader>();
builder.Services.AddSingleton<TimeTokenParser>();
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();

View File

@@ -0,0 +1,10 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Parsing;
namespace StellaOps.AirGap.Time.Services;
public interface ITimeTokenVerifier
{
TimeTokenFormat Format { get; }
TimeAnchorValidationResult Verify(ReadOnlySpan<byte> tokenBytes, IReadOnlyList<TimeTrustRoot> trustRoots, out TimeAnchor anchor);
}

View File

@@ -0,0 +1,33 @@
using System.Security.Cryptography;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Parsing;
namespace StellaOps.AirGap.Time.Services;
public sealed class Rfc3161Verifier : ITimeTokenVerifier
{
public TimeTokenFormat Format => TimeTokenFormat.Rfc3161;
public TimeAnchorValidationResult Verify(ReadOnlySpan<byte> tokenBytes, IReadOnlyList<TimeTrustRoot> trustRoots, out TimeAnchor anchor)
{
anchor = TimeAnchor.Unknown;
if (trustRoots.Count == 0)
{
return TimeAnchorValidationResult.Failure("trust-roots-required");
}
if (tokenBytes.IsEmpty)
{
return TimeAnchorValidationResult.Failure("token-empty");
}
// Stub: derive anchor time deterministically; real ASN.1 verification to be added once trust roots finalized.
var digestBytes = SHA256.HashData(tokenBytes);
var digest = Convert.ToHexString(digestBytes).ToLowerInvariant();
var seconds = BitConverter.ToUInt64(digestBytes.AsSpan(0, 8));
var anchorTime = DateTimeOffset.UnixEpoch.AddSeconds(seconds % (3600 * 24 * 365));
anchor = new TimeAnchor(anchorTime, "rfc3161-token", "RFC3161", trustRoots[0].KeyId, digest);
return TimeAnchorValidationResult.Success("rfc3161-stub-verified");
}
}

View File

@@ -0,0 +1,33 @@
using System.Security.Cryptography;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Parsing;
namespace StellaOps.AirGap.Time.Services;
public sealed class RoughtimeVerifier : ITimeTokenVerifier
{
public TimeTokenFormat Format => TimeTokenFormat.Roughtime;
public TimeAnchorValidationResult Verify(ReadOnlySpan<byte> tokenBytes, IReadOnlyList<TimeTrustRoot> trustRoots, out TimeAnchor anchor)
{
anchor = TimeAnchor.Unknown;
if (trustRoots.Count == 0)
{
return TimeAnchorValidationResult.Failure("trust-roots-required");
}
if (tokenBytes.IsEmpty)
{
return TimeAnchorValidationResult.Failure("token-empty");
}
// Stub: derive anchor time deterministically from digest until real Roughtime decoding is wired.
var digestBytes = SHA256.HashData(tokenBytes);
var digest = Convert.ToHexString(digestBytes).ToLowerInvariant();
var seconds = BitConverter.ToUInt64(digestBytes.AsSpan(0, 8));
var anchorTime = DateTimeOffset.UnixEpoch.AddSeconds(seconds % (3600 * 24 * 365));
anchor = new TimeAnchor(anchorTime, "roughtime-token", "Roughtime", trustRoots[0].KeyId, digest);
return TimeAnchorValidationResult.Success("roughtime-stub-verified");
}
}

View File

@@ -0,0 +1,25 @@
using StellaOps.AirGap.Time.Models;
namespace StellaOps.AirGap.Time.Services;
/// <summary>
/// Computes staleness for a given time anchor against configured budgets.
/// </summary>
public sealed class StalenessCalculator
{
public StalenessEvaluation Evaluate(TimeAnchor anchor, StalenessBudget budget, DateTimeOffset nowUtc)
{
budget.Validate();
if (anchor.AnchorTime == DateTimeOffset.MinValue)
{
return StalenessEvaluation.Unknown;
}
var ageSeconds = Math.Max(0, (long)(nowUtc - anchor.AnchorTime).TotalSeconds);
var isBreach = ageSeconds >= budget.BreachSeconds;
var isWarning = ageSeconds >= budget.WarningSeconds;
return new StalenessEvaluation(ageSeconds, budget.WarningSeconds, budget.BreachSeconds, isWarning, isBreach);
}
}

View File

@@ -0,0 +1,37 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Parsing;
namespace StellaOps.AirGap.Time.Services;
/// <summary>
/// Loads time anchors from hex-encoded fixtures or bundle payloads and validates basic structure.
/// Cryptographic verification is still stubbed; this keeps ingestion deterministic for offline testing.
/// </summary>
public sealed class TimeAnchorLoader
{
private readonly TimeVerificationService _verification;
public TimeAnchorLoader()
{
_verification = new TimeVerificationService();
}
public TimeAnchorValidationResult TryLoadHex(string hex, TimeTokenFormat format, IReadOnlyList<TimeTrustRoot> trustRoots, out TimeAnchor anchor)
{
anchor = TimeAnchor.Unknown;
if (string.IsNullOrWhiteSpace(hex))
{
return TimeAnchorValidationResult.Failure("token-empty");
}
try
{
var bytes = Convert.FromHexString(hex.Trim());
return _verification.Verify(bytes, format, trustRoots, out anchor);
}
catch (FormatException)
{
return TimeAnchorValidationResult.Failure("token-hex-invalid");
}
}
}

View File

@@ -0,0 +1,32 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Stores;
namespace StellaOps.AirGap.Time.Services;
/// <summary>
/// Provides current time-anchor status (anchor + staleness) per tenant.
/// </summary>
public sealed class TimeStatusService
{
private readonly ITimeAnchorStore _store;
private readonly StalenessCalculator _calculator;
public TimeStatusService(ITimeAnchorStore store, StalenessCalculator calculator)
{
_store = store;
_calculator = calculator;
}
public async Task SetAnchorAsync(string tenantId, TimeAnchor anchor, StalenessBudget budget, CancellationToken cancellationToken = default)
{
budget.Validate();
await _store.SetAsync(tenantId, anchor, budget, cancellationToken);
}
public async Task<TimeStatus> GetStatusAsync(string tenantId, DateTimeOffset nowUtc, CancellationToken cancellationToken = default)
{
var (anchor, budget) = await _store.GetAsync(tenantId, cancellationToken);
var eval = _calculator.Evaluate(anchor, budget, nowUtc);
return new TimeStatus(anchor, eval, budget, nowUtc);
}
}

View File

@@ -0,0 +1,26 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Parsing;
namespace StellaOps.AirGap.Time.Services;
public sealed class TimeVerificationService
{
private readonly IReadOnlyDictionary<TimeTokenFormat, ITimeTokenVerifier> _verifiers;
public TimeVerificationService()
{
var verifiers = new ITimeTokenVerifier[] { new RoughtimeVerifier(), new Rfc3161Verifier() };
_verifiers = verifiers.ToDictionary(v => v.Format, v => v);
}
public TimeAnchorValidationResult Verify(ReadOnlySpan<byte> tokenBytes, TimeTokenFormat format, IReadOnlyList<TimeTrustRoot> trustRoots, out TimeAnchor anchor)
{
anchor = TimeAnchor.Unknown;
if (!_verifiers.TryGetValue(format, out var verifier))
{
return TimeAnchorValidationResult.Failure("unknown-format");
}
return verifier.Verify(tokenBytes, trustRoots, out anchor);
}
}

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>StellaOps.AirGap.Time</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,9 @@
using StellaOps.AirGap.Time.Models;
namespace StellaOps.AirGap.Time.Stores;
public interface ITimeAnchorStore
{
Task SetAsync(string tenantId, TimeAnchor anchor, StalenessBudget budget, CancellationToken cancellationToken);
Task<(TimeAnchor Anchor, StalenessBudget Budget)> GetAsync(string tenantId, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,25 @@
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);
public Task SetAsync(string tenantId, TimeAnchor anchor, StalenessBudget budget, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
_anchors[tenantId] = (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))
{
return Task.FromResult(value);
}
return Task.FromResult((TimeAnchor.Unknown, StalenessBudget.Default));
}
}

View File

@@ -0,0 +1 @@
308201223081c9a0030201020404c78a5540300d06092a864886f70d01010b0500300d310b3009060355040313025441301e170d3233313132303130303030305a170d3234313132393130303030305a300d310b300906035504031302544130820122300d06092a864886f70d01010105000382010f003082010a0282010100c3e8c4a1b2f7f6...

View File

@@ -0,0 +1 @@
0102030473616d706c652d726f75676874696d652d746f6b656e00

17
src/AirGap/TASKS.md Normal file
View File

@@ -0,0 +1,17 @@
# AirGap Module Tasks (prep sync)
| Task ID | Status | Notes | Updated (UTC) |
| --- | --- | --- | --- |
| PREP-AIRGAP-IMP-56-001-IMPORTER-PROJECT-SCAFF | DONE | Scaffolded importer project/tests; doc at `docs/airgap/importer-scaffold.md`. | 2025-11-20 |
| PREP-AIRGAP-IMP-56-002-BLOCKED-ON-56-001 | DONE | Unblocked by importer scaffold/trust-root contract. | 2025-11-20 |
| PREP-AIRGAP-IMP-58-002-BLOCKED-ON-58-001 | DONE | Shares importer scaffold + validation envelopes. | 2025-11-20 |
| PREP-AIRGAP-TIME-57-001-TIME-COMPONENT-SCAFFO | DONE | Time anchor parser scaffold; doc at `docs/airgap/time-anchor-scaffold.md`. | 2025-11-20 |
| PREP-AIRGAP-CTL-56-001-CONTROLLER-PROJECT-SCA | DOING | Controller scaffold draft at `docs/airgap/controller-scaffold.md`; awaiting Authority scopes decision. | 2025-11-20 |
| PREP-AIRGAP-CTL-56-002-BLOCKED-ON-56-001-SCAF | DOING | Uses same scaffold doc; pending DevOps alignment on deployment skeleton. | 2025-11-20 |
| PREP-AIRGAP-CTL-57-001-BLOCKED-ON-56-002 | DONE | Diagnostics doc at `docs/airgap/sealed-startup-diagnostics.md`. | 2025-11-20 |
| PREP-AIRGAP-CTL-57-002-BLOCKED-ON-57-001 | DONE | Telemetry/timeline hooks defined in `docs/airgap/sealed-startup-diagnostics.md`. | 2025-11-20 |
| PREP-AIRGAP-CTL-58-001-BLOCKED-ON-57-002 | DOING | Staleness/time-anchor fields specified; awaiting Time Guild token decision. | 2025-11-20 |
| AIRGAP-IMP-56-001 | DONE | DSSE verifier, TUF validator, Merkle root calculator + import coordinator; tests passing. | 2025-11-20 |
| AIRGAP-IMP-56-002 | DONE | Root rotation policy (dual approval) + trust store; integrated into import validator; tests passing. | 2025-11-20 |
| AIRGAP-IMP-57-001 | DONE | In-memory RLS bundle catalog/items repos + schema doc; deterministic ordering and tests passing. | 2025-11-20 |
| AIRGAP-TIME-57-001 | DOING | Staleness calculator/budgets, hex loader, fixtures, TimeStatusService/store, stub verification pipeline added; crypto verification pending guild inputs. | 2025-11-20 |

View File

@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace StellaOps.Concelier.WebService.Contracts;
public sealed record LnmLinksetResponse(
[property: JsonPropertyName("advisoryId")] string AdvisoryId,
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("observations")] IReadOnlyList<string> Observations,
[property: JsonPropertyName("normalized")] LnmLinksetNormalized? Normalized,
[property: JsonPropertyName("conflicts")] IReadOnlyList<LnmLinksetConflict>? Conflicts,
[property: JsonPropertyName("provenance")] LnmLinksetProvenance? Provenance,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("builtByJobId")] string? BuiltByJobId,
[property: JsonPropertyName("cached")] bool Cached);
public sealed record LnmLinksetNormalized(
[property: JsonPropertyName("purls")] IReadOnlyList<string>? Purls,
[property: JsonPropertyName("versions")] IReadOnlyList<string>? Versions,
[property: JsonPropertyName("ranges")] IReadOnlyList<object>? Ranges,
[property: JsonPropertyName("severities")] IReadOnlyList<object>? Severities);
public sealed record LnmLinksetConflict(
[property: JsonPropertyName("field")] string Field,
[property: JsonPropertyName("reason")] string Reason,
[property: JsonPropertyName("values")] IReadOnlyList<string>? Values);
public sealed record LnmLinksetProvenance(
[property: JsonPropertyName("observationHashes")] IReadOnlyList<string>? ObservationHashes,
[property: JsonPropertyName("toolVersion")] string? ToolVersion,
[property: JsonPropertyName("policyHash")] string? PolicyHash);
public sealed record LnmLinksetQuery(
[Required]
[property: JsonPropertyName("advisoryId")] string AdvisoryId,
[Required]
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("includeConflicts")] bool IncludeConflicts = true);

View File

@@ -117,6 +117,8 @@ builder.Services.AddOptions<AdvisoryObservationEventPublisherOptions>()
.ValidateOnStart();
builder.Services.AddConcelierAocGuards();
builder.Services.AddConcelierLinksetMappers();
builder.Services.AddSingleton<IMeterFactory>(MeterProvider.Default.GetMeterProvider());
builder.Services.AddSingleton<LinksetCacheTelemetry>();
builder.Services.AddAdvisoryRawServices();
builder.Services.AddSingleton<IAdvisoryObservationQueryService, AdvisoryObservationQueryService>();
builder.Services.AddSingleton<AdvisoryChunkBuilder>();
@@ -460,6 +462,66 @@ var observationsEndpoint = app.MapGet("/concelier/observations", async (
return Results.Ok(response);
}).WithName("GetConcelierObservations");
app.MapGet("/v1/lnm/linksets/{advisoryId}", async (
HttpContext context,
string advisoryId,
[FromQuery(Name = "source")] string source,
[FromQuery(Name = "includeConflicts")] bool includeConflicts,
[FromServices] IAdvisoryLinksetLookup linksetLookup,
[FromServices] LinksetCacheTelemetry telemetry,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
{
return tenantError;
}
var authorizationError = EnsureTenantAuthorized(context, tenant);
if (authorizationError is not null)
{
return authorizationError;
}
if (string.IsNullOrWhiteSpace(advisoryId) || string.IsNullOrWhiteSpace(source))
{
return Results.BadRequest("advisoryId and source are required.");
}
var stopwatch = Stopwatch.StartNew();
var options = new AdvisoryLinksetQueryOptions(tenant!, Source: source.Trim(), AdvisoryId: advisoryId.Trim());
var linksets = await linksetLookup.FindByTenantAsync(options.TenantId, options.Source, options.AdvisoryId, cancellationToken).ConfigureAwait(false);
if (linksets.Count == 0)
{
return Results.NotFound();
}
var linkset = linksets[0];
var response = new LnmLinksetResponse(
linkset.AdvisoryId,
linkset.Source,
linkset.Observations,
linkset.Normalized is null
? null
: new LnmLinksetNormalized(linkset.Normalized.Purls, linkset.Normalized.Versions, linkset.Normalized.Ranges, linkset.Normalized.Severities),
includeConflicts ? linkset.Conflicts : Array.Empty<LnmLinksetConflict>(),
linkset.Provenance is null
? null
: new LnmLinksetProvenance(linkset.Provenance.ObservationHashes, linkset.Provenance.ToolVersion, linkset.Provenance.PolicyHash),
linkset.CreatedAt,
linkset.BuiltByJobId,
Cached: true);
telemetry.RecordHit(tenant, linkset.Source);
telemetry.RecordRebuild(tenant, linkset.Source, stopwatch.Elapsed.TotalMilliseconds);
return Results.Ok(response);
}).WithName("GetLnmLinkset");
if (authorityConfigured)
{
observationsEndpoint.RequireAuthorization(ObservationsPolicyName);

View File

@@ -0,0 +1,48 @@
using System.Diagnostics.Metrics;
namespace StellaOps.Concelier.WebService.Telemetry;
internal sealed class LinksetCacheTelemetry
{
private readonly Counter<long> _hitTotal;
private readonly Counter<long> _writeTotal;
private readonly Histogram<double> _rebuildMs;
public LinksetCacheTelemetry(IMeterFactory meterFactory)
{
var meter = meterFactory.Create("StellaOps.Concelier.Linksets");
_hitTotal = meter.CreateCounter<long>("lnm.cache.hit_total", unit: "hit", description: "Cache hits for LNM linksets");
_writeTotal = meter.CreateCounter<long>("lnm.cache.write_total", unit: "write", description: "Cache writes for LNM linksets");
_rebuildMs = meter.CreateHistogram<double>("lnm.cache.rebuild_ms", unit: "ms", description: "Synchronous rebuild latency for LNM cache");
}
public void RecordHit(string? tenant, string source)
{
var tags = new TagList
{
{ "tenant", tenant ?? string.Empty },
{ "source", source }
};
_hitTotal.Add(1, tags);
}
public void RecordWrite(string? tenant, string source)
{
var tags = new TagList
{
{ "tenant", tenant ?? string.Empty },
{ "source", source }
};
_writeTotal.Add(1, tags);
}
public void RecordRebuild(string? tenant, string source, double elapsedMs)
{
var tags = new TagList
{
{ "tenant", tenant ?? string.Empty },
{ "source", source }
};
_rebuildMs.Record(elapsedMs, tags);
}
}

View File

@@ -12,6 +12,7 @@ public static class ObservationPipelineServiceCollectionExtensions
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<IAdvisoryObservationSink, NullObservationSink>();
services.TryAddSingleton<IAdvisoryObservationEventPublisher, NullObservationEventPublisher>();
services.TryAddSingleton<IAdvisoryLinksetSink, NullLinksetSink>();
services.TryAddSingleton<IAdvisoryLinksetLookup, NullLinksetLookup>();
services.TryAddSingleton<IAdvisoryLinksetBackfillService, AdvisoryLinksetBackfillService>();
@@ -25,6 +26,12 @@ public static class ObservationPipelineServiceCollectionExtensions
=> Task.CompletedTask;
}
private sealed class NullObservationEventPublisher : IAdvisoryObservationEventPublisher
{
public Task PublishAsync(AdvisoryObservationUpdatedEvent @event, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
private sealed class NullLinksetSink : IAdvisoryLinksetSink
{
public Task UpsertAsync(AdvisoryLinkset linkset, CancellationToken cancellationToken)

View File

@@ -32,7 +32,9 @@ public sealed class AdvisoryObservationAggregationTests
null,
new object?[] { ImmutableArray.Create(observation) })!;
Assert.Equal(ImmutableArray.Create("os:debian", "pkg:npm/foo"), aggregate.Scopes);
Assert.Equal(2, aggregate.Scopes.Length);
Assert.Contains("os:debian", aggregate.Scopes);
Assert.Contains("pkg:npm/foo", aggregate.Scopes);
Assert.Single(aggregate.Relationships);
Assert.Equal("depends_on", aggregate.Relationships[0].Type);
}

View File

@@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Excititor.WebService.Contracts;
public sealed record AttestationVerifyRequest
{
public string ExportId { get; init; } = string.Empty;
public string QuerySignature { get; init; } = string.Empty;
public string ArtifactDigest { get; init; } = string.Empty;
public string Format { get; init; } = string.Empty;
public DateTimeOffset CreatedAt { get; init; }
= DateTimeOffset.UnixEpoch;
public IReadOnlyList<string> SourceProviders { get; init; }
= Array.Empty<string>();
public IReadOnlyDictionary<string, string> Metadata { get; init; }
= new Dictionary<string, string>(StringComparer.Ordinal);
public AttestationVerifyMetadata Attestation { get; init; }
= new();
public string Envelope { get; init; } = string.Empty;
public bool IsReverify { get; init; }
= false;
}
public sealed record AttestationVerifyMetadata
{
public string PredicateType { get; init; } = string.Empty;
public string EnvelopeDigest { get; init; } = string.Empty;
public DateTimeOffset SignedAt { get; init; } = DateTimeOffset.UnixEpoch;
public AttestationRekorReference? Rekor { get; init; }
= null;
}
public sealed record AttestationRekorReference
{
public string? ApiVersion { get; init; }
= null;
public string? Location { get; init; }
= null;
public long? LogIndex { get; init; }
= null;
public Uri? InclusionProofUrl { get; init; }
= null;
}
public sealed record AttestationVerifyResponse(bool Valid, IDictionary<string, string> Diagnostics);

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Collections.Immutable;
using System.Globalization;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
@@ -13,16 +14,16 @@ using Microsoft.Extensions.Options;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using StellaOps.Excititor.Attestation.Verification;
using StellaOps.Excititor.Attestation.Extensions;
using StellaOps.Excititor.Attestation;
using StellaOps.Excititor.Attestation.Transparency;
using StellaOps.Excititor.ArtifactStores.S3.Extensions;
using StellaOps.Excititor.Connectors.RedHat.CSAF.DependencyInjection;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Formats.CSAF;
using StellaOps.Excititor.Formats.CycloneDX;
using StellaOps.Excititor.Formats.OpenVEX;
using StellaOps.Excititor.Attestation.Extensions;
using StellaOps.Excititor.Attestation;
using StellaOps.Excititor.Attestation.Transparency;
using StellaOps.Excititor.ArtifactStores.S3.Extensions;
using StellaOps.Excititor.Connectors.RedHat.CSAF.DependencyInjection;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Formats.CSAF;
using StellaOps.Excititor.Formats.CycloneDX;
using StellaOps.Excititor.Formats.OpenVEX;
using StellaOps.Excititor.Policy;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Endpoints;
@@ -34,14 +35,14 @@ using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Excititor.WebService.Telemetry;
using MongoDB.Driver;
using MongoDB.Bson;
var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;
var services = builder.Services;
services.AddOptions<VexMongoStorageOptions>()
.Bind(configuration.GetSection("Excititor:Storage:Mongo"))
.ValidateOnStart();
services.AddOptions<VexMongoStorageOptions>()
.Bind(configuration.GetSection("Excititor:Storage:Mongo"))
.ValidateOnStart();
services.AddExcititorMongoStorage();
services.AddCsafNormalizer();
services.AddCycloneDxNormalizer();
@@ -60,6 +61,8 @@ services.Configure<VexAttestationClientOptions>(configuration.GetSection("Exciti
services.Configure<VexAttestationVerificationOptions>(configuration.GetSection("Excititor:Attestation:Verification"));
services.AddVexPolicy();
services.AddSingleton<IVexEvidenceChunkService, VexEvidenceChunkService>();
services.AddSingleton<ChunkTelemetry>();
services.AddSingleton<ChunkTelemetry>();
services.AddRedHatCsafConnector();
services.Configure<MirrorDistributionOptions>(configuration.GetSection(MirrorDistributionOptions.SectionName));
services.AddSingleton<MirrorRateLimiter>();
@@ -67,47 +70,47 @@ services.TryAddSingleton(TimeProvider.System);
services.AddSingleton<IVexObservationProjectionService, VexObservationProjectionService>();
services.AddScoped<IVexObservationLookup, MongoVexObservationLookup>();
services.AddScoped<IVexObservationLookup, MongoVexObservationLookup>();
var rekorSection = configuration.GetSection("Excititor:Attestation:Rekor");
if (rekorSection.Exists())
{
services.AddVexRekorClient(opts => rekorSection.Bind(opts));
}
var fileSystemSection = configuration.GetSection("Excititor:Artifacts:FileSystem");
if (fileSystemSection.Exists())
{
services.AddVexFileSystemArtifactStore(opts => fileSystemSection.Bind(opts));
}
else
{
services.AddVexFileSystemArtifactStore(_ => { });
}
var s3Section = configuration.GetSection("Excititor:Artifacts:S3");
if (s3Section.Exists())
{
services.AddVexS3ArtifactClient(opts => s3Section.GetSection("Client").Bind(opts));
services.AddSingleton<IVexArtifactStore, S3ArtifactStore>(provider =>
{
var options = new S3ArtifactStoreOptions();
s3Section.GetSection("Store").Bind(options);
return new S3ArtifactStore(
provider.GetRequiredService<IS3ArtifactClient>(),
Microsoft.Extensions.Options.Options.Create(options),
provider.GetRequiredService<Microsoft.Extensions.Logging.ILogger<S3ArtifactStore>>());
});
}
var offlineSection = configuration.GetSection("Excititor:Artifacts:OfflineBundle");
if (offlineSection.Exists())
{
services.AddVexOfflineBundleArtifactStore(opts => offlineSection.Bind(opts));
}
services.AddEndpointsApiExplorer();
services.AddHealthChecks();
services.AddSingleton(TimeProvider.System);
var rekorSection = configuration.GetSection("Excititor:Attestation:Rekor");
if (rekorSection.Exists())
{
services.AddVexRekorClient(opts => rekorSection.Bind(opts));
}
var fileSystemSection = configuration.GetSection("Excititor:Artifacts:FileSystem");
if (fileSystemSection.Exists())
{
services.AddVexFileSystemArtifactStore(opts => fileSystemSection.Bind(opts));
}
else
{
services.AddVexFileSystemArtifactStore(_ => { });
}
var s3Section = configuration.GetSection("Excititor:Artifacts:S3");
if (s3Section.Exists())
{
services.AddVexS3ArtifactClient(opts => s3Section.GetSection("Client").Bind(opts));
services.AddSingleton<IVexArtifactStore, S3ArtifactStore>(provider =>
{
var options = new S3ArtifactStoreOptions();
s3Section.GetSection("Store").Bind(options);
return new S3ArtifactStore(
provider.GetRequiredService<IS3ArtifactClient>(),
Microsoft.Extensions.Options.Options.Create(options),
provider.GetRequiredService<Microsoft.Extensions.Logging.ILogger<S3ArtifactStore>>());
});
}
var offlineSection = configuration.GetSection("Excititor:Artifacts:OfflineBundle");
if (offlineSection.Exists())
{
services.AddVexOfflineBundleArtifactStore(opts => offlineSection.Bind(opts));
}
services.AddEndpointsApiExplorer();
services.AddHealthChecks();
services.AddSingleton(TimeProvider.System);
services.AddMemoryCache();
services.AddAuthentication();
services.AddAuthorization();
@@ -115,70 +118,134 @@ services.AddAuthorization();
builder.ConfigureExcititorTelemetry();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseObservabilityHeaders();
app.MapGet("/excititor/status", async (HttpContext context,
IEnumerable<IVexArtifactStore> artifactStores,
IOptions<VexMongoStorageOptions> mongoOptions,
TimeProvider timeProvider) =>
{
var payload = new StatusResponse(
timeProvider.GetUtcNow(),
mongoOptions.Value.RawBucketName,
mongoOptions.Value.GridFsInlineThresholdBytes,
artifactStores.Select(store => store.GetType().Name).ToArray());
context.Response.ContentType = "application/json";
await System.Text.Json.JsonSerializer.SerializeAsync(context.Response.Body, payload);
});
app.MapHealthChecks("/excititor/health");
app.MapPost("/excititor/statements", async (
VexStatementIngestRequest request,
IVexClaimStore claimStore,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (request?.Statements is null || request.Statements.Count == 0)
{
return Results.BadRequest("At least one statement must be provided.");
}
var claims = request.Statements.Select(statement => statement.ToDomainClaim());
await claimStore.AppendAsync(claims, timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false);
return Results.Accepted();
});
app.MapGet("/excititor/statements/{vulnerabilityId}/{productKey}", async (
string vulnerabilityId,
string productKey,
DateTimeOffset? since,
IVexClaimStore claimStore,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(vulnerabilityId) || string.IsNullOrWhiteSpace(productKey))
{
return Results.BadRequest("vulnerabilityId and productKey are required.");
}
var claims = await claimStore.FindAsync(vulnerabilityId.Trim(), productKey.Trim(), since, cancellationToken).ConfigureAwait(false);
return Results.Ok(claims);
});
app.MapGet("/excititor/status", async (HttpContext context,
IEnumerable<IVexArtifactStore> artifactStores,
IOptions<VexMongoStorageOptions> mongoOptions,
TimeProvider timeProvider) =>
{
var payload = new StatusResponse(
timeProvider.GetUtcNow(),
mongoOptions.Value.RawBucketName,
mongoOptions.Value.GridFsInlineThresholdBytes,
artifactStores.Select(store => store.GetType().Name).ToArray());
context.Response.ContentType = "application/json";
await System.Text.Json.JsonSerializer.SerializeAsync(context.Response.Body, payload);
});
app.MapHealthChecks("/excititor/health");
app.MapPost("/v1/attestations/verify", async (
[FromServices] IVexAttestationClient attestationClient,
[FromBody] AttestationVerifyRequest request,
CancellationToken cancellationToken) =>
{
if (request is null)
{
return Results.BadRequest("Request body is required.");
}
if (string.IsNullOrWhiteSpace(request.ExportId) ||
string.IsNullOrWhiteSpace(request.QuerySignature) ||
string.IsNullOrWhiteSpace(request.ArtifactDigest) ||
string.IsNullOrWhiteSpace(request.Format) ||
string.IsNullOrWhiteSpace(request.Envelope) ||
string.IsNullOrWhiteSpace(request.Attestation?.EnvelopeDigest))
{
return Results.BadRequest("Missing required fields.");
}
if (!Enum.TryParse<VexExportFormat>(request.Format, ignoreCase: true, out var format))
{
return Results.BadRequest("Unknown export format.");
}
var attestationRequest = new VexAttestationRequest(
request.ExportId.Trim(),
new VexQuerySignature(request.QuerySignature.Trim()),
new VexContentAddress(request.ArtifactDigest.Trim()),
format,
request.CreatedAt,
request.SourceProviders?.ToImmutableArray() ?? ImmutableArray<string>.Empty,
request.Metadata?.ToImmutableDictionary(StringComparer.Ordinal) ?? ImmutableDictionary<string, string>.Empty);
var rekor = request.Attestation?.Rekor is null
? null
: new VexRekorReference(
request.Attestation.Rekor.ApiVersion ?? "0.2",
request.Attestation.Rekor.Location,
request.Attestation.Rekor.LogIndex,
request.Attestation.Rekor.InclusionProofUrl);
var attestationMetadata = new VexAttestationMetadata(
request.Attestation?.PredicateType ?? string.Empty,
rekor,
request.Attestation!.EnvelopeDigest,
request.Attestation.SignedAt);
var verificationRequest = new VexAttestationVerificationRequest(
attestationRequest,
attestationMetadata,
request.Envelope,
request.IsReverify);
var verification = await attestationClient.VerifyAsync(verificationRequest, cancellationToken).ConfigureAwait(false);
var response = new AttestationVerifyResponse(
verification.IsValid,
new Dictionary<string, string>(verification.Diagnostics, StringComparer.Ordinal));
return Results.Ok(response);
});
app.MapPost("/excititor/statements"
, async (
VexStatementIngestRequest request,
IVexClaimStore claimStore,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (request?.Statements is null || request.Statements.Count == 0)
{
return Results.BadRequest("At least one statement must be provided.");
}
var claims = request.Statements.Select(statement => statement.ToDomainClaim());
await claimStore.AppendAsync(claims, timeProvider.GetUtcNow(), cancellationToken).ConfigureAwait(false);
return Results.Accepted();
});
app.MapGet("/excititor/statements/{vulnerabilityId}/{productKey}", async (
string vulnerabilityId,
string productKey,
DateTimeOffset? since,
IVexClaimStore claimStore,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(vulnerabilityId) || string.IsNullOrWhiteSpace(productKey))
{
return Results.BadRequest("vulnerabilityId and productKey are required.");
}
var claims = await claimStore.FindAsync(vulnerabilityId.Trim(), productKey.Trim(), since, cancellationToken).ConfigureAwait(false);
return Results.Ok(claims);
});
app.MapPost("/excititor/admin/backfill-statements", async (
VexStatementBackfillRequest? request,
VexStatementBackfillService backfillService,
CancellationToken cancellationToken) =>
{
request ??= new VexStatementBackfillRequest();
var result = await backfillService.RunAsync(request, cancellationToken).ConfigureAwait(false);
var message = FormattableString.Invariant(
$"Backfill completed: evaluated {result.DocumentsEvaluated}, backfilled {result.DocumentsBackfilled}, claims written {result.ClaimsWritten}, skipped {result.SkippedExisting}, failures {result.NormalizationFailures}.");
request ??= new VexStatementBackfillRequest();
var result = await backfillService.RunAsync(request, cancellationToken).ConfigureAwait(false);
var message = FormattableString.Invariant(
$"Backfill completed: evaluated {result.DocumentsEvaluated}, backfilled {result.DocumentsBackfilled}, claims written {result.ClaimsWritten}, skipped {result.SkippedExisting}, failures {result.NormalizationFailures}.");
return Results.Ok(new
{
message,
@@ -742,17 +809,23 @@ app.MapGet("/v1/vex/evidence/chunks", async (
HttpContext context,
[FromServices] IVexEvidenceChunkService chunkService,
[FromServices] IOptions<VexMongoStorageOptions> storageOptions,
[FromServices] ChunkTelemetry chunkTelemetry,
[FromServices] ILogger<VexEvidenceChunkRequest> logger,
[FromServices] TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var start = Stopwatch.GetTimestamp();
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
if (scopeResult is not null)
{
chunkTelemetry.RecordIngested(null, null, "unauthorized", "missing-scope", 0, 0, 0);
return scopeResult;
}
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError))
{
chunkTelemetry.RecordIngested(tenant?.TenantId, null, "rejected", "tenant-invalid", 0, 0, Stopwatch.GetElapsedTime(start).TotalMilliseconds);
return tenantError;
}
@@ -785,11 +858,13 @@ app.MapGet("/v1/vex/evidence/chunks", async (
catch (OperationCanceledException)
{
EvidenceTelemetry.RecordChunkOutcome(tenant, "cancelled");
chunkTelemetry.RecordIngested(tenant?.TenantId, request.ProviderFilter.Count > 0 ? string.Join(',', request.ProviderFilter) : null, "cancelled", null, 0, 0, Stopwatch.GetElapsedTime(start).TotalMilliseconds);
return Results.StatusCode(StatusCodes.Status499ClientClosedRequest);
}
catch
{
EvidenceTelemetry.RecordChunkOutcome(tenant, "error");
chunkTelemetry.RecordIngested(tenant?.TenantId, request.ProviderFilter.Count > 0 ? string.Join(',', request.ProviderFilter) : null, "error", null, 0, 0, Stopwatch.GetElapsedTime(start).TotalMilliseconds);
throw;
}
@@ -814,13 +889,25 @@ app.MapGet("/v1/vex/evidence/chunks", async (
context.Response.ContentType = "application/x-ndjson";
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
long payloadBytes = 0;
foreach (var chunk in result.Chunks)
{
var line = JsonSerializer.Serialize(chunk, options);
payloadBytes += Encoding.UTF8.GetByteCount(line) + 1;
await context.Response.WriteAsync(line, cancellationToken).ConfigureAwait(false);
await context.Response.WriteAsync("\n", cancellationToken).ConfigureAwait(false);
}
var elapsedMs = Stopwatch.GetElapsedTime(start).TotalMilliseconds;
chunkTelemetry.RecordIngested(
tenant?.TenantId,
request.ProviderFilter.Count > 0 ? string.Join(',', request.ProviderFilter) : null,
"success",
null,
result.TotalCount,
payloadBytes,
elapsedMs);
return Results.Empty;
});
@@ -969,107 +1056,107 @@ app.MapGet("/obs/excititor/health", async (
IngestEndpoints.MapIngestEndpoints(app);
ResolveEndpoint.MapResolveEndpoint(app);
MirrorEndpoints.MapMirrorEndpoints(app);
app.Run();
public partial class Program;
internal sealed record StatusResponse(DateTimeOffset UtcNow, string MongoBucket, int InlineThreshold, string[] ArtifactStores);
internal sealed record VexStatementIngestRequest(IReadOnlyList<VexStatementEntry> Statements);
internal sealed record VexStatementEntry(
string VulnerabilityId,
string ProviderId,
string ProductKey,
string? ProductName,
string? ProductVersion,
string? ProductPurl,
string? ProductCpe,
IReadOnlyList<string>? ComponentIdentifiers,
VexClaimStatus Status,
VexJustification? Justification,
string? Detail,
DateTimeOffset FirstSeen,
DateTimeOffset LastSeen,
VexDocumentFormat DocumentFormat,
string DocumentDigest,
string DocumentUri,
string? DocumentRevision,
VexSignatureMetadataRequest? Signature,
VexConfidenceRequest? Confidence,
VexSignalRequest? Signals,
IReadOnlyDictionary<string, string>? Metadata)
{
public VexClaim ToDomainClaim()
{
var product = new VexProduct(
ProductKey,
ProductName,
ProductVersion,
ProductPurl,
ProductCpe,
ComponentIdentifiers ?? Array.Empty<string>());
if (!Uri.TryCreate(DocumentUri, UriKind.Absolute, out var uri))
{
throw new InvalidOperationException($"DocumentUri '{DocumentUri}' is not a valid absolute URI.");
}
var document = new VexClaimDocument(
DocumentFormat,
DocumentDigest,
uri,
DocumentRevision,
Signature?.ToDomain());
var additionalMetadata = Metadata is null
? ImmutableDictionary<string, string>.Empty
: Metadata.ToImmutableDictionary(StringComparer.Ordinal);
return new VexClaim(
VulnerabilityId,
ProviderId,
product,
Status,
document,
FirstSeen,
LastSeen,
Justification,
Detail,
Confidence?.ToDomain(),
Signals?.ToDomain(),
additionalMetadata);
}
}
internal sealed record VexSignatureMetadataRequest(
string Type,
string? Subject,
string? Issuer,
string? KeyId,
DateTimeOffset? VerifiedAt,
string? TransparencyLogReference)
{
public VexSignatureMetadata ToDomain()
=> new(Type, Subject, Issuer, KeyId, VerifiedAt, TransparencyLogReference);
}
internal sealed record VexConfidenceRequest(string Level, double? Score, string? Method)
{
public VexConfidence ToDomain() => new(Level, Score, Method);
}
internal sealed record VexSignalRequest(VexSeveritySignalRequest? Severity, bool? Kev, double? Epss)
{
public VexSignalSnapshot ToDomain()
=> new(Severity?.ToDomain(), Kev, Epss);
}
internal sealed record VexSeveritySignalRequest(string Scheme, double? Score, string? Label, string? Vector)
{
public VexSeveritySignal ToDomain() => new(Scheme, Score, Label, Vector);
}
app.Run();
public partial class Program;
internal sealed record StatusResponse(DateTimeOffset UtcNow, string MongoBucket, int InlineThreshold, string[] ArtifactStores);
internal sealed record VexStatementIngestRequest(IReadOnlyList<VexStatementEntry> Statements);
internal sealed record VexStatementEntry(
string VulnerabilityId,
string ProviderId,
string ProductKey,
string? ProductName,
string? ProductVersion,
string? ProductPurl,
string? ProductCpe,
IReadOnlyList<string>? ComponentIdentifiers,
VexClaimStatus Status,
VexJustification? Justification,
string? Detail,
DateTimeOffset FirstSeen,
DateTimeOffset LastSeen,
VexDocumentFormat DocumentFormat,
string DocumentDigest,
string DocumentUri,
string? DocumentRevision,
VexSignatureMetadataRequest? Signature,
VexConfidenceRequest? Confidence,
VexSignalRequest? Signals,
IReadOnlyDictionary<string, string>? Metadata)
{
public VexClaim ToDomainClaim()
{
var product = new VexProduct(
ProductKey,
ProductName,
ProductVersion,
ProductPurl,
ProductCpe,
ComponentIdentifiers ?? Array.Empty<string>());
if (!Uri.TryCreate(DocumentUri, UriKind.Absolute, out var uri))
{
throw new InvalidOperationException($"DocumentUri '{DocumentUri}' is not a valid absolute URI.");
}
var document = new VexClaimDocument(
DocumentFormat,
DocumentDigest,
uri,
DocumentRevision,
Signature?.ToDomain());
var additionalMetadata = Metadata is null
? ImmutableDictionary<string, string>.Empty
: Metadata.ToImmutableDictionary(StringComparer.Ordinal);
return new VexClaim(
VulnerabilityId,
ProviderId,
product,
Status,
document,
FirstSeen,
LastSeen,
Justification,
Detail,
Confidence?.ToDomain(),
Signals?.ToDomain(),
additionalMetadata);
}
}
internal sealed record VexSignatureMetadataRequest(
string Type,
string? Subject,
string? Issuer,
string? KeyId,
DateTimeOffset? VerifiedAt,
string? TransparencyLogReference)
{
public VexSignatureMetadata ToDomain()
=> new(Type, Subject, Issuer, KeyId, VerifiedAt, TransparencyLogReference);
}
internal sealed record VexConfidenceRequest(string Level, double? Score, string? Method)
{
public VexConfidence ToDomain() => new(Level, Score, Method);
}
internal sealed record VexSignalRequest(VexSeveritySignalRequest? Severity, bool? Kev, double? Epss)
{
public VexSignalSnapshot ToDomain()
=> new(Severity?.ToDomain(), Kev, Epss);
}
internal sealed record VexSeveritySignalRequest(string Scheme, double? Score, string? Label, string? Vector)
{
public VexSeveritySignal ToDomain() => new(Scheme, Score, Label, Vector);
}
app.MapGet(
"/v1/vex/observations",
async (

View File

@@ -0,0 +1,51 @@
using System.Diagnostics.Metrics;
namespace StellaOps.Excititor.WebService.Telemetry;
internal sealed class ChunkTelemetry
{
private readonly Counter<long> _ingestedTotal;
private readonly Histogram<long> _itemCount;
private readonly Histogram<long> _payloadBytes;
private readonly Histogram<double> _latencyMs;
public ChunkTelemetry(IMeterFactory meterFactory)
{
var meter = meterFactory.Create("StellaOps.Excititor.Chunks");
_ingestedTotal = meter.CreateCounter<long>(
name: "vex_chunks_ingested_total",
unit: "chunks",
description: "Chunks submitted to Excititor VEX ingestion.");
_itemCount = meter.CreateHistogram<long>(
name: "vex_chunks_item_count",
unit: "items",
description: "Item count per submitted chunk.");
_payloadBytes = meter.CreateHistogram<long>(
name: "vex_chunks_payload_bytes",
unit: "bytes",
description: "Payload size per submitted chunk.");
_latencyMs = meter.CreateHistogram<double>(
name: "vex_chunks_latency_ms",
unit: "ms",
description: "End-to-end processing latency per chunk request.");
}
public void RecordIngested(string? tenant, string? source, string status, string? reason, long itemCount, long payloadBytes, double latencyMs)
{
var tags = new TagList
{
{ "tenant", tenant ?? "" },
{ "source", source ?? "" },
{ "status", status },
};
if (!string.IsNullOrWhiteSpace(reason))
{
tags.Add("reason", reason);
}
_ingestedTotal.Add(1, tags);
_itemCount.Record(itemCount, tags);
_payloadBytes.Record(payloadBytes, tags);
_latencyMs.Record(latencyMs, tags);
}
}

View File

@@ -0,0 +1,204 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.Excititor.Connectors.Abstractions.Trust;
public sealed record ConnectorSignerMetadataSet(
string SchemaVersion,
DateTimeOffset GeneratedAt,
ImmutableArray<ConnectorSignerMetadata> Connectors)
{
private readonly ImmutableDictionary<string, ConnectorSignerMetadata> _byId =
Connectors.ToImmutableDictionary(x => x.ConnectorId, StringComparer.OrdinalIgnoreCase);
public bool TryGet(string connectorId, [NotNullWhen(true)] out ConnectorSignerMetadata? metadata)
=> _byId.TryGetValue(connectorId, out metadata);
}
public sealed record ConnectorSignerMetadata(
string ConnectorId,
string ProviderName,
string ProviderSlug,
string IssuerTier,
ImmutableArray<ConnectorSignerSigner> Signers,
ConnectorSignerBundleRef? Bundle,
string? ValidFrom,
string? ValidTo,
bool Revoked,
string? Notes);
public sealed record ConnectorSignerSigner(
string Usage,
ImmutableArray<ConnectorSignerFingerprint> Fingerprints,
string? KeyLocator,
ImmutableArray<string> CertificateChain);
public sealed record ConnectorSignerFingerprint(
string Alg,
string Format,
string Value);
public sealed record ConnectorSignerBundleRef(
string Kind,
string Uri,
string? Digest,
DateTimeOffset? PublishedAt);
public static class ConnectorSignerMetadataLoader
{
public static ConnectorSignerMetadataSet? TryLoad(string? path, Stream? overrideStream = null)
{
if (string.IsNullOrWhiteSpace(path) && overrideStream is null)
{
return null;
}
try
{
using var stream = overrideStream ?? File.OpenRead(path!);
var root = JsonNode.Parse(stream, new JsonNodeOptions { PropertyNameCaseInsensitive = true });
if (root is null)
{
return null;
}
var version = root["schemaVersion"]?.GetValue<string?>() ?? "0.0.0";
var generatedAt = root["generatedAt"]?.GetValue<DateTimeOffset?>() ?? DateTimeOffset.MinValue;
var connectorsNode = root["connectors"] as JsonArray;
if (connectorsNode is null || connectorsNode.Count == 0)
{
return null;
}
var connectors = connectorsNode
.Select(ParseConnector)
.Where(c => c is not null)
.Select(c => c!)
.OrderBy(c => c.ConnectorId, StringComparer.Ordinal)
.ToImmutableArray();
return new ConnectorSignerMetadataSet(version, generatedAt, connectors);
}
catch
{
return null;
}
}
private static ConnectorSignerMetadata? ParseConnector(JsonNode? node)
{
if (node is not JsonObject obj)
{
return null;
}
var id = obj["connectorId"]?.GetValue<string?>();
var providerName = obj["provider"]?["name"]?.GetValue<string?>();
var providerSlug = obj["provider"]?["slug"]?.GetValue<string?>();
var issuerTier = obj["issuerTier"]?.GetValue<string?>();
var signers = (obj["signers"] as JsonArray)?.Select(ParseSigner)
.Where(x => x is not null)
.Select(x => x!)
.ToImmutableArray() ?? ImmutableArray<ConnectorSignerSigner>.Empty;
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(providerName) || signers.Length == 0)
{
return null;
}
return new ConnectorSignerMetadata(
id!,
providerName!,
providerSlug ?? providerName!,
issuerTier ?? "untrusted",
signers,
ParseBundle(obj["bundle"]),
obj["validFrom"]?.GetValue<string?>(),
obj["validTo"]?.GetValue<string?>(),
obj["revoked"]?.GetValue<bool?>() ?? false,
obj["notes"]?.GetValue<string?>());
}
private static ConnectorSignerSigner? ParseSigner(JsonNode? node)
{
if (node is not JsonObject obj)
{
return null;
}
var usage = obj["usage"]?.GetValue<string?>();
var fps = (obj["fingerprints"] as JsonArray)?.Select(ParseFingerprint)
.Where(x => x is not null)
.Select(x => x!)
.ToImmutableArray() ?? ImmutableArray<ConnectorSignerFingerprint>.Empty;
if (string.IsNullOrWhiteSpace(usage) || fps.IsDefaultOrEmpty)
{
return null;
}
var chain = (obj["certificateChain"] as JsonArray)?.Select(x => x?.GetValue<string?>())
.Where(v => !string.IsNullOrWhiteSpace(v))
.Select(v => v!)
.ToImmutableArray() ?? ImmutableArray<string>.Empty;
return new ConnectorSignerSigner(
usage!,
fps,
obj["keyLocator"]?.GetValue<string?>(),
chain);
}
private static ConnectorSignerFingerprint? ParseFingerprint(JsonNode? node)
{
if (node is not JsonObject obj)
{
return null;
}
var alg = obj["alg"]?.GetValue<string?>();
var format = obj["format"]?.GetValue<string?>();
var value = obj["value"]?.GetValue<string?>();
if (string.IsNullOrWhiteSpace(alg) || string.IsNullOrWhiteSpace(format) || string.IsNullOrWhiteSpace(value))
{
return null;
}
return new ConnectorSignerFingerprint(alg!, format!, value!);
}
private static ConnectorSignerBundleRef? ParseBundle(JsonNode? node)
{
if (node is not JsonObject obj)
{
return null;
}
var kind = obj["kind"]?.GetValue<string?>();
var uri = obj["uri"]?.GetValue<string?>();
if (string.IsNullOrWhiteSpace(kind) || string.IsNullOrWhiteSpace(uri))
{
return null;
}
DateTimeOffset? published = null;
if (obj["publishedAt"] is JsonNode publishedNode && publishedNode.GetValue<string?>() is { } publishedString)
{
if (DateTimeOffset.TryParse(publishedString, out var parsed))
{
published = parsed;
}
}
return new ConnectorSignerBundleRef(
kind!,
uri!,
obj["digest"]?.GetValue<string?>(),
published);
}
}

View File

@@ -0,0 +1,80 @@
using System;
using System.IO;
using System.Linq;
using Microsoft.Extensions.Logging;
namespace StellaOps.Excititor.Connectors.Abstractions.Trust;
public static class ConnectorSignerMetadataEnricher
{
private static readonly object Sync = new();
private static ConnectorSignerMetadataSet? _cached;
private static string? _cachedPath;
private const string EnvVar = "STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH";
public static void Enrich(
VexConnectorMetadataBuilder builder,
string connectorId,
ILogger? logger = null,
string? metadataPath = null)
{
ArgumentNullException.ThrowIfNull(builder);
if (string.IsNullOrWhiteSpace(connectorId)) return;
var path = metadataPath ?? Environment.GetEnvironmentVariable(EnvVar);
if (string.IsNullOrWhiteSpace(path))
{
return;
}
var metadata = LoadCached(path, logger);
if (metadata is null || !metadata.TryGet(connectorId, out var connector))
{
return;
}
builder
.Add("vex.provenance.trust.issuerTier", connector.IssuerTier)
.Add("vex.provenance.trust.signers", string.Join(';', connector.Signers.SelectMany(s => s.Fingerprints.Select(fp => fp.Value))))
.Add("vex.provenance.trust.provider", connector.ProviderSlug);
if (connector.Bundle is { } bundle)
{
builder
.Add("vex.provenance.bundle.kind", bundle.Kind)
.Add("vex.provenance.bundle.uri", bundle.Uri)
.Add("vex.provenance.bundle.digest", bundle.Digest)
.Add("vex.provenance.bundle.publishedAt", bundle.PublishedAt?.ToUniversalTime().ToString("O"));
}
}
private static ConnectorSignerMetadataSet? LoadCached(string path, ILogger? logger)
{
if (!string.Equals(path, _cachedPath, StringComparison.OrdinalIgnoreCase) || _cached is null)
{
lock (Sync)
{
if (!string.Equals(path, _cachedPath, StringComparison.OrdinalIgnoreCase) || _cached is null)
{
if (!File.Exists(path))
{
logger?.LogDebug("Connector signer metadata file not found at {Path}; skipping enrichment.", path);
_cached = null;
}
else
{
_cached = ConnectorSignerMetadataLoader.TryLoad(path);
_cachedPath = path;
if (_cached is null)
{
logger?.LogWarning("Failed to load connector signer metadata from {Path}.", path);
}
}
}
}
}
return _cached;
}
}

View File

@@ -12,6 +12,7 @@ using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions.Trust;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
using StellaOps.Excititor.Core;
@@ -276,6 +277,8 @@ public sealed class MsrcCsafConnector : VexConnectorBase
{
builder.Add("http.lastModified", lastModified.ToString("O"));
}
ConnectorSignerMetadataEnricher.Enrich(builder, Descriptor.Id, _logger);
});
return CreateRawDocument(VexDocumentFormat.Csaf, documentUri, payload, metadata);

View File

@@ -1,11 +1,13 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Runtime.CompilerServices;
using System.Linq;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions.Trust;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest;
@@ -187,11 +189,11 @@ public sealed class OciOpenVexAttestationConnector : VexConnectorBase
}
}
if (signature is not null)
{
builder["vex.signature.type"] = signature.Type;
if (!string.IsNullOrWhiteSpace(signature.Subject))
{
if (signature is not null)
{
builder["vex.signature.type"] = signature.Type;
if (!string.IsNullOrWhiteSpace(signature.Subject))
{
builder["vex.signature.subject"] = signature.Subject!;
}
@@ -211,11 +213,19 @@ public sealed class OciOpenVexAttestationConnector : VexConnectorBase
}
if (!string.IsNullOrWhiteSpace(signature.TransparencyLogReference))
{
builder["vex.signature.transparencyLogReference"] = signature.TransparencyLogReference!;
}
}
return builder.ToImmutable();
}
}
{
builder["vex.signature.transparencyLogReference"] = signature.TransparencyLogReference!;
}
}
var metadataBuilder = new VexConnectorMetadataBuilder();
metadataBuilder.AddRange(builder.Select(kv => new KeyValuePair<string, string?>(kv.Key, kv.Value)));
ConnectorSignerMetadataEnricher.Enrich(metadataBuilder, Descriptor.Id, Logger);
foreach (var kv in metadataBuilder.Build())
{
builder[kv.Key] = kv.Value;
}
return builder.ToImmutable();
}
}

View File

@@ -8,10 +8,11 @@ using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Metadata;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions.Trust;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Oracle.CSAF.Metadata;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
@@ -260,11 +261,13 @@ public sealed class OracleCsafConnector : VexConnectorBase
builder.Add("oracle.csaf.sha256", NormalizeDigest(entry.Sha256));
builder.Add("oracle.csaf.size", entry.Size?.ToString(CultureInfo.InvariantCulture));
if (!entry.Products.IsDefaultOrEmpty)
{
builder.Add("oracle.csaf.products", string.Join(",", entry.Products));
}
});
if (!entry.Products.IsDefaultOrEmpty)
{
builder.Add("oracle.csaf.products", string.Join(",", entry.Products));
}
ConnectorSignerMetadataEnricher.Enrich(builder, Descriptor.Id, _logger);
});
return CreateRawDocument(VexDocumentFormat.Csaf, entry.DocumentUri, payload.AsMemory(), metadata);
}

View File

@@ -10,6 +10,7 @@ using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions.Trust;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata;
using StellaOps.Excititor.Core;
@@ -459,6 +460,8 @@ public sealed class UbuntuCsafConnector : VexConnectorBase
builder
.Add("vex.provenance.trust.tier", tier)
.Add("vex.provenance.trust.note", $"tier={tier};weight={provider.Trust.Weight.ToString("0.###", CultureInfo.InvariantCulture)}");
ConnectorSignerMetadataEnricher.Enrich(builder, Descriptor.Id, _logger);
}
private static async ValueTask UpsertProviderAsync(IServiceProvider services, VexProvider provider, CancellationToken cancellationToken)

View File

@@ -1,367 +1,464 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO.Compression;
using System.Net;
using System.Net.Http;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.MSRC.CSAF;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using Xunit;
using MongoDB.Driver;
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.Tests.Connectors;
public sealed class MsrcCsafConnectorTests
{
private static readonly VexConnectorDescriptor Descriptor = new("excititor:msrc", VexProviderKind.Vendor, "MSRC CSAF");
[Fact]
public async Task FetchAsync_EmitsDocumentAndPersistsState()
{
var summary = """
{
"value": [
{
"id": "ADV-0001",
"vulnerabilityId": "ADV-0001",
"severity": "Critical",
"releaseDate": "2025-10-17T00:00:00Z",
"lastModifiedDate": "2025-10-18T00:00:00Z",
"cvrfUrl": "https://example.com/csaf/ADV-0001.json"
}
]
}
""";
var csaf = """{"document":{"title":"Example"}}""";
var handler = TestHttpMessageHandler.Create(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csaf, "application/json"));
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://example.com/"),
};
var factory = new SingleClientHttpClientFactory(httpClient);
var stateRepository = new InMemoryConnectorStateRepository();
var options = Options.Create(CreateOptions());
var connector = new MsrcCsafConnector(
factory,
new StubTokenProvider(),
stateRepository,
options,
NullLogger<MsrcCsafConnector>.Instance,
TimeProvider.System);
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
var sink = new CapturingRawSink();
var context = new VexConnectorContext(
Since: new DateTimeOffset(2025, 10, 15, 0, 0, 0, TimeSpan.Zero),
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().HaveCount(1);
sink.Documents.Should().HaveCount(1);
var emitted = documents[0];
emitted.SourceUri.Should().Be(new Uri("https://example.com/csaf/ADV-0001.json"));
emitted.Metadata["msrc.vulnerabilityId"].Should().Be("ADV-0001");
emitted.Metadata["msrc.csaf.format"].Should().Be("json");
emitted.Metadata.Should().NotContainKey("excititor.quarantine.reason");
stateRepository.State.Should().NotBeNull();
stateRepository.State!.LastUpdated.Should().Be(new DateTimeOffset(2025, 10, 18, 0, 0, 0, TimeSpan.Zero));
stateRepository.State.DocumentDigests.Should().HaveCount(1);
}
[Fact]
public async Task FetchAsync_SkipsDocumentsWithExistingDigest()
{
var summary = """
{
"value": [
{
"id": "ADV-0001",
"vulnerabilityId": "ADV-0001",
"lastModifiedDate": "2025-10-18T00:00:00Z",
"cvrfUrl": "https://example.com/csaf/ADV-0001.json"
}
]
}
""";
var csaf = """{"document":{"title":"Example"}}""";
var handler = TestHttpMessageHandler.Create(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csaf, "application/json"));
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://example.com/"),
};
var factory = new SingleClientHttpClientFactory(httpClient);
var stateRepository = new InMemoryConnectorStateRepository();
var options = Options.Create(CreateOptions());
var connector = new MsrcCsafConnector(
factory,
new StubTokenProvider(),
stateRepository,
options,
NullLogger<MsrcCsafConnector>.Instance,
TimeProvider.System);
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
var sink = new CapturingRawSink();
var context = new VexConnectorContext(
Since: new DateTimeOffset(2025, 10, 15, 0, 0, 0, TimeSpan.Zero),
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var firstPass = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
firstPass.Add(document);
}
firstPass.Should().HaveCount(1);
stateRepository.State.Should().NotBeNull();
var persistedState = stateRepository.State!;
handler.Reset(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csaf, "application/json"));
sink.Documents.Clear();
var secondPass = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
secondPass.Add(document);
}
secondPass.Should().BeEmpty();
sink.Documents.Should().BeEmpty();
stateRepository.State.Should().NotBeNull();
stateRepository.State!.DocumentDigests.Should().Equal(persistedState.DocumentDigests);
}
[Fact]
public async Task FetchAsync_QuarantinesInvalidCsafPayload()
{
var summary = """
{
"value": [
{
"id": "ADV-0002",
"vulnerabilityId": "ADV-0002",
"lastModifiedDate": "2025-10-19T00:00:00Z",
"cvrfUrl": "https://example.com/csaf/ADV-0002.zip"
}
]
}
""";
var csafZip = CreateZip("document.json", "{ invalid json ");
var handler = TestHttpMessageHandler.Create(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csafZip, "application/zip"));
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://example.com/"),
};
var factory = new SingleClientHttpClientFactory(httpClient);
var stateRepository = new InMemoryConnectorStateRepository();
var options = Options.Create(CreateOptions());
var connector = new MsrcCsafConnector(
factory,
new StubTokenProvider(),
stateRepository,
options,
NullLogger<MsrcCsafConnector>.Instance,
TimeProvider.System);
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
var sink = new CapturingRawSink();
var context = new VexConnectorContext(
Since: new DateTimeOffset(2025, 10, 17, 0, 0, 0, TimeSpan.Zero),
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().BeEmpty();
sink.Documents.Should().HaveCount(1);
sink.Documents[0].Metadata["excititor.quarantine.reason"].Should().Contain("JSON parse failed");
sink.Documents[0].Metadata["msrc.csaf.format"].Should().Be("zip");
stateRepository.State.Should().NotBeNull();
stateRepository.State!.DocumentDigests.Should().HaveCount(1);
}
private static HttpResponseMessage Response(HttpStatusCode statusCode, string content, string contentType)
=> new(statusCode)
{
Content = new StringContent(content, Encoding.UTF8, contentType),
};
private static HttpResponseMessage Response(HttpStatusCode statusCode, byte[] content, string contentType)
{
var response = new HttpResponseMessage(statusCode);
response.Content = new ByteArrayContent(content);
response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
return response;
}
private static MsrcConnectorOptions CreateOptions()
=> new()
{
BaseUri = new Uri("https://example.com/", UriKind.Absolute),
TenantId = Guid.NewGuid().ToString(),
ClientId = "client-id",
ClientSecret = "secret",
Scope = MsrcConnectorOptions.DefaultScope,
PageSize = 5,
MaxAdvisoriesPerFetch = 5,
RequestDelay = TimeSpan.Zero,
RetryBaseDelay = TimeSpan.FromMilliseconds(10),
MaxRetryAttempts = 2,
};
private static byte[] CreateZip(string entryName, string content)
{
using var buffer = new MemoryStream();
using (var archive = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry(entryName);
using var writer = new StreamWriter(entry.Open(), Encoding.UTF8);
writer.Write(content);
}
return buffer.ToArray();
}
private sealed class StubTokenProvider : IMsrcTokenProvider
{
public ValueTask<MsrcAccessToken> GetAccessTokenAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult(new MsrcAccessToken("token", "Bearer", DateTimeOffset.MaxValue));
}
private sealed class CapturingRawSink : IVexRawDocumentSink
{
public List<VexRawDocument> Documents { get; } = new();
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
{
Documents.Add(document);
return ValueTask.CompletedTask;
}
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
{
public VexConnectorState? State { get; private set; }
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(State);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
State = state;
return ValueTask.CompletedTask;
}
}
private sealed class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Queue<Func<HttpRequestMessage, HttpResponseMessage>> _responders;
private TestHttpMessageHandler(IEnumerable<Func<HttpRequestMessage, HttpResponseMessage>> responders)
{
_responders = new Queue<Func<HttpRequestMessage, HttpResponseMessage>>(responders);
}
public static TestHttpMessageHandler Create(params Func<HttpRequestMessage, HttpResponseMessage>[] responders)
=> new(responders);
public void Reset(params Func<HttpRequestMessage, HttpResponseMessage>[] responders)
{
_responders.Clear();
foreach (var responder in responders)
{
_responders.Enqueue(responder);
}
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (_responders.Count == 0)
{
throw new InvalidOperationException("No responder configured for MSRC connector test request.");
}
var responder = _responders.Count > 1 ? _responders.Dequeue() : _responders.Peek();
var response = responder(request);
response.RequestMessage = request;
return Task.FromResult(response);
}
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.MSRC.CSAF;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Authentication;
using StellaOps.Excititor.Connectors.MSRC.CSAF.Configuration;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using Xunit;
using MongoDB.Driver;
namespace StellaOps.Excititor.Connectors.MSRC.CSAF.Tests.Connectors;
public sealed class MsrcCsafConnectorTests
{
private static readonly VexConnectorDescriptor Descriptor = new("excititor:msrc", VexProviderKind.Vendor, "MSRC CSAF");
[Fact]
public async Task FetchAsync_EmitsDocumentAndPersistsState()
{
var summary = """
{
"value": [
{
"id": "ADV-0001",
"vulnerabilityId": "ADV-0001",
"severity": "Critical",
"releaseDate": "2025-10-17T00:00:00Z",
"lastModifiedDate": "2025-10-18T00:00:00Z",
"cvrfUrl": "https://example.com/csaf/ADV-0001.json"
}
]
}
""";
var csaf = """{"document":{"title":"Example"}}""";
var handler = TestHttpMessageHandler.Create(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csaf, "application/json"));
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://example.com/"),
};
var factory = new SingleClientHttpClientFactory(httpClient);
var stateRepository = new InMemoryConnectorStateRepository();
var options = Options.Create(CreateOptions());
var connector = new MsrcCsafConnector(
factory,
new StubTokenProvider(),
stateRepository,
options,
NullLogger<MsrcCsafConnector>.Instance,
TimeProvider.System);
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
var sink = new CapturingRawSink();
var context = new VexConnectorContext(
Since: new DateTimeOffset(2025, 10, 15, 0, 0, 0, TimeSpan.Zero),
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().HaveCount(1);
sink.Documents.Should().HaveCount(1);
var emitted = documents[0];
emitted.SourceUri.Should().Be(new Uri("https://example.com/csaf/ADV-0001.json"));
emitted.Metadata["msrc.vulnerabilityId"].Should().Be("ADV-0001");
emitted.Metadata["msrc.csaf.format"].Should().Be("json");
emitted.Metadata.Should().NotContainKey("excititor.quarantine.reason");
stateRepository.State.Should().NotBeNull();
stateRepository.State!.LastUpdated.Should().Be(new DateTimeOffset(2025, 10, 18, 0, 0, 0, TimeSpan.Zero));
stateRepository.State.DocumentDigests.Should().HaveCount(1);
}
[Fact]
public async Task FetchAsync_SkipsDocumentsWithExistingDigest()
{
var summary = """
{
"value": [
{
"id": "ADV-0001",
"vulnerabilityId": "ADV-0001",
"lastModifiedDate": "2025-10-18T00:00:00Z",
"cvrfUrl": "https://example.com/csaf/ADV-0001.json"
}
]
}
""";
var csaf = """{"document":{"title":"Example"}}""";
var handler = TestHttpMessageHandler.Create(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csaf, "application/json"));
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://example.com/"),
};
var factory = new SingleClientHttpClientFactory(httpClient);
var stateRepository = new InMemoryConnectorStateRepository();
var options = Options.Create(CreateOptions());
var connector = new MsrcCsafConnector(
factory,
new StubTokenProvider(),
stateRepository,
options,
NullLogger<MsrcCsafConnector>.Instance,
TimeProvider.System);
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
var sink = new CapturingRawSink();
var context = new VexConnectorContext(
Since: new DateTimeOffset(2025, 10, 15, 0, 0, 0, TimeSpan.Zero),
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var firstPass = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
firstPass.Add(document);
}
firstPass.Should().HaveCount(1);
stateRepository.State.Should().NotBeNull();
var persistedState = stateRepository.State!;
handler.Reset(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csaf, "application/json"));
sink.Documents.Clear();
var secondPass = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
secondPass.Add(document);
}
secondPass.Should().BeEmpty();
sink.Documents.Should().BeEmpty();
stateRepository.State.Should().NotBeNull();
stateRepository.State!.DocumentDigests.Should().Equal(persistedState.DocumentDigests);
}
[Fact]
public async Task FetchAsync_QuarantinesInvalidCsafPayload()
{
var summary = """
{
"value": [
{
"id": "ADV-0002",
"vulnerabilityId": "ADV-0002",
"lastModifiedDate": "2025-10-19T00:00:00Z",
"cvrfUrl": "https://example.com/csaf/ADV-0002.zip"
}
]
}
""";
var csafZip = CreateZip("document.json", "{ invalid json ");
var handler = TestHttpMessageHandler.Create(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csafZip, "application/zip"));
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://example.com/"),
};
var factory = new SingleClientHttpClientFactory(httpClient);
var stateRepository = new InMemoryConnectorStateRepository();
var options = Options.Create(CreateOptions());
var connector = new MsrcCsafConnector(
factory,
new StubTokenProvider(),
stateRepository,
options,
NullLogger<MsrcCsafConnector>.Instance,
TimeProvider.System);
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
var sink = new CapturingRawSink();
var context = new VexConnectorContext(
Since: new DateTimeOffset(2025, 10, 17, 0, 0, 0, TimeSpan.Zero),
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().BeEmpty();
sink.Documents.Should().HaveCount(1);
sink.Documents[0].Metadata["excititor.quarantine.reason"].Should().Contain("JSON parse failed");
sink.Documents[0].Metadata["msrc.csaf.format"].Should().Be("zip");
stateRepository.State.Should().NotBeNull();
stateRepository.State!.DocumentDigests.Should().HaveCount(1);
}
private static HttpResponseMessage Response(HttpStatusCode statusCode, string content, string contentType)
=> new(statusCode)
{
Content = new StringContent(content, Encoding.UTF8, contentType),
};
private static HttpResponseMessage Response(HttpStatusCode statusCode, byte[] content, string contentType)
{
var response = new HttpResponseMessage(statusCode);
response.Content = new ByteArrayContent(content);
response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
return response;
}
private static MsrcConnectorOptions CreateOptions()
=> new()
{
BaseUri = new Uri("https://example.com/", UriKind.Absolute),
TenantId = Guid.NewGuid().ToString(),
ClientId = "client-id",
ClientSecret = "secret",
Scope = MsrcConnectorOptions.DefaultScope,
PageSize = 5,
MaxAdvisoriesPerFetch = 5,
RequestDelay = TimeSpan.Zero,
RetryBaseDelay = TimeSpan.FromMilliseconds(10),
MaxRetryAttempts = 2,
};
private static byte[] CreateZip(string entryName, string content)
{
using var buffer = new MemoryStream();
using (var archive = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry(entryName);
using var writer = new StreamWriter(entry.Open(), Encoding.UTF8);
writer.Write(content);
}
return buffer.ToArray();
}
private sealed class StubTokenProvider : IMsrcTokenProvider
{
public ValueTask<MsrcAccessToken> GetAccessTokenAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult(new MsrcAccessToken("token", "Bearer", DateTimeOffset.MaxValue));
}
private sealed class CapturingRawSink : IVexRawDocumentSink
{
public List<VexRawDocument> Documents { get; } = new();
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
{
Documents.Add(document);
return ValueTask.CompletedTask;
}
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
{
public VexConnectorState? State { get; private set; }
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(State);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
State = state;
return ValueTask.CompletedTask;
}
}
[Fact]
public async Task FetchAsync_EnrichesSignerMetadataWhenConfigured()
{
using var tempMetadata = CreateTempSignerMetadata("excititor:msrc", "tier-1", "abc123");
Environment.SetEnvironmentVariable("STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH", tempMetadata.Path);
try
{
var summary = """
{
"value": [
{ "id": "ADV-0002", "vulnerabilityId": "ADV-0002", "cvrfUrl": "https://example.com/csaf/ADV-0002.json" }
]
}
""";
var csaf = """{"document":{"title":"Example"}}""";
var handler = TestHttpMessageHandler.Create(
_ => Response(HttpStatusCode.OK, summary, "application/json"),
_ => Response(HttpStatusCode.OK, csaf, "application/json"));
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://example.com/"), };
var factory = new SingleClientHttpClientFactory(httpClient);
var stateRepository = new InMemoryConnectorStateRepository();
var connector = new MsrcCsafConnector(
factory,
new StubTokenProvider(),
stateRepository,
Options.Create(CreateOptions()),
NullLogger<MsrcCsafConnector>.Instance,
TimeProvider.System);
await connector.ValidateAsync(VexConnectorSettings.Empty, CancellationToken.None);
var sink = new CapturingRawSink();
var context = new VexConnectorContext(
Since: null,
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
await foreach (var _ in connector.FetchAsync(context, CancellationToken.None)) { }
var emitted = sink.Documents.Single();
emitted.Metadata.Should().Contain("vex.provenance.trust.issuerTier", "tier-1");
emitted.Metadata.Should().ContainKey("vex.provenance.trust.signers");
}
finally
{
Environment.SetEnvironmentVariable("STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH", null);
}
}
private static TempMetadataFile CreateTempSignerMetadata(string connectorId, string tier, string fingerprint)
{
var pathTemp = Path.GetTempFileName();
var json = $"""
{{
"schemaVersion": "1.0.0",
"generatedAt": "2025-11-20T00:00:00Z",
"connectors": [
{{
"connectorId": "{connectorId}",
"provider": {{ "name": "{connectorId}", "slug": "{connectorId}" }},
"issuerTier": "{tier}",
"signers": [
{{
"usage": "csaf",
"fingerprints": [
{{ "alg": "sha256", "format": "pgp", "value": "{fingerprint}" }}
]
}}
]
}}
]
}}
""";
File.WriteAllText(pathTemp, json);
return new TempMetadataFile(pathTemp);
}
private sealed record TempMetadataFile(string Path) : IDisposable
{
public void Dispose()
{
try { File.Delete(Path); } catch { }
}
}
private sealed class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Queue<Func<HttpRequestMessage, HttpResponseMessage>> _responders;
private TestHttpMessageHandler(IEnumerable<Func<HttpRequestMessage, HttpResponseMessage>> responders)
{
_responders = new Queue<Func<HttpRequestMessage, HttpResponseMessage>>(responders);
}
public static TestHttpMessageHandler Create(params Func<HttpRequestMessage, HttpResponseMessage>[] responders)
=> new(responders);
public void Reset(params Func<HttpRequestMessage, HttpResponseMessage>[] responders)
{
_responders.Clear();
foreach (var responder in responders)
{
_responders.Enqueue(responder);
}
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (_responders.Count == 0)
{
throw new InvalidOperationException("No responder configured for MSRC connector test request.");
}
var responder = _responders.Count > 1 ? _responders.Dequeue() : _responders.Peek();
var response = responder(request);
response.RequestMessage = request;
return Task.FromResult(response);
}
}
}

View File

@@ -1,215 +1,313 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.DependencyInjection;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
using StellaOps.Excititor.Core;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests.Connector;
public sealed class OciOpenVexAttestationConnectorTests
{
[Fact]
public async Task FetchAsync_WithOfflineBundle_EmitsRawDocument()
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/bundles/attestation.json"] = new MockFileData("{\"payload\":\"\",\"payloadType\":\"application/vnd.in-toto+json\",\"signatures\":[{\"sig\":\"\"}]}"),
});
using var cache = new MemoryCache(new MemoryCacheOptions());
var httpClient = new HttpClient(new StubHttpMessageHandler())
{
BaseAddress = new System.Uri("https://registry.example.com/")
};
var httpFactory = new SingleClientHttpClientFactory(httpClient);
var discovery = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
var fetcher = new OciAttestationFetcher(httpFactory, fileSystem, NullLogger<OciAttestationFetcher>.Instance);
var connector = new OciOpenVexAttestationConnector(
discovery,
fetcher,
NullLogger<OciOpenVexAttestationConnector>.Instance,
TimeProvider.System);
var settingsValues = ImmutableDictionary<string, string>.Empty
.Add("Images:0:Reference", "registry.example.com/repo/image:latest")
.Add("Images:0:OfflineBundlePath", "/bundles/attestation.json")
.Add("Offline:PreferOffline", "true")
.Add("Offline:AllowNetworkFallback", "false")
.Add("Cosign:Mode", "None");
var settings = new VexConnectorSettings(settingsValues);
await connector.ValidateAsync(settings, CancellationToken.None);
var sink = new CapturingRawSink();
var verifier = new CapturingSignatureVerifier();
var context = new VexConnectorContext(
Since: null,
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: verifier,
Normalizers: new NoopNormalizerRouter(),
Services: new Microsoft.Extensions.DependencyInjection.ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().HaveCount(1);
sink.Documents.Should().HaveCount(1);
documents[0].Format.Should().Be(VexDocumentFormat.OciAttestation);
documents[0].Metadata.Should().ContainKey("oci.attestation.sourceKind").WhoseValue.Should().Be("offline");
documents[0].Metadata.Should().ContainKey("vex.provenance.sourceKind").WhoseValue.Should().Be("offline");
documents[0].Metadata.Should().ContainKey("vex.provenance.registryAuthMode").WhoseValue.Should().Be("Anonymous");
verifier.VerifyCalls.Should().Be(1);
}
[Fact]
public async Task FetchAsync_WithSignatureMetadata_EnrichesProvenance()
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/bundles/attestation.json"] = new MockFileData("{\"payload\":\"\",\"payloadType\":\"application/vnd.in-toto+json\",\"signatures\":[{\"sig\":\"\"}]}"),
});
using var cache = new MemoryCache(new MemoryCacheOptions());
var httpClient = new HttpClient(new StubHttpMessageHandler())
{
BaseAddress = new System.Uri("https://registry.example.com/")
};
var httpFactory = new SingleClientHttpClientFactory(httpClient);
var discovery = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
var fetcher = new OciAttestationFetcher(httpFactory, fileSystem, NullLogger<OciAttestationFetcher>.Instance);
var connector = new OciOpenVexAttestationConnector(
discovery,
fetcher,
NullLogger<OciOpenVexAttestationConnector>.Instance,
TimeProvider.System);
var settingsValues = ImmutableDictionary<string, string>.Empty
.Add("Images:0:Reference", "registry.example.com/repo/image:latest")
.Add("Images:0:OfflineBundlePath", "/bundles/attestation.json")
.Add("Offline:PreferOffline", "true")
.Add("Offline:AllowNetworkFallback", "false")
.Add("Cosign:Mode", "Keyless")
.Add("Cosign:Keyless:Issuer", "https://issuer.example.com")
.Add("Cosign:Keyless:Subject", "subject@example.com");
var settings = new VexConnectorSettings(settingsValues);
await connector.ValidateAsync(settings, CancellationToken.None);
var sink = new CapturingRawSink();
var verifier = new CapturingSignatureVerifier
{
Result = new VexSignatureMetadata(
type: "cosign",
subject: "sig-subject",
issuer: "sig-issuer",
keyId: "key-id",
verifiedAt: DateTimeOffset.UtcNow,
transparencyLogReference: "rekor://entry/123")
};
var context = new VexConnectorContext(
Since: null,
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: verifier,
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().HaveCount(1);
var metadata = documents[0].Metadata;
metadata.Should().Contain("vex.signature.type", "cosign");
metadata.Should().Contain("vex.signature.subject", "sig-subject");
metadata.Should().Contain("vex.signature.issuer", "sig-issuer");
metadata.Should().Contain("vex.signature.keyId", "key-id");
metadata.Should().ContainKey("vex.signature.verifiedAt");
metadata.Should().Contain("vex.signature.transparencyLogReference", "rekor://entry/123");
metadata.Should().Contain("vex.provenance.cosign.mode", "Keyless");
metadata.Should().Contain("vex.provenance.cosign.issuer", "https://issuer.example.com");
metadata.Should().Contain("vex.provenance.cosign.subject", "subject@example.com");
verifier.VerifyCalls.Should().Be(1);
}
private sealed class CapturingRawSink : IVexRawDocumentSink
{
public List<VexRawDocument> Documents { get; } = new();
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
{
Documents.Add(document);
return ValueTask.CompletedTask;
}
}
private sealed class CapturingSignatureVerifier : IVexSignatureVerifier
{
public int VerifyCalls { get; private set; }
public VexSignatureMetadata? Result { get; set; }
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
{
VerifyCalls++;
return ValueTask.FromResult(Result);
}
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
RequestMessage = request
});
}
}
}
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Configuration;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.DependencyInjection;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Discovery;
using StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Fetch;
using StellaOps.Excititor.Core;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
namespace StellaOps.Excititor.Connectors.OCI.OpenVEX.Attest.Tests.Connector;
public sealed class OciOpenVexAttestationConnectorTests
{
[Fact]
public async Task FetchAsync_WithOfflineBundle_EmitsRawDocument()
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/bundles/attestation.json"] = new MockFileData("{\"payload\":\"\",\"payloadType\":\"application/vnd.in-toto+json\",\"signatures\":[{\"sig\":\"\"}]}"),
});
using var cache = new MemoryCache(new MemoryCacheOptions());
var httpClient = new HttpClient(new StubHttpMessageHandler())
{
BaseAddress = new System.Uri("https://registry.example.com/")
};
var httpFactory = new SingleClientHttpClientFactory(httpClient);
var discovery = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
var fetcher = new OciAttestationFetcher(httpFactory, fileSystem, NullLogger<OciAttestationFetcher>.Instance);
var connector = new OciOpenVexAttestationConnector(
discovery,
fetcher,
NullLogger<OciOpenVexAttestationConnector>.Instance,
TimeProvider.System);
var settingsValues = ImmutableDictionary<string, string>.Empty
.Add("Images:0:Reference", "registry.example.com/repo/image:latest")
.Add("Images:0:OfflineBundlePath", "/bundles/attestation.json")
.Add("Offline:PreferOffline", "true")
.Add("Offline:AllowNetworkFallback", "false")
.Add("Cosign:Mode", "None");
var settings = new VexConnectorSettings(settingsValues);
await connector.ValidateAsync(settings, CancellationToken.None);
var sink = new CapturingRawSink();
var verifier = new CapturingSignatureVerifier();
var context = new VexConnectorContext(
Since: null,
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: verifier,
Normalizers: new NoopNormalizerRouter(),
Services: new Microsoft.Extensions.DependencyInjection.ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().HaveCount(1);
sink.Documents.Should().HaveCount(1);
documents[0].Format.Should().Be(VexDocumentFormat.OciAttestation);
documents[0].Metadata.Should().ContainKey("oci.attestation.sourceKind").WhoseValue.Should().Be("offline");
documents[0].Metadata.Should().ContainKey("vex.provenance.sourceKind").WhoseValue.Should().Be("offline");
documents[0].Metadata.Should().ContainKey("vex.provenance.registryAuthMode").WhoseValue.Should().Be("Anonymous");
verifier.VerifyCalls.Should().Be(1);
}
[Fact]
public async Task FetchAsync_WithSignatureMetadata_EnrichesProvenance()
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/bundles/attestation.json"] = new MockFileData("{\"payload\":\"\",\"payloadType\":\"application/vnd.in-toto+json\",\"signatures\":[{\"sig\":\"\"}]}"),
});
using var cache = new MemoryCache(new MemoryCacheOptions());
var httpClient = new HttpClient(new StubHttpMessageHandler())
{
BaseAddress = new System.Uri("https://registry.example.com/")
};
var httpFactory = new SingleClientHttpClientFactory(httpClient);
var discovery = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
var fetcher = new OciAttestationFetcher(httpFactory, fileSystem, NullLogger<OciAttestationFetcher>.Instance);
var connector = new OciOpenVexAttestationConnector(
discovery,
fetcher,
NullLogger<OciOpenVexAttestationConnector>.Instance,
TimeProvider.System);
var settingsValues = ImmutableDictionary<string, string>.Empty
.Add("Images:0:Reference", "registry.example.com/repo/image:latest")
.Add("Images:0:OfflineBundlePath", "/bundles/attestation.json")
.Add("Offline:PreferOffline", "true")
.Add("Offline:AllowNetworkFallback", "false")
.Add("Cosign:Mode", "Keyless")
.Add("Cosign:Keyless:Issuer", "https://issuer.example.com")
.Add("Cosign:Keyless:Subject", "subject@example.com");
var settings = new VexConnectorSettings(settingsValues);
await connector.ValidateAsync(settings, CancellationToken.None);
var sink = new CapturingRawSink();
var verifier = new CapturingSignatureVerifier
{
Result = new VexSignatureMetadata(
type: "cosign",
subject: "sig-subject",
issuer: "sig-issuer",
keyId: "key-id",
verifiedAt: DateTimeOffset.UtcNow,
transparencyLogReference: "rekor://entry/123")
};
var context = new VexConnectorContext(
Since: null,
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: verifier,
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var document in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(document);
}
documents.Should().HaveCount(1);
var metadata = documents[0].Metadata;
metadata.Should().Contain("vex.signature.type", "cosign");
metadata.Should().Contain("vex.signature.subject", "sig-subject");
metadata.Should().Contain("vex.signature.issuer", "sig-issuer");
metadata.Should().Contain("vex.signature.keyId", "key-id");
metadata.Should().ContainKey("vex.signature.verifiedAt");
metadata.Should().Contain("vex.signature.transparencyLogReference", "rekor://entry/123");
metadata.Should().Contain("vex.provenance.cosign.mode", "Keyless");
metadata.Should().Contain("vex.provenance.cosign.issuer", "https://issuer.example.com");
metadata.Should().Contain("vex.provenance.cosign.subject", "subject@example.com");
verifier.VerifyCalls.Should().Be(1);
}
private sealed class CapturingRawSink : IVexRawDocumentSink
{
public List<VexRawDocument> Documents { get; } = new();
public ValueTask StoreAsync(VexRawDocument document, CancellationToken cancellationToken)
{
Documents.Add(document);
return ValueTask.CompletedTask;
}
}
private sealed class CapturingSignatureVerifier : IVexSignatureVerifier
{
public int VerifyCalls { get; private set; }
public VexSignatureMetadata? Result { get; set; }
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
{
VerifyCalls++;
return ValueTask.FromResult(Result);
}
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
private sealed class SingleClientHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientHttpClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
RequestMessage = request
});
}
}
[Fact]
public async Task FetchAsync_EnrichesSignerTrustMetadataWhenConfigured()
{
using var tempMetadata = CreateTempSignerMetadata("excititor:oci.openvex.attest", "tier-0", "feed-fp");
Environment.SetEnvironmentVariable("STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH", tempMetadata.Path);
try
{
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
["/bundles/attestation.json"] = new MockFileData("{"payload":"","payloadType":"application/vnd.in-toto+json","signatures":[{"sig":""}]}")
});
using var cache = new MemoryCache(new MemoryCacheOptions());
var httpClient = new HttpClient(new StubHttpMessageHandler())
{
BaseAddress = new System.Uri("https://registry.example.com/")
};
var httpFactory = new SingleClientHttpClientFactory(httpClient);
var discovery = new OciAttestationDiscoveryService(cache, fileSystem, NullLogger<OciAttestationDiscoveryService>.Instance);
var fetcher = new OciAttestationFetcher(httpFactory, fileSystem, NullLogger<OciAttestationFetcher>.Instance);
var connector = new OciOpenVexAttestationConnector(
discovery,
fetcher,
NullLogger<OciOpenVexAttestationConnector>.Instance,
TimeProvider.System);
var settingsValues = ImmutableDictionary<string, string>.Empty
.Add("Images:0:Reference", "registry.example.com/repo/image:latest")
.Add("Images:0:OfflineBundlePath", "/bundles/attestation.json")
.Add("Offline:PreferOffline", "true")
.Add("Offline:AllowNetworkFallback", "false")
.Add("Cosign:Mode", "None");
var settings = new VexConnectorSettings(settingsValues);
await connector.ValidateAsync(settings, CancellationToken.None);
var sink = new CapturingRawSink();
var context = new VexConnectorContext(
Since: null,
Settings: VexConnectorSettings.Empty,
RawSink: sink,
SignatureVerifier: new NoopSignatureVerifier(),
Normalizers: new NoopNormalizerRouter(),
Services: new ServiceCollection().BuildServiceProvider(),
ResumeTokens: ImmutableDictionary<string, string>.Empty);
await foreach (var _ in connector.FetchAsync(context, CancellationToken.None)) { }
var emitted = sink.Documents.Single();
emitted.Metadata.Should().Contain("vex.provenance.trust.issuerTier", "tier-0");
emitted.Metadata.Should().ContainKey("vex.provenance.trust.signers");
}
finally
{
Environment.SetEnvironmentVariable("STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH", null);
}
}
private static TempMetadataFile CreateTempSignerMetadata(string connectorId, string tier, string fingerprint)
{
var pathTemp = System.IO.Path.GetTempFileName();
var json = $"""
{{
"schemaVersion": "1.0.0",
"generatedAt": "2025-11-20T00:00:00Z",
"connectors": [
{{
"connectorId": "{connectorId}",
"provider": {{ "name": "{connectorId}", "slug": "{connectorId}" }},
"issuerTier": "{tier}",
"signers": [
{{
"usage": "attestation",
"fingerprints": [
{{ "alg": "sha256", "format": "cosign", "value": "{fingerprint}" }}
]
}}
]
}}
]
}}
""";
System.IO.File.WriteAllText(pathTemp, json);
return new TempMetadataFile(pathTemp);
}
private sealed record TempMetadataFile(string Path) : IDisposable
{
public void Dispose()
{
try { System.IO.File.Delete(Path); } catch { }
}
}
}

View File

@@ -1,62 +1,67 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
using MongoDB.Driver;
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Tests.Connectors;
public sealed class UbuntuCsafConnectorTests
{
[Fact]
public async Task FetchAsync_IngestsNewDocument_UpdatesStateAndUsesEtag()
{
var baseUri = new Uri("https://ubuntu.test/security/csaf/");
var indexUri = new Uri(baseUri, "index.json");
var catalogUri = new Uri(baseUri, "stable/catalog.json");
var advisoryUri = new Uri(baseUri, "stable/USN-2025-0001.json");
var manifest = CreateTestManifest(advisoryUri, "USN-2025-0001", "2025-10-18T00:00:00Z");
var documentPayload = Encoding.UTF8.GetBytes("{\"document\":\"payload\"}");
var documentSha = ComputeSha256(documentPayload);
var indexJson = manifest.IndexJson;
var catalogJson = manifest.CatalogJson.Replace("{{SHA256}}", documentSha, StringComparison.Ordinal);
var handler = new UbuntuTestHttpHandler(indexUri, indexJson, catalogUri, catalogJson, advisoryUri, documentPayload, expectedEtag: "etag-123");
var httpClient = new HttpClient(handler);
var httpFactory = new SingleClientFactory(httpClient);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var loader = new UbuntuCatalogLoader(httpFactory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, TimeProvider.System);
var optionsValidator = new UbuntuConnectorOptionsValidator(fileSystem);
var stateRepository = new InMemoryConnectorStateRepository();
var connector = new UbuntuCsafConnector(
loader,
httpFactory,
stateRepository,
new[] { optionsValidator },
NullLogger<UbuntuCsafConnector>.Instance,
TimeProvider.System);
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using FluentAssertions;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Configuration;
using StellaOps.Excititor.Connectors.Ubuntu.CSAF.Metadata;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
using MongoDB.Driver;
namespace StellaOps.Excititor.Connectors.Ubuntu.CSAF.Tests.Connectors;
public sealed class UbuntuCsafConnectorTests
{
[Fact]
public async Task FetchAsync_IngestsNewDocument_UpdatesStateAndUsesEtag()
{
using var tempMetadata = CreateTempSignerMetadata("excititor:ubuntu", "tier-2", "deadbeef");
Environment.SetEnvironmentVariable("STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH", tempMetadata.Path);
try
{
var baseUri = new Uri("https://ubuntu.test/security/csaf/");
var indexUri = new Uri(baseUri, "index.json");
var catalogUri = new Uri(baseUri, "stable/catalog.json");
var advisoryUri = new Uri(baseUri, "stable/USN-2025-0001.json");
var manifest = CreateTestManifest(advisoryUri, "USN-2025-0001", "2025-10-18T00:00:00Z");
var documentPayload = Encoding.UTF8.GetBytes("{\"document\":\"payload\"}");
var documentSha = ComputeSha256(documentPayload);
var indexJson = manifest.IndexJson;
var catalogJson = manifest.CatalogJson.Replace("{{SHA256}}", documentSha, StringComparison.Ordinal);
var handler = new UbuntuTestHttpHandler(indexUri, indexJson, catalogUri, catalogJson, advisoryUri, documentPayload, expectedEtag: "etag-123");
var httpClient = new HttpClient(handler);
var httpFactory = new SingleClientFactory(httpClient);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var loader = new UbuntuCatalogLoader(httpFactory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, TimeProvider.System);
var optionsValidator = new UbuntuConnectorOptionsValidator(fileSystem);
var stateRepository = new InMemoryConnectorStateRepository();
var connector = new UbuntuCsafConnector(
loader,
httpFactory,
stateRepository,
new[] { optionsValidator },
NullLogger<UbuntuCsafConnector>.Instance,
TimeProvider.System);
var settings = BuildConnectorSettings(indexUri, trustWeight: 0.63, trustTier: "distro-trusted",
fingerprints: new[]
{
@@ -72,15 +77,15 @@ public sealed class UbuntuCsafConnectorTests
var sink = new InMemoryRawSink();
var context = new VexConnectorContext(null, VexConnectorSettings.Empty, sink, new NoopSignatureVerifier(), new NoopNormalizerRouter(), services, ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(doc);
}
documents.Should().HaveCount(1);
sink.Documents.Should().HaveCount(1);
var documents = new List<VexRawDocument>();
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(doc);
}
documents.Should().HaveCount(1);
sink.Documents.Should().HaveCount(1);
var stored = sink.Documents.Single();
stored.Digest.Should().Be($"sha256:{documentSha}");
stored.Metadata.Should().Contain("ubuntu.etag", "etag-123");
@@ -93,25 +98,25 @@ public sealed class UbuntuCsafConnectorTests
stored.Metadata.Should().Contain(
"vex.provenance.pgp.fingerprints",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA,BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB");
stateRepository.CurrentState.Should().NotBeNull();
stateRepository.CurrentState!.DocumentDigests.Should().Contain($"sha256:{documentSha}");
stateRepository.CurrentState.DocumentDigests.Should().Contain($"etag:{advisoryUri}|etag-123");
stateRepository.CurrentState.LastUpdated.Should().Be(DateTimeOffset.Parse("2025-10-18T00:00:00Z"));
handler.DocumentRequestCount.Should().Be(1);
// Second run: Expect connector to send If-None-Match and skip download via 304.
sink.Documents.Clear();
documents.Clear();
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(doc);
}
documents.Should().BeEmpty();
sink.Documents.Should().BeEmpty();
stateRepository.CurrentState.Should().NotBeNull();
stateRepository.CurrentState!.DocumentDigests.Should().Contain($"sha256:{documentSha}");
stateRepository.CurrentState.DocumentDigests.Should().Contain($"etag:{advisoryUri}|etag-123");
stateRepository.CurrentState.LastUpdated.Should().Be(DateTimeOffset.Parse("2025-10-18T00:00:00Z"));
handler.DocumentRequestCount.Should().Be(1);
// Second run: Expect connector to send If-None-Match and skip download via 304.
sink.Documents.Clear();
documents.Clear();
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(doc);
}
documents.Should().BeEmpty();
sink.Documents.Should().BeEmpty();
handler.DocumentRequestCount.Should().Be(2);
handler.SeenIfNoneMatch.Should().Contain("\"etag-123\"");
@@ -123,37 +128,41 @@ public sealed class UbuntuCsafConnectorTests
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
});
}
[Fact]
public async Task FetchAsync_SkipsWhenChecksumMismatch()
{
var baseUri = new Uri("https://ubuntu.test/security/csaf/");
var indexUri = new Uri(baseUri, "index.json");
var catalogUri = new Uri(baseUri, "stable/catalog.json");
var advisoryUri = new Uri(baseUri, "stable/USN-2025-0002.json");
var manifest = CreateTestManifest(advisoryUri, "USN-2025-0002", "2025-10-18T00:00:00Z");
var indexJson = manifest.IndexJson;
var catalogJson = manifest.CatalogJson.Replace("{{SHA256}}", new string('a', 64), StringComparison.Ordinal);
var handler = new UbuntuTestHttpHandler(indexUri, indexJson, catalogUri, catalogJson, advisoryUri, Encoding.UTF8.GetBytes("{\"document\":\"payload\"}"), expectedEtag: "etag-999");
var httpClient = new HttpClient(handler);
var httpFactory = new SingleClientFactory(httpClient);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var loader = new UbuntuCatalogLoader(httpFactory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, TimeProvider.System);
var optionsValidator = new UbuntuConnectorOptionsValidator(fileSystem);
var stateRepository = new InMemoryConnectorStateRepository();
var connector = new UbuntuCsafConnector(
loader,
httpFactory,
stateRepository,
new[] { optionsValidator },
NullLogger<UbuntuCsafConnector>.Instance,
TimeProvider.System);
}
finally
{
Environment.SetEnvironmentVariable("STELLAOPS_CONNECTOR_SIGNER_METADATA_PATH", null);
}
[Fact]
public async Task FetchAsync_SkipsWhenChecksumMismatch()
{
var baseUri = new Uri("https://ubuntu.test/security/csaf/");
var indexUri = new Uri(baseUri, "index.json");
var catalogUri = new Uri(baseUri, "stable/catalog.json");
var advisoryUri = new Uri(baseUri, "stable/USN-2025-0002.json");
var manifest = CreateTestManifest(advisoryUri, "USN-2025-0002", "2025-10-18T00:00:00Z");
var indexJson = manifest.IndexJson;
var catalogJson = manifest.CatalogJson.Replace("{{SHA256}}", new string('a', 64), StringComparison.Ordinal);
var handler = new UbuntuTestHttpHandler(indexUri, indexJson, catalogUri, catalogJson, advisoryUri, Encoding.UTF8.GetBytes("{\"document\":\"payload\"}"), expectedEtag: "etag-999");
var httpClient = new HttpClient(handler);
var httpFactory = new SingleClientFactory(httpClient);
var cache = new MemoryCache(new MemoryCacheOptions());
var fileSystem = new MockFileSystem();
var loader = new UbuntuCatalogLoader(httpFactory, cache, fileSystem, NullLogger<UbuntuCatalogLoader>.Instance, TimeProvider.System);
var optionsValidator = new UbuntuConnectorOptionsValidator(fileSystem);
var stateRepository = new InMemoryConnectorStateRepository();
var connector = new UbuntuCsafConnector(
loader,
httpFactory,
stateRepository,
new[] { optionsValidator },
NullLogger<UbuntuCsafConnector>.Instance,
TimeProvider.System);
var settings = BuildConnectorSettings(indexUri);
await connector.ValidateAsync(settings, CancellationToken.None);
@@ -164,17 +173,17 @@ public sealed class UbuntuCsafConnectorTests
var sink = new InMemoryRawSink();
var context = new VexConnectorContext(null, VexConnectorSettings.Empty, sink, new NoopSignatureVerifier(), new NoopNormalizerRouter(), services, ImmutableDictionary<string, string>.Empty);
var documents = new List<VexRawDocument>();
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(doc);
}
documents.Should().BeEmpty();
sink.Documents.Should().BeEmpty();
stateRepository.CurrentState.Should().NotBeNull();
stateRepository.CurrentState!.DocumentDigests.Should().BeEmpty();
var documents = new List<VexRawDocument>();
await foreach (var doc in connector.FetchAsync(context, CancellationToken.None))
{
documents.Add(doc);
}
documents.Should().BeEmpty();
sink.Documents.Should().BeEmpty();
stateRepository.CurrentState.Should().NotBeNull();
stateRepository.CurrentState!.DocumentDigests.Should().BeEmpty();
handler.DocumentRequestCount.Should().Be(1);
providerStore.SavedProviders.Should().ContainSingle();
}
@@ -198,146 +207,183 @@ public sealed class UbuntuCsafConnectorTests
return new VexConnectorSettings(builder.ToImmutable());
}
private static (string IndexJson, string CatalogJson) CreateTestManifest(Uri advisoryUri, string advisoryId, string timestamp)
{
var indexJson = """
{
"generated": "2025-10-18T00:00:00Z",
"channels": [
{
"name": "stable",
"catalogUrl": "{{advisoryUri.GetLeftPart(UriPartial.Authority)}}/security/csaf/stable/catalog.json",
"sha256": "ignore"
}
]
}
""";
var catalogJson = """
{
"resources": [
{
"id": "{{advisoryId}}",
"type": "csaf",
"url": "{{advisoryUri}}",
"last_modified": "{{timestamp}}",
"hashes": {
"sha256": "{{SHA256}}"
},
"etag": "\"etag-123\"",
"title": "{{advisoryId}}"
}
]
}
""";
return (indexJson, catalogJson);
}
private static string ComputeSha256(ReadOnlySpan<byte> payload)
{
Span<byte> buffer = stackalloc byte[32];
SHA256.HashData(payload, buffer);
return Convert.ToHexString(buffer).ToLowerInvariant();
}
private sealed class SingleClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private sealed class UbuntuTestHttpHandler : HttpMessageHandler
{
private readonly Uri _indexUri;
private readonly string _indexPayload;
private readonly Uri _catalogUri;
private readonly string _catalogPayload;
private readonly Uri _documentUri;
private readonly byte[] _documentPayload;
private readonly string _expectedEtag;
public int DocumentRequestCount { get; private set; }
public List<string> SeenIfNoneMatch { get; } = new();
public UbuntuTestHttpHandler(Uri indexUri, string indexPayload, Uri catalogUri, string catalogPayload, Uri documentUri, byte[] documentPayload, string expectedEtag)
{
_indexUri = indexUri;
_indexPayload = indexPayload;
_catalogUri = catalogUri;
_catalogPayload = catalogPayload;
_documentUri = documentUri;
_documentPayload = documentPayload;
_expectedEtag = expectedEtag;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri == _indexUri)
{
return Task.FromResult(CreateJsonResponse(_indexPayload));
}
if (request.RequestUri == _catalogUri)
{
return Task.FromResult(CreateJsonResponse(_catalogPayload));
}
if (request.RequestUri == _documentUri)
{
DocumentRequestCount++;
if (request.Headers.IfNoneMatch is { Count: > 0 })
{
var header = request.Headers.IfNoneMatch.First().ToString();
SeenIfNoneMatch.Add(header);
if (header.Trim('"') == _expectedEtag || header == $"\"{_expectedEtag}\"")
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotModified));
}
}
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(_documentPayload),
};
response.Headers.ETag = new EntityTagHeaderValue($"\"{_expectedEtag}\"");
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
return Task.FromResult(response);
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent($"No response configured for {request.RequestUri}"),
});
}
private static HttpResponseMessage CreateJsonResponse(string payload)
=> new(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
}
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
{
public VexConnectorState? CurrentState { get; private set; }
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(CurrentState);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
CurrentState = state;
return ValueTask.CompletedTask;
}
}
private static (string IndexJson, string CatalogJson) CreateTestManifest(Uri advisoryUri, string advisoryId, string timestamp)
{
var indexJson = """
{
"generated": "2025-10-18T00:00:00Z",
"channels": [
{
"name": "stable",
"catalogUrl": "{{advisoryUri.GetLeftPart(UriPartial.Authority)}}/security/csaf/stable/catalog.json",
"sha256": "ignore"
}
]
}
""";
var catalogJson = """
{
"resources": [
{
"id": "{{advisoryId}}",
"type": "csaf",
"url": "{{advisoryUri}}",
"last_modified": "{{timestamp}}",
"hashes": {
"sha256": "{{SHA256}}"
},
"etag": "\"etag-123\"",
"title": "{{advisoryId}}"
}
]
}
""";
return (indexJson, catalogJson);
}
private static string ComputeSha256(ReadOnlySpan<byte> payload)
{
Span<byte> buffer = stackalloc byte[32];
SHA256.HashData(payload, buffer);
return Convert.ToHexString(buffer).ToLowerInvariant();
}
private sealed class SingleClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public SingleClientFactory(HttpClient client)
{
_client = client;
}
public HttpClient CreateClient(string name) => _client;
}
private static TempMetadataFile CreateTempSignerMetadata(string connectorId, string tier, string fingerprint)
{
var pathTemp = System.IO.Path.GetTempFileName();
var json = $"""
{{
\"schemaVersion\": \"1.0.0\",
\"generatedAt\": \"2025-11-20T00:00:00Z\",
\"connectors\": [
{{
\"connectorId\": \"{connectorId}\",
\"provider\": {{ \"name\": \"{connectorId}\", \"slug\": \"{connectorId}\" }},
\"issuerTier\": \"{tier}\",
\"signers\": [
{{
\"usage\": \"csaf\",
\"fingerprints\": [
{{ \"alg\": \"sha256\", \"format\": \"pgp\", \"value\": \"{fingerprint}\" }}
]
}}
]
}}
]
}}
""";
System.IO.File.WriteAllText(pathTemp, json);
return new TempMetadataFile(pathTemp);
}
private sealed record TempMetadataFile(string Path) : IDisposable
{
public void Dispose()
{
try { System.IO.File.Delete(Path); } catch { }
}
}
private sealed class UbuntuTestHttpHandler : HttpMessageHandler
{
private readonly Uri _indexUri;
private readonly string _indexPayload;
private readonly Uri _catalogUri;
private readonly string _catalogPayload;
private readonly Uri _documentUri;
private readonly byte[] _documentPayload;
private readonly string _expectedEtag;
public int DocumentRequestCount { get; private set; }
public List<string> SeenIfNoneMatch { get; } = new();
public UbuntuTestHttpHandler(Uri indexUri, string indexPayload, Uri catalogUri, string catalogPayload, Uri documentUri, byte[] documentPayload, string expectedEtag)
{
_indexUri = indexUri;
_indexPayload = indexPayload;
_catalogUri = catalogUri;
_catalogPayload = catalogPayload;
_documentUri = documentUri;
_documentPayload = documentPayload;
_expectedEtag = expectedEtag;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri == _indexUri)
{
return Task.FromResult(CreateJsonResponse(_indexPayload));
}
if (request.RequestUri == _catalogUri)
{
return Task.FromResult(CreateJsonResponse(_catalogPayload));
}
if (request.RequestUri == _documentUri)
{
DocumentRequestCount++;
if (request.Headers.IfNoneMatch is { Count: > 0 })
{
var header = request.Headers.IfNoneMatch.First().ToString();
SeenIfNoneMatch.Add(header);
if (header.Trim('"') == _expectedEtag || header == $"\"{_expectedEtag}\"")
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotModified));
}
}
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(_documentPayload),
};
response.Headers.ETag = new EntityTagHeaderValue($"\"{_expectedEtag}\"");
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
return Task.FromResult(response);
}
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent($"No response configured for {request.RequestUri}"),
});
}
private static HttpResponseMessage CreateJsonResponse(string payload)
=> new(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
}
private sealed class InMemoryConnectorStateRepository : IVexConnectorStateRepository
{
public VexConnectorState? CurrentState { get; private set; }
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(CurrentState);
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
CurrentState = state;
return ValueTask.CompletedTask;
}
}
private sealed class InMemoryRawSink : IVexRawDocumentSink
{
public List<VexRawDocument> Documents { get; } = new();
@@ -374,16 +420,16 @@ public sealed class UbuntuCsafConnectorTests
return ValueTask.CompletedTask;
}
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
}
private sealed class NoopSignatureVerifier : IVexSignatureVerifier
{
public ValueTask<VexSignatureMetadata?> VerifyAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult<VexSignatureMetadata?>(null);
}
private sealed class NoopNormalizerRouter : IVexNormalizerRouter
{
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(document, ImmutableArray<VexClaim>.Empty, ImmutableDictionary<string, string>.Empty));
}
}

View File

@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http.Json;
using System.Threading.Tasks;
using FluentAssertions;
using StellaOps.Excititor.WebService.Contracts;
using Xunit;
namespace StellaOps.Excititor.WebService.Tests;
public sealed class AttestationVerifyEndpointTests : IClassFixture<TestWebApplicationFactory>
{
private readonly TestWebApplicationFactory _factory;
public AttestationVerifyEndpointTests(TestWebApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task Verify_ReturnsOk_WhenPayloadValid()
{
var client = _factory.CreateClient();
var request = new AttestationVerifyRequest
{
ExportId = "export-123",
QuerySignature = "purl=foo",
ArtifactDigest = "sha256:deadbeef",
Format = "VexJson",
CreatedAt = DateTimeOffset.Parse("2025-11-20T00:00:00Z"),
SourceProviders = new[] { "ghsa" },
Metadata = new Dictionary<string, string> { { "foo", "bar" } },
Attestation = new AttestationVerifyMetadata
{
PredicateType = "https://stella-ops.org/attestations/vex-export",
EnvelopeDigest = "sha256:abcd",
SignedAt = DateTimeOffset.Parse("2025-11-20T00:00:00Z"),
Rekor = new AttestationRekorReference
{
ApiVersion = "0.2",
Location = "https://rekor.example/log/123",
LogIndex = 1,
InclusionProofUrl = new Uri("https://rekor.example/log/123/proof")
}
},
Envelope = "{}"
};
var response = await client.PostAsJsonAsync("/v1/attestations/verify", request);
response.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await response.Content.ReadFromJsonAsync<AttestationVerifyResponse>();
body.Should().NotBeNull();
body!.Valid.Should().BeTrue();
}
[Fact]
public async Task Verify_ReturnsBadRequest_WhenFieldsMissing()
{
var client = _factory.CreateClient();
var request = new AttestationVerifyRequest
{
ExportId = "", // missing
QuerySignature = "",
ArtifactDigest = "",
Format = "",
Envelope = ""
};
var response = await client.PostAsJsonAsync("/v1/attestations/verify", request);
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
}

View File

@@ -0,0 +1,51 @@
using StellaOps.Findings.Ledger.Infrastructure.Exports;
using Xunit;
namespace StellaOps.Findings.Ledger.Tests.Exports;
public class ExportPagingTests
{
[Fact]
public void ComputeFiltersHash_IsDeterministic()
{
var left = new Dictionary<string, string?>
{
["shape"] = "canonical",
["since_sequence"] = "10",
["until_sequence"] = "20"
};
var right = new Dictionary<string, string?>
{
["until_sequence"] = "20",
["shape"] = "canonical",
["since_sequence"] = "10"
};
var leftHash = ExportPaging.ComputeFiltersHash(left);
var rightHash = ExportPaging.ComputeFiltersHash(right);
Assert.Equal(leftHash, rightHash);
}
[Fact]
public void PageToken_RoundTrips()
{
var key = new ExportPaging.ExportPageKey(5, "v1", "abc123");
var filtersHash = ExportPaging.ComputeFiltersHash(new Dictionary<string, string?>
{
["shape"] = "canonical"
});
var token = ExportPaging.CreatePageToken(key, filtersHash);
var parsed = ExportPaging.TryParsePageToken(token, filtersHash, out var recovered, out var error);
Assert.True(parsed);
Assert.Null(error);
Assert.NotNull(recovered);
Assert.Equal(key.SequenceNumber, recovered!.SequenceNumber);
Assert.Equal(key.PolicyVersion, recovered.PolicyVersion);
Assert.Equal(key.CycleHash, recovered.CycleHash);
}
}

View File

@@ -4,6 +4,8 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DefaultItemExcludes>$(DefaultItemExcludes);**/tools/**/*</DefaultItemExcludes>
<DisableTransitiveProjectReferences>true</DisableTransitiveProjectReferences>
</PropertyGroup>
<ItemGroup>

View File

@@ -275,29 +275,7 @@ app.MapGet("/ledger/export/findings", async Task<Results<FileStreamHttpResult, J
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "page_token_filters_mismatch");
}
if (!string.IsNullOrEmpty(page.NextPageToken))
{
httpContext.Response.Headers["X-Stella-Next-Page-Token"] = page.NextPageToken;
}
httpContext.Response.Headers["X-Stella-Result-Count"] = page.Items.Count.ToString();
var acceptsNdjson = httpContext.Request.Headers.Accept.Any(h => h.Contains("application/x-ndjson", StringComparison.OrdinalIgnoreCase));
if (acceptsNdjson)
{
httpContext.Response.ContentType = "application/x-ndjson";
var stream = new MemoryStream();
await using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { SkipValidation = false, Indented = false });
foreach (var item in page.Items)
{
JsonSerializer.Serialize(writer, item);
writer.Flush();
await stream.WriteAsync(new byte[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false);
}
stream.Position = 0;
return TypedResults.Stream(stream, contentType: "application/x-ndjson");
}
return TypedResults.Json(page);
return await WritePagedResponse(httpContext, page, cancellationToken).ConfigureAwait(false);
})
.WithName("LedgerExportFindings")
.RequireAuthorization(LedgerExportPolicy)
@@ -342,3 +320,33 @@ static LedgerEventResponse CreateResponse(LedgerEventRecord record, string statu
MerkleLeafHash = record.MerkleLeafHash,
RecordedAt = record.RecordedAt
};
static async Task<Results<FileStreamHttpResult, JsonHttpResult<ExportPage<T>>, ProblemHttpResult>> WritePagedResponse<T>(
HttpContext httpContext,
ExportPage<T> page,
CancellationToken cancellationToken)
{
if (!string.IsNullOrEmpty(page.NextPageToken))
{
httpContext.Response.Headers["X-Stella-Next-Page-Token"] = page.NextPageToken;
}
httpContext.Response.Headers["X-Stella-Result-Count"] = page.Items.Count.ToString();
var acceptsNdjson = httpContext.Request.Headers.Accept.Any(h => h.Contains("application/x-ndjson", StringComparison.OrdinalIgnoreCase));
if (acceptsNdjson)
{
httpContext.Response.ContentType = "application/x-ndjson";
var stream = new MemoryStream();
await using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { SkipValidation = false, Indented = false });
foreach (var item in page.Items)
{
JsonSerializer.Serialize(writer, item);
writer.Flush();
await stream.WriteAsync(new byte[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false);
}
stream.Position = 0;
return TypedResults.Stream(stream, contentType: "application/x-ndjson");
}
return TypedResults.Json(page);
}

View File

@@ -0,0 +1,97 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.WebUtilities;
namespace StellaOps.Findings.Ledger.Infrastructure.Exports;
public static class ExportPaging
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static string ComputeFiltersHash(IReadOnlyDictionary<string, string?> filters)
{
var builder = new StringBuilder();
foreach (var pair in filters.OrderBy(kv => kv.Key, StringComparer.Ordinal))
{
builder.Append(pair.Key).Append('=').Append(pair.Value ?? string.Empty).Append(';');
}
using var sha = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
var hash = sha.ComputeHash(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
public static string CreatePageToken(ExportPageKey key, string filtersHash)
{
var payload = new ExportPageToken
{
FiltersHash = filtersHash,
Last = key
};
var json = JsonSerializer.Serialize(payload, SerializerOptions);
return WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(json));
}
public static bool TryParsePageToken(string token, string expectedFiltersHash, out ExportPageKey? key, out string? error)
{
key = null;
error = null;
byte[] decoded;
try
{
decoded = WebEncoders.Base64UrlDecode(token);
}
catch (FormatException)
{
error = "invalid_page_token_encoding";
return false;
}
ExportPageToken? payload;
try
{
payload = JsonSerializer.Deserialize<ExportPageToken>(decoded, SerializerOptions);
}
catch (JsonException)
{
error = "invalid_page_token_payload";
return false;
}
if (payload is null || payload.Last is null)
{
error = "invalid_page_token_payload";
return false;
}
if (!string.Equals(payload.FiltersHash, expectedFiltersHash, StringComparison.Ordinal))
{
error = "page_token_filters_mismatch";
return false;
}
key = payload.Last;
return true;
}
public sealed record ExportPageKey(long SequenceNumber, string PolicyVersion, string CycleHash);
private sealed class ExportPageToken
{
[JsonPropertyName("filters_hash")]
public string FiltersHash { get; set; } = string.Empty;
[JsonPropertyName("last")]
public ExportPageKey? Last { get; set; }
}
}

View File

@@ -31,11 +31,11 @@ internal static class LedgerMetrics
public static void RecordWriteSuccess(TimeSpan duration, string? tenantId, string? eventType, string? source)
{
var tags = new TagList
var tags = new KeyValuePair<string, object?>[]
{
{ "tenant", tenantId ?? string.Empty },
{ "event_type", eventType ?? string.Empty },
{ "source", source ?? string.Empty }
new("tenant", tenantId ?? string.Empty),
new("event_type", eventType ?? string.Empty),
new("source", source ?? string.Empty)
};
WriteLatencySeconds.Record(duration.TotalSeconds, tags);
@@ -50,12 +50,12 @@ internal static class LedgerMetrics
string? policyVersion,
string? evaluationStatus)
{
var tags = new TagList
var tags = new KeyValuePair<string, object?>[]
{
{ "tenant", tenantId ?? string.Empty },
{ "event_type", eventType ?? string.Empty },
{ "policy_version", policyVersion ?? string.Empty },
{ "evaluation_status", evaluationStatus ?? string.Empty }
new("tenant", tenantId ?? string.Empty),
new("event_type", eventType ?? string.Empty),
new("policy_version", policyVersion ?? string.Empty),
new("evaluation_status", evaluationStatus ?? string.Empty)
};
ProjectionApplySeconds.Record(duration.TotalSeconds, tags);

View File

@@ -4,12 +4,19 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DefaultItemExcludes>$(DefaultItemExcludes);tools/**/*</DefaultItemExcludes>
</PropertyGroup>
<ItemGroup>
<None Include="migrations\**\*" Pack="false" CopyToOutputDirectory="Never" />
</ItemGroup>
<ItemGroup>
<Compile Remove="tools/**/*.cs" />
<None Remove="tools/**/*" />
<None Include="tools/**/*" Pack="false" CopyToOutputDirectory="Never" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0-rc.2.25502.107" />

View File

@@ -456,7 +456,13 @@ internal sealed class NoOpPolicyEvaluationService : IPolicyEvaluationService
{
public Task<PolicyEvaluationResult> EvaluateAsync(LedgerEventRecord record, FindingProjection? current, CancellationToken cancellationToken)
{
return Task.FromResult(new PolicyEvaluationResult("noop", record.OccurredAt, record.RecordedAt, current?.Status ?? "new"));
var labels = new JsonObject();
return Task.FromResult(new PolicyEvaluationResult(
Status: current?.Status ?? "new",
Severity: current?.Severity,
Labels: labels,
ExplainRef: null,
Rationale: new JsonArray()));
}
}
@@ -465,9 +471,9 @@ internal sealed class NoOpProjectionRepository : IFindingProjectionRepository
public Task<FindingProjection?> GetAsync(string tenantId, string findingId, string policyVersion, CancellationToken cancellationToken) =>
Task.FromResult<FindingProjection?>(null);
public Task InsertActionAsync(FindingAction action, CancellationToken cancellationToken) => Task.CompletedTask;
public Task InsertActionAsync(TriageActionEntry entry, CancellationToken cancellationToken) => Task.CompletedTask;
public Task InsertHistoryAsync(FindingHistory history, CancellationToken cancellationToken) => Task.CompletedTask;
public Task InsertHistoryAsync(FindingHistoryEntry entry, CancellationToken cancellationToken) => Task.CompletedTask;
public Task SaveCheckpointAsync(ProjectionCheckpoint checkpoint, CancellationToken cancellationToken) => Task.CompletedTask;
@@ -475,17 +481,12 @@ internal sealed class NoOpProjectionRepository : IFindingProjectionRepository
Task.FromResult(new ProjectionCheckpoint(DateTimeOffset.MinValue, Guid.Empty, DateTimeOffset.MinValue));
public Task UpsertAsync(FindingProjection projection, CancellationToken cancellationToken) => Task.CompletedTask;
public Task EnsureIndexesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
internal sealed class NoOpMerkleAnchorRepository : IMerkleAnchorRepository
{
public Task InsertAsync(string tenantId, Guid anchorId, DateTimeOffset windowStart, DateTimeOffset windowEnd, long sequenceStart, long sequenceEnd, string rootHash, long leafCount, DateTime anchoredAt, string? anchorReference, CancellationToken cancellationToken)
public Task InsertAsync(string tenantId, Guid anchorId, DateTimeOffset windowStart, DateTimeOffset windowEnd, long sequenceStart, long sequenceEnd, string rootHash, int leafCount, DateTimeOffset anchoredAt, string? anchorReference, CancellationToken cancellationToken)
=> Task.CompletedTask;
public Task<MerkleAnchor?> GetLatestAsync(string tenantId, CancellationToken cancellationToken) =>
Task.FromResult<MerkleAnchor?>(null);
}
internal sealed class QueueMerkleAnchorScheduler : IMerkleAnchorScheduler

5
src/SbomService/TASKS.md Normal file
View File

@@ -0,0 +1,5 @@
# SbomService Tasks (prep sync)
| Task ID | Status | Notes | Updated (UTC) |
| --- | --- | --- | --- |
| PREP-SBOM-CONSOLE-23-001-BUILD-TEST-FAILING-D | DONE | Offline feed cache + script added; see `docs/modules/sbomservice/offline-feed-plan.md`. | 2025-11-20 |

View File

@@ -441,6 +441,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugin.
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Ingestion.Telemetry", "__Libraries\StellaOps.Ingestion.Telemetry\StellaOps.Ingestion.Telemetry.csproj", "{FB2C1275-6C67-403C-8F21-B07A48C74FE4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Time", "AirGap\StellaOps.AirGap.Time\StellaOps.AirGap.Time.csproj", "{0B4DD2CC-19C8-4FE0-A2DE-076A5FF1B704}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Importer", "AirGap\StellaOps.AirGap.Importer\StellaOps.AirGap.Importer.csproj", "{D3829E4D-6538-4533-A0E0-3418042D7BFE}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -2887,6 +2891,30 @@ Global
{FB2C1275-6C67-403C-8F21-B07A48C74FE4}.Release|x64.Build.0 = Release|Any CPU
{FB2C1275-6C67-403C-8F21-B07A48C74FE4}.Release|x86.ActiveCfg = Release|Any CPU
{FB2C1275-6C67-403C-8F21-B07A48C74FE4}.Release|x86.Build.0 = Release|Any CPU
{0B4DD2CC-19C8-4FE0-A2DE-076A5FF1B704}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0B4DD2CC-19C8-4FE0-A2DE-076A5FF1B704}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0B4DD2CC-19C8-4FE0-A2DE-076A5FF1B704}.Debug|x64.ActiveCfg = Debug|Any CPU
{0B4DD2CC-19C8-4FE0-A2DE-076A5FF1B704}.Debug|x64.Build.0 = Debug|Any CPU
{0B4DD2CC-19C8-4FE0-A2DE-076A5FF1B704}.Debug|x86.ActiveCfg = Debug|Any CPU
{0B4DD2CC-19C8-4FE0-A2DE-076A5FF1B704}.Debug|x86.Build.0 = Debug|Any CPU
{0B4DD2CC-19C8-4FE0-A2DE-076A5FF1B704}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0B4DD2CC-19C8-4FE0-A2DE-076A5FF1B704}.Release|Any CPU.Build.0 = Release|Any CPU
{0B4DD2CC-19C8-4FE0-A2DE-076A5FF1B704}.Release|x64.ActiveCfg = Release|Any CPU
{0B4DD2CC-19C8-4FE0-A2DE-076A5FF1B704}.Release|x64.Build.0 = Release|Any CPU
{0B4DD2CC-19C8-4FE0-A2DE-076A5FF1B704}.Release|x86.ActiveCfg = Release|Any CPU
{0B4DD2CC-19C8-4FE0-A2DE-076A5FF1B704}.Release|x86.Build.0 = Release|Any CPU
{D3829E4D-6538-4533-A0E0-3418042D7BFE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D3829E4D-6538-4533-A0E0-3418042D7BFE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D3829E4D-6538-4533-A0E0-3418042D7BFE}.Debug|x64.ActiveCfg = Debug|Any CPU
{D3829E4D-6538-4533-A0E0-3418042D7BFE}.Debug|x64.Build.0 = Debug|Any CPU
{D3829E4D-6538-4533-A0E0-3418042D7BFE}.Debug|x86.ActiveCfg = Debug|Any CPU
{D3829E4D-6538-4533-A0E0-3418042D7BFE}.Debug|x86.Build.0 = Debug|Any CPU
{D3829E4D-6538-4533-A0E0-3418042D7BFE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D3829E4D-6538-4533-A0E0-3418042D7BFE}.Release|Any CPU.Build.0 = Release|Any CPU
{D3829E4D-6538-4533-A0E0-3418042D7BFE}.Release|x64.ActiveCfg = Release|Any CPU
{D3829E4D-6538-4533-A0E0-3418042D7BFE}.Release|x64.Build.0 = Release|Any CPU
{D3829E4D-6538-4533-A0E0-3418042D7BFE}.Release|x86.ActiveCfg = Release|Any CPU
{D3829E4D-6538-4533-A0E0-3418042D7BFE}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -3022,5 +3050,7 @@ Global
{D913460C-2054-48F0-B274-894A94A8DD7E} = {D09AE309-2C35-6780-54D1-97CCC67DFFDE}
{AAB54944-813D-4596-B6A9-F0014523F97D} = {D09AE309-2C35-6780-54D1-97CCC67DFFDE}
{FB2C1275-6C67-403C-8F21-B07A48C74FE4} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{0B4DD2CC-19C8-4FE0-A2DE-076A5FF1B704} = {704A59BF-CC38-09FA-CE4F-73B27EC8F04F}
{D3829E4D-6538-4533-A0E0-3418042D7BFE} = {704A59BF-CC38-09FA-CE4F-73B27EC8F04F}
EndGlobalSection
EndGlobal