Add new features and tests for AirGap and Time modules
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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:
@@ -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[]>());
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.AirGap.Importer.Models;
|
||||
|
||||
public sealed record BundleCatalogEntry(
|
||||
string TenantId,
|
||||
string BundleId,
|
||||
string Digest,
|
||||
DateTimeOffset ImportedAtUtc,
|
||||
IReadOnlyList<string> ContentPaths);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.AirGap.Importer.Models;
|
||||
|
||||
public sealed record BundleItem(
|
||||
string TenantId,
|
||||
string BundleId,
|
||||
string Path,
|
||||
string Digest,
|
||||
long SizeBytes);
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
28
src/AirGap/StellaOps.AirGap.Time/Models/SetAnchorRequest.cs
Normal file
28
src/AirGap/StellaOps.AirGap.Time/Models/SetAnchorRequest.cs
Normal 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; }
|
||||
}
|
||||
22
src/AirGap/StellaOps.AirGap.Time/Models/StalenessBudget.cs
Normal file
22
src/AirGap/StellaOps.AirGap.Time/Models/StalenessBudget.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
14
src/AirGap/StellaOps.AirGap.Time/Models/TimeAnchor.cs
Normal file
14
src/AirGap/StellaOps.AirGap.Time/Models/TimeAnchor.cs
Normal 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", "", "");
|
||||
}
|
||||
10
src/AirGap/StellaOps.AirGap.Time/Models/TimeStatus.cs
Normal file
10
src/AirGap/StellaOps.AirGap.Time/Models/TimeStatus.cs
Normal 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);
|
||||
}
|
||||
44
src/AirGap/StellaOps.AirGap.Time/Models/TimeStatusDto.cs
Normal file
44
src/AirGap/StellaOps.AirGap.Time/Models/TimeStatusDto.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
3
src/AirGap/StellaOps.AirGap.Time/Models/TimeTrustRoot.cs
Normal file
3
src/AirGap/StellaOps.AirGap.Time/Models/TimeTrustRoot.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace StellaOps.AirGap.Time.Models;
|
||||
|
||||
public sealed record TimeTrustRoot(string KeyId, byte[] PublicKey, string Algorithm);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.AirGap.Time.Parsing;
|
||||
|
||||
public enum TimeTokenFormat
|
||||
{
|
||||
Roughtime,
|
||||
Rfc3161
|
||||
}
|
||||
41
src/AirGap/StellaOps.AirGap.Time/Parsing/TimeTokenParser.cs
Normal file
41
src/AirGap/StellaOps.AirGap.Time/Parsing/TimeTokenParser.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/AirGap/StellaOps.AirGap.Time/Program.cs
Normal file
19
src/AirGap/StellaOps.AirGap.Time/Program.cs
Normal 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();
|
||||
@@ -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);
|
||||
}
|
||||
33
src/AirGap/StellaOps.AirGap.Time/Services/Rfc3161Verifier.cs
Normal file
33
src/AirGap/StellaOps.AirGap.Time/Services/Rfc3161Verifier.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
308201223081c9a0030201020404c78a5540300d06092a864886f70d01010b0500300d310b3009060355040313025441301e170d3233313132303130303030305a170d3234313132393130303030305a300d310b300906035504031302544130820122300d06092a864886f70d01010105000382010f003082010a0282010100c3e8c4a1b2f7f6...
|
||||
@@ -0,0 +1 @@
|
||||
0102030473616d706c652d726f75676874696d652d746f6b656e00
|
||||
17
src/AirGap/TASKS.md
Normal file
17
src/AirGap/TASKS.md
Normal 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 |
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DefaultItemExcludes>$(DefaultItemExcludes);**/tools/**/*</DefaultItemExcludes>
|
||||
<DisableTransitiveProjectReferences>true</DisableTransitiveProjectReferences>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
5
src/SbomService/TASKS.md
Normal 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 |
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user