Add SBOM, symbols, traces, and VEX files for CVE-2022-21661 SQLi case
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Created CycloneDX and SPDX SBOM files for both reachable and unreachable images.
- Added symbols.json detailing function entry and sink points in the WordPress code.
- Included runtime traces for function calls in both reachable and unreachable scenarios.
- Developed OpenVEX files indicating vulnerability status and justification for both cases.
- Updated README for evaluator harness to guide integration with scanner output.
This commit is contained in:
master
2025-11-08 20:53:45 +02:00
parent 515975edc5
commit 536f6249a6
837 changed files with 37279 additions and 14675 deletions

View File

@@ -1,23 +1,20 @@
using System.Collections.Generic;
using Microsoft.Extensions.Logging.Abstractions;
using Mongo2Go;
using MongoDB.Bson;
using MongoDB.Driver;
namespace StellaOps.Excititor.Storage.Mongo.Tests;
public sealed class MongoVexCacheMaintenanceTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner;
private readonly IMongoDatabase _database;
public MongoVexCacheMaintenanceTests()
{
_runner = MongoDbRunner.Start();
var client = new MongoClient(_runner.ConnectionString);
_database = client.GetDatabase("vex-cache-maintenance-tests");
VexMongoMappingRegistry.Register();
}
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Bson;
using MongoDB.Driver;
namespace StellaOps.Excititor.Storage.Mongo.Tests;
public sealed class MongoVexCacheMaintenanceTests : IAsyncLifetime
{
private readonly TestMongoEnvironment _mongo = new();
private readonly IMongoDatabase _database;
public MongoVexCacheMaintenanceTests()
{
_database = _mongo.CreateDatabase("cache-maintenance");
VexMongoMappingRegistry.Register();
}
[Fact]
public async Task RemoveExpiredAsync_DeletesEntriesBeforeCutoff()
@@ -114,9 +111,5 @@ public sealed class MongoVexCacheMaintenanceTests : IAsyncLifetime
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
}
public Task DisposeAsync() => _mongo.DisposeAsync();
}

View File

@@ -3,7 +3,6 @@ using System.Globalization;
using System.Linq;
using System.Text;
using Microsoft.Extensions.Options;
using Mongo2Go;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Aoc;
@@ -13,21 +12,20 @@ using RawVexDocumentModel = StellaOps.Concelier.RawModels.VexRawDocument;
namespace StellaOps.Excititor.Storage.Mongo.Tests;
public sealed class MongoVexRepositoryTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner;
private readonly MongoClient _client;
public MongoVexRepositoryTests()
{
_runner = MongoDbRunner.Start();
_client = new MongoClient(_runner.ConnectionString);
}
public sealed class MongoVexRepositoryTests : IAsyncLifetime
{
private readonly TestMongoEnvironment _mongo = new();
private readonly MongoClient _client;
public MongoVexRepositoryTests()
{
_client = _mongo.Client;
}
[Fact]
public async Task RawStore_UsesGridFsForLargePayloads()
{
var database = _client.GetDatabase($"vex-raw-gridfs-{Guid.NewGuid():N}");
var database = _mongo.CreateDatabase("vex-raw-gridfs");
var store = CreateRawStore(database, thresholdBytes: 32);
var payload = CreateJsonPayload(new string('A', 256));
@@ -63,7 +61,7 @@ public sealed class MongoVexRepositoryTests : IAsyncLifetime
[Fact]
public async Task RawStore_ReplacesGridFsWithInlinePayload()
{
var database = _client.GetDatabase($"vex-raw-inline-{Guid.NewGuid():N}");
var database = _mongo.CreateDatabase("vex-raw-inline");
var store = CreateRawStore(database, thresholdBytes: 16);
var largePayload = CreateJsonPayload(new string('B', 128));
@@ -176,7 +174,7 @@ public sealed class MongoVexRepositoryTests : IAsyncLifetime
[Fact]
public async Task ExportStore_FindAsync_ExpiresCacheEntries()
{
var database = _client.GetDatabase($"vex-export-expire-{Guid.NewGuid():N}");
var database = _mongo.CreateDatabase("vex-export-expire");
var options = Options.Create(new VexMongoStorageOptions
{
ExportCacheTtl = TimeSpan.FromMinutes(5),
@@ -217,7 +215,7 @@ public sealed class MongoVexRepositoryTests : IAsyncLifetime
[Fact]
public async Task ClaimStore_AppendsAndQueriesStatements()
{
var database = _client.GetDatabase($"vex-claims-{Guid.NewGuid():N}");
var database = _mongo.CreateDatabase("vex-claims");
var store = new MongoVexClaimStore(database);
var product = new VexProduct("pkg:demo/app", "Demo App", version: "1.0.0", purl: "pkg:demo/app@1.0.0");
@@ -305,11 +303,7 @@ public sealed class MongoVexRepositoryTests : IAsyncLifetime
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
public Task DisposeAsync() => _mongo.DisposeAsync();
private static byte[] CreateJsonPayload(string value)
=> Encoding.UTF8.GetBytes(CreateJsonPayloadString(value));

View File

@@ -2,23 +2,23 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Mongo2Go;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo.Tests;
public sealed class MongoVexSessionConsistencyTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner;
public MongoVexSessionConsistencyTests()
{
_runner = MongoDbRunner.Start(singleNodeReplSet: true);
}
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo.Tests;
public sealed class MongoVexSessionConsistencyTests : IAsyncLifetime
{
private readonly TestMongoEnvironment _mongo = new();
private readonly MongoClient _client;
public MongoVexSessionConsistencyTests()
{
_client = _mongo.Client;
}
[Fact]
public async Task SessionProvidesReadYourWrites()
@@ -45,7 +45,7 @@ public sealed class MongoVexSessionConsistencyTests : IAsyncLifetime
await using var provider = BuildServiceProvider();
await using var scope = provider.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IMongoClient>();
var client = scope.ServiceProvider.GetRequiredService<IMongoClient>();
var sessionProvider = scope.ServiceProvider.GetRequiredService<IVexMongoSessionProvider>();
var providerStore = scope.ServiceProvider.GetRequiredService<IVexProviderStore>();
@@ -74,18 +74,18 @@ public sealed class MongoVexSessionConsistencyTests : IAsyncLifetime
private ServiceProvider BuildServiceProvider()
{
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddDebug());
services.Configure<VexMongoStorageOptions>(options =>
{
options.ConnectionString = _runner.ConnectionString;
options.DatabaseName = $"excititor-session-tests-{Guid.NewGuid():N}";
options.CommandTimeout = TimeSpan.FromSeconds(5);
options.RawBucketName = "vex.raw";
});
services.AddExcititorMongoStorage();
return services.BuildServiceProvider();
}
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddDebug());
services.Configure<VexMongoStorageOptions>(options =>
{
options.ConnectionString = _mongo.ConnectionString;
options.DatabaseName = _mongo.ReserveDatabase("session");
options.CommandTimeout = TimeSpan.FromSeconds(5);
options.RawBucketName = "vex.raw";
});
services.AddExcititorMongoStorage();
return services.BuildServiceProvider();
}
private static async Task ExecuteWithRetryAsync(Func<Task> action, CancellationToken cancellationToken)
{
@@ -176,9 +176,5 @@ public sealed class MongoVexSessionConsistencyTests : IAsyncLifetime
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
}
public Task DisposeAsync() => _mongo.DisposeAsync();
}

View File

@@ -3,22 +3,22 @@ using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Mongo2Go;
using Microsoft.Extensions.Logging;
using System.Text;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Aoc;
namespace StellaOps.Excititor.Storage.Mongo.Tests;
public sealed class MongoVexStatementBackfillServiceTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner;
public MongoVexStatementBackfillServiceTests()
{
_runner = MongoDbRunner.Start(singleNodeReplSet: true);
}
using RawVexDocumentModel = StellaOps.Concelier.RawModels.VexRawDocument;
namespace StellaOps.Excititor.Storage.Mongo.Tests;
public sealed class MongoVexStatementBackfillServiceTests : IAsyncLifetime
{
private readonly TestMongoEnvironment _mongo = new();
public MongoVexStatementBackfillServiceTests()
{
// Intentionally left blank; Mongo environment is initialized on demand.
}
[Fact]
public async Task RunAsync_BackfillsStatementsFromRawDocuments()
@@ -108,34 +108,32 @@ public sealed class MongoVexStatementBackfillServiceTests : IAsyncLifetime
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddDebug());
services.AddSingleton(TimeProvider.System);
services.Configure<VexMongoStorageOptions>(options =>
{
options.ConnectionString = _runner.ConnectionString;
options.DatabaseName = $"excititor-backfill-tests-{Guid.NewGuid():N}";
options.CommandTimeout = TimeSpan.FromSeconds(5);
options.RawBucketName = "vex.raw";
options.GridFsInlineThresholdBytes = 1024;
options.ExportCacheTtl = TimeSpan.FromHours(1);
});
services.Configure<VexMongoStorageOptions>(options =>
{
options.ConnectionString = _mongo.ConnectionString;
options.DatabaseName = _mongo.ReserveDatabase("backfill");
options.CommandTimeout = TimeSpan.FromSeconds(5);
options.RawBucketName = "vex.raw";
options.GridFsInlineThresholdBytes = 1024;
options.ExportCacheTtl = TimeSpan.FromHours(1);
options.DefaultTenant = "tests";
});
services.AddExcititorMongoStorage();
services.AddExcititorAocGuards();
services.AddSingleton<IVexRawWriteGuard, PermissiveVexRawWriteGuard>();
services.AddSingleton<IVexNormalizer, TestNormalizer>();
return services.BuildServiceProvider();
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
public Task DisposeAsync() => _mongo.DisposeAsync();
private static ReadOnlyMemory<byte> CreateJsonPayload(string value)
=> Encoding.UTF8.GetBytes($"{{\"data\":\"{value}\"}}");
private sealed class TestNormalizer : IVexNormalizer
{
private sealed class TestNormalizer : IVexNormalizer
{
public string Format => "csaf";
public bool CanHandle(VexRawDocument document) => true;
@@ -171,6 +169,14 @@ public sealed class MongoVexStatementBackfillServiceTests : IAsyncLifetime
var claims = ImmutableArray.Create(claim);
return ValueTask.FromResult(new VexClaimBatch(document, claims, ImmutableDictionary<string, string>.Empty));
}
}
}
}
}
private sealed class PermissiveVexRawWriteGuard : IVexRawWriteGuard
{
public void EnsureValid(RawVexDocumentModel document)
{
// Tests control the payloads; guard bypass keeps focus on backfill logic.
}
}
}

View File

@@ -1,23 +1,20 @@
using System.Globalization;
using Mongo2Go;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo.Tests;
public sealed class MongoVexStoreMappingTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner;
private readonly IMongoDatabase _database;
public MongoVexStoreMappingTests()
{
_runner = MongoDbRunner.Start();
var client = new MongoClient(_runner.ConnectionString);
_database = client.GetDatabase("excititor-storage-mapping-tests");
VexMongoMappingRegistry.Register();
}
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Storage.Mongo.Tests;
public sealed class MongoVexStoreMappingTests : IAsyncLifetime
{
private readonly TestMongoEnvironment _mongo = new();
private readonly IMongoDatabase _database;
public MongoVexStoreMappingTests()
{
_database = _mongo.CreateDatabase("storage-mapping");
VexMongoMappingRegistry.Register();
}
[Fact]
public async Task ProviderStore_RoundTrips_WithExtraFields()
@@ -259,9 +256,5 @@ public sealed class MongoVexStoreMappingTests : IAsyncLifetime
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
}
public Task DisposeAsync() => _mongo.DisposeAsync();
}

View File

@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using Mongo2Go;
using MongoDB.Bson;
using MongoDB.Driver;
namespace StellaOps.Excititor.Storage.Mongo.Tests;
internal sealed class TestMongoEnvironment : IAsyncLifetime
{
private const string Prefix = "exstor";
private readonly MongoDbRunner? _runner;
private readonly HashSet<string> _reservedDatabases = new(StringComparer.Ordinal);
public TestMongoEnvironment()
{
var overrideConnection = Environment.GetEnvironmentVariable("EXCITITOR_TEST_MONGO_URI");
if (!string.IsNullOrWhiteSpace(overrideConnection))
{
ConnectionString = overrideConnection.Trim();
Client = new MongoClient(ConnectionString);
return;
}
_runner = MongoDbRunner.Start(singleNodeReplSet: true);
ConnectionString = _runner.ConnectionString;
Client = new MongoClient(ConnectionString);
}
public MongoClient Client { get; }
public string ConnectionString { get; }
public string ReserveDatabase(string hint)
{
var baseName = string.IsNullOrWhiteSpace(hint) ? "db" : hint.ToLowerInvariant();
var builder = new StringBuilder(baseName.Length);
foreach (var ch in baseName)
{
builder.Append(char.IsLetterOrDigit(ch) ? ch : '_');
}
var slug = builder.Length == 0 ? "db" : builder.ToString();
var suffix = ObjectId.GenerateNewId().ToString();
var maxSlugLength = Math.Max(1, 60 - Prefix.Length - suffix.Length - 2);
if (slug.Length > maxSlugLength)
{
slug = slug[..maxSlugLength];
}
var name = $"{Prefix}_{slug}_{suffix}";
_reservedDatabases.Add(name);
return name;
}
public IMongoDatabase CreateDatabase(string hint)
{
var name = ReserveDatabase(hint);
return Client.GetDatabase(name);
}
public Task InitializeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
if (_runner is not null)
{
_runner.Dispose();
return;
}
foreach (var db in _reservedDatabases)
{
try
{
await Client.DropDatabaseAsync(db);
}
catch (MongoException)
{
// best-effort cleanup when sharing a developer-managed instance.
}
}
_reservedDatabases.Clear();
}
}

View File

@@ -1,25 +1,22 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Mongo2Go;
using MongoDB.Driver;
using StellaOps.Excititor.Storage.Mongo.Migrations;
using StellaOps.Excititor.Storage.Mongo;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Driver;
using StellaOps.Excititor.Storage.Mongo.Migrations;
using StellaOps.Excititor.Storage.Mongo;
namespace StellaOps.Excititor.Storage.Mongo.Tests;
public sealed class VexMongoMigrationRunnerTests : IAsyncLifetime
{
private readonly MongoDbRunner _runner;
private readonly IMongoDatabase _database;
public VexMongoMigrationRunnerTests()
{
_runner = MongoDbRunner.Start();
var client = new MongoClient(_runner.ConnectionString);
_database = client.GetDatabase("excititor-migrations-tests");
}
public sealed class VexMongoMigrationRunnerTests : IAsyncLifetime
{
private readonly TestMongoEnvironment _mongo = new();
private readonly IMongoDatabase _database;
public VexMongoMigrationRunnerTests()
{
_database = _mongo.CreateDatabase("migrations");
}
[Fact]
public async Task RunAsync_AppliesInitialIndexesOnce()
@@ -60,9 +57,5 @@ public sealed class VexMongoMigrationRunnerTests : IAsyncLifetime
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
_runner.Dispose();
return Task.CompletedTask;
}
}
public Task DisposeAsync() => _mongo.DisposeAsync();
}

View File

@@ -0,0 +1,451 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using EphemeralMongo;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Concelier.RawModels;
using StellaOps.Excititor.WebService.Contracts;
using StellaOps.Ingestion.Telemetry;
using Xunit;
namespace StellaOps.Excititor.WebService.Tests;
public sealed class BatchIngestValidationTests : IDisposable
{
private const string Tenant = "tests";
private readonly IMongoRunner _runner;
private readonly TestWebApplicationFactory _factory;
public BatchIngestValidationTests()
{
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
_factory = new TestWebApplicationFactory(
configureConfiguration: configuration =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
["Excititor:Storage:Mongo:DatabaseName"] = "vex_batch_tests",
["Excititor:Storage:Mongo:DefaultTenant"] = Tenant,
});
},
configureServices: services =>
{
TestServiceOverrides.Apply(services);
services.AddTestAuthentication();
});
}
[Fact]
[Trait("Category", "BatchIngestValidation")]
public async Task BatchFixturesMaintainParityMetricsAndVerify()
{
using var metrics = new IngestionMetricListener();
using var client = CreateClient();
var fixtures = VexFixtureLibrary.CreateBatch();
foreach (var fixture in fixtures)
{
var ingestResponse = await client.PostAsJsonAsync("/ingest/vex", fixture.Request);
ingestResponse.EnsureSuccessStatusCode();
var payload = await ingestResponse.Content.ReadFromJsonAsync<VexIngestResponse>();
Assert.NotNull(payload);
fixture.RecordDigest(payload!.Digest);
}
var listResponse = await client.GetAsync($"/vex/raw?limit={fixtures.Count * 2}");
listResponse.EnsureSuccessStatusCode();
var listPayload = await listResponse.Content.ReadFromJsonAsync<VexRawListResponse>();
Assert.NotNull(listPayload);
foreach (var fixture in fixtures)
{
Assert.Contains(listPayload!.Records, record => record.Digest == fixture.Digest);
}
foreach (var fixture in fixtures)
{
var recordResponse = await client.GetAsync($"/vex/raw/{Uri.EscapeDataString(fixture.Digest)}");
recordResponse.EnsureSuccessStatusCode();
var record = await recordResponse.Content.ReadFromJsonAsync<VexRawRecordResponse>();
Assert.NotNull(record);
fixture.AssertRecord(record!);
}
var verifyRequest = new VexAocVerifyRequest(
DateTimeOffset.UtcNow.AddMinutes(-5),
DateTimeOffset.UtcNow.AddMinutes(5),
fixtures.Count + 5,
null,
null);
var verifyResponse = await client.PostAsJsonAsync("/aoc/verify", verifyRequest);
verifyResponse.EnsureSuccessStatusCode();
var verifyPayload = await verifyResponse.Content.ReadFromJsonAsync<VexAocVerifyResponse>();
Assert.NotNull(verifyPayload);
Assert.Equal(Tenant, verifyPayload!.Tenant);
Assert.Equal(fixtures.Count, verifyPayload.Checked.Vex);
Assert.Empty(verifyPayload.Violations);
Assert.Equal(fixtures.Count, verifyPayload.Metrics.IngestionWriteTotal);
Assert.Equal(0, verifyPayload.Metrics.AocViolationTotal);
Assert.False(verifyPayload.Truncated);
Assert.True(metrics.WaitForMeasurements(fixtures.Count, TimeSpan.FromSeconds(2)));
foreach (var measurement in metrics.GetMeasurements())
{
Assert.Equal(Tenant, measurement.Tenant);
Assert.Equal(IngestionTelemetry.ResultOk, measurement.Result);
Assert.Equal(1, measurement.Value);
}
}
private HttpClient CreateClient()
{
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "vex.admin vex.read");
client.DefaultRequestHeaders.Add("X-Stella-Tenant", Tenant);
return client;
}
public void Dispose()
{
_factory.Dispose();
_runner.Dispose();
}
private sealed class IngestionMetricListener : IDisposable
{
private readonly List<Measurement> _measurements = new();
private readonly MeterListener _listener;
public IngestionMetricListener()
{
_listener = new MeterListener
{
InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == IngestionTelemetry.MeterName &&
instrument.Name == "ingestion_write_total")
{
listener.EnableMeasurementEvents(instrument);
}
}
};
_listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
{
if (instrument.Meter.Name != IngestionTelemetry.MeterName ||
instrument.Name != "ingestion_write_total")
{
return;
}
string tenant = string.Empty;
string source = string.Empty;
string result = string.Empty;
foreach (var tag in tags)
{
switch (tag.Key)
{
case "tenant":
tenant = tag.Value?.ToString() ?? string.Empty;
break;
case "source":
source = tag.Value?.ToString() ?? string.Empty;
break;
case "result":
result = tag.Value?.ToString() ?? string.Empty;
break;
}
}
lock (_measurements)
{
_measurements.Add(new Measurement(tenant, source, result, measurement));
}
});
_listener.Start();
}
public bool WaitForMeasurements(int expected, TimeSpan timeout)
{
var sw = Stopwatch.StartNew();
while (sw.Elapsed < timeout)
{
lock (_measurements)
{
if (_measurements.Count >= expected)
{
return true;
}
}
Thread.Sleep(25);
}
return false;
}
public IReadOnlyList<Measurement> GetMeasurements()
{
lock (_measurements)
{
return _measurements.ToList();
}
}
public void Dispose() => _listener.Dispose();
internal sealed record Measurement(string Tenant, string Source, string Result, long Value);
}
private sealed record VexFixture(
string Name,
VexIngestRequest Request,
string ExpectedFormat,
Action<JsonElement> ContentAssertion)
{
private string? _digest;
public string Digest => _digest ?? throw new InvalidOperationException("Digest not recorded yet.");
public void RecordDigest(string digest)
{
_digest = digest ?? throw new ArgumentNullException(nameof(digest));
}
public void AssertRecord(VexRawRecordResponse record)
{
Assert.Equal(ExpectedFormat, record.Document.Content.Format, StringComparer.OrdinalIgnoreCase);
ContentAssertion(record.Document.Content.Raw);
}
}
private static class VexFixtureLibrary
{
public static IReadOnlyList<VexFixture> CreateBatch()
=> new[]
{
CreateCycloneDxFixture("001", "sha256:batch-cdx-001", "CDX-BATCH-001", "not_affected"),
CreateCycloneDxFixture("002", "sha256:batch-cdx-002", "CDX-BATCH-002", "affected"),
CreateCycloneDxFixture("003", "sha256:batch-cdx-003", "CDX-BATCH-003", "fixed"),
CreateCsafFixture("010", "sha256:batch-csaf-001", "CSAF-BATCH-001", "fixed"),
CreateCsafFixture("011", "sha256:batch-csaf-002", "CSAF-BATCH-002", "known_affected"),
CreateCsafFixture("012", "sha256:batch-csaf-003", "CSAF-BATCH-003", "known_not_affected"),
CreateOpenVexFixture("020", "sha256:batch-openvex-001", "OVX-BATCH-001", "affected"),
CreateOpenVexFixture("021", "sha256:batch-openvex-002", "OVX-BATCH-002", "not_affected"),
CreateOpenVexFixture("022", "sha256:batch-openvex-003", "OVX-BATCH-003", "fixed")
};
private static VexFixture CreateCycloneDxFixture(string suffix, string digest, string upstreamId, string state)
{
var vulnerabilityId = $"CVE-2025-{suffix}";
var raw = $$"""
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"version": 1,
"metadata": {
"timestamp": "2025-11-08T00:00:00Z",
"tools": [
{ "vendor": "stellaops", "name": "batch-cdx" }
]
},
"vulnerabilities": [
{
"id": "{{vulnerabilityId}}",
"analysis": { "state": "{{state}}" },
"ratings": [
{ "score": 0.0, "method": "cvssv3" }
]
}
]
}
""";
return new VexFixture(
$"cyclonedx-{suffix}",
BuildRequest(
providerId: $"cyclonedx:batch:{suffix}",
vendor: "vendor:cyclonedx",
connector: "cdx-batch",
stream: "cyclonedx-vex",
format: "cyclonedx",
specVersion: "1.6",
rawJson: raw,
digest: digest,
upstreamId: upstreamId,
sourceUri: $"https://example.test/vex/cyclonedx/{suffix}"),
"cyclonedx",
element =>
{
var actual = element
.GetProperty("vulnerabilities")[0]
.GetProperty("analysis")
.GetProperty("state")
.GetString();
Assert.Equal(state, actual);
});
}
private static VexFixture CreateCsafFixture(string suffix, string digest, string upstreamId, string statusKey)
{
var cve = $"CVE-2025-{suffix}";
var productId = $"csaf-prod-{suffix}";
var raw = $$"""
{
"document": {
"category": "csaf_vex",
"title": "Sample CSAF VEX",
"tracking": {
"id": "CSAF-2025-{{suffix}}",
"version": "1",
"current_release_date": "2025-11-07T00:00:00Z",
"initial_release_date": "2025-11-07T00:00:00Z",
"status": "final"
}
},
"product_tree": {
"branches": [
{
"name": "products",
"product": {
"name": "sample-product-{{suffix}}",
"product_id": "{{productId}}"
}
}
]
},
"vulnerabilities": [
{
"cve": "{{cve}}",
"product_status": {
"{{statusKey}}": [ "{{productId}}" ]
},
"threats": [
{ "category": "impact", "details": "none" }
]
}
]
}
""";
return new VexFixture(
$"csaf-{suffix}",
BuildRequest(
providerId: $"csaf:batch:{suffix}",
vendor: "vendor:csaf",
connector: "csaf-batch",
stream: "csaf-vex",
format: "csaf",
specVersion: "2.0",
rawJson: raw,
digest: digest,
upstreamId: upstreamId,
sourceUri: $"https://example.test/vex/csaf/{suffix}"),
"csaf",
element =>
{
var productStatus = element
.GetProperty("vulnerabilities")[0]
.GetProperty("product_status")
.GetProperty(statusKey)
.EnumerateArray()
.First()
.GetString();
Assert.Equal(productId, productStatus);
});
}
private static VexFixture CreateOpenVexFixture(string suffix, string digest, string upstreamId, string status)
{
var raw = $$"""
{
"context": "https://openvex.dev/ns/v0.2.0",
"statements": [
{
"vulnerability": "CVE-2025-{{suffix}}",
"products": [
"pkg:docker/demo@sha256:{{digest}}"
],
"status": "{{status}}",
"statusNotes": "waiting on vendor patch"
}
]
}
""";
return new VexFixture(
$"openvex-{suffix}",
BuildRequest(
providerId: $"openvex:batch:{suffix}",
vendor: "vendor:openvex",
connector: "openvex-batch",
stream: "openvex",
format: "openvex",
specVersion: "0.2.0",
rawJson: raw,
digest: digest,
upstreamId: upstreamId,
sourceUri: $"https://example.test/vex/openvex/{suffix}"),
"openvex",
element =>
{
var actual = element
.GetProperty("statements")[0]
.GetProperty("status")
.GetString();
Assert.Equal(status, actual);
});
}
private static VexIngestRequest BuildRequest(
string providerId,
string vendor,
string connector,
string stream,
string format,
string specVersion,
string rawJson,
string digest,
string upstreamId,
string sourceUri)
{
using var rawDocument = JsonDocument.Parse(rawJson);
var metadata = new Dictionary<string, string>
{
["source.vendor"] = vendor,
["source.connector"] = connector,
["source.stream"] = stream,
["source.connector_version"] = "1.0.0"
};
return new VexIngestRequest(
providerId,
new VexIngestSourceRequest(vendor, connector, "1.0.0", stream),
new VexIngestUpstreamRequest(
sourceUri,
upstreamId,
"1",
DateTimeOffset.UtcNow,
digest,
new VexIngestSignatureRequest(false, null, null, null, null, null),
new Dictionary<string, string>()),
new VexIngestContentRequest(format, specVersion, rawDocument.RootElement.Clone(), null),
metadata);
}
}
}

View File

@@ -0,0 +1,208 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using EphemeralMongo;
using MongoDB.Bson;
using MongoDB.Driver;
using Xunit;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Services;
namespace StellaOps.Excititor.WebService.Tests;
public sealed class ObservabilityEndpointTests : IDisposable
{
private readonly TestWebApplicationFactory _factory;
private readonly IMongoRunner _runner;
public ObservabilityEndpointTests()
{
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
_factory = new TestWebApplicationFactory(
configureConfiguration: configuration =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
["Excititor:Storage:Mongo:DatabaseName"] = "excititor_obs_tests",
["Excititor:Storage:Mongo:RawBucketName"] = "vex.raw",
["Excititor:Observability:IngestWarningThreshold"] = "00:10:00",
["Excititor:Observability:IngestCriticalThreshold"] = "00:30:00",
["Excititor:Observability:SignatureWindow"] = "00:30:00",
["Excititor:Observability:ConflictTrendWindow"] = "01:00:00",
["Excititor:Observability:ConflictTrendBucketMinutes"] = "5"
});
},
configureServices: services =>
{
TestServiceOverrides.Apply(services);
services.AddTestAuthentication();
services.RemoveAll<IVexConnectorStateRepository>();
services.AddScoped<IVexConnectorStateRepository, MongoVexConnectorStateRepository>();
services.AddSingleton<IVexConnector>(_ => new StubConnector("excititor:redhat", VexProviderKind.Distro));
});
SeedDatabase();
}
[Fact]
public async Task HealthEndpoint_ReturnsAggregatedMetrics()
{
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "vex.admin");
using var response = await client.GetAsync("/obs/excititor/health");
var payload = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, payload);
using var document = JsonDocument.Parse(payload);
var root = document.RootElement;
var ingest = root.GetProperty("ingest");
Assert.Equal("healthy", ingest.GetProperty("status").GetString());
var connectors = ingest.GetProperty("connectors");
Assert.Equal(1, connectors.GetArrayLength());
Assert.Equal("excititor:redhat", connectors[0].GetProperty("connectorId").GetString());
var signature = root.GetProperty("signature");
Assert.Equal(3, signature.GetProperty("documentsEvaluated").GetInt32());
Assert.Equal(1, signature.GetProperty("failures").GetInt32());
Assert.Equal(1, signature.GetProperty("verified").GetInt32());
var conflicts = root.GetProperty("conflicts");
Assert.True(conflicts.GetProperty("conflictStatements").GetInt64() >= 2);
Assert.True(conflicts.GetProperty("trend").GetArrayLength() >= 1);
}
private void SeedDatabase()
{
using var scope = _factory.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<IMongoDatabase>();
database.DropCollection(VexMongoCollectionNames.Raw);
database.DropCollection(VexMongoCollectionNames.Consensus);
database.DropCollection(VexMongoCollectionNames.ConnectorState);
var now = DateTime.UtcNow;
var rawCollection = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Raw);
rawCollection.InsertMany(new[]
{
new BsonDocument
{
{ "Id", "raw-1" },
{ "ProviderId", "excititor:redhat" },
{ ObservabilityEndpointTestsHelper.RetrievedAtField, now },
{ ObservabilityEndpointTestsHelper.MetadataField, new BsonDocument { { "signature.present", "true" }, { "signature.verified", "true" } } }
},
new BsonDocument
{
{ "Id", "raw-2" },
{ "ProviderId", "excititor:redhat" },
{ ObservabilityEndpointTestsHelper.RetrievedAtField, now },
{ ObservabilityEndpointTestsHelper.MetadataField, new BsonDocument { { "signature.present", "true" } } }
},
new BsonDocument
{
{ "Id", "raw-3" },
{ "ProviderId", "excititor:redhat" },
{ ObservabilityEndpointTestsHelper.RetrievedAtField, now },
{ ObservabilityEndpointTestsHelper.MetadataField, new BsonDocument() }
}
});
var consensus = database.GetCollection<BsonDocument>(VexMongoCollectionNames.Consensus);
consensus.InsertMany(new[]
{
ObservabilityEndpointTestsHelper.CreateConsensusDocument("c1", now, "affected"),
ObservabilityEndpointTestsHelper.CreateConsensusDocument("c2", now.AddMinutes(-5), "not_affected")
});
var stateRepository = scope.ServiceProvider.GetRequiredService<IVexConnectorStateRepository>();
var state = new VexConnectorState(
"excititor:redhat",
now.AddMinutes(-5),
ImmutableArray<string>.Empty,
ImmutableDictionary<string, string>.Empty,
now.AddMinutes(-5),
0,
now.AddMinutes(10),
null);
stateRepository.SaveAsync(state, CancellationToken.None).GetAwaiter().GetResult();
}
public void Dispose()
{
_factory.Dispose();
_runner.Dispose();
}
private sealed class StubConnector : IVexConnector
{
public StubConnector(string id, VexProviderKind kind)
{
Id = id;
Kind = kind;
}
public string Id { get; }
public VexProviderKind Kind { get; }
public ValueTask ValidateAsync(VexConnectorSettings settings, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
public IAsyncEnumerable<VexRawDocument> FetchAsync(VexConnectorContext context, CancellationToken cancellationToken)
=> AsyncEnumerable.Empty<VexRawDocument>();
public ValueTask<VexClaimBatch> NormalizeAsync(VexRawDocument document, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexClaimBatch(
document,
ImmutableArray<VexClaim>.Empty,
ImmutableDictionary<string, string>.Empty));
}
}
internal static class ObservabilityEndpointTestsHelper
{
public const string RetrievedAtField = "RetrievedAt";
public const string MetadataField = "Metadata";
public static BsonDocument CreateConsensusDocument(string id, DateTime timestamp, string conflictStatus)
{
var conflicts = new BsonArray
{
new BsonDocument
{
{ "ProviderId", "excititor:redhat" },
{ "Status", conflictStatus },
{ "DocumentDigest", Guid.NewGuid().ToString("n") }
}
};
return new BsonDocument
{
{ "Id", id },
{ "VulnerabilityId", $"CVE-{id}" },
{ "Product", new BsonDocument { { "Key", $"pkg:{id}" }, { "Name", $"pkg-{id}" } } },
{ "Status", "affected" },
{ "CalculatedAt", timestamp },
{ "Conflicts", conflicts }
};
}
}

View File

@@ -19,8 +19,9 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Excititor.WebService/StellaOps.Excititor.WebService.csproj" />
<ProjectReference Include="../../../Aoc/__Libraries/StellaOps.Aoc/StellaOps.Aoc.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
</Project>

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
@@ -9,11 +10,12 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Attestation.Verification;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Services;
using MongoDB.Driver;
using StellaOps.Excititor.Attestation.Dsse;
using StellaOps.Excititor.Attestation.Dsse;
using StellaOps.Excititor.Attestation.Signing;
namespace StellaOps.Excititor.WebService.Tests;
@@ -24,23 +26,25 @@ internal static class TestServiceOverrides
services.RemoveAll<IVexConnector>();
services.RemoveAll<IVexIngestOrchestrator>();
services.RemoveAll<IVexConnectorStateRepository>();
services.RemoveAll<IVexExportCacheService>();
services.RemoveAll<IVexExportDataSource>();
services.RemoveAll<IVexExportStore>();
services.RemoveAll<IVexCacheIndex>();
services.RemoveAll<IVexCacheMaintenance>();
services.RemoveAll<IVexAttestationClient>();
services.AddSingleton<IVexIngestOrchestrator, StubIngestOrchestrator>();
services.AddSingleton<IVexConnectorStateRepository, StubConnectorStateRepository>();
services.AddSingleton<IVexExportCacheService, StubExportCacheService>();
services.RemoveAll<IVexExportCacheService>();
services.RemoveAll<IVexExportDataSource>();
services.RemoveAll<IVexExportStore>();
services.RemoveAll<IVexCacheIndex>();
services.RemoveAll<IVexCacheMaintenance>();
services.RemoveAll<IVexAttestationClient>();
services.RemoveAll<IVexSigner>();
services.AddSingleton<IVexIngestOrchestrator, StubIngestOrchestrator>();
services.AddSingleton<IVexConnectorStateRepository, StubConnectorStateRepository>();
services.AddSingleton<IVexExportCacheService, StubExportCacheService>();
services.RemoveAll<IExportEngine>();
services.AddSingleton<IExportEngine, StubExportEngine>();
services.AddSingleton<IVexExportDataSource, StubExportDataSource>();
services.AddSingleton<IVexExportStore, StubExportStore>();
services.AddSingleton<IVexCacheIndex, StubCacheIndex>();
services.AddSingleton<IVexCacheMaintenance, StubCacheMaintenance>();
services.AddSingleton<IVexAttestationClient, StubAttestationClient>();
services.AddSingleton<IVexExportStore, StubExportStore>();
services.AddSingleton<IVexCacheIndex, StubCacheIndex>();
services.AddSingleton<IVexCacheMaintenance, StubCacheMaintenance>();
services.AddSingleton<IVexAttestationClient, StubAttestationClient>();
services.AddSingleton<IVexSigner, StubSigner>();
services.RemoveAll<IHostedService>();
services.AddSingleton<IHostedService, NoopHostedService>();
@@ -135,8 +139,8 @@ internal static class TestServiceOverrides
=> ValueTask.FromResult(0);
}
private sealed class StubAttestationClient : IVexAttestationClient
{
private sealed class StubAttestationClient : IVexAttestationClient
{
public ValueTask<VexAttestationResponse> SignAsync(VexAttestationRequest request, CancellationToken cancellationToken)
{
var envelope = new DsseEnvelope(
@@ -168,22 +172,34 @@ internal static class TestServiceOverrides
}
}
private sealed class StubConnectorStateRepository : IVexConnectorStateRepository
{
private readonly ConcurrentDictionary<string, VexConnectorState> _states = new(StringComparer.Ordinal);
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
private sealed class StubConnectorStateRepository : IVexConnectorStateRepository
{
private readonly ConcurrentDictionary<string, VexConnectorState> _states = new(StringComparer.Ordinal);
public ValueTask<VexConnectorState?> GetAsync(string connectorId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_states.TryGetValue(connectorId, out var state);
return ValueTask.FromResult(state);
}
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_states[state.ConnectorId] = state;
return ValueTask.CompletedTask;
}
}
public ValueTask SaveAsync(VexConnectorState state, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
_states[state.ConnectorId] = state;
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyCollection<VexConnectorState>> ListAsync(CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
IReadOnlyCollection<VexConnectorState> snapshot = _states.Values.ToList();
return ValueTask.FromResult(snapshot);
}
}
private sealed class StubSigner : IVexSigner
{
public ValueTask<VexSignedPayload> SignAsync(ReadOnlyMemory<byte> payload, CancellationToken cancellationToken)
=> ValueTask.FromResult(new VexSignedPayload("stub-signature", "stub-key"));
}
private sealed class StubIngestOrchestrator : IVexIngestOrchestrator
{

View File

@@ -0,0 +1,203 @@
using System;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using StellaOps.Aoc;
namespace StellaOps.Excititor.WebService.Tests;
public sealed class VexGuardSchemaTests
{
private static readonly AocWriteGuard Guard = new();
[Fact]
public void CycloneDxFixture_CompliesWithGuard()
{
var result = ValidateCycloneDx();
Assert.True(result.IsValid, DescribeViolations(result));
}
[Fact]
public void CsafFixture_CompliesWithGuard()
{
var result = ValidateCsaf();
Assert.True(result.IsValid, DescribeViolations(result));
}
[Fact]
public void CycloneDxFixture_WithForbiddenField_ProducesErrAoc001()
{
var result = ValidateCycloneDx(node => node["severity"] = "critical");
AssertViolation(result, "ERR_AOC_001", "/severity");
}
[Fact]
public void CycloneDxFixture_WithDerivedField_ProducesErrAoc006()
{
var result = ValidateCycloneDx(node => node["effective_owner"] = "security");
AssertViolation(result, "ERR_AOC_006", "/effective_owner");
}
[Fact]
public void CycloneDxFixture_WithUnknownField_ProducesErrAoc007()
{
var result = ValidateCycloneDx(node => node["custom_field"] = 123);
AssertViolation(result, "ERR_AOC_007", "/custom_field");
}
[Fact]
public void CycloneDxFixture_WithSupersedes_RemainsValid()
{
var result = ValidateCycloneDx(node => node["supersedes"] = "digest:prev-cdx");
Assert.True(result.IsValid, DescribeViolations(result));
}
[Fact]
public void CsafFixture_WithSupersedes_RemainsValid()
{
var result = ValidateCsaf(node => node["supersedes"] = "digest:prev-csaf");
Assert.True(result.IsValid, DescribeViolations(result));
}
private static AocGuardResult ValidateCycloneDx(Action<JsonObject>? mutate = null)
=> ValidateFixture(CycloneDxRaw, mutate);
private static AocGuardResult ValidateCsaf(Action<JsonObject>? mutate = null)
=> ValidateFixture(CsafRaw, mutate);
private static AocGuardResult ValidateFixture(string json, Action<JsonObject>? mutate)
{
var node = JsonNode.Parse(json)!.AsObject();
mutate?.Invoke(node);
using var document = JsonDocument.Parse(node.ToJsonString());
return Guard.Validate(document.RootElement);
}
private static void AssertViolation(AocGuardResult result, string expectedCode, string expectedPath)
{
Assert.False(result.IsValid);
Assert.Contains(result.Violations, violation =>
violation.ErrorCode == expectedCode && string.Equals(violation.Path, expectedPath, StringComparison.OrdinalIgnoreCase));
}
private static string DescribeViolations(AocGuardResult result)
=> string.Join(", ", result.Violations.Select(v => $"{v.ErrorCode}:{v.Path}"));
private const string CycloneDxRaw = """
{
"tenant": "tests",
"source": {
"vendor": "cyclonedx",
"connector": "cdx",
"version": "1.0.0",
"stream": "vex-cyclonedx"
},
"upstream": {
"upstream_id": "CDX-2025-0001",
"document_version": "2025.11.08",
"retrieved_at": "2025-11-08T00:00:00Z",
"content_hash": "sha256:cdx",
"signature": { "present": false }
},
"content": {
"format": "CycloneDX",
"spec_version": "1.6",
"raw": {
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"serialNumber": "urn:uuid:12345678-1234-5678-9abc-def012345678",
"version": 1,
"metadata": {
"timestamp": "2025-11-08T00:00:00Z",
"tools": [
{ "vendor": "stellaops", "name": "sample-vex-bot" }
]
},
"vulnerabilities": [
{
"id": "CVE-2025-0001",
"analysis": { "state": "not_affected" },
"ratings": [
{ "score": 0.0, "method": "cvssv3" }
]
}
]
}
},
"linkset": {
"aliases": [],
"references": [],
"relationships": [],
"products": [],
"notes": {},
"reconciled_from": []
}
}
""";
private const string CsafRaw = """
{
"tenant": "tests",
"source": {
"vendor": "csaf",
"connector": "csaf-json",
"version": "1.2.3",
"stream": "vex-csaf"
},
"upstream": {
"upstream_id": "CSAF-2025-0002",
"document_version": "2025.11.07",
"retrieved_at": "2025-11-08T01:10:00Z",
"content_hash": "sha256:csaf",
"signature": { "present": false }
},
"content": {
"format": "CSAF",
"spec_version": "2.0",
"raw": {
"document": {
"category": "csaf_vex",
"title": "Sample CSAF VEX",
"tracking": {
"id": "CSAF-2025-0002",
"version": "1",
"current_release_date": "2025-11-07T00:00:00Z",
"initial_release_date": "2025-11-07T00:00:00Z",
"status": "final"
}
},
"product_tree": {
"branches": [
{
"name": "products",
"product": {
"name": "sample-product",
"product_id": "csaf-prod"
}
}
]
},
"vulnerabilities": [
{
"cve": "CVE-2025-0002",
"product_status": {
"fixed": [ "csaf-prod" ]
},
"threats": [
{ "category": "impact", "details": "none" }
]
}
]
}
},
"linkset": {
"aliases": [],
"references": [],
"relationships": [],
"products": [],
"notes": {},
"reconciled_from": []
}
}
""";
}

View File

@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using EphemeralMongo;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Contracts;
using Xunit;
namespace StellaOps.Excititor.WebService.Tests;
public sealed class VexRawEndpointsTests : IDisposable
{
private readonly IMongoRunner _runner;
private readonly TestWebApplicationFactory _factory;
public VexRawEndpointsTests()
{
_runner = MongoRunner.Run(new MongoRunnerOptions { UseSingleNodeReplicaSet = true });
_factory = new TestWebApplicationFactory(
configureConfiguration: configuration =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Excititor:Storage:Mongo:ConnectionString"] = _runner.ConnectionString,
["Excititor:Storage:Mongo:DatabaseName"] = "vex_raw_tests",
["Excititor:Storage:Mongo:DefaultTenant"] = "tests",
});
},
configureServices: services =>
{
TestServiceOverrides.Apply(services);
services.AddTestAuthentication();
});
}
[Fact]
public async Task IngestListGetAndVerifyFlow()
{
using var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "vex.admin vex.read");
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tests");
var ingestRequest = BuildVexIngestRequest();
var ingestResponse = await client.PostAsJsonAsync("/ingest/vex", ingestRequest);
ingestResponse.EnsureSuccessStatusCode();
var ingestPayload = await ingestResponse.Content.ReadFromJsonAsync<VexIngestResponse>();
Assert.NotNull(ingestPayload);
Assert.True(ingestPayload!.Inserted);
var getResponse = await client.GetAsync($"/vex/raw/{Uri.EscapeDataString(ingestPayload.Digest)}");
getResponse.EnsureSuccessStatusCode();
var record = await getResponse.Content.ReadFromJsonAsync<VexRawRecordResponse>();
Assert.NotNull(record);
Assert.Equal(ingestPayload.Digest, record!.Digest);
var listResponse = await client.GetAsync("/vex/raw?limit=5");
listResponse.EnsureSuccessStatusCode();
var listPayload = await listResponse.Content.ReadFromJsonAsync<VexRawListResponse>();
Assert.NotNull(listPayload);
Assert.Contains(listPayload!.Records, summary => summary.Digest == ingestPayload.Digest);
var verifyRequest = new VexAocVerifyRequest(null, null, 10, null, null);
var verifyResponse = await client.PostAsJsonAsync("/aoc/verify", verifyRequest);
verifyResponse.EnsureSuccessStatusCode();
var verifyPayload = await verifyResponse.Content.ReadFromJsonAsync<VexAocVerifyResponse>();
Assert.NotNull(verifyPayload);
Assert.True(verifyPayload!.Checked.Vex >= 1);
}
private static VexIngestRequest BuildVexIngestRequest()
{
using var contentDocument = JsonDocument.Parse("{\"vex\":\"payload\"}");
return new VexIngestRequest(
ProviderId: "excititor:test",
Source: new VexIngestSourceRequest("vendor:test", "connector:test", "1.0.0", "csaf"),
Upstream: new VexIngestUpstreamRequest(
SourceUri: "https://example.test/vex.json",
UpstreamId: "VEX-TEST-001",
DocumentVersion: "1",
RetrievedAt: DateTimeOffset.UtcNow,
ContentHash: "sha256:test",
Signature: new VexIngestSignatureRequest(false, null, null, null, null, null),
Provenance: new Dictionary<string, string>()),
Content: new VexIngestContentRequest("csaf", "2.0", contentDocument.RootElement.Clone(), null),
Metadata: new Dictionary<string, string>
{
["source.vendor"] = "vendor:test"
});
}
public void Dispose()
{
_factory.Dispose();
_runner.Dispose();
}
}