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
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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user